Compare commits

..

6 Commits

Author SHA1 Message Date
almostm4
a915dff34d Merge pull request 'v1.0.2' (#2) from v1.0.2 into main 2025-11-11 14:49:06 +00:00
570d527309 Status check improvement 2025-11-11 15:25:34 +01:00
a2a37b7e6c Tor log implementation 2025-11-11 15:11:14 +01:00
almostm4
38ad3e23e1 Merge pull request 'hotfix/1.0.1' (#1) from hotfix/1.0.1 into main
- Configuration parsing fix
- Graph fix
- Naming adjustments
2025-11-09 15:06:02 +00:00
c5b9e6c4b2 Naming adjustments 2025-11-09 16:05:02 +01:00
3954954f86 Fix tor configuration append / graph visualization 2025-11-09 14:45:01 +01:00
6 changed files with 292 additions and 39 deletions

View File

@@ -29,9 +29,29 @@ COLLECTOR_SRC="$SCRIPT_DIR/bin/torpanel-collect.py"
COLLECTOR_BIN="/usr/local/bin/torpanel-collect.py" COLLECTOR_BIN="/usr/local/bin/torpanel-collect.py"
SVC="/etc/systemd/system/torpanel-collector.service" SVC="/etc/systemd/system/torpanel-collector.service"
TIMER="/etc/systemd/system/torpanel-collector.timer" TIMER="/etc/systemd/system/torpanel-collector.timer"
LOGDUMP_BIN="/usr/local/bin/torpanel-logdump"
export DEBIAN_FRONTEND=noninteractive export DEBIAN_FRONTEND=noninteractive
ensure_torrc_d_include() {
local main="$TOR_ETC/torrc"
install -d -m 755 "$TOR_TORRC_D"
if [[ ! -f "$main" ]]; then
echo "# Created by TorPanel installer" > "$main"
fi
if grep -Eq '^[[:space:]]*%include[[:space:]]+/etc/tor/torrc\.d/\*\.conf' "$main"; then
return 0
fi
if sed -n 's/^[[:space:]]*#\s*%include[[:space:]]\+\/etc\/tor\/torrc\.d\/\*\.conf/%include \/etc\/tor\/torrc.d\/\*\.conf/p' "$main" | grep -q .; then
sed -i 's/^[[:space:]]*#\s*%include[[:space:]]\+\/etc\/tor\/torrc\.d\/\*\.conf/%include \/etc\/tor\/torrc.d\/\*\.conf/' "$main"
else
printf '\n%%include /etc/tor/torrc.d/*.conf\n' >> "$main"
fi
}
echo -e "${C_BOLD}Installing TorPanel...${C_RESET}" echo -e "${C_BOLD}Installing TorPanel...${C_RESET}"
info "Updating apt and installing packages" info "Updating apt and installing packages"
@@ -49,7 +69,7 @@ ln -sf "$PHP_SOCK" /run/php/php-fpm.sock || true
info "Preparing directories" info "Preparing directories"
install -d "$PANEL_PUBLIC" "$STATE_DIR" "$LOG_DIR" "$ETC_APP" install -d "$PANEL_PUBLIC" "$STATE_DIR" "$LOG_DIR" "$ETC_APP"
touch "$STATE_DIR/stats.json" echo -n '{"data":[]}' > "$STATE_DIR/stats.json"
rsync -a --delete "$SCRIPT_DIR/web/" "$PANEL_PUBLIC/" rsync -a --delete "$SCRIPT_DIR/web/" "$PANEL_PUBLIC/"
chown -R www-data:www-data "$PANEL_ROOT" "$STATE_DIR" "$LOG_DIR" chown -R www-data:www-data "$PANEL_ROOT" "$STATE_DIR" "$LOG_DIR"
chmod 750 "$PANEL_ROOT" "$STATE_DIR" "$LOG_DIR" chmod 750 "$PANEL_ROOT" "$STATE_DIR" "$LOG_DIR"
@@ -105,6 +125,10 @@ CookieAuthFileGroupReadable 1
TORRC TORRC
ok "torrc written" ok "torrc written"
info "Ensuring main torrc includes torrc.d/*.conf"
ensure_torrc_d_include
ok "torrc.d include active"
info "Setting permissions for Tor managed config" info "Setting permissions for Tor managed config"
chown root:www-data "$TOR_TORRC_D"; chmod 775 "$TOR_TORRC_D" chown root:www-data "$TOR_TORRC_D"; chmod 775 "$TOR_TORRC_D"
chown root:www-data "$TOR_PANEL_CONF"; chmod 664 "$TOR_PANEL_CONF" chown root:www-data "$TOR_PANEL_CONF"; chmod 664 "$TOR_PANEL_CONF"
@@ -114,9 +138,10 @@ info "Granting www-data access to Tor cookie"
usermod -aG debian-tor www-data || true usermod -aG debian-tor www-data || true
ok "Permissions set" ok "Permissions set"
info "Allowing www-data to control tor (limited)" info "Allowing www-data to control tor (limited) + read logs"
cat > "$SUDOERS_FILE" <<'SUD' cat > "$SUDOERS_FILE" <<SUD
www-data ALL=NOPASSWD:/bin/systemctl reload tor, /bin/systemctl restart tor, /bin/systemctl start tor, /bin/systemctl stop tor www-data ALL=NOPASSWD:/bin/systemctl reload tor, /bin/systemctl restart tor, /bin/systemctl start tor, /bin/systemctl stop tor
www-data ALL=NOPASSWD:$LOGDUMP_BIN
SUD SUD
chmod 440 "$SUDOERS_FILE" chmod 440 "$SUDOERS_FILE"
ok "Sudoers entry created" ok "Sudoers entry created"
@@ -129,6 +154,7 @@ cat > "$SVC" <<'UNIT'
Description=TorPanel minute collector Description=TorPanel minute collector
After=tor.service After=tor.service
Wants=tor.service Wants=tor.service
ConditionPathExists=/run/tor/control.authcookie
[Service] [Service]
Type=oneshot Type=oneshot
@@ -146,17 +172,22 @@ ProtectKernelModules=yes
ProtectKernelTunables=yes ProtectKernelTunables=yes
PrivateTmp=yes PrivateTmp=yes
PrivateDevices=yes PrivateDevices=yes
PrivateUsers=yes PrivateUsers=no
LockPersonality=yes LockPersonality=yes
MemoryDenyWriteExecute=yes MemoryDenyWriteExecute=yes
RestrictRealtime=yes RestrictRealtime=yes
RestrictSUIDSGID=yes RestrictSUIDSGID=yes
UMask=0077 UMask=0077
ReadWriteDirectories=/var/lib/torpanel ReadWritePaths=/var/lib/torpanel
ReadOnlyPaths=/run/tor /etc/tor ReadOnlyPaths=/run/tor /etc/tor
RestrictAddressFamilies=AF_UNIX RestrictAddressFamilies=AF_UNIX
SystemCallFilter=@system-service SystemCallFilter=@system-service
CapabilityBoundingSet= CapabilityBoundingSet=
StandardOutput=journal
StandardError=journal
Environment=PYTHONUNBUFFERED=1
Restart=on-failure
RestartSec=15s
[Install] [Install]
WantedBy=timers.target WantedBy=timers.target
@@ -165,22 +196,73 @@ UNIT
cat > "$TIMER" <<'TIMER' cat > "$TIMER" <<'TIMER'
[Unit] [Unit]
Description=Run TorPanel collector every minute Description=Run TorPanel collector every minute
[Timer] [Timer]
OnBootSec=30sec OnCalendar=*-*-* *:*:00
OnUnitActiveSec=60sec AccuracySec=15s
AccuracySec=15sec
Persistent=true Persistent=true
Unit=torpanel-collector.service
[Install] [Install]
WantedBy=timers.target WantedBy=timers.target
TIMER TIMER
ok "Systemd units installed" ok "Systemd units installed"
info "Installing tor log dumper"
cat > "$LOGDUMP_BIN" <<'BASH'
#!/usr/bin/env bash
set -Eeuo pipefail
LINES="${1:-500}"
LEVEL="${2:-info}" # info|notice|warning|err|debug
case "$LINES" in ''|*[!0-9]* ) LINES=500 ;; esac
case "$LEVEL" in debug|info|notice|warning|err) ;; * ) LEVEL=info ;; esac
unit_guess() {
if systemctl list-units --type=service | grep -q '^tor@default\.service'; then
echo 'tor@default.service'
elif systemctl list-units --type=service | grep -q '^tor\.service'; then
echo 'tor.service'
else
echo ''
fi
}
UNIT="$(unit_guess)"
if command -v journalctl >/dev/null 2>&1; then
if [[ -n "$UNIT" ]]; then
if journalctl -u "$UNIT" -t tor -p "$LEVEL" -n "$LINES" -o short-iso --no-pager; then
exit 0
fi
fi
if journalctl -t tor -p "$LEVEL" -n "$LINES" -o short-iso --no-pager; then
exit 0
fi
fi
for f in /var/log/tor/notice.log /var/log/tor/info.log /var/log/tor/log; do
if [[ -r "$f" ]]; then
tail -n "$LINES" "$f"
exit 0
fi
done
echo "No Tor logs found via journal (identifier 'tor') or /var/log/tor/*. Enable file logs with:
Log notice file /var/log/tor/notice.log
Log info file /var/log/tor/info.log
and reload tor." >&2
exit 1
BASH
chmod 0755 "$LOGDUMP_BIN"
ok "Log dumper installed"
info "Restarting services" info "Restarting services"
systemctl daemon-reload systemctl daemon-reload
systemctl enable --now tor systemctl enable --now tor
systemctl enable "$PHP_FPM_SVC" nginx >/dev/null systemctl enable "$PHP_FPM_SVC" nginx >/dev/null
systemctl restart "$PHP_FPM_SVC" systemctl restart "$PHP_FPM_SVC"
systemctl restart nginx systemctl restart nginx
systemctl start torpanel-collector.service || true
systemctl enable --now torpanel-collector.timer systemctl enable --now torpanel-collector.timer
ok "Services running" ok "Services running"

View File

@@ -1,7 +1,16 @@
<?php <?php
require __DIR__ . '/../lib/app.php'; auth_require(); require __DIR__ . '/../lib/app.php';
auth_require();
header('Content-Type: application/json'); header('Content-Type: application/json');
$path = "/var/lib/torpanel/stats.json"; header('Cache-Control: no-store, no-cache, must-revalidate, max-age=0');
if (!file_exists($path)) { echo json_encode(["data"=>[]]); exit; } header('Pragma: no-cache');
$path = '/var/lib/torpanel/stats.json';
if (!is_readable($path)) {
echo json_encode(['data' => []]);
exit;
}
$raw = file_get_contents($path); $raw = file_get_contents($path);
echo $raw ?: json_encode(["data"=>[]]); echo $raw !== false && $raw !== '' ? $raw : json_encode(['data' => []]);

51
web/api/stats_rates.php Normal file
View File

@@ -0,0 +1,51 @@
<?php
require __DIR__ . '/../lib/app.php';
auth_require();
header('Content-Type: application/json');
header('Cache-Control: no-store, no-cache, must-revalidate, max-age=0');
header('Pragma: no-cache');
$path = '/var/lib/torpanel/stats.json';
if (!is_readable($path)) { echo json_encode(['ok'=>true,'data'=>[]]); exit; }
$raw = file_get_contents($path);
$state = json_decode($raw, true);
if (!is_array($state) || !is_array($state['data'] ?? null)) {
echo json_encode(['ok'=>true,'data'=>[]]); exit;
}
usort($state['data'], function($a,$b){
return (int)($a['t'] ?? 0) <=> (int)($b['t'] ?? 0);
});
$out = [];
$prev = null;
foreach ($state['data'] as $s) {
$t = (int)($s['t'] ?? 0);
if ($t <= 0) continue;
$rx_total = (int)($s['read'] ?? ($s['bytes_read'] ?? ($s['rx'] ?? 0)));
$tx_total = (int)($s['written'] ?? ($s['bytes_written'] ?? ($s['tx'] ?? 0)));
$rx_mbps = 0.0; $tx_mbps = 0.0;
if ($prev) {
$dt = max(1, $t - $prev['t']);
$drx = max(0, $rx_total - $prev['rx_total']);
$dtx = max(0, $tx_total - $prev['tx_total']);
$rx_mbps = ($drx * 8) / $dt / 1_000_000.0;
$tx_mbps = ($dtx * 8) / $dt / 1_000_000.0;
}
$out[] = [
'ts' => $t * 1000,
'rx_mbps' => round($rx_mbps, 3),
'tx_mbps' => round($tx_mbps, 3),
'rx_total' => $rx_total,
'tx_total' => $tx_total,
];
$prev = ['t'=>$t, 'rx_total'=>$rx_total, 'tx_total'=>$tx_total];
}
if (count($out) > 1440) $out = array_slice($out, -1440);
echo json_encode(['ok'=>true,'data'=>$out]);

21
web/api/tor_log.php Normal file
View File

@@ -0,0 +1,21 @@
<?php
require __DIR__ . '/../lib/app.php';
auth_require();
header('Content-Type: application/json');
$n = isset($_GET['n']) ? (int)$_GET['n'] : 500;
if ($n < 50) $n = 50;
if ($n > 5000) $n = 5000;
$level = isset($_GET['level']) ? strtolower($_GET['level']) : 'info';
$allowed = ['debug','info','notice','warning','err'];
if (!in_array($level, $allowed, true)) $level = 'info';
$cmd = sprintf('sudo /usr/local/bin/torpanel-logdump %d %s', $n, escapeshellarg($level));
exec($cmd . ' 2>&1', $out, $rc);
if ($rc === 0 && !empty($out)) {
echo json_encode(['ok' => true, 'lines' => $out]);
} else {
echo json_encode(['ok' => false, 'error' => 'no_log', 'rc' => $rc]);
}

View File

@@ -22,6 +22,7 @@ auth_require();
Tor Relay Panel Tor Relay Panel
</span> </span>
<div class="ms-auto d-flex align-items-center gap-2"> <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> <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> <a class="btn btn-sm btn-outline-light" href="/logout.php">Logout</a>
</div> </div>
@@ -87,14 +88,14 @@ auth_require();
<div class="col-md-4"> <div class="col-md-4">
<div class="card p-3"> <div class="card p-3">
<div class="small">Read (total)</div> <div class="small">RX (total)</div>
<div class="h4 mono" id="readTotal">—</div> <div class="h4 mono" id="readTotal">—</div>
</div> </div>
</div> </div>
<div class="col-md-4"> <div class="col-md-4">
<div class="card p-3"> <div class="card p-3">
<div class="small">Written (total)</div> <div class="small">TX (total)</div>
<div class="h4 mono" id="writeTotal">—</div> <div class="h4 mono" id="writeTotal">—</div>
</div> </div>
</div> </div>
@@ -198,6 +199,32 @@ auth_require();
</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> <script>
const $ = (id) => document.getElementById(id); const $ = (id) => document.getElementById(id);
const el = { const el = {
@@ -240,7 +267,6 @@ function applyTheme(theme){
document.documentElement.setAttribute('data-theme', theme); document.documentElement.setAttribute('data-theme', theme);
localStorage.setItem(THEME_KEY, theme); localStorage.setItem(THEME_KEY, theme);
setBtnLabel(theme); setBtnLabel(theme);
// Recolor chart axes/grid
if (chart){ if (chart){
const c = getThemeColors(); const c = getThemeColors();
chart.options.scales.x.ticks.color = c.text; chart.options.scales.x.ticks.color = c.text;
@@ -326,8 +352,8 @@ function setChartData(labels, rx, tx){
chart = new Chart(ctx, { chart = new Chart(ctx, {
type: 'line', type: 'line',
data: { labels, datasets: [ data: { labels, datasets: [
{label:'Read/min', data: rx, tension:.3}, {label:'RX/min', data: rx, tension:.3},
{label:'Written/min', data: tx, tension:.3} {label:'TX/min', data: tx, tension:.3}
]}, ]},
options: { options: {
responsive:true, responsive:true,
@@ -364,10 +390,13 @@ async function refreshChart(){
setChartData(labels, rx, tx); setChartData(labels, rx, tx);
} }
async function loadReach(){ async function loadReach(){
const r = await fetch('api/reach.php'); try{
const r = await fetch('api/reach.php', {cache:'no-store'});
const j = await r.json(); const j = await r.json();
if (!j.ok) return; if (!j.ok) return false;
if (el.reachNick) el.reachNick.textContent = j.nickname || '—'; if (el.reachNick) el.reachNick.textContent = j.nickname || '—';
if (el.reachFP) el.reachFP.textContent = j.fingerprint || '—'; if (el.reachFP) el.reachFP.textContent = j.fingerprint || '—';
if (el.reachPort) el.reachPort.textContent = j.orport || '—'; if (el.reachPort) el.reachPort.textContent = j.orport || '—';
@@ -382,11 +411,20 @@ async function loadReach(){
if (j.onionoo.last_seen) seenStr = 'Last seen: ' + new Date(j.onionoo.last_seen).toLocaleString(); if (j.onionoo.last_seen) seenStr = 'Last seen: ' + new Date(j.onionoo.last_seen).toLocaleString();
} }
const running = !!(j.onionoo && j.onionoo.running); const running = !!(j.onionoo && j.onionoo.running);
if (el.reachBadge){ if (el.reachBadge){
el.reachBadge.textContent = running ? 'Running (publicly reachable)' : (j.onionoo && j.onionoo.found ? 'Not Running yet' : 'Not in consensus yet'); 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.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'; if (el.reachHelp) el.reachHelp.style.display = running ? 'none' : 'block';
return running;
}catch(e){
return false;
}
} }
if (el.btnEditCfg){ if (el.btnEditCfg){
@@ -427,6 +465,26 @@ if (el.cfgForm){
}); });
} }
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(){ (function initTheme(){
const t = preferredTheme(); const t = preferredTheme();
document.documentElement.setAttribute('data-theme', t); document.documentElement.setAttribute('data-theme', t);
@@ -442,6 +500,38 @@ if (el.cfgForm){
setInterval(refreshChart, 60000); setInterval(refreshChart, 60000);
setInterval(loadReach, 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> </script>
</body> </body>
</html> </html>

View File

@@ -125,7 +125,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
<input class="form-control" id="orport" name="orport" type="number" min="1" max="65535" placeholder="9001" <input class="form-control" id="orport" name="orport" type="number" min="1" max="65535" placeholder="9001"
value="<?= htmlspecialchars($_POST['orport'] ?? '9001') ?>"> value="<?= htmlspecialchars($_POST['orport'] ?? '9001') ?>">
</div> </div>
<div class="col-md-2"> <div class="col-md-3">
<label class="form-label" for="rate_mbps">Bandwidth</label> <label class="form-label" for="rate_mbps">Bandwidth</label>
<div class="input-group"> <div class="input-group">
<input class="form-control" id="rate_mbps" name="rate_mbps" type="number" min="1" step="1" placeholder="5" <input class="form-control" id="rate_mbps" name="rate_mbps" type="number" min="1" step="1" placeholder="5"