From a0abacaafb4148cfb7ff0fa46449fc2ea8489783 Mon Sep 17 00:00:00 2001 From: Joakim Persson Date: Thu, 18 Jun 2026 21:59:18 +0200 Subject: [PATCH] fix(ssh): survive read-only ~/.ssh ControlPath; render sidecar on all host OSes Coordinated with the pi-extensions ssh-controlmaster fix (picked up at build via PI_EXTENSIONS_REF=main), this makes `pi --ssh ` and `dssh`/`dscp` robust to a user ~/.ssh/config whose per-host ControlPath points under the read-only ~/.ssh bind-mount (e.g. `ControlPath ~/.ssh/cm/%r@%h:%p`). A system default can never override a user's per-host value, so the fix lives in two layers. - setup-lan-access.sh: always render the writable ~/.ssh-local/config sidecar (Host * ControlPath redirect into ~/.ssh-local/cm + Include ~/.ssh/config) on EVERY host OS. Previously the script exited early (no-op) on native Linux, leaving dssh/dscp broken when ~/.ssh was read-only there too. The host-jump block, its key generation, and the authorize hints stay gated on VM-backed detection / DEVBOX_LAN_ACCESS=jump (new NEED_JUMP flag). - Dockerfile.base: document that the /etc/ssh drop-in default cannot override a user per-host ControlPath; cross-ref the two handling layers. - entrypoint-user.sh: correct the now-stale "no-op on native Linux" comment. - README.md / DOCKER_HUB.md: document read-only-~/.ssh ControlPath handling. CHANGELOG: v1.1.5 (Fixed + Changed + pi 0.79.6 -> 0.79.7 auto-resolved bump). --- CHANGELOG.md | 51 ++++++++ DOCKER_HUB.md | 2 +- Dockerfile.base | 9 ++ README.md | 20 +++ entrypoint-user.sh | 16 ++- .../local/lib/pi-devbox/setup-lan-access.sh | 114 +++++++++++------- 6 files changed, 161 insertions(+), 51 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 287538e..aa529ac 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,57 @@ Pre-v1.0.0 tags followed the pi npm version (`v{pi_version}[letter]`). ## Unreleased +## v1.1.5 — 2026-06-18 + +Patch release: SSH ControlMaster read-only-socket fix + pi `0.79.6` → `0.79.7` +(auto-resolved at build). The `pi-extensions` ref is auto-resolved to `main` +HEAD at build, so the `ssh-controlmaster` fix below lands automatically. + +### Fixed + +- **`pi --ssh ` no longer fails with "Read-only file system" when the + user's `~/.ssh/config` sets a per-host `ControlPath` under the read-only + `~/.ssh` mount** (e.g. the common CGNAT idiom `ControlPath ~/.ssh/cm/%r@%h:%p`). + Root cause: SSH precedence means a user's per-host `ControlPath` always wins + over the baked `/etc/ssh/ssh_config.d` default, so the master socket tried to + bind under the RO `~/.ssh` and `ssh … pwd` exited 255 ("Could not resolve + remote pwd"). The `ssh-controlmaster` extension (pulled from `pi-extensions` + `main` via `PI_EXTENSIONS_REF`) now (a) resolves the remote pwd with a direct + connection (`-o ControlPath=none -o ControlMaster=no`), and (b) tests whether + the system `ControlPath` dir is actually writable — falling back to its own + `/tmp` master (whose command-line `-o ControlPath` overrides the user's path) + when it is not. OS-agnostic and independent of whether the user uses + ControlMaster, so the majority of configs (no ControlMaster at all) are + unaffected. + +### Changed + +- **`setup-lan-access.sh` now renders the writable SSH sidecar + (`~/.ssh-local/config`) on every host OS, not just VM-backed ones.** + Previously the whole script no-oped on native Linux, so a Linux host that + also bind-mounts `~/.ssh` read-only got no `ControlPath` redirect. The + `ControlPath` redirect + `Include ~/.ssh/config` (and `dssh`/`dscp` usability) + now work on Linux too; only the host-jump block (`Host host mac`), its key + generation, and the authorize hints remain gated on VM-backed detection + (`DEVBOX_LAN_ACCESS=auto`) or `=jump`. + +### Bumped: pi 0.79.6 → 0.79.7 + +Notable upstream changes (from [pi releases](https://github.com/earendil-works/pi/releases/tag/v0.79.7)): + +- **Automatic theme mode** — `/settings` can choose separate light and dark + themes and follow terminal color-scheme changes (`/` is now reserved in + theme names for this). +- **Self-only `pi update` by default** — bare `pi update` updates pi only; + `pi update --all` updates pi and packages together. +- **Extension API helpers** — `CONFIG_DIR_NAME` exported so extensions resolve + project config paths without hardcoding `.pi`; edit-diff helpers + (`generateDiffString`, `generateUnifiedPatch`, `EditDiffResult`) exported. +- **Warp inline images** via Kitty graphics capability detection. +- Fixes: RPC unknown-command errors now include the request id (clients no + longer hang); `/model` autocomplete matches provider/model regardless of + token order; tree navigator horizontally pans deep entries. + ## v1.1.4 — 2026-06-17 Patch release: config and shell-quality fixes on a preserved volume. No pi diff --git a/DOCKER_HUB.md b/DOCKER_HUB.md index 5eb85ba..0e18fd0 100644 --- a/DOCKER_HUB.md +++ b/DOCKER_HUB.md @@ -97,7 +97,7 @@ The entrypoint deploys/registers all of these on first container start. Re-runni ### SSH and networking -- OpenSSH client with **ControlMaster auto** preconfigured on a writable socket path (`/tmp/sshcm/`). Mitigates ssh banner-exchange failures behind CGNAT-restricted residential ISPs (~4-flow caps). +- OpenSSH client with **ControlMaster auto** preconfigured on a writable socket path (`/tmp/sshcm/`). Mitigates ssh banner-exchange failures behind CGNAT-restricted residential ISPs (~4-flow caps). A read-only `~/.ssh` carrying a per-host `ControlPath` (common CGNAT configs) is handled too — redirected to a writable socket dir for both `pi --ssh` and `dssh`/`dscp`. - A **LAN-access helper** that auto-configures ssh jump-via-host on VM-backed hosts (OrbStack / Docker Desktop on macOS) so the container can reach the host's directly-attached LAN peers (`dssh ` alias; `DEVBOX_LAN_ACCESS` / `HOST_SSH_USER`). ## Versioning diff --git a/Dockerfile.base b/Dockerfile.base index 02ef39b..cb9e8ef 100644 --- a/Dockerfile.base +++ b/Dockerfile.base @@ -130,6 +130,15 @@ RUN printf '%s\n' \ # `Include /etc/ssh/ssh_config.d/*.conf` *before* the `Host *` block, # so user config can override these defaults if desired. # +# CAVEAT (and why it is handled elsewhere): a user per-host override that +# points ControlPath BACK under the read-only ~/.ssh (e.g. the common CGNAT +# idiom `ControlPath ~/.ssh/cm/%r@%h:%p`) re-introduces the unwritable-socket +# failure — a system drop-in here can never override a user's per-host value. +# For `pi --ssh`, the ssh-controlmaster extension handles this by detecting an +# unwritable system ControlPath and falling back to its own /tmp master; for +# `ssh -F ~/.ssh-local/config` (dssh/dscp), setup-lan-access.sh redirects +# ControlPath into the writable ~/.ssh-local. See CHANGELOG "Unreleased". +# # ControlPersist=10m means the master socket sticks around 10 min after # the last session closes, so consecutive ssh calls in a workflow reuse # the same TCP flow. Companion entrypoint-user.sh creates /tmp/sshcm diff --git a/README.md b/README.md index 86270dd..bfc2787 100644 --- a/README.md +++ b/README.md @@ -82,6 +82,9 @@ For Python REPLs and notebooks beyond the system interpreter, see the - A LAN-access helper that auto-configures ssh jump-via-host on VM-backed hosts (OrbStack / Docker Desktop on macOS) so the container can reach the host's directly-attached LAN peers. +- Read-only `~/.ssh` is handled transparently: a per-host `ControlPath` + under it (common CGNAT configs like `~/.ssh/cm/...`) is redirected to a + writable socket dir for both `pi --ssh` and `dssh`/`dscp`. ## Quickstart @@ -461,6 +464,23 @@ User-level overrides in `~/.ssh/config` win because Debian's `/etc/ssh/ssh_config` includes `/etc/ssh/ssh_config.d/*.conf` before the `Host *` block. +### Per-host `ControlPath` on a read-only `~/.ssh` + +`~/.ssh` is usually bind-mounted read-only, so a user `~/.ssh/config` that +points `ControlPath` back under it (e.g. the CGNAT idiom +`ControlPath ~/.ssh/cm/%r@%h:%p`) can't bind its master socket here — and a +system default can never override a user's per-host value. Two layers handle +this without editing the read-only config: + +- **`pi --ssh `** — the `ssh-controlmaster` extension detects an + unwritable system `ControlPath` and falls back to its own writable + `/tmp/pi-cm-.sock` master (its command-line `-o ControlPath` overrides + the user's path); the remote-`pwd` probe uses `-o ControlPath=none` so it + cannot fail on the read-only socket dir. +- **`ssh -F ~/.ssh-local/config` / `dssh` / `dscp`** — `setup-lan-access.sh` + redirects `ControlPath` into the writable `~/.ssh-local/cm` for every host + (the sidecar is rendered on all host OSes). + ## tmux and 0-indexed sessions The image installs `/etc/tmux.conf` with: diff --git a/entrypoint-user.sh b/entrypoint-user.sh index b2a1808..cd3622f 100755 --- a/entrypoint-user.sh +++ b/entrypoint-user.sh @@ -12,12 +12,16 @@ set -euo pipefail mkdir -p /tmp/sshcm chmod 700 /tmp/sshcm -# ── LAN access: generic host-OS-agnostic reachability helper ──────── -# On VM-backed hosts (macOS OrbStack / Docker Desktop) the container can't -# reach the host's directly-attached LAN peers by default; this generates a -# writable ~/.ssh-local/config that uses the host as an SSH jump. On native -# Linux (LAN reachable directly) it is a no-op. Controlled by DEVBOX_LAN_ACCESS -# (auto|jump|off) + HOST_SSH_USER. Always non-fatal. See the script header. +# ── LAN access + writable SSH sidecar: host-OS-agnostic helper ────── +# Generates the writable ~/.ssh-local/config on EVERY host OS: a `Host *` +# ControlPath redirect into ~/.ssh-local/cm (so `ssh -F` / dssh / dscp work +# even when ~/.ssh is bind-mounted read-only) plus `Include ~/.ssh/config`. On +# VM-backed hosts (macOS OrbStack / Docker Desktop) it ALSO adds an +# SSH-jump-via-host block so the container can reach the host's +# directly-attached LAN peers; on native Linux (LAN reachable directly) the +# jump block is omitted but the sidecar is still rendered. Controlled by +# DEVBOX_LAN_ACCESS (auto|jump|off) + HOST_SSH_USER. Always non-fatal. See the +# script header. if [ -r /usr/local/lib/pi-devbox/setup-lan-access.sh ]; then bash /usr/local/lib/pi-devbox/setup-lan-access.sh || true fi diff --git a/rootfs/usr/local/lib/pi-devbox/setup-lan-access.sh b/rootfs/usr/local/lib/pi-devbox/setup-lan-access.sh index 5061a9a..03d36db 100755 --- a/rootfs/usr/local/lib/pi-devbox/setup-lan-access.sh +++ b/rootfs/usr/local/lib/pi-devbox/setup-lan-access.sh @@ -14,7 +14,9 @@ # The one thing reachable from a container on every OS is the host itself # (host.docker.internal). So on VM-backed hosts we generate a writable SSH # config that reaches the host and lets the user ProxyJump onward to LAN -# peers the host can reach. On native Linux we do nothing. +# peers the host can reach. On native Linux we render the same writable +# config (for the ControlPath redirect + Include ~/.ssh/config) but emit no +# jump block, since LAN peers are reachable directly there. # # We ship the MECHANISM (a generic `host` jump alias + writable config), # never the POLICY: the user's specific target hosts live in their own @@ -30,7 +32,9 @@ # # CONTROLS (env) # DEVBOX_LAN_ACCESS = auto (default) | jump | off -# auto → set up the jump config only on VM-backed hosts; no-op on Linux. +# auto → set up the host jump only on VM-backed hosts. The writable +# sidecar config (ControlPath redirect + Include) is always +# rendered, on every OS. # jump → always set up (e.g. native Linux with extra_hosts host-gateway). # off → do nothing. # HOST_SSH_USER — the username to SSH into the host as. REQUIRED for the @@ -84,42 +88,72 @@ is_vm_backed() { getent hosts "$HOST_ALIAS_HOSTNAME" >/dev/null 2>&1 } -if [ "$MODE" = "auto" ] && ! is_vm_backed; then - # Native Linux host: LAN peers are reachable directly. Nothing to do. - exit 0 -fi - -# From here: MODE=jump, or MODE=auto on a VM-backed host. - -command -v ssh-keygen >/dev/null 2>&1 || exit 0 - +# ── Writable socket dir + sidecar (ALWAYS, every host OS) ───────────── +# The ControlPath redirect in the generated config needs a writable directory +# regardless of host OS or jump mode. ~/.ssh is typically read-only, so the +# master socket lives under the writable ~/.ssh-local. We create it and render +# the config UNCONDITIONALLY so the redirect (and `Include ~/.ssh/config`) works +# even on native Linux — where we set up no host jump but a read-only ~/.ssh +# would otherwise still break ControlMaster sockets. mkdir -p "${SSH_LOCAL}/cm" 2>/dev/null || true chmod 700 "${SSH_LOCAL}" "${SSH_LOCAL}/cm" 2>/dev/null || true -# ── Jump key (generated once; preserved across restarts) ────────────── +# ── Decide whether to set up the host jump ──────────────────────────── +# Jump = reach the container host (host.docker.internal) as an SSH ProxyJump +# onward to the host's LAN peers. Needed on VM-backed hosts (macOS / Docker +# Desktop) or when forced with DEVBOX_LAN_ACCESS=jump. On native Linux LAN +# peers are reachable directly, so NEED_JUMP=0 and we emit no jump block — but +# we still render the config for the ControlPath redirect + Include. +NEED_JUMP=0 +if [ "$MODE" = "jump" ] || { [ "$MODE" = "auto" ] && is_vm_backed; }; then + NEED_JUMP=1 +fi + +# ── Jump key (only when a jump is needed; generated once, preserved) ── # Persisted via a named volume on ~/.ssh-local (see compose), so a fresh key # is generated only on the very first start (or if the volume is wiped). When # we DO generate one it must be (re-)authorized on the host, so we flag it and # print a copy-paste authorize line below. KEY_JUST_GENERATED=0 -if [ ! -f "$KEY" ]; then - ssh-keygen -t ed25519 -N '' -C "devbox-jump@${HOSTNAME:-container}" -f "$KEY" >/dev/null 2>&1 || exit 0 - chmod 600 "$KEY" 2>/dev/null || true - KEY_JUST_GENERATED=1 +if [ "$NEED_JUMP" = "1" ] && command -v ssh-keygen >/dev/null 2>&1 && [ ! -f "$KEY" ]; then + if ssh-keygen -t ed25519 -N '' -C "devbox-jump@${HOSTNAME:-container}" -f "$KEY" >/dev/null 2>&1; then + chmod 600 "$KEY" 2>/dev/null || true + KEY_JUST_GENERATED=1 + fi fi # ── Render the writable config ──────────────────────────────────────── -USER_LINE="" -if [ -n "${HOST_SSH_USER:-}" ]; then - USER_LINE=" User ${HOST_SSH_USER}" -fi - -# Optional host-owned named-peer jump overrides (portable: lives on the host, -# not in the image). Included BEFORE ~/.ssh/config so its ProxyJump wins. -SSH_LAN_CONF="${HOME}/.config/devbox-shell/ssh-lan.conf" +# Jump-specific blocks (the host alias, host-owned peer overrides, and the +# optional RFC1918 catch-all) only make sense when a jump is set up; on native +# Linux they are all empty and only the ControlPath redirect + Include remain. +JUMP_BLOCK="" LAN_CONF_BLOCK="" -if [ -r "$SSH_LAN_CONF" ]; then - LAN_CONF_BLOCK=$(cat <<'EOF' +AUTOJUMP_BLOCK="" +if [ "$NEED_JUMP" = "1" ]; then + USER_LINE="" + if [ -n "${HOST_SSH_USER:-}" ]; then + USER_LINE=" User ${HOST_SSH_USER}" + fi + JUMP_BLOCK=$(cat <`. Public IPs are unmatched @@ -146,6 +179,7 @@ Host 10.* 192.168.* 172.16.* 172.17.* 172.18.* 172.19.* 172.20.* 172.21.* 172.22 ProxyJump host EOF ) + fi fi INCLUDE_BLOCK="" @@ -176,17 +210,7 @@ Host * UserKnownHostsFile ~/.ssh-local/known_hosts StrictHostKeyChecking accept-new ControlPath ~/.ssh-local/cm/%r@%h:%p - -# The container host (OrbStack / Docker Desktop). 'host' and 'mac' are aliases. -Host host mac - HostName ${HOST_ALIAS_HOSTNAME} -${USER_LINE} - IdentityFile ~/.ssh-local/devbox_jump_ed25519 - IdentitiesOnly yes - ControlMaster auto - ControlPath ~/.ssh-local/cm/%r@%h:%p - ControlPersist 4h - ServerAliveInterval 30 +${JUMP_BLOCK} ${LAN_CONF_BLOCK} ${AUTOJUMP_BLOCK} ${INCLUDE_BLOCK} @@ -199,6 +223,7 @@ chmod 600 "$CONFIG" 2>/dev/null || true # host won't recognize. With ~/.ssh-local persisted via a named volume, case # (b) fires only on first-ever start (or after the volume is reset) — so this # is normally a one-time, one-line step per machine, with no file to locate. +if [ "$NEED_JUMP" = "1" ]; then PUBKEY_TEXT="$(cat "${KEY}.pub" 2>/dev/null)" if [ -z "${HOST_SSH_USER:-}" ]; then cat <