150 lines
7.4 KiB
Python
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()
|