mirror of
https://github.com/tailscale/tailscale.git
synced 2025-04-22 08:51:41 +00:00
Enable UPnP portmapping
This actually adds portmapping in UPnP, which is checked after NAT-PMP. Signed-off-by: julianknodt <julianknodt@gmail.com>
This commit is contained in:
parent
c6b92ddda8
commit
cb2d9c13fe
@ -38,7 +38,7 @@ tailscale.com/cmd/tailscale dependencies: (generated by github.com/tailscale/dep
|
|||||||
💣 tailscale.com/net/tshttpproxy from tailscale.com/derp/derphttp+
|
💣 tailscale.com/net/tshttpproxy from tailscale.com/derp/derphttp+
|
||||||
tailscale.com/net/upnp from tailscale.com/net/upnp/dcps/internetgateway2
|
tailscale.com/net/upnp from tailscale.com/net/upnp/dcps/internetgateway2
|
||||||
tailscale.com/net/upnp/dcps/internetgateway2 from tailscale.com/net/portmapper
|
tailscale.com/net/upnp/dcps/internetgateway2 from tailscale.com/net/portmapper
|
||||||
tailscale.com/net/upnp/httpu from tailscale.com/net/upnp+
|
tailscale.com/net/upnp/httpu from tailscale.com/net/upnp
|
||||||
tailscale.com/net/upnp/scpd from tailscale.com/net/upnp
|
tailscale.com/net/upnp/scpd from tailscale.com/net/upnp
|
||||||
tailscale.com/net/upnp/soap from tailscale.com/net/upnp+
|
tailscale.com/net/upnp/soap from tailscale.com/net/upnp+
|
||||||
tailscale.com/net/upnp/ssdp from tailscale.com/net/upnp
|
tailscale.com/net/upnp/ssdp from tailscale.com/net/upnp
|
||||||
|
@ -114,7 +114,7 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de
|
|||||||
tailscale.com/net/tstun from tailscale.com/cmd/tailscaled+
|
tailscale.com/net/tstun from tailscale.com/cmd/tailscaled+
|
||||||
tailscale.com/net/upnp from tailscale.com/net/upnp/dcps/internetgateway2
|
tailscale.com/net/upnp from tailscale.com/net/upnp/dcps/internetgateway2
|
||||||
tailscale.com/net/upnp/dcps/internetgateway2 from tailscale.com/net/portmapper
|
tailscale.com/net/upnp/dcps/internetgateway2 from tailscale.com/net/portmapper
|
||||||
tailscale.com/net/upnp/httpu from tailscale.com/net/upnp+
|
tailscale.com/net/upnp/httpu from tailscale.com/net/upnp
|
||||||
tailscale.com/net/upnp/scpd from tailscale.com/net/upnp
|
tailscale.com/net/upnp/scpd from tailscale.com/net/upnp
|
||||||
tailscale.com/net/upnp/soap from tailscale.com/net/upnp+
|
tailscale.com/net/upnp/soap from tailscale.com/net/upnp+
|
||||||
tailscale.com/net/upnp/ssdp from tailscale.com/net/upnp
|
tailscale.com/net/upnp/ssdp from tailscale.com/net/upnp
|
||||||
|
@ -12,4 +12,4 @@ func networkIsUnreachable(err error) bool { return false }
|
|||||||
// packetWasTruncated returns true if err indicates truncation but the RecvFrom
|
// packetWasTruncated returns true if err indicates truncation but the RecvFrom
|
||||||
// that generated err was otherwise successful. It always returns false on this
|
// that generated err was otherwise successful. It always returns false on this
|
||||||
// platform.
|
// platform.
|
||||||
func packetWasTruncated(err error) bool { return false }
|
func packetWasTruncated(err error) bool { return false }
|
||||||
|
@ -693,7 +693,8 @@ func (rs *reportState) probePortMapServices() {
|
|||||||
rs.setOptBool(&rs.report.PMP, false)
|
rs.setOptBool(&rs.report.PMP, false)
|
||||||
rs.setOptBool(&rs.report.PCP, false)
|
rs.setOptBool(&rs.report.PCP, false)
|
||||||
|
|
||||||
res, err := rs.c.PortMapper.Probe(context.Background())
|
rs.c.PortMapper.NewProber(context.Background())
|
||||||
|
res, err := rs.c.PortMapper.Prober.StatusBlock()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
rs.c.logf("probePortMapServices: %v", err)
|
rs.c.logf("probePortMapServices: %v", err)
|
||||||
return
|
return
|
||||||
|
@ -56,18 +56,25 @@ type Client struct {
|
|||||||
pmpPubIPTime time.Time // time pmpPubIP last verified
|
pmpPubIPTime time.Time // time pmpPubIP last verified
|
||||||
pmpLastEpoch uint32
|
pmpLastEpoch uint32
|
||||||
|
|
||||||
pcpSawTime time.Time // time we last saw PCP was available
|
localPort uint16
|
||||||
uPnPSawTime time.Time // time we last saw UPnP was available
|
|
||||||
|
|
||||||
localPort uint16
|
mapping Mapping // non-nil if we have a mapping
|
||||||
pmpMapping *pmpMapping // non-nil if we have a PMP mapping
|
|
||||||
|
Prober *Prober
|
||||||
|
}
|
||||||
|
|
||||||
|
type Mapping interface {
|
||||||
|
isCurrent() bool
|
||||||
|
release()
|
||||||
|
validUntil() time.Time
|
||||||
|
externalIPPort() netaddr.IPPort
|
||||||
}
|
}
|
||||||
|
|
||||||
// HaveMapping reports whether we have a current valid mapping.
|
// HaveMapping reports whether we have a current valid mapping.
|
||||||
func (c *Client) HaveMapping() bool {
|
func (c *Client) HaveMapping() bool {
|
||||||
c.mu.Lock()
|
c.mu.Lock()
|
||||||
defer c.mu.Unlock()
|
defer c.mu.Unlock()
|
||||||
return c.pmpMapping != nil && c.pmpMapping.useUntil.After(time.Now())
|
return c.mapping != nil && c.mapping.isCurrent()
|
||||||
}
|
}
|
||||||
|
|
||||||
// pmpMapping is an already-created PMP mapping.
|
// pmpMapping is an already-created PMP mapping.
|
||||||
@ -86,6 +93,10 @@ func (m *pmpMapping) externalValid() bool {
|
|||||||
return !m.external.IP().IsZero() && m.external.Port() != 0
|
return !m.external.IP().IsZero() && m.external.Port() != 0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (p *pmpMapping) isCurrent() bool { return p.useUntil.After(time.Now()) }
|
||||||
|
func (p *pmpMapping) validUntil() time.Time { return p.useUntil }
|
||||||
|
func (p *pmpMapping) externalIPPort() netaddr.IPPort { return p.external }
|
||||||
|
|
||||||
// release does a best effort fire-and-forget release of the PMP mapping m.
|
// release does a best effort fire-and-forget release of the PMP mapping m.
|
||||||
func (m *pmpMapping) release() {
|
func (m *pmpMapping) release() {
|
||||||
uc, err := netns.Listener().ListenPacket(context.Background(), "udp4", ":0")
|
uc, err := netns.Listener().ListenPacket(context.Background(), "udp4", ":0")
|
||||||
@ -118,8 +129,8 @@ func (c *Client) SetGatewayLookupFunc(f func() (gw, myIP netaddr.IP, ok bool)) {
|
|||||||
// comes back.
|
// comes back.
|
||||||
func (c *Client) NoteNetworkDown() {
|
func (c *Client) NoteNetworkDown() {
|
||||||
c.mu.Lock()
|
c.mu.Lock()
|
||||||
defer c.mu.Unlock()
|
|
||||||
c.invalidateMappingsLocked(false)
|
c.invalidateMappingsLocked(false)
|
||||||
|
c.mu.Unlock()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *Client) Close() error {
|
func (c *Client) Close() error {
|
||||||
@ -153,7 +164,6 @@ func (c *Client) gatewayAndSelfIP() (gw, myIP netaddr.IP, ok bool) {
|
|||||||
gw = netaddr.IP{}
|
gw = netaddr.IP{}
|
||||||
myIP = netaddr.IP{}
|
myIP = netaddr.IP{}
|
||||||
}
|
}
|
||||||
|
|
||||||
c.mu.Lock()
|
c.mu.Lock()
|
||||||
defer c.mu.Unlock()
|
defer c.mu.Unlock()
|
||||||
|
|
||||||
@ -166,16 +176,14 @@ func (c *Client) gatewayAndSelfIP() (gw, myIP netaddr.IP, ok bool) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (c *Client) invalidateMappingsLocked(releaseOld bool) {
|
func (c *Client) invalidateMappingsLocked(releaseOld bool) {
|
||||||
if c.pmpMapping != nil {
|
if c.mapping != nil {
|
||||||
if releaseOld {
|
if releaseOld {
|
||||||
c.pmpMapping.release()
|
c.mapping.release()
|
||||||
}
|
}
|
||||||
c.pmpMapping = nil
|
c.mapping = nil
|
||||||
}
|
}
|
||||||
c.pmpPubIP = netaddr.IP{}
|
c.pmpPubIP = netaddr.IP{}
|
||||||
c.pmpPubIPTime = time.Time{}
|
c.pmpPubIPTime = time.Time{}
|
||||||
c.pcpSawTime = time.Time{}
|
|
||||||
c.uPnPSawTime = time.Time{}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *Client) sawPMPRecently() bool {
|
func (c *Client) sawPMPRecently() bool {
|
||||||
@ -189,15 +197,19 @@ func (c *Client) sawPMPRecentlyLocked() bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (c *Client) sawPCPRecently() bool {
|
func (c *Client) sawPCPRecently() bool {
|
||||||
c.mu.Lock()
|
if c.Prober == nil {
|
||||||
defer c.mu.Unlock()
|
return false
|
||||||
return c.pcpSawTime.After(time.Now().Add(-trustServiceStillAvailableDuration))
|
}
|
||||||
|
present, _ := c.Prober.PCP.PresentCurrent()
|
||||||
|
return present
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *Client) sawUPnPRecently() bool {
|
func (c *Client) sawUPnPRecently() bool {
|
||||||
c.mu.Lock()
|
if c.Prober == nil {
|
||||||
defer c.mu.Unlock()
|
return false
|
||||||
return c.uPnPSawTime.After(time.Now().Add(-trustServiceStillAvailableDuration))
|
}
|
||||||
|
present, _ := c.Prober.UPnP.PresentCurrent()
|
||||||
|
return present
|
||||||
}
|
}
|
||||||
|
|
||||||
// closeCloserOnContextDone starts a new goroutine to call c.Close
|
// closeCloserOnContextDone starts a new goroutine to call c.Close
|
||||||
@ -264,13 +276,13 @@ func (c *Client) CreateOrGetMapping(ctx context.Context) (external netaddr.IPPor
|
|||||||
|
|
||||||
// Do we have an existing mapping that's valid?
|
// Do we have an existing mapping that's valid?
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
if m := c.pmpMapping; m != nil {
|
if m := c.mapping; m != nil {
|
||||||
if now.Before(m.useUntil) {
|
if now.Before(m.validUntil()) {
|
||||||
defer c.mu.Unlock()
|
defer c.mu.Unlock()
|
||||||
return m.external, nil
|
return m.externalIPPort(), nil
|
||||||
}
|
}
|
||||||
// The mapping might still be valid, so just try to renew it.
|
// The mapping might still be valid, so just try to renew it.
|
||||||
prevPort = m.external.Port()
|
prevPort = m.externalIPPort().Port()
|
||||||
}
|
}
|
||||||
|
|
||||||
// If we just did a Probe (e.g. via netchecker) but didn't
|
// If we just did a Probe (e.g. via netchecker) but didn't
|
||||||
@ -280,11 +292,11 @@ func (c *Client) CreateOrGetMapping(ctx context.Context) (external netaddr.IPPor
|
|||||||
if haveRecentPMP {
|
if haveRecentPMP {
|
||||||
m.external = m.external.WithIP(c.pmpPubIP)
|
m.external = m.external.WithIP(c.pmpPubIP)
|
||||||
}
|
}
|
||||||
|
|
||||||
if c.lastProbe.After(now.Add(-5*time.Second)) && !haveRecentPMP {
|
if c.lastProbe.After(now.Add(-5*time.Second)) && !haveRecentPMP {
|
||||||
c.mu.Unlock()
|
c.mu.Unlock()
|
||||||
return netaddr.IPPort{}, NoMappingError{ErrNoPortMappingServices}
|
return netaddr.IPPort{}, NoMappingError{ErrNoPortMappingServices}
|
||||||
}
|
}
|
||||||
|
|
||||||
c.mu.Unlock()
|
c.mu.Unlock()
|
||||||
|
|
||||||
uc, err := netns.Listener().ListenPacket(ctx, "udp4", ":0")
|
uc, err := netns.Listener().ListenPacket(ctx, "udp4", ":0")
|
||||||
@ -319,7 +331,8 @@ func (c *Client) CreateOrGetMapping(ctx context.Context) (external netaddr.IPPor
|
|||||||
if ctx.Err() == context.Canceled {
|
if ctx.Err() == context.Canceled {
|
||||||
return netaddr.IPPort{}, err
|
return netaddr.IPPort{}, err
|
||||||
}
|
}
|
||||||
return netaddr.IPPort{}, NoMappingError{ErrNoPortMappingServices}
|
// switch to trying UPnP
|
||||||
|
break
|
||||||
}
|
}
|
||||||
srcu := srci.(*net.UDPAddr)
|
srcu := srci.(*net.UDPAddr)
|
||||||
src, ok := netaddr.FromStdAddr(srcu.IP, srcu.Port, srcu.Zone)
|
src, ok := netaddr.FromStdAddr(srcu.IP, srcu.Port, srcu.Zone)
|
||||||
@ -350,10 +363,58 @@ func (c *Client) CreateOrGetMapping(ctx context.Context) (external netaddr.IPPor
|
|||||||
if m.externalValid() {
|
if m.externalValid() {
|
||||||
c.mu.Lock()
|
c.mu.Lock()
|
||||||
defer c.mu.Unlock()
|
defer c.mu.Unlock()
|
||||||
c.pmpMapping = m
|
c.mapping = m
|
||||||
return m.external, nil
|
return m.external, nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// If did not see UPnP within the past 5 seconds then bail
|
||||||
|
haveRecentUPnP := c.sawUPnPRecently()
|
||||||
|
if c.lastProbe.After(now.Add(-5*time.Second)) && !haveRecentUPnP {
|
||||||
|
return netaddr.IPPort{}, NoMappingError{ErrNoPortMappingServices}
|
||||||
|
}
|
||||||
|
// Otherwise try a uPnP mapping if PMP did not work
|
||||||
|
mpnp := &upnpMapping{
|
||||||
|
gw: m.gw,
|
||||||
|
internal: m.internal,
|
||||||
|
}
|
||||||
|
|
||||||
|
var client upnpClient
|
||||||
|
c.mu.Lock()
|
||||||
|
oldMapping, ok := c.mapping.(*upnpMapping)
|
||||||
|
c.mu.Unlock()
|
||||||
|
if ok {
|
||||||
|
client = oldMapping.client
|
||||||
|
} else if c.Prober != nil && c.Prober.upnpClient != nil {
|
||||||
|
client = c.Prober.upnpClient
|
||||||
|
} else {
|
||||||
|
client, err = getUPnPClient(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return netaddr.IPPort{}, NoMappingError{ErrNoPortMappingServices}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if client == nil {
|
||||||
|
return netaddr.IPPort{}, NoMappingError{ErrNoPortMappingServices}
|
||||||
|
}
|
||||||
|
|
||||||
|
var newPort uint16
|
||||||
|
newPort, err = AddAnyPortMapping(
|
||||||
|
ctx, client,
|
||||||
|
"", prevPort, "UDP", localPort, m.internal.IP().String(), true,
|
||||||
|
"tailscale-portfwd", pmpMapLifetimeSec,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return netaddr.IPPort{}, NoMappingError{ErrNoPortMappingServices}
|
||||||
|
}
|
||||||
|
mpnp.external = netaddr.IPPortFrom(gw, newPort)
|
||||||
|
d := time.Duration(pmpMapLifetimeSec) * time.Second / 2
|
||||||
|
mpnp.useUntil = time.Now().Add(d)
|
||||||
|
mpnp.client = client
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
c.mapping = mpnp
|
||||||
|
c.localPort = newPort
|
||||||
|
return mpnp.external, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
type pmpResultCode uint16
|
type pmpResultCode uint16
|
||||||
@ -376,12 +437,6 @@ const (
|
|||||||
pmpCodeUnsupportedOpcode pmpResultCode = 5
|
pmpCodeUnsupportedOpcode pmpResultCode = 5
|
||||||
)
|
)
|
||||||
|
|
||||||
type ProbeResult struct {
|
|
||||||
PCP bool
|
|
||||||
PMP bool
|
|
||||||
UPnP bool
|
|
||||||
}
|
|
||||||
|
|
||||||
func buildPMPRequestMappingPacket(localPort, prevPort uint16, lifetimeSec uint32) (pkt []byte) {
|
func buildPMPRequestMappingPacket(localPort, prevPort uint16, lifetimeSec uint32) (pkt []byte) {
|
||||||
pkt = make([]byte, 12)
|
pkt = make([]byte, 12)
|
||||||
|
|
||||||
@ -438,6 +493,12 @@ func parsePMPResponse(pkt []byte) (res pmpResponse, ok bool) {
|
|||||||
return res, true
|
return res, true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type ProbeResult struct {
|
||||||
|
PCP bool
|
||||||
|
PMP bool
|
||||||
|
UPnP bool
|
||||||
|
}
|
||||||
|
|
||||||
const (
|
const (
|
||||||
pcpVersion = 2
|
pcpVersion = 2
|
||||||
pcpPort = 5351
|
pcpPort = 5351
|
||||||
|
@ -12,131 +12,6 @@ import (
|
|||||||
"tailscale.com/net/netns"
|
"tailscale.com/net/netns"
|
||||||
)
|
)
|
||||||
|
|
||||||
/*
|
|
||||||
type ProbeResult struct {
|
|
||||||
PCP bool
|
|
||||||
PMP bool
|
|
||||||
UPnP bool
|
|
||||||
}
|
|
||||||
*/
|
|
||||||
|
|
||||||
// Probe returns a summary of which port mapping services are
|
|
||||||
// available on the network.
|
|
||||||
//
|
|
||||||
// If a probe has run recently and there haven't been any network changes since,
|
|
||||||
// the returned result might be served from the Client's cache, without
|
|
||||||
// sending any network traffic.
|
|
||||||
func (c *Client) Probe(ctx context.Context) (res ProbeResult, err error) {
|
|
||||||
gw, myIP, ok := c.gatewayAndSelfIP()
|
|
||||||
if !ok {
|
|
||||||
return res, ErrGatewayNotFound
|
|
||||||
}
|
|
||||||
defer func() {
|
|
||||||
if err == nil {
|
|
||||||
c.mu.Lock()
|
|
||||||
c.lastProbe = time.Now()
|
|
||||||
c.mu.Unlock()
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
|
|
||||||
uc, err := netns.Listener().ListenPacket(ctx, "udp4", ":0")
|
|
||||||
if err != nil {
|
|
||||||
c.logf("ProbePCP: %v", err)
|
|
||||||
return res, err
|
|
||||||
}
|
|
||||||
defer uc.Close()
|
|
||||||
ctx, cancel := context.WithTimeout(ctx, 250*time.Millisecond)
|
|
||||||
defer cancel()
|
|
||||||
defer closeCloserOnContextDone(ctx, uc)()
|
|
||||||
|
|
||||||
pcpAddr := netaddr.IPPortFrom(gw, pcpPort).UDPAddr()
|
|
||||||
pmpAddr := netaddr.IPPortFrom(gw, pmpPort).UDPAddr()
|
|
||||||
|
|
||||||
// Don't send probes to services that we recently learned (for
|
|
||||||
// the same gw/myIP) are available. See
|
|
||||||
// https://github.com/tailscale/tailscale/issues/1001
|
|
||||||
wg := sync.WaitGroup{}
|
|
||||||
defer wg.Wait()
|
|
||||||
if c.sawUPnPRecently() {
|
|
||||||
res.UPnP = true
|
|
||||||
} else {
|
|
||||||
wg.Add(1)
|
|
||||||
go func() {
|
|
||||||
// TODO(jknodt) this is expensive, maybe it's worth caching it and just reusing it
|
|
||||||
// more aggressively
|
|
||||||
hasUPnP, _ := probeUPnP(ctx)
|
|
||||||
if hasUPnP {
|
|
||||||
res.UPnP = true
|
|
||||||
c.mu.Lock()
|
|
||||||
c.uPnPSawTime = time.Now()
|
|
||||||
c.mu.Unlock()
|
|
||||||
}
|
|
||||||
wg.Done()
|
|
||||||
}()
|
|
||||||
}
|
|
||||||
if c.sawPMPRecently() {
|
|
||||||
res.PMP = true
|
|
||||||
} else {
|
|
||||||
uc.WriteTo(pmpReqExternalAddrPacket, pmpAddr)
|
|
||||||
}
|
|
||||||
if c.sawPCPRecently() {
|
|
||||||
res.PCP = true
|
|
||||||
} else {
|
|
||||||
uc.WriteTo(pcpAnnounceRequest(myIP), pcpAddr)
|
|
||||||
}
|
|
||||||
|
|
||||||
buf := make([]byte, 1500)
|
|
||||||
pcpHeard := false // true when we get any PCP response
|
|
||||||
for {
|
|
||||||
if pcpHeard && res.PMP {
|
|
||||||
// Nothing more to discover.
|
|
||||||
return res, nil
|
|
||||||
}
|
|
||||||
n, _, err := uc.ReadFrom(buf)
|
|
||||||
if err != nil {
|
|
||||||
if ctx.Err() == context.DeadlineExceeded {
|
|
||||||
err = nil
|
|
||||||
}
|
|
||||||
return res, err
|
|
||||||
}
|
|
||||||
if pres, ok := parsePCPResponse(buf[:n]); ok {
|
|
||||||
if pres.OpCode == pcpOpReply|pcpOpAnnounce {
|
|
||||||
pcpHeard = true
|
|
||||||
c.mu.Lock()
|
|
||||||
c.pcpSawTime = time.Now()
|
|
||||||
c.mu.Unlock()
|
|
||||||
switch pres.ResultCode {
|
|
||||||
case pcpCodeOK:
|
|
||||||
c.logf("Got PCP response: epoch: %v", pres.Epoch)
|
|
||||||
res.PCP = true
|
|
||||||
continue
|
|
||||||
case pcpCodeNotAuthorized:
|
|
||||||
// A PCP service is running, but refuses to
|
|
||||||
// provide port mapping services.
|
|
||||||
res.PCP = false
|
|
||||||
continue
|
|
||||||
default:
|
|
||||||
// Fall through to unexpected log line.
|
|
||||||
}
|
|
||||||
}
|
|
||||||
c.logf("unexpected PCP probe response: %+v", pres)
|
|
||||||
}
|
|
||||||
if pres, ok := parsePMPResponse(buf[:n]); ok {
|
|
||||||
if pres.OpCode == pmpOpReply|pmpOpMapPublicAddr && pres.ResultCode == pmpCodeOK {
|
|
||||||
c.logf("Got PMP response; IP: %v, epoch: %v", pres.PublicAddr, pres.SecondsSinceEpoch)
|
|
||||||
res.PMP = true
|
|
||||||
c.mu.Lock()
|
|
||||||
c.pmpPubIP = pres.PublicAddr
|
|
||||||
c.pmpPubIPTime = time.Now()
|
|
||||||
c.pmpLastEpoch = pres.SecondsSinceEpoch
|
|
||||||
c.mu.Unlock()
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
c.logf("unexpected PMP probe response: %+v", pres)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
type Prober struct {
|
type Prober struct {
|
||||||
// pause signals the probe to either pause temporarily (true), or stop entirely (false)
|
// pause signals the probe to either pause temporarily (true), or stop entirely (false)
|
||||||
// to restart the probe, send another pause to it.
|
// to restart the probe, send another pause to it.
|
||||||
@ -151,18 +26,22 @@ type Prober struct {
|
|||||||
|
|
||||||
// NewProber creates a new prober for a given client.
|
// NewProber creates a new prober for a given client.
|
||||||
func (c *Client) NewProber(ctx context.Context) (p *Prober) {
|
func (c *Client) NewProber(ctx context.Context) (p *Prober) {
|
||||||
stop := make(chan bool)
|
if c.Prober != nil {
|
||||||
|
return c.Prober
|
||||||
|
}
|
||||||
|
pause := make(chan bool)
|
||||||
p = &Prober{
|
p = &Prober{
|
||||||
stop: stop,
|
pause: pause,
|
||||||
|
|
||||||
PMP: NewProbeSubResult(),
|
PMP: NewProbeSubResult(),
|
||||||
PCP: NewProbeSubResult(),
|
PCP: NewProbeSubResult(),
|
||||||
UPnP: NewProbeSubResult(),
|
UPnP: NewProbeSubResult(),
|
||||||
}
|
}
|
||||||
|
c.Prober = p
|
||||||
|
|
||||||
go func() {
|
go func() {
|
||||||
for {
|
for {
|
||||||
pmp_ctx, cancel := context.WithTimeout(ctx, 250*time.Millisecond)
|
pmp_ctx, cancel := context.WithTimeout(ctx, portMapServiceTimeout)
|
||||||
hasPCP, hasPMP, err := c.probePMPAndPCP(pmp_ctx)
|
hasPCP, hasPMP, err := c.probePMPAndPCP(pmp_ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if ctx.Err() == context.DeadlineExceeded {
|
if ctx.Err() == context.DeadlineExceeded {
|
||||||
@ -209,23 +88,23 @@ func (c *Client) NewProber(ctx context.Context) (p *Prober) {
|
|||||||
defer func() {
|
defer func() {
|
||||||
// unset client when no longer using it.
|
// unset client when no longer using it.
|
||||||
p.upnpClient = nil
|
p.upnpClient = nil
|
||||||
upnpClient.RequestTermination()
|
upnpClient.RequestTermination(context.Background())
|
||||||
}()
|
}()
|
||||||
// TODO maybe do something fancy/dynamic with more delay (exponential back-off)
|
// TODO maybe do something fancy/dynamic with more delay (exponential back-off)
|
||||||
for {
|
for {
|
||||||
upnp_ctx, cancel := context.WithTimeout(ctx, 6*time.Second)
|
upnp_ctx, cancel := context.WithTimeout(ctx, portMapServiceTimeout*5)
|
||||||
retries := 0
|
retries := 0
|
||||||
hasUPnP := false
|
hasUPnP := false
|
||||||
const num_connect_retries = 5
|
const num_connect_retries = 5
|
||||||
for retries < num_connect_retries {
|
for retries < num_connect_retries {
|
||||||
status, _, _, statusErr := p.upnpClient.GetStatusInfo()
|
status, _, _, statusErr := p.upnpClient.GetStatusInfo(upnp_ctx)
|
||||||
if statusErr != nil {
|
if statusErr != nil {
|
||||||
err = statusErr
|
err = statusErr
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
hasUPnP = hasUPnP || status == "Connected"
|
hasUPnP = hasUPnP || status == "Connected"
|
||||||
if status == "Disconnected" {
|
if status == "Disconnected" {
|
||||||
upnpClient.RequestConnection()
|
upnpClient.RequestConnection(upnp_ctx)
|
||||||
}
|
}
|
||||||
retries += 1
|
retries += 1
|
||||||
}
|
}
|
||||||
@ -413,9 +292,9 @@ func (c *Client) probePMPAndPCP(ctx context.Context) (pcp bool, pmp bool, err er
|
|||||||
if pres, ok := parsePCPResponse(buf[:n]); ok {
|
if pres, ok := parsePCPResponse(buf[:n]); ok {
|
||||||
if pres.OpCode == pcpOpReply|pcpOpAnnounce {
|
if pres.OpCode == pcpOpReply|pcpOpAnnounce {
|
||||||
pcpHeard = true
|
pcpHeard = true
|
||||||
c.mu.Lock()
|
//c.mu.Lock()
|
||||||
c.pcpSawTime = time.Now()
|
//c.pcpSawTime = time.Now()
|
||||||
c.mu.Unlock()
|
//c.mu.Unlock()
|
||||||
switch pres.ResultCode {
|
switch pres.ResultCode {
|
||||||
case pcpCodeOK:
|
case pcpCodeOK:
|
||||||
c.logf("Got PCP response: epoch: %v", pres.Epoch)
|
c.logf("Got PCP response: epoch: %v", pres.Epoch)
|
||||||
|
@ -9,7 +9,7 @@ import (
|
|||||||
|
|
||||||
"golang.org/x/sync/errgroup"
|
"golang.org/x/sync/errgroup"
|
||||||
"inet.af/netaddr"
|
"inet.af/netaddr"
|
||||||
"tailscale.com/net/upnp/dcps/internetgateway2"
|
"tailscale.com/tempfork/upnp/dcps/internetgateway2"
|
||||||
)
|
)
|
||||||
|
|
||||||
type upnpMapping struct {
|
type upnpMapping struct {
|
||||||
|
File diff suppressed because it is too large
Load Diff
@ -1,312 +0,0 @@
|
|||||||
package ssdp
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"log"
|
|
||||||
"net/http"
|
|
||||||
"net/url"
|
|
||||||
"regexp"
|
|
||||||
"strconv"
|
|
||||||
"sync"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"tailscale.com/net/upnp/httpu"
|
|
||||||
)
|
|
||||||
|
|
||||||
const (
|
|
||||||
maxExpiryTimeSeconds = 24 * 60 * 60
|
|
||||||
)
|
|
||||||
|
|
||||||
var (
|
|
||||||
maxAgeRx = regexp.MustCompile("max-age= *([0-9]+)")
|
|
||||||
)
|
|
||||||
|
|
||||||
const (
|
|
||||||
EventAlive = EventType(iota)
|
|
||||||
EventUpdate
|
|
||||||
EventByeBye
|
|
||||||
)
|
|
||||||
|
|
||||||
type EventType int8
|
|
||||||
|
|
||||||
func (et EventType) String() string {
|
|
||||||
switch et {
|
|
||||||
case EventAlive:
|
|
||||||
return "EventAlive"
|
|
||||||
case EventUpdate:
|
|
||||||
return "EventUpdate"
|
|
||||||
case EventByeBye:
|
|
||||||
return "EventByeBye"
|
|
||||||
default:
|
|
||||||
return fmt.Sprintf("EventUnknown(%d)", int8(et))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
type Update struct {
|
|
||||||
// The USN of the service.
|
|
||||||
USN string
|
|
||||||
// What happened.
|
|
||||||
EventType EventType
|
|
||||||
// The entry, which is nil if the service was not known and
|
|
||||||
// EventType==EventByeBye. The contents of this must not be modified as it is
|
|
||||||
// shared with the registry and other listeners. Once created, the Registry
|
|
||||||
// does not modify the Entry value - any updates are replaced with a new
|
|
||||||
// Entry value.
|
|
||||||
Entry *Entry
|
|
||||||
}
|
|
||||||
|
|
||||||
type Entry struct {
|
|
||||||
// The address that the entry data was actually received from.
|
|
||||||
RemoteAddr string
|
|
||||||
// Unique Service Name. Identifies a unique instance of a device or service.
|
|
||||||
USN string
|
|
||||||
// Notfication Type. The type of device or service being announced.
|
|
||||||
NT string
|
|
||||||
// Server's self-identifying string.
|
|
||||||
Server string
|
|
||||||
Host string
|
|
||||||
// Location of the UPnP root device description.
|
|
||||||
Location url.URL
|
|
||||||
|
|
||||||
// Despite BOOTID,CONFIGID being required fields, apparently they are not
|
|
||||||
// always set by devices. Set to -1 if not present.
|
|
||||||
|
|
||||||
BootID int32
|
|
||||||
ConfigID int32
|
|
||||||
|
|
||||||
SearchPort uint16
|
|
||||||
|
|
||||||
// When the last update was received for this entry identified by this USN.
|
|
||||||
LastUpdate time.Time
|
|
||||||
// When the last update's cached values are advised to expire.
|
|
||||||
CacheExpiry time.Time
|
|
||||||
}
|
|
||||||
|
|
||||||
func newEntryFromRequest(r *http.Request) (*Entry, error) {
|
|
||||||
now := time.Now()
|
|
||||||
expiryDuration, err := parseCacheControlMaxAge(r.Header.Get("CACHE-CONTROL"))
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("ssdp: error parsing CACHE-CONTROL max age: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
loc, err := url.Parse(r.Header.Get("LOCATION"))
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("ssdp: error parsing entry Location URL: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
bootID, err := parseUpnpIntHeader(r.Header, "BOOTID.UPNP.ORG", -1)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
configID, err := parseUpnpIntHeader(r.Header, "CONFIGID.UPNP.ORG", -1)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
searchPort, err := parseUpnpIntHeader(r.Header, "SEARCHPORT.UPNP.ORG", ssdpSearchPort)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
if searchPort < 1 || searchPort > 65535 {
|
|
||||||
return nil, fmt.Errorf("ssdp: search port %d is out of range", searchPort)
|
|
||||||
}
|
|
||||||
|
|
||||||
return &Entry{
|
|
||||||
RemoteAddr: r.RemoteAddr,
|
|
||||||
USN: r.Header.Get("USN"),
|
|
||||||
NT: r.Header.Get("NT"),
|
|
||||||
Server: r.Header.Get("SERVER"),
|
|
||||||
Host: r.Header.Get("HOST"),
|
|
||||||
Location: *loc,
|
|
||||||
BootID: bootID,
|
|
||||||
ConfigID: configID,
|
|
||||||
SearchPort: uint16(searchPort),
|
|
||||||
LastUpdate: now,
|
|
||||||
CacheExpiry: now.Add(expiryDuration),
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func parseCacheControlMaxAge(cc string) (time.Duration, error) {
|
|
||||||
matches := maxAgeRx.FindStringSubmatch(cc)
|
|
||||||
if len(matches) != 2 {
|
|
||||||
return 0, fmt.Errorf("did not find exactly one max-age in cache control header: %q", cc)
|
|
||||||
}
|
|
||||||
expirySeconds, err := strconv.ParseInt(matches[1], 10, 16)
|
|
||||||
if err != nil {
|
|
||||||
return 0, err
|
|
||||||
}
|
|
||||||
if expirySeconds < 1 || expirySeconds > maxExpiryTimeSeconds {
|
|
||||||
return 0, fmt.Errorf("rejecting bad expiry time of %d seconds", expirySeconds)
|
|
||||||
}
|
|
||||||
return time.Duration(expirySeconds) * time.Second, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// parseUpnpIntHeader is intended to parse the
|
|
||||||
// {BOOT,CONFIGID,SEARCHPORT}.UPNP.ORG header fields. It returns the def if
|
|
||||||
// the head is empty or missing.
|
|
||||||
func parseUpnpIntHeader(headers http.Header, headerName string, def int32) (int32, error) {
|
|
||||||
s := headers.Get(headerName)
|
|
||||||
if s == "" {
|
|
||||||
return def, nil
|
|
||||||
}
|
|
||||||
v, err := strconv.ParseInt(s, 10, 32)
|
|
||||||
if err != nil {
|
|
||||||
return 0, fmt.Errorf("ssdp: could not parse header %s: %v", headerName, err)
|
|
||||||
}
|
|
||||||
return int32(v), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
var _ httpu.Handler = new(Registry)
|
|
||||||
|
|
||||||
// Registry maintains knowledge of discovered devices and services.
|
|
||||||
//
|
|
||||||
// NOTE: the interface for this is experimental and may change, or go away
|
|
||||||
// entirely.
|
|
||||||
type Registry struct {
|
|
||||||
lock sync.Mutex
|
|
||||||
byUSN map[string]*Entry
|
|
||||||
|
|
||||||
listenersLock sync.RWMutex
|
|
||||||
listeners map[chan<- Update]struct{}
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewRegistry() *Registry {
|
|
||||||
return &Registry{
|
|
||||||
byUSN: make(map[string]*Entry),
|
|
||||||
listeners: make(map[chan<- Update]struct{}),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewServerAndRegistry is a convenience function to create a registry, and an
|
|
||||||
// httpu server to pass it messages. Call ListenAndServe on the server for
|
|
||||||
// messages to be processed.
|
|
||||||
func NewServerAndRegistry() (*httpu.Server, *Registry) {
|
|
||||||
reg := NewRegistry()
|
|
||||||
srv := &httpu.Server{
|
|
||||||
Addr: ssdpUDP4Addr,
|
|
||||||
Multicast: true,
|
|
||||||
Handler: reg,
|
|
||||||
}
|
|
||||||
return srv, reg
|
|
||||||
}
|
|
||||||
|
|
||||||
func (reg *Registry) AddListener(c chan<- Update) {
|
|
||||||
reg.listenersLock.Lock()
|
|
||||||
defer reg.listenersLock.Unlock()
|
|
||||||
reg.listeners[c] = struct{}{}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (reg *Registry) RemoveListener(c chan<- Update) {
|
|
||||||
reg.listenersLock.Lock()
|
|
||||||
defer reg.listenersLock.Unlock()
|
|
||||||
delete(reg.listeners, c)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (reg *Registry) sendUpdate(u Update) {
|
|
||||||
reg.listenersLock.RLock()
|
|
||||||
defer reg.listenersLock.RUnlock()
|
|
||||||
for c := range reg.listeners {
|
|
||||||
c <- u
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetService returns known service (or device) entries for the given service
|
|
||||||
// URN.
|
|
||||||
func (reg *Registry) GetService(serviceURN string) []*Entry {
|
|
||||||
// Currently assumes that the map is small, so we do a linear search rather
|
|
||||||
// than indexed to avoid maintaining two maps.
|
|
||||||
var results []*Entry
|
|
||||||
reg.lock.Lock()
|
|
||||||
defer reg.lock.Unlock()
|
|
||||||
for _, entry := range reg.byUSN {
|
|
||||||
if entry.NT == serviceURN {
|
|
||||||
results = append(results, entry)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return results
|
|
||||||
}
|
|
||||||
|
|
||||||
// ServeMessage implements httpu.Handler, and uses SSDP NOTIFY requests to
|
|
||||||
// maintain the registry of devices and services.
|
|
||||||
func (reg *Registry) ServeMessage(r *http.Request) {
|
|
||||||
if r.Method != methodNotify {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
nts := r.Header.Get("nts")
|
|
||||||
|
|
||||||
var err error
|
|
||||||
switch nts {
|
|
||||||
case ntsAlive:
|
|
||||||
err = reg.handleNTSAlive(r)
|
|
||||||
case ntsUpdate:
|
|
||||||
err = reg.handleNTSUpdate(r)
|
|
||||||
case ntsByebye:
|
|
||||||
err = reg.handleNTSByebye(r)
|
|
||||||
default:
|
|
||||||
err = fmt.Errorf("unknown NTS value: %q", nts)
|
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("goupnp/ssdp: failed to handle %s message from %s: %v", nts, r.RemoteAddr, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (reg *Registry) handleNTSAlive(r *http.Request) error {
|
|
||||||
entry, err := newEntryFromRequest(r)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
reg.lock.Lock()
|
|
||||||
reg.byUSN[entry.USN] = entry
|
|
||||||
reg.lock.Unlock()
|
|
||||||
|
|
||||||
reg.sendUpdate(Update{
|
|
||||||
USN: entry.USN,
|
|
||||||
EventType: EventAlive,
|
|
||||||
Entry: entry,
|
|
||||||
})
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (reg *Registry) handleNTSUpdate(r *http.Request) error {
|
|
||||||
entry, err := newEntryFromRequest(r)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
nextBootID, err := parseUpnpIntHeader(r.Header, "NEXTBOOTID.UPNP.ORG", -1)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
entry.BootID = nextBootID
|
|
||||||
|
|
||||||
reg.lock.Lock()
|
|
||||||
reg.byUSN[entry.USN] = entry
|
|
||||||
reg.lock.Unlock()
|
|
||||||
|
|
||||||
reg.sendUpdate(Update{
|
|
||||||
USN: entry.USN,
|
|
||||||
EventType: EventUpdate,
|
|
||||||
Entry: entry,
|
|
||||||
})
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (reg *Registry) handleNTSByebye(r *http.Request) error {
|
|
||||||
usn := r.Header.Get("USN")
|
|
||||||
|
|
||||||
reg.lock.Lock()
|
|
||||||
entry := reg.byUSN[usn]
|
|
||||||
delete(reg.byUSN, usn)
|
|
||||||
reg.lock.Unlock()
|
|
||||||
|
|
||||||
reg.sendUpdate(Update{
|
|
||||||
USN: usn,
|
|
||||||
EventType: EventByeBye,
|
|
||||||
Entry: entry,
|
|
||||||
})
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
@ -21,8 +21,8 @@ import (
|
|||||||
"net/url"
|
"net/url"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"tailscale.com/net/upnp"
|
"tailscale.com/tempfork/upnp"
|
||||||
"tailscale.com/net/upnp/soap"
|
"tailscale.com/tempfork/upnp/soap"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Hack to avoid Go complaining if time isn't used.
|
// Hack to avoid Go complaining if time isn't used.
|
@ -8,8 +8,8 @@ import (
|
|||||||
"os/exec"
|
"os/exec"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"tailscale.com/net/upnp"
|
"tailscale.com/tempfork/upnp"
|
||||||
"tailscale.com/net/upnp/scpd"
|
"tailscale.com/tempfork/upnp/scpd"
|
||||||
)
|
)
|
||||||
|
|
||||||
// DCP collects together information about a UPnP Device Control Protocol.
|
// DCP collects together information about a UPnP Device Control Protocol.
|
@ -11,13 +11,6 @@ type DCPMetadata struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var dcpMetadata = []DCPMetadata{
|
var dcpMetadata = []DCPMetadata{
|
||||||
{
|
|
||||||
Name: "internetgateway1",
|
|
||||||
OfficialName: "Internet Gateway Device v1",
|
|
||||||
DocURL: "http://upnp.org/specs/gw/UPnP-gw-InternetGatewayDevice-v1-Device.pdf",
|
|
||||||
XMLSpecURL: "http://upnp.org/specs/gw/UPnP-gw-IGD-TestFiles-20010921.zip",
|
|
||||||
Hacks: []DCPHackFn{totalBytesHack},
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
Name: "internetgateway2",
|
Name: "internetgateway2",
|
||||||
OfficialName: "Internet Gateway Device v2",
|
OfficialName: "Internet Gateway Device v2",
|
||||||
@ -36,14 +29,35 @@ var dcpMetadata = []DCPMetadata{
|
|||||||
dcp.ServiceTypes[missingURN] = urnParts
|
dcp.ServiceTypes[missingURN] = urnParts
|
||||||
return nil
|
return nil
|
||||||
}, totalBytesHack,
|
}, totalBytesHack,
|
||||||
|
func(dcp *DCP) error {
|
||||||
|
// omit certain device types that we do not need
|
||||||
|
var allowedServices = map[string]bool{
|
||||||
|
"urn:schemas-upnp-org:service:WANIPConnection:1": true,
|
||||||
|
"urn:schemas-upnp-org:service:WANIPConnection:2": true,
|
||||||
|
"urn:schemas-upnp-org:service:WANPPPConnection:1": true,
|
||||||
|
}
|
||||||
|
var allowedParts = map[string]bool{
|
||||||
|
"WANIPConnection": true,
|
||||||
|
"WANPPPConnection": true,
|
||||||
|
}
|
||||||
|
for service := range dcp.ServiceTypes {
|
||||||
|
if _, ok := allowedServices[service]; ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
delete(dcp.ServiceTypes, service)
|
||||||
|
}
|
||||||
|
var permitted []SCPDWithURN
|
||||||
|
for _, v := range dcp.Services {
|
||||||
|
if _, ok := allowedParts[v.URNParts.Name]; ok {
|
||||||
|
permitted = append(permitted, v)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
dcp.Services = permitted
|
||||||
|
return nil
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
|
||||||
Name: "av1",
|
|
||||||
OfficialName: "MediaServer v1 and MediaRenderer v1",
|
|
||||||
DocURL: "http://upnp.org/specs/av/av1/",
|
|
||||||
XMLSpecURL: "http://upnp.org/specs/av/UPnP-av-TestFiles-20070927.zip",
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func totalBytesHack(dcp *DCP) error {
|
func totalBytesHack(dcp *DCP) error {
|
2351
tempfork/upnp/dcps/internetgateway2/internetgateway2.go
Normal file
2351
tempfork/upnp/dcps/internetgateway2/internetgateway2.go
Normal file
File diff suppressed because it is too large
Load Diff
@ -8,8 +8,8 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"net/url"
|
"net/url"
|
||||||
|
|
||||||
"tailscale.com/net/upnp/scpd"
|
"tailscale.com/tempfork/upnp/scpd"
|
||||||
"tailscale.com/net/upnp/soap"
|
"tailscale.com/tempfork/upnp/soap"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
@ -21,7 +21,7 @@ import (
|
|||||||
"net/url"
|
"net/url"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"tailscale.com/net/upnp/ssdp"
|
"tailscale.com/tempfork/upnp/ssdp"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ContextError is an error that wraps an error with some context information.
|
// ContextError is an error that wraps an error with some context information.
|
@ -4,7 +4,7 @@ import (
|
|||||||
"io"
|
"io"
|
||||||
"net"
|
"net"
|
||||||
|
|
||||||
"tailscale.com/net/upnp/httpu"
|
"tailscale.com/tempfork/upnp/httpu"
|
||||||
)
|
)
|
||||||
|
|
||||||
// httpuClient creates a HTTPU client that multiplexes to all multicast-capable
|
// httpuClient creates a HTTPU client that multiplexes to all multicast-capable
|
@ -4,7 +4,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"net/url"
|
"net/url"
|
||||||
|
|
||||||
"tailscale.com/net/upnp/soap"
|
"tailscale.com/tempfork/upnp/soap"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ServiceClient is a SOAP client, root device and the service for the SOAP
|
// ServiceClient is a SOAP client, root device and the service for the SOAP
|
Loading…
x
Reference in New Issue
Block a user