diff --git a/packages/secubox-reality/debian/changelog b/packages/secubox-reality/debian/changelog new file mode 100644 index 00000000..8ba4b840 --- /dev/null +++ b/packages/secubox-reality/debian/changelog @@ -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 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@ systemd units, client + vless:// links + QR, sliding-window volume-guard (soft/hard, disable + preserves identity). + + -- Gerald KERMA Thu, 09 Jul 2026 13:54:00 +0200 diff --git a/packages/secubox-reality/debian/compat b/packages/secubox-reality/debian/compat new file mode 100644 index 00000000..b1bd38b6 --- /dev/null +++ b/packages/secubox-reality/debian/compat @@ -0,0 +1 @@ +13 diff --git a/packages/secubox-reality/debian/control b/packages/secubox-reality/debian/control new file mode 100644 index 00000000..8e23a547 --- /dev/null +++ b/packages/secubox-reality/debian/control @@ -0,0 +1,24 @@ +Source: secubox-reality +Section: admin +Priority: optional +Maintainer: Gerald KERMA +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. diff --git a/packages/secubox-reality/debian/postinst b/packages/secubox-reality/debian/postinst new file mode 100755 index 00000000..e2bf10cb --- /dev/null +++ b/packages/secubox-reality/debian/postinst @@ -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 diff --git a/packages/secubox-reality/debian/prerm b/packages/secubox-reality/debian/prerm new file mode 100755 index 00000000..16440fd8 --- /dev/null +++ b/packages/secubox-reality/debian/prerm @@ -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@ 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/ (or + # `systemctl stop secubox-xray@` manually) before purge if a + # full teardown is intended. + ;; +esac + +#DEBHELPER# + +exit 0 diff --git a/packages/secubox-reality/debian/rules b/packages/secubox-reality/debian/rules new file mode 100755 index 00000000..f6e28688 --- /dev/null +++ b/packages/secubox-reality/debian/rules @@ -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). diff --git a/packages/secubox-reality/debian/secubox-reality-monitor.service b/packages/secubox-reality/debian/secubox-reality-monitor.service new file mode 100644 index 00000000..04a1ee10 --- /dev/null +++ b/packages/secubox-reality/debian/secubox-reality-monitor.service @@ -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 diff --git a/packages/secubox-reality/debian/secubox-reality-monitor.timer b/packages/secubox-reality/debian/secubox-reality-monitor.timer new file mode 100644 index 00000000..1b2ba24d --- /dev/null +++ b/packages/secubox-reality/debian/secubox-reality-monitor.timer @@ -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 diff --git a/packages/secubox-reality/debian/secubox-reality.service b/packages/secubox-reality/debian/secubox-reality.service new file mode 100644 index 00000000..3ca9b884 --- /dev/null +++ b/packages/secubox-reality/debian/secubox-reality.service @@ -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 diff --git a/packages/secubox-reality/debian/secubox-xray@.service b/packages/secubox-reality/debian/secubox-xray@.service new file mode 100644 index 00000000..786c61bd --- /dev/null +++ b/packages/secubox-reality/debian/secubox-xray@.service @@ -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 diff --git a/packages/secubox-reality/menu.d/562-reality.json b/packages/secubox-reality/menu.d/562-reality.json new file mode 100644 index 00000000..521d8e4d --- /dev/null +++ b/packages/secubox-reality/menu.d/562-reality.json @@ -0,0 +1,9 @@ +{ + "id": "reality", + "name": "Reality", + "category": "mesh", + "icon": "🕶️", + "path": "/reality/", + "order": 562, + "description": "VLESS+Reality+Vision egress-point manager (Xray-core)" +} diff --git a/packages/secubox-reality/secubox_reality/__init__.py b/packages/secubox-reality/secubox_reality/__init__.py new file mode 100644 index 00000000..3581fb15 --- /dev/null +++ b/packages/secubox-reality/secubox_reality/__init__.py @@ -0,0 +1,22 @@ +# SPDX-License-Identifier: LicenseRef-CMSD-1.0 +# Copyright (c) 2026 CyberMind — Gérald Kerma +# 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@.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" diff --git a/packages/secubox-reality/secubox_reality/main.py b/packages/secubox-reality/secubox_reality/main.py new file mode 100644 index 00000000..d82a3f33 --- /dev/null +++ b/packages/secubox-reality/secubox_reality/main.py @@ -0,0 +1,33 @@ +# SPDX-License-Identifier: LicenseRef-CMSD-1.0 +# Copyright (c) 2026 CyberMind — Gérald Kerma +# 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"} diff --git a/packages/secubox-reality/secubox_reality/models.py b/packages/secubox-reality/secubox_reality/models.py new file mode 100644 index 00000000..b0921801 --- /dev/null +++ b/packages/secubox-reality/secubox_reality/models.py @@ -0,0 +1,151 @@ +# SPDX-License-Identifier: LicenseRef-CMSD-1.0 +# Copyright (c) 2026 CyberMind — Gérald Kerma +# 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 diff --git a/packages/secubox-reality/secubox_reality/monitor.py b/packages/secubox-reality/secubox_reality/monitor.py new file mode 100644 index 00000000..08408368 --- /dev/null +++ b/packages/secubox-reality/secubox_reality/monitor.py @@ -0,0 +1,198 @@ +# SPDX-License-Identifier: LicenseRef-CMSD-1.0 +# Copyright (c) 2026 CyberMind — Gérald Kerma +# 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()) diff --git a/packages/secubox-reality/secubox_reality/router.py b/packages/secubox-reality/secubox_reality/router.py new file mode 100644 index 00000000..1a0ab943 --- /dev/null +++ b/packages/secubox-reality/secubox_reality/router.py @@ -0,0 +1,245 @@ +# SPDX-License-Identifier: LicenseRef-CMSD-1.0 +# Copyright (c) 2026 CyberMind — Gérald Kerma +# 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@, + 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)} diff --git a/packages/secubox-reality/secubox_reality/service.py b/packages/secubox-reality/secubox_reality/service.py new file mode 100644 index 00000000..a3df18cf --- /dev/null +++ b/packages/secubox-reality/secubox_reality/service.py @@ -0,0 +1,109 @@ +# SPDX-License-Identifier: LicenseRef-CMSD-1.0 +# Copyright (c) 2026 CyberMind — Gérald Kerma +# 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@.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) diff --git a/packages/secubox-reality/secubox_reality/store.py b/packages/secubox-reality/secubox_reality/store.py new file mode 100644 index 00000000..ae3b5ecd --- /dev/null +++ b/packages/secubox-reality/secubox_reality/store.py @@ -0,0 +1,226 @@ +# SPDX-License-Identifier: LicenseRef-CMSD-1.0 +# Copyright (c) 2026 CyberMind — Gérald Kerma +# 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 diff --git a/packages/secubox-reality/secubox_reality/validator.py b/packages/secubox-reality/secubox_reality/validator.py new file mode 100644 index 00000000..efb8198f --- /dev/null +++ b/packages/secubox-reality/secubox_reality/validator.py @@ -0,0 +1,142 @@ +# SPDX-License-Identifier: LicenseRef-CMSD-1.0 +# Copyright (c) 2026 CyberMind — Gérald Kerma +# 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)" + ) diff --git a/packages/secubox-reality/secubox_reality/xray.py b/packages/secubox-reality/secubox_reality/xray.py new file mode 100644 index 00000000..edfd40bc --- /dev/null +++ b/packages/secubox-reality/secubox_reality/xray.py @@ -0,0 +1,341 @@ +# SPDX-License-Identifier: LicenseRef-CMSD-1.0 +# Copyright (c) 2026 CyberMind — Gérald Kerma +# 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: " / "Public key: " (older releases) + - "PrivateKey: " / "Password: " (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") diff --git a/packages/secubox-reality/webui/reality.html b/packages/secubox-reality/webui/reality.html new file mode 100644 index 00000000..6d71e5b7 --- /dev/null +++ b/packages/secubox-reality/webui/reality.html @@ -0,0 +1,550 @@ + + + + + +SecuBox - Reality + + + + + + + + + + +
+
+
+

🕶️ Reality egress manager

+
Provision, validate and monitor VLESS+Reality+Vision (Xray-core) egress points.
+
+ +
+ +
+ + +
+

Egress points

+ + + +
Remarkdest / SNIPortStatusPublic key
+ + +

Provision new server

+
+
+
+ + + + + + +
+
+ + + + + + +
+
+
+
+
+ + +
+

Validate target (TLS1.3 + X25519 sanity gate)

+
+
+
+
+
+
+
+
+
+ + +
+

Apply (write config + start instance)

+
+
+ + + +
+
+
+
+
+
+
+ +
+

Clients + link

+
+
+ + + + +
+
+
+ + + +
EmailshortId
+ +
+
+ +
+ + +
+

Volume guard (sliding window)

+
+
+
+ + + + +
+
+ + +
+
+
+
+
+ +
+

Stats (per-window delta)

+
No stats yet — click Refresh.
+
+
+ + + + + diff --git a/packages/secubox-tor/debian/changelog b/packages/secubox-tor/debian/changelog index 5e33a429..a0563524 100644 --- a/packages/secubox-tor/debian/changelog +++ b/packages/secubox-tor/debian/changelog @@ -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 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, diff --git a/packages/secubox-tor/www/tor/index.html b/packages/secubox-tor/www/tor/index.html index e8002383..f4bff01c 100644 --- a/packages/secubox-tor/www/tor/index.html +++ b/packages/secubox-tor/www/tor/index.html @@ -350,9 +350,11 @@ + ○ Tor egress OFF

- Exit IP: - + Any of the mode buttons arms the transparent Tor egress (the switch that actually routes traffic); Disable turns it off. +
Exit IP: -

@@ -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(), ]); }