d0b7e5e1
feat(tenant): multi-tenancy — data model, principal, and gate authz
a73x 2026-07-25 13:19
Commit message
cmd/eitri-server/sshgate.go
| Old | New | ||
|---|---|---|---|
| @@ -101,16 +101,23 @@ func (g *sshGateSetup) startListener(st *store.Store, svc *syncsvc.Service) { | |||
| 101 | gateDomain = "localhost" | 101 | gateDomain = "localhost" |
| 102 | } | 102 | } |
| 103 | slog.Info("ssh gate host cert", "principal", gateDomain) | 103 | slog.Info("ssh gate host cert", "principal", gateDomain) |
| 104 | // resolve maps a VM name to its host/VM IDs; unknown or tombstoned ⇒ ok=false. | 104 | // resolve is tenant-scoped: the bare name is looked up WITHIN the |
| 105 | resolve := func(name string) (hostID, vmID string, ok bool) { | 105 | // connection's tenant only. |
| 106 | vm, err := st.VMByName(name) | 106 | resolve := func(tenant, name string) (hostID, vmID string, ok bool) { |
| 107 | vm, err := st.VMByTenantName(tenant, name) | ||
| 107 | if err != nil { | 108 | if err != nil { |
| 108 | return "", "", false | 109 | return "", "", false |
| 109 | } | 110 | } |
| 110 | return vm.HostID, vm.ID, true | 111 | return vm.HostID, vm.ID, true |
| 111 | } | 112 | } |
| 112 | // v1 single-admin: any CA-signed cert reaches any VM; per-user ownership is Task/Slice per §5/§9. | 113 | // authorize re-reads the VM row and requires tenant equality. With one CA |
| 113 | authorize := func(principal, vmID string) bool { return true } | 114 | // every connection is DefaultTenant so this always passes TODAY — but it |
| 115 | // is a live per-connection comparison, not a stub: tenant #2's CA maps to | ||
| 116 | // its own tenant here with no code-shape change. | ||
| 117 | authorize := func(tenant, vmID string) bool { | ||
| 118 | vm, err := st.GetVM(vmID) | ||
| 119 | return err == nil && vm.DeletedAt == nil && vm.Tenant == tenant | ||
| 120 | } | ||
| 114 | // Sign a long-lived HOST cert for the gate's own host key and present THAT | 121 | // Sign a long-lived HOST cert for the gate's own host key and present THAT |
| 115 | // (via a cert signer) instead of the bare key, so a client verifying with | 122 | // (via a cert signer) instead of the bare key, so a client verifying with |
| 116 | // `@cert-authority` accepts the gate on first connect — no TOFU window. | 123 | // `@cert-authority` accepts the gate on first connect — no TOFU window. |
| @@ -138,7 +145,7 @@ func (g *sshGateSetup) startListener(st *store.Store, svc *syncsvc.Service) { | |||
| 138 | } | 145 | } |
| 139 | return revoked | 146 | return revoked |
| 140 | } | 147 | } |
| 141 | gate := sshgate.New(gateHostSigner, g.ca.UserCA().PublicKey(), resolve, authorize, svc.OpenTCP, isRevoked) | 148 | gate := sshgate.New(gateHostSigner, g.ca.UserCA().PublicKey(), store.DefaultTenant, resolve, authorize, svc.OpenTCP, isRevoked) |
| 142 | ln, err := net.Listen("tcp", g.listen) | 149 | ln, err := net.Listen("tcp", g.listen) |
| 143 | if err != nil { | 150 | if err != nil { |
| 144 | slog.Error("ssh gate listen", "err", err) | 151 | slog.Error("ssh gate listen", "err", err) |
docs/openapi.json
| Old | New | ||
|---|---|---|---|
| @@ -296,10 +296,14 @@ | |||
| 296 | "properties": { | 296 | "properties": { |
| 297 | "certificate": { | 297 | "certificate": { |
| 298 | "type": "string" | 298 | "type": "string" |
| 299 | }, | ||
| 300 | "tenant": { | ||
| 301 | "type": "string" | ||
| 299 | } | 302 | } |
| 300 | }, | 303 | }, |
| 301 | "required": [ | 304 | "required": [ |
| 302 | "certificate" | 305 | "certificate", |
| 306 | "tenant" | ||
| 303 | ], | 307 | ], |
| 304 | "type": "object" | 308 | "type": "object" |
| 305 | }, | 309 | }, |
docs/shape.html
| Old | New | ||
|---|---|---|---|
| @@ -320,7 +320,7 @@ | |||
| 320 | { | 320 | { |
| 321 | "importPath": "internal/server/sshgate", | 321 | "importPath": "internal/server/sshgate", |
| 322 | "plane": "control", | 322 | "plane": "control", |
| 323 | "synopsis": "Package sshgate is eitri's hardened SSH jump gate: a bastion front-end that admins reach with `ssh -J gate ubuntu@\u003cvm\u003e`.", | 323 | "synopsis": "Package sshgate is eitri's hardened SSH jump gate: a bastion front-end that admins reach with `ssh -J gate ubuntu@\u003ctenant\u003e.\u003cvm\u003e`.", |
| 324 | "imports": [] | 324 | "imports": [] |
| 325 | }, | 325 | }, |
| 326 | { | 326 | { |
docs/shape.json
| Old | New | ||
|---|---|---|---|
| @@ -269,7 +269,7 @@ | |||
| 269 | { | 269 | { |
| 270 | "importPath": "internal/server/sshgate", | 270 | "importPath": "internal/server/sshgate", |
| 271 | "plane": "control", | 271 | "plane": "control", |
| 272 | "synopsis": "Package sshgate is eitri's hardened SSH jump gate: a bastion front-end that admins reach with `ssh -J gate ubuntu@\u003cvm\u003e`.", | 272 | "synopsis": "Package sshgate is eitri's hardened SSH jump gate: a bastion front-end that admins reach with `ssh -J gate ubuntu@\u003ctenant\u003e.\u003cvm\u003e`.", |
| 273 | "imports": [] | 273 | "imports": [] |
| 274 | }, | 274 | }, |
| 275 | { | 275 | { |
docs/ssh-access.md
| Old | New | ||
|---|---|---|---|
| @@ -24,6 +24,10 @@ hack/eitri-ssh <vm-name> # opens a shell on the VM | |||
| 24 | hack/eitri-ssh <vm-name> uptime # runs a command and exits | 24 | hack/eitri-ssh <vm-name> uptime # runs a command and exits |
| 25 | ``` | 25 | ``` |
| 26 | 26 | ||
| 27 | You pass the bare `<vm-name>`. VMs are actually dialed by their **gate connect | ||
| 28 | name** `<tenant>.<vm-name>` (also the VM's host-cert principal); `hack/eitri-ssh` | ||
| 29 | builds it automatically from the tenant the mint response returns. | ||
| 30 | |||
| 27 | Environment variables: | 31 | Environment variables: |
| 28 | 32 | ||
| 29 | | Var | Meaning | | 33 | | Var | Meaning | |
| @@ -56,18 +60,23 @@ The helper is a thin wrapper over three steps you can run by hand: | |||
| 56 | "$EITRI_URL/api/v1/ssh-certs" | jq -r .certificate > ~/.ssh/id_ed25519-cert.pub | 60 | "$EITRI_URL/api/v1/ssh-certs" | jq -r .certificate > ~/.ssh/id_ed25519-cert.pub |
| 57 | ``` | 61 | ``` |
| 58 | 62 | ||
| 63 | The same response also carries a `.tenant` field — the prefix of the | ||
| 64 | `<tenant>.<vm-name>` connect name you dial in step 3. | ||
| 65 | |||
| 59 | 2. **Place the cert beside the key.** OpenSSH auto-offers a cert named | 66 | 2. **Place the cert beside the key.** OpenSSH auto-offers a cert named |
| 60 | `<key>-cert.pub` next to `<key>`, so the write above is all that's needed — | 67 | `<key>-cert.pub` next to `<key>`, so the write above is all that's needed — |
| 61 | no `ssh-add` required. | 68 | no `ssh-add` required. |
| 62 | 69 | ||
| 63 | 3. **Hop through the gate** to `ubuntu@<vm>`: | 70 | 3. **Hop through the gate** to `ubuntu@<tenant>.<vm-name>`: |
| 64 | 71 | ||
| 65 | ```sh | 72 | ```sh |
| 66 | ssh -J "$EITRI_GATE" ubuntu@<vm-name> | 73 | ssh -J "$EITRI_GATE" ubuntu@<tenant>.<vm-name> |
| 67 | ``` | 74 | ``` |
| 68 | 75 | ||
| 69 | The inner user must be `ubuntu` (the cert principal). The outer gate hop | 76 | The inner user must be `ubuntu` (the cert principal). The outer gate hop |
| 70 | accepts any username. | 77 | accepts any username. The host part is the `<tenant>.<vm-name>` connect name |
| 78 | (the tenant is the `.tenant` from the mint response); the gate resolves names | ||
| 79 | within your tenant and rejects a bare or foreign-prefixed name. | ||
| 71 | 80 | ||
| 72 | ## Certs are short-lived | 81 | ## Certs are short-lived |
| 73 | 82 | ||
| @@ -102,11 +111,12 @@ ssh \ | |||
| 102 | -o "ProxyCommand=ssh -W %h:%p -o StrictHostKeyChecking=yes -o UserKnownHostsFile=$KH -p $GATE_PORT ubuntu@$GATE_HOST" \ | 111 | -o "ProxyCommand=ssh -W %h:%p -o StrictHostKeyChecking=yes -o UserKnownHostsFile=$KH -p $GATE_PORT ubuntu@$GATE_HOST" \ |
| 103 | -o StrictHostKeyChecking=yes \ | 112 | -o StrictHostKeyChecking=yes \ |
| 104 | -o "UserKnownHostsFile=$KH" \ | 113 | -o "UserKnownHostsFile=$KH" \ |
| 105 | ubuntu@<vm-name> | 114 | ubuntu@<tenant>.<vm-name> |
| 106 | ``` | 115 | ``` |
| 107 | 116 | ||
| 108 | The gate's cert principal is `ssh_gate_domain` (so `$GATE_HOST` must match it), | 117 | The gate's cert principal is `ssh_gate_domain` (so `$GATE_HOST` must match it), |
| 109 | and each VM's cert principal is its VM name (so the inner `ubuntu@<vm-name>` host | 118 | and each VM's cert principal is its `<tenant>.<vm-name>` connect name (so the |
| 110 | must match). Because verification is by CA, recycling a VM name or IP never | 119 | inner `ubuntu@<tenant>.<vm-name>` host must match). Because verification is by |
| 111 | produces a host-key-changed warning — the new VM simply presents a fresh | 120 | CA, recycling a VM name or IP never produces a host-key-changed warning — the |
| 112 | CA-signed cert for that name. `hack/eitri-ssh` does all of this for you. | 121 | new VM simply presents a fresh CA-signed cert for that name. `hack/eitri-ssh` |
| 122 | does all of this for you. | ||
hack/eitri-ssh
| Old | New | ||
|---|---|---|---|
| @@ -20,6 +20,10 @@ | |||
| 20 | # outer gate hop accepts any username. Certs are short-lived — just re-run to | 20 | # outer gate hop accepts any username. Certs are short-lived — just re-run to |
| 21 | # refresh. | 21 | # refresh. |
| 22 | # | 22 | # |
| 23 | # VMs are dialed by their gate connect name <tenant>.<vm-name> (which is also the | ||
| 24 | # VM's host-cert principal); eitri-ssh builds it automatically from the tenant | ||
| 25 | # the mint response returns, so you pass just the bare <vm-name>. | ||
| 26 | # | ||
| 23 | # Host verification is by CERTIFICATE, not TOFU: the helper fetches eitri's CA | 27 | # Host verification is by CERTIFICATE, not TOFU: the helper fetches eitri's CA |
| 24 | # public key and pins it as a `@cert-authority *` entry in a DEDICATED | 28 | # public key and pins it as a `@cert-authority *` entry in a DEDICATED |
| 25 | # known_hosts file (never your main ~/.ssh/known_hosts — a wildcard cert | 29 | # known_hosts file (never your main ~/.ssh/known_hosts — a wildcard cert |
| @@ -49,6 +53,21 @@ KEY=${EITRI_KEY:-$HOME/.ssh/id_ed25519} | |||
| 49 | # user's main known_hosts, where a wildcard CA would apply to every ssh target. | 53 | # user's main known_hosts, where a wildcard CA would apply to every ssh target. |
| 50 | KNOWN_HOSTS=${EITRI_KNOWN_HOSTS:-$HOME/.ssh/eitri_known_hosts} | 54 | KNOWN_HOSTS=${EITRI_KNOWN_HOSTS:-$HOME/.ssh/eitri_known_hosts} |
| 51 | 55 | ||
| 56 | # json_field <name>: extract a top-level JSON string field from stdin. Uses jq | ||
| 57 | # when available, else a sed fallback. The capture is NON-GREEDY (`[^"]*`, not | ||
| 58 | # `.*`) so a multi-field body doesn't swallow through to the last quote — the | ||
| 59 | # cause of a corrupted cert on jq-less machines now that the mint response | ||
| 60 | # carries both `certificate` and `tenant`. `[^"]*` is safe because the server's | ||
| 61 | # JSON string values contain no raw double quotes; `\n` escapes are unescaped so | ||
| 62 | # the multi-line cert survives (a no-op for single-token fields like tenant). | ||
| 63 | json_field() { | ||
| 64 | if command -v jq >/dev/null 2>&1; then | ||
| 65 | jq -r ".$1" | ||
| 66 | else | ||
| 67 | sed -n "s/.*\"$1\"[[:space:]]*:[[:space:]]*\"\([^\"]*\)\".*/\1/p" | sed 's/\\n/\n/g' | ||
| 68 | fi | ||
| 69 | } | ||
| 70 | |||
| 52 | # 1. Ensure a keypair exists. | 71 | # 1. Ensure a keypair exists. |
| 53 | if [ ! -f "$KEY" ]; then | 72 | if [ ! -f "$KEY" ]; then |
| 54 | echo "eitri-ssh: generating SSH key at $KEY" >&2 | 73 | echo "eitri-ssh: generating SSH key at $KEY" >&2 |
| @@ -71,17 +90,23 @@ if [ "$code" != "200" ]; then | |||
| 71 | exit 1 | 90 | exit 1 |
| 72 | fi | 91 | fi |
| 73 | 92 | ||
| 74 | # 3. Extract .certificate (jq preferred; sed fallback for the flat string field). | 93 | # 3. Extract .certificate (jq preferred; non-greedy sed fallback via json_field). |
| 75 | if command -v jq >/dev/null 2>&1; then | 94 | cert=$(printf '%s' "$body" | json_field certificate) |
| 76 | cert=$(printf '%s' "$body" | jq -r '.certificate') | ||
| 77 | else | ||
| 78 | cert=$(printf '%s' "$body" | sed -n 's/.*"certificate"[[:space:]]*:[[:space:]]*"\(.*\)".*/\1/p' | sed 's/\\n/\n/g') | ||
| 79 | fi | ||
| 80 | if [ -z "$cert" ] || [ "$cert" = "null" ]; then | 95 | if [ -z "$cert" ] || [ "$cert" = "null" ]; then |
| 81 | echo "eitri-ssh: could not extract certificate from response: $body" >&2 | 96 | echo "eitri-ssh: could not extract certificate from response: $body" >&2 |
| 82 | exit 1 | 97 | exit 1 |
| 83 | fi | 98 | fi |
| 84 | 99 | ||
| 100 | # 3b. Extract the tenant and build the gate connect name <tenant>.<vm>. The | ||
| 101 | # gate resolves names WITHIN a tenant and each VM's host-cert principal is | ||
| 102 | # the namespaced name, so the connect name must carry the tenant prefix. | ||
| 103 | tenant=$(printf '%s' "$body" | json_field tenant) | ||
| 104 | if [ -z "$tenant" ] || [ "$tenant" = "null" ]; then | ||
| 105 | echo "eitri-ssh: server did not return a tenant (pre-tenancy server?); cannot build connect name" >&2 | ||
| 106 | exit 1 | ||
| 107 | fi | ||
| 108 | TARGET="$tenant.$VM" | ||
| 109 | |||
| 85 | # 4. Write it beside the key so ssh auto-offers it. | 110 | # 4. Write it beside the key so ssh auto-offers it. |
| 86 | printf '%s\n' "$cert" >"$KEY-cert.pub" | 111 | printf '%s\n' "$cert" >"$KEY-cert.pub" |
| 87 | 112 | ||
| @@ -96,11 +121,7 @@ if [ "$ca_code" != "200" ]; then | |||
| 96 | echo "eitri-ssh: fetch CA failed (HTTP $ca_code): $ca_body" >&2 | 121 | echo "eitri-ssh: fetch CA failed (HTTP $ca_code): $ca_body" >&2 |
| 97 | exit 1 | 122 | exit 1 |
| 98 | fi | 123 | fi |
| 99 | if command -v jq >/dev/null 2>&1; then | 124 | ca=$(printf '%s' "$ca_body" | json_field ca) |
| 100 | ca=$(printf '%s' "$ca_body" | jq -r '.ca') | ||
| 101 | else | ||
| 102 | ca=$(printf '%s' "$ca_body" | sed -n 's/.*"ca"[[:space:]]*:[[:space:]]*"\(.*\)".*/\1/p' | sed 's/\\n/\n/g') | ||
| 103 | fi | ||
| 104 | if [ -z "$ca" ] || [ "$ca" = "null" ]; then | 125 | if [ -z "$ca" ] || [ "$ca" = "null" ]; then |
| 105 | echo "eitri-ssh: could not extract CA key from response: $ca_body" >&2 | 126 | echo "eitri-ssh: could not extract CA key from response: $ca_body" >&2 |
| 106 | exit 1 | 127 | exit 1 |
| @@ -119,8 +140,9 @@ printf '@cert-authority * %s\n' "$(printf '%s' "$ca" | tr -d '\r\n')" >"$KNOWN_H | |||
| 119 | # with StrictHostKeyChecking=yes. | 140 | # with StrictHostKeyChecking=yes. |
| 120 | # | 141 | # |
| 121 | # The gate host cert's principal must match $GATE_HOST (eitri's | 142 | # The gate host cert's principal must match $GATE_HOST (eitri's |
| 122 | # ssh_gate_domain); each VM's host cert principal is the VM name. A mismatch | 143 | # ssh_gate_domain); each VM's host cert principal is its <tenant>.<vm-name> |
| 123 | # is a hard failure, not a prompt — that is the point. | 144 | # connect name, which eitri-ssh builds automatically from the mint response. |
| 145 | # A mismatch is a hard failure, not a prompt — that is the point. | ||
| 124 | GATE_HOST=${EITRI_GATE%%:*} | 146 | GATE_HOST=${EITRI_GATE%%:*} |
| 125 | GATE_PORT=${EITRI_GATE##*:} | 147 | GATE_PORT=${EITRI_GATE##*:} |
| 126 | [ "$GATE_PORT" = "$EITRI_GATE" ] && GATE_PORT=22 | 148 | [ "$GATE_PORT" = "$EITRI_GATE" ] && GATE_PORT=22 |
| @@ -135,4 +157,4 @@ exec ssh \ | |||
| 135 | -o StrictHostKeyChecking=yes \ | 157 | -o StrictHostKeyChecking=yes \ |
| 136 | -o "UserKnownHostsFile=$KNOWN_HOSTS" \ | 158 | -o "UserKnownHostsFile=$KNOWN_HOSTS" \ |
| 137 | -i "$KEY" \ | 159 | -i "$KEY" \ |
| 138 | "ubuntu@$VM" "$@" | 160 | "ubuntu@$TARGET" "$@" |
internal/agent/syncclient/client_test.go
| Old | New | ||
|---|---|---|---|
| @@ -182,7 +182,7 @@ func (h *serverHarness) stop() { | |||
| 182 | 182 | ||
| 183 | // enroll creates a host and returns a valid credential for it. | 183 | // enroll creates a host and returns a valid credential for it. |
| 184 | func (h *serverHarness) enroll() (hostID, cred string) { | 184 | func (h *serverHarness) enroll() (hostID, cred string) { |
| 185 | tok, _ := h.st.CreateEnrollmentToken() | 185 | tok, _ := h.st.CreateEnrollmentToken(store.DefaultTenant) |
| 186 | host, err := h.st.RedeemEnrollmentToken(tok, "h", "linux", "amd64", "cloudhv", "") | 186 | host, err := h.st.RedeemEnrollmentToken(tok, "h", "linux", "amd64", "cloudhv", "") |
| 187 | require.NoError(h.t, err) | 187 | require.NoError(h.t, err) |
| 188 | return host.ID, hosttoken.Mint(h.secret, host.ID, host.CredGeneration, time.Now()) | 188 | return host.ID, hosttoken.Mint(h.secret, host.ID, host.CredGeneration, time.Now()) |
internal/mcpserver/api_test.go
| Old | New | ||
|---|---|---|---|
| @@ -152,22 +152,24 @@ func TestMintUserCert(t *testing.T) { | |||
| 152 | require.NoError(t, cert.SignCert(rand.Reader, caSigner)) | 152 | require.NoError(t, cert.SignCert(rand.Reader, caSigner)) |
| 153 | json.NewEncoder(w).Encode(map[string]string{ | 153 | json.NewEncoder(w).Encode(map[string]string{ |
| 154 | "certificate": string(ssh.MarshalAuthorizedKey(cert)), | 154 | "certificate": string(ssh.MarshalAuthorizedKey(cert)), |
| 155 | "tenant": "default", | ||
| 155 | }) | 156 | }) |
| 156 | }) | 157 | }) |
| 157 | 158 | ||
| 158 | got, err := c.MintUserCert(t.Context(), userPub) | 159 | got, tenant, err := c.MintUserCert(t.Context(), userPub) |
| 159 | require.NoError(t, err) | 160 | require.NoError(t, err) |
| 160 | require.NotNil(t, got) | 161 | require.NotNil(t, got) |
| 161 | assert.Equal(t, uint64(1), got.Serial) | 162 | assert.Equal(t, uint64(1), got.Serial) |
| 162 | assert.Equal(t, []string{"ubuntu"}, got.ValidPrincipals) | 163 | assert.Equal(t, []string{"ubuntu"}, got.ValidPrincipals) |
| 163 | assert.Equal(t, userPub.Marshal(), got.Key.Marshal()) | 164 | assert.Equal(t, userPub.Marshal(), got.Key.Marshal()) |
| 165 | assert.Equal(t, "default", tenant, "mint must return the response tenant") | ||
| 164 | } | 166 | } |
| 165 | 167 | ||
| 166 | func TestMintUserCertErrorDoesNotLeakToken(t *testing.T) { | 168 | func TestMintUserCertErrorDoesNotLeakToken(t *testing.T) { |
| 167 | c := fakeAPI(t, func(w http.ResponseWriter, r *http.Request) { | 169 | c := fakeAPI(t, func(w http.ResponseWriter, r *http.Request) { |
| 168 | http.Error(w, "internal error", http.StatusInternalServerError) | 170 | http.Error(w, "internal error", http.StatusInternalServerError) |
| 169 | }) | 171 | }) |
| 170 | _, err := c.MintUserCert(t.Context(), genTestKey(t)) | 172 | _, _, err := c.MintUserCert(t.Context(), genTestKey(t)) |
| 171 | require.Error(t, err) | 173 | require.Error(t, err) |
| 172 | assert.NotContains(t, err.Error(), "tok123", "token must never leak into errors") | 174 | assert.NotContains(t, err.Error(), "tok123", "token must never leak into errors") |
| 173 | } | 175 | } |
| @@ -179,6 +181,6 @@ func TestMintUserCertRejectsNonCertResponse(t *testing.T) { | |||
| 179 | "certificate": string(ssh.MarshalAuthorizedKey(notACert)), | 181 | "certificate": string(ssh.MarshalAuthorizedKey(notACert)), |
| 180 | }) | 182 | }) |
| 181 | }) | 183 | }) |
| 182 | _, err := c.MintUserCert(t.Context(), genTestKey(t)) | 184 | _, _, err := c.MintUserCert(t.Context(), genTestKey(t)) |
| 183 | require.Error(t, err) | 185 | require.Error(t, err) |
| 184 | } | 186 | } |
internal/mcpserver/gateauth.go
| Old | New | ||
|---|---|---|---|
| @@ -5,6 +5,7 @@ import ( | |||
| 5 | "context" | 5 | "context" |
| 6 | "crypto/ed25519" | 6 | "crypto/ed25519" |
| 7 | "crypto/rand" | 7 | "crypto/rand" |
| 8 | "errors" | ||
| 8 | "fmt" | 9 | "fmt" |
| 9 | "sync" | 10 | "sync" |
| 10 | "time" | 11 | "time" |
| @@ -19,7 +20,7 @@ import ( | |||
| 19 | // server. | 20 | // server. |
| 20 | type CertAuthority interface { | 21 | type CertAuthority interface { |
| 21 | FetchSSHCA(ctx context.Context) (ssh.PublicKey, error) | 22 | FetchSSHCA(ctx context.Context) (ssh.PublicKey, error) |
| 22 | MintUserCert(ctx context.Context, pub ssh.PublicKey) (*ssh.Certificate, error) | 23 | MintUserCert(ctx context.Context, pub ssh.PublicKey) (*ssh.Certificate, string, error) |
| 23 | } | 24 | } |
| 24 | 25 | ||
| 25 | // GateAuth is a concurrency-safe credential cache for authenticating to the | 26 | // GateAuth is a concurrency-safe credential cache for authenticating to the |
| @@ -36,6 +37,7 @@ type GateAuth struct { | |||
| 36 | ca ssh.PublicKey // eitri SSH CA; fetched lazily, once | 37 | ca ssh.PublicKey // eitri SSH CA; fetched lazily, once |
| 37 | cert *ssh.Certificate | 38 | cert *ssh.Certificate |
| 38 | certSigner ssh.Signer // wraps cert + ephemeral; cached alongside cert | 39 | certSigner ssh.Signer // wraps cert + ephemeral; cached alongside cert |
| 40 | tenant string // minting principal's tenant; set alongside cert | ||
| 39 | } | 41 | } |
| 40 | 42 | ||
| 41 | // NewGateAuth constructs a GateAuth backed by api. If now is nil, time.Now | 43 | // NewGateAuth constructs a GateAuth backed by api. If now is nil, time.Now |
| @@ -68,7 +70,7 @@ func (g *GateAuth) Signer(ctx context.Context) (ssh.Signer, error) { | |||
| 68 | // minting so concurrent Signer callers reuse one in-flight request | 70 | // minting so concurrent Signer callers reuse one in-flight request |
| 69 | // rather than stampeding the CA with duplicate mints. Do not "fix" this | 71 | // rather than stampeding the CA with duplicate mints. Do not "fix" this |
| 70 | // into a per-call unlock — that reintroduces a thundering herd. | 72 | // into a per-call unlock — that reintroduces a thundering herd. |
| 71 | cert, err := g.api.MintUserCert(ctx, g.ephemeral.PublicKey()) | 73 | cert, tenant, err := g.api.MintUserCert(ctx, g.ephemeral.PublicKey()) |
| 72 | if err != nil { | 74 | if err != nil { |
| 73 | return nil, fmt.Errorf("minting user certificate: %w", err) | 75 | return nil, fmt.Errorf("minting user certificate: %w", err) |
| 74 | } | 76 | } |
| @@ -78,11 +80,27 @@ func (g *GateAuth) Signer(ctx context.Context) (ssh.Signer, error) { | |||
| 78 | } | 80 | } |
| 79 | g.cert = cert | 81 | g.cert = cert |
| 80 | g.certSigner = certSigner | 82 | g.certSigner = certSigner |
| 83 | g.tenant = tenant | ||
| 81 | } | 84 | } |
| 82 | 85 | ||
| 83 | return g.certSigner, nil | 86 | return g.certSigner, nil |
| 84 | } | 87 | } |
| 85 | 88 | ||
| 89 | // ConnectName returns the gate connect name for vmName — "<tenant>.<vmName>", | ||
| 90 | // the form the gate resolves and the VM's host-cert principal matches. Minting | ||
| 91 | // is ensured first (the tenant rides the mint response). | ||
| 92 | func (g *GateAuth) ConnectName(ctx context.Context, vmName string) (string, error) { | ||
| 93 | if _, err := g.Signer(ctx); err != nil { | ||
| 94 | return "", err | ||
| 95 | } | ||
| 96 | g.mu.Lock() | ||
| 97 | defer g.mu.Unlock() | ||
| 98 | if g.tenant == "" { | ||
| 99 | return "", errors.New("eitri server did not return a tenant at cert mint (pre-tenancy server?)") | ||
| 100 | } | ||
| 101 | return g.tenant + "." + vmName, nil | ||
| 102 | } | ||
| 103 | |||
| 86 | // needsMintLocked reports whether the cached cert is absent or expires | 104 | // needsMintLocked reports whether the cached cert is absent or expires |
| 87 | // within a minute of now(). Callers must hold g.mu. | 105 | // within a minute of now(). Callers must hold g.mu. |
| 88 | func (g *GateAuth) needsMintLocked() bool { | 106 | func (g *GateAuth) needsMintLocked() bool { |
internal/mcpserver/gateauth_test.go
| Old | New | ||
|---|---|---|---|
| @@ -48,7 +48,7 @@ func (f *fakeCertAuthority) FetchSSHCA(ctx context.Context) (ssh.PublicKey, erro | |||
| 48 | return f.caSigner.PublicKey(), nil | 48 | return f.caSigner.PublicKey(), nil |
| 49 | } | 49 | } |
| 50 | 50 | ||
| 51 | func (f *fakeCertAuthority) MintUserCert(ctx context.Context, pub ssh.PublicKey) (*ssh.Certificate, error) { | 51 | func (f *fakeCertAuthority) MintUserCert(ctx context.Context, pub ssh.PublicKey) (*ssh.Certificate, string, error) { |
| 52 | f.mu.Lock() | 52 | f.mu.Lock() |
| 53 | validBefore := f.nextValidBefore | 53 | validBefore := f.nextValidBefore |
| 54 | f.mintCertCalls++ | 54 | f.mintCertCalls++ |
| @@ -56,7 +56,7 @@ func (f *fakeCertAuthority) MintUserCert(ctx context.Context, pub ssh.PublicKey) | |||
| 56 | f.mu.Unlock() | 56 | f.mu.Unlock() |
| 57 | 57 | ||
| 58 | if mintErr != nil { | 58 | if mintErr != nil { |
| 59 | return nil, mintErr | 59 | return nil, "", mintErr |
| 60 | } | 60 | } |
| 61 | 61 | ||
| 62 | cert := &ssh.Certificate{ | 62 | cert := &ssh.Certificate{ |
| @@ -67,9 +67,9 @@ func (f *fakeCertAuthority) MintUserCert(ctx context.Context, pub ssh.PublicKey) | |||
| 67 | ValidBefore: validBefore, | 67 | ValidBefore: validBefore, |
| 68 | } | 68 | } |
| 69 | if err := cert.SignCert(rand.Reader, f.caSigner); err != nil { | 69 | if err := cert.SignCert(rand.Reader, f.caSigner); err != nil { |
| 70 | return nil, err | 70 | return nil, "", err |
| 71 | } | 71 | } |
| 72 | return cert, nil | 72 | return cert, "default", nil |
| 73 | } | 73 | } |
| 74 | 74 | ||
| 75 | func (f *fakeCertAuthority) setNextValidBefore(v uint64) { | 75 | func (f *fakeCertAuthority) setNextValidBefore(v uint64) { |
internal/mcpserver/sshrun.go
| Old | New | ||
|---|---|---|---|
| @@ -19,11 +19,14 @@ import ( | |||
| 19 | // outputCap bounds captured exec/file bytes returned to the model. | 19 | // outputCap bounds captured exec/file bytes returned to the model. |
| 20 | const outputCap = 1 << 20 // 1 MiB | 20 | const outputCap = 1 << 20 // 1 MiB |
| 21 | 21 | ||
| 22 | // GateCredentials provides the cert-backed client signer and the CA host-key | 22 | // GateCredentials provides the cert-backed client signer, the CA host-key |
| 23 | // verifier the Runner authenticates with. *GateAuth satisfies it. | 23 | // verifier, and the gate connect name the Runner authenticates with. *GateAuth |
| 24 | // satisfies it. | ||
| 24 | type GateCredentials interface { | 25 | type GateCredentials interface { |
| 25 | Signer(ctx context.Context) (ssh.Signer, error) | 26 | Signer(ctx context.Context) (ssh.Signer, error) |
| 26 | HostKeyCallback() ssh.HostKeyCallback | 27 | HostKeyCallback() ssh.HostKeyCallback |
| 28 | // ConnectName maps a bare VM name to its <tenant>.<name> gate connect name. | ||
| 29 | ConnectName(ctx context.Context, vmName string) (string, error) | ||
| 27 | } | 30 | } |
| 28 | 31 | ||
| 29 | // RunnerConfig configures SSH access to VMs through the eitri SSH-CA jump gate. | 32 | // RunnerConfig configures SSH access to VMs through the eitri SSH-CA jump gate. |
| @@ -44,14 +47,23 @@ type ExecResult struct { | |||
| 44 | // Runner executes commands and transfers files on VMs over SSH, reaching each | 47 | // Runner executes commands and transfers files on VMs over SSH, reaching each |
| 45 | // VM by NAME through eitri's SSH-CA jump gate. There is no TOFU/known_hosts: | 48 | // VM by NAME through eitri's SSH-CA jump gate. There is no TOFU/known_hosts: |
| 46 | // both hops are verified against the eitri CA — the gate presents a CA-signed | 49 | // both hops are verified against the eitri CA — the gate presents a CA-signed |
| 47 | // host cert for its own domain, the VM a CA-signed host cert for its name — and | 50 | // host cert for its own domain, the VM a CA-signed host cert for its |
| 48 | // the client authenticates with a short-lived CA-signed user cert. | 51 | // <tenant>.<name> connect name — and the client authenticates with a |
| 52 | // short-lived CA-signed user cert. | ||
| 49 | type Runner struct { | 53 | type Runner struct { |
| 50 | cfg RunnerConfig | 54 | cfg RunnerConfig |
| 51 | } | 55 | } |
| 52 | 56 | ||
| 53 | func NewRunner(cfg RunnerConfig) *Runner { return &Runner{cfg: cfg} } | 57 | func NewRunner(cfg RunnerConfig) *Runner { return &Runner{cfg: cfg} } |
| 54 | 58 | ||
| 59 | // ConnectName returns the gate connect name for vmName — "<tenant>.<vmName>", | ||
| 60 | // the form the gate resolves and the VM's host-cert principal matches. It | ||
| 61 | // delegates to the gate credentials so the Tools layer can build a correct | ||
| 62 | // `ssh -J` hint without dialing. | ||
| 63 | func (r *Runner) ConnectName(ctx context.Context, vmName string) (string, error) { | ||
| 64 | return r.cfg.Auth.ConnectName(ctx, vmName) | ||
| 65 | } | ||
| 66 | |||
| 55 | // Exec runs cmd on the VM named vmName (reached through the gate). A non-zero | 67 | // Exec runs cmd on the VM named vmName (reached through the gate). A non-zero |
| 56 | // remote exit is NOT an error — it's in ExitCode. | 68 | // remote exit is NOT an error — it's in ExitCode. |
| 57 | func (r *Runner) Exec(ctx context.Context, vmName, cmd string, timeout time.Duration) (ExecResult, error) { | 69 | func (r *Runner) Exec(ctx context.Context, vmName, cmd string, timeout time.Duration) (ExecResult, error) { |
| @@ -94,16 +106,23 @@ func (r *Runner) Exec(ctx context.Context, vmName, cmd string, timeout time.Dura | |||
| 94 | 106 | ||
| 95 | // dial reaches the VM named vmName through the eitri SSH-CA gate: a client | 107 | // dial reaches the VM named vmName through the eitri SSH-CA gate: a client |
| 96 | // handshake with the gate (CA-verified host cert, CA-signed user cert), a | 108 | // handshake with the gate (CA-verified host cert, CA-signed user cert), a |
| 97 | // direct-tcpip tunnel to <vmName>:22 (the only port the gate permits), then a | 109 | // direct-tcpip tunnel to <tenant>.<vmName>:22 (the only port the gate permits), |
| 98 | // second handshake directly with the VM's sshd over that tunnel. Error messages | 110 | // then a second handshake directly with the VM's sshd over that tunnel. Error |
| 99 | // distinguish gate-unreachable/gate-handshake from VM-unreachable/VM-handshake. | 111 | // messages distinguish gate-unreachable/gate-handshake from |
| 100 | // The caller must Close the returned *ssh.Client. | 112 | // VM-unreachable/VM-handshake. The caller must Close the returned *ssh.Client. |
| 101 | func (r *Runner) dial(ctx context.Context, vmName string) (*ssh.Client, error) { | 113 | func (r *Runner) dial(ctx context.Context, vmName string) (*ssh.Client, error) { |
| 102 | signer, err := r.cfg.Auth.Signer(ctx) | 114 | signer, err := r.cfg.Auth.Signer(ctx) |
| 103 | if err != nil { | 115 | if err != nil { |
| 104 | return nil, fmt.Errorf("minting gate credentials: %w", err) | 116 | return nil, fmt.Errorf("minting gate credentials: %w", err) |
| 105 | } | 117 | } |
| 106 | hostCB := r.cfg.Auth.HostKeyCallback() | 118 | hostCB := r.cfg.Auth.HostKeyCallback() |
| 119 | // The gate resolves <tenant>.<name> and each VM's host-cert principal is that | ||
| 120 | // same namespaced name, so both the tunnel target and the VM host-cert | ||
| 121 | // verification address use it. | ||
| 122 | target, err := r.cfg.Auth.ConnectName(ctx, vmName) | ||
| 123 | if err != nil { | ||
| 124 | return nil, fmt.Errorf("building gate connect name: %w", err) | ||
| 125 | } | ||
| 107 | 126 | ||
| 108 | // Both hops share the same client config: the same CA-signed user cert | 127 | // Both hops share the same client config: the same CA-signed user cert |
| 109 | // authenticates to the gate and to the VM, and the same callback verifies | 128 | // authenticates to the gate and to the VM, and the same callback verifies |
| @@ -131,17 +150,18 @@ func (r *Runner) dial(ctx context.Context, vmName string) (*ssh.Client, error) { | |||
| 131 | } | 150 | } |
| 132 | gateClient := ssh.NewClient(gnc, gchans, greqs) | 151 | gateClient := ssh.NewClient(gnc, gchans, greqs) |
| 133 | 152 | ||
| 134 | // VM hop. Open the direct-tcpip tunnel to <vmName>:22 through the gate. A | 153 | // VM hop. Open the direct-tcpip tunnel to <tenant>.<vmName>:22 through the |
| 135 | // connection failure here is expected during the vm_create pre-sshd boot | 154 | // gate. A connection failure here is expected during the vm_create pre-sshd |
| 136 | // window (the guest hasn't started sshd yet), and the caller retries. | 155 | // boot window (the guest hasn't started sshd yet), and the caller retries. |
| 137 | vmAddr := vmName + ":22" | 156 | vmAddr := target + ":22" |
| 138 | vmConn, err := gateClient.DialContext(ctx, "tcp", vmAddr) | 157 | vmConn, err := gateClient.DialContext(ctx, "tcp", vmAddr) |
| 139 | if err != nil { | 158 | if err != nil { |
| 140 | gateClient.Close() | 159 | gateClient.Close() |
| 141 | return nil, fmt.Errorf("vm %s unreachable through the gate: %w", vmName, err) | 160 | return nil, fmt.Errorf("vm %s unreachable through the gate: %w", vmName, err) |
| 142 | } | 161 | } |
| 143 | // Verify the VM's host cert under <vmName>:22: its host-cert principal is the | 162 | // Verify the VM's host cert under <tenant>.<vmName>:22: its host-cert |
| 144 | // VM name, so ssh.CertChecker matches on the host portion of this address. | 163 | // principal is that namespaced name, so ssh.CertChecker matches on the host |
| 164 | // portion of this address. | ||
| 145 | nc, chans, reqs, err := ssh.NewClientConn(vmConn, vmAddr, clientConf) | 165 | nc, chans, reqs, err := ssh.NewClientConn(vmConn, vmAddr, clientConf) |
| 146 | if err != nil { | 166 | if err != nil { |
| 147 | gateClient.Close() | 167 | gateClient.Close() |
internal/mcpserver/sshrun_test.go
| Old | New | ||
|---|---|---|---|
| @@ -214,6 +214,9 @@ type fakeGateCreds struct { | |||
| 214 | 214 | ||
| 215 | func (f fakeGateCreds) Signer(context.Context) (ssh.Signer, error) { return f.signer, nil } | 215 | func (f fakeGateCreds) Signer(context.Context) (ssh.Signer, error) { return f.signer, nil } |
| 216 | func (f fakeGateCreds) HostKeyCallback() ssh.HostKeyCallback { return f.hostCB } | 216 | func (f fakeGateCreds) HostKeyCallback() ssh.HostKeyCallback { return f.hostCB } |
| 217 | func (f fakeGateCreds) ConnectName(_ context.Context, vmName string) (string, error) { | ||
| 218 | return "default." + vmName, nil | ||
| 219 | } | ||
| 217 | 220 | ||
| 218 | // ── tests ──────────────────────────────────────────────────────────────────── | 221 | // ── tests ──────────────────────────────────────────────────────────────────── |
| 219 | 222 | ||
| @@ -222,7 +225,7 @@ func TestExecThroughGate(t *testing.T) { | |||
| 222 | ga := NewGateAuth(fake, nil) | 225 | ga := NewGateAuth(fake, nil) |
| 223 | ca := fake.caSigner | 226 | ca := fake.caSigner |
| 224 | 227 | ||
| 225 | vmAddr := startBackingVM(t, hostCertSigner(t, ca, "testvm"), ca.PublicKey(), "hi\n", 0) | 228 | vmAddr := startBackingVM(t, hostCertSigner(t, ca, "default.testvm"), ca.PublicKey(), "hi\n", 0) |
| 226 | // Gate host cert principal "127.0.0.1" so the runner, dialing 127.0.0.1:<port>, | 229 | // Gate host cert principal "127.0.0.1" so the runner, dialing 127.0.0.1:<port>, |
| 227 | // verifies it under that host. | 230 | // verifies it under that host. |
| 228 | gateAddr := startGate(t, hostCertSigner(t, ca, "127.0.0.1"), ca.PublicKey(), vmAddr) | 231 | gateAddr := startGate(t, hostCertSigner(t, ca, "127.0.0.1"), ca.PublicKey(), vmAddr) |
| @@ -243,7 +246,7 @@ func TestExecVMForeignCAHostCertRejected(t *testing.T) { | |||
| 243 | // still trusts the real CA, so the gate hop and client auth both succeed and | 246 | // still trusts the real CA, so the gate hop and client auth both succeed and |
| 244 | // this isolates the VM host-cert rejection. | 247 | // this isolates the VM host-cert rejection. |
| 245 | foreignCA := newSigner(t) | 248 | foreignCA := newSigner(t) |
| 246 | vmAddr := startBackingVM(t, hostCertSigner(t, foreignCA, "testvm"), ca.PublicKey(), "hi\n", 0) | 249 | vmAddr := startBackingVM(t, hostCertSigner(t, foreignCA, "default.testvm"), ca.PublicKey(), "hi\n", 0) |
| 247 | gateAddr := startGate(t, hostCertSigner(t, ca, "127.0.0.1"), ca.PublicKey(), vmAddr) | 250 | gateAddr := startGate(t, hostCertSigner(t, ca, "127.0.0.1"), ca.PublicKey(), vmAddr) |
| 248 | 251 | ||
| 249 | r := NewRunner(RunnerConfig{Gate: gateAddr, Auth: ga, VMUser: "ubuntu"}) | 252 | r := NewRunner(RunnerConfig{Gate: gateAddr, Auth: ga, VMUser: "ubuntu"}) |
| @@ -257,7 +260,7 @@ func TestExecGateRejectsNonCAUserKey(t *testing.T) { | |||
| 257 | ga := NewGateAuth(fake, nil) | 260 | ga := NewGateAuth(fake, nil) |
| 258 | ca := fake.caSigner | 261 | ca := fake.caSigner |
| 259 | 262 | ||
| 260 | vmAddr := startBackingVM(t, hostCertSigner(t, ca, "testvm"), ca.PublicKey(), "hi\n", 0) | 263 | vmAddr := startBackingVM(t, hostCertSigner(t, ca, "default.testvm"), ca.PublicKey(), "hi\n", 0) |
| 261 | gateAddr := startGate(t, hostCertSigner(t, ca, "127.0.0.1"), ca.PublicKey(), vmAddr) | 264 | gateAddr := startGate(t, hostCertSigner(t, ca, "127.0.0.1"), ca.PublicKey(), vmAddr) |
| 262 | 265 | ||
| 263 | // A plain (non-cert) client key: the gate only accepts CA-signed user certs, | 266 | // A plain (non-cert) client key: the gate only accepts CA-signed user certs, |
internal/mcpserver/tools.go
| Old | New | ||
|---|---|---|---|
| @@ -24,6 +24,9 @@ type runner interface { | |||
| 24 | Exec(ctx context.Context, vmName, cmd string, timeout time.Duration) (ExecResult, error) | 24 | Exec(ctx context.Context, vmName, cmd string, timeout time.Duration) (ExecResult, error) |
| 25 | WriteFile(ctx context.Context, vmName, path string, data []byte, mode fs.FileMode) error | 25 | WriteFile(ctx context.Context, vmName, path string, data []byte, mode fs.FileMode) error |
| 26 | ReadFile(ctx context.Context, vmName, path string) ([]byte, bool, error) | 26 | ReadFile(ctx context.Context, vmName, path string) ([]byte, bool, error) |
| 27 | // ConnectName maps a bare VM name to its <tenant>.<name> gate connect name, | ||
| 28 | // so the ssh_command hint dials the form the gate actually accepts. | ||
| 29 | ConnectName(ctx context.Context, vmName string) (string, error) | ||
| 27 | } | 30 | } |
| 28 | 31 | ||
| 29 | // API is the shared eitri API client plus the one piece of MCP placement | 32 | // API is the shared eitri API client plus the one piece of MCP placement |
| @@ -153,7 +156,7 @@ func (t *Tools) VMCreate(ctx context.Context, in VMCreateIn) (VMCreateOut, error | |||
| 153 | return out, err | 156 | return out, err |
| 154 | } | 157 | } |
| 155 | out.IP = ip | 158 | out.IP = ip |
| 156 | out.SSHCommand = t.sshCommand(created.Name) | 159 | out.SSHCommand = t.sshCommand(ctx, created.Name) |
| 157 | // "ready" means cloud-hypervisor is up and the IP is ALLOCATED — NOT that the | 160 | // "ready" means cloud-hypervisor is up and the IP is ALLOCATED — NOT that the |
| 158 | // guest has booted Linux, brought up its NIC, and started sshd. The first SSH | 161 | // guest has booted Linux, brought up its NIC, and started sshd. The first SSH |
| 159 | // dials can therefore hit "no route to host"/"connection refused" while the | 162 | // dials can therefore hit "no route to host"/"connection refused" while the |
| @@ -254,11 +257,21 @@ func (t *Tools) resolveHost(ctx context.Context, name string) (string, error) { | |||
| 254 | return h.ID, nil | 257 | return h.ID, nil |
| 255 | } | 258 | } |
| 256 | 259 | ||
| 257 | func (t *Tools) sshCommand(name string) string { | 260 | func (t *Tools) sshCommand(ctx context.Context, name string) string { |
| 258 | if t.Gate != "" { | 261 | if t.Gate == "" { |
| 259 | return fmt.Sprintf("ssh -J %s %s@%s", t.Gate, t.VMUser, name) | 262 | // No gate: reach the VM directly by name/IP (a different deployment |
| 263 | // shape). Unchanged. | ||
| 264 | return fmt.Sprintf("ssh %s@%s", t.VMUser, name) | ||
| 265 | } | ||
| 266 | // The gate rejects a bare VM name — VMs are dialed by their <tenant>.<name> | ||
| 267 | // connect name (also the VM's host-cert principal), resolved via the same | ||
| 268 | // gate auth the Runner uses. If it can't be resolved (e.g. a mint failure) | ||
| 269 | // omit the hint rather than emit the bare form the gate would reject. | ||
| 270 | target, err := t.Runner.ConnectName(ctx, name) | ||
| 271 | if err != nil { | ||
| 272 | return "" | ||
| 260 | } | 273 | } |
| 261 | return fmt.Sprintf("ssh %s@%s", t.VMUser, name) | 274 | return fmt.Sprintf("ssh -J %s %s@%s", t.Gate, t.VMUser, target) |
| 262 | } | 275 | } |
| 263 | 276 | ||
| 264 | // ── vm_list / vm_info ──────────────────────────────────────────────────────── | 277 | // ── vm_list / vm_info ──────────────────────────────────────────────────────── |
| @@ -293,7 +306,7 @@ func (t *Tools) VMInfo(ctx context.Context, in VMInfoIn) (VMInfoOut, error) { | |||
| 293 | } | 306 | } |
| 294 | out := VMInfoOut{VM: vm} | 307 | out := VMInfoOut{VM: vm} |
| 295 | if vm.Lifecycle == "ready" { | 308 | if vm.Lifecycle == "ready" { |
| 296 | out.SSHCommand = t.sshCommand(vm.Name) | 309 | out.SSHCommand = t.sshCommand(ctx, vm.Name) |
| 297 | } | 310 | } |
| 298 | return out, nil | 311 | return out, nil |
| 299 | } | 312 | } |
internal/mcpserver/tools_test.go
| Old | New | ||
|---|---|---|---|
| @@ -93,6 +93,12 @@ func (f *fakeRunner) ReadFile(ctx context.Context, vmName, p string) ([]byte, bo | |||
| 93 | return d, false, nil | 93 | return d, false, nil |
| 94 | } | 94 | } |
| 95 | 95 | ||
| 96 | // ConnectName mirrors GateAuth: the gate connect name is <tenant>.<vmName>, and | ||
| 97 | // the fake's tenant is "default" (as the fake CertAuthority returns). | ||
| 98 | func (f *fakeRunner) ConnectName(ctx context.Context, vmName string) (string, error) { | ||
| 99 | return "default." + vmName, nil | ||
| 100 | } | ||
| 101 | |||
| 96 | func newTestTools(api *fakeToolsAPI, r *fakeRunner) *Tools { | 102 | func newTestTools(api *fakeToolsAPI, r *fakeRunner) *Tools { |
| 97 | return &Tools{ | 103 | return &Tools{ |
| 98 | API: api, Runner: r, | 104 | API: api, Runner: r, |
| @@ -112,7 +118,8 @@ func TestCreateWaitsForReadyAndCloudInit(t *testing.T) { | |||
| 112 | assert.Equal(t, "new1", out.ID) | 118 | assert.Equal(t, "new1", out.ID) |
| 113 | assert.Equal(t, "10.77.1.9", out.IP) | 119 | assert.Equal(t, "10.77.1.9", out.IP) |
| 114 | assert.Contains(t, out.SSHCommand, "-J localhost:2223") | 120 | assert.Contains(t, out.SSHCommand, "-J localhost:2223") |
| 115 | assert.Contains(t, out.SSHCommand, "ubuntu@claude-abc") | 121 | assert.Contains(t, out.SSHCommand, "ubuntu@default.claude-abc", |
| 122 | "hint must dial the namespaced <tenant>.<name>, not the bare name the gate rejects") | ||
| 116 | 123 | ||
| 117 | require.Len(t, api.created, 1) | 124 | require.Len(t, api.created, 1) |
| 118 | req := api.created[0] | 125 | req := api.created[0] |
| @@ -283,6 +290,29 @@ func TestReadFileNotReadyVMRejectedWithoutCallingRunner(t *testing.T) { | |||
| 283 | assert.Empty(t, run.reads, "runner must not be called on a non-ready VM") | 290 | assert.Empty(t, run.reads, "runner must not be called on a non-ready VM") |
| 284 | } | 291 | } |
| 285 | 292 | ||
| 293 | // TestVMInfoHintNamespacesConnectName pins that vm_info's ssh_command hint dials | ||
| 294 | // the gate by the <tenant>.<name> connect name (the gate rejects a bare name), | ||
| 295 | // and that the gateless deployment still emits the plain `ssh <user>@<name>`. | ||
| 296 | func TestVMInfoHintNamespacesConnectName(t *testing.T) { | ||
| 297 | api := &fakeToolsAPI{vms: []client.VM{{ID: "abc123", Name: "web-1", Lifecycle: "ready", AssignedIP: "10.77.1.5"}}} | ||
| 298 | |||
| 299 | // Gate configured: hint must be the namespaced -J form. | ||
| 300 | tl := newTestTools(api, &fakeRunner{}) | ||
| 301 | out, err := tl.VMInfo(t.Context(), VMInfoIn{VM: "web-1"}) | ||
| 302 | require.NoError(t, err) | ||
| 303 | assert.Contains(t, out.SSHCommand, "-J localhost:2223") | ||
| 304 | assert.Contains(t, out.SSHCommand, "ubuntu@default.web-1", | ||
| 305 | "gate hint must dial <tenant>.<name>, not the bare name") | ||
| 306 | assert.NotContains(t, out.SSHCommand, "ubuntu@web-1", | ||
| 307 | "hint must not contain the bare name the gate rejects") | ||
| 308 | |||
| 309 | // Gateless deployment: the direct `ssh <user>@<name>` form is unchanged. | ||
| 310 | tl.Gate = "" | ||
| 311 | out, err = tl.VMInfo(t.Context(), VMInfoIn{VM: "web-1"}) | ||
| 312 | require.NoError(t, err) | ||
| 313 | assert.Equal(t, "ssh ubuntu@web-1", out.SSHCommand) | ||
| 314 | } | ||
| 315 | |||
| 286 | func TestDestroyRequiresExactMatch(t *testing.T) { | 316 | func TestDestroyRequiresExactMatch(t *testing.T) { |
| 287 | api := &fakeToolsAPI{vms: []client.VM{{ID: "abc123", Name: "web-1"}}} | 317 | api := &fakeToolsAPI{vms: []client.VM{{ID: "abc123", Name: "web-1"}}} |
| 288 | tl := newTestTools(api, &fakeRunner{}) | 318 | tl := newTestTools(api, &fakeRunner{}) |
internal/server/api/api.go
| Old | New | ||
|---|---|---|---|
| @@ -155,7 +155,11 @@ func (a *API) adminAuth(next http.Handler) http.Handler { | |||
| 155 | http.Error(w, "unauthorized", http.StatusUnauthorized) | 155 | http.Error(w, "unauthorized", http.StatusUnauthorized) |
| 156 | return | 156 | return |
| 157 | } | 157 | } |
| 158 | next.ServeHTTP(w, r) | 158 | // The token maps to the bootstrap principal: the default tenant's |
| 159 | // operator, with the fleet bit. Multi-user auth replaces this constant | ||
| 160 | // with a real token→principal lookup; nothing downstream changes. | ||
| 161 | p := Principal{Tenant: store.DefaultTenant, Fleet: true} | ||
| 162 | next.ServeHTTP(w, r.WithContext(withPrincipal(r.Context(), p))) | ||
| 159 | }) | 163 | }) |
| 160 | } | 164 | } |
| 161 | 165 | ||
| @@ -248,7 +252,10 @@ func (a *API) handleEnroll(w http.ResponseWriter, r *http.Request) { | |||
| 248 | } | 252 | } |
| 249 | 253 | ||
| 250 | func (a *API) handleCreateEnrollToken(w http.ResponseWriter, r *http.Request) { | 254 | func (a *API) handleCreateEnrollToken(w http.ResponseWriter, r *http.Request) { |
| 251 | tok, err := a.st.CreateEnrollmentToken() | 255 | if !a.requireFleet(w, r) { |
| 256 | return | ||
| 257 | } | ||
| 258 | tok, err := a.st.CreateEnrollmentToken(principalFromContext(r).Tenant) | ||
| 252 | if err != nil { | 259 | if err != nil { |
| 253 | http.Error(w, "internal error", http.StatusInternalServerError) | 260 | http.Error(w, "internal error", http.StatusInternalServerError) |
| 254 | return | 261 | return |
| @@ -328,7 +335,7 @@ func (a *API) fetchStates(ids ...[]string) map[string]regState { | |||
| 328 | 335 | ||
| 329 | // snapshotHosts builds the wire host list (durable host rows merged with live | 336 | // snapshotHosts builds the wire host list (durable host rows merged with live |
| 330 | // registry state and server-computed allocation) for GET /hosts. | 337 | // registry state and server-computed allocation) for GET /hosts. |
| 331 | func (a *API) snapshotHosts() ([]types.Host, error) { | 338 | func (a *API) snapshotHosts(p Principal) ([]types.Host, error) { |
| 332 | // Single-tx read: hosts and alloc must not mix two epochs (same property | 339 | // Single-tx read: hosts and alloc must not mix two epochs (same property |
| 333 | // the SSE stream needs; the unused vms read is cheap on these small | 340 | // the SSE stream needs; the unused vms read is cheap on these small |
| 334 | // control-plane tables). | 341 | // control-plane tables). |
| @@ -336,6 +343,7 @@ func (a *API) snapshotHosts() ([]types.Host, error) { | |||
| 336 | if err != nil { | 343 | if err != nil { |
| 337 | return nil, err | 344 | return nil, err |
| 338 | } | 345 | } |
| 346 | hosts = filterHosts(p, hosts) | ||
| 339 | return a.buildHostResponses(hosts, alloc, a.fetchStates(hostIDs(hosts))), nil | 347 | return a.buildHostResponses(hosts, alloc, a.fetchStates(hostIDs(hosts))), nil |
| 340 | } | 348 | } |
| 341 | 349 | ||
| @@ -369,7 +377,7 @@ func (a *API) buildHostResponses(hosts []store.Host, alloc map[string]store.Allo | |||
| 369 | } | 377 | } |
| 370 | 378 | ||
| 371 | func (a *API) handleListHosts(w http.ResponseWriter, r *http.Request) { | 379 | func (a *API) handleListHosts(w http.ResponseWriter, r *http.Request) { |
| 372 | out, err := a.snapshotHosts() | 380 | out, err := a.snapshotHosts(principalFromContext(r)) |
| 373 | if err != nil { | 381 | if err != nil { |
| 374 | http.Error(w, "internal error", http.StatusInternalServerError) | 382 | http.Error(w, "internal error", http.StatusInternalServerError) |
| 375 | return | 383 | return |
| @@ -438,11 +446,12 @@ func deriveLifecycle(vm store.VM, actualPower, phase string) string { | |||
| 438 | // snapshotVMs builds the wire VM list (durable VM rows merged with live | 446 | // snapshotVMs builds the wire VM list (durable VM rows merged with live |
| 439 | // actual-state from the registry). Used by GET /vms; the SSE stream uses | 447 | // actual-state from the registry). Used by GET /vms; the SSE stream uses |
| 440 | // buildVMResponses over a single-tx store.Snapshot instead. | 448 | // buildVMResponses over a single-tx store.Snapshot instead. |
| 441 | func (a *API) snapshotVMs() ([]types.VM, error) { | 449 | func (a *API) snapshotVMs(p Principal) ([]types.VM, error) { |
| 442 | vms, err := a.st.ListVMs() | 450 | vms, err := a.st.ListVMs() |
| 443 | if err != nil { | 451 | if err != nil { |
| 444 | return nil, err | 452 | return nil, err |
| 445 | } | 453 | } |
| 454 | vms = filterVMs(p, vms) | ||
| 446 | return a.buildVMResponses(vms, a.fetchStates(vmHostIDs(vms))), nil | 455 | return a.buildVMResponses(vms, a.fetchStates(vmHostIDs(vms))), nil |
| 447 | } | 456 | } |
| 448 | 457 | ||
| @@ -476,7 +485,7 @@ func (a *API) buildVMResponses(vms []store.VM, states map[string]regState) []typ | |||
| 476 | } | 485 | } |
| 477 | 486 | ||
| 478 | func (a *API) handleListVMs(w http.ResponseWriter, r *http.Request) { | 487 | func (a *API) handleListVMs(w http.ResponseWriter, r *http.Request) { |
| 479 | out, err := a.snapshotVMs() | 488 | out, err := a.snapshotVMs(principalFromContext(r)) |
| 480 | if err != nil { | 489 | if err != nil { |
| 481 | http.Error(w, "internal error", http.StatusInternalServerError) | 490 | http.Error(w, "internal error", http.StatusInternalServerError) |
| 482 | return | 491 | return |
| @@ -560,6 +569,24 @@ func (a *API) handleCreateVM(w http.ResponseWriter, r *http.Request) { | |||
| 560 | return | 569 | return |
| 561 | } | 570 | } |
| 562 | 571 | ||
| 572 | // Tenant gate: you may only place VMs on hosts in your tenant. The host | ||
| 573 | // read also feeds the namespaced host-cert principal below. CreateVM | ||
| 574 | // re-checks host status in-tx; this pre-read is the AUTHZ point. | ||
| 575 | host, err := a.st.GetHost(req.HostID) | ||
| 576 | if err != nil { | ||
| 577 | if errors.Is(err, sql.ErrNoRows) { | ||
| 578 | http.Error(w, "unknown host_id", http.StatusBadRequest) | ||
| 579 | return | ||
| 580 | } | ||
| 581 | http.Error(w, "internal error", http.StatusInternalServerError) | ||
| 582 | return | ||
| 583 | } | ||
| 584 | if !mayActAs(principalFromContext(r), host.Tenant) { | ||
| 585 | // Indistinguishable from absent: don't confirm foreign hosts exist. | ||
| 586 | http.Error(w, "unknown host_id", http.StatusBadRequest) | ||
| 587 | return | ||
| 588 | } | ||
| 589 | |||
| 563 | // Install the SSH key into user-supplied cloud-init. When only one of the | 590 | // Install the SSH key into user-supplied cloud-init. When only one of the |
| 564 | // two is set the seed builder handles it (verbatim user-data, or the | 591 | // two is set the seed builder handles it (verbatim user-data, or the |
| 565 | // generated default template); it's the BOTH case that used to silently | 592 | // generated default template); it's the BOTH case that used to silently |
| @@ -605,12 +632,16 @@ func (a *API) handleCreateVM(w http.ResponseWriter, r *http.Request) { | |||
| 605 | } | 632 | } |
| 606 | 633 | ||
| 607 | // When the jump gate is enabled, mint a persistent per-VM host key + CA-signed | 634 | // When the jump gate is enabled, mint a persistent per-VM host key + CA-signed |
| 608 | // host cert (principal = VM name) once at create, so the VM presents a | 635 | // host cert once at create, so the VM presents a verifiable host key clients |
| 609 | // verifiable host key clients accept via `@cert-authority` — no TOFU, no | 636 | // accept via `@cert-authority` — no TOFU, no host-key-changed warnings when |
| 610 | // host-key-changed warnings when names/IPs recycle. The private key is | 637 | // names/IPs recycle. The private key is WRITE-ONLY: stored, shipped to the |
| 611 | // WRITE-ONLY: stored, shipped to the guest via seed, never echoed or logged. | 638 | // guest via seed, never echoed or logged. |
| 639 | // | ||
| 640 | // The cert principal is <tenant>.<name>: under (tenant,name) uniqueness a bare | ||
| 641 | // name is no longer unique across tenants (spec §F6), so clients dial and | ||
| 642 | // verify VMs by their namespaced connect name. | ||
| 612 | if a.hostCerts != nil { | 643 | if a.hostCerts != nil { |
| 613 | keyPEM, cert, err := a.hostCerts.MintHostCert(req.Name) | 644 | keyPEM, cert, err := a.hostCerts.MintHostCert(host.Tenant + "." + req.Name) |
| 614 | if err != nil { | 645 | if err != nil { |
| 615 | http.Error(w, "internal error", http.StatusInternalServerError) | 646 | http.Error(w, "internal error", http.StatusInternalServerError) |
| 616 | return | 647 | return |
| @@ -640,17 +671,22 @@ func (a *API) handleCreateVM(w http.ResponseWriter, r *http.Request) { | |||
| 640 | } | 671 | } |
| 641 | 672 | ||
| 642 | // mutateVM implements the choreography shared by every VM mutation endpoint | 673 | // mutateVM implements the choreography shared by every VM mutation endpoint |
| 643 | // (patch/delete/restore): run mutate; a sql.ErrNoRows becomes the caller's | 674 | // (patch/delete/restore): check ownership, then run mutate; a sql.ErrNoRows |
| 644 | // not-found response, any other error a generic 500. On success the row | 675 | // becomes the caller's not-found response, any other error a generic 500. On |
| 645 | // always survives the mutation (power change, tombstone, and restore all | 676 | // success an audit row is appended and the host's desired-state stream poked |
| 646 | // keep it), so it is scanned for name/host: a best-effort audit row is | 677 | // from the pre-read row (Name/HostID are immutable under every mutation, so |
| 647 | // appended and the host's desired-state stream poked (a lookup miss is | 678 | // no post-mutate re-fetch is needed — audit/poke fire even if the row |
| 648 | // non-fatal — the mutation already succeeded). Finally SSE watchers are | 679 | // vanishes post-mutate, since the mutation itself already succeeded). |
| 649 | // notified and the response is written as 204, unconditionally. | 680 | // Finally SSE watchers are notified and the response is written as 204, |
| 650 | func (a *API) mutateVM(w http.ResponseWriter, id string, mutate func(id string) error, | 681 | // unconditionally. |
| 682 | // | ||
| 683 | // Ownership is gated before any mutation runs: a foreign-tenant VM answers | ||
| 684 | // exactly like a missing one — existence is not leaked across tenants. | ||
| 685 | func (a *API) mutateVM(w http.ResponseWriter, r *http.Request, id string, mutate func(id string) error, | ||
| 651 | notFoundMsg string, notFoundStatus int, | 686 | notFoundMsg string, notFoundStatus int, |
| 652 | auditAction string, auditDetail func(vm store.VM) map[string]string) { | 687 | auditAction string, auditDetail func(vm store.VM) map[string]string) { |
| 653 | if err := mutate(id); err != nil { | 688 | vm, err := a.st.GetVM(id) |
| 689 | if err != nil { | ||
| 654 | if errors.Is(err, sql.ErrNoRows) { | 690 | if errors.Is(err, sql.ErrNoRows) { |
| 655 | http.Error(w, notFoundMsg, notFoundStatus) | 691 | http.Error(w, notFoundMsg, notFoundStatus) |
| 656 | return | 692 | return |
| @@ -658,10 +694,20 @@ func (a *API) mutateVM(w http.ResponseWriter, id string, mutate func(id string) | |||
| 658 | http.Error(w, "internal error", http.StatusInternalServerError) | 694 | http.Error(w, "internal error", http.StatusInternalServerError) |
| 659 | return | 695 | return |
| 660 | } | 696 | } |
| 661 | if vm, ok := a.vmByID(id); ok { | 697 | if !mayActAs(principalFromContext(r), vm.Tenant) { |
| 662 | a.audit(auditAction, auditDetail(vm)) | 698 | http.Error(w, notFoundMsg, notFoundStatus) |
| 663 | a.hub.Poke(vm.HostID) | 699 | return |
| 700 | } | ||
| 701 | if err := mutate(id); err != nil { | ||
| 702 | if errors.Is(err, sql.ErrNoRows) { | ||
| 703 | http.Error(w, notFoundMsg, notFoundStatus) | ||
| 704 | return | ||
| 705 | } | ||
| 706 | http.Error(w, "internal error", http.StatusInternalServerError) | ||
| 707 | return | ||
| 664 | } | 708 | } |
| 709 | a.audit(auditAction, auditDetail(vm)) | ||
| 710 | a.hub.Poke(vm.HostID) | ||
| 665 | a.notif.notify() | 711 | a.notif.notify() |
| 666 | w.WriteHeader(http.StatusNoContent) | 712 | w.WriteHeader(http.StatusNoContent) |
| 667 | } | 713 | } |
| @@ -676,7 +722,7 @@ func (a *API) handlePatchVM(w http.ResponseWriter, r *http.Request) { | |||
| 676 | http.Error(w, "power_state must be running or stopped", http.StatusBadRequest) | 722 | http.Error(w, "power_state must be running or stopped", http.StatusBadRequest) |
| 677 | return | 723 | return |
| 678 | } | 724 | } |
| 679 | a.mutateVM(w, id, func(id string) error { return a.st.SetVMPower(id, req.PowerState) }, | 725 | a.mutateVM(w, r, id, func(id string) error { return a.st.SetVMPower(id, req.PowerState) }, |
| 680 | "not found", http.StatusNotFound, | 726 | "not found", http.StatusNotFound, |
| 681 | "vm.power", func(vm store.VM) map[string]string { | 727 | "vm.power", func(vm store.VM) map[string]string { |
| 682 | return map[string]string{"vm_id": id, "name": vm.Name, "power": req.PowerState} | 728 | return map[string]string{"vm_id": id, "name": vm.Name, "power": req.PowerState} |
| @@ -685,7 +731,7 @@ func (a *API) handlePatchVM(w http.ResponseWriter, r *http.Request) { | |||
| 685 | 731 | ||
| 686 | func (a *API) handleDeleteVM(w http.ResponseWriter, r *http.Request) { | 732 | func (a *API) handleDeleteVM(w http.ResponseWriter, r *http.Request) { |
| 687 | id := r.PathValue("id") | 733 | id := r.PathValue("id") |
| 688 | a.mutateVM(w, id, a.st.TombstoneVM, | 734 | a.mutateVM(w, r, id, a.st.TombstoneVM, |
| 689 | "not found", http.StatusNotFound, | 735 | "not found", http.StatusNotFound, |
| 690 | "vm.delete", func(vm store.VM) map[string]string { | 736 | "vm.delete", func(vm store.VM) map[string]string { |
| 691 | return map[string]string{"vm_id": id, "name": vm.Name} | 737 | return map[string]string{"vm_id": id, "name": vm.Name} |
| @@ -698,7 +744,7 @@ func (a *API) handleDeleteVM(w http.ResponseWriter, r *http.Request) { | |||
| 698 | // converges it back toward its power_state — so the server only pokes. | 744 | // converges it back toward its power_state — so the server only pokes. |
| 699 | func (a *API) handleRestoreVM(w http.ResponseWriter, r *http.Request) { | 745 | func (a *API) handleRestoreVM(w http.ResponseWriter, r *http.Request) { |
| 700 | id := r.PathValue("id") | 746 | id := r.PathValue("id") |
| 701 | a.mutateVM(w, id, a.st.RestoreVM, | 747 | a.mutateVM(w, r, id, a.st.RestoreVM, |
| 702 | "vm not restorable (already destroyed or not deleted)", http.StatusConflict, | 748 | "vm not restorable (already destroyed or not deleted)", http.StatusConflict, |
| 703 | "vm.restore", func(vm store.VM) map[string]string { | 749 | "vm.restore", func(vm store.VM) map[string]string { |
| 704 | return map[string]string{"vm_id": id, "name": vm.Name} | 750 | return map[string]string{"vm_id": id, "name": vm.Name} |
internal/server/api/client/client.go
| Old | New | ||
|---|---|---|---|
| @@ -168,20 +168,21 @@ func (c *Client) FetchSSHCA(ctx context.Context) (ssh.PublicKey, error) { | |||
| 168 | } | 168 | } |
| 169 | 169 | ||
| 170 | // MintUserCert asks the eitri server to mint a short-lived SSH user | 170 | // MintUserCert asks the eitri server to mint a short-lived SSH user |
| 171 | // certificate for pub, signed by the server's CA. | 171 | // certificate for pub, signed by the server's CA. It returns the cert and the |
| 172 | func (c *Client) MintUserCert(ctx context.Context, pub ssh.PublicKey) (*ssh.Certificate, error) { | 172 | // minting principal's tenant: clients dial VMs as <tenant>.<name>. |
| 173 | func (c *Client) MintUserCert(ctx context.Context, pub ssh.PublicKey) (*ssh.Certificate, string, error) { | ||
| 173 | req := types.SSHCertRequest{PublicKey: strings.TrimSpace(string(ssh.MarshalAuthorizedKey(pub)))} | 174 | req := types.SSHCertRequest{PublicKey: strings.TrimSpace(string(ssh.MarshalAuthorizedKey(pub)))} |
| 174 | var out types.SSHCertResponse | 175 | var out types.SSHCertResponse |
| 175 | if err := c.do(ctx, http.MethodPost, "/api/v1/ssh-certs", req, &out); err != nil { | 176 | if err := c.do(ctx, http.MethodPost, "/api/v1/ssh-certs", req, &out); err != nil { |
| 176 | return nil, err | 177 | return nil, "", err |
| 177 | } | 178 | } |
| 178 | parsed, _, _, _, err := ssh.ParseAuthorizedKey([]byte(out.Certificate)) | 179 | parsed, _, _, _, err := ssh.ParseAuthorizedKey([]byte(out.Certificate)) |
| 179 | if err != nil { | 180 | if err != nil { |
| 180 | return nil, fmt.Errorf("client: parsing minted certificate: %w", err) | 181 | return nil, "", fmt.Errorf("client: parsing minted certificate: %w", err) |
| 181 | } | 182 | } |
| 182 | cert, ok := parsed.(*ssh.Certificate) | 183 | cert, ok := parsed.(*ssh.Certificate) |
| 183 | if !ok { | 184 | if !ok { |
| 184 | return nil, errors.New("client: server response is not an SSH certificate") | 185 | return nil, "", errors.New("client: server response is not an SSH certificate") |
| 185 | } | 186 | } |
| 186 | return cert, nil | 187 | return cert, out.Tenant, nil |
| 187 | } | 188 | } |
internal/server/api/console.go
| Old | New | ||
|---|---|---|---|
| @@ -31,6 +31,9 @@ const consoleOpenTimeout = 10 * time.Second | |||
| 31 | // admin-authenticated POST /api/v1/stream-tickets (same mechanism as SSE) — | 31 | // admin-authenticated POST /api/v1/stream-tickets (same mechanism as SSE) — |
| 32 | // the admin token never appears in a URL. | 32 | // the admin token never appears in a URL. |
| 33 | func (a *API) handleConsoleWS(w http.ResponseWriter, r *http.Request) { | 33 | func (a *API) handleConsoleWS(w http.ResponseWriter, r *http.Request) { |
| 34 | // The console WS is ticket-authed and tickets carry no principal yet — | ||
| 35 | // per-tenant console isolation is a recorded tenant-#2 blocker (spec | ||
| 36 | // §Deferred), stated here rather than implied. | ||
| 34 | if !a.tickets.consume(r.URL.Query().Get("ticket")) { | 37 | if !a.tickets.consume(r.URL.Query().Get("ticket")) { |
| 35 | http.Error(w, "unauthorized", http.StatusUnauthorized) | 38 | http.Error(w, "unauthorized", http.StatusUnauthorized) |
| 36 | return | 39 | return |
internal/server/api/events.go
| Old | New | ||
|---|---|---|---|
| @@ -24,6 +24,9 @@ import ( | |||
| 24 | // is gone with the box), reclaiming the CIDR without waiting for a drain that | 24 | // is gone with the box), reclaiming the CIDR without waiting for a drain that |
| 25 | // can never happen. | 25 | // can never happen. |
| 26 | func (a *API) handleDecommissionHost(w http.ResponseWriter, r *http.Request) { | 26 | func (a *API) handleDecommissionHost(w http.ResponseWriter, r *http.Request) { |
| 27 | if !a.requireFleet(w, r) { | ||
| 28 | return | ||
| 29 | } | ||
| 27 | id := r.PathValue("id") | 30 | id := r.PathValue("id") |
| 28 | 31 | ||
| 29 | if forceParam(r) { | 32 | if forceParam(r) { |
| @@ -77,6 +80,9 @@ func forceParam(r *http.Request) bool { | |||
| 77 | // session is closed by syncsvc within one report tick; the host stays dark | 80 | // session is closed by syncsvc within one report tick; the host stays dark |
| 78 | // until the operator re-enrolls it with a fresh join blob. | 81 | // until the operator re-enrolls it with a fresh join blob. |
| 79 | func (a *API) handleRevokeCredential(w http.ResponseWriter, r *http.Request) { | 82 | func (a *API) handleRevokeCredential(w http.ResponseWriter, r *http.Request) { |
| 83 | if !a.requireFleet(w, r) { | ||
| 84 | return | ||
| 85 | } | ||
| 80 | id := r.PathValue("id") | 86 | id := r.PathValue("id") |
| 81 | // Audit is written inside the bump transaction (a security action must | 87 | // Audit is written inside the bump transaction (a security action must |
| 82 | // not be able to happen unrecorded). | 88 | // not be able to happen unrecorded). |
| @@ -126,7 +132,16 @@ func auditRowsToResponse(rows []store.AuditEntry) []types.AuditEvent { | |||
| 126 | // handleListAudit returns the newest audit rows (default 100, ?limit=N caps | 132 | // handleListAudit returns the newest audit rows (default 100, ?limit=N caps |
| 127 | // at 1000). Completes the forensic story: rows were previously reachable only | 133 | // at 1000). Completes the forensic story: rows were previously reachable only |
| 128 | // by opening the SQLite file. | 134 | // by opening the SQLite file. |
| 135 | // | ||
| 136 | // Audit read stays Fleet-gated (not per-tenant): audit_log has no tenant column | ||
| 137 | // and some rows are genuinely tenant-less — e.g. host.enroll.denied written from | ||
| 138 | // an UNAUTHENTICATED enroll attempt, before any tenant is known. Per-tenant | ||
| 139 | // audit is a recorded tenant-#2 blocker (spec §Deferred); stated here rather | ||
| 140 | // than implied. | ||
| 129 | func (a *API) handleListAudit(w http.ResponseWriter, r *http.Request) { | 141 | func (a *API) handleListAudit(w http.ResponseWriter, r *http.Request) { |
| 142 | if !a.requireFleet(w, r) { | ||
| 143 | return | ||
| 144 | } | ||
| 130 | limit, ok := parseLimit(w, r) | 145 | limit, ok := parseLimit(w, r) |
| 131 | if !ok { | 146 | if !ok { |
| 132 | return | 147 | return |
| @@ -231,6 +246,12 @@ func (a *API) marshalSnapshot() ([]byte, error) { | |||
| 231 | if err != nil { | 246 | if err != nil { |
| 232 | return nil, err | 247 | return nil, err |
| 233 | } | 248 | } |
| 249 | // SSE is ticket-authed and tickets carry no principal yet — per-tenant | ||
| 250 | // stream isolation is a recorded tenant-#2 blocker (spec §Deferred). The | ||
| 251 | // stream therefore remains fleet-wide, stated here rather than implied. | ||
| 252 | fleet := Principal{Fleet: true} | ||
| 253 | hosts = filterHosts(fleet, hosts) | ||
| 254 | vms = filterVMs(fleet, vms) | ||
| 234 | states := a.fetchStates(hostIDs(hosts), vmHostIDs(vms)) | 255 | states := a.fetchStates(hostIDs(hosts), vmHostIDs(vms)) |
| 235 | return json.Marshal(types.StateSnapshot{ | 256 | return json.Marshal(types.StateSnapshot{ |
| 236 | Hosts: a.buildHostResponses(hosts, alloc, states), | 257 | Hosts: a.buildHostResponses(hosts, alloc, states), |
internal/server/api/principal.go
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,88 @@ | |||
| 1 | package api | ||
| 2 | |||
| 3 | import ( | ||
| 4 | "context" | ||
| 5 | "net/http" | ||
| 6 | |||
| 7 | "github.com/a73x/eitri/internal/server/store" | ||
| 8 | ) | ||
| 9 | |||
| 10 | // Principal is the authenticated actor attached to every admin-API request by | ||
| 11 | // adminAuth. It is the F3 seam: today the bearer token maps to the single | ||
| 12 | // bootstrap principal; multi-user auth later changes only how a Principal is | ||
| 13 | // RESOLVED, never how handlers consume it. | ||
| 14 | type Principal struct { | ||
| 15 | // Tenant is the tenant scope this principal acts within. | ||
| 16 | Tenant string | ||
| 17 | // Fleet marks a fleet operator: may act across tenants and perform the | ||
| 18 | // fleet-level operations that have no per-tenant owner (enroll-token mint, | ||
| 19 | // host decommission, credential revocation, audit). It is the named form | ||
| 20 | // of the actor those operations already require — not a per-tenant admin | ||
| 21 | // bit, and not required for ordinary tenant-scoped access. | ||
| 22 | Fleet bool | ||
| 23 | } | ||
| 24 | |||
| 25 | // principalKey is the context key for the request principal. | ||
| 26 | type principalKey struct{} | ||
| 27 | |||
| 28 | // withPrincipal returns a request context carrying p. | ||
| 29 | func withPrincipal(ctx context.Context, p Principal) context.Context { | ||
| 30 | return context.WithValue(ctx, principalKey{}, p) | ||
| 31 | } | ||
| 32 | |||
| 33 | // principalFromContext returns the request's principal. A request that never | ||
| 34 | // passed adminAuth yields the zero Principal — no tenant, no fleet bit — so | ||
| 35 | // every check downstream fails closed. | ||
| 36 | func principalFromContext(r *http.Request) Principal { | ||
| 37 | p, _ := r.Context().Value(principalKey{}).(Principal) | ||
| 38 | return p | ||
| 39 | } | ||
| 40 | |||
| 41 | // mayActAs reports whether p may act on a resource owned by tenant. Strict | ||
| 42 | // scope-equality with a fleet override; an empty tenant on either side never | ||
| 43 | // matches (resources always have a tenant; a zero principal must not pair | ||
| 44 | // with a malformed resource via "" == ""). | ||
| 45 | func mayActAs(p Principal, tenant string) bool { | ||
| 46 | if p.Fleet { | ||
| 47 | return true | ||
| 48 | } | ||
| 49 | return tenant != "" && p.Tenant == tenant | ||
| 50 | } | ||
| 51 | |||
| 52 | // filterVMs returns only the VMs p may act on; Fleet sees the whole fleet. | ||
| 53 | func filterVMs(p Principal, vms []store.VM) []store.VM { | ||
| 54 | if p.Fleet { | ||
| 55 | return vms | ||
| 56 | } | ||
| 57 | out := make([]store.VM, 0, len(vms)) | ||
| 58 | for _, vm := range vms { | ||
| 59 | if mayActAs(p, vm.Tenant) { | ||
| 60 | out = append(out, vm) | ||
| 61 | } | ||
| 62 | } | ||
| 63 | return out | ||
| 64 | } | ||
| 65 | |||
| 66 | // filterHosts is filterVMs for hosts. | ||
| 67 | func filterHosts(p Principal, hosts []store.Host) []store.Host { | ||
| 68 | if p.Fleet { | ||
| 69 | return hosts | ||
| 70 | } | ||
| 71 | out := make([]store.Host, 0, len(hosts)) | ||
| 72 | for _, h := range hosts { | ||
| 73 | if mayActAs(p, h.Tenant) { | ||
| 74 | out = append(out, h) | ||
| 75 | } | ||
| 76 | } | ||
| 77 | return out | ||
| 78 | } | ||
| 79 | |||
| 80 | // requireFleet allows only fleet operators through; writes 403 and returns | ||
| 81 | // false otherwise. | ||
| 82 | func (a *API) requireFleet(w http.ResponseWriter, r *http.Request) bool { | ||
| 83 | if !principalFromContext(r).Fleet { | ||
| 84 | http.Error(w, "forbidden", http.StatusForbidden) | ||
| 85 | return false | ||
| 86 | } | ||
| 87 | return true | ||
| 88 | } | ||
internal/server/api/principal_test.go
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,155 @@ | |||
| 1 | package api | ||
| 2 | |||
| 3 | import ( | ||
| 4 | "bytes" | ||
| 5 | "encoding/json" | ||
| 6 | "net/http" | ||
| 7 | "net/http/httptest" | ||
| 8 | "testing" | ||
| 9 | |||
| 10 | "github.com/a73x/eitri/internal/server/store" | ||
| 11 | "github.com/stretchr/testify/assert" | ||
| 12 | "github.com/stretchr/testify/require" | ||
| 13 | ) | ||
| 14 | |||
| 15 | func TestMayActAs(t *testing.T) { | ||
| 16 | for _, tc := range []struct { | ||
| 17 | name string | ||
| 18 | p Principal | ||
| 19 | tenant string | ||
| 20 | want bool | ||
| 21 | }{ | ||
| 22 | {"same tenant", Principal{Tenant: "t1"}, "t1", true}, | ||
| 23 | {"different tenant", Principal{Tenant: "t1"}, "t2", false}, | ||
| 24 | {"fleet crosses tenants", Principal{Tenant: "t1", Fleet: true}, "t2", true}, | ||
| 25 | {"zero principal fails closed", Principal{}, "t1", false}, | ||
| 26 | // The empty-tenant/empty-target degenerate: a zero principal must not | ||
| 27 | // accidentally match a (never-valid) empty resource tenant via "" == "". | ||
| 28 | // Guarded by mayActAs's explicit empty check. | ||
| 29 | {"zero principal vs empty tenant", Principal{}, "", false}, | ||
| 30 | } { | ||
| 31 | t.Run(tc.name, func(t *testing.T) { | ||
| 32 | assert.Equal(t, tc.want, mayActAs(tc.p, tc.tenant)) | ||
| 33 | }) | ||
| 34 | } | ||
| 35 | } | ||
| 36 | |||
| 37 | func TestPrincipalFromContextFailsClosed(t *testing.T) { | ||
| 38 | // A request that never went through adminAuth carries no principal: the | ||
| 39 | // zero value has no tenant and no fleet bit. | ||
| 40 | r := httptest.NewRequest("GET", "/", nil) | ||
| 41 | p := principalFromContext(r) | ||
| 42 | assert.False(t, p.Fleet) | ||
| 43 | assert.Empty(t, p.Tenant) | ||
| 44 | } | ||
| 45 | |||
| 46 | func TestRequireFleetFailsClosed(t *testing.T) { | ||
| 47 | w := httptest.NewRecorder() | ||
| 48 | r := httptest.NewRequest("POST", "/api/v1/enroll-tokens", nil) | ||
| 49 | a := &API{} | ||
| 50 | assert.False(t, a.requireFleet(w, r)) | ||
| 51 | assert.Equal(t, 403, w.Code) | ||
| 52 | } | ||
| 53 | |||
| 54 | func TestRequireFleetRejectsTenantWithoutFleetBit(t *testing.T) { | ||
| 55 | // A tenant-scoped principal — not a zero value, just missing Fleet — must | ||
| 56 | // still be turned away. This also exercises the withPrincipal -> | ||
| 57 | // principalFromContext round-trip for the first time. | ||
| 58 | w := httptest.NewRecorder() | ||
| 59 | r := httptest.NewRequest("POST", "/api/v1/enroll-tokens", nil) | ||
| 60 | r = r.WithContext(withPrincipal(r.Context(), Principal{Tenant: "t1"})) | ||
| 61 | a := &API{} | ||
| 62 | assert.False(t, a.requireFleet(w, r)) | ||
| 63 | assert.Equal(t, 403, w.Code) | ||
| 64 | } | ||
| 65 | |||
| 66 | func TestFilterByTenant(t *testing.T) { | ||
| 67 | vms := []store.VM{{ID: "a", Tenant: "t1"}, {ID: "b", Tenant: "t2"}} | ||
| 68 | hosts := []store.Host{{ID: "h1", Tenant: "t1"}, {ID: "h2", Tenant: "t2"}} | ||
| 69 | |||
| 70 | member := Principal{Tenant: "t1"} | ||
| 71 | gotVMs := filterVMs(member, vms) | ||
| 72 | require.Len(t, gotVMs, 1) | ||
| 73 | assert.Equal(t, "a", gotVMs[0].ID) | ||
| 74 | gotHosts := filterHosts(member, hosts) | ||
| 75 | require.Len(t, gotHosts, 1) | ||
| 76 | assert.Equal(t, "h1", gotHosts[0].ID) | ||
| 77 | |||
| 78 | fleet := Principal{Tenant: "t1", Fleet: true} | ||
| 79 | assert.Len(t, filterVMs(fleet, vms), 2, "fleet sees everything") | ||
| 80 | assert.Len(t, filterHosts(fleet, hosts), 2) | ||
| 81 | } | ||
| 82 | |||
| 83 | // foreignRequest builds a request carrying a Principal scoped to a tenant | ||
| 84 | // other than "default" (where testServer/enroll resources land), for calling | ||
| 85 | // handlers DIRECTLY — bypassing the mux, since adminAuth would otherwise | ||
| 86 | // overwrite the principal with the Fleet bootstrap principal. | ||
| 87 | func foreignRequest(t *testing.T, method, path string, body any) *http.Request { | ||
| 88 | t.Helper() | ||
| 89 | var buf bytes.Buffer | ||
| 90 | if body != nil { | ||
| 91 | require.NoError(t, json.NewEncoder(&buf).Encode(body)) | ||
| 92 | } | ||
| 93 | r := httptest.NewRequest(method, path, &buf) | ||
| 94 | return r.WithContext(withPrincipal(r.Context(), Principal{Tenant: "other"})) | ||
| 95 | } | ||
| 96 | |||
| 97 | // TestHandleListVMsFiltersForeignTenant proves the wiring in snapshotVMs: a | ||
| 98 | // principal from a different tenant sees an empty list even though a VM | ||
| 99 | // exists (in "default", where enroll/create land). | ||
| 100 | func TestHandleListVMsFiltersForeignTenant(t *testing.T) { | ||
| 101 | ts, _, _, _, a := newServer(t) | ||
| 102 | out := enroll(t, ts) | ||
| 103 | resp := do(t, "POST", ts.URL+"/api/v1/vms", "admintok", | ||
| 104 | map[string]any{"host_id": out["host_id"], "name": "vm-a"}) | ||
| 105 | require.Equal(t, 201, resp.StatusCode) | ||
| 106 | |||
| 107 | r := foreignRequest(t, "GET", "/api/v1/vms", nil) | ||
| 108 | w := httptest.NewRecorder() | ||
| 109 | a.handleListVMs(w, r) | ||
| 110 | |||
| 111 | require.Equal(t, 200, w.Code) | ||
| 112 | var vms []map[string]any | ||
| 113 | require.NoError(t, json.NewDecoder(w.Body).Decode(&vms)) | ||
| 114 | assert.Empty(t, vms, "a foreign-tenant principal must not see another tenant's VMs") | ||
| 115 | } | ||
| 116 | |||
| 117 | // TestHandleDeleteVMForeignTenantIsNotFoundAndNoop proves mutateVM's ownership | ||
| 118 | // gate runs BEFORE mutate: a foreign-tenant delete gets the same not-found | ||
| 119 | // response as a missing VM, and the VM is still live afterward. | ||
| 120 | func TestHandleDeleteVMForeignTenantIsNotFoundAndNoop(t *testing.T) { | ||
| 121 | ts, st, _, _, a := newServer(t) | ||
| 122 | out := enroll(t, ts) | ||
| 123 | created := map[string]string{} | ||
| 124 | resp := do(t, "POST", ts.URL+"/api/v1/vms", "admintok", | ||
| 125 | map[string]any{"host_id": out["host_id"], "name": "vm-a"}) | ||
| 126 | require.Equal(t, 201, resp.StatusCode) | ||
| 127 | require.NoError(t, json.NewDecoder(resp.Body).Decode(&created)) | ||
| 128 | id := created["id"] | ||
| 129 | |||
| 130 | r := foreignRequest(t, "DELETE", "/api/v1/vms/"+id, nil) | ||
| 131 | r.SetPathValue("id", id) | ||
| 132 | w := httptest.NewRecorder() | ||
| 133 | a.handleDeleteVM(w, r) | ||
| 134 | |||
| 135 | assert.Equal(t, http.StatusNotFound, w.Code) | ||
| 136 | |||
| 137 | vm, err := st.GetVM(id) | ||
| 138 | require.NoError(t, err) | ||
| 139 | assert.Nil(t, vm.DeletedAt, "the ownership gate must reject before mutate runs") | ||
| 140 | } | ||
| 141 | |||
| 142 | // TestHandleCreateVMForeignTenantHostIsUnknown proves the create pre-check: a | ||
| 143 | // foreign-tenant principal placing a VM on a "default"-tenant host gets the | ||
| 144 | // same 400 as an absent host_id. | ||
| 145 | func TestHandleCreateVMForeignTenantHostIsUnknown(t *testing.T) { | ||
| 146 | ts, _, _, _, a := newServer(t) | ||
| 147 | out := enroll(t, ts) | ||
| 148 | |||
| 149 | r := foreignRequest(t, "POST", "/api/v1/vms", map[string]any{"host_id": out["host_id"]}) | ||
| 150 | w := httptest.NewRecorder() | ||
| 151 | a.handleCreateVM(w, r) | ||
| 152 | |||
| 153 | assert.Equal(t, http.StatusBadRequest, w.Code) | ||
| 154 | assert.Contains(t, w.Body.String(), "unknown host_id") | ||
| 155 | } | ||
internal/server/api/sshcert.go
| Old | New | ||
|---|---|---|---|
| @@ -166,6 +166,7 @@ func (a *API) handleMintSSHCert(w http.ResponseWriter, r *http.Request) { | |||
| 166 | }) | 166 | }) |
| 167 | writeJSON(w, http.StatusOK, types.SSHCertResponse{ | 167 | writeJSON(w, http.StatusOK, types.SSHCertResponse{ |
| 168 | Certificate: string(ssh.MarshalAuthorizedKey(cert)), | 168 | Certificate: string(ssh.MarshalAuthorizedKey(cert)), |
| 169 | Tenant: principalFromContext(r).Tenant, | ||
| 169 | }) | 170 | }) |
| 170 | } | 171 | } |
| 171 | 172 | ||
| @@ -175,6 +176,9 @@ func (a *API) handleMintSSHCert(w http.ResponseWriter, r *http.Request) { | |||
| 175 | // it does NOT depend on the minter being wired, so unlike mint it never 404s on | 176 | // it does NOT depend on the minter being wired, so unlike mint it never 404s on |
| 176 | // a gate-off server. | 177 | // a gate-off server. |
| 177 | func (a *API) handleRevokeSSHCert(w http.ResponseWriter, r *http.Request) { | 178 | func (a *API) handleRevokeSSHCert(w http.ResponseWriter, r *http.Request) { |
| 179 | if !a.requireFleet(w, r) { | ||
| 180 | return | ||
| 181 | } | ||
| 178 | var req types.RevokeSSHCertRequest | 182 | var req types.RevokeSSHCertRequest |
| 179 | if !decodeJSON(w, r, &req) { | 183 | if !decodeJSON(w, r, &req) { |
| 180 | return | 184 | return |
| @@ -220,6 +224,9 @@ func (a *API) handleRevokeSSHCert(w http.ResponseWriter, r *http.Request) { | |||
| 220 | // handleListRevokedSSHCerts lists the revoked cert serials (+ reason/time), | 224 | // handleListRevokedSSHCerts lists the revoked cert serials (+ reason/time), |
| 221 | // newest first. Admin-authed. | 225 | // newest first. Admin-authed. |
| 222 | func (a *API) handleListRevokedSSHCerts(w http.ResponseWriter, r *http.Request) { | 226 | func (a *API) handleListRevokedSSHCerts(w http.ResponseWriter, r *http.Request) { |
| 227 | if !a.requireFleet(w, r) { | ||
| 228 | return | ||
| 229 | } | ||
| 223 | revoked, err := a.st.ListRevokedSSHCerts() | 230 | revoked, err := a.st.ListRevokedSSHCerts() |
| 224 | if err != nil { | 231 | if err != nil { |
| 225 | http.Error(w, "internal error", http.StatusInternalServerError) | 232 | http.Error(w, "internal error", http.StatusInternalServerError) |
internal/server/api/sshcert_test.go
| Old | New | ||
|---|---|---|---|
| @@ -11,6 +11,7 @@ import ( | |||
| 11 | "testing" | 11 | "testing" |
| 12 | "time" | 12 | "time" |
| 13 | 13 | ||
| 14 | "github.com/a73x/eitri/internal/server/store" | ||
| 14 | "github.com/stretchr/testify/assert" | 15 | "github.com/stretchr/testify/assert" |
| 15 | "github.com/stretchr/testify/require" | 16 | "github.com/stretchr/testify/require" |
| 16 | "golang.org/x/crypto/ssh" | 17 | "golang.org/x/crypto/ssh" |
| @@ -122,6 +123,19 @@ func TestSSHCertMintIgnoresClientPrincipals(t *testing.T) { | |||
| 122 | "client-supplied principals must be ignored — always ubuntu") | 123 | "client-supplied principals must be ignored — always ubuntu") |
| 123 | } | 124 | } |
| 124 | 125 | ||
| 126 | // TestSSHCertMintReturnsTenant asserts the mint response carries the minting | ||
| 127 | // principal's tenant, which clients use to build <tenant>.<name> connect names. | ||
| 128 | func TestSSHCertMintReturnsTenant(t *testing.T) { | ||
| 129 | ts, _ := newServerWithCertMinter(t, 10*time.Minute) | ||
| 130 | |||
| 131 | resp := do(t, "POST", ts.URL+"/api/v1/ssh-certs", "admintok", | ||
| 132 | map[string]any{"public_key": genUserPubKey(t)}) | ||
| 133 | require.Equal(t, http.StatusOK, resp.StatusCode) | ||
| 134 | var out map[string]string | ||
| 135 | require.NoError(t, json.NewDecoder(resp.Body).Decode(&out)) | ||
| 136 | assert.Equal(t, store.DefaultTenant, out["tenant"], "mint response must carry the tenant") | ||
| 137 | } | ||
| 138 | |||
| 125 | func TestSSHCertMintRequiresAdmin(t *testing.T) { | 139 | func TestSSHCertMintRequiresAdmin(t *testing.T) { |
| 126 | ts, _ := newServerWithCertMinter(t, 10*time.Minute) | 140 | ts, _ := newServerWithCertMinter(t, 10*time.Minute) |
| 127 | body := map[string]any{"public_key": genUserPubKey(t)} | 141 | body := map[string]any{"public_key": genUserPubKey(t)} |
| @@ -160,34 +174,52 @@ func TestSSHCAEndpointGateOffIs404(t *testing.T) { | |||
| 160 | assert.Equal(t, http.StatusNotFound, resp.StatusCode) | 174 | assert.Equal(t, http.StatusNotFound, resp.StatusCode) |
| 161 | } | 175 | } |
| 162 | 176 | ||
| 177 | // recordingHostMinter delegates to a real HostMinter but captures the principal | ||
| 178 | // argument, so a test can assert the caller namespaced it as <tenant>.<name>. | ||
| 179 | type recordingHostMinter struct { | ||
| 180 | inner HostCertMinter | ||
| 181 | principal string | ||
| 182 | } | ||
| 183 | |||
| 184 | func (m *recordingHostMinter) MintHostCert(principal string) (keyPEM, cert string, err error) { | ||
| 185 | m.principal = principal | ||
| 186 | return m.inner.MintHostCert(principal) | ||
| 187 | } | ||
| 188 | |||
| 163 | // TestCreateVMMintsPerVMHostCert asserts that, with the gate enabled, creating a | 189 | // TestCreateVMMintsPerVMHostCert asserts that, with the gate enabled, creating a |
| 164 | // VM persists a per-VM host private key + a CA-signed host cert scoped to the VM | 190 | // VM persists a per-VM host private key + a CA-signed host cert scoped to the |
| 165 | // name — and that neither ever appears in the API's VM response. | 191 | // namespaced <tenant>.<name> principal — and that neither the private key nor |
| 192 | // the raw principal ever appears in the API's VM response. | ||
| 166 | func TestCreateVMMintsPerVMHostCert(t *testing.T) { | 193 | func TestCreateVMMintsPerVMHostCert(t *testing.T) { |
| 167 | ts, st, _, _, a := newServer(t) | 194 | ts, st, _, _, a := newServer(t) |
| 168 | ca := newCASigner(t) | 195 | ca := newCASigner(t) |
| 169 | a.SetHostCertMinter(NewHostMinter(ca)) | 196 | rec := &recordingHostMinter{inner: NewHostMinter(ca)} |
| 197 | a.SetHostCertMinter(rec) | ||
| 170 | out := enroll(t, ts) | 198 | out := enroll(t, ts) |
| 171 | 199 | ||
| 172 | resp := do(t, "POST", ts.URL+"/api/v1/vms", "admintok", | 200 | resp := do(t, "POST", ts.URL+"/api/v1/vms", "admintok", |
| 173 | map[string]any{"host_id": out["host_id"], "name": "hosty"}) | 201 | map[string]any{"host_id": out["host_id"], "name": "hosty"}) |
| 174 | require.Equal(t, http.StatusCreated, resp.StatusCode) | 202 | require.Equal(t, http.StatusCreated, resp.StatusCode) |
| 175 | 203 | ||
| 204 | // VM create must pass the namespaced principal to the host-cert minter. | ||
| 205 | assert.Equal(t, "default.hosty", rec.principal, | ||
| 206 | "host-cert principal must be <tenant>.<name>") | ||
| 207 | |||
| 176 | // The store row carries the private key PEM + the cert. | 208 | // The store row carries the private key PEM + the cert. |
| 177 | vm, err := st.VMByName("hosty") | 209 | vm, err := st.VMByTenantName(store.DefaultTenant, "hosty") |
| 178 | require.NoError(t, err) | 210 | require.NoError(t, err) |
| 179 | require.NotEmpty(t, vm.SSHHostKey, "per-VM host private key must be persisted") | 211 | require.NotEmpty(t, vm.SSHHostKey, "per-VM host private key must be persisted") |
| 180 | require.NotEmpty(t, vm.SSHHostCert, "per-VM host cert must be persisted") | 212 | require.NotEmpty(t, vm.SSHHostCert, "per-VM host cert must be persisted") |
| 181 | assert.Contains(t, vm.SSHHostKey, "OPENSSH PRIVATE KEY") | 213 | assert.Contains(t, vm.SSHHostKey, "OPENSSH PRIVATE KEY") |
| 182 | 214 | ||
| 183 | // The cert is a HOST cert signed by the CA and scoped to the VM name. | 215 | // The cert is a HOST cert signed by the CA and scoped to <tenant>.<name>. |
| 184 | cert := parseCert(t, vm.SSHHostCert) | 216 | cert := parseCert(t, vm.SSHHostCert) |
| 185 | assert.Equal(t, uint32(ssh.HostCert), cert.CertType) | 217 | assert.Equal(t, uint32(ssh.HostCert), cert.CertType) |
| 186 | assert.Equal(t, []string{"hosty"}, cert.ValidPrincipals) | 218 | assert.Equal(t, []string{"default.hosty"}, cert.ValidPrincipals) |
| 187 | checker := &ssh.CertChecker{IsHostAuthority: func(k ssh.PublicKey, _ string) bool { | 219 | checker := &ssh.CertChecker{IsHostAuthority: func(k ssh.PublicKey, _ string) bool { |
| 188 | return bytes.Equal(k.Marshal(), ca.PublicKey().Marshal()) | 220 | return bytes.Equal(k.Marshal(), ca.PublicKey().Marshal()) |
| 189 | }} | 221 | }} |
| 190 | require.NoError(t, checker.CheckHostKey("hosty:22", nil, cert)) | 222 | require.NoError(t, checker.CheckHostKey("default.hosty:22", nil, cert)) |
| 191 | 223 | ||
| 192 | // The private key must NEVER leak through the VM listing. | 224 | // The private key must NEVER leak through the VM listing. |
| 193 | listResp := do(t, "GET", ts.URL+"/api/v1/vms", "admintok", nil) | 225 | listResp := do(t, "GET", ts.URL+"/api/v1/vms", "admintok", nil) |
| @@ -207,7 +239,7 @@ func TestCreateVMWithoutHostMinterHasNoHostCert(t *testing.T) { | |||
| 207 | map[string]any{"host_id": out["host_id"], "name": "plainvm"}) | 239 | map[string]any{"host_id": out["host_id"], "name": "plainvm"}) |
| 208 | require.Equal(t, http.StatusCreated, resp.StatusCode) | 240 | require.Equal(t, http.StatusCreated, resp.StatusCode) |
| 209 | 241 | ||
| 210 | vm, err := st.VMByName("plainvm") | 242 | vm, err := st.VMByTenantName(store.DefaultTenant, "plainvm") |
| 211 | require.NoError(t, err) | 243 | require.NoError(t, err) |
| 212 | assert.Empty(t, vm.SSHHostKey) | 244 | assert.Empty(t, vm.SSHHostKey) |
| 213 | assert.Empty(t, vm.SSHHostCert) | 245 | assert.Empty(t, vm.SSHHostCert) |
internal/server/api/testdata/ssh-cert-response.golden.json
| Old | New | ||
|---|---|---|---|
| @@ -1,3 +1,4 @@ | |||
| 1 | { | 1 | { |
| 2 | "certificate": "ssh-ed25519-cert-v01@openssh.com AAAAB3Nza cert-comment" | 2 | "certificate": "ssh-ed25519-cert-v01@openssh.com AAAAB3Nza cert-comment", |
| 3 | "tenant": "default" | ||
| 3 | } | 4 | } |
internal/server/api/types/types.go
| Old | New | ||
|---|---|---|---|
| @@ -156,6 +156,8 @@ type SSHCAResponse struct { | |||
| 156 | // certificate in authorized_keys form. | 156 | // certificate in authorized_keys form. |
| 157 | type SSHCertResponse struct { | 157 | type SSHCertResponse struct { |
| 158 | Certificate string `json:"certificate"` | 158 | Certificate string `json:"certificate"` |
| 159 | // The minting principal's tenant: clients dial VMs as <tenant>.<name>. | ||
| 160 | Tenant string `json:"tenant"` | ||
| 159 | } | 161 | } |
| 160 | 162 | ||
| 161 | // StreamTicketResponse answers POST /api/v1/stream-tickets. | 163 | // StreamTicketResponse answers POST /api/v1/stream-tickets. |
internal/server/api/wire_golden_test.go
| Old | New | ||
|---|---|---|---|
| @@ -155,6 +155,7 @@ func TestWireGolden(t *testing.T) { | |||
| 155 | 155 | ||
| 156 | goldenCheck(t, "ssh-cert-response", types.SSHCertResponse{ | 156 | goldenCheck(t, "ssh-cert-response", types.SSHCertResponse{ |
| 157 | Certificate: "ssh-ed25519-cert-v01@openssh.com AAAAB3Nza cert-comment", | 157 | Certificate: "ssh-ed25519-cert-v01@openssh.com AAAAB3Nza cert-comment", |
| 158 | Tenant: "default", | ||
| 158 | }) | 159 | }) |
| 159 | 160 | ||
| 160 | goldenCheck(t, "stream-ticket-response", types.StreamTicketResponse{ | 161 | goldenCheck(t, "stream-ticket-response", types.StreamTicketResponse{ |
internal/server/sshgate/gate.go
| Old | New | ||
|---|---|---|---|
| @@ -1,5 +1,5 @@ | |||
| 1 | // Package sshgate is eitri's hardened SSH jump gate: a bastion front-end that | 1 | // Package sshgate is eitri's hardened SSH jump gate: a bastion front-end that |
| 2 | // admins reach with `ssh -J gate ubuntu@<vm>`. It authenticates users by short- | 2 | // admins reach with `ssh -J gate ubuntu@<tenant>.<vm>`. It authenticates users by short- |
| 3 | // lived certificates signed by the eitri user CA and permits exactly one thing — | 3 | // lived certificates signed by the eitri user CA and permits exactly one thing — |
| 4 | // a `direct-tcpip` tunnel to `<vm>:22`, forwarded to the VM's host over the sync | 4 | // a `direct-tcpip` tunnel to `<vm>:22`, forwarded to the VM's host over the sync |
| 5 | // connection. Every other SSH surface is refused: no sessions/shells/exec (which | 5 | // connection. Every other SSH surface is refused: no sessions/shells/exec (which |
| @@ -22,23 +22,29 @@ import ( | |||
| 22 | "golang.org/x/crypto/ssh" | 22 | "golang.org/x/crypto/ssh" |
| 23 | ) | 23 | ) |
| 24 | 24 | ||
| 25 | // Resolver maps a VM name (as typed in `ssh -J gate user@<name>`) to its host | 25 | // Resolver maps a bare VM name WITHIN tenant to its host and VM IDs. ok=false |
| 26 | // and VM IDs. ok=false ⇒ unknown name; the channel is rejected. Resolution is | 26 | // ⇒ unknown; the channel is rejected. Resolution is NOT an authorization |
| 27 | // NOT an authorization boundary — authz is (§9 M4). | 27 | // boundary — authorize is; but it IS tenant-scoped, so names never resolve |
| 28 | type Resolver func(name string) (hostID, vmID string, ok bool) | 28 | // across tenants. |
| 29 | type Resolver func(tenant, name string) (hostID, vmID string, ok bool) | ||
| 29 | 30 | ||
| 30 | // Authorizer reports whether the verified cert principal may reach vmID. v1 wires | 31 | // Authorizer reports whether a connection belonging to tenant may reach vmID. |
| 31 | // an always-true stub (single-admin; only the admin can mint certs); the per-user | 32 | // The tenant is derived from the cert's signing CA at auth time (the ONLY |
| 32 | // implementation is the F3 choke point and must fail closed (§5). | 33 | // authenticated identity on the connection — the cert principal is just the |
| 33 | type Authorizer func(principal, vmID string) bool | 34 | // guest login user). Must fail closed. |
| 35 | // | ||
| 36 | // HONEST SCOPE (spec §Gate): with one CA every connection maps to the same | ||
| 37 | // tenant, so this check separates tenants logically, not cryptographically. | ||
| 38 | // The crypto boundary is the CA split — a recorded tenant-#2 blocker. | ||
| 39 | type Authorizer func(tenant, vmID string) bool | ||
| 34 | 40 | ||
| 35 | // Dialer opens a raw byte pipe to vmID:port on hostID (wired to syncsvc.OpenTCP). | 41 | // Dialer opens a raw byte pipe to vmID:port on hostID (wired to syncsvc.OpenTCP). |
| 36 | type Dialer func(ctx context.Context, hostID, vmID string, port uint32) (io.ReadWriteCloser, error) | 42 | type Dialer func(ctx context.Context, hostID, vmID string, port uint32) (io.ReadWriteCloser, error) |
| 37 | 43 | ||
| 38 | // principalsExt is the Permissions.Extensions key under which the verified cert | 44 | // tenantExt is the Permissions.Extensions key carrying the connection's |
| 39 | // principal(s) are stashed for the channel handler to authorize on. This is the | 45 | // tenant from auth to the channel handlers. Per-connection by construction — |
| 40 | // ONLY trusted source of the principal — never the requested hostname/username. | 46 | // a startup-captured tenant would have the wrong lifetime (review finding). |
| 41 | const principalsExt = "principals" | 47 | const tenantExt = "tenant" |
| 42 | 48 | ||
| 43 | // directTCPIP is the wire payload of an SSH `direct-tcpip` channel-open. | 49 | // directTCPIP is the wire payload of an SSH `direct-tcpip` channel-open. |
| 44 | type directTCPIP struct { | 50 | type directTCPIP struct { |
| @@ -74,7 +80,7 @@ type Revoker func(serial uint64) bool | |||
| 74 | // userCA, resolves VM names with resolve, gates them with authorize, rejects | 80 | // userCA, resolves VM names with resolve, gates them with authorize, rejects |
| 75 | // certs isRevoked flags, and tunnels through dial. A nil isRevoked disables | 81 | // certs isRevoked flags, and tunnels through dial. A nil isRevoked disables |
| 76 | // revocation checks (nothing is revoked). | 82 | // revocation checks (nothing is revoked). |
| 77 | func New(hostKey ssh.Signer, userCA ssh.PublicKey, resolve Resolver, authorize Authorizer, dial Dialer, isRevoked Revoker) *Gate { | 83 | func New(hostKey ssh.Signer, userCA ssh.PublicKey, caTenant string, resolve Resolver, authorize Authorizer, dial Dialer, isRevoked Revoker) *Gate { |
| 78 | checker := &ssh.CertChecker{ | 84 | checker := &ssh.CertChecker{ |
| 79 | IsUserAuthority: func(auth ssh.PublicKey) bool { return keysEqual(auth, userCA) }, | 85 | IsUserAuthority: func(auth ssh.PublicKey) bool { return keysEqual(auth, userCA) }, |
| 80 | } | 86 | } |
| @@ -116,7 +122,11 @@ func New(hostKey ssh.Signer, userCA ssh.PublicKey, resolve Resolver, authorize A | |||
| 116 | } | 122 | } |
| 117 | return &ssh.Permissions{ | 123 | return &ssh.Permissions{ |
| 118 | Extensions: map[string]string{ | 124 | Extensions: map[string]string{ |
| 119 | principalsExt: strings.Join(cert.ValidPrincipals, ","), | 125 | // The tenant of the CA that signed this cert. v1 trusts ONE |
| 126 | // CA (IsUserAuthority is an equality check), so this is that | ||
| 127 | // CA's tenant; the multi-CA future maps SignatureKey→tenant | ||
| 128 | // here and NOTHING downstream changes. | ||
| 129 | tenantExt: caTenant, | ||
| 120 | }, | 130 | }, |
| 121 | }, nil | 131 | }, nil |
| 122 | }, | 132 | }, |
| @@ -161,9 +171,9 @@ func (g *Gate) handleConn(nConn net.Conn) { | |||
| 161 | // legitimate here. Draining the channel also keeps the transport unblocked. | 171 | // legitimate here. Draining the channel also keeps the transport unblocked. |
| 162 | go rejectRequests(reqs) | 172 | go rejectRequests(reqs) |
| 163 | 173 | ||
| 164 | principal := "" | 174 | tenant := "" |
| 165 | if sConn.Permissions != nil { | 175 | if sConn.Permissions != nil { |
| 166 | principal = sConn.Permissions.Extensions[principalsExt] | 176 | tenant = sConn.Permissions.Extensions[tenantExt] |
| 167 | } | 177 | } |
| 168 | for newChan := range chans { | 178 | for newChan := range chans { |
| 169 | // Only direct-tcpip is permitted; this rejects session/exec/shell/ | 179 | // Only direct-tcpip is permitted; this rejects session/exec/shell/ |
| @@ -172,7 +182,7 @@ func (g *Gate) handleConn(nConn net.Conn) { | |||
| 172 | _ = newChan.Reject(ssh.UnknownChannelType, "only direct-tcpip is permitted") | 182 | _ = newChan.Reject(ssh.UnknownChannelType, "only direct-tcpip is permitted") |
| 173 | continue | 183 | continue |
| 174 | } | 184 | } |
| 175 | go g.handleDirectTCPIP(newChan, principal) | 185 | go g.handleDirectTCPIP(newChan, tenant) |
| 176 | } | 186 | } |
| 177 | } | 187 | } |
| 178 | 188 | ||
| @@ -188,7 +198,7 @@ func rejectRequests(reqs <-chan *ssh.Request) { | |||
| 188 | 198 | ||
| 189 | // handleDirectTCPIP validates a direct-tcpip open, authorizes it, dials the VM, | 199 | // handleDirectTCPIP validates a direct-tcpip open, authorizes it, dials the VM, |
| 190 | // and bridges the channel to the VM byte-for-byte. | 200 | // and bridges the channel to the VM byte-for-byte. |
| 191 | func (g *Gate) handleDirectTCPIP(newChan ssh.NewChannel, principal string) { | 201 | func (g *Gate) handleDirectTCPIP(newChan ssh.NewChannel, tenant string) { |
| 192 | var p directTCPIP | 202 | var p directTCPIP |
| 193 | if err := ssh.Unmarshal(newChan.ExtraData(), &p); err != nil { | 203 | if err := ssh.Unmarshal(newChan.ExtraData(), &p); err != nil { |
| 194 | _ = newChan.Reject(ssh.ConnectionFailed, "malformed direct-tcpip request") | 204 | _ = newChan.Reject(ssh.ConnectionFailed, "malformed direct-tcpip request") |
| @@ -200,12 +210,22 @@ func (g *Gate) handleDirectTCPIP(newChan ssh.NewChannel, principal string) { | |||
| 200 | _ = newChan.Reject(ssh.Prohibited, "only port 22 is permitted") | 210 | _ = newChan.Reject(ssh.Prohibited, "only port 22 is permitted") |
| 201 | return | 211 | return |
| 202 | } | 212 | } |
| 203 | hostID, vmID, ok := g.resolve(p.HostToConnect) | 213 | // Connect names are <tenant>.<name>. VM names are RFC1123 labels (no |
| 214 | // dots) and tenant ids are dot-free, so the FIRST dot splits | ||
| 215 | // unambiguously. The prefix must match the connection's own tenant — | ||
| 216 | // naming another tenant is rejected identically to a nonexistent VM, so | ||
| 217 | // tenancy structure is not probeable from the gate. | ||
| 218 | prefix, bare, found := strings.Cut(p.HostToConnect, ".") | ||
| 219 | if !found || prefix != tenant || bare == "" { | ||
| 220 | _ = newChan.Reject(ssh.ConnectionFailed, "unknown VM") | ||
| 221 | return | ||
| 222 | } | ||
| 223 | hostID, vmID, ok := g.resolve(tenant, bare) | ||
| 204 | if !ok { | 224 | if !ok { |
| 205 | _ = newChan.Reject(ssh.ConnectionFailed, "unknown VM") | 225 | _ = newChan.Reject(ssh.ConnectionFailed, "unknown VM") |
| 206 | return | 226 | return |
| 207 | } | 227 | } |
| 208 | if !g.authorize(principal, vmID) { | 228 | if !g.authorize(tenant, vmID) { |
| 209 | _ = newChan.Reject(ssh.Prohibited, "not authorized for this VM") | 229 | _ = newChan.Reject(ssh.Prohibited, "not authorized for this VM") |
| 210 | return | 230 | return |
| 211 | } | 231 | } |
internal/server/sshgate/gate_test.go
| Old | New | ||
|---|---|---|---|
| @@ -80,20 +80,20 @@ func startGate(t *testing.T, userCA ssh.PublicKey, authorized bool) *testGate { | |||
| 80 | dialCalls: make(chan [3]string, 4), | 80 | dialCalls: make(chan [3]string, 4), |
| 81 | authorized: authorized, | 81 | authorized: authorized, |
| 82 | } | 82 | } |
| 83 | resolve := func(name string) (string, string, bool) { | 83 | resolve := func(tenant, name string) (string, string, bool) { |
| 84 | if name == "vm1" { | 84 | if tenant == "default" && name == "vm1" { |
| 85 | return "host-1", "vm-1", true | 85 | return "host-1", "vm-1", true |
| 86 | } | 86 | } |
| 87 | return "", "", false | 87 | return "", "", false |
| 88 | } | 88 | } |
| 89 | authorize := func(principal, vmID string) bool { return tg.authorized } | 89 | authorize := func(tenant, vmID string) bool { return tg.authorized } |
| 90 | dial := func(_ context.Context, hostID, vmID string, port uint32) (io.ReadWriteCloser, error) { | 90 | dial := func(_ context.Context, hostID, vmID string, port uint32) (io.ReadWriteCloser, error) { |
| 91 | tg.dialCalls <- [3]string{hostID, vmID, "22"} | 91 | tg.dialCalls <- [3]string{hostID, vmID, "22"} |
| 92 | a, b := net.Pipe() | 92 | a, b := net.Pipe() |
| 93 | go func() { _, _ = io.Copy(b, b); b.Close() }() // echo server = fake VM sshd | 93 | go func() { _, _ = io.Copy(b, b); b.Close() }() // echo server = fake VM sshd |
| 94 | return a, nil | 94 | return a, nil |
| 95 | } | 95 | } |
| 96 | g := New(tg.hostKey, userCA, resolve, authorize, dial, nil) | 96 | g := New(tg.hostKey, userCA, "default", resolve, authorize, dial, nil) |
| 97 | 97 | ||
| 98 | l, err := net.Listen("tcp", "127.0.0.1:0") | 98 | l, err := net.Listen("tcp", "127.0.0.1:0") |
| 99 | require.NoError(t, err) | 99 | require.NoError(t, err) |
| @@ -108,20 +108,20 @@ func startGate(t *testing.T, userCA ssh.PublicKey, authorized bool) *testGate { | |||
| 108 | func startGateRevoked(t *testing.T, userCA ssh.PublicKey, isRevoked Revoker) *testGate { | 108 | func startGateRevoked(t *testing.T, userCA ssh.PublicKey, isRevoked Revoker) *testGate { |
| 109 | t.Helper() | 109 | t.Helper() |
| 110 | tg := &testGate{hostKey: newSigner(t), dialCalls: make(chan [3]string, 4), authorized: true} | 110 | tg := &testGate{hostKey: newSigner(t), dialCalls: make(chan [3]string, 4), authorized: true} |
| 111 | resolve := func(name string) (string, string, bool) { | 111 | resolve := func(tenant, name string) (string, string, bool) { |
| 112 | if name == "vm1" { | 112 | if tenant == "default" && name == "vm1" { |
| 113 | return "host-1", "vm-1", true | 113 | return "host-1", "vm-1", true |
| 114 | } | 114 | } |
| 115 | return "", "", false | 115 | return "", "", false |
| 116 | } | 116 | } |
| 117 | authorize := func(principal, vmID string) bool { return tg.authorized } | 117 | authorize := func(tenant, vmID string) bool { return tg.authorized } |
| 118 | dial := func(_ context.Context, hostID, vmID string, port uint32) (io.ReadWriteCloser, error) { | 118 | dial := func(_ context.Context, hostID, vmID string, port uint32) (io.ReadWriteCloser, error) { |
| 119 | tg.dialCalls <- [3]string{hostID, vmID, "22"} | 119 | tg.dialCalls <- [3]string{hostID, vmID, "22"} |
| 120 | a, b := net.Pipe() | 120 | a, b := net.Pipe() |
| 121 | go func() { _, _ = io.Copy(b, b); b.Close() }() | 121 | go func() { _, _ = io.Copy(b, b); b.Close() }() |
| 122 | return a, nil | 122 | return a, nil |
| 123 | } | 123 | } |
| 124 | g := New(tg.hostKey, userCA, resolve, authorize, dial, isRevoked) | 124 | g := New(tg.hostKey, userCA, "default", resolve, authorize, dial, isRevoked) |
| 125 | l, err := net.Listen("tcp", "127.0.0.1:0") | 125 | l, err := net.Listen("tcp", "127.0.0.1:0") |
| 126 | require.NoError(t, err) | 126 | require.NoError(t, err) |
| 127 | tg.addr = l.Addr().String() | 127 | tg.addr = l.Addr().String() |
| @@ -170,20 +170,20 @@ func dialClient(t *testing.T, tg *testGate, certSigner ssh.Signer) *ssh.Client { | |||
| 170 | func startGateWithHostKey(t *testing.T, hostKey ssh.Signer, userCA ssh.PublicKey) *testGate { | 170 | func startGateWithHostKey(t *testing.T, hostKey ssh.Signer, userCA ssh.PublicKey) *testGate { |
| 171 | t.Helper() | 171 | t.Helper() |
| 172 | tg := &testGate{hostKey: hostKey, dialCalls: make(chan [3]string, 4), authorized: true} | 172 | tg := &testGate{hostKey: hostKey, dialCalls: make(chan [3]string, 4), authorized: true} |
| 173 | resolve := func(name string) (string, string, bool) { | 173 | resolve := func(tenant, name string) (string, string, bool) { |
| 174 | if name == "vm1" { | 174 | if tenant == "default" && name == "vm1" { |
| 175 | return "host-1", "vm-1", true | 175 | return "host-1", "vm-1", true |
| 176 | } | 176 | } |
| 177 | return "", "", false | 177 | return "", "", false |
| 178 | } | 178 | } |
| 179 | authorize := func(principal, vmID string) bool { return tg.authorized } | 179 | authorize := func(tenant, vmID string) bool { return tg.authorized } |
| 180 | dial := func(_ context.Context, hostID, vmID string, port uint32) (io.ReadWriteCloser, error) { | 180 | dial := func(_ context.Context, hostID, vmID string, port uint32) (io.ReadWriteCloser, error) { |
| 181 | tg.dialCalls <- [3]string{hostID, vmID, "22"} | 181 | tg.dialCalls <- [3]string{hostID, vmID, "22"} |
| 182 | a, b := net.Pipe() | 182 | a, b := net.Pipe() |
| 183 | go func() { _, _ = io.Copy(b, b); b.Close() }() | 183 | go func() { _, _ = io.Copy(b, b); b.Close() }() |
| 184 | return a, nil | 184 | return a, nil |
| 185 | } | 185 | } |
| 186 | g := New(hostKey, userCA, resolve, authorize, dial, nil) | 186 | g := New(hostKey, userCA, "default", resolve, authorize, dial, nil) |
| 187 | l, err := net.Listen("tcp", "127.0.0.1:0") | 187 | l, err := net.Listen("tcp", "127.0.0.1:0") |
| 188 | require.NoError(t, err) | 188 | require.NoError(t, err) |
| 189 | tg.addr = l.Addr().String() | 189 | tg.addr = l.Addr().String() |
| @@ -238,7 +238,7 @@ func TestGatePresentsCASignedHostCert(t *testing.T) { | |||
| 238 | t.Cleanup(func() { _ = client.Close() }) | 238 | t.Cleanup(func() { _ = client.Close() }) |
| 239 | 239 | ||
| 240 | // And the tunnel still works end-to-end over the cert-authenticated host. | 240 | // And the tunnel still works end-to-end over the cert-authenticated host. |
| 241 | conn, err := client.Dial("tcp", "vm1:22") | 241 | conn, err := client.Dial("tcp", "default.vm1:22") |
| 242 | require.NoError(t, err) | 242 | require.NoError(t, err) |
| 243 | _ = conn.Close() | 243 | _ = conn.Close() |
| 244 | } | 244 | } |
| @@ -274,7 +274,7 @@ func TestGateDirectTCPIPToPort22RoundTrips(t *testing.T) { | |||
| 274 | tg := startGate(t, ca.PublicKey(), true) | 274 | tg := startGate(t, ca.PublicKey(), true) |
| 275 | client := dialClient(t, tg, mintCertSigner(t, ca, newSigner(t))) | 275 | client := dialClient(t, tg, mintCertSigner(t, ca, newSigner(t))) |
| 276 | 276 | ||
| 277 | conn, err := client.Dial("tcp", "vm1:22") | 277 | conn, err := client.Dial("tcp", "default.vm1:22") |
| 278 | require.NoError(t, err) | 278 | require.NoError(t, err) |
| 279 | defer conn.Close() | 279 | defer conn.Close() |
| 280 | 280 | ||
| @@ -328,7 +328,7 @@ func TestGateDirectTCPIPToNonSSHPortRejected(t *testing.T) { | |||
| 328 | tg := startGate(t, ca.PublicKey(), true) | 328 | tg := startGate(t, ca.PublicKey(), true) |
| 329 | client := dialClient(t, tg, mintCertSigner(t, ca, newSigner(t))) | 329 | client := dialClient(t, tg, mintCertSigner(t, ca, newSigner(t))) |
| 330 | 330 | ||
| 331 | _, err := client.Dial("tcp", "vm1:2222") | 331 | _, err := client.Dial("tcp", "default.vm1:2222") |
| 332 | require.Error(t, err, "only port 22 may be tunnelled") | 332 | require.Error(t, err, "only port 22 may be tunnelled") |
| 333 | } | 333 | } |
| 334 | 334 | ||
| @@ -337,7 +337,7 @@ func TestGateUnknownVMRejected(t *testing.T) { | |||
| 337 | tg := startGate(t, ca.PublicKey(), true) | 337 | tg := startGate(t, ca.PublicKey(), true) |
| 338 | client := dialClient(t, tg, mintCertSigner(t, ca, newSigner(t))) | 338 | client := dialClient(t, tg, mintCertSigner(t, ca, newSigner(t))) |
| 339 | 339 | ||
| 340 | _, err := client.Dial("tcp", "nope:22") | 340 | _, err := client.Dial("tcp", "default.nope:22") |
| 341 | require.Error(t, err, "unknown VM name must be rejected") | 341 | require.Error(t, err, "unknown VM name must be rejected") |
| 342 | } | 342 | } |
| 343 | 343 | ||
| @@ -346,10 +346,46 @@ func TestGateAuthzDenyRejected(t *testing.T) { | |||
| 346 | tg := startGate(t, ca.PublicKey(), false) // authorize → deny | 346 | tg := startGate(t, ca.PublicKey(), false) // authorize → deny |
| 347 | client := dialClient(t, tg, mintCertSigner(t, ca, newSigner(t))) | 347 | client := dialClient(t, tg, mintCertSigner(t, ca, newSigner(t))) |
| 348 | 348 | ||
| 349 | _, err := client.Dial("tcp", "vm1:22") | 349 | _, err := client.Dial("tcp", "default.vm1:22") |
| 350 | require.Error(t, err, "authz denial must reject the channel") | 350 | require.Error(t, err, "authz denial must reject the channel") |
| 351 | } | 351 | } |
| 352 | 352 | ||
| 353 | // TestGateRejectsBareAndForeignTenantNames: the dialed name must be | ||
| 354 | // <tenant>.<name> and the prefix must match the connection's tenant (derived | ||
| 355 | // from the signing CA). Bare (unqualified) names and foreign prefixes are | ||
| 356 | // rejected before resolution. | ||
| 357 | func TestGateRejectsBareAndForeignTenantNames(t *testing.T) { | ||
| 358 | ca := newSigner(t) | ||
| 359 | tg := startGate(t, ca.PublicKey(), true) | ||
| 360 | client := dialClient(t, tg, mintCertSigner(t, ca, newSigner(t))) | ||
| 361 | |||
| 362 | // rejectMsg dials target (which must be rejected) and returns the gate's | ||
| 363 | // channel-open reject message. | ||
| 364 | rejectMsg := func(target string) string { | ||
| 365 | _, err := client.Dial("tcp", target+":22") | ||
| 366 | require.Error(t, err, "dialing %q must be rejected", target) | ||
| 367 | var oce *ssh.OpenChannelError | ||
| 368 | require.ErrorAs(t, err, &oce) | ||
| 369 | return oce.Message | ||
| 370 | } | ||
| 371 | |||
| 372 | // A bare name (no tenant prefix) is rejected — the gate's tenant is | ||
| 373 | // "default" (the signing CA's tenant) and names must be <tenant>.<name>. | ||
| 374 | _ = rejectMsg("vm1") | ||
| 375 | |||
| 376 | // A foreign tenant prefix must be rejected with the SAME message as a | ||
| 377 | // nonexistent VM in our own tenant: the two are indistinguishable, so | ||
| 378 | // tenancy structure is not probeable from the gate. Test-lock the equal | ||
| 379 | // messages so a future refactor cannot split them silently. | ||
| 380 | assert.Equal(t, rejectMsg("default.nope"), rejectMsg("other.vm1"), | ||
| 381 | "a foreign tenant prefix must be indistinguishable from a nonexistent VM") | ||
| 382 | |||
| 383 | // Control: the correctly namespaced name resolves and tunnels. | ||
| 384 | conn, err := client.Dial("tcp", "default.vm1:22") | ||
| 385 | require.NoError(t, err, "correctly namespaced name must succeed") | ||
| 386 | _ = conn.Close() | ||
| 387 | } | ||
| 388 | |||
| 353 | func TestGateRejectsCertFromForeignCA(t *testing.T) { | 389 | func TestGateRejectsCertFromForeignCA(t *testing.T) { |
| 354 | ca := newSigner(t) | 390 | ca := newSigner(t) |
| 355 | foreignCA := newSigner(t) | 391 | foreignCA := newSigner(t) |
| @@ -406,7 +442,7 @@ func TestGateRejectsRevokedCert(t *testing.T) { | |||
| 406 | // Not revoked ⇒ the same serial authenticates and tunnels. | 442 | // Not revoked ⇒ the same serial authenticates and tunnels. |
| 407 | allowed := startGateRevoked(t, ca.PublicKey(), func(uint64) bool { return false }) | 443 | allowed := startGateRevoked(t, ca.PublicKey(), func(uint64) bool { return false }) |
| 408 | client := dialClient(t, allowed, mintCertSignerSerial(t, ca, newSigner(t), serial)) | 444 | client := dialClient(t, allowed, mintCertSignerSerial(t, ca, newSigner(t), serial)) |
| 409 | conn, err := client.Dial("tcp", "vm1:22") | 445 | conn, err := client.Dial("tcp", "default.vm1:22") |
| 410 | require.NoError(t, err, "a non-revoked cert must still tunnel") | 446 | require.NoError(t, err, "a non-revoked cert must still tunnel") |
| 411 | _ = conn.Close() | 447 | _ = conn.Close() |
| 412 | } | 448 | } |
internal/server/store/allocation_test.go
| Old | New | ||
|---|---|---|---|
| @@ -48,7 +48,7 @@ func TestAllocatedByHostExcludesTombstoned(t *testing.T) { | |||
| 48 | func TestAllocatedByHostSeparatesHosts(t *testing.T) { | 48 | func TestAllocatedByHostSeparatesHosts(t *testing.T) { |
| 49 | s := newStore(t) | 49 | s := newStore(t) |
| 50 | h1 := enrollHost(t, s) | 50 | h1 := enrollHost(t, s) |
| 51 | tok, _ := s.CreateEnrollmentToken() | 51 | tok, _ := s.CreateEnrollmentToken(DefaultTenant) |
| 52 | h2, _ := s.RedeemEnrollmentToken(tok, "h2", "linux", "amd64", "cloudhv", "") | 52 | h2, _ := s.RedeemEnrollmentToken(tok, "h2", "linux", "amd64", "cloudhv", "") |
| 53 | vmWithResources(t, s, h1, "a", 2, 2048, 10) | 53 | vmWithResources(t, s, h1, "a", 2, 2048, 10) |
| 54 | vmWithResources(t, s, h2, "b", 8, 8192, 40) | 54 | vmWithResources(t, s, h2, "b", 8, 8192, 40) |
internal/server/store/decommission_test.go
| Old | New | ||
|---|---|---|---|
| @@ -49,7 +49,7 @@ func TestRemoveHostFreesCIDRForReuse(t *testing.T) { | |||
| 49 | s := newStore(t) | 49 | s := newStore(t) |
| 50 | h1 := enrollHost(t, s) | 50 | h1 := enrollHost(t, s) |
| 51 | assert.Equal(t, "10.77.1.0/24", h1.BridgeCIDR) | 51 | assert.Equal(t, "10.77.1.0/24", h1.BridgeCIDR) |
| 52 | tok2, _ := s.CreateEnrollmentToken() | 52 | tok2, _ := s.CreateEnrollmentToken(DefaultTenant) |
| 53 | h2, _ := s.RedeemEnrollmentToken(tok2, "b", "linux", "amd64", "cloudhv", "") | 53 | h2, _ := s.RedeemEnrollmentToken(tok2, "b", "linux", "amd64", "cloudhv", "") |
| 54 | assert.Equal(t, "10.77.2.0/24", h2.BridgeCIDR) | 54 | assert.Equal(t, "10.77.2.0/24", h2.BridgeCIDR) |
| 55 | 55 | ||
| @@ -64,7 +64,7 @@ func TestRemoveHostFreesCIDRForReuse(t *testing.T) { | |||
| 64 | assert.Error(t, err, "removed host should be gone") | 64 | assert.Error(t, err, "removed host should be gone") |
| 65 | 65 | ||
| 66 | // A new enrollment reuses h1's freed CIDR rather than allocating a fresh one. | 66 | // A new enrollment reuses h1's freed CIDR rather than allocating a fresh one. |
| 67 | tok3, _ := s.CreateEnrollmentToken() | 67 | tok3, _ := s.CreateEnrollmentToken(DefaultTenant) |
| 68 | h3, err := s.RedeemEnrollmentToken(tok3, "c", "linux", "amd64", "cloudhv", "") | 68 | h3, err := s.RedeemEnrollmentToken(tok3, "c", "linux", "amd64", "cloudhv", "") |
| 69 | require.NoError(t, err) | 69 | require.NoError(t, err) |
| 70 | assert.Equal(t, "10.77.1.0/24", h3.BridgeCIDR, "freed CIDR should be reused") | 70 | assert.Equal(t, "10.77.1.0/24", h3.BridgeCIDR, "freed CIDR should be reused") |
internal/server/store/store.go
| Old | New | ||
|---|---|---|---|
| @@ -16,15 +16,14 @@ import ( | |||
| 16 | "os" | 16 | "os" |
| 17 | "path/filepath" | 17 | "path/filepath" |
| 18 | "strconv" | 18 | "strconv" |
| 19 | "strings" | ||
| 20 | "time" | 19 | "time" |
| 21 | 20 | ||
| 22 | "github.com/a73x/eitri/internal/random" | 21 | "github.com/a73x/eitri/internal/random" |
| 23 | "github.com/a73x/eitri/internal/transport" | 22 | "github.com/a73x/eitri/internal/transport" |
| 24 | _ "modernc.org/sqlite" | 23 | sqlite "modernc.org/sqlite" |
| 25 | ) | 24 | ) |
| 26 | 25 | ||
| 27 | // ErrNameTaken is returned by CreateVM when the name is already in use by a live VM. | 26 | // ErrNameTaken is returned by CreateVM when the name is already in use by a live VM in the same tenant. |
| 28 | var ErrNameTaken = errors.New("vm name already in use") | 27 | var ErrNameTaken = errors.New("vm name already in use") |
| 29 | 28 | ||
| 30 | // ErrHostNotFound is returned by CreateVM when the host_id does not exist. | 29 | // ErrHostNotFound is returned by CreateVM when the host_id does not exist. |
| @@ -34,6 +33,13 @@ var ErrHostNotFound = errors.New("host not found") | |||
| 34 | // not accepting new VMs (e.g. it is decommissioning). | 33 | // not accepting new VMs (e.g. it is decommissioning). |
| 35 | var ErrHostNotEnrolled = errors.New("host not accepting new VMs") | 34 | var ErrHostNotEnrolled = errors.New("host not accepting new VMs") |
| 36 | 35 | ||
| 36 | // DefaultTenant is the reserved bootstrap tenant every resource collapses to | ||
| 37 | // in v1. It is NOT special: no authz/resolve/uniqueness code may branch on | ||
| 38 | // this literal — it is seeded like any tenant and compared value-agnostically. | ||
| 39 | // The only legitimate reference points are bootstrap wiring (the admin | ||
| 40 | // principal, the single CA's tenant) and tests. | ||
| 41 | const DefaultTenant = "default" | ||
| 42 | |||
| 37 | type Store struct { | 43 | type Store struct { |
| 38 | db *sql.DB | 44 | db *sql.DB |
| 39 | dbDir string | 45 | dbDir string |
| @@ -46,6 +52,8 @@ type Host struct { | |||
| 46 | // one host's outstanding credential without rotating the fleet secret. | 52 | // one host's outstanding credential without rotating the fleet secret. |
| 47 | CredGeneration int64 | 53 | CredGeneration int64 |
| 48 | EnrolledAt time.Time | 54 | EnrolledAt time.Time |
| 55 | // Tenant is the owning tenant (partition key); derived from the enroll token. | ||
| 56 | Tenant string | ||
| 49 | } | 57 | } |
| 50 | 58 | ||
| 51 | type VM struct { | 59 | type VM struct { |
| @@ -63,6 +71,8 @@ type VM struct { | |||
| 63 | // The VM's reachable address is AssignedIP (the agent-reported bridge IP). | 71 | // The VM's reachable address is AssignedIP (the agent-reported bridge IP). |
| 64 | CreatedAt time.Time | 72 | CreatedAt time.Time |
| 65 | DeletedAt *time.Time | 73 | DeletedAt *time.Time |
| 74 | // Tenant is the owning tenant, always derived from the host's — never client-set. | ||
| 75 | Tenant string | ||
| 66 | } | 76 | } |
| 67 | 77 | ||
| 68 | const schema = ` | 78 | const schema = ` |
| @@ -71,6 +81,17 @@ CREATE TABLE IF NOT EXISTS meta ( | |||
| 71 | value TEXT NOT NULL | 81 | value TEXT NOT NULL |
| 72 | ); | 82 | ); |
| 73 | 83 | ||
| 84 | -- tenant ids MUST remain DOT-FREE: the jump gate's connect name is | ||
| 85 | -- <tenant>.<name> and it splits on the FIRST dot (see sshgate/gate.go), while VM | ||
| 86 | -- names are RFC1123 labels (also dot-free). A dotted tenant id would make the | ||
| 87 | -- split ambiguous. Enforce this at tenant CRUD when it lands (no CRUD yet — the | ||
| 88 | -- only tenant is the seeded 'default'). | ||
| 89 | CREATE TABLE IF NOT EXISTS tenants ( | ||
| 90 | id TEXT PRIMARY KEY, | ||
| 91 | name TEXT NOT NULL, | ||
| 92 | created_at DATETIME NOT NULL | ||
| 93 | ); | ||
| 94 | |||
| 74 | CREATE TABLE IF NOT EXISTS hosts ( | 95 | CREATE TABLE IF NOT EXISTS hosts ( |
| 75 | id TEXT PRIMARY KEY, | 96 | id TEXT PRIMARY KEY, |
| 76 | name TEXT NOT NULL, | 97 | name TEXT NOT NULL, |
| @@ -80,13 +101,15 @@ CREATE TABLE IF NOT EXISTS hosts ( | |||
| 80 | bridge_cidr TEXT NOT NULL, | 101 | bridge_cidr TEXT NOT NULL, |
| 81 | status TEXT NOT NULL DEFAULT 'enrolled', | 102 | status TEXT NOT NULL DEFAULT 'enrolled', |
| 82 | enrolled_at DATETIME NOT NULL, | 103 | enrolled_at DATETIME NOT NULL, |
| 83 | cred_generation INTEGER NOT NULL DEFAULT 1 | 104 | cred_generation INTEGER NOT NULL DEFAULT 1, |
| 105 | tenant TEXT NOT NULL DEFAULT 'default' REFERENCES tenants(id) | ||
| 84 | ); | 106 | ); |
| 85 | 107 | ||
| 86 | CREATE TABLE IF NOT EXISTS enrollment_tokens ( | 108 | CREATE TABLE IF NOT EXISTS enrollment_tokens ( |
| 87 | token_hash TEXT PRIMARY KEY, | 109 | token_hash TEXT PRIMARY KEY, |
| 88 | expires_at DATETIME NOT NULL, | 110 | expires_at DATETIME NOT NULL, |
| 89 | used_at DATETIME | 111 | used_at DATETIME, |
| 112 | tenant TEXT NOT NULL DEFAULT 'default' | ||
| 90 | ); | 113 | ); |
| 91 | 114 | ||
| 92 | CREATE TABLE IF NOT EXISTS vms ( | 115 | CREATE TABLE IF NOT EXISTS vms ( |
| @@ -108,10 +131,12 @@ CREATE TABLE IF NOT EXISTS vms ( | |||
| 108 | last_error TEXT NOT NULL DEFAULT '', | 131 | last_error TEXT NOT NULL DEFAULT '', |
| 109 | assigned_ip TEXT NOT NULL DEFAULT '', | 132 | assigned_ip TEXT NOT NULL DEFAULT '', |
| 110 | created_at DATETIME NOT NULL, | 133 | created_at DATETIME NOT NULL, |
| 111 | deleted_at DATETIME | 134 | deleted_at DATETIME, |
| 135 | tenant TEXT NOT NULL DEFAULT 'default' REFERENCES tenants(id) | ||
| 112 | ); | 136 | ); |
| 113 | 137 | ||
| 114 | CREATE UNIQUE INDEX IF NOT EXISTS vms_name ON vms(name) WHERE deleted_at IS NULL; | 138 | -- vms name uniqueness is per-tenant. |
| 139 | CREATE UNIQUE INDEX IF NOT EXISTS vms_tenant_name ON vms(tenant, name) WHERE deleted_at IS NULL; | ||
| 115 | 140 | ||
| 116 | -- bridge CIDRs returned to the pool by host decommission, available for reuse | 141 | -- bridge CIDRs returned to the pool by host decommission, available for reuse |
| 117 | -- before the monotonic next_cidr_index allocator is consulted. | 142 | -- before the monotonic next_cidr_index allocator is consulted. |
| @@ -137,6 +162,11 @@ CREATE TABLE IF NOT EXISTS revoked_ssh_certs ( | |||
| 137 | -- config). The at column is second-precision UTC RFC3339, which makes | 162 | -- config). The at column is second-precision UTC RFC3339, which makes |
| 138 | -- lexicographic comparison chronological -- PruneAudit's DELETE relies on | 163 | -- lexicographic comparison chronological -- PruneAudit's DELETE relies on |
| 139 | -- every writer keeping that format. | 164 | -- every writer keeping that format. |
| 165 | -- | ||
| 166 | -- No tenant column BY DESIGN: some rows are genuinely tenant-less — e.g. | ||
| 167 | -- host.enroll.denied written from an UNAUTHENTICATED enroll attempt, before any | ||
| 168 | -- tenant is known. Audit read therefore stays Fleet-gated (handleListAudit); | ||
| 169 | -- per-tenant audit is a recorded tenant-#2 blocker (spec §Deferred). | ||
| 140 | CREATE TABLE IF NOT EXISTS audit_log ( | 170 | CREATE TABLE IF NOT EXISTS audit_log ( |
| 141 | id INTEGER PRIMARY KEY AUTOINCREMENT, | 171 | id INTEGER PRIMARY KEY AUTOINCREMENT, |
| 142 | at DATETIME NOT NULL, | 172 | at DATETIME NOT NULL, |
| @@ -161,6 +191,13 @@ func Open(path, cidrPool string) (*Store, error) { | |||
| 161 | return nil, fmt.Errorf("apply schema: %w", err) | 191 | return nil, fmt.Errorf("apply schema: %w", err) |
| 162 | } | 192 | } |
| 163 | 193 | ||
| 194 | // Seed the bootstrap tenant; first call wins, like cidr_pool below. | ||
| 195 | if _, err := db.Exec(`INSERT INTO tenants(id, name, created_at) VALUES (?,?,?) ON CONFLICT DO NOTHING`, | ||
| 196 | DefaultTenant, DefaultTenant, time.Now().UTC().Format(time.RFC3339)); err != nil { | ||
| 197 | db.Close() | ||
| 198 | return nil, fmt.Errorf("seed default tenant: %w", err) | ||
| 199 | } | ||
| 200 | |||
| 164 | // Store cidr_pool; ON CONFLICT DO NOTHING means the first call wins. | 201 | // Store cidr_pool; ON CONFLICT DO NOTHING means the first call wins. |
| 165 | if _, err := db.Exec(`INSERT INTO meta(key, value) VALUES ('cidr_pool', ?) ON CONFLICT DO NOTHING`, cidrPool); err != nil { | 202 | if _, err := db.Exec(`INSERT INTO meta(key, value) VALUES ('cidr_pool', ?) ON CONFLICT DO NOTHING`, cidrPool); err != nil { |
| 166 | db.Close() | 203 | db.Close() |
| @@ -210,14 +247,26 @@ func subnetForIndex(pool netip.Prefix, idx int64) (string, error) { | |||
| 210 | return fmt.Sprintf("%d.%d.%d.0/24", start>>24&0xff, start>>16&0xff, start>>8&0xff), nil | 247 | return fmt.Sprintf("%d.%d.%d.0/24", start>>24&0xff, start>>16&0xff, start>>8&0xff), nil |
| 211 | } | 248 | } |
| 212 | 249 | ||
| 213 | func (s *Store) CreateEnrollmentToken() (string, error) { | 250 | // CreateEnrollmentToken mints a single-use enroll token bound to tenant. The |
| 251 | // enroll endpoint is unauthenticated, so the token IS the tenant credential — | ||
| 252 | // the enrolling host lands in this tenant. Tenant existence is checked here | ||
| 253 | // (the column carries no FK; the table is transient). | ||
| 254 | func (s *Store) CreateEnrollmentToken(tenant string) (string, error) { | ||
| 255 | var one int | ||
| 256 | if err := s.db.QueryRow(`SELECT 1 FROM tenants WHERE id=?`, tenant).Scan(&one); err != nil { | ||
| 257 | if errors.Is(err, sql.ErrNoRows) { | ||
| 258 | return "", fmt.Errorf("unknown tenant %q", tenant) | ||
| 259 | } | ||
| 260 | return "", fmt.Errorf("check tenant: %w", err) | ||
| 261 | } | ||
| 262 | |||
| 214 | tok := random.Hex(32) | 263 | tok := random.Hex(32) |
| 215 | h := sha256.Sum256([]byte(tok)) | 264 | h := sha256.Sum256([]byte(tok)) |
| 216 | hash := hex.EncodeToString(h[:]) | 265 | hash := hex.EncodeToString(h[:]) |
| 217 | expiresAt := time.Now().UTC().Add(15 * time.Minute) | 266 | expiresAt := time.Now().UTC().Add(15 * time.Minute) |
| 218 | _, err := s.db.Exec( | 267 | _, err := s.db.Exec( |
| 219 | `INSERT INTO enrollment_tokens(token_hash, expires_at) VALUES (?, ?)`, | 268 | `INSERT INTO enrollment_tokens(token_hash, expires_at, tenant) VALUES (?, ?, ?)`, |
| 220 | hash, expiresAt.Format(time.RFC3339), | 269 | hash, expiresAt.Format(time.RFC3339), tenant, |
| 221 | ) | 270 | ) |
| 222 | if err != nil { | 271 | if err != nil { |
| 223 | return "", fmt.Errorf("insert token: %w", err) | 272 | return "", fmt.Errorf("insert token: %w", err) |
| @@ -225,10 +274,11 @@ func (s *Store) CreateEnrollmentToken() (string, error) { | |||
| 225 | return tok, nil | 274 | return tok, nil |
| 226 | } | 275 | } |
| 227 | 276 | ||
| 228 | // RedeemEnrollmentToken atomically consumes tok and creates the host row. | 277 | // RedeemEnrollmentToken atomically consumes tok and creates the host row. The |
| 229 | // remote (the enrolling client's IP) is recorded in a host.enroll audit row | 278 | // created host inherits the tenant bound to the consumed token. remote (the |
| 230 | // written in the SAME transaction, so an enrolled host can never exist | 279 | // enrolling client's IP) is recorded in a host.enroll audit row written in |
| 231 | // without its durable audit record. | 280 | // the SAME transaction, so an enrolled host can never exist without its |
| 281 | // durable audit record. | ||
| 232 | func (s *Store) RedeemEnrollmentToken(tok, name, osName, arch, provisioner, remote string) (Host, error) { | 282 | func (s *Store) RedeemEnrollmentToken(tok, name, osName, arch, provisioner, remote string) (Host, error) { |
| 233 | h := sha256.Sum256([]byte(tok)) | 283 | h := sha256.Sum256([]byte(tok)) |
| 234 | hash := hex.EncodeToString(h[:]) | 284 | hash := hex.EncodeToString(h[:]) |
| @@ -252,6 +302,13 @@ func (s *Store) RedeemEnrollmentToken(tok, name, osName, arch, provisioner, remo | |||
| 252 | return Host{}, fmt.Errorf("token invalid, expired, or already used") | 302 | return Host{}, fmt.Errorf("token invalid, expired, or already used") |
| 253 | } | 303 | } |
| 254 | 304 | ||
| 305 | // The consumed token's tenant becomes the host's — the derivation chain | ||
| 306 | // (token → host → VM) starts here. | ||
| 307 | var tenant string | ||
| 308 | if err := tx.QueryRow(`SELECT tenant FROM enrollment_tokens WHERE token_hash=?`, hash).Scan(&tenant); err != nil { | ||
| 309 | return Host{}, fmt.Errorf("read token tenant: %w", err) | ||
| 310 | } | ||
| 311 | |||
| 255 | var cidrPool string | 312 | var cidrPool string |
| 256 | var nextIdx int64 | 313 | var nextIdx int64 |
| 257 | if err := tx.QueryRow(`SELECT value FROM meta WHERE key='cidr_pool'`).Scan(&cidrPool); err != nil { | 314 | if err := tx.QueryRow(`SELECT value FROM meta WHERE key='cidr_pool'`).Scan(&cidrPool); err != nil { |
| @@ -288,8 +345,8 @@ func (s *Store) RedeemEnrollmentToken(tok, name, osName, arch, provisioner, remo | |||
| 288 | id := random.Hex(16) | 345 | id := random.Hex(16) |
| 289 | 346 | ||
| 290 | if _, err := tx.Exec( | 347 | if _, err := tx.Exec( |
| 291 | `INSERT INTO hosts(id, name, os, arch, provisioner, bridge_cidr, enrolled_at) VALUES (?,?,?,?,?,?,?)`, | 348 | `INSERT INTO hosts(id, name, os, arch, provisioner, bridge_cidr, enrolled_at, tenant) VALUES (?,?,?,?,?,?,?,?)`, |
| 292 | id, name, osName, arch, provisioner, bridgeCIDR, now.Format(time.RFC3339), | 349 | id, name, osName, arch, provisioner, bridgeCIDR, now.Format(time.RFC3339), tenant, |
| 293 | ); err != nil { | 350 | ); err != nil { |
| 294 | return Host{}, fmt.Errorf("insert host: %w", err) | 351 | return Host{}, fmt.Errorf("insert host: %w", err) |
| 295 | } | 352 | } |
| @@ -320,6 +377,7 @@ func (s *Store) RedeemEnrollmentToken(tok, name, osName, arch, provisioner, remo | |||
| 320 | Status: "enrolled", | 377 | Status: "enrolled", |
| 321 | CredGeneration: 1, | 378 | CredGeneration: 1, |
| 322 | EnrolledAt: now, | 379 | EnrolledAt: now, |
| 380 | Tenant: tenant, | ||
| 323 | }, nil | 381 | }, nil |
| 324 | } | 382 | } |
| 325 | 383 | ||
| @@ -327,8 +385,8 @@ func (s *Store) GetHost(id string) (Host, error) { | |||
| 327 | var h Host | 385 | var h Host |
| 328 | var enrolledAt string | 386 | var enrolledAt string |
| 329 | err := s.db.QueryRow( | 387 | err := s.db.QueryRow( |
| 330 | `SELECT id, name, os, arch, provisioner, bridge_cidr, status, enrolled_at, cred_generation FROM hosts WHERE id=?`, id, | 388 | `SELECT id, name, os, arch, provisioner, bridge_cidr, status, enrolled_at, cred_generation, tenant FROM hosts WHERE id=?`, id, |
| 331 | ).Scan(&h.ID, &h.Name, &h.OS, &h.Arch, &h.Provisioner, &h.BridgeCIDR, &h.Status, &enrolledAt, &h.CredGeneration) | 389 | ).Scan(&h.ID, &h.Name, &h.OS, &h.Arch, &h.Provisioner, &h.BridgeCIDR, &h.Status, &enrolledAt, &h.CredGeneration, &h.Tenant) |
| 332 | if err != nil { | 390 | if err != nil { |
| 333 | return Host{}, err | 391 | return Host{}, err |
| 334 | } | 392 | } |
| @@ -372,7 +430,7 @@ type querier interface { | |||
| 372 | func (s *Store) ListHosts() ([]Host, error) { return listHosts(s.db) } | 430 | func (s *Store) ListHosts() ([]Host, error) { return listHosts(s.db) } |
| 373 | 431 | ||
| 374 | func listHosts(q querier) ([]Host, error) { | 432 | func listHosts(q querier) ([]Host, error) { |
| 375 | rows, err := q.Query(`SELECT id, name, os, arch, provisioner, bridge_cidr, status, enrolled_at, cred_generation FROM hosts`) | 433 | rows, err := q.Query(`SELECT id, name, os, arch, provisioner, bridge_cidr, status, enrolled_at, cred_generation, tenant FROM hosts`) |
| 376 | if err != nil { | 434 | if err != nil { |
| 377 | return nil, err | 435 | return nil, err |
| 378 | } | 436 | } |
| @@ -381,7 +439,7 @@ func listHosts(q querier) ([]Host, error) { | |||
| 381 | for rows.Next() { | 439 | for rows.Next() { |
| 382 | var h Host | 440 | var h Host |
| 383 | var enrolledAt string | 441 | var enrolledAt string |
| 384 | if err := rows.Scan(&h.ID, &h.Name, &h.OS, &h.Arch, &h.Provisioner, &h.BridgeCIDR, &h.Status, &enrolledAt, &h.CredGeneration); err != nil { | 442 | if err := rows.Scan(&h.ID, &h.Name, &h.OS, &h.Arch, &h.Provisioner, &h.BridgeCIDR, &h.Status, &enrolledAt, &h.CredGeneration, &h.Tenant); err != nil { |
| 385 | return nil, err | 443 | return nil, err |
| 386 | } | 444 | } |
| 387 | h.EnrolledAt, _ = time.Parse(time.RFC3339, enrolledAt) | 445 | h.EnrolledAt, _ = time.Parse(time.RFC3339, enrolledAt) |
| @@ -402,10 +460,12 @@ func (s *Store) CreateVM(vm VM) error { | |||
| 402 | vm.ID = random.Hex(16) | 460 | vm.ID = random.Hex(16) |
| 403 | } | 461 | } |
| 404 | 462 | ||
| 405 | // Refuse to place a VM on a host that is not enrolled (e.g. mid-decommission); | 463 | // Refuse to place a VM on a host that is not enrolled, and read the host's |
| 406 | // the FK below only proves the host row exists, not that it accepts new VMs. | 464 | // tenant in the SAME tx: a VM's tenant is ALWAYS its host's — derived |
| 407 | var hostStatus string | 465 | // here, never accepted from the caller (the API handler cannot override |
| 408 | switch err := tx.QueryRow(`SELECT status FROM hosts WHERE id=?`, vm.HostID).Scan(&hostStatus); { | 466 | // it; this is the enforcement point for the partition invariant). |
| 467 | var hostStatus, hostTenant string | ||
| 468 | switch err := tx.QueryRow(`SELECT status, tenant FROM hosts WHERE id=?`, vm.HostID).Scan(&hostStatus, &hostTenant); { | ||
| 409 | case errors.Is(err, sql.ErrNoRows): | 469 | case errors.Is(err, sql.ErrNoRows): |
| 410 | return ErrHostNotFound | 470 | return ErrHostNotFound |
| 411 | case err != nil: | 471 | case err != nil: |
| @@ -413,25 +473,38 @@ func (s *Store) CreateVM(vm VM) error { | |||
| 413 | case hostStatus != "enrolled": | 473 | case hostStatus != "enrolled": |
| 414 | return ErrHostNotEnrolled | 474 | return ErrHostNotEnrolled |
| 415 | } | 475 | } |
| 476 | vm.Tenant = hostTenant | ||
| 416 | 477 | ||
| 417 | _, err = tx.Exec( | 478 | _, err = tx.Exec( |
| 418 | `INSERT INTO vms(id, host_id, name, image_url, image_sha256, cloud_init, ssh_authorized_key, | 479 | `INSERT INTO vms(id, host_id, name, tenant, image_url, image_sha256, cloud_init, ssh_authorized_key, |
| 419 | ssh_host_key, ssh_host_cert, | 480 | ssh_host_key, ssh_host_cert, |
| 420 | vcpus, mem_mb, disk_gb, persistent, power_state, created_at) | 481 | vcpus, mem_mb, disk_gb, persistent, power_state, created_at) |
| 421 | VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`, | 482 | VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`, |
| 422 | vm.ID, vm.HostID, vm.Name, vm.ImageURL, vm.ImageSHA256, | 483 | vm.ID, vm.HostID, vm.Name, vm.Tenant, vm.ImageURL, vm.ImageSHA256, |
| 423 | vm.CloudInit, vm.SSHAuthorizedKey, | 484 | vm.CloudInit, vm.SSHAuthorizedKey, |
| 424 | vm.SSHHostKey, vm.SSHHostCert, | 485 | vm.SSHHostKey, vm.SSHHostCert, |
| 425 | vm.VCPUs, vm.MemMB, vm.DiskGB, vm.Persistent, vm.PowerState, | 486 | vm.VCPUs, vm.MemMB, vm.DiskGB, vm.Persistent, vm.PowerState, |
| 426 | now.Format(time.RFC3339), | 487 | now.Format(time.RFC3339), |
| 427 | ) | 488 | ) |
| 428 | if err != nil { | 489 | if err != nil { |
| 429 | msg := err.Error() | 490 | // SQLITE_CONSTRAINT_UNIQUE (2067): the only UNIQUE constraint an |
| 430 | if strings.Contains(msg, "UNIQUE constraint failed: vms.name") { | 491 | // insert can trip besides the PK (which is 1555 and random-hex) is |
| 431 | return ErrNameTaken | 492 | // vms_tenant_name — a name collision within the tenant. Matched by |
| 432 | } | 493 | // errno, not message text: the message embeds the index's column list |
| 433 | if strings.Contains(msg, "FOREIGN KEY constraint failed") { | 494 | // and silently breaks on the next index change (review finding). |
| 434 | return ErrHostNotFound | 495 | var serr *sqlite.Error |
| 496 | if errors.As(err, &serr) { | ||
| 497 | switch serr.Code() { | ||
| 498 | case 2067: // SQLITE_CONSTRAINT_UNIQUE | ||
| 499 | return ErrNameTaken | ||
| 500 | case 787: // SQLITE_CONSTRAINT_FOREIGNKEY: tenant is derived in-tx | ||
| 501 | // from the host row above, and hosts.tenant itself carries an | ||
| 502 | // enforced FK, so vm.Tenant always references an existing | ||
| 503 | // tenant — it can't be what trips this. host_id -> hosts is | ||
| 504 | // the only FK left that a CreateVM insert can violate, so 787 | ||
| 505 | // unambiguously means the host is gone. | ||
| 506 | return ErrHostNotFound | ||
| 507 | } | ||
| 435 | } | 508 | } |
| 436 | return fmt.Errorf("insert vm: %w", err) | 509 | return fmt.Errorf("insert vm: %w", err) |
| 437 | } | 510 | } |
| @@ -826,7 +899,7 @@ func (s *Store) RecordVMStatus(id, status, lastErr, ip string) error { | |||
| 826 | // name-based). queryVMs is the sole caller, so adding a column is a single | 899 | // name-based). queryVMs is the sole caller, so adding a column is a single |
| 827 | // edit here plus scanVM — every VM query goes through it and can't drift out | 900 | // edit here plus scanVM — every VM query goes through it and can't drift out |
| 828 | // of lockstep. | 901 | // of lockstep. |
| 829 | const vmColumns = `id, host_id, name, image_url, image_sha256, cloud_init, ssh_authorized_key, | 902 | const vmColumns = `id, host_id, name, tenant, image_url, image_sha256, cloud_init, ssh_authorized_key, |
| 830 | ssh_host_key, ssh_host_cert, | 903 | ssh_host_key, ssh_host_cert, |
| 831 | vcpus, mem_mb, disk_gb, persistent, power_state, status, last_error, assigned_ip, | 904 | vcpus, mem_mb, disk_gb, persistent, power_state, status, last_error, assigned_ip, |
| 832 | created_at, deleted_at` | 905 | created_at, deleted_at` |
| @@ -838,7 +911,7 @@ func scanVM(rows *sql.Rows) (VM, error) { | |||
| 838 | var createdAt string | 911 | var createdAt string |
| 839 | var deletedAt sql.NullString | 912 | var deletedAt sql.NullString |
| 840 | err := rows.Scan( | 913 | err := rows.Scan( |
| 841 | &vm.ID, &vm.HostID, &vm.Name, &vm.ImageURL, &vm.ImageSHA256, | 914 | &vm.ID, &vm.HostID, &vm.Name, &vm.Tenant, &vm.ImageURL, &vm.ImageSHA256, |
| 842 | &vm.CloudInit, &vm.SSHAuthorizedKey, &vm.SSHHostKey, &vm.SSHHostCert, | 915 | &vm.CloudInit, &vm.SSHAuthorizedKey, &vm.SSHHostKey, &vm.SSHHostCert, |
| 843 | &vm.VCPUs, &vm.MemMB, &vm.DiskGB, &vm.Persistent, | 916 | &vm.VCPUs, &vm.MemMB, &vm.DiskGB, &vm.Persistent, |
| 844 | &vm.PowerState, &vm.Status, &vm.LastError, &vm.AssignedIP, | 917 | &vm.PowerState, &vm.Status, &vm.LastError, &vm.AssignedIP, |
| @@ -857,9 +930,9 @@ func scanVM(rows *sql.Rows) (VM, error) { | |||
| 857 | 930 | ||
| 858 | // queryVMs runs a vmColumns-projected SELECT against vms, with where appended | 931 | // queryVMs runs a vmColumns-projected SELECT against vms, with where appended |
| 859 | // verbatim after `FROM vms` (e.g. " WHERE id=?", or "" for none) and args | 932 | // verbatim after `FROM vms` (e.g. " WHERE id=?", or "" for none) and args |
| 860 | // bound in order, scanning every matching row. listVMs, VMByName, GetVM, and | 933 | // bound in order, scanning every matching row. listVMs, VMByTenantName, GetVM, |
| 861 | // DesiredForHost all share this — they differ only in WHERE clause, row-count | 934 | // and DesiredForHost all share this — they differ only in WHERE clause, |
| 862 | // expectations, and whether q is *sql.DB or an in-flight *sql.Tx. | 935 | // row-count expectations, and whether q is *sql.DB or an in-flight *sql.Tx. |
| 863 | func queryVMs(q querier, where string, args ...any) ([]VM, error) { | 936 | func queryVMs(q querier, where string, args ...any) ([]VM, error) { |
| 864 | rows, err := q.Query(`SELECT `+vmColumns+` FROM vms`+where, args...) | 937 | rows, err := q.Query(`SELECT `+vmColumns+` FROM vms`+where, args...) |
| 865 | if err != nil { | 938 | if err != nil { |
| @@ -881,12 +954,13 @@ func (s *Store) ListVMs() ([]VM, error) { return listVMs(s.db) } | |||
| 881 | 954 | ||
| 882 | func listVMs(q querier) ([]VM, error) { return queryVMs(q, "") } | 955 | func listVMs(q querier) ([]VM, error) { return queryVMs(q, "") } |
| 883 | 956 | ||
| 884 | // VMByName returns the live (non-tombstoned) VM with the given name. The | 957 | // VMByTenantName returns the live (non-tombstoned) VM with the given name |
| 885 | // vms_name unique index guarantees at most one match. sql.ErrNoRows ⇒ no such | 958 | // WITHIN tenant. The vms_tenant_name unique index guarantees at most one |
| 886 | // VM. Read-only; used by the SSH jump gate to resolve `ssh -J gate user@<name>` | 959 | // match. Resolution is tenant-scoped by construction: a name in another |
| 887 | // to a host/VM ID pair. | 960 | // tenant is sql.ErrNoRows, indistinguishable from absent. Used by the SSH |
| 888 | func (s *Store) VMByName(name string) (VM, error) { | 961 | // jump gate to resolve `ssh -J gate user@<tenant>.<name>`. |
| 889 | vms, err := queryVMs(s.db, ` WHERE name=? AND deleted_at IS NULL`, name) | 962 | func (s *Store) VMByTenantName(tenant, name string) (VM, error) { |
| 963 | vms, err := queryVMs(s.db, ` WHERE tenant=? AND name=? AND deleted_at IS NULL`, tenant, name) | ||
| 890 | if err != nil { | 964 | if err != nil { |
| 891 | return VM{}, err | 965 | return VM{}, err |
| 892 | } | 966 | } |
| @@ -897,7 +971,7 @@ func (s *Store) VMByName(name string) (VM, error) { | |||
| 897 | } | 971 | } |
| 898 | 972 | ||
| 899 | // GetVM returns the VM with the given id via a single indexed lookup on the | 973 | // GetVM returns the VM with the given id via a single indexed lookup on the |
| 900 | // primary key. Unlike VMByName it does NOT filter on deleted_at: the | 974 | // primary key. Unlike VMByTenantName it does NOT filter on deleted_at: the |
| 901 | // patch/delete/restore callers operate on VMs that may be tombstoned, so the | 975 | // patch/delete/restore callers operate on VMs that may be tombstoned, so the |
| 902 | // row must be found regardless of tombstone state. sql.ErrNoRows ⇒ no such VM. | 976 | // row must be found regardless of tombstone state. sql.ErrNoRows ⇒ no such VM. |
| 903 | func (s *Store) GetVM(id string) (VM, error) { | 977 | func (s *Store) GetVM(id string) (VM, error) { |
internal/server/store/store_test.go
| Old | New | ||
|---|---|---|---|
| @@ -30,30 +30,30 @@ func newStore(t *testing.T) *Store { | |||
| 30 | 30 | ||
| 31 | func enrollHost(t *testing.T, s *Store) Host { | 31 | func enrollHost(t *testing.T, s *Store) Host { |
| 32 | t.Helper() | 32 | t.Helper() |
| 33 | tok, err := s.CreateEnrollmentToken() | 33 | tok, err := s.CreateEnrollmentToken(DefaultTenant) |
| 34 | require.NoError(t, err) | 34 | require.NoError(t, err) |
| 35 | h, err := s.RedeemEnrollmentToken(tok, "host-a", "linux", "amd64", "cloudhv", "") | 35 | h, err := s.RedeemEnrollmentToken(tok, "host-a", "linux", "amd64", "cloudhv", "") |
| 36 | require.NoError(t, err) | 36 | require.NoError(t, err) |
| 37 | return h | 37 | return h |
| 38 | } | 38 | } |
| 39 | 39 | ||
| 40 | func TestVMByName(t *testing.T) { | 40 | func TestVMByTenantName(t *testing.T) { |
| 41 | s := newStore(t) | 41 | s := newStore(t) |
| 42 | h := enrollHost(t, s) | 42 | h := enrollHost(t, s) |
| 43 | vm := makeVM(t, s, h, "web-1") | 43 | vm := makeVM(t, s, h, "web-1") |
| 44 | 44 | ||
| 45 | got, err := s.VMByName("web-1") | 45 | got, err := s.VMByTenantName(DefaultTenant, "web-1") |
| 46 | require.NoError(t, err) | 46 | require.NoError(t, err) |
| 47 | assert.Equal(t, vm.ID, got.ID) | 47 | assert.Equal(t, vm.ID, got.ID) |
| 48 | assert.Equal(t, h.ID, got.HostID) | 48 | assert.Equal(t, h.ID, got.HostID) |
| 49 | 49 | ||
| 50 | // Unknown name ⇒ ErrNoRows (the resolver reads this as ok=false). | 50 | // Unknown name ⇒ ErrNoRows (the resolver reads this as ok=false). |
| 51 | _, err = s.VMByName("nope") | 51 | _, err = s.VMByTenantName(DefaultTenant, "nope") |
| 52 | assert.ErrorIs(t, err, sql.ErrNoRows) | 52 | assert.ErrorIs(t, err, sql.ErrNoRows) |
| 53 | 53 | ||
| 54 | // Tombstoned VMs are not resolvable — the gate must not tunnel to a dead VM. | 54 | // Tombstoned VMs are not resolvable — the gate must not tunnel to a dead VM. |
| 55 | require.NoError(t, s.TombstoneVM(vm.ID)) | 55 | require.NoError(t, s.TombstoneVM(vm.ID)) |
| 56 | _, err = s.VMByName("web-1") | 56 | _, err = s.VMByTenantName(DefaultTenant, "web-1") |
| 57 | assert.ErrorIs(t, err, sql.ErrNoRows) | 57 | assert.ErrorIs(t, err, sql.ErrNoRows) |
| 58 | } | 58 | } |
| 59 | 59 | ||
| @@ -72,7 +72,7 @@ func TestGetVM(t *testing.T) { | |||
| 72 | _, err = s.GetVM("no-such-id") | 72 | _, err = s.GetVM("no-such-id") |
| 73 | assert.ErrorIs(t, err, sql.ErrNoRows) | 73 | assert.ErrorIs(t, err, sql.ErrNoRows) |
| 74 | 74 | ||
| 75 | // Unlike VMByName, GetVM must still find a tombstoned row — the | 75 | // Unlike VMByTenantName, GetVM must still find a tombstoned row — the |
| 76 | // patch/delete/restore callers operate on VMs that may be tombstoned. | 76 | // patch/delete/restore callers operate on VMs that may be tombstoned. |
| 77 | require.NoError(t, s.TombstoneVM(vm.ID)) | 77 | require.NoError(t, s.TombstoneVM(vm.ID)) |
| 78 | got, err = s.GetVM(vm.ID) | 78 | got, err = s.GetVM(vm.ID) |
| @@ -94,14 +94,14 @@ func TestVMHostKeyAndCertPersist(t *testing.T) { | |||
| 94 | })) | 94 | })) |
| 95 | 95 | ||
| 96 | // The private key + cert must round-trip through both read paths the | 96 | // The private key + cert must round-trip through both read paths the |
| 97 | // snapshot/gate rely on: DesiredForHost (agent-facing) and VMByName. | 97 | // snapshot/gate rely on: DesiredForHost (agent-facing) and VMByTenantName. |
| 98 | _, vms, err := s.DesiredForHost(h.ID) | 98 | _, vms, err := s.DesiredForHost(h.ID) |
| 99 | require.NoError(t, err) | 99 | require.NoError(t, err) |
| 100 | require.Len(t, vms, 1) | 100 | require.Len(t, vms, 1) |
| 101 | assert.Equal(t, keyPEM, vms[0].SSHHostKey) | 101 | assert.Equal(t, keyPEM, vms[0].SSHHostKey) |
| 102 | assert.Equal(t, cert, vms[0].SSHHostCert) | 102 | assert.Equal(t, cert, vms[0].SSHHostCert) |
| 103 | 103 | ||
| 104 | byName, err := s.VMByName("with-hostcert") | 104 | byName, err := s.VMByTenantName(DefaultTenant, "with-hostcert") |
| 105 | require.NoError(t, err) | 105 | require.NoError(t, err) |
| 106 | assert.Equal(t, keyPEM, byName.SSHHostKey) | 106 | assert.Equal(t, keyPEM, byName.SSHHostKey) |
| 107 | assert.Equal(t, cert, byName.SSHHostCert) | 107 | assert.Equal(t, cert, byName.SSHHostCert) |
| @@ -163,7 +163,7 @@ func TestSSHCertRevocationLargeSerial(t *testing.T) { | |||
| 163 | 163 | ||
| 164 | func TestEnrollmentAssignsSequentialCIDRsAndIsOneTimeUse(t *testing.T) { | 164 | func TestEnrollmentAssignsSequentialCIDRsAndIsOneTimeUse(t *testing.T) { |
| 165 | s := newStore(t) | 165 | s := newStore(t) |
| 166 | tok1, _ := s.CreateEnrollmentToken() | 166 | tok1, _ := s.CreateEnrollmentToken(DefaultTenant) |
| 167 | h1, err := s.RedeemEnrollmentToken(tok1, "a", "linux", "amd64", "cloudhv", "") | 167 | h1, err := s.RedeemEnrollmentToken(tok1, "a", "linux", "amd64", "cloudhv", "") |
| 168 | require.NoError(t, err) | 168 | require.NoError(t, err) |
| 169 | assert.Equal(t, "10.77.1.0/24", h1.BridgeCIDR) | 169 | assert.Equal(t, "10.77.1.0/24", h1.BridgeCIDR) |
| @@ -171,7 +171,7 @@ func TestEnrollmentAssignsSequentialCIDRsAndIsOneTimeUse(t *testing.T) { | |||
| 171 | _, err = s.RedeemEnrollmentToken(tok1, "b", "linux", "amd64", "cloudhv", "") | 171 | _, err = s.RedeemEnrollmentToken(tok1, "b", "linux", "amd64", "cloudhv", "") |
| 172 | assert.Error(t, err, "token must be one-time use") | 172 | assert.Error(t, err, "token must be one-time use") |
| 173 | 173 | ||
| 174 | tok2, _ := s.CreateEnrollmentToken() | 174 | tok2, _ := s.CreateEnrollmentToken(DefaultTenant) |
| 175 | h2, _ := s.RedeemEnrollmentToken(tok2, "b", "linux", "amd64", "cloudhv", "") | 175 | h2, _ := s.RedeemEnrollmentToken(tok2, "b", "linux", "amd64", "cloudhv", "") |
| 176 | assert.Equal(t, "10.77.2.0/24", h2.BridgeCIDR) | 176 | assert.Equal(t, "10.77.2.0/24", h2.BridgeCIDR) |
| 177 | } | 177 | } |
| @@ -258,7 +258,7 @@ func TestEnrollmentCIDRWorksForNonSlash16Pools(t *testing.T) { | |||
| 258 | s, err := Open(t.TempDir()+"/eitri.db", "192.168.4.0/22") | 258 | s, err := Open(t.TempDir()+"/eitri.db", "192.168.4.0/22") |
| 259 | require.NoError(t, err) | 259 | require.NoError(t, err) |
| 260 | defer s.Close() | 260 | defer s.Close() |
| 261 | tok, _ := s.CreateEnrollmentToken() | 261 | tok, _ := s.CreateEnrollmentToken(DefaultTenant) |
| 262 | h, err := s.RedeemEnrollmentToken(tok, "a", "linux", "amd64", "cloudhv", "") | 262 | h, err := s.RedeemEnrollmentToken(tok, "a", "linux", "amd64", "cloudhv", "") |
| 263 | require.NoError(t, err) | 263 | require.NoError(t, err) |
| 264 | assert.Equal(t, "192.168.5.0/24", h.BridgeCIDR, "1st /24 within the pool, 0th reserved") | 264 | assert.Equal(t, "192.168.5.0/24", h.BridgeCIDR, "1st /24 within the pool, 0th reserved") |
| @@ -268,11 +268,11 @@ func TestEnrollmentFailsWhenPoolExhausted(t *testing.T) { | |||
| 268 | s, err := Open(t.TempDir()+"/eitri.db", "10.9.8.0/23") // room for exactly one assignable /24 (idx 1) | 268 | s, err := Open(t.TempDir()+"/eitri.db", "10.9.8.0/23") // room for exactly one assignable /24 (idx 1) |
| 269 | require.NoError(t, err) | 269 | require.NoError(t, err) |
| 270 | defer s.Close() | 270 | defer s.Close() |
| 271 | tok1, _ := s.CreateEnrollmentToken() | 271 | tok1, _ := s.CreateEnrollmentToken(DefaultTenant) |
| 272 | h, err := s.RedeemEnrollmentToken(tok1, "a", "linux", "amd64", "cloudhv", "") | 272 | h, err := s.RedeemEnrollmentToken(tok1, "a", "linux", "amd64", "cloudhv", "") |
| 273 | require.NoError(t, err) | 273 | require.NoError(t, err) |
| 274 | assert.Equal(t, "10.9.9.0/24", h.BridgeCIDR) | 274 | assert.Equal(t, "10.9.9.0/24", h.BridgeCIDR) |
| 275 | tok2, _ := s.CreateEnrollmentToken() | 275 | tok2, _ := s.CreateEnrollmentToken(DefaultTenant) |
| 276 | _, err = s.RedeemEnrollmentToken(tok2, "b", "linux", "amd64", "cloudhv", "") | 276 | _, err = s.RedeemEnrollmentToken(tok2, "b", "linux", "amd64", "cloudhv", "") |
| 277 | assert.ErrorContains(t, err, "exhausted") | 277 | assert.ErrorContains(t, err, "exhausted") |
| 278 | } | 278 | } |
| @@ -310,7 +310,7 @@ func TestForceRemoveHostPurgesVMsAndFreesCIDR(t *testing.T) { | |||
| 310 | 310 | ||
| 311 | // The freed CIDR is consulted before the monotonic allocator, so a fresh | 311 | // The freed CIDR is consulted before the monotonic allocator, so a fresh |
| 312 | // enrollment reuses it. | 312 | // enrollment reuses it. |
| 313 | tok, err := s.CreateEnrollmentToken() | 313 | tok, err := s.CreateEnrollmentToken(DefaultTenant) |
| 314 | require.NoError(t, err) | 314 | require.NoError(t, err) |
| 315 | h2, err := s.RedeemEnrollmentToken(tok, "host-b", "linux", "amd64", "cloudhv", "") | 315 | h2, err := s.RedeemEnrollmentToken(tok, "host-b", "linux", "amd64", "cloudhv", "") |
| 316 | require.NoError(t, err) | 316 | require.NoError(t, err) |
| @@ -482,7 +482,7 @@ func TestListVMEvents(t *testing.T) { | |||
| 482 | // enrolled host can never exist without its durable audit record. | 482 | // enrolled host can never exist without its durable audit record. |
| 483 | func TestRedeemWritesAuditRowAtomically(t *testing.T) { | 483 | func TestRedeemWritesAuditRowAtomically(t *testing.T) { |
| 484 | s := newStore(t) | 484 | s := newStore(t) |
| 485 | tok, err := s.CreateEnrollmentToken() | 485 | tok, err := s.CreateEnrollmentToken(DefaultTenant) |
| 486 | require.NoError(t, err) | 486 | require.NoError(t, err) |
| 487 | h, err := s.RedeemEnrollmentToken(tok, "host-a", "linux", "amd64", "cloudhv", "192.0.2.9") | 487 | h, err := s.RedeemEnrollmentToken(tok, "host-a", "linux", "amd64", "cloudhv", "192.0.2.9") |
| 488 | require.NoError(t, err) | 488 | require.NoError(t, err) |
| @@ -569,3 +569,87 @@ func TestPruneAuditGuardsNonPositiveWindow(t *testing.T) { | |||
| 569 | require.NoError(t, err) | 569 | require.NoError(t, err) |
| 570 | assert.Len(t, rows, 1, "nothing may be deleted by a non-positive window") | 570 | assert.Len(t, rows, 1, "nothing may be deleted by a non-positive window") |
| 571 | } | 571 | } |
| 572 | |||
| 573 | func TestOpenSeedsDefaultTenant(t *testing.T) { | ||
| 574 | s := newStore(t) | ||
| 575 | var name string | ||
| 576 | require.NoError(t, s.db.QueryRow(`SELECT name FROM tenants WHERE id=?`, DefaultTenant).Scan(&name)) | ||
| 577 | assert.Equal(t, "default", name) | ||
| 578 | // enrollment_tokens carries the tenant the enrolling host will join. | ||
| 579 | var tenantCol int | ||
| 580 | require.NoError(t, s.db.QueryRow( | ||
| 581 | `SELECT count(*) FROM pragma_table_info('enrollment_tokens') WHERE name='tenant'`).Scan(&tenantCol)) | ||
| 582 | assert.Equal(t, 1, tenantCol) | ||
| 583 | } | ||
| 584 | |||
| 585 | // insertTenantHost plants a tenant + host row directly, bypassing enrollment. | ||
| 586 | func insertTenantHost(t *testing.T, s *Store, tenant, hostID string) { | ||
| 587 | t.Helper() | ||
| 588 | _, err := s.db.Exec(`INSERT INTO tenants(id, name, created_at) VALUES (?,?,?) ON CONFLICT DO NOTHING`, | ||
| 589 | tenant, tenant, "2026-01-01T00:00:00Z") | ||
| 590 | require.NoError(t, err) | ||
| 591 | _, err = s.db.Exec(`INSERT INTO hosts(id, name, os, arch, provisioner, bridge_cidr, enrolled_at, tenant) | ||
| 592 | VALUES (?, ?, 'linux', 'amd64', 'cloudhv', ?, '2026-01-01T00:00:00Z', ?)`, | ||
| 593 | hostID, "host-"+hostID, "10.77.0.0/24", tenant) | ||
| 594 | require.NoError(t, err) | ||
| 595 | } | ||
| 596 | |||
| 597 | func TestCreateVMDerivesTenantFromHost(t *testing.T) { | ||
| 598 | s := newStore(t) | ||
| 599 | insertTenantHost(t, s, "t2", "h-t2") | ||
| 600 | // Client-supplied Tenant must be IGNORED — derivation is authoritative. | ||
| 601 | require.NoError(t, s.CreateVM(VM{ID: "v1", HostID: "h-t2", Name: "web", ImageURL: "u", ImageSHA256: "s", | ||
| 602 | VCPUs: 1, MemMB: 512, DiskGB: 5, PowerState: "running", Tenant: "attacker-chosen"})) | ||
| 603 | vm, err := s.GetVM("v1") | ||
| 604 | require.NoError(t, err) | ||
| 605 | assert.Equal(t, "t2", vm.Tenant) | ||
| 606 | } | ||
| 607 | |||
| 608 | func TestVMNameUniquePerTenant(t *testing.T) { | ||
| 609 | s := newStore(t) | ||
| 610 | insertTenantHost(t, s, "t2", "h-t2") | ||
| 611 | insertTenantHost(t, s, "t3", "h-t3") | ||
| 612 | mk := func(id, host, name string) error { | ||
| 613 | return s.CreateVM(VM{ID: id, HostID: host, Name: name, ImageURL: "u", ImageSHA256: "s", | ||
| 614 | VCPUs: 1, MemMB: 512, DiskGB: 5, PowerState: "running"}) | ||
| 615 | } | ||
| 616 | require.NoError(t, mk("v1", "h-t2", "web")) | ||
| 617 | require.NoError(t, mk("v2", "h-t3", "web"), "same name in a DIFFERENT tenant must be allowed") | ||
| 618 | assert.ErrorIs(t, mk("v3", "h-t2", "web"), ErrNameTaken, "same name in the SAME tenant must collide") | ||
| 619 | } | ||
| 620 | |||
| 621 | func TestVMByTenantNameIsScoped(t *testing.T) { | ||
| 622 | s := newStore(t) | ||
| 623 | insertTenantHost(t, s, "t2", "h-t2") | ||
| 624 | insertTenantHost(t, s, "t3", "h-t3") | ||
| 625 | require.NoError(t, s.CreateVM(VM{ID: "v1", HostID: "h-t2", Name: "web", ImageURL: "u", ImageSHA256: "s", | ||
| 626 | VCPUs: 1, MemMB: 512, DiskGB: 5, PowerState: "running"})) | ||
| 627 | got, err := s.VMByTenantName("t2", "web") | ||
| 628 | require.NoError(t, err) | ||
| 629 | assert.Equal(t, "v1", got.ID) | ||
| 630 | _, err = s.VMByTenantName("t3", "web") | ||
| 631 | assert.ErrorIs(t, err, sql.ErrNoRows, "resolution must not cross tenants") | ||
| 632 | _, err = s.VMByTenantName(DefaultTenant, "web") | ||
| 633 | assert.ErrorIs(t, err, sql.ErrNoRows) | ||
| 634 | } | ||
| 635 | |||
| 636 | func TestEnrollmentCarriesTenantToHost(t *testing.T) { | ||
| 637 | s := newStore(t) | ||
| 638 | _, err := s.db.Exec(`INSERT INTO tenants(id, name, created_at) VALUES ('t2','t2','2026-01-01T00:00:00Z')`) | ||
| 639 | require.NoError(t, err) | ||
| 640 | |||
| 641 | tok, err := s.CreateEnrollmentToken("t2") | ||
| 642 | require.NoError(t, err) | ||
| 643 | h, err := s.RedeemEnrollmentToken(tok, "box", "linux", "amd64", "cloudhv", "203.0.113.9") | ||
| 644 | require.NoError(t, err) | ||
| 645 | assert.Equal(t, "t2", h.Tenant, "host must inherit the token's tenant") | ||
| 646 | got, err := s.GetHost(h.ID) | ||
| 647 | require.NoError(t, err) | ||
| 648 | assert.Equal(t, "t2", got.Tenant) | ||
| 649 | } | ||
| 650 | |||
| 651 | func TestCreateEnrollmentTokenRejectsUnknownTenant(t *testing.T) { | ||
| 652 | s := newStore(t) | ||
| 653 | _, err := s.CreateEnrollmentToken("no-such-tenant") | ||
| 654 | require.ErrorContains(t, err, "unknown tenant", "minting for a nonexistent tenant must fail at mint, not at redeem") | ||
| 655 | } | ||
internal/server/syncsvc/syncsvc_test.go
| Old | New | ||
|---|---|---|---|
| @@ -43,7 +43,7 @@ func setupWithWriteTimeout(t *testing.T, writeTimeout time.Duration) *fixture { | |||
| 43 | st, err := store.Open(t.TempDir()+"/db", "10.77.0.0/16") | 43 | st, err := store.Open(t.TempDir()+"/db", "10.77.0.0/16") |
| 44 | require.NoError(t, err) | 44 | require.NoError(t, err) |
| 45 | t.Cleanup(func() { st.Close() }) | 45 | t.Cleanup(func() { st.Close() }) |
| 46 | tok, _ := st.CreateEnrollmentToken() | 46 | tok, _ := st.CreateEnrollmentToken(store.DefaultTenant) |
| 47 | host, err := st.RedeemEnrollmentToken(tok, "h", "linux", "amd64", "cloudhv", "") | 47 | host, err := st.RedeemEnrollmentToken(tok, "h", "linux", "amd64", "cloudhv", "") |
| 48 | require.NoError(t, err) | 48 | require.NoError(t, err) |
| 49 | 49 | ||
| @@ -453,7 +453,7 @@ func TestExpiredCredentialRejectedWhenMaxAgeSet(t *testing.T) { | |||
| 453 | st, err := store.Open(t.TempDir()+"/db", "10.77.0.0/16") | 453 | st, err := store.Open(t.TempDir()+"/db", "10.77.0.0/16") |
| 454 | require.NoError(t, err) | 454 | require.NoError(t, err) |
| 455 | t.Cleanup(func() { st.Close() }) | 455 | t.Cleanup(func() { st.Close() }) |
| 456 | tok, _ := st.CreateEnrollmentToken() | 456 | tok, _ := st.CreateEnrollmentToken(store.DefaultTenant) |
| 457 | host, err := st.RedeemEnrollmentToken(tok, "h", "linux", "amd64", "cloudhv", "") | 457 | host, err := st.RedeemEnrollmentToken(tok, "h", "linux", "amd64", "cloudhv", "") |
| 458 | require.NoError(t, err) | 458 | require.NoError(t, err) |
| 459 | 459 | ||
| @@ -495,7 +495,7 @@ func TestMaxAgeEnforcedMidSession(t *testing.T) { | |||
| 495 | st, err := store.Open(t.TempDir()+"/db", "10.77.0.0/16") | 495 | st, err := store.Open(t.TempDir()+"/db", "10.77.0.0/16") |
| 496 | require.NoError(t, err) | 496 | require.NoError(t, err) |
| 497 | t.Cleanup(func() { st.Close() }) | 497 | t.Cleanup(func() { st.Close() }) |
| 498 | tok, _ := st.CreateEnrollmentToken() | 498 | tok, _ := st.CreateEnrollmentToken(store.DefaultTenant) |
| 499 | host, err := st.RedeemEnrollmentToken(tok, "h", "linux", "amd64", "cloudhv", "") | 499 | host, err := st.RedeemEnrollmentToken(tok, "h", "linux", "amd64", "cloudhv", "") |
| 500 | require.NoError(t, err) | 500 | require.NoError(t, err) |
| 501 | 501 | ||
web/src/lib/api-types.ts
| Old | New | ||
|---|---|---|---|
| @@ -950,6 +950,7 @@ export interface components { | |||
| 950 | }; | 950 | }; |
| 951 | SSHCertResponse: { | 951 | SSHCertResponse: { |
| 952 | certificate: string; | 952 | certificate: string; |
| 953 | tenant: string; | ||
| 953 | }; | 954 | }; |
| 954 | StateSnapshot: { | 955 | StateSnapshot: { |
| 955 | hosts: components["schemas"]["Host"][]; | 956 | hosts: components["schemas"]["Host"][]; |