<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/"><channel><title>Cli-Over-Https on network-notes</title><link>https://blog2.network-notes.com/topics/cli-over-https/</link><description>Recent content in Cli-Over-Https on network-notes</description><generator>Hugo -- gohugo.io</generator><language>en-us</language><managingEditor>brett@network-notes.com (Brett Lykins)</managingEditor><webMaster>brett@network-notes.com (Brett Lykins)</webMaster><copyright>© 2015-2026 Brett Lykins</copyright><lastBuildDate>Thu, 07 May 2026 09:00:00 +0000</lastBuildDate><atom:link href="https://blog2.network-notes.com/topics/cli-over-https/feed.xml" rel="self" type="application/rss+xml"/><item><title>CLI Over HTTPS Part 4: Where Do We Go from Here?</title><link>https://blog2.network-notes.com/posts/2026/cli-over-https-4/</link><pubDate>Thu, 07 May 2026 09:00:00 +0000</pubDate><author>brett@network-notes.com (Brett Lykins)</author><dc:creator>Brett Lykins</dc:creator><guid>https://blog2.network-notes.com/posts/2026/cli-over-https-4/</guid><description>&lt;p&gt;This is the last post in the series. &lt;a href="https://blog2.network-notes.com/posts/2026/cli-over-https-1/"&gt;Part 1&lt;/a&gt; explained why SSH is slow for automation. &lt;a href="https://blog2.network-notes.com/posts/2026/cli-over-https-2/"&gt;Part 2&lt;/a&gt; measured it. HTTPS batch is up to 17x faster at real-world latencies. &lt;a href="https://blog2.network-notes.com/posts/2026/cli-over-https-3/"&gt;Part 3&lt;/a&gt; showed that an edge proxy and a transparent tunnel capture most of that improvement even when devices don&amp;rsquo;t speak HTTPS natively.&lt;/p&gt;
&lt;p&gt;This post is the practical takeaway: when to use what, how to deploy it, and what the industry should build next.&lt;/p&gt;
&lt;h2 id="the-decision-framework"&gt;The Decision Framework&lt;/h2&gt;
&lt;p&gt;Not every network needs a proxy. Not every automation run is latency-sensitive. Here&amp;rsquo;s how to think about it:&lt;/p&gt;
&lt;p&gt;&lt;img src="https://blog2.network-notes.com/img/2026/cli-over-https-decision-framework.58aab159b9be4b27cc56cdae4b50f2416b19c9bb2a7f7a86026a080df6ecefcb.svg" alt="Decision framework: choose SSH direct for under 5ms RTT, a proxy or tunnel for WAN latency, or native HTTPS when the vendor supports it" loading="lazy" /&gt;&lt;/p&gt;
&lt;h3 id="ssh-direct-is-fine-when"&gt;SSH Direct Is Fine When&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;Your automation server is co-located with the devices (same DC, same site)&lt;/li&gt;
&lt;li&gt;Round-trip time to the devices is under ~5ms&lt;/li&gt;
&lt;li&gt;You&amp;rsquo;re managing fewer than a few hundred devices&lt;/li&gt;
&lt;li&gt;You&amp;rsquo;re already using SSH multiplexing (ControlMaster) or persistent connections&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;At local latency, SSH overhead is measured in single-digit milliseconds. The protocol tax from &lt;a href="https://blog2.network-notes.com/posts/2026/cli-over-https-1/"&gt;Part 1&lt;/a&gt; is real but negligible. Don&amp;rsquo;t add architectural complexity to save 3ms per device.&lt;/p&gt;
&lt;h3 id="deploy-a-proxy-when"&gt;Deploy a Proxy When&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;Your automation runs over a WAN (30ms+ RTT to the devices)&lt;/li&gt;
&lt;li&gt;You manage devices across multiple sites, regions, or continents&lt;/li&gt;
&lt;li&gt;Automation run time is operationally painful (the Rackspace problem from Part 1)&lt;/li&gt;
&lt;li&gt;You&amp;rsquo;re already running regional infrastructure (jump hosts, bastion servers, Ansible Tower nodes)&lt;/li&gt;
&lt;li&gt;You can modify your automation to speak HTTPS instead of SSH&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The proxy pattern from &lt;a href="https://blog2.network-notes.com/posts/2026/cli-over-https-3/"&gt;Part 3&lt;/a&gt; showed 5.3-14.7x improvement at real WAN latencies (14.7x with connection reuse at 150ms RTT). If you already have bastion hosts in each region, you&amp;rsquo;re halfway there. The proxy is a bastion that speaks HTTPS instead of (or in addition to) SSH.&lt;/p&gt;
&lt;h3 id="deploy-a-tunnel-when"&gt;Deploy a Tunnel When&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;You need WAN optimization but can&amp;rsquo;t change your automation tooling&lt;/li&gt;
&lt;li&gt;Your team has years of Ansible playbooks, Nornir scripts, or Netmiko wrappers that must speak SSH&lt;/li&gt;
&lt;li&gt;You want a migration path: tunnel first, proxy later&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The tunnel from &lt;a href="https://blog2.network-notes.com/posts/2026/cli-over-https-3/"&gt;Part 3&lt;/a&gt; is transparent to both sides: automation speaks SSH to the headend, the device sees SSH from the site proxy. With command batching, it hits 12.7x speedup at intercontinental latency. Without batching, it&amp;rsquo;s still 3.0x faster than SSH direct.&lt;/p&gt;
&lt;h3 id="deploy-naas-when"&gt;Deploy NAAS When&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;You want a production-ready proxy with multi-vendor support out of the box&lt;/li&gt;
&lt;li&gt;You need connection pooling, async jobs, and circuit breakers&lt;/li&gt;
&lt;li&gt;You manage devices across 100+ platforms (everything Netmiko supports)&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;a href="https://github.com/lykinsbd/naas"&gt;NAAS (Netmiko as a Service)&lt;/a&gt; implements the proxy pattern with production concerns handled. Deploy an instance per region, point your automation at it, and the SSH stays local. More on NAAS below.&lt;/p&gt;
&lt;h3 id="push-for-native-https-when"&gt;Push for Native HTTPS When&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;You&amp;rsquo;re evaluating new platforms or vendors&lt;/li&gt;
&lt;li&gt;You have influence over vendor roadmaps (large deployments, design partners)&lt;/li&gt;
&lt;li&gt;You&amp;rsquo;re building internal tooling that could expose CLI over HTTPS&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Native HTTPS eliminates the proxy entirely. The ~17x batch improvement from &lt;a href="https://blog2.network-notes.com/posts/2026/cli-over-https-2/"&gt;Part 2&lt;/a&gt; is the ceiling. No intermediate hop, no backend SSH overhead. If a vendor offers it, use it.&lt;/p&gt;
&lt;h2 id="naas-the-production-proxy"&gt;NAAS: The Production Proxy&lt;/h2&gt;
&lt;p&gt;&lt;a href="https://github.com/lykinsbd/naas"&gt;NAAS (Netmiko as a Service)&lt;/a&gt; is what the proxy pattern looks like when you build it for production. Written in Python, it wraps &lt;a href="https://github.com/ktbyers/netmiko"&gt;Netmiko&lt;/a&gt; behind a REST API, which means it supports &lt;a href="https://github.com/ktbyers/netmiko/blob/develop/PLATFORMS.md"&gt;100+ device platforms&lt;/a&gt;: Cisco IOS, NX-OS, ASA, Juniper Junos, Arista EOS, Palo Alto, and everything else Netmiko handles.&lt;/p&gt;
&lt;p&gt;You POST a JSON payload with the device address, platform, credentials, and commands. NAAS opens the SSH session, runs the commands, and returns the output in the HTTP response:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;div class="chroma"&gt;
&lt;table class="lntable"&gt;&lt;tr&gt;&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code&gt;&lt;span class="lnt"&gt;1
&lt;/span&gt;&lt;span class="lnt"&gt;2
&lt;/span&gt;&lt;span class="lnt"&gt;3
&lt;/span&gt;&lt;span class="lnt"&gt;4
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;
&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-bash" data-lang="bash"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;curl -k -X POST https://naas.dc1.example.com:8443/v1/send_command &lt;span class="se"&gt;\
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; -u &lt;span class="s2"&gt;&amp;#34;automation:token&amp;#34;&lt;/span&gt; &lt;span class="se"&gt;\
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; -H &lt;span class="s2"&gt;&amp;#34;Content-Type: application/json&amp;#34;&lt;/span&gt; &lt;span class="se"&gt;\
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; -d &lt;span class="s1"&gt;&amp;#39;{&amp;#34;host&amp;#34;: &amp;#34;10.1.1.1&amp;#34;, &amp;#34;platform&amp;#34;: &amp;#34;cisco_ios&amp;#34;, &amp;#34;commands&amp;#34;: [&amp;#34;show version&amp;#34;, &amp;#34;show ip route&amp;#34;]}&amp;#39;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;
&lt;/div&gt;
&lt;/div&gt;&lt;p&gt;What NAAS handles that a minimal proxy doesn&amp;rsquo;t:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Multi-vendor connection pooling.&lt;/strong&gt; Persistent SSH connections with health checks and automatic reconnection.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Async job queue.&lt;/strong&gt; Long-running commands (&lt;code&gt;show tech-support&lt;/code&gt;, bulk config pushes) run in a Redis-backed queue. Your automation gets a job ID back immediately and polls for results.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Circuit breaker and observability.&lt;/strong&gt; Stops hammering unreachable devices, exposes Prometheus metrics for connection pool health and per-device latency.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Deploy a NAAS instance in each data center or region, and your automation talks HTTPS to the nearest one. The SSH sessions stay local. The architecture is the same as what the benchmarks in &lt;a href="https://blog2.network-notes.com/posts/2026/cli-over-https-3/"&gt;Part 3&lt;/a&gt; measured: HTTPS over the WAN, SSH on the last hop.&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;div class="chroma"&gt;
&lt;table class="lntable"&gt;&lt;tr&gt;&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code&gt;&lt;span class="lnt"&gt;1
&lt;/span&gt;&lt;span class="lnt"&gt;2
&lt;/span&gt;&lt;span class="lnt"&gt;3
&lt;/span&gt;&lt;span class="lnt"&gt;4
&lt;/span&gt;&lt;span class="lnt"&gt;5
&lt;/span&gt;&lt;span class="lnt"&gt;6
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;
&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-bash" data-lang="bash"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;git clone https://github.com/lykinsbd/naas.git &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;cd&lt;/span&gt; naas
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;docker compose up -d
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;curl -k -X POST https://localhost:8443/v1/send_command &lt;span class="se"&gt;\
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; -u &lt;span class="s2"&gt;&amp;#34;username:password&amp;#34;&lt;/span&gt; &lt;span class="se"&gt;\
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; -H &lt;span class="s2"&gt;&amp;#34;Content-Type: application/json&amp;#34;&lt;/span&gt; &lt;span class="se"&gt;\
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; -d &lt;span class="s1"&gt;&amp;#39;{&amp;#34;host&amp;#34;: &amp;#34;10.1.1.1&amp;#34;, &amp;#34;platform&amp;#34;: &amp;#34;cisco_ios&amp;#34;, &amp;#34;commands&amp;#34;: [&amp;#34;show version&amp;#34;]}&amp;#39;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;
&lt;/div&gt;
&lt;/div&gt;&lt;p&gt;See the &lt;a href="https://naas.readthedocs.io/en/latest/installation/"&gt;NAAS getting started guide&lt;/a&gt; for full setup and configuration.&lt;/p&gt;
&lt;h2 id="what-exists-and-whats-missing"&gt;What Exists and What&amp;rsquo;s Missing&lt;/h2&gt;
&lt;p&gt;Some of the pieces are already in place.&lt;/p&gt;
&lt;p&gt;&lt;a href="https://aristanetworks.github.io/EosSdk/docs/2.22.1.5/ref/eapi.html"&gt;Arista&amp;rsquo;s eAPI&lt;/a&gt; accepts CLI commands via JSON-RPC over HTTPS. It wraps everything in JSON, but the core pattern is there: send commands over HTTPS, get output back. The ASA interface and eAPI have been in production for years. NAAS (described above) brings the proxy pattern to the 100+ platforms Netmiko supports. The &lt;a href="https://github.com/lykinsbd/clibench"&gt;clibench&lt;/a&gt; tunnel mode demonstrates the transparent SSH-to-HTTP approach for teams that can&amp;rsquo;t change their automation tooling.&lt;/p&gt;
&lt;p&gt;What&amp;rsquo;s missing:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;A standard CLI-over-HTTPS interface.&lt;/strong&gt; Not RESTCONF, not gNMI. Those are structured data interfaces for a different use case. A simple, standardized way to send CLI commands over HTTPS and get text output back. The ASA pattern is a reasonable starting point: &lt;code&gt;GET /cli/exec/{command}&lt;/code&gt; for show commands, &lt;code&gt;POST /cli/config&lt;/code&gt; for configuration. Basic auth or token auth over TLS. &lt;code&gt;Content-Type: text/plain&lt;/code&gt;. No JSON wrapping unless the client asks for it. Arista&amp;rsquo;s eAPI is the closest thing to this, but it&amp;rsquo;s vendor-specific and JSON-only.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Proxy support in the automation ecosystem.&lt;/strong&gt; Ansible could ship a connection plugin that talks HTTPS to a proxy like NAAS instead of SSH to the device. Nornir could support an HTTP transport alongside Paramiko and Netmiko. NAAS works today as a standalone API, and a native connection plugin would make adoption even easier: a configuration option instead of a code change.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Broader vendor adoption.&lt;/strong&gt; Every network OS already has an HTTPS server for its web UI. Exposing the CLI through that same server is not a large engineering effort. The ASA proves the concept. A plain-text CLI endpoint alongside the structured API would cover both use cases.&lt;/p&gt;
&lt;p&gt;None of this requires abandoning SSH. SSH remains the right tool for interactive sessions, for out-of-band recovery, for environments where HTTPS infrastructure doesn&amp;rsquo;t exist. The argument isn&amp;rsquo;t &amp;ldquo;replace SSH.&amp;rdquo; It&amp;rsquo;s &amp;ldquo;stop using SSH for the thing it&amp;rsquo;s worst at.&amp;rdquo;&lt;/p&gt;
&lt;h2 id="the-numbers-one-more-time"&gt;The Numbers, One More Time&lt;/h2&gt;
&lt;p&gt;For reference, here&amp;rsquo;s the speedup picture from the series. Most automation tools (Netmiko, Ansible, Scrapli) use PTY mode, so SSH PTY is the realistic baseline:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Scenario&lt;/th&gt;
&lt;th&gt;Speedup vs SSH PTY&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;HTTPS batch (native)&lt;/td&gt;
&lt;td&gt;~17x&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Proxy (reused connection)&lt;/td&gt;
&lt;td&gt;~14.7x&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Tunnel batch&lt;/td&gt;
&lt;td&gt;~12.7x&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Proxy (new connection)&lt;/td&gt;
&lt;td&gt;~5.3x&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;HTTPS keep-alive (native)&lt;/td&gt;
&lt;td&gt;~3.4x&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Tunnel per-command&lt;/td&gt;
&lt;td&gt;~3.0x&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;SSH with ControlMaster&lt;/td&gt;
&lt;td&gt;~1.7x&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;&lt;img src="https://blog2.network-notes.com/img/2026/cli-over-https-speedup-comparison.02343e29dd2272328fcd4e01b59870866db2b5d661afd35e764b5a7f221c1b6d.svg" alt="Bar chart comparing speedup vs SSH PTY: ControlMaster 1.7x, tunnel per-cmd 3.0x, HTTPS keep-alive 3.4x, proxy new conn 5.3x, tunnel batch 12.7x, proxy reused conn 14.7x, HTTPS batch 17x" loading="lazy" /&gt;&lt;/p&gt;
&lt;p&gt;The proxy with connection reuse gets you most of the native HTTPS improvement without requiring any changes to the devices. The tunnel with batching is close behind, and requires zero changes to your automation tooling either.&lt;/p&gt;
&lt;h2 id="try-it-yourself"&gt;Try It Yourself&lt;/h2&gt;
&lt;p&gt;The &lt;a href="https://github.com/lykinsbd/clibench"&gt;benchmark tool&lt;/a&gt; supports all scenarios:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;div class="chroma"&gt;
&lt;table class="lntable"&gt;&lt;tr&gt;&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code&gt;&lt;span class="lnt"&gt;1
&lt;/span&gt;&lt;span class="lnt"&gt;2
&lt;/span&gt;&lt;span class="lnt"&gt;3
&lt;/span&gt;&lt;span class="lnt"&gt;4
&lt;/span&gt;&lt;span class="lnt"&gt;5
&lt;/span&gt;&lt;span class="lnt"&gt;6
&lt;/span&gt;&lt;span class="lnt"&gt;7
&lt;/span&gt;&lt;span class="lnt"&gt;8
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;
&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-bash" data-lang="bash"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;# All transports (SSH, HTTPS, HTTP/3, proxy, tunnel)&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;sudo ./bin/clibench bench --latency regional --iterations &lt;span class="m"&gt;20&lt;/span&gt; --commands &lt;span class="m"&gt;5&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;# Proxy pattern (HTTPS + HTTP/3 variants)&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;sudo ./bin/clibench bench --latency regional --iterations &lt;span class="m"&gt;20&lt;/span&gt; --commands &lt;span class="m"&gt;5&lt;/span&gt; --transport proxy
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;# Tunnel (transparent SSH-to-HTTP WAN optimization)&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;sudo ./bin/clibench bench --latency intercontinental --iterations &lt;span class="m"&gt;20&lt;/span&gt; --commands &lt;span class="m"&gt;5&lt;/span&gt; --transport tunnel-https
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;
&lt;/div&gt;
&lt;/div&gt;&lt;p&gt;The code is MIT licensed. Run it on your own infrastructure, with your own latency profiles, and see what the numbers look like for your network.&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;My take:&lt;/strong&gt; The network automation community has treated SSH as a given for fifteen years. It was the right default when automation meant one engineer scripting against a handful of devices. At the scale most organizations operate today, SSH&amp;rsquo;s protocol overhead is a measurable, avoidable cost. Native HTTPS CLI is the right long-term direction. The proxy and tunnel patterns are deployable today. I built &lt;a href="https://github.com/lykinsbd/naas"&gt;NAAS&lt;/a&gt; so you can start today. Contributions welcome.&lt;/p&gt;
&lt;/blockquote&gt;</description></item><item><title>CLI Over HTTPS Part 3: The Proxy Pattern</title><link>https://blog2.network-notes.com/posts/2026/cli-over-https-3/</link><pubDate>Tue, 05 May 2026 09:00:00 +0000</pubDate><author>brett@network-notes.com (Brett Lykins)</author><dc:creator>Brett Lykins</dc:creator><guid>https://blog2.network-notes.com/posts/2026/cli-over-https-3/</guid><description>&lt;p&gt;In &lt;a href="https://blog2.network-notes.com/posts/2026/cli-over-https-1/"&gt;Part 1&lt;/a&gt; I showed that SSH burns 10-15 round trips before delivering a single byte of command output. In &lt;a href="https://blog2.network-notes.com/posts/2026/cli-over-https-2/"&gt;Part 2&lt;/a&gt; I proved it. HTTPS batch is ~17x faster than SSH at real-world latencies when the device supports it natively. Even HTTPS keep-alive, with no batching, is 3.4x faster.&lt;/p&gt;
&lt;p&gt;The obvious objection: most devices don&amp;rsquo;t support it natively. Your Cisco IOS switches, your Juniper routers, your Arista leaf nodes, they speak SSH. And while some of them have other interfaces, SSH is not changing anytime soon.&lt;/p&gt;
&lt;p&gt;So the question isn&amp;rsquo;t &amp;ldquo;how do I get my switches to speak HTTPS.&amp;rdquo; The question is: &lt;strong&gt;where does the SSH happen?&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;There are two answers. The &lt;strong&gt;proxy&lt;/strong&gt; requires your automation to speak HTTPS, but it&amp;rsquo;s architecturally simple: one hop, one translation. The &lt;strong&gt;tunnel&lt;/strong&gt; keeps SSH on both ends and optimizes only the WAN segment, so existing tooling works unchanged. Both relocate the expensive SSH round trips to a local link where they cost almost nothing.&lt;/p&gt;
&lt;h2 id="the-proxy-replace-ssh-on-the-wan"&gt;The Proxy: Replace SSH on the WAN&lt;/h2&gt;
&lt;p&gt;SSH is slow because of round trips. Round trips are slow because of distance. If you move the SSH session closer to the device, the round trips get cheap.&lt;/p&gt;
&lt;p&gt;A proxy co-located with the devices, in the same data center or local network, talks SSH to the devices over a 1-2ms link where the protocol overhead is negligible. Your automation platform talks HTTPS to the proxy over the WAN, where the round-trip savings from &lt;a href="https://blog2.network-notes.com/posts/2026/cli-over-https-1/"&gt;Part 1&lt;/a&gt; actually matter.&lt;/p&gt;
&lt;p&gt;The device never knows the difference, it sees an SSH session from a local IP. Your automation never touches SSH directly, it sends an HTTP request and gets CLI output back in the response body.&lt;/p&gt;
&lt;h2 id="the-architecture"&gt;The Architecture&lt;/h2&gt;
&lt;p&gt;&lt;img src="https://blog2.network-notes.com/img/2026/cli-over-https-proxy-architecture.a680c9de0edef0e2a287b403abe4bc1a33f193079c789073a817090ba9ab3277.svg" alt="Edge proxy architecture: automation talks HTTPS over the WAN to a co-located proxy, which talks SSH to devices over a 1-2ms local link" loading="lazy" /&gt;&lt;/p&gt;
&lt;p&gt;The proxy is the only component that touches SSH. Everything upstream is HTTPS: connection pooling, TLS 1.3, request batching, proper &lt;code&gt;Content-Length&lt;/code&gt; framing. Everything downstream is SSH, but over a link where it doesn&amp;rsquo;t matter.&lt;/p&gt;
&lt;h2 id="proving-it"&gt;Proving It&lt;/h2&gt;
&lt;p&gt;I added a proxy mode to the &lt;a href="https://github.com/lykinsbd/clibench"&gt;benchmark tool from Part 2&lt;/a&gt;. The proxy is an HTTPS server that receives commands via the same ASA-style endpoints (&lt;code&gt;/admin/exec/&lt;/code&gt;, &lt;code&gt;/admin/config&lt;/code&gt;), then opens an SSH session to a backend device and returns the output.&lt;/p&gt;
&lt;p&gt;The test setup:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Backend device:&lt;/strong&gt; SSH listener with 2ms RTT (local latency)&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Proxy:&lt;/strong&gt; HTTPS frontend with WAN latency, SSH client to backend&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Benchmark client:&lt;/strong&gt; Talks HTTPS to the proxy, same as it would to a native HTTPS device&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Four proxy modes tested with a new WAN connection per request (cold start, first request of an automation run):&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;fresh-ssh:&lt;/strong&gt; New WAN connection + new SSH to backend per request&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;pooled-ssh:&lt;/strong&gt; New WAN connection, reuses one SSH connection on the backend&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;h3-fresh-ssh:&lt;/strong&gt; Same as fresh-ssh, but the WAN leg uses HTTP/3 (QUIC)&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;h3-pooled-ssh:&lt;/strong&gt; QUIC on WAN, pooled SSH on backend&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Plus two connection-reuse modes (steady state, what a running automation platform does):&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;keep-alive:&lt;/strong&gt; Persistent HTTPS connection to proxy, pooled SSH on backend&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;h3-keep-alive:&lt;/strong&gt; Persistent QUIC connection to proxy, pooled SSH on backend&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The proxy&amp;rsquo;s WAN-facing listener gets the same latency injection as the direct SSH and HTTPS tests from Part 2. The backend SSH link gets a fixed 2ms RTT. All transports experience the same WAN conditions. The only difference is what happens on the last hop.&lt;/p&gt;
&lt;h2 id="results"&gt;Results&lt;/h2&gt;
&lt;p&gt;All runs: 20 iterations, 5 commands per iteration (batched in one POST). SSH direct numbers from Part 2 for comparison. The &amp;ldquo;SSH direct&amp;rdquo; column uses PTY/shell mode, what Netmiko and Ansible actually do.&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;WAN RTT&lt;/th&gt;
&lt;th&gt;SSH direct (PTY)&lt;/th&gt;
&lt;th&gt;Proxy (new conn)&lt;/th&gt;
&lt;th&gt;Proxy (reused conn)&lt;/th&gt;
&lt;th&gt;Speedup (reused vs SSH PTY)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;30ms&lt;/td&gt;
&lt;td&gt;528ms&lt;/td&gt;
&lt;td&gt;124ms&lt;/td&gt;
&lt;td&gt;56ms&lt;/td&gt;
&lt;td&gt;9.4x&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;70ms&lt;/td&gt;
&lt;td&gt;1,208ms&lt;/td&gt;
&lt;td&gt;248ms&lt;/td&gt;
&lt;td&gt;95ms&lt;/td&gt;
&lt;td&gt;12.7x&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;150ms&lt;/td&gt;
&lt;td&gt;2,571ms&lt;/td&gt;
&lt;td&gt;489ms&lt;/td&gt;
&lt;td&gt;175ms&lt;/td&gt;
&lt;td&gt;14.7x&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;&lt;img src="https://blog2.network-notes.com/img/2026/cli-over-https-proxy-results.158ed5e2994a247307eda2406debb6658d28e4dc01f5014b2b871c975bdb7376.svg" alt="Bar chart comparing SSH direct (PTY) vs proxy new connection vs proxy reused connection at 30ms, 70ms, and 150ms WAN latency" loading="lazy" /&gt;&lt;/p&gt;
&lt;p&gt;The new-connection proxy (full TLS handshake per request) is 4.3-5.3x faster than SSH direct. With connection reuse, the proxy hits 9.4-14.7x, and the advantage grows with latency because reusing the connection eliminates the TLS handshake entirely, paying only 1 round trip per request.&lt;/p&gt;
&lt;p&gt;At 150ms RTT (a US NOC managing devices in Hong Kong) SSH direct (PTY) takes 2.6 seconds per device. The proxy with a persistent connection does it in 175ms.&lt;/p&gt;
&lt;h2 id="why-it-works"&gt;Why It Works&lt;/h2&gt;
&lt;p&gt;SSH direct (PTY mode) at 150ms RTT pays the full protocol tax on every round trip over the WAN:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;TCP handshake: 1 RT × 150ms&lt;/li&gt;
&lt;li&gt;SSH version exchange: 1 RT × 150ms&lt;/li&gt;
&lt;li&gt;Key exchange: 2 RT × 150ms&lt;/li&gt;
&lt;li&gt;Auth + channel + PTY + shell: 4 RT × 150ms&lt;/li&gt;
&lt;li&gt;Session prep: 2 RT × 150ms&lt;/li&gt;
&lt;li&gt;5 commands with echo verification: 5 RT × 150ms&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;That&amp;rsquo;s ~15 round trips × 150ms = &lt;strong&gt;~2,250ms&lt;/strong&gt; of protocol overhead, plus processing time.&lt;/p&gt;
&lt;p&gt;The proxy with a new connection splits that cost across two links:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;WAN leg (HTTPS, new connection):&lt;/strong&gt; TCP + TLS 1.3 + HTTP request = ~3 RT × 150ms = &lt;strong&gt;~450ms&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Local leg (SSH):&lt;/strong&gt; The same ~15 SSH round trips, but at 2ms = &lt;strong&gt;~30ms&lt;/strong&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Total: ~480ms. That&amp;rsquo;s the cold-start cost when your automation opens a new connection to the proxy.&lt;/p&gt;
&lt;p&gt;The proxy with a reused connection eliminates the handshake entirely:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;WAN leg (HTTPS, reused connection):&lt;/strong&gt; HTTP request/response = ~1 RT × 150ms = &lt;strong&gt;~150ms&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Local leg (SSH):&lt;/strong&gt; Same ~30ms&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Total: ~180ms. Measured: 175ms.&lt;/p&gt;
&lt;p&gt;This is the steady-state performance. Once your automation has an open connection to the proxy (which any HTTP client maintains by default), every subsequent request costs exactly one WAN round trip plus the local SSH work. The SSH overhead is still there. It&amp;rsquo;s just happening on a link where 15 round trips cost 30ms instead of 2,250ms.&lt;/p&gt;
&lt;h2 id="fresh-vs-pooled-does-it-matter"&gt;Fresh vs Pooled: Does It Matter?&lt;/h2&gt;
&lt;p&gt;At local latency, not much.
The gap between fresh-ssh (132ms) and pooled-ssh (119ms) at 30ms WAN RTT is 13ms, the cost of one SSH handshake at 2ms RTT.
In production you&amp;rsquo;d pool connections anyway for resource efficiency, but the performance argument for pooling is modest when the backend latency is low.&lt;/p&gt;
&lt;p&gt;The operational argument matters more.
A pooled connection means fewer SSH sessions on the device, and Network devices have finite session limits.
An ASA might handle 5 concurrent SSH sessions, a catalyst might allow 16.
If your proxy is serving 50 requests per second, fresh connections will exhaust those limits instantly.
Pooling keeps one session open per device and multiplexes commands through it.&lt;/p&gt;
&lt;p&gt;The pooling logic in clibench is simple.
&lt;code&gt;getSSH()&lt;/code&gt; returns an existing connection if one is pooled, or dials a new one:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;div class="chroma"&gt;
&lt;table class="lntable"&gt;&lt;tr&gt;&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code&gt;&lt;span class="lnt"&gt; 1
&lt;/span&gt;&lt;span class="lnt"&gt; 2
&lt;/span&gt;&lt;span class="lnt"&gt; 3
&lt;/span&gt;&lt;span class="lnt"&gt; 4
&lt;/span&gt;&lt;span class="lnt"&gt; 5
&lt;/span&gt;&lt;span class="lnt"&gt; 6
&lt;/span&gt;&lt;span class="lnt"&gt; 7
&lt;/span&gt;&lt;span class="lnt"&gt; 8
&lt;/span&gt;&lt;span class="lnt"&gt; 9
&lt;/span&gt;&lt;span class="lnt"&gt;10
&lt;/span&gt;&lt;span class="lnt"&gt;11
&lt;/span&gt;&lt;span class="lnt"&gt;12
&lt;/span&gt;&lt;span class="lnt"&gt;13
&lt;/span&gt;&lt;span class="lnt"&gt;14
&lt;/span&gt;&lt;span class="lnt"&gt;15
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;
&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-go" data-lang="go"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="kd"&gt;func&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="nx"&gt;Server&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nf"&gt;getSSH&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="o"&gt;*&lt;/span&gt;&lt;span class="nx"&gt;ssh&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;Client&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kt"&gt;bool&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kt"&gt;error&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&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;if&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;pooled&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&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;err&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="nx"&gt;ssh&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Dial&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;&amp;#34;tcp&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;backendAddr&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;sshCfg&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&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&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;if&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;pool&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="kc"&gt;nil&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&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;pool&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;nil&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&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&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;err&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="nx"&gt;ssh&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Dial&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;&amp;#34;tcp&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;backendAddr&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;sshCfg&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;if&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;err&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="kc"&gt;nil&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&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;nil&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&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&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;pool&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="nx"&gt;c&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;nil&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;
&lt;/div&gt;
&lt;/div&gt;&lt;p&gt;The tradeoff is stale connections; devices reboot, sessions time out, firewalls drop idle flows.
The proxy needs to detect dead connections and reconnect, the same problem as HTTP connection pooling or database connection pooling.
In clibench, a failed session operation clears the pool so the next request gets a fresh connection.
In production, you&amp;rsquo;d add periodic health checks and a circuit breaker for unreachable devices; which is what &lt;a href="https://github.com/lykinsbd/naas"&gt;NAAS&lt;/a&gt; does, for example.&lt;/p&gt;
&lt;h2 id="the-tunnel-keep-ssh-on-both-ends"&gt;The Tunnel: Keep SSH on Both Ends&lt;/h2&gt;
&lt;p&gt;The proxy requires changing your automation client. What if you can&amp;rsquo;t?&lt;/p&gt;
&lt;p&gt;Many teams have years of Ansible playbooks, Nornir scripts, and Netmiko wrappers that all speak SSH. Rewriting them to speak HTTPS is a project, not a config change. The tunnel solves this: both your automation and the device speak SSH. The WAN segment in between uses HTTPS or HTTP/3, but neither endpoint knows or cares.&lt;/p&gt;
&lt;h3 id="architecture"&gt;Architecture&lt;/h3&gt;
&lt;p&gt;&lt;img src="https://blog2.network-notes.com/img/2026/cli-over-https-tunnel-architecture.6587a549d1a13214b49157b53d51b5ea8a213ed452a9e45a3b6c084fc7e5dce6.svg" alt="Tunnel architecture: automation speaks SSH to a headend, which converts to HTTPS/H3 over the WAN, then a site proxy converts back to SSH for the device" loading="lazy" /&gt;&lt;/p&gt;
&lt;p&gt;The &lt;strong&gt;headend&lt;/strong&gt; sits near your automation server. It accepts SSH connections, parses the exec command, and forwards it as an HTTP request over the WAN to the &lt;strong&gt;site proxy&lt;/strong&gt;. The site proxy is the same component from the proxy pattern above. It receives the HTTP request and talks SSH to the device on a local link.&lt;/p&gt;
&lt;p&gt;Your automation runs &lt;code&gt;ssh headend &amp;quot;show version&amp;quot;&lt;/code&gt; and gets back the device output. Under the hood, the WAN segment used HTTPS with 2-3 round trips instead of SSH&amp;rsquo;s 15+.&lt;/p&gt;
&lt;h3 id="results-1"&gt;Results&lt;/h3&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;WAN RTT&lt;/th&gt;
&lt;th&gt;SSH direct (PTY)&lt;/th&gt;
&lt;th&gt;Tunnel (per-cmd)&lt;/th&gt;
&lt;th&gt;Tunnel (batch)&lt;/th&gt;
&lt;th&gt;Speedup (batch vs SSH PTY)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;30ms&lt;/td&gt;
&lt;td&gt;528ms&lt;/td&gt;
&lt;td&gt;228ms&lt;/td&gt;
&lt;td&gt;82ms&lt;/td&gt;
&lt;td&gt;6.4x&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;70ms&lt;/td&gt;
&lt;td&gt;1,208ms&lt;/td&gt;
&lt;td&gt;429ms&lt;/td&gt;
&lt;td&gt;121ms&lt;/td&gt;
&lt;td&gt;10.0x&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;150ms&lt;/td&gt;
&lt;td&gt;2,571ms&lt;/td&gt;
&lt;td&gt;856ms&lt;/td&gt;
&lt;td&gt;202ms&lt;/td&gt;
&lt;td&gt;12.7x&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;Two things jump out.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Without batching, the tunnel is slower than the proxy.&lt;/strong&gt; The per-command tunnel mode (ssh-https-ssh) pays SSH overhead on &lt;em&gt;both&lt;/em&gt; ends: the automation-to-headend SSH handshake, plus the site proxy-to-device SSH handshake. That&amp;rsquo;s two sets of SSH round trips at campus latency (~2ms each), plus the WAN HTTP request per command. At 150ms, 856ms is still 3.0x faster than SSH direct, but much worse than the proxy&amp;rsquo;s 175ms.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;With batching, the tunnel approaches proxy performance.&lt;/strong&gt; The batch mode sends all 5 commands in a single SSH exec payload to the headend. The headend forwards them as one HTTP POST. The site proxy runs them all in one SSH session. At 150ms, that&amp;rsquo;s 202ms vs the proxy&amp;rsquo;s 175ms. The tunnel pays a small penalty for the extra SSH hop on the automation side, but it&amp;rsquo;s close.&lt;/p&gt;
&lt;p&gt;The tunnel&amp;rsquo;s value isn&amp;rsquo;t raw speed. It&amp;rsquo;s that you get 12.7x improvement with zero changes to your automation code or your devices.&lt;/p&gt;
&lt;h2 id="proxy-vs-tunnel-when-to-use-which"&gt;Proxy vs Tunnel: When to Use Which&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;Use the proxy&lt;/strong&gt; when you can modify your automation to speak HTTPS. It&amp;rsquo;s faster (14.7x with connection reuse vs 12.7x for the tunnel), simpler (one hop instead of two), and has lower per-request overhead.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Use the tunnel&lt;/strong&gt; when you can&amp;rsquo;t change the automation client. If your tooling must speak SSH (connection plugins, credential management, or organizational inertia) the tunnel gives you WAN optimization transparently. The batch mode requires that your SSH client sends multiple commands in one exec call (which tools like &lt;code&gt;ssh host &amp;quot;cmd1 &amp;amp;&amp;amp; cmd2&amp;quot;&lt;/code&gt; do naturally), but even per-command mode is 3.0x faster than SSH direct at high latency.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Use both&lt;/strong&gt; in a migration. Deploy the tunnel first for immediate wins with no code changes, then migrate automation to speak HTTPS to the proxy directly as you refactor.&lt;/p&gt;
&lt;h2 id="what-this-looks-like-in-practice"&gt;What This Looks Like in Practice&lt;/h2&gt;
&lt;p&gt;If you have an internal API that accepts &amp;ldquo;run this command on this device&amp;rdquo; requests and returns the output, you&amp;rsquo;re already running a version of this.&lt;/p&gt;
&lt;p&gt;Examples in the ecosystem:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Salt proxy minions with NAPALM behind the Salt REST API&lt;/li&gt;
&lt;li&gt;AWX execution environments co-located with devices&lt;/li&gt;
&lt;li&gt;Oxidized&amp;rsquo;s web interface&lt;/li&gt;
&lt;li&gt;The Rackspace Go microservices from &lt;a href="https://blog2.network-notes.com/posts/2026/cli-over-https-1/"&gt;Part 1&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/lykinsbd/naas"&gt;NAAS (Netmiko as a Service)&lt;/a&gt;: wraps Netmiko behind a REST API with connection pooling, async jobs, and circuit breakers&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Most of these co-locate SSH with the devices (good), but don&amp;rsquo;t expose a clean HTTPS interface upstream, or they bury it under job queues, inventory sync, and YAML sprawl. The core pattern is simpler than any of those tools. The proxy in &lt;a href="https://github.com/lykinsbd/clibench"&gt;clibench&lt;/a&gt; proves the concept in ~180 lines of Go. A production deployment adds multi-vendor support, health checks, and credential management on top.&lt;/p&gt;
&lt;h2 id="security"&gt;Security&lt;/h2&gt;
&lt;p&gt;The proxy doesn&amp;rsquo;t make things more or less secure. It changes the trust model.&lt;/p&gt;
&lt;p&gt;With SSH direct, your automation server holds the SSH keys and authenticates directly to every device.
With the proxy pattern, the trust boundary splits in two: your automation authenticates to the proxy (over HTTPS, using API tokens, mTLS, or whatever your org uses for service-to-service auth), and the proxy authenticates to the devices (over SSH, using keys that are available to the proxy itself).&lt;/p&gt;
&lt;p&gt;What actually changes:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Where the SSH keys live.&lt;/strong&gt; They move from the automation server to the proxy. The private keys never cross the WAN in either model (SSH public key auth sends a signature, not the key), but the proxy pattern puts the keys physically closer to the devices they unlock.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;The WAN-side auth mechanism.&lt;/strong&gt; Your automation no longer speaks SSH to devices. It speaks HTTPS to the proxy. That&amp;rsquo;s not inherently better or worse. It&amp;rsquo;s a different credential type (API token or client cert vs SSH key) managed through whatever system your organization already runs for service authentication.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;The blast radius of a compromised proxy.&lt;/strong&gt; The proxy has access to the SSH keys for every device it manages. Compromise the proxy, and you have access to the fleet. This is the same risk profile as an SSH bastion host, which most organizations already operate and already know how to harden: minimal attack surface, restricted network access, key rotation, session logging, and monitoring. The proxy deserves the same care you&amp;rsquo;d give a bastion.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id="when-the-proxy-doesnt-help"&gt;When the Proxy Doesn&amp;rsquo;t Help&lt;/h2&gt;
&lt;p&gt;The proxy pattern assumes the WAN latency between your automation and the devices is the bottleneck. If your automation server is already co-located with the devices (same rack, same DC), there&amp;rsquo;s no WAN leg to optimize. SSH at 1-2ms RTT is fast enough.&lt;/p&gt;
&lt;p&gt;It also doesn&amp;rsquo;t help if your bottleneck is device processing time rather than transport overhead. If a &lt;code&gt;show tech-support&lt;/code&gt; takes 30 seconds to generate on the device, the transport saves you a few hundred milliseconds on a 30-second operation. Still worth it at scale, but the relative improvement is smaller.&lt;/p&gt;
&lt;p&gt;And the proxy adds operational complexity. It&amp;rsquo;s another service to deploy, monitor, and maintain. For a team managing 50 devices in one location, the overhead isn&amp;rsquo;t justified. For a team managing thousands of devices across multiple continents, which is where SSH overhead actually hurts, the proxy pays for itself on the first automation run.&lt;/p&gt;
&lt;h2 id="try-it"&gt;Try It&lt;/h2&gt;
&lt;p&gt;The &lt;a href="https://github.com/lykinsbd/clibench"&gt;benchmark code&lt;/a&gt; includes all modes. Run it yourself:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;div class="chroma"&gt;
&lt;table class="lntable"&gt;&lt;tr&gt;&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code&gt;&lt;span class="lnt"&gt;1
&lt;/span&gt;&lt;span class="lnt"&gt;2
&lt;/span&gt;&lt;span class="lnt"&gt;3
&lt;/span&gt;&lt;span class="lnt"&gt;4
&lt;/span&gt;&lt;span class="lnt"&gt;5
&lt;/span&gt;&lt;span class="lnt"&gt;6
&lt;/span&gt;&lt;span class="lnt"&gt;7
&lt;/span&gt;&lt;span class="lnt"&gt;8
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;
&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-bash" data-lang="bash"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;# All transports at 150ms WAN&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;sudo ./bin/clibench bench --latency intercontinental --iterations &lt;span class="m"&gt;20&lt;/span&gt; --commands &lt;span class="m"&gt;5&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;# Proxy only (HTTPS + HTTP/3 variants)&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;sudo ./bin/clibench bench --latency intercontinental --iterations &lt;span class="m"&gt;20&lt;/span&gt; --commands &lt;span class="m"&gt;5&lt;/span&gt; --transport proxy
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;# Tunnel mode (SSH-to-HTTPS transparent WAN tunnel)&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;sudo ./bin/clibench bench --latency intercontinental --iterations &lt;span class="m"&gt;20&lt;/span&gt; --commands &lt;span class="m"&gt;5&lt;/span&gt; --transport tunnel-https
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;
&lt;/div&gt;
&lt;/div&gt;&lt;p&gt;In &lt;a href="https://blog2.network-notes.com/posts/2026/cli-over-https-4/"&gt;Part 4&lt;/a&gt;, I&amp;rsquo;ll lay out a decision framework for choosing between SSH direct, a proxy, a tunnel, and native HTTPS, and dig into &lt;a href="https://github.com/lykinsbd/naas"&gt;NAAS&lt;/a&gt; as a production deployment of the proxy pattern.&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;My take:&lt;/strong&gt; The proxy pattern isn&amp;rsquo;t a workaround. It&amp;rsquo;s the right architecture for managing geographically distributed network infrastructure. SSH is fine for the last hop. HTTPS (or QUIC) is better for everything upstream.&lt;/p&gt;
&lt;/blockquote&gt;</description></item><item><title>CLI Over HTTPS Part 2: Proving It</title><link>https://blog2.network-notes.com/posts/2026/cli-over-https-2/</link><pubDate>Thu, 30 Apr 2026 09:00:00 +0000</pubDate><author>brett@network-notes.com (Brett Lykins)</author><dc:creator>Brett Lykins</dc:creator><guid>https://blog2.network-notes.com/posts/2026/cli-over-https-2/</guid><description>&lt;p&gt;In &lt;a href="https://blog2.network-notes.com/posts/2026/cli-over-https-1/"&gt;Part 1&lt;/a&gt;, I argued that SSH is a slow transport for network automation at scale and that HTTPS is fundamentally faster. Round-trip analysis and back-of-napkin math are useful, but they&amp;rsquo;re not proof. This post is the proof.&lt;/p&gt;
&lt;p&gt;I built &lt;a href="https://github.com/lykinsbd/clibench"&gt;clibench&lt;/a&gt;, a dual-protocol network device emulator and benchmark client that measures the difference at realistic latencies sourced from &lt;a href="https://www.verizon.com/business/terms/latency/"&gt;Verizon&amp;rsquo;s published backbone measurements&lt;/a&gt;.&lt;/p&gt;
&lt;h2 id="design-constraints"&gt;Design Constraints&lt;/h2&gt;
&lt;p&gt;For the comparison to mean anything, the test has to be fair. That means:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Same device, same commands, same output.&lt;/strong&gt; Both transports hit the same &lt;code&gt;device.Device&lt;/code&gt; struct. The only variable is how the command arrives and how the response leaves.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Same latency for both.&lt;/strong&gt; Delay is injected at the TCP connection level, not the application level. Both SSH and HTTPS experience identical network conditions.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Realistic latency values.&lt;/strong&gt; No made-up numbers. Every profile is sourced from published measurements.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Multiple modes per transport.&lt;/strong&gt; SSH gets tested with fresh connections and with connection reuse (ControlMaster-style). HTTPS gets tested with fresh connections, keep-alive, multi-command batching, and config push. Each mode represents a real-world usage pattern.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id="architecture"&gt;Architecture&lt;/h2&gt;
&lt;p&gt;clibench is written in Go. The project has nine packages:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;div class="chroma"&gt;
&lt;table class="lntable"&gt;&lt;tr&gt;&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code&gt;&lt;span class="lnt"&gt; 1
&lt;/span&gt;&lt;span class="lnt"&gt; 2
&lt;/span&gt;&lt;span class="lnt"&gt; 3
&lt;/span&gt;&lt;span class="lnt"&gt; 4
&lt;/span&gt;&lt;span class="lnt"&gt; 5
&lt;/span&gt;&lt;span class="lnt"&gt; 6
&lt;/span&gt;&lt;span class="lnt"&gt; 7
&lt;/span&gt;&lt;span class="lnt"&gt; 8
&lt;/span&gt;&lt;span class="lnt"&gt; 9
&lt;/span&gt;&lt;span class="lnt"&gt;10
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;
&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-text" data-lang="text"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;internal/
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; bench/ Benchmark runner: SSH, HTTPS, proxy, and PTY modes
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; device/ Shared command engine: prefix matching, transcript loading
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; sshserver/ crypto/ssh listener, CiSSHGo patterns
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; httpserver/ net/http + TLS, ASA-style /admin/exec/ and /admin/config
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; proxy/ HTTPS→SSH edge proxy (fresh + pooled modes)
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; netem/ tc netem latency injection (Linux, requires sudo)
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; latency/ Userspace delay injection (fallback, no root)
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; stats/ Benchmark statistics: percentile, summarize, parallel runner
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; tlsutil/ Shared self-signed TLS config generator
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;
&lt;/div&gt;
&lt;/div&gt;&lt;p&gt;&lt;img src="https://blog2.network-notes.com/img/2026/cli-over-https-bench-architecture.5fda7576f27eb2b1430a4a744666530cfc1e3a520c633554f8ab7a4da0730954.svg" alt="Benchmark architecture: client on the left, latency injection in the middle, SSH and HTTPS servers both feeding into a shared device.Device on the right" loading="lazy" /&gt;&lt;/p&gt;
&lt;p&gt;The benchmark client embeds its own server. No separate process needed. Latency is injected at the kernel level using Linux &lt;code&gt;tc netem&lt;/code&gt;, applied per-port on the loopback interface so both SSH and HTTPS experience identical network conditions.&lt;/p&gt;
&lt;h3 id="the-shared-command-engine"&gt;The Shared Command Engine&lt;/h3&gt;
&lt;p&gt;Both servers use the same &lt;code&gt;device.Device&lt;/code&gt;:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;div class="chroma"&gt;
&lt;table class="lntable"&gt;&lt;tr&gt;&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code&gt;&lt;span class="lnt"&gt; 1
&lt;/span&gt;&lt;span class="lnt"&gt; 2
&lt;/span&gt;&lt;span class="lnt"&gt; 3
&lt;/span&gt;&lt;span class="lnt"&gt; 4
&lt;/span&gt;&lt;span class="lnt"&gt; 5
&lt;/span&gt;&lt;span class="lnt"&gt; 6
&lt;/span&gt;&lt;span class="lnt"&gt; 7
&lt;/span&gt;&lt;span class="lnt"&gt; 8
&lt;/span&gt;&lt;span class="lnt"&gt; 9
&lt;/span&gt;&lt;span class="lnt"&gt;10
&lt;/span&gt;&lt;span class="lnt"&gt;11
&lt;/span&gt;&lt;span class="lnt"&gt;12
&lt;/span&gt;&lt;span class="lnt"&gt;13
&lt;/span&gt;&lt;span class="lnt"&gt;14
&lt;/span&gt;&lt;span class="lnt"&gt;15
&lt;/span&gt;&lt;span class="lnt"&gt;16
&lt;/span&gt;&lt;span class="lnt"&gt;17
&lt;/span&gt;&lt;span class="lnt"&gt;18
&lt;/span&gt;&lt;span class="lnt"&gt;19
&lt;/span&gt;&lt;span class="lnt"&gt;20
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;
&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-go" data-lang="go"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="kd"&gt;type&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;Device&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kd"&gt;struct&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&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;Hostname&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;Username&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;Password&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;commands&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kd"&gt;map&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="c1"&gt;// &amp;#34;show version&amp;#34; -&amp;gt; transcript text&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;transcriptDir&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="kd"&gt;func&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;d&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="nx"&gt;Device&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nf"&gt;Exec&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kt"&gt;string&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&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;input&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="nx"&gt;strings&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;TrimSpace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;if&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;input&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="s"&gt;&amp;#34;&amp;#34;&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&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;&amp;#34;&amp;#34;&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&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&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="c1"&gt;// exact match first&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;if&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;resp&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;ok&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="nx"&gt;d&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;commands&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;ok&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&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;resp&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&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&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="c1"&gt;// prefix match (&amp;#34;sh ver&amp;#34; -&amp;gt; &amp;#34;show version&amp;#34;)&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="c1"&gt;// returns &amp;#34;% Ambiguous command&amp;#34; or &amp;#34;% Unknown command&amp;#34; on miss&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;
&lt;/div&gt;
&lt;/div&gt;&lt;p&gt;Command transcripts are plain text files loaded from a directory. The filename convention maps to the command: &lt;code&gt;show_version.txt&lt;/code&gt; becomes &lt;code&gt;show version&lt;/code&gt;. Templates support &lt;code&gt;{{.Hostname}}&lt;/code&gt; substitution. This follows the same pattern as &lt;a href="https://blog2.network-notes.com/posts/2026/cisshgo-ssh-device-emulator/"&gt;CiSSHGo&lt;/a&gt;, which I wrote about recently.&lt;/p&gt;
&lt;h3 id="the-ssh-server"&gt;The SSH Server&lt;/h3&gt;
&lt;p&gt;The SSH side uses Go&amp;rsquo;s &lt;code&gt;crypto/ssh&lt;/code&gt; package with an ed25519 host key generated at startup. It supports both exec mode (&lt;code&gt;ssh host &amp;quot;show version&amp;quot;&lt;/code&gt;) and interactive shell sessions with prompt rendering and command matching. The benchmark client tests both, since real-world tools are split: libraries like Go&amp;rsquo;s &lt;code&gt;x/crypto/ssh&lt;/code&gt; use exec mode, while Netmiko, Ansible, and Scrapli use PTY/shell.&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;div class="chroma"&gt;
&lt;table class="lntable"&gt;&lt;tr&gt;&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code&gt;&lt;span class="lnt"&gt; 1
&lt;/span&gt;&lt;span class="lnt"&gt; 2
&lt;/span&gt;&lt;span class="lnt"&gt; 3
&lt;/span&gt;&lt;span class="lnt"&gt; 4
&lt;/span&gt;&lt;span class="lnt"&gt; 5
&lt;/span&gt;&lt;span class="lnt"&gt; 6
&lt;/span&gt;&lt;span class="lnt"&gt; 7
&lt;/span&gt;&lt;span class="lnt"&gt; 8
&lt;/span&gt;&lt;span class="lnt"&gt; 9
&lt;/span&gt;&lt;span class="lnt"&gt;10
&lt;/span&gt;&lt;span class="lnt"&gt;11
&lt;/span&gt;&lt;span class="lnt"&gt;12
&lt;/span&gt;&lt;span class="lnt"&gt;13
&lt;/span&gt;&lt;span class="lnt"&gt;14
&lt;/span&gt;&lt;span class="lnt"&gt;15
&lt;/span&gt;&lt;span class="lnt"&gt;16
&lt;/span&gt;&lt;span class="lnt"&gt;17
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;
&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-go" data-lang="go"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;// Exec mode: split newline-delimited payloads for batch support&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;case&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;&amp;#34;exec&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;if&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;Payload&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;4&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&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;execCmd&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="nb"&gt;string&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;Payload&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;:])&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&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&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Reply&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;nil&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;if&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;execCmd&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="s"&gt;&amp;#34;&amp;#34;&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&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kd"&gt;var&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;out&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;strings&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;Builder&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;for&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;_&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;line&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="k"&gt;range&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;strings&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Split&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;execCmd&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;&amp;#34;\n&amp;#34;&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&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;cmd&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="nx"&gt;strings&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;TrimSpace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;line&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;if&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;cmd&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="s"&gt;&amp;#34;&amp;#34;&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&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;out&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;WriteString&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;dev&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Exec&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;cmd&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&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&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&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&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;io&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;WriteString&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ch&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;out&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;String&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&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&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;ch&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;SendRequest&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;&amp;#34;exit-status&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;false&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="kt"&gt;byte&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;})&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;
&lt;/div&gt;
&lt;/div&gt;&lt;h3 id="the-https-server"&gt;The HTTPS Server&lt;/h3&gt;
&lt;p&gt;The HTTPS side generates a self-signed P-256 ECDSA certificate at startup (negotiating TLS 1.3 with &lt;code&gt;TLS_AES_128_GCM_SHA256&lt;/code&gt;) and exposes the same endpoints as the &lt;a href="https://www.cisco.com/c/en/us/td/docs/security/asa/misc/http-interface/asa-http-interface.html"&gt;Cisco ASA HTTP interface&lt;/a&gt;:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;GET /admin/exec/show+version&lt;/code&gt;. Single command, URL-encoded&lt;/li&gt;
&lt;li&gt;&lt;code&gt;GET /admin/exec/cmd1/cmd2/cmd3&lt;/code&gt;. Multiple commands, slash-separated&lt;/li&gt;
&lt;li&gt;&lt;code&gt;POST /admin/config&lt;/code&gt;. Bulk commands, newline-delimited body&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Authentication is HTTP Basic over TLS, matching the ASA&amp;rsquo;s behavior.&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;div class="chroma"&gt;
&lt;table class="lntable"&gt;&lt;tr&gt;&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code&gt;&lt;span class="lnt"&gt; 1
&lt;/span&gt;&lt;span class="lnt"&gt; 2
&lt;/span&gt;&lt;span class="lnt"&gt; 3
&lt;/span&gt;&lt;span class="lnt"&gt; 4
&lt;/span&gt;&lt;span class="lnt"&gt; 5
&lt;/span&gt;&lt;span class="lnt"&gt; 6
&lt;/span&gt;&lt;span class="lnt"&gt; 7
&lt;/span&gt;&lt;span class="lnt"&gt; 8
&lt;/span&gt;&lt;span class="lnt"&gt; 9
&lt;/span&gt;&lt;span class="lnt"&gt;10
&lt;/span&gt;&lt;span class="lnt"&gt;11
&lt;/span&gt;&lt;span class="lnt"&gt;12
&lt;/span&gt;&lt;span class="lnt"&gt;13
&lt;/span&gt;&lt;span class="lnt"&gt;14
&lt;/span&gt;&lt;span class="lnt"&gt;15
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;
&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-go" data-lang="go"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="kd"&gt;func&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="nx"&gt;Server&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nf"&gt;handleExec&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;w&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;http&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ResponseWriter&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="nx"&gt;http&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;Request&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&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;path&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="nx"&gt;strings&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;TrimPrefix&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;URL&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;Path&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;&amp;#34;/admin/exec/&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;parts&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="nx"&gt;strings&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Split&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;path&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;&amp;#34;/&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kd"&gt;var&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;out&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;strings&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;Builder&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;for&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;_&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;p&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="k"&gt;range&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;parts&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&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;cmd&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="nx"&gt;strings&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ReplaceAll&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;&amp;#34;+&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;&amp;#34; &amp;#34;&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;cmd&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="nx"&gt;strings&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;TrimSpace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;cmd&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;if&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;cmd&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="s"&gt;&amp;#34;&amp;#34;&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&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;continue&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&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&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;out&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;WriteString&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;dev&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Exec&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;cmd&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&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&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;w&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Header&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;Set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;&amp;#34;Content-Type&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;&amp;#34;text/plain&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;io&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;WriteString&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;w&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;out&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;String&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;
&lt;/div&gt;
&lt;/div&gt;&lt;h3 id="the-benchmark-client"&gt;The Benchmark Client&lt;/h3&gt;
&lt;p&gt;The client calls both transports with the same commands and measures wall-clock time.
The key difference is visible in the code: SSH requires connection setup, auth, channel open, and per-command exec requests.
HTTPS is a single HTTP call:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;div class="chroma"&gt;
&lt;table class="lntable"&gt;&lt;tr&gt;&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code&gt;&lt;span class="lnt"&gt; 1
&lt;/span&gt;&lt;span class="lnt"&gt; 2
&lt;/span&gt;&lt;span class="lnt"&gt; 3
&lt;/span&gt;&lt;span class="lnt"&gt; 4
&lt;/span&gt;&lt;span class="lnt"&gt; 5
&lt;/span&gt;&lt;span class="lnt"&gt; 6
&lt;/span&gt;&lt;span class="lnt"&gt; 7
&lt;/span&gt;&lt;span class="lnt"&gt; 8
&lt;/span&gt;&lt;span class="lnt"&gt; 9
&lt;/span&gt;&lt;span class="lnt"&gt;10
&lt;/span&gt;&lt;span class="lnt"&gt;11
&lt;/span&gt;&lt;span class="lnt"&gt;12
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;
&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-go" data-lang="go"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;// SSH: connect + auth + exec per command&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nx"&gt;client&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;_&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="nx"&gt;ssh&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Dial&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;&amp;#34;tcp&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;addr&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;sshConfig&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;for&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;_&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;cmd&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="k"&gt;range&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;commands&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&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;session&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;_&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="nx"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;NewSession&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;output&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;_&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="nx"&gt;session&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;CombinedOutput&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;cmd&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;session&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Close&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;// HTTPS: one request, all commands&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nx"&gt;url&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="s"&gt;&amp;#34;https://&amp;#34;&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="nx"&gt;addr&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="s"&gt;&amp;#34;/admin/exec/&amp;#34;&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="nx"&gt;strings&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;commands&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;&amp;#34;/&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nx"&gt;resp&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;_&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="nx"&gt;httpClient&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;_&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="nx"&gt;io&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ReadAll&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;resp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;Body&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;
&lt;/div&gt;
&lt;/div&gt;&lt;h3 id="latency-injection"&gt;Latency Injection&lt;/h3&gt;
&lt;p&gt;Latency is injected using Linux &lt;code&gt;tc netem&lt;/code&gt; on the loopback interface, configured entirely via the &lt;a href="https://github.com/vishvananda/netlink"&gt;&lt;code&gt;vishvananda/netlink&lt;/code&gt;&lt;/a&gt; library, the same netlink library used by Docker and Kubernetes.
The tool sets up a &lt;code&gt;prio&lt;/code&gt; qdisc with per-port &lt;code&gt;u32&lt;/code&gt; filters so that traffic to the SSH and HTTPS server ports gets the configured one-way delay, while other loopback traffic is unaffected.
This requires root or &lt;code&gt;CAP_NET_ADMIN&lt;/code&gt;, the same requirement as most raw-socket networking tools.&lt;/p&gt;
&lt;details&gt;
&lt;summary&gt;Netlink qdisc setup code (click to expand)&lt;/summary&gt;
&lt;div class="highlight"&gt;&lt;div class="chroma"&gt;
&lt;table class="lntable"&gt;&lt;tr&gt;&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code&gt;&lt;span class="lnt"&gt; 1
&lt;/span&gt;&lt;span class="lnt"&gt; 2
&lt;/span&gt;&lt;span class="lnt"&gt; 3
&lt;/span&gt;&lt;span class="lnt"&gt; 4
&lt;/span&gt;&lt;span class="lnt"&gt; 5
&lt;/span&gt;&lt;span class="lnt"&gt; 6
&lt;/span&gt;&lt;span class="lnt"&gt; 7
&lt;/span&gt;&lt;span class="lnt"&gt; 8
&lt;/span&gt;&lt;span class="lnt"&gt; 9
&lt;/span&gt;&lt;span class="lnt"&gt;10
&lt;/span&gt;&lt;span class="lnt"&gt;11
&lt;/span&gt;&lt;span class="lnt"&gt;12
&lt;/span&gt;&lt;span class="lnt"&gt;13
&lt;/span&gt;&lt;span class="lnt"&gt;14
&lt;/span&gt;&lt;span class="lnt"&gt;15
&lt;/span&gt;&lt;span class="lnt"&gt;16
&lt;/span&gt;&lt;span class="lnt"&gt;17
&lt;/span&gt;&lt;span class="lnt"&gt;18
&lt;/span&gt;&lt;span class="lnt"&gt;19
&lt;/span&gt;&lt;span class="lnt"&gt;20
&lt;/span&gt;&lt;span class="lnt"&gt;21
&lt;/span&gt;&lt;span class="lnt"&gt;22
&lt;/span&gt;&lt;span class="lnt"&gt;23
&lt;/span&gt;&lt;span class="lnt"&gt;24
&lt;/span&gt;&lt;span class="lnt"&gt;25
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;
&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-go" data-lang="go"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;// Qdisc setup via netlink - no shell-out to tc&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nx"&gt;prio&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="nx"&gt;netlink&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;NewPrio&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;netlink&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;QdiscAttrs&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;LinkIndex&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;loopbackIndex&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;Handle&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;netlink&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;MakeHandle&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;Parent&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;netlink&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;HANDLE_ROOT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;})&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nx"&gt;prio&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;Bands&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="mi"&gt;4&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nx"&gt;prio&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;PriorityMap&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="mi"&gt;16&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="kt"&gt;uint8&lt;/span&gt;&lt;span class="p"&gt;{}&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="c1"&gt;// unmatched traffic → band 0 (no delay)&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nx"&gt;netlink&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;QdiscAdd&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;prio&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nx"&gt;netem&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="nx"&gt;netlink&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;NewNetem&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;netlink&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;QdiscAttrs&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;Parent&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;netlink&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;MakeHandle&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;)},&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;netlink&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;NetemQdiscAttrs&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;Latency&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;uint32&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;delay&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Microseconds&lt;/span&gt;&lt;span class="p"&gt;())},&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nx"&gt;netlink&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;QdiscAdd&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;netem&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;// Per-port u32 filter: match dport, classify to delayed band&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nx"&gt;netlink&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;FilterAdd&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="nx"&gt;netlink&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;U32&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;FilterAttrs&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;netlink&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;FilterAttrs&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;Parent&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;netlink&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;MakeHandle&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;Protocol&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mh"&gt;0x0800&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;ClassId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;netlink&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;MakeHandle&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;Sel&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="nx"&gt;netlink&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;TcU32Sel&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;Flags&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;netlink&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;TC_U32_TERMINAL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;Keys&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="nx"&gt;netlink&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;TcU32Key&lt;/span&gt;&lt;span class="p"&gt;{{&lt;/span&gt;&lt;span class="nx"&gt;Val&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;uint32&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;port&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;Mask&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mh"&gt;0xffff&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;Off&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;20&lt;/span&gt;&lt;span class="p"&gt;}},&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&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&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;})&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;
&lt;/div&gt;
&lt;/div&gt;&lt;/details&gt;
&lt;p&gt;Because &lt;code&gt;netem&lt;/code&gt; operates at the kernel&amp;rsquo;s network stack, it captures real TCP behavior: Nagle&amp;rsquo;s algorithm, delayed ACKs, TCP window scaling, and proper per-packet delay.
Every packet in both directions, client-to-server and server-to-client, experiences the configured delay.
This is more accurate than userspace delay injection, which can&amp;rsquo;t distinguish between logically separate protocol exchanges that happen to be coalesced into a single write.&lt;/p&gt;
&lt;p&gt;A &lt;code&gt;-userspace&lt;/code&gt; flag is available as a fallback for environments where root isn&amp;rsquo;t available, but the published numbers all use &lt;code&gt;tc netem&lt;/code&gt;.&lt;/p&gt;
&lt;h2 id="latency-profiles"&gt;Latency Profiles&lt;/h2&gt;
&lt;p&gt;Each profile corresponds to a real network path, sourced from &lt;a href="https://www.verizon.com/business/terms/latency/"&gt;Verizon Enterprise&amp;rsquo;s monthly IP latency statistics&lt;/a&gt; (March 2026).
The simulated RTT values are rounded for readability; the Verizon measured column shows the exact source data:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Profile&lt;/th&gt;
&lt;th&gt;Simulated RTT&lt;/th&gt;
&lt;th&gt;Real-world path&lt;/th&gt;
&lt;th&gt;Verizon measured RTT&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;local&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;0ms&lt;/td&gt;
&lt;td&gt;Co-located&lt;/td&gt;
&lt;td&gt;Baseline&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;campus&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;2ms&lt;/td&gt;
&lt;td&gt;Same data center&lt;/td&gt;
&lt;td&gt;AWS/Prisma: 1-2ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;regional&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;30ms&lt;/td&gt;
&lt;td&gt;US backbone&lt;/td&gt;
&lt;td&gt;29.9ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;continental&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;70ms&lt;/td&gt;
&lt;td&gt;NYC ↔ London&lt;/td&gt;
&lt;td&gt;70.2ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;intercontinental&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;150ms&lt;/td&gt;
&lt;td&gt;US ↔ Hong Kong&lt;/td&gt;
&lt;td&gt;145.5ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;transpacific&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;175ms&lt;/td&gt;
&lt;td&gt;NA ↔ Taiwan&lt;/td&gt;
&lt;td&gt;175.2ms&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;h2 id="benchmark-modes"&gt;Benchmark Modes&lt;/h2&gt;
&lt;p&gt;The client tests these scenarios across both transports (plus a multi-command GET mode when running more than one command per iteration):&lt;/p&gt;
&lt;h3 id="ssh-exec-modes"&gt;SSH Exec Modes&lt;/h3&gt;
&lt;p&gt;SSH exec mode opens a channel, sends a command, and reads the output. This is what Go&amp;rsquo;s &lt;code&gt;x/crypto/ssh&lt;/code&gt;, Paramiko&amp;rsquo;s &lt;code&gt;exec_command()&lt;/code&gt;, and OpenSSH&amp;rsquo;s &lt;code&gt;ssh host &amp;quot;cmd&amp;quot;&lt;/code&gt; use. Each command gets its own channel.&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Mode&lt;/th&gt;
&lt;th&gt;What it measures&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;ssh/fresh-conn&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Full SSH lifecycle per iteration: TCP + handshake + auth + channel + exec&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;ssh/reuse-conn&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;One SSH connection shared across all iterations (ControlMaster-style)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;ssh/batch-exec&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Multi-line command string over a single exec session&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;h3 id="ssh-ptyshell-modes"&gt;SSH PTY/Shell Modes&lt;/h3&gt;
&lt;p&gt;SSH PTY mode opens an interactive shell with a pseudo-terminal, sends commands as keystrokes, and detects the prompt after each command.
This is what &lt;strong&gt;Netmiko&lt;/strong&gt;, &lt;strong&gt;Ansible &lt;code&gt;network_cli&lt;/code&gt;&lt;/strong&gt;, &lt;strong&gt;Scrapli&lt;/strong&gt;, and most real-world network automation tools use.
Many network devices don&amp;rsquo;t support exec mode properly, and automation tools need prompt detection, pagination control, and mode transitions.
(Part 1 called this the &lt;a href="https://blog2.network-notes.com/posts/2026/cli-over-https-1/#the-screen-scraping-tax"&gt;&amp;ldquo;screen-scraping tax&amp;rdquo;&lt;/a&gt;, the cost of parsing an unstructured byte stream.)&lt;/p&gt;
&lt;p&gt;The PTY benchmark includes session preparation (sending &lt;code&gt;terminal length 0&lt;/code&gt; and &lt;code&gt;terminal width 511&lt;/code&gt; before the first command) and per-command echo verification (reading until the echoed command appears, then reading until the prompt), matching the protocol-level behavior common to all major tools.&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Mode&lt;/th&gt;
&lt;th&gt;What it measures&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;ssh/pty-fresh&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Full SSH lifecycle + PTY + shell + session prep + commands with echo verification&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;ssh/pty-reuse&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Shared connection, new PTY/shell per iteration with session prep&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;h3 id="https-modes"&gt;HTTPS Modes&lt;/h3&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Mode&lt;/th&gt;
&lt;th&gt;What it measures&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;https/fresh-conn&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;New TCP + TLS handshake per iteration (&lt;code&gt;DisableKeepAlives: true&lt;/code&gt;)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;https/keep-alive&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Single TCP + TLS connection reused across all iterations (default HTTP behavior)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;https/batch-post&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;All commands in one POST body (&lt;code&gt;/admin/config&lt;/code&gt;)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;https/multi-cmd&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;All commands in one GET request (ASA &lt;code&gt;/admin/exec/cmd1/cmd2&lt;/code&gt; syntax)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;Each mode runs N iterations, each executing 5 &lt;code&gt;show version&lt;/code&gt; commands. The client reports min, max, average, p50, p95, and standard deviation.&lt;/p&gt;
&lt;h2 id="running-it-yourself"&gt;Running It Yourself&lt;/h2&gt;
&lt;div class="highlight"&gt;&lt;div class="chroma"&gt;
&lt;table class="lntable"&gt;&lt;tr&gt;&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code&gt;&lt;span class="lnt"&gt; 1
&lt;/span&gt;&lt;span class="lnt"&gt; 2
&lt;/span&gt;&lt;span class="lnt"&gt; 3
&lt;/span&gt;&lt;span class="lnt"&gt; 4
&lt;/span&gt;&lt;span class="lnt"&gt; 5
&lt;/span&gt;&lt;span class="lnt"&gt; 6
&lt;/span&gt;&lt;span class="lnt"&gt; 7
&lt;/span&gt;&lt;span class="lnt"&gt; 8
&lt;/span&gt;&lt;span class="lnt"&gt; 9
&lt;/span&gt;&lt;span class="lnt"&gt;10
&lt;/span&gt;&lt;span class="lnt"&gt;11
&lt;/span&gt;&lt;span class="lnt"&gt;12
&lt;/span&gt;&lt;span class="lnt"&gt;13
&lt;/span&gt;&lt;span class="lnt"&gt;14
&lt;/span&gt;&lt;span class="lnt"&gt;15
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;
&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-bash" data-lang="bash"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;git clone https://github.com/lykinsbd/clibench.git
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nb"&gt;cd&lt;/span&gt; clibench
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;go build -o bin/bench ./cmd/bench/
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;# Baseline - no added latency (no root needed)&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;./bin/bench -latency &lt;span class="nb"&gt;local&lt;/span&gt; -iterations &lt;span class="m"&gt;20&lt;/span&gt; -commands &lt;span class="m"&gt;5&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;# US backbone - 30ms RTT (requires root for tc netem)&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;sudo ./bin/bench -latency regional -iterations &lt;span class="m"&gt;20&lt;/span&gt; -commands &lt;span class="m"&gt;5&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;# US to Hong Kong - 150ms RTT&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;sudo ./bin/bench -latency intercontinental -iterations &lt;span class="m"&gt;20&lt;/span&gt; -commands &lt;span class="m"&gt;5&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;# Fallback: userspace delay injection (no root, less accurate)&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;./bin/bench -latency regional -iterations &lt;span class="m"&gt;20&lt;/span&gt; -commands &lt;span class="m"&gt;5&lt;/span&gt; -userspace
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;
&lt;/div&gt;
&lt;/div&gt;&lt;p&gt;clibench embeds its own server. No separate process needed. Non-local profiles require root (or &lt;code&gt;CAP_NET_ADMIN&lt;/code&gt;) for &lt;code&gt;tc netem&lt;/code&gt; on the loopback interface. Output is JSON.&lt;/p&gt;
&lt;h2 id="results"&gt;Results&lt;/h2&gt;
&lt;p&gt;5 commands per iteration, all times in milliseconds (average of 20 iterations). Latency injected via &lt;code&gt;tc netem&lt;/code&gt; on the loopback interface.&lt;/p&gt;
&lt;p&gt;At zero latency, SSH exec mode is the fastest option. There&amp;rsquo;s no round-trip penalty, and SSH&amp;rsquo;s binary framing has less per-message overhead than HTTP headers + TLS:&lt;/p&gt;
&lt;p&gt;&lt;img src="https://blog2.network-notes.com/img/2026/cli-over-https-bench-local.016c463cda2ffbff3ef237d3f2986ee2b27c37a09c455f2c394e3c0b416ba37c.svg" alt="Bar chart at 0ms RTT showing SSH exec-fresh at 3.9ms, PTY-fresh at 4.3ms, and HTTPS fresh at 12.0ms" loading="lazy" /&gt;&lt;/p&gt;
&lt;p&gt;The moment real network latency enters the picture, the result flips. At 30ms RTT, a US backbone path per Verizon&amp;rsquo;s March 2026 measurements, HTTPS batch is 16.8x faster than SSH PTY fresh and 15.9x faster than SSH exec fresh-conn:&lt;/p&gt;
&lt;p&gt;&lt;img src="https://blog2.network-notes.com/img/2026/cli-over-https-bench-regional.eb891e20708c51e9da713ce909b2088de07d106485a700f823d35833d01957f0.svg" alt="Bar chart at 30ms RTT showing SSH exec-fresh at 494ms, PTY-fresh at 522ms, and HTTPS batch at 31ms" loading="lazy" /&gt;&lt;/p&gt;
&lt;p&gt;At intercontinental distances (US ↔ Hong Kong, 150ms RTT), SSH PTY fresh takes 2.6 seconds for 5 commands. SSH exec fresh takes 2.4 seconds. HTTPS batch does it in 151ms:&lt;/p&gt;
&lt;p&gt;&lt;img src="https://blog2.network-notes.com/img/2026/cli-over-https-bench-intercontinental.5a06f3614b9f61e6c31b509722e421529c638a65a69e5f8545afcafdfff06c33.svg" alt="Bar chart at 150ms RTT showing SSH PTY-fresh at 2,565ms, exec-fresh at 2,412ms, and HTTPS batch at 151ms" loading="lazy" /&gt;&lt;/p&gt;
&lt;h3 id="exec-mode-vs-ptyshell-mode"&gt;Exec Mode vs PTY/Shell Mode&lt;/h3&gt;
&lt;p&gt;The PTY overhead comes from session preparation (&lt;code&gt;terminal length 0&lt;/code&gt;, &lt;code&gt;terminal width 511&lt;/code&gt;) and per-command echo verification. At higher latencies, this adds up:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Profile&lt;/th&gt;
&lt;th&gt;RTT&lt;/th&gt;
&lt;th&gt;SSH exec fresh&lt;/th&gt;
&lt;th&gt;SSH PTY fresh&lt;/th&gt;
&lt;th&gt;PTY overhead&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;local&lt;/td&gt;
&lt;td&gt;0ms&lt;/td&gt;
&lt;td&gt;3.9ms&lt;/td&gt;
&lt;td&gt;4.3ms&lt;/td&gt;
&lt;td&gt;+0.4ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;campus&lt;/td&gt;
&lt;td&gt;2ms&lt;/td&gt;
&lt;td&gt;40ms&lt;/td&gt;
&lt;td&gt;42ms&lt;/td&gt;
&lt;td&gt;+2ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;regional&lt;/td&gt;
&lt;td&gt;30ms&lt;/td&gt;
&lt;td&gt;494ms&lt;/td&gt;
&lt;td&gt;522ms&lt;/td&gt;
&lt;td&gt;+28ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;continental&lt;/td&gt;
&lt;td&gt;70ms&lt;/td&gt;
&lt;td&gt;1,144ms&lt;/td&gt;
&lt;td&gt;1,213ms&lt;/td&gt;
&lt;td&gt;+69ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;intercontinental&lt;/td&gt;
&lt;td&gt;150ms&lt;/td&gt;
&lt;td&gt;2,412ms&lt;/td&gt;
&lt;td&gt;2,565ms&lt;/td&gt;
&lt;td&gt;+153ms&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;The PTY overhead scales linearly with RTT because the session prep commands add roughly one extra round trip of overhead before the first real command runs. At 150ms RTT, that&amp;rsquo;s ~150ms of pure protocol overhead. And this is the best case. Real devices add processing time, ANSI escape codes, and prompt detection regex that the emulator doesn&amp;rsquo;t capture.&lt;/p&gt;
&lt;h3 id="speedup-vs-ssh-pty-fresh-what-most-tools-actually-use"&gt;Speedup vs SSH PTY fresh (what most tools actually use)&lt;/h3&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Profile&lt;/th&gt;
&lt;th&gt;RTT&lt;/th&gt;
&lt;th&gt;SSH exec fresh&lt;/th&gt;
&lt;th&gt;SSH reuse&lt;/th&gt;
&lt;th&gt;HTTPS keep-alive&lt;/th&gt;
&lt;th&gt;HTTPS batch&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;local&lt;/td&gt;
&lt;td&gt;0ms&lt;/td&gt;
&lt;td&gt;1.1x&lt;/td&gt;
&lt;td&gt;3.9x&lt;/td&gt;
&lt;td&gt;7.2x&lt;/td&gt;
&lt;td&gt;19.1x&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;campus&lt;/td&gt;
&lt;td&gt;2ms&lt;/td&gt;
&lt;td&gt;1.1x&lt;/td&gt;
&lt;td&gt;1.8x&lt;/td&gt;
&lt;td&gt;3.3x&lt;/td&gt;
&lt;td&gt;16.3x&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;regional&lt;/td&gt;
&lt;td&gt;30ms&lt;/td&gt;
&lt;td&gt;1.1x&lt;/td&gt;
&lt;td&gt;1.7x&lt;/td&gt;
&lt;td&gt;3.3x&lt;/td&gt;
&lt;td&gt;16.8x&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;continental&lt;/td&gt;
&lt;td&gt;70ms&lt;/td&gt;
&lt;td&gt;1.1x&lt;/td&gt;
&lt;td&gt;1.7x&lt;/td&gt;
&lt;td&gt;3.4x&lt;/td&gt;
&lt;td&gt;16.3x&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;intercontinental&lt;/td&gt;
&lt;td&gt;150ms&lt;/td&gt;
&lt;td&gt;1.1x&lt;/td&gt;
&lt;td&gt;1.7x&lt;/td&gt;
&lt;td&gt;3.4x&lt;/td&gt;
&lt;td&gt;17.0x&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;h2 id="what-the-numbers-say"&gt;What the Numbers Say&lt;/h2&gt;
&lt;p&gt;All results are from 20 iterations per profile. Variance was low, at regional (30ms), SSH exec fresh-conn p50 was 492ms with p95 at 508ms.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;At zero latency, SSH exec wins.&lt;/strong&gt; When there&amp;rsquo;s no network delay, TLS handshake overhead dominates. SSH exec fresh-conn takes 3.9ms; HTTPS fresh-conn takes 12.0ms. But PTY mode is already slower at 4.3ms due to session prep overhead. The reuse modes tell a different story: SSH exec reuse (1.1ms) and HTTPS keep-alive (0.6ms) are both sub-millisecond. Once the handshake is amortized, both protocols are fast.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Most automation tools don&amp;rsquo;t use exec mode.&lt;/strong&gt; As covered &lt;a href="https://blog2.network-notes.com/posts/2026/cli-over-https-2/#ssh-ptyshell-modes"&gt;above&lt;/a&gt;, they use PTY/shell mode for prompt detection, pagination control, and mode transitions. The PTY numbers are what your automation actually experiences.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;SSH reuse helps, but not enough.&lt;/strong&gt; Sharing one SSH connection (the ControlMaster pattern) eliminates the handshake cost, but each command still requires its own round trips. The improvement is consistent at ~1.7x. Real, but modest.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;HTTPS keep-alive is ~3.4x faster at any real latency.&lt;/strong&gt; Every HTTP client library does connection pooling by default. You don&amp;rsquo;t have to configure anything special. Just reuse the &lt;code&gt;http.Client&lt;/code&gt;. At 30ms RTT, that&amp;rsquo;s 158ms vs 522ms (PTY fresh).&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;HTTPS batch is ~17x faster.&lt;/strong&gt; Batching all commands into a single HTTP request eliminates per-command round trips entirely. The entire exchange costs one round trip regardless of command count. At 150ms RTT, that&amp;rsquo;s 151ms vs 2,565ms (PTY fresh). Unlike keep-alive (which still pays one round trip per command), batch mode pays a fixed cost regardless of command count.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;The advantage grows with command count, for per-command modes.&lt;/strong&gt; At 30ms RTT with 50 commands, SSH exec fresh-conn takes 3,253ms. SSH PTY fresh takes 1,912ms (PTY avoids per-command channel overhead but pays per-command echo verification). HTTPS keep-alive takes 1,548ms. But HTTPS batch takes just 33ms. A ~99x improvement over exec fresh and ~58x over PTY fresh. SSH batch-exec shows the same flat scaling (~250ms regardless of command count), confirming this is a property of batching, not the transport.&lt;/p&gt;
&lt;p&gt;&lt;img src="https://blog2.network-notes.com/img/2026/cli-over-https-command-scaling.7c056e6f62149c833ff6d10a41480272480f71fb76eaa5acb3ddc1d8a7df97dc.svg" alt="Time vs command count at 30ms RTT: batch modes stay flat while per-command modes scale linearly" loading="lazy" /&gt;&lt;/p&gt;
&lt;h2 id="what-this-means-at-scale"&gt;What This Means at Scale&lt;/h2&gt;
&lt;p&gt;If you&amp;rsquo;re managing 100 devices serially (worst case, no concurrency), using PTY mode (what Netmiko/Ansible actually do):&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Profile&lt;/th&gt;
&lt;th&gt;RTT&lt;/th&gt;
&lt;th&gt;SSH PTY fresh (total)&lt;/th&gt;
&lt;th&gt;HTTPS batch (total)&lt;/th&gt;
&lt;th&gt;Time saved&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;regional&lt;/td&gt;
&lt;td&gt;30ms&lt;/td&gt;
&lt;td&gt;52s&lt;/td&gt;
&lt;td&gt;3.1s&lt;/td&gt;
&lt;td&gt;49s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;continental&lt;/td&gt;
&lt;td&gt;70ms&lt;/td&gt;
&lt;td&gt;121s (2.0 min)&lt;/td&gt;
&lt;td&gt;7.4s&lt;/td&gt;
&lt;td&gt;114s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;intercontinental&lt;/td&gt;
&lt;td&gt;150ms&lt;/td&gt;
&lt;td&gt;257s (4.3 min)&lt;/td&gt;
&lt;td&gt;15s&lt;/td&gt;
&lt;td&gt;242s (4.0 min)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;Concurrency shrinks the wall time, but the per-device cost stays the same. At 150ms RTT with 10 concurrent workers against 1,000 devices, SSH PTY takes ~4.3 minutes of wall time. HTTPS batch takes ~15 seconds.&lt;/p&gt;
&lt;h2 id="limitations"&gt;Limitations&lt;/h2&gt;
&lt;p&gt;This benchmark measures transport overhead, not device processing time. Real network devices add their own latency to command execution: parsing the command, generating output, writing to the terminal. That cost is the same regardless of transport, so it doesn&amp;rsquo;t affect the relative comparison.&lt;/p&gt;
&lt;p&gt;The HTTPS server uses a self-signed certificate with no session resumption. TLS 1.3 0-RTT resumption would make the HTTPS numbers even better on repeated connections, but I didn&amp;rsquo;t implement it because most device management scenarios don&amp;rsquo;t maintain long-lived TLS sessions.&lt;/p&gt;
&lt;p&gt;A &lt;code&gt;-userspace&lt;/code&gt; flag is available as a fallback for environments where root isn&amp;rsquo;t available, but it under-counts SSH round trips due to write coalescing in Go&amp;rsquo;s &lt;code&gt;crypto/ssh&lt;/code&gt;. The published numbers all use &lt;code&gt;tc netem&lt;/code&gt;.&lt;/p&gt;
&lt;h2 id="whats-next"&gt;What&amp;rsquo;s Next&lt;/h2&gt;
&lt;p&gt;In Part 3, I&amp;rsquo;ll look at what happens when you can&amp;rsquo;t change the device: the proxy pattern. Move SSH to the edge, talk HTTPS over the WAN, and capture most of the improvement without touching a single device config.&lt;/p&gt;
&lt;p&gt;The &lt;a href="https://github.com/lykinsbd/clibench"&gt;benchmark code&lt;/a&gt; already supports proxy mode. Try it yourself and see what your numbers look like.&lt;/p&gt;</description></item><item><title>CLI Over HTTPS Part 1: The Protocol Tax</title><link>https://blog2.network-notes.com/posts/2026/cli-over-https-1/</link><pubDate>Tue, 28 Apr 2026 09:00:00 +0000</pubDate><author>brett@network-notes.com (Brett Lykins)</author><dc:creator>Brett Lykins</dc:creator><guid>https://blog2.network-notes.com/posts/2026/cli-over-https-1/</guid><description>&lt;p&gt;During my six years at Rackspace, we spent a lot of time thinking about how to interact with network devices faster.
We had tens of thousands of them; firewalls, load balancers, switches, routers and more, all spread across multiple data centers on four continents.&lt;/p&gt;
&lt;p&gt;In the early days, managing devices across these data centers meant running shell scripts, Expect, or Perl from local machines and centralized bastions over the WAN.
It was operationally painful.
SSH connections to devices on other continents were slow enough that teams scheduled automation runs around maintenance windows not just because of the change itself, but because of how long it took to deliver the changes.&lt;/p&gt;
&lt;p&gt;An Erlang-based platform solved this by co-locating the SSH connections with the devices.
They ran inside each data center, talking SSH to devices over local links where the protocol overhead was negligible.
Phil Toland &lt;a href="https://www.infoq.com/presentations/Erlang-Ruby-Rackspace/"&gt;presented the Erlang architecture at Erlang Factory 2012&lt;/a&gt;, detailing Erlang and Ruby managing backups and automation for 20,000+ network devices across 8 data centers.
My team later supplemented it with a Go microservices architecture to provide API-driven access to device CLIs.
Both systems were effective not just because of language capabilities; crucially they were fast because SSH stayed local.&lt;/p&gt;
&lt;p&gt;But, even with co-located endpoints, the Cisco ASA fleet was a special case which tested our capabilities due to the extreme size of some of the Access Lists.
That&amp;rsquo;s when someone discovered that the ASA has an HTTP interface we could use.
Not the ineffective ASA Java-based REST API, but an actual CLI-over-HTTPS endpoint used by the ASDM client.
There was a URL on the device where you could send the same commands you&amp;rsquo;d type into an SSH session, but over an HTTPS request.
We tried it as an experiment, and it was remarkably faster.
What started as a curiosity became a production lifesaver for our ASA fleet.&lt;/p&gt;
&lt;p&gt;I&amp;rsquo;ve been thinking about that experience ever since, and I finally decided to quantify it properly.
In this series of posts, I will quantify the performance difference between SSH and HTTPS as CLI transports and explain why the gap exists.&lt;/p&gt;
&lt;h2 id="the-ssh-tax"&gt;The SSH Tax&lt;/h2&gt;
&lt;blockquote&gt;
&lt;p&gt;For a deeper look at SSH&amp;rsquo;s protocol layers, channel types, and how NETCONF and RESTCONF fit in, see &lt;a href="https://blog2.network-notes.com/posts/2026/ssh-under-the-hood/"&gt;What Actually Happens When You SSH Into a Router&lt;/a&gt;.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;When your automation tool opens an SSH connection to a network device, here&amp;rsquo;s what actually happens on the wire before a single byte of command output comes back.
(Throughout this series, &amp;ldquo;RT&amp;rdquo; means a round trip: one message out, one message back. &amp;ldquo;RTT&amp;rdquo; is the round-trip time in milliseconds for a given network path.)&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;1. TCP three-way handshake.&lt;/strong&gt; SYN, SYN-ACK, ACK. One round trip. (The ACK can piggyback on the first data segment, but the connection isn&amp;rsquo;t usable until the handshake completes.)&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;2. Protocol version exchange.&lt;/strong&gt; Client and server each send an identification string (&lt;code&gt;SSH-2.0-OpenSSH_9.6&lt;/code&gt;). Another round trip.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;3. Key exchange.&lt;/strong&gt; The client and server negotiate algorithms (encryption, MAC, compression) via KEXINIT, then perform a Diffie-Hellman key exchange. This takes 1-3 round trips depending on whether the KEXINIT messages cross in flight and which DH group is negotiated.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;4. Service request.&lt;/strong&gt; The client requests the &lt;code&gt;ssh-userauth&lt;/code&gt; service. One round trip.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;5. User authentication.&lt;/strong&gt; Password or public key auth. 1-3 round trips depending on how many methods the server probes (GSSAPI, publickey, then password, each one a round trip).&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;6. Channel open.&lt;/strong&gt; SSH multiplexes channels over a single connection. Opening a session channel is another round trip.&lt;/p&gt;
&lt;p&gt;That&amp;rsquo;s &lt;strong&gt;6-10 round trips&lt;/strong&gt; just to get an authenticated channel. If you&amp;rsquo;re running a single exec-style command, add one more round trip for the request/response and you&amp;rsquo;re done.&lt;/p&gt;
&lt;p&gt;But automation tools don&amp;rsquo;t use exec mode. Netmiko, Ansible, and Scrapli open a PTY/shell channel, which adds:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;7. PTY request.&lt;/strong&gt; Ask the server for a pseudo-terminal. One round trip.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;8. Shell request.&lt;/strong&gt; Start an interactive shell on that PTY. One round trip.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;9. Session prep.&lt;/strong&gt; Send &lt;code&gt;terminal length 0&lt;/code&gt;, &lt;code&gt;terminal width 511&lt;/code&gt;, and wait for each prompt. Two to three more round trips.&lt;/p&gt;
&lt;p&gt;Add it up: &lt;strong&gt;10-15 round trips&lt;/strong&gt; before you see the output of &lt;code&gt;show version&lt;/code&gt;, with most real-world automation sessions landing at 10-12. The &lt;a href="https://github.com/francoismichel/ssh3"&gt;SSH3 project&lt;/a&gt; cites similar overhead in their motivation for building SSH over HTTP/3.&lt;/p&gt;
&lt;p&gt;SSH multiplexing (ControlMaster) amortizes the connection setup cost across sessions, but the first connection still pays the full overhead. It helps for repeated connections to the same device, but doesn&amp;rsquo;t solve the problem at scale across thousands of hosts.&lt;/p&gt;
&lt;p&gt;&lt;img src="https://blog2.network-notes.com/img/2026/ssh-connection-lifecycle.c495d377728639bd46d3e72a152d0060cd70f07f15913d5753113b9d35d43035.svg" alt="SSH connection lifecycle showing 10-12 round trips: TCP handshake, version exchange, key exchange, authentication, channel open, PTY request, shell request, session prep, and first command" loading="lazy" /&gt;&lt;/p&gt;
&lt;p&gt;At zero latency (localhost), nobody cares. At real-world distances, it compounds fast.&lt;/p&gt;
&lt;h2 id="what-this-costs-at-real-distances"&gt;What This Costs at Real Distances&lt;/h2&gt;
&lt;p&gt;&lt;a href="https://www.verizon.com/business/terms/latency/"&gt;Verizon Enterprise publishes monthly backbone latency measurements&lt;/a&gt; from their global network. Here&amp;rsquo;s what SSH connection setup costs at those measured round-trip times, assuming 10-15 round trips for a typical PTY/shell automation session:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Path&lt;/th&gt;
&lt;th&gt;Measured RTT&lt;/th&gt;
&lt;th&gt;SSH setup (10-15 RT)&lt;/th&gt;
&lt;th&gt;Source&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;US backbone (intra-region)&lt;/td&gt;
&lt;td&gt;30ms&lt;/td&gt;
&lt;td&gt;300-450ms&lt;/td&gt;
&lt;td&gt;Verizon Mar 2026: 29.9ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Transatlantic (NYC ↔ London)&lt;/td&gt;
&lt;td&gt;70ms&lt;/td&gt;
&lt;td&gt;700-1,050ms&lt;/td&gt;
&lt;td&gt;Verizon Mar 2026: 70.2ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;US ↔ Hong Kong&lt;/td&gt;
&lt;td&gt;146ms&lt;/td&gt;
&lt;td&gt;1,460-2,190ms&lt;/td&gt;
&lt;td&gt;Verizon Mar 2026: 145.5ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;US ↔ New Zealand&lt;/td&gt;
&lt;td&gt;174ms&lt;/td&gt;
&lt;td&gt;1,740-2,610ms&lt;/td&gt;
&lt;td&gt;Verizon Mar 2026: 174.2ms&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;That&amp;rsquo;s just connection setup.
You haven&amp;rsquo;t sent a command yet.
And if your automation opens a fresh SSH connection per device, or per task, which is &lt;a href="https://www.howtouselinux.com/post/the-hidden-ssh-setting-that-makes-ansible-playbooks-faster"&gt;Ansible&amp;rsquo;s default behavior without ControlMaster&lt;/a&gt;, you pay this cost repeatedly.&lt;/p&gt;
&lt;p&gt;A thousand devices at 30ms RTT without concurrency: 300-450 seconds of pure SSH handshake overhead. At 150ms RTT: 1,500-2,250 seconds. Concurrency reduces wall time, but every device still pays the full per-connection cost.&lt;/p&gt;
&lt;h2 id="the-screen-scraping-tax"&gt;The Screen-Scraping Tax&lt;/h2&gt;
&lt;p&gt;SSH&amp;rsquo;s round-trip overhead is only half the story. The other half is what happens after you connect.&lt;/p&gt;
&lt;p&gt;SSH gives you a byte stream. A pseudo-terminal. Your automation tool has to:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Detect the prompt&lt;/strong&gt; to know when command output is complete. Netmiko does this with regex matching on every chunk of bytes received, waiting for a pattern like &lt;code&gt;hostname#&lt;/code&gt;. If the output is large or arrives in small TCP segments, this means multiple read cycles.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Handle pagination.&lt;/strong&gt; &lt;code&gt;--More--&lt;/code&gt; prompts. Most tools send &lt;code&gt;terminal length 0&lt;/code&gt; first, which is another command round trip.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Navigate mode transitions.&lt;/strong&gt; &lt;code&gt;enable&lt;/code&gt;, &lt;code&gt;configure terminal&lt;/code&gt;, &lt;code&gt;interface GigabitEthernet0/0&lt;/code&gt;. Each one is a command, a prompt change, and a round trip.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Wait for command completion.&lt;/strong&gt; Netmiko&amp;rsquo;s default &lt;code&gt;read_timeout&lt;/code&gt; adds deliberate delays to avoid reading partial output.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;None of this is Netmiko&amp;rsquo;s fault. It&amp;rsquo;s doing the best it can with what SSH gives it: an unstructured byte stream with no framing, no content-length, no end-of-message delimiter. The tool has to infer when the device is done talking. Compare that to HTTPS (&lt;code&gt;Content-Length&lt;/code&gt;), NETCONF (&lt;code&gt;]]&amp;gt;]]&amp;gt;&lt;/code&gt; delimiter), or even SSH exec mode (exit codes). PTY is the only mode where the client has to guess when the response is complete.&lt;/p&gt;
&lt;h2 id="the-https-alternative"&gt;The HTTPS Alternative&lt;/h2&gt;
&lt;p&gt;Now consider what happens when you send the same command over HTTPS:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;1. TCP three-way handshake.&lt;/strong&gt; Same as SSH. One round trip.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;2. TLS 1.3 handshake.&lt;/strong&gt; 1 round trip. (TLS 1.2 was 2 round trips; &lt;a href="https://datatracker.ietf.org/doc/html/rfc8446"&gt;TLS 1.3 cut it in half&lt;/a&gt;. With session resumption, it&amp;rsquo;s 0 round trips.)&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;3. HTTP request/response.&lt;/strong&gt; Send the command, get the output. 1 round trip.&lt;/p&gt;
&lt;p&gt;Total: &lt;strong&gt;about 3 round trips&lt;/strong&gt; for a fresh connection. With connection reuse (HTTP keep-alive, which every HTTP client library does by default): &lt;strong&gt;1 round trip per command&lt;/strong&gt; after the first.&lt;/p&gt;
&lt;p&gt;&lt;img src="https://blog2.network-notes.com/img/2026/https-connection-lifecycle.6eccac0763c450bcb998f810f0e09b2a21b3807bb16af02efe6e84a79d3f3784.svg" alt="HTTPS connection lifecycle showing ~3 round trips: TCP handshake, TLS 1.3 handshake, HTTP request and response" loading="lazy" /&gt;&lt;/p&gt;
&lt;p&gt;Side by side, the difference is stark.
Here&amp;rsquo;s the same operation (5 commands at 30ms RTT) over both protocols:&lt;/p&gt;
&lt;p&gt;&lt;img src="https://blog2.network-notes.com/img/2026/ssh-vs-https-timeline.58a901d90c485d14c887a2dcde7437eb25d29c9a37727c56f65aba89df56a0e0.svg" alt="Side-by-side timeline: SSH PTY/shell takes 15 round trips and 522ms, HTTPS batch takes 3 round trips and 31ms" loading="lazy" /&gt;&lt;/p&gt;
&lt;p&gt;And here&amp;rsquo;s the part that changes the math entirely: &lt;strong&gt;you can batch commands&lt;/strong&gt;. The Cisco ASA HTTP interface accepts multiple commands in a single request, either slash-separated in the URL or newline-delimited in a POST body. Ten commands, one request, one response. That&amp;rsquo;s 1 round trip for 10 commands, vs SSH where each command requires its own channel-open and exec round trips.&lt;/p&gt;
&lt;p&gt;No prompt detection. No pagination handling. No mode transitions. The response body is the command output, with a proper HTTP &lt;code&gt;Content-Length&lt;/code&gt; header. Your client knows exactly when the response is complete.&lt;/p&gt;
&lt;h2 id="prior-art-the-cisco-asa-http-interface"&gt;Prior Art: The Cisco ASA HTTP Interface&lt;/h2&gt;
&lt;p&gt;This isn&amp;rsquo;t a theoretical proposal. Cisco has shipped an HTTP-based CLI interface on the ASA for years. Their &lt;a href="https://www.cisco.com/c/en/us/td/docs/security/asa/misc/http-interface/asa-http-interface.html"&gt;own documentation&lt;/a&gt; opens with this:&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;One way to interface with most network appliances including ASAs is via CLI. An automated tool could Telnet or SSH into a device, authenticate and execute commands, one at a time. This method has a number of drawbacks, however. The tool must maintain the state of the Telnet and SSH connection, and if that connection is broken, the login process has to be repeated. Using CLI, it is only possible to send one command at a time, so administering many firewalls would be time consuming, &lt;strong&gt;especially when the firewalls are some latency away from the management station&lt;/strong&gt;.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;Cisco identified the exact problem and shipped a solution. The interface is straightforward:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;div class="chroma"&gt;
&lt;table class="lntable"&gt;&lt;tr&gt;&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code&gt;&lt;span class="lnt"&gt;1
&lt;/span&gt;&lt;span class="lnt"&gt;2
&lt;/span&gt;&lt;span class="lnt"&gt;3
&lt;/span&gt;&lt;span class="lnt"&gt;4
&lt;/span&gt;&lt;span class="lnt"&gt;5
&lt;/span&gt;&lt;span class="lnt"&gt;6
&lt;/span&gt;&lt;span class="lnt"&gt;7
&lt;/span&gt;&lt;span class="lnt"&gt;8
&lt;/span&gt;&lt;span class="lnt"&gt;9
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;
&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-bash" data-lang="bash"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;# Single command&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;curl -sk -u admin:admin https://asa.example.com/admin/exec/show+version
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;# Multiple commands in one request&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;curl -sk -u admin:admin https://asa.example.com/admin/exec/show+version/show+ip+interface+brief
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;# Bulk config push&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;curl -sk -u admin:admin -X POST --data-binary @config.txt &lt;span class="se"&gt;\
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; https://asa.example.com/admin/config
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;
&lt;/div&gt;
&lt;/div&gt;&lt;p&gt;Basic auth over TLS. URL-encoded commands in the path. Newline-delimited commands in a POST body. No SDK, no client library, no special protocol. Just HTTPS.&lt;/p&gt;
&lt;p&gt;Aaron Hackney, another principal engineer at Rackspace at the time who was dealing with the same fleet of ASAs, went deep on this interface in a &lt;a href="https://community.cisco.com/t5/security-blogs/script-an-asdm-session-part-i/bc-p/3663026"&gt;BRKSEC-2031 session at Cisco Live Orlando 2018&lt;/a&gt;.
The work involved reverse-engineering the ASDM client&amp;rsquo;s HTTP calls and building Python tooling to script against the same interface.
The two-part Cisco community blog series that followed is still one of the best references for anyone looking to automate ASAs over HTTPS instead of SSH.&lt;/p&gt;
&lt;h2 id="great-but-my-switches-dont-have-an-https-cli"&gt;&amp;ldquo;Great, but My Switches Don&amp;rsquo;t Have an HTTPS CLI&amp;rdquo;&lt;/h2&gt;
&lt;p&gt;The network automation community has spent a decade building tooling around SSH.
Netmiko, Paramiko, Ansible&amp;rsquo;s &lt;code&gt;network_cli&lt;/code&gt;, Nornir, NAPALM. All SSH-based for CLI interaction.
That investment is real and valuable. NETCONF and gNMI exist as alternatives, but they require device support for structured data models.
A different paradigm entirely. Many organizations have thousands of CLI commands, templates, and playbooks that work.
They don&amp;rsquo;t need a new data model.
They need a faster pipe.&lt;/p&gt;
&lt;p&gt;And the ASA&amp;rsquo;s HTTP interface is the exception, not the rule.
Most network platforms (IOS, NX-OS, EOS, Junos) don&amp;rsquo;t expose their CLI over HTTPS.
Realistically, that&amp;rsquo;s not going to change across the industry without a major shift in how vendors think about management plane interfaces.
I&amp;rsquo;m not holding my breath.&lt;/p&gt;
&lt;p&gt;But the protocol overhead problem doesn&amp;rsquo;t go away just because the devices don&amp;rsquo;t support the better transport natively.
So what do you do?&lt;/p&gt;
&lt;p&gt;You move the expensive SSH transactions to the edge.
Put a lightweight proxy, an API gateway, a microservice, whatever you want to call it, close to the devices it manages.
That proxy talks SSH to the devices over a low-latency local network where the round-trip overhead is negligible.
Your automation platform talks HTTPS to the proxy over the WAN, where the round-trip savings actually matter.&lt;/p&gt;
&lt;p&gt;The pattern looks like this: your centralized automation (Ansible, Nornir, custom tooling) sends an HTTPS request to a proxy co-located with the devices.
The proxy opens an SSH session to the device on a 1-2ms local link, executes the commands, and returns the output in the HTTP response.
Your automation never touches SSH directly.
It gets the speed of HTTPS over the WAN and the compatibility of SSH on the last hop.&lt;/p&gt;
&lt;p&gt;&lt;img src="https://blog2.network-notes.com/img/2026/cli-over-https-proxy-concept.ce050a5b98dc507728b570b47f6e8daadd209ff85ebba3533b93cf6be936e078.svg" alt="Edge proxy architecture: automation talks HTTPS over the WAN to a co-located proxy, which talks SSH to devices over a 1-2ms local link" loading="lazy" /&gt;&lt;/p&gt;
&lt;p&gt;This isn&amp;rsquo;t hypothetical.
It&amp;rsquo;s the architecture behind tools like my &lt;a href="https://github.com/lykinsbd/naas"&gt;NAAS (Netmiko as a Service)&lt;/a&gt; application, which wraps Netmiko&amp;rsquo;s SSH sessions behind a REST API.
Deploy a NAAS instance in each region or data center, and your automation talks HTTP to the nearest one.
The SSH overhead stays local.
The WAN traffic is pure HTTPS.&lt;/p&gt;
&lt;p&gt;In &lt;a href="https://blog2.network-notes.com/posts/2026/cli-over-https-2/"&gt;Part 2&lt;/a&gt;, I&amp;rsquo;ll detail a dual-protocol device emulator I built in Go that serves the same commands over both SSH and HTTPS, and a benchmark client that measures the difference at realistic latencies.
The &lt;a href="https://github.com/lykinsbd/clibench"&gt;code is already public&lt;/a&gt; if you want to run ahead.&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;My take:&lt;/strong&gt; SSH is a fine protocol for interactive terminal sessions.
It&amp;rsquo;s a poor protocol for automation at scale.
The round-trip overhead is baked into the protocol design, and no amount of connection pooling or multiplexing fully eliminates it.
HTTPS with TLS 1.3 is a strictly better transport for the &amp;ldquo;send command, get output&amp;rdquo; pattern that defines CLI automation.
The industry should be building toward it.&lt;/p&gt;
&lt;/blockquote&gt;</description></item></channel></rss>