feat(netpol): 锁定游戏服出站并为 registry 加入站围栏
This commit is contained in:
13 files changed
+620
-37
No files matched your search
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
Reference in new issue
Block a user