commit d5834de32e00f55378d3f0c7e5144d373a20bca2 Author: almostm4 Date: Sat Nov 15 09:07:04 2025 +0100 Release 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 0000000..3270ca5 Binary files /dev/null and b/screenshots/dashboard-dark.png differ diff --git a/screenshots/dashboard-light.png b/screenshots/dashboard-light.png new file mode 100644 index 0000000..31608c5 Binary files /dev/null and b/screenshots/dashboard-light.png differ diff --git a/screenshots/login.png b/screenshots/login.png new file mode 100644 index 0000000..9b9ad0b Binary files /dev/null and b/screenshots/login.png differ diff --git a/uninstall.sh b/uninstall.sh new file mode 100644 index 0000000..3081a3c --- /dev/null +++ b/uninstall.sh @@ -0,0 +1,104 @@ +#!/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} $*"; } +trap 'fail "An unexpected error occurred. Exiting."' ERR + +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_BIN="/usr/local/bin/snowpanel-collect.py" +LOGDUMP_BIN="/usr/local/bin/snowpanel-logdump" +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" + +PKGS=("snowflake-proxy" "nginx" "php-fpm" "php-cli" "php-json" "php-curl" "php-zip" "php-common" "php-opcache") + +YES=0 +PURGE=0 +for arg in "${@:-}"; do + case "$arg" in + -y|--yes) YES=1 ;; + -h|--help) + echo "Usage: sudo bash uninstall.sh [--yes]" + exit 0 ;; + *) ;; + esac +done + +if [[ $EUID -ne 0 ]]; then fail "Run as root (sudo)."; exit 1; fi +echo -e "${C_BOLD}Uninstalling SnowPanel...${C_RESET}" + +if [[ $YES -ne 1 ]]; then + read -r -p "This will remove SnowPanel files/configs. Continue? [y/N] " ans + case "${ans,,}" in y|yes) ;; *) warn "Aborted by user."; exit 0 ;; esac +fi +if [[ $YES -ne 1 ]]; then + echo + echo -e "${C_DIM}Optional:${C_RESET} Purge snowflake/nginx/php packages." + read -r -p "Also purge packages and autoremove? [y/N] " pans + case "${pans,,}" in y|yes) PURGE=1 ;; *) PURGE=0 ;; esac +else + PURGE=0 +fi + +info "Stopping timers" +systemctl disable --now snowpanel-collector.timer 2>/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