From cb0fb0bdaf0a622dbe104bc7a9d3b43ea39a244b Mon Sep 17 00:00:00 2001 From: Matthew Grotke Date: Fri, 5 Jun 2026 22:16:52 -0400 Subject: [PATCH] Development --- .../app/pages/bannedips/content.json | 2 +- .../routlin-dash/app/pages/ddns/content.json | 2 +- .../app/pages/dhcpreservations/content.json | 2 +- .../app/pages/hostoverrides/content.json | 2 +- .../app/pages/intervlan/content.json | 2 +- .../app/pages/networklayout/action.py | 4 ++ .../app/pages/portforwarding/action.py | 6 +++ .../app/pages/portforwarding/content.json | 2 +- .../app/pages/portwrangling/content.json | 2 +- routlin/core.py | 2 +- routlin/mod_nftables.py | 22 +++++++++ routlin/mod_validation.py | 49 +++++++++++++++++++ 12 files changed, 89 insertions(+), 8 deletions(-) diff --git a/docker/routlin-dash/app/pages/bannedips/content.json b/docker/routlin-dash/app/pages/bannedips/content.json index 546bb44..f2a91b4 100644 --- a/docker/routlin-dash/app/pages/bannedips/content.json +++ b/docker/routlin-dash/app/pages/bannedips/content.json @@ -34,7 +34,7 @@ "class": "col-mono" }, { - "label": "Status", + "label": "Rule State", "field": "enabled", "render": "badge_enabled_disabled" } diff --git a/docker/routlin-dash/app/pages/ddns/content.json b/docker/routlin-dash/app/pages/ddns/content.json index 6a50e88..ea2cab0 100644 --- a/docker/routlin-dash/app/pages/ddns/content.json +++ b/docker/routlin-dash/app/pages/ddns/content.json @@ -111,7 +111,7 @@ "render": "tag_list" }, { - "label": "Status", + "label": "Rule State", "field": "enabled", "render": "badge_enabled_disabled" }, diff --git a/docker/routlin-dash/app/pages/dhcpreservations/content.json b/docker/routlin-dash/app/pages/dhcpreservations/content.json index b286c72..33131ea 100644 --- a/docker/routlin-dash/app/pages/dhcpreservations/content.json +++ b/docker/routlin-dash/app/pages/dhcpreservations/content.json @@ -59,7 +59,7 @@ "render": "badge_yes_no" }, { - "label": "Rule Status", + "label": "Rule State", "field": "enabled", "render": "badge_enabled_disabled" } diff --git a/docker/routlin-dash/app/pages/hostoverrides/content.json b/docker/routlin-dash/app/pages/hostoverrides/content.json index 5745831..b62f43a 100644 --- a/docker/routlin-dash/app/pages/hostoverrides/content.json +++ b/docker/routlin-dash/app/pages/hostoverrides/content.json @@ -34,7 +34,7 @@ "class": "col-mono" }, { - "label": "Status", + "label": "Rule State", "field": "enabled", "render": "badge_enabled_disabled" } diff --git a/docker/routlin-dash/app/pages/intervlan/content.json b/docker/routlin-dash/app/pages/intervlan/content.json index 4089933..a64b94b 100644 --- a/docker/routlin-dash/app/pages/intervlan/content.json +++ b/docker/routlin-dash/app/pages/intervlan/content.json @@ -49,7 +49,7 @@ "class": "col-mono col-narrow" }, { - "label": "Status", + "label": "Rule State", "field": "enabled", "render": "badge_enabled_disabled" } diff --git a/docker/routlin-dash/app/pages/networklayout/action.py b/docker/routlin-dash/app/pages/networklayout/action.py index 1e1a5aa..e5d6793 100644 --- a/docker/routlin-dash/app/pages/networklayout/action.py +++ b/docker/routlin-dash/app/pages/networklayout/action.py @@ -276,6 +276,8 @@ def vlans_addedit(): else: existing.pop('dhcp_information', None) + 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) if errors: for msg in errors: @@ -338,6 +340,8 @@ def vlans_addedit(): entry['reservations'] = [] vlans.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') errors = validate.validate_config(cfg) if errors: for msg in errors: diff --git a/docker/routlin-dash/app/pages/portforwarding/action.py b/docker/routlin-dash/app/pages/portforwarding/action.py index a03fa67..fc2f7ac 100644 --- a/docker/routlin-dash/app/pages/portforwarding/action.py +++ b/docker/routlin-dash/app/pages/portforwarding/action.py @@ -85,6 +85,8 @@ def addrule_add(): cfg = load_config() 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') errors = validate.validate_config(cfg) if errors: for msg in errors: @@ -116,6 +118,8 @@ def rules_toggle(): old_enabled = items[idx].get('enabled', True) before = copy.deepcopy(items[idx]) items[idx]['enabled'] = not old_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) if errors: for msg in errors: @@ -151,6 +155,8 @@ def rules_edit(): before = copy.deepcopy(items[idx]) items[idx] = entry items[idx]['enabled'] = request.form.get('enabled') == 'on' + 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) if errors: for msg in errors: diff --git a/docker/routlin-dash/app/pages/portforwarding/content.json b/docker/routlin-dash/app/pages/portforwarding/content.json index cec9b88..587640d 100644 --- a/docker/routlin-dash/app/pages/portforwarding/content.json +++ b/docker/routlin-dash/app/pages/portforwarding/content.json @@ -44,7 +44,7 @@ "class": "col-mono" }, { - "label": "Status", + "label": "Rule State", "field": "enabled", "render": "badge_enabled_disabled" } diff --git a/docker/routlin-dash/app/pages/portwrangling/content.json b/docker/routlin-dash/app/pages/portwrangling/content.json index 5ae05a5..7924ea1 100644 --- a/docker/routlin-dash/app/pages/portwrangling/content.json +++ b/docker/routlin-dash/app/pages/portwrangling/content.json @@ -54,7 +54,7 @@ "class": "col-mono" }, { - "label": "Status", + "label": "Rule State", "field": "enabled", "render": "badge_enabled_disabled" } diff --git a/routlin/core.py b/routlin/core.py index debf1ff..a11cc4a 100644 --- a/routlin/core.py +++ b/routlin/core.py @@ -781,7 +781,7 @@ def cmd_apply(data, dry_run=False): timers.install_maint_timer(data) else: timers.remove_timers([timers.MAINT_TIMER_NAME], [timers.MAINT_TIMER_FILE], [timers.MAINT_TIMER_SVC_FILE]) - print("No enabled DDNS providers — timer not installed.") + print("No enabled DDNS providers - timer not installed.") print() print("Boot service ========================================================") diff --git a/routlin/mod_nftables.py b/routlin/mod_nftables.py index d6f283e..737aa76 100644 --- a/routlin/mod_nftables.py +++ b/routlin/mod_nftables.py @@ -404,19 +404,41 @@ def build_nft_config(data, dry_run=False): "", ] + L += [" # Anti-spoofing: drop packets arriving on a VLAN interface with a source IP outside that VLAN's subnet", ""] + for vlan in vlans: + if validation.is_wg(vlan): + continue + iface = validation.derive_interface(vlan, data) + subnet = vlan.get('subnet', '') + mask = vlan.get('subnet_mask', 24) + if subnet: + L.append(f" iif \"{iface}\" ip saddr != {subnet}/{mask} drop # {vlan['name']} anti-spoof") + L.append("") + L.append(" # Allow each VLAN -> WAN (outbound internet)") for vlan in vlans: + if vlan.get('restricted_vlan'): + continue L.append(f" iif \"{validation.derive_interface(vlan, data)}\" oif \"{wan}\" accept # {vlan['name']} -> WAN") L.append("") if container_bridges: L.append(" # Allow VLAN -> Docker bridge forwarding") for vlan in vlans: + if vlan.get('restricted_vlan'): + continue for bridge in container_bridges: L.append(f" iif \"{validation.derive_interface(vlan, data)}\" oif \"{bridge}\" ct state new accept" f" # {vlan['name']} -> {bridge}") L.append("") + restricted = [v for v in vlans if v.get('restricted_vlan')] + if restricted: + L.append(" # Block restricted VLANs -> WAN") + for vlan in restricted: + L.append(f" iif \"{validation.derive_interface(vlan, data)}\" oif \"{wan}\" drop # {vlan['name']} -> WAN (restricted)") + L.append("") + L += [ " # Allow Docker containers -> WAN (outbound internet access)", f" iif != \"{wan}\" oif \"{wan}\" ct state new accept", diff --git a/routlin/mod_validation.py b/routlin/mod_validation.py index e9460aa..bc227df 100644 --- a/routlin/mod_validation.py +++ b/routlin/mod_validation.py @@ -845,6 +845,26 @@ def validate_config(data): nat_check_port(f"{label} dest_port", r.get("dest_port")) nat_check_port(f"{label} nat_port", r.get("nat_port")) nat_check_ip(f"{label} nat_ip", r.get("nat_ip", "")) + if r.get("enabled"): + nat_ip_str = r.get("nat_ip", "") + try: + nat_addr = ipaddress.IPv4Address(nat_ip_str) + for v in _all_vlans: + if not v.get("restricted_vlan"): + continue + try: + vnet = ipaddress.IPv4Network(f"{v['subnet']}/{v['subnet_mask']}", strict=False) + except Exception: + continue + if nat_addr in vnet: + errors.append( + f"Port forwarding rule '{desc}' is enabled but its destination " + f"({nat_ip_str}) is on restricted VLAN '{v['name']}'. " + f"Disable the rule or remove the restricted_vlan flag." + ) + break + except Exception: + pass for r in data.get("inter_vlan_exceptions", []): desc = r.get("description", "?") @@ -930,3 +950,32 @@ def validate_config(data): errors.append(f"{lbl}: '{ip_val}' is not a valid IP, CIDR, or wildcard pattern.") return errors + + +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.""" + restricted_nets = [] + for v in data.get('vlans', []): + if v.get('restricted_vlan'): + try: + restricted_nets.append(ipaddress.IPv4Network(f"{v['subnet']}/{v['subnet_mask']}", strict=False)) + except Exception: + pass + + if not restricted_nets: + return [] + + disabled = [] + for rule in data.get('port_forwarding', []): + if not rule.get('enabled'): + continue + try: + addr = ipaddress.IPv4Address(rule.get('nat_ip', '')) + except Exception: + continue + if any(addr in net for net in restricted_nets): + rule['enabled'] = False + disabled.append(rule.get('description') or rule.get('nat_ip', '?')) + + return disabled