Compare commits

...
Author SHA1 Message Date
claude-botandClaude Opus 5.5 6fbfb0ed03 feat(ansible): proxy-1 sshd listens on the NetBird interface only
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
proxy-2 was verified: only *%wt0:22 listens, the public IPv4 and IPv6
addresses refuse port 22, and mesh SSH works after a full reboot.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 19:14:10 +01:00
k.renner 88c80ae21a Merge pull request 'feat(ansible): sshd listens on the NetBird interface only' (#1489) from ssh-mesh-only into main
Reviewed-on: #1489
2026-10-27 18:09:07 +00:00
claude-botandClaude Opus 5.5 12d11f8c50 feat(ansible): sshd listens on the NetBird interface only
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
New role ssh-mesh-only and play configure-ssh-mesh-only.yml. sshd moves to a
systemd socket with BindToDevice=wt0: on Arch a new sshd.socket (Accept=yes,
packaged sshd@.service), on Ubuntu a drop-in for ssh.socket that masks the
sshd-socket-generator output, plus BindsTo= so ssh.service stops with it.
NetBird recreates wt0 on every engine restart; BindToDevice stops the socket
with the old interface and a udev SYSTEMD_WANTS rule starts it with the new
one. The socket is not in sockets.target, which would hold boot until wt0.

The switchover runs in a transient unit and arms a revert timer first; the
play cancels it only after logging in again through the new listener and
seeing no listener outside wt0. [ssh_mesh_only] starts with proxy-2.

ssh-ca reloads sshd only when one sshd unit is running, as there is none
with per-connection or stopped socket-activated sshd.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 19:01:51 +01:00
k.renner 355b046abf Merge pull request 'feat(ansible): mesh inventory for the internet hosts, remove the Woodpecker CI SSH key' (#1488) from inventory-mesh-hosts into main
ci/woodpecker/push/deploy-infrastructure Pipeline was successful
Reviewed-on: #1488
2026-10-27 17:53:31 +00:00
claude-botandClaude Opus 5.5 b4933d65e5 feat(edge-host-iso): trust the SSH user CA
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
The build fetches the CA public key from the ssh-ca role URL and renders the
role drop-in, revocation list and root principal into the live system;
edge-install copies them to the installed system and checks sshd -T uses
the CA. Owner certificates log in as root from the first boot, with the
break-glass key as fallback.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 18:29:57 +01:00
claude-botandClaude Opus 5.5 3187be1f62 chore: remove stale SSH keys
ci/woodpecker/pr/ci-scripts-test Pipeline failed
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
The edge-host ISO carried krenner@fortress, ansible-runner@ci-new and
krenner@krenner-XPS-13-9310; it now carries only the break-glass key.
The netbird-server revoke task for netbird-backup@homelab-cluster goes:
the exclusive managed authorized_keys already excludes it. OpenTofu drops
the unused ssh_public_key/ssh_private_key variables and the Proxmox
provider ssh block.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 18:20:48 +01:00
claude-botandClaude Opus 5.5 0c1fb0a229 feat: remove the Woodpecker CI SSH key
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was canceled
The woodpecker-ci@homelab key is dropped from the managed authorized_keys
and from the edge-host ISO. The Ansible pipelines that used it are deleted;
plays run from a workstation. deploy-uptime-kuma keeps only the OpenBao
token renewal cron, and deploy-infrastructure no longer installs the key
(no root-module resource uses the Proxmox provider SSH).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 18:18:15 +01:00
claude-botandClaude Opus 5.5 373b682902 feat(ansible): reach the internet hosts over the NetBird mesh
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
proxy-1, proxy-2 and netbird are addressed by their mesh names, so Ansible
for them runs from a mesh-connected workstation. CI cannot reach the mesh;
the pipelines targeting these hosts lose their push triggers and run only
on manual deployment.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 16:45:46 +01:00
k.renner aad723252a Merge pull request 'fix(ansible): netbird-client downloads the release on the controller' (#1487) from netbird-client-controller-download into main
ci/woodpecker/push/deploy-netbird-client Pipeline failed
Reviewed-on: #1487
2026-10-27 15:35:13 +00:00
claude-botandClaude Opus 5.5 875fbfc730 fix(ansible): netbird-client downloads the release on the controller
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
The first workstation run failed on proxy-1 with 'Network is
unreachable' fetching the release: proxy-1 is IPv6-only and github.com
has no IPv6 address. The tarball is now downloaded and checksum-verified
on the controller (~/.cache/ansible/netbird) and unarchive copies it to
the host, so the hosts need no route to GitHub.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 16:34:37 +01:00
k.renner f56fc42c73 Merge pull request 'fix(ansible): install-netbird-client runs one host at a time' (#1486) from netbird-client-serial into main
ci/woodpecker/push/deploy-netbird-client Pipeline failed
Reviewed-on: #1486
2026-10-27 15:33:11 +00:00
claude-botandClaude Opus 5.5 c4ee6f1662 fix(ansible): install-netbird-client runs one host at a time
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
From the workstation each new SSH session asks for the YubiKey PIN and
a touch; with the default forks both proxies prompted at once. serial: 1
like the other plays that touch the proxies.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 16:30:50 +01:00
k.renner d0413edc27 Merge pull request 'fix(ansible): drop private_key_file from ansible.cfg' (#1485) from ansible-cfg-no-key-file into main
ci/woodpecker/push/deploy-netbird-client Pipeline failed
Reviewed-on: #1485
2026-10-27 15:28:53 +00:00
claude-botandClaude Opus 5.5 30d0299073 fix(ansible): install-netbird-client gathers facts only on selected hosts
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
The play gathered facts on every inventory host before skipping those
without netbird_setup_key, so a workstation run opened an SSH session
(YubiKey PIN + touch) to edge-1 and netbird too, and timed out on the
decommissioned claude-ws. Facts are gathered after the skip now.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 16:27:57 +01:00
claude-botandClaude Opus 5.5 c3566b8fc3 fix(ansible): drop private_key_file from ansible.cfg
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ansible.cfg forced ~/.ssh/id_ed25519, which does not exist on the
owner's workstation: runs from there failed with 'no such identity'
and bypassed the ssh config that picks the plugged-in YubiKey's key
and certificate. CI never read this file ('world writable directory,
ignoring it as an ansible.cfg source'); its pipelines write the CI key
to ~/.ssh/id_ed25519, ssh's default identity, so they are unaffected.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 16:27:25 +01:00
k.renner 271aefc5d2 Merge pull request 'feat(ansible): netbird-client installs on Arch (proxy hosts)' (#1484) from netbird-client-arch into main
ci/woodpecker/push/deploy-netbird-client Pipeline failed
ci/woodpecker/push/deploy-netbird-proxy Pipeline was successful
ci/woodpecker/push/deploy-ssh-ca Pipeline was successful
Reviewed-on: #1484
2026-10-27 15:18:13 +00:00
claude-botandClaude Opus 5.5 285057bc98 feat(ansible): netbird-client installs on Arch (proxy hosts)
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
Step 1 of SSH-only-over-NetBird: proxy-1/2 (Arch) need a host NetBird
client with wt0 before sshd can stop listening on their public
addresses. The role was apt-only.

- Arch: release tarball from GitHub, verified against the release's
  checksums file (get_url checksum URL), binary to /usr/local/bin,
  'netbird service install'. Version netbird_client_version, 0.75.0
  like netbird-ingress; Debian/Ubuntu keep following the apt repo.
- netbird_disable_dns adds --disable-dns; set for [netbird_proxy],
  whose resolv.conf the netbird-proxy role pins.
- The service is started before 'netbird up' (the Arch path has no
  package that starts it).
- The connected check matched 'Connected' inside 'Disconnected'; it
  now matches the 'Management: Connected' line.
- 'netbird up' runs with no_log: its argv holds the setup key.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 16:12:55 +01:00
k.renner 973c6d9e94 Merge pull request 'fix(bootstrap): portable DR secret commands, visible YubiKey prompts' (#1482) from dr-secrets-age-fixes into main
Reviewed-on: #1482
2026-10-27 14:29:03 +00:00
claude-botandClaude Opus 5.5 a5f2bc39e4 fix(bootstrap): portable DR secret commands, visible YubiKey prompts
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
Review of #1481 (merged with it open):
- The Job printed the operator's streaming command with the pod's
  HOME expanded (/work/.config/...), unusable on the workstation. The
  defaults stay a literal ~ now; eval expands it on the workstation.
- 'Collecting secrets' captured stderr, where age-plugin-yubikey asks
  for the PIN and touch: a silent hang. stderr stays on the terminal.
- Runbook/migration doc still named pass as the DR store in places;
  tooling list gains age + age-plugin-yubikey; the file list gains
  hetzner-cloud-api-secret.age.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 15:27:17 +01:00
k.renner cb7f727c1c Merge pull request 'feat(bootstrap): DR secrets from YubiKey/paper-key age files, not pass' (#1481) from dr-secrets-age into main
Reviewed-on: #1481
2026-10-27 14:25:33 +00:00
44 changed files with 427 additions and 449 deletions
-33
View File
@@ -1,33 +0,0 @@
when:
# Deploy only on main (merge) + manual — never auto-deploy from a branch.
- event: push
branch: main
path:
- "ansible/roles/claude-workstation/**"
- "ansible/plays/install-claude-workstation.yml"
- ".woodpecker/deploy-claude-workstation.yml"
- event: deployment
evaluate: 'CI_PIPELINE_DEPLOY_TARGET == "deploy-claude-workstation"'
timeout: 30
steps:
- name: deploy-claude-workstation
image: gitea.renner.uno/gitea_admin/ci-ansible:2026-04-29
environment:
ANSIBLE_FORCE_COLOR: "true"
ANSIBLE_HOST_KEY_CHECKING: "false"
ANSIBLE_ROLES_PATH: "../roles:./roles"
SSH_PRIVATE_KEY:
from_secret: ssh_private_key
VAULT_PASS:
from_secret: vault_pass
commands:
- mkdir -p ~/.ssh
- echo "$SSH_PRIVATE_KEY" | base64 -d > ~/.ssh/id_ed25519
- chmod 600 ~/.ssh/id_ed25519
- printf "Host *\n\tStrictHostKeyChecking no\n\tUserKnownHostsFile /dev/null\n" > ~/.ssh/config
- cd ansible
- echo "$VAULT_PASS" > .vault_pass && chmod 600 .vault_pass
- export ANSIBLE_VAULT_PASSWORD_FILE=.vault_pass
- ansible-playbook -i inventory/ plays/install-claude-workstation.yml -v
-36
View File
@@ -1,36 +0,0 @@
when:
# Deploy only on main (merge) + manual — never auto-deploy from a branch.
- event: push
branch: main
path:
- "ansible/roles/mqtt-bridge/**"
- "ansible/plays/edge-mqtt-bridge.yml"
- "ansible/inventory/group_vars/edge_host/**"
- ".woodpecker/deploy-edge-mqtt-bridge.yml"
- event: deployment
evaluate: 'CI_PIPELINE_DEPLOY_TARGET == "deploy-edge-mqtt-bridge"'
timeout: 30
steps:
- name: deploy-edge-mqtt-bridge
image: gitea.renner.uno/gitea_admin/ci-ansible:2026-04-29
environment:
ANSIBLE_FORCE_COLOR: "true"
ANSIBLE_HOST_KEY_CHECKING: "false"
ANSIBLE_ROLES_PATH: "../roles:./roles"
SSH_PRIVATE_KEY:
from_secret: ssh_private_key
VAULT_PASS:
from_secret: vault_pass
commands:
- mkdir -p ~/.ssh
- echo "$SSH_PRIVATE_KEY" | base64 -d > ~/.ssh/id_ed25519
- chmod 600 ~/.ssh/id_ed25519
- printf "Host *\n\tStrictHostKeyChecking no\n\tUserKnownHostsFile /dev/null\n" > ~/.ssh/config
- cd ansible
- echo "$VAULT_PASS" > .vault_pass && chmod 600 .vault_pass
- export ANSIBLE_VAULT_PASSWORD_FILE=.vault_pass
# Local MQTT bridge on the edge hosts only (REN-1307); restarts only the
# local mosquitto on edge-1, never the cluster broker.
- ansible-playbook -i inventory/ plays/edge-mqtt-bridge.yml -v
-7
View File
@@ -109,8 +109,6 @@ steps:
from_secret: hetzner_s3_access_key
TF_VAR_hetzner_s3_secret_key:
from_secret: hetzner_s3_secret_key
SSH_PRIVATE_KEY:
from_secret: ssh_private_key
PROXMOX_TOKEN_ID:
from_secret: proxmox_token_id
PROXMOX_TOKEN_SECRET:
@@ -121,11 +119,6 @@ steps:
ANSIBLE_ROLES_PATH: "../roles:./roles"
ANSIBLE_FORCE_COLOR: "true"
commands:
- mkdir -p ~/.ssh
- echo "$SSH_PRIVATE_KEY" | base64 -d > ~/.ssh/id_ed25519
- chmod 600 ~/.ssh/id_ed25519
- export TF_VAR_ssh_private_key="$(cat ~/.ssh/id_ed25519)"
- printf "Host *\n\tStrictHostKeyChecking no\n" > ~/.ssh/config
- echo "$VAULT_PASS" > /tmp/.vault_pass && chmod 600 /tmp/.vault_pass
- export ANSIBLE_VAULT_PASSWORD_FILE=/tmp/.vault_pass
- tofu init -upgrade -backend-config="access_key=$AWS_ACCESS_KEY_ID" -backend-config="secret_key=$AWS_SECRET_ACCESS_KEY"
-35
View File
@@ -1,35 +0,0 @@
when:
# Deploy only on main (merge) + manual — never auto-deploy from a branch.
- event: push
branch: main
path:
- "ansible/roles/netbird-client/**"
- "ansible/plays/install-netbird-client.yml"
- "ansible/inventory/group_vars/claude_workstation/**"
- ".woodpecker/deploy-netbird-client.yml"
- event: deployment
evaluate: 'CI_PIPELINE_DEPLOY_TARGET == "deploy-netbird-client"'
timeout: 30
steps:
- name: deploy-netbird-client
image: gitea.renner.uno/gitea_admin/ci-ansible:2026-04-29
environment:
ANSIBLE_FORCE_COLOR: "true"
ANSIBLE_HOST_KEY_CHECKING: "false"
ANSIBLE_ROLES_PATH: "../roles:./roles"
SSH_PRIVATE_KEY:
from_secret: ssh_private_key
VAULT_PASS:
from_secret: vault_pass
commands:
- mkdir -p ~/.ssh
- echo "$SSH_PRIVATE_KEY" | base64 -d > ~/.ssh/id_ed25519
- chmod 600 ~/.ssh/id_ed25519
- printf "Host *\n\tStrictHostKeyChecking no\n\tUserKnownHostsFile /dev/null\n" > ~/.ssh/config
- cd ansible
- echo "$VAULT_PASS" > .vault_pass && chmod 600 .vault_pass
- export ANSIBLE_VAULT_PASSWORD_FILE=.vault_pass
# Enrol only the off-site Claude workstation into the Netbird mesh.
- ansible-playbook -i inventory/ plays/install-netbird-client.yml --limit claude_workstation -v
-54
View File
@@ -1,54 +0,0 @@
when:
# Deploy only on main (merge) + manual — never auto-deploy from a branch.
- event: push
branch: main
path:
- "ansible/roles/netbird-proxy/**"
# install-netbird-proxy.yml also applies the netbird-proxy-watchdog role,
# so changes to it must redeploy this host too.
- "ansible/roles/netbird-proxy-watchdog/**"
# install-netbird-proxy.yml also applies the edge bouncer.
- "ansible/roles/crowdsec-edge-bouncer/**"
- "ansible/plays/install-netbird-proxy.yml"
# The variables that steer those roles, not just the roles themselves.
# Without these two, a change that only touches inventory never deploys
# and nothing says so: arming the bouncer lives in host_vars, and its
# LAPI key lives in group_vars, so both would sit in git looking applied
# while the hosts kept running the previous configuration.
#
# pve_hosts.yml is deliberately NOT listed. It is the single inventory
# file for the whole fleet, so listing it would redeploy the proxies for
# any unrelated host edit. The cost is that a change to [netbird_proxy]
# group membership needs a manual deployment run.
- "ansible/inventory/host_vars/proxy-*.yml"
- "ansible/inventory/group_vars/netbird_proxy/**"
- ".woodpecker/deploy-netbird-proxy.yml"
- event: deployment
evaluate: 'CI_PIPELINE_DEPLOY_TARGET == "deploy-netbird-proxy"'
timeout: 30
steps:
- name: deploy-netbird-proxy
image: gitea.renner.uno/gitea_admin/ci-ansible:2026-04-29
environment:
ANSIBLE_FORCE_COLOR: "true"
ANSIBLE_HOST_KEY_CHECKING: "false"
ANSIBLE_ROLES_PATH: "../roles:./roles"
SSH_PRIVATE_KEY:
from_secret: ssh_private_key
VAULT_PASS:
from_secret: vault_pass
PROXMOX_TOKEN_ID:
from_secret: proxmox_token_id
PROXMOX_TOKEN_SECRET:
from_secret: proxmox_token_secret
commands:
- mkdir -p ~/.ssh
- echo "$SSH_PRIVATE_KEY" | base64 -d > ~/.ssh/id_ed25519
- chmod 600 ~/.ssh/id_ed25519
- printf "Host *\n\tStrictHostKeyChecking no\n\tUserKnownHostsFile /dev/null\n" > ~/.ssh/config
- cd ansible
- echo "$VAULT_PASS" > .vault_pass && chmod 600 .vault_pass
- export ANSIBLE_VAULT_PASSWORD_FILE=.vault_pass
- ansible-playbook -i inventory/ plays/install-netbird-proxy.yml -v
-37
View File
@@ -1,37 +0,0 @@
when:
# Deploy only on main (merge) + manual — never auto-deploy from a branch.
- event: push
branch: main
path:
- "ansible/roles/netbird-server/**"
- "ansible/plays/install-netbird-server.yml"
- ".woodpecker/deploy-netbird-server.yml"
- event: deployment
evaluate: 'CI_PIPELINE_DEPLOY_TARGET == "deploy-netbird-server"'
timeout: 30
steps:
- name: deploy-netbird-server
image: gitea.renner.uno/gitea_admin/ci-ansible:2026-04-29
environment:
ANSIBLE_FORCE_COLOR: "true"
ANSIBLE_HOST_KEY_CHECKING: "false"
ANSIBLE_ROLES_PATH: "../roles:./roles"
SSH_PRIVATE_KEY:
from_secret: ssh_private_key
VAULT_PASS:
from_secret: vault_pass
PROXMOX_TOKEN_ID:
from_secret: proxmox_token_id
PROXMOX_TOKEN_SECRET:
from_secret: proxmox_token_secret
commands:
- mkdir -p ~/.ssh
- echo "$SSH_PRIVATE_KEY" | base64 -d > ~/.ssh/id_ed25519
- chmod 600 ~/.ssh/id_ed25519
- printf "Host *\n\tStrictHostKeyChecking no\n\tUserKnownHostsFile /dev/null\n" > ~/.ssh/config
- cd ansible
- echo "$VAULT_PASS" > .vault_pass && chmod 600 .vault_pass
- export ANSIBLE_VAULT_PASSWORD_FILE=.vault_pass
- ansible-playbook -i inventory/ plays/install-netbird-server.yml -v
-37
View File
@@ -1,37 +0,0 @@
when:
# Deploy only on main (merge) + manual — never auto-deploy from a branch.
- event: push
branch: main
path:
- "ansible/roles/ssh-ca/**"
- "ansible/plays/configure-ssh-ca.yml"
- "ansible/inventory/pve_hosts.yml"
- "ansible/inventory/group_vars/all/**"
- "ansible/inventory/group_vars/netbird_proxy/vars.yml"
- "ansible/inventory/host_vars/netbird.renner.uno.yml"
- ".woodpecker/deploy-ssh-ca.yml"
- event: deployment
evaluate: 'CI_PIPELINE_DEPLOY_TARGET == "deploy-ssh-ca"'
timeout: 30
steps:
- name: deploy-ssh-ca
image: gitea.renner.uno/gitea_admin/ci-ansible:2026-04-29
environment:
ANSIBLE_FORCE_COLOR: "true"
ANSIBLE_HOST_KEY_CHECKING: "false"
ANSIBLE_ROLES_PATH: "../roles:./roles"
SSH_PRIVATE_KEY:
from_secret: ssh_private_key
VAULT_PASS:
from_secret: vault_pass
commands:
- mkdir -p ~/.ssh
- echo "$SSH_PRIVATE_KEY" | base64 -d > ~/.ssh/id_ed25519
- chmod 600 ~/.ssh/id_ed25519
- printf "Host *\n\tStrictHostKeyChecking no\n\tUserKnownHostsFile /dev/null\n" > ~/.ssh/config
- cd ansible
- echo "$VAULT_PASS" > .vault_pass && chmod 600 .vault_pass
- export ANSIBLE_VAULT_PASSWORD_FILE=.vault_pass
- ansible-playbook -i inventory/ plays/configure-ssh-ca.yml -v
-33
View File
@@ -1,13 +1,4 @@
when:
# Deploy only on main (merge) + manual — never auto-deploy from a branch.
- event: push
branch: main
path:
- "ansible/roles/uptime-kuma/**"
- "ansible/plays/install-uptime-kuma.yml"
- ".woodpecker/deploy-uptime-kuma.yml"
- event: deployment
evaluate: 'CI_PIPELINE_DEPLOY_TARGET == "deploy-uptime-kuma"'
- event: cron
cron: uptime-kuma-token-renew
@@ -21,27 +12,3 @@ steps:
from_secret: openbao_uptime_kuma_token
commands:
- 'curl -sSf -X POST -H "X-Vault-Token: $VAULT_TOKEN" https://openbao.renner.uno/v1/auth/token/renew-self -o /dev/null'
- name: deploy-uptime-kuma
image: gitea.renner.uno/gitea_admin/ci-ansible:2026-10-18
when:
- event: [push, deployment]
environment:
ANSIBLE_FORCE_COLOR: "true"
ANSIBLE_HOST_KEY_CHECKING: "false"
ANSIBLE_ROLES_PATH: "../roles:./roles"
SSH_PRIVATE_KEY:
from_secret: ssh_private_key
VAULT_PASS:
from_secret: vault_pass
VAULT_TOKEN:
from_secret: openbao_uptime_kuma_token
commands:
- mkdir -p ~/.ssh
- echo "$SSH_PRIVATE_KEY" | base64 -d > ~/.ssh/id_ed25519
- chmod 600 ~/.ssh/id_ed25519
- printf "Host *\n\tStrictHostKeyChecking no\n\tUserKnownHostsFile /dev/null\n" > ~/.ssh/config
- cd ansible
- echo "$VAULT_PASS" > .vault_pass && chmod 600 .vault_pass
- export ANSIBLE_VAULT_PASSWORD_FILE=.vault_pass
- ansible-playbook -i inventory/ plays/install-uptime-kuma.yml -v
-28
View File
@@ -1,28 +0,0 @@
when:
# Manual only: reboots edge-1 (Z-Wave, heating CAN, sole NTP server) and the
# NetBird control plane. Run inside the 02:00-05:00 Europe/Berlin window.
- event: deployment
evaluate: 'CI_PIPELINE_DEPLOY_TARGET == "update-os"'
timeout: 120
steps:
- name: update-os
image: gitea.renner.uno/gitea_admin/ci-ansible:2026-04-29
environment:
ANSIBLE_FORCE_COLOR: "true"
ANSIBLE_HOST_KEY_CHECKING: "false"
ANSIBLE_ROLES_PATH: "../roles:./roles"
SSH_PRIVATE_KEY:
from_secret: ssh_private_key
VAULT_PASS:
from_secret: vault_pass
commands:
- mkdir -p ~/.ssh
- echo "$SSH_PRIVATE_KEY" | base64 -d > ~/.ssh/id_ed25519
- chmod 600 ~/.ssh/id_ed25519
- printf "Host *\n\tStrictHostKeyChecking no\n\tUserKnownHostsFile /dev/null\n" > ~/.ssh/config
- cd ansible
- echo "$VAULT_PASS" > .vault_pass && chmod 600 .vault_pass
- export ANSIBLE_VAULT_PASSWORD_FILE=.vault_pass
- ansible-playbook -i inventory/ plays/update-os.yml -v
+1 -1
View File
@@ -206,7 +206,7 @@ inventory=~/source/homelab/ansible/inventory
;poll_interval=15
# (path) Option for connections using a certificate or key file to authenticate, rather than an agent or passwords, you can set the default value here to avoid re-specifying --private-key with every invocation.
private_key_file=~/.ssh/id_ed25519
;private_key_file=
# (boolean) By default, imported roles publish their variables to the play and other roles, this setting can avoid that.
# This was introduced as a way to reset role variables to default values if a role is used more than once in a playbook.
+2 -2
View File
@@ -245,8 +245,8 @@ its own server key, nothing else. On a push it writes
`/etc/mosquitto/bridge.d/edge-1.conf`, deletes `bridge.d/legacy.conf` and
restarts the local mosquitto. The controller sets a new password whenever the
bridge reports "not connected" (also after a reinstall), so no play run is
needed for a rotation. `.woodpecker/deploy-edge-mqtt-bridge.yml` runs
`plays/edge-mqtt-bridge.yml` on merge. Diagnosis: `journalctl -u
needed for a rotation. `plays/edge-mqtt-bridge.yml` is run by hand from a
workstation. Diagnosis: `journalctl -u
edge-mqtt-cred`, and `mosquitto_sub -t '$SYS/broker/connection/edge-1/state'`
(retained `1` = connected).
+2 -29
View File
@@ -5,37 +5,10 @@ pve_token_id: "ansible"
ansible_user: "ladmin"
ansible_python_interpreter: /usr/bin/python3
# The only plain keys the admin logins accept on [ssh_keys_managed] hosts
# The only plain key the admin logins accept on [ssh_keys_managed] hosts
# (ssh-ca role, tasks/authorized_keys.yml). People log in with OpenBao SSH CA
# certificates; these two are for when that path is unavailable.
# certificates; this one is for when that path is unavailable.
# breakglass-neo: PIV key (slot 9a, PIN) on the offline YubiKey NEO.
# ssh -I /usr/lib/opensc-pkcs11.so root@<host>
# woodpecker-ci: the CI deploy key, from ssh_ci_key_from where set.
ssh_admin_authorized_keys:
- key: "ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBKvSl9LUP8u3S2Sq5YTWpW0Jxbue8cJhD7/LMFQbEsa3xo145HVR7pBzRxW71MUy+jgrgmpSlDhIb606Hswhvoo= breakglass-neo-6532348"
- key: "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAICyNnX1I4W9jyDvCJ1DJauHSmbo1ZXSyBxVhSAwMWgen woodpecker-ci@homelab"
options: "{{ ('from=\"' ~ ssh_ci_key_from ~ '\"') if ssh_ci_key_from else '' }}"
# Source the CI key is accepted from. LAN hosts see the pod address; hosts on
# the internet see the home WAN address (NAT, dynamic) and set this empty in
# their group/host vars.
ssh_ci_key_from: "{{ ci_ssh_from_cidrs }}"
# Network-of-origin restriction for the woodpecker-ci@homelab CI key
# (issue #184). Used as the authorized_keys `from=` option so the CI key only
# authenticates from within the cluster's pod network — NOT from any
# host-pinned IP.
#
# The in-cluster Woodpecker step pods SSH to fleet hosts using their POD IP as
# source, not a node IP: Cilium runs `ipv4-native-routing-cidr=10.0.0.0/8` +
# `ipv6-native-routing-cidr=fd00::/8` with masquerade enabled, so pod egress to
# any 10.x / fd.. LAN destination is NOT SNAT'd, and each node's PodCIDR is
# advertised to the UDM over BGP (kubernetes/infrastructure/post-crds/
# cilium-bgp.yaml) so return traffic L3-routes back to the pod. Hosts therefore
# see the real pod source IP.
#
# The cluster pod subnets (kubernetes/talos/patches/cluster.yaml podSubnets)
# are the stable boundary — they survive node DHCP-lease churn and pod
# rescheduling across k8s-1/2/3, unlike pinning any node's current IP.
# v6 is included because some SSH targets (edge IoT hosts) are IPv6-only.
ci_ssh_from_cidrs: '10.244.0.0/16,fd10:244::/48'
@@ -1,4 +1,4 @@
---
# CI reaches these internet hosts NATed from the home WAN address, never from
# the pod network, so the CI key carries no from= restriction here.
ssh_ci_key_from: ""
# The host NetBird client (wt0) must not take over DNS: resolv.conf is pinned to
# the systemd-resolved stub by the netbird-proxy role.
netbird_disable_dns: true
@@ -2,7 +2,3 @@
# NetBird server - VPS not managed by Proxmox
# Enable Docker monitoring (cAdvisor) for container metrics
docker_monitoring_enabled: true
# CI reaches this internet host NATed from the home WAN address, never from
# the pod network, so the CI key carries no from= restriction here.
ssh_ci_key_from: ""
+12 -5
View File
@@ -13,11 +13,11 @@
# Their hosts no longer resolve, except the three that resolve to Talos nodes.
[netbird_server]
netbird.renner.uno ansible_user=root
netbird.renner.uno ansible_host=netbird.nb.renner.uno ansible_user=root
[netbird_proxy]
proxy-1.renner.uno ansible_user=root
proxy-2.renner.uno ansible_user=root
proxy-1.renner.uno ansible_host=proxy-1.nb.renner.uno ansible_user=root
proxy-2.renner.uno ansible_host=proxy-2.nb.renner.uno ansible_user=root
# Every bare-metal edge thin client. Gets the host-generic roles only:
# flash-wear handling, the log shipper, the MQTT bridge, the VLAN
@@ -79,11 +79,18 @@ proxy-2.renner.uno
netbird.renner.uno
edge-1.iot.renner.uno
# Hosts whose admin logins accept only ssh_admin_authorized_keys (break-glass
# + CI); everything else needs a CA certificate. Members must be in [ssh_ca].
# Hosts whose admin logins accept only ssh_admin_authorized_keys (break-glass);
# everything else needs a CA certificate. Members must be in [ssh_ca].
# Added one host at a time after the [ssh_ca] report was reviewed.
[ssh_keys_managed]
proxy-1.renner.uno
proxy-2.renner.uno
netbird.renner.uno
edge-1.iot.renner.uno
# Internet hosts whose sshd listens on the NetBird interface only
# (plays/configure-ssh-mesh-only.yml). Members need the host NetBird client.
# Added one host at a time after the previous one was verified.
[ssh_mesh_only]
proxy-1.renner.uno
proxy-2.renner.uno
+1 -1
View File
@@ -15,7 +15,7 @@
roles:
- ssh-ca
- name: Reduce authorized_keys to break-glass and CI
- name: Reduce authorized_keys to the break-glass key
hosts: ssh_keys_managed
become: true
gather_facts: false
+15
View File
@@ -0,0 +1,15 @@
---
# sshd on the hosts in [ssh_mesh_only] accepts connections only on the NetBird
# interface; nothing listens on their public addresses. Run from a workstation
# in the mesh. A switchover reverts by itself unless the play logs in again
# through the new listener.
#
# serial: 1 so a host that does not come back stops the run before the next
# host is touched.
- name: Restrict sshd to the mesh interface
hosts: ssh_mesh_only
become: true
gather_facts: true
serial: 1
roles:
- ssh-mesh-only
+2 -4
View File
@@ -1,8 +1,6 @@
---
# Local MQTT bridge and its credential receiver only (REN-1307). Run by
# .woodpecker/deploy-edge-mqtt-bridge.yml on merge, so a change to this role
# reaches edge-1 without a play run by hand. The full base play
# (install-edge-host.yml) carries the same role.
# Local MQTT bridge and its credential receiver only (REN-1307). Run from a
# workstation. The full base play (install-edge-host.yml) carries the same role.
- name: Converge the edge MQTT bridge
hosts: edge_host
become: true
+9 -6
View File
@@ -15,17 +15,20 @@
- name: Install NetBird client on hosts with setup key defined
hosts: all
become: true
gather_facts: true
# One host at a time: from a workstation every new SSH session asks for the
# YubiKey PIN and a touch, and parallel hosts would prompt at once.
serial: 1
# Facts only after the skip: gathering them first opens an SSH session (a
# YubiKey touch from a workstation) to every host, including skipped ones.
gather_facts: false
pre_tasks:
- name: Check if netbird_setup_key is defined
ansible.builtin.debug:
msg: "NetBird setup key is defined for {{ inventory_hostname }}"
when: netbird_setup_key is defined
- name: Skip hosts without netbird_setup_key
ansible.builtin.meta: end_host
when: netbird_setup_key is not defined
- name: Gather facts
ansible.builtin.setup:
roles:
- netbird-client
@@ -6,3 +6,13 @@ netbird_management_url: "https://netbird.renner.uno"
# Setup key - should be defined in group_vars or host_vars
# netbird_setup_key: ""
# Release installed on hosts without a NetBird package repository (Arch).
# Debian/Ubuntu follow the apt repository instead.
netbird_client_version: "0.75.0"
netbird_release_url: "https://github.com/netbirdio/netbird/releases/download/v{{ netbird_client_version }}"
# On the controller; the archive is copied from there to the hosts.
netbird_download_dir: "{{ lookup('ansible.builtin.env', 'HOME') }}/.cache/ansible/netbird"
# Leave the host's DNS configuration alone (hosts with a pinned resolv.conf).
netbird_disable_dns: false
+76 -29
View File
@@ -2,32 +2,82 @@
# NetBird Client Installation Role
# Installs and configures NetBird client on hosts with netbird_setup_key defined
- name: Install required packages
ansible.builtin.apt:
name:
- curl
- ca-certificates
- gnupg
state: present
update_cache: true
- name: Install NetBird from the NetBird apt repository
when: ansible_facts['os_family'] == 'Debian'
block:
- name: Install required packages
ansible.builtin.apt:
name:
- curl
- ca-certificates
- gnupg
state: present
update_cache: true
- name: Add NetBird repository key
ansible.builtin.shell:
cmd: |
curl -sSL https://pkgs.netbird.io/debian/public.key | gpg --dearmor -o /usr/share/keyrings/netbird-archive-keyring.gpg
creates: /usr/share/keyrings/netbird-archive-keyring.gpg
- name: Add NetBird repository key
ansible.builtin.shell:
cmd: |
curl -sSL https://pkgs.netbird.io/debian/public.key | gpg --dearmor -o /usr/share/keyrings/netbird-archive-keyring.gpg
creates: /usr/share/keyrings/netbird-archive-keyring.gpg
- name: Add NetBird repository
ansible.builtin.apt_repository:
repo: "deb [signed-by=/usr/share/keyrings/netbird-archive-keyring.gpg] https://pkgs.netbird.io/debian stable main"
state: present
filename: netbird
- name: Add NetBird repository
ansible.builtin.apt_repository:
repo: "deb [signed-by=/usr/share/keyrings/netbird-archive-keyring.gpg] https://pkgs.netbird.io/debian stable main"
state: present
filename: netbird
- name: Install NetBird client
ansible.builtin.apt:
- name: Install NetBird client
ansible.builtin.apt:
name: netbird
state: present
update_cache: true
# Arch has no netbird package in its repositories; the release binary is
# verified against the release's checksums file. It is downloaded on the
# controller and copied over: github.com has no IPv6 address, and proxy-1 is
# IPv6-only.
- name: Install NetBird from the release tarball
when: ansible_facts['os_family'] == 'Archlinux'
block:
- name: Create the controller download directory
ansible.builtin.file:
path: "{{ netbird_download_dir }}"
state: directory
mode: "0755"
delegate_to: localhost
become: false
run_once: true
- name: Download the NetBird release on the controller
ansible.builtin.get_url:
url: "{{ netbird_release_url }}/netbird_{{ netbird_client_version }}_linux_amd64.tar.gz"
dest: "{{ netbird_download_dir }}/netbird_{{ netbird_client_version }}_linux_amd64.tar.gz"
checksum: "sha256:{{ netbird_release_url }}/netbird_{{ netbird_client_version }}_checksums.txt"
mode: "0644"
delegate_to: localhost
become: false
run_once: true
- name: Install the NetBird binary
ansible.builtin.unarchive:
src: "{{ netbird_download_dir }}/netbird_{{ netbird_client_version }}_linux_amd64.tar.gz"
dest: /usr/local/bin
include:
- netbird
owner: root
group: root
mode: "0755"
- name: Install the NetBird service
ansible.builtin.command: netbird service install
args:
creates: /etc/systemd/system/netbird.service
- name: Enable NetBird service
ansible.builtin.service:
name: netbird
state: present
update_cache: true
state: started
enabled: true
- name: Check if NetBird is already connected
ansible.builtin.command: netbird status
@@ -35,20 +85,17 @@
changed_when: false
failed_when: false
# "Disconnected" contains "Connected", so match the management line.
- name: Connect NetBird to management server
ansible.builtin.command: >
netbird up
--management-url {{ netbird_management_url }}
--setup-key {{ netbird_setup_key }}
when: "'Connected' not in netbird_status.stdout"
{{ '--disable-dns' if netbird_disable_dns else '' }}
when: "'Management: Connected' not in netbird_status.stdout"
register: netbird_connect
changed_when: netbird_connect.rc == 0
- name: Enable NetBird service
ansible.builtin.service:
name: netbird
state: started
enabled: true
no_log: true
- name: Get NetBird status
ansible.builtin.command: netbird status
+2 -12
View File
@@ -136,23 +136,13 @@
- /opt/netbird/proxy
- /opt/netbird/proxy-state
# The management store has no cluster-side backup. Pulling one put a root
# key on this host that the cluster held, and this server's own state is
# its own business to protect rather than the cluster's. Dropping the two
# tasks that placed them would only stop ansible managing the key and the
# script — the pair below revokes them instead, and can go once the role
# has run against every host that ever carried them.
# The management store has no cluster-side backup; the cluster's backup
# key is gone with the managed authorized_keys (ssh-ca role).
- name: Remove the backup dump script
ansible.builtin.file:
path: /usr/local/bin/netbird-backup-dump
state: absent
- name: Revoke the cluster backup key
ansible.posix.authorized_key:
user: root
key: "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIFeW/L9bHMTSa5/Qb0ClppCf4Z0hL39JwzI6tBmKd2FG netbird-backup@homelab-cluster"
state: absent
- name: Wait for management container to be ready
ansible.builtin.pause:
seconds: 10
+7 -4
View File
@@ -130,7 +130,9 @@
# The unit is sshd.service on Arch and RedHat, ssh.service on Debian/Ubuntu.
# Ubuntu also lists sshd.service as a not-found alias, so the running one is
# picked. sshd -T only parses the files, so the running daemon is reloaded on
# every run; a reload keeps open sessions.
# every run; a reload keeps open sessions. None runs when sshd is socket
# activated and stopped, or per connection (ssh-mesh-only role); the next
# start reads the config.
- name: Read the service list
ansible.builtin.service_facts:
@@ -138,16 +140,17 @@
vars:
ssh_ca_sshd_units: "{{ ansible_facts.services | dict2items | selectattr('key', 'in', ['sshd.service', 'ssh.service']) | selectattr('value.state', 'equalto', 'running') | map(attribute='key') | list }}"
block:
- name: Check exactly one sshd unit is running
- name: Check at most one sshd unit is running
ansible.builtin.assert:
that:
- ssh_ca_sshd_units | length == 1
fail_msg: "Expected one running sshd.service or ssh.service, found {{ ssh_ca_sshd_units }}"
- ssh_ca_sshd_units | length <= 1
fail_msg: "Expected at most one running sshd.service or ssh.service, found {{ ssh_ca_sshd_units }}"
- name: Reload sshd so the running daemon uses the CA config
ansible.builtin.systemd:
name: "{{ ssh_ca_sshd_units | first }}"
state: reloaded
when: ssh_ca_sshd_units | length == 1
- name: Report the admin logins' authorized keys
ansible.builtin.include_tasks: report.yml
@@ -0,0 +1,9 @@
---
# The only interface sshd accepts connections on.
ssh_mesh_interface: wt0
# Minutes until a switchover reverts to the distro listener, unless a new
# login through the mesh-only listener confirms it first.
ssh_mesh_revert_minutes: 5
ssh_mesh_udev_rule: /etc/udev/rules.d/90-ssh-mesh-only.rules
+136
View File
@@ -0,0 +1,136 @@
---
- name: Load the distro's sshd unit names
ansible.builtin.include_vars: "{{ ansible_facts['os_family'] }}.yml"
- name: Look for the mesh interface
ansible.builtin.stat:
path: "/sys/class/net/{{ ssh_mesh_interface }}"
register: ssh_mesh_iface
- name: Check the mesh interface exists
ansible.builtin.assert:
that:
- ssh_mesh_iface.stat.exists
fail_msg: "No {{ ssh_mesh_interface }}: sshd would be unreachable. Install the NetBird client first."
- name: Create the socket unit directory
ansible.builtin.file:
path: "{{ ssh_mesh_socket_file | dirname }}"
state: directory
owner: root
group: root
mode: "0755"
- name: Install the mesh-only socket
ansible.builtin.template:
src: "{{ ssh_mesh_socket_template }}"
dest: "{{ ssh_mesh_socket_file }}"
owner: root
group: root
mode: "0644"
- name: Bind the sshd service to the socket
when: ssh_mesh_service_dropin | length > 0
block:
- name: Create the service drop-in directory
ansible.builtin.file:
path: "{{ ssh_mesh_service_dropin | dirname }}"
state: directory
owner: root
group: root
mode: "0755"
- name: Install the service drop-in
ansible.builtin.template:
src: ssh.service-dropin.conf.j2
dest: "{{ ssh_mesh_service_dropin }}"
owner: root
group: root
mode: "0644"
- name: Install the udev rule that starts the socket with the interface
ansible.builtin.template:
src: udev.rules.j2
dest: "{{ ssh_mesh_udev_rule }}"
owner: root
group: root
mode: "0644"
register: ssh_mesh_udev
- name: Reload the udev rules
ansible.builtin.command: udevadm control --reload
when: ssh_mesh_udev is changed
changed_when: true
- name: Install the switch and revert scripts
ansible.builtin.template:
src: "{{ item }}.j2"
dest: "/usr/local/sbin/{{ item }}"
owner: root
group: root
mode: "0755"
loop:
- ssh-mesh-switch
- ssh-mesh-revert
- name: Reload systemd units
ansible.builtin.systemd:
daemon_reload: true
- name: Read the sshd listeners
ansible.builtin.command: ss -Htln 'sport = :22'
changed_when: false
register: ssh_mesh_listeners
# Each run that finds a listener outside the interface switches over. The
# revert timer runs in its own unit, so a switch that locks the play out is
# undone after ssh_mesh_revert_minutes.
- name: Switch sshd to the mesh-only socket
when: >-
ssh_mesh_listeners.stdout_lines | length == 0 or
ssh_mesh_listeners.stdout_lines | reject('search', '%' ~ ssh_mesh_interface ~ ':22\s') | list | length > 0
block:
- name: Clear a revert timer left by an earlier run
ansible.builtin.command: systemctl stop ssh-mesh-revert.timer ssh-mesh-revert.service
changed_when: false
failed_when: false
- name: Arm the revert timer
ansible.builtin.command: >-
systemd-run --unit=ssh-mesh-revert --collect
--on-active={{ ssh_mesh_revert_minutes }}min
/usr/local/sbin/ssh-mesh-revert
changed_when: true
- name: Switch the listener
ansible.builtin.command: systemd-run --unit=ssh-mesh-switch --collect --wait /usr/local/sbin/ssh-mesh-switch
changed_when: true
- name: Reconnect through the current listener
ansible.builtin.meta: reset_connection
- name: Read the sshd listeners after reconnecting
ansible.builtin.command: ss -Htln 'sport = :22'
changed_when: false
register: ssh_mesh_listeners_after
- name: Check sshd listens on the mesh interface only
ansible.builtin.assert:
that:
- ssh_mesh_listeners_after.stdout_lines | length > 0
- ssh_mesh_listeners_after.stdout_lines | reject('search', '%' ~ ssh_mesh_interface ~ ':22\s') | list | length == 0
fail_msg: "Listeners outside {{ ssh_mesh_interface }}: {{ ssh_mesh_listeners_after.stdout_lines }}"
- name: Cancel the revert timer
ansible.builtin.command: systemctl stop ssh-mesh-revert.timer
changed_when: false
failed_when: false
- name: Check the public name refuses SSH
ansible.builtin.wait_for:
host: "{{ inventory_hostname }}"
port: 22
state: stopped
timeout: 15
delegate_to: localhost
become: false
@@ -0,0 +1,9 @@
#!/bin/sh
# {{ ansible_managed }}
# Restores the distro listener on all addresses. Armed as a timer by every
# switchover; the play cancels it after a login through the new listener.
systemctl stop {{ ssh_mesh_service }} {{ ssh_mesh_socket }}
rm -f {{ ssh_mesh_udev_rule }} {{ ssh_mesh_socket_file }}{{ (' ' ~ ssh_mesh_service_dropin) if ssh_mesh_service_dropin else '' }}
udevadm control --reload
systemctl daemon-reload
systemctl enable --now {{ ssh_mesh_distro_listener }}
@@ -0,0 +1,11 @@
#!/bin/sh
# {{ ansible_managed }}
# Replaces the distro listener with the {{ ssh_mesh_interface }}-only socket.
# Open sessions survive: they run in logind scopes and the sshd units use
# KillMode=process. Any failure restores the distro listener at once.
set -eu
trap '/usr/local/sbin/ssh-mesh-revert' EXIT
systemctl disable {{ ssh_mesh_disable_units }}
systemctl stop {{ ssh_mesh_service }} {{ ssh_mesh_socket }}
systemctl start {{ ssh_mesh_socket }}
trap - EXIT
@@ -0,0 +1,7 @@
# {{ ansible_managed }}
# sshd holds the socket's listener; it stops with the socket, so it never keeps
# a listener on a removed {{ ssh_mesh_interface }}. Replaces the Requires= that
# enabling {{ ssh_mesh_socket }} would add.
[Unit]
BindsTo={{ ssh_mesh_socket }}
After={{ ssh_mesh_socket }}
@@ -0,0 +1,8 @@
# {{ ansible_managed }}
# Listen on {{ ssh_mesh_interface }} only. The unit is disabled; the udev rule
# starts it whenever {{ ssh_mesh_interface }} appears.
[Socket]
ListenStream=
ListenStream=22
BindToDevice={{ ssh_mesh_interface }}
BindIPv6Only=both
@@ -0,0 +1,12 @@
# {{ ansible_managed }}
# Started by the udev rule whenever {{ ssh_mesh_interface }} appears; BindToDevice
# stops it when the interface goes away. No [Install]: waiting for the device
# from sockets.target would hold up boot until NetBird is up.
[Unit]
Description=OpenSSH on {{ ssh_mesh_interface }} only
[Socket]
ListenStream=22
BindToDevice={{ ssh_mesh_interface }}
BindIPv6Only=both
Accept=yes
@@ -0,0 +1,3 @@
# {{ ansible_managed }}
# NetBird deletes and recreates {{ ssh_mesh_interface }} on every engine restart.
SUBSYSTEM=="net", KERNEL=="{{ ssh_mesh_interface }}", ENV{SYSTEMD_WANTS}+="{{ ssh_mesh_socket }}"
@@ -0,0 +1,10 @@
---
# No packaged socket; sshd.socket (Accept=yes) pairs with the packaged
# per-connection sshd@.service, since sshd -D cannot take a listening fd.
ssh_mesh_socket: sshd.socket
ssh_mesh_service: sshd.service
ssh_mesh_distro_listener: sshd.service
ssh_mesh_disable_units: sshd.service
ssh_mesh_socket_file: /etc/systemd/system/sshd.socket
ssh_mesh_socket_template: sshd.socket.j2
ssh_mesh_service_dropin: ""
@@ -0,0 +1,10 @@
---
# Ubuntu's ssh.socket (Accept=no) hands its listener to ssh.service. The
# drop-in is named like sshd-socket-generator's output so it masks it.
ssh_mesh_socket: ssh.socket
ssh_mesh_service: ssh.service
ssh_mesh_distro_listener: ssh.socket
ssh_mesh_disable_units: ssh.service ssh.socket
ssh_mesh_socket_file: /etc/systemd/system/ssh.socket.d/addresses.conf
ssh_mesh_socket_template: ssh.socket-addresses.conf.j2
ssh_mesh_service_dropin: /etc/systemd/system/ssh.service.d/ssh-mesh-only.conf
+7 -4
View File
@@ -12,7 +12,10 @@ The image is the official archiso **releng** profile plus:
- `edge-install` in the live environment — partitions the NVMe
(ESP + ext4 root, systemd-boot), pacstraps the base system incl.
docker/python, configures DHCP via systemd-networkd, enables sshd and
drops the common homelab SSH keys for root (key-only, no root password)
sets up root login (key-only, no root password) with owner certificates
from the OpenBao SSH CA, fetched at build time from the `ssh-ca` role's
URL, and the break-glass key (YubiKey NEO, PIV) as fallback:
`ssh -I /usr/lib/opensc-pkcs11.so root@<host>`
- the extra disk tools the installer needs
Division of labour: the ISO produces an **ansible-ready base system**;
@@ -30,7 +33,8 @@ sudo ./build.sh # ISO lands in ./out/
The profile is assembled at build time from `/usr/share/archiso/configs/releng`
plus the overlay in `airootfs/` — the repo carries only the delta, so
archiso updates flow in automatically.
archiso updates flow in automatically. The build needs openbao.renner.uno
for the CA key; rebuild the ISO after a CA rotation.
## Install
@@ -38,8 +42,7 @@ archiso updates flow in automatically.
2. Boot the target from it (UEFI, Secure Boot off; one-time F12/setup
visit if the box doesn't already boot USB first)
3. Either on the console (auto-login as root) or headless —
`ssh root@<dhcp-ip>` works out of the box, the live env carries the
homelab keys — run `edge-install`. It auto-detects the internal disk
`ssh root@<dhcp-ip>` with an owner certificate — run `edge-install`. It auto-detects the internal disk
(NVMe, else eMMC — covers the Wyse 3040 — else first non-USB disk),
shows the target and requires typing the hostname before wiping
anything. Override with `edge-install /dev/sdY`.
@@ -1,4 +1 @@
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIPzFNBP3bq2m/6JqDqod9w+FP/uxXxHMolqhq3Ojflr1 krenner@fortress
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIGfe3HJqFR+jIGrboweseAO7XjIVE24sje/b1HybgU9C ansible-runner@ci-new
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAICyNnX1I4W9jyDvCJ1DJauHSmbo1ZXSyBxVhSAwMWgen woodpecker-ci@homelab
ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBB1OFjh6Q5xeioI6Cwfuv8Xka27veomYy9tKBbpMo8mcrsB4yIN4FX8HovBLtogetBhwBUefBar8nhA9GbRh6HQ= krenner@krenner-XPS-13-9310
ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBKvSl9LUP8u3S2Sq5YTWpW0Jxbue8cJhD7/LMFQbEsa3xo145HVR7pBzRxW71MUy+jgrgmpSlDhIb606Hswhvoo= breakglass-neo-6532348
@@ -1,4 +1 @@
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIPzFNBP3bq2m/6JqDqod9w+FP/uxXxHMolqhq3Ojflr1 krenner@fortress
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIGfe3HJqFR+jIGrboweseAO7XjIVE24sje/b1HybgU9C ansible-runner@ci-new
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAICyNnX1I4W9jyDvCJ1DJauHSmbo1ZXSyBxVhSAwMWgen woodpecker-ci@homelab
ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBB1OFjh6Q5xeioI6Cwfuv8Xka27veomYy9tKBbpMo8mcrsB4yIN4FX8HovBLtogetBhwBUefBar8nhA9GbRh6HQ= krenner@krenner-XPS-13-9310
ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBKvSl9LUP8u3S2Sq5YTWpW0Jxbue8cJhD7/LMFQbEsa3xo145HVR7pBzRxW71MUy+jgrgmpSlDhIb606Hswhvoo= breakglass-neo-6532348
@@ -81,6 +81,17 @@ ln -sf /run/systemd/resolve/stub-resolv.conf /mnt/etc/resolv.conf
install -d -m700 /mnt/root/.ssh
install -m600 /root/edge-authorized_keys /mnt/root/.ssh/authorized_keys
# SSH user CA trust as baked into this ISO (owner certificates for root).
install -m644 -t /mnt/etc/ssh /etc/ssh/trusted_user_ca_keys /etc/ssh/revoked_keys
install -d -m755 /mnt/etc/ssh/auth_principals /mnt/etc/ssh/sshd_config.d
install -m644 /etc/ssh/auth_principals/root /mnt/etc/ssh/auth_principals/root
install -m644 /etc/ssh/sshd_config.d/50-ssh-ca.conf /mnt/etc/ssh/sshd_config.d/
# sshd -t/-T need host keys; sshdgenkeys would create them on first boot.
ssh-keygen -A -f /mnt
arch-chroot /mnt sshd -t
arch-chroot /mnt sshd -T | grep -qix 'trustedusercakeys /etc/ssh/trusted_user_ca_keys' \
|| { echo "installed sshd does not use the SSH CA"; exit 1; }
arch-chroot /mnt systemctl enable \
systemd-networkd systemd-resolved systemd-timesyncd sshd docker
+17
View File
@@ -10,6 +10,23 @@ profile=/tmp/profile
cp -r /usr/share/archiso/configs/releng/. "$profile"
cp -r /src/airootfs/. "$profile/airootfs/"
# SSH user CA trust from the ssh-ca role (mounted at /ssh-ca): the live system
# and the installed one accept owner certificates for root, like the managed
# hosts. The CA public key is fetched from OpenBao at build time.
ca_url=$(sed -n 's/^ssh_ca_public_key_url: "\(.*\)"$/\1/p' /ssh-ca/defaults/main.yml)
principal=$(sed -n 's/^ssh_ca_owner_principal: \(.*\)$/\1/p' /ssh-ca/defaults/main.yml)
[ -n "$ca_url" ] && [ -n "$principal" ] || { echo "ssh-ca defaults not readable"; exit 1; }
ca_key=$(curl -sSf "$ca_url")
[[ "$ca_key" == "ssh-ed25519 AAAA"* ]] || { echo "unexpected CA key from $ca_url"; exit 1; }
sshd_conf=$(grep -vx '# {{ ansible_managed }}' /ssh-ca/templates/ssh-ca.conf.j2)
! grep -q '{[{%]' <<<"$sshd_conf" || { echo "ssh-ca.conf.j2 has template logic"; exit 1; }
ssh_dir="$profile/airootfs/etc/ssh"
install -d "$ssh_dir/sshd_config.d" "$ssh_dir/auth_principals"
printf '%s\n' "$ca_key" > "$ssh_dir/trusted_user_ca_keys"
cp /ssh-ca/files/revoked_keys "$ssh_dir/revoked_keys"
printf '%s\n' "$principal" > "$ssh_dir/auth_principals/root"
printf '%s\n' "$sshd_conf" > "$ssh_dir/sshd_config.d/50-ssh-ca.conf"
# Tools the installer script needs in the live environment (idempotent —
# skip entries releng already ships).
for pkg in gptfdisk dosfstools e2fsprogs; do
+1
View File
@@ -13,6 +13,7 @@ mkdir -p "$out"
docker run --rm --privileged \
-v "$here:/src:ro" \
-v "$here/../../ansible/roles/ssh-ca:/ssh-ca:ro" \
-v "$out:/out" \
archlinux:latest /src/build-inner.sh
+11 -9
View File
@@ -52,9 +52,9 @@ kubectl -n openbao exec -it openbao-0 -- bao operator unseal # paste key
**Unseal is automatic since 2026-09-08** (static seal, PR #825, issue #805):
the seal key lives in the `openbao-seal-key` Secret (created by `bootstrap.sh`
from `pass show homelab/openbao/seal-key`) and every restarted replica unseals
itself. The shamir key above became the **recovery key** in the migration; it
is kept in `pass` under its old name, `homelab/openbao/unseal-key`. The server
from `~/.config/homelab-secrets/seal-key.age`) and every restarted replica
unseals itself. The shamir key above became the **recovery key** in the
migration; it is `~/.config/homelab-secrets/openbao-recovery-key.age`. The server
runs three raft peers (see `infrastructure/openbao/helmrelease.yaml`); a sealed
one is neither a voter nor a standby, so a replica that stays sealed after a
restart — wrong or missing seal-key Secret — costs redundancy, two cost
@@ -237,19 +237,21 @@ one full reconcile + one OpenBao pod restart/unseal cycle:
- **Backups:** ✅ nightly raft-snapshot CronJob (03:00, keeps 7 on a
dedicated PVC; see infrastructure/openbao/snapshot-cronjob.yaml) + velero
`openbao-daily` Schedule (06:15) ships that PVC offsite, 30 d retention.
A raft snapshot restore needs the matching seal key — which lives in
`pass`, outside the cluster (GPG key backed up externally 2026-07-14).
A raft snapshot restore needs the matching seal key — which lives outside
the cluster in `~/.config/homelab-secrets/seal-key.age` (age, encrypted to
both YubiKeys and the offline paper key; copy on nas-2).
A failed snapshot Job is the sealed-after-restart alarm. Still owed:
one tested restore before phase 4.
- **Cluster rebuild order:** seal-key Secret from `pass` → Flux brings up
- **Cluster rebuild order:** seal-key Secret from `seal-key.age` → Flux brings up
openbao (empty) → init → restore raft snapshot → replicas unseal themselves
→ ClusterSecretStore Ready → ESO materializes Secrets → apps start
(`bootstrap.sh flux` does all of it; RUNBOOK-cold-rebuild.md Step 4 by
hand). Until then, secret-consuming apps simply stay Pending on missing
Secrets — same as any missing dependency.
- **Break-glass:** `pass` holds seal key, recovery key + root token (GPG,
offline-capable). With those, everything is recoverable from a raft
snapshot alone.
- **Break-glass:** `~/.config/homelab-secrets` holds the seal key and
recovery key (YubiKeys + paper key, copy on nas-2);
`~/.config/openbao/root-token.age` holds the root token (YubiKeys). With
those, everything is recoverable from a raft snapshot alone.
- **Ansible Vault password:** `ansible/vault-password.sh` reads
`ANSIBLE_VAULT_PASSWORD` from the environment with no offline backup as of
2026-07-14. It gates decryption of several Ansible-Vault files (PBS
+7 -4
View File
@@ -70,7 +70,8 @@ These must exist outside the cluster or the rebuild cannot complete:
- [ ] **OpenBao seal key, recovery key, restore + pull credentials** — age
files in `~/.config/homelab-secrets/` (`seal-key.age`,
`openbao-recovery-key.age` — the former shamir key —, `s3-access.age`,
`s3-secret.age`, `ghcr-pull.age`), encrypted to both YubiKeys and the
`s3-secret.age`, `ghcr-pull.age`, `hetzner-cloud-api-secret.age`),
encrypted to both YubiKeys and the
offline paper key in the safe; a copy is on nas-2 in
`~ladmin/homelab-secrets-backup` (`~/.ssh/ssh-keys-rotate dr-copy`). The
root token is `~/.config/openbao/root-token.age` (YubiKeys only). Needed
@@ -81,7 +82,9 @@ These must exist outside the cluster or the rebuild cannot complete:
(`infrastructure/openbao/snapshot-cronjob.yaml`) plus the Velero
`openbao-daily` offsite copy. In a total-loss rebuild the offsite Velero
copy is the one that survives.
- [ ] **Tooling:** `talosctl`, `kubectl`, `flux`, `tofu`, `bao`, plus the
- [ ] **Tooling:** `talosctl`, `kubectl`, `flux`, `tofu`, `bao`, `age` +
`age-plugin-yubikey` (the plugin is only needed for the YubiKeys; the
paper key needs `age` alone), plus the
OpenTofu backend + UniFi credentials the `opentofu/` stack needs. No
Proxmox credentials — there is no Proxmox any more.
- [ ] **Install media** written and to hand: the Image Factory ISO named in
@@ -303,7 +306,7 @@ output** — a green step over a still-forming Ceph is a known trap.
## Step 4 — openbao: restore the snapshot
`bootstrap.sh flux` does this step itself (`restore_openbao`): it creates the
`openbao-seal-key` Secret from `pass` before Flux brings OpenBao up, initialises
`openbao-seal-key` Secret from `~/.config/homelab-secrets/seal-key.age` before Flux brings OpenBao up, initialises
the empty server, restores the newest raft snapshot straight out of the Velero
kopia repository, and waits until every replica has unsealed itself from the
static seal. What follows is the same procedure by hand, for when the script
@@ -442,7 +445,7 @@ it: repo → **Settings → Repository → Mirror Settings** in Gitea, or
Step 2 no longer uses it. Its `flux` and `wait-apps` phases are still used in
Steps 3, 5 and 6 and work; do not read this as the whole script being gone.
Its `flux` phase carries the OpenBao restore (Step 4); the seal key and the
other secrets are read from `pass` or streamed in at run time and never live
other secrets are decrypted from `~/.config/homelab-secrets` or streamed in at run time and never live
in CI.
- **OpenBao unseals itself, but only under the seal key the snapshot was
written with.** Three raft replicas mean the service survives losing one; a
+14 -6
View File
@@ -35,7 +35,8 @@ set -euo pipefail
# The mirror is kept current server-side by Gitea's native push-mirror. Full
# cold-rebuild procedure: kubernetes/RUNBOOK-cold-rebuild.md.
#
# Requires: talosctl, kubectl, flux, sops age key at ~/.config/sops/age/keys.txt
# Requires: talosctl, kubectl, flux, sops age key at ~/.config/sops/age/keys.txt,
# age + age-plugin-yubikey (the DR secrets in ~/.config/homelab-secrets)
TALOS_DIR="$(cd "$(dirname "$0")/talos" && pwd)"
REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
@@ -243,8 +244,13 @@ git_token_for_source() {
fi
}
HOMELAB_SECRETS_DIR="${HOMELAB_SECRETS_DIR:-$HOME/.config/homelab-secrets}"
HOMELAB_AGE_IDENTITY="${HOMELAB_AGE_IDENTITY:-$HOME/.config/age/yubikeys.txt}"
# Literal ~ on purpose: the commands are eval'd on the workstation, which
# expands it there, and the in-cluster Job prints them for the operator to run
# on that workstation, where the Job's own HOME (/work) would be wrong.
# shellcheck disable=SC2088
[[ -n "${HOMELAB_SECRETS_DIR:-}" ]] || HOMELAB_SECRETS_DIR='~/.config/homelab-secrets'
# shellcheck disable=SC2088
[[ -n "${HOMELAB_AGE_IDENTITY:-}" ]] || HOMELAB_AGE_IDENTITY='~/.config/age/yubikeys.txt'
AGE_SECRET="age -d -i $HOMELAB_AGE_IDENTITY $HOMELAB_SECRETS_DIR"
# Format: <file in SECRETS_DIR>|<what it is>|<how the operator produces it>
@@ -329,7 +335,7 @@ require_secrets() {
# maintenance.
#
# SECRETS_SELF_FETCH records which world we are in, and it is decided once:
# an empty OPENBAO_SECRETS_DIR on entry means pass, an inherited one means
# an empty OPENBAO_SECRETS_DIR on entry means local age decryption, an inherited one means
# the Job. Later calls follow that decision instead of re-deriving it from
# state this function has since changed.
if [[ -z "$OPENBAO_SECRETS_DIR" || "${SECRETS_SELF_FETCH:-0}" == "1" ]]; then
@@ -353,8 +359,10 @@ require_secrets() {
# a YubiKey touch, and asking twice for the same key is how an
# unattended run turns into a hung one.
[[ -s "$OPENBAO_SECRETS_DIR/$name" ]] && continue
if ! eval "$how" > "$OPENBAO_SECRETS_DIR/$name" 2>"$OPENBAO_SECRETS_DIR/.err"; then
echo; cat "$OPENBAO_SECRETS_DIR/.err" >&2
# stderr stays on the terminal: age-plugin-yubikey asks for the
# PIN and the touch there, and a captured prompt is a silent hang.
echo
if ! eval "$how" > "$OPENBAO_SECRETS_DIR/$name"; then
fail "could not obtain the $what with: $how"
fi
[[ -s "$OPENBAO_SECRETS_DIR/$name" ]] \
-6
View File
@@ -43,12 +43,6 @@ provider "proxmox" {
endpoint = var.proxmox_api_url
api_token = "${var.proxmox_user}!${var.proxmox_token_id}=${var.proxmox_token_secret}"
insecure = var.proxmox_tls_insecure
ssh {
agent = false
username = "root"
private_key = var.ssh_private_key
}
}
provider "unifi" {
-12
View File
@@ -28,16 +28,4 @@ variable "proxmox_tls_insecure" {
default = false
}
variable "ssh_public_key" {
description = "Public SSH key to inject into containers"
type = string
default = null
}
variable "ssh_private_key" {
description = "SSH private key for Proxmox provider to import disk images"
type = string
sensitive = true
default = ""
}