From 7d5c01abb81066310b2f09f8dca226dcae30d8d3 Mon Sep 17 00:00:00 2001 From: Miguel Angel Ajo Pelayo Date: Mon, 7 Sep 2026 09:13:33 +0200 Subject: [PATCH 1/2] docs: document traffic filter for dut-network driver + E2E tests - Add 'Traffic Filtering' section to README.md with egress allowlist, egress denylist, and ingress filter examples - Add filter field to the parameter reference table - Add filter rule fields reference table - Add E2E exporter config with egress drop policy + TCP allow rule - Add E2E tests verifying allowed TCP passes, blocked TCP is dropped, and ICMP ping is blocked under egress drop policy - Refactor TCP server/client helpers into shared functions - Update e2e/README.md with filter sub-lane documentation Co-authored-by: Cursor --- e2e/README.md | 12 + .../exporter-dut-network-filter.yaml | 34 +++ e2e/test/dut_network_test.go | 223 +++++++++++++++--- .../jumpstarter-driver-dut-network/README.md | 70 ++++++ 4 files changed, 308 insertions(+), 31 deletions(-) create mode 100644 e2e/exporters/exporter-dut-network-filter.yaml diff --git a/e2e/README.md b/e2e/README.md index e3e656ab4..3c5865e97 100644 --- a/e2e/README.md +++ b/e2e/README.md @@ -136,6 +136,18 @@ offering the `dut-network` driver (nftables NAT/masquerade + DHCP/DNS). CI insta | should add, list, and remove DNS entries via CLI | `add-dns e2e-test.lab.local 10.0.0.42`, `dns-entries`, `remove-dns`, `dns-entries` | entry appears then disappears | | should allow TCP connections from DUT to external via NAT | start Python TCP server in ext ns, connect from DUT ns via NAT | client receives "E2E_OK" | +### Filter sub-lane (`exporter-dut-network-filter.yaml`) + +Same netns topology, separate exporter on port 19092 with an egress filter +(`policy: drop`, one `accept` rule for TCP port 9997 to `10.99.0.1/32`). + +| Test Name | Steps | Pass Check | +|---|---|---| +| should show filter rules in nftables output | `j dut-network nat-rules` | output contains "drop" and `dport 9997` | +| should allow TCP to the permitted port | TCP server on allowed port 9997, client connects from DUT ns | client receives "FILTER_OK" | +| should block TCP to a non-allowed port | TCP server on blocked port 9998, client connects from DUT ns | connection fails (timeout/reset) | +| should block ICMP ping when egress policy is drop | ping from DUT ns to ext IP | ping fails (`Consistently`) | + --- ## Lane: `exit-on-lease-end` (`exit_on_lease_end_test.go`) diff --git a/e2e/exporters/exporter-dut-network-filter.yaml b/e2e/exporters/exporter-dut-network-filter.yaml new file mode 100644 index 000000000..8f9cdb0ff --- /dev/null +++ b/e2e/exporters/exporter-dut-network-filter.yaml @@ -0,0 +1,34 @@ +apiVersion: jumpstarter.dev/v1alpha1 +kind: ExporterConfig +metadata: + name: test-exporter-dut-network-filter + namespace: default +export: + dut-network: + type: jumpstarter_driver_dut_network.driver.DutNetwork + config: + interface: "jmp-vhost" + subnet: "192.168.200.0/24" + gateway_ip: "192.168.200.1" + upstream_interface: "jmp-vup" + nat_mode: "masquerade" + dhcp_enabled: true + dhcp_range_start: "192.168.200.100" + dhcp_range_end: "192.168.200.200" + addresses: + - mac: "02:00:00:00:00:01" + ip: "192.168.200.10" + hostname: "test-dut" + dns_servers: ["8.8.8.8"] + state_dir: "/tmp/jmp-e2e-dut-network-filter" + filter: + egress: + policy: drop + rules: + # Allow only the blocked-check port (9997) to a specific + # destination, and deny everything else. The E2E test verifies + # that traffic to a non-allowed destination (port 9998) is dropped. + - action: accept + destination: "10.99.0.1/32" + port: 9997 + protocol: tcp diff --git a/e2e/test/dut_network_test.go b/e2e/test/dut_network_test.go index 4cb426da4..d1d8cd117 100644 --- a/e2e/test/dut_network_test.go +++ b/e2e/test/dut_network_test.go @@ -255,43 +255,204 @@ var _ = Describe("DUT Network E2E Tests", Label("dut-network"), Ordered, Continu Context("TCP connectivity", func() { It("should allow TCP connections from DUT to external via NAT", func() { - serverScript := "import socket; " + - "s=socket.socket(); " + - "s.setsockopt(socket.SOL_SOCKET,socket.SO_REUSEADDR,1); " + - "s.bind(('',9998)); " + - "s.listen(1); " + - "s.settimeout(10); " + - "conn,_=s.accept(); " + - "conn.sendall(b'E2E_OK'); " + - "conn.close(); " + - "s.close()" - - fullArgs := []string{"ip", "netns", "exec", extNs, "python3", "-c", serverScript} - bin, cmdArgs := sudoArgs(fullArgs...) - listener := exec.Command(bin, cmdArgs...) //nolint:gosec - listener.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} - Expect(listener.Start()).To(Succeed()) - defer func() { - _ = syscall.Kill(-listener.Process.Pid, syscall.SIGKILL) - _ = listener.Wait() - }() - - time.Sleep(500 * time.Millisecond) - - clientScript := fmt.Sprintf( - "import socket; "+ - "s=socket.create_connection(('%s',9998),timeout=5); "+ - "data=s.recv(10); "+ - "s.close(); "+ - "print(data.decode())", - extIP) - out, err := runInNsCapture(dutNs, "python3", "-c", clientScript) + srv := startTCPServer(extNs, 9998, "E2E_OK") + defer killCmd(srv) + + out, err := tcpConnect(dutNs, extIP, 9998, 5) Expect(err).NotTo(HaveOccurred(), fmt.Sprintf("TCP connection failed: %s", out)) Expect(out).To(ContainSubstring("E2E_OK")) }) }) }) +// startTCPServer starts a one-shot TCP listener in the given network namespace. +// It accepts a single connection, sends payload, and exits. Returns the +// *exec.Cmd so the caller can clean up via process-group kill. +func startTCPServer(ns string, port int, payload string) *exec.Cmd { + serverScript := fmt.Sprintf( + "import socket; "+ + "s=socket.socket(); "+ + "s.setsockopt(socket.SOL_SOCKET,socket.SO_REUSEADDR,1); "+ + "s.bind(('', %d)); "+ + "s.listen(1); "+ + "s.settimeout(15); "+ + "conn,_=s.accept(); "+ + "conn.sendall(b'%s'); "+ + "conn.close(); "+ + "s.close()", port, payload) + fullArgs := []string{"ip", "netns", "exec", ns, "python3", "-c", serverScript} + bin, cmdArgs := sudoArgs(fullArgs...) + cmd := exec.Command(bin, cmdArgs...) //nolint:gosec + cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} + ExpectWithOffset(1, cmd.Start()).To(Succeed()) + time.Sleep(500 * time.Millisecond) + return cmd +} + +func killCmd(cmd *exec.Cmd) { + _ = syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL) + _ = cmd.Wait() +} + +// tcpConnect attempts a TCP connection from the given namespace to host:port +// and returns whatever the server sends. +func tcpConnect(ns string, host string, port int, timeoutSec int) (string, error) { + clientScript := fmt.Sprintf( + "import socket; "+ + "s=socket.create_connection(('%s',%d),timeout=%d); "+ + "data=s.recv(64); "+ + "s.close(); "+ + "print(data.decode())", + host, port, timeoutSec) + return runInNsCapture(ns, "python3", "-c", clientScript) +} + +// Serial: reuses the same veth topology as the base dut-network tests but +// starts the exporter with a filter config that restricts egress to a single +// TCP port. Verifies that allowed traffic passes and everything else is dropped. +var _ = Describe("DUT Network Filter E2E Tests", Label("dut-network"), Ordered, ContinueOnFailure, Serial, func() { + var ( + tracker *ProcessTracker + listenerPort = 19092 + exporterDir string + ) + + const ( + dutNs = "jmp-e2e-dut" + extNs = "jmp-e2e-ext" + vethHost = "jmp-vhost" + vethDut = "jmp-vdut" + vethUp = "jmp-vup" + vethExt = "jmp-vext" + nftTable = "jumpstarter_jmp_vhost" + dutIP = "192.168.200.10" + gatewayIP = "192.168.200.1" + extIP = "10.99.0.1" + upstreamIP = "10.99.0.2" + subnet = "192.168.200.0/24" + allowedPort = 9997 + blockedPort = 9998 + ) + + setupNetworkNamespaces := func() { + runOrFail("ip", "netns", "add", dutNs) + runOrFail("ip", "netns", "add", extNs) + runOrFail("ip", "link", "add", vethHost, "type", "veth", "peer", "name", vethDut) + runOrFail("ip", "link", "set", vethDut, "netns", dutNs) + runOrFail("ip", "link", "set", vethHost, "address", "02:00:00:00:00:01") + runOrFail("ip", "link", "add", vethUp, "type", "veth", "peer", "name", vethExt) + runOrFail("ip", "link", "set", vethExt, "netns", extNs) + runOrFail("ip", "addr", "add", upstreamIP+"/24", "dev", vethUp) + runOrFail("ip", "link", "set", vethUp, "up") + runInNs(extNs, "ip", "addr", "add", extIP+"/24", "dev", vethExt) + runInNs(extNs, "ip", "link", "set", vethExt, "up") + runInNs(extNs, "ip", "link", "set", "lo", "up") + runInNs(extNs, "ip", "route", "add", subnet, "via", upstreamIP) + runInNs(dutNs, "ip", "addr", "add", dutIP+"/24", "dev", vethDut) + runInNs(dutNs, "ip", "link", "set", vethDut, "up") + runInNs(dutNs, "ip", "link", "set", "lo", "up") + runInNs(dutNs, "ip", "route", "add", "default", "via", gatewayIP) + } + + teardownNetworkNamespaces := func() { + runIgnoreErr("ip", "link", "del", vethHost) + runIgnoreErr("ip", "link", "del", vethUp) + runIgnoreErr("ip", "netns", "del", dutNs) + runIgnoreErr("ip", "netns", "del", extNs) + runIgnoreErr("nft", "delete", "table", "ip", nftTable) + runIgnoreErr("rm", "-rf", "/tmp/jmp-e2e-dut-network-filter") + } + + BeforeAll(func() { + if runtime.GOOS != "linux" { + Skip("requires Linux") + } + if !hasPrivileges() { + Skip("requires root or passwordless sudo") + } + tracker = NewProcessTracker() + exporterDir = filepath.Join(RepoRoot(), "e2e", "exporters") + teardownNetworkNamespaces() + setupNetworkNamespaces() + + configPath := filepath.Join(exporterDir, "exporter-dut-network-filter.yaml") + tracker.StartDirectExporter(configPath, listenerPort, "", false) + WaitForDirectExporterReady(listenerPort, "") + }) + + AfterAll(func() { + tracker.StopAll() + teardownNetworkNamespaces() + + Eventually(func() error { + conn, err := net.DialTimeout("tcp", fmt.Sprintf("127.0.0.1:%d", listenerPort), 500*time.Millisecond) + if err != nil { + return nil + } + conn.Close() + return fmt.Errorf("port %d is still open", listenerPort) + }, 10*time.Second, 500*time.Millisecond).Should(Succeed(), + "port %d should be closed after stopping exporter", listenerPort) + + tracker.Cleanup() + }) + + BeforeEach(func() { + tracker.WriteLogMarker(CurrentSpecReport().FullText()) + }) + + AfterEach(func() { + if CurrentSpecReport().Failed() { + tracker.DumpLogs(250) + } + }) + + jmpShell := func(args ...string) (string, error) { + shellArgs := []string{"shell", "--tls-grpc", fmt.Sprintf("127.0.0.1:%d", listenerPort), + "--tls-grpc-insecure", "--"} + shellArgs = append(shellArgs, args...) + return Jmp(shellArgs...) + } + + Context("Filter rules visible in NAT output", func() { + It("should show filter rules in nftables output", func() { + out, err := jmpShell("j", "dut-network", "nat-rules") + Expect(err).NotTo(HaveOccurred(), out) + Expect(out).To(ContainSubstring("drop")) + Expect(out).To(ContainSubstring(fmt.Sprintf("dport %d", allowedPort))) + }) + }) + + Context("Allowed traffic passes through filter", func() { + It("should allow TCP to the permitted port", func() { + srv := startTCPServer(extNs, allowedPort, "FILTER_OK") + defer killCmd(srv) + + out, err := tcpConnect(dutNs, extIP, allowedPort, 5) + Expect(err).NotTo(HaveOccurred(), fmt.Sprintf("allowed TCP failed: %s", out)) + Expect(out).To(ContainSubstring("FILTER_OK")) + }) + }) + + Context("Blocked traffic is dropped by filter", func() { + It("should block TCP to a non-allowed port", func() { + srv := startTCPServer(extNs, blockedPort, "SHOULD_NOT_ARRIVE") + defer killCmd(srv) + + _, err := tcpConnect(dutNs, extIP, blockedPort, 3) + Expect(err).To(HaveOccurred(), "connection to blocked port should fail") + }) + + It("should block ICMP ping when egress policy is drop", func() { + Consistently(func() error { + _, err := runInNsCapture(dutNs, "ping", "-c", "1", "-W", "1", extIP) + return err + }, 3*time.Second, 1*time.Second).Should(HaveOccurred(), + "ping should be blocked by egress drop policy") + }) + }) +}) + func runOrFail(args ...string) { bin, cmdArgs := sudoArgs(args...) cmd := exec.Command(bin, cmdArgs...) //nolint:gosec diff --git a/python/packages/jumpstarter-driver-dut-network/README.md b/python/packages/jumpstarter-driver-dut-network/README.md index 7cc06e20b..10e24cefb 100644 --- a/python/packages/jumpstarter-driver-dut-network/README.md +++ b/python/packages/jumpstarter-driver-dut-network/README.md @@ -107,6 +107,75 @@ export: ip: "10.26.28.2" ``` +### Traffic Filtering + +Control which network destinations DUTs can reach using nftables-based egress +and ingress rules. Filters are applied at the **interface level** (all traffic +through the DUT interface), so they cannot be bypassed by MAC or IP spoofing. + +**Egress allowlist** — block everything except specific destinations: + +```yaml +export: + dut-network: + type: jumpstarter_driver_dut_network.driver.DutNetwork + config: + interface: "eth2" + nat_mode: "masquerade" + filter: + egress: + policy: drop + rules: + - action: accept + destination: "198.51.100.0/24" + - action: accept + destination: "203.0.113.0/24" + port: 443 + protocol: tcp +``` + +**Egress denylist** — allow everything except specific destinations: + +```yaml + filter: + egress: + policy: accept + rules: + - action: drop + destination: "10.0.0.0/8" + - action: drop + destination: "172.16.0.0/12" +``` + +**Ingress filtering** — restrict inbound connections (new connections only; +return traffic for DUT-initiated connections is always allowed via conntrack): + +```yaml + filter: + ingress: + policy: drop + rules: + - action: accept + source: "198.51.100.0/24" + port: 22 + protocol: tcp +``` + +Both `egress` and `ingress` can be combined in the same config. The filter +is set by the exporter admin and is not modifiable by the DUT user at runtime. + +#### Filter Rule Fields + +| Field | Required | Description | +|-------|----------|-------------| +| `policy` | no | Default action: `accept` (default) or `drop` | +| `rules` | no | Ordered list of rules evaluated before the policy | +| `rules[].action` | yes | `accept` or `drop` | +| `rules[].destination` | no | CIDR for egress matching (e.g. `10.0.0.0/8`) | +| `rules[].source` | no | CIDR for ingress matching | +| `rules[].port` | no | Destination port (requires `protocol`) | +| `rules[].protocol` | no | `tcp` or `udp` (required when `port` is set) | + ### Reference | Parameter | Type | Default | Description | @@ -124,6 +193,7 @@ export: | `state_dir` | str | `/var/lib/jumpstarter/dut-network-{interface}/` | Directory for dnsmasq state files | | `nat_mode` | str | `masquerade` | NAT mode: `masquerade`, `1to1`, `disabled`, or `none` | | `public_interface` | str | None | Interface for IP alias (defaults to upstream) | +| `filter` | dict | None | Traffic filter config: `{egress?, ingress?}` with `policy` and `rules` (see above) | #### Address Entry Fields From 3aaeb117bc03b1e0cfeb46dfe422999e77115f45 Mon Sep 17 00:00:00 2001 From: Miguel Angel Ajo Pelayo Date: Mon, 7 Sep 2026 09:54:52 +0200 Subject: [PATCH 2/2] fix: wait for TCP server readiness signal instead of fixed sleep Address CodeRabbit review: startTCPServer now waits for a 'READY' line on stdout (emitted after listen() succeeds) before returning. This ensures the blocked-port test exercises the firewall filter against an actual open listener, not a missing one. Also adds doc comment to killCmd for docstring coverage. Co-authored-by: Cursor --- e2e/test/dut_network_test.go | 40 ++++++++++++++++++++++++++++++++---- 1 file changed, 36 insertions(+), 4 deletions(-) diff --git a/e2e/test/dut_network_test.go b/e2e/test/dut_network_test.go index d1d8cd117..e1c4f5139 100644 --- a/e2e/test/dut_network_test.go +++ b/e2e/test/dut_network_test.go @@ -266,29 +266,61 @@ var _ = Describe("DUT Network E2E Tests", Label("dut-network"), Ordered, Continu }) // startTCPServer starts a one-shot TCP listener in the given network namespace. -// It accepts a single connection, sends payload, and exits. Returns the +// It accepts a single connection, sends payload, and exits. The function +// waits for a "READY" line on stdout (emitted after listen()) so callers +// know the port is actually open before sending traffic. Returns the // *exec.Cmd so the caller can clean up via process-group kill. func startTCPServer(ns string, port int, payload string) *exec.Cmd { serverScript := fmt.Sprintf( - "import socket; "+ + "import socket,sys; "+ "s=socket.socket(); "+ "s.setsockopt(socket.SOL_SOCKET,socket.SO_REUSEADDR,1); "+ "s.bind(('', %d)); "+ "s.listen(1); "+ + "print('READY',flush=True); "+ "s.settimeout(15); "+ "conn,_=s.accept(); "+ "conn.sendall(b'%s'); "+ "conn.close(); "+ "s.close()", port, payload) - fullArgs := []string{"ip", "netns", "exec", ns, "python3", "-c", serverScript} + fullArgs := []string{"ip", "netns", "exec", ns, "python3", "-u", "-c", serverScript} bin, cmdArgs := sudoArgs(fullArgs...) cmd := exec.Command(bin, cmdArgs...) //nolint:gosec cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} + + stdout, err := cmd.StdoutPipe() + ExpectWithOffset(1, err).NotTo(HaveOccurred()) ExpectWithOffset(1, cmd.Start()).To(Succeed()) - time.Sleep(500 * time.Millisecond) + + readyCh := make(chan error, 1) + go func() { + buf := make([]byte, 64) + n, readErr := stdout.Read(buf) + if readErr != nil { + readyCh <- fmt.Errorf("server stdout read failed: %w", readErr) + return + } + if !strings.Contains(string(buf[:n]), "READY") { + readyCh <- fmt.Errorf("unexpected server output: %s", string(buf[:n])) + return + } + readyCh <- nil + }() + + select { + case readyErr := <-readyCh: + ExpectWithOffset(1, readyErr).NotTo(HaveOccurred(), + fmt.Sprintf("TCP server on port %d failed to become ready", port)) + case <-time.After(5 * time.Second): + _ = syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL) + _ = cmd.Wait() + Fail(fmt.Sprintf("TCP server on port %d did not become ready within 5s", port)) + } + return cmd } +// killCmd sends SIGKILL to the process group and waits for exit. func killCmd(cmd *exec.Cmd) { _ = syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL) _ = cmd.Wait()