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 <povilas@radix.lt>

<MH: Rebased atop latest main & fixed cherry-pick conflicts>

Signed-off-by: Matt Heon <matthew.heon@pm.me>
This commit is contained in:
Povilas Kanapickas
2026-03-10 01:13:19 +00:00
committed by Matt Heon
parent 8cc0a6774e
commit daaf8b62ba
3 changed files with 316 additions and 25 deletions

View File

@@ -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: <destination>,<gateway>[,<metric>] or <destination>,<type>[,<metric>] 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 <destination>,<gateway>[,<metric>] or --route <destination>,<type>[,<metric>] 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 <destination>,<gateway>[,<metric>]")
}
return nil, fmt.Errorf("invalid route: %s\nFormat: --route <destination in CIDR>,<gateway>,<metric (optional)>", 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) {

View File

@@ -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
}

View File

@@ -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)),
)
})