dtssh — seamless SSH over Microsoft Dev Tunnels
dtssh turns a Microsoft Dev Tunnel
into a transparent SSH transport. It runs a dedicated, hardened OpenSSH server on
the host (bound to loopback, reachable only through the tunnel), and wires up
the client so that a plain ssh <alias> — plus scp, git, rsync, and
VS Code Remote-SSH — just works across NAT/firewalls with no manual SSH
authentication, no password prompts, and no host-key prompts.
┌────────────┐ ssh <alias> ┌──────────────┐ dev tunnel relay ┌───────────────┐
│ client │ ───────────────▶ │ dtssh proxy │ ═══════════════════▶ │ dtssh host │
│ (ssh/scp) │ ProxyCommand │ (stdio↔TCP) │ (devtunnel CLI) │ dedicated sshd│
└────────────┘ └──────────────┘ └───────────────┘
Why
Dev Tunnels give you an authenticated, internet-reachable relay to a local TCP
port without opening inbound firewall ports. SSH gives you a battle-tested,
end-to-end-encrypted session with a rich ecosystem (scp/sftp/git/port-forwarding/
VS Code). dtssh marries the two so the tunnel hop is invisible to every tool
that speaks SSH.
How it works
-
Host (dtssh host)
- Generates an ephemeral SSH keypair and a dedicated host key.
- Writes a hardened
sshd_config and launches an unprivileged sshd
instance bound to 127.0.0.1:<port> (does not touch the system SSH service).
- Hosts that port over a dev tunnel via the
devtunnel CLI.
- Publishes pairing metadata (alias, user/port, host public key, and the
ephemeral key seed) in the tunnel's description under a
dtssh label, so a
logged-in client can find and connect to it with no bundle (see
Zero-import discovery).
- Also prints a compact connect bundle for clients that don't share your
devtunnel login.
-
Client — two ways to pair, both fully automatic afterwards:
dtssh discover — finds every dtssh host on your devtunnel account and
wires up ssh <alias> for each. No bundle to copy.
dtssh import <bundle> — paste the printed bundle (for clients with no
shared devtunnel login).
Either way, dtssh installs the private key, pins the host key in a managed
known_hosts, and adds a Host <alias> block to ~/.ssh/config whose
ProxyCommand is dtssh proxy.
-
dtssh proxy (the ProxyCommand, wired up automatically)
- Ensures a single shared
devtunnel connect forwarder is running and pipes
ssh's stdin/stdout to the tunnel's loopback port. One forwarder serves all
concurrent sessions (important for VS Code Remote-SSH, which opens several).
The result: ssh <alias> performs standard SSH public-key auth over the tunnel.
"No extra SSH auth" means the tooling provisions and delivers the key for you —
not that SSH authentication is disabled.
Zero-import discovery
When both machines are logged into the same devtunnel account
(devtunnel user login), you never touch a bundle:
# Host:
dtssh host # non-anonymous + discoverable by default
# Client:
dtssh discover # finds the host, wires up ssh <alias>
ssh dt-<host>
dtssh host tags its tunnel with the dtssh label and stores compact pairing
metadata — including the ephemeral key seed and the host public key — in the
tunnel description. dtssh discover runs devtunnel list --labels dtssh,
reconstructs the SSH key from the seed, pins the host key, and installs the
ssh_config alias. Re-run dtssh discover any time to pick up new hosts (add
--prune to drop aliases whose tunnels are gone).
Because a tunnel description is only account-private on non-anonymous
tunnels, discovery metadata (which contains a secret seed) is published only
for non-anonymous tunnels. dtssh host --anonymous opts back into the classic
bundle-only flow and publishes no seed.
Install
Requires OpenSSH (ssh, ssh-keygen; plus sshd on the host). The
devtunnel CLI is auto-downloaded on
first use (dtssh login/host/discover), so you don't need to install it
separately — though a copy already on PATH (or DTSSH_DEVTUNNEL) is used if
present.
Install directly from the repo (recommended)
With a Go toolchain (1.21+), install the latest dtssh straight from the
repository — no manual clone required:
go install github.com/bmiddha/devtunnel-ssh/cmd/dtssh@latest
This drops the dtssh binary in $(go env GOPATH)/bin (usually ~/go/bin).
Make sure that directory is on your PATH:
export PATH="$(go env GOPATH)/bin:$PATH" # add to your shell profile
To pin a specific version, replace @latest with a tag, e.g. @v0.1.0.
Build from a clone
git clone https://github.com/bmiddha/devtunnel-ssh
cd dtssh
go build -o dtssh ./cmd/dtssh
install -m755 dtssh ~/.local/bin/dtssh # or use scripts/install.sh
On Windows, run scripts/install.ps1 from a cloned checkout.
Check your environment any time:
dtssh doctor
Usage
On the host (the machine you want to reach)
dtssh login # once — auto-downloads the devtunnel CLI, then logs in
dtssh host # defaults: dedicated sshd on 127.0.0.1:2222, anonymous tunnel
dtssh login fetches the official Microsoft Dev Tunnels CLI
for your platform (if it isn't already on PATH) into dtssh's cache dir, then
runs devtunnel user login. Any flags are passed straight through, e.g.
dtssh login -d (device-code auth for headless hosts) or dtssh login -g
(GitHub). The other commands (host, service, discover) auto-download the
CLI the same way, so you never have to install it manually.
dtssh host prints something like:
On the CLIENT machine, run:
dtssh import 'dtssh1:eyJ2Ijox...'
then simply:
ssh dt-myhost
Useful flags: --port, --user, --alias, --tunnel <id> (reuse a persistent
tunnel), --expiration 8h, --anonymous (classic bundle-only flow; disables
discovery), --discoverable=false (host privately without publishing pairing
metadata), --system-sshd (use the existing system sshd instead of a dedicated
instance).
Run the host as an always-on service
To keep a machine reachable without leaving a terminal open, register the host
as a per-user background service that starts at login and auto-restarts on
failure (auto-recovery). No admin rights are needed:
dtssh login # once (auto-downloads the devtunnel CLI)
dtssh service install --port 2222 # same flags as `dtssh host`
dtssh service status # or: logs, restart, stop, start
dtssh service uninstall
The service hosts in --persist mode: it reuses a single tunnel and a stable
identity across restarts, so recovery never accumulates stale tunnels or breaks
discovery. Under the hood it uses the platform's native user supervisor:
| OS |
Mechanism |
Auto-recovery |
| Linux |
systemd user unit (~/.config/systemd/user/dtssh-host.service) |
Restart=always |
| macOS |
launchd LaunchAgent (~/Library/LaunchAgents/com.bmiddha.dtssh-host.plist) |
KeepAlive |
| Windows |
Task Scheduler task (\dtssh-host, logon + boot triggers) |
RestartOnFailure |
On Linux, dtssh service install also tries loginctl enable-linger so the host
keeps running after you log out / across reboots.
WSL — running the host inside a WSL distro is fully supported and handled
specially:
- No name collision. Inside WSL, the hostname is the Windows computer name,
so a WSL host and its Windows host would otherwise derive the same alias. dtssh
detects WSL and qualifies the alias/tunnel with the distro, e.g.
dt-mypc → dt-mypc-wsl-ubuntu.
- Cold-start auto-boot. A WSL distro doesn't run until something launches it,
so a systemd user service inside WSL wouldn't come back after a Windows
reboot.
dtssh service install (when it detects WSL) also drops a hidden
launcher in the Windows per-user Startup folder that boots the distro at
logon — no admin, no Task Scheduler policy needed. With systemd=true in
/etc/wsl.conf and lingering enabled, the boot brings up the host service
automatically. Opt out with --no-wsl-boot; dtssh service uninstall removes
the launcher.
On the client (the machine you connect from)
# Same devtunnel account as the host — zero import:
dtssh discover # finds hosts, wires up ssh <alias> for each
ssh dt-myhost # seamless: no password, no key setup, no host prompt
# Or, no shared login — paste the bundle once:
dtssh import 'dtssh1:...'
scp file dt-myhost:~/
git clone ssh://dt-myhost/path/to/repo.git
code --remote ssh-remote+dt-myhost /home/you/project # VS Code Remote-SSH
Undo with dtssh remove dt-myhost. Housekeeping flags for dtssh discover:
--prune (drop local managed aliases whose tunnels are gone) and --clean
(delete superseded stale tunnels — offline duplicates of a live host — from
your devtunnel account; a lagging or sole host is never touched).
Command reference
| Command |
Description |
dtssh login [flags] |
Auto-download the devtunnel CLI (if missing) and run devtunnel user login; flags pass through (-d device code, -g GitHub, -e Entra). |
dtssh host |
Expose this machine over a dev tunnel; publish discovery metadata + print a bundle. |
dtssh service install|uninstall|start|stop|restart|status|logs |
Run the host as an auto-restarting, login-started user service. |
dtssh discover |
Auto-discover dtssh hosts on your account and wire up ssh <alias> (no import). --prune drops local aliases whose tunnels are gone; --clean deletes superseded stale tunnels from your account; -q for names only. |
dtssh import <bundle> |
Install a bundle so ssh <alias> works (for clients with no shared login). |
dtssh list |
List dtssh-managed host aliases (add -q for names only). |
dtssh remove <alias> |
Undo an import/discover (config, key, known_hosts pin). |
dtssh connect <alias> [args] |
Thin ssh wrapper. |
dtssh proxy <tunnel> |
ProxyCommand (used automatically). |
dtssh forward status|stop <tunnel> |
Manage the shared client forwarder. |
dtssh config <tunnel> |
Print an ssh_config block. |
dtssh doctor |
Check devtunnel/ssh/sshd availability. |
- Linux / macOS — dedicated-
sshd mode works out of the box (no root needed;
the instance can only log in the user who launched it). sftp-server is
auto-detected for scp/sftp support.
- Windows — the client side (
import, proxy, ssh) works with the built-in
OpenSSH client. For hosting, running a second unprivileged sshd is awkward, so
use --system-sshd: enable the OpenSSH Server optional feature, then
dtssh host --system-sshd appends the ephemeral key to your
%USERPROFILE%\.ssh\authorized_keys.
See docs/architecture.md for the full design, security
model, and the concurrency/limitations discussion.
Files & directories
dtssh follows the XDG Base Directory spec on Linux/macOS (no home-dir dotfolder)
and uses %LOCALAPPDATA% on Windows. Files are split by category:
| Category |
Linux/macOS (XDG) |
Windows |
Contents |
| data |
$XDG_DATA_HOME/dtssh (~/.local/share/dtssh) |
%LOCALAPPDATA%\dtssh |
host & client keys, authorized_keys, sshd config, service identity seeds |
| state |
$XDG_STATE_HOME/dtssh (~/.local/state/dtssh) |
%LOCALAPPDATA%\dtssh |
logs, per-tunnel forwarder metadata (pid/port/lock) |
| cache |
$XDG_CACHE_HOME/dtssh (~/.cache/dtssh) |
%LOCALAPPDATA%\dtssh |
auto-downloaded devtunnel CLI (re-fetchable) |
Set DTSSH_HOME to override everything into a single flat root (handy for tests
or portable setups). An existing legacy ~/.dtssh directory is migrated to the
XDG locations automatically on first run. The client ssh_config block and
known_hosts pins live under ~/.ssh as usual. dtssh doctor prints the
resolved directories.
Troubleshooting
Set DTSSH_DEBUG=1 to emit timestamped diagnostics on stderr for the host,
proxy, and forwarder. Debug output never touches stdout, so it is safe to enable
even while dtssh proxy is serving an SSH session.
# See exactly which tunnel id the host bakes into the bundle:
DTSSH_DEBUG=1 dtssh host
# Trace the client hop (forwarder start, parsed local port, bridge target):
DTSSH_DEBUG=1 ssh <alias> # ssh -v also shows the ProxyCommand's stderr
If ssh <alias> hangs or errors with "devtunnel connect failed … Tunnel not
found", the bundle was created by an outdated dtssh host. Update dtssh on
the host, re-run dtssh host, and re-import the fresh bundle. A valid connect id
looks like tidy-fog-btv9lwz.usw2 (id + cluster), not the short subdomain in
the browser URL (9d6v4rxw).
Security model
- The dev tunnel is the only ingress:
sshd listens on loopback.
- Two independent layers of auth: the tunnel (login or connect token) and
SSH public-key auth. Anonymous tunnels still require the SSH key.
- Host identity is pinned on import (
StrictHostKeyChecking yes against a
dtssh-managed known_hosts), so there is no trust-on-first-use prompt and MITM
of the relay cannot silently substitute a host key.
- Host-key rotation self-heals. A host key normally never changes (it is
reused from disk across restarts), but it can change if the host's data dir is
wiped/reinstalled/moved, you toggle
--system-sshd, or you re-host the same
alias on a different machine. Rather than failing with the scary
REMOTE HOST IDENTIFICATION HAS CHANGED error, dtssh proxy re-fetches the
authoritative host key from your devtunnel account metadata (TLS-served to
the owning account, unreachable by a relay MITM) on cold reconnect and refreshes
the pin — printing host key ... changed; refreshed the pin from your devtunnel account. This only applies to discoverable tunnels; bundle-imported/anonymous
hosts keep a strict pin and must be re-imported if their key legitimately rotates.
- Keys are ephemeral, per-host, stored
0600 under the dtssh data dir (see
Files & directories). The bundle contains a private key
— treat it like a password and share it over a trusted channel.
Development
go build ./...
go test ./...
go vet ./...
Licensed under the MIT License. This project uses the Microsoft devtunnel CLI
and OpenSSH; it is not affiliated with or endorsed by Microsoft.