Compare commits

..
Author SHA1 Message Date
claude-botandClaude Opus 5.5 58196acebc fix(authentik): embedded outpost config lives in the blueprint
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
The embedded outpost's config is Authentik's own and does not depend on
authentik-gate-sync. A blueprint update of an existing outpost is partial
(importer: partial=True unless MUST_CREATED), so the entry sets config
without providers and the gates the controller attached stay.

config is replaced whole; the entry mirrors the live row
(authentik_outposts_outpost, 2026-10-23) with refresh_interval
minutes=1. kubernetes_namespace stays default: the outpost has the
Local Kubernetes Cluster connection, and the authentik namespace would
let the worker create an ak-outpost Service for it.

The controller drops embedded_config/ensure_embedded_config and only
attaches providers.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 19:00:21 +02:00
claude-botandClaude Opus 5.5 95f3ff86b9 fix(authentik-gate-sync): refresh the embedded outpost every minute
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
Each authentik-server replica's embedded outpost loses its websocket whenever gunicorn recycles the worker holding it (AUTHENTIK_WEB__MAX_REQUESTS). The reconnect backoff never resets (goauthentik/authentik#26463), so a replica then misses gate changes for up to 5 minutes and catches up only on the refresh interval. A 1-minute interval bounds that gap; worker recycling stays as is.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 10:19:49 +02:00
325 changed files with 4195 additions and 6914 deletions
+12 -9
View File
@@ -243,8 +243,7 @@ steps:
- AUTH=$(printf '%s:%s' "$GITEA_USER" "$GITEA_TOKEN" | base64 | tr -d '\n')
- GH_AUTH=$(printf '%s:%s' "$GHCR_USER" "$GHCR_TOKEN" | base64 | tr -d '\n')
- printf '{"auths":{"gitea.renner.uno":{"auth":"%s"},"ghcr.io":{"auth":"%s"}}}' "$AUTH" "$GH_AUTH" > /kaniko/.docker/config.json
# konairius/ gets one tag per build: Gitea's cleanup rule counts tags.
- TAG=$(date +%Y-%m-%d-%H%M)
- TAG=$(date +%Y-%m-%d)
- |
/kaniko/executor \
--context . \
@@ -252,7 +251,9 @@ steps:
--destination gitea.renner.uno/gitea_admin/alpine-jq:$TAG \
--destination gitea.renner.uno/gitea_admin/alpine-jq:latest \
--destination gitea.renner.uno/konairius/alpine-jq:$TAG \
--destination ghcr.io/konairius/alpine-jq:$TAG
--destination gitea.renner.uno/konairius/alpine-jq:latest \
--destination ghcr.io/konairius/alpine-jq:$TAG \
--destination ghcr.io/konairius/alpine-jq:latest
# Runtime image, not CI tooling: upstream containernetworking/plugins binaries
# baked in, consumed by the multus cni-plugins-installer DaemonSet. Replaces a
@@ -294,8 +295,7 @@ steps:
- AUTH=$(printf '%s:%s' "$GITEA_USER" "$GITEA_TOKEN" | base64 | tr -d '\n')
- GH_AUTH=$(printf '%s:%s' "$GHCR_USER" "$GHCR_TOKEN" | base64 | tr -d '\n')
- printf '{"auths":{"gitea.renner.uno":{"auth":"%s"},"ghcr.io":{"auth":"%s"}}}' "$AUTH" "$GH_AUTH" > /kaniko/.docker/config.json
# konairius/ gets one tag per build: Gitea's cleanup rule counts tags.
- TAG=$(date +%Y-%m-%d-%H%M)
- TAG=$(date +%Y-%m-%d)
- |
/kaniko/executor \
--context . \
@@ -303,7 +303,9 @@ steps:
--destination gitea.renner.uno/gitea_admin/cni-plugins:$TAG \
--destination gitea.renner.uno/gitea_admin/cni-plugins:latest \
--destination gitea.renner.uno/konairius/cni-plugins:$TAG \
--destination ghcr.io/konairius/cni-plugins:$TAG
--destination gitea.renner.uno/konairius/cni-plugins:latest \
--destination ghcr.io/konairius/cni-plugins:$TAG \
--destination ghcr.io/konairius/cni-plugins:latest
# Runtime image, not CI tooling: alpine + the openssl CLI, for shell scripts
# needing s_client/x509. Named for what it is; first consumer is the paperless
@@ -334,8 +336,7 @@ steps:
- AUTH=$(printf '%s:%s' "$GITEA_USER" "$GITEA_TOKEN" | base64 | tr -d '\n')
- GH_AUTH=$(printf '%s:%s' "$GHCR_USER" "$GHCR_TOKEN" | base64 | tr -d '\n')
- printf '{"auths":{"gitea.renner.uno":{"auth":"%s"},"ghcr.io":{"auth":"%s"}}}' "$AUTH" "$GH_AUTH" > /kaniko/.docker/config.json
# konairius/ gets one tag per build: Gitea's cleanup rule counts tags.
- TAG=$(date +%Y-%m-%d-%H%M)
- TAG=$(date +%Y-%m-%d)
- |
/kaniko/executor \
--context . \
@@ -343,7 +344,9 @@ steps:
--destination gitea.renner.uno/gitea_admin/alpine-openssl:$TAG \
--destination gitea.renner.uno/gitea_admin/alpine-openssl:latest \
--destination gitea.renner.uno/konairius/alpine-openssl:$TAG \
--destination ghcr.io/konairius/alpine-openssl:$TAG
--destination gitea.renner.uno/konairius/alpine-openssl:latest \
--destination ghcr.io/konairius/alpine-openssl:$TAG \
--destination ghcr.io/konairius/alpine-openssl:latest
# Heating CAN readers: open3e (Vitocal/VX3) + E3onCAN (E380 meter), one image
# run as two services on edge-1. Replaces pip-into-venv on the host, where an
+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.1@sha256:fa3302279710be2f21a8301ec17a8b4f2301fd829bd113839b3b6b02b6f191a2
image: prom/prometheus:v3.15.0@sha256:efd719c99d83b060d9daefdcf00360461adf279f45ef5391f8d111892118753e
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.311
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.306
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.311
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.306
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.311
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.306
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.311
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.306
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.15-alpine@sha256:d44b1254cb92329418d164b22b20d86bbd3869d48e80e09e476531a409a728bb
image: python:3.14-alpine@sha256:f6a589d43c42b9e7f7dc67a12d37132491f362859a5d750607710cc56da3bc72
environment:
PYTHONDONTWRITEBYTECODE: "1"
commands:
+33
View File
@@ -0,0 +1,33 @@
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
@@ -0,0 +1,36 @@
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
+8 -1
View File
@@ -13,7 +13,7 @@ when:
steps:
- name: validate-secrets
image: alpine:3.24.3@sha256:be2656061b536c1883c4034790e21d0a09c5aaaf51968b89368a3c0371ea2c9a
image: alpine:3.24.2@sha256:294b683cb724975bec92580e1e685676bd4b50bda910ddb8c51d4cabeaec77e6
environment:
TF_VAR_proxmox_api_url:
from_secret: proxmox_api_url
@@ -109,6 +109,8 @@ 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:
@@ -119,6 +121,11 @@ 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
@@ -0,0 +1,35 @@
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
@@ -0,0 +1,54 @@
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
@@ -0,0 +1,37 @@
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
@@ -0,0 +1,47 @@
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.311
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.306
commands:
- sh scripts/ci/test-model-selection.sh
+4 -3
View File
@@ -1,5 +1,5 @@
# Builds ci/images/hermes (nousresearch/hermes-agent plus the agent's CLIs) as
# gitea.renner.uno/konairius/hermes:YYYY-MM-DD-HHMM, one tag per build.
# gitea.renner.uno/konairius/hermes:build.<unix-ts> and :latest.
when:
- event: push
branch: main
@@ -20,9 +20,10 @@ steps:
- mkdir -p /kaniko/.docker
- AUTH=$(printf '%s:%s' registry-bot "$GITEA_TOKEN" | base64 | tr -d '\n')
- printf '{"auths":{"gitea.renner.uno":{"auth":"%s"}}}' "$AUTH" > /kaniko/.docker/config.json
- TAG=$(date +%Y-%m-%d-%H%M)
- TAG="build.$(date +%s)"
- |
/kaniko/executor \
--context . \
--dockerfile Containerfile \
--destination gitea.renner.uno/konairius/hermes:$TAG
--destination gitea.renner.uno/konairius/hermes:$TAG \
--destination gitea.renner.uno/konairius/hermes:latest
+1 -1
View File
@@ -6,7 +6,7 @@ when:
steps:
- name: go-test
image: golang:1.27.3-bookworm@sha256:972b4110ba0f47539a975c31e52c64765e1ff19f2d74469ae4a595c6676e80a5
image: golang:1.27.2-bookworm@sha256:5cf287a799e6b94384bad13d16b14904c531f51ba65792237e122ce42b392f61
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:790fd66d1697395d8693d1acaa1ea702406a20f109b6fc7bac8db623eca6c06c
image: aquasec/trivy:latest@sha256:2416d1f74045b57274df1a340eee6b510cacd73c66a6ccb8da092b117eb7d222
commands:
- trivy fs --format json -o trivy-iac.json --severity CRITICAL,HIGH --ignore-unfixed .
- name: image-scans
image: aquasec/trivy:latest@sha256:790fd66d1697395d8693d1acaa1ea702406a20f109b6fc7bac8db623eca6c06c
image: aquasec/trivy:latest@sha256:2416d1f74045b57274df1a340eee6b510cacd73c66a6ccb8da092b117eb7d222
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.311
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.306
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.311
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.306
environment:
CLAUDE_CODE_OAUTH_TOKEN:
from_secret: claude_code_oauth_token
+28
View File
@@ -0,0 +1,28 @@
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
+5 -10
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=smart
;gathering=implicit
# (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 @@ gathering=smart
;hash_behaviour=replace
# (pathlist) Comma separated list of Ansible inventory sources
inventory=inventory
inventory=~/source/homelab/ansible/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=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=
private_key_file=~/.ssh/id_ed25519
# (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=vault-password.sh
vault_password_file=~/source/homelab/ansible/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=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=True
;pipelining=False
[colors]
@@ -694,8 +694,3 @@ pipelining=True
# (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
+21 -17
View File
@@ -41,11 +41,13 @@ 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, in `ansible/`:
5. From the workstation:
```
ansible-playbook site.yml
ansible-playbook -i inventory/pve_hosts.yml plays/install-edge-host.yml
```
One unrestricted run, no `--limit`. It converges every host, which is
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
the point: a run that touches only the new box tells you nothing about
the others.
@@ -70,14 +72,14 @@ none of these commands.
## Secrets (before first play run)
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:
Create `inventory/group_vars/edge_host/vault.yml` with ansible-vault; the
current live values come straight 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
```
@@ -228,14 +230,15 @@ 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.
The local broker is `allow_anonymous` on loopback — the trust boundary is the
host; nothing off-host can reach it.
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.
**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
**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
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
@@ -243,7 +246,8 @@ its own server key, nothing else. On a push it writes
`/etc/mosquitto/bridge.d/edge-1.conf`, deletes `bridge.d/legacy.conf` and
restarts the local mosquitto. The controller sets a new password whenever the
bridge reports "not connected" (also after a reinstall), so no play run is
needed for a rotation. Diagnosis: `journalctl -u
needed for a rotation. `.woodpecker/deploy-edge-mqtt-bridge.yml` runs
`plays/edge-mqtt-bridge.yml` on merge. Diagnosis: `journalctl -u
edge-mqtt-cred`, and `mosquitto_sub -t '$SYS/broker/connection/edge-1/state'`
(retained `1` = connected).
+67
View File
@@ -0,0 +1,67 @@
#!/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()
+24 -7
View File
@@ -5,10 +5,27 @@ pve_token_id: "ansible"
ansible_user: "ladmin"
ansible_python_interpreter: /usr/bin/python3
# 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"
# 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'
@@ -0,0 +1,16 @@
$ANSIBLE_VAULT;1.1;AES256
35646538363164363061366433343963623166336630376233333033646637336266343337343230
3233356135323762636439336466663430366238363338650a633762623366643137303333383634
62623739363132303335636261626263333332623066303161633466306431623930666166323833
6439663964353338350a656435626664646666363831363164393335386666356637613037323838
63653461656366613331353064656161346263356131346437666632386166623037373363333534
37323435623366643534633333303534313434613135653237303966656162633266623433363934
36343133393834393765353338383764303664363431653865633764306138376366623832346262
36303766623331666632613639366139333661636665363631326565653938343637323861653064
37393465383263376337376639613162306563653365623036376662303030613264653534353332
66363030356363373439306164386561363734353631363738656236633164633062613437663366
62386430396262366336316166353366383233316333333234666538643962336361363630363762
31653963303032373531386566336632336130383034343430303964396538663237346434613737
39643765623766373639306132316635376434643232336163303066393736313130323065666232
39393038616162666666383837326137376233613437636361636238663566336365653863643933
353338646134383038323134323034653564
@@ -50,11 +50,3 @@ 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
@@ -1,9 +0,0 @@
---
# 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
@@ -1,8 +0,0 @@
---
# DSM's own python3 is 3.8; plays/configure-synology-python.yml installs this.
ansible_python_interpreter: /var/packages/Python3.9/target/usr/bin/python3.9
# DSM offers no sftp/scp.
ansible_ssh_transfer_method: piped
# claude-bot is a DSM administrator here; its password is unknown.
ssh_ca_claude_sudo_all: true
@@ -2,6 +2,3 @@
# NetBird server - VPS not managed by Proxmox
# Enable Docker monitoring (cAdvisor) for container metrics
docker_monitoring_enabled: true
ssh_ca_claude_sudo_units:
- netbird
+12 -35
View File
@@ -8,14 +8,16 @@
# pve_cluster (jupiter/neptune/saturn) run Talos on bare metal; no SSH at all
# authentik_db / authentik_app authentik runs in-cluster (CNPG)
# grafana runs in-cluster
# synology_nas (nas-1) powered off; Garage and the PBS client
# it hosted are gone with it
# Their hosts no longer resolve, except the three that resolve to Talos nodes.
[netbird_server]
netbird.renner.uno ansible_host=netbird.nb.renner.uno ansible_user=root
netbird.renner.uno ansible_user=root
[netbird_proxy]
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
proxy-1.renner.uno ansible_user=root
proxy-2.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
@@ -23,11 +25,6 @@ proxy-2.renner.uno ansible_host=proxy-2.nb.renner.uno ansible_user=root
[edge_host]
edge-1.iot.renner.uno ansible_user=root
# Synology NAS (DSM): nas-1 RS1221+ on site, nas-2 DS1522+ off site.
[synology_nas]
nas-1.renner.uno ansible_user=root
nas-2.renner.uno ansible_user=root
# Capability groups. Membership states which hardware a host physically has,
# and each service playbook targets the group for its own concern. No flags and
# no `when:` on the roles -- moving a stick to another box is a one-line change
@@ -65,30 +62,10 @@ heating_can
[ntp_cluster:children]
edge_host
# 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
nas-1.renner.uno
nas-2.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
# 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
-11
View File
@@ -1,11 +0,0 @@
---
# 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
+1 -2
View File
@@ -13,5 +13,4 @@
roles:
- common
- role: qemu-guest-agent
when: ansible_facts['virtualization_role'] == 'guest'
- qemu-guest-agent
+125
View File
@@ -0,0 +1,125 @@
---
# 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
@@ -1,23 +0,0 @@
---
# 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
@@ -0,0 +1,48 @@
---
- 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
@@ -1,15 +0,0 @@
---
# 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
@@ -1,19 +0,0 @@
---
# Ansible needs Python >= 3.9 on the target; DSM ships 3.8. Synology's
# Python3.9 package installs beside it. raw, because the modules cannot run
# before it is there; raw is skipped in check mode.
- name: Install Python 3.9 on the Synology NAS
hosts: synology_nas
become: true
gather_facts: false
tasks:
- name: Install the Python3.9 package
ansible.builtin.raw: >-
if [ -x {{ ansible_python_interpreter }} ]; then echo present;
else /usr/syno/bin/synopkg install_from_server Python3.9; fi
register: synology_python_install
changed_when: "'present' not in synology_python_install.stdout"
- name: Check the interpreter exists
ansible.builtin.raw: test -x {{ ansible_python_interpreter }}
changed_when: false
-2
View File
@@ -29,7 +29,6 @@
- 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
@@ -57,7 +56,6 @@
- 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
@@ -0,0 +1,33 @@
---
# 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
@@ -0,0 +1,20 @@
---
# 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
+11 -10
View File
@@ -6,25 +6,26 @@
# - host_vars/<hostname>/vars.yml - per-host key (overrides group)
# - group_vars/all/vault.yml - encrypted keys
#
# Applied by site.yml.
# 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
- name: Install NetBird client on hosts with setup key defined
hosts: all
become: 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
gather_facts: true
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).
# Applied by site.yml.
# Usage: ansible-playbook -i inventory/ plays/install-netbird-proxy.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
# Applied by site.yml.
# Usage: ansible-playbook -i inventory/ plays/install-netbird-server.yml
#
# Prerequisites:
# - VPS provisioned with Ubuntu 22.04/24.04
+22
View File
@@ -0,0 +1,22 @@
---
# 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
+20 -44
View File
@@ -102,45 +102,32 @@
ansible.builtin.pause:
seconds: 5
- name: Verify chronyd answers
ansible.builtin.command:
argv:
- chronyc
- -n
- tracking
check_mode: false
- name: Verify chrony is serving NTP
ansible.builtin.shell: chronyc clients | head -10
register: chrony_clients
changed_when: false
- name: Verify chronyd listens on udp/123
ansible.builtin.command:
argv:
- ss
- -Hlunp
- sport = :123
- name: Check NTP port is listening
ansible.builtin.shell: ss -lun | grep ':123 '
register: ntp_port
check_mode: false
failed_when: false
changed_when: false
failed_when: "'chronyd' not in ntp_port.stdout"
- name: Display NTP port status
ansible.builtin.debug:
msg: "NTP port 123: {{ 'LISTENING' if ntp_port.rc == 0 else 'NOT LISTENING' }}"
- name: Check if this server has the VIP
ansible.builtin.command:
argv:
- ip
- -4
- addr
- show
- to
- "{{ ntp_vip }}"
ansible.builtin.shell: ip addr show | grep {{ ntp_vip }}
register: vip_check
check_mode: false
failed_when: vip_check.rc != 0
failed_when: false
changed_when: false
when: ntp_keepalived_enabled | default(false)
- name: Display VIP status
ansible.builtin.debug:
msg: >
{% if vip_check.stdout | default('') | length > 0 %}
{% if vip_check.rc == 0 %}
This server ({{ inventory_hostname }}) is MASTER and holds the VIP {{ ntp_vip }}
{% else %}
This server ({{ inventory_hostname }}) is BACKUP
@@ -172,8 +159,7 @@
- to
- "{{ ntp_vip6 }}"
register: vip6_check
check_mode: false
failed_when: vip6_check.rc != 0
failed_when: false
changed_when: false
when: ntp_keepalived_enabled | default(false)
@@ -198,28 +184,18 @@
(keepalived not enabled on this host).
when: not (ntp_keepalived_enabled | default(false))
# 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"
- 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)"
register: ntp_vip_test
check_mode: false
when:
- ntp_keepalived_enabled | default(false)
- vip_check.stdout | default('') | length > 0
- vip_check.rc == 0
changed_when: false
- name: Display VIP NTP test result
ansible.builtin.debug:
msg: "{{ ntp_vip_test.stderr_lines + ntp_vip_test.stdout_lines }}"
msg: "{{ ntp_vip_test.stdout_lines | default(['Not master, skipping VIP test']) }}"
when:
- ntp_keepalived_enabled | default(false)
- vip_check.stdout | default('') | length > 0
- vip_check.rc == 0
+2 -3
View File
@@ -1,14 +1,13 @@
---
# Deploy Uptime Kuma to the NetBird VPS
# Applied by site.yml.
# Usage: ansible-playbook -i inventory/ plays/install-uptime-kuma.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
# - an OpenBao token with policy uptime-kuma-vps: `bao login -method=oidc`
# (role owner) or VAULT_TOKEN
# - VAULT_TOKEN in the environment (policy: uptime-kuma-vps)
# - DNS for kuma.renner.uno pointing at the VPS
- name: Deploy Uptime Kuma
-3
View File
@@ -248,6 +248,3 @@
- "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
+1 -3
View File
@@ -43,6 +43,7 @@
- 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:
@@ -110,6 +111,3 @@
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
+1 -4
View File
@@ -1,7 +1,7 @@
---
# Package upgrade + reboot for netbird_proxy, edge_host and netbird_server,
# one host at a time.
# Usage (from ansible/): ansible-playbook plays/update-os.yml
# Usage: ansible-playbook -i inventory/ plays/update-os.yml
#
# Plays run in order; a failed host stops the whole run (max_fail_percentage: 0).
@@ -28,6 +28,3 @@
become: true
roles:
- os-update
- name: Close the SSH connections
ansible.builtin.import_playbook: close-connections.yml
@@ -18,7 +18,6 @@
- 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
@@ -0,0 +1,57 @@
# 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
@@ -0,0 +1,221 @@
# 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`).
@@ -0,0 +1,68 @@
---
# 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)
@@ -0,0 +1,22 @@
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.
@@ -0,0 +1,8 @@
# 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
@@ -0,0 +1,10 @@
---
- 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
@@ -0,0 +1,42 @@
---
# 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
@@ -0,0 +1,183 @@
---
# 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
@@ -0,0 +1,48 @@
---
# 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
@@ -0,0 +1,10 @@
[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
@@ -0,0 +1,27 @@
#!/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"
@@ -0,0 +1,10 @@
[Unit]
# {{ ansible_managed }}
Description=Periodically refresh the claude-workstation egress allowlist
[Timer]
OnBootSec=30s
OnUnitActiveSec=15min
[Install]
WantedBy=timers.target
@@ -0,0 +1,32 @@
[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
@@ -0,0 +1,32 @@
#!/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
@@ -0,0 +1,11 @@
[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
@@ -0,0 +1,55 @@
#!/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"
}
}
+10 -7
View File
@@ -8,11 +8,12 @@
lineinfile:
path: /etc/hosts
regexp: '^127\.0\.1\.1\s+{{ inventory_hostname }}'
line: "{{ ansible_facts['default_ipv4']['address'] }} {{ inventory_hostname }} {{ inventory_hostname_short | default(inventory_hostname.split('.')[0]) }}"
line: "{{ ansible_default_ipv4.address }} {{ inventory_hostname }} {{ inventory_hostname_short | default(inventory_hostname.split('.')[0]) }}"
state: present
when:
- ansible_facts['virtualization_type'] == 'lxc'
- ansible_facts['default_ipv4']['address'] is defined
- ansible_virtualization_type is defined
- ansible_virtualization_type == 'lxc'
- ansible_default_ipv4.address is defined
# -------------------------------------------------------
# 1. Install Common Packages
@@ -50,8 +51,10 @@
# -------------------------------------------------------
# 4. SSH Configuration
# -------------------------------------------------------
# SSH access (CA trust, admin authorized_keys) is managed by the ssh-ca role
# via plays/configure-ssh-ca.yml.
# 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).
# -------------------------------------------------------
# 5. Disable NTP on VMs/LXC (use host time)
@@ -63,8 +66,8 @@
enabled: no
masked: yes
when:
- ansible_facts['virtualization_role'] == 'guest'
- ansible_facts['virtualization_type'] in ['kvm', 'lxc']
- ansible_virtualization_type is defined
- ansible_virtualization_type in ['kvm', 'lxc']
ignore_errors: yes
# -------------------------------------------------------
@@ -15,8 +15,6 @@
# amd64 asset for exactly this tag.
crowdsec_bouncer_version: v0.0.36
crowdsec_bouncer_sha256: f86e4b72693549d99f40a9402abefb894108f047a3fbe6e72fada25ee17ce88b
# Controller cache for the release archive; it outlives /tmp cleanups.
crowdsec_bouncer_download_dir: "{{ lookup('ansible.builtin.env', 'HOME') }}/.cache/ansible/crowdsec"
# The LAPI over the public path these hosts already serve, see
# kubernetes/infrastructure/crowdsec/lapi-ingress.yaml. The proxies cannot
@@ -29,26 +29,14 @@
# upstream ships no container image for this bouncer, which is expected —
# it manipulates host netfilter.
#
# 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: Create the controller download directory
ansible.builtin.file:
path: "{{ crowdsec_bouncer_download_dir }}"
state: directory
mode: "0755"
delegate_to: localhost
become: false
run_once: true
check_mode: false
# The CI runner has v4, so it does the fetching. The checksum is verified
# there, before anything is copied to a host.
- 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/{{
crowdsec_bouncer_version }}/crowdsec-firewall-bouncer-linux-amd64.tgz
dest: "{{ crowdsec_bouncer_download_dir }}/crowdsec-firewall-bouncer-{{ crowdsec_bouncer_version }}.tgz"
dest: /tmp/crowdsec-firewall-bouncer-{{ crowdsec_bouncer_version }}.tgz
checksum: sha256:{{ crowdsec_bouncer_sha256 }}
mode: "0644"
delegate_to: localhost
@@ -63,7 +51,7 @@
# host.
- name: Copy the bouncer archive to the host
ansible.builtin.copy:
src: "{{ crowdsec_bouncer_download_dir }}/crowdsec-firewall-bouncer-{{ crowdsec_bouncer_version }}.tgz"
src: /tmp/crowdsec-firewall-bouncer-{{ crowdsec_bouncer_version }}.tgz
dest: /opt/crowdsec-firewall-bouncer-{{ crowdsec_bouncer_version }}.tgz
mode: "0644"
-1
View File
@@ -87,7 +87,6 @@
ansible.builtin.command:
argv: [pgrep, -af, "edge-host/(open3e|e3oncan)"]
register: heating_can_survivors
check_mode: false
changed_when: false
failed_when: false
+3 -4
View File
@@ -65,7 +65,6 @@
- /etc/network/interfaces
- /etc/network/interfaces.d/
register: keepalived_ntp_static6_ifupdown
check_mode: false
changed_when: false
failed_when: false
when:
@@ -124,7 +123,6 @@
- "{{ keepalived_ntp_vip6.split('/')[0] }}\\b"
- /etc/systemd/network/
register: keepalived_ntp_static6_networkd
check_mode: false
changed_when: false
failed_when: false
when:
@@ -142,8 +140,9 @@
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 (site.yml runs it before this play),
whose template no longer carries the [Address] block.
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
success_msg: >-
No static networkd assignment of {{ keepalived_ntp_vip6 }}; safe for
keepalived to own it.
@@ -2,7 +2,7 @@
# Managed by Ansible - Do not edit manually
global_defs {
router_id {{ ansible_facts['hostname'] }}_ntp
router_id {{ ansible_hostname }}_ntp
enable_script_security
script_user root
}
@@ -6,13 +6,3 @@ 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
+36 -99
View File
@@ -2,90 +2,47 @@
# NetBird Client Installation Role
# Installs and configures NetBird client on hosts with netbird_setup_key defined
- 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
# deb822_repository
- python3-debian
state: present
update_cache: true
- 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.deb822_repository:
name: netbird
types: deb
uris: https://pkgs.netbird.io/debian
suites: stable
components: main
signed_by: /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
# A one-line netbird.list beside netbird.sources lists the repository twice.
- name: Remove the one-line NetBird repository
ansible.builtin.file:
path: /etc/apt/sources.list.d/netbird.list
state: absent
- 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: 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: 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: Enable NetBird service
ansible.builtin.service:
@@ -93,29 +50,9 @@
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
+12 -3
View File
@@ -136,13 +136,23 @@
- /opt/netbird/proxy
- /opt/netbird/proxy-state
# The management store has no cluster-side backup; the cluster's backup
# key is gone with the managed authorized_keys (ssh-ca role).
# 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.
- 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
@@ -157,7 +167,6 @@
- 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,10 +61,6 @@ 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
@@ -0,0 +1,20 @@
---
# 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: []
@@ -0,0 +1,62 @@
#!/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"
@@ -0,0 +1,9 @@
---
- name: Reload systemd
systemd:
daemon_reload: yes
- name: Restart node-exporter
systemd:
name: node-exporter
state: restarted
@@ -0,0 +1,88 @@
---
# 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"
@@ -0,0 +1,38 @@
[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
@@ -6,15 +6,15 @@
apt:
name: qemu-guest-agent
state: present
when:
- ansible_facts['os_family'] == 'Debian'
when:
- ansible_os_family is defined and ansible_os_family == 'Debian'
- name: Install QEMU Guest Agent (RHEL/Rocky VMs)
dnf:
name: qemu-guest-agent
state: present
when:
- ansible_facts['os_family'] == 'RedHat'
when:
- ansible_os_family is defined and ansible_os_family == 'RedHat'
- name: Ensure qemu-guest-agent is started and enabled
service:
-21
View File
@@ -1,21 +0,0 @@
---
# 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: []
# Full passwordless sudo for claude-bot on this host.
ssh_ca_claude_sudo_all: false
-2
View File
@@ -1,2 +0,0 @@
# Public keys whose certificates sshd must reject (one per line, OpenSSH format).
# Revoking a key also revokes every certificate issued for it.
@@ -1,15 +0,0 @@
---
# Replaces the admin logins' authorized_keys with ssh_admin_authorized_keys
# (break-glass + CI). Everything else logs in with a CA certificate.
# ssh_ca_admin_logins comes from main.yml, which runs first in the same play.
- name: Set the admin logins' authorized_keys to the managed list
ansible.posix.authorized_key:
user: "{{ item[0] }}"
key: |
{% for k in ssh_admin_authorized_keys %}
{{ (k.options ~ ' ') if (k.options | default('')) else '' }}{{ k.key }}
{% endfor %}
exclusive: true
loop: "{{ ssh_ca_admin_logins }}"
loop_control:
label: "{{ item[0] }}"
-276
View File
@@ -1,276 +0,0 @@
---
- 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"
register: ssh_ca_trusted_file
# 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"
# /etc/passwd, not getent: DSM has no getent binary. The admin logins are
# local accounts. Each entry is the split passwd line (0 name, 5 home).
- name: Read the local accounts
ansible.builtin.slurp:
src: /etc/passwd
register: ssh_ca_passwd_file
- name: Find the existing admin logins
vars:
ssh_ca_passwd: "{{ ssh_ca_passwd_file.content | b64decode | trim | split('\n') | map('split', ':') | list }}"
ansible.builtin.set_fact:
ssh_ca_admin_logins: "{{ ssh_ca_passwd | selectattr('0', 'in', ssh_ca_owner_users) | list }}"
ssh_ca_claude_exists: "{{ ssh_ca_claude_user in ssh_ca_passwd | map('first') }}"
# DSM manages accounts with synouser/synogroup and has no useradd.
- name: Detect DSM
ansible.builtin.stat:
path: /usr/syno/sbin/synouser
register: ssh_ca_synouser
# 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
when: not ssh_ca_synouser.stat.exists
- name: Create the Claude account on DSM
when: ssh_ca_synouser.stat.exists
block:
# synouser requires a password; nobody knows it and sshd refuses passwords.
- name: Create the DSM account
ansible.builtin.command:
argv:
- /usr/syno/sbin/synouser
- --add
- "{{ ssh_ca_claude_user }}"
- "{{ lookup('ansible.builtin.password', '/dev/null', length=40, chars=['ascii_letters', 'digits']) }}"
- Claude Bot
- "0"
- ""
- "0"
# command runs in check mode here.
when:
- not ssh_ca_claude_exists
- not ansible_check_mode
no_log: true
# Members are listed as "<n>:[<name>]".
- name: Read the administrators members
ansible.builtin.command: /usr/syno/sbin/synogroup --get administrators
register: ssh_ca_dsm_admins_out
changed_when: false
check_mode: false
- name: Find the administrators members
ansible.builtin.set_fact:
ssh_ca_dsm_admins: >-
{{ ssh_ca_dsm_admins_out.stdout_lines | select('match', '^[0-9]+:\[.*\]$')
| map('regex_replace', '^[0-9]+:\[(.*)\]$', '\1') | list }}
# synogroup --member replaces the member list; a parse that lost the owner
# admin login would drop it.
- name: Check the member list holds the owner admin login
ansible.builtin.assert:
that:
- ssh_ca_dsm_admins | intersect(ssh_ca_owner_users) | length > 0
fail_msg: "administrators members lack {{ ssh_ca_owner_users }}: {{ ssh_ca_dsm_admins }}"
# DSM allows SSH for administrators only.
- name: Add the Claude account to administrators
ansible.builtin.command:
argv: "{{ ['/usr/syno/sbin/synogroup', '--member', 'administrators'] + ssh_ca_dsm_admins + [ssh_ca_claude_user] }}"
when:
- ssh_ca_claude_user not in ssh_ca_dsm_admins
- not ansible_check_mode
- 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[0] }}"
owner: root
group: root
mode: "0644"
loop: "{{ ssh_ca_admin_logins }}"
loop_control:
label: "{{ item[0] }}"
- 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
when: not ssh_ca_synouser.stat.exists
# DSM has no visudo, so the rules are checked on the controller.
- name: Install the Claude sudo rules on DSM
when: ssh_ca_synouser.stat.exists
vars:
ssh_ca_sudoers: "{{ lookup('ansible.builtin.template', 'sudoers-claude.j2') | trim }}"
block:
- name: Check the sudo rules on the controller
ansible.builtin.command:
cmd: visudo -c -f -
stdin: "{{ ssh_ca_sudoers }}"
delegate_to: localhost
become: false
changed_when: false
check_mode: false
- name: Install the checked sudo rules
ansible.builtin.copy:
content: "{{ ssh_ca_sudoers }}\n"
dest: "/etc/sudoers.d/{{ ssh_ca_claude_user }}"
owner: root
group: root
mode: "0440"
- name: Create the sshd drop-in directory
ansible.builtin.file:
path: /etc/ssh/sshd_config.d
state: directory
owner: root
group: root
mode: "0755"
# DSM's sshd_config has no Include. sshd keeps the first value it reads, so
# the Include goes first for the drop-ins to win.
- name: Include the sshd drop-in directory
ansible.builtin.lineinfile:
path: /etc/ssh/sshd_config
line: Include /etc/ssh/sshd_config.d/*.conf
insertbefore: BOF
validate: sshd -t -f %s
register: ssh_ca_include_line
# 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"
register: ssh_ca_dropin_file
- 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. The running daemon is
# reloaded when a file it reads at start changed; 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.
# Principals and revoked keys are read per login and need no reload.
# 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
- ssh_ca_trusted_file is changed or ssh_ca_include_line is changed or ssh_ca_dropin_file is changed
- name: Report the admin logins' authorized keys
ansible.builtin.include_tasks: report.yml
-26
View File
@@ -1,26 +0,0 @@
---
# 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[5] }}/.ssh/authorized_keys"
loop: "{{ ssh_ca_admin_logins }}"
loop_control:
label: "{{ item[0] }}"
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 }}"
@@ -1,6 +0,0 @@
# {{ ansible_managed }}
TrustedUserCAKeys /etc/ssh/trusted_user_ca_keys
AuthorizedPrincipalsFile /etc/ssh/auth_principals/%u
RevokedKeys /etc/ssh/revoked_keys
PasswordAuthentication no
KbdInteractiveAuthentication no
@@ -1,15 +0,0 @@
# {{ 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 %}
{% if ssh_ca_claude_sudo_all %}
{{ ssh_ca_claude_user }} ALL=(ALL) NOPASSWD: ALL
{% endif %}
@@ -1,9 +0,0 @@
---
# 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
@@ -1,114 +0,0 @@
---
- 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
@@ -1,27 +0,0 @@
---
# 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
@@ -1,9 +0,0 @@
#!/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 }}
@@ -1,11 +0,0 @@
#!/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
@@ -1,7 +0,0 @@
# {{ 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 }}
@@ -1,8 +0,0 @@
# {{ 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
@@ -1,12 +0,0 @@
# {{ 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
@@ -1,3 +0,0 @@
# {{ 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 }}"
@@ -1,10 +0,0 @@
---
# 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: ""
@@ -1,10 +0,0 @@
---
# 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
+5 -5
View File
@@ -5,12 +5,12 @@ uptime_kuma_base_url: "https://{{ uptime_kuma_host }}"
uptime_kuma_dir: /opt/uptime-kuma
uptime_kuma_traefik_network: netbird_netbird
uptime_kuma_smtp_from: uptime@renner.uno
# Alertmanager email receiver in kubernetes/apps/monitoring/helmrelease-prometheus.yaml.
uptime_kuma_smtp_to: admin@renner.uno
uptime_kuma_openbao_url: https://openbao.renner.uno
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)) }}
+42 -3
View File
@@ -1,4 +1,4 @@
// Bootstraps Uptime Kuma: admin user, general settings,
// Bootstraps Uptime Kuma: admin user, general settings, SMTP notification,
// and (when KUMA_METRICS_ROTATE=true) a fresh Prometheus API key written to KUMA_METRICS_KEY_FILE.
// All input comes from environment variables; secrets are never printed.
// Output lines: "admin user created" / "already set up", then "changed: <step>" or "ok: <step>".
@@ -20,7 +20,17 @@ const SETTINGS_DEFAULTS = {
trustProxy: false,
};
const REQUIRED = ["KUMA_SETUP_USERNAME", "KUMA_SETUP_PASSWORD", "KUMA_BASE_URL"];
const REQUIRED = [
"KUMA_SETUP_USERNAME",
"KUMA_SETUP_PASSWORD",
"KUMA_BASE_URL",
"KUMA_SMTP_HOST",
"KUMA_SMTP_PORT",
"KUMA_SMTP_USERNAME",
"KUMA_SMTP_PASSWORD",
"KUMA_SMTP_FROM",
"KUMA_SMTP_TO",
];
const missing = REQUIRED.filter((name) => !process.env[name]);
const rotateMetricsKey = process.env.KUMA_METRICS_ROTATE === "true";
if (rotateMetricsKey && !process.env.KUMA_METRICS_KEY_FILE) {
@@ -34,7 +44,7 @@ const env = process.env;
const socket = io(KUMA_URL, { transports: ["websocket"], reconnection: false });
const lists = {};
const LIST_EVENTS = ["apiKeyList"];
const LIST_EVENTS = ["notificationList", "apiKeyList"];
for (const event of LIST_EVENTS) {
socket.on(event, (data) => {
lists[event] = data;
@@ -113,6 +123,34 @@ async function ensureSettings() {
report("settings", true);
}
async function ensureNotification() {
const port = Number(env.KUMA_SMTP_PORT);
const desired = {
name: "email",
type: "smtp",
isDefault: true,
smtpHost: env.KUMA_SMTP_HOST,
smtpPort: port,
// 465 is implicit TLS; any other port uses STARTTLS.
smtpSecure: port === 465,
smtpIgnoreSTARTTLS: false,
smtpUsername: env.KUMA_SMTP_USERNAME,
smtpPassword: env.KUMA_SMTP_PASSWORD,
smtpFrom: env.KUMA_SMTP_FROM,
smtpTo: env.KUMA_SMTP_TO,
};
const existing = (lists.notificationList || []).filter((n) => n.name === desired.name).sort((a, b) => a.id - b.id)[0];
const current = existing ? JSON.parse(existing.config || "{}") : {};
const upToDate =
existing && existing.isDefault === true && Object.keys(desired).every((key) => current[key] === desired[key]);
if (upToDate) {
report("notification", false);
return;
}
await callOk("addNotification", { ...current, ...desired, applyExisting: true }, existing ? existing.id : null);
report("notification", true);
}
async function rotateMetricsApiKey() {
if (!rotateMetricsKey) {
report("metrics api key", false);
@@ -131,6 +169,7 @@ async function main() {
await setupAdmin();
await login();
await ensureSettings();
await ensureNotification();
await rotateMetricsApiKey();
}
+56 -41
View File
@@ -2,45 +2,43 @@
# 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
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 }}"
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
delegate_to: localhost
become: false
run_once: true
check_mode: false
register: _uptime_kuma_secret_read
register: _uptime_kuma_secret
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
delegate_to: localhost
become: false
run_once: true
register: _uptime_kuma_smtp
no_log: true
# 404 until the role has stored a key for the first time.
- name: Read metrics API key from OpenBao
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]
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
delegate_to: localhost
become: false
run_once: true
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_metrics_key: "{{ _uptime_kuma_metrics_read.json.data.data.api_key if _uptime_kuma_metrics_read.status == 200 else '' }}"
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(''))"
no_log: true
- name: Ensure Uptime Kuma directories exist
@@ -104,6 +102,8 @@
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
@@ -127,6 +127,18 @@
- -e
- KUMA_BASE_URL
- -e
- KUMA_SMTP_HOST
- -e
- KUMA_SMTP_PORT
- -e
- KUMA_SMTP_USERNAME
- -e
- KUMA_SMTP_PASSWORD
- -e
- KUMA_SMTP_FROM
- -e
- KUMA_SMTP_TO
- -e
- KUMA_METRICS_ROTATE
- -e
- KUMA_METRICS_KEY_FILE
@@ -136,9 +148,15 @@
args:
chdir: "{{ uptime_kuma_dir }}"
environment:
KUMA_SETUP_USERNAME: "{{ _uptime_kuma_secret.username }}"
KUMA_SETUP_PASSWORD: "{{ _uptime_kuma_secret.password }}"
KUMA_SETUP_USERNAME: "{{ _uptime_kuma_secret.secret.username }}"
KUMA_SETUP_PASSWORD: "{{ _uptime_kuma_secret.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_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' }}"
KUMA_METRICS_KEY_FILE: /app/data/.metrics-api-key
register: _uptime_kuma_setup
@@ -161,16 +179,13 @@
no_log: true
- name: Write the metrics API key to OpenBao
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]
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 }}"
delegate_to: localhost
become: false
run_once: true
-42
View File
@@ -1,42 +0,0 @@
---
# 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: Synology Python
ansible.builtin.import_playbook: plays/configure-synology-python.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

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