Compare commits

..
Author SHA1 Message Date
claude-botandClaude Opus 5.5 abc89c2476 fix(review-bot): post a review verdict, never a comment
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/review-bot-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
The bot posted COMMENT whenever the agent said comment, gave no
verdict, or approved with a red or yellow section. A COMMENT is no
review verdict, so the PR showed no approval or rejection by the bot.

Every review is now APPROVED or REQUEST_CHANGES. Only blocking findings
request changes: the verdict request-changes, a Blocking section, or an
empty change. Suggestions, nits and unverified points stay in the body
of an approval. An answer without a verdict line is not posted; the
review fails instead of guessing. The prompt offers only request-changes
and approve.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 23:26:33 +01:00
claude-botandClaude Opus 5.5 8def607a73 fix(review-bot): findings of the review of #1503
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/review-bot-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
- The sweep leaves queued and running reviews alone. Enqueueing one
  marked it for another run when it ended, so a failing review was
  chained back-to-back and FAILURE_COOLDOWN never applied.
- Mistral's read-only check accepts _compare_, so a re-review that
  calls compare_commits is not rejected as a violation.
- SELECTOR_MIN_CONFIDENCE=NaN is rejected instead of disabling the
  fallback.
- The prompt always lists recent base commits on the changed files, so
  a change already on the base is still caught when mergeable is true.
- The requested-review search pages until an empty page.
- Doc comments describe the request-only, any-repository trigger.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 23:10:33 +01:00
claude-botandClaude Opus 5.5 fcdba4505d fix(review-bot): prompt takes the base branch from the PR
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/review-bot-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was canceled
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 23:04:09 +01:00
claude-botandClaude Opus 5.5 9ec31f0769 feat(review-bot): review every repository that requests a review
REPOS is gone. The webhook takes a review request from any repository,
and the sweep asks Gitea for every open pull request that requests a
review from the bot (issues/search review_requested=true), so a repo
needs no webhook and no config entry: requesting review-bot is enough.

The sweep runs every 5 minutes, since it is now one search plus two
reads per requested PR. A head whose review failed is left to the next
request or FAILURE_COOLDOWN (1h), so a failing review is not rerun on
every sweep.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 23:03:53 +01:00
claude-botandClaude Opus 5.5 1ea1562d6f feat(review-bot): review on request, read less, log token usage
Reviews start only when review-bot is a requested reviewer: the
pull_request webhook with action review_requested for the bot, or the
sweep finding the bot in requested_reviewers. Gitea drops the request
when the bot posts its review, so each request yields one review. Pushes
and opened PRs alone start nothing.

The prompt reads the diff first, opens whole files only where the hunks
are not enough, reads the base side only when mergeable is false or the
base moved, and aims at 10-20 tool calls. Re-reviews read only the
commits since the last marker via compare_commits, now an enabled tool.

Every review logs and exports input, output and cache tokens
(review_bot_tokens_total, review_bot_tool_calls_total).

The selector falls back to the default model when it picks another one
with less than SELECTOR_MIN_CONFIDENCE (0.6), and asks for the cheapest
capable model. Sonnet is the default, Opus the exception, Haiku is gone
(41-50 tool calls on trivial PRs), Mistral stays as a separate budget.
Anthropic effort defaults to medium.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 23:03:53 +01:00
k.renner 61be8d4c43 Merge pull request 'chore(ansible): remove the Shelly NTP play' (#1501) from remove-shelly-ntp-play into main
Reviewed-on: #1501
2026-10-27 21:45:26 +00:00
k.renner 0fd4b81967 Merge pull request 'fix(ansible): NTP and dist-upgrade checks fail instead of passing silently' (#1500) from fix-play-checks into main
Reviewed-on: #1500
2026-10-27 21:45:08 +00:00
claude-botandClaude Opus 5.5 2111189ec7 chore(ansible): remove the Shelly NTP play
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
Shelly devices are not Ansible-managed. Drops
plays/configure-shelly-ntp.yml, its only helper files/discover_shelly.py,
and the mention in site.yml.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 22:43:02 +01:00
claude-botandClaude Opus 5.5 78ad56e812 fix(ansible): NTP and dist-upgrade checks fail instead of passing silently
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
install-ntp-ha.yml:
- 'chronyc clients | head -10' could not fail: without pipefail the rc
  is head's, and the result was never used. 'chronyc -n tracking'
  fails when chronyd does not answer.
- The udp/123 check had failed_when: false and only printed
  LISTENING/NOT LISTENING. 'ss -Hlunp sport = :123' now fails unless
  chronyd owns the socket.
- The v4/v6 VIP presence checks had failed_when: false, so an ip
  error (e.g. a wrong interface name) read as 'BACKUP'. They now fail
  on a non-zero rc; an empty result still means BACKUP.

update-dist-upgrade.yml: failed_when already passes 'No new release
found'; ignore_errors: true on top of it turned a failed
do-release-upgrade into 'ignored' and the play went on to reboot.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 22:41:59 +01:00
k.renner e17b7a5d49 Merge pull request 'fix(ansible): NTP VIP check queries NTP, not the chronyc command port' (#1499) from fix-ntp-vip-check into main
Reviewed-on: #1499
2026-10-27 21:36:41 +00:00
claude-botandClaude Opus 5.5 f02c1284de fix(ansible): NTP VIP check queries NTP, not the chronyc command port
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
chronyc -h <vip> talks to chronyd's command port, which chrony.conf binds
to loopback only, so it always returned '506 Cannot talk to daemon'. The
'|| echo' turned that into rc 0, so the task reported ok on every run.

chronyd -Q with a server directive on the command line does a one-shot
NTP query: no config file means no pidfile, and client-only mode zeroes
the NTP and command ports, so it runs alongside the live daemon. -t 10
makes it fail rather than hang when the VIP does not answer.

The VIP presence check used 'ip addr show | grep 10.2.40.4', which also
matches 10.2.40.40-49; it now uses 'ip -4 addr show to <vip>' like the
v6 check.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 22:10:04 +01:00
k.renner 71900cc055 Merge pull request 'fix(ansible): site.yml from the workstation; NetBird dashboard login' (#1498) from ansible-check-mode into main
Reviewed-on: #1498
2026-10-27 20:09:11 +00:00
claude-botandClaude Opus 5.5 db5a5e2a57 fix(netbird-server): dashboard CSP allows the Authentik host
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
The dashboard builds its Content-Security-Policy from AUTH_AUTHORITY, which
for Authentik is a path (https://auth.renner.uno/application/o/netbird/).
A CSP source with a path only matches URLs below it, so the browser blocked
the token endpoint (/application/o/token/): no code exchange, no refresh,
and the dashboard showed "Error: Unauthenticated" until site data was
cleared. Verified in Chromium against the live header; NETBIRD_CSP adds
https://auth.renner.uno, derived from netbird_auth_issuer.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 21:07:46 +01:00
claude-botandClaude Opus 5.5 bf8e972ad3 fix(ansible): site.yml runs from the workstation
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
- crowdsec-edge-bouncer, netbird-client: the controller downloads also run
  in check mode; they only write on the controller and the next step needs
  the archive
- uptime-kuma: reads and writes OpenBao through its HTTP API (uri) instead
  of community.hashi_vault, so the controller needs no hvac library. The
  token comes from VAULT_TOKEN or ~/.vault-token, as with the bao CLI; the
  owner OIDC role gets policy uptime-kuma-vps.
- deploy-uptime-kuma pipeline removed: its deploy step was gone, only the
  renewal cron for the CI token remained.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 20:54:41 +01:00
claude-botandClaude Opus 5.5 4dd74ef38b fix(ansible): site.yml --check reads real state
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
Check mode skips command and uri tasks, so every assert or condition on a
read-only result failed or saw nothing (ssh-ca: "dict has no attribute
content" on the CA key). The tasks that only read state run in check mode
too (check_mode: false): the CA key fetch, sshd unit lookup, key
fingerprints, sshd listeners, netbird status, grep/pgrep/ip/chronyc/
timedatectl checks. The sshd -T assert is skipped in check mode, as sshd
reads the files check mode has not written.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 20:38:03 +01:00
k.renner 970226d3be Merge pull request 'fix(ansible): drop node-exporter from the state' (#1497) from drop-node-exporter into main
Reviewed-on: #1497
2026-10-27 19:36:23 +00:00
claude-botandClaude Opus 5.5 5fdfbb6772 fix(ansible): drop node-exporter from the state
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
It was never installed on any managed host (/opt/node-exporter absent on
all four, site.yml --check) and Prometheus scrapes no :9100 target outside
the cluster. The role also downloaded from github.com on the host, which
proxy-1 (IPv6-only) cannot reach. Play and role removed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 20:35:37 +01:00
k.renner 2ac5e705fd Merge pull request 'feat(ansible): one site.yml for the whole state' (#1496) from ansible-site into main
Reviewed-on: #1496
2026-10-27 19:32:56 +00:00
claude-botandClaude Opus 5.5 9e1dfc99bd feat(ansible): one site.yml for the whole state
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ansible-playbook site.yml, from ansible/, applies every state play in
dependency order; the inventory and vault password come from ansible.cfg,
now as paths relative to it. One-off actions (update-os,
update-dist-upgrade, rotate-netbird-secrets, configure-shelly-ntp) stay
separate.

- gathering=smart: facts once per host and run, not per play
- ControlPersist=30m so a run costs one YubiKey PIN per host;
  plays/close-connections.yml ends site.yml and the action playbooks and
  closes the connections
- qemu-guest-agent only on VMs (edge-1 is bare metal)
- removed: edge-mqtt-bridge.yml (install-edge-host.yml carries the role,
  sml2mqtt is gone from edge-1) and the decommissioned claude-ws (inventory,
  group_vars, role, play)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 20:29:33 +01:00
k.renner c2b0d9b6d9 Merge pull request 'perf(ssh-ca): find the sshd unit without service_facts' (#1495) from ssh-ca-no-service-facts into main
Reviewed-on: #1495
2026-10-27 19:16:37 +00:00
claude-botandClaude Opus 5.5 7ccdd8f405 perf(ssh-ca): find the sshd unit without service_facts
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
service_facts took 3.5-11.7 s per host (profile over the four [ssh_ca]
hosts: 32 s of a 62 s run). The role only needs the running sshd unit,
which systemctl list-units answers in milliseconds; on Ubuntu the
sshd.service alias resolves to ssh.service.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 20:15:40 +01:00
k.renner 36c958fb14 Merge pull request 'perf(ansible): enable pipelining' (#1494) from ansible-pipelining into main
Reviewed-on: #1494
2026-10-27 19:11:27 +00:00
claude-botandClaude Opus 5.5 0878054873 perf(ansible): enable pipelining
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
Without it every task is a temp dir, an sftp upload, a chmod, the run and
a cleanup, each a separate SSH round trip. Measured over the mesh against
netbird.renner.uno with a warm ControlMaster: 1.85 s per command task
without pipelining, 0.85 s with it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 20:10:40 +01:00
k.renner e502990db8 Merge pull request 'feat(ssh-ca): claude-bot rights for diagnosis and restarts' (#1493) from claude-bot-docker-logs into main
Reviewed-on: #1493
2026-10-27 19:02:32 +00:00
claude-botandClaude Opus 5.5 8832d9f23f fix(ssh-ca): no password logins
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
edge-1 and netbird accepted password authentication (OpenSSH default; no
config line sets it), the proxies did not. The ssh-ca drop-in turns off
password and keyboard-interactive authentication and the role asserts it
in sshd -T. The edge-host ISO renders the same drop-in.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 20:01:20 +01:00
claude-botandClaude Opus 5.5 e72c31f3e9 feat(ssh-ca): claude-bot rights for diagnosis and restarts
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
Replaces the docker-logs flag with one fixed set for every [ssh_ca] host:
- systemd-journal group instead of sudo journalctl/systemctl status, which
  also allowed journalctl --vacuum-*/--rotate
- sudo: ss -tulpn, sshd -T, nft list ruleset (fixed args: nft joins argv
  into one command line), netbird status [*], docker ps/logs/stats and
  docker restart (no docker start: -a/-i attach to stdin)
- per-host restartable units: proxies netbird, netbird-proxy-flood-watchdog,
  crowdsec-firewall-bouncer; netbird netbird; edge-1 mosquitto,
  edge-mqtt-cred, edge-alloy, keepalived, chronyd

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 19:57:00 +01:00
claude-botandClaude Opus 5.5 50b66682d3 feat(ssh-ca): claude-bot may read container logs on netbird
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
The NetBird services on netbird.renner.uno log only to docker, which
claude-bot cannot reach. New role flag ssh_ca_claude_docker_logs adds
sudo for docker ps and docker logs; enabled for netbird.renner.uno.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 19:50:21 +01:00
k.renner 1848079297 Merge pull request 'feat(ansible): netbird sshd listens on the NetBird interface only' (#1492) from ssh-mesh-only-netbird into main
Reviewed-on: #1492
2026-10-27 18:29:26 +00:00
claude-botandClaude Opus 5.5 48f1574d29 feat(ansible): netbird sshd listens on the NetBird interface only
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
proxy-1 and proxy-2 were verified: only *%wt0:22 listens and the public
addresses refuse port 22.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 19:26:30 +01:00
k.renner df6276101f Merge pull request 'fix(ansible): one SSH login per host for the SSH plays' (#1491) from ansible-fewer-logins into main
Reviewed-on: #1491
2026-10-27 18:25:31 +00:00
claude-botandClaude Opus 5.5 df0150d24a fix(ansible): one SSH login per host for the SSH plays
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
From a workstation every new SSH login costs a YubiKey PIN and touch.
ssh-mesh-only reconnected on every run; the reconnect now sits in an
include that runs only when sshd is switched, since meta reset_connection
ignores when:. configure-ssh-ca is one play: with serial: 1, the second
play reached each host after its 60 s ControlPersist had expired, so it
logged in again.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 19:24:07 +01:00
k.renner 880bf00baa Merge pull request 'feat(ansible): proxy-1 sshd listens on the NetBird interface only' (#1490) from ssh-mesh-only-proxy-1 into main
Reviewed-on: #1490
2026-10-27 18:17:57 +00:00
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
claude-botandClaude Opus 5.5 e4f14180ff feat(bootstrap): DR secrets from YubiKey/paper-key age files, not pass
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
The cold-rebuild secrets (seal key, Hetzner restore keys, GHCR pull
token) came from the pass store: one gpg key file on the owner's laptop,
readable by any process of that user while gpg-agent caches the
passphrase, and with no second copy.

bootstrap.sh now decrypts ~/.config/homelab-secrets/<name>.age, files
encrypted to both YubiKeys (touch) and an offline paper key; a copy is
on nas-2. HOMELAB_AGE_IDENTITY switches to the paper key when both
sticks are gone. The in-cluster Job path is unchanged: the printed
streaming command is the age one. Runbook and the two OpenBao alert
texts point at the new files.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 15:07:23 +01:00
k.renner a44b55b942 Merge pull request 'feat(ansible): remaining CA hosts accept only break-glass and CI keys (SSH CA phase 4)' (#1480) from ssh-keys-managed-rest into main
ci/woodpecker/push/deploy-ssh-ca Pipeline was successful
Reviewed-on: #1480
2026-10-27 13:31:34 +00:00
k.renner 0001f2f0c2 Merge pull request 'fix(crowdsec): never ban successful UI bursts on http-crawl-non_statics' (#1478) from worktree-crowdsec-ma-imageproxy-whitelist into main
Reviewed-on: #1478
2026-10-27 13:26:45 +00:00
claude-botandClaude Opus 5.5 f78e4bc713 feat(ansible): remaining CA hosts accept only break-glass and CI keys
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
proxy-1 is verified after #1477/#1479: authorized_keys holds exactly
breakglass-neo-6532348 and the CI key, and CI still reaches it.

Removed with this run, per the [ssh_ca] report and the sshd journals
(no root login with any of them except id_ecdsa, the owner's old key):
- proxy-2: id_ecdsa, ansible-runner@ci-new
- netbird: id_ecdsa, ansible-runner@ci-new, krenner@fortress,
  netbird-tunnel (SHA256:cNy8...), a second woodpecker-ci key
  (SHA256:hQxz...); none used for a root login since 2026-08-29
- edge-1: id_ecdsa, ansible-runner@ci-new, krenner@fortress
The CI key keeps from= the pod network on edge-1 (LAN, CI arrives from
10.244.2.x) and has none on proxy-2 and netbird (CI arrives NATed).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 14:26:06 +01:00
claude-botandClaude Opus 5.5 e855921b39 fix(crowdsec): drop crawl-non_statics alerts for successful bursts
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
Replaces the per-service Music Assistant/Immich parser whitelists with
one postoverflow whitelist: http-crawl-non_statics overflows are dropped
unless at least half of the requests in the alert are 4xx.

Every self-ban in the alert history (MA 350/368/477, Immich 149/150) was
a burst of 200s from a signed-in UI. Every scanner ban on this scenario
was 404s (/.env, /.git/...). The per-service rules only covered apps
that had already failed and muted all scenarios on their paths; this
covers any image-heavy UI and touches only this scenario.

Verified in a crowdsecurity/crowdsec:v1.8.1 replay: a 60x200 burst is
whitelisted, a 60x404 burst with distinct paths still overflows and is
not whitelisted.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 14:23:43 +01:00
k.renner ec5b7314d6 Merge pull request 'fix(ansible): no from= on the CI key for internet hosts' (#1479) from ssh-ci-key-from-per-host into main
ci/woodpecker/push/deploy-netbird-proxy Pipeline was successful
ci/woodpecker/push/deploy-ssh-ca Pipeline was successful
Reviewed-on: #1479
2026-10-27 13:20:28 +00:00
claude-botandClaude Opus 5.5 d46d6fde93 fix(ansible): no from= on the CI key for internet hosts
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
#1477 locked CI out of proxy-1: the CI key got from= the pod network,
but CI reaches the internet hosts NATed from the home WAN address
(proxy-2 journal: root logins from 84.171.37.13). The from= came from
plays/configure-ssh-keys.yml, which no pipeline ever ran, so it was
never in effect on those hosts. LAN hosts do see the pod address
(edge-1 journal: 10.244.2.x).

ssh_ci_key_from is now per host: the pod CIDRs by default, empty for
[netbird_proxy] and netbird.renner.uno. The owner already removed the
from= on proxy-1 by hand; this keeps the next run from restoring it.
Restricting SSH on internet hosts to the NetBird mesh is the follow-up.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 14:15:01 +01:00
claude-botandClaude Opus 5.5 16ca2f69b7 fix(crowdsec): whitelist Immich timeline/asset loads
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
Scrolling the Immich timeline fetches a /api/timeline/bucket and then
every /api/assets/<uuid> in it; 46-54 requests in 3-9s fired
crowdsecurity/http-crawl-non_statics against a residential Telekom IPv6
(alerts 149/150, 2026-10-23).

Same parser-stage pattern as the Music Assistant whitelist, scoped to
the Immich Traefik router and those two path prefixes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 13:55:40 +01:00
claude-botandClaude Opus 5.5 cb4cabeca7 fix(crowdsec): whitelist Music Assistant imageproxy cover-art loads
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was canceled
The MA UI loads every cover through /imageproxy/<hash>?size=512 in one
burst; a library view (47 requests in 3s) fired
crowdsecurity/http-crawl-non_statics and banned a residential Telekom
IPv6 (alert 477, 2026-10-27).

Parser-stage whitelist scoped to the MA Traefik router and the
/imageproxy/ path. The Traefik parser sets no target_fqdn, so the
router name is used as host scope.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 13:54:34 +01:00
k.renner 2f63d2d426 Merge pull request 'feat(ansible): proxy-1 accepts only break-glass and CI keys (SSH CA phase 4)' (#1477) from ssh-keys-managed-proxy-1 into main
ci/woodpecker/push/deploy-ssh-ca Pipeline was successful
Reviewed-on: #1477
2026-10-27 12:14:08 +00:00
claude-botandClaude Opus 5.5 04b704fdbd feat(ansible): proxy-1 accepts only break-glass and CI keys
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
First host in [ssh_keys_managed]. root on proxy-1 currently has
id_ecdsa (krenner@krenner-XPS-13-9310), woodpecker-ci@homelab
(SHA256:DQWPS...) and ansible-runner@ci-new (dead since #184). After
this run: breakglass-neo-6532348 and the same CI key with its from=
pod-network restriction; people log in with CA certificates. proxy-2
is the twin, so the public edge survives a mistake here.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 12:49:46 +01:00
k.renner 63df267a2f Merge pull request 'feat(ansible): one admin key list; report authorized_keys (SSH CA phase 4, step 1)' (#1476) from ssh-keys-managed into main
ci/woodpecker/push/deploy-infrastructure Pipeline was successful
ci/woodpecker/push/deploy-ssh-ca Pipeline was successful
ci/woodpecker/cron/e2e Pipeline failed
ci/woodpecker/cron/security-scan Pipeline was successful
Reviewed-on: #1476
2026-10-27 11:38:59 +00:00
claude-botandClaude Opus 5.5 6c5bdd3e46 feat(ansible): one admin key list; report authorized_keys (phase 4)
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
Phase 4 of the SSH CA, step 1. Nothing is removed from any host yet.

- ssh_admin_authorized_keys (group_vars/all) is the single list of
  plain keys the admin logins may accept: the break-glass PIV key on
  the offline YubiKey NEO and the CI deploy key (from= pod network).
- ssh-ca reports, on every [ssh_ca] host, the fingerprints of the
  admin logins' current authorized_keys.
- configure-ssh-ca.yml gets a second play that sets the admin logins'
  authorized_keys exclusively to that list on [ssh_keys_managed]. The
  group is empty; hosts join one at a time after the report.
- Removed the three dead key lists: plays/configure-ssh-keys.yml (no
  pipeline ran it), common_ssh_public_keys (defined, never used) and
  opentofu/locals.tf common_ssh_keys (no reference). They carried
  krenner@fortress, ansible-runner@ci-new and the laptop id_ecdsa key.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 12:28:48 +01:00
k.renner 8a13b241cb Merge pull request 'feat(gitea): trust the OpenBao SSH CA (SSH CA phase 3b)' (#1475) from gitea-ssh-ca-trust into main
Reviewed-on: #1475
2026-10-27 10:42:44 +00:00
claude-botandClaude Opus 5.5 1b63cb8970 feat(gitea): trust the OpenBao SSH CA (phase 3b)
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
Step A is verified: Secret gitea-ssh-ca holds exactly the CA key
(SHA256:xEhuqx..., one line, 81 bytes like OpenBao's content-length),
so the webhook provider passes a text/plain body through unchanged.

- StatefulSet: SSH_TRUSTED_USER_CA_KEYS from the Secret,
  SSH_AUTHORIZED_PRINCIPALS_ALLOW=username. The built-in SSH server
  looks a cert's principals up as principal keys, so each user
  registers their username once as a principal.
- ExternalSecret: trim the trailing newline before it reaches app.ini.
- Reloader annotation: Gitea reads env only at start, so a CA rotation
  restarts it. The step A comment claimed the refresh alone reaches
  Gitea; corrected.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 11:40:05 +01:00
k.renner 4ba3f03b18 Merge pull request 'feat(gitea): sync the OpenBao SSH CA public key into a Secret (SSH CA phase 3a)' (#1474) from gitea-ssh-ca-secret into main
Reviewed-on: #1474
2026-10-27 10:35:31 +00:00
claude-botandClaude Opus 5.5 739b41b623 feat(gitea): sync the OpenBao SSH CA public key into a Secret
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
Phase 3, step A of the SSH CA: Gitea's built-in SSH server needs the CA
key as SSH_TRUSTED_USER_CA_KEYS. A namespaced ESO webhook SecretStore
reads OpenBao's unauthenticated /v1/ssh-client-signer/public_key from
the in-cluster Service into Secret gitea-ssh-ca, so the key has one
source. Gitea is not wired to it yet; step B follows once the Secret
is verified to hold exactly the CA key (the ESO docs do not say how a
text/plain body without result.jsonPath is handled).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 11:22:47 +01:00
security-bot f8796b181f chore(image-automation): bump gitea.renner.uno/konairius/wiki:build.1793094262
Updates managed by image-automation-controller.
2026-10-27 09:47:02 +00:00
k.renner e8c72cd1f1 Merge pull request 'feat(ansible): SSH CA on edge-1' (#1473) from ssh-ca-edge-1 into main
ci/woodpecker/push/deploy-ssh-ca Pipeline was successful
Reviewed-on: #1473
2026-10-27 09:46:56 +00:00
claude-botandClaude Opus 5.5 2da554d40e feat(ansible): SSH CA on edge-1
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
netbird verified (Ubuntu, ssh.service): claude-bot cert login, sudo
journalctl allowed, systemctl restart refused. edge-1 is the last
Ansible-managed host (OpenSSH_10.6 banner like the proxies).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 10:44:45 +01:00
k.renner 35461f1f54 Merge pull request 'fix(ansible): ssh-ca reloads the sshd unit that is running' (#1472) from ssh-ca-running-unit into main
ci/woodpecker/push/deploy-ssh-ca Pipeline was successful
Reviewed-on: #1472
Reviewed-by: Review Bot <review-bot@renner.uno>
2026-10-27 09:42:25 +00:00
claude-botandClaude Opus 5.5 adedc98b50 fix(ansible): ssh-ca reloads the sshd unit that is running
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
The netbird run (pipeline 14795) failed at the reload: 'Could not find
the requested service sshd.service'. Ubuntu's service_facts list
sshd.service as a not-found alias next to the real ssh.service
(running; disabled because ssh.socket activates it), and the role only
checked that the name exists.

The unit is now the one of sshd.service/ssh.service whose state is
running, asserted to be exactly one.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 10:41:06 +01:00
k.renner 1f228c94ef Merge pull request 'feat(ansible): SSH CA on netbird.renner.uno' (#1471) from ssh-ca-netbird into main
ci/woodpecker/push/deploy-ssh-ca Pipeline failed
Reviewed-on: #1471
2026-10-27 09:37:16 +00:00
claude-botandClaude Opus 5.5 9232937b85 feat(ansible): SSH CA on netbird.renner.uno
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
proxy-2 verified (claude-bot cert login, sudo allow/deny as on
proxy-1). netbird is the first Ubuntu host (OpenSSH_9.6p1
Ubuntu-3ubuntu13.20): unit ssh.service, which the role picks from
service_facts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 10:36:21 +01:00
k.renner 707f6ab840 Merge pull request 'feat(ansible): SSH CA on proxy-2' (#1470) from ssh-ca-proxy-2 into main
ci/woodpecker/push/deploy-ssh-ca Pipeline was successful
Reviewed-on: #1470
2026-10-27 09:33:35 +00:00
claude-botandClaude Opus 5.5 7600a9dbf9 feat(ansible): SSH CA on proxy-2
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
proxy-1 is verified: owner cert login as root (sk-ed25519 cert) and
claude-bot cert login with the expected sudo rules both work. proxy-2
is its twin (same OpenSSH_10.6 banner).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 10:31:42 +01:00
k.renner b12ef67002 Merge pull request 'fix(ansible): ssh-ca compares sshd -T output case-insensitively' (#1469) from ssh-ca-assert-case into main
ci/woodpecker/push/deploy-ssh-ca Pipeline was successful
Reviewed-on: #1469
Reviewed-by: Review Bot <review-bot@renner.uno>
2026-10-27 09:25:47 +00:00
claude-botandClaude Opus 5.5 e6e0e44b6a fix(ansible): compare sshd -T output case-insensitively
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
The second deploy-ssh-ca run on proxy-1 failed at the assert although
all three settings were active: OpenSSH 10.6 prints them as
TrustedUserCAKeys, AuthorizedPrincipalsFile and RevokedKeys, the check
expected lowercase keywords. The run stopped before the reload, so the
running sshd still has the old config.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 10:24:41 +01:00
k.renner 39b7fc7fc3 Merge pull request 'fix(ansible): ssh-ca reloads sshd by its real unit name' (#1468) from ssh-ca-reload-fix into main
ci/woodpecker/push/deploy-ssh-ca Pipeline failed
Reviewed-on: #1468
Reviewed-by: Review Bot <review-bot@renner.uno>
2026-10-27 09:09:51 +00:00
claude-botandClaude Opus 5.5 33dd968a25 fix(ansible): reload sshd by its real unit name on every run
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
The first deploy to proxy-1 failed at the reload: 'Could not find the
requested service ssh'. proxy-1 is not Debian (banner OpenSSH_10.6
without distro suffix, root shell /usr/bin/bash), so the unit is
sshd.service; the handler only special-cased RedHat.

The unit is now picked from service_facts. The reload runs on every
run instead of via a handler: sshd -T only parses the files, so after
a failed reload a re-run with nothing changed would otherwise leave the
running daemon on the old config.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 10:05:19 +01:00
k.renner d8813a68b7 Merge pull request 'feat(ansible): hosts trust the OpenBao SSH user CA (SSH CA phase 2)' (#1467) from ssh-ca-phase2 into main
ci/woodpecker/push/deploy-ssh-ca Pipeline failed
Reviewed-on: #1467
2026-10-27 08:53:33 +00:00
claude-botandClaude Opus 5.5 9dcaf42f96 fix(ansible): start the SSH CA rollout on proxy-1
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline failed
claude-ws is decommissioned. proxy-1 resolves by name, answers on 22,
and has proxy-2 as its twin, so the public edge survives a bad run.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 09:50:58 +01:00
security-bot d033503fc3 chore(image-automation): bump gotenberg/gotenberg:8.39.0
Updates managed by image-automation-controller.
2026-10-27 08:50:46 +00:00
claude-botandClaude Opus 5.5 66c56288a7 feat(ansible): hosts trust the OpenBao SSH user CA (phase 2)
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was canceled
New role ssh-ca with play configure-ssh-ca.yml (serial: 1) and pipeline
deploy-ssh-ca. Hosts in the new [ssh_ca] group get:

- TrustedUserCAKeys from OpenBao's public_key endpoint, fetched on the
  controller at run time (no copy in git).
- AuthorizedPrincipalsFile: k.renner for root/ladmin (whichever
  exists), claude-bot for Claude's own claude-bot account.
- RevokedKeys, always deployed: sshd refuses all key logins when the
  file is set but missing.
- claude-bot sudo: read-only journalctl/systemctl status/ss/sshd -T,
  restart/reload only for ssh_ca_claude_sudo_units; NOEXEC against
  pager shell escapes; visudo-validated.

The drop-in is tested with sshd -t and removed again on failure; sshd -T
must show all three settings afterwards. authorized_keys is untouched,
so this only adds a login path. [ssh_ca] starts with claude-ws only.

ssh-sign-owner.hcl now also records the mount, CA and owner role
commands (review on #1466).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 09:47:48 +01:00
k.renner 2b309dec92 Merge pull request 'feat(openbao): SSH client CA roles (SSH CA phase 1)' (#1466) from ssh-ca-phase1 into main
Reviewed-on: #1466
2026-10-27 08:42:24 +00:00
k.renner 396f839567 Merge pull request 'chore(ci): bump ci-claude image to 2.1.311' (#1464) from ci-image/claude-2.1.311 into main
Reviewed-on: #1464
Reviewed-by: Review Bot <review-bot@renner.uno>
2026-10-27 08:42:06 +00:00
claude-botandClaude Opus 5.5 2e412e6448 feat(openbao): SSH client CA roles for owner and claude-bot
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
Phase 1 of the SSH CA. The ssh-client-signer mount and its ed25519 CA
were set up by hand (root token); this records the roles and the
claude-bot signing policy.

- role owner: principal k.renner, 6-month certs. The mount was enabled
  with -max-lease-ttl=4380h; the 768h system default would cap it.
- role claude: principal claude-bot, 1h certs, pty only.
- policy ssh-sign-claude for the claude-bot userpass user, next to its
  existing claude-bot-write.

Verified: a YubiKey sk-ssh-ed25519 key signed on role owner yields a
sk-ssh-ed25519-cert-v01 cert, principal k.renner, valid to 2027-04-27.
CA fingerprint SHA256:xEhuqxqFVZ/ig6KpAtHOhLbwsIhyWpzXhvDcjIfM/Js,
served unauthenticated at /v1/ssh-client-signer/public_key.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 09:39:19 +01:00
security-bot 7f61ad0ce9 chore(image-automation): bump gitea.renner.uno/gitea_admin/review-bot:2026-10-27-0757 gitea.renner.uno/gitea_admin/ci-backup-verify:2026-10-27
Updates managed by image-automation-controller.
2026-10-27 08:23:50 +00:00
security-bot 6503161146 chore(image-automation): bump gitea.renner.uno/gitea_admin/kuma-sync:2026-10-27-0801
Updates managed by image-automation-controller.
2026-10-27 08:23:47 +00:00
security-bot 01133ca106 chore(image-automation): bump gitea.renner.uno/gitea_admin/secret-rotator:2026-10-27
Updates managed by image-automation-controller.
2026-10-27 08:23:46 +00:00
security-bot ce5be4d067 chore(image-automation): bump gitea.renner.uno/gitea_admin/cloudflare-lb-sync:2026-10-27-0807
Updates managed by image-automation-controller.
2026-10-27 08:17:10 +00:00
security-bot 150e8229fd chore(image-automation): bump gitea.renner.uno/konairius/hermes:build.1793088058
Updates managed by image-automation-controller.
2026-10-27 08:07:03 +00:00
security-bot 0247b3dfd5 chore(image-automation): bump gitea.renner.uno/gitea_admin/authentik-gate-sync:2026-10-27-0801
Updates managed by image-automation-controller.
2026-10-27 08:06:48 +00:00
claude-bot 9b7a64e48c chore(ci): bump ci-claude image to 2.1.311
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
2026-10-27 07:53:48 +00:00
k.renner aee26df4b8 Merge pull request 'fix(wiki): wiki-sync retries while the wiki pod restarts' (#1462) from alert-fix/issue-1461 into main
Reviewed-on: #1462
Reviewed-by: Review Bot <review-bot@renner.uno>
2026-10-27 07:53:21 +00:00
k.renner 0bf2cd309e Merge pull request 'feat(openbao): OIDC login via Authentik (SSH CA phase 0)' (#1463) from worktree-openbao-oidc-authentik into main
ci/woodpecker/push/ci-claude-refresh Pipeline was successful
ci/woodpecker/push/deploy-claude-workstation Pipeline failed
ci/woodpecker/push/deploy-edge-mqtt-bridge Pipeline was successful
ci/woodpecker/push/deploy-infrastructure Pipeline was successful
ci/woodpecker/push/deploy-netbird-client Pipeline failed
ci/woodpecker/push/deploy-netbird-proxy Pipeline was successful
ci/woodpecker/push/deploy-netbird-server Pipeline was successful
ci/woodpecker/push/deploy-uptime-kuma Pipeline was successful
ci/woodpecker/push/hermes-image Pipeline was successful
ci/woodpecker/push/build-ci-images Pipeline was successful
Reviewed-on: #1463
2026-10-27 07:52:50 +00:00
claude-bot 73d85b4aa8 Merge remote-tracking branch 'origin/main' into worktree-openbao-oidc-authentik
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline failed
# Conflicts:
#	kubernetes/apps/authentik/blueprints-configmap.yaml
2026-10-27 08:45:30 +01:00
claude-botandClaude Opus 5.5 b78cacb6c4 feat(openbao): OIDC login via Authentik for SSH cert signing
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was canceled
Phase 0 of the OpenBao SSH CA: humans log in to OpenBao with
Authentik instead of the root token.

- Authentik blueprint: group OpenBao Admins (k.renner), confidential
  OAuth2 provider openbao (CLI + UI callbacks), application, group
  binding. The client secret is generated by Authentik and only goes
  into auth/oidc/config.
- openbao-authentik-egress: auth.renner.uno resolves to the Traefik
  VIP in-cluster, which the existing world egress does not cover.
- Policy ssh-sign-owner with the manual bao commands for auth/oidc;
  its sign path is enabled in phase 1.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 08:44:15 +01:00
security-bot b753ba4776 chore(image-automation): bump gitea.renner.uno/konairius/wiki:build.1793086240
Updates managed by image-automation-controller.
2026-10-27 07:37:00 +00:00
k.renner 84c0561146 Merge pull request 'feat(authentik): bump to 2026.8.4 with samba-bridge 2026.8.4-3c8f413' (#1460) from claude/authentik-2026.8.4 into main
Reviewed-on: #1460
2026-10-27 07:28:39 +00:00
hermes-bot be4e605d65 fix(wiki): wiki-sync retry window covers the full 120 s
ci/woodpecker/pr/ci-scripts-test Pipeline failed
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
Six retries at a fixed 10 s delay stopped after about 60 s, so
--retry-max-time 120 never bound. Twelve retries let the 120 s cap
be the limit, covering a Recreate rollout slowed by an image pull.

Retrying on 5xx is safe: POST /api/v1/sync is serialized by
SyncCoordinator and re-syncs the same state, the same as a webhook
landing mid-sync. --retry-max-time 120 keeps a long sync that fails
late from being repeated.
2026-10-27 07:27:46 +00:00
hermes-bot 10cb1c2152 fix(wiki): wiki-sync retries while the wiki pod restarts
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was canceled
The wiki Deployment uses Recreate, so during an image rollout the Service
has no ready endpoint for ~10 s. wiki-sync-29884710 started 06:30:00Z, 4 s
before the new pod was Ready, failed with curl (7) and fired KubeJobFailed.
Retry up to 6 times, 10 s apart, on connection refused too, bounded to
120 s from the first attempt so a long sync that fails is not repeated.

Refs #1461
2026-10-27 07:26:13 +00:00
claude-botandClaude Opus 5.5 ae8ce26a01 feat(authentik): bump to 2026.8.4 with samba-bridge 2026.8.4-3c8f413
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
Chart, server/worker image and LDAP outpost image move together, as
the chart pin requires. Images built from authentik-samba-bridge
3c8f413 (PR #8), pipeline 43.

Supersedes #1347.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 07:39:59 +01:00
security-bot 1f1125c49c chore(image-automation): bump gitea.renner.uno/konairius/wiki:build.1793081757
Updates managed by image-automation-controller.
2026-10-27 06:26:58 +00:00
security-bot 1467596309 chore(image-automation): bump valkey/valkey:9.1.3-alpine
Updates managed by image-automation-controller.
2026-10-27 02:50:38 +00:00
security-bot 8ef130da2e chore(image-automation): bump redis:8.10.3-alpine
Updates managed by image-automation-controller.
2026-10-26 17:50:32 +00:00
security-bot 2e677483db chore(image-automation): bump redis:7.4.12-alpine
Updates managed by image-automation-controller.
2026-10-26 17:46:58 +00:00
k.renner 532b853929 Merge pull request 'fix(music-assistant): bind streams to net1 so Sendspin mDNS queries the IoT VLAN' (#1459) from fix/ma-sendspin-mdns-net1 into main
Reviewed-on: #1459
2026-10-26 14:34:12 +00:00
claude-botandClaude Opus 5.5 af7103fd75 fix(music-assistant): bind streams to net1 so Sendspin mDNS queries the IoT VLAN
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
aiosendspin starts its zeroconf on [bind_ip], or on InterfaceChoice.Default
when bind_ip is 0.0.0.0. Default egresses via the pod's main-table default
route (eth0); net1 routes sit in the sbr table. MA therefore never sent
_sendspin._tcp queries onto v30iot and only rediscovered Voice PEs when they
announced on their own, leaving them disconnected for hours after every MA
restart (e.g. 2026-10-26 13:56: 3 of 4 still offline at 15:05).

The reconcile initContainer now writes bind_ip = the live net1 address it
already computes for publish_ip. The address is read from the interface on
every boot, so the Sendspin no-bind-fallback failure (stale literal IP)
cannot recur.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 15:12:32 +01:00
k.renner 1984b01e5d Merge pull request 'fix(music-assistant): mark Up Next episodes played from the playlist' (#1458) from claude/ma-pocketcasts-up-next-mark-played into main
Reviewed-on: #1458
2026-10-26 12:53:42 +00:00
claude-botandClaude Opus 5.5 e02ad7639b fix(music-assistant): mark Up Next episodes played from the playlist
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
The Up Next payload carries no episode duration. Marking an episode played
in the UI reports the duration as the position, so the provider received 0
and took it for an explicit "mark unplayed": the episode stayed in Up Next
and was flagged unplayed.

The Up Next playlist now takes duration and playback status from the
(cached, throttled) episode details, falling back to the bare entry when
the details cannot be fetched.

Tested against the 2.10.7 source: 21/21 pocketcasts tests, ruff, mypy.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 13:51:27 +01:00
k.renner 80f5310653 Merge pull request 'feat(music-assistant): Pocket Casts Up Next as a library playlist' (#1457) from claude/ma-pocketcasts-up-next-playlist into main
Reviewed-on: #1457
2026-10-26 12:30:42 +00:00
claude-botandClaude Opus 5.5 9895b7f4f4 feat(music-assistant): Pocket Casts Up Next as a library playlist
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
Overlays the Pocket Casts provider with a patched __init__.py that adds
ProviderFeature.LIBRARY_PLAYLISTS and a read-only "Up Next" playlist of
podcast episodes, fetched live from /up_next/list in queue order.

An initContainer copies the image's provider into an emptyDir and swaps
in the patch only when the stock file's sha256 matches the version it was
written against (identical in 2.10.7 and 2.10.8). A later image bump that
changes the provider keeps the stock provider instead of breaking it.

Temporary until the change is upstreamed to music-assistant/server.
Tested against the 2.10.7 source: 18/18 pocketcasts tests, ruff, mypy.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 13:27:44 +01:00
k.renner c380c7c2b8 Merge pull request 'revert(music-assistant): remove PO token server' (#1456) from revert/ma-po-token-server into main
ci/woodpecker/cron/e2e Pipeline failed
ci/woodpecker/cron/security-scan Pipeline was successful
Reviewed-on: #1456
Reviewed-by: Review Bot <review-bot@renner.uno>
2026-10-26 10:18:28 +00:00
claude-botandClaude Opus 5.5 0f86537bb1 revert(music-assistant): remove PO token server
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
Reverts #1451. The YouTube Music provider is not kept; the PO token
server and its image automation go with it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 11:11:11 +01:00
security-bot 31459dac6d chore(image-automation): bump gitea.renner.uno/konairius/wiki:build.1793006975
Updates managed by image-automation-controller.
2026-10-26 09:36:48 +00:00
k.renner 57c96a9a62 Merge pull request 'fix(image-automation): unpin the gitea-mcp ImageRepository' (#1452) from claude/gitea-mcp-imagerepo into main
Reviewed-on: #1452
2026-10-26 09:28:43 +00:00
claude-botandClaude Opus 5.5 ffd24865ee fix(image-automation): unpin the gitea-mcp ImageRepository
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
Renovate #1439 pinned a digest into the ImageRepository's spec.image,
which image-reflector rejects ("should not include a tag"), so Flux
stopped tracking gitea-mcp-server. The image was missing from the
renovate.json list of images Flux image automation already bumps; add
it there so Renovate leaves it alone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 10:11:42 +01:00
k.renner 439e4b4b83 Merge pull request 'feat(music-assistant): PO token server for YouTube Music provider' (#1451) from feat/ma-youtube-music-po-token-v2 into main
Reviewed-on: #1451
2026-10-26 09:05:42 +00:00
k.renner 622a4cc593 Merge pull request 'chore(coredns): Talos alone owns the Corefile (2/2)' (#1450) from claude/coredns-talos-owned-2 into main
Reviewed-on: #1450
2026-10-26 09:04:47 +00:00
claude-botandClaude Opus 5.5 96ea510143 feat(music-assistant): add PO token server for YouTube Music provider
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
MA's YouTube Music provider needs a bgutil PO token server; MA installs
the matching yt-dlp plugin unpinned from PyPI (2.0.2 today), so the
server tracks 2.x via image automation. The MA docs' "1.2.1" predates
the 2.x plugin line.

Reachable only from the MA pod; egress limited to DNS and world:443.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 10:02:41 +01:00
claude-botandClaude Opus 5.5 60d3acc202 chore(coredns): Talos alone owns the Corefile
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
Step 2 of 2. The coredns ConfigMap is a Talos bootstrap manifest; Flux
stops applying its own copy (prune is disabled since the previous
commit, so the live ConfigMap stays until talosctl upgrade-k8s writes
Talos's version). CoreDNS forwards to the node resolver, which asks
k8s-gateway first, so cluster DNS no longer depends on Flux.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 09:59:37 +01:00
k.renner cb697e2812 Merge pull request 'chore(coredns): keep Flux from pruning the Talos-owned Corefile (1/2)' (#1449) from claude/coredns-talos-owned into main
Reviewed-on: #1449
Reviewed-by: Review Bot <review-bot@renner.uno>
2026-10-26 08:58:29 +00:00
claude-botandClaude Opus 5.5 1e26d5cdb2 chore(coredns): keep Flux from pruning the Talos-owned Corefile
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
Step 1 of 2 to hand the coredns ConfigMap back to Talos. It is a Talos
bootstrap manifest (talos-bootstrap-manifests-inventory) that Flux also
applies; talosctl upgrade-k8s reverts Flux's Corefile. Bootstrap already
runs on Talos's Corefile (forward to the node resolver, which asks
k8s-gateway first), and k8s-gateway carries serve_stale for every name,
so the Flux copy is redundant.

With prune disabled, step 2 can drop the file without Flux deleting the
live ConfigMap.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 09:55:13 +01:00
k.renner fbd3fbd119 Merge pull request 'fix(dns): pods ask the UDM on the segment they leave on' (#1448) from claude/k8s-gateway-ingress-resolver into main
Reviewed-on: #1448
2026-10-26 07:39:52 +00:00
k.renner 3c05098780 Merge pull request 'fix(egress): syncthing STUN 3479, Loki usage reporting off' (#1447) from claude/egress-followups into main
Reviewed-on: #1447
Reviewed-by: Review Bot <review-bot@renner.uno>
2026-10-26 07:39:25 +00:00
claude-botandClaude Opus 5.5 5ecd17d26b fix(dns): pods ask the UDM on the segment they leave on
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
Since UniFi OS 6.0.15 / Network 11.0.97 (2026-10-25 18:42Z) the UDM's
DNS answers out of the interface a query arrived on. Pod traffic to
10.2.40.1 arrives on VLAN 40 (on-link route in table 47) while the pod
CIDRs are routed back over VLAN 47, so the UDM ARPs for the pod on
VLAN 40 and never answers (seen with talosctl pcap on k8s-3; TCP 443 to
the same address works, UDP and TCP 53 do not). fd00::1 was reached
over VLAN 47 and kept working.

k8s-gateway's upstream (k8s-gateway-site), the blackbox-dns probes and
the gatus UDM check now use the UDM's ingress-segment addresses,
fd02:47::1 and 10.2.47.1 (the egress-gw annotations); both answer from
a pod.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 08:29:04 +01:00
security-bot f29e9f965a chore(image-automation): bump gitea.renner.uno/konairius/wiki:build.1792997373
Updates managed by image-automation-controller.
2026-10-26 06:56:47 +00:00
security-bot 42a3a31460 chore(image-automation): bump gitea.renner.uno/k.renner/shelly-manager:build.1792997278 gitea.renner.uno/gitea_admin/review-bot:2026-10-26-0647
Updates managed by image-automation-controller.
2026-10-26 06:53:44 +00:00
security-bot 188784d406 chore(image-automation): bump gitea.renner.uno/gitea_admin/secret-rotator:2026-10-26
Updates managed by image-automation-controller.
2026-10-26 06:53:42 +00:00
security-bot 6f5c4f7b81 chore(image-automation): bump docker.gitea.com/gitea-mcp-server:1.11.0
Updates managed by image-automation-controller.
2026-10-26 06:52:51 +00:00
claude-botandClaude Opus 5.5 76f3d4b6b5 fix(egress): syncthing STUN 3479, Loki usage reporting off
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
syncthing's STUN NAT-type probe sends its change request to port 3479,
which syncthing-egress dropped (seen: 77.72.169.212:3479/UDP).

Loki tried to send usage reports to Grafana (34.49.170.206:443), denied
by its egress policy; switch reporting off instead of allowing it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 07:51:31 +01:00
k.renner 67ff176875 Merge pull request 'chore(deps): update alpine:3.24 docker digest to be26560' (#1441) from renovate/multus into main
Reviewed-on: #1441
2026-10-26 06:43:16 +00:00
k.renner 2ddb9544e6 Merge pull request 'feat(wiki): sync and encyclopedia schedules as CronJobs' (#1446) from feat/wiki-cronjobs into main
Reviewed-on: #1446
2026-10-26 06:43:02 +00:00
k.renner 25e70dc555 Merge pull request 'chore(deps): update home-assistant non-major' (#1442) from renovate/home-assistant-minor-patch into main
Reviewed-on: #1442
2026-10-26 06:42:36 +00:00
k.renner a4b18f44c3 Merge pull request 'chore(deps): update all non-major dependencies' (#1440) from renovate/all-minor-patch into main
ci/woodpecker/push/build-ci-images Pipeline was successful
Reviewed-on: #1440
2026-10-26 06:42:16 +00:00
k.renner 49856fe577 Merge pull request 'chore(deps): update influxdb:2.9.1 docker digest to 8f106f7' (#1443) from renovate/influxdb into main
Reviewed-on: #1443
Reviewed-by: Review Bot <review-bot@renner.uno>
2026-10-26 06:42:01 +00:00
k.renner 576611faae Merge pull request 'chore(deps): update ci non-major' (#1444) from renovate/ci-minor-patch into main
ci/woodpecker/push/build-ci-images Pipeline is pending
ci/woodpecker/push/ci-claude-refresh Pipeline is pending
ci/woodpecker/push/deploy-infrastructure Pipeline is pending
Reviewed-on: #1444
Reviewed-by: Review Bot <review-bot@renner.uno>
2026-10-26 06:41:44 +00:00
k.renner cc898ec553 Merge pull request 'chore(deps): update hermes image' (#1445) from renovate/hermes-image into main
ci/woodpecker/push/build-ci-images Pipeline is pending
ci/woodpecker/push/hermes-image Pipeline is pending
Reviewed-on: #1445
2026-10-26 06:41:25 +00:00
k.renner 7d53c99062 Merge pull request 'chore(deps): pin docker.gitea.com/gitea-mcp-server docker tag to ccdb566' (#1439) from renovate/pin-image-digests into main
Reviewed-on: #1439
2026-10-26 06:40:47 +00:00
claude-botandClaude Opus 5.5 8ce5ebd8ac feat(wiki): sync and encyclopedia schedules as CronJobs
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
wiki-sync (every 30 min) and wiki-encyclopedia (06:00 Europe/Berlin) call
the service's API with a generated scheduler key; the service no longer
schedules anything itself (konairius/Wiki#18). Pods in ai already reach
the wiki through the namespace default policy.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 07:37:15 +01:00
renovate-bot 9258947fef chore(deps): update hermes image
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
2026-10-26 03:01:35 +00:00
renovate-bot 1955a11b81 chore(deps): update ci non-major
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
ci/woodpecker/pr/review-bot-test Pipeline was successful
2026-10-26 03:01:34 +00:00
renovate-bot da614f83fe chore(deps): update influxdb:2.9.1 docker digest to 8f106f7
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
2026-10-26 03:01:30 +00:00
renovate-bot e5c99655e5 chore(deps): update home-assistant non-major
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
2026-10-26 03:01:27 +00:00
renovate-bot 392a010daa chore(deps): update alpine:3.24 docker digest to be26560
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
2026-10-26 03:01:25 +00:00
renovate-bot f04f449163 chore(deps): update all non-major dependencies
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
ci/woodpecker/pr/review-bot-test Pipeline was successful
2026-10-26 03:01:23 +00:00
renovate-bot 32d0f0cc7b chore(deps): pin docker.gitea.com/gitea-mcp-server docker tag to ccdb566
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
2026-10-26 03:01:19 +00:00
security-bot c79ccfb3f3 chore(image-automation): bump lscr.io/linuxserver/lidarr:3.1.6.5079-ls46
ci/woodpecker/cron/update-audit Pipeline was successful
ci/woodpecker/cron/e2e Pipeline failed
Updates managed by image-automation-controller.
2026-10-26 02:52:46 +00:00
k.renner 0904f73721 Merge pull request 'fix(netpol): allow shelly-manager mDNS queries' (#1436) from claude/shelly-manager-mdns into main
Reviewed-on: #1436
2026-10-25 22:16:39 +00:00
k.renner ef2024c565 Merge pull request 'feat(mqtt): admin login as a register account' (#1437) from claude/mqtt-admin-register into main
Reviewed-on: #1437
2026-10-25 22:16:23 +00:00
claude-botandClaude Opus 5.5 3c86f0970c feat(mqtt): admin login as a register account
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
The controller (main-b1f294d, konairius/mqtt-account-controller#13)
delivers a password account to a namespace Secret. admin moves into the
register with its password in Secret mqtt-admin-password; Dynamic
Security is asked first, so it replaces the password_file login without
a broker restart. The controller gets get/create/patch/delete on that
Secret by name.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 21:06:03 +01:00
security-bot 4c5665aa75 chore(image-automation): bump gitea.renner.uno/gitea_admin/ci-backup-verify:2026-10-25 gitea.renner.uno/gitea_admin/review-bot:2026-10-25-1455 gitea.renner.uno/gitea_admin/secret-rotator:2026-10-25
Updates managed by image-automation-controller.
2026-10-25 15:23:41 +00:00
security-bot 38091cbe11 chore(image-automation): bump gitea.renner.uno/gitea_admin/kuma-sync:2026-10-25-1459
Updates managed by image-automation-controller.
2026-10-25 15:23:40 +00:00
security-bot 33c0266fc1 chore(image-automation): bump gitea.renner.uno/gitea_admin/cloudflare-lb-sync:2026-10-25-1504
Updates managed by image-automation-controller.
2026-10-25 15:17:02 +00:00
claude-botandClaude Opus 5.5 9bcc1cb420 fix(netpol): allow shelly-manager mDNS queries
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
shelly-manager resolves device *.local names via mDNS; the queries leave on
eth0 to 224.0.0.251:5353 and have been dropped since the egress lockdown
(#1432), failing with 'Name or service not known' since 15:07Z.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 16:10:58 +01:00
security-bot b10b50fd2d chore(image-automation): bump gitea.renner.uno/konairius/hermes:build.1792940452
Updates managed by image-automation-controller.
2026-10-25 15:06:45 +00:00
security-bot f9c517bd37 chore(image-automation): bump gitea.renner.uno/gitea_admin/authentik-gate-sync:2026-10-25-1458
Updates managed by image-automation-controller.
2026-10-25 15:06:43 +00:00
security-bot 49cafb2f6d chore(image-automation): bump gitea.renner.uno/konairius/wiki:build.1792939775
Updates managed by image-automation-controller.
2026-10-25 14:56:41 +00:00
k.renner 6fa10a7744 Merge pull request 'feat(wiki): gate wiki sign-in to Wiki Readers and enable encyclopedia planning' (#1435) from feat/wiki-encyclopedia-pilot into main
Reviewed-on: #1435
2026-10-25 14:51:08 +00:00
k.renner b341b26673 Merge pull request 'feat(netpol): restrict pod egress to the outside world per workload' (#1432) from claude/egress-lockdown into main
ci/woodpecker/push/ci-claude-refresh Pipeline was successful
ci/woodpecker/push/deploy-claude-workstation Pipeline failed
ci/woodpecker/push/deploy-edge-mqtt-bridge Pipeline was successful
ci/woodpecker/push/deploy-infrastructure Pipeline was successful
ci/woodpecker/push/deploy-netbird-client Pipeline failed
ci/woodpecker/push/deploy-netbird-proxy Pipeline was successful
ci/woodpecker/push/deploy-netbird-server Pipeline was successful
ci/woodpecker/push/deploy-uptime-kuma Pipeline was successful
ci/woodpecker/push/hermes-image Pipeline was successful
ci/woodpecker/push/build-ci-images Pipeline was successful
Reviewed-on: #1432
2026-10-25 14:50:32 +00:00
k.renner aad24d4186 Merge pull request 'feat(mosquitto): retire legacy password users (admin stays, generated Secret)' (#1433) from claude/mosquitto-legacy-auth-removal into main
Reviewed-on: #1433
2026-10-25 14:50:09 +00:00
k.renner 7ef4475952 Merge pull request 'chore(ci): bump ci-claude image to 2.1.310' (#1434) from ci-image/claude-2.1.310 into main
Reviewed-on: #1434
Reviewed-by: Review Bot <review-bot@renner.uno>
2026-10-25 14:48:49 +00:00
claude-botandClaude Opus 5.5 aca39a5a8f feat(wiki): gate wiki sign-in to Wiki Readers and enable encyclopedia planning
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
Only members of the new Authentik group Wiki Readers may sign in to the wiki
OIDC application; membership is managed in Authentik, so the owner must be
added before merge. Encyclopedia is enabled with MaxArticles=0 so the first
nightly run only plans.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 14:52:51 +01:00
claude-bot d7ca8dda11 chore(ci): bump ci-claude image to 2.1.310
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
2026-10-25 00:01:21 +00:00
security-bot c8483b3545 chore(image-automation): bump ghcr.io/home-assistant/home-assistant:2026.10.4
ci/woodpecker/cron/ci-claude-refresh Pipeline was successful
ci/woodpecker/cron/e2e Pipeline failed
ci/woodpecker/cron/security-scan Pipeline was successful
Updates managed by image-automation-controller.
2026-10-24 20:50:33 +00:00
claude-botandClaude Opus 5.5 bb090e6809 feat(mosquitto): retire legacy password users; admin stays on a generated Secret
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
Since the broker restart on 2026-10-22 only register accounts connect; the
last legacy logins (homeassistant, sml2mqtt, open3e, zigbee2mqtt) were on
2026-10-08, and admin/evcc never logged in within the 30-day Loki window.

- Delete mosquitto-bootstrap.yaml (5 Jobs that apk add at runtime + RBAC).
- The broker's password_file/acl_file now hold only admin, the owner's
  workstation login over the LoadBalancer. The controller has no delivery
  mode that puts a password account's password into a Secret, and the cert
  listener :8884 is in-cluster only, so admin cannot be a register account.
  Its password comes from an ESO Password generator (CreatedOnce), the
  pattern of apps/hermes/api-key.yaml, in Secret mosquitto-admin-password.
- Delete the evcc and health ExternalSecrets and the health rotator
  CronJob/policy; nothing reads them.
- Delete PROPOSAL-mosquitto-credential-rotation.md; its subject is gone.
- MosquittoDown test masks with the dynsec-test pod instead of a
  bootstrap Job pod; comments and docs follow.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 20:46:00 +02:00
claude-botandClaude Opus 5.5 5aee6a54ad revert(netpol): no world egress for mosquitto-bootstrap
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
Runtime package installs are not to be enabled by policy. The Jobs feed only
the unused legacy password_file and are removed in a separate PR, which must
merge before this one.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 17:33:25 +02:00
claude-botandClaude Opus 5.5 8a53171ad0 fix(netpol): let mosquitto-bootstrap Jobs reach Alpine mirrors
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
The Jobs run apk add curl jq on every execution; without world 443/80 they
fail and block the home-assistant Kustomization. Also correct the stale
renderer egress comment.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 17:26:49 +02:00
claude-botandClaude Opus 5.5 299194247e feat(netpol): allow mqtt-account-controller to deliver to edge-1
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was canceled
edge-1.iot.renner.uno:8443 is the deliver.esp32 target in mqtt-accounts.yaml
(https+mTLS credential push). Matched by FQDN, not the current 10.2.30.108.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 17:26:00 +02:00
claude-bot 6b6e1e7e04 Merge remote-tracking branch 'origin/main' into worktree-egress-lockdown
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline failed
# Conflicts:
#	kubernetes/infrastructure/network-policies/gitea-ns.yaml
#	kubernetes/infrastructure/network-policies/matrix-ns.yaml
2026-10-24 12:45:03 +02:00
claude-botandClaude Opus 5.5 752fcd5d03 feat(netpol): restrict pod egress to the outside world per workload
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was canceled
Drop namespace-wide world egress and give each workload only the external
destinations it needs (FQDN where the host is in our config, port-scoped
world otherwise). Namespaces that had ingress-only policies get an
in-cluster + DNS egress baseline. CNPG instances get Hetzner S3 via one
clusterwide policy.

Out of scope: kube-system, flux-system, rook-ceph, woodpecker-ci,
music-assistant, evcc, openbao.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 12:43:38 +02:00
security-bot 55cd083484 chore(image-automation): bump ghcr.io/advplyr/audiobookshelf:2.37.2
ci/woodpecker/cron/e2e Pipeline failed
ci/woodpecker/cron/security-scan Pipeline was successful
Updates managed by image-automation-controller.
2026-10-23 20:49:32 +00:00
security-bot ada3d3d0b4 chore(image-automation): bump ghcr.io/home-assistant/home-assistant:2026.10.3
Updates managed by image-automation-controller.
2026-10-23 19:50:03 +00:00
security-bot 1bd3111912 chore(image-automation): bump ghcr.io/music-assistant/server:2.10.8
Updates managed by image-automation-controller.
2026-10-23 14:49:29 +00:00
security-bot 0e12e90509 chore(image-automation): bump gitea.renner.uno/gitea_admin/authentik-gate-sync:2026-10-23-0757
ci/woodpecker/cron/e2e Pipeline failed
ci/woodpecker/cron/security-scan Pipeline was successful
Updates managed by image-automation-controller.
2026-10-23 08:02:02 +00:00
k.renner 613e1da011 Merge pull request 'fix(authentik-gate-sync): embedded outpost redirects browsers to the public URL' (#1430) from fix/gate-embedded-browser-host into main
ci/woodpecker/push/build-ci-images Pipeline was successful
Reviewed-on: #1430
2026-10-23 07:57:11 +00:00
246 changed files with 3901 additions and 3326 deletions
+1 -1
View File
@@ -319,7 +319,7 @@ steps:
# writes its stripped mirror under mktemp and never into the workspace.
- name: promtool
depends_on: [] # see note above `steps:`
image: prom/prometheus:v3.15.0@sha256:efd719c99d83b060d9daefdcf00360461adf279f45ef5391f8d111892118753e
image: prom/prometheus:v3.15.1@sha256:fa3302279710be2f21a8301ec17a8b4f2301fd829bd113839b3b6b02b6f191a2
commands:
- sh scripts/ci/promtool-rules.sh
+5 -5
View File
@@ -35,25 +35,25 @@ when:
steps:
- name: model-selection-tests
depends_on: []
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.306
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.311
commands:
- sh scripts/ci/test-model-selection.sh
- name: pr-review-mistral-tests
depends_on: []
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.306
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.311
commands:
- sh scripts/ci/test-pr-review-mistral.sh
- name: promtool-rules-guard-tests
depends_on: []
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.306
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.311
commands:
- sh scripts/ci/test-promtool-rules.sh
- name: yaml-citations-guard-tests
depends_on: []
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.306
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.311
commands:
- sh scripts/ci/test-yaml-citations.sh
@@ -63,7 +63,7 @@ steps:
# bridge, and the secret guard.
- name: hermes-skill-tests
depends_on: []
image: python:3.14-alpine@sha256:f6a589d43c42b9e7f7dc67a12d37132491f362859a5d750607710cc56da3bc72
image: python:3.15-alpine@sha256:d44b1254cb92329418d164b22b20d86bbd3869d48e80e09e476531a409a728bb
environment:
PYTHONDONTWRITEBYTECODE: "1"
commands:
-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
+1 -8
View File
@@ -13,7 +13,7 @@ when:
steps:
- name: validate-secrets
image: alpine:3.24.2@sha256:294b683cb724975bec92580e1e685676bd4b50bda910ddb8c51d4cabeaec77e6
image: alpine:3.24.3@sha256:be2656061b536c1883c4034790e21d0a09c5aaaf51968b89368a3c0371ea2c9a
environment:
TF_VAR_proxmox_api_url:
from_secret: proxmox_api_url
@@ -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
-47
View File
@@ -1,47 +0,0 @@
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
timeout: 30
steps:
- name: renew-openbao-token
image: gitea.renner.uno/gitea_admin/ci-ansible:2026-10-18
environment:
VAULT_TOKEN:
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
+1 -1
View File
@@ -26,7 +26,7 @@ steps:
# fetch-models.sh response mapping. No cluster, no secrets, no network — the
# ci-claude image just provides sh + jq. Keeps the flag-injection regression
# test from rotting (it exercises the exact injection shapes).
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.306
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.311
commands:
- sh scripts/ci/test-model-selection.sh
+1 -1
View File
@@ -6,7 +6,7 @@ when:
steps:
- name: go-test
image: golang:1.27.2-bookworm@sha256:5cf287a799e6b94384bad13d16b14904c531f51ba65792237e122ce42b392f61
image: golang:1.27.3-bookworm@sha256:972b4110ba0f47539a975c31e52c64765e1ff19f2d74469ae4a595c6676e80a5
commands:
- test -z "$(gofmt -l review-bot)"
- go -C review-bot vet ./...
+3 -3
View File
@@ -18,12 +18,12 @@ when:
steps:
- name: iac-scan
image: aquasec/trivy:latest@sha256:2416d1f74045b57274df1a340eee6b510cacd73c66a6ccb8da092b117eb7d222
image: aquasec/trivy:latest@sha256:790fd66d1697395d8693d1acaa1ea702406a20f109b6fc7bac8db623eca6c06c
commands:
- trivy fs --format json -o trivy-iac.json --severity CRITICAL,HIGH --ignore-unfixed .
- name: image-scans
image: aquasec/trivy:latest@sha256:2416d1f74045b57274df1a340eee6b510cacd73c66a6ccb8da092b117eb7d222
image: aquasec/trivy:latest@sha256:790fd66d1697395d8693d1acaa1ea702406a20f109b6fc7bac8db623eca6c06c
commands:
- trivy image --download-db-only
- |
@@ -70,7 +70,7 @@ steps:
# this step is skipped — otherwise we'd fork-bomb PRs.
when:
- branch: main
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.306
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.311
environment:
CLAUDE_CODE_OAUTH_TOKEN:
from_secret: claude_code_oauth_token
+1 -1
View File
@@ -30,7 +30,7 @@ when:
steps:
- name: update-audit-agent
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.306
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.311
environment:
CLAUDE_CODE_OAUTH_TOKEN:
from_secret: claude_code_oauth_token
-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
+10 -5
View File
@@ -112,7 +112,7 @@
# (string) This setting controls the default policy of fact gathering (facts discovered about remote systems).
# This option can be useful for those wishing to save fact gathering time. Both 'smart' and 'explicit' will use the cache plugin.
;gathering=implicit
gathering=smart
# (list) Set the `gather_subset` option for the :ref:`ansible_collections.ansible.builtin.setup_module` task in the implicit fact gathering. See the module documentation for specifics.
# It does **not** apply to user defined ``ansible.builtin.setup`` tasks.
@@ -134,7 +134,7 @@
;hash_behaviour=replace
# (pathlist) Comma separated list of Ansible inventory sources
inventory=~/source/homelab/ansible/inventory
inventory=inventory
# (pathspec) Colon separated paths in which Ansible will search for HttpApi Plugins.
;httpapi_plugins={{ ANSIBLE_HOME ~ "/plugins/httpapi:/usr/share/ansible/plugins/httpapi" }}
@@ -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.
@@ -274,7 +274,7 @@ roles_path=../roles:./roles
# (path) The vault password file to use. Equivalent to --vault-password-file or --vault-id
# If executable, it will be run and the resulting stdout will be used as the password.
vault_password_file=~/source/homelab/ansible/vault-password.sh
vault_password_file=vault-password.sh
# (integer) Sets the default verbosity, equivalent to the number of ``-v`` passed in the command line.
;verbosity=0
@@ -484,7 +484,7 @@ vault_password_file=~/source/homelab/ansible/vault-password.sh
# It can result in a very significant performance improvement when enabled.
# However this conflicts with privilege escalation (become). For example, when using 'sudo:' operations you must first disable 'requiretty' in /etc/sudoers on all managed hosts, which is why it is disabled by default.
# This setting will be disabled if ``ANSIBLE_KEEP_REMOTE_FILES`` is enabled.
;pipelining=False
pipelining=True
[colors]
@@ -694,3 +694,8 @@ vault_password_file=~/source/homelab/ansible/vault-password.sh
# (list) default list of tags to skip in your plays, has precedence over Run Tags
;skip=
[ssh_connection]
# Connections stay open across the plays of a run, so each host costs one
# YubiKey PIN; plays/close-connections.yml closes them at the end of a run.
ssh_args=-C -o ControlMaster=auto -o ControlPersist=30m
+17 -21
View File
@@ -41,13 +41,11 @@ Build and boot the dedicated installer image — see
4. Add the host to `[edge_host]` in `inventory/pve_hosts.yml`, by name.
That group is the base: every edge thin client belongs to it, and it
assumes no peripherals.
5. From the workstation:
5. From the workstation, in `ansible/`:
```
ansible-playbook -i inventory/pve_hosts.yml plays/install-edge-host.yml
ansible-playbook site.yml
```
One unrestricted run over the whole group. The play is self-contained --
it carries `certificates` and `common` itself, so there is no second
command and no `--limit`. Running it converges every edge host, which is
One unrestricted run, no `--limit`. It converges every host, which is
the point: a run that touches only the new box tells you nothing about
the others.
@@ -72,14 +70,14 @@ none of these commands.
## Secrets (before first play run)
Create `inventory/group_vars/edge_host/vault.yml` with ansible-vault; the
current live values come straight out of the cluster:
Create `inventory/group_vars/edge_host/vault.yml` with ansible-vault. The
cluster broker has no `open3e` user: edge-1 logs in only as the register
account `edge-1` (see "MQTT bridge" below), so
`vault_edge_open3e_mqtt_password` reaches only the loopback broker, which
allows anonymous clients; any random value works (`openssl rand -hex 24`).
The zwave-js value comes out of the cluster:
```
# sml2mqtt broker password
kubectl -n home-assistant get secret mosquitto-sml2mqtt-credentials -o jsonpath='{.data.password}' | base64 -d
# open3e broker password
kubectl -n home-assistant get secret mosquitto-open3e-credentials -o jsonpath='{.data.password}' | base64 -d
# zwave-js session secret
kubectl -n home-assistant get secret zwave-js-session -o jsonpath='{.data.session-secret}' | base64 -d
```
@@ -230,15 +228,14 @@ They connect to a **local mosquitto on loopback** (`127.0.0.1:1883`,
broker is briefly unreachable, and flushes on reconnect — edge-1 no longer
hard-depends on cluster/network uptime for every publish.
Two bridge `connection`s preserve the existing cluster identities, so **no
cluster-side change was needed**: one authenticates as the `open3e` user
(carries `open3e/#` out + `open3e/cmnd` back), one as `sml2mqtt` (`sml2mqtt/#`
out). The local broker is `allow_anonymous` on loopback — the trust boundary
is the host; nothing off-host can reach it.
The local broker is `allow_anonymous` on loopback — the trust boundary is the
host; nothing off-host can reach it.
**Bridge credential (REN-1307).** The two vault-password bridges are being
replaced by one bridge as the register account `edge-1`
(`kubernetes/apps/home-assistant/mqtt-accounts.yaml`). Its password is pushed
**Bridge credential (REN-1307).** One bridge `connection` logs in as the
register account `edge-1` (`kubernetes/apps/home-assistant/mqtt-accounts.yaml`)
and carries `open3e/#` out and `open3e/cmnd` back. The cluster broker does not
accept the vault-password bridges in `bridge.d/legacy.conf`, which exists only
until the first push. Its password is pushed
by mqtt-account-controller to `edge-mqtt-cred` (`:8443`, the controller's
esp32 API over HTTPS). That endpoint accepts only a client certificate from
the MQTT CA with CN `mqtt-account-controller`; edge-1 holds the public CA and
@@ -246,8 +243,7 @@ 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. Diagnosis: `journalctl -u
edge-mqtt-cred`, and `mosquitto_sub -t '$SYS/broker/connection/edge-1/state'`
(retained `1` = connected).
-67
View File
@@ -1,67 +0,0 @@
#!/usr/bin/env python3
"""
Discover Shelly devices on the network via HTTP API
Returns JSON array of discovered devices
"""
import sys
import json
import ipaddress
import concurrent.futures
import urllib.request
import urllib.error
def check_shelly(ip):
"""Check if IP is a Shelly device and return info"""
try:
url = f"http://{ip}/shelly"
req = urllib.request.Request(url, headers={'User-Agent': 'Ansible-Shelly-Discovery'})
with urllib.request.urlopen(req, timeout=2) as response:
data = json.loads(response.read().decode())
# Gen1 devices don't have 'gen' field, default to 1
gen = data.get('gen', 1)
if gen is None:
gen = 1
return {
'ip': str(ip),
'type': data.get('type', 'unknown'),
'mac': data.get('mac', ''),
'gen': gen,
'name': data.get('name', data.get('id', str(ip))),
'fw_id': data.get('fw_id', data.get('fw', 'unknown'))
}
except (urllib.error.URLError, urllib.error.HTTPError, OSError, json.JSONDecodeError):
return None
except Exception:
return None
def main():
if len(sys.argv) < 2:
print("Usage: discover_shelly.py <network1> [network2] ...")
print("Example: discover_shelly.py 10.2.30.0/24 10.2.20.0/24")
sys.exit(1)
networks = sys.argv[1:]
devices = []
# Collect all IPs to scan
all_ips = []
for network in networks:
try:
all_ips.extend(ipaddress.IPv4Network(network).hosts())
except ValueError as e:
print(f"Invalid network: {network} - {e}", file=sys.stderr)
continue
# Scan in parallel
with concurrent.futures.ThreadPoolExecutor(max_workers=100) as executor:
results = executor.map(check_shelly, all_ips)
for result in results:
if result:
devices.append(result)
# Output JSON
print(json.dumps(devices, indent=2))
if __name__ == "__main__":
main()
+7 -24
View File
@@ -5,27 +5,10 @@ pve_token_id: "ansible"
ansible_user: "ladmin"
ansible_python_interpreter: /usr/bin/python3
# SSH Public Keys for Cloud-Init
# ansible-runner@ci-new removed (issue #184): dead key, no such host exists.
common_ssh_public_keys: |
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIPzFNBP3bq2m/6JqDqod9w+FP/uxXxHMolqhq3Ojflr1 krenner@fortress
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAICyNnX1I4W9jyDvCJ1DJauHSmbo1ZXSyBxVhSAwMWgen woodpecker-ci@homelab
# Network-of-origin restriction for the woodpecker-ci@homelab CI key
# (issue #184). Consumed by plays/configure-ssh-keys.yml as the authorized_key
# `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'
# 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; 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>
ssh_admin_authorized_keys:
- key: "ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBKvSl9LUP8u3S2Sq5YTWpW0Jxbue8cJhD7/LMFQbEsa3xo145HVR7pBzRxW71MUy+jgrgmpSlDhIb606Hswhvoo= breakglass-neo-6532348"
@@ -1,16 +0,0 @@
$ANSIBLE_VAULT;1.1;AES256
35646538363164363061366433343963623166336630376233333033646637336266343337343230
3233356135323762636439336466663430366238363338650a633762623366643137303333383634
62623739363132303335636261626263333332623066303161633466306431623930666166323833
6439663964353338350a656435626664646666363831363164393335386666356637613037323838
63653461656366613331353064656161346263356131346437666632386166623037373363333534
37323435623366643534633333303534313434613135653237303966656162633266623433363934
36343133393834393765353338383764303664363431653865633764306138376366623832346262
36303766623331666632613639366139333661636665363631326565653938343637323861653064
37393465383263376337376639613162306563653365623036376662303030613264653534353332
66363030356363373439306164386561363734353631363738656236633164633062613437663366
62386430396262366336316166353366383233316333333234666538643962336361363630363762
31653963303032373531386566336632336130383034343430303964396538663237346434613737
39643765623766373639306132316635376434643232336163303066393736313130323065666232
39393038616162666666383837326137376233613437636361636238663566336365653863643933
353338646134383038323134323034653564
@@ -50,3 +50,11 @@ edge_mqtt_host: 127.0.0.1
edge_mqtt_port: 1883
edge_open3e_mqtt_user: open3e
edge_open3e_mqtt_topic: open3e
# Units claude-bot may restart (ssh-ca role); the containers go through docker.
ssh_ca_claude_sudo_units:
- mosquitto
- edge-mqtt-cred
- edge-alloy
- keepalived
- chronyd
@@ -0,0 +1,9 @@
---
# 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
ssh_ca_claude_sudo_units:
- netbird
- netbird-proxy-flood-watchdog
- crowdsec-firewall-bouncer
@@ -2,3 +2,6 @@
# NetBird server - VPS not managed by Proxmox
# Enable Docker monitoring (cAdvisor) for container metrics
docker_monitoring_enabled: true
ssh_ca_claude_sudo_units:
- netbird
+28 -10
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
@@ -62,10 +62,28 @@ heating_can
[ntp_cluster:children]
edge_host
# Off-site Debian VM (Synology VMM on nas-2, 10.3.40.0/24) running Claude Code.
# Reached over the site-to-site tunnel at its current DHCP lease. Switch
# ansible_host to the stable Netbird mesh IP once the host is enrolled — the
# obg.renner.uno FQDN is split-horizon and resolves to the public proxy from
# home, so it must NOT be used as ansible_host.
[claude_workstation]
claude-ws ansible_host=10.3.40.78 ansible_user=root
# Hosts that trust the OpenBao SSH user CA (plays/configure-ssh-ca.yml).
# Rolled out one host at a time: add the next host after the previous one
# was verified.
[ssh_ca]
proxy-1.renner.uno
proxy-2.renner.uno
netbird.renner.uno
edge-1.iot.renner.uno
# 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
netbird.renner.uno
+11
View File
@@ -0,0 +1,11 @@
---
# Last play of every playbook that is run directly (site.yml and the one-off
# actions). SSH connections stay open for 30 minutes (ansible.cfg) so a run
# costs one YubiKey PIN per host; closing them at the end leaves nothing that
# another process of the same user could reuse.
- name: Close the SSH connections
hosts: all
gather_facts: false
tasks:
- name: Close the connection
ansible.builtin.meta: reset_connection
+2 -1
View File
@@ -13,4 +13,5 @@
roles:
- common
- qemu-guest-agent
- role: qemu-guest-agent
when: ansible_facts['virtualization_role'] == 'guest'
-125
View File
@@ -1,125 +0,0 @@
---
# Configure NTP server on all Shelly devices
# Discovers Shelly devices via network scan and configures them to use local NTP server
#
# Usage:
# ansible-playbook plays/configure-shelly-ntp.yml
# ansible-playbook plays/configure-shelly-ntp.yml -e "shelly_ntp_server=10.2.40.4"
# ansible-playbook plays/configure-shelly-ntp.yml -e "shelly_networks=10.2.30.0/24,10.2.20.0/24"
- name: Configure Shelly NTP Settings
hosts: localhost
connection: local
gather_facts: no
vars:
shelly_ntp_server: "ntp.renner.uno"
shelly_networks: "10.2.30.0/24 10.2.20.0/24"
shelly_timeout: 5
tasks:
- name: Discover Shelly devices on network
script: "{{ playbook_dir }}/../files/discover_shelly.py {{ shelly_networks }}"
args:
executable: python3
register: discovered_devices
changed_when: false
- name: Parse discovered devices
set_fact:
shelly_devices: "{{ discovered_devices.stdout | from_json }}"
- name: Display discovered Shelly devices
debug:
msg: "Found {{ shelly_devices | length }} Shelly devices"
- name: Show device list
debug:
msg: "{{ item.name }} ({{ item.type }}) - {{ item.ip }} - Gen{{ item.gen }}"
loop: "{{ shelly_devices }}"
when: shelly_devices | length > 0
- name: Configure NTP on Gen1 devices
uri:
url: "http://{{ item.ip }}/settings?sntp_server={{ shelly_ntp_server }}"
method: GET
timeout: "{{ shelly_timeout }}"
return_content: yes
loop: "{{ shelly_devices | selectattr('gen', 'equalto', 1) | list }}"
register: gen1_results
ignore_errors: yes
when: shelly_devices | length > 0
- name: Configure NTP on Gen2+ devices
uri:
url: "http://{{ item.ip }}/rpc/Sys.SetConfig"
method: POST
body_format: json
body:
config:
sntp:
server: "{{ shelly_ntp_server }}"
timeout: "{{ shelly_timeout }}"
return_content: yes
loop: "{{ shelly_devices | selectattr('gen', 'greaterthan', 1) | list }}"
register: gen2_results
ignore_errors: yes
when: shelly_devices | length > 0
- name: Verify Gen1 NTP configuration
uri:
url: "http://{{ item.ip }}/settings"
method: GET
timeout: "{{ shelly_timeout }}"
return_content: yes
loop: "{{ shelly_devices | selectattr('gen', 'equalto', 1) | list }}"
register: gen1_verify
ignore_errors: yes
when: shelly_devices | length > 0
- name: Verify Gen2+ NTP configuration
uri:
url: "http://{{ item.ip }}/rpc/Sys.GetConfig"
method: GET
timeout: "{{ shelly_timeout }}"
return_content: yes
loop: "{{ shelly_devices | selectattr('gen', 'greaterthan', 1) | list }}"
register: gen2_verify
ignore_errors: yes
when: shelly_devices | length > 0
- name: Summary - Gen1 devices configured
debug:
msg: "{{ item.item.name }} ({{ item.item.ip }}): NTP={{ item.json.sntp.server | default('unknown') }}"
loop: "{{ gen1_verify.results | default([]) }}"
when:
- gen1_verify is defined
- gen1_verify.results is defined
- item.json is defined
- item.json.sntp is defined
loop_control:
label: "{{ item.item.name | default(item.item.ip) }}"
- name: Summary - Gen2+ devices configured
debug:
msg: "{{ item.item.name }} ({{ item.item.ip }}): NTP={{ item.json.sntp.server | default('unknown') }}"
loop: "{{ gen2_verify.results | default([]) }}"
when:
- gen2_verify is defined
- gen2_verify.results is defined
- item.json is defined
- item.json.sntp is defined
loop_control:
label: "{{ item.item.name | default(item.item.ip) }}"
- name: Final summary
debug:
msg: |
==========================================
Shelly NTP Configuration Complete
==========================================
Total devices found: {{ shelly_devices | length }}
Gen1 devices: {{ shelly_devices | selectattr('gen', 'equalto', 1) | list | length }}
Gen2+ devices: {{ shelly_devices | selectattr('gen', 'greaterthan', 1) | list | length }}
NTP Server: {{ shelly_ntp_server }}
==========================================
+23
View File
@@ -0,0 +1,23 @@
---
# Trust the OpenBao SSH user CA (ssh-client-signer) on the hosts in [ssh_ca]:
# owner certificates (principal k.renner) for root/ladmin, Claude's 1h
# certificates (principal claude-bot) for its own claude-bot account with
# limited sudo. The role reports the admin logins' authorized_keys; on
# [ssh_keys_managed] they are then reduced to ssh_admin_authorized_keys.
#
# serial: 1 so a host whose sshd rejects the change stops the run before the
# next host is touched. One play, so each host needs one login (one YubiKey
# PIN from a workstation).
- name: Configure the SSH user CA
hosts: ssh_ca
become: true
gather_facts: true
serial: 1
roles:
- ssh-ca
tasks:
- name: Reduce authorized_keys to the break-glass key
ansible.builtin.include_role:
name: ssh-ca
tasks_from: authorized_keys
when: inventory_hostname in groups['ssh_keys_managed']
-48
View File
@@ -1,48 +0,0 @@
---
- name: Deploy authorized SSH keys to all hosts
hosts: all:!localhost
become: true
gather_facts: false
# Authorized keys deployed to root and ladmin on every managed host.
#
# - krenner@fortress (human): unrestricted.
# - woodpecker-ci@homelab (CI): restricted with from= to the cluster's pod
# network (ci_ssh_from_cidrs in group_vars/all/vars.yml). The in-cluster
# Woodpecker step pods SSH to fleet hosts presenting their POD IP as source
# (Cilium native-routing-cidr covers the LAN, so no SNAT; PodCIDRs are
# BGP-advertised). This is a network-of-origin restriction, NOT host-pinned,
# so it survives node DHCP churn and pod rescheduling. See vars.yml for the
# full derivation.
# - ansible-runner@ci-new: removed — dead key, no such host exists.
vars:
authorized_keys:
- key: "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIPzFNBP3bq2m/6JqDqod9w+FP/uxXxHMolqhq3Ojflr1 krenner@fortress"
- key: "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAICyNnX1I4W9jyDvCJ1DJauHSmbo1ZXSyBxVhSAwMWgen woodpecker-ci@homelab"
options: 'from="{{ ci_ssh_from_cidrs }}"'
tasks:
- name: Check SSH connectivity
ansible.builtin.wait_for_connection:
timeout: 5
ignore_errors: true
register: ssh_check
- name: Skip unreachable hosts
ansible.builtin.meta: end_host
when: ssh_check is failed
- name: Deploy authorized keys to root
ansible.builtin.authorized_key:
user: root
key: "{{ item.key }}"
key_options: "{{ item.options | default(omit) }}"
state: present
loop: "{{ authorized_keys }}"
- name: Deploy authorized keys to ladmin
ansible.builtin.authorized_key:
user: ladmin
key: "{{ item.key }}"
key_options: "{{ item.options | default(omit) }}"
state: present
loop: "{{ authorized_keys }}"
ignore_errors: true
+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
View File
@@ -29,6 +29,7 @@
- name: Check if systemd-timesyncd is installed
command: systemctl list-unit-files systemd-timesyncd.service
register: timesyncd_installed
check_mode: false
failed_when: false
changed_when: false
@@ -56,6 +57,7 @@
- name: Verify time sync status
command: timedatectl show --property=NTPSynchronized
register: timesync_status
check_mode: false
changed_when: false
failed_when: false
-33
View File
@@ -1,33 +0,0 @@
---
# 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.
- name: Converge the edge MQTT bridge
hosts: edge_host
become: true
# The SML power-meter reader (sml2mqtt) is retired for good: owner decision
# 2026-09-19, grid data comes from the E380 on the CAN bus. Its role and
# play are gone; this removes the leftover compose project from edge-1 on
# the next pipeline run. Idempotent: a no-op once the directory is gone.
pre_tasks:
- name: Check for the retired sml2mqtt compose project
ansible.builtin.stat:
path: /opt/edge-host/sml2mqtt/docker-compose.yml
register: edge_sml2mqtt_compose
- name: Stop the retired sml2mqtt container
ansible.builtin.command:
cmd: docker compose down --remove-orphans
chdir: /opt/edge-host/sml2mqtt
when: edge_sml2mqtt_compose.stat.exists
changed_when: true
- name: Remove the retired sml2mqtt directory
ansible.builtin.file:
path: /opt/edge-host/sml2mqtt
state: absent
roles:
- mqtt-bridge
@@ -1,20 +0,0 @@
---
# Off-site Debian VM running Claude Code — reattachable interactive sessions
# plus an unattended notes-distill timer. Host creation (Synology VMM), the
# Netbird dashboard route and secret material are manual prerequisites; see
# roles/claude-workstation/README.md and docs/claude-workstation.md.
#
# CI reaches this off-site host over the site-to-site tunnel (home -> off-site
# works), same as it reaches every other inventory host.
- name: Deploy the Claude Code workstation
hosts: claude_workstation
become: true
# qemu-guest-agent is applied
# to every inventory host by the umbrella plays/common.yml, same as for the
# netbird-proxy host — run that against this host too (see RUNBOOK Step 5).
roles:
- common
- role: netbird-client # mesh enrolment (needs netbird_setup_key in vault)
when: netbird_setup_key is defined
- claude-workstation
+10 -11
View File
@@ -6,26 +6,25 @@
# - host_vars/<hostname>/vars.yml - per-host key (overrides group)
# - group_vars/all/vault.yml - encrypted keys
#
# Usage:
# ansible-playbook -i inventory/ plays/setup-netbird-client.yml
#
# Limit to specific hosts:
# ansible-playbook -i inventory/ plays/setup-netbird-client.yml --limit caddy_servers
# Applied by site.yml.
- 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
+1 -1
View File
@@ -1,6 +1,6 @@
---
# Deploy the NetBird reverse-proxy HA cluster members (the public edge).
# Usage: ansible-playbook -i inventory/ plays/install-netbird-proxy.yml
# Applied by site.yml.
#
# Prerequisites:
# - Proxy VPS provisioned (proxy-1 is Arch Linux), root SSH reachable, and
+1 -1
View File
@@ -1,6 +1,6 @@
---
# Deploy NetBird Server to VPS
# Usage: ansible-playbook -i inventory/ plays/install-netbird-server.yml
# Applied by site.yml.
#
# Prerequisites:
# - VPS provisioned with Ubuntu 22.04/24.04
-22
View File
@@ -1,22 +0,0 @@
---
# Install Node Exporter on all managed hosts
# Usage: ansible-playbook -i inventory/ plays/install-node-exporter.yml
- name: Install Node Exporter
hosts: all
become: yes
gather_facts: yes
roles:
- node-exporter
post_tasks:
- name: Verify node-exporter is running
uri:
url: "http://127.0.0.1:9100/metrics"
status_code: 200
register: exporter_health
retries: 3
delay: 5
until: exporter_health.status == 200
ignore_errors: yes
+44 -20
View File
@@ -102,32 +102,45 @@
ansible.builtin.pause:
seconds: 5
- name: Verify chrony is serving NTP
ansible.builtin.shell: chronyc clients | head -10
register: chrony_clients
- name: Verify chronyd answers
ansible.builtin.command:
argv:
- chronyc
- -n
- tracking
check_mode: false
changed_when: false
- name: Check NTP port is listening
ansible.builtin.shell: ss -lun | grep ':123 '
- name: Verify chronyd listens on udp/123
ansible.builtin.command:
argv:
- ss
- -Hlunp
- sport = :123
register: ntp_port
failed_when: false
check_mode: false
changed_when: false
- name: Display NTP port status
ansible.builtin.debug:
msg: "NTP port 123: {{ 'LISTENING' if ntp_port.rc == 0 else 'NOT LISTENING' }}"
failed_when: "'chronyd' not in ntp_port.stdout"
- name: Check if this server has the VIP
ansible.builtin.shell: ip addr show | grep {{ ntp_vip }}
ansible.builtin.command:
argv:
- ip
- -4
- addr
- show
- to
- "{{ ntp_vip }}"
register: vip_check
failed_when: false
check_mode: false
failed_when: vip_check.rc != 0
changed_when: false
when: ntp_keepalived_enabled | default(false)
- name: Display VIP status
ansible.builtin.debug:
msg: >
{% if vip_check.rc == 0 %}
{% if vip_check.stdout | default('') | length > 0 %}
This server ({{ inventory_hostname }}) is MASTER and holds the VIP {{ ntp_vip }}
{% else %}
This server ({{ inventory_hostname }}) is BACKUP
@@ -159,7 +172,8 @@
- to
- "{{ ntp_vip6 }}"
register: vip6_check
failed_when: false
check_mode: false
failed_when: vip6_check.rc != 0
changed_when: false
when: ntp_keepalived_enabled | default(false)
@@ -184,18 +198,28 @@
(keepalived not enabled on this host).
when: not (ntp_keepalived_enabled | default(false))
- name: Test NTP via VIP (only on master)
ansible.builtin.shell: |
chronyc -h {{ ntp_vip }} tracking 2>&1 || echo "Cannot connect to VIP (may need to wait for failover)"
# chronyc talks to the command port, which is loopback-only; this is a real
# NTP query. Directives on the command line mean no config file and no
# pidfile, and -Q opens no server or command port, so it runs alongside
# the live daemon.
- name: Query NTP through the VIP (only on master)
ansible.builtin.command:
argv:
- chronyd
- -Q
- -t
- "10"
- "server {{ ntp_vip }} iburst maxsamples 1"
register: ntp_vip_test
check_mode: false
when:
- ntp_keepalived_enabled | default(false)
- vip_check.rc == 0
- vip_check.stdout | default('') | length > 0
changed_when: false
- name: Display VIP NTP test result
ansible.builtin.debug:
msg: "{{ ntp_vip_test.stdout_lines | default(['Not master, skipping VIP test']) }}"
msg: "{{ ntp_vip_test.stderr_lines + ntp_vip_test.stdout_lines }}"
when:
- ntp_keepalived_enabled | default(false)
- vip_check.rc == 0
- vip_check.stdout | default('') | length > 0
+3 -2
View File
@@ -1,13 +1,14 @@
---
# Deploy Uptime Kuma to the NetBird VPS
# Usage: ansible-playbook -i inventory/ plays/install-uptime-kuma.yml
# Applied by site.yml.
#
# Prerequisites:
# - netbird-server play applied (docker, Traefik on netbird_netbird)
# - OpenBao homelab/infrastructure/uptime-kuma with keys username, password
# - OpenBao homelab/infrastructure/uptime-kuma/smtp with keys server, port, username, token
# - homelab/infrastructure/uptime-kuma-metrics (key api_key) is written by the role
# - VAULT_TOKEN in the environment (policy: uptime-kuma-vps)
# - an OpenBao token with policy uptime-kuma-vps: `bao login -method=oidc`
# (role owner) or VAULT_TOKEN
# - DNS for kuma.renner.uno pointing at the VPS
- name: Deploy Uptime Kuma
+3
View File
@@ -248,3 +248,6 @@
- "Commit inventory/group_vars/all/netbird-rotatable.yml — it is encrypted."
- "NOT rotated: datastore_key (needs dump/restore), auth_client_secret (Authentik side)."
- "OpenBao is unaffected by design — its NetBird credentials are separate ones."
- name: Close the SSH connections
ansible.builtin.import_playbook: close-connections.yml
+3 -1
View File
@@ -43,7 +43,6 @@
- ubuntu_upgrade.rc != 0
- "'No new release found' not in ubuntu_upgrade.stdout"
- "'No new release found' not in ubuntu_upgrade.stderr"
ignore_errors: true
- name: Display Upgrade Result
ansible.builtin.debug:
@@ -111,3 +110,6 @@
when:
- (ansible_distribution == 'Ubuntu' and reboot_required.stat.exists) or
(ansible_distribution == 'Debian')
- name: Close the SSH connections
ansible.builtin.import_playbook: close-connections.yml
+4 -1
View File
@@ -1,7 +1,7 @@
---
# Package upgrade + reboot for netbird_proxy, edge_host and netbird_server,
# one host at a time.
# Usage: ansible-playbook -i inventory/ plays/update-os.yml
# Usage (from ansible/): ansible-playbook plays/update-os.yml
#
# Plays run in order; a failed host stops the whole run (max_fail_percentage: 0).
@@ -28,3 +28,6 @@
become: true
roles:
- os-update
- name: Close the SSH connections
ansible.builtin.import_playbook: close-connections.yml
@@ -18,6 +18,7 @@
- name: Get installed version
command: "{{ chrony_exporter_binary }} --version"
register: installed_version
check_mode: false
when: chrony_exporter_installed.stat.exists
changed_when: false
failed_when: false
@@ -1,57 +0,0 @@
# claude-workstation
An always-on, **off-site** headless Claude Code host (Debian 12 VM under Synology
VMM on nas-2). It runs (a) reattachable interactive `tmux` sessions and (b) an
unattended notes-distill timer. It deliberately holds the **least** privilege
that still does the job.
## What lives here vs. what's manual
This role configures everything **inside** the VM. Three things are **manual**
(no IaC path) and precede/accompany it — see [`RUNBOOK.md`](RUNBOOK.md) for the
full step-by-step:
1. **VM creation** in Synology VMM (`ladmin` has no sudo; the DSM API is
root-only), with a cloud-init drive that installs the CI SSH key.
2. **Netbird dashboard route** advertising the k8s API to the mesh, and the
**site-to-site return route** on the off-site gateway (backup path).
3. **Secret material**: `claude setup-token` (subscription OAuth), the claude-bot
creds, and the read-only SA kubeconfig — encrypted into the group vault.
## Credential isolation
Two Unix users with separate credential stores so an unattended job cannot read
the interactive session's secrets:
| User | Holds | Runs |
|------|-------|------|
| `claude` | OAuth token, claude-bot creds, **read-only** SA kubeconfig | interactive tmux |
| `claude-job` | its **own** OAuth token, claude-bot creds (no kubeconfig) | notes-distill timer |
**No talosconfig, no SOPS age key** ever land here (Fable review #1). The
kubeconfig is a read-only `view` ServiceAccount (`kubernetes/apps/claude-workstation/`).
## Egress lockdown (staged)
`claude_ws_egress_enforce` ships **`false`** so a fresh host is never bricked.
The deny-default nftables ruleset, the DNS-resolver, and its refresh timer are
installed regardless. Flip to `true` (in host/group vars) only after confirming
`claude_ws_egress_allow_fqdns` / `claude_ws_egress_allow_cidrs*` cover real
traffic — watch the `claude-egress-dropped` counter first.
## Vault keys (encrypt into `inventory/group_vars/claude_workstation/vault.yml`)
```
netbird_setup_key # from the Netbird dashboard
claude_ws_oauth_token_interactive # `claude setup-token` (interactive user)
claude_ws_oauth_token_job # `claude setup-token` (job user, separate)
claude_ws_gitea_token # claude-bot PAT
claude_ws_gitea_git_credentials # https://claude-bot:<pat>@gitea.renner.uno
claude_ws_kubeconfig # read-only SA kubeconfig (literal YAML)
```
## Kill switch
Everything unattended hangs off systemd timers; `systemctl disable --now
claude-notes-distill.timer` (and the egress refresh) stops it. Every credential
is independently revocable (SA token, claude-bot PAT, OAuth token, Netbird peer).
-221
View File
@@ -1,221 +0,0 @@
# Runbook — off-site Claude Code workstation (nas-2)
Operator guide for the manual steps around the `claude-workstation` role. The
role automates everything **inside** the VM; this covers what it can't:
VM creation, networking, secret material, egress cutover, rotation and DR.
## Architecture (scope)
An always-on **Debian 13 VM under Synology VMM on the off-site nas-2**
(DS1522+, 10.3.40.0/24, reached over the site-to-site VPN, ~55 ms). It runs:
- **Reattachable interactive sessions** (`tmux` over SSH as user `claude`).
- **Unattended notes-distill timer** (as user `claude-job`).
- **A thin is-home-up signal** (the existing external Gatus on the Netbird VPS
already covers this — don't duplicate it).
**Out of scope (runs in-cluster instead):** scheduled repo-automation and
cluster-reports go in as Woodpecker cron / CronJobs with an in-cluster
ServiceAccount — *not* on this VM, so the VM holds no cluster-write creds.
Credentials on the VM are minimal: a **read-only** `view` SA kubeconfig,
claude-bot (non-admin) gitea creds, and a Claude subscription OAuth token.
**No talosconfig, no SOPS age key.**
---
## Step 1 — Create + install the VM (Synology VMM)
`ladmin` has no sudo and the DSM API is root-only, so VMM is driven through the
DSM **web UI**. Two hard facts dictate the method (all confirmed 2026-07-16, as
built): **VMM has no cloud-init**, and its ISO-install wizard only creates blank
disks. So the OS is installed from a **remastered Debian netinst ISO that
auto-boots a preseed** — no console interaction, no boot-menu race.
VMM gotchas that shape this (see the wiki build-record):
- VMM refuses to change/unmount a VM's ISO while it is **running** ("Failed to
replace ISO files"). Do all ISO/boot-device edits with the VM **off**.
- A **guest reboot does not re-read** VMM's boot-device/ISO config — only a full
VMM power-cycle applies it. So an auto-booting install ISO **loops** (reboot →
CD → reinstall, wiping the disk). Break it by preseeding the installer to
**power off** at the end: `d-i debian-installer/exit/poweroff boolean true`.
- Attach the NIC to the **untagged `Default VM Network`** — the VLAN-40
`40_Server` network is isolated (no DHCP, no network). The LAN has DHCP.
Procedure (build the ISO on a host with `xorriso`):
1. Download the current Debian **netinst** (`.../debian-cd/current/amd64/iso-cd/`).
2. Remaster it — add `/preseed.cfg` and replace `/isolinux/isolinux.cfg` with a
minimal one (`default preseed`, `timeout 1`, one label:
`kernel /install.amd/vmlinuz` /
`append auto=true priority=critical preseed/file=/cdrom/preseed.cfg vga=788 initrd=/install.amd/initrd.gz --- quiet`):
`xorriso -indev in.iso -outdev out.iso -boot_image any replay -map preseed.cfg /preseed.cfg -map isolinux.cfg /isolinux/isolinux.cfg`.
The preseed uses **DHCP** (`netcfg/dhcp_timeout 120` — the virtual-switch port
is slow to link), disk **`/dev/sda`** (VirtIO SCSI), **no user** + a locked
random root hash (key-only via `PermitRootLogin prohibit-password`), packages
`openssh-server sudo qemu-guest-agent python3`, and a `late_command` that
injects the SSH keys (`krenner@fortress`, `woodpecker-ci@homelab`, your
workstation key) into `/root/.ssh/authorized_keys`, plus the `poweroff` line.
3. Upload to nas-2 via `ssh 'cat > /volume1/Install/<iso>' < <iso>` — **scp /
sftp / rsync are blocked on DSM**. Then VMM → Image → Add → From Synology NAS
→ import (VMM **copies** it; re-import if you change the source file).
4. Create VM: 2 vCPU, 4 GB, 50 GB on `/volume1`, **Default VM Network**, Legacy
BIOS, **Autostart: Yes**; attach the ISO, **Boot from DVD/CD-ROM**, power on.
5. It installs unattended and **powers off**. Then (VM off) set **ISO =
Unmounted**, **Boot from = Virtual Disk**, power on → boots the installed
Debian. `qemu-guest-agent` reports the DHCP lease in VMM's IP column.
6. **Pin a DSM update window** (Control Panel → Update & Restore) so an
auto-reboot doesn't kill tmux/jobs.
> As built 2026-07-16: Debian 13 (trixie), got DHCP `10.3.40.78`, all three keys
> injected, packages present. Freeing RAM required powering off the pre-existing
> `pbs1` PBS VM (kept its 3.4 TB backup disk).
> **At-rest encryption (Fable #6).** Off-site physical custody is weaker than
> home. For true at-rest protection of the VM's secrets *with* unattended
> autostart, use **network-bound decryption** (clevis + a Tang server at home):
> a stolen disk off-site can't unlock because it can't reach Tang. Full-disk
> LUKS with a manual passphrase would break autostart. Until Tang is stood up,
> secrets rely on 0600 perms + VM access control — documented gap, not a fix.
---
## Step 2 — Networking (two paths)
**Netbird mesh (done via CI):** mint a setup key in the dashboard
(`https://netbird.renner.uno`), vault it as `netbird_setup_key` under
`group_vars/claude_workstation/`, and run `.woodpecker/deploy-netbird-client.yml`
(`install-netbird-client.yml --limit claude_workstation`). As built 2026-07-16
the VM enrolled as `claude-ws-190-112.nb.renner.uno` / `100.96.190.112` (+ IPv6).
> **Mesh reachability caveat.** The mesh IP (`100.96.x`) is only reachable from
> **other Netbird peers**. The home workstation and the Woodpecker CI agent are
> **not** peers — they reach the VM only via the site-to-site tunnel at its DHCP
> lease. So a *stable* mesh-based `ansible_host` needs CI (or a home host) on the
> mesh first, or a Netbird routing peer bridging tunnel↔mesh. Until then the
> inventory targets the tunnel DHCP IP.
> **DNS trap.** `claude-ws.obg.renner.uno` resolves to the VM's lease **only at
> the off-site DNS** (`@10.3.40.1`). From home this FQDN does not resolve (it
> exists only at the off-site DNS @10.3.40.1). Never use it as `ansible_host`.
**Optional — mesh route to the k8s API (redundant read path):** to reach the API
over the mesh too, add a Netbird dashboard **network route** advertising the
apiserver's ClusterIP to the `netbird-ingress` peer group, and add an egress
rule to the `netbird-ingress-egress` CiliumNetworkPolicy
(`kubernetes/infrastructure/network-policies/ingress-isolation.yaml`) permitting
`app: netbird-ingress` pods to reach `kube-apiserver:443`. Deferred from this PR
because it edits a security-critical CNP that can't be validated offline — do it
as a follow-up when you want the redundant API path.
---
## Step 3 — Read-only SA kubeconfig
After the `kubernetes/apps/claude-workstation/` PR merges and Flux reconciles:
```sh
NS=apps
TOKEN=$(kubectl -n $NS get secret claude-workstation-token -o jsonpath='{.data.token}' | base64 -d)
kubectl -n $NS get secret claude-workstation-token -o jsonpath='{.data.ca\.crt}' | base64 -d > /tmp/ca.crt
# server = the cluster's stable API endpoint (kubernetes/talos/patches/cluster.yaml).
# Use the name, not an address: it is what the API server certificate carries as a
# SAN, so an IP here fails TLS verification even when it is reachable. It must
# resolve from the workstation over the site-to-site route.
kubectl config set-cluster homelab --server=https://k8s.renner.uno:6443 \
--certificate-authority=/tmp/ca.crt --embed-certs=true --kubeconfig=/tmp/claude-ws.kubeconfig
kubectl config set-credentials claude-workstation --token="$TOKEN" --kubeconfig=/tmp/claude-ws.kubeconfig
kubectl config set-context homelab --cluster=homelab --user=claude-workstation --kubeconfig=/tmp/claude-ws.kubeconfig
kubectl config use-context homelab --kubeconfig=/tmp/claude-ws.kubeconfig
# verify read-only:
KUBECONFIG=/tmp/claude-ws.kubeconfig kubectl get nodes # works
KUBECONFIG=/tmp/claude-ws.kubeconfig kubectl get secrets -A # Forbidden (view excludes secrets)
```
The resulting file becomes the `claude_ws_kubeconfig` vault value.
---
## Step 4 — Secret material → vault
```sh
# Two independent OAuth tokens so job jobs can't impersonate the interactive user:
claude setup-token # -> claude_ws_oauth_token_interactive
claude setup-token # -> claude_ws_oauth_token_job (run again / separate session)
```
Encrypt into `ansible/inventory/group_vars/claude_workstation/vault.yml`
(ansible-vault; same VAULT_PASS the CI uses):
```yaml
netbird_setup_key: "..."
claude_ws_oauth_token_interactive: "..."
claude_ws_oauth_token_job: "..."
claude_ws_gitea_token: "..." # claude-bot PAT
claude_ws_gitea_git_credentials: "https://claude-bot:<PAT>@gitea.renner.uno"
claude_ws_kubeconfig: |
apiVersion: v1
... # the file from Step 3
```
> **Never run `claude` with `--bare`** here or on the VM — bare mode silently
> ignores `CLAUDE_CODE_OAUTH_TOKEN` and the jobs will fail auth.
---
## Step 5 — Deploy
Merge the PR, then trigger the pipeline (Woodpecker → Run → `PIPELINE ==
"deploy-claude-workstation"`), or push a change under the role's paths. CI SSHes
to `10.3.40.60` over the tunnel and applies `common`, `netbird-client`, and this
role. Also run the umbrella `plays/common.yml` against the new host once, so it
picks up `qemu-guest-agent` like every other inventory host.
---
## Step 6 — Egress cutover (staged)
The nftables allowlist ships **disabled**. To enforce:
1. Leave it off for a few days; watch what the box actually talks to.
2. Confirm `claude_ws_egress_allow_fqdns` + `claude_ws_egress_allow_cidrs*`
(home Prometheus/Loki/API) cover it.
3. Set `claude_ws_egress_enforce: true` in host/group vars, redeploy.
4. Watch the `claude-egress-dropped` nft counter (`nft list ruleset`) and Loki
for anything legitimate being dropped; widen the allowlist as needed.
---
## Step 7 — Notes (Syncthing pairing)
The role installs Syncthing for `claude-job` and starts
`syncthing@claude-job`. Pair it once (manual): from the VM's GUI (SSH tunnel to
`127.0.0.1:8384`) add your existing vault's device ID and accept the shared
`Notes` folder pointed at `{{ claude_ws_notes_dir }}`. The distill timer runs
daily at 04:30 and only ever **creates** new atomic notes.
---
## Rotation
- **OAuth token** (expires/can be revoked): re-run `claude setup-token` on your
laptop, update the two vault keys, redeploy. Headless auth failures surface in
Loki (the wrapper logs `result=failure`) — don't let them go quiet.
- **claude-bot PAT**: rotate in Gitea, update `claude_ws_gitea_*`, redeploy.
- **SA token**: `kubectl -n apps delete secret claude-workstation-token` (Flux
recreates it), re-assemble the kubeconfig (Step 3), update vault, redeploy.
## Kill switch
```sh
systemctl disable --now claude-notes-distill.timer claude-egress-refresh.timer
```
Every credential is independently revocable: delete the SA, revoke the PAT,
revoke the OAuth token, or remove the Netbird peer — no shared blast radius.
## Disaster recovery (cattle, not pet)
The VM holds no unique state — the only real state is the Notes replica, which
re-syncs from Syncthing. To rebuild: redo **Step 1** (VM + cloud-init), then
**Step 5** (pipeline). No VMM snapshots needed (they'd bloat the 86%-full
`/volume1`).
@@ -1,68 +0,0 @@
---
# claude-workstation role defaults
#
# An always-on headless host that runs Claude Code for (a) reattachable
# interactive sessions and (b) unattended notes/wiki timers. It deliberately
# holds the LEAST privilege that still lets it do its job: a read-only k8s
# ServiceAccount kubeconfig, claude-bot (non-admin) gitea creds, and a Claude
# subscription OAuth token — NO talosconfig and NO SOPS age key. See README.
# --- Users -----------------------------------------------------------------
# Two Unix users with separate credential stores so an unattended (timer) job
# cannot read the interactive session's OAuth token / kube / gitea creds.
claude_ws_interactive_user: claude # human tmux sessions
claude_ws_job_user: claude-job # unattended systemd-timer jobs
claude_ws_base_dir: /opt/claude-workstation
# --- Toolchain -------------------------------------------------------------
claude_ws_node_major: 22 # matches the ci-claude image
claude_ws_claude_code_pkg: "@anthropic-ai/claude-code"
claude_ws_base_packages:
- git
- tmux
- jq
- curl
- ca-certificates
- ripgrep
- unzip
- nftables
- python3
- python3-venv
# --- Egress allowlist ------------------------------------------------------
# Deny-default OUTPUT is the target end-state, but it can brick a fresh host if
# an allowed destination is missed, so it ships DISABLED and is tightened after
# observing real egress (staged rollout). Flip claude_ws_egress_enforce to true
# once the allowlist below is confirmed complete. See templates/nftables.conf.j2.
claude_ws_egress_enforce: false
# Static destinations the host must always reach. CDN-backed hosts (Anthropic,
# npm, apt) are matched by name at apply time via the fqdn set; home services
# are matched by IP. Tune in host_vars.
claude_ws_egress_allow_fqdns:
- api.anthropic.com
- statsig.anthropic.com
- gitea.renner.uno
- fsn1.your-objectstorage.com # Hetzner Object Storage (SOPS bundle)
- deb.debian.org
- security.debian.org
- deb.nodesource.com
- pkgs.netbird.io
- registry.npmjs.org
claude_ws_egress_allow_cidrs: [] # home Prometheus/Loki/API (v4), per-host
claude_ws_egress_allow_cidrs6: [] # same (v6) — IPv6-first, prefer these
# --- Notes / wiki pipeline -------------------------------------------------
# The Obsidian vault syncs onto this host via Syncthing (matches the user's
# existing multi-device setup). Unattended pipeline jobs must CREATE new atomic
# files rather than rewrite existing ones, to keep Syncthing conflicts rare.
claude_ws_notes_enabled: true
claude_ws_notes_dir: "/home/{{ claude_ws_job_user }}/Notes"
claude_ws_syncthing_gui_bind: "127.0.0.1:8384"
# --- Secrets (populated from the ansible-vault'd group_vars — see README) ---
# claude_ws_oauth_token_interactive : CLAUDE_CODE_OAUTH_TOKEN for the human user
# claude_ws_oauth_token_job : CLAUDE_CODE_OAUTH_TOKEN for timer jobs
# claude_ws_gitea_token : claude-bot PAT
# claude_ws_gitea_git_credentials : https://claude-bot:<pat>@gitea.renner.uno line
# claude_ws_kubeconfig : read-only SA kubeconfig (base64 or literal)
@@ -1,22 +0,0 @@
You are running unattended as a scheduled job on the claude-workstation host.
Your job is a conservative distillation pass over the Obsidian wiki vault.
Scope and rules (follow exactly — you have only Read, Write, Glob, Grep):
1. Look at `~/Notes/raw/` (ignore `~/Notes/raw/_processed/` and `~/Notes/_archive/`
entirely) for source material.
2. For each raw source, check whether `~/Notes/wiki/` already contains a note
grounded in it (Grep the wiki for the source filename / its `sources:` slug).
If a note already exists, SKIP that source — do not rewrite or duplicate it.
3. For raw sources with NO corresponding wiki note yet, create new atomic
concept notes in `~/Notes/wiki/`: one concept per file, self-contained,
tagged, cross-linked with `[[wikilinks]]`, every claim grounded in a
`sources:` entry. Never invent facts not present in the source.
4. CREATE new files only. Never rewrite, move, delete, or truncate an existing
file in `~/Notes/` — appending a missing cross-link to a note you created
THIS run is fine; touching pre-existing notes is not.
5. If there is nothing new to distill, do nothing and stop. Do not manufacture
work.
Preserve disagreement: if sources conflict, surface the conflict in the note
rather than silently picking a winner. Keep each note short and single-purpose.
@@ -1,8 +0,0 @@
# claude-workstation shared tmux config — reattachable long-running sessions.
set -g history-limit 100000
set -g mouse on
set -g base-index 1
setw -g pane-base-index 1
set -g status-right "#H %Y-%m-%d %H:%M"
# Keep sessions alive across client disconnects (the whole point of this host).
set -g destroy-unattached off
@@ -1,10 +0,0 @@
---
- name: Reload systemd
ansible.builtin.systemd:
daemon_reload: true
- name: Reload nftables
ansible.builtin.systemd:
name: nftables.service
state: reloaded
when: claude_ws_egress_enforce | bool
@@ -1,42 +0,0 @@
---
# Deny-default egress allowlist. Ships DISABLED (claude_ws_egress_enforce:
# false) so a fresh host is never bricked by a missed destination; the ruleset,
# the FQDN-resolver, and its refresh timer are installed regardless, and the
# operator flips the toggle to true once egress is confirmed complete against
# real traffic (staged rollout). CDN-backed hosts are matched via nft named
# sets that the refresh unit repopulates from DNS; static home services are
# matched by claude_ws_egress_allow_cidrs.
- name: Deploy the egress-refresh resolver
ansible.builtin.template:
src: claude-egress-refresh.sh.j2
dest: "{{ claude_ws_base_dir }}/claude-egress-refresh.sh"
mode: "0755"
- name: Deploy the egress-refresh service + timer
ansible.builtin.template:
src: "{{ item }}.j2"
dest: "/etc/systemd/system/{{ item }}"
mode: "0644"
loop:
- claude-egress-refresh.service
- claude-egress-refresh.timer
notify: Reload systemd
- name: Deploy the nftables ruleset
ansible.builtin.template:
src: nftables.conf.j2
dest: /etc/nftables.conf
mode: "0644"
validate: "nft -c -f %s"
notify: Reload nftables
- name: Enforce or relax egress lockdown
ansible.builtin.systemd:
name: "{{ item }}"
enabled: "{{ claude_ws_egress_enforce | bool }}"
state: "{{ 'started' if (claude_ws_egress_enforce | bool) else 'stopped' }}"
daemon_reload: true
loop:
- nftables.service
- claude-egress-refresh.timer
@@ -1,183 +0,0 @@
---
# claude-workstation: an always-on headless Claude Code host.
# Debian 12 VM on the off-site nas-2 (Synology VMM). See README for the manual
# bootstrap (VM creation, Netbird route, secret material) that precedes this.
#
# common.yml already applies `common` and `qemu-guest-agent`
# to every inventory host, so this role owns only the
# Claude-specific surface: toolchain, the two credential-isolated users, the
# read-only creds, notes sync, the unattended timers, and egress lockdown.
- name: Assert we are on Debian (role is apt-only)
ansible.builtin.assert:
that:
- ansible_facts['os_family'] == 'Debian'
fail_msg: "claude-workstation targets Debian; got {{ ansible_facts['os_family'] }}."
# --- Base packages ---------------------------------------------------------
- name: Install base packages
ansible.builtin.apt:
name: "{{ claude_ws_base_packages }}"
state: present
update_cache: true
# --- Node.js 22 + Claude Code (matches the ci-claude image toolchain) -------
- name: Add NodeSource signing key
ansible.builtin.get_url:
url: https://deb.nodesource.com/gpgkey/nodesource-repo.gpg.key
dest: /usr/share/keyrings/nodesource.gpg
mode: "0644"
force: false
- name: Add NodeSource apt repository
ansible.builtin.apt_repository:
repo: "deb [signed-by=/usr/share/keyrings/nodesource.gpg] https://deb.nodesource.com/node_{{ claude_ws_node_major }}.x nodistro main"
state: present
filename: nodesource
- name: Install Node.js
ansible.builtin.apt:
name: nodejs
state: present
update_cache: true
# NodeSource installs Node under /usr, so global npm modules land in
# /usr/lib/node_modules and `claude` is on PATH for every user/timer. `creates`
# keeps it idempotent (re-run `npm install -g` manually to bump the version).
- name: Install Claude Code globally
ansible.builtin.command:
cmd: "npm install -g {{ claude_ws_claude_code_pkg }}"
creates: /usr/lib/node_modules/@anthropic-ai/claude-code
# --- Credential-isolated users --------------------------------------------
- name: Create Claude users
ansible.builtin.user:
name: "{{ item }}"
shell: /bin/bash
create_home: true
loop:
- "{{ claude_ws_interactive_user }}"
- "{{ claude_ws_job_user }}"
- name: Create the role base directory (scripts, prompts)
ansible.builtin.file:
path: "{{ claude_ws_base_dir }}"
state: directory
mode: "0755"
# Let the interactive user's tmux server survive SSH logout / reboot.
- name: Enable lingering for the interactive user
ansible.builtin.command: "loginctl enable-linger {{ claude_ws_interactive_user }}"
args:
creates: "/var/lib/systemd/linger/{{ claude_ws_interactive_user }}"
- name: Deploy shared tmux config
ansible.builtin.copy:
src: tmux.conf
dest: "/home/{{ item }}/.tmux.conf"
owner: "{{ item }}"
group: "{{ item }}"
mode: "0644"
loop:
- "{{ claude_ws_interactive_user }}"
- "{{ claude_ws_job_user }}"
# --- Secrets: each user gets only what its role needs ----------------------
# The interactive user gets gitea + OAuth + read-only kubeconfig. The job user
# gets its OWN OAuth token + gitea (to open PRs) but NO kubeconfig.
- name: Create ~/.config/claude-bot for the interactive user
ansible.builtin.file:
path: "/home/{{ item }}/.config/claude-bot"
state: directory
owner: "{{ item }}"
group: "{{ item }}"
mode: "0700"
loop:
- "{{ claude_ws_interactive_user }}"
- "{{ claude_ws_job_user }}"
- name: Deploy claude-bot gitea token
ansible.builtin.copy:
content: "{{ claude_ws_gitea_token }}\n"
dest: "/home/{{ item }}/.config/claude-bot/gitea-token"
owner: "{{ item }}"
group: "{{ item }}"
mode: "0600"
loop:
- "{{ claude_ws_interactive_user }}"
- "{{ claude_ws_job_user }}"
no_log: true
- name: Deploy claude-bot git-credentials
ansible.builtin.copy:
content: "{{ claude_ws_gitea_git_credentials }}\n"
dest: "/home/{{ item }}/.config/claude-bot/git-credentials"
owner: "{{ item }}"
group: "{{ item }}"
mode: "0600"
loop:
- "{{ claude_ws_interactive_user }}"
- "{{ claude_ws_job_user }}"
no_log: true
# CLAUDE_CODE_OAUTH_TOKEN is delivered as an EnvironmentFile so both interactive
# shells and timer units can source it without it landing in shell history.
# NEVER run claude with --bare here: bare mode silently ignores this token.
- name: Deploy per-user Claude OAuth environment file
ansible.builtin.copy:
content: "CLAUDE_CODE_OAUTH_TOKEN={{ item.token }}\n"
dest: "/home/{{ item.user }}/.config/claude-code.env"
owner: "{{ item.user }}"
group: "{{ item.user }}"
mode: "0600"
loop:
- { user: "{{ claude_ws_interactive_user }}", token: "{{ claude_ws_oauth_token_interactive }}" }
- { user: "{{ claude_ws_job_user }}", token: "{{ claude_ws_oauth_token_job }}" }
loop_control:
label: "{{ item.user }}"
no_log: true
- name: Create ~/.kube for the interactive user
ansible.builtin.file:
path: "/home/{{ claude_ws_interactive_user }}/.kube"
state: directory
owner: "{{ claude_ws_interactive_user }}"
group: "{{ claude_ws_interactive_user }}"
mode: "0700"
- name: Deploy read-only SA kubeconfig (interactive user only)
ansible.builtin.copy:
content: "{{ claude_ws_kubeconfig }}"
dest: "/home/{{ claude_ws_interactive_user }}/.kube/config"
owner: "{{ claude_ws_interactive_user }}"
group: "{{ claude_ws_interactive_user }}"
mode: "0600"
no_log: true
# This VM's only git identity IS claude-bot (unlike the shared human
# workstation, where a global credential.helper is forbidden), so a per-user
# ~/.gitconfig pointing at each user's own claude-bot store is appropriate.
- name: Configure git identity + credential store for the Claude users
ansible.builtin.copy:
dest: "/home/{{ item }}/.gitconfig"
owner: "{{ item }}"
group: "{{ item }}"
mode: "0644"
content: |
[user]
name = Claude Bot
email = claude-bot@renner.uno
[credential]
helper = store --file=/home/{{ item }}/.config/claude-bot/git-credentials
loop:
- "{{ claude_ws_interactive_user }}"
- "{{ claude_ws_job_user }}"
# --- Notes / wiki pipeline (Syncthing + timers) ----------------------------
- name: Notes pipeline
ansible.builtin.include_tasks: notes.yml
when: claude_ws_notes_enabled | bool
# --- Egress lockdown -------------------------------------------------------
- name: Egress firewall
ansible.builtin.include_tasks: firewall.yml
@@ -1,48 +0,0 @@
---
# Notes/wiki pipeline: the Obsidian vault syncs onto this host via Syncthing
# (matching the user's existing multi-device setup), and a hardened, bounded
# systemd timer runs an unattended `claude -p` distill pass over it as the
# credential-isolated job user. Device pairing is a one-time manual step (see
# README) — this only installs Syncthing and the timer.
- name: Install Syncthing
ansible.builtin.apt:
name: syncthing
state: present
update_cache: true
- name: Enable Syncthing for the job user
ansible.builtin.systemd:
name: "syncthing@{{ claude_ws_job_user }}.service"
enabled: true
state: started
daemon_reload: true
- name: Deploy the notes-distill prompt
ansible.builtin.copy:
src: claude-notes-distill-prompt.md
dest: "{{ claude_ws_base_dir }}/claude-notes-distill-prompt.md"
mode: "0644"
- name: Deploy the notes-distill wrapper
ansible.builtin.template:
src: claude-notes-distill.sh.j2
dest: "{{ claude_ws_base_dir }}/claude-notes-distill.sh"
mode: "0755"
- name: Deploy the notes-distill service + timer
ansible.builtin.template:
src: "{{ item }}.j2"
dest: "/etc/systemd/system/{{ item }}"
mode: "0644"
loop:
- claude-notes-distill.service
- claude-notes-distill.timer
notify: Reload systemd
- name: Enable the notes-distill timer
ansible.builtin.systemd:
name: claude-notes-distill.timer
enabled: true
state: started
daemon_reload: true
@@ -1,10 +0,0 @@
[Unit]
# {{ ansible_managed }}
Description=Refresh claude-workstation egress allowlist from DNS
After=nftables.service network-online.target
Wants=network-online.target
Requires=nftables.service
[Service]
Type=oneshot
ExecStart={{ claude_ws_base_dir }}/claude-egress-refresh.sh
@@ -1,27 +0,0 @@
#!/usr/bin/env bash
# {{ ansible_managed }}
# Resolve the egress FQDN allowlist to current IPs and load them into the
# nftables @allow4/@allow6 sets. CDN IPs rotate, so this runs on a timer.
# No-op unless the claude_egress table is loaded (i.e. enforcement is on).
set -euo pipefail
TABLE="inet claude_egress"
FQDNS=(
{% for fqdn in claude_ws_egress_allow_fqdns %}
"{{ fqdn }}"
{% endfor %}
)
nft list table $TABLE >/dev/null 2>&1 || { echo "claude_egress table not loaded; enforcement off"; exit 0; }
v4=(); v6=()
for host in "${FQDNS[@]}"; do
while read -r ip; do [ -n "$ip" ] && v4+=("$ip"); done < <(getent ahostsv4 "$host" | awk '{print $1}' | sort -u)
while read -r ip; do [ -n "$ip" ] && v6+=("$ip"); done < <(getent ahostsv6 "$host" | awk '{print $1}' | sort -u)
done
nft flush set $TABLE allow4
nft flush set $TABLE allow6
[ ${#v4[@]} -gt 0 ] && nft add element $TABLE allow4 "{ $(IFS=,; echo "${v4[*]}") }"
[ ${#v6[@]} -gt 0 ] && nft add element $TABLE allow6 "{ $(IFS=,; echo "${v6[*]}") }"
echo "claude-egress-refresh: ${#v4[@]} v4 + ${#v6[@]} v6 addresses loaded"
@@ -1,10 +0,0 @@
[Unit]
# {{ ansible_managed }}
Description=Periodically refresh the claude-workstation egress allowlist
[Timer]
OnBootSec=30s
OnUnitActiveSec=15min
[Install]
WantedBy=timers.target
@@ -1,32 +0,0 @@
[Unit]
# {{ ansible_managed }}
Description=Claude Code unattended notes-distill pass
After=network-online.target syncthing@{{ claude_ws_job_user }}.service
Wants=network-online.target
[Service]
Type=oneshot
User={{ claude_ws_job_user }}
Group={{ claude_ws_job_user }}
EnvironmentFile=/home/{{ claude_ws_job_user }}/.config/claude-code.env
ExecStart={{ claude_ws_base_dir }}/claude-notes-distill.sh
# Hard wall-clock kill — the runaway/kill switch for this unattended agent.
TimeoutStartSec=1800
# --- Hardening: an unattended LLM job on internet-influenced input ----------
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=strict
ReadWritePaths=/home/{{ claude_ws_job_user }}
# Credential isolation: cannot read the interactive session's tokens/kubeconfig.
InaccessiblePaths=/home/{{ claude_ws_interactive_user }}
ProtectKernelTunables=true
ProtectKernelModules=true
ProtectControlGroups=true
RestrictSUIDSGID=true
RestrictNamespaces=true
LockPersonality=true
MemoryDenyWriteExecute=false
[Install]
WantedBy=multi-user.target
@@ -1,32 +0,0 @@
#!/usr/bin/env bash
# {{ ansible_managed }}
# Unattended notes-distill pass. Prompt via stdin (never shell-substituted),
# tool-allowlisted, no --bare (bare silently drops CLAUDE_CODE_OAUTH_TOKEN).
# Bounded by the unit's TimeoutStartSec, not a CLI flag (--max-turns is absent
# in the installed Claude Code). Emits a terminal state line to the journal on
# EVERY outcome so a Loki alert can catch silent failures (dead-man switch).
set -euo pipefail
export HOME="/home/{{ claude_ws_job_user }}"
NOTES="{{ claude_ws_notes_dir }}"
PROMPT="{{ claude_ws_base_dir }}/claude-notes-distill-prompt.md"
log() { logger -t claude-notes-distill -- "$*"; echo "claude-notes-distill: $*"; }
if [ ! -d "$NOTES/raw" ]; then
log "result=skipped reason=no-vault path=$NOTES"
exit 0
fi
log "result=start"
if claude -p \
--add-dir "$NOTES" \
--allowedTools "Read,Write,Glob,Grep" \
--model sonnet \
< "$PROMPT"; then
log "result=success"
else
rc=$?
log "result=failure rc=$rc"
exit "$rc"
fi
@@ -1,11 +0,0 @@
[Unit]
# {{ ansible_managed }}
Description=Run the Claude Code notes-distill pass daily
[Timer]
OnCalendar=*-*-* 04:30:00
Persistent=true
RandomizedDelaySec=900
[Install]
WantedBy=timers.target
@@ -1,55 +0,0 @@
#!/usr/sbin/nft -f
# {{ ansible_managed }}
# Deny-default egress for the claude-workstation. CDN-backed destinations are
# matched via the @allow4/@allow6 named sets, repopulated from DNS by
# claude-egress-refresh. Static home services are matched by the @static* sets.
# Only loaded when claude_ws_egress_enforce is true.
flush ruleset
table inet claude_egress {
set allow4 { type ipv4_addr; flags interval; }
set allow6 { type ipv6_addr; flags interval; }
set static4 {
type ipv4_addr
flags interval
{% if (claude_ws_egress_allow_cidrs | default([])) | length > 0 %}
elements = { {{ claude_ws_egress_allow_cidrs | join(', ') }} }
{% endif %}
}
set static6 {
type ipv6_addr
flags interval
{% if (claude_ws_egress_allow_cidrs6 | default([])) | length > 0 %}
elements = { {{ claude_ws_egress_allow_cidrs6 | join(', ') }} }
{% endif %}
}
chain output {
type filter hook output priority 0; policy drop;
oif "lo" accept
ct state established,related accept
# DNS — required to resolve the allowlist itself.
udp dport 53 accept
tcp dport 53 accept
# Diagnostics + DHCP lease renewal on the bridged interface.
ip protocol icmp accept
ip6 nexthdr icmpv6 accept
udp dport { 67, 68 } accept
# NetBird / WireGuard control + data plane (initial handshakes).
udp dport { 3478, 51820 } accept
# Allowed destinations.
ip daddr @allow4 accept
ip6 daddr @allow6 accept
ip daddr @static4 accept
ip6 daddr @static6 accept
# Everything else is dropped and counted for inspection.
counter comment "claude-egress-dropped"
}
}
+2 -4
View File
@@ -51,10 +51,8 @@
# -------------------------------------------------------
# 4. SSH Configuration
# -------------------------------------------------------
# Authorized keys (human + woodpecker-ci, the latter from=-restricted) are
# managed by the dedicated ansible/plays/configure-ssh-keys.yml play. The old
# "Add Ansible Runner SSH Key" task deployed ansible-runner@ci-new, a dead key
# for a host that no longer exists — removed (issue #184).
# SSH access (CA trust, admin authorized_keys) is managed by the ssh-ca role
# via plays/configure-ssh-ca.yml.
# -------------------------------------------------------
# 5. Disable NTP on VMs/LXC (use host time)
@@ -29,9 +29,11 @@
# upstream ships no container image for this bouncer, which is expected —
# it manipulates host netfilter.
#
# The CI runner has v4, so it does the fetching. The checksum is verified
# there, before anything is copied to a host.
# The controller has v4, so it does the fetching. The checksum is verified
# there, before anything is copied to a host. Also in check mode: the fetch
# only writes on the controller, and the copy below needs the file.
- name: Fetch the pinned bouncer release on the controller
check_mode: false
ansible.builtin.get_url:
url: >-
https://github.com/crowdsecurity/cs-firewall-bouncer/releases/download/{{
+1
View File
@@ -87,6 +87,7 @@
ansible.builtin.command:
argv: [pgrep, -af, "edge-host/(open3e|e3oncan)"]
register: heating_can_survivors
check_mode: false
changed_when: false
failed_when: false
+4 -3
View File
@@ -65,6 +65,7 @@
- /etc/network/interfaces
- /etc/network/interfaces.d/
register: keepalived_ntp_static6_ifupdown
check_mode: false
changed_when: false
failed_when: false
when:
@@ -123,6 +124,7 @@
- "{{ keepalived_ntp_vip6.split('/')[0] }}\\b"
- /etc/systemd/network/
register: keepalived_ntp_static6_networkd
check_mode: false
changed_when: false
failed_when: false
when:
@@ -140,9 +142,8 @@
Handing the address to keepalived while networkd also assigns it means
it would NOT be withdrawn when check_ntp fails -- the host would keep
serving time on an address VRRP believes it has released.
Re-apply the edge-vlan role first, whose template no longer carries the
[Address] block:
ansible-playbook -i inventory/pve_hosts.yml plays/install-edge-host.yml
Re-apply the edge-vlan role first (site.yml runs it before this play),
whose template no longer carries the [Address] block.
success_msg: >-
No static networkd assignment of {{ keepalived_ntp_vip6 }}; safe for
keepalived to own it.
@@ -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
+88 -36
View File
@@ -2,47 +2,79 @@
# 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: netbird
state: present
update_cache: true
- name: Install NetBird client
ansible.builtin.apt:
name: netbird
state: present
update_cache: true
- name: Check if NetBird is already connected
ansible.builtin.command: netbird status
register: netbird_status
changed_when: false
failed_when: false
# 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. The controller steps also run in check mode: they only write on
# the controller, and the install step needs the archive.
- 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
check_mode: false
- 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"
register: netbird_connect
changed_when: netbird_connect.rc == 0
- name: Download the NetBird release on the controller
check_mode: false
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:
@@ -50,9 +82,29 @@
state: started
enabled: true
- name: Check if NetBird is already connected
ansible.builtin.command: netbird status
register: netbird_status
check_mode: false
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 }}
{{ '--disable-dns' if netbird_disable_dns else '' }}
when: "'Management: Connected' not in netbird_status.stdout"
register: netbird_connect
changed_when: netbird_connect.rc == 0
no_log: true
- name: Get NetBird status
ansible.builtin.command: netbird status
register: netbird_final_status
check_mode: false
changed_when: false
- name: Display NetBird status
+3 -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
@@ -167,6 +157,7 @@
- name: Read the host NetBird client config
ansible.builtin.command: netbird debug config
register: _netbird_client_config
check_mode: false
changed_when: false
when: _netbird_client.stat.exists
@@ -61,6 +61,10 @@ services:
- AUTH_AUDIENCE={{ netbird_auth_audience }}
- AUTH_CLIENT_ID={{ netbird_auth_client_id }}
- AUTH_AUTHORITY={{ netbird_auth_issuer }}
# The CSP allows AUTH_AUTHORITY only as a path (/application/o/netbird/);
# Authentik's token/userinfo endpoints sit outside it, so the browser
# would block the code exchange and every token refresh.
- NETBIRD_CSP={{ netbird_auth_issuer | urlsplit('scheme') }}://{{ netbird_auth_issuer | urlsplit('netloc') }}
- USE_AUTH0=false
- AUTH_SUPPORTED_SCOPES=openid profile email
- NETBIRD_TOKEN_SOURCE=idToken
@@ -1,20 +0,0 @@
---
# Node Exporter defaults
node_exporter_version: "1.8.2"
node_exporter_port: 9100
node_exporter_user: node-exporter
node_exporter_group: node-exporter
# Installation paths
node_exporter_install_dir: /opt/node-exporter
node_exporter_binary: "{{ node_exporter_install_dir }}/node_exporter"
# Collectors to enable (defaults are usually sufficient)
node_exporter_enabled_collectors:
- systemd
- processes
- timex
# Collectors to disable
node_exporter_disabled_collectors: []
@@ -1,62 +0,0 @@
#!/bin/bash
# Generate Prometheus metrics from chronyc tracking output
# Runs as a cron job and outputs to textfile collector directory
OUTPUT_FILE="${1:-/var/lib/node-exporter/chrony.prom}"
# Get chrony tracking info
tracking=$(chronyc tracking 2>/dev/null)
if [ -z "$tracking" ]; then
echo "chrony_up 0" > "$OUTPUT_FILE.tmp"
mv "$OUTPUT_FILE.tmp" "$OUTPUT_FILE"
exit 0
fi
# Parse values
stratum=$(echo "$tracking" | grep "^Stratum" | awk '{print $3}')
system_time=$(echo "$tracking" | grep "^System time" | awk '{print $4}')
root_delay=$(echo "$tracking" | grep "^Root delay" | awk '{print $4}')
root_dispersion=$(echo "$tracking" | grep "^Root dispersion" | awk '{print $4}')
last_offset=$(echo "$tracking" | grep "^Last offset" | awk '{print $4}')
rms_offset=$(echo "$tracking" | grep "^RMS offset" | awk '{print $4}')
frequency=$(echo "$tracking" | grep "^Frequency" | awk '{print $3}')
ref_id=$(echo "$tracking" | grep "^Reference ID" | awk '{print $4}' | tr -d '()')
# Handle negative offsets (remove leading + or keep -)
system_time=$(echo "$system_time" | sed 's/^+//')
last_offset=$(echo "$last_offset" | sed 's/^+//')
rms_offset=$(echo "$rms_offset" | sed 's/^+//')
# Output Prometheus metrics
cat > "$OUTPUT_FILE.tmp" << EOF
# HELP chrony_stratum Current stratum of the NTP server
# TYPE chrony_stratum gauge
chrony_stratum ${stratum:-0}
# HELP chrony_system_time_offset_seconds System time offset from NTP time
# TYPE chrony_system_time_offset_seconds gauge
chrony_system_time_offset_seconds ${system_time:-0}
# HELP chrony_root_delay_seconds Total round-trip delay to the primary reference clock
# TYPE chrony_root_delay_seconds gauge
chrony_root_delay_seconds ${root_delay:-0}
# HELP chrony_root_dispersion_seconds Total dispersion to the primary reference clock
# TYPE chrony_root_dispersion_seconds gauge
chrony_root_dispersion_seconds ${root_dispersion:-0}
# HELP chrony_last_offset_seconds Estimated offset of the last update
# TYPE chrony_last_offset_seconds gauge
chrony_last_offset_seconds ${last_offset:-0}
# HELP chrony_rms_offset_seconds RMS offset
# TYPE chrony_rms_offset_seconds gauge
chrony_rms_offset_seconds ${rms_offset:-0}
# HELP chrony_frequency_ppm Frequency offset in parts per million
# TYPE chrony_frequency_ppm gauge
chrony_frequency_ppm ${frequency:-0}
# HELP chrony_up Whether chrony is running and synchronized
# TYPE chrony_up gauge
chrony_up 1
# HELP chrony_reference_info Reference clock information
# TYPE chrony_reference_info gauge
chrony_reference_info{reference="${ref_id:-unknown}"} 1
EOF
mv "$OUTPUT_FILE.tmp" "$OUTPUT_FILE"
@@ -1,9 +0,0 @@
---
- name: Reload systemd
systemd:
daemon_reload: yes
- name: Restart node-exporter
systemd:
name: node-exporter
state: restarted
@@ -1,88 +0,0 @@
---
# Install and configure Node Exporter
- name: Create node-exporter system group
group:
name: "{{ node_exporter_group }}"
system: yes
state: present
- name: Create node-exporter system user
user:
name: "{{ node_exporter_user }}"
group: "{{ node_exporter_group }}"
system: yes
shell: /usr/sbin/nologin
home: "{{ node_exporter_install_dir }}"
createhome: no
- name: Create installation directory
file:
path: "{{ node_exporter_install_dir }}"
state: directory
owner: "{{ node_exporter_user }}"
group: "{{ node_exporter_group }}"
mode: '0755'
- name: Check if node_exporter is already installed
stat:
path: "{{ node_exporter_binary }}"
register: node_exporter_installed
- name: Get installed version
command: "{{ node_exporter_binary }} --version"
register: installed_version
when: node_exporter_installed.stat.exists
changed_when: false
failed_when: false
- name: Download node_exporter
get_url:
url: "https://github.com/prometheus/node_exporter/releases/download/v{{ node_exporter_version }}/node_exporter-{{ node_exporter_version }}.linux-amd64.tar.gz"
dest: /tmp/node_exporter.tar.gz
mode: '0644'
when: not node_exporter_installed.stat.exists or (installed_version.stdout is defined and node_exporter_version not in installed_version.stdout)
- name: Extract node_exporter
unarchive:
src: /tmp/node_exporter.tar.gz
dest: /tmp
remote_src: yes
when: not node_exporter_installed.stat.exists or (installed_version.stdout is defined and node_exporter_version not in installed_version.stdout)
- name: Install node_exporter binary
copy:
src: "/tmp/node_exporter-{{ node_exporter_version }}.linux-amd64/node_exporter"
dest: "{{ node_exporter_binary }}"
owner: "{{ node_exporter_user }}"
group: "{{ node_exporter_group }}"
mode: '0755'
remote_src: yes
when: not node_exporter_installed.stat.exists or (installed_version.stdout is defined and node_exporter_version not in installed_version.stdout)
notify: Restart node-exporter
- name: Create systemd service
template:
src: node_exporter.service.j2
dest: /etc/systemd/system/node-exporter.service
owner: root
group: root
mode: '0644'
notify:
- Reload systemd
- Restart node-exporter
- name: Enable and start node-exporter service
systemd:
name: node-exporter
enabled: yes
state: started
daemon_reload: yes
- name: Clean up downloaded files
file:
path: "{{ item }}"
state: absent
loop:
- /tmp/node_exporter.tar.gz
- "/tmp/node_exporter-{{ node_exporter_version }}.linux-amd64"
@@ -1,38 +0,0 @@
[Unit]
Description=Prometheus Node Exporter
Documentation=https://github.com/prometheus/node_exporter
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User={{ node_exporter_user }}
Group={{ node_exporter_group }}
ExecStart={{ node_exporter_binary }} \
--web.listen-address=:{{ node_exporter_port }} \
{% for collector in node_exporter_enabled_collectors %}
--collector.{{ collector }} \
{% endfor %}
{% for collector in node_exporter_disabled_collectors %}
--no-collector.{{ collector }} \
{% endfor %}
{% if node_exporter_textfile_dir is defined %}
--collector.textfile \
--collector.textfile.directory={{ node_exporter_textfile_dir }} \
{% endif %}
--collector.filesystem.mount-points-exclude='^/(sys|proc|dev|host|etc)($$|/)'
Restart=on-failure
RestartSec=10s
# Security hardening
NoNewPrivileges=yes
ProtectSystem=strict
ProtectHome=yes
PrivateTmp=yes
ReadOnlyPaths=/
{% if node_exporter_textfile_dir is defined %}
ReadWritePaths={{ node_exporter_textfile_dir }}
{% endif %}
[Install]
WantedBy=multi-user.target
+18
View File
@@ -0,0 +1,18 @@
---
# SSH user CA (OpenBao ssh-client-signer). Hosts trust certificates signed by
# it; the public key is fetched from OpenBao on the controller at run time.
ssh_ca_public_key_url: "https://openbao.renner.uno/v1/ssh-client-signer/public_key"
# Owner certificates (role owner) carry this principal. It is accepted for
# whichever admin login the distro has; users that do not exist are skipped.
ssh_ca_owner_principal: k.renner
ssh_ca_owner_users:
- root
- ladmin
# Claude's own account. Certificates (role claude, 1h) carry the principal.
ssh_ca_claude_user: claude-bot
ssh_ca_claude_principal: claude-bot
# Units claude-bot may restart/reload via sudo on this host, e.g. [netbird].
ssh_ca_claude_sudo_units: []
+2
View File
@@ -0,0 +1,2 @@
# Public keys whose certificates sshd must reject (one per line, OpenSSH format).
# Revoking a key also revokes every certificate issued for it.
@@ -0,0 +1,23 @@
---
# Replaces the admin logins' authorized_keys with ssh_admin_authorized_keys
# (break-glass + CI). Everything else logs in with a CA certificate.
- name: Look up the admin logins
ansible.builtin.getent:
database: passwd
key: "{{ item }}"
fail_key: false
loop: "{{ ssh_ca_owner_users }}"
register: ssh_ca_admin_lookup
- name: Set the admin logins' authorized_keys to the managed list
ansible.posix.authorized_key:
user: "{{ item.item }}"
key: |
{% for k in ssh_admin_authorized_keys %}
{{ (k.options ~ ' ') if (k.options | default('')) else '' }}{{ k.key }}
{% endfor %}
exclusive: true
loop: "{{ ssh_ca_admin_lookup.results }}"
loop_control:
label: "{{ item.item }}"
when: (item.ansible_facts.getent_passwd | default({})).get(item.item) is not none
+167
View File
@@ -0,0 +1,167 @@
---
- name: Fetch the SSH CA public key from OpenBao
ansible.builtin.uri:
url: "{{ ssh_ca_public_key_url }}"
return_content: true
delegate_to: localhost
become: false
run_once: true
register: ssh_ca_key
check_mode: false
- name: Check the fetched CA key is an SSH public key
ansible.builtin.assert:
that:
- ssh_ca_key.content is match('^ssh-ed25519 AAAA')
fail_msg: "Unexpected CA key from {{ ssh_ca_public_key_url }}"
run_once: true
- name: Install the trusted user CA key
ansible.builtin.copy:
content: "{{ ssh_ca_key.content | trim }}\n"
dest: /etc/ssh/trusted_user_ca_keys
owner: root
group: root
mode: "0644"
# sshd refuses all public key logins when RevokedKeys is set but unreadable,
# so the file is always deployed, even when empty.
- name: Install the revoked keys list
ansible.builtin.copy:
src: revoked_keys
dest: /etc/ssh/revoked_keys
owner: root
group: root
mode: "0644"
# systemd-journal: read every journal without sudo (see sudoers-claude.j2).
- name: Create the Claude account
ansible.builtin.user:
name: "{{ ssh_ca_claude_user }}"
shell: /bin/bash
create_home: true
password: "!"
groups: systemd-journal
append: true
- name: Look up the owner admin logins
ansible.builtin.getent:
database: passwd
key: "{{ item }}"
fail_key: false
loop: "{{ ssh_ca_owner_users }}"
register: ssh_ca_owner_lookup
- name: Create the principals directory
ansible.builtin.file:
path: /etc/ssh/auth_principals
state: directory
owner: root
group: root
mode: "0755"
- name: Accept the owner principal for the existing admin logins
ansible.builtin.copy:
content: "{{ ssh_ca_owner_principal }}\n"
dest: "/etc/ssh/auth_principals/{{ item.item }}"
owner: root
group: root
mode: "0644"
loop: "{{ ssh_ca_owner_lookup.results }}"
loop_control:
label: "{{ item.item }}"
when: (item.ansible_facts.getent_passwd | default({})).get(item.item) is not none
- name: Accept the Claude principal for the Claude account
ansible.builtin.copy:
content: "{{ ssh_ca_claude_principal }}\n"
dest: "/etc/ssh/auth_principals/{{ ssh_ca_claude_user }}"
owner: root
group: root
mode: "0644"
- name: Install the Claude sudo rules
ansible.builtin.template:
src: sudoers-claude.j2
dest: "/etc/sudoers.d/{{ ssh_ca_claude_user }}"
owner: root
group: root
mode: "0440"
validate: visudo -cf %s
# A broken drop-in only bites on the next sshd restart, so it is tested here
# and removed again if the full config does not parse.
- name: Configure sshd for the user CA
block:
- name: Install the sshd drop-in
ansible.builtin.template:
src: ssh-ca.conf.j2
dest: /etc/ssh/sshd_config.d/50-ssh-ca.conf
owner: root
group: root
mode: "0644"
- name: Test the full sshd config
ansible.builtin.command: sshd -t
changed_when: false
rescue:
- name: Remove the drop-in that broke sshd -t
ansible.builtin.file:
path: /etc/ssh/sshd_config.d/50-ssh-ca.conf
state: absent
- name: Fail the host
ansible.builtin.fail:
msg: "sshd -t failed with the SSH CA drop-in; it was removed again"
- name: Read the effective sshd settings
ansible.builtin.command: sshd -T
changed_when: false
register: ssh_ca_effective
# Keyword case in sshd -T output differs between OpenSSH versions (mixed case
# on 10.6), so the lines are compared lowercased. Not in check mode: sshd -T
# reads the files on disk, which check mode has not written.
- name: Check sshd uses the CA, principals and revocation list
when: not ansible_check_mode
vars:
ssh_ca_effective_lines: "{{ ssh_ca_effective.stdout_lines | map('lower') | list }}"
ansible.builtin.assert:
that:
- "'trustedusercakeys /etc/ssh/trusted_user_ca_keys' in ssh_ca_effective_lines"
- "'authorizedprincipalsfile /etc/ssh/auth_principals/%u' in ssh_ca_effective_lines"
- "'revokedkeys /etc/ssh/revoked_keys' in ssh_ca_effective_lines"
- "'passwordauthentication no' in ssh_ca_effective_lines"
- "'kbdinteractiveauthentication no' in ssh_ca_effective_lines"
fail_msg: "sshd -T does not show the SSH CA settings; another config line takes precedence"
# The unit is sshd.service on Arch and RedHat, ssh.service on Debian/Ubuntu,
# where sshd.service is an alias that resolves to it. sshd -T only parses the
# files, so the running daemon is reloaded on 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.
# systemctl directly: service_facts took 3-12 s per host.
- name: Find the running sshd unit
ansible.builtin.command: systemctl list-units --state=running --plain --no-legend --type=service sshd.service ssh.service
changed_when: false
register: ssh_ca_sshd_running
check_mode: false
- name: Reload the running sshd unit
vars:
ssh_ca_sshd_units: "{{ ssh_ca_sshd_running.stdout_lines | map('split') | map('first') | unique | list }}"
block:
- name: Check at most one sshd unit is running
ansible.builtin.assert:
that:
- 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
+27
View File
@@ -0,0 +1,27 @@
---
# Read-only: what the admin logins accept besides certificates. Hosts in
# [ssh_keys_managed] have this reduced to ssh_admin_authorized_keys.
- name: Find the admin logins' authorized_keys
ansible.builtin.stat:
path: "{{ item.ansible_facts.getent_passwd[item.item][4] }}/.ssh/authorized_keys"
loop: "{{ ssh_ca_owner_lookup.results }}"
loop_control:
label: "{{ item.item }}"
when: (item.ansible_facts.getent_passwd | default({})).get(item.item) is not none
register: ssh_ca_keyfiles
- name: Fingerprint the admin logins' authorized keys
ansible.builtin.command: ssh-keygen -lf {{ item.stat.path }}
loop: "{{ ssh_ca_keyfiles.results | selectattr('stat', 'defined') | selectattr('stat.exists') | list }}"
loop_control:
label: "{{ item.stat.path }}"
changed_when: false
register: ssh_ca_fingerprints
check_mode: false
- name: Report the admin logins' authorized keys
ansible.builtin.debug:
msg: "{{ item.item.stat.path }}: {{ item.stdout_lines }}"
loop: "{{ ssh_ca_fingerprints.results }}"
loop_control:
label: "{{ item.item.stat.path }}"
@@ -0,0 +1,6 @@
# {{ ansible_managed }}
TrustedUserCAKeys /etc/ssh/trusted_user_ca_keys
AuthorizedPrincipalsFile /etc/ssh/auth_principals/%u
RevokedKeys /etc/ssh/revoked_keys
PasswordAuthentication no
KbdInteractiveAuthentication no
@@ -0,0 +1,12 @@
# {{ ansible_managed }}
# Logs and unit status come from the systemd-journal group, not sudo:
# journalctl as root would also allow --vacuum-*/--rotate.
# Commands are listed for both the Arch and the Debian/Ubuntu path.
# nft only with fixed arguments: it joins argv into one command line, so a
# wildcard would admit "; flush ruleset". No docker start: -a/-i attach to a
# container's stdin; docker restart also starts stopped containers.
{{ ssh_ca_claude_user }} ALL=(root) NOPASSWD:NOEXEC: /usr/bin/ss -tulpn, /usr/sbin/sshd -T, /usr/bin/nft list ruleset, /usr/sbin/nft list ruleset, /usr/bin/netbird status, /usr/bin/netbird status *, /usr/local/bin/netbird status, /usr/local/bin/netbird status *
{{ ssh_ca_claude_user }} ALL=(root) NOPASSWD:NOEXEC: /usr/bin/docker ps, /usr/bin/docker ps *, /usr/bin/docker logs *, /usr/bin/docker stats --no-stream, /usr/bin/docker restart *
{% for unit in ssh_ca_claude_sudo_units %}
{{ ssh_ca_claude_user }} ALL=(root) NOPASSWD:NOEXEC: /usr/bin/systemctl restart {{ unit }}, /usr/bin/systemctl reload {{ unit }}
{% endfor %}
@@ -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
+114
View File
@@ -0,0 +1,114 @@
---
- 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
check_mode: false
# Each run that finds a listener outside the interface switches over. An
# include, because meta reset_connection ignores when: and a reconnect costs a
# YubiKey PIN from a workstation.
- name: Switch sshd to the mesh-only socket
ansible.builtin.include_tasks: switch.yml
when: >-
ssh_mesh_listeners.stdout_lines | length == 0 or
ssh_mesh_listeners.stdout_lines | reject('search', '%' ~ ssh_mesh_interface ~ ':22\s') | list | length > 0
- name: Check sshd listens on the mesh interface only
ansible.builtin.assert:
that:
- ssh_mesh_listeners.stdout_lines | length > 0
- ssh_mesh_listeners.stdout_lines | reject('search', '%' ~ ssh_mesh_interface ~ ':22\s') | list | length == 0
fail_msg: "Listeners outside {{ ssh_mesh_interface }}: {{ ssh_mesh_listeners.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,27 @@
---
# The revert timer runs in its own unit, so a switch that locks the play out is
# undone after ssh_mesh_revert_minutes. main.yml cancels it after checking the
# listeners read through the new one.
- 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 new 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
@@ -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
@@ -14,3 +14,8 @@ uptime_kuma_openbao_mount: homelab
uptime_kuma_openbao_path: infrastructure/uptime-kuma
uptime_kuma_openbao_smtp_path: infrastructure/uptime-kuma/smtp
uptime_kuma_openbao_metrics_path: infrastructure/uptime-kuma-metrics
# Token with policy uptime-kuma-vps, looked up like the bao CLI does:
# VAULT_TOKEN, else the token helper file written by `bao login -method=oidc`.
uptime_kuma_openbao_token: >-
{{ lookup('ansible.builtin.env', 'VAULT_TOKEN')
or (lookup('ansible.builtin.file', '~/.vault-token', errors='ignore') | default('', true)) }}
+52 -36
View File
@@ -2,43 +2,58 @@
# Admin bootstrap runs with the Traefik route disabled so /setup is never
# reachable from outside; the route is enabled once the admin exists.
# OpenBao's KV v2 HTTP API directly, so the controller needs no hvac library.
# Reads also run in check mode.
- name: Check for an OpenBao token
ansible.builtin.assert:
that:
- uptime_kuma_openbao_token | length > 0
fail_msg: "No OpenBao token: run 'bao login -method=oidc' (policy uptime-kuma-vps) or set VAULT_TOKEN."
run_once: true
- name: Read admin credentials from OpenBao
community.hashi_vault.vault_kv2_get:
url: "{{ uptime_kuma_openbao_url }}"
engine_mount_point: "{{ uptime_kuma_openbao_mount }}"
path: "{{ uptime_kuma_openbao_path }}"
auth_method: token
ansible.builtin.uri:
url: "{{ uptime_kuma_openbao_url }}/v1/{{ uptime_kuma_openbao_mount }}/data/{{ uptime_kuma_openbao_path }}"
headers:
X-Vault-Token: "{{ uptime_kuma_openbao_token }}"
delegate_to: localhost
become: false
run_once: true
register: _uptime_kuma_secret
check_mode: false
register: _uptime_kuma_secret_read
no_log: true
- name: Read SMTP credentials from OpenBao
community.hashi_vault.vault_kv2_get:
url: "{{ uptime_kuma_openbao_url }}"
engine_mount_point: "{{ uptime_kuma_openbao_mount }}"
path: "{{ uptime_kuma_openbao_smtp_path }}"
auth_method: token
ansible.builtin.uri:
url: "{{ uptime_kuma_openbao_url }}/v1/{{ uptime_kuma_openbao_mount }}/data/{{ uptime_kuma_openbao_smtp_path }}"
headers:
X-Vault-Token: "{{ uptime_kuma_openbao_token }}"
delegate_to: localhost
become: false
run_once: true
register: _uptime_kuma_smtp
check_mode: false
register: _uptime_kuma_smtp_read
no_log: true
# 404 until the role has stored a key for the first time.
- name: Read metrics API key from OpenBao
community.hashi_vault.vault_kv2_get:
url: "{{ uptime_kuma_openbao_url }}"
engine_mount_point: "{{ uptime_kuma_openbao_mount }}"
path: "{{ uptime_kuma_openbao_metrics_path }}"
auth_method: token
ansible.builtin.uri:
url: "{{ uptime_kuma_openbao_url }}/v1/{{ uptime_kuma_openbao_mount }}/data/{{ uptime_kuma_openbao_metrics_path }}"
headers:
X-Vault-Token: "{{ uptime_kuma_openbao_token }}"
status_code: [200, 404]
delegate_to: localhost
become: false
run_once: true
register: _uptime_kuma_metrics_secret
failed_when:
- _uptime_kuma_metrics_secret.failed | default(false)
- "'Invalid or missing path' not in (_uptime_kuma_metrics_secret.msg | default(''))"
check_mode: false
register: _uptime_kuma_metrics_read
no_log: true
- name: Extract the OpenBao secrets
ansible.builtin.set_fact:
_uptime_kuma_secret: "{{ _uptime_kuma_secret_read.json.data.data }}"
_uptime_kuma_smtp: "{{ _uptime_kuma_smtp_read.json.data.data }}"
_uptime_kuma_metrics_key: "{{ _uptime_kuma_metrics_read.json.data.data.api_key if _uptime_kuma_metrics_read.status == 200 else '' }}"
no_log: true
- name: Ensure Uptime Kuma directories exist
@@ -102,8 +117,6 @@
headers:
Authorization: "Basic {{ (':' ~ _uptime_kuma_metrics_key) | b64encode }}"
status_code: [200, 401]
vars:
_uptime_kuma_metrics_key: "{{ (_uptime_kuma_metrics_secret.secret | default({})).api_key | default('') }}"
when: _uptime_kuma_metrics_key | length > 0
register: _uptime_kuma_metrics_check
no_log: true
@@ -148,13 +161,13 @@
args:
chdir: "{{ uptime_kuma_dir }}"
environment:
KUMA_SETUP_USERNAME: "{{ _uptime_kuma_secret.secret.username }}"
KUMA_SETUP_PASSWORD: "{{ _uptime_kuma_secret.secret.password }}"
KUMA_SETUP_USERNAME: "{{ _uptime_kuma_secret.username }}"
KUMA_SETUP_PASSWORD: "{{ _uptime_kuma_secret.password }}"
KUMA_BASE_URL: "{{ uptime_kuma_base_url }}"
KUMA_SMTP_HOST: "{{ _uptime_kuma_smtp.secret.server }}"
KUMA_SMTP_PORT: "{{ _uptime_kuma_smtp.secret.port }}"
KUMA_SMTP_USERNAME: "{{ _uptime_kuma_smtp.secret.username }}"
KUMA_SMTP_PASSWORD: "{{ _uptime_kuma_smtp.secret.token }}"
KUMA_SMTP_HOST: "{{ _uptime_kuma_smtp.server }}"
KUMA_SMTP_PORT: "{{ _uptime_kuma_smtp.port }}"
KUMA_SMTP_USERNAME: "{{ _uptime_kuma_smtp.username }}"
KUMA_SMTP_PASSWORD: "{{ _uptime_kuma_smtp.token }}"
KUMA_SMTP_FROM: "{{ uptime_kuma_smtp_from }}"
KUMA_SMTP_TO: "{{ uptime_kuma_smtp_to }}"
KUMA_METRICS_ROTATE: "{{ 'false' if (_uptime_kuma_metrics_check.status | default(0)) == 200 else 'true' }}"
@@ -179,13 +192,16 @@
no_log: true
- name: Write the metrics API key to OpenBao
community.hashi_vault.vault_kv2_write:
url: "{{ uptime_kuma_openbao_url }}"
engine_mount_point: "{{ uptime_kuma_openbao_mount }}"
path: "{{ uptime_kuma_openbao_metrics_path }}"
auth_method: token
data:
api_key: "{{ _uptime_kuma_new_metrics_key.content | b64decode }}"
ansible.builtin.uri:
url: "{{ uptime_kuma_openbao_url }}/v1/{{ uptime_kuma_openbao_mount }}/data/{{ uptime_kuma_openbao_metrics_path }}"
method: POST
headers:
X-Vault-Token: "{{ uptime_kuma_openbao_token }}"
body_format: json
body:
data:
api_key: "{{ _uptime_kuma_new_metrics_key.content | b64decode }}"
status_code: [200, 204]
delegate_to: localhost
become: false
run_once: true
+40
View File
@@ -0,0 +1,40 @@
---
# The whole defined state of every Ansible-managed host, in dependency order.
# Run from ansible/ on a mesh-connected workstation; the inventory comes from
# ansible.cfg:
#
# ansible-playbook site.yml
#
# One-off actions are not state and stay separate: plays/update-os.yml,
# plays/update-dist-upgrade.yml, plays/rotate-netbird-secrets.yml.
- name: Base system
ansible.builtin.import_playbook: plays/common.yml
- name: VM time sync
ansible.builtin.import_playbook: plays/configure-vm-time-sync.yml
- name: SSH user CA
ansible.builtin.import_playbook: plays/configure-ssh-ca.yml
- name: NetBird host clients
ansible.builtin.import_playbook: plays/install-netbird-client.yml
- name: sshd on the mesh interface only
ansible.builtin.import_playbook: plays/configure-ssh-mesh-only.yml
- name: NetBird server
ansible.builtin.import_playbook: plays/install-netbird-server.yml
- name: Uptime Kuma
ansible.builtin.import_playbook: plays/install-uptime-kuma.yml
- name: NetBird proxies
ansible.builtin.import_playbook: plays/install-netbird-proxy.yml
- name: Edge host base
ansible.builtin.import_playbook: plays/install-edge-host.yml
- name: Z-Wave gateway
ansible.builtin.import_playbook: plays/install-zwave-gateway.yml
- name: Heating CAN
ansible.builtin.import_playbook: plays/install-heating-can.yml
- name: NTP service
ansible.builtin.import_playbook: plays/install-ntp-ha.yml
- name: Close the SSH connections
ansible.builtin.import_playbook: plays/close-connections.yml
+1 -1
View File
@@ -1,4 +1,4 @@
FROM alpine:3.24@sha256:294b683cb724975bec92580e1e685676bd4b50bda910ddb8c51d4cabeaec77e6
FROM alpine:3.24@sha256:be2656061b536c1883c4034790e21d0a09c5aaaf51968b89368a3c0371ea2c9a
# Alpine plus jq. Nothing application-specific — a general-purpose base for any
# workload that needs to read JSON from a shell script.
+1 -1
View File
@@ -1,4 +1,4 @@
FROM alpine:3.24@sha256:294b683cb724975bec92580e1e685676bd4b50bda910ddb8c51d4cabeaec77e6
FROM alpine:3.24@sha256:be2656061b536c1883c4034790e21d0a09c5aaaf51968b89368a3c0371ea2c9a
# Alpine plus the openssl CLI. Nothing application-specific — named for what it
# is, so anything needing `openssl s_client` / `x509` from a shell script can use
+1 -1
View File
@@ -1,4 +1,4 @@
FROM alpine:3.24@sha256:294b683cb724975bec92580e1e685676bd4b50bda910ddb8c51d4cabeaec77e6
FROM alpine:3.24@sha256:be2656061b536c1883c4034790e21d0a09c5aaaf51968b89368a3c0371ea2c9a
RUN apk add --no-cache \
ansible \
+2 -2
View File
@@ -5,12 +5,12 @@
# The unit tests gate the image: the final stage copies the controller out of
# this stage, so a failing test fails the build.
FROM python:3.15-slim@sha256:2da676046630d7bf14c6764ae1143dc7fa0377a53f94bf5a642f0b214aca5bce AS test
FROM python:3.15-slim@sha256:533b0b2f9b5d46fd097091cf826f27902f1d6c3bca714b6dc74551356aa97a51 AS test
WORKDIR /src
COPY controller.py test_controller.py ./
RUN python3 -B -m unittest -v
FROM python:3.15-slim@sha256:2da676046630d7bf14c6764ae1143dc7fa0377a53f94bf5a642f0b214aca5bce
FROM python:3.15-slim@sha256:533b0b2f9b5d46fd097091cf826f27902f1d6c3bca714b6dc74551356aa97a51
RUN useradd --system --no-create-home --shell /usr/sbin/nologin controller
COPY --from=test /src/controller.py /opt/controller.py
+2 -2
View File
@@ -1,6 +1,6 @@
FROM hetznercloud/cli:v1.70.1@sha256:795ac09a5ecabd48cc13a8f1c3dc45983c8f75ae1277c70d621719e07f276ccb AS hcloud
FROM hetznercloud/cli:v1.70.2@sha256:3b3a8f66323a5754089df7939fd9d3f350127072deed13418f3d3052b4bbeeae AS hcloud
FROM alpine:3.24@sha256:294b683cb724975bec92580e1e685676bd4b50bda910ddb8c51d4cabeaec77e6
FROM alpine:3.24@sha256:be2656061b536c1883c4034790e21d0a09c5aaaf51968b89368a3c0371ea2c9a
RUN apk add --no-cache \
bash \
+1 -1
View File
@@ -1,4 +1,4 @@
FROM alpine:3.24@sha256:294b683cb724975bec92580e1e685676bd4b50bda910ddb8c51d4cabeaec77e6
FROM alpine:3.24@sha256:be2656061b536c1883c4034790e21d0a09c5aaaf51968b89368a3c0371ea2c9a
# bird: BGP daemon for the pod-network mesh over pod0. Distro package, no build
# step — the image is the binary plus its runtime.
+1 -1
View File
@@ -1,4 +1,4 @@
FROM node:24-alpine@sha256:44fce696f38ba8c987c4db89954051000bbd05ffd8d8659ba15ed4a8602e937f
FROM node:24-alpine@sha256:06d7adf3e0ef61a13f1d28682726366f97a15aa1888c934d3ecec86c33918b53
# Default to the latest Claude Code. Pinning a fixed version is the wrong
# default here: old releases eventually stop working against the API, so a
+2 -2
View File
@@ -2,7 +2,7 @@
# Cloudflare. The unit tests (no tags, so no envtest) gate the image: the
# final stage copies the binary out of this stage, so a failing test fails the
# build.
FROM golang:1.27@sha256:820153096fda4de6ac1b7194d12e416b7dd8f4e9a33d17e0345b111b945162d8 AS build
FROM golang:1.27@sha256:f20d83a83e18dbd2a4714b8ffd4b30b287b6243962dd75608e86abcc63ba4efc AS build
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
@@ -10,7 +10,7 @@ COPY . .
RUN go test ./...
RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /out/cloudflare-lb-sync ./cmd/cloudflare-lb-sync
FROM gcr.io/distroless/static:nonroot@sha256:e2e927ec666bae08560abb3c55d0659eceabb657f56b6782ab500a9fc7f555e3
FROM gcr.io/distroless/static:nonroot@sha256:18f4cc26c9ffa7f20a359298be7deadac5c607c8e03a02b8119f1a5fc2f82c99
COPY --from=build /out/cloudflare-lb-sync /cloudflare-lb-sync
USER 65532
ENTRYPOINT ["/cloudflare-lb-sync"]

Some files were not shown because too many files have changed in this diff Show More