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

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

@@ -0,0 +1,36 @@
<?php
require __DIR__ . '/../../lib/app.php';
auth_require();
header('Content-Type: application/json');
$stateDir = '/var/lib/snowpanel';
$perFile = $stateDir . '/period.json';
$lockFile = $stateDir . '/cap.lock';
$cfg = app_load_config();
$cap_day = (int)($cfg['cap_day'] ?? 1);
$cap_day = max(1, min(28, $cap_day));
$tz = new DateTimeZone(@date_default_timezone_get() ?: 'UTC');
$now = new DateTime('now', $tz);
$y = (int)$now->format('Y');
$m = (int)$now->format('n');
$d = (int)$now->format('j');
if ($d < $cap_day) {
if ($m === 1) { $m = 12; $y -= 1; } else { $m -= 1; }
}
$anchor = (new DateTime(sprintf('%04d-%02d-%02d 00:00:00', $y, $m, $cap_day), $tz))->getTimestamp();
$period = [
'period_start' => $anchor,
'rx' => 0,
'tx' => 0,
'last_ing' => 0,
'last_egr' => 0,
];
@file_put_contents($perFile, json_encode($period, JSON_UNESCAPED_SLASHES));
@unlink($lockFile);
echo json_encode(['ok' => true]);

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

@@ -0,0 +1,54 @@
<?php
require __DIR__ . '/../lib/app.php';
auth_require();
header('Content-Type: application/json');
$cfg = app_load_config();
$cap_gb = (int)($cfg['cap_gb'] ?? 0);
$cap_reset_day = (int)($cfg['cap_reset_day'] ?? 1);
$rate_mbps = (int)($cfg['rate_mbps'] ?? 0);
$stateDir = '/var/lib/snowpanel';
$statsFile = $stateDir . '/stats.json';
$metaFile = $stateDir . '/meta.json';
$usage = [
'start_ts' => null,
'period_label' => null,
'rx' => 0, 'tx' => 0, 'total' => 0,
'cap_bytes' => $cap_gb > 0 ? $cap_gb * 1024*1024*1024 : 0,
'cap_hit' => false,
];
if (is_file($metaFile)) {
$m = json_decode((string)file_get_contents($metaFile), true);
if (is_array($m)) {
foreach (['start_ts','period_label','rx','tx','total','cap_bytes','cap_hit'] as $k) {
if (array_key_exists($k, $m)) $usage[$k] = $m[$k];
}
}
}
$current_rate_mbps = 0.0;
if (is_file($statsFile)) {
$s = json_decode((string)file_get_contents($statsFile), true);
$arr = is_array($s) ? ($s['data'] ?? []) : [];
$n = count($arr);
if ($n >= 2) {
$a = $arr[$n-2]; $b = $arr[$n-1];
$dt = max(1, (int)$b['t'] - (int)$a['t']);
$dr = max(0, (int)$b['read'] - (int)$a['read']);
$dw = max(0, (int)$b['written'] - (int)$a['written']);
$current_rate_mbps = (($dr + $dw) * 8.0) / $dt / 1_000_000.0;
}
}
echo json_encode([
'ok' => true,
'cap_gb' => $cap_gb,
'cap_reset_day' => $cap_reset_day,
'rate_mbps' => $rate_mbps,
'usage' => $usage,
'current_rate_mbps' => $current_rate_mbps
]);

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

@@ -0,0 +1,28 @@
<?php
require __DIR__ . '/../lib/app.php';
auth_require();
header('Content-Type: application/json; charset=utf-8');
$raw = file_get_contents('php://input');
$in = json_decode((string)$raw, true);
if (!is_array($in)) {
echo json_encode(['ok' => false, 'error' => 'bad json']);
exit;
}
$cap_gb = max(0, (int)($in['cap_gb'] ?? 0));
$cap_reset_day = min(28, max(1, (int)($in['cap_reset_day'] ?? 1)));
$rate_mbps = max(0, (int)($in['rate_mbps'] ?? 0));
$cfg = app_load_config();
$cfg['cap_gb'] = $cap_gb;
$cfg['cap_reset_day'] = $cap_reset_day;
$cfg['rate_mbps'] = $rate_mbps;
if (!app_save_config($cfg)) {
echo json_encode(['ok' => false, 'error' => 'save failed']);
exit;
}
echo json_encode(['ok' => true]);

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

@@ -0,0 +1,23 @@
<?php
require __DIR__ . '/../lib/app.php';
header('Content-Type: application/json');
@ini_set('display_errors','0');
@error_reporting(0);
$n = isset($_GET['n']) ? (int)$_GET['n'] : 500;
if ($n < 1) $n = 200;
if ($n > 5000) $n = 5000;
$l = $_GET['level'] ?? 'info';
$levels = ['debug','info','notice','warning','err'];
$level = in_array($l,$levels,true) ? $l : 'info';
$out = [];
$rc = 0;
$cmd = '/usr/bin/sudo /usr/local/bin/snowpanel-logdump ' . escapeshellarg((string)$n) . ' ' . escapeshellarg($level) . ' 2>&1';
@exec($cmd, $out, $rc);
$lines = array_values(array_filter($out, static function($x){ return trim($x) !== ''; }));
echo json_encode(['ok'=>!empty($lines), 'lines'=>$lines], JSON_UNESCAPED_SLASHES);

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

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

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

@@ -0,0 +1,33 @@
<?php
require __DIR__ . '/../lib/app.php';
header('Content-Type: application/json; charset=utf-8');
$rows=[];
$state='/var/lib/snowpanel/stats.json';
if (is_file($state)) {
$j=json_decode((string)@file_get_contents($state),true);
if (is_array($j) && isset($j['data']) && is_array($j['data'])) $rows=$j['data'];
}
if (count($rows)<2) {
$lines=[];
$out=@shell_exec('sudo /usr/local/bin/snowpanel-logdump 20000 info 2>/dev/null');
if (is_string($out) && $out!=='') $lines=preg_split('/\r?\n/',trim($out));
else @exec('journalctl -u snowflake-proxy --since "48 hours ago" -o short-iso --no-pager 2>/dev/null',$lines);
$cum_rx=0; $cum_tx=0; $tmp=[];
foreach($lines as $line){
if(!preg_match('~^(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:[+\-]\d{2}:\d{2})?).*Traffic\s+Relayed.*?([0-9]+)\s*KB\s*,\s*([0-9]+)\s*KB~i',$line,$m)) continue;
$ts=strtotime($m[1]); if($ts===false) continue;
$tx_kb=(int)$m[2]; $rx_kb=(int)$m[3];
$cum_rx+=$rx_kb*1024; $cum_tx+=$tx_kb*1024;
$tmp[]=['t'=>$ts,'read'=>$cum_rx,'written'=>$cum_tx];
}
if(!empty($tmp)) $rows=$tmp;
}
usort($rows,function($a,$b){return $a['t']<=>$b['t'];});
if(count($rows)===1){
$d0=$rows[0];
array_unshift($rows,['t'=>$d0['t']-60,'read'=>$d0['read'],'written'=>$d0['written']]);
}
$cut=time()-172800;
$rows=array_values(array_filter($rows,function($r)use($cut){return (int)$r['t']>=$cut;}));
echo json_encode(['ok'=>true,'data'=>$rows],JSON_UNESCAPED_SLASHES);

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

@@ -0,0 +1,133 @@
:root{
--bg:#0b1220;
--card:#111a2e;
--text:#e7ecf4;
--muted:#c6d3ee;
--brand:#2b7bff;
--border:#223257;
--input:#0c1527;
--grid: rgba(231,236,244,.15);
}
:root[data-theme="light"]{
--bg:#f7f9fc;
--card:#ffffff;
--text:#0b1220;
--muted:#56617a;
--brand:#0d6efd;
--border:#dee2e6;
--input:#ffffff;
--grid: rgba(0,0,0,.1);
}
html,body{
height:100%;
background:var(--bg);
color:var(--text);
}
.card{
background:var(--card);
border:1px solid var(--border);
border-radius:16px;
color:var(--text);
}
h1,h2,h3,h4,h5,h6 { color:var(--text) !important; }
.h1,.h2,.h3,.h4,.h5,.h6 { color:var(--text) !important; }
.form-label,label{ color:var(--text) !important; }
.small, .text-muted, .form-text{ color:var(--muted) !important; }
.mono{
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;
color: var(--text) !important;
}
.form-control,.form-select{
background:var(--input);
color:var(--text);
border-color:var(--border);
}
.form-control::placeholder{ color:#9db1d6; opacity:.8; }
:root[data-theme="light"] .form-control::placeholder{ color:#6c757d; }
.form-control:focus,.form-select:focus{
background:var(--input);
color:var(--text);
border-color:var(--brand);
box-shadow:0 0 0 .2rem color-mix(in oklab, var(--brand) 25%, transparent);
}
input:-webkit-autofill,
input:-webkit-autofill:hover,
input:-webkit-autofill:focus,
textarea:-webkit-autofill,
select:-webkit-autofill{
-webkit-box-shadow:0 0 0 30px var(--input) inset !important;
-webkit-text-fill-color:var(--text) !important;
caret-color:var(--text);
}
.btn-primary{ background:var(--brand); border:0; }
.btn-secondary{ background:#3a4663; border:0; color:#fff; }
:root[data-theme="light"] .btn-secondary{ background:#6c7aa6; }
.page-wrap{ min-height:100vh; display:flex; align-items:center; justify-content:center; padding:24px; }
.maxw-960{ max-width:960px; }
.equal-row > [class*="col-"]{ display:flex; }
.equal-row .card{ width:100%; }
#trafficWrap{ height:260px; }
#trafficChart{ width:100%; height:100%; display:block; }
.navbar-brand img{ width:20px; height:20px; margin-right:.5rem; vertical-align:-3px; }
:root[data-theme="dark"] .alert-warning{
background-color:#3b2b00 !important;
border-color:#8a6d1a !important;
color:var(--text) !important;
}
:root[data-theme="dark"] .alert-warning .fw-semibold,
:root[data-theme="dark"] .alert-warning .mono,
:root[data-theme="dark"] .alert-warning .small,
:root[data-theme="dark"] .alert-warning li,
:root[data-theme="dark"] .alert-warning p{ color:var(--text) !important; }
:root[data-theme="dark"] .alert-warning a{ color:#ffd267 !important; text-decoration:underline; }
.input-group-text{
background: var(--input);
color: var(--text);
border-color: var(--border);
padding: .375rem .5rem;
}
.input-group .form-control{
background: var(--input);
color: var(--text);
border-color: var(--border);
}
.input-group .form-control:focus{
background: var(--input);
color: var(--text);
border-color: var(--brand);
box-shadow: 0 0 0 .2rem color-mix(in oklab, var(--brand) 25%, transparent);
}
.input-group > .form-control,
.input-group > .form-select,
.input-group > .input-group-text{
border-radius: .375rem;
}
:root[data-theme="light"] .card .btn-outline-light{
color: var(--brand) !important;
border-color: var(--brand) !important;
}
:root[data-theme="light"] .card .btn-outline-light:hover,
:root[data-theme="light"] .card .btn-outline-light:focus{
background: var(--brand) !important;
color: #fff !important;
border-color: var(--brand) !important;
box-shadow: 0 0 0 .2rem color-mix(in oklab, var(--brand) 25%, transparent);
}

9
web/favicon.svg Normal file
View File

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

After

Width:  |  Height:  |  Size: 372 B

436
web/index.php Normal file
View File

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

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

@@ -0,0 +1,67 @@
<?php
if (session_status() !== PHP_SESSION_ACTIVE) {
session_set_cookie_params(['lifetime'=>0,'path'=>'/','httponly'=>true,'samesite'=>'Lax']);
session_start();
}
define('APP_ETC','/etc/snowpanel');
define('APP_VAR','/var/lib/snowpanel');
define('APP_CFG_ETC', APP_ETC.'/app.json');
define('APP_CFG_VAR', APP_VAR.'/app.json');
function app_cfg_path() {
if (is_file(APP_CFG_ETC)) return APP_CFG_ETC;
if (is_file(APP_CFG_VAR)) return APP_CFG_VAR;
return APP_CFG_ETC;
}
function app_is_installed() {
$p = app_cfg_path();
if (!is_file($p)) return false;
$j = json_decode((string)@file_get_contents($p), true);
return is_array($j) && !empty($j['admin_user']) && !empty($j['admin_pass']);
}
function app_load_config() {
$p = app_cfg_path();
$j = json_decode((string)@file_get_contents($p), true);
return is_array($j) ? $j : [];
}
function app_save_config(array $cfg) {
$target = APP_CFG_ETC;
$dir = dirname($target);
if (!is_dir($dir)) @mkdir($dir, 0770, true);
$ok = @file_put_contents($target, json_encode($cfg, JSON_PRETTY_PRINT|JSON_UNESCAPED_SLASHES), LOCK_EX) !== false;
if (!$ok) {
$target = APP_CFG_VAR;
$dir = dirname($target);
if (!is_dir($dir)) @mkdir($dir, 0770, true);
$ok = @file_put_contents($target, json_encode($cfg, JSON_PRETTY_PRINT|JSON_UNESCAPED_SLASHES), LOCK_EX) !== false;
}
if ($ok) {
@chgrp(dirname($target), 'www-data'); @chmod(dirname($target), 0770);
@chgrp($target, 'www-data'); @chmod($target, 0660);
}
return $ok;
}
function auth_login($u,$p) {
$cfg = app_load_config();
$ok = ($cfg['admin_user'] ?? '') === $u && password_verify($p, $cfg['admin_pass'] ?? '');
if ($ok) { $_SESSION['uid'] = $u; $_SESSION['ts'] = time(); }
return $ok;
}
function auth_require() {
if (empty($_SESSION['uid'])) { header('Location: /login.php'); exit; }
}
function auth_logout() {
$_SESSION = [];
if (ini_get('session.use_cookies')) {
$p = session_get_cookie_params();
setcookie(session_name(), '', time()-42000, $p['path'] ?? '/', $p['domain'] ?? '', !empty($p['secure']), !empty($p['httponly']));
}
@session_destroy();
}

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

@@ -0,0 +1,28 @@
<?php
function snowctl_ports_range_normalize($s){
$s=trim($s);
$s=str_replace('-' ,':',$s);
if(!preg_match('~^\d{1,5}:\d{1,5}$~',$s)) return '';
[$a,$b]=array_map('intval',explode(':',$s,2));
if($a<1||$b<1||$a>$b||$b>65535) return '';
return $a.':'.$b;
}
function snowctl_build_flags(array $o){
$f=[];
if(!empty($o['broker'])) $f[]='-broker '.escapeshellarg($o['broker']);
if(!empty($o['stun'])) $f[]='-stun '.escapeshellarg($o['stun']);
if(!empty($o['range'])){ $r=snowctl_ports_range_normalize($o['range']); if($r!=='') $f[]='-ephemeral-ports-range '.$r; }
if(!empty($o['unsafe'])) $f[]='-unsafe-logging';
return implode(' ',$f);
}
function snowctl_override_path(){ return '/etc/systemd/system/snowflake-proxy.service.d/override.conf'; }
function snowctl_apply_override($flags){
$d=dirname(snowctl_override_path());
if(!is_dir($d)) @mkdir($d,0755,true);
$exec='/usr/bin/snowflake-proxy';
$out="[Service]\nIPAccounting=yes\nExecStart=\nExecStart=$exec $flags\n";
@file_put_contents(snowctl_override_path(),$out);
@exec('systemctl daemon-reload 2>/dev/null');
@exec('systemctl restart snowflake-proxy 2>/dev/null');
return true;
}

93
web/login.php Normal file
View File

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

4
web/logout.php Normal file
View File

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

2
web/robots.txt Normal file
View File

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

207
web/setup.php Normal file
View File

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