Remote Access Integration Guide
Technology: remote-access · Category: tooling · Last reviewed: 2026-08-23
Source: https://tech-stack.codeamanilabs.org/guide/remote-access
Insight:
Every tool here answers one question — how do I reach a machine that isn't in front of me — and the good answers all share a shape: identity over network position. SSH is the deep one: an Ed25519 keypair, a
~/.ssh/configblock, andProxyJumpmean exactly one box on your estate has a public port, and-L/-R/-Dcarry any TCP stream through that single encrypted channel — so a private Postgres becomeslocalhost:5432without widening one allowlist. Modern OpenSSH does more of the work for you than the folklore suggests:ssh-keygenhas defaulted to Ed25519 since 9.5, DSA is gone as of 10.0, key agreement is post-quantum hybrid by default, and sshd ships its own brute-force penalty box. The one default that still bites:sshdaccepts passwords until you turn them off.
██████╗ ███████╗███╗ ███╗ ██████╗ ████████╗███████╗ █████╗ ██████╗ ██████╗███████╗███████╗███████╗
██╔══██╗██╔════╝████╗ ████║██╔═══██╗╚══██╔══╝██╔════╝ ██╔══██╗██╔════╝██╔════╝██╔════╝██╔════╝██╔════╝
██████╔╝█████╗ ██╔████╔██║██║ ██║ ██║ █████╗ ███████║██║ ██║ █████╗ ███████╗███████╗
██╔══██╗██╔══╝ ██║╚██╔╝██║██║ ██║ ██║ ██╔══╝ ██╔══██║██║ ██║ ██╔══╝ ╚════██║╚════██║
██║ ██║███████╗██║ ╚═╝ ██║╚██████╔╝ ██║ ███████╗ ██║ ██║╚██████╗╚██████╗███████╗███████║███████║
╚═╝ ╚═╝╚══════╝╚═╝ ╚═╝ ╚═════╝ ╚═╝ ╚══════╝ ╚═╝ ╚═╝ ╚═════╝ ╚═════╝╚══════╝╚══════╝╚══════╝
Remote Access Integration Guide
Focus: securely reaching a machine or service that isn't directly exposed — a box behind a firewall, a
localhostdev 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
- Overview & decision tree
- SSH keys
- The SSH agent (and why forwarding is dangerous)
~/.ssh/config— host blocks- Bastions &
ProxyJump - Port forwarding:
-L,-R,-D - Hardening
sshd - SSH and WSL Ubuntu
- Public tunnels (ngrok, Cloudflare Tunnel)
- VPN & zero-trust (Tailscale, WireGuard, Access)
- RDP / VNC
- Remote development
- Security checklist
- Troubleshooting
- codeAmani notes
Official documentation
| Source | URL | What it covers |
|---|---|---|
OpenSSH ssh manual |
https://www.man7.org/linux/man-pages/man1/ssh.1.html | -L/-R/-D forwarding, -J jump host, -A, -N, -W, escape sequences |
OpenSSH ssh_config |
https://www.man7.org/linux/man-pages/man5/ssh_config.5.html | ~/.ssh/config, ProxyJump, IdentityFile, ControlMaster |
OpenSSH sshd_config |
https://www.man7.org/linux/man-pages/man5/sshd_config.5.html | Server hardening, PerSourcePenalties, AllowUsers, Match |
OpenSSH ssh-keygen |
https://www.man7.org/linux/man-pages/man1/ssh-keygen.1.html | Key generation, fingerprints, certificates, -R |
| OpenSSH release notes | https://www.openssh.org/releasenotes.html | What changed per release — Ed25519 default, DSA removal, PQ key exchange |
| ngrok getting started | https://ngrok.com/docs/getting-started/ | ngrok http, authtoken, auto-HTTPS, inspector |
| ngrok free plan limits | https://ngrok.com/docs/pricing-limits/free-plan-limits/ | Free static dev domain, endpoint cap, browser interstitial |
| Cloudflare Tunnel | https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/get-started/create-local-tunnel/ | cloudflared tunnel create/route/run, outbound-only model |
| Tailscale CLI | https://tailscale.com/kb/1080/cli | up/set/status/ssh/funnel, exit nodes |
| Tailscale SSH | https://tailscale.com/kb/1193/tailscale-ssh | tailscale set --ssh, SSH ACL grants, check mode |
| What is Tailscale | https://tailscale.com/kb/1151/what-is-tailscale | WireGuard mesh, zero-trust, MagicDNS, ACLs |
| WSL networking | https://learn.microsoft.com/windows/wsl/networking | NAT 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
localhostand 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.
flowchart TD
Q{"What do you need<br/>to reach?"}
Q -->|"a shell on a routable box"| SSH["SSH + ~/.ssh/config<br/>ProxyJump through one bastion"]
Q -->|"the internet must hit<br/>my localhost"| TUN["ngrok / cloudflared<br/>public HTTPS front door"]
Q -->|"many services,<br/>whole team, no open ports"| MESH["Tailscale / WireGuard<br/>identity-gated mesh"]
Q -->|"a graphical desktop"| GUI["RDP/VNC<br/>tunnelled, never raw"]
SSH --> FWD["-L / -R / -D<br/>carry any TCP stream"]
classDef accent fill:#0891B2,color:#fff,stroke:#22D3EE
classDef muted fill:#1e293b,color:#e2e8f0,stroke:#334155
class SSH,TUN,MESH accent
class GUI,FWD muted
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):
| Since | Change | What it means for you |
|---|---|---|
| 8.8 | ssh-rsa (RSA/SHA-1 signatures) disabled by default |
Ancient servers may reject your RSA key — regenerate as Ed25519 rather than re-enabling SHA-1 |
| 9.5 | ssh-keygen generates Ed25519 by default |
ssh-keygen with no flags is already the right answer; -t ed25519 is documentation, not necessity |
| 9.8 | PerSourcePenalties — sshd's built-in penalty box |
The server already throttles brute-forcers before you install fail2ban |
| 9.9 → 10.0 | Hybrid post-quantum key agreement mlkem768x25519-sha256 is the default |
Harvest-now-decrypt-later is covered on both ends running ≥10.0; no config needed |
| 10.0 | DSA removed entirely | ssh-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.
# 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
# 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.
~/.sshmust be700, private keys600, andauthorized_keys600. If the mode is looser,sshrefuses the key withUNPROTECTED PRIVATE KEY FILEandsshdsilently ignoresauthorized_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.
# 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.
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:
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 (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_configtakes the first value it obtains for each parameter, so aHost *block placed at the top silently freezes every later override. Specific hosts first,Host *at the bottom — always. IdentitiesOnly yesfixes "Too many authentication failures". Without it,sshoffers every key in your agent in turn; servers with the defaultMaxAuthTries 6cut you off before reaching the right one.IdentitiesOnlyrestricts the offer to theIdentityFileyou named.ControlMastermakes repeat connections instant. The firstssh prodauthenticates; subsequent ones (including everygit pushand each VS Code channel) ride the existing socket.ControlPersist 10mkeeps it warm after you exit. Note that a multiplexed session shares the master's fate — kill it withssh -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.
Host app-internal
HostName 10.0.1.50
ProxyJump bastion
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.
flowchart LR
Dev["Your laptop"] -->|"SSH session, encrypted end-to-end"| App["app-internal 10.0.1.50"]
Dev -.->|"outer SSH: relay only"| B["bastion :22<br/>sees ciphertext"]
B -.->|"forwards TCP"| App
classDef pub fill:#0891B2,color:#fff,stroke:#22D3EE
classDef priv fill:#1e293b,color:#e2e8f0,stroke:#334155
class B pub
class App priv
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.
# 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.
flowchart LR
Dev["Your laptop<br/>localhost:5432"] -->|"encrypted SSH"| B["Bastion<br/>public :22"]
B -->|"private LAN"| DB[("db.internal:5432<br/>no public port")]
classDef pub fill:#0891B2,color:#fff,stroke:#22D3EE
classDef priv fill:#1e293b,color:#e2e8f0,stroke:#334155
class B pub
class DB priv
The mnemonic:
-Lbrings something to you (Local);-Rsends something out to the Remote;-Dmakes 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:
| Sequence | Effect |
|---|---|
~? |
List all escape sequences |
~C |
Open 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:
| Directive | Default | Set to | Why |
|---|---|---|---|
PasswordAuthentication |
yes |
no |
Passwords are brute-forced continuously; keys are not |
KbdInteractiveAuthentication |
yes |
no |
The forgotten back door — PAM keyboard-interactive still accepts passwords even after you disable PasswordAuthentication |
PermitRootLogin |
prohibit-password |
no |
Per-user accounts + sudo give you an audit trail |
PubkeyAuthentication |
yes |
yes |
Keep explicit so a future edit can't quietly flip it |
MaxAuthTries |
6 |
3 |
Fewer guesses per connection |
AllowUsers / AllowGroups |
all users | explicit list | Allowlist beats denylist |
PermitEmptyPasswords |
no |
no |
Assert it |
X11Forwarding |
no (often yes on distros) |
no |
Unused attack surface on a server |
AllowTcpForwarding |
yes |
no (except bastions) |
Stops a compromised account pivoting through the box |
AllowAgentForwarding |
yes |
no |
Don't let a hop harvest visiting agents |
PerSourcePenalties |
enabled (9.8+) | leave on | Built-in penalty box for crashes, auth failures, invalid users |
# 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_configstarts withInclude /etc/ssh/sshd_config.d/*.conf, and cloud images ship files there (e.g.50-cloud-init.conf) that re-enablePasswordAuthentication 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 own99-hardening.conf, then confirm withsshd -T. - Ubuntu 22.10+ uses socket activation.
ssh.socketowns the listening port, soPort/ListenAddressinsshd_configare ignored. Change the port withsystemctl edit ssh.socket(ListenStream=), or disable the socket unit and enablessh.serviceinstead.
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.
# /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
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.
# 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.
# 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
~/.sshto/mnt/c/Users/you/.ssh. Without[automount] options = "metadata"in/etc/wsl.conf, everything under/mnt/creports mode0777, andsshrefuses the key outright withUNPROTECTED PRIVATE KEY FILE. Enablingmetadata(thenwsl --shutdown, thenchmod 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 andchmod 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.
# 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:
| Mode | Reaching WSL's sshd | Notes |
|---|---|---|
| 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 NAT |
Bridge 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 directly | Requires 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
# 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-warningas a request header, or upgrade. - The inspector at
http://127.0.0.1:4040replays 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).
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
flowchart LR
Daraja["Safaricom Daraja"] -->|"HTTPS POST callback"| CF["Cloudflare edge"]
CF -.->|"outbound-only tunnel<br/>firewall stays shut"| CFD["cloudflared on laptop"]
CFD --> App["Next.js<br/>localhost:3000<br/>/api/mpesa/callback"]
classDef accent fill:#0891B2,color:#fff,stroke:#22D3EE
class CF,CFD accent
| ngrok | Cloudflare Tunnel | Tailscale Funnel | |
|---|---|---|---|
| Setup | seconds | a few minutes | seconds (if already on the tailnet) |
| URL | free static dev domain; custom on paid | your own domain hostname | <device>.<tailnet>.ts.net |
| Needs a domain you own | no | yes (a Cloudflare zone) | no |
| Inbound ports | none | none (outbound-only) | none |
| Best for | throwaway webhook testing, request inspection | longer-lived previews, IP/domain-restricted providers | sharing 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.
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:
{
"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 atlocalhost: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/configentry, soProxyJump/bastion routing andControlMasterjust 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 autois 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, thenclaude. On Windows, running it inside WSL Ubuntu is the supported path (seewsl/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 noandKbdInteractiveAuthentication noon 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-newat minimum; neverno. 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,AllowUsersallowlists. - No agent forwarding.
ForwardAgent noglobally andAllowAgentForwarding noserver-side; useProxyJump. If some workflow truly needs it, scope it to one host and addssh-add -c. - Turn off forwarding where it isn't needed.
AllowTcpForwarding noon 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 beatauthorized_keyssprawl 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_keyson 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,
cloudflaredcredentials JSON, WireGuard private keys, SSH CA keys →.env.local/ Hazina, never committed, caught by the pre-push gitleaks gate.
14. Troubleshooting
| Symptom | Likely 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 FILE |
chmod 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 failures |
The agent is offering every key before the right one. Add IdentitiesOnly yes + an explicit IdentityFile |
REMOTE HOST IDENTIFICATION HAS CHANGED |
Server rebuilt/re-imaged (or an on-path attack). Confirm the cause, then ssh-keygen -R host |
| Config edits have no effect | First-match-wins: an earlier block (often Host * at the top) already set the keyword. Check with ssh -G host |
sshd still accepts passwords after disabling |
Either 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 remote |
Remote binds loopback by default; needs GatewayPorts yes server-side and -R 0.0.0.0:PORT:... |
| Connection drops after idle | ServerAliveInterval 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 tunnel | Tunnel 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 app | Free-plan browser interstitial. Harmless for webhooks; send ngrok-skip-browser-warning to bypass |
| Can't reach WSL's sshd from another machine | Default NAT mode. Use networkingMode=mirrored + a Hyper-V firewall rule, or netsh interface portproxy (§8) |
| Tailscale SSH refuses the connection | Missing 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, usecloudflaredmapped tocb.codeamani.com → http://localhost:3000. Either way the callback lands onapp/api/mpesa/callback/route.ts— and Daraja callbacks are effectively single-shot, so dedupe onCheckoutRequestIDand pair with a status-query reconciliation job (seeMPESA_PATTERNS.mdand the webhooks guide). - One key per device,
~/.ssh/configfor everything else. Every codeAmani machine gets its own Ed25519 key with a passphrase;~/.ssh/configcarriesIdentitiesOnly yes,ForwardAgent no,ControlMaster auto, andProxyJumpfor anything private.examples/ssh-configin 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 toC:\Users\…\.ssh—/mnt/ccan't hold600without themetadatamount option, andsshrefuses the key. Cross-refwsl/CLAUDE_CODE_INTEGRATION.mdfor 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 --sshon the Pi) and reach it asssh pi@raspberry-pifrom anywhere — identity-gated, no router port-forwarding, no dynamic-DNS hacks. Theraspberry-piguide 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@bastiongives you localpsql/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.
cloudflaredcredential 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.
Official docs:
- https://www.man7.org/linux/man-pages/man1/ssh.1.html
- https://www.man7.org/linux/man-pages/man5/ssh_config.5.html
- https://www.man7.org/linux/man-pages/man5/sshd_config.5.html
- https://www.man7.org/linux/man-pages/man1/ssh-keygen.1.html
- https://www.openssh.org/releasenotes.html
- https://ngrok.com/docs/getting-started/
- https://ngrok.com/docs/pricing-limits/free-plan-limits/
- https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/get-started/create-local-tunnel/
- https://tailscale.com/kb/1080/cli
- https://tailscale.com/kb/1193/tailscale-ssh
- https://tailscale.com/kb/1151/what-is-tailscale
- https://learn.microsoft.com/windows/wsl/networking