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)