Compare commits
2 Commits
fef9e7f1ae
...
175c0bc2d1
| Author | SHA1 | Date | |
|---|---|---|---|
| 175c0bc2d1 | |||
| 14275a5627 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -11,3 +11,4 @@ data/
|
||||
artifacts/
|
||||
.claude/
|
||||
*-credentials.txt
|
||||
*.bak-*
|
||||
|
||||
15
README.md
15
README.md
@@ -66,9 +66,18 @@ main openbao ──seal:transit──▶ openbao-unsealer (transit/autounseal)
|
||||
docker compose exec openbao-unsealer bao operator unseal <unsealer-key>
|
||||
```
|
||||
|
||||
- Caveat: the unsealer currently runs on the same host (SPOF). For real
|
||||
separation, relocate it to another host and point `seal.hcl` at it. To make
|
||||
host reboots fully hands-off, give the unsealer its own boot-unseal.
|
||||
- **Since 2026-09-20 this is automatic on boot.** `systemd/openbao-auto-unseal.service`
|
||||
(installed to /etc/systemd/system/, enabled) runs `scripts/unseal-unsealer.sh` (installed to
|
||||
/usr/local/sbin/openbao-unseal-unsealer.sh) after docker.service: it waits for the sidecar,
|
||||
unseals it with the key from **/etc/openbao-unsealer.key** (root, 0400), restarts the main
|
||||
node and writes the metric `openbao_unsealer_unsealed` into node_exporter's textfile dir.
|
||||
The script is idempotent, so it can be run at any time.
|
||||
Two pitfalls it works around: `bao operator unseal -` (key on stdin) is rejected, so the key
|
||||
goes in as an env var; and `bao status` returns exit code 2 when sealed, which still means
|
||||
"the container answers".
|
||||
- Caveat: the unsealer runs on the same host (SPOF) and now its key lives here too, so anyone
|
||||
with root on this host can unseal. That key was already in `unsealer-init.json`. For real
|
||||
separation, relocate the unsealer to another host and point `seal.hcl` at it.
|
||||
|
||||
## 4. Log in & use
|
||||
|
||||
|
||||
77
policies/claude-rw.hcl
Normal file
77
policies/claude-rw.hcl
Normal file
@@ -0,0 +1,77 @@
|
||||
# Rechte der Agentenidentitaet "claude" (Entity claude-automations).
|
||||
# Umgebaut 2026-10-03 als Ersatz fuer das zu grobe path "claude/*".
|
||||
#
|
||||
# Warum nicht einfach claude/*: dieses Muster deckt auch claude/ssh/config/ca und
|
||||
# claude/ssh/roles/* MIT SCHREIBRECHT ab (mit einem Testtoken belegt). Damit koennte
|
||||
# diese Identitaet die SSH-CA ueberschreiben und eigene Rollen mit beliebigen
|
||||
# Principals anlegen - auf jedem Host, der die CA kennt, faktisch Root.
|
||||
# Aufteilung daher: Rollen definiert der Admin, signieren darf claude.
|
||||
#
|
||||
# Hinweis zur Syntax: in OpenBAO ist * NUR als letztes Zeichen eine Wildcard;
|
||||
# mitten im Pfad ist es ein literales Sternchen. Fuer ein einzelnes Segment: +.
|
||||
# "list" ist eine eigene Faehigkeit und steckt nicht in "read".
|
||||
|
||||
# ---------- KV v2 ----------
|
||||
# Die Daten liegen unter data/, das Auflisten laeuft ueber metadata/ - zwei
|
||||
# getrennte Pfadbaeume desselben Mounts.
|
||||
path "claude/kv/data/*" {
|
||||
capabilities = ["create", "read", "update", "patch", "delete"]
|
||||
}
|
||||
|
||||
# Beide Formen absichtlich: claude/kv/metadata/* hat den Praefix
|
||||
# "claude/kv/metadata/" und trifft den Wurzel-Listing-Pfad ohne Schraegstrich nicht.
|
||||
path "claude/kv/metadata" {
|
||||
capabilities = ["list"]
|
||||
}
|
||||
path "claude/kv/metadata/" {
|
||||
capabilities = ["list"]
|
||||
}
|
||||
path "claude/kv/metadata/*" {
|
||||
capabilities = ["list", "read", "delete"]
|
||||
}
|
||||
|
||||
# Soft-Delete, Wiederherstellen und endgueltiges Loeschen von Versionen.
|
||||
path "claude/kv/delete/*" {
|
||||
capabilities = ["update"]
|
||||
}
|
||||
path "claude/kv/undelete/*" {
|
||||
capabilities = ["update"]
|
||||
}
|
||||
path "claude/kv/destroy/*" {
|
||||
capabilities = ["update"]
|
||||
}
|
||||
|
||||
# ---------- SSH ----------
|
||||
# Signieren mit vorhandenen Rollen ja, Rollen oder CA aendern nein.
|
||||
path "claude/ssh/sign/*" {
|
||||
capabilities = ["update"]
|
||||
}
|
||||
path "claude/ssh/roles" {
|
||||
capabilities = ["list"]
|
||||
}
|
||||
path "claude/ssh/roles/*" {
|
||||
capabilities = ["read"]
|
||||
}
|
||||
# claude/ssh/config/* fehlt hier BEWUSST - siehe Kopf.
|
||||
|
||||
# ---------- TOTP ----------
|
||||
path "claude/totp/code/*" {
|
||||
capabilities = ["read", "update"]
|
||||
}
|
||||
path "claude/totp/keys" {
|
||||
capabilities = ["list"]
|
||||
}
|
||||
path "claude/totp/keys/*" {
|
||||
capabilities = ["read", "create", "update", "delete"]
|
||||
}
|
||||
|
||||
# ---------- Sichtbarkeit der Mounts ----------
|
||||
# Die Mount-Liste der Weboberflaeche kommt aus sys/internal/ui/mounts und ist
|
||||
# policy-gefiltert, braucht das hier nicht. `bao secrets list` auf der
|
||||
# Kommandozeile liest dagegen sys/mounts und bliebe sonst leer.
|
||||
path "sys/mounts" {
|
||||
capabilities = ["read"]
|
||||
}
|
||||
|
||||
# Selbstverwaltung des eigenen Tokens (lookup-self, renew-self, revoke-self)
|
||||
# kommt aus der Policy "default" und steht hier deshalb nicht noch einmal.
|
||||
75
scripts/renew-gitea-cert.sh
Executable file
75
scripts/renew-gitea-cert.sh
Executable file
@@ -0,0 +1,75 @@
|
||||
#!/usr/bin/env bash
|
||||
# Renew the gitea.famfi.home leaf cert (Kopie von renew-openbao-cert.sh, 2026-09-20) from OpenBAO's pki_int engine and
|
||||
# install it where Traefik serves it. Idempotent: only renews when the current
|
||||
# cert expires within $RENEW_WINDOW_DAYS. Run as root (writes Traefik's tls dir).
|
||||
#
|
||||
# Installed as a systemd timer (gitea-cert-renew.timer). Manual run:
|
||||
# sudo /home/lutz/Projects/OpenBAO/scripts/renew-gitea-cert.sh [--force]
|
||||
set -euo pipefail
|
||||
|
||||
ADDR="${BAO_ADDR:-http://127.0.0.1:8200}"
|
||||
TOKEN_FILE="/etc/openbao-cert-renew.token"
|
||||
ROLE="pki_int/issue/famfi-home"
|
||||
CN="gitea.famfi.home"
|
||||
TTL="2160h" # 90 days
|
||||
DEST="/srv/TRAEFIK/etc/traefik/tls/gitea"
|
||||
DYN="/srv/TRAEFIK/etc/traefik/traefik.d/tls-gitea.yml"
|
||||
RENEW_WINDOW_DAYS="${RENEW_WINDOW_DAYS:-21}" # renew when <= this many days left
|
||||
FORCE="${1:-}"
|
||||
|
||||
log() { printf '%s [renew-cert] %s\n' "$(date '+%F %T')" "$*"; }
|
||||
die() { log "ERROR: $*"; exit 1; }
|
||||
|
||||
[ -r "$TOKEN_FILE" ] || die "token file $TOKEN_FILE not readable (run as root?)"
|
||||
TOKEN="$(cat "$TOKEN_FILE")"
|
||||
|
||||
# Skip if the current cert is still good (unless --force)
|
||||
if [ "$FORCE" != "--force" ] && [ -f "$DEST/fullchain.pem" ]; then
|
||||
end="$(openssl x509 -in "$DEST/fullchain.pem" -noout -enddate | cut -d= -f2)"
|
||||
days_left=$(( ( $(date -d "$end" +%s) - $(date +%s) ) / 86400 ))
|
||||
if [ "$days_left" -gt "$RENEW_WINDOW_DAYS" ]; then
|
||||
log "cert valid ${days_left}d (> ${RENEW_WINDOW_DAYS}d) — nothing to do"
|
||||
exit 0
|
||||
fi
|
||||
log "cert has ${days_left}d left (<= ${RENEW_WINDOW_DAYS}d) — renewing"
|
||||
fi
|
||||
|
||||
# Issue a fresh cert
|
||||
resp="$(curl -sS --fail-with-body --max-time 15 \
|
||||
-H "X-Vault-Token: ${TOKEN}" \
|
||||
--data "{\"common_name\":\"${CN}\",\"ttl\":\"${TTL}\"}" \
|
||||
"${ADDR}/v1/${ROLE}")" || die "issue request failed"
|
||||
|
||||
tmp="$(mktemp -d)"; trap 'rm -rf "$tmp"' EXIT
|
||||
export RESP="$resp"
|
||||
python3 - "$tmp" <<PY
|
||||
import json, sys, os
|
||||
d = json.loads(os.environ["RESP"])["data"]
|
||||
t = sys.argv[1]
|
||||
open(f"{t}/fullchain.pem", "w").write(d["certificate"] + "\n" + "\n".join(d.get("ca_chain", [])) + "\n")
|
||||
open(f"{t}/privkey.pem", "w").write(d["private_key"] + "\n")
|
||||
PY
|
||||
|
||||
# Validate cert/key match before installing
|
||||
cmod="$(openssl x509 -in "$tmp/fullchain.pem" -noout -modulus | openssl md5)"
|
||||
kmod="$(openssl rsa -in "$tmp/privkey.pem" -noout -modulus 2>/dev/null | openssl md5)"
|
||||
[ "$cmod" = "$kmod" ] || die "cert/key modulus mismatch — refusing to install"
|
||||
|
||||
install -d -o root -g root -m 0755 "$DEST"
|
||||
install -o root -g root -m 0644 "$tmp/fullchain.pem" "$DEST/fullchain.pem"
|
||||
install -o root -g root -m 0600 "$tmp/privkey.pem" "$DEST/privkey.pem"
|
||||
|
||||
# Rewrite the watched dynamic config (with a fresh timestamp) so Traefik's file
|
||||
# provider reparses and reloads the cert from disk — changing the cert file
|
||||
# alone does NOT trigger a reload.
|
||||
cat > "$DYN" <<YML
|
||||
# AUTO-MANAGED by renew-gitea-cert.sh — last renewed $(date -Iseconds).
|
||||
# Loads the internal cert for gitea.famfi.home; the gitea router serves it by SNI
|
||||
# (oeffentlich laeuft gitea.famfi.dyndns.org weiter ueber Lets Encrypt).
|
||||
tls:
|
||||
certificates:
|
||||
- certFile: /etc/traefik/tls/gitea/fullchain.pem
|
||||
keyFile: /etc/traefik/tls/gitea/privkey.pem
|
||||
YML
|
||||
chmod 0644 "$DYN"
|
||||
log "installed new cert ($(openssl x509 -in "$DEST/fullchain.pem" -noout -enddate | cut -d= -f2)); Traefik reload triggered"
|
||||
80
scripts/unseal-unsealer.sh
Executable file
80
scripts/unseal-unsealer.sh
Executable file
@@ -0,0 +1,80 @@
|
||||
#!/usr/bin/env bash
|
||||
# Entsiegelt die openbao-unsealer-Sidecar nach einem Host-Neustart.
|
||||
#
|
||||
# Warum: der Unsealer ist Shamir-versiegelt (1 Schluessel). Bis er offen ist, kann die
|
||||
# Hauptinstanz ihren Transit-Seal nicht aufmachen und startet endlos neu (2026-09-20 beobachtet,
|
||||
# erster Reboot seit 106 Tagen). Danach entsiegelt sich die Hauptinstanz von selbst.
|
||||
#
|
||||
# Der Schluessel steht in /etc/openbao-unsealer.key (root, 0400). Das ist bewusst ein Kompromiss:
|
||||
# wer root auf diesem Host hat, kann entsiegeln — er liegt aber ohnehin schon in
|
||||
# ~/Projects/OpenBAO/unsealer-init.json. Echte Trennung braeuchte den Unsealer auf einem
|
||||
# anderen Host (siehe README, Abschnitt Auto-unseal).
|
||||
set -uo pipefail
|
||||
|
||||
KEY_FILE="${KEY_FILE:-/etc/openbao-unsealer.key}"
|
||||
CONTAINER="${CONTAINER:-openbao-unsealer}"
|
||||
MAIN="${MAIN:-openbao}"
|
||||
TEXTFILE_DIR="${TEXTFILE_DIR:-/var/lib/prometheus/node-exporter}"
|
||||
PROM="${TEXTFILE_DIR}/openbao_unseal.prom"
|
||||
TIMEOUT="${TIMEOUT:-300}" # Sekunden warten, bis Docker und der Container da sind
|
||||
|
||||
log() { printf '%s [unseal] %s\n' "$(date '+%F %T')" "$*"; }
|
||||
|
||||
metric() { # $1 = 1 (offen) | 0 (zu)
|
||||
[ -d "$TEXTFILE_DIR" ] || return 0
|
||||
{
|
||||
echo "# HELP openbao_unsealer_unsealed 1 = openbao-unsealer ist entsiegelt"
|
||||
echo "# TYPE openbao_unsealer_unsealed gauge"
|
||||
echo "openbao_unsealer_unsealed $1"
|
||||
echo "# HELP openbao_unsealer_last_attempt_timestamp_seconds Letzter Entsiegelungsversuch"
|
||||
echo "# TYPE openbao_unsealer_last_attempt_timestamp_seconds gauge"
|
||||
echo "openbao_unsealer_last_attempt_timestamp_seconds $(date +%s)"
|
||||
} > "${PROM}.tmp" && mv "${PROM}.tmp" "$PROM" && chmod 644 "$PROM"
|
||||
}
|
||||
|
||||
[ -r "$KEY_FILE" ] || { log "ABBRUCH: $KEY_FILE fehlt oder ist nicht lesbar"; metric 0; exit 1; }
|
||||
|
||||
# 1. Warten, bis der Container laeuft und seine API antwortet.
|
||||
# 'bao status' liefert 0 = offen, 2 = versiegelt, 1 = nicht erreichbar. 0 und 2 heissen
|
||||
# beide "der Container antwortet" — genau darauf wird gewartet.
|
||||
deadline=$(( $(date +%s) + TIMEOUT ))
|
||||
while :; do
|
||||
docker exec "$CONTAINER" bao status >/dev/null 2>&1
|
||||
rc=$?
|
||||
case "$rc" in 0|2) break;; esac
|
||||
[ "$(date +%s)" -lt "$deadline" ] || { log "ABBRUCH: $CONTAINER antwortet nicht binnen ${TIMEOUT}s (rc=$rc)"; metric 0; exit 1; }
|
||||
sleep 5
|
||||
done
|
||||
|
||||
# 2. Schon offen? Dann nichts tun (idempotent — die Unit darf jederzeit laufen).
|
||||
if docker exec "$CONTAINER" bao status 2>/dev/null | grep -qE '^Sealed[[:space:]]+false'; then
|
||||
log "$CONTAINER ist bereits entsiegelt"
|
||||
metric 1
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# 3. Entsiegeln. Der Schluessel geht als Umgebungsvariable in den Container;
|
||||
# 'bao operator unseal -' (stdin) funktioniert nicht, es lehnt den Schluessel ab.
|
||||
UK="$(tr -d '\r\n' < "$KEY_FILE")"
|
||||
docker exec -e UK="$UK" "$CONTAINER" sh -c 'bao operator unseal "$UK"' >/dev/null 2>&1
|
||||
unset UK
|
||||
|
||||
if docker exec "$CONTAINER" bao status 2>/dev/null | grep -qE '^Sealed[[:space:]]+false'; then
|
||||
log "$CONTAINER entsiegelt"
|
||||
metric 1
|
||||
else
|
||||
log "FEHLER: $CONTAINER ist weiterhin versiegelt"
|
||||
metric 0
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 4. Die Hauptinstanz anstupsen, damit sie nicht erst den naechsten Restart-Zyklus abwartet.
|
||||
if docker inspect "$MAIN" >/dev/null 2>&1; then
|
||||
docker restart "$MAIN" >/dev/null 2>&1 && log "$MAIN neu gestartet"
|
||||
for i in $(seq 1 24); do
|
||||
docker exec "$MAIN" bao status 2>/dev/null | grep -qE '^Sealed[[:space:]]+false' && { log "$MAIN ist entsiegelt"; exit 0; }
|
||||
sleep 5
|
||||
done
|
||||
log "HINWEIS: $MAIN ist nach 120s noch nicht entsiegelt — Logs pruefen"
|
||||
fi
|
||||
exit 0
|
||||
9
systemd/gitea-cert-renew.service
Normal file
9
systemd/gitea-cert-renew.service
Normal file
@@ -0,0 +1,9 @@
|
||||
[Unit]
|
||||
Description=Erneuert gitea.famfi.home aus der OpenBAO-PKI und laedt Traefik neu
|
||||
After=network-online.target docker.service
|
||||
Wants=network-online.target
|
||||
Requires=docker.service
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
ExecStart=/home/lutz/Projects/OpenBAO/scripts/renew-gitea-cert.sh
|
||||
10
systemd/gitea-cert-renew.timer
Normal file
10
systemd/gitea-cert-renew.timer
Normal file
@@ -0,0 +1,10 @@
|
||||
[Unit]
|
||||
Description=Taeglicher Check/Renew des internen Zertifikats fuer gitea.famfi.home
|
||||
|
||||
[Timer]
|
||||
OnCalendar=daily
|
||||
Persistent=true
|
||||
RandomizedDelaySec=1h
|
||||
|
||||
[Install]
|
||||
WantedBy=timers.target
|
||||
19
systemd/openbao-auto-unseal.service
Normal file
19
systemd/openbao-auto-unseal.service
Normal file
@@ -0,0 +1,19 @@
|
||||
[Unit]
|
||||
Description=Entsiegelt die openbao-unsealer-Sidecar nach dem Start (sonst startet OpenBAO endlos neu)
|
||||
Documentation=file:///home/lutz/Projects/OpenBAO/README.md
|
||||
After=docker.service network-online.target
|
||||
Wants=docker.service
|
||||
Requires=docker.service
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
RemainAfterExit=yes
|
||||
ExecStart=/usr/local/sbin/openbao-unseal-unsealer.sh
|
||||
TimeoutStartSec=420
|
||||
# Ein fehlgeschlagener Versuch soll sichtbar sein, aber den Boot nicht blockieren.
|
||||
Restart=on-failure
|
||||
RestartSec=30
|
||||
StartLimitBurst=3
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
Reference in New Issue
Block a user