"""Expand cloudflared ingress to cover the 9 additional proxied hostnames. Mapping (per pfSense NAT discovery): ix. .5 -> 172.16.3.10:443 (IX direct, like the existing 4) git./plex./plexrequest./rmm./rmm-api./sync./rustdesk. -> 172.16.3.20:18443 via NPM secure. .2 -> 172.16.1.16:443 (unknown host, try with SNI) NPM routes on SNI, so every ingress gets originServerName = . Then flips their DNS (A 72.194.62.* proxied) -> CNAME tunnel proxied. """ import json, os, subprocess, time, urllib.request, urllib.error import paramiko, yaml ZONE = '1beb9917c22b54be32e5215df2c227ce' CF_TOKEN = os.environ.get('CF_API_TOKEN_FULL_DNS', '') if not CF_TOKEN: raise SystemExit('set CF_API_TOKEN_FULL_DNS') APPDATA = '/mnt/cache/appdata/cloudflared' # (hostname, service-url) IX = 'https://172.16.3.10:443' JNPM = 'https://172.16.3.20:18443' FULL_INGRESS = [ # Existing 4 (IX cPanel) ('azcomputerguru.com', IX), ('analytics.azcomputerguru.com', IX), ('community.azcomputerguru.com', IX), ('radio.azcomputerguru.com', IX), # New IX-origin ('ix.azcomputerguru.com', IX), # Jupiter NPM-served ('git.azcomputerguru.com', JNPM), ('plex.azcomputerguru.com', JNPM), ('plexrequest.azcomputerguru.com', JNPM), ('rmm.azcomputerguru.com', JNPM), ('rmm-api.azcomputerguru.com', JNPM), ('sync.azcomputerguru.com', JNPM), ('rustdesk.azcomputerguru.com', JNPM), # Different subnet, likely pfSense-routable ('secure.azcomputerguru.com', 'https://172.16.1.16:443'), ] NEW_HOSTS = [h for h,_ in FULL_INGRESS if h not in { 'azcomputerguru.com','analytics.azcomputerguru.com', 'community.azcomputerguru.com','radio.azcomputerguru.com' }] def cfapi(method, path, body=None): req = urllib.request.Request( f'https://api.cloudflare.com/client/v4{path}', data=json.dumps(body).encode() if body else None, method=method, headers={'Authorization': f'Bearer {CF_TOKEN}', 'Content-Type':'application/json'}, ) try: with urllib.request.urlopen(req, timeout=30) as r: return json.loads(r.read()) except urllib.error.HTTPError as e: try: return json.loads(e.read()) except: return {'success':False,'errors':[{'message':str(e)}]} # -- Jupiter SSH -- def _pwd(v): return yaml.safe_load(subprocess.run(['sops','-d',v],capture_output=True,text=True,timeout=30,check=True).stdout)['credentials']['password'] j = paramiko.SSHClient(); j.set_missing_host_key_policy(paramiko.AutoAddPolicy()) j.connect('172.16.3.20', username='root', password=_pwd('D:/vault/infrastructure/jupiter-unraid-primary.sops.yaml'), timeout=30, look_for_keys=False, allow_agent=False) def jrun(cmd, to=60): _, o, _ = j.exec_command(cmd, timeout=to) return o.read().decode('utf-8','replace') try: # Read current tunnel UUID out = jrun(f'grep "^tunnel:" {APPDATA}/config.yml') UUID = out.split(':',1)[1].strip() print(f'[INFO] tunnel UUID: {UUID}') # Build new config.yml config = f'tunnel: {UUID}\n' config += f'credentials-file: /home/nonroot/.cloudflared/{UUID}.json\n' config += 'ingress:\n' for h, svc in FULL_INGRESS: config += f' - hostname: {h}\n' config += f' service: {svc}\n' config += f' originRequest:\n' config += f' originServerName: {h}\n' config += f' noTLSVerify: true\n' config += ' - service: http_status:404\n' print('\n=== [1] write new config.yml ===') print(config) # Backup then write jrun(f'cp {APPDATA}/config.yml {APPDATA}/config.yml.bak-$(date +%Y%m%d-%H%M%S)') HEREDOC = "'EOF_CFG'" jrun(f"cat > {APPDATA}/config.yml <<{HEREDOC}\n{config}\nEOF_CFG") jrun(f'chown 65532:65532 {APPDATA}/config.yml') print('\n[OK] config.yml written') print('\n=== [2] DNS cutover for new hostnames ===') tunnel_target = f'{UUID}.cfargotunnel.com' for h in NEW_HOSTS: r = cfapi('GET', f'/zones/{ZONE}/dns_records?name={h}') if not r.get('success') or not r['result']: print(f' [SKIP] {h}: no record found') continue rec = r['result'][0] print(f' [{h}] current: type={rec["type"]} content={rec["content"]} proxied={rec["proxied"]}') if rec['type']=='CNAME' and rec['content']==tunnel_target: print(f' already tunneled, skipping') continue d = cfapi('DELETE', f'/zones/{ZONE}/dns_records/{rec["id"]}') if not d.get('success'): print(f' [FAIL delete] {d.get("errors")}') continue body = {'type':'CNAME','name':h,'content':tunnel_target,'proxied':True,'ttl':1} cr = cfapi('POST', f'/zones/{ZONE}/dns_records', body) if cr.get('success'): print(f' [OK] -> CNAME tunnel proxied') else: print(f' [FAIL create] {cr.get("errors")}') print('\n=== [3] restart cloudflared ===') print(jrun('docker restart cloudflared').rstrip()) print('\n=== [4] wait for reconnect ===') for i in range(25): time.sleep(3) logs = jrun('docker logs cloudflared 2>&1 | tail -40') conns = logs.count('Registered tunnel connection') if conns >= 4 and ('INF Starting metrics' in logs or 'initiating connection' in logs or 'Registered tunnel connection connIndex=3' in logs): print(f' [try {i+1}] {conns} connections registered') break print(f' [try {i+1}] connections: {conns}') finally: j.close() # External verification print('\n=== [5] external probe all 13 hostnames ===') for h, _ in FULL_INGRESS: try: req = urllib.request.Request(f'https://{h}/', method='HEAD', headers={'User-Agent':'Mozilla/5.0 (Windows NT 10.0; Win64; x64) Chrome/120.0'}) with urllib.request.urlopen(req, timeout=15) as r: print(f' {h:42} HTTP {r.status} {r.headers.get("Server","-")}') except urllib.error.HTTPError as e: print(f' {h:42} HTTP {e.code}') except Exception as e: print(f' {h:42} ERR {str(e)[:40]}')