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>
132 lines
5.2 KiB
Python
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()
|