Files
pi-devbox/rootfs/usr/local/lib/pi-devbox/setup-lan-access.sh
T
joakimp 8de0fad776 docs(lan): document ssh-lan.conf for naming LAN peers
The host-owned, bind-mounted ~/.config/devbox-shell/ssh-lan.conf is the
intended place to add `ProxyJump host` overrides for named LAN peers (so
`pi --ssh <peer>` / `dssh <peer>` route through the host), but it was only
documented in .env.example and the setup-lan-access.sh header — never in the
README, where someone hitting "can't reach LAN peers" actually looks.

- README: add a "Naming LAN peers" subsection under the macOS LAN-peers
  troubleshooting block, with a ProxyJump example and the read-only ~/.ssh
  caveat; add a pointer to it from the SSH and ControlMaster section.
- setup-lan-access.sh: correct the INCLUDE_BLOCK comment that suggested adding
  ProxyJump to the read-only ~/.ssh/config; point at ssh-lan.conf instead.
- CHANGELOG: note under Unreleased.

Docs/comment only — no behavior change.
2026-06-21 00:23:29 +02:00

254 lines
12 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, 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.
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.
SSH_LAN_CONF="${HOME}/.config/devbox-shell/ssh-lan.conf"
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.
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
INCLUDE_BLOCK=""
if [ -r "${HOME}/.ssh/config" ]; then
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
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}
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