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
41 changed files with 326 additions and 407 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,8 +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
@@ -11,6 +11,8 @@ netbird_management_url: "https://netbird.renner.uno"
# 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
+18 -5
View File
@@ -33,22 +33,35 @@
update_cache: true
# Arch has no netbird package in its repositories; the release binary is
# verified against the release's checksums file.
# 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: Download the NetBird release
- 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: "/tmp/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: "/tmp/netbird_{{ netbird_client_version }}_linux_amd64.tar.gz"
src: "{{ netbird_download_dir }}/netbird_{{ netbird_client_version }}_linux_amd64.tar.gz"
dest: /usr/local/bin
remote_src: true
include:
- netbird
owner: root
+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
-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 = ""
}