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 = Blueprint('accountlogout', __name__)
@bp.route('/action/log_out', methods=['POST']) @bp.route('/action/accountlogout/logout', methods=['POST'])
@require_level('viewer') @require_level('viewer')
def log_out(): def logout():
session.clear() 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 LEVEL_RANK.get(current, 0) < LEVEL_RANK.get(minimum, 0):
if current == 'nothing': if current == 'nothing':
flash('Please log in to continue.', 'error') flash('Please log in to continue.', 'error')
return redirect('/view/view_login') return redirect('/accountlogin')
flash('You do not have permission to perform this action.', 'error') flash('You do not have permission to perform this action.', 'error')
return redirect('/view/view_overview') return redirect('/overview')
return f(*args, **kwargs) return f(*args, **kwargs)
return wrapper return wrapper
return decorator return decorator

View file

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

View file

@ -3,7 +3,7 @@
{ {
"type": "nav_item", "type": "nav_item",
"label": "Overview", "label": "Overview",
"map_to": "view_overview", "map_to": "overview",
"client_requirement": "client_is_nothing+" "client_requirement": "client_is_nothing+"
}, },
{ {
@ -11,23 +11,23 @@
"label": "%MENU_LABEL%", "label": "%MENU_LABEL%",
"client_requirement": "client_is_viewer+", "client_requirement": "client_is_viewer+",
"items": [ "items": [
{ "type": "nav_item", "label": "Physical Interfaces", "map_to": "view_physicalinterfaces", "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": "view_dnsserver", "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": "view_dnsblocking", "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": "view_networklayout", "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": "view_intervlan", "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": "view_portforwarding", "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": "view_dhcp" }, { "type": "nav_item", "label": "DHCP", "map_to": "dhcp" },
{ "type": "nav_item", "label": "DDNS", "map_to": "view_ddns" }, { "type": "nav_item", "label": "DDNS", "map_to": "ddns" },
{ "type": "nav_item", "label": "Host Overrides", "map_to": "view_hostoverrides", "client_requirement": "client_is_administrator+" }, { "type": "nav_item", "label": "Host Overrides", "map_to": "hostoverrides", "client_requirement": "client_is_administrator+" },
{ "type": "nav_item", "label": "VPN", "map_to": "view_vpn" }, { "type": "nav_item", "label": "VPN", "map_to": "vpn" },
{ "type": "nav_item", "label": "Banned IPs", "map_to": "view_bannedips", "client_requirement": "client_is_administrator+" } { "type": "nav_item", "label": "Banned IPs", "map_to": "bannedips", "client_requirement": "client_is_administrator+" }
] ]
}, },
{ {
"type": "nav_item", "type": "nav_item",
"label": "Actions", "label": "Actions",
"map_to": "view_actions", "map_to": "actions",
"client_requirement": "client_is_administrator+" "client_requirement": "client_is_administrator+"
}, },
{ {
@ -36,22 +36,22 @@
"align": "right", "align": "right",
"client_requirement": "client_is_viewer+", "client_requirement": "client_is_viewer+",
"items": [ "items": [
{ "type": "nav_item", "label": "Preferences", "map_to": "view_preferences" }, { "type": "nav_item", "label": "Preferences", "map_to": "preferences" },
{ "type": "nav_item", "label": "Manage Accounts", "map_to": "view_manageaccounts", "client_requirement": "client_is_manager+" }, { "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_action", "label": "Log Out", "action": "log_out" }
] ]
}, },
{ {
"type": "nav_item", "type": "nav_item",
"label": "Log In", "label": "Log In",
"map_to": "view_login", "map_to": "accountlogin",
"align": "right", "align": "right",
"client_requirement": "client_is_nothing=" "client_requirement": "client_is_nothing="
}, },
{ {
"type": "nav_item", "type": "nav_item",
"label": "Create Account", "label": "Create Account",
"map_to": "view_createaccount", "map_to": "accountcreate",
"align": "right", "align": "right",
"client_requirement": "client_is_nothing=" "client_requirement": "client_is_nothing="
} }

View file

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

View file

@ -1,5 +1,4 @@
{ {
"id": "view_createaccount",
"client_requirement": "client_is_nothing=", "client_requirement": "client_is_nothing=",
"items": [ "items": [
{ {
@ -22,7 +21,7 @@
}, },
{ {
"type": "form", "type": "form",
"action": "/action/create_account", "action": "/action/accountcreate/form_create",
"method": "post", "method": "post",
"items": [ "items": [
{ {
@ -58,7 +57,7 @@
}, },
{ {
"type": "button_primary", "type": "button_primary",
"action": "/action/create_account", "action": "/action/accountcreate/form_create",
"method": "post", "method": "post",
"text": "Create Account", "text": "Create Account",
"class": "btn-full" "class": "btn-full"
@ -69,7 +68,7 @@
"type": "p", "type": "p",
"text": "Already have an account?", "text": "Already have an account?",
"link": { "link": {
"action": "/view/view_login", "action": "/accountlogin",
"text": "Log In" "text": "Log In"
} }
} }
@ -94,7 +93,7 @@
}, },
{ {
"type": "button_primary", "type": "button_primary",
"action": "/view/overview", "action": "/overview",
"text": "Go to 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 from flask import Blueprint, request, session, redirect, flash
import json, bcrypt import json, bcrypt
from auth import require_level from auth import require_level
from config_utils import ACCOUNTS_FILE from config_utils import ACCOUNTS_FILE
import sanitize import sanitize
bp = Blueprint('accountlogin', __name__) _PAGE = Path(__file__).parent.name
bp = Blueprint(_PAGE, __name__)
@ -16,35 +19,35 @@ def _load_accounts():
return {'accounts': []} return {'accounts': []}
@bp.route('/action/log_in', methods=['POST']) @bp.route('/action/accountlogin/form_login', methods=['POST'])
@require_level('nothing') @require_level('nothing')
def log_in(): def form_login():
# Abort if already logged in # Abort if already logged in
if session.get('access_level', 'nothing') != 'nothing': if session.get('access_level', 'nothing') != 'nothing':
return redirect('/view/view_overview') return redirect('/overview')
email = sanitize.email(request.form.get('email', '')) email = sanitize.email(request.form.get('email', ''))
password = request.form.get('password', '') password = request.form.get('password', '')
if not email or not password: if not email or not password:
flash('Email address and password are required.', 'error') flash('Email address and password are required.', 'error')
return redirect('/view/view_login') return redirect(f'/{_PAGE}')
accounts = _load_accounts().get('accounts', []) accounts = _load_accounts().get('accounts', [])
account = next((a for a in accounts if a.get('email_address', '').lower() == email), None) account = next((a for a in accounts if a.get('email_address', '').lower() == email), None)
if account is None: if account is None:
flash('Email address not recognised.', 'error') flash('Email address not recognised.', 'error')
return redirect('/view/view_login') return redirect(f'/{_PAGE}')
if not account.get('hashed_password'): if not account.get('hashed_password'):
flash('Account setup is not complete. Please use Create Account to set your password first.', 'error') flash('Account setup is not complete. Please use Create Account to set your password first.', 'error')
return redirect('/view/view_login') return redirect(f'/{_PAGE}')
stored_hash = account['hashed_password'].encode('utf-8') stored_hash = account['hashed_password'].encode('utf-8')
if not bcrypt.checkpw(password.encode('utf-8'), stored_hash): if not bcrypt.checkpw(password.encode('utf-8'), stored_hash):
flash('Invalid email address or password.', 'error') flash('Invalid email address or password.', 'error')
return redirect('/view/view_login') return redirect(f'/{_PAGE}')
session.clear() session.clear()
session['email_address'] = account['email_address'] session['email_address'] = account['email_address']
@ -53,4 +56,4 @@ def log_in():
session['apply_changes_immediately'] = False session['apply_changes_immediately'] = False
session.permanent = True 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=", "client_requirement": "client_is_nothing=",
"items": [ "items": [
{ {
@ -22,7 +21,7 @@
}, },
{ {
"type": "form", "type": "form",
"action": "/action/log_in", "action": "/action/accountlogin/form_login",
"method": "post", "method": "post",
"items": [ "items": [
{ {
@ -41,7 +40,7 @@
}, },
{ {
"type": "button_primary", "type": "button_primary",
"action": "/action/log_in", "action": "/action/accountlogin/form_login",
"method": "post", "method": "post",
"text": "Log In", "text": "Log In",
"class": "btn-full" "class": "btn-full"
@ -52,7 +51,7 @@
"type": "p", "type": "p",
"text": "Need to complete your account?", "text": "Need to complete your account?",
"link": { "link": {
"action": "/view/view_createaccount", "action": "/accountcreate",
"text": "Create Account" "text": "Create Account"
} }
} }
@ -77,7 +76,7 @@
}, },
{ {
"type": "button_primary", "type": "button_primary",
"action": "/view/overview", "action": "/overview",
"text": "Go to Overview" "text": "Go to Overview"
} }
] ]

View file

@ -1,3 +1,4 @@
from pathlib import Path
from flask import Blueprint, request, session, redirect, flash from flask import Blueprint, request, session, redirect, flash
import json, re import json, re
from datetime import datetime, timezone from datetime import datetime, timezone
@ -5,8 +6,9 @@ from auth import require_level
from config_utils import ACCOUNTS_FILE from config_utils import ACCOUNTS_FILE
import sanitize import sanitize
bp = Blueprint('accountadd', __name__) _PAGE = Path(__file__).parent.name
bp = Blueprint(_PAGE, __name__)
VALID_LEVELS = {'viewer', 'administrator', 'manager'} VALID_LEVELS = {'viewer', 'administrator', 'manager'}
@ -23,30 +25,30 @@ def _save_accounts(data):
json.dump(data, f, indent=2) json.dump(data, f, indent=2)
@bp.route('/action/add_account', methods=['POST']) @bp.route('/action/accountmanage/accounts_add', methods=['POST'])
@require_level('manager') @require_level('manager')
def add_account(): def accounts_add():
email = sanitize.email(request.form.get('email_address', '')) email = sanitize.email(request.form.get('email_address', ''))
access_level = request.form.get('access_level', '').strip() access_level = request.form.get('access_level', '').strip()
if not email: if not email:
flash('Email address is required.', 'error') flash('Email address is required.', 'error')
return redirect('/view/view_manageaccounts') return redirect(f'/{_PAGE}')
if not re.match(r'^[^@\s]+@[^@\s]+\.[^@\s]+$', email): if not re.match(r'^[^@\s]+@[^@\s]+\.[^@\s]+$', email):
flash('Email address does not appear to be valid.', 'error') flash('Email address does not appear to be valid.', 'error')
return redirect('/view/view_manageaccounts') return redirect(f'/{_PAGE}')
if access_level not in VALID_LEVELS: if access_level not in VALID_LEVELS:
flash('Invalid access level.', 'error') flash('Invalid access level.', 'error')
return redirect('/view/view_manageaccounts') return redirect(f'/{_PAGE}')
data = _load_accounts() data = _load_accounts()
accounts = data.get('accounts', []) accounts = data.get('accounts', [])
if any(a.get('email_address', '').lower() == email for a in accounts): if any(a.get('email_address', '').lower() == email for a in accounts):
flash('An account with that email address already exists.', 'error') flash('An account with that email address already exists.', 'error')
return redirect('/view/view_manageaccounts') return redirect(f'/{_PAGE}')
now = datetime.now(tz=timezone.utc).strftime('%Y-%m-%dT%H:%M:%SZ') now = datetime.now(tz=timezone.utc).strftime('%Y-%m-%dT%H:%M:%SZ')
accounts.append({ accounts.append({
@ -61,4 +63,35 @@ def add_account():
_save_accounts(data) _save_accounts(data)
flash(f'Authorization added for {email}. User must complete account setup via the Create Account page.', 'success') flash(f'Authorization added for {email}. User must complete account setup via the Create Account page.', 'success')
return redirect('/view/view_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+", "client_requirement": "client_is_manager+",
"items": [ "items": [
{ {
@ -40,7 +39,7 @@
], ],
"row_actions": [ "row_actions": [
{ {
"action": "/action/delete_account", "action": "/action/accountmanage/accounts_delete",
"method": "post", "method": "post",
"text": "Remove", "text": "Remove",
"class": "btn-danger btn-sm" "class": "btn-danger btn-sm"
@ -53,7 +52,7 @@
"items": [ "items": [
{ {
"type": "form", "type": "form",
"action": "/action/add_account", "action": "/action/accountmanage/accounts_add",
"method": "post", "method": "post",
"items": [ "items": [
{ {
@ -76,7 +75,7 @@
"items": [ "items": [
{ {
"type": "button_primary", "type": "button_primary",
"action": "/action/add_account", "action": "/action/accountmanage/accounts_add",
"method": "post", "method": "post",
"text": "Authorize" "text": "Authorize"
} }

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,3 +1,4 @@
from pathlib import Path
import copy import copy
import os import os
from flask import Blueprint, request, redirect, flash, send_file, abort 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 sanitize
import validation as validate 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' 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') @require_level('administrator')
def ddns_cardaddaccount_add(): def addaccount_add():
provider_type = sanitize.filtervalue(request.form.get('provider', ''), validate.VALID_DDNS_PROVIDERS) provider_type = sanitize.filtervalue(request.form.get('provider', ''), validate.VALID_DDNS_PROVIDERS)
description = sanitize.description(request.form.get('description', '')) description = sanitize.description(request.form.get('description', ''))
hostnames = sanitize.domainlist(request.form.get('hostnames', '').splitlines()) hostnames = sanitize.domainlist(request.form.get('hostnames', '').splitlines())
if not description: if not description:
flash('Description is required.', 'error') flash('Description is required.', 'error')
return redirect(VIEW) return redirect(f'/{_PAGE}')
if not hostnames: if not hostnames:
flash('At least one hostname is required.', 'error') flash('At least one hostname is required.', 'error')
return redirect(VIEW) return redirect(f'/{_PAGE}')
if not provider_type: if not provider_type:
flash('Unknown provider type.', 'error') flash('Unknown provider type.', 'error')
return redirect(VIEW) return redirect(f'/{_PAGE}')
if not verify_config_hash(request.form.get('config_hash', '')): if not verify_config_hash(request.form.get('config_hash', '')):
flash('Configuration was modified by another session. Please refresh and try again.', 'error') flash('Configuration was modified by another session. Please refresh and try again.', 'error')
return redirect(VIEW) return redirect(f'/{_PAGE}')
entry = { entry = {
'description': description, 'description': description,
@ -54,17 +56,17 @@ def ddns_cardaddaccount_add():
cmd='ddns update', cmd='ddns update',
queue=False, queue=False,
), 'success') ), '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') @require_level('administrator')
def ddns_tableaccounts_rowedit(): def accounts_edit():
try: try:
row_index = int(request.form.get('row_index', -1)) row_index = int(request.form.get('row_index', -1))
except (TypeError, ValueError): except (TypeError, ValueError):
flash('Invalid row index.', 'error') flash('Invalid row index.', 'error')
return redirect(VIEW) return redirect(f'/{_PAGE}')
provider_type = sanitize.filtervalue(request.form.get('provider', ''), validate.VALID_DDNS_PROVIDERS) provider_type = sanitize.filtervalue(request.form.get('provider', ''), validate.VALID_DDNS_PROVIDERS)
description = sanitize.description(request.form.get('description', '')) description = sanitize.description(request.form.get('description', ''))
@ -73,17 +75,17 @@ def ddns_tableaccounts_rowedit():
if not provider_type: if not provider_type:
flash('Unknown provider type.', 'error') flash('Unknown provider type.', 'error')
return redirect(VIEW) return redirect(f'/{_PAGE}')
if not verify_config_hash(request.form.get('config_hash', '')): if not verify_config_hash(request.form.get('config_hash', '')):
flash('Configuration was modified by another session. Please refresh and try again.', 'error') flash('Configuration was modified by another session. Please refresh and try again.', 'error')
return redirect(VIEW) return redirect(f'/{_PAGE}')
cfg = load_config() cfg = load_config()
providers = cfg.setdefault('ddns', {}).setdefault('providers', []) providers = cfg.setdefault('ddns', {}).setdefault('providers', [])
if row_index < 0 or row_index >= len(providers): if row_index < 0 or row_index >= len(providers):
flash('Invalid provider index.', 'error') flash('Invalid provider index.', 'error')
return redirect(VIEW) return redirect(f'/{_PAGE}')
before = copy.deepcopy(providers[row_index]) before = copy.deepcopy(providers[row_index])
entry = { entry = {
@ -106,27 +108,27 @@ def ddns_tableaccounts_rowedit():
cmd='ddns update', cmd='ddns update',
queue=False, queue=False,
), 'success') ), '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') @require_level('administrator')
def ddns_tableaccounts_rowdelete(): def accounts_delete():
try: try:
row_index = int(request.form.get('row_index', -1)) row_index = int(request.form.get('row_index', -1))
except (TypeError, ValueError): except (TypeError, ValueError):
flash('Invalid row index.', 'error') flash('Invalid row index.', 'error')
return redirect(VIEW) return redirect(f'/{_PAGE}')
if not verify_config_hash(request.form.get('config_hash', '')): if not verify_config_hash(request.form.get('config_hash', '')):
flash('Configuration was modified by another session. Please refresh and try again.', 'error') flash('Configuration was modified by another session. Please refresh and try again.', 'error')
return redirect(VIEW) return redirect(f'/{_PAGE}')
cfg = load_config() cfg = load_config()
providers = cfg.setdefault('ddns', {}).setdefault('providers', []) providers = cfg.setdefault('ddns', {}).setdefault('providers', [])
if row_index < 0 or row_index >= len(providers): if row_index < 0 or row_index >= len(providers):
flash('Invalid provider index.', 'error') flash('Invalid provider index.', 'error')
return redirect(VIEW) return redirect(f'/{_PAGE}')
before = copy.deepcopy(providers[row_index]) before = copy.deepcopy(providers[row_index])
description = before.get('description', str(row_index)) description = before.get('description', str(row_index))
@ -138,12 +140,12 @@ def ddns_tableaccounts_rowdelete():
cmd='ddns update', cmd='ddns update',
queue=False, queue=False,
), 'success') ), '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') @require_level('administrator')
def ddns_cardipcheckinterval_save(): def ipcheckinterval_save():
raw = request.form.get('timer_interval', '').strip() raw = request.form.get('timer_interval', '').strip()
try: try:
mins = int(raw) mins = int(raw)
@ -151,12 +153,12 @@ def ddns_cardipcheckinterval_save():
raise ValueError raise ValueError
except ValueError: except ValueError:
flash('Interval must be a whole number of minutes >= 1.', 'error') flash('Interval must be a whole number of minutes >= 1.', 'error')
return redirect(VIEW) return redirect(f'/{_PAGE}')
timer_interval = f'{mins}m' timer_interval = f'{mins}m'
if not verify_config_hash(request.form.get('config_hash', '')): if not verify_config_hash(request.form.get('config_hash', '')):
flash('Configuration was modified by another session. Please refresh and try again.', 'error') flash('Configuration was modified by another session. Please refresh and try again.', 'error')
return redirect(VIEW) return redirect(f'/{_PAGE}')
cfg = load_config() cfg = load_config()
before = copy.deepcopy(cfg.get('ddns', {}).get('general', {})) before = copy.deepcopy(cfg.get('ddns', {}).get('general', {}))
@ -167,22 +169,22 @@ def ddns_cardipcheckinterval_save():
description='Updated DDNS check interval', description='Updated DDNS check interval',
cmd='core apply', cmd='core apply',
), 'success') ), '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') @require_level('administrator')
def ddns_cardipcheckservices_save(): def ipcheckservices_save():
if not verify_config_hash(request.form.get('config_hash', '')): if not verify_config_hash(request.form.get('config_hash', '')):
flash('Configuration was modified by another session. Please refresh and try again.', 'error') 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()] 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()] dig_services = [u.strip() for u in request.form.getlist('dig_services') if u.strip()]
if not http_services and not dig_services: if not http_services and not dig_services:
flash('At least one IP check service is required.', 'error') flash('At least one IP check service is required.', 'error')
return redirect(VIEW) return redirect(f'/{_PAGE}')
cfg = load_config() cfg = load_config()
before = copy.deepcopy(cfg.get('ddns', {}).get('ip_check_services', [])) before = copy.deepcopy(cfg.get('ddns', {}).get('ip_check_services', []))
@ -196,21 +198,21 @@ def ddns_cardipcheckservices_save():
cmd='ddns update', cmd='ddns update',
queue=False, queue=False,
), 'success') ), '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') @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) log_max_kb = validate.int_range(request.form.get('log_max_kb', '').strip(), 64, None)
if log_max_kb is None: if log_max_kb is None:
flash('Max Log Size must be a number >= 64.', 'error') 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 log_errors_only = 'log_errors_only' in request.form
if not verify_config_hash(request.form.get('config_hash', '')): if not verify_config_hash(request.form.get('config_hash', '')):
flash('Configuration was modified by another session. Please refresh and try again.', 'error') flash('Configuration was modified by another session. Please refresh and try again.', 'error')
return redirect(VIEW) return redirect(f'/{_PAGE}')
cfg = load_config() cfg = load_config()
before = copy.deepcopy(cfg.get('ddns', {}).get('general', {})) before = copy.deepcopy(cfg.get('ddns', {}).get('general', {}))
@ -225,23 +227,23 @@ def ddns_cardlogging_save():
cmd='ddns update', cmd='ddns update',
queue=False, queue=False,
), 'success') ), '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') @require_level('administrator')
def ddns_cardlogging_clear(): def logging_clear():
try: try:
open(LOG_FILE, 'w').close() open(LOG_FILE, 'w').close()
flash('DDNS log cleared.', 'success') flash('DDNS log cleared.', 'success')
except Exception as ex: except Exception as ex:
flash(f'Could not clear log: {ex}', 'error') 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') @require_level('administrator')
def ddns_cardlogging_download(): def logging_download():
if not os.path.isfile(LOG_FILE): if not os.path.isfile(LOG_FILE):
abort(404) abort(404)
return send_file(LOG_FILE, as_attachment=True, download_name='ddns.log', mimetype='text/plain') 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+", "client_requirement": "client_is_viewer+",
"items": [ "items": [
{ {
@ -29,7 +28,7 @@
"label": "IP Check Interval", "label": "IP Check Interval",
"value": "%DDNS_TIMER_INTERVAL%", "value": "%DDNS_TIMER_INTERVAL%",
"sub": "%STAT_PUBLIC_IP_LAST_CHECKED%", "sub": "%STAT_PUBLIC_IP_LAST_CHECKED%",
"edit_action": "/action/ddns_cardipcheckinterval_save", "edit_action": "/action/ddns/ipcheckinterval_save",
"edit_field": "timer_interval", "edit_field": "timer_interval",
"edit_input_type": "number", "edit_input_type": "number",
"edit_min": "1", "edit_min": "1",
@ -54,7 +53,7 @@
"items": [ "items": [
{ {
"type": "form", "type": "form",
"action": "/action/ddns_cardipcheckservices_save", "action": "/action/ddns/ipcheckservices_save",
"method": "post", "method": "post",
"items": [ "items": [
{ {
@ -78,7 +77,7 @@
"items": [ "items": [
{ {
"type": "button_primary", "type": "button_primary",
"action": "/action/ddns_cardipcheckservices_save", "action": "/action/ddns/ipcheckservices_save",
"method": "post", "method": "post",
"text": "Save" "text": "Save"
}, },
@ -125,7 +124,7 @@
"row_actions": [ "row_actions": [
{ {
"client_requirement": "client_is_administrator+", "client_requirement": "client_is_administrator+",
"action": "/action/ddns_tableaccounts_rowedit", "action": "/action/ddns/accounts_edit",
"method": "inline_edit", "method": "inline_edit",
"text": "Edit", "text": "Edit",
"class": "btn-ghost btn-sm", "class": "btn-ghost btn-sm",
@ -155,7 +154,7 @@
}, },
{ {
"client_requirement": "client_is_administrator+", "client_requirement": "client_is_administrator+",
"action": "/action/ddns_tableaccounts_rowdelete", "action": "/action/ddns/accounts_delete",
"method": "post", "method": "post",
"text": "Delete", "text": "Delete",
"class": "btn-danger btn-sm" "class": "btn-danger btn-sm"
@ -169,7 +168,7 @@
"items": [ "items": [
{ {
"type": "form", "type": "form",
"action": "/action/ddns_cardaddaccount_add", "action": "/action/ddns/addaccount_add",
"method": "post", "method": "post",
"items": [ "items": [
{ {
@ -202,7 +201,7 @@
"items": [ "items": [
{ {
"type": "button_primary", "type": "button_primary",
"action": "/action/ddns_cardaddaccount_add", "action": "/action/ddns/addaccount_add",
"method": "post", "method": "post",
"text": "Add Provider" "text": "Add Provider"
}, },
@ -236,12 +235,12 @@
"items": [ "items": [
{ {
"type": "button_ghost", "type": "button_ghost",
"action": "/action/ddns_cardlogging_download", "action": "/action/ddns/logging_download",
"text": "Download Log" "text": "Download Log"
}, },
{ {
"type": "button_danger", "type": "button_danger",
"action": "/action/ddns_cardlogging_clear", "action": "/action/ddns/logging_clear",
"method": "post", "method": "post",
"text": "Clear Log" "text": "Clear Log"
} }
@ -252,7 +251,7 @@
}, },
{ {
"type": "form", "type": "form",
"action": "/action/ddns_cardlogging_save", "action": "/action/ddns/logging_save",
"method": "post", "method": "post",
"items": [ "items": [
{ {
@ -277,7 +276,7 @@
"items": [ "items": [
{ {
"type": "button_primary", "type": "button_primary",
"action": "/action/ddns_cardlogging_save", "action": "/action/ddns/logging_save",
"method": "post", "method": "post",
"text": "Save" "text": "Save"
}, },

View file

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

View file

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

View file

@ -1,3 +1,4 @@
from pathlib import Path
import copy import copy
import re import re
from flask import Blueprint, request, redirect, flash 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 sanitize
import validation as validate 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)) _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 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') @require_level('administrator')
def dnsblocking_tableblocklists_rowdelete(): def blocklists_delete():
idx = _row_index() idx = _row_index()
if idx is None: if idx is None:
flash('Invalid request.', 'error') flash('Invalid request.', 'error')
return redirect(VIEW) return redirect(f'/{_PAGE}')
if not _hash_ok(): if not _hash_ok():
return redirect(VIEW) return redirect(f'/{_PAGE}')
cfg = load_config() cfg = load_config()
items = cfg.get('dns_blocking', {}).get('blocklists', []) items = cfg.get('dns_blocking', {}).get('blocklists', [])
if idx < 0 or idx >= len(items): if idx < 0 or idx >= len(items):
flash('Entry not found.', 'error') flash('Entry not found.', 'error')
return redirect(VIEW) return redirect(f'/{_PAGE}')
before = copy.deepcopy(items[idx]) before = copy.deepcopy(items[idx])
name = before.get('name', str(idx)) name = before.get('name', str(idx))
@ -76,36 +77,36 @@ def dnsblocking_tableblocklists_rowdelete():
if errors: if errors:
for msg in errors: for msg in errors:
flash(msg, 'error') flash(msg, 'error')
return redirect(VIEW) return redirect(f'/{_PAGE}')
flash(save_config_with_snapshot( flash(save_config_with_snapshot(
cfg, path='dns_blocking', key=name, operation='delete', cfg, path='dns_blocking', key=name, operation='delete',
before=before, after=None, before=before, after=None,
description=f'Deleted blocklist: {name}', description=f'Deleted blocklist: {name}',
queue=False, queue=False,
), 'success') ), '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') @require_level('administrator')
def dnsblocking_tableblocklists_rowedit(): def blocklists_edit():
idx = _row_index() idx = _row_index()
if idx is None: if idx is None:
flash('Invalid request.', 'error') flash('Invalid request.', 'error')
return redirect(VIEW) return redirect(f'/{_PAGE}')
fields, err = _parse_fields() fields, err = _parse_fields()
if err: if err:
return redirect(VIEW) return redirect(f'/{_PAGE}')
if not _hash_ok(): if not _hash_ok():
return redirect(VIEW) return redirect(f'/{_PAGE}')
cfg = load_config() cfg = load_config()
items = cfg.get('dns_blocking', {}).get('blocklists', []) items = cfg.get('dns_blocking', {}).get('blocklists', [])
if idx < 0 or idx >= len(items): if idx < 0 or idx >= len(items):
flash('Entry not found.', 'error') flash('Entry not found.', 'error')
return redirect(VIEW) return redirect(f'/{_PAGE}')
before = copy.deepcopy(items[idx]) before = copy.deepcopy(items[idx])
items[idx].update({ items[idx].update({
@ -118,32 +119,32 @@ def dnsblocking_tableblocklists_rowedit():
if errors: if errors:
for msg in errors: for msg in errors:
flash(msg, 'error') flash(msg, 'error')
return redirect(VIEW) return redirect(f'/{_PAGE}')
flash(save_config_with_snapshot( flash(save_config_with_snapshot(
cfg, path='dns_blocking', key=fields['name'], operation='edit', cfg, path='dns_blocking', key=fields['name'], operation='edit',
before=before, after=copy.deepcopy(items[idx]), before=before, after=copy.deepcopy(items[idx]),
description=f'Edited blocklist: {fields["name"]}', description=f'Edited blocklist: {fields["name"]}',
queue=False, queue=False,
), 'success') ), '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') @require_level('administrator')
def dnsblocking_cardaddblocklist_add(): def addblocklist_add():
fields, err = _parse_fields() fields, err = _parse_fields()
if err: if err:
return redirect(VIEW) return redirect(f'/{_PAGE}')
if not _hash_ok(): if not _hash_ok():
return redirect(VIEW) return redirect(f'/{_PAGE}')
cfg = load_config() cfg = load_config()
blocklists = cfg.setdefault('dns_blocking', {}).setdefault('blocklists', []) blocklists = cfg.setdefault('dns_blocking', {}).setdefault('blocklists', [])
if any(b.get('name', '').lower() == fields['name'].lower() for b in 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') flash('The configuration has not been saved because a blocklist with that name already exists.', 'error')
return redirect(VIEW) return redirect(f'/{_PAGE}')
entry = { entry = {
'name': fields['name'], 'name': fields['name'],
@ -157,28 +158,28 @@ def dnsblocking_cardaddblocklist_add():
if errors: if errors:
for msg in errors: for msg in errors:
flash(msg, 'error') flash(msg, 'error')
return redirect(VIEW) return redirect(f'/{_PAGE}')
flash(save_config_with_snapshot( flash(save_config_with_snapshot(
cfg, path='dns_blocking', key=fields['name'], operation='add', cfg, path='dns_blocking', key=fields['name'], operation='add',
before=None, after=copy.deepcopy(entry), before=None, after=copy.deepcopy(entry),
description=f'Added blocklist: {fields["name"]}', description=f'Added blocklist: {fields["name"]}',
queue=False, queue=False,
), 'success') ), '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') @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', ''))) daily_execute_time = validate.time_24h(sanitize.time_24h(request.form.get('daily_execute_time_24hr_local', '')))
if not daily_execute_time: if not daily_execute_time:
flash('Daily Refresh Time must be a valid 24-hour time (e.g. 02:30).', 'error') 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', '')): if not verify_config_hash(request.form.get('config_hash', '')):
flash('Configuration was modified by another session. Please refresh and try again.', 'error') flash('Configuration was modified by another session. Please refresh and try again.', 'error')
return redirect(VIEW) return redirect(f'/{_PAGE}')
cfg = load_config() cfg = load_config()
before = copy.deepcopy(cfg.get('dns_blocking', {}).get('general', {})) before = copy.deepcopy(cfg.get('dns_blocking', {}).get('general', {}))
@ -189,30 +190,30 @@ def dnsblocking_cardblocklistrefresh_save():
description='Updated daily blocklist refresh time', description='Updated daily blocklist refresh time',
cmd='core apply', cmd='core apply',
), 'success') ), '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') @require_level('administrator')
def dnsblocking_cardblocklistrefresh_refreshnow(): def blocklistrefresh_refresh():
flash(queued_msg('core update-blocklists', action_label='Blocklist refresh queued'), 'success') 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') @require_level('administrator')
def dnsblocking_cardlogging_save(): def logging_save():
log_max_kb_raw = request.form.get('log_max_kb', '').strip() log_max_kb_raw = request.form.get('log_max_kb', '').strip()
log_errors_only = 'log_errors_only' in request.form log_errors_only = 'log_errors_only' in request.form
log_max_kb = validate.int_range(log_max_kb_raw, 64, None) log_max_kb = validate.int_range(log_max_kb_raw, 64, None)
if log_max_kb is None: if log_max_kb is None:
flash('Max Log Size must be a number >= 64.', 'error') 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', '')): if not verify_config_hash(request.form.get('config_hash', '')):
flash('Configuration was modified by another session. Please refresh and try again.', 'error') flash('Configuration was modified by another session. Please refresh and try again.', 'error')
return redirect(VIEW) return redirect(f'/{_PAGE}')
cfg = load_config() cfg = load_config()
before = copy.deepcopy(cfg.get('dns_blocking', {}).get('general', {})) before = copy.deepcopy(cfg.get('dns_blocking', {}).get('general', {}))
@ -224,11 +225,11 @@ def dnsblocking_cardlogging_save():
if errors: if errors:
for msg in errors: for msg in errors:
flash(msg, 'error') flash(msg, 'error')
return redirect(VIEW) return redirect(f'/{_PAGE}')
flash(save_config_with_snapshot( flash(save_config_with_snapshot(
cfg, path='dns_blocking', key='general', operation='edit', cfg, path='dns_blocking', key='general', operation='edit',
before=before, after=copy.deepcopy(cfg['dns_blocking']['general']), before=before, after=copy.deepcopy(cfg['dns_blocking']['general']),
description='Updated DNS blocking log settings', description='Updated DNS blocking log settings',
queue=False, queue=False,
), 'success') ), 'success')
return redirect(VIEW) return redirect(f'/{_PAGE}')

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,3 +1,4 @@
from pathlib import Path
import copy import copy
from flask import Blueprint, request, redirect, flash 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 sanitize
import validation as validate 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)) _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}' 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') @require_level('administrator')
def add_inter_vlan(): def addexception_add():
entry, err = _parse_entry() entry, err = _parse_entry()
if err: if err:
return redirect(VIEW) return redirect(f'/{_PAGE}')
if not _hash_ok(): if not _hash_ok():
return redirect(VIEW) return redirect(f'/{_PAGE}')
cfg = load_config() cfg = load_config()
cfg.setdefault('inter_vlan_exceptions', []).append(entry) cfg.setdefault('inter_vlan_exceptions', []).append(entry)
@ -92,7 +93,7 @@ def add_inter_vlan():
if errors: if errors:
for msg in errors: for msg in errors:
flash(msg, 'error') flash(msg, 'error')
return redirect(VIEW) return redirect(f'/{_PAGE}')
key = _entry_key(entry) key = _entry_key(entry)
flash(save_config_with_snapshot( flash(save_config_with_snapshot(
@ -101,24 +102,24 @@ def add_inter_vlan():
before=None, after=entry, before=None, after=entry,
description=f'Added inter-VLAN rule: {key}', description=f'Added inter-VLAN rule: {key}',
), 'success') ), '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') @require_level('administrator')
def toggle_inter_vlan(): def table_toggle():
idx = _row_index() idx = _row_index()
if idx is None: if idx is None:
flash('Invalid request.', 'error') flash('Invalid request.', 'error')
return redirect(VIEW) return redirect(f'/{_PAGE}')
if not _hash_ok(): if not _hash_ok():
return redirect(VIEW) return redirect(f'/{_PAGE}')
cfg = load_config() cfg = load_config()
items = cfg.get('inter_vlan_exceptions', []) items = cfg.get('inter_vlan_exceptions', [])
if idx < 0 or idx >= len(items): if idx < 0 or idx >= len(items):
flash('Entry not found.', 'error') flash('Entry not found.', 'error')
return redirect(VIEW) return redirect(f'/{_PAGE}')
old_enabled = items[idx].get('enabled', True) old_enabled = items[idx].get('enabled', True)
items[idx]['enabled'] = not old_enabled items[idx]['enabled'] = not old_enabled
@ -126,7 +127,7 @@ def toggle_inter_vlan():
if errors: if errors:
for msg in errors: for msg in errors:
flash(msg, 'error') flash(msg, 'error')
return redirect(VIEW) return redirect(f'/{_PAGE}')
key = _entry_key(items[idx]) key = _entry_key(items[idx])
action = 'Enabled' if not old_enabled else 'Disabled' 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}, before={'enabled': old_enabled}, after={'enabled': not old_enabled},
description=f'{action} inter-VLAN rule: {key}', description=f'{action} inter-VLAN rule: {key}',
), 'success') ), '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') @require_level('administrator')
def edit_inter_vlan(): def table_edit():
idx = _row_index() idx = _row_index()
if idx is None: if idx is None:
flash('Invalid request.', 'error') flash('Invalid request.', 'error')
return redirect(VIEW) return redirect(f'/{_PAGE}')
entry, err = _parse_entry() entry, err = _parse_entry()
if err: if err:
return redirect(VIEW) return redirect(f'/{_PAGE}')
if not _hash_ok(): if not _hash_ok():
return redirect(VIEW) return redirect(f'/{_PAGE}')
cfg = load_config() cfg = load_config()
items = cfg.get('inter_vlan_exceptions', []) items = cfg.get('inter_vlan_exceptions', [])
if idx < 0 or idx >= len(items): if idx < 0 or idx >= len(items):
flash('Entry not found.', 'error') flash('Entry not found.', 'error')
return redirect(VIEW) return redirect(f'/{_PAGE}')
before = copy.deepcopy(items[idx]) before = copy.deepcopy(items[idx])
items[idx] = entry items[idx] = entry
@ -166,7 +167,7 @@ def edit_inter_vlan():
if errors: if errors:
for msg in errors: for msg in errors:
flash(msg, 'error') flash(msg, 'error')
return redirect(VIEW) return redirect(f'/{_PAGE}')
key = _entry_key(entry) key = _entry_key(entry)
flash(save_config_with_snapshot( flash(save_config_with_snapshot(
@ -175,31 +176,31 @@ def edit_inter_vlan():
before=before, after=copy.deepcopy(items[idx]), before=before, after=copy.deepcopy(items[idx]),
description=f'Edited inter-VLAN rule: {key}', description=f'Edited inter-VLAN rule: {key}',
), 'success') ), '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') @require_level('administrator')
def delete_inter_vlan(): def table_delete():
idx = _row_index() idx = _row_index()
if idx is None: if idx is None:
flash('Invalid request.', 'error') flash('Invalid request.', 'error')
return redirect(VIEW) return redirect(f'/{_PAGE}')
if not _hash_ok(): if not _hash_ok():
return redirect(VIEW) return redirect(f'/{_PAGE}')
cfg = load_config() cfg = load_config()
items = cfg.get('inter_vlan_exceptions', []) items = cfg.get('inter_vlan_exceptions', [])
if idx < 0 or idx >= len(items): if idx < 0 or idx >= len(items):
flash('Entry not found.', 'error') flash('Entry not found.', 'error')
return redirect(VIEW) return redirect(f'/{_PAGE}')
removed = items.pop(idx) removed = items.pop(idx)
errors = validate.validate_config(cfg) errors = validate.validate_config(cfg)
if errors: if errors:
for msg in errors: for msg in errors:
flash(msg, 'error') flash(msg, 'error')
return redirect(VIEW) return redirect(f'/{_PAGE}')
key = _entry_key(removed) key = _entry_key(removed)
flash(save_config_with_snapshot( flash(save_config_with_snapshot(
@ -208,4 +209,4 @@ def delete_inter_vlan():
before=removed, after=None, before=removed, after=None,
description=f'Deleted inter-VLAN rule: {key}', description=f'Deleted inter-VLAN rule: {key}',
), 'success') ), 'success')
return redirect(VIEW) return redirect(f'/{_PAGE}')

View file

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

View file

@ -1,3 +1,4 @@
from pathlib import Path
import copy import copy
from flask import Blueprint, request, redirect, flash 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 sanitize
import validation as validate 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') @require_level('administrator')
def apply_mdns(): def settings_apply():
mdns_enabled = 'mdns_enabled' in request.form mdns_enabled = 'mdns_enabled' in request.form
if not verify_config_hash(request.form.get('config_hash', '')): if not verify_config_hash(request.form.get('config_hash', '')):
flash('Configuration was modified by another session. Please refresh and try again.', 'error') 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() cfg = load_config()
mdns_reflect_vlans = sanitize.filterlist( mdns_reflect_vlans = sanitize.filterlist(
@ -33,7 +36,7 @@ def apply_mdns():
if errors: if errors:
for msg in errors: for msg in errors:
flash(msg, 'error') flash(msg, 'error')
return redirect('/view/view_mdns') return redirect(f'/{_PAGE}')
flash(save_config_with_snapshot( flash(save_config_with_snapshot(
cfg, cfg,
@ -41,4 +44,4 @@ def apply_mdns():
before=before or None, after=copy.deepcopy(cfg['mdns_reflection']), before=before or None, after=copy.deepcopy(cfg['mdns_reflection']),
description='Updated mDNS reflection settings', description='Updated mDNS reflection settings',
), 'success') ), 'success')
return redirect('/view/view_mdns') return redirect(f'/{_PAGE}')

View file

@ -1,3 +1,4 @@
from pathlib import Path
import copy import copy
import ipaddress import ipaddress
@ -7,9 +8,9 @@ from config_utils import load_config, save_config_with_snapshot, verify_config_h
import sanitize import sanitize
import validation as validate import validation as validate
bp = Blueprint('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', _VLAN_FIELDS = ['name', 'vlan_id', 'is_vpn', 'subnet', 'subnet_mask', 'dnsmasq_log_queries',
'radius_default', 'mdns_reflection', 'use_blocklists'] 'radius_default', 'mdns_reflection', 'use_blocklists']
@ -29,9 +30,9 @@ def _hash_ok():
return True return True
@bp.route('/action/networklayout_cardaddvlan_addvlan', methods=['POST']) @bp.route('/action/networklayout/addvlan_add', methods=['POST'])
@require_level('administrator') @require_level('administrator')
def networklayout_cardaddvlan_addvlan(): def addvlan_add():
name = sanitize.name(request.form.get('name', '')) name = sanitize.name(request.form.get('name', ''))
vlan_id = sanitize.vlan_id(request.form.get('vlan_id', '')) vlan_id = sanitize.vlan_id(request.form.get('vlan_id', ''))
is_vpn = 'is_vpn' in request.form is_vpn = 'is_vpn' in request.form
@ -47,29 +48,29 @@ def networklayout_cardaddvlan_addvlan():
if not name: if not name:
flash('Name is required.', 'error') flash('Name is required.', 'error')
return redirect(VIEW) return redirect(f'/{_PAGE}')
if vlan_id is None: if vlan_id is None:
flash('VLAN ID must be an integer between 1 and 4094.', 'error') flash('VLAN ID must be an integer between 1 and 4094.', 'error')
return redirect(VIEW) return redirect(f'/{_PAGE}')
if not subnet: if not subnet:
flash('Subnet IP is required.', 'error') flash('Subnet IP is required.', 'error')
return redirect(VIEW) return redirect(f'/{_PAGE}')
if subnet_mask is None: if subnet_mask is None:
flash('Invalid subnet prefix (must be 1-30).', 'error') flash('Invalid subnet prefix (must be 1-30).', 'error')
return redirect(VIEW) return redirect(f'/{_PAGE}')
if not _hash_ok(): if not _hash_ok():
return redirect(VIEW) return redirect(f'/{_PAGE}')
cfg = load_config() cfg = load_config()
vlans = cfg.setdefault('vlans', []) vlans = cfg.setdefault('vlans', [])
if any(v.get('vlan_id') == vlan_id for v in vlans): if any(v.get('vlan_id') == vlan_id for v in vlans):
flash(f'VLAN ID {vlan_id} is already in use.', 'error') 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): if radius_default and any(v.get('radius_default') for v in vlans):
flash('Only one VLAN can be the RADIUS default.', 'error') flash('Only one VLAN can be the RADIUS default.', 'error')
return redirect(VIEW) return redirect(f'/{_PAGE}')
entry = { entry = {
'name': name, 'name': name,
@ -91,7 +92,7 @@ def networklayout_cardaddvlan_addvlan():
if errors: if errors:
for msg in errors: for msg in errors:
flash(msg, 'error') flash(msg, 'error')
return redirect(VIEW) return redirect(f'/{_PAGE}')
flash(save_config_with_snapshot( flash(save_config_with_snapshot(
cfg, cfg,
@ -99,16 +100,16 @@ def networklayout_cardaddvlan_addvlan():
before=None, after={k: entry[k] for k in _VLAN_FIELDS if k in entry}, before=None, after={k: entry[k] for k in _VLAN_FIELDS if k in entry},
description=f'Added VLAN: {name} ({subnet}/{subnet_mask})', description=f'Added VLAN: {name} ({subnet}/{subnet_mask})',
), 'success') ), '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') @require_level('administrator')
def networklayout_tablevlans_edit(): def vlans_edit():
idx = _row_index() idx = _row_index()
if idx is None: if idx is None:
flash('Invalid request.', 'error') flash('Invalid request.', 'error')
return redirect(VIEW) return redirect(f'/{_PAGE}')
name = sanitize.name(request.form.get('name', '')) name = sanitize.name(request.form.get('name', ''))
vlan_id = sanitize.vlan_id(request.form.get('vlan_id', '')) vlan_id = sanitize.vlan_id(request.form.get('vlan_id', ''))
@ -126,7 +127,7 @@ def networklayout_tablevlans_edit():
clean = sanitize.ip(raw_ip) clean = sanitize.ip(raw_ip)
if not clean: if not clean:
flash(f"'{raw_ip}' is not a valid IP address.", 'error') flash(f"'{raw_ip}' is not a valid IP address.", 'error')
return redirect(VIEW) return redirect(f'/{_PAGE}')
identity_ips.append(clean) identity_ips.append(clean)
identity_descs = request.form.get('server_identity_descriptions', '').splitlines() identity_descs = request.form.get('server_identity_descriptions', '').splitlines()
identity_hostnames = request.form.get('server_identity_hostnames', '').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) subnet_mask = sanitize.subnet_mask(subnet_mask_raw)
if subnet_mask is None: if subnet_mask is None:
flash('Invalid subnet prefix (must be 1-30).', 'error') flash('Invalid subnet prefix (must be 1-30).', 'error')
return redirect(VIEW) return redirect(f'/{_PAGE}')
else: else:
subnet_mask = None subnet_mask = None
if not name: if not name:
flash('Name is required.', 'error') flash('Name is required.', 'error')
return redirect(VIEW) return redirect(f'/{_PAGE}')
if vlan_id is None: if vlan_id is None:
flash('VLAN ID must be an integer between 1 and 4094.', 'error') flash('VLAN ID must be an integer between 1 and 4094.', 'error')
return redirect(VIEW) return redirect(f'/{_PAGE}')
if not subnet: if not subnet:
flash('Subnet IP is required.', 'error') flash('Subnet IP is required.', 'error')
return redirect(VIEW) return redirect(f'/{_PAGE}')
if not _hash_ok(): if not _hash_ok():
return redirect(VIEW) return redirect(f'/{_PAGE}')
cfg = load_config() cfg = load_config()
vlans = cfg.get('vlans', []) vlans = cfg.get('vlans', [])
if idx < 0 or idx >= len(vlans): if idx < 0 or idx >= len(vlans):
flash('VLAN not found.', 'error') flash('VLAN not found.', 'error')
return redirect(VIEW) return redirect(f'/{_PAGE}')
existing = vlans[idx] existing = vlans[idx]
is_vpn = existing.get('is_vpn', False) is_vpn = existing.get('is_vpn', False)
@ -167,20 +168,20 @@ def networklayout_tablevlans_edit():
for _ip in identity_ips: for _ip in identity_ips:
if ipaddress.IPv4Address(_ip) not in _vlan_net: if ipaddress.IPv4Address(_ip) not in _vlan_net:
flash(f"Server identity IP '{_ip}' is not in the VLAN subnet ({subnet}/{final_mask}).", 'error') 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') current_id = existing.get('vlan_id')
if current_id == 1 and vlan_id != 1: if current_id == 1 and vlan_id != 1:
flash('VLAN 1 is the physical interface and cannot change its ID.', 'error') 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): 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') 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)): 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') flash('Only one VLAN can be the RADIUS default.', 'error')
return redirect(VIEW) return redirect(f'/{_PAGE}')
old_identities = existing.get('server_identities', []) old_identities = existing.get('server_identities', [])
new_identities = [] new_identities = []
@ -197,7 +198,7 @@ def networklayout_tablevlans_edit():
clean_hostname = sanitize.hostname(hostname_raw) clean_hostname = sanitize.hostname(hostname_raw)
if clean_hostname is None: if clean_hostname is None:
flash(f"'{hostname_raw}' is not a valid hostname.", 'error') flash(f"'{hostname_raw}' is not a valid hostname.", 'error')
return redirect(VIEW) return redirect(f'/{_PAGE}')
entry['hostname'] = clean_hostname entry['hostname'] = clean_hostname
else: else:
entry.pop('hostname', None) entry.pop('hostname', None)
@ -206,7 +207,7 @@ def networklayout_tablevlans_edit():
gateway_raw = sanitize.ip(request.form.get('gateway', '')) gateway_raw = sanitize.ip(request.form.get('gateway', ''))
if gateway_raw and gateway_raw not in identity_ips: if gateway_raw and gateway_raw not in identity_ips:
flash(f"Gateway '{gateway_raw}' must match one of the server identity IPs.", 'error') 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])) inferred_gw = (min(identity_ips, key=lambda ip: int(ip.split('.')[-1]))
if identity_ips else '') if identity_ips else '')
new_stored_gw = gateway_raw if (gateway_raw and gateway_raw != inferred_gw) 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) _clean = sanitize.ip(_line)
if not _clean: if not _clean:
flash(f"'{_line}' is not a valid DNS server IP.", 'error') flash(f"'{_line}' is not a valid DNS server IP.", 'error')
return redirect(VIEW) return redirect(f'/{_PAGE}')
dns_ips.append(_clean) dns_ips.append(_clean)
if dns_override and not dns_ips: if dns_override and not dns_ips:
flash('At least one DNS server IP is required when override is enabled.', 'error') 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: if dns_override and dns_ips:
_vlan_net = ipaddress.IPv4Network(f'{subnet}/{final_mask}', strict=False) _vlan_net = ipaddress.IPv4Network(f'{subnet}/{final_mask}', strict=False)
for _ip in dns_ips: for _ip in dns_ips:
if ipaddress.IPv4Address(_ip) not in _vlan_net: if ipaddress.IPv4Address(_ip) not in _vlan_net:
flash(f"DNS server '{_ip}' is not in the VLAN subnet ({subnet}/{final_mask}).", 'error') 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 [] new_stored_dns = dns_ips if dns_override else []
_existing_dns = existing.get('dhcp_information', {}).get('explicit_overrides', {}).get('dns_server', []) _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 []) 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) _clean = sanitize.ip(_line)
if not _clean: if not _clean:
flash(f"'{_line}' is not a valid NTP server IP.", 'error') flash(f"'{_line}' is not a valid NTP server IP.", 'error')
return redirect(VIEW) return redirect(f'/{_PAGE}')
ntp_ips.append(_clean) ntp_ips.append(_clean)
if ntp_override and not ntp_ips: if ntp_override and not ntp_ips:
flash('At least one NTP server IP is required when override is enabled.', 'error') 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: if ntp_override and ntp_ips:
_vlan_net = ipaddress.IPv4Network(f'{subnet}/{final_mask}', strict=False) _vlan_net = ipaddress.IPv4Network(f'{subnet}/{final_mask}', strict=False)
for _ip in ntp_ips: for _ip in ntp_ips:
if ipaddress.IPv4Address(_ip) not in _vlan_net: if ipaddress.IPv4Address(_ip) not in _vlan_net:
flash(f"NTP server '{_ip}' is not in the VLAN subnet ({subnet}/{final_mask}).", 'error') 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 [] new_stored_ntp = ntp_ips if ntp_override else []
_existing_ntp = existing.get('dhcp_information', {}).get('explicit_overrides', {}).get('ntp_server', []) _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 []) 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_dns == existing_dns
and new_stored_ntp == existing_ntp): and new_stored_ntp == existing_ntp):
flash('No changes were made.', 'info') flash('No changes were made.', 'info')
return redirect(VIEW) return redirect(f'/{_PAGE}')
before = {k: existing.get(k) for k in _VLAN_FIELDS} before = {k: existing.get(k) for k in _VLAN_FIELDS}
existing.update({ existing.update({
@ -316,7 +317,7 @@ def networklayout_tablevlans_edit():
if errors: if errors:
for msg in errors: for msg in errors:
flash(msg, 'error') flash(msg, 'error')
return redirect(VIEW) return redirect(f'/{_PAGE}')
flash(save_config_with_snapshot( flash(save_config_with_snapshot(
cfg, cfg,
@ -324,31 +325,31 @@ def networklayout_tablevlans_edit():
before=before, after={k: existing.get(k) for k in _VLAN_FIELDS}, before=before, after={k: existing.get(k) for k in _VLAN_FIELDS},
description=f'Edited VLAN: {name}', description=f'Edited VLAN: {name}',
), 'success') ), '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') @require_level('administrator')
def networklayout_tablevlans_delete(): def vlans_delete():
idx = _row_index() idx = _row_index()
if idx is None: if idx is None:
flash('Invalid request.', 'error') flash('Invalid request.', 'error')
return redirect(VIEW) return redirect(f'/{_PAGE}')
if not _hash_ok(): if not _hash_ok():
return redirect(VIEW) return redirect(f'/{_PAGE}')
cfg = load_config() cfg = load_config()
vlans = cfg.get('vlans', []) vlans = cfg.get('vlans', [])
if idx < 0 or idx >= len(vlans): if idx < 0 or idx >= len(vlans):
flash('VLAN not found.', 'error') flash('VLAN not found.', 'error')
return redirect(VIEW) return redirect(f'/{_PAGE}')
removed = vlans.pop(idx) removed = vlans.pop(idx)
errors = validate.validate_config(cfg) errors = validate.validate_config(cfg)
if errors: if errors:
for msg in errors: for msg in errors:
flash(msg, 'error') flash(msg, 'error')
return redirect(VIEW) return redirect(f'/{_PAGE}')
flash(save_config_with_snapshot( flash(save_config_with_snapshot(
cfg, cfg,
@ -357,4 +358,4 @@ def networklayout_tablevlans_delete():
after=None, after=None,
description=f'Deleted VLAN: {removed["name"]}', description=f'Deleted VLAN: {removed["name"]}',
), 'success') ), 'success')
return redirect(VIEW) return redirect(f'/{_PAGE}')

View file

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

View file

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

View file

@ -1,3 +1,4 @@
from pathlib import Path
import copy import copy
import os import os
@ -7,9 +8,9 @@ from config_utils import load_config, save_config_with_snapshot, verify_config_h
import sanitize import sanitize
import validation as validate import validation as validate
bp = Blueprint('physicalinterfaces', __name__) _PAGE = Path(__file__).parent.name
_VIEW = '/view/view_physicalinterfaces' bp = Blueprint(_PAGE, __name__)
_EXCLUDE_PREFIXES = ('lo', 'wg', 'docker', 'br-', 'veth', _EXCLUDE_PREFIXES = ('lo', 'wg', 'docker', 'br-', 'veth',
'tun', 'tap', 'ppp', 'virbr', 'tun', 'tap', 'ppp', 'virbr',
@ -31,29 +32,29 @@ def _valid_interface(name):
return name in _get_system_interfaces() 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') @require_level('administrator')
def physicalinterfaces_cardphysicalinterface_save(): def physicalinterface_save():
wan = sanitize.interface_name(request.form.get('wan_interface', '')) wan = sanitize.interface_name(request.form.get('wan_interface', ''))
lan = sanitize.interface_name(request.form.get('lan_interface', '')) lan = sanitize.interface_name(request.form.get('lan_interface', ''))
if not wan or not lan: if not wan or not lan:
flash('Both WAN and LAN interfaces are required.', 'error') flash('Both WAN and LAN interfaces are required.', 'error')
return redirect(_VIEW) return redirect(f'/{_PAGE}')
if wan == lan: if wan == lan:
flash('WAN and LAN interfaces must be different.', 'error') 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', '')): if not verify_config_hash(request.form.get('config_hash', '')):
flash('Configuration was modified by another session. Please refresh and try again.', 'error') flash('Configuration was modified by another session. Please refresh and try again.', 'error')
return redirect(_VIEW) return redirect(f'/{_PAGE}')
available = _get_system_interfaces() available = _get_system_interfaces()
for iface in (wan, lan): for iface in (wan, lan):
if available and iface not in available: if available and iface not in available:
flash(f"Interface '{iface}' does not exist on this system.", 'error') flash(f"Interface '{iface}' does not exist on this system.", 'error')
return redirect(_VIEW) return redirect(f'/{_PAGE}')
cfg = load_config() cfg = load_config()
before = copy.deepcopy(cfg.get('network_interfaces', {})) before = copy.deepcopy(cfg.get('network_interfaces', {}))
@ -64,22 +65,22 @@ def physicalinterfaces_cardphysicalinterface_save():
if errors: if errors:
for msg in errors: for msg in errors:
flash(msg, 'error') flash(msg, 'error')
return redirect(_VIEW) return redirect(f'/{_PAGE}')
flash(save_config_with_snapshot( flash(save_config_with_snapshot(
cfg, path='network_interfaces', key='global', operation='edit', cfg, path='network_interfaces', key='global', operation='edit',
before=before, after=copy.deepcopy(cfg['network_interfaces']), before=before, after=copy.deepcopy(cfg['network_interfaces']),
description='Updated network interfaces', description='Updated network interfaces',
cmd='core apply', cmd='core apply',
), 'success') ), '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') @require_level('administrator')
def physicalinterfaces_cardinterfaceconfiguration_apply(): def ifaceconfig_apply():
if not verify_config_hash(request.form.get('config_hash', '')): if not verify_config_hash(request.form.get('config_hash', '')):
flash('Configuration was modified by another session. Please refresh and try again.', 'error') 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', '')) iface = sanitize.interface_name(request.form.get('iface', ''))
mtu = request.form.get('mtu', '').strip() mtu = request.form.get('mtu', '').strip()
@ -89,27 +90,27 @@ def physicalinterfaces_cardinterfaceconfiguration_apply():
if not iface: if not iface:
flash('No interface specified.', 'error') flash('No interface specified.', 'error')
return redirect(_VIEW) return redirect(f'/{_PAGE}')
if not _valid_interface(iface): if not _valid_interface(iface):
flash(f"Interface '{iface}' does not exist on this system.", 'error') flash(f"Interface '{iface}' does not exist on this system.", 'error')
return redirect(_VIEW) return redirect(f'/{_PAGE}')
mtu_int = None mtu_int = None
if mtu: if mtu:
mtu_int = validate.int_range(mtu, 68, 9000) mtu_int = validate.int_range(mtu, 68, 9000)
if mtu_int is None: if mtu_int is None:
flash('MTU must be an integer between 68 and 9000.', 'error') 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() mac_raw = request.form.get('mac', '').strip()
if mac_raw and not mac: if mac_raw and not mac:
flash('MAC address must be in the format aa:bb:cc:dd:ee:ff.', 'error') 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: if not mtu_int and not mac:
flash('No changes specified.', 'error') flash('No changes specified.', 'error')
return redirect(_VIEW) return redirect(f'/{_PAGE}')
queued = False queued = False
if mtu_int and str(mtu_int) != original_mtu: if mtu_int and str(mtu_int) != original_mtu:
@ -121,7 +122,7 @@ def physicalinterfaces_cardinterfaceconfiguration_apply():
if not queued: if not queued:
flash('No changes detected.', 'info') flash('No changes detected.', 'info')
return redirect(_VIEW) return redirect(f'/{_PAGE}')
flash(queued_msg(action_label='Changes queued'), 'success') 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+", "client_requirement": "client_is_administrator+",
"items": [ "items": [
{ {
@ -22,7 +21,7 @@
"items": [ "items": [
{ {
"type": "form", "type": "form",
"action": "/action/physicalinterfaces_cardphysicalinterface_save", "action": "/action/physicalinterfaces/physicalinterface_save",
"method": "post", "method": "post",
"items": [ "items": [
{ {
@ -46,7 +45,7 @@
"items": [ "items": [
{ {
"type": "button_primary", "type": "button_primary",
"action": "/action/physicalinterfaces_cardphysicalinterface_save", "action": "/action/physicalinterfaces/physicalinterface_save",
"method": "post", "method": "post",
"text": "Save" "text": "Save"
}, },
@ -69,7 +68,7 @@
"items": [ "items": [
{ {
"type": "form", "type": "form",
"action": "/action/physicalinterfaces_cardinterfaceconfiguration_apply", "action": "/action/physicalinterfaces/ifaceconfig_apply",
"method": "post", "method": "post",
"items": [ "items": [
{ {
@ -142,7 +141,7 @@
"items": [ "items": [
{ {
"type": "button_primary", "type": "button_primary",
"action": "/action/physicalinterfaces_cardinterfaceconfiguration_apply", "action": "/action/physicalinterfaces/ifaceconfig_apply",
"method": "post", "method": "post",
"text": "Apply" "text": "Apply"
}, },

View file

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

View file

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

View file

@ -1,10 +1,13 @@
from pathlib import Path
from flask import Blueprint, request, session, redirect, flash from flask import Blueprint, request, session, redirect, flash
import json, bcrypt import json, bcrypt
from auth import require_level from auth import require_level
from config_utils import ACCOUNTS_FILE from config_utils import ACCOUNTS_FILE
import sanitize 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) json.dump(data, f, indent=2)
@bp.route('/action/save_preferences', methods=['POST']) @bp.route('/action/preferences/accountdetails_save', methods=['POST'])
@require_level('viewer') @require_level('viewer')
def save_preferences(): def accountdetails_save():
tz = sanitize.timezone(request.form.get('timezone', '').strip()) tz = sanitize.timezone(request.form.get('timezone', '').strip())
if not tz: if not tz:
flash('Timezone is required.', 'error') flash('Timezone is required.', 'error')
return redirect('/view/view_preferences') return redirect(f'/{_PAGE}')
email = session.get('email_address', '').lower() email = session.get('email_address', '').lower()
data = _load_accounts() data = _load_accounts()
@ -36,7 +39,7 @@ def save_preferences():
if account is None: if account is None:
flash('Account not found. Please log in again.', 'error') flash('Account not found. Please log in again.', 'error')
return redirect('/view/view_login') return redirect('/accountlogin')
account['timezone'] = tz account['timezone'] = tz
_save_accounts(data) _save_accounts(data)
@ -44,27 +47,27 @@ def save_preferences():
session['timezone'] = tz session['timezone'] = tz
flash('Preferences saved.', 'success') 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') @require_level('viewer')
def change_password(): def changepassword_save():
current_password = request.form.get('current_password', '') current_password = request.form.get('current_password', '')
new_password = request.form.get('new_password', '') new_password = request.form.get('new_password', '')
confirm_password = request.form.get('confirm_password', '') confirm_password = request.form.get('confirm_password', '')
if not current_password or not new_password or not confirm_password: if not current_password or not new_password or not confirm_password:
flash('All fields are required.', 'error') flash('All fields are required.', 'error')
return redirect('/view/view_preferences') return redirect(f'/{_PAGE}')
if new_password != confirm_password: if new_password != confirm_password:
flash('New passwords do not match.', 'error') flash('New passwords do not match.', 'error')
return redirect('/view/view_preferences') return redirect(f'/{_PAGE}')
if len(new_password) < 8: if len(new_password) < 8:
flash('New password must be at least 8 characters.', 'error') 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() email = session.get('email_address', '').lower()
data = _load_accounts() data = _load_accounts()
@ -73,12 +76,12 @@ def change_password():
if account is None: if account is None:
flash('Account not found. Please log in again.', 'error') flash('Account not found. Please log in again.', 'error')
return redirect('/view/view_login') return redirect('/accountlogin')
stored_hash = account.get('hashed_password', '').encode('utf-8') stored_hash = account.get('hashed_password', '').encode('utf-8')
if not bcrypt.checkpw(current_password.encode('utf-8'), stored_hash): if not bcrypt.checkpw(current_password.encode('utf-8'), stored_hash):
flash('Current password is incorrect.', 'error') flash('Current password is incorrect.', 'error')
return redirect('/view/view_preferences') return redirect(f'/{_PAGE}')
salt = bcrypt.gensalt() salt = bcrypt.gensalt()
hashed = bcrypt.hashpw(new_password.encode('utf-8'), salt) hashed = bcrypt.hashpw(new_password.encode('utf-8'), salt)
@ -88,4 +91,4 @@ def change_password():
_save_accounts(data) _save_accounts(data)
flash('Password changed successfully.', 'success') 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+", "client_requirement": "client_is_viewer+",
"items": [ "items": [
{ {
@ -21,7 +20,7 @@
"items": [ "items": [
{ {
"type": "form", "type": "form",
"action": "/action/save_preferences", "action": "/action/preferences/accountdetails_save",
"method": "post", "method": "post",
"items": [ "items": [
{ {
@ -46,7 +45,7 @@
"items": [ "items": [
{ {
"type": "button_primary", "type": "button_primary",
"action": "/action/save_preferences", "action": "/action/preferences/accountdetails_save",
"method": "post", "method": "post",
"text": "Save Preferences" "text": "Save Preferences"
} }
@ -62,7 +61,7 @@
"items": [ "items": [
{ {
"type": "form", "type": "form",
"action": "/action/change_password", "action": "/action/preferences/changepassword_save",
"method": "post", "method": "post",
"items": [ "items": [
{ {
@ -91,7 +90,7 @@
"items": [ "items": [
{ {
"type": "button_primary", "type": "button_primary",
"action": "/action/change_password", "action": "/action/preferences/changepassword_save",
"method": "post", "method": "post",
"text": "Change Password" "text": "Change Password"
} }

View file

@ -1,3 +1,4 @@
from pathlib import Path
import base64 import base64
import copy import copy
import ipaddress import ipaddress
@ -9,9 +10,10 @@ from config_utils import load_config, save_config_with_snapshot, verify_config_h
import sanitize import sanitize
import validation as validate import validation as validate
bp = Blueprint('vpn', __name__) _PAGE = Path(__file__).parent.name
bp = Blueprint(_PAGE, __name__)
_VIEW = '/view/view_vpn'
_MTU_MIN = 576 _MTU_MIN = 576
_MTU_MAX = 9000 _MTU_MAX = 9000
@ -121,7 +123,7 @@ def _conf_response(vlan, peer_name, peer_ip, private_key):
if not server_pub: if not server_pub:
flash('Peer saved. Run sudo python3 ~/routlin/core.py --apply to generate the server ' 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') '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) conf = _build_client_conf(vlan, peer_name, peer_ip, private_key, server_pub)
safe = re.sub(r'[^A-Za-z0-9_\-]', '_', peer_name) safe = re.sub(r'[^A-Za-z0-9_\-]', '_', peer_name)
resp = make_response(conf) resp = make_response(conf)
@ -130,9 +132,9 @@ def _conf_response(vlan, peer_name, peer_ip, private_key):
return resp return resp
@bp.route('/action/apply_vpn', methods=['POST']) @bp.route('/action/vpn/wireguard_apply', methods=['POST'])
@require_level('administrator') @require_level('administrator')
def apply_vpn(): def wireguard_apply():
listen_port_raw = request.form.get('vpn_listen_port', '').strip() listen_port_raw = request.form.get('vpn_listen_port', '').strip()
server_endpoint = validate.domainname(request.form.get('vpn_server_endpoint', '')) server_endpoint = validate.domainname(request.form.get('vpn_server_endpoint', ''))
domain = validate.domainname(request.form.get('vpn_domain', '')) domain = validate.domainname(request.form.get('vpn_domain', ''))
@ -141,39 +143,39 @@ def apply_vpn():
if not listen_port_raw: if not listen_port_raw:
flash('Listen port is required.', 'error') flash('Listen port is required.', 'error')
return redirect(_VIEW) return redirect(f'/{_PAGE}')
listen_port = validate.int_range(listen_port_raw, 1, 65535) listen_port = validate.int_range(listen_port_raw, 1, 65535)
if listen_port is None: if listen_port is None:
flash(f'"{listen_port_raw}" is not a valid port number (1-65535).', 'error') flash(f'"{listen_port_raw}" is not a valid port number (1-65535).', 'error')
return redirect(_VIEW) return redirect(f'/{_PAGE}')
dns_server = '' dns_server = ''
if dns_raw: if dns_raw:
dns_server = validate.ip(dns_raw) dns_server = validate.ip(dns_raw)
if not dns_server: if not dns_server:
flash(f'"{dns_raw}" is not a valid IP address for DNS server.', 'error') flash(f'"{dns_raw}" is not a valid IP address for DNS server.', 'error')
return redirect(_VIEW) return redirect(f'/{_PAGE}')
mtu = None mtu = None
if mtu_raw: if mtu_raw:
mtu = validate.int_range(mtu_raw, _MTU_MIN, _MTU_MAX) mtu = validate.int_range(mtu_raw, _MTU_MIN, _MTU_MAX)
if mtu is None: if mtu is None:
flash(f'"{mtu_raw}" is not a valid MTU (must be {_MTU_MIN}-{_MTU_MAX}).', 'error') 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(): if not _hash_ok():
return redirect(_VIEW) return redirect(f'/{_PAGE}')
cfg = load_config() cfg = load_config()
vpn_vlan = _wg_vlan(cfg) vpn_vlan = _wg_vlan(cfg)
if vpn_vlan is None: if vpn_vlan is None:
flash('No WireGuard VLAN found in configuration.', 'error') flash('No WireGuard VLAN found in configuration.', 'error')
return redirect(_VIEW) return redirect(f'/{_PAGE}')
for v in cfg.get('vlans', []): 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: 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') 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', {})) before_info = copy.deepcopy(vpn_vlan.get('vpn_information', {}))
info = vpn_vlan.setdefault('vpn_information', {}) info = vpn_vlan.setdefault('vpn_information', {})
@ -195,7 +197,7 @@ def apply_vpn():
if errors: if errors:
for msg in errors: for msg in errors:
flash(msg, 'error') flash(msg, 'error')
return redirect(_VIEW) return redirect(f'/{_PAGE}')
vlan_name = vpn_vlan['name'] vlan_name = vpn_vlan['name']
flash(save_config_with_snapshot( flash(save_config_with_snapshot(
@ -204,12 +206,12 @@ def apply_vpn():
before=before_info or None, after=copy.deepcopy(info), before=before_info or None, after=copy.deepcopy(info),
description=f'Updated VPN configuration for {vlan_name}', description=f'Updated VPN configuration for {vlan_name}',
), 'success') ), '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') @require_level('administrator')
def add_vpn_peer(): def addpeer_add():
peer_name = sanitize.name(request.form.get('peer_name', '')) peer_name = sanitize.name(request.form.get('peer_name', ''))
peer_vlan_nm = request.form.get('peer_vlan', '').strip() peer_vlan_nm = request.form.get('peer_vlan', '').strip()
peer_ip_raw = request.form.get('peer_ip', '').strip() peer_ip_raw = request.form.get('peer_ip', '').strip()
@ -218,41 +220,41 @@ def add_vpn_peer():
if not peer_name: if not peer_name:
flash('Peer name is required.', 'error') flash('Peer name is required.', 'error')
return redirect(_VIEW) return redirect(f'/{_PAGE}')
if not peer_vlan_nm: if not peer_vlan_nm:
flash('Assigned VLAN is required.', 'error') flash('Assigned VLAN is required.', 'error')
return redirect(_VIEW) return redirect(f'/{_PAGE}')
peer_ip = validate.ip(peer_ip_raw) peer_ip = validate.ip(peer_ip_raw)
if not peer_ip: if not peer_ip:
flash(f'"{peer_ip_raw}" is not a valid IP address.', 'error') flash(f'"{peer_ip_raw}" is not a valid IP address.', 'error')
return redirect(_VIEW) return redirect(f'/{_PAGE}')
if not _hash_ok(): if not _hash_ok():
return redirect(_VIEW) return redirect(f'/{_PAGE}')
cfg = load_config() cfg = load_config()
vpn_vlan = _wg_vlan_by_name(cfg, peer_vlan_nm) vpn_vlan = _wg_vlan_by_name(cfg, peer_vlan_nm)
if vpn_vlan is None: if vpn_vlan is None:
flash(f'VPN VLAN "{peer_vlan_nm}" not found.', 'error') flash(f'VPN VLAN "{peer_vlan_nm}" not found.', 'error')
return redirect(_VIEW) return redirect(f'/{_PAGE}')
try: try:
network = ipaddress.IPv4Network(f"{vpn_vlan['subnet']}/{vpn_vlan['subnet_mask']}", strict=False) network = ipaddress.IPv4Network(f"{vpn_vlan['subnet']}/{vpn_vlan['subnet_mask']}", strict=False)
if ipaddress.IPv4Address(peer_ip) not in network: 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') 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: except Exception:
pass pass
peers = vpn_vlan.setdefault('peers', []) peers = vpn_vlan.setdefault('peers', [])
if any(p.get('name') == peer_name for p in peers): if any(p.get('name') == peer_name for p in peers):
flash(f'A peer named "{peer_name}" already exists.', 'error') flash(f'A peer named "{peer_name}" already exists.', 'error')
return redirect(_VIEW) return redirect(f'/{_PAGE}')
for v in cfg.get('vlans', []): for v in cfg.get('vlans', []):
if not v.get('is_vpn'): if not v.get('is_vpn'):
continue continue
if any(p.get('ip') == peer_ip for p in v.get('peers', [])): 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') 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() private_key, public_key = _generate_wg_keypair()
entry = { entry = {
@ -267,7 +269,7 @@ def add_vpn_peer():
if errors: if errors:
for msg in errors: for msg in errors:
flash(msg, 'error') flash(msg, 'error')
return redirect(_VIEW) return redirect(f'/{_PAGE}')
save_config_with_snapshot( save_config_with_snapshot(
cfg, cfg,
@ -279,13 +281,13 @@ def add_vpn_peer():
return _conf_response(vpn_vlan, peer_name, peer_ip, private_key) 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') @require_level('administrator')
def edit_vpn_peer(): def peers_edit():
flat_idx = _row_index() flat_idx = _row_index()
if flat_idx is None: if flat_idx is None:
flash('Invalid request.', 'error') flash('Invalid request.', 'error')
return redirect(_VIEW) return redirect(f'/{_PAGE}')
peer_name = sanitize.name(request.form.get('name', '')) peer_name = sanitize.name(request.form.get('name', ''))
split_tunnel = request.form.get('split_tunnel') in ('true', '1', 'on', 'yes') split_tunnel = request.form.get('split_tunnel') in ('true', '1', 'on', 'yes')
@ -293,20 +295,20 @@ def edit_vpn_peer():
if not peer_name: if not peer_name:
flash('Peer name is required.', 'error') flash('Peer name is required.', 'error')
return redirect(_VIEW) return redirect(f'/{_PAGE}')
if not _hash_ok(): if not _hash_ok():
return redirect(_VIEW) return redirect(f'/{_PAGE}')
cfg = load_config() cfg = load_config()
vlan, peer_idx = _find_peer_by_flat_idx(cfg, flat_idx) vlan, peer_idx = _find_peer_by_flat_idx(cfg, flat_idx)
if vlan is None: if vlan is None:
flash('Peer not found.', 'error') flash('Peer not found.', 'error')
return redirect(_VIEW) return redirect(f'/{_PAGE}')
peers = vlan.get('peers', []) peers = vlan.get('peers', [])
if any(j != peer_idx and p.get('name') == peer_name for j, p in enumerate(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') 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')}) 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}) peers[peer_idx].update({'name': peer_name, 'split_tunnel': split_tunnel, 'enabled': enabled})
@ -314,7 +316,7 @@ def edit_vpn_peer():
if errors: if errors:
for msg in errors: for msg in errors:
flash(msg, 'error') flash(msg, 'error')
return redirect(_VIEW) return redirect(f'/{_PAGE}')
vlan_name = vlan['name'] vlan_name = vlan['name']
flash(save_config_with_snapshot( 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}, before=before, after={'name': peer_name, 'split_tunnel': split_tunnel, 'enabled': enabled},
description=f'Edited VPN peer: {peer_name}', description=f'Edited VPN peer: {peer_name}',
), 'success') ), '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') @require_level('administrator')
def toggle_vpn_peer(): def peers_toggle():
flat_idx = _row_index() flat_idx = _row_index()
if flat_idx is None: if flat_idx is None:
flash('Invalid request.', 'error') flash('Invalid request.', 'error')
return redirect(_VIEW) return redirect(f'/{_PAGE}')
if not _hash_ok(): if not _hash_ok():
return redirect(_VIEW) return redirect(f'/{_PAGE}')
cfg = load_config() cfg = load_config()
vlan, peer_idx = _find_peer_by_flat_idx(cfg, flat_idx) vlan, peer_idx = _find_peer_by_flat_idx(cfg, flat_idx)
if vlan is None: if vlan is None:
flash('Peer not found.', 'error') flash('Peer not found.', 'error')
return redirect(_VIEW) return redirect(f'/{_PAGE}')
peers = vlan.get('peers', []) peers = vlan.get('peers', [])
old_enabled = peers[peer_idx].get('enabled', True) old_enabled = peers[peer_idx].get('enabled', True)
@ -349,7 +351,7 @@ def toggle_vpn_peer():
if errors: if errors:
for msg in errors: for msg in errors:
flash(msg, 'error') flash(msg, 'error')
return redirect(_VIEW) return redirect(f'/{_PAGE}')
peer_name = peers[peer_idx]['name'] peer_name = peers[peer_idx]['name']
vlan_name = vlan['name'] vlan_name = vlan['name']
@ -360,24 +362,24 @@ def toggle_vpn_peer():
before={'enabled': old_enabled}, after={'enabled': not old_enabled}, before={'enabled': old_enabled}, after={'enabled': not old_enabled},
description=f'{action} VPN peer: {peer_name}', description=f'{action} VPN peer: {peer_name}',
), 'success') ), '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') @require_level('administrator')
def delete_vpn_peer(): def peers_delete():
flat_idx = _row_index() flat_idx = _row_index()
if flat_idx is None: if flat_idx is None:
flash('Invalid request.', 'error') flash('Invalid request.', 'error')
return redirect(_VIEW) return redirect(f'/{_PAGE}')
if not _hash_ok(): if not _hash_ok():
return redirect(_VIEW) return redirect(f'/{_PAGE}')
cfg = load_config() cfg = load_config()
vlan, peer_idx = _find_peer_by_flat_idx(cfg, flat_idx) vlan, peer_idx = _find_peer_by_flat_idx(cfg, flat_idx)
if vlan is None: if vlan is None:
flash('Peer not found.', 'error') flash('Peer not found.', 'error')
return redirect(_VIEW) return redirect(f'/{_PAGE}')
peers = vlan.get('peers', []) peers = vlan.get('peers', [])
removed = peers.pop(peer_idx) removed = peers.pop(peer_idx)
@ -385,7 +387,7 @@ def delete_vpn_peer():
if errors: if errors:
for msg in errors: for msg in errors:
flash(msg, 'error') flash(msg, 'error')
return redirect(_VIEW) return redirect(f'/{_PAGE}')
vlan_name = vlan['name'] vlan_name = vlan['name']
flash(save_config_with_snapshot( flash(save_config_with_snapshot(
@ -395,24 +397,24 @@ def delete_vpn_peer():
after=None, after=None,
description=f'Deleted VPN peer: {removed["name"]}', description=f'Deleted VPN peer: {removed["name"]}',
), 'success') ), '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') @require_level('administrator')
def regenerate_vpn_peer(): def peers_regenerate():
flat_idx = _row_index() flat_idx = _row_index()
if flat_idx is None: if flat_idx is None:
flash('Invalid request.', 'error') flash('Invalid request.', 'error')
return redirect(_VIEW) return redirect(f'/{_PAGE}')
if not _hash_ok(): if not _hash_ok():
return redirect(_VIEW) return redirect(f'/{_PAGE}')
cfg = load_config() cfg = load_config()
vlan, peer_idx = _find_peer_by_flat_idx(cfg, flat_idx) vlan, peer_idx = _find_peer_by_flat_idx(cfg, flat_idx)
if vlan is None: if vlan is None:
flash('Peer not found.', 'error') flash('Peer not found.', 'error')
return redirect(_VIEW) return redirect(f'/{_PAGE}')
private_key, public_key = _generate_wg_keypair() private_key, public_key = _generate_wg_keypair()
peer = vlan['peers'][peer_idx] peer = vlan['peers'][peer_idx]
@ -422,7 +424,7 @@ def regenerate_vpn_peer():
if errors: if errors:
for msg in errors: for msg in errors:
flash(msg, 'error') flash(msg, 'error')
return redirect(_VIEW) return redirect(f'/{_PAGE}')
vlan_name = vlan['name'] vlan_name = vlan['name']
save_config_with_snapshot( save_config_with_snapshot(

View file

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

View file

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