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 no kills 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 lab needs 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.keys

Union 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)

  1. Repo: nginx public.conf, sshd drop-in, github-keys script, fail2ban jail.
  2. Tower: install hardening + fail2ban + unattended-upgrades; restart sshd with current session held open; verify sshd -T and a fresh login via the still-alive tunnel.
  3. Router: forward TCP 443 + 22 → 10.0.0.100. Verify WAN IP = public IP (no CGNAT).
  4. Off-LAN test (phone hotspot): direct ssh [email protected].
  5. Certs: CF API token (zone DNS edit), certbot dns-cloudflare, *.saah.as wildcard.
  6. nginx up; test gate: bare curl → connection closed; with header → Immich.
  7. DNS: pics/pad CNAMEs → home.saah.as (gray); Pi-hole local records.
  8. Phones: swap Immich server header (was CF Access token) on family devices.
  9. Decommission: stop/remove cloudflared + tunnel + lab.saah.as record; delete services/cloudflared/; Host labHostName 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 map
  • services/ssh/10-hardening.conf — sshd drop-in (copy to Tower)
  • services/ssh/github-keys — AuthorizedKeysCommand script
  • services/fail2ban/jail.local — sshd jail
  • content/private/notes/drop-cloudflared-direct-exposure.md — this spec

Edited files

  • dotfiles/ssh-configHost lab: HostName home.saah.as, drop ProxyCommand
  • services/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.