Release
This commit is contained in:
36
web/api/cap_reset.php
Normal file
36
web/api/cap_reset.php
Normal file
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
require __DIR__ . '/../../lib/app.php';
|
||||
auth_require();
|
||||
header('Content-Type: application/json');
|
||||
|
||||
$stateDir = '/var/lib/snowpanel';
|
||||
$perFile = $stateDir . '/period.json';
|
||||
$lockFile = $stateDir . '/cap.lock';
|
||||
|
||||
$cfg = app_load_config();
|
||||
$cap_day = (int)($cfg['cap_day'] ?? 1);
|
||||
$cap_day = max(1, min(28, $cap_day));
|
||||
|
||||
$tz = new DateTimeZone(@date_default_timezone_get() ?: 'UTC');
|
||||
$now = new DateTime('now', $tz);
|
||||
$y = (int)$now->format('Y');
|
||||
$m = (int)$now->format('n');
|
||||
$d = (int)$now->format('j');
|
||||
|
||||
if ($d < $cap_day) {
|
||||
if ($m === 1) { $m = 12; $y -= 1; } else { $m -= 1; }
|
||||
}
|
||||
$anchor = (new DateTime(sprintf('%04d-%02d-%02d 00:00:00', $y, $m, $cap_day), $tz))->getTimestamp();
|
||||
|
||||
$period = [
|
||||
'period_start' => $anchor,
|
||||
'rx' => 0,
|
||||
'tx' => 0,
|
||||
'last_ing' => 0,
|
||||
'last_egr' => 0,
|
||||
];
|
||||
|
||||
@file_put_contents($perFile, json_encode($period, JSON_UNESCAPED_SLASHES));
|
||||
@unlink($lockFile);
|
||||
|
||||
echo json_encode(['ok' => true]);
|
||||
54
web/api/limits_get.php
Normal file
54
web/api/limits_get.php
Normal file
@@ -0,0 +1,54 @@
|
||||
<?php
|
||||
require __DIR__ . '/../lib/app.php';
|
||||
auth_require();
|
||||
|
||||
header('Content-Type: application/json');
|
||||
|
||||
$cfg = app_load_config();
|
||||
$cap_gb = (int)($cfg['cap_gb'] ?? 0);
|
||||
$cap_reset_day = (int)($cfg['cap_reset_day'] ?? 1);
|
||||
$rate_mbps = (int)($cfg['rate_mbps'] ?? 0);
|
||||
|
||||
$stateDir = '/var/lib/snowpanel';
|
||||
$statsFile = $stateDir . '/stats.json';
|
||||
$metaFile = $stateDir . '/meta.json';
|
||||
|
||||
$usage = [
|
||||
'start_ts' => null,
|
||||
'period_label' => null,
|
||||
'rx' => 0, 'tx' => 0, 'total' => 0,
|
||||
'cap_bytes' => $cap_gb > 0 ? $cap_gb * 1024*1024*1024 : 0,
|
||||
'cap_hit' => false,
|
||||
];
|
||||
|
||||
if (is_file($metaFile)) {
|
||||
$m = json_decode((string)file_get_contents($metaFile), true);
|
||||
if (is_array($m)) {
|
||||
foreach (['start_ts','period_label','rx','tx','total','cap_bytes','cap_hit'] as $k) {
|
||||
if (array_key_exists($k, $m)) $usage[$k] = $m[$k];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$current_rate_mbps = 0.0;
|
||||
if (is_file($statsFile)) {
|
||||
$s = json_decode((string)file_get_contents($statsFile), true);
|
||||
$arr = is_array($s) ? ($s['data'] ?? []) : [];
|
||||
$n = count($arr);
|
||||
if ($n >= 2) {
|
||||
$a = $arr[$n-2]; $b = $arr[$n-1];
|
||||
$dt = max(1, (int)$b['t'] - (int)$a['t']);
|
||||
$dr = max(0, (int)$b['read'] - (int)$a['read']);
|
||||
$dw = max(0, (int)$b['written'] - (int)$a['written']);
|
||||
$current_rate_mbps = (($dr + $dw) * 8.0) / $dt / 1_000_000.0;
|
||||
}
|
||||
}
|
||||
|
||||
echo json_encode([
|
||||
'ok' => true,
|
||||
'cap_gb' => $cap_gb,
|
||||
'cap_reset_day' => $cap_reset_day,
|
||||
'rate_mbps' => $rate_mbps,
|
||||
'usage' => $usage,
|
||||
'current_rate_mbps' => $current_rate_mbps
|
||||
]);
|
||||
28
web/api/limits_set.php
Normal file
28
web/api/limits_set.php
Normal file
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
require __DIR__ . '/../lib/app.php';
|
||||
auth_require();
|
||||
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
|
||||
$raw = file_get_contents('php://input');
|
||||
$in = json_decode((string)$raw, true);
|
||||
if (!is_array($in)) {
|
||||
echo json_encode(['ok' => false, 'error' => 'bad json']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$cap_gb = max(0, (int)($in['cap_gb'] ?? 0));
|
||||
$cap_reset_day = min(28, max(1, (int)($in['cap_reset_day'] ?? 1)));
|
||||
$rate_mbps = max(0, (int)($in['rate_mbps'] ?? 0));
|
||||
|
||||
$cfg = app_load_config();
|
||||
$cfg['cap_gb'] = $cap_gb;
|
||||
$cfg['cap_reset_day'] = $cap_reset_day;
|
||||
$cfg['rate_mbps'] = $rate_mbps;
|
||||
|
||||
if (!app_save_config($cfg)) {
|
||||
echo json_encode(['ok' => false, 'error' => 'save failed']);
|
||||
exit;
|
||||
}
|
||||
|
||||
echo json_encode(['ok' => true]);
|
||||
23
web/api/snow_log.php
Normal file
23
web/api/snow_log.php
Normal file
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
require __DIR__ . '/../lib/app.php';
|
||||
header('Content-Type: application/json');
|
||||
@ini_set('display_errors','0');
|
||||
@error_reporting(0);
|
||||
|
||||
$n = isset($_GET['n']) ? (int)$_GET['n'] : 500;
|
||||
if ($n < 1) $n = 200;
|
||||
if ($n > 5000) $n = 5000;
|
||||
|
||||
$l = $_GET['level'] ?? 'info';
|
||||
$levels = ['debug','info','notice','warning','err'];
|
||||
$level = in_array($l,$levels,true) ? $l : 'info';
|
||||
|
||||
$out = [];
|
||||
$rc = 0;
|
||||
|
||||
$cmd = '/usr/bin/sudo /usr/local/bin/snowpanel-logdump ' . escapeshellarg((string)$n) . ' ' . escapeshellarg($level) . ' 2>&1';
|
||||
@exec($cmd, $out, $rc);
|
||||
|
||||
$lines = array_values(array_filter($out, static function($x){ return trim($x) !== ''; }));
|
||||
|
||||
echo json_encode(['ok'=>!empty($lines), 'lines'=>$lines], JSON_UNESCAPED_SLASHES);
|
||||
5
web/api/snow_status.php
Normal file
5
web/api/snow_status.php
Normal file
@@ -0,0 +1,5 @@
|
||||
<?php
|
||||
require __DIR__ . '/../lib/app.php'; auth_require();
|
||||
require __DIR__ . '/../lib/snowctl.php';
|
||||
header('Content-Type: application/json');
|
||||
echo json_encode(snowctl_status());
|
||||
33
web/api/stats.php
Normal file
33
web/api/stats.php
Normal file
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
require __DIR__ . '/../lib/app.php';
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
|
||||
$rows=[];
|
||||
$state='/var/lib/snowpanel/stats.json';
|
||||
if (is_file($state)) {
|
||||
$j=json_decode((string)@file_get_contents($state),true);
|
||||
if (is_array($j) && isset($j['data']) && is_array($j['data'])) $rows=$j['data'];
|
||||
}
|
||||
if (count($rows)<2) {
|
||||
$lines=[];
|
||||
$out=@shell_exec('sudo /usr/local/bin/snowpanel-logdump 20000 info 2>/dev/null');
|
||||
if (is_string($out) && $out!=='') $lines=preg_split('/\r?\n/',trim($out));
|
||||
else @exec('journalctl -u snowflake-proxy --since "48 hours ago" -o short-iso --no-pager 2>/dev/null',$lines);
|
||||
$cum_rx=0; $cum_tx=0; $tmp=[];
|
||||
foreach($lines as $line){
|
||||
if(!preg_match('~^(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:[+\-]\d{2}:\d{2})?).*Traffic\s+Relayed.*?([0-9]+)\s*KB\s*,\s*([0-9]+)\s*KB~i',$line,$m)) continue;
|
||||
$ts=strtotime($m[1]); if($ts===false) continue;
|
||||
$tx_kb=(int)$m[2]; $rx_kb=(int)$m[3];
|
||||
$cum_rx+=$rx_kb*1024; $cum_tx+=$tx_kb*1024;
|
||||
$tmp[]=['t'=>$ts,'read'=>$cum_rx,'written'=>$cum_tx];
|
||||
}
|
||||
if(!empty($tmp)) $rows=$tmp;
|
||||
}
|
||||
usort($rows,function($a,$b){return $a['t']<=>$b['t'];});
|
||||
if(count($rows)===1){
|
||||
$d0=$rows[0];
|
||||
array_unshift($rows,['t'=>$d0['t']-60,'read'=>$d0['read'],'written'=>$d0['written']]);
|
||||
}
|
||||
$cut=time()-172800;
|
||||
$rows=array_values(array_filter($rows,function($r)use($cut){return (int)$r['t']>=$cut;}));
|
||||
echo json_encode(['ok'=>true,'data'=>$rows],JSON_UNESCAPED_SLASHES);
|
||||
133
web/assets/panel.css
Normal file
133
web/assets/panel.css
Normal file
@@ -0,0 +1,133 @@
|
||||
:root{
|
||||
--bg:#0b1220;
|
||||
--card:#111a2e;
|
||||
--text:#e7ecf4;
|
||||
--muted:#c6d3ee;
|
||||
--brand:#2b7bff;
|
||||
--border:#223257;
|
||||
--input:#0c1527;
|
||||
--grid: rgba(231,236,244,.15);
|
||||
}
|
||||
|
||||
:root[data-theme="light"]{
|
||||
--bg:#f7f9fc;
|
||||
--card:#ffffff;
|
||||
--text:#0b1220;
|
||||
--muted:#56617a;
|
||||
--brand:#0d6efd;
|
||||
--border:#dee2e6;
|
||||
--input:#ffffff;
|
||||
--grid: rgba(0,0,0,.1);
|
||||
}
|
||||
|
||||
html,body{
|
||||
height:100%;
|
||||
background:var(--bg);
|
||||
color:var(--text);
|
||||
}
|
||||
|
||||
.card{
|
||||
background:var(--card);
|
||||
border:1px solid var(--border);
|
||||
border-radius:16px;
|
||||
color:var(--text);
|
||||
}
|
||||
|
||||
h1,h2,h3,h4,h5,h6 { color:var(--text) !important; }
|
||||
.h1,.h2,.h3,.h4,.h5,.h6 { color:var(--text) !important; }
|
||||
.form-label,label{ color:var(--text) !important; }
|
||||
.small, .text-muted, .form-text{ color:var(--muted) !important; }
|
||||
|
||||
.mono{
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;
|
||||
color: var(--text) !important;
|
||||
}
|
||||
|
||||
.form-control,.form-select{
|
||||
background:var(--input);
|
||||
color:var(--text);
|
||||
border-color:var(--border);
|
||||
}
|
||||
.form-control::placeholder{ color:#9db1d6; opacity:.8; }
|
||||
:root[data-theme="light"] .form-control::placeholder{ color:#6c757d; }
|
||||
.form-control:focus,.form-select:focus{
|
||||
background:var(--input);
|
||||
color:var(--text);
|
||||
border-color:var(--brand);
|
||||
box-shadow:0 0 0 .2rem color-mix(in oklab, var(--brand) 25%, transparent);
|
||||
}
|
||||
|
||||
input:-webkit-autofill,
|
||||
input:-webkit-autofill:hover,
|
||||
input:-webkit-autofill:focus,
|
||||
textarea:-webkit-autofill,
|
||||
select:-webkit-autofill{
|
||||
-webkit-box-shadow:0 0 0 30px var(--input) inset !important;
|
||||
-webkit-text-fill-color:var(--text) !important;
|
||||
caret-color:var(--text);
|
||||
}
|
||||
|
||||
.btn-primary{ background:var(--brand); border:0; }
|
||||
.btn-secondary{ background:#3a4663; border:0; color:#fff; }
|
||||
:root[data-theme="light"] .btn-secondary{ background:#6c7aa6; }
|
||||
|
||||
.page-wrap{ min-height:100vh; display:flex; align-items:center; justify-content:center; padding:24px; }
|
||||
.maxw-960{ max-width:960px; }
|
||||
|
||||
.equal-row > [class*="col-"]{ display:flex; }
|
||||
.equal-row .card{ width:100%; }
|
||||
|
||||
#trafficWrap{ height:260px; }
|
||||
#trafficChart{ width:100%; height:100%; display:block; }
|
||||
|
||||
.navbar-brand img{ width:20px; height:20px; margin-right:.5rem; vertical-align:-3px; }
|
||||
|
||||
:root[data-theme="dark"] .alert-warning{
|
||||
background-color:#3b2b00 !important;
|
||||
border-color:#8a6d1a !important;
|
||||
color:var(--text) !important;
|
||||
}
|
||||
:root[data-theme="dark"] .alert-warning .fw-semibold,
|
||||
:root[data-theme="dark"] .alert-warning .mono,
|
||||
:root[data-theme="dark"] .alert-warning .small,
|
||||
:root[data-theme="dark"] .alert-warning li,
|
||||
:root[data-theme="dark"] .alert-warning p{ color:var(--text) !important; }
|
||||
:root[data-theme="dark"] .alert-warning a{ color:#ffd267 !important; text-decoration:underline; }
|
||||
|
||||
.input-group-text{
|
||||
background: var(--input);
|
||||
color: var(--text);
|
||||
border-color: var(--border);
|
||||
padding: .375rem .5rem;
|
||||
}
|
||||
|
||||
.input-group .form-control{
|
||||
background: var(--input);
|
||||
color: var(--text);
|
||||
border-color: var(--border);
|
||||
}
|
||||
.input-group .form-control:focus{
|
||||
background: var(--input);
|
||||
color: var(--text);
|
||||
border-color: var(--brand);
|
||||
box-shadow: 0 0 0 .2rem color-mix(in oklab, var(--brand) 25%, transparent);
|
||||
}
|
||||
|
||||
.input-group > .form-control,
|
||||
.input-group > .form-select,
|
||||
.input-group > .input-group-text{
|
||||
border-radius: .375rem;
|
||||
}
|
||||
|
||||
:root[data-theme="light"] .card .btn-outline-light{
|
||||
color: var(--brand) !important;
|
||||
border-color: var(--brand) !important;
|
||||
}
|
||||
|
||||
:root[data-theme="light"] .card .btn-outline-light:hover,
|
||||
:root[data-theme="light"] .card .btn-outline-light:focus{
|
||||
background: var(--brand) !important;
|
||||
color: #fff !important;
|
||||
border-color: var(--brand) !important;
|
||||
box-shadow: 0 0 0 .2rem color-mix(in oklab, var(--brand) 25%, transparent);
|
||||
}
|
||||
9
web/favicon.svg
Normal file
9
web/favicon.svg
Normal file
@@ -0,0 +1,9 @@
|
||||
<?xml version="1.0"?>
|
||||
<svg height="60" xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:xlink="http://www.w3.org/1999/xlink" width="60">
|
||||
<g id="s"><path id="r" fill="none" stroke="#70D"
|
||||
stroke-width="2" stroke-linecap="round"
|
||||
d="m39,4-9,9-9-9m9-2v56m9-1-9-9-9,9"/>
|
||||
<use xlink:href="#r" transform="rotate(45 30,30)"/></g>
|
||||
<use xlink:href="#s" transform="rotate(90 30,30)"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 372 B |
436
web/index.php
Normal file
436
web/index.php
Normal file
@@ -0,0 +1,436 @@
|
||||
<?php
|
||||
require __DIR__ . '/lib/app.php';
|
||||
auth_require();
|
||||
|
||||
$act = $_POST['act'] ?? '';
|
||||
if ($act === 'start') @exec('sudo /usr/bin/systemctl start snowflake-proxy 2>/dev/null');
|
||||
if ($act === 'stop') @exec('sudo /usr/bin/systemctl stop snowflake-proxy 2>/dev/null');
|
||||
if ($act === 'restart')@exec('sudo /usr/bin/systemctl restart snowflake-proxy 2>/dev/null');
|
||||
|
||||
$active = trim((string)@shell_exec('systemctl is-active snowflake-proxy 2>/dev/null')) === 'active';
|
||||
$enabled = trim((string)@shell_exec('systemctl is-enabled snowflake-proxy 2>/dev/null')) === 'enabled';
|
||||
$pid = (int)trim((string)@shell_exec('systemctl show -p MainPID --value snowflake-proxy 2>/dev/null'));
|
||||
$ver = trim((string)@shell_exec('snowflake-proxy -version 2>/dev/null'));
|
||||
if ($ver === '') $ver = trim((string)@shell_exec("dpkg-query -W -f='\${Version}\n' snowflake-proxy 2>/dev/null"));
|
||||
$flags = '';
|
||||
$drop = '/etc/systemd/system/snowflake-proxy.service.d/override.conf';
|
||||
if (is_file($drop)) {
|
||||
$txt = (string)@file_get_contents($drop);
|
||||
if (preg_match('/^ExecStart=.*snowflake-proxy\s+(.*)$/m', $txt, $m)) $flags = trim($m[1]);
|
||||
}
|
||||
$flags_display = $flags !== '' ? $flags : 'defaults';
|
||||
?>
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>SnowPanel</title>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||
<link href="/assets/panel.css" rel="stylesheet">
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
|
||||
<link rel="alternate icon" href="/favicon.svg">
|
||||
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.1/dist/chart.umd.min.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<nav class="navbar navbar-dark" style="background:linear-gradient(90deg,#0d6efd,#4dabf7);">
|
||||
<div class="container-fluid">
|
||||
<span class="navbar-brand fw-bold">
|
||||
<img src="/favicon.svg" alt="">
|
||||
SnowPanel
|
||||
</span>
|
||||
<div class="ms-auto d-flex align-items-center gap-2">
|
||||
<button id="btnTheme" class="btn btn-sm btn-outline-light">Theme</button>
|
||||
<a class="btn btn-sm btn-outline-light" href="/logout.php">Logout</a>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<div class="container py-4">
|
||||
<div class="row g-3">
|
||||
|
||||
<div class="col-md-12">
|
||||
<div class="card p-3">
|
||||
<div class="h5 mb-0">Service</div>
|
||||
<div class="mb-1">Status: <?= $active ? '<span class="text-success">active</span>' : '<span class="text-danger">inactive</span>' ?></div>
|
||||
<div class="mb-1">Enabled: <?= $enabled ? 'yes' : 'no' ?></div>
|
||||
<div class="mb-1">PID: <span class="mono"><?= $pid ?: '—' ?></span></div>
|
||||
<div class="mb-1">Version: <span class="mono"><?= htmlspecialchars($ver ?: '—') ?></span></div>
|
||||
<div class="mb-1">Flags: <code><?= htmlspecialchars($flags_display) ?></code></div>
|
||||
<form method="post" class="d-flex gap-2 mt-3">
|
||||
<button name="act" value="start" class="btn btn-success btn-sm" type="submit">Start</button>
|
||||
<button name="act" value="restart" class="btn btn-warning btn-sm" type="submit">Restart</button>
|
||||
<button name="act" value="stop" class="btn btn-danger btn-sm" type="submit">Stop</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-12">
|
||||
<div class="card p-3">
|
||||
<div class="d-flex justify-content-between align-items-center">
|
||||
<div class="h5 mb-0">Traffic (last 48 hours)</div>
|
||||
</div>
|
||||
<div id="trafficWrap"><canvas id="trafficChart"></canvas></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-12">
|
||||
<div class="row g-3 equal-row">
|
||||
<div class="col-md-6">
|
||||
<div class="card p-3">
|
||||
<div class="small">RX (total)</div>
|
||||
<div class="h4 mono" id="rxTotal">—</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="card p-3">
|
||||
<div class="small">TX (total)</div>
|
||||
<div class="h4 mono" id="txTotal">—</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-12">
|
||||
<div class="card p-3">
|
||||
<div class="d-flex justify-content-between align-items-center">
|
||||
<div class="h5 mb-0">Limits & Usage</div>
|
||||
<div class="d-flex align-items-center gap-2">
|
||||
<span id="capBadge" class="badge bg-secondary">—</span>
|
||||
<button id="btnEditLimits" class="btn btn-sm btn-outline-light">Edit</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="limitsView" class="mt-2">
|
||||
<div class="row g-3">
|
||||
<div class="col-md-4">
|
||||
<div class="small">Monthly cap</div>
|
||||
<div class="mono" id="v_cap">—</div>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<div class="small">Reset day</div>
|
||||
<div class="mono" id="v_reset">—</div>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<div class="small">Throughput limit</div>
|
||||
<div class="mono" id="v_rate">—</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-3">
|
||||
<div class="small mb-1">This period usage</div>
|
||||
<div class="progress" role="progressbar" aria-label="Monthly cap" style="height: 14px;">
|
||||
<div id="capBar" class="progress-bar" style="width:0%"></div>
|
||||
</div>
|
||||
<div class="d-flex justify-content-between small mt-1">
|
||||
<div class="mono" id="usageLeft">—</div>
|
||||
<div class="mono text-muted" id="periodLbl">—</div>
|
||||
</div>
|
||||
<div class="small mt-2">Current rate: <span class="mono" id="curRate">—</span></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form id="limitsForm" class="mt-3" style="display:none;">
|
||||
<div class="row g-3">
|
||||
<div class="col-md-4">
|
||||
<label class="form-label" for="cap_gb">Monthly cap</label>
|
||||
<div class="input-group">
|
||||
<input id="cap_gb" class="form-control" type="number" min="0" step="1">
|
||||
<span class="input-group-text">GB</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label" for="cap_reset_day">Reset day</label>
|
||||
<select id="cap_reset_day" class="form-select">
|
||||
<?php for($d=1;$d<=28;$d++) echo "<option>$d</option>"; ?>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label" for="rate_mbps">Throughput limit</label>
|
||||
<div class="input-group">
|
||||
<input id="rate_mbps" class="form-control" type="number" min="0" step="1">
|
||||
<span class="input-group-text">Mbps</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="d-flex gap-2 mt-3">
|
||||
<button class="btn btn-primary" id="saveLimits" type="submit">Save</button>
|
||||
<button class="btn btn-secondary" id="cancelLimits" type="button">Cancel</button>
|
||||
<span id="saveLimitsMsg" class="small ms-2"></span>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-12">
|
||||
<div class="card p-3">
|
||||
<div class="d-flex align-items-center justify-content-between">
|
||||
<div class="h5 mb-0">Logs</div>
|
||||
<div class="d-flex align-items-center gap-2">
|
||||
<select id="torlogLevel" class="form-select form-select-sm" style="width:auto;">
|
||||
<option value="info" selected>info</option>
|
||||
<option value="notice">notice</option>
|
||||
<option value="warning">warning</option>
|
||||
<option value="err">err</option>
|
||||
<option value="debug">debug</option>
|
||||
</select>
|
||||
<select id="torlogLines" class="form-select form-select-sm" style="width:auto;">
|
||||
<option value="200">Last 200</option>
|
||||
<option value="500" selected>Last 500</option>
|
||||
<option value="1000">Last 1000</option>
|
||||
<option value="2000">Last 2000</option>
|
||||
</select>
|
||||
<button id="btnRefresh" class="btn btn-sm btn-outline-light">Refresh</button>
|
||||
</div>
|
||||
</div>
|
||||
<pre id="logpre" class="bg-dark text-light p-2 rounded mt-2" style="height:340px; overflow:auto; font-size:.85rem;">Loading…</pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const $ = (id)=>document.getElementById(id);
|
||||
const el = {
|
||||
btnTheme: $('btnTheme'),
|
||||
logpre: $('logpre'),
|
||||
lines: $('torlogLines'),
|
||||
lvl: $('torlogLevel'),
|
||||
btnRefresh: $('btnRefresh'),
|
||||
trafficCanvas: $('trafficChart'),
|
||||
rxTotal: $('rxTotal'),
|
||||
txTotal: $('txTotal'),
|
||||
|
||||
limitsView: $('limitsView'),
|
||||
limitsForm: $('limitsForm'),
|
||||
btnEditLimits: $('btnEditLimits'),
|
||||
saveLimits: $('saveLimits'),
|
||||
cancelLimits: $('cancelLimits'),
|
||||
saveLimitsMsg: $('saveLimitsMsg'),
|
||||
|
||||
v_cap: $('v_cap'),
|
||||
v_reset: $('v_reset'),
|
||||
v_rate: $('v_rate'),
|
||||
capBadge: $('capBadge'),
|
||||
capBar: $('capBar'),
|
||||
usageLeft: $('usageLeft'),
|
||||
periodLbl: $('periodLbl'),
|
||||
curRate: $('curRate'),
|
||||
|
||||
cap_gb: $('cap_gb'),
|
||||
cap_reset_day: $('cap_reset_day'),
|
||||
rate_mbps: $('rate_mbps'),
|
||||
};
|
||||
|
||||
let chart = null;
|
||||
|
||||
const THEME_KEY='snowpanel:theme';
|
||||
const mql=window.matchMedia('(prefers-color-scheme: dark)');
|
||||
|
||||
function preferredTheme(){
|
||||
const s=localStorage.getItem(THEME_KEY);
|
||||
return (s==='dark'||s==='light') ? s : (mql.matches ? 'dark' : 'light');
|
||||
}
|
||||
function getThemeColors(){
|
||||
const cs=getComputedStyle(document.documentElement);
|
||||
return {
|
||||
text: cs.getPropertyValue('--text').trim() || '#e7ecf4',
|
||||
grid: cs.getPropertyValue('--grid').trim() || 'rgba(231,236,244,.15)'
|
||||
};
|
||||
}
|
||||
function setBtnLabel(t){
|
||||
if(el.btnTheme) el.btnTheme.textContent = (t==='dark') ? '🌞 Light' : '🌙 Dark';
|
||||
}
|
||||
function applyTheme(t){
|
||||
document.documentElement.setAttribute('data-theme', t);
|
||||
localStorage.setItem(THEME_KEY, t);
|
||||
setBtnLabel(t);
|
||||
if(chart){
|
||||
const c=getThemeColors();
|
||||
chart.options.scales.x.ticks.color=c.text;
|
||||
chart.options.scales.y.ticks.color=c.text;
|
||||
chart.options.scales.x.grid.color=c.grid;
|
||||
chart.options.scales.y.grid.color=c.grid;
|
||||
if(chart.options.plugins?.legend?.labels) chart.options.plugins.legend.labels.color=c.text;
|
||||
chart.update('none');
|
||||
}
|
||||
}
|
||||
mql.addEventListener('change', e=>{
|
||||
if(!localStorage.getItem(THEME_KEY)) applyTheme(e.matches?'dark':'light');
|
||||
});
|
||||
el.btnTheme?.addEventListener('click', ()=>{
|
||||
const next=(document.documentElement.getAttribute('data-theme')==='dark')?'light':'dark';
|
||||
applyTheme(next);
|
||||
});
|
||||
applyTheme(preferredTheme());
|
||||
|
||||
async function fetchLog(n,l){
|
||||
const r=await fetch('/api/snow_log.php?n='+encodeURIComponent(n)+'&level='+encodeURIComponent(l),{cache:'no-store'});
|
||||
if(!r.ok) return {ok:false,lines:[]};
|
||||
return r.json();
|
||||
}
|
||||
async function loadInlineLogs(){
|
||||
const n=el.lines?.value||'500';
|
||||
const l=el.lvl?.value||'info';
|
||||
const j=await fetchLog(n,l);
|
||||
if(j.ok&&j.lines){ el.logpre.textContent=j.lines.join('\n'); el.logpre.scrollTop=el.logpre.scrollHeight; }
|
||||
else el.logpre.textContent='(no logs)';
|
||||
}
|
||||
el.btnRefresh?.addEventListener('click', loadInlineLogs);
|
||||
el.lines?.addEventListener('change', loadInlineLogs);
|
||||
el.lvl?.addEventListener('change', loadInlineLogs);
|
||||
loadInlineLogs();
|
||||
setInterval(loadInlineLogs,60000);
|
||||
|
||||
function fmtBytes(n){
|
||||
if(n<1024) return n+' B';
|
||||
const u=['KB','MB','GB','TB']; let i=-1;
|
||||
do{ n/=1024; i++; }while(n>=1024&&i<u.length-1);
|
||||
return n.toFixed(2)+' '+u[i];
|
||||
}
|
||||
|
||||
function setChartData(labels, rx, tx){
|
||||
if(!el.trafficCanvas) return;
|
||||
const ctx=el.trafficCanvas.getContext('2d');
|
||||
const c=getThemeColors();
|
||||
if(!chart){
|
||||
chart=new Chart(ctx,{
|
||||
type:'line',
|
||||
data:{labels,datasets:[
|
||||
{label:'RX/min',data:rx,tension:.3,pointRadius:2,borderWidth:2,borderColor:'#22c55e'},
|
||||
{label:'TX/min',data:tx,tension:.3,pointRadius:2,borderWidth:2,borderColor:'#a78bfa'}
|
||||
]},
|
||||
options:{
|
||||
responsive:true,
|
||||
maintainAspectRatio:false,
|
||||
animation:false,
|
||||
plugins:{legend:{labels:{color:c.text}}},
|
||||
scales:{
|
||||
x:{ticks:{color:c.text},grid:{color:c.grid}},
|
||||
y:{ticks:{color:c.text,callback:(v)=>fmtBytes(v)},grid:{color:c.grid}}
|
||||
}
|
||||
}
|
||||
});
|
||||
}else{
|
||||
chart.data.labels=labels;
|
||||
chart.data.datasets[0].data=rx;
|
||||
chart.data.datasets[1].data=tx;
|
||||
chart.update('none');
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshChartAndTotals(){
|
||||
const r=await fetch('/api/stats.php',{cache:'no-store'});
|
||||
if(!r.ok) return;
|
||||
const s=await r.json();
|
||||
const data=s.data||[];
|
||||
let labels=[],rx=[],tx=[], sumR=0, sumW=0;
|
||||
for(let i=1;i<data.length;i++){
|
||||
const dt=data[i].t-data[i-1].t;
|
||||
if(dt<=0||dt>3600) continue;
|
||||
const dr=Math.max(0,data[i].read-data[i-1].read);
|
||||
const dw=Math.max(0,data[i].written-data[i-1].written);
|
||||
labels.push(new Date(data[i].t*1000).toLocaleTimeString());
|
||||
rx.push(dr); tx.push(dw);
|
||||
sumR+=dr; sumW+=dw;
|
||||
}
|
||||
setChartData(labels,rx,tx);
|
||||
if(el.rxTotal) el.rxTotal.textContent = sumR ? fmtBytes(sumR) : '—';
|
||||
if(el.txTotal) el.txTotal.textContent = sumW ? fmtBytes(sumW) : '—';
|
||||
}
|
||||
refreshChartAndTotals();
|
||||
setInterval(refreshChartAndTotals,60000);
|
||||
|
||||
function setLimitEditMode(on){
|
||||
if(!el.limitsForm || !el.limitsView) return;
|
||||
el.limitsForm.style.display = on ? 'block' : 'none';
|
||||
el.limitsView.style.display = on ? 'none' : 'block';
|
||||
if (el.btnEditLimits) el.btnEditLimits.style.display = on ? 'none' : 'inline-block';
|
||||
}
|
||||
|
||||
async function loadLimits(){
|
||||
const r = await fetch('/api/limits_get.php',{cache:'no-store'});
|
||||
if(!r.ok) return;
|
||||
const j = await r.json();
|
||||
if(!j.ok) return;
|
||||
|
||||
const cap = j.cap_gb>0 ? (j.cap_gb+' GB') : 'Unlimited';
|
||||
const rate= j.rate_mbps>0 ? (j.rate_mbps+' Mbps') : 'Unlimited';
|
||||
if(el.v_cap) el.v_cap.textContent = cap;
|
||||
if(el.v_reset) el.v_reset.textContent = j.cap_reset_day || '—';
|
||||
if(el.v_rate) el.v_rate.textContent = rate;
|
||||
|
||||
const u = j.usage || {};
|
||||
const total = u.total || 0;
|
||||
let cap_bytes = (u.cap_bytes && u.cap_bytes > 0)
|
||||
? u.cap_bytes
|
||||
: ((j.cap_gb && j.cap_gb > 0) ? (j.cap_gb * 1073741824) : 0);
|
||||
|
||||
let pct = 0;
|
||||
if (cap_bytes > 0) {
|
||||
pct = Math.round((total * 100) / cap_bytes);
|
||||
if (pct === 0 && total > 0) pct = 1;
|
||||
if (pct > 100) pct = 100;
|
||||
}
|
||||
|
||||
if (el.capBar) {
|
||||
el.capBar.style.width = (cap_bytes > 0 ? pct : 0) + '%';
|
||||
el.capBar.className = 'progress-bar ' + ((cap_bytes > 0 && pct >= 90) ? 'bg-danger' : (pct >= 70 ? 'bg-warning' : ''));
|
||||
el.capBar.textContent = cap_bytes > 0 ? (pct + '%') : '—';
|
||||
}
|
||||
if(el.usageLeft){
|
||||
if(cap_bytes>0){
|
||||
const left = Math.max(0, cap_bytes-total);
|
||||
el.usageLeft.textContent = fmtBytes(total) + ' / ' + fmtBytes(cap_bytes) + ' (left ' + fmtBytes(left) + ')';
|
||||
} else {
|
||||
el.usageLeft.textContent = fmtBytes(total) + ' used';
|
||||
}
|
||||
}
|
||||
if(el.periodLbl){
|
||||
el.periodLbl.textContent = u.period_label || '—';
|
||||
}
|
||||
if(el.curRate){
|
||||
const cr = j.current_rate_mbps || 0;
|
||||
el.curRate.textContent = cr.toFixed(2) + ' Mbps';
|
||||
}
|
||||
if (el.capBadge) {
|
||||
if (cap_bytes > 0 && total >= cap_bytes) {
|
||||
el.capBadge.textContent = 'Cap reached';
|
||||
el.capBadge.className = 'badge bg-danger';
|
||||
} else {
|
||||
el.capBadge.textContent = 'Active';
|
||||
el.capBadge.className = 'badge bg-success';
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
if(el.cap_gb) el.cap_gb.value = j.cap_gb ?? 0;
|
||||
if(el.cap_reset_day) el.cap_reset_day.value = j.cap_reset_day ?? 1;
|
||||
if(el.rate_mbps) el.rate_mbps.value = j.rate_mbps ?? 0;
|
||||
}
|
||||
|
||||
el.btnEditLimits?.addEventListener('click', ()=> setLimitEditMode(true));
|
||||
el.cancelLimits?.addEventListener('click', ()=> setLimitEditMode(false));
|
||||
el.limitsForm?.addEventListener('submit', async (e)=>{
|
||||
e.preventDefault();
|
||||
if (el.saveLimitsMsg) el.saveLimitsMsg.textContent = 'Saving...';
|
||||
const body = {
|
||||
cap_gb: parseInt(el.cap_gb?.value||'0'),
|
||||
cap_reset_day: parseInt(el.cap_reset_day?.value||'1'),
|
||||
rate_mbps: parseInt(el.rate_mbps?.value||'0')
|
||||
};
|
||||
const r = await fetch('/api/limits_set.php', {method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify(body)});
|
||||
const j = await r.json();
|
||||
if (el.saveLimitsMsg) el.saveLimitsMsg.textContent = j.ok ? 'Saved' : ('Error: '+(j.error||'unknown'));
|
||||
if (j.ok){
|
||||
await loadLimits();
|
||||
setLimitEditMode(false);
|
||||
}
|
||||
});
|
||||
|
||||
loadLimits();
|
||||
setInterval(loadLimits, 15000);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
67
web/lib/app.php
Normal file
67
web/lib/app.php
Normal file
@@ -0,0 +1,67 @@
|
||||
<?php
|
||||
if (session_status() !== PHP_SESSION_ACTIVE) {
|
||||
session_set_cookie_params(['lifetime'=>0,'path'=>'/','httponly'=>true,'samesite'=>'Lax']);
|
||||
session_start();
|
||||
}
|
||||
|
||||
define('APP_ETC','/etc/snowpanel');
|
||||
define('APP_VAR','/var/lib/snowpanel');
|
||||
define('APP_CFG_ETC', APP_ETC.'/app.json');
|
||||
define('APP_CFG_VAR', APP_VAR.'/app.json');
|
||||
|
||||
function app_cfg_path() {
|
||||
if (is_file(APP_CFG_ETC)) return APP_CFG_ETC;
|
||||
if (is_file(APP_CFG_VAR)) return APP_CFG_VAR;
|
||||
return APP_CFG_ETC;
|
||||
}
|
||||
|
||||
function app_is_installed() {
|
||||
$p = app_cfg_path();
|
||||
if (!is_file($p)) return false;
|
||||
$j = json_decode((string)@file_get_contents($p), true);
|
||||
return is_array($j) && !empty($j['admin_user']) && !empty($j['admin_pass']);
|
||||
}
|
||||
|
||||
function app_load_config() {
|
||||
$p = app_cfg_path();
|
||||
$j = json_decode((string)@file_get_contents($p), true);
|
||||
return is_array($j) ? $j : [];
|
||||
}
|
||||
|
||||
function app_save_config(array $cfg) {
|
||||
$target = APP_CFG_ETC;
|
||||
$dir = dirname($target);
|
||||
if (!is_dir($dir)) @mkdir($dir, 0770, true);
|
||||
$ok = @file_put_contents($target, json_encode($cfg, JSON_PRETTY_PRINT|JSON_UNESCAPED_SLASHES), LOCK_EX) !== false;
|
||||
if (!$ok) {
|
||||
$target = APP_CFG_VAR;
|
||||
$dir = dirname($target);
|
||||
if (!is_dir($dir)) @mkdir($dir, 0770, true);
|
||||
$ok = @file_put_contents($target, json_encode($cfg, JSON_PRETTY_PRINT|JSON_UNESCAPED_SLASHES), LOCK_EX) !== false;
|
||||
}
|
||||
if ($ok) {
|
||||
@chgrp(dirname($target), 'www-data'); @chmod(dirname($target), 0770);
|
||||
@chgrp($target, 'www-data'); @chmod($target, 0660);
|
||||
}
|
||||
return $ok;
|
||||
}
|
||||
|
||||
function auth_login($u,$p) {
|
||||
$cfg = app_load_config();
|
||||
$ok = ($cfg['admin_user'] ?? '') === $u && password_verify($p, $cfg['admin_pass'] ?? '');
|
||||
if ($ok) { $_SESSION['uid'] = $u; $_SESSION['ts'] = time(); }
|
||||
return $ok;
|
||||
}
|
||||
|
||||
function auth_require() {
|
||||
if (empty($_SESSION['uid'])) { header('Location: /login.php'); exit; }
|
||||
}
|
||||
|
||||
function auth_logout() {
|
||||
$_SESSION = [];
|
||||
if (ini_get('session.use_cookies')) {
|
||||
$p = session_get_cookie_params();
|
||||
setcookie(session_name(), '', time()-42000, $p['path'] ?? '/', $p['domain'] ?? '', !empty($p['secure']), !empty($p['httponly']));
|
||||
}
|
||||
@session_destroy();
|
||||
}
|
||||
28
web/lib/snowctl.php
Normal file
28
web/lib/snowctl.php
Normal file
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
function snowctl_ports_range_normalize($s){
|
||||
$s=trim($s);
|
||||
$s=str_replace('-' ,':',$s);
|
||||
if(!preg_match('~^\d{1,5}:\d{1,5}$~',$s)) return '';
|
||||
[$a,$b]=array_map('intval',explode(':',$s,2));
|
||||
if($a<1||$b<1||$a>$b||$b>65535) return '';
|
||||
return $a.':'.$b;
|
||||
}
|
||||
function snowctl_build_flags(array $o){
|
||||
$f=[];
|
||||
if(!empty($o['broker'])) $f[]='-broker '.escapeshellarg($o['broker']);
|
||||
if(!empty($o['stun'])) $f[]='-stun '.escapeshellarg($o['stun']);
|
||||
if(!empty($o['range'])){ $r=snowctl_ports_range_normalize($o['range']); if($r!=='') $f[]='-ephemeral-ports-range '.$r; }
|
||||
if(!empty($o['unsafe'])) $f[]='-unsafe-logging';
|
||||
return implode(' ',$f);
|
||||
}
|
||||
function snowctl_override_path(){ return '/etc/systemd/system/snowflake-proxy.service.d/override.conf'; }
|
||||
function snowctl_apply_override($flags){
|
||||
$d=dirname(snowctl_override_path());
|
||||
if(!is_dir($d)) @mkdir($d,0755,true);
|
||||
$exec='/usr/bin/snowflake-proxy';
|
||||
$out="[Service]\nIPAccounting=yes\nExecStart=\nExecStart=$exec $flags\n";
|
||||
@file_put_contents(snowctl_override_path(),$out);
|
||||
@exec('systemctl daemon-reload 2>/dev/null');
|
||||
@exec('systemctl restart snowflake-proxy 2>/dev/null');
|
||||
return true;
|
||||
}
|
||||
93
web/login.php
Normal file
93
web/login.php
Normal file
@@ -0,0 +1,93 @@
|
||||
<?php
|
||||
require __DIR__ . '/lib/app.php';
|
||||
|
||||
if (app_is_installed() && !empty($_SESSION['uid'])) {
|
||||
header('Location: /'); exit;
|
||||
}
|
||||
|
||||
$err = '';
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
$u = trim($_POST['u'] ?? '');
|
||||
$p = $_POST['p'] ?? '';
|
||||
if ($u !== '' && auth_login($u, $p)) {
|
||||
header('Location: /'); exit;
|
||||
}
|
||||
$err = 'Invalid credentials';
|
||||
}
|
||||
?>
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Login · SnowPanel</title>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||
<link href="/assets/panel.css" rel="stylesheet">
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
|
||||
<link rel="alternate icon" href="/favicon.svg">
|
||||
</head>
|
||||
<body>
|
||||
<nav class="navbar navbar-dark" style="background:linear-gradient(90deg,#0d6efd,#4dabf7);">
|
||||
<div class="container-fluid">
|
||||
<span class="navbar-brand fw-bold">
|
||||
<img src="/favicon.svg" alt="" style="width:20px;height:20px;margin-right:.5rem;vertical-align:-3px;">
|
||||
SnowPanel
|
||||
</span>
|
||||
<div class="ms-auto">
|
||||
<button id="btnTheme" type="button" class="btn btn-sm btn-outline-light">Theme</button>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<div class="page-wrap">
|
||||
<div class="container maxw-960">
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-md-8 col-lg-6">
|
||||
<div class="card p-4 shadow-lg">
|
||||
<h1 class="h4 mb-3">Sign in</h1>
|
||||
|
||||
<?php if (isset($_GET['ok'])): ?>
|
||||
<div class="alert alert-success py-2 mb-3" role="alert">Admin created. Sign in below.</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if ($err): ?>
|
||||
<div class="alert alert-danger py-2 mb-3" role="alert"><?= htmlspecialchars($err) ?></div>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if (!app_is_installed()): ?>
|
||||
<div class="alert alert-info py-2 mb-3" role="alert">
|
||||
First time here? <a href="/setup.php">Run setup</a>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<form method="post" autocomplete="on">
|
||||
<div class="mb-3">
|
||||
<label class="form-label" for="u">Username</label>
|
||||
<input class="form-control" id="u" name="u" required autofocus>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label" for="p">Password</label>
|
||||
<input class="form-control" id="p" name="p" type="password" required autocomplete="current-password">
|
||||
</div>
|
||||
<button class="btn btn-primary w-100" type="submit">Login</button>
|
||||
</form>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const THEME_KEY='snowpanel:theme';
|
||||
const mql=window.matchMedia('(prefers-color-scheme: dark)');
|
||||
const btn=document.getElementById('btnTheme');
|
||||
function preferredTheme(){const s=localStorage.getItem(THEME_KEY);return (s==='dark'||s==='light')?s:(mql.matches?'dark':'light');}
|
||||
function setBtnLabel(t){ if(btn) btn.textContent = (t==='dark') ? '🌞 Light' : '🌙 Dark'; }
|
||||
function applyTheme(t){ document.documentElement.setAttribute('data-theme', t); localStorage.setItem(THEME_KEY, t); setBtnLabel(t); }
|
||||
mql.addEventListener('change', e=>{ if(!localStorage.getItem(THEME_KEY)) applyTheme(e.matches?'dark':'light'); });
|
||||
btn?.addEventListener('click', ()=>{ const next=(document.documentElement.getAttribute('data-theme')==='dark')?'light':'dark'; applyTheme(next); });
|
||||
applyTheme(preferredTheme());
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
4
web/logout.php
Normal file
4
web/logout.php
Normal file
@@ -0,0 +1,4 @@
|
||||
<?php
|
||||
require __DIR__ . '/lib/app.php';
|
||||
auth_logout();
|
||||
header('Location: /login.php');
|
||||
2
web/robots.txt
Normal file
2
web/robots.txt
Normal file
@@ -0,0 +1,2 @@
|
||||
User-agent: *
|
||||
Disallow: /
|
||||
207
web/setup.php
Normal file
207
web/setup.php
Normal file
@@ -0,0 +1,207 @@
|
||||
<?php
|
||||
require __DIR__ . '/lib/app.php';
|
||||
require __DIR__ . '/lib/snowctl.php';
|
||||
|
||||
if (function_exists('app_is_installed') && app_is_installed()) {
|
||||
header('Location: /'); exit;
|
||||
}
|
||||
|
||||
$err = '';
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
$u = trim($_POST['u'] ?? '');
|
||||
$p = $_POST['p'] ?? '';
|
||||
$p2 = $_POST['p2'] ?? '';
|
||||
|
||||
$broker = trim($_POST['broker'] ?? 'https://snowflake-broker.torproject.net/');
|
||||
$stun = trim($_POST['stun'] ?? 'stun:stun.l.google.com:19302');
|
||||
$range = trim($_POST['range'] ?? '10000:65535');
|
||||
$unsafe = isset($_POST['unsafe']);
|
||||
|
||||
$cap_gb = (int)($_POST['cap_gb'] ?? 100);
|
||||
$cap_reset_day= (int)($_POST['cap_reset_day']?? 1);
|
||||
$rate_mbps = (int)($_POST['rate_mbps'] ?? 0);
|
||||
|
||||
if ($u === '' || $p === '') {
|
||||
$err = 'Username and password are required.';
|
||||
} elseif ($p !== $p2) {
|
||||
$err = 'Passwords do not match.';
|
||||
} elseif ($cap_reset_day < 1 || $cap_reset_day > 28) {
|
||||
$err = 'Reset day must be between 1 and 28.';
|
||||
} else {
|
||||
$app_cfg = [
|
||||
'admin_user' => $u,
|
||||
'admin_pass' => password_hash($p, PASSWORD_DEFAULT),
|
||||
'created_at' => date('c'),
|
||||
'cap_gb' => max(0, $cap_gb),
|
||||
'cap_reset_day' => $cap_reset_day,
|
||||
'rate_mbps' => max(0, $rate_mbps),
|
||||
];
|
||||
if (function_exists('app_save_config')) {
|
||||
app_save_config($app_cfg);
|
||||
} else {
|
||||
$dir = '/etc/snowpanel';
|
||||
@mkdir($dir, 0770, true);
|
||||
@file_put_contents("$dir/app.json", json_encode($app_cfg, JSON_PRETTY_PRINT), LOCK_EX);
|
||||
@chgrp($dir, 'www-data'); @chmod($dir, 0770);
|
||||
@chgrp("$dir/app.json", 'www-data'); @chmod("$dir/app.json", 0660);
|
||||
}
|
||||
|
||||
$flags = snowctl_build_flags([
|
||||
'broker'=>$broker,
|
||||
'stun'=>$stun,
|
||||
'range'=>$range,
|
||||
'unsafe'=>$unsafe?1:0
|
||||
]);
|
||||
snowctl_apply_override($flags);
|
||||
|
||||
header('Location: /login.php?ok=1'); exit;
|
||||
}
|
||||
}
|
||||
?>
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>First-time Setup · SnowPanel</title>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||
<link href="/assets/panel.css" rel="stylesheet">
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
|
||||
<link rel="alternate icon" href="/favicon.svg">
|
||||
</head>
|
||||
<body>
|
||||
<nav class="navbar navbar-dark" style="background:linear-gradient(90deg,#0d6efd,#4dabf7);">
|
||||
<div class="container-fluid">
|
||||
<span class="navbar-brand fw-bold">
|
||||
<img src="/favicon.svg" alt="" style="width:20px;height:20px;margin-right:.5rem;vertical-align:-3px;">
|
||||
SnowPanel
|
||||
</span>
|
||||
<div class="ms-auto">
|
||||
<button id="btnTheme" type="button" class="btn btn-sm btn-outline-light">Theme</button>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<div class="page-wrap">
|
||||
<div class="container maxw-960">
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-lg-10">
|
||||
<div class="card p-4 shadow-lg">
|
||||
<h1 class="h4 mb-3">First-time Setup</h1>
|
||||
|
||||
<?php if ($err): ?>
|
||||
<div class="alert alert-danger py-2 mb-3" role="alert"><?= htmlspecialchars($err) ?></div>
|
||||
<?php endif; ?>
|
||||
|
||||
<form method="post" action="/setup.php" autocomplete="on">
|
||||
<div class="mb-2"><div class="small text-muted">Step 1</div><div class="h5 mb-2">Admin account</div></div>
|
||||
<div class="row g-3">
|
||||
<div class="col-md-4">
|
||||
<label class="form-label" for="u">Username</label>
|
||||
<input class="form-control" id="u" name="u" required autofocus value="<?= htmlspecialchars($_POST['u'] ?? '') ?>">
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label" for="p">Password</label>
|
||||
<input class="form-control" id="p" name="p" type="password" required autocomplete="new-password">
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label" for="p2">Confirm password</label>
|
||||
<input class="form-control" id="p2" name="p2" type="password" required autocomplete="new-password">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 mb-2"><div class="small text-muted">Step 2</div><div class="h5 mb-2">Proxy basics</div></div>
|
||||
|
||||
<div class="row g-3">
|
||||
<div class="col-md-6">
|
||||
<label class="form-label" for="broker">Broker URL</label>
|
||||
<input class="form-control" id="broker" name="broker" placeholder="https://snowflake-broker.torproject.net/"
|
||||
value="<?= htmlspecialchars($_POST['broker'] ?? '') ?>">
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label" for="stun">STUN server</label>
|
||||
<input class="form-control" id="stun" name="stun" placeholder="stun:stun.l.google.com:19302"
|
||||
value="<?= htmlspecialchars($_POST['stun'] ?? '') ?>">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row g-3 mt-1">
|
||||
<div class="col-md-4">
|
||||
<label class="form-label" for="range">Ephemeral ports range</label>
|
||||
<input class="form-control" id="range" name="range" placeholder="10000:65535"
|
||||
value="<?= htmlspecialchars($_POST['range'] ?? '10000:65535') ?>">
|
||||
</div>
|
||||
<div class="col-md-4 form-check mt-4">
|
||||
<input class="form-check-input" type="checkbox" id="unsafe" name="unsafe" <?= isset($_POST['unsafe']) ? 'checked' : '' ?>>
|
||||
<label class="form-check-label" for="unsafe">Unsafe verbose logging</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 mb-2"><div class="small text-muted">Step 3</div><div class="h5 mb-2">Limits</div></div>
|
||||
|
||||
<div class="row g-3">
|
||||
<div class="col-md-4">
|
||||
<label class="form-label" for="cap_gb">Monthly cap</label>
|
||||
<div class="input-group">
|
||||
<input class="form-control" id="cap_gb" name="cap_gb" type="number" min="0" step="1" placeholder="100"
|
||||
value="<?= htmlspecialchars($_POST['cap_gb'] ?? '100') ?>">
|
||||
<span class="input-group-text">GB</span>
|
||||
</div>
|
||||
<div class="form-text">0 = unlimited</div>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label" for="cap_reset_day">Reset day</label>
|
||||
<select id="cap_reset_day" name="cap_reset_day" class="form-select">
|
||||
<?php
|
||||
$sel = (int)($_POST['cap_reset_day'] ?? 1);
|
||||
for($d=1;$d<=28;$d++){
|
||||
$s = ($sel===$d)?' selected':'';
|
||||
echo "<option$s>$d</option>";
|
||||
}
|
||||
?>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label" for="rate_mbps">Throughput limit</label>
|
||||
<div class="input-group">
|
||||
<input class="form-control" id="rate_mbps" name="rate_mbps" type="number" min="0" step="1" placeholder="0"
|
||||
value="<?= htmlspecialchars($_POST['rate_mbps'] ?? '0') ?>">
|
||||
<span class="input-group-text">Mbps</span>
|
||||
</div>
|
||||
<div class="form-text">0 = unlimited (display only)</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="d-flex gap-2 mt-4">
|
||||
<button class="btn btn-primary" type="submit">Save & Finish</button>
|
||||
</div>
|
||||
|
||||
<div class="alert alert-warning mt-3 py-2">
|
||||
<div class="fw-semibold">Reminder</div>
|
||||
<ul class="mb-0">
|
||||
<li>No port forwarding is required for Snowflake.</li>
|
||||
<li>Allow outbound UDP and the chosen ephemeral range.</li>
|
||||
</ul>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const THEME_KEY='snowpanel:theme';
|
||||
const mql=window.matchMedia('(prefers-color-scheme: dark)');
|
||||
const btn=document.getElementById('btnTheme');
|
||||
function preferredTheme(){const s=localStorage.getItem(THEME_KEY);return (s==='dark'||s==='light')?s:(mql.matches?'dark':'light');}
|
||||
function setBtnLabel(t){ if(btn) btn.textContent=(t==='dark')?'🌞 Light':'🌙 Dark'; }
|
||||
function applyTheme(t){ document.documentElement.setAttribute('data-theme', t); localStorage.setItem(THEME_KEY,t); setBtnLabel(t); }
|
||||
mql.addEventListener('change',e=>{ if(!localStorage.getItem(THEME_KEY)) applyTheme(e.matches?'dark':'light'); });
|
||||
btn?.addEventListener('click',()=>applyTheme(document.documentElement.getAttribute('data-theme')==='dark'?'light':'dark'));
|
||||
applyTheme(preferredTheme());
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user