Systemic clobber: the scaffold boilerplate (install -d -m 750 /var/lib/secubox, /run/secubox) put restrictive modes on SHARED parents in ~56 module postinsts, reverting them to 0750 on every install/upgrade and breaking traversal for non-secubox daemons (kbin/toolbox 500). Empirically confirmed install -d -m only modes the final component, so /parent/leaf forms are harmless — only bare-parent targets were rewritten. Multi-arg lines (incl. ones making /var/lib world-writable 1777) split per-parent: /run/secubox=1777 root:root, /var/lib|cache|etc=0755 secubox:secubox; module-private leaves keep 0750. Scaffold + PATTERNS.md fixed so new packages don't reintroduce it.
24 KiB
PATTERNS — RPCD → FastAPI
Référence de portage issue du code source réel
Pattern 1 — Endpoint GET simple (status, lecture)
Source RPCD shell (luci.crowdsec-dashboard/status)
#!/bin/sh
. /usr/share/libubox/jshn.sh
status() {
json_init
local enabled=$(uci -q get crowdsec.config.enabled || echo "1")
local running=0
pgrep crowdsec >/dev/null && running=1
json_add_boolean "enabled" "$enabled"
json_add_boolean "running" "$running"
json_add_string "version" "$(crowdsec -version 2>&1 | head -1)"
json_print
}
case "$1" in
list) echo '{"status":{}}' ;;
call) case "$2" in status) status ;; esac ;;
esac
Cible FastAPI (api/routers/status.py)
from fastapi import APIRouter, Depends
from secubox_core.auth import require_jwt
from secubox_core.logger import get_logger
import subprocess, shutil
router = APIRouter()
log = get_logger("crowdsec")
@router.get("/status")
async def status(user=Depends(require_jwt)):
running = subprocess.run(
["pgrep", "crowdsec"], capture_output=True
).returncode == 0
version = ""
if shutil.which("crowdsec"):
r = subprocess.run(
["crowdsec", "-version"], capture_output=True, text=True
)
version = r.stdout.strip().splitlines()[0] if r.stdout else ""
return {
"running": running,
"version": version,
"enabled": True, # systemctl is-enabled crowdsec
}
Pattern 2 — Proxy vers API locale (CrowdSec LAPI)
Source RPCD shell
decisions() {
local lapi_url=$(uci -q get crowdsec.config.lapi_url || echo "http://127.0.0.1:8080")
local api_key=$(uci -q get crowdsec.config.lapi_key || echo "")
curl -s -H "X-Api-Key: $api_key" "$lapi_url/v1/decisions" | jsonfilter -e '@'
}
Cible FastAPI
import httpx
from secubox_core.config import get_config
@router.get("/decisions")
async def decisions(user=Depends(require_jwt)):
cfg = get_config("crowdsec")
async with httpx.AsyncClient() as client:
r = await client.get(
f"{cfg['lapi_url']}/v1/decisions",
headers={"X-Api-Key": cfg["lapi_key"]},
timeout=10.0,
)
return r.json() or []
Pattern 3 — Action POST avec paramètres (ban/unban)
Source RPCD shell
ban() {
local ip=$(echo "$ARGS" | jsonfilter -e '@.ip')
local duration=$(echo "$ARGS" | jsonfilter -e '@.duration' || echo "24h")
local reason=$(echo "$ARGS" | jsonfilter -e '@.reason' || echo "manual")
cscli decisions add --ip "$ip" --duration "$duration" --reason "$reason"
json_init
json_add_boolean "success" "1"
json_print
}
Cible FastAPI
from pydantic import BaseModel
class BanRequest(BaseModel):
ip: str
duration: str = "24h"
reason: str = "manual"
@router.post("/ban")
async def ban(req: BanRequest, user=Depends(require_jwt)):
result = subprocess.run(
["cscli", "decisions", "add",
"--ip", req.ip,
"--duration", req.duration,
"--reason", req.reason],
capture_output=True, text=True,
)
return {"success": result.returncode == 0, "output": result.stdout}
Pattern 4 — Lecture WireGuard (subprocess wg)
Source RPCD shell
status() {
json_init
json_add_array "interfaces"
for iface in $(wg show interfaces 2>/dev/null); do
json_add_object
json_add_string "name" "$iface"
local pubkey=$(wg show $iface public-key 2>/dev/null)
local port=$(wg show $iface listen-port 2>/dev/null)
json_add_string "public_key" "$pubkey"
json_add_int "listen_port" "${port:-0}"
# Count peers
local peers=$(wg show $iface peers 2>/dev/null | wc -l)
json_add_int "peer_count" "$peers"
json_close_object
done
json_close_array
json_print
}
Cible FastAPI
import subprocess, re
@router.get("/status")
async def wg_status(user=Depends(require_jwt)):
interfaces = []
ifaces_r = subprocess.run(
["wg", "show", "interfaces"], capture_output=True, text=True
)
for iface in ifaces_r.stdout.strip().split():
pubkey = subprocess.run(
["wg", "show", iface, "public-key"],
capture_output=True, text=True
).stdout.strip()
port = subprocess.run(
["wg", "show", iface, "listen-port"],
capture_output=True, text=True
).stdout.strip()
peers = subprocess.run(
["wg", "show", iface, "peers"],
capture_output=True, text=True
).stdout.strip().splitlines()
interfaces.append({
"name": iface,
"public_key": pubkey,
"listen_port": int(port) if port.isdigit() else 0,
"peer_count": len([p for p in peers if p]),
})
return {"interfaces": interfaces}
Pattern 5 — Netplan mode switching (network-modes)
Source RPCD shell
apply_mode() {
local mode=$(echo "$ARGS" | jsonfilter -e '@.mode')
# Backup current config
cp /etc/config/network /etc/network-modes-backup/network.$(date +%s)
# Apply mode template
cp /etc/network-modes/$mode.conf /etc/config/network
/etc/init.d/network reload
json_init
json_add_boolean "success" "1"
json_add_string "mode" "$mode"
json_print
}
Cible FastAPI — avec templates netplan
import shutil, subprocess
from pathlib import Path
from jinja2 import Environment, FileSystemLoader
from datetime import datetime
MODES_DIR = Path("/etc/secubox/netmodes")
NETPLAN_DIR = Path("/etc/netplan")
BACKUP_DIR = Path("/var/lib/secubox/netmodes-backup")
class ModeRequest(BaseModel):
mode: str # router | sniffer-inline | sniffer-passive | access-point | relay
@router.post("/apply_mode")
async def apply_mode(req: ModeRequest, user=Depends(require_jwt)):
template_path = MODES_DIR / f"{req.mode}.yaml.j2"
if not template_path.exists():
raise HTTPException(400, f"Mode inconnu: {req.mode}")
# Backup
BACKUP_DIR.mkdir(parents=True, exist_ok=True)
ts = datetime.now().strftime("%Y%m%d_%H%M%S")
for f in NETPLAN_DIR.glob("00-secubox*.yaml"):
shutil.copy(f, BACKUP_DIR / f"{f.name}.{ts}")
# Render template
env = Environment(loader=FileSystemLoader(str(MODES_DIR)))
tpl = env.get_template(f"{req.mode}.yaml.j2")
rendered = tpl.render(board=get_config("global")["board"])
# Apply
out = NETPLAN_DIR / "00-secubox.yaml"
out.write_text(rendered)
result = subprocess.run(
["netplan", "apply"], capture_output=True, text=True
)
return {
"success": result.returncode == 0,
"mode": req.mode,
"stderr": result.stderr[:500] if result.stderr else "",
}
Pattern 6 — Réécriture XHR (JS frontend)
Avant (LuCI ubus/rpc)
'require rpc';
var callDecisions = rpc.declare({
object: 'luci.crowdsec-dashboard',
method: 'decisions',
expect: { decisions: [] }
});
// Dans load():
return callDecisions().then(function(data) { ... });
Après (fetch REST)
// api.js remplacé — plus de rpc.declare
async function callDecisions() {
const r = await fetch('/api/v1/crowdsec/decisions', {
headers: { 'Authorization': 'Bearer ' + localStorage.getItem('sbx_token') }
});
if (!r.ok) throw new Error(r.status);
return r.json();
}
// Dans load():
return callDecisions().then(function(data) { ... });
Script scripts/rewrite-xhr.py fait ce remplacement automatiquement.
Pattern 7 — Structure api/main.py d'un module
# packages/secubox-<module>/api/main.py
from fastapi import FastAPI
from secubox_core.auth import router as auth_router
from .routers import status, decisions, alerts, bouncers
app = FastAPI(
title="secubox-<module>",
root_path="/api/v1/<module>",
)
# Auth router (login endpoint)
app.include_router(auth_router, prefix="/auth")
# Module routers
app.include_router(status.router, tags=["status"])
app.include_router(decisions.router, tags=["decisions"])
app.include_router(alerts.router, tags=["alerts"])
app.include_router(bouncers.router, tags=["bouncers"])
@app.get("/health")
async def health():
return {"status": "ok", "module": "<module>"}
Pattern 8 — Unit systemd
# debian/secubox-<module>.service
[Unit]
Description=SecuBox <Module> API
After=network.target secubox-core.service
Requires=secubox-core.service
[Service]
Type=simple
User=secubox
Group=secubox
WorkingDirectory=/usr/lib/secubox/<module>
ExecStart=/usr/bin/uvicorn api.main:app \
--uds /run/secubox/<module>.sock \
--log-level warning
ExecStartPost=/bin/chmod 660 /run/secubox/<module>.sock
Restart=on-failure
RestartSec=5
# Sandboxing
PrivateTmp=true
NoNewPrivileges=true
ProtectSystem=strict
ReadWritePaths=/run/secubox /var/lib/secubox /etc/secubox
[Install]
WantedBy=multi-user.target
Pattern 9 — debian/control
Source: secubox-<module>
Section: net
Priority: optional
Maintainer: Gandalf / CyberMind <gk@cybermind.fr>
Build-Depends: debhelper-compat (= 13)
Standards-Version: 4.6.2
Homepage: https://cybermind.fr/secubox
Package: secubox-<module>
Architecture: all
Depends: ${misc:Depends}, secubox-core (>= 1.0),
python3-uvicorn, <dépendances spécifiques>
Description: SecuBox <Module> — <description courte>
<Description longue sur plusieurs lignes.>
Port Debian bookworm du module luci-app-<module> de SecuBox OpenWrt.
Pattern 10 — debian/postinst
#!/bin/bash
set -e
case "$1" in
configure)
# Créer utilisateur système si absent
if ! id -u secubox >/dev/null 2>&1; then
adduser --system --group --no-create-home --home /var/lib/secubox secubox
fi
# Répertoires runtime — SHARED parents, NE JAMAIS les passer en 0750/0700
# (#623 : casse la traversée pour les daemons non-secubox → kbin/toolbox 500).
# /run/secubox reste 1777 (sticky world-writable, sockets de tous les services,
# #471) ; /var/lib/secubox reste 0755. Les leaves privées
# (/var/lib/secubox/<module>) peuvent être 0750.
install -d -o root -g root -m 1777 /run/secubox
install -d -o secubox -g secubox -m 755 /var/lib/secubox
# Activer et démarrer le service
systemctl daemon-reload
systemctl enable secubox-<module>.service
systemctl start secubox-<module>.service || true
# Recharger nginx si installé
systemctl reload nginx 2>/dev/null || true
;;
esac
#DEBHELPER#
Pattern 11 — Container Runtime (LXC ONLY)
CRITICAL REQUIREMENT: Use LXC containers exclusively. NEVER use Docker or Podman.
Container Creation
# Create Debian bookworm LXC container
subprocess.run([
"lxc-create", "-n", CONTAINER_NAME,
"-t", "download",
"--",
"-d", "debian",
"-r", "bookworm",
"-a", "amd64"
], timeout=600)
Container Status Check
def lxc_running() -> bool:
"""Check if LXC container is running."""
result = subprocess.run(
["lxc-info", "-n", CONTAINER_NAME, "-s"],
capture_output=True, text=True
)
return "RUNNING" in result.stdout
def lxc_get_ip() -> Optional[str]:
"""Get LXC container IP."""
result = subprocess.run(
["lxc-info", "-n", CONTAINER_NAME, "-iH"],
capture_output=True, text=True
)
return result.stdout.strip().split("\n")[0] if result.returncode == 0 else None
Execute Commands Inside LXC
def lxc_exec(cmd: List[str], timeout: int = 60):
"""Execute command inside LXC container."""
return subprocess.run(
["lxc-attach", "-n", CONTAINER_NAME, "--"] + cmd,
capture_output=True, text=True, timeout=timeout
)
USB Device Passthrough
# Add to container config
lxc_config = f"/var/lib/lxc/{CONTAINER_NAME}/config"
with open(lxc_config, "a") as f:
f.write(f"lxc.mount.entry = /dev/ttyUSB0 dev/ttyUSB0 none bind,create=file 0 0\n")
f.write("lxc.cgroup2.devices.allow = c 188:* rwm\n") # ttyUSB
f.write("lxc.cgroup2.devices.allow = c 166:* rwm\n") # ttyACM
Dependencies for LXC packages
Depends: ..., lxc, lxc-templates
Why LXC over Docker/Podman
- Native Linux container technology (no daemon overhead)
- Better integration with systemd cgroups
- Persistent containers by default
- Direct USB/hardware passthrough
- Lower memory footprint
- Full system containers (init, services)
Pattern 12 — Module Web UI Requirements
CRITICAL: All module frontends MUST include the shared sidebar and CRT theme.
MUST — dual-vhost split (modules with a real web UI)
A module that wraps an upstream app with its own web UI (LMS, z2m, Authelia, Nextcloud, Grafana, …) MUST split the two surfaces on separate hostnames:
| URL | Role |
|---|---|
https://admin.gk2.secubox.in/<module>/ |
SecuBox admin — static page calling /api/v1/<module>/*. NEVER a proxy_pass to the app. |
https://<module>.gk2.secubox.in/ |
Real app web UI at the vhost root, Authelia-gated. |
Why — upstream apps hardcode absolute asset paths (/material/,
/cometd/, /css/, /apps/, /public/). Reverse-proxying under a
subpath silently breaks every CSS/JS/RPC. The dedicated vhost serves
the app at root so the absolute URLs resolve.
Single source of truth — the Open <App> UI → button on the admin
page reads its href from /api/v1/<module>/access at runtime. Never
hardcode the public hostname in HTML. The /access endpoint is the
only place the URL appears (see lyrionctl, autheliactl for the
emit_access_json pattern).
fetch('/api/v1/<module>/access').then(r => r.json()).then(d => {
const access = d.access || [];
const pub = access.find(a => a.scope === 'public');
const lan = access.find(a => a.scope === 'lan');
const btn = document.getElementById('open-app');
if (btn) btn.href = (pub && pub.url) || (lan && lan.url) || '#';
});
Forbidden anti-pattern — location /<module>/ { proxy_pass http:// <lxc-ip>:<port>/; } on the canonical hub vhost. Migrate any existing
module still on this pattern (incident 2026-05-24, see secubox-lyrion
v1.1.0 changelog).
See docs/MODULE-GUIDELINES.md §4 (REQUIRED) and §5 (dual-vhost nginx
template) for the full pattern.
Required CSS Includes
<head>
<link rel="stylesheet" href="/shared/crt-light.css">
<link rel="stylesheet" href="/shared/sidebar-light.css">
<!-- Module-specific styles -->
</head>
Required HTML Structure
<body class="crt-light">
<nav class="sidebar" id="sidebar"></nav>
<main class="main-content">
<div class="container">
<!-- Module content here -->
</div>
</main>
<script src="/shared/sidebar.js"></script>
</body>
Theme-Aware CSS Variables
Each module defines accent colors with dark mode support:
:root {
--module-accent: #00bcd4; /* Light mode color */
--module-accent-dim: #0097a7;
}
body.dark {
--module-accent: #4dd0e1; /* Dark mode color */
--module-accent-dim: #00acc1;
}
.accent { color: var(--module-accent); }
.accent-bg { background: var(--module-accent); }
Shared Resources Location
/shared/crt-light.css- Light theme (P31 phosphor)/shared/crt-system.css- Dark theme (VT100 green)/shared/sidebar-light.css- Light sidebar/shared/sidebar.css- Dark sidebar/shared/sidebar.js- Dynamic menu loader
Menu Integration
Module must provide menu.d/*.json with fields:
{
"id": "module-name",
"name": "Display Name",
"icon": "🔧",
"path": "/module/",
"category": "apps",
"order": 800,
"description": "Short description"
}
Required fields: name (not title), emoji icon, path, category, order
Pattern 13 — Performance: Background Refresh Cache
CRITICAL for stats endpoints. Never block API responses with expensive computations.
Problem: Blocking Stats Collection
# BAD: 500ms+ blocking on every request
@router.get("/stats")
async def get_stats():
data = await expensive_collection() # subprocess calls, file parsing
return data
Solution: Pre-computed Cache with Instant Response
import asyncio
import json
from pathlib import Path
from secubox_core.logger import get_logger
log = get_logger("module")
CACHE_FILE = Path("/var/cache/secubox/module/stats.json")
_cache: dict = {}
_cache_lock = asyncio.Lock()
async def _refresh_cache():
"""Background task: refresh cache every 60s."""
while True:
try:
data = await _compute_stats() # expensive work
CACHE_FILE.parent.mkdir(parents=True, exist_ok=True)
CACHE_FILE.write_text(json.dumps(data))
async with _cache_lock:
_cache.update(data)
log.debug("cache refreshed")
except Exception as e:
log.error(f"cache refresh failed: {e}")
await asyncio.sleep(60)
@app.on_event("startup")
async def startup():
# Load existing cache file if available
if CACHE_FILE.exists():
try:
_cache.update(json.loads(CACHE_FILE.read_text()))
except Exception:
pass
asyncio.create_task(_refresh_cache())
@router.get("/stats")
async def get_stats():
"""Instant response from pre-computed cache."""
if _cache:
return _cache
if CACHE_FILE.exists():
return json.loads(CACHE_FILE.read_text())
return {"error": "cache not ready", "retry_after": 5}
When to Apply
- Dashboard stats endpoints
- Log aggregation endpoints
- Metrics collection (CPU, mem, disk, network)
- CrowdSec decisions/alerts lists
- Any endpoint reading files or calling subprocesses
When NOT to Apply
- Real-time actions (start/stop/restart/ban/unban)
- Configuration changes
- User-initiated operations requiring immediate feedback
Pattern 14 — Performance: Parallel Subprocess Execution
Problem: Sequential CLI Calls
# BAD: 7-10 seconds total
decisions = await run("cscli decisions list") # 2s
alerts = await run("cscli alerts list") # 2s
metrics = await run("cscli metrics") # 3s
bouncers = await run("cscli bouncers list") # 2s
Solution: asyncio.gather() Parallelization
import asyncio
async def run_cmd(cmd: str, timeout: float = 30.0) -> str:
"""Run subprocess asynchronously with timeout."""
proc = await asyncio.create_subprocess_shell(
cmd,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
try:
stdout, stderr = await asyncio.wait_for(
proc.communicate(), timeout=timeout
)
return stdout.decode() if proc.returncode == 0 else ""
except asyncio.TimeoutError:
proc.kill()
return ""
async def get_crowdsec_status():
# GOOD: 2-3 seconds total (parallel execution)
decisions, alerts, metrics, bouncers = await asyncio.gather(
run_cmd("cscli decisions list -o json"),
run_cmd("cscli alerts list -o json"),
run_cmd("cscli metrics -o json"),
run_cmd("cscli bouncers list -o json"),
)
return {
"decisions": json.loads(decisions) if decisions else [],
"alerts": json.loads(alerts) if alerts else [],
"metrics": json.loads(metrics) if metrics else {},
"bouncers": json.loads(bouncers) if bouncers else [],
}
Pattern 15 — Performance: Memory Limits in Systemd
Service Memory Configuration
# debian/secubox-module.service
[Service]
# Memory limits (adjust per device profile)
MemoryMax=100M # Hard limit - OOM kill if exceeded
MemoryHigh=80M # Soft limit - triggers reclaim pressure
# For ESPRESSObin (1GB RAM) - lighter limits
# MemoryMax=50M
# MemoryHigh=40M
# Prevent memory leaks from consuming system
MemorySwapMax=50M # Limit swap usage per service
Drop-in Override for Device Profiles
# /etc/systemd/system/secubox-module.service.d/memory.conf
[Service]
MemoryMax=50M # ESPRESSObin profile
MemoryHigh=40M
Pattern 16 — Performance: Streaming Large Responses
Problem: Loading Entire File into Memory
# BAD: Loads 100MB log file into memory
@router.get("/logs")
async def get_logs():
content = Path("/var/log/secubox/audit.log").read_text()
return {"logs": content}
Solution: StreamingResponse with Generator
from fastapi.responses import StreamingResponse
import aiofiles
@router.get("/logs")
async def get_logs():
"""Stream logs without loading entire file."""
async def generate():
async with aiofiles.open("/var/log/secubox/audit.log") as f:
async for line in f:
yield line
return StreamingResponse(
generate(),
media_type="text/plain",
headers={"X-Content-Type-Options": "nosniff"}
)
# For JSON: paginate instead of streaming
@router.get("/logs/json")
async def get_logs_paginated(offset: int = 0, limit: int = 100):
"""Paginated log access."""
lines = []
async with aiofiles.open("/var/log/secubox/audit.log") as f:
for i, line in enumerate(await f.readlines()):
if i < offset:
continue
if len(lines) >= limit:
break
lines.append(line.strip())
return {"logs": lines, "offset": offset, "limit": limit}
Pattern 17 — Performance: History Limits by Device
from secubox_core.config import get_config
# Device-specific history limits
DEVICE_PROFILES = {
"espressobin": { # 1GB RAM
"max_history_entries": 1000,
"max_log_lines": 500,
"cache_ttl_seconds": 120,
"uvicorn_workers": 1,
},
"mochabin": { # 8GB RAM
"max_history_entries": 10000,
"max_log_lines": 5000,
"cache_ttl_seconds": 60,
"uvicorn_workers": 4,
},
"default": {
"max_history_entries": 5000,
"max_log_lines": 2000,
"cache_ttl_seconds": 60,
"uvicorn_workers": 2,
},
}
def get_device_profile() -> dict:
"""Get performance profile for current device."""
cfg = get_config("global")
board = cfg.get("board", "default")
return DEVICE_PROFILES.get(board, DEVICE_PROFILES["default"])
# Usage
profile = get_device_profile()
MAX_HISTORY = profile["max_history_entries"]
Pattern 18 — Performance: Efficient Config Reading
Problem: Re-reading TOML on Every Request
# BAD: File I/O on every API call
@router.get("/status")
async def status():
config = toml.load("/etc/secubox/module.toml") # I/O every time
return {"enabled": config.get("enabled", True)}
Solution: LRU Cache with TTL
from functools import lru_cache
import time
import toml
_config_cache = {}
_config_mtime = {}
def get_module_config(module: str, ttl: int = 30) -> dict:
"""Read config with file modification check."""
path = f"/etc/secubox/{module}.toml"
try:
mtime = os.path.getmtime(path)
if module in _config_cache and _config_mtime.get(module) == mtime:
return _config_cache[module]
config = toml.load(path)
_config_cache[module] = config
_config_mtime[module] = mtime
return config
except Exception:
return _config_cache.get(module, {})
# Even simpler: @lru_cache for truly static configs
@lru_cache(maxsize=32)
def get_static_config(module: str) -> dict:
"""For configs that rarely change - clear cache on service restart."""
return toml.load(f"/etc/secubox/{module}.toml")
Pattern 19 — Performance Verification Checklist
Before marking a module complete, verify:
□ No blocking subprocess calls in GET endpoints
□ Stats endpoints use background refresh pattern
□ Memory limits defined in systemd service
□ Large responses use streaming or pagination
□ Config reads use caching (LRU or mtime-based)
□ Parallel execution for multiple CLI calls
□ History/log limits respect device profile
□ P99 latency < 500ms (ESPRESSObin) or < 200ms (MOCHAbin)
□ Service RSS < 50MB (ESPRESSObin) or < 100MB (MOCHAbin)
Quick Performance Test Commands
# API latency
./scripts/bench/api-latency.py --host $HOST --requests 50
# Memory per service
./scripts/bench/memory-baseline.sh
# Load test
locust -f scripts/bench/locustfile.py --host https://$HOST \
--headless -u 10 -r 2 -t 60s