diff --git a/.env.example b/.env.example index 1fc2f42..ed4a126 100644 --- a/.env.example +++ b/.env.example @@ -37,8 +37,11 @@ SSH_KEY_PATH=~/.ssh # directly-attached LAN peers by default. On native Linux Docker the LAN is # reachable directly and nothing is needed. The entrypoint detects this and, # on VM-backed hosts, generates ~/.ssh-local/config so the host can be used -# as an SSH jump (use the `dssh` alias, or add `ProxyJump host` to targets -# in your bind-mounted ~/.ssh/config). +# as an SSH jump (use the `dssh` alias). Reach the host itself with +# `dssh host`. To reach named LAN peers, put `ProxyJump host` overrides in a +# host-owned ~/.config/devbox-shell/ssh-lan.conf (bind-mounted in) rather than +# editing your ~/.ssh/config — see ssh-lan.conf.example. Public-IP hosts (and +# anything reached via a public jump host) connect directly, no jump needed. # # DEVBOX_LAN_ACCESS: auto (default) | jump | off # auto = set up the jump only on VM-backed hosts; no-op on native Linux. @@ -54,6 +57,12 @@ SSH_KEY_PATH=~/.ssh # # DEVBOX_HOST_ALIAS: host hostname to reach (default host.docker.internal). # DEVBOX_HOST_ALIAS=host.docker.internal +# +# DEVBOX_LAN_AUTOJUMP_PRIVATE: 1 = ProxyJump ANY RFC1918 (private) IP through +# the host, so bare `dssh user@` works on whatever LAN the (roaming) host +# is currently joined to, without naming peers. Matches the typed address, not +# the resolved HostName, so named hosts with their own ProxyJump are unaffected. +# DEVBOX_LAN_AUTOJUMP_PRIVATE=0 # ── Skillset (agent skills and instructions) ───────────────────────── # If you have a skillset repo, the entrypoint auto-deploys skills and diff --git a/AGENTS.md b/AGENTS.md index 2660dc5..015fca1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -10,7 +10,7 @@ Docker image packaging [opencode](https://opencode.ai) into a production-ready d - `Dockerfile.variant` — `FROM`s the base and adds only opencode/omos/pi installs gated by build args: `INSTALL_OPENCODE` (default true), `INSTALL_OMOS`, `INSTALL_PI`, and `INSTALL_MEMPALACE`. All GitHub-sourced binaries are pinned with version ARGs. When `INSTALL_PI=true` it also clones `pi-fork` + `pi-observational-memory` (from `github.com/elpapi42`, refs `PI_FORK_REF`/`PI_OBSMEM_REF`) to `/opt` and runs `npm install` there at build time so the `fork`/`recall` extensions can load (a local-path `pi install` does not npm-install). The `pi-only` variant sets `INSTALL_OPENCODE=false`, `INSTALL_PI=true` — pi without opencode, the single source of truth for the separate `pi-devbox` image. It is built and smoke-tested here, but **published into the `joakimp/pi-devbox` repo** as the internal building-block tag `base-pi-only[-vX.Y.Z]` (NOT under `opencode-devbox`), so an opencode-devbox tag never ships without opencode. - `entrypoint.sh` — runs as root: UID/GID adjustment, SSH permissions, volume ownership fixes (skipped via `.devbox-owner` sentinel when ownership is already correct). Then drops to developer via gosu. Volume ownership loop covers `~/.pi/` when `INSTALL_PI=true`. - `entrypoint-user.sh` — runs as developer: git config, opencode.jsonc generation (delegated to `generate-config.py`), LAN-access setup (delegated to `setup-lan-access.sh`), pi-toolkit + pi-extensions deploy (when pi installed), pi settings.json bootstrap, mempalace pi-bridge symlink, runtime `pi install /opt/{pi-fork,pi-observational-memory}` registration (idempotent), skillset auto-deploy from mounted skillset repo, OMOS setup. -- `rootfs/usr/local/lib/opencode-devbox/setup-lan-access.sh` — host-OS-agnostic LAN reachability helper. Detects VM-backed hosts (macOS OrbStack / Docker Desktop, via `host.docker.internal` resolution) and generates a writable `~/.ssh-local/config` using the host as an SSH jump; no-op on native Linux. Controlled by `DEVBOX_LAN_ACCESS` / `HOST_SSH_USER` / `DEVBOX_HOST_ALIAS`. Ships the mechanism only (generic `host` jump alias); user targets stay in their bind-mounted `~/.ssh/config`. Non-fatal. Counted in the base hash, so editing it advances `base-latest`. +- `rootfs/usr/local/lib/opencode-devbox/setup-lan-access.sh` — host-OS-agnostic LAN reachability helper. Detects VM-backed hosts (macOS OrbStack / Docker Desktop, via `host.docker.internal` resolution) and generates a writable `~/.ssh-local/config` using the host as an SSH jump; no-op on native Linux. Controlled by `DEVBOX_LAN_ACCESS` / `HOST_SSH_USER` / `DEVBOX_HOST_ALIAS` / `DEVBOX_LAN_AUTOJUMP_PRIVATE`. Ships the mechanism only (generic `host` jump alias); user targets stay host-side — named-peer `ProxyJump host` overrides go in a bind-mounted `~/.config/devbox-shell/ssh-lan.conf` (Included before `~/.ssh/config`), never baked into the image. **Scoping invariant:** every `Include` in the generated config MUST be preceded by a bare `Host *` reset — an `Include` is scoped to the enclosing `Host`/`Match` block, so without the reset the included config only applies when targeting `host`/`mac` and named peers fall back to SSH defaults. The top `Host *` block also overrides `UserKnownHostsFile` and `ControlPath` into the writable `~/.ssh-local` sidecar (first-value-wins), because the bind-mounted `~/.ssh` is read-only — otherwise multiplexed hosts (`ControlPath ~/.ssh/cm/...`) fail to create their master socket. Non-fatal. Counted in the base hash, so editing it advances `base-latest`. - `rootfs/usr/local/lib/opencode-devbox/generate-config.py` — generates `~/.config/opencode/opencode.jsonc` from env vars. Never overwrites an existing config (checks both `.json` and `.jsonc`). Auto-registers MCP servers for detected tools (mempalace via `mempalace-mcp`, gitea-mcp, context7 remote endpoint). - `scripts/smoke-test.sh` — post-build image verification. Asserts binary presence, opencode startup, entrypoint correctness, config generation idempotency, and image size thresholds. Used by both CI workflows. - `scripts/generate-dockerhub-md.py` — generates `DOCKER_HUB.md` from a hand-maintained `HUB_TEMPLATE` constant. `--check` fails if the committed file is out of sync (enforced by the `validate` workflow). diff --git a/CHANGELOG.md b/CHANGELOG.md index 8a5a26f..763c5ce 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,52 @@ Tags follow `v{opencode_version}[letter]` — bare tag for the first build on a ## Unreleased -_(no changes since v1.15.13c)_ +_(no changes since v1.15.13d)_ + +## v1.15.13d — 2026-06-04 + +LAN-access fixes + ergonomics. Letter-suffix rebuild on opencode `1.15.13` +(version unchanged). Touches `setup-lan-access.sh`, which is in the base hash, +so `base-latest` / `base-pi-only` advance and the fix propagates to `pi-devbox`. + +### Fixed: LAN-access `Include` was scoped to the `host`/`mac` block (named peers ignored) + +The generated `~/.ssh-local/config` placed `Include ~/.ssh/config` *inside* the +`Host host mac` block. Because SSH scopes an `Include` to the enclosing +`Host`/`Match` block, the user's `~/.ssh/config` was only consulted when +targeting `host`/`mac` — so `dssh pve` / `dssh ` by name silently fell +back to SSH defaults (wrong user, unresolved hostname) and never applied the +peer's settings or any `ProxyJump`. Fixed by emitting a bare `Host *` scope +reset before every `Include`. + +### Fixed: read-only `~/.ssh/cm` ControlPath broke multiplexed hosts + +The bind-mounted `~/.ssh/config` commonly sets `ControlPath ~/.ssh/cm/...` +(CGNAT flow-cap multiplexing), but `~/.ssh` is read-only in the container, so +every `ControlMaster`-enabled host (e.g. `pmx-jh`, `proxmox*`, `synlig`) failed +with `cannot bind to path … Read-only file system`. The generated config now +sets `ControlPath ~/.ssh-local/cm/%r@%h:%p` in the top `Host *` block +(first-value-wins) so master sockets land in the writable sidecar. + +### Added: host-owned `ssh-lan.conf` for named-peer jump overrides + +When the host bind-mounts `~/.config/devbox-shell/ssh-lan.conf`, the generated +config now Includes it *before* `~/.ssh/config`. Put `ProxyJump host` overrides +there (first-value-wins inherits HostName/User/IdentityFile from `~/.ssh/config`) +instead of editing the shared `~/.ssh/config` — which would break the host's own +direct access to those peers and is read-only from the container anyway. New +[`ssh-lan.conf.example`](ssh-lan.conf.example). + +### Added: `DEVBOX_LAN_AUTOJUMP_PRIVATE=1` opt-in RFC1918 auto-jump + +Emits a catch-all that ProxyJumps any private (RFC1918) IP through the host, so +bare `dssh user@` reaches whatever LAN the (roaming) host is currently on, +without naming peers. Matches the typed address (not the resolved HostName), so +named hosts carrying their own ProxyJump are unaffected; public IPs stay direct. + +All three land in `rootfs/usr/local/lib/opencode-devbox/setup-lan-access.sh`, +which is counted in the base hash → advances `base-latest` and propagates to +`pi-devbox` (built `FROM` the base). ## v1.15.13c — 2026-06-03 diff --git a/README.md b/README.md index 9452326..12612ea 100644 --- a/README.md +++ b/README.md @@ -135,6 +135,7 @@ docker compose exec -u developer devbox aws --version | `DEVBOX_LAN_ACCESS` | LAN-access mode: `auto` (jump only on VM-backed hosts), `jump` (always), `off` | `auto` | | `HOST_SSH_USER` | Username to SSH into the host as (required for the LAN jump) | — | | `DEVBOX_HOST_ALIAS` | Hostname used to reach the container host | `host.docker.internal` | +| `DEVBOX_LAN_AUTOJUMP_PRIVATE` | `1` = ProxyJump *any* RFC1918 (private) IP through the host, so bare `dssh user@` works on whatever LAN the host is currently on | `0` | | `USER_UID` | Override container user UID | Auto-detect from `/workspace` | | `USER_GID` | Override container user GID | Auto-detect from `/workspace` | | `LANG` | System locale | `en_US.UTF-8` | @@ -161,19 +162,25 @@ On every start the entrypoint detects which case applies. On VM-backed hosts it 1. Set `HOST_SSH_USER=` in `.env`. 2. Start the container once. The entrypoint prints a public key — append it to your host's `~/.ssh/authorized_keys`. 3. Ensure the host's SSH server is on (on macOS: System Settings → General → Sharing → Remote Login). -4. Reach the host with `dssh host`, and reach LAN peers by adding `ProxyJump host` to their entries in your bind-mounted `~/.ssh/config`: +4. Reach the host itself with `dssh host`. (`dssh`/`dscp` wrap `ssh -F ~/.ssh-local/config`.) + +That alone gets you `container → host`. To reach **named LAN peers** by name, give them a `ProxyJump host` override. Don't add it to the shared `~/.ssh/config` entries — the host itself reaches those peers *directly*, and a jump-through-`host` would break the host's own access (and that file is mounted read-only anyway). Instead, drop the overrides in a **host-owned** file that the container Includes ahead of your `~/.ssh/config`: ```sshconfig -# in your host ~/.ssh/config (mounted read-only into the container) -Host my-nas - HostName 192.168.1.50 - User admin +# ~/.config/devbox-shell/ssh-lan.conf — on the host, bind-mounted in +# Only ProxyJump goes here; HostName/User/IdentityFile are inherited +# (first-value-wins) from the matching block in your ~/.ssh/config. +Host my-nas pve pbs ProxyJump host ``` -Then `dssh my-nas` routes container → host → LAN peer. (`dssh`/`dscp` wrap `ssh -F ~/.ssh-local/config`; the host config is pulled in via `Include`.) +Now `dssh my-nas` routes container → host → LAN peer, pulling HostName/User/key from your existing `~/.ssh/config`. See [`ssh-lan.conf.example`](ssh-lan.conf.example). -> This ships the **mechanism** only — your specific target hosts live in your own `~/.ssh/config`, never baked into the image. Set `DEVBOX_LAN_ACCESS=off` to disable, or `=jump` to force it (e.g. native Linux with `extra_hosts: ["host.docker.internal:host-gateway"]`). +**Roaming / unnamed peers.** Because the jump always targets `host` (= the host on whatever LAN it's currently joined to), you can reach the *current* LAN from anywhere. To make bare `dssh user@` jump automatically without naming peers, set `DEVBOX_LAN_AUTOJUMP_PRIVATE=1` — it ProxyJumps any RFC1918 address through the host. It matches the address you *type* (not the resolved HostName), so named hosts that already carry their own ProxyJump are unaffected. + +**Public IPs go direct.** The container has normal internet egress, so a host with a public IP (or one reached via a *public* jump host) connects straight out — the local `host` jump is not involved. e.g. a `Host bastion` whose `HostName` is public, and everything that `ProxyJump bastion`, works from the container by name with no extra setup. + +> This ships the **mechanism** only — your specific target hosts are facts about *your* network (and a laptop roams between several), so they live in your own host-side config, never baked into the image. Set `DEVBOX_LAN_ACCESS=off` to disable, or `=jump` to force it (e.g. native Linux with `extra_hosts: ["host.docker.internal:host-gateway"]`). ### Custom opencode config diff --git a/rootfs/usr/local/lib/opencode-devbox/setup-lan-access.sh b/rootfs/usr/local/lib/opencode-devbox/setup-lan-access.sh index 59d6bcc..83ba9ae 100755 --- a/rootfs/usr/local/lib/opencode-devbox/setup-lan-access.sh +++ b/rootfs/usr/local/lib/opencode-devbox/setup-lan-access.sh @@ -37,6 +37,30 @@ # jump to authenticate. If unset we still generate the config but print # a hint with the public key to authorize on the host. # DEVBOX_HOST_ALIAS — host hostname to reach (default host.docker.internal). +# DEVBOX_LAN_AUTOJUMP_PRIVATE = 0 (default) | 1 +# 1 → also emit a catch-all that ProxyJumps *any* RFC1918 (private) IP +# through the host. Lets bare `dssh user@` work on whatever +# LAN the (roaming) host is currently joined to, without naming peers. +# Matches by the address you TYPE, not the resolved HostName, so it never +# overrides named hosts that already carry their own ProxyJump. +# +# HOST-OWNED PEER POLICY (portable; keeps this image generic) +# Named LAN peers are facts about a *specific* host's network, not about the +# image — a roaming laptop sees different LANs. So we never bake peer names +# here. Instead, if the host bind-mounts ~/.config/devbox-shell/ssh-lan.conf +# (the same devbox-shell bridge dir used for shared aliases), we Include it +# *before* ~/.ssh/config. That file holds the host's own jump overrides, e.g. +# Host pve pve-2 pbs-vm +# ProxyJump host +# First-value-wins means ProxyJump is taken from there while HostName/User/ +# IdentityFile are inherited from the matching block in ~/.ssh/config. +# +# SCOPING NOTE (important) +# `Include` is scoped to the enclosing Host/Match block. So every Include +# below is preceded by a bare `Host *` to reset the active context to +# match-all — otherwise the included config would only apply when targeting +# `host`/`mac` and named peers like `pve` would silently fall back to ssh +# defaults. # # Idempotent: re-renders the config every run (cheap); never regenerates the # key. Always non-fatal — never blocks container startup. @@ -84,9 +108,51 @@ if [ -n "${HOST_SSH_USER:-}" ]; then USER_LINE=" User ${HOST_SSH_USER}" fi -INCLUDE_LINE="" +# 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" +LAN_CONF_BLOCK="" +if [ -r "$SSH_LAN_CONF" ]; then + LAN_CONF_BLOCK=$(cat <<'EOF' + +# Host-owned named-peer jump overrides (bind-mounted; edit on the host). +# Scope reset to match-all so the Include applies to every target host. +Host * +Include ~/.config/devbox-shell/ssh-lan.conf +EOF +) +fi + +# Optional opt-in RFC1918 catch-all: ProxyJump every private IP through the +# host. Matches the typed address, never the resolved HostName, so named hosts +# with their own ProxyJump are unaffected. Network-agnostic → roaming-safe. +AUTOJUMP_BLOCK="" +if [ "${DEVBOX_LAN_AUTOJUMP_PRIVATE:-0}" = "1" ]; then + AUTOJUMP_BLOCK=$(cat <<'EOF' + +# RFC1918 auto-jump (DEVBOX_LAN_AUTOJUMP_PRIVATE=1): reach any private IP on +# the host's CURRENT LAN via bare `dssh user@`. Public IPs are unmatched +# and go direct via the container's NAT egress. NOTE: also matches the +# container's own bridge subnet and any private IP the host can't actually +# reach — for non-LAN private hosts behind a different jump, use their named +# entry (which matches first by name and keeps its own ProxyJump). +Host 10.* 192.168.* 172.16.* 172.17.* 172.18.* 172.19.* 172.20.* 172.21.* 172.22.* 172.23.* 172.24.* 172.25.* 172.26.* 172.27.* 172.28.* 172.29.* 172.30.* 172.31.* + ProxyJump host +EOF +) +fi + +INCLUDE_BLOCK="" if [ -r "${HOME}/.ssh/config" ]; then - INCLUDE_LINE="Include ~/.ssh/config" + INCLUDE_BLOCK=$(cat <<'EOF' + +# Your own target hosts. Scope reset to match-all so this Include applies to +# every target (an Include is otherwise scoped to the enclosing Host block). +# Add 'ProxyJump host' to LAN entries here (or in ssh-lan.conf above). +Host * +Include ~/.ssh/config +EOF +) fi cat > "$CONFIG" < "$CONFIG" </dev/null || true diff --git a/ssh-lan.conf.example b/ssh-lan.conf.example new file mode 100644 index 0000000..400f82c --- /dev/null +++ b/ssh-lan.conf.example @@ -0,0 +1,45 @@ +# ssh-lan.conf.example — host-owned LAN-peer jump overrides for opencode-devbox +# ============================================================================ +# WHAT THIS IS +# On a VM-backed host (macOS OrbStack / Docker Desktop) the container can't +# reach the host's LAN directly; it tunnels through the host via the `host` +# SSH jump that the entrypoint sets up (see the README "Reaching your LAN" +# section). To reach your LAN peers *by name*, they need `ProxyJump host`. +# +# WHY NOT JUST EDIT ~/.ssh/config? +# The host itself reaches those peers DIRECTLY — adding `ProxyJump host` +# there would break the host's own access (and ~/.ssh is mounted read-only +# into the container anyway). So container-only jump overrides live HERE. +# +# HOW IT'S WIRED +# If this file exists at ~/.config/devbox-shell/ssh-lan.conf on the host +# (the same bind-mounted devbox-shell bridge dir used for shared aliases), +# the generated ~/.ssh-local/config Includes it BEFORE your ~/.ssh/config. +# SSH's first-value-wins rule means ProxyJump is taken from here, while +# HostName / User / IdentityFile are inherited from the matching block in +# your ~/.ssh/config. So you only list the names + the jump — nothing else. +# +# SETUP +# 1. Copy to your host: cp ssh-lan.conf.example ~/.config/devbox-shell/ssh-lan.conf +# 2. Bind-mount ~/.config/devbox-shell into the container (most setups +# already do this for shared shell aliases). +# 3. List the host aliases (as named in your ~/.ssh/config) that should be +# reached through the host jump. +# 4. Restart the container, then: dssh +# +# NOTE: these are facts about ONE host's LAN. A roaming laptop sees different +# networks — keep this per-host, never in the image. For ad-hoc private IPs on +# whatever LAN you're currently on, prefer DEVBOX_LAN_AUTOJUMP_PRIVATE=1 +# instead of naming every peer. + +# Example — names must match Host blocks already defined in your ~/.ssh/config: +Host pve pve-2 pbs-vm my-nas + ProxyJump host + +# You can also give a peer its own settings here if it isn't in ~/.ssh/config +# at all (then specify everything, not just ProxyJump): +# Host lab-box +# HostName 192.168.1.77 +# User admin +# IdentityFile ~/.ssh/id_ed25519 +# ProxyJump host