syncthing/lib/pmp/pmp.go
Maximilian 16db6fcf3d
lib/nat, lib/upnp: IPv6 UPnP support (#9010)
This pull request allows syncthing to request an IPv6
[pinhole](https://en.wikipedia.org/wiki/Firewall_pinhole), addressing
issue #7406. This helps users who prefer to use IPv6 for hosting their
services or are forced to do so because of
[CGNAT](https://en.wikipedia.org/wiki/Carrier-grade_NAT). Otherwise,
such users would have to configure their firewall manually to allow
syncthing traffic to pass through while IPv4 users can use UPnP to take
care of network configuration already.

### Testing

I have tested this in a virtual machine setup with miniupnpd running on
the virtualized router. It successfully added an IPv6 pinhole when used
with IPv6 only, an IPv4 port mapping when used with IPv4 only and both
when dual-stack (IPv4 and IPv6) is used.

Automated tests could be added for SOAP responses from the router but
automatically testing this with a real network is likely infeasible.

### Documentation

https://docs.syncthing.net/users/firewall.html could be updated to
mention the fact that UPnP now works with IPv6, although this change is
more "behind the scenes".

---------

Co-authored-by: Simon Frei <freisim93@gmail.com>
Co-authored-by: bt90 <btom1990@googlemail.com>
Co-authored-by: André Colomb <github.com@andre.colomb.de>
2023-12-11 07:36:18 +01:00

148 lines
3.8 KiB
Go

// Copyright (C) 2016 The Syncthing Authors.
//
// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this file,
// You can obtain one at https://mozilla.org/MPL/2.0/.
package pmp
import (
"context"
"errors"
"fmt"
"net"
"strings"
"time"
"github.com/jackpal/gateway"
natpmp "github.com/jackpal/go-nat-pmp"
"github.com/syncthing/syncthing/lib/nat"
"github.com/syncthing/syncthing/lib/osutil"
"github.com/syncthing/syncthing/lib/svcutil"
)
func init() {
nat.Register(Discover)
}
func Discover(ctx context.Context, renewal, timeout time.Duration) []nat.Device {
var ip net.IP
err := svcutil.CallWithContext(ctx, func() error {
var err error
ip, err = gateway.DiscoverGateway()
return err
})
if err != nil {
l.Debugln("Failed to discover gateway", err)
return nil
}
if ip == nil || ip.IsUnspecified() {
return nil
}
l.Debugln("Discovered gateway at", ip)
c := natpmp.NewClientWithTimeout(ip, timeout)
// Try contacting the gateway, if it does not respond, assume it does not
// speak NAT-PMP.
err = svcutil.CallWithContext(ctx, func() error {
_, ierr := c.GetExternalAddress()
return ierr
})
if err != nil {
if errors.Is(err, context.Canceled) {
return nil
}
if strings.Contains(err.Error(), "Timed out") {
l.Debugln("Timeout trying to get external address, assume no NAT-PMP available")
return nil
}
}
var localIP net.IP
// Port comes from the natpmp package
timeoutCtx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
conn, err := (&net.Dialer{}).DialContext(timeoutCtx, "udp", net.JoinHostPort(ip.String(), "5351"))
if err == nil {
conn.Close()
localIP, err = osutil.IPFromAddr(conn.LocalAddr())
if localIP == nil {
l.Debugln("Failed to lookup local IP", err)
}
}
return []nat.Device{&wrapper{
renewal: renewal,
localIP: localIP,
gatewayIP: ip,
client: c,
}}
}
type wrapper struct {
renewal time.Duration
localIP net.IP
gatewayIP net.IP
client *natpmp.Client
}
func (w *wrapper) ID() string {
return fmt.Sprintf("NAT-PMP@%s", w.gatewayIP.String())
}
func (w *wrapper) GetLocalIPv4Address() net.IP {
return w.localIP
}
func (w *wrapper) AddPortMapping(ctx context.Context, protocol nat.Protocol, internalPort, externalPort int, _ string, duration time.Duration) (int, error) {
// NAT-PMP says that if duration is 0, the mapping is actually removed
// Swap the zero with the renewal value, which should make the lease for the
// exact amount of time between the calls.
if duration == 0 {
duration = w.renewal
}
var result *natpmp.AddPortMappingResult
err := svcutil.CallWithContext(ctx, func() error {
var err error
result, err = w.client.AddPortMapping(strings.ToLower(string(protocol)), internalPort, externalPort, int(duration/time.Second))
return err
})
port := 0
if result != nil {
port = int(result.MappedExternalPort)
}
return port, err
}
func (*wrapper) AddPinhole(_ context.Context, _ nat.Protocol, _ nat.Address, _ time.Duration) ([]net.IP, error) {
// NAT-PMP doesn't support pinholes.
return nil, errors.New("adding IPv6 pinholes is unsupported on NAT-PMP")
}
func (*wrapper) SupportsIPVersion(version nat.IPVersion) bool {
// NAT-PMP gateways should always try to create port mappings and not pinholes
// since NAT-PMP doesn't support IPv6.
return version == nat.IPvAny || version == nat.IPv4Only
}
func (w *wrapper) GetExternalIPv4Address(ctx context.Context) (net.IP, error) {
var result *natpmp.GetExternalAddressResult
err := svcutil.CallWithContext(ctx, func() error {
var err error
result, err = w.client.GetExternalAddress()
return err
})
ip := net.IPv4zero
if result != nil {
ip = net.IPv4(
result.ExternalIPAddress[0],
result.ExternalIPAddress[1],
result.ExternalIPAddress[2],
result.ExternalIPAddress[3],
)
}
return ip, err
}