Use a Bastion Host for Reverse SSH Access Behind CGNAT

This runbook applies to a specific learning and fallback design: Starlink’s default IPv4 CGNAT, a Netgate 2100 LAN4 DMZ on 192.168.60.0/24, and a Raspberry Pi 5 at 192.168.60.10. Nginx and cloudflared provide the public web path, while Tailscale is the normal private administration path. A reverse SSH connection to an independently administered public bastion is deliberately secondary. There is no inbound Starlink or pfSense WAN forward for SSH.

CGNAT is a connectivity fact, not an authorization model. The Pi may initiate an outbound SSH session to a bastion, but that does not justify exposing the tunnel listener, trusting the whole DMZ, or replacing Tailscale. Keep the bastion’s listener on loopback and require a second authenticated hop.

Map the actual boundary

ComponentRole and addressDo not do
StarlinkDefault IPv4 CGNAT; unsolicited inbound IPv4 is not the access plan.Do not chase an inbound Starlink port forward.
Netgate 2100LAN4 DMZ gateway for 192.168.60.0/24.Do not add a WAN pass or port forward for SSH, tunnel port, or the Nginx origin.
Raspberry Pi 5192.168.60.10; Nginx local origin and outbound connectors.Do not expose its LAN4 address or turn the DMZ into a trusted route.
Cloudflare TunnelPublic web publication from outbound cloudflared connections.Do not use the public web hostname for ordinary SSH.
TailscaleNormal private administrator path to the Pi.Do not weaken its grants or host firewall because a relay adds latency.
Public bastionIndependent fallback; its public SSH is hardened and its reverse listener is bastion-loopback only.Do not let the same operator and key control both ends without review.

Prefer the normal paths

Use Tailscale for named administrators reaching the Pi’s private SSH service. It is designed for identity-aware encrypted connectivity through NAT and may use a direct path or an encrypted DERP relay. Use Cloudflare Tunnel for the approved public web application, with Cloudflare Access where appropriate; it publishes HTTP(S) behavior and does not authorize a shell or ordinary SSH. Keep Nginx on its reviewed local bind and let cloudflared reach only that web origin.

The reverse SSH path is for a documented outage, controlled experiment, or a dependency that cannot use the normal overlay. Record why Tailscale or another private overlay cannot satisfy the requirement, who independently operates the bastion, what service is forwarded, and when the exception expires.

Allow only the Pi’s outbound carrier

On Netgate LAN4, allow the Pi’s outbound TCP/22 to the bastion’s documented public address or approved egress destination, after private-network blocks. Keep the rule stateful, logged, and narrow. Do not use a broad “LAN4 to any” rule merely because the bastion address can change; manage the bastion address as an owned dependency and revisit it when it changes. Preserve the existing blocks for trusted LAN, management, storage, backup, firewall interfaces, RFC1918, and IPv6 private destinations.

# Evidence to collect on the Pi and Netgate during a controlled test.
ip route
ss -ntp
sudo journalctl -u ssh-reverse-tunnel.service --since "15 minutes ago" --no-pager
sudo ss -lntp

On the Pi, the SSH client should be an unprivileged service identity. On the firewall, a successful outbound state is not an inbound WAN authorization. Test that a Pi attempt to the trusted LAN, Netgate management address, and unapproved DMZ service is still blocked and logged.

Build the bastion-side exception

The bastion operator should create a separate tunnel account, put it in an otherwise-unused ssh-tunnel group, and permit only a loopback listener. The group pattern below denies forwarding for every account that is not in that dedicated group; keep the group membership reviewable and do not add interactive users to it:

sudo groupadd --system ssh-tunnel
sudo usermod --append --groups ssh-tunnel tunnel

# /etc/ssh/sshd_config.d/40-forwarding.conf
GatewayPorts no

# The positive wildcard plus negation matches every user except members
# of ssh-tunnel. Keep this block before the tunnel exception.
Match Group *,!ssh-tunnel
    AllowTcpForwarding no
    DisableForwarding yes

Match User tunnel
    AllowTcpForwarding remote
    PermitListen 127.0.0.1:2222

Match all

OpenSSH uses the first obtained value for most sshd settings. A global AllowTcpForwarding no would therefore prevent a later Match User tunnel from changing it to remote; do not use that invalid override pattern. Here, ordinary users match the first block and receive the deny values, while tunnel is excluded from it and receives only remote forwarding plus the exact loopback PermitListen value. DisableForwarding is not set for the tunnel account, so -N remote forwarding remains operational. The trailing Match all prevents subsequent settings in the file from accidentally staying in the exception block.

Before reloading, verify both effective configurations with the exact connection context:

sudo sshd -t
sudo sshd -T -C user=tunnel,host=bastion.example.net,addr=198.51.100.20 \
  | grep -E '^(allowtcpforwarding|gatewayports|permitlisten|disableforwarding)'
sudo sshd -T -C user=bastion-admin,host=bastion.example.net,addr=198.51.100.20 \
  | grep -E '^(allowtcpforwarding|gatewayports|permitlisten|disableforwarding)'

The tunnel result must show allowtcpforwarding remote, gatewayports no, and the exact permitlisten endpoint; the ordinary account must show forwarding disabled. Replace the example user and source address with the actual inventory values. Preserve a known-good recovery session and confirm an out-of-band console or trusted recovery path before reloading sshd. Use an authorized_keys entry with no shell, PTY, agent, X11, or user startup file and the exact permit-listen restriction:

no-pty,no-agent-forwarding,no-X11-forwarding,no-user-rc,command="/bin/false",permitlisten="127.0.0.1:2222" ssh-ed25519 [approved-public-key-recorded-in-key-management] pi-reverse-tunnel

The bracketed marker is illustrative only. Install the actual public key through the bastion operator’s controlled key process. Do not use restrict in this particular line because it also disables the port forwarding that the exception needs. The bastion must use a current, patched OpenSSH release, host firewall rules, MFA or certificates for administrators, logging, and rate limits.

Run the Pi tunnel and require two hops

# On the Pi, as the dedicated service identity.
ssh -N -T \
  -o ExitOnForwardFailure=yes \
  -o ServerAliveInterval=30 \
  -o ServerAliveCountMax=3 \
  -o StrictHostKeyChecking=yes \
  -o UserKnownHostsFile=/var/lib/ssh-tunnel/.ssh/known_hosts \
  -i /var/lib/ssh-tunnel/.ssh/id_ed25519 \
  -R 127.0.0.1:2222:127.0.0.1:22 tunnel@bastion.example.net

The Pi opens the outbound carrier, the bastion listens only on its own loopback, and the Pi then connects to its local SSH service when a connection arrives. The administrator’s path has two separate authentications:

ssh -J bastion-admin@bastion.example.net \
  -p 2222 pi-admin@127.0.0.1

The first identity is authorized by the independently administered bastion. The second is authorized by the Pi. Neither hop is replaced by the tunnel key. Verify that ss -lntp on the bastion shows 127.0.0.1:2222, never a public or wildcard bind. A fixed port is used here for auditability; if port 0 is used, securely distribute the allocated port and add an equivalent reviewed control.

Compare the access choices

PathBest fitBoundary and operational cost
TailscaleNormal named private administration to the Pi.Tailnet grants, device lifecycle, host firewall, and provider/control-plane dependency; direct or DERP transport is expected.
Cloudflare TunnelPublic web publication and Access-protected browser applications.Outbound connector and web identity policy; not a general SSH or arbitrary TCP authorization by default.
Reverse SSH to bastionDeliberate fallback, learning, or a narrowly scoped legacy dependency.Two SSH trust domains, a public bastion to patch and monitor, key rotation, egress rules, and tunnel-specific incident response.

Do not choose the reverse tunnel simply because it is familiar. Its independent bastion operator is a security benefit only if that independence is real: separate administrative accounts, host-key verification, change review, and access logs.

Test outage and revoke paths

  1. With Tailscale healthy, confirm it remains the documented route and the fallback service is stopped.
  2. During a scheduled exercise, stop or isolate Tailscale and verify only the named fallback administrators can complete both hops.
  3. Confirm the Pi’s LAN4 egress permits the bastion carrier but blocks trusted, management, storage, backup, and unapproved private destinations.
  4. Stop the service, remove the forwarding key on the bastion, terminate the carrier, and verify that the loopback listener and second hop fail.
  5. Restore the normal Tailscale path, disable the fallback unless still approved, and remove temporary firewall or service changes.

Keep the bastion’s logs, Pi service logs, Netgate counters, and identity events correlated. A public web request through cloudflared must not be confused with an SSH connection, and neither should be used to justify a WAN pass.

Continue with Detect, Restrict, and Respond to Unauthorized SSH Tunnels to operate the boundary.

Sequence navigation

Previous: Run a Persistent Reverse SSH Tunnel Safely · Next: Detect, Restrict, and Respond to Unauthorized SSH Tunnels

Official references