Skip to content

Commit f6ba677

Browse files
committed
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 <sandbox-id>` 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.
1 parent 9eb09af commit f6ba677

2 files changed

Lines changed: 69 additions & 2 deletions

File tree

cmd/sandbox/editor.go

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -474,6 +474,13 @@ func hostLine(alias, name string) string {
474474
}
475475

476476
// renderSSHBlock builds the ~/.ssh/config stanza for the sandbox.
477+
//
478+
// HostKeyAlias pins each sandbox's known_hosts entry to its own id. Without it
479+
// every tunnel-mode box is keyed on the shared `127.0.0.1:22`, so the second
480+
// sandbox trips a CHANGED HOST KEY warning and clients that treat that as an
481+
// attack (Orca's SSH relay, for one) refuse to connect. VPN mode keys on the
482+
// overlay IP, which is also recycled between sandboxes, so it needs the same
483+
// pin.
477484
func renderSSHBlock(alias, mode, sandboxID, sbIP, gwHost string, gwPort int, user, identity, name string) (string, error) {
478485
begin := fmt.Sprintf(sshConfigBlockBegin, alias)
479486
end := fmt.Sprintf(sshConfigBlockEnd, alias)
@@ -483,13 +490,14 @@ func renderSSHBlock(alias, mode, sandboxID, sbIP, gwHost string, gwPort int, use
483490
return fmt.Sprintf(`%s
484491
Host %s
485492
HostName %s
493+
HostKeyAlias %s
486494
Port 22
487495
User %s
488496
IdentityFile %s
489497
StrictHostKeyChecking accept-new
490498
UserKnownHostsFile ~/.ssh/known_hosts_createos
491499
%s
492-
`, begin, host, sbIP, user, identity, end), nil
500+
`, begin, host, sbIP, sandboxID, user, identity, end), nil
493501
case "tunnel":
494502
// The inner `ssh -W` for the gateway needs its own
495503
// StrictHostKeyChecking + UserKnownHostsFile — it doesn't inherit
@@ -498,14 +506,15 @@ Host %s
498506
return fmt.Sprintf(`%s
499507
Host %s
500508
HostName 127.0.0.1
509+
HostKeyAlias %s
501510
Port 22
502511
User %s
503512
IdentityFile %s
504513
ProxyCommand ssh -o StrictHostKeyChecking=accept-new -o UserKnownHostsFile=~/.ssh/known_hosts_createos -W %%h:%%p %s@%s -p %d -i %s
505514
StrictHostKeyChecking accept-new
506515
UserKnownHostsFile ~/.ssh/known_hosts_createos
507516
%s
508-
`, begin, host, user, identity, sandboxID, gwHost, gwPort, identity, end), nil
517+
`, begin, host, sandboxID, user, identity, sandboxID, gwHost, gwPort, identity, end), nil
509518
default:
510519
return "", fmt.Errorf("unknown mode %q", mode)
511520
}

cmd/sandbox/editor_test.go

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
package sandbox
2+
3+
import (
4+
"strings"
5+
"testing"
6+
)
7+
8+
// Both transports must pin known_hosts to the sandbox id. Tunnel mode shares
9+
// 127.0.0.1:22 across every box and VPN mode recycles overlay IPs, so without
10+
// the pin the second sandbox reads as a changed host key.
11+
func TestRenderSSHBlockPinsHostKeyAliasToSandboxID(t *testing.T) {
12+
for _, mode := range []string{"tunnel", "vpn"} {
13+
t.Run(mode, func(t *testing.T) {
14+
block, err := renderSSHBlock(
15+
"sb-aaa", mode, "sb-aaa", "10.0.0.7",
16+
"gateway.sb.createos.sh", 2222, "root", "/keys/sb-aaa", "my-box",
17+
)
18+
if err != nil {
19+
t.Fatalf("renderSSHBlock: %v", err)
20+
}
21+
if !strings.Contains(block, "HostKeyAlias sb-aaa") {
22+
t.Errorf("block is missing the HostKeyAlias pin:\n%s", block)
23+
}
24+
})
25+
}
26+
}
27+
28+
func TestRenderSSHBlockGivesEachSandboxADistinctHostKeyIdentity(t *testing.T) {
29+
render := func(id string) string {
30+
block, err := renderSSHBlock(
31+
id, "tunnel", id, "10.0.0.7",
32+
"gateway.sb.createos.sh", 2222, "root", "/keys/"+id, "",
33+
)
34+
if err != nil {
35+
t.Fatalf("renderSSHBlock(%s): %v", id, err)
36+
}
37+
return block
38+
}
39+
if aliasLine(render("sb-aaa")) == aliasLine(render("sb-bbb")) {
40+
t.Error("two sandboxes share one host-key identity; the second will trip a changed-host-key error")
41+
}
42+
}
43+
44+
func TestRenderSSHBlockRejectsUnknownMode(t *testing.T) {
45+
if _, err := renderSSHBlock("sb-aaa", "carrier-pigeon", "sb-aaa", "10.0.0.7",
46+
"gateway.sb.createos.sh", 2222, "root", "/keys/sb-aaa", ""); err == nil {
47+
t.Error("expected an error for an unknown mode")
48+
}
49+
}
50+
51+
func aliasLine(block string) string {
52+
for _, line := range strings.Split(block, "\n") {
53+
if trimmed := strings.TrimSpace(line); strings.HasPrefix(trimmed, "HostKeyAlias") {
54+
return trimmed
55+
}
56+
}
57+
return ""
58+
}

0 commit comments

Comments
 (0)