8ae1659273
Same rule as c693aa3 (ssh-lan.conf). `Include ~/.ssh/config` went into
~/.ssh-local/config only if ~/.ssh/config was readable at container start.
A file that appeared later, e.g. on a host provisioned after its first
`docker compose up`, was ignored until a restart. Entries ADDED to a file that
already existed were never affected. Measured on tor-ms22 2026-10-19,
expectations written first:
- ssh reads Included files on every connection. A Host added after the config
was generated resolved with no rerun.
- the bind mount is live: on the Mac, a created file, an atomic replace
(write + mv) and an append each showed in the container within a second.
So this fixes only the file-appears-later case. The line is now always
written. A file that exists but is unreadable is still left out (ssh would
abort every connection), now with a warning on stderr.
Generated config compared with v1.12.1's: byte-identical when ~/.ssh/config
exists at start (DEVBOX_LAN_ACCESS=jump and auto, with and without
ssh-lan.conf), and stderr is the same too. When the file is absent, the only
difference is the added Include block.
scripts/smoke-test.sh: two new checks, run here the way exec_test runs them
(sh -c). Against v1.12.1, "Included even if created after start" FAILS and
"unreadable left out" PASSES. Against this script both pass, and so do the two
ssh-lan.conf checks.
CHANGELOG Unreleased also names pi-studio v0.10.4 (c83940d), because
check-doc-drift (and so lint on main) had gone red: upstream released 0.10.3
and 0.10.4 right after v1.12.1. 0.10.4 fixes a 0.10.2 Stop regression for
macOS uid -2. Not reachable in the image: 0.10.2's own readStopProcessTable
parsed this container's live ps output (9 rows, min uid 1000). Control: a
macOS-style -2 row throws. The lockfile packages are unchanged, and 0.10.4
loads into pi 1.3.0 with the same 4 studio_* tools, rc 0.
check-doc-drift rc 0, lint-shell rc 0, shellcheck warnings 0 before and after.
317 lines
16 KiB
Bash
Executable File
317 lines
16 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
# setup-lan-access.sh — generic, host-OS-agnostic LAN reachability helper.
|
|
#
|
|
# THE PROBLEM
|
|
# On macOS (OrbStack / Docker Desktop) and Docker Desktop on Windows, the
|
|
# container runs inside a Linux VM behind the host's network stack. The
|
|
# host's *directly-attached* LAN peers (e.g. other boxes on 192.168.1.0/24)
|
|
# are NOT bridged into the container by default — only the host itself and
|
|
# *routed* subnets are reachable. On native Linux Docker the default bridge
|
|
# already NATs container egress onto the host's LAN, so LAN peers are usually
|
|
# reachable directly and no workaround is needed.
|
|
#
|
|
# THE APPROACH ("detect, and on a VM-backed host use the host as a jump")
|
|
# 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 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
|
|
# bind-mounted ~/.ssh/config (add `ProxyJump host` to those entries) — which
|
|
# is pulled in via the `Include ~/.ssh/config` line below.
|
|
#
|
|
# WHY A WRITABLE SIDECAR (~/.ssh-local)
|
|
# The devbox typically bind-mounts the host's ~/.ssh READ-ONLY (so agents
|
|
# can read keys for git but can't tamper with config/known_hosts/authorized_
|
|
# keys). That means we cannot edit ~/.ssh/config or write ~/.ssh/known_hosts.
|
|
# So everything generated here lives under the writable ~/.ssh-local, used
|
|
# via `ssh -F ~/.ssh-local/config` (the `dssh`/`dscp` aliases wrap that).
|
|
#
|
|
# CONTROLS (env)
|
|
# DEVBOX_LAN_ACCESS = auto (default) | jump | off
|
|
# 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
|
|
# 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@<private-IP>` 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 we Include ~/.config/devbox-shell/ssh-lan.conf (the host
|
|
# bind-mounts the devbox-shell bridge dir, also used for shared aliases)
|
|
# *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.
|
|
|
|
set -uo pipefail
|
|
|
|
MODE="${DEVBOX_LAN_ACCESS:-auto}"
|
|
[ "$MODE" = "off" ] && exit 0
|
|
|
|
HOST_ALIAS_HOSTNAME="${DEVBOX_HOST_ALIAS:-host.docker.internal}"
|
|
SSH_LOCAL="${HOME}/.ssh-local"
|
|
CONFIG="${SSH_LOCAL}/config"
|
|
KEY="${SSH_LOCAL}/devbox_jump_ed25519"
|
|
|
|
# ── Detection: is this a VM-backed host (macOS / Docker Desktop)? ──────
|
|
# host.docker.internal resolves on OrbStack and Docker Desktop (mac/win) but
|
|
# NOT on native Linux Docker (unless the user added extra_hosts: host-gateway,
|
|
# in which case the jump is still harmless / usable, and they can force it
|
|
# with DEVBOX_LAN_ACCESS=jump).
|
|
is_vm_backed() {
|
|
getent hosts "$HOST_ALIAS_HOSTNAME" >/dev/null 2>&1
|
|
}
|
|
|
|
# ── 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
|
|
|
|
# ── 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 [ "$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 ────────────────────────────────────────
|
|
# 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=""
|
|
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 <<EOF
|
|
|
|
# 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
|
|
EOF
|
|
)
|
|
|
|
# Optional host-owned named-peer jump overrides (portable: lives on the host,
|
|
# not in the image). Included BEFORE ~/.ssh/config so its ProxyJump wins.
|
|
#
|
|
# Included even when the file does not exist yet. ssh skips an Include whose
|
|
# file is missing, and reads it on the next connection once it appears.
|
|
# Through v1.12.0 the Include was written only if the file was readable at
|
|
# container start. A file created later was then visible in the container but
|
|
# ignored, until a restart (EMB-3HXGNXJ0, 2026-10-18: its tor-ms22 override
|
|
# was skipped, so ssh fell back to a passphrase key and BatchMode failed).
|
|
# Only a file that EXISTS but cannot be read is left out. ssh aborts every
|
|
# connection on one ("Can't open user config file"), which would break all
|
|
# ssh, git included. A directory at that path is harmless (ssh ignores it).
|
|
# Measured with this image's OpenSSH 10.0p2; scripts/smoke-test.sh checks
|
|
# both cases.
|
|
SSH_LAN_CONF="${HOME}/.config/devbox-shell/ssh-lan.conf"
|
|
if [ -e "$SSH_LAN_CONF" ] && [ ! -r "$SSH_LAN_CONF" ]; then
|
|
echo "setup-lan-access: $SSH_LAN_CONF exists but is not readable, so it is not Included (ssh would refuse every connection). Fix its permissions, then rerun this script or restart the container." >&2
|
|
else
|
|
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.
|
|
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@<ip>`. 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
|
|
fi
|
|
|
|
# Same rule as ssh-lan.conf above: Included even when ~/.ssh/config does not
|
|
# exist yet, so a file that appears after container start (a host provisioned
|
|
# after its first `docker compose up`) is read on the next ssh. Entries added to
|
|
# a file that already existed were always picked up: ssh reads Includes on every
|
|
# connection and the mount is live. Through v1.12.1 the line was written only if
|
|
# the file was readable at start. Only a file that EXISTS but cannot be read is
|
|
# left out, because ssh would then abort every connection, git included.
|
|
INCLUDE_BLOCK=""
|
|
if [ -e "${HOME}/.ssh/config" ] && [ ! -r "${HOME}/.ssh/config" ]; then
|
|
echo "setup-lan-access: ${HOME}/.ssh/config exists but is not readable, so it is not Included (ssh would refuse every connection). Fix its permissions, then rerun this script or restart the container." >&2
|
|
else
|
|
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).
|
|
# To make a LAN peer jump via the host, add 'ProxyJump host' to its entry in
|
|
# the host-owned ~/.config/devbox-shell/ssh-lan.conf (Included above) — NOT
|
|
# here in ~/.ssh/config, which is typically bind-mounted read-only.
|
|
Host *
|
|
Include ~/.ssh/config
|
|
EOF
|
|
)
|
|
fi
|
|
|
|
# ── Multiplexing default, deliberately LAST ───────────────────────────
|
|
# Why this block exists: ControlPath above is forced, but ControlMaster is not
|
|
# set anywhere for targets that come from the user's own ~/.ssh/config. A target
|
|
# whose entry omits ControlMaster therefore opens a NEW TCP connection per ssh
|
|
# call, and an agent doing a dozen calls in a few minutes can trip fail2ban or a
|
|
# CGNAT flow-table cap on the far end — observed 2026-08-25: ~12 connections in
|
|
# 15 min and port 22 stopped answering while HTTPS to the same estate stayed fine.
|
|
#
|
|
# WHY IT IS AT THE BOTTOM, and ControlPath is at the top. ssh_config is
|
|
# first-value-wins, so position encodes intent:
|
|
# * BEFORE the Include = an OVERRIDE. Correct for ControlPath, whose value in
|
|
# the user's config points at read-only ~/.ssh and simply cannot work here.
|
|
# * AFTER the Include = a DEFAULT. Correct for ControlMaster, because an
|
|
# explicit per-host 'ControlMaster no' (or 'auto', or any value) in the
|
|
# user's own config must keep winning. We are supplying an opinion only
|
|
# where the user expressed none.
|
|
# That asymmetry is the whole design: force what is broken, default what is
|
|
# merely absent. It also means this needs no audit of anyone's ~/.ssh/config —
|
|
# which matters because that file is per-machine, differs across the fleet, and
|
|
# future machines' versions do not exist yet to be audited.
|
|
#
|
|
# Caveat worth knowing (and documented in the pi-devbox-environment skill): a
|
|
# stale master socket — file present, daemon gone, e.g. after the host suspends
|
|
# or changes network — makes every later ssh to that host hang. Recovery is
|
|
# 'ssh -F ~/.ssh-local/config -O exit <host>'. ControlPersist is deliberately
|
|
# short (10m idle, and each new session resets the idle timer) so an abandoned
|
|
# socket ages out on its own rather than lingering for hours.
|
|
MULTIPLEX_DEFAULT_BLOCK=$(cat <<'EOF'
|
|
|
|
# Multiplexing DEFAULT — intentionally after the Include above, so any explicit
|
|
# per-host ControlMaster in your own ~/.ssh/config still wins (first-value-wins).
|
|
# Applies only to targets that never mentioned ControlMaster at all.
|
|
# Stale socket after a suspend/network change? ssh -O exit <host>.
|
|
Host *
|
|
ControlMaster auto
|
|
ControlPersist 10m
|
|
EOF
|
|
)
|
|
|
|
cat > "$CONFIG" <<EOF
|
|
# AUTO-GENERATED by setup-lan-access.sh on every container start. Do not edit
|
|
# by hand — edits are overwritten. Used via: ssh -F ~/.ssh-local/config <host>
|
|
# (or the dssh / dscp aliases). See the script header for the full rationale.
|
|
|
|
# ~/.ssh is typically mounted read-only, so keep our own known_hosts here.
|
|
# Also redirect ControlPath into the writable sidecar: the bind-mounted
|
|
# ~/.ssh/config commonly sets 'ControlPath ~/.ssh/cm/...' for CGNAT multiplexing,
|
|
# but ~/.ssh is read-only here so the master socket can't be created and those
|
|
# hosts fail to connect. First-value-wins: setting it here (before the Include)
|
|
# overrides the read-only path for every host. Harmless when ControlMaster is off.
|
|
Host *
|
|
UserKnownHostsFile ~/.ssh-local/known_hosts
|
|
StrictHostKeyChecking accept-new
|
|
ControlPath ~/.ssh-local/cm/%r@%h:%p
|
|
${JUMP_BLOCK}
|
|
${LAN_CONF_BLOCK}
|
|
${AUTOJUMP_BLOCK}
|
|
${INCLUDE_BLOCK}
|
|
${MULTIPLEX_DEFAULT_BLOCK}
|
|
EOF
|
|
chmod 600 "$CONFIG" 2>/dev/null || true
|
|
|
|
# ── Authorize hints ───────────────────────────────────────────────────
|
|
# Print the copy-paste authorize line whenever we either (a) can't yet
|
|
# authenticate (HOST_SSH_USER unset) or (b) just generated a NEW key that the
|
|
# 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 <<EOF
|
|
[devbox] LAN-access jump config generated at ~/.ssh-local/config, but
|
|
HOST_SSH_USER is unset so it can't authenticate to the host yet.
|
|
To enable container -> host -> LAN-peer access:
|
|
1. Set HOST_SSH_USER=<your host username> in the container env.
|
|
2. Authorize this key on the host (run ON THE HOST, once):
|
|
echo '${PUBKEY_TEXT}' >> ~/.ssh/authorized_keys
|
|
3. Ensure the host's SSH server (Remote Login) is enabled.
|
|
Then: dssh host (or add 'ProxyJump host' to targets in ~/.ssh/config)
|
|
EOF
|
|
elif [ "$KEY_JUST_GENERATED" = "1" ]; then
|
|
cat <<EOF
|
|
[devbox] Generated a NEW LAN-jump key. Authorize it on the host (${HOST_SSH_USER}@host),
|
|
then 'dssh host' and your LAN peers will work. Run this ONCE, ON THE HOST:
|
|
echo '${PUBKEY_TEXT}' >> ~/.ssh/authorized_keys
|
|
(Ensure the host's SSH server / Remote Login is enabled.)
|
|
This key is persisted in the ~/.ssh-local volume, so you won't need to
|
|
repeat this on container updates — only if that volume is reset.
|
|
EOF
|
|
fi
|
|
fi
|
|
|
|
exit 0
|