323e2c24
feat(web): the VM page hands you the command that reaches the guest
a73x 2026-08-09 19:47
Commit message
docs/openapi.json
| Old | New | ||
|---|---|---|---|
| @@ -490,12 +490,16 @@ | |||
| 490 | "email": { | 490 | "email": { |
| 491 | "type": "string" | 491 | "type": "string" |
| 492 | }, | 492 | }, |
| 493 | "ssh_gate": { | ||
| 494 | "type": "string" | ||
| 495 | }, | ||
| 493 | "tenant": { | 496 | "tenant": { |
| 494 | "type": "string" | 497 | "type": "string" |
| 495 | } | 498 | } |
| 496 | }, | 499 | }, |
| 497 | "required": [ | 500 | "required": [ |
| 498 | "email", | 501 | "email", |
| 502 | "ssh_gate", | ||
| 499 | "tenant" | 503 | "tenant" |
| 500 | ], | 504 | ], |
| 501 | "type": "object" | 505 | "type": "object" |
| @@ -1271,7 +1275,7 @@ | |||
| 1271 | "patToken": [] | 1275 | "patToken": [] |
| 1272 | } | 1276 | } |
| 1273 | ], | 1277 | ], |
| 1274 | "summary": "The signed-in identity: the caller's tenant handle and bound email." | 1278 | "summary": "The signed-in identity: the caller's tenant handle, bound email, and the plane's SSH jump-gate address (empty when it runs no gate)." |
| 1275 | } | 1279 | } |
| 1276 | }, | 1280 | }, |
| 1277 | "/api/v1/ssh-ca": { | 1281 | "/api/v1/ssh-ca": { |
docs/ssh-access.md
| Old | New | ||
|---|---|---|---|
| @@ -181,12 +181,17 @@ GATE_HOST=${EITRI_GATE%%:*}; GATE_PORT=${EITRI_GATE##*:} | |||
| 181 | [ "$GATE_PORT" = "$EITRI_GATE" ] && GATE_PORT=22 | 181 | [ "$GATE_PORT" = "$EITRI_GATE" ] && GATE_PORT=22 |
| 182 | KH=~/.ssh/eitri_known_hosts | 182 | KH=~/.ssh/eitri_known_hosts |
| 183 | ssh \ | 183 | ssh \ |
| 184 | -o "ProxyCommand=ssh -W %h:%p -o StrictHostKeyChecking=yes -o UserKnownHostsFile=$KH -p $GATE_PORT ubuntu@$GATE_HOST" \ | 184 | -o "ProxyCommand=ssh -W %h:%p -o StrictHostKeyChecking=yes -o UserKnownHostsFile='$KH' -p $GATE_PORT ubuntu@$GATE_HOST" \ |
| 185 | -o StrictHostKeyChecking=yes \ | 185 | -o StrictHostKeyChecking=yes \ |
| 186 | -o "UserKnownHostsFile=$KH" \ | 186 | -o "UserKnownHostsFile=$KH" \ |
| 187 | ubuntu@<tenant>.<vm-name> | 187 | ubuntu@<tenant>.<vm-name> |
| 188 | ``` | 188 | ``` |
| 189 | 189 | ||
| 190 | The inner `UserKnownHostsFile` is quoted twice over. The outer shell expands | ||
| 191 | `$KH` into the `ProxyCommand` string, and ssh then runs that string through a | ||
| 192 | shell of its own—so the single quotes are what survive to the inner shell and | ||
| 193 | keep a `$HOME` with a space in it one word. | ||
| 194 | |||
| 190 | The gate's cert principal is `ssh_gate_domain` (so `$GATE_HOST` must match it), | 195 | The gate's cert principal is `ssh_gate_domain` (so `$GATE_HOST` must match it), |
| 191 | and each VM's cert principal is its `<tenant>.<vm-name>` connect name (so the | 196 | and each VM's cert principal is its `<tenant>.<vm-name>` connect name (so the |
| 192 | inner `ubuntu@<tenant>.<vm-name>` host must match). Because verification is by | 197 | inner `ubuntu@<tenant>.<vm-name>` host must match). Because verification is by |
internal/server/api/api.go
| Old | New | ||
|---|---|---|---|
| @@ -81,6 +81,7 @@ type API struct { | |||
| 81 | snap *snapshotHub // central SSE snapshot: one marshal fanned to all clients | 81 | snap *snapshotHub // central SSE snapshot: one marshal fanned to all clients |
| 82 | console ConsoleDialer // nil until main wires syncsvc (SetConsoleDialer) | 82 | console ConsoleDialer // nil until main wires syncsvc (SetConsoleDialer) |
| 83 | sshCAKey string // eitri CA public key (authorized_keys form) served by GET /api/v1/ssh-ca; empty ⇒ gate off | 83 | sshCAKey string // eitri CA public key (authorized_keys form) served by GET /api/v1/ssh-ca; empty ⇒ gate off |
| 84 | sshGate string // gate hop address (host:port) served by GET /api/v1/me; empty ⇒ gate off | ||
| 84 | release ReleaseSource // nil ⇒ release discovery disabled | 85 | release ReleaseSource // nil ⇒ release discovery disabled |
| 85 | upgrader AgentUpgrader // nil until main wires syncsvc (SetAgentUpgrader) | 86 | upgrader AgentUpgrader // nil until main wires syncsvc (SetAgentUpgrader) |
| 86 | auth *authFlow // OIDC sign-in relying party (mounted via AuthHandler, outside /api/) | 87 | auth *authFlow // OIDC sign-in relying party (mounted via AuthHandler, outside /api/) |
internal/server/api/routes.go
| Old | New | ||
|---|---|---|---|
| @@ -400,7 +400,7 @@ var routeTable = []Route{ | |||
| 400 | Kind: KindJSON, | 400 | Kind: KindJSON, |
| 401 | Response: (*types.Me)(nil), | 401 | Response: (*types.Me)(nil), |
| 402 | Success: http.StatusOK, | 402 | Success: http.StatusOK, |
| 403 | Doc: "The signed-in identity: the caller's tenant handle and bound email.", | 403 | Doc: "The signed-in identity: the caller's tenant handle, bound email, and the plane's SSH jump-gate address (empty when it runs no gate).", |
| 404 | handler: (*API).handleMe, | 404 | handler: (*API).handleMe, |
| 405 | }, | 405 | }, |
| 406 | { | 406 | { |
internal/server/api/sshcert.go
| Old | New | ||
|---|---|---|---|
| @@ -16,6 +16,15 @@ import ( | |||
| 16 | // any credential. | 16 | // any credential. |
| 17 | func (a *API) SetSSHCAAuthorizedKey(line string) { a.sshCAKey = line } | 17 | func (a *API) SetSSHCAAuthorizedKey(line string) { a.sshCAKey = line } |
| 18 | 18 | ||
| 19 | // SetSSHGate publishes the address clients dial for the jump-gate hop | ||
| 20 | // (host:port), served on GET /api/v1/me beside the caller's tenant. Called | ||
| 21 | // once by main alongside SetSSHCAAuthorizedKey when the gate is enabled; empty | ||
| 22 | // ⇒ the plane names no gate, and a client has nothing to connect through. The | ||
| 23 | // host part MUST be the gate's host-certificate principal: a client verifies | ||
| 24 | // the name it dialed against that certificate, so any other spelling of the | ||
| 25 | // same machine fails host verification by design. | ||
| 26 | func (a *API) SetSSHGate(addr string) { a.sshGate = addr } | ||
| 27 | |||
| 19 | // handleSSHCA returns the eitri HOST CA public key so a client can write a | 28 | // handleSSHCA returns the eitri HOST CA public key so a client can write a |
| 20 | // `@cert-authority * <ca>` known_hosts entry and verify the gate and every VM | 29 | // `@cert-authority * <ca>` known_hosts entry and verify the gate and every VM |
| 21 | // host key by certificate instead of TOFU. Unauthenticated (it is public | 30 | // host key by certificate instead of TOFU. Unauthenticated (it is public |
internal/server/api/testdata/me.golden.json
| Old | New | ||
|---|---|---|---|
| @@ -1,4 +1,5 @@ | |||
| 1 | { | 1 | { |
| 2 | "email": "alex@emery.xyz", | 2 | "email": "alex@emery.xyz", |
| 3 | "ssh_gate": "gate.eitri.sh:2222", | ||
| 3 | "tenant": "alex" | 4 | "tenant": "alex" |
| 4 | } | 5 | } |
internal/server/api/tokens.go
| Old | New | ||
|---|---|---|---|
| @@ -9,10 +9,11 @@ import ( | |||
| 9 | "github.com/a73x/eitri/internal/server/api/types" | 9 | "github.com/a73x/eitri/internal/server/api/types" |
| 10 | ) | 10 | ) |
| 11 | 11 | ||
| 12 | // handleMe returns the signed-in identity: the caller's tenant handle and the | 12 | // handleMe returns the signed-in identity: the caller's tenant handle, the |
| 13 | // email bound to the tenant row. It works identically for a PAT- or | 13 | // email bound to the tenant row, and the plane's jump-gate address (empty when |
| 14 | // session-authenticated caller — both resolve to a tenant, and the email comes | 14 | // it runs no gate). It works identically for a PAT- or session-authenticated |
| 15 | // off that tenant's row. The default tenant, until claimed, has an empty email. | 15 | // caller — both resolve to a tenant, and the email comes off that tenant's row. |
| 16 | // The default tenant, until claimed, has an empty email. | ||
| 16 | func (a *API) handleMe(w http.ResponseWriter, r *http.Request) { | 17 | func (a *API) handleMe(w http.ResponseWriter, r *http.Request) { |
| 17 | tenant := principalFromContext(r).Tenant | 18 | tenant := principalFromContext(r).Tenant |
| 18 | tn, ok, err := a.st.TenantByID(tenant) | 19 | tn, ok, err := a.st.TenantByID(tenant) |
| @@ -26,7 +27,7 @@ func (a *API) handleMe(w http.ResponseWriter, r *http.Request) { | |||
| 26 | http.Error(w, "sign in required", http.StatusUnauthorized) | 27 | http.Error(w, "sign in required", http.StatusUnauthorized) |
| 27 | return | 28 | return |
| 28 | } | 29 | } |
| 29 | writeJSON(w, http.StatusOK, types.Me{Email: tn.Email, Tenant: tn.ID}) | 30 | writeJSON(w, http.StatusOK, types.Me{Email: tn.Email, SSHGate: a.sshGate, Tenant: tn.ID}) |
| 30 | } | 31 | } |
| 31 | 32 | ||
| 32 | // handleCreateAPIToken mints a tenant-scoped personal access token and returns | 33 | // handleCreateAPIToken mints a tenant-scoped personal access token and returns |
internal/server/api/tokens_test.go
| Old | New | ||
|---|---|---|---|
| @@ -13,7 +13,7 @@ import ( | |||
| 13 | // email bound to that tenant's row, over BOTH credential paths (PAT and | 13 | // email bound to that tenant's row, over BOTH credential paths (PAT and |
| 14 | // session). | 14 | // session). |
| 15 | func TestMeIdentity(t *testing.T) { | 15 | func TestMeIdentity(t *testing.T) { |
| 16 | ts, st, _, _, _ := newServer(t) | 16 | ts, st, _, _, a := newServer(t) |
| 17 | 17 | ||
| 18 | t.Run("PAT returns the tenant and its bound email", func(t *testing.T) { | 18 | t.Run("PAT returns the tenant and its bound email", func(t *testing.T) { |
| 19 | resp := do(t, "GET", ts.URL+"/api/v1/me", testPAT, nil) | 19 | resp := do(t, "GET", ts.URL+"/api/v1/me", testPAT, nil) |
| @@ -45,6 +45,25 @@ func TestMeIdentity(t *testing.T) { | |||
| 45 | assert.Equal(t, tn.ID, me.Tenant) | 45 | assert.Equal(t, tn.ID, me.Tenant) |
| 46 | assert.Equal(t, "alex@emery.xyz", me.Email) | 46 | assert.Equal(t, "alex@emery.xyz", me.Email) |
| 47 | }) | 47 | }) |
| 48 | |||
| 49 | // The gate address is the plane's half of a connect name: without it a | ||
| 50 | // client holds a tenant and a VM name but nothing to hop through. | ||
| 51 | t.Run("a plane with no gate names none", func(t *testing.T) { | ||
| 52 | resp := do(t, "GET", ts.URL+"/api/v1/me", testPAT, nil) | ||
| 53 | require.Equal(t, 200, resp.StatusCode) | ||
| 54 | var me types.Me | ||
| 55 | require.NoError(t, json.NewDecoder(resp.Body).Decode(&me)) | ||
| 56 | assert.Empty(t, me.SSHGate) | ||
| 57 | }) | ||
| 58 | |||
| 59 | t.Run("a plane with a gate names it", func(t *testing.T) { | ||
| 60 | a.SetSSHGate("gate.eitri.sh:2222") | ||
| 61 | resp := do(t, "GET", ts.URL+"/api/v1/me", testPAT, nil) | ||
| 62 | require.Equal(t, 200, resp.StatusCode) | ||
| 63 | var me types.Me | ||
| 64 | require.NoError(t, json.NewDecoder(resp.Body).Decode(&me)) | ||
| 65 | assert.Equal(t, "gate.eitri.sh:2222", me.SSHGate) | ||
| 66 | }) | ||
| 48 | } | 67 | } |
| 49 | 68 | ||
| 50 | // TestAPITokenLifecycle exercises mint → list → revoke through the HTTP surface: | 69 | // TestAPITokenLifecycle exercises mint → list → revoke through the HTTP surface: |
internal/server/api/types/types.go
| Old | New | ||
|---|---|---|---|
| @@ -338,8 +338,14 @@ type UserCA struct { | |||
| 338 | // who is logged in and the settings page. Response-side only. Fields ordered | 338 | // who is logged in and the settings page. Response-side only. Fields ordered |
| 339 | // alphabetically by json key (see the map-compatibility note above). | 339 | // alphabetically by json key (see the map-compatibility note above). |
| 340 | type Me struct { | 340 | type Me struct { |
| 341 | Email string `json:"email"` | 341 | Email string `json:"email"` |
| 342 | Tenant string `json:"tenant"` | 342 | // SSHGate is the address a client dials for the jump-gate hop, host:port, |
| 343 | // the same value EITRI_GATE takes. It rides the identity because a connect | ||
| 344 | // name is half identity (the tenant below) and half plane (this), and no | ||
| 345 | // caller needs one without the other. Empty when the plane runs no gate — | ||
| 346 | // there is then no hop to name, and the console offers no connect recipe. | ||
| 347 | SSHGate string `json:"ssh_gate"` | ||
| 348 | Tenant string `json:"tenant"` | ||
| 343 | } | 349 | } |
| 344 | 350 | ||
| 345 | // CreateAPITokenRequest is the POST /api/v1/tokens body: mint a personal access | 351 | // CreateAPITokenRequest is the POST /api/v1/tokens body: mint a personal access |
internal/server/api/wire_golden_test.go
| Old | New | ||
|---|---|---|---|
| @@ -232,8 +232,9 @@ func TestWireGolden(t *testing.T) { | |||
| 232 | }}) | 232 | }}) |
| 233 | 233 | ||
| 234 | goldenCheck(t, "me", types.Me{ | 234 | goldenCheck(t, "me", types.Me{ |
| 235 | Email: "alex@emery.xyz", | 235 | Email: "alex@emery.xyz", |
| 236 | Tenant: "alex", | 236 | SSHGate: "gate.eitri.sh:2222", |
| 237 | Tenant: "alex", | ||
| 237 | }) | 238 | }) |
| 238 | 239 | ||
| 239 | goldenCheck(t, "create-api-token-request", types.CreateAPITokenRequest{ | 240 | goldenCheck(t, "create-api-token-request", types.CreateAPITokenRequest{ |
internal/server/boot/sshgate.go
| Old | New | ||
|---|---|---|---|
| @@ -53,16 +53,67 @@ func setupSSHGate(cfg serverconfig.Config, kek []byte) (*sshGateSetup, error) { | |||
| 53 | return &sshGateSetup{ca: sshGate, listen: cfg.SSHListen, domain: cfg.SSHGateDomain}, nil | 53 | return &sshGateSetup{ca: sshGate, listen: cfg.SSHListen, domain: cfg.SSHGateDomain}, nil |
| 54 | } | 54 | } |
| 55 | 55 | ||
| 56 | // wireAPI publishes the HOST CA: when the jump gate is enabled, eitri serves | 56 | // wireAPI publishes what a client needs to reach a guest: the HOST CA pubkey |
| 57 | // the host CA pubkey via GET /api/v1/ssh-ca so a client can pin | 57 | // via GET /api/v1/ssh-ca, so it can pin `@cert-authority` and verify the gate |
| 58 | // `@cert-authority` and verify the gate and every VM by certificate. eitri | 58 | // and every VM by certificate, and the gate's own address via GET /api/v1/me, |
| 59 | // never mints user certs — user CAs are BYO per-tenant (uploaded, never held | 59 | // so it knows what to hop through. eitri never mints user certs — user CAs are |
| 60 | // here). Left unwired when the gate is off, so the ssh-ca endpoint 404s. | 60 | // BYO per-tenant (uploaded, never held here). Left unwired when the gate is |
| 61 | // off, so the ssh-ca endpoint 404s and /me names no gate. | ||
| 61 | func (g *sshGateSetup) wireAPI(a *api.API) { | 62 | func (g *sshGateSetup) wireAPI(a *api.API) { |
| 62 | if g == nil { | 63 | if g == nil { |
| 63 | return | 64 | return |
| 64 | } | 65 | } |
| 65 | a.SetSSHCAAuthorizedKey(string(g.ca.HostCAAuthorizedKey())) | 66 | a.SetSSHCAAuthorizedKey(string(g.ca.HostCAAuthorizedKey())) |
| 67 | a.SetSSHGate(g.gateAddr()) | ||
| 68 | } | ||
| 69 | |||
| 70 | // gateDomain is the hostname clients dial the gate as, and therefore the one | ||
| 71 | // principal on its host certificate: the configured ssh_gate_domain, else the | ||
| 72 | // host part of ssh_listen, else localhost. | ||
| 73 | func (g *sshGateSetup) gateDomain() string { | ||
| 74 | if g.domain != "" { | ||
| 75 | return g.domain | ||
| 76 | } | ||
| 77 | if h, _, err := net.SplitHostPort(g.listen); err == nil && h != "" { | ||
| 78 | return h | ||
| 79 | } | ||
| 80 | return "localhost" | ||
| 81 | } | ||
| 82 | |||
| 83 | // gateAddr is the full address a client dials for the gate hop — the value | ||
| 84 | // EITRI_GATE takes and the one the console prints in its connect recipes — or | ||
| 85 | // "" when this plane has no name worth handing out, in which case it publishes | ||
| 86 | // none and the console offers no recipe. | ||
| 87 | // | ||
| 88 | // The host part is the configured ssh_gate_domain, else the host ssh_listen | ||
| 89 | // binds. A wildcard or empty bind (0.0.0.0, ::, ":2222") is where that stops: | ||
| 90 | // it says which interfaces to accept on, not what to call the machine, and a | ||
| 91 | // recipe built from one dials the wrong host from everywhere but this one. | ||
| 92 | // gateDomain still answers "localhost" there, because the host certificate has | ||
| 93 | // to name something, but a certificate nobody can reach is not an address. | ||
| 94 | // | ||
| 95 | // Whatever it returns, the host part is gateDomain and nothing else: a client | ||
| 96 | // verifies the name it dialed against that certificate, so any other spelling | ||
| 97 | // of the same machine is a hard verification failure, by design. | ||
| 98 | func (g *sshGateSetup) gateAddr() string { | ||
| 99 | host, port := g.domain, "22" | ||
| 100 | bindHost, bindPort, err := net.SplitHostPort(g.listen) | ||
| 101 | if err == nil && bindPort != "" { | ||
| 102 | port = bindPort | ||
| 103 | } | ||
| 104 | if host == "" { | ||
| 105 | if err != nil || unspecifiedHost(bindHost) { | ||
| 106 | return "" | ||
| 107 | } | ||
| 108 | host = bindHost | ||
| 109 | } | ||
| 110 | return net.JoinHostPort(host, port) | ||
| 111 | } | ||
| 112 | |||
| 113 | // unspecifiedHost reports whether h names every interface rather than one host. | ||
| 114 | func unspecifiedHost(h string) bool { | ||
| 115 | ip := net.ParseIP(h) | ||
| 116 | return h == "" || (ip != nil && ip.IsUnspecified()) | ||
| 66 | } | 117 | } |
| 67 | 118 | ||
| 68 | // wireSync gives the sync service the one thing it needs from the gate: the | 119 | // wireSync gives the sync service the one thing it needs from the gate: the |
| @@ -109,23 +160,15 @@ func (g *sshGateSetup) startListener(st *store.Store, svc *syncsvc.Service, fata | |||
| 109 | if g == nil { | 160 | if g == nil { |
| 110 | return nil | 161 | return nil |
| 111 | } | 162 | } |
| 112 | // The gate host cert's principal is the name clients dial. Prefer the | 163 | // The gate host cert's principal is the name clients dial — the same one |
| 113 | // configured domain; else the host part of ssh_listen; else "localhost". | 164 | // /me hands them, since a client verifies what it dialed against this cert. |
| 114 | gateDomain := g.domain | 165 | principal := g.gateDomain() |
| 115 | if gateDomain == "" { | 166 | slog.Info("ssh gate host cert", "principal", principal) |
| 116 | if h, _, err := net.SplitHostPort(g.listen); err == nil { | ||
| 117 | gateDomain = h | ||
| 118 | } | ||
| 119 | } | ||
| 120 | if gateDomain == "" { | ||
| 121 | gateDomain = "localhost" | ||
| 122 | } | ||
| 123 | slog.Info("ssh gate host cert", "principal", gateDomain) | ||
| 124 | // Sign a long-lived HOST cert for the gate's own host key and present THAT | 167 | // Sign a long-lived HOST cert for the gate's own host key and present THAT |
| 125 | // (via a cert signer) instead of the bare key, so a client verifying with | 168 | // (via a cert signer) instead of the bare key, so a client verifying with |
| 126 | // `@cert-authority` accepts the gate on first connect — no TOFU window. | 169 | // `@cert-authority` accepts the gate on first connect — no TOFU window. |
| 127 | gateCert, err := sshca.SignHostCert(g.ca.HostCA(), g.ca.HostKey().PublicKey(), | 170 | gateCert, err := sshca.SignHostCert(g.ca.HostCA(), g.ca.HostKey().PublicKey(), |
| 128 | []string{gateDomain}, "eitri-gate", time.Now(), sshca.HostCertTTL) | 171 | []string{principal}, "eitri-gate", time.Now(), sshca.HostCertTTL) |
| 129 | if err != nil { | 172 | if err != nil { |
| 130 | return fmt.Errorf("sign gate host cert: %w", err) | 173 | return fmt.Errorf("sign gate host cert: %w", err) |
| 131 | } | 174 | } |
internal/server/boot/sshgate_test.go
| Old | New | ||
|---|---|---|---|
| @@ -251,3 +251,73 @@ func TestWireSyncIsANoOpWhenTheGateIsOff(t *testing.T) { | |||
| 251 | var off *sshGateSetup | 251 | var off *sshGateSetup |
| 252 | assert.NotPanics(t, func() { off.wireSync(nil) }) | 252 | assert.NotPanics(t, func() { off.wireSync(nil) }) |
| 253 | } | 253 | } |
| 254 | |||
| 255 | // TestGateAddrNamesTheGateAsItsCertificateDoes pins the address /me hands | ||
| 256 | // clients against the principal startListener puts on the gate's host cert: | ||
| 257 | // a client verifies the name it dialed against that certificate, so an address | ||
| 258 | // spelled any other way cannot pass host verification. | ||
| 259 | // | ||
| 260 | // It also pins the one case where they part company. A wildcard bind gets a | ||
| 261 | // certificate principal, because a certificate must name something, but no | ||
| 262 | // published address: "localhost" and "0.0.0.0" are both true of the plane's own | ||
| 263 | // machine and false everywhere a user might read them. | ||
| 264 | func TestGateAddrNamesTheGateAsItsCertificateDoes(t *testing.T) { | ||
| 265 | for _, tc := range []struct { | ||
| 266 | name string | ||
| 267 | listen, domain string | ||
| 268 | wantDomain string | ||
| 269 | wantAddr string | ||
| 270 | }{ | ||
| 271 | { | ||
| 272 | name: "the configured domain wins, the port comes from ssh_listen", | ||
| 273 | listen: ":2222", domain: "gate.eitri.sh", | ||
| 274 | wantDomain: "gate.eitri.sh", wantAddr: "gate.eitri.sh:2222", | ||
| 275 | }, | ||
| 276 | { | ||
| 277 | name: "no domain configured falls back to ssh_listen's host", | ||
| 278 | listen: "gate.example.com:2222", domain: "", | ||
| 279 | wantDomain: "gate.example.com", wantAddr: "gate.example.com:2222", | ||
| 280 | }, | ||
| 281 | { | ||
| 282 | name: "a loopback bind is the truth for a single-machine plane", | ||
| 283 | listen: "127.0.0.1:2222", domain: "", | ||
| 284 | wantDomain: "127.0.0.1", wantAddr: "127.0.0.1:2222", | ||
| 285 | }, | ||
| 286 | { | ||
| 287 | name: "an IPv4 wildcard bind with no domain publishes nothing", | ||
| 288 | listen: "0.0.0.0:22", domain: "", | ||
| 289 | wantDomain: "0.0.0.0", wantAddr: "", | ||
| 290 | }, | ||
| 291 | { | ||
| 292 | name: "an IPv6 wildcard bind with no domain publishes nothing", | ||
| 293 | listen: "[::]:2222", domain: "", | ||
| 294 | wantDomain: "::", wantAddr: "", | ||
| 295 | }, | ||
| 296 | { | ||
| 297 | name: "an all-interfaces listen with no host publishes nothing", | ||
| 298 | listen: ":2222", domain: "", | ||
| 299 | wantDomain: "localhost", wantAddr: "", | ||
| 300 | }, | ||
| 301 | { | ||
| 302 | name: "a wildcard bind publishes the domain it was given", | ||
| 303 | listen: "0.0.0.0:2222", domain: "gate.eitri.sh", | ||
| 304 | wantDomain: "gate.eitri.sh", wantAddr: "gate.eitri.sh:2222", | ||
| 305 | }, | ||
| 306 | { | ||
| 307 | name: "an unparsable listen names nothing to publish", | ||
| 308 | listen: "", domain: "", | ||
| 309 | wantDomain: "localhost", wantAddr: "", | ||
| 310 | }, | ||
| 311 | { | ||
| 312 | name: "an IPv6 domain is bracketed so the port stays readable", | ||
| 313 | listen: ":2222", domain: "2001:db8::1", | ||
| 314 | wantDomain: "2001:db8::1", wantAddr: "[2001:db8::1]:2222", | ||
| 315 | }, | ||
| 316 | } { | ||
| 317 | t.Run(tc.name, func(t *testing.T) { | ||
| 318 | g := &sshGateSetup{listen: tc.listen, domain: tc.domain} | ||
| 319 | assert.Equal(t, tc.wantDomain, g.gateDomain()) | ||
| 320 | assert.Equal(t, tc.wantAddr, g.gateAddr()) | ||
| 321 | }) | ||
| 322 | } | ||
| 323 | } | ||
web/src/lib/SshConnect.svelte
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,284 @@ | |||
| 1 | <script lang="ts"> | ||
| 2 | // The connect recipes for one VM, with this plane's real values in them. | ||
| 3 | // The one-shot command is the one docs/ssh-access.md prints — the gate hop | ||
| 4 | // rides an explicit ProxyCommand rather than -J because command-line -o | ||
| 5 | // options reach only the final hop, and BOTH hops must verify the host | ||
| 6 | // certificate they are presented. The same argv shape is what | ||
| 7 | // internal/cli/sshcmd.go execs. The ~/.ssh/config block below is this | ||
| 8 | // component's own: the same two hops and the same verification, spelled as | ||
| 9 | // stanzas. | ||
| 10 | import { copyText } from '$lib/fleet.svelte'; | ||
| 11 | |||
| 12 | let { vmName, tenant, gate }: { vmName: string; tenant: string; gate: string } = $props(); | ||
| 13 | |||
| 14 | // splitGate parses the published gate address — host:port, IPv6 bracketed, | ||
| 15 | // which is what net.JoinHostPort emits — into the two pieces the recipes | ||
| 16 | // need apart: the ProxyCommand wants the port as -p, and ~/.ssh/config | ||
| 17 | // wants it on its own Port line. A bare IPv6 literal (colons, no brackets) | ||
| 18 | // is a host, not a host:port, so the last colon is not a port separator. | ||
| 19 | function splitGate(addr: string): { host: string; port: string } { | ||
| 20 | if (addr.startsWith('[')) { | ||
| 21 | const end = addr.indexOf(']'); | ||
| 22 | if (end !== -1) { | ||
| 23 | const rest = addr.slice(end + 1); | ||
| 24 | return { host: addr.slice(1, end), port: rest.slice(1) || '22' }; | ||
| 25 | } | ||
| 26 | } | ||
| 27 | const i = addr.indexOf(':'); | ||
| 28 | if (i === -1 || addr.indexOf(':', i + 1) !== -1) return { host: addr, port: '22' }; | ||
| 29 | return { host: addr.slice(0, i), port: addr.slice(i + 1) || '22' }; | ||
| 30 | } | ||
| 31 | |||
| 32 | const gateHost = $derived(splitGate(gate).host); | ||
| 33 | const gatePort = $derived(splitGate(gate).port); | ||
| 34 | // ProxyJump takes one word, so an IPv6 gate keeps its brackets there — | ||
| 35 | // without them the last hextet reads as a port. | ||
| 36 | const jumpTarget = $derived( | ||
| 37 | gateHost.includes(':') ? `[${gateHost}]:${gatePort}` : `${gateHost}:${gatePort}` | ||
| 38 | ); | ||
| 39 | // The connect name is the VM host certificate's one principal, so it is | ||
| 40 | // what the final hop must dial — a bare name would fail verification. | ||
| 41 | const connectName = $derived(`${tenant}.${vmName}`); | ||
| 42 | // The console is served same-origin with the API it fronts, so the origin | ||
| 43 | // in the browser's address bar is the URL the CA pin is fetched from. | ||
| 44 | const apiOrigin = $derived(typeof window === 'undefined' ? '' : window.location.origin); | ||
| 45 | |||
| 46 | // $KH is quoted twice over. The outer shell expands it into the | ||
| 47 | // ProxyCommand string, and ssh runs that string through a shell of its own | ||
| 48 | // — so the single quotes are what reach the inner shell and keep a $HOME | ||
| 49 | // with a space in it one word. Same reasoning as shq() in | ||
| 50 | // internal/cli/sshcmd.go, which single-quotes the path for the same hop. | ||
| 51 | const oneShot = $derived(`KH=~/.ssh/eitri_known_hosts | ||
| 52 | ssh \\ | ||
| 53 | -o "ProxyCommand=ssh -W %h:%p -o StrictHostKeyChecking=yes -o UserKnownHostsFile='$KH' -p ${gatePort} ubuntu@${gateHost}" \\ | ||
| 54 | -o StrictHostKeyChecking=yes \\ | ||
| 55 | -o "UserKnownHostsFile=$KH" \\ | ||
| 56 | ubuntu@${connectName}`); | ||
| 57 | |||
| 58 | // Two exact names, no patterns. The first IS the gate's own name, because | ||
| 59 | // ssh verifies the host certificate against the name it dialed and the | ||
| 60 | // gate's certificate carries exactly that one principal. The second is this | ||
| 61 | // VM and only this VM: a `${tenant}.*` pattern would quietly claim every | ||
| 62 | // name in the tenant, including the gate's were it ever named one, and | ||
| 63 | // every VM's page prints its own stanza anyway. | ||
| 64 | const sshConfig = $derived(`Host ${gateHost} | ||
| 65 | Port ${gatePort} | ||
| 66 | User ubuntu | ||
| 67 | StrictHostKeyChecking yes | ||
| 68 | UserKnownHostsFile ~/.ssh/eitri_known_hosts | ||
| 69 | |||
| 70 | Host ${connectName} | ||
| 71 | ProxyJump ${jumpTarget} | ||
| 72 | User ubuntu | ||
| 73 | StrictHostKeyChecking yes | ||
| 74 | UserKnownHostsFile ~/.ssh/eitri_known_hosts`); | ||
| 75 | |||
| 76 | const pinCA = $derived(`curl -sS "${apiOrigin}/api/v1/ssh-ca" | jq -r .ca \\ | ||
| 77 | | sed 's/^/@cert-authority * /' > ~/.ssh/eitri_known_hosts`); | ||
| 78 | |||
| 79 | const signKey = `ssh-keygen -s ~/.ssh/eitri_user_ca -I "$(whoami)@$(hostname)" \\ | ||
| 80 | -n ubuntu -V +30m ~/.ssh/id_ed25519.pub`; | ||
| 81 | |||
| 82 | // copied names the block whose button last succeeded, so each button | ||
| 83 | // reports for itself. | ||
| 84 | let copied = $state(''); | ||
| 85 | let blocks = $state<Record<string, HTMLPreElement | null>>({}); | ||
| 86 | |||
| 87 | async function copy(key: string, text: string) { | ||
| 88 | copied = (await copyText(text, blocks[key], 'command')) ? key : ''; | ||
| 89 | } | ||
| 90 | |||
| 91 | // The recipe is one word on the page until it is wanted: a VM's own facts | ||
| 92 | // come first, and the way in is three tabs behind them. Closed is the | ||
| 93 | // default — anyone already set up needs none of this. | ||
| 94 | let open = $state(false); | ||
| 95 | // The one-shot command leads: it is the shortest path from this page to a | ||
| 96 | // shell, and the other two are what you reach for once or not at all. | ||
| 97 | const tabs = [ | ||
| 98 | { id: 'oneshot', label: 'ssh' }, | ||
| 99 | { id: 'config', label: '~/.ssh/config' }, | ||
| 100 | { id: 'prereq', label: 'prerequisites' } | ||
| 101 | ]; | ||
| 102 | let tab = $state('oneshot'); | ||
| 103 | let tabEls = $state<Record<string, HTMLButtonElement | null>>({}); | ||
| 104 | |||
| 105 | // A tab strip is one stop on the tab key, and the arrows move within it — | ||
| 106 | // which is why only the selected tab is tabbable. Home and End go to the | ||
| 107 | // ends, as they do in every other tab strip. | ||
| 108 | function onTabKey(e: KeyboardEvent) { | ||
| 109 | const i = tabs.findIndex((t) => t.id === tab); | ||
| 110 | let j = i; | ||
| 111 | if (e.key === 'ArrowRight') j = (i + 1) % tabs.length; | ||
| 112 | else if (e.key === 'ArrowLeft') j = (i - 1 + tabs.length) % tabs.length; | ||
| 113 | else if (e.key === 'Home') j = 0; | ||
| 114 | else if (e.key === 'End') j = tabs.length - 1; | ||
| 115 | else return; | ||
| 116 | e.preventDefault(); | ||
| 117 | tab = tabs[j].id; | ||
| 118 | tabEls[tabs[j].id]?.focus(); | ||
| 119 | } | ||
| 120 | </script> | ||
| 121 | |||
| 122 | {#snippet block(key: string, text: string)} | ||
| 123 | <div class="block"> | ||
| 124 | <pre bind:this={blocks[key]}>{text}</pre> | ||
| 125 | <button type="button" class="ghost" onclick={() => copy(key, text)}> | ||
| 126 | {copied === key ? 'Copied' : 'Copy'} | ||
| 127 | </button> | ||
| 128 | </div> | ||
| 129 | {/snippet} | ||
| 130 | |||
| 131 | <div class="connect"> | ||
| 132 | <h3> | ||
| 133 | <button | ||
| 134 | type="button" | ||
| 135 | class="ghost" | ||
| 136 | aria-expanded={open} | ||
| 137 | aria-controls="connect-panel" | ||
| 138 | onclick={() => (open = !open)} | ||
| 139 | > | ||
| 140 | {open ? '▾' : '▸'} Connect | ||
| 141 | </button> | ||
| 142 | </h3> | ||
| 143 | |||
| 144 | <div id="connect-panel" hidden={!open}> | ||
| 145 | <p class="hint"> | ||
| 146 | You log in as <code>ubuntu</code>. Both hops present a certificate signed by eitri's host CA, | ||
| 147 | and both are verified against it. | ||
| 148 | </p> | ||
| 149 | |||
| 150 | <div class="tabs" role="tablist" aria-label="connect recipes"> | ||
| 151 | {#each tabs as t (t.id)} | ||
| 152 | <button | ||
| 153 | type="button" | ||
| 154 | role="tab" | ||
| 155 | id="tab-{t.id}" | ||
| 156 | class:on={tab === t.id} | ||
| 157 | aria-selected={tab === t.id} | ||
| 158 | aria-controls="panel-{t.id}" | ||
| 159 | tabindex={tab === t.id ? 0 : -1} | ||
| 160 | bind:this={tabEls[t.id]} | ||
| 161 | onclick={() => (tab = t.id)} | ||
| 162 | onkeydown={onTabKey} | ||
| 163 | > | ||
| 164 | {t.label} | ||
| 165 | </button> | ||
| 166 | {/each} | ||
| 167 | </div> | ||
| 168 | |||
| 169 | <div id="panel-oneshot" role="tabpanel" aria-labelledby="tab-oneshot" hidden={tab !== 'oneshot'}> | ||
| 170 | {@render block('oneshot', oneShot)} | ||
| 171 | </div> | ||
| 172 | |||
| 173 | <div id="panel-config" role="tabpanel" aria-labelledby="tab-config" hidden={tab !== 'config'}> | ||
| 174 | <p class="hint"> | ||
| 175 | With this in <code>~/.ssh/config</code>, the whole thing collapses to | ||
| 176 | <code>ssh {connectName}</code>. | ||
| 177 | </p> | ||
| 178 | {@render block('config', sshConfig)} | ||
| 179 | </div> | ||
| 180 | |||
| 181 | <div id="panel-prereq" role="tabpanel" aria-labelledby="tab-prereq" hidden={tab !== 'prereq'}> | ||
| 182 | <h4>Both of them need</h4> | ||
| 183 | <ol> | ||
| 184 | <li> | ||
| 185 | <p> | ||
| 186 | eitri's host CA pinned, once per machine. It goes in a file of its own—a | ||
| 187 | <code>@cert-authority *</code> line in your main | ||
| 188 | <code>known_hosts</code> would trust that CA for every host you ssh to. | ||
| 189 | </p> | ||
| 190 | {@render block('pin', pinCA)} | ||
| 191 | </li> | ||
| 192 | <li> | ||
| 193 | <p> | ||
| 194 | A certificate on your key, signed by a CA registered for this tenant (add one in | ||
| 195 | <a href="/settings">settings</a>). <code>ssh-keygen -s</code> writes it beside the key as | ||
| 196 | <code>id_ed25519-cert.pub</code>, which OpenSSH offers on its own. The principal must be | ||
| 197 | <code>ubuntu</code>: a guest matches it against the login user and refuses anything else. | ||
| 198 | </p> | ||
| 199 | {@render block('sign', signKey)} | ||
| 200 | </li> | ||
| 201 | </ol> | ||
| 202 | </div> | ||
| 203 | |||
| 204 | <p class="hint"> | ||
| 205 | <code>eitri ssh {vmName}</code> does all of the above—it signs a 30-minute certificate, pins the | ||
| 206 | CA, and execs the command above. | ||
| 207 | </p> | ||
| 208 | </div> | ||
| 209 | </div> | ||
| 210 | |||
| 211 | <style> | ||
| 212 | /* Closed, this is one button on an otherwise empty line — no box around a | ||
| 213 | box. The measure is the prose one either way. */ | ||
| 214 | .connect { | ||
| 215 | margin-top: 1.2em; | ||
| 216 | max-width: 92ch; | ||
| 217 | } | ||
| 218 | .connect h3 { | ||
| 219 | margin: 0; | ||
| 220 | font-size: inherit; | ||
| 221 | font-weight: normal; | ||
| 222 | } | ||
| 223 | .connect h4 { | ||
| 224 | margin: 0.9em 0 0.3em; | ||
| 225 | font-size: inherit; | ||
| 226 | } | ||
| 227 | #connect-panel { | ||
| 228 | margin-top: 0.6em; | ||
| 229 | } | ||
| 230 | /* A tab is its label and a rule under it: ink for the one you are reading, | ||
| 231 | faint for the others, and the strip's own hairline joining them. Reaching | ||
| 232 | for one firms its rule to ink, as every other control here does. */ | ||
| 233 | .tabs { | ||
| 234 | display: flex; | ||
| 235 | gap: 1.5em; | ||
| 236 | margin-bottom: 0.6em; | ||
| 237 | border-bottom: 1px solid var(--hairline); | ||
| 238 | } | ||
| 239 | .tabs button { | ||
| 240 | border: 0; | ||
| 241 | border-bottom: 1px solid transparent; | ||
| 242 | border-radius: 0; | ||
| 243 | margin-bottom: -1px; | ||
| 244 | padding: 0.1em 0; | ||
| 245 | color: var(--faint); | ||
| 246 | } | ||
| 247 | .tabs button:hover { | ||
| 248 | color: var(--ink); | ||
| 249 | } | ||
| 250 | .tabs button.on { | ||
| 251 | color: var(--ink); | ||
| 252 | border-bottom-color: var(--ink); | ||
| 253 | } | ||
| 254 | [role='tabpanel'][hidden], | ||
| 255 | #connect-panel[hidden] { | ||
| 256 | display: none; | ||
| 257 | } | ||
| 258 | .connect p { | ||
| 259 | margin: 0 0 0.4em; | ||
| 260 | } | ||
| 261 | .connect ol { | ||
| 262 | margin: 0; | ||
| 263 | padding-left: 2em; | ||
| 264 | } | ||
| 265 | .connect li { | ||
| 266 | margin-bottom: 0.6em; | ||
| 267 | } | ||
| 268 | /* A command and the button that takes it: the command keeps its own line | ||
| 269 | breaks and scrolls sideways rather than reflowing, since a wrapped | ||
| 270 | continuation backslash reads as a different command. */ | ||
| 271 | .block { | ||
| 272 | display: flex; | ||
| 273 | align-items: flex-start; | ||
| 274 | gap: 0.5em; | ||
| 275 | margin-bottom: 0.4em; | ||
| 276 | } | ||
| 277 | .block pre { | ||
| 278 | flex: 1; | ||
| 279 | min-width: 0; | ||
| 280 | border: 1px solid var(--hairline); | ||
| 281 | padding: 0.3em 0.6em; | ||
| 282 | overflow-x: auto; | ||
| 283 | } | ||
| 284 | </style> | ||
web/src/lib/api-types.ts
| Old | New | ||
|---|---|---|---|
| @@ -563,7 +563,7 @@ export interface paths { | |||
| 563 | path?: never; | 563 | path?: never; |
| 564 | cookie?: never; | 564 | cookie?: never; |
| 565 | }; | 565 | }; |
| 566 | /** The signed-in identity: the caller's tenant handle and bound email. */ | 566 | /** The signed-in identity: the caller's tenant handle, bound email, and the plane's SSH jump-gate address (empty when it runs no gate). */ |
| 567 | get: { | 567 | get: { |
| 568 | parameters: { | 568 | parameters: { |
| 569 | query?: never; | 569 | query?: never; |
| @@ -1619,6 +1619,7 @@ export interface components { | |||
| 1619 | }; | 1619 | }; |
| 1620 | Me: { | 1620 | Me: { |
| 1621 | email: string; | 1621 | email: string; |
| 1622 | ssh_gate: string; | ||
| 1622 | tenant: string; | 1623 | tenant: string; |
| 1623 | }; | 1624 | }; |
| 1624 | Metrics: { | 1625 | Metrics: { |
web/src/lib/fleet.svelte.ts
| Old | New | ||
|---|---|---|---|
| @@ -28,7 +28,8 @@ export type Exposure = components['schemas']['Exposure']; | |||
| 28 | 28 | ||
| 29 | export type CreateVMRequest = components['schemas']['CreateVMRequest']; | 29 | export type CreateVMRequest = components['schemas']['CreateVMRequest']; |
| 30 | 30 | ||
| 31 | /** Me is the signed-in identity: the caller's tenant handle and bound email. */ | 31 | /** Me is the signed-in identity: the caller's tenant handle and bound email, |
| 32 | * plus the plane's SSH gate address (empty when it runs none). */ | ||
| 32 | export type Me = components['schemas']['Me']; | 33 | export type Me = components['schemas']['Me']; |
| 33 | 34 | ||
| 34 | /** APIToken is a personal access token's metadata (never the secret). */ | 35 | /** APIToken is a personal access token's metadata (never the secret). */ |
| @@ -203,6 +204,32 @@ function scheduleReconnect() { | |||
| 203 | }, 3000); | 204 | }, 3000); |
| 204 | } | 205 | } |
| 205 | 206 | ||
| 207 | /** copyText puts s on the clipboard and reports whether it landed there. | ||
| 208 | * | ||
| 209 | * navigator.clipboard only exists in secure contexts; a console served over | ||
| 210 | * plain http on a LAN origin doesn't get it. Fall back to selecting the | ||
| 211 | * element holding the text so a manual Ctrl-C works in one keystroke, and say | ||
| 212 | * so in the banner — what names the noun that message uses. */ | ||
| 213 | export async function copyText( | ||
| 214 | s: string, | ||
| 215 | shown?: HTMLElement | null, | ||
| 216 | what = 'text' | ||
| 217 | ): Promise<boolean> { | ||
| 218 | try { | ||
| 219 | if (navigator.clipboard?.writeText) { | ||
| 220 | await navigator.clipboard.writeText(s); | ||
| 221 | return true; | ||
| 222 | } | ||
| 223 | if (shown) { | ||
| 224 | window.getSelection()?.selectAllChildren(shown); | ||
| 225 | fleet.error = `Clipboard unavailable over http—${what} selected, press Ctrl-C`; | ||
| 226 | } | ||
| 227 | } catch (err) { | ||
| 228 | fleet.error = String(err); | ||
| 229 | } | ||
| 230 | return false; | ||
| 231 | } | ||
| 232 | |||
| 206 | /** dismissError clears the sticky error banner. */ | 233 | /** dismissError clears the sticky error banner. */ |
| 207 | export function dismissError() { | 234 | export function dismissError() { |
| 208 | fleet.error = ''; | 235 | fleet.error = ''; |
web/src/routes/settings/+page.svelte
| Old | New | ||
|---|---|---|---|
| @@ -3,6 +3,7 @@ | |||
| 3 | import { | 3 | import { |
| 4 | fleet, | 4 | fleet, |
| 5 | action, | 5 | action, |
| 6 | copyText, | ||
| 6 | createToken, | 7 | createToken, |
| 7 | listTokens, | 8 | listTokens, |
| 8 | revokeToken, | 9 | revokeToken, |
| @@ -63,23 +64,7 @@ | |||
| 63 | 64 | ||
| 64 | async function copySecret() { | 65 | async function copySecret() { |
| 65 | if (!minted) return; | 66 | if (!minted) return; |
| 66 | try { | 67 | copied = await copyText(minted.token, document.getElementById('minted-token'), 'token'); |
| 67 | // navigator.clipboard only exists in secure contexts; a console | ||
| 68 | // served over plain http on a LAN origin doesn't get it. Fall back | ||
| 69 | // to selecting the token so a manual Ctrl-C works in one keystroke. | ||
| 70 | if (navigator.clipboard?.writeText) { | ||
| 71 | await navigator.clipboard.writeText(minted.token); | ||
| 72 | copied = true; | ||
| 73 | return; | ||
| 74 | } | ||
| 75 | const el = document.getElementById('minted-token'); | ||
| 76 | if (el) { | ||
| 77 | window.getSelection()?.selectAllChildren(el); | ||
| 78 | fleet.error = 'Clipboard unavailable over http—token selected, press Ctrl-C'; | ||
| 79 | } | ||
| 80 | } catch (err) { | ||
| 81 | fleet.error = String(err); | ||
| 82 | } | ||
| 83 | } | 68 | } |
| 84 | 69 | ||
| 85 | async function doRevoke(id: string) { | 70 | async function doRevoke(id: string) { |
web/src/routes/vms/[id]/+page.svelte
| Old | New | ||
|---|---|---|---|
| @@ -1,6 +1,7 @@ | |||
| 1 | <script lang="ts"> | 1 | <script lang="ts"> |
| 2 | import { page } from '$app/state'; | 2 | import { page } from '$app/state'; |
| 3 | import Console from '$lib/Console.svelte'; | 3 | import Console from '$lib/Console.svelte'; |
| 4 | import SshConnect from '$lib/SshConnect.svelte'; | ||
| 4 | import { | 5 | import { |
| 5 | fleet, | 6 | fleet, |
| 6 | action, | 7 | action, |
| @@ -45,6 +46,10 @@ | |||
| 45 | const powerNote = $derived( | 46 | const powerNote = $derived( |
| 46 | status === 'creating' ? 'creating—eitri is bringing this VM up' : status | 47 | status === 'creating' ? 'creating—eitri is bringing this VM up' : status |
| 47 | ); | 48 | ); |
| 49 | // A guest is reachable once it exists and can be certified — so the connect | ||
| 50 | // recipes appear for a settled VM only. A creating one may not have a signed | ||
| 51 | // host certificate yet, and a failed one has nothing listening. | ||
| 52 | const connectable = $derived(status === 'ready' || status === 'stopped'); | ||
| 48 | // approx is the coarse "time left to undo" shown in the teardown callout. | 53 | // approx is the coarse "time left to undo" shown in the teardown callout. |
| 49 | const approx = $derived(vm ? teardownApprox(vm, clock.now) : ''); | 54 | const approx = $derived(vm ? teardownApprox(vm, clock.now) : ''); |
| 50 | 55 | ||
| @@ -328,6 +333,12 @@ | |||
| 328 | </p> | 333 | </p> |
| 329 | </div> | 334 | </div> |
| 330 | 335 | ||
| 336 | <!-- A plane with no gate has no hop to name, so it prints no recipe rather | ||
| 337 | than commands that cannot work. --> | ||
| 338 | {#if connectable && fleet.me?.ssh_gate} | ||
| 339 | <SshConnect vmName={vm.name} tenant={fleet.me.tenant} gate={fleet.me.ssh_gate} /> | ||
| 340 | {/if} | ||
| 341 | |||
| 331 | {#if !tearingDown} | 342 | {#if !tearingDown} |
| 332 | <Console vmId={vm.id} /> | 343 | <Console vmId={vm.id} /> |
| 333 | {/if} | 344 | {/if} |