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 "" +}