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
12 changes: 11 additions & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -177,6 +177,15 @@ jobs:
digest="${reference#*@}"
[[ "$digest" =~ ^sha256:[0-9a-f]{64}$ ]]
printf 'service_digest=%s\n' "$digest" >> "$GITHUB_OUTPUT"
{
printf 'image=%s\n' "$SERVICE_IMAGE"
printf 'digest=%s\n' "$digest"
printf 'source_commit=%s\n' "$(git rev-parse HEAD)"
} > dist/release/published.txt
(
cd dist/release
sha256sum published.txt >> SHA256SUMS
)

- name: Attest release assets
uses: actions/attest-build-provenance@4d101475d8b20a2381f78447822ac1eab6504dd8 # v4.2.2
Expand All @@ -203,7 +212,8 @@ jobs:
"dist/release/network-plugin-manager-${VERSION}-linux-amd64.tar.xz" \
"dist/release/network-plugin-manager-${VERSION}-source.cdx.json" \
"dist/release/network-plugin-manager-${VERSION}-image.cdx.json" \
dist/release/SHA256SUMS
dist/release/SHA256SUMS \
dist/release/published.txt

- name: Clean owned runner resources
if: always()
Expand Down
25 changes: 25 additions & 0 deletions COMPATIBILITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -81,3 +81,28 @@ conntracked UDP flow; relying on its firewall mark alone can drop later VXLAN
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.

For a CNI bridge marked `skipBridgeConfigureIP`, the workload uses an
external Layer 2 gateway. The manager therefore masquerades only its own DNAT
flows to an exact published address, protocol, and port, preventing replies
from bypassing the publishing host. Other bridge networks preserve source
addresses; only locally originated host-port traffic is masqueraded. When a
host port accepts loopback traffic, `route_localnet` is enabled only on that
specific managed bridge and is rechecked during reconciliation. Before that
sysctl is enabled, the selected backend must validate and install a
bridge-scoped raw-prerouting drop for `127.0.0.0/8`. The manager records the
original per-bridge value in host-mounted runtime state and restores it before
removing the last guard. Failed preflight or firewall application never opens
the sysctl. No global `route_localnet`, forwarding policy, or third-party NAT
rule is changed.
30 changes: 28 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ themselves establish a managed-service or multi-host rollout.

The `v0.8.16` image was published with GHCR manifest digest
`sha256:a042c582689561b43349fa83ed92269e849038be3b7a2342e8a9ef0149460f92`.
The current published image is `v0.8.17`, with GHCR manifest digest
The `v0.8.17` image was published with GHCR manifest digest
`sha256:f13654b27b71f3fbddbcf33272c10b342d513dd402a255bdda1f341cfbe908f8`.
Its signed tag resolves to verified source commit
`e29dd5cefa373140d76e3a21da9bd95a3bec97e3`; the release workflow passed
Expand All @@ -62,6 +62,32 @@ host ports all passed. The manager follows the Docker-selected backend; it
does not change the host's firewall preference. This bounded test
does not establish every existing iptables or IPsec deployment's migration safety.

The `v0.8.18` image was published with GHCR manifest digest
`sha256:1f5d44de03648a771ec9e7bc448e456ef6b21a5fcd4cc51f59f99df96a804822`.
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.
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
claiming a managed source prefix.
Host ports on a flat L2 network receive target-scoped DNAT masquerading so
replies return through the publishing host even when workloads use an
external gateway. Loopback host-port access enables `route_localnet` only on
the exact managed bridge that needs it, after a bridge-scoped raw-prerouting
drop for `127.0.0.0/8` is live. The original per-bridge value is recorded on
the host-mounted runtime state and restored before the final guard is removed.
Overlay host ports retain client source addresses except for locally
originated access. Obtain the immutable
image identity from the release's checksum-covered
[`published.txt`](https://github.com/PastureStack/network-plugin-manager/releases/latest/download/published.txt)
rather than copying an older release digest.

The current preflight inspects already loaded legacy tables using an
independent iptables-legacy executable. Active old platform or Docker hooks
in the other frontend block startup; an unhooked chain declaration alone does
Expand Down Expand Up @@ -157,7 +183,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.17 IMAGE_NAMESPACE=local/pasturestack make package
VERSION_OVERRIDE=v0.8.19 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
8 changes: 6 additions & 2 deletions hostports/iptables_vm_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ func testIptablesOnDisposableVM(t *testing.T, mode firewall.Mode) {
if out, err := exec.Command(command, "-t", "nat", "-S", "DOCKER").CombinedOutput(); err != nil {
t.Fatalf("requires Docker-owned NAT chain in %s: %v: %s", mode, err, out)
}
for _, table := range []string{"nat", "filter"} {
for _, table := range []string{"raw", "nat", "filter"} {
out, err := xtVMCommand(command, "-t", table, "-S")
if err != nil {
t.Fatalf("inspect existing %s rules: %v: %s", table, err, out)
Expand All @@ -68,13 +68,15 @@ func testIptablesOnDisposableVM(t *testing.T, mode firewall.Mode) {
"isolated": {Bridge: "pstest0", SourceIP: "198.51.100.2", SourcePort: "55555", TargetIP: "10.254.250.2", TargetPort: "55556", Protocol: "tcp"},
},
ForwardSubnets: map[string]string{"isolated": "10.254.250.0/24"},
ForwardBridges: map[string]string{"isolated": "pstest0"},
}
w := &watcher{backend: firewall.Backend{Mode: mode, Command: command, Restore: restore}}
for attempt := 1; attempt <= 2; attempt++ {
if err := w.apply(rules); err != nil {
t.Fatalf("iptables-nft apply %d (includes --test -n): %v", attempt, err)
}
for _, hook := range []struct{ table, chain, target string }{
{"raw", "PREROUTING", hostPortsRawChain},
{"nat", "PREROUTING", "CATTLE_PREROUTING"},
{"nat", "OUTPUT", "CATTLE_OUTPUT"},
{"nat", "POSTROUTING", hostPortsPostRoutingChain},
Expand All @@ -100,6 +102,7 @@ func cleanupXTTestRules(t *testing.T, command string) {
table, chain string
spec []string
}{
{"raw", "PREROUTING", []string{"-j", hostPortsRawChain}},
{"nat", "PREROUTING", []string{"-m", "addrtype", "--dst-type", "LOCAL", "-j", "CATTLE_PREROUTING"}},
{"nat", "OUTPUT", []string{"-m", "addrtype", "--dst-type", "LOCAL", "-j", "CATTLE_OUTPUT"}},
{"nat", "POSTROUTING", []string{"-j", hostPortsPostRoutingChain}},
Expand All @@ -118,6 +121,7 @@ func cleanupXTTestRules(t *testing.T, command string) {
}
}
for _, entry := range []struct{ table, chain string }{
{"raw", hostPortsRawChain},
{"nat", "CATTLE_PREROUTING"},
{"nat", "CATTLE_POSTROUTING"},
{"nat", "CATTLE_OUTPUT"},
Expand All @@ -135,7 +139,7 @@ func cleanupXTTestRules(t *testing.T, command string) {
t.Errorf("delete own chain %s/%s: %v: %s", entry.table, entry.chain, err, out)
}
}
for _, table := range []string{"nat", "filter"} {
for _, table := range []string{"raw", "nat", "filter"} {
out, err := xtVMCommand(command, "-t", table, "-S")
if err != nil {
t.Errorf("verify %s cleanup: %v: %s", table, err, out)
Expand Down
5 changes: 4 additions & 1 deletion hostports/lifecycle_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,9 @@ func emptyHostportWatcher() *watcher {
localHost: func(metadata.Client, *client.Client) (metadata.Host, error) {
return metadata.Host{UUID: "host-1"}, nil
},
applied: ruleSet{Ports: map[string]PortRule{}, ForwardSubnets: map[string]string{}},
applied: ruleSet{
Ports: map[string]PortRule{}, ForwardSubnets: map[string]string{}, ForwardBridges: map[string]string{}, RouteLocalnetBridges: map[string]bool{},
},
lastApplied: time.Now(),
}
}
Expand All @@ -44,6 +46,7 @@ func TestNoPerHostNetworkDoesNotReapplyUnchangedFirewall(t *testing.T) {

const intactEmptyNFT = `{"nftables":[
{"table":{"family":"ip","name":"pasturestack_hostports"}},
{"chain":{"family":"ip","table":"pasturestack_hostports","name":"raw_prerouting","type":"filter","hook":"prerouting","prio":-300,"policy":"accept"}},
{"chain":{"family":"ip","table":"pasturestack_hostports","name":"prerouting","type":"nat","hook":"prerouting","prio":-101,"policy":"accept"}},
{"chain":{"family":"ip","table":"pasturestack_hostports","name":"output","type":"nat","hook":"output","prio":-101,"policy":"accept"}},
{"chain":{"family":"ip","table":"pasturestack_hostports","name":"postrouting","type":"nat","hook":"postrouting","prio":99,"policy":"accept"}},
Expand Down
36 changes: 28 additions & 8 deletions hostports/nft.go
Original file line number Diff line number Diff line change
Expand Up @@ -35,12 +35,14 @@ func (w *watcher) checkNFTRules() error {
Policy string `json:"policy"`
}
expected := map[string]chain{
"prerouting": {Type: "nat", Hook: "prerouting", Prio: -101, Policy: "accept"},
"output": {Type: "nat", Hook: "output", Prio: -101, Policy: "accept"},
"postrouting": {Type: "nat", Hook: "postrouting", Prio: 99, Policy: "accept"},
"forward": {Type: "filter", Hook: "forward", Prio: -1, Policy: "accept"},
"raw_prerouting": {Type: "filter", Hook: "prerouting", Prio: -300, Policy: "accept"},
"prerouting": {Type: "nat", Hook: "prerouting", Prio: -101, Policy: "accept"},
"output": {Type: "nat", Hook: "output", Prio: -101, Policy: "accept"},
"postrouting": {Type: "nat", Hook: "postrouting", Prio: 99, Policy: "accept"},
"forward": {Type: "filter", Hook: "forward", Prio: -1, Policy: "accept"},
}
ruleCount := map[string]int{}
ruleCount["raw_prerouting"] = len(sortedEnabledBridges(w.applied.RouteLocalnetBridges))
for _, p := range w.applied.Ports {
ruleCount["prerouting"]++
if p.Bridge != "" {
Expand All @@ -49,8 +51,9 @@ func (w *watcher) checkNFTRules() error {
}
ruleCount["output"]++
ruleCount["postrouting"]++
ruleCount["postrouting"]++
}
ruleCount["forward"] = 2 + len(sortedForwardSubnets(w.applied.ForwardSubnets)) + len(sortedForwardPeers(w.applied)) + len(w.applied.Ports)
ruleCount["forward"] = 2 + 2*len(sortedForwardNetworks(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 @@ -109,9 +112,15 @@ func (w *watcher) applyNFT(rules ruleSet) error {
if err := w.restore("nft", []string{"-c", "-f", "-"}, batch); err != nil {
return fmt.Errorf("validate native nft hostport batch: %w", err)
}
if err := w.restoreRemovedRouteLocalnet(rules); err != nil {
return err
}
if err := w.restore("nft", []string{"-f", "-"}, batch); err != nil {
return fmt.Errorf("apply native nft hostport batch: %w", err)
}
if err := w.activateRouteLocalnet(rules); err != nil {
return err
}
w.applied = rules
w.lastApplied = time.Now()
return nil
Expand All @@ -123,6 +132,11 @@ func nftHostportBatch(rules ruleSet, existing bool) []byte {
fmt.Fprintf(buf, "delete table ip %s\n", nftHostportsTable)
}
fmt.Fprintf(buf, "table ip %s {\n", nftHostportsTable)
buf.WriteString(" chain raw_prerouting {\n type filter hook prerouting priority -300; policy accept;\n")
for _, bridge := range sortedEnabledBridges(rules.RouteLocalnetBridges) {
fmt.Fprintf(buf, " iifname %q ip daddr 127.0.0.0/8 drop\n", bridge)
}
buf.WriteString(" }\n")
buf.WriteString(" chain prerouting {\n type nat hook prerouting priority -101; policy accept;\n")
keys := make([]string, 0, len(rules.Ports))
for key := range rules.Ports {
Expand Down Expand Up @@ -157,13 +171,19 @@ func nftHostportBatch(rules ruleSet, existing bool) []byte {
fmt.Fprintf(buf, " iifname %q oifname %q ip daddr %s %s dport %s masquerade\n", p.Bridge, p.Bridge, p.TargetIP, p.Protocol, p.TargetPort)
}
fmt.Fprintf(buf, " ip saddr %s ip daddr %s %s dport %s masquerade\n", p.TargetIP, p.TargetIP, p.Protocol, p.TargetPort)
if p.MasqueradeDNAT {
fmt.Fprintf(buf, " ct status dnat ip daddr %s %s dport %s masquerade\n", p.TargetIP, p.Protocol, p.TargetPort)
} else {
fmt.Fprintf(buf, " fib saddr type local ct status dnat ip daddr %s %s dport %s masquerade\n", p.TargetIP, p.Protocol, p.TargetPort)
}
}
buf.WriteString(" }\n chain forward {\n type filter hook forward priority -1; policy accept;\n")
for _, subnet := range sortedForwardSubnets(rules.ForwardSubnets) {
fmt.Fprintf(buf, " ip saddr %s ip daddr %s meta mark set meta mark | 0x1068 accept\n", subnet, subnet)
for _, network := range sortedForwardNetworks(rules) {
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 _, pair := range sortedForwardPeers(rules) {
fmt.Fprintf(buf, " ip saddr %s ip daddr %s meta mark set meta mark | 0x1068 accept\n", pair.Peer, pair.Local)
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)
}
// NAT chains see only the first packet of a conntracked flow. Restore the
// Docker bridge-accept mark on every forwarded datagram, scoped to our
Expand Down
4 changes: 3 additions & 1 deletion hostports/nft_lifecycle_vm_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,9 @@ func TestNativeNFTLifecycleInUnsharedVMNamespace(t *testing.T) {
localHost: func(metadata.Client, *client.Client) (metadata.Host, error) {
return metadata.Host{UUID: "test-host", AgentIP: "198.51.100.2"}, nil
},
applied: ruleSet{Ports: map[string]PortRule{}, ForwardSubnets: map[string]string{}},
applied: ruleSet{
Ports: map[string]PortRule{}, ForwardSubnets: map[string]string{}, ForwardBridges: map[string]string{}, RouteLocalnetBridges: map[string]bool{},
},
}
if err := w.onChange("initial"); err != nil {
t.Fatalf("initial production nft apply: %v", err)
Expand Down
4 changes: 3 additions & 1 deletion hostports/readiness_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,9 @@ func TestMalformedEligibleHostPortFailsReconcileWithoutApplying(t *testing.T) {
localHost: func(metadata.Client, *client.Client) (metadata.Host, error) {
return metadata.Host{UUID: "host-1", AgentIP: "192.0.2.10"}, nil
},
applied: ruleSet{Ports: map[string]PortRule{}, ForwardSubnets: map[string]string{}},
applied: ruleSet{
Ports: map[string]PortRule{}, ForwardSubnets: map[string]string{}, ForwardBridges: map[string]string{}, RouteLocalnetBridges: map[string]bool{},
},
}
err := w.onChange("test")
if err == nil || !strings.Contains(err.Error(), "invalid host port definition") {
Expand Down
Loading