b76d6744
feat(cli): one guided command stands between a token and a first shell
a73x 2026-08-11 15:18
Commit message
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 | ||