1.0.1 #1

Merged
thepetric merged 5 commits from feature/v1.0.1 into main 2026-06-22 11:59:34 +00:00
13 changed files with 630 additions and 294 deletions
+3 -2
View File
@@ -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.
+9 -2
View File
@@ -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()
@@ -15,6 +15,13 @@ def load_json(path, default):
with open(path, "r") as f: return json.load(f) with open(path, "r") as f: return json.load(f)
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"
@@ -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))
-227
View File
@@ -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)
+115
View File
@@ -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
View File
@@ -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'
+366
View File
@@ -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
View File
@@ -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"
-36
View File
@@ -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]);
+2
View File
@@ -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
View File
@@ -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>
+6
View File
@@ -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
View File
@@ -1,28 +1,93 @@
<?php <?php
function snowctl_ports_range_normalize($s){ function snowctl_ports_range_normalize($s){
$s=trim($s); $s=trim($s);
$s=str_replace('-' ,':',$s); $s=str_replace('-',':',$s);
if(!preg_match('~^\d{1,5}:\d{1,5}$~',$s)) return ''; if(!preg_match('~^\d{1,5}:\d{1,5}$~',$s)) return '';
[$a,$b]=array_map('intval',explode(':',$s,2)); [$a,$b]=array_map('intval',explode(':',$s,2));
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_from_options(array $o){
$broker=trim((string)($o['broker'] ?? ''));
$stun=trim((string)($o['stun'] ?? ''));
$range=snowctl_ports_range_normalize((string)($o['range'] ?? ''));
$unsafe=!empty($o['unsafe']);
$cmd='/usr/bin/sudo /usr/local/bin/snowpanel-snowctl apply';
if($broker!=='') $cmd.=' --broker '.escapeshellarg($broker);
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){ function snowctl_apply_override($flags){
$d=dirname(snowctl_override_path()); $cmd='/usr/bin/sudo /usr/local/bin/snowpanel-snowctl apply';
if(!is_dir($d)) @mkdir($d,0755,true); $tokens=str_getcsv($flags,' ');
$exec='/usr/bin/snowflake-proxy'; for($i=0;$i<count($tokens);$i++){
$out="[Service]\nIPAccounting=yes\nExecStart=\nExecStart=$exec $flags\n"; $t=$tokens[$i];
@file_put_contents(snowctl_override_path(),$out); if($t==='-broker' && isset($tokens[$i+1])){
@exec('systemctl daemon-reload 2>/dev/null'); $cmd.=' --broker '.escapeshellarg($tokens[++$i]);
@exec('systemctl restart snowflake-proxy 2>/dev/null'); } elseif($t==='-stun' && isset($tokens[$i+1])){
return true; $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',
];
} }
+8 -5
View File
@@ -47,15 +47,18 @@ 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.';
}
header('Location: /login.php?ok=1'); exit; if ($err === '') {
header('Location: /login.php?ok=1'); exit;
}
} }
} }
?> ?>
@@ -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>