From 3febbb51df43ae8b72a7e13df4898a96ae7992e4 Mon Sep 17 00:00:00 2001 From: sascha Date: Wed, 22 Jul 2026 11:23:50 +0200 Subject: [PATCH 1/6] sync: .gitignore for Butler 2.3.2 --- .gitignore | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.gitignore b/.gitignore index cff5543..d5b4207 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,5 @@ .env +.vault-sync.env +vault-sync.log __pycache__/ *.pyc -- 2.49.1 From 02779055ad3860accaacc829b47966a6cdaad378 Mon Sep 17 00:00:00 2001 From: sascha Date: Wed, 22 Jul 2026 11:23:51 +0200 Subject: [PATCH 2/6] sync: README.md for Butler 2.3.2 --- README.md | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index a2587ce..d0a464f 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ Unified API proxy and infrastructure management for Homelab Pfannkuchen. - **Base URL:** `http://10.4.1.116:8888` - **Authentication:** `Authorization: Bearer ` -- **Version:** 2.3.1 +- **Version:** 2.3.2 - **Interactive API documentation:** `/docs` ## Service proxy @@ -97,6 +97,12 @@ Integration Compose definition: `tests/compose.integration.yaml` (binds only to ## Changelog +### 2.3.2 — 22.07.2026 + +- Make Vaultwarden refresh durable: persistent named cache volume, protected runtime credentials, automatic API-key re-login and atomic cache writes. +- Remove the Vaultwarden master password from the tracked sync script. +- Add regression tests for cache persistence and secret handling. + ### 2.3.1 — 22.07.2026 - Use a writable runtime `known_hosts` file for SSH probes with read-only SSH mounts. -- 2.49.1 From 7048fa0c95ced93bbc5bac64fb333ec547808093 Mon Sep 17 00:00:00 2001 From: sascha Date: Wed, 22 Jul 2026 11:23:52 +0200 Subject: [PATCH 3/6] sync: app.py for Butler 2.3.2 --- app.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app.py b/app.py index ddce37b..5168a5f 100644 --- a/app.py +++ b/app.py @@ -11,7 +11,7 @@ from fastapi.responses import JSONResponse, RedirectResponse from contextlib import asynccontextmanager log = logging.getLogger("butler") -VERSION = "2.3.1" +VERSION = "2.3.2" API_DIR = os.environ.get("API_KEY_DIR", "/data/api") VAULT_CACHE_DIR = os.environ.get("VAULT_CACHE_DIR", "/data/vault-cache") -- 2.49.1 From d8b0270b565456a679c4e408900452dedcc6f430 Mon Sep 17 00:00:00 2001 From: sascha Date: Wed, 22 Jul 2026 11:23:52 +0200 Subject: [PATCH 4/6] sync: tests/test_app.py for Butler 2.3.2 --- tests/test_app.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_app.py b/tests/test_app.py index dc5f072..f2fc37e 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -42,7 +42,7 @@ def test_health_exposes_current_version(): with TestClient(app.app) as client: response = client.get("/health") assert response.status_code == 200 - assert response.json()["version"] == app.VERSION == "2.3.1" + assert response.json()["version"] == app.VERSION == "2.3.2" def test_invalid_log_target_is_rejected_before_ssh(): -- 2.49.1 From 4597540ad8531558d7bc2cb103e51541319a9d3d Mon Sep 17 00:00:00 2001 From: sascha Date: Wed, 22 Jul 2026 11:23:53 +0200 Subject: [PATCH 5/6] sync: tests/test_vault_sync.py for Butler 2.3.2 --- tests/test_vault_sync.py | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) create mode 100644 tests/test_vault_sync.py diff --git a/tests/test_vault_sync.py b/tests/test_vault_sync.py new file mode 100644 index 0000000..2353f46 --- /dev/null +++ b/tests/test_vault_sync.py @@ -0,0 +1,27 @@ +from pathlib import Path +import re + + +ROOT = Path(__file__).resolve().parents[1] + + +def test_vault_cache_is_a_persistent_named_volume(): + compose = (ROOT / "compose.yaml").read_text() + assert "vault-cache:/data/vault-cache" in compose + assert "volumes:\n vault-cache:" in compose + + +def test_vault_sync_uses_protected_environment_instead_of_embedded_password(): + script = (ROOT / "vault-sync.sh").read_text() + assert '.vault-sync.env' in script + assert not re.search(r'export BW_PASSWORD=["\'](?!\$)', script) + assert 'BW_CLIENTID' in script + assert 'BW_CLIENTSECRET' in script + assert 'bw login --apikey' in script + assert 'bw unlock --passwordenv BW_PASSWORD' in script + + +def test_vault_sync_runtime_files_are_not_tracked(): + gitignore = (ROOT / ".gitignore").read_text().splitlines() + assert ".vault-sync.env" in gitignore + assert "vault-sync.log" in gitignore -- 2.49.1 From 08ac14c089d3e2b5384225b6b42068019278c41f Mon Sep 17 00:00:00 2001 From: sascha Date: Wed, 22 Jul 2026 11:23:54 +0200 Subject: [PATCH 6/6] sync: vault-sync.sh for Butler 2.3.2 --- vault-sync.sh | 64 ++++++++++++++++++++++++++++++++++++++++----------- 1 file changed, 50 insertions(+), 14 deletions(-) diff --git a/vault-sync.sh b/vault-sync.sh index 8f03bc0..06a92c1 100644 --- a/vault-sync.sh +++ b/vault-sync.sh @@ -1,20 +1,51 @@ #!/bin/bash -# vault-sync.sh - Sync Vaultwarden items to Butler cache volume -# Run via cron: */30 * * * * /app-config/homelab-butler/vault-sync.sh +# vault-sync.sh - Sync Vaultwarden items to Butler's persistent cache volume. +# Runtime credentials live in .vault-sync.env (mode 0600, never in Git). set -euo pipefail -export BW_PASSWORD="8yRG5LADfoTLHdC1Oj" -CACHE_DIR=$(sudo docker inspect homelab-butler --format '{{range .Mounts}}{{if eq .Destination "/data/vault-cache"}}{{.Source}}{{end}}{{end}}' 2>/dev/null) +ENV_FILE="${VAULT_SYNC_ENV:-/app-config/homelab-butler/.vault-sync.env}" +if [[ ! -r "$ENV_FILE" ]]; then + echo "vault-sync: protected environment file missing or unreadable" >&2 + exit 1 +fi -[ -z "$CACHE_DIR" ] && echo "Butler container not found" && exit 1 +set -a +# shellcheck disable=SC1090 +source "$ENV_FILE" +set +a + +: "${BW_CLIENTID:?BW_CLIENTID missing}" +: "${BW_CLIENTSECRET:?BW_CLIENTSECRET missing}" +if [[ -z "${BW_PASSWORD:-}" && -n "${BW_MASTER_PASSWORD:-}" ]]; then + export BW_PASSWORD="$BW_MASTER_PASSWORD" +fi +: "${BW_PASSWORD:?BW_PASSWORD missing}" + +CACHE_DIR=$(sudo docker inspect homelab-butler --format '{{range .Mounts}}{{if eq .Destination "/data/vault-cache"}}{{.Source}}{{end}}{{end}}' 2>/dev/null) +if [[ -z "$CACHE_DIR" ]]; then + echo "vault-sync: Butler cache volume not found" >&2 + exit 1 +fi + +BW_STATE=$(bw status 2>/dev/null | python3 -c 'import json,sys; print(json.load(sys.stdin).get("status", "unknown"))') +if [[ "$BW_STATE" == "unauthenticated" ]]; then + bw login --apikey --raw >/dev/null +fi SESSION=$(bw unlock --passwordenv BW_PASSWORD --raw 2>/dev/null) -[ -z "$SESSION" ] && echo "Vault unlock failed" && exit 1 +if [[ -z "$SESSION" ]]; then + echo "vault-sync: Vault unlock failed" >&2 + exit 1 +fi -bw sync --session "$SESSION" >/dev/null 2>&1 +bw sync --session "$SESSION" >/dev/null + +bw list items --session "$SESSION" | sudo python3 -c " +import json +import os +import re +import sys -bw list items --session "$SESSION" 2>/dev/null | sudo python3 -c " -import sys, json, os items = json.load(sys.stdin) cache_dir = '$CACHE_DIR' os.makedirs(cache_dir, exist_ok=True) @@ -22,10 +53,15 @@ count = 0 for item in items: name = item.get('name', '') notes = item.get('notes') or '' - if name and notes: - safe = name.lower().replace(' ', '-') - with open(f'{cache_dir}/{safe}', 'w') as f: - f.write(notes.strip()) - count += 1 + safe = re.sub(r'[^a-z0-9._-]+', '-', name.lower()).strip('.-') + if not safe or not notes: + continue + path = os.path.join(cache_dir, safe) + tmp = path + '.tmp' + with open(tmp, 'w') as handle: + handle.write(notes.strip()) + os.chmod(tmp, 0o600) + os.replace(tmp, path) + count += 1 print(f'vault-sync: {count} items written') " -- 2.49.1