7f51aba3
feat(mcp): self-sufficient against the hosted service
a73x 2026-07-29 19:16
Commit message
docs/mcp.md
| Old | New | ||
|---|---|---|---|
| @@ -53,6 +53,14 @@ prompts gate every call regardless. | |||
| 53 | gate's host certificate principal (the server's `ssh_gate_domain`, | 53 | gate's host certificate principal (the server's `ssh_gate_domain`, |
| 54 | which defaults to the `ssh_listen` host). | 54 | which defaults to the `ssh_listen` host). |
| 55 | - `vm_user` — guest SSH user; defaults to `ubuntu` if omitted. | 55 | - `vm_user` — guest SSH user; defaults to `ubuntu` if omitted. |
| 56 | - `ca_key_path` — optional path to this client's persistent user CA | ||
| 57 | (load-or-create); defaults to a `user_ca` file next to the config. Its | ||
| 58 | public key self-registers with the tenant on first gate use, so a fresh | ||
| 59 | install needs only a PAT — no manual `eitri ca upload`. | ||
| 60 | - `tenant` — optional. The credential names the tenant (VM connect names are | ||
| 61 | derived from it), so leave this unset. Set it only when the PAT's user | ||
| 62 | CA/tenant mapping is ambiguous, e.g. a human or CA belonging to more than | ||
| 63 | one tenant. | ||
| 56 | 64 | ||
| 57 | The config path can be overridden with `--config` or `$EITRI_MCP_CONFIG`; | 65 | The config path can be overridden with `--config` or `$EITRI_MCP_CONFIG`; |
| 58 | it defaults to `~/.config/eitri-mcp/config.json`. | 66 | it defaults to `~/.config/eitri-mcp/config.json`. |
| @@ -66,23 +74,24 @@ prompts gate every call regardless. | |||
| 66 | ## Access model | 74 | ## Access model |
| 67 | 75 | ||
| 68 | eitri-mcp reaches VMs by name through eitri's SSH-CA jump gate — there is no | 76 | eitri-mcp reaches VMs by name through eitri's SSH-CA jump gate — there is no |
| 69 | injected key and no TOFU. On demand, it generates an ephemeral SSH keypair | 77 | injected key and no TOFU. It holds its own user CA (`ca_key_path`, |
| 70 | in memory (never written to disk) and mints a short-lived user certificate | 78 | load-or-create) and self-registers that CA's public key with its tenant on |
| 71 | for it (principal `ubuntu`, ~10-30 min TTL) by calling | 79 | first use — eitri never sees the private half. Per connection it signs a |
| 72 | `POST /api/v1/ssh-certs` with its personal access token; the cert is | 80 | short-lived user certificate locally (principal `ubuntu`) with that CA. It |
| 73 | auto-refreshed as it nears expiry. It also fetches the eitri SSH CA's public | 81 | also fetches the eitri host CA's public key once via `GET /api/v1/ssh-ca` |
| 74 | key once via `GET /api/v1/ssh-ca` and caches it. To reach a VM, it dials the | 82 | and caches it. To reach a VM, it derives its tenant from the credential |
| 75 | gate (the configured `gate` address), authenticates with the user | 83 | (`/me`, unless `tenant` pins one), dials the gate (the configured `gate` |
| 76 | certificate, and opens a tunnel to `<vm-name>:22` — VMs are addressed by | 84 | address), authenticates with the user certificate, and opens a tunnel to |
| 77 | name, not IP, so recycled IPs and host-key churn are not a concern. Host | 85 | `<tenant>.<vm>:22` — VMs are addressed by their namespaced connect name, |
| 78 | identity is verified on both hops using `ssh.CertChecker` against the eitri | 86 | not IP, so recycled IPs and host-key churn are not a concern. Host identity |
| 79 | CA: the gate's host certificate must carry its configured domain as | 87 | is verified on both hops using `ssh.CertChecker` against the eitri host CA: |
| 80 | principal, and each VM's host certificate must carry the VM's name. The | 88 | the gate's host certificate must carry its configured domain as principal, |
| 81 | guest trusts the CA-signed user certificate via `TrustedUserCAKeys` | 89 | and each VM's host certificate must carry the VM's connect name. The guest |
| 82 | (provisioned server-side when the gate is enabled), so no per-VM | 90 | trusts the tenant's registered user CAs via `TrustedUserCAKeys` |
| 83 | `authorized_key` injection is needed. The PAT is only ever used to mint | 91 | (provisioned through vendor-data), so no per-VM `authorized_key` injection |
| 84 | certificates — actual SSH traffic uses the certificate, and the token itself | 92 | is needed. The PAT authenticates API calls only — actual SSH traffic uses |
| 85 | is never surfaced in a tool result or error. | 93 | the certificate, and the token itself is never surfaced in a tool result or |
| 94 | error. | ||
| 86 | 95 | ||
| 87 | ## Semantics | 96 | ## Semantics |
| 88 | 97 | ||
internal/mcpserver/cli.go
| Old | New | ||
|---|---|---|---|
| @@ -1,7 +1,9 @@ | |||
| 1 | // cli.go is the eitri-mcp command line: it loads the config, self-signs with a | 1 | // cli.go is the eitri-mcp command line: it loads the config, loads (or creates) |
| 2 | // persistent per-client user CA, uploads that CA to the tenant, and serves the | 2 | // a persistent per-client user CA, and serves the MCP tools over stdio. The user |
| 3 | // MCP tools over stdio. It lives here rather than in cmd/eitri-mcp so it is | 3 | // CA self-registers with the caller's tenant on first gate use (see |
| 4 | // testable and coverage-gated (arch R14: main packages are wiring only). | 4 | // Runner.ensure), so a fresh install needs only a PAT. It lives here rather than |
| 5 | // in cmd/eitri-mcp so it is testable and coverage-gated (arch R14: main packages | ||
| 6 | // are wiring only). | ||
| 5 | 7 | ||
| 6 | package mcpserver | 8 | package mcpserver |
| 7 | 9 | ||
| @@ -16,7 +18,6 @@ import ( | |||
| 16 | "os/signal" | 18 | "os/signal" |
| 17 | "syscall" | 19 | "syscall" |
| 18 | 20 | ||
| 19 | "github.com/a73x/eitri/internal/gateclient" | ||
| 20 | "github.com/a73x/eitri/internal/server/api/client" | 21 | "github.com/a73x/eitri/internal/server/api/client" |
| 21 | "github.com/modelcontextprotocol/go-sdk/mcp" | 22 | "github.com/modelcontextprotocol/go-sdk/mcp" |
| 22 | "golang.org/x/crypto/ssh" | 23 | "golang.org/x/crypto/ssh" |
| @@ -52,16 +53,18 @@ func run(cfgPath string) error { | |||
| 52 | // The Runner reaches VMs by name through the eitri SSH-CA jump gate: it | 53 | // The Runner reaches VMs by name through the eitri SSH-CA jump gate: it |
| 53 | // authenticates with short-lived user certs it self-signs on demand with its | 54 | // authenticates with short-lived user certs it self-signs on demand with its |
| 54 | // own user CA, and verifies both hops' host certs against the eitri host CA. | 55 | // own user CA, and verifies both hops' host certs against the eitri host CA. |
| 55 | // The user CA's public key is uploaded to the tenant once (Register, below) | 56 | // On first gate use it derives the caller's tenant (an empty cfg.Tenant) and |
| 56 | // so VMs trust those certs. GateAuth is backed by the same API client. | 57 | // registers this user CA's public key so VMs trust those certs — all backed by |
| 58 | // the same API client. | ||
| 57 | api := &client.Client{BaseURL: cfg.ServerURL, Token: cfg.Token} | 59 | api := &client.Client{BaseURL: cfg.ServerURL, Token: cfg.Token} |
| 58 | gateAuth := gateclient.NewGateAuth(api, userCA, cfg.Tenant, nil) | ||
| 59 | tools := &Tools{ | 60 | tools := &Tools{ |
| 60 | API: API{Client: api}, | 61 | API: API{Client: api}, |
| 61 | Runner: NewRunner(RunnerConfig{ | 62 | Runner: NewRunner(RunnerConfig{ |
| 62 | Gate: cfg.Gate, | 63 | Gate: cfg.Gate, |
| 63 | Auth: gateAuth, | ||
| 64 | VMUser: cfg.VMUser, | 64 | VMUser: cfg.VMUser, |
| 65 | API: api, | ||
| 66 | UserCA: userCA, | ||
| 67 | Tenant: cfg.Tenant, | ||
| 65 | }), | 68 | }), |
| 66 | Gate: cfg.Gate, | 69 | Gate: cfg.Gate, |
| 67 | VMUser: cfg.VMUser, | 70 | VMUser: cfg.VMUser, |
| @@ -79,11 +82,9 @@ func run(cfgPath string) error { | |||
| 79 | ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) | 82 | ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) |
| 80 | defer stop() | 83 | defer stop() |
| 81 | 84 | ||
| 82 | // Upload our user CA to the tenant before serving, so vm_create's precondition | 85 | // The user CA registers with the caller's tenant lazily, on the first gate |
| 83 | // (a registered user CA) is satisfied and VMs trust the certs we sign. | 86 | // connection (Runner.ensure), so a control-plane blip at startup can't stop the |
| 84 | if err := gateAuth.Register(ctx); err != nil { | 87 | // server from coming up and serving the non-SSH tools. |
| 85 | return fmt.Errorf("register mcp user CA: %w", err) | ||
| 86 | } | ||
| 87 | return server.Run(ctx, &mcp.StdioTransport{}) | 88 | return server.Run(ctx, &mcp.StdioTransport{}) |
| 88 | } | 89 | } |
| 89 | 90 | ||
internal/mcpserver/config.go
| Old | New | ||
|---|---|---|---|
| @@ -20,7 +20,7 @@ type Config struct { | |||
| 20 | Gate string `json:"gate"` // SSH-CA jump gate address "<gate-domain>:<port>" | 20 | Gate string `json:"gate"` // SSH-CA jump gate address "<gate-domain>:<port>" |
| 21 | VMUser string `json:"vm_user"` // guest user (default "ubuntu") | 21 | VMUser string `json:"vm_user"` // guest user (default "ubuntu") |
| 22 | CAKeyPath string `json:"ca_key_path"` // this client's own user CA (load-or-create; default next to config) | 22 | CAKeyPath string `json:"ca_key_path"` // this client's own user CA (load-or-create; default next to config) |
| 23 | Tenant string `json:"tenant"` // this client's tenant (default "default") | 23 | Tenant string `json:"tenant"` // optional; the credential names the tenant. Set only when the PAT's CA/tenant mapping is ambiguous (a human or user CA in more than one tenant). |
| 24 | 24 | ||
| 25 | Token string `json:"-"` // loaded from TokenFile; never serialized | 25 | Token string `json:"-"` // loaded from TokenFile; never serialized |
| 26 | } | 26 | } |
| @@ -47,9 +47,8 @@ func LoadConfig(path string) (*Config, error) { | |||
| 47 | if cfg.VMUser == "" { | 47 | if cfg.VMUser == "" { |
| 48 | cfg.VMUser = "ubuntu" | 48 | cfg.VMUser = "ubuntu" |
| 49 | } | 49 | } |
| 50 | if cfg.Tenant == "" { | 50 | // An empty tenant is VALID: the credential names the tenant (Me() derives it |
| 51 | cfg.Tenant = "default" | 51 | // for connect names, and the tenant-less CA routes register on it). No default. |
| 52 | } | ||
| 53 | if cfg.CAKeyPath == "" { | 52 | if cfg.CAKeyPath == "" { |
| 54 | cfg.CAKeyPath = filepath.Join(filepath.Dir(path), "user_ca") | 53 | cfg.CAKeyPath = filepath.Join(filepath.Dir(path), "user_ca") |
| 55 | } else { | 54 | } else { |
internal/mcpserver/config_test.go
| Old | New | ||
|---|---|---|---|
| @@ -28,6 +28,32 @@ func TestLoadConfigDefaultsAndExpansion(t *testing.T) { | |||
| 28 | assert.Equal(t, "sekret", cfg.Token) // trimmed, loaded from file | 28 | assert.Equal(t, "sekret", cfg.Token) // trimmed, loaded from file |
| 29 | } | 29 | } |
| 30 | 30 | ||
| 31 | func TestLoadConfigTenantOptionalNoDefault(t *testing.T) { | ||
| 32 | dir := t.TempDir() | ||
| 33 | tok := filepath.Join(dir, "token") | ||
| 34 | require.NoError(t, os.WriteFile(tok, []byte("sekret\n"), 0o600)) | ||
| 35 | cfgPath := filepath.Join(dir, "config.json") | ||
| 36 | |||
| 37 | // Omitted tenant stays empty — the credential names it (no "default" fallback). | ||
| 38 | require.NoError(t, os.WriteFile(cfgPath, []byte(`{ | ||
| 39 | "server_url": "http://127.0.0.1:9999", | ||
| 40 | "token_file": "`+tok+`" | ||
| 41 | }`), 0o600)) | ||
| 42 | cfg, err := LoadConfig(cfgPath) | ||
| 43 | require.NoError(t, err) | ||
| 44 | assert.Empty(t, cfg.Tenant, "an omitted tenant must NOT default to a seeded tenant") | ||
| 45 | |||
| 46 | // An explicit tenant is preserved for the ambiguous multi-tenant corner. | ||
| 47 | require.NoError(t, os.WriteFile(cfgPath, []byte(`{ | ||
| 48 | "server_url": "http://127.0.0.1:9999", | ||
| 49 | "token_file": "`+tok+`", | ||
| 50 | "tenant": "team" | ||
| 51 | }`), 0o600)) | ||
| 52 | cfg, err = LoadConfig(cfgPath) | ||
| 53 | require.NoError(t, err) | ||
| 54 | assert.Equal(t, "team", cfg.Tenant) | ||
| 55 | } | ||
| 56 | |||
| 31 | func TestLoadConfigExplicitEmptyVMUserGetsDefault(t *testing.T) { | 57 | func TestLoadConfigExplicitEmptyVMUserGetsDefault(t *testing.T) { |
| 32 | dir := t.TempDir() | 58 | dir := t.TempDir() |
| 33 | tok := filepath.Join(dir, "token") | 59 | tok := filepath.Join(dir, "token") |
internal/mcpserver/sshrun.go
| Old | New | ||
|---|---|---|---|
| @@ -9,9 +9,12 @@ import ( | |||
| 9 | "io/fs" | 9 | "io/fs" |
| 10 | "os" | 10 | "os" |
| 11 | "path" | 11 | "path" |
| 12 | "strings" | ||
| 13 | "sync" | ||
| 12 | "time" | 14 | "time" |
| 13 | 15 | ||
| 14 | "github.com/a73x/eitri/internal/gateclient" | 16 | "github.com/a73x/eitri/internal/gateclient" |
| 17 | "github.com/a73x/eitri/internal/server/api/client" | ||
| 15 | "github.com/pkg/sftp" | 18 | "github.com/pkg/sftp" |
| 16 | "golang.org/x/crypto/ssh" | 19 | "golang.org/x/crypto/ssh" |
| 17 | ) | 20 | ) |
| @@ -19,11 +22,28 @@ import ( | |||
| 19 | // outputCap bounds captured exec/file bytes returned to the model. | 22 | // outputCap bounds captured exec/file bytes returned to the model. |
| 20 | const outputCap = 1 << 20 // 1 MiB | 23 | const outputCap = 1 << 20 // 1 MiB |
| 21 | 24 | ||
| 25 | // gateAPI is what the Runner needs from the control-plane client to prepare gate | ||
| 26 | // credentials before the first connection: derive the caller's tenant (Me), and | ||
| 27 | // verify/register this client's user CA (ListUserCAs + the embedded | ||
| 28 | // CertAuthority's UploadUserCA). One *client.Client value satisfies it. | ||
| 29 | type gateAPI interface { | ||
| 30 | gateclient.CertAuthority | ||
| 31 | Me() (client.Me, error) | ||
| 32 | ListUserCAs(ctx context.Context, tenant string) ([]client.UserCA, error) | ||
| 33 | } | ||
| 34 | |||
| 35 | var _ gateAPI = (*client.Client)(nil) | ||
| 36 | |||
| 22 | // RunnerConfig configures SSH access to VMs through the eitri SSH-CA jump gate. | 37 | // RunnerConfig configures SSH access to VMs through the eitri SSH-CA jump gate. |
| 38 | // Gate credentials are prepared lazily on first use from API + UserCA + Tenant | ||
| 39 | // (see Runner.ensure); the Gate/VMUser fields shape the dial itself. | ||
| 23 | type RunnerConfig struct { | 40 | type RunnerConfig struct { |
| 24 | Gate string // gate SSH address "<gate-domain>:<port>" (also the host-cert principal host) | 41 | Gate string // gate SSH address "<gate-domain>:<port>" (also the host-cert principal host) |
| 25 | Auth gateclient.Credentials // minted user-cert signer + CA host verifier | 42 | VMUser string // guest login user, e.g. "ubuntu" (matches the cert principal) |
| 26 | VMUser string // guest login user, e.g. "ubuntu" (matches the cert principal) | 43 | API gateAPI // control-plane client backing tenant derivation, CA registration, host-CA fetch |
| 44 | UserCA ssh.Signer // this client's persistent user CA; signs user certs locally | ||
| 45 | Tenant string // configured tenant; "" ⇒ derive from the credential via Me() | ||
| 46 | Now func() time.Time // test seam; nil ⇒ time.Now | ||
| 27 | } | 47 | } |
| 28 | 48 | ||
| 29 | // ExecResult is a completed remote command. | 49 | // ExecResult is a completed remote command. |
| @@ -42,16 +62,84 @@ type ExecResult struct { | |||
| 42 | // short-lived CA-signed user cert. | 62 | // short-lived CA-signed user cert. |
| 43 | type Runner struct { | 63 | type Runner struct { |
| 44 | cfg RunnerConfig | 64 | cfg RunnerConfig |
| 65 | |||
| 66 | mu sync.Mutex | ||
| 67 | auth gateclient.Credentials // gate credentials, built once ensure() succeeds; tests may inject | ||
| 45 | } | 68 | } |
| 46 | 69 | ||
| 47 | func NewRunner(cfg RunnerConfig) *Runner { return &Runner{cfg: cfg} } | 70 | func NewRunner(cfg RunnerConfig) *Runner { return &Runner{cfg: cfg} } |
| 48 | 71 | ||
| 72 | // ensure prepares gate credentials on first use and caches them in r.auth. It | ||
| 73 | // resolves the caller's tenant (the configured one, else derived from the | ||
| 74 | // credential via Me()) and makes sure this client's user CA is registered with | ||
| 75 | // that tenant so VMs trust the certs it signs. Idempotent across restarts — | ||
| 76 | // registration lists the tenant's user CAs and uploads only when its own | ||
| 77 | // fingerprint is absent. A failed ensure caches nothing, so the next call | ||
| 78 | // retries; its error names the manual fallback (`eitri ca upload`). Never logs or | ||
| 79 | // returns key material. | ||
| 80 | func (r *Runner) ensure(ctx context.Context) error { | ||
| 81 | r.mu.Lock() | ||
| 82 | defer r.mu.Unlock() | ||
| 83 | if r.auth != nil { | ||
| 84 | return nil | ||
| 85 | } | ||
| 86 | tenant := r.cfg.Tenant | ||
| 87 | if tenant == "" { | ||
| 88 | // The credential names the tenant; derive it for the connect name, whose | ||
| 89 | // <tenant>.<vmName> form the VM's host cert principal must match. | ||
| 90 | me, err := r.cfg.API.Me() | ||
| 91 | if err != nil { | ||
| 92 | return fmt.Errorf("resolving tenant from credential: %w", err) | ||
| 93 | } | ||
| 94 | if me.Tenant == "" { | ||
| 95 | return errors.New("credential resolves to no tenant") | ||
| 96 | } | ||
| 97 | tenant = me.Tenant | ||
| 98 | } | ||
| 99 | if err := r.registerUserCA(ctx); err != nil { | ||
| 100 | return err | ||
| 101 | } | ||
| 102 | r.auth = gateclient.NewGateAuth(r.cfg.API, r.cfg.UserCA, tenant, r.cfg.Now) | ||
| 103 | return nil | ||
| 104 | } | ||
| 105 | |||
| 106 | // registerUserCA idempotently registers this client's user-CA public key with | ||
| 107 | // the caller's tenant: it lists the registered CAs and uploads the local pubkey | ||
| 108 | // only if its fingerprint is absent. Routing follows the configured tenant — an | ||
| 109 | // empty tenant hits the tenant-less endpoints, which operate on the caller's own | ||
| 110 | // tenant (the credential names it). Errors name the manual fallback and never | ||
| 111 | // carry key material. | ||
| 112 | func (r *Runner) registerUserCA(ctx context.Context) error { | ||
| 113 | pub := r.cfg.UserCA.PublicKey() | ||
| 114 | fp := ssh.FingerprintSHA256(pub) | ||
| 115 | cas, err := r.cfg.API.ListUserCAs(ctx, r.cfg.Tenant) | ||
| 116 | if err != nil { | ||
| 117 | return fmt.Errorf("checking registered user CAs (run `eitri ca upload` to register manually): %w", err) | ||
| 118 | } | ||
| 119 | for _, ca := range cas { | ||
| 120 | if ca.Fingerprint == fp { | ||
| 121 | return nil | ||
| 122 | } | ||
| 123 | } | ||
| 124 | line := strings.TrimSpace(string(ssh.MarshalAuthorizedKey(pub))) | ||
| 125 | if err := r.cfg.API.UploadUserCA(ctx, r.cfg.Tenant, line); err != nil { | ||
| 126 | return fmt.Errorf("registering user CA (run `eitri ca upload` to register manually): %w", err) | ||
| 127 | } | ||
| 128 | return nil | ||
| 129 | } | ||
| 130 | |||
| 49 | // ConnectName returns the gate connect name for vmName — "<tenant>.<vmName>", | 131 | // ConnectName returns the gate connect name for vmName — "<tenant>.<vmName>", |
| 50 | // the form the gate resolves and the VM's host-cert principal matches. It | 132 | // the form the gate resolves and the VM's host-cert principal matches. It |
| 51 | // delegates to the gate credentials so the Tools layer can build a correct | 133 | // prepares gate credentials on first use so the Tools layer can build a correct |
| 52 | // `ssh -J` hint without dialing. | 134 | // `ssh -J` hint without dialing. |
| 53 | func (r *Runner) ConnectName(ctx context.Context, vmName string) (string, error) { | 135 | func (r *Runner) ConnectName(ctx context.Context, vmName string) (string, error) { |
| 54 | return r.cfg.Auth.ConnectName(ctx, vmName) | 136 | if err := r.ensure(ctx); err != nil { |
| 137 | return "", err | ||
| 138 | } | ||
| 139 | r.mu.Lock() | ||
| 140 | auth := r.auth | ||
| 141 | r.mu.Unlock() | ||
| 142 | return auth.ConnectName(ctx, vmName) | ||
| 55 | } | 143 | } |
| 56 | 144 | ||
| 57 | // Exec runs cmd on the VM named vmName (reached through the gate). A non-zero | 145 | // Exec runs cmd on the VM named vmName (reached through the gate). A non-zero |
| @@ -96,7 +184,13 @@ func (r *Runner) Exec(ctx context.Context, vmName, cmd string, timeout time.Dura | |||
| 96 | // dial reaches the VM named vmName through the eitri SSH-CA gate. See | 184 | // dial reaches the VM named vmName through the eitri SSH-CA gate. See |
| 97 | // gateclient.Dial for the two-hop dial logic this delegates to. | 185 | // gateclient.Dial for the two-hop dial logic this delegates to. |
| 98 | func (r *Runner) dial(ctx context.Context, vmName string) (*ssh.Client, error) { | 186 | func (r *Runner) dial(ctx context.Context, vmName string) (*ssh.Client, error) { |
| 99 | return gateclient.Dial(ctx, gateclient.DialConfig{Gate: r.cfg.Gate, VMUser: r.cfg.VMUser, Auth: r.cfg.Auth}, vmName) | 187 | if err := r.ensure(ctx); err != nil { |
| 188 | return nil, err | ||
| 189 | } | ||
| 190 | r.mu.Lock() | ||
| 191 | auth := r.auth | ||
| 192 | r.mu.Unlock() | ||
| 193 | return gateclient.Dial(ctx, gateclient.DialConfig{Gate: r.cfg.Gate, VMUser: r.cfg.VMUser, Auth: auth}, vmName) | ||
| 100 | } | 194 | } |
| 101 | 195 | ||
| 102 | // cappedBuf captures at most outputCap bytes and records truncation. | 196 | // cappedBuf captures at most outputCap bytes and records truncation. |
internal/mcpserver/sshrun_test.go
| Old | New | ||
|---|---|---|---|
| @@ -8,10 +8,13 @@ import ( | |||
| 8 | "errors" | 8 | "errors" |
| 9 | "io" | 9 | "io" |
| 10 | "net" | 10 | "net" |
| 11 | "strings" | ||
| 12 | "sync" | ||
| 11 | "testing" | 13 | "testing" |
| 12 | "time" | 14 | "time" |
| 13 | 15 | ||
| 14 | "github.com/a73x/eitri/internal/gateclient" | 16 | "github.com/a73x/eitri/internal/gateclient" |
| 17 | "github.com/a73x/eitri/internal/server/api/client" | ||
| 15 | "github.com/stretchr/testify/assert" | 18 | "github.com/stretchr/testify/assert" |
| 16 | "github.com/stretchr/testify/require" | 19 | "github.com/stretchr/testify/require" |
| 17 | "golang.org/x/crypto/ssh" | 20 | "golang.org/x/crypto/ssh" |
| @@ -244,7 +247,7 @@ func TestExecThroughGate(t *testing.T) { | |||
| 244 | // verifies it under that host. | 247 | // verifies it under that host. |
| 245 | gateAddr := startGate(t, hostCertSigner(t, ca, "127.0.0.1"), ca.PublicKey(), vmAddr) | 248 | gateAddr := startGate(t, hostCertSigner(t, ca, "127.0.0.1"), ca.PublicKey(), vmAddr) |
| 246 | 249 | ||
| 247 | r := NewRunner(RunnerConfig{Gate: gateAddr, Auth: ga, VMUser: "ubuntu"}) | 250 | r := &Runner{cfg: RunnerConfig{Gate: gateAddr, VMUser: "ubuntu"}, auth: ga} |
| 248 | res, err := r.Exec(t.Context(), "testvm", "echo hi", 10*time.Second) | 251 | res, err := r.Exec(t.Context(), "testvm", "echo hi", 10*time.Second) |
| 249 | require.NoError(t, err) | 252 | require.NoError(t, err) |
| 250 | assert.Equal(t, "hi\n", res.Stdout) | 253 | assert.Equal(t, "hi\n", res.Stdout) |
| @@ -262,7 +265,7 @@ func TestExecVMForeignCAHostCertRejected(t *testing.T) { | |||
| 262 | vmAddr := startBackingVM(t, hostCertSigner(t, foreignCA, "default.testvm"), ca.PublicKey(), "hi\n", 0) | 265 | vmAddr := startBackingVM(t, hostCertSigner(t, foreignCA, "default.testvm"), ca.PublicKey(), "hi\n", 0) |
| 263 | gateAddr := startGate(t, hostCertSigner(t, ca, "127.0.0.1"), ca.PublicKey(), vmAddr) | 266 | gateAddr := startGate(t, hostCertSigner(t, ca, "127.0.0.1"), ca.PublicKey(), vmAddr) |
| 264 | 267 | ||
| 265 | r := NewRunner(RunnerConfig{Gate: gateAddr, Auth: ga, VMUser: "ubuntu"}) | 268 | r := &Runner{cfg: RunnerConfig{Gate: gateAddr, VMUser: "ubuntu"}, auth: ga} |
| 266 | _, err := r.Exec(t.Context(), "testvm", "echo hi", 10*time.Second) | 269 | _, err := r.Exec(t.Context(), "testvm", "echo hi", 10*time.Second) |
| 267 | require.Error(t, err) | 270 | require.Error(t, err) |
| 268 | assert.Contains(t, err.Error(), "vm testvm ssh handshake", "expected the VM hop to reject the foreign-CA host cert") | 271 | assert.Contains(t, err.Error(), "vm testvm ssh handshake", "expected the VM hop to reject the foreign-CA host cert") |
| @@ -279,8 +282,140 @@ func TestExecGateRejectsNonCAUserKey(t *testing.T) { | |||
| 279 | // so its handshake must fail auth. Host verification still uses the real CA, | 282 | // so its handshake must fail auth. Host verification still uses the real CA, |
| 280 | // isolating the client-auth rejection at the gate hop. | 283 | // isolating the client-auth rejection at the gate hop. |
| 281 | creds := fakeGateCreds{signer: newSigner(t), hostCB: ga.HostKeyCallback()} | 284 | creds := fakeGateCreds{signer: newSigner(t), hostCB: ga.HostKeyCallback()} |
| 282 | r := NewRunner(RunnerConfig{Gate: gateAddr, Auth: creds, VMUser: "ubuntu"}) | 285 | r := &Runner{cfg: RunnerConfig{Gate: gateAddr, VMUser: "ubuntu"}, auth: creds} |
| 283 | _, err := r.Exec(t.Context(), "testvm", "echo hi", 10*time.Second) | 286 | _, err := r.Exec(t.Context(), "testvm", "echo hi", 10*time.Second) |
| 284 | require.Error(t, err) | 287 | require.Error(t, err) |
| 285 | assert.Contains(t, err.Error(), "gate "+gateAddr+" ssh handshake", "expected the gate hop to reject the non-CA user key") | 288 | assert.Contains(t, err.Error(), "gate "+gateAddr+" ssh handshake", "expected the gate hop to reject the non-CA user key") |
| 286 | } | 289 | } |
| 290 | |||
| 291 | // ── Runner.ensure: tenant derivation + idempotent CA self-registration ──────── | ||
| 292 | |||
| 293 | // fakeGateAPI is a minimal gateAPI for exercising Runner.ensure: it serves a | ||
| 294 | // host CA, records user-CA uploads, and returns scripted Me()/ListUserCAs | ||
| 295 | // results so tenant derivation and idempotent registration can be pinned without | ||
| 296 | // an httptest server. | ||
| 297 | type fakeGateAPI struct { | ||
| 298 | caSigner ssh.Signer | ||
| 299 | |||
| 300 | meTenant string | ||
| 301 | meErr error | ||
| 302 | listCAs []client.UserCA | ||
| 303 | listErrs int // leading ListUserCAs calls that fail (control-plane blip) | ||
| 304 | uploadErr error | ||
| 305 | |||
| 306 | mu sync.Mutex | ||
| 307 | meCalls int | ||
| 308 | listCalls int | ||
| 309 | uploads []string // uploaded CA lines, in order | ||
| 310 | lastTenant string // tenant arg of the last CA (list/upload) call | ||
| 311 | } | ||
| 312 | |||
| 313 | func (f *fakeGateAPI) FetchSSHCA(context.Context) (ssh.PublicKey, error) { | ||
| 314 | return f.caSigner.PublicKey(), nil | ||
| 315 | } | ||
| 316 | |||
| 317 | func (f *fakeGateAPI) Me() (client.Me, error) { | ||
| 318 | f.mu.Lock() | ||
| 319 | defer f.mu.Unlock() | ||
| 320 | f.meCalls++ | ||
| 321 | if f.meErr != nil { | ||
| 322 | return client.Me{}, f.meErr | ||
| 323 | } | ||
| 324 | return client.Me{Tenant: f.meTenant}, nil | ||
| 325 | } | ||
| 326 | |||
| 327 | func (f *fakeGateAPI) ListUserCAs(_ context.Context, tenant string) ([]client.UserCA, error) { | ||
| 328 | f.mu.Lock() | ||
| 329 | defer f.mu.Unlock() | ||
| 330 | f.listCalls++ | ||
| 331 | f.lastTenant = tenant | ||
| 332 | if f.listErrs > 0 { | ||
| 333 | f.listErrs-- | ||
| 334 | return nil, errors.New("control plane unavailable") | ||
| 335 | } | ||
| 336 | return f.listCAs, nil | ||
| 337 | } | ||
| 338 | |||
| 339 | func (f *fakeGateAPI) UploadUserCA(_ context.Context, tenant, line string) error { | ||
| 340 | f.mu.Lock() | ||
| 341 | defer f.mu.Unlock() | ||
| 342 | f.lastTenant = tenant | ||
| 343 | if f.uploadErr != nil { | ||
| 344 | return f.uploadErr | ||
| 345 | } | ||
| 346 | f.uploads = append(f.uploads, line) | ||
| 347 | return nil | ||
| 348 | } | ||
| 349 | |||
| 350 | func TestRunnerEnsureRegistersUserCAWhenAbsent(t *testing.T) { | ||
| 351 | userCA := newSigner(t) | ||
| 352 | api := &fakeGateAPI{caSigner: newSigner(t), meTenant: "acme"} // listCAs empty ⇒ absent | ||
| 353 | r := NewRunner(RunnerConfig{Gate: "g:22", VMUser: "ubuntu", API: api, UserCA: userCA}) | ||
| 354 | |||
| 355 | name, err := r.ConnectName(t.Context(), "web-1") | ||
| 356 | require.NoError(t, err) | ||
| 357 | assert.Equal(t, "acme.web-1", name, "connect name uses the tenant derived from the credential") | ||
| 358 | |||
| 359 | api.mu.Lock() | ||
| 360 | defer api.mu.Unlock() | ||
| 361 | require.Len(t, api.uploads, 1, "an absent CA must be uploaded exactly once") | ||
| 362 | assert.Equal(t, strings.TrimSpace(string(ssh.MarshalAuthorizedKey(userCA.PublicKey()))), api.uploads[0]) | ||
| 363 | assert.Equal(t, "", api.lastTenant, "an empty config tenant registers via the tenant-less route") | ||
| 364 | } | ||
| 365 | |||
| 366 | func TestRunnerEnsureSkipsUploadWhenFingerprintPresent(t *testing.T) { | ||
| 367 | userCA := newSigner(t) | ||
| 368 | fp := ssh.FingerprintSHA256(userCA.PublicKey()) | ||
| 369 | api := &fakeGateAPI{caSigner: newSigner(t), meTenant: "acme", listCAs: []client.UserCA{{Fingerprint: fp, Label: "mcp"}}} | ||
| 370 | r := NewRunner(RunnerConfig{Gate: "g:22", VMUser: "ubuntu", API: api, UserCA: userCA}) | ||
| 371 | |||
| 372 | _, err := r.ConnectName(t.Context(), "web-1") | ||
| 373 | require.NoError(t, err) | ||
| 374 | |||
| 375 | api.mu.Lock() | ||
| 376 | defer api.mu.Unlock() | ||
| 377 | assert.Empty(t, api.uploads, "a CA already registered (matching fingerprint) must NOT be re-uploaded") | ||
| 378 | } | ||
| 379 | |||
| 380 | func TestRunnerEnsureExplicitTenantSkipsMe(t *testing.T) { | ||
| 381 | api := &fakeGateAPI{caSigner: newSigner(t), meTenant: "should-not-be-used"} | ||
| 382 | r := NewRunner(RunnerConfig{Gate: "g:22", VMUser: "ubuntu", API: api, UserCA: newSigner(t), Tenant: "team"}) | ||
| 383 | |||
| 384 | name, err := r.ConnectName(t.Context(), "web-1") | ||
| 385 | require.NoError(t, err) | ||
| 386 | assert.Equal(t, "team.web-1", name, "an explicit tenant is used verbatim") | ||
| 387 | |||
| 388 | api.mu.Lock() | ||
| 389 | defer api.mu.Unlock() | ||
| 390 | assert.Zero(t, api.meCalls, "an explicit tenant must not call Me()") | ||
| 391 | assert.Equal(t, "team", api.lastTenant, "an explicit tenant pins the /tenants/{tenant} CA route") | ||
| 392 | } | ||
| 393 | |||
| 394 | func TestRunnerEnsureRegistrationFailureNamesFallback(t *testing.T) { | ||
| 395 | api := &fakeGateAPI{caSigner: newSigner(t), meTenant: "acme", uploadErr: errors.New("boom")} | ||
| 396 | r := NewRunner(RunnerConfig{Gate: "g:22", VMUser: "ubuntu", API: api, UserCA: newSigner(t)}) | ||
| 397 | |||
| 398 | _, err := r.ConnectName(t.Context(), "web-1") | ||
| 399 | require.Error(t, err) | ||
| 400 | assert.Contains(t, err.Error(), "eitri ca upload", "a registration failure must name the manual fallback") | ||
| 401 | } | ||
| 402 | |||
| 403 | func TestRunnerEnsureRetriesAfterFailureThenCaches(t *testing.T) { | ||
| 404 | api := &fakeGateAPI{caSigner: newSigner(t), meTenant: "acme", listErrs: 1} // first list fails | ||
| 405 | r := NewRunner(RunnerConfig{Gate: "g:22", VMUser: "ubuntu", API: api, UserCA: newSigner(t)}) | ||
| 406 | |||
| 407 | _, err := r.ConnectName(t.Context(), "web-1") | ||
| 408 | require.Error(t, err, "a failed ensure surfaces the error") | ||
| 409 | |||
| 410 | // The next call retries and succeeds; a third rides the cache. | ||
| 411 | _, err = r.ConnectName(t.Context(), "web-1") | ||
| 412 | require.NoError(t, err) | ||
| 413 | _, err = r.ConnectName(t.Context(), "web-1") | ||
| 414 | require.NoError(t, err) | ||
| 415 | |||
| 416 | api.mu.Lock() | ||
| 417 | defer api.mu.Unlock() | ||
| 418 | assert.Equal(t, 2, api.listCalls, "one failed + one successful list; the cached third call lists nothing") | ||
| 419 | assert.Equal(t, 2, api.meCalls, "Me() re-runs on the retry, then not after caching") | ||
| 420 | assert.Len(t, api.uploads, 1, "the successful attempt uploads the absent CA once") | ||
| 421 | } | ||
internal/mcpserver/tools.go
| Old | New | ||
|---|---|---|---|
| @@ -102,6 +102,10 @@ type VMCreateOut struct { | |||
| 102 | Name string `json:"name"` | 102 | Name string `json:"name"` |
| 103 | IP string `json:"ip,omitempty"` | 103 | IP string `json:"ip,omitempty"` |
| 104 | SSHCommand string `json:"ssh_command,omitempty"` | 104 | SSHCommand string `json:"ssh_command,omitempty"` |
| 105 | // CloudInit warns about a non-clean-but-usable boot: set to a degraded | ||
| 106 | // message when cloud-init finished with recoverable errors (exit 2), omitted | ||
| 107 | // on a clean boot. The VM is ready either way — this never marks a failure. | ||
| 108 | CloudInit string `json:"cloud_init,omitempty"` | ||
| 105 | } | 109 | } |
| 106 | 110 | ||
| 107 | func (t *Tools) VMCreate(ctx context.Context, in VMCreateIn) (VMCreateOut, error) { | 111 | func (t *Tools) VMCreate(ctx context.Context, in VMCreateIn) (VMCreateOut, error) { |
| @@ -185,10 +189,19 @@ func (t *Tools) VMCreate(ctx context.Context, in VMCreateIn) (VMCreateOut, error | |||
| 185 | remaining := max(time.Until(deadline), 30*time.Second) | 189 | remaining := max(time.Until(deadline), 30*time.Second) |
| 186 | res, execErr := t.Runner.Exec(ctx, created.Name, "cloud-init status --wait", remaining) | 190 | res, execErr := t.Runner.Exec(ctx, created.Name, "cloud-init status --wait", remaining) |
| 187 | if execErr == nil { | 191 | if execErr == nil { |
| 188 | if res.ExitCode != 0 { | 192 | switch res.ExitCode { |
| 193 | case 0: | ||
| 194 | return out, nil | ||
| 195 | case 2: | ||
| 196 | // cloud-init exit 2 is "done, with recoverable errors": the guest | ||
| 197 | // finished booting and is fully usable. Report the degradation as a | ||
| 198 | // warning on a SUCCESSFUL create — erroring here invites a retry that | ||
| 199 | // leaks the (working) VM. | ||
| 200 | out.CloudInit = "degraded — cloud-init reported recoverable errors" | ||
| 201 | return out, nil | ||
| 202 | default: | ||
| 189 | return out, fmt.Errorf("vm %s (%s) ready at %s but cloud-init exited %d: %s", created.ID, created.Name, ip, res.ExitCode, res.Stderr) | 203 | return out, fmt.Errorf("vm %s (%s) ready at %s but cloud-init exited %d: %s", created.ID, created.Name, ip, res.ExitCode, res.Stderr) |
| 190 | } | 204 | } |
| 191 | return out, nil | ||
| 192 | } | 205 | } |
| 193 | lastErr = execErr | 206 | lastErr = execErr |
| 194 | // Stop once we are past the deadline, or the next sleep would carry us | 207 | // Stop once we are past the deadline, or the next sleep would carry us |
internal/mcpserver/tools_test.go
| Old | New | ||
|---|---|---|---|
| @@ -117,6 +117,7 @@ func TestCreateWaitsForReadyAndCloudInit(t *testing.T) { | |||
| 117 | require.NoError(t, err) | 117 | require.NoError(t, err) |
| 118 | assert.Equal(t, "new1", out.ID) | 118 | assert.Equal(t, "new1", out.ID) |
| 119 | assert.Equal(t, "10.77.1.9", out.IP) | 119 | assert.Equal(t, "10.77.1.9", out.IP) |
| 120 | assert.Empty(t, out.CloudInit, "a clean cloud-init (exit 0) carries no degraded warning") | ||
| 120 | assert.Contains(t, out.SSHCommand, "-J localhost:2223") | 121 | assert.Contains(t, out.SSHCommand, "-J localhost:2223") |
| 121 | assert.Contains(t, out.SSHCommand, "ubuntu@default.claude-abc", | 122 | assert.Contains(t, out.SSHCommand, "ubuntu@default.claude-abc", |
| 122 | "hint must dial the namespaced <tenant>.<name>, not the bare name the gate rejects") | 123 | "hint must dial the namespaced <tenant>.<name>, not the bare name the gate rejects") |
| @@ -193,22 +194,45 @@ func TestCreateSSHNeverReachableReports(t *testing.T) { | |||
| 193 | assert.Empty(t, api.deleted, "spec: never auto-destroy on unreachable") | 194 | assert.Empty(t, api.deleted, "spec: never auto-destroy on unreachable") |
| 194 | } | 195 | } |
| 195 | 196 | ||
| 196 | func TestCreateReportsCloudInitFailure(t *testing.T) { | 197 | func TestCreateCloudInitExit2IsDegradedSuccess(t *testing.T) { |
| 197 | // SSH connects and cloud-init RUNS but exits non-zero. Unlike a connection | 198 | // cloud-init exit 2 = "done, with recoverable errors": the guest is booted and |
| 198 | // failure this must NOT be retried: cloud-init already ran, so report the | 199 | // fully usable. vm_create must SUCCEED (no error) and surface the degradation as |
| 199 | // degraded VM after exactly one Exec. | 200 | // a warning, so the model doesn't retry and leak the working VM. |
| 200 | api := &fakeToolsAPI{phases: []string{"creating", "ready"}} | 201 | api := &fakeToolsAPI{phases: []string{"creating", "ready"}} |
| 201 | run := &fakeRunner{out: ExecResult{ExitCode: 1, Stderr: "boom"}} | 202 | run := &fakeRunner{out: ExecResult{ExitCode: 2, Stderr: "some unit failed"}} |
| 202 | tl := newTestTools(api, run) | 203 | tl := newTestTools(api, run) |
| 203 | 204 | ||
| 204 | out, err := tl.VMCreate(t.Context(), VMCreateIn{}) | 205 | out, err := tl.VMCreate(t.Context(), VMCreateIn{}) |
| 205 | require.Error(t, err) | 206 | require.NoError(t, err, "exit 2 is a usable VM, not a failed create") |
| 206 | assert.Contains(t, err.Error(), "cloud-init exited 1") | 207 | assert.Equal(t, "10.77.1.9", out.IP) |
| 207 | assert.Contains(t, err.Error(), "new1", "error must name the VM id") | 208 | assert.Contains(t, out.CloudInit, "degraded", "exit 2 must carry a degraded warning") |
| 208 | assert.Contains(t, err.Error(), "claude-abc", "error must name the VM name") | 209 | assert.Contains(t, out.SSHCommand, "ubuntu@default.claude-abc", "a usable VM still gets its ssh hint") |
| 209 | assert.Equal(t, "10.77.1.9", out.IP, "out stays populated so the model can act on it") | ||
| 210 | assert.Empty(t, api.deleted, "spec: never auto-destroy") | 210 | assert.Empty(t, api.deleted, "spec: never auto-destroy") |
| 211 | require.Len(t, run.execs, 1, "a ran-but-failed cloud-init must NOT be retried") | 211 | require.Len(t, run.execs, 1, "a ran cloud-init must NOT be retried") |
| 212 | } | ||
| 213 | |||
| 214 | func TestCreateReportsCloudInitFailure(t *testing.T) { | ||
| 215 | // SSH connects and cloud-init RUNS but exits with a non-recoverable code | ||
| 216 | // (anything other than 0 or 2). Unlike a connection failure this must NOT be | ||
| 217 | // retried: cloud-init already ran, so report the degraded VM after exactly one | ||
| 218 | // Exec. | ||
| 219 | for _, code := range []int{1, 3} { | ||
| 220 | t.Run(fmt.Sprintf("exit%d", code), func(t *testing.T) { | ||
| 221 | api := &fakeToolsAPI{phases: []string{"creating", "ready"}} | ||
| 222 | run := &fakeRunner{out: ExecResult{ExitCode: code, Stderr: "boom"}} | ||
| 223 | tl := newTestTools(api, run) | ||
| 224 | |||
| 225 | out, err := tl.VMCreate(t.Context(), VMCreateIn{}) | ||
| 226 | require.Error(t, err) | ||
| 227 | assert.Contains(t, err.Error(), fmt.Sprintf("cloud-init exited %d", code)) | ||
| 228 | assert.Contains(t, err.Error(), "new1", "error must name the VM id") | ||
| 229 | assert.Contains(t, err.Error(), "claude-abc", "error must name the VM name") | ||
| 230 | assert.Empty(t, out.CloudInit, "a hard cloud-init failure is an error, not a warning") | ||
| 231 | assert.Equal(t, "10.77.1.9", out.IP, "out stays populated so the model can act on it") | ||
| 232 | assert.Empty(t, api.deleted, "spec: never auto-destroy") | ||
| 233 | require.Len(t, run.execs, 1, "a ran-but-failed cloud-init must NOT be retried") | ||
| 234 | }) | ||
| 235 | } | ||
| 212 | } | 236 | } |
| 213 | 237 | ||
| 214 | func TestCreateNoWaitReturnsImmediately(t *testing.T) { | 238 | func TestCreateNoWaitReturnsImmediately(t *testing.T) { |
internal/server/api/client/client.go
| Old | New | ||
|---|---|---|---|
| @@ -33,6 +33,7 @@ type ( | |||
| 33 | CreateVMResponse = types.CreateVMResponse | 33 | CreateVMResponse = types.CreateVMResponse |
| 34 | Me = types.Me | 34 | Me = types.Me |
| 35 | CreateAPITokenResponse = types.CreateAPITokenResponse | 35 | CreateAPITokenResponse = types.CreateAPITokenResponse |
| 36 | UserCA = types.UserCA | ||
| 36 | ) | 37 | ) |
| 37 | 38 | ||
| 38 | // Client calls the eitri API at BaseURL, authenticating with Token (sent as a | 39 | // Client calls the eitri API at BaseURL, authenticating with Token (sent as a |
| @@ -203,3 +204,17 @@ func (c *Client) UploadUserCA(ctx context.Context, tenant, caLine string) error | |||
| 203 | } | 204 | } |
| 204 | return c.do(ctx, http.MethodPost, path, req, nil) | 205 | return c.do(ctx, http.MethodPost, path, req, nil) |
| 205 | } | 206 | } |
| 207 | |||
| 208 | // ListUserCAs returns a tenant's registered user CAs (pubkey + label + | ||
| 209 | // fingerprint). An empty tenant targets the tenant-less endpoint, which lists | ||
| 210 | // the CALLER'S OWN tenant (the credential names it); a non-empty tenant pins one | ||
| 211 | // explicitly. It mirrors UploadUserCA's routing so a caller can check-then-upload | ||
| 212 | // idempotently against its own tenant with an empty tenant throughout. | ||
| 213 | func (c *Client) ListUserCAs(ctx context.Context, tenant string) ([]UserCA, error) { | ||
| 214 | path := "/api/v1/user-cas" | ||
| 215 | if tenant != "" { | ||
| 216 | path = "/api/v1/tenants/" + url.PathEscape(tenant) + "/user-cas" | ||
| 217 | } | ||
| 218 | var out []UserCA | ||
| 219 | return out, c.do(ctx, http.MethodGet, path, nil, &out) | ||
| 220 | } | ||
internal/server/api/client/client_test.go
| Old | New | ||
|---|---|---|---|
| @@ -409,6 +409,39 @@ func TestUploadUserCAEmptyTenantHitsOwnEndpoint(t *testing.T) { | |||
| 409 | } | 409 | } |
| 410 | } | 410 | } |
| 411 | 411 | ||
| 412 | func TestListUserCAs(t *testing.T) { | ||
| 413 | var cap capture | ||
| 414 | srv := serve(t, &cap, http.StatusOK, `[{"fingerprint":"SHA256:abc","label":"laptop","pubkey":"`+testCALine+`"}]`) | ||
| 415 | c := &client.Client{BaseURL: srv.URL, Token: "tok"} | ||
| 416 | |||
| 417 | cas, err := c.ListUserCAs(context.Background(), "default") | ||
| 418 | if err != nil { | ||
| 419 | t.Fatalf("ListUserCAs: %v", err) | ||
| 420 | } | ||
| 421 | if cap.method != http.MethodGet || cap.path != "/api/v1/tenants/default/user-cas" { | ||
| 422 | t.Errorf("request = %s %s, want GET /api/v1/tenants/default/user-cas", cap.method, cap.path) | ||
| 423 | } | ||
| 424 | if len(cas) != 1 || cas[0].Fingerprint != "SHA256:abc" || cas[0].Label != "laptop" { | ||
| 425 | t.Errorf("cas = %+v, want one entry SHA256:abc/laptop", cas) | ||
| 426 | } | ||
| 427 | } | ||
| 428 | |||
| 429 | // An empty tenant targets the tenant-less endpoint, which lists the caller's own | ||
| 430 | // tenant (the credential names it) — mirroring UploadUserCA's routing so a caller | ||
| 431 | // can check-then-upload against its own tenant with an empty tenant throughout. | ||
| 432 | func TestListUserCAsEmptyTenantHitsOwnEndpoint(t *testing.T) { | ||
| 433 | var cap capture | ||
| 434 | srv := serve(t, &cap, http.StatusOK, `[]`) | ||
| 435 | c := &client.Client{BaseURL: srv.URL, Token: "tok"} | ||
| 436 | |||
| 437 | if _, err := c.ListUserCAs(context.Background(), ""); err != nil { | ||
| 438 | t.Fatalf("ListUserCAs: %v", err) | ||
| 439 | } | ||
| 440 | if cap.method != http.MethodGet || cap.path != "/api/v1/user-cas" { | ||
| 441 | t.Errorf("request = %s %s, want GET /api/v1/user-cas", cap.method, cap.path) | ||
| 442 | } | ||
| 443 | } | ||
| 444 | |||
| 412 | func TestUploadUserCAEscapesTenant(t *testing.T) { | 445 | func TestUploadUserCAEscapesTenant(t *testing.T) { |
| 413 | var cap capture | 446 | var cap capture |
| 414 | srv := serve(t, &cap, http.StatusCreated, `{}`) | 447 | srv := serve(t, &cap, http.StatusCreated, `{}`) |