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

150 lines
7.4 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 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('--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')
state_path.parent.mkdir(parents=True, mode=0o700, exist_ok=True)
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()
if args.stage == 'https':
for path, data in changes.items():
if path.name == 'master.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 != 'master.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)
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()