diff --git a/docker/routlin-dash/app/factory.py b/docker/routlin-dash/app/factory.py index eeddc26..0842725 100644 --- a/docker/routlin-dash/app/factory.py +++ b/docker/routlin-dash/app/factory.py @@ -17,41 +17,21 @@ LEVEL_RANK = {'nothing': 0, 'viewer': 1, 'administrator': 2, 'manager': 3} STANDARD_INPUT_TYPES = {'text', 'password', 'number', 'checkbox', 'select', 'textarea'} -_VALIDATION_FLAGS = { - 'VALIDATION_IPV4_FORMAT': 1, - 'VALIDATION_IPV6_FORMAT': 2, - 'VALIDATION_SUBNET': 4, - 'VALIDATION_ADDRESS': 8, - 'VALIDATION_MAC': 16, - 'VALIDATION_URL': 32, - 'VALIDATION_PORT': 64, - 'VALIDATION_DASH_NAME': 128, - 'VALIDATION_NETWORK_NAME': 256, - 'VALIDATION_DOMAIN_NAME': 512, - 'VALIDATION_TIME24H': 1024, - 'VALIDATION_RANGE_INT': 2048, - 'VALIDATION_ENDPOINT': 4096, - 'VALIDATION_IPV4_CIDR': 8192, -} - -_COMPAT_VALIDATION = { - 'ipv4': 'VALIDATION_IPV4_FORMAT', - 'ipv6': 'VALIDATION_IPV6_FORMAT', - 'ip': 'VALIDATION_IPV4_FORMAT|VALIDATION_IPV6_FORMAT', - 'ipv4cidr': 'VALIDATION_IPV4_CIDR', - 'mac': 'VALIDATION_MAC', - 'url': 'VALIDATION_URL', - 'port': 'VALIDATION_PORT', - 'dashname': 'VALIDATION_DASH_NAME', - 'networkname': 'VALIDATION_NETWORK_NAME', - 'domainname': 'VALIDATION_DOMAIN_NAME', - 'time_24h': 'VALIDATION_TIME24H', - 'vlan_id': 'VALIDATION_RANGE_INT', - 'positive_int': 'VALIDATION_RANGE_INT', - 'endpoint': 'VALIDATION_ENDPOINT', - 'ip_in_subnet': 'VALIDATION_ADDRESS', - 'address': 'VALIDATION_ADDRESS', - 'subnet': 'VALIDATION_SUBNET', +VALIDATION_FLAGS = { + 'VALIDATION_IPV4_FORMAT': 1 << 0, + 'VALIDATION_IPV6_FORMAT': 1 << 1, + 'VALIDATION_SUBNET': 1 << 2, + 'VALIDATION_ADDRESS': 1 << 3, + 'VALIDATION_MAC': 1 << 4, + 'VALIDATION_URL': 1 << 5, + 'VALIDATION_PORT': 1 << 6, + 'VALIDATION_DASH_NAME': 1 << 7, + 'VALIDATION_NETWORK_NAME': 1 << 8, + 'VALIDATION_DOMAIN_NAME': 1 << 9, + 'VALIDATION_TIME24H': 1 << 10, + 'VALIDATION_RANGE_INT': 1 << 11, + 'VALIDATION_ENDPOINT': 1 << 12, + 'VALIDATION_IPV4_CIDR': 1 << 13, } # Utilities =========================================================== @@ -102,11 +82,10 @@ def js_str(value): def parse_validation(s): if not s: return 0 - resolved = _COMPAT_VALIDATION.get(s, s) result = 0 - for token in resolved.split('|'): + for token in s.split('|'): token = token.strip() - val = _VALIDATION_FLAGS.get(token) + val = VALIDATION_FLAGS.get(token) if val is None: print(f'[factory] WARNING: unknown validation token "{token}" in "{s}"', file=sys.stderr) continue @@ -128,26 +107,6 @@ def _encode_field_validations(fields): def build_big_validate(): - _JS_NAMES = { - 'VALIDATION_IPV4_FORMAT': 'F_IPV4', - 'VALIDATION_IPV6_FORMAT': 'F_IPV6', - 'VALIDATION_SUBNET': 'F_SUBNET', - 'VALIDATION_ADDRESS': 'F_ADDR', - 'VALIDATION_MAC': 'F_MAC', - 'VALIDATION_URL': 'F_URL', - 'VALIDATION_PORT': 'F_PORT', - 'VALIDATION_DASH_NAME': 'F_DASH', - 'VALIDATION_NETWORK_NAME': 'F_NET', - 'VALIDATION_DOMAIN_NAME': 'F_DOMAIN', - 'VALIDATION_TIME24H': 'F_T24H', - 'VALIDATION_RANGE_INT': 'F_RNGINT', - 'VALIDATION_ENDPOINT': 'F_ENDPT', - 'VALIDATION_IPV4_CIDR': 'F_IPV4C', - } - decls = ''.join( - f'var {_JS_NAMES[k]}={_VALIDATION_FLAGS[k]};' - for k in _VALIDATION_FLAGS - ) body = r""" function _ok(){return{ok:true,msg:'',partial:false};} function _par(m){return{ok:false,msg:m||'',partial:true};} @@ -172,33 +131,34 @@ function _ipv6(s){ if(d&&c>7)return'badstruct'; return(c===7&&!d)||d?'ok':'partial'; } -function _checkFlag(s,flag){ - if(flag===F_IPV4){var r=_ipv4(s);if(r==='ok')return _ok();if(r==='partial'||r==='empty')return _par('');if(r==='badchar')return _err('Invalid character');if(r==='badrange')return _err('Octet out of range');return _err('Invalid format');} - if(flag===F_IPV6){var r=_ipv6(s);if(r==='ok')return _ok();if(r==='partial'||r==='empty')return _par('');if(r==='badchar')return _err('Invalid character');return _err('Invalid format');} - if(flag===F_MAC){if(!s)return _par('');if(/[^0-9a-fA-F:]/.test(s))return _err('Invalid character');if(/::/.test(s))return _err('Invalid format');var g=s.split(':');if(g.length>6)return _err('Too many groups');for(var i=0;i2)return _err('Each group must be exactly 2 hex characters');}return(g.length===6&&g.every(function(x){return x.length===2;}))?_ok():_par('');} - if(flag===F_URL){if(!s)return _par('');if(/[^A-Za-z0-9\-._~:/?#\[\]@!$&'()*+,;=%]/.test(s))return _err('Invalid character');var sl=s.toLowerCase();if('https://'.startsWith(sl)||'http://'.startsWith(sl))return _par('');var sep=sl.indexOf('://');if(sep===-1)return _err('Invalid URL format');var scheme=sl.slice(0,sep);if(scheme!=='http'&&scheme!=='https')return _err('Invalid URL format');var after=s.slice(sep+3);if(!after)return _par('');var he=after.search(/[/:?#]/),host=he===-1?after:after.slice(0,he),rest=he===-1?'':after.slice(he);if(!host)return _par('');if(/\.\./.test(host)||host[0]==='.'||host[host.length-1]==='.')return _err('Invalid URL format');var lb=host.split('.');for(var i=0;i65535)return _err('Invalid URL format');}return _ok();} - if(flag===F_PORT){if(!s)return _par('');if(/[^0-9]/.test(s))return _err('Digits only');var n=parseInt(s,10);return(n>=1&&n<=65535)?_ok():_err('Must be between 1 and 65535');} - if(flag===F_DASH){if(!s)return _par('');if(/[^a-z0-9-]/.test(s))return _err('Lowercase letters, digits and hyphens only');if(s[0]==='-'||/--/.test(s))return _err('No leading, trailing or consecutive hyphens');if(s[s.length-1]==='-')return _par('');return _ok();} - if(flag===F_NET){if(!s)return _par('');if(/[^a-zA-Z0-9_-]/.test(s))return _err('Letters, digits, hyphens and underscores only');if(s[0]==='-'||s[0]==='_')return _err('No leading, trailing or consecutive special characters');if(/[-_]{2,}/.test(s))return _err('No leading, trailing or consecutive special characters');if(s[s.length-1]==='-'||s[s.length-1]==='_')return _par('');return _ok();} - if(flag===F_DOMAIN){if(!s)return _par('');if(/[^a-zA-Z0-9.-]/.test(s))return _err('Letters, digits, hyphens and dots only');if(s[0]==='.')return _err('Invalid domain format');if(/\.\./.test(s))return _err('Invalid domain format');if(s[s.length-1]==='.')return _par('');var lb=s.split('.');for(var i=0;imx)){if(mn!=null&&mx!==null)return _err('Must be between '+mn+' and '+mx);return mn!=null?_err('Must be >= '+mn):_err('Must be <= '+mx);}return _ok();} - if(flag===F_ENDPT){if(!s)return _par('');if(/^[0-9.]+$/.test(s)){var r=_ipv4(s);return r==='ok'?_ok():(r==='partial'||r==='empty')?_par(''):_err('Invalid character');}if(s.indexOf(':')!==-1){var cc=(s.match(/:/g)||[]).length;if(cc>1){if(/:::/.test(s)||(s.match(/::/g)||[]).length>1)return _err('Invalid hostname or IP');if(/[^0-9a-fA-F:.]/.test(s))return _err('Invalid character');var col=s.replace(/[^:]/g,'').length;return(s.indexOf('::')!==-1||col===7)?_ok():_par('');}return _checkFlag(s.slice(0,s.lastIndexOf(':')),F_DOMAIN);}return _checkFlag(s,F_DOMAIN);} - if(flag===F_IPV4C){if(!s)return _par('');var slash=s.indexOf('/');if(slash===-1){var r=_ipv4(s);return(r==='ok'||r==='partial'||r==='empty')?_par(''):(r==='badchar'?_err('Invalid character'):r==='badrange'?_err('Octet out of range'):_err('Invalid format'));}var r=_ipv4(s.slice(0,slash));if(r!=='ok')return r==='badchar'?_err('Invalid character'):r==='badrange'?_err('Octet out of range'):_par('');var pfx=s.slice(slash+1);if(!pfx)return _par('');if(/[^0-9]/.test(pfx))return _err('Invalid character');var n=parseInt(pfx,10);return(n>=0&&n<=32)?_ok():_err('Prefix must be 0-32');} - if(flag===F_SUBNET){if(!arg1)return _par('');var prefix=parseInt(arg1,10);if(isNaN(prefix)||prefix<1||prefix>30)return _err('Prefix must be 1-30');var r=_ipv4(s);if(r!=='ok')return(r==='partial'||r==='empty')?_par(''):(r==='badchar'?_err('Invalid character'):_err('Invalid format'));var mB=prefix===0?0:((0xFFFFFFFF<<(32-prefix))>>>0);var ipN=s.split('.').reduce(function(a,o){return(a<<8|+o)>>>0;},0);return((ipN&(~mB>>>0))!==0)?_err('Host bits must be zero'):_ok();} - if(flag===F_ADDR){var r=_ipv4(s);if(r!=='ok')return(r==='partial'||r==='empty')?_par(''):(r==='badchar'?_err('Invalid character'):_err('Invalid format'));if(!arg1||!arg2)return _par('');var prefix=parseInt(arg1,10);if(isNaN(prefix)||prefix<1||prefix>30)return _par('');if(_ipv4(arg2)!=='ok')return _par('');var mB=prefix===0?0:((0xFFFFFFFF<<(32-prefix))>>>0);var snN=arg2.split('.').reduce(function(a,o){return(a<<8|+o)>>>0;},0);if((snN&(~mB>>>0))!==0)return _par('');var iPts=s.split('.').map(Number),sPts=arg2.split('.').map(Number);var ipN=((iPts[0]<<24)|(iPts[1]<<16)|(iPts[2]<<8)|iPts[3])>>>0,sN=((sPts[0]<<24)|(sPts[1]<<16)|(sPts[2]<<8)|sPts[3])>>>0;if((ipN&mB)!==(sN&mB))return _err('IP not in VLAN subnet');var hM=(~mB)>>>0,netN=(sN&mB)>>>0;if(ipN===netN)return _err('Network address not allowed');if(ipN===(netN|hM)>>>0)return _err('Broadcast address not allowed');return _ok();} - return _par(''); +function _checkDomain(s){ + if(!s)return _par(''); + if(/[^a-zA-Z0-9.-]/.test(s))return _err('Letters, digits, hyphens and dots only'); + if(s[0]==='.')return _err('Invalid domain format'); + if(/\.\./.test(s))return _err('Invalid domain format'); + if(s[s.length-1]==='.')return _par(''); + var lb=s.split('.'); + for(var i=0;i30)return _err('Prefix must be 1-30');var rv=_ipv4(s);if(rv!=='ok')return(rv==='partial'||rv==='empty')?_par(''):(rv==='badchar'?_err('Invalid character'):_err('Invalid format'));var mB=prefix===0?0:((0xFFFFFFFF<<(32-prefix))>>>0);var ipN=s.split('.').reduce(function(a,o){return(a<<8|+o)>>>0;},0);return((ipN&(~mB>>>0))!==0)?_err('Host bits must be zero'):_ok();}());if(t)return t;} + if(validation&8){t=_acc(function(){var rv=_ipv4(s);if(rv!=='ok')return(rv==='partial'||rv==='empty')?_par(''):(rv==='badchar'?_err('Invalid character'):_err('Invalid format'));if(!arg1||!arg2)return _par('');var prefix=parseInt(arg1,10);if(isNaN(prefix)||prefix<1||prefix>30)return _par('');if(_ipv4(arg2)!=='ok')return _par('');var mB=prefix===0?0:((0xFFFFFFFF<<(32-prefix))>>>0);var snN=arg2.split('.').reduce(function(a,o){return(a<<8|+o)>>>0;},0);if((snN&(~mB>>>0))!==0)return _par('');var iPts=s.split('.').map(Number),sPts=arg2.split('.').map(Number);var ipN=((iPts[0]<<24)|(iPts[1]<<16)|(iPts[2]<<8)|iPts[3])>>>0,sN=((sPts[0]<<24)|(sPts[1]<<16)|(sPts[2]<<8)|sPts[3])>>>0;if((ipN&mB)!==(sN&mB))return _err('IP not in VLAN subnet');var hM=(~mB)>>>0,netN=(sN&mB)>>>0;if(ipN===netN)return _err('Network address not allowed');if(ipN===(netN|hM)>>>0)return _err('Broadcast address not allowed');return _ok();}());if(t)return t;} + if(validation&16){t=_acc(function(){if(!s)return _par('');if(/[^0-9a-fA-F:]/.test(s))return _err('Invalid character');if(/::/.test(s))return _err('Invalid format');var g=s.split(':');if(g.length>6)return _err('Too many groups');for(var i=0;i2)return _err('Each group must be exactly 2 hex characters');}return(g.length===6&&g.every(function(x){return x.length===2;}))?_ok():_par('');}());if(t)return t;} + if(validation&32){t=_acc(function(){if(!s)return _par('');if(/[^A-Za-z0-9\-._~:/?#\[\]@!$&'()*+,;=%]/.test(s))return _err('Invalid character');var sl=s.toLowerCase();if('https://'.startsWith(sl)||'http://'.startsWith(sl))return _par('');var sep=sl.indexOf('://');if(sep===-1)return _err('Invalid URL format');var scheme=sl.slice(0,sep);if(scheme!=='http'&&scheme!=='https')return _err('Invalid URL format');var after=s.slice(sep+3);if(!after)return _par('');var he=after.search(/[/:?#]/),host=he===-1?after:after.slice(0,he),rest=he===-1?'':after.slice(he);if(!host)return _par('');if(/\.\./.test(host)||host[0]==='.'||host[host.length-1]==='.')return _err('Invalid URL format');var lb=host.split('.');for(var i=0;i65535)return _err('Invalid URL format');}return _ok();}());if(t)return t;} + if(validation&64){t=_acc(function(){if(!s)return _par('');if(/[^0-9]/.test(s))return _err('Digits only');var n=parseInt(s,10);return(n>=1&&n<=65535)?_ok():_err('Must be between 1 and 65535');}());if(t)return t;} + if(validation&128){t=_acc(function(){if(!s)return _par('');if(/[^a-z0-9-]/.test(s))return _err('Lowercase letters, digits and hyphens only');if(s[0]==='-'||/--/.test(s))return _err('No leading, trailing or consecutive hyphens');if(s[s.length-1]==='-')return _par('');return _ok();}());if(t)return t;} + if(validation&256){t=_acc(function(){if(!s)return _par('');if(/[^a-zA-Z0-9_-]/.test(s))return _err('Letters, digits, hyphens and underscores only');if(s[0]==='-'||s[0]==='_')return _err('No leading, trailing or consecutive special characters');if(/[-_]{2,}/.test(s))return _err('No leading, trailing or consecutive special characters');if(s[s.length-1]==='-'||s[s.length-1]==='_')return _par('');return _ok();}());if(t)return t;} + if(validation&512){t=_acc(_checkDomain(s));if(t)return t;} + if(validation&1024){t=_acc(function(){if(!s)return _par('');if(/[^0-9:]/.test(s))return _err('Digits and colon only');if(s.length<5)return _par('');return /^([01]\d|2[0-3]):[0-5]\d$/.test(s)?_ok():_err('Must be HH:MM in 24-hour format (e.g. 02:30)');}());if(t)return t;} + if(validation&2048){t=_acc(function(){if(s===''||s===null||s===undefined)return _par('');if(/[^0-9]/.test(s))return _err('Digits only');var n=parseInt(s,10);var mn=(arg1!==''&&arg1!=null)?parseInt(arg1,10):0;var mx=(arg2!==''&&arg2!=null)?parseInt(arg2,10):null;if(nmx)){if(mn!=null&&mx!==null)return _err('Must be between '+mn+' and '+mx);return mn!=null?_err('Must be >= '+mn):_err('Must be <= '+mx);}return _ok();}());if(t)return t;} + if(validation&4096){t=_acc(function(){if(!s)return _par('');if(/^[0-9.]+$/.test(s)){var rv=_ipv4(s);return rv==='ok'?_ok():(rv==='partial'||rv==='empty')?_par(''):_err('Invalid character');}if(s.indexOf(':')!==-1){var cc=(s.match(/:/g)||[]).length;if(cc>1){if(/:::/.test(s)||(s.match(/::/g)||[]).length>1)return _err('Invalid hostname or IP');if(/[^0-9a-fA-F:.]/.test(s))return _err('Invalid character');var col=s.replace(/[^:]/g,'').length;return(s.indexOf('::')!==-1||col===7)?_ok():_par('');}return _checkDomain(s.slice(0,s.lastIndexOf(':')));}return _checkDomain(s);}());if(t)return t;} + if(validation&8192){t=_acc(function(){if(!s)return _par('');var slash=s.indexOf('/');if(slash===-1){var rv=_ipv4(s);return(rv==='ok'||rv==='partial'||rv==='empty')?_par(''):(rv==='badchar'?_err('Invalid character'):rv==='badrange'?_err('Octet out of range'):_err('Invalid format'));}var rv=_ipv4(s.slice(0,slash));if(rv!=='ok')return rv==='badchar'?_err('Invalid character'):rv==='badrange'?_err('Octet out of range'):_par('');var pfx=s.slice(slash+1);if(!pfx)return _par('');if(/[^0-9]/.test(pfx))return _err('Invalid character');var n=parseInt(pfx,10);return(n>=0&&n<=32)?_ok():_err('Prefix must be 0-32');}());if(t)return t;} return anyPartial?_par(''):_err(firstMsg||'Invalid'); } var lines=value.split('\n'),hasPartial=false,seen={},hasContent=false; @@ -215,7 +175,7 @@ for(var i=0;i' f'
' + f' placeholder="{placeholder}" class="form-input"{readonly}{validate_attr}{depends_attr}{extra_attrs}{existing_attr}/>' f'
' f'{hint_html}' ) return ( f'
' f'' + f' placeholder="{placeholder}" class="form-input"{readonly}{validate_attr}{depends_attr}{extra_attrs}{existing_attr}/>' f'{hint_html}
' ) diff --git a/docker/routlin-dash/app/pages/dnsblocking/action.py b/docker/routlin-dash/app/pages/dnsblocking/action.py index 850c6b6..d99f3a4 100644 --- a/docker/routlin-dash/app/pages/dnsblocking/action.py +++ b/docker/routlin-dash/app/pages/dnsblocking/action.py @@ -105,6 +105,13 @@ def blocklists_edit(): return redirect(f'/{_PAGE}') before = copy.deepcopy(items[idx]) + + # Blocklist name must be unique - it is the lookup key for VLAN use_blocklists references + err = validate.check_blocklist_name_unique(items, fields['name'], exclude_idx=idx) + if err: + flash(err, 'error') + return redirect(f'/{_PAGE}') + items[idx].update({ 'name': fields['name'], 'description': fields['description'], @@ -134,8 +141,10 @@ def addblocklist_add(): cfg = load_config() blocklists = cfg.setdefault('dns_blocking', {}).setdefault('blocklists', []) - if any(b.get('name', '').lower() == fields['name'].lower() for b in blocklists): - flash('The configuration has not been saved because a blocklist with that name already exists.', 'error') + # Blocklist name must be unique - it is the lookup key for VLAN use_blocklists references + err = validate.check_blocklist_name_unique(blocklists, fields['name']) + if err: + flash(err, 'error') return redirect(f'/{_PAGE}') entry = { diff --git a/docker/routlin-dash/app/pages/intervlan/action.py b/docker/routlin-dash/app/pages/intervlan/action.py index 70112d3..9d88748 100644 --- a/docker/routlin-dash/app/pages/intervlan/action.py +++ b/docker/routlin-dash/app/pages/intervlan/action.py @@ -33,7 +33,8 @@ def _parse_entry(): protocol = sanitize.filtervalue(request.form.get('protocol', ''), validate.VALID_PROTOCOLS) src_raw = request.form.get('src_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_min_raw = request.form.get('dst_port_min', '').strip() + dst_port_max_raw = request.form.get('dst_port_max', '').strip() if not protocol: flash(f'The configuration has not been saved because the protocol is invalid. ' @@ -56,19 +57,31 @@ def _parse_entry(): flash(f'The configuration has not been saved because "{dst_raw}" is not a valid IP address or subnet.', 'error') return None, True - dst_port = '' - if dst_port_raw: - dst_port = validate.port(dst_port_raw) - if not dst_port: - flash(f'The configuration has not been saved because "{dst_port_raw}" is not a valid port number (1-65535).', 'error') + dst_port_min = '' + if dst_port_min_raw: + dst_port_min = validate.port(dst_port_min_raw) + if not dst_port_min: + flash(f'The configuration has not been saved because "{dst_port_min_raw}" is not a valid port number (1-65535).', 'error') return None, True + dst_port_max = '' + if dst_port_max_raw: + dst_port_max = validate.port(dst_port_max_raw) + if not dst_port_max: + flash(f'The configuration has not been saved because "{dst_port_max_raw}" is not a valid port number (1-65535).', 'error') + return None, True + + if dst_port_min and dst_port_max and int(dst_port_min) > int(dst_port_max): + flash('Port range min must not be greater than max.', 'error') + return None, True + return { 'description': description, 'protocol': protocol, 'src_ip_or_subnet': src, 'dst_ip_or_subnet': dst, - 'dst_port': dst_port, + 'dst_port_min': dst_port_min, + 'dst_port_max': dst_port_max, 'enabled': True, }, None diff --git a/docker/routlin-dash/app/pages/intervlan/content.json b/docker/routlin-dash/app/pages/intervlan/content.json index 4dc306d..526dd18 100644 --- a/docker/routlin-dash/app/pages/intervlan/content.json +++ b/docker/routlin-dash/app/pages/intervlan/content.json @@ -26,7 +26,7 @@ { "label": "Protocol", "field": "protocol", - "class": "col-mono" + "class": "col-mono col-narrow" }, { "label": "Source", @@ -39,9 +39,14 @@ "class": "col-mono" }, { - "label": "Dest Port", - "field": "dst_port", - "class": "col-mono" + "label": "Port Min", + "field": "dst_port_min", + "class": "col-mono col-narrow" + }, + { + "label": "Port Max", + "field": "dst_port_max", + "class": "col-mono col-narrow" }, { "label": "Status", @@ -75,8 +80,12 @@ "input_type": "text" }, { - "col": "dst_port", - "input_type": "text" + "col": "dst_port_min", + "input_type": "number" + }, + { + "col": "dst_port_max", + "input_type": "number" }, { "col": "enabled", @@ -112,36 +121,53 @@ "input_type": "text", "placeholder": "e.g. Allow Chromecast" }, - { - "type": "field", - "label": "Protocol", - "name": "protocol", - "input_type": "select", - "options": "%PROTOCOL_OPTIONS%" - }, { "type": "field", "label": "Source", "name": "src_ip_or_subnet", "input_type": "text", - "validate": "VALIDATION_IPV4_CIDR", - "placeholder": "e.g. 192.168.20.0/24" + "validate": "VALIDATION_IPV4_FORMAT|VALIDATION_IPV4_CIDR", + "placeholder": "e.g. 192.168.20.100 or 192.168.20.0/24", + "hint": "You may allow either a single device IP or an entire subnet to contact dest." }, { "type": "field", "label": "Destination", "name": "dst_ip_or_subnet", "input_type": "text", - "validate": "VALIDATION_IPV4_FORMAT", - "placeholder": "e.g. 192.168.10.100" + "validate": "VALIDATION_IPV4_FORMAT|VALIDATION_IPV4_CIDR", + "placeholder": "e.g. 192.168.10.200 or 192.168.10.0/24", + "hint": "You may allow either a single device IP or an entire subnet to be reached by source." }, { - "type": "field", - "label": "Dest Port", - "name": "dst_port", - "input_type": "text", - "validate": "VALIDATION_PORT", - "placeholder": "e.g. 8009" + "type": "field_row", + "cols": 3, + "items": [ + { + "type": "field", + "label": "Protocol", + "name": "protocol", + "input_type": "select", + "options": "%PROTOCOL_OPTIONS%" + }, + { + "type": "field", + "label": "Port Min", + "name": "dst_port_min", + "input_type": "number", + "min": 1, + "max": 65535, + "hint": "This exception only applies to traffic over this port range and protocol." + }, + { + "type": "field", + "label": "Port Max", + "name": "dst_port_max", + "input_type": "number", + "min": 1, + "max": 65535 + } + ] }, { "type": "button_row", @@ -163,4 +189,4 @@ ] } ] -} \ No newline at end of file +} diff --git a/docker/routlin-dash/app/pages/networklayout/action.py b/docker/routlin-dash/app/pages/networklayout/action.py index 766d390..d0a0da7 100644 --- a/docker/routlin-dash/app/pages/networklayout/action.py +++ b/docker/routlin-dash/app/pages/networklayout/action.py @@ -218,25 +218,37 @@ def vlans_addedit(): existing = vlans[edit_idx] is_vpn = existing.get('is_vpn', False) - if is_vpn and mdns_reflection: - flash('mDNS reflection is not supported on VPN VLANs.', 'error') + # VPN VLANs do not support mDNS reflection + err = validate.check_mdns_vpn(is_vpn, mdns_reflection) + if err: + flash(err, 'error') return redirect(f'/{_PAGE}') + # VLAN 1 maps to the physical LAN interface; its ID is fixed current_id = existing.get('vlan_id') if current_id == 1 and vlan_id != 1: flash('VLAN 1 is the physical interface and cannot change its ID.', 'error') return redirect(f'/{_PAGE}') - if vlan_id != current_id and any( - v.get('vlan_id') == vlan_id for i, v in enumerate(vlans) if i != edit_idx - ): - flash(f'VLAN ID {vlan_id} is already in use.', 'error') + # VLAN ID must be unique across all VLANs (used as 802.1Q tag and interface name) + err = validate.check_vlan_id_unique(vlans, vlan_id, exclude_idx=edit_idx) + if err: + flash(err, 'error') return redirect(f'/{_PAGE}') - if radius_default and any(i != edit_idx and v.get('radius_default') for i, v in enumerate(vlans)): - flash('Only one VLAN can be the RADIUS default.', 'error') + # VLAN name must be unique - it is used as the change-history lookup key + err = validate.check_vlan_name_unique(vlans, name, exclude_idx=edit_idx) + if err: + flash(err, 'error') return redirect(f'/{_PAGE}') + # Only one VLAN may be the RADIUS default (used for dynamic VLAN assignment) + if radius_default: + err = validate.check_radius_default_unique(vlans, exclude_idx=edit_idx) + if err: + flash(err, 'error') + return redirect(f'/{_PAGE}') + before = copy.deepcopy(existing) existing.update({ 'name': name, @@ -269,18 +281,31 @@ def vlans_addedit(): else: is_vpn = 'is_vpn' in request.form - if is_vpn and mdns_reflection: - flash('mDNS reflection is not supported on VPN VLANs.', 'error') + # VPN VLANs do not support mDNS reflection + err = validate.check_mdns_vpn(is_vpn, mdns_reflection) + if err: + flash(err, 'error') return redirect(f'/{_PAGE}') - if any(v.get('vlan_id') == vlan_id for v in vlans): - flash(f'VLAN ID {vlan_id} is already in use.', 'error') + # VLAN ID must be unique across all VLANs (used as 802.1Q tag and interface name) + err = validate.check_vlan_id_unique(vlans, vlan_id) + if err: + flash(err, 'error') return redirect(f'/{_PAGE}') - if radius_default and any(v.get('radius_default') for v in vlans): - flash('Only one VLAN can be the RADIUS default.', 'error') + # VLAN name must be unique - it is used as the change-history lookup key + err = validate.check_vlan_name_unique(vlans, name) + if err: + flash(err, 'error') return redirect(f'/{_PAGE}') + # Only one VLAN may be the RADIUS default (used for dynamic VLAN assignment) + if radius_default: + err = validate.check_radius_default_unique(vlans) + if err: + flash(err, 'error') + return redirect(f'/{_PAGE}') + entry = { 'name': name, 'vlan_id': vlan_id, diff --git a/docker/routlin-dash/app/pages/networklayout/content.json b/docker/routlin-dash/app/pages/networklayout/content.json index 47e52b7..82e7287 100644 --- a/docker/routlin-dash/app/pages/networklayout/content.json +++ b/docker/routlin-dash/app/pages/networklayout/content.json @@ -149,6 +149,7 @@ "name": "name", "input_type": "text", "validate": "VALIDATION_DASH_NAME", + "existing_ids": "%EXISTING_VLAN_NAMES_JSON%", "hint": "Lowercase letters, digits, hyphens. E.g. iot" }, { diff --git a/docker/routlin-dash/app/pages/physicalinterfaces/action.py b/docker/routlin-dash/app/pages/physicalinterfaces/action.py index 278997f..89cafca 100644 --- a/docker/routlin-dash/app/pages/physicalinterfaces/action.py +++ b/docker/routlin-dash/app/pages/physicalinterfaces/action.py @@ -42,8 +42,10 @@ def physicalinterface_save(): flash('Both WAN and LAN interfaces are required.', 'error') return redirect(f'/{_PAGE}') - if wan == lan: - flash('WAN and LAN interfaces must be different.', 'error') + # WAN and LAN must be distinct physical interfaces + err = validate.check_wan_lan_unique(wan, lan) + if err: + flash(err, 'error') return redirect(f'/{_PAGE}') if not verify_config_hash(request.form.get('config_hash', '')): @@ -51,9 +53,11 @@ def physicalinterface_save(): return redirect(f'/{_PAGE}') available = _get_system_interfaces() + # Interfaces must exist on this system (checked against physical-only interface list) for iface in (wan, lan): - if available and iface not in available: - flash(f"Interface '{iface}' does not exist on this system.", 'error') + err = validate.check_interface_exists(iface, available) + if err: + flash(err, 'error') return redirect(f'/{_PAGE}') cfg = load_config() diff --git a/docker/routlin-dash/app/view_page.py b/docker/routlin-dash/app/view_page.py index 84a8737..749fc44 100644 --- a/docker/routlin-dash/app/view_page.py +++ b/docker/routlin-dash/app/view_page.py @@ -789,7 +789,8 @@ def collect_tokens(): tokens['VLAN_FILTER_OPTIONS'] = filter_opts tokens['VLAN_NAMES_AS_OPTIONS'] = json.dumps([{'value': n, 'label': n} for n in vlan_names]) tokens['VPN_VLAN_COUNT'] = str(sum(1 for v in vlans if v.get('is_vpn'))) - tokens['EXISTING_VLAN_IDS_JSON'] = json.dumps([v.get('vlan_id') for v in vlans]) + tokens['EXISTING_VLAN_IDS_JSON'] = json.dumps([v.get('vlan_id') for v in vlans]) + tokens['EXISTING_VLAN_NAMES_JSON'] = json.dumps([v.get('name') for v in vlans]) _dv = next((v for v in vlans if v.get('radius_default')), None) tokens['RADIUS_DEFAULT_VLAN'] = f'"{_dv["name"]}" (VLAN {_dv["vlan_id"]})' if _dv else 'none set' try: diff --git a/routlin/validation.py b/routlin/validation.py index 66d7515..10b6db6 100644 --- a/routlin/validation.py +++ b/routlin/validation.py @@ -253,6 +253,79 @@ def _check_banned_ipv6(ip_str): raise ValueError(f"IPv6 wildcard must have 1-7 prefix groups: {ip_str!r}") +# =================================================================== +# Cross-VLAN uniqueness checks (callable independently by action.py) +# =================================================================== + +def check_vlan_name_unique(vlans, name, exclude_idx=None): + """Return error string if name is already used by another VLAN, else None.""" + for i, v in enumerate(vlans): + if exclude_idx is not None and i == exclude_idx: + continue + if v.get('name') == name: + return f'VLAN name "{name}" is already in use (VLAN ID {v.get("vlan_id")}).' + return None + + +def check_vlan_id_unique(vlans, vlan_id, exclude_idx=None): + """Return error string if vlan_id is already used by another VLAN, else None.""" + for i, v in enumerate(vlans): + if exclude_idx is not None and i == exclude_idx: + continue + if v.get('vlan_id') == vlan_id: + return f'VLAN ID {vlan_id} is already in use (VLAN "{v.get("name")}").' + return None + + +def check_radius_default_unique(vlans, exclude_idx=None): + """Return error string if another VLAN (not exclude_idx) is already the RADIUS default, else None.""" + for i, v in enumerate(vlans): + if exclude_idx is not None and i == exclude_idx: + continue + if v.get('radius_default'): + return f'VLAN "{v.get("name", "?")}" is already the RADIUS default. Only one VLAN may be the default.' + return None + + +def check_mdns_vpn(is_vpn, mdns_reflection): + """Return error string if mDNS reflection is enabled on a VPN VLAN, else None.""" + if is_vpn and mdns_reflection: + return 'mDNS reflection is not supported on VPN VLANs.' + return None + + +# =================================================================== +# Blocklist checks (callable independently by action.py) +# =================================================================== + +def check_blocklist_name_unique(blocklists, name, exclude_idx=None): + """Return error string if name is already used by another blocklist entry, else None.""" + for i, b in enumerate(blocklists): + if exclude_idx is not None and i == exclude_idx: + continue + if b.get('name') == name: + return f'A blocklist named "{name}" already exists.' + return None + + +# =================================================================== +# Physical interface checks (callable independently by action.py) +# =================================================================== + +def check_wan_lan_unique(wan, lan): + """Return error string if WAN and LAN resolve to the same interface, else None.""" + if wan and lan and wan == lan: + return 'WAN and LAN interfaces must be different.' + return None + + +def check_interface_exists(iface, available): + """Return error string if iface is absent from the available set (when non-empty), else None.""" + if available and iface not in available: + return f"Interface '{iface}' does not exist on this system." + return None + + # =================================================================== # VLAN / interface helpers (shared with core.py apply logic) # =================================================================== @@ -300,9 +373,7 @@ def derive_interface(vlan, data): def validate_config(data): """Validate config.json structure and content. Returns list of error strings.""" errors = [] - seen_vlan_ids = {} seen_interfaces = {} - seen_names = {} seen_listen_ports = {} # Pre-compute per-VLAN vlan_ids and interface names without mutating data @@ -340,13 +411,15 @@ def validate_config(data): available_interfaces = set(os.listdir('/sys/class/net')) except Exception: pass - if available_interfaces: - if wan not in available_interfaces: - errors.append(f"network_interfaces.wan_interface: '{wan}' does not exist on this system.") - if lan not in available_interfaces: - errors.append(f"network_interfaces.lan_interface: '{lan}' does not exist on this system.") - if wan == lan: - errors.append(f"network_interfaces.wan_interface and network_interfaces.lan_interface must be different (both set to '{wan}').") + err = check_interface_exists(wan, available_interfaces) + if err: + errors.append(f"network_interfaces.wan_interface: {err}") + err = check_interface_exists(lan, available_interfaces) + if err: + errors.append(f"network_interfaces.lan_interface: {err}") + err = check_wan_lan_unique(wan, lan) + if err: + errors.append(f"network_interfaces: {err}") # Blocklist library ============================================= blocklists_by_name = {} @@ -359,8 +432,11 @@ def validate_config(data): if bl.get("format") and bl["format"] not in VALID_BLOCKLIST_FORMATS: errors.append(f"{label}: format must be one of: {', '.join(sorted(VALID_BLOCKLIST_FORMATS))}.") if name: - if name in blocklists_by_name: - errors.append(f"{label}: duplicate blocklist name '{name}'.") + err = check_blocklist_name_unique( + data.get("dns_blocking", {}).get("blocklists", []), name, exclude_idx=idx + ) + if err: + errors.append(f"{label}: {err}") else: blocklists_by_name[name] = bl @@ -375,17 +451,13 @@ def validate_config(data): if vlan_id is None or not isinstance(vlan_id, int) or not (1 <= vlan_id <= 4094): errors.append(f"vlan '{name}': vlan_id must be an integer 1-4094 (got {vlan_id!r}).") - if name in seen_names: - errors.append(f"{label}: duplicate vlan name '{name}' " - f"(also used by id={seen_names[name]}).") - else: - seen_names[name] = vlan_id + err = check_vlan_name_unique(_all_vlans, name, exclude_idx=i) + if err: + errors.append(f"{label}: {err}") - if vlan_id in seen_vlan_ids: - errors.append(f"{label}: duplicate vlan_id {vlan_id} " - f"(also used by '{seen_vlan_ids[vlan_id]}').") - else: - seen_vlan_ids[vlan_id] = name + err = check_vlan_id_unique(_all_vlans, vlan_id, exclude_idx=i) + if err: + errors.append(f"{label}: {err}") if iface in seen_interfaces: errors.append(f"{label}: duplicate interface '{iface}' " @@ -393,8 +465,9 @@ def validate_config(data): else: seen_interfaces[iface] = name - if vlan.get("mdns_reflection") is True and is_wg(vlan): - errors.append(f"{label}: mdns_reflection must be false for WireGuard interfaces.") + err = check_mdns_vpn(is_wg(vlan), vlan.get('mdns_reflection')) + if err: + errors.append(f"{label}: {err}") if is_wg(vlan): # vpn_information ======================================= @@ -702,14 +775,28 @@ def validate_config(data): if not ipv4_or_cidr(dst): errors.append(f"{label}: dst_ip_or_subnet '{dst}' is not a valid " f"IPv4 address or network.") - if r.get("dst_port") is not None: - nat_check_port(f"{label} dst_port", r.get("dst_port")) + if r.get("dst_port_min"): + nat_check_port(f"{label} dst_port_min", r.get("dst_port_min")) + if r.get("dst_port_max"): + nat_check_port(f"{label} dst_port_max", r.get("dst_port_max")) + min_p, max_p = r.get("dst_port_min", ""), r.get("dst_port_max", "") + if min_p and max_p: + try: + if int(min_p) > int(max_p): + errors.append(f"{label}: dst_port_min {min_p} is greater than dst_port_max {max_p}.") + except (ValueError, TypeError): + pass # radius_default uniqueness check =============================== - defaults = [v["name"] for v in data.get("vlans", []) if v.get("radius_default") is True] - if len(defaults) > 1: - errors.append(f"Multiple VLANs have radius_default: true ({', '.join(defaults)}). " - f"Only one VLAN may be the RADIUS default.") + _rd_idx = next( + (i for i, v in enumerate(data.get('vlans', [])) if v.get('radius_default')), None + ) + if _rd_idx is not None: + err = check_radius_default_unique(data.get('vlans', []), exclude_idx=_rd_idx) + if err: + defaults = [v.get('name', '?') for v in data.get('vlans', []) if v.get('radius_default')] + errors.append(f"Multiple VLANs have radius_default: true ({', '.join(defaults)}). " + f"Only one VLAN may be the RADIUS default.") # RADIUS requires multiple VLANs ================================ non_wg_vlans = [v for v in data.get("vlans", []) if not is_wg(v)]