router-ui: web management page for ports, devices, traffic, speedtest
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
3030d20034
commit
2f1e14495b
10 changed files with 1396 additions and 41 deletions
4
.gitignore
vendored
Normal file
4
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
__pycache__/
|
||||||
|
result
|
||||||
|
result-*
|
||||||
|
*.qcow2
|
||||||
|
|
@ -104,6 +104,7 @@ in
|
||||||
./services/shelfarr.nix
|
./services/shelfarr.nix
|
||||||
./services/adguard.nix
|
./services/adguard.nix
|
||||||
./services/router.nix
|
./services/router.nix
|
||||||
|
./services/router-ui.nix
|
||||||
./services/crowdsec.nix
|
./services/crowdsec.nix
|
||||||
./services/service-health.nix
|
./services/service-health.nix
|
||||||
./services/sabnzbd.nix
|
./services/sabnzbd.nix
|
||||||
|
|
|
||||||
17
devices.toml
Normal file
17
devices.toml
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
# devices.toml — LAN device registry for the router (services/router.nix)
|
||||||
|
#
|
||||||
|
# Written by the router UI (router.nordhammer.it), safe to edit by hand.
|
||||||
|
# Fields:
|
||||||
|
# mac — device MAC, lowercase hex with colons (required)
|
||||||
|
# name — DHCP hostname / label (required)
|
||||||
|
# ip — static reservation. Omit for a normal pool lease.
|
||||||
|
# Must be OUTSIDE the DHCP pool 10.0.0.100-250.
|
||||||
|
# blocked — true drops all traffic from this MAC on the LAN interface.
|
||||||
|
# Omitted rather than written as false, which is what the UI does.
|
||||||
|
# note — free text, shown in the UI only
|
||||||
|
|
||||||
|
[[device]]
|
||||||
|
mac = "f0:a7:31:6c:50:4b"
|
||||||
|
name = "camera-bedroom"
|
||||||
|
ip = "10.0.0.39"
|
||||||
|
note = "go2rtc / RTSP source"
|
||||||
58
ports.toml
58
ports.toml
|
|
@ -7,9 +7,31 @@
|
||||||
# ports — port range as a string, e.g. "26901-26902"
|
# ports — port range as a string, e.g. "26901-26902"
|
||||||
# protocol — "tcp", "udp", or "both"
|
# protocol — "tcp", "udp", or "both"
|
||||||
# dest — LAN IP to forward to (optional; defaults to 10.0.0.1)
|
# dest — LAN IP to forward to (optional; defaults to 10.0.0.1)
|
||||||
|
#
|
||||||
|
# Editable from the router UI (services/router-ui.nix). Keep parked/commented
|
||||||
|
# entries ABOVE the first [[forward]]: in TOML a comment belongs to whatever
|
||||||
|
# table precedes it, so anything written below the last [[forward]] is deleted
|
||||||
|
# along with that entry the moment the UI removes it.
|
||||||
|
|
||||||
dest_default = "10.0.0.1"
|
dest_default = "10.0.0.1"
|
||||||
|
|
||||||
|
# --- Parked: re-add by moving a block down and uncommenting it ---------------
|
||||||
|
#
|
||||||
|
# 7DTD — servers disabled in services/game-servers.nix.
|
||||||
|
# name = "7DTD game", port = 26900, protocol = "both"
|
||||||
|
# name = "7DTD voice/dynamic", ports = "26901-26902", protocol = "udp"
|
||||||
|
# name = "7DTD-coop game", port = 26910, protocol = "both"
|
||||||
|
# name = "7DTD-coop voice", ports = "26911-26912", protocol = "udp"
|
||||||
|
#
|
||||||
|
# DR (Dungeon Runners) — services/dr-server.nix is disabled.
|
||||||
|
# 2110 tcp, 2603 both, 2604-2605 udp, 2606 tcp
|
||||||
|
#
|
||||||
|
# WoW Classic — stopped 2026-07-28 (containers stopped, not deleted).
|
||||||
|
# name = "WoW Classic realmd", port = 3724, protocol = "tcp"
|
||||||
|
# name = "WoW Classic worldserver", port = 8095, protocol = "tcp"
|
||||||
|
|
||||||
|
# --- Active ------------------------------------------------------------------
|
||||||
|
|
||||||
[[forward]]
|
[[forward]]
|
||||||
name = "HTTP"
|
name = "HTTP"
|
||||||
port = 80
|
port = 80
|
||||||
|
|
@ -32,39 +54,3 @@ protocol = "tcp"
|
||||||
name = "Pelican game servers"
|
name = "Pelican game servers"
|
||||||
ports = "25565-25600"
|
ports = "25565-25600"
|
||||||
protocol = "both"
|
protocol = "both"
|
||||||
|
|
||||||
# 7DTD forwards commented out — servers disabled in services/game-servers.nix.
|
|
||||||
# [[forward]]
|
|
||||||
# name = "7DTD game"
|
|
||||||
# port = 26900
|
|
||||||
# protocol = "both"
|
|
||||||
#
|
|
||||||
# [[forward]]
|
|
||||||
# name = "7DTD voice/dynamic"
|
|
||||||
# ports = "26901-26902"
|
|
||||||
# protocol = "udp"
|
|
||||||
#
|
|
||||||
# [[forward]]
|
|
||||||
# name = "7DTD-coop game"
|
|
||||||
# port = 26910
|
|
||||||
# protocol = "both"
|
|
||||||
#
|
|
||||||
# [[forward]]
|
|
||||||
# name = "7DTD-coop voice/dynamic"
|
|
||||||
# ports = "26911-26912"
|
|
||||||
# protocol = "udp"
|
|
||||||
|
|
||||||
# DR (Dungeon Runners) forwards removed — services/dr-server.nix is disabled.
|
|
||||||
# Re-add 2110 tcp, 2603 both, 2604-2605 udp, 2606 tcp if it comes back.
|
|
||||||
|
|
||||||
# WoW Classic server stopped 2026-07-28 (docker containers stopped, not deleted).
|
|
||||||
# Re-enable both forwards to reopen WAN access.
|
|
||||||
# [[forward]]
|
|
||||||
# name = "WoW Classic realmd"
|
|
||||||
# port = 3724
|
|
||||||
# protocol = "tcp"
|
|
||||||
#
|
|
||||||
# [[forward]]
|
|
||||||
# name = "WoW Classic worldserver"
|
|
||||||
# port = 8095
|
|
||||||
# protocol = "tcp"
|
|
||||||
|
|
|
||||||
143
scripts/router-ui-check.py
Normal file
143
scripts/router-ui-check.py
Normal file
|
|
@ -0,0 +1,143 @@
|
||||||
|
"""Self-check for router-ui.py: TOML round-trips and the refusal rules.
|
||||||
|
|
||||||
|
Run it against the same python the service uses:
|
||||||
|
|
||||||
|
nix shell .#nixosConfigurations.FredOS-Mediaserver.pkgs.python3Packages.tomlkit \
|
||||||
|
-c python3 scripts/router-ui-check.py
|
||||||
|
|
||||||
|
The TOML round-trip cases are the ones that matter: a save must leave an
|
||||||
|
unedited file byte-identical, and must never eat the parked/commented forwards
|
||||||
|
in ports.toml.
|
||||||
|
"""
|
||||||
|
import difflib
|
||||||
|
import importlib.util
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
|
||||||
|
import tomlkit
|
||||||
|
|
||||||
|
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||||
|
REPO = os.path.join(HERE, "..")
|
||||||
|
|
||||||
|
spec = importlib.util.spec_from_file_location("routerui", os.path.join(HERE, "router-ui.py"))
|
||||||
|
r = importlib.util.module_from_spec(spec)
|
||||||
|
spec.loader.exec_module(r)
|
||||||
|
|
||||||
|
|
||||||
|
def refuses(fn, *a):
|
||||||
|
try:
|
||||||
|
fn(*a)
|
||||||
|
except r.Refused as e:
|
||||||
|
return str(e)
|
||||||
|
raise AssertionError(f"{fn.__name__} accepted what it should have refused")
|
||||||
|
|
||||||
|
|
||||||
|
def same(before, after, what):
|
||||||
|
assert before == after, what + ":\n" + "".join(
|
||||||
|
difflib.unified_diff(before.splitlines(1), after.splitlines(1), "before", "after"))
|
||||||
|
|
||||||
|
|
||||||
|
# --- ports.toml round-trip ---------------------------------------------------
|
||||||
|
src = open(os.path.join(REPO, "ports.toml")).read()
|
||||||
|
doc = tomlkit.parse(src)
|
||||||
|
items = r.forwards_from(doc)
|
||||||
|
assert len(items) == 4, items
|
||||||
|
assert items[3]["port"] == "25565-25600", items[3]
|
||||||
|
|
||||||
|
# Saving without editing anything must not touch a single byte.
|
||||||
|
r.check_forwards(items)
|
||||||
|
r.edit_aot(doc, "forward", items)
|
||||||
|
same(src, tomlkit.dumps(doc), "no-op forward save rewrote the file")
|
||||||
|
|
||||||
|
# Add one, drop the Pelican range; every comment block must survive.
|
||||||
|
doc2 = tomlkit.parse(src)
|
||||||
|
items2 = r.forwards_from(doc2)
|
||||||
|
items2.pop(3)
|
||||||
|
items2.append({"_i": None, "name": "Test", "port": "9999", "protocol": "udp",
|
||||||
|
"dest": "10.0.0.5"})
|
||||||
|
r.check_forwards(items2)
|
||||||
|
r.edit_aot(doc2, "forward", items2)
|
||||||
|
out = tomlkit.dumps(doc2)
|
||||||
|
assert "7DTD voice/dynamic" in out, "parked blocks were lost"
|
||||||
|
assert "WoW Classic realmd" in out, "parked blocks were lost"
|
||||||
|
assert "Pelican game-server allocation range" in out, "inline comment was lost"
|
||||||
|
assert "Pelican game servers" not in out.replace("# Pelican", ""), "deletion did not happen"
|
||||||
|
assert 'name = "Test"' in out and "port = 9999" in out, out[-300:]
|
||||||
|
back = tomlkit.parse(out)["forward"]
|
||||||
|
assert len(back) == 4 and back[3]["protocol"] == "udp" and back[3]["dest"] == "10.0.0.5"
|
||||||
|
assert [f["name"] for f in back[:3]] == ["HTTP", "HTTPS", "SSH"]
|
||||||
|
|
||||||
|
# --- forward validation ------------------------------------------------------
|
||||||
|
mk = lambda: r.forwards_from(tomlkit.parse(src)) # noqa: E731 - fresh fixture per case
|
||||||
|
print(refuses(r.check_forwards, [f for f in mk() if f["name"] != "SSH"]))
|
||||||
|
print(refuses(r.check_forwards, [f for f in mk() if f["name"] != "HTTPS"]))
|
||||||
|
print(refuses(r.check_forwards, mk() + [{"_i": None, "name": "x", "port": "70000",
|
||||||
|
"protocol": "tcp", "dest": ""}]))
|
||||||
|
print(refuses(r.check_forwards, mk() + [{"_i": None, "name": "x", "port": "9-8",
|
||||||
|
"protocol": "tcp", "dest": ""}]))
|
||||||
|
print(refuses(r.check_forwards, mk() + [{"_i": None, "name": "x", "port": "80",
|
||||||
|
"protocol": "sctp", "dest": ""}]))
|
||||||
|
print(refuses(r.check_forwards, mk() + [{"_i": None, "name": "", "port": "80",
|
||||||
|
"protocol": "tcp", "dest": ""}]))
|
||||||
|
# A range that covers 22 and 443 satisfies the keep-me-reachable rule.
|
||||||
|
r.check_forwards([{"_i": None, "name": "wide", "port": "20-500", "protocol": "both",
|
||||||
|
"dest": ""}])
|
||||||
|
|
||||||
|
# --- device validation -------------------------------------------------------
|
||||||
|
def dev(**kw):
|
||||||
|
base = {"_i": None, "mac": "aa:bb:cc:dd:ee:01", "name": "thing", "ip": "",
|
||||||
|
"blocked": False, "note": ""}
|
||||||
|
return {**base, **kw}
|
||||||
|
|
||||||
|
|
||||||
|
me, my_ip = "aa:bb:cc:dd:ee:99", "10.0.0.161"
|
||||||
|
|
||||||
|
print(refuses(r.check_devices, [dev(ip="10.0.0.161")], me, my_ip)) # inside the pool
|
||||||
|
print(refuses(r.check_devices, [dev(ip="10.0.0.1")], me, my_ip)) # the router itself
|
||||||
|
print(refuses(r.check_devices, [dev(ip="192.168.1.5")], me, my_ip)) # off-LAN
|
||||||
|
print(refuses(r.check_devices, [dev(mac="nope")], me, my_ip))
|
||||||
|
print(refuses(r.check_devices, [dev(name="bad name")], me, my_ip))
|
||||||
|
print(refuses(r.check_devices, [dev(), dev()], me, my_ip)) # duplicate MAC
|
||||||
|
print(refuses(r.check_devices, [dev(ip="10.0.0.20"),
|
||||||
|
dev(mac="aa:bb:cc:dd:ee:02", ip="10.0.0.20")], me, my_ip))
|
||||||
|
print(refuses(r.check_devices, [dev(mac=me, blocked=True)], me, my_ip))
|
||||||
|
print(refuses(r.check_devices, [dev(ip="10.0.0.30", blocked=True)], me, "10.0.0.30"))
|
||||||
|
|
||||||
|
ok = [dev(ip="10.0.0.39", name="camera-bedroom")]
|
||||||
|
r.check_devices(ok, me, my_ip)
|
||||||
|
assert ok[0]["ip"] == "10.0.0.39" and "blocked" not in ok[0] and "note" not in ok[0]
|
||||||
|
cleared = [dev(ip="")]
|
||||||
|
r.check_devices(cleared, me, my_ip)
|
||||||
|
assert "ip" not in cleared[0], "an empty reservation should drop the key entirely"
|
||||||
|
|
||||||
|
# --- devices.toml round-trip -------------------------------------------------
|
||||||
|
dsrc = open(os.path.join(REPO, "devices.toml")).read()
|
||||||
|
ddoc = tomlkit.parse(dsrc)
|
||||||
|
ditems = r.devices_from(ddoc)
|
||||||
|
assert len(ditems) == 1 and ditems[0]["ip"] == "10.0.0.39"
|
||||||
|
r.check_devices(ditems, me, my_ip)
|
||||||
|
r.edit_aot(ddoc, "device", ditems)
|
||||||
|
same(dsrc, tomlkit.dumps(ddoc), "no-op device save rewrote the file")
|
||||||
|
|
||||||
|
ditems = r.devices_from(ddoc)
|
||||||
|
ditems[0]["ip"] = ""
|
||||||
|
ditems[0]["blocked"] = True
|
||||||
|
r.check_devices(ditems, me, my_ip)
|
||||||
|
r.edit_aot(ddoc, "device", ditems)
|
||||||
|
entry = tomlkit.dumps(ddoc).split("[[device]]")[1]
|
||||||
|
assert "ip = " not in entry, entry
|
||||||
|
assert "blocked = true" in entry, entry
|
||||||
|
|
||||||
|
# --- speedtest parsing (bytes/s -> Mbps, ns -> ms) ---------------------------
|
||||||
|
sample = ('{"timestamp":"2026-08-15 12:00:55","servers":[{"name":"Preston",'
|
||||||
|
'"latency":18796260,"dl_speed":63607890.17,"ul_speed":8839605.08}]}\n')
|
||||||
|
tmp = os.path.join(tempfile.mkdtemp(), "st.jsonl")
|
||||||
|
open(tmp, "w").write(sample + "not json at all\n" + sample)
|
||||||
|
r.SPEEDTEST_LOG = tmp
|
||||||
|
got = r.speedtests()
|
||||||
|
assert len(got) == 2, got
|
||||||
|
assert got[0] == {"ts": "2026-08-15 12:00:55", "down": 508.9, "up": 70.7,
|
||||||
|
"ping": 18.8, "server": "Preston"}, got[0]
|
||||||
|
|
||||||
|
print("\nall checks passed", file=sys.stderr)
|
||||||
962
scripts/router-ui.py
Normal file
962
scripts/router-ui.py
Normal file
|
|
@ -0,0 +1,962 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""router-ui — management page for the FredOS router (services/router-ui.nix).
|
||||||
|
|
||||||
|
Deliberately narrow: this process never generates Nix. It reads and writes two
|
||||||
|
TOML files (ports.toml, devices.toml) through the Forgejo API, then asks a
|
||||||
|
systemd unit to deploy that exact commit. Everything else is read-only.
|
||||||
|
|
||||||
|
Two views of the config exist and they are not the same thing:
|
||||||
|
* deployed — /etc/router/*.toml, baked into the running system generation
|
||||||
|
* git — what Forgejo has on main, i.e. what the next apply will deploy
|
||||||
|
The UI edits git and shows drift against deployed.
|
||||||
|
|
||||||
|
No auth here: nginx + Authelia is the gate, and we bind loopback only.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import socket
|
||||||
|
import subprocess
|
||||||
|
import time
|
||||||
|
import urllib.error
|
||||||
|
import urllib.request
|
||||||
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||||
|
|
||||||
|
import tomlkit
|
||||||
|
|
||||||
|
# --- config (all injected by the systemd unit) -------------------------------
|
||||||
|
|
||||||
|
PORT = int(os.environ.get("ROUTER_UI_PORT", "8086"))
|
||||||
|
STATE = os.environ.get("ROUTER_UI_STATE", "/var/lib/router-ui")
|
||||||
|
ETC = os.environ.get("ROUTER_UI_ETC", "/etc/router")
|
||||||
|
FORGEJO = os.environ.get("FORGEJO_API", "https://forg.gregersen.it/api/v1")
|
||||||
|
REPO = os.environ.get("FORGEJO_REPO", "rope/nixos")
|
||||||
|
BRANCH = os.environ.get("FORGEJO_BRANCH", "main")
|
||||||
|
TOKEN_FILE = os.environ.get("FORGEJO_TOKEN_FILE", "/var/secrets/forgejo-router-token")
|
||||||
|
WAN_IF = os.environ.get("WAN_IF", "eno1")
|
||||||
|
LAN_IF = os.environ.get("LAN_IF", "eth0")
|
||||||
|
LAN_PREFIX = os.environ.get("LAN_PREFIX", "10.0.0.")
|
||||||
|
ROUTER_IP = os.environ.get("ROUTER_IP", "10.0.0.1")
|
||||||
|
POOL = (int(os.environ.get("POOL_START", "100")), int(os.environ.get("POOL_END", "250")))
|
||||||
|
LEASES = os.environ.get("DNSMASQ_LEASES", "/var/lib/dnsmasq/dnsmasq.leases")
|
||||||
|
OUI_FILE = os.environ.get("OUI_FILE", "")
|
||||||
|
SPEEDTEST_LOG = os.path.join(STATE, "speedtest.jsonl")
|
||||||
|
LAST_REV = os.path.join(STATE, "last-rev")
|
||||||
|
|
||||||
|
# Absolute, injected by the unit. sudo matches on argv, so the systemctl path
|
||||||
|
# here must be byte-identical to the one in the sudoers rule.
|
||||||
|
SUDO = os.environ.get("SUDO_BIN", "/run/wrappers/bin/sudo")
|
||||||
|
SYSTEMCTL = os.environ.get("SYSTEMCTL_BIN", "systemctl")
|
||||||
|
JOURNALCTL = os.environ.get("JOURNALCTL_BIN", "journalctl")
|
||||||
|
IP = os.environ.get("IP_BIN", "ip")
|
||||||
|
VNSTAT = os.environ.get("VNSTAT_BIN", "vnstat")
|
||||||
|
|
||||||
|
# Forwards that must always survive an edit — losing either locks us out.
|
||||||
|
REQUIRED_FORWARDS = [("tcp", "22"), ("tcp", "443")]
|
||||||
|
|
||||||
|
MAC_RE = re.compile(r"^[0-9a-f]{2}(:[0-9a-f]{2}){5}$")
|
||||||
|
PORT_RE = re.compile(r"^\d{1,5}(-\d{1,5})?$")
|
||||||
|
REV_RE = re.compile(r"^[0-9a-f]{7,40}$")
|
||||||
|
|
||||||
|
|
||||||
|
class Refused(Exception):
|
||||||
|
"""A validation failure we want to show the user verbatim."""
|
||||||
|
|
||||||
|
|
||||||
|
# --- small helpers -----------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def run(*args, timeout=15):
|
||||||
|
try:
|
||||||
|
return subprocess.run(
|
||||||
|
args, capture_output=True, text=True, timeout=timeout
|
||||||
|
).stdout
|
||||||
|
except (subprocess.SubprocessError, OSError):
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def token():
|
||||||
|
try:
|
||||||
|
with open(TOKEN_FILE) as fh:
|
||||||
|
return fh.read().strip()
|
||||||
|
except OSError as exc:
|
||||||
|
raise Refused(
|
||||||
|
f"no Forgejo token at {TOKEN_FILE} — create one with write:repository "
|
||||||
|
f"on {REPO} and write it there (see services/router-ui.nix)"
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
|
||||||
|
def forgejo(method, path, payload=None):
|
||||||
|
req = urllib.request.Request(
|
||||||
|
f"{FORGEJO}/{path}",
|
||||||
|
method=method,
|
||||||
|
data=json.dumps(payload).encode() if payload is not None else None,
|
||||||
|
headers={
|
||||||
|
"Authorization": f"token {token()}",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"Accept": "application/json",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req, timeout=30) as resp:
|
||||||
|
return json.loads(resp.read() or b"{}")
|
||||||
|
except urllib.error.HTTPError as exc:
|
||||||
|
detail = exc.read().decode(errors="replace")[:400]
|
||||||
|
raise Refused(f"Forgejo {exc.code}: {detail}") from exc
|
||||||
|
except (urllib.error.URLError, TimeoutError, socket.timeout) as exc:
|
||||||
|
raise Refused(f"Forgejo unreachable: {exc}") from exc
|
||||||
|
|
||||||
|
|
||||||
|
def git_file(name):
|
||||||
|
"""Return (tomlkit document, blob sha) for a file on the tracked branch."""
|
||||||
|
meta = forgejo("GET", f"repos/{REPO}/contents/{name}?ref={BRANCH}")
|
||||||
|
text = base64.b64decode(meta["content"]).decode()
|
||||||
|
return tomlkit.parse(text), meta["sha"]
|
||||||
|
|
||||||
|
|
||||||
|
def git_commit(name, doc, sha, message):
|
||||||
|
body = {
|
||||||
|
"content": base64.b64encode(tomlkit.dumps(doc).encode()).decode(),
|
||||||
|
"sha": sha,
|
||||||
|
"branch": BRANCH,
|
||||||
|
"message": message,
|
||||||
|
}
|
||||||
|
return forgejo("PUT", f"repos/{REPO}/contents/{name}", body)["commit"]["sha"]
|
||||||
|
|
||||||
|
|
||||||
|
def deployed(name):
|
||||||
|
"""The copy baked into the running generation, or None if unreadable."""
|
||||||
|
try:
|
||||||
|
with open(os.path.join(ETC, name)) as fh:
|
||||||
|
return tomlkit.parse(fh.read())
|
||||||
|
except (OSError, ValueError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def edit_aot(doc, key, items):
|
||||||
|
"""Apply the UI's list back onto an array-of-tables, in place.
|
||||||
|
|
||||||
|
In place, not wholesale replacement: ports.toml carries a lot of
|
||||||
|
commented-out forwards, and tomlkit only keeps those if the surrounding
|
||||||
|
tables survive. Each item carries `_i`, its index in the original array,
|
||||||
|
or None when it's new.
|
||||||
|
|
||||||
|
Assignments are skipped when the value is already what we'd write. tomlkit
|
||||||
|
moves a key to the end of its table when you re-add it, and the last table
|
||||||
|
in a file owns every trailing comment — so a blind rewrite drags keys down
|
||||||
|
past unrelated comment blocks. Writing only genuine changes keeps an
|
||||||
|
untouched entry byte-identical.
|
||||||
|
"""
|
||||||
|
aot = doc[key]
|
||||||
|
original = len(aot) # fixed up front: appends must not shift the delete set
|
||||||
|
keep = set()
|
||||||
|
added = []
|
||||||
|
for item in items:
|
||||||
|
fields = {k: v for k, v in item.items() if not k.startswith("_")}
|
||||||
|
idx = item.get("_i")
|
||||||
|
if idx is None:
|
||||||
|
added.append(fields)
|
||||||
|
continue
|
||||||
|
if not isinstance(idx, int) or not 0 <= idx < original:
|
||||||
|
raise Refused(f"stale index {idx} — reload the page and retry")
|
||||||
|
keep.add(idx)
|
||||||
|
table = aot[idx]
|
||||||
|
for stale in [k for k in table.keys() if k not in fields]:
|
||||||
|
del table[stale]
|
||||||
|
for k, v in fields.items():
|
||||||
|
if k not in table or table[k] != v:
|
||||||
|
table[k] = v
|
||||||
|
for idx in sorted(set(range(original)) - keep, reverse=True):
|
||||||
|
aot.pop(idx)
|
||||||
|
for fields in added:
|
||||||
|
table = tomlkit.table()
|
||||||
|
for k, v in fields.items():
|
||||||
|
table[k] = v
|
||||||
|
aot.append(table)
|
||||||
|
|
||||||
|
|
||||||
|
# --- LAN state ---------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
_oui_cache = {}
|
||||||
|
|
||||||
|
|
||||||
|
def vendor(mac):
|
||||||
|
if not OUI_FILE:
|
||||||
|
return ""
|
||||||
|
if not _oui_cache:
|
||||||
|
try:
|
||||||
|
with open(OUI_FILE, errors="replace") as fh:
|
||||||
|
for line in fh:
|
||||||
|
if line.startswith("#") or " " not in line:
|
||||||
|
continue
|
||||||
|
prefix, _, name = line.strip().partition(" ")
|
||||||
|
if len(prefix) == 6:
|
||||||
|
_oui_cache[prefix.upper()] = name
|
||||||
|
except OSError:
|
||||||
|
_oui_cache["_"] = ""
|
||||||
|
return _oui_cache.get(mac.replace(":", "")[:6].upper(), "")
|
||||||
|
|
||||||
|
|
||||||
|
def leases():
|
||||||
|
out = {}
|
||||||
|
try:
|
||||||
|
with open(LEASES) as fh:
|
||||||
|
for line in fh:
|
||||||
|
parts = line.split()
|
||||||
|
if len(parts) >= 4:
|
||||||
|
expiry, mac, ip, name = parts[0], parts[1].lower(), parts[2], parts[3]
|
||||||
|
out[mac] = {"ip": ip, "lease_name": "" if name == "*" else name,
|
||||||
|
"expires": int(expiry)}
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def neighbours():
|
||||||
|
"""MACs the kernel has recently exchanged packets with, i.e. online-ish."""
|
||||||
|
online = {}
|
||||||
|
for line in run(IP, "neigh", "show", "dev", LAN_IF).splitlines():
|
||||||
|
parts = line.split()
|
||||||
|
if len(parts) >= 5 and parts[1] == "lladdr":
|
||||||
|
state = parts[-1]
|
||||||
|
online[parts[2].lower()] = state not in ("FAILED", "INCOMPLETE")
|
||||||
|
return online
|
||||||
|
|
||||||
|
|
||||||
|
def caller_ip(handler):
|
||||||
|
fwd = handler.headers.get("X-Forwarded-For", "")
|
||||||
|
return fwd.split(",")[0].strip() or handler.client_address[0]
|
||||||
|
|
||||||
|
|
||||||
|
def mac_of(ip):
|
||||||
|
for mac, lease in leases().items():
|
||||||
|
if lease["ip"] == ip:
|
||||||
|
return mac
|
||||||
|
for line in run(IP, "neigh", "show", "dev", LAN_IF).splitlines():
|
||||||
|
parts = line.split()
|
||||||
|
if len(parts) >= 5 and parts[0] == ip and parts[1] == "lladdr":
|
||||||
|
return parts[2].lower()
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
# --- validation --------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def check_devices(items, self_mac, self_ip):
|
||||||
|
seen_mac, seen_ip = set(), set()
|
||||||
|
for d in items:
|
||||||
|
mac = str(d.get("mac", "")).strip().lower()
|
||||||
|
name = str(d.get("name", "")).strip()
|
||||||
|
if not MAC_RE.match(mac):
|
||||||
|
raise Refused(f"'{mac}' is not a MAC address (aa:bb:cc:dd:ee:ff)")
|
||||||
|
if not re.match(r"^[A-Za-z0-9_-]{1,63}$", name):
|
||||||
|
raise Refused(f"'{name}' is not a usable DHCP name (letters, digits, - and _)")
|
||||||
|
if mac in seen_mac:
|
||||||
|
raise Refused(f"{mac} listed twice")
|
||||||
|
seen_mac.add(mac)
|
||||||
|
d["mac"], d["name"] = mac, name
|
||||||
|
|
||||||
|
if d.get("blocked") and (mac == self_mac or d.get("ip") == self_ip):
|
||||||
|
raise Refused("that's the device you're connected from — refusing to block it")
|
||||||
|
|
||||||
|
# Drop defaults rather than writing them out: router.nix reads both
|
||||||
|
# with `or` fallbacks, and absent keys keep the file (and its diffs)
|
||||||
|
# small.
|
||||||
|
if d.get("blocked"):
|
||||||
|
d["blocked"] = True
|
||||||
|
else:
|
||||||
|
d.pop("blocked", None)
|
||||||
|
if not str(d.get("note", "")).strip():
|
||||||
|
d.pop("note", None)
|
||||||
|
else:
|
||||||
|
d["note"] = str(d["note"]).strip()
|
||||||
|
|
||||||
|
ip = str(d.get("ip", "")).strip()
|
||||||
|
if not ip:
|
||||||
|
d.pop("ip", None)
|
||||||
|
continue
|
||||||
|
if not ip.startswith(LAN_PREFIX):
|
||||||
|
raise Refused(f"{ip} is outside the LAN ({LAN_PREFIX}0/24)")
|
||||||
|
host = ip[len(LAN_PREFIX):]
|
||||||
|
if not host.isdigit() or not 1 <= int(host) <= 254:
|
||||||
|
raise Refused(f"{ip} is not a valid LAN address")
|
||||||
|
if ip == ROUTER_IP:
|
||||||
|
raise Refused(f"{ip} is the router itself")
|
||||||
|
if POOL[0] <= int(host) <= POOL[1]:
|
||||||
|
raise Refused(
|
||||||
|
f"{ip} sits inside the DHCP pool {LAN_PREFIX}{POOL[0]}-{POOL[1]} — "
|
||||||
|
f"reservations must live outside it"
|
||||||
|
)
|
||||||
|
if ip in seen_ip:
|
||||||
|
raise Refused(f"{ip} reserved twice")
|
||||||
|
seen_ip.add(ip)
|
||||||
|
d["ip"] = ip
|
||||||
|
|
||||||
|
|
||||||
|
def check_forwards(items):
|
||||||
|
covered = set()
|
||||||
|
for f in items:
|
||||||
|
name = str(f.get("name", "")).strip()
|
||||||
|
proto = str(f.get("protocol", "")).strip()
|
||||||
|
if not name:
|
||||||
|
raise Refused("every forward needs a name")
|
||||||
|
if proto not in ("tcp", "udp", "both"):
|
||||||
|
raise Refused(f"'{proto}' is not tcp, udp or both")
|
||||||
|
# Accept either key so re-validating an already-normalised entry works.
|
||||||
|
spec = str(f.get("port") or f.get("ports") or "").strip()
|
||||||
|
if not PORT_RE.match(spec):
|
||||||
|
raise Refused(f"'{spec}' is not a port or range (443, or 26901-26902)")
|
||||||
|
bounds = [int(p) for p in spec.split("-")]
|
||||||
|
if any(not 1 <= p <= 65535 for p in bounds):
|
||||||
|
raise Refused(f"{spec} is out of range")
|
||||||
|
if len(bounds) == 2 and bounds[0] >= bounds[1]:
|
||||||
|
raise Refused(f"{spec} is backwards")
|
||||||
|
# Normalise onto the two keys router.nix understands.
|
||||||
|
f.pop("port", None)
|
||||||
|
f.pop("ports", None)
|
||||||
|
f["name"] = name
|
||||||
|
f["protocol"] = proto
|
||||||
|
if len(bounds) == 2:
|
||||||
|
f["ports"] = spec
|
||||||
|
else:
|
||||||
|
f["port"] = bounds[0]
|
||||||
|
dest = str(f.get("dest", "")).strip()
|
||||||
|
if dest:
|
||||||
|
f["dest"] = dest
|
||||||
|
else:
|
||||||
|
f.pop("dest", None)
|
||||||
|
for p in range(bounds[0], bounds[-1] + 1):
|
||||||
|
for pr in (("tcp", "udp") if proto == "both" else (proto,)):
|
||||||
|
covered.add((pr, str(p)))
|
||||||
|
for need in REQUIRED_FORWARDS:
|
||||||
|
if need not in covered:
|
||||||
|
raise Refused(
|
||||||
|
f"refusing to drop the {need[0]}/{need[1]} forward — "
|
||||||
|
f"that's how you reach this box from outside"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# --- API payloads ------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def forwards_from(doc):
|
||||||
|
out = []
|
||||||
|
for i, f in enumerate(doc.get("forward", [])):
|
||||||
|
out.append({
|
||||||
|
"_i": i,
|
||||||
|
"name": str(f.get("name", "")),
|
||||||
|
"port": str(f["ports"]) if "ports" in f else str(f.get("port", "")),
|
||||||
|
"protocol": str(f.get("protocol", "tcp")),
|
||||||
|
"dest": str(f.get("dest", "")),
|
||||||
|
})
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def devices_from(doc):
|
||||||
|
out = []
|
||||||
|
for i, d in enumerate(doc.get("device", [])):
|
||||||
|
out.append({
|
||||||
|
"_i": i,
|
||||||
|
"mac": str(d.get("mac", "")).lower(),
|
||||||
|
"name": str(d.get("name", "")),
|
||||||
|
"ip": str(d.get("ip", "")),
|
||||||
|
"blocked": bool(d.get("blocked", False)),
|
||||||
|
"note": str(d.get("note", "")),
|
||||||
|
})
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def device_view():
|
||||||
|
"""Registry from git, joined with what's actually on the wire."""
|
||||||
|
doc, sha = git_file("devices.toml")
|
||||||
|
known = devices_from(doc)
|
||||||
|
lease = leases()
|
||||||
|
online = neighbours()
|
||||||
|
by_mac = {d["mac"]: d for d in known}
|
||||||
|
|
||||||
|
for d in known:
|
||||||
|
seen = lease.get(d["mac"], {})
|
||||||
|
d["lease_ip"] = seen.get("ip", "")
|
||||||
|
d["hostname"] = seen.get("lease_name", "")
|
||||||
|
d["online"] = online.get(d["mac"], False)
|
||||||
|
d["vendor"] = vendor(d["mac"])
|
||||||
|
d["known"] = True
|
||||||
|
|
||||||
|
unknown = []
|
||||||
|
for mac, seen in sorted(lease.items(), key=lambda kv: kv[1]["ip"]):
|
||||||
|
if mac in by_mac:
|
||||||
|
continue
|
||||||
|
unknown.append({
|
||||||
|
"_i": None, "mac": mac, "name": seen["lease_name"] or mac.replace(":", ""),
|
||||||
|
"ip": "", "blocked": False, "note": "",
|
||||||
|
"lease_ip": seen["ip"], "hostname": seen["lease_name"],
|
||||||
|
"online": online.get(mac, False), "vendor": vendor(mac), "known": False,
|
||||||
|
})
|
||||||
|
return {"sha": sha, "devices": known + unknown}
|
||||||
|
|
||||||
|
|
||||||
|
def wan_stats():
|
||||||
|
rx = tx = 0
|
||||||
|
try:
|
||||||
|
with open("/proc/net/dev") as fh:
|
||||||
|
for line in fh:
|
||||||
|
iface, _, rest = line.partition(":")
|
||||||
|
if iface.strip() == WAN_IF:
|
||||||
|
cols = rest.split()
|
||||||
|
rx, tx = int(cols[0]), int(cols[8])
|
||||||
|
except (OSError, ValueError, IndexError):
|
||||||
|
pass
|
||||||
|
return {"rx": rx, "tx": tx, "t": time.time()}
|
||||||
|
|
||||||
|
|
||||||
|
def overview():
|
||||||
|
addr = run(IP, "-4", "-o", "addr", "show", WAN_IF).split()
|
||||||
|
wan_ip = addr[3].split("/")[0] if len(addr) > 3 else "down"
|
||||||
|
try:
|
||||||
|
with open("/proc/uptime") as fh:
|
||||||
|
up = int(float(fh.read().split()[0]))
|
||||||
|
except (OSError, ValueError):
|
||||||
|
up = 0
|
||||||
|
try:
|
||||||
|
with open(LAST_REV) as fh:
|
||||||
|
rev = fh.read().strip()
|
||||||
|
except OSError:
|
||||||
|
rev = ""
|
||||||
|
|
||||||
|
drift = {}
|
||||||
|
for name in ("ports.toml", "devices.toml"):
|
||||||
|
local = deployed(name)
|
||||||
|
try:
|
||||||
|
remote, _ = git_file(name)
|
||||||
|
drift[name] = local is not None and tomlkit.dumps(local) != tomlkit.dumps(remote)
|
||||||
|
except Refused:
|
||||||
|
drift[name] = None
|
||||||
|
return {
|
||||||
|
"wan_ip": wan_ip,
|
||||||
|
"uptime": up,
|
||||||
|
"leases": len(leases()),
|
||||||
|
"last_rev": rev,
|
||||||
|
"drift": drift,
|
||||||
|
"wan_if": WAN_IF,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def pending():
|
||||||
|
"""Commits on the branch that this UI has not deployed."""
|
||||||
|
try:
|
||||||
|
with open(LAST_REV) as fh:
|
||||||
|
last = fh.read().strip()
|
||||||
|
except OSError:
|
||||||
|
last = ""
|
||||||
|
commits = forgejo("GET", f"repos/{REPO}/commits?sha={BRANCH}&limit=20&stat=false")
|
||||||
|
out = []
|
||||||
|
for c in commits:
|
||||||
|
if c["sha"] == last:
|
||||||
|
break
|
||||||
|
out.append({
|
||||||
|
"sha": c["sha"],
|
||||||
|
"message": c["commit"]["message"].splitlines()[0],
|
||||||
|
"date": c["commit"]["committer"]["date"],
|
||||||
|
})
|
||||||
|
head = commits[0]["sha"] if commits else ""
|
||||||
|
return {"head": head, "last_rev": last, "unapplied": out}
|
||||||
|
|
||||||
|
|
||||||
|
def speedtests(limit=30):
|
||||||
|
out = []
|
||||||
|
try:
|
||||||
|
with open(SPEEDTEST_LOG) as fh:
|
||||||
|
for line in fh:
|
||||||
|
try:
|
||||||
|
r = json.loads(line)
|
||||||
|
except ValueError:
|
||||||
|
continue
|
||||||
|
srv = (r.get("servers") or [{}])[0]
|
||||||
|
out.append({
|
||||||
|
"ts": r.get("timestamp", ""),
|
||||||
|
"down": round(srv.get("dl_speed", 0) * 8 / 1e6, 1),
|
||||||
|
"up": round(srv.get("ul_speed", 0) * 8 / 1e6, 1),
|
||||||
|
"ping": round(srv.get("latency", 0) / 1e6, 1),
|
||||||
|
"server": srv.get("name", ""),
|
||||||
|
})
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
return out[-limit:]
|
||||||
|
|
||||||
|
|
||||||
|
def traffic():
|
||||||
|
raw = run(VNSTAT, "--json", "-i", WAN_IF, timeout=20)
|
||||||
|
try:
|
||||||
|
iface = json.loads(raw)["interfaces"][0]["traffic"]
|
||||||
|
except (ValueError, KeyError, IndexError):
|
||||||
|
return {"day": [], "month": [], "hour": []}
|
||||||
|
|
||||||
|
def series(key):
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"label": "{:04d}-{:02d}-{:02d}".format(
|
||||||
|
e["date"]["year"], e["date"].get("month", 1), e["date"].get("day", 1)
|
||||||
|
) + (" {:02d}:00".format(e["time"]["hour"]) if "time" in e else ""),
|
||||||
|
"rx": e["rx"],
|
||||||
|
"tx": e["tx"],
|
||||||
|
}
|
||||||
|
for e in iface.get(key, [])
|
||||||
|
]
|
||||||
|
|
||||||
|
return {"hour": series("hour")[-24:], "day": series("day")[-30:],
|
||||||
|
"month": series("month")[-12:]}
|
||||||
|
|
||||||
|
|
||||||
|
# --- actions -----------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def start_unit(unit):
|
||||||
|
subprocess.run(
|
||||||
|
[SUDO, "-n", SYSTEMCTL, "start", "--no-block", unit],
|
||||||
|
check=True, capture_output=True, timeout=20,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def save(name, key, items, message, sha, validate):
|
||||||
|
doc, current = git_file(name)
|
||||||
|
if sha and sha != current:
|
||||||
|
raise Refused(f"{name} changed in git since you loaded it — reload and redo")
|
||||||
|
validate(items)
|
||||||
|
edit_aot(doc, key, items)
|
||||||
|
rev = git_commit(name, doc, current, message)
|
||||||
|
return {"ok": True, "rev": rev}
|
||||||
|
|
||||||
|
|
||||||
|
def apply(rev, confirmed):
|
||||||
|
if not REV_RE.match(rev or ""):
|
||||||
|
raise Refused("not a commit id")
|
||||||
|
state = pending()
|
||||||
|
extra = [c for c in state["unapplied"] if c["sha"] != rev]
|
||||||
|
if extra and not confirmed:
|
||||||
|
raise Refused(
|
||||||
|
"CONFIRM:main carries commits this page didn't make; applying deploys "
|
||||||
|
"those too:\n" + "\n".join(f" {c['sha'][:8]} {c['message']}" for c in extra)
|
||||||
|
)
|
||||||
|
start_unit(f"router-apply@{rev}.service")
|
||||||
|
return {"ok": True, "rev": rev}
|
||||||
|
|
||||||
|
|
||||||
|
# --- HTTP --------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class Handler(BaseHTTPRequestHandler):
|
||||||
|
protocol_version = "HTTP/1.1"
|
||||||
|
|
||||||
|
def log_message(self, fmt, *args): # journal already timestamps
|
||||||
|
print(fmt % args, flush=True)
|
||||||
|
|
||||||
|
def _send(self, code, body, ctype="application/json", extra=None):
|
||||||
|
blob = body if isinstance(body, bytes) else body.encode()
|
||||||
|
self.send_response(code)
|
||||||
|
self.send_header("Content-Type", ctype)
|
||||||
|
self.send_header("Content-Length", str(len(blob)))
|
||||||
|
for k, v in (extra or {}).items():
|
||||||
|
self.send_header(k, v)
|
||||||
|
self.end_headers()
|
||||||
|
self.wfile.write(blob)
|
||||||
|
|
||||||
|
def _json(self, obj, code=200):
|
||||||
|
self._send(code, json.dumps(obj))
|
||||||
|
|
||||||
|
def _body(self):
|
||||||
|
length = int(self.headers.get("Content-Length", "0"))
|
||||||
|
return json.loads(self.rfile.read(length) or b"{}")
|
||||||
|
|
||||||
|
def do_GET(self):
|
||||||
|
path, _, query = self.path.partition("?")
|
||||||
|
args = dict(p.split("=", 1) for p in query.split("&") if "=" in p)
|
||||||
|
try:
|
||||||
|
if path == "/":
|
||||||
|
return self._send(200, PAGE, "text/html; charset=utf-8")
|
||||||
|
if path == "/api/overview":
|
||||||
|
return self._json(overview())
|
||||||
|
if path == "/api/pending":
|
||||||
|
return self._json(pending())
|
||||||
|
if path == "/api/devices":
|
||||||
|
return self._json(device_view())
|
||||||
|
if path == "/api/ports":
|
||||||
|
doc, sha = git_file("ports.toml")
|
||||||
|
return self._json({"sha": sha, "forwards": forwards_from(doc)})
|
||||||
|
if path == "/api/live":
|
||||||
|
return self._json(wan_stats())
|
||||||
|
if path == "/api/traffic":
|
||||||
|
return self._json(traffic())
|
||||||
|
if path == "/api/speedtest":
|
||||||
|
return self._json(speedtests())
|
||||||
|
if path == "/api/apply/log":
|
||||||
|
return self._stream_log(args.get("rev", ""))
|
||||||
|
except Refused as exc:
|
||||||
|
return self._json({"error": str(exc)}, 400)
|
||||||
|
except Exception as exc: # noqa: BLE001 - surface, don't 500 silently
|
||||||
|
return self._json({"error": f"{type(exc).__name__}: {exc}"}, 500)
|
||||||
|
self._json({"error": "not found"}, 404)
|
||||||
|
|
||||||
|
def do_POST(self):
|
||||||
|
try:
|
||||||
|
body = self._body()
|
||||||
|
if self.path == "/api/devices":
|
||||||
|
self_ip = caller_ip(self)
|
||||||
|
return self._json(save(
|
||||||
|
"devices.toml", "device", body.get("devices", []),
|
||||||
|
"devices.toml: update from router UI", body.get("sha"),
|
||||||
|
lambda items: check_devices(items, mac_of(self_ip), self_ip),
|
||||||
|
))
|
||||||
|
if self.path == "/api/ports":
|
||||||
|
return self._json(save(
|
||||||
|
"ports.toml", "forward", body.get("forwards", []),
|
||||||
|
"ports.toml: update from router UI", body.get("sha"),
|
||||||
|
check_forwards,
|
||||||
|
))
|
||||||
|
if self.path == "/api/apply":
|
||||||
|
return self._json(apply(body.get("rev", ""), body.get("confirm", False)))
|
||||||
|
if self.path == "/api/speedtest/run":
|
||||||
|
start_unit("router-speedtest.service")
|
||||||
|
return self._json({"ok": True})
|
||||||
|
except Refused as exc:
|
||||||
|
return self._json({"error": str(exc)}, 400)
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
return self._json({"error": f"{type(exc).__name__}: {exc}"}, 500)
|
||||||
|
self._json({"error": "not found"}, 404)
|
||||||
|
|
||||||
|
def _stream_log(self, rev):
|
||||||
|
if not REV_RE.match(rev or ""):
|
||||||
|
return self._json({"error": "not a commit id"}, 400)
|
||||||
|
self.send_response(200)
|
||||||
|
self.send_header("Content-Type", "text/event-stream")
|
||||||
|
self.send_header("Cache-Control", "no-cache")
|
||||||
|
self.send_header("X-Accel-Buffering", "no") # nginx must not buffer SSE
|
||||||
|
self.send_header("Connection", "close")
|
||||||
|
self.end_headers()
|
||||||
|
proc = subprocess.Popen(
|
||||||
|
[JOURNALCTL, "-u", f"router-apply@{rev}.service",
|
||||||
|
"-f", "-n", "200", "-o", "cat", "--since", "-30min"],
|
||||||
|
stdout=subprocess.PIPE, text=True,
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
for line in proc.stdout:
|
||||||
|
self.wfile.write(f"data: {line.rstrip()}\n\n".encode())
|
||||||
|
self.wfile.flush()
|
||||||
|
except (BrokenPipeError, ConnectionResetError, OSError):
|
||||||
|
pass
|
||||||
|
finally:
|
||||||
|
proc.terminate()
|
||||||
|
|
||||||
|
|
||||||
|
PAGE = ("""<!doctype html>
|
||||||
|
<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
||||||
|
<title>Router</title>
|
||||||
|
<style>
|
||||||
|
:root{--bg:#12141a;--card:#1b1e27;--line:#2b3040;--fg:#e7e9f0;--dim:#9aa1b5;
|
||||||
|
--ok:#4ade80;--bad:#f87171;--warn:#fbbf24;--accent:#60a5fa}
|
||||||
|
*{box-sizing:border-box}
|
||||||
|
body{margin:0;background:var(--bg);color:var(--fg);font:14px/1.5 system-ui,sans-serif}
|
||||||
|
header{display:flex;gap:4px;padding:12px 16px;border-bottom:1px solid var(--line);
|
||||||
|
flex-wrap:wrap;align-items:center;position:sticky;top:0;background:var(--bg);z-index:5}
|
||||||
|
h1{font-size:15px;margin:0 16px 0 0;font-weight:600}
|
||||||
|
button{background:var(--card);color:var(--fg);border:1px solid var(--line);
|
||||||
|
border-radius:6px;padding:6px 12px;cursor:pointer;font:inherit}
|
||||||
|
button:hover{border-color:var(--accent)}
|
||||||
|
button.on{background:var(--accent);color:#06101f;border-color:var(--accent)}
|
||||||
|
button.go{background:var(--ok);color:#06210f;border-color:var(--ok);font-weight:600}
|
||||||
|
button.danger{border-color:var(--bad);color:var(--bad)}
|
||||||
|
main{padding:16px;max-width:1100px}
|
||||||
|
section{display:none}section.on{display:block}
|
||||||
|
.cards{display:flex;gap:12px;flex-wrap:wrap;margin-bottom:16px}
|
||||||
|
.card{background:var(--card);border:1px solid var(--line);border-radius:10px;
|
||||||
|
padding:12px 16px;min-width:150px}
|
||||||
|
.card .k{color:var(--dim);font-size:12px}.card .v{font-size:22px;font-weight:600}
|
||||||
|
table{border-collapse:collapse;width:100%;background:var(--card);
|
||||||
|
border:1px solid var(--line);border-radius:10px;overflow:hidden}
|
||||||
|
th,td{padding:7px 10px;text-align:left;border-bottom:1px solid var(--line);font-size:13px}
|
||||||
|
th{color:var(--dim);font-weight:500}tr:last-child td{border-bottom:none}
|
||||||
|
input[type=text]{background:#0e1016;color:var(--fg);border:1px solid var(--line);
|
||||||
|
border-radius:5px;padding:4px 7px;font:inherit;width:100%;min-width:70px}
|
||||||
|
select{background:#0e1016;color:var(--fg);border:1px solid var(--line);
|
||||||
|
border-radius:5px;padding:4px;font:inherit}
|
||||||
|
.dot{display:inline-block;width:8px;height:8px;border-radius:50%;background:var(--dim)}
|
||||||
|
.dot.up{background:var(--ok)}
|
||||||
|
.msg{padding:10px 12px;border-radius:8px;margin:12px 0;white-space:pre-wrap;
|
||||||
|
border:1px solid var(--line);background:var(--card);display:none;font-size:13px}
|
||||||
|
.msg.bad{border-color:var(--bad);color:var(--bad)}
|
||||||
|
.msg.ok{border-color:var(--ok);color:var(--ok)}
|
||||||
|
.bar{display:flex;align-items:flex-end;gap:2px;height:70px;margin-top:8px}
|
||||||
|
.bar i{flex:1;background:var(--accent);border-radius:2px 2px 0 0;min-height:1px}
|
||||||
|
.bar i.up{background:var(--warn)}
|
||||||
|
pre{background:#0e1016;border:1px solid var(--line);border-radius:8px;padding:12px;
|
||||||
|
max-height:340px;overflow:auto;font-size:12px;white-space:pre-wrap}
|
||||||
|
.dim{color:var(--dim)}.right{text-align:right}
|
||||||
|
.row{display:flex;gap:8px;align-items:center;flex-wrap:wrap;margin:12px 0}
|
||||||
|
</style>
|
||||||
|
<header>
|
||||||
|
<h1>Router</h1>
|
||||||
|
<button data-tab="overview" class="on">Overview</button>
|
||||||
|
<button data-tab="devices">Devices</button>
|
||||||
|
<button data-tab="ports">Ports</button>
|
||||||
|
<button data-tab="traffic">Traffic</button>
|
||||||
|
<button data-tab="speed">Speedtest</button>
|
||||||
|
</header>
|
||||||
|
<main>
|
||||||
|
<div id="msg" class="msg"></div>
|
||||||
|
|
||||||
|
<section id="overview" class="on">
|
||||||
|
<div class="cards" id="ovcards"></div>
|
||||||
|
<div class="row">
|
||||||
|
<button class="go" id="applybtn">Apply pending changes</button>
|
||||||
|
<span class="dim" id="pendinfo"></span>
|
||||||
|
</div>
|
||||||
|
<pre id="applylog" style="display:none"></pre>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="devices">
|
||||||
|
<div class="row"><button class="go" id="devsave">Save to git</button>
|
||||||
|
<span class="dim">Saving commits devices.toml. Nothing reaches the router until you Apply.</span></div>
|
||||||
|
<table><thead><tr><th></th><th>Name</th><th>MAC</th><th>Vendor</th><th>Lease</th>
|
||||||
|
<th>Reserved IP</th><th>Block</th><th>Note</th><th></th></tr></thead>
|
||||||
|
<tbody id="devrows"></tbody></table>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="ports">
|
||||||
|
<div class="row"><button class="go" id="portsave">Save to git</button>
|
||||||
|
<button id="portadd">Add forward</button></div>
|
||||||
|
<table><thead><tr><th>Name</th><th>Port / range</th><th>Protocol</th>
|
||||||
|
<th>Destination</th><th></th></tr></thead>
|
||||||
|
<tbody id="portrows"></tbody></table>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="traffic">
|
||||||
|
<div class="cards" id="livecards"></div>
|
||||||
|
<h3>Last 24 hours</h3><div class="bar" id="th"></div>
|
||||||
|
<h3>Last 30 days</h3><div class="bar" id="td"></div>
|
||||||
|
<h3>Monthly</h3><div class="bar" id="tm"></div>
|
||||||
|
<p class="dim">Blue = download, amber = upload.</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="speed">
|
||||||
|
<div class="row"><button class="go" id="strun">Run speedtest now</button>
|
||||||
|
<span class="dim">Takes ~30s, then refresh.</span></div>
|
||||||
|
<div class="cards" id="stcards"></div>
|
||||||
|
<table><thead><tr><th>When</th><th class="right">Down</th><th class="right">Up</th>
|
||||||
|
<th class="right">Ping</th><th>Server</th></tr></thead>
|
||||||
|
<tbody id="strows"></tbody></table>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
<script>
|
||||||
|
const $ = s => document.querySelector(s);
|
||||||
|
const el = (t, p = {}) => Object.assign(document.createElement(t), p);
|
||||||
|
let devSha = '', portSha = '', devs = [], ports = [], head = '';
|
||||||
|
|
||||||
|
function say(text, bad) {
|
||||||
|
const m = $('#msg'); m.textContent = text;
|
||||||
|
m.className = 'msg ' + (bad ? 'bad' : 'ok'); m.style.display = text ? 'block' : 'none';
|
||||||
|
}
|
||||||
|
async function api(path, opts) {
|
||||||
|
const r = await fetch(path, opts);
|
||||||
|
const j = await r.json().catch(() => ({ error: r.statusText }));
|
||||||
|
if (!r.ok) throw new Error(j.error || r.statusText);
|
||||||
|
return j;
|
||||||
|
}
|
||||||
|
const post = (p, b) => api(p, {
|
||||||
|
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(b)
|
||||||
|
});
|
||||||
|
|
||||||
|
document.querySelectorAll('header button').forEach(b => b.onclick = () => {
|
||||||
|
document.querySelectorAll('header button').forEach(x => x.classList.remove('on'));
|
||||||
|
document.querySelectorAll('section').forEach(x => x.classList.remove('on'));
|
||||||
|
b.classList.add('on'); $('#' + b.dataset.tab).classList.add('on'); say(''); load(b.dataset.tab);
|
||||||
|
});
|
||||||
|
|
||||||
|
function dur(s) {
|
||||||
|
const d = Math.floor(s / 86400), h = Math.floor(s % 86400 / 3600);
|
||||||
|
return d ? d + 'd ' + h + 'h' : h + 'h ' + Math.floor(s % 3600 / 60) + 'm';
|
||||||
|
}
|
||||||
|
const gib = b => (b / 1024 / 1024 / 1024).toFixed(1) + ' GiB';
|
||||||
|
|
||||||
|
async function load(tab) {
|
||||||
|
try {
|
||||||
|
if (tab === 'overview') {
|
||||||
|
const o = await api('/api/overview'), p = await api('/api/pending');
|
||||||
|
head = p.head;
|
||||||
|
const drift = Object.entries(o.drift).filter(([, v]) => v).map(([k]) => k);
|
||||||
|
$('#ovcards').innerHTML = '';
|
||||||
|
[['WAN address', o.wan_ip], ['Uptime', dur(o.uptime)],
|
||||||
|
['DHCP leases', o.leases], ['Deployed', o.last_rev ? o.last_rev.slice(0, 8) : 'unknown'],
|
||||||
|
['Config drift', drift.length ? drift.join(', ') : 'none']
|
||||||
|
].forEach(([k, v]) => {
|
||||||
|
const c = el('div', { className: 'card' });
|
||||||
|
c.append(el('div', { className: 'k', textContent: k }),
|
||||||
|
el('div', { className: 'v', textContent: v }));
|
||||||
|
$('#ovcards').append(c);
|
||||||
|
});
|
||||||
|
$('#pendinfo').textContent = p.unapplied.length
|
||||||
|
? p.unapplied.length + ' commit(s) not yet deployed: '
|
||||||
|
+ p.unapplied.map(c => c.sha.slice(0, 8) + ' ' + c.message).join(' · ')
|
||||||
|
: 'Nothing to deploy — git matches the last apply.';
|
||||||
|
}
|
||||||
|
if (tab === 'devices') {
|
||||||
|
const d = await api('/api/devices'); devSha = d.sha; devs = d.devices; drawDevs();
|
||||||
|
}
|
||||||
|
if (tab === 'ports') {
|
||||||
|
const p = await api('/api/ports'); portSha = p.sha; ports = p.forwards; drawPorts();
|
||||||
|
}
|
||||||
|
if (tab === 'traffic') { live(); drawTraffic(await api('/api/traffic')); }
|
||||||
|
if (tab === 'speed') drawSpeed(await api('/api/speedtest'));
|
||||||
|
} catch (e) { say(e.message, true); }
|
||||||
|
}
|
||||||
|
|
||||||
|
function bind(obj, key) {
|
||||||
|
const i = el('input', { type: 'text', value: obj[key] ?? '' });
|
||||||
|
i.oninput = () => obj[key] = i.value.trim();
|
||||||
|
return i;
|
||||||
|
}
|
||||||
|
function drawDevs() {
|
||||||
|
const body = $('#devrows'); body.innerHTML = '';
|
||||||
|
devs.forEach((d, n) => {
|
||||||
|
const tr = el('tr');
|
||||||
|
const dot = el('span', { className: 'dot' + (d.online ? ' up' : '') });
|
||||||
|
const blocked = el('input', { type: 'checkbox', checked: d.blocked });
|
||||||
|
blocked.onchange = () => d.blocked = blocked.checked;
|
||||||
|
const cells = [dot, bind(d, 'name'), d.mac, d.vendor || '—',
|
||||||
|
d.lease_ip || '—', bind(d, 'ip'), blocked, bind(d, 'note')];
|
||||||
|
cells.forEach(c => { const td = el('td'); td.append(c.nodeType ? c : document.createTextNode(c)); tr.append(td); });
|
||||||
|
const td = el('td');
|
||||||
|
if (d.known) {
|
||||||
|
const b = el('button', { className: 'danger', textContent: 'Forget' });
|
||||||
|
b.onclick = () => { devs.splice(n, 1); drawDevs(); };
|
||||||
|
td.append(b);
|
||||||
|
} else td.append(el('span', { className: 'dim', textContent: 'unregistered' }));
|
||||||
|
tr.append(td); body.append(tr);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function drawPorts() {
|
||||||
|
const body = $('#portrows'); body.innerHTML = '';
|
||||||
|
ports.forEach((f, n) => {
|
||||||
|
const tr = el('tr');
|
||||||
|
const proto = el('select');
|
||||||
|
['tcp', 'udp', 'both'].forEach(p => proto.append(el('option', { value: p, textContent: p, selected: f.protocol === p })));
|
||||||
|
proto.onchange = () => f.protocol = proto.value;
|
||||||
|
[bind(f, 'name'), bind(f, 'port'), proto, bind(f, 'dest')].forEach(c => {
|
||||||
|
const td = el('td'); td.append(c); tr.append(td);
|
||||||
|
});
|
||||||
|
const td = el('td'), b = el('button', { className: 'danger', textContent: 'Remove' });
|
||||||
|
b.onclick = () => { ports.splice(n, 1); drawPorts(); };
|
||||||
|
td.append(b); tr.append(td); body.append(tr);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function bars(node, rows) {
|
||||||
|
node.innerHTML = '';
|
||||||
|
const max = Math.max(1, ...rows.map(r => Math.max(r.rx, r.tx)));
|
||||||
|
rows.forEach(r => {
|
||||||
|
[['rx', ''], ['tx', ' up']].forEach(([k, cls]) => {
|
||||||
|
node.append(el('i', {
|
||||||
|
className: cls.trim(), title: r.label + ' ' + k + ' ' + gib(r[k]),
|
||||||
|
style: 'height:' + Math.max(1, r[k] / max * 100) + '%'
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function drawTraffic(t) { bars($('#th'), t.hour); bars($('#td'), t.day); bars($('#tm'), t.month); }
|
||||||
|
|
||||||
|
let prev = null, timer = null;
|
||||||
|
async function live() {
|
||||||
|
clearInterval(timer);
|
||||||
|
const tick = async () => {
|
||||||
|
if (!$('#traffic').classList.contains('on')) return;
|
||||||
|
const s = await api('/api/live');
|
||||||
|
if (prev) {
|
||||||
|
const dt = Math.max(0.5, s.t - prev.t);
|
||||||
|
const mbps = b => ((b) * 8 / dt / 1e6).toFixed(1) + ' Mbps';
|
||||||
|
$('#livecards').innerHTML = '';
|
||||||
|
[['Down now', mbps(s.rx - prev.rx)], ['Up now', mbps(s.tx - prev.tx)]]
|
||||||
|
.forEach(([k, v]) => {
|
||||||
|
const c = el('div', { className: 'card' });
|
||||||
|
c.append(el('div', { className: 'k', textContent: k }),
|
||||||
|
el('div', { className: 'v', textContent: v }));
|
||||||
|
$('#livecards').append(c);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
prev = s;
|
||||||
|
};
|
||||||
|
await tick(); timer = setInterval(tick, 2000);
|
||||||
|
}
|
||||||
|
function drawSpeed(rows) {
|
||||||
|
const last = rows[rows.length - 1];
|
||||||
|
$('#stcards').innerHTML = '';
|
||||||
|
if (last) [['Download', last.down + ' Mbps'], ['Upload', last.up + ' Mbps'],
|
||||||
|
['Ping', last.ping + ' ms'], ['When', last.ts]].forEach(([k, v]) => {
|
||||||
|
const c = el('div', { className: 'card' });
|
||||||
|
c.append(el('div', { className: 'k', textContent: k }),
|
||||||
|
el('div', { className: 'v', textContent: v }));
|
||||||
|
$('#stcards').append(c);
|
||||||
|
});
|
||||||
|
const body = $('#strows'); body.innerHTML = '';
|
||||||
|
rows.slice().reverse().forEach(r => {
|
||||||
|
const tr = el('tr');
|
||||||
|
[r.ts, r.down, r.up, r.ping, r.server].forEach((v, i) => {
|
||||||
|
tr.append(el('td', { textContent: v, className: i > 0 && i < 4 ? 'right' : '' }));
|
||||||
|
});
|
||||||
|
body.append(tr);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
$('#devsave').onclick = async () => {
|
||||||
|
try {
|
||||||
|
const r = await post('/api/devices', { sha: devSha, devices: devs });
|
||||||
|
head = r.rev; say('Committed ' + r.rev.slice(0, 8) + '. Go to Overview and Apply.');
|
||||||
|
load('devices');
|
||||||
|
} catch (e) { say(e.message, true); }
|
||||||
|
};
|
||||||
|
$('#portsave').onclick = async () => {
|
||||||
|
try {
|
||||||
|
const r = await post('/api/ports', { sha: portSha, forwards: ports });
|
||||||
|
head = r.rev; say('Committed ' + r.rev.slice(0, 8) + '. Go to Overview and Apply.');
|
||||||
|
load('ports');
|
||||||
|
} catch (e) { say(e.message, true); }
|
||||||
|
};
|
||||||
|
$('#portadd').onclick = () => {
|
||||||
|
ports.push({ _i: null, name: '', port: '', protocol: 'tcp', dest: '' }); drawPorts();
|
||||||
|
};
|
||||||
|
$('#strun').onclick = async () => {
|
||||||
|
try { await post('/api/speedtest/run', {}); say('Speedtest started — refresh in ~30s.'); }
|
||||||
|
catch (e) { say(e.message, true); }
|
||||||
|
};
|
||||||
|
|
||||||
|
$('#applybtn').onclick = async () => {
|
||||||
|
if (!head) { say('Nothing to apply.', true); return; }
|
||||||
|
await doApply(false);
|
||||||
|
};
|
||||||
|
async function doApply(confirm) {
|
||||||
|
try {
|
||||||
|
await post('/api/apply', { rev: head, confirm });
|
||||||
|
say('Applying ' + head.slice(0, 8) + ' — test, health check, then switch.');
|
||||||
|
tail(head);
|
||||||
|
} catch (e) {
|
||||||
|
if (e.message.startsWith('CONFIRM:')) {
|
||||||
|
if (window.confirm(e.message.slice(8) + '\\n\\nApply anyway?')) return doApply(true);
|
||||||
|
return say('Cancelled.', true);
|
||||||
|
}
|
||||||
|
say(e.message, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function tail(rev) {
|
||||||
|
const pre = $('#applylog'); pre.style.display = 'block'; pre.textContent = '';
|
||||||
|
const src = new EventSource('/api/apply/log?rev=' + rev);
|
||||||
|
src.onmessage = e => { pre.textContent += e.data + '\\n'; pre.scrollTop = pre.scrollHeight; };
|
||||||
|
src.onerror = () => src.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
load('overview');
|
||||||
|
</script>
|
||||||
|
""").encode()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
os.makedirs(STATE, exist_ok=True)
|
||||||
|
ThreadingHTTPServer(("127.0.0.1", PORT), Handler).serve_forever()
|
||||||
|
|
@ -32,6 +32,7 @@
|
||||||
{ domain = "homepage.nordhammer.it"; policy = "one_factor"; }
|
{ domain = "homepage.nordhammer.it"; policy = "one_factor"; }
|
||||||
# { domain = "7dtd.nordhammer.it"; policy = "one_factor"; } # 7DTD disabled
|
# { domain = "7dtd.nordhammer.it"; policy = "one_factor"; } # 7DTD disabled
|
||||||
{ domain = "adguard.nordhammer.it"; policy = "one_factor"; }
|
{ domain = "adguard.nordhammer.it"; policy = "one_factor"; }
|
||||||
|
{ domain = "router.nordhammer.it"; policy = "one_factor"; }
|
||||||
{ domain = "sonarr.nordhammer.it"; policy = "one_factor"; }
|
{ domain = "sonarr.nordhammer.it"; policy = "one_factor"; }
|
||||||
{ domain = "radarr.nordhammer.it"; policy = "one_factor"; }
|
{ domain = "radarr.nordhammer.it"; policy = "one_factor"; }
|
||||||
{ domain = "bazarr.nordhammer.it"; policy = "one_factor"; }
|
{ domain = "bazarr.nordhammer.it"; policy = "one_factor"; }
|
||||||
|
|
|
||||||
|
|
@ -141,6 +141,14 @@ in
|
||||||
"homepage.nordhammer.it" = protectedProxy 8084;
|
"homepage.nordhammer.it" = protectedProxy 8084;
|
||||||
# "7dtd.nordhammer.it" = protectedProxy 8090; # 7DTD disabled
|
# "7dtd.nordhammer.it" = protectedProxy 8090; # 7DTD disabled
|
||||||
"adguard.nordhammer.it" = protectedProxy 3000;
|
"adguard.nordhammer.it" = protectedProxy 3000;
|
||||||
|
# Router management UI (services/router-ui.nix). proxy_buffering off so
|
||||||
|
# the apply log streams live instead of arriving in one lump at the end.
|
||||||
|
"router.nordhammer.it" = lib.recursiveUpdate (protectedProxy 8086) {
|
||||||
|
locations."/".extraConfig = autheliaAuthConfig + securityHeaders + ''
|
||||||
|
proxy_buffering off;
|
||||||
|
proxy_read_timeout 2h;
|
||||||
|
'';
|
||||||
|
};
|
||||||
"profilarr.nordhammer.it" = protectedProxy 6868;
|
"profilarr.nordhammer.it" = protectedProxy 6868;
|
||||||
"shelfarr.nordhammer.it" = protectedProxy 5056;
|
"shelfarr.nordhammer.it" = protectedProxy 5056;
|
||||||
"sabnzbd.nordhammer.it" = protectedProxy 8085;
|
"sabnzbd.nordhammer.it" = protectedProxy 8085;
|
||||||
|
|
|
||||||
211
services/router-ui.nix
Normal file
211
services/router-ui.nix
Normal file
|
|
@ -0,0 +1,211 @@
|
||||||
|
# services/router-ui.nix — web UI for the day-to-day router chores
|
||||||
|
# (router.nordhammer.it, behind Authelia via services/nginx.nix).
|
||||||
|
#
|
||||||
|
# The app never generates Nix. It edits two TOML files — ../ports.toml and
|
||||||
|
# ../devices.toml — through the Forgejo API, then asks router-apply@<rev> to
|
||||||
|
# deploy that exact commit. services/router.nix reads those same files, so the
|
||||||
|
# UI is a constrained editor for config that is still fully reviewable in git.
|
||||||
|
#
|
||||||
|
# Apply is deliberately paranoid, because a bad generation here takes the
|
||||||
|
# household's internet with it:
|
||||||
|
#
|
||||||
|
# nixos-rebuild test -> eval/build failure aborts before anything activates
|
||||||
|
# wait, then health-check WAN ping + dnsmasq + LAN address + NAT table
|
||||||
|
# healthy -> nixos-rebuild switch (only now does it become the boot default)
|
||||||
|
# unhealthy -> switch-to-configuration test on the old profile, + ntfy alert
|
||||||
|
#
|
||||||
|
# `test` never touches the boot default, so a reboot is always an escape hatch.
|
||||||
|
#
|
||||||
|
# Requires a Forgejo token with write:repository on rope/nixos:
|
||||||
|
# printf '%s' '<token>' | sudo tee /var/secrets/forgejo-router-token
|
||||||
|
# sudo chmod 600 /var/secrets/forgejo-router-token
|
||||||
|
|
||||||
|
{ config, lib, pkgs, ... }:
|
||||||
|
let
|
||||||
|
port = 8086;
|
||||||
|
stateDir = "/var/lib/router-ui";
|
||||||
|
flakeUrl = "git+https://forg.gregersen.it/rope/nixos";
|
||||||
|
|
||||||
|
pythonEnv = pkgs.python3.withPackages (ps: [ ps.tomlkit ]);
|
||||||
|
|
||||||
|
# Absolute paths, not PATH lookups: the sudoers rule below has to match the
|
||||||
|
# argv the app actually execs, character for character.
|
||||||
|
systemctl = "${pkgs.systemd}/bin/systemctl";
|
||||||
|
sudo = "/run/wrappers/bin/sudo";
|
||||||
|
|
||||||
|
applyScript = pkgs.writeShellScript "router-apply" ''
|
||||||
|
set -uo pipefail
|
||||||
|
rev="$1"
|
||||||
|
flake="${flakeUrl}?rev=$rev"
|
||||||
|
secret=/var/secrets/ntfy-url
|
||||||
|
|
||||||
|
notify() {
|
||||||
|
[ -f "$secret" ] || return 0
|
||||||
|
url=$(${pkgs.coreutils}/bin/tr -d '\n' < "$secret")
|
||||||
|
${pkgs.curl}/bin/curl -fsS --max-time 10 \
|
||||||
|
-H "Title: Router config" -H "Priority: high" -H "Tags: satellite_antenna" \
|
||||||
|
-d "$1" "$url" >/dev/null 2>&1 || true
|
||||||
|
}
|
||||||
|
|
||||||
|
old=$(${pkgs.coreutils}/bin/readlink /run/current-system)
|
||||||
|
|
||||||
|
echo "==> building + activating $rev (test: boot default untouched)"
|
||||||
|
if ! nixos-rebuild test --refresh --flake "$flake" -L; then
|
||||||
|
echo "!! eval or build failed — nothing was activated, old config still running"
|
||||||
|
notify "Router apply ''${rev:0:8}: build failed. Nothing changed."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "==> activated, waiting 45s for the network to settle"
|
||||||
|
sleep 45
|
||||||
|
|
||||||
|
healthy=1
|
||||||
|
${pkgs.iputils}/bin/ping -c1 -W3 -I eno1 1.1.1.1 >/dev/null 2>&1 \
|
||||||
|
|| ${pkgs.iputils}/bin/ping -c1 -W3 -I eno1 8.8.8.8 >/dev/null 2>&1 \
|
||||||
|
|| { echo "!! no WAN"; healthy=0; }
|
||||||
|
${systemctl} is-active --quiet dnsmasq || { echo "!! dnsmasq down"; healthy=0; }
|
||||||
|
${pkgs.iproute2}/bin/ip -4 addr show eth0 \
|
||||||
|
| ${pkgs.gnugrep}/bin/grep -q '10\.0\.0\.1' || { echo "!! LAN address gone"; healthy=0; }
|
||||||
|
${pkgs.nftables}/bin/nft list table ip router-nat >/dev/null 2>&1 \
|
||||||
|
|| { echo "!! NAT table missing"; healthy=0; }
|
||||||
|
|
||||||
|
if [ "$healthy" = 1 ]; then
|
||||||
|
echo "==> healthy — making $rev the boot default"
|
||||||
|
if nixos-rebuild switch --refresh --flake "$flake" -L; then
|
||||||
|
${pkgs.coreutils}/bin/printf '%s' "$rev" > ${stateDir}/last-rev
|
||||||
|
${pkgs.coreutils}/bin/chown router-ui ${stateDir}/last-rev || true
|
||||||
|
record-update "$old" /run/current-system || true
|
||||||
|
notify "Router applied ''${rev:0:8}."
|
||||||
|
echo "==> done"
|
||||||
|
else
|
||||||
|
echo "!! switch failed after a healthy test — running config is fine, boot default is not"
|
||||||
|
notify "Router apply ''${rev:0:8}: switch failed after a healthy test. Check the box."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
echo "!! health check failed — reverting to the previous generation"
|
||||||
|
/nix/var/nix/profiles/system/bin/switch-to-configuration test
|
||||||
|
notify "Router apply ''${rev:0:8} FAILED health check. Rolled back."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
'';
|
||||||
|
in
|
||||||
|
{
|
||||||
|
config = lib.mkIf (config.networking.hostName == "FredOS-Mediaserver") {
|
||||||
|
|
||||||
|
users.users.router-ui = {
|
||||||
|
isSystemUser = true;
|
||||||
|
group = "router-ui";
|
||||||
|
description = "Router management UI";
|
||||||
|
};
|
||||||
|
users.groups.router-ui = { };
|
||||||
|
|
||||||
|
# WAN traffic history for the Traffic tab. No further config needed —
|
||||||
|
# vnstatd picks up every interface on its own.
|
||||||
|
services.vnstat.enable = true;
|
||||||
|
|
||||||
|
# The copies baked into the running generation. The UI diffs these against
|
||||||
|
# what Forgejo has on main to show whether a change is still undeployed.
|
||||||
|
environment.etc."router/ports.toml".source = ../ports.toml;
|
||||||
|
environment.etc."router/devices.toml".source = ../devices.toml;
|
||||||
|
|
||||||
|
systemd.services.router-ui = {
|
||||||
|
description = "Router management UI";
|
||||||
|
wantedBy = [ "multi-user.target" ];
|
||||||
|
after = [ "network.target" ];
|
||||||
|
path = [ pkgs.iproute2 config.services.vnstat.package pkgs.systemd ];
|
||||||
|
environment = {
|
||||||
|
ROUTER_UI_PORT = toString port;
|
||||||
|
ROUTER_UI_STATE = stateDir;
|
||||||
|
ROUTER_UI_ETC = "/etc/router";
|
||||||
|
FORGEJO_API = "https://forg.gregersen.it/api/v1";
|
||||||
|
FORGEJO_REPO = "rope/nixos";
|
||||||
|
FORGEJO_BRANCH = "main";
|
||||||
|
FORGEJO_TOKEN_FILE = "/var/secrets/forgejo-router-token";
|
||||||
|
WAN_IF = "eno1";
|
||||||
|
LAN_IF = "eth0";
|
||||||
|
LAN_PREFIX = "10.0.0.";
|
||||||
|
ROUTER_IP = "10.0.0.1";
|
||||||
|
POOL_START = "100";
|
||||||
|
POOL_END = "250";
|
||||||
|
DNSMASQ_LEASES = "/var/lib/dnsmasq/dnsmasq.leases";
|
||||||
|
OUI_FILE = "${pkgs.nmap}/share/nmap/nmap-mac-prefixes";
|
||||||
|
SUDO_BIN = sudo;
|
||||||
|
SYSTEMCTL_BIN = systemctl;
|
||||||
|
JOURNALCTL_BIN = "${pkgs.systemd}/bin/journalctl";
|
||||||
|
IP_BIN = "${pkgs.iproute2}/bin/ip";
|
||||||
|
VNSTAT_BIN = "${config.services.vnstat.package}/bin/vnstat";
|
||||||
|
};
|
||||||
|
serviceConfig = {
|
||||||
|
ExecStart = "${pythonEnv}/bin/python3 ${../scripts/router-ui.py}";
|
||||||
|
User = "router-ui";
|
||||||
|
Group = "router-ui";
|
||||||
|
StateDirectory = "router-ui";
|
||||||
|
Restart = "on-failure";
|
||||||
|
RestartSec = 5;
|
||||||
|
# Reads /var/secrets/forgejo-router-token, so it can't be fully locked
|
||||||
|
# down, but nothing here needs to write outside its state dir.
|
||||||
|
ProtectSystem = "strict";
|
||||||
|
ProtectHome = true;
|
||||||
|
PrivateTmp = true;
|
||||||
|
NoNewPrivileges = false; # it shells out through sudo for apply
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
# Deploy one specific commit. Instance name is the git rev.
|
||||||
|
systemd.services."router-apply@" = {
|
||||||
|
description = "Deploy router config %i (test, health check, then switch)";
|
||||||
|
path = [ pkgs.nixos-rebuild pkgs.nix pkgs.systemd "/run/current-system/sw" ];
|
||||||
|
environment.HOME = "/root";
|
||||||
|
serviceConfig = {
|
||||||
|
Type = "oneshot";
|
||||||
|
ExecStart = "${applyScript} %i";
|
||||||
|
TimeoutStartSec = "90min";
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
systemd.services.router-speedtest = {
|
||||||
|
description = "Record a speedtest result";
|
||||||
|
serviceConfig = {
|
||||||
|
Type = "oneshot";
|
||||||
|
User = "router-ui";
|
||||||
|
Group = "router-ui";
|
||||||
|
StateDirectory = "router-ui";
|
||||||
|
};
|
||||||
|
script = ''
|
||||||
|
out=$(${pkgs.speedtest-go}/bin/speedtest-go --json 2>/dev/null) || exit 0
|
||||||
|
# Only append if it actually produced a JSON object; a failed run
|
||||||
|
# prints nothing and must not corrupt the log.
|
||||||
|
case "$out" in
|
||||||
|
'{'*) ${pkgs.coreutils}/bin/printf '%s\n' "$out" >> ${stateDir}/speedtest.jsonl ;;
|
||||||
|
*) echo "speedtest produced no result" >&2 ;;
|
||||||
|
esac
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
|
||||||
|
systemd.timers.router-speedtest = {
|
||||||
|
wantedBy = [ "timers.target" ];
|
||||||
|
timerConfig = {
|
||||||
|
OnBootSec = "10min";
|
||||||
|
OnUnitActiveSec = "6h";
|
||||||
|
RandomizedDelaySec = "10min";
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
# The UI's only privilege: starting these two units. Nothing else.
|
||||||
|
security.sudo.extraRules = [{
|
||||||
|
users = [ "router-ui" ];
|
||||||
|
commands = [
|
||||||
|
{
|
||||||
|
command = "${systemctl} start --no-block router-apply@[0-9a-f]*.service";
|
||||||
|
options = [ "NOPASSWD" ];
|
||||||
|
}
|
||||||
|
{
|
||||||
|
command = "${systemctl} start --no-block router-speedtest.service";
|
||||||
|
options = [ "NOPASSWD" ];
|
||||||
|
}
|
||||||
|
];
|
||||||
|
}];
|
||||||
|
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -10,13 +10,33 @@
|
||||||
# - dnsmasq: DHCP only (port 0 for DNS — AdGuard Home owns :53)
|
# - dnsmasq: DHCP only (port 0 for DNS — AdGuard Home owns :53)
|
||||||
# - AdGuard Home (already running): DNS for LAN clients
|
# - AdGuard Home (already running): DNS for LAN clients
|
||||||
#
|
#
|
||||||
# Port forwards live in ../ports.toml so they're easy to edit.
|
# Port forwards live in ../ports.toml and LAN devices (static reservations +
|
||||||
|
# block list) in ../devices.toml, so both are easy to edit — by hand, or via
|
||||||
|
# the router UI (services/router-ui.nix), which only ever writes those two
|
||||||
|
# TOML files and never generates Nix.
|
||||||
|
|
||||||
{ config, lib, pkgs, ... }:
|
{ config, lib, pkgs, ... }:
|
||||||
let
|
let
|
||||||
portsData = builtins.fromTOML (builtins.readFile ../ports.toml);
|
portsData = builtins.fromTOML (builtins.readFile ../ports.toml);
|
||||||
destDefault = portsData.dest_default;
|
destDefault = portsData.dest_default;
|
||||||
|
|
||||||
|
devices = (builtins.fromTOML (builtins.readFile ../devices.toml)).device or [ ];
|
||||||
|
reservedDevices = builtins.filter (d: d ? ip) devices;
|
||||||
|
blockedDevices = builtins.filter (d: d.blocked or false) devices;
|
||||||
|
|
||||||
|
# Drop everything from a blocked MAC arriving on the LAN. These are emitted
|
||||||
|
# at the TOP of the input and forward chains, ahead of the `ct state
|
||||||
|
# established,related accept` line — otherwise a device that was already
|
||||||
|
# talking keeps its existing flows alive indefinitely.
|
||||||
|
#
|
||||||
|
# ponytail: conntrack entries created before the block still linger until
|
||||||
|
# they time out (a few minutes). Add `conntrack -D -s <ip>` to the apply
|
||||||
|
# path if that wait ever matters.
|
||||||
|
# ponytail: MAC-based, so a device that randomises its MAC walks around it.
|
||||||
|
blockRules = lib.concatMapStringsSep "\n "
|
||||||
|
(d: ''iifname "eth0" ether saddr ${d.mac} drop comment "${d.name} blocked"'')
|
||||||
|
blockedDevices;
|
||||||
|
|
||||||
# Phase-1 transition list; empty now that eero is in bridge mode and
|
# Phase-1 transition list; empty now that eero is in bridge mode and
|
||||||
# eno1 is strictly the ISP-facing WAN.
|
# eno1 is strictly the ISP-facing WAN.
|
||||||
trustedLegacyCidrs = [ ];
|
trustedLegacyCidrs = [ ];
|
||||||
|
|
@ -116,6 +136,8 @@ in
|
||||||
content = ''
|
content = ''
|
||||||
chain input {
|
chain input {
|
||||||
type filter hook input priority 0; policy drop;
|
type filter hook input priority 0; policy drop;
|
||||||
|
# Blocked devices first — before the conntrack accept.
|
||||||
|
${blockRules}
|
||||||
ct state established,related accept
|
ct state established,related accept
|
||||||
ct state invalid drop
|
ct state invalid drop
|
||||||
iifname "lo" accept
|
iifname "lo" accept
|
||||||
|
|
@ -136,6 +158,8 @@ in
|
||||||
}
|
}
|
||||||
chain forward {
|
chain forward {
|
||||||
type filter hook forward priority 0; policy drop;
|
type filter hook forward priority 0; policy drop;
|
||||||
|
# Blocked devices first — before the conntrack accept.
|
||||||
|
${blockRules}
|
||||||
ct state established,related accept
|
ct state established,related accept
|
||||||
ct state invalid drop
|
ct state invalid drop
|
||||||
# LAN → anywhere
|
# LAN → anywhere
|
||||||
|
|
@ -187,10 +211,8 @@ in
|
||||||
"option:router,10.0.0.1"
|
"option:router,10.0.0.1"
|
||||||
"option:dns-server,10.0.0.1"
|
"option:dns-server,10.0.0.1"
|
||||||
];
|
];
|
||||||
# Static reservations — format: "MAC,label,IP"
|
# Static reservations — format: "MAC,label,IP". From ../devices.toml.
|
||||||
dhcp-host = [
|
dhcp-host = map (d: "${d.mac},${d.name},${d.ip}") reservedDevices;
|
||||||
"f0:a7:31:6c:50:4b,camera-bedroom,10.0.0.39"
|
|
||||||
];
|
|
||||||
# Helpful: log leases to the journal
|
# Helpful: log leases to the journal
|
||||||
log-dhcp = true;
|
log-dhcp = true;
|
||||||
};
|
};
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue