a73x

b76d6744

feat(cli): one guided command stands between a token and a first shell

a73x   2026-08-11 15:18

Commit message
feat(cli): one guided command stands between a token and a first shell

`eitri init` is the first run. It asks for a personal access token, proves it
against /api/v1/me, settles the tenant's SSH CA, and writes the config every
other command reads — printing what each step will do and doing it only on a
yes. Afterwards `eitri ssh <vm>` needs no environment at all: the config names
the plane and the tenant, the certificate is signed locally, and the host-CA
pin comes from a public endpoint.

The CA step never blind-generates, because a CA is an identity and a second one
where a first exists is how a laptop ends up signing certs that are perfectly
formed and refused by every guest. It lists what the tenant has registered,
recognizes the key already on this machine, offers to register a key found
elsewhere, and only when there is neither offers to make one — naming both
files and their modes first. A signing key that matches nothing the tenant
registered is called out in those words, rather than left to arrive later as a
bare permission denied.

The client gains a config file, ~/.eitri/config.json, resolved beneath the
environment: an EITRI_* variable overrides it, it overrides the hosted
defaults. A file that exists but does not parse is named rather than silently
replaced by a default, and it is written through a rename so an interrupted
init leaves the previous config whole.

The gate stops being a constant the client assumes. A plane names its own on
/me, so `eitri ssh` takes it from there when nothing pins it, and the hosted
address survives only as the rung below — for a server too old to send the
field, or a pinned tenant with no credential to ask with. One /me probe now
answers both halves of a connect name, identity and plane.

docs/quickstart.md
Old New
@@ -123,15 +123,13 @@ a Mac's guests sit on vmnet's subnet. The host reports that subnet to the fleet,
123 so it is what the console shows—no allocation is made for a Mac and none is 123 so it is what the console shows—no allocation is made for a Mac and none is
124 used. Everything above that—`eitri ssh`, the console, reconcile—is the same. 124 used. Everything above that—`eitri ssh`, the console, reconcile—is the same.
125 125
126 ### Register your CA 126 ### Set up your laptop
127 127
128 SSH access uses certificates signed by **your** CA—eitri never holds a 128 SSH access uses certificates signed by **your** CA—eitri never holds a
129 user key that can enter your VMs. A guest trusts the CA set it is created with, 129 user key that can enter your VMs. A guest trusts the CA set it is created with,
130 so this comes before your first VM. 130 so this comes before your first VM.
131 131
132 On your laptop, download the client CLI. It 132 Download the client CLI. It targets the hosted service by default:
133 targets the hosted service by default, so the only thing to set is a personal
134 access token:
135 133
136 ```sh 134 ```sh
137 OS=$(uname -s | tr A-Z a-z) ARCH=amd64 # arm64 laptop → ARCH=arm64 135 OS=$(uname -s | tr A-Z a-z) ARCH=amd64 # arm64 laptop → ARCH=arm64
@@ -141,13 +139,43 @@ curl -fsSLO "https://eitri.sh/dl/latest/eitri-cli_${V}_${OS}_${ARCH}.tar.gz"
141 sha256sum -c SHA256SUMS --ignore-missing 139 sha256sum -c SHA256SUMS --ignore-missing
142 tar xzf "eitri-cli_${V}_${OS}_${ARCH}.tar.gz" 140 tar xzf "eitri-cli_${V}_${OS}_${ARCH}.tar.gz"
143 sudo install -m 0755 "eitri-cli_${V}_${OS}_${ARCH}/eitri" /usr/local/bin/eitri 141 sudo install -m 0755 "eitri-cli_${V}_${OS}_${ARCH}/eitri" /usr/local/bin/eitri
142 ```
143
144 Then run the guided setup:
144 145
145 export EITRI_TOKEN=<pat> # mint one in the console → Settings → Personal access tokens 146 ```sh
147 eitri init
146 ``` 148 ```
147 149
148 Register a user CA once—eitri gets the public key, never the private one: 150 It asks for a personal access token—mint one in the console under **Settings →
151 Personal access tokens**—and walks three steps, printing what each will do and
152 doing it only on a `y`:
153
154 1. **Identity.** Calls `/api/v1/me` with the token to learn your tenant and
155 which SSH gate this plane runs. The token is used once and never stored;
156 entered at the prompt it is not echoed.
157 2. **CA.** Lists the CAs your tenant has already registered. If the signing key
158 on this laptop is one of them, there is nothing to do. If you have a key it
159 does not know—here or at a path you give it—init offers to register the
160 public half. Only when there is neither does it offer to generate an ed25519
161 pair, naming both files and their modes before writing anything.
162 3. **Config.** Writes `~/.eitri/config.json`—plane, gate, tenant, CA and key
163 paths—after showing the current values beside what would change.
164
165 Run it again whenever: each step reports what is already settled and touches
166 nothing. It is also how you find out that the key on this laptop signs certs
167 your tenant never registered—the failure that otherwise arrives as a bare
168 "permission denied" from a guest that was never going to trust you.
169
170 After init, `eitri ssh` needs no environment at all: the config names the plane
171 and the tenant, the certificate is signed locally, and the host-CA pin comes
172 from a public endpoint. Every `EITRI_*` variable still overrides the file, and
173 the file still overrides the hosted defaults.
174
175 By hand instead—`eitri init` does exactly this, and asks first:
149 176
150 ```sh 177 ```sh
178 export EITRI_TOKEN=<pat>
151 ssh-keygen -t ed25519 -N '' -f ~/.ssh/eitri_user_ca -C "my eitri user CA" 179 ssh-keygen -t ed25519 -N '' -f ~/.ssh/eitri_user_ca -C "my eitri user CA"
152 eitri ca upload ~/.ssh/eitri_user_ca.pub 180 eitri ca upload ~/.ssh/eitri_user_ca.pub
153 ``` 181 ```
@@ -171,14 +199,18 @@ tool—then create the VM.
171 199
172 ### SSH in 200 ### SSH in
173 201
174 The console and gate are hosted defaults, and `eitri ssh` uses 202 `eitri init` wrote the plane, the gate and your tenant, so there is nothing
175 your token to look up your tenant for the connect name—nothing else to set: 203 left to set:
176 204
177 ```sh 205 ```sh
178 eitri ssh <vm-name> 206 eitri ssh <vm-name>
179 eitri ssh <vm-name> uptime 207 eitri ssh <vm-name> uptime
180 ``` 208 ```
181 209
210 Skipped init? The console and gate fall back to the hosted defaults, and
211 `EITRI_TOKEN` alone is enough—`eitri ssh` asks the plane for your tenant and
212 its gate.
213
182 ### Expose a service 214 ### Expose a service
183 215
184 The gate reaches a guest over SSH. Anything else a guest serves needs a 216 The gate reaches a guest over SSH. Anything else a guest serves needs a
@@ -427,6 +459,17 @@ public key, never the private one. On your laptop:
427 tar xzf eitri-cli_*_$(uname -s | tr A-Z a-z)_*.tar.gz 459 tar xzf eitri-cli_*_$(uname -s | tr A-Z a-z)_*.tar.gz
428 sudo install -m 0755 eitri-cli_*/eitri /usr/local/bin/eitri 460 sudo install -m 0755 eitri-cli_*/eitri /usr/local/bin/eitri
429 461
462 EITRI_URL=http://192.0.2.10:8080 eitri init
463 ```
464
465 `EITRI_URL` points the first run at your box; init writes it to
466 `~/.eitri/config.json` along with your tenant and the gate address the server
467 names on `/api/v1/me`, so later runs need neither variable. The three steps and
468 what each confirms are described under "Set up your laptop" above.
469
470 By hand instead:
471
472 ```sh
430 export EITRI_URL=http://192.0.2.10:8080 473 export EITRI_URL=http://192.0.2.10:8080
431 export EITRI_TOKEN=<pat> # mint one in the console → Settings 474 export EITRI_TOKEN=<pat> # mint one in the console → Settings
432 475
@@ -444,15 +487,15 @@ guest boots, then `ready`. Power reads `running`, an IP appears, you're on.
444 ### SSH in 487 ### SSH in
445 488
446 ```sh 489 ```sh
447 export EITRI_URL=http://192.0.2.10:8080 # set earlier, when you booted the VM
448 export EITRI_GATE=192.0.2.10:2222 # must match ssh_gate_domain
449
450 eitri ssh <vm-name> 490 eitri ssh <vm-name>
451 eitri ssh <vm-name> uptime 491 eitri ssh <vm-name> uptime
452 ``` 492 ```
453 493
454 Unset, `EITRI_URL` and `EITRI_GATE` default to the hosted service at eitri.sh, 494 `eitri init` wrote the plane and the gate, so nothing needs exporting. Without
455 so self-hosting always sets them to your own box. 495 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
497 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.
456 499
457 `eitri ssh` is plain ssh in a trenchcoat: it signs a short-lived cert with 500 `eitri ssh` is plain ssh in a trenchcoat: it signs a short-lived cert with
458 your CA, pins eitri's host CA, and jumps the gate to `ubuntu@<tenant>.<vm-name>` 501 your CA, pins eitri's host CA, and jumps the gate to `ubuntu@<tenant>.<vm-name>`
internal/cli/config.go
Old New
@@ -0,0 +1,106 @@
1 package cli
2
3 import (
4 "encoding/json"
5 "errors"
6 "fmt"
7 "os"
8 "path/filepath"
9 )
10
11 // Config is the client's on-disk settings, written by `eitri init` and read by
12 // every command underneath the environment. It holds the two facts a connect
13 // name needs — which plane, which tenant — and the paths of the keys that sign
14 // for it, so a laptop that has run init needs no EITRI_* variable at all: not
15 // even a token, since minting a cert is local and the host-CA pin comes from a
16 // public endpoint.
17 //
18 // It never holds a credential. A PAT is typed, used to ask the plane who you
19 // are, and forgotten; nothing here is secret, and a leaked config leaks only
20 // where you keep your keys.
21 type Config struct {
22 URL string `json:"url,omitempty"`
23 Gate string `json:"gate,omitempty"`
24 Tenant string `json:"tenant,omitempty"`
25 CA string `json:"ca,omitempty"`
26 Key string `json:"key,omitempty"`
27 }
28
29 // The known-hosts pin file is deliberately absent: it is a cache eitri rewrites
30 // on every connect, not a setting, and EITRI_KNOWN_HOSTS still moves it.
31
32 // ConfigPath is where the client keeps its settings: EITRI_CONFIG, else
33 // ~/.eitri/config.json — the same ~/.eitri a Mac agent keeps its state under.
34 func ConfigPath() (string, error) {
35 if p := os.Getenv("EITRI_CONFIG"); p != "" {
36 return p, nil
37 }
38 home, err := os.UserHomeDir()
39 if err != nil {
40 return "", err
41 }
42 return filepath.Join(home, ".eitri", "config.json"), nil
43 }
44
45 // LoadConfig reads the config at path. A missing file is the zero Config and no
46 // error — an unconfigured laptop is the normal case, and every field falls back.
47 // A file that exists but does not parse is an error naming the path: treating it
48 // as empty would hide a half-written config behind whatever default happened to
49 // apply, and the user would be told nothing while talking to the wrong plane.
50 func LoadConfig(path string) (Config, error) {
51 raw, err := os.ReadFile(path)
52 if errors.Is(err, os.ErrNotExist) {
53 return Config{}, nil
54 }
55 if err != nil {
56 return Config{}, err
57 }
58 var c Config
59 if err := json.Unmarshal(raw, &c); err != nil {
60 return Config{}, fmt.Errorf("%s: not readable as an eitri config (delete it and run 'eitri init'): %w", path, err)
61 }
62 return c, nil
63 }
64
65 // SaveConfig writes c to path through a temp file in the same directory and a
66 // rename, so what is on disk is always a whole config: an interrupted write
67 // leaves the previous file untouched rather than a truncated one for the next
68 // run to trip over.
69 func SaveConfig(path string, c Config) error {
70 dir := filepath.Dir(path)
71 if err := os.MkdirAll(dir, 0o700); err != nil {
72 return err
73 }
74 raw, err := json.MarshalIndent(c, "", " ")
75 if err != nil {
76 return err
77 }
78 f, err := os.CreateTemp(dir, ".config-*.json")
79 if err != nil {
80 return err
81 }
82 tmp := f.Name()
83 defer os.Remove(tmp)
84 if err := f.Chmod(0o600); err != nil {
85 f.Close()
86 return err
87 }
88 if _, err := f.Write(append(raw, '\n')); err != nil {
89 f.Close()
90 return err
91 }
92 if err := f.Close(); err != nil {
93 return err
94 }
95 return os.Rename(tmp, path)
96 }
97
98 // ConfigFromDisk loads the config from its resolved path — the pairing FromEnv
99 // uses, and the one `eitri init` reads to show what it would change.
100 func ConfigFromDisk() (Config, error) {
101 path, err := ConfigPath()
102 if err != nil {
103 return Config{}, err
104 }
105 return LoadConfig(path)
106 }
internal/cli/config_test.go
Old New
@@ -0,0 +1,82 @@
1 package cli
2
3 import (
4 "os"
5 "path/filepath"
6 "strings"
7 "testing"
8 )
9
10 // A laptop that has never run init has no config, and that is not a failure —
11 // every field simply falls back.
12 func TestLoadConfigMissingIsEmpty(t *testing.T) {
13 c, err := LoadConfig(filepath.Join(t.TempDir(), "nothing.json"))
14 if err != nil {
15 t.Fatalf("a missing config must not be an error: %v", err)
16 }
17 if c != (Config{}) {
18 t.Errorf("config = %+v, want zero", c)
19 }
20 }
21
22 // A file that exists but does not parse is named, not swallowed: this is the
23 // half-written config an aborted init could leave, and the user must be told
24 // rather than quietly served a default.
25 func TestLoadConfigUnreadableIsNamed(t *testing.T) {
26 path := filepath.Join(t.TempDir(), "config.json")
27 if err := os.WriteFile(path, []byte("{not json"), 0o600); err != nil {
28 t.Fatal(err)
29 }
30 _, err := LoadConfig(path)
31 if err == nil || !strings.Contains(err.Error(), path) {
32 t.Fatalf("want an error naming %s, got %v", path, err)
33 }
34 }
35
36 func TestSaveConfigRoundTrips(t *testing.T) {
37 path := filepath.Join(t.TempDir(), "sub", "config.json")
38 want := Config{URL: "http://192.0.2.10:8080", Gate: "192.0.2.10:2222", Tenant: "acme", CA: "/keys/ca", Key: "/keys/user"}
39 if err := SaveConfig(path, want); err != nil {
40 t.Fatal(err)
41 }
42 got, err := LoadConfig(path)
43 if err != nil {
44 t.Fatal(err)
45 }
46 if got != want {
47 t.Errorf("round trip: got %+v want %+v", got, want)
48 }
49 fi, err := os.Stat(path)
50 if err != nil {
51 t.Fatal(err)
52 }
53 if perm := fi.Mode().Perm(); perm != 0o600 {
54 t.Errorf("mode = %o, want 600", perm)
55 }
56 // The rename is the point: no temp file may be left behind to be found by
57 // a later glob or backup.
58 entries, err := os.ReadDir(filepath.Dir(path))
59 if err != nil {
60 t.Fatal(err)
61 }
62 if len(entries) != 1 {
63 t.Errorf("directory holds %d files, want only the config", len(entries))
64 }
65 }
66
67 // EITRI_CONFIG moves the file; unset, it sits under ~/.eitri.
68 func TestConfigPath(t *testing.T) {
69 t.Setenv("EITRI_CONFIG", "/tmp/elsewhere.json")
70 if p, err := ConfigPath(); err != nil || p != "/tmp/elsewhere.json" {
71 t.Errorf("EITRI_CONFIG: got %q, %v", p, err)
72 }
73 t.Setenv("EITRI_CONFIG", "")
74 t.Setenv("HOME", "/home/u")
75 p, err := ConfigPath()
76 if err != nil {
77 t.Fatal(err)
78 }
79 if want := filepath.Join("/home/u", ".eitri", "config.json"); p != want {
80 t.Errorf("default path = %q, want %q", p, want)
81 }
82 }
internal/cli/env.go
Old New
@@ -10,55 +10,59 @@ import (
10 "path/filepath" 10 "path/filepath"
11 ) 11 )
12 12
13 // Hosted-service defaults: unset, the CLI targets the hosted service at 13 // Hosted-service defaults: unset and unconfigured, the CLI targets the hosted
14 // eitri.sh, so a fresh laptop needs no configuration to reach it. Self-hosters 14 // service at eitri.sh, so a fresh laptop needs no configuration to reach it.
15 // and tests point elsewhere by setting the matching environment variable. 15 // Self-hosters and tests point elsewhere by setting the matching environment
16 // variable or by running `eitri init`.
17 //
18 // defaultGate is the weaker of the two. A plane names its own gate on /me, so
19 // this address is only what a client assumes when nothing has told it better:
20 // no EITRI_GATE, no config, and either no credential to ask with or a server
21 // old enough to answer /me without ssh_gate.
16 const ( 22 const (
17 defaultURL = "https://console.eitri.sh" 23 defaultURL = "https://console.eitri.sh"
18 defaultGate = "gate.eitri.sh:2222" 24 defaultGate = "gate.eitri.sh:2222"
19 ) 25 )
20 26
21 // Env is the client configuration, resolved from EITRI_* variables. 27 // Env is the client configuration, resolved from EITRI_* variables over the
28 // config file.
22 type Env struct { 29 type Env struct {
23 URL string // server base URL (EITRI_URL, else the hosted default) 30 URL string // server base URL (EITRI_URL, else config, else the hosted default)
24 Gate string // gate host[:port] (EITRI_GATE, else the hosted default) 31 Gate string // gate host[:port] (EITRI_GATE, else config, else the plane's own answer)
25 CA string // tenant user-CA private key path 32 CA string // tenant user-CA private key path
26 Tenant string 33 Tenant string
27 Key string // user SSH private key path 34 Key string // user SSH private key path
28 KnownHosts string // dedicated pin file — never the user's main known_hosts 35 KnownHosts string // dedicated pin file — never the user's main known_hosts
29 } 36 }
30 37
31 // URLFromEnv is the server base URL: EITRI_URL, or the hosted-service default. 38 // FromEnv resolves the client configuration: an EITRI_* variable when set, else
32 // It is the SINGLE source of that default — every consumer (ssh and ca alike) 39 // the value `eitri init` wrote to the config file, else a default. It is the
33 // resolves the URL through it, so the default string is written in exactly one 40 // SINGLE door onto both — every consumer (ssh, ca, init alike) resolves through
34 // place. 41 // it, so each precedence rule is written in exactly one place.
35 func URLFromEnv() string { 42 //
36 if u := os.Getenv("EITRI_URL"); u != "" { 43 // The environment sits above the file because it is the per-invocation
37 return u 44 // override; the file sits above the defaults because it is the user's own
38 } 45 // deliberate answer. Gate is the one field with a rung below the file: left
39 return defaultURL 46 // empty here, `eitri ssh` asks the plane for it (see resolvePlane) rather than
40 } 47 // assuming the hosted address.
41 48 //
42 // FromEnv resolves the client configuration. The CLI's defaults are the hosted 49 // Tenant is optional at this layer — `eitri ssh` derives it from the credential
43 // service at eitri.sh: EITRI_URL falls back to the hosted console and 50 // via /me when nothing pins it, so the user never has to type it; a pinned
44 // EITRI_GATE to the hosted gate, so nothing needs setting to reach it; 51 // tenant is the offline path and the way to disambiguate a user CA registered
45 // self-hosters and tests override via the environment. EITRI_TENANT is optional 52 // in more than one tenant. The path fields default under $HOME/.ssh.
46 // — `eitri ssh` derives the tenant from the credential (via /me) to build the
47 // namespaced connect name, so it is only needed offline or to disambiguate a
48 // user CA registered in more than one tenant. The path fields default under
49 // $HOME/.ssh.
50 func FromEnv() (Env, error) { 53 func FromEnv() (Env, error) {
54 cfg, err := ConfigFromDisk()
55 if err != nil {
56 return Env{}, err
57 }
51 e := Env{ 58 e := Env{
52 URL: URLFromEnv(), 59 URL: firstNonEmpty(os.Getenv("EITRI_URL"), cfg.URL, defaultURL),
53 Gate: os.Getenv("EITRI_GATE"), 60 Gate: firstNonEmpty(os.Getenv("EITRI_GATE"), cfg.Gate),
54 CA: os.Getenv("EITRI_CA"), 61 CA: firstNonEmpty(os.Getenv("EITRI_CA"), cfg.CA),
55 Tenant: os.Getenv("EITRI_TENANT"), 62 Tenant: firstNonEmpty(os.Getenv("EITRI_TENANT"), cfg.Tenant),
56 Key: os.Getenv("EITRI_KEY"), 63 Key: firstNonEmpty(os.Getenv("EITRI_KEY"), cfg.Key),
57 KnownHosts: os.Getenv("EITRI_KNOWN_HOSTS"), 64 KnownHosts: os.Getenv("EITRI_KNOWN_HOSTS"),
58 } 65 }
59 if e.Gate == "" {
60 e.Gate = defaultGate
61 }
62 home, err := os.UserHomeDir() 66 home, err := os.UserHomeDir()
63 if err != nil { 67 if err != nil {
64 return Env{}, err 68 return Env{}, err
@@ -73,3 +77,14 @@ func FromEnv() (Env, error) {
73 def(&e.KnownHosts, "eitri_known_hosts") 77 def(&e.KnownHosts, "eitri_known_hosts")
74 return e, nil 78 return e, nil
75 } 79 }
80
81 // firstNonEmpty returns the first set value — the shape every precedence chain
82 // in this package takes.
83 func firstNonEmpty(vals ...string) string {
84 for _, v := range vals {
85 if v != "" {
86 return v
87 }
88 }
89 return ""
90 }
internal/cli/env_test.go
Old New
@@ -1,17 +1,36 @@
1 package cli 1 package cli
2 2
3 import ( 3 import (
4 "os"
4 "path/filepath" 5 "path/filepath"
6 "strings"
5 "testing" 7 "testing"
6 ) 8 )
7 9
10 // cleanEnv isolates a test from the developer's own laptop: every EITRI_*
11 // variable unset, and the config file pointed at an empty temp dir so FromEnv
12 // cannot read a real ~/.eitri/config.json. It returns the config path, for the
13 // tests that want to put a file there.
14 func cleanEnv(t *testing.T) string {
15 t.Helper()
16 dir := t.TempDir()
17 for _, v := range []string{
18 "EITRI_URL", "EITRI_GATE", "EITRI_TENANT", "EITRI_CA", "EITRI_KEY",
19 "EITRI_KNOWN_HOSTS", "EITRI_TOKEN",
20 } {
21 t.Setenv(v, "")
22 }
23 cfg := filepath.Join(dir, "config.json")
24 t.Setenv("EITRI_CONFIG", cfg)
25 t.Setenv("HOME", dir)
26 return cfg
27 }
28
8 func TestFromEnvDefaults(t *testing.T) { 29 func TestFromEnvDefaults(t *testing.T) {
30 cleanEnv(t)
9 t.Setenv("EITRI_URL", "http://192.0.2.10:8080") 31 t.Setenv("EITRI_URL", "http://192.0.2.10:8080")
10 t.Setenv("EITRI_GATE", "192.0.2.10:2222") 32 t.Setenv("EITRI_GATE", "192.0.2.10:2222")
11 t.Setenv("EITRI_TENANT", "acme") 33 t.Setenv("EITRI_TENANT", "acme")
12 for _, v := range []string{"EITRI_CA", "EITRI_KEY", "EITRI_KNOWN_HOSTS"} {
13 t.Setenv(v, "")
14 }
15 t.Setenv("HOME", "/home/u") 34 t.Setenv("HOME", "/home/u")
16 35
17 e, err := FromEnv() 36 e, err := FromEnv()
@@ -35,30 +54,91 @@ func TestFromEnvDefaults(t *testing.T) {
35 func TestFromEnvHostedDefaults(t *testing.T) { 54 func TestFromEnvHostedDefaults(t *testing.T) {
36 // A clean environment targets the hosted service at eitri.sh — no 55 // A clean environment targets the hosted service at eitri.sh — no
37 // configuration needed to reach it (TestFromEnvDefaults proves the env 56 // configuration needed to reach it (TestFromEnvDefaults proves the env
38 // still overrides these). 57 // still overrides these). The gate is the exception: left empty here so
39 for _, v := range []string{"EITRI_URL", "EITRI_GATE", "EITRI_TENANT", "EITRI_CA", "EITRI_KEY", "EITRI_KNOWN_HOSTS"} { 58 // `eitri ssh` can ask the plane to name it rather than assuming.
40 t.Setenv(v, "") 59 cleanEnv(t)
41 }
42 e, err := FromEnv() 60 e, err := FromEnv()
43 if err != nil { 61 if err != nil {
44 t.Fatalf("clean env must succeed: %v", err) 62 t.Fatalf("clean env must succeed: %v", err)
45 } 63 }
46 if e.URL != "https://console.eitri.sh" || e.Gate != "gate.eitri.sh:2222" { 64 if e.URL != "https://console.eitri.sh" {
47 t.Errorf("hosted defaults: URL=%q Gate=%q", e.URL, e.Gate) 65 t.Errorf("hosted default URL = %q", e.URL)
66 }
67 if e.Gate != "" {
68 t.Errorf("gate = %q, want empty so the plane can name it", e.Gate)
48 } 69 }
49 } 70 }
50 71
51 func TestFromEnvTenantOptional(t *testing.T) { 72 func TestFromEnvTenantOptional(t *testing.T) {
52 // EITRI_TENANT is optional: `eitri ssh` derives the tenant from the 73 // The tenant is optional: `eitri ssh` derives it from the credential when
53 // credential when it is unset, so an unset tenant is not a FromEnv error. 74 // nothing pins it, so an unset tenant is not a FromEnv error.
75 cleanEnv(t)
54 t.Setenv("EITRI_URL", "http://192.0.2.10:8080") 76 t.Setenv("EITRI_URL", "http://192.0.2.10:8080")
55 t.Setenv("EITRI_GATE", "192.0.2.10:2222")
56 t.Setenv("EITRI_TENANT", "")
57 e, err := FromEnv() 77 e, err := FromEnv()
58 if err != nil { 78 if err != nil {
59 t.Fatalf("unset EITRI_TENANT must be accepted: %v", err) 79 t.Fatalf("unset tenant must be accepted: %v", err)
60 } 80 }
61 if e.Tenant != "" { 81 if e.Tenant != "" {
62 t.Errorf("tenant = %q, want empty", e.Tenant) 82 t.Errorf("tenant = %q, want empty", e.Tenant)
63 } 83 }
64 } 84 }
85
86 // TestFromEnvPrecedence is the whole layering in one test: an EITRI_* variable
87 // beats the config file, the config file beats the hosted default, and a field
88 // nobody set falls through to the default. This is what makes `eitri init`
89 // worth running — after it, a laptop with an empty environment still reaches
90 // its own plane as its own tenant.
91 func TestFromEnvPrecedence(t *testing.T) {
92 cfg := cleanEnv(t)
93 if err := SaveConfig(cfg, Config{
94 URL: "http://192.0.2.10:8080",
95 Gate: "192.0.2.10:2222",
96 Tenant: "acme",
97 CA: "/keys/ca",
98 Key: "/keys/user",
99 }); err != nil {
100 t.Fatal(err)
101 }
102
103 // File alone: every field comes from it.
104 e, err := FromEnv()
105 if err != nil {
106 t.Fatal(err)
107 }
108 if e.URL != "http://192.0.2.10:8080" || e.Gate != "192.0.2.10:2222" ||
109 e.Tenant != "acme" || e.CA != "/keys/ca" || e.Key != "/keys/user" {
110 t.Errorf("from config: %+v", e)
111 }
112
113 // The environment overrides it, field by field.
114 t.Setenv("EITRI_URL", "http://other:8080")
115 t.Setenv("EITRI_GATE", "other:2222")
116 t.Setenv("EITRI_TENANT", "beta")
117 e, err = FromEnv()
118 if err != nil {
119 t.Fatal(err)
120 }
121 if e.URL != "http://other:8080" || e.Gate != "other:2222" || e.Tenant != "beta" {
122 t.Errorf("env must override the file: %+v", e)
123 }
124 if e.CA != "/keys/ca" {
125 t.Errorf("an unset variable must leave the file's value: ca = %q", e.CA)
126 }
127 }
128
129 // A config that exists but does not parse — the half-written file an aborted
130 // run could leave — is an error naming the path, never a silent fallback to
131 // the hosted defaults while the user believes they are on their own plane.
132 func TestFromEnvRefusesUnreadableConfig(t *testing.T) {
133 cfg := cleanEnv(t)
134 if err := os.WriteFile(cfg, []byte(`{"url": "http://192.0`), 0o600); err != nil {
135 t.Fatal(err)
136 }
137 _, err := FromEnv()
138 if err == nil {
139 t.Fatal("a corrupt config must not resolve to the hosted defaults")
140 }
141 if got := err.Error(); !strings.Contains(got, cfg) || !strings.Contains(got, "eitri init") {
142 t.Errorf("error must name the file and the remedy: %v", err)
143 }
144 }
internal/cli/init.go
Old New
@@ -0,0 +1,361 @@
1 package cli
2
3 import (
4 "bufio"
5 "context"
6 "errors"
7 "fmt"
8 "io"
9 "os"
10 "path/filepath"
11 "strings"
12
13 "golang.org/x/crypto/ssh"
14 "golang.org/x/term"
15
16 "github.com/a73x/eitri/internal/server/api/client"
17 )
18
19 // prompter is init's dialogue with the user, over injected streams so the whole
20 // flow is drivable by a test. Every question defaults to the answer that
21 // touches nothing.
22 type prompter struct {
23 buf *bufio.Reader
24 raw io.Reader // the unbuffered source, for the terminal check in secret
25 out io.Writer
26 }
27
28 func newPrompter(in io.Reader, out io.Writer) *prompter {
29 return &prompter{buf: bufio.NewReader(in), raw: in, out: out}
30 }
31
32 func (p *prompter) sayf(format string, a ...any) {
33 fmt.Fprintf(p.out, format+"\n", a...)
34 }
35
36 // confirm asks a yes/no question, defaulting to no: a step acts only on an
37 // explicit yes, so an empty line, an unrecognized answer, and EOF (a closed or
38 // piped stdin) all decline. Nothing in init happens by running out of input.
39 func (p *prompter) confirm(format string, a ...any) bool {
40 fmt.Fprintf(p.out, " "+format+" [y/N] ", a...)
41 line, err := p.buf.ReadString('\n')
42 if err != nil && line == "" {
43 fmt.Fprintln(p.out)
44 return false
45 }
46 switch strings.ToLower(strings.TrimSpace(line)) {
47 case "y", "yes":
48 return true
49 default:
50 return false
51 }
52 }
53
54 // ask reads one line of free text, returning def for an empty answer.
55 func (p *prompter) ask(question, def string) string {
56 if def != "" {
57 fmt.Fprintf(p.out, " %s [%s] ", question, def)
58 } else {
59 fmt.Fprintf(p.out, " %s ", question)
60 }
61 line, _ := p.buf.ReadString('\n')
62 return firstNonEmpty(strings.TrimSpace(line), def)
63 }
64
65 // secret reads a value that must not be echoed or logged. A real terminal has
66 // its echo turned off for the duration; anything else — a pipe, the tests —
67 // reads a plain line, there being no terminal to turn echo off on. The fd is
68 // only read directly while the buffer is empty, so nothing already read ahead
69 // can be lost.
70 func (p *prompter) secret(question string) (string, error) {
71 fmt.Fprintf(p.out, " %s ", question)
72 if f, ok := p.raw.(*os.File); ok && p.buf.Buffered() == 0 && term.IsTerminal(int(f.Fd())) {
73 b, err := term.ReadPassword(int(f.Fd()))
74 fmt.Fprintln(p.out)
75 return string(b), err
76 }
77 line, err := p.buf.ReadString('\n')
78 if err != nil && line == "" {
79 return "", err
80 }
81 return strings.TrimSpace(line), nil
82 }
83
84 // RunInit walks a laptop from nothing to a first shell in one command: prove a
85 // token, settle the tenant's SSH CA, and write the config every other command
86 // reads. Afterwards `eitri ssh <vm>` needs no environment at all — the config
87 // carries the plane and the tenant, the cert is signed locally, and the host-CA
88 // pin comes from a public endpoint.
89 //
90 // Every step prints what it would do and acts only on a yes, and every step
91 // reports "already done" when it already is: a second init against a settled
92 // laptop changes nothing. The steps that can go wrong quietly are the ones it
93 // is most careful about — a CA the tenant has not registered signs certs that
94 // are perfectly formed and refused by every guest, so init says so rather than
95 // generating a second key on top.
96 func RunInit(ctx context.Context, e Env, cfgPath, token string, in io.Reader, out io.Writer) error {
97 p := newPrompter(in, out)
98 cur, err := LoadConfig(cfgPath)
99 if err != nil {
100 return err
101 }
102
103 // initIdentity's only network call is the deliberately context-free
104 // client.Me, so there is no ctx for it to forward.
105 me, token, err := initIdentity(p, e, token) //nolint:contextcheck
106 if err != nil {
107 return err
108 }
109 caPath, err := initCA(ctx, p, e, token, me)
110 if err != nil {
111 return err
112 }
113 next := Config{
114 URL: e.URL,
115 Gate: firstNonEmpty(me.SSHGate, e.Gate, defaultGate),
116 Tenant: me.Tenant,
117 CA: caPath,
118 Key: e.Key,
119 }
120 if err := initConfig(p, cfgPath, cur, next); err != nil {
121 return err
122 }
123 p.sayf("\nDone. `eitri ssh <vm>` from here — no environment needed.")
124 return nil
125 }
126
127 // initIdentity proves the credential and learns what comes with it: the tenant
128 // it acts for, and the gate this plane's clients dial. The token is used and
129 // forgotten — never written to the config, never printed back.
130 func initIdentity(p *prompter, e Env, token string) (client.Me, string, error) {
131 p.sayf("Step 1/3: prove your identity against %s", e.URL)
132 source := "--token"
133 if token == "" {
134 token, source = os.Getenv("EITRI_TOKEN"), "EITRI_TOKEN"
135 }
136 if token == "" {
137 p.sayf(" A personal access token, minted in the console under Settings →")
138 p.sayf(" Personal access tokens. It is used once, to ask the plane who you")
139 p.sayf(" are, and is not stored.")
140 var err error
141 if token, err = p.secret("token:"); err != nil {
142 return client.Me{}, "", err
143 }
144 source = "the prompt"
145 if token == "" {
146 return client.Me{}, "", errors.New("no token — init needs one to learn your tenant")
147 }
148 } else {
149 p.sayf(" Using the token from %s to call GET %s/api/v1/me.", source, e.URL)
150 if !p.confirm("Ask the plane who this token is?") {
151 return client.Me{}, "", errors.New("declined — init has nothing to go on without an identity")
152 }
153 }
154
155 // client.Me is deliberately context-free: its own do-timeout bounds this one
156 // synchronous probe (see the method's doc), so there is no ctx to thread.
157 me, err := (&client.Client{BaseURL: e.URL, Token: token}).Me()
158 if err != nil {
159 return client.Me{}, "", fmt.Errorf("the token from %s did not authenticate against %s — a token belongs to the plane that minted it, so check this is the right one for this server: %w", source, e.URL, err)
160 }
161 if me.Tenant == "" {
162 return client.Me{}, "", errors.New("that token resolves to no tenant")
163 }
164 p.sayf(" %s, tenant %s.", firstNonEmpty(me.Email, "signed in"), me.Tenant)
165 if me.SSHGate != "" {
166 p.sayf(" This plane's SSH gate is %s.", me.SSHGate)
167 } else {
168 // Either a server too old to send ssh_gate, or one whose gate binds a
169 // wildcard with no ssh_gate_domain to name it — in which case the
170 // hosted address below is certainly wrong, so name the remedy here
171 // rather than let it be confirmed as a plausible-looking default.
172 p.sayf(" This plane names no SSH gate, so %s is assumed. If that is wrong,", firstNonEmpty(e.Gate, defaultGate))
173 p.sayf(" set ssh_gate_domain on the server, or EITRI_GATE here.")
174 }
175 return me, token, nil
176 }
177
178 // initCA settles the tenant's SSH CA and returns the signing key `eitri ssh`
179 // should use. A guest trusts the CA set baked into it at create and nothing
180 // rewrites that set afterwards, so this is the step that decides whether the
181 // VMs made after it can ever be entered.
182 func initCA(ctx context.Context, p *prompter, e Env, token string, me client.Me) (string, error) {
183 p.sayf("\nStep 2/3: the SSH CA for tenant %s", me.Tenant)
184 c := &client.Client{BaseURL: e.URL, Token: token}
185 registered, err := c.ListUserCAs(ctx, "")
186 if err != nil {
187 return "", fmt.Errorf("listing the CAs registered for %s: %w", me.Tenant, err)
188 }
189 byFP := make(map[string]client.UserCA, len(registered))
190 for _, ca := range registered {
191 byFP[ca.Fingerprint] = ca
192 }
193 if len(registered) > 0 {
194 p.sayf(" %s has %d registered:", me.Tenant, len(registered))
195 for _, ca := range registered {
196 p.sayf(" %s %s", ca.Fingerprint, firstNonEmpty(ca.Label, "(no label)"))
197 }
198 } else {
199 p.sayf(" %s has none registered. Until it does, a VM created for it would", me.Tenant)
200 p.sayf(" trust no certificate at all — which is why create refuses one.")
201 }
202
203 pub, caPath, err := localCA(p, e.CA)
204 if err != nil {
205 return "", err
206 }
207 if pub == nil {
208 p.sayf(" Skipped — no signing key settled. `eitri ssh` cannot mint a cert until there is one.")
209 return e.CA, nil
210 }
211
212 fp := ssh.FingerprintSHA256(pub)
213 if ca, ok := byFP[fp]; ok {
214 p.sayf(" Already done: %s is registered as %s.", fp, firstNonEmpty(ca.Label, "(no label)"))
215 return caPath, nil
216 }
217 if len(registered) > 0 {
218 // The trap this step exists for. The certs this key signs are perfectly
219 // formed; every guest refuses them, and ssh reports only a denied public
220 // key, which reads like a broken account rather than an unregistered CA.
221 p.sayf(" The key at %s is %s — not one of the above.", caPath, fp)
222 p.sayf(" Certs it signs would be refused by every guest of this tenant.")
223 }
224 if !p.confirm("Register this key's public half with %s?", me.Tenant) {
225 p.sayf(" Skipped — nothing registered.")
226 return caPath, nil
227 }
228 c.UserCALabel = p.ask("label for it:", keyID())
229 if err := c.UploadUserCA(ctx, "", string(ssh.MarshalAuthorizedKey(pub))); err != nil {
230 return "", err
231 }
232 p.sayf(" Registered %s as %q. eitri holds the public half only.", fp, c.UserCALabel)
233 return caPath, nil
234 }
235
236 // localCA settles WHICH key on this machine signs for the tenant, in the order
237 // that touches least: the one already at the configured path, then one the user
238 // names, and only then a new one. Generating is last because a CA is an
239 // identity — making a second where one exists is exactly how a laptop ends up
240 // signing with a key the tenant has never heard of. A nil key means the user
241 // declined every offer.
242 func localCA(p *prompter, path string) (ssh.PublicKey, string, error) {
243 pub, keyPath, err := caPublicKey(path)
244 if err == nil {
245 return pub, keyPath, nil
246 }
247 if !errors.Is(err, os.ErrNotExist) {
248 return nil, "", err
249 }
250
251 p.sayf(" There is no signing key at %s.", path)
252 if p.confirm("Do you already have a CA key, elsewhere on this machine?") {
253 named := p.ask("path to it (the private key, or its .pub):", "")
254 if named == "" {
255 return nil, "", errors.New("no path given")
256 }
257 pub, keyPath, err := caPublicKey(expandHome(named))
258 if err != nil {
259 return nil, "", err
260 }
261 return pub, keyPath, nil
262 }
263
264 p.sayf(" A new ed25519 CA would be written, and nothing else touched:")
265 p.sayf(" %s private, mode 0600 — eitri never sees this half", path)
266 p.sayf(" %s.pub public, mode 0644 — this is the half that gets registered", path)
267 if !p.confirm("Generate it?") {
268 return nil, path, nil
269 }
270 pub, err = newEd25519Key(path)
271 if err != nil {
272 return nil, "", err
273 }
274 p.sayf(" Wrote %s and %s.pub.", path, path)
275 return pub, path, nil
276 }
277
278 // caPublicKey reads the public half of a CA at path, which a user may name
279 // either way round: the signing key (whose public half is derived, exactly as
280 // minting does) or the .pub beside it, which is what `eitri ca upload` takes.
281 // It returns the signing key's path alongside, since that is what the config
282 // records — `eitri ssh` signs with it, not with the .pub.
283 func caPublicKey(path string) (ssh.PublicKey, string, error) {
284 if strings.HasSuffix(path, ".pub") {
285 raw, err := os.ReadFile(path)
286 if err != nil {
287 return nil, "", err
288 }
289 pub, _, _, _, perr := ssh.ParseAuthorizedKey(raw)
290 if perr != nil {
291 return nil, "", fmt.Errorf("%s: not an SSH public key: %w", path, perr)
292 }
293 return pub, strings.TrimSuffix(path, ".pub"), nil
294 }
295 pub, err := userPublicKey(path)
296 if err != nil {
297 return nil, "", err
298 }
299 return pub, path, nil
300 }
301
302 // initConfig writes the settings every other command reads. It shows the file's
303 // current values beside the ones this run would leave, writes only on a yes,
304 // and says "already done" when the two are the same.
305 func initConfig(p *prompter, path string, cur, next Config) error {
306 p.sayf("\nStep 3/3: write %s", path)
307 for _, l := range configLines(cur, next) {
308 p.sayf("%s", l)
309 }
310 if cur == next {
311 p.sayf(" Already done: the file says exactly this.")
312 return nil
313 }
314 if cur.URL != "" && cur.URL != next.URL {
315 p.sayf(" This moves the laptop to a different plane: %s → %s.", cur.URL, next.URL)
316 p.sayf(" The tenant and CA above belong to the new one.")
317 }
318 if !p.confirm("Write it?") {
319 p.sayf(" Skipped — the file is unchanged.")
320 return nil
321 }
322 if err := SaveConfig(path, next); err != nil {
323 return err
324 }
325 p.sayf(" Written.")
326 return nil
327 }
328
329 // configLines renders next field by field, naming the value it replaces so the
330 // user confirms a diff rather than a wall of settings.
331 func configLines(cur, next Config) []string {
332 fields := []struct{ name, was, now string }{
333 {"url", cur.URL, next.URL},
334 {"gate", cur.Gate, next.Gate},
335 {"tenant", cur.Tenant, next.Tenant},
336 {"ca", cur.CA, next.CA},
337 {"key", cur.Key, next.Key},
338 }
339 lines := make([]string, 0, len(fields))
340 for _, f := range fields {
341 line := fmt.Sprintf(" %-7s %s", f.name, f.now)
342 if f.was != "" && f.was != f.now {
343 line += fmt.Sprintf(" (was %s)", f.was)
344 }
345 lines = append(lines, line)
346 }
347 return lines
348 }
349
350 // expandHome resolves a leading ~ in a path the user typed, which no shell
351 // expanded because it came in on a prompt rather than a command line.
352 func expandHome(path string) string {
353 if path != "~" && !strings.HasPrefix(path, "~"+string(filepath.Separator)) {
354 return path
355 }
356 home, err := os.UserHomeDir()
357 if err != nil {
358 return path
359 }
360 return filepath.Join(home, strings.TrimPrefix(path, "~"))
361 }
internal/cli/init_test.go
Old New
@@ -0,0 +1,398 @@
1 package cli
2
3 import (
4 "context"
5 "encoding/json"
6 "net/http"
7 "net/http/httptest"
8 "os"
9 "path/filepath"
10 "strings"
11 "testing"
12
13 "golang.org/x/crypto/ssh"
14 )
15
16 // fakePlane stands in for the control plane across init's three calls: who am
17 // I, which CAs does my tenant have, and register this one. A registration is
18 // visible to the next list, exactly as the real server behaves — which is what
19 // makes the second-run assertions mean anything. It records every upload, so a
20 // test can assert on the step that changed something AND on the step that
21 // deliberately did not.
22 type fakePlane struct {
23 url string
24 me map[string]any
25 cas []map[string]string
26 uploaded []map[string]string
27 status int // non-zero: every request fails with this status
28 }
29
30 func newFakePlane(t *testing.T, me map[string]any, cas ...map[string]string) *fakePlane {
31 t.Helper()
32 f := &fakePlane{me: me, cas: cas}
33 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
34 if f.status != 0 {
35 http.Error(w, "computer says no", f.status)
36 return
37 }
38 switch {
39 case r.URL.Path == "/api/v1/me":
40 json.NewEncoder(w).Encode(f.me)
41 case r.URL.Path == "/api/v1/user-cas" && r.Method == http.MethodGet:
42 if f.cas == nil {
43 f.cas = []map[string]string{}
44 }
45 json.NewEncoder(w).Encode(f.cas)
46 case r.URL.Path == "/api/v1/user-cas" && r.Method == http.MethodPost:
47 var body map[string]string
48 if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
49 t.Errorf("upload body: %v", err)
50 }
51 f.uploaded = append(f.uploaded, body)
52 pub, _, _, _, err := ssh.ParseAuthorizedKey([]byte(body["public_key"]))
53 if err != nil {
54 t.Errorf("uploaded a key the server cannot parse: %v", err)
55 http.Error(w, "invalid public_key", http.StatusBadRequest)
56 return
57 }
58 fp := ssh.FingerprintSHA256(pub)
59 f.cas = append(f.cas, map[string]string{"fingerprint": fp, "label": body["label"], "pubkey": body["public_key"]})
60 w.WriteHeader(http.StatusCreated)
61 json.NewEncoder(w).Encode(map[string]string{"fingerprint": fp})
62 default:
63 t.Errorf("unexpected request %s %s", r.Method, r.URL.Path)
64 http.Error(w, "not found", http.StatusNotFound)
65 }
66 }))
67 t.Cleanup(srv.Close)
68 f.url = srv.URL
69 return f
70 }
71
72 func meBody(tenant, gate string) map[string]any {
73 return map[string]any{"tenant": tenant, "email": tenant + "@example.test", "ssh_gate": gate}
74 }
75
76 // initEnv is a laptop with nothing on it: an empty home, no EITRI_* variables,
77 // and paths that exist only inside the test.
78 func initEnv(t *testing.T, plane *fakePlane) (Env, string) {
79 t.Helper()
80 cfg := cleanEnv(t)
81 home := filepath.Dir(cfg)
82 return Env{
83 URL: plane.url,
84 CA: filepath.Join(home, ".ssh", "eitri_user_ca"),
85 Key: filepath.Join(home, ".ssh", "id_ed25519"),
86 KnownHosts: filepath.Join(home, ".ssh", "eitri_known_hosts"),
87 }, cfg
88 }
89
90 // runInitWith drives one init to completion over canned answers, returning the
91 // transcript the user would have seen.
92 func runInitWith(t *testing.T, e Env, cfg, answers string) (string, error) {
93 t.Helper()
94 var out strings.Builder
95 err := RunInit(context.Background(), e, cfg, "tok", strings.NewReader(answers), &out)
96 return out.String(), err
97 }
98
99 // The empty-laptop path: a tenant with no CA and no key on disk is offered a
100 // generated one, registers it, and gets a config that makes every later command
101 // need no environment.
102 func TestInitFirstRunGeneratesAndRegisters(t *testing.T) {
103 plane := newFakePlane(t, meBody("acme", "gate.acme.test:2222"))
104 e, cfg := initEnv(t, plane)
105
106 // identity, no existing key elsewhere, generate, register, default label, write.
107 out, err := runInitWith(t, e, cfg, "y\nn\ny\ny\n\ny\n")
108 if err != nil {
109 t.Fatalf("%v\n%s", err, out)
110 }
111
112 if len(plane.uploaded) != 1 {
113 t.Fatalf("uploads = %d, want 1\n%s", len(plane.uploaded), out)
114 }
115 // eitri gets the public half and nothing else.
116 if !strings.HasPrefix(plane.uploaded[0]["public_key"], "ssh-ed25519 ") {
117 t.Errorf("uploaded %q, want an ed25519 public key line", plane.uploaded[0]["public_key"])
118 }
119 if strings.Contains(plane.uploaded[0]["public_key"], "PRIVATE") {
120 t.Fatal("a private key left the machine")
121 }
122
123 priv, err := os.Stat(e.CA)
124 if err != nil {
125 t.Fatalf("CA private key: %v", err)
126 }
127 if perm := priv.Mode().Perm(); perm != 0o600 {
128 t.Errorf("CA key mode = %o, want 600", perm)
129 }
130 if _, err := os.Stat(e.CA + ".pub"); err != nil {
131 t.Errorf("CA public key: %v", err)
132 }
133
134 got, err := LoadConfig(cfg)
135 if err != nil {
136 t.Fatal(err)
137 }
138 want := Config{URL: plane.url, Gate: "gate.acme.test:2222", Tenant: "acme", CA: e.CA, Key: e.Key}
139 if got != want {
140 t.Errorf("config:\n got %+v\nwant %+v", got, want)
141 }
142 // The gate written is the one the PLANE named, not the hosted default.
143 if strings.Contains(out, defaultGate) {
144 t.Errorf("a plane that names its gate must not be second-guessed:\n%s", out)
145 }
146 }
147
148 // Running init again against a settled laptop reports every step as already
149 // done and changes nothing — no second CA, no rewritten config.
150 func TestInitSecondRunChangesNothing(t *testing.T) {
151 plane := newFakePlane(t, meBody("acme", "gate.acme.test:2222"))
152 e, cfg := initEnv(t, plane)
153 if _, err := runInitWith(t, e, cfg, "y\nn\ny\ny\n\ny\n"); err != nil {
154 t.Fatal(err)
155 }
156 before, err := os.ReadFile(cfg)
157 if err != nil {
158 t.Fatal(err)
159 }
160
161 // Only the identity step asks anything the second time; every later "y"
162 // here is spare, and if one is consumed the assertions below catch it.
163 out, err := runInitWith(t, e, cfg, "y\ny\ny\ny\ny\n")
164 if err != nil {
165 t.Fatalf("%v\n%s", err, out)
166 }
167 if len(plane.uploaded) != 1 {
168 t.Errorf("uploads = %d, want the first run's 1\n%s", len(plane.uploaded), out)
169 }
170 after, err := os.ReadFile(cfg)
171 if err != nil {
172 t.Fatal(err)
173 }
174 if string(after) != string(before) {
175 t.Errorf("config rewritten:\n before %s\n after %s", before, after)
176 }
177 if n := strings.Count(out, "Already done"); n != 2 {
178 t.Errorf("want the CA and config steps both reporting already done, got %d:\n%s", n, out)
179 }
180 }
181
182 // A key already on the laptop is offered for registration; init never generates
183 // a second one on top of it.
184 func TestInitPrefersAnExistingLocalKey(t *testing.T) {
185 plane := newFakePlane(t, meBody("acme", "gate.acme.test:2222"))
186 e, cfg := initEnv(t, plane)
187 pub, err := newEd25519Key(e.CA)
188 if err != nil {
189 t.Fatal(err)
190 }
191
192 out, err := runInitWith(t, e, cfg, "y\ny\nlaptop\ny\n") // identity, register, label, write
193 if err != nil {
194 t.Fatalf("%v\n%s", err, out)
195 }
196 if strings.Contains(out, "Generate it?") {
197 t.Errorf("a key already exists — init must not offer to generate:\n%s", out)
198 }
199 if len(plane.uploaded) != 1 {
200 t.Fatalf("uploads = %d, want 1\n%s", len(plane.uploaded), out)
201 }
202 if got, want := plane.uploaded[0]["public_key"], string(ssh.MarshalAuthorizedKey(pub)); got != want {
203 t.Errorf("uploaded the wrong key:\n got %q\nwant %q", got, want)
204 }
205 if plane.uploaded[0]["label"] != "laptop" {
206 t.Errorf("label = %q, want laptop", plane.uploaded[0]["label"])
207 }
208 }
209
210 // The trap: the tenant has CAs registered, and the key on this laptop is not
211 // one of them. Every cert it signs is well-formed and refused by every guest,
212 // and ssh reports only a denied public key — so init says it plainly rather
213 // than leaving the user to read that as a broken account.
214 func TestInitNamesAKeyNoGuestWillTrust(t *testing.T) {
215 other, _, _, _, err := ssh.ParseAuthorizedKey([]byte(testUserCALine))
216 if err != nil {
217 t.Fatal(err)
218 }
219 plane := newFakePlane(t, meBody("acme", "gate.acme.test:2222"), map[string]string{
220 "fingerprint": ssh.FingerprintSHA256(other), "label": "the other laptop", "pubkey": testUserCALine,
221 })
222 e, cfg := initEnv(t, plane)
223 if _, err := newEd25519Key(e.CA); err != nil {
224 t.Fatal(err)
225 }
226
227 // Decline the offer to register: the warning must stand on its own, and
228 // nothing may be uploaded behind a "no".
229 out, err := runInitWith(t, e, cfg, "y\nn\ny\n")
230 if err != nil {
231 t.Fatalf("%v\n%s", err, out)
232 }
233 if !strings.Contains(out, "not one of the above") || !strings.Contains(out, "refused by every guest") {
234 t.Errorf("the mismatch must be named plainly:\n%s", out)
235 }
236 if !strings.Contains(out, ssh.FingerprintSHA256(other)) {
237 t.Errorf("the registered CA's fingerprint must be shown:\n%s", out)
238 }
239 if len(plane.uploaded) != 0 {
240 t.Errorf("declined, yet %d uploads happened", len(plane.uploaded))
241 }
242 }
243
244 // A key that IS one of the tenant's registered CAs is recognized: nothing is
245 // uploaded, and the step says so.
246 func TestInitRecognizesTheRegisteredKey(t *testing.T) {
247 plane := newFakePlane(t, meBody("acme", "gate.acme.test:2222"))
248 e, cfg := initEnv(t, plane)
249 pub, err := newEd25519Key(e.CA)
250 if err != nil {
251 t.Fatal(err)
252 }
253 plane.cas = []map[string]string{{
254 "fingerprint": ssh.FingerprintSHA256(pub), "label": "this laptop",
255 "pubkey": string(ssh.MarshalAuthorizedKey(pub)),
256 }}
257
258 out, err := runInitWith(t, e, cfg, "y\ny\n") // identity, write config
259 if err != nil {
260 t.Fatalf("%v\n%s", err, out)
261 }
262 if len(plane.uploaded) != 0 {
263 t.Errorf("an already-registered key must not be uploaded again")
264 }
265 if !strings.Contains(out, "Already done") || !strings.Contains(out, "this laptop") {
266 t.Errorf("want the registration recognized by label:\n%s", out)
267 }
268 }
269
270 // Declining every step leaves the laptop exactly as it was found. Nothing in
271 // init happens by default, including by running out of input.
272 func TestInitDeclinedStepsTouchNothing(t *testing.T) {
273 plane := newFakePlane(t, meBody("acme", "gate.acme.test:2222"))
274 e, cfg := initEnv(t, plane)
275
276 out, err := runInitWith(t, e, cfg, "y\nn\nn\nn\n") // identity, no key elsewhere, no generate, no write
277 if err != nil {
278 t.Fatalf("%v\n%s", err, out)
279 }
280 if _, err := os.Stat(e.CA); !os.IsNotExist(err) {
281 t.Errorf("a CA was written despite the decline: %v", err)
282 }
283 if _, err := os.Stat(cfg); !os.IsNotExist(err) {
284 t.Errorf("a config was written despite the decline: %v", err)
285 }
286 if len(plane.uploaded) != 0 {
287 t.Errorf("%d uploads despite the decline", len(plane.uploaded))
288 }
289 }
290
291 // An init run against a config that names a different plane says so before it
292 // overwrites, since the tenant and CA that follow belong to the new one.
293 func TestInitNamesAPlaneChange(t *testing.T) {
294 plane := newFakePlane(t, meBody("acme", "gate.acme.test:2222"))
295 e, cfg := initEnv(t, plane)
296 if err := SaveConfig(cfg, Config{URL: "http://192.0.2.10:8080", Gate: "192.0.2.10:2222", Tenant: "old"}); err != nil {
297 t.Fatal(err)
298 }
299
300 out, err := runInitWith(t, e, cfg, "y\nn\ny\ny\n\ny\n")
301 if err != nil {
302 t.Fatalf("%v\n%s", err, out)
303 }
304 if !strings.Contains(out, "different plane") || !strings.Contains(out, "http://192.0.2.10:8080") {
305 t.Errorf("the plane change must be called out:\n%s", out)
306 }
307 if !strings.Contains(out, "(was old)") {
308 t.Errorf("the tenant it replaces must be shown:\n%s", out)
309 }
310 }
311
312 // A token minted by another plane fails here, and the message says that rather
313 // than leaving a bare 401 to be read as a bad password.
314 func TestInitBadTokenBlamesThePlane(t *testing.T) {
315 plane := newFakePlane(t, meBody("acme", "gate.acme.test:2222"))
316 plane.status = http.StatusUnauthorized
317 e, cfg := initEnv(t, plane)
318
319 out, err := runInitWith(t, e, cfg, "y\n")
320 if err == nil {
321 t.Fatalf("want an error:\n%s", out)
322 }
323 if !strings.Contains(err.Error(), plane.url) || !strings.Contains(err.Error(), "belongs to the plane that minted it") {
324 t.Errorf("error must name the plane: %v", err)
325 }
326 if _, statErr := os.Stat(cfg); !os.IsNotExist(statErr) {
327 t.Error("a failed identity step must write no config")
328 }
329 }
330
331 // A plane that names no gate — an older server — falls back to the hosted
332 // address rather than writing an empty gate no ssh can dial.
333 func TestInitFallsBackWhenThePlaneNamesNoGate(t *testing.T) {
334 plane := newFakePlane(t, map[string]any{"tenant": "acme", "email": "me@acme.test"})
335 e, cfg := initEnv(t, plane)
336
337 out, err := runInitWith(t, e, cfg, "y\nn\ny\ny\n\ny\n")
338 if err != nil {
339 t.Fatalf("%v\n%s", err, out)
340 }
341 got, err := LoadConfig(cfg)
342 if err != nil {
343 t.Fatal(err)
344 }
345 if got.Gate != defaultGate {
346 t.Errorf("gate = %q, want the hosted fallback %q", got.Gate, defaultGate)
347 }
348 }
349
350 // A user may name their CA either way round — `eitri ca upload` takes the .pub,
351 // so that is what fingers reach for — and either way the config records the
352 // signing key, which is the half `eitri ssh` needs.
353 func TestCAPublicKeyAcceptsEitherHalf(t *testing.T) {
354 dir := t.TempDir()
355 keyPath := filepath.Join(dir, "ca")
356 want, err := newEd25519Key(keyPath)
357 if err != nil {
358 t.Fatal(err)
359 }
360 for _, named := range []string{keyPath, keyPath + ".pub"} {
361 pub, resolved, err := caPublicKey(named)
362 if err != nil {
363 t.Fatalf("%s: %v", named, err)
364 }
365 if ssh.FingerprintSHA256(pub) != ssh.FingerprintSHA256(want) {
366 t.Errorf("%s: wrong key", named)
367 }
368 if resolved != keyPath {
369 t.Errorf("%s: path = %q, want the signing key %q", named, resolved, keyPath)
370 }
371 }
372 if _, _, err := caPublicKey(filepath.Join(dir, "absent")); !os.IsNotExist(err) {
373 t.Errorf("a missing key must report as missing, got %v", err)
374 }
375 }
376
377 // newEd25519Key never overwrites: an existing CA is an identity, and a silent
378 // replacement would strand every guest that trusts the old one.
379 func TestNewEd25519KeyRefusesToOverwrite(t *testing.T) {
380 path := filepath.Join(t.TempDir(), "ca")
381 if _, err := newEd25519Key(path); err != nil {
382 t.Fatal(err)
383 }
384 before, err := os.ReadFile(path)
385 if err != nil {
386 t.Fatal(err)
387 }
388 if _, err := newEd25519Key(path); !os.IsExist(err) {
389 t.Fatalf("want an exists error, got %v", err)
390 }
391 after, err := os.ReadFile(path)
392 if err != nil {
393 t.Fatal(err)
394 }
395 if string(after) != string(before) {
396 t.Error("the existing key was replaced")
397 }
398 }
internal/cli/main.go
Old New
@@ -14,28 +14,37 @@ import (
14 14
15 // Usage is the top-level help text, printed by cmd/eitri on ErrUsage. 15 // Usage is the top-level help text, printed by cmd/eitri on ErrUsage.
16 const Usage = `usage: 16 const Usage = `usage:
17 eitri init [--token <pat>]
17 eitri ssh <vm> [ssh args / remote command...] 18 eitri ssh <vm> [ssh args / remote command...]
18 eitri ca upload [<tenant>] <ca-public-key-file> 19 eitri ca upload [<tenant>] <ca-public-key-file>
19 eitri --version 20 eitri --version
20 21
21 env: all optional — EITRI_URL and EITRI_GATE default to the hosted service at 22 init is the guided first run: it proves a token, settles your tenant's SSH CA,
22 eitri.sh (self-hosters and tests override). ca needs EITRI_TOKEN (a PAT); 23 and writes ~/.eitri/config.json, confirming every step before it acts.
23 its tenant is optional (argument, else EITRI_TENANT, else the token's own). 24
24 EITRI_TENANT disambiguates a user CA registered in more than one tenant. 25 env: all optional — each EITRI_* variable overrides the config file, which
25 EITRI_CA, EITRI_KEY, EITRI_KNOWN_HOSTS default under ~/.ssh.` 26 overrides the hosted defaults at eitri.sh. init and ca need EITRI_TOKEN (a
27 PAT) unless one is given another way; ca's tenant is optional (argument,
28 else EITRI_TENANT, else the token's own). EITRI_TENANT disambiguates a user
29 CA registered in more than one tenant, and EITRI_GATE pins the gate the
30 plane would otherwise name itself. EITRI_CA, EITRI_KEY, EITRI_KNOWN_HOSTS
31 default under ~/.ssh; EITRI_CONFIG moves the config file.`
26 32
27 // ErrUsage marks a bad invocation: the caller prints Usage and exits 2 rather 33 // ErrUsage marks a bad invocation: the caller prints Usage and exits 2 rather
28 // than treating it as a runtime failure. 34 // than treating it as a runtime failure.
29 var ErrUsage = errors.New("bad usage") 35 var ErrUsage = errors.New("bad usage")
30 36
31 // Main dispatches the eitri command line (everything after the binary name, 37 // Main dispatches the eitri command line (everything after the binary name,
32 // --version excluded — that stays in cmd/eitri). stdout carries command 38 // --version excluded — that stays in cmd/eitri). stdout carries command output;
33 // output; prompts and confirmations go to stderr inside the subcommands. 39 // prompts and confirmations go to stderr inside the subcommands, except init,
40 // whose whole output IS the dialogue and so belongs on stdout.
34 func Main(args []string, stdout io.Writer) error { 41 func Main(args []string, stdout io.Writer) error {
35 if len(args) < 1 { 42 if len(args) < 1 {
36 return ErrUsage 43 return ErrUsage
37 } 44 }
38 switch args[0] { 45 switch args[0] {
46 case "init":
47 return runInit(args[1:], stdout)
39 case "ssh": 48 case "ssh":
40 return runSSH(args[1:], stdout) 49 return runSSH(args[1:], stdout)
41 case "ca": 50 case "ca":
@@ -45,6 +54,38 @@ func Main(args []string, stdout io.Writer) error {
45 } 54 }
46 } 55 }
47 56
57 const initUsage = "usage: eitri init [--token <pat>]"
58
59 // runInit parses init's one flag. A token on the command line is visible in the
60 // shell's history and in ps, so it is offered rather than required: with no
61 // --token, init takes EITRI_TOKEN, and with neither it asks, unechoed.
62 func runInit(args []string, stdout io.Writer) error {
63 token := ""
64 for len(args) > 0 {
65 switch args[0] {
66 case "-h", "--help":
67 fmt.Fprintln(stdout, initUsage)
68 return nil
69 case "--token":
70 if len(args) < 2 {
71 return errors.New(initUsage)
72 }
73 token, args = args[1], args[2:]
74 default:
75 return errors.New(initUsage)
76 }
77 }
78 env, err := FromEnv()
79 if err != nil {
80 return err
81 }
82 cfgPath, err := ConfigPath()
83 if err != nil {
84 return err
85 }
86 return RunInit(context.Background(), env, cfgPath, token, os.Stdin, stdout)
87 }
88
48 func runSSH(args []string, stdout io.Writer) error { 89 func runSSH(args []string, stdout io.Writer) error {
49 if len(args) >= 1 && (args[0] == "-h" || args[0] == "--help") { 90 if len(args) >= 1 && (args[0] == "-h" || args[0] == "--help") {
50 fmt.Fprintln(stdout, "usage: eitri ssh <vm> [ssh args / remote command...]") 91 fmt.Fprintln(stdout, "usage: eitri ssh <vm> [ssh args / remote command...]")
@@ -79,7 +120,11 @@ func runCA(args []string, stdout io.Writer) error {
79 if token == "" { 120 if token == "" {
80 return fmt.Errorf("set EITRI_TOKEN (a personal access token)") 121 return fmt.Errorf("set EITRI_TOKEN (a personal access token)")
81 } 122 }
82 out, err := UploadUserCA(context.Background(), URLFromEnv(), token, tenant, pub) 123 env, err := FromEnv()
124 if err != nil {
125 return err
126 }
127 out, err := UploadUserCA(context.Background(), env.URL, token, tenant, pub)
83 if err != nil { 128 if err != nil {
84 return err 129 return err
85 } 130 }
internal/cli/mint.go
Old New
@@ -16,47 +16,63 @@ import (
16 ) 16 )
17 17
18 // EnsureKeypair generates an ed25519 keypair at keyPath (+ .pub) if absent. 18 // EnsureKeypair generates an ed25519 keypair at keyPath (+ .pub) if absent.
19 // An existing key is never touched. The private key is created O_EXCL so a 19 // An existing key is never touched, including one that appears between the
20 // concurrent first run cannot interleave two generations; the .pub (a 20 // stat and the create — newEd25519Key's O_EXCL makes that race a no-op rather
21 // convenience copy — minting and ssh derive the public key from the private 21 // than an overwrite.
22 // key) is written after, by whichever call won the create.
23 func EnsureKeypair(keyPath string) error { 22 func EnsureKeypair(keyPath string) error {
24 if _, err := os.Stat(keyPath); err == nil { 23 if _, err := os.Stat(keyPath); err == nil {
25 return nil 24 return nil
26 } else if !os.IsNotExist(err) { 25 } else if !os.IsNotExist(err) {
27 return err 26 return err
28 } 27 }
29 pub, priv, err := ed25519.GenerateKey(rand.Reader) 28 _, err := newEd25519Key(keyPath)
29 if errors.Is(err, os.ErrExist) {
30 return nil // another eitri run won the race; its pair is authoritative
31 }
30 if err != nil { 32 if err != nil {
31 return err 33 return err
32 } 34 }
33 block, err := ssh.MarshalPrivateKey(priv, "") 35 fmt.Fprintf(os.Stderr, "eitri: generated SSH key at %s\n", keyPath)
36 return nil
37 }
38
39 // newEd25519Key writes a fresh ed25519 keypair at path (0600) and path+".pub"
40 // (0644), returning the public half. The private key is created O_EXCL — an
41 // existing file is never overwritten and a concurrent run cannot interleave two
42 // generations, so a caller that sees os.ErrExist knows a key it did not make is
43 // already there. Both the user key and, via `eitri init`, the tenant's CA are
44 // born here.
45 func newEd25519Key(path string) (ssh.PublicKey, error) {
46 pub, priv, err := ed25519.GenerateKey(rand.Reader)
34 if err != nil { 47 if err != nil {
35 return err 48 return nil, err
36 } 49 }
37 if err := os.MkdirAll(filepath.Dir(keyPath), 0o700); err != nil { 50 block, err := ssh.MarshalPrivateKey(priv, "")
38 return err 51 if err != nil {
52 return nil, err
39 } 53 }
40 f, err := os.OpenFile(keyPath, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600) 54 if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
41 if errors.Is(err, os.ErrExist) { 55 return nil, err
42 return nil // another eitri run won the race; its pair is authoritative
43 } 56 }
57 f, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600)
44 if err != nil { 58 if err != nil {
45 return err 59 return nil, err
46 } 60 }
47 if _, err := f.Write(pem.EncodeToMemory(block)); err != nil { 61 if _, err := f.Write(pem.EncodeToMemory(block)); err != nil {
48 f.Close() 62 f.Close()
49 return err 63 return nil, err
50 } 64 }
51 if err := f.Close(); err != nil { 65 if err := f.Close(); err != nil {
52 return err 66 return nil, err
53 } 67 }
54 sshPub, err := ssh.NewPublicKey(pub) 68 sshPub, err := ssh.NewPublicKey(pub)
55 if err != nil { 69 if err != nil {
56 return err 70 return nil, err
57 } 71 }
58 fmt.Fprintf(os.Stderr, "eitri: generated SSH key at %s\n", keyPath) 72 // The .pub is a convenience copy — minting and ssh derive the public key
59 return os.WriteFile(keyPath+".pub", ssh.MarshalAuthorizedKey(sshPub), 0o644) 73 // from the private one — so it is written after whichever call won the
74 // create.
75 return sshPub, os.WriteFile(path+".pub", ssh.MarshalAuthorizedKey(sshPub), 0o644)
60 } 76 }
61 77
62 // MintCert self-signs a short-lived user certificate for keyPath's public key 78 // MintCert self-signs a short-lived user certificate for keyPath's public key
@@ -69,7 +85,7 @@ func MintCert(caPath, keyPath, keyID string) error {
69 raw, err := os.ReadFile(caPath) 85 raw, err := os.ReadFile(caPath)
70 if err != nil { 86 if err != nil {
71 if os.IsNotExist(err) { 87 if os.IsNotExist(err) {
72 return fmt.Errorf("no user CA at %s — generate one (ssh-keygen -t ed25519 -f %s) and register it with 'eitri ca upload'", caPath, caPath) 88 return fmt.Errorf("no user CA at %s — run 'eitri init', which offers to make one and register it", caPath)
73 } 89 }
74 return err 90 return err
75 } 91 }
internal/cli/mint_test.go
Old New
@@ -112,7 +112,7 @@ func TestMintCertMissingCA(t *testing.T) {
112 t.Fatal(err) 112 t.Fatal(err)
113 } 113 }
114 err := MintCert(filepath.Join(t.TempDir(), "nope"), key, "x") 114 err := MintCert(filepath.Join(t.TempDir(), "nope"), key, "x")
115 if err == nil || !strings.Contains(err.Error(), "eitri ca upload") { 115 if err == nil || !strings.Contains(err.Error(), "eitri init") {
116 t.Fatalf("want actionable no-CA error, got %v", err) 116 t.Fatalf("want actionable no-CA error, got %v", err)
117 } 117 }
118 } 118 }
internal/cli/sshcmd.go
Old New
@@ -48,31 +48,48 @@ func SSHArgv(e Env, vm string, extra []string) []string {
48 return append(argv, extra...) 48 return append(argv, extra...)
49 } 49 }
50 50
51 // resolveTenant returns the tenant for the VM's gate connect name. EITRI_TENANT 51 // resolvePlane fills the two things a session needs that nothing on this
52 // pins it — offline, and the escape hatch when a user CA lives in more than one 52 // machine has to carry: the tenant the connect name is namespaced under, and
53 // tenant — and otherwise the CLI derives it from the credential via /me. The 53 // the gate it is dialed through. Either can be pinned — EITRI_TENANT /
54 // connect name must be the namespaced <tenant>.<vm> (the VM host cert's one 54 // EITRI_GATE, or the config file `eitri init` wrote — and whatever is still
55 // principal, see SSHArgv), so the tenant is always needed; deriving it means the 55 // missing comes from the credential's /me, which answers both. A connect name
56 // user never has to know or type it. Deriving needs EITRI_TOKEN; with neither 56 // is half identity and half plane, so one probe settles it, and the plane names
57 // the tenant nor a token, the connect name cannot be built. 57 // its own gate rather than the client assuming one.
58 func resolveTenant(e Env) (string, error) { 58 //
59 if e.Tenant != "" { 59 // The hosted gate address survives only as the last rung, for a server old
60 return e.Tenant, nil 60 // enough to answer /me without ssh_gate, or for a pinned tenant with no
61 // credential to ask with. The connect name must be the namespaced <tenant>.<vm>
62 // (the VM host cert's one principal, see SSHArgv), so the tenant is always
63 // needed; deriving it is what means the user never has to know or type it, and
64 // with neither a tenant nor a token the name cannot be built at all.
65 func resolvePlane(e Env) (Env, error) {
66 if e.Tenant != "" && e.Gate != "" {
67 return e, nil
61 } 68 }
62 token := os.Getenv("EITRI_TOKEN") 69 token := os.Getenv("EITRI_TOKEN")
63 if token == "" { 70 if token == "" {
64 return "", fmt.Errorf("set EITRI_TOKEN (a personal access token) or EITRI_TENANT — the connect name needs your 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")
73 }
74 e.Gate = defaultGate
75 return e, nil
65 } 76 }
66 // client.Me is deliberately context-free — its own do-timeout bounds this 77 // client.Me is deliberately context-free — its own do-timeout bounds this
67 // one synchronous probe (see the method's doc), so there is no ctx to thread. 78 // one synchronous probe (see the method's doc), so there is no ctx to thread.
68 me, err := (&client.Client{BaseURL: e.URL, Token: token}).Me() 79 me, err := (&client.Client{BaseURL: e.URL, Token: token}).Me()
69 if err != nil { 80 if err != nil {
70 return "", fmt.Errorf("resolving your tenant from the token: %w", err) 81 return e, fmt.Errorf("resolving your tenant and gate from the token: %w", err)
71 } 82 }
72 if me.Tenant == "" { 83 if e.Tenant == "" {
73 return "", fmt.Errorf("your token resolves to no tenant") 84 if me.Tenant == "" {
85 return e, fmt.Errorf("your token resolves to no tenant")
86 }
87 e.Tenant = me.Tenant
74 } 88 }
75 return me.Tenant, nil 89 if e.Gate == "" {
90 e.Gate = firstNonEmpty(me.SSHGate, defaultGate)
91 }
92 return e, nil
76 } 93 }
77 94
78 // shq single-quotes s for POSIX shell word-splitting (ProxyCommand runs via 95 // shq single-quotes s for POSIX shell word-splitting (ProxyCommand runs via
@@ -95,13 +112,12 @@ func keyID() string {
95 // process with the system ssh. Exec (not a child process) so the TTY, signals, 112 // process with the system ssh. Exec (not a child process) so the TTY, signals,
96 // and exit code belong to ssh itself. 113 // and exit code belong to ssh itself.
97 func RunSSH(ctx context.Context, e Env, vm string, extra []string) error { 114 func RunSSH(ctx context.Context, e Env, vm string, extra []string) error {
98 // resolveTenant's only network call is the deliberately context-free 115 // resolvePlane's only network call is the deliberately context-free
99 // client.Me, so there is no ctx for it to forward. 116 // client.Me, so there is no ctx for it to forward.
100 tenant, err := resolveTenant(e) //nolint:contextcheck 117 e, err := resolvePlane(e) //nolint:contextcheck
101 if err != nil { 118 if err != nil {
102 return err 119 return err
103 } 120 }
104 e.Tenant = tenant
105 if err := EnsureKeypair(e.Key); err != nil { 121 if err := EnsureKeypair(e.Key); err != nil {
106 return err 122 return err
107 } 123 }
internal/cli/sshcmd_test.go
Old New
@@ -43,10 +43,10 @@ func TestSSHArgvAlwaysNamespaced(t *testing.T) {
43 } 43 }
44 } 44 }
45 45
46 // resolveTenant derives the tenant from the credential (via /me) when 46 // resolvePlane derives the tenant from the credential (via /me) when nothing
47 // EITRI_TENANT is unset, so the namespaced connect name can be built without the 47 // pins it, so the namespaced connect name can be built without the user knowing
48 // user knowing their tenant. 48 // their tenant.
49 func TestResolveTenantFromMe(t *testing.T) { 49 func TestResolvePlaneTenantFromMe(t *testing.T) {
50 var gotAuth string 50 var gotAuth string
51 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 51 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
52 gotAuth = r.Header.Get("Authorization") 52 gotAuth = r.Header.Get("Authorization")
@@ -61,40 +61,105 @@ func TestResolveTenantFromMe(t *testing.T) {
61 e := testEnv() 61 e := testEnv()
62 e.URL = srv.URL 62 e.URL = srv.URL
63 e.Tenant = "" 63 e.Tenant = ""
64 got, err := resolveTenant(e) 64 got, err := resolvePlane(e)
65 if err != nil { 65 if err != nil {
66 t.Fatal(err) 66 t.Fatal(err)
67 } 67 }
68 if got != "acme" { 68 if got.Tenant != "acme" {
69 t.Errorf("tenant = %q, want acme", got) 69 t.Errorf("tenant = %q, want acme", got.Tenant)
70 } 70 }
71 if gotAuth != "Bearer tok123" { 71 if gotAuth != "Bearer tok123" {
72 t.Errorf("auth = %q, want Bearer tok123", gotAuth) 72 t.Errorf("auth = %q, want Bearer tok123", gotAuth)
73 } 73 }
74 } 74 }
75 75
76 // EITRI_TENANT pins the tenant with no HTTP call — offline and the multi-tenant 76 // TestResolvePlaneGateOrder pins the gate's whole precedence chain in one place.
77 // escape hatch. 77 // A pinned gate (EITRI_GATE, or the config file — both reach here as a non-empty
78 func TestResolveTenantEnvShortCircuits(t *testing.T) { 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
80 // the rung below that, for a server too old to send ssh_gate.
81 func TestResolvePlaneGateOrder(t *testing.T) {
82 t.Setenv("EITRI_TOKEN", "tok")
83 for _, tc := range []struct {
84 name string
85 pinned string // Env.Gate as the env or the config file left it
86 meBody string
87 want string
88 }{
89 {
90 name: "a pinned gate outranks the plane's own answer",
91 pinned: "gate.mine:2222",
92 meBody: `{"tenant":"acme","ssh_gate":"gate.acme.example:2222"}`,
93 want: "gate.mine:2222",
94 },
95 {
96 name: "unpinned, the plane names its own gate",
97 meBody: `{"tenant":"acme","ssh_gate":"gate.acme.example:2222"}`,
98 want: "gate.acme.example:2222",
99 },
100 {
101 name: "a server too old to say falls back to the hosted address",
102 meBody: `{"tenant":"acme"}`,
103 want: defaultGate,
104 },
105 } {
106 t.Run(tc.name, func(t *testing.T) {
107 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
108 w.Write([]byte(tc.meBody))
109 }))
110 defer srv.Close()
111
112 e := testEnv()
113 e.URL, e.Gate, e.Tenant = srv.URL, tc.pinned, ""
114 got, err := resolvePlane(e)
115 if err != nil {
116 t.Fatal(err)
117 }
118 if got.Gate != tc.want {
119 t.Errorf("gate = %q, want %q", got.Gate, tc.want)
120 }
121 if got.Tenant != "acme" {
122 t.Errorf("tenant = %q, want acme", got.Tenant)
123 }
124 })
125 }
126 }
127
128 // Both values pinned is the offline path: nothing to ask, so nothing is asked.
129 func TestResolvePlaneFullyPinnedShortCircuits(t *testing.T) {
79 srv := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) { 130 srv := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {
80 t.Error("EITRI_TENANT must short-circuit — no /me call") 131 t.Error("a pinned tenant and gate must short-circuit — no /me call")
81 })) 132 }))
82 defer srv.Close() 133 defer srv.Close()
83 134
84 e := testEnv() 135 e := testEnv()
85 e.URL = srv.URL 136 e.URL, e.Tenant = srv.URL, "team"
86 e.Tenant = "team" 137 got, err := resolvePlane(e)
87 got, err := resolveTenant(e) 138 if err != nil {
139 t.Fatal(err)
140 }
141 if got.Tenant != "team" || got.Gate != "gate.example:2222" {
142 t.Errorf("resolved = %+v", got)
143 }
144 }
145
146 // 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.
148 func TestResolvePlaneNoTokenPinnedTenant(t *testing.T) {
149 t.Setenv("EITRI_TOKEN", "")
150 e := testEnv()
151 e.Gate, e.Tenant = "", "team"
152 got, err := resolvePlane(e)
88 if err != nil { 153 if err != nil {
89 t.Fatal(err) 154 t.Fatal(err)
90 } 155 }
91 if got != "team" { 156 if got.Gate != defaultGate {
92 t.Errorf("tenant = %q, want team", got) 157 t.Errorf("gate = %q, want %q", got.Gate, defaultGate)
93 } 158 }
94 } 159 }
95 160
96 // A credential that resolves to no tenant, or a failing /me, is a clear error. 161 // A credential that resolves to no tenant, or a failing /me, is a clear error.
97 func TestResolveTenantMeFailures(t *testing.T) { 162 func TestResolvePlaneMeFailures(t *testing.T) {
98 t.Setenv("EITRI_TOKEN", "tok") 163 t.Setenv("EITRI_TOKEN", "tok")
99 164
100 // Empty tenant in the /me response. 165 // Empty tenant in the /me response.
@@ -104,7 +169,7 @@ func TestResolveTenantMeFailures(t *testing.T) {
104 defer empty.Close() 169 defer empty.Close()
105 e := testEnv() 170 e := testEnv()
106 e.URL, e.Tenant = empty.URL, "" 171 e.URL, e.Tenant = empty.URL, ""
107 if _, err := resolveTenant(e); err == nil || !strings.Contains(err.Error(), "no tenant") { 172 if _, err := resolvePlane(e); err == nil || !strings.Contains(err.Error(), "no tenant") {
108 t.Errorf("empty tenant: got %v", err) 173 t.Errorf("empty tenant: got %v", err)
109 } 174 }
110 175
@@ -114,20 +179,21 @@ func TestResolveTenantMeFailures(t *testing.T) {
114 })) 179 }))
115 defer boom.Close() 180 defer boom.Close()
116 e.URL = boom.URL 181 e.URL = boom.URL
117 if _, err := resolveTenant(e); err == nil || !strings.Contains(err.Error(), "resolving your tenant") { 182 if _, err := resolvePlane(e); err == nil || !strings.Contains(err.Error(), "resolving your tenant") {
118 t.Errorf("me error: got %v", err) 183 t.Errorf("me error: got %v", err)
119 } 184 }
120 } 185 }
121 186
122 // With neither EITRI_TENANT nor EITRI_TOKEN the connect name cannot be built; 187 // With no tenant and no credential the connect name cannot be built; the error
123 // the error names both remedies before any network call. 188 // names every remedy before any network call.
124 func TestResolveTenantNoTokenNoTenant(t *testing.T) { 189 func TestResolvePlaneNoTokenNoTenant(t *testing.T) {
125 t.Setenv("EITRI_TOKEN", "") 190 t.Setenv("EITRI_TOKEN", "")
126 e := testEnv() 191 e := testEnv()
127 e.Tenant = "" 192 e.Tenant = ""
128 _, err := resolveTenant(e) 193 _, err := resolvePlane(e)
129 if err == nil || !strings.Contains(err.Error(), "EITRI_TOKEN") || !strings.Contains(err.Error(), "EITRI_TENANT") { 194 if err == nil || !strings.Contains(err.Error(), "eitri init") ||
130 t.Errorf("want error naming EITRI_TOKEN and EITRI_TENANT, got %v", err) 195 !strings.Contains(err.Error(), "EITRI_TOKEN") || !strings.Contains(err.Error(), "EITRI_TENANT") {
196 t.Errorf("want error naming init, EITRI_TOKEN and EITRI_TENANT, got %v", err)
131 } 197 }
132 } 198 }
133 199