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:
parent
d96ec189d4
commit
e6c1651da1
9 changed files with 150 additions and 21 deletions
26
CLAUDE.md
26
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`
|
- `inventories/prod/hosts.yml` — group `mikrotik`, host `crs310-maker`
|
||||||
- `group_vars/mikrotik.yml` — connection vars + `switch_*_enabled` flags
|
- `group_vars/mikrotik.yml` — connection vars + `switch_*_enabled` flags
|
||||||
- `group_vars/mikrotik.vault.yml` — encrypted password (excluded from linters)
|
- `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
|
- `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`
|
- `play_switch.yml` (day-2), `play_bootstrap.yml` (first contact), `play_backup.yml`
|
||||||
- `docs/` — field guide, design spec, implementation plan
|
- `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.
|
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.
|
- `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
|
- **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:
|
(see Rules). Don't hand-import: add the person to `switch_operators` in `host_vars`,
|
||||||
`scp -O key.pub sjat@192.168.88.1:` then
|
drop their `.pub` in `files/operators/`, add a `vault_operator_passwords.<name>` entry,
|
||||||
`/user/ssh-keys/import public-key-file=key.pub user=sjat`, and delete the uploaded file.
|
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
|
- `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.
|
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`
|
- 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
|
`/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
|
`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.
|
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.
|
- **New work:** branch first, implement, verify (lint + syntax + run-twice), then merge.
|
||||||
|
|
||||||
## Status / next
|
## 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`**
|
(`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
|
(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
|
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
|
`switch_web_enabled`, `switch_mgmt_dhcp_enabled`). Default `admin` disabled. Operators
|
||||||
`sjat` (key, or vaulted password). All task files + `play_bootstrap`/`play_backup` are
|
(2026-09-01): `sjat` (keys: mamba seat + `claude@ubongo` automation seat) and `claus`
|
||||||
idempotency-verified. Design + cutover runbook:
|
(`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`.
|
`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.
|
Next: SFP+ 10G uplink and real VLAN segmentation once connectors + a VLAN plan are ready.
|
||||||
|
|
|
||||||
1
files/operators/claude-ubongo.pub
Normal file
1
files/operators/claude-ubongo.pub
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIKSx1TFLJ9H8vCe5ZJSu7MYmAiH0/OC8evloQjGR0Bqw claude@ubongo
|
||||||
1
files/operators/claus-stjerno.pub
Normal file
1
files/operators/claus-stjerno.pub
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAID0J3ugdd8GF8bGULk6O+tNKinNsil0ks7cLaStVUQR4 claus@stjerno.dk
|
||||||
1
files/operators/sjat-mamba.pub
Normal file
1
files/operators/sjat-mamba.pub
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIA8XUj5gM5Enlt7Bh6PpAyZAGtu4kG/Cq6rUvVH55+tM sjat
|
||||||
|
|
@ -1,8 +1,25 @@
|
||||||
$ANSIBLE_VAULT;1.2;AES256;makerfloss
|
$ANSIBLE_VAULT;1.2;AES256;makerfloss
|
||||||
65633363353761306465316563336137323966313330313238633661313938633939653330383561
|
32653033396232323333353737313334656237356163386433626333646461376534396131636134
|
||||||
3936363934636563383032646631336464363534613366360a666162626432303066383863376530
|
6632363938393232613364383937643631303934383464380a316537663466383735363633316238
|
||||||
34616565613837326661323565306263636661396637313263613433366438653934383266343664
|
33363930316631643666383266613936393031616261316263363234306631343736643265393663
|
||||||
6538656135366336630a303536663139396364643539636532616165386533616635313166366564
|
6430363337326230640a303164306430376666626432303838316134663363656631386330613831
|
||||||
31303762313063353734666632623262616562383833353765376263333732386336616336383934
|
32623635333964613033353733653066623863616536633861643763613532373661376331653065
|
||||||
61623334666230356661636433613633653439353662393730313663656664663962346139666639
|
33313630313561343561643262613962343366346631353865633462366532643739396330346466
|
||||||
396431396664316165663030633732656632
|
35636537396137373432353036356536333931396537356536353863653435323431633639343932
|
||||||
|
65356531373032373764643664643866623236333162323335353431623165666537653234393564
|
||||||
|
35313235623362636337373138616433626130313535613831363431316366626439306666643734
|
||||||
|
65623861323966356435656637333632306463393934303065636337373163373639326364343466
|
||||||
|
38336230313634353330353162313064646338623436366263386364303239366636373064663662
|
||||||
|
66636161343539656331633064373965626434623066333036383030383161363263633261376562
|
||||||
|
38326661393161643132666337366231633338633634633165643130343461623130383163363865
|
||||||
|
35613837353263666536316337363965396138656461386265623132333236616635306137333765
|
||||||
|
66633334356231303565616535353335366232623631323733623531633564303633313030656239
|
||||||
|
64323165396634353834643236303334303234633739616561336635373133343935303135313636
|
||||||
|
34613932336338636163663531383665356662633465333762383063393138383035653235663838
|
||||||
|
38643166333433346632396666336137656166656130333237303237333433356263323638363664
|
||||||
|
37363566353762626238343866336239633461613536333834623433356532333739663665336262
|
||||||
|
31626536336464613666353732343434616464663135633535313864653032383537386662653834
|
||||||
|
30663832336366333035663163646334356533356339323262383538386338353961323235363165
|
||||||
|
33616338303231323463376165633437653362393664393033353236333635346430373761613862
|
||||||
|
37383338366238616335333632666134306663663236366331613261396363373866636665313161
|
||||||
|
35646239323236353036
|
||||||
|
|
|
||||||
|
|
@ -43,6 +43,21 @@ switch_mgmt_dhcp_network: "192.168.88.0/24"
|
||||||
|
|
||||||
switch_admin_user: "sjat"
|
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) -----
|
# ----- VLANs + per-port map (all untagged access; no trunks) -----
|
||||||
# DATA = flat 10.2.30.0/24 (uplink + device ports); MGMT = isolated admin VLAN.
|
# DATA = flat 10.2.30.0/24 (uplink + device ports); MGMT = isolated admin VLAN.
|
||||||
switch_vlans:
|
switch_vlans:
|
||||||
|
|
|
||||||
|
|
@ -2,5 +2,10 @@
|
||||||
- name: Configure MikroTik switches (day-2, key auth)
|
- name: Configure MikroTik switches (day-2, key auth)
|
||||||
hosts: mikrotik
|
hosts: mikrotik
|
||||||
gather_facts: false
|
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:
|
roles:
|
||||||
- makerfloss.mikrotik_switch
|
- makerfloss.mikrotik_switch
|
||||||
|
|
|
||||||
|
|
@ -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"
|
switch_mgmt_dhcp_network: "" # e.g. "192.168.88.0/24"
|
||||||
|
|
||||||
# ----- Users -----
|
# ----- Users -----
|
||||||
|
# Bootstrap only (play_bootstrap.yml, first contact over password auth).
|
||||||
switch_admin_user: "sjat"
|
switch_admin_user: "sjat"
|
||||||
switch_admin_group: "full"
|
switch_admin_group: "full"
|
||||||
switch_admin_ssh_pubkey_file: "~/.ssh/id_ed25519.pub"
|
switch_admin_ssh_pubkey_file: "~/.ssh/id_ed25519.pub"
|
||||||
switch_disable_default_admin: true
|
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) -----
|
# ----- VLAN / bridge / ports (PLACEHOLDER example) -----
|
||||||
# Real topology is defined in host_vars/<switch>.yml.
|
# Real topology is defined in host_vars/<switch>.yml.
|
||||||
switch_bridge_name: "bridge"
|
switch_bridge_name: "bridge"
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,87 @@
|
||||||
---
|
---
|
||||||
# Ensure the named admin user exists and (optionally) disable the built-in `admin`.
|
# Operator accounts and their SSH keys.
|
||||||
# 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
|
# `switch_operators` (host_vars) is the source of truth: one entry per person/seat,
|
||||||
# RouterOS `:if [find]` guards, so `changed_when: false` is correct here.
|
# 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:
|
community.routeros.command:
|
||||||
commands:
|
commands:
|
||||||
- >-
|
- >-
|
||||||
:if ([:len [/user find name="{{ switch_admin_user }}"]] = 0) do={
|
:if ([:len [/user find name="{{ item.name }}"]] = 0)
|
||||||
/user add name="{{ switch_admin_user }}" group="{{ switch_admin_group }}" }
|
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
|
changed_when: false
|
||||||
|
|
||||||
- name: Disable the default admin user
|
- name: Disable the default admin user
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue