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