Development
This commit is contained in:
parent
8766c6c9a2
commit
ee31a18ac6
43 changed files with 54 additions and 48 deletions
|
|
@ -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)
|
||||
|
|
@ -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(
|
||||
|
|
@ -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
|
||||
|
||||
|
|
@ -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'
|
||||
|
|
@ -13,7 +13,7 @@
|
|||
"items": [
|
||||
{
|
||||
"type": "h1",
|
||||
"text": "Router Dashboard"
|
||||
"text": "Routlin Dashboard"
|
||||
},
|
||||
{
|
||||
"type": "p",
|
||||
|
|
@ -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
1
routlin/DESCRIPTION.txt
Normal 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.
|
||||
|
|
@ -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()
|
||||
|
||||
|
|
@ -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
|
||||
Loading…
Add table
Add a link
Reference in a new issue