diff --git a/docker/router-dash/Dockerfile b/docker/router-dash/Dockerfile
index 0833fde..27bb3f8 100644
--- a/docker/router-dash/Dockerfile
+++ b/docker/router-dash/Dockerfile
@@ -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"]
diff --git a/docker/router-dash/app/action_apply_banned_ips.py b/docker/router-dash/app/action_apply_banned_ips.py
index 8881ca6..ece76b0 100644
--- a/docker/router-dash/app/action_apply_banned_ips.py
+++ b/docker/router-dash/app/action_apply_banned_ips.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)
diff --git a/docker/router-dash/app/action_apply_blocklists.py b/docker/router-dash/app/action_apply_blocklists.py
index a5a6c85..e4ec91d 100644
--- a/docker/router-dash/app/action_apply_blocklists.py
+++ b/docker/router-dash/app/action_apply_blocklists.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, 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)
diff --git a/docker/router-dash/app/action_apply_dhcp_reservations.py b/docker/router-dash/app/action_apply_dhcp_reservations.py
index eda1f20..6ca485f 100644
--- a/docker/router-dash/app/action_apply_dhcp_reservations.py
+++ b/docker/router-dash/app/action_apply_dhcp_reservations.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
@@ -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)
diff --git a/docker/router-dash/app/action_apply_general.py b/docker/router-dash/app/action_apply_general.py
index 10b2bbc..81b95cf 100644
--- a/docker/router-dash/app/action_apply_general.py
+++ b/docker/router-dash/app/action_apply_general.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
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')
diff --git a/docker/router-dash/app/action_apply_host_overrides.py b/docker/router-dash/app/action_apply_host_overrides.py
index 3401e25..e260e4d 100644
--- a/docker/router-dash/app/action_apply_host_overrides.py
+++ b/docker/router-dash/app/action_apply_host_overrides.py
@@ -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)
diff --git a/docker/router-dash/app/action_apply_iface_config.py b/docker/router-dash/app/action_apply_iface_config.py
new file mode 100644
index 0000000..0fbdae5
--- /dev/null
+++ b/docker/router-dash/app/action_apply_iface_config.py
@@ -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)
diff --git a/docker/router-dash/app/action_apply_inter_vlan.py b/docker/router-dash/app/action_apply_inter_vlan.py
index 6ec4c51..acd054d 100644
--- a/docker/router-dash/app/action_apply_inter_vlan.py
+++ b/docker/router-dash/app/action_apply_inter_vlan.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
@@ -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)
diff --git a/docker/router-dash/app/action_apply_interface.py b/docker/router-dash/app/action_apply_interface.py
index 86560d5..3ba45f1 100644
--- a/docker/router-dash/app/action_apply_interface.py
+++ b/docker/router-dash/app/action_apply_interface.py
@@ -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)
diff --git a/docker/router-dash/app/action_apply_mdns.py b/docker/router-dash/app/action_apply_mdns.py
index cb15f36..4601fa9 100644
--- a/docker/router-dash/app/action_apply_mdns.py
+++ b/docker/router-dash/app/action_apply_mdns.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
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')
diff --git a/docker/router-dash/app/action_apply_port_forwarding.py b/docker/router-dash/app/action_apply_port_forwarding.py
index 178c83f..bac06b3 100644
--- a/docker/router-dash/app/action_apply_port_forwarding.py
+++ b/docker/router-dash/app/action_apply_port_forwarding.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
@@ -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)
diff --git a/docker/router-dash/app/action_apply_upstream_dns.py b/docker/router-dash/app/action_apply_upstream_dns.py
index fa98631..9d2b805 100644
--- a/docker/router-dash/app/action_apply_upstream_dns.py
+++ b/docker/router-dash/app/action_apply_upstream_dns.py
@@ -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]
+ strict_order = 'strict_order' in request.form
+ cache_size_raw = request.form.get('cache_size', '').strip()
+ 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)
@@ -27,13 +38,19 @@ def apply_upstream_dns():
flash('Configuration was modified by another session. Please refresh and try again.', 'error')
return redirect('/view/view_upstream_dns')
- core = load_core()
+ 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,
+ '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')
diff --git a/docker/router-dash/app/action_apply_vlans.py b/docker/router-dash/app/action_apply_vlans.py
index a2245f2..fa6ef90 100644
--- a/docker/router-dash/app/action_apply_vlans.py
+++ b/docker/router-dash/app/action_apply_vlans.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 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)
diff --git a/docker/router-dash/app/action_apply_vpn.py b/docker/router-dash/app/action_apply_vpn.py
index 1f10576..198727b 100644
--- a/docker/router-dash/app/action_apply_vpn.py
+++ b/docker/router-dash/app/action_apply_vpn.py
@@ -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,18 +226,29 @@ 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):
- flash(f'IP address {peer_ip} is already assigned to another peer.', 'error')
- return redirect(_VIEW)
+ 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)
private_key, public_key = _generate_wg_keypair()
peers.append({
@@ -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)
diff --git a/docker/router-dash/app/api_apply_status.py b/docker/router-dash/app/api_apply_status.py
new file mode 100644
index 0000000..dd30668
--- /dev/null
+++ b/docker/router-dash/app/api_apply_status.py
@@ -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()})
diff --git a/docker/router-dash/app/config_utils.py b/docker/router-dash/app/config_utils.py
index b2ce756..a790864 100644
--- a/docker/router-dash/app/config_utils.py
+++ b/docker/router-dash/app/config_utils.py
@@ -1,18 +1,16 @@
-import json, subprocess, hashlib
-from markupsafe import Markup
+import json, subprocess, hashlib, os, uuid
+from datetime import datetime, timezone
+from flask import session
-_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'{_APPLY_CMD}'
- )
-
-CONFIGS_DIR = '/configs'
-CORE_FILE = f'{CONFIGS_DIR}/core.json'
+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 {install_cmd} to enable it, '
+ f'or {cli_cmd} to apply manually.')
+
+
def run_apply():
try:
subprocess.run(
diff --git a/docker/router-dash/app/main.py b/docker/router-dash/app/main.py
index 8ca56a3..9161e73 100644
--- a/docker/router-dash/app/main.py
+++ b/docker/router-dash/app/main.py
@@ -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()
diff --git a/docker/router-dash/app/sanitize.py b/docker/router-dash/app/sanitize.py
index 29bda90..575073e 100644
--- a/docker/router-dash/app/sanitize.py
+++ b/docker/router-dash/app/sanitize.py
@@ -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."""
diff --git a/docker/router-dash/app/view_page.py b/docker/router-dash/app/view_page.py
index 4489fe1..801e45c 100644
--- a/docker/router-dash/app/view_page.py
+++ b/docker/router-dash/app/view_page.py
@@ -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,15 +318,16 @@ 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', []):
- row = dict(peer)
- 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)
+ 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)
return rows
return []
@@ -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'
| Interface | Type | State | ' + f'Carrier | Speed | MTU | MAC |
|---|
| Speed | MTU | MAC |
|---|---|---|
| {_pad_speed(cur_speed)} | ' + f'{" " * max(0, 4 - len(cur_mtu or "-"))}{e(cur_mtu or "-")} | ' + f'{e(cur_mac or "-")} | ' + f'
{hint}
' 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'' for v in items_list ) + validate_attr = f' data-validate="{validate}"' if validate else '' return (f'