feat(netpol): 锁定游戏服出站并为 registry 加入站围栏

This commit is contained in:
Lemon-miaow committed 2026-09-24 14:25:17 +08:00
1 parent 3424852a39
commit 7819e5de50
13 files changed
+620 -37

No files matched your search

+6 -1
View File
@@ -89,10 +89,15 @@ func Objects(p Params) []Object {
buildNP.TypeMeta = metav1.TypeMeta{APIVersion: "networking.k8s.io/v1", Kind: "NetworkPolicy"}
objs = append(objs, buildNP)
// Minecraft-namespace ingress fence (default-deny + RCON + game).
// Minecraft-namespace ingress fence (default-deny + RCON + game), the server
// egress fence, and the registry's ingress fence.
for _, np := range MinecraftNetworkPolicies(p) {
objs = append(objs, np)
}
for _, np := range ServerEgressPolicies(p) {
objs = append(objs, np)
}
objs = append(objs, RegistryIngressPolicy(p))
// The running control-plane the fence protects: felis-api/operator Deployments
// (which bind the SAs to workloads and stamp the RCON-peer labels) and the
+28
View File
@@ -5,6 +5,7 @@ import (
"testing"
corev1 "k8s.io/api/core/v1"
networkingv1 "k8s.io/api/networking/v1"
"sigs.k8s.io/yaml"
)
@@ -21,6 +22,33 @@ func TestObjects_EveryDocHasTypeMeta(t *testing.T) {
}
}
// TestObjects_CarryTheFences checks the bundle ships every NetworkPolicy the
// security model counts on, each in the namespace it guards. Rendering one is
// worth nothing if Objects forgets to include it.
func TestObjects_CarryTheFences(t *testing.T) {
want := map[string]string{
"felis-default-deny-ingress": "minecraft",
"felis-server-egress": "minecraft",
"felis-login-to-internal-api": "minecraft",
"felis-registry-ingress": "felis",
}
for _, obj := range Objects(testParams()) {
np, ok := obj.(*networkingv1.NetworkPolicy)
if !ok {
continue
}
if ns, expected := want[np.Name]; expected {
if np.Namespace != ns {
t.Errorf("%s in namespace %q, want %q", np.Name, np.Namespace, ns)
}
delete(want, np.Name)
}
}
for name := range want {
t.Errorf("bundle is missing NetworkPolicy %s", name)
}
}
// TestObjects_NamespacesLabeled checks the three namespaces are rendered with the
// immutable name label the NetworkPolicy namespaceSelectors key on.
func TestObjects_NamespacesLabeled(t *testing.T) {
+10
View File
@@ -98,6 +98,16 @@ type Params struct {
// Pods (spec §16). Empty means no internet egress at all — the locked-down
// default the build subsystem already enforces.
PackageSourceCIDRs []string
// ServerEgressDenyCIDRs are extra destinations game server pods may never
// reach, on top of the private, link-local and loopback ranges the server
// egress policy always excludes. The installer passes the node's own global
// addresses: a node with a public IP would otherwise be reachable from a
// tenant's plugin on every host port (PostgreSQL, the kube API, kubelet).
ServerEgressDenyCIDRs []string
// ServerEgressAllowCIDRs are private destinations game servers MAY reach
// despite that exclusion, e.g. a LAN database a server's plugin uses. Empty by
// default: a tenant's code has no business on the operator's network.
ServerEgressAllowCIDRs []string
// FelisImage is the container image the felis-api and felis-operator
// Deployments run (the multi-call `felis` binary). It has NO default and no
// safe guess: `felis manifests` REQUIRES --felis-image and refuses to render
+133
View File
@@ -1,7 +1,11 @@
package platform
import (
"net"
"felis.lolicon.best/internal/apis/felis/v1alpha1"
"felis.lolicon.best/internal/build"
"felis.lolicon.best/internal/naming"
"felis.lolicon.best/internal/operator"
corev1 "k8s.io/api/core/v1"
networkingv1 "k8s.io/api/networking/v1"
@@ -124,6 +128,135 @@ func allowGameFromVelocity(p Params) *networkingv1.NetworkPolicy {
return netpol("felis-allow-game-from-velocity", p.MinecraftNamespace, serverPodSelector(), ingress)
}
// serverEgressExceptV4 / V6 are the destinations a game server never reaches
// through the internet rule: every private, shared, link-local, loopback,
// multicast and reserved range. They cover the pod and Service CIDRs of any stock
// k3s/k8s install (10.42/16, 10.43/16), the node's private addresses, cloud
// metadata (169.254.169.254) and the operator's LAN.
var (
serverEgressExceptV4 = []string{
"0.0.0.0/8", "10.0.0.0/8", "100.64.0.0/10", "127.0.0.0/8", "169.254.0.0/16",
"172.16.0.0/12", "192.168.0.0/16", "224.0.0.0/4", "240.0.0.0/4",
}
serverEgressExceptV6 = []string{"::1/128", "fc00::/7", "fe80::/10", "ff00::/8"}
)
// ServerEgressPolicies render the egress fence for game server pods. A server runs
// code its owner chose — plugins, mods, a whole image — so the namespace used to
// be a launch pad: any server could push to the unauthenticated registry, dial
// felis-api's internal face, PostgreSQL on the node, or the kube API. Now:
//
// - every server may resolve names and reach the public internet (plugin
// updates, resource packs, web maps) and nothing private;
// - the login system server additionally reaches felis-api's internal face,
// the one platform service it is built to call.
//
// Policies are additive, so the login pod gets the union of both.
func ServerEgressPolicies(p Params) []*networkingv1.NetworkPolicy {
p = p.withDefaults()
return []*networkingv1.NetworkPolicy{serverEgress(p), loginToInternalAPI(p)}
}
func serverEgress(p Params) *networkingv1.NetworkPolicy {
v4 := append([]string{}, serverEgressExceptV4...)
v6 := append([]string{}, serverEgressExceptV6...)
for _, c := range p.ServerEgressDenyCIDRs {
_, n, err := net.ParseCIDR(c)
if err != nil {
continue // `felis manifests` rejects these before rendering
}
if n.IP.To4() != nil {
v4 = append(v4, n.String())
} else {
v6 = append(v6, n.String())
}
}
peers := []networkingv1.NetworkPolicyPeer{
{IPBlock: &networkingv1.IPBlock{CIDR: "0.0.0.0/0", Except: v4}},
{IPBlock: &networkingv1.IPBlock{CIDR: "::/0", Except: v6}},
}
for _, c := range p.ServerEgressAllowCIDRs {
if _, n, err := net.ParseCIDR(c); err == nil {
peers = append(peers, networkingv1.NetworkPolicyPeer{IPBlock: &networkingv1.IPBlock{CIDR: n.String()}})
}
}
udp, tcp := corev1.ProtocolUDP, corev1.ProtocolTCP
dns := intstr.FromInt32(53)
return &networkingv1.NetworkPolicy{
TypeMeta: metav1.TypeMeta{APIVersion: "networking.k8s.io/v1", Kind: "NetworkPolicy"},
ObjectMeta: metav1.ObjectMeta{Name: "felis-server-egress", Namespace: p.MinecraftNamespace},
Spec: networkingv1.NetworkPolicySpec{
PodSelector: serverPodSelector(),
PolicyTypes: []networkingv1.PolicyType{networkingv1.PolicyTypeEgress},
Egress: []networkingv1.NetworkPolicyEgressRule{
// Name resolution through the cluster resolver: the DNS Service
// sits inside 10/8, which the internet rule excludes.
{
To: []networkingv1.NetworkPolicyPeer{build.ClusterDNSPeer()},
Ports: []networkingv1.NetworkPolicyPort{{Protocol: &udp, Port: &dns}, {Protocol: &tcp, Port: &dns}},
},
{To: peers},
},
},
}
}
// loginToInternalAPI opens felis-api's internal face (8081) to the login system
// server only. The selector needs both the reserved name and the setup-owned
// system-role label the operator copies onto that pod — the same pair that decides
// who receives FELIS_SERVICE_TOKEN (internal/operator buildEnv), so a user server
// can never match it by picking a name.
func loginToInternalAPI(p Params) *networkingv1.NetworkPolicy {
tcp := corev1.ProtocolTCP
port := intstr.FromInt32(apiInternalPort)
sel := serverPodSelector()
sel.MatchLabels[v1alpha1.LabelServer] = naming.SystemLoginServer
sel.MatchLabels[v1alpha1.LabelSystemRole] = naming.SystemLoginServer
return &networkingv1.NetworkPolicy{
TypeMeta: metav1.TypeMeta{APIVersion: "networking.k8s.io/v1", Kind: "NetworkPolicy"},
ObjectMeta: metav1.ObjectMeta{Name: "felis-login-to-internal-api", Namespace: p.MinecraftNamespace},
Spec: networkingv1.NetworkPolicySpec{
PodSelector: sel,
PolicyTypes: []networkingv1.PolicyType{networkingv1.PolicyTypeEgress},
Egress: []networkingv1.NetworkPolicyEgressRule{{
To: []networkingv1.NetworkPolicyPeer{{
NamespaceSelector: &metav1.LabelSelector{
MatchLabels: map[string]string{"kubernetes.io/metadata.name": p.ControlNamespace},
},
PodSelector: &metav1.LabelSelector{MatchLabels: map[string]string{
LabelPartOf: controlPlanePartOf,
LabelComponent: ComponentAPI,
}},
}},
Ports: []networkingv1.NetworkPolicyPort{{Protocol: &tcp, Port: &port}},
}},
},
}
}
// RegistryIngressPolicy fences the registry pod: only build pods reach its port.
// Everything else that uses the registry runs on the node — containerd's pulls and
// the installer's pushes both arrive through the loopback hostPort — and Kubernetes
// never blocks resident-node traffic. Write authorization is the gate's job; this
// policy keeps every other pod from even trying.
func RegistryIngressPolicy(p Params) *networkingv1.NetworkPolicy {
p = p.withDefaults()
tcp := corev1.ProtocolTCP
port := intstr.FromInt32(p.RegistryPort)
np := netpol("felis-registry-ingress", p.RegistryNamespace,
metav1.LabelSelector{MatchLabels: registryLabels()},
[]networkingv1.NetworkPolicyIngressRule{{
From: []networkingv1.NetworkPolicyPeer{{
NamespaceSelector: &metav1.LabelSelector{
MatchLabels: map[string]string{"kubernetes.io/metadata.name": p.BuildNamespace},
},
}},
Ports: []networkingv1.NetworkPolicyPort{{Protocol: &tcp, Port: &port}},
}},
)
return np
}
// netpol assembles an ingress-only NetworkPolicy. A nil/empty ingress slice with
// PolicyTypeIngress is the canonical "deny all ingress" shape.
func netpol(name, ns string, sel metav1.LabelSelector, ingress []networkingv1.NetworkPolicyIngressRule) *networkingv1.NetworkPolicy {
+151
View File
@@ -7,6 +7,7 @@ import (
"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 {
@@ -190,3 +191,153 @@ func assertSinglePort(t *testing.T, ports []networkingv1.NetworkPolicyPort, want
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_BuildNamespaceOnly(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) != 1 {
t.Fatalf("registry ingress shape = %+v, want one rule, one peer", 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)
}
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
}