Release
This commit is contained in:
22
web/api/config_get.php
Normal file
22
web/api/config_get.php
Normal 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
12
web/api/config_set.php
Normal 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
23
web/api/now.php
Normal 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
62
web/api/reach.php
Normal 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
7
web/api/stats.php
Normal 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
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);
|
||||
}
|
||||
1
web/favicon.svg
Normal file
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
447
web/index.php
Normal 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
109
web/lib/app.php
Normal 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
207
web/lib/torctl.php
Normal 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
106
web/login.php
Normal 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
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: /
|
||||
200
web/setup.php
Normal file
200
web/setup.php
Normal 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>
|
||||
Reference in New Issue
Block a user