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
|
||||
}
|
||||
Reference in New Issue
Block a user