"""Local Vault bootstrap API; response bodies with secrets never reach logs.""" import json import os from pathlib import Path import time import urllib.error import urllib.request STATE = Path(__file__).resolve().parents[1] / '.local' / 'dev' URL = 'http://127.0.0.1:18200/v1/' def write(path, value, mode=0o600): path.parent.mkdir(parents=True, exist_ok=True, mode=0o700) tmp = path.with_name(path.name + '.tmp') fd = os.open(tmp, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, mode) with os.fdopen(fd, 'w') as out: out.write(value) os.chmod(tmp, mode) os.replace(tmp, path) class Vault: def __init__(self): self.token = None def call(self, path, data=None, method=None, missing=False): headers = {'Content-Type': 'application/json'} if self.token: headers['X-Vault-Token'] = self.token req = urllib.request.Request(URL + path, headers=headers, data=json.dumps(data).encode() if data is not None else None, method=method) try: with urllib.request.urlopen(req, timeout=30) as response: body = response.read() return json.loads(body) if body else {} except urllib.error.HTTPError as error: if error.code == 404 and missing: return None raise RuntimeError(f'Vault {path} returned HTTP {error.code}') from None def initialize(self): for attempt in range(90): try: initialized = self.call('sys/init')['initialized'] break except (urllib.error.URLError, ConnectionError): time.sleep(1) else: raise RuntimeError('Vault did not start within 90 seconds') initfile = STATE / 'admin' / 'init.json' if not initialized: if initfile.exists(): raise RuntimeError('Vault data is missing but bootstrap material exists; restore the matching volume or explicitly reset both') init = self.call('sys/init', {'secret_shares': 1, 'secret_threshold': 1}) write(initfile, json.dumps(init)) if not initfile.exists(): raise RuntimeError('Missing .local/dev/admin/init.json for initialized Vault; restore the matching bootstrap material') init = json.loads(initfile.read_text()) self.token = init['root_token'] write(STATE / 'vault' / 'unseal', init['keys_base64'][0], 0o444) for attempt in range(60): if not self.call('sys/seal-status')['sealed']: return time.sleep(1) raise RuntimeError('Vault did not unseal within 60 seconds') def existing(self): self.token = json.loads((STATE / 'admin' / 'init.json').read_text())['root_token'] def kv(self, path): result = self.call('kv/data/' + path, missing=True) return result['data']['data'] if result else None def put(self, path, data): self.call('kv/data/' + path, {'data': data}) def ensure(self, path, factory): value = self.kv(path) if value is None: value = factory() self.put(path, value) return value def random(self, length=32): return self.call('sys/tools/random', {'bytes': length, 'format': 'hex'})['data']['random_bytes'] def mounts(self): mounts = self.call('sys/mounts')['data'] for name, kind in [('kv', 'kv'), ('database', 'database'), ('pki', 'pki')]: if name + '/' not in mounts: settings = {'type': kind} if kind == 'kv': settings['options'] = {'version': '2'} self.call('sys/mounts/' + name, settings) auth = self.call('sys/auth')['data'] if 'approle/' not in auth: self.call('sys/auth/approle', {'type': 'approle'}) def certificates(self): self.call('sys/mounts/pki/tune', {'max_lease_ttl': '87600h'}) ca = self.kv('tls') if ca is None: result = self.call('pki/root/generate/internal', {'common_name': 'SXCHNGE local development CA', 'ttl': '87600h'})['data'] ca = {'ca': result['certificate']} self.put('tls', ca) self.call('pki/roles/local', {'allowed_domains': ['localhost', 'notifications'], 'allow_bare_domains': True, 'allow_ip_sans': True, 'max_ttl': '720h'}) for name, host in [('notifications-tls', 'notifications'), ('frontend-tls', 'localhost')]: cert = self.kv(name) if cert is None or cert['expiration'] < time.time() + 7 * 86400: cert = self.call('pki/issue/local', {'common_name': host, 'ip_sans': '127.0.0.1', 'ttl': '720h'})['data'] self.put(name, cert) if name == 'frontend-tls': write(STATE / 'frontend' / 'server.crt', cert['certificate']) write(STATE / 'frontend' / 'server.key', cert['private_key']) write(STATE / 'ca.pem', ca['ca'], 0o644)