diff --git a/docker/routlin-dash/app/action_dnsserver.py b/docker/routlin-dash/app/action_dnsblocklists.py similarity index 87% rename from docker/routlin-dash/app/action_dnsserver.py rename to docker/routlin-dash/app/action_dnsblocklists.py index fef8fd1..792ddda 100644 --- a/docker/routlin-dash/app/action_dnsserver.py +++ b/docker/routlin-dash/app/action_dnsblocklists.py @@ -5,7 +5,7 @@ import re import sanitize import validation as validate -bp = Blueprint('action_dnsserver', __name__) +bp = Blueprint('action_dnsblocklists', __name__) VIEW = '/view/view_dns_server' @@ -51,9 +51,9 @@ def _parse_fields(): return {'name': name, 'description': description, 'format': fmt, 'url': url}, None -@bp.route('/action/dnsserver_tableblocklists_rowdelete', methods=['POST']) +@bp.route('/action/dnsblocklists_tableblocklists_rowdelete', methods=['POST']) @require_level('administrator') -def dnsserver_tableblocklists_rowdelete(): +def dnsblocklists_tableblocklists_rowdelete(): idx = _row_index() if idx is None: flash('Invalid request.', 'error') @@ -80,9 +80,9 @@ def dnsserver_tableblocklists_rowdelete(): return redirect(VIEW) -@bp.route('/action/dnsserver_tableblocklist_rowedit', methods=['POST']) +@bp.route('/action/dnsblocklists_tableblocklist_rowedit', methods=['POST']) @require_level('administrator') -def dnsserver_tableblocklist_rowedit(): +def dnsblocklists_tableblocklist_rowedit(): idx = _row_index() if idx is None: flash('Invalid request.', 'error') @@ -118,9 +118,9 @@ def dnsserver_tableblocklist_rowedit(): return redirect(VIEW) -@bp.route('/action/dnsserver_cardaddblocklist_add', methods=['POST']) +@bp.route('/action/dnsblocklists_cardaddblocklist_add', methods=['POST']) @require_level('administrator') -def dnsserver_cardaddblocklist_add(): +def dnsblocklists_cardaddblocklist_add(): fields, err = _parse_fields() if err: return redirect(VIEW) @@ -153,9 +153,9 @@ def dnsserver_cardaddblocklist_add(): return redirect(VIEW) -@bp.route('/action/dnsserver_cardblocklistrefresh_save', methods=['POST']) +@bp.route('/action/dnsblocklists_cardblocklistrefresh_save', methods=['POST']) @require_level('administrator') -def dnsserver_cardblocklistrefresh_save(): +def dnsblocklists_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', '')): @@ -170,8 +170,8 @@ def dnsserver_cardblocklistrefresh_save(): return redirect(VIEW) -@bp.route('/action/dnsserver_cardblocklistrefresh_refresh', methods=['POST']) +@bp.route('/action/dnsblocklists_cardblocklistrefresh_refresh', methods=['POST']) @require_level('administrator') -def dnsserver_cardblocklistrefresh_refresh(): +def dnsblocklists_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 index 4bc7a4c..60941ad 100644 --- a/docker/routlin-dash/app/action_general.py +++ b/docker/routlin-dash/app/action_general.py @@ -1,123 +1,14 @@ -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') diff --git a/docker/routlin-dash/app/action_apply_iface_config.py b/docker/routlin-dash/app/action_networkinterfaces.py similarity index 55% rename from docker/routlin-dash/app/action_apply_iface_config.py rename to docker/routlin-dash/app/action_networkinterfaces.py index 51f5a47..c82e237 100644 --- a/docker/routlin-dash/app/action_apply_iface_config.py +++ b/docker/routlin-dash/app/action_networkinterfaces.py @@ -2,32 +2,76 @@ import os from flask import Blueprint, request, redirect, flash from auth import require_level -from config_utils import verify_core_hash, queued_msg, queue_command +from config_utils import load_core, save_core, verify_core_hash, queued_msg, queue_command import sanitize import validation as validate -bp = Blueprint('action_apply_iface_config', __name__) +bp = Blueprint('action_networkinterfaces', __name__) -_VIEW = '/view/view_general' +_VIEW = '/view/view_network_interfaces' _EXCLUDE_PREFIXES = ('lo', 'wg', 'docker', 'br-', 'veth', 'tun', 'tap', 'ppp', 'virbr', 'podman', 'vnet', 'macvtap', 'fc-') -def _valid_interface(name): + +def _get_system_interfaces(): try: - return name in { + 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 False + return set() -@bp.route('/action/apply_iface_config', methods=['POST']) +def _valid_interface(name): + return name in _get_system_interfaces() + + +@bp.route('/action/networkinterfaces_cardnetworkinterface_save', methods=['POST']) @require_level('administrator') -def apply_iface_config(): +def networkinterfaces_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/networkinterfaces_cardinterfaceconfiguration_apply', methods=['POST']) +@require_level('administrator') +def networkinterfaces_cardinterfaceconfiguration_apply(): 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) diff --git a/docker/routlin-dash/app/action_upstreamdns.py b/docker/routlin-dash/app/action_upstreamdns.py new file mode 100644 index 0000000..c687e12 --- /dev/null +++ b/docker/routlin-dash/app/action_upstreamdns.py @@ -0,0 +1,61 @@ +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_upstreamdns', __name__) + +_VIEW = '/view/view_upstream_dns' + + +@bp.route('/action/upstreamdns_cardupstreamdns_save', methods=['POST']) +@require_level('administrator') +def upstreamdns_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) diff --git a/docker/routlin-dash/app/main.py b/docker/routlin-dash/app/main.py index 6236bb9..364a081 100644 --- a/docker/routlin-dash/app/main.py +++ b/docker/routlin-dash/app/main.py @@ -2,11 +2,13 @@ import os, json, sys from flask import Flask from view_page import bp as view_page_bp from action_general import bp as action_general_bp +from action_networkinterfaces import bp as action_networkinterfaces_bp +from action_upstreamdns import bp as action_upstreamdns_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_dnsserver import bp as action_dnsserver_bp +from action_dnsblocklists import bp as action_dnsblocklists_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 @@ -21,18 +23,19 @@ 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_iface_config import bp as action_apply_iface_config_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_general_bp) +app.register_blueprint(action_networkinterfaces_bp) +app.register_blueprint(action_upstreamdns_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_dnsserver_bp) +app.register_blueprint(action_dnsblocklists_bp) app.register_blueprint(action_apply_vlans_bp) app.register_blueprint(action_apply_inter_vlan_bp) app.register_blueprint(action_apply_port_forwarding_bp) @@ -47,7 +50,6 @@ 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_iface_config_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 957f567..e206712 100644 --- a/docker/routlin-dash/app/view_page.py +++ b/docker/routlin-dash/app/view_page.py @@ -207,6 +207,26 @@ def _fmt_timestamp(ts): except Exception: return '-' +def _relative_time(ts): + try: + diff = int(datetime.now(tz=timezone.utc).timestamp()) - int(ts) + if diff < 60: + n = max(0, diff) + return f'{n} second{"s" if n != 1 else ""} ago' + m = diff // 60 + if m < 60: + return f'{m} minute{"s" if m != 1 else ""} ago' + h = m // 60 + if h < 24: + return f'{h} hour{"s" if h != 1 else ""} ago' + d = h // 24 + if d < 365: + return f'{d} day{"s" if d != 1 else ""} ago' + y = d // 365 + return f'{y} year{"s" if y != 1 else ""} ago' + except Exception: + return '' + def _live_vpn_sessions(): rows = [] out = _run('wg show all dump 2>/dev/null') @@ -408,8 +428,9 @@ def _blocklist_stats_html(core): try: with open(bl_path) as f: entries = sum(1 for _ in f) + mtime = int(os.path.getmtime(bl_path)) size_str = _fmt_bytes(os.path.getsize(bl_path)) - last_refreshed = _fmt_timestamp(int(os.path.getmtime(bl_path))) + last_refreshed = f'{_fmt_timestamp(mtime)} ({_relative_time(mtime)})' except Exception: entries, size_str, last_refreshed = '-', '-', 'Never' rows += (f'