Drop cloudflared: direct exposure with nginx gate + hardened SSH
Goal: Remove cloudflared entirely. Expose Immich (family, no VPN), pad, and SSH directly from Tower — no Cloudflare bandwidth limits, minimal attack surface, GitHub as the SSH key trust root.
Why this shape
- Forward-auth proxies (Authelia/oauth2-proxy) break the Immich mobile app — they answer with browser redirects; the app speaks raw API. The gate must be non-interactive: a secret the app attaches to every request. Immich’s app supports custom headers natively (family phones already use this for the CF Access service token — same mechanism, new header).
- Keys-only sshd is safe to expose.
PasswordAuthentication nokills the credential-stuffing economy. Remaining risk is pre-auth sshd CVEs (e.g. regreSSHion 2024): defenses are prompt patching (unattended-upgrades) and fail2ban (rate-limits pre-auth connections — real mitigation for race-condition exploits, not just log hygiene). - Split-horizon DNS via Pi-hole makes hairpin NAT irrelevant: LAN clients resolve public hostnames to 10.0.0.100 directly; the world gets the public IP. Same hostname + cert everywhere,
ssh labneeds no LAN-detection ProxyCommand.
Architecture
Internet ──:443──> nginx TLS (*.saah.as wildcard, LE DNS-01; port 80 closed)
├── pics.saah.as → gate → Immich 127.0.0.1:2283
└── pad.saah.as → gate → pad 127.0.0.1:8000
──:22───> sshd keys-only + fail2ban
DNS: home.saah.as = A record (existing ddns, gray cloud)
pics/pad = CNAME → home.saah.as
Pi-hole : local records for all three → 10.0.0.100
Gone: cloudflared tunnel + Access + lab.saah.as CNAME
Components
nginx gate (services/nginx/public.conf): one map allows a request if the secret appears in the X-Lab-Key header, a lab_key cookie, or the URI; everything else gets return 444 (connection closed, zero bytes — scanners see nothing). The URI rule makes the cookie-setter self-gating: visiting /gate/<secret> sets the cookie for browsers (web UI, pad). Secret = one long random hex for all devices; per-user identity stays Immich’s job. Server blocks proxy with websocket upgrade headers + client_max_body_size 0 (Immich uploads).
sshd hardening (/etc/ssh/sshd_config.d/10-hardening.conf — lexically first, first-match wins):
PasswordAuthentication no
KbdInteractiveAuthentication no
PermitRootLogin no
AllowUsers ns
AuthorizedKeysCommand /usr/local/bin/github-keys %u
AuthorizedKeysCommandUser nobody
GitHub trust root (/usr/local/bin/github-keys, root-owned 0755):
#!/bin/sh
[ "$1" = "ns" ] || exit 0
exec curl -sf --proto =https --max-time 5 https://github.com/Suputra.keysUnion with local authorized_keys (keeps desktop key as offline break-glass): GitHub down → no keys added, local still works; add laptop to GitHub → ssh works immediately; remove → revoked next login. Trust shift: GitHub account (2FA) is now Tower’s perimeter.
fail2ban: sshd jail, defaults. unattended-upgrades: security updates on.
Rollout (lockout-safe: tunnel stays up until direct path verified)
- Repo: nginx public.conf, sshd drop-in, github-keys script, fail2ban jail.
- Tower: install hardening + fail2ban + unattended-upgrades; restart sshd with current session held open; verify
sshd -Tand a fresh login via the still-alive tunnel. - Router: forward TCP 443 + 22 → 10.0.0.100. Verify WAN IP = public IP (no CGNAT).
- Off-LAN test (phone hotspot): direct
ssh [email protected]. - Certs: CF API token (zone DNS edit), certbot dns-cloudflare,
*.saah.aswildcard. - nginx up; test gate: bare curl → connection closed; with header → Immich.
- DNS: pics/pad CNAMEs → home.saah.as (gray); Pi-hole local records.
- Phones: swap Immich server header (was CF Access token) on family devices.
- Decommission: stop/remove cloudflared + tunnel + lab.saah.as record; delete
services/cloudflared/;Host lab→HostName home.saah.as, delete ProxyCommand. Verify mutagen sync off-LAN.
Edge cases
- LAN device with hardcoded/DoH DNS bypassing Pi-hole + router without hairpin NAT → can’t reach public hostnames from home. Known ceiling; fix per-device DNS if it bites.
- Secret rotation = regenerate hex, update nginx + each phone. Fine at family scale.
- Immich app URL stays
https://pics.saah.as— no client URL churn, only the header changes.
Shape of the diff
New files
services/nginx/public.conf— public 443 server blocks + gate mapservices/ssh/10-hardening.conf— sshd drop-in (copy to Tower)services/ssh/github-keys— AuthorizedKeysCommand scriptservices/fail2ban/jail.local— sshd jailcontent/private/notes/drop-cloudflared-direct-exposure.md— this spec
Edited files
dotfiles/ssh-config—Host lab: HostName home.saah.as, drop ProxyCommandservices/README.md— network overview: tunnel → direct exposure
Deleted
services/cloudflared/— whole directory
Manual (no repo diff): router port-forwards, CF DNS records, Pi-hole local records, certbot on Tower, family phone settings.