Firewall forwarded traffic: applyOnForward

Firewall forwarded traffic: applyOnForward

Lesson 82 made a node's own interface a policy target and firewalled traffic to and from the host. But a node does more than send and receive its own packets - it forwards everyone else's: a pod on one node reaching a pod on another, or an outside client reaching a pod, transits a node's forward path. By default a HostEndpoint's policy ignores that forwarded traffic. Flip applyOnForward: true and the host firewall now polices it too - so a single GlobalNetworkPolicy can quarantine an entire class of pods cluster-wide, sitting beneath every NetworkPolicy.

What you'll learn

What is applyOnForward?

A HostEndpoint policy, by default, only sees traffic whose source or destination is the host itself. Most traffic isn't that - it's forwarded: the node is just a transit hop between two workloads, or between the outside world and a workload. applyOnForward: true extends the policy's rules to that forwarded traffic.

Traffic Policed without applyOnForward? With it?
Outside client → the host ✅ yes ✅ yes
Pod on node A → pod on node B (transits a host) ❌ no ✅ yes
Outside client → a pod (forwarded by the entry node) ❌ no ✅ yes

Because the rule lives on a wildcard (interfaceName: "*") HostEndpoint on every node, and selects by the destination workload's labels, it becomes a cluster-wide quarantine for anything matching app == 'database' - regardless of which namespace or node the database pod lives in.

The policy

apiVersion: projectcalico.org/v3
kind: HostEndpoint
metadata:
  name: fw-control-plane
  labels:
    role: forward-firewall
spec:
  node: policy-llm-control-plane
  interfaceName: "*"
  expectedIPs: ["172.18.0.2"]
---
apiVersion: projectcalico.org/v3
kind: HostEndpoint
metadata:
  name: fw-worker
  labels:
    role: forward-firewall
spec:
  node: policy-llm-worker
  interfaceName: "*"
  expectedIPs: ["172.18.0.3"]
---
apiVersion: projectcalico.org/v3
kind: GlobalNetworkPolicy
metadata:
  name: forward-firewall-deny-database
spec:
  selector: role == 'forward-firewall'
  applyOnForward: true
  types:
    - Ingress
    - Egress
  ingress:
    - action: Deny
      destination:
        selector: app == 'database'
    - action: Allow
  egress:
    - action: Deny
      destination:
        selector: app == 'database'
    - action: Allow

Reading the pieces

What to observe

Denied - every flow to a database pod, no matter the source:

Allowed - everything else:

The trap is forgetting the trailing Allow. A wildcard HostEndpoint defaults to drop. With only the Deny rule, every forwarded flow - not just the database ones - goes dark, and you've firewalled the whole cluster off itself.

{
  "question": "Why does `prod/backend → prod/database` get denied even though both pods are in the same namespace on the same node?",
  "options": [
    "The NetworkPolicy API blocks same-namespace traffic by default",
    "applyOnForward polices the traffic the node forwards - it isn't scoped to a namespace or pod, so it catches the flow the moment the host forwards it toward an app == 'database' pod",
    "Same-node pods can never reach each other in Calico"
  ],
  "answer": 1,
  "explain": "This is the whole point of a host forward firewall: it operates on the node's forward path, not on pod/namespace identity. Any flow the node forwards toward a database pod is subject to the rule, regardless of where the source sits."
}

Recap

applyOnForward promotes a HostEndpoint policy from "guard the host's own traffic" to "guard everything the host forwards." Put it on a wildcard HostEndpoint on every node and select by destination labels, and one GlobalNetworkPolicy quarantines a whole class of pods cluster-wide - the kind of blast-radius control the namespace-scoped Network Policy API can't reach. Next: the same host firewall, but aimed at the outside world - preDNAT and doNotTrack.