357 lines
14 KiB
Go
357 lines
14 KiB
Go
package platform
|
|
|
|
import (
|
|
"testing"
|
|
|
|
"felis.lolicon.best/internal/apis/felis/v1alpha1"
|
|
"felis.lolicon.best/internal/operator"
|
|
networkingv1 "k8s.io/api/networking/v1"
|
|
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
|
"k8s.io/apimachinery/pkg/labels"
|
|
)
|
|
|
|
func npByName(t *testing.T, nps []*networkingv1.NetworkPolicy, name string) *networkingv1.NetworkPolicy {
|
|
t.Helper()
|
|
for _, np := range nps {
|
|
if np.Name == name {
|
|
return np
|
|
}
|
|
}
|
|
t.Fatalf("network policy %q not found", name)
|
|
return nil
|
|
}
|
|
|
|
// TestServerSelector_MatchesOperatorLabels proves the fence selects exactly the
|
|
// pods the operator labels. If the operator ever renamed its label values the
|
|
// policy would silently stop protecting the pods — this couples the two.
|
|
func TestServerSelector_MatchesOperatorLabels(t *testing.T) {
|
|
sel := serverPodSelector()
|
|
want := map[string]string{
|
|
v1alpha1.LabelManagedBy: operator.ManagedByValue,
|
|
v1alpha1.LabelComponent: operator.ComponentValue,
|
|
}
|
|
if len(sel.MatchLabels) != len(want) {
|
|
t.Fatalf("selector has %d labels, want %d", len(sel.MatchLabels), len(want))
|
|
}
|
|
for k, v := range want {
|
|
if sel.MatchLabels[k] != v {
|
|
t.Errorf("selector[%q] = %q, want %q", k, sel.MatchLabels[k], v)
|
|
}
|
|
}
|
|
// Guard against the values being empty (a typo making the selector match-all-ish).
|
|
if operator.ManagedByValue == "" || operator.ComponentValue == "" {
|
|
t.Fatal("operator label values must be non-empty")
|
|
}
|
|
}
|
|
|
|
// TestDefaultDeny enforces the baseline: select all pods, declare Ingress policy,
|
|
// admit nothing.
|
|
func TestDefaultDeny(t *testing.T) {
|
|
nps := MinecraftNetworkPolicies(testParams())
|
|
dd := npByName(t, nps, "felis-default-deny-ingress")
|
|
|
|
if len(dd.Spec.PodSelector.MatchLabels) != 0 || len(dd.Spec.PodSelector.MatchExpressions) != 0 {
|
|
t.Error("default-deny must select ALL pods (empty podSelector)")
|
|
}
|
|
if !hasPolicyType(dd, networkingv1.PolicyTypeIngress) {
|
|
t.Error("default-deny must declare the Ingress policy type")
|
|
}
|
|
if len(dd.Spec.Ingress) != 0 {
|
|
t.Error("default-deny must have NO ingress rules (deny all)")
|
|
}
|
|
}
|
|
|
|
// TestAllowRcon enforces the AND-semantics control-plane peer on port 25575.
|
|
func TestAllowRcon(t *testing.T) {
|
|
nps := MinecraftNetworkPolicies(testParams())
|
|
rcon := npByName(t, nps, "felis-allow-rcon-from-control-plane")
|
|
|
|
// Selects server pods, not all pods.
|
|
if rcon.Spec.PodSelector.MatchLabels[v1alpha1.LabelComponent] != operator.ComponentValue {
|
|
t.Error("rcon policy must select server pods")
|
|
}
|
|
if len(rcon.Spec.Ingress) != 1 {
|
|
t.Fatalf("rcon policy must have exactly 1 ingress rule, got %d", len(rcon.Spec.Ingress))
|
|
}
|
|
rule := rcon.Spec.Ingress[0]
|
|
|
|
// Exactly one peer, combining BOTH selectors (intersection = AND), never two
|
|
// peers (which would be a union/OR — far wider).
|
|
if len(rule.From) != 1 {
|
|
t.Fatalf("rcon peer count = %d, want 1 (AND-combined); 2 would be OR semantics", len(rule.From))
|
|
}
|
|
peer := rule.From[0]
|
|
if peer.NamespaceSelector == nil || peer.PodSelector == nil {
|
|
t.Fatal("rcon peer must set BOTH namespaceSelector AND podSelector")
|
|
}
|
|
if got := peer.NamespaceSelector.MatchLabels["kubernetes.io/metadata.name"]; got != "felis" {
|
|
t.Errorf("rcon namespaceSelector = %q, want control ns felis", got)
|
|
}
|
|
if peer.PodSelector.MatchLabels[LabelPartOf] != controlPlanePartOf {
|
|
t.Error("rcon podSelector must require part-of=felis-control-plane")
|
|
}
|
|
|
|
// Component must be In {api, operator} — and NOT include reaper.
|
|
var compReq *metav1.LabelSelectorRequirement
|
|
for i := range peer.PodSelector.MatchExpressions {
|
|
if peer.PodSelector.MatchExpressions[i].Key == LabelComponent {
|
|
compReq = &peer.PodSelector.MatchExpressions[i]
|
|
}
|
|
}
|
|
if compReq == nil {
|
|
t.Fatal("rcon podSelector must constrain the component label")
|
|
}
|
|
if compReq.Operator != metav1.LabelSelectorOpIn {
|
|
t.Errorf("component requirement operator = %q, want In", compReq.Operator)
|
|
}
|
|
if !contains(compReq.Values, ComponentAPI) || !contains(compReq.Values, ComponentOperator) {
|
|
t.Errorf("component values = %v, want both api and operator", compReq.Values)
|
|
}
|
|
if contains(compReq.Values, ComponentReaper) {
|
|
t.Error("reaper must NOT be allowed to reach RCON")
|
|
}
|
|
|
|
// Port = the operator's default RCON port (single source of truth, so the
|
|
// prober can reach a default-port server through the fence). Pinned to the
|
|
// concrete 25575 too, guarding an accidental change to the operator default.
|
|
if operator.DefaultRconPort != 25575 {
|
|
t.Errorf("operator.DefaultRconPort = %d, want 25575 (conventional RCON port)", operator.DefaultRconPort)
|
|
}
|
|
assertSinglePort(t, rule.Ports, int(operator.DefaultRconPort))
|
|
}
|
|
|
|
// TestAllowGame_WithCIDRs renders the ipBlock allow path on port 25565.
|
|
func TestAllowGame_WithCIDRs(t *testing.T) {
|
|
p := testParams()
|
|
p.VelocityCIDRs = []string{"10.0.0.5/32", "10.0.0.6/32"}
|
|
game := npByName(t, MinecraftNetworkPolicies(p), "felis-allow-game-from-velocity")
|
|
|
|
if len(game.Spec.Ingress) != 1 {
|
|
t.Fatalf("game policy must have 1 ingress rule, got %d", len(game.Spec.Ingress))
|
|
}
|
|
rule := game.Spec.Ingress[0]
|
|
if len(rule.From) != 2 {
|
|
t.Fatalf("game peers = %d, want 2 ipBlocks", len(rule.From))
|
|
}
|
|
for i, peer := range rule.From {
|
|
if peer.IPBlock == nil {
|
|
t.Errorf("game peer %d must be an ipBlock (Velocity is off-cluster, not a pod)", i)
|
|
}
|
|
if peer.PodSelector != nil || peer.NamespaceSelector != nil {
|
|
t.Errorf("game peer %d must NOT use pod/namespace selectors", i)
|
|
}
|
|
}
|
|
if rule.From[0].IPBlock.CIDR != "10.0.0.5/32" {
|
|
t.Errorf("game cidr[0] = %q", rule.From[0].IPBlock.CIDR)
|
|
}
|
|
if game.Spec.PodSelector.MatchLabels[v1alpha1.LabelComponent] != operator.ComponentValue {
|
|
t.Error("game policy must select server pods")
|
|
}
|
|
assertSinglePort(t, rule.Ports, int(operator.GamePort))
|
|
}
|
|
|
|
// TestAllowGame_FailsClosed is the footgun guard: no VelocityCIDRs ⇒ a policy that
|
|
// admits NOBODY (no ingress rule), never an empty-From rule (which K8s reads as
|
|
// allow-all).
|
|
func TestAllowGame_FailsClosed(t *testing.T) {
|
|
p := testParams()
|
|
p.VelocityCIDRs = nil
|
|
game := npByName(t, MinecraftNetworkPolicies(p), "felis-allow-game-from-velocity")
|
|
|
|
if len(game.Spec.Ingress) != 0 {
|
|
t.Fatalf("game policy with no CIDRs must have ZERO ingress rules (fail-closed), got %d", len(game.Spec.Ingress))
|
|
}
|
|
// Still a valid, selecting policy (so it actively denies, paired with default-deny).
|
|
if game.Spec.PodSelector.MatchLabels[v1alpha1.LabelComponent] != operator.ComponentValue {
|
|
t.Error("game policy must still select server pods even when fail-closed")
|
|
}
|
|
if !hasPolicyType(game, networkingv1.PolicyTypeIngress) {
|
|
t.Error("game policy must declare Ingress policy type")
|
|
}
|
|
}
|
|
|
|
func hasPolicyType(np *networkingv1.NetworkPolicy, pt networkingv1.PolicyType) bool {
|
|
for _, t := range np.Spec.PolicyTypes {
|
|
if t == pt {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func assertSinglePort(t *testing.T, ports []networkingv1.NetworkPolicyPort, want int) {
|
|
t.Helper()
|
|
if len(ports) != 1 {
|
|
t.Fatalf("expected exactly 1 port, got %d", len(ports))
|
|
}
|
|
if ports[0].Port == nil || ports[0].Port.IntValue() != want {
|
|
t.Errorf("port = %v, want %d", ports[0].Port, want)
|
|
}
|
|
if ports[0].Protocol == nil || *ports[0].Protocol != "TCP" {
|
|
t.Errorf("protocol = %v, want TCP", ports[0].Protocol)
|
|
}
|
|
}
|
|
|
|
// TestServerEgress_OnlyDNSAndPublicInternet pins the egress fence on game server
|
|
// pods: DNS by port, the public internet by address, and every private range
|
|
// carved out of it — the pod and Service CIDRs, the node's LAN, metadata.
|
|
func TestServerEgress_OnlyDNSAndPublicInternet(t *testing.T) {
|
|
p := testParams()
|
|
p.ServerEgressDenyCIDRs = []string{"203.0.113.7/32", "2001:db8::1/128"}
|
|
p.ServerEgressAllowCIDRs = []string{"10.9.8.0/24"}
|
|
eg := npByName(t, ServerEgressPolicies(p), "felis-server-egress")
|
|
|
|
if !hasPolicyType(eg, networkingv1.PolicyTypeEgress) || hasPolicyType(eg, networkingv1.PolicyTypeIngress) {
|
|
t.Fatalf("server egress policy types = %v, want Egress only (ingress stays with the default-deny set)", eg.Spec.PolicyTypes)
|
|
}
|
|
if !selectorEquals(eg.Spec.PodSelector, serverPodSelector()) {
|
|
t.Errorf("server egress selects %v, want every server pod", eg.Spec.PodSelector)
|
|
}
|
|
if len(eg.Spec.Egress) != 2 {
|
|
t.Fatalf("egress rules = %d, want DNS + internet", len(eg.Spec.Egress))
|
|
}
|
|
dns := eg.Spec.Egress[0]
|
|
if len(dns.To) != 1 || dns.To[0].PodSelector == nil || dns.To[0].PodSelector.MatchLabels["k8s-app"] != "kube-dns" || len(dns.Ports) != 2 {
|
|
t.Errorf("DNS rule = %+v, want port 53 udp+tcp to the cluster DNS pods", dns)
|
|
}
|
|
for _, port := range dns.Ports {
|
|
if port.Port == nil || port.Port.IntValue() != 53 {
|
|
t.Errorf("DNS rule port = %v, want 53", port.Port)
|
|
}
|
|
}
|
|
|
|
net := eg.Spec.Egress[1]
|
|
if len(net.Ports) != 0 {
|
|
t.Errorf("internet rule must not be port-restricted, got %v", net.Ports)
|
|
}
|
|
blocks := map[string][]string{}
|
|
for _, peer := range net.To {
|
|
if peer.IPBlock == nil || peer.PodSelector != nil || peer.NamespaceSelector != nil {
|
|
t.Fatalf("internet rule peer %+v must be a bare ipBlock", peer)
|
|
}
|
|
blocks[peer.IPBlock.CIDR] = peer.IPBlock.Except
|
|
}
|
|
for _, want := range []string{"10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16", "100.64.0.0/10", "169.254.0.0/16", "127.0.0.0/8", "203.0.113.7/32"} {
|
|
if !contains(blocks["0.0.0.0/0"], want) {
|
|
t.Errorf("0.0.0.0/0 except = %v, missing %s", blocks["0.0.0.0/0"], want)
|
|
}
|
|
}
|
|
for _, want := range []string{"fc00::/7", "fe80::/10", "::1/128", "2001:db8::1/128"} {
|
|
if !contains(blocks["::/0"], want) {
|
|
t.Errorf("::/0 except = %v, missing %s", blocks["::/0"], want)
|
|
}
|
|
}
|
|
if except, ok := blocks["10.9.8.0/24"]; !ok || len(except) != 0 {
|
|
t.Errorf("allow CIDR 10.9.8.0/24 must be its own peer, blocks=%v", blocks)
|
|
}
|
|
if len(blocks) != 3 {
|
|
t.Errorf("internet rule peers = %v, want v4 + v6 + one allow CIDR", blocks)
|
|
}
|
|
}
|
|
|
|
// TestLoginToInternalAPI_SelectsOnlyTheSystemLoginPod pins the one hole in the
|
|
// server egress fence: felis-api's internal face on 8081, for the pod that is both
|
|
// named login AND carries the setup-owned system-role label.
|
|
func TestLoginToInternalAPI_SelectsOnlyTheSystemLoginPod(t *testing.T) {
|
|
p := testParams().withDefaults()
|
|
np := npByName(t, ServerEgressPolicies(p), "felis-login-to-internal-api")
|
|
sel, err := metav1.LabelSelectorAsSelector(&np.Spec.PodSelector)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
server := map[string]string{
|
|
v1alpha1.LabelManagedBy: operator.ManagedByValue,
|
|
v1alpha1.LabelComponent: operator.ComponentValue,
|
|
}
|
|
with := func(extra map[string]string) labels.Set {
|
|
m := labels.Set{}
|
|
for k, v := range server {
|
|
m[k] = v
|
|
}
|
|
for k, v := range extra {
|
|
m[k] = v
|
|
}
|
|
return m
|
|
}
|
|
if !sel.Matches(with(map[string]string{v1alpha1.LabelServer: "login", v1alpha1.LabelSystemRole: "login"})) {
|
|
t.Error("the system login pod must match")
|
|
}
|
|
for name, l := range map[string]map[string]string{
|
|
"user server": {v1alpha1.LabelServer: "survival"},
|
|
"login name without the role": {v1alpha1.LabelServer: "login"},
|
|
"role label on another server": {v1alpha1.LabelServer: "survival", v1alpha1.LabelSystemRole: "login"},
|
|
"lobby": {v1alpha1.LabelServer: "lobby", v1alpha1.LabelSystemRole: "lobby"},
|
|
} {
|
|
if sel.Matches(with(l)) {
|
|
t.Errorf("%s must not reach felis-api's internal face", name)
|
|
}
|
|
}
|
|
|
|
if len(np.Spec.Egress) != 1 || len(np.Spec.Egress[0].To) != 1 {
|
|
t.Fatalf("login egress shape = %+v, want one rule with one peer", np.Spec.Egress)
|
|
}
|
|
rule := np.Spec.Egress[0]
|
|
assertSinglePort(t, rule.Ports, int(apiInternalPort))
|
|
peer := rule.To[0]
|
|
if peer.NamespaceSelector == nil || peer.NamespaceSelector.MatchLabels["kubernetes.io/metadata.name"] != p.ControlNamespace {
|
|
t.Errorf("login egress namespace = %v, want %s", peer.NamespaceSelector, p.ControlNamespace)
|
|
}
|
|
if peer.PodSelector == nil || !mapSelectorMatches(peer.PodSelector.MatchLabels, APIDeployment(p).Spec.Template.Labels) {
|
|
t.Errorf("login egress pod selector %v must select the api pod", peer.PodSelector)
|
|
}
|
|
if mapSelectorMatches(peer.PodSelector.MatchLabels, OperatorDeployment(p).Spec.Template.Labels) {
|
|
t.Error("login egress must not reach the operator")
|
|
}
|
|
}
|
|
|
|
// TestRegistryIngress_BuildNamespaceOnly pins who may dial the registry pod: build
|
|
// pods, on the registry port. Game servers and the control plane never pull
|
|
// through the Service — containerd pulls over the node's loopback hostPort.
|
|
func TestRegistryIngress_BuildNamespaceAndAPI(t *testing.T) {
|
|
p := testParams().withDefaults()
|
|
np := RegistryIngressPolicy(p)
|
|
if np.Namespace != p.RegistryNamespace {
|
|
t.Errorf("registry ingress namespace = %s, want %s", np.Namespace, p.RegistryNamespace)
|
|
}
|
|
if !mapSelectorMatches(np.Spec.PodSelector.MatchLabels, registryDeployment(p).Spec.Template.Labels) {
|
|
t.Errorf("registry ingress selector %v does not select the registry pod", np.Spec.PodSelector)
|
|
}
|
|
if mapSelectorMatches(np.Spec.PodSelector.MatchLabels, APIDeployment(p).Spec.Template.Labels) {
|
|
t.Error("registry ingress must not also fence the api pod")
|
|
}
|
|
if len(np.Spec.Ingress) != 1 || len(np.Spec.Ingress[0].From) != 2 {
|
|
t.Fatalf("registry ingress shape = %+v, want one rule, two peers", np.Spec.Ingress)
|
|
}
|
|
peer := np.Spec.Ingress[0].From[0]
|
|
if peer.PodSelector != nil || peer.IPBlock != nil || peer.NamespaceSelector == nil ||
|
|
peer.NamespaceSelector.MatchLabels["kubernetes.io/metadata.name"] != p.BuildNamespace {
|
|
t.Errorf("registry ingress peer = %+v, want the whole %s namespace", peer, p.BuildNamespace)
|
|
}
|
|
// The second peer is felis-api alone: it selects the api pod and not the
|
|
// operator's, both of which live in the control namespace.
|
|
api := np.Spec.Ingress[0].From[1]
|
|
if api.IPBlock != nil || api.NamespaceSelector == nil || api.PodSelector == nil ||
|
|
api.NamespaceSelector.MatchLabels["kubernetes.io/metadata.name"] != p.ControlNamespace {
|
|
t.Fatalf("registry ingress api peer = %+v, want pods in %s", api, p.ControlNamespace)
|
|
}
|
|
if !mapSelectorMatches(api.PodSelector.MatchLabels, APIDeployment(p).Spec.Template.Labels) {
|
|
t.Errorf("registry ingress api peer %v does not select the api pod", api.PodSelector)
|
|
}
|
|
if mapSelectorMatches(api.PodSelector.MatchLabels, OperatorDeployment(p).Spec.Template.Labels) {
|
|
t.Errorf("registry ingress api peer %v also selects the operator pod", api.PodSelector)
|
|
}
|
|
assertSinglePort(t, np.Spec.Ingress[0].Ports, int(p.RegistryPort))
|
|
}
|
|
|
|
func selectorEquals(a, b metav1.LabelSelector) bool {
|
|
if len(a.MatchLabels) != len(b.MatchLabels) || len(a.MatchExpressions)+len(b.MatchExpressions) != 0 {
|
|
return false
|
|
}
|
|
for k, v := range a.MatchLabels {
|
|
if b.MatchLabels[k] != v {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|