Release
This commit is contained in:
135
README.md
135
README.md
@@ -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.
|
||||||
|
|
||||||
|
* **You’re 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 don’t 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 Pi’s IP in your browser, follow the **first-time setup**, and you’re done.
|
||||||
|
|
||||||
|
|
||||||
|
You’ll 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 ISP’s 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 you’re comfortable.
|
||||||
|
|
||||||
|
|
||||||
|
Uninstall
|
||||||
|
---------
|
||||||
|
|
||||||
|
Prefer to remove everything later? There’s 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
60
bin/torpanel-collect.py
Normal 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
192
install.sh
Normal 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."
|
||||||
BIN
screenshots/dashboard-dark.jpg
Normal file
BIN
screenshots/dashboard-dark.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 78 KiB |
BIN
screenshots/dasrhboard-light.jpg
Normal file
BIN
screenshots/dasrhboard-light.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 71 KiB |
BIN
screenshots/login.jpg
Normal file
BIN
screenshots/login.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 30 KiB |
BIN
screenshots/setup-wizard.jpg
Normal file
BIN
screenshots/setup-wizard.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 62 KiB |
115
uninstall.sh
Normal file
115
uninstall.sh
Normal 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
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