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

View file

@ -3,7 +3,7 @@ import ipaddress
from flask import Blueprint, request, redirect, flash from flask import Blueprint, request, redirect, flash
from auth import require_level 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 sanitize
import validation as validate import validation as validate
@ -20,7 +20,7 @@ def _row_index():
def _hash_ok(): 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') flash('Configuration was modified by another session. Please refresh and try again.', 'error')
return False return False
return True return True
@ -86,8 +86,8 @@ def add_dhcp_reservation():
if not _hash_ok(): if not _hash_ok():
return redirect(VIEW) return redirect(VIEW)
core = load_core() cfg = load_config()
vlans = core.get('vlans', []) vlans = cfg.get('vlans', [])
vlan = next((v for v in vlans if v.get('name') == vlan_name), None) vlan = next((v for v in vlans if v.get('name') == vlan_name), None)
if vlan is None: if vlan is None:
flash(f'The configuration has not been saved because VLAN "{vlan_name}" was not found.', 'error') 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, 'enabled': True,
} }
vlan.setdefault('reservations', []).append(entry) vlan.setdefault('reservations', []).append(entry)
errors = validate.validate_config(core) errors = validate.validate_config(cfg)
if errors: if errors:
for msg in errors: for msg in errors:
flash(msg, 'error') flash(msg, 'error')
return redirect(VIEW) return redirect(VIEW)
flash(save_core_with_snapshot( flash(save_config_with_snapshot(
core, cfg,
path=f'vlans.{vlan_name}.reservations', key=mac, operation='add', path=f'vlans.{vlan_name}.reservations', key=mac, operation='add',
before=None, after=entry, before=None, after=entry,
description=f'Added DHCP reservation: {hostname or mac} ({ip})', description=f'Added DHCP reservation: {hostname or mac} ({ip})',
@ -132,8 +132,8 @@ def toggle_dhcp_reservation():
if not _hash_ok(): if not _hash_ok():
return redirect(VIEW) return redirect(VIEW)
core = load_core() cfg = load_config()
vlans = core.get('vlans', []) vlans = cfg.get('vlans', [])
vi, ri = _flat_index_to_vlan_res(vlans, idx) vi, ri = _flat_index_to_vlan_res(vlans, idx)
if vi is None: if vi is None:
flash('Entry not found.', 'error') flash('Entry not found.', 'error')
@ -142,7 +142,7 @@ def toggle_dhcp_reservation():
res = vlans[vi]['reservations'][ri] res = vlans[vi]['reservations'][ri]
old_enabled = res.get('enabled', True) old_enabled = res.get('enabled', True)
res['enabled'] = not old_enabled res['enabled'] = not old_enabled
errors = validate.validate_config(core) errors = validate.validate_config(cfg)
if errors: if errors:
for msg in errors: for msg in errors:
flash(msg, 'error') flash(msg, 'error')
@ -150,8 +150,8 @@ def toggle_dhcp_reservation():
vlan_name = vlans[vi]['name'] vlan_name = vlans[vi]['name']
action = 'Enabled' if not old_enabled else 'Disabled' action = 'Enabled' if not old_enabled else 'Disabled'
flash(save_core_with_snapshot( flash(save_config_with_snapshot(
core, cfg,
path=f'vlans.{vlan_name}.reservations', key=res['mac'], operation='toggle', path=f'vlans.{vlan_name}.reservations', key=res['mac'], operation='toggle',
before={'enabled': old_enabled}, after={'enabled': not old_enabled}, before={'enabled': old_enabled}, after={'enabled': not old_enabled},
description=f'{action} DHCP reservation: {res.get("hostname") or res["mac"]}', description=f'{action} DHCP reservation: {res.get("hostname") or res["mac"]}',
@ -181,8 +181,8 @@ def edit_dhcp_reservation():
if not _hash_ok(): if not _hash_ok():
return redirect(VIEW) return redirect(VIEW)
core = load_core() cfg = load_config()
vlans = core.get('vlans', []) vlans = cfg.get('vlans', [])
vi, ri = _flat_index_to_vlan_res(vlans, idx) vi, ri = _flat_index_to_vlan_res(vlans, idx)
if vi is None: if vi is None:
flash('Entry not found.', 'error') flash('Entry not found.', 'error')
@ -203,15 +203,15 @@ def edit_dhcp_reservation():
'radius_client': radius_client, 'radius_client': radius_client,
'enabled': 'enabled' in request.form, 'enabled': 'enabled' in request.form,
}) })
errors = validate.validate_config(core) errors = validate.validate_config(cfg)
if errors: if errors:
for msg in errors: for msg in errors:
flash(msg, 'error') flash(msg, 'error')
return redirect(VIEW) return redirect(VIEW)
vlan_name = vlans[vi]['name'] vlan_name = vlans[vi]['name']
flash(save_core_with_snapshot( flash(save_config_with_snapshot(
core, cfg,
path=f'vlans.{vlan_name}.reservations', key=mac, operation='edit', path=f'vlans.{vlan_name}.reservations', key=mac, operation='edit',
before=before, after=copy.deepcopy(res), before=before, after=copy.deepcopy(res),
description=f'Edited DHCP reservation: {hostname or mac} ({ip})', description=f'Edited DHCP reservation: {hostname or mac} ({ip})',
@ -229,8 +229,8 @@ def delete_dhcp_reservation():
if not _hash_ok(): if not _hash_ok():
return redirect(VIEW) return redirect(VIEW)
core = load_core() cfg = load_config()
vlans = core.get('vlans', []) vlans = cfg.get('vlans', [])
vi, ri = _flat_index_to_vlan_res(vlans, idx) vi, ri = _flat_index_to_vlan_res(vlans, idx)
if vi is None: if vi is None:
flash('Entry not found.', 'error') flash('Entry not found.', 'error')
@ -238,14 +238,14 @@ def delete_dhcp_reservation():
vlan_name = vlans[vi]['name'] vlan_name = vlans[vi]['name']
removed = vlans[vi]['reservations'].pop(ri) removed = vlans[vi]['reservations'].pop(ri)
errors = validate.validate_config(core) errors = validate.validate_config(cfg)
if errors: if errors:
for msg in errors: for msg in errors:
flash(msg, 'error') flash(msg, 'error')
return redirect(VIEW) return redirect(VIEW)
flash(save_core_with_snapshot( flash(save_config_with_snapshot(
core, cfg,
path=f'vlans.{vlan_name}.reservations', key=removed['mac'], operation='delete', path=f'vlans.{vlan_name}.reservations', key=removed['mac'], operation='delete',
before=removed, after=None, before=removed, after=None,
description=f'Deleted DHCP reservation: {removed.get("hostname") or removed["mac"]}', 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 flask import Blueprint, request, redirect, flash
from auth import require_level 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 sanitize
import validation as validate import validation as validate
@ -12,9 +12,9 @@ bp = Blueprint('action_apply_host_overrides', __name__)
VIEW = '/view/view_host_overrides' VIEW = '/view/view_host_overrides'
def _vlan_networks(core): def _vlan_networks(cfg):
nets = [] nets = []
for v in core.get('vlans', []): for v in cfg.get('vlans', []):
subnet = v.get('subnet', '') subnet = v.get('subnet', '')
mask = v.get('subnet_mask', '') mask = v.get('subnet_mask', '')
if subnet and mask: if subnet and mask:
@ -25,12 +25,12 @@ def _vlan_networks(core):
return nets return nets
def _ip_in_vlan(ip_str, core): def _ip_in_vlan(ip_str, cfg):
try: try:
addr = ipaddress.IPv4Address(ip_str) addr = ipaddress.IPv4Address(ip_str)
except ValueError: except ValueError:
return False return False
nets = _vlan_networks(core) nets = _vlan_networks(cfg)
return not nets or any(addr in net for net in nets) return not nets or any(addr in net for net in nets)
@ -42,7 +42,7 @@ def _row_index():
def _hash_ok(): 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') flash('Configuration was modified by another session. Please refresh and try again.', 'error')
return False return False
return True return True
@ -61,21 +61,21 @@ def add_host_override():
if not _hash_ok(): if not _hash_ok():
return redirect(VIEW) return redirect(VIEW)
core = load_core() cfg = load_config()
if not _ip_in_vlan(ip, core): if not _ip_in_vlan(ip, cfg):
flash('IP address does not fall within any configured VLAN subnet.', 'error') flash('IP address does not fall within any configured VLAN subnet.', 'error')
return redirect(VIEW) return redirect(VIEW)
entry = {'description': description, 'host': host, 'ip': ip, 'enabled': True} entry = {'description': description, 'host': host, 'ip': ip, 'enabled': True}
core.setdefault('host_overrides', []).append(entry) cfg.setdefault('host_overrides', []).append(entry)
errors = validate.validate_config(core) errors = validate.validate_config(cfg)
if errors: if errors:
for msg in errors: for msg in errors:
flash(msg, 'error') flash(msg, 'error')
return redirect(VIEW) return redirect(VIEW)
flash(save_core_with_snapshot( flash(save_config_with_snapshot(
core, cfg,
path='host_overrides', key=host, operation='add', path='host_overrides', key=host, operation='add',
before=None, after=entry, before=None, after=entry,
description=f'Added host override: {host}{ip}', description=f'Added host override: {host}{ip}',
@ -93,23 +93,23 @@ def toggle_host_override():
if not _hash_ok(): if not _hash_ok():
return redirect(VIEW) return redirect(VIEW)
core = load_core() cfg = load_config()
items = core.get('host_overrides', []) items = cfg.get('host_overrides', [])
if idx < 0 or idx >= len(items): if idx < 0 or idx >= len(items):
flash('Entry not found.', 'error') flash('Entry not found.', 'error')
return redirect(VIEW) return redirect(VIEW)
old_enabled = items[idx].get('enabled', True) old_enabled = items[idx].get('enabled', True)
items[idx]['enabled'] = not old_enabled items[idx]['enabled'] = not old_enabled
errors = validate.validate_config(core) errors = validate.validate_config(cfg)
if errors: if errors:
for msg in errors: for msg in errors:
flash(msg, 'error') flash(msg, 'error')
return redirect(VIEW) return redirect(VIEW)
action = 'Enabled' if not old_enabled else 'Disabled' action = 'Enabled' if not old_enabled else 'Disabled'
flash(save_core_with_snapshot( flash(save_config_with_snapshot(
core, cfg,
path='host_overrides', key=items[idx]['host'], operation='toggle', path='host_overrides', key=items[idx]['host'], operation='toggle',
before={'enabled': old_enabled}, after={'enabled': not old_enabled}, before={'enabled': old_enabled}, after={'enabled': not old_enabled},
description=f'{action} host override: {items[idx]["host"]}', description=f'{action} host override: {items[idx]["host"]}',
@ -136,26 +136,26 @@ def edit_host_override():
if not _hash_ok(): if not _hash_ok():
return redirect(VIEW) return redirect(VIEW)
core = load_core() cfg = load_config()
if not _ip_in_vlan(ip, core): if not _ip_in_vlan(ip, cfg):
flash('IP address does not fall within any configured VLAN subnet.', 'error') flash('IP address does not fall within any configured VLAN subnet.', 'error')
return redirect(VIEW) return redirect(VIEW)
items = core.get('host_overrides', []) items = cfg.get('host_overrides', [])
if idx < 0 or idx >= len(items): if idx < 0 or idx >= len(items):
flash('Entry not found.', 'error') flash('Entry not found.', 'error')
return redirect(VIEW) return redirect(VIEW)
before = copy.deepcopy(items[idx]) before = copy.deepcopy(items[idx])
items[idx].update({'description': description, 'host': host, 'ip': ip, 'enabled': enabled}) items[idx].update({'description': description, 'host': host, 'ip': ip, 'enabled': enabled})
errors = validate.validate_config(core) errors = validate.validate_config(cfg)
if errors: if errors:
for msg in errors: for msg in errors:
flash(msg, 'error') flash(msg, 'error')
return redirect(VIEW) return redirect(VIEW)
flash(save_core_with_snapshot( flash(save_config_with_snapshot(
core, cfg,
path='host_overrides', key=host, operation='edit', path='host_overrides', key=host, operation='edit',
before=before, after=copy.deepcopy(items[idx]), before=before, after=copy.deepcopy(items[idx]),
description=f'Edited host override: {host}{ip}', description=f'Edited host override: {host}{ip}',
@ -173,21 +173,21 @@ def delete_host_override():
if not _hash_ok(): if not _hash_ok():
return redirect(VIEW) return redirect(VIEW)
core = load_core() cfg = load_config()
items = core.get('host_overrides', []) items = cfg.get('host_overrides', [])
if idx < 0 or idx >= len(items): if idx < 0 or idx >= len(items):
flash('Entry not found.', 'error') flash('Entry not found.', 'error')
return redirect(VIEW) return redirect(VIEW)
removed = items.pop(idx) removed = items.pop(idx)
errors = validate.validate_config(core) errors = validate.validate_config(cfg)
if errors: if errors:
for msg in errors: for msg in errors:
flash(msg, 'error') flash(msg, 'error')
return redirect(VIEW) return redirect(VIEW)
flash(save_core_with_snapshot( flash(save_config_with_snapshot(
core, cfg,
path='host_overrides', key=removed['host'], operation='delete', path='host_overrides', key=removed['host'], operation='delete',
before=removed, after=None, before=removed, after=None,
description=f'Deleted host override: {removed["host"]}', description=f'Deleted host override: {removed["host"]}',

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,6 +1,6 @@
from flask import Blueprint, request, redirect, flash from flask import Blueprint, request, redirect, flash
from auth import require_level 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 sanitize
import validation as validate import validation as validate
@ -28,27 +28,27 @@ def upstreamdns_cardupstreamdns_save():
return redirect(_VIEW) return redirect(_VIEW)
upstream_servers.append(clean) 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') flash('Configuration was modified by another session. Please refresh and try again.', 'error')
return redirect(_VIEW) return redirect(_VIEW)
core = load_core() cfg = load_config()
current = core.get('upstream_dns', {}) current = cfg.get('upstream_dns', {})
if (strict_order == bool(current.get('strict_order', False)) and if (strict_order == bool(current.get('strict_order', False)) and
upstream_servers == current.get('upstream_servers', [])): upstream_servers == current.get('upstream_servers', [])):
flash('No changes detected.', 'info') flash('No changes detected.', 'info')
return redirect(_VIEW) return redirect(_VIEW)
core.setdefault('upstream_dns', {}).update({ cfg.setdefault('upstream_dns', {}).update({
'strict_order': strict_order, 'strict_order': strict_order,
'upstream_servers': upstream_servers, 'upstream_servers': upstream_servers,
}) })
errors = validate.validate_config(core) errors = validate.validate_config(cfg)
if errors: if errors:
for msg in errors: for msg in errors:
flash(msg, 'error') flash(msg, 'error')
return redirect(_VIEW) return redirect(_VIEW)
save_core(core) save_config(cfg)
flash(queued_msg('core apply'), 'success') flash(queued_msg('core apply'), 'success')
return redirect(_VIEW) return redirect(_VIEW)
@ -61,22 +61,22 @@ def upstreamdns_cardforwardingdnsservice_save():
flash('Cache Size must be a non-negative integer.', 'error') flash('Cache Size must be a non-negative integer.', 'error')
return redirect(_VIEW) 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') flash('Configuration was modified by another session. Please refresh and try again.', 'error')
return redirect(_VIEW) return redirect(_VIEW)
core = load_core() cfg = load_config()
current = core.get('upstream_dns', {}) current = cfg.get('upstream_dns', {})
if cache_size == int(current.get('cache_size', 0)): if cache_size == int(current.get('cache_size', 0)):
flash('No changes detected.', 'info') flash('No changes detected.', 'info')
return redirect(_VIEW) return redirect(_VIEW)
core.setdefault('upstream_dns', {})['cache_size'] = cache_size cfg.setdefault('upstream_dns', {})['cache_size'] = cache_size
errors = validate.validate_config(core) errors = validate.validate_config(cfg)
if errors: if errors:
for msg in errors: for msg in errors:
flash(msg, 'error') flash(msg, 'error')
return redirect(_VIEW) return redirect(_VIEW)
save_core(core) save_config(cfg)
flash(queued_msg('core apply'), 'success') flash(queued_msg('core apply'), 'success')
return redirect(_VIEW) return redirect(_VIEW)

View file

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

View file

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

View file

@ -627,7 +627,8 @@
{ {
"type": "button_secondary", "type": "button_secondary",
"formaction": "/action/actions_cardpending_revertselected", "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 | | 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 | | `ddns.json` | DDNS provider credentials, hostnames/subdomains, update interval, IP-check services |
### Dotfiles (auto-generated, do not edit) ### 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 ## Initial Configuration
### 1. Edit Core Configuration (`core.json`) ### 1. Edit Core Configuration (`config.json`)
Edit the top-level `network_interfaces` block: 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. **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 ```json
"mdns_reflection": { "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): 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 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 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 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 --start # Run update and install systemd timer
sudo python3 ddns.py --disable # Stop updates and remove 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 --force # Force update regardless of cached IP
python3 ddns.py --status # Timer/service status python3 ddns.py --status # Timer/service status
python3 ddns.py --getip # Print current public IP and exit python3 ddns.py --getip # Print current public IP and exit

View file

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

View file

@ -1,8 +1,8 @@
#!/usr/bin/env python3 #!/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. 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 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 from pathlib import Path
SCRIPT_DIR = Path(__file__).parent SCRIPT_DIR = Path(__file__).parent
CONFIG_FILE = SCRIPT_DIR / "core.json" CONFIG_FILE = SCRIPT_DIR / "config.json"
def die(msg): 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) vlan = next((v for v in wg_vlans if resolve_wg_iface(v, data) == iface), None)
if vlan is None: if vlan is None:
known = ", ".join(resolve_wg_iface(v, data) for v in wg_vlans) or "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}.") f"Known WireGuard interfaces: {known}.")
return vlan return vlan
@ -71,12 +71,12 @@ def find_wg_vlan(data, iface=None, vlan_id=None):
known = ", ".join( known = ", ".join(
f"{v['vlan_id']} ({resolve_wg_iface(v, data)})" for v in wg_vlans f"{v['vlan_id']} ({resolve_wg_iface(v, data)})" for v in wg_vlans
) or "none" ) 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}.") f"Known WireGuard VLANs: {known}.")
return vlan return vlan
if not wg_vlans: 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.") "Add a VLAN with is_vpn set to true.")
if len(wg_vlans) > 1: if len(wg_vlans) > 1:
options = " " + "\n ".join( options = " " + "\n ".join(
@ -149,7 +149,7 @@ def build_client_conf(vlan, peer_ip, private_key, server_pub, split_tunnel):
def main(): def main():
parser = argparse.ArgumentParser( 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("--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)") 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() private_key, public_key = generate_keypair()
srv_pub = server_pubkey(iface) srv_pub = server_pubkey(iface)
# -- Update core.json ------------------------------------------------------ # -- Update config.json ------------------------------------------------------
peers.append({ peers.append({
"name": args.name, "name": args.name,
"ip": peer_ip, "ip": peer_ip,
@ -207,7 +207,7 @@ def main():
"enabled": True, "enabled": True,
}) })
save_config(data) save_config(data)
print(f"Added peer '{args.name}' to core.json.") print(f"Added peer '{args.name}' to config.json.")
# -- Write client conf ----------------------------------------------------- # -- Write client conf -----------------------------------------------------
conf_content = build_client_conf(vlan, peer_ip, private_key, srv_pub, args.split_tunnel) 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. 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 and updates each enabled provider block only if the IP has changed
since the last successful update for that provider. since the last successful update for that provider.
Designed to be run on a systemd timer managed by core.py --apply. 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. Log is cleared when it exceeds general.log_max_kb from config.
Usage: 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 --force Force update regardless of cached IP
python3 ddns.py --getip Print current public IP and exit python3 ddns.py --getip Print current public IP and exit
""" """
@ -32,7 +32,7 @@ import logging
from pathlib import Path from pathlib import Path
SCRIPT_DIR = Path(__file__).parent 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" CACHE_SERVICE_FILE = SCRIPT_DIR / ".ddns-last-service"
LOG_FILE = SCRIPT_DIR / "ddns.log" LOG_FILE = SCRIPT_DIR / "ddns.log"
@ -512,18 +512,18 @@ def main():
formatter_class=argparse.RawDescriptionHelpFormatter, formatter_class=argparse.RawDescriptionHelpFormatter,
epilog=( epilog=(
"examples:\n" "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 --force Force update regardless of cached IP\n"
" python3 ddns.py --getip Print current public IP and exit\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("--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") parser.add_argument("--getip", action="store_true", help="Print current public IP and exit")
args = parser.parse_args() 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() parser.print_help()
return return
@ -540,7 +540,7 @@ def main():
general = cfg["general"] general = cfg["general"]
setup_logging(general["log_max_kb"], general["log_errors_only"]) 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) run_update(cfg, force=args.force)
if __name__ == "__main__": if __name__ == "__main__":

View file

@ -1,8 +1,8 @@
#!/usr/bin/env python3 #!/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 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 combination of blocklist names), then sends SIGHUP to each running dnsmasq
instance so it reloads its config without restarting. instance so it reloads its config without restarting.
@ -23,7 +23,7 @@ from pathlib import Path
PRODUCT_NAME = "routlin" PRODUCT_NAME = "routlin"
SCRIPT_DIR = Path(__file__).parent SCRIPT_DIR = Path(__file__).parent
CONFIG_FILE = SCRIPT_DIR / "core.json" CONFIG_FILE = SCRIPT_DIR / "config.json"
BLOCKLIST_DIR = SCRIPT_DIR / "blocklists" BLOCKLIST_DIR = SCRIPT_DIR / "blocklists"
LOG_FILE = SCRIPT_DIR / "dns-blocklists.log" LOG_FILE = SCRIPT_DIR / "dns-blocklists.log"
@ -80,7 +80,7 @@ def load_config():
with open(CONFIG_FILE) as f: with open(CONFIG_FILE) as f:
data = json.load(f) data = json.load(f)
if not data.get("vlans"): if not data.get("vlans"):
die("No vlans defined in core.json.") die("No vlans defined in config.json.")
return data return data

View file

@ -1,7 +1,7 @@
""" """
health.py -- System health checks for Routlin. 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. .health JSON. Imported by core.py; also runnable standalone.
Public API: Public API:
@ -29,7 +29,7 @@ from validation import derive_interface, derive_vlan_id, is_wg
PRODUCT_NAME = "routlin" PRODUCT_NAME = "routlin"
SCRIPT_DIR = Path(__file__).parent SCRIPT_DIR = Path(__file__).parent
HEALTH_FILE = SCRIPT_DIR / ".health" HEALTH_FILE = SCRIPT_DIR / ".health"
CONFIG_FILE = SCRIPT_DIR / "core.json" CONFIG_FILE = SCRIPT_DIR / "config.json"
BLOCKLIST_DIR = SCRIPT_DIR / "blocklists" BLOCKLIST_DIR = SCRIPT_DIR / "blocklists"
DNSMASQ_CONF_DIR = Path(f"/etc/dnsmasq-{PRODUCT_NAME}") DNSMASQ_CONF_DIR = Path(f"/etc/dnsmasq-{PRODUCT_NAME}")
LEASES_DIR = Path("/var/lib/misc") LEASES_DIR = Path("/var/lib/misc")
@ -532,7 +532,7 @@ def check_configurations(data):
f"DHCP pool ({vlan['name']})", "warning", f"DHCP pool ({vlan['name']})", "warning",
f"DHCP pool for VLAN '{vlan['name']}' is {pct}% full " f"DHCP pool for VLAN '{vlan['name']}' is {pct}% full "
f"({len(leases)}/{pool_size} leases).", 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']}`")) f"with: `sudo python3 core.py --reset-leases {vlan['name']}`"))
else: else:
results.append(_ok(f"dhcp_pool_{vlan['name']}", results.append(_ok(f"dhcp_pool_{vlan['name']}",
@ -596,7 +596,7 @@ def check_configurations(data):
results.append(_problem( results.append(_problem(
"upstream_dns", "Upstream DNS reachability", "warning", "upstream_dns", "Upstream DNS reachability", "warning",
f"Upstream DNS server(s) unreachable on port 53: {', '.join(unreachable)}.", 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: elif servers:
results.append(_ok("upstream_dns", "Upstream DNS reachability")) results.append(_ok("upstream_dns", "Upstream DNS reachability"))

View file

@ -564,13 +564,13 @@ def main():
# -- Dashboard ------------------------------------------------- # -- Dashboard -------------------------------------------------
header("Dashboard (optional)") header("Dashboard (optional)")
print(" The Routlin Dashboard is a web UI for managing the router.") 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(" be edited manually.")
print() print()
next_step = ( next_step = (
f"\n Next step: use the web dashboard to configure your network, or\n" 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" 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 Lives alongside core.py in ~/routlin/ and is volume-mounted into the
routlin-dash container at /app/validation.py. Importable by both routlin-dash container at /app/validation.py. Importable by both
@ -304,7 +304,7 @@ def derive_interface(vlan, data):
# =================================================================== # ===================================================================
def validate_config(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 = [] errors = []
seen_vlan_ids = {} seen_vlan_ids = {}
seen_interfaces = {} seen_interfaces = {}