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
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 |