UI and security improvements

This commit is contained in:
Matthew Grotke 2026-05-18 20:02:22 -04:00
parent 9a272ee959
commit b8c4914a52
13 changed files with 136 additions and 80 deletions

View file

@ -34,8 +34,8 @@ def _save_as_from_name(name):
def _parse_fields(): def _parse_fields():
"""Parse and validate add/edit form fields. Returns (fields_dict, None) or (None, already_flashed).""" """Parse and validate add/edit form fields. Returns (fields_dict, None) or (None, already_flashed)."""
name = sanitize.name(request.form.get('name', '')) name = sanitize.name(request.form.get('name', ''))
description = sanitize.text(request.form.get('description', '')) description = sanitize.description(request.form.get('description', ''))
fmt = request.form.get('format', '').strip() fmt = sanitize.filtervalue(request.form.get('format', ''), validate.VALID_BLOCKLIST_FORMATS)
url = sanitize.url(request.form.get('url', '')) url = sanitize.url(request.form.get('url', ''))
if not name: if not name:
@ -44,8 +44,8 @@ def _parse_fields():
if not url: if not url:
flash('The configuration has not been saved because a URL is required.', 'error') flash('The configuration has not been saved because a URL is required.', 'error')
return None, True return None, True
if fmt not in validate.VALID_BLOCKLIST_FORMATS: if not fmt:
flash(f'The configuration has not been saved because "{fmt}" is not a valid format. ' flash(f'The configuration has not been saved because the format is invalid. '
f'Accepted formats: {_VALID_FORMATS_STR}.', 'error') f'Accepted formats: {_VALID_FORMATS_STR}.', 'error')
return None, True return None, True
@ -75,7 +75,6 @@ def add_blocklist():
'format': fields['format'], 'format': fields['format'],
'url': fields['url'], 'url': fields['url'],
'save_as': _save_as_from_name(fields['name']), 'save_as': _save_as_from_name(fields['name']),
'enabled': True,
}) })
save_core(core) save_core(core)
@ -83,29 +82,6 @@ def add_blocklist():
return redirect(VIEW) return redirect(VIEW)
@bp.route('/action/toggle_blocklist', methods=['POST'])
@require_level('administrator')
def toggle_blocklist():
idx = _row_index()
if idx is None:
flash('Invalid request.', 'error')
return redirect(VIEW)
if not _hash_ok():
return redirect(VIEW)
core = load_core()
items = core.get('blocklists', [])
if idx < 0 or idx >= len(items):
flash('Entry not found.', 'error')
return redirect(VIEW)
items[idx]['enabled'] = not items[idx].get('enabled', True)
save_core(core)
flash(apply_msg(), 'success')
return redirect(VIEW)
@bp.route('/action/edit_blocklist', methods=['POST']) @bp.route('/action/edit_blocklist', methods=['POST'])
@require_level('administrator') @require_level('administrator')
@ -128,13 +104,11 @@ def edit_blocklist():
flash('Entry not found.', 'error') flash('Entry not found.', 'error')
return redirect(VIEW) return redirect(VIEW)
enabled = request.form.get('enabled') == 'on'
items[idx].update({ items[idx].update({
'name': fields['name'], 'name': fields['name'],
'description': fields['description'], 'description': fields['description'],
'format': fields['format'], 'format': fields['format'],
'url': fields['url'], 'url': fields['url'],
'enabled': enabled,
}) })
save_core(core) save_core(core)

View file

@ -1,6 +1,8 @@
from flask import Blueprint, request, redirect, flash from flask import Blueprint, request, redirect, flash
from auth import require_level from auth import require_level
import json import json
import sanitize
import validate
bp = Blueprint('action_apply_ddns_providers', __name__) bp = Blueprint('action_apply_ddns_providers', __name__)
@ -10,10 +12,9 @@ DDNS_FILE = '/configs/ddns.json'
@bp.route('/action/add_ddns_provider', methods=['POST']) @bp.route('/action/add_ddns_provider', methods=['POST'])
@require_level('administrator') @require_level('administrator')
def add_ddns_provider(): def add_ddns_provider():
provider_type = request.form.get('provider', '').strip().lower() provider_type = sanitize.filtervalue(request.form.get('provider', ''), validate.VALID_DDNS_PROVIDERS)
description = request.form.get('description', '').strip() description = sanitize.description(request.form.get('description', ''))
hostnames_raw = request.form.get('hostnames', '') hostnames = sanitize.domainlist(request.form.get('hostnames', '').splitlines())
hostnames = [h.strip() for h in hostnames_raw.splitlines() if h.strip()]
if not description: if not description:
flash('Description is required.', 'error') flash('Description is required.', 'error')
@ -21,7 +22,7 @@ def add_ddns_provider():
if not hostnames: if not hostnames:
flash('At least one hostname is required.', 'error') flash('At least one hostname is required.', 'error')
return redirect('/view/view_ddns') return redirect('/view/view_ddns')
if provider_type not in ('noip', 'cloudflare', 'duckdns'): if not provider_type:
flash('Unknown provider type.', 'error') flash('Unknown provider type.', 'error')
return redirect('/view/view_ddns') return redirect('/view/view_ddns')
@ -64,12 +65,16 @@ def edit_ddns_provider():
flash('Invalid row index.', 'error') flash('Invalid row index.', 'error')
return redirect('/view/view_ddns') return redirect('/view/view_ddns')
provider_type = request.form.get('provider', '').strip().lower() provider_type = sanitize.filtervalue(request.form.get('provider', ''), validate.VALID_DDNS_PROVIDERS)
description = request.form.get('description', '').strip() description = sanitize.description(request.form.get('description', ''))
hostnames_raw = request.form.get('hostnames', '') hostnames_raw = request.form.get('hostnames', '')
enabled = request.form.get('enabled') == 'on' enabled = request.form.get('enabled') == 'on'
hostnames = [h.strip() for h in hostnames_raw.splitlines() if h.strip()] hostnames = [h.strip() for h in hostnames_raw.splitlines() if h.strip()]
if not provider_type:
flash('Unknown provider type.', 'error')
return redirect('/view/view_ddns')
try: try:
with open(DDNS_FILE) as f: with open(DDNS_FILE) as f:
data = json.load(f) data = json.load(f)

View file

@ -51,7 +51,7 @@ def _parse_ip():
def add_dhcp_reservation(): def add_dhcp_reservation():
vlan_name = sanitize.name(request.form.get('vlan_name', '')) vlan_name = sanitize.name(request.form.get('vlan_name', ''))
description = sanitize.text(request.form.get('description', '')) description = sanitize.text(request.form.get('description', ''))
hostname = sanitize.hostname(request.form.get('hostname', '')) hostname = sanitize.domainname(request.form.get('hostname', ''))
mac = sanitize.mac(request.form.get('mac', '')) mac = sanitize.mac(request.form.get('mac', ''))
ip = _parse_ip() ip = _parse_ip()
radius_client = 'radius_client' in request.form radius_client = 'radius_client' in request.form
@ -125,7 +125,7 @@ def edit_dhcp_reservation():
return redirect(VIEW) return redirect(VIEW)
description = sanitize.text(request.form.get('description', '')) description = sanitize.text(request.form.get('description', ''))
hostname = sanitize.hostname(request.form.get('hostname', '')) hostname = sanitize.domainname(request.form.get('hostname', ''))
mac = sanitize.mac(request.form.get('mac', '')) mac = sanitize.mac(request.form.get('mac', ''))
ip = _parse_ip() ip = _parse_ip()
radius_client = 'radius_client' in request.form radius_client = 'radius_client' in request.form

View file

@ -51,7 +51,7 @@ def _hash_ok():
@require_level('administrator') @require_level('administrator')
def add_host_override(): def add_host_override():
description = sanitize.text(request.form.get('description', '')) description = sanitize.text(request.form.get('description', ''))
host = sanitize.hostname(request.form.get('host', '')) host = sanitize.domainname(request.form.get('host', ''))
ip = sanitize.ip(request.form.get('ip', '')) ip = sanitize.ip(request.form.get('ip', ''))
if not host or not ip: if not host or not ip:
@ -111,7 +111,7 @@ def edit_host_override():
return redirect(VIEW) return redirect(VIEW)
description = sanitize.text(request.form.get('description', '')) description = sanitize.text(request.form.get('description', ''))
host = sanitize.hostname(request.form.get('host', '')) host = sanitize.domainname(request.form.get('host', ''))
ip = sanitize.ip(request.form.get('ip', '')) ip = sanitize.ip(request.form.get('ip', ''))
enabled = request.form.get('enabled') == 'on' enabled = request.form.get('enabled') == 'on'

View file

@ -28,13 +28,13 @@ def _hash_ok():
def _parse_entry(): def _parse_entry():
"""Parse and validate form fields. Returns (entry_dict, None) or (None, already_flashed).""" """Parse and validate form fields. Returns (entry_dict, None) or (None, already_flashed)."""
description = sanitize.text(request.form.get('description', '')) description = sanitize.text(request.form.get('description', ''))
protocol = request.form.get('protocol', '').strip() protocol = sanitize.filtervalue(request.form.get('protocol', ''), validate.VALID_PROTOCOLS)
src_raw = request.form.get('src_ip_or_subnet', '').strip() src_raw = request.form.get('src_ip_or_subnet', '').strip()
dst_raw = request.form.get('dst_ip_or_subnet', '').strip() dst_raw = request.form.get('dst_ip_or_subnet', '').strip()
dst_port_raw = request.form.get('dst_port', '').strip() dst_port_raw = request.form.get('dst_port', '').strip()
if protocol not in validate.VALID_PROTOCOLS: if not protocol:
flash(f'The configuration has not been saved because "{protocol}" is not a valid protocol. ' flash(f'The configuration has not been saved because the protocol is invalid. '
f'Accepted values: {_VALID_PROTOS_STR}.', 'error') f'Accepted values: {_VALID_PROTOS_STR}.', 'error')
return None, True return None, True

View file

@ -11,13 +11,14 @@ bp = Blueprint('action_apply_mdns', __name__)
@require_level('administrator') @require_level('administrator')
def apply_mdns(): def apply_mdns():
mdns_enabled = 'mdns_enabled' in request.form mdns_enabled = 'mdns_enabled' in request.form
mdns_reflect_vlans = [sanitize.name(v) for v in request.form.getlist('mdns_reflect_vlans') if v.strip()]
if not verify_core_hash(request.form.get('config_hash', '')): if not verify_core_hash(request.form.get('config_hash', '')):
flash('Configuration was modified by another session. Please refresh and try again.', 'error') flash('Configuration was modified by another session. Please refresh and try again.', 'error')
return redirect('/view/view_mdns') return redirect('/view/view_mdns')
core = load_core() core = load_core()
mdns_reflect_vlans = sanitize.filterlist(request.form.getlist('mdns_reflect_vlans'),
{v.get('name') for v in core.get('vlans', [])})
core.setdefault('mdns_reflection', {}).update({ core.setdefault('mdns_reflection', {}).update({
'enabled': mdns_enabled, 'enabled': mdns_enabled,
'reflect_vlans': mdns_reflect_vlans, 'reflect_vlans': mdns_reflect_vlans,

View file

@ -28,13 +28,13 @@ def _hash_ok():
def _parse_entry(): def _parse_entry():
"""Parse and validate form fields. Returns (entry_dict, None) or (None, already_flashed).""" """Parse and validate form fields. Returns (entry_dict, None) or (None, already_flashed)."""
description = sanitize.text(request.form.get('description', '')) description = sanitize.text(request.form.get('description', ''))
protocol = request.form.get('protocol', '').strip() protocol = sanitize.filtervalue(request.form.get('protocol', ''), validate.VALID_PROTOCOLS)
dest_port_raw = request.form.get('dest_port', '').strip() dest_port_raw = request.form.get('dest_port', '').strip()
nat_ip_raw = request.form.get('nat_ip', '').strip() nat_ip_raw = request.form.get('nat_ip', '').strip()
nat_port_raw = request.form.get('nat_port', '').strip() nat_port_raw = request.form.get('nat_port', '').strip()
if protocol not in validate.VALID_PROTOCOLS: if not protocol:
flash(f'The configuration has not been saved because "{protocol}" is not a valid protocol. ' flash(f'The configuration has not been saved because the protocol is invalid. '
f'Accepted values: {_VALID_PROTOS_STR}.', 'error') f'Accepted values: {_VALID_PROTOS_STR}.', 'error')
return None, True return None, True

View file

@ -41,12 +41,14 @@ def _derive_vlan_id(subnet, prefix):
@bp.route('/action/add_vlan', methods=['POST']) @bp.route('/action/add_vlan', methods=['POST'])
@require_level('administrator') @require_level('administrator')
def add_vlan(): def add_vlan():
name = sanitize.name(request.form.get('name', '')).lower() name = sanitize.name(request.form.get('name', ''))
is_vpn = 'is_vpn' in request.form is_vpn = 'is_vpn' in request.form
subnet = sanitize.ip(request.form.get('subnet', '')) subnet = sanitize.ip(request.form.get('subnet', ''))
subnet_mask = sanitize.subnet_mask(request.form.get('subnet_mask', '')) subnet_mask = sanitize.subnet_mask(request.form.get('subnet_mask', ''))
radius_default = 'radius_default' in request.form radius_default = 'radius_default' in request.form
mdns_reflection = 'mdns_reflection' in request.form mdns_reflection = 'mdns_reflection' in request.form
use_blocklists = sanitize.filterlist(request.form.getlist('use_blocklists'),
{b.get('name') for b in load_core().get('blocklists', [])})
if not name: if not name:
flash('Name is required.', 'error') flash('Name is required.', 'error')
@ -81,7 +83,7 @@ def add_vlan():
'is_vpn': is_vpn, 'is_vpn': is_vpn,
'subnet': subnet, 'subnet': subnet,
'subnet_mask': subnet_mask, 'subnet_mask': subnet_mask,
'use_blocklists': [], 'use_blocklists': use_blocklists,
'radius_default': radius_default, 'radius_default': radius_default,
'mdns_reflection': mdns_reflection, 'mdns_reflection': mdns_reflection,
} }
@ -104,11 +106,12 @@ def edit_vlan():
flash('Invalid request.', 'error') flash('Invalid request.', 'error')
return redirect(VIEW) return redirect(VIEW)
name = sanitize.name(request.form.get('name', '')).lower() name = sanitize.name(request.form.get('name', ''))
subnet = sanitize.ip(request.form.get('subnet', '')) subnet = sanitize.ip(request.form.get('subnet', ''))
radius_default = 'radius_default' in request.form radius_default = 'radius_default' in request.form
mdns_reflection = 'mdns_reflection' in request.form mdns_reflection = 'mdns_reflection' in request.form
use_blocklists = request.form.getlist('use_blocklists') use_blocklists = sanitize.filterlist(request.form.getlist('use_blocklists'),
{b.get('name') for b in load_core().get('blocklists', [])})
# subnet_mask is only present when the column is visible (not all edit paths send it). # subnet_mask is only present when the column is visible (not all edit paths send it).
# Validate if submitted; fall back to the stored value otherwise. # Validate if submitted; fall back to the stored value otherwise.

View file

@ -122,8 +122,8 @@ def _conf_response(vlan, peer_name, peer_ip, private_key):
@require_level('administrator') @require_level('administrator')
def apply_vpn(): def apply_vpn():
listen_port_raw = request.form.get('vpn_listen_port', '').strip() listen_port_raw = request.form.get('vpn_listen_port', '').strip()
server_endpoint = sanitize.hostname(request.form.get('vpn_server_endpoint', '')) server_endpoint = sanitize.domainname(request.form.get('vpn_server_endpoint', ''))
domain = sanitize.hostname(request.form.get('vpn_domain', '')) domain = sanitize.domainname(request.form.get('vpn_domain', ''))
dns_raw = request.form.get('vpn_dns_server', '').strip() dns_raw = request.form.get('vpn_dns_server', '').strip()
mtu_raw = request.form.get('vpn_mtu', '').strip() mtu_raw = request.form.get('vpn_mtu', '').strip()

View file

@ -105,14 +105,33 @@ def text(value, max_len=200):
"""General description: letters, digits, spaces, basic punctuation. No quotes/braces/brackets/slashes.""" """General description: letters, digits, spaces, basic punctuation. No quotes/braces/brackets/slashes."""
return _strip(value, r'''["'{}\[\]\\/<>;`^~]''', max_len) return _strip(value, r'''["'{}\[\]\\/<>;`^~]''', max_len)
def name(value, max_len=64): def description(value, max_len=200):
"""Label/name: letters, digits, spaces, hyphens, underscores, dots.""" """Human-readable description: letters, digits, hyphens, parentheses, commas, forward slashes, spaces.
return _strip(value, r'[^A-Za-z0-9 \-_.]', max_len) Whitespace collapsed; no sequential commas or slashes."""
s = re.sub(r'[^A-Za-z0-9\-()/,\s]', '', str(value))
s = re.sub(r'\s+', ' ', s)
s = re.sub(r',{2,}', ',', s)
s = re.sub(r'/{2,}', '/', s)
s = re.sub(r'-{2,}', '-', s)
s = re.sub(r'\({2,}', '(', s)
s = re.sub(r'\){2,}', ')', s)
return s.strip()[:max_len]
def hostname(value, max_len=253): def name(value, max_len=40):
"""Identifier: lowercase letters, digits, hyphens only. No sequential hyphens."""
s = re.sub(r'[\s_]+', '-', str(value).strip().lower())
s = re.sub(r'[^a-z0-9-]', '', s)[:max_len]
s = re.sub(r'-{2,}', '-', s)
return s.strip('-')
def domainname(value, max_len=253):
"""Hostname or domain: letters, digits, hyphens, dots. Lowercased.""" """Hostname or domain: letters, digits, hyphens, dots. Lowercased."""
return _strip(value.lower(), r'[^a-z0-9\-.]', max_len) return _strip(value.lower(), r'[^a-z0-9\-.]', max_len)
def domainlist(lines):
"""Sanitize a list of domain name strings, returning only non-empty results."""
return [h for v in lines if (h := domainname(v))]
def ip(value, max_len=45): def ip(value, max_len=45):
"""IPv4 or IPv6 address. Returns '' if not a valid address.""" """IPv4 or IPv6 address. Returns '' if not a valid address."""
cleaned = _strip(value, r'[^0-9a-fA-F.:]', max_len) cleaned = _strip(value, r'[^0-9a-fA-F.:]', max_len)
@ -169,6 +188,16 @@ def timezone(value):
"""Timezone string: must be in VALID_TIMEZONES list. Returns '' if not found.""" """Timezone string: must be in VALID_TIMEZONES list. Returns '' if not found."""
return value if value in _TIMEZONE_SET else '' return value if value in _TIMEZONE_SET else ''
def filterlist(submitted, allowed):
"""Filter a list of submitted values to those present in the allowed set, after sanitizing each."""
allowed = set(allowed)
return [n for v in submitted if (n := name(v)) in allowed]
def filtervalue(value, allowed):
"""Return the sanitized value if it exists in the allowed set, otherwise ''."""
n = name(value)
return n if n in set(allowed) else ''
_DOTTED_TO_PREFIX = { _DOTTED_TO_PREFIX = {
'255.0.0.0': 8, '255.255.0.0': 16, '255.255.255.0': 24, '255.0.0.0': 8, '255.255.0.0': 16, '255.255.255.0': 24,
'255.255.255.128': 25, '255.255.255.192': 26, '255.255.255.128': 25, '255.255.255.192': 26,

View file

@ -15,9 +15,12 @@ from validation import (
banned_ip, banned_ip,
) )
VALID_DDNS_PROVIDERS = ('noip', 'cloudflare', 'duckdns')
__all__ = [ __all__ = [
'VALID_PROTOCOLS', 'VALID_PROTOCOLS',
'VALID_BLOCKLIST_FORMATS', 'VALID_BLOCKLIST_FORMATS',
'VALID_DDNS_PROVIDERS',
'ip', 'ip',
'ip_or_cidr', 'ip_or_cidr',
'port', 'port',

View file

@ -2,6 +2,7 @@ from flask import Blueprint, session, redirect, get_flashed_messages
from markupsafe import Markup from markupsafe import Markup
import json, re, subprocess, os, sys, html as html_mod import json, re, subprocess, os, sys, html as html_mod
import sanitize import sanitize
import validate
from datetime import datetime, timezone from datetime import datetime, timezone
from config_utils import core_hash from config_utils import core_hash
@ -211,12 +212,14 @@ def _config_datasource(name):
return rows return rows
if name == 'vlans': if name == 'vlans':
core = _load_core() bl_desc = {b['name']: b.get('description', b['name']) for b in core.get('blocklists', []) if 'name' in b}
rows = [] rows = []
for v in sorted(vlans, key=lambda x: x.get('vlan_id', 0)): for v in sorted(vlans, key=lambda x: x.get('vlan_id', 0)):
row = {k: v.get(k) for k in ('vlan_id', 'name', 'subnet', 'subnet_mask', 'radius_default', 'mdns_reflection', 'is_vpn')} row = {k: v.get(k) for k in ('vlan_id', 'name', 'subnet', 'subnet_mask', 'radius_default', 'mdns_reflection', 'is_vpn')}
row['interface'] = _resolve_iface(v, core) row['interface'] = _resolve_iface(v, core)
row['use_blocklists'] = json.dumps(v.get('use_blocklists', [])) row['use_blocklists'] = json.dumps([
{'n': bl, 'd': bl_desc.get(bl, bl)} for bl in v.get('use_blocklists', [])
])
rows.append(row) rows.append(row)
return rows return rows
@ -475,16 +478,16 @@ def collect_tokens():
tokens['EXISTING_VLAN_NAMES_JSON'] = json.dumps([v.get('name', '') for v in vlans]) tokens['EXISTING_VLAN_NAMES_JSON'] = json.dumps([v.get('name', '') for v in vlans])
tokens['EXISTING_VLAN_INTERFACES_JSON'] = json.dumps([_resolve_iface(v, core) for v in vlans]) tokens['EXISTING_VLAN_INTERFACES_JSON'] = json.dumps([_resolve_iface(v, core) for v in vlans])
tokens['STAT_BANNED_IP_COUNT'] = str(sum(1 for b in core.get('banned_ips', []) if b.get('enabled', True))) tokens['STAT_BANNED_IP_COUNT'] = str(sum(1 for b in core.get('banned_ips', []) if b.get('enabled', True)))
tokens['STAT_BLOCKLIST_COUNT'] = str(sum(1 for b in core.get('blocklists', []) if b.get('enabled', True))) tokens['STAT_BLOCKLIST_COUNT'] = str(len(core.get('blocklists', [])))
ddns = _load_ddns() ddns = _load_ddns()
tokens['DDNS_TIMER_INTERVAL'] = ddns.get('general', {}).get('timer_interval', '-') tokens['DDNS_TIMER_INTERVAL'] = ddns.get('general', {}).get('timer_interval', '-')
enabled_p = [p for p in ddns.get('providers', []) if p.get('enabled', True)] enabled_p = [p for p in ddns.get('providers', []) if p.get('enabled', True)]
tokens['STAT_DDNS_PROVIDER_COUNT'] = str(len(enabled_p)) tokens['STAT_DDNS_PROVIDER_COUNT'] = str(len(enabled_p))
_ddns_labels = {'noip': 'No-IP', 'cloudflare': 'Cloudflare', 'duckdns': 'DuckDNS'}
tokens['DDNS_PROVIDER_OPTIONS'] = json.dumps([ tokens['DDNS_PROVIDER_OPTIONS'] = json.dumps([
{'value': 'noip', 'label': 'No-IP'}, {'value': p, 'label': _ddns_labels.get(p, p.title())}
{'value': 'cloudflare', 'label': 'Cloudflare'}, for p in validate.VALID_DDNS_PROVIDERS
{'value': 'duckdns', 'label': 'DuckDNS'},
]) ])
wg_vlan = next((v for v in vlans if v.get('is_vpn')), {}) wg_vlan = next((v for v in vlans if v.get('is_vpn')), {})
@ -1027,7 +1030,21 @@ def _render_table_cell(value, render_fn, col_class='', field='', row_idx=None,
items = json.loads(value) if value.startswith('[') else [s.strip() for s in value.split(',')] items = json.loads(value) if value.startswith('[') else [s.strip() for s in value.split(',')]
except Exception: except Exception:
items = [value] items = [value]
tags = ''.join(f'<span class="tag">{e(str(t))}</span>' for t in items if str(t).strip()) def _tag(t):
if isinstance(t, dict):
s, tooltip = str(t.get('n', '')).strip(), str(t.get('d', t.get('n', ''))).strip()
else:
s = tooltip = str(t).strip()
if not s:
return ''
short = s.split('-')[0]
mini = s[0]
return (f'<span class="tag" data-tooltip="{e(tooltip)}">'
f'<span class="tl-full">{e(s)}</span>'
f'<span class="tl-short">{e(short)}</span>'
f'<span class="tl-min">{e(mini)}</span>'
f'</span>')
tags = ''.join(_tag(t) for t in items)
return f'{td_open}<div class="tag-list">{tags}</div></td>' return f'{td_open}<div class="tag-list">{tags}</div></td>'
if render_fn == 'interface_status': if render_fn == 'interface_status':
@ -1160,6 +1177,35 @@ function deriveVlanId(subnet, prefix) {
return (id >= 0 && id <= 4094) ? id : null; return (id >= 0 && id <= 4094) ? id : null;
} }
function networkBitsMessage(octets, prefix) {
var byteIdx = Math.floor((prefix - 1) / 8);
var hostBitsInActive = (prefix % 8 === 0) ? 0 : (8 - (prefix % 8));
var activeMask = hostBitsInActive === 0 ? 0xFF : ((0xFF << hostBitsInActive) & 0xFF);
var ordinals = ['1st', '2nd', '3rd', '4th'];
var parts = [];
if (hostBitsInActive > 0 && (octets[byteIdx] & ~activeMask) !== 0) {
var step = 1 << hostBitsInActive;
var vals = [];
for (var v = 0; v < 256; v += step) vals.push(String(v));
var valStr = vals.length <= 8
? vals.slice(0, -1).join(', ') + ' or ' + vals[vals.length - 1]
: 'a multiple of ' + step;
parts.push(ordinals[byteIdx] + ' quartet must be ' + valStr);
}
var badTrailing = [];
for (var i = byteIdx + 1; i < 4; i++) {
if (octets[i] !== 0) badTrailing.push(ordinals[i]);
}
if (badTrailing.length > 0) {
var nameStr = badTrailing.length === 1
? badTrailing[0]
: badTrailing.slice(0, -1).join(', ') + ' and ' + badTrailing[badTrailing.length - 1];
parts.push(nameStr + ' quartet' + (badTrailing.length > 1 ? 's' : '') + ' must be 0');
}
if (parts.length === 0) return null;
return parts.join('; ') + ' for /' + prefix;
}
function classifySubnet(s) { function classifySubnet(s) {
if (!s) return 'empty'; if (!s) return 'empty';
if (/[^0-9.]/.test(s)) return 'invalid_char'; if (/[^0-9.]/.test(s)) return 'invalid_char';
@ -1230,8 +1276,12 @@ function updateAddVlanForm(form) {
} else if (sClass === 'range') { } else if (sClass === 'range') {
subnetMsg = 'Quartet out of range'; subnetState = 'error'; subnetMsg = 'Quartet out of range'; subnetState = 'error';
} else { } else {
if (id === 0) { var octetsArr = subnet.split('.').map(Number);
subnetMsg = 'Reserved'; subnetState = 'warning'; var hostMsg = networkBitsMessage(octetsArr, prefix);
if (hostMsg) {
subnetMsg = hostMsg; subnetState = 'error';
} else if (id === 0) {
subnetMsg = 'Would compute to VLAN ID 0 (reserved)'; subnetState = 'error';
} else if (id === null || EXISTING_VLAN_IDS.indexOf(id) !== -1) { } else if (id === null || EXISTING_VLAN_IDS.indexOf(id) !== -1) {
subnetMsg = id === null ? '' : 'Duplicate'; subnetState = id === null ? 'warning' : 'error'; subnetMsg = id === null ? '' : 'Duplicate'; subnetState = id === null ? 'warning' : 'error';
} else { } else {
@ -1242,7 +1292,7 @@ function updateAddVlanForm(form) {
// Interface duplicate/reserved sub-text // Interface duplicate/reserved sub-text
if (ifacePrev) { if (ifacePrev) {
if (id === 0) { if (id === 0 && !isVpn) {
setFieldHint(ifacePrev, 'Reserved', 'error'); setFieldHint(ifacePrev, 'Reserved', 'error');
} else { } else {
var ifaceDupe = ifaceVal.length > 0 && EXISTING_VLAN_INTERFACES.indexOf(ifaceVal) !== -1; var ifaceDupe = ifaceVal.length > 0 && EXISTING_VLAN_INTERFACES.indexOf(ifaceVal) !== -1;

View file

@ -954,11 +954,6 @@
"label": "Source URL", "label": "Source URL",
"field": "url", "field": "url",
"class": "col-mono" "class": "col-mono"
},
{
"label": "Status",
"field": "enabled",
"render": "badge_enabled_disabled"
} }
], ],
"toolbar": { "toolbar": {
@ -996,10 +991,6 @@
{ {
"col": "url", "col": "url",
"input_type": "text" "input_type": "text"
},
{
"col": "enabled",
"input_type": "checkbox"
} }
] ]
}, },
@ -1028,14 +1019,14 @@
"label": "Name", "label": "Name",
"name": "name", "name": "name",
"input_type": "text", "input_type": "text",
"placeholder": "e.g. StevenBlack" "placeholder": "e.g. steven-black"
}, },
{ {
"type": "field", "type": "field",
"label": "Description", "label": "Description",
"name": "description", "name": "description",
"input_type": "text", "input_type": "text",
"placeholder": "e.g. Unified ad/malware hosts" "placeholder": "e.g. Steven Black (ads, malware, trackers)"
}, },
{ {
"type": "field", "type": "field",
@ -1088,7 +1079,7 @@
{ {
"type": "info_bar", "type": "info_bar",
"variant": "info", "variant": "info",
"text": "VLAN ID is derived automatically from the subnet and prefix using the active-octet rule: for /24 the third octet is used (192.168.10.0/24 → VLAN 10), for /16 the second octet, for /8 the first octet, for /25/30 the fourth. For a basic flat network with no VLAN segmentation, only use VLAN 1 and delete the others." "text": "For a basic flat network with no VLAN segmentation, only use VLAN 1 and delete the others."
}, },
{ {
"type": "table", "type": "table",