feat(netdetect): detect WAN interface, gateways and addresses

This commit is contained in:
ahp
2026-10-15 14:29:19 +03:30
parent 150a78bdde
commit 83871815c2
2 changed files with 314 additions and 0 deletions
+187
View File
@@ -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
}
+127
View File
@@ -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)
}
}