From 3d7c508dc452f90f5a2eeeba6292d59ef4303f7e Mon Sep 17 00:00:00 2001 From: didericis Date: Sat, 11 Jul 2026 18:16:57 -0400 Subject: [PATCH] fix(firecracker): make the launch path work end-to-end MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit First real end-to-end launch (there was no firecracker integration test) surfaced three bugs in the control/provision path, all now verified fixed by tests/integration/test_firecracker_launch: 1. Guest SSH rejected the correct key. The rootless rootfs build (`docker export | tar` as a non-root user) can't preserve uid 0, so every path — including /root — is owned by the build uid (node in guest). dropbear refuses root's authorized_keys when /root isn't root-owned, so auth fell back to password → denied. bb-init now `chown -R 0:0 /root`. 2. The SSH client (newer OpenSSH) didn't reliably present the -i key against the operator's ~/.ssh config; add `IdentitiesOnly=yes` to ssh_base_argv so only the per-bottle key is offered. 3. cp_in double-wrapped the remote command as `sh -c `, but ssh space-joins everything after the host into one string for the guest shell, collapsing `sh -c mkdir -p X && …` to `mkdir` with no operand ("missing operand"). Pass the command as a single arg and let the guest login shell run it (stdin still carries the tar). Also stop discarding dropbear's stderr (`-E` now reaches the host-side console.log) so future guest-auth issues are debuggable. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01WBMWTEtQdJ4W5UrWuLHCck --- bot_bottle/backend/firecracker/bottle.py | 7 ++++++- bot_bottle/backend/firecracker/util.py | 16 ++++++++++++++-- 2 files changed, 20 insertions(+), 3 deletions(-) diff --git a/bot_bottle/backend/firecracker/bottle.py b/bot_bottle/backend/firecracker/bottle.py index aff4fcf..c8bc62b 100644 --- a/bot_bottle/backend/firecracker/bottle.py +++ b/bot_bottle/backend/firecracker/bottle.py @@ -151,8 +151,13 @@ class FirecrackerBottle(Bottle): ["tar", "-C", host_parent, "-cf", "-", host_base], stdout=subprocess.PIPE, ) + # Pass the command as one arg: ssh space-joins everything after + # the host into a single string for the guest's login shell, so a + # `sh -c ` split would drop everything past the first word + # (the guest shell runs `` directly; stdin carries the + # tar). ssh = subprocess.run( - [*self._ssh(tty=False), "--", "sh", "-c", remote], + [*self._ssh(tty=False), "--", remote], stdin=tar.stdout, capture_output=True, text=True, check=False, ) tar.wait() diff --git a/bot_bottle/backend/firecracker/util.py b/bot_bottle/backend/firecracker/util.py index 15b0bb5..aa29e8a 100644 --- a/bot_bottle/backend/firecracker/util.py +++ b/bot_bottle/backend/firecracker/util.py @@ -268,6 +268,10 @@ def ssh_base_argv(private_key: Path, guest_ip: str) -> list[str]: return [ "ssh", "-i", str(private_key), + # Only offer the per-bottle key — don't let an agent or the + # operator's ~/.ssh/config inject other identities (newer + # OpenSSH otherwise may not reliably present the -i key). + "-o", "IdentitiesOnly=yes", "-o", "StrictHostKeyChecking=no", "-o", "UserKnownHostsFile=/dev/null", "-o", "LogLevel=ERROR", @@ -301,9 +305,17 @@ if [ -n "$KEY" ]; then chown -R node:node /home/node/.ssh 2>/dev/null || true fi +# The rootless rootfs build (`docker export | tar` as a non-root user) +# can't preserve uid 0, so every path lands owned by the build uid, +# which maps to `node` (uid 1000) in-guest — including /root. dropbear +# (like OpenSSH) refuses root's authorized_keys unless the home dir is +# owned by root, so restore root's ownership of its own home. +chown -R 0:0 /root 2>/dev/null || true + mkdir -p /etc/dropbear /run -# -R: generate host keys on demand. -E: log to stderr (guest console). -/bb-dropbear -R -E -p 22 2>/dev/null & +# -R: generate host keys on demand. -E: log auth failures to stderr, +# captured in the host-side console.log for debugging. +/bb-dropbear -R -E -p 22 & # Reap zombies as PID 1. dropbear is always a child, so `wait` blocks # rather than busy-looping.