Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion cmd/cli/commands/network/firewall/add.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ var addCmd = &cobra.Command{

func init() {
addCmd.Flags().StringVar(&flagMgmtCIDR, "mgmt-cidr", "", "A single management CIDR to add")
addCmd.Flags().StringVar(&flagBlockedCIDR, "blocked-cidr", "", "A single operator block-list CIDR to add")
addCmd.Flags().StringVar(&flagBlockedCIDR, "blocked-cidr", "", "A single operator block-list CIDR to add (dropped inbound, outbound, and forwarded)")
addCmd.Flags().IntVar(&flagInClusterPort, "in-cluster-port", 0, "A single in-cluster host-service port to add")
addCmd.MarkFlagsMutuallyExclusive("mgmt-cidr", "blocked-cidr", "in-cluster-port")
}
2 changes: 1 addition & 1 deletion cmd/cli/commands/network/firewall/create.go
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,7 @@ var createCmd = &cobra.Command{

func init() {
createCmd.Flags().StringSliceVar(&flagMgmtCIDRs, "mgmt-cidrs", nil, "Management/SSH allowlist CIDRs (comma-separated or repeated)")
createCmd.Flags().StringSliceVar(&flagBlockedCIDRs, "blocked-cidrs", nil, "Operator-curated block list CIDRs, dropped before any other rule (comma-separated or repeated)")
createCmd.Flags().StringSliceVar(&flagBlockedCIDRs, "blocked-cidrs", nil, "Operator-curated block list CIDRs, dropped inbound, outbound, and forwarded, ahead of conntrack (comma-separated or repeated)")
createCmd.Flags().IntSliceVar(&flagInClusterPorts, "in-cluster-ports", fw.DefaultInClusterPorts, "Host-service ports reachable from the pod CIDR")
createCmd.Flags().IntVar(&flagSSHPort, "ssh-port", fw.DefaultSSHPort, "SSH/management TCP port accepted from the allowlist")
createCmd.Flags().StringSliceVar(&flagPodCIDR, "pod-cidr", nil, "Pod CIDR(s) allowed to reach the in-cluster host-service ports; may be IPv4 and/or IPv6 (comma-separated or repeated). Default: auto-detected from the local node's .spec.podCIDR; the rule is omitted if no cluster is reachable")
Expand Down
2 changes: 1 addition & 1 deletion cmd/cli/commands/network/policy/delete.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ var deleteCmd = &cobra.Command{
Long: "Remove a named policy from the `inet weaver-workload-policy` table: re-renders the chain without it, " +
"applies the result to the live kernel, restores remaining policies' live membership, " +
"removes the registry file, and atomically rewrites network-weaver-workload-policy.nft. " +
"If this is the last policy, an empty chain (policy drop, no rules) is applied and the " +
"If this is the last policy, the table is torn down entirely (live and on disk) and the " +
"boot oneshot is left enabled.",
RunE: func(cmd *cobra.Command, args []string) error {
if err := newManager().Delete(cmd.Context(), flagName); err != nil {
Expand Down
77 changes: 61 additions & 16 deletions docs/dev/traffic-shaper.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,37 @@ and its traffic categories and daemon reconciler are block-node-specific (below)
but the table itself holds whatever `network policy` writes, block-node-related
or not.

### Which plane sees which traffic

The two tables register on different hooks, so they see **disjoint traffic**. That is why
neither carries a rule for the other's ports, and why no block-node service port appears
anywhere in the host firewall's rules or templates.

| Traffic | Outcome | Decided by |
|---|---|---|
| External → node address, **non**-service port | Dropped | Host firewall `input` (`policy drop`) |
| External → node address, service port | Translated, then forwarded. Classified when the port is in a managed `<name>_ports` set, otherwise forwarded unclassified | Workload policy `forward` |
| In-cluster → pod address directly, any port | Not constrained here — forwarded under `policy accept` | Cilium |
| Either endpoint in `@bn-restricted` | Dropped, both directions and both families | Workload policy `forward` |

The first row misleads, because the mechanism is not the one the rule layout suggests. A packet
addressed to a port with no service behind it gets **no load-balancer translation** — only
exposed service ports have translation entries. Untranslated, its destination is still the
node's own address, so the routing decision delivers it locally, it arrives at `input`, and the
default drop catches it. It never becomes pod-bound traffic, so the classifier never sees it.

Service traffic takes the opposite path: translation happens *before* the routing decision
(Cilium's eBPF at the tc ingress hook, or `prerouting` when kube-proxy performs the DNAT), so
the packet is forwarded and bypasses `input` entirely. That is why a block node serves traffic
on its service ports while the host firewall opens none of them.

One consequence worth knowing, because it is silent: **an exposed port absent from the managed
`<name>_ports` sets is forwarded and unshaped.** It matches no classification rule, carries no
`meta priority`, and lands in the HTB default class — `reserve-ingress` inbound, a 10%
guarantee. Since those sets are reconciled from statusz, a listener the block node does not
report gets no shaping rather than an error. For what each hook does and does not enforce, see
[Coexistence with the host's existing network stack](#coexistence-with-the-hosts-existing-network-stack).

## How classify-and-shape fits together

The policy plane and the shaper are decoupled and meet through exactly one thing:
Expand Down Expand Up @@ -287,22 +318,36 @@ weaver tables simply register alongside the others.
What that pattern does **not** give you is additive permissiveness. Within a base chain,
`accept` ends evaluation *of that chain only* — the packet still traverses every other base
chain registered on the same hook. A `drop` (or `reject`) is final for the packet across all
of them. Both weaver chains are `policy drop`, so anything they do not explicitly accept is
dropped; the `forward` chain also ends in an explicit `drop`, while `input` falls through to
its chain policy. That makes **weaver the binding filter on the node**: nothing Cilium or
kube-proxy accepts can rescue traffic weaver does not match.

Concretely, the only broad escapes are:

| Hook | Escapes |
|---|---|
| `input` (host firewall) | mgmt allowlist on the SSH port, `in_cluster_ports` from the pod CIDR, ICMP path-health, `ct state established,related` |
| `forward` (workload policy) | `ip saddr <podCIDR> accept` (unclassified pod egress), `ct state established,related accept` |

Everything else forwarded or delivered on that host is dropped on new connections. On a
single-purpose block-node host that is the intent, but it is a node-wide decision, not a
block-node-scoped one — a second CNI, a docker bridge, a VPN, DHCPv6, or cross-node
kubelet/etcd/NodePort traffic all need an explicit rule or they are dropped.
of them. So on any hook where a weaver chain is `policy drop`, weaver is the binding filter:
nothing Cilium or kube-proxy accepts can rescue traffic weaver does not match.

The two tables sit on opposite sides of that line, and the distinction matters:

| Hook | Table | Chain policy | Role |
|---|---|---|---|
| `prerouting` (priority `raw`, −300) | host firewall | `accept` | Drops the operator block list ahead of conntrack. Covers the forward path too, so a blocked CIDR is blocked for pod-bound traffic as well. |
| `input` (priority `filter`, 0) | host firewall | `drop` | **Enforcing.** Anything not explicitly accepted is dropped. |
| `output` (priority `filter`, 0) | host firewall | `accept` | Block-list symmetry only — drops traffic *to* a blocked CIDR. Deliberately not an egress allowlist. |
| `forward` (priority `filter`, 0) | workload policy | `accept` | **Classifying.** Stamps `meta priority` for the HTB hierarchy; the only drops are the explicit `bn-restricted` quarantine rules. |

The block list is spelled on three hooks because one is not enough. Dropping a peer inbound
does not stop the host from dialing it, and once the host initiates, the replies come back in
under `ct state established` — so an inbound-only block list does not block the connection at
all. The `input` copy is redundant with `prerouting` for anything arriving on a wire; it is
kept so the block list's ordering relative to the conntrack fast-path stays a property of the
`input` chain itself rather than a consequence of a chain on another hook.

On `input`, the only broad escapes are the mgmt allowlist on the SSH port, `in_cluster_ports`
from the pod CIDR, the ICMP path-health subset, and `ct state established,related`. Everything
else delivered to that host is dropped on new connections. On a single-purpose block-node host
that is the intent, but it is a node-wide decision, not a block-node-scoped one — a second CNI,
a docker bridge, a VPN, DHCPv6, or cross-node kubelet/etcd/NodePort traffic all need an
explicit rule or they are dropped.

On `forward`, weaver constrains nothing. A packet matching no classification rule is accepted
carrying no `meta priority` and lands in the HTB default class. Workload isolation on that hook
rests entirely on Cilium — which also means a host whose Cilium datapath is degraded or not yet
up has no weaver-side backstop for forwarded traffic.

### tc: why the HTB hierarchies do not fight Cilium

Expand Down
4 changes: 2 additions & 2 deletions docs/quickstart.md
Original file line number Diff line number Diff line change
Expand Up @@ -222,7 +222,7 @@ sudo solo-provisioner block node install \
| `--load-balancer-enabled` | Inject MetalLB address-pool annotation into the block node service; set to `false` for environments without MetalLB (default: `true`). See [Block-node service exposure](./block-node-service-exposure.md) for how this interacts with `service.type` and the chart's split topology. |
| `--firewall-enabled` | Apply the node-level host firewall (`inet weaver-host-firewall` table: SSH/mgmt allowlist, ICMP policy, in-cluster ports). Opt-in (default: `false`); set to `true` to have this tool manage the host firewall |
| `--mgmt-cidrs` | Host firewall SSH/management allowlist CIDRs (IPv4 and/or IPv6 — each entry is routed to the matching `ipv4_addr`/`ipv6_addr` set). Empty skips the host firewall. |
| `--blocked-cidrs` | Host firewall operator-curated block list CIDRs (IPv4 and/or IPv6), dropped before any other rule including established connections. Distinct from the BN workload plane's `bn-restricted` set, which the traffic-shaper daemon manages automatically. |
| `--blocked-cidrs` | Host firewall operator-curated block list CIDRs (IPv4 and/or IPv6), dropped inbound, outbound, and forwarded — including established connections, and including pod-bound traffic. Distinct from the BN workload plane's `bn-restricted` set, which the traffic-shaper daemon manages automatically. |
| `--ssh-port` | Host firewall SSH/management TCP port (default `22`) |
| `--pod-cidr` | Host firewall pod CIDR for the in-cluster host-service ports rule (defaults to the cluster pod subnet). May be IPv4 and/or IPv6 (repeat or comma-separate for dual-stack). |
| `--in-cluster-ports` | Host firewall in-cluster host-service ports (defaults to `6443,4244,7472,10250`) |
Expand Down Expand Up @@ -841,7 +841,7 @@ Remove a policy's rules, set, and registry file, and re-render the `inet weaver-
sudo solo-provisioner network policy delete --name bn-restricted
```

`delete` re-renders the full chain without the removed policy, snapshots and restores remaining policies' live membership (so the destructive `delete table; add table` does not wipe their sets), removes the registry file, and atomically overwrites `network-weaver-workload-policy.nft`. If this is the last policy, an empty chain (`policy drop`, no rules) is applied; the boot oneshot stays enabled.
`delete` re-renders the full chain without the removed policy, snapshots and restores remaining policies' live membership (so the destructive `delete table; add table` does not wipe their sets), removes the registry file, and atomically overwrites `network-weaver-workload-policy.nft`. If this is the last policy, the table is torn down entirely (live and on disk); the boot oneshot stays enabled.

| Flag | Description | Required |
|----------|-----------------|----------|
Expand Down
36 changes: 36 additions & 0 deletions internal/network/firewall/firewall_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,42 @@ func TestRender_SecurityInvariants(t *testing.T) {
require.Less(t, blockedIdx, ctIdx, "blocked-CIDR drop must precede the conntrack fast-path")
}

// TestRender_BlockListReachesEveryPath pins the block list's scope. A CIDR in
// @blocked_addrs means "blocked on this node": dropped ahead of conntrack on the
// way in (which also covers pod-bound forwarded traffic), and dropped as a
// destination on the way out. Inbound-only is not enough — blocking a peer does
// not stop this host from dialing it, and the replies to a host-initiated
// connection are admitted by the input chain's established accept.
func TestRender_BlockListReachesEveryPath(t *testing.T) {
doc, err := dualStackTable().Render()
require.NoError(t, err)

// Priority must be below conntrack's -200, or the early drop buys nothing.
pre := chainBody(t, doc, "prerouting_blocklist")
require.Contains(t, pre, "type filter hook prerouting priority -300; policy accept;")
require.Contains(t, pre, "ip saddr @blocked_addrs drop")
require.Contains(t, pre, "ip6 saddr @blocked_addrs6 drop")

// The output chain is block-list symmetry, NOT an egress allowlist: it must
// stay `policy accept` and must never grow a rule that gates normal traffic.
out := chainBody(t, doc, "output")
require.Contains(t, out, "type filter hook output priority 0; policy accept;")
require.Contains(t, out, "ip daddr @blocked_addrs drop")
require.Contains(t, out, "ip6 daddr @blocked_addrs6 drop")
for _, line := range strings.Split(out, "\n") {
line = strings.TrimSpace(line)
if line == "" || strings.HasPrefix(line, "#") || strings.HasPrefix(line, "type filter") {
continue
}
require.Contains(t, line, "@blocked_addrs", "output chain must carry block-list rules only, got %q", line)
}

// The input copy is redundant with prerouting for wire traffic but is kept
// deliberately, so the block list's position relative to the conntrack
// fast-path remains a property of the input chain itself.
require.Contains(t, chainBody(t, doc, "input"), "ip saddr @blocked_addrs drop")
}

// TestRender_FamilySplit pins the point of the per-family chains: a packet must
// never be evaluated against a rule belonging to the other address family.
func TestRender_FamilySplit(t *testing.T) {
Expand Down
7 changes: 7 additions & 0 deletions internal/network/firewall/table.go
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,13 @@ type Table struct {
// which the traffic-shaper daemon reconciles from the block node's statusz
// "restricted" category; an operator block list needs a home the daemon
// never overwrites.
//
// A blocked CIDR means "blocked on this node", not "blocked from the host's
// own services": it is dropped on prerouting (which covers pod-bound
// forwarded traffic and runs ahead of conntrack), again on input, and as a
// destination on output — because blocking a peer inbound does not stop this
// host from dialing it, and the replies to a host-initiated connection are
// admitted by the input chain's established accept.
BlockedCIDRs []string
// InClusterPorts are host-service ports reachable from PodCIDR (set
// @in_cluster_ports). Per design there is deliberately no --service-ports:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,19 @@ table inet weaver-host-firewall {
set blocked_addrs6 { type ipv6_addr; flags interval; }
set in_cluster_ports { type inet_service; elements = { 4244, 6443, 7472, 10250 }; }

# Operator block list, dropped as early as the packet can be seen. Priority
# -300 is the `raw` band, ahead of conntrack at -200, so a blocked source
# never gets a conntrack lookup or a provisional entry allocated.
#
# This hook covers the forward path as well as the host path, so a blocked
# CIDR is blocked for pod-bound traffic too — the block list means "this peer
# is blocked on this node", not "blocked from the host's own services".
chain prerouting_blocklist {
type filter hook prerouting priority -300; policy accept;
ip saddr @blocked_addrs drop
ip6 saddr @blocked_addrs6 drop
}

# The hooked chain carries only what applies to every packet regardless of
# address family, then dispatches into the regular chains below — so an IPv4
# packet never evaluates an IPv6 rule and vice versa. A jump that returns
Expand All @@ -21,6 +34,12 @@ table inet weaver-host-firewall {
# entry added here drops already-open connections too. Purely
# operator-managed: nothing else ever writes to these sets. One rule per
# family — two address compares are cheaper than a dispatch.
#
# Redundant for anything arriving on a wire, since prerouting_blocklist
# already dropped it. Kept because this ordering — block list ahead of the
# conntrack fast-path — is the tested definition of what the block list
# does on the host path, and it should not become contingent on a chain
# registered on a different hook.
ip saddr @blocked_addrs drop
ip6 saddr @blocked_addrs6 drop

Expand Down Expand Up @@ -111,4 +130,19 @@ table inet weaver-host-firewall {
# SSH / management access from the allowlist only.
ip6 saddr @mgmt_addrs6 tcp dport 22 accept
}

# Block-list symmetry on locally-generated traffic. Dropping a peer inbound
# does not stop this host from dialing it, and once the host initiates, the
# replies are admitted by the input chain's `ct state established` accept —
# so an inbound-only block list does not actually block the connection.
#
# `policy accept`: this is not an egress allowlist. Enumerating legitimate
# outbound traffic on a Kubernetes node (kubelet to the API server, etcd,
# DNS, NTP, image pulls from arbitrary registries, Cilium, Teleport) is both
# large and brittle, and getting it wrong strands the node.
chain output {
type filter hook output priority 0; policy accept;
ip daddr @blocked_addrs drop
ip6 daddr @blocked_addrs6 drop
}
}
14 changes: 7 additions & 7 deletions internal/network/policy/manager.go
Original file line number Diff line number Diff line change
Expand Up @@ -697,8 +697,9 @@ func (m *Manager) showOne(ctx context.Context, p *Policy) (string, error) {
// live membership (which the destructive re-render wipes), removes the
// registry file, and atomically rewrites network-weaver-workload-policy.nft.
//
// If this is the last policy, an empty chain (policy drop, no rules) is
// applied and the boot oneshot is left enabled.
// If this is the last policy, the table is torn down entirely — live and on
// disk — rather than left registered with nothing to classify. The boot oneshot
// is left enabled.
func (m *Manager) Delete(ctx context.Context, name string) error {
return m.withLock(func() error {
policies, err := loadAll(m.registryDir)
Expand All @@ -725,11 +726,10 @@ func (m *Manager) Delete(ctx context.Context, name string) error {

if len(remaining) == 0 {
// Deleting the last policy: tear the whole table down rather than
// render an empty chain. Render([]) emits `policy drop` with no
// accept rule for new connections — a blackhole that Apply() would
// load into the kernel and atomicWrite would persist for replay at
// boot. Remove the live table (if present) and the persisted file so
// an empty registry means "no inet weaver-workload-policy table", live or on disk.
// render a chain with no rules, which Apply() would load into the
// kernel and atomicWrite would persist for replay at boot. Remove the
// live table (if present) and the persisted file so an empty registry
// means "no inet weaver-workload-policy table", live or on disk.
if exists, err := m.runner.Exists(ctx); err != nil {
return errorx.Decorate(err, "failed to check the inet weaver-workload-policy table while removing the last policy")
} else if exists {
Expand Down
4 changes: 2 additions & 2 deletions internal/network/policy/manager_ops_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -331,8 +331,8 @@ func TestDelete_LastPolicy_TearsDownTable(t *testing.T) {

require.NoError(t, m.Delete(context.Background(), "bn-restricted"))

// Deleting the last policy tears the whole table down rather than applying
// an empty policy-drop chain that would blackhole all forwarded traffic.
// Deleting the last policy tears the whole table down rather than leaving an
// empty table registered on the forward hook.
require.False(t, r.exists, "inet weaver-workload-policy table must be deleted after the last policy is removed")
// The persisted file is removed so the boot oneshot replays nothing.
require.NoFileExists(t, nftPath)
Expand Down
Loading