diff --git a/internal/netdetect/netdetect.go b/internal/netdetect/netdetect.go new file mode 100644 index 0000000..64e3322 --- /dev/null +++ b/internal/netdetect/netdetect.go @@ -0,0 +1,187 @@ +// 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 +} diff --git a/internal/netdetect/netdetect_test.go b/internal/netdetect/netdetect_test.go new file mode 100644 index 0000000..12bcbdb --- /dev/null +++ b/internal/netdetect/netdetect_test.go @@ -0,0 +1,127 @@ +// SPDX-License-Identifier: MIT + +package netdetect + +import ( + "context" + "strings" + "testing" + + "prx/internal/execx" +) + +func fake(handler func(name string, args []string) (string, error)) *execx.Fake { + return &execx.Fake{Handler: func(name string, args []string, _ []byte) (execx.Result, error) { + out, err := handler(name, args) + if err != nil { + return execx.Result{}, err + } + return execx.Result{Stdout: []byte(out)}, nil + }} +} + +func TestDetect(t *testing.T) { + r := fake(func(name string, args []string) (string, error) { + joined := strings.Join(args, " ") + switch { + case joined == "-j route show default": + return `[{"dst":"default","gateway":"192.168.1.1","dev":"wlan0","metric":600}]`, nil + case joined == "-6 -j route show default": + return `[]`, nil + case joined == "-j addr show dev wlan0": + return `[{"ifname":"wlan0","addr_info":[ + {"family":"inet","local":"192.168.1.100","prefixlen":24,"scope":"global"}, + {"family":"inet6","local":"fe80::abcd","prefixlen":64,"scope":"link"}]}]`, nil + } + return "", nil + }) + + wan, err := Detect(context.Background(), r, "", "", "") + if err != nil { + t.Fatalf("Detect: %v", err) + } + if wan.Interface != "wlan0" { + t.Errorf("interface = %q", wan.Interface) + } + if wan.Gateway4 != "192.168.1.1" { + t.Errorf("gateway4 = %q", wan.Gateway4) + } + if wan.Gateway6 != "" { + t.Errorf("gateway6 = %q, want empty", wan.Gateway6) + } + if len(wan.Local4) != 1 || wan.Local4[0] != "192.168.1.100" { + t.Errorf("local4 = %v", wan.Local4) + } + if len(wan.Local6) != 0 { + t.Errorf("local6 = %v, want none (link-local excluded)", wan.Local6) + } +} + +func TestDetectPrefersLowestMetric(t *testing.T) { + r := fake(func(_ string, args []string) (string, error) { + joined := strings.Join(args, " ") + if joined == "-j route show default" { + return `[{"dst":"default","gateway":"10.0.0.1","dev":"eth9","metric":900}, + {"dst":"default","gateway":"192.168.1.1","dev":"wlan0","metric":100}]`, nil + } + if strings.Contains(joined, "addr show dev wlan0") { + return `[{"ifname":"wlan0","addr_info":[{"family":"inet","local":"192.168.1.100","prefixlen":24,"scope":"global"}]}]`, nil + } + return `[]`, nil + }) + wan, err := Detect(context.Background(), r, "", "", "") + if err != nil { + t.Fatalf("Detect: %v", err) + } + if wan.Interface != "wlan0" || wan.Gateway4 != "192.168.1.1" { + t.Fatalf("picked %s/%s, want wlan0/192.168.1.1", wan.Interface, wan.Gateway4) + } +} + +func TestDetectOverride(t *testing.T) { + r := fake(func(_ string, args []string) (string, error) { + if strings.Join(args, " ") == "-j route show default" { + return `[{"dst":"default","gateway":"192.168.1.1","dev":"wlan0","metric":100}]`, nil + } + if strings.Contains(strings.Join(args, " "), "addr show dev wlan0") { + return `[{"ifname":"wlan0","addr_info":[{"family":"inet","local":"192.168.1.100","prefixlen":24,"scope":"global"}]}]`, nil + } + return `[]`, nil + }) + wan, err := Detect(context.Background(), r, "wlan0", "10.9.9.9", "") + if err != nil { + t.Fatalf("Detect: %v", err) + } + if wan.Gateway4 != "10.9.9.9" { + t.Fatalf("gateway override not applied: %q", wan.Gateway4) + } +} + +func TestDetectNoDefaultRoute(t *testing.T) { + r := fake(func(_ string, _ []string) (string, error) { return `[]`, nil }) + if _, err := Detect(context.Background(), r, "", "", ""); err == nil { + t.Fatal("expected error when no default route exists") + } +} + +func TestDetectIPv6OnlyUplink(t *testing.T) { + r := fake(func(_ string, args []string) (string, error) { + joined := strings.Join(args, " ") + switch { + case joined == "-j route show default": + return `[]`, nil + case joined == "-6 -j route show default": + return `[{"dst":"default","gateway":"2001:db8::1","dev":"eth0","metric":100}]`, nil + case joined == "-j addr show dev eth0": + return `[{"ifname":"eth0","addr_info":[{"family":"inet6","local":"2001:db8::5","prefixlen":64,"scope":"global"}]}]`, nil + } + return `[]`, nil + }) + wan, err := Detect(context.Background(), r, "", "", "") + if err != nil { + t.Fatalf("Detect: %v", err) + } + if wan.Interface != "eth0" || wan.Gateway4 != "" || wan.Gateway6 != "2001:db8::1" { + t.Fatalf("ipv6-only detection = %+v", wan) + } +}