Development

This commit is contained in:
Matthew Grotke 2026-05-25 02:22:21 -04:00
parent 27eaea3d73
commit c6d2ded525
8 changed files with 188 additions and 171 deletions

View file

@ -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:

View file

@ -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:

View file

@ -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)

View file

@ -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 = '<span class="badge badge-disabled">Disabled</span>'
return f'{td_open}{inner}</td>'
if render_fn == 'badge_recording_on_off':
if str(value).lower() in ('true', '1', 'yes'):
inner = '<span class="badge badge-enabled">Recording On</span>'
else:
inner = '<span class="badge badge-disabled">Recording Off</span>'
return f'{td_open}{inner}</td>'
if render_fn == 'badge_toggle':
if str(value).lower() in ('true', '1', 'yes', 'enabled'):
label = 'Enabled'; badge_cls = 'badge-enabled'

View file

@ -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+" }
]

View file

@ -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": [

View file

@ -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": {

View file

@ -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)