Files
sxchnge/dev/vault.py
T

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)