Files
pi-devbox/rootfs/usr/local/lib/pi-devbox/setup-lan-access.sh
T
joakimp 8ae1659273
Lint / skill-floor (push) Successful in 11s
Lint / hadolint (push) Successful in 17s
Lint / actionlint (push) Successful in 22s
Lint / doc-drift (push) Successful in 13s
setup-lan-access: Include ~/.ssh/config even if it appears after start
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.
2026-10-19 08:25:14 +02:00

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