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/adguard.nix
|
||||
./services/router.nix
|
||||
./services/router-ui.nix
|
||||
./services/crowdsec.nix
|
||||
./services/service-health.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"
|
||||
# protocol — "tcp", "udp", or "both"
|
||||
# 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"
|
||||
|
||||
# --- 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]]
|
||||
name = "HTTP"
|
||||
port = 80
|
||||
|
|
@ -32,39 +54,3 @@ protocol = "tcp"
|
|||
name = "Pelican game servers"
|
||||
ports = "25565-25600"
|
||||
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 = "7dtd.nordhammer.it"; policy = "one_factor"; } # 7DTD disabled
|
||||
{ domain = "adguard.nordhammer.it"; policy = "one_factor"; }
|
||||
{ domain = "router.nordhammer.it"; policy = "one_factor"; }
|
||||
{ domain = "sonarr.nordhammer.it"; policy = "one_factor"; }
|
||||
{ domain = "radarr.nordhammer.it"; policy = "one_factor"; }
|
||||
{ domain = "bazarr.nordhammer.it"; policy = "one_factor"; }
|
||||
|
|
|
|||
|
|
@ -141,6 +141,14 @@ in
|
|||
"homepage.nordhammer.it" = protectedProxy 8084;
|
||||
# "7dtd.nordhammer.it" = protectedProxy 8090; # 7DTD disabled
|
||||
"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;
|
||||
"shelfarr.nordhammer.it" = protectedProxy 5056;
|
||||
"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)
|
||||
# - 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, ... }:
|
||||
let
|
||||
portsData = builtins.fromTOML (builtins.readFile ../ports.toml);
|
||||
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
|
||||
# eno1 is strictly the ISP-facing WAN.
|
||||
trustedLegacyCidrs = [ ];
|
||||
|
|
@ -116,6 +136,8 @@ in
|
|||
content = ''
|
||||
chain input {
|
||||
type filter hook input priority 0; policy drop;
|
||||
# Blocked devices first — before the conntrack accept.
|
||||
${blockRules}
|
||||
ct state established,related accept
|
||||
ct state invalid drop
|
||||
iifname "lo" accept
|
||||
|
|
@ -136,6 +158,8 @@ in
|
|||
}
|
||||
chain forward {
|
||||
type filter hook forward priority 0; policy drop;
|
||||
# Blocked devices first — before the conntrack accept.
|
||||
${blockRules}
|
||||
ct state established,related accept
|
||||
ct state invalid drop
|
||||
# LAN → anywhere
|
||||
|
|
@ -187,10 +211,8 @@ in
|
|||
"option:router,10.0.0.1"
|
||||
"option:dns-server,10.0.0.1"
|
||||
];
|
||||
# Static reservations — format: "MAC,label,IP"
|
||||
dhcp-host = [
|
||||
"f0:a7:31:6c:50:4b,camera-bedroom,10.0.0.39"
|
||||
];
|
||||
# Static reservations — format: "MAC,label,IP". From ../devices.toml.
|
||||
dhcp-host = map (d: "${d.mac},${d.name},${d.ip}") reservedDevices;
|
||||
# Helpful: log leases to the journal
|
||||
log-dhcp = true;
|
||||
};
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue