043bd8dc
feat: the credential is the tenant
a73x 2026-07-29 18:30
Commit message
docs/openapi.json
| Old | New | ||
|---|---|---|---|
| @@ -1325,6 +1325,81 @@ | |||
| 1325 | "summary": "Revoke a personal access token by id; unknown or foreign ids answer 404 (no existence leak)." | 1325 | "summary": "Revoke a personal access token by id; unknown or foreign ids answer 404 (no existence leak)." |
| 1326 | } | 1326 | } |
| 1327 | }, | 1327 | }, |
| 1328 | "/api/v1/user-cas": { | ||
| 1329 | "get": { | ||
| 1330 | "responses": { | ||
| 1331 | "200": { | ||
| 1332 | "content": { | ||
| 1333 | "application/json": { | ||
| 1334 | "schema": { | ||
| 1335 | "items": { | ||
| 1336 | "$ref": "#/components/schemas/UserCA" | ||
| 1337 | }, | ||
| 1338 | "type": "array" | ||
| 1339 | } | ||
| 1340 | } | ||
| 1341 | }, | ||
| 1342 | "description": "success" | ||
| 1343 | }, | ||
| 1344 | "default": { | ||
| 1345 | "content": { | ||
| 1346 | "text/plain": { | ||
| 1347 | "schema": { | ||
| 1348 | "type": "string" | ||
| 1349 | } | ||
| 1350 | } | ||
| 1351 | }, | ||
| 1352 | "description": "error (plain text)" | ||
| 1353 | } | ||
| 1354 | }, | ||
| 1355 | "security": [ | ||
| 1356 | { | ||
| 1357 | "patToken": [] | ||
| 1358 | } | ||
| 1359 | ], | ||
| 1360 | "summary": "List the caller's own tenant's registered SSH user CAs (pubkey, label, fingerprint)." | ||
| 1361 | }, | ||
| 1362 | "post": { | ||
| 1363 | "requestBody": { | ||
| 1364 | "content": { | ||
| 1365 | "application/json": { | ||
| 1366 | "schema": { | ||
| 1367 | "$ref": "#/components/schemas/UserCARequest" | ||
| 1368 | } | ||
| 1369 | } | ||
| 1370 | }, | ||
| 1371 | "required": true | ||
| 1372 | }, | ||
| 1373 | "responses": { | ||
| 1374 | "201": { | ||
| 1375 | "content": { | ||
| 1376 | "application/json": { | ||
| 1377 | "schema": { | ||
| 1378 | "$ref": "#/components/schemas/UserCAUploadResponse" | ||
| 1379 | } | ||
| 1380 | } | ||
| 1381 | }, | ||
| 1382 | "description": "success" | ||
| 1383 | }, | ||
| 1384 | "default": { | ||
| 1385 | "content": { | ||
| 1386 | "text/plain": { | ||
| 1387 | "schema": { | ||
| 1388 | "type": "string" | ||
| 1389 | } | ||
| 1390 | } | ||
| 1391 | }, | ||
| 1392 | "description": "error (plain text)" | ||
| 1393 | } | ||
| 1394 | }, | ||
| 1395 | "security": [ | ||
| 1396 | { | ||
| 1397 | "patToken": [] | ||
| 1398 | } | ||
| 1399 | ], | ||
| 1400 | "summary": "Register a BYO SSH user CA public key for the caller's own tenant; eitri never holds a user signing key." | ||
| 1401 | } | ||
| 1402 | }, | ||
| 1328 | "/api/v1/vms": { | 1403 | "/api/v1/vms": { |
| 1329 | "get": { | 1404 | "get": { |
| 1330 | "responses": { | 1405 | "responses": { |
docs/quickstart.md
| Old | New | ||
|---|---|---|---|
| @@ -49,8 +49,9 @@ browser serial console. | |||
| 49 | ### SSH in | 49 | ### SSH in |
| 50 | 50 | ||
| 51 | SSH access uses certificates signed by **your** CA — eitri never holds a | 51 | SSH access uses certificates signed by **your** CA — eitri never holds a |
| 52 | user key that can enter your VMs. On your laptop, download the client CLI, | 52 | user key that can enter your VMs. On your laptop, download the client CLI. It |
| 53 | then put a personal access token and your tenant handle in the environment: | 53 | targets the hosted service by default, so the only thing to set is a personal |
| 54 | access token: | ||
| 54 | 55 | ||
| 55 | ```sh | 56 | ```sh |
| 56 | V=v0.0.1 | 57 | V=v0.0.1 |
| @@ -58,23 +59,23 @@ curl -fsSLO "https://eitri.sh/dl/$V/eitri-cli_${V}_$(uname -s | tr A-Z a-z)_amd6 | |||
| 58 | tar xzf eitri-cli_${V}_*.tar.gz | 59 | tar xzf eitri-cli_${V}_*.tar.gz |
| 59 | sudo install -m 0755 eitri-cli_*/eitri /usr/local/bin/eitri | 60 | sudo install -m 0755 eitri-cli_*/eitri /usr/local/bin/eitri |
| 60 | 61 | ||
| 61 | export EITRI_URL=https://console.eitri.sh | ||
| 62 | export EITRI_TOKEN=<pat> # mint one in the console → Settings → Personal access tokens | 62 | export EITRI_TOKEN=<pat> # mint one in the console → Settings → Personal access tokens |
| 63 | export EITRI_TENANT=<your-tenant> # shown on the console's Settings page, under Identity | ||
| 64 | ``` | 63 | ``` |
| 65 | 64 | ||
| 66 | Register a user CA once — eitri gets the public key, never the private one: | 65 | Register a user CA once — eitri gets the public key, never the private one: |
| 67 | 66 | ||
| 68 | ```sh | 67 | ```sh |
| 69 | ssh-keygen -t ed25519 -N '' -f ~/.ssh/eitri_user_ca -C "my eitri user CA" | 68 | ssh-keygen -t ed25519 -N '' -f ~/.ssh/eitri_user_ca -C "my eitri user CA" |
| 70 | eitri ca upload "$EITRI_TENANT" ~/.ssh/eitri_user_ca.pub | 69 | eitri ca upload ~/.ssh/eitri_user_ca.pub |
| 71 | ``` | 70 | ``` |
| 72 | 71 | ||
| 73 | Then SSH in. Unlike self-hosting, the console and the SSH gate are | 72 | Your token names the tenant, so neither command needs one. In more than one |
| 74 | separate names, so set the gate too: | 73 | tenant? `EITRI_TENANT` and `eitri ca upload <tenant> <key>` pin one explicitly. |
| 74 | |||
| 75 | Then SSH in. The console and gate are hosted defaults, and `eitri ssh` uses | ||
| 76 | your token to look up your tenant for the connect name — nothing else to set: | ||
| 75 | 77 | ||
| 76 | ```sh | 78 | ```sh |
| 77 | export EITRI_GATE=gate.eitri.sh:2222 | ||
| 78 | eitri ssh <vm-name> | 79 | eitri ssh <vm-name> |
| 79 | eitri ssh <vm-name> uptime | 80 | eitri ssh <vm-name> uptime |
| 80 | ``` | 81 | ``` |
| @@ -260,11 +261,11 @@ export EITRI_URL=http://192.0.2.10:8080 | |||
| 260 | export EITRI_TOKEN=<pat> # mint one in the console → Settings | 261 | export EITRI_TOKEN=<pat> # mint one in the console → Settings |
| 261 | 262 | ||
| 262 | ssh-keygen -t ed25519 -N '' -f ~/.ssh/eitri_user_ca -C "my eitri user CA" | 263 | ssh-keygen -t ed25519 -N '' -f ~/.ssh/eitri_user_ca -C "my eitri user CA" |
| 263 | eitri ca upload <your-tenant> ~/.ssh/eitri_user_ca.pub | 264 | eitri ca upload ~/.ssh/eitri_user_ca.pub |
| 264 | ``` | 265 | ``` |
| 265 | 266 | ||
| 266 | `<your-tenant>` is the handle shown on the Settings page — derived from your | 267 | Your token names the tenant, so `ca upload` needs no handle. In more than one |
| 267 | email on first sign-in. | 268 | tenant? `EITRI_TENANT` and `eitri ca upload <tenant> <key>` pin one explicitly. |
| 268 | 269 | ||
| 269 | **+ Create VM**, pick a host, create. Defaults: 2 vCPUs, 2048 MB, 10 GB, the | 270 | **+ Create VM**, pick a host, create. Defaults: 2 vCPUs, 2048 MB, 10 GB, the |
| 270 | default image. Status reads `creating` while the image downloads and the | 271 | default image. Status reads `creating` while the image downloads and the |
| @@ -273,17 +274,22 @@ guest boots, then `ready`. Power reads `running`, an IP appears, you're on. | |||
| 273 | ### SSH in | 274 | ### SSH in |
| 274 | 275 | ||
| 275 | ```sh | 276 | ```sh |
| 276 | export EITRI_GATE=192.0.2.10:2222 # must match ssh_gate_domain | 277 | export EITRI_URL=http://192.0.2.10:8080 # set earlier, when you booted the VM |
| 277 | export EITRI_TENANT=<your-tenant> # same handle you uploaded the CA to | 278 | export EITRI_GATE=192.0.2.10:2222 # must match ssh_gate_domain |
| 278 | 279 | ||
| 279 | eitri ssh <vm-name> | 280 | eitri ssh <vm-name> |
| 280 | eitri ssh <vm-name> uptime | 281 | eitri ssh <vm-name> uptime |
| 281 | ``` | 282 | ``` |
| 282 | 283 | ||
| 284 | Unset, `EITRI_URL` and `EITRI_GATE` default to the hosted service at eitri.sh, | ||
| 285 | so self-hosting always sets them to your own box. | ||
| 286 | |||
| 283 | `eitri ssh` is plain ssh in a trenchcoat: it signs a short-lived cert with | 287 | `eitri ssh` is plain ssh in a trenchcoat: it signs a short-lived cert with |
| 284 | your CA, pins eitri's host CA, and jumps the gate to | 288 | your CA, pins eitri's host CA, and jumps the gate to `ubuntu@<tenant>.<vm-name>` |
| 285 | `ubuntu@<your-tenant>.<vm-name>`. No token. [ssh-access.md](ssh-access.md) | 289 | — the VM's host cert carries that one namespaced principal, so that is the name |
| 286 | shows it done by hand. | 290 | verified. You still type only `<vm-name>`; `eitri ssh` looks the tenant up from |
| 291 | your token (or `EITRI_TENANT`). [ssh-access.md](ssh-access.md) shows it done by | ||
| 292 | hand. | ||
| 287 | 293 | ||
| 288 | ### More | 294 | ### More |
| 289 | 295 | ||
docs/ssh-access.md
| Old | New | ||
|---|---|---|---|
| @@ -20,16 +20,17 @@ a server compromise cannot mint user credentials. | |||
| 20 | 20 | ||
| 21 | ## Bring your own CA (once per tenant) | 21 | ## Bring your own CA (once per tenant) |
| 22 | 22 | ||
| 23 | Generate a user CA and register its **public** key with your tenant | 23 | Generate a user CA and register its **public** key with your tenant. The token |
| 24 | (`POST /api/v1/tenants/<tenant>/user-cas`, authenticated by a personal access | 24 | names the tenant (`POST /api/v1/user-cas`), so no handle is needed; a token that |
| 25 | token): | 25 | can act for more than one tenant pins one with `eitri ca upload <tenant> <key>` |
| 26 | (`POST /api/v1/tenants/<tenant>/user-cas`): | ||
| 26 | 27 | ||
| 27 | ```sh | 28 | ```sh |
| 28 | ssh-keygen -t ed25519 -N '' -f ~/.ssh/eitri_user_ca -C "my tenant user CA" | 29 | ssh-keygen -t ed25519 -N '' -f ~/.ssh/eitri_user_ca -C "my tenant user CA" |
| 29 | 30 | ||
| 30 | export EITRI_URL=https://eitri.example.com | 31 | export EITRI_URL=https://eitri.example.com |
| 31 | export EITRI_TOKEN=<personal-access-token> # mint one in the console → Settings | 32 | export EITRI_TOKEN=<personal-access-token> # mint one in the console → Settings |
| 32 | eitri ca upload <your-tenant> ~/.ssh/eitri_user_ca.pub | 33 | eitri ca upload ~/.ssh/eitri_user_ca.pub |
| 33 | ``` | 34 | ``` |
| 34 | 35 | ||
| 35 | The CA's private key never leaves your machine; the server stores only the | 36 | The CA's private key never leaves your machine; the server stores only the |
| @@ -47,26 +48,36 @@ eitri ssh <vm-name> # opens a shell on the VM | |||
| 47 | eitri ssh <vm-name> uptime # runs a command and exits | 48 | eitri ssh <vm-name> uptime # runs a command and exits |
| 48 | ``` | 49 | ``` |
| 49 | 50 | ||
| 50 | You pass the bare `<vm-name>`. VMs are actually dialed by their **gate connect | 51 | You pass the bare `<vm-name>`, but the name that reaches the wire is always the |
| 51 | name** `<tenant>.<vm-name>` (also the VM's host-cert principal); `eitri ssh` | 52 | **gate connect name** `<tenant>.<vm-name>` — a VM's host cert carries exactly |
| 52 | builds it from `EITRI_TENANT`. | 53 | that one principal, and `eitri ssh` verifies the dialed name against it under |
| 54 | strict checking, so a bare name would fail host verification. (The gate itself | ||
| 55 | also *resolves* a bare name within the connection's tenant, but the VM's cert | ||
| 56 | does not, so the client sends the namespaced form.) You never need to know your | ||
| 57 | tenant: `eitri ssh` derives it from your credential via `/me`, or takes it from | ||
| 58 | `EITRI_TENANT` when set (offline, and the escape hatch for a CA registered in | ||
| 59 | more than one tenant). | ||
| 53 | 60 | ||
| 54 | Environment variables: | 61 | Environment variables: |
| 55 | 62 | ||
| 56 | | Var | Meaning | | 63 | | Var | Meaning | |
| 57 | | ------------- | --------------------------------------------------------- | | 64 | | ------------- | --------------------------------------------------------- | |
| 58 | | `EITRI_URL` | Base URL of the eitri server | | 65 | | `EITRI_URL` | Base URL of the eitri server (default the hosted `https://console.eitri.sh`) | |
| 59 | | `EITRI_GATE` | Jump gate address for the hop (host:port, `ssh_listen`) | | 66 | | `EITRI_GATE` | Jump gate address for the hop (host:port, `ssh_listen`; default the hosted `gate.eitri.sh:2222`) | |
| 60 | | `EITRI_CA` | Your tenant user-CA **private** key (default `~/.ssh/eitri_user_ca`) | | 67 | | `EITRI_CA` | Your tenant user-CA **private** key (default `~/.ssh/eitri_user_ca`) | |
| 61 | | `EITRI_TENANT`| Tenant your CA was uploaded to (default `default`) | | 68 | | `EITRI_TOKEN` | Personal access token, used only to look up your tenant for the connect name (skipped when `EITRI_TENANT` is set) | |
| 69 | | `EITRI_TENANT`| Optional: pins the tenant (offline, and the escape hatch when your CA is registered in more than one); otherwise derived from the token | | ||
| 62 | | `EITRI_KEY` | SSH private key path (default `~/.ssh/id_ed25519`) | | 70 | | `EITRI_KEY` | SSH private key path (default `~/.ssh/id_ed25519`) | |
| 63 | | `EITRI_KNOWN_HOSTS` | eitri-managed known_hosts for the CA pin (default `~/.ssh/eitri_known_hosts`) | | 71 | | `EITRI_KNOWN_HOSTS` | eitri-managed known_hosts for the CA pin (default `~/.ssh/eitri_known_hosts`) | |
| 64 | 72 | ||
| 65 | No token: the helper needs no API credential — your signing CA *is* the | 73 | The SSH session authenticates with no API credential — your signing CA *is* the |
| 66 | credential. It generates `~/.ssh/id_ed25519` if missing, self-signs a 30-minute | 74 | credential. `eitri ssh` generates `~/.ssh/id_ed25519` if missing, self-signs a |
| 67 | cert to `<key>-cert.pub` (which OpenSSH auto-offers), fetches the eitri host CA | 75 | 30-minute cert to `<key>-cert.pub` (which OpenSSH auto-offers), fetches the |
| 68 | and pins it as `@cert-authority *` in a dedicated known_hosts file, and execs | 76 | eitri host CA and pins it as `@cert-authority *` in a dedicated known_hosts |
| 69 | `ssh` with both hops verified. | 77 | file, and execs `ssh` with both hops verified. The one thing it needs your |
| 78 | `EITRI_TOKEN` for is looking up your tenant to build the namespaced connect | ||
| 79 | name — set `EITRI_TENANT` instead to skip that call (offline, or the | ||
| 80 | multi-tenant pin). | ||
| 70 | 81 | ||
| 71 | > The host `EITRI_GATE` points at **must match** the gate's host-cert principal, | 82 | > The host `EITRI_GATE` points at **must match** the gate's host-cert principal, |
| 72 | > i.e. the server's `ssh_gate_domain` (which defaults to the host part of | 83 | > i.e. the server's `ssh_gate_domain` (which defaults to the host part of |
internal/cli/ca.go
| Old | New | ||
|---|---|---|---|
| @@ -10,11 +10,13 @@ import ( | |||
| 10 | "github.com/a73x/eitri/internal/server/api/client" | 10 | "github.com/a73x/eitri/internal/server/api/client" |
| 11 | ) | 11 | ) |
| 12 | 12 | ||
| 13 | // UploadUserCA registers the tenant user-CA public key in pubFile with the | 13 | // UploadUserCA registers the user-CA public key in pubFile with the server |
| 14 | // server (admin-authenticated). The key is parse-validated locally — garbage | 14 | // under the PAT token. An empty tenant registers on the token's own tenant (the |
| 15 | // is rejected before it leaves the machine — and the returned summary line | 15 | // credential names it); a non-empty tenant pins one explicitly. The key is |
| 16 | // carries the locally computed fingerprint (identical to the server's echo: | 16 | // parse-validated locally — garbage is rejected before it leaves the machine — |
| 17 | // both fingerprint the same key). | 17 | // and the returned summary line carries the locally computed fingerprint |
| 18 | // (identical to the server's echo: both fingerprint the same key), naming the | ||
| 19 | // tenant only when we pinned one. | ||
| 18 | func UploadUserCA(ctx context.Context, baseURL, token, tenant, pubFile string) (string, error) { | 20 | func UploadUserCA(ctx context.Context, baseURL, token, tenant, pubFile string) (string, error) { |
| 19 | pub, err := os.ReadFile(pubFile) | 21 | pub, err := os.ReadFile(pubFile) |
| 20 | if err != nil { | 22 | if err != nil { |
| @@ -28,5 +30,9 @@ func UploadUserCA(ctx context.Context, baseURL, token, tenant, pubFile string) ( | |||
| 28 | if err := c.UploadUserCA(ctx, tenant, string(pub)); err != nil { | 30 | if err := c.UploadUserCA(ctx, tenant, string(pub)); err != nil { |
| 29 | return "", err | 31 | return "", err |
| 30 | } | 32 | } |
| 31 | return fmt.Sprintf("registered user CA %s for tenant %s", ssh.FingerprintSHA256(pk), tenant), nil | 33 | fp := ssh.FingerprintSHA256(pk) |
| 34 | if tenant == "" { | ||
| 35 | return fmt.Sprintf("registered user CA %s", fp), nil | ||
| 36 | } | ||
| 37 | return fmt.Sprintf("registered user CA %s for tenant %s", fp, tenant), nil | ||
| 32 | } | 38 | } |
internal/cli/ca_test.go
| Old | New | ||
|---|---|---|---|
| @@ -54,6 +54,38 @@ func TestUploadUserCA(t *testing.T) { | |||
| 54 | } | 54 | } |
| 55 | } | 55 | } |
| 56 | 56 | ||
| 57 | // An empty tenant targets the tenant-less endpoint (the token names the tenant) | ||
| 58 | // and the summary line makes no tenant claim. | ||
| 59 | func TestUploadUserCATokenNamedTenant(t *testing.T) { | ||
| 60 | var gotPath string | ||
| 61 | srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | ||
| 62 | gotPath = r.URL.Path | ||
| 63 | w.Write([]byte(`{"fingerprint":"SHA256:server-echo"}`)) | ||
| 64 | })) | ||
| 65 | defer srv.Close() | ||
| 66 | |||
| 67 | pub := filepath.Join(t.TempDir(), "ca.pub") | ||
| 68 | os.WriteFile(pub, []byte(testUserCALine), 0o644) | ||
| 69 | |||
| 70 | out, err := UploadUserCA(context.Background(), srv.URL, "tok123", "", pub) | ||
| 71 | if err != nil { | ||
| 72 | t.Fatal(err) | ||
| 73 | } | ||
| 74 | if gotPath != "/api/v1/user-cas" { | ||
| 75 | t.Errorf("path = %q, want /api/v1/user-cas", gotPath) | ||
| 76 | } | ||
| 77 | if strings.Contains(out, "for tenant") { | ||
| 78 | t.Errorf("out must not claim a tenant we did not pin: %q", out) | ||
| 79 | } | ||
| 80 | pk, _, _, _, err := ssh.ParseAuthorizedKey([]byte(testUserCALine)) | ||
| 81 | if err != nil { | ||
| 82 | t.Fatal(err) | ||
| 83 | } | ||
| 84 | if !strings.Contains(out, ssh.FingerprintSHA256(pk)) { | ||
| 85 | t.Errorf("out = %q", out) | ||
| 86 | } | ||
| 87 | } | ||
| 88 | |||
| 57 | func TestUploadUserCAErrorSurfacesBody(t *testing.T) { | 89 | func TestUploadUserCAErrorSurfacesBody(t *testing.T) { |
| 58 | srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | 90 | srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| 59 | http.Error(w, "tenant not found", http.StatusNotFound) | 91 | http.Error(w, "tenant not found", http.StatusNotFound) |
internal/cli/env.go
| Old | New | ||
|---|---|---|---|
| @@ -6,39 +6,58 @@ | |||
| 6 | package cli | 6 | package cli |
| 7 | 7 | ||
| 8 | import ( | 8 | import ( |
| 9 | "fmt" | ||
| 10 | "os" | 9 | "os" |
| 11 | "path/filepath" | 10 | "path/filepath" |
| 12 | ) | 11 | ) |
| 13 | 12 | ||
| 13 | // Hosted-service defaults: unset, the CLI targets the hosted service at | ||
| 14 | // eitri.sh, so a fresh laptop needs no configuration to reach it. Self-hosters | ||
| 15 | // and tests point elsewhere by setting the matching environment variable. | ||
| 16 | const ( | ||
| 17 | defaultURL = "https://console.eitri.sh" | ||
| 18 | defaultGate = "gate.eitri.sh:2222" | ||
| 19 | ) | ||
| 20 | |||
| 14 | // Env is the client configuration, resolved from EITRI_* variables. | 21 | // Env is the client configuration, resolved from EITRI_* variables. |
| 15 | type Env struct { | 22 | type Env struct { |
| 16 | URL string // server base URL (required) | 23 | URL string // server base URL (EITRI_URL, else the hosted default) |
| 17 | Gate string // gate host[:port] (required) | 24 | Gate string // gate host[:port] (EITRI_GATE, else the hosted default) |
| 18 | CA string // tenant user-CA private key path | 25 | CA string // tenant user-CA private key path |
| 19 | Tenant string | 26 | Tenant string |
| 20 | Key string // user SSH private key path | 27 | Key string // user SSH private key path |
| 21 | KnownHosts string // dedicated pin file — never the user's main known_hosts | 28 | KnownHosts string // dedicated pin file — never the user's main known_hosts |
| 22 | } | 29 | } |
| 23 | 30 | ||
| 24 | // FromEnv resolves the env contract shared with the former scripts: | 31 | // URLFromEnv is the server base URL: EITRI_URL, or the hosted-service default. |
| 25 | // EITRI_URL, EITRI_GATE and EITRI_TENANT are required (there is no implicit | 32 | // It is the SINGLE source of that default — every consumer (ssh and ca alike) |
| 26 | // tenant — connect names are <tenant>.<vm> and guessing one would fail with an | 33 | // resolves the URL through it, so the default string is written in exactly one |
| 27 | // opaque gate refusal); the path fields default under $HOME/.ssh. | 34 | // place. |
| 35 | func URLFromEnv() string { | ||
| 36 | if u := os.Getenv("EITRI_URL"); u != "" { | ||
| 37 | return u | ||
| 38 | } | ||
| 39 | return defaultURL | ||
| 40 | } | ||
| 41 | |||
| 42 | // FromEnv resolves the client configuration. The CLI's defaults are the hosted | ||
| 43 | // service at eitri.sh: EITRI_URL falls back to the hosted console and | ||
| 44 | // EITRI_GATE to the hosted gate, so nothing needs setting to reach it; | ||
| 45 | // self-hosters and tests override via the environment. EITRI_TENANT is optional | ||
| 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. | ||
| 28 | func FromEnv() (Env, error) { | 50 | func FromEnv() (Env, error) { |
| 29 | e := Env{ | 51 | e := Env{ |
| 30 | URL: os.Getenv("EITRI_URL"), | 52 | URL: URLFromEnv(), |
| 31 | Gate: os.Getenv("EITRI_GATE"), | 53 | Gate: os.Getenv("EITRI_GATE"), |
| 32 | CA: os.Getenv("EITRI_CA"), | 54 | CA: os.Getenv("EITRI_CA"), |
| 33 | Tenant: os.Getenv("EITRI_TENANT"), | 55 | Tenant: os.Getenv("EITRI_TENANT"), |
| 34 | Key: os.Getenv("EITRI_KEY"), | 56 | Key: os.Getenv("EITRI_KEY"), |
| 35 | KnownHosts: os.Getenv("EITRI_KNOWN_HOSTS"), | 57 | KnownHosts: os.Getenv("EITRI_KNOWN_HOSTS"), |
| 36 | } | 58 | } |
| 37 | if e.URL == "" || e.Gate == "" { | 59 | if e.Gate == "" { |
| 38 | return Env{}, fmt.Errorf("set EITRI_URL (server base URL) and EITRI_GATE (gate host:port)") | 60 | e.Gate = defaultGate |
| 39 | } | ||
| 40 | if e.Tenant == "" { | ||
| 41 | return Env{}, fmt.Errorf("set EITRI_TENANT (your tenant handle — shown on the console's Settings page)") | ||
| 42 | } | 61 | } |
| 43 | home, err := os.UserHomeDir() | 62 | home, err := os.UserHomeDir() |
| 44 | if err != nil { | 63 | if err != nil { |
internal/cli/env_test.go
| Old | New | ||
|---|---|---|---|
| @@ -2,7 +2,6 @@ package cli | |||
| 2 | 2 | ||
| 3 | import ( | 3 | import ( |
| 4 | "path/filepath" | 4 | "path/filepath" |
| 5 | "strings" | ||
| 6 | "testing" | 5 | "testing" |
| 7 | ) | 6 | ) |
| 8 | 7 | ||
| @@ -33,26 +32,33 @@ func TestFromEnvDefaults(t *testing.T) { | |||
| 33 | } | 32 | } |
| 34 | } | 33 | } |
| 35 | 34 | ||
| 36 | func TestFromEnvRequiresURLAndGate(t *testing.T) { | 35 | func TestFromEnvHostedDefaults(t *testing.T) { |
| 37 | t.Setenv("EITRI_URL", "") | 36 | // A clean environment targets the hosted service at eitri.sh — no |
| 38 | t.Setenv("EITRI_GATE", "") | 37 | // configuration needed to reach it (TestFromEnvDefaults proves the env |
| 39 | if _, err := FromEnv(); err == nil { | 38 | // still overrides these). |
| 40 | t.Fatal("want error without EITRI_URL/EITRI_GATE") | 39 | for _, v := range []string{"EITRI_URL", "EITRI_GATE", "EITRI_TENANT", "EITRI_CA", "EITRI_KEY", "EITRI_KNOWN_HOSTS"} { |
| 40 | t.Setenv(v, "") | ||
| 41 | } | ||
| 42 | e, err := FromEnv() | ||
| 43 | if err != nil { | ||
| 44 | t.Fatalf("clean env must succeed: %v", err) | ||
| 45 | } | ||
| 46 | if e.URL != "https://console.eitri.sh" || e.Gate != "gate.eitri.sh:2222" { | ||
| 47 | t.Errorf("hosted defaults: URL=%q Gate=%q", e.URL, e.Gate) | ||
| 41 | } | 48 | } |
| 42 | } | 49 | } |
| 43 | 50 | ||
| 44 | func TestFromEnvRequiresTenant(t *testing.T) { | 51 | func TestFromEnvTenantOptional(t *testing.T) { |
| 45 | // There is no implicit tenant: connect names are <tenant>.<vm>, and a | 52 | // EITRI_TENANT is optional: `eitri ssh` derives the tenant from the |
| 46 | // guessed tenant would surface as an opaque gate refusal instead of this | 53 | // credential when it is unset, so an unset tenant is not a FromEnv error. |
| 47 | // actionable error. | ||
| 48 | t.Setenv("EITRI_URL", "http://192.0.2.10:8080") | 54 | t.Setenv("EITRI_URL", "http://192.0.2.10:8080") |
| 49 | t.Setenv("EITRI_GATE", "192.0.2.10:2222") | 55 | t.Setenv("EITRI_GATE", "192.0.2.10:2222") |
| 50 | t.Setenv("EITRI_TENANT", "") | 56 | t.Setenv("EITRI_TENANT", "") |
| 51 | _, err := FromEnv() | 57 | e, err := FromEnv() |
| 52 | if err == nil { | 58 | if err != nil { |
| 53 | t.Fatal("want error without EITRI_TENANT") | 59 | t.Fatalf("unset EITRI_TENANT must be accepted: %v", err) |
| 54 | } | 60 | } |
| 55 | if !strings.Contains(err.Error(), "EITRI_TENANT") { | 61 | if e.Tenant != "" { |
| 56 | t.Errorf("error must name the missing variable: %v", err) | 62 | t.Errorf("tenant = %q, want empty", e.Tenant) |
| 57 | } | 63 | } |
| 58 | } | 64 | } |
internal/cli/main.go
| Old | New | ||
|---|---|---|---|
| @@ -18,9 +18,11 @@ const Usage = `usage: | |||
| 18 | eitri ca upload [<tenant>] <ca-public-key-file> | 18 | eitri ca upload [<tenant>] <ca-public-key-file> |
| 19 | eitri --version | 19 | eitri --version |
| 20 | 20 | ||
| 21 | env: EITRI_URL, EITRI_GATE, EITRI_TENANT (required for ssh); | 21 | env: all optional — EITRI_URL and EITRI_GATE default to the hosted service at |
| 22 | EITRI_URL, EITRI_TOKEN and a tenant (argument or EITRI_TENANT) for ca; | 22 | eitri.sh (self-hosters and tests override). ca needs EITRI_TOKEN (a PAT); |
| 23 | EITRI_CA, EITRI_KEY, EITRI_KNOWN_HOSTS (optional, default under ~/.ssh)` | 23 | its tenant is optional (argument, else EITRI_TENANT, else the token's own). |
| 24 | EITRI_TENANT disambiguates a user CA registered in more than one tenant. | ||
| 25 | EITRI_CA, EITRI_KEY, EITRI_KNOWN_HOSTS default under ~/.ssh.` | ||
| 24 | 26 | ||
| 25 | // ErrUsage marks a bad invocation: the caller prints Usage and exits 2 rather | 27 | // ErrUsage marks a bad invocation: the caller prints Usage and exits 2 rather |
| 26 | // than treating it as a runtime failure. | 28 | // than treating it as a runtime failure. |
| @@ -66,21 +68,18 @@ func runCA(args []string, stdout io.Writer) error { | |||
| 66 | if len(rest) < 1 || len(rest) > 2 { | 68 | if len(rest) < 1 || len(rest) > 2 { |
| 67 | return fmt.Errorf("usage: eitri ca upload [<tenant>] <ca-public-key-file>") | 69 | return fmt.Errorf("usage: eitri ca upload [<tenant>] <ca-public-key-file>") |
| 68 | } | 70 | } |
| 69 | // The tenant comes from the positional when given, else EITRI_TENANT. | 71 | // One arg: the token names the tenant, unless EITRI_TENANT pins one. Two |
| 70 | // There is no implicit tenant (a guess would surface as an opaque 404). | 72 | // args: the leading positional pins the tenant explicitly (for a token that |
| 73 | // can act for more than one). | ||
| 71 | tenant, pub := os.Getenv("EITRI_TENANT"), rest[0] | 74 | tenant, pub := os.Getenv("EITRI_TENANT"), rest[0] |
| 72 | if len(rest) == 2 { | 75 | if len(rest) == 2 { |
| 73 | tenant, pub = rest[0], rest[1] | 76 | tenant, pub = rest[0], rest[1] |
| 74 | } | 77 | } |
| 75 | if tenant == "" { | ||
| 76 | return fmt.Errorf("no tenant: pass one (eitri ca upload <tenant> <file>) or set EITRI_TENANT") | ||
| 77 | } | ||
| 78 | url := os.Getenv("EITRI_URL") | ||
| 79 | token := os.Getenv("EITRI_TOKEN") | 78 | token := os.Getenv("EITRI_TOKEN") |
| 80 | if url == "" || token == "" { | 79 | if token == "" { |
| 81 | return fmt.Errorf("set EITRI_URL and EITRI_TOKEN (a personal access token)") | 80 | return fmt.Errorf("set EITRI_TOKEN (a personal access token)") |
| 82 | } | 81 | } |
| 83 | out, err := UploadUserCA(context.Background(), url, token, tenant, pub) | 82 | out, err := UploadUserCA(context.Background(), URLFromEnv(), token, tenant, pub) |
| 84 | if err != nil { | 83 | if err != nil { |
| 85 | return err | 84 | return err |
| 86 | } | 85 | } |
internal/cli/main_test.go
| Old | New | ||
|---|---|---|---|
| @@ -27,9 +27,12 @@ func TestMainDispatch(t *testing.T) { | |||
| 27 | } | 27 | } |
| 28 | } | 28 | } |
| 29 | 29 | ||
| 30 | // TestRunCATenantResolution pins how `eitri ca upload` finds its tenant: | 30 | // TestRunCATenantResolution pins how `eitri ca upload` finds its tenant: a |
| 31 | // positional wins, EITRI_TENANT is the fallback, and no tenant at all is an | 31 | // leading positional pins it, EITRI_TENANT is the fallback, and with neither the |
| 32 | // actionable error before anything touches the network. | 32 | // token names it — no tenant is needed. EITRI_URL defaults to the hosted |
| 33 | // service, so the only network variable left to require is EITRI_TOKEN; each | ||
| 34 | // path reaching that check proves tenant resolution passed rather than | ||
| 35 | // short-circuiting on a missing tenant. | ||
| 33 | func TestRunCATenantResolution(t *testing.T) { | 36 | func TestRunCATenantResolution(t *testing.T) { |
| 34 | var out bytes.Buffer | 37 | var out bytes.Buffer |
| 35 | t.Setenv("EITRI_URL", "") | 38 | t.Setenv("EITRI_URL", "") |
| @@ -43,21 +46,21 @@ func TestRunCATenantResolution(t *testing.T) { | |||
| 43 | t.Errorf("upload with no args: got %v", err) | 46 | t.Errorf("upload with no args: got %v", err) |
| 44 | } | 47 | } |
| 45 | 48 | ||
| 46 | // No positional tenant, no EITRI_TENANT: the error names both remedies. | 49 | // One arg, no EITRI_TENANT: the token names the tenant, so resolution is not |
| 50 | // an error — it falls through to the token check. | ||
| 47 | err := Main([]string{"ca", "upload", "ca.pub"}, &out) | 51 | err := Main([]string{"ca", "upload", "ca.pub"}, &out) |
| 48 | if err == nil || !strings.Contains(err.Error(), "EITRI_TENANT") { | 52 | if err == nil || !strings.Contains(err.Error(), "EITRI_TOKEN") { |
| 49 | t.Errorf("tenantless: got %v", err) | 53 | t.Errorf("token-named tenant: got %v", err) |
| 50 | } | 54 | } |
| 51 | 55 | ||
| 52 | // A tenant (positional or env) but no URL/token: fails on the env contract | 56 | // Two args pin the tenant explicitly; still falls through to the token check. |
| 53 | // — proving tenant resolution passed. | ||
| 54 | err = Main([]string{"ca", "upload", "acme", "ca.pub"}, &out) | 57 | err = Main([]string{"ca", "upload", "acme", "ca.pub"}, &out) |
| 55 | if err == nil || !strings.Contains(err.Error(), "EITRI_URL and EITRI_TOKEN") { | 58 | if err == nil || !strings.Contains(err.Error(), "EITRI_TOKEN") { |
| 56 | t.Errorf("positional tenant: got %v", err) | 59 | t.Errorf("positional tenant: got %v", err) |
| 57 | } | 60 | } |
| 58 | t.Setenv("EITRI_TENANT", "acme") | 61 | t.Setenv("EITRI_TENANT", "acme") |
| 59 | err = Main([]string{"ca", "upload", "ca.pub"}, &out) | 62 | err = Main([]string{"ca", "upload", "ca.pub"}, &out) |
| 60 | if err == nil || !strings.Contains(err.Error(), "EITRI_URL and EITRI_TOKEN") { | 63 | if err == nil || !strings.Contains(err.Error(), "EITRI_TOKEN") { |
| 61 | t.Errorf("env tenant: got %v", err) | 64 | t.Errorf("env tenant: got %v", err) |
| 62 | } | 65 | } |
| 63 | } | 66 | } |
internal/cli/sshcmd.go
| Old | New | ||
|---|---|---|---|
| @@ -9,9 +9,17 @@ import ( | |||
| 9 | "os/user" | 9 | "os/user" |
| 10 | "strings" | 10 | "strings" |
| 11 | "syscall" | 11 | "syscall" |
| 12 | |||
| 13 | "github.com/a73x/eitri/internal/server/api/client" | ||
| 12 | ) | 14 | ) |
| 13 | 15 | ||
| 14 | // SSHArgv builds the system-ssh invocation for a session to <tenant>.<vm>. | 16 | // SSHArgv builds the system-ssh invocation for a session to a VM. The connect |
| 17 | // name sent on the wire is ALWAYS the namespaced <tenant>.<vm>: a VM's host cert | ||
| 18 | // carries exactly one principal, its <tenant>.<vm> name, and the final hop | ||
| 19 | // verifies the dialed name against that principal under strict checking, so a | ||
| 20 | // bare name would fail host verification (the gate resolves bare names, but the | ||
| 21 | // VM's cert does not). e.Tenant is resolved before this call (RunSSH derives it | ||
| 22 | // from the credential when unset), so the user still never types their tenant. | ||
| 15 | // The gate hop rides an explicit ProxyCommand — NOT -J — because command-line | 23 | // The gate hop rides an explicit ProxyCommand — NOT -J — because command-line |
| 16 | // -o options reach only the final hop; both hops must verify the presented | 24 | // -o options reach only the final hop; both hops must verify the presented |
| 17 | // host certificate against the pinned eitri CA with strict checking. The | 25 | // host certificate against the pinned eitri CA with strict checking. The |
| @@ -40,6 +48,33 @@ func SSHArgv(e Env, vm string, extra []string) []string { | |||
| 40 | return append(argv, extra...) | 48 | return append(argv, extra...) |
| 41 | } | 49 | } |
| 42 | 50 | ||
| 51 | // resolveTenant returns the tenant for the VM's gate connect name. EITRI_TENANT | ||
| 52 | // pins it — offline, and the escape hatch when a user CA lives in more than one | ||
| 53 | // tenant — and otherwise the CLI derives it from the credential via /me. The | ||
| 54 | // connect name must be the namespaced <tenant>.<vm> (the VM host cert's one | ||
| 55 | // principal, see SSHArgv), so the tenant is always needed; deriving it means the | ||
| 56 | // user never has to know or type it. Deriving needs EITRI_TOKEN; with neither | ||
| 57 | // the tenant nor a token, the connect name cannot be built. | ||
| 58 | func resolveTenant(e Env) (string, error) { | ||
| 59 | if e.Tenant != "" { | ||
| 60 | return e.Tenant, nil | ||
| 61 | } | ||
| 62 | token := os.Getenv("EITRI_TOKEN") | ||
| 63 | if token == "" { | ||
| 64 | return "", fmt.Errorf("set EITRI_TOKEN (a personal access token) or EITRI_TENANT — the connect name needs your tenant") | ||
| 65 | } | ||
| 66 | // 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. | ||
| 68 | me, err := (&client.Client{BaseURL: e.URL, Token: token}).Me() | ||
| 69 | if err != nil { | ||
| 70 | return "", fmt.Errorf("resolving your tenant from the token: %w", err) | ||
| 71 | } | ||
| 72 | if me.Tenant == "" { | ||
| 73 | return "", fmt.Errorf("your token resolves to no tenant") | ||
| 74 | } | ||
| 75 | return me.Tenant, nil | ||
| 76 | } | ||
| 77 | |||
| 43 | // shq single-quotes s for POSIX shell word-splitting (ProxyCommand runs via | 78 | // shq single-quotes s for POSIX shell word-splitting (ProxyCommand runs via |
| 44 | // the user's shell). | 79 | // the user's shell). |
| 45 | func shq(s string) string { | 80 | func shq(s string) string { |
| @@ -60,6 +95,13 @@ func keyID() string { | |||
| 60 | // process with the system ssh. Exec (not a child process) so the TTY, signals, | 95 | // process with the system ssh. Exec (not a child process) so the TTY, signals, |
| 61 | // and exit code belong to ssh itself. | 96 | // and exit code belong to ssh itself. |
| 62 | func RunSSH(ctx context.Context, e Env, vm string, extra []string) error { | 97 | func RunSSH(ctx context.Context, e Env, vm string, extra []string) error { |
| 98 | // resolveTenant's only network call is the deliberately context-free | ||
| 99 | // client.Me, so there is no ctx for it to forward. | ||
| 100 | tenant, err := resolveTenant(e) //nolint:contextcheck | ||
| 101 | if err != nil { | ||
| 102 | return err | ||
| 103 | } | ||
| 104 | e.Tenant = tenant | ||
| 63 | if err := EnsureKeypair(e.Key); err != nil { | 105 | if err := EnsureKeypair(e.Key); err != nil { |
| 64 | return err | 106 | return err |
| 65 | } | 107 | } |
internal/cli/sshcmd_test.go
| Old | New | ||
|---|---|---|---|
| @@ -1,6 +1,8 @@ | |||
| 1 | package cli | 1 | package cli |
| 2 | 2 | ||
| 3 | import ( | 3 | import ( |
| 4 | "net/http" | ||
| 5 | "net/http/httptest" | ||
| 4 | "slices" | 6 | "slices" |
| 5 | "strings" | 7 | "strings" |
| 6 | "testing" | 8 | "testing" |
| @@ -32,6 +34,103 @@ func TestSSHArgvTwoVerifiedHops(t *testing.T) { | |||
| 32 | } | 34 | } |
| 33 | } | 35 | } |
| 34 | 36 | ||
| 37 | // The wire name is ALWAYS the namespaced <tenant>.<vm> — a VM host cert's one | ||
| 38 | // principal — so SSHArgv never emits a bare target. | ||
| 39 | func TestSSHArgvAlwaysNamespaced(t *testing.T) { | ||
| 40 | got := SSHArgv(testEnv(), "dev", nil) | ||
| 41 | if want := "ubuntu@default.dev"; got[len(got)-1] != want { | ||
| 42 | t.Errorf("target = %q, want %q", got[len(got)-1], want) | ||
| 43 | } | ||
| 44 | } | ||
| 45 | |||
| 46 | // resolveTenant derives the tenant from the credential (via /me) when | ||
| 47 | // EITRI_TENANT is unset, so the namespaced connect name can be built without the | ||
| 48 | // user knowing their tenant. | ||
| 49 | func TestResolveTenantFromMe(t *testing.T) { | ||
| 50 | var gotAuth string | ||
| 51 | srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | ||
| 52 | gotAuth = r.Header.Get("Authorization") | ||
| 53 | if r.URL.Path != "/api/v1/me" { | ||
| 54 | t.Errorf("path = %q, want /api/v1/me", r.URL.Path) | ||
| 55 | } | ||
| 56 | w.Write([]byte(`{"tenant":"acme","email":"me@acme.example"}`)) | ||
| 57 | })) | ||
| 58 | defer srv.Close() | ||
| 59 | t.Setenv("EITRI_TOKEN", "tok123") | ||
| 60 | |||
| 61 | e := testEnv() | ||
| 62 | e.URL = srv.URL | ||
| 63 | e.Tenant = "" | ||
| 64 | got, err := resolveTenant(e) | ||
| 65 | if err != nil { | ||
| 66 | t.Fatal(err) | ||
| 67 | } | ||
| 68 | if got != "acme" { | ||
| 69 | t.Errorf("tenant = %q, want acme", got) | ||
| 70 | } | ||
| 71 | if gotAuth != "Bearer tok123" { | ||
| 72 | t.Errorf("auth = %q, want Bearer tok123", gotAuth) | ||
| 73 | } | ||
| 74 | } | ||
| 75 | |||
| 76 | // EITRI_TENANT pins the tenant with no HTTP call — offline and the multi-tenant | ||
| 77 | // escape hatch. | ||
| 78 | func TestResolveTenantEnvShortCircuits(t *testing.T) { | ||
| 79 | srv := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) { | ||
| 80 | t.Error("EITRI_TENANT must short-circuit — no /me call") | ||
| 81 | })) | ||
| 82 | defer srv.Close() | ||
| 83 | |||
| 84 | e := testEnv() | ||
| 85 | e.URL = srv.URL | ||
| 86 | e.Tenant = "team" | ||
| 87 | got, err := resolveTenant(e) | ||
| 88 | if err != nil { | ||
| 89 | t.Fatal(err) | ||
| 90 | } | ||
| 91 | if got != "team" { | ||
| 92 | t.Errorf("tenant = %q, want team", got) | ||
| 93 | } | ||
| 94 | } | ||
| 95 | |||
| 96 | // A credential that resolves to no tenant, or a failing /me, is a clear error. | ||
| 97 | func TestResolveTenantMeFailures(t *testing.T) { | ||
| 98 | t.Setenv("EITRI_TOKEN", "tok") | ||
| 99 | |||
| 100 | // Empty tenant in the /me response. | ||
| 101 | empty := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | ||
| 102 | w.Write([]byte(`{"tenant":"","email":"x@y"}`)) | ||
| 103 | })) | ||
| 104 | defer empty.Close() | ||
| 105 | e := testEnv() | ||
| 106 | e.URL, e.Tenant = empty.URL, "" | ||
| 107 | if _, err := resolveTenant(e); err == nil || !strings.Contains(err.Error(), "no tenant") { | ||
| 108 | t.Errorf("empty tenant: got %v", err) | ||
| 109 | } | ||
| 110 | |||
| 111 | // A non-2xx /me surfaces as an error naming the resolution step. | ||
| 112 | boom := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | ||
| 113 | http.Error(w, "nope", http.StatusUnauthorized) | ||
| 114 | })) | ||
| 115 | defer boom.Close() | ||
| 116 | e.URL = boom.URL | ||
| 117 | if _, err := resolveTenant(e); err == nil || !strings.Contains(err.Error(), "resolving your tenant") { | ||
| 118 | t.Errorf("me error: got %v", err) | ||
| 119 | } | ||
| 120 | } | ||
| 121 | |||
| 122 | // With neither EITRI_TENANT nor EITRI_TOKEN the connect name cannot be built; | ||
| 123 | // the error names both remedies before any network call. | ||
| 124 | func TestResolveTenantNoTokenNoTenant(t *testing.T) { | ||
| 125 | t.Setenv("EITRI_TOKEN", "") | ||
| 126 | e := testEnv() | ||
| 127 | e.Tenant = "" | ||
| 128 | _, err := resolveTenant(e) | ||
| 129 | if err == nil || !strings.Contains(err.Error(), "EITRI_TOKEN") || !strings.Contains(err.Error(), "EITRI_TENANT") { | ||
| 130 | t.Errorf("want error naming EITRI_TOKEN and EITRI_TENANT, got %v", err) | ||
| 131 | } | ||
| 132 | } | ||
| 133 | |||
| 35 | func TestSSHArgvGateDefaultPort(t *testing.T) { | 134 | func TestSSHArgvGateDefaultPort(t *testing.T) { |
| 36 | e := testEnv() | 135 | e := testEnv() |
| 37 | e.Gate = "gate.example" | 136 | e.Gate = "gate.example" |
internal/server/api/client/client.go
| Old | New | ||
|---|---|---|---|
| @@ -190,10 +190,16 @@ func (c *Client) FetchSSHCA(ctx context.Context) (ssh.PublicKey, error) { | |||
| 190 | return pub, nil | 190 | return pub, nil |
| 191 | } | 191 | } |
| 192 | 192 | ||
| 193 | // UploadUserCA registers caLine (a BYO user-CA authorized_keys line) with | 193 | // UploadUserCA registers caLine (a BYO user-CA authorized_keys line) with a |
| 194 | // tenant, labeled with c.UserCALabel, so the tenant's VMs trust certs that CA | 194 | // tenant, labeled with c.UserCALabel, so the tenant's VMs trust certs that CA |
| 195 | // signs. Idempotent server-side. | 195 | // signs. An empty tenant targets the tenant-less endpoint, which registers on |
| 196 | // the CALLER'S OWN tenant (the credential names it); a non-empty tenant pins | ||
| 197 | // one explicitly. Idempotent server-side. | ||
| 196 | func (c *Client) UploadUserCA(ctx context.Context, tenant, caLine string) error { | 198 | func (c *Client) UploadUserCA(ctx context.Context, tenant, caLine string) error { |
| 197 | req := types.UserCARequest{PublicKey: caLine, Label: c.UserCALabel} | 199 | req := types.UserCARequest{PublicKey: caLine, Label: c.UserCALabel} |
| 198 | return c.do(ctx, http.MethodPost, "/api/v1/tenants/"+url.PathEscape(tenant)+"/user-cas", req, nil) | 200 | path := "/api/v1/user-cas" |
| 201 | if tenant != "" { | ||
| 202 | path = "/api/v1/tenants/" + url.PathEscape(tenant) + "/user-cas" | ||
| 203 | } | ||
| 204 | return c.do(ctx, http.MethodPost, path, req, nil) | ||
| 199 | } | 205 | } |
internal/server/api/client/client_test.go
| Old | New | ||
|---|---|---|---|
| @@ -394,6 +394,21 @@ func TestUploadUserCA(t *testing.T) { | |||
| 394 | } | 394 | } |
| 395 | } | 395 | } |
| 396 | 396 | ||
| 397 | // An empty tenant targets the tenant-less endpoint, which registers on the | ||
| 398 | // caller's own tenant (the credential names it). | ||
| 399 | func TestUploadUserCAEmptyTenantHitsOwnEndpoint(t *testing.T) { | ||
| 400 | var cap capture | ||
| 401 | srv := serve(t, &cap, http.StatusCreated, `{"fingerprint":"SHA256:abc"}`) | ||
| 402 | c := &client.Client{BaseURL: srv.URL, Token: "tok"} | ||
| 403 | |||
| 404 | if err := c.UploadUserCA(context.Background(), "", testCALine); err != nil { | ||
| 405 | t.Fatalf("UploadUserCA: %v", err) | ||
| 406 | } | ||
| 407 | if cap.method != http.MethodPost || cap.path != "/api/v1/user-cas" { | ||
| 408 | t.Errorf("request = %s %s, want POST /api/v1/user-cas", cap.method, cap.path) | ||
| 409 | } | ||
| 410 | } | ||
| 411 | |||
| 397 | func TestUploadUserCAEscapesTenant(t *testing.T) { | 412 | func TestUploadUserCAEscapesTenant(t *testing.T) { |
| 398 | var cap capture | 413 | var cap capture |
| 399 | srv := serve(t, &cap, http.StatusCreated, `{}`) | 414 | srv := serve(t, &cap, http.StatusCreated, `{}`) |
internal/server/api/routes.go
| Old | New | ||
|---|---|---|---|
| @@ -266,6 +266,29 @@ var routeTable = []Route{ | |||
| 266 | Doc: "Remove a registered SSH user CA by its public_key line.", | 266 | Doc: "Remove a registered SSH user CA by its public_key line.", |
| 267 | handler: (*API).handleDeleteUserCA, | 267 | handler: (*API).handleDeleteUserCA, |
| 268 | }, | 268 | }, |
| 269 | // Tenant-less siblings that operate on the CALLER'S OWN tenant: the PAT or | ||
| 270 | // session already names the tenant, so no {tenant} rides in the path. | ||
| 271 | { | ||
| 272 | Method: "POST", | ||
| 273 | Path: "/api/v1/user-cas", | ||
| 274 | Auth: AuthUser, | ||
| 275 | Kind: KindJSON, | ||
| 276 | Request: (*types.UserCARequest)(nil), | ||
| 277 | Response: (*types.UserCAUploadResponse)(nil), | ||
| 278 | Success: http.StatusCreated, | ||
| 279 | Doc: "Register a BYO SSH user CA public key for the caller's own tenant; eitri never holds a user signing key.", | ||
| 280 | handler: (*API).handleUploadUserCA, | ||
| 281 | }, | ||
| 282 | { | ||
| 283 | Method: "GET", | ||
| 284 | Path: "/api/v1/user-cas", | ||
| 285 | Auth: AuthUser, | ||
| 286 | Kind: KindJSON, | ||
| 287 | Response: []types.UserCA(nil), | ||
| 288 | Success: http.StatusOK, | ||
| 289 | Doc: "List the caller's own tenant's registered SSH user CAs (pubkey, label, fingerprint).", | ||
| 290 | handler: (*API).handleListUserCAs, | ||
| 291 | }, | ||
| 269 | // Revoke a minted user cert (by serial or cert line) and list revocations — | 292 | // Revoke a minted user cert (by serial or cert line) and list revocations — |
| 270 | // enforced at the gate before a cert's short TTL expires. Pure store ops, | 293 | // enforced at the gate before a cert's short TTL expires. Pure store ops, |
| 271 | // available regardless of whether the minter is wired. | 294 | // available regardless of whether the minter is wired. |
internal/server/api/routes_test.go
| Old | New | ||
|---|---|---|---|
| @@ -34,7 +34,7 @@ func exemplarElem(t *testing.T, route Route, role string, v any) reflect.Type { | |||
| 34 | // `required` array for request schemas, so a type serving both roles would | 34 | // `required` array for request schemas, so a type serving both roles would |
| 35 | // get the wrong treatment on one of them). | 35 | // get the wrong treatment on one of them). |
| 36 | func TestRouteTable(t *testing.T) { | 36 | func TestRouteTable(t *testing.T) { |
| 37 | const wantRoutes = 26 | 37 | const wantRoutes = 28 |
| 38 | if len(routeTable) != wantRoutes { | 38 | if len(routeTable) != wantRoutes { |
| 39 | t.Fatalf("route table has %d entries, want %d — new endpoint? update this pin and cmd/eitri-apispec coverage together", len(routeTable), wantRoutes) | 39 | t.Fatalf("route table has %d entries, want %d — new endpoint? update this pin and cmd/eitri-apispec coverage together", len(routeTable), wantRoutes) |
| 40 | } | 40 | } |
internal/server/api/usercas.go
| Old | New | ||
|---|---|---|---|
| @@ -8,13 +8,32 @@ import ( | |||
| 8 | "golang.org/x/crypto/ssh" | 8 | "golang.org/x/crypto/ssh" |
| 9 | ) | 9 | ) |
| 10 | 10 | ||
| 11 | // handleUploadUserCA registers a BYO user-CA public key for the {tenant} in the | 11 | // userCATenant picks the tenant a user-CA request operates on and authorizes |
| 12 | // path. eitri stores only the pubkey (canonical line) — it never holds a user | 12 | // the caller against it. The {tenant} path segment names it on the explicit |
| 13 | // signing key. Tenant-scoped: the caller must act for {tenant}. | 13 | // /tenants/{tenant} routes; on the tenant-less sibling routes there is no |
| 14 | func (a *API) handleUploadUserCA(w http.ResponseWriter, r *http.Request) { | 14 | // segment and the caller's own credential names it. Either way mayActAs is the |
| 15 | // gate — an empty tenant (a principal with no tenant on a tenant-less route) is | ||
| 16 | // a 403. On refusal it writes the response and returns ok=false. | ||
| 17 | func (a *API) userCATenant(w http.ResponseWriter, r *http.Request) (string, bool) { | ||
| 18 | p := principalFromContext(r) | ||
| 15 | tenant := r.PathValue("tenant") | 19 | tenant := r.PathValue("tenant") |
| 16 | if !mayActAs(principalFromContext(r), tenant) { | 20 | if tenant == "" { |
| 21 | tenant = p.Tenant | ||
| 22 | } | ||
| 23 | if !mayActAs(p, tenant) { | ||
| 17 | http.Error(w, "forbidden", http.StatusForbidden) | 24 | http.Error(w, "forbidden", http.StatusForbidden) |
| 25 | return "", false | ||
| 26 | } | ||
| 27 | return tenant, true | ||
| 28 | } | ||
| 29 | |||
| 30 | // handleUploadUserCA registers a BYO user-CA public key for a tenant: the | ||
| 31 | // {tenant} path segment on the explicit route, or the caller's own tenant on | ||
| 32 | // the tenant-less sibling. eitri stores only the pubkey (canonical line) — it | ||
| 33 | // never holds a user signing key. The caller must act for that tenant. | ||
| 34 | func (a *API) handleUploadUserCA(w http.ResponseWriter, r *http.Request) { | ||
| 35 | tenant, ok := a.userCATenant(w, r) | ||
| 36 | if !ok { | ||
| 18 | return | 37 | return |
| 19 | } | 38 | } |
| 20 | var req types.UserCARequest | 39 | var req types.UserCARequest |
| @@ -35,11 +54,12 @@ func (a *API) handleUploadUserCA(w http.ResponseWriter, r *http.Request) { | |||
| 35 | writeJSON(w, http.StatusCreated, types.UserCAUploadResponse{Fingerprint: ssh.FingerprintSHA256(pub)}) | 54 | writeJSON(w, http.StatusCreated, types.UserCAUploadResponse{Fingerprint: ssh.FingerprintSHA256(pub)}) |
| 36 | } | 55 | } |
| 37 | 56 | ||
| 38 | // handleListUserCAs lists a tenant's registered user CAs (pubkey + label + fp). | 57 | // handleListUserCAs lists a tenant's registered user CAs (pubkey + label + fp) |
| 58 | // — the {tenant} path segment on the explicit route, or the caller's own tenant | ||
| 59 | // on the tenant-less sibling. | ||
| 39 | func (a *API) handleListUserCAs(w http.ResponseWriter, r *http.Request) { | 60 | func (a *API) handleListUserCAs(w http.ResponseWriter, r *http.Request) { |
| 40 | tenant := r.PathValue("tenant") | 61 | tenant, ok := a.userCATenant(w, r) |
| 41 | if !mayActAs(principalFromContext(r), tenant) { | 62 | if !ok { |
| 42 | http.Error(w, "forbidden", http.StatusForbidden) | ||
| 43 | return | 63 | return |
| 44 | } | 64 | } |
| 45 | cas, err := a.st.ListTenantUserCAs(tenant) | 65 | cas, err := a.st.ListTenantUserCAs(tenant) |
| @@ -61,9 +81,8 @@ func (a *API) handleListUserCAs(w http.ResponseWriter, r *http.Request) { | |||
| 61 | 81 | ||
| 62 | // handleDeleteUserCA removes a registered CA by its public_key line. | 82 | // handleDeleteUserCA removes a registered CA by its public_key line. |
| 63 | func (a *API) handleDeleteUserCA(w http.ResponseWriter, r *http.Request) { | 83 | func (a *API) handleDeleteUserCA(w http.ResponseWriter, r *http.Request) { |
| 64 | tenant := r.PathValue("tenant") | 84 | tenant, ok := a.userCATenant(w, r) |
| 65 | if !mayActAs(principalFromContext(r), tenant) { | 85 | if !ok { |
| 66 | http.Error(w, "forbidden", http.StatusForbidden) | ||
| 67 | return | 86 | return |
| 68 | } | 87 | } |
| 69 | var req types.UserCARequest | 88 | var req types.UserCARequest |
internal/server/api/usercas_test.go
| Old | New | ||
|---|---|---|---|
| @@ -5,6 +5,7 @@ import ( | |||
| 5 | "net/http" | 5 | "net/http" |
| 6 | "testing" | 6 | "testing" |
| 7 | 7 | ||
| 8 | "github.com/a73x/eitri/internal/server/api/types" | ||
| 8 | "github.com/a73x/eitri/internal/server/sshca" | 9 | "github.com/a73x/eitri/internal/server/sshca" |
| 9 | "github.com/stretchr/testify/assert" | 10 | "github.com/stretchr/testify/assert" |
| 10 | "github.com/stretchr/testify/require" | 11 | "github.com/stretchr/testify/require" |
| @@ -35,6 +36,53 @@ func TestUploadUserCAStoresCanonicalLine(t *testing.T) { | |||
| 35 | assert.Equal(t, testTenant, tenant) | 36 | assert.Equal(t, testTenant, tenant) |
| 36 | } | 37 | } |
| 37 | 38 | ||
| 39 | // TestUploadUserCATenantlessLandsOnCallerTenant posts to the tenant-less | ||
| 40 | // /api/v1/user-cas and asserts the CA registers under the CALLER's own tenant | ||
| 41 | // (the credential names it), while a second tenant neither lands there nor sees | ||
| 42 | // it through its own tenant-less list. | ||
| 43 | func TestUploadUserCATenantlessLandsOnCallerTenant(t *testing.T) { | ||
| 44 | w := newTwoTenant(t) | ||
| 45 | |||
| 46 | _, signer, err := sshca.GenerateHostKey() | ||
| 47 | require.NoError(t, err) | ||
| 48 | line := sshca.AuthorizedKeyLine(signer.PublicKey()) | ||
| 49 | |||
| 50 | // default's PAT uploads via the tenant-less endpoint. | ||
| 51 | resp := do(t, "POST", w.ts.URL+"/api/v1/user-cas", testPAT, | ||
| 52 | map[string]any{"public_key": line, "label": "mine"}) | ||
| 53 | require.Equal(t, http.StatusCreated, resp.StatusCode) | ||
| 54 | |||
| 55 | // It resolves back to default — the caller's own tenant, not beta. | ||
| 56 | tenant, ok, err := w.st.TenantForUserCA(line) | ||
| 57 | require.NoError(t, err) | ||
| 58 | require.True(t, ok) | ||
| 59 | assert.Equal(t, testTenant, tenant) | ||
| 60 | |||
| 61 | // default's own tenant-less list includes it. | ||
| 62 | require.True(t, tenantlessListHas(t, w.ts.URL, testPAT, line), | ||
| 63 | "default's tenant-less list must include its own CA") | ||
| 64 | |||
| 65 | // beta's tenant-less list (its own tenant) must not. | ||
| 66 | require.False(t, tenantlessListHas(t, w.ts.URL, w.betaPAT, line), | ||
| 67 | "beta must not see default's CA through its own tenant-less list") | ||
| 68 | } | ||
| 69 | |||
| 70 | // tenantlessListHas reports whether GET /api/v1/user-cas as pat returns a CA | ||
| 71 | // whose pubkey line equals want. | ||
| 72 | func tenantlessListHas(t *testing.T, baseURL, pat, want string) bool { | ||
| 73 | t.Helper() | ||
| 74 | resp := do(t, "GET", baseURL+"/api/v1/user-cas", pat, nil) | ||
| 75 | require.Equal(t, http.StatusOK, resp.StatusCode) | ||
| 76 | var cas []types.UserCA | ||
| 77 | require.NoError(t, json.NewDecoder(resp.Body).Decode(&cas)) | ||
| 78 | for _, c := range cas { | ||
| 79 | if c.PubKey == want { | ||
| 80 | return true | ||
| 81 | } | ||
| 82 | } | ||
| 83 | return false | ||
| 84 | } | ||
| 85 | |||
| 38 | func TestUploadUserCAGarbageKeyIs400(t *testing.T) { | 86 | func TestUploadUserCAGarbageKeyIs400(t *testing.T) { |
| 39 | ts, _, _, _, _ := newServer(t) | 87 | ts, _, _, _, _ := newServer(t) |
| 40 | resp := do(t, "POST", ts.URL+"/api/v1/tenants/"+testTenant+"/user-cas", testPAT, | 88 | resp := do(t, "POST", ts.URL+"/api/v1/tenants/"+testTenant+"/user-cas", testPAT, |
internal/server/sshgate/gate.go
| Old | New | ||
|---|---|---|---|
| @@ -222,13 +222,25 @@ func (g *Gate) handleDirectTCPIP(newChan ssh.NewChannel, tenant string) { | |||
| 222 | _ = newChan.Reject(ssh.Prohibited, "only port 22 is permitted") | 222 | _ = newChan.Reject(ssh.Prohibited, "only port 22 is permitted") |
| 223 | return | 223 | return |
| 224 | } | 224 | } |
| 225 | // Connect names are <tenant>.<name>. VM names are RFC1123 labels (no | 225 | // Connect names take two forms, both resolving within the connection's |
| 226 | // dots) and tenant ids are dot-free, so the FIRST dot splits | 226 | // own tenant (the cert's signing CA already names the tenant): |
| 227 | // unambiguously. The prefix must match the connection's own tenant — | 227 | // <name> — a bare VM name in the connection's tenant. |
| 228 | // naming another tenant is rejected identically to a nonexistent VM, so | 228 | // <tenant>.<name> — the explicit form; the prefix MUST equal the |
| 229 | // tenancy structure is not probeable from the gate. | 229 | // connection's tenant. Naming another tenant is |
| 230 | prefix, bare, found := strings.Cut(p.HostToConnect, ".") | 230 | // rejected identically to a nonexistent VM, so |
| 231 | if !found || prefix != tenant || bare == "" { | 231 | // tenancy structure is not probeable from the gate. |
| 232 | // VM names are RFC1123 labels (no dots) and tenant ids are dot-free, so | ||
| 233 | // the FIRST dot splits the explicit form unambiguously; a name with no dot | ||
| 234 | // is unambiguously bare. | ||
| 235 | bare := p.HostToConnect | ||
| 236 | if prefix, rest, found := strings.Cut(p.HostToConnect, "."); found { | ||
| 237 | if prefix != tenant || rest == "" { | ||
| 238 | _ = newChan.Reject(ssh.ConnectionFailed, "unknown VM") | ||
| 239 | return | ||
| 240 | } | ||
| 241 | bare = rest | ||
| 242 | } | ||
| 243 | if bare == "" { | ||
| 232 | _ = newChan.Reject(ssh.ConnectionFailed, "unknown VM") | 244 | _ = newChan.Reject(ssh.ConnectionFailed, "unknown VM") |
| 233 | return | 245 | return |
| 234 | } | 246 | } |
internal/server/sshgate/gate_test.go
| Old | New | ||
|---|---|---|---|
| @@ -369,11 +369,67 @@ func TestGateAuthzDenyRejected(t *testing.T) { | |||
| 369 | require.Error(t, err, "authz denial must reject the channel") | 369 | require.Error(t, err, "authz denial must reject the channel") |
| 370 | } | 370 | } |
| 371 | 371 | ||
| 372 | // TestGateRejectsBareAndForeignTenantNames: the dialed name must be | 372 | // TestGateBareNameResolvesInCertTenant: a connect name with NO tenant prefix is |
| 373 | // <tenant>.<name> and the prefix must match the connection's tenant (derived | 373 | // the bare VM name within the connection's own tenant — the cert's signing CA |
| 374 | // from the signing CA). Bare (unqualified) names and foreign prefixes are | 374 | // already names the tenant — so it resolves and tunnels exactly like the |
| 375 | // rejected before resolution. | 375 | // explicit <tenant>.<vm> form. |
| 376 | func TestGateRejectsBareAndForeignTenantNames(t *testing.T) { | 376 | func TestGateBareNameResolvesInCertTenant(t *testing.T) { |
| 377 | ca := newSigner(t) | ||
| 378 | tg := startGate(t, ca.PublicKey(), true) | ||
| 379 | client := dialClient(t, tg, mintCertSigner(t, ca, newSigner(t))) | ||
| 380 | |||
| 381 | conn, err := client.Dial("tcp", "vm1:22") | ||
| 382 | require.NoError(t, err, "a bare VM name must resolve within the cert's tenant") | ||
| 383 | defer conn.Close() | ||
| 384 | |||
| 385 | // The dialer must have been reached with the VM resolved inside "default". | ||
| 386 | select { | ||
| 387 | case got := <-tg.dialCalls: | ||
| 388 | assert.Equal(t, [3]string{"host-1", "vm-1", "22"}, got) | ||
| 389 | case <-time.After(2 * time.Second): | ||
| 390 | t.Fatal("dialer was never called") | ||
| 391 | } | ||
| 392 | } | ||
| 393 | |||
| 394 | // TestGateBareNameForeignTenantRejected: resolution is tenant-scoped, so a bare | ||
| 395 | // name that belongs to ANOTHER tenant does not resolve within the connection's | ||
| 396 | // tenant and is rejected as an unknown VM. | ||
| 397 | func TestGateBareNameForeignTenantRejected(t *testing.T) { | ||
| 398 | ca := newSigner(t) | ||
| 399 | // The only known VM ("victim") lives in tenant "other", while the cert (and | ||
| 400 | // thus the connection) is tenant "default": a tenant-scoped resolver never | ||
| 401 | // hands it back for "default". | ||
| 402 | tg := &testGate{hostKey: newSigner(t), dialCalls: make(chan [3]string, 4), authorized: true} | ||
| 403 | resolve := func(tenant, name string) (string, string, bool) { | ||
| 404 | if tenant == "other" && name == "victim" { | ||
| 405 | return "host-x", "vm-x", true | ||
| 406 | } | ||
| 407 | return "", "", false | ||
| 408 | } | ||
| 409 | authorize := func(string, string) bool { return true } | ||
| 410 | dial := func(_ context.Context, hostID, vmID string, port uint32) (io.ReadWriteCloser, error) { | ||
| 411 | tg.dialCalls <- [3]string{hostID, vmID, "22"} | ||
| 412 | a, b := net.Pipe() | ||
| 413 | go func() { _, _ = io.Copy(b, b); b.Close() }() | ||
| 414 | return a, nil | ||
| 415 | } | ||
| 416 | g := New(tg.hostKey, singleCALookup(ca.PublicKey(), "default"), resolve, authorize, dial, nil) | ||
| 417 | l, err := net.Listen("tcp", "127.0.0.1:0") | ||
| 418 | require.NoError(t, err) | ||
| 419 | tg.addr = l.Addr().String() | ||
| 420 | go func() { _ = g.Serve(l) }() | ||
| 421 | t.Cleanup(func() { _ = l.Close() }) | ||
| 422 | |||
| 423 | client := dialClient(t, tg, mintCertSigner(t, ca, newSigner(t))) | ||
| 424 | _, err = client.Dial("tcp", "victim:22") | ||
| 425 | require.Error(t, err, "a bare name for another tenant's VM must be rejected as unknown") | ||
| 426 | } | ||
| 427 | |||
| 428 | // TestGateForeignTenantNameIndistinguishable: an explicit <tenant>.<vm> whose | ||
| 429 | // prefix is a FOREIGN tenant is rejected with the SAME message as a nonexistent | ||
| 430 | // VM in the connection's own tenant, so tenancy structure is not probeable from | ||
| 431 | // the gate. | ||
| 432 | func TestGateForeignTenantNameIndistinguishable(t *testing.T) { | ||
| 377 | ca := newSigner(t) | 433 | ca := newSigner(t) |
| 378 | tg := startGate(t, ca.PublicKey(), true) | 434 | tg := startGate(t, ca.PublicKey(), true) |
| 379 | client := dialClient(t, tg, mintCertSigner(t, ca, newSigner(t))) | 435 | client := dialClient(t, tg, mintCertSigner(t, ca, newSigner(t))) |
| @@ -388,14 +444,9 @@ func TestGateRejectsBareAndForeignTenantNames(t *testing.T) { | |||
| 388 | return oce.Message | 444 | return oce.Message |
| 389 | } | 445 | } |
| 390 | 446 | ||
| 391 | // A bare name (no tenant prefix) is rejected — the gate's tenant is | ||
| 392 | // "default" (the signing CA's tenant) and names must be <tenant>.<name>. | ||
| 393 | _ = rejectMsg("vm1") | ||
| 394 | |||
| 395 | // A foreign tenant prefix must be rejected with the SAME message as a | 447 | // A foreign tenant prefix must be rejected with the SAME message as a |
| 396 | // nonexistent VM in our own tenant: the two are indistinguishable, so | 448 | // nonexistent VM in our own tenant: the two are indistinguishable. Test-lock |
| 397 | // tenancy structure is not probeable from the gate. Test-lock the equal | 449 | // the equal messages so a future refactor cannot split them silently. |
| 398 | // messages so a future refactor cannot split them silently. | ||
| 399 | assert.Equal(t, rejectMsg("default.nope"), rejectMsg("other.vm1"), | 450 | assert.Equal(t, rejectMsg("default.nope"), rejectMsg("other.vm1"), |
| 400 | "a foreign tenant prefix must be indistinguishable from a nonexistent VM") | 451 | "a foreign tenant prefix must be indistinguishable from a nonexistent VM") |
| 401 | 452 | ||
web/src/lib/api-types.ts
| Old | New | ||
|---|---|---|---|
| @@ -842,6 +842,84 @@ export interface paths { | |||
| 842 | patch?: never; | 842 | patch?: never; |
| 843 | trace?: never; | 843 | trace?: never; |
| 844 | }; | 844 | }; |
| 845 | "/api/v1/user-cas": { | ||
| 846 | parameters: { | ||
| 847 | query?: never; | ||
| 848 | header?: never; | ||
| 849 | path?: never; | ||
| 850 | cookie?: never; | ||
| 851 | }; | ||
| 852 | /** List the caller's own tenant's registered SSH user CAs (pubkey, label, fingerprint). */ | ||
| 853 | get: { | ||
| 854 | parameters: { | ||
| 855 | query?: never; | ||
| 856 | header?: never; | ||
| 857 | path?: never; | ||
| 858 | cookie?: never; | ||
| 859 | }; | ||
| 860 | requestBody?: never; | ||
| 861 | responses: { | ||
| 862 | /** @description success */ | ||
| 863 | 200: { | ||
| 864 | headers: { | ||
| 865 | [name: string]: unknown; | ||
| 866 | }; | ||
| 867 | content: { | ||
| 868 | "application/json": components["schemas"]["UserCA"][]; | ||
| 869 | }; | ||
| 870 | }; | ||
| 871 | /** @description error (plain text) */ | ||
| 872 | default: { | ||
| 873 | headers: { | ||
| 874 | [name: string]: unknown; | ||
| 875 | }; | ||
| 876 | content: { | ||
| 877 | "text/plain": string; | ||
| 878 | }; | ||
| 879 | }; | ||
| 880 | }; | ||
| 881 | }; | ||
| 882 | put?: never; | ||
| 883 | /** Register a BYO SSH user CA public key for the caller's own tenant; eitri never holds a user signing key. */ | ||
| 884 | post: { | ||
| 885 | parameters: { | ||
| 886 | query?: never; | ||
| 887 | header?: never; | ||
| 888 | path?: never; | ||
| 889 | cookie?: never; | ||
| 890 | }; | ||
| 891 | requestBody: { | ||
| 892 | content: { | ||
| 893 | "application/json": components["schemas"]["UserCARequest"]; | ||
| 894 | }; | ||
| 895 | }; | ||
| 896 | responses: { | ||
| 897 | /** @description success */ | ||
| 898 | 201: { | ||
| 899 | headers: { | ||
| 900 | [name: string]: unknown; | ||
| 901 | }; | ||
| 902 | content: { | ||
| 903 | "application/json": components["schemas"]["UserCAUploadResponse"]; | ||
| 904 | }; | ||
| 905 | }; | ||
| 906 | /** @description error (plain text) */ | ||
| 907 | default: { | ||
| 908 | headers: { | ||
| 909 | [name: string]: unknown; | ||
| 910 | }; | ||
| 911 | content: { | ||
| 912 | "text/plain": string; | ||
| 913 | }; | ||
| 914 | }; | ||
| 915 | }; | ||
| 916 | }; | ||
| 917 | delete?: never; | ||
| 918 | options?: never; | ||
| 919 | head?: never; | ||
| 920 | patch?: never; | ||
| 921 | trace?: never; | ||
| 922 | }; | ||
| 845 | "/api/v1/vms": { | 923 | "/api/v1/vms": { |
| 846 | parameters: { | 924 | parameters: { |
| 847 | query?: never; | 925 | query?: never; |