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
24 changes: 14 additions & 10 deletions COMPATIBILITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
15 changes: 10 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down
5 changes: 4 additions & 1 deletion hostports/nft.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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)
}
Expand Down
40 changes: 39 additions & 1 deletion hostports/watcher.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand Down Expand Up @@ -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{}
Expand Down Expand Up @@ -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))
}
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -659,6 +674,7 @@ type bridgeNetworkConfig struct {
Bridge string
Subnet string
ExternalGateway bool
SharedIngress bool
}

func bridgeConfigForNetwork(network metadata.Network) (bridgeNetworkConfig, error) {
Expand All @@ -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
Expand Down Expand Up @@ -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 {
Expand Down
28 changes: 25 additions & 3 deletions hostports/watcher_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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{}{
Expand Down Expand Up @@ -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},
}
}
Expand Down Expand Up @@ -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{
Expand All @@ -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)
Expand Down