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

135
README.md
View File

@@ -1,3 +1,134 @@
# rpi-tor-relay-panel Tor Relay Panel for Raspberry Pi
================================
Run a Tor middle relay on Raspberry Pi with a web dashboard, live traffic charts, reachability checks, and one-command install. _Run a Tor relay at home with a beautiful, simple web dashboard._
Bring real privacy power to the internet—right from your Raspberry Pi. **Tor Relay Panel** makes it easy for anyone to run a **Tor middle relay** with a clean, password-protected dashboard, live charts, and simple controls. No command-line expertise needed.
Why run a Tor relay?
--------------------
* **Strengthen online privacy** Keep the Tor network fast, healthy, and resilient for everyone who needs it.
* **Support digital freedom** Join a global, decentralized infrastructure that protects free expression.
* **Youre in control** Set your own bandwidth and monthly data cap to match your connection and plan.
Highlights
----------
* **Friendly web interface** Configure everything in minutes.
* **Middle relay only** Safe for homes; no exit traffic from your IP.
* **Live charts & stats** See traffic trends from the last 48 hours at a glance.
* **Reachability widget** Check if your relay is publicly reachable and has the **Running** flag.
* **Simple controls** Edit nickname, contact, ORPort, bandwidth, burst, and monthly cap anytime.
* **Secure session login** Password-protected dashboard for your local network.
* **Dark / Light theme** Switch instantly; your preference is remembered.
* **First-time setup wizard** Create your admin account and initial settings in one flow.
* **Lightweight & open-source** Built for Raspberry Pi OS (Debian) Lite 64-bit.
Who is it for?
--------------
* **Home users** who want to contribute to internet privacy.
* **Makers & Raspberry Pi fans** looking for a meaningful, always-on project.
* **Community builders** who want a reliable middle relay with simple, visual management.
What you need
-------------
* A **Raspberry Pi** running Raspberry Pi OS (Debian) **Lite 64-bit**.
* A stable **home internet connection**.
* Recommended: **IPv4 port forward** (or **IPv6 inbound**) to your Tor **ORPort** so your relay can be publicly reachable.
> 🔒 The web dashboard is designed for your **local network**. You dont need to expose it to the internet.
Setup in minutes
----------------
1. **Get the files**Clone or download this repository to your Raspberry Pi.
2. **Run the installer**From the project folder, run the install script with sudo (one line).
3. **Open the dashboard**Visit the Pis IP in your browser, follow the **first-time setup**, and youre done.
Youll then see:
* **Live status** (Tor version, bootstrap, circuits)
* **Traffic graphs** (read/written bytes over time)
* **Reachability check** (consensus, flags, “Tor thinks your address is…”)
* **Simple edit panel** for bandwidth, burst, and monthly cap
Screenshots
-----------
* **Dashboard (Dark Mode)**!\[Tor Relay Panel Dashboard (Dark)\](screenshots/dashboard-dark.jpg)
* **Dashboard (Light Mode)**!\[Tor Relay Panel Dashboard (Light)\](screenshots/dashboard-light.jpg)
* **Login Panel**!\[Tor Relay Panel Login\](screenshots/login.jpg)
* **First-Time Setup**!\[Tor Relay Panel Setup Wizard\](screenshots/setup-wizard.jpg)
Frequently asked questions
--------------------------
**Do I need to open a port?**Yes. To earn the **Running** flag and carry traffic, your relay must be reachable on its **ORPort** (default **9001/TCP**). The dashboard includes a reachability widget to help verify this. Most homes use a simple router port-forward (or allow inbound on IPv6).
**Is this an exit relay?**No. This runs as a **non-exit, middle relay**. Your IP is not used to access websites on behalf of others.
**Will this slow down my internet?**You choose your **bandwidth limit** and **monthly data cap**. Pick values that keep your connection comfortable.
**Is it safe to run at home?**A middle relay is a popular, community-friendly way to support Tor. Always follow your ISPs terms and local laws.
**Do I need to expose the dashboard to the internet?**No. Keep the dashboard **local-only** on your home network. Only the Tor **ORPort** needs to be reachable from the outside.
**Can I stop or change limits later?**Yes—use the dashboard to pause, adjust bandwidth, burst, or monthly cap any time.
Tips for the best experience
----------------------------
* **Give it time:** after changes, it can take a while before your relay appears with the **Running** flag.
* **Be consistent:** keeping your Pi on helps the network and improves reliability.
* **Start modestly:** begin with conservative bandwidth/cap values, then adjust upward as youre comfortable.
Uninstall
---------
Prefer to remove everything later? Theres a friendly uninstall script included to clean up the panel and related packages. Run it from the project folder with sudo.
Join the network
----------------
Spin up your Raspberry Pi, run the installer, set your limits, and start helping keep the open internet open. Your small contribution makes a **big** difference. 🧅
Keywords (for discoverability)
------------------------------
Raspberry Pi Tor relay, Tor middle relay dashboard, easy Tor relay setup, privacy network volunteer, run a Tor node at home, open-source Tor panel, simple Tor web interface, live Tor traffic graphs, secure Tor relay management, Raspberry Pi privacy project, Tor relay Raspberry Pi guide, local web dashboard for Tor, dark mode Tor panel, lightweight Tor admin UI

60
bin/torpanel-collect.py Normal file
View File

@@ -0,0 +1,60 @@
#!/usr/bin/env python3
import json, os, socket, time
CONTROL_SOCK = "/run/tor/control"
COOKIE_FILE = "/run/tor/control.authcookie"
STATE_FILE = "/var/lib/torpanel/stats.json"
ROLL_MAX = 45000
def torctl_send(cmds):
s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
s.settimeout(2.5); s.connect(CONTROL_SOCK)
cookie = open(COOKIE_FILE, "rb").read().hex().upper()
s.sendall(f"AUTHENTICATE {cookie}\r\n".encode())
if not s.recv(4096).decode(errors="ignore").startswith("250"):
raise RuntimeError("auth failed")
out = {}
for c in cmds:
s.sendall((c+"\r\n").encode())
buf=b""
while True:
chunk=s.recv(4096)
if not chunk: break
buf+=chunk
if buf.endswith(b"250 OK\r\n"): break
for line in buf.decode(errors="ignore").strip().splitlines():
if line.startswith("250-") or line.startswith("250 "):
line=line[4:]
if "=" in line:
k,v=line.split("=",1); out[k.strip()]=v.strip()
s.close(); return out
def load_state():
try: return json.load(open(STATE_FILE,"r"))
except: return {"data":[]}
def save_state(obj):
obj["data"] = obj["data"][-ROLL_MAX:]
tmp = STATE_FILE + ".tmp"
with open(tmp, "w") as f:
json.dump(obj, f, separators=(",", ":"))
f.flush(); os.fsync(f.fileno())
os.replace(tmp, STATE_FILE)
def main():
try:
info=torctl_send(["GETINFO traffic/read","GETINFO traffic/written","GETINFO status/circuit-established","GETINFO version"])
now=int(time.time())
state=load_state()
state["data"].append({
"t":now,
"read":int(info.get("traffic/read","0")),
"written":int(info.get("traffic/written","0")),
"circ":1 if info.get("status/circuit-established","0")=="1" else 0,
"version":info.get("version","unknown")
})
save_state(state)
except Exception:
pass
if __name__=="__main__": main()

192
install.sh Normal file
View File

@@ -0,0 +1,192 @@
#!/usr/bin/env bash
set -euo pipefail
C_RESET="\033[0m"; C_DIM="\033[2m"; C_BOLD="\033[1m"
C_RED="\033[31m"; C_GRN="\033[32m"; C_BLU="\033[34m"; C_YEL="\033[33m"
info(){ echo -e "${C_BLU}${C_RESET} $*"; }
ok(){ echo -e "${C_GRN}${C_RESET} $*"; }
warn(){ echo -e "${C_YEL}!${C_RESET} $*"; }
fail(){ echo -e "${C_RED}${C_RESET} $*"; }
if [[ $EUID -ne 0 ]]; then fail "Run as root (sudo)."; exit 1; fi
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PANEL_ROOT="/var/www/torpanel"
PANEL_PUBLIC="$PANEL_ROOT/public"
STATE_DIR="/var/lib/torpanel"
LOG_DIR="/var/log/torpanel"
ETC_APP="/etc/torpanel"
TOR_ETC="/etc/tor"
TOR_TORRC_D="$TOR_ETC/torrc.d"
TOR_PANEL_CONF="$TOR_TORRC_D/99-torpanel.conf"
NGX_SITE_AVAIL="/etc/nginx/sites-available/torpanel"
NGX_SITE_ENABL="/etc/nginx/sites-enabled/torpanel"
SUDOERS_FILE="/etc/sudoers.d/torpanel"
COLLECTOR_SRC="$SCRIPT_DIR/bin/torpanel-collect.py"
COLLECTOR_BIN="/usr/local/bin/torpanel-collect.py"
SVC="/etc/systemd/system/torpanel-collector.service"
TIMER="/etc/systemd/system/torpanel-collector.timer"
export DEBIAN_FRONTEND=noninteractive
echo -e "${C_BOLD}Installing TorPanel...${C_RESET}"
info "Updating apt and installing packages"
apt-get update -y >/dev/null
apt-get install -y --no-install-recommends \
tor tor-geoipdb nginx rsync \
php-fpm php-cli php-json php-curl php-zip php-common php-opcache \
python3 >/dev/null
ok "Packages installed"
PHPV=$(php -r 'echo PHP_MAJOR_VERSION.".".PHP_MINOR_VERSION;')
PHP_FPM_SVC="php${PHPV}-fpm"
PHP_SOCK="/run/php/php${PHPV}-fpm.sock"
ln -sf "$PHP_SOCK" /run/php/php-fpm.sock || true
info "Preparing directories"
install -d "$PANEL_PUBLIC" "$STATE_DIR" "$LOG_DIR" "$ETC_APP"
touch "$STATE_DIR/stats.json"
rsync -a --delete "$SCRIPT_DIR/web/" "$PANEL_PUBLIC/"
chown -R www-data:www-data "$PANEL_ROOT" "$STATE_DIR" "$LOG_DIR"
chmod 750 "$PANEL_ROOT" "$STATE_DIR" "$LOG_DIR"
chown root:www-data "$ETC_APP"; chmod 770 "$ETC_APP"
ok "Web files deployed"
info "Writing Nginx site"
cat > "$NGX_SITE_AVAIL" <<'NGINX'
server {
listen 80 default_server;
server_name _;
root /var/www/torpanel/public;
index index.php index.html;
location / {
try_files $uri $uri/ /index.php?$args;
}
location ~ \.php$ {
include snippets/fastcgi-php.conf;
fastcgi_pass unix:/run/php/php-fpm.sock;
}
location ~ /\. {
deny all;
}
}
NGINX
rm -f /etc/nginx/sites-enabled/default || true
ln -sf "$NGX_SITE_AVAIL" "$NGX_SITE_ENABL"
ok "Nginx site enabled"
info "Writing torrc defaults"
install -d "$TOR_TORRC_D"
cat > "$TOR_PANEL_CONF" <<'TORRC'
## --- Managed by TorPanel ---
SocksPort 0
ORPort 9001
ExitRelay 0
ExitPolicy reject *:*
Nickname RaspberryRelay
ContactInfo contact@admin.com
BandwidthRate 5 MB
BandwidthBurst 10 MB
AccountingMax 100 GB
AccountingStart month 1 00:00
ControlPort 0
ControlSocket /run/tor/control
CookieAuthentication 1
CookieAuthFileGroupReadable 1
# --- End TorPanel block ---
TORRC
ok "torrc written"
info "Setting permissions for Tor managed config"
chown root:www-data "$TOR_TORRC_D"; chmod 775 "$TOR_TORRC_D"
chown root:www-data "$TOR_PANEL_CONF"; chmod 664 "$TOR_PANEL_CONF"
ok "Permissions applied"
info "Granting www-data access to Tor cookie"
usermod -aG debian-tor www-data || true
ok "Permissions set"
info "Allowing www-data to control tor (limited)"
cat > "$SUDOERS_FILE" <<'SUD'
www-data ALL=NOPASSWD:/bin/systemctl reload tor, /bin/systemctl restart tor, /bin/systemctl start tor, /bin/systemctl stop tor
SUD
chmod 440 "$SUDOERS_FILE"
ok "Sudoers entry created"
info "Installing collector"
install -m 0755 "$COLLECTOR_SRC" "$COLLECTOR_BIN"
chown www-data:www-data "$COLLECTOR_BIN"
cat > "$SVC" <<'UNIT'
[Unit]
Description=TorPanel minute collector
After=tor.service
Wants=tor.service
[Service]
Type=oneshot
User=www-data
Group=www-data
SupplementaryGroups=debian-tor
ExecStart=/usr/bin/env python3 /usr/local/bin/torpanel-collect.py
NoNewPrivileges=yes
ProtectSystem=strict
ProtectHome=yes
ProtectClock=yes
ProtectHostname=yes
ProtectKernelLogs=yes
ProtectKernelModules=yes
ProtectKernelTunables=yes
PrivateTmp=yes
PrivateDevices=yes
PrivateUsers=yes
LockPersonality=yes
MemoryDenyWriteExecute=yes
RestrictRealtime=yes
RestrictSUIDSGID=yes
UMask=0077
ReadWriteDirectories=/var/lib/torpanel
ReadOnlyPaths=/run/tor /etc/tor
RestrictAddressFamilies=AF_UNIX
SystemCallFilter=@system-service
CapabilityBoundingSet=
[Install]
WantedBy=timers.target
UNIT
cat > "$TIMER" <<'TIMER'
[Unit]
Description=Run TorPanel collector every minute
[Timer]
OnBootSec=30sec
OnUnitActiveSec=60sec
AccuracySec=15sec
Persistent=true
[Install]
WantedBy=timers.target
TIMER
ok "Systemd units installed"
info "Restarting services"
systemctl daemon-reload
systemctl enable --now tor
systemctl enable "$PHP_FPM_SVC" nginx >/dev/null
systemctl restart "$PHP_FPM_SVC"
systemctl restart nginx
systemctl enable --now torpanel-collector.timer
ok "Services running"
IP=$(hostname -I 2>/dev/null | awk '{print $1}')
echo
echo -e "${C_BOLD}All set!${C_RESET}"
echo -e " URL: ${C_GRN}http://$IP/${C_RESET}"
echo -e " First run: you'll see a setup page to create the admin user."
echo -e " Tip: forward ${C_BOLD}TCP/9001${C_RESET} to your Pi for a publicly reachable relay."

Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 71 KiB

BIN
screenshots/login.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

115
uninstall.sh Normal file
View File

@@ -0,0 +1,115 @@
#!/usr/bin/env bash
set -euo pipefail
C_RESET="\033[0m"; C_DIM="\033[2m"; C_BOLD="\033[1m"
C_RED="\033[31m"; C_GRN="\033[32m"; C_BLU="\033[34m"; C_YEL="\033[33m"
info(){ echo -e "${C_BLU}${C_RESET} $*"; }
ok(){ echo -e "${C_GRN}${C_RESET} $*"; }
warn(){ echo -e "${C_YEL}!${C_RESET} $*"; }
fail(){ echo -e "${C_RED}${C_RESET} $*"; }
trap 'fail "An unexpected error occurred. Exiting."' ERR
PANEL_ROOT="/var/www/torpanel"
PANEL_PUBLIC="$PANEL_ROOT/public"
STATE_DIR="/var/lib/torpanel"
LOG_DIR="/var/log/torpanel"
ETC_APP="/etc/torpanel"
NGX_SITE_AVAIL="/etc/nginx/sites-available/torpanel"
NGX_SITE_ENABL="/etc/nginx/sites-enabled/torpanel"
SUDOERS_FILE="/etc/sudoers.d/torpanel"
TOR_TORRC_D="/etc/tor/torrc.d"
TOR_PANEL_CONF="$TOR_TORRC_D/99-torpanel.conf"
COLLECTOR_BIN="/usr/local/bin/torpanel-collect.py"
SVC="/etc/systemd/system/torpanel-collector.service"
TIMER="/etc/systemd/system/torpanel-collector.timer"
PKGS=("tor" "tor-geoipdb" "nginx" "php-fpm" "php-cli" "php-json" "php-curl" "php-zip" "php-common" "php-opcache")
YES=0
PURGE=0
for arg in "${@:-}"; do
case "$arg" in
-y|--yes) YES=1 ;;
-h|--help)
echo "Usage: sudo bash uninstall.sh [--yes]"
echo " --yes : don't ask for confirmation (defaults to no package purge)"
exit 0
;;
*) ;;
esac
done
if [[ $EUID -ne 0 ]]; then fail "Run as root (sudo)."; exit 1; fi
echo -e "${C_BOLD}Uninstalling TorPanel...${C_RESET}"
# Step 1: confirm uninstall
if [[ $YES -ne 1 ]]; then
read -r -p "This will remove TorPanel files/configs. Continue? [y/N] " ans
case "${ans,,}" in y|yes) ;; *) warn "Aborted by user."; exit 0 ;; esac
fi
# Step 2: ask if we should purge packages (only when not --yes)
if [[ $YES -ne 1 ]]; then
echo
echo -e "${C_DIM}Optional:${C_RESET} You can also ${C_BOLD}purge Tor/Nginx/PHP packages${C_RESET} installed by the panel."
read -r -p "Also purge Tor/Nginx/PHP packages and autoremove? [y/N] " pans
case "${pans,,}" in y|yes) PURGE=1 ;; *) PURGE=0 ;; esac
else
PURGE=0
fi
info "Stopping services/timers if present"
systemctl disable --now torpanel-collector.timer 2>/dev/null || true
systemctl disable --now torpanel-collector.service 2>/dev/null || true
ok "Services/timers handled"
info "Removing systemd units"
rm -f "$SVC" "$TIMER"
systemctl daemon-reload
ok "Systemd units removed"
info "Removing collector binary"
rm -f "$COLLECTOR_BIN"
ok "Collector removed"
info "Removing Nginx site"
rm -f "$NGX_SITE_ENABL" "$NGX_SITE_AVAIL"
if nginx -t >/dev/null 2>&1; then
systemctl reload nginx || true
else
warn "Nginx config test failed (maybe not installed); skipping reload"
fi
ok "Nginx site removed"
info "Removing TorPanel app/config/state"
rm -rf "$PANEL_ROOT" "$STATE_DIR" "$LOG_DIR" "$ETC_APP"
ok "Files removed"
info "Removing torrc override and reloading Tor"
rm -f "$TOR_PANEL_CONF"
systemctl reload tor 2>/dev/null || true
ok "Tor reloaded"
info "Removing sudoers entry"
rm -f "$SUDOERS_FILE"
ok "Sudoers cleaned"
if [[ $PURGE -eq 1 ]]; then
info "Purging Tor/Nginx/PHP packages"
apt-get purge -y "${PKGS[@]}" || true
apt-get autoremove -y || true
ok "Packages purged"
fi
echo
echo -e "${C_BOLD}All clean!${C_RESET}"
if [[ $PURGE -eq 1 ]]; then
echo -e " TorPanel removed and packages were purged."
else
echo -e " TorPanel files and overrides have been removed (packages remain)."
fi
echo -e " Reinstall anytime with: ${C_GRN}sudo bash install.sh${C_RESET}"

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>