diff --git a/docker/routlin-dash/app/action_apply_general.py b/docker/routlin-dash/app/action_apply_general.py deleted file mode 100644 index 2be86a0..0000000 --- a/docker/routlin-dash/app/action_apply_general.py +++ /dev/null @@ -1,44 +0,0 @@ -from flask import Blueprint, request, redirect, flash -from auth import require_level -from config_utils import load_core, save_core, verify_core_hash, queued_msg -import sanitize -import validation as validate - -bp = Blueprint('action_apply_general', __name__) - - -@bp.route('/action/apply_general', methods=['POST']) -@require_level('administrator') -def apply_general(): - log_max_kb_raw = request.form.get('log_max_kb', '').strip() - log_errors_only = 'log_errors_only' in request.form - dnsmasq_log_queries = 'dnsmasq_log_queries' in request.form - daily_execute_time = sanitize.time_24h(request.form.get('daily_execute_time_24hr_local', '')) - apply_on_save = 'apply_on_save' in request.form - - log_max_kb = validate.int_range(log_max_kb_raw, 64, None) - if log_max_kb is None: - flash('Max Log Size must be a number >= 64.', 'error') - return redirect('/view/view_general') - - if not verify_core_hash(request.form.get('config_hash', '')): - flash('Configuration was modified by another session. Please refresh and try again.', 'error') - return redirect('/view/view_general') - - core = load_core() - core.setdefault('general', {}).update({ - 'log_max_kb': log_max_kb, - 'log_errors_only': log_errors_only, - 'dnsmasq_log_queries': dnsmasq_log_queries, - 'daily_execute_time_24hr_local': daily_execute_time, - 'apply_on_save': apply_on_save, - }) - errors = validate.validate_config(core) - if errors: - for msg in errors: - flash(msg, 'error') - return redirect('/view/view_general') - save_core(core) - - flash(queued_msg('core apply'), 'success') - return redirect('/view/view_general') diff --git a/docker/routlin-dash/app/action_apply_interface.py b/docker/routlin-dash/app/action_apply_interface.py deleted file mode 100644 index 38ccf63..0000000 --- a/docker/routlin-dash/app/action_apply_interface.py +++ /dev/null @@ -1,65 +0,0 @@ -import os - -from flask import Blueprint, request, redirect, flash -from auth import require_level -from config_utils import load_core, save_core, verify_core_hash, queued_msg -import sanitize -import validation as validate - -bp = Blueprint('action_apply_interface', __name__) - -_VIEW = '/view/view_general' - - -_EXCLUDE_PREFIXES = ('lo', 'wg', 'docker', 'br-', 'veth', - 'tun', 'tap', 'ppp', 'virbr', - 'podman', 'vnet', 'macvtap', 'fc-') - -def _get_system_interfaces(): - try: - return { - n for n in os.listdir('/sys/class/net') - if not n.startswith(_EXCLUDE_PREFIXES) - and os.path.exists(f'/sys/class/net/{n}/device') - } - except Exception: - return set() - - -@bp.route('/action/apply_interface', methods=['POST']) -@require_level('administrator') -def apply_interface(): - wan = sanitize.interface_name(request.form.get('wan_interface', '')) - lan = sanitize.interface_name(request.form.get('lan_interface', '')) - - if not wan or not lan: - flash('Both WAN and LAN interfaces are required.', 'error') - return redirect(_VIEW) - - if wan == lan: - flash('WAN and LAN interfaces must be different.', 'error') - return redirect(_VIEW) - - if not verify_core_hash(request.form.get('config_hash', '')): - flash('Configuration was modified by another session. Please refresh and try again.', 'error') - return redirect(_VIEW) - - available = _get_system_interfaces() - for iface in (wan, lan): - if available and iface not in available: - flash(f"Interface '{iface}' does not exist on this system.", 'error') - return redirect(_VIEW) - - core = load_core() - gen = core.setdefault('general', {}) - gen['wan_interface'] = wan - gen['lan_interface'] = lan - errors = validate.validate_config(core) - if errors: - for msg in errors: - flash(msg, 'error') - return redirect(_VIEW) - save_core(core) - - flash(queued_msg('core apply'), 'success') - return redirect(_VIEW) diff --git a/docker/routlin-dash/app/action_apply_pending.py b/docker/routlin-dash/app/action_apply_pending.py deleted file mode 100644 index c08f1cc..0000000 --- a/docker/routlin-dash/app/action_apply_pending.py +++ /dev/null @@ -1,26 +0,0 @@ -from flask import Blueprint, redirect, flash -from auth import require_level -from config_utils import (flush_pending_to_queue, get_dashboard_pending, - _is_locked, _format_timing, _seconds_until_next_run) - -bp = Blueprint('action_apply_pending', __name__) - - -@bp.route('/action/apply_pending', methods=['POST']) -@require_level('administrator') -def apply_pending(): - items = get_dashboard_pending() - if not items: - flash('No pending changes to apply.', 'info') - return redirect('/view/view_general') - flush_pending_to_queue() - if _is_locked(): - msg = 'Changes queued. They are being applied now.' - else: - timing = _format_timing(_seconds_until_next_run()) - if timing: - msg = f'Changes queued. They will be applied {timing}.' - else: - msg = 'Changes queued. The processing service is not running.' - flash(msg, 'success') - return redirect('/view/view_general') diff --git a/docker/routlin-dash/app/action_apply_upstream_dns.py b/docker/routlin-dash/app/action_apply_upstream_dns.py deleted file mode 100644 index 757205f..0000000 --- a/docker/routlin-dash/app/action_apply_upstream_dns.py +++ /dev/null @@ -1,59 +0,0 @@ -from flask import Blueprint, request, redirect, flash -from auth import require_level -from config_utils import load_core, save_core, verify_core_hash, queued_msg -import sanitize -import validation as validate - -bp = Blueprint('action_apply_upstream_dns', __name__) - - -@bp.route('/action/apply_upstream_dns', methods=['POST']) -@require_level('administrator') -def apply_upstream_dns(): - strict_order = 'strict_order' in request.form - cache_size_raw = request.form.get('cache_size', '').strip() - submitted = request.form.getlist('upstream_servers') - - for s in submitted: - if not s.strip(): - flash('Remove blank server entries before saving.', 'error') - return redirect('/view/view_upstream_dns') - - upstream_servers = [] - for s in submitted: - clean = sanitize.ip(s.strip()) - if not clean: - flash(f"'{s.strip()}' is not a valid IP address.", 'error') - return redirect('/view/view_upstream_dns') - upstream_servers.append(clean) - - cache_size = validate.int_range(cache_size_raw, 0, None) - if cache_size is None: - flash('Cache Size must be a non-negative integer.', 'error') - return redirect('/view/view_upstream_dns') - - if not verify_core_hash(request.form.get('config_hash', '')): - flash('Configuration was modified by another session. Please refresh and try again.', 'error') - return redirect('/view/view_upstream_dns') - - core = load_core() - current = core.get('upstream_dns', {}) - if (strict_order == bool(current.get('strict_order', False)) and - cache_size == int(current.get('cache_size', 0)) and - upstream_servers == current.get('upstream_servers', [])): - flash('No changes detected.', 'info') - return redirect('/view/view_upstream_dns') - - core.setdefault('upstream_dns', {}).update({ - 'strict_order': strict_order, - 'cache_size': cache_size, - 'upstream_servers': upstream_servers, - }) - errors = validate.validate_config(core) - if errors: - for msg in errors: - flash(msg, 'error') - return redirect('/view/view_upstream_dns') - save_core(core) - flash(queued_msg('core apply'), 'success') - return redirect('/view/view_upstream_dns') diff --git a/docker/routlin-dash/app/action_apply_blocklists.py b/docker/routlin-dash/app/action_dnsserver.py similarity index 77% rename from docker/routlin-dash/app/action_apply_blocklists.py rename to docker/routlin-dash/app/action_dnsserver.py index 85b79a1..fef8fd1 100644 --- a/docker/routlin-dash/app/action_apply_blocklists.py +++ b/docker/routlin-dash/app/action_dnsserver.py @@ -5,9 +5,9 @@ import re import sanitize import validation as validate -bp = Blueprint('action_apply_blocklists', __name__) +bp = Blueprint('action_dnsserver', __name__) -VIEW = '/view/view_blocklists' +VIEW = '/view/view_dns_server' _VALID_FORMATS_STR = ', '.join(sorted(validate.VALID_BLOCKLIST_FORMATS)) @@ -32,7 +32,6 @@ def _save_as_from_name(name): def _parse_fields(): - """Parse and validate add/edit form fields. Returns (fields_dict, None) or (None, already_flashed).""" name = sanitize.name(request.form.get('name', '')) description = sanitize.description(request.form.get('description', '')) fmt = sanitize.filtervalue(request.form.get('format', ''), validate.VALID_BLOCKLIST_FORMATS) @@ -52,30 +51,24 @@ def _parse_fields(): return {'name': name, 'description': description, 'format': fmt, 'url': url}, None -@bp.route('/action/add_blocklist', methods=['POST']) +@bp.route('/action/dnsserver_tableblocklists_rowdelete', methods=['POST']) @require_level('administrator') -def add_blocklist(): - fields, err = _parse_fields() - if err: +def dnsserver_tableblocklists_rowdelete(): + idx = _row_index() + if idx is None: + flash('Invalid request.', 'error') return redirect(VIEW) if not _hash_ok(): return redirect(VIEW) - core = load_core() - blocklists = core.setdefault('blocklists', []) - - if any(b.get('name', '').lower() == fields['name'].lower() for b in blocklists): - flash('The configuration has not been saved because a blocklist with that name already exists.', 'error') + core = load_core() + items = core.get('blocklists', []) + if idx < 0 or idx >= len(items): + flash('Entry not found.', 'error') return redirect(VIEW) - blocklists.append({ - 'name': fields['name'], - 'description': fields['description'], - 'format': fields['format'], - 'url': fields['url'], - 'save_as': _save_as_from_name(fields['name']), - }) + items.pop(idx) errors = validate.validate_config(core) if errors: for msg in errors: @@ -87,10 +80,9 @@ def add_blocklist(): return redirect(VIEW) - -@bp.route('/action/edit_blocklist', methods=['POST']) +@bp.route('/action/dnsserver_tableblocklist_rowedit', methods=['POST']) @require_level('administrator') -def edit_blocklist(): +def dnsserver_tableblocklist_rowedit(): idx = _row_index() if idx is None: flash('Invalid request.', 'error') @@ -126,24 +118,30 @@ def edit_blocklist(): return redirect(VIEW) -@bp.route('/action/delete_blocklist', methods=['POST']) +@bp.route('/action/dnsserver_cardaddblocklist_add', methods=['POST']) @require_level('administrator') -def delete_blocklist(): - idx = _row_index() - if idx is None: - flash('Invalid request.', 'error') +def dnsserver_cardaddblocklist_add(): + fields, err = _parse_fields() + if err: return redirect(VIEW) if not _hash_ok(): return redirect(VIEW) - core = load_core() - items = core.get('blocklists', []) - if idx < 0 or idx >= len(items): - flash('Entry not found.', 'error') + core = load_core() + blocklists = core.setdefault('blocklists', []) + + if any(b.get('name', '').lower() == fields['name'].lower() for b in blocklists): + flash('The configuration has not been saved because a blocklist with that name already exists.', 'error') return redirect(VIEW) - removed = items.pop(idx) + blocklists.append({ + 'name': fields['name'], + 'description': fields['description'], + 'format': fields['format'], + 'url': fields['url'], + 'save_as': _save_as_from_name(fields['name']), + }) errors = validate.validate_config(core) if errors: for msg in errors: @@ -155,8 +153,25 @@ def delete_blocklist(): return redirect(VIEW) -@bp.route('/action/update_blocklists', methods=['POST']) +@bp.route('/action/dnsserver_cardblocklistrefresh_save', methods=['POST']) @require_level('administrator') -def update_blocklists(): +def dnsserver_cardblocklistrefresh_save(): + daily_execute_time = sanitize.time_24h(request.form.get('daily_execute_time_24hr_local', '')) + + if not verify_core_hash(request.form.get('config_hash', '')): + flash('Configuration was modified by another session. Please refresh and try again.', 'error') + return redirect(VIEW) + + core = load_core() + core.setdefault('general', {})['daily_execute_time_24hr_local'] = daily_execute_time + save_core(core) + + flash(queued_msg('core apply'), 'success') + return redirect(VIEW) + + +@bp.route('/action/dnsserver_cardblocklistrefresh_refresh', methods=['POST']) +@require_level('administrator') +def dnsserver_cardblocklistrefresh_refresh(): flash(queued_msg('core update-blocklists'), 'success') return redirect(VIEW) diff --git a/docker/routlin-dash/app/action_general.py b/docker/routlin-dash/app/action_general.py new file mode 100644 index 0000000..4bc7a4c --- /dev/null +++ b/docker/routlin-dash/app/action_general.py @@ -0,0 +1,194 @@ +import os + +from flask import Blueprint, request, redirect, flash +from auth import require_level +from config_utils import (load_core, save_core, verify_core_hash, queued_msg, + flush_pending_to_queue, get_dashboard_pending, + _is_locked, _format_timing, _seconds_until_next_run) +import sanitize +import validation as validate + +bp = Blueprint('action_general', __name__) + +_VIEW = '/view/view_general' + +_EXCLUDE_PREFIXES = ('lo', 'wg', 'docker', 'br-', 'veth', + 'tun', 'tap', 'ppp', 'virbr', + 'podman', 'vnet', 'macvtap', 'fc-') + + +def _get_system_interfaces(): + try: + return { + n for n in os.listdir('/sys/class/net') + if not n.startswith(_EXCLUDE_PREFIXES) + and os.path.exists(f'/sys/class/net/{n}/device') + } + except Exception: + return set() + + +@bp.route('/action/general_cardnetworkinterface_save', methods=['POST']) +@require_level('administrator') +def general_cardnetworkinterface_save(): + wan = sanitize.interface_name(request.form.get('wan_interface', '')) + lan = sanitize.interface_name(request.form.get('lan_interface', '')) + + if not wan or not lan: + flash('Both WAN and LAN interfaces are required.', 'error') + return redirect(_VIEW) + + if wan == lan: + flash('WAN and LAN interfaces must be different.', 'error') + return redirect(_VIEW) + + if not verify_core_hash(request.form.get('config_hash', '')): + flash('Configuration was modified by another session. Please refresh and try again.', 'error') + return redirect(_VIEW) + + available = _get_system_interfaces() + for iface in (wan, lan): + if available and iface not in available: + flash(f"Interface '{iface}' does not exist on this system.", 'error') + return redirect(_VIEW) + + core = load_core() + gen = core.setdefault('general', {}) + gen['wan_interface'] = wan + gen['lan_interface'] = lan + errors = validate.validate_config(core) + if errors: + for msg in errors: + flash(msg, 'error') + return redirect(_VIEW) + save_core(core) + + flash(queued_msg('core apply'), 'success') + return redirect(_VIEW) + + +@bp.route('/action/general_cardupstreamdns_save', methods=['POST']) +@require_level('administrator') +def general_cardupstreamdns_save(): + strict_order = 'strict_order' in request.form + cache_size_raw = request.form.get('cache_size', '').strip() + submitted = request.form.getlist('upstream_servers') + + for s in submitted: + if not s.strip(): + flash('Remove blank server entries before saving.', 'error') + return redirect(_VIEW) + + upstream_servers = [] + for s in submitted: + clean = sanitize.ip(s.strip()) + if not clean: + flash(f"'{s.strip()}' is not a valid IP address.", 'error') + return redirect(_VIEW) + upstream_servers.append(clean) + + cache_size = validate.int_range(cache_size_raw, 0, None) + if cache_size is None: + flash('Cache Size must be a non-negative integer.', 'error') + return redirect(_VIEW) + + if not verify_core_hash(request.form.get('config_hash', '')): + flash('Configuration was modified by another session. Please refresh and try again.', 'error') + return redirect(_VIEW) + + core = load_core() + current = core.get('upstream_dns', {}) + if (strict_order == bool(current.get('strict_order', False)) and + cache_size == int(current.get('cache_size', 0)) and + upstream_servers == current.get('upstream_servers', [])): + flash('No changes detected.', 'info') + return redirect(_VIEW) + + core.setdefault('upstream_dns', {}).update({ + 'strict_order': strict_order, + 'cache_size': cache_size, + 'upstream_servers': upstream_servers, + }) + errors = validate.validate_config(core) + if errors: + for msg in errors: + flash(msg, 'error') + return redirect(_VIEW) + save_core(core) + flash(queued_msg('core apply'), 'success') + return redirect(_VIEW) + + +@bp.route('/action/general_cardlogging_save', methods=['POST']) +@require_level('administrator') +def general_cardlogging_save(): + log_max_kb_raw = request.form.get('log_max_kb', '').strip() + log_errors_only = 'log_errors_only' in request.form + dnsmasq_log_queries = 'dnsmasq_log_queries' in request.form + + log_max_kb = validate.int_range(log_max_kb_raw, 64, None) + if log_max_kb is None: + flash('Max Log Size must be a number >= 64.', 'error') + return redirect(_VIEW) + + if not verify_core_hash(request.form.get('config_hash', '')): + flash('Configuration was modified by another session. Please refresh and try again.', 'error') + return redirect(_VIEW) + + core = load_core() + core.setdefault('general', {}).update({ + 'log_max_kb': log_max_kb, + 'log_errors_only': log_errors_only, + 'dnsmasq_log_queries': dnsmasq_log_queries, + }) + errors = validate.validate_config(core) + if errors: + for msg in errors: + flash(msg, 'error') + return redirect(_VIEW) + save_core(core) + + flash(queued_msg('core apply'), 'success') + return redirect(_VIEW) + + +@bp.route('/action/general_cardpendingchanges_save', methods=['POST']) +@require_level('administrator') +def general_cardpendingchanges_save(): + if not verify_core_hash(request.form.get('config_hash', '')): + flash('Configuration was modified by another session. Please refresh and try again.', 'error') + return redirect(_VIEW) + + core = load_core() + core.setdefault('general', {})['apply_on_save'] = 'apply_on_save' in request.form + save_core(core) + + flash(queued_msg('core apply'), 'success') + return redirect(_VIEW) + + +@bp.route('/action/general_cardpendingchanges_applyselected', methods=['POST']) +@require_level('administrator') +def general_cardpendingchanges_applyselected(): + items = get_dashboard_pending() + if not items: + flash('No pending changes to apply.', 'info') + return redirect(_VIEW) + flush_pending_to_queue() + if _is_locked(): + msg = 'Changes queued. They are being applied now.' + else: + timing = _format_timing(_seconds_until_next_run()) + if timing: + msg = f'Changes queued. They will be applied {timing}.' + else: + msg = 'Changes queued. The processing service is not running.' + flash(msg, 'success') + return redirect(_VIEW) + + +@bp.route('/action/general_cardpendingchanges_deleteselected', methods=['POST']) +@require_level('administrator') +def general_cardpendingchanges_deleteselected(): + flash('Not yet implemented.', 'info') + return redirect(_VIEW) diff --git a/docker/routlin-dash/app/main.py b/docker/routlin-dash/app/main.py index 8b5eeea..6236bb9 100644 --- a/docker/routlin-dash/app/main.py +++ b/docker/routlin-dash/app/main.py @@ -1,13 +1,12 @@ import os, json, sys from flask import Flask from view_page import bp as view_page_bp -from action_apply_general import bp as action_apply_general_bp -from action_apply_upstream_dns import bp as action_apply_upstream_dns_bp +from action_general import bp as action_general_bp from action_apply_mdns import bp as action_apply_mdns_bp from action_apply_vpn import bp as action_apply_vpn_bp from action_apply_banned_ips import bp as action_apply_banned_ips_bp from action_apply_host_overrides import bp as action_apply_host_overrides_bp -from action_apply_blocklists import bp as action_apply_blocklists_bp +from action_dnsserver import bp as action_dnsserver_bp from action_apply_vlans import bp as action_apply_vlans_bp from action_apply_inter_vlan import bp as action_apply_inter_vlan_bp from action_apply_port_forwarding import bp as action_apply_port_forwarding_bp @@ -22,21 +21,18 @@ from action_save_preferences import bp as action_save_preferences_bp from action_change_password import bp as action_change_password_bp from action_clear_ddns_log import bp as action_clear_ddns_log_bp from action_apply_ddns_providers import bp as action_apply_ddns_providers_bp -from action_apply_interface import bp as action_apply_interface_bp from action_apply_iface_config import bp as action_apply_iface_config_bp -from action_apply_pending import bp as action_apply_pending_bp from api_apply_status import bp as api_apply_status_bp app = Flask(__name__) app.secret_key = os.environ.get('SECRET_KEY', os.urandom(24)) app.register_blueprint(view_page_bp) -app.register_blueprint(action_apply_general_bp) -app.register_blueprint(action_apply_upstream_dns_bp) +app.register_blueprint(action_general_bp) app.register_blueprint(action_apply_mdns_bp) app.register_blueprint(action_apply_vpn_bp) app.register_blueprint(action_apply_banned_ips_bp) app.register_blueprint(action_apply_host_overrides_bp) -app.register_blueprint(action_apply_blocklists_bp) +app.register_blueprint(action_dnsserver_bp) app.register_blueprint(action_apply_vlans_bp) app.register_blueprint(action_apply_inter_vlan_bp) app.register_blueprint(action_apply_port_forwarding_bp) @@ -51,9 +47,7 @@ app.register_blueprint(action_save_preferences_bp) app.register_blueprint(action_change_password_bp) app.register_blueprint(action_clear_ddns_log_bp) app.register_blueprint(action_apply_ddns_providers_bp) -app.register_blueprint(action_apply_interface_bp) app.register_blueprint(action_apply_iface_config_bp) -app.register_blueprint(action_apply_pending_bp) app.register_blueprint(api_apply_status_bp) def _seed_initial_account(): diff --git a/docker/routlin-dash/app/view_page.py b/docker/routlin-dash/app/view_page.py index 37fedcd..d71f8d9 100644 --- a/docker/routlin-dash/app/view_page.py +++ b/docker/routlin-dash/app/view_page.py @@ -398,6 +398,41 @@ def _bl_last_update(): except Exception: return '-' +def _blocklist_stats_html(core): + bl_dir = f'{CONFIGS_DIR}/blocklists' + rows = '' + for bl in core.get('blocklists', []): + name = e(bl.get('name', '')) + save_as = bl.get('save_as', '') + bl_path = f'{bl_dir}/{save_as}' if save_as else '' + try: + with open(bl_path) as f: + entries = sum(1 for _ in f) + size_str = _fmt_bytes(os.path.getsize(bl_path)) + last_refreshed = _fmt_timestamp(int(os.path.getmtime(bl_path))) + except Exception: + entries, size_str, last_refreshed = '-', '-', 'Never' + rows += (f'
| Blocklist | ' + 'Entries | ' + 'Size | ' + 'Last Refreshed | ' + '
|---|