ac374699
fix(cli): what you pinned stays pinned, and offline stays offline
a73x 2026-08-12 14:48
Commit message
internal/cli/init.go
| Old | New | ||
|---|---|---|---|
| @@ -110,11 +110,18 @@ func RunInit(ctx context.Context, e Env, cfgPath, token string, in io.Reader, ou | |||
| 110 | if err != nil { | 110 | if err != nil { |
| 111 | return err | 111 | return err |
| 112 | } | 112 | } |
| 113 | // The written gate is resolved in the order `eitri ssh` resolves it — a pin | ||
| 114 | // here (EITRI_GATE, or the gate a previous run wrote) outranks the plane's | ||
| 115 | // own answer, which fills only an unpinned one. Writing the plane's answer | ||
| 116 | // over a pin would un-pin it in the file: the next session would silently | ||
| 117 | // stop using the gate its user chose, and init would have been the thing | ||
| 118 | // that changed it. initIdentity says so at the prompt when the two differ. | ||
| 119 | // | ||
| 113 | // A plane that named no gate and is not the hosted one leaves this empty, | 120 | // A plane that named no gate and is not the hosted one leaves this empty, |
| 114 | // which is the honest record: initIdentity has already said what to set, and | 121 | // which is the honest record: initIdentity has already said what to set, and |
| 115 | // an unpinned gate makes `eitri ssh` say it again rather than send the | 122 | // an unpinned gate makes `eitri ssh` say it again rather than send the |
| 116 | // session through eitri.sh's gate to reach a guest that is nowhere near it. | 123 | // session through eitri.sh's gate to reach a guest that is nowhere near it. |
| 117 | gate, _ := gateFor(firstNonEmpty(me.SSHGate, e.Gate), e.URL) | 124 | gate, _ := gateFor(firstNonEmpty(e.Gate, me.SSHGate), e.URL) |
| 118 | next := Config{ | 125 | next := Config{ |
| 119 | URL: e.URL, | 126 | URL: e.URL, |
| 120 | Gate: gate, | 127 | Gate: gate, |
| @@ -173,6 +180,11 @@ func initIdentity(p *prompter, e Env, token string) (client.Me, string, error) { | |||
| 173 | // address belongs to the hosted plane alone — anywhere else it is a | 180 | // address belongs to the hosted plane alone — anywhere else it is a |
| 174 | // confidently wrong answer, so the remedy is named instead. | 181 | // confidently wrong answer, so the remedy is named instead. |
| 175 | switch { | 182 | switch { |
| 183 | case e.Gate != "" && me.SSHGate != "" && e.Gate != me.SSHGate: | ||
| 184 | // The pin wins (see RunInit), and a disagreement it silently won would | ||
| 185 | // be the one thing here the user could not have noticed: two plausible | ||
| 186 | // gates, one of them theirs, and no sign that the other exists. | ||
| 187 | p.sayf(" %s pins %s; this plane names %s — keeping your pin.", gatePinnedBy(), e.Gate, me.SSHGate) | ||
| 176 | case me.SSHGate != "": | 188 | case me.SSHGate != "": |
| 177 | p.sayf(" This plane's SSH gate is %s.", me.SSHGate) | 189 | p.sayf(" This plane's SSH gate is %s.", me.SSHGate) |
| 178 | case e.Gate != "": | 190 | case e.Gate != "": |
| @@ -188,6 +200,18 @@ func initIdentity(p *prompter, e Env, token string) (client.Me, string, error) { | |||
| 188 | return me, token, nil | 200 | return me, token, nil |
| 189 | } | 201 | } |
| 190 | 202 | ||
| 203 | // gatePinnedBy names where a pinned gate came from. FromEnv has already | ||
| 204 | // collapsed the two sources into one field because they rank the same; only | ||
| 205 | // this sentence needs to tell them apart, so that a user told their pin was | ||
| 206 | // kept knows which pin — the variable in this shell, or the file init is about | ||
| 207 | // to rewrite. | ||
| 208 | func gatePinnedBy() string { | ||
| 209 | if os.Getenv("EITRI_GATE") != "" { | ||
| 210 | return "EITRI_GATE" | ||
| 211 | } | ||
| 212 | return "the config file" | ||
| 213 | } | ||
| 214 | |||
| 191 | // initCA settles the tenant's SSH CA and returns the signing key `eitri ssh` | 215 | // initCA settles the tenant's SSH CA and returns the signing key `eitri ssh` |
| 192 | // should use. A guest trusts the CA set baked into it at create and nothing | 216 | // should use. A guest trusts the CA set baked into it at create and nothing |
| 193 | // rewrites that set afterwards, so this is the step that decides whether the | 217 | // rewrites that set afterwards, so this is the step that decides whether the |
| @@ -254,24 +278,38 @@ func initCA(ctx context.Context, p *prompter, e Env, token string, me client.Me) | |||
| 254 | // declined every offer. | 278 | // declined every offer. |
| 255 | func localCA(p *prompter, path string) (ssh.PublicKey, string, error) { | 279 | func localCA(p *prompter, path string) (ssh.PublicKey, string, error) { |
| 256 | pub, keyPath, err := caPublicKey(path) | 280 | pub, keyPath, err := caPublicKey(path) |
| 257 | if err == nil { | 281 | switch { |
| 282 | case err == nil: | ||
| 258 | return pub, keyPath, nil | 283 | return pub, keyPath, nil |
| 259 | } | 284 | case errors.Is(err, errNoPrivateHalf): |
| 260 | if !errors.Is(err, os.ErrNotExist) { | 285 | // A public half names the CA but cannot sign for it, so there is |
| 286 | // nothing usable here: say which file is missing, then offer as if the | ||
| 287 | // path had been empty. | ||
| 288 | p.sayf(" %v.", err) | ||
| 289 | case errors.Is(err, os.ErrNotExist): | ||
| 290 | p.sayf(" There is no signing key at %s.", path) | ||
| 291 | default: | ||
| 261 | return nil, "", err | 292 | return nil, "", err |
| 262 | } | 293 | } |
| 263 | 294 | ||
| 264 | p.sayf(" There is no signing key at %s.", path) | ||
| 265 | if p.confirm("Do you already have a CA key, elsewhere on this machine?") { | 295 | if p.confirm("Do you already have a CA key, elsewhere on this machine?") { |
| 266 | named := p.ask("path to it (the private key, or its .pub):", "") | 296 | // A path is typed, so it can be mistyped, and a rejected one is a |
| 267 | if named == "" { | 297 | // question still open rather than a run to abandon: say what is wrong |
| 268 | return nil, "", errors.New("no path given") | 298 | // with it and ask again. |
| 299 | for { | ||
| 300 | named := p.ask("path to it (the private key, or its .pub):", "") | ||
| 301 | if named == "" { | ||
| 302 | return nil, "", errors.New("no path given") | ||
| 303 | } | ||
| 304 | pub, keyPath, err := caPublicKey(expandHome(named)) | ||
| 305 | if err == nil { | ||
| 306 | return pub, keyPath, nil | ||
| 307 | } | ||
| 308 | p.sayf(" %v", err) | ||
| 309 | if !p.confirm("Name a different path?") { | ||
| 310 | return nil, "", err | ||
| 311 | } | ||
| 269 | } | 312 | } |
| 270 | pub, keyPath, err := caPublicKey(expandHome(named)) | ||
| 271 | if err != nil { | ||
| 272 | return nil, "", err | ||
| 273 | } | ||
| 274 | return pub, keyPath, nil | ||
| 275 | } | 313 | } |
| 276 | 314 | ||
| 277 | p.sayf(" A new ed25519 CA would be written, and nothing else touched:") | 315 | p.sayf(" A new ed25519 CA would be written, and nothing else touched:") |
| @@ -288,11 +326,24 @@ func localCA(p *prompter, path string) (ssh.PublicKey, string, error) { | |||
| 288 | return pub, path, nil | 326 | return pub, path, nil |
| 289 | } | 327 | } |
| 290 | 328 | ||
| 329 | // errNoPrivateHalf marks a .pub that names a CA this machine cannot sign for. | ||
| 330 | // It is a distinct condition from a missing file: the key the user pointed at | ||
| 331 | // is right there and readable, and only the half that matters is absent. | ||
| 332 | var errNoPrivateHalf = errors.New("the public half alone cannot sign") | ||
| 333 | |||
| 291 | // caPublicKey reads the public half of a CA at path, which a user may name | 334 | // caPublicKey reads the public half of a CA at path, which a user may name |
| 292 | // either way round: the signing key (whose public half is derived, exactly as | 335 | // either way round: the signing key (whose public half is derived, exactly as |
| 293 | // minting does) or the .pub beside it, which is what `eitri ca upload` takes. | 336 | // minting does) or the .pub beside it, which is what `eitri ca upload` takes. |
| 294 | // It returns the signing key's path alongside, since that is what the config | 337 | // It returns the signing key's path alongside, since that is what the config |
| 295 | // records — `eitri ssh` signs with it, not with the .pub. | 338 | // records — `eitri ssh` signs with it, not with the .pub. |
| 339 | // | ||
| 340 | // Naming the .pub is the one way to settle a CA whose signing half was never | ||
| 341 | // checked for, so it is checked for here. A .pub is the copyable half — it is | ||
| 342 | // what gets pasted into the console and scp'd between laptops — and one that | ||
| 343 | // arrives alone reads exactly like one that did not: init would register the | ||
| 344 | // CA, write its path into the config, and hand every later `eitri ssh` a | ||
| 345 | // signing key that is not there, while every VM created afterwards bakes in | ||
| 346 | // trust for a CA nobody present can sign with. | ||
| 296 | func caPublicKey(path string) (ssh.PublicKey, string, error) { | 347 | func caPublicKey(path string) (ssh.PublicKey, string, error) { |
| 297 | if strings.HasSuffix(path, ".pub") { | 348 | if strings.HasSuffix(path, ".pub") { |
| 298 | raw, err := os.ReadFile(path) | 349 | raw, err := os.ReadFile(path) |
| @@ -303,7 +354,17 @@ func caPublicKey(path string) (ssh.PublicKey, string, error) { | |||
| 303 | if perr != nil { | 354 | if perr != nil { |
| 304 | return nil, "", fmt.Errorf("%s: not an SSH public key: %w", path, perr) | 355 | return nil, "", fmt.Errorf("%s: not an SSH public key: %w", path, perr) |
| 305 | } | 356 | } |
| 306 | return pub, strings.TrimSuffix(path, ".pub"), nil | 357 | key := strings.TrimSuffix(path, ".pub") |
| 358 | // Loaded the way minting will load it, so a key that passes here is one | ||
| 359 | // `eitri ssh` can actually use — including the passphrase-protected | ||
| 360 | // case, which userPublicKey resolves without asking for the passphrase. | ||
| 361 | if _, err := userPublicKey(key); err != nil { | ||
| 362 | if errors.Is(err, os.ErrNotExist) { | ||
| 363 | return nil, "", fmt.Errorf("%s: %w — the private key would need to be at %s", path, errNoPrivateHalf, key) | ||
| 364 | } | ||
| 365 | return nil, "", err | ||
| 366 | } | ||
| 367 | return pub, key, nil | ||
| 307 | } | 368 | } |
| 308 | pub, err := userPublicKey(path) | 369 | pub, err := userPublicKey(path) |
| 309 | if err != nil { | 370 | if err != nil { |
internal/cli/init_test.go
| Old | New | ||
|---|---|---|---|
| @@ -3,6 +3,7 @@ package cli | |||
| 3 | import ( | 3 | import ( |
| 4 | "context" | 4 | "context" |
| 5 | "encoding/json" | 5 | "encoding/json" |
| 6 | "errors" | ||
| 6 | "net/http" | 7 | "net/http" |
| 7 | "net/http/httptest" | 8 | "net/http/httptest" |
| 8 | "os" | 9 | "os" |
| @@ -375,6 +376,140 @@ func TestInitKeepsAPinnedGateWhenThePlaneNamesNone(t *testing.T) { | |||
| 375 | } | 376 | } |
| 376 | } | 377 | } |
| 377 | 378 | ||
| 379 | // A pinned gate is a deliberate answer, and init is the command most likely to | ||
| 380 | // overwrite it: it is holding the plane's own answer, which for everyone who | ||
| 381 | // pinned nothing IS the right one. So the pin wins, in the same order `eitri | ||
| 382 | // ssh` resolves it — and because a file that already said the pin shows no diff | ||
| 383 | // when the pin is kept, the disagreement is said out loud instead. | ||
| 384 | func TestInitKeepsAPinnedGateAgainstAPlaneThatNamesAnother(t *testing.T) { | ||
| 385 | for _, tc := range []struct{ name, envGate, wantSource string }{ | ||
| 386 | {name: "pinned by the environment", envGate: "gate.mine:2222", wantSource: "EITRI_GATE"}, | ||
| 387 | {name: "pinned by a previous run's config", wantSource: "the config file"}, | ||
| 388 | } { | ||
| 389 | t.Run(tc.name, func(t *testing.T) { | ||
| 390 | plane := newFakePlane(t, meBody("acme", "gate.acme.test:2222")) | ||
| 391 | e, cfg := initEnv(t, plane) | ||
| 392 | // FromEnv collapses both sources into Env.Gate; only the sentence | ||
| 393 | // tells them apart, so only the variable differs here. | ||
| 394 | e.Gate = "gate.mine:2222" | ||
| 395 | if tc.envGate != "" { | ||
| 396 | t.Setenv("EITRI_GATE", tc.envGate) | ||
| 397 | } | ||
| 398 | |||
| 399 | out, err := runInitWith(t, e, cfg, "y\nn\ny\ny\n\ny\n") | ||
| 400 | if err != nil { | ||
| 401 | t.Fatalf("%v\n%s", err, out) | ||
| 402 | } | ||
| 403 | got, err := LoadConfig(cfg) | ||
| 404 | if err != nil { | ||
| 405 | t.Fatal(err) | ||
| 406 | } | ||
| 407 | if got.Gate != "gate.mine:2222" { | ||
| 408 | t.Errorf("gate = %q, want the pin kept", got.Gate) | ||
| 409 | } | ||
| 410 | want := tc.wantSource + " pins gate.mine:2222; this plane names gate.acme.test:2222 — keeping your pin." | ||
| 411 | if !strings.Contains(out, want) { | ||
| 412 | t.Errorf("the divergence must be shown, not silently resolved:\nwant %q\n%s", want, out) | ||
| 413 | } | ||
| 414 | }) | ||
| 415 | } | ||
| 416 | } | ||
| 417 | |||
| 418 | // The closed loop this breaks: a .pub copied to a second laptop, its | ||
| 419 | // fingerprint already registered by the laptop that made it. Accepting it would | ||
| 420 | // report the CA as settled, write a signing path with nothing at it, and leave | ||
| 421 | // every session dying in MintCert — while a second init reported the same thing | ||
| 422 | // again. A registration is not what lets a laptop connect; the private half is. | ||
| 423 | func TestInitRefusesARegisteredCAItCannotSignWith(t *testing.T) { | ||
| 424 | other, _, _, _, err := ssh.ParseAuthorizedKey([]byte(testUserCALine)) | ||
| 425 | if err != nil { | ||
| 426 | t.Fatal(err) | ||
| 427 | } | ||
| 428 | plane := newFakePlane(t, meBody("acme", "gate.acme.test:2222"), map[string]string{ | ||
| 429 | "fingerprint": ssh.FingerprintSHA256(other), "label": "the laptop that made it", "pubkey": testUserCALine, | ||
| 430 | }) | ||
| 431 | e, cfg := initEnv(t, plane) | ||
| 432 | |||
| 433 | // The public half, alone, where the user dropped it. | ||
| 434 | pubOnly := filepath.Join(t.TempDir(), "ca.pub") | ||
| 435 | if err := os.WriteFile(pubOnly, []byte(testUserCALine), 0o600); err != nil { | ||
| 436 | t.Fatal(err) | ||
| 437 | } | ||
| 438 | signing := strings.TrimSuffix(pubOnly, ".pub") | ||
| 439 | |||
| 440 | // identity, yes there is a key elsewhere, that .pub, no other path to try. | ||
| 441 | out, err := runInitWith(t, e, cfg, "y\ny\n"+pubOnly+"\nn\n") | ||
| 442 | if err == nil { | ||
| 443 | t.Fatalf("want a refusal:\n%s", out) | ||
| 444 | } | ||
| 445 | if !strings.Contains(out, "the public half alone cannot sign") || !strings.Contains(out, signing) { | ||
| 446 | t.Errorf("the transcript must name what is missing and where it belongs:\n%s", out) | ||
| 447 | } | ||
| 448 | if strings.Contains(out, "Already done") { | ||
| 449 | t.Errorf("a CA this laptop cannot sign with is not already done:\n%s", out) | ||
| 450 | } | ||
| 451 | if len(plane.uploaded) != 0 { | ||
| 452 | t.Errorf("%d uploads for a key that cannot sign", len(plane.uploaded)) | ||
| 453 | } | ||
| 454 | if _, statErr := os.Stat(cfg); !os.IsNotExist(statErr) { | ||
| 455 | t.Error("a config was written naming a CA with no signing half") | ||
| 456 | } | ||
| 457 | } | ||
| 458 | |||
| 459 | // A path is typed, so it can be mistyped, and one bad line should not end a run | ||
| 460 | // that has already proved a token and listed a tenant's CAs. | ||
| 461 | func TestInitReoffersAfterAPathThatIsNotAKey(t *testing.T) { | ||
| 462 | plane := newFakePlane(t, meBody("acme", "gate.acme.test:2222")) | ||
| 463 | e, cfg := initEnv(t, plane) | ||
| 464 | elsewhere := filepath.Join(t.TempDir(), "ca") | ||
| 465 | pub, err := newEd25519Key(elsewhere) | ||
| 466 | if err != nil { | ||
| 467 | t.Fatal(err) | ||
| 468 | } | ||
| 469 | |||
| 470 | // identity, have a key, a path that is not there, try again, the real one, | ||
| 471 | // register, default label, write. | ||
| 472 | out, err := runInitWith(t, e, cfg, "y\ny\n"+elsewhere+"-typo\ny\n"+elsewhere+"\ny\n\ny\n") | ||
| 473 | if err != nil { | ||
| 474 | t.Fatalf("%v\n%s", err, out) | ||
| 475 | } | ||
| 476 | if len(plane.uploaded) != 1 { | ||
| 477 | t.Fatalf("uploads = %d, want the second path's 1\n%s", len(plane.uploaded), out) | ||
| 478 | } | ||
| 479 | if got, want := plane.uploaded[0]["public_key"], string(ssh.MarshalAuthorizedKey(pub)); got != want { | ||
| 480 | t.Errorf("registered the wrong key:\n got %q\nwant %q", got, want) | ||
| 481 | } | ||
| 482 | cfgGot, err := LoadConfig(cfg) | ||
| 483 | if err != nil { | ||
| 484 | t.Fatal(err) | ||
| 485 | } | ||
| 486 | if cfgGot.CA != elsewhere { | ||
| 487 | t.Errorf("ca = %q, want the path that worked %q", cfgGot.CA, elsewhere) | ||
| 488 | } | ||
| 489 | } | ||
| 490 | |||
| 491 | // A .pub is the half that travels — pasted into the console, copied between | ||
| 492 | // machines — so it is the half that can arrive alone, and it looks identical | ||
| 493 | // either way. What separates them is whether anything here can sign. | ||
| 494 | func TestCAPublicKeyRefusesAPublicHalfAlone(t *testing.T) { | ||
| 495 | dir := t.TempDir() | ||
| 496 | keyPath := filepath.Join(dir, "ca") | ||
| 497 | if _, err := newEd25519Key(keyPath); err != nil { | ||
| 498 | t.Fatal(err) | ||
| 499 | } | ||
| 500 | if err := os.Remove(keyPath); err != nil { // only the .pub was ever copied here | ||
| 501 | t.Fatal(err) | ||
| 502 | } | ||
| 503 | |||
| 504 | _, _, err := caPublicKey(keyPath + ".pub") | ||
| 505 | if !errors.Is(err, errNoPrivateHalf) { | ||
| 506 | t.Fatalf("want a refusal naming the missing half, got %v", err) | ||
| 507 | } | ||
| 508 | if !strings.Contains(err.Error(), keyPath) { | ||
| 509 | t.Errorf("the error must name where the private key belongs (%s): %v", keyPath, err) | ||
| 510 | } | ||
| 511 | } | ||
| 512 | |||
| 378 | // A user may name their CA either way round — `eitri ca upload` takes the .pub, | 513 | // A user may name their CA either way round — `eitri ca upload` takes the .pub, |
| 379 | // so that is what fingers reach for — and either way the config records the | 514 | // so that is what fingers reach for — and either way the config records the |
| 380 | // signing key, which is the half `eitri ssh` needs. | 515 | // signing key, which is the half `eitri ssh` needs. |
internal/cli/sshcmd.go
| Old | New | ||
|---|---|---|---|
| @@ -62,21 +62,35 @@ func SSHArgv(e Env, vm string, extra []string) []string { | |||
| 62 | // is always needed; deriving it is what means the user never has to know or | 62 | // is always needed; deriving it is what means the user never has to know or |
| 63 | // type it, and with neither a tenant nor a token the name cannot be built at | 63 | // type it, and with neither a tenant nor a token the name cannot be built at |
| 64 | // all. | 64 | // all. |
| 65 | // | ||
| 66 | // The probe happens only when something is still missing after everything this | ||
| 67 | // machine can answer alone. A token in the environment is a credential, not an | ||
| 68 | // instruction to use it: holding one must never cost a round trip that not | ||
| 69 | // holding one would have skipped, or `eitri ssh` breaks offline for exactly the | ||
| 70 | // users who are best set up. | ||
| 65 | func resolvePlane(e Env) (Env, error) { | 71 | func resolvePlane(e Env) (Env, error) { |
| 66 | if e.Tenant != "" && e.Gate != "" { | 72 | if e.Tenant != "" { |
| 67 | return e, nil | 73 | if e.Gate != "" { |
| 74 | return e, nil | ||
| 75 | } | ||
| 76 | // A pinned tenant leaves only the gate, and against the hosted plane | ||
| 77 | // that is knowledge this binary already has (see gateFor). Anywhere | ||
| 78 | // else the answer has to be asked for, so fall through. | ||
| 79 | if gate, err := gateFor("", e.URL); err == nil { | ||
| 80 | e.Gate = gate | ||
| 81 | return e, nil | ||
| 82 | } | ||
| 68 | } | 83 | } |
| 69 | token := os.Getenv("EITRI_TOKEN") | 84 | token := os.Getenv("EITRI_TOKEN") |
| 70 | if token == "" { | 85 | if token == "" { |
| 71 | if e.Tenant == "" { | 86 | if e.Tenant == "" { |
| 72 | return e, fmt.Errorf("run 'eitri init', or set EITRI_TOKEN (a personal access token) or EITRI_TENANT — the connect name needs your tenant") | 87 | return e, fmt.Errorf("run 'eitri init', or set EITRI_TOKEN (a personal access token) or EITRI_TENANT — the connect name needs your tenant") |
| 73 | } | 88 | } |
| 74 | gate, err := gateFor("", e.URL) | 89 | // The tenant is pinned, the gate is not, and there is no credential to |
| 75 | if err != nil { | 90 | // ask the plane with: gateFor's refusal names the two settings that fix |
| 76 | return e, err | 91 | // it, and is the whole answer here. |
| 77 | } | 92 | _, err := gateFor("", e.URL) |
| 78 | e.Gate = gate | 93 | return e, err |
| 79 | return e, nil | ||
| 80 | } | 94 | } |
| 81 | // client.Me is deliberately context-free — its own do-timeout bounds this | 95 | // client.Me is deliberately context-free — its own do-timeout bounds this |
| 82 | // one synchronous probe (see the method's doc), so there is no ctx to thread. | 96 | // one synchronous probe (see the method's doc), so there is no ctx to thread. |
| @@ -100,16 +114,17 @@ func resolvePlane(e Env) (Env, error) { | |||
| 100 | return e, nil | 114 | return e, nil |
| 101 | } | 115 | } |
| 102 | 116 | ||
| 103 | // gateFor settles the gate when nothing on this machine pinned one: the plane's | 117 | // gateFor settles the gate from whatever has already named one — a pin here or |
| 104 | // own answer from /me, else the hosted address — and that last rung only when | 118 | // the plane's own answer on /me, whichever the caller ranks first — else the |
| 105 | // the plane IS the hosted one. Against a self-hosted plane the hosted gate is | 119 | // hosted address, and that last rung only when the plane IS the hosted one. |
| 106 | // not a weak default but a confidently wrong one: the session would hop through | 120 | // Against a self-hosted plane the hosted gate is not a weak default but a |
| 107 | // eitri.sh to reach a VM that lives nowhere near it, and fail as a host-key | 121 | // confidently wrong one: the session would hop through eitri.sh to reach a VM |
| 108 | // refusal rather than as the missing configuration it is. So it says which | 122 | // that lives nowhere near it, and fail as a host-key refusal rather than as the |
| 109 | // configuration is missing, on the server side and on this one. | 123 | // missing configuration it is. So it says which configuration is missing, on |
| 110 | func gateFor(meGate, url string) (string, error) { | 124 | // the server side and on this one. |
| 111 | if meGate != "" { | 125 | func gateFor(named, url string) (string, error) { |
| 112 | return meGate, nil | 126 | if named != "" { |
| 127 | return named, nil | ||
| 113 | } | 128 | } |
| 114 | if hostedPlane(url) { | 129 | if hostedPlane(url) { |
| 115 | return defaultGate, nil | 130 | return defaultGate, nil |
internal/cli/sshcmd_test.go
| Old | New | ||
|---|---|---|---|
| @@ -195,6 +195,41 @@ func TestResolvePlaneFullyPinnedShortCircuits(t *testing.T) { | |||
| 195 | } | 195 | } |
| 196 | } | 196 | } |
| 197 | 197 | ||
| 198 | // A token is a credential, not an instruction to spend a round trip. Holding | ||
| 199 | // one must never cost a call that not holding one would have skipped: the | ||
| 200 | // tokenless path below resolves a pinned tenant on the hosted plane offline, so | ||
| 201 | // this one has to as well, or `eitri ssh` breaks on a plane the user cannot | ||
| 202 | // currently reach — and breaks it for exactly the laptops that are best set up. | ||
| 203 | func TestResolvePlaneATokenCostsNoNetworkCall(t *testing.T) { | ||
| 204 | t.Setenv("EITRI_TOKEN", "tok") | ||
| 205 | |||
| 206 | // There is no test server to point the hosted case at: the hosted rung is | ||
| 207 | // scoped to the hosted URL by name (see gateFor), so a /me here would leave | ||
| 208 | // the machine for console.eitri.sh. Which is what makes the assertion | ||
| 209 | // sound — that call answers 401 to this token, and finds no network at all | ||
| 210 | // in CI, so a regression fails here either way rather than passing quietly. | ||
| 211 | e := testEnv() | ||
| 212 | e.URL, e.Gate, e.Tenant = defaultURL, "", "team" | ||
| 213 | got, err := resolvePlane(e) | ||
| 214 | if err != nil { | ||
| 215 | t.Fatalf("a pinned tenant on the hosted plane must resolve without asking: %v", err) | ||
| 216 | } | ||
| 217 | if got.Tenant != "team" || got.Gate != defaultGate { | ||
| 218 | t.Errorf("resolved = %+v, want tenant team on %s", got, defaultGate) | ||
| 219 | } | ||
| 220 | |||
| 221 | // With the gate pinned too there IS something to prove it against: a live | ||
| 222 | // server that fails this test if a single request reaches it. | ||
| 223 | untouched := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) { | ||
| 224 | t.Error("a tenant and gate that resolve here must reach the plane for nothing") | ||
| 225 | })) | ||
| 226 | defer untouched.Close() | ||
| 227 | e.URL, e.Gate = untouched.URL, "gate.example:2222" | ||
| 228 | if got, err = resolvePlane(e); err != nil || got.Gate != "gate.example:2222" { | ||
| 229 | t.Errorf("resolved = %+v, err = %v", got, err) | ||
| 230 | } | ||
| 231 | } | ||
| 232 | |||
| 198 | // A pinned tenant with no credential still needs a gate, and there is nobody to | 233 | // A pinned tenant with no credential still needs a gate, and there is nobody to |
| 199 | // ask. Against the hosted plane the assumption is made without a call; against | 234 | // ask. Against the hosted plane the assumption is made without a call; against |
| 200 | // any other one there is nothing to assume, and the error says what to set. | 235 | // any other one there is nothing to assume, and the error says what to set. |
web/src/routes/settings/+page.svelte
| Old | New | ||
|---|---|---|---|
| @@ -199,7 +199,7 @@ | |||
| 199 | <h2>SSH Access ({fleet.userCAs.length})</h2> | 199 | <h2>SSH Access ({fleet.userCAs.length})</h2> |
| 200 | </div> | 200 | </div> |
| 201 | 201 | ||
| 202 | {#if fleet.userCAs.length === 0} | 202 | {#if fleet.userCAsLoaded && fleet.userCAs.length === 0} |
| 203 | <p class="hint">No SSH CA registered—a VM cannot be created until the tenant has one.</p> | 203 | <p class="hint">No SSH CA registered—a VM cannot be created until the tenant has one.</p> |
| 204 | <div class="enroll"> | 204 | <div class="enroll"> |
| 205 | Generate a CA, paste its public key below, then connect: | 205 | Generate a CA, paste its public key below, then connect: |
| @@ -207,7 +207,7 @@ | |||
| 207 | <code># paste ~/.ssh/eitri_user_ca.pub in the field below, then:</code> | 207 | <code># paste ~/.ssh/eitri_user_ca.pub in the field below, then:</code> |
| 208 | <code>EITRI_CA=~/.ssh/eitri_user_ca eitri ssh <vm-name></code> | 208 | <code>EITRI_CA=~/.ssh/eitri_user_ca eitri ssh <vm-name></code> |
| 209 | </div> | 209 | </div> |
| 210 | {:else} | 210 | {:else if fleet.userCAs.length > 0} |
| 211 | <div class="scroll"> | 211 | <div class="scroll"> |
| 212 | <table> | 212 | <table> |
| 213 | <thead> | 213 | <thead> |