This commit is contained in:
2025-11-07 09:47:03 +01:00
parent 646e574059
commit 55620c52d4
22 changed files with 1835 additions and 2 deletions

22
web/api/config_get.php Normal file
View File

@@ -0,0 +1,22 @@
<?php
require __DIR__ . '/../lib/app.php';
require __DIR__ . '/../lib/torctl.php';
header('Content-Type: application/json');
$parsed = read_torpanel_conf();
$ui = torrc_to_ui($parsed);
$rate_mb = round($ui['rate_mbps'] / 8, 2);
$burst_mb = round($ui['burst_mbps'] / 8, 2);
$torrc = [
'Nickname' => $ui['nickname'],
'ContactInfo' => $ui['contact'],
'ORPort' => (string)$ui['orport'],
'BandwidthRate' => rtrim(rtrim(number_format($rate_mb, 2, '.', ''), '0'), '.') . ' MB',
'BandwidthBurst' => rtrim(rtrim(number_format($burst_mb, 2, '.', ''), '0'), '.') . ' MB',
'AccountingMax' => $ui['cap_gb'] . ' GB',
'AccountingStart' => 'month ' . $ui['acc_day'] . ' 00:00',
];
echo json_encode(['ok' => true] + $ui + $torrc);

12
web/api/config_set.php Normal file
View File

@@ -0,0 +1,12 @@
<?php
require __DIR__ . '/../lib/app.php';
require __DIR__ . '/../lib/torctl.php';
auth_require();
header('Content-Type: application/json');
$in = json_decode(file_get_contents('php://input'), true);
if (!is_array($in)) { echo json_encode(['ok'=>false,'error'=>'bad json']); exit; }
$ok = config_apply($in);
echo json_encode(['ok'=>$ok]);

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

@@ -0,0 +1,23 @@
<?php
require __DIR__ . '/../lib/app.php'; auth_require();
require __DIR__ . '/../lib/torctl.php';
header('Content-Type: application/json');
try {
$info = torctl([
"GETINFO traffic/read",
"GETINFO traffic/written",
"GETINFO status/circuit-established",
"GETINFO version",
"GETINFO status/bootstrap-phase"
]);
echo json_encode([
"ok" => true,
"read" => (int)($info["traffic/read"] ?? 0),
"written" => (int)($info["traffic/written"] ?? 0),
"circuits" => (($info["status/circuit-established"] ?? "0") === "1"),
"version" => ($info["version"] ?? "unknown"),
"bootstrap" => ($info["status/bootstrap-phase"] ?? "")
]);
} catch (Throwable $e) {
echo json_encode(["ok"=>false,"error"=>$e->getMessage()]);
}

62
web/api/reach.php Normal file
View File

@@ -0,0 +1,62 @@
<?php
require __DIR__ . '/../lib/app.php'; auth_require();
require __DIR__ . '/../lib/torctl.php';
header('Content-Type: application/json');
try {
$cfg = read_torpanel_conf();
$orport = (int)($cfg['ORPort'] ?? 9001);
$info = torctl(["GETINFO fingerprint","GETINFO address"]);
$fp_raw = trim($info['fingerprint'] ?? '');
$fingerprint = strtoupper(str_replace([' ', "\n", "\r", "\t"], '', $fp_raw));
$tor_addr = trim($info['address'] ?? '');
$lan_ip = trim(shell_exec('hostname -I 2>/dev/null'));
$lan_ip = $lan_ip ? preg_split('/\s+/', $lan_ip)[0] : '';
$onionoo = [
'found' => false, 'running' => false, 'flags' => [],
'last_seen' => null, 'nickname' => ($cfg['Nickname'] ?? 'RaspberryRelay'),
'or_addresses' => [], 'country' => null
];
if ($fingerprint !== '') {
$url = 'https://onionoo.torproject.org/details?lookup=' . urlencode($fingerprint);
$ch = curl_init($url);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 6,
CURLOPT_USERAGENT => 'TorPanel/1.0 (+relay reachability check)',
]);
$res = curl_exec($ch);
$http = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($res !== false && $http === 200) {
$j = json_decode($res, true);
if (!empty($j['relays']) && count($j['relays']) > 0) {
$r = $j['relays'][0];
$onionoo['found'] = true;
$onionoo['running'] = (bool)($r['running'] ?? false);
$onionoo['flags'] = $r['flags'] ?? [];
$onionoo['last_seen'] = $r['last_seen'] ?? null;
$onionoo['nickname'] = $r['nickname'] ?? $onionoo['nickname'];
$onionoo['or_addresses']= $r['or_addresses'] ?? [];
$onionoo['country'] = $r['country'] ?? null;
}
}
}
echo json_encode([
'ok' => true,
'fingerprint' => $fingerprint,
'nickname' => $onionoo['nickname'],
'orport' => $orport,
'tor_address' => $tor_addr,
'lan_ip' => $lan_ip,
'onionoo' => $onionoo,
]);
} catch (Throwable $e) {
echo json_encode(['ok'=>false,'error'=>$e->getMessage()]);
}

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

@@ -0,0 +1,7 @@
<?php
require __DIR__ . '/../lib/app.php'; auth_require();
header('Content-Type: application/json');
$path = "/var/lib/torpanel/stats.json";
if (!file_exists($path)) { echo json_encode(["data"=>[]]); exit; }
$raw = file_get_contents($path);
echo $raw ?: json_encode(["data"=>[]]);

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);
}

1
web/favicon.svg Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 5.6 KiB

447
web/index.php Normal file
View File

@@ -0,0 +1,447 @@
<?php
require __DIR__ . '/lib/app.php';
auth_require();
?>
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Tor Relay Panel</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="">
Tor Relay Panel
</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-12">
<div class="card p-3">
<div class="d-flex align-items-center justify-content-between">
<div class="h5 mb-0">Public Reachability</div>
<div class="d-flex align-items-center gap-2">
<span id="reachBadge" class="badge bg-secondary">—</span>
<button id="reachRefresh" class="btn btn-sm btn-outline-light">Check status</button>
</div>
</div>
<div class="row mt-2 gy-2">
<div class="col-md-3"><div class="small">Nickname</div><div class="mono" id="reachNick">—</div></div>
<div class="col-md-5"><div class="small">Fingerprint</div><div class="mono" id="reachFP">—</div></div>
<div class="col-md-2"><div class="small">ORPort</div><div class="mono" id="reachPort">—</div></div>
<div class="col-md-2"><div class="small">LAN IP</div><div class="mono" id="reachLAN">—</div></div>
</div>
<div class="row mt-2 gy-2">
<div class="col-md-3">
<div class="small">Flags / Last seen</div>
<div class="mono" id="reachFlags">—</div>
<div class="mono" id="reachSeen">—</div>
</div>
<div class="col-md-5">
<div class="small">Tor thinks your address is</div>
<div class="mono text-break" id="reachAddr">—</div>
</div>
<div class="col-md-2 d-none d-md-block"></div>
<div class="col-md-2 d-none d-md-block"></div>
</div>
<div id="reachHelp" class="alert alert-warning mt-3 py-2" style="display:none;">
<div class="fw-semibold">Not reachable yet</div>
<ul class="mb-0">
<li>Forward <span class="mono">TCP <span id="hintPort">9001</span></span> to this Pi: <span class="mono" id="hintLAN">—</span></li>
<li>If you have IPv6, allow inbound to the ORPort.</li>
<li>It can take a while to get the <b>Running</b> flag after changes.</li>
</ul>
</div>
</div>
</div>
<div class="row g-3 equal-row">
<div class="col-md-4">
<div class="card p-3">
<div class="d-flex justify-content-between align-items-center">
<div class="h5 mb-0">Tor Status</div>
<span id="badgeCircuits" class="badge bg-secondary">—</span>
</div>
<div class="mt-2">Version: <span class="mono" id="torVersion">—</span></div>
<div class="mt-1">Bootstrap: <span class="mono" id="bootstrap">—</span></div>
</div>
</div>
<div class="col-md-4">
<div class="card p-3">
<div class="small">Read (total)</div>
<div class="h4 mono" id="readTotal">—</div>
</div>
</div>
<div class="col-md-4">
<div class="card p-3">
<div class="small">Written (total)</div>
<div class="h4 mono" id="writeTotal">—</div>
</div>
</div>
</div>
<div class="col-12 mt-3">
<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="card p-3">
<div class="d-flex justify-content-between align-items-center">
<div class="h5 mb-0">Relay Configuration</div>
<button id="btnEditCfg" class="btn btn-sm btn-outline-light">Edit</button>
</div>
<div id="cfgView">
<div class="row g-3 mt-1">
<div class="col-md-3">
<div class="small">Nickname</div>
<div class="mono" id="v_nickname">—</div>
</div>
<div class="col-md-3">
<div class="small">Contact</div>
<div class="mono" id="v_contact">—</div>
</div>
<div class="col-md-2">
<div class="small">ORPort</div>
<div class="mono" id="v_orport">—</div>
</div>
<div class="col-md-2">
<div class="small">Bandwidth</div>
<div class="mono" id="v_rate">—</div>
</div>
<div class="col-md-2">
<div class="small">Burst</div>
<div class="mono" id="v_burst">—</div>
</div>
</div>
<div class="row g-3 mt-1">
<div class="col-md-3">
<div class="small">Monthly cap</div>
<div class="mono" id="v_cap">—</div>
</div>
<div class="col-md-3">
<div class="small">Accounting day</div>
<div class="mono" id="v_accday">—</div>
</div>
</div>
</div>
<form id="cfgForm" class="mt-1" style="display:none;">
<div class="row g-3">
<div class="col-md-3">
<label class="form-label" for="nickname">Nickname</label>
<input id="nickname" class="form-control" placeholder="RPI-Relay">
</div>
<div class="col-md-3">
<label class="form-label" for="contact">Contact</label>
<input id="contact" class="form-control" placeholder="admin@example.com">
</div>
<div class="col-md-2">
<label class="form-label" for="orport">ORPort</label>
<input id="orport" class="form-control" type="number" min="1" max="65535" placeholder="9001">
</div>
<div class="col-md-2">
<label class="form-label" for="rate_mbps">Bandwidth (Mbps)</label>
<input id="rate_mbps" class="form-control" type="number" min="1" step="1" placeholder="5">
</div>
<div class="col-md-2">
<label class="form-label" for="burst_mbps">Burst (Mbps)</label>
<input id="burst_mbps" class="form-control" type="number" min="1" step="1" placeholder="10">
</div>
</div>
<div class="row g-3 mt-1">
<div class="col-md-3">
<label class="form-label" for="cap_gb">Monthly cap (GB)</label>
<input id="cap_gb" class="form-control" type="number" min="1" step="1" placeholder="100">
</div>
<div class="col-md-3">
<label class="form-label" for="acc_day">Accounting day</label>
<select id="acc_day" class="form-select">
<?php for($d=1;$d<=28;$d++) echo "<option>$d</option>"; ?>
</select>
</div>
</div>
<div class="d-flex gap-2 mt-3">
<button class="btn btn-primary" id="saveBtn" type="submit">Save & Reload Tor</button>
<button class="btn btn-secondary" id="btnCancelCfg" type="button">Cancel</button>
<span id="saveMsg" class="ms-2 small"></span>
</div>
</form>
</div>
</div>
</div>
</div>
<script>
const $ = (id) => document.getElementById(id);
const el = {
readTotal: $('readTotal'), writeTotal: $('writeTotal'),
torVersion: $('torVersion'), bootstrap: $('bootstrap'), badgeCircuits: $('badgeCircuits'),
btnTheme: $('btnTheme'),
trafficCanvas: $('trafficChart'),
reachBadge: $('reachBadge'), reachRefresh: $('reachRefresh'),
reachNick: $('reachNick'), reachFP: $('reachFP'), reachPort: $('reachPort'), reachLAN: $('reachLAN'),
reachAddr: $('reachAddr'), reachFlags: $('reachFlags'), reachSeen: $('reachSeen'),
reachHelp: $('reachHelp'), hintPort: $('hintPort'), hintLAN: $('hintLAN'),
cfgForm: $('cfgForm'), nickname: $('nickname'), contact: $('contact'), orport: $('orport'),
rate_mbps: $('rate_mbps'), burst_mbps: $('burst_mbps'), cap_gb: $('cap_gb'), acc_day: $('acc_day'),
saveBtn: $('saveBtn'), saveMsg: $('saveMsg'),
btnEditCfg: $('btnEditCfg'), btnCancelCfg: $('btnCancelCfg'),
v_nickname: $('v_nickname'), v_contact: $('v_contact'), v_orport: $('v_orport'),
v_rate: $('v_rate'), v_burst: $('v_burst'), v_cap: $('v_cap'), v_accday: $('v_accday'),
};
const THEME_KEY = 'torpanel:theme';
const mql = window.matchMedia('(prefers-color-scheme: dark)');
function preferredTheme(){
const saved = localStorage.getItem(THEME_KEY);
if (saved === 'dark' || saved === 'light') return saved;
return 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(theme){
if (!el.btnTheme) return;
el.btnTheme.textContent = theme === 'dark' ? '🌞 Light' : '🌙 Dark';
}
function applyTheme(theme){
document.documentElement.setAttribute('data-theme', theme);
localStorage.setItem(THEME_KEY, theme);
setBtnLabel(theme);
// Recolor chart axes/grid
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 && chart.options.plugins.legend && chart.options.plugins.legend.labels){
chart.options.plugins.legend.labels.color = c.text;
}
chart.update('none');
}
}
mql.addEventListener('change', (e)=>{
const saved = localStorage.getItem(THEME_KEY);
if (!saved) applyTheme(e.matches ? 'dark' : 'light');
});
if (el.btnTheme){
el.btnTheme.addEventListener('click', ()=>{
const next = (document.documentElement.getAttribute('data-theme') === 'dark') ? 'light' : 'dark';
applyTheme(next);
});
}
function setEditMode(on){
if (!el.cfgForm) return;
el.cfgForm.style.display = on ? 'block' : 'none';
const view = document.getElementById('cfgView');
if (view) view.style.display = on ? 'none' : 'block';
if (el.btnEditCfg) el.btnEditCfg.style.display = on ? 'none' : 'inline-block';
}
function updateCfgViewFromForm(){
if (!el.v_nickname) return;
el.v_nickname.textContent = (el.nickname?.value || '—') || '—';
el.v_contact.textContent = (el.contact?.value || '—') || '—';
el.v_orport.textContent = el.orport?.value || '—';
el.v_rate.textContent = (el.rate_mbps?.value ? (el.rate_mbps.value + ' Mbps') : '—');
el.v_burst.textContent = (el.burst_mbps?.value ? (el.burst_mbps.value + ' Mbps') : '—');
el.v_cap.textContent = (el.cap_gb?.value ? (el.cap_gb.value + ' GB') : '—');
el.v_accday.textContent = el.acc_day?.value || '—';
}
const fmtBytes = (n) => {
if (n < 1024) return n + ' B';
let 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];
};
async function loadNow(){
const r = await fetch('api/now.php');
const j = await r.json();
if (!j.ok) return;
if (el.readTotal) el.readTotal.textContent = fmtBytes(j.read);
if (el.writeTotal) el.writeTotal.textContent = fmtBytes(j.written);
if (el.torVersion) el.torVersion.textContent = j.version;
if (el.bootstrap) el.bootstrap.textContent = j.bootstrap || '—';
if (el.badgeCircuits){
el.badgeCircuits.textContent = j.circuits ? 'circuits established' : 'not ready';
el.badgeCircuits.className = 'badge ' + (j.circuits ? 'bg-success' : 'bg-danger');
}
}
async function loadCfg(){
const r = await fetch('api/config_get.php');
const cfg = await r.json();
const getMB = (s) => (parseFloat((s||'').split(' ')[0])||0);
if (el.nickname) el.nickname.value = cfg.Nickname || '';
if (el.contact) el.contact.value = cfg.ContactInfo || '';
if (el.orport) el.orport.value = (cfg.ORPort || '9001');
if (el.rate_mbps) el.rate_mbps.value = Math.round(getMB(cfg.BandwidthRate)*8) || 5;
if (el.burst_mbps) el.burst_mbps.value = Math.round(getMB(cfg.BandwidthBurst)*8) || (parseInt(el.rate_mbps.value||'5')*2);
if (el.cap_gb) el.cap_gb.value = parseInt((cfg.AccountingMax||'100').split(' ')[0]) || 100;
if (el.acc_day) el.acc_day.value = ((cfg.AccountingStart||'month 1 00:00').match(/month\s+(\d+)/i)||[])[1] || '1';
updateCfgViewFromForm();
}
let chart = null;
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:'Read/min', data: rx, tension:.3},
{label:'Written/min', data: tx, tension:.3}
]},
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 refreshChart(){
const r = await fetch('api/stats.php');
const s = await r.json();
const data = (s.data||[]);
let labels=[], rx=[], tx=[];
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);
}
setChartData(labels, rx, tx);
}
async function loadReach(){
const r = await fetch('api/reach.php');
const j = await r.json();
if (!j.ok) return;
if (el.reachNick) el.reachNick.textContent = j.nickname || '—';
if (el.reachFP) el.reachFP.textContent = j.fingerprint || '—';
if (el.reachPort) el.reachPort.textContent = j.orport || '—';
if (el.reachLAN) el.reachLAN.textContent = j.lan_ip || '—';
if (el.hintPort) el.hintPort.textContent = j.orport || '—';
if (el.hintLAN) el.hintLAN.textContent = j.lan_ip || '—';
if (el.reachAddr) el.reachAddr.textContent = j.tor_address || '—';
let flagStr = '—', seenStr = '—';
if (j.onionoo && j.onionoo.found) {
flagStr = (j.onionoo.flags || []).join(', ') || '—';
if (j.onionoo.last_seen) seenStr = 'Last seen: ' + new Date(j.onionoo.last_seen).toLocaleString();
}
const running = !!(j.onionoo && j.onionoo.running);
if (el.reachBadge){
el.reachBadge.textContent = running ? 'Running (publicly reachable)' : (j.onionoo && j.onionoo.found ? 'Not Running yet' : 'Not in consensus yet');
el.reachBadge.className = 'badge ' + (running ? 'bg-success' : (j.onionoo && j.onionoo.found ? 'bg-warning' : 'bg-danger'));
}
if (el.reachHelp) el.reachHelp.style.display = running ? 'none' : 'block';
}
if (el.btnEditCfg){
el.btnEditCfg.addEventListener('click', ()=> setEditMode(true));
}
if (el.btnCancelCfg){
el.btnCancelCfg.addEventListener('click', async ()=>{
await loadCfg();
setEditMode(false);
});
}
if (el.cfgForm){
el.cfgForm.addEventListener('submit', async (e)=>{
e.preventDefault();
if (el.saveBtn) el.saveBtn.disabled = true;
if (el.saveMsg) el.saveMsg.textContent = 'Saving...';
const body = {
nickname: (el.nickname?.value||'').trim(),
contact: (el.contact?.value||'').trim(),
orport: parseInt(el.orport?.value||'9001'),
rate_mbps: parseInt(el.rate_mbps?.value||'5'),
burst_mbps: parseInt(el.burst_mbps?.value||String((parseInt(el.rate_mbps?.value||'5')*2))),
cap_gb: parseInt(el.cap_gb?.value||'100'),
acc_day: parseInt(el.acc_day?.value||'1'),
};
const r = await fetch('api/config_set.php', {
method:'POST',
headers:{'Content-Type':'application/json'},
body: JSON.stringify(body)
});
const j = await r.json();
if (el.saveBtn) el.saveBtn.disabled = false;
if (el.saveMsg) el.saveMsg.textContent = j.ok ? 'Saved. Tor reloaded.' : ('Error: ' + (j.error||'unknown'));
if (j.ok){
await loadCfg();
setEditMode(false);
}
});
}
(function initTheme(){
const t = preferredTheme();
document.documentElement.setAttribute('data-theme', t);
setBtnLabel(t);
})();
(async ()=>{
await loadCfg();
await loadNow();
await refreshChart();
await loadReach();
applyTheme(preferredTheme());
setInterval(loadNow, 5000);
setInterval(refreshChart, 60000);
setInterval(loadReach, 60000);
})();
</script>
</body>
</html>

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

@@ -0,0 +1,109 @@
<?php
declare(strict_types=1);
$secure = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off');
session_set_cookie_params([
'lifetime' => 0,
'path' => '/',
'domain' => '',
'secure' => $secure,
'httponly' => true,
'samesite' => 'Strict',
]);
ini_set('session.use_strict_mode', '1');
ini_set('session.cookie_httponly', '1');
session_name('torpanel');
session_start();
const APP_CONF_DIR = '/etc/torpanel';
const APP_CONF_FILE = APP_CONF_DIR . '/app.json';
const STATE_DIR = '/var/lib/torpanel';
const LOGIN_THROTTLE_FILE = STATE_DIR . '/login_throttle.json';
const SESSION_IDLE_TTL = 60 * 60 * 12;
if (!empty($_SESSION['last']) && (time() - (int)$_SESSION['last']) > SESSION_IDLE_TTL) {
$_SESSION = [];
if (ini_get('session.use_cookies')) {
$p = session_get_cookie_params();
setcookie(session_name(), '', time() - 42000, $p['path'], $p['domain'], $p['secure'] ?? false, $p['httponly'] ?? true);
}
session_destroy();
session_start();
}
$_SESSION['last'] = time();
function app_is_installed(): bool {
return is_file(APP_CONF_FILE);
}
function app_config(): array {
if (!app_is_installed()) return [];
$raw = @file_get_contents(APP_CONF_FILE);
return $raw ? (json_decode($raw, true) ?: []) : [];
}
function app_save_config(array $cfg): void {
if (!is_dir(APP_CONF_DIR)) mkdir(APP_CONF_DIR, 0770, true);
$tmp = APP_CONF_FILE . '.tmp';
file_put_contents($tmp, json_encode($cfg, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES));
rename($tmp, APP_CONF_FILE);
@chmod(APP_CONF_FILE, 0640);
}
function auth_login(string $u, string $p): bool {
$cfg = app_config();
if (!isset($cfg['admin_user'], $cfg['admin_pass'])) return false;
if (!hash_equals($cfg['admin_user'], $u)) return false;
if (!password_verify($p, $cfg['admin_pass'])) return false;
session_regenerate_id(true);
$_SESSION['uid'] = $u;
$_SESSION['ua'] = substr($_SERVER['HTTP_USER_AGENT'] ?? '', 0, 160);
$_SESSION['last'] = time();
return true;
}
function auth_logout(): void {
$_SESSION = [];
if (ini_get('session.use_cookies')) {
$p = session_get_cookie_params();
setcookie(session_name(), '', time() - 42000, $p['path'], $p['domain'], $p['secure'] ?? false, $p['httponly'] ?? true);
}
session_destroy();
}
function auth_require(): void {
if (!app_is_installed()) { header('Location: /setup.php'); exit; }
if (empty($_SESSION['uid'])) { header('Location: /login.php'); exit; }
}
function throttle_state(): array {
if (!is_dir(STATE_DIR)) @mkdir(STATE_DIR, 0775, true);
$raw = @file_get_contents(LOGIN_THROTTLE_FILE);
return $raw ? (json_decode($raw, true) ?: []) : [];
}
function throttle_save(array $st): void {
$tmp = LOGIN_THROTTLE_FILE . '.tmp';
file_put_contents($tmp, json_encode($st));
@chmod($tmp, 0660);
rename($tmp, LOGIN_THROTTLE_FILE);
}
function throttle_check(string $ip): int {
$st = throttle_state();
$now = time();
$key = $ip ?: 'unknown';
$rec = $st[$key] ?? ['fails'=>0, 'until'=>0];
if ($rec['until'] > $now) return $rec['until'] - $now;
return 0;
}
function throttle_record(string $ip, bool $ok): void {
$st = throttle_state();
$now = time();
$key = $ip ?: 'unknown';
$rec = $st[$key] ?? ['fails'=>0, 'until'=>0];
if ($ok) {
$rec = ['fails'=>0, 'until'=>0];
} else {
$rec['fails'] = min(10, ($rec['fails'] ?? 0) + 1);
$wait = min(300, 2 ** $rec['fails']);
$rec['until'] = $now + $wait;
}
$st[$key] = $rec;
throttle_save($st);
}

207
web/lib/torctl.php Normal file
View File

@@ -0,0 +1,207 @@
<?php
function torctl(array $commands): array {
$sock = @stream_socket_client("unix:///run/tor/control", $errno, $errstr, 2.0);
if (!$sock) { throw new Exception("control connect failed: $errstr"); }
stream_set_timeout($sock, 2);
$cookieRaw = @file_get_contents("/run/tor/control.authcookie");
if ($cookieRaw === false) { fclose($sock); throw new Exception("cookie read failed"); }
$cookie = bin2hex($cookieRaw);
fwrite($sock, "AUTHENTICATE $cookie\r\n");
$resp = fgets($sock);
if ($resp === false || strpos($resp, "250") !== 0) { fclose($sock); throw new Exception("auth failed: $resp"); }
$out = [];
foreach ($commands as $c) {
fwrite($sock, rtrim($c)."\r\n");
$buf = "";
while (($line = fgets($sock)) !== false) {
$buf .= $line;
if (rtrim($line) === "250 OK") break;
}
foreach (explode("\n", trim($buf)) as $ln) {
$ln = trim($ln);
if (strpos($ln, "250-") === 0) $ln = substr($ln, 4);
else if (strpos($ln, "250 ") === 0) $ln = substr($ln, 4);
else continue;
if (strpos($ln, "=") !== false) {
[$k,$v] = explode("=", $ln, 2);
$out[trim($k)] = trim($v);
} else {
$out[] = $ln;
}
}
}
fclose($sock);
return $out;
}
function torpanel_conf_path(): string { return "/etc/tor/torrc.d/99-torpanel.conf"; }
function _sanitize_nickname(string $nick): string {
$nick = preg_replace('/[^A-Za-z0-9_-]/', '', $nick);
if ($nick === '') $nick = 'RaspberryRelay';
return substr($nick, 0, 19);
}
function _mbps_to_kb(int $mbps): int {
$mbps = max(1, min($mbps, 1000000));
return (int)max(1, round($mbps * 125));
}
function _kb_to_mbps(float $kb): int {
return (int)max(1, round($kb / 125));
}
function _rate_to_kb(string $val): int {
if (preg_match('/^\s*(\d+(?:\.\d+)?)\s*(KB|MB)?\s*$/i', $val, $m)) {
$n = (float)$m[1];
$u = isset($m[2]) ? strtoupper($m[2]) : 'KB';
if ($u === 'MB') return (int)max(1, round($n * 1000));
return (int)max(1, round($n));
}
return 625; // ~5 Mbps
}
function read_torpanel_conf(): array {
$path = torpanel_conf_path();
$cfg = [
"Nickname" => "RaspberryRelay",
"ContactInfo" => "contact@admin.com",
"ORPort" => "9001",
"BandwidthRate" => "625 KB",
"BandwidthBurst" => "1250 KB",
"AccountingMax" => "100 GB",
"AccountingStart" => "month 1 00:00",
"SocksPort" => "0",
"TransPort" => "0",
"ExitRelay" => "0",
"ControlPort" => "0",
"ControlSocket" => "/run/tor/control",
"CookieAuthentication" => "1",
"CookieAuthFileGroupReadable" => "1",
];
if (!is_readable($path)) return $cfg;
foreach (@file($path) ?: [] as $line) {
if (preg_match('/^\s*(Nickname|ContactInfo|ORPort|BandwidthRate|BandwidthBurst|AccountingMax|AccountingStart|SocksPort|TransPort|ExitRelay|ControlPort|ControlSocket|CookieAuthentication|CookieAuthFileGroupReadable)\s+(.+?)\s*$/', $line, $m)) {
$cfg[$m[1]] = trim($m[2]);
}
}
return $cfg;
}
function torrc_to_ui(array $parsed): array {
$rate_kb = _rate_to_kb((string)($parsed['BandwidthRate'] ?? '625 KB'));
$burst_kb = _rate_to_kb((string)($parsed['BandwidthBurst'] ?? max(1, $rate_kb * 2)));
$cap_gb = 100;
if (preg_match('/^\s*(\d+(?:\.\d+)?)\s*GB\b/i', (string)($parsed['AccountingMax'] ?? ''), $m)) {
$cap_gb = (int)max(1, round((float)$m[1]));
}
$acc_day = 1;
if (preg_match('/month\s+(\d+)\s+\d+:\d+/i', (string)($parsed['AccountingStart'] ?? ''), $m)) {
$acc_day = max(1, min(28, (int)$m[1]));
}
return [
'nickname' => (string)($parsed['Nickname'] ?? 'RaspberryRelay'),
'contact' => (string)($parsed['ContactInfo'] ?? 'contact@admin.com'),
'orport' => (int) ($parsed['ORPort'] ?? 9001),
'rate_mbps' => _kb_to_mbps($rate_kb),
'burst_mbps' => _kb_to_mbps($burst_kb),
'cap_gb' => (int)$cap_gb,
'acc_day' => (int)$acc_day,
];
}
function write_torpanel_conf(array $in): bool {
$nick = isset($in['nickname']) ? (string)$in['nickname'] :
(isset($in['Nickname']) ? (string)$in['Nickname'] : 'RaspberryRelay');
$nick = _sanitize_nickname($nick);
$contact = isset($in['contact']) ? (string)$in['contact'] :
(isset($in['ContactInfo']) ? (string)$in['ContactInfo'] : 'contact@admin.com');
$contact = substr(preg_replace('/[\x00-\x1F\x7F]/', '', trim($contact)), 0, 200);
$orport = (int)($in['orport'] ?? ($in['ORPort'] ?? 9001));
if ($orport < 1 || $orport > 65535) $orport = 9001;
if (isset($in['rate_mbps'])) { $rate_kb = _mbps_to_kb((int)$in['rate_mbps']); }
else { $rate_kb = _rate_to_kb((string)($in['BandwidthRate'] ?? '625 KB')); }
if (isset($in['burst_mbps'])) { $burst_kb = _mbps_to_kb((int)$in['burst_mbps']); }
else { $burst_kb = _rate_to_kb((string)($in['BandwidthBurst'] ?? max(1, $rate_kb*2) . ' KB')); }
if ($burst_kb < $rate_kb) $burst_kb = $rate_kb;
$cap_gb = (int)($in['cap_gb'] ?? 0);
if ($cap_gb <= 0 && isset($in['AccountingMax']) && preg_match('/^\s*(\d+(?:\.\d+)?)\s*GB\b/i', (string)$in['AccountingMax'], $m)) {
$cap_gb = (int)max(1, round((float)$m[1]));
}
if ($cap_gb < 1) $cap_gb = 100;
$acc_day = (int)($in['acc_day'] ?? 0);
if ($acc_day <= 0 && isset($in['AccountingStart']) && preg_match('/month\s+(\d+)\s+\d+:\d+/i', (string)$in['AccountingStart'], $m)) {
$acc_day = (int)$m[1];
}
if ($acc_day < 1 || $acc_day > 28) $acc_day = 1;
$out = <<<EOC
## --- Managed by TorPanel ---
SocksPort 0
TransPort 0
ORPort {$orport}
ExitRelay 0
ExitPolicy reject *:*
Nickname {$nick}
ContactInfo {$contact}
BandwidthRate {$rate_kb} KB
BandwidthBurst {$burst_kb} KB
AccountingMax {$cap_gb} GB
AccountingStart month {$acc_day} 00:00
ControlPort 0
ControlSocket /run/tor/control
CookieAuthentication 1
CookieAuthFileGroupReadable 1
# --- End TorPanel block ---
EOC;
$path = torpanel_conf_path();
$dir = dirname($path);
if (file_exists($path) && is_writable($path)) {
if (file_put_contents($path, $out, LOCK_EX) !== false) {
@chgrp($path, 'www-data'); @chmod($path, 0664);
return true;
}
return false;
}
if (is_dir($dir) && is_writable($dir)) {
$tmp = $path . '.tmp';
if (file_put_contents($tmp, $out) !== false) {
@chgrp($tmp, 'www-data'); @chmod($tmp, 0664);
@rename($tmp, $path);
@chgrp($path, 'www-data'); @chmod($path, 0664);
return true;
}
@is_file($tmp) && @unlink($tmp);
}
if (file_put_contents($path, $out) !== false) {
@chgrp($path, 'www-data'); @chmod($path, 0664);
return true;
}
return false;
}
function config_apply(array $in): bool {
$ok = write_torpanel_conf($in);
if (!$ok) return false;
@exec('sudo /bin/systemctl reload tor 2>/dev/null');
return true;
}

106
web/login.php Normal file
View File

@@ -0,0 +1,106 @@
<?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 · Tor Relay Panel</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;">
Tor Relay Panel
</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 ($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='torpanel: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: /

200
web/setup.php Normal file
View File

@@ -0,0 +1,200 @@
<?php
require __DIR__ . '/lib/app.php';
require __DIR__ . '/lib/torctl.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'] ?? '';
$nickname = trim($_POST['nickname'] ?? 'RPI-Relay');
$contact = trim($_POST['contact'] ?? 'contact@admin.com');
$orport = (int)($_POST['orport'] ?? 9001);
$rate_mbps = (int)($_POST['rate_mbps'] ?? 5);
$burst_mbps = (int)($_POST['burst_mbps']?? max(10, $rate_mbps*2));
$cap_gb = (int)($_POST['cap_gb'] ?? 100);
$acc_day = (int)($_POST['acc_day'] ?? 1);
if ($u === '' || $p === '') {
$err = 'Username and password are required.';
} elseif ($p !== $p2) {
$err = 'Passwords do not match.';
} else {
$app_cfg = [
'admin_user' => $u,
'admin_pass' => password_hash($p, PASSWORD_DEFAULT),
'created_at' => date('c'),
];
if (function_exists('app_save_config')) {
app_save_config($app_cfg);
} else {
$dir = '/etc/torpanel';
@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);
}
config_apply([
'nickname' => $nickname,
'contact' => $contact,
'orport' => $orport,
'rate_mbps' => $rate_mbps,
'burst_mbps' => $burst_mbps,
'cap_gb' => $cap_gb,
'acc_day' => $acc_day,
]);
header('Location: /login.php?ok=1'); exit;
}
}
?>
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>First-time Setup · Tor Relay Panel</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;">
Tor Relay Panel
</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">Relay basics</div></div>
<div class="row g-3">
<div class="col-md-3">
<label class="form-label" for="nickname">Nickname</label>
<input class="form-control" id="nickname" name="nickname" placeholder="RPI-Relay"
value="<?= htmlspecialchars($_POST['nickname'] ?? '') ?>">
</div>
<div class="col-md-4">
<label class="form-label" for="contact">Contact</label>
<input class="form-control" id="contact" name="contact" placeholder="admin@example.com"
value="<?= htmlspecialchars($_POST['contact'] ?? '') ?>">
</div>
<div class="col-md-2">
<label class="form-label" for="orport">ORPort</label>
<input class="form-control" id="orport" name="orport" type="number" min="1" max="65535" placeholder="9001"
value="<?= htmlspecialchars($_POST['orport'] ?? '9001') ?>">
</div>
<div class="col-md-2">
<label class="form-label" for="rate_mbps">Bandwidth</label>
<div class="input-group">
<input class="form-control" id="rate_mbps" name="rate_mbps" type="number" min="1" step="1" placeholder="5"
value="<?= htmlspecialchars($_POST['rate_mbps'] ?? '5') ?>">
<span class="input-group-text">Mbps</span>
</div>
</div>
</div>
<div class="row g-3 mt-1">
<div class="col-md-3">
<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="1" step="1" placeholder="100"
value="<?= htmlspecialchars($_POST['cap_gb'] ?? '100') ?>">
<span class="input-group-text">GB</span>
</div>
</div>
<div class="col-md-3">
<label class="form-label" for="acc_day">Accounting day</label>
<select id="acc_day" name="acc_day" class="form-select">
<?php
$sel = (int)($_POST['acc_day'] ?? 1);
for($d=1;$d<=28;$d++){
$s = ($sel===$d)?' selected':'';
echo "<option$s>$d</option>";
}
?>
</select>
</div>
<div class="col-md-3">
<label class="form-label" for="burst_mbps">Burst</label>
<div class="input-group">
<input class="form-control" id="burst_mbps" name="burst_mbps" type="number" min="1" step="1" placeholder="10"
value="<?= htmlspecialchars($_POST['burst_mbps'] ?? '10') ?>">
<span class="input-group-text">Mbps</span>
</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>Forward <span class="mono">TCP 9001</span> (or your ORPort) from your router to this device.</li>
<li>It can take a while to get the <b>Running</b> flag after changes.</li>
</ul>
</div>
</form>
</div>
</div>
</div>
</div>
</div>
<script>
const THEME_KEY='torpanel: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>