a73x

ac374699

fix(cli): what you pinned stays pinned, and offline stays offline

a73x   2026-08-12 14:48

Commit message
fix(cli): what you pinned stays pinned, and offline stays offline

Four ways a client could end up worse off for being better configured.

`eitri ssh` probed /me whenever a token was in the environment, even with
the tenant pinned and the gate one this binary already knows. A token is a
credential, not an instruction to spend a round trip: with the console
unreachable, holding one broke a session that holding nothing would have
resolved offline. The probe now happens only once something is still
missing after everything this machine can answer alone.

`eitri init` accepted a .pub whose private half was never copied. It
registered the CA, wrote a signing path with nothing at it, and left every
later session dying in MintCert — while a second init reported the same
thing settled and wrote the same dangling path again, and every VM created
in between baked in trust for a CA nobody present could sign with. The
private half is now loaded the way minting will load it, before the .pub
is accepted; a rejected path is re-offered rather than ending the run.

init also wrote the plane's gate over a deliberate pin, the opposite of
the order `eitri ssh` resolves it in, so a pinned EITRI_GATE came back
un-pinned in the file and never appeared in the diff it confirmed. The pin
now outranks the plane's answer, which fills only an unpinned gate, and a
disagreement between the two is said at the prompt.

The settings page warned "No SSH CA registered" off the bare list length,
so a tenant with CAs saw it until the mount-time fetch landed. It waits
for userCAsLoaded, as the dashboard already does.

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 &lt;vm-name&gt;</code> 208 <code>EITRI_CA=~/.ssh/eitri_user_ca eitri ssh &lt;vm-name&gt;</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>