This commit is contained in:
2025-11-15 09:07:04 +01:00
commit d5834de32e
26 changed files with 2170 additions and 0 deletions

18
LICENSE Normal file
View File

@@ -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.

169
README.md Normal file
View File

@@ -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 dont 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 Pis IP in your browser (e.g. http://192.168.1.23/), follow the **first-time setup**, and youre done.
Youll 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. Youre 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 its 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**. Theres 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 isnt 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

126
bin/snowpanel-collect.py Normal file
View File

@@ -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()

227
bin/snowpanel-enforce.py Normal file
View File

@@ -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)

33
bin/snowpanel-logdump Normal file
View File

@@ -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

143
bin/snowpanel-shaper Normal file
View File

@@ -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

192
install.sh Normal file
View File

@@ -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" <<SUD
www-data ALL=NOPASSWD:/usr/local/bin/snowpanel-logdump
www-data ALL=NOPASSWD:/bin/systemctl start snowflake-proxy, /bin/systemctl stop snowflake-proxy, /bin/systemctl restart snowflake-proxy
www-data ALL=NOPASSWD:/bin/systemctl restart snowpanel-shaper
SUD
chmod 440 "$SUDOERS_FILE"
ok "Sudoers set"
info "Enabling systemd IP accounting"
mkdir -p "$SF_DROPIN_DIR"
cat > "$SF_ACCOUNTING" <<EOF
[Service]
IPAccounting=yes
EOF
ok "Accounting drop-in written"
info "Creating collector units"
cat > "$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."

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

BIN
screenshots/login.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.8 KiB

104
uninstall.sh Normal file
View File

@@ -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

36
web/api/cap_reset.php Normal file
View File

@@ -0,0 +1,36 @@
<?php
require __DIR__ . '/../../lib/app.php';
auth_require();
header('Content-Type: application/json');
$stateDir = '/var/lib/snowpanel';
$perFile = $stateDir . '/period.json';
$lockFile = $stateDir . '/cap.lock';
$cfg = app_load_config();
$cap_day = (int)($cfg['cap_day'] ?? 1);
$cap_day = max(1, min(28, $cap_day));
$tz = new DateTimeZone(@date_default_timezone_get() ?: 'UTC');
$now = new DateTime('now', $tz);
$y = (int)$now->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]);

54
web/api/limits_get.php Normal file
View File

@@ -0,0 +1,54 @@
<?php
require __DIR__ . '/../lib/app.php';
auth_require();
header('Content-Type: application/json');
$cfg = app_load_config();
$cap_gb = (int)($cfg['cap_gb'] ?? 0);
$cap_reset_day = (int)($cfg['cap_reset_day'] ?? 1);
$rate_mbps = (int)($cfg['rate_mbps'] ?? 0);
$stateDir = '/var/lib/snowpanel';
$statsFile = $stateDir . '/stats.json';
$metaFile = $stateDir . '/meta.json';
$usage = [
'start_ts' => 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
]);

28
web/api/limits_set.php Normal file
View File

@@ -0,0 +1,28 @@
<?php
require __DIR__ . '/../lib/app.php';
auth_require();
header('Content-Type: application/json; charset=utf-8');
$raw = file_get_contents('php://input');
$in = json_decode((string)$raw, true);
if (!is_array($in)) {
echo json_encode(['ok' => 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]);

23
web/api/snow_log.php Normal file
View File

@@ -0,0 +1,23 @@
<?php
require __DIR__ . '/../lib/app.php';
header('Content-Type: application/json');
@ini_set('display_errors','0');
@error_reporting(0);
$n = isset($_GET['n']) ? (int)$_GET['n'] : 500;
if ($n < 1) $n = 200;
if ($n > 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);

5
web/api/snow_status.php Normal file
View File

@@ -0,0 +1,5 @@
<?php
require __DIR__ . '/../lib/app.php'; auth_require();
require __DIR__ . '/../lib/snowctl.php';
header('Content-Type: application/json');
echo json_encode(snowctl_status());

33
web/api/stats.php Normal file
View File

@@ -0,0 +1,33 @@
<?php
require __DIR__ . '/../lib/app.php';
header('Content-Type: application/json; charset=utf-8');
$rows=[];
$state='/var/lib/snowpanel/stats.json';
if (is_file($state)) {
$j=json_decode((string)@file_get_contents($state),true);
if (is_array($j) && isset($j['data']) && is_array($j['data'])) $rows=$j['data'];
}
if (count($rows)<2) {
$lines=[];
$out=@shell_exec('sudo /usr/local/bin/snowpanel-logdump 20000 info 2>/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);

133
web/assets/panel.css Normal file
View File

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

9
web/favicon.svg Normal file
View File

@@ -0,0 +1,9 @@
<?xml version="1.0"?>
<svg height="60" xmlns="http://www.w3.org/2000/svg"
xmlns:xlink="http://www.w3.org/1999/xlink" width="60">
<g id="s"><path id="r" fill="none" stroke="#70D"
stroke-width="2" stroke-linecap="round"
d="m39,4-9,9-9-9m9-2v56m9-1-9-9-9,9"/>
<use xlink:href="#r" transform="rotate(45 30,30)"/></g>
<use xlink:href="#s" transform="rotate(90 30,30)"/>
</svg>

After

Width:  |  Height:  |  Size: 372 B

436
web/index.php Normal file
View File

@@ -0,0 +1,436 @@
<?php
require __DIR__ . '/lib/app.php';
auth_require();
$act = $_POST['act'] ?? '';
if ($act === 'start') @exec('sudo /usr/bin/systemctl start snowflake-proxy 2>/dev/null');
if ($act === 'stop') @exec('sudo /usr/bin/systemctl stop snowflake-proxy 2>/dev/null');
if ($act === 'restart')@exec('sudo /usr/bin/systemctl restart snowflake-proxy 2>/dev/null');
$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';
?>
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>SnowPanel</title>
<meta name="viewport" content="width=device-width, initial-scale=1">
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
<link href="/assets/panel.css" rel="stylesheet">
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
<link rel="alternate icon" href="/favicon.svg">
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.1/dist/chart.umd.min.js"></script>
</head>
<body>
<nav class="navbar navbar-dark" style="background:linear-gradient(90deg,#0d6efd,#4dabf7);">
<div class="container-fluid">
<span class="navbar-brand fw-bold">
<img src="/favicon.svg" alt="">
SnowPanel
</span>
<div class="ms-auto d-flex align-items-center gap-2">
<button id="btnTheme" class="btn btn-sm btn-outline-light">Theme</button>
<a class="btn btn-sm btn-outline-light" href="/logout.php">Logout</a>
</div>
</div>
</nav>
<div class="container py-4">
<div class="row g-3">
<div class="col-md-12">
<div class="card p-3">
<div class="h5 mb-0">Service</div>
<div class="mb-1">Status: <?= $active ? '<span class="text-success">active</span>' : '<span class="text-danger">inactive</span>' ?></div>
<div class="mb-1">Enabled: <?= $enabled ? 'yes' : 'no' ?></div>
<div class="mb-1">PID: <span class="mono"><?= $pid ?: '—' ?></span></div>
<div class="mb-1">Version: <span class="mono"><?= htmlspecialchars($ver ?: '—') ?></span></div>
<div class="mb-1">Flags: <code><?= htmlspecialchars($flags_display) ?></code></div>
<form method="post" class="d-flex gap-2 mt-3">
<button name="act" value="start" class="btn btn-success btn-sm" type="submit">Start</button>
<button name="act" value="restart" class="btn btn-warning btn-sm" type="submit">Restart</button>
<button name="act" value="stop" class="btn btn-danger btn-sm" type="submit">Stop</button>
</form>
</div>
</div>
<div class="col-12">
<div class="card p-3">
<div class="d-flex justify-content-between align-items-center">
<div class="h5 mb-0">Traffic (last 48 hours)</div>
</div>
<div id="trafficWrap"><canvas id="trafficChart"></canvas></div>
</div>
</div>
<div class="col-12">
<div class="row g-3 equal-row">
<div class="col-md-6">
<div class="card p-3">
<div class="small">RX (total)</div>
<div class="h4 mono" id="rxTotal">—</div>
</div>
</div>
<div class="col-md-6">
<div class="card p-3">
<div class="small">TX (total)</div>
<div class="h4 mono" id="txTotal">—</div>
</div>
</div>
</div>
</div>
<div class="col-12">
<div class="card p-3">
<div class="d-flex justify-content-between align-items-center">
<div class="h5 mb-0">Limits & Usage</div>
<div class="d-flex align-items-center gap-2">
<span id="capBadge" class="badge bg-secondary">—</span>
<button id="btnEditLimits" class="btn btn-sm btn-outline-light">Edit</button>
</div>
</div>
<div id="limitsView" class="mt-2">
<div class="row g-3">
<div class="col-md-4">
<div class="small">Monthly cap</div>
<div class="mono" id="v_cap">—</div>
</div>
<div class="col-md-4">
<div class="small">Reset day</div>
<div class="mono" id="v_reset">—</div>
</div>
<div class="col-md-4">
<div class="small">Throughput limit</div>
<div class="mono" id="v_rate">—</div>
</div>
</div>
<div class="mt-3">
<div class="small mb-1">This period usage</div>
<div class="progress" role="progressbar" aria-label="Monthly cap" style="height: 14px;">
<div id="capBar" class="progress-bar" style="width:0%"></div>
</div>
<div class="d-flex justify-content-between small mt-1">
<div class="mono" id="usageLeft">—</div>
<div class="mono text-muted" id="periodLbl">—</div>
</div>
<div class="small mt-2">Current rate: <span class="mono" id="curRate">—</span></div>
</div>
</div>
<form id="limitsForm" class="mt-3" style="display:none;">
<div class="row g-3">
<div class="col-md-4">
<label class="form-label" for="cap_gb">Monthly cap</label>
<div class="input-group">
<input id="cap_gb" class="form-control" type="number" min="0" step="1">
<span class="input-group-text">GB</span>
</div>
</div>
<div class="col-md-4">
<label class="form-label" for="cap_reset_day">Reset day</label>
<select id="cap_reset_day" class="form-select">
<?php for($d=1;$d<=28;$d++) echo "<option>$d</option>"; ?>
</select>
</div>
<div class="col-md-4">
<label class="form-label" for="rate_mbps">Throughput limit</label>
<div class="input-group">
<input id="rate_mbps" class="form-control" type="number" min="0" step="1">
<span class="input-group-text">Mbps</span>
</div>
</div>
</div>
<div class="d-flex gap-2 mt-3">
<button class="btn btn-primary" id="saveLimits" type="submit">Save</button>
<button class="btn btn-secondary" id="cancelLimits" type="button">Cancel</button>
<span id="saveLimitsMsg" class="small ms-2"></span>
</div>
</form>
</div>
</div>
<div class="col-12">
<div class="card p-3">
<div class="d-flex align-items-center justify-content-between">
<div class="h5 mb-0">Logs</div>
<div class="d-flex align-items-center gap-2">
<select id="torlogLevel" class="form-select form-select-sm" style="width:auto;">
<option value="info" selected>info</option>
<option value="notice">notice</option>
<option value="warning">warning</option>
<option value="err">err</option>
<option value="debug">debug</option>
</select>
<select id="torlogLines" class="form-select form-select-sm" style="width:auto;">
<option value="200">Last 200</option>
<option value="500" selected>Last 500</option>
<option value="1000">Last 1000</option>
<option value="2000">Last 2000</option>
</select>
<button id="btnRefresh" class="btn btn-sm btn-outline-light">Refresh</button>
</div>
</div>
<pre id="logpre" class="bg-dark text-light p-2 rounded mt-2" style="height:340px; overflow:auto; font-size:.85rem;">Loading…</pre>
</div>
</div>
</div>
</div>
<script>
const $ = (id)=>document.getElementById(id);
const el = {
btnTheme: $('btnTheme'),
logpre: $('logpre'),
lines: $('torlogLines'),
lvl: $('torlogLevel'),
btnRefresh: $('btnRefresh'),
trafficCanvas: $('trafficChart'),
rxTotal: $('rxTotal'),
txTotal: $('txTotal'),
limitsView: $('limitsView'),
limitsForm: $('limitsForm'),
btnEditLimits: $('btnEditLimits'),
saveLimits: $('saveLimits'),
cancelLimits: $('cancelLimits'),
saveLimitsMsg: $('saveLimitsMsg'),
v_cap: $('v_cap'),
v_reset: $('v_reset'),
v_rate: $('v_rate'),
capBadge: $('capBadge'),
capBar: $('capBar'),
usageLeft: $('usageLeft'),
periodLbl: $('periodLbl'),
curRate: $('curRate'),
cap_gb: $('cap_gb'),
cap_reset_day: $('cap_reset_day'),
rate_mbps: $('rate_mbps'),
};
let chart = null;
const THEME_KEY='snowpanel:theme';
const mql=window.matchMedia('(prefers-color-scheme: dark)');
function preferredTheme(){
const s=localStorage.getItem(THEME_KEY);
return (s==='dark'||s==='light') ? s : (mql.matches ? 'dark' : 'light');
}
function getThemeColors(){
const cs=getComputedStyle(document.documentElement);
return {
text: cs.getPropertyValue('--text').trim() || '#e7ecf4',
grid: cs.getPropertyValue('--grid').trim() || 'rgba(231,236,244,.15)'
};
}
function setBtnLabel(t){
if(el.btnTheme) el.btnTheme.textContent = (t==='dark') ? '🌞 Light' : '🌙 Dark';
}
function applyTheme(t){
document.documentElement.setAttribute('data-theme', t);
localStorage.setItem(THEME_KEY, t);
setBtnLabel(t);
if(chart){
const c=getThemeColors();
chart.options.scales.x.ticks.color=c.text;
chart.options.scales.y.ticks.color=c.text;
chart.options.scales.x.grid.color=c.grid;
chart.options.scales.y.grid.color=c.grid;
if(chart.options.plugins?.legend?.labels) chart.options.plugins.legend.labels.color=c.text;
chart.update('none');
}
}
mql.addEventListener('change', e=>{
if(!localStorage.getItem(THEME_KEY)) applyTheme(e.matches?'dark':'light');
});
el.btnTheme?.addEventListener('click', ()=>{
const next=(document.documentElement.getAttribute('data-theme')==='dark')?'light':'dark';
applyTheme(next);
});
applyTheme(preferredTheme());
async function fetchLog(n,l){
const r=await fetch('/api/snow_log.php?n='+encodeURIComponent(n)+'&level='+encodeURIComponent(l),{cache:'no-store'});
if(!r.ok) return {ok:false,lines:[]};
return r.json();
}
async function loadInlineLogs(){
const n=el.lines?.value||'500';
const l=el.lvl?.value||'info';
const j=await fetchLog(n,l);
if(j.ok&&j.lines){ el.logpre.textContent=j.lines.join('\n'); el.logpre.scrollTop=el.logpre.scrollHeight; }
else el.logpre.textContent='(no logs)';
}
el.btnRefresh?.addEventListener('click', loadInlineLogs);
el.lines?.addEventListener('change', loadInlineLogs);
el.lvl?.addEventListener('change', loadInlineLogs);
loadInlineLogs();
setInterval(loadInlineLogs,60000);
function fmtBytes(n){
if(n<1024) return n+' B';
const u=['KB','MB','GB','TB']; let i=-1;
do{ n/=1024; i++; }while(n>=1024&&i<u.length-1);
return n.toFixed(2)+' '+u[i];
}
function setChartData(labels, rx, tx){
if(!el.trafficCanvas) return;
const ctx=el.trafficCanvas.getContext('2d');
const c=getThemeColors();
if(!chart){
chart=new Chart(ctx,{
type:'line',
data:{labels,datasets:[
{label:'RX/min',data:rx,tension:.3,pointRadius:2,borderWidth:2,borderColor:'#22c55e'},
{label:'TX/min',data:tx,tension:.3,pointRadius:2,borderWidth:2,borderColor:'#a78bfa'}
]},
options:{
responsive:true,
maintainAspectRatio:false,
animation:false,
plugins:{legend:{labels:{color:c.text}}},
scales:{
x:{ticks:{color:c.text},grid:{color:c.grid}},
y:{ticks:{color:c.text,callback:(v)=>fmtBytes(v)},grid:{color:c.grid}}
}
}
});
}else{
chart.data.labels=labels;
chart.data.datasets[0].data=rx;
chart.data.datasets[1].data=tx;
chart.update('none');
}
}
async function refreshChartAndTotals(){
const r=await fetch('/api/stats.php',{cache:'no-store'});
if(!r.ok) return;
const s=await r.json();
const data=s.data||[];
let labels=[],rx=[],tx=[], sumR=0, sumW=0;
for(let i=1;i<data.length;i++){
const dt=data[i].t-data[i-1].t;
if(dt<=0||dt>3600) continue;
const dr=Math.max(0,data[i].read-data[i-1].read);
const dw=Math.max(0,data[i].written-data[i-1].written);
labels.push(new Date(data[i].t*1000).toLocaleTimeString());
rx.push(dr); tx.push(dw);
sumR+=dr; sumW+=dw;
}
setChartData(labels,rx,tx);
if(el.rxTotal) el.rxTotal.textContent = sumR ? fmtBytes(sumR) : '—';
if(el.txTotal) el.txTotal.textContent = sumW ? fmtBytes(sumW) : '—';
}
refreshChartAndTotals();
setInterval(refreshChartAndTotals,60000);
function setLimitEditMode(on){
if(!el.limitsForm || !el.limitsView) return;
el.limitsForm.style.display = on ? 'block' : 'none';
el.limitsView.style.display = on ? 'none' : 'block';
if (el.btnEditLimits) el.btnEditLimits.style.display = on ? 'none' : 'inline-block';
}
async function loadLimits(){
const r = await fetch('/api/limits_get.php',{cache:'no-store'});
if(!r.ok) return;
const j = await r.json();
if(!j.ok) return;
const cap = j.cap_gb>0 ? (j.cap_gb+' GB') : 'Unlimited';
const rate= j.rate_mbps>0 ? (j.rate_mbps+' Mbps') : 'Unlimited';
if(el.v_cap) el.v_cap.textContent = cap;
if(el.v_reset) el.v_reset.textContent = j.cap_reset_day || '—';
if(el.v_rate) el.v_rate.textContent = rate;
const u = j.usage || {};
const total = u.total || 0;
let cap_bytes = (u.cap_bytes && u.cap_bytes > 0)
? u.cap_bytes
: ((j.cap_gb && j.cap_gb > 0) ? (j.cap_gb * 1073741824) : 0);
let pct = 0;
if (cap_bytes > 0) {
pct = Math.round((total * 100) / cap_bytes);
if (pct === 0 && total > 0) pct = 1;
if (pct > 100) pct = 100;
}
if (el.capBar) {
el.capBar.style.width = (cap_bytes > 0 ? pct : 0) + '%';
el.capBar.className = 'progress-bar ' + ((cap_bytes > 0 && pct >= 90) ? 'bg-danger' : (pct >= 70 ? 'bg-warning' : ''));
el.capBar.textContent = cap_bytes > 0 ? (pct + '%') : '—';
}
if(el.usageLeft){
if(cap_bytes>0){
const left = Math.max(0, cap_bytes-total);
el.usageLeft.textContent = fmtBytes(total) + ' / ' + fmtBytes(cap_bytes) + ' (left ' + fmtBytes(left) + ')';
} else {
el.usageLeft.textContent = fmtBytes(total) + ' used';
}
}
if(el.periodLbl){
el.periodLbl.textContent = u.period_label || '—';
}
if(el.curRate){
const cr = j.current_rate_mbps || 0;
el.curRate.textContent = cr.toFixed(2) + ' Mbps';
}
if (el.capBadge) {
if (cap_bytes > 0 && total >= cap_bytes) {
el.capBadge.textContent = 'Cap reached';
el.capBadge.className = 'badge bg-danger';
} else {
el.capBadge.textContent = 'Active';
el.capBadge.className = 'badge bg-success';
}
}
if(el.cap_gb) el.cap_gb.value = j.cap_gb ?? 0;
if(el.cap_reset_day) el.cap_reset_day.value = j.cap_reset_day ?? 1;
if(el.rate_mbps) el.rate_mbps.value = j.rate_mbps ?? 0;
}
el.btnEditLimits?.addEventListener('click', ()=> setLimitEditMode(true));
el.cancelLimits?.addEventListener('click', ()=> setLimitEditMode(false));
el.limitsForm?.addEventListener('submit', async (e)=>{
e.preventDefault();
if (el.saveLimitsMsg) el.saveLimitsMsg.textContent = 'Saving...';
const body = {
cap_gb: parseInt(el.cap_gb?.value||'0'),
cap_reset_day: parseInt(el.cap_reset_day?.value||'1'),
rate_mbps: parseInt(el.rate_mbps?.value||'0')
};
const r = await fetch('/api/limits_set.php', {method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify(body)});
const j = await r.json();
if (el.saveLimitsMsg) el.saveLimitsMsg.textContent = j.ok ? 'Saved' : ('Error: '+(j.error||'unknown'));
if (j.ok){
await loadLimits();
setLimitEditMode(false);
}
});
loadLimits();
setInterval(loadLimits, 15000);
</script>
</body>
</html>

67
web/lib/app.php Normal file
View File

@@ -0,0 +1,67 @@
<?php
if (session_status() !== PHP_SESSION_ACTIVE) {
session_set_cookie_params(['lifetime'=>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();
}

28
web/lib/snowctl.php Normal file
View File

@@ -0,0 +1,28 @@
<?php
function snowctl_ports_range_normalize($s){
$s=trim($s);
$s=str_replace('-' ,':',$s);
if(!preg_match('~^\d{1,5}:\d{1,5}$~',$s)) return '';
[$a,$b]=array_map('intval',explode(':',$s,2));
if($a<1||$b<1||$a>$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;
}

93
web/login.php Normal file
View File

@@ -0,0 +1,93 @@
<?php
require __DIR__ . '/lib/app.php';
if (app_is_installed() && !empty($_SESSION['uid'])) {
header('Location: /'); exit;
}
$err = '';
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$u = trim($_POST['u'] ?? '');
$p = $_POST['p'] ?? '';
if ($u !== '' && auth_login($u, $p)) {
header('Location: /'); exit;
}
$err = 'Invalid credentials';
}
?>
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Login · SnowPanel</title>
<meta name="viewport" content="width=device-width, initial-scale=1">
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
<link href="/assets/panel.css" rel="stylesheet">
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
<link rel="alternate icon" href="/favicon.svg">
</head>
<body>
<nav class="navbar navbar-dark" style="background:linear-gradient(90deg,#0d6efd,#4dabf7);">
<div class="container-fluid">
<span class="navbar-brand fw-bold">
<img src="/favicon.svg" alt="" style="width:20px;height:20px;margin-right:.5rem;vertical-align:-3px;">
SnowPanel
</span>
<div class="ms-auto">
<button id="btnTheme" type="button" class="btn btn-sm btn-outline-light">Theme</button>
</div>
</div>
</nav>
<div class="page-wrap">
<div class="container maxw-960">
<div class="row justify-content-center">
<div class="col-md-8 col-lg-6">
<div class="card p-4 shadow-lg">
<h1 class="h4 mb-3">Sign in</h1>
<?php if (isset($_GET['ok'])): ?>
<div class="alert alert-success py-2 mb-3" role="alert">Admin created. Sign in below.</div>
<?php endif; ?>
<?php if ($err): ?>
<div class="alert alert-danger py-2 mb-3" role="alert"><?= htmlspecialchars($err) ?></div>
<?php endif; ?>
<?php if (!app_is_installed()): ?>
<div class="alert alert-info py-2 mb-3" role="alert">
First time here? <a href="/setup.php">Run setup</a>
</div>
<?php endif; ?>
<form method="post" autocomplete="on">
<div class="mb-3">
<label class="form-label" for="u">Username</label>
<input class="form-control" id="u" name="u" required autofocus>
</div>
<div class="mb-3">
<label class="form-label" for="p">Password</label>
<input class="form-control" id="p" name="p" type="password" required autocomplete="current-password">
</div>
<button class="btn btn-primary w-100" type="submit">Login</button>
</form>
</div>
</div>
</div>
</div>
</div>
<script>
const THEME_KEY='snowpanel:theme';
const mql=window.matchMedia('(prefers-color-scheme: dark)');
const btn=document.getElementById('btnTheme');
function preferredTheme(){const s=localStorage.getItem(THEME_KEY);return (s==='dark'||s==='light')?s:(mql.matches?'dark':'light');}
function setBtnLabel(t){ if(btn) btn.textContent = (t==='dark') ? '🌞 Light' : '🌙 Dark'; }
function applyTheme(t){ document.documentElement.setAttribute('data-theme', t); localStorage.setItem(THEME_KEY, t); setBtnLabel(t); }
mql.addEventListener('change', e=>{ if(!localStorage.getItem(THEME_KEY)) applyTheme(e.matches?'dark':'light'); });
btn?.addEventListener('click', ()=>{ const next=(document.documentElement.getAttribute('data-theme')==='dark')?'light':'dark'; applyTheme(next); });
applyTheme(preferredTheme());
</script>
</body>
</html>

4
web/logout.php Normal file
View File

@@ -0,0 +1,4 @@
<?php
require __DIR__ . '/lib/app.php';
auth_logout();
header('Location: /login.php');

2
web/robots.txt Normal file
View File

@@ -0,0 +1,2 @@
User-agent: *
Disallow: /

207
web/setup.php Normal file
View File

@@ -0,0 +1,207 @@
<?php
require __DIR__ . '/lib/app.php';
require __DIR__ . '/lib/snowctl.php';
if (function_exists('app_is_installed') && app_is_installed()) {
header('Location: /'); exit;
}
$err = '';
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$u = trim($_POST['u'] ?? '');
$p = $_POST['p'] ?? '';
$p2 = $_POST['p2'] ?? '';
$broker = trim($_POST['broker'] ?? 'https://snowflake-broker.torproject.net/');
$stun = trim($_POST['stun'] ?? 'stun:stun.l.google.com:19302');
$range = trim($_POST['range'] ?? '10000:65535');
$unsafe = isset($_POST['unsafe']);
$cap_gb = (int)($_POST['cap_gb'] ?? 100);
$cap_reset_day= (int)($_POST['cap_reset_day']?? 1);
$rate_mbps = (int)($_POST['rate_mbps'] ?? 0);
if ($u === '' || $p === '') {
$err = 'Username and password are required.';
} elseif ($p !== $p2) {
$err = 'Passwords do not match.';
} elseif ($cap_reset_day < 1 || $cap_reset_day > 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;
}
}
?>
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>First-time Setup · SnowPanel</title>
<meta name="viewport" content="width=device-width, initial-scale=1">
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
<link href="/assets/panel.css" rel="stylesheet">
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
<link rel="alternate icon" href="/favicon.svg">
</head>
<body>
<nav class="navbar navbar-dark" style="background:linear-gradient(90deg,#0d6efd,#4dabf7);">
<div class="container-fluid">
<span class="navbar-brand fw-bold">
<img src="/favicon.svg" alt="" style="width:20px;height:20px;margin-right:.5rem;vertical-align:-3px;">
SnowPanel
</span>
<div class="ms-auto">
<button id="btnTheme" type="button" class="btn btn-sm btn-outline-light">Theme</button>
</div>
</div>
</nav>
<div class="page-wrap">
<div class="container maxw-960">
<div class="row justify-content-center">
<div class="col-lg-10">
<div class="card p-4 shadow-lg">
<h1 class="h4 mb-3">First-time Setup</h1>
<?php if ($err): ?>
<div class="alert alert-danger py-2 mb-3" role="alert"><?= htmlspecialchars($err) ?></div>
<?php endif; ?>
<form method="post" action="/setup.php" autocomplete="on">
<div class="mb-2"><div class="small text-muted">Step 1</div><div class="h5 mb-2">Admin account</div></div>
<div class="row g-3">
<div class="col-md-4">
<label class="form-label" for="u">Username</label>
<input class="form-control" id="u" name="u" required autofocus value="<?= htmlspecialchars($_POST['u'] ?? '') ?>">
</div>
<div class="col-md-4">
<label class="form-label" for="p">Password</label>
<input class="form-control" id="p" name="p" type="password" required autocomplete="new-password">
</div>
<div class="col-md-4">
<label class="form-label" for="p2">Confirm password</label>
<input class="form-control" id="p2" name="p2" type="password" required autocomplete="new-password">
</div>
</div>
<div class="mt-4 mb-2"><div class="small text-muted">Step 2</div><div class="h5 mb-2">Proxy basics</div></div>
<div class="row g-3">
<div class="col-md-6">
<label class="form-label" for="broker">Broker URL</label>
<input class="form-control" id="broker" name="broker" placeholder="https://snowflake-broker.torproject.net/"
value="<?= htmlspecialchars($_POST['broker'] ?? '') ?>">
</div>
<div class="col-md-6">
<label class="form-label" for="stun">STUN server</label>
<input class="form-control" id="stun" name="stun" placeholder="stun:stun.l.google.com:19302"
value="<?= htmlspecialchars($_POST['stun'] ?? '') ?>">
</div>
</div>
<div class="row g-3 mt-1">
<div class="col-md-4">
<label class="form-label" for="range">Ephemeral ports range</label>
<input class="form-control" id="range" name="range" placeholder="10000:65535"
value="<?= htmlspecialchars($_POST['range'] ?? '10000:65535') ?>">
</div>
<div class="col-md-4 form-check mt-4">
<input class="form-check-input" type="checkbox" id="unsafe" name="unsafe" <?= isset($_POST['unsafe']) ? 'checked' : '' ?>>
<label class="form-check-label" for="unsafe">Unsafe verbose logging</label>
</div>
</div>
<div class="mt-4 mb-2"><div class="small text-muted">Step 3</div><div class="h5 mb-2">Limits</div></div>
<div class="row g-3">
<div class="col-md-4">
<label class="form-label" for="cap_gb">Monthly cap</label>
<div class="input-group">
<input class="form-control" id="cap_gb" name="cap_gb" type="number" min="0" step="1" placeholder="100"
value="<?= htmlspecialchars($_POST['cap_gb'] ?? '100') ?>">
<span class="input-group-text">GB</span>
</div>
<div class="form-text">0 = unlimited</div>
</div>
<div class="col-md-4">
<label class="form-label" for="cap_reset_day">Reset day</label>
<select id="cap_reset_day" name="cap_reset_day" class="form-select">
<?php
$sel = (int)($_POST['cap_reset_day'] ?? 1);
for($d=1;$d<=28;$d++){
$s = ($sel===$d)?' selected':'';
echo "<option$s>$d</option>";
}
?>
</select>
</div>
<div class="col-md-4">
<label class="form-label" for="rate_mbps">Throughput limit</label>
<div class="input-group">
<input class="form-control" id="rate_mbps" name="rate_mbps" type="number" min="0" step="1" placeholder="0"
value="<?= htmlspecialchars($_POST['rate_mbps'] ?? '0') ?>">
<span class="input-group-text">Mbps</span>
</div>
<div class="form-text">0 = unlimited (display only)</div>
</div>
</div>
<div class="d-flex gap-2 mt-4">
<button class="btn btn-primary" type="submit">Save & Finish</button>
</div>
<div class="alert alert-warning mt-3 py-2">
<div class="fw-semibold">Reminder</div>
<ul class="mb-0">
<li>No port forwarding is required for Snowflake.</li>
<li>Allow outbound UDP and the chosen ephemeral range.</li>
</ul>
</div>
</form>
</div>
</div>
</div>
</div>
</div>
<script>
const THEME_KEY='snowpanel:theme';
const mql=window.matchMedia('(prefers-color-scheme: dark)');
const btn=document.getElementById('btnTheme');
function preferredTheme(){const s=localStorage.getItem(THEME_KEY);return (s==='dark'||s==='light')?s:(mql.matches?'dark':'light');}
function setBtnLabel(t){ if(btn) btn.textContent=(t==='dark')?'🌞 Light':'🌙 Dark'; }
function applyTheme(t){ document.documentElement.setAttribute('data-theme', t); localStorage.setItem(THEME_KEY,t); setBtnLabel(t); }
mql.addEventListener('change',e=>{ if(!localStorage.getItem(THEME_KEY)) applyTheme(e.matches?'dark':'light'); });
btn?.addEventListener('click',()=>applyTheme(document.documentElement.getAttribute('data-theme')==='dark'?'light':'dark'));
applyTheme(preferredTheme());
</script>
</body>
</html>