ra8-firmware 0.1.0
Bare-metal firmware for the Renesas RA8 family (RA8D2 / RA8P1)
Loading...
Searching...
No Matches
openbao_client.py
Go to the documentation of this file.
1# SPDX-License-Identifier: MIT
2# Copyright (c) 2026 Brighton Sikarskie
3"""Shared HTTP client for the EXISTING OpenBao server.
4
5This does NOT run, embed, or spin up a vault. It is a thin urllib client
6(no `bao` CLI needed) that talks to the OpenBao server you already run --
7the k3s pod reached at BAO_ADDR (e.g. http://100.64.0.1:32200). It performs
8AppRole login plus KV v2 read / write / metadata, so the same operator
9identity backs both the HIL Tapo secrets (hil_secrets.py) and the
10root-of-trust key store (rot_keystore.py). No server = fall back to the
11local path (see rot_keystore.py's `local` backend / the .env pattern).
12
13Consumer config comes from an operator-controlled 0600 creds file (default
14~/.config/hil/openbao.env, override with HIL_OPENBAO_ENV) or the matching
15environment variables (e.g. CI injecting GitHub Actions secrets):
16
17 BAO_ADDR base URL, e.g. http://100.64.0.1:32200
18 ROLE_ID AppRole role id
19 SECRET_ID AppRole secret id
20 BAO_APPROLE_PATH auth mount (default "approle")
21 BAO_KV_MOUNT KV v2 mount (default "secret")
22
23The file never holds the secrets themselves -- only how to reach the vault
24and the AppRole identity.
25"""
26
27from __future__ import annotations
28
29import json
30import os
31import urllib.error
32import urllib.request
33from pathlib import Path
34
35_DEFAULT_CREDS = Path.home() / ".config" / "hil" / "openbao.env"
36_HTTP_TIMEOUT_S = 6.0
37_GET_ARG_COUNT = 4
38
39
40class OpenBaoError(RuntimeError):
41 """Any OpenBao transport, auth, or KV error (never leaks secret values)."""
42
43
44def creds_path() -> Path:
45 """Return the consumer creds file path, honouring HIL_OPENBAO_ENV."""
46 override = os.environ.get("HIL_OPENBAO_ENV", "").strip()
47 return Path(override) if override else _DEFAULT_CREDS
48
49
50def _parse_env_file(path: Path) -> dict[str, str]:
51 out: dict[str, str] = {}
52 # External, non-repo file: decode permissively rather than ASCII-strict.
53 for raw in path.read_text(encoding="utf-8").splitlines():
54 line = raw.strip()
55 if not line or line.startswith("#") or "=" not in line:
56 continue
57 key, val = line.split("=", 1)
58 out[key.strip()] = val.strip()
59 return out
60
61
62def load_config() -> dict[str, str]:
63 """Load consumer config from the 0600 creds file, else the environment."""
64 path = creds_path()
65 if path.exists():
66 return _parse_env_file(path)
67 keys = ("BAO_ADDR", "ROLE_ID", "SECRET_ID", "BAO_APPROLE_PATH", "BAO_KV_MOUNT")
68 return {k: os.environ[k] for k in keys if k in os.environ}
69
70
71def _http_json(
72 url: str,
73 headers: dict[str, str],
74 payload: dict | None = None,
75 method: str | None = None,
76) -> dict:
77 if not url.startswith(("http://", "https://")):
78 msg = "refusing non-http(s) OpenBao URL"
79 raise OpenBaoError(msg)
80 body = json.dumps(payload).encode("ascii") if payload is not None else None
81 verb = method or ("POST" if payload is not None else "GET")
82 hdrs = {"Content-Type": "application/json"} if body is not None else {}
83 hdrs.update(headers)
84 # Scheme guarded above; BAO_ADDR comes from a local 0600 operator-controlled
85 # creds file, never untrusted input.
86 req = urllib.request.Request( # noqa: S310 -- HTTPS scheme validated above
87 url, data=body, headers=hdrs, method=verb
88 )
89 try:
90 with urllib.request.urlopen( # noqa: S310 -- request URL validated above
91 req, timeout=_HTTP_TIMEOUT_S
92 ) as resp:
93 raw = resp.read().decode("utf-8")
94 except urllib.error.HTTPError as exc:
95 msg = f"OpenBao HTTP {exc.code} for {verb} {url}"
96 raise OpenBaoError(msg) from exc
97 except (urllib.error.URLError, TimeoutError, OSError) as exc:
98 msg = f"OpenBao unreachable: {exc}"
99 raise OpenBaoError(msg) from exc
100 return json.loads(raw) if raw else {}
101
102
103class OpenBaoClient:
104 """AppRole-authenticated KV v2 client for a single OpenBao server."""
105
106 def __init__(self, cfg: dict[str, str] | None = None) -> None:
107 """Read connection settings from ``cfg``, or from the environment when None.
108
109 Performs NO I/O and never raises on a missing setting -- every field
110 defaults to empty. Reachability is a separate question answered by
111 ``configured`` and ``login``, which is what lets a caller construct a
112 client and then decide to fall back to a local store instead.
113 """
114 cfg = cfg if cfg is not None else load_config()
115 self.addr = cfg.get("BAO_ADDR", "").rstrip("/")
116 self.role_id = cfg.get("ROLE_ID", "")
117 self.secret_id = cfg.get("SECRET_ID", "")
118 self.approle = cfg.get("BAO_APPROLE_PATH", "approle")
119 self.mount = cfg.get("BAO_KV_MOUNT", "secret")
120 self._token: str | None = None
121
122 @property
123 def configured(self) -> bool:
124 """True when address + AppRole identity are all present."""
125 return bool(self.addr and self.role_id and self.secret_id)
126
127 def login(self) -> str:
128 """AppRole login; caches and returns the client token."""
129 if self._token:
130 return self._token
131 if not self.configured:
132 msg = "OpenBao not configured (missing BAO_ADDR / ROLE_ID / SECRET_ID)"
133 raise OpenBaoError(msg)
134 resp = _http_json(
135 f"{self.addr}/v1/auth/{self.approle}/login",
136 {},
137 {"role_id": self.role_id, "secret_id": self.secret_id},
138 )
139 token = resp.get("auth", {}).get("client_token")
140 if not token:
141 msg = "OpenBao login returned no client_token"
142 raise OpenBaoError(msg)
143 self._token = token
144 return token
145
146 def _mount(self, mount: str | None) -> str:
147 return mount or self.mount
148
149 def kv_get(self, path: str, version: int | None = None, mount: str | None = None) -> dict:
150 """Return one KV v2 version's secret data (latest unless version given)."""
151 url = f"{self.addr}/v1/{self._mount(mount)}/data/{path}"
152 if version is not None:
153 url = f"{url}?version={int(version)}"
154 resp = _http_json(url, {"X-Vault-Token": self.login()})
155 return resp.get("data", {}).get("data", {})
156
157 def kv_put(self, path: str, data: dict, mount: str | None = None) -> int:
158 """Write a new KV v2 version; return the created version number."""
159 resp = _http_json(
160 f"{self.addr}/v1/{self._mount(mount)}/data/{path}",
161 {"X-Vault-Token": self.login()},
162 {"data": data},
163 )
164 return int(resp.get("data", {}).get("version", 0))
165
166 def kv_metadata(self, path: str, mount: str | None = None) -> dict:
167 """Return KV v2 metadata (per-version created_time, custom_metadata)."""
168 url = f"{self.addr}/v1/{self._mount(mount)}/metadata/{path}"
169 try:
170 resp = _http_json(url, {"X-Vault-Token": self.login()})
171 except OpenBaoError as exc:
172 if "HTTP 404" in str(exc):
173 return {}
174 raise
175 return resp.get("data", {})
176
177 def kv_put_metadata(
178 self,
179 path: str,
180 custom_metadata: dict[str, str],
181 mount: str | None = None,
182 ) -> None:
183 """Set path-level custom_metadata (tags) on a KV v2 secret."""
184 _http_json(
185 f"{self.addr}/v1/{self._mount(mount)}/metadata/{path}",
186 {"X-Vault-Token": self.login()},
187 {"custom_metadata": custom_metadata},
188 )
189
190
191if __name__ == "__main__":
192 import sys
193
194 if len(sys.argv) >= _GET_ARG_COUNT and sys.argv[1] == "get":
195 req_path = sys.argv[2].removeprefix("secret/")
196 req_key = sys.argv[3]
197 try:
198 client = OpenBaoClient()
199 if not client.configured:
200 sys.exit(1)
201 secret_data = client.kv_get(req_path)
202 if req_key in secret_data:
203 print(secret_data[req_key])
204 sys.exit(0)
205 except (OpenBaoError, OSError):
206 sys.exit(1)
207 sys.exit(1)