Files

188 lines
4.8 KiB
Go
Raw Permalink Normal View History

// 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
}