Development

This commit is contained in:
Matthew Grotke 2026-06-07 14:21:40 -04:00
parent 99447b4987
commit 27f2356cd1
10 changed files with 241 additions and 6 deletions

View file

@ -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)

View file

@ -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+" }
]
},
{

View file

@ -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}')

View file

@ -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://<domain>/. 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"
}
]
}
]
}
]
}
]
}

View file

@ -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

View file

@ -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():

View file

@ -26,4 +26,5 @@ services:
- SMTP_PASSWORD=lfhrygyuwvlaczaw
- SMTP_FROM=grotek.industries@gmail.com
- LICENSE=asdf
- DEV_MODE=true
restart: unless-stopped

View file

@ -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"

View file

@ -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 += [