Development

This commit is contained in:
Matthew Grotke 2026-05-27 20:56:30 -04:00
parent d8d1d46fd2
commit eed1d295dc
69 changed files with 3355 additions and 3230 deletions

View file

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

View file

@ -13,7 +13,7 @@ def require_level(minimum):
if LEVEL_RANK.get(current, 0) < LEVEL_RANK.get(minimum, 0): if LEVEL_RANK.get(current, 0) < LEVEL_RANK.get(minimum, 0):
if current == 'nothing': if current == 'nothing':
flash('Please log in to continue.', 'error') 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') flash('You do not have permission to perform this action.', 'error')
return redirect('/view/view_overview') return redirect('/view/view_overview')
return f(*args, **kwargs) return f(*args, **kwargs)

View file

@ -1,11 +1,13 @@
import copy, json, subprocess, hashlib, os, uuid import copy, json, subprocess, hashlib, os, uuid
import os as _os
from datetime import datetime, timezone from datetime import datetime, timezone
from flask import session from flask import session
APP_DIR = _os.path.dirname(_os.path.abspath(__file__))
CONFIGS_DIR = '/routlin_location' CONFIGS_DIR = '/routlin_location'
DATA_DIR = '/data' DATA_DIR = '/data'
WWW_DIR = '/www' 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' CONFIG_FILE = f'{CONFIGS_DIR}/config.json'
DASHBOARD_QUEUE = f'{CONFIGS_DIR}/.dashboard-queue' DASHBOARD_QUEUE = f'{CONFIGS_DIR}/.dashboard-queue'
DASHBOARD_DONE = f'{CONFIGS_DIR}/.dashboard-done' DASHBOARD_DONE = f'{CONFIGS_DIR}/.dashboard-done'

View file

@ -1,60 +1,59 @@
import os, json, sys import os, json, sys
from flask import Flask from flask import Flask
from config_utils import ACCOUNTS_FILE
from view_page import bp as view_page_bp from view_page import bp as view_page_bp
from action_actions import bp as action_actions_bp from pages.actions.action import bp as actions_bp
from action_physicalinterfaces import bp as action_physicalinterfaces_bp from pages.bannedips.action import bp as bannedips_bp
from action_dnsserver import bp as action_dnsserver_bp from pages.ddns.action import bp as ddns_bp
from action_apply_mdns import bp as action_apply_mdns_bp from pages.dhcp.action import bp as dhcp_bp
from action_apply_vpn import bp as action_apply_vpn_bp from pages.dnsblocking.action import bp as dnsblocking_bp
from action_apply_banned_ips import bp as action_apply_banned_ips_bp from pages.dnsserver.action import bp as dnsserver_bp
from action_apply_host_overrides import bp as action_apply_host_overrides_bp from pages.hostoverrides.action import bp as hostoverrides_bp
from action_dnsblocking import bp as action_dnsblocking_bp from pages.intervlan.action import bp as intervlan_bp
from action_networklayout import bp as action_networklayout_bp from pages.accountlogin.action import bp as accountlogin_bp
from action_apply_inter_vlan import bp as action_apply_inter_vlan_bp from pages.networklayout.action import bp as networklayout_bp
from action_apply_port_forwarding import bp as action_apply_port_forwarding_bp from pages.physicalinterfaces.action import bp as physicalinterfaces_bp
from action_apply_dhcp_reservations import bp as action_apply_dhcp_reservations_bp from pages.portforwarding.action import bp as portforwarding_bp
from action_create_account import bp as action_create_account_bp from pages.preferences.action import bp as preferences_bp
from action_log_in import bp as action_log_in_bp from pages.accountverifyemail.action import bp as accountverifyemail_bp
from action_log_out import bp as action_log_out_bp from pages.vpn.action import bp as vpn_bp
from action_verify_email import bp as action_verify_email_bp from pages.accountcreate.action import bp as accountcreate_bp
from action_add_account import bp as action_add_account_bp from pages.accountadd.action import bp as accountadd_bp
from action_delete_account import bp as action_delete_account_bp from pages.accountdelete.action import bp as accountdelete_bp
from action_save_preferences import bp as action_save_preferences_bp from pages.accountlogout.action import bp as accountlogout_bp
from action_change_password import bp as action_change_password_bp from pages.mdns.action import bp as mdns_bp
from action_ddns import bp as action_ddns_bp
from api_apply_health import bp as api_apply_health_bp from api_apply_health import bp as api_apply_health_bp
app = Flask(__name__) app = Flask(__name__)
app.secret_key = os.environ.get('SECRET_KEY', os.urandom(24)) app.secret_key = os.environ.get('SECRET_KEY', os.urandom(24))
app.register_blueprint(view_page_bp) app.register_blueprint(view_page_bp)
app.register_blueprint(action_actions_bp) app.register_blueprint(actions_bp)
app.register_blueprint(action_physicalinterfaces_bp) app.register_blueprint(bannedips_bp)
app.register_blueprint(action_dnsserver_bp) app.register_blueprint(ddns_bp)
app.register_blueprint(action_apply_mdns_bp) app.register_blueprint(dhcp_bp)
app.register_blueprint(action_apply_vpn_bp) app.register_blueprint(dnsblocking_bp)
app.register_blueprint(action_apply_banned_ips_bp) app.register_blueprint(dnsserver_bp)
app.register_blueprint(action_apply_host_overrides_bp) app.register_blueprint(hostoverrides_bp)
app.register_blueprint(action_dnsblocking_bp) app.register_blueprint(intervlan_bp)
app.register_blueprint(action_networklayout_bp) app.register_blueprint(accountlogin_bp)
app.register_blueprint(action_apply_inter_vlan_bp) app.register_blueprint(networklayout_bp)
app.register_blueprint(action_apply_port_forwarding_bp) app.register_blueprint(physicalinterfaces_bp)
app.register_blueprint(action_apply_dhcp_reservations_bp) app.register_blueprint(portforwarding_bp)
app.register_blueprint(action_create_account_bp) app.register_blueprint(preferences_bp)
app.register_blueprint(action_log_in_bp) app.register_blueprint(accountverifyemail_bp)
app.register_blueprint(action_log_out_bp) app.register_blueprint(vpn_bp)
app.register_blueprint(action_verify_email_bp) app.register_blueprint(accountcreate_bp)
app.register_blueprint(action_add_account_bp) app.register_blueprint(accountadd_bp)
app.register_blueprint(action_delete_account_bp) app.register_blueprint(accountdelete_bp)
app.register_blueprint(action_save_preferences_bp) app.register_blueprint(accountlogout_bp)
app.register_blueprint(action_change_password_bp) app.register_blueprint(mdns_bp)
app.register_blueprint(action_ddns_bp)
app.register_blueprint(api_apply_health_bp) app.register_blueprint(api_apply_health_bp)
def _seed_initial_account(): def _seed_initial_account():
email = os.environ.get('INITIAL_MANAGER_EMAIL', '').strip().lower() email = os.environ.get('INITIAL_MANAGER_EMAIL', '').strip().lower()
if not email: if not email:
try: try:
with open(accounts_file) as f: with open(ACCOUNTS_FILE) as f:
data = json.load(f) data = json.load(f)
except Exception: except Exception:
data = {'accounts': []} data = {'accounts': []}
@ -62,9 +61,8 @@ def _seed_initial_account():
print('[main] WARNING: No accounts exist and INITIAL_MANAGER_EMAIL is not set. ' 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) 'Set it in docker-compose.yml to seed the initial manager account.', file=sys.stderr)
return return
accounts_file = '/data/authorized_accounts.json'
try: try:
with open(accounts_file) as f: with open(ACCOUNTS_FILE) as f:
data = json.load(f) data = json.load(f)
except Exception: except Exception:
data = {'accounts': []} data = {'accounts': []}
@ -76,7 +74,7 @@ def _seed_initial_account():
'hashed_password': '', 'hashed_password': '',
'timezone': '', 'timezone': '',
}] }]
with open(accounts_file, 'w') as f: with open(ACCOUNTS_FILE, 'w') as f:
json.dump(data, f, indent=2) json.dump(data, f, indent=2)
print(f'[main] Seeded initial manager account: {email}', file=sys.stderr) print(f'[main] Seeded initial manager account: {email}', file=sys.stderr)

View file

@ -11,17 +11,17 @@
"label": "%MENU_LABEL%", "label": "%MENU_LABEL%",
"client_requirement": "client_is_viewer+", "client_requirement": "client_is_viewer+",
"items": [ "items": [
{ "type": "nav_item", "label": "Physical Interfaces", "map_to": "view_physical_interfaces", "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_dns_server", "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_dns_blocking", "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_network_layout", "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_inter_vlan", "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_port_forwarding", "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": "DHCP", "map_to": "view_dhcp" },
{ "type": "nav_item", "label": "DDNS", "map_to": "view_ddns" }, { "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": "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+", "client_requirement": "client_is_viewer+",
"items": [ "items": [
{ "type": "nav_item", "label": "Preferences", "map_to": "view_preferences" }, { "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_action", "label": "Log Out", "action": "log_out" }
] ]
}, },
{ {
"type": "nav_item", "type": "nav_item",
"label": "Log In", "label": "Log In",
"map_to": "view_log_in", "map_to": "view_login",
"align": "right", "align": "right",
"client_requirement": "client_is_nothing=" "client_requirement": "client_is_nothing="
}, },
{ {
"type": "nav_item", "type": "nav_item",
"label": "Create Account", "label": "Create Account",
"map_to": "view_create_account", "map_to": "view_createaccount",
"align": "right", "align": "right",
"client_requirement": "client_is_nothing=" "client_requirement": "client_is_nothing="
} }

View file

@ -5,7 +5,7 @@ from auth import require_level
from config_utils import ACCOUNTS_FILE from config_utils import ACCOUNTS_FILE
import sanitize import sanitize
bp = Blueprint('action_add_account', __name__) bp = Blueprint('accountadd', __name__)
VALID_LEVELS = {'viewer', 'administrator', 'manager'} VALID_LEVELS = {'viewer', 'administrator', 'manager'}
@ -31,22 +31,22 @@ def add_account():
if not email: if not email:
flash('Email address is required.', 'error') 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): if not re.match(r'^[^@\s]+@[^@\s]+\.[^@\s]+$', email):
flash('Email address does not appear to be valid.', 'error') 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: if access_level not in VALID_LEVELS:
flash('Invalid access level.', 'error') flash('Invalid access level.', 'error')
return redirect('/view/view_manage_accounts') return redirect('/view/view_manageaccounts')
data = _load_accounts() data = _load_accounts()
accounts = data.get('accounts', []) accounts = data.get('accounts', [])
if any(a.get('email_address', '').lower() == email for a in accounts): if any(a.get('email_address', '').lower() == email for a in accounts):
flash('An account with that email address already exists.', 'error') 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') now = datetime.now(tz=timezone.utc).strftime('%Y-%m-%dT%H:%M:%SZ')
accounts.append({ accounts.append({
@ -61,4 +61,4 @@ def add_account():
_save_accounts(data) _save_accounts(data)
flash(f'Authorization added for {email}. User must complete account setup via the Create Account page.', 'success') 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')

View file

@ -6,7 +6,7 @@ from auth import require_level
from config_utils import WEB_APP_DISPLAY_NAME, ACCOUNTS_FILE from config_utils import WEB_APP_DISPLAY_NAME, ACCOUNTS_FILE
import sanitize import sanitize
bp = Blueprint('action_create_account', __name__) bp = Blueprint('accountcreate', __name__)
CODE_TTL_MIN = 15 CODE_TTL_MIN = 15
@ -62,26 +62,26 @@ def create_account():
if not email or not password or not password_confirm or not tz: if not email or not password or not password_confirm or not tz:
flash('All fields are required.', 'error') flash('All fields are required.', 'error')
return redirect('/view/view_create_account') return redirect('/view/view_createaccount')
if password != password_confirm: if password != password_confirm:
flash('Passwords do not match.', 'error') flash('Passwords do not match.', 'error')
return redirect('/view/view_create_account') return redirect('/view/view_createaccount')
if len(password) < 8: if len(password) < 8:
flash('Password must be at least 8 characters.', 'error') 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', []) accounts = _load_accounts().get('accounts', [])
account = next((a for a in accounts if a.get('email_address', '').lower() == email), None) account = next((a for a in accounts if a.get('email_address', '').lower() == email), None)
if account is None: if account is None:
flash('Email address not recognised. Contact your manager.', 'error') 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'): if account.get('hashed_password'):
flash('This account is already set up. Please log in instead.', 'error') 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() salt = bcrypt.gensalt()
hashed = bcrypt.hashpw(password.encode('utf-8'), salt) hashed = bcrypt.hashpw(password.encode('utf-8'), salt)
@ -92,7 +92,7 @@ def create_account():
_send_verification_email(account['email_address'], code) _send_verification_email(account['email_address'], code)
except Exception as exc: except Exception as exc:
flash(f'Could not send verification email: {exc}', 'error') flash(f'Could not send verification email: {exc}', 'error')
return redirect('/view/view_create_account') return redirect('/view/view_createaccount')
session['pending_create_account'] = { session['pending_create_account'] = {
'email': account['email_address'], 'email': account['email_address'],
@ -102,4 +102,4 @@ def create_account():
'expires': expires, 'expires': expires,
} }
return redirect('/view/view_verify_email') return redirect('/view/view_verifyemail')

View file

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

View file

@ -3,7 +3,7 @@ import json
from auth import require_level from auth import require_level
from config_utils import ACCOUNTS_FILE 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', '')) row_index = int(request.form.get('row_index', ''))
except (ValueError, TypeError): except (ValueError, TypeError):
flash('Invalid request.', 'error') flash('Invalid request.', 'error')
return redirect('/view/view_manage_accounts') return redirect('/view/view_manageaccounts')
data = _load_accounts() data = _load_accounts()
accounts = data.get('accounts', []) accounts = data.get('accounts', [])
if row_index < 0 or row_index >= len(accounts): if row_index < 0 or row_index >= len(accounts):
flash('Account not found.', 'error') flash('Account not found.', 'error')
return redirect('/view/view_manage_accounts') return redirect('/view/view_manageaccounts')
target = accounts[row_index] target = accounts[row_index]
if target.get('email_address', '').lower() == session.get('email_address', '').lower(): if target.get('email_address', '').lower() == session.get('email_address', '').lower():
flash('You cannot remove your own account.', 'error') 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', '') removed_email = target.get('email_address', '')
accounts.pop(row_index) accounts.pop(row_index)
@ -47,4 +47,4 @@ def delete_account():
_save_accounts(data) _save_accounts(data)
flash(f'Account for {removed_email} has been removed.', 'success') flash(f'Account for {removed_email} has been removed.', 'success')
return redirect('/view/view_manage_accounts') return redirect('/view/view_manageaccounts')

View file

@ -4,7 +4,7 @@ from auth import require_level
from config_utils import ACCOUNTS_FILE from config_utils import ACCOUNTS_FILE
import sanitize 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: if not email or not password:
flash('Email address and password are required.', 'error') flash('Email address and password are required.', 'error')
return redirect('/view/view_log_in') return redirect('/view/view_login')
accounts = _load_accounts().get('accounts', []) accounts = _load_accounts().get('accounts', [])
account = next((a for a in accounts if a.get('email_address', '').lower() == email), None) account = next((a for a in accounts if a.get('email_address', '').lower() == email), None)
if account is None: if account is None:
flash('Email address not recognised.', 'error') flash('Email address not recognised.', 'error')
return redirect('/view/view_log_in') return redirect('/view/view_login')
if not account.get('hashed_password'): if not account.get('hashed_password'):
flash('Account setup is not complete. Please use Create Account to set your password first.', 'error') 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') stored_hash = account['hashed_password'].encode('utf-8')
if not bcrypt.checkpw(password.encode('utf-8'), stored_hash): if not bcrypt.checkpw(password.encode('utf-8'), stored_hash):
flash('Invalid email address or password.', 'error') flash('Invalid email address or password.', 'error')
return redirect('/view/view_log_in') return redirect('/view/view_login')
session.clear() session.clear()
session['email_address'] = account['email_address'] session['email_address'] = account['email_address']

View file

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

View file

@ -1,7 +1,7 @@
from flask import Blueprint, session, redirect from flask import Blueprint, session, redirect
from auth import require_level from auth import require_level
bp = Blueprint('action_log_out', __name__) bp = Blueprint('accountlogout', __name__)
@bp.route('/action/log_out', methods=['POST']) @bp.route('/action/log_out', methods=['POST'])

View file

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

View file

@ -4,7 +4,7 @@ from datetime import datetime, timezone, timedelta
from auth import require_level from auth import require_level
from config_utils import ACCOUNTS_FILE 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: if not pending:
flash('No pending account creation found. Please start over.', 'error') 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']) expires = datetime.fromisoformat(pending['expires'])
if datetime.now(tz=timezone.utc) > expires: if datetime.now(tz=timezone.utc) > expires:
session.pop('pending_create_account', None) session.pop('pending_create_account', None)
flash('Verification code has expired. Please start over.', 'error') 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() submitted = request.form.get('code', '').strip()
if submitted != pending['code']: if submitted != pending['code']:
flash('Incorrect verification code.', 'error') flash('Incorrect verification code.', 'error')
return redirect('/view/view_verify_email') return redirect('/view/view_verifyemail')
data = _load_accounts() data = _load_accounts()
accounts = data.get('accounts', []) accounts = data.get('accounts', [])
@ -54,12 +54,12 @@ def verify_email():
if account is None: if account is None:
session.pop('pending_create_account', None) session.pop('pending_create_account', None)
flash('Account no longer exists. Contact your manager.', 'error') 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'): if account.get('hashed_password'):
session.pop('pending_create_account', None) session.pop('pending_create_account', None)
flash('This account is already set up. Please log in.', 'error') 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') now = datetime.now(tz=timezone.utc).strftime('%Y-%m-%dT%H:%M:%SZ')
account['hashed_password'] = pending['hashed_password'] account['hashed_password'] = pending['hashed_password']
@ -87,13 +87,13 @@ def resend_verification():
if session.get('access_level', 'nothing') != 'nothing': if session.get('access_level', 'nothing') != 'nothing':
return redirect('/view/view_overview') 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') pending = session.get('pending_create_account')
if not pending: if not pending:
flash('No pending account creation found. Please start over.', 'error') 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}' code = f'{secrets.randbelow(1000000):06d}'
expires = (datetime.now(tz=timezone.utc) + timedelta(minutes=CODE_TTL_MIN)).isoformat() 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) _send_verification_email(pending['email'], code)
except Exception as exc: except Exception as exc:
flash(f'Could not resend verification email: {exc}', 'error') flash(f'Could not resend verification email: {exc}', 'error')
return redirect('/view/view_verify_email') return redirect('/view/view_verifyemail')
pending['code'] = code pending['code'] = code
pending['expires'] = expires pending['expires'] = expires
session['pending_create_account'] = pending session['pending_create_account'] = pending
flash('A new verification code has been sent.', 'success') flash('A new verification code has been sent.', 'success')
return redirect('/view/view_verify_email') return redirect('/view/view_verifyemail')

View file

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

View file

@ -3,7 +3,7 @@ from auth import require_level
from config_utils import (flush_pending_to_queue, get_dashboard_pending, from config_utils import (flush_pending_to_queue, get_dashboard_pending,
revert_snapshot_to_config, queued_msg) revert_snapshot_to_config, queued_msg)
bp = Blueprint('action_actions', __name__) bp = Blueprint('actions', __name__)
_VIEW = '/view/view_actions' _VIEW = '/view/view_actions'

View file

@ -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%"
}
]
}
]
}
]
}
]
}

View file

@ -6,9 +6,9 @@ from config_utils import load_config, save_config_with_snapshot, verify_config_h
import sanitize import sanitize
import validation as validate 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(): def _row_index():

View file

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

View file

@ -6,7 +6,7 @@ from config_utils import load_config, verify_config_hash, save_config_with_snaps
import sanitize import sanitize
import validation as validate import validation as validate
bp = Blueprint('action_ddns', __name__) bp = Blueprint('ddns', __name__)
VIEW = '/view/view_ddns' VIEW = '/view/view_ddns'
LOG_FILE = f'{CONFIGS_DIR}/ddns.log' LOG_FILE = f'{CONFIGS_DIR}/ddns.log'

View file

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

View file

@ -7,7 +7,7 @@ from config_utils import load_config, save_config_with_snapshot, verify_config_h
import sanitize import sanitize
import validation as validate import validation as validate
bp = Blueprint('action_apply_dhcp_reservations', __name__) bp = Blueprint('dhcp', __name__)
VIEW = '/view/view_dhcp' VIEW = '/view/view_dhcp'

View file

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

View file

@ -6,9 +6,9 @@ from config_utils import load_config, save_config_with_snapshot, verify_config_h
import sanitize import sanitize
import validation as validate 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)) _VALID_FORMATS_STR = ', '.join(sorted(validate.VALID_BLOCKLIST_FORMATS))

View file

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

View file

@ -5,9 +5,9 @@ from config_utils import load_config, save_config_with_snapshot, verify_config_h
import sanitize import sanitize
import validation as validate 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']) @bp.route('/action/dnsserver_cardupstreamdns_save', methods=['POST'])

View file

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

View file

@ -7,9 +7,9 @@ from config_utils import load_config, save_config_with_snapshot, verify_config_h
import sanitize import sanitize
import validation as validate 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): def _vlan_networks(cfg):

View file

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

View file

@ -6,9 +6,9 @@ from config_utils import load_config, save_config_with_snapshot, verify_config_h
import sanitize import sanitize
import validation as validate 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)) _VALID_PROTOS_STR = ', '.join(sorted(validate.VALID_PROTOCOLS))

View file

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

View file

@ -6,7 +6,7 @@ from config_utils import load_config, save_config_with_snapshot, verify_config_h
import sanitize import sanitize
import validation as validate import validation as validate
bp = Blueprint('action_apply_mdns', __name__) bp = Blueprint('mdns', __name__)
@bp.route('/action/apply_mdns', methods=['POST']) @bp.route('/action/apply_mdns', methods=['POST'])

View file

@ -7,9 +7,9 @@ from config_utils import load_config, save_config_with_snapshot, verify_config_h
import sanitize import sanitize
import validation as validate 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', _VLAN_FIELDS = ['name', 'vlan_id', 'is_vpn', 'subnet', 'subnet_mask', 'dnsmasq_log_queries',
'radius_default', 'mdns_reflection', 'use_blocklists'] 'radius_default', 'mdns_reflection', 'use_blocklists']

View file

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

View file

@ -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%"
}
]
}
]
}
]
}
]
}

View file

@ -7,9 +7,9 @@ from config_utils import load_config, save_config_with_snapshot, verify_config_h
import sanitize import sanitize
import validation as validate 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', _EXCLUDE_PREFIXES = ('lo', 'wg', 'docker', 'br-', 'veth',
'tun', 'tap', 'ppp', 'virbr', 'tun', 'tap', 'ppp', 'virbr',

View file

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

View file

@ -6,9 +6,9 @@ from config_utils import load_config, save_config_with_snapshot, verify_config_h
import sanitize import sanitize
import validation as validate 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)) _VALID_PROTOS_STR = ', '.join(sorted(validate.VALID_PROTOCOLS))

View file

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

View file

@ -2,8 +2,9 @@ from flask import Blueprint, request, session, redirect, flash
import json, bcrypt import json, bcrypt
from auth import require_level from auth import require_level
from config_utils import ACCOUNTS_FILE 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) 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']) @bp.route('/action/change_password', methods=['POST'])
@require_level('viewer') @require_level('viewer')
def change_password(): def change_password():
@ -45,7 +73,7 @@ def change_password():
if account is None: if account is None:
flash('Account not found. Please log in again.', 'error') 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') stored_hash = account.get('hashed_password', '').encode('utf-8')
if not bcrypt.checkpw(current_password.encode('utf-8'), stored_hash): if not bcrypt.checkpw(current_password.encode('utf-8'), stored_hash):

View file

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

View file

@ -9,7 +9,7 @@ from config_utils import load_config, save_config_with_snapshot, verify_config_h
import sanitize import sanitize
import validation as validate import validation as validate
bp = Blueprint('action_apply_vpn', __name__) bp = Blueprint('vpn', __name__)
_VIEW = '/view/view_vpn' _VIEW = '/view/view_vpn'
_MTU_MIN = 576 _MTU_MIN = 576

View file

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

View file

@ -4,7 +4,9 @@ import json, re, subprocess, os, sys, html as html_mod
import sanitize import sanitize
import validation as validate import validation as validate
from datetime import datetime, timezone 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__) 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_config(): return _load_json(f'{CONFIGS_DIR}/config.json')
def _load_ddns(): return _load_config().get('ddns', {}) 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(): def _load_css():
try: try:
@ -64,6 +66,25 @@ def _load_icon(name):
return '' 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 ====================================================== # Shell helper ======================================================
def _run(cmd): def _run(cmd):
@ -1043,7 +1064,7 @@ def _render_item(item, tokens, inherited_req=None):
extra_cls = (' ' + item['class']) if item.get('class') else '' extra_cls = (' ' + item['class']) if item.get('class') else ''
return f'<button type="button" class="btn btn-secondary btn-cancel{extra_cls}" disabled>{text}</button>' return f'<button type="button" class="btn btn-secondary btn-cancel{extra_cls}" disabled>{text}</button>'
if t == 'page_header': if t == 'header_page_title':
return f'<div class="page-header">{render_items(item.get("items", []), tokens, req)}</div>' return f'<div class="page-header">{render_items(item.get("items", []), tokens, req)}</div>'
if t in ('section', 'auth_wrapper'): if t in ('section', 'auth_wrapper'):
@ -1192,7 +1213,9 @@ def _render_item(item, tokens, inherited_req=None):
f'<input type="hidden" name="original_values" value="{e(json.dumps(originals))}"/>' f'<input type="hidden" name="original_values" value="{e(json.dumps(originals))}"/>'
if originals else '' if originals else ''
) )
return f'<form action="{action}" method="{method}">{hash_field}{orig_field}{inner}</form>' 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'<form action="{action}" method="{method}">{hash_field}{orig_field}{inner}</form>{script}'
if t == 'hidden': if t == 'hidden':
name = e(item.get('name', '')) name = e(item.get('name', ''))
@ -1331,7 +1354,6 @@ def _render_field(item, tokens):
placeholder = e(apply_tokens(item.get('placeholder', ''), tokens)) placeholder = e(apply_tokens(item.get('placeholder', ''), tokens))
hint = e(apply_tokens(item.get('hint', ''), tokens)) hint = e(apply_tokens(item.get('hint', ''), tokens))
hint_html = f'<p class="form-hint">{hint}</p>' if hint else '' hint_html = f'<p class="form-hint">{hint}</p>' if hint else ''
extra_cls = f' {e(item["class"])}' if item.get('class') else ''
readonly = ' readonly' if item.get('readonly') else '' readonly = ' readonly' if item.get('readonly') else ''
if input_type == 'hidden': if input_type == 'hidden':
@ -1387,20 +1409,28 @@ def _render_field(item, tokens):
f'<option value="{e(o["value"])}"{" selected" if o["value"] == current else ""}>{e(o["label"])}</option>' f'<option value="{e(o["value"])}"{" selected" if o["value"] == current else ""}>{e(o["label"])}</option>'
for o in options 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 = '<p class="form-hint field-dyn-hint hidden"></p>' if validate else ''
return ( return (
f'<div class="form-group"><label class="form-label">{label}</label>' f'<div class="form-group"><label class="form-label">{label}</label>'
f'<select name="{name}" class="form-select{extra_cls}">{opts_html}</select>' f'<select name="{name}" class="form-select"{validate_attr}{depends_attr}>{opts_html}</select>'
f'{hint_html}</div>' f'{hint_html}{dyn_hint}</div>'
) )
if input_type == 'number': if input_type == 'number':
min_attr = f' min="{item["min"]}"' if 'min' 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 '' 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 = '<p class="form-hint field-dyn-hint hidden"></p>' dyn_hint_html = '<p class="form-hint field-dyn-hint hidden"></p>'
inp = ( inp = (
f'<input type="number" name="{name}" value="{e(value)}"{min_attr}{max_attr}' f'<input type="number" name="{name}" value="{e(value)}"{min_attr}{max_attr}'
f' class="form-input{extra_cls}"{readonly}' f' class="form-input form-input-mono"{readonly}{validate_attr}{depends_attr}/>'
' data-validate="positive_int" />'
) )
if item.get('layout') == 'inline': if item.get('layout') == 'inline':
return ( return (
@ -1533,16 +1563,125 @@ def _render_field(item, tokens):
'</div>' '</div>'
) )
validate = item.get('validate', '') validate = item.get('validate', '')
depends = item.get('depends', [])
validate_attr = f' data-validate="{e(validate)}"' if validate else '' validate_attr = f' data-validate="{e(validate)}"' if validate else ''
dyn_hint = '<p class="form-hint field-dyn-hint hidden"></p>' 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 = '<p class="form-hint field-dyn-hint hidden"></p>' if validate else ''
return ( return (
f'<div class="form-group"><label class="form-label">{label}</label>' f'<div class="form-group"><label class="form-label">{label}</label>'
f'<input type="{e(input_type)}" name="{name}" value="{e(value)}"' f'<input type="{e(input_type)}" name="{name}" value="{e(value)}"'
f' placeholder="{placeholder}" class="form-input{extra_cls}"{readonly}{validate_attr}/>{hint_html}{dyn_hint}</div>' f' placeholder="{placeholder}" class="form-input"{readonly}{validate_attr}{depends_attr}/>'
f'{hint_html}{dyn_hint}</div>'
) )
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 <script> for a form's validation and submit-gate wiring."""
import re
_safe = re.compile(r'^[a-zA-Z0-9_-]+$')
lines = ['(function() {']
lines.append(" var _prev = document.currentScript.previousElementSibling;")
lines.append(" var _card = _prev.closest('.card') || _prev.parentElement;")
lines.append(f" var _submit = _card ? _card.querySelector('{submit_sel}') : null;")
lines.append('')
# Classify each spec =================================================
subnet_items = [] # (subnet_var, prefix_var, subnet_name, prefix_name)
validate_items = [] # (js_var, field_name) — validated via validateEl
checkbox_only = [] # js_var — checkboxes that only need change→_upd
gate_vars = [] # JS boolean expressions that must all be true for submit
for spec in field_specs:
t = spec.get('type', '')
if t == 'subnet_row':
sn = spec.get('subnet_name', 'subnet')
pn = spec.get('prefix_name', 'subnet_mask')
if not (_safe.match(sn) and _safe.match(pn)):
continue
sv = '_' + sn.replace('-', '_')
pv = '_' + pn.replace('-', '_')
lines.append(f" var {sv} = _card.querySelector('[name=\"{sn}\"]');")
lines.append(f" var {pv} = _card.querySelector('[name=\"{pn}\"]');")
subnet_items.append((sv, pv))
gate_vars.append(f'{sv} && {sv}._valid')
elif t == 'field':
nm = spec.get('name', '')
itype = spec.get('input_type', 'text')
if not nm or not _safe.match(nm):
continue
vn = '_' + nm.replace('-', '_')
lines.append(f" var {vn} = _card.querySelector('[name=\"{nm}\"]');")
if itype == 'checkbox':
if spec.get('validate'):
validate_items.append((vn, nm))
gate_vars.append(f'{vn} && {vn}._valid')
else:
checkbox_only.append(vn)
else:
validate_items.append((vn, nm))
gate_vars.append(f'{vn} && {vn}._valid')
lines.append('')
# Submit gate =========================================================
gate_expr = ' && '.join(gate_vars) if gate_vars else 'true'
lines.append(' function _upd() {')
lines.append(' if (!_submit) return;')
lines.append(f' _submit.disabled = !({gate_expr});')
lines.append(' }')
lines.append('')
# validateEl listeners ================================================
for vn, _ in validate_items:
lines.append(f" if ({vn}) {vn}.addEventListener('input', function() {{ validateEl({vn}); _upd(); }});")
# subnet_row custom block =============================================
for sv, pv in subnet_items:
lines.append(f' function _chkSubnet() {{')
lines.append(f' if (!{sv} || !{pv}) return;')
lines.append(f" var res = _ipv4SubnetValid({sv}.value.trim(), {pv}.value.trim());")
lines.append(f" setFieldHint({sv}, res.ok ? '' : (res.msg||''), res.ok ? 'ok' : (res.partial ? 'warning' : 'error'));")
lines.append(f' {sv}._valid = res.ok;')
lines.append(f" var dot = {pv}.closest('.form-group').querySelector('.subnet-dotted');")
lines.append(f' var n = parseInt({pv}.value, 10);')
lines.append(f" if (dot) dot.textContent = (!isNaN(n) && n >= 1 && n <= 30) ? prefixToDotted(n) : '';")
lines.append(f' _upd();')
lines.append(f' }}')
lines.append(f" if ({sv}) {sv}.addEventListener('input', _chkSubnet);")
lines.append(f" if ({pv}) {pv}.addEventListener('input', _chkSubnet);")
# Checkbox change → _upd only =========================================
for vn in checkbox_only:
lines.append(f" if ({vn}) {vn}.addEventListener('change', _upd);")
lines.append('}());')
return '<script>' + '\n'.join(lines) + '</script>'
def _collect_form_originals(items, tokens): def _collect_form_originals(items, tokens):
"""Walk form items and return {name: value} for all input fields (used for original_values).""" """Walk form items and return {name: value} for all input fields (used for original_values)."""
result = {} result = {}
@ -1948,7 +2087,7 @@ def render_layout(view_id, content_html, tokens):
def _render_navbar(active_view, level, tokens, pending_alert=False): 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 = [], [] left, right = [], []
for item in navbar_data.get('items', []): for item in navbar_data.get('items', []):
req = item.get('client_requirement') req = item.get('client_requirement')
@ -2029,8 +2168,8 @@ def view(view_id):
return _serve_view(view_id) return _serve_view(view_id)
def _serve_view(view_id): def _serve_view(view_id):
content_data = _load_json(f'{DATA_DIR}/page_content.json') page_name = _VIEW_MAP.get(view_id)
view_def = next((v for v in content_data.get('views', []) if v.get('id') == view_id), None) view_def = _load_json(_os.path.join(_PAGES_DIR, page_name, 'content.json')) if page_name else None
if view_def is None: if view_def is None:
from flask import abort from flask import abort
@ -2039,7 +2178,7 @@ def _serve_view(view_id):
view_req = view_def.get('client_requirement') view_req = view_def.get('client_requirement')
level = _client_level() level = _client_level()
if not _passes(view_req, 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() tokens = collect_tokens()

File diff suppressed because it is too large Load diff