Development

This commit is contained in:
Matthew Grotke 2026-05-25 19:59:42 -04:00
parent d0cfffac52
commit adcfe55c7c
24 changed files with 405 additions and 359 deletions

View file

@ -2,7 +2,7 @@ import copy
from flask import Blueprint, request, redirect, flash
from auth import require_level
from config_utils import load_core, save_core_with_snapshot, verify_core_hash
from config_utils import load_config, save_config_with_snapshot, verify_config_hash
import sanitize
import validation as validate
@ -19,7 +19,7 @@ def _row_index():
def _hash_ok():
if not verify_core_hash(request.form.get('config_hash', '')):
if not verify_config_hash(request.form.get('config_hash', '')):
flash('Configuration was modified by another session. Please refresh and try again.', 'error')
return False
return True
@ -47,17 +47,17 @@ def add_banned_ip():
if not _hash_ok():
return redirect(VIEW)
core = load_core()
cfg = load_config()
entry = {'description': description, 'ip': ip, 'enabled': True}
core.setdefault('banned_ips', []).append(entry)
errors = validate.validate_config(core)
cfg.setdefault('banned_ips', []).append(entry)
errors = validate.validate_config(cfg)
if errors:
for msg in errors:
flash(msg, 'error')
return redirect(VIEW)
flash(save_core_with_snapshot(
core,
flash(save_config_with_snapshot(
cfg,
path='banned_ips', key=ip, operation='add',
before=None, after=entry,
description=f'Added banned IP: {ip}',
@ -75,23 +75,23 @@ def toggle_banned_ip():
if not _hash_ok():
return redirect(VIEW)
core = load_core()
items = core.get('banned_ips', [])
cfg = load_config()
items = cfg.get('banned_ips', [])
if idx < 0 or idx >= len(items):
flash('Entry not found.', 'error')
return redirect(VIEW)
old_enabled = items[idx].get('enabled', True)
items[idx]['enabled'] = not old_enabled
errors = validate.validate_config(core)
errors = validate.validate_config(cfg)
if errors:
for msg in errors:
flash(msg, 'error')
return redirect(VIEW)
action = 'Enabled' if not old_enabled else 'Disabled'
flash(save_core_with_snapshot(
core,
flash(save_config_with_snapshot(
cfg,
path='banned_ips', key=items[idx]['ip'], operation='toggle',
before={'enabled': old_enabled}, after={'enabled': not old_enabled},
description=f'{action} banned IP: {items[idx]["ip"]}',
@ -116,22 +116,22 @@ def edit_banned_ip():
if not _hash_ok():
return redirect(VIEW)
core = load_core()
items = core.get('banned_ips', [])
cfg = load_config()
items = cfg.get('banned_ips', [])
if idx < 0 or idx >= len(items):
flash('Entry not found.', 'error')
return redirect(VIEW)
before = copy.deepcopy(items[idx])
items[idx].update({'description': description, 'ip': ip, 'enabled': enabled})
errors = validate.validate_config(core)
errors = validate.validate_config(cfg)
if errors:
for msg in errors:
flash(msg, 'error')
return redirect(VIEW)
flash(save_core_with_snapshot(
core,
flash(save_config_with_snapshot(
cfg,
path='banned_ips', key=ip, operation='edit',
before=before, after=copy.deepcopy(items[idx]),
description=f'Edited banned IP: {ip}',
@ -149,21 +149,21 @@ def delete_banned_ip():
if not _hash_ok():
return redirect(VIEW)
core = load_core()
items = core.get('banned_ips', [])
cfg = load_config()
items = cfg.get('banned_ips', [])
if idx < 0 or idx >= len(items):
flash('Entry not found.', 'error')
return redirect(VIEW)
removed = items.pop(idx)
errors = validate.validate_config(core)
errors = validate.validate_config(cfg)
if errors:
for msg in errors:
flash(msg, 'error')
return redirect(VIEW)
flash(save_core_with_snapshot(
core,
flash(save_config_with_snapshot(
cfg,
path='banned_ips', key=removed['ip'], operation='delete',
before=removed, after=None,
description=f'Deleted banned IP: {removed["ip"]}',

View file

@ -3,7 +3,7 @@ import ipaddress
from flask import Blueprint, request, redirect, flash
from auth import require_level
from config_utils import load_core, save_core_with_snapshot, verify_core_hash
from config_utils import load_config, save_config_with_snapshot, verify_config_hash
import sanitize
import validation as validate
@ -20,7 +20,7 @@ def _row_index():
def _hash_ok():
if not verify_core_hash(request.form.get('config_hash', '')):
if not verify_config_hash(request.form.get('config_hash', '')):
flash('Configuration was modified by another session. Please refresh and try again.', 'error')
return False
return True
@ -86,8 +86,8 @@ def add_dhcp_reservation():
if not _hash_ok():
return redirect(VIEW)
core = load_core()
vlans = core.get('vlans', [])
cfg = load_config()
vlans = cfg.get('vlans', [])
vlan = next((v for v in vlans if v.get('name') == vlan_name), None)
if vlan is None:
flash(f'The configuration has not been saved because VLAN "{vlan_name}" was not found.', 'error')
@ -107,14 +107,14 @@ def add_dhcp_reservation():
'enabled': True,
}
vlan.setdefault('reservations', []).append(entry)
errors = validate.validate_config(core)
errors = validate.validate_config(cfg)
if errors:
for msg in errors:
flash(msg, 'error')
return redirect(VIEW)
flash(save_core_with_snapshot(
core,
flash(save_config_with_snapshot(
cfg,
path=f'vlans.{vlan_name}.reservations', key=mac, operation='add',
before=None, after=entry,
description=f'Added DHCP reservation: {hostname or mac} ({ip})',
@ -132,8 +132,8 @@ def toggle_dhcp_reservation():
if not _hash_ok():
return redirect(VIEW)
core = load_core()
vlans = core.get('vlans', [])
cfg = load_config()
vlans = cfg.get('vlans', [])
vi, ri = _flat_index_to_vlan_res(vlans, idx)
if vi is None:
flash('Entry not found.', 'error')
@ -142,7 +142,7 @@ def toggle_dhcp_reservation():
res = vlans[vi]['reservations'][ri]
old_enabled = res.get('enabled', True)
res['enabled'] = not old_enabled
errors = validate.validate_config(core)
errors = validate.validate_config(cfg)
if errors:
for msg in errors:
flash(msg, 'error')
@ -150,8 +150,8 @@ def toggle_dhcp_reservation():
vlan_name = vlans[vi]['name']
action = 'Enabled' if not old_enabled else 'Disabled'
flash(save_core_with_snapshot(
core,
flash(save_config_with_snapshot(
cfg,
path=f'vlans.{vlan_name}.reservations', key=res['mac'], operation='toggle',
before={'enabled': old_enabled}, after={'enabled': not old_enabled},
description=f'{action} DHCP reservation: {res.get("hostname") or res["mac"]}',
@ -181,8 +181,8 @@ def edit_dhcp_reservation():
if not _hash_ok():
return redirect(VIEW)
core = load_core()
vlans = core.get('vlans', [])
cfg = load_config()
vlans = cfg.get('vlans', [])
vi, ri = _flat_index_to_vlan_res(vlans, idx)
if vi is None:
flash('Entry not found.', 'error')
@ -203,15 +203,15 @@ def edit_dhcp_reservation():
'radius_client': radius_client,
'enabled': 'enabled' in request.form,
})
errors = validate.validate_config(core)
errors = validate.validate_config(cfg)
if errors:
for msg in errors:
flash(msg, 'error')
return redirect(VIEW)
vlan_name = vlans[vi]['name']
flash(save_core_with_snapshot(
core,
flash(save_config_with_snapshot(
cfg,
path=f'vlans.{vlan_name}.reservations', key=mac, operation='edit',
before=before, after=copy.deepcopy(res),
description=f'Edited DHCP reservation: {hostname or mac} ({ip})',
@ -229,8 +229,8 @@ def delete_dhcp_reservation():
if not _hash_ok():
return redirect(VIEW)
core = load_core()
vlans = core.get('vlans', [])
cfg = load_config()
vlans = cfg.get('vlans', [])
vi, ri = _flat_index_to_vlan_res(vlans, idx)
if vi is None:
flash('Entry not found.', 'error')
@ -238,14 +238,14 @@ def delete_dhcp_reservation():
vlan_name = vlans[vi]['name']
removed = vlans[vi]['reservations'].pop(ri)
errors = validate.validate_config(core)
errors = validate.validate_config(cfg)
if errors:
for msg in errors:
flash(msg, 'error')
return redirect(VIEW)
flash(save_core_with_snapshot(
core,
flash(save_config_with_snapshot(
cfg,
path=f'vlans.{vlan_name}.reservations', key=removed['mac'], operation='delete',
before=removed, after=None,
description=f'Deleted DHCP reservation: {removed.get("hostname") or removed["mac"]}',

View file

@ -3,7 +3,7 @@ import ipaddress
from flask import Blueprint, request, redirect, flash
from auth import require_level
from config_utils import load_core, save_core_with_snapshot, verify_core_hash
from config_utils import load_config, save_config_with_snapshot, verify_config_hash
import sanitize
import validation as validate
@ -12,9 +12,9 @@ bp = Blueprint('action_apply_host_overrides', __name__)
VIEW = '/view/view_host_overrides'
def _vlan_networks(core):
def _vlan_networks(cfg):
nets = []
for v in core.get('vlans', []):
for v in cfg.get('vlans', []):
subnet = v.get('subnet', '')
mask = v.get('subnet_mask', '')
if subnet and mask:
@ -25,12 +25,12 @@ def _vlan_networks(core):
return nets
def _ip_in_vlan(ip_str, core):
def _ip_in_vlan(ip_str, cfg):
try:
addr = ipaddress.IPv4Address(ip_str)
except ValueError:
return False
nets = _vlan_networks(core)
nets = _vlan_networks(cfg)
return not nets or any(addr in net for net in nets)
@ -42,7 +42,7 @@ def _row_index():
def _hash_ok():
if not verify_core_hash(request.form.get('config_hash', '')):
if not verify_config_hash(request.form.get('config_hash', '')):
flash('Configuration was modified by another session. Please refresh and try again.', 'error')
return False
return True
@ -61,21 +61,21 @@ def add_host_override():
if not _hash_ok():
return redirect(VIEW)
core = load_core()
if not _ip_in_vlan(ip, core):
cfg = load_config()
if not _ip_in_vlan(ip, cfg):
flash('IP address does not fall within any configured VLAN subnet.', 'error')
return redirect(VIEW)
entry = {'description': description, 'host': host, 'ip': ip, 'enabled': True}
core.setdefault('host_overrides', []).append(entry)
errors = validate.validate_config(core)
cfg.setdefault('host_overrides', []).append(entry)
errors = validate.validate_config(cfg)
if errors:
for msg in errors:
flash(msg, 'error')
return redirect(VIEW)
flash(save_core_with_snapshot(
core,
flash(save_config_with_snapshot(
cfg,
path='host_overrides', key=host, operation='add',
before=None, after=entry,
description=f'Added host override: {host}{ip}',
@ -93,23 +93,23 @@ def toggle_host_override():
if not _hash_ok():
return redirect(VIEW)
core = load_core()
items = core.get('host_overrides', [])
cfg = load_config()
items = cfg.get('host_overrides', [])
if idx < 0 or idx >= len(items):
flash('Entry not found.', 'error')
return redirect(VIEW)
old_enabled = items[idx].get('enabled', True)
items[idx]['enabled'] = not old_enabled
errors = validate.validate_config(core)
errors = validate.validate_config(cfg)
if errors:
for msg in errors:
flash(msg, 'error')
return redirect(VIEW)
action = 'Enabled' if not old_enabled else 'Disabled'
flash(save_core_with_snapshot(
core,
flash(save_config_with_snapshot(
cfg,
path='host_overrides', key=items[idx]['host'], operation='toggle',
before={'enabled': old_enabled}, after={'enabled': not old_enabled},
description=f'{action} host override: {items[idx]["host"]}',
@ -136,26 +136,26 @@ def edit_host_override():
if not _hash_ok():
return redirect(VIEW)
core = load_core()
if not _ip_in_vlan(ip, core):
cfg = load_config()
if not _ip_in_vlan(ip, cfg):
flash('IP address does not fall within any configured VLAN subnet.', 'error')
return redirect(VIEW)
items = core.get('host_overrides', [])
items = cfg.get('host_overrides', [])
if idx < 0 or idx >= len(items):
flash('Entry not found.', 'error')
return redirect(VIEW)
before = copy.deepcopy(items[idx])
items[idx].update({'description': description, 'host': host, 'ip': ip, 'enabled': enabled})
errors = validate.validate_config(core)
errors = validate.validate_config(cfg)
if errors:
for msg in errors:
flash(msg, 'error')
return redirect(VIEW)
flash(save_core_with_snapshot(
core,
flash(save_config_with_snapshot(
cfg,
path='host_overrides', key=host, operation='edit',
before=before, after=copy.deepcopy(items[idx]),
description=f'Edited host override: {host}{ip}',
@ -173,21 +173,21 @@ def delete_host_override():
if not _hash_ok():
return redirect(VIEW)
core = load_core()
items = core.get('host_overrides', [])
cfg = load_config()
items = cfg.get('host_overrides', [])
if idx < 0 or idx >= len(items):
flash('Entry not found.', 'error')
return redirect(VIEW)
removed = items.pop(idx)
errors = validate.validate_config(core)
errors = validate.validate_config(cfg)
if errors:
for msg in errors:
flash(msg, 'error')
return redirect(VIEW)
flash(save_core_with_snapshot(
core,
flash(save_config_with_snapshot(
cfg,
path='host_overrides', key=removed['host'], operation='delete',
before=removed, after=None,
description=f'Deleted host override: {removed["host"]}',

View file

@ -2,7 +2,7 @@ import copy
from flask import Blueprint, request, redirect, flash
from auth import require_level
from config_utils import load_core, save_core_with_snapshot, verify_core_hash
from config_utils import load_config, save_config_with_snapshot, verify_config_hash
import sanitize
import validation as validate
@ -21,7 +21,7 @@ def _row_index():
def _hash_ok():
if not verify_core_hash(request.form.get('config_hash', '')):
if not verify_config_hash(request.form.get('config_hash', '')):
flash('Configuration was modified by another session. Please refresh and try again.', 'error')
return False
return True
@ -86,17 +86,17 @@ def add_inter_vlan():
if not _hash_ok():
return redirect(VIEW)
core = load_core()
core.setdefault('inter_vlan_exceptions', []).append(entry)
errors = validate.validate_config(core)
cfg = load_config()
cfg.setdefault('inter_vlan_exceptions', []).append(entry)
errors = validate.validate_config(cfg)
if errors:
for msg in errors:
flash(msg, 'error')
return redirect(VIEW)
key = _entry_key(entry)
flash(save_core_with_snapshot(
core,
flash(save_config_with_snapshot(
cfg,
path='inter_vlan_exceptions', key=key, operation='add',
before=None, after=entry,
description=f'Added inter-VLAN rule: {key}',
@ -114,15 +114,15 @@ def toggle_inter_vlan():
if not _hash_ok():
return redirect(VIEW)
core = load_core()
items = core.get('inter_vlan_exceptions', [])
cfg = load_config()
items = cfg.get('inter_vlan_exceptions', [])
if idx < 0 or idx >= len(items):
flash('Entry not found.', 'error')
return redirect(VIEW)
old_enabled = items[idx].get('enabled', True)
items[idx]['enabled'] = not old_enabled
errors = validate.validate_config(core)
errors = validate.validate_config(cfg)
if errors:
for msg in errors:
flash(msg, 'error')
@ -130,8 +130,8 @@ def toggle_inter_vlan():
key = _entry_key(items[idx])
action = 'Enabled' if not old_enabled else 'Disabled'
flash(save_core_with_snapshot(
core,
flash(save_config_with_snapshot(
cfg,
path='inter_vlan_exceptions', key=key, operation='toggle',
before={'enabled': old_enabled}, after={'enabled': not old_enabled},
description=f'{action} inter-VLAN rule: {key}',
@ -153,8 +153,8 @@ def edit_inter_vlan():
if not _hash_ok():
return redirect(VIEW)
core = load_core()
items = core.get('inter_vlan_exceptions', [])
cfg = load_config()
items = cfg.get('inter_vlan_exceptions', [])
if idx < 0 or idx >= len(items):
flash('Entry not found.', 'error')
return redirect(VIEW)
@ -162,15 +162,15 @@ def edit_inter_vlan():
before = copy.deepcopy(items[idx])
items[idx] = entry
items[idx]['enabled'] = request.form.get('enabled') == 'on'
errors = validate.validate_config(core)
errors = validate.validate_config(cfg)
if errors:
for msg in errors:
flash(msg, 'error')
return redirect(VIEW)
key = _entry_key(entry)
flash(save_core_with_snapshot(
core,
flash(save_config_with_snapshot(
cfg,
path='inter_vlan_exceptions', key=key, operation='edit',
before=before, after=copy.deepcopy(items[idx]),
description=f'Edited inter-VLAN rule: {key}',
@ -188,22 +188,22 @@ def delete_inter_vlan():
if not _hash_ok():
return redirect(VIEW)
core = load_core()
items = core.get('inter_vlan_exceptions', [])
cfg = load_config()
items = cfg.get('inter_vlan_exceptions', [])
if idx < 0 or idx >= len(items):
flash('Entry not found.', 'error')
return redirect(VIEW)
removed = items.pop(idx)
errors = validate.validate_config(core)
errors = validate.validate_config(cfg)
if errors:
for msg in errors:
flash(msg, 'error')
return redirect(VIEW)
key = _entry_key(removed)
flash(save_core_with_snapshot(
core,
flash(save_config_with_snapshot(
cfg,
path='inter_vlan_exceptions', key=key, operation='delete',
before=removed, after=None,
description=f'Deleted inter-VLAN rule: {key}',

View file

@ -2,7 +2,7 @@ import copy
from flask import Blueprint, request, redirect, flash
from auth import require_level
from config_utils import load_core, save_core_with_snapshot, verify_core_hash
from config_utils import load_config, save_config_with_snapshot, verify_config_hash
import sanitize
import validation as validate
@ -14,31 +14,31 @@ bp = Blueprint('action_apply_mdns', __name__)
def apply_mdns():
mdns_enabled = 'mdns_enabled' in request.form
if not verify_core_hash(request.form.get('config_hash', '')):
if not verify_config_hash(request.form.get('config_hash', '')):
flash('Configuration was modified by another session. Please refresh and try again.', 'error')
return redirect('/view/view_mdns')
core = load_core()
cfg = load_config()
mdns_reflect_vlans = sanitize.filterlist(
request.form.getlist('mdns_reflect_vlans'),
{v.get('name') for v in core.get('vlans', [])},
{v.get('name') for v in cfg.get('vlans', [])},
)
before = copy.deepcopy(core.get('mdns_reflection', {}))
core.setdefault('mdns_reflection', {}).update({
before = copy.deepcopy(cfg.get('mdns_reflection', {}))
cfg.setdefault('mdns_reflection', {}).update({
'enabled': mdns_enabled,
'reflect_vlans': mdns_reflect_vlans,
})
errors = validate.validate_config(core)
errors = validate.validate_config(cfg)
if errors:
for msg in errors:
flash(msg, 'error')
return redirect('/view/view_mdns')
flash(save_core_with_snapshot(
core,
flash(save_config_with_snapshot(
cfg,
path='mdns_reflection', key='global', operation='edit',
before=before or None, after=copy.deepcopy(core['mdns_reflection']),
before=before or None, after=copy.deepcopy(cfg['mdns_reflection']),
description='Updated mDNS reflection settings',
), 'success')
return redirect('/view/view_mdns')

View file

@ -2,7 +2,7 @@ import copy
from flask import Blueprint, request, redirect, flash
from auth import require_level
from config_utils import load_core, save_core_with_snapshot, verify_core_hash
from config_utils import load_config, save_config_with_snapshot, verify_config_hash
import sanitize
import validation as validate
@ -21,7 +21,7 @@ def _row_index():
def _hash_ok():
if not verify_core_hash(request.form.get('config_hash', '')):
if not verify_config_hash(request.form.get('config_hash', '')):
flash('Configuration was modified by another session. Please refresh and try again.', 'error')
return False
return True
@ -82,17 +82,17 @@ def add_port_forward():
if not _hash_ok():
return redirect(VIEW)
core = load_core()
core.setdefault('port_forwarding', []).append(entry)
errors = validate.validate_config(core)
cfg = load_config()
cfg.setdefault('port_forwarding', []).append(entry)
errors = validate.validate_config(cfg)
if errors:
for msg in errors:
flash(msg, 'error')
return redirect(VIEW)
key = f'{entry["protocol"]}:{entry["dest_port"]}'
flash(save_core_with_snapshot(
core,
flash(save_config_with_snapshot(
cfg,
path='port_forwarding', key=key, operation='add',
before=None, after=entry,
description=f'Added port forward: {key}{entry["nat_ip"]}:{entry["nat_port"]}',
@ -110,15 +110,15 @@ def toggle_port_forward():
if not _hash_ok():
return redirect(VIEW)
core = load_core()
items = core.get('port_forwarding', [])
cfg = load_config()
items = cfg.get('port_forwarding', [])
if idx < 0 or idx >= len(items):
flash('Entry not found.', 'error')
return redirect(VIEW)
old_enabled = items[idx].get('enabled', True)
items[idx]['enabled'] = not old_enabled
errors = validate.validate_config(core)
errors = validate.validate_config(cfg)
if errors:
for msg in errors:
flash(msg, 'error')
@ -126,8 +126,8 @@ def toggle_port_forward():
key = f'{items[idx]["protocol"]}:{items[idx]["dest_port"]}'
action = 'Enabled' if not old_enabled else 'Disabled'
flash(save_core_with_snapshot(
core,
flash(save_config_with_snapshot(
cfg,
path='port_forwarding', key=key, operation='toggle',
before={'enabled': old_enabled}, after={'enabled': not old_enabled},
description=f'{action} port forward: {key}',
@ -149,8 +149,8 @@ def edit_port_forward():
if not _hash_ok():
return redirect(VIEW)
core = load_core()
items = core.get('port_forwarding', [])
cfg = load_config()
items = cfg.get('port_forwarding', [])
if idx < 0 or idx >= len(items):
flash('Entry not found.', 'error')
return redirect(VIEW)
@ -158,15 +158,15 @@ def edit_port_forward():
before = copy.deepcopy(items[idx])
items[idx] = entry
items[idx]['enabled'] = request.form.get('enabled') == 'on'
errors = validate.validate_config(core)
errors = validate.validate_config(cfg)
if errors:
for msg in errors:
flash(msg, 'error')
return redirect(VIEW)
key = f'{entry["protocol"]}:{entry["dest_port"]}'
flash(save_core_with_snapshot(
core,
flash(save_config_with_snapshot(
cfg,
path='port_forwarding', key=key, operation='edit',
before=before, after=copy.deepcopy(items[idx]),
description=f'Edited port forward: {key}{entry["nat_ip"]}:{entry["nat_port"]}',
@ -184,22 +184,22 @@ def delete_port_forward():
if not _hash_ok():
return redirect(VIEW)
core = load_core()
items = core.get('port_forwarding', [])
cfg = load_config()
items = cfg.get('port_forwarding', [])
if idx < 0 or idx >= len(items):
flash('Entry not found.', 'error')
return redirect(VIEW)
removed = items.pop(idx)
errors = validate.validate_config(core)
errors = validate.validate_config(cfg)
if errors:
for msg in errors:
flash(msg, 'error')
return redirect(VIEW)
key = f'{removed["protocol"]}:{removed["dest_port"]}'
flash(save_core_with_snapshot(
core,
flash(save_config_with_snapshot(
cfg,
path='port_forwarding', key=key, operation='delete',
before=removed, after=None,
description=f'Deleted port forward: {key}',

View file

@ -2,7 +2,7 @@ import copy
from flask import Blueprint, request, redirect, flash
from auth import require_level
from config_utils import load_core, save_core_with_snapshot, verify_core_hash
from config_utils import load_config, save_config_with_snapshot, verify_config_hash
import sanitize
import validation as validate
@ -22,7 +22,7 @@ def _row_index():
def _hash_ok():
if not verify_core_hash(request.form.get('config_hash', '')):
if not verify_config_hash(request.form.get('config_hash', '')):
flash('Configuration was modified by another session. Please refresh and try again.', 'error')
return False
return True
@ -40,7 +40,7 @@ def add_vlan():
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', [])},
{b.get('name') for b in load_config().get('dns_blocking', {}).get('blocklists', [])},
)
if not name:
@ -61,8 +61,8 @@ def add_vlan():
if not _hash_ok():
return redirect(VIEW)
core = load_core()
vlans = core.setdefault('vlans', [])
cfg = load_config()
vlans = cfg.setdefault('vlans', [])
if any(validate.derive_vlan_id(v.get('subnet', ''), v.get('subnet_mask', 24)) == vlan_id for v in vlans):
flash(f'VLAN {vlan_id} (derived from subnet) already exists.', 'error')
@ -86,14 +86,14 @@ def add_vlan():
else:
entry['reservations'] = []
vlans.append(entry)
errors = validate.validate_config(core)
errors = validate.validate_config(cfg)
if errors:
for msg in errors:
flash(msg, 'error')
return redirect(VIEW)
flash(save_core_with_snapshot(
core,
flash(save_config_with_snapshot(
cfg,
path='vlans', key=name, operation='add',
before=None, after={k: entry[k] for k in _VLAN_FIELDS if k in entry},
description=f'Added VLAN: {name} ({subnet}/{subnet_mask})',
@ -116,7 +116,7 @@ def edit_vlan():
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', [])},
{b.get('name') for b in load_config().get('dns_blocking', {}).get('blocklists', [])},
)
subnet_mask_raw = request.form.get('subnet_mask')
@ -137,8 +137,8 @@ def edit_vlan():
if not _hash_ok():
return redirect(VIEW)
core = load_core()
vlans = core.get('vlans', [])
cfg = load_config()
vlans = cfg.get('vlans', [])
if idx < 0 or idx >= len(vlans):
flash('VLAN not found.', 'error')
return redirect(VIEW)
@ -179,14 +179,14 @@ def edit_vlan():
'mdns_reflection': mdns_reflection,
'use_blocklists': use_blocklists,
})
errors = validate.validate_config(core)
errors = validate.validate_config(cfg)
if errors:
for msg in errors:
flash(msg, 'error')
return redirect(VIEW)
flash(save_core_with_snapshot(
core,
flash(save_config_with_snapshot(
cfg,
path='vlans', key=name, operation='edit',
before=before, after={k: existing.get(k) for k in _VLAN_FIELDS},
description=f'Edited VLAN: {name}',
@ -204,21 +204,21 @@ def delete_vlan():
if not _hash_ok():
return redirect(VIEW)
core = load_core()
vlans = core.get('vlans', [])
cfg = load_config()
vlans = cfg.get('vlans', [])
if idx < 0 or idx >= len(vlans):
flash('VLAN not found.', 'error')
return redirect(VIEW)
removed = vlans.pop(idx)
errors = validate.validate_config(core)
errors = validate.validate_config(cfg)
if errors:
for msg in errors:
flash(msg, 'error')
return redirect(VIEW)
flash(save_core_with_snapshot(
core,
flash(save_config_with_snapshot(
cfg,
path='vlans', key=removed['name'], operation='delete',
before={k: removed.get(k) for k in _VLAN_FIELDS},
after=None,

View file

@ -5,7 +5,7 @@ import re
from flask import Blueprint, make_response, redirect, flash, request
from auth import require_level
from config_utils import load_core, save_core_with_snapshot, verify_core_hash, CONFIGS_DIR, WEB_APP_DISPLAY_NAME
from config_utils import load_config, save_config_with_snapshot, verify_config_hash, CONFIGS_DIR, WEB_APP_DISPLAY_NAME
import sanitize
import validation as validate
@ -16,17 +16,17 @@ _MTU_MIN = 576
_MTU_MAX = 9000
def _wg_vlan(core):
return next((v for v in core.get('vlans', []) if v.get('is_vpn')), None)
def _wg_vlan(cfg):
return next((v for v in cfg.get('vlans', []) if v.get('is_vpn')), None)
def _wg_vlan_by_name(core, name):
return next((v for v in core.get('vlans', []) if v.get('is_vpn') and v.get('name') == name), None)
def _wg_vlan_by_name(cfg, name):
return next((v for v in cfg.get('vlans', []) if v.get('is_vpn') and v.get('name') == name), None)
def _find_peer_by_flat_idx(core, flat_idx):
def _find_peer_by_flat_idx(cfg, flat_idx):
i = 0
for vlan in core.get('vlans', []):
for vlan in cfg.get('vlans', []):
if not vlan.get('is_vpn'):
continue
peers = vlan.get('peers', [])
@ -37,8 +37,8 @@ def _find_peer_by_flat_idx(core, flat_idx):
return None, None
def _wg_iface(vlan, core):
wg_vlans = [v for v in core.get('vlans', []) if v.get('is_vpn')]
def _wg_iface(vlan, cfg):
wg_vlans = [v for v in cfg.get('vlans', []) if v.get('is_vpn')]
idx = next((i for i, v in enumerate(wg_vlans) if v is vlan), 0)
return f'wg{idx}'
@ -51,7 +51,7 @@ def _row_index():
def _hash_ok():
if not verify_core_hash(request.form.get('config_hash', '')):
if not verify_config_hash(request.form.get('config_hash', '')):
flash('Configuration was modified by another session. Please refresh and try again.', 'error')
return False
return True
@ -115,8 +115,8 @@ def _build_client_conf(vlan, peer_name, peer_ip, private_key, server_pubkey):
def _conf_response(vlan, peer_name, peer_ip, private_key):
core = load_core()
iface = _wg_iface(vlan, core)
cfg = load_config()
iface = _wg_iface(vlan, cfg)
server_pub = _server_pubkey(iface)
if not server_pub:
flash('Peer saved. Run sudo python3 ~/routlin/core.py --apply to generate the server '
@ -164,13 +164,13 @@ def apply_vpn():
if not _hash_ok():
return redirect(_VIEW)
core = load_core()
vpn_vlan = _wg_vlan(core)
cfg = load_config()
vpn_vlan = _wg_vlan(cfg)
if vpn_vlan is None:
flash('No WireGuard VLAN found in configuration.', 'error')
return redirect(_VIEW)
for v in core.get('vlans', []):
for v in cfg.get('vlans', []):
if v.get('is_vpn') and v is not vpn_vlan and v.get('vpn_information', {}).get('listen_port') == listen_port:
flash(f'Listen port {listen_port} is already used by another VPN VLAN.', 'error')
return redirect(_VIEW)
@ -191,15 +191,15 @@ def apply_vpn():
else:
overrides.pop('mtu', None)
errors = validate.validate_config(core)
errors = validate.validate_config(cfg)
if errors:
for msg in errors:
flash(msg, 'error')
return redirect(_VIEW)
vlan_name = vpn_vlan['name']
flash(save_core_with_snapshot(
core,
flash(save_config_with_snapshot(
cfg,
path=f'vlans.{vlan_name}.vpn_information', key=vlan_name, operation='edit',
before=before_info or None, after=copy.deepcopy(info),
description=f'Updated VPN configuration for {vlan_name}',
@ -229,8 +229,8 @@ def add_vpn_peer():
if not _hash_ok():
return redirect(_VIEW)
core = load_core()
vpn_vlan = _wg_vlan_by_name(core, peer_vlan_nm)
cfg = load_config()
vpn_vlan = _wg_vlan_by_name(cfg, peer_vlan_nm)
if vpn_vlan is None:
flash(f'VPN VLAN "{peer_vlan_nm}" not found.', 'error')
return redirect(_VIEW)
@ -247,7 +247,7 @@ def add_vpn_peer():
if any(p.get('name') == peer_name for p in peers):
flash(f'A peer named "{peer_name}" already exists.', 'error')
return redirect(_VIEW)
for v in core.get('vlans', []):
for v in cfg.get('vlans', []):
if not v.get('is_vpn'):
continue
if any(p.get('ip') == peer_ip for p in v.get('peers', [])):
@ -263,14 +263,14 @@ def add_vpn_peer():
'enabled': enabled,
}
peers.append(entry)
errors = validate.validate_config(core)
errors = validate.validate_config(cfg)
if errors:
for msg in errors:
flash(msg, 'error')
return redirect(_VIEW)
save_core_with_snapshot(
core,
save_config_with_snapshot(
cfg,
path=f'vlans.{peer_vlan_nm}.peers', key=peer_name, operation='add',
before=None, after={k: v for k, v in entry.items() if k != 'public_key'},
description=f'Added VPN peer: {peer_name} ({peer_ip})',
@ -297,8 +297,8 @@ def edit_vpn_peer():
if not _hash_ok():
return redirect(_VIEW)
core = load_core()
vlan, peer_idx = _find_peer_by_flat_idx(core, flat_idx)
cfg = load_config()
vlan, peer_idx = _find_peer_by_flat_idx(cfg, flat_idx)
if vlan is None:
flash('Peer not found.', 'error')
return redirect(_VIEW)
@ -310,15 +310,15 @@ def edit_vpn_peer():
before = copy.deepcopy({k: peers[peer_idx].get(k) for k in ('name', 'split_tunnel', 'enabled')})
peers[peer_idx].update({'name': peer_name, 'split_tunnel': split_tunnel, 'enabled': enabled})
errors = validate.validate_config(core)
errors = validate.validate_config(cfg)
if errors:
for msg in errors:
flash(msg, 'error')
return redirect(_VIEW)
vlan_name = vlan['name']
flash(save_core_with_snapshot(
core,
flash(save_config_with_snapshot(
cfg,
path=f'vlans.{vlan_name}.peers', key=peer_name, operation='edit',
before=before, after={'name': peer_name, 'split_tunnel': split_tunnel, 'enabled': enabled},
description=f'Edited VPN peer: {peer_name}',
@ -336,8 +336,8 @@ def toggle_vpn_peer():
if not _hash_ok():
return redirect(_VIEW)
core = load_core()
vlan, peer_idx = _find_peer_by_flat_idx(core, flat_idx)
cfg = load_config()
vlan, peer_idx = _find_peer_by_flat_idx(cfg, flat_idx)
if vlan is None:
flash('Peer not found.', 'error')
return redirect(_VIEW)
@ -345,7 +345,7 @@ def toggle_vpn_peer():
peers = vlan.get('peers', [])
old_enabled = peers[peer_idx].get('enabled', True)
peers[peer_idx]['enabled'] = not old_enabled
errors = validate.validate_config(core)
errors = validate.validate_config(cfg)
if errors:
for msg in errors:
flash(msg, 'error')
@ -354,8 +354,8 @@ def toggle_vpn_peer():
peer_name = peers[peer_idx]['name']
vlan_name = vlan['name']
action = 'Enabled' if not old_enabled else 'Disabled'
flash(save_core_with_snapshot(
core,
flash(save_config_with_snapshot(
cfg,
path=f'vlans.{vlan_name}.peers', key=peer_name, operation='toggle',
before={'enabled': old_enabled}, after={'enabled': not old_enabled},
description=f'{action} VPN peer: {peer_name}',
@ -373,23 +373,23 @@ def delete_vpn_peer():
if not _hash_ok():
return redirect(_VIEW)
core = load_core()
vlan, peer_idx = _find_peer_by_flat_idx(core, flat_idx)
cfg = load_config()
vlan, peer_idx = _find_peer_by_flat_idx(cfg, flat_idx)
if vlan is None:
flash('Peer not found.', 'error')
return redirect(_VIEW)
peers = vlan.get('peers', [])
removed = peers.pop(peer_idx)
errors = validate.validate_config(core)
errors = validate.validate_config(cfg)
if errors:
for msg in errors:
flash(msg, 'error')
return redirect(_VIEW)
vlan_name = vlan['name']
flash(save_core_with_snapshot(
core,
flash(save_config_with_snapshot(
cfg,
path=f'vlans.{vlan_name}.peers', key=removed['name'], operation='delete',
before={k: removed.get(k) for k in ('name', 'ip', 'split_tunnel', 'enabled')},
after=None,
@ -408,8 +408,8 @@ def regenerate_vpn_peer():
if not _hash_ok():
return redirect(_VIEW)
core = load_core()
vlan, peer_idx = _find_peer_by_flat_idx(core, flat_idx)
cfg = load_config()
vlan, peer_idx = _find_peer_by_flat_idx(cfg, flat_idx)
if vlan is None:
flash('Peer not found.', 'error')
return redirect(_VIEW)
@ -418,15 +418,15 @@ def regenerate_vpn_peer():
peer = vlan['peers'][peer_idx]
old_pub_key = peer.get('public_key', '')
peer['public_key'] = public_key
errors = validate.validate_config(core)
errors = validate.validate_config(cfg)
if errors:
for msg in errors:
flash(msg, 'error')
return redirect(_VIEW)
vlan_name = vlan['name']
save_core_with_snapshot(
core,
save_config_with_snapshot(
cfg,
path=f'vlans.{vlan_name}.peers', key=peer['name'], operation='regenerate',
before={'public_key': old_pub_key}, after={'public_key': public_key},
description=f'Regenerated keypair for VPN peer: {peer["name"]}',

View file

@ -1,8 +1,8 @@
import copy
import os
import re
from flask import Blueprint, request, redirect, flash, send_file, abort
from auth import require_level
from config_utils import load_core, save_core, verify_core_hash, queued_msg, CONFIGS_DIR
from config_utils import load_config, verify_config_hash, save_config_with_snapshot, CONFIGS_DIR
import sanitize
import validation as validate
@ -29,6 +29,10 @@ def ddns_cardaddaccount_add():
flash('Unknown provider type.', 'error')
return redirect(VIEW)
if not verify_config_hash(request.form.get('config_hash', '')):
flash('Configuration was modified by another session. Please refresh and try again.', 'error')
return redirect(VIEW)
entry = {
'description': description,
'provider': provider_type,
@ -41,10 +45,14 @@ def ddns_cardaddaccount_add():
else:
entry['api_token'] = request.form.get('api_token', '').strip()
core = load_core()
core.setdefault('ddns', {}).setdefault('providers', []).append(entry)
save_core(core)
flash(f'DDNS provider "{description}" added.', 'success')
cfg = load_config()
cfg.setdefault('ddns', {}).setdefault('providers', []).append(entry)
flash(save_config_with_snapshot(
cfg, path='ddns', key=description, operation='add',
before=None, after=copy.deepcopy(entry),
description=f'Added DDNS provider: {description}',
cmd='ddns update',
), 'success')
return redirect(VIEW)
@ -66,12 +74,17 @@ def ddns_tableaccounts_rowedit():
flash('Unknown provider type.', 'error')
return redirect(VIEW)
core = load_core()
providers = core.setdefault('ddns', {}).setdefault('providers', [])
if not verify_config_hash(request.form.get('config_hash', '')):
flash('Configuration was modified by another session. Please refresh and try again.', 'error')
return redirect(VIEW)
cfg = load_config()
providers = cfg.setdefault('ddns', {}).setdefault('providers', [])
if row_index < 0 or row_index >= len(providers):
flash('Invalid provider index.', 'error')
return redirect(VIEW)
before = copy.deepcopy(providers[row_index])
entry = {
'description': description,
'provider': provider_type,
@ -85,8 +98,12 @@ def ddns_tableaccounts_rowedit():
entry['api_token'] = request.form.get('api_token', '').strip()
providers[row_index] = entry
save_core(core)
flash('DDNS provider updated.', 'success')
flash(save_config_with_snapshot(
cfg, path='ddns', key=description, operation='edit',
before=before, after=copy.deepcopy(entry),
description=f'Edited DDNS provider: {description}',
cmd='ddns update',
), 'success')
return redirect(VIEW)
@ -99,15 +116,25 @@ def ddns_tableaccounts_rowdelete():
flash('Invalid row index.', 'error')
return redirect(VIEW)
core = load_core()
providers = core.setdefault('ddns', {}).setdefault('providers', [])
if not verify_config_hash(request.form.get('config_hash', '')):
flash('Configuration was modified by another session. Please refresh and try again.', 'error')
return redirect(VIEW)
cfg = load_config()
providers = cfg.setdefault('ddns', {}).setdefault('providers', [])
if row_index < 0 or row_index >= len(providers):
flash('Invalid provider index.', 'error')
return redirect(VIEW)
before = copy.deepcopy(providers[row_index])
description = before.get('description', str(row_index))
del providers[row_index]
save_core(core)
flash('DDNS provider deleted.', 'success')
flash(save_config_with_snapshot(
cfg, path='ddns', key=description, operation='delete',
before=before, after=None,
description=f'Deleted DDNS provider: {description}',
cmd='ddns update',
), 'success')
return redirect(VIEW)
@ -123,20 +150,27 @@ def ddns_cardipcheckinterval_save():
flash('Interval must be a whole number of minutes >= 1.', 'error')
return redirect(VIEW)
timer_interval = f'{mins}m'
if not verify_core_hash(request.form.get('config_hash', '')):
if not verify_config_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('ddns', {}).setdefault('general', {})['timer_interval'] = timer_interval
save_core(core)
flash(queued_msg('core apply'), 'success')
cfg = load_config()
before = copy.deepcopy(cfg.get('ddns', {}).get('general', {}))
cfg.setdefault('ddns', {}).setdefault('general', {})['timer_interval'] = timer_interval
flash(save_config_with_snapshot(
cfg, path='ddns', key='general', operation='edit',
before=before, after=copy.deepcopy(cfg['ddns']['general']),
description='Updated DDNS check interval',
cmd='core apply',
), 'success')
return redirect(VIEW)
@bp.route('/action/ddns_cardipcheckservices_save', methods=['POST'])
@require_level('administrator')
def ddns_cardipcheckservices_save():
if not verify_core_hash(request.form.get('config_hash', '')):
if not verify_config_hash(request.form.get('config_hash', '')):
flash('Configuration was modified by another session. Please refresh and try again.', 'error')
return redirect(VIEW)
@ -147,13 +181,17 @@ def ddns_cardipcheckservices_save():
flash('At least one IP check service is required.', 'error')
return redirect(VIEW)
cfg = load_config()
before = copy.deepcopy(cfg.get('ddns', {}).get('ip_check_services', []))
services = [{'type': 'http', 'url': u} for u in http_services]
services += [{'type': 'dig', 'url': u} for u in dig_services]
core = load_core()
core.setdefault('ddns', {})['ip_check_services'] = services
save_core(core)
flash('IP check services saved.', 'success')
cfg.setdefault('ddns', {})['ip_check_services'] = services
flash(save_config_with_snapshot(
cfg, path='ddns', key='ip_check_services', operation='edit',
before=before, after=copy.deepcopy(services),
description='Updated DDNS IP check services',
cmd='ddns update',
), 'success')
return redirect(VIEW)
@ -165,16 +203,23 @@ def ddns_cardlogging_save():
flash('Max Log Size must be a number >= 64.', 'error')
return redirect(VIEW)
log_errors_only = 'log_errors_only' in request.form
if not verify_core_hash(request.form.get('config_hash', '')):
if not verify_config_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('ddns', {}).setdefault('general', {}).update({
cfg = load_config()
before = copy.deepcopy(cfg.get('ddns', {}).get('general', {}))
cfg.setdefault('ddns', {}).setdefault('general', {}).update({
'log_max_kb': log_max_kb,
'log_errors_only': log_errors_only,
})
save_core(core)
flash('DDNS log settings saved.', 'success')
flash(save_config_with_snapshot(
cfg, path='ddns', key='general', operation='edit',
before=before, after=copy.deepcopy(cfg['ddns']['general']),
description='Updated DDNS logging settings',
cmd='ddns update',
), 'success')
return redirect(VIEW)

View file

@ -1,7 +1,7 @@
import re
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
from config_utils import load_config, save_config, verify_config_hash, queued_msg
import sanitize
import validation as validate
@ -20,7 +20,7 @@ def _row_index():
def _hash_ok():
if not verify_core_hash(request.form.get('config_hash', '')):
if not verify_config_hash(request.form.get('config_hash', '')):
flash('Configuration was modified by another session. Please refresh and try again.', 'error')
return False
return True
@ -62,19 +62,19 @@ def dnsblocking_tableblocklists_rowdelete():
if not _hash_ok():
return redirect(VIEW)
core = load_core()
items = core.get('dns_blocking', {}).get('blocklists', [])
cfg = load_config()
items = cfg.get('dns_blocking', {}).get('blocklists', [])
if idx < 0 or idx >= len(items):
flash('Entry not found.', 'error')
return redirect(VIEW)
items.pop(idx)
errors = validate.validate_config(core)
errors = validate.validate_config(cfg)
if errors:
for msg in errors:
flash(msg, 'error')
return redirect(VIEW)
save_core(core)
save_config(cfg)
flash(queued_msg('core apply'), 'success')
return redirect(VIEW)
@ -95,8 +95,8 @@ def dnsblocking_tableblocklists_rowedit():
if not _hash_ok():
return redirect(VIEW)
core = load_core()
items = core.get('dns_blocking', {}).get('blocklists', [])
cfg = load_config()
items = cfg.get('dns_blocking', {}).get('blocklists', [])
if idx < 0 or idx >= len(items):
flash('Entry not found.', 'error')
return redirect(VIEW)
@ -107,12 +107,12 @@ def dnsblocking_tableblocklists_rowedit():
'format': fields['format'],
'url': fields['url'],
})
errors = validate.validate_config(core)
errors = validate.validate_config(cfg)
if errors:
for msg in errors:
flash(msg, 'error')
return redirect(VIEW)
save_core(core)
save_config(cfg)
flash(queued_msg('core apply'), 'success')
return redirect(VIEW)
@ -128,8 +128,8 @@ def dnsblocking_cardaddblocklist_add():
if not _hash_ok():
return redirect(VIEW)
core = load_core()
blocklists = core.setdefault('dns_blocking', {}).setdefault('blocklists', [])
cfg = load_config()
blocklists = cfg.setdefault('dns_blocking', {}).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')
@ -142,12 +142,12 @@ def dnsblocking_cardaddblocklist_add():
'url': fields['url'],
'save_as': _save_as_from_name(fields['name']),
})
errors = validate.validate_config(core)
errors = validate.validate_config(cfg)
if errors:
for msg in errors:
flash(msg, 'error')
return redirect(VIEW)
save_core(core)
save_config(cfg)
flash(queued_msg('core apply'), 'success')
return redirect(VIEW)
@ -162,13 +162,13 @@ def dnsblocking_cardblocklistrefresh_save():
flash('Daily Refresh Time must be a valid 24-hour time (e.g. 02:30).', 'error')
return redirect(VIEW)
if not verify_core_hash(request.form.get('config_hash', '')):
if not verify_config_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('dns_blocking', {}).setdefault('general', {})['daily_execute_time_24hr_local'] = daily_execute_time
save_core(core)
cfg = load_config()
cfg.setdefault('dns_blocking', {}).setdefault('general', {})['daily_execute_time_24hr_local'] = daily_execute_time
save_config(cfg)
flash(queued_msg('core apply'), 'success')
return redirect(VIEW)
@ -192,21 +192,21 @@ def dnsblocking_cardlogging_save():
flash('Max Log Size must be a number >= 64.', 'error')
return redirect(VIEW)
if not verify_core_hash(request.form.get('config_hash', '')):
if not verify_config_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('dns_blocking', {}).setdefault('general', {}).update({
cfg = load_config()
cfg.setdefault('dns_blocking', {}).setdefault('general', {}).update({
'log_max_kb': log_max_kb,
'log_errors_only': log_errors_only,
})
errors = validate.validate_config(core)
errors = validate.validate_config(cfg)
if errors:
for msg in errors:
flash(msg, 'error')
return redirect(VIEW)
save_core(core)
save_config(cfg)
flash(queued_msg('core apply'), 'success')
return redirect(VIEW)

View file

@ -2,7 +2,7 @@ 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, queue_command
from config_utils import load_config, save_config, verify_config_hash, queued_msg, queue_command
import sanitize
import validation as validate
@ -44,7 +44,7 @@ def networkinterfaces_cardnetworkinterface_save():
flash('WAN and LAN interfaces must be different.', 'error')
return redirect(_VIEW)
if not verify_core_hash(request.form.get('config_hash', '')):
if not verify_config_hash(request.form.get('config_hash', '')):
flash('Configuration was modified by another session. Please refresh and try again.', 'error')
return redirect(_VIEW)
@ -54,16 +54,16 @@ def networkinterfaces_cardnetworkinterface_save():
flash(f"Interface '{iface}' does not exist on this system.", 'error')
return redirect(_VIEW)
core = load_core()
gen = core.setdefault('network_interfaces', {})
cfg = load_config()
gen = cfg.setdefault('network_interfaces', {})
gen['wan_interface'] = wan
gen['lan_interface'] = lan
errors = validate.validate_config(core)
errors = validate.validate_config(cfg)
if errors:
for msg in errors:
flash(msg, 'error')
return redirect(_VIEW)
save_core(core)
save_config(cfg)
flash(queued_msg('core apply'), 'success')
return redirect(_VIEW)
@ -72,7 +72,7 @@ def networkinterfaces_cardnetworkinterface_save():
@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', '')):
if not verify_config_hash(request.form.get('config_hash', '')):
flash('Configuration was modified by another session. Please refresh and try again.', 'error')
return redirect(_VIEW)

View file

@ -1,6 +1,6 @@
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
from config_utils import load_config, save_config, verify_config_hash, queued_msg
import sanitize
import validation as validate
@ -28,27 +28,27 @@ def upstreamdns_cardupstreamdns_save():
return redirect(_VIEW)
upstream_servers.append(clean)
if not verify_core_hash(request.form.get('config_hash', '')):
if not verify_config_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', {})
cfg = load_config()
current = cfg.get('upstream_dns', {})
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({
cfg.setdefault('upstream_dns', {}).update({
'strict_order': strict_order,
'upstream_servers': upstream_servers,
})
errors = validate.validate_config(core)
errors = validate.validate_config(cfg)
if errors:
for msg in errors:
flash(msg, 'error')
return redirect(_VIEW)
save_core(core)
save_config(cfg)
flash(queued_msg('core apply'), 'success')
return redirect(_VIEW)
@ -61,22 +61,22 @@ def upstreamdns_cardforwardingdnsservice_save():
flash('Cache Size must be a non-negative integer.', 'error')
return redirect(_VIEW)
if not verify_core_hash(request.form.get('config_hash', '')):
if not verify_config_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', {})
cfg = load_config()
current = cfg.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)
cfg.setdefault('upstream_dns', {})['cache_size'] = cache_size
errors = validate.validate_config(cfg)
if errors:
for msg in errors:
flash(msg, 'error')
return redirect(_VIEW)
save_core(core)
save_config(cfg)
flash(queued_msg('core apply'), 'success')
return redirect(_VIEW)

View file

@ -5,7 +5,7 @@ from flask import session
CONFIGS_DIR = '/routlin_location'
DATA_DIR = '/data'
ACCOUNTS_FILE = f'{DATA_DIR}/authorized_accounts.json'
CORE_FILE = f'{CONFIGS_DIR}/core.json'
CONFIG_FILE = f'{CONFIGS_DIR}/config.json'
DASHBOARD_QUEUE = f'{CONFIGS_DIR}/.dashboard-queue'
DASHBOARD_DONE = f'{CONFIGS_DIR}/.dashboard-done'
DASHBOARD_LAST_RUN = f'{CONFIGS_DIR}/.dashboard-last-run'
@ -21,31 +21,31 @@ DASHB_INTERVAL_SECS = 60
QUEUE_MAX_LINES = 50
def load_core():
def load_config():
try:
with open(CORE_FILE) as f:
with open(CONFIG_FILE) as f:
return json.load(f)
except Exception:
return {}
def save_core(data):
with open(CORE_FILE, 'w') as f:
def save_config(data):
with open(CONFIG_FILE, 'w') as f:
json.dump(data, f, indent=2)
def core_hash():
def config_hash():
try:
with open(CORE_FILE, 'rb') as f:
with open(CONFIG_FILE, 'rb') as f:
return hashlib.md5(f.read()).hexdigest()
except Exception:
return ''
def verify_core_hash(submitted):
def verify_config_hash(submitted):
if not submitted:
return True
return submitted == core_hash()
return submitted == config_hash()
def _load_done_set():
@ -358,7 +358,7 @@ def _items_match(item, ref):
def revert_snapshot_to_core(entry_uuid):
"""Apply the inverse of a snapshot to core.json and queue a new pending change.
"""Apply the inverse of a snapshot to config.json and queue a new pending change.
Returns (flash_message, success_bool).
"""
@ -375,7 +375,7 @@ def revert_snapshot_to_core(entry_uuid):
if operation == 'revert':
return 'This change is already a revert; cannot revert again.', False
core = load_core()
core = load_config()
if key == 'global':
if before is None:
@ -396,7 +396,7 @@ def revert_snapshot_to_core(entry_uuid):
items[i] = before
break
msg = save_core_with_snapshot(
msg = save_config_with_snapshot(
core, path=path, key=key, operation='revert',
before=after, after=before,
description=f"Reverted: {snap.get('description', '')}",
@ -417,7 +417,7 @@ def load_snapshot_for_uuid(entry_uuid):
return None
def save_core_with_snapshot(new_core, path, key, operation, before, after,
def save_config_with_snapshot(new_core, path, key, operation, before, after,
description='', cmd='core apply', queue=True):
"""
Write a .snapshots/{ts}-{uuid}.json file, save new_core to disk, and
@ -447,7 +447,7 @@ def save_core_with_snapshot(new_core, path, key, operation, before, after,
with open(os.path.join(SNAPSHOTS_DIR, f'{entry_ts}-{entry_uuid}.json'), 'w') as f:
json.dump(snapshot, f, indent=2)
save_core(new_core)
save_config(new_core)
if not queue:
return None

View file

@ -4,7 +4,7 @@ import json, re, subprocess, os, sys, html as html_mod
import sanitize
import validation as validate
from datetime import datetime, timezone
from config_utils import core_hash, get_pending_entries, get_dashboard_pending, get_dashboard_done, load_snapshot_for_uuid, _seconds_until_next_run, _format_timing, _is_locked, _lock_mtime, WEB_APP_DISPLAY_NAME, CONFIGS_DIR, DATA_DIR
from config_utils import config_hash, get_pending_entries, get_dashboard_pending, get_dashboard_done, load_snapshot_for_uuid, _seconds_until_next_run, _format_timing, _is_locked, _lock_mtime, WEB_APP_DISPLAY_NAME, CONFIGS_DIR, DATA_DIR
bp = Blueprint('view_page', __name__)
@ -44,8 +44,8 @@ def _load_json(path):
print(f'[view_page] ERROR loading {path}: {ex}', file=sys.stderr)
return {}
def _load_core(): return _load_json(f'{CONFIGS_DIR}/core.json')
def _load_ddns(): return _load_core().get('ddns', {})
def _load_config(): return _load_json(f'{CONFIGS_DIR}/config.json')
def _load_ddns(): return _load_config().get('ddns', {})
def _load_accounts(): return _load_json(f'{DATA_DIR}/authorized_accounts.json')
def _load_css():
@ -149,17 +149,17 @@ def _iface_status(iface):
return 'INVALID'
def _resolve_iface(vlan, core):
def _resolve_iface(vlan, cfg):
"""Compute interface name from is_vpn + derived vlan_id + general.lan_interface."""
if vlan.get('is_vpn'):
wg_vlans = [v for v in core.get('vlans', []) if v.get('is_vpn')]
wg_vlans = [v for v in cfg.get('vlans', []) if v.get('is_vpn')]
wg_sorted = sorted(wg_vlans, key=lambda v: (
validate.derive_vlan_id(v.get('subnet', ''), v.get('subnet_mask', 24)) is None,
validate.derive_vlan_id(v.get('subnet', ''), v.get('subnet_mask', 24)) or 0,
))
idx = next((i for i, v in enumerate(wg_sorted) if v is vlan), 0)
return f'wg{idx}'
lan = core.get('network_interfaces', {}).get('lan_interface', 'eth0')
lan = cfg.get('network_interfaces', {}).get('lan_interface', 'eth0')
vid = validate.derive_vlan_id(vlan.get('subnet', ''), vlan.get('subnet_mask', 24)) or 1
return lan if vid == 1 else f'{lan}.{vid}'
@ -187,7 +187,7 @@ def _live_dhcp_leases():
def _vlan_name_for_ip(ip):
import ipaddress
for vlan in _load_core().get('vlans', []):
for vlan in _load_config().get('vlans', []):
subnet = vlan.get('subnet', '')
mask = vlan.get('subnet_mask', 24)
if not subnet:
@ -254,11 +254,11 @@ def _fmt_bytes(n):
# Config data loaders ===============================================
def _config_datasource(name):
core = _load_core()
vlans = core.get('vlans', [])
cfg = _load_config()
vlans = cfg.get('vlans', [])
if name == 'interfaces':
gen = core.get('network_interfaces', {})
gen = cfg.get('network_interfaces', {})
wan = gen.get('wan_interface', '')
lan = gen.get('lan_interface', '')
return [
@ -267,14 +267,14 @@ def _config_datasource(name):
]
if name == 'banned_ips':
return core.get('banned_ips', [])
return cfg.get('banned_ips', [])
if name == 'host_overrides':
return core.get('host_overrides', [])
return cfg.get('host_overrides', [])
if name == 'blocklists':
rows = []
for bl in core.get('dns_blocking', {}).get('blocklists', []):
for bl in cfg.get('dns_blocking', {}).get('blocklists', []):
row = dict(bl)
bl_path = f'{CONFIGS_DIR}/blocklists/{bl.get("save_as", "")}'
try:
@ -288,12 +288,12 @@ def _config_datasource(name):
return rows
if name == 'vlans':
bl_desc = {b['name']: b.get('description', b['name']) for b in core.get('dns_blocking', {}).get('blocklists', []) if 'name' in b}
bl_desc = {b['name']: b.get('description', b['name']) for b in cfg.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', '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['interface'] = _resolve_iface(v, cfg)
row['use_blocklists'] = json.dumps([
{'n': bl, 'd': bl_desc.get(bl, bl)} for bl in v.get('use_blocklists', [])
])
@ -301,10 +301,10 @@ def _config_datasource(name):
return rows
if name == 'inter_vlan_exceptions':
return core.get('inter_vlan_exceptions', [])
return cfg.get('inter_vlan_exceptions', [])
if name == 'port_forwarding':
return core.get('port_forwarding', [])
return cfg.get('port_forwarding', [])
if name == 'dhcp_reservations':
rows = []
@ -418,10 +418,10 @@ def _bl_last_update():
except Exception:
return '-'
def _blocklist_stats_html(core):
def _blocklist_stats_html(cfg):
bl_dir = f'{CONFIGS_DIR}/blocklists'
rows = ''
for bl in core.get('dns_blocking', {}).get('blocklists', []):
for bl in cfg.get('dns_blocking', {}).get('blocklists', []):
name = e(bl.get('name', ''))
save_as = bl.get('save_as', '')
bl_path = f'{bl_dir}/{save_as}' if save_as else ''
@ -546,7 +546,7 @@ def _ddns_last_checked():
return 'Last checked: ---'
def _vpn_info():
for vlan in _load_core().get('vlans', []):
for vlan in _load_config().get('vlans', []):
if 'vpn_information' in vlan:
return vlan['vpn_information']
return {}
@ -556,11 +556,11 @@ def _vpn_info():
def collect_tokens():
tokens = {}
core = _load_core()
net = core.get('network_interfaces', {})
dns_blk_gen = core.get('dns_blocking', {}).get('general', {})
dns = core.get('upstream_dns', {})
vlans = core.get('vlans', [])
cfg = _load_config()
net = cfg.get('network_interfaces', {})
dns_blk_gen = cfg.get('dns_blocking', {}).get('general', {})
dns = cfg.get('upstream_dns', {})
vlans = cfg.get('vlans', [])
tokens['GENERAL_WAN_INTERFACE'] = str(net.get('wan_interface', '-'))
tokens['GENERAL_LAN_INTERFACE'] = str(net.get('lan_interface', '-'))
tokens['GENERAL_WAN_STATUS'] = _iface_status(net.get('wan_interface', ''))
@ -693,10 +693,10 @@ def collect_tokens():
tokens['VPN_VLAN_COUNT'] = str(sum(1 for v in vlans if v.get('is_vpn')))
tokens['EXISTING_VLAN_IDS_JSON'] = json.dumps([validate.derive_vlan_id(v.get('subnet', ''), v.get('subnet_mask', 24)) for v in vlans])
tokens['EXISTING_VLAN_NAMES_JSON'] = json.dumps([v.get('name', '') for v in vlans])
tokens['EXISTING_VLAN_INTERFACES_JSON'] = json.dumps([_resolve_iface(v, core) for v in vlans])
tokens['STAT_BANNED_IP_COUNT'] = str(sum(1 for b in core.get('banned_ips', []) if b.get('enabled', True)))
tokens['STAT_BLOCKLIST_COUNT'] = str(len(core.get('dns_blocking', {}).get('blocklists', [])))
tokens['BLOCKLIST_STATS_HTML'] = _blocklist_stats_html(core)
tokens['EXISTING_VLAN_INTERFACES_JSON'] = json.dumps([_resolve_iface(v, cfg) for v in vlans])
tokens['STAT_BANNED_IP_COUNT'] = str(sum(1 for b in cfg.get('banned_ips', []) if b.get('enabled', True)))
tokens['STAT_BLOCKLIST_COUNT'] = str(len(cfg.get('dns_blocking', {}).get('blocklists', [])))
tokens['BLOCKLIST_STATS_HTML'] = _blocklist_stats_html(cfg)
ddns = _load_ddns()
ddns_gen = ddns.get('general', {})
@ -795,7 +795,7 @@ def collect_tokens():
tokens['BLOCKLIST_NAME_OPTIONS'] = json.dumps([
{'value': bl.get('name', ''), 'label': bl.get('description', bl.get('name', ''))}
for bl in core.get('dns_blocking', {}).get('blocklists', [])
for bl in cfg.get('dns_blocking', {}).get('blocklists', [])
])
tokens['ACCOUNT_LEVEL_OPTIONS'] = json.dumps([
@ -984,7 +984,7 @@ def _render_item(item, tokens, inherited_req=None):
f'<button type="button" class="btn btn-ghost btn-sm stat-card-edit-btn">Edit</button>'
f'</div>'
f'<form class="stat-card-edit-form" style="display:none" action="{e(edit_action)}" method="post">'
f'<input type="hidden" name="config_hash" value="{e(core_hash())}"/>'
f'<input type="hidden" name="config_hash" value="{e(config_hash())}"/>'
f'{input_wrap}'
f'<div style="margin-top:0.5em;display:flex;gap:0.5em">'
f'<button type="submit" class="btn btn-primary btn-sm" disabled>Save</button>'
@ -1066,7 +1066,7 @@ def _render_item(item, tokens, inherited_req=None):
action = e(apply_tokens(item.get('action', ''), tokens))
method = e(item.get('method', 'post'))
inner = render_items(item.get('items', []), tokens, req)
hash_field = f'<input type="hidden" name="config_hash" value="{e(core_hash())}"/>'
hash_field = f'<input type="hidden" name="config_hash" value="{e(config_hash())}"/>'
originals = _collect_form_originals(item.get('items', []), tokens)
orig_field = (f'<input type="hidden" name="original_values" value="{e(json.dumps(originals))}"/>'
if originals else '')
@ -1406,7 +1406,7 @@ def _render_table(item, tokens, inherited_req=None):
rows = _load_datasource(item.get('datasource', ''))
empty = e(item.get('empty_message', 'No data.'))
row_actions = item.get('row_actions', [])
hash_val = core_hash()
hash_val = config_hash()
toolbar_html = ''
toolbar = item.get('toolbar')
@ -1594,7 +1594,7 @@ def render_layout(view_id, content_html, tokens):
navbar_html = _render_navbar(view_id, level, tokens)
footer_html = f'<footer class="footer">{WEB_APP_DISPLAY_NAME}</footer>'
page_hash = core_hash()
page_hash = config_hash()
lan_iface = e(tokens.get('GENERAL_LAN_INTERFACE', ''))
vpn_count = tokens.get('VPN_VLAN_COUNT', '0')
existing_ids = tokens.get('EXISTING_VLAN_IDS_JSON', '[]')

View file

@ -627,7 +627,8 @@
{
"type": "button_secondary",
"formaction": "/action/actions_cardpending_revertselected",
"text": "Revert Selected"
"text": "Revert Selected",
"disabled": "%NO_PENDING%"
}
]
}

View file

@ -10,7 +10,7 @@ All configuration lives in two JSON files. Edit these to match your network befo
| File | Controls |
|---|---|
| `core.json` | VLANs, subnets, gateways, dynamic pools, static/dynamic reservations, RADIUS client flags, mDNS reflection scope, WireGuard interface settings and peers, upstream DNS servers, blocklist sources, per-VLAN blocklist assignments, host overrides, banned IPs, WAN interface, port forwarding rules, port wrangling, inter-VLAN exceptions |
| `config.json` | VLANs, subnets, gateways, dynamic pools, static/dynamic reservations, RADIUS client flags, mDNS reflection scope, WireGuard interface settings and peers, upstream DNS servers, blocklist sources, per-VLAN blocklist assignments, host overrides, banned IPs, WAN interface, port forwarding rules, port wrangling, inter-VLAN exceptions |
| `ddns.json` | DDNS provider credentials, hostnames/subdomains, update interval, IP-check services |
### Dotfiles (auto-generated, do not edit)
@ -33,7 +33,7 @@ All configuration lives in two JSON files. Edit these to match your network befo
## Initial Configuration
### 1. Edit Core Configuration (`core.json`)
### 1. Edit Core Configuration (`config.json`)
Edit the top-level `network_interfaces` block:
@ -149,7 +149,7 @@ mDNS (Multicast DNS) is the protocol devices use to advertise and discover servi
**Multi-VLAN networks:** A device on the IoT VLAN (e.g. a network printer) advertising via mDNS is invisible to devices on the Kids or Trusted VLANs, because the multicast packets never leave the IoT subnet. The `mdns_reflection` feature solves this by running `avahi-daemon` as an mDNS proxy on the router, which has an interface on every VLAN. Avahi listens for mDNS announcements arriving on any of the designated reflection interfaces and re-broadcasts them on all the others, making services discoverable across VLANs without requiring any changes on the devices themselves.
Configure mDNS reflection with the top-level `mdns_reflection` block in `core.json`:
Configure mDNS reflection with the top-level `mdns_reflection` block in `config.json`:
```json
"mdns_reflection": {
@ -190,7 +190,7 @@ sudo python3 ddns.py --start # Run an immediate IP update and install t
Optional (if WireGuard VPN is desired):
1. Add a WireGuard VLAN to `core.json` with `is_vpn: true` (see configuration example above)
1. Add a WireGuard VLAN to `config.json` with `is_vpn: true` (see configuration example above)
2. Run `sudo python3 core.py --apply` - this generates the server keypair, writes `/etc/wireguard/wg0.conf`, and brings the interface up
3. Add peers using `create_vpn_peer.py` (see below), then run `sudo python3 core.py --apply` again to sync them to the live interface
@ -201,7 +201,7 @@ python3 create_vpn_peer.py --name phone --ip 192.168.40.3 --split-tunnel
python3 create_vpn_peer.py --name tablet --ip 192.168.40.4 --output ~/tablet.conf
```
The script reads the specified WireGuard VLAN from `core.json`, validates the IP against the VLAN subnet, generates a keypair, appends the peer to `core.json`, and writes the client `.conf` file. If the config has exactly one WireGuard VLAN, `--iface` is optional. Transfer the `.conf` to the peer device by secure means, then delete it from the server.
The script reads the specified WireGuard VLAN from `config.json`, validates the IP against the VLAN subnet, generates a keypair, appends the peer to `config.json`, and writes the client `.conf` file. If the config has exactly one WireGuard VLAN, `--iface` is optional. Transfer the `.conf` to the peer device by secure means, then delete it from the server.
---
@ -266,7 +266,7 @@ Only `--start` and `--disable` require `sudo` as they install/remove systemd tim
sudo python3 ddns.py --start # Run update and install systemd timer
sudo python3 ddns.py --disable # Stop updates and remove systemd timer
python3 ddns.py --apply # Run one immediate DDNS update (used by timer)
python3 ddns.py --update # Run one immediate DDNS update (used by timer)
python3 ddns.py --force # Force update regardless of cached IP
python3 ddns.py --status # Timer/service status
python3 ddns.py --getip # Print current public IP and exit

View file

@ -1,8 +1,8 @@
#!/usr/bin/env python3
"""
core.py -- Apply core.json to systemd-networkd, per-VLAN dnsmasq instances, and nftables.
core.py -- Apply config.json to systemd-networkd, per-VLAN dnsmasq instances, and nftables.
Each VLAN defined in core.json gets its own dnsmasq instance that handles
Each VLAN defined in config.json gets its own dnsmasq instance that handles
both DHCP and DNS for that VLAN. WireGuard VLANs get a DNS-only instance
(no DHCP, since peers have statically assigned IPs).
@ -105,7 +105,7 @@ from validation import (
PRODUCT_NAME = "routlin"
SCRIPT_DIR = Path(__file__).parent
CONFIG_FILE = SCRIPT_DIR / "core.json"
CONFIG_FILE = SCRIPT_DIR / "config.json"
BLOCKLIST_DIR = SCRIPT_DIR / "blocklists"
METRICS_FILE = SCRIPT_DIR / ".dns-metrics"
DNSMASQ_CONF_DIR = Path(f"/etc/dnsmasq-{PRODUCT_NAME}")
@ -260,7 +260,7 @@ def load_config():
with open(CONFIG_FILE) as f:
data = json.load(f)
if not data.get("vlans"):
die("No vlans defined in core.json.")
die("No vlans defined in config.json.")
return data
# ===================================================================
@ -270,7 +270,7 @@ def load_config():
def build_netdev(vlan, vid, iface):
return "\n".join([
"# Generated by core.py -- do not edit manually.",
"# Edit core.json and re-run: sudo python3 core.py --apply",
"# Edit config.json and re-run: sudo python3 core.py --apply",
"",
"[NetDev]",
f"Name={iface}",
@ -286,7 +286,7 @@ def build_network(vlan, vid, iface, all_vlan_ids):
prefix = network.prefixlen
lines = [
"# Generated by core.py -- do not edit manually.",
"# Edit core.json and re-run: sudo python3 core.py --apply",
"# Edit config.json and re-run: sudo python3 core.py --apply",
"",
"[Match]",
f"Name={iface}",
@ -452,7 +452,7 @@ def build_vlan_dnsmasq_conf(vlan, data, iface):
L.append(s)
line("# Generated by core.py -- do not edit manually.")
line("# Edit core.json and re-run: sudo python3 core.py --apply")
line("# Edit config.json and re-run: sudo python3 core.py --apply")
line(f"# VLAN: {name} (vlan_id={derive_vlan_id(vlan.get('subnet', ''), vlan.get('subnet_mask', 24))})")
line()
line(f"pid-file={vlan_pid_file(vlan)}")
@ -772,7 +772,7 @@ def generate_wg_server_key(iface):
return private
def build_wg_server_conf(vlan, server_private_key, iface):
"""Build the /etc/wireguard/<iface>.conf content from core.json peers."""
"""Build the /etc/wireguard/<iface>.conf content from config.json peers."""
info = vlan["vpn_information"]
gateway = resolve_vlan_options(vlan)["gateway"]
network = network_for(vlan)
@ -1158,7 +1158,7 @@ def install_ddns_timer(data):
"",
"[Service]",
"Type=oneshot",
f"ExecStart=/usr/bin/python3 {script_path} --apply",
f"ExecStart=/usr/bin/python3 {script_path} --update",
"",
])
timer_content = "\n".join([
@ -1387,7 +1387,7 @@ def build_nft_config(data, dry_run=False):
L.append(s)
line("# Generated by core.py -- do not edit manually.")
line("# Edit core.json and re-run: sudo python3 core.py --apply")
line("# Edit config.json and re-run: sudo python3 core.py --apply")
line()
# ==========================================================================
@ -1829,7 +1829,7 @@ def build_radius_clients_conf(data, secret):
"""Generate freeradius clients.conf from reservations with radius_client: true."""
lines = [
"# Generated by core.py -- do not edit manually.",
"# Edit core.json and re-run: sudo python3 core.py --apply",
"# Edit config.json and re-run: sudo python3 core.py --apply",
"",
"# localhost (required)",
"client localhost {",
@ -1867,7 +1867,7 @@ def build_radius_users(data):
lines = [
"# Generated by core.py -- do not edit manually.",
"# Edit core.json and re-run: sudo python3 core.py --apply",
"# Edit config.json and re-run: sudo python3 core.py --apply",
"",
]
@ -3028,7 +3028,7 @@ def cmd_apply(data, dry_run=False):
def main():
parser = argparse.ArgumentParser(
description="Apply core.json to systemd-networkd, per-VLAN dnsmasq instances, and nftables",
description="Apply config.json to systemd-networkd, per-VLAN dnsmasq instances, and nftables",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog=(
"examples:\n"

View file

@ -1,8 +1,8 @@
#!/usr/bin/env python3
"""
create_vpn_peer.py -- Add a WireGuard peer to core.json and write the client .conf file.
create_vpn_peer.py -- Add a WireGuard peer to config.json and write the client .conf file.
Generates a fresh keypair, appends the peer to the specified WireGuard VLAN in core.json,
Generates a fresh keypair, appends the peer to the specified WireGuard VLAN in config.json,
and saves a ready-to-import client config file.
Use --iface or --vlan-id to select the target VLAN. If the config contains exactly one
@ -26,7 +26,7 @@ import sys
from pathlib import Path
SCRIPT_DIR = Path(__file__).parent
CONFIG_FILE = SCRIPT_DIR / "core.json"
CONFIG_FILE = SCRIPT_DIR / "config.json"
def die(msg):
@ -61,7 +61,7 @@ def find_wg_vlan(data, iface=None, vlan_id=None):
vlan = next((v for v in wg_vlans if resolve_wg_iface(v, data) == iface), None)
if vlan is None:
known = ", ".join(resolve_wg_iface(v, data) for v in wg_vlans) or "none"
die(f"No WireGuard VLAN with interface '{iface}' found in core.json. "
die(f"No WireGuard VLAN with interface '{iface}' found in config.json. "
f"Known WireGuard interfaces: {known}.")
return vlan
@ -71,12 +71,12 @@ def find_wg_vlan(data, iface=None, vlan_id=None):
known = ", ".join(
f"{v['vlan_id']} ({resolve_wg_iface(v, data)})" for v in wg_vlans
) or "none"
die(f"No WireGuard VLAN with vlan_id {vlan_id} found in core.json. "
die(f"No WireGuard VLAN with vlan_id {vlan_id} found in config.json. "
f"Known WireGuard VLANs: {known}.")
return vlan
if not wg_vlans:
die("No WireGuard VLANs found in core.json. "
die("No WireGuard VLANs found in config.json. "
"Add a VLAN with is_vpn set to true.")
if len(wg_vlans) > 1:
options = " " + "\n ".join(
@ -149,7 +149,7 @@ def build_client_conf(vlan, peer_ip, private_key, server_pub, split_tunnel):
def main():
parser = argparse.ArgumentParser(
description="Add a WireGuard peer to core.json and write the client .conf file."
description="Add a WireGuard peer to config.json and write the client .conf file."
)
parser.add_argument("--name", required=True, help="Peer name (e.g. laptop)")
parser.add_argument("--ip", required=True, help="Peer IP within the VPN subnet (e.g. 192.168.40.2)")
@ -198,7 +198,7 @@ def main():
private_key, public_key = generate_keypair()
srv_pub = server_pubkey(iface)
# -- Update core.json ------------------------------------------------------
# -- Update config.json ------------------------------------------------------
peers.append({
"name": args.name,
"ip": peer_ip,
@ -207,7 +207,7 @@ def main():
"enabled": True,
})
save_config(data)
print(f"Added peer '{args.name}' to core.json.")
print(f"Added peer '{args.name}' to config.json.")
# -- Write client conf -----------------------------------------------------
conf_content = build_client_conf(vlan, peer_ip, private_key, srv_pub, args.split_tunnel)

View file

@ -2,7 +2,7 @@
"""
ddns.py -- Update DDNS provider(s) with current public IP.
Reads the ddns block from core.json, fetches the current public IP,
Reads the ddns block from config.json, fetches the current public IP,
and updates each enabled provider block only if the IP has changed
since the last successful update for that provider.
Designed to be run on a systemd timer managed by core.py --apply.
@ -16,7 +16,7 @@ Logs to ddns.log in the same directory as this script.
Log is cleared when it exceeds general.log_max_kb from config.
Usage:
python3 ddns.py --apply Run update once (used by timer)
python3 ddns.py --update Run update once (used by timer)
python3 ddns.py --force Force update regardless of cached IP
python3 ddns.py --getip Print current public IP and exit
"""
@ -32,7 +32,7 @@ import logging
from pathlib import Path
SCRIPT_DIR = Path(__file__).parent
CONFIG_FILE = SCRIPT_DIR / "core.json"
CONFIG_FILE = SCRIPT_DIR / "config.json"
CACHE_SERVICE_FILE = SCRIPT_DIR / ".ddns-last-service"
LOG_FILE = SCRIPT_DIR / "ddns.log"
@ -512,18 +512,18 @@ def main():
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog=(
"examples:\n"
" python3 ddns.py --apply Run update once (used by timer)\n"
" python3 ddns.py --update Run update once (used by timer)\n"
" python3 ddns.py --force Force update regardless of cached IP\n"
" python3 ddns.py --getip Print current public IP and exit\n"
)
)
parser.add_argument("--apply", action="store_true", help="Run update once (used by timer)")
parser.add_argument("--update", action="store_true", help="Run update once (used by timer)")
parser.add_argument("--force", action="store_true", help="Force update regardless of cached IP")
parser.add_argument("--getip", action="store_true", help="Print current public IP and exit")
args = parser.parse_args()
if not any([args.apply, args.force, args.getip]):
if not any([args.update, args.force, args.getip]):
parser.print_help()
return
@ -540,7 +540,7 @@ def main():
general = cfg["general"]
setup_logging(general["log_max_kb"], general["log_errors_only"])
if args.apply or args.force:
if args.update or args.force:
run_update(cfg, force=args.force)
if __name__ == "__main__":

View file

@ -1,8 +1,8 @@
#!/usr/bin/env python3
"""
dns-blocklists.py -- Download and merge DNS blocklists defined in core.json.
dns-blocklists.py -- Download and merge DNS blocklists defined in config.json.
Reads the blocklists library from core.json, downloads every blocklist referenced
Reads the blocklists library from config.json, downloads every blocklist referenced
by at least one VLAN, merges them into per-combo conf files (one per unique
combination of blocklist names), then sends SIGHUP to each running dnsmasq
instance so it reloads its config without restarting.
@ -23,7 +23,7 @@ from pathlib import Path
PRODUCT_NAME = "routlin"
SCRIPT_DIR = Path(__file__).parent
CONFIG_FILE = SCRIPT_DIR / "core.json"
CONFIG_FILE = SCRIPT_DIR / "config.json"
BLOCKLIST_DIR = SCRIPT_DIR / "blocklists"
LOG_FILE = SCRIPT_DIR / "dns-blocklists.log"
@ -80,7 +80,7 @@ def load_config():
with open(CONFIG_FILE) as f:
data = json.load(f)
if not data.get("vlans"):
die("No vlans defined in core.json.")
die("No vlans defined in config.json.")
return data

View file

@ -1,7 +1,7 @@
"""
health.py -- System health checks for Routlin.
Reads core.json, checks services, configuration files, and logs, then writes
Reads config.json, checks services, configuration files, and logs, then writes
.health JSON. Imported by core.py; also runnable standalone.
Public API:
@ -29,7 +29,7 @@ from validation import derive_interface, derive_vlan_id, is_wg
PRODUCT_NAME = "routlin"
SCRIPT_DIR = Path(__file__).parent
HEALTH_FILE = SCRIPT_DIR / ".health"
CONFIG_FILE = SCRIPT_DIR / "core.json"
CONFIG_FILE = SCRIPT_DIR / "config.json"
BLOCKLIST_DIR = SCRIPT_DIR / "blocklists"
DNSMASQ_CONF_DIR = Path(f"/etc/dnsmasq-{PRODUCT_NAME}")
LEASES_DIR = Path("/var/lib/misc")
@ -532,7 +532,7 @@ def check_configurations(data):
f"DHCP pool ({vlan['name']})", "warning",
f"DHCP pool for VLAN '{vlan['name']}' is {pct}% full "
f"({len(leases)}/{pool_size} leases).",
"Expand the pool range in core.json or clean up stale leases "
"Expand the pool range in config.json or clean up stale leases "
f"with: `sudo python3 core.py --reset-leases {vlan['name']}`"))
else:
results.append(_ok(f"dhcp_pool_{vlan['name']}",
@ -596,7 +596,7 @@ def check_configurations(data):
results.append(_problem(
"upstream_dns", "Upstream DNS reachability", "warning",
f"Upstream DNS server(s) unreachable on port 53: {', '.join(unreachable)}.",
"Check WAN connectivity and upstream DNS server addresses in core.json."))
"Check WAN connectivity and upstream DNS server addresses in config.json."))
elif servers:
results.append(_ok("upstream_dns", "Upstream DNS reachability"))

View file

@ -564,13 +564,13 @@ def main():
# -- Dashboard -------------------------------------------------
header("Dashboard (optional)")
print(" The Routlin Dashboard is a web UI for managing the router.")
print(" It runs as a Docker container. Without it, core.json must")
print(" It runs as a Docker container. Without it, config.json must")
print(" be edited manually.")
print()
next_step = (
f"\n Next step: use the web dashboard to configure your network, or\n"
f" configure {SCRIPT_DIR}/core.json manually and then run:\n"
f" configure {SCRIPT_DIR}/config.json manually and then run:\n"
f" sudo python3 {SCRIPT_DIR}/core.py --apply"
)

View file

@ -1,5 +1,5 @@
"""
validation.py -- Shared structural validators for core.json fields.
validation.py -- Shared structural validators for config.json fields.
Lives alongside core.py in ~/routlin/ and is volume-mounted into the
routlin-dash container at /app/validation.py. Importable by both
@ -304,7 +304,7 @@ def derive_interface(vlan, data):
# ===================================================================
def validate_config(data):
"""Validate core.json structure and content. Returns list of error strings."""
"""Validate config.json structure and content. Returns list of error strings."""
errors = []
seen_vlan_ids = {}
seen_interfaces = {}