From 03feb9fd1da98368b35d80087a6f8c4dc902f073 Mon Sep 17 00:00:00 2001 From: "f.domain" Date: Fri, 3 Jul 2026 17:46:22 +0200 Subject: [PATCH] route: support v4-mapped IPv6 nexthops on V6 routes A v4-mapped IPv6 address (::ffff:a.b.c.d) is byte-identical to its IPv4 form as a net.IP, so nl.GetIPFamily reports FAMILY_V4 for it. When such an address was used as a route's gateway on a V6 route it was encoded as a 4-byte AF_INET nexthop, and combined with a V6 destination it failed outright with "gateway... not the same IP family". Resolve this locally in prepareRouteReq rather than changing GetIPFamily (which has no route context and must keep reporting V4 for the common net.ParseIP("1.2.3.4") case used by many other callers): - Honor route.Family, which was previously ignored on the add path, so a gateway-only route (no destination to pin the family) can still be requested as V6. - When the caller explicitly requests a V6 route via route.Family, let a 16-byte v4-mapped gateway (route.Gw and multipath nexthops) conform to it and be encoded as a 16-byte AF_INET6 nexthop. Support is opt-in via route.Family so existing callers see no change: a v4-mapped gateway without route.Family set is still treated as V4 and rejected on a V6 route, exactly as before. The conform is also gated on a 16-byte slice so that an explicit 4-byte IPv4 gateway still errors rather than being silently reinterpreted; a 4-byte slice is the only unambiguous "I meant IPv4" signal available at the net.IP level. Scope is limited to the gateway/nexthop, the real-world case (verified against the kernel: `ip -6 route` reports the nexthop as ::ffff:a.b.c.d). v4-mapped addresses are a socket-API representation construct and are not routable as IPv6 destinations, so Dst and Src are left unchanged. Fixes #1114 Co-Authored-By: Claude Opus 4.8 (1M context) --- route_linux.go | 25 ++++++- route_test.go | 180 +++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 203 insertions(+), 2 deletions(-) diff --git a/route_linux.go b/route_linux.go index c8c600d37..875d80e2f 100644 --- a/route_linux.go +++ b/route_linux.go @@ -919,12 +919,33 @@ func (h *Handle) routeHandleIter(route *Route, req *nl.NetlinkRequest, msg *nl.R return req.ExecuteIter(unix.NETLINK_ROUTE, 0, f) } +// gwIPFamily returns the IP family to use for a gateway address on the given +// route. A v4-mapped IPv6 gateway (::ffff:a.b.c.d, 16 bytes) reports FAMILY_V4 +// but is a valid V6 nexthop, so it is conformed to V6 only when the caller +// explicitly requested a V6 route via route.Family. This makes the behavior +// opt-in rather than silently reinterpreting an ambiguous address, and +// requiring 16 bytes leaves an explicit 4-byte IPv4 gateway reporting V4. +func gwIPFamily(route *Route, gw net.IP) int { + gwFamily := nl.GetIPFamily(gw) + if route.Family == FAMILY_V6 && gwFamily == FAMILY_V4 && len(gw) == net.IPv6len { + return FAMILY_V6 + } + return gwFamily +} + func (h *Handle) prepareRouteReq(route *Route, req *nl.NetlinkRequest, msg *nl.RtMsg) error { if req.NlMsghdr.Type != unix.RTM_GETROUTE && (route.Dst == nil || route.Dst.IP == nil) && route.Src == nil && route.Gw == nil && route.MPLSDst == nil { return fmt.Errorf("either Dst.IP, Src.IP or Gw must be set") } family := -1 + if route.Family != 0 { + // AF_UNSPEC (0) means unset. Honor an explicitly requested family so + // that ambiguous addresses (e.g. a v4-mapped IPv6 gateway, which is + // byte-identical to its IPv4 form as a net.IP) are encoded for the + // intended family even when no destination pins it down. + family = route.Family + } var rtAttrs []*nl.RtAttr if route.Dst != nil && route.Dst.IP != nil { @@ -996,7 +1017,7 @@ func (h *Handle) prepareRouteReq(route *Route, req *nl.NetlinkRequest, msg *nl.R } if route.Gw != nil { - gwFamily := nl.GetIPFamily(route.Gw) + gwFamily := gwIPFamily(route, route.Gw) if family != -1 && family != gwFamily { return fmt.Errorf("gateway, source, and destination ip are not the same IP family") } @@ -1030,7 +1051,7 @@ func (h *Handle) prepareRouteReq(route *Route, req *nl.NetlinkRequest, msg *nl.R } children := []nl.NetlinkRequestData{} if nh.Gw != nil { - gwFamily := nl.GetIPFamily(nh.Gw) + gwFamily := gwIPFamily(route, nh.Gw) if family != -1 && family != gwFamily { return fmt.Errorf("gateway, source, and destination ip are not the same IP family") } diff --git a/route_test.go b/route_test.go index 2f4d61524..e0fc0ec16 100644 --- a/route_test.go +++ b/route_test.go @@ -2977,3 +2977,183 @@ func TestRouteNHID(t *testing.T) { t.Fatalf("Expected route NHID %d, got %d", nh.ID, routes[0].NHID) } } + +// findRtAttr returns the Data of the first top-level RtAttr of the given type +// in req, failing the test if it is absent. +func findRtAttr(t *testing.T, req *nl.NetlinkRequest, attrType uint16) []byte { + t.Helper() + for _, d := range req.Data { + if attr, ok := d.(*nl.RtAttr); ok && attr.Type == attrType { + return attr.Data + } + } + t.Fatalf("attribute type %d not found in request", attrType) + return nil +} + +// TestPrepareRouteReqV4MappedV6Gateway verifies that a v4-mapped IPv6 gateway +// (::ffff:a.b.c.d) is encoded as a 16-byte AF_INET6 nexthop when the caller +// opts in by setting route.Family to FAMILY_V6. As a net.IP the gateway is +// byte-identical to its IPv4 form, so GetIPFamily reports FAMILY_V4; the +// explicit family is what enables the V6 encoding. This does not need a live +// netlink socket: prepareRouteReq only builds the request. +func TestPrepareRouteReqV4MappedV6Gateway(t *testing.T) { + gw := net.ParseIP("::ffff:192.0.2.1") + _, v6dst, err := net.ParseCIDR("2001:db8::/64") + if err != nil { + t.Fatal(err) + } + + for _, tt := range []struct { + name string + route *Route + }{ + { + name: "explicit V6 family, no destination", + route: &Route{Family: FAMILY_V6, Gw: gw}, + }, + { + name: "explicit V6 family with v6 destination", + route: &Route{Family: FAMILY_V6, Dst: v6dst, Gw: gw}, + }, + } { + t.Run(tt.name, func(t *testing.T) { + req := nl.NewNetlinkRequest(unix.RTM_NEWROUTE, unix.NLM_F_CREATE|unix.NLM_F_EXCL|unix.NLM_F_ACK) + msg := nl.NewRtMsg() + + if err := (&Handle{}).prepareRouteReq(tt.route, req, msg); err != nil { + t.Fatalf("prepareRouteReq: %v", err) + } + + if msg.Family != unix.AF_INET6 { + t.Fatalf("msg.Family = %d, want AF_INET6 (%d)", msg.Family, unix.AF_INET6) + } + + gwData := findRtAttr(t, req, unix.RTA_GATEWAY) + if len(gwData) != net.IPv6len { + t.Fatalf("RTA_GATEWAY length = %d, want %d", len(gwData), net.IPv6len) + } + if !net.IP(gwData).Equal(gw) { + t.Fatalf("RTA_GATEWAY = %v, want %v", net.IP(gwData), gw) + } + }) + } +} + +// TestPrepareRouteReqV4MappedV6GatewayMultiPath is the multipath analogue of +// TestPrepareRouteReqV4MappedV6Gateway: a v4-mapped IPv6 gateway carried in a +// MultiPath NexthopInfo must be encoded as a 16-byte AF_INET6 nexthop when the +// caller opts in via route.Family, matching the direct Route.Gw behavior. +func TestPrepareRouteReqV4MappedV6GatewayMultiPath(t *testing.T) { + gw := net.ParseIP("::ffff:192.0.2.1") + _, v6dst, err := net.ParseCIDR("2001:db8::/64") + if err != nil { + t.Fatal(err) + } + + route := &Route{ + Family: FAMILY_V6, + Dst: v6dst, + MultiPath: []*NexthopInfo{ + {LinkIndex: 1, Gw: gw}, + }, + } + + req := nl.NewNetlinkRequest(unix.RTM_NEWROUTE, unix.NLM_F_CREATE|unix.NLM_F_EXCL|unix.NLM_F_ACK) + msg := nl.NewRtMsg() + + if err := (&Handle{}).prepareRouteReq(route, req, msg); err != nil { + t.Fatalf("prepareRouteReq: %v", err) + } + + if msg.Family != unix.AF_INET6 { + t.Fatalf("msg.Family = %d, want AF_INET6 (%d)", msg.Family, unix.AF_INET6) + } + + // Descend into the nested RTA_MULTIPATH -> RtNexthop -> RTA_GATEWAY. + mp := findRtAttr(t, req, unix.RTA_MULTIPATH) + if len(mp) < unix.SizeofRtNexthop { + t.Fatalf("RTA_MULTIPATH too short: %d bytes", len(mp)) + } + nh := nl.DeserializeRtNexthop(mp) + attrs, err := nl.ParseRouteAttr(mp[unix.SizeofRtNexthop:int(nh.RtNexthop.Len)]) + if err != nil { + t.Fatalf("ParseRouteAttr: %v", err) + } + var gwData []byte + for _, attr := range attrs { + if attr.Attr.Type == unix.RTA_GATEWAY { + gwData = attr.Value + } + } + if gwData == nil { + t.Fatal("RTA_GATEWAY not found in multipath nexthop") + } + if len(gwData) != net.IPv6len { + t.Fatalf("RTA_GATEWAY length = %d, want %d", len(gwData), net.IPv6len) + } + if !net.IP(gwData).Equal(gw) { + t.Fatalf("RTA_GATEWAY = %v, want %v", net.IP(gwData), gw) + } +} + +// TestPrepareRouteReqV4MappedV6GatewayRequiresFamily verifies that the conform +// is opt-in: without an explicit route.Family, a v4-mapped gateway even +// alongside a V6 destination is treated as FAMILY_V4 and rejected, exactly as +// it was before v4-mapped nexthops were supported. +func TestPrepareRouteReqV4MappedV6GatewayRequiresFamily(t *testing.T) { + _, dst, err := net.ParseCIDR("2001:db8::/64") + if err != nil { + t.Fatal(err) + } + + req := nl.NewNetlinkRequest(unix.RTM_NEWROUTE, unix.NLM_F_CREATE|unix.NLM_F_EXCL|unix.NLM_F_ACK) + msg := nl.NewRtMsg() + + route := &Route{Dst: dst, Gw: net.ParseIP("::ffff:192.0.2.1")} + if err := (&Handle{}).prepareRouteReq(route, req, msg); err == nil { + t.Fatal("expected an error for a v4-mapped gateway without Family set, got nil") + } +} + +// TestPrepareRouteReqV4GatewayUnaffected guards against regressing the common +// case: a plain IPv4 gateway must still be encoded as a 4-byte AF_INET nexthop. +func TestPrepareRouteReqV4GatewayUnaffected(t *testing.T) { + _, dst, err := net.ParseCIDR("192.0.2.0/24") + if err != nil { + t.Fatal(err) + } + gw := net.ParseIP("192.0.2.1") + + req := nl.NewNetlinkRequest(unix.RTM_NEWROUTE, unix.NLM_F_CREATE|unix.NLM_F_EXCL|unix.NLM_F_ACK) + msg := nl.NewRtMsg() + + if err := (&Handle{}).prepareRouteReq(&Route{Dst: dst, Gw: gw}, req, msg); err != nil { + t.Fatalf("prepareRouteReq: %v", err) + } + + if msg.Family != unix.AF_INET { + t.Fatalf("msg.Family = %d, want AF_INET (%d)", msg.Family, unix.AF_INET) + } + + gwData := findRtAttr(t, req, unix.RTA_GATEWAY) + if len(gwData) != net.IPv4len { + t.Fatalf("RTA_GATEWAY length = %d, want %d", len(gwData), net.IPv4len) + } +} + +// TestPrepareRouteReqExplicitV4GatewayOnV6Route verifies that even with an +// explicit V6 family, a 4-byte IPv4 gateway is not conformed to V6: the conform +// requires a 16-byte slice, so a deliberately 4-byte address ("I really meant +// IPv4") still errors rather than being silently reinterpreted as a mapped +// nexthop. +func TestPrepareRouteReqExplicitV4GatewayOnV6Route(t *testing.T) { + gw := net.ParseIP("192.0.2.1").To4() // explicit 4-byte IPv4 + + req := nl.NewNetlinkRequest(unix.RTM_NEWROUTE, unix.NLM_F_CREATE|unix.NLM_F_EXCL|unix.NLM_F_ACK) + msg := nl.NewRtMsg() + + if err := (&Handle{}).prepareRouteReq(&Route{Family: FAMILY_V6, Gw: gw}, req, msg); err == nil { + t.Fatal("expected an error for a 4-byte IPv4 gateway on a V6 route, got nil") + } +}