feat(ssh-controlmaster): use ~/.ssh-local/config so pi --ssh can reach LAN peers

dssh reaches host-LAN peers from inside the devbox container because it runs
`ssh -F ~/.ssh-local/config` (which Includes the host-owned, bind-mounted
ssh-lan.conf carrying `ProxyJump host` entries). pi --ssh shelled out to plain
`ssh`/`ssh -G` against the default ~/.ssh/config, which has no jump, so it
could not reach peers the host can.

Thread `-F <config>` through every ssh call (ssh -G, pwd probe, master start
for both key and password paths, sshExec, bash exec, ssh -O exit), resolved
once at load by resolveSshConfigOpts():
  PI_SSH_CONFIG (leading ~ expanded, honored even if missing)
  else ~/.ssh-local/config if present
  else [] (no -F)

No hostnames are baked into the image — the LAN list lives only in the
host-owned, read-only-mounted ~/.config/devbox-shell/ssh-lan.conf. On the host
(native pi) ~/.ssh-local/config doesn't exist, so -F is omitted and behavior is
unchanged. Command-line -o options still win over -F, so own-master /tmp socket
and ControlMaster decisions are unaffected. Status/notify shows [config: <path>]
when a non-default config is used.

Verified from the container: the patched probe reaches an enrolled peer (pve ->
/root) where plain ssh times out. Reaching a new peer (e.g. alpserv-2) is now a
one-line host-side edit to ssh-lan.conf.

Docs: README + AGENTS.md updated.
This commit is contained in:
2026-06-20 22:40:57 +02:00
parent aaa7d906df
commit 8a47f2f3b4
3 changed files with 62 additions and 9 deletions
+41 -8
View File
@@ -28,7 +28,7 @@
*/
import { spawn } from "node:child_process";
import { constants as fsConstants } from "node:fs";
import { constants as fsConstants, existsSync } from "node:fs";
import { access, writeFile, unlink } from "node:fs/promises";
import { homedir, tmpdir } from "node:os";
import { dirname, join } from "node:path";
@@ -71,6 +71,37 @@ const BATCH_OPTS = ["-o", "BatchMode=yes"];
// Wall-clock cap for ssh calls made during session_start.
const STARTUP_TIMEOUT_MS = 15000;
/**
* If a non-default SSH config should be used, resolve it to ["-F", <path>].
*
* Why: inside the pi-devbox container, `setup-lan-access.sh` regenerates
* ~/.ssh-local/config on every start. That config carries the LAN-jump
* (`ProxyJump host`) overrides the host contributes via the bind-mounted,
* read-only ssh-lan.conf, plus a writable ControlPath. `dssh` reaches LAN peers
* because it runs `ssh -F ~/.ssh-local/config`; the default `ssh`/`ssh -G` does
* NOT, which is exactly why `pi --ssh <lan-host>` couldn't reach a peer the host
* can. We thread the same `-F` through every ssh call this extension makes.
*
* No hostnames are baked into the image — the LAN list lives only in the
* host-owned, read-only-mounted ssh-lan.conf. On the host (native pi)
* ~/.ssh-local/config does not exist, so this is a no-op and default ssh
* behavior is unchanged.
*
* Override: PI_SSH_CONFIG=/path/to/config (honored even when auto-detect would
* find nothing; a leading ~ is expanded). PI_SSH_CONFIG= (empty) = unset.
*/
function resolveSshConfigOpts(): { opts: string[]; path: string | null } {
const expand = (p: string) => p.replace(/^~(?=\/|$)/, homedir());
const raw = process.env.PI_SSH_CONFIG?.trim();
if (raw) {
const p = expand(raw);
return { opts: ["-F", p], path: p };
}
const sidecar = join(homedir(), ".ssh-local", "config");
return existsSync(sidecar) ? { opts: ["-F", sidecar], path: sidecar } : { opts: [], path: null };
}
const { opts: SSH_CONFIG_OPTS, path: SSH_CONFIG_PATH } = resolveSshConfigOpts();
function ownSocketPath(): string {
// Keep path short — macOS has a ~104-char Unix socket path limit
return join(tmpdir(), `pi-cm-${process.pid}.sock`);
@@ -134,7 +165,7 @@ async function startControlMasterWithPassword(
await new Promise<void>((resolve, reject) => {
const child = spawn(
"ssh",
["-fN", "-o", "ControlMaster=yes", "-o", `ControlPath=${socketPath}`, "-o", "ControlPersist=yes", ...CONNECT_OPTS, remote],
[...SSH_CONFIG_OPTS, "-fN", "-o", "ControlMaster=yes", "-o", `ControlPath=${socketPath}`, "-o", "ControlPersist=yes", ...CONNECT_OPTS, remote],
{
stdio: "ignore",
env: {
@@ -203,7 +234,7 @@ async function readSshConfig(remote: string): Promise<CmConfig> {
// Strip any user@ prefix for -G (ssh -G takes a hostname or alias, not user@host)
const host = remote.includes("@") ? remote.split("@")[1] : remote;
try {
const output = await run(["ssh", "-G", host]);
const output = await run(["ssh", ...SSH_CONFIG_OPTS, "-G", host]);
const get = (key: string): string => {
const m = output.match(new RegExp(`^${key}\\s+(.+)$`, "im"));
return m ? m[1].trim() : "";
@@ -273,7 +304,7 @@ function startControlMaster(remote: string, socketPath: string): Promise<void> {
// prompt (which would hang invisibly behind pi's TUI).
const child = spawn(
"ssh",
["-fN", "-o", "ControlMaster=yes", "-o", `ControlPath=${socketPath}`, "-o", "ControlPersist=yes", ...CONNECT_OPTS, ...BATCH_OPTS, remote],
[...SSH_CONFIG_OPTS, "-fN", "-o", "ControlMaster=yes", "-o", `ControlPath=${socketPath}`, "-o", "ControlPersist=yes", ...CONNECT_OPTS, ...BATCH_OPTS, remote],
{ stdio: "ignore" },
);
const timer = setTimeout(() => {
@@ -293,7 +324,7 @@ function stopControlMaster(remote: string, socketPath: string): Promise<void> {
return new Promise((resolve) => {
const child = spawn(
"ssh",
["-O", "exit", "-o", `ControlPath=${socketPath}`, remote],
[...SSH_CONFIG_OPTS, "-O", "exit", "-o", `ControlPath=${socketPath}`, remote],
{ stdio: "ignore" },
);
child.on("close", () => resolve()); // best-effort; ignore errors
@@ -311,7 +342,7 @@ function sshExec(remote: string, socketPath: string, command: string): Promise<B
return new Promise((resolve, reject) => {
const child = spawn(
"ssh",
["-o", "ControlMaster=no", "-o", `ControlPath=${socketPath}`, ...CONNECT_OPTS, remote, command],
[...SSH_CONFIG_OPTS, "-o", "ControlMaster=no", "-o", `ControlPath=${socketPath}`, ...CONNECT_OPTS, remote, command],
{ stdio: ["ignore", "pipe", "pipe"] },
);
const out: Buffer[] = [];
@@ -380,7 +411,7 @@ function createRemoteBashOps(
const cmd = `cd ${JSON.stringify(r(cwd))} && ${command}`;
const child = spawn(
"ssh",
["-o", "ControlMaster=no", "-o", `ControlPath=${socketPath}`, ...CONNECT_OPTS, remote, cmd],
[...SSH_CONFIG_OPTS, "-o", "ControlMaster=no", "-o", `ControlPath=${socketPath}`, ...CONNECT_OPTS, remote, cmd],
{ stdio: ["ignore", "pipe", "pipe"] },
);
let timedOut = false;
@@ -500,6 +531,7 @@ export default function (pi: ExtensionAPI) {
// from inside a container). BatchMode is added for key auth so ssh never
// waits silently on a /dev/tty prompt; it is omitted under --ssh-ask-pass.
const probeOpts = [
...SSH_CONFIG_OPTS,
"-o", "ControlPath=none",
"-o", "ControlMaster=no",
...CONNECT_OPTS,
@@ -552,8 +584,9 @@ export default function (pi: ExtensionAPI) {
state = { remote, remoteCwd, socketPath, ownsmaster };
const tag = ownsmaster ? "⚡ own master" : "⚡ system master";
const cfgNote = SSH_CONFIG_PATH ? ` [config: ${SSH_CONFIG_PATH}]` : "";
ctx.ui.setStatus("ssh", ctx.ui.theme.fg("accent", `SSH ${tag} ${remote}:${remoteCwd}`));
ctx.ui.notify(`SSH ready (${ownsmaster ? "own" : "system"} master) — ${remote}:${remoteCwd}`, "success");
ctx.ui.notify(`SSH ready (${ownsmaster ? "own" : "system"} master) — ${remote}:${remoteCwd}${cfgNote}`, "success");
});
pi.on("session_shutdown", async () => {