5080647e
fix(server): a one-click create takes the image its host can run
a73x 2026-08-06 09:12
Commit message
docs/quickstart.md
| Old | New | ||
|---|---|---|---|
| @@ -137,9 +137,10 @@ Set `SERVER_ADDR`, paste the rest: | |||
| 137 | ```sh | 137 | ```sh |
| 138 | SERVER_ADDR=192.0.2.10 | 138 | SERVER_ADDR=192.0.2.10 |
| 139 | IMAGE_DIR=https://cloud-images.ubuntu.com/resolute/current | 139 | IMAGE_DIR=https://cloud-images.ubuntu.com/resolute/current |
| 140 | IMAGE_FILE=resolute-server-cloudimg-amd64.img | ||
| 141 | HOST_SECRET=$(openssl rand -hex 32) | 140 | HOST_SECRET=$(openssl rand -hex 32) |
| 142 | IMAGE_SHA256=$(curl -fsSL "$IMAGE_DIR/SHA256SUMS" | awk -v f="$IMAGE_FILE" '$2 == "*" f {print $1}') | 141 | sha() { curl -fsSL "$IMAGE_DIR/SHA256SUMS" | awk -v f="resolute-server-cloudimg-$1.img" '$2 == "*" f {print $1}'; } |
| 142 | AMD64_SHA=$(sha amd64) | ||
| 143 | ARM64_SHA=$(sha arm64) | ||
| 143 | 144 | ||
| 144 | sudo tee /etc/eitri/server.json >/dev/null <<EOF | 145 | sudo tee /etc/eitri/server.json >/dev/null <<EOF |
| 145 | { | 146 | { |
| @@ -155,8 +156,10 @@ sudo tee /etc/eitri/server.json >/dev/null <<EOF | |||
| 155 | "public_url": "http://$SERVER_ADDR:8080" | 156 | "public_url": "http://$SERVER_ADDR:8080" |
| 156 | }, | 157 | }, |
| 157 | "host_secret": "$HOST_SECRET", | 158 | "host_secret": "$HOST_SECRET", |
| 158 | "default_image_url": "$IMAGE_DIR/$IMAGE_FILE", | 159 | "default_images": { |
| 159 | "default_image_sha256": "$IMAGE_SHA256", | 160 | "amd64": {"url": "$IMAGE_DIR/resolute-server-cloudimg-amd64.img", "sha256": "$AMD64_SHA"}, |
| 161 | "arm64": {"url": "$IMAGE_DIR/resolute-server-cloudimg-arm64.img", "sha256": "$ARM64_SHA"} | ||
| 162 | }, | ||
| 160 | "ssh_listen": ":2222", | 163 | "ssh_listen": ":2222", |
| 161 | "ssh_gate_domain": "$SERVER_ADDR", | 164 | "ssh_gate_domain": "$SERVER_ADDR", |
| 162 | "ssh_ca_key": "/var/lib/eitri/ssh_ca", | 165 | "ssh_ca_key": "/var/lib/eitri/ssh_ca", |
| @@ -170,9 +173,16 @@ The chmod matters: `server.json` carries `host_secret`, so it is root-owned | |||
| 170 | and readable only via the `eitri` group—not world-readable. | 173 | and readable only via the `eitri` group—not world-readable. |
| 171 | 174 | ||
| 172 | `advertise_*` is what hosts and your laptop dial. Not `127.0.0.1`. Any | 175 | `advertise_*` is what hosts and your laptop dial. Not `127.0.0.1`. Any |
| 173 | cloud-init disk image works as the default image; the Ubuntu one boots out of | 176 | cloud-init disk image works as a default image; the Ubuntu one boots out of |
| 174 | the box. | 177 | the box. |
| 175 | 178 | ||
| 179 | `default_images` is keyed by the architecture of the host a VM lands on, and a | ||
| 180 | VM created without an explicit image takes the entry for its host. Configure | ||
| 181 | every architecture in your fleet: a host can only run a guest built for its own | ||
| 182 | CPU, and a create for an architecture you have not listed is refused rather than | ||
| 183 | served an image that cannot boot. One entry is plenty for a single-architecture | ||
| 184 | fleet—both are shown because an Apple silicon host takes `arm64`. | ||
| 185 | |||
| 176 | The `oidc` block points the console's sign-in at the bundled issuer you start | 186 | The `oidc` block points the console's sign-in at the bundled issuer you start |
| 177 | next. `public_url` is where browsers reach the console (the callback lands at | 187 | next. `public_url` is where browsers reach the console (the callback lands at |
| 178 | `$public_url/auth/callback`), so keep it equal to `advertise_http`. Bringing | 188 | `$public_url/auth/callback`), so keep it equal to `advertise_http`. Bringing |
internal/server/api/api.go
| Old | New | ||
|---|---|---|---|
| @@ -8,6 +8,7 @@ import ( | |||
| 8 | "encoding/hex" | 8 | "encoding/hex" |
| 9 | "encoding/json" | 9 | "encoding/json" |
| 10 | "errors" | 10 | "errors" |
| 11 | "fmt" | ||
| 11 | "log/slog" | 12 | "log/slog" |
| 12 | "net/http" | 13 | "net/http" |
| 13 | "regexp" | 14 | "regexp" |
| @@ -39,11 +40,14 @@ type DefaultImage struct { | |||
| 39 | 40 | ||
| 40 | // Config holds static configuration for the API server. | 41 | // Config holds static configuration for the API server. |
| 41 | type Config struct { | 42 | type Config struct { |
| 42 | HostSecret []byte | 43 | HostSecret []byte |
| 43 | DefaultImage DefaultImage | 44 | // DefaultImages is keyed by host ARCHITECTURE ("amd64", "arm64"). A |
| 45 | // one-click create takes the entry matching the host it is placed on; see | ||
| 46 | // applyVMDefaults. | ||
| 47 | DefaultImages map[string]DefaultImage | ||
| 44 | ServerCertSHA256 string | 48 | ServerCertSHA256 string |
| 45 | AdvertiseHTTP string // HTTP base URL agents use to reach this server (scheme+host+port) | 49 | AdvertiseHTTP string // HTTP base URL agents use to reach this server (scheme+host+port) |
| 46 | AdvertiseQUIC string // QUIC host:port agents use to reach this server | 50 | AdvertiseQUIC string // QUIC host:port agents use to reach this server |
| 47 | OIDC OIDCConfig // console sign-in relying-party settings (see auth.go) | 51 | OIDC OIDCConfig // console sign-in relying-party settings (see auth.go) |
| 48 | } | 52 | } |
| 49 | 53 | ||
| @@ -622,17 +626,32 @@ func (a *API) handleListVMs(w http.ResponseWriter, r *http.Request) { | |||
| 622 | writeJSON(w, http.StatusOK, out) | 626 | writeJSON(w, http.StatusOK, out) |
| 623 | } | 627 | } |
| 624 | 628 | ||
| 625 | // applyVMDefaults fills one-click defaults in place. It returns an error message | 629 | // applyVMDefaults fills one-click defaults in place. hostArch is the |
| 626 | // and HTTP status (msg=="" when ok) for the image-pairing rule, which is a | 630 | // architecture of the host the VM is being placed on, which selects the default |
| 627 | // validation, not a default. | 631 | // image: a host can only execute a guest built for its own CPU, and nothing |
| 628 | func (a *API) applyVMDefaults(req *types.CreateVMRequest) (string, int) { | 632 | // downstream checks — an image the host cannot run boots into nothing and is |
| 633 | // reported as a lost VM, with no clue as to why. | ||
| 634 | // | ||
| 635 | // An explicitly supplied image is NOT arch-checked. A URL does not say what its | ||
| 636 | // contents can execute, and guessing from the filename would reject legitimate | ||
| 637 | // custom images to catch a mistake the operator made deliberately. The guard is | ||
| 638 | // on the default, which eitri chooses, not on the choice the caller made. | ||
| 639 | // | ||
| 640 | // It returns an error message and HTTP status (msg=="" when ok) for the | ||
| 641 | // image-pairing rule, which is a validation, not a default. | ||
| 642 | func (a *API) applyVMDefaults(req *types.CreateVMRequest, hostArch string) (string, int) { | ||
| 629 | if req.Name == "" { | 643 | if req.Name == "" { |
| 630 | req.Name = "sandbox-" + random.Hex(3) | 644 | req.Name = "sandbox-" + random.Hex(3) |
| 631 | } | 645 | } |
| 632 | // Image URL+SHA must come as a pair; apply defaults only when BOTH are empty. | 646 | // Image URL+SHA must come as a pair; apply defaults only when BOTH are empty. |
| 633 | if req.ImageURL == "" && req.ImageSHA256 == "" { | 647 | if req.ImageURL == "" && req.ImageSHA256 == "" { |
| 634 | req.ImageURL = a.cfg.DefaultImage.URL | 648 | img, ok := a.cfg.DefaultImages[hostArch] |
| 635 | req.ImageSHA256 = a.cfg.DefaultImage.SHA256 | 649 | if !ok { |
| 650 | return fmt.Sprintf("no default image configured for %s hosts; pass image_url and image_sha256, "+ | ||
| 651 | "or add default_images[%q] to the server config", hostArch, hostArch), http.StatusBadRequest | ||
| 652 | } | ||
| 653 | req.ImageURL = img.URL | ||
| 654 | req.ImageSHA256 = img.SHA256 | ||
| 636 | } else if req.ImageURL == "" || req.ImageSHA256 == "" { | 655 | } else if req.ImageURL == "" || req.ImageSHA256 == "" { |
| 637 | return "image_url and image_sha256 must be provided together", http.StatusBadRequest | 656 | return "image_url and image_sha256 must be provided together", http.StatusBadRequest |
| 638 | } | 657 | } |
| @@ -689,18 +708,14 @@ func (a *API) handleCreateVM(w http.ResponseWriter, r *http.Request) { | |||
| 689 | return | 708 | return |
| 690 | } | 709 | } |
| 691 | 710 | ||
| 692 | if msg, code := a.applyVMDefaults(&req); msg != "" { | ||
| 693 | http.Error(w, msg, code) | ||
| 694 | return | ||
| 695 | } | ||
| 696 | if msg, code := validateCreateVM(&req); msg != "" { | ||
| 697 | http.Error(w, msg, code) | ||
| 698 | return | ||
| 699 | } | ||
| 700 | |||
| 701 | // Tenant gate: you may only place VMs on hosts in your tenant. The host | 711 | // Tenant gate: you may only place VMs on hosts in your tenant. The host |
| 702 | // read also feeds the namespaced host-cert principal below. CreateVM | 712 | // read also feeds the namespaced host-cert principal below, and the default |
| 703 | // re-checks host status in-tx; this pre-read is the AUTHZ point. | 713 | // image, which follows the host's architecture. CreateVM re-checks host |
| 714 | // status in-tx; this pre-read is the AUTHZ point. | ||
| 715 | // | ||
| 716 | // It runs BEFORE defaults and validation: a caller who may not see this host | ||
| 717 | // gets "unknown host_id" whatever else is wrong with the body, rather than a | ||
| 718 | // validation error that confirms the request got as far as the host. | ||
| 704 | host, err := a.st.GetHost(req.HostID) | 719 | host, err := a.st.GetHost(req.HostID) |
| 705 | if err != nil { | 720 | if err != nil { |
| 706 | if errors.Is(err, sql.ErrNoRows) { | 721 | if errors.Is(err, sql.ErrNoRows) { |
| @@ -716,6 +731,15 @@ func (a *API) handleCreateVM(w http.ResponseWriter, r *http.Request) { | |||
| 716 | return | 731 | return |
| 717 | } | 732 | } |
| 718 | 733 | ||
| 734 | if msg, code := a.applyVMDefaults(&req, host.Arch); msg != "" { | ||
| 735 | http.Error(w, msg, code) | ||
| 736 | return | ||
| 737 | } | ||
| 738 | if msg, code := validateCreateVM(&req); msg != "" { | ||
| 739 | http.Error(w, msg, code) | ||
| 740 | return | ||
| 741 | } | ||
| 742 | |||
| 719 | // BYO CA precondition: a VM with no trusted user CA baked at create is | 743 | // BYO CA precondition: a VM with no trusted user CA baked at create is |
| 720 | // unreachable. Require the tenant to have registered ≥1 user CA first. | 744 | // unreachable. Require the tenant to have registered ≥1 user CA first. |
| 721 | if has, err := a.st.TenantHasUserCA(host.Tenant); err != nil { | 745 | if has, err := a.st.TenantHasUserCA(host.Tenant); err != nil { |
internal/server/api/api_test.go
| Old | New | ||
|---|---|---|---|
| @@ -3,6 +3,7 @@ package api | |||
| 3 | import ( | 3 | import ( |
| 4 | "bytes" | 4 | "bytes" |
| 5 | "encoding/json" | 5 | "encoding/json" |
| 6 | "io" | ||
| 6 | "maps" | 7 | "maps" |
| 7 | "net/http" | 8 | "net/http" |
| 8 | "net/http/httptest" | 9 | "net/http/httptest" |
| @@ -124,6 +125,71 @@ func TestCreateVMDuplicateNameReturns409(t *testing.T) { | |||
| 124 | assert.NotContains(t, string(bodyStr), "UNIQUE", "raw SQLite error must not leak into response") | 125 | assert.NotContains(t, string(bodyStr), "UNIQUE", "raw SQLite error must not leak into response") |
| 125 | } | 126 | } |
| 126 | 127 | ||
| 128 | // enrollArch enrols a host with a given name/os/arch, so a test can place a VM | ||
| 129 | // on something other than the default linux/amd64 host. | ||
| 130 | func enrollArch(t *testing.T, ts *httptest.Server, name, os, arch, prov string) map[string]string { | ||
| 131 | t.Helper() | ||
| 132 | resp := do(t, "POST", ts.URL+"/api/v1/enroll-tokens", testPAT, nil) | ||
| 133 | require.Equal(t, 201, resp.StatusCode) | ||
| 134 | var tok map[string]string | ||
| 135 | json.NewDecoder(resp.Body).Decode(&tok) | ||
| 136 | resp = do(t, "POST", ts.URL+"/api/v1/enroll", "", map[string]string{ | ||
| 137 | "token": tok["token"], "name": name, "os": os, "arch": arch, "provisioner": prov}) | ||
| 138 | require.Equal(t, 201, resp.StatusCode) | ||
| 139 | var out map[string]string | ||
| 140 | json.NewDecoder(resp.Body).Decode(&out) | ||
| 141 | return out | ||
| 142 | } | ||
| 143 | |||
| 144 | // TestCreateVMDefaultImageFollowsHostArch pins the rule a mixed-arch fleet | ||
| 145 | // depends on: a one-click create takes the default image for the architecture of | ||
| 146 | // the host it lands on. The fleet-wide default that preceded this handed an | ||
| 147 | // arm64 Mac an amd64 image, which boots into nothing and surfaces only as | ||
| 148 | // "ephemeral VM lost" once the hypervisor exits. | ||
| 149 | func TestCreateVMDefaultImageFollowsHostArch(t *testing.T) { | ||
| 150 | ts, st, _ := testServer(t) | ||
| 151 | linux := enroll(t, ts) // linux/amd64 | ||
| 152 | mac := enrollArch(t, ts, "host-m", "darwin", "arm64", "vfkit") //nolint:misspell // vfkit | ||
| 153 | riscv := enrollArch(t, ts, "host-r", "linux", "riscv64", "cloudhv") // no configured image | ||
| 154 | |||
| 155 | // testServer configures amd64 and arm64 (see the Config literal above). | ||
| 156 | for _, tc := range []struct{ name, hostID, wantImage string }{ | ||
| 157 | {"amd64 host", linux["host_id"], "amd64"}, | ||
| 158 | {"arm64 host", mac["host_id"], "arm64"}, | ||
| 159 | } { | ||
| 160 | t.Run(tc.name, func(t *testing.T) { | ||
| 161 | resp := do(t, "POST", ts.URL+"/api/v1/vms", testPAT, | ||
| 162 | map[string]any{"host_id": tc.hostID, "name": "vm-" + tc.wantImage}) | ||
| 163 | require.Equal(t, 201, resp.StatusCode) | ||
| 164 | var out map[string]string | ||
| 165 | json.NewDecoder(resp.Body).Decode(&out) | ||
| 166 | vm, err := st.GetVM(out["id"]) | ||
| 167 | require.NoError(t, err) | ||
| 168 | assert.Contains(t, vm.ImageURL, tc.wantImage, | ||
| 169 | "the default image must match the host's architecture") | ||
| 170 | }) | ||
| 171 | } | ||
| 172 | |||
| 173 | // An architecture with no configured image is a 400 that names it — not a | ||
| 174 | // silent fallback to some other arch's image. | ||
| 175 | t.Run("unconfigured arch refuses", func(t *testing.T) { | ||
| 176 | resp := do(t, "POST", ts.URL+"/api/v1/vms", testPAT, | ||
| 177 | map[string]any{"host_id": riscv["host_id"], "name": "vm-riscv"}) | ||
| 178 | require.Equal(t, 400, resp.StatusCode) | ||
| 179 | body, _ := io.ReadAll(resp.Body) | ||
| 180 | assert.Contains(t, string(body), "riscv64", "the error must name the architecture") | ||
| 181 | }) | ||
| 182 | |||
| 183 | // An EXPLICIT image is never arch-checked: a URL says nothing about what it | ||
| 184 | // can execute, and guessing would reject legitimate custom images. | ||
| 185 | t.Run("explicit image is not second-guessed", func(t *testing.T) { | ||
| 186 | resp := do(t, "POST", ts.URL+"/api/v1/vms", testPAT, map[string]any{ | ||
| 187 | "host_id": mac["host_id"], "name": "vm-explicit", | ||
| 188 | "image_url": "https://example.test/my-amd64-build.img", "image_sha256": strings.Repeat("b", 64)}) | ||
| 189 | assert.Equal(t, 201, resp.StatusCode) | ||
| 190 | }) | ||
| 191 | } | ||
| 192 | |||
| 127 | // TestCreateVMUnknownHostReturns400 pins that an unknown host_id returns 400. | 193 | // TestCreateVMUnknownHostReturns400 pins that an unknown host_id returns 400. |
| 128 | func TestCreateVMUnknownHostReturns400(t *testing.T) { | 194 | func TestCreateVMUnknownHostReturns400(t *testing.T) { |
| 129 | ts, _, _ := testServer(t) | 195 | ts, _, _ := testServer(t) |
| @@ -217,9 +283,14 @@ func newServer(t *testing.T) (*httptest.Server, *store.Store, *hub.Hub, *registr | |||
| 217 | reg := registry.New(time.Now) | 283 | reg := registry.New(time.Now) |
| 218 | a := New(Config{ | 284 | a := New(Config{ |
| 219 | HostSecret: []byte("hostsecret"), | 285 | HostSecret: []byte("hostsecret"), |
| 220 | DefaultImage: DefaultImage{ | 286 | DefaultImages: map[string]DefaultImage{ |
| 221 | URL: "https://cloud-images.ubuntu.com/noble/current/noble-server-cloudimg-amd64.img", | 287 | "amd64": { |
| 222 | SHA256: strings.Repeat("a", 64)}, | 288 | URL: "https://cloud-images.ubuntu.com/noble/current/noble-server-cloudimg-amd64.img", |
| 289 | SHA256: strings.Repeat("a", 64)}, | ||
| 290 | "arm64": { | ||
| 291 | URL: "https://cloud-images.ubuntu.com/noble/current/noble-server-cloudimg-arm64.img", | ||
| 292 | SHA256: strings.Repeat("a", 64)}, | ||
| 293 | }, | ||
| 223 | AdvertiseHTTP: "http://127.0.0.1:8080", | 294 | AdvertiseHTTP: "http://127.0.0.1:8080", |
| 224 | AdvertiseQUIC: "127.0.0.1:8443", | 295 | AdvertiseQUIC: "127.0.0.1:8443", |
| 225 | ServerCertSHA256: strings.Repeat("c", 64), | 296 | ServerCertSHA256: strings.Repeat("c", 64), |
internal/server/api/decommission_api_test.go
| Old | New | ||
|---|---|---|---|
| @@ -26,7 +26,7 @@ func apiServer(t *testing.T) (*httptest.Server, *API, *store.Store) { | |||
| 26 | seedTestTenant(t, st) | 26 | seedTestTenant(t, st) |
| 27 | a := New(Config{ | 27 | a := New(Config{ |
| 28 | HostSecret: []byte("hostsecret"), | 28 | HostSecret: []byte("hostsecret"), |
| 29 | DefaultImage: DefaultImage{URL: "http://img", SHA256: strings.Repeat("a", 64)}, | 29 | DefaultImages: map[string]DefaultImage{"amd64": {URL: "http://img", SHA256: strings.Repeat("a", 64)}}, |
| 30 | AdvertiseHTTP: "http://127.0.0.1:8080", | 30 | AdvertiseHTTP: "http://127.0.0.1:8080", |
| 31 | AdvertiseQUIC: "127.0.0.1:8443", | 31 | AdvertiseQUIC: "127.0.0.1:8443", |
| 32 | ServerCertSHA256: strings.Repeat("c", 64), | 32 | ServerCertSHA256: strings.Repeat("c", 64), |
internal/server/boot/boot.go
| Old | New | ||
|---|---|---|---|
| @@ -141,7 +141,7 @@ func run(cfgPath string) error { | |||
| 141 | h := hub.New() | 141 | h := hub.New() |
| 142 | 142 | ||
| 143 | a := api.New(api.Config{HostSecret: []byte(cfg.HostSecret), | 143 | a := api.New(api.Config{HostSecret: []byte(cfg.HostSecret), |
| 144 | DefaultImage: api.DefaultImage{URL: cfg.DefaultImageURL, SHA256: cfg.DefaultImageSHA}, | 144 | DefaultImages: defaultImages(cfg.DefaultImages), |
| 145 | ServerCertSHA256: certFP, | 145 | ServerCertSHA256: certFP, |
| 146 | AdvertiseHTTP: cfg.AdvertiseHTTP, | 146 | AdvertiseHTTP: cfg.AdvertiseHTTP, |
| 147 | AdvertiseQUIC: cfg.AdvertiseQUIC, | 147 | AdvertiseQUIC: cfg.AdvertiseQUIC, |
| @@ -266,3 +266,14 @@ func run(cfgPath string) error { | |||
| 266 | a.Close() | 266 | a.Close() |
| 267 | return nil | 267 | return nil |
| 268 | } | 268 | } |
| 269 | |||
| 270 | // defaultImages translates the config's per-architecture guest images into the | ||
| 271 | // API's own type, so the API package does not import the config schema (R1: the | ||
| 272 | // wiring converts, the leaves stay independent). | ||
| 273 | func defaultImages(in map[string]serverconfig.DefaultImage) map[string]api.DefaultImage { | ||
| 274 | out := make(map[string]api.DefaultImage, len(in)) | ||
| 275 | for arch, img := range in { | ||
| 276 | out[arch] = api.DefaultImage{URL: img.URL, SHA256: img.SHA256} | ||
| 277 | } | ||
| 278 | return out | ||
| 279 | } | ||
internal/server/config/config.go
| Old | New | ||
|---|---|---|---|
| @@ -10,13 +10,26 @@ type Config struct { | |||
| 10 | // AdminToken is retired: sign-in is OIDC (see OIDC below) and console | 10 | // AdminToken is retired: sign-in is OIDC (see OIDC below) and console |
| 11 | // credentials are sessions/PATs. The field is kept only so the server can | 11 | // credentials are sessions/PATs. The field is kept only so the server can |
| 12 | // detect a stale token in an old config and warn the operator to remove it. | 12 | // detect a stale token in an old config and warn the operator to remove it. |
| 13 | AdminToken string `json:"admin_token"` | 13 | AdminToken string `json:"admin_token"` |
| 14 | HostSecret string `json:"host_secret"` | 14 | HostSecret string `json:"host_secret"` |
| 15 | CIDRPool string `json:"cidr_pool"` | 15 | CIDRPool string `json:"cidr_pool"` |
| 16 | // DefaultImageURL/SHA are retired in favour of DefaultImages: one image for | ||
| 17 | // the whole fleet is only correct while every host shares an architecture. | ||
| 18 | // Kept so the server can spot them in an old config and say what to write. | ||
| 16 | DefaultImageURL string `json:"default_image_url"` | 19 | DefaultImageURL string `json:"default_image_url"` |
| 17 | DefaultImageSHA string `json:"default_image_sha256"` | 20 | DefaultImageSHA string `json:"default_image_sha256"` |
| 18 | AdvertiseHTTP string `json:"advertise_http"` | 21 | // DefaultImages is the guest image a one-click create applies, keyed by the |
| 19 | AdvertiseQUIC string `json:"advertise_quic"` | 22 | // ARCHITECTURE of the host the VM is being placed on ("amd64", "arm64" — |
| 23 | // the GOARCH each agent reports at enrollment). | ||
| 24 | // | ||
| 25 | // Keyed by arch alone, not os/arch: the guest is Linux whichever host runs | ||
| 26 | // it — a Mac hosts Linux arm64 guests through Virtualization.framework. The | ||
| 27 | // host's OS picks the backend; only the CPU architecture has to match the | ||
| 28 | // image, and handing a host an image it cannot execute is the one mistake | ||
| 29 | // this map exists to prevent. | ||
| 30 | DefaultImages map[string]DefaultImage `json:"default_images"` | ||
| 31 | AdvertiseHTTP string `json:"advertise_http"` | ||
| 32 | AdvertiseQUIC string `json:"advertise_quic"` | ||
| 20 | // CredentialMaxAge optionally bounds host credential age (Go duration, | 33 | // CredentialMaxAge optionally bounds host credential age (Go duration, |
| 21 | // e.g. "2160h" for 90 days). Empty/zero disables — revocation via | 34 | // e.g. "2160h" for 90 days). Empty/zero disables — revocation via |
| 22 | // POST /api/v1/hosts/{id}/revoke-credential is the primary mechanism; | 35 | // POST /api/v1/hosts/{id}/revoke-credential is the primary mechanism; |
| @@ -52,14 +65,21 @@ type Config struct { | |||
| 52 | OIDC OIDC `json:"oidc"` | 65 | OIDC OIDC `json:"oidc"` |
| 53 | } | 66 | } |
| 54 | 67 | ||
| 68 | // DefaultImage is one architecture's guest image: the URL to fetch and the | ||
| 69 | // digest the agent verifies it against. | ||
| 70 | type DefaultImage struct { | ||
| 71 | URL string `json:"url"` | ||
| 72 | SHA256 string `json:"sha256"` | ||
| 73 | } | ||
| 74 | |||
| 55 | // OIDC configures the server's relying-party side. The issuer is sometimes | 75 | // OIDC configures the server's relying-party side. The issuer is sometimes |
| 56 | // the bundled eitri-oidc next door and sometimes an external IdP — the | 76 | // the bundled eitri-oidc next door and sometimes an external IdP — the |
| 57 | // server cannot tell the difference (spec §2). | 77 | // server cannot tell the difference (spec §2). |
| 58 | type OIDC struct { | 78 | type OIDC struct { |
| 59 | Issuer string `json:"issuer"` | 79 | Issuer string `json:"issuer"` |
| 60 | ClientID string `json:"client_id"` | 80 | ClientID string `json:"client_id"` |
| 61 | ClientSecret string `json:"client_secret"` // external confidential clients only | 81 | ClientSecret string `json:"client_secret"` // external confidential clients only |
| 62 | PublicURL string `json:"public_url"` | 82 | PublicURL string `json:"public_url"` |
| 63 | AllowedDomains []string `json:"allowed_domains"` // optional signup gate | 83 | AllowedDomains []string `json:"allowed_domains"` // optional signup gate |
| 64 | AllowedIdentities []string `json:"allowed_identities"` // optional signup gate | 84 | AllowedIdentities []string `json:"allowed_identities"` // optional signup gate |
| 65 | } | 85 | } |
internal/server/config/load.go
| Old | New | ||
|---|---|---|---|
| @@ -8,9 +8,11 @@ import ( | |||
| 8 | "encoding/json" | 8 | "encoding/json" |
| 9 | "fmt" | 9 | "fmt" |
| 10 | "log/slog" | 10 | "log/slog" |
| 11 | "maps" | ||
| 11 | "net/url" | 12 | "net/url" |
| 12 | "os" | 13 | "os" |
| 13 | "regexp" | 14 | "regexp" |
| 15 | "slices" | ||
| 14 | "strings" | 16 | "strings" |
| 15 | "time" | 17 | "time" |
| 16 | ) | 18 | ) |
| @@ -40,6 +42,18 @@ func Load(path string) (Config, error) { | |||
| 40 | if cfg.AdminToken != "" { | 42 | if cfg.AdminToken != "" { |
| 41 | slog.Warn("server.json: admin_token is no longer used and is ignored; remove it") | 43 | slog.Warn("server.json: admin_token is no longer used and is ignored; remove it") |
| 42 | } | 44 | } |
| 45 | // default_image_url/sha are retired in favour of default_images. They are | ||
| 46 | // IGNORED rather than used as a fallback for an unlisted architecture: the | ||
| 47 | // digest says nothing about what the image can execute, so honouring one | ||
| 48 | // image for every host is how an amd64 image reaches an arm64 Mac and dies | ||
| 49 | // as "ephemeral VM lost". A one-click create for an unlisted arch fails | ||
| 50 | // instead, at create time, naming the arch — and the server still boots, so | ||
| 51 | // this never turns a config upgrade into an outage. | ||
| 52 | if cfg.DefaultImageURL != "" || cfg.DefaultImageSHA != "" { | ||
| 53 | slog.Warn("server.json: default_image_url/default_image_sha256 are no longer used and are IGNORED; " + | ||
| 54 | `move them under default_images keyed by host architecture, e.g. ` + | ||
| 55 | `"default_images": {"amd64": {"url": "...", "sha256": "..."}}`) | ||
| 56 | } | ||
| 43 | return cfg, nil | 57 | return cfg, nil |
| 44 | } | 58 | } |
| 45 | 59 | ||
| @@ -72,10 +86,19 @@ func validate(cfg Config) error { | |||
| 72 | if cfg.AdvertiseHTTP == "" || cfg.AdvertiseQUIC == "" { | 86 | if cfg.AdvertiseHTTP == "" || cfg.AdvertiseQUIC == "" { |
| 73 | return fmt.Errorf("advertise_http and advertise_quic are required (the addresses agents use to reach this server, e.g. http://192.168.0.190:8080 and 192.168.0.190:8443)") | 87 | return fmt.Errorf("advertise_http and advertise_quic are required (the addresses agents use to reach this server, e.g. http://192.168.0.190:8080 and 192.168.0.190:8443)") |
| 74 | } | 88 | } |
| 75 | // Fail fast on a malformed default image digest rather than letting every | 89 | // Fail fast on a malformed default image rather than letting every one-click |
| 76 | // mint silently propagate a bad hash. | 90 | // create propagate a bad hash. Sorted so a config with several bad entries |
| 77 | if cfg.DefaultImageSHA != "" && !defaultImageSHARe.MatchString(cfg.DefaultImageSHA) { | 91 | // reports them in a stable order. |
| 78 | return fmt.Errorf("default_image_sha256 malformed (want 64 lowercase hex chars), got %q", cfg.DefaultImageSHA) | 92 | for _, arch := range slices.Sorted(maps.Keys(cfg.DefaultImages)) { |
| 93 | img := cfg.DefaultImages[arch] | ||
| 94 | switch { | ||
| 95 | case arch == "": | ||
| 96 | return fmt.Errorf(`default_images has an empty architecture key (want a GOARCH, e.g. "amd64")`) | ||
| 97 | case img.URL == "": | ||
| 98 | return fmt.Errorf("default_images[%q].url is required", arch) | ||
| 99 | case !defaultImageSHARe.MatchString(img.SHA256): | ||
| 100 | return fmt.Errorf("default_images[%q].sha256 malformed (want 64 lowercase hex chars), got %q", arch, img.SHA256) | ||
| 101 | } | ||
| 79 | } | 102 | } |
| 80 | return nil | 103 | return nil |
| 81 | } | 104 | } |
internal/server/config/load_test.go
| Old | New | ||
|---|---|---|---|
| @@ -93,16 +93,54 @@ func TestLoadRequiresAdvertiseAddrs(t *testing.T) { | |||
| 93 | } | 93 | } |
| 94 | } | 94 | } |
| 95 | 95 | ||
| 96 | func TestLoadRejectsMalformedImageSHA(t *testing.T) { | 96 | // withImages splices a default_images block into the minimal config. |
| 97 | withSHA := strings.Replace(minimal, `"host_secret": "s3cret",`, | 97 | func withImages(body string) string { |
| 98 | `"host_secret": "s3cret", "default_image_sha256": "NOTHEX",`, 1) | 98 | return strings.Replace(minimal, `"host_secret": "s3cret",`, |
| 99 | if _, err := Load(write(t, withSHA)); err == nil || !strings.Contains(err.Error(), "default_image_sha256") { | 99 | `"host_secret": "s3cret", "default_images": {`+body+`},`, 1) |
| 100 | t.Errorf("got %v", err) | 100 | } |
| 101 | |||
| 102 | func TestLoadValidatesDefaultImages(t *testing.T) { | ||
| 103 | good := strings.Repeat("a", 64) | ||
| 104 | for _, tc := range []struct { | ||
| 105 | name, body, want string // want=="" ⇒ must load cleanly | ||
| 106 | }{ | ||
| 107 | {"one arch", `"amd64": {"url": "http://i", "sha256": "` + good + `"}`, ""}, | ||
| 108 | {"both arches", `"amd64": {"url": "http://i", "sha256": "` + good + `"}, | ||
| 109 | "arm64": {"url": "http://j", "sha256": "` + good + `"}`, ""}, | ||
| 110 | {"absent entirely", "", ""}, | ||
| 111 | {"malformed sha", `"arm64": {"url": "http://i", "sha256": "NOTHEX"}`, `default_images["arm64"].sha256`}, | ||
| 112 | {"missing url", `"amd64": {"sha256": "` + good + `"}`, `default_images["amd64"].url`}, | ||
| 113 | {"empty arch key", `"": {"url": "http://i", "sha256": "` + good + `"}`, "empty architecture key"}, | ||
| 114 | } { | ||
| 115 | t.Run(tc.name, func(t *testing.T) { | ||
| 116 | _, err := Load(write(t, withImages(tc.body))) | ||
| 117 | if tc.want == "" { | ||
| 118 | if err != nil { | ||
| 119 | t.Fatalf("valid config rejected: %v", err) | ||
| 120 | } | ||
| 121 | return | ||
| 122 | } | ||
| 123 | if err == nil || !strings.Contains(err.Error(), tc.want) { | ||
| 124 | t.Fatalf("want error containing %q, got %v", tc.want, err) | ||
| 125 | } | ||
| 126 | }) | ||
| 127 | } | ||
| 128 | } | ||
| 129 | |||
| 130 | // TestLoadToleratesRetiredDefaultImageKeys pins that the old single-image keys | ||
| 131 | // warn but never fail: a config upgrade must not stop the control plane | ||
| 132 | // booting. They are ignored, so a one-click create for an unlisted arch fails | ||
| 133 | // at create time instead of handing a host an image it cannot execute. | ||
| 134 | func TestLoadToleratesRetiredDefaultImageKeys(t *testing.T) { | ||
| 135 | old := strings.Replace(minimal, `"host_secret": "s3cret",`, | ||
| 136 | `"host_secret": "s3cret", "default_image_url": "http://i", | ||
| 137 | "default_image_sha256": "`+strings.Repeat("a", 64)+`",`, 1) | ||
| 138 | cfg, err := Load(write(t, old)) | ||
| 139 | if err != nil { | ||
| 140 | t.Fatalf("retired keys must not fail the load: %v", err) | ||
| 101 | } | 141 | } |
| 102 | okSHA := strings.Replace(minimal, `"host_secret": "s3cret",`, | 142 | if len(cfg.DefaultImages) != 0 { |
| 103 | `"host_secret": "s3cret", "default_image_sha256": "`+strings.Repeat("a", 64)+`",`, 1) | 143 | t.Errorf("retired keys must not populate DefaultImages, got %v", cfg.DefaultImages) |
| 104 | if _, err := Load(write(t, okSHA)); err != nil { | ||
| 105 | t.Errorf("valid sha rejected: %v", err) | ||
| 106 | } | 144 | } |
| 107 | } | 145 | } |
| 108 | 146 | ||