UI improvements and input validations
This commit is contained in:
parent
b8c4914a52
commit
270856b391
22 changed files with 1548 additions and 302 deletions
|
|
@ -2,6 +2,7 @@ FROM python:3.12-slim
|
|||
WORKDIR /app
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
ARG CACHE_BUST
|
||||
COPY app/*.py .
|
||||
EXPOSE 25327
|
||||
CMD ["python", "main.py"]
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
from flask import Blueprint, request, redirect, flash
|
||||
from auth import require_level
|
||||
from config_utils import load_core, save_core, verify_core_hash, apply_msg
|
||||
from config_utils import load_core, save_core, verify_core_hash, queued_msg
|
||||
import sanitize
|
||||
import validate
|
||||
|
||||
|
|
@ -55,7 +55,7 @@ def add_banned_ip():
|
|||
})
|
||||
save_core(core)
|
||||
|
||||
flash(apply_msg(), 'success')
|
||||
flash(queued_msg('core apply'), 'success')
|
||||
return redirect(VIEW)
|
||||
|
||||
|
||||
|
|
@ -79,7 +79,7 @@ def toggle_banned_ip():
|
|||
items[idx]['enabled'] = not items[idx].get('enabled', True)
|
||||
save_core(core)
|
||||
|
||||
flash(apply_msg(), 'success')
|
||||
flash(queued_msg('core apply'), 'success')
|
||||
return redirect(VIEW)
|
||||
|
||||
|
||||
|
|
@ -109,7 +109,7 @@ def edit_banned_ip():
|
|||
items[idx].update({'description': description, 'ip': ip, 'enabled': enabled})
|
||||
save_core(core)
|
||||
|
||||
flash(apply_msg(), 'success')
|
||||
flash(queued_msg('core apply'), 'success')
|
||||
return redirect(VIEW)
|
||||
|
||||
|
||||
|
|
@ -133,5 +133,5 @@ def delete_banned_ip():
|
|||
removed = items.pop(idx)
|
||||
save_core(core)
|
||||
|
||||
flash(apply_msg(), 'success')
|
||||
flash(queued_msg('core apply'), 'success')
|
||||
return redirect(VIEW)
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
from flask import Blueprint, request, redirect, flash
|
||||
from auth import require_level
|
||||
from config_utils import load_core, save_core, verify_core_hash, run_update_blocklists, apply_msg
|
||||
from config_utils import load_core, save_core, verify_core_hash, queued_msg
|
||||
import re
|
||||
import sanitize
|
||||
import validate
|
||||
|
|
@ -78,7 +78,7 @@ def add_blocklist():
|
|||
})
|
||||
save_core(core)
|
||||
|
||||
flash(apply_msg(), 'success')
|
||||
flash(queued_msg('core apply'), 'success')
|
||||
return redirect(VIEW)
|
||||
|
||||
|
||||
|
|
@ -112,7 +112,7 @@ def edit_blocklist():
|
|||
})
|
||||
save_core(core)
|
||||
|
||||
flash(apply_msg(), 'success')
|
||||
flash(queued_msg('core apply'), 'success')
|
||||
return redirect(VIEW)
|
||||
|
||||
|
||||
|
|
@ -136,13 +136,12 @@ def delete_blocklist():
|
|||
removed = items.pop(idx)
|
||||
save_core(core)
|
||||
|
||||
flash(apply_msg(), 'success')
|
||||
flash(queued_msg('core apply'), 'success')
|
||||
return redirect(VIEW)
|
||||
|
||||
|
||||
@bp.route('/action/update_blocklists', methods=['POST'])
|
||||
@require_level('administrator')
|
||||
def update_blocklists():
|
||||
run_update_blocklists()
|
||||
flash('Blocklist refresh triggered.', 'success')
|
||||
flash(queued_msg('core update-blocklists'), 'success')
|
||||
return redirect(VIEW)
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
from flask import Blueprint, request, redirect, flash
|
||||
from auth import require_level
|
||||
from config_utils import load_core, save_core, verify_core_hash, apply_msg
|
||||
from config_utils import load_core, save_core, verify_core_hash, queued_msg
|
||||
import sanitize
|
||||
import validate
|
||||
|
||||
|
|
@ -86,7 +86,7 @@ def add_dhcp_reservation():
|
|||
})
|
||||
save_core(core)
|
||||
|
||||
flash(apply_msg(), 'success')
|
||||
flash(queued_msg('core apply'), 'success')
|
||||
return redirect(VIEW)
|
||||
|
||||
|
||||
|
|
@ -112,7 +112,7 @@ def toggle_dhcp_reservation():
|
|||
res['enabled'] = not res.get('enabled', True)
|
||||
save_core(core)
|
||||
|
||||
flash(apply_msg(), 'success')
|
||||
flash(queued_msg('core apply'), 'success')
|
||||
return redirect(VIEW)
|
||||
|
||||
|
||||
|
|
@ -157,7 +157,7 @@ def edit_dhcp_reservation():
|
|||
})
|
||||
save_core(core)
|
||||
|
||||
flash(apply_msg(), 'success')
|
||||
flash(queued_msg('core apply'), 'success')
|
||||
return redirect(VIEW)
|
||||
|
||||
|
||||
|
|
@ -182,5 +182,5 @@ def delete_dhcp_reservation():
|
|||
removed = vlans[vi]['reservations'].pop(ri)
|
||||
save_core(core)
|
||||
|
||||
flash(apply_msg(), 'success')
|
||||
flash(queued_msg('core apply'), 'success')
|
||||
return redirect(VIEW)
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
from flask import Blueprint, request, redirect, flash
|
||||
from auth import require_level
|
||||
from config_utils import load_core, save_core, verify_core_hash, apply_msg
|
||||
from config_utils import load_core, save_core, verify_core_hash, queued_msg
|
||||
import sanitize
|
||||
|
||||
bp = Blueprint('action_apply_general', __name__)
|
||||
|
|
@ -35,5 +35,5 @@ def apply_general():
|
|||
})
|
||||
save_core(core)
|
||||
|
||||
flash(apply_msg(), 'success')
|
||||
flash(queued_msg('core apply'), 'success')
|
||||
return redirect('/view/view_general')
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import ipaddress
|
|||
|
||||
from flask import Blueprint, request, redirect, flash
|
||||
from auth import require_level
|
||||
from config_utils import load_core, save_core, verify_core_hash, apply_msg
|
||||
from config_utils import load_core, save_core, verify_core_hash, queued_msg
|
||||
import sanitize
|
||||
|
||||
bp = Blueprint('action_apply_host_overrides', __name__)
|
||||
|
|
@ -74,7 +74,7 @@ def add_host_override():
|
|||
})
|
||||
save_core(core)
|
||||
|
||||
flash(apply_msg(), 'success')
|
||||
flash(queued_msg('core apply'), 'success')
|
||||
return redirect(VIEW)
|
||||
|
||||
|
||||
|
|
@ -98,7 +98,7 @@ def toggle_host_override():
|
|||
items[idx]['enabled'] = not items[idx].get('enabled', True)
|
||||
save_core(core)
|
||||
|
||||
flash(apply_msg(), 'success')
|
||||
flash(queued_msg('core apply'), 'success')
|
||||
return redirect(VIEW)
|
||||
|
||||
|
||||
|
|
@ -135,7 +135,7 @@ def edit_host_override():
|
|||
items[idx].update({'description': description, 'host': host, 'ip': ip, 'enabled': enabled})
|
||||
save_core(core)
|
||||
|
||||
flash(apply_msg(), 'success')
|
||||
flash(queued_msg('core apply'), 'success')
|
||||
return redirect(VIEW)
|
||||
|
||||
|
||||
|
|
@ -159,5 +159,5 @@ def delete_host_override():
|
|||
removed = items.pop(idx)
|
||||
save_core(core)
|
||||
|
||||
flash(apply_msg(), 'success')
|
||||
flash(queued_msg('core apply'), 'success')
|
||||
return redirect(VIEW)
|
||||
|
|
|
|||
80
docker/router-dash/app/action_apply_iface_config.py
Normal file
80
docker/router-dash/app/action_apply_iface_config.py
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
import os
|
||||
|
||||
from flask import Blueprint, request, redirect, flash
|
||||
from auth import require_level
|
||||
from config_utils import verify_core_hash, queued_msg, queue_command
|
||||
import sanitize
|
||||
|
||||
bp = Blueprint('action_apply_iface_config', __name__)
|
||||
|
||||
_VIEW = '/view/view_general'
|
||||
|
||||
_EXCLUDE_PREFIXES = ('lo', 'wg', 'docker', 'br-', 'veth',
|
||||
'tun', 'tap', 'ppp', 'virbr',
|
||||
'podman', 'vnet', 'macvtap', 'fc-')
|
||||
|
||||
def _valid_interface(name):
|
||||
try:
|
||||
return name in {
|
||||
n for n in os.listdir('/sys/class/net')
|
||||
if not n.startswith(_EXCLUDE_PREFIXES)
|
||||
and os.path.exists(f'/sys/class/net/{n}/device')
|
||||
}
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
@bp.route('/action/apply_iface_config', methods=['POST'])
|
||||
@require_level('administrator')
|
||||
def apply_iface_config():
|
||||
if not verify_core_hash(request.form.get('config_hash', '')):
|
||||
flash('Configuration was modified by another session. Please refresh and try again.', 'error')
|
||||
return redirect(_VIEW)
|
||||
|
||||
iface = sanitize.interface_name(request.form.get('iface', ''))
|
||||
mtu = request.form.get('mtu', '').strip()
|
||||
mac = sanitize.mac(request.form.get('mac', ''))
|
||||
original_mtu = request.form.get('original_mtu', '').strip()
|
||||
original_mac = sanitize.mac(request.form.get('original_mac', ''))
|
||||
|
||||
if not iface:
|
||||
flash('No interface specified.', 'error')
|
||||
return redirect(_VIEW)
|
||||
|
||||
if not _valid_interface(iface):
|
||||
flash(f"Interface '{iface}' does not exist on this system.", 'error')
|
||||
return redirect(_VIEW)
|
||||
|
||||
mtu_int = None
|
||||
if mtu:
|
||||
try:
|
||||
mtu_int = int(mtu)
|
||||
if not (68 <= mtu_int <= 9000):
|
||||
raise ValueError
|
||||
except ValueError:
|
||||
flash('MTU must be an integer between 68 and 9000.', 'error')
|
||||
return redirect(_VIEW)
|
||||
|
||||
mac_raw = request.form.get('mac', '').strip()
|
||||
if mac_raw and not mac:
|
||||
flash('MAC address must be in the format aa:bb:cc:dd:ee:ff.', 'error')
|
||||
return redirect(_VIEW)
|
||||
|
||||
if not mtu_int and not mac:
|
||||
flash('No changes specified.', 'error')
|
||||
return redirect(_VIEW)
|
||||
|
||||
queued = False
|
||||
if mtu_int and str(mtu_int) != original_mtu:
|
||||
queue_command(f'mtu {iface} {mtu_int}')
|
||||
queued = True
|
||||
if mac and mac != original_mac:
|
||||
queue_command(f'mac {iface} {mac}')
|
||||
queued = True
|
||||
|
||||
if not queued:
|
||||
flash('No changes detected.', 'info')
|
||||
return redirect(_VIEW)
|
||||
|
||||
flash(queued_msg(), 'success')
|
||||
return redirect(_VIEW)
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
from flask import Blueprint, request, redirect, flash
|
||||
from auth import require_level
|
||||
from config_utils import load_core, save_core, verify_core_hash, apply_msg
|
||||
from config_utils import load_core, save_core, verify_core_hash, queued_msg
|
||||
import sanitize
|
||||
import validate
|
||||
|
||||
|
|
@ -85,7 +85,7 @@ def add_inter_vlan():
|
|||
core.setdefault('inter_vlan_exceptions', []).append(entry)
|
||||
save_core(core)
|
||||
|
||||
flash(apply_msg(), 'success')
|
||||
flash(queued_msg('core apply'), 'success')
|
||||
return redirect(VIEW)
|
||||
|
||||
|
||||
|
|
@ -109,7 +109,7 @@ def toggle_inter_vlan():
|
|||
items[idx]['enabled'] = not items[idx].get('enabled', True)
|
||||
save_core(core)
|
||||
|
||||
flash(apply_msg(), 'success')
|
||||
flash(queued_msg('core apply'), 'success')
|
||||
return redirect(VIEW)
|
||||
|
||||
|
||||
|
|
@ -138,7 +138,7 @@ def edit_inter_vlan():
|
|||
items[idx]['enabled'] = request.form.get('enabled') == 'on'
|
||||
save_core(core)
|
||||
|
||||
flash(apply_msg(), 'success')
|
||||
flash(queued_msg('core apply'), 'success')
|
||||
return redirect(VIEW)
|
||||
|
||||
|
||||
|
|
@ -162,5 +162,5 @@ def delete_inter_vlan():
|
|||
items.pop(idx)
|
||||
save_core(core)
|
||||
|
||||
flash(apply_msg(), 'success')
|
||||
flash(queued_msg('core apply'), 'success')
|
||||
return redirect(VIEW)
|
||||
|
|
|
|||
|
|
@ -1,9 +1,8 @@
|
|||
import re
|
||||
import subprocess
|
||||
import os
|
||||
|
||||
from flask import Blueprint, request, redirect, flash
|
||||
from auth import require_level
|
||||
from config_utils import load_core, save_core, verify_core_hash, apply_msg
|
||||
from config_utils import load_core, save_core, verify_core_hash, queued_msg
|
||||
import sanitize
|
||||
|
||||
bp = Blueprint('action_apply_interface', __name__)
|
||||
|
|
@ -11,11 +10,17 @@ bp = Blueprint('action_apply_interface', __name__)
|
|||
_VIEW = '/view/view_general'
|
||||
|
||||
|
||||
_EXCLUDE_PREFIXES = ('lo', 'wg', 'docker', 'br-', 'veth',
|
||||
'tun', 'tap', 'ppp', 'virbr',
|
||||
'podman', 'vnet', 'macvtap', 'fc-')
|
||||
|
||||
def _get_system_interfaces():
|
||||
try:
|
||||
r = subprocess.run(['ip', 'link', 'show'], capture_output=True, text=True, timeout=5)
|
||||
names = re.findall(r'^\d+:\s+(\S+):', r.stdout, re.MULTILINE)
|
||||
return {n.split('@')[0] for n in names} - {'lo'}
|
||||
return {
|
||||
n for n in os.listdir('/sys/class/net')
|
||||
if not n.startswith(_EXCLUDE_PREFIXES)
|
||||
and os.path.exists(f'/sys/class/net/{n}/device')
|
||||
}
|
||||
except Exception:
|
||||
return set()
|
||||
|
||||
|
|
@ -23,41 +28,32 @@ def _get_system_interfaces():
|
|||
@bp.route('/action/apply_interface', methods=['POST'])
|
||||
@require_level('administrator')
|
||||
def apply_interface():
|
||||
idx_raw = request.form.get('row_index', '').strip()
|
||||
interface = sanitize.interface_name(request.form.get('interface', ''))
|
||||
wan = sanitize.interface_name(request.form.get('wan_interface', ''))
|
||||
lan = sanitize.interface_name(request.form.get('lan_interface', ''))
|
||||
|
||||
try:
|
||||
idx = int(idx_raw)
|
||||
if idx not in (0, 1):
|
||||
raise ValueError
|
||||
except (ValueError, TypeError):
|
||||
flash('Invalid request.', 'error')
|
||||
if not wan or not lan:
|
||||
flash('Both WAN and LAN interfaces are required.', 'error')
|
||||
return redirect(_VIEW)
|
||||
|
||||
if not interface:
|
||||
flash('Interface name is required.', 'error')
|
||||
if wan == lan:
|
||||
flash('WAN and LAN interfaces must be different.', 'error')
|
||||
return redirect(_VIEW)
|
||||
|
||||
if not verify_core_hash(request.form.get('config_hash', '')):
|
||||
flash('Configuration was modified by another session. Please refresh and try again.', 'error')
|
||||
return redirect(_VIEW)
|
||||
|
||||
available = _get_system_interfaces()
|
||||
for iface in (wan, lan):
|
||||
if available and iface not in available:
|
||||
flash(f"Interface '{iface}' does not exist on this system.", 'error')
|
||||
return redirect(_VIEW)
|
||||
|
||||
core = load_core()
|
||||
gen = core.setdefault('general', {})
|
||||
|
||||
other_key = 'lan_interface' if idx == 0 else 'wan_interface'
|
||||
if interface == gen.get(other_key, ''):
|
||||
flash('WAN and LAN interfaces must be different.', 'error')
|
||||
return redirect(_VIEW)
|
||||
|
||||
available = _get_system_interfaces()
|
||||
if available and interface not in available:
|
||||
flash(f"Interface '{interface}' does not exist on this system.", 'error')
|
||||
return redirect(_VIEW)
|
||||
|
||||
key = 'wan_interface' if idx == 0 else 'lan_interface'
|
||||
gen[key] = interface
|
||||
gen['wan_interface'] = wan
|
||||
gen['lan_interface'] = lan
|
||||
save_core(core)
|
||||
|
||||
flash(apply_msg(), 'success')
|
||||
flash(queued_msg('core apply'), 'success')
|
||||
return redirect(_VIEW)
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
from flask import Blueprint, request, redirect, flash
|
||||
from auth import require_level
|
||||
from config_utils import load_core, save_core, verify_core_hash, apply_msg
|
||||
from config_utils import load_core, save_core, verify_core_hash, queued_msg
|
||||
import sanitize
|
||||
|
||||
bp = Blueprint('action_apply_mdns', __name__)
|
||||
|
|
@ -25,5 +25,5 @@ def apply_mdns():
|
|||
})
|
||||
save_core(core)
|
||||
|
||||
flash(apply_msg(), 'success')
|
||||
flash(queued_msg('core apply'), 'success')
|
||||
return redirect('/view/view_mdns')
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
from flask import Blueprint, request, redirect, flash
|
||||
from auth import require_level
|
||||
from config_utils import load_core, save_core, verify_core_hash, apply_msg
|
||||
from config_utils import load_core, save_core, verify_core_hash, queued_msg
|
||||
import sanitize
|
||||
import validate
|
||||
|
||||
|
|
@ -86,7 +86,7 @@ def add_port_forward():
|
|||
core.setdefault('port_forwarding', []).append(entry)
|
||||
save_core(core)
|
||||
|
||||
flash(apply_msg(), 'success')
|
||||
flash(queued_msg('core apply'), 'success')
|
||||
return redirect(VIEW)
|
||||
|
||||
|
||||
|
|
@ -110,7 +110,7 @@ def toggle_port_forward():
|
|||
items[idx]['enabled'] = not items[idx].get('enabled', True)
|
||||
save_core(core)
|
||||
|
||||
flash(apply_msg(), 'success')
|
||||
flash(queued_msg('core apply'), 'success')
|
||||
return redirect(VIEW)
|
||||
|
||||
|
||||
|
|
@ -139,7 +139,7 @@ def edit_port_forward():
|
|||
items[idx]['enabled'] = request.form.get('enabled') == 'on'
|
||||
save_core(core)
|
||||
|
||||
flash(apply_msg(), 'success')
|
||||
flash(queued_msg('core apply'), 'success')
|
||||
return redirect(VIEW)
|
||||
|
||||
|
||||
|
|
@ -163,5 +163,5 @@ def delete_port_forward():
|
|||
removed = items.pop(idx)
|
||||
save_core(core)
|
||||
|
||||
flash(apply_msg(), 'success')
|
||||
flash(queued_msg('core apply'), 'success')
|
||||
return redirect(VIEW)
|
||||
|
|
|
|||
|
|
@ -1,19 +1,30 @@
|
|||
from flask import Blueprint, request, redirect, flash
|
||||
from auth import require_level
|
||||
from config_utils import load_core, save_core, verify_core_hash, apply_msg
|
||||
from config_utils import load_core, save_core, verify_core_hash, queued_msg
|
||||
import sanitize
|
||||
|
||||
bp = Blueprint('action_apply_upstream_dns', __name__)
|
||||
|
||||
|
||||
|
||||
@bp.route('/action/apply_upstream_dns', methods=['POST'])
|
||||
@require_level('administrator')
|
||||
def apply_upstream_dns():
|
||||
strict_order = 'strict_order' in request.form
|
||||
cache_size_raw = request.form.get('cache_size', '').strip()
|
||||
upstream_servers = [sanitize.ip(s) for s in request.form.getlist('upstream_servers') if s.strip()]
|
||||
upstream_servers = [s for s in upstream_servers if s]
|
||||
submitted = request.form.getlist('upstream_servers')
|
||||
|
||||
for s in submitted:
|
||||
if not s.strip():
|
||||
flash('Remove blank server entries before saving.', 'error')
|
||||
return redirect('/view/view_upstream_dns')
|
||||
|
||||
upstream_servers = []
|
||||
for s in submitted:
|
||||
clean = sanitize.ip(s.strip())
|
||||
if not clean:
|
||||
flash(f"'{s.strip()}' is not a valid IP address.", 'error')
|
||||
return redirect('/view/view_upstream_dns')
|
||||
upstream_servers.append(clean)
|
||||
|
||||
try:
|
||||
cache_size = int(cache_size_raw)
|
||||
|
|
@ -28,12 +39,18 @@ def apply_upstream_dns():
|
|||
return redirect('/view/view_upstream_dns')
|
||||
|
||||
core = load_core()
|
||||
current = core.get('upstream_dns', {})
|
||||
if (strict_order == bool(current.get('strict_order', False)) and
|
||||
cache_size == int(current.get('cache_size', 0)) and
|
||||
upstream_servers == current.get('upstream_servers', [])):
|
||||
flash('No changes detected.', 'info')
|
||||
return redirect('/view/view_upstream_dns')
|
||||
|
||||
core.setdefault('upstream_dns', {}).update({
|
||||
'strict_order': strict_order,
|
||||
'cache_size': cache_size,
|
||||
'upstream_servers': upstream_servers,
|
||||
})
|
||||
save_core(core)
|
||||
|
||||
flash(apply_msg(), 'success')
|
||||
flash(queued_msg('core apply'), 'success')
|
||||
return redirect('/view/view_upstream_dns')
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
from flask import Blueprint, request, redirect, flash
|
||||
from auth import require_level
|
||||
from config_utils import load_core, save_core, verify_core_hash, apply_msg
|
||||
from config_utils import load_core, save_core, verify_core_hash, queued_msg
|
||||
import sanitize
|
||||
import ipaddress as _ipaddress
|
||||
|
||||
|
|
@ -24,7 +24,7 @@ def _hash_ok():
|
|||
|
||||
|
||||
def _derive_vlan_id(subnet, prefix):
|
||||
"""Return VLAN ID (1–4094) derived from the active octet of the network address,
|
||||
"""Return VLAN ID (1-4094) derived from the active octet of the network address,
|
||||
or None if not derivable. byte_index = (prefix-1) // 8."""
|
||||
try:
|
||||
network = _ipaddress.ip_network(f'{subnet}/{prefix}', strict=False)
|
||||
|
|
@ -64,7 +64,7 @@ def add_vlan():
|
|||
|
||||
vlan_id = _derive_vlan_id(subnet, subnet_mask)
|
||||
if vlan_id is None:
|
||||
flash('Cannot derive a valid VLAN ID (1–4094) from this subnet/prefix combination.', 'error')
|
||||
flash('Cannot derive a valid VLAN ID (1-4094) from this subnet/prefix combination.', 'error')
|
||||
return redirect(VIEW)
|
||||
|
||||
if not _hash_ok():
|
||||
|
|
@ -94,7 +94,7 @@ def add_vlan():
|
|||
vlans.append(entry)
|
||||
save_core(core)
|
||||
|
||||
flash(apply_msg(), 'success')
|
||||
flash(queued_msg('core apply'), 'success')
|
||||
return redirect(VIEW)
|
||||
|
||||
|
||||
|
|
@ -142,14 +142,14 @@ def edit_vlan():
|
|||
return redirect(VIEW)
|
||||
|
||||
existing = vlans[idx]
|
||||
# is_vpn is never changed via edit — toggling it would invalidate peers/reservations.
|
||||
# is_vpn is never changed via edit -- toggling it would invalidate peers/reservations.
|
||||
is_vpn = existing.get('is_vpn', False)
|
||||
# Use submitted subnet_mask, or fall back to whatever is already stored.
|
||||
final_mask = subnet_mask if subnet_mask is not None else existing.get('subnet_mask', 24)
|
||||
|
||||
vlan_id = _derive_vlan_id(subnet, final_mask)
|
||||
if vlan_id is None:
|
||||
flash('Cannot derive a valid VLAN ID (1–4094) from this subnet/prefix combination.', 'error')
|
||||
flash('Cannot derive a valid VLAN ID (1-4094) from this subnet/prefix combination.', 'error')
|
||||
return redirect(VIEW)
|
||||
|
||||
current_id = existing.get('vlan_id')
|
||||
|
|
@ -173,7 +173,7 @@ def edit_vlan():
|
|||
})
|
||||
save_core(core)
|
||||
|
||||
flash(apply_msg(), 'success')
|
||||
flash(queued_msg('core apply'), 'success')
|
||||
return redirect(VIEW)
|
||||
|
||||
|
||||
|
|
@ -197,5 +197,5 @@ def delete_vlan():
|
|||
removed = vlans.pop(idx)
|
||||
save_core(core)
|
||||
|
||||
flash(apply_msg(), 'success')
|
||||
flash(queued_msg('core apply'), 'success')
|
||||
return redirect(VIEW)
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import re
|
|||
|
||||
from flask import Blueprint, make_response, redirect, flash, request
|
||||
from auth import require_level
|
||||
from config_utils import load_core, save_core, verify_core_hash, apply_msg, CONFIGS_DIR
|
||||
from config_utils import load_core, save_core, verify_core_hash, queued_msg, CONFIGS_DIR
|
||||
import sanitize
|
||||
import validate
|
||||
|
||||
|
|
@ -19,6 +19,24 @@ def _wg_vlan(core):
|
|||
return next((v for v in core.get('vlans', []) if v.get('is_vpn')), None)
|
||||
|
||||
|
||||
def _wg_vlan_by_name(core, name):
|
||||
return next((v for v in core.get('vlans', []) if v.get('is_vpn') and v.get('name') == name), None)
|
||||
|
||||
|
||||
def _find_peer_by_flat_idx(core, flat_idx):
|
||||
"""Return (vlan, peer_list_index) by flat index across all VPN VLANs in order."""
|
||||
i = 0
|
||||
for vlan in core.get('vlans', []):
|
||||
if not vlan.get('is_vpn'):
|
||||
continue
|
||||
peers = vlan.get('peers', [])
|
||||
for j in range(len(peers)):
|
||||
if i == flat_idx:
|
||||
return vlan, j
|
||||
i += 1
|
||||
return None, None
|
||||
|
||||
|
||||
def _wg_iface(vlan, core):
|
||||
"""Return the WireGuard interface name (wg0, wg1, ...) for a VPN VLAN."""
|
||||
wg_vlans = [v for v in core.get('vlans', []) if v.get('is_vpn')]
|
||||
|
|
@ -180,7 +198,7 @@ def apply_vpn():
|
|||
overrides.pop('mtu', None)
|
||||
|
||||
save_core(core)
|
||||
flash(apply_msg(), 'success')
|
||||
flash(queued_msg('core apply'), 'success')
|
||||
return redirect(_VIEW)
|
||||
|
||||
|
||||
|
|
@ -188,12 +206,17 @@ def apply_vpn():
|
|||
@require_level('administrator')
|
||||
def add_vpn_peer():
|
||||
peer_name = sanitize.name(request.form.get('peer_name', ''))
|
||||
peer_vlan_nm = request.form.get('peer_vlan', '').strip()
|
||||
peer_ip_raw = request.form.get('peer_ip', '').strip()
|
||||
split_tunnel = 'split_tunnel' in request.form
|
||||
enabled = 'enabled' in request.form
|
||||
|
||||
if not peer_name:
|
||||
flash('Peer name is required.', 'error')
|
||||
return redirect(_VIEW)
|
||||
if not peer_vlan_nm:
|
||||
flash('Assigned VLAN is required.', 'error')
|
||||
return redirect(_VIEW)
|
||||
peer_ip = validate.ip(peer_ip_raw)
|
||||
if not peer_ip:
|
||||
flash(f'"{peer_ip_raw}" is not a valid IP address.', 'error')
|
||||
|
|
@ -203,16 +226,27 @@ def add_vpn_peer():
|
|||
return redirect(_VIEW)
|
||||
|
||||
core = load_core()
|
||||
vpn_vlan = _wg_vlan(core)
|
||||
vpn_vlan = _wg_vlan_by_name(core, peer_vlan_nm)
|
||||
if vpn_vlan is None:
|
||||
flash('No WireGuard VLAN found in configuration.', 'error')
|
||||
flash(f'VPN VLAN "{peer_vlan_nm}" not found.', 'error')
|
||||
return redirect(_VIEW)
|
||||
|
||||
try:
|
||||
network = ipaddress.IPv4Network(f"{vpn_vlan['subnet']}/{vpn_vlan['subnet_mask']}", strict=False)
|
||||
if ipaddress.IPv4Address(peer_ip) not in network:
|
||||
flash(f'{peer_ip} is not within the subnet {vpn_vlan["subnet"]}/{vpn_vlan["subnet_mask"]} of {peer_vlan_nm}.', 'error')
|
||||
return redirect(_VIEW)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
peers = vpn_vlan.setdefault('peers', [])
|
||||
if any(p.get('name') == peer_name for p in peers):
|
||||
flash(f'A peer named "{peer_name}" already exists.', 'error')
|
||||
return redirect(_VIEW)
|
||||
if any(p.get('ip') == peer_ip for p in peers):
|
||||
for v in core.get('vlans', []):
|
||||
if not v.get('is_vpn'):
|
||||
continue
|
||||
if any(p.get('ip') == peer_ip for p in v.get('peers', [])):
|
||||
flash(f'IP address {peer_ip} is already assigned to another peer.', 'error')
|
||||
return redirect(_VIEW)
|
||||
|
||||
|
|
@ -222,7 +256,7 @@ def add_vpn_peer():
|
|||
'ip': peer_ip,
|
||||
'public_key': public_key,
|
||||
'split_tunnel': split_tunnel,
|
||||
'enabled': True,
|
||||
'enabled': enabled,
|
||||
})
|
||||
save_core(core)
|
||||
|
||||
|
|
@ -232,8 +266,8 @@ def add_vpn_peer():
|
|||
@bp.route('/action/edit_vpn_peer', methods=['POST'])
|
||||
@require_level('administrator')
|
||||
def edit_vpn_peer():
|
||||
idx = _row_index()
|
||||
if idx is None:
|
||||
flat_idx = _row_index()
|
||||
if flat_idx is None:
|
||||
flash('Invalid request.', 'error')
|
||||
return redirect(_VIEW)
|
||||
|
||||
|
|
@ -248,105 +282,86 @@ def edit_vpn_peer():
|
|||
return redirect(_VIEW)
|
||||
|
||||
core = load_core()
|
||||
vpn_vlan = _wg_vlan(core)
|
||||
if vpn_vlan is None:
|
||||
flash('No WireGuard VLAN found.', 'error')
|
||||
return redirect(_VIEW)
|
||||
|
||||
peers = vpn_vlan.get('peers', [])
|
||||
if idx < 0 or idx >= len(peers):
|
||||
vlan, peer_idx = _find_peer_by_flat_idx(core, flat_idx)
|
||||
if vlan is None:
|
||||
flash('Peer not found.', 'error')
|
||||
return redirect(_VIEW)
|
||||
|
||||
# Reject duplicate name if it belongs to a different peer
|
||||
if any(i != idx and p.get('name') == peer_name for i, p in enumerate(peers)):
|
||||
peers = vlan.get('peers', [])
|
||||
if any(j != peer_idx and p.get('name') == peer_name for j, p in enumerate(peers)):
|
||||
flash(f'A peer named "{peer_name}" already exists.', 'error')
|
||||
return redirect(_VIEW)
|
||||
|
||||
peers[idx].update({'name': peer_name, 'split_tunnel': split_tunnel, 'enabled': enabled})
|
||||
peers[peer_idx].update({'name': peer_name, 'split_tunnel': split_tunnel, 'enabled': enabled})
|
||||
save_core(core)
|
||||
flash(apply_msg(), 'success')
|
||||
flash(queued_msg('core apply'), 'success')
|
||||
return redirect(_VIEW)
|
||||
|
||||
|
||||
@bp.route('/action/toggle_vpn_peer', methods=['POST'])
|
||||
@require_level('administrator')
|
||||
def toggle_vpn_peer():
|
||||
idx = _row_index()
|
||||
if idx is None:
|
||||
flat_idx = _row_index()
|
||||
if flat_idx is None:
|
||||
flash('Invalid request.', 'error')
|
||||
return redirect(_VIEW)
|
||||
if not _hash_ok():
|
||||
return redirect(_VIEW)
|
||||
|
||||
core = load_core()
|
||||
vpn_vlan = _wg_vlan(core)
|
||||
if vpn_vlan is None:
|
||||
flash('No WireGuard VLAN found.', 'error')
|
||||
return redirect(_VIEW)
|
||||
|
||||
peers = vpn_vlan.get('peers', [])
|
||||
if idx < 0 or idx >= len(peers):
|
||||
vlan, peer_idx = _find_peer_by_flat_idx(core, flat_idx)
|
||||
if vlan is None:
|
||||
flash('Peer not found.', 'error')
|
||||
return redirect(_VIEW)
|
||||
|
||||
peers[idx]['enabled'] = not peers[idx].get('enabled', True)
|
||||
peers = vlan.get('peers', [])
|
||||
peers[peer_idx]['enabled'] = not peers[peer_idx].get('enabled', True)
|
||||
save_core(core)
|
||||
flash(apply_msg(), 'success')
|
||||
flash(queued_msg('core apply'), 'success')
|
||||
return redirect(_VIEW)
|
||||
|
||||
|
||||
@bp.route('/action/delete_vpn_peer', methods=['POST'])
|
||||
@require_level('administrator')
|
||||
def delete_vpn_peer():
|
||||
idx = _row_index()
|
||||
if idx is None:
|
||||
flat_idx = _row_index()
|
||||
if flat_idx is None:
|
||||
flash('Invalid request.', 'error')
|
||||
return redirect(_VIEW)
|
||||
if not _hash_ok():
|
||||
return redirect(_VIEW)
|
||||
|
||||
core = load_core()
|
||||
vpn_vlan = _wg_vlan(core)
|
||||
if vpn_vlan is None:
|
||||
flash('No WireGuard VLAN found.', 'error')
|
||||
return redirect(_VIEW)
|
||||
|
||||
peers = vpn_vlan.get('peers', [])
|
||||
if idx < 0 or idx >= len(peers):
|
||||
vlan, peer_idx = _find_peer_by_flat_idx(core, flat_idx)
|
||||
if vlan is None:
|
||||
flash('Peer not found.', 'error')
|
||||
return redirect(_VIEW)
|
||||
|
||||
peers.pop(idx)
|
||||
vlan.get('peers', []).pop(peer_idx)
|
||||
save_core(core)
|
||||
flash(apply_msg(), 'success')
|
||||
flash(queued_msg('core apply'), 'success')
|
||||
return redirect(_VIEW)
|
||||
|
||||
|
||||
@bp.route('/action/regenerate_vpn_peer', methods=['POST'])
|
||||
@require_level('administrator')
|
||||
def regenerate_vpn_peer():
|
||||
idx = _row_index()
|
||||
if idx is None:
|
||||
flat_idx = _row_index()
|
||||
if flat_idx is None:
|
||||
flash('Invalid request.', 'error')
|
||||
return redirect(_VIEW)
|
||||
if not _hash_ok():
|
||||
return redirect(_VIEW)
|
||||
|
||||
core = load_core()
|
||||
vpn_vlan = _wg_vlan(core)
|
||||
if vpn_vlan is None:
|
||||
flash('No WireGuard VLAN found.', 'error')
|
||||
return redirect(_VIEW)
|
||||
|
||||
peers = vpn_vlan.get('peers', [])
|
||||
if idx < 0 or idx >= len(peers):
|
||||
vlan, peer_idx = _find_peer_by_flat_idx(core, flat_idx)
|
||||
if vlan is None:
|
||||
flash('Peer not found.', 'error')
|
||||
return redirect(_VIEW)
|
||||
|
||||
private_key, public_key = _generate_wg_keypair()
|
||||
peer = peers[idx]
|
||||
peer = vlan['peers'][peer_idx]
|
||||
peer['public_key'] = public_key
|
||||
save_core(core)
|
||||
|
||||
return _conf_response(vpn_vlan, peer['name'], peer['ip'], private_key)
|
||||
return _conf_response(vlan, peer['name'], peer['ip'], private_key)
|
||||
|
|
|
|||
28
docker/router-dash/app/api_apply_status.py
Normal file
28
docker/router-dash/app/api_apply_status.py
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
from flask import Blueprint, request, jsonify
|
||||
from auth import require_level
|
||||
from config_utils import (
|
||||
_load_done_set, _is_locked, _lock_mtime,
|
||||
_seconds_until_next_run, _entry_ts_from_queue,
|
||||
)
|
||||
|
||||
bp = Blueprint('api_apply_status', __name__)
|
||||
|
||||
|
||||
@bp.route('/api/apply-status')
|
||||
@require_level('viewer')
|
||||
def apply_status():
|
||||
entry_uuid = request.args.get('uuid', '')
|
||||
if not entry_uuid:
|
||||
return jsonify({'status': 'unknown'})
|
||||
|
||||
if entry_uuid in _load_done_set():
|
||||
return jsonify({'status': 'complete'})
|
||||
|
||||
if _is_locked():
|
||||
mtime = _lock_mtime()
|
||||
entry_ts = _entry_ts_from_queue(entry_uuid)
|
||||
if mtime and entry_ts is not None and entry_ts < mtime:
|
||||
return jsonify({'status': 'running'})
|
||||
return jsonify({'status': 'pending', 'next_in': None})
|
||||
|
||||
return jsonify({'status': 'pending', 'next_in': _seconds_until_next_run()})
|
||||
|
|
@ -1,18 +1,16 @@
|
|||
import json, subprocess, hashlib
|
||||
from markupsafe import Markup
|
||||
|
||||
_APPLY_CMD = 'sudo python3 ~/router/core.py --apply'
|
||||
|
||||
|
||||
def apply_msg():
|
||||
"""Return a Markup flash message for the apply reminder."""
|
||||
return Markup(
|
||||
f'Configuration updated. To apply changes, run: '
|
||||
f'<code><strong>{_APPLY_CMD}</strong></code>'
|
||||
)
|
||||
import json, subprocess, hashlib, os, uuid
|
||||
from datetime import datetime, timezone
|
||||
from flask import session
|
||||
|
||||
CONFIGS_DIR = '/configs'
|
||||
CORE_FILE = f'{CONFIGS_DIR}/core.json'
|
||||
DASHBOARD_QUEUE = f'{CONFIGS_DIR}/.dashboard-queue'
|
||||
DASHBOARD_DONE = f'{CONFIGS_DIR}/.dashboard-done'
|
||||
DASHBOARD_LAST_RUN = f'{CONFIGS_DIR}/.dashboard-last-run'
|
||||
DASHBOARD_LOCK = f'{CONFIGS_DIR}/.dashboard-lock'
|
||||
DASHB_TIMER_NAME = 'router-dashboard-queue'
|
||||
DASHB_INTERVAL_SECS = 60
|
||||
QUEUE_MAX_LINES = 50
|
||||
|
||||
|
||||
def load_core():
|
||||
|
|
@ -42,6 +40,150 @@ def verify_core_hash(submitted):
|
|||
return submitted == core_hash()
|
||||
|
||||
|
||||
def _load_done_set():
|
||||
try:
|
||||
done = set()
|
||||
for line in open(DASHBOARD_DONE).read().splitlines():
|
||||
parts = line.split()
|
||||
if parts:
|
||||
done.add(parts[0])
|
||||
return done
|
||||
except Exception:
|
||||
return set()
|
||||
|
||||
|
||||
def _read_pending(done_set):
|
||||
pending = []
|
||||
try:
|
||||
lines = open(DASHBOARD_QUEUE).read().splitlines()
|
||||
except Exception:
|
||||
return pending
|
||||
for line in lines:
|
||||
try:
|
||||
parts = line.split(None, 3)
|
||||
if len(parts) == 4:
|
||||
entry_uuid, entry_ts, _dt, rest = parts
|
||||
cmd_user = rest.rsplit(' (', 1)
|
||||
entry_cmd = cmd_user[0].strip('[]')
|
||||
entry_user = cmd_user[1].rstrip(')') if len(cmd_user) == 2 else ''
|
||||
if entry_uuid not in done_set:
|
||||
pending.append((entry_uuid, int(entry_ts), entry_cmd, entry_user))
|
||||
except Exception:
|
||||
pass
|
||||
return pending
|
||||
|
||||
|
||||
def get_pending_entries():
|
||||
return _read_pending(_load_done_set())
|
||||
|
||||
|
||||
def _format_timing(secs):
|
||||
if secs is None:
|
||||
return None
|
||||
if secs <= 5:
|
||||
return 'momentarily'
|
||||
if secs < 60:
|
||||
return f'in about {secs} seconds'
|
||||
mins = round(secs / 60)
|
||||
return f'in about {mins} minute{"s" if mins != 1 else ""}'
|
||||
|
||||
|
||||
def _trim_if_needed():
|
||||
try:
|
||||
lines = [l for l in open(DASHBOARD_QUEUE).read().splitlines() if l]
|
||||
if len(lines) <= QUEUE_MAX_LINES:
|
||||
return
|
||||
done_set = _load_done_set()
|
||||
pending = [l for l in lines if l.split()[0] not in done_set]
|
||||
with open(DASHBOARD_QUEUE, 'w') as f:
|
||||
f.write('\n'.join(pending) + ('\n' if pending else ''))
|
||||
open(DASHBOARD_DONE, 'w').close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def _queue_command(cmd):
|
||||
done_set = _load_done_set()
|
||||
pending = _read_pending(done_set)
|
||||
current_user = session.get('email_address', 'unknown')
|
||||
for entry_uuid, entry_ts, entry_cmd, entry_user in pending:
|
||||
if entry_cmd == cmd and entry_user == current_user:
|
||||
return entry_uuid, entry_ts
|
||||
entry_uuid = str(uuid.uuid4())
|
||||
now = datetime.now()
|
||||
entry_ts = int(now.timestamp())
|
||||
dt_str = now.strftime('%Y-%m-%dT%H:%M:%S')
|
||||
user = session.get('email_address', 'unknown')
|
||||
with open(DASHBOARD_QUEUE, 'a') as f:
|
||||
f.write(f'{entry_uuid} {entry_ts} {dt_str} [{cmd}] ({user})\n')
|
||||
_trim_if_needed()
|
||||
return entry_uuid, entry_ts
|
||||
|
||||
|
||||
def _entry_ts_from_queue(entry_uuid):
|
||||
try:
|
||||
for line in open(DASHBOARD_QUEUE).read().splitlines():
|
||||
parts = line.split(None, 2)
|
||||
if len(parts) >= 2 and parts[0] == entry_uuid:
|
||||
return int(parts[1])
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
def _seconds_until_next_run():
|
||||
try:
|
||||
last_run = float(open(DASHBOARD_LAST_RUN).read().strip())
|
||||
elapsed = datetime.now(timezone.utc).timestamp() - last_run
|
||||
return int(max(0, DASHB_INTERVAL_SECS - elapsed))
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _is_locked():
|
||||
try:
|
||||
return os.path.getsize(DASHBOARD_LOCK) > 0
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def _lock_mtime():
|
||||
try:
|
||||
return os.path.getmtime(DASHBOARD_LOCK)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def queue_command(cmd):
|
||||
"""Queue a command without generating a flash message."""
|
||||
return _queue_command(cmd)
|
||||
|
||||
|
||||
def queued_msg(cmd=None):
|
||||
"""Queue cmd if given, then return a timing message.
|
||||
Without cmd, just returns timing (for commands already queued by the caller)."""
|
||||
entry_ts = None
|
||||
if cmd is not None:
|
||||
_entry_uuid, entry_ts = queue_command(cmd)
|
||||
if _is_locked():
|
||||
mtime = _lock_mtime()
|
||||
if entry_ts is not None and mtime and entry_ts < mtime:
|
||||
return 'Configuration saved. Changes are being applied now.'
|
||||
return 'Configuration saved. Changes will be applied on the next run.'
|
||||
timing = _format_timing(_seconds_until_next_run())
|
||||
if timing:
|
||||
return f'Configuration saved. Changes will be applied {timing}.'
|
||||
if cmd is None:
|
||||
return 'Changes queued. The processing service is not running.'
|
||||
parts = cmd.split()
|
||||
cli_cmd = f'sudo python3 {parts[0]}.py --{parts[1]}' if len(parts) == 2 else cmd
|
||||
install_cmd = f'sudo python3 {parts[0]}.py --install' if len(parts) >= 1 else 'core.py --install'
|
||||
from markupsafe import Markup
|
||||
return Markup(f'Configuration saved. The command processing service is not installed. '
|
||||
f'Run <strong>{install_cmd}</strong> to enable it, '
|
||||
f'or <strong>{cli_cmd}</strong> to apply manually.')
|
||||
|
||||
|
||||
def run_apply():
|
||||
try:
|
||||
subprocess.run(
|
||||
|
|
|
|||
|
|
@ -23,6 +23,8 @@ from action_change_password import bp as action_change_password_bp
|
|||
from action_clear_ddns_log import bp as action_clear_ddns_log_bp
|
||||
from action_apply_ddns_providers import bp as action_apply_ddns_providers_bp
|
||||
from action_apply_interface import bp as action_apply_interface_bp
|
||||
from action_apply_iface_config import bp as action_apply_iface_config_bp
|
||||
from api_apply_status import bp as api_apply_status_bp
|
||||
|
||||
app = Flask(__name__)
|
||||
app.secret_key = os.environ.get('SECRET_KEY', os.urandom(24))
|
||||
|
|
@ -49,6 +51,8 @@ app.register_blueprint(action_change_password_bp)
|
|||
app.register_blueprint(action_clear_ddns_log_bp)
|
||||
app.register_blueprint(action_apply_ddns_providers_bp)
|
||||
app.register_blueprint(action_apply_interface_bp)
|
||||
app.register_blueprint(action_apply_iface_config_bp)
|
||||
app.register_blueprint(api_apply_status_bp)
|
||||
|
||||
def _seed_initial_account():
|
||||
email = os.environ.get('INITIAL_MANAGER_EMAIL', '').strip().lower()
|
||||
|
|
|
|||
|
|
@ -153,9 +153,13 @@ def ip_or_cidr(value, max_len=49):
|
|||
except ValueError:
|
||||
return ''
|
||||
|
||||
def mac(value, max_len=17):
|
||||
"""MAC address: hex digits and colons."""
|
||||
return _strip(value.upper(), r'[^0-9A-F:]', max_len)
|
||||
def mac(value):
|
||||
"""MAC address in aa:bb:cc:dd:ee:ff format. Colons required; no other separators accepted.
|
||||
Returns lowercase colon-separated MAC if valid, '' otherwise."""
|
||||
s = str(value).strip().lower()
|
||||
if re.fullmatch(r'([0-9a-f]{2}:){5}[0-9a-f]{2}', s):
|
||||
return s
|
||||
return ''
|
||||
|
||||
def url(value, max_len=500):
|
||||
"""URL: printable ASCII except quotes, braces, brackets, backslash, spaces."""
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import json, re, subprocess, os, sys, html as html_mod
|
|||
import sanitize
|
||||
import validate
|
||||
from datetime import datetime, timezone
|
||||
from config_utils import core_hash
|
||||
from config_utils import core_hash, get_pending_entries, _seconds_until_next_run, _format_timing, _is_locked, _lock_mtime
|
||||
|
||||
bp = Blueprint('view_page', __name__)
|
||||
|
||||
|
|
@ -74,11 +74,65 @@ def _prefix_to_dotted(n):
|
|||
|
||||
|
||||
def _get_system_interfaces():
|
||||
"""Return sorted list of physical-ish interface names from `ip link show`."""
|
||||
out = _run('ip link show')
|
||||
names = re.findall(r'^\d+:\s+(\S+):', out, re.MULTILINE)
|
||||
ifaces = sorted({n.split('@')[0] for n in names} - {'lo'})
|
||||
return ifaces
|
||||
_EXCLUDE_PREFIXES = ('lo', 'wg', 'docker', 'br-', 'veth',
|
||||
'tun', 'tap', 'ppp', 'virbr',
|
||||
'podman', 'vnet', 'macvtap', 'fc-')
|
||||
try:
|
||||
return sorted(
|
||||
n for n in os.listdir('/sys/class/net')
|
||||
if not n.startswith(_EXCLUDE_PREFIXES)
|
||||
and os.path.exists(f'/sys/class/net/{n}/device')
|
||||
)
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
|
||||
def _iface_info(iface):
|
||||
base = f'/sys/class/net/{iface}'
|
||||
def _rd(path):
|
||||
try:
|
||||
with open(f'{base}/{path}') as f:
|
||||
return f.read().strip()
|
||||
except Exception:
|
||||
return None
|
||||
wireless = os.path.isdir(f'{base}/wireless')
|
||||
state = (_rd('operstate') or 'unknown').upper()
|
||||
if state == 'UNKNOWN':
|
||||
state = 'UP'
|
||||
carrier_raw = _rd('carrier')
|
||||
carrier = (carrier_raw == '1') if carrier_raw is not None else None
|
||||
speed_raw = _rd('speed')
|
||||
try:
|
||||
mbps = int(speed_raw)
|
||||
if mbps <= 0:
|
||||
speed = None
|
||||
elif mbps >= 1000 and mbps % 1000 == 0:
|
||||
speed = f'{mbps // 1000} Gbps'
|
||||
else:
|
||||
speed = f'{mbps} Mbps'
|
||||
except (TypeError, ValueError):
|
||||
speed = None
|
||||
mac = _rd('address')
|
||||
perm_mac = _rd('perm_address')
|
||||
if perm_mac and perm_mac == '00:00:00:00:00:00':
|
||||
perm_mac = None
|
||||
# DEBUG
|
||||
# if not perm_mac: perm_mac = 'de:ad:be:ef:f0:0d'
|
||||
def _int(val):
|
||||
try: return int(val) if val else None
|
||||
except ValueError: return None
|
||||
return {
|
||||
'name': iface,
|
||||
'wireless': wireless,
|
||||
'state': state,
|
||||
'carrier': carrier,
|
||||
'speed': speed,
|
||||
'mtu': _rd('mtu'),
|
||||
'min_mtu': _int(_rd('min_mtu')),
|
||||
'max_mtu': _int(_rd('max_mtu')),
|
||||
'mac': mac,
|
||||
'perm_mac': perm_mac,
|
||||
}
|
||||
|
||||
|
||||
def _iface_status(iface):
|
||||
|
|
@ -247,7 +301,8 @@ def _config_datasource(name):
|
|||
if ptype == 'noip':
|
||||
row['credentials'] = f"U: {p.get('username', '-')}"
|
||||
elif ptype in ('cloudflare', 'duckdns'):
|
||||
row['credentials'] = '(token set)' if p.get('api_token') else '(not set)'
|
||||
tok = p.get('api_token', '')
|
||||
row['credentials'] = f'API Token: {tok[:8]}…' if tok else '(not set)'
|
||||
else:
|
||||
row['credentials'] = '-'
|
||||
row['hostnames'] = json.dumps(p.get('hostnames', p.get('subdomains', [])))
|
||||
|
|
@ -263,12 +318,13 @@ def _config_datasource(name):
|
|||
return rows
|
||||
|
||||
if name == 'vpn_peers':
|
||||
wg_vlan = next((v for v in vlans if v.get('is_vpn')), None)
|
||||
if not wg_vlan:
|
||||
return []
|
||||
rows = []
|
||||
for peer in wg_vlan.get('peers', []):
|
||||
for i, vlan in enumerate(v for v in vlans if v.get('is_vpn')):
|
||||
iface = f'wg{i}'
|
||||
vlan_display = f'{iface} (VLAN {vlan.get("vlan_id", "?")})'
|
||||
for peer in vlan.get('peers', []):
|
||||
row = dict(peer)
|
||||
row['vlan_display'] = vlan_display
|
||||
row['split_tunnel'] = 'yes' if peer.get('split_tunnel') else 'no'
|
||||
row['pubkey_short'] = peer.get('public_key', '')[:20] + '...' if peer.get('public_key') else '-'
|
||||
rows.append(row)
|
||||
|
|
@ -436,6 +492,8 @@ def collect_tokens():
|
|||
vlans = core.get('vlans', [])
|
||||
tokens['GENERAL_WAN_INTERFACE'] = str(gen.get('wan_interface', '-'))
|
||||
tokens['GENERAL_LAN_INTERFACE'] = str(gen.get('lan_interface', '-'))
|
||||
tokens['GENERAL_WAN_STATUS'] = _iface_status(gen.get('wan_interface', ''))
|
||||
tokens['GENERAL_LAN_STATUS'] = _iface_status(gen.get('lan_interface', ''))
|
||||
tokens['GENERAL_LOG_MAX_KB'] = str(gen.get('log_max_kb', '-'))
|
||||
|
||||
sys_ifaces = _get_system_interfaces()
|
||||
|
|
@ -448,8 +506,15 @@ def collect_tokens():
|
|||
[{'value': i, 'label': i} for i in sys_ifaces]
|
||||
)
|
||||
tokens['NETWORK_INTERFACE_STATUS_OPTIONS'] = json.dumps(
|
||||
[{'value': i, 'label': f'{i} — {_iface_status(i).title()}'} for i in sys_ifaces]
|
||||
[{'value': i, 'label': f'{i} - {_iface_status(i).title()}'} for i in sys_ifaces]
|
||||
)
|
||||
iface_data = [_iface_info(i) for i in sys_ifaces]
|
||||
tokens['NETWORK_INTERFACE_DATA_JSON'] = json.dumps(iface_data)
|
||||
max_speed_len = max(
|
||||
(len(str(d.get('speed') or '')) for d in iface_data),
|
||||
default=len('Speed')
|
||||
)
|
||||
tokens['NETWORK_INTERFACE_STATS_SPEED_PAD'] = str(max(max_speed_len, len('Speed')))
|
||||
|
||||
tokens['GENERAL_LOG_ERRORS_ONLY'] = 'true' if gen.get('log_errors_only') else 'false'
|
||||
tokens['GENERAL_DNSMASQ_LOG_QUERIES'] = 'true' if gen.get('dnsmasq_log_queries') else 'false'
|
||||
|
|
@ -490,7 +555,12 @@ def collect_tokens():
|
|||
for p in validate.VALID_DDNS_PROVIDERS
|
||||
])
|
||||
|
||||
wg_vlan = next((v for v in vlans if v.get('is_vpn')), {})
|
||||
wg_vlans_list = [v for v in vlans if v.get('is_vpn')]
|
||||
tokens['VPN_VLAN_OPTIONS'] = json.dumps([
|
||||
{'value': v.get('name', ''), 'label': f'wg{i} (VLAN {v.get("vlan_id", "?")})'}
|
||||
for i, v in enumerate(wg_vlans_list)
|
||||
])
|
||||
wg_vlan = wg_vlans_list[0] if wg_vlans_list else {}
|
||||
vpn = wg_vlan.get('vpn_information', {})
|
||||
overrides = vpn.get('explicit_overrides', {})
|
||||
tokens['VPN_LISTEN_PORT'] = str(vpn.get('listen_port', ''))
|
||||
|
|
@ -693,6 +763,20 @@ def _render_item(item, tokens, inherited_req=None):
|
|||
body = render_items(item.get('items', []), tokens, req)
|
||||
return f'<div class="card"{id_attr}{style}>{header}<div class="card-body">{body}</div></div>'
|
||||
|
||||
if t == 'field_status':
|
||||
label = e(item.get('label', ''))
|
||||
raw = apply_tokens(item.get('value', ''), tokens).upper()
|
||||
badge_map = {
|
||||
'UP': ('badge-enabled', 'Up'),
|
||||
'DOWN': ('badge-warning', 'Down'),
|
||||
'INVALID': ('badge-danger', 'Invalid'),
|
||||
}
|
||||
badge_cls, badge_text = badge_map.get(raw, ('badge-disabled', raw.title() or 'Unknown'))
|
||||
return (f'<div class="form-group">'
|
||||
f'<label class="form-label">{label}</label>'
|
||||
f'<div class="field-status-badge"><span class="badge {badge_cls}">{badge_text}</span></div>'
|
||||
f'</div>')
|
||||
|
||||
if t == 'info_bar':
|
||||
variant = item.get('variant', 'info')
|
||||
text = e(apply_tokens(item.get('text', ''), tokens))
|
||||
|
|
@ -737,7 +821,14 @@ def _render_item(item, tokens, inherited_req=None):
|
|||
method = e(item.get('method', 'post'))
|
||||
inner = render_items(item.get('items', []), tokens, req)
|
||||
hash_field = f'<input type="hidden" name="config_hash" value="{e(core_hash())}">'
|
||||
return f'<form action="{action}" method="{method}">{hash_field}{inner}</form>'
|
||||
originals = json.dumps(_collect_form_originals(item.get('items', []), tokens))
|
||||
orig_field = f'<input type="hidden" name="original_values" value="{e(originals)}">'
|
||||
return f'<form action="{action}" method="{method}">{hash_field}{orig_field}{inner}</form>'
|
||||
|
||||
if t == 'hidden':
|
||||
name = e(item.get('name', ''))
|
||||
value = e(apply_tokens(item.get('value', ''), tokens))
|
||||
return f'<input type="hidden" name="{name}" value="{value}">'
|
||||
|
||||
if t == 'field':
|
||||
return _render_field(item, tokens)
|
||||
|
|
@ -851,7 +942,7 @@ def _render_field(item, tokens):
|
|||
for o in options
|
||||
)
|
||||
return (f'<div class="form-group"><label class="form-label">{label}</label>'
|
||||
f'<select name="{name}" class="form-select">{opts_html}</select>'
|
||||
f'<select name="{name}" class="form-select{extra_cls}">{opts_html}</select>'
|
||||
f'{hint_html}</div>')
|
||||
|
||||
if input_type == 'number':
|
||||
|
|
@ -868,10 +959,152 @@ def _render_field(item, tokens):
|
|||
f' class="form-input">{e(value)}</textarea>'
|
||||
f'{hint_html}</div>')
|
||||
|
||||
dyn_hint = '<p class="form-hint field-dyn-hint" style="display:none"></p>' if item.get('readonly') else ''
|
||||
if input_type == 'interface_picker':
|
||||
current = apply_tokens(item.get('value', ''), tokens)
|
||||
try:
|
||||
ifaces = json.loads(apply_tokens(item.get('data', '[]'), tokens))
|
||||
except Exception:
|
||||
ifaces = []
|
||||
state_map = {
|
||||
'UP': ('badge-enabled', 'Up'),
|
||||
'DOWN': ('badge-warning', 'Down'),
|
||||
'INVALID': ('badge-danger', 'Invalid'),
|
||||
}
|
||||
rows_html = ''
|
||||
cur_sc, cur_st = 'badge-disabled', ''
|
||||
cur_speed = cur_mtu = cur_mac = cur_perm_mac = cur_min_mtu = cur_max_mtu = None
|
||||
try:
|
||||
speed_pad = int(tokens.get('NETWORK_INTERFACE_STATS_SPEED_PAD', '0'))
|
||||
except Exception:
|
||||
speed_pad = 0
|
||||
def _pad_speed(val):
|
||||
s = val or '-'
|
||||
return ' ' * max(0, speed_pad - len(s)) + e(s)
|
||||
for ifc in ifaces:
|
||||
iname = ifc.get('name', '')
|
||||
wireless = ifc.get('wireless', False)
|
||||
state = ifc.get('state', 'UNKNOWN')
|
||||
carrier = ifc.get('carrier')
|
||||
raw_speed = ifc.get('speed')
|
||||
raw_mtu = ifc.get('mtu')
|
||||
raw_mac = ifc.get('mac')
|
||||
speed = raw_speed or '-'
|
||||
mtu = raw_mtu or '-'
|
||||
mac = raw_mac or '-'
|
||||
sc, st = state_map.get(state, ('badge-disabled', state.title()))
|
||||
type_txt = 'Wireless' if wireless else 'Wired'
|
||||
if wireless:
|
||||
carrier_txt = '-'
|
||||
else:
|
||||
carrier_txt = 'Yes' if carrier else ('No' if carrier is False else '-')
|
||||
sel_cls = ' selected' if iname == current else ''
|
||||
if iname == current:
|
||||
cur_sc, cur_st = sc, st
|
||||
cur_speed, cur_mtu, cur_mac = raw_speed, raw_mtu, raw_mac
|
||||
cur_perm_mac = ifc.get('perm_mac')
|
||||
cur_min_mtu = ifc.get('min_mtu')
|
||||
cur_max_mtu = ifc.get('max_mtu')
|
||||
padded_speed = _pad_speed(raw_speed)
|
||||
padded_mtu = ' ' * max(0, 4 - len(raw_mtu or '-')) + e(raw_mtu or '-')
|
||||
rows_html += (f'<tr class="iface-picker-row{sel_cls}" data-iface="{e(iname)}"'
|
||||
f' data-state-class="{e(sc)}" data-state-label="{e(st)}"'
|
||||
f' data-speed="{padded_speed}" data-mtu="{padded_mtu}"'
|
||||
f' data-mac="{e(raw_mac or "")}">'
|
||||
f'<td class="col-mono">{e(iname)}</td>'
|
||||
f'<td>{e(type_txt)}</td>'
|
||||
f'<td><span class="badge {sc}">{st}</span></td>'
|
||||
f'<td>{e(carrier_txt)}</td>'
|
||||
f'<td>{e(speed)}</td>'
|
||||
f'<td>{e(mtu)}</td>'
|
||||
f'<td class="col-mono">{e(mac)}</td>'
|
||||
f'</tr>')
|
||||
table_html = (f'<div class="table-wrapper">'
|
||||
f'<table class="data-table iface-picker-table">'
|
||||
f'<thead><tr><th>Interface</th><th>Type</th><th>State</th>'
|
||||
f'<th>Carrier</th><th>Speed</th><th>MTU</th><th>MAC</th></tr></thead>'
|
||||
f'<tbody>{rows_html}</tbody>'
|
||||
f'</table></div>')
|
||||
btn_label = f'<span class="iface-picker-name">{e(current) or "Select..."}</span>'
|
||||
btn_badge = (f'<span class="badge {cur_sc} iface-picker-badge">{e(cur_st)}</span>'
|
||||
if current else '')
|
||||
if current and any([cur_speed, cur_mtu, cur_mac]):
|
||||
ext_meta = (f'<table class="iface-picker-stats">'
|
||||
f'<thead><tr><th>Speed</th><th>MTU</th><th>MAC</th></tr></thead>'
|
||||
f'<tbody><tr>'
|
||||
f'<td>{_pad_speed(cur_speed)}</td>'
|
||||
f'<td>{" " * max(0, 4 - len(cur_mtu or "-"))}{e(cur_mtu or "-")}</td>'
|
||||
f'<td class="col-mono">{e(cur_mac or "-")}</td>'
|
||||
f'</tr></tbody>'
|
||||
f'</table>')
|
||||
else:
|
||||
ext_meta = ''
|
||||
configure_btn = (
|
||||
f'<button type="button" class="btn btn-secondary iface-configure-btn"'
|
||||
f' data-iface="{e(current)}" data-mtu="{e(cur_mtu or "")}"'
|
||||
f' data-mac="{e(cur_mac or "")}" data-perm-mac="{e(cur_perm_mac or "")}"'
|
||||
f' data-min-mtu="{cur_min_mtu if cur_min_mtu is not None else ""}"'
|
||||
f' data-max-mtu="{cur_max_mtu if cur_max_mtu is not None else ""}">'
|
||||
f'Configure</button>'
|
||||
) if current else ''
|
||||
return (f'<div class="form-group">'
|
||||
f'<label class="form-label">{label}</label>'
|
||||
f'<div class="iface-picker">'
|
||||
f'<input type="hidden" name="{name}" value="{e(current)}">'
|
||||
f'<div class="iface-picker-header">'
|
||||
f'<button type="button" class="iface-picker-btn">{btn_label}{btn_badge}</button>'
|
||||
f'{ext_meta}'
|
||||
f'{configure_btn}'
|
||||
f'</div>'
|
||||
f'<div class="iface-picker-dropdown">{table_html}</div>'
|
||||
f'</div>'
|
||||
f'</div>')
|
||||
|
||||
validate = item.get('validate', '')
|
||||
validate_attr = f' data-validate="{e(validate)}"' if validate else ''
|
||||
dyn_hint = '<p class="form-hint field-dyn-hint" style="display:none"></p>' if (item.get('readonly') or item.get('dyn_hint') or validate) else ''
|
||||
return (f'<div class="form-group"><label class="form-label">{label}</label>'
|
||||
f'<input type="{e(input_type)}" name="{name}" value="{e(value)}"'
|
||||
f' placeholder="{placeholder}" class="form-input{extra_cls}"{readonly}>{hint_html}{dyn_hint}</div>')
|
||||
f' placeholder="{placeholder}" class="form-input{extra_cls}"{readonly}{validate_attr}>{hint_html}{dyn_hint}</div>')
|
||||
|
||||
|
||||
def _collect_form_originals(items, tokens):
|
||||
"""Walk form items and return {name: value} for all input fields (used for original_values)."""
|
||||
result = {}
|
||||
for item in items:
|
||||
t = item.get('type', '')
|
||||
if t == 'field':
|
||||
name = item.get('name', '')
|
||||
input_type = item.get('input_type', 'text')
|
||||
if not name or input_type == 'hidden':
|
||||
continue
|
||||
value = apply_tokens(item.get('value', ''), tokens)
|
||||
if input_type == 'checkbox':
|
||||
result[name] = '1' if value.lower() in ('true', '1', 'yes') else '0'
|
||||
elif input_type == 'select' and not value:
|
||||
try:
|
||||
opts = json.loads(apply_tokens(item.get('options', '[]'), tokens))
|
||||
value = opts[0]['value'] if opts else ''
|
||||
except Exception:
|
||||
pass
|
||||
result[name] = value
|
||||
else:
|
||||
result[name] = value
|
||||
elif t == 'editable_list':
|
||||
name = item.get('name', '')
|
||||
if not name:
|
||||
continue
|
||||
try:
|
||||
vals = json.loads(apply_tokens(item.get('items', '[]'), tokens))
|
||||
vals = [str(v) for v in vals]
|
||||
except Exception:
|
||||
vals = []
|
||||
result[name] = vals
|
||||
elif t == 'subnet_row':
|
||||
result[item.get('subnet_name', 'subnet')] = apply_tokens(item.get('subnet_value', ''), tokens)
|
||||
result[item.get('prefix_name', 'subnet_mask')] = apply_tokens(item.get('prefix_value', '24'), tokens)
|
||||
elif t == 'field_row':
|
||||
result.update(_collect_form_originals(item.get('items', []), tokens))
|
||||
return result
|
||||
|
||||
|
||||
def _render_editable_list(item, tokens):
|
||||
|
|
@ -881,6 +1114,7 @@ def _render_editable_list(item, tokens):
|
|||
add_lbl = e(apply_tokens(item.get('add_label', 'Add'), tokens))
|
||||
hint = e(apply_tokens(item.get('hint', ''), tokens))
|
||||
hint_html = f'<p class="form-hint">{hint}</p>' if hint else ''
|
||||
validate = e(item.get('validate', ''))
|
||||
|
||||
try:
|
||||
items_list = json.loads(apply_tokens(item.get('items', '[]'), tokens))
|
||||
|
|
@ -894,8 +1128,9 @@ def _render_editable_list(item, tokens):
|
|||
f'</div>'
|
||||
for v in items_list
|
||||
)
|
||||
validate_attr = f' data-validate="{validate}"' if validate else ''
|
||||
return (f'<div class="form-group"><label class="form-label">{label}</label>'
|
||||
f'<div class="editable-list" data-name="{name}" data-placeholder="{ph}">'
|
||||
f'<div class="editable-list" data-name="{name}" data-placeholder="{ph}"{validate_attr}>'
|
||||
f'{rows}'
|
||||
f'<button type="button" class="btn btn-ghost btn-sm editable-list-add">+ {add_lbl}</button>'
|
||||
f'</div>{hint_html}</div>')
|
||||
|
|
@ -917,7 +1152,10 @@ def _render_table(item, tokens, inherited_req=None):
|
|||
t_inner = render_items(toolbar.get('items', []), tokens, req)
|
||||
toolbar_html = f'<div class="table-toolbar">{t_inner}</div>'
|
||||
|
||||
thead = ''.join(f'<th>{e(c.get("label",""))}</th>' for c in columns)
|
||||
thead = ''.join(
|
||||
f'<th class="{e(c["class"])}">{e(c.get("label",""))}</th>' if c.get("class") else f'<th>{e(c.get("label",""))}</th>'
|
||||
for c in columns
|
||||
)
|
||||
if row_actions:
|
||||
thead += '<th></th>'
|
||||
|
||||
|
|
@ -1088,6 +1326,30 @@ def render_layout(view_id, content_html, tokens):
|
|||
existing_ids = tokens.get('EXISTING_VLAN_IDS_JSON', '[]')
|
||||
existing_names = tokens.get('EXISTING_VLAN_NAMES_JSON', '[]')
|
||||
existing_interfaces = tokens.get('EXISTING_VLAN_INTERFACES_JSON', '[]')
|
||||
current_user = session.get('email_address', '')
|
||||
pending = get_pending_entries()
|
||||
my_uuid = next((u for u, t, c, usr in pending if usr == current_user), None)
|
||||
|
||||
secs = _seconds_until_next_run()
|
||||
locked = _is_locked()
|
||||
lock_mtime = _lock_mtime()
|
||||
other_bars = ''
|
||||
seen_other_users = set()
|
||||
for o_uuid, o_ts, o_cmd, o_user in pending:
|
||||
if o_user == current_user:
|
||||
continue
|
||||
if o_user in seen_other_users:
|
||||
continue
|
||||
seen_other_users.add(o_user)
|
||||
if locked and lock_mtime and o_ts < lock_mtime:
|
||||
text = f'{e(o_user)}\'s changes are being applied now...'
|
||||
cls = 'info-bar-warning info-bar-running'
|
||||
else:
|
||||
timing = _format_timing(secs)
|
||||
text = f'{e(o_user)} has pending changes which will be applied {timing}.' if timing else f'{e(o_user)} has pending changes which will be applied on the next timer tick.'
|
||||
cls = 'info-bar-warning'
|
||||
other_bars += f'<div class="info-bar {cls}" data-apply-uuid="{e(o_uuid)}" data-apply-user="{e(o_user)}">{text}</div>\n'
|
||||
|
||||
return (f'<!DOCTYPE html>\n<html lang="en">\n<head>\n'
|
||||
f' <meta charset="UTF-8">\n'
|
||||
f' <meta name="viewport" content="width=device-width, initial-scale=1.0">\n'
|
||||
|
|
@ -1096,9 +1358,9 @@ def render_layout(view_id, content_html, tokens):
|
|||
f'</head>\n<body>\n'
|
||||
f'{titlebar_html}\n'
|
||||
f'{navbar_html}\n'
|
||||
f'<main class="main-content">\n{content_html}\n</main>\n'
|
||||
f'<main class="main-content">\n{other_bars}{content_html}\n</main>\n'
|
||||
f'{footer_html}\n'
|
||||
f'<script>var CONFIG_HASH="{page_hash}";var LAN_IFACE="{lan_iface}";var VPN_VLAN_COUNT={vpn_count};var EXISTING_VLAN_IDS={existing_ids};var EXISTING_VLAN_NAMES={existing_names};var EXISTING_VLAN_INTERFACES={existing_interfaces};</script>\n'
|
||||
f'<script>var CONFIG_HASH="{page_hash}";var LAN_IFACE="{lan_iface}";var VPN_VLAN_COUNT={vpn_count};var EXISTING_VLAN_IDS={existing_ids};var EXISTING_VLAN_NAMES={existing_names};var EXISTING_VLAN_INTERFACES={existing_interfaces};var APPLY_UUID={json.dumps(my_uuid)};</script>\n'
|
||||
f'<script>{_inline_js()}</script>\n'
|
||||
f'</body>\n</html>')
|
||||
|
||||
|
|
@ -1206,6 +1468,146 @@ function networkBitsMessage(octets, prefix) {
|
|||
return parts.join('; ') + ' for /' + prefix;
|
||||
}
|
||||
|
||||
function classifyMac(s) {
|
||||
if (!s) return 'empty';
|
||||
if (/[^0-9a-fA-F:]/.test(s)) return 'invalid_char';
|
||||
if (/::/.test(s)) return 'invalid_struct';
|
||||
var groups = s.split(':');
|
||||
if (groups.length > 6) return 'too_many';
|
||||
for (var i = 0; i < groups.length; i++) {
|
||||
if (groups[i].length > 2) return 'invalid_group';
|
||||
}
|
||||
if (groups.length === 6 && groups.every(function(g) { return g.length === 2; })) return 'complete';
|
||||
return 'incomplete';
|
||||
}
|
||||
|
||||
function classifyIp(s) {
|
||||
if (!s) return 'empty';
|
||||
if (/[^0-9a-fA-F:.]/.test(s)) return 'invalid_char';
|
||||
if (s.indexOf(':') !== -1) {
|
||||
// IPv6
|
||||
if (/:::/.test(s) || (s.match(/::/g) || []).length > 1) return 'invalid_struct';
|
||||
var v6parts = s.split(':').filter(function(p) { return p !== ''; });
|
||||
if (!v6parts.every(function(p) { return /^[0-9a-fA-F]{1,4}$/.test(p) || /^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(p); })) return 'invalid';
|
||||
var fullGroups = s.replace(/[^:]/g, '').length;
|
||||
if (s.indexOf('::') !== -1 || fullGroups === 7) return 'complete';
|
||||
return 'incomplete';
|
||||
}
|
||||
// IPv4
|
||||
if (/\.\./.test(s) || s.charAt(0) === '.') return 'invalid_struct';
|
||||
var parts = s.split('.');
|
||||
if (parts.length > 4) return 'invalid_struct';
|
||||
for (var i = 0; i < parts.length; i++) {
|
||||
if (!parts[i]) continue;
|
||||
var n = parseInt(parts[i], 10);
|
||||
if (isNaN(n) || n > 255 || String(n) !== parts[i]) return 'invalid_range';
|
||||
}
|
||||
if (parts.length === 4 && parts.every(function(p) { return p !== ''; })) return 'complete';
|
||||
return 'incomplete';
|
||||
}
|
||||
|
||||
function classifyIpv4(s) {
|
||||
if (!s) return 'empty';
|
||||
if (s.indexOf(':') !== -1) return 'invalid_struct';
|
||||
return classifyIp(s);
|
||||
}
|
||||
|
||||
function classifyIpv6(s) {
|
||||
if (!s) return 'empty';
|
||||
if (s.indexOf('.') !== -1 && s.indexOf(':') === -1) return 'invalid_struct';
|
||||
if (s.indexOf(':') === -1) return 'incomplete';
|
||||
return classifyIp(s);
|
||||
}
|
||||
|
||||
function classifyUrl(s) {
|
||||
if (!s) return 'empty';
|
||||
if (/[^A-Za-z0-9\-._~:/?#\[\]@!$&'()*+,;=%]/.test(s)) return 'invalid_char';
|
||||
var sl = s.toLowerCase();
|
||||
if ('https://'.startsWith(sl) || 'http://'.startsWith(sl)) return 'incomplete';
|
||||
var sep = sl.indexOf('://');
|
||||
if (sep === -1) return 'invalid_struct';
|
||||
var scheme = sl.slice(0, sep);
|
||||
if (scheme !== 'http' && scheme !== 'https') return 'invalid_struct';
|
||||
var afterScheme = s.slice(sep + 3);
|
||||
if (!afterScheme) return 'incomplete';
|
||||
var hostEnd = afterScheme.search(/[/:?#]/);
|
||||
var host = hostEnd === -1 ? afterScheme : afterScheme.slice(0, hostEnd);
|
||||
var rest = hostEnd === -1 ? '' : afterScheme.slice(hostEnd);
|
||||
if (!host) return 'incomplete';
|
||||
if (/\.\./.test(host) || host.charAt(0) === '.' || host.charAt(host.length - 1) === '.') return 'invalid_struct';
|
||||
var labels = host.split('.');
|
||||
for (var i = 0; i < labels.length; i++) {
|
||||
if (!/^[a-zA-Z0-9]([a-zA-Z0-9\-]*[a-zA-Z0-9])?$/.test(labels[i])) return 'invalid_struct';
|
||||
}
|
||||
if (rest.charAt(0) === ':') {
|
||||
var portMatch = rest.slice(1).match(/^\d+/);
|
||||
if (!portMatch) return 'incomplete';
|
||||
if (parseInt(portMatch[0]) < 1 || parseInt(portMatch[0]) > 65535) return 'invalid_struct';
|
||||
}
|
||||
return 'complete';
|
||||
}
|
||||
|
||||
function classifyPort(s) {
|
||||
if (!s) return 'empty';
|
||||
if (/[^0-9]/.test(s)) return 'invalid_char';
|
||||
var n = parseInt(s, 10);
|
||||
if (n < 1 || n > 65535) return 'out_of_range';
|
||||
return 'complete';
|
||||
}
|
||||
|
||||
function classifyIpv4Cidr(s) {
|
||||
if (!s) return 'empty';
|
||||
var slash = s.indexOf('/');
|
||||
if (slash === -1) return classifyIpv4(s);
|
||||
var ipCls = classifyIpv4(s.slice(0, slash));
|
||||
if (ipCls !== 'complete') return ipCls;
|
||||
var prefix = s.slice(slash + 1);
|
||||
if (!prefix) return 'incomplete';
|
||||
if (/[^0-9]/.test(prefix)) return 'invalid_char';
|
||||
var n = parseInt(prefix, 10);
|
||||
if (n < 0 || n > 32) return 'invalid_struct';
|
||||
return 'complete';
|
||||
}
|
||||
|
||||
function classifyEndpoint(s) {
|
||||
if (!s) return 'empty';
|
||||
if (s.indexOf(':') !== -1) return classifyIp(s);
|
||||
if (/^[0-9.]+$/.test(s)) return classifyIp(s);
|
||||
return classifyDomainname(s);
|
||||
}
|
||||
|
||||
function classifyDashname(s) {
|
||||
if (!s) return 'empty';
|
||||
if (/[^a-z0-9-]/.test(s)) return 'invalid_char';
|
||||
if (s.charAt(0) === '-') return 'invalid_struct';
|
||||
if (/--/.test(s)) return 'invalid_struct';
|
||||
if (s.charAt(s.length - 1) === '-') return 'incomplete';
|
||||
return 'complete';
|
||||
}
|
||||
|
||||
function classifyDomainname(s) {
|
||||
if (!s) return 'empty';
|
||||
if (/[^a-zA-Z0-9.-]/.test(s)) return 'invalid_char';
|
||||
if (s.charAt(0) === '.') return 'invalid_struct';
|
||||
if (/\.\./.test(s)) return 'invalid_struct';
|
||||
if (s.charAt(s.length - 1) === '.') return 'incomplete';
|
||||
var labels = s.split('.');
|
||||
for (var i = 0; i < labels.length; i++) {
|
||||
var l = labels[i];
|
||||
if (l.charAt(0) === '-' || l.charAt(l.length - 1) === '-') return 'invalid_struct';
|
||||
}
|
||||
return 'complete';
|
||||
}
|
||||
|
||||
function classifyNetworkname(s) {
|
||||
if (!s) return 'empty';
|
||||
if (/[^a-zA-Z0-9_-]/.test(s)) return 'invalid_char';
|
||||
if (s.charAt(0) === '-' || s.charAt(0) === '_') return 'invalid_struct';
|
||||
if (/[-_]{2,}/.test(s)) return 'invalid_struct';
|
||||
if (s.charAt(s.length - 1) === '-' || s.charAt(s.length - 1) === '_') return 'incomplete';
|
||||
return 'complete';
|
||||
}
|
||||
|
||||
function classifySubnet(s) {
|
||||
if (!s) return 'empty';
|
||||
if (/[^0-9.]/.test(s)) return 'invalid_char';
|
||||
|
|
@ -1225,8 +1627,9 @@ function classifySubnet(s) {
|
|||
function setFieldHint(input, message, state) {
|
||||
// state: 'error' | 'warning' | 'ok'
|
||||
var fg = input.closest('.form-group');
|
||||
if (fg) {
|
||||
var hint = fg.querySelector('.field-dyn-hint');
|
||||
var hintContainer = fg || input.parentElement;
|
||||
if (hintContainer) {
|
||||
var hint = hintContainer.querySelector('.field-dyn-hint');
|
||||
if (hint) {
|
||||
hint.textContent = message;
|
||||
hint.style.display = message ? '' : 'none';
|
||||
|
|
@ -1443,8 +1846,11 @@ document.addEventListener('click', function(e) {
|
|||
} else if (inputType === 'credentials') {
|
||||
td.innerHTML = buildCredentialsHtml(rowData.provider || 'noip', rowData);
|
||||
} else {
|
||||
var validateAttr = fDef.validate ? ' data-validate="' + esc(fDef.validate) + '"' : '';
|
||||
var hintHtml = fDef.validate ? '<p class="form-hint field-dyn-hint" style="display:none"></p>' : '';
|
||||
td.innerHTML = '<input type="' + inputType + '" name="' + field +
|
||||
'" value="' + esc(String(val)) + '" class="form-input inline-edit-input">';
|
||||
'" value="' + esc(String(val)) + '" class="form-input inline-edit-input"' + validateAttr + '>' + hintHtml;
|
||||
if (fDef.validate && typeof validateEl === 'function') validateEl(td.querySelector('input'));
|
||||
}
|
||||
});
|
||||
|
||||
|
|
@ -1531,58 +1937,190 @@ document.querySelectorAll('.js-hide-card').forEach(function(btn) {
|
|||
});
|
||||
});
|
||||
|
||||
function _elMakeRow(list, value) {
|
||||
var row = document.createElement('div');
|
||||
row.className = 'editable-list-item';
|
||||
var inp = document.createElement('input');
|
||||
inp.type = 'text'; inp.name = list.dataset.name; inp.value = value;
|
||||
inp.placeholder = list.dataset.placeholder || ''; inp.className = 'form-input';
|
||||
var btn = document.createElement('button');
|
||||
btn.type = 'button'; btn.className = 'btn btn-ghost btn-sm editable-list-remove';
|
||||
btn.textContent = 'Remove';
|
||||
btn.addEventListener('click', function() { row.remove(); });
|
||||
row.appendChild(inp); row.appendChild(btn);
|
||||
return row;
|
||||
}
|
||||
|
||||
document.querySelectorAll('.editable-list').forEach(function(list) {
|
||||
list.querySelectorAll('.editable-list-item').forEach(function(row) {
|
||||
row.querySelector('.editable-list-remove').addEventListener('click', function() { row.remove(); });
|
||||
});
|
||||
list.querySelector('.editable-list-add').addEventListener('click', function() {
|
||||
list.insertBefore(_elMakeRow(list, ''), this);
|
||||
});
|
||||
});
|
||||
|
||||
var validateEl;
|
||||
(function() {
|
||||
document.querySelectorAll('form').forEach(function(form) {
|
||||
var cancelBtn = form.querySelector('.btn-cancel');
|
||||
if (!cancelBtn) return;
|
||||
var origValues = {};
|
||||
form.querySelectorAll('input, textarea, select').forEach(function(el) {
|
||||
if (el.name) origValues[el.name] = el.type === 'checkbox' ? el.checked : el.value;
|
||||
});
|
||||
function checkChanged() {
|
||||
var changed = false;
|
||||
form.querySelectorAll('input, textarea, select').forEach(function(el) {
|
||||
if (!el.name) return;
|
||||
var cur = el.type === 'checkbox' ? el.checked : el.value;
|
||||
if (cur !== origValues[el.name]) changed = true;
|
||||
});
|
||||
cancelBtn.disabled = !changed;
|
||||
}
|
||||
form.addEventListener('input', checkChanged);
|
||||
form.addEventListener('change', checkChanged);
|
||||
cancelBtn.addEventListener('click', function() {
|
||||
form.querySelectorAll('input, textarea, select').forEach(function(el) {
|
||||
if (!el.name) return;
|
||||
if (el.type === 'checkbox') {
|
||||
el.checked = origValues[el.name];
|
||||
var _ipMsgs = { invalid_char: 'Invalid character', invalid_struct: 'Invalid format',
|
||||
invalid_range: 'Octet out of range', invalid: 'Invalid IP address' };
|
||||
var _msgs = {
|
||||
ip: _ipMsgs,
|
||||
ipv4: _ipMsgs,
|
||||
ipv6: _ipMsgs,
|
||||
mac: { invalid_char: 'Invalid character', invalid_struct: 'Invalid format',
|
||||
too_many: 'Too many groups', invalid_group: 'Each group must be exactly 2 hex characters' },
|
||||
subnet: { invalid_char: 'Invalid character', invalid_struct: 'Invalid format',
|
||||
range: 'Octet out of range' },
|
||||
url: { invalid_char: 'Invalid character', invalid_struct: 'Invalid URL format' },
|
||||
port: { invalid_char: 'Digits only', out_of_range: 'Must be between 1 and 65535' },
|
||||
ipv4cidr: { invalid_char: 'Invalid character', invalid_struct: 'Prefix must be 0–32',
|
||||
invalid_range: 'Octet out of range' },
|
||||
endpoint: { invalid_char: 'Invalid character', invalid_struct: 'Invalid hostname or IP',
|
||||
invalid_range: 'Octet out of range', invalid: 'Invalid IP address' },
|
||||
dashname: { invalid_char: 'Lowercase letters, digits and hyphens only',
|
||||
invalid_struct: 'No leading, trailing or consecutive hyphens' },
|
||||
domainname: { invalid_char: 'Letters, digits, hyphens and dots only',
|
||||
invalid_struct: 'Invalid domain format' },
|
||||
networkname: { invalid_char: 'Letters, digits, hyphens and underscores only',
|
||||
invalid_struct: 'No leading, trailing or consecutive special characters' }
|
||||
};
|
||||
var _classifiers = { ip: classifyIp, ipv4: classifyIpv4, ipv6: classifyIpv6, mac: classifyMac,
|
||||
subnet: classifySubnet, url: classifyUrl,
|
||||
port: classifyPort, ipv4cidr: classifyIpv4Cidr,
|
||||
endpoint: classifyEndpoint,
|
||||
dashname: classifyDashname, domainname: classifyDomainname, networkname: classifyNetworkname };
|
||||
|
||||
validateEl = function(el) {
|
||||
var list = el.closest('.editable-list[data-validate]');
|
||||
var vtype = el.dataset.validate || (list ? list.dataset.validate : '');
|
||||
var classify = _classifiers[vtype];
|
||||
if (!classify) return;
|
||||
var cls = classify(el.value);
|
||||
if (list) {
|
||||
el.classList.remove('field-invalid', 'field-warning');
|
||||
if (cls === 'incomplete') el.classList.add('field-warning');
|
||||
else if (cls !== 'empty' && cls !== 'complete') el.classList.add('field-invalid');
|
||||
} else {
|
||||
el.value = origValues[el.name];
|
||||
var msgs = _msgs[vtype] || {};
|
||||
if (cls === 'complete' || cls === 'empty') {
|
||||
setFieldHint(el, el._postValidate ? el._postValidate(cls) : '', 'ok');
|
||||
} else if (cls === 'incomplete') {
|
||||
setFieldHint(el, el._postValidate ? el._postValidate(cls) : '', 'warning');
|
||||
} else {
|
||||
setFieldHint(el, msgs[cls] || 'Invalid', 'error');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Regular fields (not inside editable lists) — initial state + expose _triggerValidate
|
||||
document.querySelectorAll('input[data-validate]').forEach(function(el) {
|
||||
if (el.closest('.editable-list')) return;
|
||||
el._triggerValidate = function() { validateEl(el); };
|
||||
validateEl(el);
|
||||
});
|
||||
cancelBtn.disabled = true;
|
||||
|
||||
// Document-level delegation for regular fields (covers static + dynamically added inputs)
|
||||
document.addEventListener('input', function(ev) {
|
||||
var el = ev.target;
|
||||
if (el.tagName !== 'INPUT' || !el.dataset.validate || el.closest('.editable-list')) return;
|
||||
validateEl(el);
|
||||
});
|
||||
|
||||
// Editable lists: validate existing items, delegation + MutationObserver for added items
|
||||
document.querySelectorAll('.editable-list[data-validate]').forEach(function(list) {
|
||||
if (!_classifiers[list.dataset.validate]) return;
|
||||
list.querySelectorAll('input').forEach(function(inp) { validateEl(inp); });
|
||||
list.addEventListener('input', function(ev) {
|
||||
if (ev.target.tagName === 'INPUT') validateEl(ev.target);
|
||||
});
|
||||
new MutationObserver(function(mutations) {
|
||||
mutations.forEach(function(m) {
|
||||
m.addedNodes.forEach(function(node) {
|
||||
if (node.nodeType !== 1) return;
|
||||
var inp = node.querySelector ? node.querySelector('input') : null;
|
||||
if (inp) validateEl(inp);
|
||||
});
|
||||
});
|
||||
}).observe(list, {childList: true});
|
||||
});
|
||||
})();
|
||||
|
||||
document.querySelectorAll('.editable-list').forEach(function(list) {
|
||||
var name = list.dataset.name;
|
||||
var ph = list.dataset.placeholder;
|
||||
function attachRemove(row) {
|
||||
row.querySelector('.editable-list-remove').addEventListener('click', function() {
|
||||
row.remove();
|
||||
(function() {
|
||||
document.querySelectorAll('form').forEach(function(form) {
|
||||
var origInput = form.querySelector('input[name="original_values"]');
|
||||
if (!origInput) return;
|
||||
var original;
|
||||
try { original = JSON.parse(origInput.value); } catch(ex) { return; }
|
||||
|
||||
var submitBtns = form.querySelectorAll('button[type="submit"]');
|
||||
var cancelBtns = form.querySelectorAll('.btn-cancel');
|
||||
submitBtns.forEach(function(b) { b.disabled = true; });
|
||||
cancelBtns.forEach(function(b) { b.disabled = true; });
|
||||
|
||||
// Only track fields named in original — naturally excludes config_hash,
|
||||
// row_index, etc., while including hidden inputs (e.g. picker values).
|
||||
function snapshot() {
|
||||
var state = {};
|
||||
Object.keys(original).forEach(function(k) { if (Array.isArray(original[k])) state[k] = []; });
|
||||
form.querySelectorAll('input, select, textarea').forEach(function(el) {
|
||||
if (!el.name || !(el.name in original)) return;
|
||||
var val = el.type === 'checkbox' ? (el.checked ? '1' : '0') : el.value;
|
||||
if (Array.isArray(state[el.name])) { state[el.name].push(val); }
|
||||
else if (Array.isArray(original[el.name])) { state[el.name] = [val]; }
|
||||
else { state[el.name] = val; }
|
||||
});
|
||||
return JSON.stringify(state);
|
||||
}
|
||||
|
||||
var baseSnap = snapshot();
|
||||
|
||||
function checkDirty() {
|
||||
var dirty = snapshot() !== baseSnap;
|
||||
submitBtns.forEach(function(b) { b.disabled = !dirty; });
|
||||
cancelBtns.forEach(function(b) { b.disabled = !dirty; });
|
||||
}
|
||||
|
||||
function resetToBase() {
|
||||
// Reset editable lists (DOM rebuild)
|
||||
form.querySelectorAll('.editable-list').forEach(function(list) {
|
||||
var addBtn = list.querySelector('.editable-list-add');
|
||||
list.querySelectorAll('.editable-list-item').forEach(function(r) { r.remove(); });
|
||||
(original[list.dataset.name] || []).forEach(function(v) {
|
||||
list.insertBefore(_elMakeRow(list, v), addBtn);
|
||||
});
|
||||
});
|
||||
// Reset all tracked inputs; dispatch change so custom widgets update themselves
|
||||
form.querySelectorAll('input, select, textarea').forEach(function(el) {
|
||||
if (!el.name || !(el.name in original) || el.closest('.editable-list')) return;
|
||||
var orig = original[el.name];
|
||||
var newVal = orig !== undefined ? String(orig) : '';
|
||||
if (el.type === 'checkbox') {
|
||||
el.checked = (orig === '1');
|
||||
el.dispatchEvent(new Event('change', {bubbles: true}));
|
||||
} else if (el.value !== newVal) {
|
||||
el.value = newVal;
|
||||
el.dispatchEvent(new Event('change', {bubbles: true}));
|
||||
}
|
||||
});
|
||||
checkDirty();
|
||||
form.querySelectorAll('input[data-validate]').forEach(function(el) {
|
||||
if (typeof validateEl === 'function') validateEl(el);
|
||||
});
|
||||
}
|
||||
list.querySelectorAll('.editable-list-item').forEach(attachRemove);
|
||||
list.querySelector('.editable-list-add').addEventListener('click', function() {
|
||||
var row = document.createElement('div');
|
||||
row.className = 'editable-list-item';
|
||||
row.innerHTML = '<input type="text" name="' + name + '" placeholder="' + ph +
|
||||
'" class="form-input"><button type="button" class="btn btn-ghost btn-sm' +
|
||||
' editable-list-remove">Remove</button>';
|
||||
list.insertBefore(row, this);
|
||||
attachRemove(row);
|
||||
});
|
||||
|
||||
cancelBtns.forEach(function(b) { b.addEventListener('click', resetToBase); });
|
||||
form.addEventListener('input', checkDirty);
|
||||
form.addEventListener('change', checkDirty);
|
||||
new MutationObserver(checkDirty).observe(form, {childList: true, subtree: true});
|
||||
|
||||
form._resetDirtyState = function() {
|
||||
baseSnap = snapshot();
|
||||
submitBtns.forEach(function(b) { b.disabled = true; });
|
||||
cancelBtns.forEach(function(b) { b.disabled = true; });
|
||||
};
|
||||
});
|
||||
})();
|
||||
|
||||
(function() {
|
||||
function updateCredFields(container, provider) {
|
||||
|
|
@ -1602,6 +2140,191 @@ document.querySelectorAll('.editable-list').forEach(function(list) {
|
|||
sel.addEventListener('change', function() { updateCredFields(container, this.value); });
|
||||
});
|
||||
})();
|
||||
|
||||
function startApplyPoller(uuid, bar, mine) {
|
||||
var nextIn = null;
|
||||
var pollTimer = null;
|
||||
var tickTimer = null;
|
||||
|
||||
function user() { return bar.getAttribute('data-apply-user') || ''; }
|
||||
function esc(s) { return s.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>'); }
|
||||
function setHtml(html) { bar.innerHTML = html; }
|
||||
|
||||
function updateCountdown() {
|
||||
if (nextIn === null) {
|
||||
setHtml(mine ? 'Configuration saved. The command processing service is not installed. Run <strong>core.py --install</strong> to enable it, or <strong>core.py --apply</strong> to apply manually.'
|
||||
: esc(user()) + ' has pending changes. The command processing service is not installed.');
|
||||
return;
|
||||
}
|
||||
var timing = nextIn <= 5 ? 'momentarily'
|
||||
: nextIn < 60 ? 'in about ' + nextIn + ' seconds'
|
||||
: 'in about ' + Math.round(nextIn / 60) + ' minute' + (Math.round(nextIn / 60) !== 1 ? 's' : '');
|
||||
setHtml(mine ? 'Configuration saved. Changes will be applied ' + timing + '.'
|
||||
: esc(user()) + ' has pending changes which will be applied ' + timing + '.');
|
||||
}
|
||||
|
||||
function onStatus(data) {
|
||||
if (data.status === 'complete') {
|
||||
bar.classList.remove('info-bar-running');
|
||||
setHtml(mine ? 'Changes have been applied.' : esc(user()) + '\'s changes have been applied.');
|
||||
clearTimeout(pollTimer);
|
||||
clearTimeout(tickTimer);
|
||||
return;
|
||||
}
|
||||
if (data.status === 'running') {
|
||||
bar.classList.add('info-bar-running');
|
||||
setHtml(mine ? 'Configuration saved. Changes are being applied now...'
|
||||
: esc(user()) + '\'s changes are being applied now...');
|
||||
} else {
|
||||
bar.classList.remove('info-bar-running');
|
||||
if (data.next_in !== null && data.next_in !== undefined) { nextIn = data.next_in; }
|
||||
updateCountdown();
|
||||
}
|
||||
pollTimer = setTimeout(doPoll, 3000);
|
||||
}
|
||||
|
||||
function doPoll() {
|
||||
fetch('/api/apply-status?uuid=' + encodeURIComponent(uuid))
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(onStatus)
|
||||
.catch(function() { pollTimer = setTimeout(doPoll, 3000); });
|
||||
}
|
||||
|
||||
function tick() {
|
||||
if (nextIn !== null && nextIn > 0) { nextIn--; updateCountdown(); }
|
||||
tickTimer = setTimeout(tick, 1000);
|
||||
}
|
||||
|
||||
doPoll();
|
||||
tick();
|
||||
}
|
||||
|
||||
(function() {
|
||||
if (typeof APPLY_UUID !== 'undefined' && APPLY_UUID) {
|
||||
var bar = document.querySelector('.info-bar-flash.info-bar-success');
|
||||
if (bar) startApplyPoller(APPLY_UUID, bar, true);
|
||||
}
|
||||
document.querySelectorAll('[data-apply-uuid]').forEach(function(bar) {
|
||||
startApplyPoller(bar.getAttribute('data-apply-uuid'), bar, false);
|
||||
});
|
||||
})();
|
||||
|
||||
(function() {
|
||||
function closeAll() {
|
||||
document.querySelectorAll('.iface-picker-dropdown.open').forEach(function(d) {
|
||||
d.classList.remove('open');
|
||||
});
|
||||
}
|
||||
document.querySelectorAll('.iface-picker').forEach(function(picker) {
|
||||
var btn = picker.querySelector('.iface-picker-btn');
|
||||
var header = picker.querySelector('.iface-picker-header');
|
||||
var dropdown = picker.querySelector('.iface-picker-dropdown');
|
||||
var hidden = picker.querySelector('input[type="hidden"]');
|
||||
|
||||
function applySelection(iface) {
|
||||
var row = dropdown.querySelector('.iface-picker-row[data-iface="' + iface + '"]');
|
||||
if (!row) return;
|
||||
btn.querySelector('.iface-picker-name').textContent = iface;
|
||||
var badge = btn.querySelector('.iface-picker-badge');
|
||||
if (!badge) { badge = document.createElement('span'); btn.appendChild(badge); }
|
||||
badge.className = 'badge ' + row.dataset.stateClass + ' iface-picker-badge';
|
||||
badge.textContent = row.dataset.stateLabel;
|
||||
var stats = header.querySelector('.iface-picker-stats');
|
||||
if (!stats) {
|
||||
stats = document.createElement('table');
|
||||
stats.className = 'iface-picker-stats';
|
||||
stats.innerHTML = '<thead><tr><th>Speed</th><th>MTU</th><th>MAC</th></tr></thead><tbody><tr></tr></tbody>';
|
||||
header.appendChild(stats);
|
||||
}
|
||||
stats.querySelector('tbody tr').innerHTML =
|
||||
'<td>' + (row.dataset.speed || '-') + '</td>'
|
||||
+ '<td>' + (row.dataset.mtu || '-') + '</td>'
|
||||
+ '<td class="col-mono">' + (row.dataset.mac || '-') + '</td>';
|
||||
dropdown.querySelectorAll('.iface-picker-row').forEach(function(r) {
|
||||
r.classList.toggle('selected', r === row);
|
||||
});
|
||||
}
|
||||
|
||||
hidden.addEventListener('change', function() { applySelection(hidden.value); });
|
||||
|
||||
btn.addEventListener('click', function(e) {
|
||||
e.stopPropagation();
|
||||
var wasOpen = dropdown.classList.contains('open');
|
||||
closeAll();
|
||||
if (!wasOpen) dropdown.classList.add('open');
|
||||
});
|
||||
dropdown.addEventListener('click', function(e) { e.stopPropagation(); });
|
||||
dropdown.querySelectorAll('.iface-picker-row').forEach(function(row) {
|
||||
row.addEventListener('click', function() {
|
||||
hidden.value = this.dataset.iface;
|
||||
closeAll();
|
||||
hidden.dispatchEvent(new Event('change', {bubbles: true}));
|
||||
});
|
||||
});
|
||||
});
|
||||
document.addEventListener('click', closeAll);
|
||||
document.addEventListener('keydown', function(e) {
|
||||
if (e.key === 'Escape') closeAll();
|
||||
});
|
||||
document.querySelectorAll('.iface-configure-btn').forEach(function(btn) {
|
||||
btn.addEventListener('click', function() {
|
||||
var card = document.getElementById('iface-config-card');
|
||||
if (!card) return;
|
||||
var form = card.querySelector('form');
|
||||
if (!form) return;
|
||||
form.querySelector('[name="iface"]').value = this.dataset.iface;
|
||||
var minMtu = this.dataset.minMtu !== '' ? parseInt(this.dataset.minMtu) : null;
|
||||
var maxMtu = this.dataset.maxMtu !== '' ? parseInt(this.dataset.maxMtu) : null;
|
||||
var mtuSel = form.querySelector('[name="mtu"]');
|
||||
var originalMtu = this.dataset.mtu || '';
|
||||
if (mtuSel) {
|
||||
Array.from(mtuSel.options).forEach(function(opt) {
|
||||
var v = parseInt(opt.value);
|
||||
var out = !isNaN(v) && ((minMtu !== null && v < minMtu) || (maxMtu !== null && v > maxMtu));
|
||||
opt.disabled = out;
|
||||
opt.hidden = out;
|
||||
});
|
||||
mtuSel.value = originalMtu;
|
||||
if (!mtuSel.value || mtuSel.selectedOptions[0].disabled) {
|
||||
var first = Array.from(mtuSel.options).find(function(o) { return !o.disabled; });
|
||||
if (first) mtuSel.value = first.value;
|
||||
originalMtu = mtuSel.value;
|
||||
}
|
||||
}
|
||||
var origMtuField = form.querySelector('[name="original_mtu"]');
|
||||
if (origMtuField) origMtuField.value = originalMtu;
|
||||
var macInput = form.querySelector('[name="mac"]');
|
||||
var originalMac = this.dataset.mac || '';
|
||||
if (macInput) {
|
||||
macInput.dataset.permMac = this.dataset.permMac || '';
|
||||
macInput.value = originalMac;
|
||||
if (macInput._triggerValidate) macInput._triggerValidate();
|
||||
}
|
||||
var origMacField = form.querySelector('[name="original_mac"]');
|
||||
if (origMacField) origMacField.value = originalMac;
|
||||
if (form._resetDirtyState) form._resetDirtyState();
|
||||
card.style.display = '';
|
||||
card.scrollIntoView({behavior: 'smooth', block: 'nearest'});
|
||||
});
|
||||
});
|
||||
document.querySelectorAll('.iface-config-cancel').forEach(function(a) {
|
||||
a.addEventListener('click', function(ev) {
|
||||
ev.preventDefault();
|
||||
var card = document.getElementById('iface-config-card');
|
||||
if (card) card.style.display = 'none';
|
||||
});
|
||||
});
|
||||
})();
|
||||
(function() {
|
||||
var card = document.getElementById('iface-config-card');
|
||||
if (!card) return;
|
||||
var macInput = card.querySelector('input[name="mac"]');
|
||||
if (!macInput || !macInput._triggerValidate) return;
|
||||
macInput._postValidate = function() {
|
||||
return macInput.dataset.permMac ? 'Factory default: ' + macInput.dataset.permMac : '';
|
||||
};
|
||||
macInput._triggerValidate();
|
||||
})();
|
||||
"""
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -275,7 +275,7 @@
|
|||
"cells": [
|
||||
{
|
||||
"type": "grid_label",
|
||||
"text": "Upstream Servers"
|
||||
"text": "DNS Providers"
|
||||
},
|
||||
{
|
||||
"type": "grid_value",
|
||||
|
|
@ -440,6 +440,10 @@
|
|||
"text": "Add Provider",
|
||||
"action": "/action/add_ddns_provider",
|
||||
"method": "post"
|
||||
},
|
||||
{
|
||||
"type": "button_cancel",
|
||||
"text": "Cancel"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -491,41 +495,41 @@
|
|||
{
|
||||
"type": "card",
|
||||
"label": "Network Interfaces",
|
||||
"client_requirement": "client_is_viewer+",
|
||||
"client_requirement": "client_is_administrator+",
|
||||
"items": [
|
||||
{
|
||||
"type": "table",
|
||||
"datasource": "config:interfaces",
|
||||
"empty_message": "No interfaces configured.",
|
||||
"columns": [
|
||||
{
|
||||
"label": "Type",
|
||||
"field": "iface_type",
|
||||
"class": "col-mono"
|
||||
},
|
||||
{
|
||||
"label": "Interface",
|
||||
"field": "interface",
|
||||
"class": "col-mono"
|
||||
},
|
||||
{
|
||||
"label": "Status",
|
||||
"field": "status",
|
||||
"render": "interface_status"
|
||||
}
|
||||
],
|
||||
"row_actions": [
|
||||
{
|
||||
"text": "Edit",
|
||||
"class": "btn-ghost btn-sm",
|
||||
"type": "form",
|
||||
"action": "/action/apply_interface",
|
||||
"method": "inline_edit",
|
||||
"client_requirement": "client_is_administrator+",
|
||||
"fields": [
|
||||
"method": "post",
|
||||
"items": [
|
||||
{
|
||||
"col": "interface",
|
||||
"input_type": "select",
|
||||
"options": "%NETWORK_INTERFACE_STATUS_OPTIONS%"
|
||||
"type": "field",
|
||||
"label": "WAN Interface",
|
||||
"name": "wan_interface",
|
||||
"input_type": "interface_picker",
|
||||
"value": "%GENERAL_WAN_INTERFACE%",
|
||||
"data": "%NETWORK_INTERFACE_DATA_JSON%"
|
||||
},
|
||||
{
|
||||
"type": "field",
|
||||
"label": "LAN Interface",
|
||||
"name": "lan_interface",
|
||||
"input_type": "interface_picker",
|
||||
"value": "%GENERAL_LAN_INTERFACE%",
|
||||
"data": "%NETWORK_INTERFACE_DATA_JSON%"
|
||||
},
|
||||
{
|
||||
"type": "button_row",
|
||||
"items": [
|
||||
{
|
||||
"type": "button_primary",
|
||||
"text": "Save",
|
||||
"action": "/action/apply_interface",
|
||||
"method": "post"
|
||||
},
|
||||
{
|
||||
"type": "button_cancel",
|
||||
"text": "Cancel"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -535,7 +539,87 @@
|
|||
},
|
||||
{
|
||||
"type": "card",
|
||||
"label": "General",
|
||||
"id": "iface-config-card",
|
||||
"label": "Interface Configuration",
|
||||
"hidden": true,
|
||||
"client_requirement": "client_is_administrator+",
|
||||
"items": [
|
||||
{
|
||||
"type": "form",
|
||||
"action": "/action/apply_iface_config",
|
||||
"method": "post",
|
||||
"items": [
|
||||
{
|
||||
"type": "hidden",
|
||||
"name": "original_mtu",
|
||||
"value": ""
|
||||
},
|
||||
{
|
||||
"type": "hidden",
|
||||
"name": "original_mac",
|
||||
"value": ""
|
||||
},
|
||||
{
|
||||
"type": "field_row",
|
||||
"cols": 3,
|
||||
"items": [
|
||||
{
|
||||
"type": "field",
|
||||
"label": "Interface",
|
||||
"name": "iface",
|
||||
"input_type": "text",
|
||||
"readonly": true,
|
||||
"value": ""
|
||||
},
|
||||
{
|
||||
"type": "field",
|
||||
"label": "MTU",
|
||||
"name": "mtu",
|
||||
"input_type": "select",
|
||||
"value": "",
|
||||
"options": [
|
||||
{"value": "576", "label": "576"},
|
||||
{"value": "1280", "label": "1280"},
|
||||
{"value": "1492", "label": "1492"},
|
||||
{"value": "1500", "label": "1500"},
|
||||
{"value": "4096", "label": "4096"},
|
||||
{"value": "9000", "label": "9000"}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "field",
|
||||
"label": "MAC Address",
|
||||
"name": "mac",
|
||||
"input_type": "text",
|
||||
"validate": "mac",
|
||||
"value": ""
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "button_row",
|
||||
"items": [
|
||||
{
|
||||
"type": "button_primary",
|
||||
"text": "Apply",
|
||||
"action": "/action/apply_iface_config",
|
||||
"method": "post"
|
||||
},
|
||||
{
|
||||
"type": "button_secondary",
|
||||
"text": "Cancel",
|
||||
"action": "#",
|
||||
"class": "iface-config-cancel"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "card",
|
||||
"label": "Logging",
|
||||
"items": [
|
||||
{
|
||||
"type": "form",
|
||||
|
|
@ -630,7 +714,7 @@
|
|||
"name": "strict_order",
|
||||
"input_type": "checkbox",
|
||||
"value": "%DNS_STRICT_ORDER%",
|
||||
"hint": "Query upstream servers in list order rather than in parallel."
|
||||
"hint": "Query DNS providers in list order rather than in parallel."
|
||||
},
|
||||
{
|
||||
"type": "field",
|
||||
|
|
@ -643,11 +727,12 @@
|
|||
},
|
||||
{
|
||||
"type": "editable_list",
|
||||
"label": "Upstream Servers",
|
||||
"label": "DNS Providers",
|
||||
"name": "upstream_servers",
|
||||
"items": "%DNS_UPSTREAM_SERVERS_JSON%",
|
||||
"item_placeholder": "e.g. 1.1.1.1",
|
||||
"add_label": "Add Server",
|
||||
"add_label": "Add Provider",
|
||||
"validate": "ip",
|
||||
"hint": "DNS resolvers queried for external hostnames. Supports IPv4 and IPv6."
|
||||
},
|
||||
{
|
||||
|
|
@ -660,9 +745,8 @@
|
|||
"method": "post"
|
||||
},
|
||||
{
|
||||
"type": "button_secondary",
|
||||
"text": "Cancel",
|
||||
"action": "/view/view_upstream_dns"
|
||||
"type": "button_cancel",
|
||||
"text": "Cancel"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -780,6 +864,10 @@
|
|||
"text": "Add Banned IP",
|
||||
"action": "/action/add_banned_ip",
|
||||
"method": "post"
|
||||
},
|
||||
{
|
||||
"type": "button_cancel",
|
||||
"text": "Cancel"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -845,11 +933,13 @@
|
|||
},
|
||||
{
|
||||
"col": "host",
|
||||
"input_type": "text"
|
||||
"input_type": "text",
|
||||
"validate": "domainname"
|
||||
},
|
||||
{
|
||||
"col": "ip",
|
||||
"input_type": "text"
|
||||
"input_type": "text",
|
||||
"validate": "ip"
|
||||
},
|
||||
{
|
||||
"col": "enabled",
|
||||
|
|
@ -889,6 +979,7 @@
|
|||
"label": "Hostname",
|
||||
"name": "host",
|
||||
"input_type": "text",
|
||||
"validate": "domainname",
|
||||
"placeholder": "e.g. server.home.local"
|
||||
},
|
||||
{
|
||||
|
|
@ -896,6 +987,7 @@
|
|||
"label": "Resolves To",
|
||||
"name": "ip",
|
||||
"input_type": "text",
|
||||
"validate": "ip",
|
||||
"placeholder": "e.g. 192.168.1.100"
|
||||
},
|
||||
{
|
||||
|
|
@ -906,6 +998,10 @@
|
|||
"text": "Add Host Override",
|
||||
"action": "/action/add_host_override",
|
||||
"method": "post"
|
||||
},
|
||||
{
|
||||
"type": "button_cancel",
|
||||
"text": "Cancel"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -977,7 +1073,8 @@
|
|||
"fields": [
|
||||
{
|
||||
"col": "name",
|
||||
"input_type": "text"
|
||||
"input_type": "text",
|
||||
"validate": "dashname"
|
||||
},
|
||||
{
|
||||
"col": "description",
|
||||
|
|
@ -990,7 +1087,8 @@
|
|||
},
|
||||
{
|
||||
"col": "url",
|
||||
"input_type": "text"
|
||||
"input_type": "text",
|
||||
"validate": "url"
|
||||
}
|
||||
]
|
||||
},
|
||||
|
|
@ -1019,6 +1117,7 @@
|
|||
"label": "Name",
|
||||
"name": "name",
|
||||
"input_type": "text",
|
||||
"validate": "dashname",
|
||||
"placeholder": "e.g. steven-black"
|
||||
},
|
||||
{
|
||||
|
|
@ -1040,6 +1139,7 @@
|
|||
"label": "Source URL",
|
||||
"name": "url",
|
||||
"input_type": "text",
|
||||
"validate": "url",
|
||||
"placeholder": "https://..."
|
||||
},
|
||||
{
|
||||
|
|
@ -1050,6 +1150,10 @@
|
|||
"text": "Add Blocklist",
|
||||
"action": "/action/add_blocklist",
|
||||
"method": "post"
|
||||
},
|
||||
{
|
||||
"type": "button_cancel",
|
||||
"text": "Cancel"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -1089,40 +1193,44 @@
|
|||
{
|
||||
"label": "VLAN ID",
|
||||
"field": "vlan_id",
|
||||
"class": "col-mono"
|
||||
"class": "col-mono col-narrow"
|
||||
},
|
||||
{
|
||||
"label": "Name",
|
||||
"field": "name"
|
||||
"field": "name",
|
||||
"class": "col-narrow"
|
||||
},
|
||||
{
|
||||
"label": "Interface",
|
||||
"field": "interface",
|
||||
"class": "col-mono"
|
||||
"class": "col-mono col-narrow"
|
||||
},
|
||||
{
|
||||
"label": "Subnet",
|
||||
"field": "subnet",
|
||||
"class": "col-mono"
|
||||
"class": "col-mono col-narrow"
|
||||
},
|
||||
{
|
||||
"label": "Mask",
|
||||
"field": "subnet_mask",
|
||||
"class": "col-mono"
|
||||
"class": "col-mono col-narrow"
|
||||
},
|
||||
{
|
||||
"label": "Blocklists",
|
||||
"field": "use_blocklists",
|
||||
"class": "col-expand",
|
||||
"render": "tag_list"
|
||||
},
|
||||
{
|
||||
"label": "RADIUS Default",
|
||||
"field": "radius_default",
|
||||
"class": "col-narrow",
|
||||
"render": "badge_enabled_disabled"
|
||||
},
|
||||
{
|
||||
"label": "mDNS Reflection",
|
||||
"field": "mdns_reflection",
|
||||
"class": "col-narrow",
|
||||
"render": "badge_enabled_disabled"
|
||||
}
|
||||
],
|
||||
|
|
@ -1136,7 +1244,8 @@
|
|||
"fields": [
|
||||
{
|
||||
"col": "name",
|
||||
"input_type": "text"
|
||||
"input_type": "text",
|
||||
"validate": "dashname"
|
||||
},
|
||||
{
|
||||
"col": "subnet",
|
||||
|
|
@ -1196,6 +1305,7 @@
|
|||
"label": "VLAN Name",
|
||||
"name": "name",
|
||||
"input_type": "text",
|
||||
"validate": "dashname",
|
||||
"hint": "Lowercase letters, digits, hyphens. E.g. iot"
|
||||
},
|
||||
{
|
||||
|
|
@ -1277,6 +1387,10 @@
|
|||
"method": "post",
|
||||
"class": "add-vlan-btn",
|
||||
"disabled": true
|
||||
},
|
||||
{
|
||||
"type": "button_cancel",
|
||||
"text": "Cancel"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -1412,6 +1526,7 @@
|
|||
"label": "Source",
|
||||
"name": "src_ip_or_subnet",
|
||||
"input_type": "text",
|
||||
"validate": "ipv4cidr",
|
||||
"placeholder": "e.g. 192.168.20.0/24"
|
||||
},
|
||||
{
|
||||
|
|
@ -1419,6 +1534,7 @@
|
|||
"label": "Destination",
|
||||
"name": "dst_ip_or_subnet",
|
||||
"input_type": "text",
|
||||
"validate": "ipv4",
|
||||
"placeholder": "e.g. 192.168.10.100"
|
||||
},
|
||||
{
|
||||
|
|
@ -1426,6 +1542,7 @@
|
|||
"label": "Dest Port",
|
||||
"name": "dst_port",
|
||||
"input_type": "text",
|
||||
"validate": "port",
|
||||
"placeholder": "e.g. 8009"
|
||||
},
|
||||
{
|
||||
|
|
@ -1436,6 +1553,10 @@
|
|||
"text": "Add Exception",
|
||||
"action": "/action/add_inter_vlan",
|
||||
"method": "post"
|
||||
},
|
||||
{
|
||||
"type": "button_cancel",
|
||||
"text": "Cancel"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -1571,6 +1692,7 @@
|
|||
"label": "Ext Port",
|
||||
"name": "dest_port",
|
||||
"input_type": "text",
|
||||
"validate": "port",
|
||||
"placeholder": "e.g. 25565"
|
||||
},
|
||||
{
|
||||
|
|
@ -1578,6 +1700,7 @@
|
|||
"label": "NAT IP",
|
||||
"name": "nat_ip",
|
||||
"input_type": "text",
|
||||
"validate": "ipv4",
|
||||
"placeholder": "e.g. 192.168.1.50"
|
||||
},
|
||||
{
|
||||
|
|
@ -1585,6 +1708,7 @@
|
|||
"label": "NAT Port",
|
||||
"name": "nat_port",
|
||||
"input_type": "text",
|
||||
"validate": "port",
|
||||
"placeholder": "e.g. 25565"
|
||||
},
|
||||
{
|
||||
|
|
@ -1595,6 +1719,10 @@
|
|||
"text": "Add Rule",
|
||||
"action": "/action/add_port_forward",
|
||||
"method": "post"
|
||||
},
|
||||
{
|
||||
"type": "button_cancel",
|
||||
"text": "Cancel"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -1714,15 +1842,18 @@
|
|||
},
|
||||
{
|
||||
"col": "hostname",
|
||||
"input_type": "text"
|
||||
"input_type": "text",
|
||||
"validate": "networkname"
|
||||
},
|
||||
{
|
||||
"col": "mac",
|
||||
"input_type": "text"
|
||||
"input_type": "text",
|
||||
"validate": "mac"
|
||||
},
|
||||
{
|
||||
"col": "ip",
|
||||
"input_type": "text"
|
||||
"input_type": "text",
|
||||
"validate": "ipv4"
|
||||
},
|
||||
{
|
||||
"col": "radius_client",
|
||||
|
|
@ -1774,6 +1905,7 @@
|
|||
"label": "Hostname",
|
||||
"name": "hostname",
|
||||
"input_type": "text",
|
||||
"validate": "networkname",
|
||||
"placeholder": "e.g. nas"
|
||||
},
|
||||
{
|
||||
|
|
@ -1781,6 +1913,7 @@
|
|||
"label": "MAC Address",
|
||||
"name": "mac",
|
||||
"input_type": "text",
|
||||
"validate": "mac",
|
||||
"placeholder": "e.g. aa:bb:cc:dd:ee:ff"
|
||||
},
|
||||
{
|
||||
|
|
@ -1788,6 +1921,7 @@
|
|||
"label": "IP Address",
|
||||
"name": "ip",
|
||||
"input_type": "text",
|
||||
"validate": "ipv4",
|
||||
"placeholder": "e.g. 192.168.10.50"
|
||||
},
|
||||
{
|
||||
|
|
@ -1805,6 +1939,10 @@
|
|||
"text": "Add Reservation",
|
||||
"action": "/action/add_dhcp_reservation",
|
||||
"method": "post"
|
||||
},
|
||||
{
|
||||
"type": "button_cancel",
|
||||
"text": "Cancel"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -1841,11 +1979,6 @@
|
|||
"label": "Peer",
|
||||
"field": "peer_name"
|
||||
},
|
||||
{
|
||||
"label": "Interface",
|
||||
"field": "interface",
|
||||
"class": "col-mono"
|
||||
},
|
||||
{
|
||||
"label": "Tunnel IP",
|
||||
"field": "tunnel_ip",
|
||||
|
|
@ -1883,7 +2016,12 @@
|
|||
"field": "name"
|
||||
},
|
||||
{
|
||||
"label": "IP",
|
||||
"label": "Assigned VLAN",
|
||||
"field": "vlan_display",
|
||||
"class": "col-mono"
|
||||
},
|
||||
{
|
||||
"label": "Assigned IP",
|
||||
"field": "ip",
|
||||
"class": "col-mono"
|
||||
},
|
||||
|
|
@ -1912,7 +2050,8 @@
|
|||
"fields": [
|
||||
{
|
||||
"col": "name",
|
||||
"input_type": "text"
|
||||
"input_type": "text",
|
||||
"validate": "dashname"
|
||||
},
|
||||
{
|
||||
"col": "split_tunnel",
|
||||
|
|
@ -1955,14 +2094,23 @@
|
|||
"label": "Name",
|
||||
"name": "peer_name",
|
||||
"input_type": "text",
|
||||
"validate": "dashname",
|
||||
"placeholder": "e.g. laptop",
|
||||
"hint": "Friendly name for this peer."
|
||||
},
|
||||
{
|
||||
"type": "field",
|
||||
"label": "IP Address",
|
||||
"label": "Assigned VLAN",
|
||||
"name": "peer_vlan",
|
||||
"input_type": "select",
|
||||
"options": "%VPN_VLAN_OPTIONS%"
|
||||
},
|
||||
{
|
||||
"type": "field",
|
||||
"label": "Assigned IP",
|
||||
"name": "peer_ip",
|
||||
"input_type": "text",
|
||||
"validate": "ipv4",
|
||||
"placeholder": "e.g. 192.168.40.2",
|
||||
"hint": "Static IP assigned to this peer within the VPN subnet."
|
||||
},
|
||||
|
|
@ -1973,6 +2121,13 @@
|
|||
"input_type": "checkbox",
|
||||
"hint": "Route only VPN subnet traffic through the tunnel. When unchecked all traffic is routed through the VPN."
|
||||
},
|
||||
{
|
||||
"type": "field",
|
||||
"label": "Enabled",
|
||||
"name": "enabled",
|
||||
"input_type": "checkbox",
|
||||
"checked": true
|
||||
},
|
||||
{
|
||||
"type": "button_row",
|
||||
"items": [
|
||||
|
|
@ -2017,6 +2172,7 @@
|
|||
"label": "Server Endpoint",
|
||||
"name": "vpn_server_endpoint",
|
||||
"input_type": "text",
|
||||
"validate": "endpoint",
|
||||
"value": "%VPN_SERVER_ENDPOINT%",
|
||||
"placeholder": "e.g. vpn.example.com",
|
||||
"hint": "Publicly reachable hostname or IP of this server, embedded in client config files."
|
||||
|
|
@ -2026,6 +2182,7 @@
|
|||
"label": "Domain",
|
||||
"name": "vpn_domain",
|
||||
"input_type": "text",
|
||||
"validate": "dashname",
|
||||
"value": "%VPN_DOMAIN%",
|
||||
"placeholder": "e.g. local",
|
||||
"hint": "DNS search domain pushed to VPN clients."
|
||||
|
|
@ -2035,6 +2192,7 @@
|
|||
"label": "DNS Override",
|
||||
"name": "vpn_dns_server",
|
||||
"input_type": "text",
|
||||
"validate": "ipv4",
|
||||
"value": "%VPN_DNS_SERVER%",
|
||||
"placeholder": "Leave blank to use gateway IP (%VPN_GATEWAY%)",
|
||||
"hint": "Explicit DNS server pushed to peers. Defaults to the gateway IP."
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ services:
|
|||
- $HOME/router:/configs
|
||||
- $HOME/router/validation.py:/app/validation.py
|
||||
- /sys/class/net:/sys/class/net:ro
|
||||
- /sys/devices:/sys/devices:ro
|
||||
environment:
|
||||
- INITIAL_MANAGER_EMAIL=mgrotke@gmail.com
|
||||
- SECRET_KEY=ey8hSQCCYE5kQXV8nOg1CB44LSd3AoUet2ZBc3aZlFrwBbazE7aHcxXWyuT97eAObet5jmOL0CjMg0rB1hE4d2SBVYHPfl8De55EiFv307r1QP3Mf5XgOSSCxD3TuD
|
||||
|
|
|
|||
108
router/core.py
108
router/core.py
|
|
@ -111,9 +111,18 @@ DNSMASQ_CONF_DIR = Path("/etc/dnsmasq-router")
|
|||
LEASES_DIR = Path("/var/lib/misc")
|
||||
NETWORKD_DIR = Path("/etc/systemd/network")
|
||||
SYSTEMD_DIR = Path("/etc/systemd/system")
|
||||
TIMER_NAME = "dns-blocklists-update"
|
||||
TIMER_FILE = SYSTEMD_DIR / f"{TIMER_NAME}.timer"
|
||||
TIMER_SVC_FILE = SYSTEMD_DIR / f"{TIMER_NAME}.service"
|
||||
BLIST_TIMER_NAME = "dns-blocklists-update"
|
||||
BLIST_TIMER_FILE = SYSTEMD_DIR / f"{BLIST_TIMER_NAME}.timer"
|
||||
BLIST_TIMER_SVC_FILE = SYSTEMD_DIR / f"{BLIST_TIMER_NAME}.service"
|
||||
DASHB_TIMER_NAME = "router-dashboard-queue"
|
||||
DASHB_TIMER_FILE = SYSTEMD_DIR / f"{DASHB_TIMER_NAME}.timer"
|
||||
DASHB_TIMER_SVC_FILE = SYSTEMD_DIR / f"{DASHB_TIMER_NAME}.service"
|
||||
DASHB_TIMER_INTERVAL_SEC = 60
|
||||
DASHB_QUEUE_FILE = SCRIPT_DIR / ".dashboard-queue"
|
||||
DASHB_DONE_FILE = SCRIPT_DIR / ".dashboard-done"
|
||||
DASHB_LAST_RUN_FILE = SCRIPT_DIR / ".dashboard-last-run"
|
||||
DASHB_LOCK_FILE = SCRIPT_DIR / ".dashboard-lock"
|
||||
DASHB_SCRIPT_FILE = SCRIPT_DIR / "do_dashboard_queue.sh"
|
||||
RESOLV_CONF = Path("/etc/resolv.conf")
|
||||
NAT_SERVICE_NAME = "core-nat"
|
||||
NAT_SERVICE_FILE = SYSTEMD_DIR / f"{NAT_SERVICE_NAME}.service"
|
||||
|
|
@ -1729,20 +1738,77 @@ def install_timer(data):
|
|||
"",
|
||||
])
|
||||
|
||||
for path, content in ((TIMER_FILE, timer_content), (TIMER_SVC_FILE, service_content)):
|
||||
for path, content in ((BLIST_TIMER_FILE, timer_content), (BLIST_TIMER_SVC_FILE, service_content)):
|
||||
if not path.exists() or path.read_text() != content:
|
||||
path.write_text(content)
|
||||
print(f"Written: {path}")
|
||||
|
||||
subprocess.run(["systemctl", "daemon-reload"], capture_output=True, text=True)
|
||||
subprocess.run(["systemctl", "enable", "--now", f"{TIMER_NAME}.timer"],
|
||||
subprocess.run(["systemctl", "enable", "--now", f"{BLIST_TIMER_NAME}.timer"],
|
||||
capture_output=True, text=True)
|
||||
print(f"Timer {TIMER_NAME}.timer enabled (runs daily at {execute_time}).")
|
||||
print(f"Timer {BLIST_TIMER_NAME}.timer enabled (runs daily at {execute_time}).")
|
||||
|
||||
def install_dashboard_timer():
|
||||
"""Install the 1-minute dashboard-queue timer that processes .dashboard-queue."""
|
||||
timer_content = "\n".join([
|
||||
"# Generated by core.py -- do not edit manually.",
|
||||
"",
|
||||
"[Unit]",
|
||||
"Description=Router dashboard pending-update processor",
|
||||
"",
|
||||
"[Timer]",
|
||||
f"OnActiveSec={DASHB_TIMER_INTERVAL_SEC}s",
|
||||
f"OnUnitActiveSec={DASHB_TIMER_INTERVAL_SEC}s",
|
||||
"AccuracySec=10s",
|
||||
"",
|
||||
"[Install]",
|
||||
"WantedBy=timers.target",
|
||||
"",
|
||||
])
|
||||
|
||||
service_content = "\n".join([
|
||||
"# Generated by core.py -- do not edit manually.",
|
||||
"",
|
||||
"[Unit]",
|
||||
"Description=Router dashboard update processor",
|
||||
"",
|
||||
"[Service]",
|
||||
"Type=oneshot",
|
||||
f"ExecStart=/bin/bash {DASHB_SCRIPT_FILE}",
|
||||
"",
|
||||
])
|
||||
|
||||
for path, content in ((DASHB_TIMER_FILE, timer_content), (DASHB_TIMER_SVC_FILE, service_content)):
|
||||
if not path.exists() or path.read_text() != content:
|
||||
path.write_text(content)
|
||||
print(f"Written: {path}")
|
||||
|
||||
subprocess.run(["systemctl", "daemon-reload"], capture_output=True, text=True)
|
||||
subprocess.run(["systemctl", "enable", f"{DASHB_TIMER_NAME}.timer"],
|
||||
capture_output=True, text=True)
|
||||
active = subprocess.run(
|
||||
["systemctl", "is-active", f"{DASHB_TIMER_NAME}.timer"],
|
||||
capture_output=True, text=True
|
||||
).stdout.strip() == "active"
|
||||
verb = "restart" if active else "start"
|
||||
subprocess.run(["systemctl", verb, f"{DASHB_TIMER_NAME}.timer"],
|
||||
capture_output=True, text=True)
|
||||
print(f"Timer {DASHB_TIMER_NAME}.timer enabled (runs every {DASHB_TIMER_INTERVAL_SEC}s).")
|
||||
|
||||
def remove_dashboard_timer():
|
||||
subprocess.run(["systemctl", "disable", "--now", f"{DASHB_TIMER_NAME}.timer"],
|
||||
capture_output=True, text=True)
|
||||
for f in (DASHB_TIMER_FILE, DASHB_TIMER_SVC_FILE):
|
||||
if f.exists():
|
||||
f.unlink()
|
||||
print(f"Removed: {f}")
|
||||
else:
|
||||
print(f"Not found, skipping: {f}")
|
||||
|
||||
def remove_timer():
|
||||
subprocess.run(["systemctl", "disable", "--now", f"{TIMER_NAME}.timer"],
|
||||
subprocess.run(["systemctl", "disable", "--now", f"{BLIST_TIMER_NAME}.timer"],
|
||||
capture_output=True, text=True)
|
||||
for f in (TIMER_FILE, TIMER_SVC_FILE):
|
||||
for f in (BLIST_TIMER_FILE, BLIST_TIMER_SVC_FILE):
|
||||
if f.exists():
|
||||
f.unlink()
|
||||
print(f"Removed: {f}")
|
||||
|
|
@ -2252,12 +2318,12 @@ def apply_nftables(data, dry_run=False):
|
|||
dst = r.get("dst_ip_or_subnet") or r.get("dst_ip", "")
|
||||
try:
|
||||
# Single IP -- check if it's in an active subnet
|
||||
addr = _ipaddress.IPv4Address(dst)
|
||||
addr = ipaddress.IPv4Address(dst)
|
||||
return any(addr in net for net in active_subnets)
|
||||
except ValueError:
|
||||
try:
|
||||
# Subnet -- check if it overlaps with any active subnet
|
||||
net = _ipaddress.IPv4Network(dst, strict=False)
|
||||
net = ipaddress.IPv4Network(dst, strict=False)
|
||||
return any(net.overlaps(s) for s in active_subnets)
|
||||
except ValueError:
|
||||
return True
|
||||
|
|
@ -2636,7 +2702,7 @@ def show_status(data):
|
|||
units.append((vlan_service_name(vlan), "(wg0 not up)", "active"))
|
||||
else:
|
||||
units.append((vlan_service_name(vlan), None, "active"))
|
||||
units.append((f"{TIMER_NAME}.timer", None, "active"))
|
||||
units.append((f"{BLIST_TIMER_NAME}.timer", None, "active"))
|
||||
units.append((NAT_SERVICE_NAME, None, "inactive")) # oneshot - exits after running
|
||||
units.append(("freeradius", None, "active"))
|
||||
units.append(("avahi-daemon", None, "active"))
|
||||
|
|
@ -2652,12 +2718,12 @@ def show_status(data):
|
|||
|
||||
# Timer next trigger
|
||||
r = subprocess.run(
|
||||
["systemctl", "show", f"{TIMER_NAME}.timer", "--property=NextElapseUSecRealtime,NextElapseUSecMonotonic"],
|
||||
["systemctl", "show", f"{BLIST_TIMER_NAME}.timer", "--property=NextElapseUSecRealtime,NextElapseUSecMonotonic"],
|
||||
capture_output=True, text=True
|
||||
)
|
||||
# Fall back to human-readable 'Trigger' field from status output
|
||||
r2 = subprocess.run(
|
||||
["systemctl", "status", f"{TIMER_NAME}.timer", "--no-pager"],
|
||||
["systemctl", "status", f"{BLIST_TIMER_NAME}.timer", "--no-pager"],
|
||||
capture_output=True, text=True
|
||||
)
|
||||
for line in r2.stdout.splitlines():
|
||||
|
|
@ -2983,6 +3049,7 @@ def show_metrics(data):
|
|||
def stop_instances(data):
|
||||
"""Remove timer and stop all per-VLAN instances (config files preserved)."""
|
||||
remove_timer()
|
||||
remove_dashboard_timer()
|
||||
print()
|
||||
for vlan in data["vlans"]:
|
||||
svc = vlan_service_name(vlan)
|
||||
|
|
@ -3193,7 +3260,7 @@ def _dry_run_timer(data):
|
|||
print("-- Timer (dry-run) ---------------------------------------------------")
|
||||
general = data.get("general", {})
|
||||
execute_time = general.get("daily_execute_time_24hr_local", "02:30")
|
||||
for path, label in [(TIMER_FILE, "timer unit"), (TIMER_SVC_FILE, "service unit")]:
|
||||
for path, label in [(BLIST_TIMER_FILE, "timer unit"), (BLIST_TIMER_SVC_FILE, "service unit")]:
|
||||
action = "update" if path.exists() else "create and enable"
|
||||
print(f" Would {action}: {path}")
|
||||
print(f" Schedule: daily at {execute_time} local time (Persistent=true - catches up if missed)")
|
||||
|
|
@ -3213,7 +3280,7 @@ def _dry_run_disable(data, iface, use_dhcp, static_cidr, resolv_ok, dns_choice,
|
|||
print()
|
||||
|
||||
print("-- Stopping router services (dry-run) --------------------------------")
|
||||
print(f" Would disable and stop: {TIMER_NAME}.timer")
|
||||
print(f" Would disable and stop: {BLIST_TIMER_NAME}.timer")
|
||||
for vlan in data["vlans"]:
|
||||
svc = vlan_service_name(vlan)
|
||||
conf = vlan_conf_file(vlan)
|
||||
|
|
@ -3484,6 +3551,13 @@ def cmd_install(data):
|
|||
check_root()
|
||||
check_dependencies()
|
||||
print("All required packages are installed.")
|
||||
install_dashboard_timer()
|
||||
# Create blank dotfiles for dashboard updates
|
||||
for dotfile in (DASHB_QUEUE_FILE, DASHB_DONE_FILE, DASHB_LAST_RUN_FILE, DASHB_LOCK_FILE):
|
||||
if not dotfile.exists():
|
||||
dotfile.touch()
|
||||
chown_to_script_dir_owner(dotfile)
|
||||
print(f"Created: {dotfile}")
|
||||
|
||||
|
||||
def cmd_apply(data, dry_run=False):
|
||||
|
|
@ -3580,6 +3654,10 @@ def cmd_apply(data, dry_run=False):
|
|||
install_timer(data)
|
||||
print()
|
||||
|
||||
print("-- Dashboard timer ---------------------------------------------------")
|
||||
install_dashboard_timer()
|
||||
print()
|
||||
|
||||
print("-- Boot service ------------------------------------------------------")
|
||||
install_nat_service()
|
||||
print()
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue