From 98341c76659ff41a9f0da491b8486744a05bb6c9 Mon Sep 17 00:00:00 2001 From: Admin <1+admin@noreply.git.dcntrlized.org> Date: Sun, 23 Aug 2026 01:02:29 +0100 Subject: [PATCH] feat(instance): public profile endpoint GET /api/instance (hub-app M1) --- spec.yaml | 104 +++++++++++++++++- src/frontend/src/lib/types.ts | 36 +++++++ src/handlers/instance/public.go | 129 ++++++++++++++++++++++ src/handlers/instance/public_test.go | 156 +++++++++++++++++++++++++++ src/lib/env/env.go | 7 ++ src/lib/federation/store.go | 69 ++++++++++++ src/lib/instance/instance.go | 94 ++++++++++++++++ src/lib/pki/helpers.go | 20 ++++ src/main.go | 7 ++ 9 files changed, 621 insertions(+), 1 deletion(-) create mode 100644 src/handlers/instance/public.go create mode 100644 src/handlers/instance/public_test.go create mode 100644 src/lib/federation/store.go create mode 100644 src/lib/instance/instance.go diff --git a/spec.yaml b/spec.yaml index e866aa9..c159a3f 100644 --- a/spec.yaml +++ b/spec.yaml @@ -2,7 +2,7 @@ openapi: 3.0.3 info: title: Dcntrlized Maestro API version: 1.0.0 - description: The API of the Maestro orchestration router. Maestro manages the lifecycle of Dcntrlized service containers and routes inter-service action calls to them. Every /api/* route except /api/auth/* requires an instance-owner session (cookies issued by the auth endpoints); in setup mode gated routes answer 428 ERR_SETUP_REQUIRED, otherwise 401 ERR_UNAUTHENTICATED. Mutating requests from browser origins are additionally checked against the request host (CSRF). + description: The API of the Maestro orchestration router. Maestro manages the lifecycle of Dcntrlized service containers and routes inter-service action calls to them. Every /api/* route except /api/auth/* and the deliberately public GET /api/instance (hub-app theme) requires an instance-owner session (cookies issued by the auth endpoints); in setup mode gated routes answer 428 ERR_SETUP_REQUIRED, otherwise 401 ERR_UNAUTHENTICATED. Mutating requests from browser origins are additionally checked against the request host (CSRF). components: schemas: @@ -144,6 +144,90 @@ components: issued_at: type: string + InstanceIdentity: + type: object + description: The instance's public self-description (hub-app M1). + required: [name] + properties: + name: + type: string + example: "home-node" + title: + type: string + description: + type: string + fingerprint: + type: string + description: Instance CA public-key SHA256 (lib/pki); omitted when no CA is provisioned. + example: "sha256:…" + + PublicService: + type: object + description: | + Deliberately reduced view of a running service — no container id, + docker name, private IP, or port mapping leaves the instance (hub-app D3). + required: [service_name, status, image] + properties: + service_name: + type: string + title: + type: string + description: + type: string + status: + type: string + version: + type: string + capabilities: + type: array + items: + type: string + has_auth: + type: boolean + image: + type: string + + TrustedInstance: + type: object + description: A remote instance this instance trusts (federation store row). + required: [name, url, fingerprint, status] + properties: + name: + type: string + url: + type: string + fingerprint: + type: string + example: "sha256:…" + status: + type: string + example: "authorized" + scopes: + type: array + items: + type: string + + InstancePublicProfile: + type: object + description: | + The unauthenticated GET /api/instance response (hub-app theme). With + DCNTRLIZED_PUBLIC_PROFILE=false only `instance` is returned. Absent + federation store → trusted_instances:null; present-but-empty → []. + required: [instance] + properties: + instance: + $ref: '#/components/schemas/InstanceIdentity' + services: + type: array + nullable: true + items: + $ref: '#/components/schemas/PublicService' + trusted_instances: + type: array + nullable: true + items: + $ref: '#/components/schemas/TrustedInstance' + RegistryContainer: type: object required: [name, image] @@ -506,6 +590,24 @@ paths: '502': description: Failed to connect to upstream registry + /api/instance: + get: + summary: Public instance profile (unauthenticated) + tags: ["instance"] + security: [] + description: >- + The one read surface the owner gate deliberately allows through + (hub-app theme M1/D2): what this instance is, which non-hidden services + it runs (public fields only), and which remote instances it trusts. + DCNTRLIZED_PUBLIC_PROFILE=false omits services + trusted_instances. + responses: + '200': + description: The instance public profile + content: + application/json: + schema: + $ref: '#/components/schemas/InstancePublicProfile' + /api/auth/status: get: summary: Instance-owner gate state (public) diff --git a/src/frontend/src/lib/types.ts b/src/frontend/src/lib/types.ts index 43ab31f..94ab4db 100644 --- a/src/frontend/src/lib/types.ts +++ b/src/frontend/src/lib/types.ts @@ -148,3 +148,39 @@ export interface SessionResponse { role: string access_ttl_s: number } + +/** The instance's public self-description (hub-app M1). */ +export interface InstanceIdentity { + name: string + title?: string + description?: string + fingerprint?: string +} + +/** Deliberately reduced service view — no ids, IPs, or ports (hub-app D3). */ +export interface PublicService { + service_name: string + title?: string + description?: string + status: string + version?: string + capabilities?: string[] + has_auth?: boolean + image: string +} + +/** A remote instance this instance trusts (federation store row). */ +export interface TrustedInstance { + name: string + url: string + fingerprint: string + status: string + scopes?: string[] +} + +/** GET /api/instance — the one unauthenticated read surface. */ +export interface InstancePublicProfile { + instance: InstanceIdentity + services?: PublicService[] | null + trusted_instances?: TrustedInstance[] | null +} diff --git a/src/handlers/instance/public.go b/src/handlers/instance/public.go new file mode 100644 index 0000000..c16ff56 --- /dev/null +++ b/src/handlers/instance/public.go @@ -0,0 +1,129 @@ +// Copyright (C) 2026 Dcntrlized +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published +// by the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +// Package instance serves the instance's public profile: the single +// unauthenticated read surface the owner gate allows through (hub-app theme +// M1/D2). It answers "what is this instance, what does it run, and who does +// it trust?" for the untrusted-first hub client. +package instance + +import ( + "encoding/json" + "net/http" + + "git.dcntrlized.org/Dcntrlized/Maestro/lib/env" + "git.dcntrlized.org/Dcntrlized/Maestro/lib/federation" + "git.dcntrlized.org/Dcntrlized/Maestro/lib/instance" + "git.dcntrlized.org/Dcntrlized/Maestro/lib/pki" + "git.dcntrlized.org/Dcntrlized/Maestro/lib/types" +) + +// InstanceIdentity is the public face of this instance. Fingerprint is the +// instance CA's public-key hash — the same value a peer pins at first contact. +type InstanceIdentity struct { + Name string `json:"name"` + Title string `json:"title,omitempty"` + Description string `json:"description,omitempty"` + Fingerprint string `json:"fingerprint,omitempty"` +} + +// PublicService is the deliberately reduced view of a running service: +// no container id, docker name, private IP, or port mapping ever leaves the +// instance (hub-app D3). +type PublicService struct { + ServiceName string `json:"service_name"` + Title string `json:"title,omitempty"` + Description string `json:"description,omitempty"` + Status string `json:"status"` + Version string `json:"version,omitempty"` + Capabilities []string `json:"capabilities,omitempty"` + HasAuth bool `json:"has_auth,omitempty"` + Image string `json:"image"` +} + +// PublicProfile is the GET /api/instance wire shape. The section pointers +// carry three distinct states: nil → field omitted (privacy switch off), +// pointer-to-nil → JSON null (store absent), pointer-to-empty → JSON []. +type PublicProfile struct { + Instance InstanceIdentity `json:"instance"` + Services *[]PublicService `json:"services,omitempty"` + TrustedInstances *[]federation.RemoteInstance `json:"trusted_instances,omitempty"` +} + +// NewPublicProfileHandler serves GET /api/instance. getServices is the cache +// accessor (cache.GetServices self-locks); dataRoot resolves identity, CA and +// federation stores on demand. +func NewPublicProfileHandler(getServices func() []types.Container, dataRoot string) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + profile := PublicProfile{ + Instance: buildIdentity(dataRoot), + } + + if env.PublicProfile() { + services := publicServices(getServices()) + trusted, err := federation.List(dataRoot) + if err != nil { + // A malformed store must not take the profile down; + // report absence instead. + trusted = nil + } + profile.Services = &services + profile.TrustedInstances = &trusted + } + + w.Header().Set("Content-Type", "application/json") + if err := json.NewEncoder(w).Encode(profile); err != nil { + http.Error(w, "Error encoding instance profile", http.StatusInternalServerError) + return + } + } +} + +func buildIdentity(dataRoot string) InstanceIdentity { + id := instance.Load(dataRoot) + return InstanceIdentity{ + Name: id.Name, + Title: id.Title, + Description: id.Description, + Fingerprint: pki.CAFingerprint(dataRoot), + } +} + +// publicServices filters hidden infrastructure (role-labeled containers such +// as dcntrlized-db / dcntrlized-auth and anything flagged hidden) down to the +// public field set. +func publicServices(all []types.Container) []PublicService { + public := make([]PublicService, 0, len(all)) + for _, c := range all { + if c.Hidden || c.Role != "" { + continue + } + name := c.ServiceName + if name == "" { + name = c.Name + } + public = append(public, PublicService{ + ServiceName: name, + Title: c.Title, + Description: c.Description, + Status: c.Status, + Version: c.Version, + Capabilities: c.Capabilities, + HasAuth: c.HasAuth, + Image: c.Image, + }) + } + return public +} diff --git a/src/handlers/instance/public_test.go b/src/handlers/instance/public_test.go new file mode 100644 index 0000000..3378ae8 --- /dev/null +++ b/src/handlers/instance/public_test.go @@ -0,0 +1,156 @@ +// Copyright (C) 2026 Dcntrlized +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published +// by the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package instance + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "testing" + + "git.dcntrlized.org/Dcntrlized/Maestro/lib/federation" + "git.dcntrlized.org/Dcntrlized/Maestro/lib/types" +) + +func testServices() []types.Container { + return []types.Container{ + { + ID: "c1", Name: "keepnote-1", ServiceName: "keepnote", DockerName: "keepnote-1", + Image: "registry.example/dcntrlized/keepnote:1.0", Version: "1.0", + Status: "running", Title: "KeepNote", Description: "notes app", + Capabilities: []string{"database"}, HasAuth: true, + Network: types.Network{Ports: []types.PortMapping{{Host: 8081, Guest: 8080}}}, + }, + {ID: "c2", Name: "dcntrlized-db", Role: "db", Hidden: true, Status: "running"}, + {ID: "c3", Name: "dcntrlized-auth", Role: "auth", Hidden: true, Status: "running"}, + {ID: "c4", Name: "secret", Hidden: true, Status: "running"}, + } +} + +func serve(t *testing.T, dataRoot string, services []types.Container) map[string]any { + t.Helper() + srv := httptest.NewServer(NewPublicProfileHandler(func() []types.Container { return services }, dataRoot)) + t.Cleanup(srv.Close) + resp, err := http.Get(srv.URL) + if err != nil { + t.Fatalf("GET failed: %v", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + t.Fatalf("status = %d, want 200", resp.StatusCode) + } + var body map[string]any + if err := json.NewDecoder(resp.Body).Decode(&body); err != nil { + t.Fatalf("decode: %v", err) + } + return body +} + +func TestPublicProfileFiltersHiddenInfra(t *testing.T) { + dataRoot := t.TempDir() + body := serve(t, dataRoot, testServices()) + + raw, _ := json.Marshal(body["services"]) + var services []map[string]any + if err := json.Unmarshal(raw, &services); err != nil { + t.Fatalf("services not an array: %v", err) + } + if len(services) != 1 { + t.Fatalf("got %d services, want exactly the one public service (%s)", len(services), raw) + } + svc := services[0] + if svc["service_name"] != "keepnote" || svc["status"] != "running" || svc["image"] == "" { + t.Fatalf("unexpected public service shape: %v", svc) + } + + // No private fields may serialize. + for _, forbidden := range []string{"id", "docker_name", "network", "ipAddress", "ports", "role", "hidden", "public_key"} { + if _, ok := svc[forbidden]; ok { + t.Errorf("public service leaked %q", forbidden) + } + } +} + +func TestPublicProfileIdentityFallbackAndFile(t *testing.T) { + dataRoot := t.TempDir() + body := serve(t, dataRoot, nil) + instance := body["instance"].(map[string]any) + if instance["name"] == "" { + t.Fatal("identity name must fall back to hostname/env, got empty") + } + + if err := os.WriteFile(filepath.Join(dataRoot, "instance.json"), + []byte(`{"name":"home-node","title":"Home node","description":"main rig"}`), 0o600); err != nil { + t.Fatal(err) + } + body = serve(t, dataRoot, nil) + instance = body["instance"].(map[string]any) + if instance["name"] != "home-node" || instance["title"] != "Home node" { + t.Fatalf("file identity not used: %v", instance) + } +} + +func TestPublicProfileTrustedInstancesNullVsEmpty(t *testing.T) { + dataRoot := t.TempDir() + + // Store absent → JSON null (field present with null value). + body := serve(t, dataRoot, testServices()) + if v, ok := body["trusted_instances"]; !ok || v != nil { + raw, _ := json.Marshal(body) + t.Fatalf("absent federation store should yield trusted_instances:null, got %s", raw) + } + + // Present but empty → []. + if err := os.MkdirAll(filepath.Join(dataRoot, "federation"), 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(federation.Path(dataRoot), []byte(`[]`), 0o600); err != nil { + t.Fatal(err) + } + body = serve(t, dataRoot, testServices()) + raw, _ := json.Marshal(body["trusted_instances"]) + if string(raw) != "[]" { + t.Fatalf("empty store should marshal as [], got %s", raw) + } + + // Populated → rows pass through. + populated := `{"instances":[{"name":"peer-b","url":"http://192.168.1.20:8080","fingerprint":"sha256:ab","status":"authorized","scopes":["read"]}]}` + if err := os.WriteFile(federation.Path(dataRoot), []byte(populated), 0o600); err != nil { + t.Fatal(err) + } + body = serve(t, dataRoot, testServices()) + list := body["trusted_instances"].([]any) + if len(list) != 1 || list[0].(map[string]any)["name"] != "peer-b" { + t.Fatalf("populated store not surfaced: %v", list) + } +} + +func TestPublicProfilePrivacySwitchOmitsSections(t *testing.T) { + t.Setenv("DCNTRLIZED_PUBLIC_PROFILE", "false") + dataRoot := t.TempDir() + body := serve(t, dataRoot, testServices()) + + for _, section := range []string{"services", "trusted_instances"} { + if _, ok := body[section]; ok { + t.Errorf("%q must be omitted when DCNTRLIZED_PUBLIC_PROFILE=false", section) + } + } + if _, ok := body["instance"]; !ok { + t.Error("instance identity must remain visible with the profile off") + } +} diff --git a/src/lib/env/env.go b/src/lib/env/env.go index 3b69242..62787ed 100644 --- a/src/lib/env/env.go +++ b/src/lib/env/env.go @@ -32,3 +32,10 @@ func IsDev() bool { func GiteaURL() string { return os.Getenv("DCNTRLIZED_GITEA_URL") } + +// PublicProfile reports whether the unauthenticated instance profile +// (GET /api/instance, hub-app theme M1) is served. Default on; set +// DCNTRLIZED_PUBLIC_PROFILE=false to blank services + trusted_instances. +func PublicProfile() bool { + return os.Getenv("DCNTRLIZED_PUBLIC_PROFILE") != "false" +} diff --git a/src/lib/federation/store.go b/src/lib/federation/store.go new file mode 100644 index 0000000..02ec34e --- /dev/null +++ b/src/lib/federation/store.go @@ -0,0 +1,69 @@ +// Copyright (C) 2026 Dcntrlized +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published +// by the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +// Package federation is a defensive reader for the remote-instance trust +// store that the multi-registry & instance federation theme will own. The +// hub app's public profile (hub-app theme M1) surfaces authorized peers from +// it when present; until that theme lands, the file simply does not exist and +// every read returns nil. +package federation + +import ( + "encoding/json" + "os" + "path/filepath" +) + +// RemoteInstance mirrors the planned trust-store row: a peer this instance +// trusts, addressed by URL and identified by its CA fingerprint. +type RemoteInstance struct { + Name string `json:"name"` + URL string `json:"url"` + Fingerprint string `json:"fingerprint"` + Status string `json:"status"` + Scopes []string `json:"scopes,omitempty"` +} + +// Path returns the trust-store location for the given data root. +func Path(dataRoot string) string { + return filepath.Join(dataRoot, "federation", "instances.json") +} + +// List reads the authorized remote instances. A missing store yields +// (nil, nil): callers distinguish "absent" (nil → JSON null) from +// "present but empty" (non-nil, len 0 → JSON []). +func List(dataRoot string) ([]RemoteInstance, error) { + data, err := os.ReadFile(Path(dataRoot)) + if err != nil { + if os.IsNotExist(err) { + return nil, nil + } + return nil, err + } + + // Defensive parse: accept both a bare array and the wrapped shape the + // federation theme is expected to persist. + var direct []RemoteInstance + if json.Unmarshal(data, &direct) == nil { + return direct, nil + } + var wrapped struct { + Instances []RemoteInstance `json:"instances"` + } + if err := json.Unmarshal(data, &wrapped); err != nil { + return nil, err + } + return wrapped.Instances, nil +} diff --git a/src/lib/instance/instance.go b/src/lib/instance/instance.go new file mode 100644 index 0000000..a3bbe83 --- /dev/null +++ b/src/lib/instance/instance.go @@ -0,0 +1,94 @@ +// Copyright (C) 2026 Dcntrlized +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published +// by the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +// Package instance holds the instance's public identity: a small, self- +// contained file at the data root that answers "who am I" for the hub app's +// public profile (hub-app theme M1) and, later, federation attestation. +package instance + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" +) + +// Identity is the instance's user-visible self-description. Name falls back to +// DCNTRLIZED_INSTANCE_NAME and then the hostname when instance.json is absent; +// Title and Description are optional and only ever come from the file. +type Identity struct { + Name string `json:"name"` + Title string `json:"title,omitempty"` + Description string `json:"description,omitempty"` +} + +// Path returns the identity file location for the given data root. +func Path(dataRoot string) string { + return filepath.Join(dataRoot, "instance.json") +} + +// Load resolves the instance identity: instance.json when present, else +// DCNTRLIZED_INSTANCE_NAME, else os.Hostname(). Read-on-demand by design — +// no daemon state, so an edited file shows up on the next request. +func Load(dataRoot string) Identity { + id := defaultIdentity() + + if data, err := os.ReadFile(Path(dataRoot)); err == nil { + var fromFile Identity + if json.Unmarshal(data, &fromFile) == nil && fromFile.Name != "" { + if fromFile.Title != "" { + id.Title = fromFile.Title + } + if fromFile.Description != "" { + id.Description = fromFile.Description + } + id.Name = fromFile.Name + } + } + return id +} + +// Save atomically writes the identity file (tmp + rename), chmod 600 like the +// rest of Maestro's file stores. The owner-gated edit endpoint that uses this +// lands with the federation theme; the helper exists so both callers share one +// write path. +func Save(dataRoot string, id Identity) error { + if id.Name == "" { + return fmt.Errorf("instance name must not be empty") + } + data, err := json.MarshalIndent(id, "", " ") + if err != nil { + return fmt.Errorf("marshal instance identity: %w", err) + } + path := Path(dataRoot) + tmp := path + ".tmp" + if err := os.WriteFile(tmp, append(data, '\n'), 0o600); err != nil { + return fmt.Errorf("write instance identity: %w", err) + } + if err := os.Rename(tmp, path); err != nil { + return fmt.Errorf("replace instance identity: %w", err) + } + return nil +} + +func defaultIdentity() Identity { + name := os.Getenv("DCNTRLIZED_INSTANCE_NAME") + if name == "" { + if host, err := os.Hostname(); err == nil { + name = host + } + } + return Identity{Name: name} +} diff --git a/src/lib/pki/helpers.go b/src/lib/pki/helpers.go index 500501f..5106c59 100644 --- a/src/lib/pki/helpers.go +++ b/src/lib/pki/helpers.go @@ -117,3 +117,23 @@ func FingerprintDER(spki []byte) string { } return string(b) } + +// CAFingerprint returns the instance CA's public-key fingerprint by reading +// the certificate from disk on demand (hub-app M1's public profile surfaces +// it as the instance identity). Empty string when no CA exists yet — dev-mode +// static services never provision one. +func CAFingerprint(dataRoot string) string { + pemBytes, err := os.ReadFile(CertPath(dataRoot)) + if err != nil { + return "" + } + block, _ := pem.Decode(pemBytes) + if block == nil { + return "" + } + cert, err := x509.ParseCertificate(block.Bytes) + if err != nil { + return "" + } + return Fingerprint(cert) +} diff --git a/src/main.go b/src/main.go index b7c625c..eb84431 100644 --- a/src/main.go +++ b/src/main.go @@ -27,6 +27,7 @@ import ( "git.dcntrlized.org/Dcntrlized/Maestro/handlers/actions" "git.dcntrlized.org/Dcntrlized/Maestro/handlers/auth" "git.dcntrlized.org/Dcntrlized/Maestro/handlers/containers" + handlersinstance "git.dcntrlized.org/Dcntrlized/Maestro/handlers/instance" "git.dcntrlized.org/Dcntrlized/Maestro/handlers/peers" handlers "git.dcntrlized.org/Dcntrlized/Maestro/handlers/registry" libcontainers "git.dcntrlized.org/Dcntrlized/Maestro/lib/containers" @@ -150,6 +151,12 @@ func main() { mux.HandleFunc("/api/auth/logout", authService.Logout) mux.Handle("/api/auth/change-password", gate(http.HandlerFunc(authService.ChangePassword))) + // Instance public profile (hub-app theme M1): the one additional + // unauthenticated surface — deliberately allowlisted around the owner + // gate (cross-theme contract, hub-app D2). Exact path only; sub-paths + // like /api/instance/attestation (federation) are registered separately. + mux.Handle("/api/instance", handlersinstance.NewPublicProfileHandler(cache.GetServices, dataRoot)) + mux.Handle("/api/ws", authService.GateWebSocket(http.HandlerFunc(wsHub.HandleWS))) mux.Handle("/api/containers", gate(containers.NewListContainersHandler(cache))) mux.Handle("/api/containers/install", gate(containers.InstallContainerHandler(cache)))