diff --git a/CLAUDE.md b/CLAUDE.md index d0ba127..d09b87e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -16,7 +16,8 @@ conventions this repo copies); independent repo on `forgejo.makerfloss.eu`. - `inventories/prod/hosts.yml` — group `mikrotik`, host `crs310-maker` - `group_vars/mikrotik.yml` — connection vars + `switch_*_enabled` flags - `group_vars/mikrotik.vault.yml` — encrypted password (excluded from linters) -- `host_vars/crs310-maker.yml` — device facts, real addressing, VLAN/port map +- `host_vars/crs310-maker.yml` — device facts, real addressing, VLAN/port map, operators +- `files/operators/*.pub` — operator public keys (public, safe to commit) - `roles/makerfloss.mikrotik_switch/` — one role, per-domain task files gated by flags - `play_switch.yml` (day-2), `play_bootstrap.yml` (first contact), `play_backup.yml` - `docs/` — field guide, design spec, implementation plan @@ -45,9 +46,11 @@ ssh-keygen -R '[127.0.0.1]:2222' # if the tunnel overlay). It superseded the old `-J kuku … sjat@10.8.0.4` hop; that path is dead. - `ansible_user: sjat` already comes from `host_vars`, so don't pass it on the CLI. - **Every operator seat needs its own key on the device** — key auth is the only way in - (see Rules). `ubongo`'s `claude@ubongo` key was added 2026-09-01; a new seat repeats: - `scp -O key.pub sjat@192.168.88.1:` then - `/user/ssh-keys/import public-key-file=key.pub user=sjat`, and delete the uploaded file. + (see Rules). Don't hand-import: add the person to `switch_operators` in `host_vars`, + drop their `.pub` in `files/operators/`, add a `vault_operator_passwords.` entry, + then `ansible-playbook play_switch.yml --tags users`. The task compares against the + device's `key-owner` (= the key's comment) and uploads only what is missing, so it is + idempotent. Revoke with `/user/ssh-keys/remove [find key-owner="…"]` plus the var. - `mamba` is the mgmt station on **switch port 8** (MGMT VLAN); it must be on port 8 to reach `192.168.88.1`. From a data port it gets `10.2.30.x` and **cannot** reach mgmt. - NM profiles on `mamba` `enp0s31f6`: `crs310-bench` (static `.2`) and `Wired connection 1` @@ -78,6 +81,14 @@ ssh-keygen -R '[127.0.0.1]:2222' # if the tunnel `/ip/ssh always-allow-password-login=no` (the default, and deliberately kept). So `play_bootstrap.yml`'s password is a one-shot for user creation; after the key import the only SSH path is key auth. Never "fix" a failed login by flipping that flag. +- **Operators are users, not extra keys on one account** — one RouterOS user per person + (`switch_operators`), so logins are attributable and revocable one at a time. The + exception is `sjat`, which carries a second key for the `ubongo`/`claude` automation + seat rather than a separate account. +- **Never create a passwordless RouterOS user.** SSH is key-only, but WinBox/console will + accept an empty password, and WinBox is deliberately left enabled for recovery — so + every `switch_operators` entry needs a `vault_operator_passwords` entry. `users.yml` + asserts this before touching the device. - **New work:** branch first, implement, verify (lint + syntax + run-twice), then merge. ## Status / next @@ -86,9 +97,10 @@ Live on the device (2026-06-09): flat L2 switch on `10.2.30.0/24` — **DATA VLA (`ether1` copper uplink + `ether2-7` + SFP+), **isolated MGMT VLAN 99 on `ether8`** (mgmt `192.168.88.1/24`, no gateway/NTP/DNS), `vlan-filtering` on. The mgmt port also serves DHCP (`192.168.88.10-.254`) + the web UI as a makerspace experiment (flags -`switch_web_enabled`, `switch_mgmt_dhcp_enabled`). Default `admin` disabled; login as -`sjat` (key, or vaulted password). All task files + `play_bootstrap`/`play_backup` are -idempotency-verified. Design + cutover runbook: +`switch_web_enabled`, `switch_mgmt_dhcp_enabled`). Default `admin` disabled. Operators +(2026-09-01): `sjat` (keys: mamba seat + `claude@ubongo` automation seat) and `claus` +(`claus@stjerno.dk`), both group `full`, both managed by `switch_operators` in `host_vars`. +All task files + `play_bootstrap`/`play_backup` are idempotency-verified. Design + cutover runbook: `docs/superpowers/specs/2026-06-09-crs310-flat-mgmtvlan-design.md`. Next: SFP+ 10G uplink and real VLAN segmentation once connectors + a VLAN plan are ready. diff --git a/files/operators/claude-ubongo.pub b/files/operators/claude-ubongo.pub new file mode 100644 index 0000000..d952b3d --- /dev/null +++ b/files/operators/claude-ubongo.pub @@ -0,0 +1 @@ +ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIKSx1TFLJ9H8vCe5ZJSu7MYmAiH0/OC8evloQjGR0Bqw claude@ubongo diff --git a/files/operators/claus-stjerno.pub b/files/operators/claus-stjerno.pub new file mode 100644 index 0000000..e46a796 --- /dev/null +++ b/files/operators/claus-stjerno.pub @@ -0,0 +1 @@ +ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAID0J3ugdd8GF8bGULk6O+tNKinNsil0ks7cLaStVUQR4 claus@stjerno.dk diff --git a/files/operators/sjat-mamba.pub b/files/operators/sjat-mamba.pub new file mode 100644 index 0000000..e659555 --- /dev/null +++ b/files/operators/sjat-mamba.pub @@ -0,0 +1 @@ +ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIA8XUj5gM5Enlt7Bh6PpAyZAGtu4kG/Cq6rUvVH55+tM sjat diff --git a/group_vars/mikrotik.vault.yml b/group_vars/mikrotik.vault.yml index ce193b6..0a67cea 100644 --- a/group_vars/mikrotik.vault.yml +++ b/group_vars/mikrotik.vault.yml @@ -1,8 +1,25 @@ $ANSIBLE_VAULT;1.2;AES256;makerfloss -65633363353761306465316563336137323966313330313238633661313938633939653330383561 -3936363934636563383032646631336464363534613366360a666162626432303066383863376530 -34616565613837326661323565306263636661396637313263613433366438653934383266343664 -6538656135366336630a303536663139396364643539636532616165386533616635313166366564 -31303762313063353734666632623262616562383833353765376263333732386336616336383934 -61623334666230356661636433613633653439353662393730313663656664663962346139666639 -396431396664316165663030633732656632 +32653033396232323333353737313334656237356163386433626333646461376534396131636134 +6632363938393232613364383937643631303934383464380a316537663466383735363633316238 +33363930316631643666383266613936393031616261316263363234306631343736643265393663 +6430363337326230640a303164306430376666626432303838316134663363656631386330613831 +32623635333964613033353733653066623863616536633861643763613532373661376331653065 +33313630313561343561643262613962343366346631353865633462366532643739396330346466 +35636537396137373432353036356536333931396537356536353863653435323431633639343932 +65356531373032373764643664643866623236333162323335353431623165666537653234393564 +35313235623362636337373138616433626130313535613831363431316366626439306666643734 +65623861323966356435656637333632306463393934303065636337373163373639326364343466 +38336230313634353330353162313064646338623436366263386364303239366636373064663662 +66636161343539656331633064373965626434623066333036383030383161363263633261376562 +38326661393161643132666337366231633338633634633165643130343461623130383163363865 +35613837353263666536316337363965396138656461386265623132333236616635306137333765 +66633334356231303565616535353335366232623631323733623531633564303633313030656239 +64323165396634353834643236303334303234633739616561336635373133343935303135313636 +34613932336338636163663531383665356662633465333762383063393138383035653235663838 +38643166333433346632396666336137656166656130333237303237333433356263323638363664 +37363566353762626238343866336239633461613536333834623433356532333739663665336262 +31626536336464613666353732343434616464663135633535313864653032383537386662653834 +30663832336366333035663163646334356533356339323262383538386338353961323235363165 +33616338303231323463376165633437653362393664393033353236333635346430373761613862 +37383338366238616335333632666134306663663236366331613261396363373866636665313161 +35646239323236353036 diff --git a/host_vars/crs310-maker.yml b/host_vars/crs310-maker.yml index aef12c0..ace0df9 100644 --- a/host_vars/crs310-maker.yml +++ b/host_vars/crs310-maker.yml @@ -43,6 +43,21 @@ switch_mgmt_dhcp_network: "192.168.88.0/24" switch_admin_user: "sjat" +# ----- Operators (day-2 source of truth) ----- +# One entry per person; `pubkey_files` are the seats allowed to log in as that user. +# Adding a person = add an entry + their .pub + a vault_operator_passwords entry, +# then `ansible-playbook play_switch.yml --tags users` (twice). +switch_operators: + - name: "sjat" # operator; seats: mamba + ubongo/claude + group: "full" + pubkey_files: + - "files/operators/sjat-mamba.pub" + - "files/operators/claude-ubongo.pub" + - name: "claus" # added 2026-09-01 + group: "full" + pubkey_files: + - "files/operators/claus-stjerno.pub" + # ----- VLANs + per-port map (all untagged access; no trunks) ----- # DATA = flat 10.2.30.0/24 (uplink + device ports); MGMT = isolated admin VLAN. switch_vlans: diff --git a/play_switch.yml b/play_switch.yml index 393764a..f958e61 100644 --- a/play_switch.yml +++ b/play_switch.yml @@ -2,5 +2,10 @@ - name: Configure MikroTik switches (day-2, key auth) hosts: mikrotik gather_facts: false + # Operator passwords (users.yml) are vaulted; group_vars/mikrotik.vault.yml does not + # match the group-name convention, so load it explicitly. Decrypted via the makerfloss + # vault id in ansible.cfg — no --ask-vault-pass needed. + vars_files: + - group_vars/mikrotik.vault.yml roles: - makerfloss.mikrotik_switch diff --git a/roles/makerfloss.mikrotik_switch/defaults/main.yml b/roles/makerfloss.mikrotik_switch/defaults/main.yml index d0685a3..a407132 100644 --- a/roles/makerfloss.mikrotik_switch/defaults/main.yml +++ b/roles/makerfloss.mikrotik_switch/defaults/main.yml @@ -25,11 +25,16 @@ switch_mgmt_dhcp_pool: "" # e.g. "192.168.88.10-192.168.88.254" switch_mgmt_dhcp_network: "" # e.g. "192.168.88.0/24" # ----- Users ----- +# Bootstrap only (play_bootstrap.yml, first contact over password auth). switch_admin_user: "sjat" switch_admin_group: "full" switch_admin_ssh_pubkey_file: "~/.ssh/id_ed25519.pub" switch_disable_default_admin: true +# Day-2 operator accounts. Real list lives in host_vars; each name needs a matching +# vault_operator_passwords entry. `pubkey_files` are repo-relative paths. +switch_operators: [] + # ----- VLAN / bridge / ports (PLACEHOLDER example) ----- # Real topology is defined in host_vars/.yml. switch_bridge_name: "bridge" diff --git a/roles/makerfloss.mikrotik_switch/tasks/users.yml b/roles/makerfloss.mikrotik_switch/tasks/users.yml index 189898b..d88ba5a 100644 --- a/roles/makerfloss.mikrotik_switch/tasks/users.yml +++ b/roles/makerfloss.mikrotik_switch/tasks/users.yml @@ -1,15 +1,87 @@ --- -# Ensure the named admin user exists and (optionally) disable the built-in `admin`. -# The operator SSH key is imported once by play_bootstrap.yml; day-2 only guarantees -# the user is present and the default account is hardened. Idempotency comes from the -# RouterOS `:if [find]` guards, so `changed_when: false` is correct here. +# Operator accounts and their SSH keys. +# +# `switch_operators` (host_vars) is the source of truth: one entry per person/seat, +# each with the group it belongs to and the public keys that may log in as it. The +# role only holds mechanism. +# +# Idempotency: the device is queried FIRST and only genuinely missing keys are +# uploaded — an unconditional `net_put` would report changed on every run. RouterOS +# reports a key's comment field as `key-owner`, which is what we match on. +# +# Passwords: a RouterOS user with no password can still be reached over WinBox/console +# without one, so operators are created with a vaulted password even though SSH is +# key-only (`/ip/ssh always-allow-password-login=no`). See CLAUDE.md. -- name: Ensure named admin user exists +- name: Assert every operator has a vaulted password + ansible.builtin.assert: + that: switch_operators | map(attribute='name') | difference(vault_operator_passwords | default({}) | list) | length == 0 + fail_msg: >- + Every entry in switch_operators needs a vault_operator_passwords. entry — + a passwordless RouterOS user is reachable over WinBox/console without one. + Missing: {{ switch_operators | map(attribute='name') + | difference(vault_operator_passwords | default({}) | list) | join(', ') }} + quiet: true + +- name: Read the SSH keys currently installed on the device + community.routeros.command: + commands: + - /user/ssh-keys/print detail + register: switch_ssh_keys_raw + changed_when: false + +- name: Build the set of user/key-owner pairs already present + ansible.builtin.set_fact: + switch_ssh_keys_present: >- + {{ switch_ssh_keys_raw.stdout[0] + | regex_findall('user=(\S+).*?key-owner="([^"]*)"') + | map('join', '|') | list }} + +- name: Ensure each operator account exists in the right group community.routeros.command: commands: - >- - :if ([:len [/user find name="{{ switch_admin_user }}"]] = 0) do={ - /user add name="{{ switch_admin_user }}" group="{{ switch_admin_group }}" } + :if ([:len [/user find name="{{ item.name }}"]] = 0) + do={ /user add name="{{ item.name }}" group="{{ item.group }}" + password="{{ vault_operator_passwords[item.name] }}" } + else={ :if ([/user get [find name="{{ item.name }}"] group] != "{{ item.group }}") + do={ /user set [find name="{{ item.name }}"] group="{{ item.group }}" } } + loop: "{{ switch_operators }}" + loop_control: + label: "{{ item.name }}" + no_log: true + changed_when: false + +- name: Upload the operator public keys that are not on the device yet + ansible.netcommon.net_put: + src: "{{ item.1 }}" + dest: "{{ item.1 | basename }}" + loop: "{{ switch_operators | subelements('pubkey_files') }}" + loop_control: + label: "{{ item.0.name }} <- {{ item.1 | basename }}" + when: item.0.name ~ '|' ~ (lookup('file', item.1).split() | last) not in switch_ssh_keys_present + +- name: Import the newly uploaded operator public keys + community.routeros.command: + commands: + - >- + /user/ssh-keys/import public-key-file="{{ item.1 | basename }}" + user="{{ item.0.name }}" + loop: "{{ switch_operators | subelements('pubkey_files') }}" + loop_control: + label: "{{ item.0.name }} <- {{ item.1 | basename }}" + when: item.0.name ~ '|' ~ (lookup('file', item.1).split() | last) not in switch_ssh_keys_present + changed_when: true + +- name: Remove the uploaded key files from the switch flash + community.routeros.command: + commands: + - >- + :if ([:len [/file find name="{{ item.1 | basename }}"]] > 0) + do={ /file remove [find name="{{ item.1 | basename }}"] } + loop: "{{ switch_operators | subelements('pubkey_files') }}" + loop_control: + label: "{{ item.1 | basename }}" changed_when: false - name: Disable the default admin user