From c6d2ded52575181a117d708617bbaddb1f3023e6 Mon Sep 17 00:00:00 2001 From: Matthew Grotke Date: Mon, 25 May 2026 02:22:21 -0400 Subject: [PATCH] Development --- docker/routlin-dash/app/action_apply_vlans.py | 40 +++-- docker/routlin-dash/app/action_dnsblocking.py | 6 +- docker/routlin-dash/app/action_upstreamdns.py | 43 +++-- docker/routlin-dash/app/view_page.py | 14 +- docker/routlin-dash/data/navbar_content.json | 4 +- docker/routlin-dash/data/page_content.json | 168 +++++++++++------- routlin/core.json | 33 ++-- routlin/core.py | 51 +----- 8 files changed, 188 insertions(+), 171 deletions(-) diff --git a/docker/routlin-dash/app/action_apply_vlans.py b/docker/routlin-dash/app/action_apply_vlans.py index 2b02821..a06869a 100644 --- a/docker/routlin-dash/app/action_apply_vlans.py +++ b/docker/routlin-dash/app/action_apply_vlans.py @@ -30,8 +30,9 @@ def add_vlan(): is_vpn = 'is_vpn' in request.form subnet = sanitize.ip(request.form.get('subnet', '')) subnet_mask = sanitize.subnet_mask(request.form.get('subnet_mask', '')) - radius_default = 'radius_default' in request.form - mdns_reflection = 'mdns_reflection' in request.form + radius_default = 'radius_default' in request.form + mdns_reflection = 'mdns_reflection' in request.form + dnsmasq_log_queries = 'dnsmasq_log_queries' in request.form use_blocklists = sanitize.filterlist(request.form.getlist('use_blocklists'), {b.get('name') for b in load_core().get('dns_blocking', {}).get('blocklists', [])}) @@ -67,13 +68,14 @@ def add_vlan(): return redirect(VIEW) entry = { - 'name': name, - 'is_vpn': is_vpn, - 'subnet': subnet, - 'subnet_mask': subnet_mask, - 'use_blocklists': use_blocklists, - 'radius_default': radius_default, - 'mdns_reflection': mdns_reflection, + 'name': name, + 'is_vpn': is_vpn, + 'subnet': subnet, + 'subnet_mask': subnet_mask, + 'dnsmasq_log_queries': dnsmasq_log_queries, + 'use_blocklists': use_blocklists, + 'radius_default': radius_default, + 'mdns_reflection': mdns_reflection, } if is_vpn: entry['peers'] = [] @@ -101,8 +103,9 @@ def edit_vlan(): name = sanitize.name(request.form.get('name', '')) subnet = sanitize.ip(request.form.get('subnet', '')) - radius_default = 'radius_default' in request.form - mdns_reflection = 'mdns_reflection' in request.form + radius_default = 'radius_default' in request.form + mdns_reflection = 'mdns_reflection' in request.form + dnsmasq_log_queries = 'dnsmasq_log_queries' in request.form use_blocklists = sanitize.filterlist(request.form.getlist('use_blocklists'), {b.get('name') for b in load_core().get('dns_blocking', {}).get('blocklists', [])}) @@ -162,13 +165,14 @@ def edit_vlan(): return redirect(VIEW) existing.update({ - 'name': name, - 'is_vpn': is_vpn, - 'subnet': subnet, - 'subnet_mask': final_mask, - 'radius_default': radius_default, - 'mdns_reflection': mdns_reflection, - 'use_blocklists': use_blocklists, + 'name': name, + 'is_vpn': is_vpn, + 'subnet': subnet, + 'subnet_mask': final_mask, + 'dnsmasq_log_queries': dnsmasq_log_queries, + 'radius_default': radius_default, + 'mdns_reflection': mdns_reflection, + 'use_blocklists': use_blocklists, }) errors = validate.validate_config(core) if errors: diff --git a/docker/routlin-dash/app/action_dnsblocking.py b/docker/routlin-dash/app/action_dnsblocking.py index 5bee78a..f5266d8 100644 --- a/docker/routlin-dash/app/action_dnsblocking.py +++ b/docker/routlin-dash/app/action_dnsblocking.py @@ -184,9 +184,8 @@ def dnsblocking_cardblocklistrefresh_refreshnow(): @bp.route('/action/dnsblocking_cardlogging_save', methods=['POST']) @require_level('administrator') def dnsblocking_cardlogging_save(): - log_max_kb_raw = request.form.get('log_max_kb', '').strip() - log_errors_only = 'log_errors_only' in request.form - dnsmasq_log_queries = 'dnsmasq_log_queries' in request.form + log_max_kb_raw = request.form.get('log_max_kb', '').strip() + log_errors_only = 'log_errors_only' in request.form log_max_kb = validate.int_range(log_max_kb_raw, 64, None) if log_max_kb is None: @@ -202,7 +201,6 @@ def dnsblocking_cardlogging_save(): 'log_max_kb': log_max_kb, 'log_errors_only': log_errors_only, }) - core.setdefault('network_interfaces', {})['dnsmasq_log_queries'] = dnsmasq_log_queries errors = validate.validate_config(core) if errors: for msg in errors: diff --git a/docker/routlin-dash/app/action_upstreamdns.py b/docker/routlin-dash/app/action_upstreamdns.py index c687e12..c5ca1e3 100644 --- a/docker/routlin-dash/app/action_upstreamdns.py +++ b/docker/routlin-dash/app/action_upstreamdns.py @@ -12,9 +12,8 @@ _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') + strict_order = 'strict_order' in request.form + submitted = request.form.getlist('upstream_servers') for s in submitted: if not s.strip(): @@ -29,26 +28,19 @@ def upstreamdns_cardupstreamdns_save(): 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 + if (strict_order == bool(current.get('strict_order', False)) 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) @@ -59,3 +51,32 @@ def upstreamdns_cardupstreamdns_save(): save_core(core) flash(queued_msg('core apply'), 'success') return redirect(_VIEW) + + +@bp.route('/action/upstreamdns_cardforwardingdnsservice_save', methods=['POST']) +@require_level('administrator') +def upstreamdns_cardforwardingdnsservice_save(): + cache_size = validate.int_range(request.form.get('cache_size', '').strip(), 0, None) + if cache_size is None: + flash('Cache Size must be a non-negative integer.', 'error') + return redirect(_VIEW) + + if not verify_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 cache_size == int(current.get('cache_size', 0)): + flash('No changes detected.', 'info') + return redirect(_VIEW) + + core.setdefault('upstream_dns', {})['cache_size'] = cache_size + 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/view_page.py b/docker/routlin-dash/app/view_page.py index 0c16670..bca5e68 100644 --- a/docker/routlin-dash/app/view_page.py +++ b/docker/routlin-dash/app/view_page.py @@ -291,7 +291,7 @@ def _config_datasource(name): bl_desc = {b['name']: b.get('description', b['name']) for b in core.get('dns_blocking', {}).get('blocklists', []) if 'name' in b} rows = [] for v in sorted(vlans, key=lambda x: validate.derive_vlan_id(x.get('subnet', ''), x.get('subnet_mask', 24)) or 0): - row = {k: v.get(k) for k in ('name', 'subnet', 'subnet_mask', 'radius_default', 'mdns_reflection', 'is_vpn')} + row = {k: v.get(k) for k in ('name', 'subnet', 'subnet_mask', 'radius_default', 'mdns_reflection', 'is_vpn', 'dnsmasq_log_queries')} row['vlan_id'] = validate.derive_vlan_id(v.get('subnet', ''), v.get('subnet_mask', 24)) row['interface'] = _resolve_iface(v, core) row['use_blocklists'] = json.dumps([ @@ -587,9 +587,8 @@ def collect_tokens(): ) tokens['NETWORK_INTERFACE_STATS_SPEED_PAD'] = str(max(max_speed_len, len('Speed'))) - tokens['GENERAL_LOG_ERRORS_ONLY'] = 'true' if dns_blk_gen.get('log_errors_only') else 'false' - tokens['GENERAL_DNSMASQ_LOG_QUERIES'] = 'true' if net.get('dnsmasq_log_queries') else 'false' - tokens['GENERAL_DAILY_EXECUTE_TIME'] = str(dns_blk_gen.get('daily_execute_time_24hr_local', '-')) + tokens['GENERAL_LOG_ERRORS_ONLY'] = 'true' if dns_blk_gen.get('log_errors_only') else 'false' + tokens['GENERAL_DAILY_EXECUTE_TIME'] = str(dns_blk_gen.get('daily_execute_time_24hr_local', '-')) tokens['GENERAL_APPLY_ON_SAVE'] = 'true' if net.get('apply_on_save', True) else 'false' pending_items = get_dashboard_pending() @@ -1431,6 +1430,13 @@ def _render_table_cell(value, render_fn, col_class='', field='', row_idx=None, inner = 'Disabled' return f'{td_open}{inner}' + if render_fn == 'badge_recording_on_off': + if str(value).lower() in ('true', '1', 'yes'): + inner = 'Recording On' + else: + inner = 'Recording Off' + return f'{td_open}{inner}' + if render_fn == 'badge_toggle': if str(value).lower() in ('true', '1', 'yes', 'enabled'): label = 'Enabled'; badge_cls = 'badge-enabled' diff --git a/docker/routlin-dash/data/navbar_content.json b/docker/routlin-dash/data/navbar_content.json index 1985ba2..1844a62 100644 --- a/docker/routlin-dash/data/navbar_content.json +++ b/docker/routlin-dash/data/navbar_content.json @@ -13,14 +13,14 @@ "items": [ { "type": "nav_item", "label": "General", "map_to": "view_general", "client_requirement": "client_is_administrator+" }, { "type": "nav_item", "label": "Network Interfaces", "map_to": "view_network_interfaces", "client_requirement": "client_is_administrator+" }, - { "type": "nav_item", "label": "Upstream DNS", "map_to": "view_upstream_dns", "client_requirement": "client_is_administrator+" }, + { "type": "nav_item", "label": "DNS", "map_to": "view_upstream_dns", "client_requirement": "client_is_administrator+" }, { "type": "nav_item", "label": "DNS Blocking", "map_to": "view_dns_blocking", "client_requirement": "client_is_administrator+" }, + { "type": "nav_item", "label": "DDNS", "map_to": "view_ddns" }, { "type": "nav_item", "label": "VLANs", "map_to": "view_vlans", "client_requirement": "client_is_administrator+" }, { "type": "nav_item", "label": "Inter-VLAN Exceptions", "map_to": "view_inter_vlan", "client_requirement": "client_is_administrator+" }, { "type": "nav_item", "label": "Port Forwarding", "map_to": "view_port_forwarding", "client_requirement": "client_is_administrator+" }, { "type": "nav_item", "label": "DHCP", "map_to": "view_dhcp" }, { "type": "nav_item", "label": "Host Overrides", "map_to": "view_host_overrides", "client_requirement": "client_is_administrator+" }, - { "type": "nav_item", "label": "DDNS", "map_to": "view_ddns" }, { "type": "nav_item", "label": "VPN", "map_to": "view_vpn" }, { "type": "nav_item", "label": "Banned IPs", "map_to": "view_banned_ips", "client_requirement": "client_is_administrator+" } ] diff --git a/docker/routlin-dash/data/page_content.json b/docker/routlin-dash/data/page_content.json index d3c1c7f..b839d4c 100644 --- a/docker/routlin-dash/data/page_content.json +++ b/docker/routlin-dash/data/page_content.json @@ -819,11 +819,11 @@ "items": [ { "type": "h1", - "text": "Upstream DNS" + "text": "DNS" }, { "type": "p", - "text": "Upstream resolvers and caching behaviour for dnsmasq." + "text": "Upstream resolvers and forwarding DNS service settings." } ] }, @@ -845,15 +845,6 @@ "value": "%DNS_STRICT_ORDER%", "hint": "Query DNS providers in list order rather than in parallel." }, - { - "type": "field", - "label": "Cache Size", - "name": "cache_size", - "input_type": "number", - "value": "%DNS_CACHE_SIZE%", - "min": 0, - "hint": "Max DNS responses to cache per instance. Set to 0 to disable caching." - }, { "type": "editable_list", "label": "DNS Providers", @@ -882,6 +873,44 @@ ] } ] + }, + { + "type": "card", + "label": "Forwarding DNS Service", + "client_requirement": "client_is_administrator+", + "items": [ + { + "type": "form", + "action": "/action/upstreamdns_cardforwardingdnsservice_save", + "method": "post", + "items": [ + { + "type": "field", + "label": "Cache Size", + "name": "cache_size", + "input_type": "number", + "value": "%DNS_CACHE_SIZE%", + "min": 0, + "hint": "Max DNS responses to cache per instance. Set to 0 to disable caching." + }, + { + "type": "button_row", + "items": [ + { + "type": "button_primary", + "action": "/action/upstreamdns_cardforwardingdnsservice_save", + "method": "post", + "text": "Save" + }, + { + "type": "button_cancel", + "text": "Cancel" + } + ] + } + ] + } + ] } ] }, @@ -1156,60 +1185,6 @@ } ] }, - { - "type": "card", - "label": "Logging", - "client_requirement": "client_is_administrator+", - "items": [ - { - "type": "form", - "action": "/action/dnsblocking_cardlogging_save", - "method": "post", - "items": [ - { - "type": "field", - "label": "Max Log Size (KB)", - "name": "log_max_kb", - "input_type": "number", - "value": "%GENERAL_LOG_MAX_KB%", - "min": 64, - "hint": "Log is cleared and restarted when it exceeds this size." - }, - { - "type": "field", - "label": "Errors Only", - "name": "log_errors_only", - "input_type": "checkbox", - "value": "%GENERAL_LOG_ERRORS_ONLY%", - "hint": "Only write error-level messages to the log." - }, - { - "type": "field", - "label": "Log DNS Queries", - "name": "dnsmasq_log_queries", - "input_type": "checkbox", - "value": "%GENERAL_DNSMASQ_LOG_QUERIES%", - "hint": "Log every DNS query. High volume \u2014 enable for debugging only." - }, - { - "type": "button_row", - "items": [ - { - "type": "button_primary", - "action": "/action/dnsblocking_cardlogging_save", - "method": "post", - "text": "Save" - }, - { - "type": "button_cancel", - "text": "Cancel" - } - ] - } - ] - } - ] - }, { "type": "table", "datasource": "config:blocklists", @@ -1393,6 +1368,52 @@ ] } ] + }, + { + "type": "card", + "label": "Logging", + "client_requirement": "client_is_administrator+", + "items": [ + { + "type": "form", + "action": "/action/dnsblocking_cardlogging_save", + "method": "post", + "items": [ + { + "type": "field", + "label": "Max Log Size (KB)", + "name": "log_max_kb", + "input_type": "number", + "value": "%GENERAL_LOG_MAX_KB%", + "min": 64, + "hint": "Log is cleared and restarted when it exceeds this size." + }, + { + "type": "field", + "label": "Errors Only", + "name": "log_errors_only", + "input_type": "checkbox", + "value": "%GENERAL_LOG_ERRORS_ONLY%", + "hint": "Only write error-level messages to the log." + }, + { + "type": "button_row", + "items": [ + { + "type": "button_primary", + "action": "/action/dnsblocking_cardlogging_save", + "method": "post", + "text": "Save" + }, + { + "type": "button_cancel", + "text": "Cancel" + } + ] + } + ] + } + ] } ] }, @@ -1465,6 +1486,12 @@ "field": "mdns_reflection", "class": "col-narrow", "render": "badge_enabled_disabled" + }, + { + "label": "DNS Queries", + "field": "dnsmasq_log_queries", + "class": "col-narrow", + "render": "badge_recording_on_off" } ], "row_actions": [ @@ -1498,6 +1525,10 @@ "col": "mdns_reflection", "input_type": "checkbox" }, + { + "col": "dnsmasq_log_queries", + "input_type": "checkbox" + }, { "col": "use_blocklists", "input_type": "checkbox_multi", @@ -1610,6 +1641,13 @@ "input_type": "checkbox", "hint": "Reflect mDNS traffic to/from this VLAN via avahi-daemon. Not supported on WireGuard interfaces." }, + { + "type": "field", + "label": "Record DNS Queries", + "name": "dnsmasq_log_queries", + "input_type": "checkbox", + "hint": "Log every DNS query. High volume — enable for debugging only." + }, { "type": "button_row", "items": [ diff --git a/routlin/core.json b/routlin/core.json index a8b83ed..f6abad6 100644 --- a/routlin/core.json +++ b/routlin/core.json @@ -1,8 +1,7 @@ { "network_interfaces": { "wan_interface": "eno2", - "lan_interface": "enp6s0", - "dnsmasq_log_queries": false + "lan_interface": "enp6s0" }, "upstream_dns": { "strict_order": false, @@ -264,10 +263,11 @@ ], "vlans": [ { - "vlan_id": 1, "name": "trusted", "subnet": "192.168.1.0", "subnet_mask": 24, + "is_vpn": false, + "dnsmasq_log_queries": false, "radius_default": false, "mdns_reflection": false, "use_blocklists": [ @@ -364,14 +364,14 @@ "dest_port": 123, "redirect_to": "192.168.1.1" } - ], - "is_vpn": false + ] }, { - "vlan_id": 10, "name": "iot", "subnet": "192.168.10.0", "subnet_mask": 24, + "is_vpn": false, + "dnsmasq_log_queries": false, "radius_default": false, "mdns_reflection": true, "use_blocklists": [ @@ -468,14 +468,14 @@ "dest_port": 123, "redirect_to": "192.168.10.1" } - ], - "is_vpn": false + ] }, { - "vlan_id": 20, "name": "guest", "subnet": "192.168.20.0", "subnet_mask": 24, + "is_vpn": false, + "dnsmasq_log_queries": false, "radius_default": true, "mdns_reflection": true, "use_blocklists": [ @@ -530,14 +530,14 @@ "dest_port": 123, "redirect_to": "192.168.20.1" } - ], - "is_vpn": false + ] }, { - "vlan_id": 30, "name": "kids", "subnet": "192.168.30.0", "subnet_mask": 24, + "is_vpn": false, + "dnsmasq_log_queries": false, "radius_default": false, "mdns_reflection": true, "use_blocklists": [ @@ -607,14 +607,14 @@ "dest_port": 123, "redirect_to": "192.168.30.1" } - ], - "is_vpn": false + ] }, { - "vlan_id": 40, "name": "vpn", "subnet": "192.168.40.0", "subnet_mask": 24, + "is_vpn": true, + "dnsmasq_log_queries": false, "radius_default": false, "mdns_reflection": false, "use_blocklists": [ @@ -653,8 +653,7 @@ "dest_port": 123, "redirect_to": "192.168.40.1" } - ], - "is_vpn": true + ] } ], "ddns": { diff --git a/routlin/core.py b/routlin/core.py index 2e6f127..00299a6 100644 --- a/routlin/core.py +++ b/routlin/core.py @@ -87,7 +87,6 @@ Usage: import hashlib import ipaddress import json -import logging import os import re import subprocess @@ -108,7 +107,6 @@ PRODUCT_NAME = "routlin" SCRIPT_DIR = Path(__file__).parent CONFIG_FILE = SCRIPT_DIR / "core.json" BLOCKLIST_DIR = SCRIPT_DIR / "blocklists" -LOG_FILE = SCRIPT_DIR / "core.log" METRICS_FILE = SCRIPT_DIR / ".dns-metrics" DNSMASQ_CONF_DIR = Path(f"/etc/dnsmasq-{PRODUCT_NAME}") LEASES_DIR = Path("/var/lib/misc") @@ -140,48 +138,6 @@ NAT_SERVICE_FILE = SYSTEMD_DIR / f"{NAT_SERVICE_NAME}.service" WG_DIR = Path("/etc/wireguard") WG_KEEPALIVE = 25 -log = None - -# =================================================================== -# Logging -# =================================================================== - -def chown_to_script_dir_owner(path): - """Chown a file to the owner of the script directory. - This works correctly whether invoked via sudo, directly as root (e.g. systemd timer), - or as a normal user - the script directory owner is always the right target. - """ - try: - stat = SCRIPT_DIR.stat() - os.chown(path, stat.st_uid, stat.st_gid) - except OSError: - pass # non-fatal - -def setup_logging(max_kb, errors_only): - global log - try: - if LOG_FILE.exists() and LOG_FILE.stat().st_size > max_kb * 1024: - LOG_FILE.write_text("") - if not LOG_FILE.exists(): - LOG_FILE.touch() - chown_to_script_dir_owner(LOG_FILE) - file_handler = logging.FileHandler(LOG_FILE) - except PermissionError: - print(f"WARNING: Cannot write to {LOG_FILE} (permission denied). " - f"Run with sudo or fix ownership: sudo chown $USER {LOG_FILE}") - file_handler = None - level = logging.ERROR if errors_only else logging.INFO - handlers = [logging.StreamHandler(sys.stdout)] - if file_handler: - handlers.insert(0, file_handler) - logging.basicConfig( - level=level, - format="%(asctime)s %(levelname)-8s %(message)s", - datefmt="%Y-%m-%d %H:%M:%S", - handlers=handlers, - ) - log = logging.getLogger("dns-dhcp") - # =================================================================== # Helpers # =================================================================== @@ -592,7 +548,7 @@ def build_vlan_dnsmasq_conf(vlan, data, iface): continue # skip IPv6 upstream -- WAN has no IPv6 address line(f"server={srv}") line(f"cache-size={dns_cfg.get('cache_size', 1000)}") - if general.get("dnsmasq_log_queries", False): + if vlan.get("dnsmasq_log_queries", False): line("log-queries") line() @@ -3132,11 +3088,6 @@ def main(): print(f" - {e}", file=sys.stderr) sys.exit(1) - general = data.get("dns_blocking", {}).get("general", {}) - setup_logging( - general.get("log_max_kb", 1024), - general.get("log_errors_only", False) - ) if args.status: show_status(data)