feat(secrets): OpenBao write path + auto-unseal on boot (Phase 4 hardening)
- SecretBackend gains write/delete/list_names; FileSecretBackend + OpenBaoBackend implement them (vault KV-v2 POST/DELETE/LIST); castle-api/secrets.py routes all CRUD through the active backend (dashboard writes to the vault in openbao mode) - add systemd exec_start_post (general; '-' prefix so a hook failure can't fail the unit); castle-openbao runs an unseal.sh on boot using OPENBAO_UNSEAL_KEY - tests: file write/read/list/delete round-trip Verified live: write→vault→read→list→delete against OpenBao; seal→restart→ auto-unsealed. TLS hardening remains (deferred; gates cross-network/real secrets).
This commit is contained in:
@@ -174,6 +174,15 @@ SuccessExitStatus=143
|
||||
reload_argv[0] = resolved_reload
|
||||
unit += f"ExecReload={' '.join(reload_argv)}\n"
|
||||
|
||||
# Post-start hooks (e.g. OpenBao auto-unseal). `-` prefix → failure is ignored,
|
||||
# so a hiccup in the hook never fails the unit.
|
||||
for cmd in (sd.exec_start_post if sd else []):
|
||||
argv = cmd.split()
|
||||
resolved = shutil.which(argv[0])
|
||||
if resolved:
|
||||
argv[0] = resolved
|
||||
unit += f"ExecStartPost=-{' '.join(argv)}\n"
|
||||
|
||||
if sd and sd.no_new_privileges:
|
||||
unit += "NoNewPrivileges=true\n"
|
||||
|
||||
|
||||
@@ -160,6 +160,9 @@ class SystemdSpec(BaseModel):
|
||||
no_new_privileges: bool = True
|
||||
readiness: ReadinessHttpGet | None = None
|
||||
exec_reload: str | None = None
|
||||
# Commands run after the main process starts (systemd ``ExecStartPost=``), one
|
||||
# line each — e.g. an OpenBao auto-unseal step. Failures don't fail the unit.
|
||||
exec_start_post: list[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
class ManageSpec(BaseModel):
|
||||
|
||||
@@ -26,10 +26,13 @@ from typing import Protocol
|
||||
|
||||
class SecretBackend(Protocol):
|
||||
def read(self, name: str) -> str | None: ...
|
||||
def write(self, name: str, value: str) -> None: ...
|
||||
def delete(self, name: str) -> None: ...
|
||||
def list_names(self) -> list[str]: ...
|
||||
|
||||
|
||||
class FileSecretBackend:
|
||||
"""Reads ``<secrets_dir>/<name>`` (the historical behavior)."""
|
||||
"""Reads/writes ``<secrets_dir>/<name>`` (the historical behavior)."""
|
||||
|
||||
def __init__(self, secrets_dir: Path) -> None:
|
||||
self._dir = secrets_dir
|
||||
@@ -40,6 +43,20 @@ class FileSecretBackend:
|
||||
return path.read_text().strip()
|
||||
return None
|
||||
|
||||
def write(self, name: str, value: str) -> None:
|
||||
self._dir.mkdir(parents=True, exist_ok=True)
|
||||
(self._dir / name).write_text(value.strip() + "\n")
|
||||
|
||||
def delete(self, name: str) -> None:
|
||||
path = self._dir / name
|
||||
if path.exists():
|
||||
path.unlink()
|
||||
|
||||
def list_names(self) -> list[str]:
|
||||
if not self._dir.exists():
|
||||
return []
|
||||
return sorted(f.name for f in self._dir.iterdir() if f.is_file())
|
||||
|
||||
|
||||
class OpenBaoBackend:
|
||||
"""Reads from an OpenBao/Vault KV-v2 mount; falls back to ``fallback``.
|
||||
@@ -66,14 +83,44 @@ class OpenBaoBackend:
|
||||
if not self._token:
|
||||
return None
|
||||
url = f"{self._addr}/v1/{self._mount}/data/{name}"
|
||||
req = urllib.request.Request(url, headers={"X-Vault-Token": self._token})
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=5) as resp: # noqa: S310
|
||||
data = json.load(resp)
|
||||
data = self._request("GET", url)
|
||||
return data["data"]["data"].get("value")
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
def write(self, name: str, value: str) -> None:
|
||||
url = f"{self._addr}/v1/{self._mount}/data/{name}"
|
||||
self._request("POST", url, {"data": {"value": value.strip()}})
|
||||
|
||||
def delete(self, name: str) -> None:
|
||||
# Remove all versions (metadata delete), matching file-backend semantics.
|
||||
url = f"{self._addr}/v1/{self._mount}/metadata/{name}"
|
||||
self._request("DELETE", url)
|
||||
|
||||
def list_names(self) -> list[str]:
|
||||
url = f"{self._addr}/v1/{self._mount}/metadata?list=true"
|
||||
try:
|
||||
data = self._request("GET", url)
|
||||
return sorted(data.get("data", {}).get("keys", []))
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
def _request(self, method: str, url: str, body: dict | None = None) -> dict:
|
||||
payload = json.dumps(body).encode() if body is not None else None
|
||||
req = urllib.request.Request( # noqa: S310
|
||||
url,
|
||||
data=payload,
|
||||
method=method,
|
||||
headers={
|
||||
"X-Vault-Token": self._token,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
)
|
||||
with urllib.request.urlopen(req, timeout=5) as resp: # noqa: S310
|
||||
raw = resp.read()
|
||||
return json.loads(raw) if raw else {}
|
||||
|
||||
|
||||
def build_backend(secrets_dir: Path) -> SecretBackend:
|
||||
"""Construct the active secret backend from the environment."""
|
||||
|
||||
Reference in New Issue
Block a user