Files
syncthing/lib/upnp/igd_service.go
T
Catfriend1andMarcus B Spencer 9d425b0588 fix(beacon, osutil, upnp): fix local discovery send and intf detection on Android (#10196)
Before:
- Local discovery on Android 10+ is broken. The phone receives local
discovery packets from other devices running Syncthing on the same
network, e.g. a computer. But it doesn't send its own local discovery
packets.
- Startup of the beacon/broadcast.go and beacon/multicast.go "services"
subsequently fail, see the log entries of "service.go" with "2 of 2
failures, backing off".

Root cause:
- Android 10+ restricts determining the network interfaces for privacy
reasons. The interfaces and IP addresses cannot be determined.
- There's a bug in the go "net" library. I can actually get the
interfaces, but the fix was not implemented by the go team.

Workaround:
- The "community" found a workaround by creating a light wrapper around
"net" called "anet" library.
- "anet" adjusts the behaviour on Android 10+ and gets the interfaces
plus their IP addresses, as required by Syncthing.

After:
- By using the "anet" lib, Syncthing is able to get the interface ip
addresses and put them into the "AllAddresses" string array.
- The "AllAddresses" string array is then announced on the local
discovery multicast and broadcast packets, if enabled in Syncthing's
config.
- By correctly getting the interfaces and IP addresses using "anet" in
"beacon/broadcast.go" and "beacon/multicast.go", the services start up
fine again.

Verification: 
- I've built "libSyncthingNative.so" with this PR applied for Android
and put it into Syncthing-Fork v1.29.7.5 for testing. My two phones,
Android 10 and Android 15 (arm64-v8a) immediately discovered each other
using local discovery.
- I can see the "sent XX bytes" and "recv XX bytes" on both phones in
the log filtering for "SyncthingNativeCode" :-).

Personal note:
- Please go light on me, and, if it's not demanded too much of your
time, please help me on this. I am no go programmer. Most things you
think are easy or common sense aren't part of my knowledge set. I'd just
like to help and hope we somehow can drive this home together to fix the
problem.

----

ref: https://github.com/Catfriend1/syncthing-android/pull/1501
ref: https://github.com/Catfriend1/syncthing-android/issues/1500
ref: https://github.com/wlynxg/anet/blob/main/interface.go &
https://github.com/wlynxg/anet/blob/main/interface_android.go

With that fix, I can see the broadcast/multicast lines again and my
phone can be discovered by other phones running the Syncthing app which
wasn't possible before on Android 10+.

```
[ET76H] .346892 broadcast.go:107: DEBUG: sent 185 bytes to 192.168.x.255:21027
[ET76H] .347114 multicast.go:86: DEBUG: sent 185 bytes to [ff12::8384]:21027 on wlan0
```

---------

Co-authored-by: Marcus B Spencer <marcus@marcusspencer.us>
2025-06-25 18:18:12 +00:00

276 lines
9.6 KiB
Go

// Copyright (C) 2016 The Syncthing Authors.
//
// Adapted from https://github.com/jackpal/Taipei-Torrent/blob/dd88a8bfac6431c01d959ce3c745e74b8a911793/IGD.go
// Copyright (c) 2010 Jack Palevich (https://github.com/jackpal/Taipei-Torrent/blob/dd88a8bfac6431c01d959ce3c745e74b8a911793/LICENSE)
//
// Redistribution and use in source and binary forms, with or without
// modification, are permitted provided that the following conditions are
// met:
//
// * Redistributions of source code must retain the above copyright
// notice, this list of conditions and the following disclaimer.
// * Redistributions in binary form must reproduce the above
// copyright notice, this list of conditions and the following disclaimer
// in the documentation and/or other materials provided with the
// distribution.
// * Neither the name of Google Inc. nor the names of its
// contributors may be used to endorse or promote products derived from
// this software without specific prior written permission.
//
// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
// "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
// LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
// A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
// OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
// SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
// LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
// DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
// THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
// (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
// OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
//
package upnp
import (
"context"
"encoding/xml"
"errors"
"fmt"
"net"
"time"
"github.com/wlynxg/anet"
"github.com/syncthing/syncthing/lib/nat"
)
// An IGDService is a specific service provided by an IGD.
type IGDService struct {
UUID string
Device upnpDevice
ServiceID string
URL string
URN string
LocalIPv4 net.IP
Interface *net.Interface
nat.Service
}
// AddPinhole adds an IPv6 pinhole in accordance to http://upnp.org/specs/gw/UPnP-gw-WANIPv6FirewallControl-v1-Service.pdf
// This is attempted for each IPv6 on the interface.
func (s *IGDService) AddPinhole(ctx context.Context, protocol nat.Protocol, intAddr nat.Address, duration time.Duration) ([]net.IP, error) {
var returnErr error
var successfulIPs []net.IP
if s.Interface == nil {
return nil, errors.New("no interface")
}
addrs, err := anet.InterfaceAddrsByInterface(s.Interface)
if err != nil {
return nil, err
}
if !intAddr.IP.IsUnspecified() {
// We have an explicit listener address. Check if that's on the interface
// and pinhole it if so. It's not an error if not though, so don't return
// an error if one doesn't occur.
if intAddr.IP.To4() != nil {
l.Debugf("Listener is IPv4. Not using gateway %s", s.ID())
return nil, nil
}
for _, addr := range addrs {
ip, _, err := net.ParseCIDR(addr.String())
if err != nil {
return nil, err
}
if ip.Equal(intAddr.IP) {
err := s.tryAddPinholeForIP6(ctx, protocol, intAddr.Port, duration, intAddr.IP)
if err != nil {
return nil, err
}
return []net.IP{
intAddr.IP,
}, nil
}
l.Debugf("Listener IP %s not on interface for gateway %s", intAddr.IP, s.ID())
}
return nil, nil
}
// Otherwise, try to get a pinhole for all IPs, since we are listening on all
for _, addr := range addrs {
ip, _, err := net.ParseCIDR(addr.String())
if err != nil {
l.Infof("Couldn't parse address %s: %s", addr, err)
continue
}
// Note that IsGlobalUnicast allows ULAs.
if ip.To4() != nil || !ip.IsGlobalUnicast() || ip.IsPrivate() {
continue
}
if err := s.tryAddPinholeForIP6(ctx, protocol, intAddr.Port, duration, ip); err != nil {
l.Infof("Couldn't add pinhole for [%s]:%d/%s. %s", ip, intAddr.Port, protocol, err)
returnErr = err
} else {
successfulIPs = append(successfulIPs, ip)
}
}
if len(successfulIPs) > 0 {
// (Maybe partial) success, we added a pinhole for at least one GUA.
return successfulIPs, nil
} else {
return nil, returnErr
}
}
func (s *IGDService) tryAddPinholeForIP6(ctx context.Context, protocol nat.Protocol, port int, duration time.Duration, ip net.IP) error {
var protoNumber int
if protocol == nat.TCP {
protoNumber = 6
} else if protocol == nat.UDP {
protoNumber = 17
} else {
return errors.New("protocol not supported")
}
const template = `<u:AddPinhole xmlns:u="%s">
<RemoteHost></RemoteHost>
<RemotePort>0</RemotePort>
<Protocol>%d</Protocol>
<InternalPort>%d</InternalPort>
<InternalClient>%s</InternalClient>
<LeaseTime>%d</LeaseTime>
</u:AddPinhole>`
body := fmt.Sprintf(template, s.URN, protoNumber, port, ip, duration/time.Second)
// IP should be a global unicast address, so we can use it as the source IP.
// By the UPnP spec, the source address for unauthenticated clients should be
// the same as the InternalAddress the pinhole is requested for.
// Currently, WANIPv6FirewallProtocol is restricted to IPv6 gateways, so we can always set the IP.
resp, err := soapRequestWithIP(ctx, s.URL, s.URN, "AddPinhole", body, &net.TCPAddr{IP: ip})
if err != nil && resp != nil {
var errResponse soapErrorResponse
if unmarshalErr := xml.Unmarshal(resp, &errResponse); unmarshalErr != nil {
// There is an error response that we cannot parse.
return unmarshalErr
}
// There is a parsable UPnP error. Return that.
return fmt.Errorf("UPnP error: %s (%d)", errResponse.ErrorDescription, errResponse.ErrorCode)
} else if resp != nil {
var succResponse soapAddPinholeResponse
if unmarshalErr := xml.Unmarshal(resp, &succResponse); unmarshalErr != nil {
// Ignore errors since this is only used for debug logging.
l.Debugf("Failed to parse response from gateway %s: %s", s.ID(), unmarshalErr)
} else {
l.Debugf("UPnPv6: UID for pinhole on [%s]:%d/%s is %d on gateway %s", ip, port, protocol, succResponse.UniqueID, s.ID())
}
}
// Either there was no error or an error not handled above (no response, e.g. network error).
return err
}
// AddPortMapping adds a port mapping to the specified IGD service.
func (s *IGDService) AddPortMapping(ctx context.Context, protocol nat.Protocol, internalPort, externalPort int, description string, duration time.Duration) (int, error) {
if s.LocalIPv4 == nil {
return 0, errors.New("no local IPv4")
}
const template = `<u:AddPortMapping xmlns:u="%s">
<NewRemoteHost></NewRemoteHost>
<NewExternalPort>%d</NewExternalPort>
<NewProtocol>%s</NewProtocol>
<NewInternalPort>%d</NewInternalPort>
<NewInternalClient>%s</NewInternalClient>
<NewEnabled>1</NewEnabled>
<NewPortMappingDescription>%s</NewPortMappingDescription>
<NewLeaseDuration>%d</NewLeaseDuration>
</u:AddPortMapping>`
body := fmt.Sprintf(template, s.URN, externalPort, protocol, internalPort, s.LocalIPv4, description, duration/time.Second)
response, err := soapRequestWithIP(ctx, s.URL, s.URN, "AddPortMapping", body, &net.TCPAddr{IP: s.LocalIPv4})
if err != nil && duration > 0 {
// Try to repair error code 725 - OnlyPermanentLeasesSupported
var envelope soapErrorResponse
if unmarshalErr := xml.Unmarshal(response, &envelope); unmarshalErr != nil {
return externalPort, unmarshalErr
}
if envelope.ErrorCode == 725 {
return s.AddPortMapping(ctx, protocol, internalPort, externalPort, description, 0)
}
err = fmt.Errorf("UPnP Error: %s (%d)", envelope.ErrorDescription, envelope.ErrorCode)
l.Debugf("Couldn't add port mapping for %s (external port %d -> internal port %d/%s): %s", s.LocalIPv4, externalPort, internalPort, protocol, err)
}
return externalPort, err
}
// DeletePortMapping deletes a port mapping from the specified IGD service.
func (s *IGDService) DeletePortMapping(ctx context.Context, protocol nat.Protocol, externalPort int) error {
const template = `<u:DeletePortMapping xmlns:u="%s">
<NewRemoteHost></NewRemoteHost>
<NewExternalPort>%d</NewExternalPort>
<NewProtocol>%s</NewProtocol>
</u:DeletePortMapping>`
body := fmt.Sprintf(template, s.URN, externalPort, protocol)
_, err := soapRequest(ctx, s.URL, s.URN, "DeletePortMapping", body)
return err
}
// GetExternalIPv4Address queries the IGD service for its external IP address.
// Returns nil if the external IP address is invalid or undefined, along with
// any relevant errors
func (s *IGDService) GetExternalIPv4Address(ctx context.Context) (net.IP, error) {
const template = `<u:GetExternalIPAddress xmlns:u="%s" />`
body := fmt.Sprintf(template, s.URN)
response, err := soapRequest(ctx, s.URL, s.URN, "GetExternalIPAddress", body)
if err != nil {
return nil, err
}
var envelope soapGetExternalIPAddressResponseEnvelope
if err := xml.Unmarshal(response, &envelope); err != nil {
return nil, err
}
result := net.ParseIP(envelope.Body.GetExternalIPAddressResponse.NewExternalIPAddress)
return result, nil
}
// GetLocalIPv4Address returns local IP address used to contact this service
func (s *IGDService) GetLocalIPv4Address() net.IP {
return s.LocalIPv4
}
// SupportsIPVersion checks whether this is a WANIPv6FirewallControl device,
// in which case pinholing instead of port mapping should be done
func (s *IGDService) SupportsIPVersion(version nat.IPVersion) bool {
if version == nat.IPvAny {
return true
} else if version == nat.IPv6Only {
return s.URN == urnWANIPv6FirewallControlV1
} else if version == nat.IPv4Only {
return s.URN != urnWANIPv6FirewallControlV1
}
return true
}
// ID returns a unique ID for the service
func (s *IGDService) ID() string {
return s.UUID + "/" + s.Device.FriendlyName + "/" + s.ServiceID + "/" + s.URN + "/" + s.URL
}