188 lines
4.8 KiB
Go
188 lines
4.8 KiB
Go
// 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
|
||
|
|
}
|