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>
152 lines
6.0 KiB
Python
152 lines
6.0 KiB
Python
"""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 = <hostname>.
|
|
|
|
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]}')
|