| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769 |
- // Copyright (c) Tailscale Inc & AUTHORS
- // SPDX-License-Identifier: BSD-3-Clause
- //go:build !js
- // (no raw sockets in JS/WASM)
- package portmapper
- import (
- "bufio"
- "bytes"
- "cmp"
- "context"
- "encoding/xml"
- "fmt"
- "io"
- "math/rand"
- "net"
- "net/http"
- "net/netip"
- "net/url"
- "slices"
- "strings"
- "sync/atomic"
- "time"
- "github.com/tailscale/goupnp"
- "github.com/tailscale/goupnp/dcps/internetgateway2"
- "github.com/tailscale/goupnp/soap"
- "tailscale.com/envknob"
- "tailscale.com/net/netns"
- "tailscale.com/types/logger"
- "tailscale.com/util/mak"
- )
- // References:
- //
- // WANIP Connection v2: http://upnp.org/specs/gw/UPnP-gw-WANIPConnection-v2-Service.pdf
- // upnpMapping is a port mapping over the upnp protocol. After being created it is immutable,
- // but the client field may be shared across mapping instances.
- type upnpMapping struct {
- gw netip.Addr
- external netip.AddrPort
- internal netip.AddrPort
- goodUntil time.Time
- renewAfter time.Time
- // rootDev is the UPnP root device, and may be reused across different
- // UPnP mappings.
- rootDev *goupnp.RootDevice
- // loc is the location used to fetch the rootDev
- loc *url.URL
- // client is the most recent UPnP client used, and should only be used
- // to release an existing mapping; new mappings should be selected from
- // the rootDev on each attempt.
- client upnpClient
- }
- // upnpProtocolUDP represents the protocol name for UDP, to be used in the UPnP
- // <AddPortMapping> message in the <NewProtocol> field.
- //
- // NOTE: this must be an upper-case string, or certain routers will reject the
- // mapping request. Other implementations like miniupnp send an upper-case
- // protocol as well. See:
- //
- // https://github.com/tailscale/tailscale/issues/7377
- const upnpProtocolUDP = "UDP"
- func (u *upnpMapping) MappingType() string { return "upnp" }
- func (u *upnpMapping) GoodUntil() time.Time { return u.goodUntil }
- func (u *upnpMapping) RenewAfter() time.Time { return u.renewAfter }
- func (u *upnpMapping) External() netip.AddrPort { return u.external }
- func (u *upnpMapping) MappingDebug() string {
- return fmt.Sprintf("upnpMapping{gw:%v, external:%v, internal:%v, renewAfter:%d, goodUntil:%d, loc:%q}",
- u.gw, u.external, u.internal,
- u.renewAfter.Unix(), u.goodUntil.Unix(),
- u.loc)
- }
- func (u *upnpMapping) Release(ctx context.Context) {
- u.client.DeletePortMapping(ctx, "", u.external.Port(), upnpProtocolUDP)
- }
- // upnpClient is an interface over the multiple different clients exported by goupnp,
- // exposing the functions we need for portmapping. Those clients are auto-generated from XML-specs,
- // which is why they're not very idiomatic.
- type upnpClient interface {
- AddPortMapping(
- ctx context.Context,
- // remoteHost is the remote device sending packets to this device, in the format of x.x.x.x.
- // The empty string, "", means any host out on the internet can send packets in.
- remoteHost string,
- // externalPort is the exposed port of this port mapping. Visible during NAT operations.
- // 0 will let the router select the port, but there is an additional call,
- // `AddAnyPortMapping`, which is available on 1 of the 3 possible protocols,
- // which should be used if available. See `addAnyPortMapping` below, which calls this if
- // `AddAnyPortMapping` is not supported.
- externalPort uint16,
- // protocol is whether this is over TCP or UDP. Either "TCP" or "UDP".
- protocol string,
- // internalPort is the port that the gateway device forwards the traffic to.
- internalPort uint16,
- // internalClient is the IP address that packets will be forwarded to for this mapping.
- // Internal client is of the form "x.x.x.x".
- internalClient string,
- // enabled is whether this portmapping should be enabled or disabled.
- enabled bool,
- // portMappingDescription is a user-readable description of this portmapping.
- portMappingDescription string,
- // leaseDurationSec is the duration of this portmapping. The value of this argument must be
- // greater than 0. From the spec, it appears if it is set to 0, it will switch to using
- // 604800 seconds, but not sure why this is desired. The recommended time is 3600 seconds.
- leaseDurationSec uint32,
- ) error
- DeletePortMapping(ctx context.Context, remoteHost string, externalPort uint16, protocol string) error
- GetExternalIPAddress(ctx context.Context) (externalIPAddress string, err error)
- GetStatusInfo(ctx context.Context) (status string, lastConnError string, uptime uint32, err error)
- }
- // tsPortMappingDesc gets sent to UPnP clients as a human-readable label for the portmapping.
- // It is not used for anything other than labelling.
- const tsPortMappingDesc = "tailscale-portmap"
- // addAnyPortMapping abstracts over different UPnP client connections, calling
- // the available AddAnyPortMapping call if available for WAN IP connection v2,
- // otherwise picking either the previous port (if one is present) or a random
- // port and trying to obtain a mapping using AddPortMapping.
- //
- // It returns the new external port (which may not be identical to the external
- // port specified), or an error.
- //
- // TODO(bradfitz): also returned the actual lease duration obtained. and check it regularly.
- func addAnyPortMapping(
- ctx context.Context,
- upnp upnpClient,
- externalPort uint16,
- internalPort uint16,
- internalClient string,
- leaseDuration time.Duration,
- ) (newPort uint16, err error) {
- // Some devices don't let clients add a port mapping for privileged
- // ports (ports below 1024). Additionally, per section 2.3.18 of the
- // UPnP spec, regarding the ExternalPort field:
- //
- // If this value is specified as a wildcard (i.e. 0), connection
- // request on all external ports (that are not otherwise mapped)
- // will be forwarded to InternalClient. In the wildcard case, the
- // value(s) of InternalPort on InternalClient are ignored by the IGD
- // for those connections that are forwarded to InternalClient.
- // Obviously only one such entry can exist in the NAT at any time
- // and conflicts are handled with a “first write wins” behavior.
- //
- // We obviously do not want to open all ports on the user's device to
- // the internet, so we want to do this prior to calling either
- // AddAnyPortMapping or AddPortMapping.
- //
- // Pick an external port that's greater than 1024 by getting a random
- // number in [0, 65535 - 1024] and then adding 1024 to it, shifting the
- // range to [1024, 65535].
- if externalPort < 1024 {
- externalPort = uint16(rand.Intn(65535-1024) + 1024)
- }
- // First off, try using AddAnyPortMapping; if there's a conflict, the
- // router will pick another port and return it.
- if upnp, ok := upnp.(*internetgateway2.WANIPConnection2); ok {
- return upnp.AddAnyPortMapping(
- ctx,
- "",
- externalPort,
- upnpProtocolUDP,
- internalPort,
- internalClient,
- true,
- tsPortMappingDesc,
- uint32(leaseDuration.Seconds()),
- )
- }
- // Fall back to using AddPortMapping, which requests a mapping to/from
- // a specific external port.
- err = upnp.AddPortMapping(
- ctx,
- "",
- externalPort,
- upnpProtocolUDP,
- internalPort,
- internalClient,
- true,
- tsPortMappingDesc,
- uint32(leaseDuration.Seconds()),
- )
- return externalPort, err
- }
- // getUPnPRootDevice fetches the UPnP root device given the discovery response,
- // ignoring the underlying protocol for now.
- // Adapted from https://github.com/huin/goupnp/blob/master/GUIDE.md.
- //
- // The gw is the detected gateway.
- //
- // The meta is the most recently parsed UDP discovery packet response
- // from the Internet Gateway Device.
- func getUPnPRootDevice(ctx context.Context, logf logger.Logf, debug DebugKnobs, gw netip.Addr, meta uPnPDiscoResponse) (rootDev *goupnp.RootDevice, loc *url.URL, err error) {
- if debug.DisableUPnP {
- return nil, nil, nil
- }
- if meta.Location == "" {
- return nil, nil, nil
- }
- if debug.VerboseLogs {
- logf("fetching %v", meta.Location)
- }
- u, err := url.Parse(meta.Location)
- if err != nil {
- return nil, nil, err
- }
- ipp, err := netip.ParseAddrPort(u.Host)
- if err != nil {
- return nil, nil, fmt.Errorf("unexpected host %q in %q", u.Host, meta.Location)
- }
- if ipp.Addr() != gw {
- // https://github.com/tailscale/tailscale/issues/5502
- logf("UPnP discovered root %q does not match gateway IP %v; repointing at gateway which is assumed to be floating",
- meta.Location, gw)
- u.Host = net.JoinHostPort(gw.String(), u.Port())
- }
- // We're fetching a smallish XML document over plain HTTP
- // across the local LAN, without using DNS. There should be
- // very few round trips and low latency, so one second is a
- // long time.
- ctx, cancel := context.WithTimeout(ctx, time.Second)
- defer cancel()
- // This part does a network fetch.
- root, err := goupnp.DeviceByURL(ctx, u)
- if err != nil {
- return nil, nil, err
- }
- return root, u, nil
- }
- // selectBestService picks the "best" service from the given UPnP root device
- // to use to create a port mapping. It may return (nil, nil) if no supported
- // service was found in the provided *goupnp.RootDevice.
- //
- // loc is the parsed location that was used to fetch the given RootDevice.
- //
- // The provided ctx is not retained in the returned upnpClient, but
- // its associated HTTP client is (if set via goupnp.WithHTTPClient).
- func selectBestService(ctx context.Context, logf logger.Logf, root *goupnp.RootDevice, loc *url.URL) (client upnpClient, err error) {
- method := "none"
- defer func() {
- if client == nil {
- return
- }
- logf("saw UPnP type %v at %v; %v (%v), method=%s",
- strings.TrimPrefix(fmt.Sprintf("%T", client), "*internetgateway2."),
- loc, root.Device.FriendlyName, root.Device.Manufacturer,
- method)
- }()
- // First, get all available clients from the device, and append to our
- // list of possible clients. Order matters here; we want to prefer
- // WANIPConnection2 over WANIPConnection1 or WANPPPConnection.
- wanIP2, _ := internetgateway2.NewWANIPConnection2ClientsFromRootDevice(ctx, root, loc)
- wanIP1, _ := internetgateway2.NewWANIPConnection1ClientsFromRootDevice(ctx, root, loc)
- wanPPP, _ := internetgateway2.NewWANPPPConnection1ClientsFromRootDevice(ctx, root, loc)
- var clients []upnpClient
- for _, v := range wanIP2 {
- clients = append(clients, v)
- }
- for _, v := range wanIP1 {
- clients = append(clients, v)
- }
- for _, v := range wanPPP {
- clients = append(clients, v)
- }
- // If we have no clients, then return right now; if we only have one,
- // just select and return it.
- if len(clients) == 0 {
- return nil, nil
- }
- if len(clients) == 1 {
- method = "single"
- metricUPnPSelectSingle.Add(1)
- return clients[0], nil
- }
- metricUPnPSelectMultiple.Add(1)
- // In order to maximize the chances that we find a valid UPnP device
- // that can give us a port mapping, we check a few properties:
- // 1. Whether the device is "online", as defined by GetStatusInfo
- // 2. Whether the device has an external IP address, as defined by
- // GetExternalIPAddress
- // 3. Whether the device's external IP address is a public address
- // or a private one.
- //
- // We prefer a device where all of the above is true, and fall back if
- // none are found.
- //
- // In order to save on network requests, iterate through all devices
- // and determine how many "points" they have based on the above
- // criteria, but return immediately if we find one that meets all
- // three.
- var (
- connected = make(map[upnpClient]bool)
- externalIPs map[upnpClient]netip.Addr
- )
- for _, svc := range clients {
- isConnected := serviceIsConnected(ctx, logf, svc)
- connected[svc] = isConnected
- // Don't bother checking for an external IP if the device isn't
- // connected; technically this could happen with a misbehaving
- // device, but that seems unlikely.
- if !isConnected {
- continue
- }
- // Check if the device has an external IP address.
- extIP, err := svc.GetExternalIPAddress(ctx)
- if err != nil {
- continue
- }
- externalIP, err := netip.ParseAddr(extIP)
- if err != nil {
- continue
- }
- mak.Set(&externalIPs, svc, externalIP)
- // If we get here, this device has a non-private external IP
- // and is up, so we can just return it.
- if !externalIP.IsPrivate() {
- method = "ext-public"
- metricUPnPSelectExternalPublic.Add(1)
- return svc, nil
- }
- }
- // Okay, we have no devices that meet all the available options. Fall
- // back to first checking for devices that are up and have a private
- // external IP (order matters), and then devices that are up, and then
- // just anything at all.
- //
- // try=0 Up + private external IP
- // try=1 Up
- for try := 0; try <= 1; try++ {
- for _, svc := range clients {
- if !connected[svc] {
- continue
- }
- _, hasExtIP := externalIPs[svc]
- if hasExtIP {
- method = "ext-private"
- metricUPnPSelectExternalPrivate.Add(1)
- return svc, nil
- } else if try == 1 {
- method = "up"
- metricUPnPSelectUp.Add(1)
- return svc, nil
- }
- }
- }
- // Nothing is up, but we have something (length of clients checked
- // above); just return the first one.
- metricUPnPSelectNone.Add(1)
- return clients[0], nil
- }
- // serviceIsConnected returns whether a given UPnP service is connected, based
- // on the NewConnectionStatus field returned from GetStatusInfo.
- func serviceIsConnected(ctx context.Context, logf logger.Logf, svc upnpClient) bool {
- status, _ /* NewLastConnectionError */, _ /* NewUptime */, err := svc.GetStatusInfo(ctx)
- if err != nil {
- return false
- }
- return status == "Connected" || status == "Up"
- }
- func (c *Client) upnpHTTPClientLocked() *http.Client {
- if c.uPnPHTTPClient == nil {
- c.uPnPHTTPClient = &http.Client{
- Transport: &http.Transport{
- DialContext: netns.NewDialer(c.logf, c.netMon).DialContext,
- IdleConnTimeout: 2 * time.Second, // LAN is cheap
- },
- }
- if c.debug.LogHTTP {
- c.uPnPHTTPClient = requestLogger(c.logf, c.uPnPHTTPClient)
- }
- }
- return c.uPnPHTTPClient
- }
- var (
- disableUPnpEnv = envknob.RegisterBool("TS_DISABLE_UPNP")
- )
- // getUPnPPortMapping attempts to create a port-mapping over the UPnP protocol. On success,
- // it will return the externally exposed IP and port. Otherwise, it will return a zeroed IP and
- // port and an error.
- func (c *Client) getUPnPPortMapping(
- ctx context.Context,
- gw netip.Addr,
- internal netip.AddrPort,
- prevPort uint16,
- ) (external netip.AddrPort, ok bool) {
- if disableUPnpEnv() || c.debug.DisableUPnP || (c.controlKnobs != nil && c.controlKnobs.DisableUPnP.Load()) {
- return netip.AddrPort{}, false
- }
- now := time.Now()
- upnp := &upnpMapping{
- gw: gw,
- internal: internal,
- }
- // We can have multiple UPnP "meta" values (which correspond to the
- // UPnP discovery responses received). We want to try all of them when
- // obtaining a mapping, but also prefer any existing mapping's root
- // device (if present), since that will allow us to renew an existing
- // mapping instead of creating a new one.
- // Start by grabbing the list of metas, any existing mapping, and
- // creating a HTTP client for use.
- c.mu.Lock()
- oldMapping, ok := c.mapping.(*upnpMapping)
- metas := c.uPnPMetas
- ctx = goupnp.WithHTTPClient(ctx, c.upnpHTTPClientLocked())
- c.mu.Unlock()
- // Wrapper for a uPnPDiscoResponse with an optional existing root
- // device + URL (if we've got a previous cached mapping).
- type step struct {
- rootDev *goupnp.RootDevice // if nil, use 'meta'
- loc *url.URL // non-nil if rootDev is non-nil
- meta uPnPDiscoResponse
- }
- var steps []step
- // Now, if we have an existing mapping, swap that mapping's entry to
- // the first entry in our "metas" list so we try it first.
- haveOldMapping := ok && oldMapping != nil
- if haveOldMapping && oldMapping.rootDev != nil {
- steps = append(steps, step{rootDev: oldMapping.rootDev, loc: oldMapping.loc})
- }
- // Note: this includes the meta for a previously-cached mapping, in
- // case the rootDev changes.
- for _, meta := range metas {
- steps = append(steps, step{meta: meta})
- }
- // Now, iterate through every meta that we have trying to get an
- // external IP address. If we succeed, we'll return; if we fail, we
- // continue this loop.
- var errs []error
- for _, step := range steps {
- var (
- rootDev *goupnp.RootDevice
- loc *url.URL
- err error
- )
- if step.rootDev != nil {
- rootDev = step.rootDev
- loc = step.loc
- } else {
- rootDev, loc, err = getUPnPRootDevice(ctx, c.logf, c.debug, gw, step.meta)
- c.vlogf("getUPnPRootDevice: loc=%q err=%v", loc, err)
- if err != nil {
- errs = append(errs, err)
- continue
- }
- }
- if rootDev == nil {
- continue
- }
- // This actually performs the port mapping operation using this
- // root device.
- //
- // TODO(andrew-d): this can successfully perform a portmap and
- // return an externalAddrPort that refers to a non-public IP
- // address if the first selected RootDevice is a device that is
- // connected to another internal network. This is still better
- // than randomly flapping between multiple devices, but we
- // should probably split this up further to try the best
- // service (one with an external IP) first, instead of
- // iterating by device.
- //
- // This is probably sufficiently unlikely that I'm leaving that
- // as a follow-up task if it's necessary.
- externalAddrPort, client, err := c.tryUPnPPortmapWithDevice(ctx, internal, prevPort, rootDev, loc)
- if err != nil {
- errs = append(errs, err)
- continue
- }
- // If we get here, we're successful; we can cache this mapping,
- // update our local port, and then return.
- //
- // NOTE: this time might not technically be accurate if we created a
- // permanent lease above, but we should still re-check the presence of
- // the lease on a regular basis so we use it anyway.
- d := time.Duration(pmpMapLifetimeSec) * time.Second
- upnp.goodUntil = now.Add(d)
- upnp.renewAfter = now.Add(d / 2)
- upnp.external = externalAddrPort
- upnp.rootDev = rootDev
- upnp.loc = loc
- upnp.client = client
- c.mu.Lock()
- defer c.mu.Unlock()
- c.mapping = upnp
- c.localPort = externalAddrPort.Port()
- return upnp.external, true
- }
- // If we get here, we didn't get anything.
- // TODO(andrew-d): use or log errs?
- _ = errs
- return netip.AddrPort{}, false
- }
- // tryUPnPPortmapWithDevice attempts to perform a port forward from the given
- // UPnP device to the 'internal' address. It tries to re-use the previous port,
- // if a non-zero value is provided, and handles retries and errors about
- // unsupported features.
- //
- // It returns the external address and port that was mapped (i.e. the
- // address+port that another Tailscale node can use to make a connection to
- // this one) and the UPnP client that was used to obtain that mapping.
- func (c *Client) tryUPnPPortmapWithDevice(
- ctx context.Context,
- internal netip.AddrPort,
- prevPort uint16,
- rootDev *goupnp.RootDevice,
- loc *url.URL,
- ) (netip.AddrPort, upnpClient, error) {
- // Select the best mapping service from the given root device. This
- // makes network requests, and can vary from mapping to mapping if the
- // upstream device's connection status changes.
- client, err := selectBestService(ctx, c.logf, rootDev, loc)
- if err != nil {
- return netip.AddrPort{}, nil, err
- }
- // If we have no client, we cannot continue; this can happen if we get
- // a valid UPnP response that does not contain any of the service types
- // that we know how to use.
- if client == nil {
- // For debugging, print all available services that we aren't
- // using because they're not supported; use c.vlogf so we don't
- // spam the logs unless verbose debugging is turned on.
- rootDev.Device.VisitServices(func(s *goupnp.Service) {
- c.vlogf("unsupported UPnP service: Type=%q ID=%q ControlURL=%q", s.ServiceType, s.ServiceId, s.ControlURL.Str)
- })
- return netip.AddrPort{}, nil, fmt.Errorf("no supported UPnP clients")
- }
- // Start by trying to make a temporary lease with a duration.
- var newPort uint16
- newPort, err = addAnyPortMapping(
- ctx,
- client,
- prevPort,
- internal.Port(),
- internal.Addr().String(),
- pmpMapLifetimeSec*time.Second,
- )
- c.vlogf("addAnyPortMapping: %v, err=%q", newPort, err)
- // If this is an error and the code is
- // "OnlyPermanentLeasesSupported", then we retry with no lease
- // duration; see the following issue for details:
- // https://github.com/tailscale/tailscale/issues/9343
- if err != nil {
- code, ok := getUPnPErrorCode(err)
- if ok {
- getUPnPErrorsMetric(code).Add(1)
- }
- // From the UPnP spec: http://upnp.org/specs/gw/UPnP-gw-WANIPConnection-v2-Service.pdf
- // 725: OnlyPermanentLeasesSupported
- if ok && code == 725 {
- newPort, err = addAnyPortMapping(
- ctx,
- client,
- prevPort,
- internal.Port(),
- internal.Addr().String(),
- 0, // permanent
- )
- c.vlogf("addAnyPortMapping: 725 retry %v, err=%q", newPort, err)
- }
- }
- if err != nil {
- return netip.AddrPort{}, nil, err
- }
- // TODO cache this ip somewhere?
- extIP, err := client.GetExternalIPAddress(ctx)
- c.vlogf("client.GetExternalIPAddress: %v, %v", extIP, err)
- if err != nil {
- return netip.AddrPort{}, nil, err
- }
- externalIP, err := netip.ParseAddr(extIP)
- if err != nil {
- return netip.AddrPort{}, nil, err
- }
- return netip.AddrPortFrom(externalIP, newPort), client, nil
- }
- // processUPnPResponses sorts and deduplicates a list of UPnP discovery
- // responses, returning the possibly-reduced list.
- //
- // It will perform a consistent sort of the provided responses, so if we have
- // multiple valid UPnP destinations a consistent option will be picked every
- // time.
- func processUPnPResponses(metas []uPnPDiscoResponse) []uPnPDiscoResponse {
- // Sort and compact all responses to remove duplicates; since
- // we send multiple probes, we often get duplicate responses.
- slices.SortFunc(metas, func(a, b uPnPDiscoResponse) int {
- // Sort the USN in reverse, so that
- // "InternetGatewayDevice:2" sorts before
- // "InternetGatewayDevice:1".
- if ii := cmp.Compare(a.USN, b.USN); ii != 0 {
- return -ii
- }
- if ii := cmp.Compare(a.Location, b.Location); ii != 0 {
- return ii
- }
- return cmp.Compare(a.Server, b.Server)
- })
- // We can get multiple responses that point to a single Location, since
- // we probe for both ssdp:all and InternetGatewayDevice:1 as
- // independent packets. Compact by comparing the Location and Server,
- // but not the USN (which contains the device being offered).
- //
- // Since the slices are sorted in reverse above, this means that if we
- // get a discovery response for both InternetGatewayDevice:1 and
- // InternetGatewayDevice:2, we'll keep the first
- // (InternetGatewayDevice:2) response, which is what we want.
- metas = slices.CompactFunc(metas, func(a, b uPnPDiscoResponse) bool {
- return a.Location == b.Location && a.Server == b.Server
- })
- return metas
- }
- // getUPnPErrorCode returns the UPnP error code from the given response, if the
- // error is a SOAP error in the proper format, and a boolean indicating whether
- // the provided error was actually a UPnP error.
- func getUPnPErrorCode(err error) (int, bool) {
- soapErr, ok := err.(*soap.SOAPFaultError)
- if !ok {
- return 0, false
- }
- var upnpErr struct {
- XMLName xml.Name
- Code int `xml:"errorCode"`
- Description string `xml:"errorDescription"`
- }
- if err := xml.Unmarshal([]byte(soapErr.Detail.Raw), &upnpErr); err != nil {
- return 0, false
- }
- if upnpErr.XMLName.Local != "UPnPError" {
- return 0, false
- }
- return upnpErr.Code, true
- }
- type uPnPDiscoResponse struct {
- Location string
- // Server describes what version the UPnP is, such as MiniUPnPd/2.x.x
- Server string
- // USN is the serial number of the device, which also contains
- // what kind of UPnP service is being offered, i.e. InternetGatewayDevice:2
- USN string
- }
- // parseUPnPDiscoResponse parses a UPnP HTTP-over-UDP discovery response.
- func parseUPnPDiscoResponse(body []byte) (uPnPDiscoResponse, error) {
- var r uPnPDiscoResponse
- res, err := http.ReadResponse(bufio.NewReaderSize(bytes.NewReader(body), 128), nil)
- if err != nil {
- return r, err
- }
- r.Location = res.Header.Get("Location")
- r.Server = res.Header.Get("Server")
- r.USN = res.Header.Get("Usn")
- return r, nil
- }
- type roundTripperFunc func(*http.Request) (*http.Response, error)
- func (r roundTripperFunc) RoundTrip(req *http.Request) (*http.Response, error) {
- return r(req)
- }
- func requestLogger(logf logger.Logf, client *http.Client) *http.Client {
- // Clone the HTTP client, and override the Transport to log to the
- // provided logger.
- ret := *client
- oldTransport := ret.Transport
- var requestCounter atomic.Uint64
- loggingTransport := roundTripperFunc(func(req *http.Request) (*http.Response, error) {
- ctr := requestCounter.Add(1)
- // Read the body and re-set it.
- var (
- body []byte
- err error
- )
- if req.Body != nil {
- body, err = io.ReadAll(req.Body)
- req.Body.Close()
- if err != nil {
- return nil, err
- }
- req.Body = io.NopCloser(bytes.NewReader(body))
- }
- logf("request[%d]: %s %q body=%q", ctr, req.Method, req.URL, body)
- resp, err := oldTransport.RoundTrip(req)
- if err != nil {
- logf("response[%d]: err=%v", ctr, err)
- return nil, err
- }
- // Read the response body
- if resp.Body != nil {
- body, err = io.ReadAll(resp.Body)
- resp.Body.Close()
- if err != nil {
- logf("response[%d]: %d bodyErr=%v", ctr, resp.StatusCode, err)
- return nil, err
- }
- resp.Body = io.NopCloser(bytes.NewReader(body))
- }
- logf("response[%d]: %d body=%q", ctr, resp.StatusCode, body)
- return resp, nil
- })
- ret.Transport = loggingTransport
- return &ret
- }
|