Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
23 changes: 17 additions & 6 deletions .github/workflows/ci.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -439,7 +439,8 @@ jobs:
needs: [changes]
if: (needs.changes.outputs.images == 'true') || contains(fromJSON(needs.changes.outputs.ci-jobs || '[]'), 'build-images')
runs-on: ubuntu-latest
timeout-minutes: 15
# Include all image builds, cache exports, artifact upload and cleanup.
timeout-minutes: 30
permissions:
contents: read
steps:
Expand Down Expand Up @@ -1232,6 +1233,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: sanity-check rendered manifests
run: |
grep -q 'kind: Deployment' /tmp/rendered.yaml
Expand Down Expand Up @@ -1793,7 +1796,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]
bucket: [operator, api-auth, api-roles, api-rbac, api-agent, api-mods, telemetry, optional-components, restricted-workloads]
arch: [amd64]
include:
- bucket: operator
Expand Down Expand Up @@ -1829,6 +1832,10 @@ jobs:
parallel: 1
test_timeout: 20m # one lifecycle test: helm upgrade, then mcp, syslog, frp tunnel and core checks in order
job_timeout: 60 # 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
env:
CLUSTER: gameplane-e2e
CTX: kind-gameplane-e2e
Expand Down Expand Up @@ -1900,7 +1907,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 @@ -1936,6 +1943,10 @@ jobs:
parallel: 1
test_timeout: 20m # one lifecycle test: helm upgrade, then mcp, syslog, frp tunnel and core checks in order
job_timeout: 60 # 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
env:
CLUSTER: gameplane-e2e
CTX: kind-gameplane-e2e
Expand Down Expand Up @@ -2660,8 +2671,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) \//.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) \//.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 @@ -2813,7 +2824,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) \//);
const m = n.match(/^e2e (operator|api-auth|api-roles|api-rbac|api-agent|api-mods|telemetry|optional-components|restricted-workloads) \//);
if (m) bucketSet.add(m[1]);
if (/^e2e multicluster \//.test(n)) bucketSet.add('multicluster');
if (/^e2e upgrade \//.test(n)) bucketSet.add('upgrade');
Expand Down
29 changes: 29 additions & 0 deletions agent/internal/rcon/nuclearoption_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1275,18 +1275,47 @@ type pipeFrameCase struct {
forbidErr string // substring the error must NOT contain (e.g. a detail suffix)
}

type responseDeadlineConn struct {
net.Conn
deadlineSet chan<- struct{}
}

func (c *responseDeadlineConn) SetDeadline(deadline time.Time) error {
if err := c.Conn.SetDeadline(deadline); err != nil {
return err
}
select {
case c.deadlineSet <- struct{}{}:
default:
}
return nil
}

// runPipeFrameCases sends each case's raw frame back to the client over net.Pipe
// and checks the body or the error.
func runPipeFrameCases(t *testing.T, tests []pipeFrameCase) {
t.Helper()
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
deadlineSet := make(chan struct{}, 1)
client := newPipeClient(t, func(remote net.Conn) {
if _, _, err := readNuclearOptionRequest(remote); err != nil {
return
}
if len(tt.frame) == 0 {
// Exec sets its response deadline after sending the request.
// Closing first can fail SetDeadline instead of the status read.
select {
case <-deadlineSet:
case <-t.Context().Done():
return
}
}
writeFrameSegments(remote, tt.frame, tt.oneByte)
})
if len(tt.frame) == 0 {
client.conn = &responseDeadlineConn{Conn: client.conn, deadlineSet: deadlineSet}
}

body, err := client.Exec("test-command")
if tt.wantErr != "" {
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 == nil || (u.Role != "admin" && !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