ssh sidecar: default to multiplexing, as a default and not an override
A target whose ~/.ssh/config entry never mentioned ControlMaster got no
multiplexing from the sidecar (only ControlPath was supplied), so every ssh call
opened a fresh TCP connection. On 2026-08-25 that produced ~12 connections to
one host in 15 min and a fail2ban block that looked like an outage — the tell
being that HTTPS to the same estate stayed healthy.
The correctness of this depends entirely on WHERE the block goes. ssh_config is
first-value-wins:
ControlPath before the Include -> override (the user's value points at
read-only ~/.ssh and cannot work in the container)
ControlMaster after the Include -> default (an explicit per-host
'ControlMaster no' must keep winning)
Force what is broken, default what is merely absent. The first draft put both in
the leading block and would have silently overridden an explicit 'no'.
Verified with ssh -G rather than from the man page, including the counterfactual:
under the shipped layout an explicit 'no' resolves to controlmaster false while a
silent host resolves to auto; under the rejected layout the 'no' host flips to
auto. So the test discriminates position, not presence. Plus a sandbox render of
the real script, bash -n, and shellcheck -S error (the v1.8.7 gate) clean.
Effect measured on 41 real host aliases: 22 silent entries gain auto+10m, 0
overridden. Note the fleet's one deliberate opt-out is written as absence plus a
comment ('# No ControlMaster — VPN means direct route'), which ssh cannot
distinguish from no opinion; that host now multiplexes, which its own comment
says is unnecessary rather than harmful.
Skill documents the sidecar-vs-~/.ssh trap (the failure misleads: read-only
ControlPath makes multiplexing look impossible rather than misconfigured) and
the stale-master recovery, ssh -O check / -O exit.
This commit is contained in:
@@ -11,6 +11,67 @@ Pre-v1.0.0 tags followed the pi npm version (`v{pi_version}[letter]`).
|
||||
|
||||
---
|
||||
|
||||
## Unreleased
|
||||
|
||||
### Added
|
||||
|
||||
- **The SSH sidecar now defaults to connection multiplexing, without overriding
|
||||
anyone's explicit choice.** `~/.ssh-local/config` already forced `ControlPath`
|
||||
into the writable sidecar dir, but nothing supplied `ControlMaster` for targets
|
||||
coming from the user's own bind-mounted `~/.ssh/config`. An entry that never
|
||||
mentioned it therefore opened a **fresh TCP connection per `ssh` call** — and
|
||||
an agent doing a dozen calls in a few minutes is exactly the traffic shape that
|
||||
trips fail2ban or a CGNAT flow-table cap. Observed 2026-08-25 on this fleet:
|
||||
~12 connections to one host in 15 minutes, after which port 22 stopped
|
||||
answering while HTTPS to the same estate stayed healthy in 0.44 s (that
|
||||
asymmetry is the tell for rate-limiting rather than an outage).
|
||||
|
||||
**The fix is where the block sits, not what it says.** `ssh_config` is
|
||||
first-value-wins, so position encodes intent, and the two settings need
|
||||
opposite treatment:
|
||||
|
||||
| Setting | Position | Meaning | Why |
|
||||
|---|---|---|---|
|
||||
| `ControlPath` | **before** `Include ~/.ssh/config` | override | the user's value points at read-only `~/.ssh`; it cannot work here, so it must lose |
|
||||
| `ControlMaster auto` + `ControlPersist 10m` | **after** the `Include` | default | an explicit per-host `ControlMaster no` must keep winning; we only supply an opinion where the user expressed none |
|
||||
|
||||
*Force what is broken, default what is merely absent.* The first draft of this
|
||||
put both in the leading block, which would have silently overridden an explicit
|
||||
`ControlMaster no` — the counterfactual is in the test below.
|
||||
|
||||
Verified with `ssh -G` (the resolved-config oracle) rather than by reading the
|
||||
man page, against a fixture with one host set to `no`, one silent, one set to
|
||||
`auto`: the explicit `no` resolves to `controlmaster false` **and** still gets
|
||||
the writable `ControlPath`, the silent host resolves to `auto`, and the same
|
||||
fixture under the rejected layout flips the `no` host to `auto` — so the test
|
||||
discriminates the *position*, not merely the presence of the block. Then
|
||||
end-to-end: the real script rendered in a sandbox `HOME`, block last, `bash -n`
|
||||
clean, `shellcheck -S error` clean (the gate added in v1.8.7).
|
||||
|
||||
Measured effect on the author's own config (41 host aliases): **22 were silent
|
||||
about `ControlMaster` and gain `auto` + 10 m persist; 0 are overridden**, since
|
||||
the fleet contains no explicit `no`. Worth noting *how* the one deliberate
|
||||
exception is written — `proxmox002-vpn` carries `# No ControlMaster — VPN means
|
||||
direct route, no CGNAT flow cap`, i.e. the intent is expressed as **absence
|
||||
plus a comment**, which `ssh` cannot distinguish from "no opinion". That host
|
||||
does now get multiplexing; its comment says multiplexing is *unnecessary*
|
||||
there, not harmful. Anything that must stay unmultiplexed needs a literal
|
||||
`ControlMaster no`.
|
||||
|
||||
Why the ordering matters beyond this one config: `~/.ssh/config` is
|
||||
**per-machine**, differs across the fleet, and future machines' versions do not
|
||||
exist yet to be audited. A default-not-override design is correct without
|
||||
needing to inspect any of them.
|
||||
|
||||
`ControlPersist` is deliberately short (10 m idle, and each new session resets
|
||||
the idle timer — long enough to collapse an agent's burst, short enough that an
|
||||
abandoned socket ages out). A per-host entry that sets its own value keeps it:
|
||||
hosts already specifying `ControlPersist 4h` still resolve to 4 h. The known
|
||||
cost of multiplexing is the **stale master** — socket present, daemon gone,
|
||||
after a suspend or network change — which makes every later `ssh` to that host
|
||||
hang; recovery is `ssh -F ~/.ssh-local/config -O exit <host>`, now documented
|
||||
in the `pi-devbox-environment` skill along with `-O check`.
|
||||
|
||||
## v1.8.7 — 2026-08-25
|
||||
|
||||
Patch release, and the fastest turnaround in the series (~9 h after v1.8.6) for
|
||||
|
||||
Reference in New Issue
Block a user