From d5834de32e00f55378d3f0c7e5144d373a20bca2 Mon Sep 17 00:00:00 2001 From: almostm4 Date: Sat, 15 Nov 2025 09:07:04 +0100 Subject: [PATCH] Release --- LICENSE | 18 ++ README.md | 169 +++++++++++++ bin/snowpanel-collect.py | 126 +++++++++ bin/snowpanel-enforce.py | 227 +++++++++++++++++ bin/snowpanel-logdump | 33 +++ bin/snowpanel-shaper | 143 +++++++++++ install.sh | 192 ++++++++++++++ screenshots/dashboard-dark.png | Bin 0 -> 28998 bytes screenshots/dashboard-light.png | Bin 0 -> 31021 bytes screenshots/login.png | Bin 0 -> 8020 bytes uninstall.sh | 104 ++++++++ web/api/cap_reset.php | 36 +++ web/api/limits_get.php | 54 ++++ web/api/limits_set.php | 28 ++ web/api/snow_log.php | 23 ++ web/api/snow_status.php | 5 + web/api/stats.php | 33 +++ web/assets/panel.css | 133 ++++++++++ web/favicon.svg | 9 + web/index.php | 436 ++++++++++++++++++++++++++++++++ web/lib/app.php | 67 +++++ web/lib/snowctl.php | 28 ++ web/login.php | 93 +++++++ web/logout.php | 4 + web/robots.txt | 2 + web/setup.php | 207 +++++++++++++++ 26 files changed, 2170 insertions(+) create mode 100644 LICENSE create mode 100644 README.md create mode 100644 bin/snowpanel-collect.py create mode 100644 bin/snowpanel-enforce.py create mode 100644 bin/snowpanel-logdump create mode 100644 bin/snowpanel-shaper create mode 100644 install.sh create mode 100644 screenshots/dashboard-dark.png create mode 100644 screenshots/dashboard-light.png create mode 100644 screenshots/login.png create mode 100644 uninstall.sh create mode 100644 web/api/cap_reset.php create mode 100644 web/api/limits_get.php create mode 100644 web/api/limits_set.php create mode 100644 web/api/snow_log.php create mode 100644 web/api/snow_status.php create mode 100644 web/api/stats.php create mode 100644 web/assets/panel.css create mode 100644 web/favicon.svg create mode 100644 web/index.php create mode 100644 web/lib/app.php create mode 100644 web/lib/snowctl.php create mode 100644 web/login.php create mode 100644 web/logout.php create mode 100644 web/robots.txt create mode 100644 web/setup.php diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..a046b42 --- /dev/null +++ b/LICENSE @@ -0,0 +1,18 @@ +MIT License + +Copyright (c) 2025 almostm4 + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and +associated documentation files (the "Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the +following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial +portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT +LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO +EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE +USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..bee2d92 --- /dev/null +++ b/README.md @@ -0,0 +1,169 @@ +Snowflake Proxy Panel for Raspberry Pi +====================================== + +_Run a Tor_ _**Snowflake proxy**_ _at home with a beautiful, simple web dashboard._ + +Help people in censored regions reach the Tor network—right from your Raspberry Pi. **Snowflake Proxy Panel (“Snowpanel”)** makes it easy to run a **Snowflake proxy** with a clean, password-protected dashboard, live charts, totals, logs, and simple controls. No command-line expertise needed. + +Why run a Snowflake proxy? +-------------------------- + +* **Unblock access to Tor** – Your proxy helps users in censored networks connect over WebRTC. + +* **Strengthen digital freedom** – Join a global volunteer network that defeats censorship. + +* **Zero port-forwarding** – Unlike relays, Snowflake works without inbound ports from your ISP. + + +Highlights +---------- + +* **Friendly web interface** – Configure everything in minutes. + +* **Live traffic graph** – Visualize RX/TX over time. + +* **Totals at a glance** – See cumulative **RX total** and **TX total** for the window. + +* **Live logs** – Filter and view recent proxy logs right in the UI. + +* **Simple service controls** – Start/Stop/Restart the Snowflake service. + +* **Dark / Light theme** – One-click toggle; preference is remembered. + +* **First-time setup wizard** – Create admin user and set proxy basics in one flow: + + * **Broker URL** + + * **STUN server** + + * **Ephemeral ports range (outbound)** + + * Optional **unsafe verbose logging** + + * Bandwith limiters + +* **Lightweight** – Built for Raspberry Pi OS (Debian) Lite 64-bit. + + +> 🔒 The web dashboard is meant for your **local network**. You don’t need to expose it to the internet. + +Who is it for? +-------------- + +* **Home users** who want to help people bypass censorship. + +* **Makers & Raspberry Pi fans** looking for a meaningful always-on project. + +* **Community builders** who prefer a visual, low-maintenance setup. + + +What you need +------------- + +* A **Raspberry Pi** running Raspberry Pi OS (Debian) **Lite 64-bit**. + +* A stable **home internet connection**. + +* **Outbound UDP** allowed (WebRTC/STUN) and **ephemeral outbound range** you choose (default suggested 10000:65535). + +* **No port forwarding** required. + + +Setup in minutes +---------------- + +1. **Get the files**Clone or download this repository to your Raspberry Pi. + +2. sudo bash install.sh + +3. **Open the dashboard**Visit the Pi’s IP in your browser (e.g. http://192.168.1.23/), follow the **first-time setup**, and you’re done. + + +You’ll then see: + +* **Service status** (active/enabled, PID, version, flags) + +* **Traffic graph (48h)** and **RX/TX totals** + +* **Live logs** with quick filters (info/notice/warning/err/debug) + +* **Buttons** to Start/Restart/Stop the service + +* **Theme switcher** (Dark/Light) + + +Screenshots +----------- + +* **Dashboard (Dark Mode)** + ![SnowPanel – Dashboard (Dark)](screenshots/dashboard-dark.png) + +* **Dashboard (Light Mode)** + ![SnowPanel – Dashboard (Light)](screenshots/dashboard-light.png) + +* **Login Panel** + ![SnowPanel – Login](screenshots/login.png) + + + +Frequently asked questions +-------------------------- + +**Do I need to open ports on my router?**No. Snowflake uses WebRTC and works without inbound port forwarding. Ensure outbound UDP and your chosen ephemeral range are permitted by your firewall. + +**Will my IP be used to browse the web (like an exit)?**No. You are running a **Snowflake proxy**, not a Tor exit relay. You’re helping censored users reach Tor by relaying their WebRTC connection to the Tor network. + +**Why does my graph show zeros at first?**Clients are matched via the Snowflake broker. If no clients have been assigned recently, traffic may be low. Leave the proxy running—connections arrive intermittently based on global demand. + +**How do I verify it’s actually working?** + +* The **Service** card shows active and a valid **PID**. + +* **Logs** show lines like Proxy starting and occasional connection/traffic info. + +* The **Traffic** chart and **RX/TX totals** update after clients connect. + +* On the host, systemctl status snowflake-proxy will show **IP accounting** (bytes in/out) if enabled. + + +**Is the dashboard safe to expose to the internet?**Keep it **local-only**. There’s no need to expose the panel externally. If you must, put it behind proper auth/VPN and understand the risk. + +**Can I change broker/STUN/range later?**Yes—re-run the setup or adjust configuration via the UI (where available) and restart the service. + +Troubleshooting +--------------- + +**I only see (no logs) in the panel.** + +* Ensure the installer placed helper scripts and sudoers entries correctly. + +* Confirm journald is default (not forward-only) and that snowflake-proxy actually logs. + +* Try journalctl -u snowflake-proxy -n 200 on the host to verify log output exists. + + +**Graph shows zero traffic.** + +* systemctl status snowpanel-collector.timer + +* Leave the proxy running; client assignments are intermittent. + +* Verify outbound UDP isn’t blocked by your router/ISP and your chosen ephemeral range is allowed. + + +**Service shows active but no connections.** + +* This can be normal during quiet periods. Keep the device on. + +* Check **Broker URL** and **STUN** settings you used during setup. + + +Join the network +---------------- + +Power up your Raspberry Pi, run the installer, complete the setup, and let Snowpanel do the rest. Your small proxy can make a **real difference** for someone trying to reach a freer internet. + +Keywords (for discoverability) +------------------------------ + +Raspberry Pi Snowflake proxy, Tor Snowflake dashboard, easy Snowflake setup, bypass censorship WebRTC, run a Tor Snowflake node at home, open-source Snowflake panel, simple Snowflake web interface, live Snowflake traffic graphs, secure Snowflake management, Raspberry Pi privacy project, Snowflake proxy Raspberry Pi guide, local web dashboard for Snowflake, dark mode Snowflake panel, lightweight Snowflake admin UI \ No newline at end of file diff --git a/bin/snowpanel-collect.py b/bin/snowpanel-collect.py new file mode 100644 index 0000000..532c67b --- /dev/null +++ b/bin/snowpanel-collect.py @@ -0,0 +1,126 @@ +#!/usr/bin/env python3 +import json, subprocess, time, os, sys, math, shutil +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" + +def sh(cmd): + return subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, text=True).stdout.strip() + +def load_json(path, default): + try: + with open(path, "r") as f: return json.load(f) + except Exception: + return default + +def save_json(path, obj): + tmp = path + ".tmp" + with open(tmp, "w") as f: + json.dump(obj, f, separators=(",", ":"), ensure_ascii=False) + os.replace(tmp, path) + +def now(): + return int(time.time()) + +def service_bytes(): + out = sh(["/bin/systemctl","show","-p","IPIngressBytes","-p","IPEgressBytes","snowflake-proxy"]) + rx = tx = 0 + for line in out.splitlines(): + if line.startswith("IPIngressBytes="): + try: rx = int(line.split("=",1)[1] or "0") + except: pass + elif line.startswith("IPEgressBytes="): + try: tx = int(line.split("=",1)[1] or "0") + except: pass + return rx, tx + +def period_start_for_reset_day(reset_day: int) -> int: + reset_day = max(1, min(28, int(reset_day or 1))) + now_dt = datetime.now(timezone.utc).astimezone() + year = now_dt.year + month = now_dt.month + this_start = datetime(year, month, reset_day, 0, 0, 0, tzinfo=now_dt.tzinfo) + if now_dt >= this_start: + start = this_start + else: + if month == 1: + year -= 1; month = 12 + else: + month -= 1 + start = datetime(year, month, reset_day, 0, 0, 0, tzinfo=now_dt.tzinfo) + return int(start.timestamp()) + +def main(): + os.makedirs(STATE_DIR, exist_ok=True) + + rx, tx = service_bytes() + t = now() + + stats = load_json(STATS, {"data":[]}) + arr = stats.get("data", []) + arr.append({"t": t, "read": int(rx), "written": int(tx)}) + if len(arr) > 5000: + arr = arr[-5000:] + stats["data"] = arr + save_json(STATS, stats) + + cfg = load_json(CFG, {}) + 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)) + + start_ts = period_start_for_reset_day(cap_reset_day) + + rx_sum = 0 + tx_sum = 0 + prev = None + for point in arr: + if point["t"] < start_ts: + continue + if prev is not None: + dt = point["t"] - prev["t"] + if dt <= 0 or dt > 3600: + prev = point; continue + dr = max(0, point["read"] - prev["read"]) + dw = max(0, point["written"] - prev["written"]) + rx_sum += dr + tx_sum += dw + prev = point + + total = rx_sum + tx_sum + cap_bytes = cap_gb * 1024 * 1024 * 1024 if cap_gb > 0 else 0 + + current_rate_mbps = 0.0 + if len(arr) >= 2: + a = arr[-2]; b = arr[-1] + dt = max(1, b["t"] - a["t"]) + dr = max(0, b["read"] - a["read"]) + dw = max(0, b["written"] - a["written"]) + current_rate_mbps = ((dr + dw) * 8.0) / dt / 1_000_000.0 + + label = datetime.fromtimestamp(start_ts).strftime("%Y-%m-%d") + f" (reset day {cap_reset_day})" + meta = { + "start_ts": start_ts, + "period_label": label, + "rx": rx_sum, + "tx": tx_sum, + "total": total, + "cap_bytes": cap_bytes, + "cap_hit": False, + "current_rate_mbps": current_rate_mbps, + "rate_mbps": rate_mbps + } + + if cap_bytes and total >= cap_bytes: + meta["cap_hit"] = True + active = sh(["/bin/systemctl","is-active","snowflake-proxy"]) == "active" + if active: + subprocess.run(["/usr/bin/sudo","/bin/systemctl","stop","snowflake-proxy"], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + + save_json(META, meta) + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/bin/snowpanel-enforce.py b/bin/snowpanel-enforce.py new file mode 100644 index 0000000..70bc46d --- /dev/null +++ b/bin/snowpanel-enforce.py @@ -0,0 +1,227 @@ +#!/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-logdump b/bin/snowpanel-logdump new file mode 100644 index 0000000..52aea7b --- /dev/null +++ b/bin/snowpanel-logdump @@ -0,0 +1,33 @@ +#!/usr/bin/env bash +set -Eeuo pipefail +LINES="${1:-500}" +LEVEL="${2:-info}" +case "$LINES" in ''|*[!0-9]* ) LINES=500 ;; esac +case "$LEVEL" in debug|info|notice|warning|err) ;; * ) LEVEL=info ;; esac + +strip(){ sed -E 's/^.*\]:[[:space:]]*//'; } + +app_logs="" +if out="$(journalctl -t snowflake-proxy -p "$LEVEL" -n $((LINES*3)) -o short-iso --no-pager 2>/dev/null)"; then + app_logs="$out" +elif out="$(journalctl _COMM=snowflake-proxy -p "$LEVEL" -n $((LINES*3)) -o short-iso --no-pager 2>/dev/null)"; then + app_logs="$out" +fi + +sys_lines="" +if out="$(journalctl -u snowflake-proxy.service -p "$LEVEL" -n $((LINES*6)) -o short-iso --no-pager 2>/dev/null | grep -E ' (Started|Stopped|Stopping|Restarted|Reloaded|Failed) snowflake-proxy\.service|snowflake-proxy\.service: Consumed ')"; then + sys_lines="$out" +fi + +combined="$(printf "%s\n%s\n" "${app_logs:-}" "${sys_lines:-}" | sed '/^[[:space:]]*$/d' | sort)" +if [[ -n "$combined" ]]; then + echo "$combined" | tail -n "$LINES" | strip + exit 0 +fi + +if out="$(systemctl status snowflake-proxy --no-pager -l 2>/dev/null | grep -E ' snowflake-proxy\[[0-9]+\]:| (Started|Stopped|Stopping|Restarted|Reloaded|Failed) snowflake-proxy\.service|snowflake-proxy\.service: Consumed ')"; then + echo "$out" | tail -n "$LINES" | strip + exit 0 +fi + +exit 0 \ No newline at end of file diff --git a/bin/snowpanel-shaper b/bin/snowpanel-shaper new file mode 100644 index 0000000..b3ebe9d --- /dev/null +++ b/bin/snowpanel-shaper @@ -0,0 +1,143 @@ +#!/usr/bin/env bash +set -euo pipefail + +read_cfg() { + python3 - "$@" <<'PY' +import json,sys +cands=["/var/lib/snowpanel/app.json","/etc/snowpanel/app.json","/etc/snowpanel/limits.json","/var/lib/snowpanel/limits.json"] +rate=0 +for p in cands: + try: + with open(p,"r",encoding="utf-8") as f: + j=json.load(f) + if isinstance(j,dict): + v=j.get("rate_mbps") or (j.get("limits",{}) if isinstance(j.get("limits"),dict) else {}).get("rate_mbps") + if v is not None: + rate=int(v) + break + except Exception: + pass +print(rate) +PY +} + +get_iface() { + local ifs=() d4 d6 + d4=$(ip -o -4 route show to default 2>/dev/null | awk '{print $5}' | head -n1 || true) + d6=$(ip -o -6 route show ::/0 2>/dev/null | awk '{print $5}' | head -n1 || true) + [[ -n "${d4:-}" ]] && ifs+=("$d4") + [[ -n "${d6:-}" && "${d6:-}" != "${d4:-}" ]] && ifs+=("$d6") + [[ ${#ifs[@]} -eq 0 ]] && { echo ""; return 1; } + echo "${ifs[0]}" +} + +get_uid() { + local u pid + u=$(systemctl show -p User --value snowflake-proxy 2>/dev/null || true) + if [[ -n "${u:-}" ]]; then id -u "$u" 2>/dev/null || true; return 0; fi + if id -u snowflake &>/dev/null; then id -u snowflake; return 0; fi + pid=$(systemctl show -p MainPID --value snowflake-proxy 2>/dev/null || true) + if [[ -n "${pid:-}" && -r "/proc/$pid/status" ]]; then awk '/^Uid:/{print $2; exit}' "/proc/$pid/status"; return 0; fi + echo "" +} + +ipt_add() { + local bin="$1" chain="$2" rule="$3" + "$bin" -t mangle -N "$chain" 2>/dev/null || true + "$bin" -t mangle -C OUTPUT -j "$chain" 2>/dev/null || "$bin" -t mangle -A OUTPUT -j "$chain" + eval "$bin -t mangle -C $chain $rule" 2>/dev/null || eval "$bin -t mangle -A $chain $rule" +} +ipt_in_add() { + local bin="$1" chain="$2" + "$bin" -t mangle -N "$chain" 2>/dev/null || true + "$bin" -t mangle -C PREROUTING -j "$chain" 2>/dev/null || "$bin" -t mangle -A PREROUTING -j "$chain" + "$bin" -t mangle -C "$chain" -j CONNMARK --restore-mark 2>/dev/null || "$bin" -t mangle -A "$chain" -j CONNMARK --restore-mark +} +ipt_clear() { + local bin="$1" + for c in SNOWPANEL SNOWPANEL_IN; do + while "$bin" -t mangle -D OUTPUT -j SNOWPANEL 2>/dev/null; do :; done + while "$bin" -t mangle -D PREROUTING -j SNOWPANEL_IN 2>/dev/null; do :; done + "$bin" -t mangle -F "$c" 2>/dev/null || true + "$bin" -t mangle -X "$c" 2>/dev/null || true + done +} + +tc_clear() { + local ifc="$1" + tc qdisc del dev "$ifc" root 2>/dev/null || true + tc qdisc del dev "$ifc" ingress 2>/dev/null || true + tc qdisc del dev ifb0 root 2>/dev/null || true + tc qdisc del dev ifb0 ingress 2>/dev/null || true + ip link set ifb0 down 2>/dev/null || true + ip link delete ifb0 type ifb 2>/dev/null || true +} + +tc_apply() { + local ifc="$1" rate_kbit="$2" + tc qdisc replace dev "$ifc" root handle 1: htb default 30 + tc class add dev "$ifc" parent 1: classid 1:1 htb rate 10000000kbit ceil 10000000kbit 2>/dev/null || \ + tc class change dev "$ifc" parent 1: classid 1:1 htb rate 10000000kbit ceil 10000000kbit + tc class add dev "$ifc" parent 1:1 classid 1:10 htb rate "${rate_kbit}kbit" ceil "${rate_kbit}kbit" 2>/dev/null || \ + tc class change dev "$ifc" parent 1:1 classid 1:10 htb rate "${rate_kbit}kbit" ceil "${rate_kbit}kbit" + tc class add dev "$ifc" parent 1:1 classid 1:30 htb rate 9000000kbit ceil 10000000kbit 2>/dev/null || \ + tc class change dev "$ifc" parent 1:1 classid 1:30 htb rate 9000000kbit ceil 10000000kbit + tc filter replace dev "$ifc" parent 1: protocol all handle 0x1 fw flowid 1:10 + + modprobe ifb numifbs=1 2>/dev/null || true + ip link add ifb0 type ifb 2>/dev/null || true + ip link set dev ifb0 up + tc qdisc replace dev "$ifc" ingress + tc filter replace dev "$ifc" parent ffff: protocol all u32 match u32 0 0 action mirred egress redirect dev ifb0 + + tc qdisc replace dev ifb0 root handle 2: htb default 30 + tc class add dev ifb0 parent 2: classid 2:1 htb rate 10000000kbit ceil 10000000kbit 2>/dev/null || \ + tc class change dev ifb0 parent 2: classid 2:1 htb rate 10000000kbit ceil 10000000kbit + tc class add dev ifb0 parent 2:1 classid 2:10 htb rate "${rate_kbit}kbit" ceil "${rate_kbit}kbit" 2>/dev/null || \ + tc class change dev ifb0 parent 2:1 classid 2:10 htb rate "${rate_kbit}kbit" ceil "${rate_kbit}kbit" + tc class add dev ifb0 parent 2:1 classid 2:30 htb rate 9000000kbit ceil 10000000kbit 2>/dev/null || \ + tc class change dev ifb0 parent 2:1 classid 2:30 htb rate 9000000kbit ceil 10000000kbit + tc filter replace dev ifb0 parent 2: protocol all handle 0x1 fw flowid 2:10 +} + +apply() { + local rate uid ifc per_kbit + rate=$(read_cfg) + uid=$(get_uid) + ifc=$(get_iface || true) + ipt_clear iptables || true + ipt_clear ip6tables || true + if [[ -z "${ifc:-}" || -z "${uid:-}" || "$rate" -le 0 ]]; then + tc_clear "${ifc:-eth0}" || true + exit 0 + fi + per_kbit=$(( rate * 1000 / 2 )) + [[ $per_kbit -lt 64 ]] && per_kbit=64 + + ipt_add iptables SNOWPANEL "-m owner --uid-owner $uid -j MARK --set-xmark 0x1/0x1" + ipt_add iptables SNOWPANEL "-m owner --uid-owner $uid -j CONNMARK --save-mark" + ipt_in_add iptables SNOWPANEL_IN + if command -v ip6tables >/dev/null 2>&1; then + ipt_add ip6tables SNOWPANEL "-m owner --uid-owner $uid -j MARK --set-xmark 0x1/0x1" + ipt_add ip6tables SNOWPANEL "-m owner --uid-owner $uid -j CONNMARK --save-mark" + ipt_in_add ip6tables SNOWPANEL_IN + fi + + tc_clear "$ifc" || true + tc_apply "$ifc" "$per_kbit" +} + +clear_all() { + local ifc + ifc=$(get_iface || echo eth0) + ipt_clear iptables || true + ipt_clear ip6tables || true + tc_clear "$ifc" || true +} + +cmd="${1:-apply}" +case "$cmd" in + apply) apply ;; + clear) clear_all ;; + *) apply ;; +esac \ No newline at end of file diff --git a/install.sh b/install.sh new file mode 100644 index 0000000..5f1a1fd --- /dev/null +++ b/install.sh @@ -0,0 +1,192 @@ +#!/usr/bin/env bash +set -euo pipefail +C_RESET="\033[0m"; C_DIM="\033[2m"; C_BOLD="\033[1m" +C_RED="\033[31m"; C_GRN="\033[32m"; C_BLU="\033[34m"; C_YEL="\033[33m" +info(){ echo -e "${C_BLU}➜${C_RESET} $*"; } +ok(){ echo -e "${C_GRN}✓${C_RESET} $*"; } +warn(){ echo -e "${C_YEL}!${C_RESET} $*"; } +fail(){ echo -e "${C_RED}✗${C_RESET} $*"; } +if [[ $EUID -ne 0 ]]; then fail "Run as root (sudo)."; exit 1; fi +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +PANEL_ROOT="/var/www/snowpanel" +PANEL_PUBLIC="$PANEL_ROOT/public" +STATE_DIR="/var/lib/snowpanel" +LOG_DIR="/var/log/snowpanel" +ETC_APP="/etc/snowpanel" + +NGX_SITE_AVAIL="/etc/nginx/sites-available/snowpanel" +NGX_SITE_ENABL="/etc/nginx/sites-enabled/snowpanel" +SUDOERS_FILE="/etc/sudoers.d/snowpanel" + +COLLECTOR_SRC="$SCRIPT_DIR/bin/snowpanel-collect.py" +COLLECTOR_BIN="/usr/local/bin/snowpanel-collect.py" +LOGDUMP_SRC="$SCRIPT_DIR/bin/snowpanel-logdump" +LOGDUMP_BIN="/usr/local/bin/snowpanel-logdump" + +SHAPER_SRC="$SCRIPT_DIR/bin/snowpanel-shaper" +SHAPER_BIN="/usr/local/bin/snowpanel-shaper" +SHAPER_SVC="/etc/systemd/system/snowpanel-shaper.service" +SHAPER_PATH="/etc/systemd/system/snowpanel-shaper.path" + +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" + +export DEBIAN_FRONTEND=noninteractive + +echo -e "${C_BOLD}Installing SnowPanel...${C_RESET}" + +info "Updating apt and installing packages" +apt-get update -y >/dev/null +apt-get install -y --no-install-recommends snowflake-proxy nginx rsync php-fpm php-cli php-json php-curl php-zip php-common php-opcache python3 iproute2 iptables >/dev/null +ok "Packages installed" + +PHPV=$(php -r 'echo PHP_MAJOR_VERSION.".".PHP_MINOR_VERSION;' 2>/dev/null || echo "8.2") +PHP_FPM_SVC="php${PHPV}-fpm" +PHP_SOCK="/run/php/php${PHPV}-fpm.sock" +ln -sf "$PHP_SOCK" /run/php/php-fpm.sock || true + +info "Preparing directories" +install -d "$PANEL_PUBLIC" "$STATE_DIR" "$LOG_DIR" "$ETC_APP" +install -d -m 0755 /usr/local/bin +chown -R www-data:www-data "$PANEL_ROOT" || true +chown -R www-data:www-data "$STATE_DIR" "$LOG_DIR" || true +chmod 750 "$PANEL_ROOT" "$STATE_DIR" "$LOG_DIR" +ok "Directories ready" + +info "Deploying web" +rsync -a --delete "$SCRIPT_DIR/web/" "$PANEL_PUBLIC/" +chown -R www-data:www-data "$PANEL_PUBLIC" +ok "Web deployed" + +info "Seeding state" +echo -n '{"data":[]}' > "$STATE_DIR/stats.json" +chown www-data:www-data "$STATE_DIR/stats.json" +chmod 640 "$STATE_DIR/stats.json" +ok "State seeded" + +info "Writing Nginx site" +cat > "$NGX_SITE_AVAIL" <<'NGINX' +server { + listen 80 default_server; + server_name _; + root /var/www/snowpanel/public; + index index.php index.html; + location / { + try_files $uri $uri/ /index.php?$args; + } + location ~ \.php$ { + include snippets/fastcgi-php.conf; + fastcgi_pass unix:/run/php/php-fpm.sock; + } + location ~ /\. { + deny all; + } +} +NGINX +rm -f /etc/nginx/sites-enabled/default || true +ln -sf "$NGX_SITE_AVAIL" "$NGX_SITE_ENABL" +ok "Nginx site enabled" + +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 -d -m 0750 -o www-data -g www-data "$STATE_DIR" +ok "Helpers installed" + +info "Granting sudoers" +cat > "$SUDOERS_FILE" < "$SF_ACCOUNTING" < "$SVC" <<'UNIT' +[Unit] +Description=SnowPanel minute collector +After=snowflake-proxy.service + +[Service] +Type=oneshot +User=www-data +Group=www-data +ExecStart=/usr/bin/env python3 /usr/local/bin/snowpanel-collect.py +UNIT +cat > "$TIMER" <<'TIMER' +[Unit] +Description=Run SnowPanel collector every minute + +[Timer] +OnCalendar=*-*-* *:*:00 +Persistent=true +Unit=snowpanel-collector.service + +[Install] +WantedBy=timers.target +TIMER +ok "Collector units created" + +info "Creating shaper units" +cat > "$SHAPER_SVC" <<'UNIT' +[Unit] +Description=SnowPanel traffic shaper for snowflake-proxy +After=network-online.target snowflake-proxy.service +Wants=network-online.target + +[Service] +Type=oneshot +ExecStart=/usr/local/bin/snowpanel-shaper apply +ExecStop=/usr/local/bin/snowpanel-shaper clear +RemainAfterExit=yes + +[Install] +WantedBy=multi-user.target +UNIT + +cat > "$SHAPER_PATH" <<'PATHUNIT' +[Unit] +Description=Watch SnowPanel limits and reapply shaper + +[Path] +PathChanged=/var/lib/snowpanel/app.json +PathChanged=/etc/snowpanel/app.json +PathChanged=/etc/snowpanel/limits.json +PathChanged=/var/lib/snowpanel/limits.json + +[Install] +WantedBy=multi-user.target +PATHUNIT +ok "Shaper units created" + +info "Restarting services" +systemctl daemon-reload +systemctl enable "$PHP_FPM_SVC" nginx >/dev/null 2>&1 || true +systemctl restart "$PHP_FPM_SVC" || true +systemctl restart nginx +systemctl enable --now snowflake-proxy >/dev/null 2>&1 || true +systemctl restart snowflake-proxy || true +systemctl enable --now snowpanel-collector.timer +systemctl start snowpanel-collector.service || true +systemctl enable --now snowpanel-shaper.service snowpanel-shaper.path +systemctl restart snowpanel-shaper.service || true +ok "Services running" + +IP=$(hostname -I 2>/dev/null | awk '{print $1}') +echo +echo -e "${C_BOLD}All set!${C_RESET}" +echo -e "URL: ${C_GRN}http://$IP/${C_RESET}" +echo -e " First run: you'll see a setup page to create the admin user." \ No newline at end of file diff --git a/screenshots/dashboard-dark.png b/screenshots/dashboard-dark.png new file mode 100644 index 0000000000000000000000000000000000000000..3270ca556b12f2065c443e869226767b5c5280fa GIT binary patch literal 28998 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 literal 0 HcmV?d00001 diff --git a/screenshots/dashboard-light.png b/screenshots/dashboard-light.png new file mode 100644 index 0000000000000000000000000000000000000000..31608c5725d0e2ccb5376b38d9ca790fb19a9c40 GIT binary patch literal 31021 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%0AHU@``*8EZQ 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 Ola*BZR3rX5;Qs-Zuw>8x literal 0 HcmV?d00001 diff --git a/screenshots/login.png b/screenshots/login.png new file mode 100644 index 0000000000000000000000000000000000000000..9b9ad0bcb6187d3dc2153e362ee53ed2ebe2124f GIT binary patch literal 8020 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 llOAl=9JV}E-=PUn/dev/null || true +systemctl disable --now snowpanel-collector.service 2>/dev/null || true +ok "Timers stopped" + +info "Removing systemd units" +rm -f "$SVC" "$TIMER" +systemctl daemon-reload +ok "Units removed" + +info "Removing helper binaries" +rm -f "$COLLECTOR_BIN" "$LOGDUMP_BIN" +ok "Helpers removed" + +info "Removing Nginx site" +rm -f "$NGX_SITE_ENABL" "$NGX_SITE_AVAIL" +if nginx -t >/dev/null 2>&1; then systemctl reload nginx || true; fi +ok "Nginx site removed" + +info "Removing app files" +rm -rf "$PANEL_ROOT" "$STATE_DIR" "$LOG_DIR" "$ETC_APP" +ok "Files removed" + +info "Removing accounting drop-in" +rm -f "$SF_ACCOUNTING" +systemctl daemon-reload +systemctl restart snowflake-proxy 2>/dev/null || true +ok "Drop-in removed" + +info "Removing sudoers" +rm -f "$SUDOERS_FILE" +ok "Sudoers removed" + +if [[ $PURGE -eq 1 ]]; then + info "Purging packages" + apt-get purge -y "${PKGS[@]}" || true + apt-get autoremove -y || true + ok "Packages purged" +fi + +echo +echo -e "${C_BOLD}All clean!${C_RESET}" +if [[ $PURGE -eq 1 ]]; then + echo -e "SnowPanel removed and packages were purged." +else + echo -e "SnowPanel files removed; packages remain." +fi \ No newline at end of file diff --git a/web/api/cap_reset.php b/web/api/cap_reset.php new file mode 100644 index 0000000..2c0238d --- /dev/null +++ b/web/api/cap_reset.php @@ -0,0 +1,36 @@ +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/api/limits_get.php b/web/api/limits_get.php new file mode 100644 index 0000000..68914e6 --- /dev/null +++ b/web/api/limits_get.php @@ -0,0 +1,54 @@ + null, + 'period_label' => null, + 'rx' => 0, 'tx' => 0, 'total' => 0, + 'cap_bytes' => $cap_gb > 0 ? $cap_gb * 1024*1024*1024 : 0, + 'cap_hit' => false, +]; + +if (is_file($metaFile)) { + $m = json_decode((string)file_get_contents($metaFile), true); + if (is_array($m)) { + foreach (['start_ts','period_label','rx','tx','total','cap_bytes','cap_hit'] as $k) { + if (array_key_exists($k, $m)) $usage[$k] = $m[$k]; + } + } +} + +$current_rate_mbps = 0.0; +if (is_file($statsFile)) { + $s = json_decode((string)file_get_contents($statsFile), true); + $arr = is_array($s) ? ($s['data'] ?? []) : []; + $n = count($arr); + if ($n >= 2) { + $a = $arr[$n-2]; $b = $arr[$n-1]; + $dt = max(1, (int)$b['t'] - (int)$a['t']); + $dr = max(0, (int)$b['read'] - (int)$a['read']); + $dw = max(0, (int)$b['written'] - (int)$a['written']); + $current_rate_mbps = (($dr + $dw) * 8.0) / $dt / 1_000_000.0; + } +} + +echo json_encode([ + 'ok' => true, + 'cap_gb' => $cap_gb, + 'cap_reset_day' => $cap_reset_day, + 'rate_mbps' => $rate_mbps, + 'usage' => $usage, + 'current_rate_mbps' => $current_rate_mbps +]); diff --git a/web/api/limits_set.php b/web/api/limits_set.php new file mode 100644 index 0000000..099f166 --- /dev/null +++ b/web/api/limits_set.php @@ -0,0 +1,28 @@ + false, 'error' => 'bad json']); + exit; +} + +$cap_gb = max(0, (int)($in['cap_gb'] ?? 0)); +$cap_reset_day = min(28, max(1, (int)($in['cap_reset_day'] ?? 1))); +$rate_mbps = max(0, (int)($in['rate_mbps'] ?? 0)); + +$cfg = app_load_config(); +$cfg['cap_gb'] = $cap_gb; +$cfg['cap_reset_day'] = $cap_reset_day; +$cfg['rate_mbps'] = $rate_mbps; + +if (!app_save_config($cfg)) { + echo json_encode(['ok' => false, 'error' => 'save failed']); + exit; +} + +echo json_encode(['ok' => true]); diff --git a/web/api/snow_log.php b/web/api/snow_log.php new file mode 100644 index 0000000..7900aeb --- /dev/null +++ b/web/api/snow_log.php @@ -0,0 +1,23 @@ + 5000) $n = 5000; + +$l = $_GET['level'] ?? 'info'; +$levels = ['debug','info','notice','warning','err']; +$level = in_array($l,$levels,true) ? $l : 'info'; + +$out = []; +$rc = 0; + +$cmd = '/usr/bin/sudo /usr/local/bin/snowpanel-logdump ' . escapeshellarg((string)$n) . ' ' . escapeshellarg($level) . ' 2>&1'; +@exec($cmd, $out, $rc); + +$lines = array_values(array_filter($out, static function($x){ return trim($x) !== ''; })); + +echo json_encode(['ok'=>!empty($lines), 'lines'=>$lines], JSON_UNESCAPED_SLASHES); \ No newline at end of file diff --git a/web/api/snow_status.php b/web/api/snow_status.php new file mode 100644 index 0000000..180f5f5 --- /dev/null +++ b/web/api/snow_status.php @@ -0,0 +1,5 @@ +/dev/null'); + if (is_string($out) && $out!=='') $lines=preg_split('/\r?\n/',trim($out)); + else @exec('journalctl -u snowflake-proxy --since "48 hours ago" -o short-iso --no-pager 2>/dev/null',$lines); + $cum_rx=0; $cum_tx=0; $tmp=[]; + foreach($lines as $line){ + if(!preg_match('~^(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:[+\-]\d{2}:\d{2})?).*Traffic\s+Relayed.*?([0-9]+)\s*KB\s*,\s*([0-9]+)\s*KB~i',$line,$m)) continue; + $ts=strtotime($m[1]); if($ts===false) continue; + $tx_kb=(int)$m[2]; $rx_kb=(int)$m[3]; + $cum_rx+=$rx_kb*1024; $cum_tx+=$tx_kb*1024; + $tmp[]=['t'=>$ts,'read'=>$cum_rx,'written'=>$cum_tx]; + } + if(!empty($tmp)) $rows=$tmp; +} +usort($rows,function($a,$b){return $a['t']<=>$b['t'];}); +if(count($rows)===1){ + $d0=$rows[0]; + array_unshift($rows,['t'=>$d0['t']-60,'read'=>$d0['read'],'written'=>$d0['written']]); +} +$cut=time()-172800; +$rows=array_values(array_filter($rows,function($r)use($cut){return (int)$r['t']>=$cut;})); +echo json_encode(['ok'=>true,'data'=>$rows],JSON_UNESCAPED_SLASHES); diff --git a/web/assets/panel.css b/web/assets/panel.css new file mode 100644 index 0000000..c8bf4f6 --- /dev/null +++ b/web/assets/panel.css @@ -0,0 +1,133 @@ +:root{ + --bg:#0b1220; + --card:#111a2e; + --text:#e7ecf4; + --muted:#c6d3ee; + --brand:#2b7bff; + --border:#223257; + --input:#0c1527; + --grid: rgba(231,236,244,.15); +} + +:root[data-theme="light"]{ + --bg:#f7f9fc; + --card:#ffffff; + --text:#0b1220; + --muted:#56617a; + --brand:#0d6efd; + --border:#dee2e6; + --input:#ffffff; + --grid: rgba(0,0,0,.1); +} + +html,body{ + height:100%; + background:var(--bg); + color:var(--text); +} + +.card{ + background:var(--card); + border:1px solid var(--border); + border-radius:16px; + color:var(--text); +} + +h1,h2,h3,h4,h5,h6 { color:var(--text) !important; } +.h1,.h2,.h3,.h4,.h5,.h6 { color:var(--text) !important; } +.form-label,label{ color:var(--text) !important; } +.small, .text-muted, .form-text{ color:var(--muted) !important; } + +.mono{ + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace; + color: var(--text) !important; +} + +.form-control,.form-select{ + background:var(--input); + color:var(--text); + border-color:var(--border); +} +.form-control::placeholder{ color:#9db1d6; opacity:.8; } +:root[data-theme="light"] .form-control::placeholder{ color:#6c757d; } +.form-control:focus,.form-select:focus{ + background:var(--input); + color:var(--text); + border-color:var(--brand); + box-shadow:0 0 0 .2rem color-mix(in oklab, var(--brand) 25%, transparent); +} + +input:-webkit-autofill, +input:-webkit-autofill:hover, +input:-webkit-autofill:focus, +textarea:-webkit-autofill, +select:-webkit-autofill{ + -webkit-box-shadow:0 0 0 30px var(--input) inset !important; + -webkit-text-fill-color:var(--text) !important; + caret-color:var(--text); +} + +.btn-primary{ background:var(--brand); border:0; } +.btn-secondary{ background:#3a4663; border:0; color:#fff; } +:root[data-theme="light"] .btn-secondary{ background:#6c7aa6; } + +.page-wrap{ min-height:100vh; display:flex; align-items:center; justify-content:center; padding:24px; } +.maxw-960{ max-width:960px; } + +.equal-row > [class*="col-"]{ display:flex; } +.equal-row .card{ width:100%; } + +#trafficWrap{ height:260px; } +#trafficChart{ width:100%; height:100%; display:block; } + +.navbar-brand img{ width:20px; height:20px; margin-right:.5rem; vertical-align:-3px; } + +:root[data-theme="dark"] .alert-warning{ + background-color:#3b2b00 !important; + border-color:#8a6d1a !important; + color:var(--text) !important; +} +:root[data-theme="dark"] .alert-warning .fw-semibold, +:root[data-theme="dark"] .alert-warning .mono, +:root[data-theme="dark"] .alert-warning .small, +:root[data-theme="dark"] .alert-warning li, +:root[data-theme="dark"] .alert-warning p{ color:var(--text) !important; } +:root[data-theme="dark"] .alert-warning a{ color:#ffd267 !important; text-decoration:underline; } + +.input-group-text{ + background: var(--input); + color: var(--text); + border-color: var(--border); + padding: .375rem .5rem; +} + +.input-group .form-control{ + background: var(--input); + color: var(--text); + border-color: var(--border); +} +.input-group .form-control:focus{ + background: var(--input); + color: var(--text); + border-color: var(--brand); + box-shadow: 0 0 0 .2rem color-mix(in oklab, var(--brand) 25%, transparent); +} + +.input-group > .form-control, +.input-group > .form-select, +.input-group > .input-group-text{ + border-radius: .375rem; +} + +:root[data-theme="light"] .card .btn-outline-light{ + color: var(--brand) !important; + border-color: var(--brand) !important; +} + +:root[data-theme="light"] .card .btn-outline-light:hover, +:root[data-theme="light"] .card .btn-outline-light:focus{ + background: var(--brand) !important; + color: #fff !important; + border-color: var(--brand) !important; + box-shadow: 0 0 0 .2rem color-mix(in oklab, var(--brand) 25%, transparent); +} \ No newline at end of file diff --git a/web/favicon.svg b/web/favicon.svg new file mode 100644 index 0000000..34fb5aa --- /dev/null +++ b/web/favicon.svg @@ -0,0 +1,9 @@ + + + + + + \ No newline at end of file diff --git a/web/index.php b/web/index.php new file mode 100644 index 0000000..05a720e --- /dev/null +++ b/web/index.php @@ -0,0 +1,436 @@ +/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'); + +$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'; +$pid = (int)trim((string)@shell_exec('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 = '/etc/systemd/system/snowflake-proxy.service.d/override.conf'; +if (is_file($drop)) { + $txt = (string)@file_get_contents($drop); + if (preg_match('/^ExecStart=.*snowflake-proxy\s+(.*)$/m', $txt, $m)) $flags = trim($m[1]); +} +$flags_display = $flags !== '' ? $flags : 'defaults'; +?> + + + + + SnowPanel + + + + + + + + + + +
+
+ +
+
+
Service
+
Status: active' : 'inactive' ?>
+
Enabled:
+
PID:
+
Version:
+
Flags:
+
+ + + +
+
+
+ +
+
+
+
Traffic (last 48 hours)
+
+
+
+
+ +
+
+
+
+
RX (total)
+
+
+
+
+
+
TX (total)
+
+
+
+
+
+ +
+
+
+
Limits & Usage
+
+ + +
+
+ +
+
+
+
Monthly cap
+
+
+
+
Reset day
+
+
+
+
Throughput limit
+
+
+
+ +
+
This period usage
+
+
+
+
+
+
+
+
Current rate:
+
+
+ + +
+
+ +
+
+
+
Logs
+
+ + + +
+
+
Loading…
+
+
+ +
+
+ + + + \ No newline at end of file diff --git a/web/lib/app.php b/web/lib/app.php new file mode 100644 index 0000000..cc902ec --- /dev/null +++ b/web/lib/app.php @@ -0,0 +1,67 @@ +0,'path'=>'/','httponly'=>true,'samesite'=>'Lax']); + session_start(); +} + +define('APP_ETC','/etc/snowpanel'); +define('APP_VAR','/var/lib/snowpanel'); +define('APP_CFG_ETC', APP_ETC.'/app.json'); +define('APP_CFG_VAR', APP_VAR.'/app.json'); + +function app_cfg_path() { + if (is_file(APP_CFG_ETC)) return APP_CFG_ETC; + if (is_file(APP_CFG_VAR)) return APP_CFG_VAR; + return APP_CFG_ETC; +} + +function app_is_installed() { + $p = app_cfg_path(); + if (!is_file($p)) return false; + $j = json_decode((string)@file_get_contents($p), true); + return is_array($j) && !empty($j['admin_user']) && !empty($j['admin_pass']); +} + +function app_load_config() { + $p = app_cfg_path(); + $j = json_decode((string)@file_get_contents($p), true); + return is_array($j) ? $j : []; +} + +function app_save_config(array $cfg) { + $target = APP_CFG_ETC; + $dir = dirname($target); + if (!is_dir($dir)) @mkdir($dir, 0770, true); + $ok = @file_put_contents($target, json_encode($cfg, JSON_PRETTY_PRINT|JSON_UNESCAPED_SLASHES), LOCK_EX) !== false; + if (!$ok) { + $target = APP_CFG_VAR; + $dir = dirname($target); + if (!is_dir($dir)) @mkdir($dir, 0770, true); + $ok = @file_put_contents($target, json_encode($cfg, JSON_PRETTY_PRINT|JSON_UNESCAPED_SLASHES), LOCK_EX) !== false; + } + if ($ok) { + @chgrp(dirname($target), 'www-data'); @chmod(dirname($target), 0770); + @chgrp($target, 'www-data'); @chmod($target, 0660); + } + return $ok; +} + +function auth_login($u,$p) { + $cfg = app_load_config(); + $ok = ($cfg['admin_user'] ?? '') === $u && password_verify($p, $cfg['admin_pass'] ?? ''); + if ($ok) { $_SESSION['uid'] = $u; $_SESSION['ts'] = time(); } + return $ok; +} + +function auth_require() { + if (empty($_SESSION['uid'])) { header('Location: /login.php'); exit; } +} + +function auth_logout() { + $_SESSION = []; + if (ini_get('session.use_cookies')) { + $p = session_get_cookie_params(); + setcookie(session_name(), '', time()-42000, $p['path'] ?? '/', $p['domain'] ?? '', !empty($p['secure']), !empty($p['httponly'])); + } + @session_destroy(); +} \ No newline at end of file diff --git a/web/lib/snowctl.php b/web/lib/snowctl.php new file mode 100644 index 0000000..0e1afe1 --- /dev/null +++ b/web/lib/snowctl.php @@ -0,0 +1,28 @@ +$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['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($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; +} \ No newline at end of file diff --git a/web/login.php b/web/login.php new file mode 100644 index 0000000..9f123cc --- /dev/null +++ b/web/login.php @@ -0,0 +1,93 @@ + + + + + + Login · SnowPanel + + + + + + + + + +
+
+
+
+
+

Sign in

+ + + + + + + + + + + + + +
+
+ + +
+
+ + +
+ +
+ +
+
+
+
+
+ + + + \ No newline at end of file diff --git a/web/logout.php b/web/logout.php new file mode 100644 index 0000000..e81e153 --- /dev/null +++ b/web/logout.php @@ -0,0 +1,4 @@ + 28) { + $err = 'Reset day must be between 1 and 28.'; + } else { + $app_cfg = [ + 'admin_user' => $u, + 'admin_pass' => password_hash($p, PASSWORD_DEFAULT), + 'created_at' => date('c'), + 'cap_gb' => max(0, $cap_gb), + 'cap_reset_day' => $cap_reset_day, + 'rate_mbps' => max(0, $rate_mbps), + ]; + if (function_exists('app_save_config')) { + app_save_config($app_cfg); + } else { + $dir = '/etc/snowpanel'; + @mkdir($dir, 0770, true); + @file_put_contents("$dir/app.json", json_encode($app_cfg, JSON_PRETTY_PRINT), LOCK_EX); + @chgrp($dir, 'www-data'); @chmod($dir, 0770); + @chgrp("$dir/app.json", 'www-data'); @chmod("$dir/app.json", 0660); + } + + $flags = snowctl_build_flags([ + 'broker'=>$broker, + 'stun'=>$stun, + 'range'=>$range, + 'unsafe'=>$unsafe?1:0 + ]); + snowctl_apply_override($flags); + + header('Location: /login.php?ok=1'); exit; + } +} +?> + + + + + First-time Setup · SnowPanel + + + + + + + + + +
+
+
+
+
+

First-time Setup

+ + + + + +
+
Step 1
Admin account
+
+
+ + +
+
+ + +
+
+ + +
+
+ +
Step 2
Proxy basics
+ +
+
+ + +
+
+ + +
+
+ +
+
+ + +
+
+ > + +
+
+ +
Step 3
Limits
+ +
+
+ +
+ + GB +
+
0 = unlimited
+
+
+ + +
+
+ +
+ + Mbps +
+
0 = unlimited (display only)
+
+
+ +
+ +
+ +
+
Reminder
+
    +
  • No port forwarding is required for Snowflake.
  • +
  • Allow outbound UDP and the chosen ephemeral range.
  • +
+
+
+ +
+
+
+
+
+ + + + \ No newline at end of file