inbound-library/inbound_library/vault.py
tegwick ac8227eead Retry one dropped OpenBao request and report the last intake in health.
The bridge forward was down 2026-10-05 20:30 to about 00:20 and dropped
the first request after reconnecting; 13 timer runs reported
credential_unavailable without health showing it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

Assistant: claude-code
Assistant-Model: opus
Assistant-Process: 7780@bnt-lap001
Assistant-Session: 622cd705-cb31-4322-ba08-dd42e44fa583
2026-10-06 00:26:09 +02:00

80 lines
3.3 KiB
Python

"""Unattended OpenBao credential fetch through an AppRole.
Each run logs in with the delivered role_id/secret_id, reads one KV v2 entry
and revokes the token. Values stay in memory for the run. They are never logged,
printed or written to disk.
"""
import json
import os
import urllib.error
import urllib.request
from pathlib import Path
class CredentialUnavailable(Exception):
"""The credential could not be obtained. The message never contains a secret."""
def _call(addr, method, path, token=None, body=None, timeout=15, attempts=2):
"""One OpenBao HTTP call. A connection error is retried once, because the
SSH bridge forward often drops the first request after it reconnects."""
for attempt in range(attempts):
try:
return _once(addr, method, path, token, body, timeout)
except _Unreachable as exc:
if attempt + 1 == attempts:
raise CredentialUnavailable(str(exc)) from None
class _Unreachable(Exception):
pass
def _once(addr, method, path, token, body, timeout):
request = urllib.request.Request(
addr.rstrip('/') + '/v1/' + path.lstrip('/'), method=method,
data=None if body is None else json.dumps(body).encode(),
headers={'Content-Type': 'application/json', **({'X-Vault-Token': token} if token else {})})
try:
with urllib.request.urlopen(request, timeout=timeout) as response:
raw = response.read()
return json.loads(raw) if raw else {}
except urllib.error.HTTPError as exc:
# Status only. The response body could echo request data.
raise CredentialUnavailable(f'OpenBao {method} {path.split("/")[0]}/… returned {exc.code}') from None
except (urllib.error.URLError, OSError) as exc:
raise _Unreachable(f'OpenBao unreachable at {addr}: {type(exc).__name__}') from None
def kv_v2_data_path(kv_path):
mount, _, rest = kv_path.partition('/')
if not rest:
raise ValueError('kv_path must be <mount>/<path>')
return f'{mount}/data/{rest}'
def fetch(spec, call=_call):
"""Return the KV fields as a dict, read through AppRole login."""
addr = os.environ.get('BAO_ADDR') or spec.get('addr', 'http://127.0.0.1:18200')
approle = Path(os.path.expanduser(spec.get('approle_dir', '~/.local/inbound-library/approle')))
try:
role_id = (approle / 'role_id').read_text().strip()
secret_id = (approle / 'secret_id').read_text().strip()
except OSError:
raise CredentialUnavailable(f'AppRole credentials missing in {approle}') from None
login = call(addr, 'POST', f"auth/{spec.get('approle_mount', 'approle')}/login",
body={'role_id': role_id, 'secret_id': secret_id})
token = (login.get('auth') or {}).get('client_token')
if not token:
raise CredentialUnavailable('AppRole login returned no token')
try:
read = call(addr, 'GET', kv_v2_data_path(spec['kv_path']), token=token)
data = ((read.get('data') or {}).get('data')) or {}
if not data:
raise CredentialUnavailable(f"no data at {spec['kv_path']}")
return data
finally:
try:
call(addr, 'POST', 'auth/token/revoke-self', token=token)
except CredentialUnavailable:
pass # The token expires on its own (bounded TTL and number of uses).