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