feat(netdetect): detect WAN interface, gateways and addresses
This commit is contained in:
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user