From a8f7d1f92e27965e57d15a917acaf54bf8f4cc52 Mon Sep 17 00:00:00 2001 From: chen21019 Date: Mon, 14 Sep 2026 12:21:22 +0800 Subject: [PATCH] Restore shared overlay forwarding --- COMPATIBILITY.md | 24 +++++++++++++---------- README.md | 15 ++++++++++----- hostports/nft.go | 5 ++++- hostports/watcher.go | 40 ++++++++++++++++++++++++++++++++++++++- hostports/watcher_test.go | 28 ++++++++++++++++++++++++--- 5 files changed, 92 insertions(+), 20 deletions(-) diff --git a/COMPATIBILITY.md b/COMPATIBILITY.md index ad75e26..39509ec 100644 --- a/COMPATIBILITY.md +++ b/COMPATIBILITY.md @@ -82,16 +82,20 @@ datagrams at Docker's bridge filter. The filter-path rule also requires DNAT state and the exact target address, protocol, and port. It does not create a host-wide accept rule or change Docker's default FORWARD policy. -Each managed bridge subnet may initiate forwarding, while traffic returning -to it is accepted only in `ESTABLISHED` or `RELATED` conntrack states. -Unsolicited traffic whose destination is a managed subnet is not admitted by -this rule. Both directions are matched against the exact validated CNI bridge -and subnet pair; missing or conflicting bridge metadata fails before any -firewall change. Native nftables applies Docker's configured bridge-accept -mark on both directions; the two iptables frontends use only the selected -`CATTLE_FORWARD` chain. This bounded rule is what permits current Docker -filters to carry Metadata, DNS, and normal workload egress without trusting a -spoofed managed prefix arriving on another host interface. +Each managed bridge subnet may initiate forwarding, while ordinary return +traffic is limited to `ESTABLISHED` or `RELATED` conntrack states. A shared +overlay may additionally opt into new inbound flows with +`allowSharedSubnetIngress: true`; for compatibility, a fixed bridge subnet +with `hostNat: true` has the same meaning when the explicit key is absent. +That exception requires both source and destination to match the exact shared +subnet and requires the packet to leave through the exact managed bridge. +Host-label-based per-host subnets cannot enable this shared exception and use +their narrower active-peer rules instead. Missing, conflicting, or non-boolean +bridge metadata fails before any firewall change. Native nftables applies +Docker's configured bridge-accept mark; the two iptables frontends use only +the selected `CATTLE_FORWARD` chain. IPsec supplies authenticated transport; +VXLAN and flat Layer 2 deployments still depend on a trusted underlay and +must not expose the managed subnet to untrusted source spoofing. For a CNI bridge marked `skipBridgeConfigureIP`, the workload uses an external Layer 2 gateway. The manager therefore masquerades only its own DNAT diff --git a/README.md b/README.md index 97c4908..aca01bc 100644 --- a/README.md +++ b/README.md @@ -68,10 +68,15 @@ It restores target-scoped authorization for every packet in an owned DNAT flow, including later UDP datagrams, without accepting unrelated Docker traffic. -The current release is `v0.8.19`. Managed bridge subnets can initiate -outbound traffic and receive only established or related replies; this keeps -Metadata and DNS reachable behind current Docker bridge filters without -opening unsolicited inbound forwarding or changing the host's global policy. +The current release is `v0.8.20`. Managed bridge subnets can initiate +outbound traffic and receive established or related replies. Shared overlay +subnets used by IPsec and VXLAN can also receive new connections from the +same validated subnet through the exact managed bridge. Existing templates +with `hostNat: true` retain this behavior; current templates declare +`allowSharedSubnetIngress: true` explicitly. Per-host subnets remain limited +to the active peer CIDRs derived from host labels. These rules keep Metadata, +DNS, and cross-host workload traffic reachable behind current Docker bridge +filters without changing the host's global policy. Every forwarding exception is bound to the exact validated CNI bridge and subnet pair; missing or conflicting bridge metadata fails before any firewall change. This prevents traffic arriving on an unrelated host interface from @@ -183,7 +188,7 @@ The Alpine 3.23 base image is digest-pinned. Direct runtime packages are exact-v make test make validate bash scripts/check-build-downloads -VERSION_OVERRIDE=v0.8.19 IMAGE_NAMESPACE=local/pasturestack make package +VERSION_OVERRIDE=v0.8.20 IMAGE_NAMESPACE=local/pasturestack make package ``` Pull requests and `main` run one non-publishing gate: tests, vet/format checks, govulncheck, a reproducible binary build, one runtime image build, and Trivy scans plus CycloneDX SBOMs for the source, binary, and image. All reported vulnerabilities and secrets fail the gate. Publishing remains a separate, explicitly authorized operation. diff --git a/hostports/nft.go b/hostports/nft.go index 503c7f0..795bc2d 100644 --- a/hostports/nft.go +++ b/hostports/nft.go @@ -53,7 +53,7 @@ func (w *watcher) checkNFTRules() error { ruleCount["postrouting"]++ ruleCount["postrouting"]++ } - ruleCount["forward"] = 2 + 2*len(sortedForwardNetworks(w.applied)) + len(sortedForwardPeers(w.applied)) + len(w.applied.Ports) + ruleCount["forward"] = 2 + 2*len(sortedForwardNetworks(w.applied)) + len(sortedSharedIngressNetworks(w.applied)) + len(sortedForwardPeers(w.applied)) + len(w.applied.Ports) seen := map[string]bool{} actualRules := map[string]int{} for _, item := range listing.NFTables { @@ -182,6 +182,9 @@ func nftHostportBatch(rules ruleSet, existing bool) []byte { fmt.Fprintf(buf, " iifname %q ip saddr %s ct state new,established,related meta mark set meta mark | 0x1068 accept\n", network.Bridge, network.Subnet) fmt.Fprintf(buf, " oifname %q ip daddr %s ct state established,related meta mark set meta mark | 0x1068 accept\n", network.Bridge, network.Subnet) } + for _, network := range sortedSharedIngressNetworks(rules) { + fmt.Fprintf(buf, " oifname %q ip saddr %s ip daddr %s ct state new,established,related meta mark set meta mark | 0x1068 accept\n", network.Bridge, network.Subnet, network.Subnet) + } for _, pair := range sortedForwardPeers(rules) { fmt.Fprintf(buf, " oifname %q ip saddr %s ip daddr %s meta mark set meta mark | 0x1068 accept\n", pair.Bridge, pair.Peer, pair.Local) } diff --git a/hostports/watcher.go b/hostports/watcher.go index 3d3c1eb..8b361c6 100644 --- a/hostports/watcher.go +++ b/hostports/watcher.go @@ -96,6 +96,7 @@ type ruleSet struct { ForwardSubnets map[string]string ForwardBridges map[string]string ForwardPeers map[string][]string + SharedIngress map[string]bool RouteLocalnetBridges map[string]bool } @@ -370,6 +371,12 @@ func (w *watcher) onChangeLocked(version string, force bool) error { } newRules.ForwardSubnets[uuid] = resolved newRules.ForwardBridges[uuid] = bridgeConfig.Bridge + if bridgeConfig.SharedIngress { + if newRules.SharedIngress == nil { + newRules.SharedIngress = map[string]bool{} + } + newRules.SharedIngress[uuid] = true + } if hostlabel.IsReference(subnet) { if newRules.ForwardPeers == nil { newRules.ForwardPeers = map[string][]string{} @@ -490,6 +497,9 @@ func (w *watcher) apply(rules ruleSet) error { buf.WriteString(fmt.Sprintf("-A CATTLE_FORWARD -i %s -s %s -m conntrack --ctstate NEW,ESTABLISHED,RELATED -j ACCEPT\n", network.Bridge, network.Subnet)) buf.WriteString(fmt.Sprintf("-A CATTLE_FORWARD -o %s -d %s -m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPT\n", network.Bridge, network.Subnet)) } + for _, network := range sortedSharedIngressNetworks(rules) { + buf.WriteString(fmt.Sprintf("-A CATTLE_FORWARD -o %s -s %s -d %s -m conntrack --ctstate NEW,ESTABLISHED,RELATED -j ACCEPT\n", network.Bridge, network.Subnet, network.Subnet)) + } for _, pair := range sortedForwardPeers(rules) { buf.WriteString(fmt.Sprintf("-A CATTLE_FORWARD -o %s -s %s -d %s -j ACCEPT\n", pair.Bridge, pair.Peer, pair.Local)) } @@ -566,6 +576,11 @@ func validateRuleSet(rules ruleSet) error { return fmt.Errorf("forward bridge network %s has no local subnet", uuid) } } + for uuid, enabled := range rules.SharedIngress { + if enabled && (rules.ForwardSubnets[uuid] == "" || rules.ForwardBridges[uuid] == "") { + return fmt.Errorf("shared ingress network %s has no managed bridge and subnet", uuid) + } + } for uuid, peers := range rules.ForwardPeers { if rules.ForwardSubnets[uuid] == "" { return fmt.Errorf("forward peer network %s has no local subnet", uuid) @@ -659,6 +674,7 @@ type bridgeNetworkConfig struct { Bridge string Subnet string ExternalGateway bool + SharedIngress bool } func bridgeConfigForNetwork(network metadata.Network) (bridgeNetworkConfig, error) { @@ -682,7 +698,19 @@ func bridgeConfigForNetwork(network metadata.Network) (bridgeNetworkConfig, erro bridge, _ := props["bridge"].(string) bridgeSubnet, _ := props["bridgeSubnet"].(string) externalGateway, _ := props["skipBridgeConfigureIP"].(bool) - candidate := bridgeNetworkConfig{Bridge: bridge, Subnet: bridgeSubnet, ExternalGateway: externalGateway} + hostNat, _ := props["hostNat"].(bool) + sharedIngress := hostNat && bridgeSubnet != "" && !hostlabel.IsReference(bridgeSubnet) + if configured, exists := props["allowSharedSubnetIngress"]; exists { + value, ok := configured.(bool) + if !ok { + return bridgeNetworkConfig{}, fmt.Errorf("managed bridge entry %q has a non-boolean allowSharedSubnetIngress", key) + } + sharedIngress = value + } + if sharedIngress && hostlabel.IsReference(bridgeSubnet) { + return bridgeNetworkConfig{}, fmt.Errorf("managed bridge entry %q cannot combine shared ingress with a host-label subnet", key) + } + candidate := bridgeNetworkConfig{Bridge: bridge, Subnet: bridgeSubnet, ExternalGateway: externalGateway, SharedIngress: sharedIngress} if !found { result = candidate resultKey = key @@ -732,6 +760,16 @@ func sortedForwardNetworks(rules ruleSet) []forwardNetwork { return result } +func sortedSharedIngressNetworks(rules ruleSet) []forwardNetwork { + result := []forwardNetwork{} + for _, network := range sortedForwardNetworks(rules) { + if rules.SharedIngress[network.UUID] { + result = append(result, network) + } + } + return result +} + type forwardPair struct{ Peer, Local, Bridge string } func sortedForwardPeers(rules ruleSet) []forwardPair { diff --git a/hostports/watcher_test.go b/hostports/watcher_test.go index 5085799..7d9da55 100644 --- a/hostports/watcher_test.go +++ b/hostports/watcher_test.go @@ -32,6 +32,25 @@ func TestForwardSubnetSupportsPastureBridge(t *testing.T) { } } +func TestSharedIngressDefaultsFromLegacyHostNatAndCanBeDisabled(t *testing.T) { + legacy := metadata.Network{Metadata: map[string]interface{}{"cniConfig": map[string]interface{}{ + "10-overlay.conf": map[string]interface{}{ + "type": "pasture-bridge", "bridge": "cattle0", "bridgeSubnet": "10.42.0.0/16", "hostNat": true, + }, + }}} + config, err := bridgeConfigForNetwork(legacy) + if err != nil || !config.SharedIngress { + t.Fatalf("legacy shared overlay config = %#v, %v", config, err) + } + + disabled := legacy.Metadata["cniConfig"].(map[string]interface{})["10-overlay.conf"].(map[string]interface{}) + disabled["allowSharedSubnetIngress"] = false + config, err = bridgeConfigForNetwork(legacy) + if err != nil || config.SharedIngress { + t.Fatalf("explicitly disabled shared ingress config = %#v, %v", config, err) + } +} + func TestFlatBridgeUsesOwnedDNATMasquerade(t *testing.T) { flat := metadata.Network{Metadata: map[string]interface{}{ "cniConfig": map[string]interface{}{ @@ -219,6 +238,7 @@ func testRuleSet() ruleSet { }, ForwardSubnets: map[string]string{"network": "10.42.0.0/16"}, ForwardBridges: map[string]string{"network": "cattle0"}, + SharedIngress: map[string]bool{"network": true}, RouteLocalnetBridges: map[string]bool{"cattle0": true}, } } @@ -259,7 +279,7 @@ func TestPublishedUDPDNATIsAcceptedForWholeFlow(t *testing.T) { } } -func TestManagedSubnetAllowsOutboundAndEstablishedReturnOnly(t *testing.T) { +func TestManagedSharedSubnetAllowsBidirectionalOverlayTraffic(t *testing.T) { rules := testRuleSet() var iptablesRules string w := &watcher{ @@ -277,18 +297,20 @@ func TestManagedSubnetAllowsOutboundAndEstablishedReturnOnly(t *testing.T) { for _, want := range []string{ "-A CATTLE_FORWARD -i cattle0 -s 10.42.0.0/16 -m conntrack --ctstate NEW,ESTABLISHED,RELATED -j ACCEPT", "-A CATTLE_FORWARD -o cattle0 -d 10.42.0.0/16 -m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPT", + "-A CATTLE_FORWARD -o cattle0 -s 10.42.0.0/16 -d 10.42.0.0/16 -m conntrack --ctstate NEW,ESTABLISHED,RELATED -j ACCEPT", } { if !strings.Contains(iptablesRules, want) { t.Fatalf("iptables rules missing %q", want) } } - if strings.Contains(iptablesRules, "-d 10.42.0.0/16 -m conntrack --ctstate NEW") { - t.Fatal("unsolicited inbound traffic was allowed") + if strings.Contains(iptablesRules, "-o cattle0 -s 0.0.0.0/0") { + t.Fatal("shared ingress was not bounded to the managed source subnet") } nftRules := string(nftHostportBatch(rules, false)) for _, want := range []string{ "iifname \"cattle0\" ip saddr 10.42.0.0/16 ct state new,established,related meta mark set meta mark | 0x1068 accept", "oifname \"cattle0\" ip daddr 10.42.0.0/16 ct state established,related meta mark set meta mark | 0x1068 accept", + "oifname \"cattle0\" ip saddr 10.42.0.0/16 ip daddr 10.42.0.0/16 ct state new,established,related meta mark set meta mark | 0x1068 accept", } { if !strings.Contains(nftRules, want) { t.Fatalf("nft rules missing %q", want)