a73x

323e2c24

feat(web): the VM page hands you the command that reaches the guest

a73x   2026-08-09 19:47

Commit message
feat(web): the VM page hands you the command that reaches the guest

A VM's page prints the ssh that opens a shell on it, with this plane's gate and
this VM's connect name already in it. The gate hop rides an explicit
ProxyCommand rather than -J, because a command-line -o reaches only the final
hop and both hops must verify the certificate they present — the same argv
`eitri ssh` execs, and the same one docs/ssh-access.md prints, down to the
single quotes around the known-hosts path in the nested command: the outer
shell expands it into the ProxyCommand string and ssh runs that string through
a shell of its own, so a $HOME with a space in it needs quoting at both layers.
docs/ssh-access.md was missing the inner pair and now carries it, with the
sentence that says why.

A VM's page is its own facts; the way in is one line under them. "Connect"
opens onto three tabs — the one command, the ~/.ssh/config stanzas, and what
both of them need — and the command leads, because it is the shortest path
from this page to a shell. Closed is the default: anyone already set up needs
none of it.

The second tab is the ~/.ssh/config stanzas that make the whole thing
`ssh <tenant>.<vm>`: one for the gate, one for this VM, both exact names. The
gate's stanza is its own name because ssh verifies the certificate against the
name it dialed and that certificate carries exactly one principal; the VM's is
this VM alone, since a `<tenant>.*` pattern would quietly claim every name in
the tenant — the gate's included — and every VM's page prints its own stanza
anyway.

Then what both of them need, stated rather than assumed: eitri's host CA pinned
in a file of its own, and a certificate on your key from a CA this tenant has
registered — with the principal `ubuntu`, which a guest matches against the
login user and refuses anything else of. The CA link goes to the settings page
that holds them.

The strip is a real one. It is a single stop on the tab key, the arrows and
Home/End move within it, and each panel is named by the tab that selects it,
so the recipe reads the same to a screen reader as it does on the page. The recipes appear for a settled VM only: one still being
created may not have a signed host certificate yet, and a failed one has
nothing listening.

GET /api/v1/me carries the gate address, host:port, the value EITRI_GATE takes.
A connect name is half identity and half plane and no caller needs one without
the other, so both halves ride the response the console already fetches before
it renders. The host part is the gate's host-certificate principal and nothing
else, because any other spelling of the same machine is a hard verification
failure by design — and where the plane has no such name, it publishes none:
a wildcard bind says which interfaces to accept on, not what to call the
machine, and `localhost` is true of the plane's own box and false everywhere a
user might read it. A plane that publishes no gate gets no recipe.

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}