v2: Vault integration - read credentials from Vaultwarden with disk cache fallback
This commit is contained in:
parent
a85a3175cd
commit
09bbc47d6c
4 changed files with 191 additions and 41 deletions
202
app.py
202
app.py
|
|
@ -1,24 +1,122 @@
|
|||
"""Homelab Butler – Unified API proxy for Pfannkuchen homelab."""
|
||||
"""Homelab Butler – Unified API proxy for Pfannkuchen homelab.
|
||||
Reads credentials from Vaultwarden (Bitwarden CLI) on startup, caches locally."""
|
||||
|
||||
import os, json, httpx
|
||||
import os, json, asyncio, subprocess, logging
|
||||
import httpx
|
||||
from fastapi import FastAPI, Request, HTTPException, Depends
|
||||
from fastapi.responses import JSONResponse
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
app = FastAPI(title="Homelab Butler", version="1.0.0")
|
||||
log = logging.getLogger("butler")
|
||||
|
||||
API_DIR = os.environ.get("API_KEY_DIR", "/data/api")
|
||||
BUTLER_TOKEN = os.environ.get("BUTLER_TOKEN", "")
|
||||
BW_PASSWORD = os.environ.get("BW_PASSWORD", "")
|
||||
VAULT_REFRESH_MINUTES = int(os.environ.get("VAULT_REFRESH_MINUTES", "30"))
|
||||
|
||||
# --- Credential loading ---
|
||||
# --- Vault sync ---
|
||||
|
||||
_vault_cache: dict[str, str] = {}
|
||||
|
||||
# Mapping: vault item name -> local file name
|
||||
VAULT_TO_FILE = {
|
||||
"HA_TOKEN": "homeassistent",
|
||||
"N8N_API_KEY": "n8n",
|
||||
"OUTLINE_API_KEY": "outline",
|
||||
"HETZNER_DNS_TOKEN": "hetzner-dns",
|
||||
"TG_HARALD_TOKEN": "tg-harald",
|
||||
"TG_SASCHA_CHAT": "tg-sascha-chat",
|
||||
"SEERR_URL": None, # skip URL-only items
|
||||
}
|
||||
|
||||
def _sync_vault():
|
||||
"""Pull all items from Vaultwarden via bw CLI and cache them."""
|
||||
global _vault_cache
|
||||
if not BW_PASSWORD:
|
||||
log.info("No BW_PASSWORD set, skipping vault sync")
|
||||
return False
|
||||
try:
|
||||
env = {**os.environ, "BW_PASSWORD": BW_PASSWORD}
|
||||
session = subprocess.run(
|
||||
["bw", "unlock", "--passwordenv", "BW_PASSWORD", "--raw"],
|
||||
capture_output=True, text=True, env=env, timeout=30
|
||||
).stdout.strip()
|
||||
if not session:
|
||||
log.warning("Vault unlock failed")
|
||||
return False
|
||||
subprocess.run(["bw", "sync", "--session", session],
|
||||
capture_output=True, env=env, timeout=30)
|
||||
result = subprocess.run(
|
||||
["bw", "list", "items", "--session", session],
|
||||
capture_output=True, text=True, env=env, timeout=30
|
||||
)
|
||||
items = json.loads(result.stdout)
|
||||
new_cache = {}
|
||||
for item in items:
|
||||
name = item.get("name", "")
|
||||
notes = item.get("notes") or ""
|
||||
if name and notes:
|
||||
new_cache[name] = notes.strip()
|
||||
_vault_cache = new_cache
|
||||
# Write to disk as cache (fallback if vault unreachable later)
|
||||
os.makedirs(f"{API_DIR}/.vault-cache", exist_ok=True)
|
||||
for name, value in new_cache.items():
|
||||
safe = name.lower().replace(" ", "-")
|
||||
with open(f"{API_DIR}/.vault-cache/{safe}", "w") as f:
|
||||
f.write(value)
|
||||
log.info(f"Vault sync: {len(new_cache)} items cached")
|
||||
return True
|
||||
except Exception as e:
|
||||
log.warning(f"Vault sync failed: {e}")
|
||||
return False
|
||||
|
||||
def _load_disk_cache():
|
||||
"""Load cached vault items from disk (fallback)."""
|
||||
global _vault_cache
|
||||
cache_dir = f"{API_DIR}/.vault-cache"
|
||||
if not os.path.isdir(cache_dir):
|
||||
return
|
||||
for f in os.listdir(cache_dir):
|
||||
path = os.path.join(cache_dir, f)
|
||||
if os.path.isfile(path):
|
||||
_vault_cache[f] = open(path).read().strip()
|
||||
log.info(f"Loaded {len(_vault_cache)} items from disk cache")
|
||||
|
||||
async def _periodic_vault_sync():
|
||||
while True:
|
||||
await asyncio.sleep(VAULT_REFRESH_MINUTES * 60)
|
||||
log.info("Periodic vault refresh...")
|
||||
_sync_vault()
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
# Startup: sync vault, fall back to disk cache
|
||||
if not _sync_vault():
|
||||
_load_disk_cache()
|
||||
task = asyncio.create_task(_periodic_vault_sync())
|
||||
yield
|
||||
task.cancel()
|
||||
|
||||
app = FastAPI(title="Homelab Butler", version="2.0.0", lifespan=lifespan)
|
||||
|
||||
# --- Credential reading (vault-first, file-fallback) ---
|
||||
|
||||
def _read(name):
|
||||
"""Read a credential: vault cache first, then flat file."""
|
||||
# Check vault cache by exact name
|
||||
if name in _vault_cache:
|
||||
return _vault_cache[name]
|
||||
# Check vault cache by uppercase convention
|
||||
upper = name.upper().replace("-", "_")
|
||||
if upper in _vault_cache:
|
||||
return _vault_cache[upper]
|
||||
# Fallback to flat file
|
||||
try:
|
||||
return open(f"{API_DIR}/{name}").read().strip()
|
||||
except FileNotFoundError:
|
||||
return None
|
||||
|
||||
def _parse_kv(name):
|
||||
"""Parse key-value files like proxmox, authentik."""
|
||||
raw = _read(name)
|
||||
if not raw:
|
||||
return {}
|
||||
|
|
@ -30,45 +128,63 @@ def _parse_kv(name):
|
|||
return d
|
||||
|
||||
def _parse_url_key(name):
|
||||
"""Parse files with URL on line 1, key on line 2 (radarr1080p, sonarr1080p)."""
|
||||
raw = _read(name)
|
||||
if not raw:
|
||||
return None, None
|
||||
lines = [l.strip() for l in raw.splitlines() if l.strip()]
|
||||
return (lines[0] if lines else None, lines[1] if len(lines) > 1 else None)
|
||||
|
||||
# --- Vault-aware credential getters ---
|
||||
|
||||
def _get_seerr_key():
|
||||
return _read("seer") or _vault_cache.get("SEERR_API_KEY", "")
|
||||
|
||||
def _get_sonarr_key(variant=""):
|
||||
if variant == "1080p":
|
||||
return _read("sonarr1080p") or _vault_cache.get("SONARR_FHD_KEY", "")
|
||||
return _read("sonarr") or _vault_cache.get("SONARR_UHD_KEY", "")
|
||||
|
||||
def _get_radarr_key(variant=""):
|
||||
if variant == "1080p":
|
||||
return _read("radarr1080p") or _vault_cache.get("RADARR_FHD_KEY", "")
|
||||
return _read("radarr") or _vault_cache.get("RADARR_UHD_KEY", "")
|
||||
|
||||
# --- Service configs ---
|
||||
|
||||
SERVICES = {
|
||||
"dockhand": {"url": "http://10.4.1.116:3000", "auth": "session"},
|
||||
"sonarr": {"url": "http://10.2.1.100:8989", "auth": "apikey", "key_file": "sonarr"},
|
||||
"sonarr1080p": {"url": None, "auth": "apikey_urlfile", "key_file": "sonarr1080p"},
|
||||
"radarr": {"url": "http://10.2.1.100:7878", "auth": "apikey", "key_file": "radarr"},
|
||||
"radarr1080p": {"url": None, "auth": "apikey_urlfile", "key_file": "radarr1080p"},
|
||||
"seerr": {"url": "http://10.2.1.100:5055", "auth": "apikey", "key_file": "seer"},
|
||||
"outline": {"url": "http://10.1.1.100:3000", "auth": "bearer", "key_file": "outline"},
|
||||
"n8n": {"url": "http://10.4.1.113:5678", "auth": "n8n", "key_file": "n8n"},
|
||||
"proxmox": {"url": "https://10.5.85.11:8006", "auth": "proxmox"},
|
||||
"homeassistant": {"url": "http://10.10.1.20:8123","auth": "bearer", "key_file": "homeassistent"},
|
||||
"grafana": {"url": "http://10.1.1.111:3000", "auth": "bearer", "key_file": "grafana"},
|
||||
"uptime": {"url": "http://159.69.245.190:3001", "auth": "bearer", "key_file": "uptime"},
|
||||
"dockhand": {"url": "http://10.4.1.116:3000", "auth": "session"},
|
||||
"sonarr": {"url": "http://10.2.1.100:8989", "auth": "apikey", "key_file": "sonarr"},
|
||||
"sonarr1080p": {"url": None, "auth": "apikey_urlfile", "key_file": "sonarr1080p"},
|
||||
"radarr": {"url": "http://10.2.1.100:7878", "auth": "apikey", "key_file": "radarr"},
|
||||
"radarr1080p": {"url": None, "auth": "apikey_urlfile", "key_file": "radarr1080p"},
|
||||
"seerr": {"url": "http://10.2.1.100:5055", "auth": "apikey", "key_file": "seer"},
|
||||
"outline": {"url": "http://10.1.1.100:3000", "auth": "bearer", "key_file": "outline",
|
||||
"vault_key": "OUTLINE_API_KEY"},
|
||||
"n8n": {"url": "http://10.4.1.113:5678", "auth": "n8n", "key_file": "n8n",
|
||||
"vault_key": "N8N_API_KEY"},
|
||||
"proxmox": {"url": "https://10.5.85.11:8006", "auth": "proxmox"},
|
||||
"homeassistant": {"url": "http://10.10.1.20:8123", "auth": "bearer", "key_file": "homeassistent",
|
||||
"vault_key": "HA_TOKEN"},
|
||||
"grafana": {"url": "http://10.1.1.111:3000", "auth": "bearer", "key_file": "grafana"},
|
||||
"uptime": {"url": "http://159.69.245.190:3001", "auth": "bearer", "key_file": "uptime"},
|
||||
}
|
||||
|
||||
# --- Session cache for Dockhand ---
|
||||
# --- Dockhand session cache ---
|
||||
|
||||
_dockhand_cookie = None
|
||||
|
||||
async def _dockhand_login(client):
|
||||
global _dockhand_cookie
|
||||
pw = _read("dockhand")
|
||||
r = await client.post(
|
||||
f"{SERVICES['dockhand']['url']}/api/auth/login",
|
||||
json={"username": "admin", "password": _read("dockhand") or ""},
|
||||
json={"username": "admin", "password": pw or ""},
|
||||
)
|
||||
if r.status_code == 200:
|
||||
_dockhand_cookie = dict(r.cookies)
|
||||
return _dockhand_cookie
|
||||
|
||||
# --- Auth helper ---
|
||||
# --- Auth ---
|
||||
|
||||
def _verify(request: Request):
|
||||
if not BUTLER_TOKEN:
|
||||
|
|
@ -77,15 +193,31 @@ def _verify(request: Request):
|
|||
if auth != f"Bearer {BUTLER_TOKEN}":
|
||||
raise HTTPException(401, "Invalid token")
|
||||
|
||||
def _get_key(cfg):
|
||||
"""Get API key from vault (preferred) or file (fallback)."""
|
||||
vault_key = cfg.get("vault_key")
|
||||
if vault_key and vault_key in _vault_cache:
|
||||
return _vault_cache[vault_key]
|
||||
return _read(cfg.get("key_file", ""))
|
||||
|
||||
# --- Routes ---
|
||||
|
||||
@app.get("/")
|
||||
async def root():
|
||||
return {"service": "homelab-butler", "version": "1.0.0", "services": list(SERVICES.keys())}
|
||||
return {
|
||||
"service": "homelab-butler", "version": "2.0.0",
|
||||
"services": list(SERVICES.keys()),
|
||||
"vault_items": len(_vault_cache),
|
||||
}
|
||||
|
||||
@app.get("/health")
|
||||
async def health():
|
||||
return {"status": "ok"}
|
||||
return {"status": "ok", "vault_items": len(_vault_cache)}
|
||||
|
||||
@app.post("/vault/refresh")
|
||||
async def vault_refresh(_=Depends(_verify)):
|
||||
ok = _sync_vault()
|
||||
return {"refreshed": ok, "items": len(_vault_cache)}
|
||||
|
||||
@app.api_route("/{service}/{path:path}", methods=["GET", "POST", "PUT", "DELETE", "PATCH"])
|
||||
async def proxy(service: str, path: str, request: Request, _=Depends(_verify)):
|
||||
|
|
@ -98,14 +230,11 @@ async def proxy(service: str, path: str, request: Request, _=Depends(_verify)):
|
|||
headers = dict(request.headers)
|
||||
cookies = {}
|
||||
|
||||
# Remove hop-by-hop headers and butler auth (replaced by service auth below)
|
||||
for h in ["host", "content-length", "transfer-encoding", "authorization"]:
|
||||
headers.pop(h, None)
|
||||
|
||||
# Build auth
|
||||
if auth_type == "apikey":
|
||||
key = _read(cfg["key_file"])
|
||||
headers["X-Api-Key"] = key or ""
|
||||
headers["X-Api-Key"] = _get_key(cfg) or ""
|
||||
|
||||
elif auth_type == "apikey_urlfile":
|
||||
url, key = _parse_url_key(cfg["key_file"])
|
||||
|
|
@ -113,17 +242,15 @@ async def proxy(service: str, path: str, request: Request, _=Depends(_verify)):
|
|||
headers["X-Api-Key"] = key or ""
|
||||
|
||||
elif auth_type == "bearer":
|
||||
key = _read(cfg["key_file"])
|
||||
headers["Authorization"] = f"Bearer {key}"
|
||||
headers["Authorization"] = f"Bearer {_get_key(cfg)}"
|
||||
|
||||
elif auth_type == "n8n":
|
||||
headers["X-N8N-API-KEY"] = _get_key(cfg) or ""
|
||||
|
||||
elif auth_type == "proxmox":
|
||||
pv = _parse_kv("proxmox")
|
||||
headers["Authorization"] = f"PVEAPIToken={pv.get('tokenid', '')}={pv.get('secret', '')}"
|
||||
|
||||
elif auth_type == "n8n":
|
||||
key = _read(cfg["key_file"])
|
||||
headers["X-N8N-API-KEY"] = key or ""
|
||||
|
||||
elif auth_type == "session":
|
||||
global _dockhand_cookie
|
||||
if not _dockhand_cookie:
|
||||
|
|
@ -136,14 +263,9 @@ async def proxy(service: str, path: str, request: Request, _=Depends(_verify)):
|
|||
|
||||
async with httpx.AsyncClient(verify=False, timeout=30.0) as client:
|
||||
resp = await client.request(
|
||||
method=request.method,
|
||||
url=target,
|
||||
headers=headers,
|
||||
cookies=cookies,
|
||||
content=body,
|
||||
method=request.method, url=target,
|
||||
headers=headers, cookies=cookies, content=body,
|
||||
)
|
||||
|
||||
# Dockhand session expired? Re-login once.
|
||||
if auth_type == "session" and resp.status_code == 401:
|
||||
_dockhand_cookie = None
|
||||
await _dockhand_login(client)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue