Zero Trust K3s Network With Cilium

I wanted to implement full zero-trust networking within my k3s cluster which uses the Cilium CNI, which has custom CiliumClusterwideNetworkPolicy and CiliumNetworkPolicyresources, which extend what is possible with standard Kubernetes NetworkPolicy resources.

Cilium defaults to allowing traffic, but if a policy is applied to an endpoint, it switches and will deny any connect not explicitely allowed. Note that this is direction dependent, so ingress and egress are treated separately.

Zero trust policies require you to control traffic in both directions. Not only does your database need to accept traffic from your app, but your app has to allow the connection to the database.

This is tedious, and if you don’t get it right it will break your cluster and your ability to tell what you’re missing. So I figured I’d document the policies required to keep your cluster functional.

Note that my k3s cluster has been deployed with --disable-network-policy, --disable-kube-proxy, --disable-servicelb, and --disable-traefik, because these services are provided by Cilium (or ingress-nginx, in the case of traefik).

Lastly, while the policies below apply to k3s, they’re probably a good starting point for other clusters – the specifics will be different, but you’re always going to want to allow traffic to your DNS service, etc.

Hubble UI

Before attempting any network policies, ensure you’ve got hubble ui and hubble observe working. You should verify that the endpoints and ports used in the policies below match your cluster.

Cluster Wide Policies

These policies are applied cluster wide, without regard for namespace boundaries.

Default Deny

Does what it says on the tin.

apiVersion: "cilium.io/v2"
kind: CiliumClusterwideNetworkPolicy
metadata:
  name: "default-deny"
spec:
  description: "Empty ingress and egress policy to enforce default-deny on all endpoints"
  endpointSelector:
    {}
  ingress:
  - {}
  egress:
  - {}

Allow Health Checks

Required to allow cluster health checks to pass.

apiVersion: "cilium.io/v2"
kind: CiliumClusterwideNetworkPolicy
metadata:
  name: "health-checks"
spec:
  endpointSelector:
    matchLabels:
      'reserved:health': ''
  ingress:
    - fromEntities:
      - remote-node
  egress:
    - toEntities:
      - remote-node

Allow ICMP

ICMP is useful with IPv4, and absolutely necessary for IPv6. This policy allows select ICMP and ICMPv6 request types globally, both within and outside the cluster.

apiVersion: "cilium.io/v2"
kind: CiliumClusterwideNetworkPolicy
metadata:
  name: "allow-icmp"
spec:
  description: "Policy to allow select ICMP traffic globally"
  endpointSelector:
    {}
  ingress:
  - fromEntities:
    - all
  - icmps:
    - fields:
      - type: EchoRequest
        family: IPv4
      - type: EchoReply
        family: IPv4
      - type: DestinationUnreachable
        family: IPv4
      - type: TimeExceeded
        family: IPv4
      - type: ParameterProblem
        family: IPv4
      - type: Redirect 
        family: IPv4
      - type: EchoRequest
        family: IPv6
      - type: DestinationUnreachable
        family: IPv6
      - type: TimeExceeded
        family: IPv6
      - type: ParameterProblem
        family: IPv6
      - type: RedirectMessage
        family: IPv6
      - type: PacketTooBig
        family: IPv6
      - type: MulticastListenerQuery
        family: IPv6
      - type: MulticastListenerReport
        family: IPv6
  egress:
  - toEntities:
    - all
  - icmps:
    - fields:
      - type: EchoRequest
        family: IPv4
      - type: EchoReply
        family: IPv4
      - type: DestinationUnreachable
        family: IPv4
      - type: TimeExceeded
        family: IPv4
      - type: ParameterProblem
        family: IPv4
      - type: Redirect 
        family: IPv4
      - type: EchoRequest
        family: IPv6
      - type: EchoReply
        family: IPv6
      - type: DestinationUnreachable
        family: IPv6
      - type: TimeExceeded
        family: IPv6
      - type: ParameterProblem
        family: IPv6
      - type: RedirectMessage
        family: IPv6
      - type: PacketTooBig
        family: IPv6
      - type: MulticastListenerQuery
        family: IPv6
      - type: MulticastListenerReport
        family: IPv6

Allow Kube DNS

This pair of policies allows the cluster to query DNS.

apiVersion: "cilium.io/v2"
kind: CiliumClusterwideNetworkPolicy
metadata:
  name: "allow-to-kubedns-ingress"
spec:
  description: "Policy for ingress allow to kube-dns from all Cilium managed endpoints in the cluster"
  endpointSelector:
    matchLabels:
      k8s:io.kubernetes.pod.namespace: kube-system
      k8s-app: kube-dns
  ingress:
  - fromEndpoints:
    - {}
    toPorts:
    - ports:
      - port: "53"
        protocol: UDP
---
apiVersion: "cilium.io/v2"
kind: CiliumClusterwideNetworkPolicy
metadata:
  name: "allow-to-kubedns-egress"
spec:
  description: "Policy for egress allow to kube-dns from all Cilium managed endpoints in the cluster"
  endpointSelector:
    {}
  egress:
  - toEndpoints:
    - matchLabels:
        k8s:io.kubernetes.pod.namespace: kube-system
        k8s-app: kube-dns
    toPorts:
    - ports:
      - port: "53"
        protocol: UDP

Kubernetes Services

These policies are applied to the standard kubernetes services running in the kube-system namespace.

Kube DNS

Kube DNS (or Core DNS in some k8s distros) needs to talk to the k8s API server and also to DNS resolvers outside the cluster.

apiVersion: "cilium.io/v2"
kind: CiliumNetworkPolicy
metadata:
  name: kube-dns
  namespace: kube-system
spec:
  endpointSelector:
    matchLabels:
      k8s:io.kubernetes.pod.namespace: kube-system
      k8s-app: kube-dns
  ingress:
  - fromEntities:
    - host
    toPorts:
    - ports:
      - port: "8080"
        protocol: TCP
      - port: "8181"
        protocol: TCP
  egress:
  - toEntities:
    - world
    toPorts:
    - ports:
      - port: "53"
        protocol: UDP
  - toEntities:
    - host
    toPorts:
    - ports:
      - port: "6443"
        protocol: TCP

Metrics Server

The metrics service needs to talk to most of the k8s services.

apiVersion: "cilium.io/v2"
kind: CiliumNetworkPolicy
metadata:
  name: metrics-server
  namespace: kube-system
spec:
  endpointSelector:
    matchLabels:
      k8s:io.kubernetes.pod.namespace: kube-system
      k8s-app: metrics-server
  ingress:
  - fromEntities:
    - host
    - remote-node
    - kube-apiserver
    toPorts:
    - ports:
      - port: "10250"
        protocol: TCP
  egress:
  - toEntities:
    - host
    - kube-apiserver
    - remote-node
    toPorts:
    - ports:
      - port: "10250"
        protocol: TCP
  - toEntities:
    - kube-apiserver
    toPorts:
    - ports:
      - port: "6443"
        protocol: TCP

Local Path Provisioner

The local path provisioner only seems to talk to the k8s API server.

apiVersion: "cilium.io/v2"
kind: CiliumNetworkPolicy
metadata:
  name: local-path-provisioner
  namespace: kube-system
spec:
  endpointSelector:
    matchLabels:
      k8s:io.kubernetes.pod.namespace: kube-system
      app: local-path-provisioner
  egress:
  - toEntities:
    - host
    - kube-apiserver
    toPorts:
    - ports:
      - port: "6443"
        protocol: TCP

Cilium Services

These policies apply to the Cilium services themselves. I deployed mine to the cilium namespace, so adjust as necessary if you deployed Cilium to the kube-system namespace.

Hubble Relay

The hubble-relay service needs to talk to all cilium and hubble components in order to consolidate a cluster-wide view.

apiVersion: "cilium.io/v2"
kind: CiliumNetworkPolicy
metadata:
  namespace: cilium
  name: hubble-relay
spec:
  endpointSelector:
    matchLabels:
      app.kubernetes.io/name: hubble-relay
  ingress:
  - fromEntities:
      - host
    toPorts:
    - ports:
      - port: "4222"
        protocol: TCP
      - port: "4245"
        protocol: TCP
  - fromEndpoints:
    - matchLabels:
        app.kubernetes.io/name: hubble-ui
    toPorts:
    - ports:
      - port: "4245"
        protocol: TCP
  egress:
  - toEntities:
    - host
    - remote-node
    - kube-apiserver
    toPorts:
      - ports:
        - port: "4244"
          protocol: TCP

Hubble UI

The hubble-ui provides the tools necessary to actually observe traffic in the cluster.

apiVersion: "cilium.io/v2"
kind: CiliumNetworkPolicy
metadata:
  namespace: cilium
  name: hubble-ui
spec:
  endpointSelector:
    matchLabels:
      app.kubernetes.io/name: hubble-ui
  ingress:
  - fromEntities:
      - host
    toPorts:
    - ports:
      - port: "8081"
        protocol: TCP
  egress:
  - toEndpoints:
    - matchLabels:
        app.kubernetes.io/name: hubble-relay
    toPorts:
      - ports:
        - port: "4245"
          protocol: TCP
  - toEntities:
    - kube-apiserver
    toPorts:
      - ports:
        - port: "6443"
          protocol: TCP

Cert Manager

These policies will help if you’re using cert-manager.

Cert Manager

apiVersion: "cilium.io/v2"
kind: CiliumNetworkPolicy
metadata:
  namespace: cert-manager
  name: cert-manager
spec:
  endpointSelector:
    matchLabels:
      app.kubernetes.io/name: cert-manager
  ingress:
  - fromEntities:
      - host
    toPorts:
    - ports:
      - port: "9403"
        protocol: TCP
  egress:
  - toEntities:
    - kube-apiserver
    toPorts:
      - ports:
        - port: "6443"
          protocol: TCP
  - toEntities:
    - world
    toPorts:
      - ports:
        - port: "443"
          protocol: TCP
        - port: "53"
          protocol: UDP

Webhook

apiVersion: "cilium.io/v2"
kind: CiliumNetworkPolicy
metadata:
  namespace: cert-manager
  name: webhook
spec:
  endpointSelector:
    matchLabels:
      app.kubernetes.io/name: webhook
  ingress:
  - fromEntities:
      - host
    toPorts:
    - ports:
      - port: "6080"
        protocol: TCP
  - fromEntities:
      - kube-apiserver
    toPorts:
    - ports:
      - port: "10250"
        protocol: TCP
  egress:
  - toEntities:
    - kube-apiserver
    toPorts:
      - ports:
        - port: "6443"
          protocol: TCP

CA Injector

apiVersion: "cilium.io/v2"
kind: CiliumNetworkPolicy
metadata:
  namespace: cert-manager
  name: cainjector
spec:
  endpointSelector:
    matchLabels:
      app.kubernetes.io/name: cainjector
  egress:
  - toEntities:
    - kube-apiserver
    toPorts:
      - ports:
        - port: "6443"
          protocol: TCP

External DNS

This policy will allow external-dns to communicate with API driven DNS services. To update local DNS services via RFC2136 updates, change the world egress port from 443 TCP to 54 UDP.

apiVersion: "cilium.io/v2"
kind: CiliumNetworkPolicy
metadata:
  namespace: external-dns
  name: external-dns
spec:
  endpointSelector:
    matchLabels:
      app.kubernetes.io/name: external-dns
  ingress:
  - fromEntities:
      - host
    toPorts:
    - ports:
      - port: "7979"
        protocol: TCP
  egress:
  - toEntities:
    - kube-apiserver
    toPorts:
      - ports:
        - port: "6443"
          protocol: TCP
  - toEntities:
    - world
    toPorts:
      - ports:
        - port: "443"
          protocol: TCP

Ingress-Nginx & OAuth2 Proxy

These policies will be helpful if you use ingress-nginx and oauth2-proxy. Note that I deployed them to their own namespaces, so you may need to adjust if you deployed them to the same namespace.

Ingress-Nginx

apiVersion: "cilium.io/v2"
kind: CiliumNetworkPolicy
metadata:
  namespace: ingress-nginx
  name: ingress-nginx
spec:
  endpointSelector:
    matchLabels:
      app.kubernetes.io/name: ingress-nginx
  ingress:
  - fromEntities:
      - kube-apiserver
    toPorts:
    - ports:
      - port: "8443"
        protocol: TCP
  - fromEntities:
      - host
    toPorts:
    - ports:
      - port: "10254"
        protocol: TCP
  - fromEntities:
      - world
    toPorts:
    - ports:
      - port: "80"
        protocol: TCP
      - port: "443"
        protocol: TCP
  egress:
  - toEntities:
    - kube-apiserver
    toPorts:
      - ports:
        - port: "6443"
          protocol: TCP
  - toEndpoints:
    - matchLabels:
        k8s:io.kubernetes.pod.namespace: oauth2-proxy
        app.kubernetes.io/name: oauth2-proxy
    toPorts:
    - ports:
      - port: "4180"
        protocol: TCP

OAuth2 Proxy

apiVersion: "cilium.io/v2"
kind: CiliumNetworkPolicy
metadata:
  namespace: oauth2-proxy
  name: oauth2-proxy
spec:
  endpointSelector:
    matchLabels:
      app.kubernetes.io/name: oauth2-proxy
  ingress:
  - fromEndpoints:
    - matchLabels:
        k8s:io.kubernetes.pod.namespace: ingress-nginx
        app.kubernetes.io/name: ingress-nginx
    toPorts:
    - ports:
      - port: "4180"
        protocol: TCP
  - fromEntities:
    - host
    toPorts:
    - ports:
      - port: "4180"
        protocol: TCP
  egress:
  - toEntities:
    - world
    toPorts:
      - ports:
        - port: "443"
          protocol: TCP

Conclusion

These policies should get your cluster off the ground (or close to it). You’ll still need to add additional policies for your actual workloads (and probably extend the ingress-nginx one).