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
13 changes: 9 additions & 4 deletions .github/workflows/ci.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -1864,8 +1864,8 @@ jobs:
job_timeout: 75
- bucket: network-isolation
parallel: 1
test_timeout: 15m
job_timeout: 60
test_timeout: 25m
job_timeout: 75
- bucket: operator
parallel: 8
test_timeout: 20m
Expand Down Expand Up @@ -1951,17 +1951,22 @@ jobs:
GAMEPLANE_E2E_PROFILE: ${{ matrix.bucket == 'wake-hostport' && 'two-node' || (matrix.bucket == 'management-placement' || matrix.bucket == 'backup-preparation') && 'management' || 'default' }}
run: ./deploy/kind/e2e.sh up "$CLUSTER" e2e "$GAMEPLANE_E2E_PROFILE"
- name: install real monitoring fixture
if: matrix.bucket == 'monitoring' && env.RUN_REGEX != ''
if: (matrix.bucket == 'monitoring' || matrix.bucket == 'network-isolation') && env.RUN_REGEX != ''
run: bash deploy/kind/monitoring.sh
- name: run go e2e (${{ matrix.bucket }})
if: env.RUN_REGEX != ''
env:
GAMEPLANE_RECOVERY_REPORT_DIR: ${{ github.workspace }}/recovery-reports
E2E_BUCKET: ${{ matrix.bucket }}
run: |
prefix=()
if [[ "$E2E_BUCKET" == network-isolation ]]; then
prefix=(bash ../../deploy/kind/with-network-peers.sh)
fi
cd test/e2e && \
GAMEPLANE_E2E_REUSE_CLUSTER=1 \
GAMEPLANE_E2E_CLUSTER="$CLUSTER" \
go test -tags=e2e -timeout ${{ matrix.test_timeout }} -v \
"${prefix[@]}" go test -tags=e2e -timeout ${{ matrix.test_timeout }} -v \
-parallel ${{ matrix.parallel }} -run "$RUN_REGEX" ./...
- name: upload measured panel recovery report
if: always() && matrix.bucket == 'recovery'
Expand Down
5 changes: 5 additions & 0 deletions changelog/unreleased/894-tenant-network-policies.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
# Tenant network isolation

The opt-in `networkPolicies.tenantIsolation` profile restricts traffic between game
workloads in managed namespaces. The monitoring guide now distinguishes per-server probe
ingress from the legacy kubelet rule. [PR #894](https://github.com/GameplanePanel/Gameplane/pull/894)
47 changes: 32 additions & 15 deletions charts/gameplane/templates/networkpolicies.yaml
Original file line number Diff line number Diff line change
@@ -1,13 +1,20 @@
{{- if (eq (include "gameplane.operatorEnabled" .) "true") }}
{{- if .Values.networkPolicies.enabled }}
{{- $namespaces := list .Values.gamesNamespace }}
{{- if .Values.networkPolicies.tenantIsolation }}
{{- $namespaces = include "gameplane.operatorGameNamespaces" . | fromJsonArray }}
{{- end }}
{{- range $namespace := $namespaces }}
{{- with $ }}
---
# Default-deny ingress in the games namespace. Individual GameServers
# open only the ports declared in their template (and whatever the
# operator surfaces via the backing Service / Ingress).
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: default-deny-ingress
namespace: {{ .Values.gamesNamespace }}
namespace: {{ $namespace }}
labels: {{- include "gameplane.labels" . | nindent 4 }}
spec:
podSelector: {}
Expand All @@ -23,7 +30,7 @@ apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: default-deny-egress
namespace: {{ .Values.gamesNamespace }}
namespace: {{ $namespace }}
labels: {{- include "gameplane.labels" . | nindent 4 }}
spec:
podSelector: {}
Expand All @@ -33,6 +40,10 @@ spec:
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: kube-system
{{- if .Values.networkPolicies.tenantIsolation }}
podSelector:
matchLabels: { k8s-app: kube-dns }
{{- end }}
ports:
- { protocol: UDP, port: 53 }
- { protocol: TCP, port: 53 }
Expand All @@ -47,7 +58,7 @@ apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: allow-agent-to-apiserver
namespace: {{ .Values.gamesNamespace }}
namespace: {{ $namespace }}
labels: {{- include "gameplane.labels" . | nindent 4 }}
spec:
podSelector:
Expand Down Expand Up @@ -78,7 +89,7 @@ apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: allow-api-to-agent
namespace: {{ .Values.gamesNamespace }}
namespace: {{ $namespace }}
labels: {{- include "gameplane.labels" . | nindent 4 }}
spec:
podSelector:
Expand Down Expand Up @@ -110,7 +121,7 @@ apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: allow-api-to-capture
namespace: {{ .Values.gamesNamespace }}
namespace: {{ $namespace }}
labels: {{- include "gameplane.labels" . | nindent 4 }}
spec:
podSelector:
Expand Down Expand Up @@ -150,7 +161,7 @@ apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: allow-prometheus-to-agent
namespace: {{ $.Values.gamesNamespace }}
namespace: {{ $namespace }}
labels: {{- include "gameplane.labels" $ | nindent 4 }}
spec:
podSelector:
Expand All @@ -168,7 +179,7 @@ apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: allow-prometheus-to-tunnel
namespace: {{ $.Values.gamesNamespace }}
namespace: {{ $namespace }}
labels: {{- include "gameplane.labels" $ | nindent 4 }}
spec:
podSelector:
Expand Down Expand Up @@ -199,7 +210,7 @@ apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: allow-kubelet-probes
namespace: {{ .Values.gamesNamespace }}
namespace: {{ $namespace }}
labels: {{- include "gameplane.labels" . | nindent 4 }}
spec:
podSelector:
Expand Down Expand Up @@ -237,7 +248,7 @@ apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: allow-game-public-egress
namespace: {{ .Values.gamesNamespace }}
namespace: {{ $namespace }}
labels: {{- include "gameplane.labels" . | nindent 4 }}
spec:
podSelector:
Expand Down Expand Up @@ -279,7 +290,7 @@ apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: allow-backup-restore-egress
namespace: {{ .Values.gamesNamespace }}
namespace: {{ $namespace }}
labels: {{- include "gameplane.labels" . | nindent 4 }}
spec:
podSelector:
Expand All @@ -288,7 +299,9 @@ spec:
policyTypes: [Egress]
egress:
- to:
- ipBlock: { cidr: 0.0.0.0/0 }
{{- range ($backupEgress.toCIDRs | default (list "0.0.0.0/0")) }}
- ipBlock: { cidr: {{ . | quote }} }
{{- end }}
ports:
{{- range $backupEgress.ports }}
- { protocol: {{ .protocol }}, port: {{ .port }} }
Expand All @@ -306,7 +319,7 @@ apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: allow-sentinel-ingress
namespace: {{ .Values.gamesNamespace }}
namespace: {{ $namespace }}
labels: {{- include "gameplane.labels" . | nindent 4 }}
spec:
podSelector:
Expand All @@ -332,7 +345,7 @@ apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: allow-sentinel-to-apiserver
namespace: {{ .Values.gamesNamespace }}
namespace: {{ $namespace }}
labels: {{- include "gameplane.labels" . | nindent 4 }}
spec:
podSelector:
Expand All @@ -356,6 +369,7 @@ spec:
- { protocol: TCP, port: 443 }
- { protocol: TCP, port: 6443 }
---
{{- if not .Values.networkPolicies.tenantIsolation }}
# Legacy sentinel proxy allowance, ingress half. This grants ALL ports between
# non-isolated sentinels and games. NetworkPolicies are additive; per-server
# player ingress does not narrow it. Opted-in templates use operator policies.
Expand All @@ -369,7 +383,7 @@ apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: allow-sentinel-to-game
namespace: {{ .Values.gamesNamespace }}
namespace: {{ $namespace }}
labels: {{- include "gameplane.labels" . | nindent 4 }}
spec:
podSelector:
Expand Down Expand Up @@ -397,7 +411,7 @@ apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: allow-sentinel-to-game-egress
namespace: {{ .Values.gamesNamespace }}
namespace: {{ $namespace }}
labels: {{- include "gameplane.labels" . | nindent 4 }}
spec:
podSelector:
Expand All @@ -415,5 +429,8 @@ spec:
- { key: gameplane.local/sentinel-isolation, operator: NotIn, values: ["true"] }
# Intentionally unrestricted for templates that have not opted in.
{{- end }}
{{- end }}
{{- end }}
{{- end }}

{{- end }}
3 changes: 3 additions & 0 deletions charts/gameplane/templates/operator.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -335,6 +335,9 @@ spec:
- --tunnel-frp-image={{ include "gameplane.tunnelFrpImage" . }}
- --tunnel-tailscale-image={{ include "gameplane.tunnelTailscaleImage" . }}
- --tunnel-playit-image={{ include "gameplane.tunnelPlayitImage" . }}
{{- if .Values.networkPolicies.tenantIsolation }}
- --restrict-tunnel-egress
{{- end }}
{{- if .Values.operator.configInitImage }}
- --config-init-image={{ .Values.operator.configInitImage }}
{{- end }}
Expand Down
40 changes: 35 additions & 5 deletions charts/gameplane/templates/validate.yaml
Original file line number Diff line number Diff line change
@@ -1,4 +1,26 @@
{{- $network := .Values.networkPolicies | default dict }}
{{- if $network.tenantIsolation }}
{{- if or (not $network.strict) (not ($network.probeIngress | default dict).enabled) $network.probePorts }}
{{- fail "networkPolicies.tenantIsolation requires strict=true, probeIngress.enabled=true and empty shared probePorts" }}
{{- end }}
{{- if ($network.gameEgress | default dict).enabled }}
{{- fail "networkPolicies.tenantIsolation requires gameEgress.enabled=false; grant approved game downloads with destination-specific policies" }}
{{- end }}
{{- range $cidr := concat ($network.apiServerCIDRs | default list) ($network.kubeletCIDRs | default list) }}
{{- if not (regexMatch "(/32|/128)$" (toString $cidr)) }}
{{- fail "networkPolicies.tenantIsolation requires individual API and kubelet host CIDRs" }}
{{- end }}
{{- end }}
{{- $backup := $network.backupEgress | default (dict "enabled" true) }}
{{- if and $backup.enabled (or (not $backup.ports) (not $backup.toCIDRs)) }}
{{- fail "networkPolicies.tenantIsolation requires explicit backupEgress.toCIDRs and ports, or backupEgress.enabled=false" }}
{{- end }}
{{- range $backup.toCIDRs }}
{{- if hasSuffix "/0" (toString .) }}
{{- fail "networkPolicies.tenantIsolation forbids unrestricted backup destinations" }}
{{- end }}
{{- end }}
{{- end }}
{{- if $network.strict }}
{{- if not $network.enabled }}
{{- fail "networkPolicies.strict requires networkPolicies.enabled=true" }}
Expand All @@ -17,16 +39,24 @@
{{- $ipv4 := printf "^%s(\\.%s){3}/([0-9]|[12][0-9]|3[0-2])$" $octet $octet }}
{{- $h := "[0-9A-Fa-f]{1,4}" }}
{{- $ipv6 := printf "^((%s:){7}%s|(%s:){1,7}:|(%s:){1,6}:%s|(%s:){1,5}(:%s){1,2}|(%s:){1,4}(:%s){1,3}|(%s:){1,3}(:%s){1,4}|(%s:){1,2}(:%s){1,5}|%s:((:%s){1,6})|:((:%s){1,7}|:))/([0-9]|[1-9][0-9]|1[01][0-9]|12[0-8])$" $h $h $h $h $h $h $h $h $h $h $h $h $h $h $h $h }}
{{- range $key := list "kubeletCIDRs" "apiServerCIDRs" }}
{{- range (index $network $key) }}
{{- $cidrLists := dict "kubeletCIDRs" $network.kubeletCIDRs "apiServerCIDRs" $network.apiServerCIDRs }}
{{- $portLists := dict "probePorts" $network.probePorts }}
{{- if and $network.tenantIsolation ($network.backupEgress | default dict).enabled }}
{{- $_ := set $cidrLists "backupEgress.toCIDRs" $network.backupEgress.toCIDRs }}
{{- $_ := set $portLists "backupEgress.ports" $network.backupEgress.ports }}
{{- end }}
{{- range $key, $entries := $cidrLists }}
{{- range $entries }}
{{- if or (not (kindIs "string" .)) (not (or (regexMatch $ipv4 (toString .)) (regexMatch $ipv6 (toString .)))) }}
{{- fail (printf "networkPolicies.%s entries must be explicit IPv4/IPv6 CIDRs" $key) }}
{{- end }}
{{- end }}
{{- end }}
{{- range $network.probePorts }}
{{- if or (not (has (.protocol | default "") (list "TCP" "UDP" "SCTP"))) (not (regexMatch "^[0-9]+$" (toString .port))) (lt (int .port) 1) (gt (int .port) 65535) }}
{{- fail "networkPolicies.probePorts requires protocol TCP/UDP/SCTP and an integer port from 1 to 65535" }}
{{- range $key, $entries := $portLists }}
{{- range $entries }}
{{- if or (not (kindIs "map" .)) (not (has (.protocol | default "") (list "TCP" "UDP" "SCTP"))) (not (regexMatch "^[0-9]+$" (toString .port))) (lt (int .port) 1) (gt (int .port) 65535) }}
{{- fail (printf "networkPolicies.%s requires protocol TCP/UDP/SCTP and an integer port from 1 to 65535" $key) }}
{{- end }}
{{- end }}
{{- end }}
{{- end }}
Expand Down
8 changes: 8 additions & 0 deletions charts/gameplane/values.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -595,6 +595,10 @@ networkPolicies:
# Fail closed unless actual kubelet source CIDRs/probe ports and apiserver
# endpoint CIDRs are supplied. Defaults retain the legacy broad allowances.
strict: false
# Opt in only after provisioning each managed namespace and its provider
# egress. Requires strict endpoints, per-server probes, and explicit backup
# destinations. See the tenant network profile in docs/security.md.
tenantIsolation: false
# CIDRs that hold kubelet source IPs for liveness/readiness probes.
# If unset, the chart falls back to RFC1918 + link-local ranges,
# which cover most clusters without opening the public internet.
Expand Down Expand Up @@ -648,6 +652,10 @@ networkPolicies:
# store) as well as public.
backupEgress:
enabled: true
# Explicit repository destinations. The compatibility profile defaults
# to any IPv4 address on the configured ports. Tenant isolation requires
# a nonempty list (or backupEgress.enabled=false and administrator policies).
toCIDRs: []
# Ports opened for restic repository connections. Defaults cover
# HTTPS-based backends (S3, B2, REST-over-TLS, Swift, Azure, GCS)
# and SFTP. Add your repository's port if it uses neither, e.g. a
Expand Down
26 changes: 26 additions & 0 deletions charts/tests/check_network_isolation.py
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,32 @@ def main():
"NetworkPolicy", "allow-kubelet-probes")["spec"]["ingress"][0]
assert extra["ports"] == [{"protocol": "TCP", "port": 9000}]

tenant = copy.deepcopy(strict)
tenant["operator"] = {"gameNamespaces": ["tenant-a", "tenant-a"]}
tenant["networkPolicies"].update({
"tenantIsolation": True,
"gameEgress": {"enabled": False},
"backupEgress": {"enabled": True, "toCIDRs": ["192.0.2.3/32"],
"ports": [{"protocol": "TCP", "port": 443}]},
})
policies = [o for o in render(tenant) if o.get("kind") == "NetworkPolicy"]
for ns in ("gameplane-games", "tenant-a"):
local = [p for p in policies if p["metadata"]["namespace"] == ns]
names = [p["metadata"]["name"] for p in local]
assert len(names) == len(set(names))
assert {"default-deny-ingress", "default-deny-egress", "allow-agent-to-apiserver"} <= set(names)
assert not {"allow-sentinel-to-game", "allow-sentinel-to-game-egress", "allow-kubelet-probes"} & set(names)
backup = one(local, "NetworkPolicy", "allow-backup-restore-egress")
assert backup["spec"]["egress"] == [{"to": [{"ipBlock": {"cidr": "192.0.2.3/32"}}],
"ports": [{"protocol": "TCP", "port": 443}]}]
for field, invalid_value, failure in (("ports", [{}], "integer port"),
("ports", [{"protocol": "TCP", "port": 0}], "integer port"),
("toCIDRs", ["192.0.2.999/32"], "must be explicit"),
("toCIDRs", ["::/0"], "unrestricted backup")):
invalid = copy.deepcopy(tenant)
invalid["networkPolicies"]["backupEgress"][field] = invalid_value
render(invalid, failure=failure)


if __name__ == "__main__":
main()
48 changes: 48 additions & 0 deletions deploy/kind/with-network-peers.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
#!/usr/bin/env bash
# Run the Cilium bucket with reachable, off-cluster endpoints on kind's bridge.
set -euo pipefail
node="$(kind get nodes --name "${CLUSTER:?}" | head -n1)"
[[ -n "$node" && "$(docker inspect --format '{{index .Config.Labels "io.x-k8s.kind.cluster"}}' "$node")" == "$CLUSTER" ]]
scratch="$(mktemp -d)"
peer="" repo="" route_added=false
metadata=169.254.169.253
cleanup() {
if [[ "$route_added" == true ]]; then docker exec "$node" ip route del "$metadata/32" via "$GAMEPLANE_NET_PEER_IP" || true; fi
if [[ -n "$repo" ]]; then docker rm -f "$repo" >/dev/null || true; fi
if [[ -n "$peer" ]]; then docker rm -f "$peer" >/dev/null || true; fi
rm -rf "$scratch"
}
trap cleanup EXIT
openssl req -x509 -newkey rsa:2048 -nodes -days 1 -subj /CN=kind-private-fixture \
-keyout "$scratch/tls.key" -out "$scratch/tls.crt" >/dev/null 2>&1
cat >"$scratch/listen.py" <<'PY'
import http.server, ssl, threading

class Handler(http.server.BaseHTTPRequestHandler):
def do_GET(self):
self.send_response(200)
self.end_headers()
self.wfile.write(b"isolated-kind-peer\n")

metadata = http.server.ThreadingHTTPServer(("169.254.169.253", 80), Handler)
threading.Thread(target=metadata.serve_forever, daemon=True).start()
tls = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
tls.load_cert_chain("/fixture/tls.crt", "/fixture/tls.key")
https = http.server.ThreadingHTTPServer(("0.0.0.0", 443), Handler)
https.socket = tls.wrap_socket(https.socket, server_side=True)
https.serve_forever()
PY
peer="$(docker run --detach --network kind --cap-add NET_ADMIN --volume "$scratch:/fixture:ro" \
python:3.14-alpine sh -ec 'ip addr add 169.254.169.253/32 dev lo; exec python /fixture/listen.py')"
GAMEPLANE_NET_PEER_IP="$(docker inspect --format '{{(index .NetworkSettings.Networks "kind").IPAddress}}' "$peer")"
[[ -n "$GAMEPLANE_NET_PEER_IP" ]]
# Route only this alternate metadata address inside the disposable node.
# The real cloud metadata address is never used.
docker exec "$node" ip route add "$metadata/32" via "$GAMEPLANE_NET_PEER_IP"
route_added=true
repo="$(docker run --detach --network "container:$peer" --entrypoint rest-server \
restic/rest-server:0.14.0 --listen :8000 --no-auth --path /tmp/repository)"
GAMEPLANE_NET_NODE_IP="$(docker inspect --format '{{(index .NetworkSettings.Networks "kind").IPAddress}}' "$node")"
export GAMEPLANE_NET_PEER_IP GAMEPLANE_NET_NODE_IP
export GAMEPLANE_NET_PEER_CONTAINER="$peer"
"$@"
Loading
Loading