← Back to dashboard
remote-accesstoolingfreshReader view (for NotebookLM)

Remote Access Integration Guide

What is remote access?

The real model

Identity over network position: keys, least-privilege tunnels, zero-trust mesh, audit.

The design principle is identity over location — prove who you are (Ed25519 keys, MFA, an IdP) and open the minimum path. SSH `-L`/`-R` forward arbitrary TCP through an encrypted channel; `ProxyJump` chains through a bastion so only one box has a public port. ngrok / `cloudflared` expose localhost over HTTPS — the exact Daraja-callback workflow, since Safaricom only POSTs to public HTTPS URLs. Tailscale/WireGuard replace open ports with an identity-gated mesh — ideal for administering a fleet of Raspberry Pis with SSH never touching the public internet.

Six remote-access primitives

Pick by the shape of the problem: shell, expose-localhost, private mesh, or desktop.

Text
██████╗ ███████╗███╗   ███╗ ██████╗ ████████╗███████╗     █████╗  ██████╗ ██████╗███████╗███████╗███████╗
██╔══██╗██╔════╝████╗ ████║██╔═══██╗╚══██╔══╝██╔════╝    ██╔══██╗██╔════╝██╔════╝██╔════╝██╔════╝██╔════╝
██████╔╝█████╗  ██╔████╔██║██║   ██║   ██║   █████╗      ███████║██║     ██║     █████╗  ███████╗███████╗
██╔══██╗██╔══╝  ██║╚██╔╝██║██║   ██║   ██║   ██╔══╝      ██╔══██║██║     ██║     ██╔══╝  ╚════██║╚════██║
██║  ██║███████╗██║ ╚═╝ ██║╚██████╔╝   ██║   ███████╗    ██║  ██║╚██████╗╚██████╗███████╗███████║███████║
╚═╝  ╚═╝╚══════╝╚═╝     ╚═╝ ╚═════╝    ╚═╝   ╚══════╝    ╚═╝  ╚═╝ ╚═════╝ ╚═════╝╚══════╝╚══════╝╚══════╝

Remote Access Integration Guide

Focus: securely reaching a machine or service that isn't directly exposed — a box behind a firewall, a localhost dev server Daraja needs to call, a Raspberry Pi on the office LAN, an Ubuntu distro inside WSL. Deep on SSH (keys, ~/.ssh/config, bastions, agent, forwarding, hardening), then tunnels, mesh VPN, and remote dev. Grounded in the OpenSSH man pages, ngrok, Cloudflare and Tailscale docs; reviewed 2026-08-23 against OpenSSH 10.5p1.

Table of contents

  1. Overview & decision tree
  2. SSH keys
  3. The SSH agent (and why forwarding is dangerous)
  4. ~/.ssh/config — host blocks
  5. Bastions & ProxyJump
  6. Port forwarding: -L, -R, -D
  7. Hardening sshd
  8. SSH and WSL Ubuntu
  9. Public tunnels (ngrok, Cloudflare Tunnel)
  10. VPN & zero-trust (Tailscale, WireGuard, Access)
  11. RDP / VNC
  12. Remote development
  13. Security checklist
  14. Troubleshooting
  15. codeAmani notes

Official documentation

SourceURLWhat it covers
OpenSSH ssh manualhttps://www.man7.org/linux/man-pages/man1/ssh.1.html-L/-R/-D forwarding, -J jump host, -A, -N, -W, escape sequences
OpenSSH ssh_confighttps://www.man7.org/linux/man-pages/man5/ssh_config.5.html~/.ssh/config, ProxyJump, IdentityFile, ControlMaster
OpenSSH sshd_confighttps://www.man7.org/linux/man-pages/man5/sshd_config.5.htmlServer hardening, PerSourcePenalties, AllowUsers, Match
OpenSSH ssh-keygenhttps://www.man7.org/linux/man-pages/man1/ssh-keygen.1.htmlKey generation, fingerprints, certificates, -R
OpenSSH release noteshttps://www.openssh.org/releasenotes.htmlWhat changed per release — Ed25519 default, DSA removal, PQ key exchange
ngrok getting startedhttps://ngrok.com/docs/getting-started/ngrok http, authtoken, auto-HTTPS, inspector
ngrok free plan limitshttps://ngrok.com/docs/pricing-limits/free-plan-limits/Free static dev domain, endpoint cap, browser interstitial
Cloudflare Tunnelhttps://developers.cloudflare.com/cloudflare-one/connections/connect-networks/get-started/create-local-tunnel/cloudflared tunnel create/route/run, outbound-only model
Tailscale CLIhttps://tailscale.com/kb/1080/cliup/set/status/ssh/funnel, exit nodes
Tailscale SSHhttps://tailscale.com/kb/1193/tailscale-sshtailscale set --ssh, SSH ACL grants, check mode
What is Tailscalehttps://tailscale.com/kb/1151/what-is-tailscaleWireGuard mesh, zero-trust, MagicDNS, ACLs
WSL networkinghttps://learn.microsoft.com/windows/wsl/networkingNAT vs mirrored mode, netsh portproxy, Hyper-V firewall

1. Overview & decision tree

"Remote access" is one verb — reach a thing that's elsewhere — answered by a handful of tools that trade off predictably:

  • SSH — the workhorse for administering a box you can route to. Key-based auth, an encrypted shell, and the underused superpower: port forwarding, which carries an arbitrary TCP stream through the SSH connection so a database on a private subnet looks like it's on your localhost.
  • Public tunnels (ngrok, Cloudflare Tunnel, Tailscale Funnel) — the inverse problem: you have a service on localhost and the public internet needs to reach it over HTTPS. This is exactly the M-Pesa/Daraja callback workflow — Daraja will only POST to a public HTTPS URL, and your laptop isn't one.
  • VPN & zero-trust (WireGuard, Tailscale, Cloudflare Access) — instead of exposing services one port at a time, put the machines on a private encrypted network and gate entry on identity. The firewall stays shut; access is a function of who you are, not where you are.
  • RDP/VNC — when you need a graphical desktop, not a shell. Always tunnelled, never exposed raw.
  • Remote development (VS Code Remote-SSH, Codespaces) — run your editor locally but execute on the remote box, so the code lives where the CPU, GPU, or private data is.

Security is the constant: keys not passwords, least privilege, MFA, and an audit trail.

What changed in OpenSSH recently

Folklore about SSH ages badly. The current baseline (OpenSSH 10.5p1, released 2026-08-11):

SinceChangeWhat it means for you
8.8ssh-rsa (RSA/SHA-1 signatures) disabled by defaultAncient servers may reject your RSA key — regenerate as Ed25519 rather than re-enabling SHA-1
9.5ssh-keygen generates Ed25519 by defaultssh-keygen with no flags is already the right answer; -t ed25519 is documentation, not necessity
9.8PerSourcePenalties — sshd's built-in penalty boxThe server already throttles brute-forcers before you install fail2ban
9.9 → 10.0Hybrid post-quantum key agreement mlkem768x25519-sha256 is the defaultHarvest-now-decrypt-later is covered on both ends running ≥10.0; no config needed
10.0DSA removed entirelyssh-dss keys are dead. If a device still needs DSA, it needs replacing, not a config exception

2. SSH keys

Generate

The private key never leaves your machine; only the .pub half is ever copied anywhere.

Bash
# Ed25519 — small, fast, and the ssh-keygen default since OpenSSH 9.5.
# -C is a free-text comment (shows up in authorized_keys — make it identify the key).
# -f names the file so you can keep per-purpose keys instead of one id_ed25519 for everything.
ssh-keygen -t ed25519 -C "barnabas@codeamani-laptop" -f ~/.ssh/id_ed25519

# Same, with a passphrase-hardened private key: -a sets KDF rounds (higher = slower to
# brute-force if the file is stolen). 100 is a common, comfortable value.
ssh-keygen -t ed25519 -a 100 -C "barnabas@codeamani-laptop" -f ~/.ssh/id_ed25519

# Only if a legacy appliance genuinely can't do Ed25519:
ssh-keygen -t rsa -b 4096 -C "legacy-appliance-only"

Always set a passphrase. An unprotected private key is a bearer token sitting in a file — anyone who copies it is you. The passphrase is what makes a stolen laptop a nuisance instead of a breach, and the agent (§3) means you type it once per boot.

Use one key per device, not one key per human. Losing a laptop should mean deleting one line from authorized_keys, not rotating every server you own.

Install the public half

Bash
# Easiest — needs an existing way in (password auth, or another key already installed)
ssh-copy-id -i ~/.ssh/id_ed25519.pub user@server.example.com

# Manual equivalent when ssh-copy-id isn't available (permissions matter — see below)
cat ~/.ssh/id_ed25519.pub | ssh user@server.example.com \
  'mkdir -p ~/.ssh && chmod 700 ~/.ssh && cat >> ~/.ssh/authorized_keys && chmod 600 ~/.ssh/authorized_keys'

# Verify before you disable password auth — keep the working session open!
ssh -i ~/.ssh/id_ed25519 user@server.example.com

Permissions are enforced, not advisory. ~/.ssh must be 700, private keys 600, and authorized_keys 600. If the mode is looser, ssh refuses the key with UNPROTECTED PRIVATE KEY FILE and sshd silently ignores authorized_keys. This is the #1 cause of "my key just doesn't work" — and the #1 reason not to keep keys on a Windows mount inside WSL (§8).

Host key verification (the direction people forget)

Keys prove you to the server. The host key proves the server to you — it's what stops an on-path attacker from impersonating your bastion.

Bash
# First connect is trust-on-first-use: you're shown a fingerprint and asked to accept.
# Compare it out-of-band against the server's own fingerprint before typing yes:
ssh-keygen -lf /etc/ssh/ssh_host_ed25519_key.pub    # run on the server

# Pre-seed known_hosts for CI / scripted access instead of disabling checking
ssh-keyscan -t ed25519 server.example.com >> ~/.ssh/known_hosts

# Host key changed (rebuild, re-image, new VM at the same address)? Remove the stale entry:
ssh-keygen -R server.example.com

StrictHostKeyChecking accept-new auto-accepts a first fingerprint but still refuses a changed one — a reasonable middle ground for ephemeral infra. Never StrictHostKeyChecking no: that accepts changed keys too, which is precisely the attack it exists to catch.


3. The SSH agent (and why forwarding is dangerous)

The agent holds your decrypted private key in memory so you type the passphrase once instead of on every connection.

Bash
eval "$(ssh-agent -s)"          # start one (most desktops already run it)
ssh-add ~/.ssh/id_ed25519       # unlock the key into the agent
ssh-add -l                      # list loaded keys
ssh-add -t 8h ~/.ssh/id_ed25519 # auto-expire after 8 hours
ssh-add -c ~/.ssh/id_ed25519    # require confirmation on EVERY use of this key
ssh-add -D                      # drop all keys (do this when you step away)

Set AddKeysToAgent yes in ~/.ssh/config and the key loads on first use automatically. On macOS add UseKeychain yes to persist the passphrase in the Keychain. On Windows, the agent is a service: Start-Service ssh-agent; Set-Service ssh-agent -StartupType Automatic.

Agent forwarding: what it actually risks

ForwardAgent yes / ssh -A exposes your local agent's socket on the remote host so you can hop onward using your local keys. The ssh(1) man page is blunt about the consequence:

Users with the ability to bypass file permissions on the remote host (for the agent's Unix-domain socket) can access the local agent through the forwarded connection. An attacker cannot obtain key material from the agent, however they can perform operations on the keys that enable them to authenticate using the identities loaded into the agent.

Read that carefully: root on the hop cannot steal your key, but for as long as your session is open they can sign with it — i.e. log in as you to every machine that key opens. On a shared or compromised bastion that's a full lateral-movement primitive.

Use ProxyJump instead (§5). It solves the same problem — reach host B via host A — without ever exposing your agent to A, because the SSH session is encrypted end-to-end from your laptop to B and A only relays bytes. Default ForwardAgent no globally, and if some workflow genuinely needs forwarding, scope it to a single trusted host and pair it with ssh-add -c so each use requires an explicit confirmation:

SSH-CONFIG
Host *
    ForwardAgent no          # default deny

Host trusted-build-box
    ForwardAgent yes         # opt in for exactly one host, deliberately

4. ~/.ssh/config — host blocks

A config block turns a long command into ssh prod, and it's where bastion routing, identity selection and keepalives live. Every tool that shells out to ssh — git, rsync, scp, VS Code Remote-SSH, Ansible — inherits it for free.

SSH-CONFIG
# ~/.ssh/config   (chmod 600)

# ── The bastion: the ONLY box with a public SSH port ──────────────────
Host bastion
    HostName bastion.example.com
    User jumpuser
    Port 22
    IdentityFile ~/.ssh/id_ed25519
    IdentitiesOnly yes            # offer ONLY this key (see gotcha below)
    ForwardAgent no

# ── A private box reachable only through the bastion ──────────────────
Host app-internal
    HostName 10.0.1.50            # private IP, no public exposure
    User appuser
    ProxyJump bastion             # SSH hops through bastion automatically
    IdentityFile ~/.ssh/id_ed25519
    IdentitiesOnly yes

# ── Pattern matching: one block for a whole fleet ─────────────────────
Host db-* cache-*
    User ops
    ProxyJump bastion
    IdentityFile ~/.ssh/id_ed25519

# ── A distinct key for GitHub (keeps work/personal identities apart) ──
Host github.com
    User git
    IdentityFile ~/.ssh/id_ed25519_github
    IdentitiesOnly yes

# ── Shared defaults. MUST BE LAST — see first-match-wins below. ────────
Host *
    AddKeysToAgent yes
    ForwardAgent no
    ServerAliveInterval 60        # ping every 60s...
    ServerAliveCountMax 3         # ...give up after 3 misses (~3 min dead link)
    ControlMaster auto            # reuse one TCP+auth session for repeat connects
    ControlPath ~/.ssh/cm-%r@%h:%p
    ControlPersist 10m

Three things worth internalising:

  • First match wins, per keyword. ssh_config takes the first value it obtains for each parameter, so a Host * block placed at the top silently freezes every later override. Specific hosts first, Host * at the bottom — always.
  • IdentitiesOnly yes fixes "Too many authentication failures". Without it, ssh offers every key in your agent in turn; servers with the default MaxAuthTries 6 cut you off before reaching the right one. IdentitiesOnly restricts the offer to the IdentityFile you named.
  • ControlMaster makes repeat connections instant. The first ssh prod authenticates; subsequent ones (including every git push and each VS Code channel) ride the existing socket. ControlPersist 10m keeps it warm after you exit. Note that a multiplexed session shares the master's fate — kill it with ssh -O exit prod.

ssh -G app-internal prints the fully-resolved config for a host — the fastest way to find out which block actually won.


5. Bastions & ProxyJump

The bastion (jump host) pattern: exactly one hardened box has a public SSH port; everything else lives on private IPs and is reached through it. One place to audit, one place to patch, one place to revoke.

SSH-CONFIG
Host app-internal
    HostName 10.0.1.50
    ProxyJump bastion
Bash
ssh app-internal                                        # config-driven
ssh -J jumpuser@bastion.example.com appuser@10.0.1.50   # one-shot equivalent
ssh -J hop1,hop2 user@final-host                        # chain multiple hops

ProxyJump is not agent forwarding. Under the hood it runs a nested ssh -W host:port on the jump host, which merely relays TCP. Your session to the final host is encrypted end-to-end from your laptop — the bastion sees ciphertext, never your keystrokes and never your agent. That property is the whole reason to prefer it.

ProxyCommand is the older, more general escape hatch (ProxyCommand ssh -W %h:%p bastion, or an AWS SSM / Cloudflare cloudflared access ssh invocation). Reach for ProxyJump unless you need something ProxyJump can't express.

On the bastion itself, keep the blast radius small: no application code, no secrets, AllowTcpForwarding yes (it needs it) but AllowAgentForwarding no, per-user accounts, and full session logging.


6. Port forwarding: -L, -R, -D

SSH's most useful and least-known feature: carry an arbitrary TCP stream through the encrypted connection. -N means "no shell, just hold the tunnel open"; add -f to background it.

Bash
# LOCAL forward (-L): pull a remote/private service onto YOUR localhost.
# Reach a Postgres on a private subnet as if it were local:5432.
ssh -N -L 5432:db.internal:5432 user@bastion.example.com
#        └local┘ └──remote target──┘
# → psql -h localhost -p 5432   now hits db.internal through the tunnel.
# The "db.internal:5432" part is resolved BY THE BASTION, not by you — which is
# why this works for hostnames that don't resolve on your machine at all.

# REMOTE forward (-R): push YOUR localhost out to a port on the remote box.
ssh -N -R 8080:localhost:3000 user@server.example.com
# → server.example.com:8080 reaches your laptop's :3000.
# By default this binds the remote's LOOPBACK only. To expose it on the remote's
# public interface the SERVER must set `GatewayPorts yes` (or `clientspecified`),
# then: ssh -N -R 0.0.0.0:8080:localhost:3000 user@server.example.com

# DYNAMIC forward (-D): a local SOCKS5 proxy that egresses from the remote host.
ssh -N -D 1080 user@bastion.example.com
# → curl --socks5-hostname localhost:1080 https://internal-dashboard.example.com
# Point a browser's SOCKS settings at localhost:1080 and every request exits
# from the bastion — the cleanest way to browse an internal admin panel without
# forwarding each service individually.

The mnemonic: -L brings something to you (Local); -R sends something out to the Remote; -D makes everything Dynamic through a SOCKS proxy.

Escape sequences (typed at the start of a line in an interactive session) save you when a forward is missing or a session hangs:

SequenceEffect
~?List all escape sequences
~COpen a command line — add a forward mid-session: -L 8080:localhost:80
~.Kill a hung session (when Ctrl-C won't work)
~&Background the session

On the server side, AllowTcpForwarding no in sshd_config disables all of this — set it on any host that has no business relaying traffic, and leave it on only for bastions.


7. Hardening sshd

The defaults in sshd_config are permissive on purpose — OpenSSH ships something that works everywhere, and expects you to lock it down. These are the directives that matter, with their documented defaults:

DirectiveDefaultSet toWhy
PasswordAuthenticationyesnoPasswords are brute-forced continuously; keys are not
KbdInteractiveAuthenticationyesnoThe forgotten back door — PAM keyboard-interactive still accepts passwords even after you disable PasswordAuthentication
PermitRootLoginprohibit-passwordnoPer-user accounts + sudo give you an audit trail
PubkeyAuthenticationyesyesKeep explicit so a future edit can't quietly flip it
MaxAuthTries63Fewer guesses per connection
AllowUsers / AllowGroupsall usersexplicit listAllowlist beats denylist
PermitEmptyPasswordsnonoAssert it
X11Forwardingno (often yes on distros)noUnused attack surface on a server
AllowTcpForwardingyesno (except bastions)Stops a compromised account pivoting through the box
AllowAgentForwardingyesnoDon't let a hop harvest visiting agents
PerSourcePenaltiesenabled (9.8+)leave onBuilt-in penalty box for crashes, auth failures, invalid users
Bash
# Edit, then ALWAYS validate before restarting
sudo nano /etc/ssh/sshd_config
sudo sshd -t                    # syntax check — silence means OK
sudo sshd -T | grep -Ei 'passwordauth|permitrootlogin|kbdinteractive'   # effective config
sudo systemctl restart ssh

# ⚠ Keep your CURRENT session open and verify a NEW one connects before closing it.
# A typo in sshd_config plus a closed last session is a locked-out server.

Two distro gotchas that waste afternoons:

  • Drop-in includes override your edits. Modern sshd_config starts with Include /etc/ssh/sshd_config.d/*.conf, and cloud images ship files there (e.g. 50-cloud-init.conf) that re-enable PasswordAuthentication yes. Because sshd takes the first value obtained, the drop-in wins over your edit further down the main file. Fix the drop-in, or add your own 99-hardening.conf, then confirm with sshd -T.
  • Ubuntu 22.10+ uses socket activation. ssh.socket owns the listening port, so Port/ListenAddress in sshd_config are ignored. Change the port with systemctl edit ssh.socket (ListenStream=), or disable the socket unit and enable ssh.service instead.

PerSourcePenalties vs fail2ban

Since OpenSSH 9.8, sshd penalises misbehaving source addresses itself — enabled by default, with per-event penalties (auth failure 5s, invalid user 5s, crash 90s, …) accumulating up to a 10-minute cap. For a single host with keys-only auth, that plus PasswordAuthentication no already removes essentially all brute-force value.

fail2ban still earns its place when you want firewall-level bans (dropping packets rather than answering them), longer ban windows, bans shared across services (SSH + nginx + postfix), or a ban list you can inspect and report on.

INI
# /etc/fail2ban/jail.local
[DEFAULT]
bantime  = 1h
findtime = 10m
maxretry = 3

[sshd]
enabled = true
# Ubuntu 24.04 / Debian 12 log SSH to journald and may have NO /var/log/auth.log.
# With the default `backend = auto`, fail2ban then reads nothing and silently
# never bans anyone. Point it at the journal explicitly:
backend = systemd
Bash
sudo systemctl restart fail2ban
sudo fail2ban-client status sshd     # verify it is actually seeing failures

Beyond authorized_keys: SSH certificates

authorized_keys sprawl is the real operational problem at fleet scale — one departing engineer means editing every server. SSH certificates invert it: a CA signs short-lived user certificates, servers trust the CA, and expiry does the revocation for you.

Bash
# On the CA host (protect this key like a root credential)
ssh-keygen -t ed25519 -f ~/ca_user_key -C "codeamani-user-ca"

# Sign a user's public key for 8 hours, valid as principal "appuser"
ssh-keygen -s ~/ca_user_key -I "barnabas@codeamani" -n appuser -V +8h \
  ~/.ssh/id_ed25519.pub          # → produces id_ed25519-cert.pub

# On every server: trust the CA instead of listing individual keys
#   /etc/ssh/sshd_config
#   TrustedUserCAKeys /etc/ssh/ca_user_key.pub

If you'd rather not run a CA, Tailscale SSH (§10) and Cloudflare Access give you the same "identity, not a file on a laptop" property as a managed service.


8. SSH and WSL Ubuntu

WSL trips people up because there are two operating systems with two separate SSH worlds on one machine. See wsl/CLAUDE_CODE_INTEGRATION.md (§7 configuration, §10 networking) for the WSL fundamentals; this section covers only the SSH-shaped parts.

SSH out of WSL (the common case)

The distro's ~/.ssh is at /home/you/.ssh inside the Linux filesystem, entirely separate from Windows' C:\Users\you\.ssh. Simplest correct answer: generate a distro-local key and treat the distro as its own device.

Bash
# Inside Ubuntu on WSL
ssh-keygen -t ed25519 -a 100 -C "barnabas@wsl-ubuntu"
ssh-copy-id -i ~/.ssh/id_ed25519.pub user@server.example.com

Do not symlink ~/.ssh to /mnt/c/Users/you/.ssh. Without [automount] options = "metadata" in /etc/wsl.conf, everything under /mnt/c reports mode 0777, and ssh refuses the key outright with UNPROTECTED PRIVATE KEY FILE. Enabling metadata (then wsl --shutdown, then chmod 600) does make it work, but you've now got one key whose permissions depend on a mount option — a fragile setup. Copying the key in and chmod 600-ing it, or issuing a separate key, is the durable choice.

If you want a single key custody point across both OSes, bridge the Windows agent into WSL with npiperelay + socat (or a wrapper like wsl2-ssh-agent) and set SSH_AUTH_SOCK in your shell rc. That keeps the private key in the Windows agent — or in 1Password/ssh-agent.exe — with WSL holding no key material at all. Worth the setup cost only if you're already curating Windows-side keys.

SSH into WSL

Two distinct problems: getting sshd running inside the distro, and making the distro reachable at all.

Bash
# 1. Install and start the server inside Ubuntu
sudo apt update && sudo apt install -y openssh-server
sudo ssh-keygen -A                      # generate host keys if the install didn't

# Avoid colliding with the Windows host's own OpenSSH server on :22
sudo sed -i 's/^#\?Port .*/Port 2222/' /etc/ssh/sshd_config
sudo sed -i 's/^#\?PasswordAuthentication .*/PasswordAuthentication no/' /etc/ssh/sshd_config
sudo sshd -t

# 2. Start it. With systemd enabled ([boot] systemd=true in /etc/wsl.conf):
sudo systemctl enable --now ssh
# Without systemd, WSL has no init — start it per session (or from ~/.bashrc):
sudo service ssh start

Reachability depends on the networking mode:

ModeReaching WSL's sshdNotes
NAT (default)From Windows itself, localhost:2222 generally works via localhost forwarding. From another machine on the LAN it does not — the distro is a VM behind NATBridge it with netsh interface portproxy add v4tov4 listenport=2222 listenaddress=0.0.0.0 connectport=2222 connectaddress=$(wsl hostname -I) — but the WSL IP changes on restart, so this needs re-running
Mirrored (networkingMode=mirrored in %UserProfile%\.wslconfig; Win 11 22H2 + WSL 2.0.9+)WSL mirrors the host's interfaces, so a LAN peer can reach the distro directlyRequires opening the Hyper-V firewall for inbound: Set-NetFirewallHyperVVMSetting -Name '{40E0AC32-46A5-438A-A0B2-2B479E8F2E90}' -DefaultInboundAction Allow, or a targeted New-NetFirewallHyperVRule per port

Remember wsl --shutdown after editing .wslconfig — the setting only applies to a fresh VM.

The codeAmani answer is usually neither. Run Tailscale inside the distro (or on the Windows host with mirrored networking) and reach it by MagicDNS name over the tailnet — no port forwarding, no IP that changes on reboot, no router config, and identity-gated access. Reserve the portproxy dance for one-off LAN testing.


9. Public tunnels (ngrok, Cloudflare Tunnel)

Daraja, Stripe, Clerk and every other webhook provider POST to a public HTTPS URL. Your dev server on http://localhost:3000 is invisible to them. A tunnel gives localhost a public HTTPS front door.

ngrok — fastest path

Bash
# One-time: register your account's authtoken
ngrok config add-authtoken <YOUR_TOKEN>

# Ephemeral URL — fine for a five-minute test
ngrok http 3000

# Better: bind your account's free STATIC dev domain so the URL survives restarts
ngrok http 3000 --url https://<YOUR-DEV-DOMAIN>.ngrok-free.app

Every ngrok account — free tier included — now gets one automatically assigned static dev domain, so the old ritual of re-registering a fresh random URL in the Daraja portal after every restart is no longer necessary. Bind it with --url and the callback URL you configured stays valid. (--subdomain and --hostname are deprecated in favour of --domain/--url.)

Free plan caveats worth knowing before you debug something that isn't broken:

  • One dev domain, with up to 3 online endpoints pointed at it.
  • HTML browser traffic gets an interstitial warning page. Machine-to-machine webhook POSTs are unaffected, but if you're eyeballing the tunnel in a browser and seeing an ngrok page instead of your app, that's why — send ngrok-skip-browser-warning as a request header, or upgrade.
  • The inspector at http://127.0.0.1:4040 replays every request/response — the single most useful thing about ngrok when a callback payload isn't what you expected.

Cloudflare Tunnel (cloudflared) — durable, no open ports

cloudflared makes an outbound-only connection to Cloudflare's edge; your firewall stays fully closed to inbound traffic, and you get a stable hostname on your own domain (which matters when a provider whitelists callback domains).

Bash
cloudflared tunnel login                                   # browser auth, picks a zone
cloudflared tunnel create daraja-dev                       # named tunnel + UUID
cloudflared tunnel route dns daraja-dev cb.codeamani.com   # map a hostname

# ~/.cloudflared/config.yml
#   tunnel: <UUID>
#   credentials-file: /home/you/.cloudflared/<UUID>.json
#   ingress:
#     - hostname: cb.codeamani.com
#       service: http://localhost:3000
#     - service: http_status:404

cloudflared tunnel run daraja-dev                          # bring it up

# Throwaway alternative — no account, no config, random *.trycloudflare.com URL:
cloudflared tunnel --url http://localhost:3000
ngrokCloudflare TunnelTailscale Funnel
Setupsecondsa few minutesseconds (if already on the tailnet)
URLfree static dev domain; custom on paidyour own domain hostname<device>.<tailnet>.ts.net
Needs a domain you ownnoyes (a Cloudflare zone)no
Inbound portsnonenone (outbound-only)none
Best forthrowaway webhook testing, request inspectionlonger-lived previews, IP/domain-restricted providerssharing a dev service you're already meshing

10. VPN & zero-trust (Tailscale, WireGuard, Access)

Tunnels and bastions expose one path at a time. A zero-trust mesh flips the model: machines join a private encrypted network and access is granted by identity + policy, not by where a packet originates.

Tailscale (managed WireGuard mesh)

Tailscale builds an encrypted peer-to-peer WireGuard mesh (a "tailnet"). No central gateway to bottleneck, no inbound ports, and devices authenticate against your existing IdP.

Bash
tailscale up                       # auth via browser/IdP, join the tailnet
tailscale status                   # peers + their 100.x.y.z addresses
tailscale ip -4                    # this device's tailnet IPv4

tailscale set --ssh                # enable identity-gated Tailscale SSH on THIS device
ssh pi@raspberry-pi                # connect by MagicDNS name — no authorized_keys at all
tailscale ssh pi@raspberry-pi      # equivalent via the Tailscale CLI

tailscale funnel 3000              # expose a local port publicly at https://<device>.<tailnet>.ts.net

tailscale set --ssh is the current documented way to turn the SSH server on (tailscale up --ssh still works and is what older docs show). Access needs both a normal ACL permitting the connection and an SSH rule in the policy file:

JSON
{
  "action": "accept",
  "src":    ["autogroup:member"],
  "dst":    ["autogroup:self"],
  "users":  ["autogroup:nonroot"]
}

"action": "check" instead of "accept" forces periodic re-authentication — sessions reset after 12 hours by default (checkPeriod). Limits to plan around: the SSH server runs on Linux and macOS open-source builds only, port 22 is assumed and not configurable, restarting tailscaled drops live sessions, and "checkPeriod": "always" will break automation like Ansible.

The free Personal plan currently covers up to 6 users with unlimited user devices, 3 ACL groups, 50 tagged resources, and Tailscale SSH on up to 5 hosts — comfortably enough for a dev fleet and a handful of Raspberry Pis. MagicDNS gives you stable names (raspberry-pi) instead of 100.x.y.z addresses; use names in scripts.

WireGuard (the raw protocol)

Tailscale is WireGuard with identity, key distribution and NAT traversal bolted on. Plain WireGuard (wg, wg-quick up wg0) is the DIY option — you manage keys and peer config yourself. Reach for it when you want a single self-hosted VPN concentrator and no third party in the path, and accept that key rotation and device revocation become your job.

Cloudflare Access (zero-trust for HTTP)

Pairs with Cloudflare Tunnel: put an internal app behind a Tunnel, then enforce an identity policy (email domain, IdP group, MFA) at Cloudflare's edge before any request reaches your origin. No VPN client, no open port — the app is private but reachable by exactly the right people. cloudflared access ssh extends the same policy layer to SSH via a ProxyCommand.


11. RDP / VNC

When you need a screen, not a shell:

  • RDP (Windows) and VNC (cross-platform) are graphical remote-desktop protocols.
  • Never expose RDP/VNC directly to the internet — RDP brute-forcing is a top ransomware entry vector. Tunnel them over SSH (ssh -N -L 5900:localhost:5900 user@host, then point the VNC client at localhost:5900) or, better, over Tailscale/WireGuard so the desktop is only reachable inside the private mesh.
  • VNC's native authentication is weak and its traffic is often unencrypted — the tunnel isn't optional hardening, it is the security.

12. Remote development

Run the editor locally, execute remotely — so code lives next to the data, GPU, or private network it needs.

  • VS Code Remote-SSH — connects using your existing ~/.ssh/config entry, so ProxyJump/bastion routing and ControlMaster just work. It installs a small server on the remote host and you edit/run/debug as if local. Zero extra infrastructure. (It opens several channels; ControlMaster auto is what keeps that fast.)
  • GitHub Codespaces — a fully managed cloud dev container, nothing of your own to administer. Good for onboarding (a new dev is coding in minutes) and for heavy builds you don't want on a laptop.
  • Claude Code over SSH — it's a terminal application, so it runs anywhere you have a shell: ssh app-internal, then claude. On Windows, running it inside WSL Ubuntu is the supported path (see wsl/CLAUDE_CODE_INTEGRATION.md §14).

13. Security checklist

Remote access is the front door to your infrastructure — treat every item as mandatory, not optional.

  • Keys, never passwords. PasswordAuthentication no and KbdInteractiveAuthentication no on every server; Ed25519 keys; every private key passphrase-protected and held by an agent.
  • One key per device, not per human. Losing a laptop should mean deleting one line, not rotating an estate.
  • Verify host keys. StrictHostKeyChecking accept-new at minimum; never no. Check the fingerprint out-of-band on first connect to anything that matters.
  • Least privilege. One bastion with a public port; everything else private via ProxyJump. Per-user accounts, PermitRootLogin no, AllowUsers allowlists.
  • No agent forwarding. ForwardAgent no globally and AllowAgentForwarding no server-side; use ProxyJump. If some workflow truly needs it, scope it to one host and add ssh-add -c.
  • Turn off forwarding where it isn't needed. AllowTcpForwarding no on every host that isn't a bastion.
  • MFA / identity. Gate SSH and internal apps behind an IdP + MFA (Tailscale SSH, Cloudflare Access) rather than a bare key on a stolen laptop.
  • Short-lived over long-lived. SSH certificates (TrustedUserCAKeys) or a managed identity layer beat authorized_keys sprawl once you pass a handful of servers.
  • No raw exposure. RDP/VNC/databases never face the internet — tunnel or mesh them.
  • Outbound-only where possible. Cloudflare Tunnel and Tailscale need zero inbound firewall rules — nothing to scan, nothing to brute-force.
  • Validate before you restart. sudo sshd -t, then confirm a new session connects before closing the one you have.
  • Rotate & revoke. Remove departed users from authorized_keys on every server and from the tailnet ACL, same day.
  • Audit. Log SSH sessions and access decisions; Tailscale and Cloudflare give you a who-reached-what trail out of the box.
  • Tunnel credentials are secrets. ngrok authtoken, cloudflared credentials JSON, WireGuard private keys, SSH CA keys → .env.local / Hazina, never committed, caught by the pre-push gitleaks gate.

14. Troubleshooting

SymptomLikely cause / fix
Permission denied (publickey)Public key not in the server's authorized_keys, or wrong user. Diagnose with ssh -vvv user@host and read which keys were offered
UNPROTECTED PRIVATE KEY FILEchmod 600 ~/.ssh/id_ed25519, chmod 700 ~/.ssh. On WSL, the key is probably on /mnt/c — move it into the Linux filesystem (§8)
Key ignored server-side, no error~/.ssh or authorized_keys too permissive on the server; sshd silently skips them. Check sudo journalctl -u ssh
Too many authentication failuresThe agent is offering every key before the right one. Add IdentitiesOnly yes + an explicit IdentityFile
REMOTE HOST IDENTIFICATION HAS CHANGEDServer rebuilt/re-imaged (or an on-path attack). Confirm the cause, then ssh-keygen -R host
Config edits have no effectFirst-match-wins: an earlier block (often Host * at the top) already set the keyword. Check with ssh -G host
sshd still accepts passwords after disablingEither KbdInteractiveAuthentication yes is still on, or a drop-in in /etc/ssh/sshd_config.d/ overrides you. Verify with sudo sshd -T | grep -i auth
Port change in sshd_config ignored (Ubuntu 22.10+)ssh.socket owns the port — systemctl edit ssh.socket and set ListenStream=
-R forward not reachable from outside the remoteRemote binds loopback by default; needs GatewayPorts yes server-side and -R 0.0.0.0:PORT:...
Connection drops after idleServerAliveInterval 60 + ServerAliveCountMax 3 in ~/.ssh/config
fail2ban never bans anything (Ubuntu 24.04)No /var/log/auth.log; set backend = systemd in the [sshd] jail
Webhook provider gets a 404 through the tunnelTunnel points at the wrong port, or cloudflared ingress falls through to http_status:404 — hostname must match exactly
ngrok shows a warning page instead of the appFree-plan browser interstitial. Harmless for webhooks; send ngrok-skip-browser-warning to bypass
Can't reach WSL's sshd from another machineDefault NAT mode. Use networkingMode=mirrored + a Hyper-V firewall rule, or netsh interface portproxy (§8)
Tailscale SSH refuses the connectionMissing the SSH rule in the policy file — a normal ACL grant alone isn't enough

15. codeAmani notes

  • Daraja callback tunneling is the everyday use case. Daraja only POSTs to public HTTPS. Locally: ngrok http 3000 --url https://<your-dev-domain>.ngrok-free.app — bind the free static dev domain so the callback URL you register survives restarts (the old "re-register a new random URL every time" ritual is obsolete). Test against sandbox shortcode 174379 / test phone 254708374149 before going live. For a hostname a provider can whitelist, use cloudflared mapped to cb.codeamani.com → http://localhost:3000. Either way the callback lands on app/api/mpesa/callback/route.ts — and Daraja callbacks are effectively single-shot, so dedupe on CheckoutRequestID and pair with a status-query reconciliation job (see MPESA_PATTERNS.md and the webhooks guide).
  • One key per device, ~/.ssh/config for everything else. Every codeAmani machine gets its own Ed25519 key with a passphrase; ~/.ssh/config carries IdentitiesOnly yes, ForwardAgent no, ControlMaster auto, and ProxyJump for anything private. examples/ssh-config in this folder is the template to copy.
  • WSL is a separate device. The Ubuntu distro on a Windows workstation gets its own key (barnabas@wsl-ubuntu), not a symlink to C:\Users\…\.ssh — /mnt/c can't hold 600 without the metadata mount option, and ssh refuses the key. Cross-ref wsl/CLAUDE_CODE_INTEGRATION.md for the networking modes; §8 above covers the SSH specifics.
  • Raspberry Pi administration. A Pi on the office LAN should not have SSH exposed to the internet. Put it on the tailnet (tailscale set --ssh on the Pi) and reach it as ssh pi@raspberry-pi from anywhere — identity-gated, no router port-forwarding, no dynamic-DNS hacks. The raspberry-pi guide covers the device side.
  • Zero-trust over open ports. For any internal tool (a staging dashboard, an admin panel), prefer Cloudflare Tunnel + Access or Tailscale over opening a port. The firewall stays shut and access is a function of identity — the same posture as Clerk-everywhere on the app side.
  • Bastion pattern for managed DBs. When a Supabase/Neon-style resource sits behind a private network, ssh -N -L 5432:db.internal:5432 user@bastion gives you local psql/migration access through an encrypted tunnel — no need to widen the DB's IP allowlist. Tear the tunnel down when the migration finishes.
  • Secrets hygiene. cloudflared credential JSONs, ngrok authtokens, WireGuard private keys and SSH CA keys are credentials. They live in .env.local / Hazina and are caught by the pre-push gitleaks gate — never pasted in chat, never committed.