// SPDX-License-Identifier: MIT // Package netdetect discovers the default WAN interface, gateway and local // addresses from the routing table using `ip -j` JSON output. package netdetect import ( "context" "encoding/json" "errors" "fmt" "net" "sort" "strings" "prx/internal/execx" ) // WAN describes the detected outward-facing network. type WAN struct { Interface string Gateway4 string Gateway6 string Local4 []string Local6 []string } type routeJSON struct { Dst string `json:"dst"` Gateway string `json:"gateway"` Dev string `json:"dev"` Metric int `json:"metric"` } type addrJSON struct { IfName string `json:"ifname"` AddrInfo []struct { Family string `json:"family"` Local string `json:"local"` PrefixLen int `json:"prefixlen"` Scope string `json:"scope"` } `json:"addr_info"` } // Detect resolves the WAN interface, gateways and local addresses. // // ifaceOverride, gateway4Override and gateway6Override come from configuration // and take precedence over auto-detection when set. When no IPv4 default route // exists the interface is taken from the IPv6 default route instead, so an // IPv6-only uplink still works. func Detect(ctx context.Context, r execx.Runner, ifaceOverride, gateway4Override, gateway6Override string) (WAN, error) { var wan WAN r4, err := defaultRoutes(ctx, r, 4) if err != nil { return WAN{}, err } // IPv6 is best-effort: not every host has one, and `ip -6` may be absent. r6, _ := defaultRoutes(ctx, r, 6) iface, gw4 := pickRoute(r4, ifaceOverride) gw6 := "" if iface == "" { iface, gw6 = pickRoute(r6, ifaceOverride) } else if _, g := pickRoute(r6, iface); g != "" { gw6 = g } if iface == "" { return WAN{}, errors.New("no default route found; is the host online?") } if gateway4Override != "" { gw4 = gateway4Override } if gateway6Override != "" { gw6 = gateway6Override } wan.Interface = iface wan.Gateway4 = gw4 wan.Gateway6 = gw6 local4, local6, err := localAddrs(ctx, r, iface) if err != nil { return WAN{}, err } wan.Local4 = local4 wan.Local6 = local6 if len(wan.Local4) == 0 && len(wan.Local6) == 0 { return WAN{}, fmt.Errorf("interface %s has no usable addresses", iface) } return wan, nil } func defaultRoutes(ctx context.Context, r execx.Runner, family int) ([]routeJSON, error) { args := []string{} if family == 6 { args = append(args, "-6") } args = append(args, "-j", "route", "show", "default") res, err := r.Run(ctx, nil, "ip", args...) if err != nil { return nil, fmt.Errorf("ip route show default: %w", err) } var routes []routeJSON if len(res.Stdout) == 0 { return nil, nil } if err := json.Unmarshal(res.Stdout, &routes); err != nil { // Non-JSON output means `ip` predates JSON support. return nil, fmt.Errorf("parse output of %q: %w (iproute2 with -j support is required)", strings.Join(args, " "), err) } return routes, nil } func pickRoute(routes []routeJSON, iface string) (string, string) { candidates := make([]routeJSON, 0, len(routes)) for _, rt := range routes { if rt.Dst != "" && rt.Dst != "default" { continue } if iface != "" && rt.Dev != iface { continue } candidates = append(candidates, rt) } if len(candidates) == 0 { return "", "" } sort.SliceStable(candidates, func(i, j int) bool { return candidates[i].Metric < candidates[j].Metric }) return candidates[0].Dev, candidates[0].Gateway } func localAddrs(ctx context.Context, r execx.Runner, iface string) ([]string, []string, error) { res, err := r.Run(ctx, nil, "ip", "-j", "addr", "show", "dev", iface) if err != nil { return nil, nil, fmt.Errorf("ip addr show %s: %w", iface, err) } var addrs []addrJSON if len(res.Stdout) > 0 { if err := json.Unmarshal(res.Stdout, &addrs); err != nil { return nil, nil, fmt.Errorf("parse output of \"ip -j addr show dev %s\": %w (iproute2 with -j support is required)", iface, err) } } var v4, v6 []string for _, a := range addrs { for _, ai := range a.AddrInfo { ip := net.ParseIP(ai.Local) if ip == nil { continue } if ip.IsLinkLocalUnicast() || ip.IsLoopback() { continue } switch ai.Family { case "inet": v4 = append(v4, ip.String()) case "inet6": v6 = append(v6, ip.String()) } } } return v4, v6, nil } // TunInterfaces lists TUN/TAP interface names present on the host. func TunInterfaces(ctx context.Context, r execx.Runner) ([]string, error) { res, err := r.Run(ctx, nil, "ip", "-j", "link", "show", "type", "tun") if err != nil { // Some versions reject `type tun`; fall back to an empty list. return nil, nil } var links []struct { IfName string `json:"ifname"` } if len(res.Stdout) > 0 { if err := json.Unmarshal(res.Stdout, &links); err != nil { return nil, nil } } var names []string for _, l := range links { if l.IfName != "" { names = append(names, l.IfName) } } return names, nil }