a73x

7f51aba3

feat(mcp): self-sufficient against the hosted service

a73x   2026-07-29 19:16

Commit message
feat(mcp): self-sufficient against the hosted service

eitri-mcp needs exactly a PAT: its user CA self-registers on first use
(idempotent, via the caller's-own-tenant route), the tenant is derived
from the credential when unset — the credential names the tenant, and the
gate connect name the VM's host cert requires is <tenant>.<vm>, with an
explicit tenant config kept for the ambiguous multi-tenant corner — and a
cloud-init that finishes with recoverable errors (exit 2) reports a
degraded warning on a successful create instead of failing it: the VM is
usable, and an erroring create invites a retry that leaks VMs.

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, `{}`)