diff --git a/docker/routlin-dash/app/action_save_preferences.py b/docker/routlin-dash/app/action_save_preferences.py deleted file mode 100644 index a5856c5..0000000 --- a/docker/routlin-dash/app/action_save_preferences.py +++ /dev/null @@ -1,47 +0,0 @@ -from flask import Blueprint, request, session, redirect, flash -import json -from auth import require_level -from config_utils import ACCOUNTS_FILE -import sanitize - -bp = Blueprint('action_save_preferences', __name__) - - - -def _load_accounts(): - try: - with open(ACCOUNTS_FILE) as f: - return json.load(f) - except Exception: - return {'accounts': []} - -def _save_accounts(data): - with open(ACCOUNTS_FILE, 'w') as f: - json.dump(data, f, indent=2) - - -@bp.route('/action/save_preferences', methods=['POST']) -@require_level('viewer') -def save_preferences(): - tz = sanitize.timezone(request.form.get('timezone', '').strip()) - - if not tz: - flash('Timezone is required.', 'error') - return redirect('/view/view_preferences') - - email = session.get('email_address', '').lower() - data = _load_accounts() - accounts = data.get('accounts', []) - account = next((a for a in accounts if a.get('email_address', '').lower() == email), None) - - if account is None: - flash('Account not found. Please log in again.', 'error') - return redirect('/view/view_log_in') - - account['timezone'] = tz - _save_accounts(data) - - session['timezone'] = tz - - flash('Preferences saved.', 'success') - return redirect('/view/view_preferences') diff --git a/docker/routlin-dash/app/auth.py b/docker/routlin-dash/app/auth.py index 3f59a44..ae20c47 100644 --- a/docker/routlin-dash/app/auth.py +++ b/docker/routlin-dash/app/auth.py @@ -13,7 +13,7 @@ def require_level(minimum): if LEVEL_RANK.get(current, 0) < LEVEL_RANK.get(minimum, 0): if current == 'nothing': flash('Please log in to continue.', 'error') - return redirect('/view/view_log_in') + return redirect('/view/view_login') flash('You do not have permission to perform this action.', 'error') return redirect('/view/view_overview') return f(*args, **kwargs) diff --git a/docker/routlin-dash/data/authorized_accounts.json b/docker/routlin-dash/app/authorized_accounts.json similarity index 100% rename from docker/routlin-dash/data/authorized_accounts.json rename to docker/routlin-dash/app/authorized_accounts.json diff --git a/docker/routlin-dash/app/config_utils.py b/docker/routlin-dash/app/config_utils.py index 75940a7..2e4a963 100644 --- a/docker/routlin-dash/app/config_utils.py +++ b/docker/routlin-dash/app/config_utils.py @@ -1,11 +1,13 @@ import copy, json, subprocess, hashlib, os, uuid +import os as _os from datetime import datetime, timezone from flask import session +APP_DIR = _os.path.dirname(_os.path.abspath(__file__)) CONFIGS_DIR = '/routlin_location' DATA_DIR = '/data' WWW_DIR = '/www' -ACCOUNTS_FILE = f'{DATA_DIR}/authorized_accounts.json' +ACCOUNTS_FILE = f'{APP_DIR}/authorized_accounts.json' CONFIG_FILE = f'{CONFIGS_DIR}/config.json' DASHBOARD_QUEUE = f'{CONFIGS_DIR}/.dashboard-queue' DASHBOARD_DONE = f'{CONFIGS_DIR}/.dashboard-done' diff --git a/docker/routlin-dash/app/main.py b/docker/routlin-dash/app/main.py index 592a751..9a56f97 100644 --- a/docker/routlin-dash/app/main.py +++ b/docker/routlin-dash/app/main.py @@ -1,60 +1,59 @@ import os, json, sys from flask import Flask +from config_utils import ACCOUNTS_FILE from view_page import bp as view_page_bp -from action_actions import bp as action_actions_bp -from action_physicalinterfaces import bp as action_physicalinterfaces_bp -from action_dnsserver import bp as action_dnsserver_bp -from action_apply_mdns import bp as action_apply_mdns_bp -from action_apply_vpn import bp as action_apply_vpn_bp -from action_apply_banned_ips import bp as action_apply_banned_ips_bp -from action_apply_host_overrides import bp as action_apply_host_overrides_bp -from action_dnsblocking import bp as action_dnsblocking_bp -from action_networklayout import bp as action_networklayout_bp -from action_apply_inter_vlan import bp as action_apply_inter_vlan_bp -from action_apply_port_forwarding import bp as action_apply_port_forwarding_bp -from action_apply_dhcp_reservations import bp as action_apply_dhcp_reservations_bp -from action_create_account import bp as action_create_account_bp -from action_log_in import bp as action_log_in_bp -from action_log_out import bp as action_log_out_bp -from action_verify_email import bp as action_verify_email_bp -from action_add_account import bp as action_add_account_bp -from action_delete_account import bp as action_delete_account_bp -from action_save_preferences import bp as action_save_preferences_bp -from action_change_password import bp as action_change_password_bp -from action_ddns import bp as action_ddns_bp +from pages.actions.action import bp as actions_bp +from pages.bannedips.action import bp as bannedips_bp +from pages.ddns.action import bp as ddns_bp +from pages.dhcp.action import bp as dhcp_bp +from pages.dnsblocking.action import bp as dnsblocking_bp +from pages.dnsserver.action import bp as dnsserver_bp +from pages.hostoverrides.action import bp as hostoverrides_bp +from pages.intervlan.action import bp as intervlan_bp +from pages.accountlogin.action import bp as accountlogin_bp +from pages.networklayout.action import bp as networklayout_bp +from pages.physicalinterfaces.action import bp as physicalinterfaces_bp +from pages.portforwarding.action import bp as portforwarding_bp +from pages.preferences.action import bp as preferences_bp +from pages.accountverifyemail.action import bp as accountverifyemail_bp +from pages.vpn.action import bp as vpn_bp +from pages.accountcreate.action import bp as accountcreate_bp +from pages.accountadd.action import bp as accountadd_bp +from pages.accountdelete.action import bp as accountdelete_bp +from pages.accountlogout.action import bp as accountlogout_bp +from pages.mdns.action import bp as mdns_bp from api_apply_health import bp as api_apply_health_bp app = Flask(__name__) app.secret_key = os.environ.get('SECRET_KEY', os.urandom(24)) app.register_blueprint(view_page_bp) -app.register_blueprint(action_actions_bp) -app.register_blueprint(action_physicalinterfaces_bp) -app.register_blueprint(action_dnsserver_bp) -app.register_blueprint(action_apply_mdns_bp) -app.register_blueprint(action_apply_vpn_bp) -app.register_blueprint(action_apply_banned_ips_bp) -app.register_blueprint(action_apply_host_overrides_bp) -app.register_blueprint(action_dnsblocking_bp) -app.register_blueprint(action_networklayout_bp) -app.register_blueprint(action_apply_inter_vlan_bp) -app.register_blueprint(action_apply_port_forwarding_bp) -app.register_blueprint(action_apply_dhcp_reservations_bp) -app.register_blueprint(action_create_account_bp) -app.register_blueprint(action_log_in_bp) -app.register_blueprint(action_log_out_bp) -app.register_blueprint(action_verify_email_bp) -app.register_blueprint(action_add_account_bp) -app.register_blueprint(action_delete_account_bp) -app.register_blueprint(action_save_preferences_bp) -app.register_blueprint(action_change_password_bp) -app.register_blueprint(action_ddns_bp) +app.register_blueprint(actions_bp) +app.register_blueprint(bannedips_bp) +app.register_blueprint(ddns_bp) +app.register_blueprint(dhcp_bp) +app.register_blueprint(dnsblocking_bp) +app.register_blueprint(dnsserver_bp) +app.register_blueprint(hostoverrides_bp) +app.register_blueprint(intervlan_bp) +app.register_blueprint(accountlogin_bp) +app.register_blueprint(networklayout_bp) +app.register_blueprint(physicalinterfaces_bp) +app.register_blueprint(portforwarding_bp) +app.register_blueprint(preferences_bp) +app.register_blueprint(accountverifyemail_bp) +app.register_blueprint(vpn_bp) +app.register_blueprint(accountcreate_bp) +app.register_blueprint(accountadd_bp) +app.register_blueprint(accountdelete_bp) +app.register_blueprint(accountlogout_bp) +app.register_blueprint(mdns_bp) app.register_blueprint(api_apply_health_bp) def _seed_initial_account(): email = os.environ.get('INITIAL_MANAGER_EMAIL', '').strip().lower() if not email: try: - with open(accounts_file) as f: + with open(ACCOUNTS_FILE) as f: data = json.load(f) except Exception: data = {'accounts': []} @@ -62,9 +61,8 @@ def _seed_initial_account(): print('[main] WARNING: No accounts exist and INITIAL_MANAGER_EMAIL is not set. ' 'Set it in docker-compose.yml to seed the initial manager account.', file=sys.stderr) return - accounts_file = '/data/authorized_accounts.json' try: - with open(accounts_file) as f: + with open(ACCOUNTS_FILE) as f: data = json.load(f) except Exception: data = {'accounts': []} @@ -76,7 +74,7 @@ def _seed_initial_account(): 'hashed_password': '', 'timezone': '', }] - with open(accounts_file, 'w') as f: + with open(ACCOUNTS_FILE, 'w') as f: json.dump(data, f, indent=2) print(f'[main] Seeded initial manager account: {email}', file=sys.stderr) diff --git a/docker/routlin-dash/data/navbar_content.json b/docker/routlin-dash/app/navbar_content.json similarity index 72% rename from docker/routlin-dash/data/navbar_content.json rename to docker/routlin-dash/app/navbar_content.json index f4db5f5..992fe4e 100644 --- a/docker/routlin-dash/data/navbar_content.json +++ b/docker/routlin-dash/app/navbar_content.json @@ -11,17 +11,17 @@ "label": "%MENU_LABEL%", "client_requirement": "client_is_viewer+", "items": [ - { "type": "nav_item", "label": "Physical Interfaces", "map_to": "view_physical_interfaces", "client_requirement": "client_is_administrator+" }, - { "type": "nav_item", "label": "DNS Server", "map_to": "view_dns_server", "client_requirement": "client_is_administrator+" }, - { "type": "nav_item", "label": "DNS Blocking", "map_to": "view_dns_blocking", "client_requirement": "client_is_administrator+" }, - { "type": "nav_item", "label": "Network Layout", "map_to": "view_network_layout", "client_requirement": "client_is_administrator+" }, - { "type": "nav_item", "label": "Inter-VLAN Exceptions", "map_to": "view_inter_vlan", "client_requirement": "client_is_administrator+" }, - { "type": "nav_item", "label": "Port Forwarding", "map_to": "view_port_forwarding", "client_requirement": "client_is_administrator+" }, + { "type": "nav_item", "label": "Physical Interfaces", "map_to": "view_physicalinterfaces", "client_requirement": "client_is_administrator+" }, + { "type": "nav_item", "label": "DNS Server", "map_to": "view_dnsserver", "client_requirement": "client_is_administrator+" }, + { "type": "nav_item", "label": "DNS Blocking", "map_to": "view_dnsblocking", "client_requirement": "client_is_administrator+" }, + { "type": "nav_item", "label": "Network Layout", "map_to": "view_networklayout", "client_requirement": "client_is_administrator+" }, + { "type": "nav_item", "label": "Inter-VLAN Exceptions", "map_to": "view_intervlan", "client_requirement": "client_is_administrator+" }, + { "type": "nav_item", "label": "Port Forwarding", "map_to": "view_portforwarding", "client_requirement": "client_is_administrator+" }, { "type": "nav_item", "label": "DHCP", "map_to": "view_dhcp" }, { "type": "nav_item", "label": "DDNS", "map_to": "view_ddns" }, - { "type": "nav_item", "label": "Host Overrides", "map_to": "view_host_overrides", "client_requirement": "client_is_administrator+" }, + { "type": "nav_item", "label": "Host Overrides", "map_to": "view_hostoverrides", "client_requirement": "client_is_administrator+" }, { "type": "nav_item", "label": "VPN", "map_to": "view_vpn" }, - { "type": "nav_item", "label": "Banned IPs", "map_to": "view_banned_ips", "client_requirement": "client_is_administrator+" } + { "type": "nav_item", "label": "Banned IPs", "map_to": "view_bannedips", "client_requirement": "client_is_administrator+" } ] }, { @@ -37,21 +37,21 @@ "client_requirement": "client_is_viewer+", "items": [ { "type": "nav_item", "label": "Preferences", "map_to": "view_preferences" }, - { "type": "nav_item", "label": "Manage Accounts", "map_to": "view_manage_accounts", "client_requirement": "client_is_manager+" }, + { "type": "nav_item", "label": "Manage Accounts", "map_to": "view_manageaccounts", "client_requirement": "client_is_manager+" }, { "type": "nav_action", "label": "Log Out", "action": "log_out" } ] }, { "type": "nav_item", "label": "Log In", - "map_to": "view_log_in", + "map_to": "view_login", "align": "right", "client_requirement": "client_is_nothing=" }, { "type": "nav_item", "label": "Create Account", - "map_to": "view_create_account", + "map_to": "view_createaccount", "align": "right", "client_requirement": "client_is_nothing=" } diff --git a/docker/routlin-dash/app/pages/__init__.py b/docker/routlin-dash/app/pages/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/docker/routlin-dash/app/pages/accountadd/__init__.py b/docker/routlin-dash/app/pages/accountadd/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/docker/routlin-dash/app/action_add_account.py b/docker/routlin-dash/app/pages/accountadd/action.py similarity index 85% rename from docker/routlin-dash/app/action_add_account.py rename to docker/routlin-dash/app/pages/accountadd/action.py index a6019be..5da658d 100644 --- a/docker/routlin-dash/app/action_add_account.py +++ b/docker/routlin-dash/app/pages/accountadd/action.py @@ -5,7 +5,7 @@ from auth import require_level from config_utils import ACCOUNTS_FILE import sanitize -bp = Blueprint('action_add_account', __name__) +bp = Blueprint('accountadd', __name__) VALID_LEVELS = {'viewer', 'administrator', 'manager'} @@ -31,22 +31,22 @@ def add_account(): if not email: flash('Email address is required.', 'error') - return redirect('/view/view_manage_accounts') + return redirect('/view/view_manageaccounts') if not re.match(r'^[^@\s]+@[^@\s]+\.[^@\s]+$', email): flash('Email address does not appear to be valid.', 'error') - return redirect('/view/view_manage_accounts') + return redirect('/view/view_manageaccounts') if access_level not in VALID_LEVELS: flash('Invalid access level.', 'error') - return redirect('/view/view_manage_accounts') + return redirect('/view/view_manageaccounts') data = _load_accounts() accounts = data.get('accounts', []) if any(a.get('email_address', '').lower() == email for a in accounts): flash('An account with that email address already exists.', 'error') - return redirect('/view/view_manage_accounts') + return redirect('/view/view_manageaccounts') now = datetime.now(tz=timezone.utc).strftime('%Y-%m-%dT%H:%M:%SZ') accounts.append({ @@ -61,4 +61,4 @@ def add_account(): _save_accounts(data) flash(f'Authorization added for {email}. User must complete account setup via the Create Account page.', 'success') - return redirect('/view/view_manage_accounts') + return redirect('/view/view_manageaccounts') diff --git a/docker/routlin-dash/app/pages/accountcreate/__init__.py b/docker/routlin-dash/app/pages/accountcreate/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/docker/routlin-dash/app/action_create_account.py b/docker/routlin-dash/app/pages/accountcreate/action.py similarity index 88% rename from docker/routlin-dash/app/action_create_account.py rename to docker/routlin-dash/app/pages/accountcreate/action.py index ae70e16..32e816d 100644 --- a/docker/routlin-dash/app/action_create_account.py +++ b/docker/routlin-dash/app/pages/accountcreate/action.py @@ -6,7 +6,7 @@ from auth import require_level from config_utils import WEB_APP_DISPLAY_NAME, ACCOUNTS_FILE import sanitize -bp = Blueprint('action_create_account', __name__) +bp = Blueprint('accountcreate', __name__) CODE_TTL_MIN = 15 @@ -62,26 +62,26 @@ def create_account(): if not email or not password or not password_confirm or not tz: flash('All fields are required.', 'error') - return redirect('/view/view_create_account') + return redirect('/view/view_createaccount') if password != password_confirm: flash('Passwords do not match.', 'error') - return redirect('/view/view_create_account') + return redirect('/view/view_createaccount') if len(password) < 8: flash('Password must be at least 8 characters.', 'error') - return redirect('/view/view_create_account') + return redirect('/view/view_createaccount') accounts = _load_accounts().get('accounts', []) account = next((a for a in accounts if a.get('email_address', '').lower() == email), None) if account is None: flash('Email address not recognised. Contact your manager.', 'error') - return redirect('/view/view_create_account') + return redirect('/view/view_createaccount') if account.get('hashed_password'): flash('This account is already set up. Please log in instead.', 'error') - return redirect('/view/view_create_account') + return redirect('/view/view_createaccount') salt = bcrypt.gensalt() hashed = bcrypt.hashpw(password.encode('utf-8'), salt) @@ -92,7 +92,7 @@ def create_account(): _send_verification_email(account['email_address'], code) except Exception as exc: flash(f'Could not send verification email: {exc}', 'error') - return redirect('/view/view_create_account') + return redirect('/view/view_createaccount') session['pending_create_account'] = { 'email': account['email_address'], @@ -102,4 +102,4 @@ def create_account(): 'expires': expires, } - return redirect('/view/view_verify_email') + return redirect('/view/view_verifyemail') diff --git a/docker/routlin-dash/app/pages/accountcreate/content.json b/docker/routlin-dash/app/pages/accountcreate/content.json new file mode 100644 index 0000000..0805471 --- /dev/null +++ b/docker/routlin-dash/app/pages/accountcreate/content.json @@ -0,0 +1,103 @@ +{ + "id": "view_createaccount", + "client_requirement": "client_is_nothing=", + "items": [ + { + "type": "auth_wrapper", + "client_requirement": "client_is_nothing=", + "items": [ + { + "type": "auth_card", + "items": [ + { + "type": "h1", + "text": "Complete Your Account" + }, + { + "type": "p", + "text": "If your email has been pre-registered by a manager, setup your account below." + }, + { + "type": "hr" + }, + { + "type": "form", + "action": "/action/create_account", + "method": "post", + "items": [ + { + "type": "field", + "label": "Email Address", + "name": "email", + "input_type": "text", + "placeholder": "you@example.com", + "hint": "Must match your pre-registered email address." + }, + { + "type": "field", + "label": "New Password", + "name": "password", + "input_type": "password", + "placeholder": "Choose a strong password" + }, + { + "type": "field", + "label": "Confirm Password", + "name": "password_confirm", + "input_type": "password", + "placeholder": "Repeat your password" + }, + { + "type": "field", + "label": "Timezone", + "name": "timezone", + "input_type": "select", + "value": "", + "options": "%TIMEZONE_OPTIONS%", + "hint": "Used to display timestamps in your local time." + }, + { + "type": "button_primary", + "action": "/action/create_account", + "method": "post", + "text": "Create Account", + "class": "btn-full" + } + ] + }, + { + "type": "p", + "text": "Already have an account?", + "link": { + "action": "/view/view_login", + "text": "Log In" + } + } + ] + } + ] + }, + { + "type": "section", + "client_requirement": "client_is_viewer+", + "items": [ + { + "type": "h1", + "text": "Already logged in." + }, + { + "type": "p", + "text": "Your account is already active." + }, + { + "type": "spacer" + }, + { + "type": "button_primary", + "action": "/view/overview", + "text": "Go to Overview" + } + ] + } + ] +} \ No newline at end of file diff --git a/docker/routlin-dash/app/pages/accountdelete/__init__.py b/docker/routlin-dash/app/pages/accountdelete/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/docker/routlin-dash/app/action_delete_account.py b/docker/routlin-dash/app/pages/accountdelete/action.py similarity index 82% rename from docker/routlin-dash/app/action_delete_account.py rename to docker/routlin-dash/app/pages/accountdelete/action.py index ffde63b..e63c42f 100644 --- a/docker/routlin-dash/app/action_delete_account.py +++ b/docker/routlin-dash/app/pages/accountdelete/action.py @@ -3,7 +3,7 @@ import json from auth import require_level from config_utils import ACCOUNTS_FILE -bp = Blueprint('action_delete_account', __name__) +bp = Blueprint('accountdelete', __name__) @@ -26,20 +26,20 @@ def delete_account(): row_index = int(request.form.get('row_index', '')) except (ValueError, TypeError): flash('Invalid request.', 'error') - return redirect('/view/view_manage_accounts') + return redirect('/view/view_manageaccounts') data = _load_accounts() accounts = data.get('accounts', []) if row_index < 0 or row_index >= len(accounts): flash('Account not found.', 'error') - return redirect('/view/view_manage_accounts') + return redirect('/view/view_manageaccounts') target = accounts[row_index] if target.get('email_address', '').lower() == session.get('email_address', '').lower(): flash('You cannot remove your own account.', 'error') - return redirect('/view/view_manage_accounts') + return redirect('/view/view_manageaccounts') removed_email = target.get('email_address', '') accounts.pop(row_index) @@ -47,4 +47,4 @@ def delete_account(): _save_accounts(data) flash(f'Account for {removed_email} has been removed.', 'success') - return redirect('/view/view_manage_accounts') + return redirect('/view/view_manageaccounts') diff --git a/docker/routlin-dash/app/pages/accountlogin/__init__.py b/docker/routlin-dash/app/pages/accountlogin/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/docker/routlin-dash/app/action_log_in.py b/docker/routlin-dash/app/pages/accountlogin/action.py similarity index 88% rename from docker/routlin-dash/app/action_log_in.py rename to docker/routlin-dash/app/pages/accountlogin/action.py index aec1d07..e57584c 100644 --- a/docker/routlin-dash/app/action_log_in.py +++ b/docker/routlin-dash/app/pages/accountlogin/action.py @@ -4,7 +4,7 @@ from auth import require_level from config_utils import ACCOUNTS_FILE import sanitize -bp = Blueprint('action_log_in', __name__) +bp = Blueprint('accountlogin', __name__) @@ -28,23 +28,23 @@ def log_in(): if not email or not password: flash('Email address and password are required.', 'error') - return redirect('/view/view_log_in') + return redirect('/view/view_login') accounts = _load_accounts().get('accounts', []) account = next((a for a in accounts if a.get('email_address', '').lower() == email), None) if account is None: flash('Email address not recognised.', 'error') - return redirect('/view/view_log_in') + return redirect('/view/view_login') if not account.get('hashed_password'): flash('Account setup is not complete. Please use Create Account to set your password first.', 'error') - return redirect('/view/view_log_in') + return redirect('/view/view_login') stored_hash = account['hashed_password'].encode('utf-8') if not bcrypt.checkpw(password.encode('utf-8'), stored_hash): flash('Invalid email address or password.', 'error') - return redirect('/view/view_log_in') + return redirect('/view/view_login') session.clear() session['email_address'] = account['email_address'] diff --git a/docker/routlin-dash/app/pages/accountlogin/content.json b/docker/routlin-dash/app/pages/accountlogin/content.json new file mode 100644 index 0000000..fea107c --- /dev/null +++ b/docker/routlin-dash/app/pages/accountlogin/content.json @@ -0,0 +1,86 @@ +{ + "id": "view_login", + "client_requirement": "client_is_nothing=", + "items": [ + { + "type": "auth_wrapper", + "client_requirement": "client_is_nothing=", + "items": [ + { + "type": "auth_card", + "items": [ + { + "type": "h1", + "text": "Log In" + }, + { + "type": "p", + "text": "Enter your credentials to access the dashboard." + }, + { + "type": "hr" + }, + { + "type": "form", + "action": "/action/log_in", + "method": "post", + "items": [ + { + "type": "field", + "label": "Email Address", + "name": "email", + "input_type": "text", + "placeholder": "you@example.com" + }, + { + "type": "field", + "label": "Password", + "name": "password", + "input_type": "password", + "placeholder": "Password" + }, + { + "type": "button_primary", + "action": "/action/log_in", + "method": "post", + "text": "Log In", + "class": "btn-full" + } + ] + }, + { + "type": "p", + "text": "Need to complete your account?", + "link": { + "action": "/view/view_createaccount", + "text": "Create Account" + } + } + ] + } + ] + }, + { + "type": "section", + "client_requirement": "client_is_viewer+", + "items": [ + { + "type": "h1", + "text": "Already logged in." + }, + { + "type": "p", + "text": "You are already authenticated." + }, + { + "type": "spacer" + }, + { + "type": "button_primary", + "action": "/view/overview", + "text": "Go to Overview" + } + ] + } + ] +} \ No newline at end of file diff --git a/docker/routlin-dash/app/pages/accountlogout/__init__.py b/docker/routlin-dash/app/pages/accountlogout/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/docker/routlin-dash/app/action_log_out.py b/docker/routlin-dash/app/pages/accountlogout/action.py similarity index 84% rename from docker/routlin-dash/app/action_log_out.py rename to docker/routlin-dash/app/pages/accountlogout/action.py index 4afd7d9..d98f82f 100644 --- a/docker/routlin-dash/app/action_log_out.py +++ b/docker/routlin-dash/app/pages/accountlogout/action.py @@ -1,7 +1,7 @@ from flask import Blueprint, session, redirect from auth import require_level -bp = Blueprint('action_log_out', __name__) +bp = Blueprint('accountlogout', __name__) @bp.route('/action/log_out', methods=['POST']) diff --git a/docker/routlin-dash/app/pages/accountmanage/__init__.py b/docker/routlin-dash/app/pages/accountmanage/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/docker/routlin-dash/app/pages/accountmanage/content.json b/docker/routlin-dash/app/pages/accountmanage/content.json new file mode 100644 index 0000000..49c7b91 --- /dev/null +++ b/docker/routlin-dash/app/pages/accountmanage/content.json @@ -0,0 +1,90 @@ +{ + "id": "view_manageaccounts", + "client_requirement": "client_is_manager+", + "items": [ + { + "type": "header_page_title", + "items": [ + { + "type": "h1", + "text": "Manage Accounts" + } + ] + }, + { + "type": "table", + "datasource": "config:accounts", + "empty_message": "No accounts configured.", + "columns": [ + { + "label": "Email Address", + "field": "email_address" + }, + { + "label": "Access Level", + "field": "access_level" + }, + { + "label": "Added By", + "field": "account_created_by" + }, + { + "label": "Added", + "field": "account_created_utc" + }, + { + "label": "Status", + "field": "account_status", + "render": "badge_active_inactive" + } + ], + "row_actions": [ + { + "action": "/action/delete_account", + "method": "post", + "text": "Remove", + "class": "btn-danger btn-sm" + } + ] + }, + { + "type": "card", + "label": "Authorize New Account", + "items": [ + { + "type": "form", + "action": "/action/add_account", + "method": "post", + "items": [ + { + "type": "field", + "label": "Email Address", + "name": "email_address", + "input_type": "text", + "placeholder": "user@example.com", + "hint": "The user will verify ownership of this address during account setup." + }, + { + "type": "field", + "label": "Access Level", + "name": "access_level", + "input_type": "select", + "options": "%ACCOUNT_LEVEL_OPTIONS%" + }, + { + "type": "button_row", + "items": [ + { + "type": "button_primary", + "action": "/action/add_account", + "method": "post", + "text": "Authorize" + } + ] + } + ] + } + ] + } + ] +} \ No newline at end of file diff --git a/docker/routlin-dash/app/pages/accountverifyemail/__init__.py b/docker/routlin-dash/app/pages/accountverifyemail/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/docker/routlin-dash/app/action_verify_email.py b/docker/routlin-dash/app/pages/accountverifyemail/action.py similarity index 86% rename from docker/routlin-dash/app/action_verify_email.py rename to docker/routlin-dash/app/pages/accountverifyemail/action.py index d82181d..75e9b14 100644 --- a/docker/routlin-dash/app/action_verify_email.py +++ b/docker/routlin-dash/app/pages/accountverifyemail/action.py @@ -4,7 +4,7 @@ from datetime import datetime, timezone, timedelta from auth import require_level from config_utils import ACCOUNTS_FILE -bp = Blueprint('action_verify_email', __name__) +bp = Blueprint('accountverifyemail', __name__) @@ -31,18 +31,18 @@ def verify_email(): if not pending: flash('No pending account creation found. Please start over.', 'error') - return redirect('/view/view_create_account') + return redirect('/view/view_createaccount') expires = datetime.fromisoformat(pending['expires']) if datetime.now(tz=timezone.utc) > expires: session.pop('pending_create_account', None) flash('Verification code has expired. Please start over.', 'error') - return redirect('/view/view_create_account') + return redirect('/view/view_createaccount') submitted = request.form.get('code', '').strip() if submitted != pending['code']: flash('Incorrect verification code.', 'error') - return redirect('/view/view_verify_email') + return redirect('/view/view_verifyemail') data = _load_accounts() accounts = data.get('accounts', []) @@ -54,12 +54,12 @@ def verify_email(): if account is None: session.pop('pending_create_account', None) flash('Account no longer exists. Contact your manager.', 'error') - return redirect('/view/view_create_account') + return redirect('/view/view_createaccount') if account.get('hashed_password'): session.pop('pending_create_account', None) flash('This account is already set up. Please log in.', 'error') - return redirect('/view/view_log_in') + return redirect('/view/view_login') now = datetime.now(tz=timezone.utc).strftime('%Y-%m-%dT%H:%M:%SZ') account['hashed_password'] = pending['hashed_password'] @@ -87,13 +87,13 @@ def resend_verification(): if session.get('access_level', 'nothing') != 'nothing': return redirect('/view/view_overview') - from action_create_account import _send_verification_email, CODE_TTL_MIN + from pages.accountcreate.action import _send_verification_email, CODE_TTL_MIN pending = session.get('pending_create_account') if not pending: flash('No pending account creation found. Please start over.', 'error') - return redirect('/view/view_create_account') + return redirect('/view/view_createaccount') code = f'{secrets.randbelow(1000000):06d}' expires = (datetime.now(tz=timezone.utc) + timedelta(minutes=CODE_TTL_MIN)).isoformat() @@ -102,11 +102,11 @@ def resend_verification(): _send_verification_email(pending['email'], code) except Exception as exc: flash(f'Could not resend verification email: {exc}', 'error') - return redirect('/view/view_verify_email') + return redirect('/view/view_verifyemail') pending['code'] = code pending['expires'] = expires session['pending_create_account'] = pending flash('A new verification code has been sent.', 'success') - return redirect('/view/view_verify_email') + return redirect('/view/view_verifyemail') diff --git a/docker/routlin-dash/app/pages/accountverifyemail/content.json b/docker/routlin-dash/app/pages/accountverifyemail/content.json new file mode 100644 index 0000000..fa5a318 --- /dev/null +++ b/docker/routlin-dash/app/pages/accountverifyemail/content.json @@ -0,0 +1,85 @@ +{ + "id": "view_verifyemail", + "client_requirement": "client_is_nothing=", + "items": [ + { + "type": "auth_wrapper", + "client_requirement": "client_is_nothing=", + "items": [ + { + "type": "auth_card", + "items": [ + { + "type": "h1", + "text": "Verify Your Email" + }, + { + "type": "p", + "text": "A 6-digit code was sent to your email address. Enter it below to complete your account." + }, + { + "type": "form", + "action": "/action/verify_email", + "method": "post", + "items": [ + { + "type": "field", + "label": "Verification Code", + "name": "code", + "input_type": "text", + "placeholder": "000000", + "hint": "The code expires in 15 minutes." + }, + { + "type": "button_primary", + "action": "/action/verify_email", + "method": "post", + "text": "Verify", + "class": "btn-full" + } + ] + }, + { + "type": "p", + "text": "Didn't receive it?", + "link": { + "action": "/action/resend_verification", + "text": "Resend code" + } + }, + { + "type": "p", + "text": "Wrong email?", + "link": { + "action": "/view/view_createaccount", + "text": "Start over" + } + } + ] + } + ] + }, + { + "type": "section", + "client_requirement": "client_is_viewer+", + "items": [ + { + "type": "h1", + "text": "Already logged in." + }, + { + "type": "p", + "text": "Your account is already active." + }, + { + "type": "spacer" + }, + { + "type": "button_primary", + "action": "/view/view_overview", + "text": "Go to Overview" + } + ] + } + ] +} \ No newline at end of file diff --git a/docker/routlin-dash/app/pages/actions/__init__.py b/docker/routlin-dash/app/pages/actions/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/docker/routlin-dash/app/action_actions.py b/docker/routlin-dash/app/pages/actions/action.py similarity index 97% rename from docker/routlin-dash/app/action_actions.py rename to docker/routlin-dash/app/pages/actions/action.py index 33033fb..972434f 100644 --- a/docker/routlin-dash/app/action_actions.py +++ b/docker/routlin-dash/app/pages/actions/action.py @@ -3,7 +3,7 @@ from auth import require_level from config_utils import (flush_pending_to_queue, get_dashboard_pending, revert_snapshot_to_config, queued_msg) -bp = Blueprint('action_actions', __name__) +bp = Blueprint('actions', __name__) _VIEW = '/view/view_actions' diff --git a/docker/routlin-dash/app/pages/actions/content.json b/docker/routlin-dash/app/pages/actions/content.json new file mode 100644 index 0000000..4ed8c4e --- /dev/null +++ b/docker/routlin-dash/app/pages/actions/content.json @@ -0,0 +1,112 @@ +{ + "id": "view_actions", + "client_requirement": "client_is_viewer+", + "items": [ + { + "type": "header_page_title", + "items": [ + { + "type": "h1", + "text": "Actions" + }, + { + "type": "p", + "text": "Apply or stage pending configuration changes." + } + ] + }, + { + "type": "card", + "label": "Pending Actions", + "client_requirement": "client_is_administrator+", + "items": [ + { + "type": "form", + "action": "/action/actions_cardpending_applynow", + "method": "post", + "items": [ + { + "type": "raw_html", + "html": "%PENDING_ACTIONS_HTML%" + }, + { + "type": "button_row", + "items": [ + { + "type": "button_primary", + "text": "Apply Now", + "disabled": "%NO_PENDING%" + }, + { + "type": "raw_html", + "html": "%APPLY_WARNING%" + } + ] + } + ] + }, + { + "type": "hr" + }, + { + "type": "form", + "action": "/action/actions_cardpending_save", + "method": "post", + "items": [ + { + "type": "field", + "label": "Apply Changes Immediately", + "name": "apply_changes_immediately", + "input_type": "checkbox", + "value": "%GENERAL_APPLY_ON_SAVE%", + "hint": "When enabled, saved changes are queued immediately. When disabled, changes accumulate in Pending Actions until you click Apply Now." + }, + { + "type": "button_row", + "items": [ + { + "type": "button_primary", + "action": "/action/actions_cardpending_save", + "method": "post", + "text": "Save" + }, + { + "type": "button_cancel", + "text": "Cancel" + } + ] + } + ] + } + ] + }, + { + "type": "card", + "label": "Change History", + "client_requirement": "client_is_administrator+", + "items": [ + { + "type": "form", + "action": "/action/actions_cardhistory_revertselected", + "method": "post", + "items": [ + { + "type": "raw_html", + "html": "%CHANGE_HISTORY_HTML%" + }, + { + "type": "button_row", + "items": [ + { + "type": "button_danger", + "text": "Revert Selected", + "disabled": "%NO_HISTORY%" + } + ] + } + ] + } + ] + } + ] +} \ No newline at end of file diff --git a/docker/routlin-dash/app/pages/bannedips/__init__.py b/docker/routlin-dash/app/pages/bannedips/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/docker/routlin-dash/app/action_apply_banned_ips.py b/docker/routlin-dash/app/pages/bannedips/action.py similarity index 98% rename from docker/routlin-dash/app/action_apply_banned_ips.py rename to docker/routlin-dash/app/pages/bannedips/action.py index 51b5331..6c0af66 100644 --- a/docker/routlin-dash/app/action_apply_banned_ips.py +++ b/docker/routlin-dash/app/pages/bannedips/action.py @@ -6,9 +6,9 @@ from config_utils import load_config, save_config_with_snapshot, verify_config_h import sanitize import validation as validate -bp = Blueprint('action_apply_banned_ips', __name__) +bp = Blueprint('bannedips', __name__) -VIEW = '/view/view_banned_ips' +VIEW = '/view/view_bannedips' def _row_index(): diff --git a/docker/routlin-dash/app/pages/bannedips/content.json b/docker/routlin-dash/app/pages/bannedips/content.json new file mode 100644 index 0000000..7010acd --- /dev/null +++ b/docker/routlin-dash/app/pages/bannedips/content.json @@ -0,0 +1,121 @@ +{ + "id": "view_bannedips", + "client_requirement": "client_is_viewer+", + "items": [ + { + "type": "header_page_title", + "items": [ + { + "type": "h1", + "text": "Banned IPs" + }, + { + "type": "p", + "text": "IPs and ranges blocked in both directions at the nftables firewall." + } + ] + }, + { + "type": "info_bar", + "variant": "info", + "text": "Supports single IPs, CIDR (94.130.0.0/16), wildcards (94.130.*.*), and ranges (94.130.52.1-20). IPv4 and IPv6 are both supported." + }, + { + "type": "table", + "datasource": "config:banned_ips", + "empty_message": "No IP bans configured.", + "columns": [ + { + "label": "Description", + "field": "description" + }, + { + "label": "IP / Range", + "field": "ip", + "class": "col-mono" + }, + { + "label": "Status", + "field": "enabled", + "render": "badge_enabled_disabled" + } + ], + "row_actions": [ + { + "client_requirement": "client_is_administrator+", + "action": "/action/edit_banned_ip", + "method": "inline_edit", + "text": "Edit", + "class": "btn-ghost btn-sm", + "fields": [ + { + "col": "description", + "input_type": "text" + }, + { + "col": "ip", + "input_type": "text" + }, + { + "col": "enabled", + "input_type": "checkbox", + "checkbox_label": "Enabled" + } + ] + }, + { + "client_requirement": "client_is_administrator+", + "action": "/action/delete_banned_ip", + "method": "post", + "text": "Delete", + "class": "btn-danger btn-sm" + } + ] + }, + { + "type": "card", + "id": "add-form", + "label": "Add Banned IP", + "client_requirement": "client_is_administrator+", + "items": [ + { + "type": "form", + "action": "/action/add_banned_ip", + "method": "post", + "items": [ + { + "type": "field", + "label": "Description", + "name": "description", + "input_type": "text", + "placeholder": "e.g. Bad actor", + "hint": "Optional label for this entry." + }, + { + "type": "field", + "label": "IP / Range", + "name": "ip", + "input_type": "text", + "placeholder": "e.g. 1.2.3.4 or 1.2.3.0/24" + }, + { + "type": "button_row", + "items": [ + { + "type": "button_primary", + "action": "/action/add_banned_ip", + "method": "post", + "text": "Add Banned IP" + }, + { + "type": "button_cancel", + "text": "Cancel" + } + ] + } + ] + } + ] + } + ] +} \ No newline at end of file diff --git a/docker/routlin-dash/app/pages/ddns/__init__.py b/docker/routlin-dash/app/pages/ddns/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/docker/routlin-dash/app/action_ddns.py b/docker/routlin-dash/app/pages/ddns/action.py similarity index 99% rename from docker/routlin-dash/app/action_ddns.py rename to docker/routlin-dash/app/pages/ddns/action.py index 9f6e065..b6b837d 100644 --- a/docker/routlin-dash/app/action_ddns.py +++ b/docker/routlin-dash/app/pages/ddns/action.py @@ -6,7 +6,7 @@ from config_utils import load_config, verify_config_hash, save_config_with_snaps import sanitize import validation as validate -bp = Blueprint('action_ddns', __name__) +bp = Blueprint('ddns', __name__) VIEW = '/view/view_ddns' LOG_FILE = f'{CONFIGS_DIR}/ddns.log' diff --git a/docker/routlin-dash/app/pages/ddns/content.json b/docker/routlin-dash/app/pages/ddns/content.json new file mode 100644 index 0000000..b968463 --- /dev/null +++ b/docker/routlin-dash/app/pages/ddns/content.json @@ -0,0 +1,295 @@ +{ + "id": "view_ddns", + "client_requirement": "client_is_viewer+", + "items": [ + { + "type": "header_page_title", + "items": [ + { + "type": "h1", + "text": "DDNS" + }, + { + "type": "p", + "text": "Dynamic DNS provider status and last known IP update." + } + ] + }, + { + "type": "stat_card_grid", + "items": [ + { + "type": "stat_card", + "label": "Current Public IP", + "value": "%STAT_PUBLIC_IP%", + "sub": "%STAT_PUBLIC_IP_LAST_OBTAINED%" + }, + { + "type": "stat_card", + "label": "IP Check Interval", + "value": "%DDNS_TIMER_INTERVAL%", + "sub": "%STAT_PUBLIC_IP_LAST_CHECKED%", + "edit_action": "/action/ddns_cardipcheckinterval_save", + "edit_field": "timer_interval", + "edit_input_type": "number", + "edit_min": "1", + "edit_suffix": "minutes", + "edit_value": "%DDNS_TIMER_INTERVAL_MINS%" + }, + { + "type": "stat_card", + "label": "IP Check Services", + "value": "%STAT_IP_CHECK_TOTAL%", + "sub": "%STAT_IP_CHECK_SUB%", + "reveal_card_id": "ip-check-services-edit" + } + ] + }, + { + "type": "card", + "id": "ip-check-services-edit", + "label": "IP Check Services", + "hidden": true, + "client_requirement": "client_is_administrator+", + "items": [ + { + "type": "form", + "action": "/action/ddns_cardipcheckservices_save", + "method": "post", + "items": [ + { + "type": "editable_list", + "label": "HTTP APIs", + "name": "http_services", + "item_placeholder": "https://...", + "add_label": "Add HTTP API", + "items": "%IP_CHECK_HTTP_JSON%" + }, + { + "type": "editable_list", + "label": "Dig APIs", + "name": "dig_services", + "item_placeholder": "e.g. @1.1.1.1 ch txt whoami.cloudflare", + "add_label": "Add Dig API", + "items": "%IP_CHECK_DIG_JSON%" + }, + { + "type": "button_row", + "items": [ + { + "type": "button_primary", + "action": "/action/ddns_cardipcheckservices_save", + "method": "post", + "text": "Save" + }, + { + "type": "button_cancel", + "text": "Cancel", + "class": "js-hide-card" + } + ] + } + ] + } + ] + }, + { + "type": "table", + "datasource": "config:ddns_providers", + "empty_message": "No DDNS providers configured.", + "columns": [ + { + "label": "Description", + "field": "description" + }, + { + "label": "Provider", + "field": "provider" + }, + { + "label": "Hostname(s)", + "field": "hostnames", + "render": "tag_list" + }, + { + "label": "Status", + "field": "enabled", + "render": "badge_enabled_disabled" + }, + { + "label": "Credentials", + "field": "credentials", + "render": "raw_html" + } + ], + "row_actions": [ + { + "client_requirement": "client_is_administrator+", + "action": "/action/ddns_tableaccounts_rowedit", + "method": "inline_edit", + "text": "Edit", + "class": "btn-ghost btn-sm", + "fields": [ + { + "col": "description", + "input_type": "text" + }, + { + "col": "provider", + "input_type": "select", + "options": "%DDNS_PROVIDER_OPTIONS%" + }, + { + "col": "hostnames", + "input_type": "textarea" + }, + { + "col": "enabled", + "input_type": "checkbox" + }, + { + "col": "credentials", + "input_type": "credentials" + } + ] + }, + { + "client_requirement": "client_is_administrator+", + "action": "/action/ddns_tableaccounts_rowdelete", + "method": "post", + "text": "Delete", + "class": "btn-danger btn-sm" + } + ] + }, + { + "type": "card", + "label": "Add DDNS Account", + "client_requirement": "client_is_administrator+", + "items": [ + { + "type": "form", + "action": "/action/ddns_cardaddaccount_add", + "method": "post", + "items": [ + { + "type": "field", + "label": "Description", + "name": "description", + "input_type": "text", + "placeholder": "e.g. My DuckDNS Account" + }, + { + "type": "field", + "label": "Provider", + "name": "provider", + "input_type": "select", + "options": "%DDNS_PROVIDER_OPTIONS%" + }, + { + "type": "field", + "label": "Hostnames (one per line)", + "name": "hostnames", + "input_type": "textarea", + "placeholder": "e.g. myhome.duckdns.org" + }, + { + "type": "credential_fields", + "provider_select": "provider" + }, + { + "type": "button_row", + "items": [ + { + "type": "button_primary", + "action": "/action/ddns_cardaddaccount_add", + "method": "post", + "text": "Add Provider" + }, + { + "type": "button_cancel", + "text": "Cancel" + } + ] + } + ] + } + ] + }, + { + "type": "card", + "label": "Logging", + "client_requirement": "client_is_administrator+", + "items": [ + { + "type": "pre_block", + "text": "%DDNS_LOG_TAIL%", + "scroll_to_bottom": true + }, + { + "type": "raw_html", + "html": "%DDNS_LOG_SUMMARY%" + }, + { + "type": "button_row", + "justify": "space-between", + "items": [ + { + "type": "button_ghost", + "action": "/action/ddns_cardlogging_download", + "text": "Download Log" + }, + { + "type": "button_danger", + "action": "/action/ddns_cardlogging_clear", + "method": "post", + "text": "Clear Log" + } + ] + }, + { + "type": "hr" + }, + { + "type": "form", + "action": "/action/ddns_cardlogging_save", + "method": "post", + "items": [ + { + "type": "field", + "label": "Max Log Size (KB)", + "name": "log_max_kb", + "input_type": "number", + "layout": "inline", + "value": "%DDNS_GEN_LOG_MAX_KB%", + "min": "64" + }, + { + "type": "field", + "label": "", + "name": "log_errors_only", + "input_type": "checkbox", + "checkbox_label": "Only record errors to log", + "value": "%DDNS_GEN_LOG_ERRORS_ONLY%" + }, + { + "type": "button_row", + "items": [ + { + "type": "button_primary", + "action": "/action/ddns_cardlogging_save", + "method": "post", + "text": "Save" + }, + { + "type": "button_cancel", + "text": "Cancel" + } + ] + } + ] + } + ] + } + ] +} \ No newline at end of file diff --git a/docker/routlin-dash/app/pages/dhcp/__init__.py b/docker/routlin-dash/app/pages/dhcp/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/docker/routlin-dash/app/action_apply_dhcp_reservations.py b/docker/routlin-dash/app/pages/dhcp/action.py similarity index 99% rename from docker/routlin-dash/app/action_apply_dhcp_reservations.py rename to docker/routlin-dash/app/pages/dhcp/action.py index f6fb336..d3c9e26 100644 --- a/docker/routlin-dash/app/action_apply_dhcp_reservations.py +++ b/docker/routlin-dash/app/pages/dhcp/action.py @@ -7,7 +7,7 @@ from config_utils import load_config, save_config_with_snapshot, verify_config_h import sanitize import validation as validate -bp = Blueprint('action_apply_dhcp_reservations', __name__) +bp = Blueprint('dhcp', __name__) VIEW = '/view/view_dhcp' diff --git a/docker/routlin-dash/app/pages/dhcp/content.json b/docker/routlin-dash/app/pages/dhcp/content.json new file mode 100644 index 0000000..257a37e --- /dev/null +++ b/docker/routlin-dash/app/pages/dhcp/content.json @@ -0,0 +1,221 @@ +{ + "id": "view_dhcp", + "client_requirement": "client_is_viewer+", + "items": [ + { + "type": "header_page_title", + "items": [ + { + "type": "h1", + "text": "DHCP" + }, + { + "type": "p", + "text": "Active leases, IP reservations, and VLAN authorizations." + } + ] + }, + { + "type": "table", + "datasource": "live:dhcp_leases", + "empty_message": "No active DHCP leases found.", + "columns": [ + { + "label": "Hostname", + "field": "hostname" + }, + { + "label": "IP Address", + "field": "ip_address", + "class": "col-mono" + }, + { + "label": "MAC Address", + "field": "mac_address", + "class": "col-mono" + }, + { + "label": "VLAN", + "field": "vlan_name" + }, + { + "label": "Expires", + "field": "expires" + } + ] + }, + { + "type": "table", + "datasource": "config:dhcp_reservations", + "empty_message": "No DHCP reservations configured.", + "columns": [ + { + "label": "Description", + "field": "description" + }, + { + "label": "Hostname", + "field": "hostname", + "class": "col-mono" + }, + { + "label": "MAC", + "field": "mac", + "class": "col-mono" + }, + { + "label": "IP", + "field": "ip", + "class": "col-mono" + }, + { + "label": "VLAN", + "field": "vlan_name" + }, + { + "label": "RADIUS", + "field": "radius_client", + "render": "badge_yes_no" + }, + { + "label": "Status", + "field": "enabled", + "render": "badge_enabled_disabled" + } + ], + "toolbar": { + "items": [ + { + "type": "select", + "name": "vlan_filter", + "value": "all", + "options": "%VLAN_FILTER_OPTIONS%", + "filter_col": "vlan_name" + } + ] + }, + "row_actions": [ + { + "client_requirement": "client_is_administrator+", + "action": "/action/edit_dhcp_reservation", + "method": "inline_edit", + "text": "Edit", + "class": "btn-ghost btn-sm", + "fields": [ + { + "col": "description", + "input_type": "text" + }, + { + "col": "hostname", + "input_type": "text", + "validate": "networkname" + }, + { + "col": "mac", + "input_type": "text", + "validate": "mac" + }, + { + "col": "ip", + "input_type": "text" + }, + { + "col": "radius_client", + "input_type": "checkbox", + "checkbox_label": "Enabled" + }, + { + "col": "enabled", + "input_type": "checkbox", + "checkbox_label": "Enabled" + } + ] + }, + { + "client_requirement": "client_is_administrator+", + "action": "/action/delete_dhcp_reservation", + "method": "post", + "text": "Delete", + "class": "btn-danger btn-sm" + } + ] + }, + { + "type": "card", + "id": "add-form", + "label": "Add Reservation/Authorization", + "client_requirement": "client_is_administrator+", + "items": [ + { + "type": "form", + "action": "/action/add_dhcp_reservation", + "method": "post", + "items": [ + { + "type": "field", + "label": "VLAN", + "name": "vlan_name", + "input_type": "select", + "options": "%VLAN_NAMES_AS_OPTIONS%", + "hint": "VLAN this reservation belongs to." + }, + { + "type": "field", + "label": "Description", + "name": "description", + "input_type": "text", + "placeholder": "e.g. NAS" + }, + { + "type": "field", + "label": "Hostname", + "name": "hostname", + "input_type": "text", + "validate": "networkname", + "placeholder": "e.g. nas" + }, + { + "type": "field", + "label": "MAC Address", + "name": "mac", + "input_type": "text", + "validate": "mac", + "placeholder": "e.g. aa:bb:cc:dd:ee:ff" + }, + { + "type": "field", + "label": "IP Address", + "name": "ip", + "input_type": "text", + "placeholder": "e.g. 192.168.10.50", + "hint": "Leave blank to authorize device on this VLAN dynamically." + }, + { + "type": "field", + "label": "RADIUS Client", + "name": "radius_client", + "input_type": "checkbox", + "hint": "This device acts as a RADIUS authenticator, verifying credentials of other devices on the network." + }, + { + "type": "button_row", + "items": [ + { + "type": "button_primary", + "action": "/action/add_dhcp_reservation", + "method": "post", + "text": "Add" + }, + { + "type": "button_cancel", + "text": "Cancel" + } + ] + } + ] + } + ] + } + ] +} \ No newline at end of file diff --git a/docker/routlin-dash/app/pages/dnsblocking/__init__.py b/docker/routlin-dash/app/pages/dnsblocking/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/docker/routlin-dash/app/action_dnsblocking.py b/docker/routlin-dash/app/pages/dnsblocking/action.py similarity index 99% rename from docker/routlin-dash/app/action_dnsblocking.py rename to docker/routlin-dash/app/pages/dnsblocking/action.py index e29ef81..a087c62 100644 --- a/docker/routlin-dash/app/action_dnsblocking.py +++ b/docker/routlin-dash/app/pages/dnsblocking/action.py @@ -6,9 +6,9 @@ from config_utils import load_config, save_config_with_snapshot, verify_config_h import sanitize import validation as validate -bp = Blueprint('action_dnsblocking', __name__) +bp = Blueprint('dnsblocking', __name__) -VIEW = '/view/view_dns_blocking' +VIEW = '/view/view_dnsblocking' _VALID_FORMATS_STR = ', '.join(sorted(validate.VALID_BLOCKLIST_FORMATS)) diff --git a/docker/routlin-dash/app/pages/dnsblocking/content.json b/docker/routlin-dash/app/pages/dnsblocking/content.json new file mode 100644 index 0000000..dc78231 --- /dev/null +++ b/docker/routlin-dash/app/pages/dnsblocking/content.json @@ -0,0 +1,247 @@ +{ + "id": "view_dnsblocking", + "client_requirement": "client_is_viewer+", + "items": [ + { + "type": "header_page_title", + "items": [ + { + "type": "h1", + "text": "DNS Blocking" + }, + { + "type": "p", + "text": "Domain level blocking via dnsmasq." + } + ] + }, + { + "type": "table", + "datasource": "config:blocklists", + "empty_message": "No blocklists configured.", + "columns": [ + { + "label": "Name", + "field": "name" + }, + { + "label": "Description", + "field": "description" + }, + { + "label": "Format", + "field": "format", + "class": "col-mono" + }, + { + "label": "Source URL", + "field": "url", + "class": "col-mono" + } + ], + "row_actions": [ + { + "client_requirement": "client_is_administrator+", + "action": "/action/dnsblocking_tableblocklists_rowedit", + "method": "inline_edit", + "text": "Edit", + "class": "btn-ghost btn-sm", + "fields": [ + { + "col": "name", + "input_type": "text", + "validate": "dashname" + }, + { + "col": "description", + "input_type": "text" + }, + { + "col": "format", + "input_type": "select", + "options": "%BLOCKLIST_FORMAT_OPTIONS%" + }, + { + "col": "url", + "input_type": "text", + "validate": "url" + } + ] + }, + { + "client_requirement": "client_is_administrator+", + "action": "/action/dnsblocking_tableblocklists_rowdelete", + "method": "post", + "text": "Delete", + "class": "btn-danger btn-sm" + } + ] + }, + { + "type": "card", + "id": "add-form", + "label": "Add Blocklist", + "client_requirement": "client_is_administrator+", + "items": [ + { + "type": "form", + "action": "/action/dnsblocking_cardaddblocklist_add", + "method": "post", + "items": [ + { + "type": "field", + "label": "Name", + "name": "name", + "input_type": "text", + "validate": "dashname", + "placeholder": "e.g. steven-black" + }, + { + "type": "field", + "label": "Description", + "name": "description", + "input_type": "text", + "placeholder": "e.g. Steven Black (ads, malware, trackers)" + }, + { + "type": "field", + "label": "Format", + "name": "format", + "input_type": "select", + "options": "%BLOCKLIST_FORMAT_OPTIONS%" + }, + { + "type": "field", + "label": "Source URL", + "name": "url", + "input_type": "text", + "validate": "url", + "placeholder": "https://..." + }, + { + "type": "button_row", + "items": [ + { + "type": "button_primary", + "action": "/action/dnsblocking_cardaddblocklist_add", + "method": "post", + "text": "Add Blocklist" + }, + { + "type": "button_cancel", + "text": "Cancel" + } + ] + } + ] + } + ] + }, + { + "type": "card", + "label": "Blocklist Refresh", + "client_requirement": "client_is_administrator+", + "items": [ + { + "type": "raw_html", + "html": "%BLOCKLIST_STATS_HTML%" + }, + { + "type": "hr" + }, + { + "type": "button_row", + "items": [ + { + "type": "button_secondary", + "action": "/action/dnsblocking_cardblocklistrefresh_refreshnow", + "method": "post", + "text": "Refresh All Now" + } + ] + }, + { + "type": "hr" + }, + { + "type": "form", + "action": "/action/dnsblocking_cardblocklistrefresh_save", + "method": "post", + "items": [ + { + "type": "field", + "label": "Daily Refresh Time", + "name": "daily_execute_time_24hr_local", + "input_type": "text", + "validate": "time_24h", + "value": "%GENERAL_DAILY_EXECUTE_TIME%", + "placeholder": "e.g. 02:30", + "hint": "24-hour local time for the daily blocklist refresh." + }, + { + "type": "button_row", + "items": [ + { + "type": "button_primary", + "action": "/action/dnsblocking_cardblocklistrefresh_save", + "method": "post", + "text": "Save" + }, + { + "type": "button_cancel", + "text": "Cancel" + } + ] + } + ] + } + ] + }, + { + "type": "card", + "label": "Logging", + "client_requirement": "client_is_administrator+", + "items": [ + { + "type": "form", + "action": "/action/dnsblocking_cardlogging_save", + "method": "post", + "items": [ + { + "type": "field", + "label": "Max Log Size (KB)", + "name": "log_max_kb", + "input_type": "number", + "value": "%GENERAL_LOG_MAX_KB%", + "min": 64, + "hint": "Log is cleared and restarted when it exceeds this size." + }, + { + "type": "field", + "label": "Only record errors to log", + "name": "log_errors_only", + "input_type": "checkbox", + "value": "%GENERAL_LOG_ERRORS_ONLY%", + "hint": "Only write error-level messages to the log." + }, + { + "type": "button_row", + "items": [ + { + "type": "button_primary", + "action": "/action/dnsblocking_cardlogging_save", + "method": "post", + "text": "Save" + }, + { + "type": "button_cancel", + "text": "Cancel" + } + ] + } + ] + } + ] + } + ] +} \ No newline at end of file diff --git a/docker/routlin-dash/app/pages/dnsserver/__init__.py b/docker/routlin-dash/app/pages/dnsserver/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/docker/routlin-dash/app/action_dnsserver.py b/docker/routlin-dash/app/pages/dnsserver/action.py similarity index 97% rename from docker/routlin-dash/app/action_dnsserver.py rename to docker/routlin-dash/app/pages/dnsserver/action.py index 76261c7..f0bcd4d 100644 --- a/docker/routlin-dash/app/action_dnsserver.py +++ b/docker/routlin-dash/app/pages/dnsserver/action.py @@ -5,9 +5,9 @@ from config_utils import load_config, save_config_with_snapshot, verify_config_h import sanitize import validation as validate -bp = Blueprint('action_dnsserver', __name__) +bp = Blueprint('dnsserver', __name__) -_VIEW = '/view/view_dns_server' +_VIEW = '/view/view_dnsserver' @bp.route('/action/dnsserver_cardupstreamdns_save', methods=['POST']) diff --git a/docker/routlin-dash/app/pages/dnsserver/content.json b/docker/routlin-dash/app/pages/dnsserver/content.json new file mode 100644 index 0000000..b4e903a --- /dev/null +++ b/docker/routlin-dash/app/pages/dnsserver/content.json @@ -0,0 +1,104 @@ +{ + "id": "view_dnsserver", + "client_requirement": "client_is_administrator+", + "items": [ + { + "type": "header_page_title", + "items": [ + { + "type": "h1", + "text": "DNS Server" + }, + { + "type": "p", + "text": "Upstream resolvers and forwarding DNS service settings." + } + ] + }, + { + "type": "card", + "label": "Upstream DNS", + "client_requirement": "client_is_administrator+", + "items": [ + { + "type": "form", + "action": "/action/dnsserver_cardupstreamdns_save", + "method": "post", + "items": [ + { + "type": "editable_list", + "label": "DNS Providers", + "name": "upstream_servers", + "item_placeholder": "e.g. 1.1.1.1", + "add_label": "Add Provider", + "validate": "ip", + "hint": "DNS resolvers queried for external hostnames. Supports IPv4 and IPv6.", + "items": "%DNS_UPSTREAM_SERVERS_JSON%" + }, + { + "type": "field", + "label": "Strict Order", + "name": "strict_order", + "input_type": "checkbox", + "value": "%DNS_STRICT_ORDER%", + "hint": "Query DNS providers in list order rather than in parallel." + }, + { + "type": "button_row", + "items": [ + { + "type": "button_primary", + "action": "/action/dnsserver_cardupstreamdns_save", + "method": "post", + "text": "Save" + }, + { + "type": "button_cancel", + "text": "Cancel" + } + ] + } + ] + } + ] + }, + { + "type": "card", + "label": "DNS Forwarding", + "client_requirement": "client_is_administrator+", + "items": [ + { + "type": "form", + "action": "/action/dnsserver_carddnsforwarding_save", + "method": "post", + "items": [ + { + "type": "field", + "label": "Cache Size", + "name": "cache_size", + "input_type": "number", + "value": "%DNS_CACHE_SIZE%", + "min": 0, + "hint": "Max DNS responses to cache per instance. Set to 0 to disable caching." + }, + { + "type": "button_row", + "items": [ + { + "type": "button_primary", + "action": "/action/dnsserver_carddnsforwarding_save", + "method": "post", + "text": "Save" + }, + { + "type": "button_cancel", + "text": "Cancel" + } + ] + } + ] + } + ] + } + ] +} \ No newline at end of file diff --git a/docker/routlin-dash/app/pages/hostoverrides/__init__.py b/docker/routlin-dash/app/pages/hostoverrides/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/docker/routlin-dash/app/action_apply_host_overrides.py b/docker/routlin-dash/app/pages/hostoverrides/action.py similarity index 98% rename from docker/routlin-dash/app/action_apply_host_overrides.py rename to docker/routlin-dash/app/pages/hostoverrides/action.py index bab498d..5f18b35 100644 --- a/docker/routlin-dash/app/action_apply_host_overrides.py +++ b/docker/routlin-dash/app/pages/hostoverrides/action.py @@ -7,9 +7,9 @@ from config_utils import load_config, save_config_with_snapshot, verify_config_h import sanitize import validation as validate -bp = Blueprint('action_apply_host_overrides', __name__) +bp = Blueprint('hostoverrides', __name__) -VIEW = '/view/view_host_overrides' +VIEW = '/view/view_hostoverrides' def _vlan_networks(cfg): diff --git a/docker/routlin-dash/app/pages/hostoverrides/content.json b/docker/routlin-dash/app/pages/hostoverrides/content.json new file mode 100644 index 0000000..cbe2818 --- /dev/null +++ b/docker/routlin-dash/app/pages/hostoverrides/content.json @@ -0,0 +1,135 @@ +{ + "id": "view_hostoverrides", + "client_requirement": "client_is_viewer+", + "items": [ + { + "type": "header_page_title", + "items": [ + { + "type": "h1", + "text": "Host Overrides" + }, + { + "type": "p", + "text": "Force a hostname to resolve to a specific internal IP." + } + ] + }, + { + "type": "table", + "datasource": "config:host_overrides", + "empty_message": "No host overrides configured.", + "columns": [ + { + "label": "Description", + "field": "description" + }, + { + "label": "Hostname", + "field": "host", + "class": "col-mono" + }, + { + "label": "Resolves To", + "field": "ip", + "class": "col-mono" + }, + { + "label": "Status", + "field": "enabled", + "render": "badge_enabled_disabled" + } + ], + "row_actions": [ + { + "client_requirement": "client_is_administrator+", + "action": "/action/edit_host_override", + "method": "inline_edit", + "text": "Edit", + "class": "btn-ghost btn-sm", + "fields": [ + { + "col": "description", + "input_type": "text" + }, + { + "col": "host", + "input_type": "text", + "validate": "domainname" + }, + { + "col": "ip", + "input_type": "text", + "validate": "ip" + }, + { + "col": "enabled", + "input_type": "checkbox", + "checkbox_label": "Enabled" + } + ] + }, + { + "client_requirement": "client_is_administrator+", + "action": "/action/delete_host_override", + "method": "post", + "text": "Delete", + "class": "btn-danger btn-sm" + } + ] + }, + { + "type": "card", + "id": "add-form", + "label": "Add Host Override", + "client_requirement": "client_is_administrator+", + "items": [ + { + "type": "form", + "action": "/action/add_host_override", + "method": "post", + "items": [ + { + "type": "field", + "label": "Description", + "name": "description", + "input_type": "text", + "placeholder": "e.g. Local server" + }, + { + "type": "field", + "label": "Hostname", + "name": "host", + "input_type": "text", + "validate": "domainname", + "placeholder": "e.g. server.home.local" + }, + { + "type": "field", + "label": "Resolves To", + "name": "ip", + "input_type": "text", + "validate": "ip", + "placeholder": "e.g. 192.168.1.100" + }, + { + "type": "button_row", + "items": [ + { + "type": "button_primary", + "action": "/action/add_host_override", + "method": "post", + "text": "Add Host Override" + }, + { + "type": "button_cancel", + "text": "Cancel" + } + ] + } + ] + } + ] + } + ] +} \ No newline at end of file diff --git a/docker/routlin-dash/app/pages/intervlan/__init__.py b/docker/routlin-dash/app/pages/intervlan/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/docker/routlin-dash/app/action_apply_inter_vlan.py b/docker/routlin-dash/app/pages/intervlan/action.py similarity index 98% rename from docker/routlin-dash/app/action_apply_inter_vlan.py rename to docker/routlin-dash/app/pages/intervlan/action.py index 92ea8a8..8f0abca 100644 --- a/docker/routlin-dash/app/action_apply_inter_vlan.py +++ b/docker/routlin-dash/app/pages/intervlan/action.py @@ -6,9 +6,9 @@ from config_utils import load_config, save_config_with_snapshot, verify_config_h import sanitize import validation as validate -bp = Blueprint('action_apply_inter_vlan', __name__) +bp = Blueprint('intervlan', __name__) -VIEW = '/view/view_inter_vlan' +VIEW = '/view/view_intervlan' _VALID_PROTOS_STR = ', '.join(sorted(validate.VALID_PROTOCOLS)) diff --git a/docker/routlin-dash/app/pages/intervlan/content.json b/docker/routlin-dash/app/pages/intervlan/content.json new file mode 100644 index 0000000..f2642b0 --- /dev/null +++ b/docker/routlin-dash/app/pages/intervlan/content.json @@ -0,0 +1,167 @@ +{ + "id": "view_intervlan", + "client_requirement": "client_is_viewer+", + "items": [ + { + "type": "header_page_title", + "items": [ + { + "type": "h1", + "text": "Inter-VLAN Exceptions" + }, + { + "type": "p", + "text": "Firewall rules that permit specific traffic to cross VLAN boundaries." + } + ] + }, + { + "type": "table", + "datasource": "config:inter_vlan_exceptions", + "empty_message": "No inter-VLAN exceptions configured. All cross-VLAN traffic is blocked by default.", + "columns": [ + { + "label": "Description", + "field": "description" + }, + { + "label": "Protocol", + "field": "protocol", + "class": "col-mono" + }, + { + "label": "Source", + "field": "src_ip_or_subnet", + "class": "col-mono" + }, + { + "label": "Destination", + "field": "dst_ip_or_subnet", + "class": "col-mono" + }, + { + "label": "Dest Port", + "field": "dst_port", + "class": "col-mono" + }, + { + "label": "Status", + "field": "enabled", + "render": "badge_enabled_disabled" + } + ], + "row_actions": [ + { + "client_requirement": "client_is_administrator+", + "action": "/action/edit_inter_vlan", + "method": "inline_edit", + "text": "Edit", + "class": "btn-ghost btn-sm", + "fields": [ + { + "col": "description", + "input_type": "text" + }, + { + "col": "protocol", + "input_type": "select", + "options": "%PROTOCOL_OPTIONS%" + }, + { + "col": "src_ip_or_subnet", + "input_type": "text" + }, + { + "col": "dst_ip_or_subnet", + "input_type": "text" + }, + { + "col": "dst_port", + "input_type": "text" + }, + { + "col": "enabled", + "input_type": "checkbox", + "checkbox_label": "Enabled" + } + ] + }, + { + "client_requirement": "client_is_administrator+", + "action": "/action/delete_inter_vlan", + "method": "post", + "text": "Delete", + "class": "btn-danger btn-sm" + } + ] + }, + { + "type": "card", + "id": "add-form", + "label": "Add Exception", + "client_requirement": "client_is_administrator+", + "items": [ + { + "type": "form", + "action": "/action/add_inter_vlan", + "method": "post", + "items": [ + { + "type": "field", + "label": "Description", + "name": "description", + "input_type": "text", + "placeholder": "e.g. Allow Chromecast" + }, + { + "type": "field", + "label": "Protocol", + "name": "protocol", + "input_type": "select", + "options": "%PROTOCOL_OPTIONS%" + }, + { + "type": "field", + "label": "Source", + "name": "src_ip_or_subnet", + "input_type": "text", + "validate": "ipv4cidr", + "placeholder": "e.g. 192.168.20.0/24" + }, + { + "type": "field", + "label": "Destination", + "name": "dst_ip_or_subnet", + "input_type": "text", + "validate": "ipv4", + "placeholder": "e.g. 192.168.10.100" + }, + { + "type": "field", + "label": "Dest Port", + "name": "dst_port", + "input_type": "text", + "validate": "port", + "placeholder": "e.g. 8009" + }, + { + "type": "button_row", + "items": [ + { + "type": "button_primary", + "action": "/action/add_inter_vlan", + "method": "post", + "text": "Add Exception" + }, + { + "type": "button_cancel", + "text": "Cancel" + } + ] + } + ] + } + ] + } + ] +} \ No newline at end of file diff --git a/docker/routlin-dash/app/pages/mdns/__init__.py b/docker/routlin-dash/app/pages/mdns/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/docker/routlin-dash/app/action_apply_mdns.py b/docker/routlin-dash/app/pages/mdns/action.py similarity index 96% rename from docker/routlin-dash/app/action_apply_mdns.py rename to docker/routlin-dash/app/pages/mdns/action.py index 6b8daa6..6b51f02 100644 --- a/docker/routlin-dash/app/action_apply_mdns.py +++ b/docker/routlin-dash/app/pages/mdns/action.py @@ -6,7 +6,7 @@ from config_utils import load_config, save_config_with_snapshot, verify_config_h import sanitize import validation as validate -bp = Blueprint('action_apply_mdns', __name__) +bp = Blueprint('mdns', __name__) @bp.route('/action/apply_mdns', methods=['POST']) diff --git a/docker/routlin-dash/app/pages/networklayout/__init__.py b/docker/routlin-dash/app/pages/networklayout/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/docker/routlin-dash/app/action_networklayout.py b/docker/routlin-dash/app/pages/networklayout/action.py similarity index 99% rename from docker/routlin-dash/app/action_networklayout.py rename to docker/routlin-dash/app/pages/networklayout/action.py index 1f11d82..260224a 100644 --- a/docker/routlin-dash/app/action_networklayout.py +++ b/docker/routlin-dash/app/pages/networklayout/action.py @@ -7,9 +7,9 @@ from config_utils import load_config, save_config_with_snapshot, verify_config_h import sanitize import validation as validate -bp = Blueprint('action_networklayout', __name__) +bp = Blueprint('networklayout', __name__) -VIEW = '/view/view_network_layout' +VIEW = '/view/view_networklayout' _VLAN_FIELDS = ['name', 'vlan_id', 'is_vpn', 'subnet', 'subnet_mask', 'dnsmasq_log_queries', 'radius_default', 'mdns_reflection', 'use_blocklists'] diff --git a/docker/routlin-dash/app/pages/networklayout/content.json b/docker/routlin-dash/app/pages/networklayout/content.json new file mode 100644 index 0000000..8eaf746 --- /dev/null +++ b/docker/routlin-dash/app/pages/networklayout/content.json @@ -0,0 +1,283 @@ +{ + "id": "view_networklayout", + "client_requirement": "client_is_viewer+", + "items": [ + { + "type": "header_page_title", + "items": [ + { + "type": "h1", + "text": "Network Layout" + }, + { + "type": "p", + "text": "Network segments managed by systemd-networkd, dnsmasq, nftables, and freeradius." + } + ] + }, + { + "type": "info_bar", + "variant": "info", + "text": "For a basic flat network with no VLAN segmentation, only use VLAN 1 and delete the others." + }, + { + "type": "table", + "datasource": "config:vlans", + "empty_message": "No VLANs configured.", + "columns": [ + { + "label": "VLAN ID", + "field": "vlan_id", + "class": "col-mono col-narrow" + }, + { + "label": "Name", + "field": "name", + "class": "col-narrow" + }, + { + "label": "Interface", + "field": "interface", + "class": "col-mono col-narrow" + }, + { + "label": "Subnet", + "field": "subnet", + "class": "col-mono col-narrow" + }, + { + "label": "Mask", + "field": "subnet_mask", + "class": "col-mono col-narrow" + }, + { + "label": "Self Ident(s)", + "field": "server_identity_ips", + "render": "tag_list" + }, + { + "label": "Blocklists", + "field": "use_blocklists", + "class": "col-expand", + "render": "tag_list" + }, + { + "label": "Default", + "field": "radius_default", + "class": "col-narrow", + "render": "badge_yes_no", + "render_options": { + "title_true": "RADIUS Default", + "title_false": "Not RADIUS Default" + } + }, + { + "label": "mDNS", + "field": "mdns_reflection", + "class": "col-narrow", + "render": "badge_yes_no", + "render_options": { + "title_true": "mDNS Reflection Enabled", + "title_false": "mDNS Reflection Disabled" + } + }, + { + "label": "Record", + "field": "dnsmasq_log_queries", + "class": "col-narrow", + "render": "badge_yes_no", + "render_options": { + "title_true": "DNS Queries Recorded", + "title_false": "DNS Queries Not Recorded" + } + } + ], + "row_actions": [ + { + "client_requirement": "client_is_administrator+", + "action": "/action/networklayout_tablevlans_edit", + "method": "inline_edit", + "text": "Edit", + "class": "btn-ghost btn-sm", + "fields": [ + { + "col": "name", + "input_type": "text", + "validate": "dashname" + }, + { + "col": "subnet", + "input_type": "text", + "validate": "subnet" + }, + { + "col": "subnet_mask", + "input_type": "number", + "min": 1, + "max": 30 + }, + { + "col": "server_identity_ips", + "input_type": "textarea_pair", + "col_label": "IP Address", + "col_validate": "ip", + "pair_col": "server_identity_descriptions", + "pair_label": "Description (Opt)", + "pair_wide": true, + "pair_col2": "server_identity_hostnames", + "pair_label2": "Hostname (Opt)", + "pair_validate2": "networkname", + "gateway_col": "server_identity_gateway", + "dns_col": "server_identity_dns_server", + "ntp_col": "server_identity_ntp_server" + }, + { + "col": "radius_default", + "input_type": "checkbox", + "checkbox_label": "Enabled" + }, + { + "col": "mdns_reflection", + "input_type": "checkbox", + "checkbox_label": "Enabled" + }, + { + "col": "dnsmasq_log_queries", + "input_type": "checkbox", + "checkbox_label": "Record" + }, + { + "col": "use_blocklists", + "input_type": "checkbox_multi", + "options": "%BLOCKLIST_NAME_OPTIONS%" + } + ] + }, + { + "client_requirement": "client_is_administrator+", + "action": "/action/networklayout_tablevlans_delete", + "method": "post", + "text": "Delete", + "class": "btn-danger btn-sm", + "disable_if": { + "field": "vlan_id", + "value": 1 + } + } + ] + }, + { + "type": "card", + "id": "add-form", + "label": "Add VLAN", + "client_requirement": "client_is_administrator+", + "items": [ + { + "type": "form", + "action": "/action/networklayout_cardaddvlan_addvlan", + "method": "post", + "items": [ + { + "type": "field_row", + "cols": 4, + "items": [ + { + "type": "field", + "label": "VLAN ID", + "name": "vlan_id", + "input_type": "number", + "min": 1, + "max": 4094, + "validate": "vlan_id", + "hint": "Unique integer 1-4094. Sets the 802.1Q tag and interface name." + }, + { + "type": "field", + "label": "VLAN Name", + "name": "name", + "input_type": "text", + "validate": "dashname", + "hint": "Lowercase letters, digits, hyphens. E.g. iot" + }, + { + "type": "subnet_row", + "subnet_name": "subnet", + "prefix_name": "subnet_mask", + "subnet_placeholder": "e.g. 192.168.x.0", + "prefix_value": "24" + }, + { + "type": "field", + "label": "VLAN Type", + "name": "is_vpn", + "input_type": "checkbox", + "checkbox_label": "Is VPN", + "hint": "Check if this VLAN uses a WireGuard interface (e.g. wg0, wg1, etc)." + } + ] + }, + { + "type": "hr" + }, + { + "type": "identity_builder", + "label": "Router Identities on this VLAN:" + }, + { + "type": "hr" + }, + { + "type": "field", + "label": "Blocklists", + "name": "use_blocklists", + "input_type": "checkbox_group", + "options": "%BLOCKLIST_NAME_OPTIONS%", + "hint": "Note: Selected lists will be merged and de-duplicated prior to use." + }, + { + "type": "hr" + }, + { + "type": "field", + "label": "RADIUS Default", + "name": "radius_default", + "input_type": "checkbox", + "hint": "Wireless devices without a DHCP reservation will be placed into this VLAN. (Note: wired devices are not placed via RADIUS but rather by layer 3 switch policy.)" + }, + { + "type": "field", + "label": "mDNS Reflection", + "name": "mdns_reflection", + "input_type": "checkbox", + "hint": "Reflect mDNS traffic to/from this VLAN via avahi-daemon. Not supported on WireGuard interfaces." + }, + { + "type": "field", + "label": "Record DNS Queries", + "name": "dnsmasq_log_queries", + "input_type": "checkbox", + "hint": "Log every DNS query. High volume - enable for debugging only." + }, + { + "type": "button_row", + "items": [ + { + "type": "button_primary", + "action": "/action/networklayout_cardaddvlan_addvlan", + "method": "post", + "text": "Add VLAN", + "class": "add-vlan-btn", + "disabled": true + }, + { + "type": "button_cancel", + "text": "Cancel" + } + ] + } + ] + } + ] + } + ] +} \ No newline at end of file diff --git a/docker/routlin-dash/app/pages/overview/__init__.py b/docker/routlin-dash/app/pages/overview/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/docker/routlin-dash/app/pages/overview/content.json b/docker/routlin-dash/app/pages/overview/content.json new file mode 100644 index 0000000..490587a --- /dev/null +++ b/docker/routlin-dash/app/pages/overview/content.json @@ -0,0 +1,289 @@ +{ + "id": "view_overview", + "client_requirement": "client_is_nothing+", + "items": [ + { + "type": "auth_wrapper", + "client_requirement": "client_is_nothing=", + "items": [ + { + "type": "auth_card", + "items": [ + { + "type": "h1", + "text": "Routlin Dashboard" + }, + { + "type": "p", + "text": "Log in to monitor and manage your network." + }, + { + "type": "spacer" + }, + { + "type": "button_primary", + "action": "/view/view_login", + "text": "Log In" + } + ] + } + ] + }, + { + "type": "header_page_title", + "client_requirement": "client_is_viewer+", + "items": [ + { + "type": "h1", + "text": "Overview" + }, + { + "type": "p", + "text": "Current network status at a glance." + } + ] + }, + { + "type": "stat_card_grid", + "client_requirement": "client_is_viewer+", + "items": [ + { + "type": "stat_card", + "label": "DHCP Leases", + "value": "%STAT_LEASE_COUNT%", + "sub": "active leases", + "variant": "accent" + }, + { + "type": "stat_card", + "label": "Queries Blocked", + "value": "%STAT_BLOCKED_TODAY%", + "sub": "since midnight", + "variant": "warning" + }, + { + "type": "stat_card", + "label": "Public IP", + "value": "%STAT_PUBLIC_IP%", + "sub": "%STAT_DDNS_HOSTNAME%" + } + ] + }, + { + "type": "card", + "label": "Network", + "client_requirement": "client_is_viewer+", + "items": [ + { + "type": "grid", + "rows": [ + { + "cells": [ + { + "type": "grid_label", + "text": "WAN Interface" + }, + { + "type": "grid_value", + "text": "%GENERAL_WAN_INTERFACE%" + } + ] + }, + { + "cells": [ + { + "type": "grid_label", + "text": "VLANs" + }, + { + "type": "grid_value", + "text": "%OVERVIEW_VLAN_NAMES%" + } + ] + }, + { + "cells": [ + { + "type": "grid_label", + "text": "Firewall" + }, + { + "type": "grid_value", + "text": "%STAT_NFTABLES_STATUS%" + } + ] + }, + { + "cells": [ + { + "type": "grid_label", + "text": "System Uptime" + }, + { + "type": "grid_value", + "text": "%STAT_UPTIME%" + } + ] + } + ] + } + ] + }, + { + "type": "card", + "label": "DNS Blocking", + "client_requirement": "client_is_viewer+", + "items": [ + { + "type": "grid", + "rows": [ + { + "cells": [ + { + "type": "grid_label", + "text": "Blocked Domains" + }, + { + "type": "grid_value", + "text": "%STAT_BLOCKED_DOMAINS%" + } + ] + }, + { + "cells": [ + { + "type": "grid_label", + "text": "Active Blocklists" + }, + { + "type": "grid_value", + "text": "%STAT_BLOCKLIST_COUNT% lists" + } + ] + }, + { + "cells": [ + { + "type": "grid_label", + "text": "Last Refreshed" + }, + { + "type": "grid_value", + "text": "%STAT_BL_LAST_UPDATE%" + } + ] + }, + { + "cells": [ + { + "type": "grid_label", + "text": "Active IP Bans" + }, + { + "type": "grid_value", + "text": "%STAT_BANNED_IP_COUNT% rules" + } + ] + } + ] + } + ] + }, + { + "type": "card", + "label": "DNS Caching", + "client_requirement": "client_is_viewer+", + "items": [ + { + "type": "grid", + "rows": [ + { + "cells": [ + { + "type": "grid_label", + "text": "Total Queries" + }, + { + "type": "grid_value", + "text": "%DNS_STAT_QUERIES%" + } + ] + }, + { + "cells": [ + { + "type": "grid_label", + "text": "Cache Hits" + }, + { + "type": "grid_value", + "text": "%DNS_STAT_HITS% (%DNS_STAT_HIT_RATE% hit rate)" + } + ] + }, + { + "cells": [ + { + "type": "grid_label", + "text": "Forwarded" + }, + { + "type": "grid_value", + "text": "%DNS_STAT_FORWARDED%" + } + ] + }, + { + "cells": [ + { + "type": "grid_label", + "text": "Cache Capacity" + }, + { + "type": "grid_value", + "text": "%DNS_CACHE_SIZE% entries" + } + ] + }, + { + "cells": [ + { + "type": "grid_label", + "text": "Authoritative Answers" + }, + { + "type": "grid_value", + "text": "%DNS_STAT_AUTH%" + } + ] + }, + { + "cells": [ + { + "type": "grid_label", + "text": "TCP Connections Peak" + }, + { + "type": "grid_value", + "text": "%DNS_STAT_TCP_PEAK%" + } + ] + }, + { + "cells": [ + { + "type": "grid_label", + "text": "DNS Providers" + }, + { + "type": "grid_value", + "text": "%OVERVIEW_UPSTREAM_SERVERS%" + } + ] + } + ] + } + ] + } + ] +} \ No newline at end of file diff --git a/docker/routlin-dash/app/pages/physicalinterfaces/__init__.py b/docker/routlin-dash/app/pages/physicalinterfaces/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/docker/routlin-dash/app/action_physicalinterfaces.py b/docker/routlin-dash/app/pages/physicalinterfaces/action.py similarity index 97% rename from docker/routlin-dash/app/action_physicalinterfaces.py rename to docker/routlin-dash/app/pages/physicalinterfaces/action.py index af9b379..7b3b4d1 100644 --- a/docker/routlin-dash/app/action_physicalinterfaces.py +++ b/docker/routlin-dash/app/pages/physicalinterfaces/action.py @@ -7,9 +7,9 @@ from config_utils import load_config, save_config_with_snapshot, verify_config_h import sanitize import validation as validate -bp = Blueprint('action_physicalinterfaces', __name__) +bp = Blueprint('physicalinterfaces', __name__) -_VIEW = '/view/view_physical_interfaces' +_VIEW = '/view/view_physicalinterfaces' _EXCLUDE_PREFIXES = ('lo', 'wg', 'docker', 'br-', 'veth', 'tun', 'tap', 'ppp', 'virbr', diff --git a/docker/routlin-dash/app/pages/physicalinterfaces/content.json b/docker/routlin-dash/app/pages/physicalinterfaces/content.json new file mode 100644 index 0000000..8baa310 --- /dev/null +++ b/docker/routlin-dash/app/pages/physicalinterfaces/content.json @@ -0,0 +1,166 @@ +{ + "id": "view_physicalinterfaces", + "client_requirement": "client_is_administrator+", + "items": [ + { + "type": "header_page_title", + "items": [ + { + "type": "h1", + "text": "Physical Interfaces" + }, + { + "type": "p", + "text": "WAN/LAN interface assignments and per-interface settings." + } + ] + }, + { + "type": "card", + "label": "Physical Interfaces", + "client_requirement": "client_is_administrator+", + "items": [ + { + "type": "form", + "action": "/action/physicalinterfaces_cardphysicalinterface_save", + "method": "post", + "items": [ + { + "type": "field", + "label": "WAN Interface", + "name": "wan_interface", + "input_type": "interface_picker", + "value": "%GENERAL_WAN_INTERFACE%", + "data": "%NETWORK_INTERFACE_DATA_JSON%" + }, + { + "type": "field", + "label": "LAN Interface", + "name": "lan_interface", + "input_type": "interface_picker", + "value": "%GENERAL_LAN_INTERFACE%", + "data": "%NETWORK_INTERFACE_DATA_JSON%" + }, + { + "type": "button_row", + "items": [ + { + "type": "button_primary", + "action": "/action/physicalinterfaces_cardphysicalinterface_save", + "method": "post", + "text": "Save" + }, + { + "type": "button_cancel", + "text": "Cancel" + } + ] + } + ] + } + ] + }, + { + "type": "card", + "id": "iface-config-card", + "label": "Interface Configuration", + "hidden": true, + "client_requirement": "client_is_administrator+", + "items": [ + { + "type": "form", + "action": "/action/physicalinterfaces_cardinterfaceconfiguration_apply", + "method": "post", + "items": [ + { + "type": "hidden", + "name": "original_mtu", + "value": "" + }, + { + "type": "hidden", + "name": "original_mac", + "value": "" + }, + { + "type": "field_row", + "cols": 3, + "items": [ + { + "type": "field", + "label": "Interface", + "name": "iface", + "input_type": "text", + "readonly": true, + "value": "" + }, + { + "type": "field", + "label": "MTU", + "name": "mtu", + "input_type": "select", + "value": "", + "options": [ + { + "label": "576", + "value": "576" + }, + { + "label": "1280", + "value": "1280" + }, + { + "label": "1492", + "value": "1492" + }, + { + "label": "1500", + "value": "1500" + }, + { + "label": "4096", + "value": "4096" + }, + { + "label": "9000", + "value": "9000" + } + ] + }, + { + "type": "field", + "label": "MAC Address", + "name": "mac", + "input_type": "text", + "validate": "mac", + "value": "" + } + ] + }, + { + "type": "button_row", + "items": [ + { + "type": "button_primary", + "action": "/action/physicalinterfaces_cardinterfaceconfiguration_apply", + "method": "post", + "text": "Apply" + }, + { + "type": "button_secondary", + "action": "#", + "text": "Cancel", + "class": "iface-config-cancel" + } + ] + } + ] + } + ] + }, + { + "type": "raw_html", + "html": "




" + } + ] +} \ No newline at end of file diff --git a/docker/routlin-dash/app/pages/portforwarding/__init__.py b/docker/routlin-dash/app/pages/portforwarding/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/docker/routlin-dash/app/action_apply_port_forwarding.py b/docker/routlin-dash/app/pages/portforwarding/action.py similarity index 98% rename from docker/routlin-dash/app/action_apply_port_forwarding.py rename to docker/routlin-dash/app/pages/portforwarding/action.py index ea13d3f..ef3aa0c 100644 --- a/docker/routlin-dash/app/action_apply_port_forwarding.py +++ b/docker/routlin-dash/app/pages/portforwarding/action.py @@ -6,9 +6,9 @@ from config_utils import load_config, save_config_with_snapshot, verify_config_h import sanitize import validation as validate -bp = Blueprint('action_apply_port_forwarding', __name__) +bp = Blueprint('portforwarding', __name__) -VIEW = '/view/view_port_forwarding' +VIEW = '/view/view_portforwarding' _VALID_PROTOS_STR = ', '.join(sorted(validate.VALID_PROTOCOLS)) diff --git a/docker/routlin-dash/app/pages/portforwarding/content.json b/docker/routlin-dash/app/pages/portforwarding/content.json new file mode 100644 index 0000000..9c6f13c --- /dev/null +++ b/docker/routlin-dash/app/pages/portforwarding/content.json @@ -0,0 +1,167 @@ +{ + "id": "view_portforwarding", + "client_requirement": "client_is_viewer+", + "items": [ + { + "type": "header_page_title", + "items": [ + { + "type": "h1", + "text": "Port Forwarding" + }, + { + "type": "p", + "text": "DNAT rules that forward inbound WAN traffic to internal hosts." + } + ] + }, + { + "type": "table", + "datasource": "config:port_forwarding", + "empty_message": "No port forwarding rules configured.", + "columns": [ + { + "label": "Description", + "field": "description" + }, + { + "label": "Protocol", + "field": "protocol", + "class": "col-mono" + }, + { + "label": "Ext Port", + "field": "dest_port", + "class": "col-mono" + }, + { + "label": "NAT IP", + "field": "nat_ip", + "class": "col-mono" + }, + { + "label": "NAT Port", + "field": "nat_port", + "class": "col-mono" + }, + { + "label": "Status", + "field": "enabled", + "render": "badge_enabled_disabled" + } + ], + "row_actions": [ + { + "client_requirement": "client_is_administrator+", + "action": "/action/edit_port_forward", + "method": "inline_edit", + "text": "Edit", + "class": "btn-ghost btn-sm", + "fields": [ + { + "col": "description", + "input_type": "text" + }, + { + "col": "protocol", + "input_type": "select", + "options": "%PROTOCOL_OPTIONS%" + }, + { + "col": "dest_port", + "input_type": "text" + }, + { + "col": "nat_ip", + "input_type": "text" + }, + { + "col": "nat_port", + "input_type": "text" + }, + { + "col": "enabled", + "input_type": "checkbox", + "checkbox_label": "Enabled" + } + ] + }, + { + "client_requirement": "client_is_administrator+", + "action": "/action/delete_port_forward", + "method": "post", + "text": "Delete", + "class": "btn-danger btn-sm" + } + ] + }, + { + "type": "card", + "id": "add-form", + "label": "Add Rule", + "client_requirement": "client_is_administrator+", + "items": [ + { + "type": "form", + "action": "/action/add_port_forward", + "method": "post", + "items": [ + { + "type": "field", + "label": "Description", + "name": "description", + "input_type": "text", + "placeholder": "e.g. Minecraft server" + }, + { + "type": "field", + "label": "Protocol", + "name": "protocol", + "input_type": "select", + "options": "%PROTOCOL_OPTIONS%" + }, + { + "type": "field", + "label": "Ext Port", + "name": "dest_port", + "input_type": "text", + "validate": "port", + "placeholder": "e.g. 25565" + }, + { + "type": "field", + "label": "NAT IP", + "name": "nat_ip", + "input_type": "text", + "validate": "ipv4", + "placeholder": "e.g. 192.168.1.50" + }, + { + "type": "field", + "label": "NAT Port", + "name": "nat_port", + "input_type": "text", + "validate": "port", + "placeholder": "e.g. 25565" + }, + { + "type": "button_row", + "items": [ + { + "type": "button_primary", + "action": "/action/add_port_forward", + "method": "post", + "text": "Add Rule" + }, + { + "type": "button_cancel", + "text": "Cancel" + } + ] + } + ] + } + ] + } + ] +} \ No newline at end of file diff --git a/docker/routlin-dash/app/pages/preferences/__init__.py b/docker/routlin-dash/app/pages/preferences/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/docker/routlin-dash/app/action_change_password.py b/docker/routlin-dash/app/pages/preferences/action.py similarity index 69% rename from docker/routlin-dash/app/action_change_password.py rename to docker/routlin-dash/app/pages/preferences/action.py index 571bd95..340c2ab 100644 --- a/docker/routlin-dash/app/action_change_password.py +++ b/docker/routlin-dash/app/pages/preferences/action.py @@ -2,8 +2,9 @@ from flask import Blueprint, request, session, redirect, flash import json, bcrypt from auth import require_level from config_utils import ACCOUNTS_FILE +import sanitize -bp = Blueprint('action_change_password', __name__) +bp = Blueprint('preferences', __name__) @@ -19,6 +20,33 @@ def _save_accounts(data): json.dump(data, f, indent=2) +@bp.route('/action/save_preferences', methods=['POST']) +@require_level('viewer') +def save_preferences(): + tz = sanitize.timezone(request.form.get('timezone', '').strip()) + + if not tz: + flash('Timezone is required.', 'error') + return redirect('/view/view_preferences') + + email = session.get('email_address', '').lower() + data = _load_accounts() + accounts = data.get('accounts', []) + account = next((a for a in accounts if a.get('email_address', '').lower() == email), None) + + if account is None: + flash('Account not found. Please log in again.', 'error') + return redirect('/view/view_login') + + account['timezone'] = tz + _save_accounts(data) + + session['timezone'] = tz + + flash('Preferences saved.', 'success') + return redirect('/view/view_preferences') + + @bp.route('/action/change_password', methods=['POST']) @require_level('viewer') def change_password(): @@ -45,7 +73,7 @@ def change_password(): if account is None: flash('Account not found. Please log in again.', 'error') - return redirect('/view/view_log_in') + return redirect('/view/view_login') stored_hash = account.get('hashed_password', '').encode('utf-8') if not bcrypt.checkpw(current_password.encode('utf-8'), stored_hash): diff --git a/docker/routlin-dash/app/pages/preferences/content.json b/docker/routlin-dash/app/pages/preferences/content.json new file mode 100644 index 0000000..5b096a1 --- /dev/null +++ b/docker/routlin-dash/app/pages/preferences/content.json @@ -0,0 +1,105 @@ +{ + "id": "view_preferences", + "client_requirement": "client_is_viewer+", + "items": [ + { + "type": "header_page_title", + "items": [ + { + "type": "h1", + "text": "Preferences" + }, + { + "type": "p", + "text": "Your personal account settings." + } + ] + }, + { + "type": "card", + "label": "Account Details", + "items": [ + { + "type": "form", + "action": "/action/save_preferences", + "method": "post", + "items": [ + { + "type": "field", + "label": "Email Address", + "name": "email", + "input_type": "text", + "value": "%PREF_EMAIL%", + "hint": "Contact your manager to change your email address." + }, + { + "type": "field", + "label": "Timezone", + "name": "timezone", + "input_type": "select", + "value": "%PREF_TIMEZONE%", + "options": "%TIMEZONE_OPTIONS%", + "hint": "All timestamps will be displayed in this timezone." + }, + { + "type": "button_row", + "items": [ + { + "type": "button_primary", + "action": "/action/save_preferences", + "method": "post", + "text": "Save Preferences" + } + ] + } + ] + } + ] + }, + { + "type": "card", + "label": "Change Password", + "items": [ + { + "type": "form", + "action": "/action/change_password", + "method": "post", + "items": [ + { + "type": "field", + "label": "Current Password", + "name": "current_password", + "input_type": "password", + "placeholder": "Current password" + }, + { + "type": "field", + "label": "New Password", + "name": "new_password", + "input_type": "password", + "placeholder": "New password" + }, + { + "type": "field", + "label": "Confirm Password", + "name": "confirm_password", + "input_type": "password", + "placeholder": "Repeat new password" + }, + { + "type": "button_row", + "items": [ + { + "type": "button_primary", + "action": "/action/change_password", + "method": "post", + "text": "Change Password" + } + ] + } + ] + } + ] + } + ] +} \ No newline at end of file diff --git a/docker/routlin-dash/app/pages/vpn/__init__.py b/docker/routlin-dash/app/pages/vpn/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/docker/routlin-dash/app/action_apply_vpn.py b/docker/routlin-dash/app/pages/vpn/action.py similarity index 99% rename from docker/routlin-dash/app/action_apply_vpn.py rename to docker/routlin-dash/app/pages/vpn/action.py index 83c8075..aa9f1f7 100644 --- a/docker/routlin-dash/app/action_apply_vpn.py +++ b/docker/routlin-dash/app/pages/vpn/action.py @@ -9,7 +9,7 @@ from config_utils import load_config, save_config_with_snapshot, verify_config_h import sanitize import validation as validate -bp = Blueprint('action_apply_vpn', __name__) +bp = Blueprint('vpn', __name__) _VIEW = '/view/view_vpn' _MTU_MIN = 576 diff --git a/docker/routlin-dash/app/pages/vpn/content.json b/docker/routlin-dash/app/pages/vpn/content.json new file mode 100644 index 0000000..6d75b15 --- /dev/null +++ b/docker/routlin-dash/app/pages/vpn/content.json @@ -0,0 +1,277 @@ +{ + "id": "view_vpn", + "client_requirement": "client_is_viewer+", + "items": [ + { + "type": "header_page_title", + "items": [ + { + "type": "h1", + "text": "VPN" + }, + { + "type": "p", + "text": "WireGuard peer management and server interface configuration." + } + ] + }, + { + "type": "table", + "label": "Active Sessions", + "datasource": "live:vpn_sessions", + "empty_message": "No active VPN sessions.", + "columns": [ + { + "label": "Peer", + "field": "peer_name" + }, + { + "label": "Tunnel IP", + "field": "tunnel_ip", + "class": "col-mono" + }, + { + "label": "Endpoint", + "field": "endpoint", + "class": "col-mono" + }, + { + "label": "Last Handshake", + "field": "last_handshake" + }, + { + "label": "Received", + "field": "rx_bytes", + "class": "col-mono" + }, + { + "label": "Sent", + "field": "tx_bytes", + "class": "col-mono" + } + ] + }, + { + "type": "table", + "label": "Peers", + "datasource": "config:vpn_peers", + "empty_message": "No peers configured. Use Add Peer below.", + "columns": [ + { + "label": "Name", + "field": "name" + }, + { + "label": "Assigned VLAN", + "field": "vlan_display", + "class": "col-mono" + }, + { + "label": "Assigned IP", + "field": "ip", + "class": "col-mono" + }, + { + "label": "Split Tunnel", + "field": "split_tunnel" + }, + { + "label": "Enabled", + "field": "enabled", + "render": "badge_enabled_disabled" + }, + { + "label": "Public Key", + "field": "pubkey_short", + "class": "col-mono" + } + ], + "row_actions": [ + { + "client_requirement": "client_is_administrator+", + "action": "/action/edit_vpn_peer", + "method": "inline_edit", + "text": "Edit", + "class": "btn-ghost btn-sm", + "fields": [ + { + "col": "name", + "input_type": "text", + "validate": "dashname" + }, + { + "col": "split_tunnel", + "input_type": "checkbox", + "checkbox_label": "Enabled" + }, + { + "col": "enabled", + "input_type": "checkbox", + "checkbox_label": "Enabled" + } + ] + }, + { + "client_requirement": "client_is_administrator+", + "action": "/action/regenerate_vpn_peer", + "method": "post", + "text": "Regen Conf", + "class": "btn-ghost btn-sm" + }, + { + "client_requirement": "client_is_administrator+", + "action": "/action/delete_vpn_peer", + "method": "post", + "text": "Delete", + "class": "btn-danger btn-sm" + } + ] + }, + { + "type": "card", + "label": "Add Peer", + "client_requirement": "client_is_administrator+", + "items": [ + { + "type": "form", + "action": "/action/add_vpn_peer", + "method": "post", + "items": [ + { + "type": "field", + "label": "Name", + "name": "peer_name", + "input_type": "text", + "validate": "dashname", + "placeholder": "e.g. laptop", + "hint": "Friendly name for this peer." + }, + { + "type": "field", + "label": "Assigned VLAN", + "name": "peer_vlan", + "input_type": "select", + "options": "%VPN_VLAN_OPTIONS%" + }, + { + "type": "field", + "label": "Assigned IP", + "name": "peer_ip", + "input_type": "text", + "validate": "ipv4", + "placeholder": "e.g. 192.168.40.2", + "hint": "Static IP assigned to this peer within the VPN subnet." + }, + { + "type": "field", + "label": "Split Tunnel", + "name": "split_tunnel", + "input_type": "checkbox", + "hint": "Route only VPN subnet traffic through the tunnel. When unchecked all traffic is routed through the VPN." + }, + { + "type": "field", + "label": "Enabled", + "name": "enabled", + "input_type": "checkbox", + "checked": true + }, + { + "type": "button_row", + "items": [ + { + "type": "button_primary", + "action": "/action/add_vpn_peer", + "method": "post", + "text": "Add Peer & Download Conf" + }, + { + "type": "button_cancel", + "text": "Cancel" + } + ] + } + ] + } + ] + }, + { + "type": "card", + "label": "WireGuard Interface", + "client_requirement": "client_is_administrator+", + "items": [ + { + "type": "form", + "action": "/action/apply_vpn", + "method": "post", + "items": [ + { + "type": "field", + "label": "Listen Port", + "name": "vpn_listen_port", + "input_type": "number", + "value": "%VPN_LISTEN_PORT%", + "min": 1024, + "max": 65535, + "hint": "UDP port WireGuard listens on. Must match your port forwarding rule." + }, + { + "type": "field", + "label": "Server Endpoint", + "name": "vpn_server_endpoint", + "input_type": "text", + "validate": "endpoint", + "value": "%VPN_SERVER_ENDPOINT%", + "placeholder": "e.g. vpn.example.com", + "hint": "Publicly reachable hostname or IP of this server, embedded in client config files." + }, + { + "type": "field", + "label": "Domain", + "name": "vpn_domain", + "input_type": "text", + "validate": "dashname", + "value": "%VPN_DOMAIN%", + "placeholder": "e.g. local", + "hint": "DNS search domain pushed to VPN clients." + }, + { + "type": "field", + "label": "DNS Override", + "name": "vpn_dns_server", + "input_type": "text", + "validate": "ipv4", + "value": "%VPN_DNS_SERVER%", + "placeholder": "Leave blank to use gateway IP (%VPN_GATEWAY%)", + "hint": "Explicit DNS server pushed to peers. Defaults to the gateway IP." + }, + { + "type": "field", + "label": "MTU Override", + "name": "vpn_mtu", + "input_type": "number", + "value": "%VPN_MTU%", + "placeholder": "Leave blank for default", + "hint": "Override tunnel MTU. Leave blank for the system default." + }, + { + "type": "button_row", + "items": [ + { + "type": "button_primary", + "action": "/action/apply_vpn", + "method": "post", + "text": "Save" + }, + { + "type": "button_cancel", + "text": "Cancel" + } + ] + } + ] + } + ] + } + ] +} \ No newline at end of file diff --git a/docker/routlin-dash/app/view_page.py b/docker/routlin-dash/app/view_page.py index 3d3b74f..325daa0 100644 --- a/docker/routlin-dash/app/view_page.py +++ b/docker/routlin-dash/app/view_page.py @@ -4,7 +4,9 @@ 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 config_hash, get_pending_entries, get_dashboard_pending, get_dashboard_done, load_snapshot_for_uuid, load_all_snapshots, get_done_timestamps, queue_command, _find_cmd_in_queues, _entry_ts_from_queue, _apply_changes_immediately, _seconds_until_next_run, _format_timing, _is_locked, _lock_mtime, WEB_APP_DISPLAY_NAME, CONFIGS_DIR, DATA_DIR, WWW_DIR +from config_utils import config_hash, get_pending_entries, get_dashboard_pending, get_dashboard_done, load_snapshot_for_uuid, load_all_snapshots, get_done_timestamps, queue_command, _find_cmd_in_queues, _entry_ts_from_queue, _apply_changes_immediately, _seconds_until_next_run, _format_timing, _is_locked, _lock_mtime, WEB_APP_DISPLAY_NAME, CONFIGS_DIR, DATA_DIR, WWW_DIR, ACCOUNTS_FILE, APP_DIR +import os as _os +_PAGES_DIR = _os.path.join(APP_DIR, 'pages') bp = Blueprint('view_page', __name__) @@ -46,7 +48,7 @@ def _load_json(path): def _load_config(): return _load_json(f'{CONFIGS_DIR}/config.json') def _load_ddns(): return _load_config().get('ddns', {}) -def _load_accounts(): return _load_json(f'{DATA_DIR}/authorized_accounts.json') +def _load_accounts(): return _load_json(ACCOUNTS_FILE) def _load_css(): try: @@ -64,6 +66,25 @@ def _load_icon(name): return '' +def _build_view_map(): + m = {} + if not _os.path.isdir(_PAGES_DIR): + return m + for name in _os.listdir(_PAGES_DIR): + cpath = _os.path.join(_PAGES_DIR, name, 'content.json') + if _os.path.isfile(cpath): + try: + with open(cpath) as f: + d = json.load(f) + vid = d.get('id') + if vid: + m[vid] = name + except Exception: + pass + return m +_VIEW_MAP = _build_view_map() + + # Shell helper ====================================================== def _run(cmd): @@ -1043,7 +1064,7 @@ def _render_item(item, tokens, inherited_req=None): extra_cls = (' ' + item['class']) if item.get('class') else '' return f'' - if t == 'page_header': + if t == 'header_page_title': return f'' if t in ('section', 'auth_wrapper'): @@ -1192,7 +1213,9 @@ def _render_item(item, tokens, inherited_req=None): f'' if originals else '' ) - return f'
{hash_field}{orig_field}{inner}
' + field_specs, submit_sel = _collect_form_specs(item.get('items', [])) + script = _render_form_script(field_specs, submit_sel) if (field_specs and submit_sel) else '' + return f'
{hash_field}{orig_field}{inner}
{script}' if t == 'hidden': name = e(item.get('name', '')) @@ -1331,7 +1354,6 @@ def _render_field(item, tokens): placeholder = e(apply_tokens(item.get('placeholder', ''), tokens)) hint = e(apply_tokens(item.get('hint', ''), tokens)) hint_html = f'

{hint}

' if hint else '' - extra_cls = f' {e(item["class"])}' if item.get('class') else '' readonly = ' readonly' if item.get('readonly') else '' if input_type == 'hidden': @@ -1387,20 +1409,28 @@ def _render_field(item, tokens): f'' for o in options ) + validate = item.get('validate', '') + depends = item.get('depends', []) + validate_attr = f' data-validate="{e(validate)}"' if validate else '' + depends_attr = f' data-depends="{e(",".join(depends))}"' if depends else '' + dyn_hint = '' if validate else '' return ( f'
' - f'' - f'{hint_html}
' + f'' + f'{hint_html}{dyn_hint}' ) if input_type == 'number': - min_attr = f' min="{item["min"]}"' if 'min' in item else '' - max_attr = f' max="{item["max"]}"' if 'max' in item else '' + min_attr = f' min="{item["min"]}"' if 'min' in item else '' + max_attr = f' max="{item["max"]}"' if 'max' in item else '' + validate = item.get('validate', 'positive_int') + depends = item.get('depends', []) + validate_attr = f' data-validate="{e(validate)}"' + depends_attr = f' data-depends="{e(",".join(depends))}"' if depends else '' dyn_hint_html = '' inp = ( f'' + f' class="form-input form-input-mono"{readonly}{validate_attr}{depends_attr}/>' ) if item.get('layout') == 'inline': return ( @@ -1533,16 +1563,125 @@ def _render_field(item, tokens): '' ) - validate = item.get('validate', '') + validate = item.get('validate', '') + depends = item.get('depends', []) validate_attr = f' data-validate="{e(validate)}"' if validate else '' - dyn_hint = '' if (item.get('readonly') or item.get('dyn_hint') or validate) else '' + depends_attr = f' data-depends="{e(",".join(depends))}"' if depends else '' + dyn_hint = '' if validate else '' return ( f'
' f'{hint_html}{dyn_hint}
' + f' placeholder="{placeholder}" class="form-input"{readonly}{validate_attr}{depends_attr}/>' + f'{hint_html}{dyn_hint}' ) +def _collect_form_specs(items): + """Walk form items; return (field_specs, submit_sel) for factory script generation.""" + fields = [] + submit_sel = None + for item in items: + t = item.get('type', '') + if t == 'field': + itype = item.get('input_type', 'text') + if item.get('validate') or itype == 'checkbox' or itype == 'number': + fields.append(item) + elif t == 'subnet_row': + fields.append(item) + elif t == 'button_primary' and item.get('class'): + first_cls = item['class'].split()[0] + submit_sel = submit_sel or ('.' + first_cls) + elif t in ('field_row', 'button_row', 'section', 'form'): + sub, sub_btn = _collect_form_specs(item.get('items', [])) + fields.extend(sub) + submit_sel = submit_sel or sub_btn + return fields, submit_sel + + +def _render_form_script(field_specs, submit_sel): + """Generate an inline ' + + def _collect_form_originals(items, tokens): """Walk form items and return {name: value} for all input fields (used for original_values).""" result = {} @@ -1948,7 +2087,7 @@ def render_layout(view_id, content_html, tokens): def _render_navbar(active_view, level, tokens, pending_alert=False): - navbar_data = _load_json(f'{DATA_DIR}/navbar_content.json') + navbar_data = _load_json(f'{APP_DIR}/navbar_content.json') left, right = [], [] for item in navbar_data.get('items', []): req = item.get('client_requirement') @@ -2029,8 +2168,8 @@ def view(view_id): return _serve_view(view_id) def _serve_view(view_id): - content_data = _load_json(f'{DATA_DIR}/page_content.json') - view_def = next((v for v in content_data.get('views', []) if v.get('id') == view_id), None) + page_name = _VIEW_MAP.get(view_id) + view_def = _load_json(_os.path.join(_PAGES_DIR, page_name, 'content.json')) if page_name else None if view_def is None: from flask import abort @@ -2039,7 +2178,7 @@ def _serve_view(view_id): view_req = view_def.get('client_requirement') level = _client_level() if not _passes(view_req, level): - return redirect('/view/view_overview' if level > 0 else '/view/view_log_in') + return redirect('/view/view_overview' if level > 0 else '/view/view_login') tokens = collect_tokens() diff --git a/docker/routlin-dash/data/page_content.json b/docker/routlin-dash/data/page_content.json deleted file mode 100644 index 53080c2..0000000 --- a/docker/routlin-dash/data/page_content.json +++ /dev/null @@ -1,3048 +0,0 @@ -{ - "views": [ - { - "id": "view_overview", - "client_requirement": "client_is_nothing+", - "items": [ - { - "type": "auth_wrapper", - "client_requirement": "client_is_nothing=", - "items": [ - { - "type": "auth_card", - "items": [ - { - "type": "h1", - "text": "Routlin Dashboard" - }, - { - "type": "p", - "text": "Log in to monitor and manage your network." - }, - { - "type": "spacer" - }, - { - "type": "button_primary", - "action": "/view/view_log_in", - "text": "Log In" - } - ] - } - ] - }, - { - "type": "page_header", - "client_requirement": "client_is_viewer+", - "items": [ - { - "type": "h1", - "text": "Overview" - }, - { - "type": "p", - "text": "Current network status at a glance." - } - ] - }, - { - "type": "stat_card_grid", - "client_requirement": "client_is_viewer+", - "items": [ - { - "type": "stat_card", - "label": "DHCP Leases", - "value": "%STAT_LEASE_COUNT%", - "sub": "active leases", - "variant": "accent" - }, - { - "type": "stat_card", - "label": "Queries Blocked", - "value": "%STAT_BLOCKED_TODAY%", - "sub": "since midnight", - "variant": "warning" - }, - { - "type": "stat_card", - "label": "Public IP", - "value": "%STAT_PUBLIC_IP%", - "sub": "%STAT_DDNS_HOSTNAME%" - } - ] - }, - { - "type": "card", - "label": "Network", - "client_requirement": "client_is_viewer+", - "items": [ - { - "type": "grid", - "rows": [ - { - "cells": [ - { - "type": "grid_label", - "text": "WAN Interface" - }, - { - "type": "grid_value", - "text": "%GENERAL_WAN_INTERFACE%" - } - ] - }, - { - "cells": [ - { - "type": "grid_label", - "text": "VLANs" - }, - { - "type": "grid_value", - "text": "%OVERVIEW_VLAN_NAMES%" - } - ] - }, - { - "cells": [ - { - "type": "grid_label", - "text": "Firewall" - }, - { - "type": "grid_value", - "text": "%STAT_NFTABLES_STATUS%" - } - ] - }, - { - "cells": [ - { - "type": "grid_label", - "text": "System Uptime" - }, - { - "type": "grid_value", - "text": "%STAT_UPTIME%" - } - ] - } - ] - } - ] - }, - { - "type": "card", - "label": "DNS Blocking", - "client_requirement": "client_is_viewer+", - "items": [ - { - "type": "grid", - "rows": [ - { - "cells": [ - { - "type": "grid_label", - "text": "Blocked Domains" - }, - { - "type": "grid_value", - "text": "%STAT_BLOCKED_DOMAINS%" - } - ] - }, - { - "cells": [ - { - "type": "grid_label", - "text": "Active Blocklists" - }, - { - "type": "grid_value", - "text": "%STAT_BLOCKLIST_COUNT% lists" - } - ] - }, - { - "cells": [ - { - "type": "grid_label", - "text": "Last Refreshed" - }, - { - "type": "grid_value", - "text": "%STAT_BL_LAST_UPDATE%" - } - ] - }, - { - "cells": [ - { - "type": "grid_label", - "text": "Active IP Bans" - }, - { - "type": "grid_value", - "text": "%STAT_BANNED_IP_COUNT% rules" - } - ] - } - ] - } - ] - }, - { - "type": "card", - "label": "DNS Caching", - "client_requirement": "client_is_viewer+", - "items": [ - { - "type": "grid", - "rows": [ - { - "cells": [ - { - "type": "grid_label", - "text": "Total Queries" - }, - { - "type": "grid_value", - "text": "%DNS_STAT_QUERIES%" - } - ] - }, - { - "cells": [ - { - "type": "grid_label", - "text": "Cache Hits" - }, - { - "type": "grid_value", - "text": "%DNS_STAT_HITS% (%DNS_STAT_HIT_RATE% hit rate)" - } - ] - }, - { - "cells": [ - { - "type": "grid_label", - "text": "Forwarded" - }, - { - "type": "grid_value", - "text": "%DNS_STAT_FORWARDED%" - } - ] - }, - { - "cells": [ - { - "type": "grid_label", - "text": "Cache Capacity" - }, - { - "type": "grid_value", - "text": "%DNS_CACHE_SIZE% entries" - } - ] - }, - { - "cells": [ - { - "type": "grid_label", - "text": "Authoritative Answers" - }, - { - "type": "grid_value", - "text": "%DNS_STAT_AUTH%" - } - ] - }, - { - "cells": [ - { - "type": "grid_label", - "text": "TCP Connections Peak" - }, - { - "type": "grid_value", - "text": "%DNS_STAT_TCP_PEAK%" - } - ] - }, - { - "cells": [ - { - "type": "grid_label", - "text": "DNS Providers" - }, - { - "type": "grid_value", - "text": "%OVERVIEW_UPSTREAM_SERVERS%" - } - ] - } - ] - } - ] - } - ] - }, - { - "id": "view_ddns", - "client_requirement": "client_is_viewer+", - "items": [ - { - "type": "page_header", - "items": [ - { - "type": "h1", - "text": "DDNS" - }, - { - "type": "p", - "text": "Dynamic DNS provider status and last known IP update." - } - ] - }, - { - "type": "stat_card_grid", - "items": [ - { - "type": "stat_card", - "label": "Current Public IP", - "value": "%STAT_PUBLIC_IP%", - "sub": "%STAT_PUBLIC_IP_LAST_OBTAINED%" - }, - { - "type": "stat_card", - "label": "IP Check Interval", - "value": "%DDNS_TIMER_INTERVAL%", - "sub": "%STAT_PUBLIC_IP_LAST_CHECKED%", - "edit_action": "/action/ddns_cardipcheckinterval_save", - "edit_field": "timer_interval", - "edit_input_type": "number", - "edit_min": "1", - "edit_suffix": "minutes", - "edit_value": "%DDNS_TIMER_INTERVAL_MINS%" - }, - { - "type": "stat_card", - "label": "IP Check Services", - "value": "%STAT_IP_CHECK_TOTAL%", - "sub": "%STAT_IP_CHECK_SUB%", - "reveal_card_id": "ip-check-services-edit" - } - ] - }, - { - "type": "card", - "id": "ip-check-services-edit", - "label": "IP Check Services", - "hidden": true, - "client_requirement": "client_is_administrator+", - "items": [ - { - "type": "form", - "action": "/action/ddns_cardipcheckservices_save", - "method": "post", - "items": [ - { - "type": "editable_list", - "label": "HTTP APIs", - "name": "http_services", - "item_placeholder": "https://...", - "add_label": "Add HTTP API", - "items": "%IP_CHECK_HTTP_JSON%" - }, - { - "type": "editable_list", - "label": "Dig APIs", - "name": "dig_services", - "item_placeholder": "e.g. @1.1.1.1 ch txt whoami.cloudflare", - "add_label": "Add Dig API", - "items": "%IP_CHECK_DIG_JSON%" - }, - { - "type": "button_row", - "items": [ - { - "type": "button_primary", - "action": "/action/ddns_cardipcheckservices_save", - "method": "post", - "text": "Save" - }, - { - "type": "button_cancel", - "text": "Cancel", - "class": "js-hide-card" - } - ] - } - ] - } - ] - }, - { - "type": "table", - "datasource": "config:ddns_providers", - "empty_message": "No DDNS providers configured.", - "columns": [ - { - "label": "Description", - "field": "description" - }, - { - "label": "Provider", - "field": "provider" - }, - { - "label": "Hostname(s)", - "field": "hostnames", - "render": "tag_list" - }, - { - "label": "Status", - "field": "enabled", - "render": "badge_enabled_disabled" - }, - { - "label": "Credentials", - "field": "credentials", - "render": "raw_html" - } - ], - "row_actions": [ - { - "client_requirement": "client_is_administrator+", - "action": "/action/ddns_tableaccounts_rowedit", - "method": "inline_edit", - "text": "Edit", - "class": "btn-ghost btn-sm", - "fields": [ - { - "col": "description", - "input_type": "text" - }, - { - "col": "provider", - "input_type": "select", - "options": "%DDNS_PROVIDER_OPTIONS%" - }, - { - "col": "hostnames", - "input_type": "textarea" - }, - { - "col": "enabled", - "input_type": "checkbox" - }, - { - "col": "credentials", - "input_type": "credentials" - } - ] - }, - { - "client_requirement": "client_is_administrator+", - "action": "/action/ddns_tableaccounts_rowdelete", - "method": "post", - "text": "Delete", - "class": "btn-danger btn-sm" - } - ] - }, - { - "type": "card", - "label": "Add DDNS Account", - "client_requirement": "client_is_administrator+", - "items": [ - { - "type": "form", - "action": "/action/ddns_cardaddaccount_add", - "method": "post", - "items": [ - { - "type": "field", - "label": "Description", - "name": "description", - "input_type": "text", - "placeholder": "e.g. My DuckDNS Account" - }, - { - "type": "field", - "label": "Provider", - "name": "provider", - "input_type": "select", - "options": "%DDNS_PROVIDER_OPTIONS%" - }, - { - "type": "field", - "label": "Hostnames (one per line)", - "name": "hostnames", - "input_type": "textarea", - "placeholder": "e.g. myhome.duckdns.org" - }, - { - "type": "credential_fields", - "provider_select": "provider" - }, - { - "type": "button_row", - "items": [ - { - "type": "button_primary", - "action": "/action/ddns_cardaddaccount_add", - "method": "post", - "text": "Add Provider" - }, - { - "type": "button_cancel", - "text": "Cancel" - } - ] - } - ] - } - ] - }, - { - "type": "card", - "label": "Logging", - "client_requirement": "client_is_administrator+", - "items": [ - { - "type": "pre_block", - "text": "%DDNS_LOG_TAIL%", - "scroll_to_bottom": true - }, - { - "type": "raw_html", - "html": "%DDNS_LOG_SUMMARY%" - }, - { - "type": "button_row", - "justify": "space-between", - "items": [ - { - "type": "button_ghost", - "action": "/action/ddns_cardlogging_download", - "text": "Download Log" - }, - { - "type": "button_danger", - "action": "/action/ddns_cardlogging_clear", - "method": "post", - "text": "Clear Log" - } - ] - }, - { - "type": "hr" - }, - { - "type": "form", - "action": "/action/ddns_cardlogging_save", - "method": "post", - "items": [ - { - "type": "field", - "label": "Max Log Size (KB)", - "name": "log_max_kb", - "input_type": "number", - "layout": "inline", - "value": "%DDNS_GEN_LOG_MAX_KB%", - "min": "64" - }, - { - "type": "field", - "label": "", - "name": "log_errors_only", - "input_type": "checkbox", - "checkbox_label": "Only record errors to log", - "value": "%DDNS_GEN_LOG_ERRORS_ONLY%" - }, - { - "type": "button_row", - "items": [ - { - "type": "button_primary", - "action": "/action/ddns_cardlogging_save", - "method": "post", - "text": "Save" - }, - { - "type": "button_cancel", - "text": "Cancel" - } - ] - } - ] - } - ] - } - ] - }, - { - "id": "view_actions", - "client_requirement": "client_is_viewer+", - "items": [ - { - "type": "page_header", - "items": [ - { - "type": "h1", - "text": "Actions" - }, - { - "type": "p", - "text": "Apply or stage pending configuration changes." - } - ] - }, - { - "type": "card", - "label": "Pending Actions", - "client_requirement": "client_is_administrator+", - "items": [ - { - "type": "form", - "action": "/action/actions_cardpending_applynow", - "method": "post", - "items": [ - { - "type": "raw_html", - "html": "%PENDING_ACTIONS_HTML%" - }, - { - "type": "button_row", - "items": [ - { - "type": "button_primary", - "text": "Apply Now", - "disabled": "%NO_PENDING%" - }, - { - "type": "raw_html", - "html": "%APPLY_WARNING%" - } - ] - } - ] - }, - { - "type": "hr" - }, - { - "type": "form", - "action": "/action/actions_cardpending_save", - "method": "post", - "items": [ - { - "type": "field", - "label": "Apply Changes Immediately", - "name": "apply_changes_immediately", - "input_type": "checkbox", - "value": "%GENERAL_APPLY_ON_SAVE%", - "hint": "When enabled, saved changes are queued immediately. When disabled, changes accumulate in Pending Actions until you click Apply Now." - }, - { - "type": "button_row", - "items": [ - { - "type": "button_primary", - "action": "/action/actions_cardpending_save", - "method": "post", - "text": "Save" - }, - { - "type": "button_cancel", - "text": "Cancel" - } - ] - } - ] - } - ] - }, - { - "type": "card", - "label": "Change History", - "client_requirement": "client_is_administrator+", - "items": [ - { - "type": "form", - "action": "/action/actions_cardhistory_revertselected", - "method": "post", - "items": [ - { - "type": "raw_html", - "html": "%CHANGE_HISTORY_HTML%" - }, - { - "type": "button_row", - "items": [ - { - "type": "button_danger", - "text": "Revert Selected", - "disabled": "%NO_HISTORY%" - } - ] - } - ] - } - ] - } - ] - }, - { - "id": "view_physical_interfaces", - "client_requirement": "client_is_administrator+", - "items": [ - { - "type": "page_header", - "items": [ - { - "type": "h1", - "text": "Physical Interfaces" - }, - { - "type": "p", - "text": "WAN/LAN interface assignments and per-interface settings." - } - ] - }, - { - "type": "card", - "label": "Physical Interfaces", - "client_requirement": "client_is_administrator+", - "items": [ - { - "type": "form", - "action": "/action/physicalinterfaces_cardphysicalinterface_save", - "method": "post", - "items": [ - { - "type": "field", - "label": "WAN Interface", - "name": "wan_interface", - "input_type": "interface_picker", - "value": "%GENERAL_WAN_INTERFACE%", - "data": "%NETWORK_INTERFACE_DATA_JSON%" - }, - { - "type": "field", - "label": "LAN Interface", - "name": "lan_interface", - "input_type": "interface_picker", - "value": "%GENERAL_LAN_INTERFACE%", - "data": "%NETWORK_INTERFACE_DATA_JSON%" - }, - { - "type": "button_row", - "items": [ - { - "type": "button_primary", - "action": "/action/physicalinterfaces_cardphysicalinterface_save", - "method": "post", - "text": "Save" - }, - { - "type": "button_cancel", - "text": "Cancel" - } - ] - } - ] - } - ] - }, - { - "type": "card", - "id": "iface-config-card", - "label": "Interface Configuration", - "hidden": true, - "client_requirement": "client_is_administrator+", - "items": [ - { - "type": "form", - "action": "/action/physicalinterfaces_cardinterfaceconfiguration_apply", - "method": "post", - "items": [ - { - "type": "hidden", - "name": "original_mtu", - "value": "" - }, - { - "type": "hidden", - "name": "original_mac", - "value": "" - }, - { - "type": "field_row", - "cols": 3, - "items": [ - { - "type": "field", - "label": "Interface", - "name": "iface", - "input_type": "text", - "readonly": true, - "value": "" - }, - { - "type": "field", - "label": "MTU", - "name": "mtu", - "input_type": "select", - "value": "", - "options": [ - { - "label": "576", - "value": "576" - }, - { - "label": "1280", - "value": "1280" - }, - { - "label": "1492", - "value": "1492" - }, - { - "label": "1500", - "value": "1500" - }, - { - "label": "4096", - "value": "4096" - }, - { - "label": "9000", - "value": "9000" - } - ] - }, - { - "type": "field", - "label": "MAC Address", - "name": "mac", - "input_type": "text", - "validate": "mac", - "value": "" - } - ] - }, - { - "type": "button_row", - "items": [ - { - "type": "button_primary", - "action": "/action/physicalinterfaces_cardinterfaceconfiguration_apply", - "method": "post", - "text": "Apply" - }, - { - "type": "button_secondary", - "action": "#", - "text": "Cancel", - "class": "iface-config-cancel" - } - ] - } - ] - } - ] - }, - { - "type": "raw_html", - "html": "




" - } - ] - }, - { - "id": "view_dns_server", - "client_requirement": "client_is_administrator+", - "items": [ - { - "type": "page_header", - "items": [ - { - "type": "h1", - "text": "DNS Server" - }, - { - "type": "p", - "text": "Upstream resolvers and forwarding DNS service settings." - } - ] - }, - { - "type": "card", - "label": "Upstream DNS", - "client_requirement": "client_is_administrator+", - "items": [ - { - "type": "form", - "action": "/action/dnsserver_cardupstreamdns_save", - "method": "post", - "items": [ - { - "type": "editable_list", - "label": "DNS Providers", - "name": "upstream_servers", - "item_placeholder": "e.g. 1.1.1.1", - "add_label": "Add Provider", - "validate": "ip", - "hint": "DNS resolvers queried for external hostnames. Supports IPv4 and IPv6.", - "items": "%DNS_UPSTREAM_SERVERS_JSON%" - }, - { - "type": "field", - "label": "Strict Order", - "name": "strict_order", - "input_type": "checkbox", - "value": "%DNS_STRICT_ORDER%", - "hint": "Query DNS providers in list order rather than in parallel." - }, - { - "type": "button_row", - "items": [ - { - "type": "button_primary", - "action": "/action/dnsserver_cardupstreamdns_save", - "method": "post", - "text": "Save" - }, - { - "type": "button_cancel", - "text": "Cancel" - } - ] - } - ] - } - ] - }, - { - "type": "card", - "label": "DNS Forwarding", - "client_requirement": "client_is_administrator+", - "items": [ - { - "type": "form", - "action": "/action/dnsserver_carddnsforwarding_save", - "method": "post", - "items": [ - { - "type": "field", - "label": "Cache Size", - "name": "cache_size", - "input_type": "number", - "value": "%DNS_CACHE_SIZE%", - "min": 0, - "hint": "Max DNS responses to cache per instance. Set to 0 to disable caching." - }, - { - "type": "button_row", - "items": [ - { - "type": "button_primary", - "action": "/action/dnsserver_carddnsforwarding_save", - "method": "post", - "text": "Save" - }, - { - "type": "button_cancel", - "text": "Cancel" - } - ] - } - ] - } - ] - } - ] - }, - { - "id": "view_banned_ips", - "client_requirement": "client_is_viewer+", - "items": [ - { - "type": "page_header", - "items": [ - { - "type": "h1", - "text": "Banned IPs" - }, - { - "type": "p", - "text": "IPs and ranges blocked in both directions at the nftables firewall." - } - ] - }, - { - "type": "info_bar", - "variant": "info", - "text": "Supports single IPs, CIDR (94.130.0.0/16), wildcards (94.130.*.*), and ranges (94.130.52.1-20). IPv4 and IPv6 are both supported." - }, - { - "type": "table", - "datasource": "config:banned_ips", - "empty_message": "No IP bans configured.", - "columns": [ - { - "label": "Description", - "field": "description" - }, - { - "label": "IP / Range", - "field": "ip", - "class": "col-mono" - }, - { - "label": "Status", - "field": "enabled", - "render": "badge_enabled_disabled" - } - ], - "row_actions": [ - { - "client_requirement": "client_is_administrator+", - "action": "/action/edit_banned_ip", - "method": "inline_edit", - "text": "Edit", - "class": "btn-ghost btn-sm", - "fields": [ - { - "col": "description", - "input_type": "text" - }, - { - "col": "ip", - "input_type": "text" - }, - { - "col": "enabled", - "input_type": "checkbox", - "checkbox_label": "Enabled" - } - ] - }, - { - "client_requirement": "client_is_administrator+", - "action": "/action/delete_banned_ip", - "method": "post", - "text": "Delete", - "class": "btn-danger btn-sm" - } - ] - }, - { - "type": "card", - "id": "add-form", - "label": "Add Banned IP", - "client_requirement": "client_is_administrator+", - "items": [ - { - "type": "form", - "action": "/action/add_banned_ip", - "method": "post", - "items": [ - { - "type": "field", - "label": "Description", - "name": "description", - "input_type": "text", - "placeholder": "e.g. Bad actor", - "hint": "Optional label for this entry." - }, - { - "type": "field", - "label": "IP / Range", - "name": "ip", - "input_type": "text", - "placeholder": "e.g. 1.2.3.4 or 1.2.3.0/24" - }, - { - "type": "button_row", - "items": [ - { - "type": "button_primary", - "action": "/action/add_banned_ip", - "method": "post", - "text": "Add Banned IP" - }, - { - "type": "button_cancel", - "text": "Cancel" - } - ] - } - ] - } - ] - } - ] - }, - { - "id": "view_host_overrides", - "client_requirement": "client_is_viewer+", - "items": [ - { - "type": "page_header", - "items": [ - { - "type": "h1", - "text": "Host Overrides" - }, - { - "type": "p", - "text": "Force a hostname to resolve to a specific internal IP." - } - ] - }, - { - "type": "table", - "datasource": "config:host_overrides", - "empty_message": "No host overrides configured.", - "columns": [ - { - "label": "Description", - "field": "description" - }, - { - "label": "Hostname", - "field": "host", - "class": "col-mono" - }, - { - "label": "Resolves To", - "field": "ip", - "class": "col-mono" - }, - { - "label": "Status", - "field": "enabled", - "render": "badge_enabled_disabled" - } - ], - "row_actions": [ - { - "client_requirement": "client_is_administrator+", - "action": "/action/edit_host_override", - "method": "inline_edit", - "text": "Edit", - "class": "btn-ghost btn-sm", - "fields": [ - { - "col": "description", - "input_type": "text" - }, - { - "col": "host", - "input_type": "text", - "validate": "domainname" - }, - { - "col": "ip", - "input_type": "text", - "validate": "ip" - }, - { - "col": "enabled", - "input_type": "checkbox", - "checkbox_label": "Enabled" - } - ] - }, - { - "client_requirement": "client_is_administrator+", - "action": "/action/delete_host_override", - "method": "post", - "text": "Delete", - "class": "btn-danger btn-sm" - } - ] - }, - { - "type": "card", - "id": "add-form", - "label": "Add Host Override", - "client_requirement": "client_is_administrator+", - "items": [ - { - "type": "form", - "action": "/action/add_host_override", - "method": "post", - "items": [ - { - "type": "field", - "label": "Description", - "name": "description", - "input_type": "text", - "placeholder": "e.g. Local server" - }, - { - "type": "field", - "label": "Hostname", - "name": "host", - "input_type": "text", - "validate": "domainname", - "placeholder": "e.g. server.home.local" - }, - { - "type": "field", - "label": "Resolves To", - "name": "ip", - "input_type": "text", - "validate": "ip", - "placeholder": "e.g. 192.168.1.100" - }, - { - "type": "button_row", - "items": [ - { - "type": "button_primary", - "action": "/action/add_host_override", - "method": "post", - "text": "Add Host Override" - }, - { - "type": "button_cancel", - "text": "Cancel" - } - ] - } - ] - } - ] - } - ] - }, - { - "id": "view_dns_blocking", - "client_requirement": "client_is_viewer+", - "items": [ - { - "type": "page_header", - "items": [ - { - "type": "h1", - "text": "DNS Blocking" - }, - { - "type": "p", - "text": "Domain level blocking via dnsmasq." - } - ] - }, - { - "type": "table", - "datasource": "config:blocklists", - "empty_message": "No blocklists configured.", - "columns": [ - { - "label": "Name", - "field": "name" - }, - { - "label": "Description", - "field": "description" - }, - { - "label": "Format", - "field": "format", - "class": "col-mono" - }, - { - "label": "Source URL", - "field": "url", - "class": "col-mono" - } - ], - "row_actions": [ - { - "client_requirement": "client_is_administrator+", - "action": "/action/dnsblocking_tableblocklists_rowedit", - "method": "inline_edit", - "text": "Edit", - "class": "btn-ghost btn-sm", - "fields": [ - { - "col": "name", - "input_type": "text", - "validate": "dashname" - }, - { - "col": "description", - "input_type": "text" - }, - { - "col": "format", - "input_type": "select", - "options": "%BLOCKLIST_FORMAT_OPTIONS%" - }, - { - "col": "url", - "input_type": "text", - "validate": "url" - } - ] - }, - { - "client_requirement": "client_is_administrator+", - "action": "/action/dnsblocking_tableblocklists_rowdelete", - "method": "post", - "text": "Delete", - "class": "btn-danger btn-sm" - } - ] - }, - { - "type": "card", - "id": "add-form", - "label": "Add Blocklist", - "client_requirement": "client_is_administrator+", - "items": [ - { - "type": "form", - "action": "/action/dnsblocking_cardaddblocklist_add", - "method": "post", - "items": [ - { - "type": "field", - "label": "Name", - "name": "name", - "input_type": "text", - "validate": "dashname", - "placeholder": "e.g. steven-black" - }, - { - "type": "field", - "label": "Description", - "name": "description", - "input_type": "text", - "placeholder": "e.g. Steven Black (ads, malware, trackers)" - }, - { - "type": "field", - "label": "Format", - "name": "format", - "input_type": "select", - "options": "%BLOCKLIST_FORMAT_OPTIONS%" - }, - { - "type": "field", - "label": "Source URL", - "name": "url", - "input_type": "text", - "validate": "url", - "placeholder": "https://..." - }, - { - "type": "button_row", - "items": [ - { - "type": "button_primary", - "action": "/action/dnsblocking_cardaddblocklist_add", - "method": "post", - "text": "Add Blocklist" - }, - { - "type": "button_cancel", - "text": "Cancel" - } - ] - } - ] - } - ] - }, - { - "type": "card", - "label": "Blocklist Refresh", - "client_requirement": "client_is_administrator+", - "items": [ - { - "type": "raw_html", - "html": "%BLOCKLIST_STATS_HTML%" - }, - { - "type": "hr" - }, - { - "type": "button_row", - "items": [ - { - "type": "button_secondary", - "action": "/action/dnsblocking_cardblocklistrefresh_refreshnow", - "method": "post", - "text": "Refresh All Now" - } - ] - }, - { - "type": "hr" - }, - { - "type": "form", - "action": "/action/dnsblocking_cardblocklistrefresh_save", - "method": "post", - "items": [ - { - "type": "field", - "label": "Daily Refresh Time", - "name": "daily_execute_time_24hr_local", - "input_type": "text", - "validate": "time_24h", - "value": "%GENERAL_DAILY_EXECUTE_TIME%", - "placeholder": "e.g. 02:30", - "hint": "24-hour local time for the daily blocklist refresh." - }, - { - "type": "button_row", - "items": [ - { - "type": "button_primary", - "action": "/action/dnsblocking_cardblocklistrefresh_save", - "method": "post", - "text": "Save" - }, - { - "type": "button_cancel", - "text": "Cancel" - } - ] - } - ] - } - ] - }, - { - "type": "card", - "label": "Logging", - "client_requirement": "client_is_administrator+", - "items": [ - { - "type": "form", - "action": "/action/dnsblocking_cardlogging_save", - "method": "post", - "items": [ - { - "type": "field", - "label": "Max Log Size (KB)", - "name": "log_max_kb", - "input_type": "number", - "value": "%GENERAL_LOG_MAX_KB%", - "min": 64, - "hint": "Log is cleared and restarted when it exceeds this size." - }, - { - "type": "field", - "label": "Only record errors to log", - "name": "log_errors_only", - "input_type": "checkbox", - "value": "%GENERAL_LOG_ERRORS_ONLY%", - "hint": "Only write error-level messages to the log." - }, - { - "type": "button_row", - "items": [ - { - "type": "button_primary", - "action": "/action/dnsblocking_cardlogging_save", - "method": "post", - "text": "Save" - }, - { - "type": "button_cancel", - "text": "Cancel" - } - ] - } - ] - } - ] - } - ] - }, - { - "id": "view_network_layout", - "client_requirement": "client_is_viewer+", - "items": [ - { - "type": "page_header", - "items": [ - { - "type": "h1", - "text": "Network Layout" - }, - { - "type": "p", - "text": "Network segments managed by systemd-networkd, dnsmasq, nftables, and freeradius." - } - ] - }, - { - "type": "info_bar", - "variant": "info", - "text": "For a basic flat network with no VLAN segmentation, only use VLAN 1 and delete the others." - }, - { - "type": "table", - "datasource": "config:vlans", - "empty_message": "No VLANs configured.", - "columns": [ - { - "label": "VLAN ID", - "field": "vlan_id", - "class": "col-mono col-narrow" - }, - { - "label": "Name", - "field": "name", - "class": "col-narrow" - }, - { - "label": "Interface", - "field": "interface", - "class": "col-mono col-narrow" - }, - { - "label": "Subnet", - "field": "subnet", - "class": "col-mono col-narrow" - }, - { - "label": "Mask", - "field": "subnet_mask", - "class": "col-mono col-narrow" - }, - { - "label": "Self Ident(s)", - "field": "server_identity_ips", - "render": "tag_list" - }, - { - "label": "Blocklists", - "field": "use_blocklists", - "class": "col-expand", - "render": "tag_list" - }, - { - "label": "Default", - "field": "radius_default", - "class": "col-narrow", - "render": "badge_yes_no", - "render_options": { "title_true": "RADIUS Default", "title_false": "Not RADIUS Default" } - }, - { - "label": "mDNS", - "field": "mdns_reflection", - "class": "col-narrow", - "render": "badge_yes_no", - "render_options": { "title_true": "mDNS Reflection Enabled", "title_false": "mDNS Reflection Disabled" } - }, - { - "label": "Record", - "field": "dnsmasq_log_queries", - "class": "col-narrow", - "render": "badge_yes_no", - "render_options": { "title_true": "DNS Queries Recorded", "title_false": "DNS Queries Not Recorded" } - } - ], - "row_actions": [ - { - "client_requirement": "client_is_administrator+", - "action": "/action/networklayout_tablevlans_edit", - "method": "inline_edit", - "text": "Edit", - "class": "btn-ghost btn-sm", - "fields": [ - { - "col": "name", - "input_type": "text", - "validate": "dashname" - }, - { - "col": "subnet", - "input_type": "text", - "validate": "subnet" - }, - { - "col": "subnet_mask", - "input_type": "number", - "min": 1, - "max": 30 - }, - { - "col": "server_identity_ips", - "input_type": "textarea_pair", - "col_label": "IP Address", - "col_validate": "ip", - "pair_col": "server_identity_descriptions", - "pair_label": "Description (Opt)", - "pair_wide": true, - "pair_col2": "server_identity_hostnames", - "pair_label2": "Hostname (Opt)", - "pair_validate2": "networkname", - "gateway_col": "server_identity_gateway", - "dns_col": "server_identity_dns_server", - "ntp_col": "server_identity_ntp_server" - }, - { - "col": "radius_default", - "input_type": "checkbox", - "checkbox_label": "Enabled" - }, - { - "col": "mdns_reflection", - "input_type": "checkbox", - "checkbox_label": "Enabled" - }, - { - "col": "dnsmasq_log_queries", - "input_type": "checkbox", - "checkbox_label": "Record" - }, - { - "col": "use_blocklists", - "input_type": "checkbox_multi", - "options": "%BLOCKLIST_NAME_OPTIONS%" - } - ] - }, - { - "client_requirement": "client_is_administrator+", - "action": "/action/networklayout_tablevlans_delete", - "method": "post", - "text": "Delete", - "class": "btn-danger btn-sm", - "disable_if": { - "field": "vlan_id", - "value": 1 - } - } - ] - }, - { - "type": "card", - "id": "add-form", - "label": "Add VLAN", - "client_requirement": "client_is_administrator+", - "items": [ - { - "type": "form", - "action": "/action/networklayout_cardaddvlan_addvlan", - "method": "post", - "items": [ - { - "type": "field_row", - "cols": 4, - "items": [ - { - "type": "field", - "label": "VLAN ID", - "name": "vlan_id", - "input_type": "number", - "min": 1, - "max": 4094, - "class": "vlan-id-input form-input-mono", - "hint": "Unique integer 1-4094. Sets the 802.1Q tag and interface name." - }, - { - "type": "field", - "label": "VLAN Name", - "name": "name", - "input_type": "text", - "validate": "dashname", - "hint": "Lowercase letters, digits, hyphens. E.g. iot" - }, - { - "type": "subnet_row", - "subnet_name": "subnet", - "prefix_name": "subnet_mask", - "subnet_placeholder": "e.g. 192.168.x.0", - "prefix_value": "24" - }, - { - "type": "field", - "label": "VLAN Type", - "name": "is_vpn", - "input_type": "checkbox", - "checkbox_label": "Is VPN", - "hint": "Check if this VLAN uses a WireGuard interface (e.g. wg0, wg1, etc)." - } - ] - }, - { - "type": "hr" - }, - { - "type": "identity_builder", - "label": "Router Identities on this VLAN:" - }, - { - "type": "hr" - }, - { - "type": "field", - "label": "Blocklists", - "name": "use_blocklists", - "input_type": "checkbox_group", - "options": "%BLOCKLIST_NAME_OPTIONS%", - "hint": "Note: Selected lists will be merged and de-duplicated prior to use." - }, - { - "type": "hr" - }, - { - "type": "field", - "label": "RADIUS Default", - "name": "radius_default", - "input_type": "checkbox", - "hint": "Wireless devices without a DHCP reservation will be placed into this VLAN. (Note: wired devices are not placed via RADIUS but rather by layer 3 switch policy.)" - }, - { - "type": "field", - "label": "mDNS Reflection", - "name": "mdns_reflection", - "input_type": "checkbox", - "hint": "Reflect mDNS traffic to/from this VLAN via avahi-daemon. Not supported on WireGuard interfaces." - }, - { - "type": "field", - "label": "Record DNS Queries", - "name": "dnsmasq_log_queries", - "input_type": "checkbox", - "hint": "Log every DNS query. High volume - enable for debugging only." - }, - { - "type": "button_row", - "items": [ - { - "type": "button_primary", - "action": "/action/networklayout_cardaddvlan_addvlan", - "method": "post", - "text": "Add VLAN", - "class": "add-vlan-btn", - "disabled": true - }, - { - "type": "button_cancel", - "text": "Cancel" - } - ] - } - ] - } - ] - } - ] - }, - { - "id": "view_inter_vlan", - "client_requirement": "client_is_viewer+", - "items": [ - { - "type": "page_header", - "items": [ - { - "type": "h1", - "text": "Inter-VLAN Exceptions" - }, - { - "type": "p", - "text": "Firewall rules that permit specific traffic to cross VLAN boundaries." - } - ] - }, - { - "type": "table", - "datasource": "config:inter_vlan_exceptions", - "empty_message": "No inter-VLAN exceptions configured. All cross-VLAN traffic is blocked by default.", - "columns": [ - { - "label": "Description", - "field": "description" - }, - { - "label": "Protocol", - "field": "protocol", - "class": "col-mono" - }, - { - "label": "Source", - "field": "src_ip_or_subnet", - "class": "col-mono" - }, - { - "label": "Destination", - "field": "dst_ip_or_subnet", - "class": "col-mono" - }, - { - "label": "Dest Port", - "field": "dst_port", - "class": "col-mono" - }, - { - "label": "Status", - "field": "enabled", - "render": "badge_enabled_disabled" - } - ], - "row_actions": [ - { - "client_requirement": "client_is_administrator+", - "action": "/action/edit_inter_vlan", - "method": "inline_edit", - "text": "Edit", - "class": "btn-ghost btn-sm", - "fields": [ - { - "col": "description", - "input_type": "text" - }, - { - "col": "protocol", - "input_type": "select", - "options": "%PROTOCOL_OPTIONS%" - }, - { - "col": "src_ip_or_subnet", - "input_type": "text" - }, - { - "col": "dst_ip_or_subnet", - "input_type": "text" - }, - { - "col": "dst_port", - "input_type": "text" - }, - { - "col": "enabled", - "input_type": "checkbox", - "checkbox_label": "Enabled" - } - ] - }, - { - "client_requirement": "client_is_administrator+", - "action": "/action/delete_inter_vlan", - "method": "post", - "text": "Delete", - "class": "btn-danger btn-sm" - } - ] - }, - { - "type": "card", - "id": "add-form", - "label": "Add Exception", - "client_requirement": "client_is_administrator+", - "items": [ - { - "type": "form", - "action": "/action/add_inter_vlan", - "method": "post", - "items": [ - { - "type": "field", - "label": "Description", - "name": "description", - "input_type": "text", - "placeholder": "e.g. Allow Chromecast" - }, - { - "type": "field", - "label": "Protocol", - "name": "protocol", - "input_type": "select", - "options": "%PROTOCOL_OPTIONS%" - }, - { - "type": "field", - "label": "Source", - "name": "src_ip_or_subnet", - "input_type": "text", - "validate": "ipv4cidr", - "placeholder": "e.g. 192.168.20.0/24" - }, - { - "type": "field", - "label": "Destination", - "name": "dst_ip_or_subnet", - "input_type": "text", - "validate": "ipv4", - "placeholder": "e.g. 192.168.10.100" - }, - { - "type": "field", - "label": "Dest Port", - "name": "dst_port", - "input_type": "text", - "validate": "port", - "placeholder": "e.g. 8009" - }, - { - "type": "button_row", - "items": [ - { - "type": "button_primary", - "action": "/action/add_inter_vlan", - "method": "post", - "text": "Add Exception" - }, - { - "type": "button_cancel", - "text": "Cancel" - } - ] - } - ] - } - ] - } - ] - }, - { - "id": "view_port_forwarding", - "client_requirement": "client_is_viewer+", - "items": [ - { - "type": "page_header", - "items": [ - { - "type": "h1", - "text": "Port Forwarding" - }, - { - "type": "p", - "text": "DNAT rules that forward inbound WAN traffic to internal hosts." - } - ] - }, - { - "type": "table", - "datasource": "config:port_forwarding", - "empty_message": "No port forwarding rules configured.", - "columns": [ - { - "label": "Description", - "field": "description" - }, - { - "label": "Protocol", - "field": "protocol", - "class": "col-mono" - }, - { - "label": "Ext Port", - "field": "dest_port", - "class": "col-mono" - }, - { - "label": "NAT IP", - "field": "nat_ip", - "class": "col-mono" - }, - { - "label": "NAT Port", - "field": "nat_port", - "class": "col-mono" - }, - { - "label": "Status", - "field": "enabled", - "render": "badge_enabled_disabled" - } - ], - "row_actions": [ - { - "client_requirement": "client_is_administrator+", - "action": "/action/edit_port_forward", - "method": "inline_edit", - "text": "Edit", - "class": "btn-ghost btn-sm", - "fields": [ - { - "col": "description", - "input_type": "text" - }, - { - "col": "protocol", - "input_type": "select", - "options": "%PROTOCOL_OPTIONS%" - }, - { - "col": "dest_port", - "input_type": "text" - }, - { - "col": "nat_ip", - "input_type": "text" - }, - { - "col": "nat_port", - "input_type": "text" - }, - { - "col": "enabled", - "input_type": "checkbox", - "checkbox_label": "Enabled" - } - ] - }, - { - "client_requirement": "client_is_administrator+", - "action": "/action/delete_port_forward", - "method": "post", - "text": "Delete", - "class": "btn-danger btn-sm" - } - ] - }, - { - "type": "card", - "id": "add-form", - "label": "Add Rule", - "client_requirement": "client_is_administrator+", - "items": [ - { - "type": "form", - "action": "/action/add_port_forward", - "method": "post", - "items": [ - { - "type": "field", - "label": "Description", - "name": "description", - "input_type": "text", - "placeholder": "e.g. Minecraft server" - }, - { - "type": "field", - "label": "Protocol", - "name": "protocol", - "input_type": "select", - "options": "%PROTOCOL_OPTIONS%" - }, - { - "type": "field", - "label": "Ext Port", - "name": "dest_port", - "input_type": "text", - "validate": "port", - "placeholder": "e.g. 25565" - }, - { - "type": "field", - "label": "NAT IP", - "name": "nat_ip", - "input_type": "text", - "validate": "ipv4", - "placeholder": "e.g. 192.168.1.50" - }, - { - "type": "field", - "label": "NAT Port", - "name": "nat_port", - "input_type": "text", - "validate": "port", - "placeholder": "e.g. 25565" - }, - { - "type": "button_row", - "items": [ - { - "type": "button_primary", - "action": "/action/add_port_forward", - "method": "post", - "text": "Add Rule" - }, - { - "type": "button_cancel", - "text": "Cancel" - } - ] - } - ] - } - ] - } - ] - }, - { - "id": "view_dhcp", - "client_requirement": "client_is_viewer+", - "items": [ - { - "type": "page_header", - "items": [ - { - "type": "h1", - "text": "DHCP" - }, - { - "type": "p", - "text": "Active leases, IP reservations, and VLAN authorizations." - } - ] - }, - { - "type": "table", - "datasource": "live:dhcp_leases", - "empty_message": "No active DHCP leases found.", - "columns": [ - { - "label": "Hostname", - "field": "hostname" - }, - { - "label": "IP Address", - "field": "ip_address", - "class": "col-mono" - }, - { - "label": "MAC Address", - "field": "mac_address", - "class": "col-mono" - }, - { - "label": "VLAN", - "field": "vlan_name" - }, - { - "label": "Expires", - "field": "expires" - } - ] - }, - { - "type": "table", - "datasource": "config:dhcp_reservations", - "empty_message": "No DHCP reservations configured.", - "columns": [ - { - "label": "Description", - "field": "description" - }, - { - "label": "Hostname", - "field": "hostname", - "class": "col-mono" - }, - { - "label": "MAC", - "field": "mac", - "class": "col-mono" - }, - { - "label": "IP", - "field": "ip", - "class": "col-mono" - }, - { - "label": "VLAN", - "field": "vlan_name" - }, - { - "label": "RADIUS", - "field": "radius_client", - "render": "badge_yes_no" - }, - { - "label": "Status", - "field": "enabled", - "render": "badge_enabled_disabled" - } - ], - "toolbar": { - "items": [ - { - "type": "select", - "name": "vlan_filter", - "value": "all", - "options": "%VLAN_FILTER_OPTIONS%", - "filter_col": "vlan_name" - } - ] - }, - "row_actions": [ - { - "client_requirement": "client_is_administrator+", - "action": "/action/edit_dhcp_reservation", - "method": "inline_edit", - "text": "Edit", - "class": "btn-ghost btn-sm", - "fields": [ - { - "col": "description", - "input_type": "text" - }, - { - "col": "hostname", - "input_type": "text", - "validate": "networkname" - }, - { - "col": "mac", - "input_type": "text", - "validate": "mac" - }, - { - "col": "ip", - "input_type": "text" - }, - { - "col": "radius_client", - "input_type": "checkbox", - "checkbox_label": "Enabled" - }, - { - "col": "enabled", - "input_type": "checkbox", - "checkbox_label": "Enabled" - } - ] - }, - { - "client_requirement": "client_is_administrator+", - "action": "/action/delete_dhcp_reservation", - "method": "post", - "text": "Delete", - "class": "btn-danger btn-sm" - } - ] - }, - { - "type": "card", - "id": "add-form", - "label": "Add Reservation/Authorization", - "client_requirement": "client_is_administrator+", - "items": [ - { - "type": "form", - "action": "/action/add_dhcp_reservation", - "method": "post", - "items": [ - { - "type": "field", - "label": "VLAN", - "name": "vlan_name", - "input_type": "select", - "options": "%VLAN_NAMES_AS_OPTIONS%", - "hint": "VLAN this reservation belongs to." - }, - { - "type": "field", - "label": "Description", - "name": "description", - "input_type": "text", - "placeholder": "e.g. NAS" - }, - { - "type": "field", - "label": "Hostname", - "name": "hostname", - "input_type": "text", - "validate": "networkname", - "placeholder": "e.g. nas" - }, - { - "type": "field", - "label": "MAC Address", - "name": "mac", - "input_type": "text", - "validate": "mac", - "placeholder": "e.g. aa:bb:cc:dd:ee:ff" - }, - { - "type": "field", - "label": "IP Address", - "name": "ip", - "input_type": "text", - "placeholder": "e.g. 192.168.10.50", - "hint": "Leave blank to authorize device on this VLAN dynamically." - }, - { - "type": "field", - "label": "RADIUS Client", - "name": "radius_client", - "input_type": "checkbox", - "hint": "This device acts as a RADIUS authenticator, verifying credentials of other devices on the network." - }, - { - "type": "button_row", - "items": [ - { - "type": "button_primary", - "action": "/action/add_dhcp_reservation", - "method": "post", - "text": "Add" - }, - { - "type": "button_cancel", - "text": "Cancel" - } - ] - } - ] - } - ] - } - ] - }, - { - "id": "view_vpn", - "client_requirement": "client_is_viewer+", - "items": [ - { - "type": "page_header", - "items": [ - { - "type": "h1", - "text": "VPN" - }, - { - "type": "p", - "text": "WireGuard peer management and server interface configuration." - } - ] - }, - { - "type": "table", - "label": "Active Sessions", - "datasource": "live:vpn_sessions", - "empty_message": "No active VPN sessions.", - "columns": [ - { - "label": "Peer", - "field": "peer_name" - }, - { - "label": "Tunnel IP", - "field": "tunnel_ip", - "class": "col-mono" - }, - { - "label": "Endpoint", - "field": "endpoint", - "class": "col-mono" - }, - { - "label": "Last Handshake", - "field": "last_handshake" - }, - { - "label": "Received", - "field": "rx_bytes", - "class": "col-mono" - }, - { - "label": "Sent", - "field": "tx_bytes", - "class": "col-mono" - } - ] - }, - { - "type": "table", - "label": "Peers", - "datasource": "config:vpn_peers", - "empty_message": "No peers configured. Use Add Peer below.", - "columns": [ - { - "label": "Name", - "field": "name" - }, - { - "label": "Assigned VLAN", - "field": "vlan_display", - "class": "col-mono" - }, - { - "label": "Assigned IP", - "field": "ip", - "class": "col-mono" - }, - { - "label": "Split Tunnel", - "field": "split_tunnel" - }, - { - "label": "Enabled", - "field": "enabled", - "render": "badge_enabled_disabled" - }, - { - "label": "Public Key", - "field": "pubkey_short", - "class": "col-mono" - } - ], - "row_actions": [ - { - "client_requirement": "client_is_administrator+", - "action": "/action/edit_vpn_peer", - "method": "inline_edit", - "text": "Edit", - "class": "btn-ghost btn-sm", - "fields": [ - { - "col": "name", - "input_type": "text", - "validate": "dashname" - }, - { - "col": "split_tunnel", - "input_type": "checkbox", - "checkbox_label": "Enabled" - }, - { - "col": "enabled", - "input_type": "checkbox", - "checkbox_label": "Enabled" - } - ] - }, - { - "client_requirement": "client_is_administrator+", - "action": "/action/regenerate_vpn_peer", - "method": "post", - "text": "Regen Conf", - "class": "btn-ghost btn-sm" - }, - { - "client_requirement": "client_is_administrator+", - "action": "/action/delete_vpn_peer", - "method": "post", - "text": "Delete", - "class": "btn-danger btn-sm" - } - ] - }, - { - "type": "card", - "label": "Add Peer", - "client_requirement": "client_is_administrator+", - "items": [ - { - "type": "form", - "action": "/action/add_vpn_peer", - "method": "post", - "items": [ - { - "type": "field", - "label": "Name", - "name": "peer_name", - "input_type": "text", - "validate": "dashname", - "placeholder": "e.g. laptop", - "hint": "Friendly name for this peer." - }, - { - "type": "field", - "label": "Assigned VLAN", - "name": "peer_vlan", - "input_type": "select", - "options": "%VPN_VLAN_OPTIONS%" - }, - { - "type": "field", - "label": "Assigned IP", - "name": "peer_ip", - "input_type": "text", - "validate": "ipv4", - "placeholder": "e.g. 192.168.40.2", - "hint": "Static IP assigned to this peer within the VPN subnet." - }, - { - "type": "field", - "label": "Split Tunnel", - "name": "split_tunnel", - "input_type": "checkbox", - "hint": "Route only VPN subnet traffic through the tunnel. When unchecked all traffic is routed through the VPN." - }, - { - "type": "field", - "label": "Enabled", - "name": "enabled", - "input_type": "checkbox", - "checked": true - }, - { - "type": "button_row", - "items": [ - { - "type": "button_primary", - "action": "/action/add_vpn_peer", - "method": "post", - "text": "Add Peer & Download Conf" - }, - { - "type": "button_cancel", - "text": "Cancel" - } - ] - } - ] - } - ] - }, - { - "type": "card", - "label": "WireGuard Interface", - "client_requirement": "client_is_administrator+", - "items": [ - { - "type": "form", - "action": "/action/apply_vpn", - "method": "post", - "items": [ - { - "type": "field", - "label": "Listen Port", - "name": "vpn_listen_port", - "input_type": "number", - "value": "%VPN_LISTEN_PORT%", - "min": 1024, - "max": 65535, - "hint": "UDP port WireGuard listens on. Must match your port forwarding rule." - }, - { - "type": "field", - "label": "Server Endpoint", - "name": "vpn_server_endpoint", - "input_type": "text", - "validate": "endpoint", - "value": "%VPN_SERVER_ENDPOINT%", - "placeholder": "e.g. vpn.example.com", - "hint": "Publicly reachable hostname or IP of this server, embedded in client config files." - }, - { - "type": "field", - "label": "Domain", - "name": "vpn_domain", - "input_type": "text", - "validate": "dashname", - "value": "%VPN_DOMAIN%", - "placeholder": "e.g. local", - "hint": "DNS search domain pushed to VPN clients." - }, - { - "type": "field", - "label": "DNS Override", - "name": "vpn_dns_server", - "input_type": "text", - "validate": "ipv4", - "value": "%VPN_DNS_SERVER%", - "placeholder": "Leave blank to use gateway IP (%VPN_GATEWAY%)", - "hint": "Explicit DNS server pushed to peers. Defaults to the gateway IP." - }, - { - "type": "field", - "label": "MTU Override", - "name": "vpn_mtu", - "input_type": "number", - "value": "%VPN_MTU%", - "placeholder": "Leave blank for default", - "hint": "Override tunnel MTU. Leave blank for the system default." - }, - { - "type": "button_row", - "items": [ - { - "type": "button_primary", - "action": "/action/apply_vpn", - "method": "post", - "text": "Save" - }, - { - "type": "button_cancel", - "text": "Cancel" - } - ] - } - ] - } - ] - } - ] - }, - { - "id": "view_log_in", - "client_requirement": "client_is_nothing=", - "items": [ - { - "type": "auth_wrapper", - "client_requirement": "client_is_nothing=", - "items": [ - { - "type": "auth_card", - "items": [ - { - "type": "h1", - "text": "Log In" - }, - { - "type": "p", - "text": "Enter your credentials to access the dashboard." - }, - { - "type": "hr" - }, - { - "type": "form", - "action": "/action/log_in", - "method": "post", - "items": [ - { - "type": "field", - "label": "Email Address", - "name": "email", - "input_type": "text", - "placeholder": "you@example.com" - }, - { - "type": "field", - "label": "Password", - "name": "password", - "input_type": "password", - "placeholder": "Password" - }, - { - "type": "button_primary", - "action": "/action/log_in", - "method": "post", - "text": "Log In", - "class": "btn-full" - } - ] - }, - { - "type": "p", - "text": "Need to complete your account?", - "link": { - "action": "/view/view_create_account", - "text": "Create Account" - } - } - ] - } - ] - }, - { - "type": "section", - "client_requirement": "client_is_viewer+", - "items": [ - { - "type": "h1", - "text": "Already logged in." - }, - { - "type": "p", - "text": "You are already authenticated." - }, - { - "type": "spacer" - }, - { - "type": "button_primary", - "action": "/view/overview", - "text": "Go to Overview" - } - ] - } - ] - }, - { - "id": "view_create_account", - "client_requirement": "client_is_nothing=", - "items": [ - { - "type": "auth_wrapper", - "client_requirement": "client_is_nothing=", - "items": [ - { - "type": "auth_card", - "items": [ - { - "type": "h1", - "text": "Complete Your Account" - }, - { - "type": "p", - "text": "If your email has been pre-registered by a manager, setup your account below." - }, - { - "type": "hr" - }, - { - "type": "form", - "action": "/action/create_account", - "method": "post", - "items": [ - { - "type": "field", - "label": "Email Address", - "name": "email", - "input_type": "text", - "placeholder": "you@example.com", - "hint": "Must match your pre-registered email address." - }, - { - "type": "field", - "label": "New Password", - "name": "password", - "input_type": "password", - "placeholder": "Choose a strong password" - }, - { - "type": "field", - "label": "Confirm Password", - "name": "password_confirm", - "input_type": "password", - "placeholder": "Repeat your password" - }, - { - "type": "field", - "label": "Timezone", - "name": "timezone", - "input_type": "select", - "value": "", - "options": "%TIMEZONE_OPTIONS%", - "hint": "Used to display timestamps in your local time." - }, - { - "type": "button_primary", - "action": "/action/create_account", - "method": "post", - "text": "Create Account", - "class": "btn-full" - } - ] - }, - { - "type": "p", - "text": "Already have an account?", - "link": { - "action": "/view/view_log_in", - "text": "Log In" - } - } - ] - } - ] - }, - { - "type": "section", - "client_requirement": "client_is_viewer+", - "items": [ - { - "type": "h1", - "text": "Already logged in." - }, - { - "type": "p", - "text": "Your account is already active." - }, - { - "type": "spacer" - }, - { - "type": "button_primary", - "action": "/view/overview", - "text": "Go to Overview" - } - ] - } - ] - }, - { - "id": "view_verify_email", - "client_requirement": "client_is_nothing=", - "items": [ - { - "type": "auth_wrapper", - "client_requirement": "client_is_nothing=", - "items": [ - { - "type": "auth_card", - "items": [ - { - "type": "h1", - "text": "Verify Your Email" - }, - { - "type": "p", - "text": "A 6-digit code was sent to your email address. Enter it below to complete your account." - }, - { - "type": "form", - "action": "/action/verify_email", - "method": "post", - "items": [ - { - "type": "field", - "label": "Verification Code", - "name": "code", - "input_type": "text", - "placeholder": "000000", - "hint": "The code expires in 15 minutes." - }, - { - "type": "button_primary", - "action": "/action/verify_email", - "method": "post", - "text": "Verify", - "class": "btn-full" - } - ] - }, - { - "type": "p", - "text": "Didn't receive it?", - "link": { - "action": "/action/resend_verification", - "text": "Resend code" - } - }, - { - "type": "p", - "text": "Wrong email?", - "link": { - "action": "/view/view_create_account", - "text": "Start over" - } - } - ] - } - ] - }, - { - "type": "section", - "client_requirement": "client_is_viewer+", - "items": [ - { - "type": "h1", - "text": "Already logged in." - }, - { - "type": "p", - "text": "Your account is already active." - }, - { - "type": "spacer" - }, - { - "type": "button_primary", - "action": "/view/view_overview", - "text": "Go to Overview" - } - ] - } - ] - }, - { - "id": "view_preferences", - "client_requirement": "client_is_viewer+", - "items": [ - { - "type": "page_header", - "items": [ - { - "type": "h1", - "text": "Preferences" - }, - { - "type": "p", - "text": "Your personal account settings." - } - ] - }, - { - "type": "card", - "label": "Account Details", - "items": [ - { - "type": "form", - "action": "/action/save_preferences", - "method": "post", - "items": [ - { - "type": "field", - "label": "Email Address", - "name": "email", - "input_type": "text", - "value": "%PREF_EMAIL%", - "hint": "Contact your manager to change your email address." - }, - { - "type": "field", - "label": "Timezone", - "name": "timezone", - "input_type": "select", - "value": "%PREF_TIMEZONE%", - "options": "%TIMEZONE_OPTIONS%", - "hint": "All timestamps will be displayed in this timezone." - }, - { - "type": "button_row", - "items": [ - { - "type": "button_primary", - "action": "/action/save_preferences", - "method": "post", - "text": "Save Preferences" - } - ] - } - ] - } - ] - }, - { - "type": "card", - "label": "Change Password", - "items": [ - { - "type": "form", - "action": "/action/change_password", - "method": "post", - "items": [ - { - "type": "field", - "label": "Current Password", - "name": "current_password", - "input_type": "password", - "placeholder": "Current password" - }, - { - "type": "field", - "label": "New Password", - "name": "new_password", - "input_type": "password", - "placeholder": "New password" - }, - { - "type": "field", - "label": "Confirm Password", - "name": "confirm_password", - "input_type": "password", - "placeholder": "Repeat new password" - }, - { - "type": "button_row", - "items": [ - { - "type": "button_primary", - "action": "/action/change_password", - "method": "post", - "text": "Change Password" - } - ] - } - ] - } - ] - } - ] - }, - { - "id": "view_manage_accounts", - "client_requirement": "client_is_manager+", - "items": [ - { - "type": "page_header", - "items": [ - { - "type": "h1", - "text": "Manage Accounts" - } - ] - }, - { - "type": "table", - "datasource": "config:accounts", - "empty_message": "No accounts configured.", - "columns": [ - { - "label": "Email Address", - "field": "email_address" - }, - { - "label": "Access Level", - "field": "access_level" - }, - { - "label": "Added By", - "field": "account_created_by" - }, - { - "label": "Added", - "field": "account_created_utc" - }, - { - "label": "Status", - "field": "account_status", - "render": "badge_active_inactive" - } - ], - "row_actions": [ - { - "action": "/action/delete_account", - "method": "post", - "text": "Remove", - "class": "btn-danger btn-sm" - } - ] - }, - { - "type": "card", - "label": "Authorize New Account", - "items": [ - { - "type": "form", - "action": "/action/add_account", - "method": "post", - "items": [ - { - "type": "field", - "label": "Email Address", - "name": "email_address", - "input_type": "text", - "placeholder": "user@example.com", - "hint": "The user will verify ownership of this address during account setup." - }, - { - "type": "field", - "label": "Access Level", - "name": "access_level", - "input_type": "select", - "options": "%ACCOUNT_LEVEL_OPTIONS%" - }, - { - "type": "button_row", - "items": [ - { - "type": "button_primary", - "action": "/action/add_account", - "method": "post", - "text": "Authorize" - } - ] - } - ] - } - ] - } - ] - } - ] -}