Development
This commit is contained in:
parent
99447b4987
commit
27f2356cd1
10 changed files with 241 additions and 6 deletions
|
|
@ -25,6 +25,7 @@ from pages.accountmanage.action import bp as accountmanage_bp
|
||||||
from pages.mdns.action import bp as mdns_bp
|
from pages.mdns.action import bp as mdns_bp
|
||||||
from pages.radius.action import bp as radius_bp
|
from pages.radius.action import bp as radius_bp
|
||||||
from pages.clientcredentials.action import bp as clientcredentials_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 action_accountlogout import bp as accountlogout_bp
|
||||||
from api_apply_health import bp as api_apply_health_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(mdns_bp)
|
||||||
app.register_blueprint(radius_bp)
|
app.register_blueprint(radius_bp)
|
||||||
app.register_blueprint(clientcredentials_bp)
|
app.register_blueprint(clientcredentials_bp)
|
||||||
|
app.register_blueprint(captiveportal_bp)
|
||||||
app.register_blueprint(api_apply_health_bp)
|
app.register_blueprint(api_apply_health_bp)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -25,7 +25,8 @@
|
||||||
{ "type": "nav_item", "label": "VPN", "map_to": "vpn" },
|
{ "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": "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": "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+" }
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
|
||||||
0
docker/routlin-dash/app/pages/captiveportal/__init__.py
Normal file
0
docker/routlin-dash/app/pages/captiveportal/__init__.py
Normal file
53
docker/routlin-dash/app/pages/captiveportal/action.py
Normal file
53
docker/routlin-dash/app/pages/captiveportal/action.py
Normal 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}')
|
||||||
112
docker/routlin-dash/app/pages/captiveportal/content.json
Normal file
112
docker/routlin-dash/app/pages/captiveportal/content.json
Normal 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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
26
docker/routlin-dash/app/pages/captiveportal/view.py
Normal file
26
docker/routlin-dash/app/pages/captiveportal/view.py
Normal 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
|
||||||
|
|
@ -2,7 +2,7 @@ import os
|
||||||
|
|
||||||
|
|
||||||
def is_production():
|
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():
|
def is_pro():
|
||||||
|
|
|
||||||
|
|
@ -26,4 +26,5 @@ services:
|
||||||
- SMTP_PASSWORD=lfhrygyuwvlaczaw
|
- SMTP_PASSWORD=lfhrygyuwvlaczaw
|
||||||
- SMTP_FROM=grotek.industries@gmail.com
|
- SMTP_FROM=grotek.industries@gmail.com
|
||||||
- LICENSE=asdf
|
- LICENSE=asdf
|
||||||
|
- DEV_MODE=true
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
|
||||||
|
|
@ -8,8 +8,8 @@ CAPTIVE_QUEUE_FILE = shared.SCRIPT_DIR / ".captive-queue"
|
||||||
CAPTIVE_DB_FILE = shared.SCRIPT_DIR / ".client-credentials"
|
CAPTIVE_DB_FILE = shared.SCRIPT_DIR / ".client-credentials"
|
||||||
|
|
||||||
# nftables table and set that hold authenticated client IPs
|
# nftables table and set that hold authenticated client IPs
|
||||||
CAPTIVE_NFT_FAMILY = "inet"
|
CAPTIVE_NFT_FAMILY = "ip"
|
||||||
CAPTIVE_NFT_TABLE = "filter"
|
CAPTIVE_NFT_TABLE = f"{shared.PRODUCT_NAME}-filter"
|
||||||
CAPTIVE_NFT_SET = "captive_allowed"
|
CAPTIVE_NFT_SET = "captive_allowed"
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -242,6 +242,7 @@ def build_nft_config(data, dry_run=False):
|
||||||
all_except = rule_enabled(data.get("inter_vlan_exceptions", []))
|
all_except = rule_enabled(data.get("inter_vlan_exceptions", []))
|
||||||
banned_v4, banned_v6 = banned_ip_sets(data)
|
banned_v4, banned_v6 = banned_ip_sets(data)
|
||||||
container_bridges = get_container_bridges()
|
container_bridges = get_container_bridges()
|
||||||
|
captive_vlans = [v for v in vlans if v.get('restricted_vlan') == 'c']
|
||||||
|
|
||||||
L = [
|
L = [
|
||||||
"# Generated by core.py -- do not edit manually.",
|
"# 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
|
# INPUT chain
|
||||||
L += [
|
L += [
|
||||||
" # INPUT -- traffic destined for this machine itself",
|
" # 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(f" iif \"{validation.derive_interface(vlan, data)}\" oif \"{wan}\" drop # {vlan['name']} -> WAN (quarantined)")
|
||||||
L.append("")
|
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 += [
|
L += [
|
||||||
" # Allow Docker containers -> WAN (outbound internet access)",
|
" # 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;",
|
" 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:
|
if banned_v6:
|
||||||
elements = ", ".join(banned_v6)
|
elements = ", ".join(banned_v6)
|
||||||
L += [
|
L += [
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue