From daaf8b62ba9eeff5af3c9deed450f0475e10c5c5 Mon Sep 17 00:00:00 2001 From: Povilas Kanapickas Date: Tue, 10 Mar 2026 01:13:19 +0000 Subject: [PATCH] podman: Add support for blackhole routes Add support for blackhole, unreachable, and prohibit route types in podman networks. These route types allow silently discarding packets (blackhole), rejecting with destination unreachable (unreachable), or rejecting with administratively prohibited (prohibit). Note: Blackhole routes require netavark >= 2.0.0. Regular unicast routes remain backward compatible with all netavark versions. Signed-off-by: Povilas Kanapickas Signed-off-by: Matt Heon --- cmd/podman/networks/create.go | 66 +++++---- cmd/podman/networks/create_test.go | 222 +++++++++++++++++++++++++++++ test/e2e/network_create_test.go | 53 +++++++ 3 files changed, 316 insertions(+), 25 deletions(-) create mode 100644 cmd/podman/networks/create_test.go diff --git a/cmd/podman/networks/create.go b/cmd/podman/networks/create.go index 84fb48a7f4..8467c83b2e 100644 --- a/cmd/podman/networks/create.go +++ b/cmd/podman/networks/create.go @@ -73,7 +73,7 @@ func networkCreateFlags(cmd *cobra.Command) { _ = cmd.RegisterFlagCompletionFunc(subnetFlagName, completion.AutocompleteNone) routeFlagName := "route" - flags.StringArrayVar(&networkCreateOptions.Routes, routeFlagName, nil, "static routes") + flags.StringArrayVar(&networkCreateOptions.Routes, routeFlagName, nil, "Static routes for this network. Format: ,[,] or ,[,] where type is blackhole, unreachable, or prohibit") _ = cmd.RegisterFlagCompletionFunc(routeFlagName, completion.AutocompleteNone) interfaceFlagName := "interface-name" @@ -192,41 +192,57 @@ func networkCreate(cmd *cobra.Command, args []string) error { func parseRoute(routeStr string) (*types.Route, error) { s := strings.Split(routeStr, ",") - var metric *uint32 - if len(s) == 2 || len(s) == 3 { - dstStr := s[0] - gwStr := s[1] + if len(s) < 2 || len(s) > 3 { + return nil, fmt.Errorf("invalid route: %s\nFormat: --route ,[,] or --route ,[,] where type is blackhole, unreachable, or prohibit", routeStr) + } - destination, err := types.ParseCIDR(dstStr) - gateway := net.ParseIP(gwStr) + destination, err := types.ParseCIDR(s[0]) + if err != nil { + return nil, fmt.Errorf("invalid route destination %s: %w", s[0], err) + } - if err != nil { - return nil, fmt.Errorf("invalid route destination %s", dstStr) - } + route := &types.Route{ + Destination: destination, + } - if gateway == nil { - return nil, fmt.Errorf("invalid route gateway %s", gwStr) - } - - if len(s) == 3 { + // Check if second field is a route type (blackhole, unreachable, prohibit) + secondField := strings.ToLower(s[1]) + switch types.RouteType(secondField) { + case types.RouteTypeBlackhole, types.RouteTypeUnreachable, types.RouteTypeProhibit: + route.RouteType = types.RouteType(secondField) + // Parse optional metric from position 2 + if len(s) >= 3 { mtr, err := strconv.ParseUint(s[2], 10, 32) if err != nil { return nil, fmt.Errorf("invalid route metric %s", s[2]) } x := uint32(mtr) - metric = &x + route.Metric = &x } - - r := types.Route{ - Destination: destination, - Gateway: gateway, - Metric: metric, - } - - return &r, nil + return route, nil + case types.RouteTypeUnicast: + return nil, fmt.Errorf("unicast route requires a gateway, format: --route ,[,]") } - return nil, fmt.Errorf("invalid route: %s\nFormat: --route ,,", routeStr) + + // Regular unicast route with gateway + gateway := net.ParseIP(s[1]) + if gateway == nil { + return nil, fmt.Errorf("invalid route gateway %s", s[1]) + } + route.Gateway = gateway + route.RouteType = types.RouteTypeUnicast // Default + + // Parse optional metric + if len(s) >= 3 { + mtr, err := strconv.ParseUint(s[2], 10, 32) + if err != nil { + return nil, fmt.Errorf("invalid route metric %s", s[2]) + } + x := uint32(mtr) + route.Metric = &x + } + return route, nil } func parseRange(iprange string) (*types.LeaseRange, error) { diff --git a/cmd/podman/networks/create_test.go b/cmd/podman/networks/create_test.go new file mode 100644 index 0000000000..11fce69eeb --- /dev/null +++ b/cmd/podman/networks/create_test.go @@ -0,0 +1,222 @@ +package network + +import ( + "net" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.podman.io/common/libnetwork/types" +) + +func TestParseRoute(t *testing.T) { + tests := []struct { + name string + routeStr string + want *types.Route + wantErr bool + errContains string + }{ + // Valid unicast routes (2 fields) + { + name: "unicast route with destination and gateway", + routeStr: "10.21.0.0/24,10.19.12.250", + want: &types.Route{ + Destination: mustParseCIDR(t, "10.21.0.0/24"), + Gateway: net.ParseIP("10.19.12.250"), + RouteType: types.RouteTypeUnicast, + }, + }, + // Valid unicast routes (3 fields with metric) + { + name: "unicast route with destination, gateway and metric", + routeStr: "10.21.0.0/24,10.19.12.250,100", + want: &types.Route{ + Destination: mustParseCIDR(t, "10.21.0.0/24"), + Gateway: net.ParseIP("10.19.12.250"), + Metric: uint32Ptr(100), + RouteType: types.RouteTypeUnicast, + }, + }, + // Valid blackhole routes (2 fields) + { + name: "blackhole route with destination only", + routeStr: "10.21.0.0/24,blackhole", + want: &types.Route{ + Destination: mustParseCIDR(t, "10.21.0.0/24"), + RouteType: types.RouteTypeBlackhole, + }, + }, + // Valid blackhole routes (3 fields with metric) + { + name: "blackhole route with destination and metric", + routeStr: "10.21.0.0/24,blackhole,200", + want: &types.Route{ + Destination: mustParseCIDR(t, "10.21.0.0/24"), + Metric: uint32Ptr(200), + RouteType: types.RouteTypeBlackhole, + }, + }, + // Valid unreachable routes + { + name: "unreachable route with destination only", + routeStr: "192.168.100.0/24,unreachable", + want: &types.Route{ + Destination: mustParseCIDR(t, "192.168.100.0/24"), + RouteType: types.RouteTypeUnreachable, + }, + }, + { + name: "unreachable route with destination and metric", + routeStr: "192.168.100.0/24,unreachable,150", + want: &types.Route{ + Destination: mustParseCIDR(t, "192.168.100.0/24"), + Metric: uint32Ptr(150), + RouteType: types.RouteTypeUnreachable, + }, + }, + // Valid prohibit routes + { + name: "prohibit route with destination only", + routeStr: "172.16.0.0/16,prohibit", + want: &types.Route{ + Destination: mustParseCIDR(t, "172.16.0.0/16"), + RouteType: types.RouteTypeProhibit, + }, + }, + { + name: "prohibit route with destination and metric", + routeStr: "172.16.0.0/16,prohibit,50", + want: &types.Route{ + Destination: mustParseCIDR(t, "172.16.0.0/16"), + Metric: uint32Ptr(50), + RouteType: types.RouteTypeProhibit, + }, + }, + // Case insensitivity for route types + { + name: "blackhole route type uppercase", + routeStr: "10.21.0.0/24,BLACKHOLE", + want: &types.Route{ + Destination: mustParseCIDR(t, "10.21.0.0/24"), + RouteType: types.RouteTypeBlackhole, + }, + }, + { + name: "unreachable route type mixed case", + routeStr: "10.21.0.0/24,Unreachable", + want: &types.Route{ + Destination: mustParseCIDR(t, "10.21.0.0/24"), + RouteType: types.RouteTypeUnreachable, + }, + }, + { + name: "prohibit route type mixed case", + routeStr: "10.21.0.0/24,ProHiBit", + want: &types.Route{ + Destination: mustParseCIDR(t, "10.21.0.0/24"), + RouteType: types.RouteTypeProhibit, + }, + }, + // IPv6 routes + { + name: "unicast IPv6 route", + routeStr: "fd00:1::/64,fd00::1", + want: &types.Route{ + Destination: mustParseCIDR(t, "fd00:1::/64"), + Gateway: net.ParseIP("fd00::1"), + RouteType: types.RouteTypeUnicast, + }, + }, + { + name: "blackhole IPv6 route", + routeStr: "fd00:2::/64,blackhole", + want: &types.Route{ + Destination: mustParseCIDR(t, "fd00:2::/64"), + RouteType: types.RouteTypeBlackhole, + }, + }, + // Invalid routes - too few fields + { + name: "route with only destination", + routeStr: "10.21.0.0/24", + wantErr: true, + errContains: "invalid route", + }, + // Invalid routes - too many fields + { + name: "route with too many fields", + routeStr: "10.21.0.0/24,10.19.12.250,100,extra", + wantErr: true, + errContains: "invalid route", + }, + // Invalid routes - unicast without gateway + { + name: "unicast route type without gateway", + routeStr: "10.21.0.0/24,unicast", + wantErr: true, + errContains: "unicast route requires a gateway", + }, + // Invalid routes - bad destination + { + name: "invalid destination CIDR", + routeStr: "invalid-cidr,10.19.12.250", + wantErr: true, + errContains: "invalid route destination", + }, + // Invalid routes - bad gateway + { + name: "invalid gateway IP", + routeStr: "10.21.0.0/24,invalid-gateway", + wantErr: true, + errContains: "invalid route gateway", + }, + // Invalid routes - bad metric + { + name: "invalid metric for unicast route", + routeStr: "10.21.0.0/24,10.19.12.250,invalid-metric", + wantErr: true, + errContains: "invalid route metric", + }, + { + name: "invalid metric for blackhole route", + routeStr: "10.21.0.0/24,blackhole,invalid-metric", + wantErr: true, + errContains: "invalid route metric", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := parseRoute(tt.routeStr) + if tt.wantErr { + require.Error(t, err) + if tt.errContains != "" { + assert.Contains(t, err.Error(), tt.errContains) + } + return + } + require.NoError(t, err) + assert.Equal(t, tt.want.Destination.String(), got.Destination.String()) + assert.Equal(t, tt.want.Gateway, got.Gateway) + assert.Equal(t, tt.want.RouteType, got.RouteType) + if tt.want.Metric != nil { + require.NotNil(t, got.Metric) + assert.Equal(t, *tt.want.Metric, *got.Metric) + } else { + assert.Nil(t, got.Metric) + } + }) + } +} + +func mustParseCIDR(t *testing.T, cidr string) types.IPNet { + t.Helper() + ipnet, err := types.ParseCIDR(cidr) + require.NoError(t, err) + return ipnet +} + +func uint32Ptr(v uint32) *uint32 { + return &v +} diff --git a/test/e2e/network_create_test.go b/test/e2e/network_create_test.go index 0872244836..25aa684284 100644 --- a/test/e2e/network_create_test.go +++ b/test/e2e/network_create_test.go @@ -3,6 +3,7 @@ package integration import ( + "encoding/json" "fmt" "net" "strings" @@ -10,6 +11,7 @@ import ( . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" "go.podman.io/common/libnetwork/types" + "go.podman.io/podman/v6/pkg/domain/entities" . "go.podman.io/podman/v6/test/utils" "go.podman.io/storage/pkg/stringid" ) @@ -19,6 +21,10 @@ func removeNetworkDevice(name string) { session.WaitWithDefaultTimeout() } +func uintPtr(u uint32) *uint32 { + return &u +} + var _ = Describe("Podman network create", func() { It("podman network create with name and subnet", func() { netName := "subnet-" + stringid.GenerateRandomID() @@ -660,4 +666,51 @@ var _ = Describe("Podman network create", func() { // All we care about is the ip is from the range which allows for both. Expect(containerIP.String()).To(Or(Equal("10.11.16.11"), Equal("10.11.16.12")), "ip address must be in --ip-range") }) + + DescribeTable("podman network create with special route types", + func(subnet string, route string, expectedDest string, expectedRouteType types.RouteType, expectedMetric *uint32) { + SkipIfNetavarkVersionLessThan("2.0.0") + netName := "subnet-" + stringid.GenerateRandomID() + nc := podmanTest.Podman([]string{ + "network", + "create", + "--subnet", + subnet, + "--route", + route, + netName, + }) + nc.WaitWithDefaultTimeout() + defer podmanTest.removeNetwork(netName) + Expect(nc).Should(ExitCleanly()) + + inspect := podmanTest.Podman([]string{"network", "inspect", netName}) + inspect.WaitWithDefaultTimeout() + Expect(inspect).Should(ExitCleanly()) + + var results []entities.NetworkInspectReport + err := json.Unmarshal([]byte(inspect.OutputToString()), &results) + Expect(err).ToNot(HaveOccurred()) + Expect(results).To(HaveLen(1)) + result := results[0] + Expect(result).To(HaveField("Name", netName)) + Expect(result.Subnets).To(HaveLen(1)) + Expect(result.Subnets[0].Subnet.String()).To(Equal(subnet)) + Expect(result.Routes).To(HaveLen(1)) + Expect(result.Routes[0].Destination.String()).To(Equal(expectedDest)) + Expect(result.Routes[0].Gateway).To(BeNil()) + Expect(result.Routes[0].RouteType).To(Equal(expectedRouteType)) + if expectedMetric != nil { + Expect(*result.Routes[0].Metric).To(Equal(*expectedMetric)) + } else { + Expect(result.Routes[0].Metric).To(BeNil()) + } + + defer removeNetworkDevice(result.NetworkInterface) + }, + Entry("blackhole route", "10.19.20.0/24", "10.21.10.0/24,blackhole", "10.21.10.0/24", types.RouteTypeBlackhole, nil), + Entry("unreachable route", "10.19.21.0/24", "10.21.11.0/24,unreachable", "10.21.11.0/24", types.RouteTypeUnreachable, nil), + Entry("prohibit route", "10.19.22.0/24", "10.21.12.0/24,prohibit", "10.21.12.0/24", types.RouteTypeProhibit, nil), + Entry("blackhole route with metric", "10.19.23.0/24", "10.21.13.0/24,blackhole,250", "10.21.13.0/24", types.RouteTypeBlackhole, uintPtr(250)), + ) })