feat(connections, nat): add UDP portmapping/pinhole for QUIC (fixes #7403) (#10171)

Fixes #7403.

Tested by enabling UPnP on the router, and checking on the router page
that the external ports of the UDP mappings match what is shown in the
logs and the internal ports matching the QUIC listening port.
This commit is contained in:
Marcus B Spencer
2025-06-20 04:24:45 +00:00
committed by GitHub
parent b4ff96d754
commit 4c64843d60
4 changed files with 60 additions and 27 deletions
+27 -4
View File
@@ -49,9 +49,11 @@ type quicListener struct {
registry *registry.Registry registry *registry.Registry
lanChecker *lanChecker lanChecker *lanChecker
address *url.URL address *url.URL
laddr net.Addr natService *nat.Service
mut sync.Mutex mapping *nat.Mapping
laddr net.Addr
mut sync.Mutex
} }
func (t *quicListener) OnNATTypeChanged(natType stun.NATType) { func (t *quicListener) OnNATTypeChanged(natType stun.NATType) {
@@ -126,7 +128,24 @@ func (t *quicListener) serve(ctx context.Context) error {
l.Infof("QUIC listener (%v) starting", udpConn.LocalAddr()) l.Infof("QUIC listener (%v) starting", udpConn.LocalAddr())
defer l.Infof("QUIC listener (%v) shutting down", udpConn.LocalAddr()) defer l.Infof("QUIC listener (%v) shutting down", udpConn.LocalAddr())
var ipVersion nat.IPVersion
switch t.uri.Scheme {
case "quic4":
ipVersion = nat.IPv4Only
case "quic6":
ipVersion = nat.IPv6Only
default:
ipVersion = nat.IPvAny
}
mapping := t.natService.NewMapping(nat.UDP, ipVersion, udpAddr.IP, udpAddr.Port)
mapping.OnChanged(func() {
t.notifyAddressesChanged(t)
})
// Should be called after t.mapping is nil'ed out.
defer t.natService.RemoveMapping(mapping)
t.mut.Lock() t.mut.Lock()
t.mapping = mapping
t.laddr = udpConn.LocalAddr() t.laddr = udpConn.LocalAddr()
t.mut.Unlock() t.mut.Unlock()
defer func() { defer func() {
@@ -196,6 +215,9 @@ func (t *quicListener) WANAddresses() []*url.URL {
if t.address != nil { if t.address != nil {
uris = append(uris, t.address) uris = append(uris, t.address)
} }
uris = append(uris, portMappingURIs(t.mapping, *t.uri)...)
t.mut.Unlock() t.mut.Unlock()
return uris return uris
} }
@@ -232,12 +254,13 @@ func (*quicListenerFactory) Valid(config.Configuration) error {
return nil return nil
} }
func (f *quicListenerFactory) New(uri *url.URL, cfg config.Wrapper, tlsCfg *tls.Config, conns chan internalConn, _ *nat.Service, registry *registry.Registry, lanChecker *lanChecker) genericListener { func (f *quicListenerFactory) New(uri *url.URL, cfg config.Wrapper, tlsCfg *tls.Config, conns chan internalConn, natService *nat.Service, registry *registry.Registry, lanChecker *lanChecker) genericListener {
l := &quicListener{ l := &quicListener{
uri: fixupPort(uri, config.DefaultQUICPort), uri: fixupPort(uri, config.DefaultQUICPort),
cfg: cfg, cfg: cfg,
tlsCfg: tlsCfg, tlsCfg: tlsCfg,
conns: conns, conns: conns,
natService: natService,
factory: f, factory: f,
registry: registry, registry: registry,
lanChecker: lanChecker, lanChecker: lanChecker,
+2 -17
View File
@@ -175,24 +175,9 @@ func (t *tcpListener) WANAddresses() []*url.URL {
uris := []*url.URL{ uris := []*url.URL{
maybeReplacePort(t.uri, t.laddr), maybeReplacePort(t.uri, t.laddr),
} }
if t.mapping != nil {
addrs := t.mapping.ExternalAddresses()
for _, addr := range addrs {
uri := *t.uri
// Does net.JoinHostPort internally
uri.Host = addr.String()
uris = append(uris, &uri)
// For every address with a specified IP, add one without an IP, uris = append(uris, portMappingURIs(t.mapping, *t.uri)...)
// just in case the specified IP is still internal (router behind DMZ).
if len(addr.IP) != 0 && !addr.IP.IsUnspecified() {
zeroUri := *t.uri
addr.IP = nil
zeroUri.Host = addr.String()
uris = append(uris, &zeroUri)
}
}
}
t.mut.RUnlock() t.mut.RUnlock()
// If we support ReusePort, add an unspecified zero port address, which will be resolved by the discovery server // If we support ReusePort, add an unspecified zero port address, which will be resolved by the discovery server
+25
View File
@@ -12,6 +12,7 @@ import (
"strconv" "strconv"
"strings" "strings"
"github.com/syncthing/syncthing/lib/nat"
"github.com/syncthing/syncthing/lib/osutil" "github.com/syncthing/syncthing/lib/osutil"
) )
@@ -130,3 +131,27 @@ func maybeReplacePort(uri *url.URL, laddr net.Addr) *url.URL {
uriCopy.Host = net.JoinHostPort(host, lportStr) uriCopy.Host = net.JoinHostPort(host, lportStr)
return &uriCopy return &uriCopy
} }
func portMappingURIs(mapping *nat.Mapping, listener_uri url.URL) []*url.URL {
var uris []*url.URL
if mapping != nil {
addrs := mapping.ExternalAddresses()
for _, addr := range addrs {
uri := listener_uri
// Does net.JoinHostPort internally
uri.Host = addr.String()
uris = append(uris, &uri)
// For every address with a specified IP, add one without an IP,
// just in case the specified IP is still internal (router behind DMZ).
if len(addr.IP) != 0 && !addr.IP.IsUnspecified() {
zeroUri := listener_uri
addr.IP = nil
zeroUri.Host = addr.String()
uris = append(uris, &zeroUri)
}
}
}
return uris
}
+6 -6
View File
@@ -257,7 +257,7 @@ func (s *Service) verifyExistingLocked(ctx context.Context, mapping *Mapping, na
// extAddrs either contains one IPv4 address, or possibly several // extAddrs either contains one IPv4 address, or possibly several
// IPv6 addresses all using the same port. Therefore the first // IPv6 addresses all using the same port. Therefore the first
// entry always has the external port. // entry always has the external port.
responseAddrs, err := s.tryNATDevice(ctx, nat, mapping.address, extAddrs[0].Port, leaseTime) responseAddrs, err := s.tryNATDevice(ctx, nat, mapping.address, extAddrs[0].Port, mapping.protocol, leaseTime)
if err != nil { if err != nil {
l.Infof("Failed to renew %s -> %v open port on %s: %s", mapping, extAddrs, id, err) l.Infof("Failed to renew %s -> %v open port on %s: %s", mapping, extAddrs, id, err)
mapping.removeAddressLocked(id) mapping.removeAddressLocked(id)
@@ -309,7 +309,7 @@ func (s *Service) acquireNewLocked(ctx context.Context, mapping *Mapping, nats m
continue continue
} }
addrs, err := s.tryNATDevice(ctx, nat, mapping.address, 0, leaseTime) addrs, err := s.tryNATDevice(ctx, nat, mapping.address, 0, mapping.protocol, leaseTime)
if err != nil { if err != nil {
l.Infof("Failed to acquire %s open port on %s: %s", mapping, id, err) l.Infof("Failed to acquire %s open port on %s: %s", mapping, id, err)
continue continue
@@ -325,14 +325,14 @@ func (s *Service) acquireNewLocked(ctx context.Context, mapping *Mapping, nats m
// tryNATDevice tries to acquire a port mapping for the given internal address to // tryNATDevice tries to acquire a port mapping for the given internal address to
// the given external port. If external port is 0, picks a pseudo-random port. // the given external port. If external port is 0, picks a pseudo-random port.
func (s *Service) tryNATDevice(ctx context.Context, natd Device, intAddr Address, extPort int, leaseTime time.Duration) ([]Address, error) { func (s *Service) tryNATDevice(ctx context.Context, natd Device, intAddr Address, extPort int, protocol Protocol, leaseTime time.Duration) ([]Address, error) {
var err error var err error
var port int var port int
// For IPv6, we just try to create the pinhole. If it fails, nothing can be done (probably no IGDv2 support). // For IPv6, we just try to create the pinhole. If it fails, nothing can be done (probably no IGDv2 support).
// If it already exists, the relevant UPnP standard requires that the gateway recognizes this and updates the lease time. // If it already exists, the relevant UPnP standard requires that the gateway recognizes this and updates the lease time.
// Since we usually have a global unicast IPv6 address so no conflicting mappings, we just request the port we're running on // Since we usually have a global unicast IPv6 address so no conflicting mappings, we just request the port we're running on
if natd.SupportsIPVersion(IPv6Only) { if natd.SupportsIPVersion(IPv6Only) {
ipaddrs, err := natd.AddPinhole(ctx, TCP, intAddr, leaseTime) ipaddrs, err := natd.AddPinhole(ctx, protocol, intAddr, leaseTime)
var addrs []Address var addrs []Address
for _, ipaddr := range ipaddrs { for _, ipaddr := range ipaddrs {
addrs = append(addrs, Address{ addrs = append(addrs, Address{
@@ -354,7 +354,7 @@ func (s *Service) tryNATDevice(ctx context.Context, natd Device, intAddr Address
if extPort != 0 { if extPort != 0 {
// First try renewing our existing mapping, if we have one. // First try renewing our existing mapping, if we have one.
name := fmt.Sprintf("syncthing-%d", extPort) name := fmt.Sprintf("syncthing-%d", extPort)
port, err = natd.AddPortMapping(ctx, TCP, intAddr.Port, extPort, name, leaseTime) port, err = natd.AddPortMapping(ctx, protocol, intAddr.Port, extPort, name, leaseTime)
if err == nil { if err == nil {
extPort = port extPort = port
goto findIP goto findIP
@@ -372,7 +372,7 @@ func (s *Service) tryNATDevice(ctx context.Context, natd Device, intAddr Address
// Then try up to ten random ports. // Then try up to ten random ports.
extPort = 1024 + predictableRand.Intn(65535-1024) extPort = 1024 + predictableRand.Intn(65535-1024)
name := fmt.Sprintf("syncthing-%d", extPort) name := fmt.Sprintf("syncthing-%d", extPort)
port, err = natd.AddPortMapping(ctx, TCP, intAddr.Port, extPort, name, leaseTime) port, err = natd.AddPortMapping(ctx, protocol, intAddr.Port, extPort, name, leaseTime)
if err == nil { if err == nil {
extPort = port extPort = port
goto findIP goto findIP