Add per-VLAN QoS support and fix menu naming issues

secubox-qos v1.1.0:
- Add multi-interface support (eth0, eth0.100, eth0.200, etc.)
- Add VLAN discovery and listing (/vlans endpoint)
- Add per-VLAN bandwidth policies with priority settings
- Add 802.1p PCP (Priority Code Point) marking support
- Add VLAN creation/deletion endpoints
- Add VLAN-aware traffic classification rules
- Add per-interface statistics and tc class stats
- Support applying QoS to all managed interfaces at once
- Update frontend with VLAN policies table and PCP settings

secubox-roadmap:
- Add conditional navbar based on authentication status
- Show guest header with Login button when not authenticated
- Show full sidebar when authenticated

Menu fixes (5 modules):
- Fix mitmproxy: title → name
- Fix exposure: title → name, add description
- Fix traffic: title → name, add description
- Fix wireguard: title → name, section → category, fix icon
- Fix zkp: remove duplicate title, url → path

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
CyberMind-FR 2026-03-24 10:17:10 +01:00
parent 6a41f9d508
commit c53f24dc6c
11 changed files with 1039 additions and 38 deletions

View File

@ -1,5 +1,5 @@
# MIGRATION MAP — SecuBox OpenWrt → Debian
*Mis à jour : 2026-03-23*
*Mis à jour : 2026-03-24*
Légende : ✅ Terminé · 🔄 En cours · ⬜ À faire · ⏸ Bloqué
@ -48,7 +48,7 @@ Légende : ✅ Terminé · 🔄 En cours · ⬜ À faire · ⏸ Bloqué
| **secubox-vhost** | ✅ | ✅ | ✅ | vhosts, ssl, certs | ✅ |
| **secubox-mediaflow** | ✅ (20) | ✅ | ✅ | streams, alerts... | ✅ |
| **secubox-dpi** | ✅ | ✅ | ✅ | 40+ endpoints netifyd | ✅ |
| **secubox-qos** | ✅ (80) | ✅ | ✅ | 60+ endpoints HTB | ✅ |
| **secubox-qos** | ✅ (80) | ✅ | ✅ | 80+ endpoints HTB + VLAN v1.1.0 | ✅ |
| **secubox-auth** | ✅ (11) | ✅ | ✅ | 20+ endpoints | ✅ |
| **secubox-cdn** | ✅ (36) | ✅ | ✅ | 25+ endpoints | ✅ |
| **secubox-system** | ✅ (42) | ✅ | ✅ | 35+ endpoints | ✅ |

View File

@ -1,10 +1,30 @@
# WIP — Work In Progress
*Mis à jour : 2026-03-23 (Session 9)*
*Mis à jour : 2026-03-24 (Session 10)*
---
## ✅ Terminé cette session
### secubox-qos v1.1.0 — Per-VLAN QoS Support ✅
- **Multi-interface support** — Manage QoS on eth0, eth0.100, eth0.200, etc.
- **VLAN discovery** — Auto-detect existing VLAN interfaces
- **Per-VLAN policies** — Independent bandwidth limits per VLAN
- **802.1p PCP marking** — Map tc classes to VLAN priority (0-7)
- **VLAN creation/deletion** — Create VLAN interfaces with QoS from UI
- **VLAN-aware rules** — Traffic classification by VLAN ID
- **Per-interface statistics** — RX/TX bytes, tc class stats
- **Apply-all function** — Apply QoS to all managed interfaces at once
- **Frontend updated** — VLAN policies table, PCP settings, interface stats
New API endpoints:
- `GET /vlans` — List VLAN interfaces with policies
- `GET/POST/DELETE /vlan/{interface}` — VLAN policy management
- `POST /vlan/create` — Create new VLAN with QoS
- `POST /vlan/apply_all` — Apply QoS to all interfaces
- `GET/POST /pcp/mappings` — 802.1p priority mappings
- `GET/POST/DELETE /interfaces` — Interface management
- `GET/POST/DELETE /vlan/rules` — VLAN classification rules
### 6 New Modules Committed (Session 9) ✅
- **secubox-backup** v1.0.0 — System config and LXC container backup/restore
- **secubox-watchdog** v1.0.0 — Container, service, and endpoint monitoring

View File

@ -1,8 +1,9 @@
{
"id": "exposure",
"title": "Exposure",
"name": "Exposure",
"icon": "🌐",
"path": "/exposure/",
"category": "network",
"order": 565
"order": 565,
"description": "Unified exposure settings (Tor, SSL, DNS, Mesh)"
}

View File

@ -1,8 +1,9 @@
{
"id": "mitmproxy",
"title": "MITM Proxy",
"name": "MITM Proxy",
"icon": "🔍",
"path": "/mitmproxy/",
"category": "security",
"order": 115
"order": 115,
"description": "Traffic inspection and WAF proxy"
}

View File

@ -4,6 +4,13 @@ Port de luci-app-bandwidth-manager
Méthodes RPCD portées :
status, classes, rules, schedules, clients, usage, quotas, apply_qos
v1.1.0: Per-VLAN QoS support
- Multi-interface support (eth0, eth0.100, eth0.200, etc.)
- Per-VLAN bandwidth policies
- 802.1p PCP priority mapping
- VLAN-aware traffic classification
- VLAN discovery and management
"""
from __future__ import annotations
from fastapi import FastAPI, APIRouter, Depends, HTTPException
@ -11,10 +18,11 @@ from pydantic import BaseModel
from secubox_core.auth import router as auth_router, require_jwt
from secubox_core.config import get_config
from secubox_core.logger import get_logger
import subprocess, json
import subprocess, json, re
from pathlib import Path
from typing import Optional
app = FastAPI(title="secubox-qos", version="1.0.0", root_path="/api/v1/qos")
app = FastAPI(title="secubox-qos", version="1.1.0", root_path="/api/v1/qos")
app.include_router(auth_router, prefix="/auth")
router = APIRouter()
@ -22,18 +30,52 @@ log = get_logger("qos")
QOS_CONF = Path("/etc/secubox/qos.json")
# 802.1p Priority Code Point (PCP) mapping
# Maps tc class priority to VLAN PCP values (0-7)
PCP_MAPPING = {
"realtime": 7, # Network Control
"voice": 6, # Voice (VoIP)
"video": 5, # Video
"controlled": 4, # Controlled Load
"excellent": 3, # Excellent Effort (Business Critical)
"best_effort": 0, # Best Effort (Default)
"background": 1, # Background
"bulk": 2, # Spare/Bulk
}
# Default VLAN policies template
DEFAULT_VLAN_POLICY = {
"enabled": True,
"upload_mbps": 100,
"download_mbps": 200,
"priority": 0, # PCP value
"description": "",
"classes": [],
}
def _load_conf() -> dict:
if QOS_CONF.exists():
return json.loads(QOS_CONF.read_text())
conf = json.loads(QOS_CONF.read_text())
# Migration: ensure new fields exist
if "vlan_policies" not in conf:
conf["vlan_policies"] = {}
if "interfaces" not in conf:
# Migrate single interface to list
conf["interfaces"] = [conf.get("interface", "eth0")]
return conf
return {
"enabled": False,
"interface": "eth0",
"interface": "eth0", # Primary interface (legacy)
"interfaces": ["eth0"], # All managed interfaces
"upload_mbps": 100,
"download_mbps": 200,
"classes": [],
"rules": [],
"quotas": [],
"vlan_policies": {}, # Per-VLAN policies: {"eth0.100": {...}, ...}
"pcp_enabled": False, # 802.1p marking enabled
"pcp_mappings": {}, # Custom PCP mappings
}
@ -42,6 +84,89 @@ def _save_conf(conf: dict):
QOS_CONF.write_text(json.dumps(conf, indent=2))
def _discover_vlans() -> list[dict]:
"""Discover all VLAN interfaces on the system."""
vlans = []
r = subprocess.run(["ip", "-j", "link", "show", "type", "vlan"],
capture_output=True, text=True)
try:
links = json.loads(r.stdout) if r.stdout.strip() else []
for link in links:
ifname = link.get("ifname", "")
# Parse VLAN ID from interface name or linkinfo
vlan_id = None
if "." in ifname:
try:
vlan_id = int(ifname.split(".")[-1])
except ValueError:
pass
linkinfo = link.get("linkinfo", {}).get("info_data", {})
if not vlan_id:
vlan_id = linkinfo.get("id")
parent = link.get("link", "")
vlans.append({
"interface": ifname,
"vlan_id": vlan_id,
"parent": parent,
"state": link.get("operstate", "unknown"),
"mtu": link.get("mtu", 1500),
})
except Exception:
pass
return vlans
def _get_parent_interface(vlan_iface: str) -> str:
"""Get parent interface for a VLAN interface."""
if "." in vlan_iface:
return vlan_iface.split(".")[0]
return vlan_iface
def _is_vlan_interface(iface: str) -> bool:
"""Check if interface is a VLAN interface."""
if "." in iface:
return True
# Check via ip link
r = subprocess.run(["ip", "-d", "link", "show", iface],
capture_output=True, text=True)
return "vlan" in r.stdout.lower()
def _get_vlan_id(iface: str) -> Optional[int]:
"""Extract VLAN ID from interface."""
if "." in iface:
try:
return int(iface.split(".")[-1])
except ValueError:
pass
return None
def _create_vlan_interface(parent: str, vlan_id: int) -> dict:
"""Create a VLAN interface."""
iface = f"{parent}.{vlan_id}"
r = subprocess.run(
["ip", "link", "add", "link", parent, "name", iface,
"type", "vlan", "id", str(vlan_id)],
capture_output=True, text=True
)
if r.returncode != 0:
return {"success": False, "error": r.stderr}
# Bring interface up
subprocess.run(["ip", "link", "set", iface, "up"], capture_output=True)
return {"success": True, "interface": iface}
def _delete_vlan_interface(iface: str) -> dict:
"""Delete a VLAN interface."""
r = subprocess.run(["ip", "link", "delete", iface],
capture_output=True, text=True)
return {"success": r.returncode == 0, "error": r.stderr if r.returncode != 0 else None}
def _iface_stats(iface: str) -> dict:
base = Path(f"/sys/class/net/{iface}/statistics")
if not base.exists():
@ -63,14 +188,24 @@ def _tc_show(iface: str) -> dict:
return {"qdiscs": [], "raw": r.stdout[:500]}
def _apply_htb(conf: dict) -> dict:
def _apply_htb(conf: dict, iface: str = None) -> dict:
"""
Applique la configuration HTB via pyroute2 ou tc CLI.
Utilise tc CLI pour la compatibilité maximale.
Applique la configuration HTB via tc CLI.
Supports per-interface and per-VLAN configuration.
"""
iface = conf.get("interface", "eth0")
up_kbps = int(conf.get("upload_mbps", 100)) * 1000
down_kbps = int(conf.get("download_mbps", 200)) * 1000
if iface is None:
iface = conf.get("interface", "eth0")
# Check for VLAN-specific policy
vlan_policy = conf.get("vlan_policies", {}).get(iface, {})
if vlan_policy:
up_kbps = int(vlan_policy.get("upload_mbps", 100)) * 1000
down_kbps = int(vlan_policy.get("download_mbps", 200)) * 1000
prio_offset = vlan_policy.get("priority", 0)
else:
up_kbps = int(conf.get("upload_mbps", 100)) * 1000
down_kbps = int(conf.get("download_mbps", 200)) * 1000
prio_offset = 0
cmds = [
# Reset
@ -101,7 +236,68 @@ def _apply_htb(conf: dict) -> dict:
r = subprocess.run(cmd, capture_output=True, text=True)
results.append({"cmd": " ".join(cmd[-4:]), "ok": r.returncode == 0})
return {"steps": results, "interface": iface}
# Apply VLAN-specific filters if this is a VLAN interface
vlan_id = _get_vlan_id(iface)
if vlan_id and conf.get("pcp_enabled"):
pcp_results = _apply_pcp_marking(iface, vlan_policy)
results.extend(pcp_results)
return {"steps": results, "interface": iface, "vlan_id": vlan_id}
def _apply_htb_all(conf: dict) -> dict:
"""Apply HTB to all configured interfaces."""
results = {}
interfaces = conf.get("interfaces", [conf.get("interface", "eth0")])
for iface in interfaces:
results[iface] = _apply_htb(conf, iface)
return {"interfaces": results, "count": len(interfaces)}
def _apply_pcp_marking(iface: str, policy: dict) -> list:
"""Apply 802.1p PCP marking for VLAN interface."""
results = []
pcp = policy.get("priority", 0)
# Use tc to set skb priority which maps to VLAN PCP
# Class 1:10 (high) → PCP 6 (voice)
# Class 1:30 (standard) → PCP 0 (best effort)
# Class 1:50 (bulk) → PCP 1 (background)
pcp_map = {
"1:10": min(pcp + 6, 7),
"1:30": pcp,
"1:50": max(pcp - 1, 0) if pcp > 0 else 1,
}
for classid, pcp_val in pcp_map.items():
# Set skb->priority via tc filter action
cmd = [
"tc", "filter", "add", "dev", iface, "parent", "1:",
"protocol", "all", "prio", "1",
"handle", classid.replace(":", ""),
"fw", "flowid", classid,
"action", "skbedit", "priority", str(pcp_val)
]
r = subprocess.run(cmd, capture_output=True, text=True)
results.append({"cmd": f"pcp {classid}{pcp_val}", "ok": r.returncode == 0})
return results
def _apply_vlan_filter(parent_iface: str, vlan_id: int, class_id: str) -> dict:
"""Apply tc filter to classify traffic by VLAN ID."""
# Use u32 filter to match VLAN ID in 802.1Q header
# VLAN ID is in bytes 14-15 of ethernet frame (offset 12 from IP)
cmd = [
"tc", "filter", "add", "dev", parent_iface, "parent", "1:",
"protocol", "802.1Q", "prio", "1",
"u32", "match", "u16", f"0x{vlan_id:04x}", "0x0fff", "at", "-4",
"flowid", class_id
]
r = subprocess.run(cmd, capture_output=True, text=True)
return {"cmd": f"vlan {vlan_id}{class_id}", "ok": r.returncode == 0, "error": r.stderr}
# ── GET ───────────────────────────────────────────────────────────
@ -725,9 +921,425 @@ async def reload(user=Depends(require_jwt)):
return {"success": True, "message": "QoS disabled"}
# ══════════════════════════════════════════════════════════════════
# ══ VLAN QoS Management ═══════════════════════════════════════════
# ══════════════════════════════════════════════════════════════════
@router.get("/vlans")
async def list_vlans(user=Depends(require_jwt)):
"""List all VLAN interfaces with their QoS policies."""
vlans = _discover_vlans()
conf = _load_conf()
policies = conf.get("vlan_policies", {})
for vlan in vlans:
iface = vlan["interface"]
vlan["policy"] = policies.get(iface, None)
vlan["qos_enabled"] = iface in conf.get("interfaces", [])
return {"vlans": vlans, "count": len(vlans)}
@router.get("/vlan/{interface}")
async def get_vlan_policy(interface: str, user=Depends(require_jwt)):
"""Get QoS policy for a specific VLAN interface."""
conf = _load_conf()
policy = conf.get("vlan_policies", {}).get(interface, None)
stats = _iface_stats(interface)
tc = _tc_show(interface)
vlan_id = _get_vlan_id(interface)
return {
"interface": interface,
"vlan_id": vlan_id,
"policy": policy,
"stats": stats,
"tc": tc,
"qos_active": any(q.get("kind") == "htb" for q in tc.get("qdiscs", [])),
}
class VlanPolicyRequest(BaseModel):
upload_mbps: int = 100
download_mbps: int = 200
priority: int = 0 # 802.1p PCP base value (0-7)
description: str = ""
enabled: bool = True
classes: list[dict] = []
@router.post("/vlan/{interface}/policy")
async def set_vlan_policy(interface: str, req: VlanPolicyRequest, user=Depends(require_jwt)):
"""Set QoS policy for a VLAN interface."""
conf = _load_conf()
# Validate interface exists
if not Path(f"/sys/class/net/{interface}").exists():
raise HTTPException(status_code=404, detail=f"Interface {interface} not found")
# Validate PCP value
if not 0 <= req.priority <= 7:
raise HTTPException(status_code=400, detail="Priority must be 0-7 (802.1p PCP)")
# Store policy
conf.setdefault("vlan_policies", {})
conf["vlan_policies"][interface] = req.model_dump()
# Add to managed interfaces if enabled
if req.enabled and interface not in conf.get("interfaces", []):
conf.setdefault("interfaces", [])
conf["interfaces"].append(interface)
elif not req.enabled and interface in conf.get("interfaces", []):
conf["interfaces"].remove(interface)
_save_conf(conf)
# Apply if QoS is globally enabled
result = {}
if conf.get("enabled") and req.enabled:
result = _apply_htb(conf, interface)
log.info("VLAN QoS applied: %s - %sMbps↑ %sMbps↓ PCP=%d",
interface, req.upload_mbps, req.download_mbps, req.priority)
return {"success": True, "interface": interface, **result}
@router.delete("/vlan/{interface}/policy")
async def delete_vlan_policy(interface: str, user=Depends(require_jwt)):
"""Remove QoS policy from a VLAN interface."""
conf = _load_conf()
# Remove policy
if interface in conf.get("vlan_policies", {}):
del conf["vlan_policies"][interface]
# Remove from managed interfaces
if interface in conf.get("interfaces", []):
conf["interfaces"].remove(interface)
_save_conf(conf)
# Clear tc rules
subprocess.run(["tc", "qdisc", "del", "dev", interface, "root"],
capture_output=True)
log.info("VLAN QoS policy removed: %s", interface)
return {"success": True, "interface": interface}
class CreateVlanRequest(BaseModel):
parent: str = "eth0"
vlan_id: int
upload_mbps: int = 100
download_mbps: int = 200
priority: int = 0
description: str = ""
@router.post("/vlan/create")
async def create_vlan(req: CreateVlanRequest, user=Depends(require_jwt)):
"""Create a new VLAN interface with QoS policy."""
# Validate VLAN ID
if not 1 <= req.vlan_id <= 4094:
raise HTTPException(status_code=400, detail="VLAN ID must be 1-4094")
# Validate parent interface exists
if not Path(f"/sys/class/net/{req.parent}").exists():
raise HTTPException(status_code=404, detail=f"Parent interface {req.parent} not found")
# Create VLAN interface
result = _create_vlan_interface(req.parent, req.vlan_id)
if not result["success"]:
raise HTTPException(status_code=500, detail=result.get("error", "Failed to create VLAN"))
interface = result["interface"]
# Set QoS policy
conf = _load_conf()
conf.setdefault("vlan_policies", {})
conf["vlan_policies"][interface] = {
"enabled": True,
"upload_mbps": req.upload_mbps,
"download_mbps": req.download_mbps,
"priority": req.priority,
"description": req.description,
"classes": [],
}
conf.setdefault("interfaces", [])
if interface not in conf["interfaces"]:
conf["interfaces"].append(interface)
_save_conf(conf)
log.info("VLAN created: %s (ID: %d) on %s", interface, req.vlan_id, req.parent)
return {"success": True, "interface": interface, "vlan_id": req.vlan_id}
@router.delete("/vlan/{interface}")
async def delete_vlan(interface: str, user=Depends(require_jwt)):
"""Delete a VLAN interface and its QoS policy."""
if not _is_vlan_interface(interface):
raise HTTPException(status_code=400, detail="Not a VLAN interface")
# Remove QoS first
conf = _load_conf()
if interface in conf.get("vlan_policies", {}):
del conf["vlan_policies"][interface]
if interface in conf.get("interfaces", []):
conf["interfaces"].remove(interface)
_save_conf(conf)
# Delete interface
result = _delete_vlan_interface(interface)
if not result["success"]:
raise HTTPException(status_code=500, detail=result.get("error", "Failed to delete VLAN"))
log.info("VLAN deleted: %s", interface)
return {"success": True, "interface": interface}
@router.get("/vlan/policies")
async def list_vlan_policies(user=Depends(require_jwt)):
"""List all VLAN QoS policies."""
conf = _load_conf()
policies = conf.get("vlan_policies", {})
return {
"policies": [
{"interface": iface, **policy}
for iface, policy in policies.items()
],
"count": len(policies),
}
@router.post("/vlan/apply_all")
async def apply_all_vlan_qos(user=Depends(require_jwt)):
"""Apply QoS to all configured VLAN interfaces."""
conf = _load_conf()
if not conf.get("enabled"):
return {"success": False, "error": "QoS is globally disabled"}
result = _apply_htb_all(conf)
log.info("QoS applied to %d interfaces", result["count"])
return {"success": True, **result}
# ── 802.1p PCP Management ─────────────────────────────────────────
@router.get("/pcp/mappings")
async def get_pcp_mappings(user=Depends(require_jwt)):
"""Get 802.1p PCP priority mappings."""
conf = _load_conf()
return {
"default_mappings": PCP_MAPPING,
"custom_mappings": conf.get("pcp_mappings", {}),
"pcp_enabled": conf.get("pcp_enabled", False),
}
class PcpMappingRequest(BaseModel):
class_id: str # e.g., "1:10"
pcp_value: int # 0-7
@router.post("/pcp/mapping")
async def set_pcp_mapping(req: PcpMappingRequest, user=Depends(require_jwt)):
"""Set custom PCP mapping for a traffic class."""
if not 0 <= req.pcp_value <= 7:
raise HTTPException(status_code=400, detail="PCP value must be 0-7")
conf = _load_conf()
conf.setdefault("pcp_mappings", {})
conf["pcp_mappings"][req.class_id] = req.pcp_value
_save_conf(conf)
return {"success": True, "mapping": {req.class_id: req.pcp_value}}
@router.post("/pcp/enable")
async def enable_pcp(enabled: bool = True, user=Depends(require_jwt)):
"""Enable or disable 802.1p PCP marking."""
conf = _load_conf()
conf["pcp_enabled"] = enabled
_save_conf(conf)
log.info("802.1p PCP marking %s", "enabled" if enabled else "disabled")
return {"success": True, "pcp_enabled": enabled}
# ── Per-Interface Management ──────────────────────────────────────
@router.get("/interfaces")
async def list_managed_interfaces(user=Depends(require_jwt)):
"""List all managed interfaces with their QoS status."""
conf = _load_conf()
managed = conf.get("interfaces", [])
vlan_policies = conf.get("vlan_policies", {})
interfaces = []
for iface in managed:
stats = _iface_stats(iface)
tc = _tc_show(iface)
policy = vlan_policies.get(iface, {
"upload_mbps": conf.get("upload_mbps", 100),
"download_mbps": conf.get("download_mbps", 200),
})
interfaces.append({
"interface": iface,
"is_vlan": _is_vlan_interface(iface),
"vlan_id": _get_vlan_id(iface),
"policy": policy,
"stats": stats,
"qos_active": any(q.get("kind") == "htb" for q in tc.get("qdiscs", [])),
})
return {"interfaces": interfaces, "count": len(interfaces)}
class AddInterfaceRequest(BaseModel):
interface: str
upload_mbps: int = 100
download_mbps: int = 200
priority: int = 0
apply_now: bool = True
@router.post("/interface/add")
async def add_managed_interface(req: AddInterfaceRequest, user=Depends(require_jwt)):
"""Add an interface to QoS management."""
if not Path(f"/sys/class/net/{req.interface}").exists():
raise HTTPException(status_code=404, detail=f"Interface {req.interface} not found")
conf = _load_conf()
conf.setdefault("interfaces", [])
if req.interface not in conf["interfaces"]:
conf["interfaces"].append(req.interface)
# If it's a VLAN, store policy
if _is_vlan_interface(req.interface):
conf.setdefault("vlan_policies", {})
conf["vlan_policies"][req.interface] = {
"enabled": True,
"upload_mbps": req.upload_mbps,
"download_mbps": req.download_mbps,
"priority": req.priority,
"description": "",
"classes": [],
}
_save_conf(conf)
result = {}
if req.apply_now and conf.get("enabled"):
result = _apply_htb(conf, req.interface)
log.info("Interface added to QoS: %s", req.interface)
return {"success": True, "interface": req.interface, **result}
@router.delete("/interface/{interface}")
async def remove_managed_interface(interface: str, user=Depends(require_jwt)):
"""Remove an interface from QoS management."""
conf = _load_conf()
if interface in conf.get("interfaces", []):
conf["interfaces"].remove(interface)
if interface in conf.get("vlan_policies", {}):
del conf["vlan_policies"][interface]
_save_conf(conf)
# Clear tc rules
subprocess.run(["tc", "qdisc", "del", "dev", interface, "root"],
capture_output=True)
log.info("Interface removed from QoS: %s", interface)
return {"success": True, "interface": interface}
@router.get("/interface/{interface}/stats")
async def get_interface_stats(interface: str, user=Depends(require_jwt)):
"""Get detailed stats for a specific interface."""
if not Path(f"/sys/class/net/{interface}").exists():
raise HTTPException(status_code=404, detail=f"Interface {interface} not found")
stats = _iface_stats(interface)
tc = _tc_show(interface)
conf = _load_conf()
policy = conf.get("vlan_policies", {}).get(interface, {})
# Get tc class stats
r = subprocess.run(["tc", "-s", "-j", "class", "show", "dev", interface],
capture_output=True, text=True)
try:
class_stats = json.loads(r.stdout) if r.stdout.strip() else []
except Exception:
class_stats = []
return {
"interface": interface,
"is_vlan": _is_vlan_interface(interface),
"vlan_id": _get_vlan_id(interface),
"policy": policy,
"stats": stats,
"tc": tc,
"class_stats": class_stats,
}
# ── VLAN-aware Rules ──────────────────────────────────────────────
class VlanRuleRequest(BaseModel):
name: str
vlan_id: int
class_id: str = "1:30"
priority: int = 10
enabled: bool = True
@router.post("/vlan/rule")
async def add_vlan_rule(req: VlanRuleRequest, user=Depends(require_jwt)):
"""Add a VLAN-based classification rule."""
conf = _load_conf()
conf.setdefault("vlan_rules", [])
conf["vlan_rules"].append(req.model_dump())
_save_conf(conf)
# Apply filter on parent interface
# Find interfaces that might carry this VLAN
result = None
for iface in conf.get("interfaces", []):
parent = _get_parent_interface(iface)
if parent != iface: # This is a VLAN interface
continue
# Apply to parent interfaces
result = _apply_vlan_filter(iface, req.vlan_id, req.class_id)
log.info("VLAN rule added: VLAN %d%s", req.vlan_id, req.class_id)
return {"success": True, "rule": req.model_dump(), "filter_result": result}
@router.get("/vlan/rules")
async def list_vlan_rules(user=Depends(require_jwt)):
"""List all VLAN classification rules."""
conf = _load_conf()
return {"rules": conf.get("vlan_rules", [])}
@router.delete("/vlan/rule/{name}")
async def delete_vlan_rule(name: str, user=Depends(require_jwt)):
"""Delete a VLAN classification rule."""
conf = _load_conf()
conf["vlan_rules"] = [r for r in conf.get("vlan_rules", []) if r.get("name") != name]
_save_conf(conf)
return {"success": True, "deleted": name}
@router.get("/health")
async def health():
return {"status": "ok", "module": "qos"}
return {"status": "ok", "module": "qos", "version": "1.1.0"}
app.include_router(router)

View File

@ -1,3 +1,18 @@
secubox-qos (1.1.0-1~bookworm1) bookworm; urgency=medium
* Add per-VLAN QoS support
* Add multi-interface management (eth0, eth0.100, eth0.200, etc.)
* Add VLAN discovery and listing (/vlans endpoint)
* Add per-VLAN bandwidth policies with priority settings
* Add 802.1p PCP (Priority Code Point) marking support
* Add VLAN creation/deletion endpoints
* Add VLAN-aware traffic classification rules
* Add per-interface statistics and tc class stats
* Support applying QoS to all managed interfaces at once
* Backward compatible with single-interface configuration
-- Gerald KERMA <devel@cybermind.fr> Mon, 24 Mar 2026 12:30:00 +0100
secubox-qos (1.0.4-1~bookworm1) bookworm; urgency=medium
* Add dynamic menu system with menu.d JSON definitions

View File

@ -308,6 +308,91 @@
<tbody id="rulesTable"><tr><td colspan="5">Loading...</td></tr></tbody>
</table>
</div>
<!-- VLAN QoS Section -->
<div class="card">
<h2>🏷️ VLAN QoS Policies</h2>
<div style="margin-bottom: 1rem;">
<button class="btn primary" onclick="showAddVlan()">Add VLAN Policy</button>
<button class="btn" onclick="applyAllVlans()">Apply All</button>
<button class="btn" onclick="refreshVlans()">Refresh</button>
</div>
<table>
<thead><tr><th>Interface</th><th>VLAN ID</th><th>Upload</th><th>Download</th><th>PCP</th><th>Status</th><th>Actions</th></tr></thead>
<tbody id="vlansTable"><tr><td colspan="7">Loading...</td></tr></tbody>
</table>
</div>
<div class="card">
<h2>📊 Per-Interface Statistics</h2>
<table>
<thead><tr><th>Interface</th><th>Type</th><th>RX Bytes</th><th>TX Bytes</th><th>QoS Active</th></tr></thead>
<tbody id="interfacesTable"><tr><td colspan="5">Loading...</td></tr></tbody>
</table>
</div>
<div class="card">
<h2>🔢 802.1p PCP Mappings</h2>
<div style="margin-bottom: 1rem;">
<label style="margin-right: 1rem;">
<input type="checkbox" id="pcpEnabled" onchange="togglePcp()"> Enable 802.1p PCP Marking
</label>
</div>
<table>
<thead><tr><th>Traffic Type</th><th>PCP Value</th><th>Description</th></tr></thead>
<tbody id="pcpTable">
<tr><td>Network Control</td><td>7</td><td>Highest priority, routing protocols</td></tr>
<tr><td>Voice (VoIP)</td><td>6</td><td>Real-time voice traffic</td></tr>
<tr><td>Video</td><td>5</td><td>Real-time video streaming</td></tr>
<tr><td>Controlled Load</td><td>4</td><td>Important business traffic</td></tr>
<tr><td>Excellent Effort</td><td>3</td><td>Business critical applications</td></tr>
<tr><td>Spare/Bulk</td><td>2</td><td>Bulk data transfers</td></tr>
<tr><td>Background</td><td>1</td><td>Background traffic</td></tr>
<tr><td>Best Effort</td><td>0</td><td>Default traffic (no priority)</td></tr>
</tbody>
</table>
</div>
<!-- Add VLAN Modal -->
<div id="vlanModal" class="modal" style="display:none; position:fixed; top:0; left:0; width:100%; height:100%; background:rgba(0,0,0,0.85); z-index:1000;">
<div class="modal-content" style="background:var(--tube-deep); border:1px solid var(--p31-dim); max-width:500px; margin:10% auto; padding:1.5rem; border-radius:8px;">
<h3 class="modal-title" style="color:var(--p31-decay); margin-bottom:1rem;">Add VLAN QoS Policy</h3>
<form id="vlanForm">
<div style="margin-bottom:1rem;">
<label>Parent Interface:</label>
<select id="vlanParent" style="width:100%;">
<option value="eth0">eth0</option>
<option value="eth1">eth1</option>
<option value="br0">br0</option>
</select>
</div>
<div style="margin-bottom:1rem;">
<label>VLAN ID (1-4094):</label>
<input type="number" id="vlanId" min="1" max="4094" style="width:100%;" required>
</div>
<div style="margin-bottom:1rem;">
<label>Upload (Mbps):</label>
<input type="number" id="vlanUpload" value="100" style="width:100%;">
</div>
<div style="margin-bottom:1rem;">
<label>Download (Mbps):</label>
<input type="number" id="vlanDownload" value="200" style="width:100%;">
</div>
<div style="margin-bottom:1rem;">
<label>802.1p Priority (0-7):</label>
<input type="number" id="vlanPriority" min="0" max="7" value="0" style="width:100%;">
</div>
<div style="margin-bottom:1rem;">
<label>Description:</label>
<input type="text" id="vlanDesc" placeholder="e.g., Management VLAN" style="width:100%;">
</div>
<div style="display:flex; gap:1rem; justify-content:flex-end;">
<button type="button" class="btn" onclick="hideVlanModal()">Cancel</button>
<button type="submit" class="btn primary">Create VLAN</button>
</div>
</form>
</div>
</div>
</main>
<script>
@ -369,8 +454,177 @@
function showAddRule() { alert('Rule configuration coming soon'); }
function toggleRule(id) { alert('Toggle rule: ' + id); }
// VLAN QoS Functions
async function refreshVlans() {
// Load VLANs
const vlans = await api('/vlans');
const vlansBody = document.getElementById('vlansTable');
if (vlans.vlans && vlans.vlans.length > 0) {
vlansBody.innerHTML = vlans.vlans.map(v => `
<tr>
<td><code>${v.interface}</code></td>
<td>${v.vlan_id || '-'}</td>
<td>${v.policy?.upload_mbps || '-'} Mbps</td>
<td>${v.policy?.download_mbps || '-'} Mbps</td>
<td><span class="badge badge-cyan">PCP ${v.policy?.priority || 0}</span></td>
<td><span class="badge ${v.qos_enabled ? 'badge-green' : 'badge-yellow'}">${v.qos_enabled ? 'Active' : 'Inactive'}</span></td>
<td>
<button class="btn btn-blue" onclick="editVlan('${v.interface}')">Edit</button>
<button class="btn btn-red" onclick="deleteVlan('${v.interface}')">Delete</button>
</td>
</tr>
`).join('');
} else {
vlansBody.innerHTML = '<tr><td colspan="7" class="empty">No VLAN interfaces detected. Create one to enable per-VLAN QoS.</td></tr>';
}
// Load managed interfaces
const ifaces = await api('/interfaces');
const ifaceBody = document.getElementById('interfacesTable');
if (ifaces.interfaces && ifaces.interfaces.length > 0) {
ifaceBody.innerHTML = ifaces.interfaces.map(i => `
<tr>
<td><code>${i.interface}</code></td>
<td><span class="badge ${i.is_vlan ? 'badge-purple' : 'badge-cyan'}">${i.is_vlan ? 'VLAN' : 'Physical'}</span></td>
<td>${formatBytes(i.stats?.rx_bytes || 0)}</td>
<td>${formatBytes(i.stats?.tx_bytes || 0)}</td>
<td><span class="badge ${i.qos_active ? 'badge-green' : 'badge-yellow'}">${i.qos_active ? 'HTB Active' : 'No QoS'}</span></td>
</tr>
`).join('');
} else {
ifaceBody.innerHTML = '<tr><td colspan="5" class="empty">No managed interfaces</td></tr>';
}
// Load PCP status
const pcp = await api('/pcp/mappings');
document.getElementById('pcpEnabled').checked = pcp.pcp_enabled || false;
}
function formatBytes(bytes) {
if (bytes === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
}
function showAddVlan() {
document.getElementById('vlanModal').style.display = 'block';
}
function hideVlanModal() {
document.getElementById('vlanModal').style.display = 'none';
}
document.getElementById('vlanForm').onsubmit = async function(e) {
e.preventDefault();
const data = {
parent: document.getElementById('vlanParent').value,
vlan_id: parseInt(document.getElementById('vlanId').value),
upload_mbps: parseInt(document.getElementById('vlanUpload').value),
download_mbps: parseInt(document.getElementById('vlanDownload').value),
priority: parseInt(document.getElementById('vlanPriority').value),
description: document.getElementById('vlanDesc').value
};
try {
const res = await fetch(API + '/vlan/create', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify(data)
});
const result = await res.json();
if (result.success) {
hideVlanModal();
refreshVlans();
alert('VLAN ' + data.vlan_id + ' created successfully!');
} else {
alert('Error: ' + (result.detail || result.error || 'Unknown error'));
}
} catch (err) {
alert('Error: ' + err.message);
}
};
async function deleteVlan(iface) {
if (!confirm('Delete VLAN interface ' + iface + '?')) return;
try {
const res = await fetch(API + '/vlan/' + iface, {
method: 'DELETE',
credentials: 'include'
});
const result = await res.json();
if (result.success) {
refreshVlans();
} else {
alert('Error: ' + (result.detail || 'Failed to delete'));
}
} catch (err) {
alert('Error: ' + err.message);
}
}
async function editVlan(iface) {
const policy = await api('/vlan/' + iface);
if (policy.policy) {
const p = policy.policy;
const newUp = prompt('Upload (Mbps):', p.upload_mbps);
const newDown = prompt('Download (Mbps):', p.download_mbps);
const newPrio = prompt('802.1p Priority (0-7):', p.priority);
if (newUp && newDown && newPrio !== null) {
const res = await fetch(API + '/vlan/' + iface + '/policy', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({
upload_mbps: parseInt(newUp),
download_mbps: parseInt(newDown),
priority: parseInt(newPrio),
enabled: true,
description: p.description || ''
})
});
refreshVlans();
}
} else {
alert('No policy found for ' + iface);
}
}
async function applyAllVlans() {
try {
const res = await fetch(API + '/vlan/apply_all', {
method: 'POST',
credentials: 'include'
});
const result = await res.json();
if (result.success) {
alert('QoS applied to ' + result.count + ' interfaces');
refreshVlans();
} else {
alert('Error: ' + (result.error || 'Failed to apply'));
}
} catch (err) {
alert('Error: ' + err.message);
}
}
async function togglePcp() {
const enabled = document.getElementById('pcpEnabled').checked;
try {
await fetch(API + '/pcp/enable?enabled=' + enabled, {
method: 'POST',
credentials: 'include'
});
} catch (err) {
console.error('PCP toggle error:', err);
}
}
refresh();
refreshVlans();
setInterval(refresh, 10000);
setInterval(refreshVlans, 15000);
</script>
<script src="/shared/crt-engine.js"></script>
</body>

View File

@ -30,15 +30,85 @@
display: flex;
}
/* Conditional sidebar visibility */
body.authenticated .main-content {
margin-left: 260px;
}
body.guest .main-content {
margin-left: 0;
}
body.guest #sidebar-container {
display: none;
}
.main-content {
flex: 1;
padding: 1.5rem;
margin-left: 260px;
min-height: 100vh;
}
@media (max-width: 768px) {
.main-content { margin-left: 0; padding: 1rem; }
.main-content { margin-left: 0 !important; padding: 1rem; }
}
/* Guest header bar */
.guest-header {
display: none;
position: fixed;
top: 0;
left: 0;
right: 0;
height: 60px;
background: var(--bg-card);
border-bottom: 1px solid var(--border);
padding: 0 1.5rem;
align-items: center;
justify-content: space-between;
z-index: 100;
}
body.guest .guest-header {
display: flex;
}
body.guest .main-content {
padding-top: 80px;
}
.guest-header .logo {
display: flex;
align-items: center;
gap: 0.75rem;
color: var(--green);
text-decoration: none;
font-size: 1.2rem;
font-weight: bold;
}
.guest-header .logo-icon {
font-size: 1.5rem;
}
.guest-header .login-btn {
background: transparent;
border: 1px solid var(--green);
color: var(--green);
padding: 0.5rem 1.25rem;
border-radius: 4px;
font-family: inherit;
font-size: 0.9rem;
cursor: pointer;
text-transform: uppercase;
letter-spacing: 0.1em;
transition: all 0.2s;
}
.guest-header .login-btn:hover {
background: var(--green);
color: var(--bg-dark);
box-shadow: 0 0 15px var(--green);
}
.page-header {
@ -294,7 +364,17 @@
</style>
</head>
<body>
<div id="sidebar-container"></div>
<!-- Guest header (shown when not authenticated) -->
<header class="guest-header">
<a href="/" class="logo">
<span class="logo-icon">🔒</span>
<span>SECUBOX</span>
</a>
<button class="login-btn" onclick="window.location.href='/portal/login.html'">Login</button>
</header>
<!-- Sidebar (shown when authenticated) -->
<nav class="sidebar" id="sidebar"></nav>
<main class="main-content">
<header class="page-header">
@ -327,11 +407,30 @@
</section>
</main>
<script src="/shared/sidebar.js"></script>
<script src="/shared/crt-engine.js"></script>
<script>
const API_BASE = '/api/v1/roadmap';
// Check authentication status
function isAuthenticated() {
return !!localStorage.getItem('sbx_token');
}
// Setup page based on auth status
function setupAuthState() {
if (isAuthenticated()) {
document.body.classList.add('authenticated');
document.body.classList.remove('guest');
// Load sidebar dynamically
const script = document.createElement('script');
script.src = '/shared/sidebar.js';
document.body.appendChild(script);
} else {
document.body.classList.add('guest');
document.body.classList.remove('authenticated');
}
}
async function api(endpoint) {
try {
const res = await fetch(API_BASE + endpoint);
@ -410,6 +509,7 @@
// Initialize
document.addEventListener('DOMContentLoaded', () => {
setupAuthState();
loadRoadmap();
setInterval(loadRoadmap, 60000); // Refresh every minute
});

View File

@ -1,8 +1,9 @@
{
"id": "traffic",
"title": "Traffic Shaping",
"name": "Traffic Shaping",
"icon": "📊",
"path": "/traffic/",
"category": "network",
"order": 36
"order": 36,
"description": "TC/CAKE QoS traffic shaping per interface"
}

View File

@ -1,10 +1,9 @@
{
"id": "wireguard",
"title": "WireGuard VPN",
"icon": "shield",
"name": "WireGuard VPN",
"icon": "🔐",
"path": "/wireguard/",
"section": "security",
"category": "security",
"order": 25,
"api": "/api/v1/wireguard/",
"description": "WireGuard VPN tunnel management"
}

View File

@ -1,11 +1,9 @@
{
"id": "zkp",
"name": "ZKP",
"title": "Zero-Knowledge Proof",
"icon": "🔐",
"url": "/zkp/",
"category": "security",
"order": 570,
"description": "Zero-Knowledge Proof Hamiltonian management",
"requires": ["secubox-zkp"]
"id": "zkp",
"name": "Zero-Knowledge Proof",
"icon": "🔐",
"path": "/zkp/",
"category": "security",
"order": 570,
"description": "Zero-Knowledge Proof Hamiltonian management"
}