From 82df24f294578975d0d5fcd6a2cbea2cf73144dc Mon Sep 17 00:00:00 2001 From: Matthew Grotke Date: Fri, 5 Jun 2026 22:54:12 -0400 Subject: [PATCH] Development --- docker/routlin-dash/app/factory.py | 15 +++++++++++++++ .../app/pages/intervlan/content.json | 4 ++++ .../app/pages/portforwarding/action.py | 19 +++++++++++++++++-- .../app/pages/portforwarding/content.json | 2 +- routlin/mod_validation.py | 19 +++++++++++++++++++ 5 files changed, 56 insertions(+), 3 deletions(-) diff --git a/docker/routlin-dash/app/factory.py b/docker/routlin-dash/app/factory.py index 2db0971..efb4cd6 100644 --- a/docker/routlin-dash/app/factory.py +++ b/docker/routlin-dash/app/factory.py @@ -40,8 +40,18 @@ VALIDATION_FLAGS = { 'VALIDATION_RANGE_INT': 1 << 11, 'VALIDATION_ENDPOINT': 1 << 12, 'VALIDATION_IPV4_CIDR': 1 << 13, + 'VALIDATION_UNRESTRICTED': 1 << 14, } +def _restricted_vlan_subnets(): + """Return list of 'subnet/prefix' strings for all restricted VLANs.""" + vlans = load_config().get('vlans', []) + result = [] + for v in vlans: + if v.get('restricted_vlan') and v.get('subnet') and v.get('subnet_mask') is not None: + result.append(f"{v['subnet']}/{v['subnet_mask']}") + return result + # File / shell helpers ================================================ def load_json(path): @@ -192,6 +202,7 @@ function _checkLine(s){ 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;} + if(validation&16384){t=_acc(function(){if(!s)return _par('');var rv=_ipv4(s);if(rv!=='ok')return _par('');if(!collisions||!collisions.length)return _ok();var ip=s.split('.').map(Number);var ipN=((ip[0]<<24)|(ip[1]<<16)|(ip[2]<<8)|ip[3])>>>0;for(var i=0;i>>0;var pfx=parseInt(sp[1],10);var mB=pfx===0?0:((0xFFFFFFFF<<(32-pfx))>>>0);if((ipN&mB)===(netN&mB))return _err('IP is on a restricted VLAN');}return _ok();}());if(t)return t;} return anyPartial?_par(''):_err(firstMsg||'Invalid'); } var lines=value.split('\n'),hasPartial=false,seen={},hasContent=false; @@ -841,6 +852,10 @@ def build_field(item, tokens): optional_attr = ' data-optional="1"' if item.get('optional') else '' existing_ids = apply_tokens(item.get('existing_ids', ''), tokens) existing_attr = f' data-existing-ids="{e(existing_ids)}"' if existing_ids else '' + if not existing_ids and (_vmask & VALIDATION_FLAGS.get('VALIDATION_UNRESTRICTED', 0)): + _rsubnets = _restricted_vlan_subnets() + if _rsubnets: + existing_attr = f' data-existing-ids="{e(json.dumps(_rsubnets))}"' if _vmask: return ( f'
' diff --git a/docker/routlin-dash/app/pages/intervlan/content.json b/docker/routlin-dash/app/pages/intervlan/content.json index a64b94b..b3a050b 100644 --- a/docker/routlin-dash/app/pages/intervlan/content.json +++ b/docker/routlin-dash/app/pages/intervlan/content.json @@ -151,6 +151,10 @@ "type": "raw_html", "html": "

This exception only applies to traffic matching the selected protocol and destination port range.

" }, + { + "type": "raw_html", + "html": "
" + }, { "type": "field", "label": "Enabled", diff --git a/docker/routlin-dash/app/pages/portforwarding/action.py b/docker/routlin-dash/app/pages/portforwarding/action.py index fc2f7ac..109e2bc 100644 --- a/docker/routlin-dash/app/pages/portforwarding/action.py +++ b/docker/routlin-dash/app/pages/portforwarding/action.py @@ -84,6 +84,10 @@ def addrule_add(): return redirect(f'/{_PAGE}') cfg = load_config() + err = validate.check_portfwd_restricted_vlan(entry['nat_ip'], cfg.get('vlans', [])) + if err: + flash(err, 'error') + return redirect(f'/{_PAGE}') cfg.setdefault('port_forwarding', []).append(entry) for desc in validate.disable_portfwd_on_restricted_vlans(cfg): flash(f"Port forwarding rule '{desc}' was disabled because its destination is on a restricted VLAN.", 'info') @@ -115,9 +119,15 @@ def rules_toggle(): flash('Entry not found.', 'error') return redirect(f'/{_PAGE}') - old_enabled = items[idx].get('enabled', True) + old_enabled = items[idx].get('enabled', True) + new_enabled = not old_enabled + if new_enabled: + err = validate.check_portfwd_restricted_vlan(items[idx].get('nat_ip', ''), cfg.get('vlans', [])) + if err: + flash(err, 'error') + return redirect(f'/{_PAGE}') before = copy.deepcopy(items[idx]) - items[idx]['enabled'] = not old_enabled + items[idx]['enabled'] = new_enabled for desc in validate.disable_portfwd_on_restricted_vlans(cfg): flash(f"Port forwarding rule '{desc}' was disabled because its destination is on a restricted VLAN.", 'info') errors = validate.validate_config(cfg) @@ -152,6 +162,11 @@ def rules_edit(): flash('Entry not found.', 'error') return redirect(f'/{_PAGE}') + if request.form.get('enabled') == 'on': + err = validate.check_portfwd_restricted_vlan(entry['nat_ip'], cfg.get('vlans', [])) + if err: + flash(err, 'error') + return redirect(f'/{_PAGE}') before = copy.deepcopy(items[idx]) items[idx] = entry items[idx]['enabled'] = request.form.get('enabled') == 'on' diff --git a/docker/routlin-dash/app/pages/portforwarding/content.json b/docker/routlin-dash/app/pages/portforwarding/content.json index 587640d..2551635 100644 --- a/docker/routlin-dash/app/pages/portforwarding/content.json +++ b/docker/routlin-dash/app/pages/portforwarding/content.json @@ -109,7 +109,7 @@ "label": "NAT IP", "name": "nat_ip", "input_type": "text", - "validate": "VALIDATION_IPV4_FORMAT", + "validate": "VALIDATION_IPV4_FORMAT|VALIDATION_UNRESTRICTED", "placeholder": "e.g. 192.168.1.50" }, { diff --git a/routlin/mod_validation.py b/routlin/mod_validation.py index bc227df..1ba2483 100644 --- a/routlin/mod_validation.py +++ b/routlin/mod_validation.py @@ -952,6 +952,25 @@ def validate_config(data): return errors +def check_portfwd_restricted_vlan(nat_ip, vlans): + """Return an error string if nat_ip falls within a restricted VLAN's subnet, else None.""" + try: + addr = ipaddress.IPv4Address(nat_ip) + except Exception: + return None + for v in vlans: + if not v.get('restricted_vlan'): + continue + try: + net = ipaddress.IPv4Network(f"{v['subnet']}/{v['subnet_mask']}", strict=False) + except Exception: + continue + if addr in net: + return (f"NAT IP '{nat_ip}' is on restricted VLAN '{v['name']}'. " + f"Port forwarding to restricted VLANs is not permitted.") + return None + + def disable_portfwd_on_restricted_vlans(data): """Auto-disable enabled port forwarding rules whose nat_ip falls within a restricted VLAN's subnet. Mutates data in place. Returns list of descriptions of rules that were disabled."""