3"""Shared HTTP client for the EXISTING OpenBao server.
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).
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):
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")
23The file never holds the secrets themselves -- only how to reach the vault
24and the AppRole identity.
27from __future__
import annotations
33from pathlib
import Path
35_DEFAULT_CREDS = Path.home() /
".config" /
"hil" /
"openbao.env"
40class OpenBaoError(RuntimeError):
41 """Any OpenBao transport, auth, or KV error (never leaks secret values)."""
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
50def _parse_env_file(path: Path) -> dict[str, str]:
51 out: dict[str, str] = {}
53 for raw
in path.read_text(encoding=
"utf-8").splitlines():
55 if not line
or line.startswith(
"#")
or "=" not in line:
57 key, val = line.split(
"=", 1)
58 out[key.strip()] = val.strip()
62def load_config() -> dict[str, str]:
63 """Load consumer config from the 0600 creds file, else the environment."""
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}
73 headers: dict[str, str],
74 payload: dict |
None =
None,
75 method: str |
None =
None,
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 {}
86 req = urllib.request.Request(
87 url, data=body, headers=hdrs, method=verb
90 with urllib.request.urlopen(
91 req, timeout=_HTTP_TIMEOUT_S
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 {}
104 """AppRole-authenticated KV v2 client for a single OpenBao server."""
106 def __init__(self, cfg: dict[str, str] |
None =
None) ->
None:
107 """Read connection settings from ``cfg``, or from the environment when None.
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.
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
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)
127 def login(self) -> str:
128 """AppRole login; caches and returns the client token."""
131 if not self.configured:
132 msg =
"OpenBao not configured (missing BAO_ADDR / ROLE_ID / SECRET_ID)"
133 raise OpenBaoError(msg)
135 f
"{self.addr}/v1/auth/{self.approle}/login",
137 {
"role_id": self.role_id,
"secret_id": self.secret_id},
139 token = resp.get(
"auth", {}).get(
"client_token")
141 msg =
"OpenBao login returned no client_token"
142 raise OpenBaoError(msg)
146 def _mount(self, mount: str |
None) -> str:
147 return mount
or self.mount
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", {})
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."""
160 f
"{self.addr}/v1/{self._mount(mount)}/data/{path}",
161 {
"X-Vault-Token": self.login()},
164 return int(resp.get(
"data", {}).get(
"version", 0))
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}"
170 resp = _http_json(url, {
"X-Vault-Token": self.login()})
171 except OpenBaoError
as exc:
172 if "HTTP 404" in str(exc):
175 return resp.get(
"data", {})
180 custom_metadata: dict[str, str],
181 mount: str |
None =
None,
183 """Set path-level custom_metadata (tags) on a KV v2 secret."""
185 f
"{self.addr}/v1/{self._mount(mount)}/metadata/{path}",
186 {
"X-Vault-Token": self.login()},
187 {
"custom_metadata": custom_metadata},
191if __name__ ==
"__main__":
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]
198 client = OpenBaoClient()
199 if not client.configured:
201 secret_data = client.kv_get(req_path)
202 if req_key
in secret_data:
203 print(secret_data[req_key])
205 except (OpenBaoError, OSError):