Merge secubox-reality: VLESS+Reality+Vision (Xray) egress module — API+WebUI, fleet skin, navbar (deployed on gk2)
Some checks are pending
License Headers / check (push) Waiting to run

This commit is contained in:
CyberMind-FR 2026-07-09 15:11:54 +02:00
commit 0ccee5028b
23 changed files with 2286 additions and 19 deletions

View File

@ -0,0 +1,15 @@
secubox-reality (0.1.1-1~bookworm1) bookworm; urgency=medium
* fleet crt-light skin + shared sidebar + navbar menu entry.
-- Gerald KERMA <devel@cybermind.fr> Wed, 09 Jul 2026 21:30:00 +0200
secubox-reality (0.1.0-1~bookworm1) bookworm; urgency=medium
* Initial release: VLESS+Reality+Vision egress-point manager (Xray-core).
x25519/UUID identity provisioning, TLS1.3+X25519 target validation,
hardened per-instance secubox-xray@<id> systemd units, client
vless:// links + QR, sliding-window volume-guard (soft/hard, disable
preserves identity).
-- Gerald KERMA <devel@cybermind.fr> Thu, 09 Jul 2026 13:54:00 +0200

View File

@ -0,0 +1 @@
13

View File

@ -0,0 +1,24 @@
Source: secubox-reality
Section: admin
Priority: optional
Maintainer: Gerald KERMA <devel@cybermind.fr>
Build-Depends: debhelper (>= 13)
Standards-Version: 4.6.2
Homepage: https://secubox.in
Package: secubox-reality
Architecture: all
Depends: ${misc:Depends},
secubox-core (>= 1.0.0),
python3,
python3-fastapi,
python3-pydantic,
python3-segno,
openssl
Recommends: xray
Description: SecuBox Reality — VLESS+Reality+Vision egress-point manager
Manages VLESS+Reality+Vision egress points backed by Xray-core: x25519
identity provisioning, Reality dest/SNI sanity validation (TLS1.3 +
X25519), per-instance hardened systemd units, client vless:// links with
QR codes, and a sliding-window volume-guard (soft warn / hard disable)
that preserves egress identity across a stop.

View File

@ -0,0 +1,36 @@
#!/bin/sh
set -e
case "$1" in
configure)
# Own only OUR subdirectories — never touch the shared /etc/secubox
# or /var/lib/secubox parents (traversal perms for sibling modules
# depend on those staying 0755; see project history #494/#511).
mkdir -p /etc/secubox/reality
chown secubox:secubox /etc/secubox/reality
chmod 0750 /etc/secubox/reality
mkdir -p /var/lib/secubox/reality
chown secubox:secubox /var/lib/secubox/reality
chmod 0750 /var/lib/secubox/reality
# /run/secubox itself is provisioned by secubox-runtime /
# RuntimeDirectory=secubox on the unit — never mkdir/chown it here.
systemctl daemon-reload || true
systemctl enable secubox-reality.service || true
systemctl try-restart secubox-reality.service 2>/dev/null || systemctl start secubox-reality.service || true
# Non-package-name units are not auto-handled by dh_installsystemd —
# enable the monitor timer explicitly. The secubox-xray@ template is
# deliberately NOT enabled/started here: instances are started
# per-egress-point only when an operator calls POST /apply.
systemctl enable secubox-reality-monitor.timer || true
systemctl try-restart secubox-reality-monitor.timer 2>/dev/null || systemctl start secubox-reality-monitor.timer || true
;;
esac
#DEBHELPER#
exit 0

View File

@ -0,0 +1,19 @@
#!/bin/sh
set -e
case "$1" in
remove|deconfigure)
systemctl stop secubox-reality-monitor.timer 2>/dev/null || true
systemctl stop secubox-reality.service 2>/dev/null || true
# Running secubox-xray@<id> egress instances are left alone here —
# they are independent operational entities (an operator's live
# egress points); removing the API/monitor must not silently take
# traffic down. Use `secubox-reality` API DELETE /servers/<id> (or
# `systemctl stop secubox-xray@<id>` manually) before purge if a
# full teardown is intended.
;;
esac
#DEBHELPER#
exit 0

View File

@ -0,0 +1,34 @@
#!/usr/bin/make -f
%:
dh $@
override_dh_auto_install:
# Python package: secubox_reality/*.py -> /usr/lib/secubox/reality/secubox_reality/
install -d $(CURDIR)/debian/secubox-reality/usr/lib/secubox/reality/secubox_reality
install -m 644 secubox_reality/*.py \
$(CURDIR)/debian/secubox-reality/usr/lib/secubox/reality/secubox_reality/
# WebUI (static, self-contained — no build step)
install -d $(CURDIR)/debian/secubox-reality/usr/share/secubox/www/reality
install -m 644 webui/reality.html \
$(CURDIR)/debian/secubox-reality/usr/share/secubox/www/reality/reality.html
# Navbar/sidebar menu entry
install -d $(CURDIR)/debian/secubox-reality/usr/share/secubox/menu.d
[ -d menu.d ] && cp -r menu.d/. $(CURDIR)/debian/secubox-reality/usr/share/secubox/menu.d/ || true
# systemd units: main API + templated per-egress-point instance + monitor timer
install -d $(CURDIR)/debian/secubox-reality/usr/lib/systemd/system
install -m 644 debian/secubox-reality.service \
$(CURDIR)/debian/secubox-reality/usr/lib/systemd/system/
install -m 644 debian/secubox-xray@.service \
$(CURDIR)/debian/secubox-reality/usr/lib/systemd/system/
install -m 644 debian/secubox-reality-monitor.service \
$(CURDIR)/debian/secubox-reality/usr/lib/systemd/system/
install -m 644 debian/secubox-reality-monitor.timer \
$(CURDIR)/debian/secubox-reality/usr/lib/systemd/system/
override_dh_auto_test:
# No network / systemd / xray binary available in the build chroot —
# unit tests for this module are run out-of-band (see docs).

View File

@ -0,0 +1,16 @@
[Unit]
Description=SecuBox Reality volume-guard sweep (oneshot)
After=secubox-reality.service
Wants=secubox-reality.service
[Service]
Type=oneshot
User=secubox
Group=secubox
WorkingDirectory=/usr/lib/secubox/reality
ExecStart=/usr/bin/python3 -m secubox_reality.monitor
NoNewPrivileges=true
RuntimeDirectory=secubox
RuntimeDirectoryPreserve=yes
ReadWritePaths=/var/lib/secubox/reality /etc/secubox/reality

View File

@ -0,0 +1,11 @@
[Unit]
Description=SecuBox Reality volume-guard sweep — every 5 minutes
[Timer]
OnBootSec=2min
OnUnitActiveSec=5min
AccuracySec=30s
Persistent=true
[Install]
WantedBy=timers.target

View File

@ -0,0 +1,24 @@
[Unit]
Description=SecuBox Reality API
After=network.target secubox-core.service
Requires=secubox-core.service
[Service]
Type=simple
User=secubox
Group=secubox
WorkingDirectory=/usr/lib/secubox/reality
ExecStart=/usr/bin/python3 -m uvicorn secubox_reality.main:app --uds /run/secubox/reality.sock --log-level warning
Restart=on-failure
RestartSec=5
UMask=0000
NoNewPrivileges=true
RuntimeDirectory=secubox
RuntimeDirectoryPreserve=yes
RuntimeDirectoryMode=0775
ReadWritePaths=/run/secubox /var/lib/secubox/reality /etc/secubox/reality
[Install]
WantedBy=multi-user.target

View File

@ -0,0 +1,39 @@
[Unit]
Description=SecuBox Reality egress point %i (Xray-core, VLESS+Reality+Vision)
Documentation=https://xtls.github.io/
After=network-online.target secubox-reality.service
Wants=network-online.target
PartOf=secubox-reality.service
[Service]
Type=simple
# The per-instance config lives at /etc/secubox/reality/%i.json and holds
# the x25519 private key + client UUIDs — never world-readable. LoadCredential
# copies it (mode 0400) into a private per-unit credentials directory instead
# of relying on DAC group membership for the DynamicUser below.
LoadCredential=config:/etc/secubox/reality/%i.json
ExecStart=/usr/bin/xray run -config %d/config
Restart=on-failure
RestartSec=5
# --- CSPN-oriented hardening ---
DynamicUser=yes
NoNewPrivileges=yes
ProtectSystem=strict
ProtectHome=yes
PrivateTmp=yes
PrivateDevices=yes
ProtectKernelTunables=yes
ProtectKernelModules=yes
ProtectControlGroups=yes
RestrictSUIDSGID=yes
RestrictRealtime=yes
LockPersonality=yes
MemoryDenyWriteExecute=yes
CapabilityBoundingSet=CAP_NET_BIND_SERVICE
AmbientCapabilities=CAP_NET_BIND_SERVICE
[Install]
WantedBy=multi-user.target

View File

@ -0,0 +1,9 @@
{
"id": "reality",
"name": "Reality",
"category": "mesh",
"icon": "🕶️",
"path": "/reality/",
"order": 562,
"description": "VLESS+Reality+Vision egress-point manager (Xray-core)"
}

View File

@ -0,0 +1,22 @@
# SPDX-License-Identifier: LicenseRef-CMSD-1.0
# Copyright (c) 2026 CyberMind — Gérald Kerma <devel@cybermind.fr>
# Source-Disclosed License — All rights reserved except as expressly granted.
# See LICENCE-CMSD-1.0.md for terms.
"""
SecuBox-Deb :: Reality VLESS+Reality+Vision egress-point manager (Xray-core)
CyberMind https://cybermind.fr
Hamiltonian path (see docs at top of ``router.py`` for the full map):
AUTH -> WALL -> BOOT -> MIND -> ROOT -> MESH
AUTH identity provisioning (x25519 keypair + UUID) -> xray.py
WALL split-tunnel routing rules + traffic shaping -> xray.py
BOOT instance lifecycle (secubox-xray@<id>.service) -> service.py
MIND decision gates: target sanity + volume-guard actions -> validator.py, monitor.py
ROOT persistent state (SQLite WAL) -> store.py
MESH transport (Reality streamSettings) -> xray.py
"""
__version__ = "0.1.0"

View File

@ -0,0 +1,33 @@
# SPDX-License-Identifier: LicenseRef-CMSD-1.0
# Copyright (c) 2026 CyberMind — Gérald Kerma <devel@cybermind.fr>
# Source-Disclosed License — All rights reserved except as expressly granted.
# See LICENCE-CMSD-1.0.md for terms.
"""
SecuBox-Deb :: Reality ASGI entry point
CyberMind https://cybermind.fr
Not part of the module spec's explicit file list, but required as the
uvicorn target referenced by ``debian/secubox-reality.service``
(``uvicorn secubox_reality.main:app``). Kept minimal on purpose: all
behaviour lives in ``router.py``.
"""
from __future__ import annotations
from fastapi import FastAPI
from . import store
from .router import router
app = FastAPI(title="SecuBox Reality", version="0.1.0")
app.include_router(router)
@app.on_event("startup")
def _startup() -> None:
store.init_db()
@app.get("/healthz")
def healthz() -> dict:
return {"status": "ok", "module": "reality"}

View File

@ -0,0 +1,151 @@
# SPDX-License-Identifier: LicenseRef-CMSD-1.0
# Copyright (c) 2026 CyberMind — Gérald Kerma <devel@cybermind.fr>
# Source-Disclosed License — All rights reserved except as expressly granted.
# See LICENCE-CMSD-1.0.md for terms.
"""
SecuBox-Deb :: Reality Pydantic v2 data contracts
CyberMind https://cybermind.fr
All models use the Pydantic v2 API (``model_config`` / ``field_validator``)
the legacy v1 ``class Config`` / ``@validator`` decorators are not used
anywhere in this module.
"""
from __future__ import annotations
import re
import time
import uuid as _uuid
from typing import List, Literal, Optional
from pydantic import BaseModel, ConfigDict, Field, field_validator
_HEX_RE = re.compile(r"^[0-9a-fA-F]*$")
def _now() -> float:
return time.time()
# --- AUTH / ROOT: server (egress point) records ---------------------------
class ServerCreate(BaseModel):
"""Payload to provision a new VLESS+Reality+Vision egress point."""
model_config = ConfigDict(extra="forbid")
remark: str = Field(..., min_length=1, max_length=128)
dest: str = Field(..., description="Reality camouflage target, host[:port] (SNI origin)")
sni: str = Field(..., description="Server Name Indication presented to clients")
listen_port: int = Field(443, ge=1, le=65535)
dest_port: int = Field(443, ge=1, le=65535)
flow: Literal["xtls-rprx-vision", ""] = "xtls-rprx-vision"
short_id_count: int = Field(1, ge=1, le=8)
@field_validator("dest", "sni")
@classmethod
def _no_scheme(cls, v: str) -> str:
v = v.strip()
if "://" in v:
raise ValueError("dest/sni must be a bare host, not a URL")
return v
class Server(BaseModel):
"""A provisioned Reality egress point, as persisted in ROOT storage."""
model_config = ConfigDict(extra="forbid")
id: str
remark: str
private_key: str
public_key: str
uuid: str
dest: str
sni: str
listen_port: int
dest_port: int
flow: str = "xtls-rprx-vision"
short_ids: List[str] = Field(default_factory=list)
status: Literal["new", "applied", "running", "stopped", "disabled_volume_guard"] = "new"
created_at: float = Field(default_factory=_now)
updated_at: float = Field(default_factory=_now)
@field_validator("short_ids")
@classmethod
def _validate_short_ids(cls, v: List[str]) -> List[str]:
for sid in v:
if len(sid) % 2 != 0 or not _HEX_RE.match(sid):
raise ValueError(f"invalid shortId {sid!r}: must be even-length hex")
return v
# --- Clients ----------------------------------------------------------------
class ClientCreate(BaseModel):
model_config = ConfigDict(extra="forbid")
server_id: str
email: str = Field(..., min_length=1, max_length=128)
flow: Literal["xtls-rprx-vision", ""] = "xtls-rprx-vision"
class Client(BaseModel):
model_config = ConfigDict(extra="forbid")
id: str
server_id: str
uuid: str = Field(default_factory=lambda: str(_uuid.uuid4()))
email: str
short_id: str
flow: str = "xtls-rprx-vision"
created_at: float = Field(default_factory=_now)
# --- MIND: volume-guard configuration ---------------------------------------
class GuardConfig(BaseModel):
"""Sliding-window volume-guard policy (soft warn / hard disable)."""
model_config = ConfigDict(extra="forbid")
window_hours: int = Field(48, ge=1, le=24 * 30)
soft_gb: float = Field(80.0, gt=0)
hard_gb: float = Field(100.0, gt=0)
action: Literal["disable"] = "disable"
@field_validator("hard_gb")
@classmethod
def _hard_ge_soft(cls, v: float, info) -> float:
soft = info.data.get("soft_gb")
if soft is not None and v < soft:
raise ValueError("hard_gb must be >= soft_gb")
return v
class StatsPoint(BaseModel):
model_config = ConfigDict(extra="forbid")
server_id: str
ts: float = Field(default_factory=_now)
uplink: int = Field(0, ge=0)
downlink: int = Field(0, ge=0)
# --- Validation / apply requests --------------------------------------------
class ValidateTargetRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
host: str = Field(..., min_length=1)
port: int = Field(443, ge=1, le=65535)
class ApplyRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
server_id: str

View File

@ -0,0 +1,198 @@
# SPDX-License-Identifier: LicenseRef-CMSD-1.0
# Copyright (c) 2026 CyberMind — Gérald Kerma <devel@cybermind.fr>
# Source-Disclosed License — All rights reserved except as expressly granted.
# See LICENCE-CMSD-1.0.md for terms.
"""
SecuBox-Deb :: Reality MIND stage: volume-guard decisioning
CyberMind https://cybermind.fr
Queries cumulative traffic counters from each running egress point's
loopback ``StatsService`` (via ``xray api statsquery``), samples them into
SQLite (ROOT), and applies a sliding-window volume-guard: soft warn / hard
disable. Xray counters are CUMULATIVE (reset only on process restart), so
the per-window delta is computed as ``max(value) - min(value)`` across the
samples captured inside the window NOT a simple last-sample read.
Caveat: if the xray instance restarts mid-window (counters reset to 0),
max-min under-counts that window's true usage. This is a known limitation
of the max-min approach and is accepted per spec; a monotonic-aware delta
(summing only positive deltas and treating any decrease as a reset-then-
recount) would close the gap but is out of scope here.
Invoked every 5 minutes by ``secubox-reality-monitor.timer`` ->
``secubox-reality-monitor.service`` (oneshot), which runs
``python3 -m secubox_reality.monitor``.
"""
from __future__ import annotations
import json
import subprocess
import sys
import time
from typing import Any, Dict, List, Optional
from . import service, store
from .models import GuardConfig
from .xray import API_LISTEN, API_PORT, write_config_atomic
XRAY_BIN = "xray"
_TIMEOUT = 10
GB = 1024 ** 3
GUARD_CONFIG_PATH = f"{service.CONFIG_DIR}/guard.json"
def load_guard_config(path: str = GUARD_CONFIG_PATH) -> GuardConfig:
"""Load the operator-tunable volume-guard policy from
``/etc/secubox/reality/guard.json``, falling back to defaults if the
file is absent or unreadable (never raises)."""
try:
with open(path, "r") as fh:
return GuardConfig(**json.load(fh))
except (FileNotFoundError, json.JSONDecodeError, OSError, TypeError, ValueError):
return GuardConfig()
def save_guard_config(guard: GuardConfig, path: str = GUARD_CONFIG_PATH) -> None:
write_config_atomic(path, guard.model_dump())
class MonitorError(RuntimeError):
pass
# --- stats collection --------------------------------------------------
def query_stats(api_addr: str = f"{API_LISTEN}:{API_PORT}", xray_bin: str = XRAY_BIN) -> Dict[str, int]:
"""Query cumulative uplink/downlink counters via ``xray api statsquery``
against the loopback StatsService API inbound. Returns
``{"uplink": int, "downlink": int}`` summed across all matching stats.
Never raises for a stopped/unreachable instance returns zeros so the
caller can skip sampling gracefully.
"""
try:
proc = subprocess.run(
[xray_bin, "api", "statsquery", f"--server={api_addr}", "-pattern", ""],
capture_output=True,
text=True,
timeout=_TIMEOUT,
check=False,
)
except (FileNotFoundError, subprocess.TimeoutExpired):
return {"uplink": 0, "downlink": 0}
if proc.returncode != 0 or not proc.stdout.strip():
return {"uplink": 0, "downlink": 0}
try:
data = json.loads(proc.stdout)
except json.JSONDecodeError:
return {"uplink": 0, "downlink": 0}
uplink = 0
downlink = 0
for stat in data.get("stat", []):
name = stat.get("name", "")
value = int(stat.get("value", 0))
if "uplink" in name:
uplink += value
elif "downlink" in name:
downlink += value
return {"uplink": uplink, "downlink": downlink}
def sample_server(server_id: str, path: str = store.DEFAULT_DB_PATH, xray_bin: str = XRAY_BIN) -> None:
st = service.status_instance(server_id)
if not st["running"]:
return
counters = query_stats(xray_bin=xray_bin)
store.insert_stats_sample(server_id, counters["uplink"], counters["downlink"], path=path)
# --- MIND: sliding-window delta + guard decision ----------------------------
def compute_window_delta(samples: List[Dict[str, Any]]) -> Dict[str, int]:
"""Per spec: delta = max(value) - min(value) across window samples,
computed independently for uplink and downlink.
"""
if not samples:
return {"uplink": 0, "downlink": 0}
uplinks = [s["uplink"] for s in samples]
downlinks = [s["downlink"] for s in samples]
return {
"uplink": max(uplinks) - min(uplinks),
"downlink": max(downlinks) - min(downlinks),
}
def check_volume_guard(
server_id: str,
guard: GuardConfig,
path: str = store.DEFAULT_DB_PATH,
) -> Dict[str, Any]:
"""Evaluate the volume-guard for one server and take action if the hard
ceiling is crossed. Returns a report dict; never raises on the "over
limit" case — that is a normal, expected outcome, not an error.
"""
since = time.time() - guard.window_hours * 3600
samples = store.get_stats_window(server_id, since, path=path)
delta = compute_window_delta(samples)
total_bytes = delta["uplink"] + delta["downlink"]
total_gb = total_bytes / GB
soft_hit = total_gb >= guard.soft_gb
hard_hit = total_gb >= guard.hard_gb
action_taken: Optional[str] = None
if hard_hit and guard.action == "disable":
# DISABLE = stop the instance WITHOUT destroying it: keys, config
# and DB row (i.e. the IP + identity) survive so it can be
# restarted once the operator raises the guard or a new window
# opens.
service.stop_instance(server_id)
store.update_server_status(server_id, "disabled_volume_guard", path=path)
action_taken = "disabled"
return {
"server_id": server_id,
"window_hours": guard.window_hours,
"uplink_bytes": delta["uplink"],
"downlink_bytes": delta["downlink"],
"total_gb": round(total_gb, 3),
"soft_gb": guard.soft_gb,
"hard_gb": guard.hard_gb,
"soft_hit": soft_hit,
"hard_hit": hard_hit,
"action_taken": action_taken,
}
def run_all(guard: GuardConfig, path: str = store.DEFAULT_DB_PATH, xray_bin: str = XRAY_BIN) -> List[Dict[str, Any]]:
"""Sample every server and evaluate the guard — the timer-driven entry
point (also used directly by ``router.py``'s GET /stats)."""
reports = []
for srv in store.list_servers(path=path):
sample_server(srv["id"], path=path, xray_bin=xray_bin)
reports.append(check_volume_guard(srv["id"], guard, path=path))
return reports
def main() -> int:
"""CLI entry point for the systemd timer/oneshot service."""
store.init_db()
guard = load_guard_config()
reports = run_all(guard)
for r in reports:
if r["action_taken"]:
print(f"[secubox-reality] volume-guard DISABLED {r['server_id']}: {r['total_gb']}GB "
f"(hard={r['hard_gb']}GB)", file=sys.stderr)
return 0
if __name__ == "__main__":
raise SystemExit(main())

View File

@ -0,0 +1,245 @@
# SPDX-License-Identifier: LicenseRef-CMSD-1.0
# Copyright (c) 2026 CyberMind — Gérald Kerma <devel@cybermind.fr>
# Source-Disclosed License — All rights reserved except as expressly granted.
# See LICENCE-CMSD-1.0.md for terms.
"""
SecuBox-Deb :: Reality REST surface (/api/mesh/reality)
CyberMind https://cybermind.fr
===============================================================================
Hamiltonian path: AUTH -> WALL -> BOOT -> MIND -> ROOT -> MESH
===============================================================================
AUTH xray.auth_provision_identity() x25519 keypair + UUID + shortIds,
invoked from POST /servers
WALL xray.wall_build_routing_rules() split-tunnel routing rules baked
into every generated config
BOOT service.boot_provision() (re)start of secubox-xray@<id>,
invoked from POST /apply
MIND validator.validate_target() + target sanity gate (POST
monitor.check_volume_guard() /validate-target, POST /apply) and
volume-guard decisioning (GET /stats)
ROOT store.* SQLite WAL persistence for every
servers/clients/stats_samples read
and write below
MESH xray.mesh_build_stream_settings() Reality transport, composed into
the config written by BOOT
Every route in this router is JWT-gated via ``secubox_core.auth.require_jwt``
(router-level dependency no route can accidentally skip it). Handlers that
shell out to xray/openssl/systemctl (directly, or transitively through
``xray.py`` / ``validator.py`` / ``service.py`` / ``monitor.py``) are declared
as plain ``def`` so FastAPI/Starlette runs them in a threadpool instead of on
the shared asyncio event loop.
"""
from __future__ import annotations
import uuid as _uuid
from typing import Any, Dict, List, Optional
from fastapi import APIRouter, Depends, HTTPException, Query
from secubox_core.auth import require_jwt
from . import monitor, service, store, validator, xray
from .models import (
ApplyRequest,
Client,
ClientCreate,
GuardConfig,
Server,
ServerCreate,
ValidateTargetRequest,
)
router = APIRouter(prefix="/api/mesh/reality", dependencies=[Depends(require_jwt)])
def _ensure_db() -> None:
store.init_db()
def _require_server(server_id: str) -> Dict[str, Any]:
_ensure_db()
srv = store.get_server(server_id)
if srv is None:
raise HTTPException(status_code=404, detail=f"server {server_id!r} not found")
return srv
# --- servers (ROOT + AUTH) ---------------------------------------------------
@router.post("/servers", response_model=Server)
def create_server(payload: ServerCreate) -> Dict[str, Any]:
"""AUTH stage: provision x25519 keys + UUID + shortIds, then persist."""
_ensure_db()
try:
identity = xray.auth_provision_identity(short_id_count=payload.short_id_count)
except xray.XrayError as exc:
raise HTTPException(status_code=502, detail=f"xray x25519 failed: {exc}") from exc
server = {
"id": str(_uuid.uuid4()),
"remark": payload.remark,
"private_key": identity["private_key"],
"public_key": identity["public_key"],
"uuid": identity["uuid"],
"dest": payload.dest,
"sni": payload.sni,
"listen_port": payload.listen_port,
"dest_port": payload.dest_port,
"flow": payload.flow,
"short_ids": identity["short_ids"],
"status": "new",
}
return store.create_server(server)
@router.get("/servers", response_model=List[Server])
def list_servers() -> List[Dict[str, Any]]:
_ensure_db()
return store.list_servers()
@router.get("/servers/{server_id}", response_model=Server)
def get_server(server_id: str) -> Dict[str, Any]:
return _require_server(server_id)
@router.delete("/servers/{server_id}")
def delete_server(server_id: str) -> Dict[str, str]:
_require_server(server_id)
# Best-effort: stop the running instance before dropping its state.
service.stop_instance(server_id)
service.disable_instance(server_id)
store.delete_server(server_id)
return {"status": "deleted", "id": server_id}
# --- MIND: target validation --------------------------------------------
@router.post("/validate-target")
def validate_target(payload: ValidateTargetRequest) -> Dict[str, Any]:
try:
return validator.validate_target(payload.host, payload.port)
except validator.ValidatorError as exc:
raise HTTPException(status_code=502, detail=str(exc)) from exc
# --- BOOT: apply (write config + start instance) ----------------------------
@router.post("/apply")
def apply_server(payload: ApplyRequest, force: bool = Query(False)) -> Dict[str, Any]:
srv = _require_server(payload.server_id)
validation = None
try:
validation = validator.validate_target(srv["dest"], srv["dest_port"])
validator.refuse_apply_if_insane(validation, force=force)
except validator.ValidatorError as exc:
raise HTTPException(status_code=502, detail=str(exc)) from exc
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
clients = store.list_clients(server_id=srv["id"])
config = xray.build_server_config(srv, clients=clients)
try:
status_snapshot = service.boot_provision(srv["id"], config)
except service.ServiceError as exc:
raise HTTPException(status_code=502, detail=str(exc)) from exc
new_status = "running" if status_snapshot["running"] else "applied"
store.update_server_status(srv["id"], new_status)
return {"server": store.get_server(srv["id"]), "validation": validation, "instance": status_snapshot}
# --- clients (ROOT) ----------------------------------------------------
@router.post("/clients", response_model=Client)
def create_client(payload: ClientCreate) -> Dict[str, Any]:
_require_server(payload.server_id)
client = {
"id": str(_uuid.uuid4()),
"server_id": payload.server_id,
"uuid": xray.generate_uuid(),
"email": payload.email,
"short_id": xray.generate_short_ids(1)[0],
"flow": payload.flow,
}
return store.create_client(client)
@router.get("/clients", response_model=List[Client])
def list_clients(server_id: Optional[str] = Query(None)) -> List[Dict[str, Any]]:
_ensure_db()
return store.list_clients(server_id=server_id)
@router.delete("/clients/{client_id}")
def delete_client(client_id: str) -> Dict[str, str]:
_ensure_db()
client = store.get_client(client_id)
if client is None:
raise HTTPException(status_code=404, detail=f"client {client_id!r} not found")
store.delete_client(client_id)
return {"status": "deleted", "id": client_id}
# --- link + QR ---------------------------------------------------------
@router.get("/link")
def get_link(client_id: str = Query(...)) -> Dict[str, Any]:
_ensure_db()
client = store.get_client(client_id)
if client is None:
raise HTTPException(status_code=404, detail=f"client {client_id!r} not found")
srv = _require_server(client["server_id"])
uri = xray.build_vless_uri(
uuid_value=client["uuid"],
host=srv["sni"],
port=srv["listen_port"],
public_key=srv["public_key"],
short_id=client["short_id"],
sni=srv["sni"],
flow=client.get("flow") or srv.get("flow") or "xtls-rprx-vision",
remark=f"{srv['remark']}-{client['email']}",
)
try:
qr_svg = xray.generate_qr_svg(uri)
except ImportError:
qr_svg = None
return {"uri": uri, "qr_svg": qr_svg}
# --- MIND: volume guard --------------------------------------------------
@router.get("/guard", response_model=GuardConfig)
def get_guard() -> GuardConfig:
return monitor.load_guard_config()
@router.put("/guard", response_model=GuardConfig)
def put_guard(payload: GuardConfig) -> GuardConfig:
monitor.save_guard_config(payload)
return payload
@router.get("/stats")
def get_stats(server_id: Optional[str] = Query(None)) -> Dict[str, Any]:
_ensure_db()
guard = monitor.load_guard_config()
if server_id:
_require_server(server_id)
monitor.sample_server(server_id)
return monitor.check_volume_guard(server_id, guard)
return {"servers": monitor.run_all(guard)}

View File

@ -0,0 +1,109 @@
# SPDX-License-Identifier: LicenseRef-CMSD-1.0
# Copyright (c) 2026 CyberMind — Gérald Kerma <devel@cybermind.fr>
# Source-Disclosed License — All rights reserved except as expressly granted.
# See LICENCE-CMSD-1.0.md for terms.
"""
SecuBox-Deb :: Reality BOOT stage: templated instance lifecycle
CyberMind https://cybermind.fr
Each egress point runs as its own hardened systemd instance,
``secubox-xray@<server_id>.service`` (unit template shipped in
``debian/secubox-xray@.service``: ``DynamicUser=yes``, ``ProtectSystem=strict``,
``AmbientCapabilities=CAP_NET_BIND_SERVICE``, ``NoNewPrivileges=yes``).
All functions here shell out to ``systemctl`` and are intentionally
synchronous (plain ``def``) callers in ``router.py`` must NOT ``await``
them directly from an ``async def`` handler; FastAPI offloads plain-``def``
route handlers to a threadpool, which is the supported way to keep these
subprocess calls off the shared event loop.
"""
from __future__ import annotations
import subprocess
from typing import Any, Dict
SYSTEMCTL_BIN = "systemctl"
CONFIG_DIR = "/etc/secubox/reality"
_TIMEOUT = 15
class ServiceError(RuntimeError):
pass
def instance_name(server_id: str) -> str:
return f"secubox-xray@{server_id}"
def config_path(server_id: str) -> str:
return f"{CONFIG_DIR}/{server_id}.json"
def _systemctl(*args: str) -> subprocess.CompletedProcess:
cmd = [SYSTEMCTL_BIN, *args]
try:
return subprocess.run(
cmd, capture_output=True, text=True, timeout=_TIMEOUT, check=False
)
except FileNotFoundError as exc:
raise ServiceError(f"systemctl not found: {exc}") from exc
except subprocess.TimeoutExpired as exc:
raise ServiceError(f"systemctl {' '.join(args)} timed out") from exc
def start_instance(server_id: str) -> bool:
proc = _systemctl("start", instance_name(server_id))
return proc.returncode == 0
def stop_instance(server_id: str) -> bool:
"""Stop the instance WITHOUT disabling/destroying it — the config file,
keys and DB row are all preserved so the egress point can be restarted
later without re-provisioning (used by the volume-guard DISABLE action).
"""
proc = _systemctl("stop", instance_name(server_id))
return proc.returncode == 0
def enable_instance(server_id: str) -> bool:
proc = _systemctl("enable", instance_name(server_id))
return proc.returncode == 0
def disable_instance(server_id: str) -> bool:
proc = _systemctl("disable", instance_name(server_id))
return proc.returncode == 0
def restart_instance(server_id: str) -> bool:
proc = _systemctl("restart", instance_name(server_id))
return proc.returncode == 0
def status_instance(server_id: str) -> Dict[str, Any]:
proc = _systemctl("is-active", instance_name(server_id))
active = proc.stdout.strip()
return {
"unit": instance_name(server_id),
"active": active,
"running": active == "active",
}
# --- BOOT: full provisioning flow -------------------------------------------
def boot_provision(server_id: str, config: Dict[str, Any]) -> Dict[str, Any]:
"""Write the instance's Xray config atomically, then enable + (re)start
the templated unit. Returns the resulting status snapshot.
"""
from . import xray
xray.write_config_atomic(config_path(server_id), config)
enable_instance(server_id)
if not restart_instance(server_id):
# restart on a never-started unit can fail on some systemd versions;
# fall back to a plain start.
start_instance(server_id)
return status_instance(server_id)

View File

@ -0,0 +1,226 @@
# SPDX-License-Identifier: LicenseRef-CMSD-1.0
# Copyright (c) 2026 CyberMind — Gérald Kerma <devel@cybermind.fr>
# Source-Disclosed License — All rights reserved except as expressly granted.
# See LICENCE-CMSD-1.0.md for terms.
"""
SecuBox-Deb :: Reality ROOT stage: persistent state (SQLite WAL)
CyberMind https://cybermind.fr
Every query in this module is parameterized (``?`` placeholders) no string
interpolation of caller-supplied values is ever used to build SQL.
"""
from __future__ import annotations
import json
import os
import sqlite3
import time
from contextlib import contextmanager
from typing import Any, Dict, Iterator, List, Optional
DEFAULT_DB_PATH = "/var/lib/secubox/reality/reality.db"
_SCHEMA = """
CREATE TABLE IF NOT EXISTS servers (
id TEXT PRIMARY KEY,
remark TEXT NOT NULL,
private_key TEXT NOT NULL,
public_key TEXT NOT NULL,
uuid TEXT NOT NULL,
dest TEXT NOT NULL,
sni TEXT NOT NULL,
listen_port INTEGER NOT NULL,
dest_port INTEGER NOT NULL,
flow TEXT NOT NULL DEFAULT 'xtls-rprx-vision',
short_ids TEXT NOT NULL DEFAULT '[]',
status TEXT NOT NULL DEFAULT 'new',
created_at REAL NOT NULL,
updated_at REAL NOT NULL
);
CREATE TABLE IF NOT EXISTS clients (
id TEXT PRIMARY KEY,
server_id TEXT NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
uuid TEXT NOT NULL,
email TEXT NOT NULL,
short_id TEXT NOT NULL,
flow TEXT NOT NULL DEFAULT 'xtls-rprx-vision',
created_at REAL NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_clients_server ON clients(server_id);
CREATE TABLE IF NOT EXISTS stats_samples (
sample_id INTEGER PRIMARY KEY AUTOINCREMENT,
server_id TEXT NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
ts REAL NOT NULL,
uplink INTEGER NOT NULL DEFAULT 0,
downlink INTEGER NOT NULL DEFAULT 0
);
CREATE INDEX IF NOT EXISTS idx_stats_server_ts ON stats_samples(server_id, ts);
"""
def init_db(path: str = DEFAULT_DB_PATH) -> None:
os.makedirs(os.path.dirname(path), exist_ok=True)
with _connect(path) as conn:
conn.executescript(_SCHEMA)
conn.commit()
@contextmanager
def _connect(path: str = DEFAULT_DB_PATH) -> Iterator[sqlite3.Connection]:
conn = sqlite3.connect(path, timeout=10)
conn.row_factory = sqlite3.Row
try:
conn.execute("PRAGMA journal_mode=WAL")
conn.execute("PRAGMA synchronous=NORMAL")
conn.execute("PRAGMA foreign_keys=ON")
yield conn
finally:
conn.close()
def _row_to_server(row: sqlite3.Row) -> Dict[str, Any]:
d = dict(row)
d["short_ids"] = json.loads(d.get("short_ids") or "[]")
return d
# --- servers -----------------------------------------------------------
def create_server(server: Dict[str, Any], path: str = DEFAULT_DB_PATH) -> Dict[str, Any]:
now = time.time()
with _connect(path) as conn:
conn.execute(
"""
INSERT INTO servers
(id, remark, private_key, public_key, uuid, dest, sni,
listen_port, dest_port, flow, short_ids, status, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(
server["id"],
server["remark"],
server["private_key"],
server["public_key"],
server["uuid"],
server["dest"],
server["sni"],
server["listen_port"],
server["dest_port"],
server.get("flow", "xtls-rprx-vision"),
json.dumps(server.get("short_ids", [])),
server.get("status", "new"),
now,
now,
),
)
conn.commit()
return get_server(server["id"], path=path) # type: ignore[return-value]
def get_server(server_id: str, path: str = DEFAULT_DB_PATH) -> Optional[Dict[str, Any]]:
with _connect(path) as conn:
row = conn.execute("SELECT * FROM servers WHERE id = ?", (server_id,)).fetchone()
return _row_to_server(row) if row else None
def list_servers(path: str = DEFAULT_DB_PATH) -> List[Dict[str, Any]]:
with _connect(path) as conn:
rows = conn.execute("SELECT * FROM servers ORDER BY created_at DESC").fetchall()
return [_row_to_server(r) for r in rows]
def update_server_status(server_id: str, status: str, path: str = DEFAULT_DB_PATH) -> None:
with _connect(path) as conn:
conn.execute(
"UPDATE servers SET status = ?, updated_at = ? WHERE id = ?",
(status, time.time(), server_id),
)
conn.commit()
def delete_server(server_id: str, path: str = DEFAULT_DB_PATH) -> None:
with _connect(path) as conn:
conn.execute("DELETE FROM servers WHERE id = ?", (server_id,))
conn.commit()
# --- clients -------------------------------------------------------------
def create_client(client: Dict[str, Any], path: str = DEFAULT_DB_PATH) -> Dict[str, Any]:
now = time.time()
with _connect(path) as conn:
conn.execute(
"""
INSERT INTO clients (id, server_id, uuid, email, short_id, flow, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?)
""",
(
client["id"],
client["server_id"],
client["uuid"],
client["email"],
client["short_id"],
client.get("flow", "xtls-rprx-vision"),
now,
),
)
conn.commit()
return get_client(client["id"], path=path) # type: ignore[return-value]
def get_client(client_id: str, path: str = DEFAULT_DB_PATH) -> Optional[Dict[str, Any]]:
with _connect(path) as conn:
row = conn.execute("SELECT * FROM clients WHERE id = ?", (client_id,)).fetchone()
return dict(row) if row else None
def list_clients(server_id: Optional[str] = None, path: str = DEFAULT_DB_PATH) -> List[Dict[str, Any]]:
with _connect(path) as conn:
if server_id:
rows = conn.execute(
"SELECT * FROM clients WHERE server_id = ? ORDER BY created_at DESC", (server_id,)
).fetchall()
else:
rows = conn.execute("SELECT * FROM clients ORDER BY created_at DESC").fetchall()
return [dict(r) for r in rows]
def delete_client(client_id: str, path: str = DEFAULT_DB_PATH) -> None:
with _connect(path) as conn:
conn.execute("DELETE FROM clients WHERE id = ?", (client_id,))
conn.commit()
# --- stats samples ---------------------------------------------------------
def insert_stats_sample(server_id: str, uplink: int, downlink: int, path: str = DEFAULT_DB_PATH) -> None:
with _connect(path) as conn:
conn.execute(
"INSERT INTO stats_samples (server_id, ts, uplink, downlink) VALUES (?, ?, ?, ?)",
(server_id, time.time(), uplink, downlink),
)
conn.commit()
def get_stats_window(server_id: str, since_ts: float, path: str = DEFAULT_DB_PATH) -> List[Dict[str, Any]]:
with _connect(path) as conn:
rows = conn.execute(
"SELECT ts, uplink, downlink FROM stats_samples "
"WHERE server_id = ? AND ts >= ? ORDER BY ts ASC",
(server_id, since_ts),
).fetchall()
return [dict(r) for r in rows]
def prune_stats_older_than(cutoff_ts: float, path: str = DEFAULT_DB_PATH) -> int:
"""Housekeeping: drop samples older than ``cutoff_ts``. Returns row count deleted."""
with _connect(path) as conn:
cur = conn.execute("DELETE FROM stats_samples WHERE ts < ?", (cutoff_ts,))
conn.commit()
return cur.rowcount

View File

@ -0,0 +1,142 @@
# SPDX-License-Identifier: LicenseRef-CMSD-1.0
# Copyright (c) 2026 CyberMind — Gérald Kerma <devel@cybermind.fr>
# Source-Disclosed License — All rights reserved except as expressly granted.
# See LICENCE-CMSD-1.0.md for terms.
"""
SecuBox-Deb :: Reality target sanity validation (MIND stage)
CyberMind https://cybermind.fr
A Reality ``dest``/SNI camouflage target must itself be a real TLS 1.3 +
X25519 site otherwise the "reality" of the handshake collapses and the
egress point is trivially fingerprintable. ``validate_target`` shells out to
``openssl s_client`` to probe this before ``router.py``'s ``/apply`` endpoint
is allowed to provision anything against it (unless the caller forces it).
"""
from __future__ import annotations
import re
import subprocess
from typing import Any, Dict, List
OPENSSL_BIN = "openssl"
_PROBE_TIMEOUT = 12
# OpenSSL has printed the negotiated protocol in at least two output shapes
# across versions/configs: the classic "SSL-Session:" dump ("Protocol :
# TLSv1.3") and the terser one-line handshake summary OpenSSL 3.x prints by
# default ("New, TLSv1.3, Cipher is ..."). Match either defensively.
_TLS13_RE = re.compile(r"(?:Protocol\s*:\s*TLSv1\.3|New,\s*TLSv1\.3\s*,)", re.IGNORECASE)
_X25519_TEMP_KEY_RE = re.compile(r"Server Temp Key:\s*X25519", re.IGNORECASE)
_ALPN_RE = re.compile(r"ALPN protocol:\s*(\S+)", re.IGNORECASE)
class ValidatorError(RuntimeError):
"""Raised only on hard failures to invoke openssl itself."""
def _run_probe(host: str, port: int, timeout: int = _PROBE_TIMEOUT) -> Dict[str, Any]:
"""Run one openssl s_client TLS1.3/X25519 probe against host:port.
Never raises for network-level failures (refused, timeout, TLS alert)
those are reported as ``reachable=False`` in the returned dict. Only a
missing/broken openssl binary raises ``ValidatorError``.
"""
cmd = [
OPENSSL_BIN,
"s_client",
"-tls1_3",
"-groups",
"X25519",
"-alpn",
"h2,http/1.1",
"-servername",
host,
"-connect",
f"{host}:{port}",
]
try:
proc = subprocess.run(
cmd,
input="",
capture_output=True,
text=True,
timeout=timeout,
check=False,
)
except FileNotFoundError as exc:
raise ValidatorError(f"openssl binary not found: {OPENSSL_BIN}") from exc
except subprocess.TimeoutExpired:
return {"reachable": False, "raw": "", "reason": "timeout"}
output = f"{proc.stdout}\n{proc.stderr}"
# A non-zero exit paired with a BIO/connect error means the TCP/TLS
# handshake itself never happened (refused, no route, reset) — that is
# a reachability failure, distinct from "connected but not TLS1.3".
connect_failed = proc.returncode != 0 and re.search(
r"connect:errno|BIO_connect|Connection refused|Connection timed out|"
r"No route to host|Name or service not known",
output,
re.IGNORECASE,
)
if connect_failed:
return {"reachable": False, "raw": output, "reason": "connection failed"}
return {"reachable": True, "raw": output, "returncode": proc.returncode}
def validate_target(host: str, port: int = 443) -> Dict[str, Any]:
"""Probe ``host:port`` and report TLS1.3 / X25519 / ALPN / reachability.
Returns a dict with keys: ``ok``, ``reachable``, ``tls13``, ``x25519``,
``alpn`` (list[str]), ``reason`` (set when ``ok`` is False).
"""
probe = _run_probe(host, port)
if not probe["reachable"]:
return {
"ok": False,
"reachable": False,
"tls13": False,
"x25519": False,
"alpn": [],
"reason": probe.get("reason", "unreachable"),
}
raw = probe["raw"]
tls13 = bool(_TLS13_RE.search(raw))
x25519 = bool(_X25519_TEMP_KEY_RE.search(raw))
alpn: List[str] = sorted(set(_ALPN_RE.findall(raw)))
ok = tls13 and x25519
result: Dict[str, Any] = {
"ok": ok,
"reachable": True,
"tls13": tls13,
"x25519": x25519,
"alpn": alpn,
}
if not ok:
missing = []
if not tls13:
missing.append("TLS1.3")
if not x25519:
missing.append("X25519 temp key")
result["reason"] = f"target does not offer {', '.join(missing)}"
return result
# --- MIND: apply gate ---------------------------------------------------
def refuse_apply_if_insane(validation: Dict[str, Any], force: bool = False) -> None:
"""Raise ``ValueError`` if ``validation`` says the target is not sane,
unless ``force`` is set (operator override, still logged by the caller).
"""
if force:
return
if not validation.get("ok"):
raise ValueError(
"Reality dest/SNI failed sanity validation: "
f"{validation.get('reason', 'unknown reason')} "
"(pass force=true to override)"
)

View File

@ -0,0 +1,341 @@
# SPDX-License-Identifier: LicenseRef-CMSD-1.0
# Copyright (c) 2026 CyberMind — Gérald Kerma <devel@cybermind.fr>
# Source-Disclosed License — All rights reserved except as expressly granted.
# See LICENCE-CMSD-1.0.md for terms.
"""
SecuBox-Deb :: Reality Xray-core config/identity generation
CyberMind https://cybermind.fr
Hamiltonian hooks implemented in this file:
AUTH auth_provision_identity() x25519 keypair + UUID + shortIds
WALL wall_build_routing_rules() split-tunnel rules + optional shaping
MESH mesh_build_stream_settings() Reality transport (streamSettings)
``build_server_config`` composes the three into a full Xray JSON config that
also wires a loopback ``dokodemo-door`` API inbound + ``StatsService`` so
``monitor.py`` can query cumulative traffic counters (see #AUTH/#MESH below).
"""
from __future__ import annotations
import json
import os
import re
import secrets
import subprocess
import uuid as _uuid
from typing import Any, Dict, List, Optional
# --- Compat constants (kept stable across xray-core versions) --------------
NETWORK_KEY = "raw"
TARGET_KEY = "target"
LINK_TYPE = "tcp"
# Loopback API inbound used by monitor.py for StatsService queries.
API_LISTEN = "127.0.0.1"
API_PORT = 10085
API_TAG = "api"
XRAY_BIN = "xray"
_KEYPAIR_TIMEOUT = 10
class XrayError(RuntimeError):
"""Raised when xray-core cannot be invoked or its output is unusable."""
# --- AUTH: identity provisioning --------------------------------------------
def _parse_x25519_output(raw: str) -> Dict[str, str]:
"""Defensively parse ``xray x25519`` output across known variants.
Observed variants across xray-core releases:
- "Private key: <b64>" / "Public key: <b64>" (older releases)
- "PrivateKey: <b64>" / "Password: <b64>" (newer releases,
"Password" is xray's label for the derived public key)
Any unrecognized line is ignored rather than raising we only require
that *both* a private and a public value were found somewhere.
"""
private_key: Optional[str] = None
public_key: Optional[str] = None
for line in raw.splitlines():
line = line.strip()
if not line or ":" not in line:
continue
label, _, value = line.partition(":")
label_norm = re.sub(r"[^a-z]", "", label.strip().lower())
value = value.strip()
if not value:
continue
if label_norm in ("privatekey",):
private_key = value
elif label_norm in ("publickey", "password"):
public_key = value
if not private_key or not public_key:
raise XrayError(
"unable to parse 'xray x25519' output "
f"(private={'set' if private_key else 'missing'}, "
f"public={'set' if public_key else 'missing'}); raw={raw!r}"
)
return {"private_key": private_key, "public_key": public_key}
def generate_x25519_keypair(xray_bin: str = XRAY_BIN) -> Dict[str, str]:
"""Invoke ``xray x25519`` and defensively parse its stdout.
Never raises on unexpected *formatting* only on a hard subprocess
failure (binary missing / non-zero exit) or output that truly contains
neither key.
"""
try:
proc = subprocess.run(
[xray_bin, "x25519"],
capture_output=True,
text=True,
timeout=_KEYPAIR_TIMEOUT,
check=False,
)
except FileNotFoundError as exc:
raise XrayError(f"xray binary not found: {xray_bin}") from exc
except subprocess.TimeoutExpired as exc:
raise XrayError("xray x25519 timed out") from exc
if proc.returncode != 0:
raise XrayError(f"xray x25519 exited {proc.returncode}: {proc.stderr.strip()}")
return _parse_x25519_output(proc.stdout)
def generate_uuid() -> str:
return str(_uuid.uuid4())
def generate_short_ids(count: int = 1, length: int = 8) -> List[str]:
"""Generate ``count`` shortIds as even-length lowercase hex strings.
Reality shortIds are matched byte-pairwise by clients, so an odd hex
length is invalid ``length`` is forced to the nearest even number.
"""
if length % 2 != 0:
length += 1
nbytes = length // 2
return [secrets.token_hex(nbytes) for _ in range(max(1, count))]
def auth_provision_identity(short_id_count: int = 1, xray_bin: str = XRAY_BIN) -> Dict[str, Any]:
"""AUTH stage entry point: emit a full identity bundle for a new server."""
keys = generate_x25519_keypair(xray_bin=xray_bin)
return {
"private_key": keys["private_key"],
"public_key": keys["public_key"],
"uuid": generate_uuid(),
"short_ids": generate_short_ids(short_id_count),
}
# --- WALL: split-tunnel routing + shaping -----------------------------------
def wall_build_routing_rules(
api_inbound_tag: str = API_TAG,
shaping: Optional[Dict[str, Any]] = None,
) -> Dict[str, Any]:
"""Build the ``routing`` block: API traffic stays local, everything else
egresses via the ``direct`` outbound. ``shaping`` (optional) feeds a
bandwidth-limited outbound tag consumers may route specific domains to.
"""
rules: List[Dict[str, Any]] = [
{
"type": "field",
"inboundTag": [api_inbound_tag],
"outboundTag": api_inbound_tag,
},
]
if shaping and shaping.get("domains"):
rules.append(
{
"type": "field",
"domain": list(shaping["domains"]),
"outboundTag": shaping.get("outbound_tag", "shaped"),
}
)
return {"domainStrategy": "AsIs", "rules": rules}
# --- MESH: transport (Reality streamSettings) -------------------------------
def mesh_build_stream_settings(
private_key: str,
short_ids: List[str],
dest: str,
dest_port: int,
sni: str,
) -> Dict[str, Any]:
"""Reality streamSettings block for the VLESS inbound."""
return {
"network": NETWORK_KEY,
"security": "reality",
"realitySettings": {
"show": False,
TARGET_KEY: f"{dest}:{dest_port}",
"xver": 0,
"serverNames": [sni],
"privateKey": private_key,
"shortIds": short_ids,
},
}
# --- Full server config composition -----------------------------------------
def build_server_config(server: Dict[str, Any], clients: Optional[List[Dict[str, Any]]] = None) -> Dict[str, Any]:
"""Compose a full Xray-core JSON config for one egress point.
``server`` is a plain dict shaped like ``models.Server`` (or the dict
returned by ``store.get_server``). ``clients`` is a list of dicts shaped
like ``models.Client``; if empty, a single client is derived from the
server's own identity so the egress point is immediately usable.
"""
clients = clients or []
vless_clients = [
{
"id": c["uuid"],
"email": c.get("email", server["remark"]),
"flow": c.get("flow") or server.get("flow") or "xtls-rprx-vision",
}
for c in clients
] or [
{
"id": server["uuid"],
"email": server["remark"],
"flow": server.get("flow") or "xtls-rprx-vision",
}
]
inbounds = [
{
"tag": "reality-in",
"listen": "0.0.0.0",
"port": server["listen_port"],
"protocol": "vless",
"settings": {
"clients": vless_clients,
"decryption": "none",
},
"streamSettings": mesh_build_stream_settings(
private_key=server["private_key"],
short_ids=server["short_ids"],
dest=server["dest"],
dest_port=server["dest_port"],
sni=server["sni"],
),
},
# Loopback stats API inbound (dokodemo-door) — never exposed off-box.
{
"tag": API_TAG,
"listen": API_LISTEN,
"port": API_PORT,
"protocol": "dokodemo-door",
"settings": {"address": API_LISTEN},
},
]
outbounds = [
{"tag": "direct", "protocol": "freedom", "settings": {}},
{"tag": "blocked", "protocol": "blackhole", "settings": {}},
]
config: Dict[str, Any] = {
"log": {"loglevel": "warning"},
"stats": {},
"api": {
"tag": API_TAG,
"listen": f"{API_LISTEN}:{API_PORT}",
"services": ["StatsService"],
},
"policy": {
"levels": {"0": {"statsUserUplink": True, "statsUserDownlink": True}},
"system": {"statsInboundUplink": True, "statsInboundDownlink": True},
},
"inbounds": inbounds,
"outbounds": outbounds,
"routing": wall_build_routing_rules(api_inbound_tag=API_TAG, shaping=server.get("shaping")),
}
return config
def write_config_atomic(path: str, config: Dict[str, Any]) -> None:
"""Write ``config`` as JSON to ``path`` atomically (tempfile + os.replace)."""
directory = os.path.dirname(path) or "."
os.makedirs(directory, exist_ok=True)
fd, tmp_path = None, None
try:
import tempfile
fd, tmp_path = tempfile.mkstemp(prefix=".reality-", suffix=".json.tmp", dir=directory)
with os.fdopen(fd, "w") as fh:
fd = None # fdopen took ownership
json.dump(config, fh, indent=2, sort_keys=True)
fh.write("\n")
fh.flush()
os.fsync(fh.fileno())
os.replace(tmp_path, path)
tmp_path = None
finally:
if fd is not None:
os.close(fd)
if tmp_path is not None and os.path.exists(tmp_path):
os.unlink(tmp_path)
# --- Client link (vless:// URI) + QR ----------------------------------------
def build_vless_uri(
uuid_value: str,
host: str,
port: int,
public_key: str,
short_id: str,
sni: str,
flow: str = "xtls-rprx-vision",
remark: str = "secubox-reality",
) -> str:
"""Build a ``vless://`` URI, keeping ``pbk=`` and ``type=tcp`` intact
per the compat constants (``LINK_TYPE``)."""
from urllib.parse import quote, urlencode
params = {
"type": LINK_TYPE,
"security": "reality",
"pbk": public_key,
"fp": "chrome",
"sni": sni,
"sid": short_id,
"flow": flow,
}
query = urlencode(params, safe=",")
return f"vless://{uuid_value}@{host}:{port}?{query}#{quote(remark)}"
def generate_qr_svg(uri: str) -> str:
"""Render ``uri`` as an SVG QR code using the ``segno`` library.
Imported lazily so the rest of this module (config generation, identity
provisioning) works without ``segno`` installed e.g. under unit tests
or ``py_compile`` sanity checks on a bare interpreter.
"""
import io
import segno
qr = segno.make(uri, error="m")
buf = io.BytesIO()
qr.save(buf, kind="svg", scale=4, dark="#104A88", xmldecl=False)
return buf.getvalue().decode("utf-8")

View File

@ -0,0 +1,550 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>SecuBox - Reality</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=Courier+Prime:wght@400;700&display=swap" rel="stylesheet">
<link rel="stylesheet" href="/shared/crt-light.css">
<link rel="stylesheet" href="/shared/sidebar-light.css">
<style>
:root {
/* P31 Phosphor spectrum */
--p31-peak: #00dd44;
--p31-hot: #00ff55;
--p31-mid: #009933;
--p31-dim: #006622;
--p31-ghost: #003311;
--p31-decay: #ffb347;
--p31-decay-dim: #cc7722;
/* Tube glass */
--tube-light: #e8f5e9;
--tube-pale: #c8e6c9;
--tube-soft: #a5d6a7;
/* Legacy mappings */
--bg-dark: var(--tube-light);
--bg-card: var(--tube-pale);
--bg-sidebar: var(--tube-black);
--border: var(--tube-soft);
--text: var(--tube-dark);
--text-dim: var(--p31-dim);
--primary: var(--p31-peak);
--cyan: var(--p31-peak);
--green: var(--p31-peak);
--red: #ff4466;
--yellow: var(--p31-decay);
--orange: var(--p31-decay-dim);
--purple: #a371f7;
/* Bloom effects */
--bloom-text: 0 0 2px var(--p31-peak), 0 0 6px var(--p31-peak), 0 0 14px rgba(51,255,102,0.5);
--bloom-soft: 0 0 6px var(--p31-peak), 0 0 14px rgba(51,255,102,0.5);
--bloom-amber: 0 0 3px var(--p31-decay), 0 0 10px rgba(255,179,71,0.4);
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: 'Courier Prime', 'Courier New', monospace;
background: var(--tube-light);
background-image: radial-gradient(ellipse at 50% 40%, rgba(51,255,102,0.025) 0%, transparent 70%);
color: var(--tube-dark);
display: flex;
min-height: 100vh;
font-size: 14px;
line-height: 1.6;
}
code, .mono, input, textarea, select, button, table { font-family: 'Courier Prime', 'Courier New', monospace; }
.main { flex: 1; margin-left: 220px; padding: 1.5rem; min-width: 0; }
.header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 1.25rem; flex-wrap: wrap; gap: 0.75rem; }
.header h1 {
font-size: 1.4rem; font-weight: 700; letter-spacing: 0.05em;
color: var(--p31-hot); text-shadow: var(--bloom-text);
display: flex; align-items: center; gap: 0.5rem;
}
.header .desc { font-size: 0.8rem; color: var(--text-dim); margin-top: 0.2rem; }
#errbar { display: none; background: rgba(255,68,102,0.1); border: 1px solid var(--red); color: var(--red); padding: 0.6rem 1rem; border-radius: 8px; margin-bottom: 1rem; font-size: 0.8rem; }
#errbar.show { display: block; }
.card {
background: var(--tube-pale); border: 1px solid var(--tube-soft); border-radius: 8px;
padding: 1.25rem 1.4rem; margin-bottom: 1.25rem;
box-shadow: inset 0 0 20px rgba(0,0,0,0.3);
}
.card:hover { border-color: var(--p31-dim); box-shadow: 0 0 8px rgba(51,255,102,0.05), inset 0 0 20px rgba(0,0,0,0.3); }
.card h2 {
font-size: 0.85rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.1em;
color: var(--p31-decay); text-shadow: var(--bloom-amber);
margin-bottom: 1rem; display: flex; align-items: center; gap: 0.5rem;
}
.grid2 { display: grid; grid-template-columns: 1fr 1fr; gap: 1.25rem; }
@media (max-width: 900px) { .grid2 { grid-template-columns: 1fr; } }
@media (max-width: 768px) { .main { margin-left: 0; } }
label { display: block; font-size: 0.7rem; color: var(--text-dim); text-transform: uppercase; letter-spacing: 0.08em; margin: 0.6rem 0 0.25rem; }
input, select {
width: 100%; padding: 0.45rem 0.6rem; border-radius: 6px; font-size: 0.85rem;
font-family: 'Courier Prime', monospace;
background: var(--tube-light); border: 1px solid var(--tube-soft); color: var(--p31-mid);
}
input:focus, select:focus { outline: none; border-color: var(--p31-dim); box-shadow: 0 0 8px rgba(51,255,102,0.1); }
.btn {
padding: 0.5rem 1rem; border-radius: 6px; cursor: pointer; font-size: 0.8rem;
font-family: 'Courier Prime', monospace; letter-spacing: 0.06em; text-transform: uppercase;
border: 1px solid var(--tube-soft); background: var(--tube-pale); color: var(--p31-mid);
transition: all 0.2s;
}
.btn:hover { border-color: var(--p31-dim); color: var(--p31-peak); text-shadow: var(--bloom-soft); box-shadow: 0 0 12px rgba(51,255,102,0.1); }
.btn.primary { border-color: var(--p31-peak); color: var(--p31-peak); background: rgba(51,255,102,0.1); }
.btn.primary:hover { background: rgba(51,255,102,0.2); box-shadow: 0 0 16px rgba(51,255,102,0.2); color: var(--p31-peak); }
.btn.danger { border-color: var(--red); color: var(--red); background: transparent; }
.btn.danger:hover { background: rgba(255,68,102,0.15); }
.btn.small { padding: 0.3rem 0.6rem; font-size: 0.7rem; }
.actions { display: flex; gap: 0.5rem; margin-top: 0.75rem; flex-wrap: wrap; align-items: center; }
table { width: 100%; border-collapse: collapse; font-size: 12px; font-family: 'Courier Prime', monospace; }
th, td { padding: 0.5rem 0.6rem; text-align: left; border-bottom: 1px solid var(--p31-ghost); vertical-align: top; }
th { color: var(--p31-decay); text-shadow: var(--bloom-amber); font-size: 10px; text-transform: uppercase; letter-spacing: 0.1em; }
td { color: var(--p31-mid); }
tr:hover { background: rgba(51,255,102,0.03); }
.mono-cell { font-size: 11px; word-break: break-all; }
.empty { color: var(--p31-dim); font-style: italic; padding: 0.6rem 0; font-size: 0.8rem; }
.pill { display: inline-block; padding: 0.15rem 0.55rem; border-radius: 999px; font-size: 10px; font-weight: 700; letter-spacing: 0.06em; text-transform: uppercase; border: 1px solid; }
.pill.green { background: rgba(51,255,102,0.12); border-color: var(--p31-peak); color: var(--p31-peak); text-shadow: var(--bloom-soft); }
.pill.yellow { background: rgba(255,179,71,0.12); border-color: var(--p31-decay); color: var(--p31-decay-dim); }
.pill.red { background: rgba(255,68,102,0.12); border-color: var(--red); color: var(--red); }
.pill.blue { background: rgba(51,255,102,0.08); border-color: var(--p31-mid); color: var(--p31-mid); }
.pill.gray { background: rgba(0,102,34,0.08); border-color: var(--p31-ghost); color: var(--p31-dim); }
.qr-box { display: flex; align-items: center; gap: 1rem; flex-wrap: wrap; margin-top: 0.75rem; }
.qr-box svg { width: 160px; height: 160px; border: 1px solid var(--tube-soft); border-radius: 8px; background: #fff; }
.uri-box { flex: 1; min-width: 240px; word-break: break-all; font-size: 11px; background: var(--tube-light); border: 1px solid var(--tube-soft); border-radius: 6px; padding: 0.6rem; color: var(--p31-mid); }
.stat-row { display: grid; grid-template-columns: repeat(4, 1fr); gap: 0.75rem; margin-top: 0.75rem; }
.stat { background: var(--tube-light); border: 1px solid var(--tube-soft); border-radius: 8px; padding: 0.75rem 0.9rem; }
.stat .l { font-size: 10px; text-transform: uppercase; letter-spacing: 0.1em; color: var(--p31-dim); }
.stat .v { font-size: 18px; font-weight: 700; color: var(--p31-peak); text-shadow: 0 0 10px currentColor; margin-top: 0.2rem; }
@media (max-width: 700px) { .stat-row { grid-template-columns: repeat(2, 1fr); } }
/* Scrollbar */
::-webkit-scrollbar { width: 6px; height: 6px; }
::-webkit-scrollbar-track { background: var(--tube-light); }
::-webkit-scrollbar-thumb { background: var(--p31-dim); }
::-webkit-scrollbar-thumb:hover { background: var(--p31-mid); }
</style>
</head>
<body class="crt-light">
<nav class="sidebar" id="sidebar"></nav>
<script src="/shared/sidebar.js"></script>
<main class="main">
<div class="header">
<div>
<h1>🕶️ Reality egress manager</h1>
<div class="desc">Provision, validate and monitor VLESS+Reality+Vision (Xray-core) egress points.</div>
</div>
<button class="btn" id="refreshAll">↻ Refresh</button>
</div>
<div id="errbar"></div>
<!-- server list + create -->
<section class="card" id="servers">
<h2>Egress points</h2>
<table id="serversTable">
<thead><tr><th>Remark</th><th>dest / SNI</th><th>Port</th><th>Status</th><th>Public key</th><th></th></tr></thead>
<tbody></tbody>
</table>
<div class="empty" id="serversEmpty" style="display:none;">No egress point provisioned yet.</div>
<h2 style="margin-top:1.5rem;">Provision new server</h2>
<form id="createServerForm">
<div class="grid2">
<div>
<label>Remark</label>
<input name="remark" required maxlength="128" placeholder="edge-eu-1">
<label>Reality dest (camouflage target)</label>
<input name="dest" required placeholder="www.microsoft.com">
<label>SNI</label>
<input name="sni" required placeholder="www.microsoft.com">
</div>
<div>
<label>Listen port</label>
<input name="listen_port" type="number" value="443" min="1" max="65535">
<label>Dest port</label>
<input name="dest_port" type="number" value="443" min="1" max="65535">
<label>shortId count</label>
<input name="short_id_count" type="number" value="1" min="1" max="8">
</div>
</div>
<div class="actions"><button class="btn primary" type="submit">Provision (keys + UUID)</button></div>
</form>
</section>
<!-- validate target -->
<section class="card" id="validate">
<h2>Validate target (TLS1.3 + X25519 sanity gate)</h2>
<form id="validateForm">
<div class="grid2">
<div><label>Host</label><input name="host" required placeholder="www.microsoft.com"></div>
<div><label>Port</label><input name="port" type="number" value="443" min="1" max="65535"></div>
</div>
<div class="actions"><button class="btn" type="submit">Validate</button></div>
</form>
<div id="validateResult" style="margin-top:0.75rem;"></div>
</section>
<!-- apply + clients + link -->
<section class="card" id="apply">
<h2>Apply (write config + start instance)</h2>
<div class="grid2">
<div>
<label>Server</label>
<select id="applyServerSelect"></select>
<label><input type="checkbox" id="applyForce" style="width:auto;display:inline-block;"> force (skip target sanity gate)</label>
</div>
<div style="align-self:end;">
<div class="actions"><button class="btn primary" id="applyBtn" type="button">Apply</button></div>
</div>
</div>
<div id="applyResult" style="margin-top:0.75rem;"></div>
</section>
<section class="card" id="clients">
<h2>Clients + link</h2>
<div class="grid2">
<div>
<label>Server</label>
<select id="clientServerSelect"></select>
<label>Client email / remark</label>
<input id="clientEmail" placeholder="alice@example.com">
<div class="actions"><button class="btn" id="createClientBtn" type="button">Add client</button></div>
</div>
<div>
<table id="clientsTable">
<thead><tr><th>Email</th><th>shortId</th><th></th></tr></thead>
<tbody></tbody>
</table>
<div class="empty" id="clientsEmpty" style="display:none;">No clients for this server.</div>
</div>
</div>
<div class="qr-box" id="linkBox" style="display:none;">
<div id="qrHolder"></div>
<div class="uri-box" id="uriHolder"></div>
</div>
</section>
<!-- volume guard -->
<section class="card" id="guard">
<h2>Volume guard (sliding window)</h2>
<form id="guardForm">
<div class="grid2">
<div>
<label>Window (hours)</label>
<input name="window_hours" type="number" min="1" value="48">
<label>Soft limit (GB)</label>
<input name="soft_gb" type="number" min="0" step="0.1" value="80">
</div>
<div>
<label>Hard limit (GB, action = disable)</label>
<input name="hard_gb" type="number" min="0" step="0.1" value="100">
</div>
</div>
<div class="actions"><button class="btn primary" type="submit">Save guard config</button></div>
</form>
</section>
<section class="card" id="stats">
<h2>Stats (per-window delta)</h2>
<div id="statsBox" class="empty">No stats yet — click Refresh.</div>
</section>
</main>
<script>
(function () {
'use strict';
var API_BASE = '/api/mesh/reality';
function esc(s) {
if (s === null || s === undefined) return '';
return String(s).replace(/[&<>"']/g, function (c) {
return { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c];
});
}
function showError(msg) {
var bar = document.getElementById('errbar');
bar.textContent = msg;
bar.classList.add('show');
setTimeout(function () { bar.classList.remove('show'); }, 6000);
}
// Fail-safe fetch: never throws to the caller's caller; always resolves
// to {ok, status, data} so UI code can render a graceful error instead
// of a blank/broken panel.
function api(path, opts) {
opts = opts || {};
var token = window.localStorage.getItem('sbx_token') || '';
var headers = Object.assign({ 'Content-Type': 'application/json' }, opts.headers || {});
if (token) headers['Authorization'] = 'Bearer ' + token;
return fetch(API_BASE + path, {
method: opts.method || 'GET',
headers: headers,
body: opts.body ? JSON.stringify(opts.body) : undefined,
credentials: 'same-origin',
}).then(function (resp) {
if (resp.status === 401) {
window.location.href = '/login.html?redirect=' + encodeURIComponent(window.location.pathname);
return { ok: false, status: 401, data: null };
}
return resp.text().then(function (text) {
var data = null;
try { data = text ? JSON.parse(text) : null; } catch (e) { /* non-JSON body */ }
return { ok: resp.ok, status: resp.status, data: data };
});
}).catch(function (err) {
showError('Network error: ' + err.message);
return { ok: false, status: 0, data: null };
});
}
function statusPill(status) {
var cls = 'gray';
if (status === 'running') cls = 'green';
else if (status === 'applied') cls = 'blue';
else if (status === 'disabled_volume_guard') cls = 'red';
else if (status === 'new') cls = 'yellow';
return '<span class="pill ' + cls + '">' + esc(status) + '</span>';
}
var STATE = { servers: [], selectedClientServer: null };
function loadServers() {
return api('/servers').then(function (r) {
if (!r.ok || !r.data) { showError('Failed to load servers'); return; }
STATE.servers = r.data;
renderServers();
renderServerSelects();
});
}
function renderServers() {
var tbody = document.querySelector('#serversTable tbody');
tbody.innerHTML = '';
document.getElementById('serversEmpty').style.display = STATE.servers.length ? 'none' : 'block';
STATE.servers.forEach(function (s) {
var tr = document.createElement('tr');
tr.innerHTML =
'<td>' + esc(s.remark) + '</td>' +
'<td class="mono-cell">' + esc(s.dest) + ' / ' + esc(s.sni) + '</td>' +
'<td>' + esc(s.listen_port) + '</td>' +
'<td>' + statusPill(s.status) + '</td>' +
'<td class="mono-cell">' + esc((s.public_key || '').slice(0, 16)) + '…</td>' +
'<td><button class="btn small danger" data-del="' + esc(s.id) + '">Delete</button></td>';
tbody.appendChild(tr);
});
tbody.querySelectorAll('[data-del]').forEach(function (btn) {
btn.addEventListener('click', function () {
if (!confirm('Delete this egress point? Keys and config are destroyed.')) return;
api('/servers/' + encodeURIComponent(btn.getAttribute('data-del')), { method: 'DELETE' })
.then(function (r) { if (r.ok) { loadServers(); } else { showError('Delete failed'); } });
});
});
}
function renderServerSelects() {
['applyServerSelect', 'clientServerSelect'].forEach(function (id) {
var sel = document.getElementById(id);
var prev = sel.value;
sel.innerHTML = '';
STATE.servers.forEach(function (s) {
var opt = document.createElement('option');
opt.value = s.id;
opt.textContent = s.remark + ' (' + s.status + ')';
sel.appendChild(opt);
});
if (prev) sel.value = prev;
});
if (document.getElementById('clientServerSelect').value) {
loadClients(document.getElementById('clientServerSelect').value);
}
}
document.getElementById('createServerForm').addEventListener('submit', function (ev) {
ev.preventDefault();
var fd = new FormData(ev.target);
api('/servers', {
method: 'POST',
body: {
remark: fd.get('remark'),
dest: fd.get('dest'),
sni: fd.get('sni'),
listen_port: parseInt(fd.get('listen_port'), 10),
dest_port: parseInt(fd.get('dest_port'), 10),
short_id_count: parseInt(fd.get('short_id_count'), 10),
},
}).then(function (r) {
if (r.ok) { ev.target.reset(); loadServers(); }
else { showError('Provision failed: ' + esc(r.data && r.data.detail || r.status)); }
});
});
document.getElementById('validateForm').addEventListener('submit', function (ev) {
ev.preventDefault();
var fd = new FormData(ev.target);
var box = document.getElementById('validateResult');
box.innerHTML = 'Validating…';
api('/validate-target', { method: 'POST', body: { host: fd.get('host'), port: parseInt(fd.get('port'), 10) } })
.then(function (r) {
if (!r.ok || !r.data) { box.innerHTML = '<span class="pill red">error</span>'; return; }
var d = r.data;
box.innerHTML =
(d.ok ? '<span class="pill green">SANE</span>' : '<span class="pill red">INSANE</span>') + ' ' +
'<span class="pill ' + (d.reachable ? 'green' : 'red') + '">reachable=' + d.reachable + '</span> ' +
'<span class="pill ' + (d.tls13 ? 'green' : 'red') + '">TLS1.3=' + d.tls13 + '</span> ' +
'<span class="pill ' + (d.x25519 ? 'green' : 'red') + '">X25519=' + d.x25519 + '</span> ' +
'<span class="pill blue">ALPN=' + esc((d.alpn || []).join(',') || '-') + '</span>' +
(d.reason ? '<div class="empty">' + esc(d.reason) + '</div>' : '');
});
});
document.getElementById('applyBtn').addEventListener('click', function () {
var id = document.getElementById('applyServerSelect').value;
if (!id) { showError('No server selected'); return; }
var force = document.getElementById('applyForce').checked;
var box = document.getElementById('applyResult');
box.innerHTML = 'Applying…';
api('/apply?force=' + (force ? 'true' : 'false'), { method: 'POST', body: { server_id: id } })
.then(function (r) {
if (!r.ok || !r.data) {
box.innerHTML = '<span class="pill red">' + esc(r.data && r.data.detail || 'apply failed') + '</span>';
return;
}
var inst = r.data.instance || {};
box.innerHTML = '<span class="pill ' + (inst.running ? 'green' : 'yellow') + '">' +
esc(inst.unit) + ' — ' + esc(inst.active) + '</span>';
loadServers();
});
});
document.getElementById('createClientBtn').addEventListener('click', function () {
var id = document.getElementById('clientServerSelect').value;
var email = document.getElementById('clientEmail').value.trim();
if (!id || !email) { showError('Select a server and enter a client email/remark'); return; }
api('/clients', { method: 'POST', body: { server_id: id, email: email } })
.then(function (r) {
if (r.ok) { document.getElementById('clientEmail').value = ''; loadClients(id); }
else { showError('Create client failed'); }
});
});
function loadClients(serverId) {
STATE.selectedClientServer = serverId;
return api('/clients?server_id=' + encodeURIComponent(serverId)).then(function (r) {
var tbody = document.querySelector('#clientsTable tbody');
tbody.innerHTML = '';
var clients = (r.ok && r.data) ? r.data : [];
document.getElementById('clientsEmpty').style.display = clients.length ? 'none' : 'block';
clients.forEach(function (c) {
var tr = document.createElement('tr');
tr.innerHTML =
'<td>' + esc(c.email) + '</td>' +
'<td class="mono-cell">' + esc(c.short_id) + '</td>' +
'<td>' +
'<button class="btn small" data-link="' + esc(c.id) + '">Link</button> ' +
'<button class="btn small danger" data-delc="' + esc(c.id) + '">Del</button>' +
'</td>';
tbody.appendChild(tr);
});
tbody.querySelectorAll('[data-link]').forEach(function (btn) {
btn.addEventListener('click', function () { showLink(btn.getAttribute('data-link')); });
});
tbody.querySelectorAll('[data-delc]').forEach(function (btn) {
btn.addEventListener('click', function () {
api('/clients/' + encodeURIComponent(btn.getAttribute('data-delc')), { method: 'DELETE' })
.then(function (r2) { if (r2.ok) loadClients(serverId); });
});
});
});
}
document.getElementById('clientServerSelect').addEventListener('change', function (ev) {
loadClients(ev.target.value);
});
function showLink(clientId) {
api('/link?client_id=' + encodeURIComponent(clientId)).then(function (r) {
if (!r.ok || !r.data) { showError('Failed to build link'); return; }
var box = document.getElementById('linkBox');
box.style.display = 'flex';
document.getElementById('uriHolder').textContent = r.data.uri || '';
var qrHolder = document.getElementById('qrHolder');
// qr_svg is generated server-side (segno) from a URI we built
// ourselves — not raw user input — so it's safe to insert as markup.
qrHolder.innerHTML = r.data.qr_svg || '<div class="empty">QR unavailable (segno not installed)</div>';
});
}
document.getElementById('guardForm').addEventListener('submit', function (ev) {
ev.preventDefault();
var fd = new FormData(ev.target);
api('/guard', {
method: 'PUT',
body: {
window_hours: parseInt(fd.get('window_hours'), 10),
soft_gb: parseFloat(fd.get('soft_gb')),
hard_gb: parseFloat(fd.get('hard_gb')),
action: 'disable',
},
}).then(function (r) {
if (r.ok) { loadStats(); } else { showError('Save guard failed: ' + esc(r.data && r.data.detail || r.status)); }
});
});
function loadGuard() {
api('/guard').then(function (r) {
if (!r.ok || !r.data) return;
var f = document.getElementById('guardForm');
f.window_hours.value = r.data.window_hours;
f.soft_gb.value = r.data.soft_gb;
f.hard_gb.value = r.data.hard_gb;
});
}
function loadStats() {
var box = document.getElementById('statsBox');
api('/stats').then(function (r) {
if (!r.ok || !r.data || !r.data.servers || !r.data.servers.length) {
box.className = 'empty';
box.textContent = 'No stats yet.';
return;
}
box.className = '';
box.innerHTML = r.data.servers.map(function (s) {
return '<div class="stat-row">' +
'<div class="stat"><div class="l">' + esc(s.server_id.slice(0, 8)) + '</div><div class="v">' + esc(s.total_gb) + ' GB</div></div>' +
'<div class="stat"><div class="l">window</div><div class="v">' + esc(s.window_hours) + 'h</div></div>' +
'<div class="stat"><div class="l">soft/hard</div><div class="v">' + esc(s.soft_gb) + '/' + esc(s.hard_gb) + '</div></div>' +
'<div class="stat"><div class="l">action</div><div class="v">' + (s.action_taken ? '<span class="pill red">' + esc(s.action_taken) + '</span>' : '<span class="pill green">ok</span>') + '</div></div>' +
'</div>';
}).join('');
});
}
document.getElementById('refreshAll').addEventListener('click', function () {
loadServers(); loadGuard(); loadStats();
});
// initial load
loadServers();
loadGuard();
loadStats();
})();
</script>
<script src="/shared/crt-engine.js"></script>
</body>
</html>

View File

@ -1,3 +1,9 @@
secubox-tor (1.0.4-1~bookworm1) bookworm; urgency=medium
* /tor/ Control Panel now arms the transparent Tor egress (tor_mode) + shows state.
-- Gerald KERMA <devel@cybermind.fr> Wed, 09 Jul 2026 20:30:00 +0200
secubox-tor (1.0.3-1~bookworm1) bookworm; urgency=medium
* Tor control panels on the /tor/ page (exit-country, VPN clients, obfs4 bridges,

View File

@ -350,9 +350,11 @@
<button class="btn btn-purple tor-mode-btn" data-preset="stealth">🥷 Stealth Mode</button>
<button class="btn tor-mode-btn" data-preset="minimal">⚡ Minimal Mode</button>
<button class="btn btn-red" id="btn-disable-tor">⏹️ Disable Tor</button>
<span id="tor-egress-state" style="margin-left:1rem; font-weight:600; font-family:'JetBrains Mono',monospace;">○ Tor egress OFF</span>
</div>
<p style="margin-top: 1rem; font-size: 0.85rem; color: var(--text-dim);">
<strong>Exit IP:</strong> <span id="exit-ip">-</span>
Any of the mode buttons <strong>arms the transparent Tor egress</strong> (the switch that actually routes traffic); Disable turns it off.
<br><strong>Exit IP:</strong> <span id="exit-ip">-</span>
</p>
</div>
@ -609,28 +611,42 @@
b.addEventListener('click', () => removeHiddenService(b.dataset.name)));
}
async function enableTor(preset) {
showToast(`Enabling Tor (${preset} mode)...`);
const res = await api('/enable', {
method: 'POST',
body: JSON.stringify({ preset })
});
// The master Tor-egress switch (tor_mode) lives in the toolbox portal,
// cookie-gated on this same admin vhost. Arming it is what actually
// routes traffic through Tor — without it, "Anonymous mode" does nothing.
async function torEgressSet(on) {
try {
const r = await fetch(`${TOOLBOX_API}/admin/tor/${on ? 'on' : 'off'}`,
{ method: 'POST', credentials: 'same-origin' });
return r.ok;
} catch (e) { return false; }
}
async function loadTorEgressState() {
const el = document.getElementById('tor-egress-state');
if (!el) return;
try {
const r = await fetch(`${TOOLBOX_API}/admin/tor/state`, { credentials: 'same-origin' });
const s = r.ok ? await r.json() : {};
const on = !!s.tor_mode;
el.textContent = on ? '● Tor egress ARMED' : '○ Tor egress OFF';
el.style.color = on ? 'var(--green)' : 'var(--text-dim)';
} catch (e) { el.textContent = '○ Tor egress unknown'; }
}
if (res && res.success) {
showToast('Tor enabled');
} else {
showToast((res && (res.error || res.__error)) || 'Failed to enable Tor', 'error');
}
async function enableTor(preset) {
showToast(`Enabling Tor (${preset} mode) + arming egress…`);
await api('/enable', { method: 'POST', body: JSON.stringify({ preset }) });
const armed = await torEgressSet(true); // the part that actually routes traffic
showToast(armed ? 'Tor enabled + egress ARMED' : 'Tor enabled but egress arm failed', armed ? '' : 'error');
loadTorEgressState();
refresh();
}
async function disableTor() {
const res = await api('/disable', { method: 'POST' });
if (res && res.success) {
showToast('Tor disabled');
} else {
showToast((res && (res.error || res.__error)) || 'Failed to disable Tor', 'error');
}
await api('/disable', { method: 'POST' });
await torEgressSet(false);
showToast('Tor disabled + egress OFF');
loadTorEgressState();
refresh();
}
@ -907,7 +923,7 @@
async function refresh() {
await Promise.all([
loadStatus(), loadCircuits(), loadHiddenServices(), loadOnionDns(),
loadExitCountry(), loadVpnClients(), loadBridges(),
loadExitCountry(), loadVpnClients(), loadBridges(), loadTorEgressState(),
]);
}