a73x

0aab65cc

fix(gate): a gate that cannot name itself refuses to boot, and the client stops guessing

a73x   2026-08-11 16:47

Commit message
fix(gate): a gate that cannot name itself refuses to boot, and the client stops guessing

A gate bound to every interface with no ssh_gate_domain is a config that is
internally consistent and serves nobody. The bind says which interfaces to
accept on, not what to call the machine, so the gate has no name: /me
advertises none, and the host certificate gets "localhost" as its one
principal. Both hops verify under strict checking, so no value of EITRI_GATE
makes that gate reachable from another machine — the failure is not on the
client side and cannot be fixed there.

So the server stops at startup, beside the check that already refuses a gate
with nowhere to keep its keys, and names both remedies: set ssh_gate_domain,
or bind one concrete address. A running plane with a gate on now always has an
address to publish, which is what the console recipes and the client assume.

The client half is the other end of the same trap. When a plane named no gate,
the CLI fell back to the hosted gate.eitri.sh — a plausible-looking answer that
is certainly wrong anywhere but eitri.sh, and one that fails later as a denied
host key rather than as the missing setting it is. That default now applies to
the hosted plane alone. Anywhere else, `eitri ssh` says the plane names no gate
and which two settings would fix it, and `eitri init` writes no gate rather
than proposing the hosted one.

deploy/server/config.required
Old New
@@ -36,6 +36,11 @@ key_encryption_key required
36 cidr_pool required 36 cidr_pool required
37 advertise_http required 37 advertise_http required
38 advertise_quic required 38 advertise_quic required
39 # The gate's bind, and the name it answers to. A bind that names every interface
40 # (":2222", "0.0.0.0:…") gives the gate no name of its own, so the server refuses
41 # to boot without ssh_gate_domain: its host certificate would name localhost and
42 # every remote client would refuse it. This plane binds a wildcard, hence both
43 # required; a plane binding one concrete address may leave the domain out.
39 ssh_listen required 44 ssh_listen required
40 ssh_gate_domain required 45 ssh_gate_domain required
41 ssh_ca_key required 46 ssh_ca_key required
docs/assumptions.md
Old New
@@ -515,3 +515,16 @@ the artifact: the built `build/` tree contains no reference to the packages the
515 advisories name. False the moment anything runs `node` against this app — a 515 advisories name. False the moment anything runs `node` against this app — a
516 preview server put in front of users, a prerendered route, an SSR endpoint. Any 516 preview server put in front of users, a prerendered route, an SSR endpoint. Any
517 of those turns the accepted advisories live and this list must be re-read. 517 of those turns the accepted advisories live and this list must be re-read.
518
519 ### A gate is reachable only at the name on its host certificate
520
521 Both hops verify under strict checking, so the name a client dials must be a
522 principal on the cert it is presented. Underpins refusing to boot a gate that
523 binds every interface with no `ssh_gate_domain` — that cert names `localhost`,
524 and no `EITRI_GATE` a remote client sets can make it verify — rather than
525 booting one and letting the failure surface as a denied host key.
526 **Proven** in code: the gate's cert carries exactly `ssh_gate_domain` as its
527 one principal, and every client path (`eitri ssh`, the console recipe, the
528 manual flow) dials that name with `StrictHostKeyChecking=yes`. False if a
529 deployment ever fronts the gate at a second name — that needs a second
530 principal on the cert, not a second address in the config.
docs/quickstart.md
Old New
@@ -359,6 +359,13 @@ both.
359 cloud-init disk image works as a default image; the Ubuntu one boots out of 359 cloud-init disk image works as a default image; the Ubuntu one boots out of
360 the box. 360 the box.
361 361
362 `ssh_listen` here binds every interface, which is why `ssh_gate_domain` is set
363 beside it: a wildcard bind (`:2222`, `0.0.0.0:2222`) gives the gate no name, so
364 the server requires one and refuses to start without it. That name is what
365 clients dial and the one principal on the gate's host certificate. A gate bound
366 to one concrete address (`127.0.0.1:2222` for a single-machine plane) names
367 itself and may leave `ssh_gate_domain` out.
368
362 `default_images` is keyed by the architecture of the host a VM lands on, and a 369 `default_images` is keyed by the architecture of the host a VM lands on, and a
363 VM created without an explicit image takes the entry for its host. Configure 370 VM created without an explicit image takes the entry for its host. Configure
364 every architecture in your fleet: a host can only run a guest built for its own 371 every architecture in your fleet: a host can only run a guest built for its own
@@ -495,7 +502,10 @@ eitri ssh <vm-name> uptime
495 a config, `EITRI_URL` must name your box—unset it defaults to the hosted 502 a config, `EITRI_URL` must name your box—unset it defaults to the hosted
496 service at eitri.sh—and the gate comes from that server's `/api/v1/me`, which 503 service at eitri.sh—and the gate comes from that server's `/api/v1/me`, which
497 serves whatever `ssh_gate_domain` and `ssh_listen` say. `EITRI_GATE` overrides 504 serves whatever `ssh_gate_domain` and `ssh_listen` say. `EITRI_GATE` overrides
498 it either way, for a server behind a different address than it advertises. 505 it either way, for a server behind a different address than it advertises. The
506 hosted gate address is assumed only for the hosted plane: against your own
507 server, a `/me` that names no gate is an error naming these two settings, never
508 a silent hop through eitri.sh.
499 509
500 `eitri ssh` is plain ssh in a trenchcoat: it signs a short-lived cert with 510 `eitri ssh` is plain ssh in a trenchcoat: it signs a short-lived cert with
501 your CA, pins eitri's host CA, and jumps the gate to `ubuntu@<tenant>.<vm-name>` 511 your CA, pins eitri's host CA, and jumps the gate to `ubuntu@<tenant>.<vm-name>`
docs/ssh-access.md
Old New
@@ -126,7 +126,7 @@ Environment variables:
126 | Var | Meaning | 126 | Var | Meaning |
127 | ------------- | --------------------------------------------------------- | 127 | ------------- | --------------------------------------------------------- |
128 | `EITRI_URL` | Base URL of the eitri server (default the hosted `https://console.eitri.sh`) | 128 | `EITRI_URL` | Base URL of the eitri server (default the hosted `https://console.eitri.sh`) |
129 | `EITRI_GATE` | Jump gate address for the hop (host:port, `ssh_listen`; default the hosted `gate.eitri.sh:2222`) | 129 | `EITRI_GATE` | Jump gate address for the hop (host:port, `ssh_listen`); otherwise the plane's own `/me` answer, and the hosted `gate.eitri.sh:2222` only when `EITRI_URL` is the hosted plane |
130 | `EITRI_CA` | Your tenant user-CA **private** key (default `~/.ssh/eitri_user_ca`) | 130 | `EITRI_CA` | Your tenant user-CA **private** key (default `~/.ssh/eitri_user_ca`) |
131 | `EITRI_TOKEN` | Personal access token, used only to look up your tenant for the connect name (skipped when `EITRI_TENANT` is set) | 131 | `EITRI_TOKEN` | Personal access token, used only to look up your tenant for the connect name (skipped when `EITRI_TENANT` is set) |
132 | `EITRI_TENANT`| Optional: pins the tenant (offline, and the escape hatch when your CA is registered in more than one); otherwise derived from the token | 132 | `EITRI_TENANT`| Optional: pins the tenant (offline, and the escape hatch when your CA is registered in more than one); otherwise derived from the token |
@@ -142,6 +142,9 @@ file, and execs `ssh` with both hops verified.
142 > The host `EITRI_GATE` points at **must match** the gate's host-cert principal, 142 > The host `EITRI_GATE` points at **must match** the gate's host-cert principal,
143 > i.e. the server's `ssh_gate_domain` (which defaults to the host part of 143 > i.e. the server's `ssh_gate_domain` (which defaults to the host part of
144 > `ssh_listen`). A mismatch is a hard host-verification failure, by design. 144 > `ssh_listen`). A mismatch is a hard host-verification failure, by design.
145 > A server whose `ssh_listen` binds every interface (`:2222`, `0.0.0.0:2222`)
146 > has no host part to default to, so `ssh_gate_domain` is required there and
147 > the server refuses to start without it.
145 148
146 ## Manual flow 149 ## Manual flow
147 150
internal/cli/env.go
Old New
@@ -8,6 +8,7 @@ package cli
8 import ( 8 import (
9 "os" 9 "os"
10 "path/filepath" 10 "path/filepath"
11 "strings"
11 ) 12 )
12 13
13 // Hosted-service defaults: unset and unconfigured, the CLI targets the hosted 14 // Hosted-service defaults: unset and unconfigured, the CLI targets the hosted
@@ -15,15 +16,24 @@ import (
15 // Self-hosters and tests point elsewhere by setting the matching environment 16 // Self-hosters and tests point elsewhere by setting the matching environment
16 // variable or by running `eitri init`. 17 // variable or by running `eitri init`.
17 // 18 //
18 // defaultGate is the weaker of the two. A plane names its own gate on /me, so 19 // defaultGate is the weaker of the two, and it belongs to defaultURL alone. A
19 // this address is only what a client assumes when nothing has told it better: 20 // plane names its own gate on /me, so this address is only what a client
20 // no EITRI_GATE, no config, and either no credential to ask with or a server 21 // assumes when nothing has told it better: no EITRI_GATE, no config, and either
21 // old enough to answer /me without ssh_gate. 22 // no credential to ask with or a server old enough to answer /me without
23 // ssh_gate. Against any OTHER plane that assumption is not a fallback but a
24 // wrong answer that looks right — a self-hoster's laptop hopping through
25 // eitri.sh's gate — so hostedPlane gates it.
22 const ( 26 const (
23 defaultURL = "https://console.eitri.sh" 27 defaultURL = "https://console.eitri.sh"
24 defaultGate = "gate.eitri.sh:2222" 28 defaultGate = "gate.eitri.sh:2222"
25 ) 29 )
26 30
31 // hostedPlane reports whether url is the hosted service, the one plane whose
32 // gate this binary knows without being told.
33 func hostedPlane(url string) bool {
34 return strings.TrimSuffix(url, "/") == defaultURL
35 }
36
27 // Env is the client configuration, resolved from EITRI_* variables over the 37 // Env is the client configuration, resolved from EITRI_* variables over the
28 // config file. 38 // config file.
29 type Env struct { 39 type Env struct {
internal/cli/init.go
Old New
@@ -110,9 +110,14 @@ 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 // 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
115 // 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.
117 gate, _ := gateFor(firstNonEmpty(me.SSHGate, e.Gate), e.URL)
113 next := Config{ 118 next := Config{
114 URL: e.URL, 119 URL: e.URL,
115 Gate: firstNonEmpty(me.SSHGate, e.Gate, defaultGate), 120 Gate: gate,
116 Tenant: me.Tenant, 121 Tenant: me.Tenant,
117 CA: caPath, 122 CA: caPath,
118 Key: e.Key, 123 Key: e.Key,
@@ -162,15 +167,23 @@ func initIdentity(p *prompter, e Env, token string) (client.Me, string, error) {
162 return client.Me{}, "", errors.New("that token resolves to no tenant") 167 return client.Me{}, "", errors.New("that token resolves to no tenant")
163 } 168 }
164 p.sayf(" %s, tenant %s.", firstNonEmpty(me.Email, "signed in"), me.Tenant) 169 p.sayf(" %s, tenant %s.", firstNonEmpty(me.Email, "signed in"), me.Tenant)
165 if me.SSHGate != "" { 170 // A gate that binds every interface with no ssh_gate_domain to name it does
171 // not boot, so a plane answering with no ssh_gate has its gate switched off
172 // or is old enough to answer without the field. Either way the hosted
173 // address belongs to the hosted plane alone — anywhere else it is a
174 // confidently wrong answer, so the remedy is named instead.
175 switch {
176 case me.SSHGate != "":
166 p.sayf(" This plane's SSH gate is %s.", me.SSHGate) 177 p.sayf(" This plane's SSH gate is %s.", me.SSHGate)
167 } else { 178 case e.Gate != "":
168 // Either a server too old to send ssh_gate, or one whose gate binds a 179 p.sayf(" This plane names no SSH gate; %s is set here and is kept.", e.Gate)
169 // wildcard with no ssh_gate_domain to name it — in which case the 180 case hostedPlane(e.URL):
170 // hosted address below is certainly wrong, so name the remedy here 181 p.sayf(" This plane names no SSH gate, so the hosted %s is assumed.", defaultGate)
171 // rather than let it be confirmed as a plausible-looking default. 182 default:
172 p.sayf(" This plane names no SSH gate, so %s is assumed. If that is wrong,", firstNonEmpty(e.Gate, defaultGate)) 183 p.sayf(" This plane names no SSH gate — its gate is off, or it predates")
173 p.sayf(" set ssh_gate_domain on the server, or EITRI_GATE here.") 184 p.sayf(" the servers that always name one. `eitri ssh` cannot reach a")
185 p.sayf(" guest without one: set ssh_gate_domain on the server, or")
186 p.sayf(" EITRI_GATE here.")
174 } 187 }
175 return me, token, nil 188 return me, token, nil
176 } 189 }
internal/cli/init_test.go
Old New
@@ -328,9 +328,11 @@ func TestInitBadTokenBlamesThePlane(t *testing.T) {
328 } 328 }
329 } 329 }
330 330
331 // A plane that names no gate — an older server — falls back to the hosted 331 // A self-hosted plane that names no gate gets no gate written: the hosted
332 // address rather than writing an empty gate no ssh can dial. 332 // address belongs to the hosted plane, and writing it here would send every
333 func TestInitFallsBackWhenThePlaneNamesNoGate(t *testing.T) { 333 // later session through eitri.sh to reach a guest that is nowhere near it. So
334 // init records nothing and says which two settings would fix it.
335 func TestInitWritesNoGateForAPlaneThatNamesNone(t *testing.T) {
334 plane := newFakePlane(t, map[string]any{"tenant": "acme", "email": "me@acme.test"}) 336 plane := newFakePlane(t, map[string]any{"tenant": "acme", "email": "me@acme.test"})
335 e, cfg := initEnv(t, plane) 337 e, cfg := initEnv(t, plane)
336 338
@@ -342,8 +344,34 @@ func TestInitFallsBackWhenThePlaneNamesNoGate(t *testing.T) {
342 if err != nil { 344 if err != nil {
343 t.Fatal(err) 345 t.Fatal(err)
344 } 346 }
345 if got.Gate != defaultGate { 347 if got.Gate != "" {
346 t.Errorf("gate = %q, want the hosted fallback %q", got.Gate, defaultGate) 348 t.Errorf("gate = %q, want none written", got.Gate)
349 }
350 if strings.Contains(out, defaultGate) {
351 t.Errorf("the hosted gate must not be proposed for a self-hosted plane:\n%s", out)
352 }
353 if !strings.Contains(out, "ssh_gate_domain") || !strings.Contains(out, "EITRI_GATE") {
354 t.Errorf("both remedies must be named:\n%s", out)
355 }
356 }
357
358 // A gate already pinned here (EITRI_GATE, or the config from a previous run)
359 // survives a plane that names none: init keeps it rather than clearing it.
360 func TestInitKeepsAPinnedGateWhenThePlaneNamesNone(t *testing.T) {
361 plane := newFakePlane(t, map[string]any{"tenant": "acme", "email": "me@acme.test"})
362 e, cfg := initEnv(t, plane)
363 e.Gate = "gate.acme.test:2222"
364
365 out, err := runInitWith(t, e, cfg, "y\nn\ny\ny\n\ny\n")
366 if err != nil {
367 t.Fatalf("%v\n%s", err, out)
368 }
369 got, err := LoadConfig(cfg)
370 if err != nil {
371 t.Fatal(err)
372 }
373 if got.Gate != "gate.acme.test:2222" {
374 t.Errorf("gate = %q, want the pinned one kept", got.Gate)
347 } 375 }
348 } 376 }
349 377
internal/cli/sshcmd.go
Old New
@@ -56,12 +56,12 @@ func SSHArgv(e Env, vm string, extra []string) []string {
56 // is half identity and half plane, so one probe settles it, and the plane names 56 // is half identity and half plane, so one probe settles it, and the plane names
57 // its own gate rather than the client assuming one. 57 // its own gate rather than the client assuming one.
58 // 58 //
59 // The hosted gate address survives only as the last rung, for a server old 59 // The hosted gate address survives only as the last rung, and only against the
60 // enough to answer /me without ssh_gate, or for a pinned tenant with no 60 // hosted plane (see gateFor). The connect name must be the namespaced
61 // credential to ask with. The connect name must be the namespaced <tenant>.<vm> 61 // <tenant>.<vm> (the VM host cert's one principal, see SSHArgv), so the tenant
62 // (the VM host cert's one principal, see SSHArgv), so the tenant is always 62 // is always needed; deriving it is what means the user never has to know or
63 // needed; deriving it is what means the user never has to know or type it, and 63 // type it, and with neither a tenant nor a token the name cannot be built at
64 // with neither a tenant nor a token the name cannot be built at all. 64 // all.
65 func resolvePlane(e Env) (Env, error) { 65 func resolvePlane(e Env) (Env, error) {
66 if e.Tenant != "" && e.Gate != "" { 66 if e.Tenant != "" && e.Gate != "" {
67 return e, nil 67 return e, nil
@@ -71,7 +71,11 @@ func resolvePlane(e Env) (Env, error) {
71 if e.Tenant == "" { 71 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") 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")
73 } 73 }
74 e.Gate = defaultGate 74 gate, err := gateFor("", e.URL)
75 if err != nil {
76 return e, err
77 }
78 e.Gate = gate
75 return e, nil 79 return e, nil
76 } 80 }
77 // client.Me is deliberately context-free — its own do-timeout bounds this 81 // client.Me is deliberately context-free — its own do-timeout bounds this
@@ -87,11 +91,32 @@ func resolvePlane(e Env) (Env, error) {
87 e.Tenant = me.Tenant 91 e.Tenant = me.Tenant
88 } 92 }
89 if e.Gate == "" { 93 if e.Gate == "" {
90 e.Gate = firstNonEmpty(me.SSHGate, defaultGate) 94 gate, gerr := gateFor(me.SSHGate, e.URL)
95 if gerr != nil {
96 return e, gerr
97 }
98 e.Gate = gate
91 } 99 }
92 return e, nil 100 return e, nil
93 } 101 }
94 102
103 // gateFor settles the gate when nothing on this machine pinned one: the plane's
104 // own answer from /me, else the hosted address — and that last rung only when
105 // the plane IS the hosted one. Against a self-hosted plane the hosted gate is
106 // not a weak default but a confidently wrong one: the session would hop through
107 // eitri.sh to reach a VM that lives nowhere near it, and fail as a host-key
108 // refusal rather than as the missing configuration it is. So it says which
109 // configuration is missing, on the server side and on this one.
110 func gateFor(meGate, url string) (string, error) {
111 if meGate != "" {
112 return meGate, nil
113 }
114 if hostedPlane(url) {
115 return defaultGate, nil
116 }
117 return "", fmt.Errorf("%s names no SSH gate, and only the hosted plane has one this client can assume — set EITRI_GATE to the gate's host:port, or set ssh_gate_domain on the server so it names its own", url)
118 }
119
95 // shq single-quotes s for POSIX shell word-splitting (ProxyCommand runs via 120 // shq single-quotes s for POSIX shell word-splitting (ProxyCommand runs via
96 // the user's shell). 121 // the user's shell).
97 func shq(s string) string { 122 func shq(s string) string {
internal/cli/sshcmd_test.go
Old New
@@ -76,8 +76,9 @@ func TestResolvePlaneTenantFromMe(t *testing.T) {
76 // TestResolvePlaneGateOrder pins the gate's whole precedence chain in one place. 76 // TestResolvePlaneGateOrder pins the gate's whole precedence chain in one place.
77 // A pinned gate (EITRI_GATE, or the config file — both reach here as a non-empty 77 // A pinned gate (EITRI_GATE, or the config file — both reach here as a non-empty
78 // Env.Gate) outranks even a plane that names a different one; unpinned, the 78 // Env.Gate) outranks even a plane that names a different one; unpinned, the
79 // plane's own answer is taken; and the hardcoded hosted address survives only as 79 // plane's own answer is taken; and a self-hosted plane that names none is an
80 // the rung below that, for a server too old to send ssh_gate. 80 // error rather than a hop through the hosted gate. The hosted rung below is
81 // pinned by TestGateFor, which needs no server to reach the hosted URL.
81 func TestResolvePlaneGateOrder(t *testing.T) { 82 func TestResolvePlaneGateOrder(t *testing.T) {
82 t.Setenv("EITRI_TOKEN", "tok") 83 t.Setenv("EITRI_TOKEN", "tok")
83 for _, tc := range []struct { 84 for _, tc := range []struct {
@@ -97,11 +98,6 @@ func TestResolvePlaneGateOrder(t *testing.T) {
97 meBody: `{"tenant":"acme","ssh_gate":"gate.acme.example:2222"}`, 98 meBody: `{"tenant":"acme","ssh_gate":"gate.acme.example:2222"}`,
98 want: "gate.acme.example:2222", 99 want: "gate.acme.example:2222",
99 }, 100 },
100 {
101 name: "a server too old to say falls back to the hosted address",
102 meBody: `{"tenant":"acme"}`,
103 want: defaultGate,
104 },
105 } { 101 } {
106 t.Run(tc.name, func(t *testing.T) { 102 t.Run(tc.name, func(t *testing.T) {
107 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 103 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
@@ -125,6 +121,62 @@ func TestResolvePlaneGateOrder(t *testing.T) {
125 } 121 }
126 } 122 }
127 123
124 // TestGateFor pins the one rung that is scoped to a single plane. The hosted
125 // gate address is knowledge about eitri.sh, not about planes in general: applied
126 // to a self-hosted one it is not a weak guess but a wrong answer that looks
127 // right, and it fails later as a host-key refusal rather than as the missing
128 // setting it is. So it is offered for the hosted URL and refused elsewhere.
129 func TestGateFor(t *testing.T) {
130 for _, tc := range []struct {
131 name string
132 meGate, url string
133 want string
134 wantErr bool
135 }{
136 {
137 name: "the plane's own answer needs no default at all",
138 meGate: "gate.acme.example:2222", url: "https://eitri.acme.example",
139 want: "gate.acme.example:2222",
140 },
141 {
142 name: "the hosted plane naming none gets the hosted gate",
143 url: defaultURL, want: defaultGate,
144 },
145 {
146 name: "a trailing slash is the same hosted plane",
147 url: defaultURL + "/", want: defaultGate,
148 },
149 {
150 name: "a self-hosted plane naming none is an error, not a guess",
151 url: "https://eitri.acme.example", wantErr: true,
152 },
153 } {
154 t.Run(tc.name, func(t *testing.T) {
155 got, err := gateFor(tc.meGate, tc.url)
156 if tc.wantErr {
157 if err == nil {
158 t.Fatalf("want an error, got gate %q", got)
159 }
160 for _, want := range []string{tc.url, "EITRI_GATE", "ssh_gate_domain"} {
161 if !strings.Contains(err.Error(), want) {
162 t.Errorf("error must name %q: %v", want, err)
163 }
164 }
165 if strings.Contains(err.Error(), defaultGate) {
166 t.Errorf("the error must not still suggest the hosted gate: %v", err)
167 }
168 return
169 }
170 if err != nil {
171 t.Fatal(err)
172 }
173 if got != tc.want {
174 t.Errorf("gate = %q, want %q", got, tc.want)
175 }
176 })
177 }
178 }
179
128 // Both values pinned is the offline path: nothing to ask, so nothing is asked. 180 // Both values pinned is the offline path: nothing to ask, so nothing is asked.
129 func TestResolvePlaneFullyPinnedShortCircuits(t *testing.T) { 181 func TestResolvePlaneFullyPinnedShortCircuits(t *testing.T) {
130 srv := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) { 182 srv := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {
@@ -144,11 +196,12 @@ func TestResolvePlaneFullyPinnedShortCircuits(t *testing.T) {
144 } 196 }
145 197
146 // A pinned tenant with no credential still needs a gate, and there is nobody to 198 // A pinned tenant with no credential still needs a gate, and there is nobody to
147 // ask: the hosted address is the assumption, and it is made without a call. 199 // 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.
148 func TestResolvePlaneNoTokenPinnedTenant(t *testing.T) { 201 func TestResolvePlaneNoTokenPinnedTenant(t *testing.T) {
149 t.Setenv("EITRI_TOKEN", "") 202 t.Setenv("EITRI_TOKEN", "")
150 e := testEnv() 203 e := testEnv()
151 e.Gate, e.Tenant = "", "team" 204 e.URL, e.Gate, e.Tenant = defaultURL, "", "team"
152 got, err := resolvePlane(e) 205 got, err := resolvePlane(e)
153 if err != nil { 206 if err != nil {
154 t.Fatal(err) 207 t.Fatal(err)
@@ -156,6 +209,11 @@ func TestResolvePlaneNoTokenPinnedTenant(t *testing.T) {
156 if got.Gate != defaultGate { 209 if got.Gate != defaultGate {
157 t.Errorf("gate = %q, want %q", got.Gate, defaultGate) 210 t.Errorf("gate = %q, want %q", got.Gate, defaultGate)
158 } 211 }
212
213 e.URL = "https://eitri.acme.example"
214 if _, err := resolvePlane(e); err == nil || !strings.Contains(err.Error(), "names no SSH gate") {
215 t.Errorf("a self-hosted plane must not be given the hosted gate: %v", err)
216 }
159 } 217 }
160 218
161 // A credential that resolves to no tenant, or a failing /me, is a clear error. 219 // A credential that resolves to no tenant, or a failing /me, is a clear error.
internal/server/boot/sealedgate_test.go
Old New
@@ -127,3 +127,57 @@ func TestSetupSSHGateNeedsBothKeyPaths(t *testing.T) {
127 }) 127 })
128 } 128 }
129 } 129 }
130
131 // TestSetupSSHGateNeedsAName is the refusal that keeps a plane from booting a
132 // gate nobody can reach. A bind says which interfaces to accept on, not what to
133 // call the machine, so a wildcard bind with no ssh_gate_domain leaves the gate
134 // nameless: /me would advertise nothing and the host certificate would name
135 // localhost, which every remote client refuses against the name it dialed. No
136 // EITRI_GATE fixes that from the client side, so the server stops instead —
137 // which is what lets everything downstream assume a running gate has an address.
138 func TestSetupSSHGateNeedsAName(t *testing.T) {
139 for _, tc := range []struct {
140 name string
141 listen, domain string
142 wantRefusal bool
143 }{
144 {name: "an all-interfaces bind with no domain refuses", listen: ":2222", wantRefusal: true},
145 {name: "an IPv4 wildcard bind with no domain refuses", listen: "0.0.0.0:2222", wantRefusal: true},
146 {name: "an IPv6 wildcard bind with no domain refuses", listen: "[::]:2222", wantRefusal: true},
147 {name: "a wildcard bind boots once it is named", listen: ":2222", domain: "gate.eitri.sh"},
148 {name: "a concrete bind names itself", listen: "gate.example.com:2222"},
149 {name: "a loopback bind is the truth for a single-machine plane", listen: "127.0.0.1:2223"},
150 } {
151 t.Run(tc.name, func(t *testing.T) {
152 cfg := gateConfig(t)
153 cfg.SSHListen, cfg.SSHGateDomain = tc.listen, tc.domain
154
155 g, err := setupSSHGate(cfg, kekFilled(0x2b))
156 if !tc.wantRefusal {
157 require.NoError(t, err)
158 require.NotNil(t, g)
159 assert.NotEmpty(t, g.gateAddr(), "a booted gate always has an address to advertise")
160 return
161 }
162 require.Error(t, err)
163 assert.Nil(t, g)
164 // Both remedies, so the operator is not left to guess which half of
165 // the pair to change.
166 assert.Contains(t, err.Error(), "ssh_gate_domain")
167 assert.Contains(t, err.Error(), "127.0.0.1")
168 _, statErr := os.Stat(cfg.SSHCAKey)
169 assert.True(t, os.IsNotExist(statErr), "a refused config creates no key material")
170 })
171 }
172 }
173
174 // TestSetupSSHGateOffIsUnaffectedByTheNameCheck: no ssh_listen means no gate at
175 // all, and a gate that does not exist needs no name.
176 func TestSetupSSHGateOffIsUnaffectedByTheNameCheck(t *testing.T) {
177 cfg := gateConfig(t)
178 cfg.SSHListen, cfg.SSHGateDomain = "", ""
179
180 g, err := setupSSHGate(cfg, kekFilled(0x2b))
181 require.NoError(t, err)
182 assert.Nil(t, g)
183 }
internal/server/boot/sshgate.go
Old New
@@ -32,6 +32,10 @@ type sshGateSetup struct {
32 // host key (0600, sealed under kek, never logged). A key file that will not 32 // host key (0600, sealed under kek, never logged). A key file that will not
33 // open is a startup failure: sshca never regenerates over one, because a fresh 33 // open is a startup failure: sshca never regenerates over one, because a fresh
34 // host CA would invalidate every pin and every VM's host certificate at once. 34 // host CA would invalidate every pin and every VM's host certificate at once.
35 //
36 // A gate that is on must also be able to name itself, or it does not boot: see
37 // the refusal below. That is what lets the rest of the system assume a running
38 // plane with a gate publishes a real address for it.
35 func setupSSHGate(cfg serverconfig.Config, kek []byte) (*sshGateSetup, error) { 39 func setupSSHGate(cfg serverconfig.Config, kek []byte) (*sshGateSetup, error) {
36 if cfg.SSHListen == "" { 40 if cfg.SSHListen == "" {
37 return nil, nil 41 return nil, nil
@@ -39,10 +43,21 @@ func setupSSHGate(cfg serverconfig.Config, kek []byte) (*sshGateSetup, error) {
39 if cfg.SSHCAKey == "" || cfg.SSHHostKey == "" { 43 if cfg.SSHCAKey == "" || cfg.SSHHostKey == "" {
40 return nil, errors.New("ssh_ca_key and ssh_host_key are required when ssh_listen is set") 44 return nil, errors.New("ssh_ca_key and ssh_host_key are required when ssh_listen is set")
41 } 45 }
46 g := &sshGateSetup{listen: cfg.SSHListen, domain: cfg.SSHGateDomain}
47 // The config is internally consistent and still serves nobody. gateAddr
48 // publishing nothing is the symptom; the cause is that the gate has no name,
49 // and gateDomain therefore puts "localhost" on its host certificate — which
50 // every client verifies against the name it dialed, so no value of EITRI_GATE
51 // reaches this gate from another machine. Stop here rather than run one only
52 // this box can use.
53 if g.gateAddr() == "" {
54 return nil, fmt.Errorf("ssh_gate_domain is required when ssh_listen binds every interface (got %q): a bind says which interfaces to accept on, not what to call the machine, so the gate has no name to advertise and its host certificate would name localhost, which every remote client refuses against the name it dialed. Set ssh_gate_domain to the name clients dial, or bind one concrete address (127.0.0.1 for a plane only its own machine reaches)", cfg.SSHListen)
55 }
42 sshGate, err := sshca.New(cfg.SSHCAKey, cfg.SSHHostKey, kek) 56 sshGate, err := sshca.New(cfg.SSHCAKey, cfg.SSHHostKey, kek)
43 if err != nil { 57 if err != nil {
44 return nil, fmt.Errorf("ssh ca: %w", err) 58 return nil, fmt.Errorf("ssh ca: %w", err)
45 } 59 }
60 g.ca = sshGate
46 // Log the HOST CA identity operators pin via @cert-authority for gate + VM 61 // Log the HOST CA identity operators pin via @cert-authority for gate + VM
47 // host verification. Only the *public* key is ever logged (private material 62 // host verification. Only the *public* key is ever logged (private material
48 // never is). eitri holds no user CA — those are BYO per-tenant. 63 // never is). eitri holds no user CA — those are BYO per-tenant.
@@ -50,7 +65,7 @@ func setupSSHGate(cfg serverconfig.Config, kek []byte) (*sshGateSetup, error) {
50 "host_ca", string(sshGate.HostCAAuthorizedKey())) 65 "host_ca", string(sshGate.HostCAAuthorizedKey()))
51 // The gate listener itself is started later (startListener), once 66 // The gate listener itself is started later (startListener), once
52 // syncsvc.Service (the tunnel dialer) exists. 67 // syncsvc.Service (the tunnel dialer) exists.
53 return &sshGateSetup{ca: sshGate, listen: cfg.SSHListen, domain: cfg.SSHGateDomain}, nil 68 return g, nil
54 } 69 }
55 70
56 // wireAPI publishes what a client needs to reach a guest: the HOST CA pubkey 71 // wireAPI publishes what a client needs to reach a guest: the HOST CA pubkey
@@ -69,7 +84,9 @@ func (g *sshGateSetup) wireAPI(a *api.API) {
69 84
70 // gateDomain is the hostname clients dial the gate as, and therefore the one 85 // 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 86 // principal on its host certificate: the configured ssh_gate_domain, else the
72 // host part of ssh_listen, else localhost. 87 // host part of ssh_listen, else localhost. That last rung is the reason a
88 // wildcard bind with no domain refuses to boot — a certificate has to name
89 // something, and "localhost" is a name no remote client can dial.
73 func (g *sshGateSetup) gateDomain() string { 90 func (g *sshGateSetup) gateDomain() string {
74 if g.domain != "" { 91 if g.domain != "" {
75 return g.domain 92 return g.domain
@@ -81,16 +98,14 @@ func (g *sshGateSetup) gateDomain() string {
81 } 98 }
82 99
83 // gateAddr is the full address a client dials for the gate hop — the value 100 // 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 101 // EITRI_GATE takes and the one the console prints in its connect recipes.
85 // "" when this plane has no name worth handing out, in which case it publishes
86 // none and the console offers no recipe.
87 // 102 //
88 // The host part is the configured ssh_gate_domain, else the host ssh_listen 103 // 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: 104 // 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 105 // 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. 106 // recipe built from one dials the wrong host from everywhere but this one. It
92 // gateDomain still answers "localhost" there, because the host certificate has 107 // answers "" there — and setupSSHGate reads that empty answer as a refusal to
93 // to name something, but a certificate nobody can reach is not an address. 108 // boot, so a running plane with a gate on always has an address to publish.
94 // 109 //
95 // Whatever it returns, the host part is gateDomain and nothing else: a client 110 // 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 111 // verifies the name it dialed against that certificate, so any other spelling
internal/server/boot/sshgate_test.go
Old New
@@ -260,7 +260,10 @@ func TestWireSyncIsANoOpWhenTheGateIsOff(t *testing.T) {
260 // It also pins the one case where they part company. A wildcard bind gets a 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 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 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. 263 // machine and false everywhere a user might read them. Those rows are the
264 // predicate setupSSHGate refuses to boot on (TestSetupSSHGateNeedsAName), so
265 // they are configs no running plane holds — pinned here because that refusal
266 // reads its answer.
264 func TestGateAddrNamesTheGateAsItsCertificateDoes(t *testing.T) { 267 func TestGateAddrNamesTheGateAsItsCertificateDoes(t *testing.T) {
265 for _, tc := range []struct { 268 for _, tc := range []struct {
266 name string 269 name string
internal/server/config/config.go
Old New
@@ -64,9 +64,11 @@ type Config struct {
64 SSHListen string `json:"ssh_listen"` 64 SSHListen string `json:"ssh_listen"`
65 // SSHGateDomain is the hostname clients dial the gate as (the principal put 65 // SSHGateDomain is the hostname clients dial the gate as (the principal put
66 // on the gate's signed HOST certificate). Empty ⇒ derived from SSHListen's 66 // on the gate's signed HOST certificate). Empty ⇒ derived from SSHListen's
67 // host part; if that is also empty (e.g. ":2222") it falls back to 67 // host part, which is why it is REQUIRED whenever SSHListen binds every
68 // "localhost". It must match the host in EITRI_GATE so `@cert-authority` 68 // interface (":2222", "0.0.0.0:…", "[::]:…"): there is no host to derive,
69 // verification accepts the presented host cert. 69 // and a gate that cannot name itself refuses to boot. It must match the
70 // host in EITRI_GATE so `@cert-authority` verification accepts the
71 // presented host cert.
70 SSHGateDomain string `json:"ssh_gate_domain"` 72 SSHGateDomain string `json:"ssh_gate_domain"`
71 // ReleaseManifestURL is where the server discovers the latest eitri 73 // ReleaseManifestURL is where the server discovers the latest eitri
72 // release (default https://eitri.sh/dl/latest/manifest.json when the 74 // release (default https://eitri.sh/dl/latest/manifest.json when the