dpl/scripts/gateway-admin-proxy/deploy.py

174 lines
8.9 KiB
Python

#!/usr/bin/env python3
"""Install only approved proxy files, backing up and reverting on Nginx failure."""
import argparse
import datetime
import hashlib
import importlib.util
import ipaddress
import json
import os
import pathlib
import re
import shutil
import subprocess
spec = importlib.util.spec_from_file_location('render', pathlib.Path(__file__).with_name('render.py'))
render = importlib.util.module_from_spec(spec)
spec.loader.exec_module(render)
def sha(data):
return hashlib.sha256(data).hexdigest()
def remove_legacy_wildcards(text):
spans = []
for m in re.finditer(r'(?m)^server\s*\{', text):
end, depth = m.end(), 1
while end < len(text) and depth:
depth += (text[end] == '{') - (text[end] == '}')
end += 1
block = text[m.start():end]
if re.search(r'(?m)^\s*server_name\s+\*\.tomshell\.com\.de\s*;', block):
spans.append((m.start(), end))
if len(spans) != 2:
raise RuntimeError('Expected exactly two old wildcard server blocks; refusing ambiguous edit')
for start, end in reversed(spans):
text = text[:start] + text[end:]
return text
def update_legacy_fuc_certificate(text, directory):
for directive, filename in [('ssl_certificate', 'fullchain.pem'), ('ssl_certificate_key', 'privkey.pem')]:
old = '/etc/letsencrypt/live/tomshell.com.de/' + filename
pattern = r'(?m)^(\s*' + directive + r'\s+)' + re.escape(old) + r'(\s*;)'
text, count = re.subn(pattern, lambda m: m[1] + directory + '/' + filename + m[2], text)
if count != 1:
raise RuntimeError('Expected exactly one old fuc certificate directive; refusing ambiguous edit')
return text
def main():
parser = argparse.ArgumentParser()
parser.add_argument('role', choices=['gateway', 'white'])
parser.add_argument('--stage', choices=['http', 'https'], required=True)
parser.add_argument('--skip-legacy', action='store_true')
parser.add_argument('--legacy-certificate', default='/etc/white-proxy-letsencrypt/live/tomshell.com.de')
parser.add_argument('--expected-legacy-sha256')
parser.add_argument('--expected-fuc-sha256')
parser.add_argument('--white-ip', default='104.168.30.40')
args = parser.parse_args()
ipaddress.ip_address(args.white_ip)
if not re.fullmatch(r'/[A-Za-z0-9/._-]+', args.legacy_certificate):
parser.error('Invalid certificate directory')
if os.geteuid() != 0:
parser.error('Remote deployment requires root')
os.umask(0o077)
subprocess.run(['nginx', '-t'], check=True, capture_output=True)
state_path = pathlib.Path('/var/lib/gateway-admin-proxy/state.json')
# Workers need traversal to the ACME webroot; private state stays 0600.
state_path.parent.mkdir(parents=True, mode=0o711, exist_ok=True)
state_path.parent.chmod(0o711)
old_state = json.loads(state_path.read_text()) if state_path.exists() else {'files': {}}
if old_state.get('role', args.role) != args.role:
parser.error('Deployment role differs from existing state')
if args.role == 'gateway':
legacy = ''
if args.stage == 'https':
data = json.loads(subprocess.check_output(['docker', 'inspect', 'subscribe-admin'], text=True))[0]
values = dict(x.split('=', 1) for x in data['Config']['Env'] if '=' in x)
legacy = '/' + values['ADMIN_SECRET_PATH'].strip('/') + '/'
if not re.fullmatch(r'/[A-Za-z0-9_-]+/', legacy):
parser.error('Invalid existing admin prefix')
if 'location ^~ ' + legacy not in pathlib.Path('/etc/nginx/conf.d/g1.gembox-sub.com.conf').read_text():
parser.error('Legacy admin prefix disagrees with current vhost')
files = render.render_gateway(args.stage, legacy, args.white_ip)
else:
files = render.render_white(args.stage, args.legacy_certificate, args.skip_legacy)
changes = {pathlib.Path('/etc/nginx/conf.d') / name: data.encode() for name, data in files.items()}
if args.role == 'white' and args.stage == 'https' and not args.skip_legacy:
legacy_file = pathlib.Path('/etc/nginx/conf.d/master.tomshell.my.conf')
current = legacy_file.read_bytes()
if str(legacy_file) in old_state['files']:
if sha(current) != old_state['files'][str(legacy_file)]:
parser.error('Legacy file changed after previous deployment')
changes[legacy_file] = current
else:
if not args.expected_legacy_sha256 or sha(current) != args.expected_legacy_sha256:
parser.error('Legacy file needs an exact current hash before modifying wildcard blocks')
changes[legacy_file] = remove_legacy_wildcards(current.decode()).encode()
fuc_file = pathlib.Path('/etc/nginx/conf.d/fu.tomshell.my.conf')
current = fuc_file.read_bytes()
if str(fuc_file) in old_state['files']:
if sha(current) != old_state['files'][str(fuc_file)]:
parser.error('Legacy fuc file changed after previous deployment')
changes[fuc_file] = current
else:
if not args.expected_fuc_sha256 or sha(current) != args.expected_fuc_sha256:
parser.error('Legacy fuc certificate update needs an exact current file hash')
changes[fuc_file] = update_legacy_fuc_certificate(current.decode(), args.legacy_certificate).encode()
if args.stage == 'https':
for path, data in changes.items():
if path.name in ('master.tomshell.my.conf', 'fu.tomshell.my.conf'):
continue
for cert in re.findall(rb'(?m)^\s*ssl_certificate\s+([^;]+);', data):
subprocess.run(['openssl', 'x509', '-in', cert.decode(), '-checkend', '86400', '-noout'],
check=True, capture_output=True)
if path.name == 'white-old-proxy.conf':
san = subprocess.check_output(['openssl', 'x509', '-in', cert.decode(), '-noout',
'-ext', 'subjectAltName'], text=True)
if 'DNS:*.tomshell.com.de' not in san or 'DNS:tomshell.com.de' not in san:
parser.error('Legacy proxy requires root and wildcard SANs, not a single-host certificate')
for path, data in changes.items():
if path.exists() and str(path) not in old_state['files']:
if path.name not in ('master.tomshell.my.conf', 'fu.tomshell.my.conf'):
raise RuntimeError('Unmanaged file already exists: ' + str(path))
elif path.exists() and sha(path.read_bytes()) != old_state['files'].get(str(path)):
raise RuntimeError('Managed file changed outside this deployment: ' + str(path))
timestamp = datetime.datetime.now(datetime.timezone.utc).strftime('%Y%m%d-%H%M%S-%f')
backup = pathlib.Path('/root/codex-backups/gateway-admin-proxy') / (timestamp + '-' + args.role)
backup.mkdir(parents=True, mode=0o700)
originals = {}
modes = {}
for path in changes:
originals[str(path)] = path.read_bytes() if path.exists() else None
modes[str(path)] = path.stat().st_mode & 0o777 if path.exists() else 0o644
if path.exists():
shutil.copy2(path, backup / path.name)
manifest = {'role': args.role, 'stage': args.stage,
'before': {p: sha(data) if data is not None else None for p, data in originals.items()},
'after': {str(p): sha(data) for p, data in changes.items()}, 'modes': modes}
(backup / 'manifest.json').write_text(json.dumps(manifest, indent=2))
try:
for path, data in changes.items():
temp = path.with_suffix('.conf.new')
temp.write_bytes(data)
temp.chmod(modes[str(path)])
os.replace(temp, path)
pathlib.Path(render.ACME).mkdir(parents=True, mode=0o755, exist_ok=True)
pathlib.Path(render.ACME).chmod(0o755)
subprocess.run(['nginx', '-t'], check=True, capture_output=True)
subprocess.run(['systemctl', 'reload', 'nginx'], check=True)
except Exception:
for path, data in originals.items():
target = pathlib.Path(path)
if data is None:
target.unlink(missing_ok=True)
else:
target.write_bytes(data)
target.chmod(modes[path])
subprocess.run(['nginx', '-t'], check=True, capture_output=True)
subprocess.run(['systemctl', 'reload', 'nginx'], check=True)
raise
old_state.update({'role': args.role, 'stage': args.stage, 'backup': str(backup)})
old_state['files'].update(manifest['after'])
state_new = state_path.with_suffix('.json.new')
state_new.write_text(json.dumps(old_state, indent=2))
os.replace(state_new, state_path)
print(json.dumps({'role': args.role, 'stage': args.stage, 'backup': str(backup),
'installed': [str(x) for x in changes], 'nginx_valid': True, 'reloaded': True}))
if __name__ == '__main__':
main()