Development

This commit is contained in:
Matthew Grotke 2026-05-27 22:04:04 -04:00
parent eed1d295dc
commit d9f3bd8289
45 changed files with 635 additions and 666 deletions

View file

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

View file

@ -13,9 +13,9 @@ def require_level(minimum):
if LEVEL_RANK.get(current, 0) < LEVEL_RANK.get(minimum, 0):
if current == 'nothing':
flash('Please log in to continue.', 'error')
return redirect('/view/view_login')
return redirect('/accountlogin')
flash('You do not have permission to perform this action.', 'error')
return redirect('/view/view_overview')
return redirect('/overview')
return f(*args, **kwargs)
return wrapper
return decorator

View file

@ -18,10 +18,9 @@ from pages.preferences.action import bp as preferences_bp
from pages.accountverifyemail.action import bp as accountverifyemail_bp
from pages.vpn.action import bp as vpn_bp
from pages.accountcreate.action import bp as accountcreate_bp
from pages.accountadd.action import bp as accountadd_bp
from pages.accountdelete.action import bp as accountdelete_bp
from pages.accountlogout.action import bp as accountlogout_bp
from pages.accountmanage.action import bp as accountmanage_bp
from pages.mdns.action import bp as mdns_bp
from action_accountlogout import bp as accountlogout_bp
from api_apply_health import bp as api_apply_health_bp
app = Flask(__name__)
@ -43,8 +42,7 @@ app.register_blueprint(preferences_bp)
app.register_blueprint(accountverifyemail_bp)
app.register_blueprint(vpn_bp)
app.register_blueprint(accountcreate_bp)
app.register_blueprint(accountadd_bp)
app.register_blueprint(accountdelete_bp)
app.register_blueprint(accountmanage_bp)
app.register_blueprint(accountlogout_bp)
app.register_blueprint(mdns_bp)
app.register_blueprint(api_apply_health_bp)

View file

@ -3,7 +3,7 @@
{
"type": "nav_item",
"label": "Overview",
"map_to": "view_overview",
"map_to": "overview",
"client_requirement": "client_is_nothing+"
},
{
@ -11,23 +11,23 @@
"label": "%MENU_LABEL%",
"client_requirement": "client_is_viewer+",
"items": [
{ "type": "nav_item", "label": "Physical Interfaces", "map_to": "view_physicalinterfaces", "client_requirement": "client_is_administrator+" },
{ "type": "nav_item", "label": "DNS Server", "map_to": "view_dnsserver", "client_requirement": "client_is_administrator+" },
{ "type": "nav_item", "label": "DNS Blocking", "map_to": "view_dnsblocking", "client_requirement": "client_is_administrator+" },
{ "type": "nav_item", "label": "Network Layout", "map_to": "view_networklayout", "client_requirement": "client_is_administrator+" },
{ "type": "nav_item", "label": "Inter-VLAN Exceptions", "map_to": "view_intervlan", "client_requirement": "client_is_administrator+" },
{ "type": "nav_item", "label": "Port Forwarding", "map_to": "view_portforwarding", "client_requirement": "client_is_administrator+" },
{ "type": "nav_item", "label": "DHCP", "map_to": "view_dhcp" },
{ "type": "nav_item", "label": "DDNS", "map_to": "view_ddns" },
{ "type": "nav_item", "label": "Host Overrides", "map_to": "view_hostoverrides", "client_requirement": "client_is_administrator+" },
{ "type": "nav_item", "label": "VPN", "map_to": "view_vpn" },
{ "type": "nav_item", "label": "Banned IPs", "map_to": "view_bannedips", "client_requirement": "client_is_administrator+" }
{ "type": "nav_item", "label": "Physical Interfaces", "map_to": "physicalinterfaces", "client_requirement": "client_is_administrator+" },
{ "type": "nav_item", "label": "DNS Server", "map_to": "dnsserver", "client_requirement": "client_is_administrator+" },
{ "type": "nav_item", "label": "DNS Blocking", "map_to": "dnsblocking", "client_requirement": "client_is_administrator+" },
{ "type": "nav_item", "label": "Network Layout", "map_to": "networklayout", "client_requirement": "client_is_administrator+" },
{ "type": "nav_item", "label": "Inter-VLAN Exceptions", "map_to": "intervlan", "client_requirement": "client_is_administrator+" },
{ "type": "nav_item", "label": "Port Forwarding", "map_to": "portforwarding", "client_requirement": "client_is_administrator+" },
{ "type": "nav_item", "label": "DHCP", "map_to": "dhcp" },
{ "type": "nav_item", "label": "DDNS", "map_to": "ddns" },
{ "type": "nav_item", "label": "Host Overrides", "map_to": "hostoverrides", "client_requirement": "client_is_administrator+" },
{ "type": "nav_item", "label": "VPN", "map_to": "vpn" },
{ "type": "nav_item", "label": "Banned IPs", "map_to": "bannedips", "client_requirement": "client_is_administrator+" }
]
},
{
"type": "nav_item",
"label": "Actions",
"map_to": "view_actions",
"map_to": "actions",
"client_requirement": "client_is_administrator+"
},
{
@ -36,22 +36,22 @@
"align": "right",
"client_requirement": "client_is_viewer+",
"items": [
{ "type": "nav_item", "label": "Preferences", "map_to": "view_preferences" },
{ "type": "nav_item", "label": "Manage Accounts", "map_to": "view_manageaccounts", "client_requirement": "client_is_manager+" },
{ "type": "nav_item", "label": "Preferences", "map_to": "preferences" },
{ "type": "nav_item", "label": "Manage Accounts", "map_to": "accountmanage", "client_requirement": "client_is_manager+" },
{ "type": "nav_action", "label": "Log Out", "action": "log_out" }
]
},
{
"type": "nav_item",
"label": "Log In",
"map_to": "view_login",
"map_to": "accountlogin",
"align": "right",
"client_requirement": "client_is_nothing="
},
{
"type": "nav_item",
"label": "Create Account",
"map_to": "view_createaccount",
"map_to": "accountcreate",
"align": "right",
"client_requirement": "client_is_nothing="
}

View file

@ -1,3 +1,4 @@
from pathlib import Path
from flask import Blueprint, request, session, redirect, flash
import json, os, bcrypt, secrets, smtplib
from datetime import datetime, timezone, timedelta
@ -6,7 +7,9 @@ from auth import require_level
from config_utils import WEB_APP_DISPLAY_NAME, ACCOUNTS_FILE
import sanitize
bp = Blueprint('accountcreate', __name__)
_PAGE = Path(__file__).parent.name
bp = Blueprint(_PAGE, __name__)
CODE_TTL_MIN = 15
@ -48,12 +51,12 @@ def _send_verification_email(to_address, code):
smtp.send_message(msg)
@bp.route('/action/create_account', methods=['POST'])
@bp.route('/action/accountcreate/form_create', methods=['POST'])
@require_level('nothing')
def create_account():
def form_create():
# Abort if already logged in
if session.get('access_level', 'nothing') != 'nothing':
return redirect('/view/view_overview')
return redirect('/overview')
email = sanitize.email(request.form.get('email', ''))
password = request.form.get('password', '')
@ -62,26 +65,26 @@ def create_account():
if not email or not password or not password_confirm or not tz:
flash('All fields are required.', 'error')
return redirect('/view/view_createaccount')
return redirect(f'/{_PAGE}')
if password != password_confirm:
flash('Passwords do not match.', 'error')
return redirect('/view/view_createaccount')
return redirect(f'/{_PAGE}')
if len(password) < 8:
flash('Password must be at least 8 characters.', 'error')
return redirect('/view/view_createaccount')
return redirect(f'/{_PAGE}')
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')
return redirect(f'/{_PAGE}')
if account.get('hashed_password'):
flash('This account is already set up. Please log in instead.', 'error')
return redirect('/view/view_createaccount')
return redirect(f'/{_PAGE}')
salt = bcrypt.gensalt()
hashed = bcrypt.hashpw(password.encode('utf-8'), salt)
@ -92,7 +95,7 @@ def create_account():
_send_verification_email(account['email_address'], code)
except Exception as exc:
flash(f'Could not send verification email: {exc}', 'error')
return redirect('/view/view_createaccount')
return redirect(f'/{_PAGE}')
session['pending_create_account'] = {
'email': account['email_address'],
@ -102,4 +105,4 @@ def create_account():
'expires': expires,
}
return redirect('/view/view_verifyemail')
return redirect('/accountverifyemail')

View file

@ -1,5 +1,4 @@
{
"id": "view_createaccount",
"client_requirement": "client_is_nothing=",
"items": [
{
@ -22,7 +21,7 @@
},
{
"type": "form",
"action": "/action/create_account",
"action": "/action/accountcreate/form_create",
"method": "post",
"items": [
{
@ -58,7 +57,7 @@
},
{
"type": "button_primary",
"action": "/action/create_account",
"action": "/action/accountcreate/form_create",
"method": "post",
"text": "Create Account",
"class": "btn-full"
@ -69,7 +68,7 @@
"type": "p",
"text": "Already have an account?",
"link": {
"action": "/view/view_login",
"action": "/accountlogin",
"text": "Log In"
}
}
@ -94,7 +93,7 @@
},
{
"type": "button_primary",
"action": "/view/overview",
"action": "/overview",
"text": "Go to Overview"
}
]

View file

@ -1,50 +0,0 @@
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

@ -1,10 +1,13 @@
from pathlib import Path
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__)
_PAGE = Path(__file__).parent.name
bp = Blueprint(_PAGE, __name__)
@ -16,35 +19,35 @@ def _load_accounts():
return {'accounts': []}
@bp.route('/action/log_in', methods=['POST'])
@bp.route('/action/accountlogin/form_login', methods=['POST'])
@require_level('nothing')
def log_in():
def form_login():
# Abort if already logged in
if session.get('access_level', 'nothing') != 'nothing':
return redirect('/view/view_overview')
return redirect('/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')
return redirect(f'/{_PAGE}')
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')
return redirect(f'/{_PAGE}')
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')
return redirect(f'/{_PAGE}')
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')
return redirect(f'/{_PAGE}')
session.clear()
session['email_address'] = account['email_address']
@ -53,4 +56,4 @@ def log_in():
session['apply_changes_immediately'] = False
session.permanent = True
return redirect('/view/view_overview')
return redirect('/overview')

View file

@ -1,5 +1,4 @@
{
"id": "view_login",
"client_requirement": "client_is_nothing=",
"items": [
{
@ -22,7 +21,7 @@
},
{
"type": "form",
"action": "/action/log_in",
"action": "/action/accountlogin/form_login",
"method": "post",
"items": [
{
@ -41,7 +40,7 @@
},
{
"type": "button_primary",
"action": "/action/log_in",
"action": "/action/accountlogin/form_login",
"method": "post",
"text": "Log In",
"class": "btn-full"
@ -52,7 +51,7 @@
"type": "p",
"text": "Need to complete your account?",
"link": {
"action": "/view/view_createaccount",
"action": "/accountcreate",
"text": "Create Account"
}
}
@ -77,7 +76,7 @@
},
{
"type": "button_primary",
"action": "/view/overview",
"action": "/overview",
"text": "Go to Overview"
}
]

View file

@ -1,3 +1,4 @@
from pathlib import Path
from flask import Blueprint, request, session, redirect, flash
import json, re
from datetime import datetime, timezone
@ -5,8 +6,9 @@ from auth import require_level
from config_utils import ACCOUNTS_FILE
import sanitize
bp = Blueprint('accountadd', __name__)
_PAGE = Path(__file__).parent.name
bp = Blueprint(_PAGE, __name__)
VALID_LEVELS = {'viewer', 'administrator', 'manager'}
@ -23,30 +25,30 @@ def _save_accounts(data):
json.dump(data, f, indent=2)
@bp.route('/action/add_account', methods=['POST'])
@bp.route('/action/accountmanage/accounts_add', methods=['POST'])
@require_level('manager')
def add_account():
def accounts_add():
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')
return redirect(f'/{_PAGE}')
if not re.match(r'^[^@\s]+@[^@\s]+\.[^@\s]+$', email):
flash('Email address does not appear to be valid.', 'error')
return redirect('/view/view_manageaccounts')
return redirect(f'/{_PAGE}')
if access_level not in VALID_LEVELS:
flash('Invalid access level.', 'error')
return redirect('/view/view_manageaccounts')
return redirect(f'/{_PAGE}')
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')
return redirect(f'/{_PAGE}')
now = datetime.now(tz=timezone.utc).strftime('%Y-%m-%dT%H:%M:%SZ')
accounts.append({
@ -61,4 +63,35 @@ def add_account():
_save_accounts(data)
flash(f'Authorization added for {email}. User must complete account setup via the Create Account page.', 'success')
return redirect('/view/view_manageaccounts')
return redirect(f'/{_PAGE}')
@bp.route('/action/accountmanage/accounts_delete', methods=['POST'])
@require_level('manager')
def accounts_delete():
try:
row_index = int(request.form.get('row_index', ''))
except (ValueError, TypeError):
flash('Invalid request.', 'error')
return redirect(f'/{_PAGE}')
data = _load_accounts()
accounts = data.get('accounts', [])
if row_index < 0 or row_index >= len(accounts):
flash('Account not found.', 'error')
return redirect(f'/{_PAGE}')
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(f'/{_PAGE}')
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(f'/{_PAGE}')

View file

@ -1,5 +1,4 @@
{
"id": "view_manageaccounts",
"client_requirement": "client_is_manager+",
"items": [
{
@ -40,7 +39,7 @@
],
"row_actions": [
{
"action": "/action/delete_account",
"action": "/action/accountmanage/accounts_delete",
"method": "post",
"text": "Remove",
"class": "btn-danger btn-sm"
@ -53,7 +52,7 @@
"items": [
{
"type": "form",
"action": "/action/add_account",
"action": "/action/accountmanage/accounts_add",
"method": "post",
"items": [
{
@ -76,7 +75,7 @@
"items": [
{
"type": "button_primary",
"action": "/action/add_account",
"action": "/action/accountmanage/accounts_add",
"method": "post",
"text": "Authorize"
}

View file

@ -1,10 +1,13 @@
from pathlib import Path
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__)
_PAGE = Path(__file__).parent.name
bp = Blueprint(_PAGE, __name__)
@ -20,29 +23,29 @@ def _save_accounts(data):
json.dump(data, f, indent=2)
@bp.route('/action/verify_email', methods=['POST'])
@bp.route('/action/accountverifyemail/email_verify', methods=['POST'])
@require_level('nothing')
def verify_email():
def email_verify():
# Abort if already logged in
if session.get('access_level', 'nothing') != 'nothing':
return redirect('/view/view_overview')
return redirect('/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')
return redirect('/accountcreate')
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')
return redirect('/accountcreate')
submitted = request.form.get('code', '').strip()
if submitted != pending['code']:
flash('Incorrect verification code.', 'error')
return redirect('/view/view_verifyemail')
return redirect(f'/{_PAGE}')
data = _load_accounts()
accounts = data.get('accounts', [])
@ -54,12 +57,12 @@ def verify_email():
if account is None:
session.pop('pending_create_account', None)
flash('Account no longer exists. Contact your manager.', 'error')
return redirect('/view/view_createaccount')
return redirect('/accountcreate')
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')
return redirect('/accountlogin')
now = datetime.now(tz=timezone.utc).strftime('%Y-%m-%dT%H:%M:%SZ')
account['hashed_password'] = pending['hashed_password']
@ -77,15 +80,15 @@ def verify_email():
session['timezone'] = pending['timezone']
session.permanent = True
return redirect('/view/view_overview')
return redirect('/overview')
@bp.route('/action/resend_verification')
@bp.route('/action/accountverifyemail/email_resend')
@require_level('nothing')
def resend_verification():
def email_resend():
# Abort if already logged in
if session.get('access_level', 'nothing') != 'nothing':
return redirect('/view/view_overview')
return redirect('/overview')
from pages.accountcreate.action import _send_verification_email, CODE_TTL_MIN
@ -93,7 +96,7 @@ def resend_verification():
if not pending:
flash('No pending account creation found. Please start over.', 'error')
return redirect('/view/view_createaccount')
return redirect('/accountcreate')
code = f'{secrets.randbelow(1000000):06d}'
expires = (datetime.now(tz=timezone.utc) + timedelta(minutes=CODE_TTL_MIN)).isoformat()
@ -102,11 +105,11 @@ def resend_verification():
_send_verification_email(pending['email'], code)
except Exception as exc:
flash(f'Could not resend verification email: {exc}', 'error')
return redirect('/view/view_verifyemail')
return redirect(f'/{_PAGE}')
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')
return redirect(f'/{_PAGE}')

View file

@ -1,5 +1,4 @@
{
"id": "view_verifyemail",
"client_requirement": "client_is_nothing=",
"items": [
{
@ -19,7 +18,7 @@
},
{
"type": "form",
"action": "/action/verify_email",
"action": "/action/accountverifyemail/email_verify",
"method": "post",
"items": [
{
@ -32,7 +31,7 @@
},
{
"type": "button_primary",
"action": "/action/verify_email",
"action": "/action/accountverifyemail/email_verify",
"method": "post",
"text": "Verify",
"class": "btn-full"
@ -43,7 +42,7 @@
"type": "p",
"text": "Didn't receive it?",
"link": {
"action": "/action/resend_verification",
"action": "/action/accountverifyemail/email_resend",
"text": "Resend code"
}
},
@ -51,7 +50,7 @@
"type": "p",
"text": "Wrong email?",
"link": {
"action": "/view/view_createaccount",
"action": "/accountcreate",
"text": "Start over"
}
}
@ -76,7 +75,7 @@
},
{
"type": "button_primary",
"action": "/view/view_overview",
"action": "/overview",
"text": "Go to Overview"
}
]

View file

@ -1,41 +1,41 @@
from pathlib import Path
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__)
_PAGE = Path(__file__).parent.name
_VIEW = '/view/view_actions'
bp = Blueprint(_PAGE, __name__)
@bp.route('/action/actions_cardpending_save', methods=['POST'])
@bp.route('/action/actions/pending_save', methods=['POST'])
@require_level('administrator')
def actions_cardpending_save():
def pending_save():
session['apply_changes_immediately'] = 'apply_changes_immediately' in request.form
flash('Preference saved.', 'success')
return redirect(_VIEW)
return redirect(f'/{_PAGE}')
@bp.route('/action/actions_cardpending_applynow', methods=['POST'])
@bp.route('/action/actions/pending_apply', methods=['POST'])
@require_level('administrator')
def actions_cardpending_applynow():
def pending_apply():
pending = get_dashboard_pending()
if not pending:
flash('No pending changes to apply.', 'info')
return redirect(_VIEW)
return redirect(f'/{_PAGE}')
flush_pending_to_queue()
if any(cmd != 'fix problems' for _, _, cmd, _ in pending):
flash('Changes queued.', 'success')
return redirect(_VIEW)
return redirect(f'/{_PAGE}')
@bp.route('/action/actions_cardhistory_revertselected', methods=['POST'])
@bp.route('/action/actions/history_revert', methods=['POST'])
@require_level('administrator')
def actions_cardhistory_revertselected():
def history_revert():
selected_uuids = request.form.getlist('selected_uuids')
if not selected_uuids:
flash('No items selected.', 'info')
return redirect(_VIEW)
return redirect(f'/{_PAGE}')
succeeded, failed = 0, 0
for uuid in selected_uuids:
msg, ok = revert_snapshot_to_config(uuid)
@ -47,4 +47,4 @@ def actions_cardhistory_revertselected():
if succeeded:
plural = 's' if succeeded != 1 else ''
flash(f'{succeeded} change{plural} reverted.', 'success')
return redirect(_VIEW)
return redirect(f'/{_PAGE}')

View file

@ -1,5 +1,4 @@
{
"id": "view_actions",
"client_requirement": "client_is_viewer+",
"items": [
{
@ -22,7 +21,7 @@
"items": [
{
"type": "form",
"action": "/action/actions_cardpending_applynow",
"action": "/action/actions/pending_apply",
"method": "post",
"items": [
{
@ -50,7 +49,7 @@
},
{
"type": "form",
"action": "/action/actions_cardpending_save",
"action": "/action/actions/pending_save",
"method": "post",
"items": [
{
@ -66,7 +65,7 @@
"items": [
{
"type": "button_primary",
"action": "/action/actions_cardpending_save",
"action": "/action/actions/pending_save",
"method": "post",
"text": "Save"
},
@ -87,7 +86,7 @@
"items": [
{
"type": "form",
"action": "/action/actions_cardhistory_revertselected",
"action": "/action/actions/history_revert",
"method": "post",
"items": [
{

View file

@ -1,3 +1,4 @@
from pathlib import Path
import copy
from flask import Blueprint, request, redirect, flash
@ -6,10 +7,9 @@ from config_utils import load_config, save_config_with_snapshot, verify_config_h
import sanitize
import validation as validate
bp = Blueprint('bannedips', __name__)
VIEW = '/view/view_bannedips'
_PAGE = Path(__file__).parent.name
bp = Blueprint(_PAGE, __name__)
def _row_index():
try:
@ -37,15 +37,15 @@ def _parse_ip():
return ip
@bp.route('/action/add_banned_ip', methods=['POST'])
@bp.route('/action/bannedips/addip_add', methods=['POST'])
@require_level('administrator')
def add_banned_ip():
def addip_add():
description = sanitize.text(request.form.get('description', ''))
ip = _parse_ip()
if ip is None:
return redirect(VIEW)
return redirect(f'/{_PAGE}')
if not _hash_ok():
return redirect(VIEW)
return redirect(f'/{_PAGE}')
cfg = load_config()
entry = {'description': description, 'ip': ip, 'enabled': True}
@ -54,7 +54,7 @@ def add_banned_ip():
if errors:
for msg in errors:
flash(msg, 'error')
return redirect(VIEW)
return redirect(f'/{_PAGE}')
flash(save_config_with_snapshot(
cfg,
@ -62,24 +62,24 @@ def add_banned_ip():
before=None, after=entry,
description=f'Added banned IP: {ip}',
), 'success')
return redirect(VIEW)
return redirect(f'/{_PAGE}')
@bp.route('/action/toggle_banned_ip', methods=['POST'])
@bp.route('/action/bannedips/table_toggle', methods=['POST'])
@require_level('administrator')
def toggle_banned_ip():
def table_toggle():
idx = _row_index()
if idx is None:
flash('Invalid request.', 'error')
return redirect(VIEW)
return redirect(f'/{_PAGE}')
if not _hash_ok():
return redirect(VIEW)
return redirect(f'/{_PAGE}')
cfg = load_config()
items = cfg.get('banned_ips', [])
if idx < 0 or idx >= len(items):
flash('Entry not found.', 'error')
return redirect(VIEW)
return redirect(f'/{_PAGE}')
old_enabled = items[idx].get('enabled', True)
items[idx]['enabled'] = not old_enabled
@ -87,7 +87,7 @@ def toggle_banned_ip():
if errors:
for msg in errors:
flash(msg, 'error')
return redirect(VIEW)
return redirect(f'/{_PAGE}')
action = 'Enabled' if not old_enabled else 'Disabled'
flash(save_config_with_snapshot(
@ -96,31 +96,31 @@ def toggle_banned_ip():
before={'enabled': old_enabled}, after={'enabled': not old_enabled},
description=f'{action} banned IP: {items[idx]["ip"]}',
), 'success')
return redirect(VIEW)
return redirect(f'/{_PAGE}')
@bp.route('/action/edit_banned_ip', methods=['POST'])
@bp.route('/action/bannedips/table_edit', methods=['POST'])
@require_level('administrator')
def edit_banned_ip():
def table_edit():
idx = _row_index()
if idx is None:
flash('Invalid request.', 'error')
return redirect(VIEW)
return redirect(f'/{_PAGE}')
description = sanitize.text(request.form.get('description', ''))
ip = _parse_ip()
if ip is None:
return redirect(VIEW)
return redirect(f'/{_PAGE}')
enabled = request.form.get('enabled') == 'on'
if not _hash_ok():
return redirect(VIEW)
return redirect(f'/{_PAGE}')
cfg = load_config()
items = cfg.get('banned_ips', [])
if idx < 0 or idx >= len(items):
flash('Entry not found.', 'error')
return redirect(VIEW)
return redirect(f'/{_PAGE}')
before = copy.deepcopy(items[idx])
items[idx].update({'description': description, 'ip': ip, 'enabled': enabled})
@ -128,7 +128,7 @@ def edit_banned_ip():
if errors:
for msg in errors:
flash(msg, 'error')
return redirect(VIEW)
return redirect(f'/{_PAGE}')
flash(save_config_with_snapshot(
cfg,
@ -136,31 +136,31 @@ def edit_banned_ip():
before=before, after=copy.deepcopy(items[idx]),
description=f'Edited banned IP: {ip}',
), 'success')
return redirect(VIEW)
return redirect(f'/{_PAGE}')
@bp.route('/action/delete_banned_ip', methods=['POST'])
@bp.route('/action/bannedips/table_delete', methods=['POST'])
@require_level('administrator')
def delete_banned_ip():
def table_delete():
idx = _row_index()
if idx is None:
flash('Invalid request.', 'error')
return redirect(VIEW)
return redirect(f'/{_PAGE}')
if not _hash_ok():
return redirect(VIEW)
return redirect(f'/{_PAGE}')
cfg = load_config()
items = cfg.get('banned_ips', [])
if idx < 0 or idx >= len(items):
flash('Entry not found.', 'error')
return redirect(VIEW)
return redirect(f'/{_PAGE}')
removed = items.pop(idx)
errors = validate.validate_config(cfg)
if errors:
for msg in errors:
flash(msg, 'error')
return redirect(VIEW)
return redirect(f'/{_PAGE}')
flash(save_config_with_snapshot(
cfg,
@ -168,4 +168,4 @@ def delete_banned_ip():
before=removed, after=None,
description=f'Deleted banned IP: {removed["ip"]}',
), 'success')
return redirect(VIEW)
return redirect(f'/{_PAGE}')

View file

@ -1,5 +1,4 @@
{
"id": "view_bannedips",
"client_requirement": "client_is_viewer+",
"items": [
{
@ -43,7 +42,7 @@
"row_actions": [
{
"client_requirement": "client_is_administrator+",
"action": "/action/edit_banned_ip",
"action": "/action/bannedips/table_edit",
"method": "inline_edit",
"text": "Edit",
"class": "btn-ghost btn-sm",
@ -65,7 +64,7 @@
},
{
"client_requirement": "client_is_administrator+",
"action": "/action/delete_banned_ip",
"action": "/action/bannedips/table_delete",
"method": "post",
"text": "Delete",
"class": "btn-danger btn-sm"
@ -80,7 +79,7 @@
"items": [
{
"type": "form",
"action": "/action/add_banned_ip",
"action": "/action/bannedips/addip_add",
"method": "post",
"items": [
{
@ -103,7 +102,7 @@
"items": [
{
"type": "button_primary",
"action": "/action/add_banned_ip",
"action": "/action/bannedips/addip_add",
"method": "post",
"text": "Add Banned IP"
},

View file

@ -1,3 +1,4 @@
from pathlib import Path
import copy
import os
from flask import Blueprint, request, redirect, flash, send_file, abort
@ -6,32 +7,33 @@ from config_utils import load_config, verify_config_hash, save_config_with_snaps
import sanitize
import validation as validate
bp = Blueprint('ddns', __name__)
_PAGE = Path(__file__).parent.name
bp = Blueprint(_PAGE, __name__)
VIEW = '/view/view_ddns'
LOG_FILE = f'{CONFIGS_DIR}/ddns.log'
@bp.route('/action/ddns_cardaddaccount_add', methods=['POST'])
@bp.route('/action/ddns/addaccount_add', methods=['POST'])
@require_level('administrator')
def ddns_cardaddaccount_add():
def addaccount_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)
return redirect(f'/{_PAGE}')
if not hostnames:
flash('At least one hostname is required.', 'error')
return redirect(VIEW)
return redirect(f'/{_PAGE}')
if not provider_type:
flash('Unknown provider type.', 'error')
return redirect(VIEW)
return redirect(f'/{_PAGE}')
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)
return redirect(f'/{_PAGE}')
entry = {
'description': description,
@ -54,17 +56,17 @@ def ddns_cardaddaccount_add():
cmd='ddns update',
queue=False,
), 'success')
return redirect(VIEW)
return redirect(f'/{_PAGE}')
@bp.route('/action/ddns_tableaccounts_rowedit', methods=['POST'])
@bp.route('/action/ddns/accounts_edit', methods=['POST'])
@require_level('administrator')
def ddns_tableaccounts_rowedit():
def accounts_edit():
try:
row_index = int(request.form.get('row_index', -1))
except (TypeError, ValueError):
flash('Invalid row index.', 'error')
return redirect(VIEW)
return redirect(f'/{_PAGE}')
provider_type = sanitize.filtervalue(request.form.get('provider', ''), validate.VALID_DDNS_PROVIDERS)
description = sanitize.description(request.form.get('description', ''))
@ -73,17 +75,17 @@ def ddns_tableaccounts_rowedit():
if not provider_type:
flash('Unknown provider type.', 'error')
return redirect(VIEW)
return redirect(f'/{_PAGE}')
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)
return redirect(f'/{_PAGE}')
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)
return redirect(f'/{_PAGE}')
before = copy.deepcopy(providers[row_index])
entry = {
@ -106,27 +108,27 @@ def ddns_tableaccounts_rowedit():
cmd='ddns update',
queue=False,
), 'success')
return redirect(VIEW)
return redirect(f'/{_PAGE}')
@bp.route('/action/ddns_tableaccounts_rowdelete', methods=['POST'])
@bp.route('/action/ddns/accounts_delete', methods=['POST'])
@require_level('administrator')
def ddns_tableaccounts_rowdelete():
def accounts_delete():
try:
row_index = int(request.form.get('row_index', -1))
except (TypeError, ValueError):
flash('Invalid row index.', 'error')
return redirect(VIEW)
return redirect(f'/{_PAGE}')
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)
return redirect(f'/{_PAGE}')
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)
return redirect(f'/{_PAGE}')
before = copy.deepcopy(providers[row_index])
description = before.get('description', str(row_index))
@ -138,12 +140,12 @@ def ddns_tableaccounts_rowdelete():
cmd='ddns update',
queue=False,
), 'success')
return redirect(VIEW)
return redirect(f'/{_PAGE}')
@bp.route('/action/ddns_cardipcheckinterval_save', methods=['POST'])
@bp.route('/action/ddns/ipcheckinterval_save', methods=['POST'])
@require_level('administrator')
def ddns_cardipcheckinterval_save():
def ipcheckinterval_save():
raw = request.form.get('timer_interval', '').strip()
try:
mins = int(raw)
@ -151,12 +153,12 @@ def ddns_cardipcheckinterval_save():
raise ValueError
except ValueError:
flash('Interval must be a whole number of minutes >= 1.', 'error')
return redirect(VIEW)
return redirect(f'/{_PAGE}')
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)
return redirect(f'/{_PAGE}')
cfg = load_config()
before = copy.deepcopy(cfg.get('ddns', {}).get('general', {}))
@ -167,22 +169,22 @@ def ddns_cardipcheckinterval_save():
description='Updated DDNS check interval',
cmd='core apply',
), 'success')
return redirect(VIEW)
return redirect(f'/{_PAGE}')
@bp.route('/action/ddns_cardipcheckservices_save', methods=['POST'])
@bp.route('/action/ddns/ipcheckservices_save', methods=['POST'])
@require_level('administrator')
def ddns_cardipcheckservices_save():
def ipcheckservices_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)
return redirect(f'/{_PAGE}')
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)
return redirect(f'/{_PAGE}')
cfg = load_config()
before = copy.deepcopy(cfg.get('ddns', {}).get('ip_check_services', []))
@ -196,21 +198,21 @@ def ddns_cardipcheckservices_save():
cmd='ddns update',
queue=False,
), 'success')
return redirect(VIEW)
return redirect(f'/{_PAGE}')
@bp.route('/action/ddns_cardlogging_save', methods=['POST'])
@bp.route('/action/ddns/logging_save', methods=['POST'])
@require_level('administrator')
def ddns_cardlogging_save():
def logging_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)
return redirect(f'/{_PAGE}')
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)
return redirect(f'/{_PAGE}')
cfg = load_config()
before = copy.deepcopy(cfg.get('ddns', {}).get('general', {}))
@ -225,23 +227,23 @@ def ddns_cardlogging_save():
cmd='ddns update',
queue=False,
), 'success')
return redirect(VIEW)
return redirect(f'/{_PAGE}')
@bp.route('/action/ddns_cardlogging_clear', methods=['POST'])
@bp.route('/action/ddns/logging_clear', methods=['POST'])
@require_level('administrator')
def ddns_cardlogging_clear():
def logging_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)
return redirect(f'/{_PAGE}')
@bp.route('/action/ddns_cardlogging_download', methods=['GET'])
@bp.route('/action/ddns/logging_download', methods=['GET'])
@require_level('administrator')
def ddns_cardlogging_download():
def logging_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

@ -1,5 +1,4 @@
{
"id": "view_ddns",
"client_requirement": "client_is_viewer+",
"items": [
{
@ -29,7 +28,7 @@
"label": "IP Check Interval",
"value": "%DDNS_TIMER_INTERVAL%",
"sub": "%STAT_PUBLIC_IP_LAST_CHECKED%",
"edit_action": "/action/ddns_cardipcheckinterval_save",
"edit_action": "/action/ddns/ipcheckinterval_save",
"edit_field": "timer_interval",
"edit_input_type": "number",
"edit_min": "1",
@ -54,7 +53,7 @@
"items": [
{
"type": "form",
"action": "/action/ddns_cardipcheckservices_save",
"action": "/action/ddns/ipcheckservices_save",
"method": "post",
"items": [
{
@ -78,7 +77,7 @@
"items": [
{
"type": "button_primary",
"action": "/action/ddns_cardipcheckservices_save",
"action": "/action/ddns/ipcheckservices_save",
"method": "post",
"text": "Save"
},
@ -125,7 +124,7 @@
"row_actions": [
{
"client_requirement": "client_is_administrator+",
"action": "/action/ddns_tableaccounts_rowedit",
"action": "/action/ddns/accounts_edit",
"method": "inline_edit",
"text": "Edit",
"class": "btn-ghost btn-sm",
@ -155,7 +154,7 @@
},
{
"client_requirement": "client_is_administrator+",
"action": "/action/ddns_tableaccounts_rowdelete",
"action": "/action/ddns/accounts_delete",
"method": "post",
"text": "Delete",
"class": "btn-danger btn-sm"
@ -169,7 +168,7 @@
"items": [
{
"type": "form",
"action": "/action/ddns_cardaddaccount_add",
"action": "/action/ddns/addaccount_add",
"method": "post",
"items": [
{
@ -202,7 +201,7 @@
"items": [
{
"type": "button_primary",
"action": "/action/ddns_cardaddaccount_add",
"action": "/action/ddns/addaccount_add",
"method": "post",
"text": "Add Provider"
},
@ -236,12 +235,12 @@
"items": [
{
"type": "button_ghost",
"action": "/action/ddns_cardlogging_download",
"action": "/action/ddns/logging_download",
"text": "Download Log"
},
{
"type": "button_danger",
"action": "/action/ddns_cardlogging_clear",
"action": "/action/ddns/logging_clear",
"method": "post",
"text": "Clear Log"
}
@ -252,7 +251,7 @@
},
{
"type": "form",
"action": "/action/ddns_cardlogging_save",
"action": "/action/ddns/logging_save",
"method": "post",
"items": [
{
@ -277,7 +276,7 @@
"items": [
{
"type": "button_primary",
"action": "/action/ddns_cardlogging_save",
"action": "/action/ddns/logging_save",
"method": "post",
"text": "Save"
},

View file

@ -1,3 +1,4 @@
from pathlib import Path
import copy
import ipaddress
@ -7,10 +8,9 @@ from config_utils import load_config, save_config_with_snapshot, verify_config_h
import sanitize
import validation as validate
bp = Blueprint('dhcp', __name__)
VIEW = '/view/view_dhcp'
_PAGE = Path(__file__).parent.name
bp = Blueprint(_PAGE, __name__)
def _row_index():
try:
@ -66,9 +66,9 @@ def _check_ip_conflicts(ip, vlan):
return None
@bp.route('/action/add_dhcp_reservation', methods=['POST'])
@bp.route('/action/dhcp/addreservation_add', methods=['POST'])
@require_level('administrator')
def add_dhcp_reservation():
def addreservation_add():
vlan_name = sanitize.name(request.form.get('vlan_name', ''))
description = sanitize.text(request.form.get('description', ''))
hostname = validate.domainname(request.form.get('hostname', ''))
@ -77,27 +77,27 @@ def add_dhcp_reservation():
radius_client = 'radius_client' in request.form
if ip is None:
return redirect(VIEW)
return redirect(f'/{_PAGE}')
if not vlan_name:
flash('The configuration has not been saved because a VLAN is required.', 'error')
return redirect(VIEW)
return redirect(f'/{_PAGE}')
if not mac:
flash('The configuration has not been saved because a MAC address is required.', 'error')
return redirect(VIEW)
return redirect(f'/{_PAGE}')
if not _hash_ok():
return redirect(VIEW)
return redirect(f'/{_PAGE}')
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)
return redirect(f'/{_PAGE}')
conflict = _check_ip_conflicts(ip, vlan)
if conflict:
flash(f'The configuration has not been saved because {conflict}', 'error')
return redirect(VIEW)
return redirect(f'/{_PAGE}')
entry = {
'description': description,
@ -112,7 +112,7 @@ def add_dhcp_reservation():
if errors:
for msg in errors:
flash(msg, 'error')
return redirect(VIEW)
return redirect(f'/{_PAGE}')
flash(save_config_with_snapshot(
cfg,
@ -120,25 +120,25 @@ def add_dhcp_reservation():
before=None, after=entry,
description=f'Added DHCP reservation: {hostname or mac} ({ip or "dynamic"})',
), 'success')
return redirect(VIEW)
return redirect(f'/{_PAGE}')
@bp.route('/action/toggle_dhcp_reservation', methods=['POST'])
@bp.route('/action/dhcp/reservations_toggle', methods=['POST'])
@require_level('administrator')
def toggle_dhcp_reservation():
def reservations_toggle():
idx = _row_index()
if idx is None:
flash('Invalid request.', 'error')
return redirect(VIEW)
return redirect(f'/{_PAGE}')
if not _hash_ok():
return redirect(VIEW)
return redirect(f'/{_PAGE}')
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)
return redirect(f'/{_PAGE}')
res = vlans[vi]['reservations'][ri]
old_enabled = res.get('enabled', True)
@ -147,7 +147,7 @@ def toggle_dhcp_reservation():
if errors:
for msg in errors:
flash(msg, 'error')
return redirect(VIEW)
return redirect(f'/{_PAGE}')
vlan_name = vlans[vi]['name']
action = 'Enabled' if not old_enabled else 'Disabled'
@ -157,16 +157,16 @@ def toggle_dhcp_reservation():
before={'enabled': old_enabled}, after={'enabled': not old_enabled},
description=f'{action} DHCP reservation: {res.get("hostname") or res["mac"]}',
), 'success')
return redirect(VIEW)
return redirect(f'/{_PAGE}')
@bp.route('/action/edit_dhcp_reservation', methods=['POST'])
@bp.route('/action/dhcp/reservations_edit', methods=['POST'])
@require_level('administrator')
def edit_dhcp_reservation():
def reservations_edit():
idx = _row_index()
if idx is None:
flash('Invalid request.', 'error')
return redirect(VIEW)
return redirect(f'/{_PAGE}')
description = sanitize.text(request.form.get('description', ''))
hostname = validate.domainname(request.form.get('hostname', ''))
@ -175,24 +175,24 @@ def edit_dhcp_reservation():
radius_client = 'radius_client' in request.form
if ip is None:
return redirect(VIEW)
return redirect(f'/{_PAGE}')
if not mac:
flash('The configuration has not been saved because a MAC address is required.', 'error')
return redirect(VIEW)
return redirect(f'/{_PAGE}')
if not _hash_ok():
return redirect(VIEW)
return redirect(f'/{_PAGE}')
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)
return redirect(f'/{_PAGE}')
conflict = _check_ip_conflicts(ip, vlans[vi])
if conflict:
flash(f'The configuration has not been saved because {conflict}', 'error')
return redirect(VIEW)
return redirect(f'/{_PAGE}')
res = vlans[vi]['reservations'][ri]
before = copy.deepcopy(res)
@ -208,7 +208,7 @@ def edit_dhcp_reservation():
if errors:
for msg in errors:
flash(msg, 'error')
return redirect(VIEW)
return redirect(f'/{_PAGE}')
vlan_name = vlans[vi]['name']
flash(save_config_with_snapshot(
@ -217,25 +217,25 @@ def edit_dhcp_reservation():
before=before, after=copy.deepcopy(res),
description=f'Edited DHCP reservation: {hostname or mac} ({ip or "dynamic"})',
), 'success')
return redirect(VIEW)
return redirect(f'/{_PAGE}')
@bp.route('/action/delete_dhcp_reservation', methods=['POST'])
@bp.route('/action/dhcp/reservations_delete', methods=['POST'])
@require_level('administrator')
def delete_dhcp_reservation():
def reservations_delete():
idx = _row_index()
if idx is None:
flash('Invalid request.', 'error')
return redirect(VIEW)
return redirect(f'/{_PAGE}')
if not _hash_ok():
return redirect(VIEW)
return redirect(f'/{_PAGE}')
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)
return redirect(f'/{_PAGE}')
vlan_name = vlans[vi]['name']
removed = vlans[vi]['reservations'].pop(ri)
@ -243,7 +243,7 @@ def delete_dhcp_reservation():
if errors:
for msg in errors:
flash(msg, 'error')
return redirect(VIEW)
return redirect(f'/{_PAGE}')
flash(save_config_with_snapshot(
cfg,
@ -251,4 +251,4 @@ def delete_dhcp_reservation():
before=removed, after=None,
description=f'Deleted DHCP reservation: {removed.get("hostname") or removed["mac"]}',
), 'success')
return redirect(VIEW)
return redirect(f'/{_PAGE}')

View file

@ -1,5 +1,4 @@
{
"id": "view_dhcp",
"client_requirement": "client_is_viewer+",
"items": [
{
@ -97,7 +96,7 @@
"row_actions": [
{
"client_requirement": "client_is_administrator+",
"action": "/action/edit_dhcp_reservation",
"action": "/action/dhcp/reservations_edit",
"method": "inline_edit",
"text": "Edit",
"class": "btn-ghost btn-sm",
@ -134,7 +133,7 @@
},
{
"client_requirement": "client_is_administrator+",
"action": "/action/delete_dhcp_reservation",
"action": "/action/dhcp/reservations_delete",
"method": "post",
"text": "Delete",
"class": "btn-danger btn-sm"
@ -149,7 +148,7 @@
"items": [
{
"type": "form",
"action": "/action/add_dhcp_reservation",
"action": "/action/dhcp/addreservation_add",
"method": "post",
"items": [
{
@ -203,7 +202,7 @@
"items": [
{
"type": "button_primary",
"action": "/action/add_dhcp_reservation",
"action": "/action/dhcp/addreservation_add",
"method": "post",
"text": "Add"
},

View file

@ -1,3 +1,4 @@
from pathlib import Path
import copy
import re
from flask import Blueprint, request, redirect, flash
@ -6,9 +7,9 @@ from config_utils import load_config, save_config_with_snapshot, verify_config_h
import sanitize
import validation as validate
bp = Blueprint('dnsblocking', __name__)
_PAGE = Path(__file__).parent.name
VIEW = '/view/view_dnsblocking'
bp = Blueprint(_PAGE, __name__)
_VALID_FORMATS_STR = ', '.join(sorted(validate.VALID_BLOCKLIST_FORMATS))
@ -52,22 +53,22 @@ def _parse_fields():
return {'name': name, 'description': description, 'format': fmt, 'url': url}, None
@bp.route('/action/dnsblocking_tableblocklists_rowdelete', methods=['POST'])
@bp.route('/action/dnsblocking/blocklists_delete', methods=['POST'])
@require_level('administrator')
def dnsblocking_tableblocklists_rowdelete():
def blocklists_delete():
idx = _row_index()
if idx is None:
flash('Invalid request.', 'error')
return redirect(VIEW)
return redirect(f'/{_PAGE}')
if not _hash_ok():
return redirect(VIEW)
return redirect(f'/{_PAGE}')
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)
return redirect(f'/{_PAGE}')
before = copy.deepcopy(items[idx])
name = before.get('name', str(idx))
@ -76,36 +77,36 @@ def dnsblocking_tableblocklists_rowdelete():
if errors:
for msg in errors:
flash(msg, 'error')
return redirect(VIEW)
return redirect(f'/{_PAGE}')
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)
return redirect(f'/{_PAGE}')
@bp.route('/action/dnsblocking_tableblocklists_rowedit', methods=['POST'])
@bp.route('/action/dnsblocking/blocklists_edit', methods=['POST'])
@require_level('administrator')
def dnsblocking_tableblocklists_rowedit():
def blocklists_edit():
idx = _row_index()
if idx is None:
flash('Invalid request.', 'error')
return redirect(VIEW)
return redirect(f'/{_PAGE}')
fields, err = _parse_fields()
if err:
return redirect(VIEW)
return redirect(f'/{_PAGE}')
if not _hash_ok():
return redirect(VIEW)
return redirect(f'/{_PAGE}')
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)
return redirect(f'/{_PAGE}')
before = copy.deepcopy(items[idx])
items[idx].update({
@ -118,32 +119,32 @@ def dnsblocking_tableblocklists_rowedit():
if errors:
for msg in errors:
flash(msg, 'error')
return redirect(VIEW)
return redirect(f'/{_PAGE}')
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)
return redirect(f'/{_PAGE}')
@bp.route('/action/dnsblocking_cardaddblocklist_add', methods=['POST'])
@bp.route('/action/dnsblocking/addblocklist_add', methods=['POST'])
@require_level('administrator')
def dnsblocking_cardaddblocklist_add():
def addblocklist_add():
fields, err = _parse_fields()
if err:
return redirect(VIEW)
return redirect(f'/{_PAGE}')
if not _hash_ok():
return redirect(VIEW)
return redirect(f'/{_PAGE}')
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)
return redirect(f'/{_PAGE}')
entry = {
'name': fields['name'],
@ -157,28 +158,28 @@ def dnsblocking_cardaddblocklist_add():
if errors:
for msg in errors:
flash(msg, 'error')
return redirect(VIEW)
return redirect(f'/{_PAGE}')
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)
return redirect(f'/{_PAGE}')
@bp.route('/action/dnsblocking_cardblocklistrefresh_save', methods=['POST'])
@bp.route('/action/dnsblocking/blocklistrefresh_save', methods=['POST'])
@require_level('administrator')
def dnsblocking_cardblocklistrefresh_save():
def blocklistrefresh_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)
return redirect(f'/{_PAGE}')
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)
return redirect(f'/{_PAGE}')
cfg = load_config()
before = copy.deepcopy(cfg.get('dns_blocking', {}).get('general', {}))
@ -189,30 +190,30 @@ def dnsblocking_cardblocklistrefresh_save():
description='Updated daily blocklist refresh time',
cmd='core apply',
), 'success')
return redirect(VIEW)
return redirect(f'/{_PAGE}')
@bp.route('/action/dnsblocking_cardblocklistrefresh_refreshnow', methods=['POST'])
@bp.route('/action/dnsblocking/blocklistrefresh_refresh', methods=['POST'])
@require_level('administrator')
def dnsblocking_cardblocklistrefresh_refreshnow():
def blocklistrefresh_refresh():
flash(queued_msg('core update-blocklists', action_label='Blocklist refresh queued'), 'success')
return redirect(VIEW)
return redirect(f'/{_PAGE}')
@bp.route('/action/dnsblocking_cardlogging_save', methods=['POST'])
@bp.route('/action/dnsblocking/logging_save', methods=['POST'])
@require_level('administrator')
def dnsblocking_cardlogging_save():
def logging_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)
return redirect(f'/{_PAGE}')
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)
return redirect(f'/{_PAGE}')
cfg = load_config()
before = copy.deepcopy(cfg.get('dns_blocking', {}).get('general', {}))
@ -224,11 +225,11 @@ def dnsblocking_cardlogging_save():
if errors:
for msg in errors:
flash(msg, 'error')
return redirect(VIEW)
return redirect(f'/{_PAGE}')
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)
return redirect(f'/{_PAGE}')

View file

@ -1,5 +1,4 @@
{
"id": "view_dnsblocking",
"client_requirement": "client_is_viewer+",
"items": [
{
@ -42,7 +41,7 @@
"row_actions": [
{
"client_requirement": "client_is_administrator+",
"action": "/action/dnsblocking_tableblocklists_rowedit",
"action": "/action/dnsblocking/blocklists_edit",
"method": "inline_edit",
"text": "Edit",
"class": "btn-ghost btn-sm",
@ -70,7 +69,7 @@
},
{
"client_requirement": "client_is_administrator+",
"action": "/action/dnsblocking_tableblocklists_rowdelete",
"action": "/action/dnsblocking/blocklists_delete",
"method": "post",
"text": "Delete",
"class": "btn-danger btn-sm"
@ -85,7 +84,7 @@
"items": [
{
"type": "form",
"action": "/action/dnsblocking_cardaddblocklist_add",
"action": "/action/dnsblocking/addblocklist_add",
"method": "post",
"items": [
{
@ -123,7 +122,7 @@
"items": [
{
"type": "button_primary",
"action": "/action/dnsblocking_cardaddblocklist_add",
"action": "/action/dnsblocking/addblocklist_add",
"method": "post",
"text": "Add Blocklist"
},
@ -154,7 +153,7 @@
"items": [
{
"type": "button_secondary",
"action": "/action/dnsblocking_cardblocklistrefresh_refreshnow",
"action": "/action/dnsblocking/blocklistrefresh_refresh",
"method": "post",
"text": "Refresh All Now"
}
@ -165,7 +164,7 @@
},
{
"type": "form",
"action": "/action/dnsblocking_cardblocklistrefresh_save",
"action": "/action/dnsblocking/blocklistrefresh_save",
"method": "post",
"items": [
{
@ -183,7 +182,7 @@
"items": [
{
"type": "button_primary",
"action": "/action/dnsblocking_cardblocklistrefresh_save",
"action": "/action/dnsblocking/blocklistrefresh_save",
"method": "post",
"text": "Save"
},
@ -204,7 +203,7 @@
"items": [
{
"type": "form",
"action": "/action/dnsblocking_cardlogging_save",
"action": "/action/dnsblocking/logging_save",
"method": "post",
"items": [
{
@ -229,7 +228,7 @@
"items": [
{
"type": "button_primary",
"action": "/action/dnsblocking_cardlogging_save",
"action": "/action/dnsblocking/logging_save",
"method": "post",
"text": "Save"
},

View file

@ -1,3 +1,4 @@
from pathlib import Path
import copy
from flask import Blueprint, request, redirect, flash
from auth import require_level
@ -5,33 +6,32 @@ from config_utils import load_config, save_config_with_snapshot, verify_config_h
import sanitize
import validation as validate
bp = Blueprint('dnsserver', __name__)
_PAGE = Path(__file__).parent.name
_VIEW = '/view/view_dnsserver'
bp = Blueprint(_PAGE, __name__)
@bp.route('/action/dnsserver_cardupstreamdns_save', methods=['POST'])
@bp.route('/action/dnsserver/upstreamdns_save', methods=['POST'])
@require_level('administrator')
def dnsserver_cardupstreamdns_save():
def upstreamdns_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)
return redirect(f'/{_PAGE}')
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)
return redirect(f'/{_PAGE}')
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)
return redirect(f'/{_PAGE}')
cfg = load_config()
before = copy.deepcopy(cfg.get('upstream_dns', {}))
@ -39,7 +39,7 @@ def dnsserver_cardupstreamdns_save():
if (strict_order == bool(current.get('strict_order', False)) and
upstream_servers == current.get('upstream_servers', [])):
flash('No changes detected.', 'info')
return redirect(_VIEW)
return redirect(f'/{_PAGE}')
cfg.setdefault('upstream_dns', {}).update({
'strict_order': strict_order,
@ -49,45 +49,45 @@ def dnsserver_cardupstreamdns_save():
if errors:
for msg in errors:
flash(msg, 'error')
return redirect(_VIEW)
return redirect(f'/{_PAGE}')
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)
return redirect(f'/{_PAGE}')
@bp.route('/action/dnsserver_carddnsforwarding_save', methods=['POST'])
@bp.route('/action/dnsserver/dnsforwarding_save', methods=['POST'])
@require_level('administrator')
def dnsserver_carddnsforwarding_save():
def dnsforwarding_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)
return redirect(f'/{_PAGE}')
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)
return redirect(f'/{_PAGE}')
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)
return redirect(f'/{_PAGE}')
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)
return redirect(f'/{_PAGE}')
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)
return redirect(f'/{_PAGE}')

View file

@ -1,5 +1,4 @@
{
"id": "view_dnsserver",
"client_requirement": "client_is_administrator+",
"items": [
{
@ -22,7 +21,7 @@
"items": [
{
"type": "form",
"action": "/action/dnsserver_cardupstreamdns_save",
"action": "/action/dnsserver/upstreamdns_save",
"method": "post",
"items": [
{
@ -48,7 +47,7 @@
"items": [
{
"type": "button_primary",
"action": "/action/dnsserver_cardupstreamdns_save",
"action": "/action/dnsserver/upstreamdns_save",
"method": "post",
"text": "Save"
},
@ -69,7 +68,7 @@
"items": [
{
"type": "form",
"action": "/action/dnsserver_carddnsforwarding_save",
"action": "/action/dnsserver/dnsforwarding_save",
"method": "post",
"items": [
{
@ -86,7 +85,7 @@
"items": [
{
"type": "button_primary",
"action": "/action/dnsserver_carddnsforwarding_save",
"action": "/action/dnsserver/dnsforwarding_save",
"method": "post",
"text": "Save"
},

View file

@ -1,3 +1,4 @@
from pathlib import Path
import copy
import ipaddress
@ -7,10 +8,9 @@ from config_utils import load_config, save_config_with_snapshot, verify_config_h
import sanitize
import validation as validate
bp = Blueprint('hostoverrides', __name__)
VIEW = '/view/view_hostoverrides'
_PAGE = Path(__file__).parent.name
bp = Blueprint(_PAGE, __name__)
def _vlan_networks(cfg):
nets = []
@ -48,23 +48,23 @@ def _hash_ok():
return True
@bp.route('/action/add_host_override', methods=['POST'])
@bp.route('/action/hostoverrides/addoverride_add', methods=['POST'])
@require_level('administrator')
def add_host_override():
def addoverride_add():
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)
return redirect(f'/{_PAGE}')
if not _hash_ok():
return redirect(VIEW)
return redirect(f'/{_PAGE}')
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)
return redirect(f'/{_PAGE}')
entry = {'description': description, 'host': host, 'ip': ip, 'enabled': True}
cfg.setdefault('host_overrides', []).append(entry)
@ -72,7 +72,7 @@ def add_host_override():
if errors:
for msg in errors:
flash(msg, 'error')
return redirect(VIEW)
return redirect(f'/{_PAGE}')
flash(save_config_with_snapshot(
cfg,
@ -80,24 +80,24 @@ def add_host_override():
before=None, after=entry,
description=f'Added host override: {host}{ip}',
), 'success')
return redirect(VIEW)
return redirect(f'/{_PAGE}')
@bp.route('/action/toggle_host_override', methods=['POST'])
@bp.route('/action/hostoverrides/table_toggle', methods=['POST'])
@require_level('administrator')
def toggle_host_override():
def table_toggle():
idx = _row_index()
if idx is None:
flash('Invalid request.', 'error')
return redirect(VIEW)
return redirect(f'/{_PAGE}')
if not _hash_ok():
return redirect(VIEW)
return redirect(f'/{_PAGE}')
cfg = load_config()
items = cfg.get('host_overrides', [])
if idx < 0 or idx >= len(items):
flash('Entry not found.', 'error')
return redirect(VIEW)
return redirect(f'/{_PAGE}')
old_enabled = items[idx].get('enabled', True)
items[idx]['enabled'] = not old_enabled
@ -105,7 +105,7 @@ def toggle_host_override():
if errors:
for msg in errors:
flash(msg, 'error')
return redirect(VIEW)
return redirect(f'/{_PAGE}')
action = 'Enabled' if not old_enabled else 'Disabled'
flash(save_config_with_snapshot(
@ -114,16 +114,16 @@ def toggle_host_override():
before={'enabled': old_enabled}, after={'enabled': not old_enabled},
description=f'{action} host override: {items[idx]["host"]}',
), 'success')
return redirect(VIEW)
return redirect(f'/{_PAGE}')
@bp.route('/action/edit_host_override', methods=['POST'])
@bp.route('/action/hostoverrides/table_edit', methods=['POST'])
@require_level('administrator')
def edit_host_override():
def table_edit():
idx = _row_index()
if idx is None:
flash('Invalid request.', 'error')
return redirect(VIEW)
return redirect(f'/{_PAGE}')
description = sanitize.text(request.form.get('description', ''))
host = validate.domainname(request.form.get('host', ''))
@ -132,19 +132,19 @@ def edit_host_override():
if not host or not ip:
flash('Hostname and IP address are required.', 'error')
return redirect(VIEW)
return redirect(f'/{_PAGE}')
if not _hash_ok():
return redirect(VIEW)
return redirect(f'/{_PAGE}')
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)
return redirect(f'/{_PAGE}')
items = cfg.get('host_overrides', [])
if idx < 0 or idx >= len(items):
flash('Entry not found.', 'error')
return redirect(VIEW)
return redirect(f'/{_PAGE}')
before = copy.deepcopy(items[idx])
items[idx].update({'description': description, 'host': host, 'ip': ip, 'enabled': enabled})
@ -152,7 +152,7 @@ def edit_host_override():
if errors:
for msg in errors:
flash(msg, 'error')
return redirect(VIEW)
return redirect(f'/{_PAGE}')
flash(save_config_with_snapshot(
cfg,
@ -160,31 +160,31 @@ def edit_host_override():
before=before, after=copy.deepcopy(items[idx]),
description=f'Edited host override: {host}{ip}',
), 'success')
return redirect(VIEW)
return redirect(f'/{_PAGE}')
@bp.route('/action/delete_host_override', methods=['POST'])
@bp.route('/action/hostoverrides/table_delete', methods=['POST'])
@require_level('administrator')
def delete_host_override():
def table_delete():
idx = _row_index()
if idx is None:
flash('Invalid request.', 'error')
return redirect(VIEW)
return redirect(f'/{_PAGE}')
if not _hash_ok():
return redirect(VIEW)
return redirect(f'/{_PAGE}')
cfg = load_config()
items = cfg.get('host_overrides', [])
if idx < 0 or idx >= len(items):
flash('Entry not found.', 'error')
return redirect(VIEW)
return redirect(f'/{_PAGE}')
removed = items.pop(idx)
errors = validate.validate_config(cfg)
if errors:
for msg in errors:
flash(msg, 'error')
return redirect(VIEW)
return redirect(f'/{_PAGE}')
flash(save_config_with_snapshot(
cfg,
@ -192,4 +192,4 @@ def delete_host_override():
before=removed, after=None,
description=f'Deleted host override: {removed["host"]}',
), 'success')
return redirect(VIEW)
return redirect(f'/{_PAGE}')

View file

@ -1,5 +1,4 @@
{
"id": "view_hostoverrides",
"client_requirement": "client_is_viewer+",
"items": [
{
@ -43,7 +42,7 @@
"row_actions": [
{
"client_requirement": "client_is_administrator+",
"action": "/action/edit_host_override",
"action": "/action/hostoverrides/table_edit",
"method": "inline_edit",
"text": "Edit",
"class": "btn-ghost btn-sm",
@ -71,7 +70,7 @@
},
{
"client_requirement": "client_is_administrator+",
"action": "/action/delete_host_override",
"action": "/action/hostoverrides/table_delete",
"method": "post",
"text": "Delete",
"class": "btn-danger btn-sm"
@ -86,7 +85,7 @@
"items": [
{
"type": "form",
"action": "/action/add_host_override",
"action": "/action/hostoverrides/addoverride_add",
"method": "post",
"items": [
{
@ -117,7 +116,7 @@
"items": [
{
"type": "button_primary",
"action": "/action/add_host_override",
"action": "/action/hostoverrides/addoverride_add",
"method": "post",
"text": "Add Host Override"
},

View file

@ -1,3 +1,4 @@
from pathlib import Path
import copy
from flask import Blueprint, request, redirect, flash
@ -6,9 +7,9 @@ from config_utils import load_config, save_config_with_snapshot, verify_config_h
import sanitize
import validation as validate
bp = Blueprint('intervlan', __name__)
_PAGE = Path(__file__).parent.name
VIEW = '/view/view_intervlan'
bp = Blueprint(_PAGE, __name__)
_VALID_PROTOS_STR = ', '.join(sorted(validate.VALID_PROTOCOLS))
@ -77,14 +78,14 @@ def _entry_key(entry):
return f'{entry["protocol"]}:{entry["src_ip_or_subnet"]}{entry["dst_ip_or_subnet"]}{port}'
@bp.route('/action/add_inter_vlan', methods=['POST'])
@bp.route('/action/intervlan/addexception_add', methods=['POST'])
@require_level('administrator')
def add_inter_vlan():
def addexception_add():
entry, err = _parse_entry()
if err:
return redirect(VIEW)
return redirect(f'/{_PAGE}')
if not _hash_ok():
return redirect(VIEW)
return redirect(f'/{_PAGE}')
cfg = load_config()
cfg.setdefault('inter_vlan_exceptions', []).append(entry)
@ -92,7 +93,7 @@ def add_inter_vlan():
if errors:
for msg in errors:
flash(msg, 'error')
return redirect(VIEW)
return redirect(f'/{_PAGE}')
key = _entry_key(entry)
flash(save_config_with_snapshot(
@ -101,24 +102,24 @@ def add_inter_vlan():
before=None, after=entry,
description=f'Added inter-VLAN rule: {key}',
), 'success')
return redirect(VIEW)
return redirect(f'/{_PAGE}')
@bp.route('/action/toggle_inter_vlan', methods=['POST'])
@bp.route('/action/intervlan/table_toggle', methods=['POST'])
@require_level('administrator')
def toggle_inter_vlan():
def table_toggle():
idx = _row_index()
if idx is None:
flash('Invalid request.', 'error')
return redirect(VIEW)
return redirect(f'/{_PAGE}')
if not _hash_ok():
return redirect(VIEW)
return redirect(f'/{_PAGE}')
cfg = load_config()
items = cfg.get('inter_vlan_exceptions', [])
if idx < 0 or idx >= len(items):
flash('Entry not found.', 'error')
return redirect(VIEW)
return redirect(f'/{_PAGE}')
old_enabled = items[idx].get('enabled', True)
items[idx]['enabled'] = not old_enabled
@ -126,7 +127,7 @@ def toggle_inter_vlan():
if errors:
for msg in errors:
flash(msg, 'error')
return redirect(VIEW)
return redirect(f'/{_PAGE}')
key = _entry_key(items[idx])
action = 'Enabled' if not old_enabled else 'Disabled'
@ -136,28 +137,28 @@ def toggle_inter_vlan():
before={'enabled': old_enabled}, after={'enabled': not old_enabled},
description=f'{action} inter-VLAN rule: {key}',
), 'success')
return redirect(VIEW)
return redirect(f'/{_PAGE}')
@bp.route('/action/edit_inter_vlan', methods=['POST'])
@bp.route('/action/intervlan/table_edit', methods=['POST'])
@require_level('administrator')
def edit_inter_vlan():
def table_edit():
idx = _row_index()
if idx is None:
flash('Invalid request.', 'error')
return redirect(VIEW)
return redirect(f'/{_PAGE}')
entry, err = _parse_entry()
if err:
return redirect(VIEW)
return redirect(f'/{_PAGE}')
if not _hash_ok():
return redirect(VIEW)
return redirect(f'/{_PAGE}')
cfg = load_config()
items = cfg.get('inter_vlan_exceptions', [])
if idx < 0 or idx >= len(items):
flash('Entry not found.', 'error')
return redirect(VIEW)
return redirect(f'/{_PAGE}')
before = copy.deepcopy(items[idx])
items[idx] = entry
@ -166,7 +167,7 @@ def edit_inter_vlan():
if errors:
for msg in errors:
flash(msg, 'error')
return redirect(VIEW)
return redirect(f'/{_PAGE}')
key = _entry_key(entry)
flash(save_config_with_snapshot(
@ -175,31 +176,31 @@ def edit_inter_vlan():
before=before, after=copy.deepcopy(items[idx]),
description=f'Edited inter-VLAN rule: {key}',
), 'success')
return redirect(VIEW)
return redirect(f'/{_PAGE}')
@bp.route('/action/delete_inter_vlan', methods=['POST'])
@bp.route('/action/intervlan/table_delete', methods=['POST'])
@require_level('administrator')
def delete_inter_vlan():
def table_delete():
idx = _row_index()
if idx is None:
flash('Invalid request.', 'error')
return redirect(VIEW)
return redirect(f'/{_PAGE}')
if not _hash_ok():
return redirect(VIEW)
return redirect(f'/{_PAGE}')
cfg = load_config()
items = cfg.get('inter_vlan_exceptions', [])
if idx < 0 or idx >= len(items):
flash('Entry not found.', 'error')
return redirect(VIEW)
return redirect(f'/{_PAGE}')
removed = items.pop(idx)
errors = validate.validate_config(cfg)
if errors:
for msg in errors:
flash(msg, 'error')
return redirect(VIEW)
return redirect(f'/{_PAGE}')
key = _entry_key(removed)
flash(save_config_with_snapshot(
@ -208,4 +209,4 @@ def delete_inter_vlan():
before=removed, after=None,
description=f'Deleted inter-VLAN rule: {key}',
), 'success')
return redirect(VIEW)
return redirect(f'/{_PAGE}')

View file

@ -1,5 +1,4 @@
{
"id": "view_intervlan",
"client_requirement": "client_is_viewer+",
"items": [
{
@ -53,7 +52,7 @@
"row_actions": [
{
"client_requirement": "client_is_administrator+",
"action": "/action/edit_inter_vlan",
"action": "/action/intervlan/table_edit",
"method": "inline_edit",
"text": "Edit",
"class": "btn-ghost btn-sm",
@ -88,7 +87,7 @@
},
{
"client_requirement": "client_is_administrator+",
"action": "/action/delete_inter_vlan",
"action": "/action/intervlan/table_delete",
"method": "post",
"text": "Delete",
"class": "btn-danger btn-sm"
@ -103,7 +102,7 @@
"items": [
{
"type": "form",
"action": "/action/add_inter_vlan",
"action": "/action/intervlan/addexception_add",
"method": "post",
"items": [
{
@ -149,7 +148,7 @@
"items": [
{
"type": "button_primary",
"action": "/action/add_inter_vlan",
"action": "/action/intervlan/addexception_add",
"method": "post",
"text": "Add Exception"
},

View file

@ -1,3 +1,4 @@
from pathlib import Path
import copy
from flask import Blueprint, request, redirect, flash
@ -6,17 +7,19 @@ from config_utils import load_config, save_config_with_snapshot, verify_config_h
import sanitize
import validation as validate
bp = Blueprint('mdns', __name__)
_PAGE = Path(__file__).parent.name
bp = Blueprint(_PAGE, __name__)
@bp.route('/action/apply_mdns', methods=['POST'])
@bp.route('/action/mdns/settings_apply', methods=['POST'])
@require_level('administrator')
def apply_mdns():
def settings_apply():
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')
return redirect(f'/{_PAGE}')
cfg = load_config()
mdns_reflect_vlans = sanitize.filterlist(
@ -33,7 +36,7 @@ def apply_mdns():
if errors:
for msg in errors:
flash(msg, 'error')
return redirect('/view/view_mdns')
return redirect(f'/{_PAGE}')
flash(save_config_with_snapshot(
cfg,
@ -41,4 +44,4 @@ def apply_mdns():
before=before or None, after=copy.deepcopy(cfg['mdns_reflection']),
description='Updated mDNS reflection settings',
), 'success')
return redirect('/view/view_mdns')
return redirect(f'/{_PAGE}')

View file

@ -1,3 +1,4 @@
from pathlib import Path
import copy
import ipaddress
@ -7,9 +8,9 @@ from config_utils import load_config, save_config_with_snapshot, verify_config_h
import sanitize
import validation as validate
bp = Blueprint('networklayout', __name__)
_PAGE = Path(__file__).parent.name
VIEW = '/view/view_networklayout'
bp = Blueprint(_PAGE, __name__)
_VLAN_FIELDS = ['name', 'vlan_id', 'is_vpn', 'subnet', 'subnet_mask', 'dnsmasq_log_queries',
'radius_default', 'mdns_reflection', 'use_blocklists']
@ -29,9 +30,9 @@ def _hash_ok():
return True
@bp.route('/action/networklayout_cardaddvlan_addvlan', methods=['POST'])
@bp.route('/action/networklayout/addvlan_add', methods=['POST'])
@require_level('administrator')
def networklayout_cardaddvlan_addvlan():
def addvlan_add():
name = sanitize.name(request.form.get('name', ''))
vlan_id = sanitize.vlan_id(request.form.get('vlan_id', ''))
is_vpn = 'is_vpn' in request.form
@ -47,29 +48,29 @@ def networklayout_cardaddvlan_addvlan():
if not name:
flash('Name is required.', 'error')
return redirect(VIEW)
return redirect(f'/{_PAGE}')
if vlan_id is None:
flash('VLAN ID must be an integer between 1 and 4094.', 'error')
return redirect(VIEW)
return redirect(f'/{_PAGE}')
if not subnet:
flash('Subnet IP is required.', 'error')
return redirect(VIEW)
return redirect(f'/{_PAGE}')
if subnet_mask is None:
flash('Invalid subnet prefix (must be 1-30).', 'error')
return redirect(VIEW)
return redirect(f'/{_PAGE}')
if not _hash_ok():
return redirect(VIEW)
return redirect(f'/{_PAGE}')
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)
return redirect(f'/{_PAGE}')
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)
return redirect(f'/{_PAGE}')
entry = {
'name': name,
@ -91,7 +92,7 @@ def networklayout_cardaddvlan_addvlan():
if errors:
for msg in errors:
flash(msg, 'error')
return redirect(VIEW)
return redirect(f'/{_PAGE}')
flash(save_config_with_snapshot(
cfg,
@ -99,16 +100,16 @@ def networklayout_cardaddvlan_addvlan():
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)
return redirect(f'/{_PAGE}')
@bp.route('/action/networklayout_tablevlans_edit', methods=['POST'])
@bp.route('/action/networklayout/vlans_edit', methods=['POST'])
@require_level('administrator')
def networklayout_tablevlans_edit():
def vlans_edit():
idx = _row_index()
if idx is None:
flash('Invalid request.', 'error')
return redirect(VIEW)
return redirect(f'/{_PAGE}')
name = sanitize.name(request.form.get('name', ''))
vlan_id = sanitize.vlan_id(request.form.get('vlan_id', ''))
@ -126,7 +127,7 @@ def networklayout_tablevlans_edit():
clean = sanitize.ip(raw_ip)
if not clean:
flash(f"'{raw_ip}' is not a valid IP address.", 'error')
return redirect(VIEW)
return redirect(f'/{_PAGE}')
identity_ips.append(clean)
identity_descs = request.form.get('server_identity_descriptions', '').splitlines()
identity_hostnames = request.form.get('server_identity_hostnames', '').splitlines()
@ -136,27 +137,27 @@ def networklayout_tablevlans_edit():
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)
return redirect(f'/{_PAGE}')
else:
subnet_mask = None
if not name:
flash('Name is required.', 'error')
return redirect(VIEW)
return redirect(f'/{_PAGE}')
if vlan_id is None:
flash('VLAN ID must be an integer between 1 and 4094.', 'error')
return redirect(VIEW)
return redirect(f'/{_PAGE}')
if not subnet:
flash('Subnet IP is required.', 'error')
return redirect(VIEW)
return redirect(f'/{_PAGE}')
if not _hash_ok():
return redirect(VIEW)
return redirect(f'/{_PAGE}')
cfg = load_config()
vlans = cfg.get('vlans', [])
if idx < 0 or idx >= len(vlans):
flash('VLAN not found.', 'error')
return redirect(VIEW)
return redirect(f'/{_PAGE}')
existing = vlans[idx]
is_vpn = existing.get('is_vpn', False)
@ -167,20 +168,20 @@ def networklayout_tablevlans_edit():
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)
return redirect(f'/{_PAGE}')
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)
return redirect(f'/{_PAGE}')
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)
return redirect(f'/{_PAGE}')
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)
return redirect(f'/{_PAGE}')
old_identities = existing.get('server_identities', [])
new_identities = []
@ -197,7 +198,7 @@ def networklayout_tablevlans_edit():
clean_hostname = sanitize.hostname(hostname_raw)
if clean_hostname is None:
flash(f"'{hostname_raw}' is not a valid hostname.", 'error')
return redirect(VIEW)
return redirect(f'/{_PAGE}')
entry['hostname'] = clean_hostname
else:
entry.pop('hostname', None)
@ -206,7 +207,7 @@ def networklayout_tablevlans_edit():
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)
return redirect(f'/{_PAGE}')
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 ''
@ -221,17 +222,17 @@ def networklayout_tablevlans_edit():
_clean = sanitize.ip(_line)
if not _clean:
flash(f"'{_line}' is not a valid DNS server IP.", 'error')
return redirect(VIEW)
return redirect(f'/{_PAGE}')
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)
return redirect(f'/{_PAGE}')
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)
return redirect(f'/{_PAGE}')
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 [])
@ -245,17 +246,17 @@ def networklayout_tablevlans_edit():
_clean = sanitize.ip(_line)
if not _clean:
flash(f"'{_line}' is not a valid NTP server IP.", 'error')
return redirect(VIEW)
return redirect(f'/{_PAGE}')
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)
return redirect(f'/{_PAGE}')
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)
return redirect(f'/{_PAGE}')
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 [])
@ -282,7 +283,7 @@ def networklayout_tablevlans_edit():
and new_stored_dns == existing_dns
and new_stored_ntp == existing_ntp):
flash('No changes were made.', 'info')
return redirect(VIEW)
return redirect(f'/{_PAGE}')
before = {k: existing.get(k) for k in _VLAN_FIELDS}
existing.update({
@ -316,7 +317,7 @@ def networklayout_tablevlans_edit():
if errors:
for msg in errors:
flash(msg, 'error')
return redirect(VIEW)
return redirect(f'/{_PAGE}')
flash(save_config_with_snapshot(
cfg,
@ -324,31 +325,31 @@ def networklayout_tablevlans_edit():
before=before, after={k: existing.get(k) for k in _VLAN_FIELDS},
description=f'Edited VLAN: {name}',
), 'success')
return redirect(VIEW)
return redirect(f'/{_PAGE}')
@bp.route('/action/networklayout_tablevlans_delete', methods=['POST'])
@bp.route('/action/networklayout/vlans_delete', methods=['POST'])
@require_level('administrator')
def networklayout_tablevlans_delete():
def vlans_delete():
idx = _row_index()
if idx is None:
flash('Invalid request.', 'error')
return redirect(VIEW)
return redirect(f'/{_PAGE}')
if not _hash_ok():
return redirect(VIEW)
return redirect(f'/{_PAGE}')
cfg = load_config()
vlans = cfg.get('vlans', [])
if idx < 0 or idx >= len(vlans):
flash('VLAN not found.', 'error')
return redirect(VIEW)
return redirect(f'/{_PAGE}')
removed = vlans.pop(idx)
errors = validate.validate_config(cfg)
if errors:
for msg in errors:
flash(msg, 'error')
return redirect(VIEW)
return redirect(f'/{_PAGE}')
flash(save_config_with_snapshot(
cfg,
@ -357,4 +358,4 @@ def networklayout_tablevlans_delete():
after=None,
description=f'Deleted VLAN: {removed["name"]}',
), 'success')
return redirect(VIEW)
return redirect(f'/{_PAGE}')

View file

@ -1,5 +1,4 @@
{
"id": "view_networklayout",
"client_requirement": "client_is_viewer+",
"items": [
{
@ -95,7 +94,7 @@
"row_actions": [
{
"client_requirement": "client_is_administrator+",
"action": "/action/networklayout_tablevlans_edit",
"action": "/action/networklayout/vlans_edit",
"method": "inline_edit",
"text": "Edit",
"class": "btn-ghost btn-sm",
@ -155,7 +154,7 @@
},
{
"client_requirement": "client_is_administrator+",
"action": "/action/networklayout_tablevlans_delete",
"action": "/action/networklayout/vlans_delete",
"method": "post",
"text": "Delete",
"class": "btn-danger btn-sm",
@ -174,7 +173,7 @@
"items": [
{
"type": "form",
"action": "/action/networklayout_cardaddvlan_addvlan",
"action": "/action/networklayout/addvlan_add",
"method": "post",
"items": [
{
@ -263,7 +262,7 @@
"items": [
{
"type": "button_primary",
"action": "/action/networklayout_cardaddvlan_addvlan",
"action": "/action/networklayout/addvlan_add",
"method": "post",
"text": "Add VLAN",
"class": "add-vlan-btn",

View file

@ -1,5 +1,4 @@
{
"id": "view_overview",
"client_requirement": "client_is_nothing+",
"items": [
{
@ -22,7 +21,7 @@
},
{
"type": "button_primary",
"action": "/view/view_login",
"action": "/accountlogin",
"text": "Log In"
}
]

View file

@ -1,3 +1,4 @@
from pathlib import Path
import copy
import os
@ -7,9 +8,9 @@ from config_utils import load_config, save_config_with_snapshot, verify_config_h
import sanitize
import validation as validate
bp = Blueprint('physicalinterfaces', __name__)
_PAGE = Path(__file__).parent.name
_VIEW = '/view/view_physicalinterfaces'
bp = Blueprint(_PAGE, __name__)
_EXCLUDE_PREFIXES = ('lo', 'wg', 'docker', 'br-', 'veth',
'tun', 'tap', 'ppp', 'virbr',
@ -31,29 +32,29 @@ def _valid_interface(name):
return name in _get_system_interfaces()
@bp.route('/action/physicalinterfaces_cardphysicalinterface_save', methods=['POST'])
@bp.route('/action/physicalinterfaces/physicalinterface_save', methods=['POST'])
@require_level('administrator')
def physicalinterfaces_cardphysicalinterface_save():
def physicalinterface_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)
return redirect(f'/{_PAGE}')
if wan == lan:
flash('WAN and LAN interfaces must be different.', 'error')
return redirect(_VIEW)
return redirect(f'/{_PAGE}')
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)
return redirect(f'/{_PAGE}')
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)
return redirect(f'/{_PAGE}')
cfg = load_config()
before = copy.deepcopy(cfg.get('network_interfaces', {}))
@ -64,22 +65,22 @@ def physicalinterfaces_cardphysicalinterface_save():
if errors:
for msg in errors:
flash(msg, 'error')
return redirect(_VIEW)
return redirect(f'/{_PAGE}')
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)
return redirect(f'/{_PAGE}')
@bp.route('/action/physicalinterfaces_cardinterfaceconfiguration_apply', methods=['POST'])
@bp.route('/action/physicalinterfaces/ifaceconfig_apply', methods=['POST'])
@require_level('administrator')
def physicalinterfaces_cardinterfaceconfiguration_apply():
def ifaceconfig_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)
return redirect(f'/{_PAGE}')
iface = sanitize.interface_name(request.form.get('iface', ''))
mtu = request.form.get('mtu', '').strip()
@ -89,27 +90,27 @@ def physicalinterfaces_cardinterfaceconfiguration_apply():
if not iface:
flash('No interface specified.', 'error')
return redirect(_VIEW)
return redirect(f'/{_PAGE}')
if not _valid_interface(iface):
flash(f"Interface '{iface}' does not exist on this system.", 'error')
return redirect(_VIEW)
return redirect(f'/{_PAGE}')
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)
return redirect(f'/{_PAGE}')
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)
return redirect(f'/{_PAGE}')
if not mtu_int and not mac:
flash('No changes specified.', 'error')
return redirect(_VIEW)
return redirect(f'/{_PAGE}')
queued = False
if mtu_int and str(mtu_int) != original_mtu:
@ -121,7 +122,7 @@ def physicalinterfaces_cardinterfaceconfiguration_apply():
if not queued:
flash('No changes detected.', 'info')
return redirect(_VIEW)
return redirect(f'/{_PAGE}')
flash(queued_msg(action_label='Changes queued'), 'success')
return redirect(_VIEW)
return redirect(f'/{_PAGE}')

View file

@ -1,5 +1,4 @@
{
"id": "view_physicalinterfaces",
"client_requirement": "client_is_administrator+",
"items": [
{
@ -22,7 +21,7 @@
"items": [
{
"type": "form",
"action": "/action/physicalinterfaces_cardphysicalinterface_save",
"action": "/action/physicalinterfaces/physicalinterface_save",
"method": "post",
"items": [
{
@ -46,7 +45,7 @@
"items": [
{
"type": "button_primary",
"action": "/action/physicalinterfaces_cardphysicalinterface_save",
"action": "/action/physicalinterfaces/physicalinterface_save",
"method": "post",
"text": "Save"
},
@ -69,7 +68,7 @@
"items": [
{
"type": "form",
"action": "/action/physicalinterfaces_cardinterfaceconfiguration_apply",
"action": "/action/physicalinterfaces/ifaceconfig_apply",
"method": "post",
"items": [
{
@ -142,7 +141,7 @@
"items": [
{
"type": "button_primary",
"action": "/action/physicalinterfaces_cardinterfaceconfiguration_apply",
"action": "/action/physicalinterfaces/ifaceconfig_apply",
"method": "post",
"text": "Apply"
},

View file

@ -1,3 +1,4 @@
from pathlib import Path
import copy
from flask import Blueprint, request, redirect, flash
@ -6,9 +7,9 @@ from config_utils import load_config, save_config_with_snapshot, verify_config_h
import sanitize
import validation as validate
bp = Blueprint('portforwarding', __name__)
_PAGE = Path(__file__).parent.name
VIEW = '/view/view_portforwarding'
bp = Blueprint(_PAGE, __name__)
_VALID_PROTOS_STR = ', '.join(sorted(validate.VALID_PROTOCOLS))
@ -73,14 +74,14 @@ def _parse_entry():
}, None
@bp.route('/action/add_port_forward', methods=['POST'])
@bp.route('/action/portforwarding/addrule_add', methods=['POST'])
@require_level('administrator')
def add_port_forward():
def addrule_add():
entry, err = _parse_entry()
if err:
return redirect(VIEW)
return redirect(f'/{_PAGE}')
if not _hash_ok():
return redirect(VIEW)
return redirect(f'/{_PAGE}')
cfg = load_config()
cfg.setdefault('port_forwarding', []).append(entry)
@ -88,7 +89,7 @@ def add_port_forward():
if errors:
for msg in errors:
flash(msg, 'error')
return redirect(VIEW)
return redirect(f'/{_PAGE}')
key = f'{entry["protocol"]}:{entry["dest_port"]}'
flash(save_config_with_snapshot(
@ -97,24 +98,24 @@ def add_port_forward():
before=None, after=entry,
description=f'Added port forward: {key}{entry["nat_ip"]}:{entry["nat_port"]}',
), 'success')
return redirect(VIEW)
return redirect(f'/{_PAGE}')
@bp.route('/action/toggle_port_forward', methods=['POST'])
@bp.route('/action/portforwarding/rules_toggle', methods=['POST'])
@require_level('administrator')
def toggle_port_forward():
def rules_toggle():
idx = _row_index()
if idx is None:
flash('Invalid request.', 'error')
return redirect(VIEW)
return redirect(f'/{_PAGE}')
if not _hash_ok():
return redirect(VIEW)
return redirect(f'/{_PAGE}')
cfg = load_config()
items = cfg.get('port_forwarding', [])
if idx < 0 or idx >= len(items):
flash('Entry not found.', 'error')
return redirect(VIEW)
return redirect(f'/{_PAGE}')
old_enabled = items[idx].get('enabled', True)
items[idx]['enabled'] = not old_enabled
@ -122,7 +123,7 @@ def toggle_port_forward():
if errors:
for msg in errors:
flash(msg, 'error')
return redirect(VIEW)
return redirect(f'/{_PAGE}')
key = f'{items[idx]["protocol"]}:{items[idx]["dest_port"]}'
action = 'Enabled' if not old_enabled else 'Disabled'
@ -132,28 +133,28 @@ def toggle_port_forward():
before={'enabled': old_enabled}, after={'enabled': not old_enabled},
description=f'{action} port forward: {key}',
), 'success')
return redirect(VIEW)
return redirect(f'/{_PAGE}')
@bp.route('/action/edit_port_forward', methods=['POST'])
@bp.route('/action/portforwarding/rules_edit', methods=['POST'])
@require_level('administrator')
def edit_port_forward():
def rules_edit():
idx = _row_index()
if idx is None:
flash('Invalid request.', 'error')
return redirect(VIEW)
return redirect(f'/{_PAGE}')
entry, err = _parse_entry()
if err:
return redirect(VIEW)
return redirect(f'/{_PAGE}')
if not _hash_ok():
return redirect(VIEW)
return redirect(f'/{_PAGE}')
cfg = load_config()
items = cfg.get('port_forwarding', [])
if idx < 0 or idx >= len(items):
flash('Entry not found.', 'error')
return redirect(VIEW)
return redirect(f'/{_PAGE}')
before = copy.deepcopy(items[idx])
items[idx] = entry
@ -162,7 +163,7 @@ def edit_port_forward():
if errors:
for msg in errors:
flash(msg, 'error')
return redirect(VIEW)
return redirect(f'/{_PAGE}')
key = f'{entry["protocol"]}:{entry["dest_port"]}'
flash(save_config_with_snapshot(
@ -171,31 +172,31 @@ def edit_port_forward():
before=before, after=copy.deepcopy(items[idx]),
description=f'Edited port forward: {key}{entry["nat_ip"]}:{entry["nat_port"]}',
), 'success')
return redirect(VIEW)
return redirect(f'/{_PAGE}')
@bp.route('/action/delete_port_forward', methods=['POST'])
@bp.route('/action/portforwarding/rules_delete', methods=['POST'])
@require_level('administrator')
def delete_port_forward():
def rules_delete():
idx = _row_index()
if idx is None:
flash('Invalid request.', 'error')
return redirect(VIEW)
return redirect(f'/{_PAGE}')
if not _hash_ok():
return redirect(VIEW)
return redirect(f'/{_PAGE}')
cfg = load_config()
items = cfg.get('port_forwarding', [])
if idx < 0 or idx >= len(items):
flash('Entry not found.', 'error')
return redirect(VIEW)
return redirect(f'/{_PAGE}')
removed = items.pop(idx)
errors = validate.validate_config(cfg)
if errors:
for msg in errors:
flash(msg, 'error')
return redirect(VIEW)
return redirect(f'/{_PAGE}')
key = f'{removed["protocol"]}:{removed["dest_port"]}'
flash(save_config_with_snapshot(
@ -204,4 +205,4 @@ def delete_port_forward():
before=removed, after=None,
description=f'Deleted port forward: {key}',
), 'success')
return redirect(VIEW)
return redirect(f'/{_PAGE}')

View file

@ -1,5 +1,4 @@
{
"id": "view_portforwarding",
"client_requirement": "client_is_viewer+",
"items": [
{
@ -53,7 +52,7 @@
"row_actions": [
{
"client_requirement": "client_is_administrator+",
"action": "/action/edit_port_forward",
"action": "/action/portforwarding/rules_edit",
"method": "inline_edit",
"text": "Edit",
"class": "btn-ghost btn-sm",
@ -88,7 +87,7 @@
},
{
"client_requirement": "client_is_administrator+",
"action": "/action/delete_port_forward",
"action": "/action/portforwarding/rules_delete",
"method": "post",
"text": "Delete",
"class": "btn-danger btn-sm"
@ -103,7 +102,7 @@
"items": [
{
"type": "form",
"action": "/action/add_port_forward",
"action": "/action/portforwarding/addrule_add",
"method": "post",
"items": [
{
@ -149,7 +148,7 @@
"items": [
{
"type": "button_primary",
"action": "/action/add_port_forward",
"action": "/action/portforwarding/addrule_add",
"method": "post",
"text": "Add Rule"
},

View file

@ -1,10 +1,13 @@
from pathlib import Path
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__)
_PAGE = Path(__file__).parent.name
bp = Blueprint(_PAGE, __name__)
@ -20,14 +23,14 @@ def _save_accounts(data):
json.dump(data, f, indent=2)
@bp.route('/action/save_preferences', methods=['POST'])
@bp.route('/action/preferences/accountdetails_save', methods=['POST'])
@require_level('viewer')
def save_preferences():
def accountdetails_save():
tz = sanitize.timezone(request.form.get('timezone', '').strip())
if not tz:
flash('Timezone is required.', 'error')
return redirect('/view/view_preferences')
return redirect(f'/{_PAGE}')
email = session.get('email_address', '').lower()
data = _load_accounts()
@ -36,7 +39,7 @@ def save_preferences():
if account is None:
flash('Account not found. Please log in again.', 'error')
return redirect('/view/view_login')
return redirect('/accountlogin')
account['timezone'] = tz
_save_accounts(data)
@ -44,27 +47,27 @@ def save_preferences():
session['timezone'] = tz
flash('Preferences saved.', 'success')
return redirect('/view/view_preferences')
return redirect(f'/{_PAGE}')
@bp.route('/action/change_password', methods=['POST'])
@bp.route('/action/preferences/changepassword_save', methods=['POST'])
@require_level('viewer')
def change_password():
def changepassword_save():
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')
return redirect(f'/{_PAGE}')
if new_password != confirm_password:
flash('New passwords do not match.', 'error')
return redirect('/view/view_preferences')
return redirect(f'/{_PAGE}')
if len(new_password) < 8:
flash('New password must be at least 8 characters.', 'error')
return redirect('/view/view_preferences')
return redirect(f'/{_PAGE}')
email = session.get('email_address', '').lower()
data = _load_accounts()
@ -73,12 +76,12 @@ def change_password():
if account is None:
flash('Account not found. Please log in again.', 'error')
return redirect('/view/view_login')
return redirect('/accountlogin')
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')
return redirect(f'/{_PAGE}')
salt = bcrypt.gensalt()
hashed = bcrypt.hashpw(new_password.encode('utf-8'), salt)
@ -88,4 +91,4 @@ def change_password():
_save_accounts(data)
flash('Password changed successfully.', 'success')
return redirect('/view/view_preferences')
return redirect(f'/{_PAGE}')

View file

@ -1,5 +1,4 @@
{
"id": "view_preferences",
"client_requirement": "client_is_viewer+",
"items": [
{
@ -21,7 +20,7 @@
"items": [
{
"type": "form",
"action": "/action/save_preferences",
"action": "/action/preferences/accountdetails_save",
"method": "post",
"items": [
{
@ -46,7 +45,7 @@
"items": [
{
"type": "button_primary",
"action": "/action/save_preferences",
"action": "/action/preferences/accountdetails_save",
"method": "post",
"text": "Save Preferences"
}
@ -62,7 +61,7 @@
"items": [
{
"type": "form",
"action": "/action/change_password",
"action": "/action/preferences/changepassword_save",
"method": "post",
"items": [
{
@ -91,7 +90,7 @@
"items": [
{
"type": "button_primary",
"action": "/action/change_password",
"action": "/action/preferences/changepassword_save",
"method": "post",
"text": "Change Password"
}

View file

@ -1,3 +1,4 @@
from pathlib import Path
import base64
import copy
import ipaddress
@ -9,9 +10,10 @@ from config_utils import load_config, save_config_with_snapshot, verify_config_h
import sanitize
import validation as validate
bp = Blueprint('vpn', __name__)
_PAGE = Path(__file__).parent.name
bp = Blueprint(_PAGE, __name__)
_VIEW = '/view/view_vpn'
_MTU_MIN = 576
_MTU_MAX = 9000
@ -121,7 +123,7 @@ def _conf_response(vlan, peer_name, peer_ip, private_key):
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)
return redirect(f'/{_PAGE}')
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)
@ -130,9 +132,9 @@ def _conf_response(vlan, peer_name, peer_ip, private_key):
return resp
@bp.route('/action/apply_vpn', methods=['POST'])
@bp.route('/action/vpn/wireguard_apply', methods=['POST'])
@require_level('administrator')
def apply_vpn():
def wireguard_apply():
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', ''))
@ -141,39 +143,39 @@ def apply_vpn():
if not listen_port_raw:
flash('Listen port is required.', 'error')
return redirect(_VIEW)
return redirect(f'/{_PAGE}')
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)
return redirect(f'/{_PAGE}')
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)
return redirect(f'/{_PAGE}')
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)
return redirect(f'/{_PAGE}')
if not _hash_ok():
return redirect(_VIEW)
return redirect(f'/{_PAGE}')
cfg = load_config()
vpn_vlan = _wg_vlan(cfg)
if vpn_vlan is None:
flash('No WireGuard VLAN found in configuration.', 'error')
return redirect(_VIEW)
return redirect(f'/{_PAGE}')
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)
return redirect(f'/{_PAGE}')
before_info = copy.deepcopy(vpn_vlan.get('vpn_information', {}))
info = vpn_vlan.setdefault('vpn_information', {})
@ -195,7 +197,7 @@ def apply_vpn():
if errors:
for msg in errors:
flash(msg, 'error')
return redirect(_VIEW)
return redirect(f'/{_PAGE}')
vlan_name = vpn_vlan['name']
flash(save_config_with_snapshot(
@ -204,12 +206,12 @@ def apply_vpn():
before=before_info or None, after=copy.deepcopy(info),
description=f'Updated VPN configuration for {vlan_name}',
), 'success')
return redirect(_VIEW)
return redirect(f'/{_PAGE}')
@bp.route('/action/add_vpn_peer', methods=['POST'])
@bp.route('/action/vpn/addpeer_add', methods=['POST'])
@require_level('administrator')
def add_vpn_peer():
def addpeer_add():
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()
@ -218,41 +220,41 @@ def add_vpn_peer():
if not peer_name:
flash('Peer name is required.', 'error')
return redirect(_VIEW)
return redirect(f'/{_PAGE}')
if not peer_vlan_nm:
flash('Assigned VLAN is required.', 'error')
return redirect(_VIEW)
return redirect(f'/{_PAGE}')
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)
return redirect(f'/{_PAGE}')
if not _hash_ok():
return redirect(_VIEW)
return redirect(f'/{_PAGE}')
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)
return redirect(f'/{_PAGE}')
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)
return redirect(f'/{_PAGE}')
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)
return redirect(f'/{_PAGE}')
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)
return redirect(f'/{_PAGE}')
private_key, public_key = _generate_wg_keypair()
entry = {
@ -267,7 +269,7 @@ def add_vpn_peer():
if errors:
for msg in errors:
flash(msg, 'error')
return redirect(_VIEW)
return redirect(f'/{_PAGE}')
save_config_with_snapshot(
cfg,
@ -279,13 +281,13 @@ def add_vpn_peer():
return _conf_response(vpn_vlan, peer_name, peer_ip, private_key)
@bp.route('/action/edit_vpn_peer', methods=['POST'])
@bp.route('/action/vpn/peers_edit', methods=['POST'])
@require_level('administrator')
def edit_vpn_peer():
def peers_edit():
flat_idx = _row_index()
if flat_idx is None:
flash('Invalid request.', 'error')
return redirect(_VIEW)
return redirect(f'/{_PAGE}')
peer_name = sanitize.name(request.form.get('name', ''))
split_tunnel = request.form.get('split_tunnel') in ('true', '1', 'on', 'yes')
@ -293,20 +295,20 @@ def edit_vpn_peer():
if not peer_name:
flash('Peer name is required.', 'error')
return redirect(_VIEW)
return redirect(f'/{_PAGE}')
if not _hash_ok():
return redirect(_VIEW)
return redirect(f'/{_PAGE}')
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)
return redirect(f'/{_PAGE}')
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)
return redirect(f'/{_PAGE}')
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})
@ -314,7 +316,7 @@ def edit_vpn_peer():
if errors:
for msg in errors:
flash(msg, 'error')
return redirect(_VIEW)
return redirect(f'/{_PAGE}')
vlan_name = vlan['name']
flash(save_config_with_snapshot(
@ -323,24 +325,24 @@ def edit_vpn_peer():
before=before, after={'name': peer_name, 'split_tunnel': split_tunnel, 'enabled': enabled},
description=f'Edited VPN peer: {peer_name}',
), 'success')
return redirect(_VIEW)
return redirect(f'/{_PAGE}')
@bp.route('/action/toggle_vpn_peer', methods=['POST'])
@bp.route('/action/vpn/peers_toggle', methods=['POST'])
@require_level('administrator')
def toggle_vpn_peer():
def peers_toggle():
flat_idx = _row_index()
if flat_idx is None:
flash('Invalid request.', 'error')
return redirect(_VIEW)
return redirect(f'/{_PAGE}')
if not _hash_ok():
return redirect(_VIEW)
return redirect(f'/{_PAGE}')
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)
return redirect(f'/{_PAGE}')
peers = vlan.get('peers', [])
old_enabled = peers[peer_idx].get('enabled', True)
@ -349,7 +351,7 @@ def toggle_vpn_peer():
if errors:
for msg in errors:
flash(msg, 'error')
return redirect(_VIEW)
return redirect(f'/{_PAGE}')
peer_name = peers[peer_idx]['name']
vlan_name = vlan['name']
@ -360,24 +362,24 @@ def toggle_vpn_peer():
before={'enabled': old_enabled}, after={'enabled': not old_enabled},
description=f'{action} VPN peer: {peer_name}',
), 'success')
return redirect(_VIEW)
return redirect(f'/{_PAGE}')
@bp.route('/action/delete_vpn_peer', methods=['POST'])
@bp.route('/action/vpn/peers_delete', methods=['POST'])
@require_level('administrator')
def delete_vpn_peer():
def peers_delete():
flat_idx = _row_index()
if flat_idx is None:
flash('Invalid request.', 'error')
return redirect(_VIEW)
return redirect(f'/{_PAGE}')
if not _hash_ok():
return redirect(_VIEW)
return redirect(f'/{_PAGE}')
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)
return redirect(f'/{_PAGE}')
peers = vlan.get('peers', [])
removed = peers.pop(peer_idx)
@ -385,7 +387,7 @@ def delete_vpn_peer():
if errors:
for msg in errors:
flash(msg, 'error')
return redirect(_VIEW)
return redirect(f'/{_PAGE}')
vlan_name = vlan['name']
flash(save_config_with_snapshot(
@ -395,24 +397,24 @@ def delete_vpn_peer():
after=None,
description=f'Deleted VPN peer: {removed["name"]}',
), 'success')
return redirect(_VIEW)
return redirect(f'/{_PAGE}')
@bp.route('/action/regenerate_vpn_peer', methods=['POST'])
@bp.route('/action/vpn/peers_regenerate', methods=['POST'])
@require_level('administrator')
def regenerate_vpn_peer():
def peers_regenerate():
flat_idx = _row_index()
if flat_idx is None:
flash('Invalid request.', 'error')
return redirect(_VIEW)
return redirect(f'/{_PAGE}')
if not _hash_ok():
return redirect(_VIEW)
return redirect(f'/{_PAGE}')
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)
return redirect(f'/{_PAGE}')
private_key, public_key = _generate_wg_keypair()
peer = vlan['peers'][peer_idx]
@ -422,7 +424,7 @@ def regenerate_vpn_peer():
if errors:
for msg in errors:
flash(msg, 'error')
return redirect(_VIEW)
return redirect(f'/{_PAGE}')
vlan_name = vlan['name']
save_config_with_snapshot(

View file

@ -1,5 +1,4 @@
{
"id": "view_vpn",
"client_requirement": "client_is_viewer+",
"items": [
{
@ -89,7 +88,7 @@
"row_actions": [
{
"client_requirement": "client_is_administrator+",
"action": "/action/edit_vpn_peer",
"action": "/action/vpn/peers_edit",
"method": "inline_edit",
"text": "Edit",
"class": "btn-ghost btn-sm",
@ -113,14 +112,14 @@
},
{
"client_requirement": "client_is_administrator+",
"action": "/action/regenerate_vpn_peer",
"action": "/action/vpn/peers_regenerate",
"method": "post",
"text": "Regen Conf",
"class": "btn-ghost btn-sm"
},
{
"client_requirement": "client_is_administrator+",
"action": "/action/delete_vpn_peer",
"action": "/action/vpn/peers_delete",
"method": "post",
"text": "Delete",
"class": "btn-danger btn-sm"
@ -134,7 +133,7 @@
"items": [
{
"type": "form",
"action": "/action/add_vpn_peer",
"action": "/action/vpn/addpeer_add",
"method": "post",
"items": [
{
@ -181,7 +180,7 @@
"items": [
{
"type": "button_primary",
"action": "/action/add_vpn_peer",
"action": "/action/vpn/addpeer_add",
"method": "post",
"text": "Add Peer & Download Conf"
},
@ -202,7 +201,7 @@
"items": [
{
"type": "form",
"action": "/action/apply_vpn",
"action": "/action/vpn/wireguard_apply",
"method": "post",
"items": [
{
@ -259,7 +258,7 @@
"items": [
{
"type": "button_primary",
"action": "/action/apply_vpn",
"action": "/action/vpn/wireguard_apply",
"method": "post",
"text": "Save"
},

View file

@ -66,23 +66,6 @@ def _load_icon(name):
return ''
def _build_view_map():
m = {}
if not _os.path.isdir(_PAGES_DIR):
return m
for name in _os.listdir(_PAGES_DIR):
cpath = _os.path.join(_PAGES_DIR, name, 'content.json')
if _os.path.isfile(cpath):
try:
with open(cpath) as f:
d = json.load(f)
vid = d.get('id')
if vid:
m[vid] = name
except Exception:
pass
return m
_VIEW_MAP = _build_view_map()
# Shell helper ======================================================
@ -2040,7 +2023,7 @@ def render_layout(view_id, content_html, tokens):
else 'Fix pending. The processing service is not running.')
else:
fix_suffix = ('Fix pending. Click <strong>Apply Now</strong> below to fix.'
if view_id == 'view_actions' else
if view_id == 'actions' else
'Fix pending. Visit the <strong>Actions</strong> page ASAP to apply fix.')
for sev, items in grouped.items():
if not items:
@ -2062,7 +2045,7 @@ def render_layout(view_id, content_html, tokens):
pass
pending_bar = ''
if has_pending_alert and not problem_bars and view_id != 'view_actions':
if has_pending_alert and not problem_bars and view_id != 'actions':
pending_bar = (
'<div class="info-bar info-bar-warning">'
'<span>You have actions pending. Please visit the <strong>Actions</strong> page.</span>'
@ -2114,7 +2097,7 @@ def _render_nav_item(item, active_view, level, in_dropdown=False, inherited_req=
map_to = item.get('map_to', '')
action = item.get('action', '')
is_active = ' active' if map_to and map_to == active_view else ''
pending = ' nav-item-pending' if pending_alert and map_to == 'view_actions' else ''
pending = ' nav-item-pending' if pending_alert and map_to == 'actions' else ''
cls = f'dropdown-item{is_active}' if in_dropdown else f'nav-item{is_active}{pending}'
if action:
return (
@ -2122,7 +2105,7 @@ def _render_nav_item(item, active_view, level, in_dropdown=False, inherited_req=
f'<button type="submit" class="{cls}">{label}</button></form>'
)
if map_to:
return f'<a href="/view/{e(map_to)}" class="{cls}">{label}</a>'
return f'<a href="/{e(map_to)}" class="{cls}">{label}</a>'
return f'<span class="{cls}">{label}</span>'
if t == 'nav_menu':
@ -2161,15 +2144,14 @@ def _inline_js():
@bp.route('/')
def index():
return _serve_view('view_overview')
return _serve_view('overview')
@bp.route('/view/<view_id>')
def view(view_id):
return _serve_view(view_id)
@bp.route('/<page_name>')
def view(page_name):
return _serve_view(page_name)
def _serve_view(view_id):
page_name = _VIEW_MAP.get(view_id)
view_def = _load_json(_os.path.join(_PAGES_DIR, page_name, 'content.json')) if page_name else None
def _serve_view(page_name):
view_def = _load_json(_os.path.join(_PAGES_DIR, page_name, 'content.json'))
if view_def is None:
from flask import abort
@ -2178,7 +2160,7 @@ def _serve_view(view_id):
view_req = view_def.get('client_requirement')
level = _client_level()
if not _passes(view_req, level):
return redirect('/view/view_overview' if level > 0 else '/view/view_login')
return redirect('/overview' if level > 0 else '/accountlogin')
tokens = collect_tokens()
@ -2189,4 +2171,4 @@ def _serve_view(view_id):
flash_html += f'<div class="info-bar info-bar-{variant} info-bar-flash"><span>{msg_html}</span></div>'
content_html = flash_html + render_items(view_def.get('items', []), tokens, view_req)
return render_layout(view_id, content_html, tokens)
return render_layout(page_name, content_html, tokens)