From 27f2356cd14f1f602eb0bff24f45bf6be8c242de Mon Sep 17 00:00:00 2001 From: Matthew Grotke Date: Sun, 7 Jun 2026 14:21:40 -0400 Subject: [PATCH] Development --- docker/routlin-dash/app/main.py | 2 + docker/routlin-dash/app/navbar.json | 3 +- .../app/pages/captiveportal/__init__.py | 0 .../app/pages/captiveportal/action.py | 53 +++++++++ .../app/pages/captiveportal/content.json | 112 ++++++++++++++++++ .../app/pages/captiveportal/view.py | 26 ++++ docker/routlin-dash/app/settings.py | 2 +- docker/routlin-dash/docker-compose.yml | 1 + routlin/mod_captive.py | 4 +- routlin/mod_nftables.py | 44 ++++++- 10 files changed, 241 insertions(+), 6 deletions(-) create mode 100644 docker/routlin-dash/app/pages/captiveportal/__init__.py create mode 100644 docker/routlin-dash/app/pages/captiveportal/action.py create mode 100644 docker/routlin-dash/app/pages/captiveportal/content.json create mode 100644 docker/routlin-dash/app/pages/captiveportal/view.py diff --git a/docker/routlin-dash/app/main.py b/docker/routlin-dash/app/main.py index 30f40c6..42ea4e5 100644 --- a/docker/routlin-dash/app/main.py +++ b/docker/routlin-dash/app/main.py @@ -25,6 +25,7 @@ from pages.accountmanage.action import bp as accountmanage_bp from pages.mdns.action import bp as mdns_bp from pages.radius.action import bp as radius_bp from pages.clientcredentials.action import bp as clientcredentials_bp +from pages.captiveportal.action import bp as captiveportal_bp from action_accountlogout import bp as accountlogout_bp from api_apply_health import bp as api_apply_health_bp @@ -138,6 +139,7 @@ app.register_blueprint(accountlogout_bp) app.register_blueprint(mdns_bp) app.register_blueprint(radius_bp) app.register_blueprint(clientcredentials_bp) +app.register_blueprint(captiveportal_bp) app.register_blueprint(api_apply_health_bp) diff --git a/docker/routlin-dash/app/navbar.json b/docker/routlin-dash/app/navbar.json index ff00045..953a487 100644 --- a/docker/routlin-dash/app/navbar.json +++ b/docker/routlin-dash/app/navbar.json @@ -25,7 +25,8 @@ { "type": "nav_item", "label": "VPN", "map_to": "vpn" }, { "type": "nav_item", "label": "Banned IPs", "map_to": "bannedips", "client_requirement": "client_is_administrator+" }, { "type": "nav_item", "label": "RADIUS", "map_to": "radius", "client_requirement": "client_is_administrator+" }, - { "type": "nav_item", "label": "Client Credentials", "map_to": "clientcredentials", "client_requirement": "client_is_administrator+" } + { "type": "nav_item", "label": "Client Credentials", "map_to": "clientcredentials", "client_requirement": "client_is_administrator+" }, + { "type": "nav_item", "label": "Captive Portal", "map_to": "captiveportal", "client_requirement": "client_is_administrator+" } ] }, { diff --git a/docker/routlin-dash/app/pages/captiveportal/__init__.py b/docker/routlin-dash/app/pages/captiveportal/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/docker/routlin-dash/app/pages/captiveportal/action.py b/docker/routlin-dash/app/pages/captiveportal/action.py new file mode 100644 index 0000000..9271d79 --- /dev/null +++ b/docker/routlin-dash/app/pages/captiveportal/action.py @@ -0,0 +1,53 @@ +import copy + +from flask import Blueprint, request, redirect, flash +import auth +import config_utils +import sanitize + +_PAGE = 'captiveportal' + +bp = Blueprint(_PAGE, __name__) + + +@bp.route('/action/captiveportal/options_save', methods=['POST']) +@auth.require_level('administrator') +def options_save(): + cfg = config_utils.load_config() + before = copy.deepcopy(cfg.get('captive_portal', {})) + + try: + http_port = int(request.form.get('http_port', '8081')) + if not (1024 <= http_port <= 65535): + raise ValueError + except (ValueError, TypeError): + flash('HTTP port must be between 1024 and 65535.', 'error') + return redirect(f'/{_PAGE}') + + https_domain = sanitize.description(request.form.get('https_domain', '')) + + after = {**before, 'http_port': http_port, 'https_domain': https_domain} + cfg.setdefault('captive_portal', {}).update(after) + changes = config_utils.diff_fields(before, after) + flash(config_utils.record_group( + cfg, 'captive_portal', 'setting', 'captive_portal', changes, 'core apply' + ), 'success') + return redirect(f'/{_PAGE}') + + +@bp.route('/action/captiveportal/splash_save', methods=['POST']) +@auth.require_level('administrator') +def splash_save(): + cfg = config_utils.load_config() + before = copy.deepcopy(cfg.get('captive_portal', {})) + + splash_text = sanitize.description(request.form.get('splash_text', '')) + terms = [t.strip() for t in request.form.getlist('terms') if t.strip()] + + after = {**before, 'splash_text': splash_text, 'terms': terms} + cfg.setdefault('captive_portal', {}).update(after) + changes = config_utils.diff_fields(before, after) + flash(config_utils.record_group( + cfg, 'captive_portal', 'setting', 'captive_portal', changes, 'core apply' + ), 'success') + return redirect(f'/{_PAGE}') diff --git a/docker/routlin-dash/app/pages/captiveportal/content.json b/docker/routlin-dash/app/pages/captiveportal/content.json new file mode 100644 index 0000000..10958f5 --- /dev/null +++ b/docker/routlin-dash/app/pages/captiveportal/content.json @@ -0,0 +1,112 @@ +{ + "client_requirement": "client_is_administrator+", + "items": [ + { + "type": "header_page_title", + "items": [ + { + "type": "h1", + "text": "Captive Portal" + }, + { + "type": "p", + "text": "Redirect unauthenticated guests on captive portal VLANs to a login page before allowing internet access." + } + ] + }, + { + "type": "info_bar", + "variant": "info", + "text": "%CAPTIVE_STATUS_TEXT%" + }, + { + "type": "card", + "label": "Options", + "client_requirement": "client_is_administrator+", + "items": [ + { + "type": "form", + "action": "/action/captiveportal/options_save", + "method": "post", + "items": [ + { + "type": "field", + "label": "HTTP Port", + "name": "http_port", + "input_type": "number", + "value": "%CAPTIVE_HTTP_PORT%", + "min": 1024, + "max": 65535, + "hint": "Port the captive portal service listens on. nftables redirects unauthenticated HTTP traffic from captive portal VLANs to this port." + }, + { + "type": "field", + "label": "HTTPS Domain", + "name": "https_domain", + "input_type": "text", + "value": "%CAPTIVE_HTTPS_DOMAIN%", + "hint": "Domain with an existing Caddy + Let's Encrypt certificate. The portal login page is served at https:///. Leave blank for HTTP-only." + }, + { + "type": "button_row", + "items": [ + { + "type": "button_primary", + "text": "Save" + }, + { + "type": "button_cancel", + "text": "Cancel" + } + ] + } + ] + } + ] + }, + { + "type": "card", + "label": "Splash Screen", + "client_requirement": "client_is_administrator+", + "items": [ + { + "type": "form", + "action": "/action/captiveportal/splash_save", + "method": "post", + "items": [ + { + "type": "field", + "label": "Welcome Text", + "name": "splash_text", + "input_type": "text", + "value": "%CAPTIVE_SPLASH_TEXT%", + "hint": "Welcome message shown at the top of the login page." + }, + { + "type": "editable_list", + "label": "Terms", + "name": "terms", + "items": "%CAPTIVE_TERMS%", + "add_label": "Add Term", + "item_placeholder": "e.g. I agree to the acceptable use policy.", + "hint": "Each term renders as a required checkbox the user must tick before submitting credentials." + }, + { + "type": "button_row", + "items": [ + { + "type": "button_primary", + "text": "Save" + }, + { + "type": "button_cancel", + "text": "Cancel" + } + ] + } + ] + } + ] + } + ] +} diff --git a/docker/routlin-dash/app/pages/captiveportal/view.py b/docker/routlin-dash/app/pages/captiveportal/view.py new file mode 100644 index 0000000..019f2f8 --- /dev/null +++ b/docker/routlin-dash/app/pages/captiveportal/view.py @@ -0,0 +1,26 @@ +import json + +import config_utils +import factory + + +def collect_tokens(cfg): + tokens = config_utils.collect_layout_tokens(cfg) + cp = cfg.get('captive_portal', {}) + captive_vlans = [v for v in cfg.get('vlans', []) if v.get('restricted_vlan') == 'c'] + + if captive_vlans: + names = ', '.join(v['name'] for v in captive_vlans) + tokens['CAPTIVE_STATUS_TEXT'] = f"Captive portal active on: {names}." + else: + tokens['CAPTIVE_STATUS_TEXT'] = ( + 'No captive portal VLANs configured. ' + 'Set Restricted VLAN = Captive Portal on the Network Layout page.' + ) + + tokens['CAPTIVE_HTTP_PORT'] = str(cp.get('http_port', 8081)) + tokens['CAPTIVE_HTTPS_DOMAIN'] = cp.get('https_domain', '') + tokens['CAPTIVE_SPLASH_TEXT'] = cp.get('splash_text', '') + tokens['CAPTIVE_TERMS'] = json.dumps(cp.get('terms', [])) + + return tokens diff --git a/docker/routlin-dash/app/settings.py b/docker/routlin-dash/app/settings.py index 0688863..1464d1d 100644 --- a/docker/routlin-dash/app/settings.py +++ b/docker/routlin-dash/app/settings.py @@ -2,7 +2,7 @@ import os def is_production(): - return os.environ.get('PRODUCTION_MODE', '').lower() in ('1', 'true', 'yes') + return not os.environ.get('DEV_MODE', '').lower() in ('1', 'true', 'yes') def is_pro(): diff --git a/docker/routlin-dash/docker-compose.yml b/docker/routlin-dash/docker-compose.yml index f8a88c4..84caf93 100644 --- a/docker/routlin-dash/docker-compose.yml +++ b/docker/routlin-dash/docker-compose.yml @@ -26,4 +26,5 @@ services: - SMTP_PASSWORD=lfhrygyuwvlaczaw - SMTP_FROM=grotek.industries@gmail.com - LICENSE=asdf + - DEV_MODE=true restart: unless-stopped diff --git a/routlin/mod_captive.py b/routlin/mod_captive.py index 5e30461..3fe03b1 100644 --- a/routlin/mod_captive.py +++ b/routlin/mod_captive.py @@ -8,8 +8,8 @@ CAPTIVE_QUEUE_FILE = shared.SCRIPT_DIR / ".captive-queue" CAPTIVE_DB_FILE = shared.SCRIPT_DIR / ".client-credentials" # nftables table and set that hold authenticated client IPs -CAPTIVE_NFT_FAMILY = "inet" -CAPTIVE_NFT_TABLE = "filter" +CAPTIVE_NFT_FAMILY = "ip" +CAPTIVE_NFT_TABLE = f"{shared.PRODUCT_NAME}-filter" CAPTIVE_NFT_SET = "captive_allowed" diff --git a/routlin/mod_nftables.py b/routlin/mod_nftables.py index 7cb2c80..b9b540d 100644 --- a/routlin/mod_nftables.py +++ b/routlin/mod_nftables.py @@ -242,6 +242,7 @@ def build_nft_config(data, dry_run=False): all_except = rule_enabled(data.get("inter_vlan_exceptions", [])) banned_v4, banned_v6 = banned_ip_sets(data) container_bridges = get_container_bridges() + captive_vlans = [v for v in vlans if v.get('restricted_vlan') == 'c'] L = [ "# Generated by core.py -- do not edit manually.", @@ -314,6 +315,14 @@ def build_nft_config(data, dry_run=False): "", ] + if captive_vlans: + L += [ + " set captive_allowed {", + " type ipv4_addr", + " }", + "", + ] + # INPUT chain L += [ " # INPUT -- traffic destined for this machine itself", @@ -439,7 +448,19 @@ def build_nft_config(data, dry_run=False): L.append(f" iif \"{validation.derive_interface(vlan, data)}\" oif \"{wan}\" drop # {vlan['name']} -> WAN (quarantined)") L.append("") - # TODO: captive portal VLANs ('c') - PREROUTING REDIRECT rules for HTTP/HTTPS + dynamic allow-set + if captive_vlans: + L.append(" # Allow authenticated captive clients -> WAN") + for vlan in captive_vlans: + iface = validation.derive_interface(vlan, data) + L.append(f" iif \"{iface}\" oif \"{wan}\" ip saddr @captive_allowed accept" + f" # {vlan['name']} authenticated -> WAN") + L.append("") + L.append(" # Block unauthenticated captive clients -> WAN") + for vlan in captive_vlans: + iface = validation.derive_interface(vlan, data) + L.append(f" iif \"{iface}\" oif \"{wan}\" drop" + f" # {vlan['name']} -> WAN (captive, unauthenticated)") + L.append("") L += [ " # Allow Docker containers -> WAN (outbound internet access)", @@ -505,9 +526,28 @@ def build_nft_config(data, dry_run=False): " type filter hook output priority filter; policy accept;", " }", "", - "}", ] + if captive_vlans: + http_port = data.get('captive_portal', {}).get('http_port', 8081) + L += [ + " chain captive_prerouting {", + f" type nat hook prerouting priority dstnat + 1; policy accept;", + "", + ] + for vlan in captive_vlans: + iface = validation.derive_interface(vlan, data) + L += [ + f" # Captive portal redirect - {vlan['name']}", + f" iif \"{iface}\" ip saddr != @captive_allowed tcp dport 80" + f" redirect to :{http_port}", + f" iif \"{iface}\" ip saddr != @captive_allowed tcp dport 443 drop", + "", + ] + L += [" }", ""] + + L.append("}") + if banned_v6: elements = ", ".join(banned_v6) L += [