1295 lines
36 KiB
Go
1295 lines
36 KiB
Go
package netdiag
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/binary"
|
|
"encoding/hex"
|
|
"encoding/xml"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"log/slog"
|
|
"net"
|
|
"net/http"
|
|
"net/netip"
|
|
"net/url"
|
|
"os"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
)
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// tunables
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const (
|
|
// pmPort is shared by NAT-PMP (RFC 6886) and PCP (RFC 6887).
|
|
pmPort = 5351
|
|
// pmSSDPTarget is the SSDP multicast rendezvous address.
|
|
pmSSDPTarget = "239.255.255.250:1900"
|
|
|
|
// pmBudget bounds the whole [ProbePortMapping] call.
|
|
pmBudget = 6 * time.Second
|
|
// pmProtoBudget bounds one protocol probe across every gateway candidate.
|
|
pmProtoBudget = 4500 * time.Millisecond
|
|
// pmUnicastTimeout is the per-attempt wait for a NAT-PMP/PCP reply.
|
|
pmUnicastTimeout = 750 * time.Millisecond
|
|
// pmRetries is the number of *extra* attempts after the first one.
|
|
pmRetries = 2
|
|
// pmSSDPMX is the MX header we advertise: devices answer after a random
|
|
// delay in [0, MX] seconds.
|
|
pmSSDPMX = 2
|
|
// pmSSDPCollect is how long we listen for SSDP replies, measured from the
|
|
// moment the M-SEARCH goes out. It must exceed pmSSDPMX, otherwise devices
|
|
// that happen to draw a delay near the top of the range are cut off and
|
|
// the IGD is intermittently reported as absent.
|
|
pmSSDPCollect = 3 * time.Second
|
|
// pmHTTPTimeout bounds one description fetch or SOAP call.
|
|
pmHTTPTimeout = 2 * time.Second
|
|
// pmMaxDescBody caps a device description body.
|
|
pmMaxDescBody = 256 << 10
|
|
// pmMaxGateways caps how many gateway candidates we are willing to poke.
|
|
pmMaxGateways = 4
|
|
// pmMaxSSDPSockets bounds the in-flight SSDP goroutines.
|
|
pmMaxSSDPSockets = 8
|
|
// pmMaxLocations caps how many distinct SSDP LOCATIONs we fetch.
|
|
pmMaxLocations = 4
|
|
)
|
|
|
|
// pmWANServices are the IGD service types that expose GetExternalIPAddress,
|
|
// in preference order.
|
|
var pmWANServices = []string{
|
|
"urn:schemas-upnp-org:service:WANIPConnection:2",
|
|
"urn:schemas-upnp-org:service:WANIPConnection:1",
|
|
"urn:schemas-upnp-org:service:WANPPPConnection:1",
|
|
}
|
|
|
|
// pmLog returns a logger tagged for this subsystem, tolerating a nil logger.
|
|
func pmLog(logger *slog.Logger) *slog.Logger {
|
|
if logger == nil {
|
|
logger = slog.Default()
|
|
}
|
|
return logger.With(slog.String("from", "netdiag/portmap"))
|
|
}
|
|
|
|
// pmDeadline returns the shorter of ctx's deadline and now+d.
|
|
func pmDeadline(ctx context.Context, d time.Duration) time.Time {
|
|
t := time.Now().Add(d)
|
|
if dl, ok := ctx.Deadline(); ok && dl.Before(t) {
|
|
return dl
|
|
}
|
|
return t
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// gateway discovery
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// DiscoverGateways returns candidate default-gateway addresses, best first.
|
|
//
|
|
// On Linux the kernel routing tables are parsed directly; everywhere else (and
|
|
// as a fallback when the tables yield nothing) the conventional first and last
|
|
// host addresses of every private IPv4 prefix on an up interface are offered.
|
|
// The result is deduplicated, ordered deterministically and capped at four
|
|
// entries. It never shells out and never blocks on the network.
|
|
func DiscoverGateways(ctx context.Context, logger *slog.Logger) []netip.Addr {
|
|
gws, _ := pmGateways(ctx, logger)
|
|
return gws
|
|
}
|
|
|
|
// pmGateways is [DiscoverGateways] plus the interface enumeration error, which
|
|
// [ProbePortMapping] needs to tell "no router" apart from "no network stack".
|
|
func pmGateways(ctx context.Context, logger *slog.Logger) ([]netip.Addr, error) {
|
|
log := pmLog(logger)
|
|
if err := ctx.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
var out []netip.Addr
|
|
out = append(out, pmProcRoutes(log)...)
|
|
|
|
ifaces, ifErr := net.Interfaces()
|
|
if ifErr != nil {
|
|
log.Debug("cannot enumerate interfaces", slog.String("error", ifErr.Error()))
|
|
} else {
|
|
out = append(out, pmGuessedGateways(ifaces)...)
|
|
}
|
|
|
|
out = pmDedupAddrs(out)
|
|
if len(out) > pmMaxGateways {
|
|
out = out[:pmMaxGateways]
|
|
}
|
|
if len(out) == 0 && ifErr != nil {
|
|
return nil, ifErr
|
|
}
|
|
log.Debug("gateway candidates", slog.Int("count", len(out)), slog.String("addrs", pmJoinAddrs(out)))
|
|
return out, nil
|
|
}
|
|
|
|
// pmProcRoutes reads the Linux routing tables. It returns nil on any other
|
|
// platform, or when the files are unreadable.
|
|
func pmProcRoutes(log *slog.Logger) []netip.Addr {
|
|
var out []netip.Addr
|
|
out = append(out, pmProcRoute4(log)...)
|
|
out = append(out, pmProcRoute6(log)...)
|
|
return out
|
|
}
|
|
|
|
// pmRouteCandidate is a parsed routing-table row, kept so rows can be ordered
|
|
// by metric before their gateways are handed out.
|
|
type pmRouteCandidate struct {
|
|
addr netip.Addr
|
|
metric int
|
|
iface string
|
|
}
|
|
|
|
// pmSortRoutes orders routes by metric, then interface, then address, so the
|
|
// candidate list does not jitter between refreshes.
|
|
//
|
|
// Link-local IPv6 next hops get their interface zone reattached. A Linux IPv6
|
|
// default route almost always points at an fe80:: address, and dialling one
|
|
// without a zone fails outright — so without this the PCP-over-IPv6 probe
|
|
// never reaches the router, and the unusable candidates still consume slots
|
|
// against pmMaxGateways, crowding out the IPv4 guesses.
|
|
func pmSortRoutes(rs []pmRouteCandidate) []netip.Addr {
|
|
sort.SliceStable(rs, func(i, j int) bool {
|
|
if rs[i].metric != rs[j].metric {
|
|
return rs[i].metric < rs[j].metric
|
|
}
|
|
if rs[i].iface != rs[j].iface {
|
|
return rs[i].iface < rs[j].iface
|
|
}
|
|
return rs[i].addr.Compare(rs[j].addr) < 0
|
|
})
|
|
out := make([]netip.Addr, 0, len(rs))
|
|
for _, r := range rs {
|
|
addr := r.addr
|
|
if addr.Is6() && addr.IsLinkLocalUnicast() {
|
|
if r.iface == "" {
|
|
continue // unusable without a zone
|
|
}
|
|
addr = addr.WithZone(r.iface)
|
|
}
|
|
out = append(out, addr)
|
|
}
|
|
return out
|
|
}
|
|
|
|
const (
|
|
pmRTFUp = 0x0001
|
|
pmRTFGateway = 0x0002
|
|
)
|
|
|
|
// pmProcRoute4 parses /proc/net/route. Every numeric column is hex; the
|
|
// address columns are little-endian, so 0102A8C0 is 192.168.2.1. The default
|
|
// route is the row with a zero destination and the RTF_GATEWAY flag.
|
|
func pmProcRoute4(log *slog.Logger) []netip.Addr {
|
|
data, err := os.ReadFile("/proc/net/route")
|
|
if err != nil {
|
|
log.Debug("no /proc/net/route", slog.String("error", err.Error()))
|
|
return nil
|
|
}
|
|
var rows []pmRouteCandidate
|
|
for i, line := range strings.Split(string(data), "\n") {
|
|
if i == 0 { // header
|
|
continue
|
|
}
|
|
f := strings.Fields(line)
|
|
if len(f) < 8 {
|
|
continue
|
|
}
|
|
dest, err1 := strconv.ParseUint(f[1], 16, 32)
|
|
gw, err2 := strconv.ParseUint(f[2], 16, 32)
|
|
flags, err3 := strconv.ParseUint(f[3], 16, 32)
|
|
if err1 != nil || err2 != nil || err3 != nil {
|
|
continue
|
|
}
|
|
if dest != 0 || flags&pmRTFGateway == 0 || flags&pmRTFUp == 0 || gw == 0 {
|
|
continue
|
|
}
|
|
metric := 0
|
|
if len(f) >= 7 {
|
|
if m, err := strconv.Atoi(f[6]); err == nil {
|
|
metric = m
|
|
}
|
|
}
|
|
v := uint32(gw)
|
|
addr := netip.AddrFrom4([4]byte{
|
|
byte(v), byte(v >> 8), byte(v >> 16), byte(v >> 24),
|
|
})
|
|
rows = append(rows, pmRouteCandidate{addr: addr, metric: metric, iface: f[0]})
|
|
}
|
|
return pmSortRoutes(rows)
|
|
}
|
|
|
|
// pmProcRoute6 parses /proc/net/ipv6_route best-effort. Columns are
|
|
// dest/plen/src/srcplen/nexthop/metric/refcnt/use/flags/iface, all hex, with
|
|
// addresses written big-endian as 32 hex digits.
|
|
func pmProcRoute6(log *slog.Logger) []netip.Addr {
|
|
data, err := os.ReadFile("/proc/net/ipv6_route")
|
|
if err != nil {
|
|
log.Debug("no /proc/net/ipv6_route", slog.String("error", err.Error()))
|
|
return nil
|
|
}
|
|
var rows []pmRouteCandidate
|
|
for _, line := range strings.Split(string(data), "\n") {
|
|
f := strings.Fields(line)
|
|
if len(f) < 10 {
|
|
continue
|
|
}
|
|
plen, err := strconv.ParseUint(f[1], 16, 8)
|
|
if err != nil || plen != 0 {
|
|
continue
|
|
}
|
|
if strings.Trim(f[0], "0") != "" { // destination must be ::
|
|
continue
|
|
}
|
|
flags, err := strconv.ParseUint(f[8], 16, 64)
|
|
if err != nil || flags&pmRTFGateway == 0 {
|
|
continue
|
|
}
|
|
nh, ok := pmHexAddr16(f[4])
|
|
if !ok || !nh.IsValid() || nh.IsUnspecified() {
|
|
continue
|
|
}
|
|
metric := 0
|
|
if m, err := strconv.ParseUint(f[5], 16, 32); err == nil {
|
|
metric = int(m)
|
|
}
|
|
rows = append(rows, pmRouteCandidate{addr: nh, metric: metric, iface: f[9]})
|
|
}
|
|
return pmSortRoutes(rows)
|
|
}
|
|
|
|
// pmHexAddr16 decodes 32 hex digits into an IPv6 address.
|
|
func pmHexAddr16(s string) (netip.Addr, bool) {
|
|
if len(s) != 32 {
|
|
return netip.Addr{}, false
|
|
}
|
|
raw, err := hex.DecodeString(s)
|
|
if err != nil {
|
|
return netip.Addr{}, false
|
|
}
|
|
var b [16]byte
|
|
copy(b[:], raw)
|
|
return netip.AddrFrom16(b), true
|
|
}
|
|
|
|
// pmGuessedGateways offers x.x.x.1 and x.x.x.254 for every private IPv4 prefix
|
|
// on an up, non-loopback interface. Interfaces are visited in name order for
|
|
// determinism.
|
|
func pmGuessedGateways(ifaces []net.Interface) []netip.Addr {
|
|
sorted := make([]net.Interface, len(ifaces))
|
|
copy(sorted, ifaces)
|
|
sort.Slice(sorted, func(i, j int) bool { return sorted[i].Name < sorted[j].Name })
|
|
|
|
var out []netip.Addr
|
|
for _, ifc := range sorted {
|
|
if ifc.Flags&net.FlagUp == 0 || ifc.Flags&net.FlagLoopback != 0 {
|
|
continue
|
|
}
|
|
addrs, err := ifc.Addrs()
|
|
if err != nil {
|
|
continue
|
|
}
|
|
for _, a := range addrs {
|
|
ipn, ok := a.(*net.IPNet)
|
|
if !ok {
|
|
continue
|
|
}
|
|
pfx, ok := pmPrefixOf(ipn)
|
|
if !ok || !pfx.Addr().Is4() || !pfx.Addr().IsPrivate() {
|
|
continue
|
|
}
|
|
if bits := pfx.Bits(); bits < 8 || bits > 30 {
|
|
continue
|
|
}
|
|
base := pfx.Masked().Addr()
|
|
out = append(out, base.Next()) // x.x.x.1
|
|
if last, ok := pmLastHost(pfx); ok {
|
|
out = append(out, last)
|
|
}
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// pmPrefixOf converts a net.IPNet to a netip.Prefix.
|
|
func pmPrefixOf(ipn *net.IPNet) (netip.Prefix, bool) {
|
|
addr, ok := netip.AddrFromSlice(ipn.IP)
|
|
if !ok {
|
|
return netip.Prefix{}, false
|
|
}
|
|
addr = addr.Unmap()
|
|
ones, _ := ipn.Mask.Size()
|
|
if ones == 0 && len(ipn.Mask) == 0 {
|
|
return netip.Prefix{}, false
|
|
}
|
|
pfx, err := addr.Prefix(ones)
|
|
if err != nil {
|
|
return netip.Prefix{}, false
|
|
}
|
|
return pfx, true
|
|
}
|
|
|
|
// pmLastHost returns the conventional "high" gateway of an IPv4 prefix:
|
|
// x.x.x.254 for prefixes at least as large as a /24, otherwise the last usable
|
|
// address before the broadcast address.
|
|
func pmLastHost(pfx netip.Prefix) (netip.Addr, bool) {
|
|
base := pfx.Masked().Addr()
|
|
if !base.Is4() {
|
|
return netip.Addr{}, false
|
|
}
|
|
b := base.As4()
|
|
if pfx.Bits() <= 24 {
|
|
b[3] = 254
|
|
return netip.AddrFrom4(b), true
|
|
}
|
|
// Broadcast address of the prefix, minus one.
|
|
host := uint32(1)<<(32-uint(pfx.Bits())) - 1
|
|
v := binary.BigEndian.Uint32(b[:]) | host
|
|
if v == 0 {
|
|
return netip.Addr{}, false
|
|
}
|
|
var out [4]byte
|
|
binary.BigEndian.PutUint32(out[:], v-1)
|
|
return netip.AddrFrom4(out), true
|
|
}
|
|
|
|
// pmDedupAddrs removes duplicates and invalid entries, preserving order.
|
|
func pmDedupAddrs(in []netip.Addr) []netip.Addr {
|
|
seen := make(map[netip.Addr]bool, len(in))
|
|
out := make([]netip.Addr, 0, len(in))
|
|
for _, a := range in {
|
|
a = a.Unmap()
|
|
if !a.IsValid() || a.IsUnspecified() || a.IsLoopback() || seen[a] {
|
|
continue
|
|
}
|
|
seen[a] = true
|
|
out = append(out, a)
|
|
}
|
|
return out
|
|
}
|
|
|
|
func pmJoinAddrs(in []netip.Addr) string {
|
|
parts := make([]string, 0, len(in))
|
|
for _, a := range in {
|
|
parts = append(parts, a.String())
|
|
}
|
|
return strings.Join(parts, ",")
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// the public probe
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// ProbePortMapping probes UPnP IGD, NAT-PMP and PCP concurrently and reports
|
|
// what the router is willing to do for us.
|
|
//
|
|
// The three protocols are independent, so a hang in one cannot delay the
|
|
// others; the whole call is bounded both by ctx and by an internal six second
|
|
// budget. A router that answers nothing is [StatusWarn], not an error: it is a
|
|
// real (and common) finding for peer-to-peer traffic. [StatusFail] is reserved
|
|
// for the case where the local network stack could not even be enumerated.
|
|
func ProbePortMapping(ctx context.Context, logger *slog.Logger) PortMapReport {
|
|
log := pmLog(logger)
|
|
|
|
ctx, cancel := context.WithTimeout(ctx, pmBudget)
|
|
defer cancel()
|
|
|
|
var rep PortMapReport
|
|
|
|
gws, err := pmGateways(ctx, log)
|
|
if err != nil && len(gws) == 0 {
|
|
rep.Status = StatusFail
|
|
rep.Summary = "无法枚举本机网络接口,端口映射检测已跳过"
|
|
rep.UPnP.Err = err.Error()
|
|
rep.NATPMP.Err = err.Error()
|
|
rep.PCP.Err = err.Error()
|
|
log.Warn("port mapping probe aborted", slog.String("error", err.Error()))
|
|
return rep
|
|
}
|
|
if len(gws) > 0 {
|
|
rep.Gateway = gws[0]
|
|
}
|
|
|
|
var (
|
|
mu sync.Mutex
|
|
answered netip.Addr
|
|
wg sync.WaitGroup
|
|
)
|
|
note := func(gw netip.Addr) {
|
|
mu.Lock()
|
|
if !answered.IsValid() && gw.IsValid() {
|
|
answered = gw
|
|
}
|
|
mu.Unlock()
|
|
}
|
|
|
|
wg.Add(3)
|
|
go func() {
|
|
defer wg.Done()
|
|
probe, gw := pmProbeNATPMP(ctx, gws, log)
|
|
mu.Lock()
|
|
rep.NATPMP = probe
|
|
mu.Unlock()
|
|
if probe.Available {
|
|
note(gw)
|
|
}
|
|
}()
|
|
go func() {
|
|
defer wg.Done()
|
|
probe, gw := pmProbePCP(ctx, gws, log)
|
|
mu.Lock()
|
|
rep.PCP = probe
|
|
mu.Unlock()
|
|
if probe.Available {
|
|
note(gw)
|
|
}
|
|
}()
|
|
go func() {
|
|
defer wg.Done()
|
|
probe := pmProbeUPnP(ctx, log)
|
|
mu.Lock()
|
|
rep.UPnP = probe
|
|
mu.Unlock()
|
|
}()
|
|
wg.Wait()
|
|
|
|
if answered.IsValid() {
|
|
rep.Gateway = answered
|
|
}
|
|
pmSummarize(&rep)
|
|
log.Info("port mapping probe done",
|
|
slog.String("gateway", rep.Gateway.String()),
|
|
slog.Bool("upnp", rep.UPnP.Available),
|
|
slog.Bool("natpmp", rep.NATPMP.Available),
|
|
slog.Bool("pcp", rep.PCP.Available),
|
|
slog.String("status", rep.Status.String()))
|
|
return rep
|
|
}
|
|
|
|
// pmSummarize fills Status and the one-line Chinese summary.
|
|
func pmSummarize(rep *PortMapReport) {
|
|
var ok []string
|
|
if rep.UPnP.Available {
|
|
ok = append(ok, "UPnP")
|
|
}
|
|
if rep.NATPMP.Available {
|
|
ok = append(ok, "NAT-PMP")
|
|
}
|
|
if rep.PCP.Available {
|
|
ok = append(ok, "PCP")
|
|
}
|
|
|
|
gw := "未知网关"
|
|
if rep.Gateway.IsValid() {
|
|
gw = rep.Gateway.String()
|
|
}
|
|
ext := rep.UPnP.ExternalIP
|
|
if !ext.IsValid() {
|
|
ext = rep.NATPMP.ExternalIP
|
|
}
|
|
|
|
if len(ok) == 0 {
|
|
rep.Status = StatusWarn
|
|
rep.Summary = fmt.Sprintf("路由器 %s 未响应 UPnP / NAT-PMP / PCP,需要手动端口转发", gw)
|
|
return
|
|
}
|
|
rep.Status = StatusOK
|
|
if ext.IsValid() {
|
|
rep.Summary = fmt.Sprintf("路由器 %s 支持 %s,可自动映射端口,外网地址 %s",
|
|
gw, strings.Join(ok, " / "), ext)
|
|
return
|
|
}
|
|
rep.Summary = fmt.Sprintf("路由器 %s 支持 %s,可自动映射端口", gw, strings.Join(ok, " / "))
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// NAT-PMP (RFC 6886)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// pmProbeNATPMP asks each gateway candidate in turn for its external address
|
|
// and returns the first answer, along with the gateway that gave it.
|
|
func pmProbeNATPMP(ctx context.Context, gws []netip.Addr, log *slog.Logger) (ServiceProbe, netip.Addr) {
|
|
var probe ServiceProbe
|
|
if len(gws) == 0 {
|
|
probe.Err = "no gateway candidate"
|
|
return probe, netip.Addr{}
|
|
}
|
|
deadline := pmDeadline(ctx, pmProtoBudget)
|
|
var lastErr error
|
|
for _, gw := range gws {
|
|
if !gw.Is4() { // NAT-PMP is IPv4-only
|
|
continue
|
|
}
|
|
if time.Now().After(deadline) || ctx.Err() != nil {
|
|
break
|
|
}
|
|
p, err := pmNATPMPOnce(ctx, gw, deadline, log)
|
|
if err != nil {
|
|
lastErr = err
|
|
continue
|
|
}
|
|
return p, gw
|
|
}
|
|
if lastErr != nil {
|
|
probe.Err = lastErr.Error()
|
|
} else {
|
|
probe.Err = "no IPv4 gateway candidate"
|
|
}
|
|
return probe, netip.Addr{}
|
|
}
|
|
|
|
// pmNATPMPOnce runs one external-address transaction against gw, retrying
|
|
// pmRetries times as RFC 6886 prescribes.
|
|
func pmNATPMPOnce(ctx context.Context, gw netip.Addr, deadline time.Time, log *slog.Logger) (ServiceProbe, error) {
|
|
var probe ServiceProbe
|
|
|
|
conn, err := pmDialUDP(ctx, gw)
|
|
if err != nil {
|
|
return probe, err
|
|
}
|
|
defer conn.Close()
|
|
|
|
req := []byte{0x00, 0x00} // version 0, opcode 0 = public address request
|
|
buf := make([]byte, 64)
|
|
|
|
// start is per attempt, not per probe: timing from before the retry loop
|
|
// would add every prior attempt's full timeout to the reported round trip,
|
|
// so a router that answers on the third try would look ~1.5s away.
|
|
var start time.Time
|
|
|
|
for attempt := 0; attempt <= pmRetries; attempt++ {
|
|
if ctx.Err() != nil {
|
|
return probe, ctx.Err()
|
|
}
|
|
until := time.Now().Add(pmUnicastTimeout)
|
|
if until.After(deadline) {
|
|
until = deadline
|
|
}
|
|
if !until.After(time.Now()) {
|
|
break
|
|
}
|
|
start = time.Now()
|
|
if _, err = conn.Write(req); err != nil {
|
|
return probe, err
|
|
}
|
|
_ = conn.SetReadDeadline(until)
|
|
n, rerr := conn.Read(buf)
|
|
if rerr != nil {
|
|
err = rerr
|
|
log.Debug("nat-pmp attempt timed out",
|
|
slog.String("gateway", gw.String()), slog.Int("attempt", attempt+1))
|
|
continue
|
|
}
|
|
rtt := time.Since(start)
|
|
if n < 12 {
|
|
err = fmt.Errorf("short nat-pmp response: %d bytes", n)
|
|
continue
|
|
}
|
|
if buf[0] != 0 || buf[1] != 0x80 {
|
|
err = fmt.Errorf("unexpected nat-pmp header %#x/%#x", buf[0], buf[1])
|
|
continue
|
|
}
|
|
code := binary.BigEndian.Uint16(buf[2:4])
|
|
epoch := binary.BigEndian.Uint32(buf[4:8])
|
|
if code != 0 {
|
|
probe.RTT = rtt
|
|
probe.Err = fmt.Sprintf("NAT-PMP v0 result code %d (%s)", code, pmPMPResultName(code))
|
|
probe.Detail = fmt.Sprintf("NAT-PMP v0 @ %s, epoch %ds", gw, epoch)
|
|
return probe, nil
|
|
}
|
|
var ip4 [4]byte
|
|
copy(ip4[:], buf[8:12])
|
|
probe.Available = true
|
|
probe.RTT = rtt
|
|
probe.ExternalIP = netip.AddrFrom4(ip4)
|
|
probe.Detail = fmt.Sprintf("NAT-PMP v0 @ %s, epoch %ds (路由器已运行 %s)",
|
|
gw, epoch, pmHuman(time.Duration(epoch)*time.Second))
|
|
return probe, nil
|
|
}
|
|
if err == nil {
|
|
err = errors.New("no nat-pmp response")
|
|
}
|
|
return probe, err
|
|
}
|
|
|
|
// pmPMPResultName names the RFC 6886 result codes.
|
|
func pmPMPResultName(code uint16) string {
|
|
switch code {
|
|
case 1:
|
|
return "unsupported version"
|
|
case 2:
|
|
return "not authorized / refused"
|
|
case 3:
|
|
return "network failure"
|
|
case 4:
|
|
return "out of resources"
|
|
case 5:
|
|
return "unsupported opcode"
|
|
default:
|
|
return "unknown"
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// PCP (RFC 6887)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// pmProbePCP sends a PCP ANNOUNCE to each gateway candidate and returns the
|
|
// first conclusive answer.
|
|
//
|
|
// PCP shares UDP/5351 with NAT-PMP, so a version-0 reply (or a version-2
|
|
// UNSUPP_VERSION result) means "this router speaks NAT-PMP but not PCP" rather
|
|
// than "nothing is there"; that distinction is recorded in Detail.
|
|
func pmProbePCP(ctx context.Context, gws []netip.Addr, log *slog.Logger) (ServiceProbe, netip.Addr) {
|
|
var probe ServiceProbe
|
|
if len(gws) == 0 {
|
|
probe.Err = "no gateway candidate"
|
|
return probe, netip.Addr{}
|
|
}
|
|
deadline := pmDeadline(ctx, pmProtoBudget)
|
|
var (
|
|
lastErr error
|
|
fallback *ServiceProbe
|
|
)
|
|
for _, gw := range gws {
|
|
if time.Now().After(deadline) || ctx.Err() != nil {
|
|
break
|
|
}
|
|
p, err := pmPCPOnce(ctx, gw, deadline, log)
|
|
if err != nil {
|
|
lastErr = err
|
|
continue
|
|
}
|
|
if p.Available {
|
|
return p, gw
|
|
}
|
|
if fallback == nil {
|
|
cp := p
|
|
fallback = &cp
|
|
}
|
|
}
|
|
if fallback != nil {
|
|
return *fallback, netip.Addr{}
|
|
}
|
|
if lastErr != nil {
|
|
probe.Err = lastErr.Error()
|
|
} else {
|
|
probe.Err = "no pcp response"
|
|
}
|
|
return probe, netip.Addr{}
|
|
}
|
|
|
|
// pmPCPOnce runs one ANNOUNCE transaction against gw.
|
|
func pmPCPOnce(ctx context.Context, gw netip.Addr, deadline time.Time, log *slog.Logger) (ServiceProbe, error) {
|
|
var probe ServiceProbe
|
|
|
|
conn, err := pmDialUDP(ctx, gw)
|
|
if err != nil {
|
|
return probe, err
|
|
}
|
|
defer conn.Close()
|
|
|
|
local, ok := netip.AddrFromSlice(conn.LocalAddr().(*net.UDPAddr).IP)
|
|
if !ok {
|
|
return probe, errors.New("cannot determine local address for pcp")
|
|
}
|
|
client := local.Unmap().As16() // IPv4-mapped IPv6 for v4 sources
|
|
|
|
req := make([]byte, 24)
|
|
req[0] = 2 // version
|
|
req[1] = 0x00 // R=0 (request), opcode 0 = ANNOUNCE
|
|
// req[2:4] reserved, req[4:8] requested lifetime = 0
|
|
copy(req[8:24], client[:])
|
|
|
|
buf := make([]byte, 1100)
|
|
|
|
// Per attempt; see the note in pmNATPMPOnce.
|
|
var start time.Time
|
|
|
|
for attempt := 0; attempt <= pmRetries; attempt++ {
|
|
if ctx.Err() != nil {
|
|
return probe, ctx.Err()
|
|
}
|
|
until := time.Now().Add(pmUnicastTimeout)
|
|
if until.After(deadline) {
|
|
until = deadline
|
|
}
|
|
if !until.After(time.Now()) {
|
|
break
|
|
}
|
|
start = time.Now()
|
|
if _, err = conn.Write(req); err != nil {
|
|
return probe, err
|
|
}
|
|
_ = conn.SetReadDeadline(until)
|
|
n, rerr := conn.Read(buf)
|
|
if rerr != nil {
|
|
err = rerr
|
|
log.Debug("pcp attempt timed out",
|
|
slog.String("gateway", gw.String()), slog.Int("attempt", attempt+1))
|
|
continue
|
|
}
|
|
rtt := time.Since(start)
|
|
|
|
// A NAT-PMP-speaking router answers a PCP request with version 0.
|
|
if n >= 4 && buf[0] == 0 {
|
|
probe.RTT = rtt
|
|
probe.Detail = fmt.Sprintf("%s 回应 NAT-PMP v0,不支持 PCP", gw)
|
|
probe.Err = "gateway answered NAT-PMP v0, PCP unsupported"
|
|
return probe, nil
|
|
}
|
|
if n < 24 {
|
|
err = fmt.Errorf("short pcp response: %d bytes", n)
|
|
continue
|
|
}
|
|
if buf[0] != 2 || buf[1] != 0x80 {
|
|
err = fmt.Errorf("unexpected pcp header %#x/%#x", buf[0], buf[1])
|
|
continue
|
|
}
|
|
code := buf[3]
|
|
epoch := binary.BigEndian.Uint32(buf[8:12])
|
|
if code == 1 { // UNSUPP_VERSION
|
|
probe.RTT = rtt
|
|
probe.Detail = fmt.Sprintf("%s 返回 UNSUPP_VERSION,仅支持 NAT-PMP,不支持 PCP v2", gw)
|
|
probe.Err = "pcp result code 1 (unsupported version); NAT-PMP present"
|
|
return probe, nil
|
|
}
|
|
if code != 0 {
|
|
probe.RTT = rtt
|
|
probe.Detail = fmt.Sprintf("PCP v2 @ %s, epoch %ds", gw, epoch)
|
|
probe.Err = fmt.Sprintf("PCP v2 result code %d (%s)", code, pmPCPResultName(code))
|
|
return probe, nil
|
|
}
|
|
probe.Available = true
|
|
probe.RTT = rtt
|
|
probe.Detail = fmt.Sprintf("PCP v2 @ %s, epoch %ds (路由器已运行 %s)",
|
|
gw, epoch, pmHuman(time.Duration(epoch)*time.Second))
|
|
return probe, nil
|
|
}
|
|
if err == nil {
|
|
err = errors.New("no pcp response")
|
|
}
|
|
return probe, err
|
|
}
|
|
|
|
// pmPCPResultName names the RFC 6887 result codes we are likely to see.
|
|
func pmPCPResultName(code byte) string {
|
|
switch code {
|
|
case 1:
|
|
return "unsupported version"
|
|
case 2:
|
|
return "not authorized"
|
|
case 3:
|
|
return "malformed request"
|
|
case 4:
|
|
return "unsupported opcode"
|
|
case 5:
|
|
return "unsupported option"
|
|
case 6:
|
|
return "malformed option"
|
|
case 7:
|
|
return "network failure"
|
|
case 8:
|
|
return "no resources"
|
|
case 9:
|
|
return "unsupported protocol"
|
|
case 10:
|
|
return "user exceeded quota"
|
|
case 11:
|
|
return "cannot provide external"
|
|
case 12:
|
|
return "address mismatch"
|
|
case 13:
|
|
return "excessive remote peers"
|
|
default:
|
|
return "unknown"
|
|
}
|
|
}
|
|
|
|
// pmDialUDP opens a connected UDP socket towards gw:5351, honouring ctx.
|
|
func pmDialUDP(ctx context.Context, gw netip.Addr) (net.Conn, error) {
|
|
d := net.Dialer{Timeout: pmUnicastTimeout}
|
|
network := "udp4"
|
|
if gw.Is6() {
|
|
network = "udp6"
|
|
}
|
|
return d.DialContext(ctx, network, netip.AddrPortFrom(gw, pmPort).String())
|
|
}
|
|
|
|
// pmHuman renders a duration the way a router uptime reads.
|
|
func pmHuman(d time.Duration) string {
|
|
d = d.Round(time.Minute)
|
|
days := int(d.Hours()) / 24
|
|
h := int(d.Hours()) % 24
|
|
m := int(d.Minutes()) % 60
|
|
if days > 0 {
|
|
return fmt.Sprintf("%dd%dh", days, h)
|
|
}
|
|
if h > 0 {
|
|
return fmt.Sprintf("%dh%dm", h, m)
|
|
}
|
|
return fmt.Sprintf("%dm", m)
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// UPnP IGD
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// pmProbeUPnP runs SSDP discovery, fetches the device description and asks the
|
|
// WAN connection service for the external address.
|
|
//
|
|
// Available flips to true as soon as an IGD answered SSDP and its description
|
|
// parsed; a failing SOAP call only fills Err, because a discoverable IGD is
|
|
// still a useful finding.
|
|
func pmProbeUPnP(ctx context.Context, log *slog.Logger) ServiceProbe {
|
|
var probe ServiceProbe
|
|
start := time.Now()
|
|
|
|
locations := pmSSDPDiscover(ctx, "urn:schemas-upnp-org:device:InternetGatewayDevice:1", log)
|
|
if len(locations) == 0 && ctx.Err() == nil {
|
|
log.Debug("ssdp igd search empty, retrying with ssdp:all")
|
|
locations = pmSSDPDiscover(ctx, "ssdp:all", log)
|
|
}
|
|
if len(locations) == 0 {
|
|
probe.Err = "no ssdp response"
|
|
return probe
|
|
}
|
|
if len(locations) > pmMaxLocations {
|
|
locations = locations[:pmMaxLocations]
|
|
}
|
|
|
|
client := &http.Client{
|
|
Transport: &http.Transport{
|
|
Proxy: nil, // the router is local; never go through a proxy
|
|
DisableKeepAlives: true,
|
|
TLSHandshakeTimeout: pmHTTPTimeout,
|
|
},
|
|
}
|
|
|
|
var lastErr error
|
|
for _, loc := range locations {
|
|
if ctx.Err() != nil {
|
|
break
|
|
}
|
|
dev, base, err := pmFetchDescription(ctx, client, loc, log)
|
|
if err != nil {
|
|
lastErr = err
|
|
continue
|
|
}
|
|
probe.Available = true
|
|
probe.RTT = time.Since(start)
|
|
probe.Detail = pmDeviceLabel(dev, loc)
|
|
|
|
svcType, ctrlURL, ok := pmFindWANService(dev, base)
|
|
if !ok {
|
|
probe.Err = "no WANIPConnection/WANPPPConnection service in device description"
|
|
return probe
|
|
}
|
|
probe.Detail = pmDeviceLabel(dev, ctrlURL)
|
|
|
|
ip, err := pmSOAPExternalIP(ctx, client, ctrlURL, svcType)
|
|
if err != nil {
|
|
probe.Err = "GetExternalIPAddress: " + err.Error()
|
|
return probe
|
|
}
|
|
probe.ExternalIP = ip
|
|
probe.RTT = time.Since(start)
|
|
return probe
|
|
}
|
|
if lastErr != nil {
|
|
probe.Err = lastErr.Error()
|
|
} else {
|
|
probe.Err = "no usable igd description"
|
|
}
|
|
return probe
|
|
}
|
|
|
|
// pmDeviceLabel renders "<friendlyName> (<modelName>)" when known, falling
|
|
// back to the manufacturer or the URL.
|
|
func pmDeviceLabel(dev *pmUPnPDevice, fallback string) string {
|
|
name := strings.TrimSpace(dev.FriendlyName)
|
|
model := strings.TrimSpace(dev.ModelName)
|
|
switch {
|
|
case name != "" && model != "":
|
|
return fmt.Sprintf("%s (%s)", name, model)
|
|
case name != "":
|
|
return name
|
|
case model != "":
|
|
return model
|
|
case strings.TrimSpace(dev.Manufacturer) != "":
|
|
return strings.TrimSpace(dev.Manufacturer)
|
|
default:
|
|
return fallback
|
|
}
|
|
}
|
|
|
|
// pmSSDPDiscover sends an M-SEARCH from every usable IPv4 interface address and
|
|
// collects the LOCATION headers of the replies for roughly two seconds. The
|
|
// returned list is deduplicated and sorted so the UI does not jitter.
|
|
func pmSSDPDiscover(ctx context.Context, st string, log *slog.Logger) []string {
|
|
srcs := pmSSDPSources(log)
|
|
if len(srcs) == 0 {
|
|
return nil
|
|
}
|
|
body := "M-SEARCH * HTTP/1.1\r\n" +
|
|
"HOST: 239.255.255.250:1900\r\n" +
|
|
"MAN: \"ssdp:discover\"\r\n" +
|
|
"MX: " + strconv.Itoa(pmSSDPMX) + "\r\n" +
|
|
"ST: " + st + "\r\n" +
|
|
"\r\n"
|
|
|
|
target, err := net.ResolveUDPAddr("udp4", pmSSDPTarget)
|
|
if err != nil {
|
|
log.Debug("cannot resolve ssdp target", slog.String("error", err.Error()))
|
|
return nil
|
|
}
|
|
deadline := pmDeadline(ctx, pmSSDPCollect)
|
|
|
|
var (
|
|
mu sync.Mutex
|
|
locs = map[string]bool{}
|
|
wg sync.WaitGroup
|
|
sem = make(chan struct{}, pmMaxSSDPSockets)
|
|
)
|
|
for _, src := range srcs {
|
|
wg.Add(1)
|
|
go func(src netip.Addr) {
|
|
defer wg.Done()
|
|
sem <- struct{}{}
|
|
defer func() { <-sem }()
|
|
for _, l := range pmSSDPOne(src, target, body, deadline, log) {
|
|
mu.Lock()
|
|
locs[l] = true
|
|
mu.Unlock()
|
|
}
|
|
}(src)
|
|
}
|
|
wg.Wait()
|
|
|
|
out := make([]string, 0, len(locs))
|
|
for l := range locs {
|
|
out = append(out, l)
|
|
}
|
|
sort.Strings(out)
|
|
return out
|
|
}
|
|
|
|
// pmSSDPOne performs the M-SEARCH from a single source address. Interfaces
|
|
// without multicast support fail here routinely; that is a debug-level event,
|
|
// not an error worth surfacing.
|
|
func pmSSDPOne(src netip.Addr, target *net.UDPAddr, body string, hardDeadline time.Time, log *slog.Logger) []string {
|
|
if !hardDeadline.After(time.Now()) {
|
|
return nil
|
|
}
|
|
conn, err := net.ListenUDP("udp4", &net.UDPAddr{IP: net.IP(src.AsSlice())})
|
|
if err != nil {
|
|
log.Debug("ssdp socket unavailable",
|
|
slog.String("src", src.String()), slog.String("error", err.Error()))
|
|
return nil
|
|
}
|
|
defer conn.Close()
|
|
|
|
if _, err := conn.WriteToUDP([]byte(body), target); err != nil {
|
|
log.Debug("ssdp multicast send failed",
|
|
slog.String("src", src.String()), slog.String("error", err.Error()))
|
|
return nil
|
|
}
|
|
|
|
// Give devices the full MX window measured from the send, not from when
|
|
// the caller computed a shared deadline: socket setup and goroutine
|
|
// scheduling happen in between, and every millisecond of that came out of
|
|
// the reply window.
|
|
deadline := time.Now().Add(pmSSDPCollect)
|
|
if deadline.After(hardDeadline) {
|
|
deadline = hardDeadline
|
|
}
|
|
|
|
var out []string
|
|
buf := make([]byte, 4096)
|
|
for {
|
|
if !deadline.After(time.Now()) {
|
|
break
|
|
}
|
|
_ = conn.SetReadDeadline(deadline)
|
|
n, from, err := conn.ReadFromUDP(buf)
|
|
if err != nil {
|
|
break
|
|
}
|
|
if loc := pmSSDPLocation(buf[:n]); loc != "" {
|
|
log.Debug("ssdp reply",
|
|
slog.String("from", from.String()), slog.String("location", loc))
|
|
out = append(out, loc)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// pmSSDPLocation extracts the LOCATION header from an SSDP reply,
|
|
// case-insensitively.
|
|
func pmSSDPLocation(b []byte) string {
|
|
for _, line := range strings.Split(string(b), "\n") {
|
|
line = strings.TrimRight(line, "\r")
|
|
k, v, ok := strings.Cut(line, ":")
|
|
if !ok || !strings.EqualFold(strings.TrimSpace(k), "location") {
|
|
continue
|
|
}
|
|
v = strings.TrimSpace(v)
|
|
u, err := url.Parse(v)
|
|
if err != nil || (u.Scheme != "http" && u.Scheme != "https") {
|
|
return ""
|
|
}
|
|
return v
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// pmSSDPSources lists the IPv4 addresses worth sending an M-SEARCH from.
|
|
func pmSSDPSources(log *slog.Logger) []netip.Addr {
|
|
ifaces, err := net.Interfaces()
|
|
if err != nil {
|
|
log.Debug("cannot enumerate interfaces for ssdp", slog.String("error", err.Error()))
|
|
return nil
|
|
}
|
|
sort.Slice(ifaces, func(i, j int) bool { return ifaces[i].Name < ifaces[j].Name })
|
|
|
|
var out []netip.Addr
|
|
for _, ifc := range ifaces {
|
|
if ifc.Flags&net.FlagUp == 0 || ifc.Flags&net.FlagLoopback != 0 {
|
|
continue
|
|
}
|
|
addrs, err := ifc.Addrs()
|
|
if err != nil {
|
|
continue
|
|
}
|
|
for _, a := range addrs {
|
|
ipn, ok := a.(*net.IPNet)
|
|
if !ok {
|
|
continue
|
|
}
|
|
addr, ok := netip.AddrFromSlice(ipn.IP)
|
|
if !ok {
|
|
continue
|
|
}
|
|
addr = addr.Unmap()
|
|
if !addr.Is4() || addr.IsLoopback() || addr.IsLinkLocalUnicast() {
|
|
continue
|
|
}
|
|
out = append(out, addr)
|
|
}
|
|
}
|
|
return pmDedupAddrs(out)
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// device description
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// pmUPnPService is one service entry of a UPnP device description.
|
|
type pmUPnPService struct {
|
|
ServiceType string `xml:"serviceType"`
|
|
ControlURL string `xml:"controlURL"`
|
|
}
|
|
|
|
// pmUPnPDevice is one (possibly nested) device entry of a UPnP description.
|
|
type pmUPnPDevice struct {
|
|
DeviceType string `xml:"deviceType"`
|
|
FriendlyName string `xml:"friendlyName"`
|
|
Manufacturer string `xml:"manufacturer"`
|
|
ModelName string `xml:"modelName"`
|
|
Services []pmUPnPService `xml:"serviceList>service"`
|
|
Devices []pmUPnPDevice `xml:"deviceList>device"`
|
|
}
|
|
|
|
// pmUPnPRoot is the root element of a UPnP device description.
|
|
type pmUPnPRoot struct {
|
|
URLBase string `xml:"URLBase"`
|
|
Device pmUPnPDevice `xml:"device"`
|
|
}
|
|
|
|
// pmFetchDescription GETs a LOCATION and parses the device description. It
|
|
// returns the root device and the base URL that relative control URLs resolve
|
|
// against.
|
|
func pmFetchDescription(ctx context.Context, client *http.Client, loc string, log *slog.Logger) (*pmUPnPDevice, *url.URL, error) {
|
|
rctx, cancel := context.WithTimeout(ctx, pmHTTPTimeout)
|
|
defer cancel()
|
|
|
|
req, err := http.NewRequestWithContext(rctx, http.MethodGet, loc, nil)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
resp, err := client.Do(req)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
defer func() {
|
|
_, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, 4096))
|
|
resp.Body.Close()
|
|
}()
|
|
if resp.StatusCode != http.StatusOK {
|
|
return nil, nil, fmt.Errorf("description %s: http %d", loc, resp.StatusCode)
|
|
}
|
|
|
|
body, err := io.ReadAll(io.LimitReader(resp.Body, pmMaxDescBody))
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
|
|
var root pmUPnPRoot
|
|
dec := xml.NewDecoder(bytes.NewReader(body))
|
|
dec.CharsetReader = pmPassthroughCharset
|
|
dec.Strict = false
|
|
if err := dec.Decode(&root); err != nil {
|
|
return nil, nil, fmt.Errorf("parse description %s: %w", loc, err)
|
|
}
|
|
|
|
base, err := url.Parse(loc)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
// URLBase comes from a device description fetched after an unauthenticated
|
|
// multicast handshake, i.e. it is attacker-controlled on a hostile LAN.
|
|
// Accepting it unchecked would let any device on the network redirect the
|
|
// SOAP POST below to a host of its choosing — including internal services
|
|
// the router itself cannot reach. Only honour a rebase that stays on the
|
|
// same origin we already decided to talk to.
|
|
if b := strings.TrimSpace(root.URLBase); b != "" {
|
|
if u, err := url.Parse(b); err == nil && pmSameOrigin(base, u) {
|
|
base = u
|
|
} else {
|
|
log.Debug("ignoring off-origin URLBase",
|
|
slog.String("location", loc), slog.String("urlbase", b))
|
|
}
|
|
}
|
|
log.Debug("igd description parsed",
|
|
slog.String("location", loc), slog.String("device", root.Device.FriendlyName))
|
|
return &root.Device, base, nil
|
|
}
|
|
|
|
// pmSameOrigin reports whether u is an http(s) URL on the same host as base.
|
|
// The port may differ — IGDs routinely serve the description and the control
|
|
// endpoint on different ports — but the host may not.
|
|
func pmSameOrigin(base, u *url.URL) bool {
|
|
if u.Scheme != "http" && u.Scheme != "https" {
|
|
return false
|
|
}
|
|
if u.Host == "" {
|
|
return false
|
|
}
|
|
return strings.EqualFold(u.Hostname(), base.Hostname())
|
|
}
|
|
|
|
// pmPassthroughCharset accepts non-UTF-8 declarations rather than failing the
|
|
// parse; router descriptions are ASCII in practice.
|
|
func pmPassthroughCharset(_ string, input io.Reader) (io.Reader, error) {
|
|
return input, nil
|
|
}
|
|
|
|
// pmFindWANService walks the nested device tree for a WAN connection service
|
|
// and resolves its control URL against base.
|
|
func pmFindWANService(dev *pmUPnPDevice, base *url.URL) (svcType, ctrlURL string, ok bool) {
|
|
for _, want := range pmWANServices {
|
|
if st, cu, found := pmFindService(dev, want); found {
|
|
ref, err := url.Parse(strings.TrimSpace(cu))
|
|
if err != nil {
|
|
continue
|
|
}
|
|
return st, base.ResolveReference(ref).String(), true
|
|
}
|
|
}
|
|
return "", "", false
|
|
}
|
|
|
|
// pmFindService searches dev and its children for an exact serviceType match.
|
|
func pmFindService(dev *pmUPnPDevice, want string) (svcType, ctrlURL string, ok bool) {
|
|
for _, s := range dev.Services {
|
|
if strings.EqualFold(strings.TrimSpace(s.ServiceType), want) && strings.TrimSpace(s.ControlURL) != "" {
|
|
return strings.TrimSpace(s.ServiceType), s.ControlURL, true
|
|
}
|
|
}
|
|
for i := range dev.Devices {
|
|
if st, cu, found := pmFindService(&dev.Devices[i], want); found {
|
|
return st, cu, true
|
|
}
|
|
}
|
|
return "", "", false
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// SOAP
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// pmSOAPExternalIP invokes GetExternalIPAddress on an IGD control URL.
|
|
func pmSOAPExternalIP(ctx context.Context, client *http.Client, ctrlURL, svcType string) (netip.Addr, error) {
|
|
envelope := `<?xml version="1.0"?>` + "\r\n" +
|
|
`<s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/" ` +
|
|
`s:encodingStyle="http://schemas.xmlsoap.org/soap/encoding/">` +
|
|
`<s:Body><u:GetExternalIPAddress xmlns:u="` + svcType + `"></u:GetExternalIPAddress></s:Body>` +
|
|
`</s:Envelope>` + "\r\n"
|
|
|
|
rctx, cancel := context.WithTimeout(ctx, pmHTTPTimeout)
|
|
defer cancel()
|
|
|
|
req, err := http.NewRequestWithContext(rctx, http.MethodPost, ctrlURL, strings.NewReader(envelope))
|
|
if err != nil {
|
|
return netip.Addr{}, err
|
|
}
|
|
req.Header.Set("Content-Type", `text/xml; charset="utf-8"`)
|
|
req.Header.Set("SOAPAction", `"`+svcType+`#GetExternalIPAddress"`)
|
|
|
|
resp, err := client.Do(req)
|
|
if err != nil {
|
|
return netip.Addr{}, err
|
|
}
|
|
defer func() {
|
|
_, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, 4096))
|
|
resp.Body.Close()
|
|
}()
|
|
|
|
body, err := io.ReadAll(io.LimitReader(resp.Body, pmMaxDescBody))
|
|
if err != nil {
|
|
return netip.Addr{}, err
|
|
}
|
|
if resp.StatusCode != http.StatusOK {
|
|
return netip.Addr{}, fmt.Errorf("http %d", resp.StatusCode)
|
|
}
|
|
raw := pmXMLValue(body, "NewExternalIPAddress")
|
|
if raw == "" {
|
|
return netip.Addr{}, errors.New("no NewExternalIPAddress in soap response")
|
|
}
|
|
addr, err := netip.ParseAddr(strings.TrimSpace(raw))
|
|
if err != nil {
|
|
return netip.Addr{}, fmt.Errorf("bad external address %q", raw)
|
|
}
|
|
return addr.Unmap(), nil
|
|
}
|
|
|
|
// pmXMLValue returns the character data of the first element with the given
|
|
// local name, ignoring namespaces.
|
|
func pmXMLValue(body []byte, local string) string {
|
|
dec := xml.NewDecoder(bytes.NewReader(body))
|
|
dec.CharsetReader = pmPassthroughCharset
|
|
dec.Strict = false
|
|
for {
|
|
tok, err := dec.Token()
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
se, ok := tok.(xml.StartElement)
|
|
if !ok || se.Name.Local != local {
|
|
continue
|
|
}
|
|
var v string
|
|
if err := dec.DecodeElement(&v, &se); err != nil {
|
|
return ""
|
|
}
|
|
return v
|
|
}
|
|
}
|