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

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

View file

@ -0,0 +1,105 @@
from flask import Blueprint, request, session, redirect, flash
import json, os, bcrypt, secrets, smtplib
from datetime import datetime, timezone, timedelta
from email.message import EmailMessage
from auth import require_level
from config_utils import WEB_APP_DISPLAY_NAME, ACCOUNTS_FILE
import sanitize
bp = Blueprint('accountcreate', __name__)
CODE_TTL_MIN = 15
def _load_accounts():
try:
with open(ACCOUNTS_FILE) as f:
return json.load(f)
except Exception:
return {'accounts': []}
def _send_verification_email(to_address, code):
host = os.environ.get('SMTP_HOST', '')
port = int(os.environ.get('SMTP_PORT', 587))
user = os.environ.get('SMTP_USER', '')
password = os.environ.get('SMTP_PASSWORD', '')
from_addr = os.environ.get('SMTP_FROM', user)
if not host:
raise RuntimeError('SMTP_HOST is not configured.')
msg = EmailMessage()
msg['Subject'] = f'{WEB_APP_DISPLAY_NAME} - Email Verification'
msg['From'] = from_addr
msg['To'] = to_address
msg.set_content(
f'Your verification code is: {code}\n\n'
f'This code expires in {CODE_TTL_MIN} minutes.\n\n'
f'If you did not request this, you can ignore this email.'
)
with smtplib.SMTP(host, port) as smtp:
smtp.ehlo()
if port != 465:
smtp.starttls()
if user and password:
smtp.login(user, password)
smtp.send_message(msg)
@bp.route('/action/create_account', methods=['POST'])
@require_level('nothing')
def create_account():
# Abort if already logged in
if session.get('access_level', 'nothing') != 'nothing':
return redirect('/view/view_overview')
email = sanitize.email(request.form.get('email', ''))
password = request.form.get('password', '')
password_confirm = request.form.get('password_confirm', '')
tz = sanitize.timezone(request.form.get('timezone', '').strip())
if not email or not password or not password_confirm or not tz:
flash('All fields are required.', 'error')
return redirect('/view/view_createaccount')
if password != password_confirm:
flash('Passwords do not match.', 'error')
return redirect('/view/view_createaccount')
if len(password) < 8:
flash('Password must be at least 8 characters.', 'error')
return redirect('/view/view_createaccount')
accounts = _load_accounts().get('accounts', [])
account = next((a for a in accounts if a.get('email_address', '').lower() == email), None)
if account is None:
flash('Email address not recognised. Contact your manager.', 'error')
return redirect('/view/view_createaccount')
if account.get('hashed_password'):
flash('This account is already set up. Please log in instead.', 'error')
return redirect('/view/view_createaccount')
salt = bcrypt.gensalt()
hashed = bcrypt.hashpw(password.encode('utf-8'), salt)
code = f'{secrets.randbelow(1000000):06d}'
expires = (datetime.now(tz=timezone.utc) + timedelta(minutes=CODE_TTL_MIN)).isoformat()
try:
_send_verification_email(account['email_address'], code)
except Exception as exc:
flash(f'Could not send verification email: {exc}', 'error')
return redirect('/view/view_createaccount')
session['pending_create_account'] = {
'email': account['email_address'],
'hashed_password': hashed.decode('utf-8'),
'timezone': tz,
'code': code,
'expires': expires,
}
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

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

View file

@ -0,0 +1,56 @@
from flask import Blueprint, request, session, redirect, flash
import json, bcrypt
from auth import require_level
from config_utils import ACCOUNTS_FILE
import sanitize
bp = Blueprint('accountlogin', __name__)
def _load_accounts():
try:
with open(ACCOUNTS_FILE) as f:
return json.load(f)
except Exception:
return {'accounts': []}
@bp.route('/action/log_in', methods=['POST'])
@require_level('nothing')
def log_in():
# Abort if already logged in
if session.get('access_level', 'nothing') != 'nothing':
return redirect('/view/view_overview')
email = sanitize.email(request.form.get('email', ''))
password = request.form.get('password', '')
if not email or not password:
flash('Email address and password are required.', 'error')
return redirect('/view/view_login')
accounts = _load_accounts().get('accounts', [])
account = next((a for a in accounts if a.get('email_address', '').lower() == email), None)
if account is None:
flash('Email address not recognised.', 'error')
return redirect('/view/view_login')
if not account.get('hashed_password'):
flash('Account setup is not complete. Please use Create Account to set your password first.', 'error')
return redirect('/view/view_login')
stored_hash = account['hashed_password'].encode('utf-8')
if not bcrypt.checkpw(password.encode('utf-8'), stored_hash):
flash('Invalid email address or password.', 'error')
return redirect('/view/view_login')
session.clear()
session['email_address'] = account['email_address']
session['access_level'] = account.get('access_level', 'viewer')
session['timezone'] = account.get('timezone', '')
session['apply_changes_immediately'] = False
session.permanent = True
return redirect('/view/view_overview')

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

@ -0,0 +1,11 @@
from flask import Blueprint, session, redirect
from auth import require_level
bp = Blueprint('accountlogout', __name__)
@bp.route('/action/log_out', methods=['POST'])
@require_level('viewer')
def log_out():
session.clear()
return redirect('/view/view_overview')

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

@ -0,0 +1,112 @@
from flask import Blueprint, request, session, redirect, flash
import json, os, secrets
from datetime import datetime, timezone, timedelta
from auth import require_level
from config_utils import ACCOUNTS_FILE
bp = Blueprint('accountverifyemail', __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/verify_email', methods=['POST'])
@require_level('nothing')
def verify_email():
# Abort if already logged in
if session.get('access_level', 'nothing') != 'nothing':
return redirect('/view/view_overview')
pending = session.get('pending_create_account')
if not pending:
flash('No pending account creation found. Please start over.', 'error')
return redirect('/view/view_createaccount')
expires = datetime.fromisoformat(pending['expires'])
if datetime.now(tz=timezone.utc) > expires:
session.pop('pending_create_account', None)
flash('Verification code has expired. Please start over.', 'error')
return redirect('/view/view_createaccount')
submitted = request.form.get('code', '').strip()
if submitted != pending['code']:
flash('Incorrect verification code.', 'error')
return redirect('/view/view_verifyemail')
data = _load_accounts()
accounts = data.get('accounts', [])
account = next(
(a for a in accounts if a.get('email_address', '').lower() == pending['email'].lower()),
None
)
if account is None:
session.pop('pending_create_account', None)
flash('Account no longer exists. Contact your manager.', 'error')
return redirect('/view/view_createaccount')
if account.get('hashed_password'):
session.pop('pending_create_account', None)
flash('This account is already set up. Please log in.', 'error')
return redirect('/view/view_login')
now = datetime.now(tz=timezone.utc).strftime('%Y-%m-%dT%H:%M:%SZ')
account['hashed_password'] = pending['hashed_password']
account['timezone'] = pending['timezone']
if not account.get('account_created_utc'):
account['account_created_utc'] = now
if not account.get('account_created_by'):
account['account_created_by'] = 'self'
_save_accounts(data)
session.pop('pending_create_account', None)
session['email_address'] = account['email_address']
session['access_level'] = account.get('access_level', 'viewer')
session['timezone'] = pending['timezone']
session.permanent = True
return redirect('/view/view_overview')
@bp.route('/action/resend_verification')
@require_level('nothing')
def resend_verification():
# Abort if already logged in
if session.get('access_level', 'nothing') != 'nothing':
return redirect('/view/view_overview')
from pages.accountcreate.action import _send_verification_email, CODE_TTL_MIN
pending = session.get('pending_create_account')
if not pending:
flash('No pending account creation found. Please start over.', 'error')
return redirect('/view/view_createaccount')
code = f'{secrets.randbelow(1000000):06d}'
expires = (datetime.now(tz=timezone.utc) + timedelta(minutes=CODE_TTL_MIN)).isoformat()
try:
_send_verification_email(pending['email'], code)
except Exception as exc:
flash(f'Could not resend verification email: {exc}', 'error')
return redirect('/view/view_verifyemail')
pending['code'] = code
pending['expires'] = expires
session['pending_create_account'] = pending
flash('A new verification code has been sent.', 'success')
return redirect('/view/view_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

@ -0,0 +1,50 @@
from flask import Blueprint, request, redirect, flash, session
from auth import require_level
from config_utils import (flush_pending_to_queue, get_dashboard_pending,
revert_snapshot_to_config, queued_msg)
bp = Blueprint('actions', __name__)
_VIEW = '/view/view_actions'
@bp.route('/action/actions_cardpending_save', methods=['POST'])
@require_level('administrator')
def actions_cardpending_save():
session['apply_changes_immediately'] = 'apply_changes_immediately' in request.form
flash('Preference saved.', 'success')
return redirect(_VIEW)
@bp.route('/action/actions_cardpending_applynow', methods=['POST'])
@require_level('administrator')
def actions_cardpending_applynow():
pending = get_dashboard_pending()
if not pending:
flash('No pending changes to apply.', 'info')
return redirect(_VIEW)
flush_pending_to_queue()
if any(cmd != 'fix problems' for _, _, cmd, _ in pending):
flash('Changes queued.', 'success')
return redirect(_VIEW)
@bp.route('/action/actions_cardhistory_revertselected', methods=['POST'])
@require_level('administrator')
def actions_cardhistory_revertselected():
selected_uuids = request.form.getlist('selected_uuids')
if not selected_uuids:
flash('No items selected.', 'info')
return redirect(_VIEW)
succeeded, failed = 0, 0
for uuid in selected_uuids:
msg, ok = revert_snapshot_to_config(uuid)
if ok:
succeeded += 1
else:
flash(msg, 'error')
failed += 1
if succeeded:
plural = 's' if succeeded != 1 else ''
flash(f'{succeeded} change{plural} reverted.', 'success')
return redirect(_VIEW)

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

@ -0,0 +1,171 @@
import copy
from flask import Blueprint, request, redirect, flash
from auth import require_level
from config_utils import load_config, save_config_with_snapshot, verify_config_hash
import sanitize
import validation as validate
bp = Blueprint('bannedips', __name__)
VIEW = '/view/view_bannedips'
def _row_index():
try:
return int(request.form.get('row_index', ''))
except (ValueError, TypeError):
return None
def _hash_ok():
if not verify_config_hash(request.form.get('config_hash', '')):
flash('Configuration was modified by another session. Please refresh and try again.', 'error')
return False
return True
def _parse_ip():
raw = request.form.get('ip', '').strip()
if not raw:
flash('The configuration has not been saved because an IP address, CIDR, or wildcard pattern is required.', 'error')
return None
ip = validate.banned_ip(raw)
if not ip:
flash(f'The configuration has not been saved because "{raw}" is not a valid IP address, CIDR, or wildcard pattern.', 'error')
return None
return ip
@bp.route('/action/add_banned_ip', methods=['POST'])
@require_level('administrator')
def add_banned_ip():
description = sanitize.text(request.form.get('description', ''))
ip = _parse_ip()
if ip is None:
return redirect(VIEW)
if not _hash_ok():
return redirect(VIEW)
cfg = load_config()
entry = {'description': description, 'ip': ip, 'enabled': True}
cfg.setdefault('banned_ips', []).append(entry)
errors = validate.validate_config(cfg)
if errors:
for msg in errors:
flash(msg, 'error')
return redirect(VIEW)
flash(save_config_with_snapshot(
cfg,
path='banned_ips', key=ip, operation='add',
before=None, after=entry,
description=f'Added banned IP: {ip}',
), 'success')
return redirect(VIEW)
@bp.route('/action/toggle_banned_ip', methods=['POST'])
@require_level('administrator')
def toggle_banned_ip():
idx = _row_index()
if idx is None:
flash('Invalid request.', 'error')
return redirect(VIEW)
if not _hash_ok():
return redirect(VIEW)
cfg = load_config()
items = cfg.get('banned_ips', [])
if idx < 0 or idx >= len(items):
flash('Entry not found.', 'error')
return redirect(VIEW)
old_enabled = items[idx].get('enabled', True)
items[idx]['enabled'] = not old_enabled
errors = validate.validate_config(cfg)
if errors:
for msg in errors:
flash(msg, 'error')
return redirect(VIEW)
action = 'Enabled' if not old_enabled else 'Disabled'
flash(save_config_with_snapshot(
cfg,
path='banned_ips', key=items[idx]['ip'], operation='toggle',
before={'enabled': old_enabled}, after={'enabled': not old_enabled},
description=f'{action} banned IP: {items[idx]["ip"]}',
), 'success')
return redirect(VIEW)
@bp.route('/action/edit_banned_ip', methods=['POST'])
@require_level('administrator')
def edit_banned_ip():
idx = _row_index()
if idx is None:
flash('Invalid request.', 'error')
return redirect(VIEW)
description = sanitize.text(request.form.get('description', ''))
ip = _parse_ip()
if ip is None:
return redirect(VIEW)
enabled = request.form.get('enabled') == 'on'
if not _hash_ok():
return redirect(VIEW)
cfg = load_config()
items = cfg.get('banned_ips', [])
if idx < 0 or idx >= len(items):
flash('Entry not found.', 'error')
return redirect(VIEW)
before = copy.deepcopy(items[idx])
items[idx].update({'description': description, 'ip': ip, 'enabled': enabled})
errors = validate.validate_config(cfg)
if errors:
for msg in errors:
flash(msg, 'error')
return redirect(VIEW)
flash(save_config_with_snapshot(
cfg,
path='banned_ips', key=ip, operation='edit',
before=before, after=copy.deepcopy(items[idx]),
description=f'Edited banned IP: {ip}',
), 'success')
return redirect(VIEW)
@bp.route('/action/delete_banned_ip', methods=['POST'])
@require_level('administrator')
def delete_banned_ip():
idx = _row_index()
if idx is None:
flash('Invalid request.', 'error')
return redirect(VIEW)
if not _hash_ok():
return redirect(VIEW)
cfg = load_config()
items = cfg.get('banned_ips', [])
if idx < 0 or idx >= len(items):
flash('Entry not found.', 'error')
return redirect(VIEW)
removed = items.pop(idx)
errors = validate.validate_config(cfg)
if errors:
for msg in errors:
flash(msg, 'error')
return redirect(VIEW)
flash(save_config_with_snapshot(
cfg,
path='banned_ips', key=removed['ip'], operation='delete',
before=removed, after=None,
description=f'Deleted banned IP: {removed["ip"]}',
), 'success')
return redirect(VIEW)

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

@ -0,0 +1,247 @@
import copy
import os
from flask import Blueprint, request, redirect, flash, send_file, abort
from auth import require_level
from config_utils import load_config, verify_config_hash, save_config_with_snapshot, CONFIGS_DIR
import sanitize
import validation as validate
bp = Blueprint('ddns', __name__)
VIEW = '/view/view_ddns'
LOG_FILE = f'{CONFIGS_DIR}/ddns.log'
@bp.route('/action/ddns_cardaddaccount_add', methods=['POST'])
@require_level('administrator')
def ddns_cardaddaccount_add():
provider_type = sanitize.filtervalue(request.form.get('provider', ''), validate.VALID_DDNS_PROVIDERS)
description = sanitize.description(request.form.get('description', ''))
hostnames = sanitize.domainlist(request.form.get('hostnames', '').splitlines())
if not description:
flash('Description is required.', 'error')
return redirect(VIEW)
if not hostnames:
flash('At least one hostname is required.', 'error')
return redirect(VIEW)
if not provider_type:
flash('Unknown provider type.', 'error')
return redirect(VIEW)
if not verify_config_hash(request.form.get('config_hash', '')):
flash('Configuration was modified by another session. Please refresh and try again.', 'error')
return redirect(VIEW)
entry = {
'description': description,
'provider': provider_type,
'enabled': True,
'hostnames': hostnames,
}
if provider_type == 'noip':
entry['username'] = request.form.get('username', '').strip()
entry['password'] = request.form.get('password', '').strip()
else:
entry['api_token'] = request.form.get('api_token', '').strip()
cfg = load_config()
cfg.setdefault('ddns', {}).setdefault('providers', []).append(entry)
flash(save_config_with_snapshot(
cfg, path='ddns', key=description, operation='add',
before=None, after=copy.deepcopy(entry),
description=f'Added DDNS provider: {description}',
cmd='ddns update',
queue=False,
), 'success')
return redirect(VIEW)
@bp.route('/action/ddns_tableaccounts_rowedit', methods=['POST'])
@require_level('administrator')
def ddns_tableaccounts_rowedit():
try:
row_index = int(request.form.get('row_index', -1))
except (TypeError, ValueError):
flash('Invalid row index.', 'error')
return redirect(VIEW)
provider_type = sanitize.filtervalue(request.form.get('provider', ''), validate.VALID_DDNS_PROVIDERS)
description = sanitize.description(request.form.get('description', ''))
hostnames = [h.strip() for h in request.form.get('hostnames', '').splitlines() if h.strip()]
enabled = request.form.get('enabled') == 'on'
if not provider_type:
flash('Unknown provider type.', 'error')
return redirect(VIEW)
if not verify_config_hash(request.form.get('config_hash', '')):
flash('Configuration was modified by another session. Please refresh and try again.', 'error')
return redirect(VIEW)
cfg = load_config()
providers = cfg.setdefault('ddns', {}).setdefault('providers', [])
if row_index < 0 or row_index >= len(providers):
flash('Invalid provider index.', 'error')
return redirect(VIEW)
before = copy.deepcopy(providers[row_index])
entry = {
'description': description,
'provider': provider_type,
'enabled': enabled,
'hostnames': hostnames,
}
if provider_type == 'noip':
entry['username'] = request.form.get('username', '').strip()
entry['password'] = request.form.get('password', '').strip()
else:
entry['api_token'] = request.form.get('api_token', '').strip()
providers[row_index] = entry
flash(save_config_with_snapshot(
cfg, path='ddns', key=description, operation='edit',
before=before, after=copy.deepcopy(entry),
description=f'Edited DDNS provider: {description}',
cmd='ddns update',
queue=False,
), 'success')
return redirect(VIEW)
@bp.route('/action/ddns_tableaccounts_rowdelete', methods=['POST'])
@require_level('administrator')
def ddns_tableaccounts_rowdelete():
try:
row_index = int(request.form.get('row_index', -1))
except (TypeError, ValueError):
flash('Invalid row index.', 'error')
return redirect(VIEW)
if not verify_config_hash(request.form.get('config_hash', '')):
flash('Configuration was modified by another session. Please refresh and try again.', 'error')
return redirect(VIEW)
cfg = load_config()
providers = cfg.setdefault('ddns', {}).setdefault('providers', [])
if row_index < 0 or row_index >= len(providers):
flash('Invalid provider index.', 'error')
return redirect(VIEW)
before = copy.deepcopy(providers[row_index])
description = before.get('description', str(row_index))
del providers[row_index]
flash(save_config_with_snapshot(
cfg, path='ddns', key=description, operation='delete',
before=before, after=None,
description=f'Deleted DDNS provider: {description}',
cmd='ddns update',
queue=False,
), 'success')
return redirect(VIEW)
@bp.route('/action/ddns_cardipcheckinterval_save', methods=['POST'])
@require_level('administrator')
def ddns_cardipcheckinterval_save():
raw = request.form.get('timer_interval', '').strip()
try:
mins = int(raw)
if mins < 1:
raise ValueError
except ValueError:
flash('Interval must be a whole number of minutes >= 1.', 'error')
return redirect(VIEW)
timer_interval = f'{mins}m'
if not verify_config_hash(request.form.get('config_hash', '')):
flash('Configuration was modified by another session. Please refresh and try again.', 'error')
return redirect(VIEW)
cfg = load_config()
before = copy.deepcopy(cfg.get('ddns', {}).get('general', {}))
cfg.setdefault('ddns', {}).setdefault('general', {})['timer_interval'] = timer_interval
flash(save_config_with_snapshot(
cfg, path='ddns', key='general', operation='edit',
before=before, after=copy.deepcopy(cfg['ddns']['general']),
description='Updated DDNS check interval',
cmd='core apply',
), 'success')
return redirect(VIEW)
@bp.route('/action/ddns_cardipcheckservices_save', methods=['POST'])
@require_level('administrator')
def ddns_cardipcheckservices_save():
if not verify_config_hash(request.form.get('config_hash', '')):
flash('Configuration was modified by another session. Please refresh and try again.', 'error')
return redirect(VIEW)
http_services = [u.strip() for u in request.form.getlist('http_services') if u.strip()]
dig_services = [u.strip() for u in request.form.getlist('dig_services') if u.strip()]
if not http_services and not dig_services:
flash('At least one IP check service is required.', 'error')
return redirect(VIEW)
cfg = load_config()
before = copy.deepcopy(cfg.get('ddns', {}).get('ip_check_services', []))
services = [{'type': 'http', 'url': u} for u in http_services]
services += [{'type': 'dig', 'url': u} for u in dig_services]
cfg.setdefault('ddns', {})['ip_check_services'] = services
flash(save_config_with_snapshot(
cfg, path='ddns', key='ip_check_services', operation='edit',
before=before, after=copy.deepcopy(services),
description='Updated DDNS IP check services',
cmd='ddns update',
queue=False,
), 'success')
return redirect(VIEW)
@bp.route('/action/ddns_cardlogging_save', methods=['POST'])
@require_level('administrator')
def ddns_cardlogging_save():
log_max_kb = validate.int_range(request.form.get('log_max_kb', '').strip(), 64, None)
if log_max_kb is None:
flash('Max Log Size must be a number >= 64.', 'error')
return redirect(VIEW)
log_errors_only = 'log_errors_only' in request.form
if not verify_config_hash(request.form.get('config_hash', '')):
flash('Configuration was modified by another session. Please refresh and try again.', 'error')
return redirect(VIEW)
cfg = load_config()
before = copy.deepcopy(cfg.get('ddns', {}).get('general', {}))
cfg.setdefault('ddns', {}).setdefault('general', {}).update({
'log_max_kb': log_max_kb,
'log_errors_only': log_errors_only,
})
flash(save_config_with_snapshot(
cfg, path='ddns', key='general', operation='edit',
before=before, after=copy.deepcopy(cfg['ddns']['general']),
description='Updated DDNS logging settings',
cmd='ddns update',
queue=False,
), 'success')
return redirect(VIEW)
@bp.route('/action/ddns_cardlogging_clear', methods=['POST'])
@require_level('administrator')
def ddns_cardlogging_clear():
try:
open(LOG_FILE, 'w').close()
flash('DDNS log cleared.', 'success')
except Exception as ex:
flash(f'Could not clear log: {ex}', 'error')
return redirect(VIEW)
@bp.route('/action/ddns_cardlogging_download', methods=['GET'])
@require_level('administrator')
def ddns_cardlogging_download():
if not os.path.isfile(LOG_FILE):
abort(404)
return send_file(LOG_FILE, as_attachment=True, download_name='ddns.log', mimetype='text/plain')

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

@ -0,0 +1,254 @@
import copy
import ipaddress
from flask import Blueprint, request, redirect, flash
from auth import require_level
from config_utils import load_config, save_config_with_snapshot, verify_config_hash
import sanitize
import validation as validate
bp = Blueprint('dhcp', __name__)
VIEW = '/view/view_dhcp'
def _row_index():
try:
return int(request.form.get('row_index', ''))
except (ValueError, TypeError):
return None
def _hash_ok():
if not verify_config_hash(request.form.get('config_hash', '')):
flash('Configuration was modified by another session. Please refresh and try again.', 'error')
return False
return True
def _flat_index_to_vlan_res(vlans, flat_idx):
pos = 0
for vi, vlan in enumerate(vlans):
for ri in range(len(vlan.get('reservations', []))):
if pos == flat_idx:
return vi, ri
pos += 1
return None, None
def _parse_ip():
raw = request.form.get('ip', '').strip()
if not raw:
return 'dynamic'
ip = validate.ip(raw)
if not ip:
flash(f'The configuration has not been saved because "{raw}" is not a valid IP address.', 'error')
return None
return ip
def _check_ip_conflicts(ip, vlan):
if ip == 'dynamic':
return None
dhcp = vlan.get('dhcp_information', {})
pool_start = dhcp.get('dynamic_pool_start')
pool_end = dhcp.get('dynamic_pool_end')
if pool_start and pool_end:
try:
if (ipaddress.IPv4Address(pool_start) <= ipaddress.IPv4Address(ip)
<= ipaddress.IPv4Address(pool_end)):
return f'{ip} falls within the dynamic pool range ({pool_start}-{pool_end}).'
except Exception:
pass
identity_ips = {s['ip'] for s in vlan.get('server_identities', []) if s.get('ip')}
if ip in identity_ips:
return f'{ip} is already assigned as a server identity IP.'
return None
@bp.route('/action/add_dhcp_reservation', methods=['POST'])
@require_level('administrator')
def add_dhcp_reservation():
vlan_name = sanitize.name(request.form.get('vlan_name', ''))
description = sanitize.text(request.form.get('description', ''))
hostname = validate.domainname(request.form.get('hostname', ''))
mac = sanitize.mac(request.form.get('mac', ''))
ip = _parse_ip()
radius_client = 'radius_client' in request.form
if ip is None:
return redirect(VIEW)
if not vlan_name:
flash('The configuration has not been saved because a VLAN is required.', 'error')
return redirect(VIEW)
if not mac:
flash('The configuration has not been saved because a MAC address is required.', 'error')
return redirect(VIEW)
if not _hash_ok():
return redirect(VIEW)
cfg = load_config()
vlans = cfg.get('vlans', [])
vlan = next((v for v in vlans if v.get('name') == vlan_name), None)
if vlan is None:
flash(f'The configuration has not been saved because VLAN "{vlan_name}" was not found.', 'error')
return redirect(VIEW)
conflict = _check_ip_conflicts(ip, vlan)
if conflict:
flash(f'The configuration has not been saved because {conflict}', 'error')
return redirect(VIEW)
entry = {
'description': description,
'hostname': hostname,
'mac': mac,
'ip': ip,
'radius_client': radius_client,
'enabled': True,
}
vlan.setdefault('reservations', []).append(entry)
errors = validate.validate_config(cfg)
if errors:
for msg in errors:
flash(msg, 'error')
return redirect(VIEW)
flash(save_config_with_snapshot(
cfg,
path=f'vlans.{vlan_name}.reservations', key=mac, operation='add',
before=None, after=entry,
description=f'Added DHCP reservation: {hostname or mac} ({ip or "dynamic"})',
), 'success')
return redirect(VIEW)
@bp.route('/action/toggle_dhcp_reservation', methods=['POST'])
@require_level('administrator')
def toggle_dhcp_reservation():
idx = _row_index()
if idx is None:
flash('Invalid request.', 'error')
return redirect(VIEW)
if not _hash_ok():
return redirect(VIEW)
cfg = load_config()
vlans = cfg.get('vlans', [])
vi, ri = _flat_index_to_vlan_res(vlans, idx)
if vi is None:
flash('Entry not found.', 'error')
return redirect(VIEW)
res = vlans[vi]['reservations'][ri]
old_enabled = res.get('enabled', True)
res['enabled'] = not old_enabled
errors = validate.validate_config(cfg)
if errors:
for msg in errors:
flash(msg, 'error')
return redirect(VIEW)
vlan_name = vlans[vi]['name']
action = 'Enabled' if not old_enabled else 'Disabled'
flash(save_config_with_snapshot(
cfg,
path=f'vlans.{vlan_name}.reservations', key=res['mac'], operation='toggle',
before={'enabled': old_enabled}, after={'enabled': not old_enabled},
description=f'{action} DHCP reservation: {res.get("hostname") or res["mac"]}',
), 'success')
return redirect(VIEW)
@bp.route('/action/edit_dhcp_reservation', methods=['POST'])
@require_level('administrator')
def edit_dhcp_reservation():
idx = _row_index()
if idx is None:
flash('Invalid request.', 'error')
return redirect(VIEW)
description = sanitize.text(request.form.get('description', ''))
hostname = validate.domainname(request.form.get('hostname', ''))
mac = sanitize.mac(request.form.get('mac', ''))
ip = _parse_ip()
radius_client = 'radius_client' in request.form
if ip is None:
return redirect(VIEW)
if not mac:
flash('The configuration has not been saved because a MAC address is required.', 'error')
return redirect(VIEW)
if not _hash_ok():
return redirect(VIEW)
cfg = load_config()
vlans = cfg.get('vlans', [])
vi, ri = _flat_index_to_vlan_res(vlans, idx)
if vi is None:
flash('Entry not found.', 'error')
return redirect(VIEW)
conflict = _check_ip_conflicts(ip, vlans[vi])
if conflict:
flash(f'The configuration has not been saved because {conflict}', 'error')
return redirect(VIEW)
res = vlans[vi]['reservations'][ri]
before = copy.deepcopy(res)
res.update({
'description': description,
'hostname': hostname,
'mac': mac,
'ip': ip,
'radius_client': radius_client,
'enabled': 'enabled' in request.form,
})
errors = validate.validate_config(cfg)
if errors:
for msg in errors:
flash(msg, 'error')
return redirect(VIEW)
vlan_name = vlans[vi]['name']
flash(save_config_with_snapshot(
cfg,
path=f'vlans.{vlan_name}.reservations', key=mac, operation='edit',
before=before, after=copy.deepcopy(res),
description=f'Edited DHCP reservation: {hostname or mac} ({ip or "dynamic"})',
), 'success')
return redirect(VIEW)
@bp.route('/action/delete_dhcp_reservation', methods=['POST'])
@require_level('administrator')
def delete_dhcp_reservation():
idx = _row_index()
if idx is None:
flash('Invalid request.', 'error')
return redirect(VIEW)
if not _hash_ok():
return redirect(VIEW)
cfg = load_config()
vlans = cfg.get('vlans', [])
vi, ri = _flat_index_to_vlan_res(vlans, idx)
if vi is None:
flash('Entry not found.', 'error')
return redirect(VIEW)
vlan_name = vlans[vi]['name']
removed = vlans[vi]['reservations'].pop(ri)
errors = validate.validate_config(cfg)
if errors:
for msg in errors:
flash(msg, 'error')
return redirect(VIEW)
flash(save_config_with_snapshot(
cfg,
path=f'vlans.{vlan_name}.reservations', key=removed['mac'], operation='delete',
before=removed, after=None,
description=f'Deleted DHCP reservation: {removed.get("hostname") or removed["mac"]}',
), 'success')
return redirect(VIEW)

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

@ -0,0 +1,234 @@
import copy
import re
from flask import Blueprint, request, redirect, flash
from auth import require_level
from config_utils import load_config, save_config_with_snapshot, verify_config_hash, queued_msg
import sanitize
import validation as validate
bp = Blueprint('dnsblocking', __name__)
VIEW = '/view/view_dnsblocking'
_VALID_FORMATS_STR = ', '.join(sorted(validate.VALID_BLOCKLIST_FORMATS))
def _row_index():
try:
return int(request.form.get('row_index', ''))
except (ValueError, TypeError):
return None
def _hash_ok():
if not verify_config_hash(request.form.get('config_hash', '')):
flash('Configuration was modified by another session. Please refresh and try again.', 'error')
return False
return True
def _save_as_from_name(name):
slug = re.sub(r'[^a-z0-9_-]', '_', name.lower()).strip('_')
return f'{slug}.conf'
def _parse_fields():
name = sanitize.name(request.form.get('name', ''))
description = sanitize.description(request.form.get('description', ''))
fmt = sanitize.filtervalue(request.form.get('format', ''), validate.VALID_BLOCKLIST_FORMATS)
url = sanitize.url(request.form.get('url', ''))
if not name:
flash('The configuration has not been saved because a name is required.', 'error')
return None, True
if not url:
flash('The configuration has not been saved because a URL is required.', 'error')
return None, True
if not fmt:
flash(f'The configuration has not been saved because the format is invalid. '
f'Accepted formats: {_VALID_FORMATS_STR}.', 'error')
return None, True
return {'name': name, 'description': description, 'format': fmt, 'url': url}, None
@bp.route('/action/dnsblocking_tableblocklists_rowdelete', methods=['POST'])
@require_level('administrator')
def dnsblocking_tableblocklists_rowdelete():
idx = _row_index()
if idx is None:
flash('Invalid request.', 'error')
return redirect(VIEW)
if not _hash_ok():
return redirect(VIEW)
cfg = load_config()
items = cfg.get('dns_blocking', {}).get('blocklists', [])
if idx < 0 or idx >= len(items):
flash('Entry not found.', 'error')
return redirect(VIEW)
before = copy.deepcopy(items[idx])
name = before.get('name', str(idx))
items.pop(idx)
errors = validate.validate_config(cfg)
if errors:
for msg in errors:
flash(msg, 'error')
return redirect(VIEW)
flash(save_config_with_snapshot(
cfg, path='dns_blocking', key=name, operation='delete',
before=before, after=None,
description=f'Deleted blocklist: {name}',
queue=False,
), 'success')
return redirect(VIEW)
@bp.route('/action/dnsblocking_tableblocklists_rowedit', methods=['POST'])
@require_level('administrator')
def dnsblocking_tableblocklists_rowedit():
idx = _row_index()
if idx is None:
flash('Invalid request.', 'error')
return redirect(VIEW)
fields, err = _parse_fields()
if err:
return redirect(VIEW)
if not _hash_ok():
return redirect(VIEW)
cfg = load_config()
items = cfg.get('dns_blocking', {}).get('blocklists', [])
if idx < 0 or idx >= len(items):
flash('Entry not found.', 'error')
return redirect(VIEW)
before = copy.deepcopy(items[idx])
items[idx].update({
'name': fields['name'],
'description': fields['description'],
'format': fields['format'],
'url': fields['url'],
})
errors = validate.validate_config(cfg)
if errors:
for msg in errors:
flash(msg, 'error')
return redirect(VIEW)
flash(save_config_with_snapshot(
cfg, path='dns_blocking', key=fields['name'], operation='edit',
before=before, after=copy.deepcopy(items[idx]),
description=f'Edited blocklist: {fields["name"]}',
queue=False,
), 'success')
return redirect(VIEW)
@bp.route('/action/dnsblocking_cardaddblocklist_add', methods=['POST'])
@require_level('administrator')
def dnsblocking_cardaddblocklist_add():
fields, err = _parse_fields()
if err:
return redirect(VIEW)
if not _hash_ok():
return redirect(VIEW)
cfg = load_config()
blocklists = cfg.setdefault('dns_blocking', {}).setdefault('blocklists', [])
if any(b.get('name', '').lower() == fields['name'].lower() for b in blocklists):
flash('The configuration has not been saved because a blocklist with that name already exists.', 'error')
return redirect(VIEW)
entry = {
'name': fields['name'],
'description': fields['description'],
'format': fields['format'],
'url': fields['url'],
'save_as': _save_as_from_name(fields['name']),
}
blocklists.append(entry)
errors = validate.validate_config(cfg)
if errors:
for msg in errors:
flash(msg, 'error')
return redirect(VIEW)
flash(save_config_with_snapshot(
cfg, path='dns_blocking', key=fields['name'], operation='add',
before=None, after=copy.deepcopy(entry),
description=f'Added blocklist: {fields["name"]}',
queue=False,
), 'success')
return redirect(VIEW)
@bp.route('/action/dnsblocking_cardblocklistrefresh_save', methods=['POST'])
@require_level('administrator')
def dnsblocking_cardblocklistrefresh_save():
daily_execute_time = validate.time_24h(sanitize.time_24h(request.form.get('daily_execute_time_24hr_local', '')))
if not daily_execute_time:
flash('Daily Refresh Time must be a valid 24-hour time (e.g. 02:30).', 'error')
return redirect(VIEW)
if not verify_config_hash(request.form.get('config_hash', '')):
flash('Configuration was modified by another session. Please refresh and try again.', 'error')
return redirect(VIEW)
cfg = load_config()
before = copy.deepcopy(cfg.get('dns_blocking', {}).get('general', {}))
cfg.setdefault('dns_blocking', {}).setdefault('general', {})['daily_execute_time_24hr_local'] = daily_execute_time
flash(save_config_with_snapshot(
cfg, path='dns_blocking', key='general', operation='edit',
before=before, after=copy.deepcopy(cfg['dns_blocking']['general']),
description='Updated daily blocklist refresh time',
cmd='core apply',
), 'success')
return redirect(VIEW)
@bp.route('/action/dnsblocking_cardblocklistrefresh_refreshnow', methods=['POST'])
@require_level('administrator')
def dnsblocking_cardblocklistrefresh_refreshnow():
flash(queued_msg('core update-blocklists', action_label='Blocklist refresh queued'), 'success')
return redirect(VIEW)
@bp.route('/action/dnsblocking_cardlogging_save', methods=['POST'])
@require_level('administrator')
def dnsblocking_cardlogging_save():
log_max_kb_raw = request.form.get('log_max_kb', '').strip()
log_errors_only = 'log_errors_only' in request.form
log_max_kb = validate.int_range(log_max_kb_raw, 64, None)
if log_max_kb is None:
flash('Max Log Size must be a number >= 64.', 'error')
return redirect(VIEW)
if not verify_config_hash(request.form.get('config_hash', '')):
flash('Configuration was modified by another session. Please refresh and try again.', 'error')
return redirect(VIEW)
cfg = load_config()
before = copy.deepcopy(cfg.get('dns_blocking', {}).get('general', {}))
cfg.setdefault('dns_blocking', {}).setdefault('general', {}).update({
'log_max_kb': log_max_kb,
'log_errors_only': log_errors_only,
})
errors = validate.validate_config(cfg)
if errors:
for msg in errors:
flash(msg, 'error')
return redirect(VIEW)
flash(save_config_with_snapshot(
cfg, path='dns_blocking', key='general', operation='edit',
before=before, after=copy.deepcopy(cfg['dns_blocking']['general']),
description='Updated DNS blocking log settings',
queue=False,
), 'success')
return redirect(VIEW)

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

@ -0,0 +1,93 @@
import copy
from flask import Blueprint, request, redirect, flash
from auth import require_level
from config_utils import load_config, save_config_with_snapshot, verify_config_hash
import sanitize
import validation as validate
bp = Blueprint('dnsserver', __name__)
_VIEW = '/view/view_dnsserver'
@bp.route('/action/dnsserver_cardupstreamdns_save', methods=['POST'])
@require_level('administrator')
def dnsserver_cardupstreamdns_save():
strict_order = 'strict_order' in request.form
submitted = request.form.getlist('upstream_servers')
for s in submitted:
if not s.strip():
flash('Remove blank server entries before saving.', 'error')
return redirect(_VIEW)
upstream_servers = []
for s in submitted:
clean = sanitize.ip(s.strip())
if not clean:
flash(f"'{s.strip()}' is not a valid IP address.", 'error')
return redirect(_VIEW)
upstream_servers.append(clean)
if not verify_config_hash(request.form.get('config_hash', '')):
flash('Configuration was modified by another session. Please refresh and try again.', 'error')
return redirect(_VIEW)
cfg = load_config()
before = copy.deepcopy(cfg.get('upstream_dns', {}))
current = cfg.get('upstream_dns', {})
if (strict_order == bool(current.get('strict_order', False)) and
upstream_servers == current.get('upstream_servers', [])):
flash('No changes detected.', 'info')
return redirect(_VIEW)
cfg.setdefault('upstream_dns', {}).update({
'strict_order': strict_order,
'upstream_servers': upstream_servers,
})
errors = validate.validate_config(cfg)
if errors:
for msg in errors:
flash(msg, 'error')
return redirect(_VIEW)
flash(save_config_with_snapshot(
cfg, path='upstream_dns', key='global', operation='edit',
before=before, after=copy.deepcopy(cfg['upstream_dns']),
description='Updated upstream DNS servers',
cmd='core apply',
), 'success')
return redirect(_VIEW)
@bp.route('/action/dnsserver_carddnsforwarding_save', methods=['POST'])
@require_level('administrator')
def dnsserver_carddnsforwarding_save():
cache_size = validate.int_range(request.form.get('cache_size', '').strip(), 0, None)
if cache_size is None:
flash('Cache Size must be a non-negative integer.', 'error')
return redirect(_VIEW)
if not verify_config_hash(request.form.get('config_hash', '')):
flash('Configuration was modified by another session. Please refresh and try again.', 'error')
return redirect(_VIEW)
cfg = load_config()
before = copy.deepcopy(cfg.get('upstream_dns', {}))
current = cfg.get('upstream_dns', {})
if cache_size == int(current.get('cache_size', 0)):
flash('No changes detected.', 'info')
return redirect(_VIEW)
cfg.setdefault('upstream_dns', {})['cache_size'] = cache_size
errors = validate.validate_config(cfg)
if errors:
for msg in errors:
flash(msg, 'error')
return redirect(_VIEW)
flash(save_config_with_snapshot(
cfg, path='upstream_dns', key='global', operation='edit',
before=before, after=copy.deepcopy(cfg['upstream_dns']),
description='Updated DNS cache size',
cmd='core apply',
), 'success')
return redirect(_VIEW)

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

@ -0,0 +1,195 @@
import copy
import ipaddress
from flask import Blueprint, request, redirect, flash
from auth import require_level
from config_utils import load_config, save_config_with_snapshot, verify_config_hash
import sanitize
import validation as validate
bp = Blueprint('hostoverrides', __name__)
VIEW = '/view/view_hostoverrides'
def _vlan_networks(cfg):
nets = []
for v in cfg.get('vlans', []):
subnet = v.get('subnet', '')
mask = v.get('subnet_mask', '')
if subnet and mask:
try:
nets.append(ipaddress.IPv4Network(f'{subnet}/{mask}', strict=False))
except ValueError:
pass
return nets
def _ip_in_vlan(ip_str, cfg):
try:
addr = ipaddress.IPv4Address(ip_str)
except ValueError:
return False
nets = _vlan_networks(cfg)
return not nets or any(addr in net for net in nets)
def _row_index():
try:
return int(request.form.get('row_index', ''))
except (ValueError, TypeError):
return None
def _hash_ok():
if not verify_config_hash(request.form.get('config_hash', '')):
flash('Configuration was modified by another session. Please refresh and try again.', 'error')
return False
return True
@bp.route('/action/add_host_override', methods=['POST'])
@require_level('administrator')
def add_host_override():
description = sanitize.text(request.form.get('description', ''))
host = validate.domainname(request.form.get('host', ''))
ip = sanitize.ip(request.form.get('ip', ''))
if not host or not ip:
flash('Hostname and IP address are required.', 'error')
return redirect(VIEW)
if not _hash_ok():
return redirect(VIEW)
cfg = load_config()
if not _ip_in_vlan(ip, cfg):
flash('IP address does not fall within any configured VLAN subnet.', 'error')
return redirect(VIEW)
entry = {'description': description, 'host': host, 'ip': ip, 'enabled': True}
cfg.setdefault('host_overrides', []).append(entry)
errors = validate.validate_config(cfg)
if errors:
for msg in errors:
flash(msg, 'error')
return redirect(VIEW)
flash(save_config_with_snapshot(
cfg,
path='host_overrides', key=host, operation='add',
before=None, after=entry,
description=f'Added host override: {host}{ip}',
), 'success')
return redirect(VIEW)
@bp.route('/action/toggle_host_override', methods=['POST'])
@require_level('administrator')
def toggle_host_override():
idx = _row_index()
if idx is None:
flash('Invalid request.', 'error')
return redirect(VIEW)
if not _hash_ok():
return redirect(VIEW)
cfg = load_config()
items = cfg.get('host_overrides', [])
if idx < 0 or idx >= len(items):
flash('Entry not found.', 'error')
return redirect(VIEW)
old_enabled = items[idx].get('enabled', True)
items[idx]['enabled'] = not old_enabled
errors = validate.validate_config(cfg)
if errors:
for msg in errors:
flash(msg, 'error')
return redirect(VIEW)
action = 'Enabled' if not old_enabled else 'Disabled'
flash(save_config_with_snapshot(
cfg,
path='host_overrides', key=items[idx]['host'], operation='toggle',
before={'enabled': old_enabled}, after={'enabled': not old_enabled},
description=f'{action} host override: {items[idx]["host"]}',
), 'success')
return redirect(VIEW)
@bp.route('/action/edit_host_override', methods=['POST'])
@require_level('administrator')
def edit_host_override():
idx = _row_index()
if idx is None:
flash('Invalid request.', 'error')
return redirect(VIEW)
description = sanitize.text(request.form.get('description', ''))
host = validate.domainname(request.form.get('host', ''))
ip = sanitize.ip(request.form.get('ip', ''))
enabled = request.form.get('enabled') == 'on'
if not host or not ip:
flash('Hostname and IP address are required.', 'error')
return redirect(VIEW)
if not _hash_ok():
return redirect(VIEW)
cfg = load_config()
if not _ip_in_vlan(ip, cfg):
flash('IP address does not fall within any configured VLAN subnet.', 'error')
return redirect(VIEW)
items = cfg.get('host_overrides', [])
if idx < 0 or idx >= len(items):
flash('Entry not found.', 'error')
return redirect(VIEW)
before = copy.deepcopy(items[idx])
items[idx].update({'description': description, 'host': host, 'ip': ip, 'enabled': enabled})
errors = validate.validate_config(cfg)
if errors:
for msg in errors:
flash(msg, 'error')
return redirect(VIEW)
flash(save_config_with_snapshot(
cfg,
path='host_overrides', key=host, operation='edit',
before=before, after=copy.deepcopy(items[idx]),
description=f'Edited host override: {host}{ip}',
), 'success')
return redirect(VIEW)
@bp.route('/action/delete_host_override', methods=['POST'])
@require_level('administrator')
def delete_host_override():
idx = _row_index()
if idx is None:
flash('Invalid request.', 'error')
return redirect(VIEW)
if not _hash_ok():
return redirect(VIEW)
cfg = load_config()
items = cfg.get('host_overrides', [])
if idx < 0 or idx >= len(items):
flash('Entry not found.', 'error')
return redirect(VIEW)
removed = items.pop(idx)
errors = validate.validate_config(cfg)
if errors:
for msg in errors:
flash(msg, 'error')
return redirect(VIEW)
flash(save_config_with_snapshot(
cfg,
path='host_overrides', key=removed['host'], operation='delete',
before=removed, after=None,
description=f'Deleted host override: {removed["host"]}',
), 'success')
return redirect(VIEW)

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

@ -0,0 +1,211 @@
import copy
from flask import Blueprint, request, redirect, flash
from auth import require_level
from config_utils import load_config, save_config_with_snapshot, verify_config_hash
import sanitize
import validation as validate
bp = Blueprint('intervlan', __name__)
VIEW = '/view/view_intervlan'
_VALID_PROTOS_STR = ', '.join(sorted(validate.VALID_PROTOCOLS))
def _row_index():
try:
return int(request.form.get('row_index', ''))
except (ValueError, TypeError):
return None
def _hash_ok():
if not verify_config_hash(request.form.get('config_hash', '')):
flash('Configuration was modified by another session. Please refresh and try again.', 'error')
return False
return True
def _parse_entry():
description = sanitize.text(request.form.get('description', ''))
protocol = sanitize.filtervalue(request.form.get('protocol', ''), validate.VALID_PROTOCOLS)
src_raw = request.form.get('src_ip_or_subnet', '').strip()
dst_raw = request.form.get('dst_ip_or_subnet', '').strip()
dst_port_raw = request.form.get('dst_port', '').strip()
if not protocol:
flash(f'The configuration has not been saved because the protocol is invalid. '
f'Accepted values: {_VALID_PROTOS_STR}.', 'error')
return None, True
if not src_raw:
flash('The configuration has not been saved because a source IP or subnet is required.', 'error')
return None, True
src = validate.ip_or_cidr(src_raw)
if not src:
flash(f'The configuration has not been saved because "{src_raw}" is not a valid IP address or subnet.', 'error')
return None, True
if not dst_raw:
flash('The configuration has not been saved because a destination IP or subnet is required.', 'error')
return None, True
dst = validate.ip_or_cidr(dst_raw)
if not dst:
flash(f'The configuration has not been saved because "{dst_raw}" is not a valid IP address or subnet.', 'error')
return None, True
dst_port = ''
if dst_port_raw:
dst_port = validate.port(dst_port_raw)
if not dst_port:
flash(f'The configuration has not been saved because "{dst_port_raw}" is not a valid port number (1-65535).', 'error')
return None, True
return {
'description': description,
'protocol': protocol,
'src_ip_or_subnet': src,
'dst_ip_or_subnet': dst,
'dst_port': dst_port,
'enabled': True,
}, None
def _entry_key(entry):
port = f':{entry["dst_port"]}' if entry.get('dst_port') else ''
return f'{entry["protocol"]}:{entry["src_ip_or_subnet"]}{entry["dst_ip_or_subnet"]}{port}'
@bp.route('/action/add_inter_vlan', methods=['POST'])
@require_level('administrator')
def add_inter_vlan():
entry, err = _parse_entry()
if err:
return redirect(VIEW)
if not _hash_ok():
return redirect(VIEW)
cfg = load_config()
cfg.setdefault('inter_vlan_exceptions', []).append(entry)
errors = validate.validate_config(cfg)
if errors:
for msg in errors:
flash(msg, 'error')
return redirect(VIEW)
key = _entry_key(entry)
flash(save_config_with_snapshot(
cfg,
path='inter_vlan_exceptions', key=key, operation='add',
before=None, after=entry,
description=f'Added inter-VLAN rule: {key}',
), 'success')
return redirect(VIEW)
@bp.route('/action/toggle_inter_vlan', methods=['POST'])
@require_level('administrator')
def toggle_inter_vlan():
idx = _row_index()
if idx is None:
flash('Invalid request.', 'error')
return redirect(VIEW)
if not _hash_ok():
return redirect(VIEW)
cfg = load_config()
items = cfg.get('inter_vlan_exceptions', [])
if idx < 0 or idx >= len(items):
flash('Entry not found.', 'error')
return redirect(VIEW)
old_enabled = items[idx].get('enabled', True)
items[idx]['enabled'] = not old_enabled
errors = validate.validate_config(cfg)
if errors:
for msg in errors:
flash(msg, 'error')
return redirect(VIEW)
key = _entry_key(items[idx])
action = 'Enabled' if not old_enabled else 'Disabled'
flash(save_config_with_snapshot(
cfg,
path='inter_vlan_exceptions', key=key, operation='toggle',
before={'enabled': old_enabled}, after={'enabled': not old_enabled},
description=f'{action} inter-VLAN rule: {key}',
), 'success')
return redirect(VIEW)
@bp.route('/action/edit_inter_vlan', methods=['POST'])
@require_level('administrator')
def edit_inter_vlan():
idx = _row_index()
if idx is None:
flash('Invalid request.', 'error')
return redirect(VIEW)
entry, err = _parse_entry()
if err:
return redirect(VIEW)
if not _hash_ok():
return redirect(VIEW)
cfg = load_config()
items = cfg.get('inter_vlan_exceptions', [])
if idx < 0 or idx >= len(items):
flash('Entry not found.', 'error')
return redirect(VIEW)
before = copy.deepcopy(items[idx])
items[idx] = entry
items[idx]['enabled'] = request.form.get('enabled') == 'on'
errors = validate.validate_config(cfg)
if errors:
for msg in errors:
flash(msg, 'error')
return redirect(VIEW)
key = _entry_key(entry)
flash(save_config_with_snapshot(
cfg,
path='inter_vlan_exceptions', key=key, operation='edit',
before=before, after=copy.deepcopy(items[idx]),
description=f'Edited inter-VLAN rule: {key}',
), 'success')
return redirect(VIEW)
@bp.route('/action/delete_inter_vlan', methods=['POST'])
@require_level('administrator')
def delete_inter_vlan():
idx = _row_index()
if idx is None:
flash('Invalid request.', 'error')
return redirect(VIEW)
if not _hash_ok():
return redirect(VIEW)
cfg = load_config()
items = cfg.get('inter_vlan_exceptions', [])
if idx < 0 or idx >= len(items):
flash('Entry not found.', 'error')
return redirect(VIEW)
removed = items.pop(idx)
errors = validate.validate_config(cfg)
if errors:
for msg in errors:
flash(msg, 'error')
return redirect(VIEW)
key = _entry_key(removed)
flash(save_config_with_snapshot(
cfg,
path='inter_vlan_exceptions', key=key, operation='delete',
before=removed, after=None,
description=f'Deleted inter-VLAN rule: {key}',
), 'success')
return redirect(VIEW)

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

@ -0,0 +1,44 @@
import copy
from flask import Blueprint, request, redirect, flash
from auth import require_level
from config_utils import load_config, save_config_with_snapshot, verify_config_hash
import sanitize
import validation as validate
bp = Blueprint('mdns', __name__)
@bp.route('/action/apply_mdns', methods=['POST'])
@require_level('administrator')
def apply_mdns():
mdns_enabled = 'mdns_enabled' in request.form
if not verify_config_hash(request.form.get('config_hash', '')):
flash('Configuration was modified by another session. Please refresh and try again.', 'error')
return redirect('/view/view_mdns')
cfg = load_config()
mdns_reflect_vlans = sanitize.filterlist(
request.form.getlist('mdns_reflect_vlans'),
{v.get('name') for v in cfg.get('vlans', [])},
)
before = copy.deepcopy(cfg.get('mdns_reflection', {}))
cfg.setdefault('mdns_reflection', {}).update({
'enabled': mdns_enabled,
'reflect_vlans': mdns_reflect_vlans,
})
errors = validate.validate_config(cfg)
if errors:
for msg in errors:
flash(msg, 'error')
return redirect('/view/view_mdns')
flash(save_config_with_snapshot(
cfg,
path='mdns_reflection', key='global', operation='edit',
before=before or None, after=copy.deepcopy(cfg['mdns_reflection']),
description='Updated mDNS reflection settings',
), 'success')
return redirect('/view/view_mdns')

View file

@ -0,0 +1,360 @@
import copy
import ipaddress
from flask import Blueprint, request, redirect, flash
from auth import require_level
from config_utils import load_config, save_config_with_snapshot, verify_config_hash
import sanitize
import validation as validate
bp = Blueprint('networklayout', __name__)
VIEW = '/view/view_networklayout'
_VLAN_FIELDS = ['name', 'vlan_id', 'is_vpn', 'subnet', 'subnet_mask', 'dnsmasq_log_queries',
'radius_default', 'mdns_reflection', 'use_blocklists']
def _row_index():
try:
return int(request.form.get('row_index', ''))
except (ValueError, TypeError):
return None
def _hash_ok():
if not verify_config_hash(request.form.get('config_hash', '')):
flash('Configuration was modified by another session. Please refresh and try again.', 'error')
return False
return True
@bp.route('/action/networklayout_cardaddvlan_addvlan', methods=['POST'])
@require_level('administrator')
def networklayout_cardaddvlan_addvlan():
name = sanitize.name(request.form.get('name', ''))
vlan_id = sanitize.vlan_id(request.form.get('vlan_id', ''))
is_vpn = 'is_vpn' in request.form
subnet = sanitize.ip(request.form.get('subnet', ''))
subnet_mask = sanitize.subnet_mask(request.form.get('subnet_mask', ''))
radius_default = 'radius_default' in request.form
mdns_reflection = 'mdns_reflection' in request.form
dnsmasq_log_queries = 'dnsmasq_log_queries' in request.form
use_blocklists = sanitize.filterlist(
request.form.getlist('use_blocklists'),
{b.get('name') for b in load_config().get('dns_blocking', {}).get('blocklists', [])},
)
if not name:
flash('Name is required.', 'error')
return redirect(VIEW)
if vlan_id is None:
flash('VLAN ID must be an integer between 1 and 4094.', 'error')
return redirect(VIEW)
if not subnet:
flash('Subnet IP is required.', 'error')
return redirect(VIEW)
if subnet_mask is None:
flash('Invalid subnet prefix (must be 1-30).', 'error')
return redirect(VIEW)
if not _hash_ok():
return redirect(VIEW)
cfg = load_config()
vlans = cfg.setdefault('vlans', [])
if any(v.get('vlan_id') == vlan_id for v in vlans):
flash(f'VLAN ID {vlan_id} is already in use.', 'error')
return redirect(VIEW)
if radius_default and any(v.get('radius_default') for v in vlans):
flash('Only one VLAN can be the RADIUS default.', 'error')
return redirect(VIEW)
entry = {
'name': name,
'vlan_id': vlan_id,
'is_vpn': is_vpn,
'subnet': subnet,
'subnet_mask': subnet_mask,
'dnsmasq_log_queries': dnsmasq_log_queries,
'use_blocklists': use_blocklists,
'radius_default': radius_default,
'mdns_reflection': mdns_reflection,
}
if is_vpn:
entry['peers'] = []
else:
entry['reservations'] = []
vlans.append(entry)
errors = validate.validate_config(cfg)
if errors:
for msg in errors:
flash(msg, 'error')
return redirect(VIEW)
flash(save_config_with_snapshot(
cfg,
path='vlans', key=name, operation='add',
before=None, after={k: entry[k] for k in _VLAN_FIELDS if k in entry},
description=f'Added VLAN: {name} ({subnet}/{subnet_mask})',
), 'success')
return redirect(VIEW)
@bp.route('/action/networklayout_tablevlans_edit', methods=['POST'])
@require_level('administrator')
def networklayout_tablevlans_edit():
idx = _row_index()
if idx is None:
flash('Invalid request.', 'error')
return redirect(VIEW)
name = sanitize.name(request.form.get('name', ''))
vlan_id = sanitize.vlan_id(request.form.get('vlan_id', ''))
subnet = sanitize.ip(request.form.get('subnet', ''))
radius_default = 'radius_default' in request.form
mdns_reflection = 'mdns_reflection' in request.form
dnsmasq_log_queries = 'dnsmasq_log_queries' in request.form
use_blocklists = sanitize.filterlist(
request.form.getlist('use_blocklists'),
{b.get('name') for b in load_config().get('dns_blocking', {}).get('blocklists', [])},
)
identity_ips_raw = [line.strip() for line in request.form.get('server_identity_ips', '').splitlines() if line.strip()]
identity_ips = []
for raw_ip in identity_ips_raw:
clean = sanitize.ip(raw_ip)
if not clean:
flash(f"'{raw_ip}' is not a valid IP address.", 'error')
return redirect(VIEW)
identity_ips.append(clean)
identity_descs = request.form.get('server_identity_descriptions', '').splitlines()
identity_hostnames = request.form.get('server_identity_hostnames', '').splitlines()
subnet_mask_raw = request.form.get('subnet_mask')
if subnet_mask_raw is not None:
subnet_mask = sanitize.subnet_mask(subnet_mask_raw)
if subnet_mask is None:
flash('Invalid subnet prefix (must be 1-30).', 'error')
return redirect(VIEW)
else:
subnet_mask = None
if not name:
flash('Name is required.', 'error')
return redirect(VIEW)
if vlan_id is None:
flash('VLAN ID must be an integer between 1 and 4094.', 'error')
return redirect(VIEW)
if not subnet:
flash('Subnet IP is required.', 'error')
return redirect(VIEW)
if not _hash_ok():
return redirect(VIEW)
cfg = load_config()
vlans = cfg.get('vlans', [])
if idx < 0 or idx >= len(vlans):
flash('VLAN not found.', 'error')
return redirect(VIEW)
existing = vlans[idx]
is_vpn = existing.get('is_vpn', False)
final_mask = subnet_mask if subnet_mask is not None else existing.get('subnet_mask', 24)
if identity_ips:
_vlan_net = ipaddress.IPv4Network(f'{subnet}/{final_mask}', strict=False)
for _ip in identity_ips:
if ipaddress.IPv4Address(_ip) not in _vlan_net:
flash(f"Server identity IP '{_ip}' is not in the VLAN subnet ({subnet}/{final_mask}).", 'error')
return redirect(VIEW)
current_id = existing.get('vlan_id')
if current_id == 1 and vlan_id != 1:
flash('VLAN 1 is the physical interface and cannot change its ID.', 'error')
return redirect(VIEW)
if vlan_id != current_id and any(v.get('vlan_id') == vlan_id for i, v in enumerate(vlans) if i != idx):
flash(f'VLAN ID {vlan_id} is already in use.', 'error')
return redirect(VIEW)
if radius_default and any(i != idx and v.get('radius_default') for i, v in enumerate(vlans)):
flash('Only one VLAN can be the RADIUS default.', 'error')
return redirect(VIEW)
old_identities = existing.get('server_identities', [])
new_identities = []
for i, ip in enumerate(identity_ips):
entry = dict(old_identities[i]) if i < len(old_identities) else {}
entry['ip'] = ip
desc = identity_descs[i].strip() if i < len(identity_descs) else ''
if desc:
entry['description'] = desc
else:
entry.pop('description', None)
hostname_raw = identity_hostnames[i].strip() if i < len(identity_hostnames) else ''
if hostname_raw:
clean_hostname = sanitize.hostname(hostname_raw)
if clean_hostname is None:
flash(f"'{hostname_raw}' is not a valid hostname.", 'error')
return redirect(VIEW)
entry['hostname'] = clean_hostname
else:
entry.pop('hostname', None)
new_identities.append(entry)
gateway_raw = sanitize.ip(request.form.get('gateway', ''))
if gateway_raw and gateway_raw not in identity_ips:
flash(f"Gateway '{gateway_raw}' must match one of the server identity IPs.", 'error')
return redirect(VIEW)
inferred_gw = (min(identity_ips, key=lambda ip: int(ip.split('.')[-1]))
if identity_ips else '')
new_stored_gw = gateway_raw if (gateway_raw and gateway_raw != inferred_gw) else ''
existing_gw = existing.get('dhcp_information', {}).get('explicit_overrides', {}).get('gateway', '')
dns_override = 'dns_server_override' in request.form
dns_ips = []
for _line in request.form.get('dns_server', '').splitlines():
_line = _line.strip()
if not _line:
continue
_clean = sanitize.ip(_line)
if not _clean:
flash(f"'{_line}' is not a valid DNS server IP.", 'error')
return redirect(VIEW)
dns_ips.append(_clean)
if dns_override and not dns_ips:
flash('At least one DNS server IP is required when override is enabled.', 'error')
return redirect(VIEW)
if dns_override and dns_ips:
_vlan_net = ipaddress.IPv4Network(f'{subnet}/{final_mask}', strict=False)
for _ip in dns_ips:
if ipaddress.IPv4Address(_ip) not in _vlan_net:
flash(f"DNS server '{_ip}' is not in the VLAN subnet ({subnet}/{final_mask}).", 'error')
return redirect(VIEW)
new_stored_dns = dns_ips if dns_override else []
_existing_dns = existing.get('dhcp_information', {}).get('explicit_overrides', {}).get('dns_server', [])
existing_dns = _existing_dns if isinstance(_existing_dns, list) else ([_existing_dns] if _existing_dns else [])
ntp_override = 'ntp_server_override' in request.form
ntp_ips = []
for _line in request.form.get('ntp_server', '').splitlines():
_line = _line.strip()
if not _line:
continue
_clean = sanitize.ip(_line)
if not _clean:
flash(f"'{_line}' is not a valid NTP server IP.", 'error')
return redirect(VIEW)
ntp_ips.append(_clean)
if ntp_override and not ntp_ips:
flash('At least one NTP server IP is required when override is enabled.', 'error')
return redirect(VIEW)
if ntp_override and ntp_ips:
_vlan_net = ipaddress.IPv4Network(f'{subnet}/{final_mask}', strict=False)
for _ip in ntp_ips:
if ipaddress.IPv4Address(_ip) not in _vlan_net:
flash(f"NTP server '{_ip}' is not in the VLAN subnet ({subnet}/{final_mask}).", 'error')
return redirect(VIEW)
new_stored_ntp = ntp_ips if ntp_override else []
_existing_ntp = existing.get('dhcp_information', {}).get('explicit_overrides', {}).get('ntp_server', [])
existing_ntp = _existing_ntp if isinstance(_existing_ntp, list) else ([_existing_ntp] if _existing_ntp else [])
_ids_unchanged = (
len(new_identities) == len(old_identities) and
all(
n.get('ip') == o.get('ip') and
n.get('description', '') == o.get('description', '') and
n.get('hostname', '') == o.get('hostname', '')
for n, o in zip(new_identities, old_identities)
)
)
if (name == existing.get('name', '')
and vlan_id == existing.get('vlan_id')
and subnet == existing.get('subnet', '')
and final_mask == existing.get('subnet_mask', 24)
and dnsmasq_log_queries == bool(existing.get('dnsmasq_log_queries', False))
and radius_default == bool(existing.get('radius_default', False))
and mdns_reflection == bool(existing.get('mdns_reflection', False))
and sorted(use_blocklists) == sorted(existing.get('use_blocklists', []))
and _ids_unchanged
and new_stored_gw == existing_gw
and new_stored_dns == existing_dns
and new_stored_ntp == existing_ntp):
flash('No changes were made.', 'info')
return redirect(VIEW)
before = {k: existing.get(k) for k in _VLAN_FIELDS}
existing.update({
'name': name,
'vlan_id': vlan_id,
'is_vpn': is_vpn,
'subnet': subnet,
'subnet_mask': final_mask,
'dnsmasq_log_queries': dnsmasq_log_queries,
'radius_default': radius_default,
'mdns_reflection': mdns_reflection,
'use_blocklists': use_blocklists,
'server_identities': new_identities,
})
dhcp_overrides = existing.setdefault('dhcp_information', {}).setdefault('explicit_overrides', {})
if new_stored_gw:
dhcp_overrides['gateway'] = new_stored_gw
else:
dhcp_overrides.pop('gateway', None)
if new_stored_dns:
dhcp_overrides['dns_server'] = new_stored_dns
else:
dhcp_overrides.pop('dns_server', None)
if new_stored_ntp:
dhcp_overrides['ntp_server'] = new_stored_ntp
else:
dhcp_overrides.pop('ntp_server', None)
if not dhcp_overrides:
existing.get('dhcp_information', {}).pop('explicit_overrides', None)
errors = validate.validate_config(cfg)
if errors:
for msg in errors:
flash(msg, 'error')
return redirect(VIEW)
flash(save_config_with_snapshot(
cfg,
path='vlans', key=name, operation='edit',
before=before, after={k: existing.get(k) for k in _VLAN_FIELDS},
description=f'Edited VLAN: {name}',
), 'success')
return redirect(VIEW)
@bp.route('/action/networklayout_tablevlans_delete', methods=['POST'])
@require_level('administrator')
def networklayout_tablevlans_delete():
idx = _row_index()
if idx is None:
flash('Invalid request.', 'error')
return redirect(VIEW)
if not _hash_ok():
return redirect(VIEW)
cfg = load_config()
vlans = cfg.get('vlans', [])
if idx < 0 or idx >= len(vlans):
flash('VLAN not found.', 'error')
return redirect(VIEW)
removed = vlans.pop(idx)
errors = validate.validate_config(cfg)
if errors:
for msg in errors:
flash(msg, 'error')
return redirect(VIEW)
flash(save_config_with_snapshot(
cfg,
path='vlans', key=removed['name'], operation='delete',
before={k: removed.get(k) for k in _VLAN_FIELDS},
after=None,
description=f'Deleted VLAN: {removed["name"]}',
), 'success')
return redirect(VIEW)

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

@ -0,0 +1,127 @@
import copy
import os
from flask import Blueprint, request, redirect, flash
from auth import require_level
from config_utils import load_config, save_config_with_snapshot, verify_config_hash, queued_msg, queue_command
import sanitize
import validation as validate
bp = Blueprint('physicalinterfaces', __name__)
_VIEW = '/view/view_physicalinterfaces'
_EXCLUDE_PREFIXES = ('lo', 'wg', 'docker', 'br-', 'veth',
'tun', 'tap', 'ppp', 'virbr',
'podman', 'vnet', 'macvtap', 'fc-')
def _get_system_interfaces():
try:
return {
n for n in os.listdir('/sys/class/net')
if not n.startswith(_EXCLUDE_PREFIXES)
and os.path.exists(f'/sys/class/net/{n}/device')
}
except Exception:
return set()
def _valid_interface(name):
return name in _get_system_interfaces()
@bp.route('/action/physicalinterfaces_cardphysicalinterface_save', methods=['POST'])
@require_level('administrator')
def physicalinterfaces_cardphysicalinterface_save():
wan = sanitize.interface_name(request.form.get('wan_interface', ''))
lan = sanitize.interface_name(request.form.get('lan_interface', ''))
if not wan or not lan:
flash('Both WAN and LAN interfaces are required.', 'error')
return redirect(_VIEW)
if wan == lan:
flash('WAN and LAN interfaces must be different.', 'error')
return redirect(_VIEW)
if not verify_config_hash(request.form.get('config_hash', '')):
flash('Configuration was modified by another session. Please refresh and try again.', 'error')
return redirect(_VIEW)
available = _get_system_interfaces()
for iface in (wan, lan):
if available and iface not in available:
flash(f"Interface '{iface}' does not exist on this system.", 'error')
return redirect(_VIEW)
cfg = load_config()
before = copy.deepcopy(cfg.get('network_interfaces', {}))
gen = cfg.setdefault('network_interfaces', {})
gen['wan_interface'] = wan
gen['lan_interface'] = lan
errors = validate.validate_config(cfg)
if errors:
for msg in errors:
flash(msg, 'error')
return redirect(_VIEW)
flash(save_config_with_snapshot(
cfg, path='network_interfaces', key='global', operation='edit',
before=before, after=copy.deepcopy(cfg['network_interfaces']),
description='Updated network interfaces',
cmd='core apply',
), 'success')
return redirect(_VIEW)
@bp.route('/action/physicalinterfaces_cardinterfaceconfiguration_apply', methods=['POST'])
@require_level('administrator')
def physicalinterfaces_cardinterfaceconfiguration_apply():
if not verify_config_hash(request.form.get('config_hash', '')):
flash('Configuration was modified by another session. Please refresh and try again.', 'error')
return redirect(_VIEW)
iface = sanitize.interface_name(request.form.get('iface', ''))
mtu = request.form.get('mtu', '').strip()
mac = sanitize.mac(request.form.get('mac', ''))
original_mtu = request.form.get('original_mtu', '').strip()
original_mac = sanitize.mac(request.form.get('original_mac', ''))
if not iface:
flash('No interface specified.', 'error')
return redirect(_VIEW)
if not _valid_interface(iface):
flash(f"Interface '{iface}' does not exist on this system.", 'error')
return redirect(_VIEW)
mtu_int = None
if mtu:
mtu_int = validate.int_range(mtu, 68, 9000)
if mtu_int is None:
flash('MTU must be an integer between 68 and 9000.', 'error')
return redirect(_VIEW)
mac_raw = request.form.get('mac', '').strip()
if mac_raw and not mac:
flash('MAC address must be in the format aa:bb:cc:dd:ee:ff.', 'error')
return redirect(_VIEW)
if not mtu_int and not mac:
flash('No changes specified.', 'error')
return redirect(_VIEW)
queued = False
if mtu_int and str(mtu_int) != original_mtu:
queue_command(f'mtu {iface} {mtu_int}')
queued = True
if mac and mac != original_mac:
queue_command(f'mac {iface} {mac}')
queued = True
if not queued:
flash('No changes detected.', 'info')
return redirect(_VIEW)
flash(queued_msg(action_label='Changes queued'), 'success')
return redirect(_VIEW)

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

@ -0,0 +1,207 @@
import copy
from flask import Blueprint, request, redirect, flash
from auth import require_level
from config_utils import load_config, save_config_with_snapshot, verify_config_hash
import sanitize
import validation as validate
bp = Blueprint('portforwarding', __name__)
VIEW = '/view/view_portforwarding'
_VALID_PROTOS_STR = ', '.join(sorted(validate.VALID_PROTOCOLS))
def _row_index():
try:
return int(request.form.get('row_index', ''))
except (ValueError, TypeError):
return None
def _hash_ok():
if not verify_config_hash(request.form.get('config_hash', '')):
flash('Configuration was modified by another session. Please refresh and try again.', 'error')
return False
return True
def _parse_entry():
description = sanitize.text(request.form.get('description', ''))
protocol = sanitize.filtervalue(request.form.get('protocol', ''), validate.VALID_PROTOCOLS)
dest_port_raw = request.form.get('dest_port', '').strip()
nat_ip_raw = request.form.get('nat_ip', '').strip()
nat_port_raw = request.form.get('nat_port', '').strip()
if not protocol:
flash(f'The configuration has not been saved because the protocol is invalid. '
f'Accepted values: {_VALID_PROTOS_STR}.', 'error')
return None, True
if not dest_port_raw:
flash('The configuration has not been saved because the external port is required.', 'error')
return None, True
dest_port = validate.port(dest_port_raw)
if not dest_port:
flash(f'The configuration has not been saved because "{dest_port_raw}" is not a valid port number (1-65535).', 'error')
return None, True
if not nat_ip_raw:
flash('The configuration has not been saved because the NAT IP address is required.', 'error')
return None, True
nat_ip = validate.ip(nat_ip_raw)
if not nat_ip:
flash(f'The configuration has not been saved because "{nat_ip_raw}" is not a valid IP address.', 'error')
return None, True
if not nat_port_raw:
flash('The configuration has not been saved because the NAT port is required.', 'error')
return None, True
nat_port = validate.port(nat_port_raw)
if not nat_port:
flash(f'The configuration has not been saved because "{nat_port_raw}" is not a valid port number (1-65535).', 'error')
return None, True
return {
'description': description,
'protocol': protocol,
'dest_port': dest_port,
'nat_ip': nat_ip,
'nat_port': nat_port,
'enabled': True,
}, None
@bp.route('/action/add_port_forward', methods=['POST'])
@require_level('administrator')
def add_port_forward():
entry, err = _parse_entry()
if err:
return redirect(VIEW)
if not _hash_ok():
return redirect(VIEW)
cfg = load_config()
cfg.setdefault('port_forwarding', []).append(entry)
errors = validate.validate_config(cfg)
if errors:
for msg in errors:
flash(msg, 'error')
return redirect(VIEW)
key = f'{entry["protocol"]}:{entry["dest_port"]}'
flash(save_config_with_snapshot(
cfg,
path='port_forwarding', key=key, operation='add',
before=None, after=entry,
description=f'Added port forward: {key}{entry["nat_ip"]}:{entry["nat_port"]}',
), 'success')
return redirect(VIEW)
@bp.route('/action/toggle_port_forward', methods=['POST'])
@require_level('administrator')
def toggle_port_forward():
idx = _row_index()
if idx is None:
flash('Invalid request.', 'error')
return redirect(VIEW)
if not _hash_ok():
return redirect(VIEW)
cfg = load_config()
items = cfg.get('port_forwarding', [])
if idx < 0 or idx >= len(items):
flash('Entry not found.', 'error')
return redirect(VIEW)
old_enabled = items[idx].get('enabled', True)
items[idx]['enabled'] = not old_enabled
errors = validate.validate_config(cfg)
if errors:
for msg in errors:
flash(msg, 'error')
return redirect(VIEW)
key = f'{items[idx]["protocol"]}:{items[idx]["dest_port"]}'
action = 'Enabled' if not old_enabled else 'Disabled'
flash(save_config_with_snapshot(
cfg,
path='port_forwarding', key=key, operation='toggle',
before={'enabled': old_enabled}, after={'enabled': not old_enabled},
description=f'{action} port forward: {key}',
), 'success')
return redirect(VIEW)
@bp.route('/action/edit_port_forward', methods=['POST'])
@require_level('administrator')
def edit_port_forward():
idx = _row_index()
if idx is None:
flash('Invalid request.', 'error')
return redirect(VIEW)
entry, err = _parse_entry()
if err:
return redirect(VIEW)
if not _hash_ok():
return redirect(VIEW)
cfg = load_config()
items = cfg.get('port_forwarding', [])
if idx < 0 or idx >= len(items):
flash('Entry not found.', 'error')
return redirect(VIEW)
before = copy.deepcopy(items[idx])
items[idx] = entry
items[idx]['enabled'] = request.form.get('enabled') == 'on'
errors = validate.validate_config(cfg)
if errors:
for msg in errors:
flash(msg, 'error')
return redirect(VIEW)
key = f'{entry["protocol"]}:{entry["dest_port"]}'
flash(save_config_with_snapshot(
cfg,
path='port_forwarding', key=key, operation='edit',
before=before, after=copy.deepcopy(items[idx]),
description=f'Edited port forward: {key}{entry["nat_ip"]}:{entry["nat_port"]}',
), 'success')
return redirect(VIEW)
@bp.route('/action/delete_port_forward', methods=['POST'])
@require_level('administrator')
def delete_port_forward():
idx = _row_index()
if idx is None:
flash('Invalid request.', 'error')
return redirect(VIEW)
if not _hash_ok():
return redirect(VIEW)
cfg = load_config()
items = cfg.get('port_forwarding', [])
if idx < 0 or idx >= len(items):
flash('Entry not found.', 'error')
return redirect(VIEW)
removed = items.pop(idx)
errors = validate.validate_config(cfg)
if errors:
for msg in errors:
flash(msg, 'error')
return redirect(VIEW)
key = f'{removed["protocol"]}:{removed["dest_port"]}'
flash(save_config_with_snapshot(
cfg,
path='port_forwarding', key=key, operation='delete',
before=removed, after=None,
description=f'Deleted port forward: {key}',
), 'success')
return redirect(VIEW)

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

@ -0,0 +1,91 @@
from flask import Blueprint, request, session, redirect, flash
import json, bcrypt
from auth import require_level
from config_utils import ACCOUNTS_FILE
import sanitize
bp = Blueprint('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_login')
account['timezone'] = tz
_save_accounts(data)
session['timezone'] = tz
flash('Preferences saved.', 'success')
return redirect('/view/view_preferences')
@bp.route('/action/change_password', methods=['POST'])
@require_level('viewer')
def change_password():
current_password = request.form.get('current_password', '')
new_password = request.form.get('new_password', '')
confirm_password = request.form.get('confirm_password', '')
if not current_password or not new_password or not confirm_password:
flash('All fields are required.', 'error')
return redirect('/view/view_preferences')
if new_password != confirm_password:
flash('New passwords do not match.', 'error')
return redirect('/view/view_preferences')
if len(new_password) < 8:
flash('New password must be at least 8 characters.', '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')
stored_hash = account.get('hashed_password', '').encode('utf-8')
if not bcrypt.checkpw(current_password.encode('utf-8'), stored_hash):
flash('Current password is incorrect.', 'error')
return redirect('/view/view_preferences')
salt = bcrypt.gensalt()
hashed = bcrypt.hashpw(new_password.encode('utf-8'), salt)
account['hashed_password'] = hashed.decode('utf-8')
account['salt'] = salt.decode('utf-8')
_save_accounts(data)
flash('Password changed successfully.', 'success')
return redirect('/view/view_preferences')

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

@ -0,0 +1,435 @@
import base64
import copy
import ipaddress
import re
from flask import Blueprint, make_response, redirect, flash, request
from auth import require_level
from config_utils import load_config, save_config_with_snapshot, verify_config_hash, CONFIGS_DIR, WEB_APP_DISPLAY_NAME
import sanitize
import validation as validate
bp = Blueprint('vpn', __name__)
_VIEW = '/view/view_vpn'
_MTU_MIN = 576
_MTU_MAX = 9000
def _wg_vlan(cfg):
return next((v for v in cfg.get('vlans', []) if v.get('is_vpn')), None)
def _wg_vlan_by_name(cfg, name):
return next((v for v in cfg.get('vlans', []) if v.get('is_vpn') and v.get('name') == name), None)
def _find_peer_by_flat_idx(cfg, flat_idx):
i = 0
for vlan in cfg.get('vlans', []):
if not vlan.get('is_vpn'):
continue
peers = vlan.get('peers', [])
for j in range(len(peers)):
if i == flat_idx:
return vlan, j
i += 1
return None, None
def _wg_iface(vlan, cfg):
wg_vlans = [v for v in cfg.get('vlans', []) if v.get('is_vpn')]
idx = next((i for i, v in enumerate(wg_vlans) if v is vlan), 0)
return f'wg{idx}'
def _row_index():
try:
return int(request.form.get('row_index', ''))
except (ValueError, TypeError):
return None
def _hash_ok():
if not verify_config_hash(request.form.get('config_hash', '')):
flash('Configuration was modified by another session. Please refresh and try again.', 'error')
return False
return True
def _generate_wg_keypair():
from cryptography.hazmat.primitives.asymmetric.x25519 import X25519PrivateKey
from cryptography.hazmat.primitives.serialization import (
Encoding, PublicFormat, PrivateFormat, NoEncryption,
)
private = X25519PrivateKey.generate()
priv_raw = private.private_bytes(Encoding.Raw, PrivateFormat.Raw, NoEncryption())
pub_raw = private.public_key().public_bytes(Encoding.Raw, PublicFormat.Raw)
return base64.b64encode(priv_raw).decode(), base64.b64encode(pub_raw).decode()
def _server_pubkey(iface):
try:
with open(f'{CONFIGS_DIR}/.{iface}.pub') as f:
return f.read().strip()
except OSError:
return None
def _build_client_conf(vlan, peer_name, peer_ip, private_key, server_pubkey):
info = vlan.get('vpn_information', {})
overrides = info.get('explicit_overrides', {})
subnet = vlan.get('subnet', '')
mask = vlan.get('subnet_mask', '')
network = ipaddress.IPv4Network(f'{subnet}/{mask}', strict=False)
ident_ips = [s['ip'] for s in vlan.get('server_identities', []) if s.get('ip')]
default = str(min((ipaddress.IPv4Address(ip) for ip in ident_ips),
key=lambda x: x.packed[-1])) if ident_ips else str(next(network.hosts()))
gateway = overrides.get('gateway') or default
dns = overrides.get('dns_server') or gateway
prefix = network.prefixlen
mtu = overrides.get('mtu', '')
endpoint = info.get('server_endpoint', '')
listen_port = info.get('listen_port', 51820)
split_tunnel = next(
(p.get('split_tunnel', False) for p in vlan.get('peers', []) if p.get('name') == peer_name),
False,
)
allowed_ips = f'{subnet}/{prefix}' if split_tunnel else '0.0.0.0/0'
lines = [
f'# Generated by {WEB_APP_DISPLAY_NAME}', '',
'[Interface]',
f'PrivateKey = {private_key}',
f'Address = {peer_ip}/{prefix}',
f'DNS = {dns}',
]
if mtu:
lines.append(f'MTU = {mtu}')
lines += ['', '[Peer]', f'PublicKey = {server_pubkey}']
if endpoint:
lines.append(f'Endpoint = {endpoint}:{listen_port}')
lines += [f'AllowedIPs = {allowed_ips}', 'PersistentKeepalive = 25', '']
return '\n'.join(lines)
def _conf_response(vlan, peer_name, peer_ip, private_key):
cfg = load_config()
iface = _wg_iface(vlan, cfg)
server_pub = _server_pubkey(iface)
if not server_pub:
flash('Peer saved. Run sudo python3 ~/routlin/core.py --apply to generate the server '
'public key, then regenerate this peer to download the client config.', 'warning')
return redirect(_VIEW)
conf = _build_client_conf(vlan, peer_name, peer_ip, private_key, server_pub)
safe = re.sub(r'[^A-Za-z0-9_\-]', '_', peer_name)
resp = make_response(conf)
resp.headers['Content-Type'] = 'text/plain; charset=utf-8'
resp.headers['Content-Disposition'] = f'attachment; filename="vpn-client-{safe}.conf"'
return resp
@bp.route('/action/apply_vpn', methods=['POST'])
@require_level('administrator')
def apply_vpn():
listen_port_raw = request.form.get('vpn_listen_port', '').strip()
server_endpoint = validate.domainname(request.form.get('vpn_server_endpoint', ''))
domain = validate.domainname(request.form.get('vpn_domain', ''))
dns_raw = request.form.get('vpn_dns_server', '').strip()
mtu_raw = request.form.get('vpn_mtu', '').strip()
if not listen_port_raw:
flash('Listen port is required.', 'error')
return redirect(_VIEW)
listen_port = validate.int_range(listen_port_raw, 1, 65535)
if listen_port is None:
flash(f'"{listen_port_raw}" is not a valid port number (1-65535).', 'error')
return redirect(_VIEW)
dns_server = ''
if dns_raw:
dns_server = validate.ip(dns_raw)
if not dns_server:
flash(f'"{dns_raw}" is not a valid IP address for DNS server.', 'error')
return redirect(_VIEW)
mtu = None
if mtu_raw:
mtu = validate.int_range(mtu_raw, _MTU_MIN, _MTU_MAX)
if mtu is None:
flash(f'"{mtu_raw}" is not a valid MTU (must be {_MTU_MIN}-{_MTU_MAX}).', 'error')
return redirect(_VIEW)
if not _hash_ok():
return redirect(_VIEW)
cfg = load_config()
vpn_vlan = _wg_vlan(cfg)
if vpn_vlan is None:
flash('No WireGuard VLAN found in configuration.', 'error')
return redirect(_VIEW)
for v in cfg.get('vlans', []):
if v.get('is_vpn') and v is not vpn_vlan and v.get('vpn_information', {}).get('listen_port') == listen_port:
flash(f'Listen port {listen_port} is already used by another VPN VLAN.', 'error')
return redirect(_VIEW)
before_info = copy.deepcopy(vpn_vlan.get('vpn_information', {}))
info = vpn_vlan.setdefault('vpn_information', {})
info['listen_port'] = listen_port
info['server_endpoint'] = server_endpoint
info['domain'] = domain
overrides = info.setdefault('explicit_overrides', {})
if dns_server:
overrides['dns_server'] = dns_server
else:
overrides.pop('dns_server', None)
if mtu is not None:
overrides['mtu'] = mtu
else:
overrides.pop('mtu', None)
errors = validate.validate_config(cfg)
if errors:
for msg in errors:
flash(msg, 'error')
return redirect(_VIEW)
vlan_name = vpn_vlan['name']
flash(save_config_with_snapshot(
cfg,
path=f'vlans.{vlan_name}.vpn_information', key=vlan_name, operation='edit',
before=before_info or None, after=copy.deepcopy(info),
description=f'Updated VPN configuration for {vlan_name}',
), 'success')
return redirect(_VIEW)
@bp.route('/action/add_vpn_peer', methods=['POST'])
@require_level('administrator')
def add_vpn_peer():
peer_name = sanitize.name(request.form.get('peer_name', ''))
peer_vlan_nm = request.form.get('peer_vlan', '').strip()
peer_ip_raw = request.form.get('peer_ip', '').strip()
split_tunnel = 'split_tunnel' in request.form
enabled = 'enabled' in request.form
if not peer_name:
flash('Peer name is required.', 'error')
return redirect(_VIEW)
if not peer_vlan_nm:
flash('Assigned VLAN is required.', 'error')
return redirect(_VIEW)
peer_ip = validate.ip(peer_ip_raw)
if not peer_ip:
flash(f'"{peer_ip_raw}" is not a valid IP address.', 'error')
return redirect(_VIEW)
if not _hash_ok():
return redirect(_VIEW)
cfg = load_config()
vpn_vlan = _wg_vlan_by_name(cfg, peer_vlan_nm)
if vpn_vlan is None:
flash(f'VPN VLAN "{peer_vlan_nm}" not found.', 'error')
return redirect(_VIEW)
try:
network = ipaddress.IPv4Network(f"{vpn_vlan['subnet']}/{vpn_vlan['subnet_mask']}", strict=False)
if ipaddress.IPv4Address(peer_ip) not in network:
flash(f'{peer_ip} is not within the subnet {vpn_vlan["subnet"]}/{vpn_vlan["subnet_mask"]} of {peer_vlan_nm}.', 'error')
return redirect(_VIEW)
except Exception:
pass
peers = vpn_vlan.setdefault('peers', [])
if any(p.get('name') == peer_name for p in peers):
flash(f'A peer named "{peer_name}" already exists.', 'error')
return redirect(_VIEW)
for v in cfg.get('vlans', []):
if not v.get('is_vpn'):
continue
if any(p.get('ip') == peer_ip for p in v.get('peers', [])):
flash(f'IP address {peer_ip} is already assigned to another peer.', 'error')
return redirect(_VIEW)
private_key, public_key = _generate_wg_keypair()
entry = {
'name': peer_name,
'ip': peer_ip,
'public_key': public_key,
'split_tunnel': split_tunnel,
'enabled': enabled,
}
peers.append(entry)
errors = validate.validate_config(cfg)
if errors:
for msg in errors:
flash(msg, 'error')
return redirect(_VIEW)
save_config_with_snapshot(
cfg,
path=f'vlans.{peer_vlan_nm}.peers', key=peer_name, operation='add',
before=None, after={k: v for k, v in entry.items() if k != 'public_key'},
description=f'Added VPN peer: {peer_name} ({peer_ip})',
queue=True,
)
return _conf_response(vpn_vlan, peer_name, peer_ip, private_key)
@bp.route('/action/edit_vpn_peer', methods=['POST'])
@require_level('administrator')
def edit_vpn_peer():
flat_idx = _row_index()
if flat_idx is None:
flash('Invalid request.', 'error')
return redirect(_VIEW)
peer_name = sanitize.name(request.form.get('name', ''))
split_tunnel = request.form.get('split_tunnel') in ('true', '1', 'on', 'yes')
enabled = request.form.get('enabled') not in ('false', '0', '')
if not peer_name:
flash('Peer name is required.', 'error')
return redirect(_VIEW)
if not _hash_ok():
return redirect(_VIEW)
cfg = load_config()
vlan, peer_idx = _find_peer_by_flat_idx(cfg, flat_idx)
if vlan is None:
flash('Peer not found.', 'error')
return redirect(_VIEW)
peers = vlan.get('peers', [])
if any(j != peer_idx and p.get('name') == peer_name for j, p in enumerate(peers)):
flash(f'A peer named "{peer_name}" already exists.', 'error')
return redirect(_VIEW)
before = copy.deepcopy({k: peers[peer_idx].get(k) for k in ('name', 'split_tunnel', 'enabled')})
peers[peer_idx].update({'name': peer_name, 'split_tunnel': split_tunnel, 'enabled': enabled})
errors = validate.validate_config(cfg)
if errors:
for msg in errors:
flash(msg, 'error')
return redirect(_VIEW)
vlan_name = vlan['name']
flash(save_config_with_snapshot(
cfg,
path=f'vlans.{vlan_name}.peers', key=peer_name, operation='edit',
before=before, after={'name': peer_name, 'split_tunnel': split_tunnel, 'enabled': enabled},
description=f'Edited VPN peer: {peer_name}',
), 'success')
return redirect(_VIEW)
@bp.route('/action/toggle_vpn_peer', methods=['POST'])
@require_level('administrator')
def toggle_vpn_peer():
flat_idx = _row_index()
if flat_idx is None:
flash('Invalid request.', 'error')
return redirect(_VIEW)
if not _hash_ok():
return redirect(_VIEW)
cfg = load_config()
vlan, peer_idx = _find_peer_by_flat_idx(cfg, flat_idx)
if vlan is None:
flash('Peer not found.', 'error')
return redirect(_VIEW)
peers = vlan.get('peers', [])
old_enabled = peers[peer_idx].get('enabled', True)
peers[peer_idx]['enabled'] = not old_enabled
errors = validate.validate_config(cfg)
if errors:
for msg in errors:
flash(msg, 'error')
return redirect(_VIEW)
peer_name = peers[peer_idx]['name']
vlan_name = vlan['name']
action = 'Enabled' if not old_enabled else 'Disabled'
flash(save_config_with_snapshot(
cfg,
path=f'vlans.{vlan_name}.peers', key=peer_name, operation='toggle',
before={'enabled': old_enabled}, after={'enabled': not old_enabled},
description=f'{action} VPN peer: {peer_name}',
), 'success')
return redirect(_VIEW)
@bp.route('/action/delete_vpn_peer', methods=['POST'])
@require_level('administrator')
def delete_vpn_peer():
flat_idx = _row_index()
if flat_idx is None:
flash('Invalid request.', 'error')
return redirect(_VIEW)
if not _hash_ok():
return redirect(_VIEW)
cfg = load_config()
vlan, peer_idx = _find_peer_by_flat_idx(cfg, flat_idx)
if vlan is None:
flash('Peer not found.', 'error')
return redirect(_VIEW)
peers = vlan.get('peers', [])
removed = peers.pop(peer_idx)
errors = validate.validate_config(cfg)
if errors:
for msg in errors:
flash(msg, 'error')
return redirect(_VIEW)
vlan_name = vlan['name']
flash(save_config_with_snapshot(
cfg,
path=f'vlans.{vlan_name}.peers', key=removed['name'], operation='delete',
before={k: removed.get(k) for k in ('name', 'ip', 'split_tunnel', 'enabled')},
after=None,
description=f'Deleted VPN peer: {removed["name"]}',
), 'success')
return redirect(_VIEW)
@bp.route('/action/regenerate_vpn_peer', methods=['POST'])
@require_level('administrator')
def regenerate_vpn_peer():
flat_idx = _row_index()
if flat_idx is None:
flash('Invalid request.', 'error')
return redirect(_VIEW)
if not _hash_ok():
return redirect(_VIEW)
cfg = load_config()
vlan, peer_idx = _find_peer_by_flat_idx(cfg, flat_idx)
if vlan is None:
flash('Peer not found.', 'error')
return redirect(_VIEW)
private_key, public_key = _generate_wg_keypair()
peer = vlan['peers'][peer_idx]
old_pub_key = peer.get('public_key', '')
peer['public_key'] = public_key
errors = validate.validate_config(cfg)
if errors:
for msg in errors:
flash(msg, 'error')
return redirect(_VIEW)
vlan_name = vlan['name']
save_config_with_snapshot(
cfg,
path=f'vlans.{vlan_name}.peers', key=peer['name'], operation='regenerate',
before={'public_key': old_pub_key}, after={'public_key': public_key},
description=f'Regenerated keypair for VPN peer: {peer["name"]}',
queue=True,
)
return _conf_response(vlan, peer['name'], peer['ip'], private_key)

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