feat: add Kubernetes multiplayer security baseline

This commit is contained in:
Josh Creek
2026-08-31 21:21:55 +01:00
parent 79e66c7a95
commit 88b5ffedb2
9 changed files with 236 additions and 4 deletions
@@ -0,0 +1,59 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: control-plane
labels:
app.kubernetes.io/name: control-plane
spec:
replicas: 2
selector:
matchLabels:
app.kubernetes.io/name: control-plane
template:
metadata:
labels:
app.kubernetes.io/name: control-plane
spec:
serviceAccountName: control-plane
automountServiceAccountToken: false
securityContext:
runAsNonRoot: true
runAsUser: 10001
runAsGroup: 10001
seccompProfile:
type: RuntimeDefault
containers:
- name: control-plane
image: ghcr.io/cosmic-clash/control-plane@sha256:0000000000000000000000000000000000000000000000000000000000000000
ports:
- name: http
containerPort: 8080
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop: [ALL]
resources:
requests:
cpu: 100m
memory: 128Mi
limits:
cpu: 1
memory: 512Mi
env:
- name: DATABASE_PASSWORD
valueFrom:
secretKeyRef:
name: cosmic-clash-database
key: password
- name: REDIS_PASSWORD
valueFrom:
secretKeyRef:
name: cosmic-clash-redis
key: password
- name: STEAM_PUBLISHER_KEY
valueFrom:
secretKeyRef:
name: cosmic-clash-steam
key: publisher-key
+10
View File
@@ -0,0 +1,10 @@
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
namespace: cosmic-clash
resources:
- namespace.yaml
- service-accounts.yaml
- rbac.yaml
- network-policies.yaml
- control-plane-deployment.yaml
+9
View File
@@ -0,0 +1,9 @@
apiVersion: v1
kind: Namespace
metadata:
name: cosmic-clash
labels:
pod-security.kubernetes.io/enforce: restricted
pod-security.kubernetes.io/audit: restricted
pod-security.kubernetes.io/warn: restricted
+67
View File
@@ -0,0 +1,67 @@
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: default-deny-ingress-egress
spec:
podSelector: {}
policyTypes: [Ingress, Egress]
---
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: control-plane-allowed-flows
spec:
podSelector:
matchLabels:
app.kubernetes.io/name: control-plane
policyTypes: [Ingress, Egress]
ingress:
- from:
- namespaceSelector: {}
podSelector:
matchLabels:
app.kubernetes.io/name: edge-gateway
ports:
- protocol: TCP
port: 8080
egress:
- to:
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: data
podSelector:
matchLabels:
app.kubernetes.io/name: postgres
ports:
- protocol: TCP
port: 5432
- to:
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: data
podSelector:
matchLabels:
app.kubernetes.io/name: redis
ports:
- protocol: TCP
port: 6379
- to:
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: agones-system
ports:
- protocol: TCP
port: 443
- ports:
- protocol: UDP
port: 53
- protocol: TCP
port: 53
to:
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: kube-system
podSelector:
matchLabels:
k8s-app: kube-dns
+24
View File
@@ -0,0 +1,24 @@
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
name: control-plane-agones-allocator
namespace: agones-system
rules:
- apiGroups: ["allocation.agones.dev"]
resources: ["gameserverallocations"]
verbs: ["create"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
name: cosmic-clash-control-plane-agones-allocator
namespace: agones-system
subjects:
- kind: ServiceAccount
name: control-plane
namespace: cosmic-clash
roleRef:
kind: Role
name: control-plane-agones-allocator
apiGroup: rbac.authorization.k8s.io
+14
View File
@@ -0,0 +1,14 @@
apiVersion: v1
kind: ServiceAccount
metadata:
name: control-plane
namespace: cosmic-clash
automountServiceAccountToken: false
---
apiVersion: v1
kind: ServiceAccount
metadata:
name: match-server
namespace: cosmic-clash
automountServiceAccountToken: false
+5 -3
View File
@@ -57,9 +57,11 @@ product policy are in [`docs/MATCHMAKING.md`](docs/MATCHMAKING.md).
- [x] Complete the threat model for forgery, replay, queue/flood/bot abuse,
workload/insider compromise, DDoS, supply chain and denial-of-wallet
([THREAT-MODEL.md](docs/THREAT-MODEL.md)).
- [ ] Enforce restricted workloads/RBAC/networks/private stores/backups/secrets;
isolate SDR signing behind an audited non-exportable signer and add
volumetric edge defense, WebSocket limits and overload shedding.
- [ ] **IN PROGRESS:** Enforce restricted workloads/RBAC/networks/private
stores/backups/secrets; isolate SDR signing behind an audited non-exportable
signer and add volumetric edge defense, WebSocket limits and overload
shedding. A provider-neutral restricted Kubernetes baseline and structural
policy tests now exist; live edge/data-plane controls remain.
- [ ] Pin, scan, SBOM and sign artifacts; verify signatures at admission and
document the critical vulnerability SLA.
+1 -1
View File
@@ -1184,7 +1184,7 @@ the local/CI/community transport, not a silent production fallback.
| 8.9 `[D:8.4,8.7]` | **IN PROGRESS.** Pure Go join policy binds SteamID/player/match/server/team/slot/protocol/expiry, rejects duplicate roster slots, permits same-identity reclaim through backend loss and fences prior server-owned generations | `server/domain/reconnect.go` covers SteamID/server/slot binding, expiry, repeated reclaim, grace boundary and old-generation fencing; signed token issuance/verification, persistent leases, Godot `hello` transport and production integration remain |
| 8.10 `[D:8.5,8.31]` | **IN PROGRESS.** Dependency-free workload credential policy validates an adapter-verified signature, issuer/audience/time bounds, namespace/service account, pod UID, GameServer UID, allocation ID, match ID and server ID before result submission | `server/domain/workload.go` and adversarial tests reject every binding mutation, missing/unverified signature and time boundary; `server/testkit/pipeline_test.go` carries allocation identity through the offline result path; projected-token/JWT adapter, trusted-cluster verification and live duplicate/conflict alerting remain |
| 8.11 `[D:8.1]` | **DONE.** Write the threat model: forged clients/replay/queues/results, floods/bots, pod/insider compromise, gameplay and API DDoS, SDR signing-key theft, dependencies and denial-of-wallet | [`docs/THREAT-MODEL.md`](docs/THREAT-MODEL.md) records prevention, detection/response, owner and residual risk for every threat; it separates offline CA/online signer, client/game-server/PostgreSQL/Redis trust boundaries and recovery behavior |
| 8.12 `[D:8.11]` | Harden workloads and edge: restricted containers, least RBAC, private DB/Redis, default-deny networks, backups/secrets, volumetric DDoS/WAF/origin shielding, WebSocket limits and overload shedding | Policy/network tests enforce declared flows; edge load test preserves result ingress/live matches while rejecting new work; no credential appears in Git/images/args/telemetry |
| 8.12 `[D:8.11]` | **IN PROGRESS.** Provider-neutral Kubernetes baseline enforces restricted namespace admission, non-root/read-only/no-capability workloads, separate service accounts, allocator-only RBAC, default-deny networking and explicit edge/data/DNS/Agones flows; application manifests consume externally populated Secret objects | `deploy/k8s/base/` plus `server/security/test_kubernetes_policies.py` cover the static hardening and secret-reference invariants; private-store provisioning, edge DDoS/WAF/origin shielding, WebSocket limits, overload shedding, encrypted backups and live policy/load tests remain |
| 8.13 `[D:8.12]` | Pin images by digest; generate SBOMs, scan dependencies/images, sign artifacts, verify signatures at admission and document a critical-fix SLA | CI blocks a vulnerable/disallowed or unsigned release artifact and records the exact provenance deployed |
#### 8C — Queueing, matchmaking, playlists and rating
@@ -0,0 +1,47 @@
from pathlib import Path
import re
import unittest
BASE = Path(__file__).parents[2] / "deploy" / "k8s" / "base"
class KubernetesPolicyTest(unittest.TestCase):
def read(self, name):
return (BASE / name).read_text()
def test_namespace_enforces_restricted_pod_security(self):
namespace = self.read("namespace.yaml")
for key in ("enforce", "audit", "warn"):
self.assertIn(f"pod-security.kubernetes.io/{key}: restricted", namespace)
def test_workload_is_non_root_immutable_and_unprivileged(self):
deployment = self.read("control-plane-deployment.yaml")
for required in (
"runAsNonRoot: true", "type: RuntimeDefault", "allowPrivilegeEscalation: false",
"readOnlyRootFilesystem: true", "drop: [ALL]", "resources:",
"image: ghcr.io/cosmic-clash/control-plane@sha256:",
):
self.assertIn(required, deployment)
self.assertNotRegex(deployment, r"(?im)^\s*(password|token|private.?key):\s*[^\n]+$")
self.assertIn("secretKeyRef:", deployment)
def test_rbac_is_scoped_to_allocator_create(self):
rbac = self.read("rbac.yaml")
self.assertIn("namespace: agones-system", rbac)
self.assertIn('resources: ["gameserverallocations"]', rbac)
self.assertIn('verbs: ["create"]', rbac)
self.assertNotRegex(rbac, r"verbs:.*\b(get|list|watch|update|patch|delete|\*)\b")
self.assertNotIn('resources: ["*"]', rbac)
def test_default_deny_and_only_declared_data_dns_edge_flows_exist(self):
policies = self.read("network-policies.yaml")
self.assertIn("name: default-deny-ingress-egress", policies)
self.assertIn("policyTypes: [Ingress, Egress]", policies)
for port in ("port: 8080", "port: 5432", "port: 6379", "port: 443", "port: 53"):
self.assertIn(port, policies)
self.assertNotIn("ipBlock:", policies)
if __name__ == "__main__":
unittest.main()