Development

This commit is contained in:
Matthew Grotke 2026-05-21 01:34:42 -04:00
parent 8766c6c9a2
commit ee31a18ac6
43 changed files with 54 additions and 48 deletions

View file

@ -4,7 +4,7 @@ import re
from flask import Blueprint, make_response, redirect, flash, request from flask import Blueprint, make_response, redirect, flash, request
from auth import require_level from auth import require_level
from config_utils import load_core, save_core, verify_core_hash, queued_msg, CONFIGS_DIR from config_utils import load_core, save_core, verify_core_hash, queued_msg, CONFIGS_DIR, PRODUCT_DISPLAY_NAME
import sanitize import sanitize
import validation as validate import validation as validate
@ -103,7 +103,7 @@ def _build_client_conf(vlan, peer_name, peer_ip, private_key, server_pubkey):
allowed_ips = f'{subnet}/{prefix}' if split_tunnel else '0.0.0.0/0' allowed_ips = f'{subnet}/{prefix}' if split_tunnel else '0.0.0.0/0'
lines = [ lines = [
'# Generated by router-dash', f'# Generated by {PRODUCT_DISPLAY_NAME}',
'', '',
'[Interface]', '[Interface]',
f'PrivateKey = {private_key}', f'PrivateKey = {private_key}',
@ -125,7 +125,7 @@ def _conf_response(vlan, peer_name, peer_ip, private_key):
iface = _wg_iface(vlan, core) iface = _wg_iface(vlan, core)
server_pub = _server_pubkey(iface) server_pub = _server_pubkey(iface)
if not server_pub: if not server_pub:
flash('Peer saved. Run sudo python3 ~/router/core.py --apply to generate the server ' flash('Peer saved. Run sudo python3 ~/routlin/core.py --apply to generate the server '
'public key, then regenerate this peer to download the client config.', 'warning') 'public key, then regenerate this peer to download the client config.', 'warning')
return redirect(_VIEW) return redirect(_VIEW)
conf = _build_client_conf(vlan, peer_name, peer_ip, private_key, server_pub) conf = _build_client_conf(vlan, peer_name, peer_ip, private_key, server_pub)

View file

@ -3,6 +3,7 @@ import json, os, bcrypt, secrets, smtplib
from datetime import datetime, timezone, timedelta from datetime import datetime, timezone, timedelta
from email.message import EmailMessage from email.message import EmailMessage
from auth import require_level from auth import require_level
from config_utils import PRODUCT_DISPLAY_NAME
import sanitize import sanitize
bp = Blueprint('action_create_account', __name__) bp = Blueprint('action_create_account', __name__)
@ -31,7 +32,7 @@ def _send_verification_email(to_address, code):
raise RuntimeError('SMTP_HOST is not configured.') raise RuntimeError('SMTP_HOST is not configured.')
msg = EmailMessage() msg = EmailMessage()
msg['Subject'] = 'Router Dashboard - Email Verification' msg['Subject'] = f'{PRODUCT_DISPLAY_NAME} - Email Verification'
msg['From'] = from_addr msg['From'] = from_addr
msg['To'] = to_address msg['To'] = to_address
msg.set_content( msg.set_content(

View file

@ -8,7 +8,8 @@ DASHBOARD_QUEUE = f'{CONFIGS_DIR}/.dashboard-queue'
DASHBOARD_DONE = f'{CONFIGS_DIR}/.dashboard-done' DASHBOARD_DONE = f'{CONFIGS_DIR}/.dashboard-done'
DASHBOARD_LAST_RUN = f'{CONFIGS_DIR}/.dashboard-last-run' DASHBOARD_LAST_RUN = f'{CONFIGS_DIR}/.dashboard-last-run'
DASHBOARD_LOCK = f'{CONFIGS_DIR}/.dashboard-lock' DASHBOARD_LOCK = f'{CONFIGS_DIR}/.dashboard-lock'
DASHB_TIMER_NAME = 'router-dashboard-queue' DASHB_TIMER_NAME = 'routlin-dashboard-queue'
PRODUCT_DISPLAY_NAME = os.environ.get('PRODUCT_DISPLAY_NAME', 'Routlin Dashboard')
DASHB_INTERVAL_SECS = 60 DASHB_INTERVAL_SECS = 60
QUEUE_MAX_LINES = 50 QUEUE_MAX_LINES = 50

View file

@ -4,7 +4,7 @@ import json, re, subprocess, os, sys, html as html_mod
import sanitize import sanitize
import validation as validate import validation as validate
from datetime import datetime, timezone from datetime import datetime, timezone
from config_utils import core_hash, get_pending_entries, _seconds_until_next_run, _format_timing, _is_locked, _lock_mtime from config_utils import core_hash, get_pending_entries, _seconds_until_next_run, _format_timing, _is_locked, _lock_mtime, PRODUCT_DISPLAY_NAME
bp = Blueprint('view_page', __name__) bp = Blueprint('view_page', __name__)
@ -1330,9 +1330,9 @@ def _load_datasource(spec):
def render_layout(view_id, content_html, tokens): def render_layout(view_id, content_html, tokens):
css = _load_css() css = _load_css()
level = _client_level() level = _client_level()
titlebar_html = '<div class="titlebar"><span class="titlebar-brand">Router Dashboard</span></div>' titlebar_html = f'<div class="titlebar"><span class="titlebar-brand">{PRODUCT_DISPLAY_NAME}</span></div>'
navbar_html = _render_navbar(view_id, level, tokens) navbar_html = _render_navbar(view_id, level, tokens)
footer_html = '<footer class="footer">Router Dashboard</footer>' footer_html = f'<footer class="footer">{PRODUCT_DISPLAY_NAME}</footer>'
page_hash = core_hash() page_hash = core_hash()
lan_iface = e(tokens.get('GENERAL_LAN_INTERFACE', '')) lan_iface = e(tokens.get('GENERAL_LAN_INTERFACE', ''))
@ -1367,7 +1367,7 @@ def render_layout(view_id, content_html, tokens):
return (f'<!DOCTYPE html>\n<html lang="en">\n<head>\n' return (f'<!DOCTYPE html>\n<html lang="en">\n<head>\n'
f' <meta charset="UTF-8">\n' f' <meta charset="UTF-8">\n'
f' <meta name="viewport" content="width=device-width, initial-scale=1.0">\n' f' <meta name="viewport" content="width=device-width, initial-scale=1.0">\n'
f' <title>Router Dashboard</title>\n' f' <title>{PRODUCT_DISPLAY_NAME}</title>\n'
f' <style>{css}</style>\n' f' <style>{css}</style>\n'
f'</head>\n<body>\n' f'</head>\n<body>\n'
f'{titlebar_html}\n' f'{titlebar_html}\n'

View file

@ -13,7 +13,7 @@
"items": [ "items": [
{ {
"type": "h1", "type": "h1",
"text": "Router Dashboard" "text": "Routlin Dashboard"
}, },
{ {
"type": "p", "type": "p",

View file

@ -1,18 +1,19 @@
name: router-dash name: routlin-dash
services: services:
flask-app: flask-app:
container_name: router-dash container_name: routlin-dash
build: . build: .
ports: ports:
- "25327:25327" - "25327:25327"
volumes: volumes:
- ./data:/data - ./data:/data
- $HOME/router:/configs - $HOME/routlin:/configs
- $HOME/router/validation.py:/app/validation.py - $HOME/routlin/validation.py:/app/validation.py
- /sys/class/net:/sys/class/net:ro - /sys/class/net:/sys/class/net:ro
- /sys/devices:/sys/devices:ro - /sys/devices:/sys/devices:ro
environment: environment:
- PRODUCT_DISPLAY_NAME=Routlin Dashboard
- INITIAL_MANAGER_EMAIL=mgrotke@gmail.com - INITIAL_MANAGER_EMAIL=mgrotke@gmail.com
- SECRET_KEY=ey8hSQCCYE5kQXV8nOg1CB44LSd3AoUet2ZBc3aZlFrwBbazE7aHcxXWyuT97eAObet5jmOL0CjMg0rB1hE4d2SBVYHPfl8De55EiFv307r1QP3Mf5XgOSSCxD3TuD - SECRET_KEY=ey8hSQCCYE5kQXV8nOg1CB44LSd3AoUet2ZBc3aZlFrwBbazE7aHcxXWyuT97eAObet5jmOL0CjMg0rB1hE4d2SBVYHPfl8De55EiFv307r1QP3Mf5XgOSSCxD3TuD
- SMTP_HOST=smtp.gmail.com - SMTP_HOST=smtp.gmail.com

1
routlin/DESCRIPTION.txt Normal file
View file

@ -0,0 +1 @@
Turn any Linux machine with 2 NICs into an enterprise-grade router and firewall. Ditch vendor gated appliances and opaque firmware while keeping your machine fully multipurpose and under your control. Easily manage VLANs, NAT, DNS, DHCP, VPNs, RADIUS, mDNS, and content filtering through a modern interface built on battle-tested Linux tools like dnsmasq, nftables, systemd-networkd, FreeRADIUS, and WireGuard. Designed to integrate seamlessly with existing enterprise and prosumer networking hardware.

View file

@ -16,8 +16,8 @@ VLAN's dnsmasq instance loads the merged file for its specific combination,
giving true per-VLAN DNS filtering. Blocked domains and all their subdomains giving true per-VLAN DNS filtering. Blocked domains and all their subdomains
return NXDOMAIN via dnsmasq's local=/ syntax. return NXDOMAIN via dnsmasq's local=/ syntax.
nftables rules are applied atomically into dedicated tables (router-nat, nftables rules are applied atomically into dedicated tables (routlin-nat,
router-filter) that do not touch Docker-managed tables. A systemd boot routlin-filter) that do not touch Docker-managed tables. A systemd boot
service (core-nat.service) re-applies the rules on every boot. service (core-nat.service) re-applies the rules on every boot.
File layout: File layout:
@ -25,17 +25,17 @@ File layout:
<save_as> -- raw downloaded blocklist files <save_as> -- raw downloaded blocklist files
merged-<hash>.conf -- merged file per unique blocklist combo merged-<hash>.conf -- merged file per unique blocklist combo
/etc/dnsmasq-router/ /etc/dnsmasq-routlin/
<name>.conf -- per-VLAN dnsmasq config <name>.conf -- per-VLAN dnsmasq config
/etc/systemd/system/ /etc/systemd/system/
dnsmasq-router-<name>.service -- per-VLAN dnsmasq service unit dnsmasq-routlin-<name>.service -- per-VLAN dnsmasq service unit
dns-blocklists-update.timer -- daily blocklist refresh timer routlin-dns-blocklist-update.timer -- daily blocklist refresh timer
dns-blocklists-update.service -- timer service unit routlin-dns-blocklist-update.service -- timer service unit
core-nat.service -- boot service to re-apply nftables rules core-nat.service -- boot service to re-apply nftables rules
/var/lib/misc/ /var/lib/misc/
dnsmasq-router-<name>.leases -- per-VLAN DHCP lease files dnsmasq-routlin-<name>.leases -- per-VLAN DHCP lease files
.dns-metrics -- cumulative lifetime DNS metrics .dns-metrics -- cumulative lifetime DNS metrics
@ -107,19 +107,21 @@ from validation import (
resolve_vlan_derived_fields, validate_config, resolve_vlan_derived_fields, validate_config,
) )
PRODUCT_NAME = "routlin"
SCRIPT_DIR = Path(__file__).parent SCRIPT_DIR = Path(__file__).parent
CONFIG_FILE = SCRIPT_DIR / "core.json" CONFIG_FILE = SCRIPT_DIR / "core.json"
BLOCKLIST_DIR = SCRIPT_DIR / "blocklists" BLOCKLIST_DIR = SCRIPT_DIR / "blocklists"
LOG_FILE = SCRIPT_DIR / "core.log" LOG_FILE = SCRIPT_DIR / "core.log"
METRICS_FILE = SCRIPT_DIR / ".dns-metrics" METRICS_FILE = SCRIPT_DIR / ".dns-metrics"
DNSMASQ_CONF_DIR = Path("/etc/dnsmasq-router") DNSMASQ_CONF_DIR = Path(f"/etc/dnsmasq-{PRODUCT_NAME}")
LEASES_DIR = Path("/var/lib/misc") LEASES_DIR = Path("/var/lib/misc")
NETWORKD_DIR = Path("/etc/systemd/network") NETWORKD_DIR = Path("/etc/systemd/network")
SYSTEMD_DIR = Path("/etc/systemd/system") SYSTEMD_DIR = Path("/etc/systemd/system")
BLIST_TIMER_NAME = "dns-blocklists-update" BLIST_TIMER_NAME = f"{PRODUCT_NAME}-dns-blocklist-update"
BLIST_TIMER_FILE = SYSTEMD_DIR / f"{BLIST_TIMER_NAME}.timer" BLIST_TIMER_FILE = SYSTEMD_DIR / f"{BLIST_TIMER_NAME}.timer"
BLIST_TIMER_SVC_FILE = SYSTEMD_DIR / f"{BLIST_TIMER_NAME}.service" BLIST_TIMER_SVC_FILE = SYSTEMD_DIR / f"{BLIST_TIMER_NAME}.service"
DASHB_TIMER_NAME = "router-dashboard-queue" DASHB_TIMER_NAME = f"{PRODUCT_NAME}-dashboard-queue"
DASHB_TIMER_FILE = SYSTEMD_DIR / f"{DASHB_TIMER_NAME}.timer" DASHB_TIMER_FILE = SYSTEMD_DIR / f"{DASHB_TIMER_NAME}.timer"
DASHB_TIMER_SVC_FILE = SYSTEMD_DIR / f"{DASHB_TIMER_NAME}.service" DASHB_TIMER_SVC_FILE = SYSTEMD_DIR / f"{DASHB_TIMER_NAME}.service"
DASHB_TIMER_INTERVAL_SEC = 60 DASHB_TIMER_INTERVAL_SEC = 60
@ -253,12 +255,12 @@ def is_physical(vlan):
return vlan["vlan_id"] == 1 return vlan["vlan_id"] == 1
def networkd_stem(vlan): def networkd_stem(vlan):
return f"10-router-{vlan['name']}" return f"10-{PRODUCT_NAME}-{vlan['name']}"
def vlan_service_name(vlan): def vlan_service_name(vlan):
if is_wg(vlan): if is_wg(vlan):
return f"dnsmasq-router-{vlan['name']}-{vlan['interface']}" return f"dnsmasq-{PRODUCT_NAME}-{vlan['name']}-{vlan['interface']}"
return f"dnsmasq-router-{vlan['name']}" return f"dnsmasq-{PRODUCT_NAME}-{vlan['name']}"
def vlan_service_file(vlan): def vlan_service_file(vlan):
return SYSTEMD_DIR / f"{vlan_service_name(vlan)}.service" return SYSTEMD_DIR / f"{vlan_service_name(vlan)}.service"
@ -267,10 +269,10 @@ def vlan_conf_file(vlan):
return DNSMASQ_CONF_DIR / f"{vlan['name']}.conf" return DNSMASQ_CONF_DIR / f"{vlan['name']}.conf"
def vlan_leases_file(vlan): def vlan_leases_file(vlan):
return LEASES_DIR / f"dnsmasq-router-{vlan['name']}.leases" return LEASES_DIR / f"dnsmasq-{PRODUCT_NAME}-{vlan['name']}.leases"
def vlan_pid_file(vlan): def vlan_pid_file(vlan):
return Path("/run") / f"dnsmasq-router-{vlan['name']}.pid" return Path("/run") / f"dnsmasq-{PRODUCT_NAME}-{vlan['name']}.pid"
# nftables rule list helpers # nftables rule list helpers
def rule_enabled(rules): def rule_enabled(rules):
@ -352,7 +354,7 @@ def find_legacy_files(managed_interfaces):
to_remove = [] to_remove = []
for pattern in ("*.network", "*.netdev"): for pattern in ("*.network", "*.netdev"):
for f in NETWORKD_DIR.glob(pattern): for f in NETWORKD_DIR.glob(pattern):
if f.name.startswith("10-router-"): if f.name.startswith(f"10-{PRODUCT_NAME}-"):
continue continue
try: try:
content = f.read_text() content = f.read_text()
@ -1153,12 +1155,12 @@ def apply_dnsmasq_instances(data, dry_run=False, start_if_needed=True):
subprocess.run(["systemctl", "daemon-reload"], capture_output=True, text=True) subprocess.run(["systemctl", "daemon-reload"], capture_output=True, text=True)
# Remove stale service units (VLANs removed from config) # Remove stale service units (VLANs removed from config)
for f in SYSTEMD_DIR.glob("dnsmasq-router-*.service"): for f in SYSTEMD_DIR.glob(f"dnsmasq-{PRODUCT_NAME}-*.service"):
if f.stem not in active_service_stems: if f.stem not in active_service_stems:
subprocess.run(["systemctl", "disable", "--now", f.stem], subprocess.run(["systemctl", "disable", "--now", f.stem],
capture_output=True, text=True) capture_output=True, text=True)
f.unlink() f.unlink()
n = f.stem.removeprefix("dnsmasq-router-") n = f.stem.removeprefix(f"dnsmasq-{PRODUCT_NAME}-")
stale_conf = DNSMASQ_CONF_DIR / f"{n}.conf" stale_conf = DNSMASQ_CONF_DIR / f"{n}.conf"
if stale_conf.exists(): if stale_conf.exists():
stale_conf.unlink() stale_conf.unlink()
@ -1530,10 +1532,10 @@ def build_nft_config(data, dry_run=False):
line() line()
# ========================================================================== # ==========================================================================
# router-nat table # {PRODUCT_NAME}-nat table
# ========================================================================== # ==========================================================================
line("table ip router-nat {") line(f"table ip {PRODUCT_NAME}-nat {{")
line() line()
line(" chain prerouting {") line(" chain prerouting {")
line(" type nat hook prerouting priority dstnat - 10; policy accept;") line(" type nat hook prerouting priority dstnat - 10; policy accept;")
@ -1574,10 +1576,10 @@ def build_nft_config(data, dry_run=False):
line() line()
# ========================================================================== # ==========================================================================
# router-filter table # {PRODUCT_NAME}-filter table
# ========================================================================== # ==========================================================================
line("table ip router-filter {") line(f"table ip {PRODUCT_NAME}-filter {{")
line() line()
if banned_v4: if banned_v4:
@ -1732,7 +1734,7 @@ def build_nft_config(data, dry_run=False):
if banned_v6: if banned_v6:
line() line()
line("table ip6 router-ban {") line(f"table ip6 {PRODUCT_NAME}-ban {{")
line() line()
line(" set banned_ipv6 {") line(" set banned_ipv6 {")
line(" type ipv6_addr") line(" type ipv6_addr")
@ -1767,7 +1769,7 @@ def table_exists(family, name):
return result.returncode == 0 return result.returncode == 0
def delete_our_tables(): def delete_our_tables():
for family, table in [("ip", "router-nat"), ("ip", "router-filter"), ("ip6", "router-ban")]: for family, table in [("ip", f"{PRODUCT_NAME}-nat"), ("ip", f"{PRODUCT_NAME}-filter"), ("ip6", f"{PRODUCT_NAME}-ban")]:
if table_exists(family, table): if table_exists(family, table):
result = subprocess.run( result = subprocess.run(
["nft", "delete", "table", family, table], ["nft", "delete", "table", family, table],
@ -1872,7 +1874,7 @@ def apply_nftables(data, dry_run=False):
print(f" [{r['protocol'].upper():<4}] {src} -> {dst_str} ({r['description']})") print(f" [{r['protocol'].upper():<4}] {src} -> {dst_str} ({r['description']})")
def show_rules(): def show_rules():
for table in ("router-nat", "router-filter"): for table in (f"{PRODUCT_NAME}-nat", f"{PRODUCT_NAME}-filter"):
result = subprocess.run( result = subprocess.run(
["nft", "list", "table", "ip", table], ["nft", "list", "table", "ip", table],
capture_output=True, text=True capture_output=True, text=True
@ -1890,7 +1892,7 @@ def install_nat_service():
script_path = Path(__file__).resolve() script_path = Path(__file__).resolve()
service_content = f"""[Unit] service_content = f"""[Unit]
Description=Apply router NAT and firewall rules Description=Apply {PRODUCT_NAME} NAT and firewall rules
After=network-online.target docker.service After=network-online.target docker.service
Wants=network-online.target docker.service Wants=network-online.target docker.service
@ -2599,7 +2601,7 @@ def disable_all(data):
def _write_client_network(iface, dhcp, static_cidr=None): def _write_client_network(iface, dhcp, static_cidr=None):
"""Remove all router networkd files and write a plain client .network file.""" """Remove all router networkd files and write a plain client .network file."""
for pattern in ("10-router-*.network", "10-router-*.netdev"): for pattern in (f"10-{PRODUCT_NAME}-*.network", f"10-{PRODUCT_NAME}-*.netdev"):
for f in NETWORKD_DIR.glob(pattern): for f in NETWORKD_DIR.glob(pattern):
f.unlink() f.unlink()
print(f"Removed: {f}") print(f"Removed: {f}")
@ -2793,7 +2795,7 @@ def _dry_run_disable(data, iface, use_dhcp, static_cidr, resolv_ok, dns_choice,
print("[DRY RUN] Based on your selections, --disable would perform the following:") print("[DRY RUN] Based on your selections, --disable would perform the following:")
print() print()
print("-- Stopping router services (dry-run) --------------------------------") print(f"-- Stopping {PRODUCT_NAME} services (dry-run) --------------------------------")
print(f" Would disable and stop: {BLIST_TIMER_NAME}.timer") print(f" Would disable and stop: {BLIST_TIMER_NAME}.timer")
for vlan in data["vlans"]: for vlan in data["vlans"]:
svc = vlan_service_name(vlan) svc = vlan_service_name(vlan)
@ -2805,7 +2807,7 @@ def _dry_run_disable(data, iface, use_dhcp, static_cidr, resolv_ok, dns_choice,
if svc_f.exists(): if svc_f.exists():
print(f" Would remove: {svc_f}") print(f" Would remove: {svc_f}")
print(f" Would reload: systemd daemon") print(f" Would reload: systemd daemon")
for table in ("router-nat", "router-filter"): for table in (f"{PRODUCT_NAME}-nat", f"{PRODUCT_NAME}-filter"):
r = subprocess.run(["nft", "list", "table", "ip", table], r = subprocess.run(["nft", "list", "table", "ip", table],
capture_output=True, text=True) capture_output=True, text=True)
if r.returncode == 0: if r.returncode == 0:
@ -2833,8 +2835,8 @@ def _dry_run_disable(data, iface, use_dhcp, static_cidr, resolv_ok, dns_choice,
print() print()
print("-- Network interface (dry-run) ----------------------------------------") print("-- Network interface (dry-run) ----------------------------------------")
router_net = list(NETWORKD_DIR.glob("10-router-*.network")) router_net = list(NETWORKD_DIR.glob(f"10-{PRODUCT_NAME}-*.network"))
router_dev = list(NETWORKD_DIR.glob("10-router-*.netdev")) router_dev = list(NETWORKD_DIR.glob(f"10-{PRODUCT_NAME}-*.netdev"))
client_file = NETWORKD_DIR / f"10-client-{iface}.network" client_file = NETWORKD_DIR / f"10-client-{iface}.network"
for f in router_net + router_dev: for f in router_net + router_dev:
print(f" Would remove: {f}") print(f" Would remove: {f}")
@ -3020,7 +3022,7 @@ def cmd_disable(data, dry_run=False):
_dry_run_disable(data, iface, use_dhcp, static_cidr, resolv_ok, dns_choice, static_nameserver) _dry_run_disable(data, iface, use_dhcp, static_cidr, resolv_ok, dns_choice, static_nameserver)
return return
print("-- Stopping router services ------------------------------------------") print(f"-- Stopping {PRODUCT_NAME} services ------------------------------------------")
disable_all(data) disable_all(data)
print() print()

View file

@ -1,8 +1,8 @@
""" """
validation.py -- Shared structural validators for core.json fields. validation.py -- Shared structural validators for core.json fields.
Lives alongside core.py in ~/router/ and is volume-mounted into the Lives alongside core.py in ~/routlin/ and is volume-mounted into the
router-dash container at /app/validation.py. Importable by both routlin-dash container at /app/validation.py. Importable by both
core.py (router host) and the Flask app directly. core.py (router host) and the Flask app directly.
Convention: primitive validators accept a raw string and return the Convention: primitive validators accept a raw string and return the