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:
@@ -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 () => {
|
||||
|
||||
Reference in New Issue
Block a user