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 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 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'
lines = [
'# Generated by router-dash',
f'# Generated by {PRODUCT_DISPLAY_NAME}',
'',
'[Interface]',
f'PrivateKey = {private_key}',
@ -125,7 +125,7 @@ def _conf_response(vlan, peer_name, peer_ip, private_key):
iface = _wg_iface(vlan, core)
server_pub = _server_pubkey(iface)
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')
return redirect(_VIEW)
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 email.message import EmailMessage
from auth import require_level
from config_utils import PRODUCT_DISPLAY_NAME
import sanitize
bp = Blueprint('action_create_account', __name__)
@ -31,7 +32,7 @@ def _send_verification_email(to_address, code):
raise RuntimeError('SMTP_HOST is not configured.')
msg = EmailMessage()
msg['Subject'] = 'Router Dashboard - Email Verification'
msg['Subject'] = f'{PRODUCT_DISPLAY_NAME} - Email Verification'
msg['From'] = from_addr
msg['To'] = to_address
msg.set_content(

View file

@ -8,7 +8,8 @@ DASHBOARD_QUEUE = f'{CONFIGS_DIR}/.dashboard-queue'
DASHBOARD_DONE = f'{CONFIGS_DIR}/.dashboard-done'
DASHBOARD_LAST_RUN = f'{CONFIGS_DIR}/.dashboard-last-run'
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
QUEUE_MAX_LINES = 50

View file

@ -4,7 +4,7 @@ import json, re, subprocess, os, sys, html as html_mod
import sanitize
import validation as validate
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__)
@ -1330,9 +1330,9 @@ def _load_datasource(spec):
def render_layout(view_id, content_html, tokens):
css = _load_css()
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)
footer_html = '<footer class="footer">Router Dashboard</footer>'
footer_html = f'<footer class="footer">{PRODUCT_DISPLAY_NAME}</footer>'
page_hash = core_hash()
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'
f' <meta charset="UTF-8">\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'</head>\n<body>\n'
f'{titlebar_html}\n'

View file

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

View file

@ -1,18 +1,19 @@
name: router-dash
name: routlin-dash
services:
flask-app:
container_name: router-dash
container_name: routlin-dash
build: .
ports:
- "25327:25327"
volumes:
- ./data:/data
- $HOME/router:/configs
- $HOME/router/validation.py:/app/validation.py
- $HOME/routlin:/configs
- $HOME/routlin/validation.py:/app/validation.py
- /sys/class/net:/sys/class/net:ro
- /sys/devices:/sys/devices:ro
environment:
- PRODUCT_DISPLAY_NAME=Routlin Dashboard
- INITIAL_MANAGER_EMAIL=mgrotke@gmail.com
- SECRET_KEY=ey8hSQCCYE5kQXV8nOg1CB44LSd3AoUet2ZBc3aZlFrwBbazE7aHcxXWyuT97eAObet5jmOL0CjMg0rB1hE4d2SBVYHPfl8De55EiFv307r1QP3Mf5XgOSSCxD3TuD
- 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
return NXDOMAIN via dnsmasq's local=/ syntax.
nftables rules are applied atomically into dedicated tables (router-nat,
router-filter) that do not touch Docker-managed tables. A systemd boot
nftables rules are applied atomically into dedicated tables (routlin-nat,
routlin-filter) that do not touch Docker-managed tables. A systemd boot
service (core-nat.service) re-applies the rules on every boot.
File layout:
@ -25,17 +25,17 @@ File layout:
<save_as> -- raw downloaded blocklist files
merged-<hash>.conf -- merged file per unique blocklist combo
/etc/dnsmasq-router/
/etc/dnsmasq-routlin/
<name>.conf -- per-VLAN dnsmasq config
/etc/systemd/system/
dnsmasq-router-<name>.service -- per-VLAN dnsmasq service unit
dns-blocklists-update.timer -- daily blocklist refresh timer
dns-blocklists-update.service -- timer service unit
dnsmasq-routlin-<name>.service -- per-VLAN dnsmasq service unit
routlin-dns-blocklist-update.timer -- daily blocklist refresh timer
routlin-dns-blocklist-update.service -- timer service unit
core-nat.service -- boot service to re-apply nftables rules
/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
@ -107,19 +107,21 @@ from validation import (
resolve_vlan_derived_fields, validate_config,
)
PRODUCT_NAME = "routlin"
SCRIPT_DIR = Path(__file__).parent
CONFIG_FILE = SCRIPT_DIR / "core.json"
BLOCKLIST_DIR = SCRIPT_DIR / "blocklists"
LOG_FILE = SCRIPT_DIR / "core.log"
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")
NETWORKD_DIR = Path("/etc/systemd/network")
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_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_SVC_FILE = SYSTEMD_DIR / f"{DASHB_TIMER_NAME}.service"
DASHB_TIMER_INTERVAL_SEC = 60
@ -253,12 +255,12 @@ def is_physical(vlan):
return vlan["vlan_id"] == 1
def networkd_stem(vlan):
return f"10-router-{vlan['name']}"
return f"10-{PRODUCT_NAME}-{vlan['name']}"
def vlan_service_name(vlan):
if is_wg(vlan):
return f"dnsmasq-router-{vlan['name']}-{vlan['interface']}"
return f"dnsmasq-router-{vlan['name']}"
return f"dnsmasq-{PRODUCT_NAME}-{vlan['name']}-{vlan['interface']}"
return f"dnsmasq-{PRODUCT_NAME}-{vlan['name']}"
def vlan_service_file(vlan):
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"
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):
return Path("/run") / f"dnsmasq-router-{vlan['name']}.pid"
return Path("/run") / f"dnsmasq-{PRODUCT_NAME}-{vlan['name']}.pid"
# nftables rule list helpers
def rule_enabled(rules):
@ -352,7 +354,7 @@ def find_legacy_files(managed_interfaces):
to_remove = []
for pattern in ("*.network", "*.netdev"):
for f in NETWORKD_DIR.glob(pattern):
if f.name.startswith("10-router-"):
if f.name.startswith(f"10-{PRODUCT_NAME}-"):
continue
try:
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)
# 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:
subprocess.run(["systemctl", "disable", "--now", f.stem],
capture_output=True, text=True)
f.unlink()
n = f.stem.removeprefix("dnsmasq-router-")
n = f.stem.removeprefix(f"dnsmasq-{PRODUCT_NAME}-")
stale_conf = DNSMASQ_CONF_DIR / f"{n}.conf"
if stale_conf.exists():
stale_conf.unlink()
@ -1530,10 +1532,10 @@ def build_nft_config(data, dry_run=False):
line()
# ==========================================================================
# router-nat table
# {PRODUCT_NAME}-nat table
# ==========================================================================
line("table ip router-nat {")
line(f"table ip {PRODUCT_NAME}-nat {{")
line()
line(" chain prerouting {")
line(" type nat hook prerouting priority dstnat - 10; policy accept;")
@ -1574,10 +1576,10 @@ def build_nft_config(data, dry_run=False):
line()
# ==========================================================================
# router-filter table
# {PRODUCT_NAME}-filter table
# ==========================================================================
line("table ip router-filter {")
line(f"table ip {PRODUCT_NAME}-filter {{")
line()
if banned_v4:
@ -1732,7 +1734,7 @@ def build_nft_config(data, dry_run=False):
if banned_v6:
line()
line("table ip6 router-ban {")
line(f"table ip6 {PRODUCT_NAME}-ban {{")
line()
line(" set banned_ipv6 {")
line(" type ipv6_addr")
@ -1767,7 +1769,7 @@ def table_exists(family, name):
return result.returncode == 0
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):
result = subprocess.run(
["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']})")
def show_rules():
for table in ("router-nat", "router-filter"):
for table in (f"{PRODUCT_NAME}-nat", f"{PRODUCT_NAME}-filter"):
result = subprocess.run(
["nft", "list", "table", "ip", table],
capture_output=True, text=True
@ -1890,7 +1892,7 @@ def install_nat_service():
script_path = Path(__file__).resolve()
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
Wants=network-online.target docker.service
@ -2599,7 +2601,7 @@ def disable_all(data):
def _write_client_network(iface, dhcp, static_cidr=None):
"""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):
f.unlink()
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()
print("-- Stopping router services (dry-run) --------------------------------")
print(f"-- Stopping {PRODUCT_NAME} services (dry-run) --------------------------------")
print(f" Would disable and stop: {BLIST_TIMER_NAME}.timer")
for vlan in data["vlans"]:
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():
print(f" Would remove: {svc_f}")
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],
capture_output=True, text=True)
if r.returncode == 0:
@ -2833,8 +2835,8 @@ def _dry_run_disable(data, iface, use_dhcp, static_cidr, resolv_ok, dns_choice,
print()
print("-- Network interface (dry-run) ----------------------------------------")
router_net = list(NETWORKD_DIR.glob("10-router-*.network"))
router_dev = list(NETWORKD_DIR.glob("10-router-*.netdev"))
router_net = list(NETWORKD_DIR.glob(f"10-{PRODUCT_NAME}-*.network"))
router_dev = list(NETWORKD_DIR.glob(f"10-{PRODUCT_NAME}-*.netdev"))
client_file = NETWORKD_DIR / f"10-client-{iface}.network"
for f in router_net + router_dev:
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)
return
print("-- Stopping router services ------------------------------------------")
print(f"-- Stopping {PRODUCT_NAME} services ------------------------------------------")
disable_all(data)
print()

View file

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