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>
276 lines
9.6 KiB
Go
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
|
|
}
|