119 lines
4.5 KiB
Python
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}))
|