Release
This commit is contained in:
18
LICENSE
Normal file
18
LICENSE
Normal 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
169
README.md
Normal 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 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)**
|
||||||
|

|
||||||
|
|
||||||
|
* **Dashboard (Light Mode)**
|
||||||
|

|
||||||
|
|
||||||
|
* **Login Panel**
|
||||||
|

|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
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
|
||||||
126
bin/snowpanel-collect.py
Normal file
126
bin/snowpanel-collect.py
Normal 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
227
bin/snowpanel-enforce.py
Normal 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
33
bin/snowpanel-logdump
Normal 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
143
bin/snowpanel-shaper
Normal 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
192
install.sh
Normal 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."
|
||||||
BIN
screenshots/dashboard-dark.png
Normal file
BIN
screenshots/dashboard-dark.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 28 KiB |
BIN
screenshots/dashboard-light.png
Normal file
BIN
screenshots/dashboard-light.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 30 KiB |
BIN
screenshots/login.png
Normal file
BIN
screenshots/login.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 7.8 KiB |
104
uninstall.sh
Normal file
104
uninstall.sh
Normal 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
36
web/api/cap_reset.php
Normal 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
54
web/api/limits_get.php
Normal 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
28
web/api/limits_set.php
Normal 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
23
web/api/snow_log.php
Normal 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
5
web/api/snow_status.php
Normal 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
33
web/api/stats.php
Normal 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
133
web/assets/panel.css
Normal 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
9
web/favicon.svg
Normal 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
436
web/index.php
Normal 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
67
web/lib/app.php
Normal 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
28
web/lib/snowctl.php
Normal 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
93
web/login.php
Normal 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
4
web/logout.php
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
<?php
|
||||||
|
require __DIR__ . '/lib/app.php';
|
||||||
|
auth_logout();
|
||||||
|
header('Location: /login.php');
|
||||||
2
web/robots.txt
Normal file
2
web/robots.txt
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
User-agent: *
|
||||||
|
Disallow: /
|
||||||
207
web/setup.php
Normal file
207
web/setup.php
Normal 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>
|
||||||
Reference in New Issue
Block a user