From a4eb431f22f6574db214c06449c16a2f1446038a Mon Sep 17 00:00:00 2001 From: Matthew Grotke Date: Sat, 6 Jun 2026 23:22:02 -0400 Subject: [PATCH] Development --- docker/routlin-dash/app/config_utils.py | 1 + docker/routlin-dash/app/main.py | 2 + docker/routlin-dash/app/navbar.json | 3 +- .../app/pages/clientcredentials/__init__.py | 0 .../app/pages/clientcredentials/action.py | 301 ++++++++++++++++++ .../app/pages/clientcredentials/content.json | 213 +++++++++++++ .../app/pages/clientcredentials/view.py | 96 ++++++ routlin/check_captive_users.py | 65 ++++ routlin/core.py | 10 + routlin/mod_captive.py | 17 + routlin/mod_timers.py | 37 +++ 11 files changed, 744 insertions(+), 1 deletion(-) create mode 100644 docker/routlin-dash/app/pages/clientcredentials/__init__.py create mode 100644 docker/routlin-dash/app/pages/clientcredentials/action.py create mode 100644 docker/routlin-dash/app/pages/clientcredentials/content.json create mode 100644 docker/routlin-dash/app/pages/clientcredentials/view.py create mode 100755 routlin/check_captive_users.py create mode 100644 routlin/mod_captive.py diff --git a/docker/routlin-dash/app/config_utils.py b/docker/routlin-dash/app/config_utils.py index 94093d2..ea480f5 100644 --- a/docker/routlin-dash/app/config_utils.py +++ b/docker/routlin-dash/app/config_utils.py @@ -14,6 +14,7 @@ DASHBOARD_LAST_RUN = f'{CONFIGS_DIR}/.dashboard-last-run' DASHBOARD_LOCK = f'{CONFIGS_DIR}/.dashboard-lock' DASHBOARD_PENDING = f'{CONFIGS_DIR}/.dashboard-pending' DASHBOARD_DB = f'{CONFIGS_DIR}/.dashboard-snapshots' +CREDENTIALS_DB = f'{CONFIGS_DIR}/.client-credentials' HEALTH_FILE = f'{CONFIGS_DIR}/.health' BLOCKLISTS_DIR = f'{CONFIGS_DIR}/blocklists' PRODUCT_NAME = os.environ.get('PRODUCT_NAME', 'routlin') diff --git a/docker/routlin-dash/app/main.py b/docker/routlin-dash/app/main.py index 250656f..7c1d819 100644 --- a/docker/routlin-dash/app/main.py +++ b/docker/routlin-dash/app/main.py @@ -30,6 +30,7 @@ from pages.accountcreate.action import bp as accountcreate_bp 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 action_accountlogout import bp as accountlogout_bp from api_apply_health import bp as api_apply_health_bp @@ -142,6 +143,7 @@ app.register_blueprint(accountmanage_bp) app.register_blueprint(accountlogout_bp) app.register_blueprint(mdns_bp) app.register_blueprint(radius_bp) +app.register_blueprint(clientcredentials_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 53f19fb..ff00045 100644 --- a/docker/routlin-dash/app/navbar.json +++ b/docker/routlin-dash/app/navbar.json @@ -24,7 +24,8 @@ { "type": "nav_item", "label": "Host Overrides", "map_to": "hostoverrides", "client_requirement": "client_is_administrator+" }, { "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": "RADIUS", "map_to": "radius", "client_requirement": "client_is_administrator+" }, + { "type": "nav_item", "label": "Client Credentials", "map_to": "clientcredentials", "client_requirement": "client_is_administrator+" } ] }, { diff --git a/docker/routlin-dash/app/pages/clientcredentials/__init__.py b/docker/routlin-dash/app/pages/clientcredentials/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/docker/routlin-dash/app/pages/clientcredentials/action.py b/docker/routlin-dash/app/pages/clientcredentials/action.py new file mode 100644 index 0000000..9e89b2d --- /dev/null +++ b/docker/routlin-dash/app/pages/clientcredentials/action.py @@ -0,0 +1,301 @@ +import hashlib +import sqlite3 +import time +from pathlib import Path + +import bcrypt +from flask import Blueprint, request, redirect, flash +from auth import require_level +from config_utils import CREDENTIALS_DB +import sanitize +import settings as settings + +_PAGE = 'clientcredentials' +PRO_LICENSE = settings.is_pro() + +bp = Blueprint(_PAGE, __name__) + +# Enum values stored in the database +USER_TYPE_CAPTIVE = 0 +USER_TYPE_SUPPLICANT = 1 + +HASH_CLEARTEXT = 0 +HASH_NT = 1 +HASH_BCRYPT = 2 + +VALID_USER_TYPES = {USER_TYPE_CAPTIVE, USER_TYPE_SUPPLICANT} +VALID_HASH_TYPES = {HASH_CLEARTEXT, HASH_NT, HASH_BCRYPT} + +# Compatible hash types per user type +COMPATIBLE_HASHES = { + USER_TYPE_CAPTIVE: {HASH_CLEARTEXT, HASH_BCRYPT}, + USER_TYPE_SUPPLICANT: {HASH_CLEARTEXT, HASH_NT}, +} + + +# =================================================================== +# DB helpers +# =================================================================== + +def _db_conn(): + conn = sqlite3.connect(CREDENTIALS_DB) + conn.row_factory = sqlite3.Row + conn.execute("PRAGMA foreign_keys = ON") + conn.execute(""" + CREATE TABLE IF NOT EXISTS credentials ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + username TEXT NOT NULL UNIQUE COLLATE NOCASE, + password TEXT NOT NULL, + description TEXT NOT NULL DEFAULT '', + user_type INTEGER NOT NULL, + hash_type INTEGER NOT NULL, + vlan TEXT NOT NULL DEFAULT '', + enabled INTEGER NOT NULL DEFAULT 1, + date_set INTEGER NOT NULL, + valid_for INTEGER DEFAULT NULL + ) + """) + conn.execute(""" + CREATE TABLE IF NOT EXISTS sessions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + ip TEXT NOT NULL, + credential_id INTEGER REFERENCES credentials(id) ON DELETE CASCADE, + started_at INTEGER NOT NULL, + expires_at INTEGER, + mac TEXT NOT NULL DEFAULT '' + ) + """) + conn.commit() + return conn + + +def _get_by_index(conn, row_index): + rows = conn.execute("SELECT * FROM credentials ORDER BY id").fetchall() + if row_index < 0 or row_index >= len(rows): + return None + return rows[row_index] + + +def _hash_password(plaintext, hash_type): + if hash_type == HASH_CLEARTEXT: + return plaintext + if hash_type == HASH_NT: + try: + return hashlib.new('md4', plaintext.encode('utf-16-le')).hexdigest() + except ValueError: + raise ValueError("NT-Password requires MD4 support. It may be disabled on this system's OpenSSL build.") + if hash_type == HASH_BCRYPT: + return bcrypt.hashpw(plaintext.encode(), bcrypt.gensalt()).decode() + raise ValueError(f"Unknown hash_type: {hash_type}") + + +def _parse_valid_for(value_str, unit_str): + """Return valid_for in seconds (int) or None for no expiry.""" + unit_str = (unit_str or '').strip() + if unit_str == 'never' or not value_str or not value_str.strip(): + return None + try: + n = int(value_str.strip()) + if n < 1: + return None + except (ValueError, TypeError): + return None + multipliers = {'hours': 3600, 'days': 86400} + mult = multipliers.get(unit_str) + if mult is None: + return None + return n * mult + + +def _valid_for_to_display(valid_for): + """Return (value_str, unit_str) for form pre-population.""" + if valid_for is None: + return '', 'never' + if valid_for % 86400 == 0: + return str(valid_for // 86400), 'days' + if valid_for % 3600 == 0: + return str(valid_for // 3600), 'hours' + return str(valid_for // 3600 or 1), 'hours' + + +def _row_index(): + try: + return int(request.form.get('row_index', '')) + except (ValueError, TypeError): + return None + + +# =================================================================== +# Routes +# =================================================================== + +@bp.route('/action/clientcredentials/addedit', methods=['POST']) +@require_level('administrator') +def addedit(): + if not PRO_LICENSE: + flash('Client Credentials requires a Routlin Pro license.', 'error') + return redirect(f'/{_PAGE}') + + ri = _row_index() + is_edit = ri is not None + username = sanitize.name(request.form.get('username', '')) + password = request.form.get('password', '').strip() + description = sanitize.description(request.form.get('description', '')) + + try: + user_type = int(request.form.get('user_type', '')) + hash_type = int(request.form.get('hash_type', '')) + except (ValueError, TypeError): + flash('Invalid user type or hash type.', 'error') + return redirect(f'/{_PAGE}') + + if user_type not in VALID_USER_TYPES: + flash('Invalid user type.', 'error') + return redirect(f'/{_PAGE}') + if hash_type not in VALID_HASH_TYPES: + flash('Invalid hash type.', 'error') + return redirect(f'/{_PAGE}') + if hash_type not in COMPATIBLE_HASHES[user_type]: + flash('Selected hash type is not compatible with the selected user type.', 'error') + return redirect(f'/{_PAGE}') + + vlan = sanitize.name(request.form.get('vlan', '')) if user_type == USER_TYPE_SUPPLICANT else '' + if user_type == USER_TYPE_SUPPLICANT and not vlan: + flash('VLAN is required for 802.1X supplicant credentials.', 'error') + return redirect(f'/{_PAGE}') + + enabled = 'enabled' in request.form + valid_for = _parse_valid_for( + request.form.get('valid_for_value', ''), + request.form.get('valid_for_unit', 'never'), + ) + + if not username: + flash('Username is required.', 'error') + return redirect(f'/{_PAGE}') + + conn = _db_conn() + + if is_edit: + existing = _get_by_index(conn, ri) + if not existing: + conn.close() + flash('Credential not found.', 'error') + return redirect(f'/{_PAGE}') + + if password: + try: + hashed = _hash_password(password, hash_type) + except ValueError as exc: + conn.close() + flash(str(exc), 'error') + return redirect(f'/{_PAGE}') + stored_password = hashed + stored_hash_type = hash_type + date_set = int(time.time()) + else: + stored_password = existing['password'] + stored_hash_type = existing['hash_type'] + date_set = existing['date_set'] + + try: + conn.execute( + """UPDATE credentials + SET username=?, password=?, description=?, user_type=?, hash_type=?, + vlan=?, enabled=?, date_set=?, valid_for=? + WHERE id=?""", + (username, stored_password, description, user_type, stored_hash_type, + vlan, int(enabled), date_set, valid_for, existing['id']), + ) + conn.commit() + except sqlite3.IntegrityError: + conn.close() + flash(f"Username '{username}' is already taken.", 'error') + return redirect(f'/{_PAGE}') + conn.close() + flash(f"Credential '{username}' updated.", 'success') + + else: + if not password: + flash('Password is required when adding a credential.', 'error') + conn.close() + return redirect(f'/{_PAGE}') + + try: + hashed = _hash_password(password, hash_type) + except ValueError as exc: + conn.close() + flash(str(exc), 'error') + return redirect(f'/{_PAGE}') + + try: + conn.execute( + """INSERT INTO credentials + (username, password, description, user_type, hash_type, vlan, enabled, date_set, valid_for) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)""", + (username, hashed, description, user_type, hash_type, + vlan, int(enabled), int(time.time()), valid_for), + ) + conn.commit() + except sqlite3.IntegrityError: + conn.close() + flash(f"Username '{username}' already exists.", 'error') + return redirect(f'/{_PAGE}') + conn.close() + flash(f"Credential '{username}' added.", 'success') + + return redirect(f'/{_PAGE}') + + +@bp.route('/action/clientcredentials/delete', methods=['POST']) +@require_level('administrator') +def delete(): + if not PRO_LICENSE: + flash('Client Credentials requires a Routlin Pro license.', 'error') + return redirect(f'/{_PAGE}') + + ri = _row_index() + if ri is None: + flash('Invalid request.', 'error') + return redirect(f'/{_PAGE}') + + conn = _db_conn() + row = _get_by_index(conn, ri) + if not row: + conn.close() + flash('Credential not found.', 'error') + return redirect(f'/{_PAGE}') + + conn.execute("DELETE FROM credentials WHERE id=?", (row['id'],)) + conn.commit() + conn.close() + flash(f"Credential '{row['username']}' deleted.", 'success') + return redirect(f'/{_PAGE}') + + +@bp.route('/action/clientcredentials/toggle', methods=['POST']) +@require_level('administrator') +def toggle(): + if not PRO_LICENSE: + flash('Client Credentials requires a Routlin Pro license.', 'error') + return redirect(f'/{_PAGE}') + + ri = _row_index() + if ri is None: + flash('Invalid request.', 'error') + return redirect(f'/{_PAGE}') + + conn = _db_conn() + row = _get_by_index(conn, ri) + if not row: + conn.close() + flash('Credential not found.', 'error') + return redirect(f'/{_PAGE}') + + new_enabled = 0 if row['enabled'] else 1 + conn.execute("UPDATE credentials SET enabled=? WHERE id=?", (new_enabled, row['id'])) + conn.commit() + conn.close() + state = 'enabled' if new_enabled else 'disabled' + flash(f"Credential '{row['username']}' {state}.", 'success') + return redirect(f'/{_PAGE}') diff --git a/docker/routlin-dash/app/pages/clientcredentials/content.json b/docker/routlin-dash/app/pages/clientcredentials/content.json new file mode 100644 index 0000000..b2ae20d --- /dev/null +++ b/docker/routlin-dash/app/pages/clientcredentials/content.json @@ -0,0 +1,213 @@ +{ + "client_requirement": "client_is_viewer+", + "items": [ + { + "type": "header_page_title", + "items": [ + { + "type": "h1", + "text": "Client Credentials" + }, + { + "type": "p", + "text": "Username and password credentials for 802.1X supplicants and captive portal authentication." + } + ] + }, + { + "type": "raw_html", + "html": "%PRO_NOTE%" + }, + { + "type": "table", + "datasource": "sqlite:client_credentials", + "empty_message": "No credentials configured.", + "columns": [ + { + "label": "Username", + "field": "username", + "class": "col-mono" + }, + { + "label": "Description", + "field": "description" + }, + { + "label": "Type", + "field": "user_type_label", + "class": "col-narrow" + }, + { + "label": "Hash", + "field": "hash_type_label", + "class": "col-narrow" + }, + { + "label": "VLAN", + "field": "vlan", + "class": "col-narrow col-mono" + }, + { + "label": "Enabled", + "field": "enabled", + "class": "col-narrow", + "render": "badge_toggle", + "toggle_action": "/action/clientcredentials/toggle", + "client_requirement": "client_is_administrator+" + }, + { + "label": "Expires", + "field": "expires_label", + "class": "col-narrow" + } + ], + "row_actions": [ + { + "client_requirement": "client_is_administrator+", + "method": "js_edit", + "target": "add-form", + "text": "Edit", + "class": "btn-ghost btn-sm" + }, + { + "client_requirement": "client_is_administrator+", + "action": "/action/clientcredentials/delete", + "method": "post", + "text": "Delete", + "class": "btn-danger btn-sm" + } + ] + }, + { + "type": "card", + "id": "add-form", + "label": "Add Credential", + "client_requirement": "client_is_administrator+", + "items": [ + { + "type": "form", + "action": "/action/clientcredentials/addedit", + "method": "post", + "items": [ + { + "type": "hidden", + "name": "row_index", + "value": "" + }, + { + "type": "field_row", + "cols": 2, + "items": [ + { + "type": "field", + "label": "Username", + "name": "username", + "input_type": "text", + "validate": "VALIDATION_DASH_NAME", + "hint": "Lowercase letters, digits, and hyphens." + }, + { + "type": "field", + "label": "Password", + "name": "password", + "input_type": "password", + "hint": "Leave blank when editing to keep the existing password. Changing hash type also requires a new password." + } + ] + }, + { + "type": "field", + "label": "Description", + "name": "description", + "input_type": "text", + "hint": "Optional label." + }, + { + "type": "hr" + }, + { + "type": "field_row", + "cols": 2, + "items": [ + { + "type": "field", + "label": "User Type", + "name": "user_type", + "input_type": "select", + "options": [ + {"value": "0", "label": "Captive Portal"}, + {"value": "1", "label": "802.1X Supplicant"} + ] + }, + { + "type": "field", + "label": "Hash Type", + "name": "hash_type", + "input_type": "select", + "options": [] + } + ] + }, + { + "type": "field", + "label": "VLAN", + "name": "vlan", + "input_type": "select", + "options": "%VLAN_OPTIONS%", + "hint": "VLAN to assign after 802.1X authentication." + }, + { + "type": "hr" + }, + { + "type": "field", + "label": "Enabled", + "name": "enabled", + "input_type": "checkbox" + }, + { + "type": "field_row", + "cols": 2, + "items": [ + { + "type": "field", + "label": "Valid For", + "name": "valid_for_value", + "input_type": "number", + "min": 1, + "hint": "How long this credential is valid after creation. Leave blank for no expiry." + }, + { + "type": "field", + "label": "Unit", + "name": "valid_for_unit", + "input_type": "select", + "options": [ + {"value": "never", "label": "Never (no expiry)"}, + {"value": "hours", "label": "Hours"}, + {"value": "days", "label": "Days"} + ] + } + ] + }, + { + "type": "button_row", + "items": [ + { + "type": "button_primary", + "text": "Add Credential", + "class": "add-credential-btn", + "disabled": "%ADD_CREDENTIAL_DISABLED%" + }, + { + "type": "button_cancel", + "text": "Cancel" + } + ] + } + ] + } + ] + } + ] +} diff --git a/docker/routlin-dash/app/pages/clientcredentials/view.py b/docker/routlin-dash/app/pages/clientcredentials/view.py new file mode 100644 index 0000000..8e542d9 --- /dev/null +++ b/docker/routlin-dash/app/pages/clientcredentials/view.py @@ -0,0 +1,96 @@ +import json +import sqlite3 +import time +import datetime + +from config_utils import collect_layout_tokens, CREDENTIALS_DB +from factory import load_json, build_table, table_token_key, iter_table_items, PAGES_DIR +import settings as settings + +PRO_LICENSE = settings.is_pro() + +USER_TYPE_LABELS = {0: 'Captive Portal', 1: '802.1X'} +HASH_TYPE_LABELS = {0: 'Cleartext', 1: 'NT-Password', 2: 'Bcrypt'} + + +def _load_credentials(): + try: + conn = sqlite3.connect(CREDENTIALS_DB) + conn.row_factory = sqlite3.Row + conn.execute(""" + CREATE TABLE IF NOT EXISTS credentials ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + username TEXT NOT NULL UNIQUE COLLATE NOCASE, + password TEXT NOT NULL, + description TEXT NOT NULL DEFAULT '', + user_type INTEGER NOT NULL, + hash_type INTEGER NOT NULL, + vlan TEXT NOT NULL DEFAULT '', + enabled INTEGER NOT NULL DEFAULT 1, + date_set INTEGER NOT NULL, + valid_for INTEGER DEFAULT NULL + ) + """) + conn.execute(""" + CREATE TABLE IF NOT EXISTS sessions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + ip TEXT NOT NULL, + credential_id INTEGER REFERENCES credentials(id) ON DELETE CASCADE, + started_at INTEGER NOT NULL, + expires_at INTEGER, + mac TEXT NOT NULL DEFAULT '' + ) + """) + conn.commit() + rows = conn.execute("SELECT * FROM credentials ORDER BY id").fetchall() + conn.close() + return rows + except Exception: + return [] + + +def _format_expiry(date_set, valid_for): + if valid_for is None: + return 'Never' + expires_ts = date_set + valid_for + now = int(time.time()) + if expires_ts <= now: + return 'Expired' + dt = datetime.datetime.fromtimestamp(expires_ts) + return dt.strftime('%Y-%m-%d %H:%M') + + +def collect_tokens(cfg): + tokens = collect_layout_tokens(cfg) + + tokens['PRO_NOTE'] = ( + '' if PRO_LICENSE else + '
' + 'Client Credentials is a Routlin Pro feature. ' + 'Credentials can be viewed but not added or edited without a Pro license.
' + ) + tokens['ADD_CREDENTIAL_DISABLED'] = '' if PRO_LICENSE else 'true' + + vlans = [v for v in cfg.get('vlans', []) if not v.get('is_vpn')] + tokens['VLAN_OPTIONS'] = json.dumps( + [{'value': '', 'label': '— Select VLAN —'}] + + [{'value': v['name'], 'label': f"{v['name']} (VLAN {v['vlan_id']})"} for v in vlans] + ) + + raw_rows = _load_credentials() + display_rows = [] + for row in raw_rows: + r = dict(row) + r.pop('password', None) + r['user_type_label'] = USER_TYPE_LABELS.get(r.get('user_type'), str(r.get('user_type', ''))) + r['hash_type_label'] = HASH_TYPE_LABELS.get(r.get('hash_type'), str(r.get('hash_type', ''))) + r['expires_label'] = _format_expiry(r.get('date_set', 0), r.get('valid_for')) + display_rows.append(r) + + content = load_json(f'{PAGES_DIR}/clientcredentials/content.json') + for table_item in iter_table_items(content.get('items', [])): + ds = table_item.get('datasource', '') + data = display_rows if ds == 'sqlite:client_credentials' else [] + tokens[table_token_key(ds)] = build_table(table_item, tokens, data) + + return tokens diff --git a/routlin/check_captive_users.py b/routlin/check_captive_users.py new file mode 100755 index 0000000..e03e2c5 --- /dev/null +++ b/routlin/check_captive_users.py @@ -0,0 +1,65 @@ +#!/usr/bin/env python3 +""" +check_captive_users.py -- Expire captive portal sessions. + +Runs every 5 minutes (systemd timer installed by core.py --apply). +Queries .client-credentials for sessions past their expiry time, +deletes them, and appends disallow commands to .captive-queue so +do_captive_queue.sh removes the corresponding nftables entries. +""" + +import sys +import time +import sqlite3 +from pathlib import Path + +SCRIPT_DIR = Path(__file__).parent +DB_FILE = SCRIPT_DIR / ".client-credentials" +QUEUE_FILE = SCRIPT_DIR / ".captive-queue" + + +def main(): + if not DB_FILE.exists(): + return + + try: + conn = sqlite3.connect(DB_FILE) + conn.row_factory = sqlite3.Row + except Exception as e: + print(f"check_captive_users: cannot open {DB_FILE}: {e}", file=sys.stderr) + return + + now = int(time.time()) + + try: + expired_ips = [ + row["ip"] + for row in conn.execute( + "SELECT ip FROM sessions WHERE expires_at IS NOT NULL AND expires_at <= ?", + (now,), + ) + ] + except sqlite3.OperationalError: + conn.close() + return + + if not expired_ips: + conn.close() + return + + conn.execute( + "DELETE FROM sessions WHERE expires_at IS NOT NULL AND expires_at <= ?", + (now,), + ) + conn.commit() + conn.close() + + lines = "".join(f"disallow {ip}\n" for ip in expired_ips) + with open(QUEUE_FILE, "a") as f: + f.write(lines) + + print(f"check_captive_users: queued disallow for {len(expired_ips)} expired session(s).") + + +if __name__ == "__main__": + main() diff --git a/routlin/core.py b/routlin/core.py index a11cc4a..3deaac3 100644 --- a/routlin/core.py +++ b/routlin/core.py @@ -97,6 +97,7 @@ from pathlib import Path import health as health import mod_avahi as avahi +import mod_captive as captive import mod_dnsmasq as dnsmasq import mod_metrics as metrics import mod_networkd as networkd @@ -816,6 +817,15 @@ def cmd_apply(data, dry_run=False): avahi.disable_avahi() print() + print("Captive portal ==============================================") + if captive.captive_portal_enabled(data): + timers.install_captive_timers() + print("Captive portal enabled - timers installed.") + else: + timers.remove_captive_timers() + print("No captive portal VLANs - timers removed.") + print() + print("Done.") healthy, status = health.run_and_write(data) diff --git a/routlin/mod_captive.py b/routlin/mod_captive.py new file mode 100644 index 0000000..5e30461 --- /dev/null +++ b/routlin/mod_captive.py @@ -0,0 +1,17 @@ +""" +mod_captive.py -- Captive portal state and path constants. +""" + +import mod_shared as shared + +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_SET = "captive_allowed" + + +def captive_portal_enabled(data): + return any(v.get("restricted_vlan") == "c" for v in data.get("vlans", [])) diff --git a/routlin/mod_timers.py b/routlin/mod_timers.py index 3e44e0b..d7305bd 100644 --- a/routlin/mod_timers.py +++ b/routlin/mod_timers.py @@ -22,6 +22,16 @@ HEALTH_TIMER_FILE = shared.SYSTEMD_DIR / f"{HEALTH_TIMER_NAME}.timer" HEALTH_TIMER_SVC_FILE = shared.SYSTEMD_DIR / f"{HEALTH_TIMER_NAME}.service" HEALTH_TIMER_INTERVAL_SEC = 300 +CAPTIVE_QUEUE_TIMER_NAME = f"{shared.PRODUCT_NAME}-captive-queue" +CAPTIVE_QUEUE_TIMER_FILE = shared.SYSTEMD_DIR / f"{CAPTIVE_QUEUE_TIMER_NAME}.timer" +CAPTIVE_QUEUE_TIMER_SVC_FILE = shared.SYSTEMD_DIR / f"{CAPTIVE_QUEUE_TIMER_NAME}.service" +CAPTIVE_QUEUE_TIMER_INTERVAL = 10 + +CAPTIVE_CHECK_TIMER_NAME = f"{shared.PRODUCT_NAME}-captive-check" +CAPTIVE_CHECK_TIMER_FILE = shared.SYSTEMD_DIR / f"{CAPTIVE_CHECK_TIMER_NAME}.timer" +CAPTIVE_CHECK_TIMER_SVC_FILE = shared.SYSTEMD_DIR / f"{CAPTIVE_CHECK_TIMER_NAME}.service" +CAPTIVE_CHECK_TIMER_INTERVAL = 300 + # =================================================================== # Blocklist timer @@ -212,3 +222,30 @@ def install_maint_timer(data): subprocess.run(["systemctl"] + verb.split() + [f"{MAINT_TIMER_NAME}.timer"], capture_output=True, text=True) print(f"Timer {MAINT_TIMER_NAME}.timer enabled (runs every {interval}).") + + +# =================================================================== +# Captive portal timers +# =================================================================== + +def install_captive_timers(): + install_interval_timers( + names=[CAPTIVE_QUEUE_TIMER_NAME, CAPTIVE_CHECK_TIMER_NAME], + timer_files=[CAPTIVE_QUEUE_TIMER_FILE, CAPTIVE_CHECK_TIMER_FILE], + svc_files=[CAPTIVE_QUEUE_TIMER_SVC_FILE, CAPTIVE_CHECK_TIMER_SVC_FILE], + descriptions=["Captive portal queue processor", "Captive portal session expiry checker"], + exec_starts=[ + f"/bin/bash {shared.SCRIPT_DIR / 'do_captive_queue.sh'}", + f"/usr/bin/python3 {shared.SCRIPT_DIR / 'check_captive_users.py'}", + ], + interval_secs=[CAPTIVE_QUEUE_TIMER_INTERVAL, CAPTIVE_CHECK_TIMER_INTERVAL], + ) + + +def remove_captive_timers(): + remove_timers( + names=[CAPTIVE_QUEUE_TIMER_NAME, CAPTIVE_CHECK_TIMER_NAME], + timer_files=[CAPTIVE_QUEUE_TIMER_FILE, CAPTIVE_CHECK_TIMER_FILE], + svc_files=[CAPTIVE_QUEUE_TIMER_SVC_FILE, CAPTIVE_CHECK_TIMER_SVC_FILE], + daemon_reload=True, + )