From 8176a6799f9ab4e0584d5e3567dac5056e82c198 Mon Sep 17 00:00:00 2001 From: Joshua Gilman Date: Fri, 16 Oct 2026 09:08:55 -0700 Subject: [PATCH 1/2] feat(network): add services VLAN 50 Routed 10.10.50.0/24 for long-lived service VMs (first: media01 on nas01), off both management VLAN 10 and the OVN-owned VLAN 40 uplink. - gw01: eth1 vif 50 10.10.50.1/24, SERVICES_FORWARD/SERVICES_LOCAL, management-to-services accept, DHCP LAB_SERVICES, CoreDNS binds. - sw-core01: VLAN 50 tagged on sfp-sfpplus8 (gw01) and sfp-sfpplus7 (nas01). Apply sw-core01 first, then gw01. --- routeros/sw-core01/README.md | 5 ++ routeros/sw-core01/bridge.tf | 9 ++ src/networking_vyos/policy.py | 1 + src/networking_vyos/verify.py | 7 ++ tests/conftest.py | 4 + tests/test_policy.py | 15 ++++ tests/test_sync_order.py | 8 ++ vyos/gw01/assets/coredns/Corefile | 4 +- vyos/gw01/config.boot.tmpl | 142 +++++++++++++++++++++++++++++- 9 files changed, 191 insertions(+), 4 deletions(-) diff --git a/routeros/sw-core01/README.md b/routeros/sw-core01/README.md index 4e2a96e..3181065 100644 --- a/routeros/sw-core01/README.md +++ b/routeros/sw-core01/README.md @@ -111,6 +111,11 @@ VLAN 30 is L2-only. It is tagged on `bond-lab01`–`03` and `sfp-sfpplus7` (nas01). It is not tagged on `bridge-lab` or the gw01 trunk (`sfp-sfpplus8`). +VLAN 50 (services) is routed on gw01 (`10.10.50.1/24`). It is tagged on +`sfp-sfpplus8` (gw01 trunk) and `sfp-sfpplus7` (nas01) only, not on the +lab bonds or `bridge-lab`. Apply it on sw-core01 before gw01 so the +gateway's `eth1.50` has a path to nas01 as soon as it comes up. + ## Notes - Ethernet names stay at factory (`ether1`, `sfp-sfpplus1`–`8`). Roles and diff --git a/routeros/sw-core01/bridge.tf b/routeros/sw-core01/bridge.tf index 9a3a62f..ebb635b 100644 --- a/routeros/sw-core01/bridge.tf +++ b/routeros/sw-core01/bridge.tf @@ -69,3 +69,12 @@ resource "routeros_interface_bridge_vlan" "vlan40" { routeros_interface_ethernet.sfp_sfpplus8.name, ] } + +resource "routeros_interface_bridge_vlan" "vlan50" { + bridge = routeros_interface_bridge.lab.name + vlan_ids = ["50"] + tagged = [ + routeros_interface_ethernet.sfp_sfpplus7.name, + routeros_interface_ethernet.sfp_sfpplus8.name, + ] +} diff --git a/src/networking_vyos/policy.py b/src/networking_vyos/policy.py index 4d24fcb..d5d2b42 100644 --- a/src/networking_vyos/policy.py +++ b/src/networking_vyos/policy.py @@ -27,6 +27,7 @@ (r"\b10\.0\.0\.2/30\b", "transit address"), (r"\b10\.10\.10\.1/24\b", "management gateway"), (r"\b10\.10\.40\.1/24\b", "sandbox gateway"), + (r"\b10\.10\.50\.1/24\b", "services gateway"), (r"\b10\.10\.70\.1/24\b", "OOB gateway"), (r"\bnext-hop\s+10\.0\.0\.1\b", "default route"), (r"\btailscale\b", "Tailscale"), diff --git a/src/networking_vyos/verify.py b/src/networking_vyos/verify.py index 7b111a9..4f802d3 100644 --- a/src/networking_vyos/verify.py +++ b/src/networking_vyos/verify.py @@ -14,6 +14,7 @@ ("set interfaces bridge br70 address", "10.10.70.1/24"), ("set interfaces ethernet eth1 vif 10",), ("set interfaces ethernet eth1 vif 40",), + ("set interfaces ethernet eth1 vif 50 address", "10.10.50.1/24"), ("set interfaces ethernet eth3 vif 10",), ("set interfaces ethernet eth3 vif 70",), ("set interfaces bridge br10 member interface eth3.10",), @@ -24,6 +25,7 @@ ("set service dhcp-server listen-interface", "br10"), ("set service dhcp-server listen-interface", "br40"), ("set service dhcp-server listen-interface", "br70"), + ("set service dhcp-server listen-interface", "eth1.50"), ("set container name tailscale environment TS_ROUTES value", "10.10.0.0/16"), ("set container name tailscale",), ("set container name coredns-glab-lol",), @@ -37,6 +39,8 @@ ("set firewall ipv4 forward filter rule 120 jump-target", "SANDBOX_FORWARD"), ("set firewall ipv4 forward filter rule 130 inbound-interface name", "br70"), ("set firewall ipv4 forward filter rule 130 jump-target", "OOB_FORWARD"), + ("set firewall ipv4 forward filter rule 150 inbound-interface name", "eth1.50"), + ("set firewall ipv4 forward filter rule 150 jump-target", "SERVICES_FORWARD"), ("set firewall ipv4 input filter rule 100 inbound-interface name", "eth0"), ("set firewall ipv4 input filter rule 100 jump-target", "WAN_LOCAL"), ("set firewall ipv4 input filter rule 110 inbound-interface name", "br10"), @@ -45,6 +49,8 @@ ("set firewall ipv4 input filter rule 120 jump-target", "SANDBOX_LOCAL"), ("set firewall ipv4 input filter rule 130 inbound-interface name", "br70"), ("set firewall ipv4 input filter rule 130 jump-target", "OOB_LOCAL"), + ("set firewall ipv4 input filter rule 150 inbound-interface name", "eth1.50"), + ("set firewall ipv4 input filter rule 150 jump-target", "SERVICES_LOCAL"), ) RETIRED_FRAGMENTS = ( @@ -104,6 +110,7 @@ def verify_operational_state( ("eth0", "10.0.0.2/30"), ("br10", "10.10.10.1/24"), ("br40", "10.10.40.1/24"), + ("eth1.50", "10.10.50.1/24"), ("br70", "10.10.70.1/24"), ) for fragments in required_interfaces: diff --git a/tests/conftest.py b/tests/conftest.py index 07740bb..00ed677 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -15,6 +15,9 @@ } vif 40 { } + vif 50 { + address 10.10.50.1/24 + } } ethernet eth3 { vif 10 { @@ -59,6 +62,7 @@ listen-interface br10 listen-interface br40 listen-interface br70 + listen-interface eth1.50 } } container { diff --git a/tests/test_policy.py b/tests/test_policy.py index 1d088ab..8846747 100644 --- a/tests/test_policy.py +++ b/tests/test_policy.py @@ -45,6 +45,21 @@ def test_sandbox_to_management_is_allowed() -> None: }""" in load_template() +def test_services_to_management_is_blocked() -> None: + from networking_vyos.render import load_template + + assert """rule 10 { + action drop + description "Block new connections to management" + destination { + group { + network-group LAB_MGMT + } + } + state new + }""" in load_template() + + def test_invented_footer_is_rejected() -> None: with pytest.raises(ToolError, match="must not invent"): validate_template(VALID_TEMPLATE + '\n// vyos-config-version: "guessed@1"\n') diff --git a/tests/test_sync_order.py b/tests/test_sync_order.py index 0906ac7..0885cc6 100644 --- a/tests/test_sync_order.py +++ b/tests/test_sync_order.py @@ -84,6 +84,7 @@ def test_verify_commands_require_accepted_state_and_reject_retired() -> None: "set interfaces bridge br70 address '10.10.70.1/24'", "set interfaces ethernet eth1 vif 10", "set interfaces ethernet eth1 vif 40", + "set interfaces ethernet eth1 vif 50 address '10.10.50.1/24'", "set interfaces ethernet eth3 vif 10", "set interfaces ethernet eth3 vif 70", "set interfaces bridge br10 member interface eth3.10", @@ -94,6 +95,7 @@ def test_verify_commands_require_accepted_state_and_reject_retired() -> None: "set service dhcp-server listen-interface br10", "set service dhcp-server listen-interface br40", "set service dhcp-server listen-interface br70", + "set service dhcp-server listen-interface eth1.50", "set container name tailscale environment TS_ROUTES value '10.10.0.0/16'", "set container name tailscale image docker.io/tailscale/tailscale:v1.96.5", "set container name coredns-glab-lol", @@ -107,6 +109,8 @@ def test_verify_commands_require_accepted_state_and_reject_retired() -> None: "set firewall ipv4 forward filter rule 120 jump-target SANDBOX_FORWARD", "set firewall ipv4 forward filter rule 130 inbound-interface name br70", "set firewall ipv4 forward filter rule 130 jump-target OOB_FORWARD", + "set firewall ipv4 forward filter rule 150 inbound-interface name eth1.50", + "set firewall ipv4 forward filter rule 150 jump-target SERVICES_FORWARD", "set firewall ipv4 input filter rule 100 inbound-interface name eth0", "set firewall ipv4 input filter rule 100 jump-target WAN_LOCAL", "set firewall ipv4 input filter rule 110 inbound-interface name br10", @@ -115,6 +119,8 @@ def test_verify_commands_require_accepted_state_and_reject_retired() -> None: "set firewall ipv4 input filter rule 120 jump-target SANDBOX_LOCAL", "set firewall ipv4 input filter rule 130 inbound-interface name br70", "set firewall ipv4 input filter rule 130 jump-target OOB_LOCAL", + "set firewall ipv4 input filter rule 150 inbound-interface name eth1.50", + "set firewall ipv4 input filter rule 150 jump-target SERVICES_LOCAL", ] verify_commands(commands, stage="verification") with pytest.raises(ToolError, match="retired state"): @@ -132,6 +138,7 @@ def test_operational_verification_requires_routes_interfaces_and_containers() -> "eth0 10.0.0.2/30 up\n" "br10 10.10.10.1/24 up\n" "br40 10.10.40.1/24 up\n" + "eth1.50 10.10.50.1/24 up\n" "br70 10.10.70.1/24 up" ), routes="S>* 0.0.0.0/0 [1/0] via 10.0.0.1, eth0", @@ -144,6 +151,7 @@ def test_operational_verification_requires_routes_interfaces_and_containers() -> "eth0 10.0.0.2/30 up\n" "br10 10.10.10.1/24 up\n" "br40 10.10.40.1/24 up\n" + "eth1.50 10.10.50.1/24 up\n" "br70 10.10.70.1/24 up" ), routes="S>* 0.0.0.0/0 [1/0] via 10.0.0.1, eth0", diff --git a/vyos/gw01/assets/coredns/Corefile b/vyos/gw01/assets/coredns/Corefile index 3b62b03..3c7023a 100644 --- a/vyos/gw01/assets/coredns/Corefile +++ b/vyos/gw01/assets/coredns/Corefile @@ -1,5 +1,5 @@ glab.lol:53 { - bind 10.10.10.54 10.10.10.1 10.10.40.1 10.10.70.1 + bind 10.10.10.54 10.10.10.1 10.10.40.1 10.10.50.1 10.10.70.1 errors log file /zones/glab.lol.zone glab.lol { @@ -8,7 +8,7 @@ glab.lol:53 { } .:53 { - bind 10.10.10.1 10.10.40.1 10.10.70.1 + bind 10.10.10.1 10.10.40.1 10.10.50.1 10.10.70.1 cache 300 errors forward . 1.1.1.1 8.8.8.8 diff --git a/vyos/gw01/config.boot.tmpl b/vyos/gw01/config.boot.tmpl index a00f639..920a287 100644 --- a/vyos/gw01/config.boot.tmpl +++ b/vyos/gw01/config.boot.tmpl @@ -5,7 +5,7 @@ * * Interface mapping: * eth0 SFP+ 1 routed transit 10.0.0.2/30 to rtr01 - * eth1 SFP+ 2 802.1Q trunk VLANs 10,40 to sw-core01 + * eth1 SFP+ 2 802.1Q trunk VLANs 10,40,50 to sw-core01 * eth2 Port 2 untagged VLAN 40 to sandbox01 * eth3 Port 1 802.1Q trunk VLANs 10,70 to sw-mgmt01 * eth4 Port 3 untagged VLAN 70 to pikvm01 @@ -16,6 +16,9 @@ * br40 = eth1.40 + eth2 * br70 = eth3.70 + eth4 + eth5 * + * VLAN 50 (services) has no untagged access port, so it is routed + * directly on eth1.50 without a bridge. + * * The renderer replaces the SSH public-key sentinel in memory and appends * component metadata captured from the live /config/config.boot. The * password hash is applied separately through pyinfra-vyos so a load @@ -42,6 +45,9 @@ firewall { network-group LAB_SANDBOX { network 10.10.40.0/24 } + network-group LAB_SERVICES { + network 10.10.50.0/24 + } network-group LAB_OOB { network 10.10.70.0/24 } @@ -109,6 +115,14 @@ firewall { } } } + rule 150 { + action jump + description "Services inbound" + inbound-interface { + name eth1.50 + } + jump-target SERVICES_FORWARD + } } } input { @@ -168,6 +182,14 @@ firewall { } } } + rule 150 { + action jump + description "Services to local" + inbound-interface { + name eth1.50 + } + jump-target SERVICES_LOCAL + } } } name MGMT_FORWARD { @@ -202,6 +224,15 @@ firewall { } } } + rule 35 { + action accept + description "Allow management to services" + destination { + group { + network-group LAB_SERVICES + } + } + } rule 40 { action accept description "Allow internet egress" @@ -392,6 +423,94 @@ firewall { protocol udp } } + name SERVICES_FORWARD { + default-action drop + default-log + description "Forward from services" + rule 10 { + action drop + description "Block new connections to management" + destination { + group { + network-group LAB_MGMT + } + } + state new + } + rule 20 { + action drop + description "Block new connections to OOB" + destination { + group { + network-group LAB_OOB + } + } + state new + } + rule 30 { + action drop + description "Block new connections to sandbox" + destination { + group { + network-group LAB_SANDBOX + } + } + state new + } + rule 40 { + action drop + description "Block new connections to home" + destination { + group { + network-group HOME_NETWORKS + } + } + state new + } + rule 50 { + action accept + description "Allow internet egress" + destination { + group { + network-group !RFC1918 + } + } + } + } + name SERVICES_LOCAL { + default-action drop + default-log + description "Local services from services" + rule 10 { + action accept + description "Allow ICMP" + protocol icmp + } + rule 20 { + action accept + description "Allow DNS UDP" + destination { + port 53 + } + protocol udp + } + rule 30 { + action accept + description "Allow DNS TCP" + destination { + port 53 + } + protocol tcp + } + rule 40 { + action accept + description "Allow DHCP" + destination { + port 67 + } + protocol udp + } + } name TAILSCALE_FORWARD { default-action drop default-log @@ -626,7 +745,7 @@ interfaces { } } ethernet eth1 { - description "TRUNK - sw-core01 VLANs 10,40" + description "TRUNK - sw-core01 VLANs 10,40,50" offload { gro gso @@ -639,6 +758,10 @@ interfaces { vif 40 { description "LAB_SANDBOX - Bridge member (br40)" } + vif 50 { + address 10.10.50.1/24 + description "LAB_SERVICES - Services" + } } ethernet eth2 { description "LAN - sandbox01 untagged VLAN 40" @@ -734,6 +857,7 @@ service { listen-interface br10 listen-interface br40 listen-interface br70 + listen-interface eth1.50 shared-network-name LAB_MGMT { subnet 10.10.10.0/24 { lease 86400 @@ -788,6 +912,20 @@ service { subnet-id 3 } } + shared-network-name LAB_SERVICES { + subnet 10.10.50.0/24 { + lease 86400 + option { + default-router 10.10.50.1 + name-server 10.10.50.1 + } + range 0 { + start 10.10.50.200 + stop 10.10.50.250 + } + subnet-id 4 + } + } } ntp { allow-client { From 298c8a3e793e633814d0db44b3de654f3f721b3f Mon Sep 17 00:00:00 2001 From: Joshua Gilman Date: Fri, 16 Oct 2026 09:42:17 -0700 Subject: [PATCH 2/2] fix(coredns): reload the Corefile when the sync stages a new one The VLAN 50 sync staged a Corefile binding 10.10.50.1, but the running container kept its old listeners until a manual restart. --- vyos/gw01/assets/coredns/Corefile | 2 ++ 1 file changed, 2 insertions(+) diff --git a/vyos/gw01/assets/coredns/Corefile b/vyos/gw01/assets/coredns/Corefile index 3c7023a..6cc67fa 100644 --- a/vyos/gw01/assets/coredns/Corefile +++ b/vyos/gw01/assets/coredns/Corefile @@ -2,6 +2,7 @@ glab.lol:53 { bind 10.10.10.54 10.10.10.1 10.10.40.1 10.10.50.1 10.10.70.1 errors log + reload file /zones/glab.lol.zone glab.lol { reload 30s } @@ -10,6 +11,7 @@ glab.lol:53 { .:53 { bind 10.10.10.1 10.10.40.1 10.10.50.1 10.10.70.1 cache 300 + reload errors forward . 1.1.1.1 8.8.8.8 log