dpl/scripts/hn/gateway/install.py

106 lines
7.5 KiB
Python
Executable File

#!/usr/bin/env python3
"""Guarded HN gateway first deployment; never operates existing DPL/FUC containers."""
from pathlib import Path
import argparse,hashlib,ipaddress,json,os,shutil,socket,subprocess,sys,time
BASE=Path(__file__).resolve().parent
STATE=Path('/var/lib/hn-sgw')
BACKUP=Path('/root/codex-backups/hn-gateway/20261027-first-install')
CERT=Path('/etc/hn-gateway-letsencrypt')
HOST=Path('/etc/nginx/conf.d/hn-subscribe.conf')
ORIGINAL=['dpl-subscribe-gateway','dpl-subscribe-admin','subscribe-gateway','subscribe-admin']
NEW=['hn-subscribe-gateway','hn-subscribe-admin']
def run(args,**kwargs):return subprocess.run(args,check=True,**kwargs)
def output(args):return subprocess.check_output(args,text=True)
def write(path,data,mode=0o644):
p=Path(path);assert not p.exists(),str(p);p.parent.mkdir(parents=True,exist_ok=True);p.write_text(data);p.chmod(mode)
def original_state():
containers=json.loads(output(['docker','inspect',*ORIGINAL]))
return {'containers':{x['Name']:{'id':x['Id'],'started':x['State']['StartedAt'],'restarts':x['RestartCount'],'running':x['State']['Running']}for x in containers},'nginx':{str(p):hashlib.sha256(p.read_bytes()).hexdigest()for p in Path('/etc/nginx/conf.d').glob('*.conf')if p!=HOST}}
def verify_original():
before=json.loads((BACKUP/'protected-baseline.json').read_text());now=original_state()
assert now['containers']==before['containers'],'Original containers changed; inspect before proceeding'
assert all(now['nginx'].get(p)==h for p,h in before['nginx'].items()),'Original Nginx configuration changed'
print('DPL/FUC original container IDs, start times and Nginx configurations unchanged.')
def compose(*args):run(['docker','compose','-f',str(BASE/'gateway.compose.yml'),*args],cwd=BASE)
def prepare():
assert not STATE.exists() and not CERT.exists() and not HOST.exists()
assert not BACKUP.exists()
for name in NEW:
assert subprocess.run(['docker','container','inspect',name],stdout=subprocess.DEVNULL,stderr=subprocess.DEVNULL).returncode!=0
for port in [28080,28443,26444]:
with socket.socket()as s:s.bind(('127.0.0.1',port))
available=int(next(line.split()[1]for line in Path('/proc/meminfo').read_text().splitlines()if line.startswith('MemAvailable:')))
assert available>=300*1024,'Insufficient available memory for two bounded HN containers'
assert shutil.disk_usage('/').free>=1024**3
ids=output(['docker','network','ls','-q']).split()
networks=json.loads(output(['docker','network','inspect',*ids]))
proposed=ipaddress.ip_network('172.28.32.0/24')
for n in networks:
for c in n.get('IPAM',{}).get('Config')or[]:
if c.get('Subnet'):
existing=ipaddress.ip_network(c['Subnet']);assert existing.version!=4 or not proposed.overlaps(existing),'HN subnet collision'
for image in ['sha256:d8807a5b2442bc25ce4fbc1312354b9dcbb0f538d75181ef735f4ea980760e70','sha256:31ff8ea3e8f958a835edbd1579709017da75b742ff3a5984ea59d12ea0775577']:
run(['docker','image','inspect',image],stdout=subprocess.DEVNULL)
BACKUP.mkdir(parents=True,mode=0o700);BACKUP.chmod(0o700)
write(BACKUP/'protected-baseline.json',json.dumps(original_state(),indent=2),0o600)
for folder in [STATE,STATE/'acme',STATE/'ssl',STATE/'certbot',Path('/var/log/hn-gateway-certbot')]:folder.mkdir(mode=0o755,parents=True,exist_ok=False)
CERT.mkdir(mode=0o700)
# Reuse the same host's existing registered ACME account; never copy or run global renewal hooks.
shutil.copytree('/etc/dpl-letsencrypt/accounts',CERT/'accounts')
for parent,dirs,files in os.walk(CERT/'accounts'):
os.chmod(parent,0o700)
for f in files:os.chmod(Path(parent)/f,0o600)
write('/etc/nginx/hn-cloudflare-realip.conf',Path('/etc/nginx/dpl-cloudflare-realip.conf').read_text())
write('/etc/nginx/hn-subscribe-proxy.conf',(BASE/'proxy.conf').read_text())
http=(BASE/'host.conf').read_text().split('\nserver {',1)[0]+'\n'
write(HOST,http);run(['nginx','-t']);run(['systemctl','reload','nginx']);verify_original()
print('HN HTTP ACME route prepared; existing services untouched.')
def certificate():
assert STATE.is_dir() and CERT.is_dir()
verify_original()
args=['certbot','certonly','--webroot','-w',str(STATE/'acme'),'--config-dir',str(CERT),'--work-dir',str(STATE/'certbot'),'--logs-dir','/var/log/hn-gateway-certbot','--cert-name','t34.hardnerd-nodes.com','-d','t34.hardnerd-nodes.com','-d','hardnerd-nodes.com','-d','www.hardnerd-nodes.com','--non-interactive','--no-directory-hooks']
with (BACKUP/'certificate.log').open('w')as log:run(args,stdout=log,stderr=subprocess.STDOUT)
print(output(['openssl','x509','-in',str(CERT/'live/t34.hardnerd-nodes.com/fullchain.pem'),'-noout','-dates','-ext','subjectAltName']))
def start():
verify_original();assert (CERT/'live/t34.hardnerd-nodes.com/fullchain.pem').is_file()
secret=json.load(sys.stdin)
assert isinstance(secret.get('username'),str)and isinstance(secret.get('password'),str)
assert all('\n' not in secret[k]for k in ['username','password'])
environment='ADMIN_USER='+secret['username']+'\nADMIN_PASS='+secret['password']+'\nV2B_BACKEND=https://hardnerd.net\nV2B_HOST=hardnerd.net\n'
if (STATE/'admin.env').exists():assert (STATE/'admin.env').read_text()==environment,'Existing HN credentials differ; stop for inspection'
else:write(STATE/'admin.env',environment,0o600)
for name,target in [('cert.pem',CERT/'live/t34.hardnerd-nodes.com/fullchain.pem'),('key.pem',CERT/'live/t34.hardnerd-nodes.com/privkey.pem')]:
link=STATE/'ssl'/name
if link.is_symlink():assert link.resolve()==target.resolve()
else:link.symlink_to(target)
run(['docker','volume','create','hn_sgw_config'],stdout=subprocess.DEVNULL)
directory=Path(output(['docker','volume','inspect','-f','{{.Mountpoint}}','hn_sgw_config']).strip())
if not (directory/'whitelist_ips.txt').exists():write(directory/'whitelist_ips.txt','104.168.30.40\n')
else:assert (directory/'whitelist_ips.txt').read_text()=='104.168.30.40\n'
compose('config','--quiet');compose('up','-d')
# The image downloads its public IP list on first start before creating cloud_geo.conf.
deadline=time.monotonic()+55
for name in NEW:
while subprocess.run(['docker','exec',name,'nginx','-t'],stdout=subprocess.DEVNULL,stderr=subprocess.DEVNULL).returncode:
assert time.monotonic()<deadline,'HN initialization timeout; inspect and safely resume start'
time.sleep(2)
run(['docker','exec',name,'nginx','-t'])
old=HOST.read_text();write(BACKUP/'hn-http-only.conf',old,0o600)
HOST.write_text((BASE/'host.conf').read_text())
try:run(['nginx','-t']);run(['systemctl','reload','nginx'])
except Exception:HOST.write_text(old);run(['nginx','-t']);raise
for name in ['hn-gateway-cert.service','hn-gateway-cert.timer','hn-gateway-cloudflare-ips.service','hn-gateway-cloudflare-ips.timer']:
write(Path('/etc/systemd/system')/name,(BASE/name).read_text())
run(['systemctl','daemon-reload']);run(['systemctl','enable','--now','hn-gateway-cert.timer','hn-gateway-cloudflare-ips.timer'])
write(STATE/'deployment.json',json.dumps({'commit':output(['git','-C',str(BASE),'rev-parse','HEAD']).strip(),'host':'t34.hardnerd-nodes.com','upstream':'https://hardnerd.net','containers':NEW,'admin_address':'https://127.0.0.1:26444','public_admin':False},indent=2),0o600)
verify_original();print('HN isolated gateway started; valid-token acceptance still required.')
if __name__=='__main__':
parser=argparse.ArgumentParser();parser.add_argument('phase',choices=['prepare','certificate','start','verify-original']);args=parser.parse_args()
assert os.geteuid()==0,'Run on the registered gateway host as root'
{'prepare':prepare,'certificate':certificate,'start':start,'verify-original':verify_original}[args.phase]()