Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
c0e8f40
feat(security): add Restricted workloads and per-server node capture …
Baylem Oct 21, 2026
13594aa
merge: include shared web build prerequisite
Baylem Oct 21, 2026
16fdc13
fix: confine capture identity creation to its output directory
Baylem Oct 21, 2026
b029fcd
ci: expand restricted workload checks on both architectures
Baylem Oct 21, 2026
6663821
fix: align capture fixtures with isolated credentials
Baylem Oct 21, 2026
d3968bb
fix(security): preserve shared config access through restricted upgrades
Baylem Oct 22, 2026
34efe8d
fix(operator): reject restricted host ports before template admission
Baylem Oct 22, 2026
7afe9e8
fix(ci): select restricted workload tests after upstream integration
Baylem Oct 23, 2026
feca754
test: synchronize closed RCON response fixtures
Baylem Oct 23, 2026
c7d5592
test: handle explicit fixture conflicts and lost login forwards
Baylem Oct 23, 2026
62b25fc
test: verify shared PID survives restricted capture
Baylem Oct 24, 2026
0749710
fix: revalidate node diagnostic namespace authority
Baylem Oct 25, 2026
f47fb6c
chore: merge upstream namespace controls into workload security
Baylem Oct 25, 2026
af0e29f
chore: reconcile restricted workloads and refresh CI inputs
Baylem Oct 27, 2026
2e4b332
chore: reconcile restricted CRDs with upstream Tailscale schemas
Baylem Oct 28, 2026
63486f6
Merge upstream and address stopped capture review feedback
Baylem Oct 31, 2026
ce65034
merge: stack restricted workloads on stopped backup integration
Baylem Nov 4, 2026
4f07ac2
fix(security): isolate node diagnostics from every managed namespace
Baylem Nov 4, 2026
31a8851
fix(security): require diagnostics admin on selected cluster
Baylem Nov 5, 2026
91f4df6
merge: carry shared Trivy installer from stopped backups
Baylem Nov 5, 2026
3ceace2
Merge stopped-backup review fixes and compress security update
Baylem Nov 6, 2026
fbdb1c2
Merge workspace checksum for compress security update
Baylem Nov 6, 2026
ccb61eb
merge: carry bounded source admission into restricted workloads
Baylem Nov 6, 2026
fcff80f
merge: carry source-admission regression compatibility
Baylem Nov 6, 2026
bd17465
merge: carry backup source status regression fix
Baylem Nov 6, 2026
4e56aa3
merge: carry patched networking dependencies into restricted workloads
Baylem Nov 6, 2026
e4695f6
merge: normalize networking dependency checksums
Baylem Nov 6, 2026
a669276
merge: retain required workspace graph checksums
Baylem Nov 6, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 15 additions & 5 deletions .github/workflows/ci.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -1257,6 +1257,8 @@ jobs:
run: python3 charts/tests/check_gateway.py
- name: standalone panel isolation and persistence chart renders
run: python3 charts/tests/check_standalone.py
- name: workload security chart contract
run: python3 charts/tests/check_workload_security.py
- name: auxiliary resources and management placement chart renders
run: python3 charts/tests/check_workloads.py
- name: operator Secret namespace permissions and cache configuration
Expand Down Expand Up @@ -1822,7 +1824,7 @@ jobs:
# (operator does zero logins and is wait-dominated, so it runs
# wide; the api buckets stay within the login limiter's budget).
# The arm64 job below uses the same steps and per-bucket settings.
bucket: [operator, api-auth, api-roles, api-rbac, api-agent, api-mods, telemetry, optional-components, management-placement]
bucket: [operator, api-auth, api-roles, api-rbac, api-agent, api-mods, telemetry, optional-components, restricted-workloads, management-placement]
arch: [amd64]
include:
- bucket: operator
Expand Down Expand Up @@ -1862,6 +1864,10 @@ jobs:
parallel: 1
test_timeout: 35m # lifecycle plus two-GameServer TCP/UDP relay identity, outage and recovery checks
job_timeout: 75 # Boots a kind cluster, installs the chart, runs the bucket's suite.
- bucket: restricted-workloads
parallel: 1
test_timeout: 25m # Includes real projected-token rotation and node capture cleanup.
job_timeout: 60 # Boots a kind cluster, installs the chart, runs the bucket's suite.
env:
CLUSTER: gameplane-e2e
CTX: kind-gameplane-e2e
Expand Down Expand Up @@ -1935,7 +1941,7 @@ jobs:
# (operator does zero logins and is wait-dominated, so it runs
# wide; the api buckets stay within the login limiter's budget).
# Mirrors e2e-go's steps and per-bucket settings.
bucket: [operator, api-auth, api-roles, api-rbac, api-agent, api-mods, telemetry, optional-components]
bucket: [operator, api-auth, api-roles, api-rbac, api-agent, api-mods, telemetry, optional-components, restricted-workloads]
arch: [arm64]
include:
- bucket: operator
Expand Down Expand Up @@ -1971,6 +1977,10 @@ jobs:
parallel: 1
test_timeout: 35m # lifecycle plus two-GameServer TCP/UDP relay identity, outage and recovery checks
job_timeout: 75 # Boots a kind cluster, installs the chart, runs the bucket's suite.
- bucket: restricted-workloads
parallel: 1
test_timeout: 25m # Includes real projected-token rotation and node capture cleanup.
job_timeout: 60 # Boots a kind cluster, installs the chart, runs the bucket's suite.
env:
CLUSTER: gameplane-e2e
CTX: kind-gameplane-e2e
Expand Down Expand Up @@ -2704,8 +2714,8 @@ jobs:
'go-e2e-unit': (n) => n === 'go (test/e2e)',
'api-postgres': (n) => n === 'api (postgres)',
'e2e-buckets': (n) => n === 'e2e bucket coverage',
'e2e-go': (n) => /^e2e (operator|api-auth|api-roles|api-rbac|api-agent|api-mods|telemetry|optional-components|management-placement) \//.test(n) && n.includes('/ amd64 '),
'e2e-go-arm64': (n) => /^e2e (operator|api-auth|api-roles|api-rbac|api-agent|api-mods|telemetry|optional-components) \//.test(n) && n.includes('/ arm64 '),
'e2e-go': (n) => /^e2e (operator|api-auth|api-roles|api-rbac|api-agent|api-mods|telemetry|optional-components|restricted-workloads|management-placement) \//.test(n) && n.includes('/ amd64 '),
'e2e-go-arm64': (n) => /^e2e (operator|api-auth|api-roles|api-rbac|api-agent|api-mods|telemetry|optional-components|restricted-workloads) \//.test(n) && n.includes('/ arm64 '),
'e2e-multicluster': (n) => n.startsWith('e2e multicluster /') && n.includes('/ amd64 '),
'e2e-multicluster-arm64': (n) => n.startsWith('e2e multicluster /') && n.includes('/ arm64 '),
'e2e-upgrade': (n) => n.startsWith('e2e upgrade /') && n.includes('/ amd64 '),
Expand Down Expand Up @@ -2857,7 +2867,7 @@ jobs:
const bucketSet = new Set();
for (const j of apiJobs) {
const n = j.name || '';
const m = n.match(/^e2e (operator|api-auth|api-roles|api-rbac|api-agent|api-mods|telemetry|optional-components|management-placement) \//);
const m = n.match(/^e2e (operator|api-auth|api-roles|api-rbac|api-agent|api-mods|telemetry|optional-components|restricted-workloads|management-placement) \//);
if (m) bucketSet.add(m[1]);
if (/^e2e multicluster \//.test(n)) bucketSet.add('multicluster');
if (/^e2e upgrade \//.test(n)) bucketSet.add('upgrade');
Expand Down
10 changes: 10 additions & 0 deletions agent/specs.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,16 @@

## Purpose

### Kubernetes token projection (#714)

Game Pods disable automatic credentials. The agent alone mounts a rotating
projected token plus kube-root CA and namespace at client-go's standard path;
`rest.InClusterConfig` preserves `BearerTokenFile` refresh. No token is mounted
into game/config-init. Shared PID remains intentional for process metrics and
is residual in-Pod trust; mount isolation does not make the agent safe from
all processes in its Pod. CI exercises live projection rotation and a heartbeat
after the original short token expires.

The agent is a per-pod HTTP/HTTPS sidecar that runs inside every game pod to expose the game server's operations to the control plane and dashboard. It translates dashboard requests into game-protocol actions (RCON, file I/O, container logs, player queries) and reports liveness, resource usage, and player metrics back to the operator via Kubernetes API patches, decoupling game-specific behavior from the cluster control plane.

## Responsibilities
Expand Down
6 changes: 6 additions & 0 deletions api/internal/gateway/capture.go
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,12 @@ func (h *handler) serveCapture(w http.ResponseWriter, req *http.Request, target
}
defer transport.CloseIdleConnections()
host := target.Name + "-agent." + target.Namespace + ".svc.cluster.local:9091"
if name, namespace, node, err := kube.NodeCaptureService(nc); err != nil {
http.NotFound(w, req)
return
} else if node {
host = name + "-agent." + namespace + ".svc.cluster.local:9091"
}
proxy := &httputil.ReverseProxy{
Transport: transport,
Rewrite: func(pr *httputil.ProxyRequest) {
Expand Down
12 changes: 10 additions & 2 deletions api/internal/handlers/capture.go
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,8 @@ func MountCapture(r chi.Router, reg *kube.Registry, auditor *audit.Auditor, cfg
}
r.Post("/servers/{name}:capture-enable", h.captureEnable)
r.Post("/servers/{name}:capture-disable", h.captureDisable)
r.Post("/servers/{name}:capture-node-enable", h.captureNodeEnable)
r.Post("/servers/{name}:capture-node-disable", h.captureNodeDisable)
r.Post("/servers/{name}:capture-start", h.captureStart)
r.Post("/servers/{name}:capture-stop", h.captureStop)
r.Get("/servers/{name}:captures", h.captureList)
Expand Down Expand Up @@ -957,7 +959,9 @@ func (h *captureHandler) captureDelete(w http.ResponseWriter, req *http.Request)
}
deleteErr = deleteCaptureWithUID(req.Context(), k, nc)
} else {
h.deleteSidecarCaptureFile(req.Context(), gs.Name, gs.Namespace, captureID)
if nc.Annotations[kube.NodeCaptureNamespaceAnnotation] == "" {
h.deleteSidecarCaptureFile(req.Context(), gs.Name, gs.Namespace, captureID)
}
deleteErr = k.DeleteNetworkCapture(req.Context(), ns, captureID)
}

Expand Down Expand Up @@ -1099,7 +1103,11 @@ func (h *captureHandler) captureDownload(w http.ResponseWriter, req *http.Reques
if isRemoteCluster(req) {
status = h.remoteCaptureDownload(w, req, gs, nc)
} else {
status = h.proxyFileDownload(w, req, host, captureID)
if _, _, node, _ := kube.NodeCaptureService(nc); node {
status = h.proxyNodeCaptureDownload(w, req, gs, nc)
} else {
status = h.proxyFileDownload(w, req, host, captureID)
}
}
if status < http.StatusOK || (status >= http.StatusMultipleChoices && status != http.StatusNotModified) {
// A disconnect must not also erase the corrective audit outcome.
Expand Down
111 changes: 111 additions & 0 deletions api/internal/handlers/capture_node.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
package handlers

import (
"encoding/json"
"errors"
"net/http"
"net/url"

"github.com/GameplanePanel/gameplane/api/internal/auth"
"github.com/GameplanePanel/gameplane/api/internal/httperr"
"github.com/GameplanePanel/gameplane/api/internal/kube"
"github.com/GameplanePanel/gameplane/api/internal/scope"
"github.com/go-chi/chi/v5"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/types"
)

func (h *captureHandler) captureNodeEnable(w http.ResponseWriter, r *http.Request) {
h.captureNodeOptIn(w, r, true)
}
func (h *captureHandler) captureNodeDisable(w http.ResponseWriter, r *http.Request) {
h.captureNodeOptIn(w, r, false)
}

func (h *captureHandler) captureNodeOptIn(w http.ResponseWriter, req *http.Request, enabled bool) {
k, ok := resolveCluster(w, req, h.reg)
if !ok {
return
}
ns, ok := resolveNS(w, req)
if !ok {
return
}
name := chi.URLParam(req, "name")
u := auth.UserFromContext(req.Context())
if !u.Can("*", true, scope.RequestedCluster(req), "*") {
if h.auditWriteOrFail(w, req, req.Method, req.URL.Path, name, "node_diagnostics_admin_required", http.StatusForbidden) {
httperr.WriteCode(w, req, http.StatusForbidden, errors.New("node diagnostic exception requires administrator privileges"))
}
return
}
gs, err := k.GetServer(req.Context(), ns, name)
if err != nil {
httperr.Write(w, req, err)
return
}
if gs.GetDeletionTimestamp() != nil {
httperr.WriteCode(w, req, http.StatusConflict, errors.New("server is terminating"))
return
}
reason := "node_diagnostics_opt_out"
if enabled {
reason = "node_diagnostics_opt_in_host_privileges"
}
// Record intent synchronously before granting host capabilities. A failed
// audit write prevents the exception; Kubernetes status remains separate.
if !h.auditWriteOrFail(w, req, req.Method, req.URL.Path, name+":"+string(gs.GetUID()), reason, http.StatusOK) {
return
}
patch, err := json.Marshal(map[string]any{"metadata": map[string]any{"uid": string(gs.GetUID()), "resourceVersion": gs.GetResourceVersion()}, "spec": map[string]any{"capture": map[string]any{"nodeDiagnostic": enabled}}})
if err != nil {
httperr.Write(w, req, err)
return
}
_, err = k.Dynamic.Resource(kube.GVRs["servers"]).Namespace(ns).Patch(req.Context(), name, types.MergePatchType, patch, metav1.PatchOptions{})
if err != nil {
if h.auditWriteOrFail(w, req, req.Method, req.URL.Path, name, "node_diagnostics_update_failed", http.StatusInternalServerError) {
httperr.Write(w, req, err)
}
return
}
if !enabled {
if err := h.stopActiveCaptures(req.Context(), k, ns, name); err != nil {
httperr.Write(w, req, err)
return
}
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]bool{"nodeDiagnostic": enabled})
}

func (h *captureHandler) proxyNodeCaptureDownload(w http.ResponseWriter, req *http.Request, gs *kube.GameServer, nc *kube.NetworkCapture) int {
bound, err := captureTarget(scope.RequestedCluster(req), gs, nc)
if err != nil {
http.NotFound(w, req)
return http.StatusNotFound
}
name, ns, _, err := kube.NodeCaptureService(nc)
if err != nil {
httperr.Write(w, req, err)
return http.StatusInternalServerError
}
host, ok := newValidatedHost(name, ns)
if !ok || h.tlsClient == nil {
http.Error(w, "capture artifacts unavailable", http.StatusServiceUnavailable)
return http.StatusServiceUnavailable
}
u := url.URL{Scheme: "https", Host: string(host), Path: "/v1/targets/" + bound.UID + "/captures/" + bound.Capture + "/uids/" + bound.CaptureUID + "/file"}
up, err := http.NewRequestWithContext(req.Context(), http.MethodGet, u.String(), nil)
if err != nil {
httperr.Write(w, req, err)
return http.StatusInternalServerError
}
copyProxyHeaders(up.Header, req.Header)
resp, err := h.tlsClient.Do(up)
if err != nil {
return writeUpstreamError(w, req, err)
}
defer func() { _ = resp.Body.Close() }()
return streamCaptureResponse(w, req, resp, nc.Name)
}
Loading
Loading