Files
claudetools/clients/internal-infrastructure/scripts/cloudflared-tunnel-setup/audit_proxied.py
Mike Swanson 9ab36352ae Session log: Tunnel expansion + WHM fix (ix. grey-cloud)
Audited all 25 proxied zone records and expanded tunnel ingress to cover
9 hostnames total (azcomputerguru + analytics + community + radio +
git + plexrequest + rmm + rmm-api + sync). All verified HTTP 200.

Reverted 3 hostnames to original A records after discovering they
require backend work, not tunnel changes:
- plex/rustdesk: NPM on Jupiter has no vhost for these (returned
  'tls: unrecognized name' when tunneled)
- secure: Jupiter can't route to its backend subnet 172.16.1.0/24

Reverted ix.azcomputerguru.com to DNS-only A record after user
reported :2087 WHM access broken. Cloudflare Tunnel is hostname-bound,
not port-bound, so non-standard admin ports can't pass through. Direct
NAT to 72.194.62.5 restored WHM/cPanel access.

Adds four new helper scripts under clients/internal-infrastructure/
scripts/cloudflared-tunnel-setup/ (audit_proxied, discover_backends,
expand_tunnel, revert_broken). All use SOPS vault / env var for creds.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 15:59:49 -07:00

132 lines
5.2 KiB
Python

"""Audit proxied Cloudflare hosts vs. current tunnel ingress.
For each proxied record in the zone:
- classify origin (internal LAN, public IP owned by us, external)
- test HTTPS through CF (currently 2xx/3xx/4xx/5xx?)
- cross-check against ingress list in config.yml
Flags which proxied hosts would benefit from being added to the tunnel.
"""
import json, os, re, socket, subprocess, urllib.error, urllib.request
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 env var')
# Our public IPs (from pfSense WAN)
OUR_PUBLIC_IPS = {
'72.194.62.' + str(n) for n in range(2, 11)
} | {
'70.175.28.' + str(n) for n in list(range(51, 55)) + [56, 57]
} | {'98.181.90.163'}
# Known internal LAN reachability from Jupiter (where tunnel runs)
LAN_HOSTS = {
'172.16.3.10': 'IX (cPanel/WHM)',
'172.16.3.20': 'Jupiter (this tunnel host)',
'172.16.3.22': 'gitea',
'172.16.3.29': 'UniFi OS Server VM',
'172.16.0.1': 'pfSense',
}
def cfapi(path):
req = urllib.request.Request(
f'https://api.cloudflare.com/client/v4{path}',
headers={'Authorization': f'Bearer {CF_TOKEN}'},
)
with urllib.request.urlopen(req, timeout=30) as r:
return json.load(r)
def probe(host):
"""HEAD https://host/ with a browser UA, return (status, cf_ray_or_server)."""
try:
req = urllib.request.Request(f'https://{host}/', method='HEAD',
headers={'User-Agent':'Mozilla/5.0 (Windows NT 10.0; Win64; x64) Chrome/120.0'})
with urllib.request.urlopen(req, timeout=12) as r:
return r.status, r.headers.get('Server', '-')
except urllib.error.HTTPError as e:
return e.code, e.headers.get('Server', '-') if hasattr(e,'headers') else '-'
except Exception as e:
return 'ERR', str(e)[:40]
def load_current_ingress():
"""Pull config.yml from Jupiter and return the set of hostnames already tunneled."""
creds = yaml.safe_load(subprocess.run(
['sops','-d','D:/vault/infrastructure/jupiter-unraid-primary.sops.yaml'],
capture_output=True, text=True, timeout=30, check=True,
).stdout)
c = paramiko.SSHClient(); c.set_missing_host_key_policy(paramiko.AutoAddPolicy())
c.connect('172.16.3.20', username='root', password=creds['credentials']['password'],
timeout=30, look_for_keys=False, allow_agent=False)
_, o, _ = c.exec_command('cat /mnt/cache/appdata/cloudflared/config.yml', timeout=30)
cfg = yaml.safe_load(o.read().decode())
c.close()
return {i.get('hostname') for i in cfg.get('ingress', []) if i.get('hostname')}
def classify(content, ctype):
"""Bucket the origin."""
if ctype == 'A':
if content in OUR_PUBLIC_IPS:
return 'OUR_PUBLIC_IP'
if content in LAN_HOSTS:
return 'LAN'
return 'EXTERNAL_IP'
if ctype == 'CNAME':
low = content.lower()
if low.endswith('cfargotunnel.com'):
return 'TUNNEL_CNAME'
if any(low.endswith(d) for d in [
'outlook.com','msftonline.com','microsoft.com','office.com','microsoftonline.com',
'sendgrid.net','unbouncepages.com','msp360.com','secureserver.net',
'azurestaticapps.net','azurefd.net','aws.com','acm-validations.aws','ucaasnetwork.com',
'itglue.com','manage.microsoft.com','windows.net','mtasv.net','onmicrosoft.com',
]):
return 'EXTERNAL_SAAS'
if low.endswith('azcomputerguru.com'):
return 'SELF_CNAME'
return 'EXTERNAL_CNAME'
return 'OTHER'
def main():
print('[INFO] fetching DNS records...')
a_recs = cfapi(f'/zones/{ZONE}/dns_records?type=A&per_page=100')['result']
cname_recs = cfapi(f'/zones/{ZONE}/dns_records?type=CNAME&per_page=100')['result']
all_recs = [r for r in a_recs + cname_recs if r.get('proxied')]
print(f'[INFO] {len(all_recs)} proxied records')
print('[INFO] reading current tunnel ingress...')
tunneled = load_current_ingress()
print(f'[INFO] currently tunneled hostnames: {sorted(tunneled)}')
print()
print(f'{"HOSTNAME":42} {"TYPE":6} {"TARGET":35} {"CLASS":14} {"IN_TUNNEL":10} {"HTTPS":>5} {"SERVER":10}')
print('-' * 130)
candidates = []
for r in sorted(all_recs, key=lambda x: x['name']):
name = r['name']
ctype = r['type']
content = r['content']
cls = classify(content, ctype)
in_tunnel = 'YES' if name in tunneled else ''
status, server = probe(name)
line = f'{name:42} {ctype:6} {content[:35]:35} {cls:14} {in_tunnel:10} {status!s:>5} {server[:10]:10}'
print(line)
# Candidates for tunnel: our origin (LAN or OUR_PUBLIC_IP) + not already in tunnel
if cls in ('LAN','OUR_PUBLIC_IP') and name not in tunneled:
candidates.append((name, content, cls, status))
print()
print('=' * 60)
print('CANDIDATES FOR TUNNEL INGRESS (own origin, not yet tunneled):')
print('=' * 60)
if not candidates:
print('(none)')
for name, content, cls, status in candidates:
print(f' {name:42} -> {content:20} ({cls}, currently HTTP {status})')
if __name__ == '__main__':
main()