package natimport (logging)// ErrNoMapping signals no mapping exists for an addressvarErrNoMapping = errors.New("mapping not established")var log = logging.Logger("nat")// MappingDuration is a default port mapping duration.// Port mappings are renewed every (MappingDuration / 3)constMappingDuration = time.Minute// CacheTime is the time a mapping will cache an external address forconstCacheTime = 15 * time.Second// DiscoveryTimeout is the maximum time to wait for NAT discovery.// This is based on the underlying UPnP and NAT-PMP/PCP protocols:// - SSDP (UPnP discovery) waits 5 seconds for responses// - NAT-PMP uses exponential backoff starting at 250ms, up to 9 retries// (total ~32 seconds if exhausted, but typically responds in 1-2 seconds)// - PCP follows similar timing to NAT-PMP// - 10 seconds covers common cases while failing fast when no NAT existsconstDiscoveryTimeout = 10 * time.Second// rediscoveryThreshold is the number of consecutive connection failures// before triggering NAT rediscovery. We ignore first few failures to// distinguish between transient network issues and persistent router// problems like restarts or port changes that require finding the NAT device again.const rediscoveryThreshold = 3type entry struct { protocol string port int}// so we can mock it in testsvar discoverGateway = nat.DiscoverGateway// DiscoverNAT looks for a NAT device in the network and returns an object that can manage port mappings.func ( context.Context) (*NAT, error) { , := discoverGateway()if != nil {returnnil, } := getExternalAddress()// Log the device addr. , := .GetDeviceAddress()if != nil {log.Warn("DiscoverGateway address error", "err", ) } else {log.Info("DiscoverGateway address", "address", ) } , := context.WithCancel(context.Background()) := &NAT{nat: ,mappings: make(map[entry]int),ctx: ,ctxCancel: , } .extAddr.Store(&) .refCount.Add(1)gofunc() {defer .refCount.Done() .background() }()return , nil}// NAT is an object that manages address port mappings in// NATs (Network Address Translators). It is a long-running// service that will periodically renew port mappings,// and keep an up-to-date list of all the external addresses.//// Locking strategy:// - natmu: Protects nat instance and rediscovery state (nat, consecutiveFailures, rediscovering)// - mappingmu: Protects port mappings table and closed flag (mappings, closed)// - Lock ordering: When both locks are needed, always acquire mappingmu before natmu// to prevent deadlocks// - We use separate mutexes because the NAT instance may change (e.g., when router// restarts and UPnP port changes), but the port mappings must persist and be// re-applied across all instances. This separation allows the mappings table to// remain stable while the underlying NAT device changes.typeNATstruct { natmu sync.Mutex nat nat.NAT// Track connection failures for auto-rediscovery consecutiveFailures int// protected by natmu rediscovering bool// protected by natmu// External IP of the NAT. Will be renewed periodically (every CacheTime). extAddr atomic.Pointer[netip.Addr] refCount sync.WaitGroup ctx context.Context ctxCancel context.CancelFunc// Port mappings that should persist across NAT instance changes mappingmu sync.RWMutex closed bool// protected by mappingmu mappings map[entry]int// protected by mappingmu}// Close shuts down all port mappings. NAT can no longer be used.func ( *NAT) () error { .mappingmu.Lock() .closed = true .mappingmu.Unlock() .ctxCancel() .refCount.Wait()returnnil}func ( *NAT) ( string, int) ( netip.AddrPort, bool) { .mappingmu.Lock()defer .mappingmu.Unlock()if !.extAddr.Load().IsValid() {returnnetip.AddrPort{}, false } , := .mappings[entry{protocol: , port: }]// The mapping may have an invalid port.if ! || == 0 {returnnetip.AddrPort{}, false }returnnetip.AddrPortFrom(*.extAddr.Load(), uint16()), true}// AddMapping attempts to construct a mapping on protocol and internal port.// It blocks until a mapping was established. Once added, it periodically renews the mapping.//// May not succeed, and mappings may change over time;// NAT devices may not respect our port requests, and even lie.func ( *NAT) ( context.Context, string, int) error {switch {case"tcp", "udp":default:returnfmt.Errorf("invalid protocol: %s", ) } .mappingmu.Lock()defer .mappingmu.Unlock()if .closed {returnerrors.New("closed") }// Check if the mapping already exists to avoid duplicate work := entry{protocol: , port: }if , := .mappings[]; {returnnil }log.Info("Starting maintenance of port mapping", "protocol", , "port", )// do it once synchronously, so first mapping is done right away, and before exiting, // allowing users -- in the optimistic case -- to use results right after. := .establishMapping(, , )// Don't validate the mapping here, we refresh the mappings based on this map. // We can try getting a port again in case it succeeds. In the worst case, // this is one extra LAN request every few minutes. .mappings[] = returnnil}// RemoveMapping removes a port mapping.// It blocks until the NAT has removed the mapping.func ( *NAT) ( context.Context, string, int) error { .mappingmu.Lock()defer .mappingmu.Unlock() .natmu.Lock()defer .natmu.Unlock()switch {case"tcp", "udp": := entry{protocol: , port: }if , := .mappings[]; {log.Info("Stopping maintenance of port mapping", "protocol", , "port", )delete(.mappings, )return .nat.DeletePortMapping(, , ) }returnerrors.New("unknown mapping")default:returnfmt.Errorf("invalid protocol: %s", ) }}func ( *NAT) () {// Renew port mappings every 20 seconds (1/3 of 60s lifetime). // - NAT-PMP RFC 6886 recommends renewing at 50% of lifetime // - We use 33% for added safety against silent lifetime reductions // NOTE: This aggressive 60s/20s pattern may be outdated for modern routers // but provides quick cleanup and fast failure detection for our rediscovery. // TODO: Research longer durations (e.g. 30min/10min) to reduce router loadconst = MappingDuration / 3 := time.Now() := .Add() := .Add(CacheTime) := time.NewTimer(minTime(, ).Sub()) // don't use a ticker here. We don't know how long establishing the mappings takes.defer .Stop()var []entryvar []int// port numbersfor {select {case := <-.C:if .After() { = [:0] = [:0] .mappingmu.Lock()for := range .mappings { = append(, ) } .mappingmu.Unlock()// Establishing the mapping involves network requests. // Don't hold the mutex, just save the ports.for , := range { = append(, .establishMapping(.ctx, .protocol, .port)) } .mappingmu.Lock()for , := range {if , := .mappings[]; ! {continue// entry might have been deleted } .mappings[] = [] } .mappingmu.Unlock() = time.Now().Add() }if .After() { .natmu.Lock() , := .nat.GetExternalAddress() .natmu.Unlock()varnetip.Addrif == nil { , _ = netip.AddrFromSlice() } .extAddr.Store(&) = time.Now().Add(CacheTime) } .Reset(time.Until(minTime(, )))case<-.ctx.Done(): .mappingmu.Lock()defer .mappingmu.Unlock() .natmu.Lock()defer .natmu.Unlock() , := context.WithTimeout(context.Background(), 10*time.Second)defer ()for := range .mappings { .nat.DeletePortMapping(, .protocol, .port) }clear(.mappings)return } }}func ( *NAT) ( context.Context, string, int) ( int) {log.Debug("Attempting port map", "protocol", , "internal_port", )const = "libp2p"// Try to establish the mapping with both NAT calls under the same lock .natmu.Lock()defer .natmu.Unlock()varerror , = .nat.AddPortMapping(, , , , MappingDuration)if != nil {// Some hardware does not support mappings with timeout, so try that , = .nat.AddPortMapping(, , , , 0) }// Handle successif == nil && != 0 { .consecutiveFailures = 0log.Debug("NAT port mapping established", "protocol", , "internal_port", , "external_port", )return }// Handle failuresif != nil {log.Warn("NAT port mapping failed", "protocol", , "internal_port", , "err", )// Check if this is a connection error that might indicate router restart // See: https://github.com/libp2p/go-libp2p/issues/3224#issuecomment-2866844723 // Note: We use string matching because goupnp doesn't preserve error chains (uses %v instead of %w)ifstrings.Contains(.Error(), "connection refused") { .consecutiveFailures++if .consecutiveFailures >= rediscoveryThreshold && !.rediscovering { .rediscovering = true// Spawn in goroutine to avoid blocking the caller while we // perform network discovery, which can take up to 30 seconds. // The rediscovering flag prevents multiple concurrent attempts.go .rediscoverNAT() } } else {// Reset counter for non-connection errors (transient failures) .consecutiveFailures = 0 }return0 }// externalPort is 0 but no error was returnedlog.Warn("NAT port mapping failed", "protocol", , "internal_port", , "external_port", 0)return0}// rediscoverNAT attempts to rediscover the NAT device after connection failuresfunc ( *NAT) () {log.Info("NAT rediscovery triggered due to repeated connection failures") , := context.WithTimeout(.ctx, DiscoveryTimeout)defer () , := discoverGateway()if != nil {log.Warn("NAT rediscovery failed", "err", ) .natmu.Lock()defer .natmu.Unlock() .rediscovering = falsereturn } := getExternalAddress()// Replace the NAT instance // No cleanup of the old instance needed because: // - Router restart has already wiped all mappings // - Old UPnP endpoint is dead (connection refused) // - If router didn't actually restart (false positive), any stale mappings // on the router expire naturally (60 second UPnP timeout) .natmu.Lock() .nat = .extAddr.Store(&) .consecutiveFailures = 0 .rediscovering = false .natmu.Unlock()// Re-establish all existing mappings on the new NAT instance .mappingmu.Lock()for := range .mappings { := .establishMapping(.ctx, .protocol, .port) .mappings[] = if != 0 {log.Info("NAT mapping restored after rediscovery", "protocol", .protocol, "internal_port", .port, "external_port", ) } } .mappingmu.Unlock()log.Info("NAT rediscovery successful")}func minTime(, time.Time) time.Time {if .Before() {return }return}// getExternalAddress retrieves and parses the external address from a NAT instancefunc getExternalAddress( nat.NAT) netip.Addr { , := .GetExternalAddress()if != nil {log.Debug("Failed to get external address", "err", )returnnetip.Addr{} } , := netip.AddrFromSlice()if ! {log.Debug("Failed to parse external address", "ip", )returnnetip.Addr{} }return}
The pages are generated with Goldsv0.8.2. (GOOS=linux GOARCH=amd64)
Golds is a Go 101 project developed by Tapir Liu.
PR and bug reports are welcome and can be submitted to the issue list.
Please follow @zigo_101 (reachable from the left QR code) to get the latest news of Golds.