From 33dc378c3c055c5e77d7ef88ed13e1b2d4378a58 Mon Sep 17 00:00:00 2001 From: sjat Date: Mon, 8 Jun 2026 19:39:04 +0200 Subject: [PATCH] feat(vlans): VLAN-aware bridge, ports, mgmt interface (mechanism) Implements Task 7. Deliberate lockout-safe ordering (vlan-filtering LAST) with :if [find] guards that adopt the existing defconf bridge/ports rather than recreating them. Membership Jinja: trunk ports tagged per tagged_vlans, access ports untagged per pvid, bridge/CPU tagged only on the mgmt VLAN; else={set} makes membership declarative. Jinja render validated offline against the placeholder topology. Device run DEFERRED to an on-site session with a recovery channel (remote bench has no serial/WinBox-MAC fallback). Topology stays placeholder. Co-Authored-By: Claude Opus 4.8 (1M context) --- host_vars/crs310-maker.yml | 10 +- .../tasks/vlans.yml | 103 +++++++++++++++++- 2 files changed, 109 insertions(+), 4 deletions(-) diff --git a/host_vars/crs310-maker.yml b/host_vars/crs310-maker.yml index 58a83bf..ef3b848 100644 --- a/host_vars/crs310-maker.yml +++ b/host_vars/crs310-maker.yml @@ -23,7 +23,15 @@ switch_ntp_servers: "10.0.99.1" switch_admin_user: "sjat" -# Real VLAN/port topology (EDIT to the makerspace plan when known) +# PLACEHOLDER VLAN/port topology — vlans.yml is correct mechanism, but these IDs +# and the per-port map are NOT the real makerspace plan. Replace with the real +# VLAN ids + full ether1-8/sfp map before any on-site VLAN run. Notes: +# - mode: access -> untagged member of `pvid`; mode: trunk -> tagged member of +# each id in `tagged_vlans`, with `pvid` as the native (untagged) VLAN. +# - trunk pvid: 1 means untagged frames on the uplink land in VLAN 1 (unused in a +# hardened design). Decide deliberately whether the uplink should carry any +# untagged traffic; set pvid to an intended native VLAN or leave 1 as a dead end. +# - the bridge (CPU) is tagged ONLY on switch_mgmt_vlan_id (see vlans.yml). switch_vlans: - {id: 99, name: "mgmt"} - {id: 10, name: "members"} diff --git a/roles/makerfloss.mikrotik_switch/tasks/vlans.yml b/roles/makerfloss.mikrotik_switch/tasks/vlans.yml index fe0a9a3..a95dfd9 100644 --- a/roles/makerfloss.mikrotik_switch/tasks/vlans.yml +++ b/roles/makerfloss.mikrotik_switch/tasks/vlans.yml @@ -1,4 +1,101 @@ --- -- name: Placeholder - ansible.builtin.debug: - msg: "not yet implemented" +# VLAN-aware bridge, access/trunk ports, and the management VLAN interface. +# +# ORDERING IS DELIBERATE (lockout safety): bridge (filtering OFF) -> ports+pvid -> +# VLAN membership -> mgmt VLAN iface + IP -> default route -> vlan-filtering LAST. +# Enabling vlan-filtering is the point at which a wrong management path strands the +# switch, so it runs only after the mgmt VLAN/IP exist. Keep a serial/WinBox-MAC +# recovery channel open when running this against a live device. +# +# DEFCONF NOTE: on a factory-default CRS310 the `bridge` already exists with every +# port as an untagged member and the management IP sits directly on `bridge` +# (192.168.88.1/24). This role does NOT delete that legacy IP — after you have +# proven reachability on the new mgmt VLAN, remove the old bridge IP on-site so the +# device is reachable only via vlan-mgmt. The guards below adopt the existing bridge +# and ports rather than recreating them. +# +# Idempotency comes from the RouterOS `:if [find]` guards (changed_when: false). + +- name: Create VLAN-aware bridge (filtering off initially) + community.routeros.command: + commands: + - >- + :if ([:len [/interface/bridge/find name="{{ switch_bridge_name }}"]] = 0) + do={ /interface/bridge/add name="{{ switch_bridge_name }}" + vlan-filtering=no } + changed_when: false + +- name: Add or adopt bridge ports and set their PVID + community.routeros.command: + commands: + - >- + :if ([:len [/interface/bridge/port/find interface="{{ item.interface }}"]] = 0) + do={ /interface/bridge/port/add bridge="{{ switch_bridge_name }}" + interface="{{ item.interface }}" pvid={{ item.pvid }} } + else={ /interface/bridge/port/set [find interface="{{ item.interface }}"] + pvid={{ item.pvid }} } + loop: "{{ switch_bridge_ports }}" + loop_control: + label: "{{ item.interface }} (pvid {{ item.pvid }})" + changed_when: false + +# tagged = trunk ports whose tagged_vlans include this id, plus the bridge (CPU) +# ONLY on the management VLAN so the vlan-mgmt interface is reachable. +# untagged = access ports whose pvid equals this id. +- name: Define bridge VLANs (tagged/untagged membership) + community.routeros.command: + commands: + - >- + :local tagged "{{ ((switch_bridge_ports + | selectattr('mode', 'equalto', 'trunk') + | selectattr('tagged_vlans', 'defined') + | selectattr('tagged_vlans', 'contains', item.id) + | map(attribute='interface') | list) + + ([switch_bridge_name] if item.id == switch_mgmt_vlan_id else [])) + | join(',') }}"; + :local untagged "{{ switch_bridge_ports + | selectattr('mode', 'equalto', 'access') + | selectattr('pvid', 'equalto', item.id) + | map(attribute='interface') | list | join(',') }}"; + :if ([:len [/interface/bridge/vlan/find vlan-ids={{ item.id }}]] = 0) + do={ /interface/bridge/vlan/add bridge="{{ switch_bridge_name }}" + vlan-ids={{ item.id }} tagged=$tagged untagged=$untagged } + else={ /interface/bridge/vlan/set [find vlan-ids={{ item.id }}] + tagged=$tagged untagged=$untagged } + loop: "{{ switch_vlans }}" + loop_control: + label: "vlan {{ item.id }} ({{ item.name }})" + changed_when: false + +- name: Create the management VLAN interface + community.routeros.command: + commands: + - >- + :if ([:len [/interface/vlan/find name="vlan-mgmt"]] = 0) + do={ /interface/vlan/add name="vlan-mgmt" + interface="{{ switch_bridge_name }}" vlan-id={{ switch_mgmt_vlan_id }} } + changed_when: false + +- name: Assign the management IP address + community.routeros.command: + commands: + - >- + :if ([:len [/ip/address/find interface="vlan-mgmt"]] = 0) + do={ /ip/address/add address="{{ switch_mgmt_address }}" + interface="vlan-mgmt" } + changed_when: false + +- name: Set the default gateway route + community.routeros.command: + commands: + - >- + :if ([:len [/ip/route/find dst-address="0.0.0.0/0"]] = 0) + do={ /ip/route/add dst-address=0.0.0.0/0 + gateway="{{ switch_mgmt_gateway }}" } + changed_when: false + +- name: Enable VLAN filtering (LAST — prove mgmt reachability first) + community.routeros.command: + commands: + - /interface/bridge/set "{{ switch_bridge_name }}" vlan-filtering=yes + changed_when: false