diff --git a/CHANGELOG.md b/CHANGELOG.md index 9b3e8d3..f1eaf52 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,24 @@ Tags follow `v{opencode_version}[letter]` — bare tag for the first build on a ## Unreleased +### Base: SSH ControlMaster default on a writable socket path + +Devboxes typically mount `~/.ssh` from the host as **read-only** (security: keys remain readable but agents can't tamper with config / known_hosts / authorized_keys / plant a malicious ProxyCommand). OpenSSH's default `ControlPath` lands inside `~/.ssh/cm/`, which is unwritable on such mounts — so any attempt to use `ControlMaster auto` (or anything that wants to multiplex) fails with: + +``` +unix_listener: cannot bind to path /home/.../.ssh/cm/...: Read-only file system +kex_exchange_identification: Connection closed by remote host +``` + +The second line is downstream: when ControlMaster fails the ssh client falls back to a fresh TCP connection, and on residential CGNAT (most European ISPs) the per-(src,dst) concurrent-flow cap (~4) silently drops further SYNs once exceeded — manifesting as banner-exchange timeouts that look like a remote problem. + +- **`Dockerfile.base`** — new section right after the apt block bakes `/etc/ssh/ssh_config.d/00-devbox-controlmaster.conf` with `Host *` defaults: `ControlMaster auto`, `ControlPath /tmp/sshcm/%r@%h:%p`, `ControlPersist 10m`, plus `ServerAliveInterval 30` / `ServerAliveCountMax 6` for resilience to mid-stream NAT timeouts. `/tmp` is per-container and always writable, so the read-only `~/.ssh` mount is left untouched. Debian's stock `/etc/ssh/ssh_config` includes `ssh_config.d/*.conf` *before* its own `Host *` block, so user `~/.ssh/config` overrides still win. +- **`entrypoint-user.sh`** — creates `/tmp/sshcm` mode 700 on every container start. `/tmp` is per-container so the dir doesn't survive recreation; baking it into a Dockerfile layer would be wrong. Mode 700 is required — OpenSSH refuses to use a `ControlPath` directory others can write to. +- **`scripts/smoke-test.sh`** — two new assertions: (a) the conf file exists at the expected path; (b) `ssh -G example.invalid` reports a `controlpath` rooted at `/tmp/sshcm/`. The second catches the silent regression where something later in the SSH config chain shadows the bake-in. +- **No size/threshold impact:** the conf file is ~250 bytes. + +Downstream pi-devbox and any other variant inherits this on its next build against `base-latest`. Discovered while running a recon-shell from inside pi-devbox to a Proxmox node — fresh ssh hit banner timeout, debug output pointed at the read-only socket dir. + ### Base: gitleaks added; git-crypt confirmed already installed `gitleaks` is now baked into `Dockerfile.base` (Go-compiled binary fetched from GitHub releases, same `/releases/latest` redirect-resolution pattern as gosu/fzf/git-lfs/etc.). It pairs with `git-crypt`, which has been installed via apt all along but wasn't asserted by smoke or called out in user-facing docs. Several of the user's repos use both as part of their secret-management setup (gitleaks pre-commit hook + git-crypt for selectively-encrypted canonical config); having them in the devbox means `pi install`-style hooks fire correctly inside the container instead of warning that gitleaks is missing. diff --git a/Dockerfile.base b/Dockerfile.base index 2dd15fc..f16f6bd 100644 --- a/Dockerfile.base +++ b/Dockerfile.base @@ -71,6 +71,44 @@ RUN apt-get update && \ && apt-get clean \ && rm -rf /var/lib/apt/lists/* +# ── SSH client defaults: ControlMaster on a writable socket path ────── +# Why this exists: the devbox typically mounts ~/.ssh from the host as +# read-only (security: keys are readable, but agents can't tamper with +# config / known_hosts / authorized_keys / plant a malicious ProxyCommand). +# OpenSSH's default ControlPath is ~/.ssh/cm/... which is unwritable on +# such mounts, so any attempt to use ControlMaster fails. Symptoms: +# unix_listener: cannot bind to path /home/.../.ssh/cm/...: Read-only file system +# kex_exchange_identification: Connection closed by remote host +# The latter manifests downstream of CGNAT per-destination flow caps +# (~4 concurrent flows on most European residential ISPs) which silently +# drop further SYNs once exceeded — making fresh ssh attempts fail with +# banner-exchange timeouts that look like a remote problem. +# +# Fix: set a system-wide default ControlPath in /tmp (per-container, +# tmpfs-friendly, always writable) so multiplexing Just Works without +# touching the read-only ~/.ssh mount. Per-host overrides in user's +# ~/.ssh/config still win — Debian's default /etc/ssh/ssh_config has +# `Include /etc/ssh/ssh_config.d/*.conf` *before* the `Host *` block, +# so user config can override these defaults if desired. +# +# 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 +# (mode 700) on each container start. +RUN mkdir -p /etc/ssh/ssh_config.d && \ + printf '%s\n' \ + '# Devbox-baked default. See Dockerfile.base "SSH client defaults".' \ + '# Override per-host in ~/.ssh/config if the master socket location' \ + '# needs to differ.' \ + 'Host *' \ + ' ControlMaster auto' \ + ' ControlPath /tmp/sshcm/%r@%h:%p' \ + ' ControlPersist 10m' \ + ' ServerAliveInterval 30' \ + ' ServerAliveCountMax 6' \ + > /etc/ssh/ssh_config.d/00-devbox-controlmaster.conf && \ + chmod 644 /etc/ssh/ssh_config.d/00-devbox-controlmaster.conf + # ── Go-compiled tools (install from GitHub to avoid CVEs in Debian's old Go builds) # # Version policy for the binaries below: diff --git a/entrypoint-user.sh b/entrypoint-user.sh index 9be6ff4..c36bf0f 100644 --- a/entrypoint-user.sh +++ b/entrypoint-user.sh @@ -1,6 +1,17 @@ #!/usr/bin/env bash set -euo pipefail +# ── SSH ControlMaster socket dir ──────────────────────────────── +# Companion to /etc/ssh/ssh_config.d/00-devbox-controlmaster.conf in the +# base image — that file declares ControlPath=/tmp/sshcm/%r@%h:%p; this +# creates the directory with the right permissions on every container +# start. /tmp is per-container so the dir doesn't survive recreation; +# baking it into a Dockerfile layer would be wrong. +# Mode 700 is required — OpenSSH refuses to use a ControlPath dir that +# others can write to. +mkdir -p /tmp/sshcm +chmod 700 /tmp/sshcm + # ── Shell defaults: copy baked files from /etc/skel-devbox/ if absent # Respects host bind-mounts and user customizations — existing files # are never overwritten. To restore defaults: rm ~/.bash_aliases (or diff --git a/scripts/smoke-test.sh b/scripts/smoke-test.sh index 8cfb1d0..3924f71 100755 --- a/scripts/smoke-test.sh +++ b/scripts/smoke-test.sh @@ -131,6 +131,13 @@ run "gitea-mcp" "gitea-mcp --version" run "gosu" "gosu --version" run "tmux" "tmux -V" +# SSH ControlMaster baked defaults: the config file must exist (image-level) +# and ssh -G must report ControlPath rooted at /tmp/sshcm/ for an arbitrary +# host. Catches both regressions: someone removing the conf file, OR something +# else later in the config chain shadowing the ControlPath setting. +run "ssh-config-cm-file" "test -f /etc/ssh/ssh_config.d/00-devbox-controlmaster.conf" +run_expect "ssh-config-cm-path" "ssh -G example.invalid 2>/dev/null | grep -i ^controlpath" "/tmp/sshcm/" + echo echo "-- Optional / variant-gated --" # mempalace: present unless built with INSTALL_MEMPALACE=false