feat(users): model operators as accounts; add claus

Replace the single `switch_admin_user` day-2 task with a `switch_operators`
list in host_vars: one RouterOS user per person, each with the public keys
that may log in as it. Logins become attributable and revocable per person
instead of accreting as extra keys on `sjat`.

Adds claus@stjerno.dk as user `claus` (group full), and codifies the two
keys already on the device (sjat's mamba seat, the claude@ubongo automation
seat that was hand-imported earlier today).

Two things the mechanism has to get right:
- Idempotency: query /user/ssh-keys first and upload only missing keys.
  An unconditional net_put reports changed on every run.
- No passwordless accounts: WinBox/console accept an empty password and
  WinBox is deliberately left on for recovery, so each operator gets a
  vaulted password. users.yml asserts one exists before touching anything.

Verified: yamllint, ansible-lint (production), syntax-check, and the play
run twice against crs310-maker — second run changed=0.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EyYJWABgSEHzbjsZGrkxVE
This commit is contained in:
sjat 2026-09-01 20:29:18 +02:00
parent d96ec189d4
commit e6c1651da1
9 changed files with 150 additions and 21 deletions

View file

@ -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.<name>` 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.

View file

@ -0,0 +1 @@
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIKSx1TFLJ9H8vCe5ZJSu7MYmAiH0/OC8evloQjGR0Bqw claude@ubongo

View file

@ -0,0 +1 @@
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAID0J3ugdd8GF8bGULk6O+tNKinNsil0ks7cLaStVUQR4 claus@stjerno.dk

View file

@ -0,0 +1 @@
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIA8XUj5gM5Enlt7Bh6PpAyZAGtu4kG/Cq6rUvVH55+tM sjat

View file

@ -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

View file

@ -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:

View file

@ -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

View file

@ -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/<switch>.yml.
switch_bridge_name: "bridge"

View file

@ -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.<name> 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