"""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}))