118 lines
4.9 KiB
Python
118 lines
4.9 KiB
Python
"""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)
|