Files
rpi-tor-relay-panel/web/index.php
2025-11-11 15:25:34 +01:00

537 lines
21 KiB
PHP

<?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="btnTorLog" class="btn btn-sm btn-outline-light">View Tor Log</button>
<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">RX (total)</div>
<div class="h4 mono" id="readTotal">—</div>
</div>
</div>
<div class="col-md-4">
<div class="card p-3">
<div class="small">TX (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>
<div id="torlogModal" style="display:none; position:fixed; inset:0; background:rgba(0,0,0,.6); z-index:1050;">
<div style="background:#111; color:#eee; max-width:960px; height:70vh; margin:5vh auto; border-radius:12px; overflow:hidden; box-shadow:0 10px 30px rgba(0,0,0,.4); border:1px solid rgba(255,255,255,.08)">
<div class="d-flex align-items-center justify-content-between p-2" style="background:#1b1b1b; border-bottom:1px solid rgba(255,255,255,.08)">
<div class="fw-semibold">Tor Log</div>
<div class="d-flex gap-2 align-items-center">
<select id="torlogLevel" class="form-select form-select-sm bg-dark text-light" 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 bg-dark text-light" 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="torlogRefresh" class="btn btn-sm btn-outline-light">Refresh</button>
<button id="torlogClose" class="btn btn-sm btn-light text-dark">Close</button>
</div>
</div>
<pre id="torlogPre" class="p-3 m-0" style="background:#0c0c0c; color:#cdd3df; height:calc(70vh - 56px); overflow:auto; font-family:ui-monospace, SFMono-Regular, Menlo, Consolas, 'Liberation Mono', monospace; font-size:12px; line-height:1.35"></pre>
</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);
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:'RX/min', data: rx, tension:.3},
{label:'TX/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(){
try{
const r = await fetch('api/reach.php', {cache:'no-store'});
const j = await r.json();
if (!j.ok) return false;
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'));
el.reachBadge.title = 'Last checked: ' + new Date().toLocaleString();
}
if (el.reachFlags) el.reachFlags.textContent = flagStr;
if (el.reachSeen) el.reachSeen.textContent = seenStr;
if (el.reachHelp) el.reachHelp.style.display = running ? 'none' : 'block';
return running;
}catch(e){
return false;
}
}
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);
}
});
}
if (el.reachRefresh){
el.reachRefresh.addEventListener('click', async ()=>{
const btn = el.reachRefresh;
const originalClasses = btn.className;
btn.disabled = true;
btn.innerHTML = '<span class="spinner-border spinner-border-sm me-1" role="status" aria-hidden="true"></span>Checking…';
const running = await loadReach();
btn.className = running ? 'btn btn-sm btn-success' : 'btn btn-sm btn-danger';
btn.innerHTML = running ? 'Reachable ✓' : 'Not reachable ✗';
setTimeout(()=>{
btn.className = originalClasses;
btn.innerHTML = 'Check status';
btn.disabled = false;
}, 1500);
});
}
(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);
})();
(function(){
const m = document.getElementById('torlogModal');
const btn = document.getElementById('btnTorLog');
const pre = document.getElementById('torlogPre');
const lines= document.getElementById('torlogLines');
const lvl = document.getElementById('torlogLevel');
const close= document.getElementById('torlogClose');
const ref = document.getElementById('torlogRefresh');
async function loadLog(){
const n = lines?.value || '500';
const L = lvl?.value || 'info';
try{
const res = await fetch('api/tor_log.php?n=' + encodeURIComponent(n) + '&level=' + encodeURIComponent(L), {cache:'no-store'});
if (!res.ok){ pre.textContent = 'Failed to load log.'; return; }
const json = await res.json();
if (!json.ok){ pre.textContent = 'No logs available.'; return; }
pre.textContent = (json.lines||[]).join('\n');
pre.scrollTop = pre.scrollHeight;
}catch(e){
pre.textContent = 'Error: ' + e;
}
}
btn?.addEventListener('click', ()=>{ m.style.display='block'; loadLog(); });
lines?.addEventListener('change', loadLog);
lvl?.addEventListener('change', loadLog);
document.getElementById('torlogRefresh')?.addEventListener('click', loadLog);
document.getElementById('torlogClose')?.addEventListener('click', ()=>{ m.style.display='none'; });
m?.addEventListener('click', (e)=>{ if (e.target === m) m.style.display='none'; });
})();
</script>
</body>
</html>