106 lines
7.5 KiB
Python
Executable File
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]()
|