Files
IKAR/tool/configure_caddy_ip.py
T

119 lines
4.5 KiB
Python

"""Add only IKAR's public-IP route to the existing Caddy instance."""
from pathlib import Path
import datetime, hashlib, json, os, shutil, subprocess, sys
ROOT = Path('/mnt/data/ikar_server')
IP = '195.216.241.165'
WORK = ROOT / 'shared/caddy-ip'
SITE = Path('/etc/caddy/sites/ikar-ip.caddy')
assert os.geteuid() == 0 and os.path.ismount('/mnt/data')
assert ROOT.resolve() == ROOT and Path('/etc/caddy/Caddyfile').is_file()
def command(*args):
return subprocess.check_output(args, text=True).strip()
def atomic_write(path, value, mode=0o644):
temporary = path.with_name(path.name + '.next')
temporary.write_text(value)
os.chmod(temporary, mode)
os.replace(temporary, path)
for name in ['webroot/.well-known/acme-challenge', 'certbot-config', 'certbot-work', 'certbot-logs', 'tls']:
(WORK / name).mkdir(parents=True, exist_ok=True)
for name in ['certbot-config', 'certbot-work', 'certbot-logs']:
os.chmod(WORK / name, 0o700)
baseline_path = WORK / 'baseline.json'
if not baseline_path.exists():
assert not SITE.exists(), 'A pre-existing IP site must be inspected first'
backup = ROOT / 'rollback' / ('caddy-ip-' + datetime.datetime.now(datetime.timezone.utc).strftime('%Y%m%dT%H%M%SZ'))
backup.mkdir(mode=0o700)
shutil.copytree('/etc/caddy', backup / 'etc-caddy')
atomic_write(backup / 'active-config.json', command('curl', '-fsS', 'http://127.0.0.1:2019/config/'), 0o600)
original = [Path('/etc/caddy/Caddyfile'), *sorted(Path('/etc/caddy/sites').glob('*.caddy'))]
containers = {item['Name'].lstrip('/'): {'id': item['Id'], 'startedAt': item['State']['StartedAt']}
for item in json.loads(command('docker', 'inspect', *command('docker', 'ps', '-q').splitlines()))}
baseline = {'backup': str(backup), 'files': {str(p): hashlib.sha256(p.read_bytes()).hexdigest() for p in original},
'containers': containers, 'caddyPid': command('systemctl', 'show', 'caddy', '-p', 'MainPID', '--value')}
atomic_write(baseline_path, json.dumps(baseline, indent=2), 0o600)
baseline = json.loads(baseline_path.read_text())
for name, expected in baseline['files'].items():
assert hashlib.sha256(Path(name).read_bytes()).hexdigest() == expected, f'Existing route changed: {name}'
unit = Path('/etc/systemd/system/ikar_server-ip-certificate.service')
timer = Path('/etc/systemd/system/ikar_server-ip-certificate.timer')
atomic_write(unit, f'''[Unit]
Description=IKAR public IP certificate renewal
After=caddy.service docker.service
RequiresMountsFor=/mnt/data
ConditionPathExists={WORK}/tls/current/fullchain.pem
[Service]
Type=oneshot
ExecStart=/usr/bin/python3 {ROOT}/renew_ip_certificate.py renew
Nice=15
TimeoutStartSec=300
''')
atomic_write(timer, '''[Unit]
Description=Renew IKAR public IP certificate every six hours
[Timer]
OnCalendar=*-*-* 00,06,12,18:10:00
Persistent=true
RandomizedDelaySec=300
Unit=ikar_server-ip-certificate.service
[Install]
WantedBy=timers.target
''')
subprocess.run(['systemctl', 'daemon-reload'], check=True)
subprocess.run(['systemctl', 'enable', '--now', timer.name], check=True)
phase = sys.argv[1]
assert phase in ('prepare', 'http', 'https')
if phase == 'prepare':
print(json.dumps({'certificateTimer': timer.name, 'enabled': True,
'waitsForIssuedCertificate': True, 'caddyChanged': False}))
raise SystemExit(0)
http_site = f'''http://{IP} {{
handle /.well-known/acme-challenge/* {{
root * {WORK}/webroot
file_server
}}
handle {{
redir https://{IP}{{uri}} 308
}}
}}
'''
site = http_site
if phase == 'https':
certificate = WORK / 'tls/current/fullchain.pem'
key = WORK / 'tls/current/privkey.pem'
assert certificate.is_file() and key.is_file()
subprocess.run(['openssl', 'x509', '-in', str(certificate), '-checkip', IP, '-noout'], check=True)
site += f'''
https://{IP} {{
tls {certificate} {key}
encode zstd gzip
header X-Content-Type-Options nosniff
header Referrer-Policy no-referrer
reverse_proxy 127.0.0.1:5099
}}
'''
previous = SITE.read_bytes() if SITE.exists() else None
atomic_write(SITE, site)
try:
subprocess.run(['caddy', 'validate', '--config', '/etc/caddy/Caddyfile'], check=True)
subprocess.run(['systemctl', 'reload', 'caddy'], check=True)
except Exception:
if previous is None:
SITE.unlink(missing_ok=True)
else:
atomic_write(SITE, previous.decode())
raise
assert command('systemctl', 'show', 'caddy', '-p', 'MainPID', '--value') == baseline['caddyPid']
print(json.dumps({'site': str(SITE), 'phase': phase, 'backup': baseline['backup'], 'caddyRestarted': False}))