UI and security improvements
This commit is contained in:
parent
9a272ee959
commit
b8c4914a52
13 changed files with 136 additions and 80 deletions
|
|
@ -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)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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'
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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',
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue