fix: make Vaultwarden cache refresh durable
This commit is contained in:
parent
8e07f50c03
commit
6e80578ca6
6 changed files with 88 additions and 17 deletions
2
.gitignore
vendored
2
.gitignore
vendored
|
|
@ -1,3 +1,5 @@
|
||||||
.env
|
.env
|
||||||
|
.vault-sync.env
|
||||||
|
vault-sync.log
|
||||||
__pycache__/
|
__pycache__/
|
||||||
*.pyc
|
*.pyc
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ Unified API proxy and infrastructure management for Homelab Pfannkuchen.
|
||||||
|
|
||||||
- **Base URL:** `http://10.4.1.116:8888`
|
- **Base URL:** `http://10.4.1.116:8888`
|
||||||
- **Authentication:** `Authorization: Bearer <BUTLER_TOKEN>`
|
- **Authentication:** `Authorization: Bearer <BUTLER_TOKEN>`
|
||||||
- **Version:** 2.3.1
|
- **Version:** 2.3.2
|
||||||
- **Interactive API documentation:** `/docs`
|
- **Interactive API documentation:** `/docs`
|
||||||
|
|
||||||
## Service proxy
|
## Service proxy
|
||||||
|
|
@ -97,6 +97,12 @@ Integration Compose definition: `tests/compose.integration.yaml` (binds only to
|
||||||
|
|
||||||
## Changelog
|
## 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
|
### 2.3.1 — 22.07.2026
|
||||||
|
|
||||||
- Use a writable runtime `known_hosts` file for SSH probes with read-only SSH mounts.
|
- Use a writable runtime `known_hosts` file for SSH probes with read-only SSH mounts.
|
||||||
|
|
|
||||||
2
app.py
2
app.py
|
|
@ -11,7 +11,7 @@ from fastapi.responses import JSONResponse, RedirectResponse
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
|
|
||||||
log = logging.getLogger("butler")
|
log = logging.getLogger("butler")
|
||||||
VERSION = "2.3.1"
|
VERSION = "2.3.2"
|
||||||
|
|
||||||
API_DIR = os.environ.get("API_KEY_DIR", "/data/api")
|
API_DIR = os.environ.get("API_KEY_DIR", "/data/api")
|
||||||
VAULT_CACHE_DIR = os.environ.get("VAULT_CACHE_DIR", "/data/vault-cache")
|
VAULT_CACHE_DIR = os.environ.get("VAULT_CACHE_DIR", "/data/vault-cache")
|
||||||
|
|
|
||||||
|
|
@ -42,7 +42,7 @@ def test_health_exposes_current_version():
|
||||||
with TestClient(app.app) as client:
|
with TestClient(app.app) as client:
|
||||||
response = client.get("/health")
|
response = client.get("/health")
|
||||||
assert response.status_code == 200
|
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():
|
def test_invalid_log_target_is_rejected_before_ssh():
|
||||||
|
|
|
||||||
27
tests/test_vault_sync.py
Normal file
27
tests/test_vault_sync.py
Normal file
|
|
@ -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
|
||||||
|
|
@ -1,20 +1,51 @@
|
||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
# vault-sync.sh - Sync Vaultwarden items to Butler cache volume
|
# vault-sync.sh - Sync Vaultwarden items to Butler's persistent cache volume.
|
||||||
# Run via cron: */30 * * * * /app-config/homelab-butler/vault-sync.sh
|
# Runtime credentials live in .vault-sync.env (mode 0600, never in Git).
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
export BW_PASSWORD="8yRG5LADfoTLHdC1Oj"
|
ENV_FILE="${VAULT_SYNC_ENV:-/app-config/homelab-butler/.vault-sync.env}"
|
||||||
CACHE_DIR=$(sudo docker inspect homelab-butler --format '{{range .Mounts}}{{if eq .Destination "/data/vault-cache"}}{{.Source}}{{end}}{{end}}' 2>/dev/null)
|
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)
|
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)
|
items = json.load(sys.stdin)
|
||||||
cache_dir = '$CACHE_DIR'
|
cache_dir = '$CACHE_DIR'
|
||||||
os.makedirs(cache_dir, exist_ok=True)
|
os.makedirs(cache_dir, exist_ok=True)
|
||||||
|
|
@ -22,10 +53,15 @@ count = 0
|
||||||
for item in items:
|
for item in items:
|
||||||
name = item.get('name', '')
|
name = item.get('name', '')
|
||||||
notes = item.get('notes') or ''
|
notes = item.get('notes') or ''
|
||||||
if name and notes:
|
safe = re.sub(r'[^a-z0-9._-]+', '-', name.lower()).strip('.-')
|
||||||
safe = name.lower().replace(' ', '-')
|
if not safe or not notes:
|
||||||
with open(f'{cache_dir}/{safe}', 'w') as f:
|
continue
|
||||||
f.write(notes.strip())
|
path = os.path.join(cache_dir, safe)
|
||||||
count += 1
|
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')
|
print(f'vault-sync: {count} items written')
|
||||||
"
|
"
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue