<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Svyatoslav Pavlov</title>
    <description>The latest articles on DEV Community by Svyatoslav Pavlov (@wolfhound1995).</description>
    <link>https://dev.to/wolfhound1995</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4060150%2Fade29fc0-19eb-4bbf-9bf8-0fc51e252710.jpg</url>
      <title>DEV Community: Svyatoslav Pavlov</title>
      <link>https://dev.to/wolfhound1995</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/wolfhound1995"/>
    <language>en</language>
    <item>
      <title>Which SSH clients support MCP in 2026</title>
      <dc:creator>Svyatoslav Pavlov</dc:creator>
      <pubDate>Thu, 29 Oct 2026 20:10:38 +0000</pubDate>
      <link>https://dev.to/wolfhound1995/which-ssh-clients-support-mcp-in-2026-47hd</link>
      <guid>https://dev.to/wolfhound1995/which-ssh-clients-support-mcp-in-2026-47hd</guid>
      <description>&lt;p&gt;A handful of SSH clients and terminals support MCP now, but "support MCP" means three different things, and the difference is what you actually care about:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Built-in MCP server&lt;/strong&gt; — the client exposes &lt;em&gt;your hosts&lt;/em&gt; as MCP tools an external agent can call (Conduit, XPipe, VibeShell, Termalin).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;MCP client&lt;/strong&gt; — the app's own in-terminal agent can &lt;em&gt;use&lt;/em&gt; other MCP servers (Warp).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Community bridge or plugin&lt;/strong&gt; — a third-party add-on exposes the app's sessions (Tabby, MobaXterm, Termius).&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Disclosure: &lt;strong&gt;we build Termalin&lt;/strong&gt;, one of the entries below. Facts checked October 14, 2026 — this moves monthly, so check each vendor's page before you commit.&lt;/p&gt;

&lt;h2&gt;
  
  
  The table
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Client&lt;/th&gt;
&lt;th&gt;MCP support&lt;/th&gt;
&lt;th&gt;Local / hosted&lt;/th&gt;
&lt;th&gt;Tools&lt;/th&gt;
&lt;th&gt;Price&lt;/th&gt;
&lt;th&gt;Platforms&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Termalin&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Built-in MCP server&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;Local + hosted&lt;/strong&gt; (OAuth sign-in + API key)&lt;/td&gt;
&lt;td&gt;22 local / 22 hosted&lt;/td&gt;
&lt;td&gt;Free; Pro $8/mo yearly&lt;/td&gt;
&lt;td&gt;Win, macOS, Linux, iOS (+ web)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Conduit&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Built-in MCP server&lt;/td&gt;
&lt;td&gt;Local only (socket / named pipe)&lt;/td&gt;
&lt;td&gt;~60&lt;/td&gt;
&lt;td&gt;Free (1 device); Pro $10/mo&lt;/td&gt;
&lt;td&gt;macOS, Win, Linux, iOS&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;XPipe&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Built-in MCP server&lt;/td&gt;
&lt;td&gt;Local only (HTTP, off by default)&lt;/td&gt;
&lt;td&gt;read-only until enabled&lt;/td&gt;
&lt;td&gt;Free / $5–10/mo&lt;/td&gt;
&lt;td&gt;Win, macOS, Linux (+ web)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;VibeShell&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Built-in "Agent Gateway"&lt;/td&gt;
&lt;td&gt;Local&lt;/td&gt;
&lt;td&gt;~28&lt;/td&gt;
&lt;td&gt;Free (MIT)&lt;/td&gt;
&lt;td&gt;Win, macOS, Linux&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Warp&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;MCP client + its own agents&lt;/td&gt;
&lt;td&gt;Agents run in the terminal&lt;/td&gt;
&lt;td&gt;n/a&lt;/td&gt;
&lt;td&gt;Free / $20/mo+&lt;/td&gt;
&lt;td&gt;Win, macOS, Linux&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Tabby&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Community plugin (&lt;code&gt;tabby-mcp-server&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;Local&lt;/td&gt;
&lt;td&gt;plugin-defined&lt;/td&gt;
&lt;td&gt;Free (MIT)&lt;/td&gt;
&lt;td&gt;Win, macOS, Linux (+ web)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;MobaXterm&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Community bridge (&lt;code&gt;mobaxterm-mcp&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;Local&lt;/td&gt;
&lt;td&gt;reads &lt;code&gt;MobaXterm.ini&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Home free / $69&lt;/td&gt;
&lt;td&gt;Windows&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Termius&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Community bridges only (no official server)&lt;/td&gt;
&lt;td&gt;Local / cloud&lt;/td&gt;
&lt;td&gt;varies&lt;/td&gt;
&lt;td&gt;Free / $10/mo&lt;/td&gt;
&lt;td&gt;Win, macOS, Linux, mobile&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Wave Terminal&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;None found (Oct 2026)&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;Free (Apache-2.0)&lt;/td&gt;
&lt;td&gt;macOS, Win, Linux&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;SecureCRT / Royal TS&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;None found&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;paid&lt;/td&gt;
&lt;td&gt;varies&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  Notes on the rows
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Conduit&lt;/strong&gt; is the pick most AI assistants name for "SSH + MCP" today: a connection manager for SSH, RDP, VNC and web with an AES-256 vault and an Apache-2.0 built-in server, local only. &lt;strong&gt;XPipe&lt;/strong&gt; also ships a built-in server — a local HTTP endpoint, off by default and read-only until you enable its mutation tools — handy if you already browse Docker, Kubernetes and VMs there. &lt;strong&gt;VibeShell&lt;/strong&gt; exposes ~28 MCP tools through an Agent Gateway (MIT), with device-local credential storage its own README says is "not Keychain-grade."&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Warp&lt;/strong&gt; is a different case: it's an AI-first terminal whose own agents write and run commands, including in SSH sessions you open. That's not the same as exposing your hosts to an &lt;em&gt;outside&lt;/em&gt; agent through a built-in MCP server — check Warp's docs for its current MCP-client support if you want to wire other servers into it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Tabby, MobaXterm and Termius&lt;/strong&gt; have no built-in MCP server; the community fills the gap. &lt;code&gt;tabby-mcp-server&lt;/code&gt; is a plugin, &lt;code&gt;mobaxterm-mcp&lt;/code&gt; reads your saved &lt;code&gt;MobaXterm.ini&lt;/code&gt; sessions, and Termius has three separate &lt;code&gt;termius-mcp&lt;/code&gt;-style bridges — the full breakdown is in &lt;a href="https://termal.in/blog/does-termius-have-an-mcp-server/" rel="noopener noreferrer"&gt;does Termius have an MCP server?&lt;/a&gt;. None ship from the vendor.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Wave, SecureCRT and Royal TS&lt;/strong&gt;: we found no MCP server in their docs as of October 2026. For SecureCRT and Royal TS the agent route today is a standalone SSH MCP server pointed at those hosts.&lt;/p&gt;

&lt;h2&gt;
  
  
  Termalin, in one row
&lt;/h2&gt;

&lt;p&gt;Since we build it, here's the plain version: Termalin is an SSH client whose MCP server is a &lt;strong&gt;key custodian&lt;/strong&gt; — the agent never holds a private key. It runs two ways: a bundled &lt;strong&gt;local&lt;/strong&gt; server (22 tools) for an agent on your machine, and a &lt;strong&gt;hosted&lt;/strong&gt; endpoint (&lt;code&gt;https://termal.in/mcp&lt;/code&gt;, 22 tools) that claude.ai, ChatGPT, Claude Code, Codex and Cursor sign into, reaching servers keylessly through an outbound Connector with a per-connection command policy (full, allowlist or read-only) and an audit log. It's the only entry here with both a hosted endpoint and a custodian model — the other built-in servers (Conduit, XPipe) are local only.&lt;/p&gt;

&lt;h2&gt;
  
  
  How to pick
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Want a built-in server and nothing leaving your machine&lt;/strong&gt; → Conduit (broadest protocols) or XPipe (if you live in containers and VMs).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Want the agent to work from CI, a cloud sandbox, or a chat with no desktop&lt;/strong&gt; → you need a &lt;strong&gt;hosted&lt;/strong&gt; endpoint; among SSH clients that's Termalin, otherwise a hosted connector like browser-ssh or sshrew (see the &lt;a href="https://termal.in/blog/ssh-mcp-servers-compared/" rel="noopener noreferrer"&gt;servers comparison&lt;/a&gt;).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Already on Tabby, MobaXterm or Termius and happy there&lt;/strong&gt; → the community bridge for your app, with the usual "read the code" caveat.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Want AI inside your local terminal, not external agent access&lt;/strong&gt; → Warp.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For the deeper trade-offs — key custody, per-host policy, live oversight — see the &lt;a href="https://termal.in/blog/ssh-clients-for-ai-agents-2026/" rel="noopener noreferrer"&gt;landscape roundup&lt;/a&gt; and the &lt;a href="https://termal.in/blog/best-ssh-client-with-mcp-server/" rel="noopener noreferrer"&gt;buying guide&lt;/a&gt;.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Termalin is a free, cross-platform SSH client with a built-in MCP server, a key custodian and per-connection policy — &lt;a href="https://termal.in/download/" rel="noopener noreferrer"&gt;download it&lt;/a&gt; or read the &lt;a href="https://termal.in/mcp/" rel="noopener noreferrer"&gt;MCP reference&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>mcp</category>
      <category>ssh</category>
      <category>ai</category>
      <category>productivity</category>
    </item>
    <item>
      <title>Does Termius have an MCP server? (2026)</title>
      <dc:creator>Svyatoslav Pavlov</dc:creator>
      <pubDate>Wed, 28 Oct 2026 21:21:43 +0000</pubDate>
      <link>https://dev.to/wolfhound1995/does-termius-have-an-mcp-server-2026-50oo</link>
      <guid>https://dev.to/wolfhound1995/does-termius-have-an-mcp-server-2026-50oo</guid>
      <description>&lt;p&gt;&lt;strong&gt;As of October 2026, Termius has no official MCP server&lt;/strong&gt; — nothing built into the app, and no announcement from Termius we could find. But several community projects bridge Termius to an AI agent, and a couple of other SSH clients get you the same result a different way. Here's each one, how it actually works, and the risk to weigh.&lt;/p&gt;

&lt;p&gt;One disclosure up front: &lt;strong&gt;we build Termalin&lt;/strong&gt;, one of the options below. We've tried to describe the rest as fairly as we'd want ours described, and to say where they're the better fit.&lt;/p&gt;

&lt;h2&gt;
  
  
  The community bridges to Termius
&lt;/h2&gt;

&lt;p&gt;Three unrelated projects share the name &lt;code&gt;termius-mcp&lt;/code&gt;, and they work in very different ways.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;termius-mcp (MiaM1ku, on PyPI)&lt;/strong&gt; — the one that's &lt;code&gt;pip install&lt;/code&gt;-able (BSD, marked beta). A stdio server that logs into &lt;strong&gt;Termius Cloud&lt;/strong&gt;: it syncs your vault, looks up a host, and runs SSH exec and SFTP. &lt;strong&gt;Ikaleio/termius-mcp&lt;/strong&gt; is a close twin with the same description. Because it talks to Termius Cloud, you're handing it your Termius login — read the code and scope it accordingly.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;yapp/termius-mcp&lt;/strong&gt; — drives the &lt;strong&gt;terminals in a running Termius desktop app&lt;/strong&gt; over the Chrome DevTools Protocol. It needs Termius ≥ 10.0.6 launched with &lt;code&gt;--remote-debugging-port=9229&lt;/code&gt;, plus Node.js. It can create terminals, send keystrokes and keys, read output, and work the SSH sessions you already have open. The catch is that open debug port: anything local that can reach it can drive your terminals, so treat it as a development convenience, not a hardened setup.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;deltaguita/termius-mcp&lt;/strong&gt; — also drives already-open Termius tabs over CDP, but adds claim/lock guards so two agents don't stomp on the same session.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Termius Session MCP
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Termius Session MCP&lt;/strong&gt; (ItzGlace) is a tidier take on the same idea. It shares &lt;strong&gt;one&lt;/strong&gt; visible Termius terminal with an MCP client through four tools — &lt;code&gt;status&lt;/code&gt;, &lt;code&gt;sessions&lt;/code&gt;, &lt;code&gt;read&lt;/code&gt;, &lt;code&gt;write&lt;/code&gt; — and adds an MCP button inside Termius. Its defining feature is caution: &lt;strong&gt;every write and every output release needs in-app approval&lt;/strong&gt;. It's &lt;strong&gt;Windows only&lt;/strong&gt;, needs Node ≥ 22, is MIT-licensed, and is explicit that it's "an internal-API integration, not an official Termius extension" (tested against Termius 9.39.0). If you want a human finger on every step, this is the one.&lt;/p&gt;

&lt;p&gt;A name to ignore in these searches: &lt;strong&gt;TerminusAI&lt;/strong&gt; turns up alongside the others but is &lt;strong&gt;unrelated to Termius&lt;/strong&gt; — it's a generic one-tool MCP server that runs a command locally or over SSH, nothing to do with your Termius vault.&lt;/p&gt;

&lt;h2&gt;
  
  
  The other clients that get you there
&lt;/h2&gt;

&lt;p&gt;If the real goal is "an AI agent that can work my servers," you don't have to stay inside Termius:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Conduit&lt;/strong&gt; is a connection manager for SSH, RDP, VNC and web sessions with a &lt;strong&gt;built-in MCP server&lt;/strong&gt; (Apache-2.0, around 60 local tools). It's &lt;strong&gt;local only&lt;/strong&gt; — a Unix socket or Windows named pipe — so there's no cloud endpoint, but nothing leaves your machine. Import your hosts and an agent can drive them.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Termalin&lt;/strong&gt; is an SSH client with its own MCP server in two shapes: &lt;strong&gt;22 local tools&lt;/strong&gt; in the desktop app, plus a &lt;strong&gt;hosted endpoint&lt;/strong&gt; (&lt;code&gt;https://termal.in/mcp&lt;/code&gt;, 21 tools) that &lt;strong&gt;claude.ai, ChatGPT, Claude Code, Codex and Cursor&lt;/strong&gt; sign into. It reaches your servers &lt;strong&gt;keylessly&lt;/strong&gt; through an outbound &lt;strong&gt;Connector&lt;/strong&gt; — no inbound port — applies a &lt;strong&gt;per-connection command policy&lt;/strong&gt; (full, allowlist or read-only), and writes an &lt;strong&gt;audit log&lt;/strong&gt;; it's listed in the ChatGPT and Claude connector directories. Import your Termius hosts first (an &lt;code&gt;~/.ssh/config&lt;/code&gt; export or a CSV) and the agent works them.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The honest trade: if your hosts and habits already live in Termius and you want the lightest possible bridge, a community &lt;code&gt;termius-mcp&lt;/code&gt; or Termius Session MCP keeps you there. If you'd rather the agent &lt;strong&gt;never hold a key&lt;/strong&gt; and be able to work your servers from CI or a chat with no app running, a custodian-style client earns the cost of importing your hosts into a second app.&lt;/p&gt;

&lt;h2&gt;
  
  
  At a glance
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Option&lt;/th&gt;
&lt;th&gt;How it reaches Termius hosts&lt;/th&gt;
&lt;th&gt;Key handling&lt;/th&gt;
&lt;th&gt;Approvals&lt;/th&gt;
&lt;th&gt;Platforms&lt;/th&gt;
&lt;th&gt;License&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;termius-mcp (MiaM1ku)&lt;/td&gt;
&lt;td&gt;Logs into Termius Cloud&lt;/td&gt;
&lt;td&gt;Your Termius login&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;Anywhere (Python)&lt;/td&gt;
&lt;td&gt;BSD&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;yapp/termius-mcp&lt;/td&gt;
&lt;td&gt;CDP into a running Termius app&lt;/td&gt;
&lt;td&gt;Uses the app's sessions&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;Anywhere (Node) + Termius&lt;/td&gt;
&lt;td&gt;Unverified&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;deltaguita/termius-mcp&lt;/td&gt;
&lt;td&gt;CDP into open Termius tabs&lt;/td&gt;
&lt;td&gt;Uses the app's sessions&lt;/td&gt;
&lt;td&gt;Claim/lock guards&lt;/td&gt;
&lt;td&gt;Anywhere (Node) + Termius&lt;/td&gt;
&lt;td&gt;Unverified&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Termius Session MCP&lt;/td&gt;
&lt;td&gt;Shares one visible terminal&lt;/td&gt;
&lt;td&gt;Uses the app's session&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Per write + per output&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Windows + Termius&lt;/td&gt;
&lt;td&gt;MIT&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Conduit&lt;/td&gt;
&lt;td&gt;Its own vault (import hosts)&lt;/td&gt;
&lt;td&gt;In its AES-256 vault&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;macOS, Win, Linux, iOS&lt;/td&gt;
&lt;td&gt;Apache-2.0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Termalin&lt;/td&gt;
&lt;td&gt;Its own MCP, local + hosted&lt;/td&gt;
&lt;td&gt;Custodian signs; key never handed over&lt;/td&gt;
&lt;td&gt;Per-connection policy + audit&lt;/td&gt;
&lt;td&gt;Win, macOS, Linux, iOS (+ web)&lt;/td&gt;
&lt;td&gt;Source-available in parts&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;("Unverified" means we couldn't confirm the license on the repo as of October 2026.)&lt;/p&gt;

&lt;h2&gt;
  
  
  How to pick
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Stay inside Termius, lightest touch&lt;/strong&gt; → a community &lt;code&gt;termius-mcp&lt;/code&gt;; read the code and mind the debug port.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Want a human to approve every action&lt;/strong&gt; → Termius Session MCP (Windows only).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;One open-source app across SSH, RDP and VNC, local only&lt;/strong&gt; → Conduit.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Agent must run from CI or a chat with no desktop, and never hold a key&lt;/strong&gt; → Termalin, after importing your hosts.&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;&lt;em&gt;Termalin is a cross-platform SSH client whose built-in MCP server is a key custodian with per-connection policy and an audit log — &lt;a href="https://termal.in/mcp/" rel="noopener noreferrer"&gt;see the MCP page&lt;/a&gt;, or the full &lt;a href="https://termal.in/blog/termius-alternatives/" rel="noopener noreferrer"&gt;Termius alternatives&lt;/a&gt; roundup.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>mcp</category>
      <category>ssh</category>
      <category>ai</category>
      <category>devops</category>
    </item>
    <item>
      <title>SSH MCP servers compared</title>
      <dc:creator>Svyatoslav Pavlov</dc:creator>
      <pubDate>Sat, 24 Oct 2026 16:29:13 +0000</pubDate>
      <link>https://dev.to/wolfhound1995/ssh-mcp-servers-compared-139p</link>
      <guid>https://dev.to/wolfhound1995/ssh-mcp-servers-compared-139p</guid>
      <description>&lt;p&gt;Search "ssh mcp server" and you'll find a dozen projects that all promise to let an AI agent run SSH commands. They are not the same thing, and the differences — who holds the key, whether you can bound what runs, whether anyone can audit it — are exactly what decides if you can leave one running. This compares the three families: standalone servers you run yourself, hosted connectors you reach by URL, and SSH clients with a server built in.&lt;/p&gt;

&lt;p&gt;Disclosure: &lt;strong&gt;we build Termalin&lt;/strong&gt;, which appears in the third group. The rest are described from their public repos and docs, checked October 14, 2026.&lt;/p&gt;

&lt;h2&gt;
  
  
  Standalone servers (you run them; they hold a key)
&lt;/h2&gt;

&lt;p&gt;You configure the hosts and keys, run the server, and point your agent at it. No GUI, scriptable, lightweight.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;tufantunc/ssh-mcp&lt;/strong&gt; — the most-starred of the bunch (~800★, MIT, 14 tools, TypeScript), and the one to beat. Its v2 adds command classification (read-only / safe / destructive / privileged), &lt;strong&gt;RBAC&lt;/strong&gt; roles (viewer / operator / admin), &lt;strong&gt;approval modes&lt;/strong&gt;, an audit log with redaction, and host-key pinning. Set up that way, it answers the policy and audit questions most of this family punts on.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;AiondaDotCom/mcp-ssh&lt;/strong&gt; ("MCP SSH Agent") — reads your &lt;code&gt;~/.ssh/config&lt;/code&gt; and &lt;code&gt;known_hosts&lt;/code&gt; and shells out to native &lt;code&gt;ssh&lt;/code&gt;. Simple and familiar.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;blackaxgit/ssh-mcp&lt;/strong&gt; — also uses &lt;code&gt;~/.ssh/config&lt;/code&gt;, with server groups for working a fleet.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;veithly/ssh-client-mcp&lt;/strong&gt; — SSH plus SFTP, password or key auth.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;idletoaster/ssh-mcp-server&lt;/strong&gt; — Node, built on the official MCP SDK.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;the-nine-nation/remote-ssh-mcp&lt;/strong&gt; — persistent, stateful sessions over your local OpenSSH.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;claude-ssh-mcp (MaraBank)&lt;/strong&gt; — &lt;code&gt;pip&lt;/code&gt;/&lt;code&gt;npx&lt;/code&gt;, the classic paste-a-password-in-chat style; the quickest to stand up and the bluntest on secrets.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The shared trait: &lt;strong&gt;the key lives wherever the server runs&lt;/strong&gt;, usually a plain key file in the agent's reach, and you audit after the fact from logs — if you configured logging. tufantunc's v2 is the clear exception on both counts.&lt;/p&gt;

&lt;h2&gt;
  
  
  Hosted connectors (a URL; no key in the agent)
&lt;/h2&gt;

&lt;p&gt;These answer the question the standalone servers can't: &lt;em&gt;what reaches my servers when my laptop is off?&lt;/em&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;browser-ssh&lt;/strong&gt; — a remote MCP server at &lt;code&gt;api.browserssh.com/mcp&lt;/code&gt; offering, in its words, "scoped, audited SSH exec, sessions, and SFTP on saved servers without exposing credentials." You add it to an agent as a URL.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;sshrew&lt;/strong&gt; (duriantaco) — the self-hosted twin: the box dials out through cloudflared and you paste the resulting URL into Claude's custom connector, with &lt;strong&gt;allowlisted commands&lt;/strong&gt; and a read-only share root (&lt;code&gt;~/.sshrew-share&lt;/code&gt;).&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;These put the credential on the service or the dialing-out box instead of in the agent, and they work from a chat or CI. The trade is that you're trusting a hosted path and, for browser-ssh, a third party in your connection.&lt;/p&gt;

&lt;h2&gt;
  
  
  Client-bundled servers
&lt;/h2&gt;

&lt;p&gt;The SSH clients that ship a server themselves, so the custodian and the UI come in one package:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Conduit&lt;/strong&gt; and &lt;strong&gt;XPipe&lt;/strong&gt; — both bundle a &lt;strong&gt;local&lt;/strong&gt; MCP server (Conduit over a socket/pipe, ~60 tools, Apache-2.0; XPipe a local HTTP endpoint, read-only by default). No cloud endpoint, nothing leaves the machine.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Termalin&lt;/strong&gt; — a bundled &lt;strong&gt;local&lt;/strong&gt; server (22 tools) &lt;em&gt;and&lt;/em&gt; a &lt;strong&gt;hosted&lt;/strong&gt; endpoint (&lt;code&gt;https://termal.in/mcp&lt;/code&gt;, 21 tools, OAuth sign-in for claude.ai/ChatGPT/Claude Code/Codex/Cursor). The MCP server is a &lt;strong&gt;key custodian&lt;/strong&gt; — it signs on the agent's behalf, so no key is handed over — reaches servers keylessly through an outbound Connector, applies a per-connection policy (full, allowlist or read-only), and writes an audit log.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The comparison
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Server&lt;/th&gt;
&lt;th&gt;Family&lt;/th&gt;
&lt;th&gt;Key custody&lt;/th&gt;
&lt;th&gt;Policy / approvals&lt;/th&gt;
&lt;th&gt;Audit&lt;/th&gt;
&lt;th&gt;Hosted&lt;/th&gt;
&lt;th&gt;License&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;tufantunc/ssh-mcp&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Standalone&lt;/td&gt;
&lt;td&gt;Key where it runs&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;v2: classes + RBAC + approvals&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Redacted log&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Runs anywhere headless&lt;/td&gt;
&lt;td&gt;MIT&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;mcp-ssh (Aionda)&lt;/td&gt;
&lt;td&gt;Standalone&lt;/td&gt;
&lt;td&gt;Key / &lt;code&gt;~/.ssh/config&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Basic&lt;/td&gt;
&lt;td&gt;Logs&lt;/td&gt;
&lt;td&gt;Headless&lt;/td&gt;
&lt;td&gt;OSS&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;the-nine-nation/remote-ssh-mcp&lt;/td&gt;
&lt;td&gt;Standalone&lt;/td&gt;
&lt;td&gt;Local OpenSSH&lt;/td&gt;
&lt;td&gt;Basic&lt;/td&gt;
&lt;td&gt;Logs&lt;/td&gt;
&lt;td&gt;Headless&lt;/td&gt;
&lt;td&gt;OSS&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;claude-ssh-mcp&lt;/td&gt;
&lt;td&gt;Standalone&lt;/td&gt;
&lt;td&gt;Password in chat&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;Headless&lt;/td&gt;
&lt;td&gt;OSS&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;browser-ssh&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Hosted&lt;/td&gt;
&lt;td&gt;On the service&lt;/td&gt;
&lt;td&gt;Scoped exec&lt;/td&gt;
&lt;td&gt;Audited&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Yes (URL)&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;sshrew&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Hosted&lt;/td&gt;
&lt;td&gt;On the dialing-out box&lt;/td&gt;
&lt;td&gt;Allowlisted commands&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Yes (cloudflared)&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;OSS&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Conduit&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Client&lt;/td&gt;
&lt;td&gt;In its AES-256 vault&lt;/td&gt;
&lt;td&gt;None we could find&lt;/td&gt;
&lt;td&gt;MCP calls logged&lt;/td&gt;
&lt;td&gt;No (local)&lt;/td&gt;
&lt;td&gt;Apache-2.0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;XPipe&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Client&lt;/td&gt;
&lt;td&gt;Client-held, local&lt;/td&gt;
&lt;td&gt;Read-only until enabled&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;No (local)&lt;/td&gt;
&lt;td&gt;Open-core&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Termalin&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Client&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Custodian signs; key never handed over&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Per-connection (full / allowlist / read-only)&lt;/td&gt;
&lt;td&gt;Watch grid + marked recordings + log&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Yes (OAuth + API key)&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Source-available in parts&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  When each fits
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;You want one small, auditable moving part and you run headless anyway&lt;/strong&gt; → &lt;strong&gt;tufantunc/ssh-mcp&lt;/strong&gt; v2, with RBAC and approvals turned on. It's the standalone server that takes policy seriously.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;You want the lightest native &lt;code&gt;ssh&lt;/code&gt; wrapper&lt;/strong&gt; → mcp-ssh or the-nine-nation, accepting that the key sits with the server and auditing is log-only.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The agent lives in a chat or CI and you don't run a desktop&lt;/strong&gt; → a hosted connector: &lt;strong&gt;browser-ssh&lt;/strong&gt; (fully managed) or &lt;strong&gt;sshrew&lt;/strong&gt; (self-hosted via cloudflared).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;You also want a human to watch live, a per-host policy enforced before a command runs, and the key never in the agent at all&lt;/strong&gt; → a client-bundled custodian. Conduit and XPipe cover the local case; &lt;strong&gt;Termalin&lt;/strong&gt; adds the hosted, keyless endpoint on top.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;There's no single winner — a hardened standalone server and a custodian client are solving the same problem from opposite ends. The question to settle first is the one from &lt;a href="https://termal.in/blog/what-is-an-mcp-server/" rel="noopener noreferrer"&gt;what an MCP server is&lt;/a&gt;: &lt;em&gt;who holds the credential?&lt;/em&gt; Everything in the table above flows from that answer. For the client-only view, see the &lt;a href="https://termal.in/blog/best-ssh-client-with-mcp-server/" rel="noopener noreferrer"&gt;buying guide&lt;/a&gt;.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Termalin is a cross-platform SSH client whose built-in MCP server is a key custodian with per-connection policy, a live watch grid and an agent-tagged audit log — &lt;a href="https://termal.in/download/" rel="noopener noreferrer"&gt;download it&lt;/a&gt; or read the &lt;a href="https://termal.in/security/" rel="noopener noreferrer"&gt;security model&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>mcp</category>
      <category>ssh</category>
      <category>ai</category>
      <category>security</category>
    </item>
    <item>
      <title>SSH into a server behind NAT or CGNAT, without port forwarding</title>
      <dc:creator>Svyatoslav Pavlov</dc:creator>
      <pubDate>Thu, 15 Oct 2026 15:55:05 +0000</pubDate>
      <link>https://dev.to/wolfhound1995/ssh-into-a-server-behind-nat-or-cgnat-without-port-forwarding-4dma</link>
      <guid>https://dev.to/wolfhound1995/ssh-into-a-server-behind-nat-or-cgnat-without-port-forwarding-4dma</guid>
      <description>&lt;p&gt;Reach a server with no open port: reverse SSH tunnel, Tailscale or WireGuard, Cloudflare Tunnel, or an outbound connector — with trade-offs and setup.&lt;/p&gt;

&lt;p&gt;You want to SSH into a machine you can't accept connections &lt;em&gt;to&lt;/em&gt;: a home server behind your router, a Raspberry Pi at the office, a box on a mobile or 5G link. The old answer — forward port 22 on the router — increasingly isn't available, and where it is, it's a bad idea. This post walks the four approaches that actually work, what each one costs you, and a worked example of each. None of them opens an inbound port on the machine you're reaching.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why you can't just forward a port anymore
&lt;/h2&gt;

&lt;p&gt;Two things get in the way. First, &lt;strong&gt;carrier-grade NAT (CGNAT)&lt;/strong&gt;: most home fibre, cable, mobile and Starlink connections now share one public IP across many customers, so there's no port on "your" address to forward — the router you control isn't the one holding the public IP. Second, even with a real public IP, a forwarded SSH port is found by scanners within minutes; the logs fill with brute-force attempts from the moment it goes live. The fix in every case below is the same shape: the private machine makes an &lt;strong&gt;outbound&lt;/strong&gt; connection to something reachable, and you ride that connection back in. Outbound works even through CGNAT and locked-down firewalls, because it's just another connection leaving the network.&lt;/p&gt;

&lt;h2&gt;
  
  
  The four options at a glance
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Approach&lt;/th&gt;
&lt;th&gt;What you run&lt;/th&gt;
&lt;th&gt;Extra infrastructure&lt;/th&gt;
&lt;th&gt;Long-lived key on the box?&lt;/th&gt;
&lt;th&gt;Best for&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Reverse SSH tunnel&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;ssh -R&lt;/code&gt; + autossh on the box&lt;/td&gt;
&lt;td&gt;a VPS with a public IP&lt;/td&gt;
&lt;td&gt;yes (to the VPS)&lt;/td&gt;
&lt;td&gt;you already rent a VPS&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Mesh VPN (Tailscale / WireGuard)&lt;/td&gt;
&lt;td&gt;a VPN client on both ends&lt;/td&gt;
&lt;td&gt;a coordination service&lt;/td&gt;
&lt;td&gt;your normal SSH key&lt;/td&gt;
&lt;td&gt;reaching several machines&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cloudflare Tunnel&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;cloudflared&lt;/code&gt; on the box&lt;/td&gt;
&lt;td&gt;a Cloudflare account + domain&lt;/td&gt;
&lt;td&gt;your normal SSH key&lt;/td&gt;
&lt;td&gt;you already use Cloudflare&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Outbound connector&lt;/td&gt;
&lt;td&gt;a small agent on the box&lt;/td&gt;
&lt;td&gt;a Termalin account&lt;/td&gt;
&lt;td&gt;no — short-lived certificate&lt;/td&gt;
&lt;td&gt;browser access, no VPS or VPN&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The first three give you a normal &lt;code&gt;ssh you@…&lt;/code&gt; from your laptop. The last one reaches the box from a browser or an AI agent instead. Read on for which fits.&lt;/p&gt;

&lt;h2&gt;
  
  
  Option 1 — Reverse SSH tunnel through a VPS
&lt;/h2&gt;

&lt;p&gt;If you already rent a cheap VPS with a public IP, the private box can dial out to it and forward its own SSH port back. On the box behind NAT:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;ssh &lt;span class="nt"&gt;-N&lt;/span&gt; &lt;span class="nt"&gt;-R&lt;/span&gt; 2222:localhost:22 tunnel@vps.example.com
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That opens port &lt;code&gt;2222&lt;/code&gt; on the VPS and pipes every connection to it back to port &lt;code&gt;22&lt;/code&gt; on the home box. From anywhere, jump through the VPS to land on the box:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;ssh &lt;span class="nt"&gt;-J&lt;/span&gt; tunnel@vps.example.com &lt;span class="nt"&gt;-p&lt;/span&gt; 2222 you@localhost
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A raw &lt;code&gt;ssh -R&lt;/code&gt; dies the first time the network hiccups, so wrap it in &lt;code&gt;autossh&lt;/code&gt; under a systemd unit to keep it alive across reboots and drops, and add &lt;code&gt;-o ServerAliveInterval=30 -o ExitOnForwardFailure=yes&lt;/code&gt;. The full mechanics — including the &lt;code&gt;GatewayPorts&lt;/code&gt; gotcha and why the tunnel binds to the VPS's loopback by default — are in &lt;a href="https://termal.in/blog/ssh-reverse-tunnel/" rel="noopener noreferrer"&gt;SSH reverse tunnels, explained&lt;/a&gt;. Trade-off: you're maintaining a VPS and a long-lived key on the box that can reach it, and that VPS is now part of your attack surface.&lt;/p&gt;

&lt;h2&gt;
  
  
  Option 2 — A mesh VPN (Tailscale or WireGuard)
&lt;/h2&gt;

&lt;p&gt;Instead of exposing one port, put both machines on a private network only they can see. Tailscale is the low-effort version. On the box behind NAT:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-fsSL&lt;/span&gt; https://tailscale.com/install.sh | sh
&lt;span class="nb"&gt;sudo &lt;/span&gt;tailscale up
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Install Tailscale on your laptop too, and both get a stable &lt;code&gt;100.x.y.z&lt;/code&gt; address (and a MagicDNS name). Then it's just:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;ssh you@home-box
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;as if you were on the same LAN — the box is never exposed to the public internet at all. &lt;code&gt;sudo tailscale up --ssh&lt;/code&gt; can even handle the SSH auth for you, so you don't manage keys for it. WireGuard is the same idea built by hand: you run the coordination yourself and configure peers manually — more work, no third-party coordinator. For a home lab with a NAS and a couple of Pis, a mesh VPN is usually the whole answer, and it's the default I reach for in the &lt;a href="https://termal.in/blog/ssh-into-home-nas-server/" rel="noopener noreferrer"&gt;home NAS/server guide&lt;/a&gt;. Trade-off: every device that needs access has to join the network, which is a client to install and an account to run.&lt;/p&gt;

&lt;h2&gt;
  
  
  Option 3 — Cloudflare Tunnel
&lt;/h2&gt;

&lt;p&gt;If your domain is already on Cloudflare, &lt;code&gt;cloudflared&lt;/code&gt; gives you an outbound tunnel with no open port. On the box:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;cloudflared tunnel login
cloudflared tunnel create home
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Route a hostname to the tunnel and point it at local sshd (&lt;code&gt;ssh://localhost:22&lt;/code&gt;) in the tunnel config, then run &lt;code&gt;cloudflared&lt;/code&gt; as a service. On the client side, SSH reaches it through a &lt;code&gt;ProxyCommand&lt;/code&gt; in your &lt;a href="https://termal.in/blog/ssh-config-file-guide/" rel="noopener noreferrer"&gt;&lt;code&gt;~/.ssh/config&lt;/code&gt;&lt;/a&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ssh"&gt;&lt;code&gt;&lt;span class="k"&gt;Host&lt;/span&gt; ssh.example.com
    &lt;span class="k"&gt;ProxyCommand&lt;/span&gt; cloudflared access ssh --hostname %h
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now &lt;code&gt;ssh you@ssh.example.com&lt;/code&gt; flows through Cloudflare's edge to the box. The free tier covers this. Trade-off: you're routing your SSH through a third party and taking a dependency on a Cloudflare account and a domain you host there — great if you already do, overkill if you don't.&lt;/p&gt;

&lt;h2&gt;
  
  
  Option 4 — An outbound connector (no VPS, no VPN)
&lt;/h2&gt;

&lt;p&gt;The three options above each ask for standing infrastructure — a VPS, a VPN, or a Cloudflare domain. Termalin's &lt;a href="https://termal.in/connector/" rel="noopener noreferrer"&gt;Connector&lt;/a&gt; collapses that to one command and reaches the box from your browser, the Termalin app or an AI agent instead of a local &lt;code&gt;ssh&lt;/code&gt;. On the private server, one line installs a small static agent (Linux x86_64 and arm64):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-fsSL&lt;/span&gt; https://termal.in/tunnel-install.sh | &lt;span class="nb"&gt;sudo &lt;/span&gt;&lt;span class="nv"&gt;TERMALIN_TOKEN&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&amp;lt;token&amp;gt; sh
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The agent dials &lt;strong&gt;out&lt;/strong&gt; to Termalin over a single WebSocket and holds it open; it never listens on a port. When you open a terminal in the &lt;a href="https://termal.in/web-ssh-client/" rel="noopener noreferrer"&gt;web client&lt;/a&gt;, the request travels down that link and the agent connects to the box's own sshd on &lt;code&gt;localhost&lt;/code&gt; — it only ever moves opaque SSH bytes, so it never sees your credentials or session plaintext. Enrolment makes the box's sshd trust Termalin's SSH certificate authority, so logins use a &lt;strong&gt;short-lived certificate&lt;/strong&gt; rather than a long-lived private key sitting on the server. That's the one row in the table with "no" under &lt;em&gt;long-lived key on the box&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;The catch, stated plainly: you reach the box through Termalin's hosted side — the web terminal, the Termalin desktop and iOS apps (which connect through the same gateway), or an AI agent over the &lt;a href="https://termal.in/mcp/" rel="noopener noreferrer"&gt;MCP server&lt;/a&gt; — not with a raw &lt;code&gt;ssh&lt;/code&gt; command from your own machine, and the Connector is part of the paid tier (or the trial). If what you want is a normal terminal from your laptop, options 1–3 fit better. If what you want is browser access from any device — or to let an agent work on a box that has no public address and no key to steal — this is the option built for it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Which one should you pick?
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;You already rent a VPS:&lt;/strong&gt; the reverse tunnel reuses it — one &lt;code&gt;ssh -R&lt;/code&gt; under autossh and you're done.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;You have more than one machine to reach, or want them to feel like a LAN:&lt;/strong&gt; a mesh VPN like Tailscale scales best and exposes nothing.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Your domain lives on Cloudflare:&lt;/strong&gt; their tunnel is a natural fit with no new vendor.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;You want zero standing infrastructure, browser access, or supervised agent access:&lt;/strong&gt; an outbound connector, with no long-lived key left on the server.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;All four share the one principle worth remembering: never open an inbound port on a machine you can't afford to have scanned. Let it reach out instead — that single reversal of direction is what makes SSH behind NAT safe.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Termalin is a free, cross-platform SSH client with saved tunnels, a web terminal and a keyless Connector for private servers — &lt;a href="https://termal.in/download/" rel="noopener noreferrer"&gt;download it&lt;/a&gt;, or see how it &lt;a href="https://termal.in/security/" rel="noopener noreferrer"&gt;handles your keys safely&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ssh</category>
      <category>tunneling</category>
      <category>networking</category>
      <category>homelab</category>
    </item>
    <item>
      <title>ssh-agent on Windows: start the service, add your keys, fix the errors</title>
      <dc:creator>Svyatoslav Pavlov</dc:creator>
      <pubDate>Tue, 13 Oct 2026 15:21:17 +0000</pubDate>
      <link>https://dev.to/wolfhound1995/ssh-agent-on-windows-start-the-service-add-your-keys-fix-the-errors-5bik</link>
      <guid>https://dev.to/wolfhound1995/ssh-agent-on-windows-start-the-service-add-your-keys-fix-the-errors-5bik</guid>
      <description>&lt;p&gt;You put a passphrase on your SSH key, and now Windows asks for it on every &lt;code&gt;git push&lt;/code&gt; and every &lt;code&gt;ssh&lt;/code&gt;. The fix is the SSH agent — unlock the key once, and every connection after that signs silently. The twist on Windows is that there isn't &lt;em&gt;one&lt;/em&gt; agent. There are three that can be running at the same time, they hold keys in separate places, and a key loaded into one is invisible to the others. Almost every "it worked yesterday" agent problem on Windows is really a &lt;em&gt;wrong agent&lt;/em&gt; problem. So this guide starts by naming them, then gets each one running, then decodes the errors you actually hit.&lt;/p&gt;

&lt;h2&gt;
  
  
  The three agents, and why they don't share keys
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Agent&lt;/th&gt;
&lt;th&gt;Ships with&lt;/th&gt;
&lt;th&gt;Where it listens&lt;/th&gt;
&lt;th&gt;Which &lt;code&gt;ssh&lt;/code&gt; sees it&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;OpenSSH Authentication Agent (a Windows service)&lt;/td&gt;
&lt;td&gt;Windows' native OpenSSH&lt;/td&gt;
&lt;td&gt;named pipe &lt;code&gt;\\.\pipe\openssh-ssh-agent&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;ssh.exe&lt;/code&gt; in &lt;code&gt;C:\Windows\System32\OpenSSH&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;The MSYS2 agent (&lt;code&gt;ssh-agent&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;Git for Windows / Git Bash&lt;/td&gt;
&lt;td&gt;a Unix-style socket named by &lt;code&gt;SSH_AUTH_SOCK&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;the &lt;code&gt;ssh&lt;/code&gt; bundled inside Git Bash&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;A Linux agent&lt;/td&gt;
&lt;td&gt;inside your WSL distro&lt;/td&gt;
&lt;td&gt;a Unix socket in that distro&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;ssh&lt;/code&gt; running inside WSL&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The lesson from the table: &lt;strong&gt;&lt;code&gt;ssh-add&lt;/code&gt; in Git Bash loads a key into the MSYS2 agent, and the native Windows &lt;code&gt;ssh.exe&lt;/code&gt; will never see it&lt;/strong&gt; — different endpoint, different key store. Pick one agent, load your keys there, and use the matching &lt;code&gt;ssh&lt;/code&gt;. Mixing them is the root cause of most of the confusion below. The &lt;a href="https://termal.in/blog/ssh-agent-ssh-add-guide/" rel="noopener noreferrer"&gt;ssh-agent and ssh-add guide&lt;/a&gt; covers the cross-platform mechanics; this page is the Windows-specific half.&lt;/p&gt;

&lt;h2&gt;
  
  
  The built-in OpenSSH Authentication Agent service
&lt;/h2&gt;

&lt;p&gt;Modern Windows 10 and 11 ship OpenSSH, and with it a Windows service that runs the agent — but it's off by default. Enable it once, from an &lt;strong&gt;elevated&lt;/strong&gt; PowerShell (Run as administrator):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight powershell"&gt;&lt;code&gt;&lt;span class="n"&gt;Set-Service&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;ssh-agent&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;-StartupType&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;Automatic&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="n"&gt;Start-Service&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;ssh-agent&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;Automatic&lt;/code&gt; starts it on every boot, so this is a one-time setup. Check the result:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight powershell"&gt;&lt;code&gt;&lt;span class="n"&gt;Get-Service&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;ssh-agent&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;|&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;Select-Object&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;Status&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;StartType&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You want &lt;code&gt;Running&lt;/code&gt; and &lt;code&gt;Automatic&lt;/code&gt;. From then on, in a normal (non-Git-Bash) PowerShell or &lt;code&gt;cmd&lt;/code&gt;, &lt;code&gt;ssh-add&lt;/code&gt; talks to this service:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight powershell"&gt;&lt;code&gt;&lt;span class="n"&gt;ssh-add&lt;/span&gt;&lt;span class="w"&gt;                                 &lt;/span&gt;&lt;span class="c"&gt;# load default keys (id_ed25519, id_rsa, …)&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="n"&gt;ssh-add&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nv"&gt;$&lt;/span&gt;&lt;span class="nn"&gt;env&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="nv"&gt;USERPROFILE&lt;/span&gt;&lt;span class="nx"&gt;\.ssh\id_ed25519&lt;/span&gt;&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="c"&gt;# load a specific key&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="n"&gt;ssh-add&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;-l&lt;/span&gt;&lt;span class="w"&gt;                              &lt;/span&gt;&lt;span class="c"&gt;# list loaded key fingerprints&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;ssh-add -l&lt;/code&gt; is your first diagnostic for any login failure — if the key you expect isn't listed, the agent can't sign with it, whatever else looks right.&lt;/p&gt;

&lt;p&gt;To stop retyping &lt;code&gt;ssh-add&lt;/code&gt;, let the first connection load the key for you. Put this in &lt;code&gt;%USERPROFILE%\.ssh\config&lt;/code&gt; (the same &lt;a href="https://termal.in/blog/ssh-config-file-guide/" rel="noopener noreferrer"&gt;ssh_config file&lt;/a&gt; OpenSSH reads everywhere):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ssh"&gt;&lt;code&gt;&lt;span class="k"&gt;Host&lt;/span&gt; *
    &lt;span class="k"&gt;AddKeysToAgent&lt;/span&gt; &lt;span class="no"&gt;yes&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now the first &lt;code&gt;ssh&lt;/code&gt; that needs a passphrase prompts once and loads the key into the service as a side effect. Because the native Windows &lt;code&gt;git&lt;/code&gt; and &lt;code&gt;ssh&lt;/code&gt; both use this service, &lt;code&gt;git push&lt;/code&gt; starts working silently too.&lt;/p&gt;

&lt;h2&gt;
  
  
  Error 1058: "unable to start the ssh-agent service"
&lt;/h2&gt;

&lt;p&gt;You run &lt;code&gt;Start-Service ssh-agent&lt;/code&gt; and get:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Start-Service : Service 'OpenSSH Authentication Agent (ssh-agent)' cannot be started due to the following error:
Cannot start service ssh-agent on computer '.'.
... error 1058 ...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Error &lt;strong&gt;1058&lt;/strong&gt; means the service is &lt;em&gt;disabled&lt;/em&gt; — Windows won't start a service whose Startup type is &lt;code&gt;Disabled&lt;/code&gt;, and &lt;code&gt;Start-Service&lt;/code&gt; doesn't change that for you. Fix the startup type first, then start it (elevated PowerShell):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight powershell"&gt;&lt;code&gt;&lt;span class="n"&gt;Set-Service&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;ssh-agent&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;-StartupType&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;Manual&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="n"&gt;Start-Service&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;ssh-agent&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;Manual&lt;/code&gt; is enough to start it on demand; use &lt;code&gt;Automatic&lt;/code&gt; if you want it up on every boot. If you're on a managed or domain machine, a group policy may have disabled it — in that case your admin has to allow the service before this sticks.&lt;/p&gt;

&lt;h2&gt;
  
  
  "error connecting to agent: No such file or directory"
&lt;/h2&gt;

&lt;p&gt;This one comes from the &lt;strong&gt;Git Bash / MSYS2&lt;/strong&gt; &lt;code&gt;ssh&lt;/code&gt;, not the Windows service. It means that &lt;code&gt;ssh&lt;/code&gt; was told where to find an agent socket — via &lt;code&gt;SSH_AUTH_SOCK&lt;/code&gt; — and nothing is there. Two common causes:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;No agent is running in this shell.&lt;/strong&gt; Git Bash doesn't start one for you. Start it in the shell you're using:
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;  &lt;span class="nb"&gt;eval&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;ssh-agent &lt;span class="nt"&gt;-s&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
  ssh-add ~/.ssh/id_ed25519
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;eval&lt;/code&gt; matters: &lt;code&gt;ssh-agent -s&lt;/code&gt; only &lt;em&gt;prints&lt;/em&gt; the variables; the &lt;code&gt;eval&lt;/code&gt; sets them so &lt;code&gt;ssh&lt;/code&gt; can find the socket. Running &lt;code&gt;ssh-agent&lt;/code&gt; bare and wondering why nothing changed is the classic misstep.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;SSH_AUTH_SOCK&lt;/code&gt; is stale.&lt;/strong&gt; A variable set by a previous session, a login profile, or another tool points at a socket that no longer exists. Check it with &lt;code&gt;echo $SSH_AUTH_SOCK&lt;/code&gt;, then either unset it and start a fresh agent, or fix it to point at a live one.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you'd rather use the Windows service from inside Git Bash, call the native binary directly — &lt;code&gt;/c/Windows/System32/OpenSSH/ssh.exe&lt;/code&gt; — instead of the MSYS2 &lt;code&gt;ssh&lt;/code&gt; on your &lt;code&gt;PATH&lt;/code&gt;. Then Git Bash reaches the same keys you loaded into the service, and &lt;code&gt;SSH_AUTH_SOCK&lt;/code&gt; stops mattering.&lt;/p&gt;

&lt;h2&gt;
  
  
  Keys in WSL
&lt;/h2&gt;

&lt;p&gt;WSL is a separate Linux system, so it has its own agent, its own &lt;code&gt;~/.ssh&lt;/code&gt;, and its own &lt;code&gt;SSH_AUTH_SOCK&lt;/code&gt;. Keys loaded on the Windows side are not visible inside your distro, and vice versa. You have two honest choices:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Run an agent inside WSL&lt;/strong&gt; the ordinary Linux way — &lt;code&gt;eval "$(ssh-agent -s)"&lt;/code&gt; and &lt;code&gt;ssh-add&lt;/code&gt;, or &lt;code&gt;AddKeysToAgent yes&lt;/code&gt; in the distro's &lt;code&gt;~/.ssh/config&lt;/code&gt;. Simple, fully isolated, and covered in the &lt;a href="https://termal.in/blog/ssh-agent-ssh-add-guide/" rel="noopener noreferrer"&gt;ssh-add guide&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Bridge to the Windows agent&lt;/strong&gt; so both worlds share one key store. The usual recipe pipes the Windows named pipe into a Unix socket that WSL's &lt;code&gt;SSH_AUTH_SOCK&lt;/code&gt; points at, using &lt;code&gt;npiperelay.exe&lt;/code&gt; together with &lt;code&gt;socat&lt;/code&gt;. It works well once set up, but it's extra moving parts — reach for it only if you genuinely need the same key on both sides.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Whichever you pick, remember that &lt;code&gt;ssh-add -l&lt;/code&gt; inside WSL is a &lt;em&gt;different question&lt;/em&gt; from &lt;code&gt;ssh-add -l&lt;/code&gt; in PowerShell. Ask the right one when you debug.&lt;/p&gt;

&lt;h2&gt;
  
  
  When the agent is fine but login still fails
&lt;/h2&gt;

&lt;p&gt;If &lt;code&gt;ssh-add -l&lt;/code&gt; shows your key and the server still says &lt;code&gt;Permission denied (publickey)&lt;/code&gt;, the agent has done its job — the problem moved downstream to the server's &lt;code&gt;authorized_keys&lt;/code&gt;, the wrong user, or the wrong key being offered. That's a separate checklist: &lt;a href="https://termal.in/blog/ssh-permission-denied-publickey/" rel="noopener noreferrer"&gt;Permission denied (publickey), fixed in order&lt;/a&gt;. And before you forward the agent to a remote box with &lt;code&gt;ssh -A&lt;/code&gt;, read why &lt;a href="https://termal.in/blog/ssh-agent-forwarding-risks/" rel="noopener noreferrer"&gt;agent forwarding is convenient and dangerous&lt;/a&gt; — it exposes live use of your keys on the far end.&lt;/p&gt;

&lt;h2&gt;
  
  
  How Termalin handles this on Windows
&lt;/h2&gt;

&lt;p&gt;If juggling services, sockets and three key stores is more than you want to think about, that's the workflow &lt;a href="https://termal.in/features/" rel="noopener noreferrer"&gt;Termalin&lt;/a&gt; builds in. It runs its own key agent on a Windows named pipe, &lt;code&gt;\\.\pipe\termalin-ssh-agent&lt;/code&gt;, and serves the keys you've already unlocked in the app over the &lt;strong&gt;standard ssh-agent protocol&lt;/strong&gt; — so a tool that speaks that protocol can sign through it with no OS service to enable, no &lt;code&gt;eval&lt;/code&gt; line, and no manual &lt;code&gt;ssh-add&lt;/code&gt;. The private keys stay in the running app and are never written to disk. The point of that agent is to let Termalin's own &lt;a href="https://termal.in/mcp/" rel="noopener noreferrer"&gt;MCP server&lt;/a&gt; authenticate to your servers with your keys &lt;strong&gt;without ever holding the key material itself&lt;/strong&gt; — the agent signs, the caller never sees the secret. It's the same one-unlock idea as the OpenSSH service, with the Windows setup taken off your plate.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Termalin is a free, cross-platform SSH client whose built-in agent unlocks your keys once and signs on your behalf — &lt;a href="https://termal.in/download/" rel="noopener noreferrer"&gt;download it&lt;/a&gt;, or read how it &lt;a href="https://termal.in/security/" rel="noopener noreferrer"&gt;handles your keys safely&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ssh</category>
      <category>sshagent</category>
      <category>windows</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>The best SSH client with a built-in MCP server (2026 buying guide)</title>
      <dc:creator>Svyatoslav Pavlov</dc:creator>
      <pubDate>Thu, 08 Oct 2026 16:57:37 +0000</pubDate>
      <link>https://dev.to/wolfhound1995/the-best-ssh-client-with-a-built-in-mcp-server-2026-buying-guide-3eke</link>
      <guid>https://dev.to/wolfhound1995/the-best-ssh-client-with-a-built-in-mcp-server-2026-buying-guide-3eke</guid>
      <description>&lt;p&gt;&lt;em&gt;Five checks for an SSH client with a built-in MCP server: key custody, per-host policy, a hosted endpoint, live oversight, platforms. How Termalin scores.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;"SSH client with a built-in MCP server" is a young category, and it's easy to be sold the wrong thing. A lot of what ships under that banner is a thin wrapper: an &lt;code&gt;ssh_exec&lt;/code&gt; tool with a private key sitting in the wrapper's config. That gets a credential out of the model's context — genuinely worth something — but it stops there, and &lt;em&gt;there&lt;/em&gt; is where most of the risk lives. This guide is about the criteria that separate a demo from something you'd actually leave running, and how to judge any client against them.&lt;/p&gt;

&lt;p&gt;If you want the wide competitive landscape — Termalin, VibeShell, Gumpbox, the hosted proxies and the watch-only iOS apps — that's the &lt;a href="https://termal.in/blog/ssh-clients-for-ai-agents-2026/" rel="noopener noreferrer"&gt;full roundup&lt;/a&gt;. This post is the shorter, sharper version: the five things to check before you commit.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Verdict:&lt;/strong&gt; the client that answers all five is the one whose MCP server &lt;em&gt;is&lt;/em&gt; a key custodian, enforces a per-host command policy, ships both a local binary and a hosted endpoint, and shows you the sessions live. By that bar the recommendation is Termalin — with three honest exceptions (a fully open-source client you can audit line by line, Apple-only native sandboxing, or a Docker/Kubernetes manager) spelled out at the end. Here's how the shapes on the market score:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Option&lt;/th&gt;
&lt;th&gt;Key custody&lt;/th&gt;
&lt;th&gt;Per-host policy&lt;/th&gt;
&lt;th&gt;Hosted endpoint&lt;/th&gt;
&lt;th&gt;Live oversight&lt;/th&gt;
&lt;th&gt;Platforms&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Termalin&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Agent never holds the key — the MCP server signs&lt;/td&gt;
&lt;td&gt;Full / allowlist / blocked, per host locally (per API key on the hosted endpoint); a host is only exposed once you tick it&lt;/td&gt;
&lt;td&gt;Yes — scoped, expiring API keys (Pro or trial)&lt;/td&gt;
&lt;td&gt;Watch grid for agent-driven tabs + marked, output-only recordings; hosted runs audit-logged&lt;/td&gt;
&lt;td&gt;Windows, macOS, Linux, iOS (+ web)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Thin &lt;code&gt;ssh_exec&lt;/code&gt; wrapper&lt;/td&gt;
&lt;td&gt;No — the key sits in the wrapper's config&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Varies&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Standalone MCP server&lt;/td&gt;
&lt;td&gt;Usually plain key files in the agent's reach&lt;/td&gt;
&lt;td&gt;The good ones take it seriously&lt;/td&gt;
&lt;td&gt;It &lt;em&gt;is&lt;/em&gt; headless — runs anywhere&lt;/td&gt;
&lt;td&gt;No — logs after the fact, if configured&lt;/td&gt;
&lt;td&gt;Anywhere headless&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Hosted SSH proxy&lt;/td&gt;
&lt;td&gt;Credentials sit in the connection path&lt;/td&gt;
&lt;td&gt;Per-command policy mid-connection&lt;/td&gt;
&lt;td&gt;Yes — it's a hosted service&lt;/td&gt;
&lt;td&gt;Approvals mid-connection&lt;/td&gt;
&lt;td&gt;A service, not a client&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;macOS/iOS-only watcher&lt;/td&gt;
&lt;td&gt;Keychain-held&lt;/td&gt;
&lt;td&gt;Varies&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Consent sheet per action&lt;/td&gt;
&lt;td&gt;Apple only&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  1. Who holds the key — and can the agent ever read it?
&lt;/h2&gt;

&lt;p&gt;The first question, and the one every option answers differently. Pasting a key into the agent's environment is &lt;a href="https://termal.in/blog/agent-ssh-access-blast-radius/" rel="noopener noreferrer"&gt;the one move you can't take back&lt;/a&gt;: once the bytes have passed through a model's context, a transcript, or a tool call you didn't read, you can't prove they didn't leak. The bar to look for is a &lt;strong&gt;key custodian&lt;/strong&gt;: the agent asks, and something you control does the authenticating — you unlock once, the client signs on the agent's behalf, and there's no key file for the agent to read at all. Ask of any client: &lt;em&gt;if this agent's context leaks tomorrow, is a credential in it?&lt;/em&gt; If the answer is yes, keep looking.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Can you bound what the agent runs — per host?
&lt;/h2&gt;

&lt;p&gt;Key custody is necessary and not sufficient. A custodian that hands over an unrestricted shell has solved custody and left the &lt;a href="https://termal.in/blog/per-host-command-allowlists-for-ai-agents/" rel="noopener noreferrer"&gt;blast radius&lt;/a&gt; wide open. What you want is a &lt;strong&gt;per-host command policy&lt;/strong&gt;: each host set to full, an allowlist of commands, or blocked, enforced &lt;em&gt;before&lt;/em&gt; a command runs — not a setting the agent is trusted to honor. This is the difference between "the agent has a shell on my fleet" and "the agent may run these commands, on these hosts, and nothing else."&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Does it work when your desktop isn't running?
&lt;/h2&gt;

&lt;p&gt;A local MCP server (a stdio binary the agent launches) is the right default for an agent on your laptop. But agents increasingly run where you aren't — CI jobs, cloud sandboxes, chat assistants like &lt;a href="https://termal.in/claude-ssh/" rel="noopener noreferrer"&gt;claude.ai&lt;/a&gt; and &lt;a href="https://termal.in/chatgpt-ssh/" rel="noopener noreferrer"&gt;ChatGPT&lt;/a&gt;. For those you need a &lt;strong&gt;hosted endpoint&lt;/strong&gt;: an HTTPS URL the agent reaches with a scoped, expiring API key, that connects to your servers keyless (a short-lived certificate, not a standing key) with no desktop running anywhere. A client that only offers the local binary can't help an agent in &lt;a href="https://termal.in/blog/agent-ssh-access-in-ci-cd/" rel="noopener noreferrer"&gt;CI/CD&lt;/a&gt;. One that offers both covers the whole range.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Can you see what it did — and prove it later?
&lt;/h2&gt;

&lt;p&gt;An agent's summary of its own work is not a record; it's a statement by the thing being audited. Look for two things: &lt;strong&gt;live oversight&lt;/strong&gt; — a view where sessions the agent is driving are visible as they happen, so you can step in — and an &lt;strong&gt;audit trail made outside the agent&lt;/strong&gt;, where its commands are &lt;a href="https://termal.in/blog/audit-what-an-ai-agent-does-on-your-servers/" rel="noopener noreferrer"&gt;marked as the agent's&lt;/a&gt;, with host, device and time, and session output is recorded (output only — a client that records keystrokes has built a keylogger, not an audit log). "What did the agent do at 2 a.m.?" should be a filter you apply, not a story you reconstruct.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. Does it run where you do?
&lt;/h2&gt;

&lt;p&gt;Mundane, but it eliminates options fast. Several agent-SSH tools are single-platform (macOS/iOS only, or one desktop OS). If your fleet is managed from a mix of Windows, macOS and Linux — and increasingly a phone — a client that only exists on one of them isn't a contender, however good its agent story.&lt;/p&gt;

&lt;h2&gt;
  
  
  How Termalin scores against its own checklist
&lt;/h2&gt;

&lt;p&gt;We built &lt;a href="https://termal.in/mcp/" rel="noopener noreferrer"&gt;Termalin&lt;/a&gt; to answer all five, so here's the honest scorecard:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Key custody&lt;/strong&gt; — the built-in MCP server &lt;em&gt;is&lt;/em&gt; the custodian. You unlock once; it signs on the agent's behalf; no key ever reaches the agent or the model.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Per-host policy&lt;/strong&gt; — locally, a host is only exposed to the agent once you tick it, and each exposed host is full, an allowlist, or blocked, enforced at the point the agent asks. On the hosted endpoint the same three levels are set per API key, and AI access can be switched off per server.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Local &lt;em&gt;and&lt;/em&gt; hosted&lt;/strong&gt; — a bundled &lt;code&gt;termalin-mcp&lt;/code&gt; binary for agents on your machine (&lt;a href="https://termal.in/blog/connect-claude-code-to-your-server-over-ssh/" rel="noopener noreferrer"&gt;Claude Code&lt;/a&gt;, &lt;a href="https://termal.in/blog/cursor-ssh-access-for-agents/" rel="noopener noreferrer"&gt;Cursor&lt;/a&gt;, &lt;a href="https://termal.in/blog/connect-codex-to-your-server-over-ssh/" rel="noopener noreferrer"&gt;Codex&lt;/a&gt;, &lt;a href="https://termal.in/blog/connect-gemini-cli-to-your-server-over-ssh/" rel="noopener noreferrer"&gt;Gemini&lt;/a&gt;), plus a hosted endpoint (on Pro, or the 14-day trial) with scoped, expiring API keys for CI, cloud sandboxes, and the claude.ai / ChatGPT connectors.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Oversight&lt;/strong&gt; — a live &lt;a href="https://termal.in/watch-grid/" rel="noopener noreferrer"&gt;watch grid&lt;/a&gt; where terminal tabs an agent drives glow, output-only recordings of those tabs with each agent command marked, and on the hosted endpoint an audit log of every run: server, login, command, exit code and time. (Headless &lt;code&gt;ssh_exec&lt;/code&gt; calls from the local binary open no tab and aren't audit-logged yet.)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Platforms&lt;/strong&gt; — Windows, macOS, Linux and iOS (iPhone, iPad, Apple Watch), plus a browser cabinet for anything else.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;strong&gt;Where it isn't the answer:&lt;/strong&gt; if you need a fully open-source client you can audit line by line, Termalin isn't that (it's a company product, source-available in parts). If your agent work is Apple-only and you want the tightest native sandboxing on the target, a macOS/iOS-only tool may fit better. And Termalin is deliberately an SSH-focused client, not a Docker/Kubernetes/hypervisor manager — if that browsing is the point, a tool built for it will go deeper. The &lt;a href="https://termal.in/blog/ssh-clients-for-ai-agents-2026/" rel="noopener noreferrer"&gt;landscape post&lt;/a&gt; lays those trade-offs out in a table, including recommendations against ourselves.&lt;/p&gt;

&lt;h2&gt;
  
  
  The short version
&lt;/h2&gt;

&lt;p&gt;If you're choosing an SSH client to give AI agents server access, don't stop at "it has an MCP server." Check that the agent never holds the key, that you can bound what it runs per host, that it works with no desktop for CI and chat agents, that you can watch and audit it, and that it runs on your platforms. A tool that does all five is one you can leave running; a tool that only checks the first is a demo you'll have to babysit.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Termalin is a cross-platform SSH client whose built-in MCP server is a key custodian with per-host policy, a live watch grid and an agent-tagged audit log — &lt;a href="https://termal.in/download/" rel="noopener noreferrer"&gt;download it&lt;/a&gt;, or read the &lt;a href="https://termal.in/security/" rel="noopener noreferrer"&gt;security model&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>mcp</category>
      <category>ssh</category>
      <category>ai</category>
      <category>security</category>
    </item>
    <item>
      <title>Connect Google's Gemini CLI to your servers over SSH — safely</title>
      <dc:creator>Svyatoslav Pavlov</dc:creator>
      <pubDate>Wed, 07 Oct 2026 20:23:33 +0000</pubDate>
      <link>https://dev.to/wolfhound1995/connect-googles-gemini-cli-to-your-servers-over-ssh-safely-50eh</link>
      <guid>https://dev.to/wolfhound1995/connect-googles-gemini-cli-to-your-servers-over-ssh-safely-50eh</guid>
      <description>&lt;p&gt;&lt;em&gt;Gemini CLI speaks MCP, so it runs commands and manages files on your servers through Termalin — one entry in ~/.gemini/settings.json, no key handed over.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Google's Gemini CLI is a capable agent in a terminal, and like the other coding agents it eventually needs to leave your laptop and touch a real server — restart a service, tail a log, run the deploy on the box itself. The tempting shortcut is to let it run &lt;code&gt;ssh&lt;/code&gt; directly: the CLI already executes shell commands, so if a key sits unlocked in &lt;code&gt;~/.ssh&lt;/code&gt; or a running &lt;code&gt;ssh-agent&lt;/code&gt;, &lt;code&gt;ssh prod&lt;/code&gt; works for it whether or not you meant to grant that. That's the whole problem in one line — you gave away full server access by default.&lt;/p&gt;

&lt;p&gt;Gemini CLI has a cleaner path built in: it speaks &lt;a href="https://termal.in/blog/what-is-an-mcp-server/" rel="noopener noreferrer"&gt;MCP&lt;/a&gt;, the same protocol &lt;a href="https://termal.in/blog/connect-claude-code-to-your-server-over-ssh/" rel="noopener noreferrer"&gt;Claude Code&lt;/a&gt;, &lt;a href="https://termal.in/blog/cursor-ssh-access-for-agents/" rel="noopener noreferrer"&gt;Cursor&lt;/a&gt; and &lt;a href="https://termal.in/blog/connect-codex-to-your-server-over-ssh/" rel="noopener noreferrer"&gt;Codex&lt;/a&gt; use. Termalin is an SSH client with a built-in MCP server, so you point Gemini at that server and it works your hosts for real — while the agent never holds a key. About twenty minutes, one host.&lt;/p&gt;

&lt;h2&gt;
  
  
  What you need
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Termalin&lt;/strong&gt; — the desktop app; the &lt;a href="https://termal.in/download/" rel="noopener noreferrer"&gt;Free tier is fine&lt;/a&gt;. Its built-in MCP server is what Gemini talks to.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Gemini CLI&lt;/strong&gt;, on the same machine.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;One host&lt;/strong&gt; saved in Termalin — something low-stakes for the first run.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Step 1 — turn on agent access and set a policy
&lt;/h2&gt;

&lt;p&gt;Agent access is &lt;strong&gt;off by default&lt;/strong&gt;. In Termalin, open &lt;strong&gt;Settings → MCP&lt;/strong&gt;, enable it, and tick the hosts Gemini may reach. Nothing outside that list exists as far as the agent is concerned — a new host is a decision you make in the app, not something the agent can talk its way into.&lt;/p&gt;

&lt;p&gt;Under the hood, Termalin writes a host inventory for the MCP server with &lt;strong&gt;agent-only auth entries — no passwords or keys land in that file&lt;/strong&gt; — and authentication stays with the app's key custodian: you unlock once, and Termalin signs on the agent's behalf. There's no key file for Gemini to read, so there's nothing to leak, and the &lt;code&gt;~/.ssh&lt;/code&gt; back door above closes with it.&lt;/p&gt;

&lt;p&gt;While you're there, set each host's &lt;strong&gt;agent policy&lt;/strong&gt;: full access, an allowlist of commands, or blocked. Start with an allowlist for anything you care about — the &lt;a href="https://termal.in/blog/per-host-command-allowlists-for-ai-agents/" rel="noopener noreferrer"&gt;least-privilege setup is its own short post&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 2 — register the server in Gemini's settings
&lt;/h2&gt;

&lt;p&gt;Gemini CLI reads MCP servers from the &lt;code&gt;mcpServers&lt;/code&gt; object in its settings file — &lt;code&gt;~/.gemini/settings.json&lt;/code&gt; for all your projects, or a project-local &lt;code&gt;.gemini/settings.json&lt;/code&gt;. Add one entry pointing at the bundled &lt;code&gt;termalin-mcp&lt;/code&gt; binary:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"mcpServers"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"termalin"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"command"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"&amp;lt;path&amp;gt;/termalin-mcp"&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's the whole integration. Restart Gemini CLI (or reload its MCP servers), and the tools appear to the agent: &lt;code&gt;hosts_list&lt;/code&gt;, &lt;code&gt;ssh_exec&lt;/code&gt;, persistent sessions (&lt;code&gt;session_open&lt;/code&gt; / &lt;code&gt;session_exec&lt;/code&gt;), SFTP reads and writes, and port forwards. Run &lt;code&gt;/mcp&lt;/code&gt; in Gemini to confirm the server is connected and see the tools it exposes. The &lt;a href="https://termal.in/docs/mcp-tools/" rel="noopener noreferrer"&gt;full tool list&lt;/a&gt; documents every one.&lt;/p&gt;

&lt;p&gt;If you'd rather not wire MCP at all, there's a lighter option: Termalin has a local terminal tab, so you can just type &lt;code&gt;gemini&lt;/code&gt; and run the CLI right inside the app next to your SSH sessions. That gives you Gemini in the same window — but it's the MCP setup above that lets Gemini act &lt;em&gt;on your servers&lt;/em&gt; through the custodian, which is the part that keeps your keys out of its reach.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 3 — give it a real chore
&lt;/h2&gt;

&lt;p&gt;Skip the demo. Hand it something real:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;The deploy to &lt;em&gt;staging-1&lt;/em&gt; finished but the health check keeps flapping. Find out why and fix it.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Gemini calls &lt;code&gt;hosts_list&lt;/code&gt;, sees &lt;code&gt;staging-1&lt;/code&gt;, opens a session, and starts pulling threads — service status, the last hundred log lines, the config diff against what's running. It proposes a fix, applies it within the policy you set, and confirms the check goes green.&lt;/p&gt;

&lt;p&gt;What makes this comfortable rather than nerve-wracking is that you can watch it. Turn on &lt;strong&gt;Agent in Terminal&lt;/strong&gt; in Settings and Gemini can work in the app's own terminal tabs (&lt;code&gt;terminal_open&lt;/code&gt; / &lt;code&gt;terminal_run&lt;/code&gt;): each tab it opens is marked as the agent's, and the &lt;strong&gt;&lt;a href="https://termal.in/blog/watch-many-ssh-agent-sessions-live/" rel="noopener noreferrer"&gt;watch grid&lt;/a&gt;&lt;/strong&gt; puts all open sessions side by side with the agent's tiles glowing, so you see the commands land and can step in at any point. Its headless calls (&lt;code&gt;ssh_exec&lt;/code&gt;, &lt;code&gt;session_*&lt;/code&gt;) don't open a tab; they run straight from the MCP server, inside the host list and policy you set.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where the boundaries are
&lt;/h2&gt;

&lt;p&gt;Defaults worth knowing before you point it at anything that matters:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Your keys never enter Gemini's environment.&lt;/strong&gt; The custodian signs; the agent requests. Revoking access is a toggle in Settings, not a key rotation across your fleet.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Its reach is the host list&lt;/strong&gt; you ticked in Step 1, and each host's command policy bounds what it can run there. "Full" on production is still a full shell — set it deliberately.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Typing into &lt;em&gt;your&lt;/em&gt; session is separate.&lt;/strong&gt; By default Gemini opens its own sessions; joining one you already have open is its own consent toggle.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Watch it, or record it.&lt;/strong&gt; Agent work in the app's terminal tabs is visible live and can be recorded — output only, never keystrokes. &lt;a href="https://termal.in/blog/audit-what-an-ai-agent-does-on-your-servers/" rel="noopener noreferrer"&gt;How auditing an agent works&lt;/a&gt; covers the rest.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  When Gemini runs in the cloud
&lt;/h2&gt;

&lt;p&gt;If Gemini is running where your desktop isn't — a CI job, a cloud sandbox — the local binary isn't reachable, but Termalin's &lt;strong&gt;hosted MCP endpoint&lt;/strong&gt; is (on Pro, or the 14-day trial). Mint an API key in the web cabinet, scope it to the right servers, set it read-only or to an allowlist, give it an expiry, and point the agent at &lt;code&gt;https://termal.in/api/v1/mcp&lt;/code&gt;. It reaches your &lt;a href="https://termal.in/mcp/" rel="noopener noreferrer"&gt;Connector-enrolled servers&lt;/a&gt; keyless, authenticating each run with a short-lived certificate — the &lt;a href="https://termal.in/blog/agent-ssh-access-in-ci-cd/" rel="noopener noreferrer"&gt;full CI/CD pattern is here&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The habit that makes it stick
&lt;/h2&gt;

&lt;p&gt;Treat the first week like onboarding a fast teammate. Keep the watch grid open, give Gemini chores with a clear done-state, and read how it gets there before widening anything. The trade is the point: Gemini gets real reach on your servers, and you keep the one thing — the keys — that can't be handed back once it's out.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Termalin is a cross-platform SSH client with a built-in MCP server, a key custodian and &lt;a href="https://termal.in/ssh-for-ai-agents/" rel="noopener noreferrer"&gt;per-host agent policy&lt;/a&gt; — &lt;a href="https://termal.in/download/" rel="noopener noreferrer"&gt;download it&lt;/a&gt;, read the &lt;a href="https://termal.in/mcp/" rel="noopener noreferrer"&gt;MCP reference&lt;/a&gt;, or see how it &lt;a href="https://termal.in/security/" rel="noopener noreferrer"&gt;handles keys&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>gemini</category>
      <category>mcp</category>
      <category>ai</category>
      <category>ssh</category>
    </item>
    <item>
      <title>Watch many SSH sessions at once — a live grid for humans and agents</title>
      <dc:creator>Svyatoslav Pavlov</dc:creator>
      <pubDate>Tue, 06 Oct 2026 18:08:47 +0000</pubDate>
      <link>https://dev.to/wolfhound1995/watch-many-ssh-sessions-at-once-a-live-grid-for-humans-and-agents-3l6n</link>
      <guid>https://dev.to/wolfhound1995/watch-many-ssh-sessions-at-once-a-live-grid-for-humans-and-agents-3l6n</guid>
      <description>&lt;p&gt;&lt;em&gt;tmux and csshX tile your terminals; Termalin's watch grid tiles every open SSH session as a live view and glows the ones an AI agent is driving.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Anyone who has run more than a couple of servers knows the failure mode: eight terminal windows, a deploy going out across four of them, and no single place to see whether it's working. The old answers — &lt;code&gt;tmux&lt;/code&gt; panes, &lt;code&gt;csshX&lt;/code&gt;, a wall of iTerm splits — tile terminals so you can glance across them. They work, and they stop working the moment the thing you're watching isn't just &lt;em&gt;you&lt;/em&gt;. When an AI agent is driving some of those sessions, "glance across the panes" isn't enough: you need to know &lt;em&gt;which&lt;/em&gt; sessions are the agent's, and you need to be able to step in. That's what a watch grid is for.&lt;/p&gt;

&lt;h2&gt;
  
  
  The problem tmux solves, and the one it doesn't
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;tmux&lt;/code&gt; and &lt;code&gt;csshX&lt;/code&gt; are about &lt;em&gt;your&lt;/em&gt; attention across &lt;em&gt;your&lt;/em&gt; sessions. Broadcast a command to a set of panes, watch the outputs line up, done. It's a genuinely good tool for fan-out work — patch twelve boxes, tail a log on all of them at once.&lt;/p&gt;

&lt;p&gt;What they don't model is &lt;em&gt;authorship&lt;/em&gt;. Every pane is you; there's no notion of "this session is being driven by something else, and I'm supervising it." In 2026 that's the gap, because more and more of the sessions on your machine are opened by an agent — &lt;a href="https://termal.in/blog/connect-claude-code-to-your-server-over-ssh/" rel="noopener noreferrer"&gt;Claude Code&lt;/a&gt;, &lt;a href="https://termal.in/blog/connect-codex-to-your-server-over-ssh/" rel="noopener noreferrer"&gt;Codex&lt;/a&gt;, &lt;a href="https://termal.in/blog/connect-gemini-cli-to-your-server-over-ssh/" rel="noopener noreferrer"&gt;Gemini&lt;/a&gt; — working a host through an &lt;a href="https://termal.in/mcp/" rel="noopener noreferrer"&gt;MCP server&lt;/a&gt;. A pane full of commands you didn't type needs a different kind of attention than one you did.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the watch grid does
&lt;/h2&gt;

&lt;p&gt;Termalin's &lt;a href="https://termal.in/watch-grid/" rel="noopener noreferrer"&gt;watch grid&lt;/a&gt; is one screen that mirrors &lt;strong&gt;every open session as a live tile&lt;/strong&gt;. Not a summary, not a status dot — the actual terminal, updating as output arrives, laid out side by side. Two things make it more than a pretty &lt;code&gt;tmux&lt;/code&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Agent-driven tiles glow.&lt;/strong&gt; A session an AI agent is running is visually marked, in real time, while it acts. You don't have to remember which tab you handed to the agent; the grid tells you at a glance, across the whole fleet if need be.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;You can step in.&lt;/strong&gt; A tile isn't a read-only feed. See the agent heading somewhere you don't like, and you drop into that session and take over — the same session, live, no reconnecting. Supervision, not just observation.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;em&gt;Which agents show up here: ones working through Termalin's local MCP server inside the app's own sessions (the "Agent in Terminal" switch). Calls from a hosted chat connector, such as Claude or ChatGPT in the browser, run one by one on the server and land in the audit log instead of a tile.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Because every session stays alive in the background whether or not it's on screen, switching to a tile is instant and nothing is lost when you look away. (Keeping dozens of live SSH sessions responsive at once is its own engineering problem — we wrote up &lt;a href="https://termal.in/blog/take-it-out-of-the-map-rust-sessions/" rel="noopener noreferrer"&gt;how the session store stays lock-free under that load&lt;/a&gt;.)&lt;/p&gt;

&lt;h2&gt;
  
  
  Why "glowing" matters more than it sounds
&lt;/h2&gt;

&lt;p&gt;The live view changes what supervision costs. Without it, letting an agent work a server is an act of faith punctuated by reading a summary afterward — and a summary is &lt;a href="https://termal.in/blog/audit-what-an-ai-agent-does-on-your-servers/" rel="noopener noreferrer"&gt;written by the thing you're supervising&lt;/a&gt;, so it's the least trustworthy possible record. With a grid where agent sessions glow, oversight is ambient: you see the commands land as they happen, and a steered agent — one talked into something by a poisoned log line or filename — gets caught in the seconds it takes to notice, not in a postmortem. It doesn't lower the odds of a bad instruction; it collapses the time between the instruction and you seeing it.&lt;/p&gt;

&lt;p&gt;That's the same argument as recording, one step earlier in time. Recording lets you answer "what did it do?" after the fact; the watch grid lets you answer "what is it doing?" right now, and intervene. Together they're the &lt;a href="https://termal.in/blog/agent-ssh-access-blast-radius/" rel="noopener noreferrer"&gt;blast-radius&lt;/a&gt; safety net: a &lt;a href="https://termal.in/blog/per-host-command-allowlists-for-ai-agents/" rel="noopener noreferrer"&gt;per-host policy&lt;/a&gt; bounds what the agent &lt;em&gt;can&lt;/em&gt; do, the grid shows you what it &lt;em&gt;is&lt;/em&gt; doing, and the marked recording proves what it &lt;em&gt;did&lt;/em&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  For a human fleet, too
&lt;/h2&gt;

&lt;p&gt;None of this is agent-only. The grid is also just a good way to run several servers at once:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;A deploy across a set of hosts&lt;/strong&gt; — watch all of them go green (or one of them not) in one view, instead of alt-tabbing to find the straggler.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;An incident&lt;/strong&gt; — pull up the affected boxes as tiles, with each one's live CPU, memory and disk in the status bar, so "which node is actually pegged?" is visible, not guessed.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Routine fan-out&lt;/strong&gt; — paired with send-to-many input, the grid is the classic &lt;code&gt;csshX&lt;/code&gt; workflow with a modern client around it: &lt;a href="https://termal.in/blog/ssh-into-home-nas-server/" rel="noopener noreferrer"&gt;reach a whole folder of hosts at once&lt;/a&gt;, type once, watch everywhere.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The difference from stitching this together with &lt;code&gt;tmux&lt;/code&gt; and SSH config is that it's one app that already knows your hosts, holds your keys safely, and keeps the sessions alive — so the grid is a button, not a shell script you maintain.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where recording and audit fit
&lt;/h2&gt;

&lt;p&gt;The watch grid is the &lt;em&gt;live&lt;/em&gt; layer; it pairs with the durable one. Every session can be recorded &lt;strong&gt;output-only&lt;/strong&gt; (never keystrokes), with each agent command marked on the replay timeline, and the audit log keeps the who/when/from-where — the same client-side approach that makes &lt;a href="https://termal.in/blog/record-audit-ssh-sessions-bastion/" rel="noopener noreferrer"&gt;recording and auditing sessions&lt;/a&gt; work through a jump host. Watch live to catch things fast; replay and audit to prove what happened. You want both, and they're the same feature seen at two speeds.&lt;/p&gt;

&lt;h2&gt;
  
  
  The short version
&lt;/h2&gt;

&lt;p&gt;If your reason for tiling terminals is "I have a lot of servers," &lt;code&gt;tmux&lt;/code&gt; and &lt;code&gt;csshX&lt;/code&gt; are fine and free. If some of those sessions are driven by an AI agent — or will be soon — you want a view that marks authorship, lets you take over, and feeds a recording you can trust. That's the watch grid: &lt;a href="https://termal.in/watch-grid/" rel="noopener noreferrer"&gt;see it in motion&lt;/a&gt;, then &lt;a href="https://termal.in/ssh-for-ai-agents/" rel="noopener noreferrer"&gt;set your agents loose&lt;/a&gt; knowing you're watching.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Termalin is a free, cross-platform SSH client with a live watch grid, output-only recording and a built-in MCP server — &lt;a href="https://termal.in/download/" rel="noopener noreferrer"&gt;download it&lt;/a&gt;, or read how it &lt;a href="https://termal.in/security/" rel="noopener noreferrer"&gt;keeps keys safe&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ssh</category>
      <category>ai</category>
      <category>productivity</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>Leaving MobaXterm: take your sessions and keys to macOS or Linux</title>
      <dc:creator>Svyatoslav Pavlov</dc:creator>
      <pubDate>Mon, 05 Oct 2026 18:29:23 +0000</pubDate>
      <link>https://dev.to/wolfhound1995/leaving-mobaxterm-take-your-sessions-and-keys-to-macos-or-linux-14ej</link>
      <guid>https://dev.to/wolfhound1995/leaving-mobaxterm-take-your-sessions-and-keys-to-macos-or-linux-14ej</guid>
      <description>&lt;p&gt;&lt;em&gt;Where MobaXterm stores bookmarks and keys, how to export them, and how to land the whole setup in a cross-platform client — plus when to stay.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;MobaXterm earned its following. On Windows it's the Swiss Army knife: tabbed terminal, a real X server, SFTP browser docked beside every shell, and a pile of bundled Unix tools that mean you rarely reach for anything else. If you live on Windows and lean on the X server, you may not need to leave at all — this guide will even tell you when to stay.&lt;/p&gt;

&lt;p&gt;But two itches send people looking. One is the platform wall: MobaXterm is &lt;strong&gt;Windows-only&lt;/strong&gt;, so the day you pick up a Mac or a Linux box, your whole setup stays behind. The other is the opposite of its strength — some people want a fast, focused SSH client, not a toolbox that does everything. Either way, getting out with your sessions, keys and habits intact is a smaller job than it looks. It's written with &lt;a href="https://termal.in/mobaxterm-alternative/" rel="noopener noreferrer"&gt;Termalin&lt;/a&gt; as the destination, but the first half applies to any modern client.&lt;/p&gt;

&lt;h2&gt;
  
  
  Know where MobaXterm keeps your stuff
&lt;/h2&gt;

&lt;p&gt;MobaXterm calls saved sessions &lt;strong&gt;bookmarks&lt;/strong&gt;, and where they live depends on which edition you run:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Installer edition&lt;/strong&gt; — in the Windows registry, under &lt;code&gt;HKEY_CURRENT_USER\Software\Mobatek\MobaXterm&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Portable edition&lt;/strong&gt; — in a &lt;code&gt;MobaXterm.ini&lt;/code&gt; file sitting next to the &lt;code&gt;.exe&lt;/code&gt;. That single file is your whole configuration, which is why the portable build travels on a USB stick.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Private keys&lt;/strong&gt; — wherever you saved them. MobaXterm works with standard OpenSSH keys and with PuTTY's &lt;code&gt;.ppk&lt;/code&gt;, and it ships &lt;strong&gt;MobaKeyGen&lt;/strong&gt; (a PuTTYgen clone) for making and converting them.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Saved passwords&lt;/strong&gt; — in MobaXterm's own credential store, protected by its master password. These do not export in plaintext, by design — you'll re-enter them once in the new client.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Step 1 — export the sessions
&lt;/h2&gt;

&lt;p&gt;The portable, any-OS route: open the &lt;strong&gt;Sessions&lt;/strong&gt; sidebar, right-click the top &lt;strong&gt;User sessions&lt;/strong&gt; node, and choose &lt;strong&gt;Export sessions&lt;/strong&gt; to write a &lt;code&gt;.mxtsessions&lt;/code&gt; file (it's plain text — you can open it and read your inventory). That file is what an importer consumes on a Mac or Linux box where the registry doesn't exist.&lt;/p&gt;

&lt;p&gt;In Termalin, open the hosts list's import menu and paste that file or load it — the portable edition's &lt;code&gt;MobaXterm.ini&lt;/code&gt; works too. Bookmarks arrive &lt;strong&gt;with their folders&lt;/strong&gt;, as regular hosts you can sort, tag and colour-label. (The same importer also takes an &lt;code&gt;~/.ssh/config&lt;/code&gt;, a CSV, or a PuTTY &lt;code&gt;.reg&lt;/code&gt; export, and on Windows it can read saved PuTTY sessions straight from the registry, if your setup is a mix.)&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 2 — bring the keys
&lt;/h2&gt;

&lt;p&gt;Here MobaXterm users are usually in good shape, because MobaXterm already speaks OpenSSH. If your keys are OpenSSH-format &lt;code&gt;id_ed25519&lt;/code&gt; / &lt;code&gt;id_rsa&lt;/code&gt; files, just import them into the new client — done.&lt;/p&gt;

&lt;p&gt;If some keys are in PuTTY's &lt;code&gt;.ppk&lt;/code&gt; format (common if you migrated &lt;em&gt;into&lt;/em&gt; MobaXterm from PuTTY years ago), convert them once: open &lt;strong&gt;MobaKeyGen&lt;/strong&gt;, &lt;code&gt;Load&lt;/code&gt; the &lt;code&gt;.ppk&lt;/code&gt;, and &lt;strong&gt;Export OpenSSH key&lt;/strong&gt; (force the new format — the legacy one predates Ed25519). The passphrase carries over. New to key formats? The &lt;a href="https://termal.in/blog/ssh-key-types-ed25519-vs-rsa/" rel="noopener noreferrer"&gt;Ed25519 vs RSA&lt;/a&gt; explainer is the thirty-second version, and a migration is a fine moment to generate a modern key for anything still on an old RSA one.&lt;/p&gt;

&lt;p&gt;In Termalin the imported keys go into the built-in agent, so it's &lt;strong&gt;one unlock per session of work&lt;/strong&gt; with passphrase caching on a timer you set, the same role MobaXterm's agent played. It speaks the standard ssh-agent protocol on its own per-user pipe or socket, so a CLI pointed at it with &lt;code&gt;SSH_AUTH_SOCK&lt;/code&gt; can sign through it too.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 3 — don't blindly re-trust every server
&lt;/h2&gt;

&lt;p&gt;MobaXterm remembers the host keys of servers you've connected to. Don't try to migrate that list. A move is the one natural moment you'll ever get to re-verify what you're actually connecting to. A modern client (Termalin uses trust-on-first-use with a proper &lt;a href="https://termal.in/blog/ssh-host-key-verification-failed/" rel="noopener noreferrer"&gt;&lt;code&gt;known_hosts&lt;/code&gt;&lt;/a&gt;) shows each server's fingerprint on first connect — glance at the ones that matter, accept, move on. Ten minutes for a fleet you'll then trust cleanly for years.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 4 — the habits that carry over (and the one that doesn't)
&lt;/h2&gt;

&lt;p&gt;Most of what you like about MobaXterm has a direct equivalent:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Tabs&lt;/strong&gt; — you already think in tabs; you keep them (browser-style, drag to reorder, an activity glow on the quiet ones).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Files beside the shell&lt;/strong&gt; — MobaXterm's docked SFTP browser becomes an SFTP panel in the same tab as the terminal: browse, edit in place with a built-in editor, upload. No second window, no re-entering credentials.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The credential vault&lt;/strong&gt; — replaced by a local encrypted vault with a master password, auto-lock, and (on Windows) Windows Hello to re-unlock.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The one habit that does &lt;strong&gt;not&lt;/strong&gt; carry over: the &lt;strong&gt;bundled X server&lt;/strong&gt;. Termalin deliberately isn't a toolbox — it's a focused client for SSH, SFTP, FTP, Telnet, Serial and S3, and it does not ship an X server or a bag of Unix binaries. If forwarding X11 GUI apps from a server to your Windows desktop is part of your daily work, that's the honest reason to keep MobaXterm installed alongside — use the right tool for that job and let the new client handle the day-to-day.&lt;/p&gt;

&lt;h2&gt;
  
  
  What you gain that MobaXterm can't offer
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The same setup on every OS.&lt;/strong&gt; &lt;a href="https://termal.in/ssh-client-for-windows/" rel="noopener noreferrer"&gt;Windows&lt;/a&gt;, &lt;a href="https://termal.in/ssh-client-for-macos/" rel="noopener noreferrer"&gt;macOS&lt;/a&gt; and &lt;a href="https://termal.in/ssh-client-for-linux/" rel="noopener noreferrer"&gt;Linux&lt;/a&gt; desktops and native iPhone and iPad apps, with your hosts, keys and command history following you — plus a &lt;a href="https://termal.in/web-ssh-client/" rel="noopener noreferrer"&gt;browser cabinet&lt;/a&gt; at &lt;code&gt;termal.in/account&lt;/code&gt; for a real terminal from any laptop, phone or tablet with no install. That's the wall MobaXterm can't cross.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Encrypted sync instead of a USB stick (Pro).&lt;/strong&gt; Your vault syncs end-to-end encrypted, so the sync server only ever holds ciphertext — the modern version of carrying &lt;code&gt;MobaXterm.ini&lt;/code&gt; around.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;An agent layer.&lt;/strong&gt; A built-in &lt;a href="https://termal.in/mcp/" rel="noopener noreferrer"&gt;MCP server&lt;/a&gt; lets an AI agent — &lt;a href="https://termal.in/blog/connect-claude-code-to-your-server-over-ssh/" rel="noopener noreferrer"&gt;Claude Code&lt;/a&gt;, Cursor, Codex — work your servers through the app without ever holding a key, under a per-host policy you set and mirrored in a live watch grid.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;On price: MobaXterm's Professional license is a one-time &lt;strong&gt;$69&lt;/strong&gt;. Termalin's Free tier is the whole desktop app, local, with no host limits; Pro (the encrypted cloud) is &lt;strong&gt;$8/mo billed yearly&lt;/strong&gt;, with a one-time lifetime deal during the beta and a 14-day trial that needs no card. Different models — pick the one that fits how you like to pay.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Termalin runs on Windows, macOS, Linux and iOS; the Free tier has no host limits and imports a MobaXterm sessions export with its folders — &lt;a href="https://termal.in/download/" rel="noopener noreferrer"&gt;download it&lt;/a&gt;, or see the full &lt;a href="https://termal.in/mobaxterm-alternative/" rel="noopener noreferrer"&gt;MobaXterm comparison&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>mobaxterm</category>
      <category>ssh</category>
      <category>migration</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>Take it out of the map: keeping a shared session store lock-free during slow work</title>
      <dc:creator>Svyatoslav Pavlov</dc:creator>
      <pubDate>Sun, 04 Oct 2026 16:27:10 +0000</pubDate>
      <link>https://dev.to/wolfhound1995/take-it-out-of-the-map-keeping-a-shared-session-store-lock-free-during-slow-work-h3o</link>
      <guid>https://dev.to/wolfhound1995/take-it-out-of-the-map-keeping-a-shared-session-store-lock-free-during-slow-work-h3o</guid>
      <description>&lt;p&gt;&lt;em&gt;An SSH client keeps every live session in a shared map behind a lock. Holding it through a slow handshake froze every other session — here's the Rust fix.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Here's a shape you'll recognise if you've ever put "a bunch of live things" behind one lock. An SSH client keeps its open sessions in a single map:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="k"&gt;struct&lt;/span&gt; &lt;span class="n"&gt;SessionManager&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;sessions&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;HashMap&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nb"&gt;String&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;SshSession&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="c1"&gt;// ...&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;static&lt;/span&gt; &lt;span class="n"&gt;SESSION_MANAGER&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;OnceLock&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nb"&gt;Arc&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;Mutex&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;SessionManager&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&amp;gt;&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nn"&gt;OnceLock&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;new&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One &lt;code&gt;Arc&amp;lt;Mutex&amp;lt;HashMap&amp;lt;Id, Session&amp;gt;&amp;gt;&amp;gt;&lt;/code&gt;, shared by every tab. Reads are cheap — grab the lock, find the session, write some bytes, drop the lock. It worked fine until someone opened a tenth tab and connected to a new host, and every &lt;em&gt;other&lt;/em&gt; tab froze for three seconds. Through a jump host, longer.&lt;/p&gt;

&lt;h2&gt;
  
  
  The bug is one &lt;code&gt;.await&lt;/code&gt; too many
&lt;/h2&gt;

&lt;p&gt;The connect path looked like the obvious thing:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;fn&lt;/span&gt; &lt;span class="nf"&gt;connect_session&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;creds&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Creds&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;Result&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="nb"&gt;String&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;mgr&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;get_session_manager&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="k"&gt;mut&lt;/span&gt; &lt;span class="n"&gt;mgr&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;mgr&lt;/span&gt;&lt;span class="nf"&gt;.lock&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="k"&gt;.await&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;session&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;mgr&lt;/span&gt;&lt;span class="py"&gt;.sessions&lt;/span&gt;&lt;span class="nf"&gt;.get_mut&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="nf"&gt;.ok_or&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"not found"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;?&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="n"&gt;session&lt;/span&gt;&lt;span class="nf"&gt;.connect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;creds&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="k"&gt;.await&lt;/span&gt;   &lt;span class="c1"&gt;// &amp;lt;-- SSH handshake: seconds&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The problem is the last line. &lt;code&gt;session.connect(...).await&lt;/code&gt; is the full SSH handshake — key exchange, auth, maybe an agent trying keys in batches, maybe a whole second hop through a jump host. And we're holding the manager lock across it. Every keystroke in every other session, every stats poll, every "write these bytes to the terminal" now waits in line behind a network round-trip to a machine that might be on the other side of the planet.&lt;/p&gt;

&lt;p&gt;Holding a lock across &lt;code&gt;.await&lt;/code&gt; isn't a deadlock here — tokio's &lt;code&gt;Mutex&lt;/code&gt; is fair and it'll get released. It's &lt;em&gt;head-of-line blocking&lt;/em&gt;: the lock is held for as long as the slowest operation under it takes, and everyone else pays for it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Move ownership out, do the slow work unlocked
&lt;/h2&gt;

&lt;p&gt;The fix is to stop treating the session as something you &lt;em&gt;borrow from the map&lt;/em&gt; for the duration of a slow call. Take it &lt;em&gt;out&lt;/em&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="cd"&gt;/// Remove a session from the map so a slow `&amp;amp;mut self` op can run on it&lt;/span&gt;
&lt;span class="cd"&gt;/// without holding the manager lock. The caller MUST hand it back.&lt;/span&gt;
&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;fn&lt;/span&gt; &lt;span class="nf"&gt;take_session_for_op&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;Result&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;SshSession&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;String&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="k"&gt;mut&lt;/span&gt; &lt;span class="n"&gt;mgr&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;get_session_manager&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="nf"&gt;.lock&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="k"&gt;.await&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;mgr&lt;/span&gt;&lt;span class="py"&gt;.in_flight&lt;/span&gt;&lt;span class="nf"&gt;.contains_key&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;Err&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"an operation is already in progress for this session"&lt;/span&gt;&lt;span class="nf"&gt;.into&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;match&lt;/span&gt; &lt;span class="n"&gt;mgr&lt;/span&gt;&lt;span class="py"&gt;.sessions&lt;/span&gt;&lt;span class="nf"&gt;.remove&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nf"&gt;Some&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;session&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="n"&gt;mgr&lt;/span&gt;&lt;span class="py"&gt;.in_flight&lt;/span&gt;&lt;span class="nf"&gt;.insert&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="nf"&gt;.to_string&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="k"&gt;false&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// false = not cancelled&lt;/span&gt;
            &lt;span class="nf"&gt;Ok&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;session&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="nb"&gt;None&lt;/span&gt; &lt;span class="k"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;Err&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"session not found"&lt;/span&gt;&lt;span class="nf"&gt;.into&lt;/span&gt;&lt;span class="p"&gt;()),&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now the lock is held only long enough to &lt;code&gt;remove&lt;/code&gt; the entry and record that an operation is running. The connect becomes:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;fn&lt;/span&gt; &lt;span class="nf"&gt;connect_session&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;String&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;creds&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Creds&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;Result&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="nb"&gt;String&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="k"&gt;mut&lt;/span&gt; &lt;span class="n"&gt;session&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;take_session_for_op&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="k"&gt;.await&lt;/span&gt;&lt;span class="o"&gt;?&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// brief lock&lt;/span&gt;
    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;session&lt;/span&gt;&lt;span class="nf"&gt;.connect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;creds&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="k"&gt;.await&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;         &lt;span class="c1"&gt;// NO lock held&lt;/span&gt;
    &lt;span class="nf"&gt;finish_session_op&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;session&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="k"&gt;.await&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;             &lt;span class="c1"&gt;// brief lock&lt;/span&gt;
    &lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="nf"&gt;.map_err&lt;/span&gt;&lt;span class="p"&gt;(|&lt;/span&gt;&lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;|&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="nf"&gt;.to_string&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Between the first and third line the manager lock is free. The ten other tabs keep echoing keystrokes and drawing output while the new connection does its handshake in the background, holding nothing but its own owned &lt;code&gt;SshSession&lt;/code&gt;. The slow work is exactly as slow as before — it just stops being everyone else's problem.&lt;/p&gt;

&lt;h2&gt;
  
  
  Now handle the two races you just created
&lt;/h2&gt;

&lt;p&gt;The moment a session can be "outside the map," two things can go wrong, and both have bitten real users.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A second operation on the same session.&lt;/strong&gt; Someone double-clicks connect, or a reconnect fires while the first is still handshaking. That's what the &lt;code&gt;in_flight&lt;/code&gt; map is for: &lt;code&gt;take_session_for_op&lt;/code&gt; refuses if an entry is already there. One in-flight &lt;code&gt;&amp;amp;mut self&lt;/code&gt; operation per session, enforced at the door.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A disconnect arrives mid-handshake.&lt;/strong&gt; This is the nasty one. The user closes the tab while the connection is still coming up. &lt;code&gt;disconnect&lt;/code&gt; grabs the lock, looks in &lt;code&gt;sessions&lt;/code&gt;… and it's not there, because we took it out. If you do nothing, the handshake finishes a moment later, &lt;code&gt;finish_session_op&lt;/code&gt; puts a now-fully-connected session back into the map, and you have a live connection nobody is watching — a zombie.&lt;/p&gt;

&lt;p&gt;So the in-flight marker doubles as a cancel flag:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;fn&lt;/span&gt; &lt;span class="nf"&gt;disconnect_session&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;String&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;Result&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="nb"&gt;String&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;owned&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="k"&gt;mut&lt;/span&gt; &lt;span class="n"&gt;mgr&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;get_session_manager&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="nf"&gt;.lock&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="k"&gt;.await&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="c1"&gt;// Not in the map? It's mid-connect. Flag the in-flight op to tear&lt;/span&gt;
        &lt;span class="c1"&gt;// its fresh connection down instead of re-inserting it.&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="n"&gt;mgr&lt;/span&gt;&lt;span class="py"&gt;.sessions&lt;/span&gt;&lt;span class="nf"&gt;.contains_key&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nf"&gt;Some&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;cancel&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;mgr&lt;/span&gt;&lt;span class="py"&gt;.in_flight&lt;/span&gt;&lt;span class="nf"&gt;.get_mut&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;cancel&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;true&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
            &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="n"&gt;mgr&lt;/span&gt;&lt;span class="py"&gt;.sessions&lt;/span&gt;&lt;span class="nf"&gt;.remove&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="c1"&gt;// Some(..) if already connected; None if mid-connect&lt;/span&gt;
    &lt;span class="p"&gt;};&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nf"&gt;Some&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;mut&lt;/span&gt; &lt;span class="n"&gt;session&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;owned&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;session&lt;/span&gt;&lt;span class="nf"&gt;.disconnect&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="k"&gt;.await&lt;/span&gt;&lt;span class="o"&gt;?&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// slow part, lock released&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="nf"&gt;Ok&lt;/span&gt;&lt;span class="p"&gt;(())&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And the return path honours it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;fn&lt;/span&gt; &lt;span class="nf"&gt;finish_session_op&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;session&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;SshSession&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="k"&gt;mut&lt;/span&gt; &lt;span class="n"&gt;to_drop&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;None&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="k"&gt;mut&lt;/span&gt; &lt;span class="n"&gt;mgr&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;get_session_manager&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="nf"&gt;.lock&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="k"&gt;.await&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="c1"&gt;// unwrap_or(true): if the marker vanished, assume cancelled.&lt;/span&gt;
        &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;cancelled&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;mgr&lt;/span&gt;&lt;span class="py"&gt;.in_flight&lt;/span&gt;&lt;span class="nf"&gt;.remove&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="nf"&gt;.unwrap_or&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;true&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;cancelled&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="n"&gt;to_drop&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;Some&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;session&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;          &lt;span class="c1"&gt;// don't re-insert&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="n"&gt;mgr&lt;/span&gt;&lt;span class="py"&gt;.sessions&lt;/span&gt;&lt;span class="nf"&gt;.insert&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="nf"&gt;.into&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;session&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nf"&gt;Some&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;mut&lt;/span&gt; &lt;span class="n"&gt;session&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;to_drop&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;session&lt;/span&gt;&lt;span class="nf"&gt;.disconnect&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="k"&gt;.await&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;   &lt;span class="c1"&gt;// tear the fresh connection down&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The decision — reinsert or tear down — is made under the lock by reading one flag; the actual teardown happens after the lock is dropped. A disconnect that races a connect now always wins cleanly: either it removes an already-connected session, or it leaves a note that turns the in-flight connection into a no-op on arrival. No zombie either way.&lt;/p&gt;

&lt;h2&gt;
  
  
  Reaping the dead, without a background timer
&lt;/h2&gt;

&lt;p&gt;Sessions also die on their own — the server hangs up, the network drops, the OS kills a backgrounded app. Those need collecting too, and the same "short lock, slow work released" discipline applies:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;fn&lt;/span&gt; &lt;span class="nf"&gt;cleanup_dead_sessions&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="k"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;Vec&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nb"&gt;String&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;dead&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;owned&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="k"&gt;mut&lt;/span&gt; &lt;span class="n"&gt;mgr&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;get_session_manager&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="nf"&gt;.lock&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="k"&gt;.await&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;dead&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Vec&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nb"&gt;String&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;mgr&lt;/span&gt;&lt;span class="py"&gt;.sessions&lt;/span&gt;&lt;span class="nf"&gt;.iter&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
            &lt;span class="nf"&gt;.filter&lt;/span&gt;&lt;span class="p"&gt;(|(&lt;/span&gt;&lt;span class="n"&gt;_&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;)|&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="nf"&gt;.is_handle_closed&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
            &lt;span class="nf"&gt;.map&lt;/span&gt;&lt;span class="p"&gt;(|(&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt;&lt;span class="p"&gt;)|&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="nf"&gt;.clone&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
            &lt;span class="nf"&gt;.collect&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
        &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;owned&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Vec&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;_&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;dead&lt;/span&gt;&lt;span class="nf"&gt;.iter&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="nf"&gt;.filter_map&lt;/span&gt;&lt;span class="p"&gt;(|&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;|&lt;/span&gt; &lt;span class="n"&gt;mgr&lt;/span&gt;&lt;span class="py"&gt;.sessions&lt;/span&gt;&lt;span class="nf"&gt;.remove&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;&lt;span class="nf"&gt;.collect&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
        &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;dead&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;owned&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;};&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="k"&gt;mut&lt;/span&gt; &lt;span class="n"&gt;session&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="n"&gt;owned&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;session&lt;/span&gt;&lt;span class="nf"&gt;.disconnect&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="k"&gt;.await&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// lock released&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="n"&gt;dead&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two deliberate choices here. There's &lt;strong&gt;no background task&lt;/strong&gt; — this is the only GC path, and the UI just calls it on a cheap interval (and on demand). A background reaper is one more thing holding the lock on a timer you don't control; calling it explicitly keeps GC on &lt;em&gt;your&lt;/em&gt; schedule. And it only touches sessions that are actually in the map: anything mid-connect (owned out of the map) or never-connected is left alone, so the reaper and an in-flight handshake can never fight over the same session.&lt;/p&gt;

&lt;h2&gt;
  
  
  The shape, minus the SSH
&lt;/h2&gt;

&lt;p&gt;Strip out the protocol and the pattern is reusable anywhere you keep live, individually-slow things in a shared map:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Hold the shared lock only long enough to &lt;strong&gt;move ownership out&lt;/strong&gt; (&lt;code&gt;remove&lt;/code&gt;), not for the duration of the work.&lt;/li&gt;
&lt;li&gt;Do the slow &lt;code&gt;async&lt;/code&gt; work on the &lt;strong&gt;owned value&lt;/strong&gt;, lock released.&lt;/li&gt;
&lt;li&gt;Re-acquire briefly to &lt;strong&gt;put it back&lt;/strong&gt; — with a guard against a second concurrent operation, and a &lt;strong&gt;cancel flag&lt;/strong&gt; so an op that raced the slow work can tear its result down instead of reinserting it.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;It costs you a second map (&lt;code&gt;in_flight&lt;/code&gt;) and a little bookkeeping, and it buys you a lock that's never held longer than a &lt;code&gt;HashMap::remove&lt;/code&gt;. For anything behind &lt;code&gt;Arc&amp;lt;Mutex&amp;lt;HashMap&amp;lt;_, _&amp;gt;&amp;gt;&amp;gt;&lt;/code&gt; where the per-entry operations touch the network, that trade is almost always worth it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where this lives
&lt;/h2&gt;

&lt;p&gt;This is from the Rust core of &lt;a href="https://termal.in/features/" rel="noopener noreferrer"&gt;Termalin&lt;/a&gt;, a cross-platform SSH client (the protocol layer is &lt;code&gt;russh&lt;/code&gt;). The full session manager has more moving parts — per-session output buffers, tunnels, stats collectors — but the connect/disconnect/reap paths all follow the take-and-return discipline above.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Termalin is a fast SSH client for you and your AI agents — &lt;a href="https://termal.in/features/" rel="noopener noreferrer"&gt;have a look&lt;/a&gt;, or read &lt;a href="https://termal.in/mcp/" rel="noopener noreferrer"&gt;how the agent side works&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>rust</category>
      <category>concurrency</category>
      <category>ssh</category>
      <category>programming</category>
    </item>
    <item>
      <title>SSH reverse tunnels (ssh -R): expose a local port through a remote server</title>
      <dc:creator>Svyatoslav Pavlov</dc:creator>
      <pubDate>Sat, 03 Oct 2026 19:12:34 +0000</pubDate>
      <link>https://dev.to/wolfhound1995/ssh-reverse-tunnels-ssh-r-expose-a-local-port-through-a-remote-server-4bm8</link>
      <guid>https://dev.to/wolfhound1995/ssh-reverse-tunnels-ssh-r-expose-a-local-port-through-a-remote-server-4bm8</guid>
      <description>&lt;p&gt;&lt;em&gt;How ssh -R works — the listener opens on the remote side. Demoing a dev server, reaching a machine behind NAT, catching webhooks, plus GatewayPorts.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;You have something running on your laptop — a dev server, an API, a machine at home behind NAT — and something on the other side of the internet needs to reach it. No public IP, no router access, no time to set up a VPN. This is the job of the SSH reverse tunnel, &lt;code&gt;ssh -R&lt;/code&gt;: it makes a port appear on a server you &lt;em&gt;can&lt;/em&gt; reach, and quietly pipes everything that hits it back to your machine.&lt;/p&gt;

&lt;p&gt;It's the flag people find backwards, so let's fix the mental model first, then work through the real cases.&lt;/p&gt;

&lt;h2&gt;
  
  
  The mental model: &lt;code&gt;-L&lt;/code&gt;, mirrored
&lt;/h2&gt;

&lt;p&gt;Every SSH tunnel opens a listening port on one machine and ships whatever arrives to somewhere reachable from the other machine. With local forwarding (&lt;code&gt;-L&lt;/code&gt;), the port opens on &lt;strong&gt;your&lt;/strong&gt; machine and traffic exits on the server's side. A reverse tunnel is the exact mirror image:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;With &lt;code&gt;-R&lt;/code&gt;, the listening port opens on the &lt;em&gt;remote server&lt;/em&gt;, and connections to it come out on &lt;em&gt;your&lt;/em&gt; side.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;That's the entire trick. The machine that can't accept inbound connections makes an &lt;em&gt;outbound&lt;/em&gt; SSH connection instead — and rides connections back in over it. If the two still blur together, the full family portrait is in &lt;a href="https://termal.in/blog/ssh-port-forwarding-tunnels/" rel="noopener noreferrer"&gt;SSH port forwarding, explained&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The syntax, read left to right
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;ssh &lt;span class="nt"&gt;-R&lt;/span&gt; &lt;span class="o"&gt;[&lt;/span&gt;bind_address:]remote_port:localhost:local_port user@server
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Read it as: &lt;em&gt;on the server, listen on &lt;code&gt;remote_port&lt;/code&gt;; forward whatever arrives to &lt;code&gt;localhost:local_port&lt;/code&gt; — as resolved from my machine.&lt;/em&gt; So:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;ssh &lt;span class="nt"&gt;-R&lt;/span&gt; 8080:localhost:3000 deploy@vps.example.com
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;opens port &lt;code&gt;8080&lt;/code&gt; on the VPS, and every connection to it lands on port &lt;code&gt;3000&lt;/code&gt; of your laptop. The destination doesn't have to be your laptop itself — &lt;code&gt;-R 8080:192.168.1.50:80&lt;/code&gt; forwards to another machine on &lt;em&gt;your&lt;/em&gt; LAN, using your laptop as the exit.&lt;/p&gt;

&lt;p&gt;One flag worth knowing: &lt;code&gt;-R 0:localhost:3000&lt;/code&gt; lets the server pick any free port, and prints &lt;code&gt;Allocated port 43022 for remote forward&lt;/code&gt; so you know which one you got.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three things people actually build with it
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Show a local dev server to a client.&lt;/strong&gt; Your work-in-progress runs on &lt;code&gt;localhost:3000&lt;/code&gt;. Instead of deploying it somewhere just for a demo:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;ssh &lt;span class="nt"&gt;-N&lt;/span&gt; &lt;span class="nt"&gt;-R&lt;/span&gt; 8080:localhost:3000 deploy@demo.example.com
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Anyone who can reach &lt;code&gt;demo.example.com:8080&lt;/code&gt; is now looking at the app on your laptop, live. (&lt;code&gt;-N&lt;/code&gt; skips the remote shell — you only want the tunnel. Whether &lt;em&gt;anyone&lt;/em&gt; or only the server itself can connect depends on &lt;code&gt;GatewayPorts&lt;/code&gt; — next section.)&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Reach a home machine behind NAT.&lt;/strong&gt; Your home box can't accept connections, but it can make them. Have it hold a tunnel out to a small VPS:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# on the home machine&lt;/span&gt;
ssh &lt;span class="nt"&gt;-N&lt;/span&gt; &lt;span class="nt"&gt;-R&lt;/span&gt; 2222:localhost:22 tunnel@vps.example.com
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now the VPS's port &lt;code&gt;2222&lt;/code&gt; &lt;em&gt;is&lt;/em&gt; the home machine's SSH port, and from anywhere:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;ssh &lt;span class="nt"&gt;-J&lt;/span&gt; vps.example.com &lt;span class="nt"&gt;-p&lt;/span&gt; 2222 you@localhost
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;jumps through the VPS straight into the box behind NAT. This is the classic self-hosted answer to &lt;a href="https://termal.in/blog/ssh-into-home-nas-server/" rel="noopener noreferrer"&gt;how to SSH into your home server&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Deliver webhooks to your laptop.&lt;/strong&gt; A payment provider or git host needs to POST to your in-development handler. Tunnel their reachable endpoint back to you:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;ssh &lt;span class="nt"&gt;-N&lt;/span&gt; &lt;span class="nt"&gt;-R&lt;/span&gt; 9000:localhost:9000 deploy@vps.example.com
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Point the webhook at the VPS. If you keep the tunnel bound to the server's loopback (the default), put nginx or any reverse proxy on the VPS in front of &lt;code&gt;127.0.0.1:9000&lt;/code&gt; — you get TLS termination for free and never expose the raw tunnel port.&lt;/p&gt;

&lt;h2&gt;
  
  
  GatewayPorts: why nobody else can reach your tunnel
&lt;/h2&gt;

&lt;p&gt;The number-one surprise: the tunnel works when you test it &lt;em&gt;from the server&lt;/em&gt; (&lt;code&gt;curl localhost:8080&lt;/code&gt; succeeds) but nobody outside can connect. That's by design — &lt;strong&gt;by default, sshd binds remote-forwarded ports to &lt;code&gt;127.0.0.1&lt;/code&gt; only&lt;/strong&gt;, no matter what bind address you asked for.&lt;/p&gt;

&lt;p&gt;To open a tunnel to the world, the &lt;em&gt;server's&lt;/em&gt; &lt;code&gt;/etc/ssh/sshd_config&lt;/code&gt; needs:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ssh"&gt;&lt;code&gt;&lt;span class="k"&gt;GatewayPorts&lt;/span&gt; clientspecified
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;then restart sshd, and request the bind explicitly: &lt;code&gt;ssh -R 0.0.0.0:8080:localhost:3000 ...&lt;/code&gt;. (&lt;code&gt;GatewayPorts yes&lt;/code&gt; binds every remote forward to all interfaces, which is broader than you usually want.) This is a server-side decision you cannot override from the client — a deliberate safety default, since a reverse tunnel is a hole punched from the inside, and whatever it exposes is now effectively internet-facing. Treat it that way.&lt;/p&gt;

&lt;p&gt;Two related server-side switches can bite you: &lt;code&gt;AllowTcpForwarding no&lt;/code&gt; disables tunnels entirely, and &lt;code&gt;PermitListen&lt;/code&gt; can restrict which ports &lt;code&gt;-R&lt;/code&gt; may claim.&lt;/p&gt;

&lt;h2&gt;
  
  
  Keeping it alive
&lt;/h2&gt;

&lt;p&gt;A reverse tunnel is one TCP connection, and idle TCP connections die. For anything longer than a demo:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;ssh &lt;span class="nt"&gt;-N&lt;/span&gt; &lt;span class="nt"&gt;-R&lt;/span&gt; 2222:localhost:22 &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;-o&lt;/span&gt; &lt;span class="nv"&gt;ServerAliveInterval&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;30 &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;-o&lt;/span&gt; &lt;span class="nv"&gt;ExitOnForwardFailure&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nb"&gt;yes&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
    tunnel@vps.example.com
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;ServerAliveInterval 30&lt;/code&gt;&lt;/strong&gt; keeps traffic flowing so NAT entries and idle timeouts don't reap the connection.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;ExitOnForwardFailure yes&lt;/code&gt;&lt;/strong&gt; makes ssh die loudly if the remote port can't be bound, instead of connecting anyway and forwarding nothing.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;autossh&lt;/code&gt;&lt;/strong&gt; wraps the command and restarts the tunnel whenever it drops — the standard tool for tunnels that should survive reboots and flaky networks, usually run as &lt;code&gt;autossh -M 0 -N -R ...&lt;/code&gt; under a systemd unit.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;And once a tunnel earns a permanent place, move it into &lt;code&gt;~/.ssh/config&lt;/code&gt; with a &lt;code&gt;RemoteForward&lt;/code&gt; line so it travels with the host entry — structure covered in &lt;a href="https://termal.in/blog/ssh-config-file-guide/" rel="noopener noreferrer"&gt;the ssh_config guide&lt;/a&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ssh"&gt;&lt;code&gt;&lt;span class="k"&gt;Host&lt;/span&gt; homelink
    &lt;span class="k"&gt;HostName&lt;/span&gt; vps.example.com
    &lt;span class="k"&gt;User&lt;/span&gt; tunnel
    &lt;span class="k"&gt;RemoteForward&lt;/span&gt; &lt;span class="m"&gt;2222&lt;/span&gt; localhost:22
    &lt;span class="k"&gt;ServerAliveInterval&lt;/span&gt; &lt;span class="m"&gt;30&lt;/span&gt;
    &lt;span class="k"&gt;ExitOnForwardFailure&lt;/span&gt; &lt;span class="no"&gt;yes&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  &lt;code&gt;-L&lt;/code&gt; vs &lt;code&gt;-R&lt;/code&gt; vs &lt;code&gt;-D&lt;/code&gt; at a glance
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Flag&lt;/th&gt;
&lt;th&gt;Port opens on&lt;/th&gt;
&lt;th&gt;Traffic exits on&lt;/th&gt;
&lt;th&gt;Typical job&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;-L&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;your machine&lt;/td&gt;
&lt;td&gt;server's side&lt;/td&gt;
&lt;td&gt;pull a remote DB/dashboard to localhost&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;-R&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;the server&lt;/td&gt;
&lt;td&gt;your side&lt;/td&gt;
&lt;td&gt;expose local service; reach a NATed box&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;-D&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;your machine&lt;/td&gt;
&lt;td&gt;server's side, any destination&lt;/td&gt;
&lt;td&gt;SOCKS proxy into a network&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  When a reverse tunnel is the wrong tool
&lt;/h2&gt;

&lt;p&gt;A reverse tunnel is a superb &lt;em&gt;temporary&lt;/em&gt; answer: demos, webhook debugging, rescuing a machine behind NAT. It's a poor &lt;em&gt;permanent&lt;/em&gt; one. If the thing you're exposing needs to be up for months, serve real users, or survive your laptop sleeping — that's ingress, and ingress deserves real infrastructure: a reverse proxy on a server, a VPN or overlay network, an actual deployment. The tunnel's charm is that it's one command with no standing footprint; the moment you're writing monitoring for it, you've outgrown it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Troubleshooting the usual failures
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;Warning: remote port forwarding failed for listen port 8080&lt;/code&gt;&lt;/strong&gt; — the port is taken on the server. Often the culprit is your &lt;em&gt;own&lt;/em&gt; previous tunnel: the old sshd process holds the port until the dead connection times out. Keepalives prevent the zombie; &lt;code&gt;-R 0:...&lt;/code&gt; sidesteps the collision; &lt;code&gt;ExitOnForwardFailure&lt;/code&gt; turns the warning into an error you can't miss.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Works from the server, refused from outside&lt;/strong&gt; — the listener is on the server's loopback. That's the &lt;code&gt;GatewayPorts&lt;/code&gt; default doing its job; either configure it or front the port with a reverse proxy.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Tunnel connects but the service doesn't answer&lt;/strong&gt; — check the &lt;em&gt;local&lt;/em&gt; half: is anything actually listening on the local port you named? &lt;code&gt;-R 8080:localhost:3000&lt;/code&gt; with nothing on your &lt;code&gt;:3000&lt;/code&gt; yields connection refused for every visitor.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The SSH connection itself won't establish&lt;/strong&gt; — that's a different problem with its own checklist: &lt;a href="https://termal.in/blog/ssh-connection-refused-timed-out/" rel="noopener noreferrer"&gt;connection refused and timed out, decoded&lt;/a&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  When tunnels become furniture
&lt;/h2&gt;

&lt;p&gt;The reverse tunnel you run once, you type. The one that carries your home lab, your demo box and your webhook endpoint is state you rebuild every morning after every laptop sleep — and lose track of by Thursday. Termalin's &lt;a href="https://termal.in/ssh-tunnel-manager/" rel="noopener noreferrer"&gt;saved tunnel manager&lt;/a&gt; keeps local, remote and dynamic forwards attached to the host they belong to: start them with a click, see at a glance which are live, and have saved ones start when the app launches and come back if they drop, instead of being retyped from shell history. On Pro, the host list — keys, jump hosts and tunnels included — syncs across your machines end-to-end encrypted. And when a box sits behind NAT for good, its outbound-only &lt;a href="https://termal.in/connector/" rel="noopener noreferrer"&gt;Connector&lt;/a&gt; (Pro) replaces the hand-rolled reverse tunnel entirely — no inbound port, and no &lt;code&gt;autossh&lt;/code&gt; unit to babysit. The command line teaches you what &lt;code&gt;-R&lt;/code&gt; means; a client keeps the tunnels you rely on from depending on your memory.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Termalin is a free, cross-platform SSH client with built-in tunnels, SFTP and a key manager — &lt;a href="https://termal.in/download/" rel="noopener noreferrer"&gt;download it&lt;/a&gt;, or see how it &lt;a href="https://termal.in/security/" rel="noopener noreferrer"&gt;handles your keys safely&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ssh</category>
      <category>portforwarding</category>
      <category>tunneling</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>Leaving PuTTY: migrate your sessions, keys and habits without losing anything</title>
      <dc:creator>Svyatoslav Pavlov</dc:creator>
      <pubDate>Fri, 02 Oct 2026 17:17:06 +0000</pubDate>
      <link>https://dev.to/wolfhound1995/leaving-putty-migrate-your-sessions-keys-and-habits-without-losing-anything-27fo</link>
      <guid>https://dev.to/wolfhound1995/leaving-putty-migrate-your-sessions-keys-and-habits-without-losing-anything-27fo</guid>
      <description>&lt;p&gt;&lt;em&gt;Where PuTTY hides your sessions and host keys, how to convert .ppk keys to OpenSSH, and how to land the whole setup in a modern client in one evening.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;PuTTY deserves its reputation. It's small, it's honest, it has worked the same way since the dial-up era, and half the world's server closets were administered through it. Nobody &lt;em&gt;has&lt;/em&gt; to leave. But if you've caught yourself juggling eleven PuTTY windows on a taskbar, copying files with a separate WinSCP window, and typing the same passphrase forty times a day — the itch is real, and the move is smaller than it looks.&lt;/p&gt;

&lt;p&gt;This guide gets everything out of PuTTY intact: sessions, keys, host keys, habits. It's written with &lt;a href="https://termal.in/putty-alternative/" rel="noopener noreferrer"&gt;Termalin&lt;/a&gt; as the destination, but the first half applies to any modern client.&lt;/p&gt;

&lt;h2&gt;
  
  
  Know where PuTTY keeps your stuff
&lt;/h2&gt;

&lt;p&gt;PuTTY famously has no config file. Everything lives in the Windows registry:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Sessions&lt;/strong&gt; — &lt;code&gt;HKEY_CURRENT_USER\Software\SimonTatham\PuTTY\Sessions&lt;/code&gt;, one key per saved session: host, port, username, per-session settings.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Known host keys&lt;/strong&gt; — &lt;code&gt;HKEY_CURRENT_USER\Software\SimonTatham\PuTTY\SshHostKeys&lt;/code&gt; — the fingerprints of every server you've ever trusted.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Private keys&lt;/strong&gt; — wherever you saved the &lt;code&gt;.ppk&lt;/code&gt; files. The registry only stores the &lt;em&gt;path&lt;/em&gt; to a session's key, not the key itself. If you're like most long-time users, there's a &lt;code&gt;keys&lt;/code&gt; folder somewhere you haven't opened since 2019.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Worth knowing even if you stay: this is why your PuTTY setup doesn't follow you to a new machine unless you remember to export the registry branch.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 1 — export the sessions
&lt;/h2&gt;

&lt;p&gt;On the machine with your PuTTY setup, from a command prompt:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;reg export "HKCU\Software\SimonTatham\PuTTY\Sessions" putty-sessions.reg
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That single &lt;code&gt;.reg&lt;/code&gt; file is your complete session inventory — portable, human-readable, and the input format most importers (Termalin included) accept on any OS. On Windows you can usually skip even this: Termalin's hosts list has an import menu that &lt;strong&gt;reads your saved PuTTY sessions straight from the registry, one click&lt;/strong&gt;, and they arrive as regular hosts you can sort into folders and tag.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 2 — convert the keys
&lt;/h2&gt;

&lt;p&gt;PuTTY's &lt;code&gt;.ppk&lt;/code&gt; format is its own thing; the rest of the world speaks OpenSSH. Conversion is built into PuTTYgen and takes seconds per key:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Open &lt;strong&gt;PuTTYgen&lt;/strong&gt;, &lt;code&gt;Load&lt;/code&gt; the &lt;code&gt;.ppk&lt;/code&gt; (enter its passphrase).&lt;/li&gt;
&lt;li&gt;Menu &lt;strong&gt;Conversions → Export OpenSSH key (force new file format)&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;Save as &lt;code&gt;id_ed25519&lt;/code&gt; / &lt;code&gt;id_rsa&lt;/code&gt; — the passphrase carries over.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Two notes from the trenches: export the &lt;em&gt;new&lt;/em&gt; format (the legacy one predates Ed25519), and don't forget keys referenced by sessions but stored in odd places — each session's &lt;code&gt;PublicKeyFile&lt;/code&gt; value in the &lt;code&gt;.reg&lt;/code&gt; export shows the path it expected (the import marks those hosts as key-based, so you know which ones still need a key). Once converted, import the keys into your new client; in Termalin they go into the built-in agent, which means &lt;strong&gt;one unlock per session of work&lt;/strong&gt;, passphrase caching on a timer you control, instead of one passphrase per connection.&lt;/p&gt;

&lt;p&gt;If you drove Pageant all day, that habit maps directly: Termalin's agent plays the same role. It speaks the standard ssh-agent protocol on its own per-user pipe or socket, so a CLI pointed at it with &lt;code&gt;SSH_AUTH_SOCK&lt;/code&gt; can sign through it too.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 3 — don't blindly re-trust every server
&lt;/h2&gt;

&lt;p&gt;You &lt;em&gt;can&lt;/em&gt; migrate PuTTY's &lt;code&gt;SshHostKeys&lt;/code&gt; — but honestly: don't bother. Fingerprint formats differ, and a migration is the one natural moment you'll ever have to re-verify what you're connecting to. A modern client (Termalin uses trust-on-first-use with a proper &lt;code&gt;known_hosts&lt;/code&gt;) will show each server's fingerprint on first connect — glance at the ones that matter, accept, move on. Ten minutes for a fleet you'll trust cleanly for years.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 4 — rebuild the two habits PuTTY never had
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Tabs instead of windows.&lt;/strong&gt; Eleven sessions become one window with browser-style tabs (drag to reorder, activity glow on the quiet ones). The win isn't cosmetic — it's that "which window was staging?" stops being a question.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Files next to the terminal.&lt;/strong&gt; The separate-WinSCP-window workflow collapses into an SFTP panel in the same tab as the shell: browse, edit in place with a built-in editor, upload — no second app, no reconnecting the same credentials.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Ftermal.in%2Feditor.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Ftermal.in%2Feditor.png" width="800" height="450" alt="The SFTP panel and built-in editor beside a terminal session"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Everything else transfers as-is: your servers are still your servers, your muscle memory still works, &lt;code&gt;Ctrl+D&lt;/code&gt; still logs you out.&lt;/p&gt;

&lt;h2&gt;
  
  
  What you gain that has no PuTTY equivalent
&lt;/h2&gt;

&lt;p&gt;Sessions that follow you — hosts, keys and command history sync across machines, end-to-end encrypted so the sync server only ever holds ciphertext. Terminal themes per host (staging in Nord, production in something appropriately red). And if you work with AI agents: a built-in &lt;a href="https://termal.in/mcp/" rel="noopener noreferrer"&gt;MCP server&lt;/a&gt; that lets an agent open SSH sessions through the app, under your eyes, without ever seeing a key — a sentence that would have sounded like science fiction when your &lt;code&gt;.ppk&lt;/code&gt; files were created.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Termalin runs on &lt;a href="https://termal.in/ssh-client-for-windows/" rel="noopener noreferrer"&gt;Windows&lt;/a&gt;, macOS, Linux and iOS; the Free tier has no host limits and imports PuTTY sessions in one click — &lt;a href="https://termal.in/download/" rel="noopener noreferrer"&gt;download it&lt;/a&gt;, or see the full &lt;a href="https://termal.in/putty-alternative/" rel="noopener noreferrer"&gt;PuTTY comparison&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>putty</category>
      <category>ssh</category>
      <category>migration</category>
      <category>tutorial</category>
    </item>
  </channel>
</rss>
