UI improvements and input validations

This commit is contained in:
Matthew Grotke 2026-05-20 04:06:50 -04:00
parent b8c4914a52
commit 270856b391
22 changed files with 1548 additions and 302 deletions

View file

@ -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"]

View file

@ -1,6 +1,6 @@
from flask import Blueprint, request, redirect, flash
from auth import require_level
from config_utils import load_core, save_core, verify_core_hash, 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)

View file

@ -1,6 +1,6 @@
from flask import Blueprint, request, redirect, flash
from auth import require_level
from config_utils import load_core, save_core, verify_core_hash, 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)

View file

@ -1,6 +1,6 @@
from flask import Blueprint, request, redirect, flash
from auth import require_level
from config_utils import load_core, save_core, verify_core_hash, 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)

View file

@ -1,6 +1,6 @@
from flask import Blueprint, request, redirect, flash
from auth import require_level
from config_utils import load_core, save_core, verify_core_hash, 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')

View file

@ -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)

View 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)

View file

@ -1,6 +1,6 @@
from flask import Blueprint, request, redirect, flash
from auth import require_level
from config_utils import load_core, save_core, verify_core_hash, 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)

View file

@ -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)

View file

@ -1,6 +1,6 @@
from flask import Blueprint, request, redirect, flash
from auth import require_level
from config_utils import load_core, save_core, verify_core_hash, 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')

View file

@ -1,6 +1,6 @@
from flask import Blueprint, request, redirect, flash
from auth import require_level
from config_utils import load_core, save_core, verify_core_hash, 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)

View file

@ -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')

View file

@ -1,6 +1,6 @@
from flask import Blueprint, request, redirect, flash
from auth import require_level
from config_utils import load_core, save_core, verify_core_hash, 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 (14094) 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 (14094) 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 (14094) 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)

View file

@ -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)

View 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()})

View file

@ -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'<code><strong>{_APPLY_CMD}</strong></code>'
)
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 <strong>{install_cmd}</strong> to enable it, '
f'or <strong>{cli_cmd}</strong> to apply manually.')
def run_apply():
try:
subprocess.run(

View file

@ -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()

View file

@ -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."""

File diff suppressed because it is too large Load diff

View file

@ -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": [
"type": "form",
"action": "/action/apply_interface",
"method": "post",
"items": [
{
"label": "Type",
"field": "iface_type",
"class": "col-mono"
"type": "field",
"label": "WAN Interface",
"name": "wan_interface",
"input_type": "interface_picker",
"value": "%GENERAL_WAN_INTERFACE%",
"data": "%NETWORK_INTERFACE_DATA_JSON%"
},
{
"label": "Interface",
"field": "interface",
"class": "col-mono"
"type": "field",
"label": "LAN Interface",
"name": "lan_interface",
"input_type": "interface_picker",
"value": "%GENERAL_LAN_INTERFACE%",
"data": "%NETWORK_INTERFACE_DATA_JSON%"
},
{
"label": "Status",
"field": "status",
"render": "interface_status"
}
],
"row_actions": [
{
"text": "Edit",
"class": "btn-ghost btn-sm",
"action": "/action/apply_interface",
"method": "inline_edit",
"client_requirement": "client_is_administrator+",
"fields": [
"type": "button_row",
"items": [
{
"col": "interface",
"input_type": "select",
"options": "%NETWORK_INTERFACE_STATUS_OPTIONS%"
"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."

View file

@ -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