diff --git a/deploy/k8s/base/control-plane-deployment.yaml b/deploy/k8s/base/control-plane-deployment.yaml new file mode 100644 index 00000000..5a88635c --- /dev/null +++ b/deploy/k8s/base/control-plane-deployment.yaml @@ -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 + diff --git a/deploy/k8s/base/kustomization.yaml b/deploy/k8s/base/kustomization.yaml new file mode 100644 index 00000000..8cdd1a18 --- /dev/null +++ b/deploy/k8s/base/kustomization.yaml @@ -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 + diff --git a/deploy/k8s/base/namespace.yaml b/deploy/k8s/base/namespace.yaml new file mode 100644 index 00000000..2e40d676 --- /dev/null +++ b/deploy/k8s/base/namespace.yaml @@ -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 + diff --git a/deploy/k8s/base/network-policies.yaml b/deploy/k8s/base/network-policies.yaml new file mode 100644 index 00000000..3188323e --- /dev/null +++ b/deploy/k8s/base/network-policies.yaml @@ -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 + diff --git a/deploy/k8s/base/rbac.yaml b/deploy/k8s/base/rbac.yaml new file mode 100644 index 00000000..f440c7ad --- /dev/null +++ b/deploy/k8s/base/rbac.yaml @@ -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 + diff --git a/deploy/k8s/base/service-accounts.yaml b/deploy/k8s/base/service-accounts.yaml new file mode 100644 index 00000000..8e701794 --- /dev/null +++ b/deploy/k8s/base/service-accounts.yaml @@ -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 + diff --git a/multiplayer-next.md b/multiplayer-next.md index f48de9ad..a4da0519 100644 --- a/multiplayer-next.md +++ b/multiplayer-next.md @@ -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. diff --git a/multiplayer-todo.md b/multiplayer-todo.md index d8020933..7706a5f5 100644 --- a/multiplayer-todo.md +++ b/multiplayer-todo.md @@ -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 diff --git a/server/security/test_kubernetes_policies.py b/server/security/test_kubernetes_policies.py new file mode 100644 index 00000000..5ee421d7 --- /dev/null +++ b/server/security/test_kubernetes_policies.py @@ -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()