Build local product stack with Vault-managed secrets (#42)
This commit is contained in:
1 parent
8e2ef7aa88
commit
184a3be413
32 files changed
+1191
-10
No files matched your search
+117
@@ -0,0 +1,117 @@
|
||||
"""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)
|
||||
Reference in new issue
Block a user