mirror of
https://github.com/CyberMind-FR/secubox-deb.git
synced 2026-07-28 21:17:36 +00:00
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
Some checks are pending
License Headers / check (push) Waiting to run
This commit is contained in:
commit
0ccee5028b
15
packages/secubox-reality/debian/changelog
Normal file
15
packages/secubox-reality/debian/changelog
Normal 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
|
||||
1
packages/secubox-reality/debian/compat
Normal file
1
packages/secubox-reality/debian/compat
Normal file
|
|
@ -0,0 +1 @@
|
|||
13
|
||||
24
packages/secubox-reality/debian/control
Normal file
24
packages/secubox-reality/debian/control
Normal 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.
|
||||
36
packages/secubox-reality/debian/postinst
Executable file
36
packages/secubox-reality/debian/postinst
Executable 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
|
||||
19
packages/secubox-reality/debian/prerm
Executable file
19
packages/secubox-reality/debian/prerm
Executable 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
|
||||
34
packages/secubox-reality/debian/rules
Executable file
34
packages/secubox-reality/debian/rules
Executable 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).
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
24
packages/secubox-reality/debian/secubox-reality.service
Normal file
24
packages/secubox-reality/debian/secubox-reality.service
Normal 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
|
||||
39
packages/secubox-reality/debian/secubox-xray@.service
Normal file
39
packages/secubox-reality/debian/secubox-xray@.service
Normal 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
|
||||
9
packages/secubox-reality/menu.d/562-reality.json
Normal file
9
packages/secubox-reality/menu.d/562-reality.json
Normal 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)"
|
||||
}
|
||||
22
packages/secubox-reality/secubox_reality/__init__.py
Normal file
22
packages/secubox-reality/secubox_reality/__init__.py
Normal 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"
|
||||
33
packages/secubox-reality/secubox_reality/main.py
Normal file
33
packages/secubox-reality/secubox_reality/main.py
Normal 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"}
|
||||
151
packages/secubox-reality/secubox_reality/models.py
Normal file
151
packages/secubox-reality/secubox_reality/models.py
Normal 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
|
||||
198
packages/secubox-reality/secubox_reality/monitor.py
Normal file
198
packages/secubox-reality/secubox_reality/monitor.py
Normal 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())
|
||||
245
packages/secubox-reality/secubox_reality/router.py
Normal file
245
packages/secubox-reality/secubox_reality/router.py
Normal 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)}
|
||||
109
packages/secubox-reality/secubox_reality/service.py
Normal file
109
packages/secubox-reality/secubox_reality/service.py
Normal 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)
|
||||
226
packages/secubox-reality/secubox_reality/store.py
Normal file
226
packages/secubox-reality/secubox_reality/store.py
Normal 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
|
||||
142
packages/secubox-reality/secubox_reality/validator.py
Normal file
142
packages/secubox-reality/secubox_reality/validator.py
Normal 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)"
|
||||
)
|
||||
341
packages/secubox-reality/secubox_reality/xray.py
Normal file
341
packages/secubox-reality/secubox_reality/xray.py
Normal 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")
|
||||
550
packages/secubox-reality/webui/reality.html
Normal file
550
packages/secubox-reality/webui/reality.html
Normal 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 { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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>
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
]);
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user