From 500aca1c18783beeac1c7708e1275915842f5bf9 Mon Sep 17 00:00:00 2001 From: thepetric Date: Mon, 22 Jun 2026 12:35:40 +0200 Subject: [PATCH 1/5] Versioning --- web/index.php | 2 +- web/lib/app.php | 6 ++++++ web/setup.php | 2 +- 3 files changed, 8 insertions(+), 2 deletions(-) diff --git a/web/index.php b/web/index.php index 05a720e..0d2b42b 100644 --- a/web/index.php +++ b/web/index.php @@ -37,7 +37,7 @@ $flags_display = $flags !== '' ? $flags : 'defaults';
- SnowPanel + SnowPanel v
diff --git a/web/lib/app.php b/web/lib/app.php index cc902ec..7379aba 100644 --- a/web/lib/app.php +++ b/web/lib/app.php @@ -9,6 +9,12 @@ define('APP_VAR','/var/lib/snowpanel'); define('APP_CFG_ETC', APP_ETC.'/app.json'); define('APP_CFG_VAR', APP_VAR.'/app.json'); +define('SNOWPANEL_VERSION','1.0.1'); + +function app_version() { + return SNOWPANEL_VERSION; +} + function app_cfg_path() { if (is_file(APP_CFG_ETC)) return APP_CFG_ETC; if (is_file(APP_CFG_VAR)) return APP_CFG_VAR; diff --git a/web/setup.php b/web/setup.php index 83085af..21f8290 100644 --- a/web/setup.php +++ b/web/setup.php @@ -169,7 +169,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') { value=""> Mbps
-
0 = unlimited (display only)
+
0 = unlimited
-- 2.52.0 From d5d4a2df10e8e454193c91d32273aa58417e7849 Mon Sep 17 00:00:00 2001 From: thepetric Date: Mon, 22 Jun 2026 12:38:39 +0200 Subject: [PATCH 2/5] Config resiliency --- bin/snowpanel-collect.py | 11 +++++++++-- web/api/limits_set.php | 2 ++ 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/bin/snowpanel-collect.py b/bin/snowpanel-collect.py index 532c67b..fc072bd 100644 --- a/bin/snowpanel-collect.py +++ b/bin/snowpanel-collect.py @@ -5,7 +5,7 @@ from datetime import datetime, timezone, timedelta STATE_DIR = "/var/lib/snowpanel" STATS = os.path.join(STATE_DIR, "stats.json") META = os.path.join(STATE_DIR, "meta.json") -CFG = "/etc/snowpanel/app.json" +CFG_CANDIDATES = ["/etc/snowpanel/app.json", "/var/lib/snowpanel/app.json", "/etc/snowpanel/limits.json", "/var/lib/snowpanel/limits.json"] def sh(cmd): return subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, text=True).stdout.strip() @@ -15,6 +15,13 @@ def load_json(path, default): with open(path, "r") as f: return json.load(f) except Exception: return default + +def load_config(): + for path in CFG_CANDIDATES: + cfg = load_json(path, None) + if isinstance(cfg, dict): + return cfg + return {} def save_json(path, obj): tmp = path + ".tmp" @@ -67,7 +74,7 @@ def main(): stats["data"] = arr save_json(STATS, stats) - cfg = load_json(CFG, {}) + cfg = load_config() cap_gb = int(cfg.get("cap_gb", 0)) cap_reset_day = int(cfg.get("cap_reset_day", 1)) rate_mbps = int(cfg.get("rate_mbps", 0)) diff --git a/web/api/limits_set.php b/web/api/limits_set.php index 099f166..50da650 100644 --- a/web/api/limits_set.php +++ b/web/api/limits_set.php @@ -25,4 +25,6 @@ if (!app_save_config($cfg)) { exit; } +@exec('sudo /bin/systemctl restart snowpanel-shaper.service 2>/dev/null'); + echo json_encode(['ok' => true]); -- 2.52.0 From ed18d32ad6acf6754324b6a475a006ad72f954d7 Mon Sep 17 00:00:00 2001 From: thepetric Date: Mon, 22 Jun 2026 12:52:23 +0200 Subject: [PATCH 3/5] Improve install/uninstall process --- README.md | 5 +++-- install.sh | 14 ++++++++++++++ uninstall.sh | 17 +++++++++++++---- 3 files changed, 30 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 317787d..a529077 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,5 @@ -Snowflake Proxy Panel for Raspberry Pi +Snowflake Proxy Panel for Raspberry Pi +Current version: **1.0.1** ====================================== _Run a Tor_ _**Snowflake proxy**_ _at home with a beautiful, simple web dashboard._ @@ -40,7 +41,7 @@ Highlights * Optional **unsafe verbose logging** - * Bandwith limiters + * Bandwidth limiters * **Lightweight** – Built for Raspberry Pi OS (Debian) Lite 64-bit. diff --git a/install.sh b/install.sh index 5f1a1fd..bcc0b92 100644 --- a/install.sh +++ b/install.sh @@ -33,6 +33,8 @@ SVC="/etc/systemd/system/snowpanel-collector.service" TIMER="/etc/systemd/system/snowpanel-collector.timer" SF_DROPIN_DIR="/etc/systemd/system/snowflake-proxy.service.d" SF_ACCOUNTING="$SF_DROPIN_DIR/10-accounting.conf" +SF_USER="snowflake-proxy" +SF_USER_DROPIN="$SF_DROPIN_DIR/20-user.conf" export DEBIAN_FRONTEND=noninteractive @@ -56,6 +58,12 @@ chown -R www-data:www-data "$STATE_DIR" "$LOG_DIR" || true chmod 750 "$PANEL_ROOT" "$STATE_DIR" "$LOG_DIR" ok "Directories ready" +info "Preparing snowflake user" +if ! id -u "$SF_USER" >/dev/null 2>&1; then + useradd --system --home-dir /nonexistent --shell /usr/sbin/nologin "$SF_USER" +fi +ok "Snowflake user ready" + info "Deploying web" rsync -a --delete "$SCRIPT_DIR/web/" "$PANEL_PUBLIC/" chown -R www-data:www-data "$PANEL_PUBLIC" @@ -114,6 +122,12 @@ IPAccounting=yes EOF ok "Accounting drop-in written" +cat > "$SF_USER_DROPIN" < "$SVC" <<'UNIT' [Unit] diff --git a/uninstall.sh b/uninstall.sh index 3081a3c..c9e4f39 100644 --- a/uninstall.sh +++ b/uninstall.sh @@ -24,8 +24,11 @@ SVC="/etc/systemd/system/snowpanel-collector.service" TIMER="/etc/systemd/system/snowpanel-collector.timer" SF_DROPIN_DIR="/etc/systemd/system/snowflake-proxy.service.d" SF_ACCOUNTING="$SF_DROPIN_DIR/10-accounting.conf" +SHAPER_BIN="/usr/local/bin/snowpanel-shaper" +SHAPER_SVC="/etc/systemd/system/snowpanel-shaper.service" +SHAPER_PATH="/etc/systemd/system/snowpanel-shaper.path" -PKGS=("snowflake-proxy" "nginx" "php-fpm" "php-cli" "php-json" "php-curl" "php-zip" "php-common" "php-opcache") +PKGS=("snowflake-proxy" "nginx" "php-fpm" "php-cli" "php-json" "php-curl" "php-zip" "php-common" "php-opcache" "iproute2" "iptables") YES=0 PURGE=0 @@ -60,13 +63,19 @@ systemctl disable --now snowpanel-collector.timer 2>/dev/null || true systemctl disable --now snowpanel-collector.service 2>/dev/null || true ok "Timers stopped" +info "Stopping shaper" +systemctl disable --now snowpanel-shaper.path 2>/dev/null || true +systemctl disable --now snowpanel-shaper.service 2>/dev/null || true +/usr/local/bin/snowpanel-shaper clear 2>/dev/null || true +ok "Shaper stopped" + info "Removing systemd units" -rm -f "$SVC" "$TIMER" +rm -f "$SVC" "$TIMER" "$SHAPER_SVC" "$SHAPER_PATH" systemctl daemon-reload ok "Units removed" info "Removing helper binaries" -rm -f "$COLLECTOR_BIN" "$LOGDUMP_BIN" +rm -f "$COLLECTOR_BIN" "$LOGDUMP_BIN" "$SHAPER_BIN" ok "Helpers removed" info "Removing Nginx site" @@ -79,7 +88,7 @@ rm -rf "$PANEL_ROOT" "$STATE_DIR" "$LOG_DIR" "$ETC_APP" ok "Files removed" info "Removing accounting drop-in" -rm -f "$SF_ACCOUNTING" +rm -f "$SF_ACCOUNTING" "$SF_DROPIN_DIR/20-user.conf" systemctl daemon-reload systemctl restart snowflake-proxy 2>/dev/null || true ok "Drop-in removed" -- 2.52.0 From d60a10806b3269d0cf867d7167a7f080da0cac38 Mon Sep 17 00:00:00 2001 From: thepetric Date: Mon, 22 Jun 2026 13:08:56 +0200 Subject: [PATCH 4/5] Major improvements --- bin/snowpanel-enforce.py | 227 --------------------------------------- bin/snowpanel-snowctl | 115 ++++++++++++++++++++ install.sh | 11 +- uninstall.sh | 5 +- web/api/cap_reset.php | 36 ------- web/index.php | 6 +- web/lib/snowctl.php | 87 +++++++++++++-- web/setup.php | 11 +- 8 files changed, 211 insertions(+), 287 deletions(-) delete mode 100644 bin/snowpanel-enforce.py create mode 100644 bin/snowpanel-snowctl delete mode 100644 web/api/cap_reset.php diff --git a/bin/snowpanel-enforce.py b/bin/snowpanel-enforce.py deleted file mode 100644 index 70bc46d..0000000 --- a/bin/snowpanel-enforce.py +++ /dev/null @@ -1,227 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- -import argparse -import json -import os -import sys -import time -import subprocess -from datetime import datetime, timedelta - -APP_JSON_CANDIDATES = ["/etc/snowpanel/app.json", "/var/lib/snowpanel/app.json"] -LIMITS_JSON_CANDIDATES = ["/etc/snowpanel/limits.json", "/var/lib/snowpanel/limits.json"] - -STATE_DIR = "/var/lib/snowpanel" -META_JSON = os.path.join(STATE_DIR, "meta.json") -STATS_JSON = os.path.join(STATE_DIR, "stats.json") - -SYSTEMCTL = "/bin/systemctl" - -def pretty_bytes(n: int) -> str: - n = int(n) - units = ["B", "KB", "MB", "GB", "TB"] - i = 0 - f = float(n) - while f >= 1024 and i < len(units) - 1: - f /= 1024.0 - i += 1 - if i == 0: - return f"{int(f)} {units[i]}" - return f"{f:.2f} {units[i]}" - -def load_first_json(paths): - for p in paths: - try: - with open(p, "r", encoding="utf-8") as f: - return json.load(f) - except Exception: - pass - return {} - -def save_json_atomic(path, obj): - tmp = f"{path}.tmp" - with open(tmp, "w", encoding="utf-8") as f: - json.dump(obj, f, ensure_ascii=False, indent=2) - os.replace(tmp, path) - -def period_bounds(reset_day: int): - reset_day = max(1, min(28, int(reset_day or 1))) - now = datetime.now() - year = now.year - month = now.month - if now.day < reset_day: - month -= 1 - if month == 0: - month = 12 - year -= 1 - start = datetime(year, month, reset_day, 0, 0, 0) - - end_guess = start + timedelta(days=32) - end_year, end_month = end_guess.year, end_guess.month - try: - end = datetime(end_year, end_month, reset_day, 0, 0, 0) - except ValueError: - end = datetime(end_year, end_month, 1, 0, 0, 0) - return start, end - -def period_label(start: datetime, end: datetime) -> str: - return f"{start.strftime('%b %-d')} → {end.strftime('%b %-d')}" - -def load_limits(): - cfg = load_first_json(APP_JSON_CANDIDATES) - - cap_gb = 0 - cap_reset_day = 1 - rate_mbps = 0 - - if cfg: - cap_gb = int(cfg.get("cap_gb", 0) or 0) - cap_reset_day = int(cfg.get("cap_reset_day", 0) or 0) - rate_mbps = int(cfg.get("rate_mbps", 0) or 0) - lim = cfg.get("limits") or {} - if cap_gb == 0: cap_gb = int(lim.get("cap_gb", 0) or 0) - if cap_reset_day == 0: cap_reset_day = int(lim.get("cap_reset_day", 0) or 0) - if rate_mbps == 0: rate_mbps = int(lim.get("rate_mbps", 0) or 0) - - if cap_gb == 0 or cap_reset_day == 0 or rate_mbps == 0: - legacy = load_first_json(LIMITS_JSON_CANDIDATES) - if legacy: - if cap_gb == 0: cap_gb = int(legacy.get("cap_gb", 0) or 0) - if cap_reset_day == 0: cap_reset_day = int(legacy.get("cap_reset_day", 1) or 1) - if rate_mbps == 0: rate_mbps = int(legacy.get("rate_mbps", 0) or 0) - - cap_gb = max(0, cap_gb) - cap_reset_day = min(28, max(1, cap_reset_day or 1)) - rate_mbps = max(0, rate_mbps) - return cap_gb, cap_reset_day, rate_mbps - -def load_usage(period_start_ts: int, period_end_ts: int, verbose: bool = False): - usage = { - "start_ts": period_start_ts, - "period_label": "", - "rx": 0, - "tx": 0, - "total": 0, - "cap_bytes": 0, - "cap_hit": False, - } - - try: - with open(META_JSON, "r", encoding="utf-8") as f: - meta = json.load(f) or {} - except Exception: - meta = {} - - if meta: - meta_start = int(meta.get("start_ts") or 0) - if meta_start >= period_start_ts and meta_start < period_end_ts: - for k in ("start_ts", "period_label", "rx", "tx", "total", "cap_bytes", "cap_hit"): - if k in meta: - usage[k] = meta[k] - if verbose: - print("Using usage from meta.json") - return usage - - if verbose: - print("meta.json missing/out-of-period, estimating from stats.json") - - try: - with open(STATS_JSON, "r", encoding="utf-8") as f: - s = json.load(f) or {} - rows = s.get("data") or [] - except Exception: - rows = [] - - rows = sorted(rows, key=lambda r: int(r.get("t", 0))) - prev = None - total_rx = 0 - total_tx = 0 - for r in rows: - t = int(r.get("t", 0)) - if t < period_start_ts or t >= period_end_ts: - continue - if prev is not None: - dr = max(0, int(r.get("read", 0)) - int(prev.get("read", 0))) - dw = max(0, int(r.get("written", 0)) - int(prev.get("written", 0))) - total_rx += dr - total_tx += dw - prev = r - - usage["rx"] = total_rx - usage["tx"] = total_tx - usage["total"] = total_rx + total_tx - usage["start_ts"] = period_start_ts - usage["period_label"] = period_label(datetime.fromtimestamp(period_start_ts), - datetime.fromtimestamp(period_end_ts)) - usage["cap_bytes"] = 0 - usage["cap_hit"] = False - return usage - -def update_meta_cap_hit(cap_hit: bool, period_start_ts: int, period_end_ts: int, verbose: bool = False): - try: - meta = {} - if os.path.isfile(META_JSON): - with open(META_JSON, "r", encoding="utf-8") as f: - meta = json.load(f) or {} - - meta_start = int(meta.get("start_ts") or 0) - if not (period_start_ts <= meta_start < period_end_ts): - meta["start_ts"] = period_start_ts - meta["period_label"] = period_label(datetime.fromtimestamp(period_start_ts), - datetime.fromtimestamp(period_end_ts)) - meta["cap_hit"] = bool(cap_hit) - - save_json_atomic(META_JSON, meta) - if verbose: - print(f"meta.json updated: cap_hit={cap_hit}") - except Exception as e: - if verbose: - print(f"meta.json update skipped: {e}") - -def stop_service(service: str, verbose: bool = False, dry_run: bool = False): - cmd = [SYSTEMCTL, "stop", service] - if verbose: - print("RUN:", " ".join(cmd) if not dry_run else "(dry-run) " + " ".join(cmd)) - if not dry_run: - subprocess.run(cmd, check=False) - -def main(): - ap = argparse.ArgumentParser(description="SnowPanel monthly cap enforcer") - ap.add_argument("-v", "--verbose", action="store_true", help="verbose output") - ap.add_argument("--dry-run", action="store_true", help="do not stop the service") - ap.add_argument("--service", default="snowflake-proxy", help="systemd service name") - args = ap.parse_args() - - cap_gb, reset_day, rate_mbps = load_limits() - start_dt, end_dt = period_bounds(reset_day) - start_ts = int(start_dt.timestamp()) - end_ts = int(end_dt.timestamp()) - - usage = load_usage(start_ts, end_ts, verbose=args.verbose) - cap_bytes = usage.get("cap_bytes") or (cap_gb * (1024 ** 3)) - total = int(usage.get("total") or 0) - - if args.verbose: - print(f"Limits: cap_gb={cap_gb}, reset_day={reset_day}") - print(f"Usage : total={total}B cap={cap_bytes}B start_ts={start_ts}") - if cap_bytes > 0: - pct = min(100, round(total * 100 / cap_bytes)) if cap_bytes else 0 - print(f" {pretty_bytes(total)} / {pretty_bytes(cap_bytes)} ({pct}%)") - else: - print(" Unlimited cap") - - if cap_bytes > 0 and total >= cap_bytes: - update_meta_cap_hit(True, start_ts, end_ts, verbose=args.verbose) - if args.verbose: - print(f"Cap reached — stopping {args.service}.") - stop_service(args.service, verbose=args.verbose, dry_run=args.dry_run) - else: - update_meta_cap_hit(False, start_ts, end_ts, verbose=args.verbose) - if args.verbose: - print("Cap not reached — no action.") - -if __name__ == "__main__": - try: - main() - except KeyboardInterrupt: - sys.exit(130) \ No newline at end of file diff --git a/bin/snowpanel-snowctl b/bin/snowpanel-snowctl new file mode 100644 index 0000000..bb35440 --- /dev/null +++ b/bin/snowpanel-snowctl @@ -0,0 +1,115 @@ +#!/usr/bin/env python3 +import argparse +import os +import re +import subprocess +import sys + +DROPIN_DIR = "/etc/systemd/system/snowflake-proxy.service.d" +DROPIN_FILE = os.path.join(DROPIN_DIR, "30-options.conf") +BIN = "/usr/bin/snowflake-proxy" + +def q(s): + return '"' + s.replace("\\", "\\\\").replace('"', '\\"') + '"' + +def valid_broker(s): + return s == "" or re.match(r"^https://[A-Za-z0-9._~:/?#\[\]@!$&'()*+,;=%-]+/?$", s) + +def valid_stun(s): + return s == "" or re.match(r"^stun:[A-Za-z0-9.-]+:[0-9]{1,5}$", s) + +def norm_range(s): + s = (s or "").strip().replace("-", ":") + if s == "": + return "" + m = re.match(r"^([0-9]{1,5}):([0-9]{1,5})$", s) + if not m: + raise ValueError("invalid port range") + a = int(m.group(1)) + b = int(m.group(2)) + if a < 1 or b < 1 or a > b or b > 65535: + raise ValueError("invalid port range") + return f"{a}:{b}" + +def run(cmd): + subprocess.run(cmd, check=False, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + +def apply(args): + broker = (args.broker or "").strip() + stun = (args.stun or "").strip() + prange = norm_range(args.range or "") + unsafe = bool(args.unsafe) + + if broker and not valid_broker(broker): + print("invalid broker", file=sys.stderr) + return 2 + if stun and not valid_stun(stun): + print("invalid stun", file=sys.stderr) + return 2 + + flags = [] + if broker: + flags += ["-broker", broker] + if stun: + flags += ["-stun", stun] + if prange: + flags += ["-ephemeral-ports-range", prange] + if unsafe: + flags += ["-unsafe-logging"] + + os.makedirs(DROPIN_DIR, mode=0o755, exist_ok=True) + + exec_line = BIN + for item in flags: + if item.startswith("-"): + exec_line += " " + item + else: + exec_line += " " + q(item) + + data = "[Service]\nExecStart=\nExecStart=" + exec_line + "\n" + + tmp = DROPIN_FILE + ".tmp" + with open(tmp, "w", encoding="utf-8") as f: + f.write(data) + os.replace(tmp, DROPIN_FILE) + os.chmod(DROPIN_FILE, 0o644) + + run(["/bin/systemctl", "daemon-reload"]) + run(["/bin/systemctl", "restart", "snowflake-proxy"]) + run(["/bin/systemctl", "restart", "snowpanel-shaper.service"]) + return 0 + +def clear(args): + try: + os.remove(DROPIN_FILE) + except FileNotFoundError: + pass + run(["/bin/systemctl", "daemon-reload"]) + run(["/bin/systemctl", "restart", "snowflake-proxy"]) + run(["/bin/systemctl", "restart", "snowpanel-shaper.service"]) + return 0 + +def main(): + p = argparse.ArgumentParser() + sub = p.add_subparsers(dest="cmd") + + a = sub.add_parser("apply") + a.add_argument("--broker", default="") + a.add_argument("--stun", default="") + a.add_argument("--range", default="") + a.add_argument("--unsafe", action="store_true") + + sub.add_parser("clear") + + args = p.parse_args() + + if args.cmd == "apply": + return apply(args) + if args.cmd == "clear": + return clear(args) + + p.print_help() + return 1 + +if __name__ == "__main__": + sys.exit(main()) \ No newline at end of file diff --git a/install.sh b/install.sh index bcc0b92..385a0b9 100644 --- a/install.sh +++ b/install.sh @@ -35,6 +35,8 @@ SF_DROPIN_DIR="/etc/systemd/system/snowflake-proxy.service.d" SF_ACCOUNTING="$SF_DROPIN_DIR/10-accounting.conf" SF_USER="snowflake-proxy" SF_USER_DROPIN="$SF_DROPIN_DIR/20-user.conf" +SNOWCTL_SRC="$SCRIPT_DIR/bin/snowpanel-snowctl" +SNOWCTL_BIN="/usr/local/bin/snowpanel-snowctl" export DEBIAN_FRONTEND=noninteractive @@ -102,31 +104,32 @@ info "Installing helpers" install -m 0755 "$COLLECTOR_SRC" "$COLLECTOR_BIN" install -m 0755 "$LOGDUMP_SRC" "$LOGDUMP_BIN" install -m 0755 "$SHAPER_SRC" "$SHAPER_BIN" +install -m 0755 "$SNOWCTL_SRC" "$SNOWCTL_BIN" install -d -m 0750 -o www-data -g www-data "$STATE_DIR" ok "Helpers installed" info "Granting sudoers" cat > "$SUDOERS_FILE" < "$SF_ACCOUNTING" < "$SF_USER_DROPIN" < "$SVC" <<'UNIT' diff --git a/uninstall.sh b/uninstall.sh index c9e4f39..d981694 100644 --- a/uninstall.sh +++ b/uninstall.sh @@ -27,6 +27,7 @@ SF_ACCOUNTING="$SF_DROPIN_DIR/10-accounting.conf" SHAPER_BIN="/usr/local/bin/snowpanel-shaper" SHAPER_SVC="/etc/systemd/system/snowpanel-shaper.service" SHAPER_PATH="/etc/systemd/system/snowpanel-shaper.path" +SNOWCTL_BIN="/usr/local/bin/snowpanel-snowctl" PKGS=("snowflake-proxy" "nginx" "php-fpm" "php-cli" "php-json" "php-curl" "php-zip" "php-common" "php-opcache" "iproute2" "iptables") @@ -75,7 +76,7 @@ systemctl daemon-reload ok "Units removed" info "Removing helper binaries" -rm -f "$COLLECTOR_BIN" "$LOGDUMP_BIN" "$SHAPER_BIN" +rm -f "$COLLECTOR_BIN" "$LOGDUMP_BIN" "$SHAPER_BIN" "$SNOWCTL_BIN" ok "Helpers removed" info "Removing Nginx site" @@ -88,7 +89,7 @@ rm -rf "$PANEL_ROOT" "$STATE_DIR" "$LOG_DIR" "$ETC_APP" ok "Files removed" info "Removing accounting drop-in" -rm -f "$SF_ACCOUNTING" "$SF_DROPIN_DIR/20-user.conf" +rm -f "$SF_ACCOUNTING" "$SF_DROPIN_DIR/20-user.conf" "$SF_DROPIN_DIR/30-options.conf" systemctl daemon-reload systemctl restart snowflake-proxy 2>/dev/null || true ok "Drop-in removed" diff --git a/web/api/cap_reset.php b/web/api/cap_reset.php deleted file mode 100644 index 2c0238d..0000000 --- a/web/api/cap_reset.php +++ /dev/null @@ -1,36 +0,0 @@ -format('Y'); -$m = (int)$now->format('n'); -$d = (int)$now->format('j'); - -if ($d < $cap_day) { - if ($m === 1) { $m = 12; $y -= 1; } else { $m -= 1; } -} -$anchor = (new DateTime(sprintf('%04d-%02d-%02d 00:00:00', $y, $m, $cap_day), $tz))->getTimestamp(); - -$period = [ - 'period_start' => $anchor, - 'rx' => 0, - 'tx' => 0, - 'last_ing' => 0, - 'last_egr' => 0, -]; - -@file_put_contents($perFile, json_encode($period, JSON_UNESCAPED_SLASHES)); -@unlink($lockFile); - -echo json_encode(['ok' => true]); \ No newline at end of file diff --git a/web/index.php b/web/index.php index 0d2b42b..56e135f 100644 --- a/web/index.php +++ b/web/index.php @@ -3,9 +3,9 @@ require __DIR__ . '/lib/app.php'; auth_require(); $act = $_POST['act'] ?? ''; -if ($act === 'start') @exec('sudo /usr/bin/systemctl start snowflake-proxy 2>/dev/null'); -if ($act === 'stop') @exec('sudo /usr/bin/systemctl stop snowflake-proxy 2>/dev/null'); -if ($act === 'restart')@exec('sudo /usr/bin/systemctl restart snowflake-proxy 2>/dev/null'); +if ($act === 'start') @exec('sudo /bin/systemctl start snowflake-proxy 2>/dev/null'); +if ($act === 'stop') @exec('sudo /bin/systemctl stop snowflake-proxy 2>/dev/null'); +if ($act === 'restart')@exec('sudo /bin/systemctl restart snowflake-proxy 2>/dev/null'); $active = trim((string)@shell_exec('systemctl is-active snowflake-proxy 2>/dev/null')) === 'active'; $enabled = trim((string)@shell_exec('systemctl is-enabled snowflake-proxy 2>/dev/null')) === 'enabled'; diff --git a/web/lib/snowctl.php b/web/lib/snowctl.php index 0e1afe1..bd38a90 100644 --- a/web/lib/snowctl.php +++ b/web/lib/snowctl.php @@ -1,28 +1,93 @@ $b||$b>65535) return ''; return $a.':'.$b; } + function snowctl_build_flags(array $o){ $f=[]; if(!empty($o['broker'])) $f[]='-broker '.escapeshellarg($o['broker']); if(!empty($o['stun'])) $f[]='-stun '.escapeshellarg($o['stun']); - if(!empty($o['range'])){ $r=snowctl_ports_range_normalize($o['range']); if($r!=='') $f[]='-ephemeral-ports-range '.$r; } + if(!empty($o['range'])){ + $r=snowctl_ports_range_normalize($o['range']); + if($r!=='') $f[]='-ephemeral-ports-range '.$r; + } if(!empty($o['unsafe'])) $f[]='-unsafe-logging'; return implode(' ',$f); } -function snowctl_override_path(){ return '/etc/systemd/system/snowflake-proxy.service.d/override.conf'; } + +function snowctl_apply_override_from_options(array $o){ + $broker=trim((string)($o['broker'] ?? '')); + $stun=trim((string)($o['stun'] ?? '')); + $range=snowctl_ports_range_normalize((string)($o['range'] ?? '')); + $unsafe=!empty($o['unsafe']); + + $cmd='/usr/bin/sudo /usr/local/bin/snowpanel-snowctl apply'; + if($broker!=='') $cmd.=' --broker '.escapeshellarg($broker); + if($stun!=='') $cmd.=' --stun '.escapeshellarg($stun); + if($range!=='') $cmd.=' --range '.escapeshellarg($range); + if($unsafe) $cmd.=' --unsafe'; + + $out=[]; + $rc=0; + @exec($cmd.' 2>&1',$out,$rc); + return $rc===0; +} + function snowctl_apply_override($flags){ - $d=dirname(snowctl_override_path()); - if(!is_dir($d)) @mkdir($d,0755,true); - $exec='/usr/bin/snowflake-proxy'; - $out="[Service]\nIPAccounting=yes\nExecStart=\nExecStart=$exec $flags\n"; - @file_put_contents(snowctl_override_path(),$out); - @exec('systemctl daemon-reload 2>/dev/null'); - @exec('systemctl restart snowflake-proxy 2>/dev/null'); - return true; + $cmd='/usr/bin/sudo /usr/local/bin/snowpanel-snowctl apply'; + $tokens=str_getcsv($flags,' '); + for($i=0;$i&1',$out,$rc); + return $rc===0; +} + +function snowctl_override_path(){ + return '/etc/systemd/system/snowflake-proxy.service.d/30-options.conf'; +} + +function snowctl_status(){ + $active=trim((string)@shell_exec('/bin/systemctl is-active snowflake-proxy 2>/dev/null')); + $enabled=trim((string)@shell_exec('/bin/systemctl is-enabled snowflake-proxy 2>/dev/null')); + $pid=trim((string)@shell_exec('/bin/systemctl show -p MainPID --value snowflake-proxy 2>/dev/null')); + $ver=trim((string)@shell_exec('snowflake-proxy -version 2>/dev/null')); + + if($ver===''){ + $ver=trim((string)@shell_exec("dpkg-query -W -f='\${Version}\n' snowflake-proxy 2>/dev/null")); + } + + $flags=''; + $drop=snowctl_override_path(); + if(is_file($drop)){ + $txt=(string)@file_get_contents($drop); + if(preg_match('/^ExecStart=.*snowflake-proxy\s+(.*)$/m',$txt,$m)){ + $flags=trim($m[1]); + } + } + + return [ + 'ok'=>true, + 'active'=>$active, + 'enabled'=>$enabled, + 'pid'=>(int)$pid, + 'version'=>$ver, + 'flags'=>$flags!==''?$flags:'defaults', + ]; } \ No newline at end of file diff --git a/web/setup.php b/web/setup.php index 21f8290..300a81d 100644 --- a/web/setup.php +++ b/web/setup.php @@ -47,15 +47,18 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') { @chgrp("$dir/app.json", 'www-data'); @chmod("$dir/app.json", 0660); } - $flags = snowctl_build_flags([ + if (!snowctl_apply_override_from_options([ 'broker'=>$broker, 'stun'=>$stun, 'range'=>$range, 'unsafe'=>$unsafe?1:0 - ]); - snowctl_apply_override($flags); + ])) { + $err = 'Snowflake options could not be applied.'; + } - header('Location: /login.php?ok=1'); exit; + if ($err === '') { + header('Location: /login.php?ok=1'); exit; + } } } ?> -- 2.52.0 From 8d4491bbe80a8b3c7170f182f426d1339fc95140 Mon Sep 17 00:00:00 2001 From: thepetric Date: Mon, 22 Jun 2026 13:58:13 +0200 Subject: [PATCH 5/5] Testing automation --- install.sh | 7 + tests/check-install.sh | 366 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 373 insertions(+) create mode 100644 tests/check-install.sh diff --git a/install.sh b/install.sh index 385a0b9..1bb1315 100644 --- a/install.sh +++ b/install.sh @@ -68,6 +68,13 @@ ok "Snowflake user ready" info "Deploying web" rsync -a --delete "$SCRIPT_DIR/web/" "$PANEL_PUBLIC/" +find "$PANEL_PUBLIC" -type f \( \ + -name "*.php" -o \ + -name "*.css" -o \ + -name "*.js" -o \ + -name "*.svg" -o \ + -name "*.txt" \ +\) -exec sed -i 's/\r$//' {} \; chown -R www-data:www-data "$PANEL_PUBLIC" ok "Web deployed" diff --git a/tests/check-install.sh b/tests/check-install.sh new file mode 100644 index 0000000..5663d5c --- /dev/null +++ b/tests/check-install.sh @@ -0,0 +1,366 @@ +#!/usr/bin/env bash +set -u + +EXPECTED_VERSION="${EXPECTED_VERSION:-1.0.1}" + +WEB_ROOT="/var/www/snowpanel/public" +STATE_DIR="/var/lib/snowpanel" +ETC_DIR="/etc/snowpanel" +DROPIN_DIR="/etc/systemd/system/snowflake-proxy.service.d" + +PASS=0 +WARN=0 +FAIL=0 + +if [[ -t 1 ]]; then + R="\033[0m"; G="\033[32m"; Y="\033[33m"; RED="\033[31m"; B="\033[34m"; BO="\033[1m" +else + R=""; G=""; Y=""; RED=""; B=""; BO="" +fi + +ok(){ echo -e "${G}PASS${R} $*"; PASS=$((PASS+1)); } +warn(){ echo -e "${Y}WARN${R} $*"; WARN=$((WARN+1)); } +bad(){ echo -e "${RED}FAIL${R} $*"; FAIL=$((FAIL+1)); } +sec(){ echo; echo -e "${B}${BO}$*${R}"; } + +need_root(){ + if [[ $EUID -ne 0 ]]; then + echo "Run with sudo: sudo bash check-install.sh" + exit 2 + fi +} + +cmd_ok(){ + command -v "$1" >/dev/null 2>&1 && ok "command: $1" || bad "missing command: $1" +} + +file_ok(){ + [[ -f "$1" ]] && ok "$2" || bad "$2 missing: $1" +} + +file_absent(){ + [[ ! -e "$1" ]] && ok "$2 absent" || bad "$2 should not exist: $1" +} + +dir_ok(){ + [[ -d "$1" ]] && ok "$2" || bad "$2 missing: $1" +} + +json_ok(){ + local f="$1" label="$2" + if [[ ! -f "$f" ]]; then + warn "$label missing: $f" + return + fi + python3 -m json.tool "$f" >/dev/null 2>&1 && ok "$label valid JSON" || bad "$label invalid JSON: $f" +} + +unit_loaded(){ + local u="$1" + systemctl show "$u" -p LoadState --value 2>/dev/null | grep -qx loaded && ok "unit loaded: $u" || bad "unit not loaded: $u" +} + +unit_active(){ + local u="$1" + systemctl is-active --quiet "$u" && ok "active: $u" || bad "not active: $u" +} + +unit_enabled(){ + local u="$1" + systemctl is-enabled --quiet "$u" 2>/dev/null && ok "enabled: $u" || warn "not enabled: $u" +} + +http_check(){ + local url="$1" name="$2" tmp code + tmp="$(mktemp)" + code="$(curl -s -o "$tmp" -w '%{http_code}' --max-time 8 "$url" 2>/dev/null || echo 000)" + + if [[ "$code" == "200" ]]; then + if python3 -m json.tool "$tmp" >/dev/null 2>&1; then + ok "$name HTTP 200 valid JSON" + else + warn "$name HTTP 200 but not JSON" + fi + elif [[ "$code" == "302" ]]; then + ok "$name protected by login HTTP 302" + else + warn "$name HTTP $code" + fi + + rm -f "$tmp" +} + +read_cfg_value(){ + local key="$1" + python3 - "$key" <<'PY' +import json,sys +key=sys.argv[1] +cands=[ + "/var/lib/snowpanel/app.json", + "/etc/snowpanel/app.json", + "/etc/snowpanel/limits.json", + "/var/lib/snowpanel/limits.json", +] +for p in cands: + try: + with open(p,"r",encoding="utf-8") as f: + j=json.load(f) + if not isinstance(j,dict): + continue + v=j.get(key) + if v is None and isinstance(j.get("limits"),dict): + v=j["limits"].get(key) + if v is not None: + print(v) + raise SystemExit(0) + except Exception: + pass +print("") +PY +} + +need_root + +sec "SnowPanel installed-system check" +echo "Expected version: $EXPECTED_VERSION" + +sec "Required commands" + +for c in bash python3 php systemctl curl nginx snowflake-proxy tc ip iptables sudo visudo journalctl; do + cmd_ok "$c" +done + +sec "Installed directories" + +dir_ok "$WEB_ROOT" "web root" +dir_ok "$STATE_DIR" "state directory" +dir_ok "$ETC_DIR" "config directory" +dir_ok "$DROPIN_DIR" "snowflake systemd drop-in directory" + +sec "Installed files" + +file_ok "$WEB_ROOT/index.php" "dashboard index" +file_ok "$WEB_ROOT/login.php" "login page" +file_ok "$WEB_ROOT/setup.php" "setup page" +file_ok "$WEB_ROOT/lib/app.php" "app library" +file_ok "$WEB_ROOT/lib/snowctl.php" "snowctl PHP library" +file_ok "$WEB_ROOT/api/stats.php" "stats API" +file_ok "$WEB_ROOT/api/limits_get.php" "limits_get API" +file_ok "$WEB_ROOT/api/limits_set.php" "limits_set API" +file_ok "$WEB_ROOT/api/snow_log.php" "snow_log API" +file_ok "$WEB_ROOT/api/snow_status.php" "snow_status API" + +file_ok /usr/local/bin/snowpanel-collect.py "collector helper" +file_ok /usr/local/bin/snowpanel-logdump "logdump helper" +file_ok /usr/local/bin/snowpanel-shaper "shaper helper" +file_ok /usr/local/bin/snowpanel-snowctl "snowctl helper" + +file_ok /etc/sudoers.d/snowpanel "sudoers file" +file_ok /etc/nginx/sites-enabled/snowpanel "nginx site" + +file_absent /usr/local/bin/snowpanel-enforce.py "old enforcer helper" +file_absent "$WEB_ROOT/api/cap_reset.php" "old cap_reset API" + +sec "Line endings" + +CRLF_INST="$(grep -RIl $'\r' \ + /usr/local/bin/snowpanel-* \ + "$WEB_ROOT" \ + /etc/systemd/system/snowpanel* \ + "$DROPIN_DIR" \ + 2>/dev/null | head -n 30 || true)" + +if [[ -z "$CRLF_INST" ]]; then + ok "installed files have no CRLF line endings" +else + bad "installed files have CRLF line endings" + echo "$CRLF_INST" +fi + +sec "Installed syntax" + +for f in /usr/local/bin/snowpanel-logdump /usr/local/bin/snowpanel-shaper; do + if [[ -f "$f" ]]; then + bash -n "$f" >/dev/null 2>&1 && ok "bash syntax: $f" || bad "bash syntax failed: $f" + fi +done + +for f in /usr/local/bin/snowpanel-collect.py /usr/local/bin/snowpanel-snowctl; do + if [[ -f "$f" ]]; then + python3 -m py_compile "$f" >/dev/null 2>&1 && ok "python syntax: $f" || bad "python syntax failed: $f" + fi +done + +if command -v php >/dev/null 2>&1; then + PHP_BAD=0 + while IFS= read -r -d '' f; do + if ! php -l "$f" >/dev/null 2>&1; then + echo "PHP syntax failed: $f" + PHP_BAD=1 + fi + done < <(find "$WEB_ROOT" -name '*.php' -print0 2>/dev/null) + + [[ "$PHP_BAD" -eq 0 ]] && ok "installed PHP syntax" || bad "installed PHP syntax errors" +else + bad "php missing, cannot lint installed PHP" +fi + +sec "Version" + +if [[ -f "$WEB_ROOT/lib/app.php" ]]; then + INST_VER="$(php -r 'require "/var/www/snowpanel/public/lib/app.php"; echo function_exists("app_version") ? app_version() : "";' 2>/dev/null || true)" + [[ "$INST_VER" == "$EXPECTED_VERSION" ]] && ok "installed version: $INST_VER" || bad "installed version mismatch: got '${INST_VER:-missing}', expected '$EXPECTED_VERSION'" +else + bad "installed app.php missing" +fi + +sec "Config and state" + +json_ok "$STATE_DIR/app.json" "app config" +json_ok "$STATE_DIR/stats.json" "stats" +json_ok "$STATE_DIR/meta.json" "meta" + +STATE_OWNER="$(stat -c '%U:%G %a' "$STATE_DIR" 2>/dev/null || true)" +case "$STATE_OWNER" in + www-data:www-data*) ok "state directory ownership: $STATE_OWNER" ;; + *) warn "state directory ownership: $STATE_OWNER" ;; +esac + +RATE="$(read_cfg_value rate_mbps)" +CAP="$(read_cfg_value cap_gb)" +RESET_DAY="$(read_cfg_value cap_reset_day)" +ok "config values: cap_gb=${CAP:-unset}, cap_reset_day=${RESET_DAY:-unset}, rate_mbps=${RATE:-unset}" + +sec "Systemd units" + +for u in snowflake-proxy.service snowpanel-collector.service snowpanel-collector.timer snowpanel-shaper.service snowpanel-shaper.path nginx.service; do + unit_loaded "$u" +done + +unit_active nginx.service +unit_active snowflake-proxy.service +unit_enabled snowflake-proxy.service +unit_active snowpanel-collector.timer +unit_active snowpanel-shaper.path + +SHAPER_STATE="$(systemctl is-active snowpanel-shaper.service 2>/dev/null || true)" +if [[ "$SHAPER_STATE" == "active" ]]; then + ok "shaper service active" +else + bad "shaper service state: $SHAPER_STATE" +fi + +PHPFPM="$(systemctl list-units --type=service --all 'php*-fpm.service' --no-legend 2>/dev/null | awk '{print $1; exit}')" +if [[ -n "$PHPFPM" ]]; then + unit_active "$PHPFPM" +else + warn "php-fpm service not detected" +fi + +sec "Snowflake service" + +PID="$(systemctl show -p MainPID --value snowflake-proxy 2>/dev/null || true)" +if [[ -n "$PID" && "$PID" != "0" && -d "/proc/$PID" ]]; then + PROC="$(ps -o user=,group=,pid=,cmd= -p "$PID" 2>/dev/null || true)" + echo "$PROC" + + UID_NUM="$(awk '/^Uid:/{print $2; exit}' "/proc/$PID/status" 2>/dev/null || true)" + USERNAME="$(getent passwd "$UID_NUM" | cut -d: -f1)" + + [[ "$USERNAME" == "snowflake-proxy" ]] && ok "runs as dedicated user" || bad "runs as '$USERNAME', expected snowflake-proxy" +else + bad "snowflake-proxy has no running PID" +fi + +file_ok "$DROPIN_DIR/10-accounting.conf" "IPAccounting drop-in" +file_ok "$DROPIN_DIR/20-user.conf" "User drop-in" + +if [[ -f "$DROPIN_DIR/30-options.conf" ]]; then + ok "options drop-in exists" + grep -q '^ExecStart=' "$DROPIN_DIR/30-options.conf" && ok "options drop-in has ExecStart" || bad "options drop-in missing ExecStart" +else + warn "options drop-in missing, defaults are used" +fi + +journalctl -u snowflake-proxy -n 50 --no-pager 2>/dev/null | grep -qi 'Proxy starting' && ok "proxy startup log present" || warn "proxy startup log not found" +journalctl -u snowflake-proxy -n 100 --no-pager 2>/dev/null | grep -qi 'NAT type' && ok "NAT type log present" || warn "NAT type log not found yet" + +sec "Traffic shaper" + +IFACE="$(ip -o -4 route show to default 2>/dev/null | awk '{print $5; exit}' || true)" +[[ -z "$IFACE" ]] && IFACE="$(ip -o -6 route show ::/0 2>/dev/null | awk '{print $5; exit}' || true)" + +if [[ -z "$IFACE" ]]; then + bad "default interface not detected" +else + ok "default interface: $IFACE" + + if [[ "${RATE:-0}" =~ ^[0-9]+$ && "${RATE:-0}" -gt 0 ]]; then + tc qdisc show dev "$IFACE" 2>/dev/null | grep -q htb && ok "HTB qdisc on $IFACE" || bad "HTB qdisc missing on $IFACE" + ip link show ifb0 >/dev/null 2>&1 && ok "ifb0 exists" || bad "ifb0 missing" + tc qdisc show dev ifb0 2>/dev/null | grep -q htb && ok "HTB qdisc on ifb0" || bad "HTB qdisc missing on ifb0" + iptables -t mangle -S SNOWPANEL >/dev/null 2>&1 && ok "iptables SNOWPANEL chain exists" || bad "iptables SNOWPANEL chain missing" + else + warn "rate_mbps is 0/unset, shaper rules may be intentionally cleared" + fi +fi + +sec "Sudoers" + +visudo -cf /etc/sudoers.d/snowpanel >/dev/null 2>&1 && ok "sudoers syntax valid" || bad "sudoers syntax invalid" + +SUDO_LIST="$(sudo -l -U www-data 2>/dev/null || true)" +grep -q '/usr/local/bin/snowpanel-logdump' <<<"$SUDO_LIST" && ok "www-data can run logdump" || bad "www-data cannot run logdump" +grep -q '/usr/local/bin/snowpanel-snowctl' <<<"$SUDO_LIST" && ok "www-data can run snowctl" || bad "www-data cannot run snowctl" +grep -q '/bin/systemctl restart snowpanel-shaper' <<<"$SUDO_LIST" && ok "www-data can restart shaper" || bad "www-data cannot restart shaper" + +sudo -u www-data sudo -n /usr/local/bin/snowpanel-logdump 1 info >/dev/null 2>&1 && ok "logdump sudo test" || bad "logdump sudo test failed" + +sec "Web and APIs" + +ROOT_CODE="$(curl -s -o /dev/null -w '%{http_code}' --max-time 8 http://127.0.0.1/ 2>/dev/null || echo 000)" +[[ "$ROOT_CODE" =~ ^(200|302)$ ]] && ok "dashboard HTTP $ROOT_CODE" || bad "dashboard HTTP $ROOT_CODE" + +LOGIN_CODE="$(curl -s -o /dev/null -w '%{http_code}' --max-time 8 http://127.0.0.1/login.php 2>/dev/null || echo 000)" +[[ "$LOGIN_CODE" =~ ^(200|302)$ ]] && ok "login HTTP $LOGIN_CODE" || warn "login HTTP $LOGIN_CODE" + +http_check http://127.0.0.1/api/stats.php "stats API" +http_check http://127.0.0.1/api/limits_get.php "limits_get API" +http_check 'http://127.0.0.1/api/snow_log.php?n=5' "snow_log API" +http_check http://127.0.0.1/api/snow_status.php "snow_status API" + +sec "Collector run" + +systemctl start snowpanel-collector.service >/dev/null 2>&1 +sleep 1 + +COLLECT_STATE="$(systemctl is-active snowpanel-collector.service 2>/dev/null || true)" +COLLECT_RESULT="$(systemctl show snowpanel-collector.service -p Result --value 2>/dev/null || true)" + +if [[ "$COLLECT_STATE" == "inactive" && "$COLLECT_RESULT" == "success" ]]; then + ok "collector oneshot completed" +else + bad "collector oneshot state=$COLLECT_STATE result=$COLLECT_RESULT" + journalctl -u snowpanel-collector.service -n 30 --no-pager 2>/dev/null || true +fi + +sec "Final installed state" + +systemctl --no-pager --failed | grep -q 'snowpanel\|snowflake' && { + bad "failed SnowPanel/Snowflake units present" + systemctl --no-pager --failed | grep 'snowpanel\|snowflake' || true +} || ok "no failed SnowPanel/Snowflake units" + +sec "Summary" + +echo -e "${G}PASS${R}: $PASS" +echo -e "${Y}WARN${R}: $WARN" +echo -e "${RED}FAIL${R}: $FAIL" + +if [[ "$FAIL" -eq 0 ]]; then + echo -e "${G}Installed SnowPanel check passed.${R}" + exit 0 +fi + +echo -e "${RED}Installed SnowPanel check failed.${R}" +exit 1 \ No newline at end of file -- 2.52.0