From f6ba677d0b1eae5ace519d0032c7fd5bf629289b Mon Sep 17 00:00:00 2001 From: pratikbin <68642400+pratikbin@users.noreply.github.com> Date: Fri, 21 Aug 2026 14:16:27 +0530 Subject: [PATCH] fix(sandbox): pin ssh known_hosts to sandbox id Tunnel mode writes `HostName 127.0.0.1` for every sandbox, so all sandboxes share one known_hosts identity. After the first box is recorded, the next one presents a different host key on the same `[127.0.0.1]:22` entry and ssh reports REMOTE HOST IDENTIFICATION HAS CHANGED. OpenSSH downgrades that to a warning when public-key auth is used, but stricter clients treat a changed host key as an attack and refuse the connection outright. Orca's SSH relay is one of them, which makes a second CreateOS sandbox unusable as a remote host. VPN mode has the same defect with a longer fuse: it keys on the overlay IP, and those are recycled between sandboxes. Add `HostKeyAlias ` to both blocks. OpenSSH then keys known_hosts on the sandbox id, which is unique and stable across pause/resume, instead of on a shared address. Reproduced against sb-01m0hp7v5vtdnj2pwpj25drhwe while integrating CreateOS sandboxes as Orca remote hosts. --- cmd/sandbox/editor.go | 13 +++++++-- cmd/sandbox/editor_test.go | 58 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 69 insertions(+), 2 deletions(-) create mode 100644 cmd/sandbox/editor_test.go diff --git a/cmd/sandbox/editor.go b/cmd/sandbox/editor.go index 9fe4247..5bb6870 100644 --- a/cmd/sandbox/editor.go +++ b/cmd/sandbox/editor.go @@ -474,6 +474,13 @@ func hostLine(alias, name string) string { } // renderSSHBlock builds the ~/.ssh/config stanza for the sandbox. +// +// HostKeyAlias pins each sandbox's known_hosts entry to its own id. Without it +// every tunnel-mode box is keyed on the shared `127.0.0.1:22`, so the second +// sandbox trips a CHANGED HOST KEY warning and clients that treat that as an +// attack (Orca's SSH relay, for one) refuse to connect. VPN mode keys on the +// overlay IP, which is also recycled between sandboxes, so it needs the same +// pin. func renderSSHBlock(alias, mode, sandboxID, sbIP, gwHost string, gwPort int, user, identity, name string) (string, error) { begin := fmt.Sprintf(sshConfigBlockBegin, alias) end := fmt.Sprintf(sshConfigBlockEnd, alias) @@ -483,13 +490,14 @@ func renderSSHBlock(alias, mode, sandboxID, sbIP, gwHost string, gwPort int, use return fmt.Sprintf(`%s Host %s HostName %s + HostKeyAlias %s Port 22 User %s IdentityFile %s StrictHostKeyChecking accept-new UserKnownHostsFile ~/.ssh/known_hosts_createos %s -`, begin, host, sbIP, user, identity, end), nil +`, begin, host, sbIP, sandboxID, user, identity, end), nil case "tunnel": // The inner `ssh -W` for the gateway needs its own // StrictHostKeyChecking + UserKnownHostsFile — it doesn't inherit @@ -498,6 +506,7 @@ Host %s return fmt.Sprintf(`%s Host %s HostName 127.0.0.1 + HostKeyAlias %s Port 22 User %s IdentityFile %s @@ -505,7 +514,7 @@ Host %s StrictHostKeyChecking accept-new UserKnownHostsFile ~/.ssh/known_hosts_createos %s -`, begin, host, user, identity, sandboxID, gwHost, gwPort, identity, end), nil +`, begin, host, sandboxID, user, identity, sandboxID, gwHost, gwPort, identity, end), nil default: return "", fmt.Errorf("unknown mode %q", mode) } diff --git a/cmd/sandbox/editor_test.go b/cmd/sandbox/editor_test.go new file mode 100644 index 0000000..d15bdf4 --- /dev/null +++ b/cmd/sandbox/editor_test.go @@ -0,0 +1,58 @@ +package sandbox + +import ( + "strings" + "testing" +) + +// Both transports must pin known_hosts to the sandbox id. Tunnel mode shares +// 127.0.0.1:22 across every box and VPN mode recycles overlay IPs, so without +// the pin the second sandbox reads as a changed host key. +func TestRenderSSHBlockPinsHostKeyAliasToSandboxID(t *testing.T) { + for _, mode := range []string{"tunnel", "vpn"} { + t.Run(mode, func(t *testing.T) { + block, err := renderSSHBlock( + "sb-aaa", mode, "sb-aaa", "10.0.0.7", + "gateway.sb.createos.sh", 2222, "root", "/keys/sb-aaa", "my-box", + ) + if err != nil { + t.Fatalf("renderSSHBlock: %v", err) + } + if !strings.Contains(block, "HostKeyAlias sb-aaa") { + t.Errorf("block is missing the HostKeyAlias pin:\n%s", block) + } + }) + } +} + +func TestRenderSSHBlockGivesEachSandboxADistinctHostKeyIdentity(t *testing.T) { + render := func(id string) string { + block, err := renderSSHBlock( + id, "tunnel", id, "10.0.0.7", + "gateway.sb.createos.sh", 2222, "root", "/keys/"+id, "", + ) + if err != nil { + t.Fatalf("renderSSHBlock(%s): %v", id, err) + } + return block + } + if aliasLine(render("sb-aaa")) == aliasLine(render("sb-bbb")) { + t.Error("two sandboxes share one host-key identity; the second will trip a changed-host-key error") + } +} + +func TestRenderSSHBlockRejectsUnknownMode(t *testing.T) { + if _, err := renderSSHBlock("sb-aaa", "carrier-pigeon", "sb-aaa", "10.0.0.7", + "gateway.sb.createos.sh", 2222, "root", "/keys/sb-aaa", ""); err == nil { + t.Error("expected an error for an unknown mode") + } +} + +func aliasLine(block string) string { + for _, line := range strings.Split(block, "\n") { + if trimmed := strings.TrimSpace(line); strings.HasPrefix(trimmed, "HostKeyAlias") { + return trimmed + } + } + return "" +}