返回 DeepSeek-Reasonix
sshconfig_test.go
根目录 / internal / remote / sshconfig_test.go
1 package remote
2
3 import (
4 "context"
5 "errors"
6 "os"
7 "os/exec"
8 "path/filepath"
9 "runtime"
10 "testing"
11
12 "reasonix/internal/config"
13 )
14
15 const sampleSSHConfig = `
16 Host gpu
17 HostName 203.0.113.9
18 User dev
19 Port 2222
20 IdentityFile ~/.ssh/gpu_ed25519
21
22 Host bastion-*
23 User jump
24
25 Host viajump
26 HostName 10.1.1.1
27 ProxyJump bastion-1
28
29 Match host somehost
30 User shouldbeignored
31 `
32
33 func writeSampleConfig(t *testing.T) string {
34 t.Helper()
35 p := filepath.Join(t.TempDir(), "config")
36 if err := os.WriteFile(p, []byte(sampleSSHConfig), 0o600); err != nil {
37 t.Fatal(err)
38 }
39 return p
40 }
41
42 func TestEffectiveSSHConfigUsesOpenSSHOutputAndKeepsAllIdentities(t *testing.T) {
43 src, err := LoadSSHConfig(writeSampleConfig(t))
44 if err != nil {
45 t.Fatal(err)
46 }
47 src.resolveOpenSSH = func(_ context.Context, path, alias string) ([]byte, error) {
48 if path != src.Path() || alias != "gpu" {
49 t.Fatalf("ssh -G request = path %q alias %q", path, alias)
50 }
51 return []byte("hostname resolved.example\nuser effective-user\nport 2207\nidentityfile ~/.ssh/first\nidentityfile ~/.ssh/second\nproxyjump jump-a,jump-b\nidentitiesonly yes\n"), nil
52 }
53
54 got := src.Effective("gpu")
55 if got.HostName != "resolved.example" || got.User != "effective-user" || got.Port != 2207 || got.ProxyJump != "jump-a,jump-b" || !got.IdentitiesOnly {
56 t.Fatalf("effective config = %+v", got)
57 }
58 home, err := os.UserHomeDir()
59 if err != nil {
60 t.Fatal(err)
61 }
62 wantIdentities := []string{filepath.Join(home, ".ssh", "first"), filepath.Join(home, ".ssh", "second")}
63 if len(got.IdentityFiles) != 2 || got.IdentityFiles[0] != wantIdentities[0] || got.IdentityFiles[1] != wantIdentities[1] {
64 t.Fatalf("identity files = %v", got.IdentityFiles)
65 }
66 }
67
68 func TestSSHConfigMatchExecUsesOpenSSHEvenWhenFallbackRejectsIt(t *testing.T) {
69 path := filepath.Join(t.TempDir(), "config")
70 contents := "Host matched-box\n HostName 192.0.2.10\nMatch exec \"true\"\n User matched-user\n"
71 if err := os.WriteFile(path, []byte(contents), 0o600); err != nil {
72 t.Fatal(err)
73 }
74 src, err := LoadSSHConfig(path)
75 if err != nil {
76 t.Fatalf("valid OpenSSH Match exec config was rejected: %v", err)
77 }
78 src.resolveOpenSSH = func(_ context.Context, gotPath, alias string) ([]byte, error) {
79 if gotPath != path || alias != "matched-box" {
80 t.Fatalf("ssh -G request = path %q alias %q", gotPath, alias)
81 }
82 return []byte("hostname 192.0.2.10\nuser matched-user\nport 22\nidentitiesonly no\n"), nil
83 }
84 aliases := src.Aliases()
85 if len(aliases) != 1 || aliases[0].Alias != "matched-box" {
86 t.Fatalf("Match exec aliases = %+v", aliases)
87 }
88 got, err := src.EffectiveWithError("matched-box")
89 if err != nil {
90 t.Fatal(err)
91 }
92 if got.User != "matched-user" {
93 t.Fatalf("Match exec effective config = %+v", got)
94 }
95 }
96
97 func TestSSHConfigMatchExecRealOpenSSH(t *testing.T) {
98 if runtime.GOOS == "windows" {
99 t.Skip("Match exec command is shell-dependent on Windows")
100 }
101 if _, err := exec.LookPath("ssh"); err != nil {
102 t.Skip("OpenSSH client is not installed")
103 }
104 path := filepath.Join(t.TempDir(), "config")
105 contents := "Host real-match-box\n HostName 192.0.2.11\nMatch exec \"true\"\n User real-match-user\n"
106 if err := os.WriteFile(path, []byte(contents), 0o600); err != nil {
107 t.Fatal(err)
108 }
109 src, err := LoadSSHConfig(path)
110 if err != nil {
111 t.Fatal(err)
112 }
113 got := src.Effective("real-match-box")
114 if got.HostName != "192.0.2.11" || got.User != "real-match-user" {
115 t.Fatalf("real ssh -G Match exec result = %+v", got)
116 }
117 }
118
119 func TestSSHConfigLookups(t *testing.T) {
120 src, err := LoadSSHConfig(writeSampleConfig(t))
121 if err != nil {
122 t.Fatal(err)
123 }
124 if got := src.HostName("gpu"); got != "203.0.113.9" {
125 t.Errorf("HostName(gpu) = %q", got)
126 }
127 if got := src.User("gpu"); got != "dev" {
128 t.Errorf("User(gpu) = %q", got)
129 }
130 if got := src.Port("gpu"); got != 2222 {
131 t.Errorf("Port(gpu) = %d", got)
132 }
133 if got := src.ProxyJump("viajump"); got != "bastion-1" {
134 t.Errorf("ProxyJump(viajump) = %q", got)
135 }
136 }
137
138 func TestSSHConfigAliasesSkipWildcards(t *testing.T) {
139 src, err := LoadSSHConfig(writeSampleConfig(t))
140 if err != nil {
141 t.Fatal(err)
142 }
143 aliases := src.Aliases()
144 names := map[string]bool{}
145 for _, a := range aliases {
146 names[a.Alias] = true
147 }
148 if !names["gpu"] || !names["viajump"] {
149 t.Fatalf("expected concrete aliases gpu/viajump, got %v", names)
150 }
151 if names["bastion-*"] {
152 t.Fatal("wildcard pattern surfaced as an importable alias")
153 }
154 }
155
156 func TestSSHConfigAliasesIncludeImportedFiles(t *testing.T) {
157 dir := t.TempDir()
158 included := filepath.Join(dir, "hosts.conf")
159 if err := os.WriteFile(included, []byte("Host included-box\n HostName 192.0.2.10\n"), 0o600); err != nil {
160 t.Fatal(err)
161 }
162 main := filepath.Join(dir, "config")
163 if err := os.WriteFile(main, []byte("Include "+included+"\nHost direct-box\n HostName 192.0.2.9\n"), 0o600); err != nil {
164 t.Fatal(err)
165 }
166 src, err := LoadSSHConfig(main)
167 if err != nil {
168 t.Fatal(err)
169 }
170 // Installed OpenSSH rejects config files whose ACL is wider than the owner,
171 // which t.TempDir() cannot guarantee on Windows. Include handling is the
172 // parser's contract here; the ssh -G path has its own stubbed tests.
173 src.resolveOpenSSH = nil
174 aliases := src.Aliases()
175 if len(aliases) != 2 || aliases[0].Alias != "included-box" || aliases[1].Alias != "direct-box" {
176 t.Fatalf("included aliases = %+v", aliases)
177 }
178 got, err := src.EffectiveWithError("included-box")
179 if err != nil {
180 t.Fatal(err)
181 }
182 if got.HostName != "192.0.2.10" {
183 t.Fatalf("included host was not resolved on demand: %+v", got)
184 }
185 }
186
187 func TestSSHAliasesDoNotResolveEveryHost(t *testing.T) {
188 src, err := LoadSSHConfig(writeSampleConfig(t))
189 if err != nil {
190 t.Fatal(err)
191 }
192 calls := 0
193 src.resolveOpenSSH = func(context.Context, string, string) ([]byte, error) {
194 calls++
195 return nil, nil
196 }
197 if got := src.Aliases(); len(got) != 2 {
198 t.Fatalf("aliases = %+v", got)
199 }
200 if calls != 0 {
201 t.Fatalf("alias discovery invoked ssh -G %d times", calls)
202 }
203 }
204
205 func TestEffectiveSSHConfigPreservesIdentityFileNone(t *testing.T) {
206 src, err := LoadSSHConfig(writeSampleConfig(t))
207 if err != nil {
208 t.Fatal(err)
209 }
210 src.resolveOpenSSH = func(context.Context, string, string) ([]byte, error) {
211 return []byte("hostname host.test\nidentityfile none\nidentityfile ~/.ssh/explicit\n"), nil
212 }
213 got, err := src.EffectiveWithError("gpu")
214 if err != nil {
215 t.Fatal(err)
216 }
217 if !got.IdentityFileNone || len(got.IdentityFiles) != 1 || filepath.Base(got.IdentityFiles[0]) != "explicit" {
218 t.Fatalf("identity settings = %+v", got)
219 }
220 }
221
222 func TestEmbeddedSSHConfigPreservesIdentityFileNone(t *testing.T) {
223 path := filepath.Join(t.TempDir(), "config")
224 if err := os.WriteFile(path, []byte("Host none-box\n IdentityFile none\n IdentitiesOnly yes\n"), 0o600); err != nil {
225 t.Fatal(err)
226 }
227 src, err := LoadSSHConfig(path)
228 if err != nil {
229 t.Fatal(err)
230 }
231 src.resolveOpenSSH = nil
232 got, err := src.EffectiveWithError("none-box")
233 if err != nil {
234 t.Fatal(err)
235 }
236 if !got.IdentityFileNone || len(got.IdentityFiles) != 0 || !got.IdentitiesOnly {
237 t.Fatalf("embedded identity settings = %+v", got)
238 }
239 }
240
241 func TestEffectiveSSHConfigPropagatesInstalledOpenSSHErrors(t *testing.T) {
242 src, err := LoadSSHConfig(writeSampleConfig(t))
243 if err != nil {
244 t.Fatal(err)
245 }
246 want := context.DeadlineExceeded
247 src.resolveOpenSSH = func(context.Context, string, string) ([]byte, error) { return nil, want }
248 if _, err := src.EffectiveWithError("gpu"); !errors.Is(err, want) {
249 t.Fatalf("EffectiveWithError error = %v, want %v", err, want)
250 }
251 }
252
253 func TestResolveHostPropagatesInstalledOpenSSHErrors(t *testing.T) {
254 src, err := LoadSSHConfig(writeSampleConfig(t))
255 if err != nil {
256 t.Fatal(err)
257 }
258 want := context.DeadlineExceeded
259 src.resolveOpenSSH = func(context.Context, string, string) ([]byte, error) { return nil, want }
260 cfg := config.Default()
261 if err := cfg.UpsertRemoteHost(config.RemoteHostEntry{Name: "gpu", Host: "gpu", UseSSHConfig: true}); err != nil {
262 t.Fatal(err)
263 }
264 if _, err := ResolveHost(cfg, "gpu", src); !errors.Is(err, want) {
265 t.Fatalf("ResolveHost error = %v, want %v", err, want)
266 }
267 }
268
269 func TestEffectiveSSHConfigFallsBackOnlyWhenOpenSSHUnavailable(t *testing.T) {
270 src, err := LoadSSHConfig(writeSampleConfig(t))
271 if err != nil {
272 t.Fatal(err)
273 }
274 src.resolveOpenSSH = func(context.Context, string, string) ([]byte, error) { return nil, exec.ErrNotFound }
275 got, err := src.EffectiveWithError("gpu")
276 if err != nil {
277 t.Fatal(err)
278 }
279 if got.HostName != "203.0.113.9" || got.User != "dev" {
280 t.Fatalf("embedded fallback = %+v", got)
281 }
282 }
283
284 func TestMissingOpenSSHExecutableIsDetectable(t *testing.T) {
285 t.Setenv("PATH", t.TempDir())
286 _, err := runOpenSSHEffectiveConfig(context.Background(), "", "missing-ssh-box")
287 if !errors.Is(err, exec.ErrNotFound) {
288 t.Fatalf("missing ssh error = %v, want exec.ErrNotFound", err)
289 }
290 }
291
292 func TestLoadUserSSHConfigUsesNormalOpenSSHConfigStack(t *testing.T) {
293 home := t.TempDir()
294 t.Setenv("HOME", home)
295 if runtime.GOOS == "windows" {
296 t.Setenv("USERPROFILE", home)
297 }
298 sshDir := filepath.Join(home, ".ssh")
299 if err := os.MkdirAll(sshDir, 0o700); err != nil {
300 t.Fatal(err)
301 }
302 if err := os.WriteFile(filepath.Join(sshDir, "config"), []byte("Host user-box\n HostName 192.0.2.55\n"), 0o600); err != nil {
303 t.Fatal(err)
304 }
305 src, err := LoadUserSSHConfig()
306 if err != nil {
307 t.Fatal(err)
308 }
309 src.resolveOpenSSH = func(_ context.Context, path, alias string) ([]byte, error) {
310 if path != "" || alias != "user-box" {
311 t.Fatalf("normal ssh -G request = path %q alias %q", path, alias)
312 }
313 return []byte("hostname 192.0.2.55\n"), nil
314 }
315 if _, err := src.EffectiveWithError("user-box"); err != nil {
316 t.Fatal(err)
317 }
318 }
319
320 func TestSSHConfigMissingFileIsEmpty(t *testing.T) {
321 src, err := LoadSSHConfig(filepath.Join(t.TempDir(), "does-not-exist"))
322 if err != nil {
323 t.Fatalf("missing file should not error: %v", err)
324 }
325 if len(src.Aliases()) != 0 {
326 t.Fatal("missing file yielded aliases")
327 }
328 if src.HostName("anything") != "" {
329 t.Fatal("missing file returned a hostname")
330 }
331 }
332
333 // TestResolveHostLayersSSHConfig checks the precedence: an explicit TOML field
334 // wins, but unset fields fall through to ~/.ssh/config when use_ssh_config.
335 func TestResolveHostLayersSSHConfig(t *testing.T) {
336 src, err := LoadSSHConfig(writeSampleConfig(t))
337 if err != nil {
338 t.Fatal(err)
339 }
340 cfg := config.Default()
341 if err := cfg.UpsertRemoteHost(config.RemoteHostEntry{
342 Name: "gpu",
343 Host: "gpu", // alias; ssh_config supplies the real HostName
344 User: "override",
345 UseSSHConfig: true,
346 }); err != nil {
347 t.Fatal(err)
348 }
349 h, err := ResolveHost(cfg, "gpu", src)
350 if err != nil {
351 t.Fatal(err)
352 }
353 if h.HostName != "203.0.113.9" {
354 t.Errorf("HostName not taken from ssh_config: %q", h.HostName)
355 }
356 if h.User != "override" {
357 t.Errorf("explicit TOML user should win: %q", h.User)
358 }
359 if h.Port != 2222 {
360 t.Errorf("Port not taken from ssh_config: %d", h.Port)
361 }
362 }
363
364 func TestResolveHostUsesPersistedHostAsTheSSHConfigLookupKey(t *testing.T) {
365 src, err := LoadSSHConfig(writeSampleConfig(t))
366 if err != nil {
367 t.Fatal(err)
368 }
369 // Make the test independent of the local OpenSSH executable.
370 src.resolveOpenSSH = nil
371
372 t.Run("legacy import remains a snapshot", func(t *testing.T) {
373 cfg := config.Default()
374 if err := cfg.UpsertRemoteHost(config.RemoteHostEntry{
375 Name: "gpu", Host: "203.0.113.9", User: "legacy-user", Port: 2201,
376 IdentityFile: "/legacy/id", UseSSHConfig: true,
377 }); err != nil {
378 t.Fatal(err)
379 }
380 h, err := ResolveHost(cfg, "gpu", src)
381 if err != nil {
382 t.Fatal(err)
383 }
384 if h.HostName != "203.0.113.9" || h.User != "legacy-user" || h.Port != 2201 || h.IdentityFile != "/legacy/id" {
385 t.Fatalf("legacy snapshot was redirected through its display label: %+v", h)
386 }
387 })
388
389 t.Run("display label does not replace saved lookup key", func(t *testing.T) {
390 cfg := config.Default()
391 if err := cfg.UpsertRemoteHost(config.RemoteHostEntry{
392 Name: "my-gpu-label", Host: "gpu", UseSSHConfig: true,
393 }); err != nil {
394 t.Fatal(err)
395 }
396 h, err := ResolveHost(cfg, "my-gpu-label", src)
397 if err != nil {
398 t.Fatal(err)
399 }
400 if h.HostName != "203.0.113.9" || h.User != "dev" {
401 t.Fatalf("saved Host alias was lost: %+v", h)
402 }
403 })
404
405 t.Run("display label collision cannot redirect saved alias", func(t *testing.T) {
406 cfg := config.Default()
407 if err := cfg.UpsertRemoteHost(config.RemoteHostEntry{
408 Name: "gpu", Host: "viajump", UseSSHConfig: true,
409 }); err != nil {
410 t.Fatal(err)
411 }
412 h, err := ResolveHost(cfg, "gpu", src)
413 if err != nil {
414 t.Fatal(err)
415 }
416 if h.HostName != "10.1.1.1" || h.ProxyJump[0] != "bastion-1" {
417 t.Fatalf("display label collision redirected the saved Host alias: %+v", h)
418 }
419 })
420 }
421
421 lines GO