feat(seed): outbound SSH client material for hands-free node->mother

The baked mother key (build/mother-ssh-key) puts a private key in the image,
which only works for a non-published personalized stick. The offline FAT32
seed is the correct home for per-node secrets.

Teach the importer to install outbound SSH client material from an agent's
ssh/ dir into the agent home:
  - config       -> ~/.ssh/config       (0600)
  - known_hosts* -> ~/.ssh/known_hosts* (0644, merged + de-duped)
  - <name>.pub   -> ~/.ssh/<name>.pub   (0644)
  - <name>       -> ~/.ssh/<name>        (0600, any other file = private key)
authorized_keys stays inbound-only via _seed_install_authorized_keys.

This closes the 'without manual key exchange' gap: known_hosts pins mother's
host key so the first node->mother connect does not prompt, and the private
client key rides on the offline seed instead of the base image — so the
published image stays secret-free. Supersedes the baked-key path (#112),
which can retire once this is validated on hardware.

Verified offline (CLAWDIE_SEED_TEST): correct perms (key 0600, pub/known_hosts
0644, config 0600, .ssh 0700) and idempotent known_hosts merge across re-runs.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Sam & Claude 2026-06-22 09:55:56 +02:00
parent 72491ee3b8
commit 862af0583b
2 changed files with 91 additions and 1 deletions

View file

@ -112,6 +112,65 @@ _seed_install_authorized_keys() {
_seed_log "installed authorized_keys from ${_src} -> ${_dst}"
}
# Install OUTBOUND ssh client material from an agent's ssh/ dir into the agent
# home so a seeded node can SSH out (e.g. node -> mother) hands-free, with the
# private key delivered on the offline seed instead of baked into the image:
# config -> ~/.ssh/config (0600; e.g. a "Host mother" alias)
# known_hosts* -> ~/.ssh/known_hosts* (0644; merged, so mother's host key
# is trusted and the first connect
# does not prompt)
# <name>.pub -> ~/.ssh/<name>.pub (0644)
# <name> -> ~/.ssh/<name> (0600; any other file = private key)
# authorized_keys is INBOUND and handled by _seed_install_authorized_keys.
_seed_install_ssh_material() {
_srcdir="$1"
_user="${2:-${SEED_USER}}"
_home="${3:-${SEED_USER_HOME}}"
[ -d "${_srcdir}" ] || return 0
_ssh_dir="${_home}/.ssh"
mkdir -p "${_ssh_dir}"
chown "${_user}:${_user}" "${_ssh_dir}" 2>/dev/null || true
chmod 0700 "${_ssh_dir}"
for _f in "${_srcdir}"/*; do
[ -f "${_f}" ] || continue
_base="$(basename "${_f}")"
_dst="${_ssh_dir}/${_base}"
case "${_base}" in
authorized_keys)
continue ;; # inbound, installed separately
known_hosts|known_hosts2)
touch "${_dst}"
tr -d '\r' <"${_f}" >>"${_dst}"
# De-dup so re-imports stay idempotent (order is irrelevant here).
sort -u "${_dst}" -o "${_dst}" 2>/dev/null || true
chmod 0644 "${_dst}"
chown "${_user}:${_user}" "${_dst}" 2>/dev/null || true
_seed_log "merged ${_base} -> ${_dst}" ;;
config)
tr -d '\r' <"${_f}" >"${_dst}.new"
mv -f "${_dst}.new" "${_dst}"
chmod 0600 "${_dst}"
chown "${_user}:${_user}" "${_dst}" 2>/dev/null || true
_seed_log "installed ssh config -> ${_dst}" ;;
*.pub)
tr -d '\r' <"${_f}" >"${_dst}.new"
mv -f "${_dst}.new" "${_dst}"
chmod 0644 "${_dst}"
chown "${_user}:${_user}" "${_dst}" 2>/dev/null || true
_seed_log "installed public key -> ${_dst}" ;;
*)
tr -d '\r' <"${_f}" >"${_dst}.new"
mv -f "${_dst}.new" "${_dst}"
chmod 0600 "${_dst}"
chown "${_user}:${_user}" "${_dst}" 2>/dev/null || true
_seed_log "installed private key -> ${_dst}" ;;
esac
done
}
# Valid POSIX-ish shell env var name. Guards against garbled seed lines
# injecting odd content into .env.
_seed_key_ok() {
@ -285,6 +344,11 @@ _seed_activate_agent() {
if [ -f "${_dir}/ssh/authorized_keys" ]; then
_seed_install_authorized_keys "${_dir}/ssh/authorized_keys" "${_user}" "${_home}"
fi
# Outbound client material (config, known_hosts, client keys) for node->mother
# connectivity, delivered via the offline seed rather than baked in the image.
if [ -d "${_dir}/ssh" ]; then
_seed_install_ssh_material "${_dir}/ssh" "${_user}" "${_home}"
fi
_seed_log "activated agent '${_agent}' for user ${_user} (home ${_home})"
}

View file

@ -79,7 +79,33 @@ Inside it, any of these are honored:
under /var/db/clawdie/seed/<agent>/soul for
the agent workspace to load.
/<agent>/ssh/authorized_keys Public SSH keys for this agent.
/<agent>/ssh/authorized_keys INBOUND: public keys allowed to SSH INTO this
node. Installed to ~/.ssh/authorized_keys (0600).
/<agent>/ssh/<name> OUTBOUND: a private client key so this node can
SSH OUT hands-free (e.g. node -> mother).
Installed to ~/.ssh/<name> (0600). The private
key rides on this offline seed instead of being
baked into the image, so the image stays
secret-free. Any file here that is not
authorized_keys/config/known_hosts and does not
end in .pub is treated as a private key.
/<agent>/ssh/<name>.pub OUTBOUND: installed to ~/.ssh/<name>.pub (0644).
/<agent>/ssh/config OUTBOUND: installed to ~/.ssh/config (0600).
Typical use — a host alias for the mother server:
Host mother
HostName osa.smilepowered.org
User clawdie
IdentityFile ~/.ssh/osa-mother-2026
/<agent>/ssh/known_hosts OUTBOUND: merged into ~/.ssh/known_hosts (0644),
de-duplicated. Pin the mother server's host key
here so the first node -> mother connection does
not stop on an unknown-host prompt. Get the line
with: ssh-keyscan osa.smilepowered.org
Agent directory names may contain only A-Z a-z 0-9 . _ - (no spaces or
slashes). The name `ssh` is reserved for Layer 1.