From 0182e1676cb12f9926be3a64c4edb6a59b86593d Mon Sep 17 00:00:00 2001 From: Baylem Date: Wed, 7 Oct 2026 23:45:14 -0400 Subject: [PATCH 01/39] feat: support standalone panel and API without a local cluster Signed-off-by: Baylem --- .github/workflows/ci.yaml | 28 +- CHANGELOG.md | 8 + api/Dockerfile | 4 + api/cmd/main.go | 41 +-- api/cmd/management.go | 41 +++ api/cmd/management_test.go | 38 ++ api/internal/auth/perms.go | 6 +- api/internal/auth/perms_test.go | 33 ++ api/internal/auth/registry.go | 2 +- api/internal/controlplane/resources.go | 134 +++++++ api/internal/controlplane/store.go | 340 ++++++++++++++++++ api/internal/controlplane/store_test.go | 316 ++++++++++++++++ .../common/016_standalone_management.sql | 11 + api/internal/handlers/binding_permissions.go | 4 +- api/internal/handlers/cluster.go | 6 +- api/internal/handlers/clusters.go | 24 +- api/internal/handlers/clusters_gateway.go | 120 +++++++ .../handlers/clusters_gateway_test.go | 183 ++++++++++ api/internal/handlers/events_scope_test.go | 15 +- api/internal/handlers/fleet.go | 17 +- api/internal/handlers/fleet_test.go | 12 +- api/internal/handlers/installation.go | 16 + api/internal/handlers/modules_cluster.go | 59 +++ api/internal/handlers/modules_cluster_test.go | 44 +++ api/internal/handlers/remote_bindings_test.go | 64 +++- api/internal/handlers/secrets_managed.go | 27 +- .../handlers/standalone_storage_test.go | 233 ++++++++++++ api/internal/handlers/standalone_test.go | 47 +++ api/internal/handlers/systemlogs.go | 4 + api/internal/kube/client.go | 5 + api/internal/kube/loader.go | 4 +- api/internal/kube/management.go | 60 ++++ api/internal/kube/registry.go | 28 +- api/internal/kube/watch.go | 6 +- api/internal/kube/watch_standalone.go | 134 +++++++ api/internal/kube/watch_standalone_test.go | 128 +++++++ api/internal/notify/notify.go | 1 + api/internal/notify/registry.go | 68 ++++ api/internal/notify/sinks.go | 2 +- api/internal/notify/watch.go | 11 +- api/internal/rbac/rbac.go | 3 +- api/internal/registry/keys.go | 2 +- api/internal/telemetry/collect.go | 13 +- api/internal/ws/gateway_client.go | 8 +- api/specs.md | 12 + .../examples/standalone-panel-values.yaml | 12 + charts/gameplane/templates/NOTES.txt | 8 + charts/gameplane/templates/_helpers.tpl | 9 + charts/gameplane/templates/api.yaml | 33 +- .../gameplane/templates/clusterops-rbac.yaml | 2 +- .../gameplane/templates/crd-apply-hook.yaml | 3 + .../templates/grafana-dashboard.yaml | 3 + charts/gameplane/templates/mcp-server.yaml | 3 + .../templates/module-cosign-key.yaml | 3 + charts/gameplane/templates/modulesource.yaml | 3 + charts/gameplane/templates/mtls.yaml | 3 + charts/gameplane/templates/namespaces.yaml | 3 + .../gameplane/templates/networkpolicies.yaml | 3 + charts/gameplane/templates/operator.yaml | 3 + .../gameplane/templates/prometheusrule.yaml | 3 + .../gameplane/templates/servicemonitors.yaml | 5 + charts/gameplane/templates/validate.yaml | 11 + charts/gameplane/templates/web.yaml | 1 + charts/gameplane/values.yaml | 8 +- charts/tests/check_standalone.py | 66 ++++ deploy/standalone/compose.yaml | 48 +++ deploy/standalone/smoke.py | 105 ++++++ docs/install.md | 5 + docs/standalone-panel.md | 177 +++++++++ specs/023-standalone-panel/spec.md | 27 ++ web/specs.md | 11 + web/src/components/ClusterSelector.test.tsx | 7 +- web/src/components/ClusterSelector.tsx | 7 +- web/src/components/ModuleTarget.tsx | 42 +++ web/src/components/RegisterClusterDialog.tsx | 37 ++ .../components/modules/BuildModuleDialog.tsx | 20 +- .../components/modules/ModuleCard.test.tsx | 7 + web/src/components/modules/ModuleCard.tsx | 24 +- .../components/modules/ModuleSourcesPanel.tsx | 25 +- .../components/modules/UploadModuleDialog.tsx | 7 +- web/src/lib/api.ts | 2 +- web/src/lib/endpoints.ts | 70 ++-- web/src/lib/moduleTargets.test.ts | 37 ++ web/src/lib/useInstallation.ts | 15 + web/src/routes/AdminLogs.test.tsx | 6 + web/src/routes/AdminLogs.tsx | 19 +- web/src/routes/AdminSettings.tsx | 12 +- web/src/routes/Cluster.tsx | 18 +- web/src/routes/Clusters.tsx | 27 +- web/src/routes/Modules.tsx | 41 ++- web/src/routes/Standalone.test.tsx | 157 ++++++++ web/src/routes/Users.test.tsx | 32 ++ web/src/routes/Users.tsx | 25 +- web/src/test/handlers.ts | 2 + 94 files changed, 3349 insertions(+), 210 deletions(-) create mode 100644 api/cmd/management.go create mode 100644 api/cmd/management_test.go create mode 100644 api/internal/controlplane/resources.go create mode 100644 api/internal/controlplane/store.go create mode 100644 api/internal/controlplane/store_test.go create mode 100644 api/internal/db/migrations/common/016_standalone_management.sql create mode 100644 api/internal/handlers/clusters_gateway.go create mode 100644 api/internal/handlers/clusters_gateway_test.go create mode 100644 api/internal/handlers/installation.go create mode 100644 api/internal/handlers/modules_cluster.go create mode 100644 api/internal/handlers/modules_cluster_test.go create mode 100644 api/internal/handlers/standalone_storage_test.go create mode 100644 api/internal/handlers/standalone_test.go create mode 100644 api/internal/kube/management.go create mode 100644 api/internal/kube/watch_standalone.go create mode 100644 api/internal/kube/watch_standalone_test.go create mode 100644 api/internal/notify/registry.go create mode 100644 charts/gameplane/examples/standalone-panel-values.yaml create mode 100644 charts/gameplane/templates/validate.yaml create mode 100644 charts/tests/check_standalone.py create mode 100644 deploy/standalone/compose.yaml create mode 100644 deploy/standalone/smoke.py create mode 100644 docs/standalone-panel.md create mode 100644 specs/023-standalone-panel/spec.md create mode 100644 web/src/components/ModuleTarget.tsx create mode 100644 web/src/components/RegisterClusterDialog.tsx create mode 100644 web/src/lib/moduleTargets.test.ts create mode 100644 web/src/lib/useInstallation.ts create mode 100644 web/src/routes/Standalone.test.tsx diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 77dcb7887..5c0926b02 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -135,6 +135,7 @@ jobs: - 'web/**' charts: - 'charts/**' + - 'deploy/standalone/**' # The chart embeds a copy of the root signing key; the drift # check in `chart render` has to run when either side moves. - 'cosign.pub' @@ -764,6 +765,27 @@ jobs: - uses: azure/setup-helm@9bc31f4ebc9c6b171d7bfbaa5d006ae7abdb4310 # v5.0.1 - run: helm lint charts/gameplane + standalone-compose: + name: standalone Compose smoke + needs: [changes] + if: needs.changes.outputs.images == 'true' || needs.changes.outputs.web == 'true' || needs.changes.outputs.charts == 'true' + runs-on: ubuntu-latest + timeout-minutes: 30 + permissions: + contents: read + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - name: build and start standalone panel + run: docker compose -f deploy/standalone/compose.yaml up -d --build + - name: bootstrap and verify persistent standalone panel + run: python3 deploy/standalone/smoke.py + - name: container logs + if: failure() + run: docker compose -f deploy/standalone/compose.yaml logs + - name: remove smoke containers and volume + if: always() + run: docker compose -f deploy/standalone/compose.yaml down --volumes + chart-template: name: chart render needs: [changes] @@ -778,6 +800,8 @@ jobs: - run: helm template gameplane charts/gameplane > /tmp/rendered.yaml - name: gateway isolation and API-disabled chart renders run: python3 charts/tests/check_gateway.py + - name: standalone panel isolation and persistence chart renders + run: python3 charts/tests/check_standalone.py - name: sanity-check rendered manifests run: | grep -q 'kind: Deployment' /tmp/rendered.yaml @@ -1853,6 +1877,7 @@ jobs: web-e2e-mock, helm, chart-template, + standalone-compose, workflow-lint, go-e2e-unit, api-postgres, @@ -1958,7 +1983,7 @@ jobs: const NEEDS_ORDER = [ 'changes', 'build-images', 'build-images-arm64', 'capture-sidecar-setcap-proof', 'docs', 'lint', 'go', - 'web', 'web-e2e-mock', 'helm', 'chart-template', 'workflow-lint', 'go-e2e-unit', + 'web', 'web-e2e-mock', 'helm', 'chart-template', 'standalone-compose', 'workflow-lint', 'go-e2e-unit', 'api-postgres', 'e2e-buckets', 'e2e-go', 'e2e-go-arm64', 'e2e-multicluster', 'e2e-multicluster-arm64', 'e2e-upgrade', 'e2e-upgrade-arm64', 'e2e-web-live', 'e2e-web-live-arm64', 'e2e-game-bot', 'e2e-kind-ingress-smoke', @@ -1979,6 +2004,7 @@ jobs: 'web-e2e-mock': (n) => n === 'web e2e (mock)', helm: (n) => n === 'helm lint', 'chart-template': (n) => n === 'chart render', + 'standalone-compose': (n) => n === 'standalone Compose smoke', 'workflow-lint': (n) => n === 'workflow lint', 'go-e2e-unit': (n) => n === 'go (test/e2e)', 'api-postgres': (n) => n === 'api (postgres)', diff --git a/CHANGELOG.md b/CHANGELOG.md index 6883164b8..93688970b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,14 @@ reaches `1.0.0`. Pre-1.0 minor versions may contain breaking changes. ### Upgrade Notes +- **Standalone panel/API:** run the central panel with Docker Compose or the + standalone Helm profile, without a local Kubernetes cluster or operator. + Register remote clusters explicitly and back up the credential encryption key + with the database. See [standalone installation](docs/standalone-panel.md). +- **Remote module/template grants:** module and template permissions now apply + to their selected cluster. Grant these permissions explicitly on remote + clusters; a local grant no longer authorizes another cluster's catalog. + - **Telemetry is on by default for new installs, after a first-login notice:** a new install with a telemetry destination in effect now shares basic and extended data by default; the stock destination is the project's diff --git a/api/Dockerfile b/api/Dockerfile index 1d2e26994..2b695e45e 100644 --- a/api/Dockerfile +++ b/api/Dockerfile @@ -27,9 +27,13 @@ RUN CGO_ENABLED=0 GOOS=linux GOARCH=${TARGETARCH} go build \ -ldflags="-s -w -X main.Version=${VERSION}" \ -o /out/api ./cmd +RUN mkdir -p /out/data && chown 65532:65532 /out/data + FROM gcr.io/distroless/static:nonroot WORKDIR / COPY --from=build /out/api /api +# Seed named Docker volumes with a directory writable by the runtime user. +COPY --from=build --chown=65532:65532 /out/data /data USER 65532:65532 EXPOSE 8000 VOLUME ["/data"] diff --git a/api/cmd/main.go b/api/cmd/main.go index 5626a7382..41314177e 100644 --- a/api/cmd/main.go +++ b/api/cmd/main.go @@ -17,7 +17,6 @@ import ( "github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5/middleware" - ctrl "sigs.k8s.io/controller-runtime" "github.com/GameplanePanel/gameplane/api/internal/audit" "github.com/GameplanePanel/gameplane/api/internal/auth" @@ -27,7 +26,6 @@ import ( "github.com/GameplanePanel/gameplane/api/internal/notify" "github.com/GameplanePanel/gameplane/api/internal/rbac" "github.com/GameplanePanel/gameplane/api/internal/registry" - "github.com/GameplanePanel/gameplane/api/internal/scope" "github.com/GameplanePanel/gameplane/api/internal/telemetry" "github.com/GameplanePanel/gameplane/api/internal/ws" ) @@ -179,25 +177,11 @@ func main() { os.Exit(1) } - restCfg, err := ctrl.GetConfig() + k8s, reg, err := managementClients(ctx, cfg, store) if err != nil { - logger.Error("get kubeconfig", "err", err) + logger.Error("initialize management storage", "err", err) os.Exit(1) } - k8s, err := kube.New(restCfg) - if err != nil { - logger.Error("kube client", "err", err) - os.Exit(1) - } - // reg is the pool of per-cluster clients that cluster-dispatch-aware - // handlers resolve `?cluster=` against. A single-cluster install has - // exactly one entry: the home cluster registered as scope.DefaultCluster - // ("local"). Cluster-dispatch handlers (Resources, PodEvents, Lifecycle, - // Ownership, Events, Destinations) take reg; home-cluster/control-plane - // handlers (SystemLogs, Modules, Cluster, ClusterActions, AuthProviderSecrets, ws) - // still take the bare k8s client directly. - reg := kube.NewRegistry(scope.DefaultCluster) - reg.Set(scope.DefaultCluster, k8s) sessions := auth.NewSessionStore(store) sessions.StartGC(ctx, time.Hour) @@ -282,7 +266,7 @@ func main() { // configured in Admin Settings. Best-effort mirror — never gates // reconciliation or requests. notifier := notify.New(store, k8s, cfg.namespace) - go notifier.Run(ctx) + go notifier.RunWithRegistry(ctx, reg) // Cluster registry: watch Cluster CRDs and populate the client registry // with remote cluster connections. Single-cluster installs only have the @@ -377,13 +361,14 @@ func main() { handlers.MountNotifications(p, notifier, k8s, cfg.namespace) handlers.MountAuthProviderSecrets(p, k8s, cfg.namespace) handlers.MountCluster(p, reg, store, Version, cfg.clusterOps, cfg.updateChannel) - handlers.MountClusterActions(p, k8s, cfg.clusterOps, cfg.clusterExternalAddress) + handlers.MountClusterActions(p, k8s, cfg.clusterOps && !cfg.standalone, cfg.clusterExternalAddress) handlers.MountClusters(p, reg, k8s, cfg.namespace) + handlers.MountInstallation(p, cfg.standalone) handlers.MountEvents(p, reg) handlers.MountDestinations(p, reg) handlers.MountTunnelCredentials(p, reg) handlers.MountSystemLogs(p, k8s, cfg.namespace) - handlers.MountModules(p, k8s, cfg.namespace) + handlers.MountModulesWithRegistry(p, reg, cfg.namespace) handlers.MountRegistrySecrets(p, k8s, cfg.namespace) // Precedence: a runtime, admin-configured key (DB row + labelled // Secret, resolved lazily per registry.DBKeyFunc) always wins over @@ -499,11 +484,13 @@ func main() { } type config struct { - addr string - metricsAddr string - dbDriver string - dbDSN string - logLevel string + standalone bool + panelKeyFile string + addr string + metricsAddr string + dbDriver string + dbDSN string + logLevel string oidcIssuer string oidcClientID string @@ -624,6 +611,8 @@ func (c *config) bindFlags(fs *flag.FlagSet) { c.captureMaxRetentionSecs = envOrInt64("GAMEPLANE_CAPTURE_MAX_RETENTION", 604800) fs.IntVar(&c.captureDefaultMaxDurationS, "capture-default-max-duration", envOrInt("GAMEPLANE_CAPTURE_DEFAULT_MAX_DURATION", 300), "default max capture duration in seconds") c.captureDefaultMaxSizeBytes = envOrInt64("GAMEPLANE_CAPTURE_DEFAULT_MAX_SIZE", 5368709120) + fs.BoolVar(&c.standalone, "standalone", envOr("GAMEPLANE_STANDALONE", "") == "true", "run without a local Kubernetes cluster") + fs.StringVar(&c.panelKeyFile, "panel-key-file", envOr("GAMEPLANE_PANEL_KEY_FILE", "/data/panel.key"), "persistent standalone credential encryption key file") fs.StringVar(&c.namespace, "namespace", envOr("GAMEPLANE_NAMESPACE", "gameplane-system"), "namespace the control plane runs in (module upload ConfigMaps are stored here)") fs.StringVar(&c.gameDataStorageClass, "game-data-storage-class", envOr("GAMEPLANE_GAME_DATA_STORAGE_CLASS", ""), diff --git a/api/cmd/management.go b/api/cmd/management.go new file mode 100644 index 000000000..c4a757b4c --- /dev/null +++ b/api/cmd/management.go @@ -0,0 +1,41 @@ +package main + +import ( + "context" + "fmt" + + ctrl "sigs.k8s.io/controller-runtime" + + "github.com/GameplanePanel/gameplane/api/internal/controlplane" + "github.com/GameplanePanel/gameplane/api/internal/db" + "github.com/GameplanePanel/gameplane/api/internal/kube" + "github.com/GameplanePanel/gameplane/api/internal/scope" +) + +// managementClients separates panel storage from workload clients. Standalone +// never consults in-cluster credentials or the host's kubeconfig. +func managementClients(ctx context.Context, cfg config, store *db.Store) (*kube.Client, *kube.Registry, error) { + reg := kube.NewRegistry(scope.DefaultCluster) + if cfg.standalone { + if cfg.clusterOps { + return nil, nil, fmt.Errorf("cluster-ops requires a local Kubernetes cluster") + } + management, err := controlplane.New(ctx, store, cfg.panelKeyFile) + if err != nil { + return nil, nil, fmt.Errorf("standalone storage: %w", err) + } + reg.SetManagement(management) + return management, reg, nil + } + restCfg, err := ctrl.GetConfig() + if err != nil { + return nil, nil, fmt.Errorf("get kubeconfig: %w", err) + } + local, err := kube.New(restCfg) + if err != nil { + return nil, nil, fmt.Errorf("kube client: %w", err) + } + reg.Set(scope.DefaultCluster, local) + reg.SetManagement(local) + return local, reg, nil +} diff --git a/api/cmd/management_test.go b/api/cmd/management_test.go new file mode 100644 index 000000000..ca0a4a52f --- /dev/null +++ b/api/cmd/management_test.go @@ -0,0 +1,38 @@ +package main + +import ( + "path/filepath" + "testing" + + "github.com/GameplanePanel/gameplane/api/internal/db" +) + +func TestStandaloneManagementIgnoresKubeconfig(t *testing.T) { + t.Setenv("KUBECONFIG", filepath.Join(t.TempDir(), "does-not-exist")) + t.Setenv("KUBERNETES_SERVICE_HOST", "") + store, err := db.Open(t.Context(), "sqlite", "file::memory:?cache=shared") + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = store.Close() }) + if err := store.Migrate(t.Context()); err != nil { + t.Fatal(err) + } + management, reg, err := managementClients(t.Context(), config{standalone: true, panelKeyFile: filepath.Join(t.TempDir(), "panel.key")}, store) + if err != nil { + t.Fatal(err) + } + if management.Typed != nil || management.Dynamic != nil || management.Config != nil { + t.Fatal("standalone management acquired a Kubernetes client") + } + if reg.Default() != nil || len(reg.IDs()) != 0 || reg.Management() != management { + t.Fatal("standalone registered a local workload cluster") + } +} + +func TestStandaloneRejectsLocalCredentialMinting(t *testing.T) { + _, _, err := managementClients(t.Context(), config{standalone: true, clusterOps: true}, nil) + if err == nil { + t.Fatal("standalone must reject local cluster operations") + } +} diff --git a/api/internal/auth/perms.go b/api/internal/auth/perms.go index d71ebf18b..1a6cd22dc 100644 --- a/api/internal/auth/perms.go +++ b/api/internal/auth/perms.go @@ -117,7 +117,7 @@ func PermsByClusterToJSON(perms map[string]map[string]map[string]struct{}) map[s // privilege escalation. // - The "*" permission wildcard (the built-in admin role) matches any perm // but is still subject to the same cluster gating for namespaced perms. -// - Inventory (cluster:read) always requires a cluster-wide grant on the +// - Inventory, modules and templates always require a cluster-wide grant on the // selected cluster or wildcard cluster, regardless of namespaced. An // omitted cluster selects the home cluster; namespace grants never qualify. func (u *User) Can(perm string, namespaced bool, cluster, ns string) bool { @@ -128,7 +128,7 @@ func (u *User) Can(perm string, namespaced bool, cluster, ns string) bool { cwHolds := func(ck string) bool { return permSetHas(u.Perms[ck]["*"], "*") || permSetHas(u.Perms[ck]["*"], perm) } - if perm == "cluster:read" { + if perm == "cluster:read" || perm == "modules:read" || perm == "modules:manage" || perm == "templates:read" || perm == "templates:write" { if cluster == "" { cluster = scope.DefaultCluster } @@ -168,7 +168,7 @@ func (u *User) CanDiscoverCluster(cluster string) bool { if u.Can("users:manage", false, "", "") || u.Can("cluster:manage", false, "", "") { return true } - if u.Can("cluster:read", true, cluster, "") { + if u.Can("cluster:read", true, cluster, "") || u.Can("modules:read", true, cluster, "") || u.Can("modules:manage", true, cluster, "") { return true } for _, ck := range []string{cluster, "*"} { diff --git a/api/internal/auth/perms_test.go b/api/internal/auth/perms_test.go index 00e9d52af..9eb70c39d 100644 --- a/api/internal/auth/perms_test.go +++ b/api/internal/auth/perms_test.go @@ -128,6 +128,39 @@ func TestUserCan_ClusterScopedNotGatedByCluster(t *testing.T) { } } +func TestUserCan_ModuleAndTemplatePermissionsAreTargetScoped(t *testing.T) { + for _, permission := range []string{"modules:read", "modules:manage", "templates:read", "templates:write"} { + for _, namespaced := range []bool{false, true} { + t.Run(permission+map[bool]string{false: "/cluster-scoped", true: "/explicit-target"}[namespaced], func(t *testing.T) { + remote := &User{Perms: map[string]map[string]map[string]struct{}{"remote": {"*": {permission: {}}}}} + if !remote.Can(permission, namespaced, "remote", "") { + t.Fatal("remote workload grant denied on selected cluster") + } + for _, other := range []string{"", "local", "other"} { + if remote.Can(permission, namespaced, other, "") { + t.Errorf("remote workload grant leaked to target %q", other) + } + } + namespace := &User{Perms: map[string]map[string]map[string]struct{}{"remote": {"games": {permission: {}, "*": {}}}}} + if namespace.Can(permission, namespaced, "remote", "games") { + t.Fatal("namespace grant authorized cluster-wide catalog access") + } + wildcard := &User{Perms: map[string]map[string]map[string]struct{}{"*": {"*": {permission: {}}}}} + for _, target := range []string{"", "local", "remote", "other"} { + if !wildcard.Can(permission, namespaced, target, "") { + t.Errorf("wildcard workload grant denied target %q", target) + } + } + for _, central := range []string{"users:manage", "roles:manage", "config:manage", "cluster:manage"} { + if remote.Can(central, false, "", "") { + t.Errorf("workload grant conferred central %s", central) + } + } + }) + } + } +} + func TestUserCan_InventoryAlwaysTargetScoped(t *testing.T) { for _, tc := range []struct { name, bindingCluster, bindingNamespace, permission, target, namespace string diff --git a/api/internal/auth/registry.go b/api/internal/auth/registry.go index 622f837a3..de5c69936 100644 --- a/api/internal/auth/registry.go +++ b/api/internal/auth/registry.go @@ -103,7 +103,7 @@ type SecretReader func(ctx context.Context, name string) (map[string][]byte, err // ProviderSecretLabel=true. func NewK8sSecretReader(k *kube.Client, ns string) SecretReader { return func(ctx context.Context, name string) (map[string][]byte, error) { - sec, err := k.Typed.CoreV1().Secrets(ns).Get(ctx, name, metav1.GetOptions{}) + sec, err := k.Secrets(ns).Get(ctx, name, metav1.GetOptions{}) if err != nil { return nil, fmt.Errorf("get provider secret %s/%s: %w", ns, name, err) } diff --git a/api/internal/controlplane/resources.go b/api/internal/controlplane/resources.go new file mode 100644 index 000000000..8fa05d5e6 --- /dev/null +++ b/api/internal/controlplane/resources.go @@ -0,0 +1,134 @@ +package controlplane + +import ( + "context" + "encoding/json" + "fmt" + + corev1 "k8s.io/api/core/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" +) + +type secrets struct{ objects } +type clusters struct{ objects } + +func secretObject(secret *corev1.Secret) (*unstructured.Unstructured, error) { + secret = secret.DeepCopy() + if secret.Data == nil { + secret.Data = map[string][]byte{} + } + for key, value := range secret.StringData { + secret.Data[key] = []byte(value) + } + secret.StringData = nil + secret.APIVersion = "v1" + secret.Kind = "Secret" + data, err := json.Marshal(secret) + if err != nil { + return nil, fmt.Errorf("encode credential: %w", err) + } + obj := &unstructured.Unstructured{} + if err := json.Unmarshal(data, &obj.Object); err != nil { + return nil, fmt.Errorf("decode credential object: %w", err) + } + return obj, nil +} + +func asSecret(obj *unstructured.Unstructured, err error) (*corev1.Secret, error) { + if err != nil { + return nil, err + } + data, err := json.Marshal(obj.Object) + if err != nil { + return nil, fmt.Errorf("encode credential object: %w", err) + } + secret := &corev1.Secret{} + if err := json.Unmarshal(data, secret); err != nil { + return nil, fmt.Errorf("decode credential: %w", err) + } + return secret, nil +} + +func (s *secrets) Get(ctx context.Context, name string, _ metav1.GetOptions) (*corev1.Secret, error) { + return asSecret(s.get(ctx, name)) +} + +func (s *secrets) List(ctx context.Context, opts metav1.ListOptions) (*corev1.SecretList, error) { + list, err := s.list(ctx, opts) + if err != nil { + return nil, err + } + out := &corev1.SecretList{Items: []corev1.Secret{}} + out.Continue = list.GetContinue() + for i := range list.Items { + secret, err := asSecret(&list.Items[i], nil) + if err != nil { + return nil, err + } + out.Items = append(out.Items, *secret) + } + return out, nil +} + +func (s *secrets) Create(ctx context.Context, secret *corev1.Secret, opts metav1.CreateOptions) (*corev1.Secret, error) { + if len(opts.DryRun) != 0 { + return nil, apierrors.NewBadRequest("management dry run is unsupported") + } + obj, err := secretObject(secret) + if err != nil { + return nil, err + } + return asSecret(s.save(ctx, obj, true)) +} + +func (s *secrets) Update(ctx context.Context, secret *corev1.Secret, opts metav1.UpdateOptions) (*corev1.Secret, error) { + if len(opts.DryRun) != 0 { + return nil, apierrors.NewBadRequest("management dry run is unsupported") + } + obj, err := secretObject(secret) + if err != nil { + return nil, err + } + return asSecret(s.save(ctx, obj, false)) +} + +func (s *secrets) Delete(ctx context.Context, name string, opts metav1.DeleteOptions) error { + if len(opts.DryRun) != 0 { + return apierrors.NewBadRequest("management dry run is unsupported") + } + return s.delete(ctx, name, opts) +} + +func (s *clusters) Get(ctx context.Context, name string, _ metav1.GetOptions, subresources ...string) (*unstructured.Unstructured, error) { + if len(subresources) != 0 { + return nil, apierrors.NewBadRequest("management subresources are unsupported") + } + return s.get(ctx, name) +} + +func (s *clusters) List(ctx context.Context, opts metav1.ListOptions) (*unstructured.UnstructuredList, error) { + return s.list(ctx, opts) +} + +func (s *clusters) Create(ctx context.Context, obj *unstructured.Unstructured, opts metav1.CreateOptions, subresources ...string) (*unstructured.Unstructured, error) { + if len(subresources) != 0 || len(opts.DryRun) != 0 { + return nil, apierrors.NewBadRequest("management subresources and dry run are unsupported") + } + return s.save(ctx, obj, true) +} + +func (s *clusters) Update(ctx context.Context, obj *unstructured.Unstructured, opts metav1.UpdateOptions, subresources ...string) (*unstructured.Unstructured, error) { + if len(subresources) != 0 || len(opts.DryRun) != 0 { + return nil, apierrors.NewBadRequest("management subresources and dry run are unsupported") + } + return s.save(ctx, obj, false) +} + +func (s *clusters) Delete(ctx context.Context, name string, opts metav1.DeleteOptions, subresources ...string) error { + if len(subresources) != 0 || len(opts.DryRun) != 0 { + return apierrors.NewBadRequest("management subresources and dry run are unsupported") + } + return s.delete(ctx, name, opts) +} diff --git a/api/internal/controlplane/store.go b/api/internal/controlplane/store.go new file mode 100644 index 000000000..afdabbbb2 --- /dev/null +++ b/api/internal/controlplane/store.go @@ -0,0 +1,340 @@ +// Package controlplane stores standalone panel credentials and remote cluster +// registrations without creating a local Kubernetes cluster. +package controlplane + +import ( + "context" + "crypto/aes" + "crypto/cipher" + "crypto/rand" + "database/sql" + "encoding/base64" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "strconv" + "strings" + + "github.com/GameplanePanel/gameplane/api/internal/db" + "github.com/GameplanePanel/gameplane/api/internal/kube" + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/labels" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/apimachinery/pkg/types" + "k8s.io/apimachinery/pkg/util/validation" +) + +type backend struct { + db *sql.DB + aead cipher.AEAD +} + +type objects struct { + *backend + kind string + ns string +} + +// New opens management storage after Store.Migrate. The encryption key must +// survive alongside the database; a missing key with existing secrets is fatal. +// Kubernetes workload clients are intentionally absent from the result. +func New(ctx context.Context, store *db.Store, keyFile string) (*kube.Client, error) { + var count int + if err := store.DB.QueryRowContext(ctx, `SELECT COUNT(*) FROM management_objects WHERE kind = 'secrets'`).Scan(&count); err != nil { + return nil, fmt.Errorf("inspect management credentials: %w", err) + } + key, err := loadKey(keyFile, count != 0) + if err != nil { + return nil, err + } + block, err := aes.NewCipher(key) + if err != nil { + return nil, fmt.Errorf("create credential cipher: %w", err) + } + aead, err := cipher.NewGCM(block) + if err != nil { + return nil, fmt.Errorf("create credential encryption: %w", err) + } + b := &backend{db: store.DB, aead: aead} + // Validate every persisted row before serving requests. Wrong keys and + // corrupt ciphertext must fail startup, not silently hide credentials. + rows, err := b.db.QueryContext(ctx, `SELECT kind, namespace, name, uid, version, payload FROM management_objects`) + if err != nil { + return nil, fmt.Errorf("validate management storage: %w", err) + } + defer rows.Close() + for rows.Next() { + var kind, ns, name, uid, payload string + var version int64 + if err := rows.Scan(&kind, &ns, &name, &uid, &version, &payload); err != nil { + return nil, fmt.Errorf("read management row: %w", err) + } + if kind != "secrets" && kind != "clusters" { + return nil, errors.New("unknown management object kind") + } + if _, err := (&objects{b, kind, ns}).decode(name, uid, version, payload); err != nil { + return nil, err + } + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("read management storage: %w", err) + } + return &kube.Client{ + SecretStore: func(ns string) kube.SecretStore { return &secrets{objects{b, "secrets", ns}} }, + ClusterStore: &clusters{objects{b, "clusters", ""}}, + }, nil +} + +func loadKey(path string, required bool) ([]byte, error) { + if path == "" { + return nil, errors.New("management key file path is required") + } + key, err := os.ReadFile(path) + if err == nil { + if len(key) != 32 { + return nil, errors.New("management key file must contain exactly 32 bytes") + } + return key, nil + } + if !errors.Is(err, os.ErrNotExist) { + return nil, fmt.Errorf("read management key: %w", err) + } + if required { + return nil, errors.New("management encryption key is missing; restore the original key alongside the database") + } + if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil { + return nil, fmt.Errorf("create key directory: %w", err) + } + key = make([]byte, 32) + if _, err := rand.Read(key); err != nil { + return nil, fmt.Errorf("generate management key: %w", err) + } + // Link a fully written private temporary file into place atomically. An + // existing destination is never overwritten, including concurrent startup. + f, err := os.CreateTemp(filepath.Dir(path), ".management-key-*") + if err != nil { + return nil, fmt.Errorf("create management key: %w", err) + } + tmp := f.Name() + defer func() { _ = os.Remove(tmp) }() + if _, err := f.Write(key); err != nil { + _ = f.Close() + return nil, fmt.Errorf("write management key: %w", err) + } + if err := f.Sync(); err != nil { + _ = f.Close() + return nil, fmt.Errorf("sync management key: %w", err) + } + if err := f.Close(); err != nil { + return nil, fmt.Errorf("close management key: %w", err) + } + if err := os.Link(tmp, path); err != nil { + if errors.Is(err, os.ErrExist) { + return loadKey(path, true) + } + return nil, fmt.Errorf("publish management key: %w", err) + } + return key, nil +} + +func (s *objects) resource() schema.GroupResource { + if s.kind == "clusters" { + return kube.GVRCluster.GroupResource() + } + return schema.GroupResource{Resource: "secrets"} +} + +func (s *objects) aad(name, uid string, version int64) []byte { + return []byte(strings.Join([]string{s.kind, s.ns, name, uid, strconv.FormatInt(version, 10)}, "\x00")) +} + +func (s *objects) encode(obj *unstructured.Unstructured, version int64) (string, error) { + data, err := json.Marshal(obj.Object) + if err != nil { + return "", fmt.Errorf("encode management object: %w", err) + } + if s.kind != "secrets" { + return string(data), nil + } + nonce := make([]byte, s.aead.NonceSize()) + if _, err := rand.Read(nonce); err != nil { + return "", fmt.Errorf("generate credential nonce: %w", err) + } + sealed := s.aead.Seal(nonce, nonce, data, s.aad(obj.GetName(), string(obj.GetUID()), version)) + return base64.StdEncoding.EncodeToString(sealed), nil +} + +func (s *objects) decode(name, uid string, version int64, payload string) (*unstructured.Unstructured, error) { + data := []byte(payload) + if s.kind == "secrets" { + sealed, err := base64.StdEncoding.DecodeString(payload) + if err != nil || len(sealed) < s.aead.NonceSize() { + return nil, errors.New("invalid encrypted management credential") + } + data, err = s.aead.Open(nil, sealed[:s.aead.NonceSize()], sealed[s.aead.NonceSize():], s.aad(name, uid, version)) + if err != nil { + return nil, errors.New("management credential authentication failed; verify the database and encryption key") + } + } + obj := &unstructured.Unstructured{} + if err := json.Unmarshal(data, &obj.Object); err != nil { + return nil, errors.New("invalid management object data") + } + if obj.GetName() != name || obj.GetNamespace() != s.ns || string(obj.GetUID()) != uid || obj.GetResourceVersion() != strconv.FormatInt(version, 10) { + return nil, errors.New("management object metadata does not match stored identity") + } + return obj, nil +} + +func (s *objects) get(ctx context.Context, name string) (*unstructured.Unstructured, error) { + var uid, payload string + var version int64 + err := s.db.QueryRowContext(ctx, `SELECT uid, version, payload FROM management_objects WHERE kind = ? AND namespace = ? AND name = ?`, s.kind, s.ns, name).Scan(&uid, &version, &payload) + if errors.Is(err, sql.ErrNoRows) { + return nil, apierrors.NewNotFound(s.resource(), name) + } + if err != nil { + return nil, fmt.Errorf("read management object: %w", err) + } + return s.decode(name, uid, version, payload) +} + +func (s *objects) list(ctx context.Context, opts metav1.ListOptions) (*unstructured.UnstructuredList, error) { + selector, err := labels.Parse(opts.LabelSelector) + if err != nil { + return nil, apierrors.NewBadRequest("invalid label selector") + } + if opts.FieldSelector != "" || opts.Watch || opts.Limit < 0 { + return nil, apierrors.NewBadRequest("unsupported management list options") + } + var after string + if opts.Continue != "" { + decoded, err := base64.RawURLEncoding.DecodeString(opts.Continue) + if err != nil { + return nil, apierrors.NewBadRequest("invalid continuation token") + } + after = string(decoded) + } + rows, err := s.db.QueryContext(ctx, `SELECT name, uid, version, payload FROM management_objects WHERE kind = ? AND namespace = ? AND name > ? ORDER BY name`, s.kind, s.ns, after) + if err != nil { + return nil, fmt.Errorf("list management objects: %w", err) + } + defer rows.Close() + result := &unstructured.UnstructuredList{Items: []unstructured.Unstructured{}} + for rows.Next() { + var name, uid, payload string + var version int64 + if err := rows.Scan(&name, &uid, &version, &payload); err != nil { + return nil, fmt.Errorf("read management object: %w", err) + } + obj, err := s.decode(name, uid, version, payload) + if err != nil { + return nil, err + } + if !selector.Matches(labels.Set(obj.GetLabels())) { + continue + } + if opts.Limit > 0 && int64(len(result.Items)) == opts.Limit { + result.SetContinue(base64.RawURLEncoding.EncodeToString([]byte(result.Items[len(result.Items)-1].GetName()))) + break + } + result.Items = append(result.Items, *obj) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("list management objects: %w", err) + } + return result, nil +} + +func (s *objects) save(ctx context.Context, obj *unstructured.Unstructured, create bool) (*unstructured.Unstructured, error) { + obj = obj.DeepCopy() + name := obj.GetName() + if len(validation.IsDNS1123Subdomain(name)) != 0 { + return nil, apierrors.NewBadRequest("a valid management object name is required") + } + if obj.GetNamespace() != "" && obj.GetNamespace() != s.ns { + return nil, apierrors.NewBadRequest("namespace does not match management store") + } + obj.SetNamespace(s.ns) + version := int64(1) + if create { + if obj.GetResourceVersion() != "" { + return nil, apierrors.NewBadRequest("resourceVersion must be empty on create") + } + uid := make([]byte, 16) + if _, err := rand.Read(uid); err != nil { + return nil, fmt.Errorf("generate management identity: %w", err) + } + obj.SetUID(types.UID(hex.EncodeToString(uid))) + obj.SetCreationTimestamp(metav1.Now()) + } else { + existing, err := s.get(ctx, name) + if err != nil { + return nil, err + } + if obj.GetResourceVersion() == "" || obj.GetResourceVersion() != existing.GetResourceVersion() || (obj.GetUID() != "" && obj.GetUID() != existing.GetUID()) { + return nil, apierrors.NewConflict(s.resource(), name, errors.New("management object changed; reload before updating")) + } + previous, err := strconv.ParseInt(existing.GetResourceVersion(), 10, 64) + if err != nil { + return nil, errors.New("invalid management object version") + } + version = previous + 1 + obj.SetUID(existing.GetUID()) + obj.SetCreationTimestamp(existing.GetCreationTimestamp()) + } + obj.SetResourceVersion(strconv.FormatInt(version, 10)) + payload, err := s.encode(obj, version) + if err != nil { + return nil, err + } + var result sql.Result + if create { + result, err = s.db.ExecContext(ctx, `INSERT INTO management_objects (kind, namespace, name, uid, version, payload) VALUES (?, ?, ?, ?, ?, ?) ON CONFLICT (kind, namespace, name) DO NOTHING`, s.kind, s.ns, name, string(obj.GetUID()), version, payload) + } else { + result, err = s.db.ExecContext(ctx, `UPDATE management_objects SET version = ?, payload = ? WHERE kind = ? AND namespace = ? AND name = ? AND uid = ? AND version = ?`, version, payload, s.kind, s.ns, name, string(obj.GetUID()), version-1) + } + if err != nil { + return nil, fmt.Errorf("persist management object: %w", err) + } + n, err := result.RowsAffected() + if err != nil { + return nil, fmt.Errorf("inspect management write: %w", err) + } + if n == 0 { + if create { + return nil, apierrors.NewAlreadyExists(s.resource(), name) + } + return nil, apierrors.NewConflict(s.resource(), name, errors.New("management object changed during update")) + } + return obj, nil +} + +func (s *objects) delete(ctx context.Context, name string, opts metav1.DeleteOptions) error { + obj, err := s.get(ctx, name) + if err != nil { + return err + } + if p := opts.Preconditions; p != nil { + if (p.UID != nil && *p.UID != obj.GetUID()) || (p.ResourceVersion != nil && *p.ResourceVersion != obj.GetResourceVersion()) { + return apierrors.NewConflict(s.resource(), name, errors.New("management object delete precondition failed")) + } + } + result, err := s.db.ExecContext(ctx, `DELETE FROM management_objects WHERE kind = ? AND namespace = ? AND name = ? AND uid = ? AND version = ?`, s.kind, s.ns, name, string(obj.GetUID()), obj.GetResourceVersion()) + if err != nil { + return fmt.Errorf("delete management object: %w", err) + } + n, err := result.RowsAffected() + if err != nil { + return fmt.Errorf("inspect management deletion: %w", err) + } + if n == 0 { + return apierrors.NewConflict(s.resource(), name, errors.New("management object changed during deletion")) + } + return nil +} diff --git a/api/internal/controlplane/store_test.go b/api/internal/controlplane/store_test.go new file mode 100644 index 000000000..bfbdfabb6 --- /dev/null +++ b/api/internal/controlplane/store_test.go @@ -0,0 +1,316 @@ +package controlplane + +import ( + "context" + "encoding/base64" + "encoding/pem" + "fmt" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/GameplanePanel/gameplane/api/internal/db" + "github.com/GameplanePanel/gameplane/api/internal/kube" + corev1 "k8s.io/api/core/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/types" +) + +func managementFixture(t *testing.T) (*db.Store, *kube.Client, string) { + t.Helper() + dir := t.TempDir() + store, err := db.Open(t.Context(), "sqlite", filepath.Join(dir, "panel.db")) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = store.Close() }) + if err := store.Migrate(t.Context()); err != nil { + t.Fatal(err) + } + keyFile := filepath.Join(dir, "management.key") + client, err := New(t.Context(), store, keyFile) + if err != nil { + t.Fatal(err) + } + return store, client, keyFile +} + +func credential(name string) *corev1.Secret { + return &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{Name: name, Labels: map[string]string{"managed": "true"}}, + StringData: map[string]string{"token": "very-private-test-credential"}, + } +} + +func TestCredentialsPersistEncryptedAndRemainNamespaceIsolated(t *testing.T) { + store, client, keyFile := managementFixture(t) + created, err := client.Secrets("panel").Create(t.Context(), credential("provider"), metav1.CreateOptions{}) + if err != nil { + t.Fatal(err) + } + if created.UID == "" || created.ResourceVersion != "1" || len(created.StringData) != 0 { + t.Fatalf("missing persisted identity: %#v", created.ObjectMeta) + } + var payload string + if err := store.DB.QueryRowContext(t.Context(), `SELECT payload FROM management_objects WHERE name = 'provider'`).Scan(&payload); err != nil { + t.Fatal(err) + } + for _, forbidden := range []string{"very-private-test-credential", base64.StdEncoding.EncodeToString(created.Data["token"]), `"token"`} { + if strings.Contains(payload, forbidden) { + t.Fatal("credential data stored without encryption") + } + } + keyBefore, err := os.ReadFile(keyFile) + if err != nil { + t.Fatal(err) + } + reopened, err := New(t.Context(), store, keyFile) + if err != nil { + t.Fatal(err) + } + keyAfter, err := os.ReadFile(keyFile) + if err != nil || string(keyBefore) != string(keyAfter) { + t.Fatal("startup changed the encryption key") + } + got, err := reopened.Secrets("panel").Get(t.Context(), "provider", metav1.GetOptions{}) + if err != nil || string(got.Data["token"]) != "very-private-test-credential" || got.UID != created.UID { + t.Fatalf("credential did not survive reopen: %v", err) + } + if _, err := reopened.Secrets("other").Get(t.Context(), "provider", metav1.GetOptions{}); !apierrors.IsNotFound(err) { + t.Fatalf("namespace leak: %v", err) + } + if _, err := reopened.Secrets("other").Create(t.Context(), credential("provider"), metav1.CreateOptions{}); err != nil { + t.Fatal(err) + } + list, err := reopened.Secrets("panel").List(t.Context(), metav1.ListOptions{LabelSelector: "managed=true"}) + if err != nil || len(list.Items) != 1 { + t.Fatalf("label/namespace selection failed: %v", err) + } +} + +func TestCredentialUpdatesAndDeletesUseOptimisticConcurrency(t *testing.T) { + _, client, _ := managementFixture(t) + secrets := client.Secrets("panel") + first, err := secrets.Create(t.Context(), credential("provider"), metav1.CreateOptions{}) + if err != nil { + t.Fatal(err) + } + if _, err := secrets.Create(t.Context(), credential("provider"), metav1.CreateOptions{}); !apierrors.IsAlreadyExists(err) { + t.Fatalf("duplicate create: %v", err) + } + changed := first.DeepCopy() + changed.Data["token"] = []byte("rotated") + second, err := secrets.Update(t.Context(), changed, metav1.UpdateOptions{}) + if err != nil { + t.Fatal(err) + } + if second.UID != first.UID || second.ResourceVersion != "2" { + t.Fatal("update lost identity/version") + } + if _, err := secrets.Update(t.Context(), first, metav1.UpdateOptions{}); !apierrors.IsConflict(err) { + t.Fatalf("stale update accepted: %v", err) + } + if err := secrets.Delete(t.Context(), "provider", metav1.DeleteOptions{Preconditions: &metav1.Preconditions{ResourceVersion: &first.ResourceVersion}}); !apierrors.IsConflict(err) { + t.Fatalf("stale delete accepted: %v", err) + } + wrong := types.UID("wrong") + if err := secrets.Delete(t.Context(), "provider", metav1.DeleteOptions{Preconditions: &metav1.Preconditions{UID: &wrong}}); !apierrors.IsConflict(err) { + t.Fatalf("wrong identity delete accepted: %v", err) + } + if err := secrets.Delete(t.Context(), "provider", metav1.DeleteOptions{Preconditions: &metav1.Preconditions{UID: &second.UID, ResourceVersion: &second.ResourceVersion}}); err != nil { + t.Fatal(err) + } + recreated, err := secrets.Create(t.Context(), credential("provider"), metav1.CreateOptions{}) + if err != nil { + t.Fatal(err) + } + if recreated.UID == first.UID { + t.Fatal("recreation reused deleted identity") + } + if _, err := secrets.Update(t.Context(), first, metav1.UpdateOptions{}); !apierrors.IsConflict(err) { + t.Fatalf("old object replaced recreated credential: %v", err) + } +} + +func TestStartupFailsClosedOnMissingWrongKeyAndCorruptCiphertext(t *testing.T) { + for _, damage := range []string{"missing-key", "wrong-key", "ciphertext"} { + t.Run(damage, func(t *testing.T) { + store, client, keyFile := managementFixture(t) + if _, err := client.Secrets("panel").Create(t.Context(), credential("provider"), metav1.CreateOptions{}); err != nil { + t.Fatal(err) + } + switch damage { + case "missing-key": + if err := os.Remove(keyFile); err != nil { + t.Fatal(err) + } + case "wrong-key": + if err := os.WriteFile(keyFile, make([]byte, 32), 0600); err != nil { + t.Fatal(err) + } + case "ciphertext": + if _, err := store.DB.ExecContext(t.Context(), `UPDATE management_objects SET payload = 'corrupt'`); err != nil { + t.Fatal(err) + } + } + if _, err := New(t.Context(), store, keyFile); err == nil { + t.Fatal("startup accepted inaccessible credential state") + } + if damage == "missing-key" { + if _, err := os.Stat(keyFile); !os.IsNotExist(err) { + t.Fatal("startup replaced a missing key over existing credentials") + } + } + }) + } +} + +func TestCiphertextCannotBeMovedBetweenCredentialIdentities(t *testing.T) { + store, client, _ := managementFixture(t) + for _, name := range []string{"one", "two"} { + if _, err := client.Secrets("panel").Create(t.Context(), credential(name), metav1.CreateOptions{}); err != nil { + t.Fatal(err) + } + } + if _, err := store.DB.ExecContext(t.Context(), `UPDATE management_objects SET payload = (SELECT payload FROM management_objects WHERE name = 'one') WHERE name = 'two'`); err != nil { + t.Fatal(err) + } + if _, err := client.Secrets("panel").Get(t.Context(), "two", metav1.GetOptions{}); err == nil { + t.Fatal("swapped ciphertext authenticated") + } +} + +func registration(name string) *unstructured.Unstructured { + return &unstructured.Unstructured{Object: map[string]any{ + "apiVersion": "gameplane.local/v1alpha1", "kind": "Cluster", + "metadata": map[string]any{"name": name}, + "spec": map[string]any{"kubeconfigSecret": map[string]any{"name": "remote-config", "key": "kubeconfig"}}, + }} +} + +func TestStandaloneRegistryIsEmptyAndRegistrationsPersistWithPagination(t *testing.T) { + store, client, keyFile := managementFixture(t) + reg := kube.NewRegistry("") + reg.SetManagement(client) + if len(reg.IDs()) != 0 || reg.Default() != nil || reg.Management() != client || client.Typed != nil || client.Dynamic != nil || client.Config != nil { + t.Fatal("standalone panel became a workload cluster") + } + for _, name := range []string{"alpha", "beta"} { + if _, err := client.Clusters().Create(t.Context(), registration(name), metav1.CreateOptions{}); err != nil { + t.Fatal(err) + } + } + reopened, err := New(t.Context(), store, keyFile) + if err != nil { + t.Fatal(err) + } + first, err := reopened.Clusters().List(t.Context(), metav1.ListOptions{Limit: 1}) + if err != nil || len(first.Items) != 1 || first.Items[0].GetName() != "alpha" || first.GetContinue() == "" { + t.Fatalf("first page: %v", err) + } + second, err := reopened.Clusters().List(t.Context(), metav1.ListOptions{Limit: 1, Continue: first.GetContinue()}) + if err != nil || len(second.Items) != 1 || second.Items[0].GetName() != "beta" || second.GetContinue() != "" { + t.Fatalf("second page: %v", err) + } + stale := first.Items[0].DeepCopy() + current := stale.DeepCopy() + current.Object["status"] = map[string]any{"phase": "Healthy"} + if _, err := reopened.Clusters().Update(t.Context(), current, metav1.UpdateOptions{}); err != nil { + t.Fatal(err) + } + if _, err := reopened.Clusters().Update(t.Context(), stale, metav1.UpdateOptions{}); !apierrors.IsConflict(err) { + t.Fatalf("stale registration update: %v", err) + } +} + +func TestStandaloneWatcherReloadsAndPersistsRemoteHealth(t *testing.T) { + store, client, keyFile := managementFixture(t) + server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/version" { + t.Errorf("unexpected remote path: %s", r.URL.Path) + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"gitVersion":"v1.35.0"}`)) + })) + defer server.Close() + ca := base64.StdEncoding.EncodeToString(pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: server.Certificate().Raw})) + config := fmt.Sprintf(`apiVersion: v1 +kind: Config +clusters: +- name: remote + cluster: + server: %s + certificate-authority-data: %s +contexts: +- name: remote + context: + cluster: remote + user: panel +current-context: remote +users: +- name: panel + user: + token: private-remote-token +`, server.URL, ca) + _, err := client.Secrets("panel").Create(t.Context(), &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{Name: "remote-config", Labels: map[string]string{kube.ClusterKubeconfigLabel: "true"}}, + Data: map[string][]byte{"kubeconfig": []byte(config)}, + }, metav1.CreateOptions{}) + if err != nil { + t.Fatal(err) + } + if _, err := client.Clusters().Create(t.Context(), registration("remote"), metav1.CreateOptions{}); err != nil { + t.Fatal(err) + } + client, err = New(t.Context(), store, keyFile) + if err != nil { + t.Fatal(err) + } + reg := kube.NewRegistry("") + reg.SetManagement(client) + ctx, cancel := context.WithCancel(t.Context()) + defer cancel() + done := make(chan struct{}) + go func() { defer close(done); kube.WatchClusters(ctx, client, reg, "panel") }() + deadline := time.After(5 * time.Second) + ticker := time.NewTicker(10 * time.Millisecond) + defer ticker.Stop() + for { + select { + case <-deadline: + t.Fatal("watcher did not restore registration and health") + case <-ticker.C: + obj, err := client.Clusters().Get(t.Context(), "remote", metav1.GetOptions{}) + if err != nil { + t.Fatal(err) + } + phase, _, _ := unstructured.NestedString(obj.Object, "status", "phase") + if phase != "Healthy" { + continue + } + version, _, _ := unstructured.NestedString(obj.Object, "status", "serverVersion") + if version != "v1.35.0" { + t.Fatalf("wrong remote version: %s", version) + } + if _, ok := reg.Get("remote"); !ok { + continue + } + if reg.Default() != nil { + t.Fatal("watcher installed a local workload client") + } + cancel() + select { + case <-done: + case <-time.After(time.Second): + t.Fatal("watcher did not stop on cancellation") + } + return + } + } +} diff --git a/api/internal/db/migrations/common/016_standalone_management.sql b/api/internal/db/migrations/common/016_standalone_management.sql new file mode 100644 index 000000000..7cd769593 --- /dev/null +++ b/api/internal/db/migrations/common/016_standalone_management.sql @@ -0,0 +1,11 @@ +-- Credentials are authenticated ciphertext, never plaintext JSON. Cluster +-- registrations contain public connection references and health metadata. +CREATE TABLE management_objects ( + kind TEXT NOT NULL, + namespace TEXT NOT NULL, + name TEXT NOT NULL, + uid TEXT NOT NULL, + version BIGINT NOT NULL, + payload TEXT NOT NULL, + PRIMARY KEY (kind, namespace, name) +); diff --git a/api/internal/handlers/binding_permissions.go b/api/internal/handlers/binding_permissions.go index 517e4cb7e..efefa18d3 100644 --- a/api/internal/handlers/binding_permissions.go +++ b/api/internal/handlers/binding_permissions.go @@ -11,11 +11,11 @@ import ( // remoteWidePermissionsAllowed keeps supplemental remote grants away from // control-plane administration. Can always scopes cluster:read to the selected // cluster. Other Can(false) permissions accept a global grant from any cluster, -// so only inventory and catalogued namespaced permissions are safe here; +// so only target-scoped inventory, modules, templates and catalogued namespaced permissions are safe here; // '*' is never allowed. func remoteWidePermissionsAllowed(permissions []string) bool { for _, permission := range permissions { - if permission != "cluster:read" && !rbac.Namespaced(permission) { + if permission != "cluster:read" && permission != "modules:read" && permission != "modules:manage" && permission != "templates:read" && permission != "templates:write" && !rbac.Namespaced(permission) { return false } } diff --git a/api/internal/handlers/cluster.go b/api/internal/handlers/cluster.go index 5901ebbcd..7ce26fd8b 100644 --- a/api/internal/handlers/cluster.go +++ b/api/internal/handlers/cluster.go @@ -73,9 +73,9 @@ func (h *clusterHandler) forRequest(w http.ResponseWriter, req *http.Request) (* // Registration metadata lives centrally, but inventory never does. // Keep a disconnected registration distinguishable from an unknown ID. if id != h.reg.DefaultID() { - home := h.reg.Default() - if home != nil && home.Dynamic != nil { - _, err := home.Dynamic.Resource(kube.GVRCluster).Get(req.Context(), id, metav1.GetOptions{}) + home := h.reg.Management() + if home != nil && home.Clusters() != nil { + _, err := home.Clusters().Get(req.Context(), id, metav1.GetOptions{}) if apierrors.IsNotFound(err) { httperr.Write(w, req, scope.ErrForbiddenCluster) return nil, false diff --git a/api/internal/handlers/clusters.go b/api/internal/handlers/clusters.go index 26896ac4c..b72ff7466 100644 --- a/api/internal/handlers/clusters.go +++ b/api/internal/handlers/clusters.go @@ -32,6 +32,8 @@ func MountClusters(r chi.Router, reg *kube.Registry, k *kube.Client, ns string) r.Get("/", h.list) r.Post("/", h.create) r.Delete("/{name}", h.delete) + r.Put("/{name}/gateway", h.configureGateway) + r.Delete("/{name}/gateway", h.removeGateway) }) } @@ -54,7 +56,7 @@ func deleteClusterKubeconfigSecret(ctx context.Context, k *kube.Client, ns, clus } // Fetch the Secret to check its labels. - secret, err := k.Typed.CoreV1().Secrets(ns).Get(ctx, secretName, metav1.GetOptions{}) + secret, err := k.Secrets(ns).Get(ctx, secretName, metav1.GetOptions{}) if err != nil { return err } @@ -65,7 +67,7 @@ func deleteClusterKubeconfigSecret(ctx context.Context, k *kube.Client, ns, clus } // Delete the Secret. - return k.Typed.CoreV1().Secrets(ns).Delete(ctx, secretName, metav1.DeleteOptions{}) + return k.Secrets(ns).Delete(ctx, secretName, metav1.DeleteOptions{Preconditions: objectDeletePreconditions(secret)}) } type clustersHandler struct { @@ -103,13 +105,13 @@ func (h clustersHandler) list(w http.ResponseWriter, req *http.Request) { } out := clustersListResp{Items: make([]clusterRegistryView, 0)} local := h.reg.DefaultID() - if u.CanDiscoverCluster(local) { + if h.reg.Default() != nil && u.CanDiscoverCluster(local) { out.Items = append(out.Items, clusterRegistryView{Name: local, Phase: "Healthy", CanViewInventory: u.Can("cluster:read", true, local, "")}) } // Persisted registrations remain discoverable when a kubeconfig cannot // load; the client registry alone would silently drop those clusters. - registrations, err := h.k.Dynamic.Resource(kube.GVRCluster).List(req.Context(), metav1.ListOptions{}) + registrations, err := h.k.Clusters().List(req.Context(), metav1.ListOptions{}) if err != nil { // Older single-cluster installations may not have the optional CRD. // Only that absence may degrade to local discovery; permission and @@ -196,7 +198,7 @@ func (h clustersHandler) create(w http.ResponseWriter, req *http.Request) { "kubeconfig": []byte(in.Kubeconfig), }, } - _, err = h.k.Typed.CoreV1().Secrets(h.namespace).Create(req.Context(), secret, metav1.CreateOptions{}) + _, err = h.k.Secrets(h.namespace).Create(req.Context(), secret, metav1.CreateOptions{}) if err != nil { if apierrors.IsAlreadyExists(err) { httperr.WriteCode(w, req, http.StatusConflict, errors.New("cluster already exists")) @@ -223,10 +225,10 @@ func (h clustersHandler) create(w http.ResponseWriter, req *http.Request) { }, }, } - _, err = h.k.Dynamic.Resource(kube.GVRCluster).Create(req.Context(), clusterCR, metav1.CreateOptions{}) + _, err = h.k.Clusters().Create(req.Context(), clusterCR, metav1.CreateOptions{}) if err != nil { // Clean up the Secret on CR creation failure. - _ = h.k.Typed.CoreV1().Secrets(h.namespace).Delete(req.Context(), secretName, metav1.DeleteOptions{}) + _ = h.k.Secrets(h.namespace).Delete(req.Context(), secretName, metav1.DeleteOptions{}) if apierrors.IsAlreadyExists(err) { httperr.WriteCode(w, req, http.StatusConflict, errors.New("cluster already exists")) return @@ -253,7 +255,7 @@ func (h clustersHandler) delete(w http.ResponseWriter, req *http.Request) { } // Read the Cluster CR to find the Secret name. - u, err := h.k.Dynamic.Resource(kube.GVRCluster).Get(req.Context(), name, metav1.GetOptions{}) + u, err := h.k.Clusters().Get(req.Context(), name, metav1.GetOptions{}) if err != nil { if apierrors.IsNotFound(err) { httperr.WriteCode(w, req, http.StatusNotFound, err) @@ -273,7 +275,7 @@ func (h clustersHandler) delete(w http.ResponseWriter, req *http.Request) { } // Delete the Cluster CR. - if err := h.k.Dynamic.Resource(kube.GVRCluster).Delete(req.Context(), name, metav1.DeleteOptions{}); err != nil { + if err := h.k.Clusters().Delete(req.Context(), name, metav1.DeleteOptions{}); err != nil { if !apierrors.IsNotFound(err) { httperr.Write(w, req, err) return @@ -283,6 +285,10 @@ func (h clustersHandler) delete(w http.ResponseWriter, req *http.Request) { // Drop the cluster's client now instead of waiting for the cluster // watch, so no request is dispatched through a removed registration. h.reg.Remove(name) + // Only remove credentials this API owns; external GitOps credentials survive. + if err := deleteManagedSecret(req.Context(), h.k, h.namespace, gatewaySecretName(name), clusterGatewayLabel); err != nil && !apierrors.IsNotFound(err) { + slog.Warn("cluster delete: gateway credential cleanup failed", "cluster", name) + } // Clean up the kubeconfig Secret only if it is the one this cluster was created with // (cluster--kubeconfig) and carries the kubeconfig label. This includes Secrets diff --git a/api/internal/handlers/clusters_gateway.go b/api/internal/handlers/clusters_gateway.go new file mode 100644 index 000000000..f74982e6c --- /dev/null +++ b/api/internal/handlers/clusters_gateway.go @@ -0,0 +1,120 @@ +package handlers + +import ( + "context" + "crypto/tls" + "crypto/x509" + "encoding/json" + "errors" + "net/http" + "net/url" + + "github.com/GameplanePanel/gameplane/api/internal/kube" + "github.com/go-chi/chi/v5" + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/client-go/util/retry" + + "github.com/GameplanePanel/gameplane/api/internal/httperr" +) + +const clusterGatewayLabel = "gameplane.local/agent-gateway-credentials" + +type clusterGatewayRequest struct { + URL string `json:"url"` + CACert string `json:"caCert"` + ClientCert string `json:"clientCert"` + ClientKey string `json:"clientKey"` +} + +func gatewaySecretName(name string) string { return "cluster-" + name + "-gateway" } + +func (h clustersHandler) configureGateway(w http.ResponseWriter, req *http.Request) { + name := chi.URLParam(req, "name") + if name == h.reg.DefaultID() || !dnsLabelRE.MatchString(name) { + httperr.WriteCode(w, req, http.StatusBadRequest, errors.New("select a registered remote cluster")) + return + } + var in clusterGatewayRequest + if err := json.NewDecoder(req.Body).Decode(&in); err != nil { + httperr.WriteCode(w, req, http.StatusBadRequest, errors.New("invalid gateway configuration")) + return + } + u, err := url.Parse(in.URL) + if err != nil || u.Scheme != "https" || u.Hostname() == "" || u.User != nil || u.RawQuery != "" || u.Fragment != "" || (u.Path != "" && u.Path != "/") || u.Opaque != "" { + httperr.WriteCode(w, req, http.StatusBadRequest, errors.New("gateway URL must be an HTTPS origin")) + return + } + if _, err := tls.X509KeyPair([]byte(in.ClientCert), []byte(in.ClientKey)); err != nil { + httperr.WriteCode(w, req, http.StatusBadRequest, errors.New("invalid gateway client certificate or key")) + return + } + if !x509.NewCertPool().AppendCertsFromPEM([]byte(in.CACert)) { + httperr.WriteCode(w, req, http.StatusBadRequest, errors.New("invalid gateway CA certificate")) + return + } + registration, err := h.k.Clusters().Get(req.Context(), name, metav1.GetOptions{}) + if err != nil { + httperr.Write(w, req, err) + return + } + secretName := gatewaySecretName(name) + if err := upsertLabelledSecret(req.Context(), h.k, h.namespace, secretName, clusterGatewayLabel, map[string]string{ + "ca.crt": in.CACert, "tls.crt": in.ClientCert, "tls.key": in.ClientKey, + }); err != nil { + httperr.Write(w, req, err) + return + } + if err := h.updateGatewayRegistration(req.Context(), registration, map[string]any{ + "url": in.URL, "tlsSecretRef": map[string]any{"name": secretName}, + }); err != nil { + httperr.Write(w, req, err) + return + } + + w.WriteHeader(http.StatusNoContent) +} + +func (h clustersHandler) removeGateway(w http.ResponseWriter, req *http.Request) { + name := chi.URLParam(req, "name") + registration, err := h.k.Clusters().Get(req.Context(), name, metav1.GetOptions{}) + if err != nil { + httperr.Write(w, req, err) + return + } + secretName, _, _ := unstructured.NestedString(registration.Object, "spec", "agentGateway", "tlsSecretRef", "name") + if err := h.updateGatewayRegistration(req.Context(), registration, nil); err != nil { + httperr.Write(w, req, err) + return + } + + if secretName == gatewaySecretName(name) { + if err := deleteManagedSecret(req.Context(), h.k, h.namespace, secretName, clusterGatewayLabel); err != nil && !apierrors.IsNotFound(err) { + httperr.Write(w, req, err) + return + } + } + w.WriteHeader(http.StatusNoContent) +} + +// A health status update may race configuration. Retry against fresh metadata, +// but never carry a gateway write across deletion/recreation of a registration. +func (h clustersHandler) updateGatewayRegistration(ctx context.Context, original *unstructured.Unstructured, gateway map[string]any) error { + return retry.RetryOnConflict(retry.DefaultRetry, func() error { + current, err := h.k.Clusters().Get(ctx, original.GetName(), metav1.GetOptions{}) + if err != nil { + return err + } + if current.GetUID() != original.GetUID() { + return apierrors.NewNotFound(kube.GVRCluster.GroupResource(), original.GetName()) + } + if gateway == nil { + unstructured.RemoveNestedField(current.Object, "spec", "agentGateway") + } else if err := unstructured.SetNestedMap(current.Object, gateway, "spec", "agentGateway"); err != nil { + return err + } + _, err = h.k.Clusters().Update(ctx, current, metav1.UpdateOptions{}) + return err + }) +} diff --git a/api/internal/handlers/clusters_gateway_test.go b/api/internal/handlers/clusters_gateway_test.go new file mode 100644 index 000000000..d32530d1e --- /dev/null +++ b/api/internal/handlers/clusters_gateway_test.go @@ -0,0 +1,183 @@ +package handlers + +import ( + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/x509" + "encoding/pem" + "math/big" + "net/http" + "strings" + "testing" + "time" + + corev1 "k8s.io/api/core/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + + "github.com/GameplanePanel/gameplane/api/internal/controlplane" +) + +func gatewayCertificateRequest(t *testing.T) clusterGatewayRequest { + t.Helper() + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + t.Fatal(err) + } + template := &x509.Certificate{ + SerialNumber: big.NewInt(1), NotBefore: time.Now().Add(-time.Hour), NotAfter: time.Now().Add(time.Hour), + KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageCertSign, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth}, IsCA: true, BasicConstraintsValid: true, + } + der, err := x509.CreateCertificate(rand.Reader, template, template, &key.PublicKey, key) + if err != nil { + t.Fatal(err) + } + privateKey, err := x509.MarshalECPrivateKey(key) + if err != nil { + t.Fatal(err) + } + certificate := string(pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der})) + return clusterGatewayRequest{URL: "https://gateway.example.invalid:8443", CACert: certificate, ClientCert: certificate, ClientKey: string(pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: privateKey}))} +} + +func TestStandaloneGatewayConfigurationEncryptsCredentialsAndStoresOnlyReference(t *testing.T) { + store, home, reg, keyPath := standaloneHandlerStore(t) + if _, err := home.Clusters().Create(t.Context(), newCluster("remote", map[string]any{}, nil), metav1.CreateOptions{}); err != nil { + t.Fatal(err) + } + in := gatewayCertificateRequest(t) + router := standaloneClustersRouter(home, reg) + configured := doClusters(t, router, http.MethodPut, "/clusters/remote/gateway", in) + if configured.Code != http.StatusNoContent || configured.Body.Len() != 0 { + t.Fatalf("configure: %d %s", configured.Code, configured.Body) + } + assertManagementCiphertext(t, store, gatewaySecretName("remote"), in.ClientKey, in.ClientCert) + home, err := controlplane.New(t.Context(), store, keyPath) + if err != nil { + t.Fatal(err) + } + stored, err := home.Secrets(standaloneTestNamespace).Get(t.Context(), gatewaySecretName("remote"), metav1.GetOptions{}) + if err != nil { + t.Fatal(err) + } + if string(stored.Data["tls.key"]) != in.ClientKey || string(stored.Data["tls.crt"]) != in.ClientCert || string(stored.Data["ca.crt"]) != in.CACert { + t.Fatal("gateway credentials did not survive reopen") + } + if stored.Labels[clusterGatewayLabel] != "true" || stored.Labels[ManagedByLabel] != managedByValue { + t.Fatal("gateway credential ownership labels missing") + } + registration, err := home.Clusters().Get(t.Context(), "remote", metav1.GetOptions{}) + if err != nil { + t.Fatal(err) + } + gateway, found, err := unstructured.NestedMap(registration.Object, "spec", "agentGateway") + if err != nil || !found || len(gateway) != 2 || gateway["url"] != in.URL { + t.Fatalf("unexpected gateway registration: %+v %v", gateway, err) + } + ref, _, _ := unstructured.NestedString(registration.Object, "spec", "agentGateway", "tlsSecretRef", "name") + if ref != gatewaySecretName("remote") { + t.Fatalf("wrong credential reference: %s", ref) + } + var payload string + if err := store.DB.QueryRowContext(t.Context(), `SELECT payload FROM management_objects WHERE kind = 'clusters' AND name = 'remote'`).Scan(&payload); err != nil { + t.Fatal(err) + } + if strings.Contains(payload, "PRIVATE KEY") || strings.Contains(payload, "CERTIFICATE") || strings.Contains(payload, "clientKey") { + t.Fatal("registration contains credential material") + } + listed := doClusters(t, router, http.MethodGet, "/clusters/", nil) + if listed.Code != http.StatusOK || strings.Contains(listed.Body.String(), "gateway.example") || strings.Contains(listed.Body.String(), "PRIVATE KEY") { + t.Fatalf("gateway leaked in discovery: %d %s", listed.Code, listed.Body) + } +} + +func TestGatewayRejectsNonHTTPSOriginsAndInvalidPEMWithoutWritingSecrets(t *testing.T) { + valid := gatewayCertificateRequest(t) + for _, badURL := range []string{"http://gateway.example.invalid", "https://user:password@gateway.example.invalid", "https://gateway.example.invalid/path", "https://gateway.example.invalid?token=private", "https://gateway.example.invalid#fragment", "https://"} { + t.Run(badURL, func(t *testing.T) { + _, home, reg, _ := standaloneHandlerStore(t) + if _, err := home.Clusters().Create(t.Context(), newCluster("remote", map[string]any{}, nil), metav1.CreateOptions{}); err != nil { + t.Fatal(err) + } + in := valid + in.URL = badURL + rr := doClusters(t, standaloneClustersRouter(home, reg), http.MethodPut, "/clusters/remote/gateway", in) + if rr.Code != http.StatusBadRequest { + t.Fatalf("invalid URL accepted: %d %s", rr.Code, rr.Body) + } + if _, err := home.Secrets(standaloneTestNamespace).Get(t.Context(), gatewaySecretName("remote"), metav1.GetOptions{}); !apierrors.IsNotFound(err) { + t.Fatalf("invalid URL wrote credentials: %v", err) + } + }) + } + for _, field := range []string{"ca", "certificate", "key"} { + t.Run(field, func(t *testing.T) { + _, home, reg, _ := standaloneHandlerStore(t) + if _, err := home.Clusters().Create(t.Context(), newCluster("remote", map[string]any{}, nil), metav1.CreateOptions{}); err != nil { + t.Fatal(err) + } + in := valid + switch field { + case "ca": + in.CACert = "private-invalid-ca" + case "certificate": + in.ClientCert = "private-invalid-cert" + case "key": + in.ClientKey = "private-invalid-key" + } + rr := doClusters(t, standaloneClustersRouter(home, reg), http.MethodPut, "/clusters/remote/gateway", in) + if rr.Code != http.StatusBadRequest || strings.Contains(rr.Body.String(), "private-invalid") { + t.Fatalf("invalid PEM response: %d %s", rr.Code, rr.Body) + } + if _, err := home.Secrets(standaloneTestNamespace).Get(t.Context(), gatewaySecretName("remote"), metav1.GetOptions{}); !apierrors.IsNotFound(err) { + t.Fatalf("invalid PEM wrote credentials: %v", err) + } + }) + } +} + +func TestGatewayRemovalDeletesOnlyGeneratedSecretWithBothOwnershipLabels(t *testing.T) { + for _, tc := range []struct { + name, secretName string + labels map[string]string + deleted bool + }{ + {"managed", gatewaySecretName("remote"), map[string]string{clusterGatewayLabel: "true", ManagedByLabel: managedByValue}, true}, + {"missing-feature-label", gatewaySecretName("remote"), map[string]string{ManagedByLabel: managedByValue}, false}, + {"gitops-secret", gatewaySecretName("remote"), map[string]string{clusterGatewayLabel: "true"}, false}, + {"external-reference", "externally-owned-gateway", map[string]string{clusterGatewayLabel: "true", ManagedByLabel: managedByValue}, false}, + } { + t.Run(tc.name, func(t *testing.T) { + _, home, reg, _ := standaloneHandlerStore(t) + _, err := home.Clusters().Create(t.Context(), newCluster("remote", map[string]any{"agentGateway": map[string]any{"url": "https://gateway.example.invalid", "tlsSecretRef": map[string]any{"name": tc.secretName}}}, nil), metav1.CreateOptions{}) + if err != nil { + t.Fatal(err) + } + _, err = home.Secrets(standaloneTestNamespace).Create(t.Context(), &corev1.Secret{ObjectMeta: metav1.ObjectMeta{Name: tc.secretName, Labels: tc.labels}, StringData: map[string]string{"tls.key": "private-key-value"}}, metav1.CreateOptions{}) + if err != nil { + t.Fatal(err) + } + rr := doClusters(t, standaloneClustersRouter(home, reg), http.MethodDelete, "/clusters/remote/gateway", nil) + if rr.Code != http.StatusNoContent { + t.Fatalf("remove: %d %s", rr.Code, rr.Body) + } + _, err = home.Secrets(standaloneTestNamespace).Get(t.Context(), tc.secretName, metav1.GetOptions{}) + if tc.deleted && !apierrors.IsNotFound(err) { + t.Fatalf("managed secret survived: %v", err) + } + if !tc.deleted && err != nil { + t.Fatalf("unowned secret removed: %v", err) + } + registration, err := home.Clusters().Get(t.Context(), "remote", metav1.GetOptions{}) + if err != nil { + t.Fatal(err) + } + if _, found, err := unstructured.NestedMap(registration.Object, "spec", "agentGateway"); err != nil || found { + t.Fatalf("gateway reference survived: %v", err) + } + }) + } +} diff --git a/api/internal/handlers/events_scope_test.go b/api/internal/handlers/events_scope_test.go index 0dede011c..dea2dfc01 100644 --- a/api/internal/handlers/events_scope_test.go +++ b/api/internal/handlers/events_scope_test.go @@ -117,12 +117,19 @@ func TestEvents_StreamsOnlyReadableKinds(t *testing.T) { want: []string{"servers", "templates"}, }, { - // Namespaced kinds are gated by the target cluster; the - // cluster-scoped templates:read is granted by any cluster's - // cluster-wide binding (auth.User.Can). + // Templates, like namespaced workload kinds, require permission + // on the selected cluster. name: "bindings on another cluster only", perms: permMap{"other": {"*": eventsPermSet("servers:read", "backups:read", "schedules:read", "templates:read")}}, - want: []string{"templates"}, + want: []string{}, + }, + { + name: "local server reader cannot stream another cluster's templates", + perms: permMap{ + scope.DefaultCluster: {"*": eventsPermSet("servers:read")}, + "other": {"*": eventsPermSet("templates:read")}, + }, + want: []string{"servers"}, }, { name: "admin", diff --git a/api/internal/handlers/fleet.go b/api/internal/handlers/fleet.go index ee87779b9..ee53371b4 100644 --- a/api/internal/handlers/fleet.go +++ b/api/internal/handlers/fleet.go @@ -175,11 +175,11 @@ func (h fleetHandler) candidates(ctx context.Context, filter string) ([]fleetClu k, _ := h.reg.Get(id) byID[id] = fleetCluster{id: id, name: id, k: k} } - if _, ok := byID[h.reg.DefaultID()]; !ok { + if _, ok := byID[h.reg.DefaultID()]; !ok && h.reg.Default() != nil { byID[h.reg.DefaultID()] = fleetCluster{id: h.reg.DefaultID(), name: h.reg.DefaultID()} } issues := make([]fleetIssue, 0) - home := h.reg.Default() + home := h.reg.Management() applyRegistration := func(obj *unstructured.Unstructured) { id := obj.GetName() if obj.GetDeletionTimestamp() != nil { @@ -198,7 +198,7 @@ func (h fleetHandler) candidates(ctx context.Context, filter string) ([]fleetClu } // The local registration is intrinsic; its explicit filter does not // depend on discovering unrelated remote registrations. - if filter != h.reg.DefaultID() && (home == nil || home.Dynamic == nil) { + if filter != h.reg.DefaultID() && (home == nil || home.Clusters() == nil) { issues = append(issues, fleetIssue{Code: "unavailable", Message: "Cluster discovery is incomplete"}) } else if filter != h.reg.DefaultID() { ctx, cancel := context.WithTimeout(ctx, fleetScopeTimeout) @@ -207,12 +207,12 @@ func (h fleetHandler) candidates(ctx context.Context, filter string) ([]fleetClu var err error if filter != "" { var obj *unstructured.Unstructured - obj, err = home.Dynamic.Resource(kube.GVRCluster).Get(ctx, filter, metav1.GetOptions{}) + obj, err = home.Clusters().Get(ctx, filter, metav1.GetOptions{}) if err == nil { list = &unstructured.UnstructuredList{Items: []unstructured.Unstructured{*obj}} } } else { - list, err = home.Dynamic.Resource(kube.GVRCluster).List(ctx, metav1.ListOptions{Limit: fleetMaxItems}) + list, err = home.Clusters().List(ctx, metav1.ListOptions{Limit: fleetMaxItems}) } if err != nil && !apierrors.IsNotFound(err) { issues = append(issues, fleetIssue{Code: "unavailable", Message: "Cluster discovery is incomplete"}) @@ -660,10 +660,6 @@ func (h fleetHandler) placements(w http.ResponseWriter, req *http.Request) { return } out := fleetResult[fleetPlacement]{Items: make([]fleetPlacement, 0), Issues: make([]fleetIssue, 0)} - if !u.Can("templates:read", false, "", "") { - writeJSON(w, out) - return - } ctx, cancel := context.WithTimeout(req.Context(), fleetTimeout) defer cancel() clusters, issues := h.candidates(ctx, f.cluster) @@ -674,6 +670,9 @@ func (h fleetHandler) placements(w http.ResponseWriter, req *http.Request) { } allowed := make([]eligible, 0) for _, c := range clusters { + if !u.Can("templates:read", false, c.id, "") { + continue + } namespaces := make([]string, 0) for _, ns := range scope.AllowedNamespaces { if (f.namespace == "" || ns == f.namespace) && u.Can("servers:write", true, c.id, ns) { diff --git a/api/internal/handlers/fleet_test.go b/api/internal/handlers/fleet_test.go index 816395625..7b0b2ed45 100644 --- a/api/internal/handlers/fleet_test.go +++ b/api/internal/handlers/fleet_test.go @@ -327,9 +327,9 @@ func TestFleetPlacements_RequireCreateAndTemplateRead(t *testing.T) { home := fleetTestClient(newCluster("remote", nil, nil), newCluster("offline", nil, nil), template.DeepCopy()) reg := clusterTestRegistry(home) reg.Set("remote", fleetTestClient(template)) - u := inventoryUser("local", "*", "templates:read") - u.Perms["remote"] = map[string]map[string]struct{}{scope.DefaultNamespace: {"servers:write": {}}} - u.Perms["offline"] = map[string]map[string]struct{}{scope.DefaultNamespace: {"servers:write": {}}} + u := inventoryUser("remote", "*", "templates:read") + u.Perms["remote"][scope.DefaultNamespace] = map[string]struct{}{"servers:write": {}} + u.Perms["offline"] = map[string]map[string]struct{}{"*": {"templates:read": {}}, scope.DefaultNamespace: {"servers:write": {}}} got := decodeFleet[fleetPlacement](t, fleetRouter(reg), u, "/fleet/placements") if !got.Partial || len(got.Items) != 1 || got.Items[0].Cluster != "remote" || got.Items[0].Namespace != scope.DefaultNamespace || string(got.Items[0].Templates[0].GetUID()) != "remote-template" { t.Fatalf("placements=%+v", got) @@ -347,6 +347,12 @@ func TestFleetPlacements_RequireCreateAndTemplateRead(t *testing.T) { if len(noCreate.Items) != 0 || noCreate.Partial { t.Fatalf("template reader gained create placement: %+v", noCreate) } + wrongTarget := inventoryUser("local", "*", "templates:read") + wrongTarget.Perms["remote"] = map[string]map[string]struct{}{scope.DefaultNamespace: {"servers:write": {}}} + denied := decodeFleet[fleetPlacement](t, fleetRouter(reg), wrongTarget, "/fleet/placements") + if len(denied.Items) != 0 || denied.Partial { + t.Fatalf("local template grant authorized remote placement: %+v", denied) + } } func TestFleetExactServerAccess_IsTargetScoped(t *testing.T) { diff --git a/api/internal/handlers/installation.go b/api/internal/handlers/installation.go new file mode 100644 index 000000000..90cd0a863 --- /dev/null +++ b/api/internal/handlers/installation.go @@ -0,0 +1,16 @@ +package handlers + +import ( + "github.com/go-chi/chi/v5" + "net/http" +) + +// MountInstallation exposes capabilities only through the authenticated router. +func MountInstallation(r chi.Router, standalone bool) { + r.Get("/admin/installation", func(w http.ResponseWriter, _ *http.Request) { + writeJSON(w, struct { + Standalone bool `json:"standalone"` + LocalCluster bool `json:"localCluster"` + }{Standalone: standalone, LocalCluster: !standalone}) + }) +} diff --git a/api/internal/handlers/modules_cluster.go b/api/internal/handlers/modules_cluster.go new file mode 100644 index 000000000..b562ea915 --- /dev/null +++ b/api/internal/handlers/modules_cluster.go @@ -0,0 +1,59 @@ +package handlers + +import ( + "context" + "errors" + "net/http" + "strings" + + "github.com/GameplanePanel/gameplane/api/internal/auth" + "github.com/GameplanePanel/gameplane/api/internal/httperr" + "github.com/GameplanePanel/gameplane/api/internal/kube" + "github.com/go-chi/chi/v5" +) + +// MountModulesWithRegistry retains central module ownership in combined installs. +// Without a local cluster the caller must select the operator that owns modules. +func MountModulesWithRegistry(r chi.Router, reg *kube.Registry, namespace string) { + if reg.Default() != nil { + r.Group(func(local chi.Router) { + local.Use(func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { + if rejectRemoteCluster(w, req) { + return + } + next.ServeHTTP(w, req) + }) + }) + MountModules(local, reg.Default(), namespace) + }) + return + } + handle := func(w http.ResponseWriter, req *http.Request) { + id := strings.TrimSpace(req.URL.Query().Get("cluster")) + if id == "" || id == reg.DefaultID() || len(req.URL.Query()["cluster"]) != 1 { + httperr.WriteCode(w, req, http.StatusBadRequest, errors.New("select a registered cluster for modules")) + return + } + permission := "modules:read" + if req.Method != http.MethodGet { + permission = "modules:manage" + } + u := auth.UserFromContext(req.Context()) + if u == nil || !u.Can(permission, true, id, "") { + http.Error(w, "forbidden", http.StatusForbidden) + return + } + k, ok := resolveCluster(w, req, reg) + if !ok { + return + } + // A request owns its handler/client; never mutate shared selection. + selected := chi.NewRouter() + MountModules(selected, k, namespace) + ctx := context.WithValue(req.Context(), chi.RouteCtxKey, chi.NewRouteContext()) + selected.ServeHTTP(w, req.WithContext(ctx)) + } + r.HandleFunc("/modules", handle) + r.HandleFunc("/modules/*", handle) +} diff --git a/api/internal/handlers/modules_cluster_test.go b/api/internal/handlers/modules_cluster_test.go new file mode 100644 index 000000000..c14c585ed --- /dev/null +++ b/api/internal/handlers/modules_cluster_test.go @@ -0,0 +1,44 @@ +package handlers + +import ( + "net/http" + "strings" + "testing" + + "github.com/GameplanePanel/gameplane/api/internal/kube" + "github.com/GameplanePanel/gameplane/api/internal/rbac" + "github.com/go-chi/chi/v5" +) + +func TestStandaloneModulesUseExplicitTargetAndItsPermission(t *testing.T) { + reg := kube.NewRegistry("local") + reg.SetManagement(fakeKubeClientWithClusters()) + reg.Set("remote", fakeKubeClient(newModule("remote-only", nil))) + reg.Set("other", fakeKubeClient(newModule("other-only", nil))) + r := chi.NewRouter() + r.Use(rbac.Middleware(reg)) + MountModulesWithRegistry(r, reg, "gameplane-system") + user := inventoryUser("remote", "*", "modules:read") + rr := inventoryRequest(t, r, user, http.MethodGet, "/modules/?cluster=remote") + if rr.Code != http.StatusOK || !strings.Contains(rr.Body.String(), "remote-only") || strings.Contains(rr.Body.String(), "other-only") { + t.Fatalf("remote list: %d %s", rr.Code, rr.Body) + } + for _, target := range []string{"/modules/", "/modules/?cluster=other", "/modules/?cluster=remote&cluster=other"} { + rr := inventoryRequest(t, r, user, http.MethodGet, target) + if rr.Code < 400 { + t.Fatalf("unscoped/unauthorized request %s: %d %s", target, rr.Code, rr.Body) + } + } +} + +func TestCombinedModulesCannotUseRemoteGrantForLocalCatalog(t *testing.T) { + reg := kube.NewRegistry("local") + reg.Set("local", fakeKubeClient(newModule("local-only", nil))) + r := chi.NewRouter() + r.Use(rbac.Middleware(reg)) + MountModulesWithRegistry(r, reg, "gameplane-system") + rr := inventoryRequest(t, r, inventoryUser("remote", "*", "modules:read"), http.MethodGet, "/modules/?cluster=remote") + if rr.Code != http.StatusNotImplemented { + t.Fatalf("remote selector: %d %s", rr.Code, rr.Body) + } +} diff --git a/api/internal/handlers/remote_bindings_test.go b/api/internal/handlers/remote_bindings_test.go index 51550f431..134511179 100644 --- a/api/internal/handlers/remote_bindings_test.go +++ b/api/internal/handlers/remote_bindings_test.go @@ -136,7 +136,7 @@ func TestRemoteBindings_GrantAndRevokePreservePrimaryRole(t *testing.T) { func TestRemoteBindings_RejectGlobalRolesAndNonRemoteTargets(t *testing.T) { r, store, sessions, id := remoteBindingFixture(t) createRemoteRole(t, r, "cluster-reader", []string{"cluster:read"}) - for _, permission := range []string{"users:manage", "roles:manage", "config:manage", "cluster:manage", "modules:read"} { + for _, permission := range []string{"users:manage", "roles:manage", "config:manage", "cluster:manage"} { resource, _, _ := strings.Cut(permission, ":") createRemoteRole(t, r, "global-"+resource, []string{permission}) } @@ -148,7 +148,6 @@ func TestRemoteBindings_RejectGlobalRolesAndNonRemoteTargets(t *testing.T) { {RoleName: "global-roles", Namespace: "*", Cluster: "remote"}, {RoleName: "global-config", Namespace: "*", Cluster: "remote"}, {RoleName: "global-cluster", Namespace: "*", Cluster: "remote"}, - {RoleName: "global-modules", Namespace: "*", Cluster: "remote"}, {RoleName: "cluster-reader", Namespace: "*", Cluster: "local"}, {RoleName: "cluster-reader", Namespace: "*", Cluster: ""}, {RoleName: "cluster-reader", Namespace: "*", Cluster: "*"}, @@ -172,6 +171,65 @@ func TestRemoteBindings_RejectGlobalRolesAndNonRemoteTargets(t *testing.T) { } } +func TestRemoteBindings_ModuleAndTemplateRoleGrantsStayOnSelectedCluster(t *testing.T) { + r, _, sessions, id := remoteBindingFixture(t) + permissions := []string{"modules:read", "modules:manage", "templates:read", "templates:write"} + before := loadedBindingUser(t, sessions, id) + createRemoteRole(t, r, "remote-catalog", permissions) + path := "/users/" + strconv.FormatInt(id, 10) + "/bindings" + granted := remoteBindingRequest(t, r, http.MethodPost, path, addBindingReq{RoleName: "remote-catalog", Namespace: "*", Cluster: "remote"}) + if granted.Code != http.StatusCreated { + t.Fatalf("remote catalog grant: %d %s", granted.Code, granted.Body) + } + u := loadedBindingUser(t, sessions, id) + for _, permission := range permissions { + if !u.Can(permission, false, "remote", "") { + t.Errorf("remote role did not grant %s", permission) + } + if u.Can(permission, false, "other", "") { + t.Errorf("remote role leaked %s to other cluster", permission) + } + if u.Can(permission, false, "local", "") != before.Can(permission, false, "local", "") { + t.Errorf("remote role changed primary local grant for %s", permission) + } + } + for _, permission := range []string{"users:manage", "roles:manage", "config:manage", "cluster:manage"} { + if u.Can(permission, false, "", "") { + t.Errorf("remote catalog role conferred central %s", permission) + } + } + // A bound role can gain another target-scoped catalog permission while + // central administration remains forbidden by the role-edit guards. + updated := remoteBindingRequest(t, r, http.MethodPatch, "/roles/remote-catalog", map[string]any{"permissions": []string{"modules:read", "templates:read"}}) + if updated.Code != http.StatusOK { + t.Fatalf("catalog role read-only edit: %d %s", updated.Code, updated.Body) + } + u = loadedBindingUser(t, sessions, id) + if u.Can("modules:manage", false, "remote", "") || u.Can("templates:write", false, "remote", "") || !u.Can("templates:read", false, "remote", "") { + t.Fatal("catalog role edit failed to revoke writes while preserving reads") + } + updated = remoteBindingRequest(t, r, http.MethodPatch, "/roles/remote-catalog", map[string]any{"permissions": permissions}) + if updated.Code != http.StatusOK { + t.Fatalf("catalog role target-scoped edit: %d %s", updated.Code, updated.Body) + } + for _, permission := range []string{"users:manage", "roles:manage", "config:manage", "cluster:manage"} { + denied := remoteBindingRequest(t, r, http.MethodPatch, "/roles/remote-catalog", map[string]any{"permissions": []string{"modules:read", permission}}) + if denied.Code != http.StatusBadRequest { + t.Errorf("catalog role widened to %s: %d %s", permission, denied.Code, denied.Body) + } + } + revoked := remoteBindingRequest(t, r, http.MethodDelete, path+"/remote-catalog/*?cluster=remote", nil) + if revoked.Code != http.StatusNoContent { + t.Fatalf("catalog role revoke: %d %s", revoked.Code, revoked.Body) + } + u = loadedBindingUser(t, sessions, id) + for _, permission := range permissions { + if u.Can(permission, false, "remote", "") { + t.Errorf("revoked remote permission survived: %s", permission) + } + } +} + func TestRemoteBindings_RoleEditsCannotWidenButCanRevoke(t *testing.T) { r, _, sessions, id := remoteBindingFixture(t) createRemoteRole(t, r, "cluster-reader", []string{"cluster:read", "servers:read"}) @@ -180,7 +238,7 @@ func TestRemoteBindings_RoleEditsCannotWidenButCanRevoke(t *testing.T) { if rr.Code != http.StatusCreated { t.Fatalf("grant status=%d body=%s", rr.Code, rr.Body) } - for _, permission := range []string{"*", "users:manage", "roles:manage", "config:manage", "cluster:manage", "modules:read"} { + for _, permission := range []string{"*", "users:manage", "roles:manage", "config:manage", "cluster:manage"} { rr = remoteBindingRequest(t, r, http.MethodPatch, "/roles/cluster-reader", map[string]any{"permissions": []string{"cluster:read", permission}}) if rr.Code != http.StatusBadRequest { t.Fatalf("widen to %s: status=%d body=%s", permission, rr.Code, rr.Body) diff --git a/api/internal/handlers/secrets_managed.go b/api/internal/handlers/secrets_managed.go index 9b03e93d0..0142240fb 100644 --- a/api/internal/handlers/secrets_managed.go +++ b/api/internal/handlers/secrets_managed.go @@ -15,13 +15,11 @@ package handlers import ( "context" - "encoding/json" "errors" corev1 "k8s.io/api/core/v1" apierrors "k8s.io/apimachinery/pkg/api/errors" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "k8s.io/apimachinery/pkg/types" "github.com/GameplanePanel/gameplane/api/internal/kube" ) @@ -50,30 +48,29 @@ func upsertLabelledSecret(ctx context.Context, k *kube.Client, ns, name, feature Type: corev1.SecretTypeOpaque, StringData: data, } - _, err := k.Typed.CoreV1().Secrets(ns).Create(ctx, desired, metav1.CreateOptions{}) + _, err := k.Secrets(ns).Create(ctx, desired, metav1.CreateOptions{}) if err == nil { return nil } if !apierrors.IsAlreadyExists(err) { return err } - existing, err := k.Typed.CoreV1().Secrets(ns).Get(ctx, name, metav1.GetOptions{}) + existing, err := k.Secrets(ns).Get(ctx, name, metav1.GetOptions{}) if err != nil { return err } if existing.Labels[featureLabel] != "true" { return errNotManagedSecret } - patch, err := json.Marshal(map[string]any{ - // Re-assert managed-by so a pre-created labelled Secret an admin - // re-saves through the UI becomes deletable through the UI too. - "metadata": map[string]any{"labels": labels}, - "stringData": data, - }) - if err != nil { - return err + // Keep unrelated keys/labels and bind the update to the version just read. + if existing.Labels == nil { + existing.Labels = map[string]string{} + } + for key, value := range labels { + existing.Labels[key] = value } - _, err = k.Typed.CoreV1().Secrets(ns).Patch(ctx, name, types.MergePatchType, patch, metav1.PatchOptions{}) + existing.StringData = data + _, err = k.Secrets(ns).Update(ctx, existing, metav1.UpdateOptions{}) return err } @@ -81,12 +78,12 @@ func upsertLabelledSecret(ctx context.Context, k *kube.Client, ns, name, feature // feature label and managed-by=gameplane-api. Anything else reads as // not-found so the endpoint doesn't leak which Secrets exist. func deleteManagedSecret(ctx context.Context, k *kube.Client, ns, name, featureLabel string) error { - existing, err := k.Typed.CoreV1().Secrets(ns).Get(ctx, name, metav1.GetOptions{}) + existing, err := k.Secrets(ns).Get(ctx, name, metav1.GetOptions{}) if err != nil { return err } if existing.Labels[featureLabel] != "true" || existing.Labels[ManagedByLabel] != managedByValue { return apierrors.NewNotFound(corev1.Resource("secrets"), name) } - return k.Typed.CoreV1().Secrets(ns).Delete(ctx, name, metav1.DeleteOptions{}) + return k.Secrets(ns).Delete(ctx, name, metav1.DeleteOptions{Preconditions: objectDeletePreconditions(existing)}) } diff --git a/api/internal/handlers/standalone_storage_test.go b/api/internal/handlers/standalone_storage_test.go new file mode 100644 index 000000000..7359d1151 --- /dev/null +++ b/api/internal/handlers/standalone_storage_test.go @@ -0,0 +1,233 @@ +package handlers + +import ( + "bytes" + "context" + "encoding/base64" + "encoding/json" + "net/http" + "net/http/httptest" + "path/filepath" + "strings" + "testing" + + "github.com/go-chi/chi/v5" + corev1 "k8s.io/api/core/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + "github.com/GameplanePanel/gameplane/api/internal/auth" + "github.com/GameplanePanel/gameplane/api/internal/controlplane" + "github.com/GameplanePanel/gameplane/api/internal/db" + "github.com/GameplanePanel/gameplane/api/internal/kube" + "github.com/GameplanePanel/gameplane/api/internal/rbac" +) + +const standaloneTestNamespace = "gameplane-system" +const standaloneTestKubeconfig = `apiVersion: v1 +kind: Config +clusters: +- name: remote + cluster: + server: https://remote.example.invalid:6443 +contexts: +- name: remote + context: + cluster: remote + user: panel +current-context: remote +users: +- name: panel + user: + token: secret-standalone-registration-token +` + +func standaloneHandlerStore(t *testing.T) (*db.Store, *kube.Client, *kube.Registry, string) { + t.Helper() + dir := t.TempDir() + store, err := db.Open(t.Context(), "sqlite", filepath.Join(dir, "panel.db")) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = store.Close() }) + if err := store.Migrate(t.Context()); err != nil { + t.Fatal(err) + } + keyPath := filepath.Join(dir, "credentials.key") + home, err := controlplane.New(t.Context(), store, keyPath) + if err != nil { + t.Fatal(err) + } + reg := kube.NewRegistry("local") + reg.SetManagement(home) + return store, home, reg, keyPath +} + +func standaloneClustersRouter(home *kube.Client, reg *kube.Registry) http.Handler { + r := chi.NewRouter() + r.Use(rbac.Middleware(reg)) + MountClusters(r, reg, home, standaloneTestNamespace) + return r +} + +func assertManagementCiphertext(t *testing.T, store *db.Store, name string, sensitive ...string) { + t.Helper() + var payload string + if err := store.DB.QueryRowContext(t.Context(), `SELECT payload FROM management_objects WHERE kind = 'secrets' AND namespace = ? AND name = ?`, standaloneTestNamespace, name).Scan(&payload); err != nil { + t.Fatal(err) + } + if json.Valid([]byte(payload)) { + t.Fatal("credential stored as plaintext JSON") + } + for _, value := range sensitive { + if strings.Contains(payload, value) || strings.Contains(payload, base64.StdEncoding.EncodeToString([]byte(value))) { + t.Fatal("credential content stored without encryption") + } + } +} + +func TestStandaloneClusterHTTPRegistrationPersistsEncryptedAndDeletesCleanly(t *testing.T) { + store, home, reg, keyPath := standaloneHandlerStore(t) + router := standaloneClustersRouter(home, reg) + empty := doClusters(t, router, http.MethodGet, "/clusters/", nil) + if empty.Code != http.StatusOK || strings.TrimSpace(empty.Body.String()) != `{"items":[]}` { + t.Fatalf("new standalone discovery: %d %s", empty.Code, empty.Body) + } + created := doClusters(t, router, http.MethodPost, "/clusters/", clusterCreateReq{Name: "remote", DisplayName: "Remote cluster", Kubeconfig: standaloneTestKubeconfig}) + if created.Code != http.StatusCreated { + t.Fatalf("create: %d %s", created.Code, created.Body) + } + if strings.Contains(created.Body.String(), "secret-standalone") || strings.Contains(created.Body.String(), "kubeconfig") { + t.Fatal("registration response exposed credentials") + } + assertManagementCiphertext(t, store, clusterKubeconfigSecretName("remote"), standaloneTestKubeconfig, "secret-standalone-registration-token") + first, err := home.Clusters().Get(t.Context(), "remote", metav1.GetOptions{}) + if err != nil { + t.Fatal(err) + } + home, err = controlplane.New(t.Context(), store, keyPath) + if err != nil { + t.Fatal(err) + } + reg = kube.NewRegistry("local") + reg.SetManagement(home) + router = standaloneClustersRouter(home, reg) + stored, err := home.Secrets(standaloneTestNamespace).Get(t.Context(), clusterKubeconfigSecretName("remote"), metav1.GetOptions{}) + if err != nil || string(stored.Data["kubeconfig"]) != standaloneTestKubeconfig { + t.Fatalf("reopened credential: %v", err) + } + registration, err := home.Clusters().Get(t.Context(), "remote", metav1.GetOptions{}) + if err != nil || registration.GetUID() != first.GetUID() { + t.Fatalf("reopened registration identity: %v", err) + } + listed := doClusters(t, router, http.MethodGet, "/clusters/", nil) + if listed.Code != http.StatusOK { + t.Fatalf("list: %d %s", listed.Code, listed.Body) + } + var out clustersListResp + if err := json.Unmarshal(listed.Body.Bytes(), &out); err != nil { + t.Fatal(err) + } + if len(out.Items) != 1 || out.Items[0].Name != "remote" || out.Items[0].DisplayName != "Remote cluster" { + t.Fatalf("unexpected discovery: %+v", out.Items) + } + for _, forbidden := range []string{"secret-standalone", "kubeconfig", "remote.example.invalid", `"name":"local"`} { + if strings.Contains(listed.Body.String(), forbidden) { + t.Fatalf("discovery leaked %q", forbidden) + } + } + if reg.Default() != nil || home.Typed != nil || home.Dynamic != nil { + t.Fatal("management backend became a workload cluster") + } + deleted := doClusters(t, router, http.MethodDelete, "/clusters/remote", nil) + if deleted.Code != http.StatusNoContent { + t.Fatalf("delete: %d %s", deleted.Code, deleted.Body) + } + if _, err := home.Clusters().Get(t.Context(), "remote", metav1.GetOptions{}); !apierrors.IsNotFound(err) { + t.Fatalf("registration survived delete: %v", err) + } + if _, err := home.Secrets(standaloneTestNamespace).Get(t.Context(), clusterKubeconfigSecretName("remote"), metav1.GetOptions{}); !apierrors.IsNotFound(err) { + t.Fatalf("kubeconfig survived delete: %v", err) + } +} + +func TestStandaloneRegistrationRejectsRemoteOnlyWorkloadPermissions(t *testing.T) { + for _, permission := range []string{"servers:write", "cluster:read"} { + t.Run(permission, func(t *testing.T) { + _, home, reg, _ := standaloneHandlerStore(t) + router := standaloneClustersRouter(home, reg) + created := doClusters(t, router, http.MethodPost, "/clusters/", clusterCreateReq{Name: "remote", Kubeconfig: standaloneTestKubeconfig}) + if created.Code != http.StatusCreated { + t.Fatalf("fixture registration: %d %s", created.Code, created.Body) + } + user := &auth.User{Perms: map[string]map[string]map[string]struct{}{"remote": {"*": {permission: {}}}}} + for _, request := range []struct{ method, path string }{ + {http.MethodPost, "/clusters/?cluster=remote"}, + {http.MethodDelete, "/clusters/remote?cluster=remote"}, + {http.MethodPut, "/clusters/remote/gateway?cluster=remote"}, + {http.MethodDelete, "/clusters/remote/gateway?cluster=remote"}, + } { + // Intentionally invalid mutation body: authorization must run first. + req := httptest.NewRequestWithContext(auth.WithUser(t.Context(), user), request.method, request.path, bytes.NewBufferString(`{}`)) + rr := httptest.NewRecorder() + router.ServeHTTP(rr, req) + if rr.Code != http.StatusForbidden { + t.Errorf("remote-only %s allowed %s %s: %d %s", permission, request.method, request.path, rr.Code, rr.Body) + } + } + if _, err := home.Clusters().Get(t.Context(), "remote", metav1.GetOptions{}); err != nil { + t.Fatalf("denied mutation changed registration: %v", err) + } + }) + } +} + +type replaceCredentialBeforeDelete struct { + kube.SecretStore + replace func(context.Context, string) error +} + +func (s *replaceCredentialBeforeDelete) Delete(ctx context.Context, name string, opts metav1.DeleteOptions) error { + if err := s.replace(ctx, name); err != nil { + return err + } + return s.SecretStore.Delete(ctx, name, opts) +} + +func TestStandaloneCredentialCleanupDoesNotDeleteConcurrentReplacement(t *testing.T) { + for _, feature := range []string{clusterGatewayLabel, kube.ClusterKubeconfigLabel} { + t.Run(feature, func(t *testing.T) { + _, home, _, _ := standaloneHandlerStore(t) + name := gatewaySecretName("remote") + if feature == kube.ClusterKubeconfigLabel { + name = clusterKubeconfigSecretName("remote") + } + underlying := home.Secrets(standaloneTestNamespace) + _, err := underlying.Create(t.Context(), &corev1.Secret{ObjectMeta: metav1.ObjectMeta{Name: name, Labels: map[string]string{feature: "true", ManagedByLabel: managedByValue}}}, metav1.CreateOptions{}) + if err != nil { + t.Fatal(err) + } + home.SecretStore = func(string) kube.SecretStore { + return &replaceCredentialBeforeDelete{SecretStore: underlying, replace: func(ctx context.Context, name string) error { + if err := underlying.Delete(ctx, name, metav1.DeleteOptions{}); err != nil { + return err + } + _, err := underlying.Create(ctx, &corev1.Secret{ObjectMeta: metav1.ObjectMeta{Name: name}, StringData: map[string]string{"owner": "external-replacement"}}, metav1.CreateOptions{}) + return err + }} + } + if feature == kube.ClusterKubeconfigLabel { + err = deleteClusterKubeconfigSecret(t.Context(), home, standaloneTestNamespace, "remote", name) + } else { + err = deleteManagedSecret(t.Context(), home, standaloneTestNamespace, name, feature) + } + if !apierrors.IsConflict(err) { + t.Fatalf("cleanup did not reject replaced credential: %v", err) + } + replacement, err := underlying.Get(t.Context(), name, metav1.GetOptions{}) + if err != nil || string(replacement.Data["owner"]) != "external-replacement" { + t.Fatalf("replacement was deleted: %v", err) + } + }) + } +} diff --git a/api/internal/handlers/standalone_test.go b/api/internal/handlers/standalone_test.go new file mode 100644 index 000000000..e0b19b77b --- /dev/null +++ b/api/internal/handlers/standalone_test.go @@ -0,0 +1,47 @@ +package handlers + +import ( + "encoding/json" + "net/http" + "testing" + + "github.com/GameplanePanel/gameplane/api/internal/kube" +) + +func TestStandaloneDiscoveryDoesNotInventLocalCluster(t *testing.T) { + home := fakeKubeClientWithClusters() + reg := kube.NewRegistry("local") + reg.SetManagement(home) + rr := doClusters(t, mountClustersRouter(home, reg), http.MethodGet, "/clusters/", nil) + if rr.Code != http.StatusOK { + t.Fatalf("status=%d body=%s", rr.Code, rr.Body) + } + var out clustersListResp + if err := json.Unmarshal(rr.Body.Bytes(), &out); err != nil { + t.Fatal(err) + } + if out.Items == nil || len(out.Items) != 0 { + t.Fatalf("expected empty array, got %+v", out.Items) + } +} + +func TestStandaloneFleetDiscoversOnlyManagementRegistrations(t *testing.T) { + home := fakeKubeClientWithClusters(newCluster("offline", nil, nil)) + reg := kube.NewRegistry("local") + reg.SetManagement(home) + h := fleetHandler{reg: reg} + items, issues := h.candidates(t.Context(), "") + if len(issues) != 0 || len(items) != 1 || items[0].id != "offline" { + t.Fatalf("items=%+v issues=%+v", items, issues) + } +} + +func TestStandaloneFleetEmptyIsComplete(t *testing.T) { + reg := kube.NewRegistry("local") + reg.SetManagement(fakeKubeClientWithClusters()) + h := fleetHandler{reg: reg} + items, issues := h.candidates(t.Context(), "") + if len(items) != 0 || len(issues) != 0 { + t.Fatalf("items=%+v issues=%+v", items, issues) + } +} diff --git a/api/internal/handlers/systemlogs.go b/api/internal/handlers/systemlogs.go index 46149e207..1f3cf3f72 100644 --- a/api/internal/handlers/systemlogs.go +++ b/api/internal/handlers/systemlogs.go @@ -22,6 +22,10 @@ import ( // MountSystemLogs registers the system logs API handlers. func MountSystemLogs(r chi.Router, k *kube.Client, namespace string) { r.Get("/admin/system-logs/{component}", func(w http.ResponseWriter, req *http.Request) { + if k == nil || k.Typed == nil { + httperr.WriteCode(w, req, http.StatusNotImplemented, errors.New("standalone panel logs are available from the container runtime")) + return + } component := chi.URLParam(req, "component") // Component must be exactly "api" or "operator" diff --git a/api/internal/kube/client.go b/api/internal/kube/client.go index c8dce7824..f8be281a0 100644 --- a/api/internal/kube/client.go +++ b/api/internal/kube/client.go @@ -19,6 +19,11 @@ type Client struct { Dynamic dynamic.Interface Typed kubernetes.Interface Config *rest.Config + // Management stores are set only for a standalone panel. Such a client + // deliberately has no Kubernetes transport and is never a workload target. + SecretStore func(string) SecretStore + ClusterStore ClusterStore + kubeconfigDigest [32]byte } // GetServer fetches a GameServer by namespace and name. Returns nil if diff --git a/api/internal/kube/loader.go b/api/internal/kube/loader.go index e9af95503..a6ce65426 100644 --- a/api/internal/kube/loader.go +++ b/api/internal/kube/loader.go @@ -2,6 +2,7 @@ package kube import ( "context" + "crypto/sha256" "errors" "fmt" @@ -32,7 +33,7 @@ func ClientFromSecret(ctx context.Context, home *Client, ns, name, key string) ( if key == "" { key = "kubeconfig" } - secret, err := home.Typed.CoreV1().Secrets(ns).Get(ctx, name, metav1.GetOptions{}) + secret, err := home.Secrets(ns).Get(ctx, name, metav1.GetOptions{}) if err != nil { return nil, fmt.Errorf("get secret: %w", err) } @@ -52,6 +53,7 @@ func ClientFromSecret(ctx context.Context, home *Client, ns, name, key string) ( if err != nil { return nil, fmt.Errorf("create client from config: %w", err) } + c.kubeconfigDigest = sha256.Sum256(data) return c, nil } diff --git a/api/internal/kube/management.go b/api/internal/kube/management.go new file mode 100644 index 000000000..7b724c377 --- /dev/null +++ b/api/internal/kube/management.go @@ -0,0 +1,60 @@ +package kube + +import ( + "context" + + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" +) + +// SecretStore is the management credential operations shared by Kubernetes +// and the standalone encrypted database backend. +type SecretStore interface { + Get(context.Context, string, metav1.GetOptions) (*corev1.Secret, error) + List(context.Context, metav1.ListOptions) (*corev1.SecretList, error) + Create(context.Context, *corev1.Secret, metav1.CreateOptions) (*corev1.Secret, error) + Update(context.Context, *corev1.Secret, metav1.UpdateOptions) (*corev1.Secret, error) + Delete(context.Context, string, metav1.DeleteOptions) error +} + +// ClusterStore persists remote cluster registrations independently of workload +// clients. The signatures also accept the Kubernetes dynamic resource client. +type ClusterStore interface { + Get(context.Context, string, metav1.GetOptions, ...string) (*unstructured.Unstructured, error) + List(context.Context, metav1.ListOptions) (*unstructured.UnstructuredList, error) + Create(context.Context, *unstructured.Unstructured, metav1.CreateOptions, ...string) (*unstructured.Unstructured, error) + Update(context.Context, *unstructured.Unstructured, metav1.UpdateOptions, ...string) (*unstructured.Unstructured, error) + Delete(context.Context, string, metav1.DeleteOptions, ...string) error +} + +// Secrets selects management credentials in a namespace. +func (c *Client) Secrets(ns string) SecretStore { + if c == nil { + return nil + } + if c.SecretStore != nil { + return c.SecretStore(ns) + } + if c.Typed == nil { + return nil + } + return c.Typed.CoreV1().Secrets(ns) +} + +// Clusters selects the registration store. +func (c *Client) Clusters() ClusterStore { + if c == nil { + return nil + } + if c.ClusterStore != nil { + return c.ClusterStore + } + if c.Dynamic == nil { + return nil + } + return c.Dynamic.Resource(GVRCluster) +} + +// IsStandalone reports whether management is independent of Kubernetes. +func (c *Client) IsStandalone() bool { return c != nil && c.ClusterStore != nil } diff --git a/api/internal/kube/registry.go b/api/internal/kube/registry.go index 04ac6721c..42582adb4 100644 --- a/api/internal/kube/registry.go +++ b/api/internal/kube/registry.go @@ -16,10 +16,30 @@ import ( // is not usable; construct with NewRegistry. All methods are safe for // concurrent use. type Registry struct { - mu sync.RWMutex - clients map[string]*Client - uids map[string]types.UID - defaultID string + mu sync.RWMutex + clients map[string]*Client + uids map[string]types.UID + defaultID string + management *Client +} + +// SetManagement sets the panel's credential and registration backend. It does +// not register the panel host as a workload cluster. +func (r *Registry) SetManagement(c *Client) { + r.mu.Lock() + defer r.mu.Unlock() + r.management = c +} + +// Management returns the explicit backend, falling back to the default +// Kubernetes client for existing installations and callers. +func (r *Registry) Management() *Client { + r.mu.RLock() + defer r.mu.RUnlock() + if r.management != nil { + return r.management + } + return r.clients[r.defaultID] } // NewRegistry returns an empty Registry whose default cluster is defaultID. diff --git a/api/internal/kube/watch.go b/api/internal/kube/watch.go index 96408bd81..ef520ffc7 100644 --- a/api/internal/kube/watch.go +++ b/api/internal/kube/watch.go @@ -23,6 +23,10 @@ const resyncPeriod = 10 * time.Minute // remote kubeconfigs are logged but do not crash — the registry continues // to serve what it has. func WatchClusters(ctx context.Context, home *Client, reg *Registry, ns string) { + if home.IsStandalone() { + watchStandaloneClusters(ctx, home, reg, ns) + return + } factory := dynamicinformer.NewDynamicSharedInformerFactory(home.Dynamic, resyncPeriod) if _, err := factory.ForResource(GVRCluster).Informer().AddEventHandler(cache.ResourceEventHandlerFuncs{ @@ -105,7 +109,7 @@ func removeDeletedCluster(reg *Registry, obj any) { // loads the secret, creates a client, and registers it in the registry. // If the Cluster is being deleted, it is removed from the registry instead. func loadCluster(ctx context.Context, home *Client, reg *Registry, ns, name string) error { - u, err := home.Dynamic.Resource(GVRCluster).Get(ctx, name, metav1.GetOptions{}) + u, err := home.Clusters().Get(ctx, name, metav1.GetOptions{}) if err != nil { return fmt.Errorf("get cluster CRD: %w", err) } diff --git a/api/internal/kube/watch_standalone.go b/api/internal/kube/watch_standalone.go new file mode 100644 index 000000000..e0ddfb367 --- /dev/null +++ b/api/internal/kube/watch_standalone.go @@ -0,0 +1,134 @@ +package kube + +import ( + "context" + "encoding/json" + "log/slog" + "sync" + "time" + + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/version" +) + +const standalonePollInterval = 30 * time.Second + +func watchStandaloneClusters(ctx context.Context, home *Client, reg *Registry, ns string) { + ticker := time.NewTicker(standalonePollInterval) + defer ticker.Stop() + for { + syncStandaloneClusters(ctx, home, reg, ns) + select { + case <-ctx.Done(): + return + case <-ticker.C: + } + } +} + +// syncStandaloneClusters uses a fixed-size worker pool with cancellable remote +// probes. No network operation holds a database transaction or registry lock. +func syncStandaloneClusters(ctx context.Context, home *Client, reg *Registry, ns string) { + list, err := home.Clusters().List(ctx, metav1.ListOptions{}) + if err != nil { + if ctx.Err() == nil { + slog.Warn("standalone cluster poll: registration store unavailable") + } + return + } + jobs := make(chan *unstructured.Unstructured) + var workers sync.WaitGroup + for range 4 { + workers.Go(func() { + for registration := range jobs { + probeStandaloneCluster(ctx, home, reg, ns, registration) + } + }) + } + for i := range list.Items { + select { + case <-ctx.Done(): + close(jobs) + workers.Wait() + return + case jobs <- &list.Items[i]: + } + } + close(jobs) + workers.Wait() + // Recheck under the same lock used to publish loaded clients. A registration + // added after the list must not be evicted because it missed that snapshot. + for _, name := range reg.IDs() { + reg.mu.Lock() + _, err := home.Clusters().Get(ctx, name, metav1.GetOptions{}) + if apierrors.IsNotFound(err) { + delete(reg.clients, name) + delete(reg.uids, name) + } + reg.mu.Unlock() + } +} + +func probeStandaloneCluster(ctx context.Context, home *Client, reg *Registry, ns string, registration *unstructured.Unstructured) { + probeCtx, cancel := context.WithTimeout(ctx, 10*time.Second) + defer cancel() + name := registration.GetName() + secretName, _, _ := unstructured.NestedString(registration.Object, "spec", "kubeconfigSecret", "name") + key, _, _ := unstructured.NestedString(registration.Object, "spec", "kubeconfigSecret", "key") + phase, message, serverVersion := "Unhealthy", "Cluster credentials could not be loaded", "" + client, err := ClientFromSecret(probeCtx, home, ns, secretName, key) + if err == nil { + // Keep a stable client identity while credentials are unchanged, so + // consumers can retain their informers and transport connections. + reg.mu.RLock() + if existing := reg.clients[name]; existing != nil && reg.uids[name] == registration.GetUID() && existing.kubeconfigDigest == client.kubeconfigDigest { + client = existing + } + reg.mu.RUnlock() + message = "Cluster connection is unavailable" + var data []byte + data, err = client.Typed.Discovery().RESTClient().Get().AbsPath("/version").Do(probeCtx).Raw() + if err == nil { + var info version.Info + if json.Unmarshal(data, &info) == nil && info.GitVersion != "" { + phase, message, serverVersion = "Healthy", "", info.GitVersion + } + } + } + if ctx.Err() != nil { + return + } + // Never persist transport/parser error strings: they may contain credentials + // or host details. The public status is deliberately a small safe projection. + registration = registration.DeepCopy() + registration.Object["status"] = map[string]any{ + "phase": phase, "message": message, "serverVersion": serverVersion, + "lastCheckTime": time.Now().UTC().Format(time.RFC3339), + } + // A delete handler removes the persisted registration before Registry.Remove. + // Holding this lock across the final read/CAS and publication prevents a slow + // load from resurrecting a deleted or replaced registration in the registry. + reg.mu.Lock() + defer reg.mu.Unlock() + latest, err := home.Clusters().Get(ctx, name, metav1.GetOptions{}) + if err != nil || latest.GetUID() != registration.GetUID() || latest.GetResourceVersion() != registration.GetResourceVersion() { + return + } + if latest.GetDeletionTimestamp() != nil { + delete(reg.clients, name) + delete(reg.uids, name) + return + } + if _, err := home.Clusters().Update(ctx, registration, metav1.UpdateOptions{}); err != nil { + return + } + if client == nil { + delete(reg.clients, name) + delete(reg.uids, name) + return + } + reg.clients[name] = client + reg.uids[name] = registration.GetUID() +} diff --git a/api/internal/kube/watch_standalone_test.go b/api/internal/kube/watch_standalone_test.go new file mode 100644 index 000000000..518d9896c --- /dev/null +++ b/api/internal/kube/watch_standalone_test.go @@ -0,0 +1,128 @@ +package kube + +import ( + "encoding/base64" + "encoding/pem" + "fmt" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" + dynamicfake "k8s.io/client-go/dynamic/fake" + kubefake "k8s.io/client-go/kubernetes/fake" +) + +func standaloneWatchFixture(t *testing.T, handler http.Handler) (*Client, *Registry, *unstructured.Unstructured) { + t.Helper() + server := httptest.NewTLSServer(handler) + t.Cleanup(server.Close) + ca := base64.StdEncoding.EncodeToString(pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: server.Certificate().Raw})) + config := fmt.Sprintf(`{"apiVersion":"v1","kind":"Config","clusters":[{"name":"remote","cluster":{"server":%q,"certificate-authority-data":%q}}],"contexts":[{"name":"remote","context":{"cluster":"remote","user":"panel"}}],"current-context":"remote","users":[{"name":"panel","user":{"token":"private-token"}}]}`, server.URL, ca) + secret := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{Name: "cluster-remote-kubeconfig", Namespace: "panel", Labels: map[string]string{ClusterKubeconfigLabel: "true"}}, + Data: map[string][]byte{"kubeconfig": []byte(config)}, + } + typed := kubefake.NewClientset(secret) + registration := newTestCluster("remote") + registration.SetUID("remote-identity") + registration.SetResourceVersion("1") + dynamic := dynamicfake.NewSimpleDynamicClientWithCustomListKinds(runtime.NewScheme(), map[schema.GroupVersionResource]string{GVRCluster: "ClusterList"}, registration) + home := &Client{ + SecretStore: func(ns string) SecretStore { return typed.CoreV1().Secrets(ns) }, + ClusterStore: dynamic.Resource(GVRCluster), + } + reg := NewRegistry("") + reg.SetManagement(home) + return home, reg, registration +} + +func TestStandaloneProbeDoesNotResurrectDeletedRegistration(t *testing.T) { + started, release := make(chan struct{}), make(chan struct{}) + home, reg, registration := standaloneWatchFixture(t, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + close(started) + <-release + _, _ = w.Write([]byte(`{"gitVersion":"v1.35.0"}`)) + })) + done := make(chan struct{}) + go func() { defer close(done); probeStandaloneCluster(t.Context(), home, reg, "panel", registration) }() + select { + case <-started: + case <-time.After(3 * time.Second): + close(release) + t.Fatal("probe did not start") + } + if err := home.Clusters().Delete(t.Context(), "remote", metav1.DeleteOptions{}); err != nil { + close(release) + t.Fatal(err) + } + reg.Remove("remote") + close(release) + select { + case <-done: + case <-time.After(3 * time.Second): + t.Fatal("probe did not finish") + } + if _, ok := reg.Get("remote"); ok { + t.Fatal("stale probe resurrected deleted registration") + } +} + +func TestStandalonePollReusesClientsAndRotatesChangedCredentials(t *testing.T) { + home, reg, registration := standaloneWatchFixture(t, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte(`{"gitVersion":"v1.35.0"}`)) + })) + probeStandaloneCluster(t.Context(), home, reg, "panel", registration) + first, ok := reg.Get("remote") + if !ok { + t.Fatal("first probe did not load client") + } + current, err := home.Clusters().Get(t.Context(), "remote", metav1.GetOptions{}) + if err != nil { + t.Fatal(err) + } + probeStandaloneCluster(t.Context(), home, reg, "panel", current) + second, _ := reg.Get("remote") + if first != second { + t.Fatal("unchanged credentials replaced the workload client") + } + secret, err := home.Secrets("panel").Get(t.Context(), "cluster-remote-kubeconfig", metav1.GetOptions{}) + if err != nil { + t.Fatal(err) + } + secret.Data["kubeconfig"] = []byte(strings.ReplaceAll(string(secret.Data["kubeconfig"]), "private-token", "rotated-token")) + if _, err := home.Secrets("panel").Update(t.Context(), secret, metav1.UpdateOptions{}); err != nil { + t.Fatal(err) + } + current, err = home.Clusters().Get(t.Context(), "remote", metav1.GetOptions{}) + if err != nil { + t.Fatal(err) + } + probeStandaloneCluster(t.Context(), home, reg, "panel", current) + third, _ := reg.Get("remote") + if third == nil || third == second { + t.Fatal("changed credentials did not replace workload client") + } +} + +func TestStandaloneProbePersistsSafeFailureMessage(t *testing.T) { + home, reg, registration := standaloneWatchFixture(t, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + http.Error(w, "private-token https://sensitive.example.invalid", http.StatusForbidden) + })) + probeStandaloneCluster(t.Context(), home, reg, "panel", registration) + current, err := home.Clusters().Get(t.Context(), "remote", metav1.GetOptions{}) + if err != nil { + t.Fatal(err) + } + phase, _, _ := unstructured.NestedString(current.Object, "status", "phase") + message, _, _ := unstructured.NestedString(current.Object, "status", "message") + if phase != "Unhealthy" || message != "Cluster connection is unavailable" { + t.Fatalf("unsafe or missing failure status: %s %s", phase, message) + } +} diff --git a/api/internal/notify/notify.go b/api/internal/notify/notify.go index 8dc69a4b5..ee44bc9a3 100644 --- a/api/internal/notify/notify.go +++ b/api/internal/notify/notify.go @@ -47,6 +47,7 @@ const queueSize = 256 // shipped to generic webhook sinks, so field renames are a breaking change // for webhook consumers. type Event struct { + Cluster string `json:"cluster,omitempty"` Type EventType `json:"type"` TS string `json:"ts"` Kind string `json:"kind,omitempty"` // CRD kind: GameServer | Backup | Restore diff --git a/api/internal/notify/registry.go b/api/internal/notify/registry.go new file mode 100644 index 000000000..2eeb43f99 --- /dev/null +++ b/api/internal/notify/registry.go @@ -0,0 +1,68 @@ +package notify + +import ( + "context" + "time" + + "github.com/GameplanePanel/gameplane/api/internal/kube" +) + +// RunWithRegistry keeps credentials central while watching remote workloads in a +// standalone panel. Removing or replacing a client cancels its old informers. +func (n *Notifier) RunWithRegistry(ctx context.Context, reg *kube.Registry) { + if !n.k.IsStandalone() { + n.Run(ctx) + return + } + go n.watchRegistry(ctx, reg) + for { + select { + case <-ctx.Done(): + return + case e := <-n.ch: + n.fanOut(ctx, e) + } + } +} + +func (n *Notifier) watchRegistry(ctx context.Context, reg *kube.Registry) { + type observer struct { + client *kube.Client + cancel context.CancelFunc + } + watchers := map[string]observer{} + defer func() { + for _, w := range watchers { + w.cancel() + } + }() + ticker := time.NewTicker(5 * time.Second) + defer ticker.Stop() + for { + clients := map[string]*kube.Client{} + for _, id := range reg.IDs() { + if k, ok := reg.Get(id); ok && k != nil { + clients[id] = k + } + } + for id, w := range watchers { + if clients[id] != w.client { + w.cancel() + delete(watchers, id) + } + } + for id, k := range clients { + if _, ok := watchers[id]; ok || k.Dynamic == nil { + continue + } + watchCtx, cancel := context.WithCancel(ctx) + watchers[id] = observer{client: k, cancel: cancel} + go n.runClusterWatchers(watchCtx, k, id) + } + select { + case <-ctx.Done(): + return + case <-ticker.C: + } + } +} diff --git a/api/internal/notify/sinks.go b/api/internal/notify/sinks.go index b4a88b157..ea72b290e 100644 --- a/api/internal/notify/sinks.go +++ b/api/internal/notify/sinks.go @@ -65,7 +65,7 @@ func sinkMatches(s Sink, t EventType) bool { // sinkSecret fetches the sink's credential Secret from the control-plane // namespace, refusing any Secret not labelled SinkSecretLabel=true. func (n *Notifier) sinkSecret(ctx context.Context, name string) (map[string][]byte, error) { - sec, err := n.k.Typed.CoreV1().Secrets(n.controlNS).Get(ctx, name, metav1.GetOptions{}) + sec, err := n.k.Secrets(n.controlNS).Get(ctx, name, metav1.GetOptions{}) if err != nil { return nil, fmt.Errorf("get sink secret %s/%s: %w", n.controlNS, name, err) } diff --git a/api/internal/notify/watch.go b/api/internal/notify/watch.go index d1c30bdfc..9062d8e51 100644 --- a/api/internal/notify/watch.go +++ b/api/internal/notify/watch.go @@ -25,7 +25,14 @@ const resyncPeriod = 10 * time.Minute // which is also the whole restart story (a failure that completes while // the API is down is missed here but still visible in the dashboard). func (n *Notifier) runWatchers(ctx context.Context) { - factory := dynamicinformer.NewDynamicSharedInformerFactory(n.k.Dynamic, resyncPeriod) + n.runClusterWatchers(ctx, n.k, "") +} + +func (n *Notifier) runClusterWatchers(ctx context.Context, k *kube.Client, cluster string) { + if k == nil || k.Dynamic == nil { + return + } + factory := dynamicinformer.NewDynamicSharedInformerFactory(k.Dynamic, resyncPeriod) // alerted tracks GameServers with an un-recovered unhealthy alert, so // recovery events pair with an outage instead of firing on every start. @@ -51,6 +58,7 @@ func (n *Notifier) runWatchers(ctx context.Context) { } mu.Unlock() for _, e := range events { + e.Cluster = cluster n.Enqueue(stampTS(e)) } }, @@ -80,6 +88,7 @@ func (n *Notifier) runWatchers(ctx context.Context) { return } for _, e := range phaseEvents(kind, oldU, newU) { + e.Cluster = cluster n.Enqueue(stampTS(e)) } }, diff --git a/api/internal/rbac/rbac.go b/api/internal/rbac/rbac.go index ff0cbc645..e8c14b903 100644 --- a/api/internal/rbac/rbac.go +++ b/api/internal/rbac/rbac.go @@ -92,7 +92,7 @@ func Middleware(fetch ServerFetcher) func(http.Handler) http.Handler { } ns := "" cl := scope.DefaultCluster - if clusterInventoryRead(req.Method, req.URL.Path) { + if clusterInventoryRead(req.Method, req.URL.Path) || strings.HasPrefix(req.URL.Path, "/modules") || strings.HasPrefix(req.URL.Path, "/templates") { // Authorize before discovery, including registered clusters whose // credentials could not load. The handler validates registration. cl = scope.RequestedCluster(req) @@ -214,6 +214,7 @@ var rules = []rule{ // Admin area. Audit is a read; config splits read vs manage; anything // else under /admin defaults to the admin wildcard (fail-closed-ish). + {method: "GET", segment: "admin", suffix: "/admin/installation", perm: ""}, {method: "GET", segment: "admin", prefix: "/admin/audit", perm: "audit:read"}, {method: "GET", segment: "admin", prefix: "/admin/config", perm: "config:read"}, {segment: "admin", prefix: "/admin/config", perm: "config:manage"}, diff --git a/api/internal/registry/keys.go b/api/internal/registry/keys.go index 83b4e7ea9..526539599 100644 --- a/api/internal/registry/keys.go +++ b/api/internal/registry/keys.go @@ -54,7 +54,7 @@ type SecretReader func(ctx context.Context, name string) (map[string][]byte, err // RegistryKeySecretLabel=true. func NewK8sSecretReader(k *kube.Client, ns string) SecretReader { return func(ctx context.Context, name string) (map[string][]byte, error) { - sec, err := k.Typed.CoreV1().Secrets(ns).Get(ctx, name, metav1.GetOptions{}) + sec, err := k.Secrets(ns).Get(ctx, name, metav1.GetOptions{}) if err != nil { return nil, fmt.Errorf("get registry-key secret %s/%s: %w", ns, name, err) } diff --git a/api/internal/telemetry/collect.go b/api/internal/telemetry/collect.go index 6cd6552d3..1d0a55007 100644 --- a/api/internal/telemetry/collect.go +++ b/api/internal/telemetry/collect.go @@ -251,7 +251,7 @@ func collectFeatures(ctx context.Context, deps Deps, servers []unstructured.Unst Backups: len(listItems(ctx, deps, "schedules")) > 0, SSO: deps.Flags.OIDCConfigured || hasSSOProvider(ctx, deps.Store), AuditForwarding: deps.Flags.AuditWebhook || deps.Flags.AuditS3, - Clusters: telemetryschema.ClusterBand(1 + countClusters(ctx, deps)), + Clusters: telemetryschema.ClusterBand(localClusterCount(deps) + countClusters(ctx, deps)), DB: telemetryschema.SanitizeEnum(telemetryschema.DBs, deps.Flags.DBDriver), Language: "en", } @@ -259,10 +259,10 @@ func collectFeatures(ctx context.Context, deps Deps, servers []unstructured.Unst // countClusters counts registered Cluster CRs, 0 when they can't be listed. func countClusters(ctx context.Context, deps Deps) int { - if deps.Kube == nil || deps.Kube.Dynamic == nil { + if deps.Kube == nil || deps.Kube.Clusters() == nil { return 0 } - list, err := deps.Kube.Dynamic.Resource(kube.GVRCluster).List(ctx, metav1.ListOptions{}) + list, err := deps.Kube.Clusters().List(ctx, metav1.ListOptions{}) if err != nil { return 0 } @@ -296,3 +296,10 @@ func hasSSOProvider(ctx context.Context, store *db.Store) bool { } return false } + +func localClusterCount(deps Deps) int { + if deps.Kube == nil || deps.Kube.IsStandalone() { + return 0 + } + return 1 +} diff --git a/api/internal/ws/gateway_client.go b/api/internal/ws/gateway_client.go index df1dc20dd..81a59ce7f 100644 --- a/api/internal/ws/gateway_client.go +++ b/api/internal/ws/gateway_client.go @@ -47,11 +47,11 @@ func (r *agentGatewayResolver) resolve(ctx context.Context, cluster string, targ if !ok || k == nil { return nil, nil, scope.ErrForbiddenCluster } - home := r.registry.Default() - if home == nil || home.Dynamic == nil || home.Typed == nil { + home := r.registry.Management() + if home == nil || home.Clusters() == nil { return nil, nil, errGatewayUnavailable } - registration, err := home.Dynamic.Resource(kube.GVRCluster).Get(ctx, cluster, metav1.GetOptions{}) + registration, err := home.Clusters().Get(ctx, cluster, metav1.GetOptions{}) if err != nil { return nil, nil, fmt.Errorf("get gateway registration: %w", err) } @@ -73,7 +73,7 @@ func (r *agentGatewayResolver) resolve(ctx context.Context, cluster string, targ if err != nil { return nil, nil, err } - secret, err := home.Typed.CoreV1().Secrets(r.namespace).Get(ctx, secretName, metav1.GetOptions{}) + secret, err := home.Secrets(r.namespace).Get(ctx, secretName, metav1.GetOptions{}) if err != nil { return nil, nil, fmt.Errorf("read gateway credentials: %w", err) } diff --git a/api/specs.md b/api/specs.md index b3ce7ca86..79a589fac 100644 --- a/api/specs.md +++ b/api/specs.md @@ -850,3 +850,15 @@ Kubernetes clients. Gateway loss does not inherently disable those clients. The protocol does not provide storage replication, workload migration, or central database availability. Deployment guidance is in [`docs/gateway-install.md`](../docs/gateway-install.md). + +## Standalone management mode + +`--standalone` / `GAMEPLANE_STANDALONE=true` skips Kubernetes credential discovery and does not register `local`. Management secrets and remote registrations persist in the API database through narrow `kube.Client.Secrets`/`Clusters` interfaces; workloads still use explicit Kubernetes registry clients. Credential payloads use AES-GCM with a durable 32-byte key selected by `--panel-key-file` / `GAMEPLANE_PANEL_KEY_FILE` (default `/data/panel.key`). Missing/wrong keys and corrupted credentials fail startup. Back up the key with the database; existing Kubernetes management objects are not imported automatically. + +`GET /admin/installation` is authenticated and reports `{standalone,localCluster}`. Empty standalone `/clusters` and `/fleet/*` responses contain no synthesized local cluster. A bounded API poller loads registrations and maintains safe connectivity status, independent of an operator on the panel host. Notifications observe registered remote workloads while credentials remain central. + +`POST /clusters` accepts a raw, self-contained kubeconfig string; existing kubeconfig validation rejects executable/file-backed credential references. `PUT /clusters/{name}/gateway` accepts `{url,caCert,clientCert,clientKey}` with an HTTPS origin and PEM TLS material. It persists only labelled management credentials and returns 204 without exposing secrets. `DELETE /clusters/{name}/gateway` clears the reference and only removes API-managed credentials. Existing central `cluster:manage` authorization applies to registration and gateway mutations. + +Standalone `/modules/*` requests require one explicit registered remote `cluster` selector and cluster-wide module permission on that target. The selected remote operator owns module reconciliation; upload ConfigMaps use the configured operator namespace. Combined installs retain local module ownership and reject remote selectors on this surface. Local node credential minting is incompatible with standalone startup; Kubernetes system-log endpoints return 501 and the dashboard directs administrators to container logs. + +See [standalone installation](../docs/standalone-panel.md) and [design](../specs/023-standalone-panel/spec.md). diff --git a/charts/gameplane/examples/standalone-panel-values.yaml b/charts/gameplane/examples/standalone-panel-values.yaml new file mode 100644 index 000000000..12472e1be --- /dev/null +++ b/charts/gameplane/examples/standalone-panel-values.yaml @@ -0,0 +1,12 @@ +# Fresh central panel install; use helm install/upgrade --skip-crds. +# Helm processes crds/ before values/templates, so values alone cannot skip CRDs. +operator: + enabled: false +api: + enabled: true + standalone: true +crds: + autoApply: + enabled: false +ingress: + enabled: false diff --git a/charts/gameplane/templates/NOTES.txt b/charts/gameplane/templates/NOTES.txt index 05742eea5..5865108d6 100644 --- a/charts/gameplane/templates/NOTES.txt +++ b/charts/gameplane/templates/NOTES.txt @@ -4,6 +4,14 @@ persistence and dashboard blocks are absent. */ -}} {{- $r := .Values.api.telemetry.receiver }} +{{- if .Values.api.standalone }} + +Standalone central panel: no local game cluster or Kubernetes API credentials. +Register remote clusters through the panel API after bootstrapping an admin. +Back up the API data volume including panel.key together with the database. +Always pass --skip-crds when installing this profile; Helm's native crds/ +installation happens before chart values can disable resources. +{{- end }} {{- $receiverDeployed := and (eq (include "gameplane.apiEnabled" .) "true") $r.enabled (not .Values.api.telemetry.endpoint) }} {{- $persistence := $r.persistence | default dict }} {{- $persistenceEnabled := ternary $persistence.enabled false (hasKey $persistence "enabled") }} diff --git a/charts/gameplane/templates/_helpers.tpl b/charts/gameplane/templates/_helpers.tpl index 3ea948fdb..bec3e1ffb 100644 --- a/charts/gameplane/templates/_helpers.tpl +++ b/charts/gameplane/templates/_helpers.tpl @@ -126,3 +126,12 @@ without them rotating a custom Secret would not roll the API and operator. true {{- end -}} {{- end -}} + +{{- /* Preserve the combined default when upgrading with --reuse-values. */}} +{{- define "gameplane.operatorEnabled" -}} +{{- if hasKey .Values.operator "enabled" -}} +{{- .Values.operator.enabled -}} +{{- else -}} +true +{{- end -}} +{{- end -}} diff --git a/charts/gameplane/templates/api.yaml b/charts/gameplane/templates/api.yaml index 9d14271ba..25096e8cd 100644 --- a/charts/gameplane/templates/api.yaml +++ b/charts/gameplane/templates/api.yaml @@ -11,6 +11,7 @@ metadata: name: gameplane-api namespace: {{ .Release.Namespace }} labels: {{- include "gameplane.labels" . | nindent 4 }} +{{- if not .Values.api.standalone }} --- # Cluster-wide READ access: templates (cluster-scoped), nodes, storage # classes, and read-only watches on Gameplane CRDs for the /events stream. @@ -36,7 +37,7 @@ rules: verbs: [create, update, patch, delete] # source management (add/edit/remove sources) from the dashboard - apiGroups: ["gameplane.local"] resources: [clusters] - verbs: [get, list, watch, create, delete] # cluster registration and management from the dashboard + verbs: [get, list, watch, create, update, delete] # registration and gateway configuration from the dashboard - apiGroups: [""] resources: [nodes, events] verbs: [get, list, watch] @@ -179,11 +180,12 @@ subjects: - kind: ServiceAccount name: gameplane-api namespace: {{ .Release.Namespace }} +{{- end }} {{- /* Safety guard: If switching an existing install to existingClaim, ensure the live gameplane-api-data PVC won't be pruned and destroyed by Helm. */}} -{{- if and (eq .Values.api.db.driver "sqlite") .Values.api.storage.existingClaim }} +{{- if and (or .Values.api.standalone (eq .Values.api.db.driver "sqlite")) .Values.api.storage.existingClaim }} {{- $livePVC := lookup "v1" "PersistentVolumeClaim" .Release.Namespace "gameplane-api-data" }} {{- if $livePVC }} {{- $annotations := $livePVC.metadata.annotations | default dict }} @@ -192,7 +194,7 @@ subjects: {{- end }} {{- end }} {{- end }} -{{- if and (eq .Values.api.db.driver "sqlite") (not .Values.api.storage.existingClaim) }} +{{- if and (or .Values.api.standalone (eq .Values.api.db.driver "sqlite")) (not .Values.api.storage.existingClaim) }} --- apiVersion: v1 kind: PersistentVolumeClaim @@ -221,7 +223,7 @@ spec: replicas: {{ .Values.api.replicas }} selector: matchLabels: { app.kubernetes.io/name: gameplane-api } - {{- if eq .Values.api.db.driver "sqlite" }} + {{- if or .Values.api.standalone (eq .Values.api.db.driver "sqlite") }} # Recreate strategy for SQLite: the database is a single-writer store # on a ReadWriteOnce PVC. RollingUpdate would start a new pod before # terminating the old one, allowing two processes to compete for the @@ -249,10 +251,15 @@ spec: labels: app.kubernetes.io/name: gameplane-api {{- include "gameplane.labels" . | nindent 8 }} + {{- if not .Values.api.standalone }} annotations: checksum/mtls: {{ include "gameplane.mtlsChecksum" . }} + {{- end }} spec: serviceAccountName: gameplane-api + {{- if .Values.api.standalone }} + automountServiceAccountToken: false + {{- end }} securityContext: runAsNonRoot: true runAsUser: 65532 @@ -263,6 +270,10 @@ spec: image: {{ include "gameplane.apiImage" . }} imagePullPolicy: {{ .Values.image.pullPolicy }} args: + {{- if .Values.api.standalone }} + - --standalone + - --panel-key-file=/data/panel.key + {{- end }} - --db-driver={{ .Values.api.db.driver }} - --db-dsn={{ .Values.api.db.dsn }} {{- if .Values.api.oidc.enabled }} @@ -288,12 +299,14 @@ spec: - {{ printf "--oidc-role-mapping-viewer=%s" (join "," .Values.api.oidc.roleMappings.viewer) | quote }} {{- end }} {{- end }} + {{- if not .Values.api.standalone }} {{- if and .Values.operator.gameDataStorage .Values.operator.gameDataStorage.storageClassName }} - --game-data-storage-class={{ .Values.operator.gameDataStorage.storageClassName }} {{- end }} - --agent-ca-bundle=/etc/gameplane/agent-ca/ca.crt - --agent-client-cert=/etc/gameplane/agent-client/tls.crt - --agent-client-key=/etc/gameplane/agent-client/tls.key + {{- end }} {{- if gt (int .Values.api.audit.retentionDays) 0 }} - --audit-retention-days={{ .Values.api.audit.retentionDays }} {{- end }} @@ -336,7 +349,7 @@ spec: {{- if not (ternary $telemetry.enabled true (hasKey $telemetry "enabled")) }} - --telemetry-disabled {{- end }} - {{- if .Values.defaultModuleSource.enabled }} + {{- if and (not .Values.api.standalone) .Values.defaultModuleSource.enabled }} - --official-module-source={{ .Values.defaultModuleSource.name | default "default" }} {{- end }} {{- /* @@ -359,14 +372,14 @@ spec: - --update-channel={{ $channel }} - --trusted-proxies={{ .Values.api.trustedProxies }} - --metrics-addr=:{{ .Values.api.metricsPort | default 9090 }} - {{- if .Values.clusterOps.externalAddress }} + {{- if and (not .Values.api.standalone) .Values.clusterOps.externalAddress }} - --cluster-external-address={{ .Values.clusterOps.externalAddress }} {{- end }} env: - name: GAMEPLANE_NAMESPACE valueFrom: fieldRef: { fieldPath: metadata.namespace } - {{- if .Values.clusterOps.enabled }} + {{- if and (not .Values.api.standalone) .Values.clusterOps.enabled }} - name: GAMEPLANE_CLUSTER_OPS value: "true" {{- end }} @@ -443,19 +456,23 @@ spec: capabilities: { drop: [ALL] } volumeMounts: - { name: data, mountPath: /data } + {{- if not .Values.api.standalone }} - { name: agent-ca, mountPath: /etc/gameplane/agent-ca, readOnly: true } - { name: agent-client, mountPath: /etc/gameplane/agent-client, readOnly: true } + {{- end }} volumes: - name: data - {{- if eq .Values.api.db.driver "sqlite" }} + {{- if or .Values.api.standalone (eq .Values.api.db.driver "sqlite") }} persistentVolumeClaim: { claimName: {{ .Values.api.storage.existingClaim | default "gameplane-api-data" }} } {{- else }} emptyDir: {} {{- end }} + {{- if not .Values.api.standalone }} - name: agent-ca secret: { secretName: {{ .Values.api.agentMTLS.caSecretRef.name }} } - name: agent-client secret: { secretName: {{ .Values.api.agentMTLS.clientCertRef.name }} } + {{- end }} {{- with .Values.image.pullSecrets }} imagePullSecrets: {{ toYaml . | nindent 8 }} {{- end }} diff --git a/charts/gameplane/templates/clusterops-rbac.yaml b/charts/gameplane/templates/clusterops-rbac.yaml index 1ed613719..0a4ca9cbc 100644 --- a/charts/gameplane/templates/clusterops-rbac.yaml +++ b/charts/gameplane/templates/clusterops-rbac.yaml @@ -1,4 +1,4 @@ -{{- if and (eq (include "gameplane.apiEnabled" .) "true") .Values.clusterOps.enabled }} +{{- if and (not .Values.api.standalone) (eq (include "gameplane.apiEnabled" .) "true") .Values.clusterOps.enabled }} # RBAC for the credential-minting cluster operations (Add node, Download # kubeconfig). Granted ONLY when clusterOps.enabled — these are powerful # (create bootstrap tokens; approve client-cert CSRs), so they are opt-in diff --git a/charts/gameplane/templates/crd-apply-hook.yaml b/charts/gameplane/templates/crd-apply-hook.yaml index f0f9d6bcb..b767b4e28 100644 --- a/charts/gameplane/templates/crd-apply-hook.yaml +++ b/charts/gameplane/templates/crd-apply-hook.yaml @@ -1,3 +1,4 @@ +{{- if not .Values.api.standalone }} {{- if .Values.crds.autoApply.enabled }} {{- /* Helm installs the files under crds/ only on the FIRST install and never @@ -278,3 +279,5 @@ spec: imagePullSecrets: {{ toYaml . | nindent 8 }} {{- end }} {{- end }} + +{{- end }} diff --git a/charts/gameplane/templates/grafana-dashboard.yaml b/charts/gameplane/templates/grafana-dashboard.yaml index e8b3b7387..de1052252 100644 --- a/charts/gameplane/templates/grafana-dashboard.yaml +++ b/charts/gameplane/templates/grafana-dashboard.yaml @@ -1,3 +1,4 @@ +{{- if (eq (include "gameplane.operatorEnabled" .) "true") }} {{- if .Values.grafanaDashboards.enabled }} # Grafana dashboard for the operator, shipped as a ConfigMap that the Grafana # sidecar (kube-prometheus-stack / grafana helm chart) auto-imports by label. @@ -17,3 +18,5 @@ data: gameplane-operator.json: |- {{- .Files.Get "dashboards/gameplane-operator.json" | nindent 4 }} {{- end }} + +{{- end }} diff --git a/charts/gameplane/templates/mcp-server.yaml b/charts/gameplane/templates/mcp-server.yaml index aae636650..3858878a3 100644 --- a/charts/gameplane/templates/mcp-server.yaml +++ b/charts/gameplane/templates/mcp-server.yaml @@ -1,3 +1,4 @@ +{{- if not .Values.api.standalone }} {{- if .Values.mcpServer.enabled }} # Optional, strictly read-only MCP server. It speaks MCP (JSON-RPC 2.0) over # stdio only, never a network port, so there is no Service here — reach a @@ -87,3 +88,5 @@ spec: imagePullSecrets: {{- toYaml . | nindent 8 }} {{- end }} {{- end }} + +{{- end }} diff --git a/charts/gameplane/templates/module-cosign-key.yaml b/charts/gameplane/templates/module-cosign-key.yaml index de4b51940..20eb373fe 100644 --- a/charts/gameplane/templates/module-cosign-key.yaml +++ b/charts/gameplane/templates/module-cosign-key.yaml @@ -1,3 +1,4 @@ +{{- if (eq (include "gameplane.operatorEnabled" .) "true") }} {{- if and .Values.defaultModuleSource.enabled (eq (.Values.defaultModuleSource.type | default "oci") "oci") .Values.defaultModuleSource.oci.verify.enabled }} {{- if not .Values.defaultModuleSource.oci.verify.cosignPublicKey }} {{- fail "defaultModuleSource.oci.verify.enabled is true but defaultModuleSource.oci.verify.cosignPublicKey is empty — provide the signing public key (PEM)" }} @@ -17,3 +18,5 @@ type: Opaque data: cosign.pub: {{ .Values.defaultModuleSource.oci.verify.cosignPublicKey | b64enc }} {{- end }} + +{{- end }} diff --git a/charts/gameplane/templates/modulesource.yaml b/charts/gameplane/templates/modulesource.yaml index ad94ae188..ad43139ae 100644 --- a/charts/gameplane/templates/modulesource.yaml +++ b/charts/gameplane/templates/modulesource.yaml @@ -1,3 +1,4 @@ +{{- if (eq (include "gameplane.operatorEnabled" .) "true") }} {{- if .Values.defaultModuleSource.enabled }} {{- $src := .Values.defaultModuleSource }} {{- $type := $src.type | default "oci" }} @@ -50,3 +51,5 @@ spec: type: upload refreshInterval: {{ .Values.uploadModuleSource.refreshInterval | default "1h" }} {{- end }} + +{{- end }} diff --git a/charts/gameplane/templates/mtls.yaml b/charts/gameplane/templates/mtls.yaml index 116c363da..3eda0bbcd 100644 --- a/charts/gameplane/templates/mtls.yaml +++ b/charts/gameplane/templates/mtls.yaml @@ -1,3 +1,4 @@ +{{- if or (eq (include "gameplane.operatorEnabled" .) "true") (and (eq (include "gameplane.apiEnabled" .) "true") (not .Values.api.standalone)) }} {{- /* Agent mTLS material. @@ -109,3 +110,5 @@ data: {{- fail (printf "api.agentMTLS.clientCertRef.name=%q: Secret not found or missing required keys (tls.crt, tls.key)" $clientSecretName) }} {{- end }} {{- end }} + +{{- end }} diff --git a/charts/gameplane/templates/namespaces.yaml b/charts/gameplane/templates/namespaces.yaml index 0d62fbc31..8642b33dd 100644 --- a/charts/gameplane/templates/namespaces.yaml +++ b/charts/gameplane/templates/namespaces.yaml @@ -1,3 +1,4 @@ +{{- if (eq (include "gameplane.operatorEnabled" .) "true") }} apiVersion: v1 kind: Namespace metadata: @@ -14,3 +15,5 @@ metadata: # StatefulSet, PVC and per-server Secret Kubernetes garbage-collects # underneath it). Same pattern as the API PVC in api.yaml. helm.sh/resource-policy: keep + +{{- end }} diff --git a/charts/gameplane/templates/networkpolicies.yaml b/charts/gameplane/templates/networkpolicies.yaml index 6e93edb10..81f963f4d 100644 --- a/charts/gameplane/templates/networkpolicies.yaml +++ b/charts/gameplane/templates/networkpolicies.yaml @@ -1,3 +1,4 @@ +{{- if (eq (include "gameplane.operatorEnabled" .) "true") }} {{- if .Values.networkPolicies.enabled }} # Default-deny ingress in the games namespace. Individual GameServers # open only the ports declared in their template (and whatever the @@ -383,3 +384,5 @@ spec: # and the per-server ingress policy cover which ports are actually # advertised for each server. {{- end }} + +{{- end }} diff --git a/charts/gameplane/templates/operator.yaml b/charts/gameplane/templates/operator.yaml index c81f91084..772e0b10e 100644 --- a/charts/gameplane/templates/operator.yaml +++ b/charts/gameplane/templates/operator.yaml @@ -1,3 +1,4 @@ +{{- if (eq (include "gameplane.operatorEnabled" .) "true") }} apiVersion: v1 kind: ServiceAccount metadata: @@ -409,3 +410,5 @@ spec: {{- with .Values.image.pullSecrets }} imagePullSecrets: {{ toYaml . | nindent 8 }} {{- end }} + +{{- end }} diff --git a/charts/gameplane/templates/prometheusrule.yaml b/charts/gameplane/templates/prometheusrule.yaml index 6754a6c13..e7b039e94 100644 --- a/charts/gameplane/templates/prometheusrule.yaml +++ b/charts/gameplane/templates/prometheusrule.yaml @@ -1,3 +1,4 @@ +{{- if (eq (include "gameplane.operatorEnabled" .) "true") }} {{- if .Values.prometheusRules.enabled }} # Alerts on the operator's standard controller-runtime metrics (scraped by the # operator ServiceMonitor). Deliberately uses only the operator's own metric @@ -87,3 +88,5 @@ spec: failed and has not been superseded; check the Backup status and the operator logs before relying on restore. {{- end }} + +{{- end }} diff --git a/charts/gameplane/templates/servicemonitors.yaml b/charts/gameplane/templates/servicemonitors.yaml index 03bde412e..4521db18e 100644 --- a/charts/gameplane/templates/servicemonitors.yaml +++ b/charts/gameplane/templates/servicemonitors.yaml @@ -1,4 +1,5 @@ {{- if .Values.serviceMonitors.enabled }} +{{- if (eq (include "gameplane.operatorEnabled" .) "true") }} apiVersion: v1 kind: Service metadata: @@ -28,6 +29,7 @@ spec: endpoints: - { port: metrics, interval: 30s } --- +{{- end }} {{- if (eq (include "gameplane.apiEnabled" .) "true") }} apiVersion: monitoring.coreos.com/v1 kind: ServiceMonitor @@ -87,6 +89,7 @@ spec: rationale (rejected client-cert approach, containerPort naming) lives in agent/specs.md's /metrics section — this is a pointer, not a copy. */}} +{{- if (eq (include "gameplane.operatorEnabled" .) "true") }} apiVersion: monitoring.coreos.com/v1 kind: PodMonitor metadata: @@ -105,3 +108,5 @@ spec: interval: 30s path: /metrics {{- end }} + +{{- end }} diff --git a/charts/gameplane/templates/validate.yaml b/charts/gameplane/templates/validate.yaml new file mode 100644 index 000000000..a3a23e903 --- /dev/null +++ b/charts/gameplane/templates/validate.yaml @@ -0,0 +1,11 @@ +{{- if .Values.api.standalone }} +{{- if eq (include "gameplane.operatorEnabled" .) "true" }} +{{- fail "api.standalone requires operator.enabled=false; install operators in the remote game clusters" }} +{{- end }} +{{- if ne (int .Values.api.replicas) 1 }} +{{- fail "api.standalone requires api.replicas=1 for the persistent panel key and database" }} +{{- end }} +{{- if (.Values.gateway | default dict).enabled }} +{{- fail "api.standalone cannot host gateway.enabled; install the gateway in the remote game cluster" }} +{{- end }} +{{- end }} diff --git a/charts/gameplane/templates/web.yaml b/charts/gameplane/templates/web.yaml index 872cc415a..4429fc5e4 100644 --- a/charts/gameplane/templates/web.yaml +++ b/charts/gameplane/templates/web.yaml @@ -25,6 +25,7 @@ spec: app.kubernetes.io/name: gameplane-web {{- include "gameplane.labels" . | nindent 8 }} spec: + automountServiceAccountToken: false securityContext: runAsNonRoot: true runAsUser: 101 # the nginx user in nginx-unprivileged diff --git a/charts/gameplane/values.yaml b/charts/gameplane/values.yaml index c3b85ff2f..341f11ecf 100644 --- a/charts/gameplane/values.yaml +++ b/charts/gameplane/values.yaml @@ -47,6 +47,8 @@ crds: pullPolicy: IfNotPresent operator: + # Disable all operator resources for a standalone central panel. + enabled: true replicas: 1 logLevel: info # zap log level: debug|info|error (warn not supported by zap's --zap-log-level) agentLogLevel: "" # injected into every agent sidecar as GAMEPLANE_LOG_LEVEL (debug|info|warn|error); empty injects nothing, avoiding StatefulSet rollouts on upgrade @@ -123,6 +125,9 @@ api: # Disable the central API, its database/RBAC, dashboard, ingress and API-only # integrations on remote operator+gateway installs. Agent mTLS is preserved. enabled: true + # Central panel only: SQL-backed settings and credentials, no local cluster. + # Requires operator.enabled=false and helm install --skip-crds. + standalone: false replicas: 1 # Keep at 1. SQLite is single-writer on a RWO volume; with db.driver=postgres the user-management lock and audit hash chain are still per-process logLevel: info # API log verbosity (debug|info|warn|error), passed as GAMEPLANE_LOG_LEVEL # Port of the API's Prometheus metrics listener. It is separate from the @@ -154,7 +159,8 @@ api: # image is built without it). Example: # dsn: "postgres://gameplane:***@postgres/gameplane?sslmode=require" - # PVC attached to the API for SQLite storage. Only used when db.driver=sqlite. + # PVC for SQLite and, in standalone mode, the credential encryption key. + # Standalone mode keeps this volume even with PostgreSQL; back up panel.key. storage: size: 2Gi storageClassName: "" diff --git a/charts/tests/check_standalone.py b/charts/tests/check_standalone.py new file mode 100644 index 000000000..62da1b8b4 --- /dev/null +++ b/charts/tests/check_standalone.py @@ -0,0 +1,66 @@ +"""CI-only checks for combined, standalone central, and remote deployment roles.""" +from pathlib import Path + +import yaml + +from check_gateway import one, render + + +def main(): + defaults = render() + one(defaults, "Deployment", "gameplane-operator") + one(defaults, "Deployment", "gameplane-api") + one(defaults, "ModuleSource", "default") + api_rules = one(defaults, "ClusterRole", "gameplane-api-read")["rules"] + assert any(rule.get("resources") == ["clusters"] and "update" in rule["verbs"] for rule in api_rules) + assert "--standalone" not in one(defaults, "Deployment", "gameplane-api")["spec"]["template"]["spec"]["containers"][0]["args"] + + values = { + "operator": {"enabled": False}, "api": {"standalone": True}, + "serviceMonitors": {"enabled": True}, "prometheusRules": {"enabled": True}, + "grafanaDashboards": {"enabled": True}, "clusterOps": {"enabled": True}, + "mcpServer": {"enabled": True}, "capture": {"enabled": True}, + } + for upgrade in (False, True): + objects = render(values, upgrade=upgrade) + for obj in objects: + assert obj["kind"] not in {"ClusterRole", "ClusterRoleBinding", "Role", "RoleBinding", "Namespace", "ModuleSource", "NetworkPolicy", "PodMonitor", "PrometheusRule", "Job"}, obj + assert "operator" not in obj["metadata"]["name"], obj + assert not obj["metadata"]["name"].startswith("gameplane-agent"), obj + one(objects, "Deployment", "gameplane-web") + one(objects, "ServiceMonitor", "gameplane-api") + one(objects, "PersistentVolumeClaim", "gameplane-api-data") + deployment = one(objects, "Deployment", "gameplane-api") + pod = deployment["spec"]["template"]["spec"] + assert pod["automountServiceAccountToken"] is False + assert "annotations" not in deployment["spec"]["template"]["metadata"] + container = pod["containers"][0] + assert "--standalone" in container["args"] + assert "--panel-key-file=/data/panel.key" in container["args"] + assert not any(arg.startswith("--agent-") for arg in container["args"]) + assert container["volumeMounts"] == [{"name": "data", "mountPath": "/data"}] + assert pod["volumes"] == [{"name": "data", "persistentVolumeClaim": {"claimName": "gameplane-api-data"}}] + + # The encryption key must survive pod replacement with either database driver. + objects = render({"operator": {"enabled": False}, "api": {"standalone": True, "db": {"driver": "postgres"}}}) + one(objects, "PersistentVolumeClaim", "gameplane-api-data") + objects = render({"operator": {"enabled": False}, "api": {"standalone": True, "storage": {"existingClaim": "panel-data"}}}) + assert not any(obj["kind"] == "PersistentVolumeClaim" for obj in objects) + assert one(objects, "Deployment", "gameplane-api")["spec"]["template"]["spec"]["volumes"][0]["persistentVolumeClaim"]["claimName"] == "panel-data" + + remote = render({"api": {"enabled": False}}) + one(remote, "Deployment", "gameplane-operator") + one(remote, "Secret", "gameplane-agent-ca") + assert not any(obj["metadata"]["name"] in {"gameplane-api", "gameplane-web"} for obj in remote) + disabled = render({"operator": {"enabled": False}}) + assert not any("operator" in obj["metadata"]["name"] for obj in disabled) + one(disabled, "Deployment", "gameplane-api") + + render({"api": {"standalone": True}}, failure="api.standalone requires operator.enabled=false") + render({"operator": {"enabled": False}, "api": {"standalone": True, "replicas": 2}}, failure="api.standalone requires api.replicas=1") + example = yaml.safe_load((Path(__file__).parents[1] / "gameplane/examples/standalone-panel-values.yaml").read_text()) + one(render(example), "Deployment", "gameplane-api") + + +if __name__ == "__main__": + main() diff --git a/deploy/standalone/compose.yaml b/deploy/standalone/compose.yaml new file mode 100644 index 000000000..c869411ed --- /dev/null +++ b/deploy/standalone/compose.yaml @@ -0,0 +1,48 @@ +name: gameplane-panel + +services: + gameplane-api: + build: + context: ../.. + dockerfile: api/Dockerfile + restart: unless-stopped + environment: + GAMEPLANE_STANDALONE: "true" + GAMEPLANE_PANEL_KEY_FILE: /data/panel.key + GAMEPLANE_DB_DRIVER: sqlite + GAMEPLANE_DB_DSN: "file:/data/gameplane.db?_pragma=journal_mode(WAL)" + volumes: + - panel-data:/data + read_only: true + tmpfs: + - /tmp:uid=65532,gid=65532,mode=1777 + cap_drop: [ALL] + security_opt: [no-new-privileges:true] + + web: + build: + context: ../.. + dockerfile: web/Dockerfile + restart: unless-stopped + depends_on: + - gameplane-api + environment: + API_UPSTREAM: http://gameplane-api:8000 + ports: + - "127.0.0.1:${GAMEPLANE_PORT:-8080}:8080" + read_only: true + tmpfs: + - /etc/nginx/conf.d:uid=101,gid=101,mode=0755 + - /tmp:uid=101,gid=101,mode=1777 + - /var/cache/nginx:uid=101,gid=101,mode=0755 + - /var/run:uid=101,gid=101,mode=0755 + cap_drop: [ALL] + security_opt: [no-new-privileges:true] + healthcheck: + test: [CMD, wget, -q, -O, /dev/null, "http://127.0.0.1:8080/nginx-health"] + interval: 30s + timeout: 5s + retries: 3 + +volumes: + panel-data: diff --git a/deploy/standalone/smoke.py b/deploy/standalone/smoke.py new file mode 100644 index 000000000..6826f3557 --- /dev/null +++ b/deploy/standalone/smoke.py @@ -0,0 +1,105 @@ +"""CI-only smoke test against the real Compose images, without Kubernetes.""" +import hashlib +from http.cookies import SimpleCookie +import json +import os +from pathlib import Path +import secrets +import subprocess +import tempfile +import time +import urllib.error +import urllib.request + + +ROOT = Path(__file__).resolve().parents[2] +COMPOSE = ["docker", "compose", "-f", str(ROOT / "deploy/standalone/compose.yaml")] +ORIGIN = "http://127.0.0.1:" + os.environ.get("GAMEPLANE_PORT", "8080") + + +def compose(*args, **kwargs): + return subprocess.run(COMPOSE + list(args), check=True, text=True, **kwargs) + + +def request(path, data=None, cookie=""): + headers = {"Accept": "application/json", "Cookie": cookie} + if data is not None: + headers["Content-Type"] = "application/json" + req = urllib.request.Request(ORIGIN + path, headers=headers, + data=json.dumps(data).encode() if data is not None else None) + with urllib.request.urlopen(req, timeout=10) as response: + return response.read(), response.headers + + +def wait_ready(): + deadline = time.monotonic() + 120 + while time.monotonic() < deadline: + try: + request("/healthz") + return + except (OSError, urllib.error.URLError): + time.sleep(1) + raise AssertionError("standalone API did not become healthy") + + +def login(password): + body, headers = request("/auth/login", {"username": "standalone-admin", "password": password}) + assert json.loads(body)["user"]["role"] == "admin" + cookies = SimpleCookie() + for value in headers.get_all("Set-Cookie", []): + cookies.load(value) + assert "gameplane_session" in cookies + # This isolated CI endpoint is loopback HTTP; send Secure cookies explicitly. + # Production deployments retain HTTPS and normal browser cookie enforcement. + return "; ".join(f"{key}={value.value}" for key, value in cookies.items()) + + +def assert_empty(cookie): + clusters = json.loads(request("/clusters", cookie=cookie)[0]) + assert clusters["items"] == [], clusters + for kind in ("inventory", "servers", "backups", "placements"): + fleet = json.loads(request("/fleet/" + kind, cookie=cookie)[0]) + assert fleet["items"] == [], fleet + assert not fleet["partial"], fleet + assert not fleet.get("issues"), fleet + user = json.loads(request("/users/me", cookie=cookie)[0]) + assert user["username"] == "standalone-admin" + + +def key_digest(container): + with tempfile.TemporaryDirectory() as directory: + key = Path(directory) / "panel.key" + subprocess.run(["docker", "cp", container + ":/data/panel.key", str(key)], check=True) + return hashlib.sha256(key.read_bytes()).digest() + + +def main(): + config = json.loads(compose("config", "--format", "json", capture_output=True).stdout) + assert set(config["services"]) == {"gameplane-api", "web"} + api = config["services"]["gameplane-api"] + assert "KUBECONFIG" not in api.get("environment", {}) + assert {volume["target"] for volume in api["volumes"]} == {"/data"} + assert not api.get("ports") + assert api["read_only"] is True + assert api["environment"]["GAMEPLANE_STANDALONE"] == "true" + wait_ready() + container = compose("ps", "-q", "gameplane-api", capture_output=True).stdout.strip() + inspection = json.loads(subprocess.run(["docker", "inspect", container], check=True, text=True, capture_output=True).stdout)[0] + assert inspection["Config"]["User"] == "65532:65532" + assert not any("serviceaccount" in mount["Destination"] or "docker.sock" in mount["Destination"] for mount in inspection["Mounts"]) + original_key = key_digest(container) + password = secrets.token_urlsafe(24) + compose("exec", "-T", "gameplane-api", "/api", "bootstrap-admin", "--username", "standalone-admin", "--password-stdin", input=password + "\n") + cookie = login(password) + assert_empty(cookie) + compose("up", "-d", "--no-build", "--force-recreate", "gameplane-api") + wait_ready() + container = compose("ps", "-q", "gameplane-api", capture_output=True).stdout.strip() + assert key_digest(container) == original_key, "panel key changed on container replacement" + assert_empty(cookie) # Existing session and database must also survive. + assert_empty(login(password)) + print("Standalone Compose: bootstrap, empty fleet, persistent key and restart passed") + + +if __name__ == "__main__": + main() diff --git a/docs/install.md b/docs/install.md index 92e313d18..e2b20be9d 100644 --- a/docs/install.md +++ b/docs/install.md @@ -1,5 +1,10 @@ # Install +For a central dashboard/API on a host without Kubernetes, use the +[standalone panel guide](standalone-panel.md), including Docker Compose and a +panel-only Helm profile. The instructions below describe the default combined +installation, with an operator and a local game cluster. + ## Prerequisites - Kubernetes 1.28+ diff --git a/docs/standalone-panel.md b/docs/standalone-panel.md new file mode 100644 index 000000000..bf495be26 --- /dev/null +++ b/docs/standalone-panel.md @@ -0,0 +1,177 @@ +# Standalone panel + +Run the dashboard and API on a host that has no Kubernetes installation. Remote +game clusters run their own operator and game server agents. The central host is +never registered as a `local` cluster and cannot host games itself. + +The default Helm install remains a combined panel and operator installation. +Standalone mode is an explicit choice for a **fresh** central panel; this guide +does not migrate an existing installation's Kubernetes Secrets or Cluster CRs +into SQL storage. + +## Docker Compose + +Install Docker with the Compose plugin, then clone this repository and check out +the version containing standalone support. The Compose file builds the API and +web images from that checkout so both components use the same code: + +```sh +docker compose -f deploy/standalone/compose.yaml up -d --build +docker compose -f deploy/standalone/compose.yaml logs gameplane-api +``` + +Only two services run: `gameplane-api` and `web`. Neither mounts a kubeconfig, +host Docker socket, or Kubernetes credentials. The API is reachable only through +the Compose network; nginx forwards requests to `http://gameplane-api:8000`. +The dashboard is bound to `127.0.0.1:8080` on the host. Set `GAMEPLANE_PORT` to +change that port. Use `http://localhost:8080` locally; for remote access, put an +HTTPS reverse proxy in front of the loopback port and forward WebSocket upgrades +and streaming responses. Login cookies require a secure browser context; serving +the dashboard on an ordinary remote HTTP origin will not work. + +Create the first administrator using the same database volume. This Bash example +reads the password without including it in shell history or process arguments: + +```sh +read -r -s -p 'Admin password: ' PANEL_PASSWORD +printf '\n' +printf '%s\n' "$PANEL_PASSWORD" | docker compose -f deploy/standalone/compose.yaml \ + exec -T gameplane-api /api bootstrap-admin --username admin --password-stdin +unset PANEL_PASSWORD +``` + +Use a password of at least 12 characters. Sign in at the dashboard. A new panel +has no clusters; register a remote cluster before using game-server operations. +Authentication, users, roles, settings, and registration remain available before +any remote cluster exists. Administrators can use **Register cluster** on the +Clusters page or the registration API below. The cluster picker selects already +registered clusters. Gateway credentials are configured through the API. + +## Storage and recovery + +The named `panel-data` volume contains `/data/gameplane.db` and +`/data/panel.key`. Standalone settings and remote credentials are stored in SQL; +credential values are encrypted using the persistent key. Both files are needed +for recovery. Stop the services before copying the entire volume so the SQLite +database and its WAL files are consistent. Restore the volume with ownership +`65532:65532`, then start the services. Normal `docker compose down` retains the +volume; `down --volumes` deletes the database and key. + +The API image seeds a new named volume with a directory writable by its non-root +runtime user. If you replace it with a host bind mount, create that directory with +ownership `65532:65532` yourself. Keep the key private and back it up with the +database; creating a replacement key cannot decrypt existing credentials. + +For custom deployments, pass `--standalone` or `GAMEPLANE_STANDALONE=true` to the +API. `--panel-key-file` / `GAMEPLANE_PANEL_KEY_FILE` selects the key path (default +`/data/panel.key`). A persistent key file is required even when using the +experimental PostgreSQL build. Run one API replica. + +## Central panel on Kubernetes + +The panel can also run in a Kubernetes cluster without using that cluster for +games or granting the API Kubernetes access. From this checkout: + +```sh +helm upgrade --install gameplane charts/gameplane \ + --namespace gameplane-system --create-namespace --skip-crds \ + --values charts/gameplane/examples/standalone-panel-values.yaml +``` + +Use images built from the matching version (set `image.registry` and `image.tag` +for your build). The example enables `api.standalone`, disables `operator.enabled` +and the CRD upgrade hook, and disables ingress until you configure your hostname +and TLS. Expose the web Service through your HTTPS ingress or use a local port +forward. Bootstrap the admin with `/api bootstrap-admin` in the API pod as in the +[combined installation guide](install.md#first-time-setup). + +**Keep `--skip-crds` on installation commands.** Helm installs files in `crds/` +before evaluating templates; setting values alone cannot suppress those CRDs. +Standalone mode skips the CRD apply hook automatically. It creates no operator, +agent certificates, game namespace, module source, game network policies, +operator monitors, or API Kubernetes RBAC. Its API pod disables service account +token mounting. Its PVC holds both SQLite and the panel key. Do not switch an +existing combined release to these values as a migration procedure. + +## Install and register remote game clusters + +Each target still needs Kubernetes, Gameplane CRDs, an operator, and storage for +games. Install the matching chart there with `api.enabled=false`; leave +`operator.enabled=true` and `api.standalone=false`. Agents are created alongside +game servers by that operator. Enable the optional +[private gateway](gateway-install.md) for agent operations such as console, +files, and player management. Without a gateway, Kubernetes resource operations +remain available, but agent-backed operations are unavailable. + +The central API must reach each target's Kubernetes API directly. The gateway +does not tunnel Kubernetes traffic. Use a kubeconfig containing credentials and +CA data that are usable from the central host, with the +[documented remote permissions](install.md#prerequisites-1). Do not use kubeconfigs +whose credentials require local files or interactive credential plugins. Use +the same operator namespace on the targets as the API's `--namespace` setting +(default `gameplane-system`). + +Register the target with an authenticated administrator session. In the examples +below, `cookies.txt` is a private cookie jar for that session and `CSRF_TOKEN` +is its `gameplane_csrf` cookie value. `PANEL_URL` is the HTTPS dashboard origin. +The kubeconfig is a raw YAML string in JSON, not base64: + +```sh +jq -n --rawfile kubeconfig remote-kubeconfig.yaml \ + '{name:"remote-1",displayName:"Remote games",kubeconfig:$kubeconfig}' | + curl --fail-with-body --cookie cookies.txt \ + --header "X-Gameplane-CSRF: $CSRF_TOKEN" \ + --header 'Content-Type: application/json' \ + --data-binary @- "$PANEL_URL/clusters" +``` + +The standalone panel saves the registration and credentials in its database; +there is no central `Cluster` CR or Kubernetes Secret to create. It monitors +remote Kubernetes connectivity itself. Selecting that registration routes game +operations to the target, and never falls back to the central host. + +Registration does not grant workload access, including to the bootstrap admin. +Its initial role administers the central panel. In **Users**, use the **Roles** +tab to create a custom role containing the target permissions you need: + +- `cluster:read` for node inventory. +- `modules:read` and `modules:manage` for the catalog, sources, and module installation. +- `templates:read` and `templates:write` for game templates. +- The server, backup, and other namespace permissions needed for your workloads. + +Open your user in the Users tab, then under **Cluster & +namespace grants**, select the remote cluster and that role. Choose a game +namespace for a namespace grant, or **All namespaces** for an allowed role that +should apply across that remote cluster, and click **Add**. Inventory, module, +and template permissions need an **All namespaces** grant on that target; a grant +limited to one game namespace does not grant those cluster-wide operations. +Adding a grant ends +that user's sessions; sign in again to use it. Grant other users access the same +way. Do not remove the bootstrap user's primary role to add remote access. + +Remote grants cannot include central administration permissions or the built-in +admin wildcard. Use a custom workload role rather than assigning the built-in +admin role to all namespaces. Wait for the registration to connect before adding +a target grant; registration metadata can appear before its client is loaded. + +For a configured remote gateway, store the central client's dedicated mTLS +credentials through the standalone registration API: + +```sh +jq -n --rawfile caCert gateway-ca.crt \ + --rawfile clientCert central-client.crt --rawfile clientKey central-client.key \ + '{url:"https://remote-1-gateway.internal:8443",caCert:$caCert,clientCert:$clientCert,clientKey:$clientKey}' | + curl --fail-with-body --request PUT --cookie cookies.txt \ + --header "X-Gameplane-CSRF: $CSRF_TOKEN" \ + --header 'Content-Type: application/json' \ + --data-binary @- "$PANEL_URL/clusters/remote-1/gateway" +``` + +The gateway's `clusterID` must be `remote-1`, and its `peerURI` must match the +client certificate URI SAN. Its server certificate must match the endpoint +hostname. Use a separate trust root from the target's local agent CA. Repeat PUT +to rotate credentials; DELETE on the same endpoint removes gateway access while +retaining the cluster registration. Credential responses do not return private +keys. See [gateway routing and trust](multicluster-agent-gateway.md) for the +network and certificate requirements; that document's central Kubernetes Secret +instructions apply to combined mode, while standalone uses the API above. diff --git a/specs/023-standalone-panel/spec.md b/specs/023-standalone-panel/spec.md new file mode 100644 index 000000000..d80db9add --- /dev/null +++ b/specs/023-standalone-panel/spec.md @@ -0,0 +1,27 @@ +# Standalone panel and API + +## Intent + +Run the panel and API independently of the operator and agents, including with Docker Compose on a host that is not a Kubernetes cluster. Start with no registered clusters and add remote clusters explicitly. Preserve the default combined Kubernetes installation. + +## Design + +`--standalone` (`GAMEPLANE_STANDALONE=true`) selects database-backed management storage and skips Kubernetes credential discovery. The API does not register a synthetic `local` cluster. Cluster registration metadata and labelled management credentials use a narrow storage interface; the existing installation delegates that interface to Kubernetes. Standalone secrets are authenticated-encrypted with a persistent key outside the database, selected by `--panel-key-file` (`GAMEPLANE_PANEL_KEY_FILE`, default `/data/panel.key`). Backups must include both database and key. Losing or replacing a key must fail closed. + +Remote workloads remain Kubernetes resources reconciled by the remote operator. The API maintains remote connectivity status without requiring a central operator. Agent operations use each remote cluster's existing private gateway protocol. Registration and gateway credentials remain central and must not appear in response bodies, errors or logs. Modules are routed to an explicit remote cluster in standalone mode; management authentication, users, roles and installation settings remain central. + +The dashboard derives cluster choices from the API and supports zero registered clusters. It must neither fabricate a local entry nor send background inventory requests to the panel host. Authenticated installation capabilities distinguish the modes without leaking infrastructure information before login. + +Helm exposes independent operator enablement and standalone API configuration. A documented panel-only profile suppresses workload resources and local credentials. Docker Compose contains only API and web services with persistent database and key storage. Existing combined and remote operator/gateway profiles retain their behavior. + +## Acceptance and validation + +- Start without kubeconfig, Kubernetes service credentials, operator, or agent. +- Login, manage panel settings, register/remove remote clusters and survive restart. +- Empty cluster and fleet lists are successful and contain no implicit local cluster. +- Workload, module, inventory and agent requests target only explicitly selected registered clusters and preserve RBAC. +- Secret writes preserve label isolation, optimistic concurrency and encryption; malformed input fails safely. +- Chart matrix covers combined, remote-only and standalone profiles; Compose documentation includes bootstrap and persistence. +- Repository policy requires tests/lint in CI; compile locally, add regression coverage and report CI status accurately. + +Existing Kubernetes-managed registrations are not silently imported into standalone storage. Switching an existing deployment requires deliberate configuration transfer and backups. diff --git a/web/specs.md b/web/specs.md index 96383c983..7559ced51 100644 --- a/web/specs.md +++ b/web/specs.md @@ -20,6 +20,17 @@ The Gameplane dashboard is a React SPA providing a UI layer over the Gameplane A - Validate form inputs against declared server/template schema before submit - Display real-time resource status (CPU, memory, uptime) and live action metrics from RCON/agent +### Standalone panel installations + +- Authenticated `GET /admin/installation` reports `standalone` and `localCluster`. The dashboard waits for this capability response before making local infrastructure or module requests; a failed capability request does not imply a local cluster. +- Standalone `/clusters` may be empty. The UI must not synthesize a local registration or fetch local inventory, templates, or module catalogs in that state. Existing fleet pages continue to render their empty states. +- Cluster managers can register an existing workload cluster from the Clusters page using its name, display name, and self-contained kubeconfig. Registration does not install an operator or make the panel host a workload node. Removing a registration requires confirmation and leaves running workloads intact. Agent features additionally require remote gateway configuration through the documented API. +- On standalone installations, Modules and Admin → Module sources require an explicitly selected registered cluster. Catalogs, source mutations, module installation, uploads, builder installation, and deployment use that same captured target. Switching the target remounts dialogs so pending form state cannot be submitted to another cluster. +- Combined installations retain the local module catalog and local module deployment behavior. Account, authentication, configuration, registry, fleet, and sharing endpoints remain management APIs independent of workload selection. +- Standalone About and Updates sections do not query local Kubernetes information; upgrades use panel/API container images. +- Standalone System logs shows container-runtime guidance without opening a local Kubernetes log stream. Module reads and management controls check permissions against the selected workload cluster (local on combined installations); permissions granted on another cluster do not enable these controls. +- Standalone user grants require explicit remote selection, omit the local workload option, and keep the primary panel role separate. Remote cluster-wide roles may contain module read/manage and template read/write permissions as well as inventory and namespaced permissions. + ## Non-goals / Boundaries **Design-first rule:** Any change to the dashboard's visual surface (new page, new form field, layout shift, color/icon change) originates in `design.pen` (Pencil MCP server), not in React code. The Pencil file is the source of truth for all UI design; code-led redesigns are reverted. See docs/architecture.md and CLAUDE.md rule 1. diff --git a/web/src/components/ClusterSelector.test.tsx b/web/src/components/ClusterSelector.test.tsx index d107c227c..80d75af6a 100644 --- a/web/src/components/ClusterSelector.test.tsx +++ b/web/src/components/ClusterSelector.test.tsx @@ -181,17 +181,18 @@ describe("ClusterSelector", () => { renderWithQuery(); - // Should render "local" as fallback + // An empty registry must not advertise a nonexistent local cluster. await waitFor(() => { - expect(screen.getByText("local")).toBeInTheDocument(); + expect(screen.getByText("Select cluster")).toBeInTheDocument(); }); + expect(screen.queryByText("local")).not.toBeInTheDocument(); const trigger = screen.getByRole("button", { name: /select cluster/i }); await userEvent.click(trigger); // Should show a helpful message await waitFor(() => { - expect(screen.getByText("No clusters available")).toBeInTheDocument(); + expect(screen.getByRole("menuitem", { name: /Manage clusters/ })).toBeEnabled(); }); }); diff --git a/web/src/components/ClusterSelector.tsx b/web/src/components/ClusterSelector.tsx index 5161a6581..4d401e3e9 100644 --- a/web/src/components/ClusterSelector.tsx +++ b/web/src/components/ClusterSelector.tsx @@ -44,7 +44,8 @@ export function ClusterSelector() { const clusters = data?.items ?? []; const currentCluster = clusters.find((c) => c.name === currentClusterId); - const displayName = getDisplayName(currentCluster, currentClusterId); + const displayName = data && !currentCluster && currentClusterId === "local" + ? "Select cluster" : getDisplayName(currentCluster, currentClusterId); const phase = currentCluster?.phase ?? "Unknown"; const phaseColor = getPhaseColor(phase); @@ -83,8 +84,8 @@ export function ClusterSelector() { ) : clusters.length === 0 ? ( - - No clusters available + + No clusters available · Manage clusters ) : ( <> diff --git a/web/src/components/ModuleTarget.tsx b/web/src/components/ModuleTarget.tsx new file mode 100644 index 000000000..964165e33 --- /dev/null +++ b/web/src/components/ModuleTarget.tsx @@ -0,0 +1,42 @@ +import type { ReactNode } from "react"; +import { useQuery } from "@tanstack/react-query"; +import { Link } from "@tanstack/react-router"; +import { Card, CardContent } from "@heroui/react"; +import { Clusters } from "@/lib/endpoints"; +import { useCurrentCluster } from "@/lib/cluster"; +import { useInstallation } from "@/lib/useInstallation"; +import { ClusterSelector } from "@/components/ClusterSelector"; +import { ErrorCard } from "@/components/ui/ErrorCard"; +import { LoadingCard } from "@/components/ui/LoadingCard"; +import { useMe, can } from "@/lib/auth"; + +// Do not mount workload queries until the installation and target are known. +// Combined installations retain their existing local module catalog. +export function ModuleTarget({ children, loadingMessage = "Loading installation…" }: { children: (cluster: string, canManage: boolean) => ReactNode; loadingMessage?: string }) { + const installation = useInstallation(); + const identity = useMe(); + const selected = useCurrentCluster(); + const registry = useQuery({ + queryKey: ["clusters"], queryFn: () => Clusters.list(), + enabled: installation.data?.standalone === true, + }); + if (installation.isPending) return ; + if (installation.isError) return void installation.refetch()} />; + const renderTarget = (cluster: string) => identity.isPending ? + : can(identity.data, "modules:read", "*", cluster) + ? children(cluster, can(identity.data, "modules:manage", "*", cluster)) + : ; + if (!installation.data.standalone) return renderTarget("local"); + const target = registry.data?.items.find((cluster) => cluster.name === selected); + return
+ + {registry.isPending ? + : registry.isError ? void registry.refetch()} /> + : target ? renderTarget(target.name) + : +

Select a workload cluster

+

Modules and their sources are managed by the operator on a registered workload cluster.

+ Manage clusters +
} +
; +} diff --git a/web/src/components/RegisterClusterDialog.tsx b/web/src/components/RegisterClusterDialog.tsx new file mode 100644 index 000000000..917e0b6b9 --- /dev/null +++ b/web/src/components/RegisterClusterDialog.tsx @@ -0,0 +1,37 @@ +import { useState } from "react"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { Button, Input, Label, Modal, ModalBackdrop, ModalContainer, ModalDialog, ModalHeader, ModalHeading, ModalBody, ModalFooter, TextArea } from "@heroui/react"; +import { Clusters } from "@/lib/endpoints"; +import { APIError } from "@/lib/api"; + +export function RegisterClusterDialog({ open, onOpenChange }: { open: boolean; onOpenChange: (open: boolean) => void }) { + const client = useQueryClient(); + const [name, setName] = useState(""); + const [displayName, setDisplayName] = useState(""); + const [kubeconfig, setKubeconfig] = useState(""); + const register = useMutation({ + mutationFn: () => Clusters.register({ name: name.trim(), displayName: displayName.trim(), kubeconfig }), + onSuccess: async () => { + setKubeconfig(""); + onOpenChange(false); + await client.invalidateQueries({ queryKey: ["clusters"] }); + await client.invalidateQueries({ queryKey: ["fleet"] }); + }, + }); + return + + + Register cluster + +

Connect an existing workload cluster with the Gameplane operator and CRDs installed. The panel host does not become a workload node.

+
setName(event.target.value)} placeholder="production" />
+
setDisplayName(event.target.value)} />
+