diff --git a/app/cmd/client.go b/app/cmd/client.go index ba50d73..98f3d24 100644 --- a/app/cmd/client.go +++ b/app/cmd/client.go @@ -92,10 +92,11 @@ type clientConfig struct { } type clientConfigRealm struct { - STUNServers []string `mapstructure:"stunServers"` - STUNTimeout time.Duration `mapstructure:"stunTimeout"` - PunchTimeout time.Duration `mapstructure:"punchTimeout"` - Insecure bool `mapstructure:"insecure"` + STUNServers []string `mapstructure:"stunServers"` + STUNTimeout time.Duration `mapstructure:"stunTimeout"` + PunchTimeout time.Duration `mapstructure:"punchTimeout"` + Insecure bool `mapstructure:"insecure"` + PortMapping realmPortMappingConfig `mapstructure:"portMapping"` } type clientConfigTransportUDP struct { @@ -638,6 +639,22 @@ func (c *clientConfig) realmConfig(addr *realm.Addr) (*client.Config, error) { }() ctx := context.Background() + // Gateway port mapping (UPnP/NAT-PMP) runs before STUN. + // With the pinhole in place, in a double-NAT setup, + // the address STUN observes corresponds to a path whose inner leg + // goes through the static mapping rather than a filtered dynamic one. + var mapper *realm.PortMapper + if c.Realm.PortMapping.Enabled { + localPort := baseConn.LocalAddr().(*net.UDPAddr).Port + mapper = newRealmPortMapper(ctx, addr.RealmID, localPort, c.Realm.PortMapping) + if mapper != nil { + defer func() { + if !success { + _ = mapper.Close() + } + }() + } + } stunServers := c.realmSTUNServers(addr) logger.Debug("realm client STUN discovery started", zap.String("realm", addr.RealmID), @@ -654,6 +671,9 @@ func (c *clientConfig) realmConfig(addr *realm.Addr) (*client.Config, error) { zap.String("realm", addr.RealmID), zap.Strings("addresses", addrPortStrings(localAddrs)), zap.String("duration", formatLogDuration(time.Since(stunStart)))) + if mapper != nil { + localAddrs = mergeMappedAddr(localAddrs, mapper.ExternalAddr()) + } meta, err := realm.NewPunchMetadata() if err != nil { return nil, configError{Field: "realm", Err: err} @@ -710,6 +730,11 @@ func (c *clientConfig) realmConfig(addr *realm.Addr) (*client.Config, error) { if err != nil { return nil, err } + if mapper != nil { + mapCtx, mapCancel := context.WithCancel(context.Background()) + go realmPortMapLoop(mapCtx, addr.RealmID, mapper) + finalConn = &cleanupPacketConn{PacketConn: finalConn, cleanup: mapCancel} + } hyConfig.ConnFactory = &singleUseConnFactory{ Open: func() (net.PacketConn, error) { return finalConn, nil }, } diff --git a/app/cmd/realm_portmap.go b/app/cmd/realm_portmap.go new file mode 100644 index 0000000..0fc9474 --- /dev/null +++ b/app/cmd/realm_portmap.go @@ -0,0 +1,123 @@ +package cmd + +import ( + "context" + "net" + "net/netip" + "slices" + "strings" + "time" + + "go.uber.org/zap" + + "github.com/apernet/hysteria/extras/v2/realm" +) + +type realmPortMappingConfig struct { + Enabled bool `mapstructure:"enabled"` + Timeout time.Duration `mapstructure:"timeout"` + Lifetime time.Duration `mapstructure:"lifetime"` +} + +// newRealmPortMapper maps localPort on the gateway via UPnP/NAT-PMP. +// Failures are non-fatal by design: it logs a warning and returns nil, +// in which case the realm flow continues with STUN-discovered addresses only. +func newRealmPortMapper(ctx context.Context, realmID string, localPort int, config realmPortMappingConfig) *realm.PortMapper { + logger.Debug("realm port mapping started", + zap.String("realm", realmID), + zap.Int("port", localPort)) + start := time.Now() + mapper, err := realm.NewPortMapper(ctx, localPort, realm.PortMapConfig{ + Timeout: config.Timeout, + Lifetime: config.Lifetime, + }) + if err != nil { + logger.Warn("realm port mapping failed; continuing without it", + zap.String("realm", realmID), + zap.Error(err)) + return nil + } + logger.Debug("realm port mapping added", + zap.String("realm", realmID), + zap.String("gateway", mapper.GatewayType()), + zap.Int("port", localPort), + zap.String("external", mapper.ExternalAddr().String()), + zap.String("duration", formatLogDuration(time.Since(start)))) + return mapper +} + +// realmPortMapLoop renews the mapping at half its lease lifetime until ctx is +// cancelled, then removes it from the gateway. +func realmPortMapLoop(ctx context.Context, realmID string, mapper *realm.PortMapper) { + defer func() { + if err := mapper.Close(); err != nil { + logger.Debug("realm port mapping removal failed", + zap.String("realm", realmID), + zap.Error(err)) + } else { + logger.Debug("realm port mapping removed", zap.String("realm", realmID)) + } + }() + interval := mapper.Lifetime() / 2 + if interval <= 0 { + interval = time.Minute + } + t := time.NewTicker(interval) + defer t.Stop() + failing := false + for { + select { + case <-ctx.Done(): + return + case <-t.C: + changed, err := mapper.Renew(ctx) + if err != nil { + if ctx.Err() != nil { + return + } + // Warn only on the first failure + if !failing { + logger.Warn("realm port mapping renewal failed", + zap.String("realm", realmID), + zap.Error(err)) + failing = true + } + continue + } + if failing { + logger.Info("realm port mapping recovered", + zap.String("realm", realmID), + zap.String("external", mapper.ExternalAddr().String())) + failing = false + } + logger.Debug("realm port mapping renewed", + zap.String("realm", realmID), + zap.String("external", mapper.ExternalAddr().String()), + zap.Bool("changed", changed)) + } + } +} + +type cleanupPacketConn struct { + net.PacketConn + cleanup func() +} + +func (c *cleanupPacketConn) Close() error { + c.cleanup() + return c.PacketConn.Close() +} + +func mergeMappedAddr(addrs []netip.AddrPort, addr netip.AddrPort) []netip.AddrPort { + if !addr.IsValid() { + return addrs + } + out := append([]netip.AddrPort(nil), addrs...) + i, found := slices.BinarySearchFunc(out, addr, func(a, b netip.AddrPort) int { + return strings.Compare(a.String(), b.String()) + }) + if found { + return out + } + return slices.Insert(out, i, addr) +} diff --git a/app/cmd/server.go b/app/cmd/server.go index 8c9e8ea..8d8bd11 100644 --- a/app/cmd/server.go +++ b/app/cmd/server.go @@ -86,11 +86,12 @@ type serverConfig struct { } type serverConfigRealm struct { - STUNServers []string `mapstructure:"stunServers"` - STUNTimeout time.Duration `mapstructure:"stunTimeout"` - PunchTimeout time.Duration `mapstructure:"punchTimeout"` - HeartbeatInterval time.Duration `mapstructure:"heartbeatInterval"` - Insecure bool `mapstructure:"insecure"` + STUNServers []string `mapstructure:"stunServers"` + STUNTimeout time.Duration `mapstructure:"stunTimeout"` + PunchTimeout time.Duration `mapstructure:"punchTimeout"` + HeartbeatInterval time.Duration `mapstructure:"heartbeatInterval"` + Insecure bool `mapstructure:"insecure"` + PortMapping realmPortMappingConfig `mapstructure:"portMapping"` } type serverConfigObfsSalamander struct { @@ -446,14 +447,35 @@ func (c *serverConfig) startRealmServerRuntime(ctx context.Context, cancel conte puncher: puncher, config: c.Realm, } + // Gateway port mapping (UPnP/NAT-PMP) runs before STUN. + // With the pinhole in place, in a double-NAT setup, + // the address STUN observes corresponds to a path whose inner leg + // goes through the static mapping rather than a filtered dynamic one. + if c.Realm.PortMapping.Enabled { + localPort := 0 + if udpAddr, ok := punchConn.LocalAddr().(*net.UDPAddr); ok { + localPort = udpAddr.Port + } + rt.mapper = newRealmPortMapper(ctx, addr.RealmID, localPort, c.Realm.PortMapping) + } + cleanupMapper := func() { + if rt.mapper != nil { + _ = rt.mapper.Close() + } + } if _, _, err := rt.refreshAddrsDirect(ctx); err != nil { + cleanupMapper() return nil, configError{Field: "realm.stun", Err: err} } initialSession, err := rt.register(ctx) if err != nil { + cleanupMapper() return nil, configError{Field: "realm.register", Err: err} } rt.setSession(initialSession) + if rt.mapper != nil { + go realmPortMapLoop(ctx, addr.RealmID, rt.mapper) + } go rt.run(ctx, initialSession) return rt, nil } @@ -487,6 +509,7 @@ type realmServerRuntime struct { stunServers []string puncher *realm.ServerPuncher config serverConfigRealm + mapper *realm.PortMapper // nil if port mapping is disabled or failed mu sync.Mutex session realmSession @@ -708,11 +731,20 @@ func (r *realmServerRuntime) connectAddrs(ctx context.Context) ([]netip.AddrPort func (r *realmServerRuntime) cachedAddrs() []netip.AddrPort { r.mu.Lock() - defer r.mu.Unlock() if r.addrs == nil || time.Since(r.addrsAt) >= realmConnectSTUNCacheTTL { + r.mu.Unlock() return nil } - return append([]netip.AddrPort(nil), r.addrs...) + addrs := append([]netip.AddrPort(nil), r.addrs...) + r.mu.Unlock() + return r.withMappedAddr(addrs) +} + +func (r *realmServerRuntime) withMappedAddr(addrs []netip.AddrPort) []netip.AddrPort { + if r.mapper == nil { + return addrs + } + return mergeMappedAddr(addrs, r.mapper.ExternalAddr()) } func (r *realmServerRuntime) respond(ctx context.Context, ev *realm.PunchEvent) { @@ -828,13 +860,14 @@ func (r *realmServerRuntime) refreshAddrsWith(ctx context.Context, discover func zap.Strings("addresses", addrPortStrings(current)), zap.Bool("changed", changed), zap.String("duration", formatLogDuration(time.Since(start)))) - return current, changed, nil + return r.withMappedAddr(current), changed, nil } func (r *realmServerRuntime) currentAddrs() []netip.AddrPort { r.mu.Lock() - defer r.mu.Unlock() - return append([]netip.AddrPort(nil), r.addrs...) + addrs := append([]netip.AddrPort(nil), r.addrs...) + r.mu.Unlock() + return r.withMappedAddr(addrs) } func sessionTTLDuration(ttl int) time.Duration { diff --git a/app/go.mod b/app/go.mod index 5def5a2..140f951 100644 --- a/app/go.mod +++ b/app/go.mod @@ -40,13 +40,19 @@ require ( github.com/go-ole/go-ole v1.3.0 // indirect github.com/go-viper/mapstructure/v2 v2.5.0 // indirect github.com/google/go-querystring v1.1.0 // indirect + github.com/google/gopacket v1.1.19 // indirect github.com/hashicorp/go-cleanhttp v0.5.2 // indirect github.com/hashicorp/go-retryablehttp v0.7.6 // indirect github.com/hashicorp/golang-lru/v2 v2.0.5 // indirect + github.com/huin/goupnp v1.2.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/jackpal/go-nat-pmp v1.0.2 // indirect github.com/klauspost/compress v1.17.9 // indirect github.com/klauspost/cpuid/v2 v2.1.1 // indirect + github.com/koron/go-ssdp v0.0.4 // indirect github.com/libdns/libdns v0.2.2 // indirect + github.com/libp2p/go-nat v1.0.1-0.20250821073202-01afc089f138 // indirect + github.com/libp2p/go-netroute v0.2.1 // indirect github.com/miekg/dns v1.1.59 // indirect github.com/patrickmn/go-cache v2.1.0+incompatible // indirect github.com/pelletier/go-toml/v2 v2.2.4 // indirect diff --git a/app/go.sum b/app/go.sum index 9c72033..7724ad7 100644 --- a/app/go.sum +++ b/app/go.sum @@ -35,6 +35,8 @@ github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8= github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU= +github.com/google/gopacket v1.1.19 h1:ves8RnFZPGiFnTS0uPQStjwru6uO6h+nlr9j6fL7kF8= +github.com/google/gopacket v1.1.19/go.mod h1:iJ8V8n6KS+z2U1A8pUwu8bW5SyEMkXJB8Yo/Vo+TKTo= github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ= github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48= github.com/hashicorp/go-hclog v1.6.3 h1:Qr2kF+eVWjTiYmU7Y31tYlP1h0q/X3Nl3tPGdaB11/k= @@ -43,12 +45,18 @@ github.com/hashicorp/go-retryablehttp v0.7.6 h1:TwRYfx2z2C4cLbXmT8I5PgP/xmuqASDy github.com/hashicorp/go-retryablehttp v0.7.6/go.mod h1:pkQpWZeYWskR+D1tR2O5OcBFOxfA7DoAO6xtkuQnHTk= github.com/hashicorp/golang-lru/v2 v2.0.5 h1:wW7h1TG88eUIJ2i69gaE3uNVtEPIagzhGvHgwfx2Vm4= github.com/hashicorp/golang-lru/v2 v2.0.5/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= +github.com/huin/goupnp v1.2.0 h1:uOKW26NG1hsSSbXIZ1IR7XP9Gjd1U8pnLaCMgntmkmY= +github.com/huin/goupnp v1.2.0/go.mod h1:gnGPsThkYa7bFi/KWmEysQRf48l2dvR5bxr2OFckNX8= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/jackpal/go-nat-pmp v1.0.2 h1:KzKSgb7qkJvOUTqYl9/Hg/me3pWgBmERKrTGD7BdWus= +github.com/jackpal/go-nat-pmp v1.0.2/go.mod h1:QPH045xvCAeXUZOxsnwmrtiCoxIr9eob+4orBN1SBKc= github.com/klauspost/compress v1.17.9 h1:6KIumPrER1LHsvBVuDa0r5xaG0Es51mhhB9BQB2qeMA= github.com/klauspost/compress v1.17.9/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw= github.com/klauspost/cpuid/v2 v2.1.1 h1:t0wUqjowdm8ezddV5k0tLWVklVuvLJpoHeb4WBdydm0= github.com/klauspost/cpuid/v2 v2.1.1/go.mod h1:RVVoqg1df56z8g3pUjL/3lE5UfnlrJX8tyFgg4nqhuY= +github.com/koron/go-ssdp v0.0.4 h1:1IDwrghSKYM7yLf7XCzbByg2sJ/JcNOZRXS2jczTwz0= +github.com/koron/go-ssdp v0.0.4/go.mod h1:oDXq+E5IL5q0U8uSBcoAXzTzInwy5lEgC91HoKtbmZk= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= @@ -71,6 +79,10 @@ github.com/libdns/namedotcom v0.3.3 h1:R10C7+IqQGVeC4opHHMiFNBxdNBg1bi65ZwqLESl+ github.com/libdns/namedotcom v0.3.3/go.mod h1:GbYzsAF2yRUpI0WgIK5fs5UX+kDVUPaYCFLpTnKQm0s= github.com/libdns/vultr v1.0.0 h1:W8B4+k2bm9ro3bZLSZV9hMOQI+uO6Svu+GmD+Olz7ZI= github.com/libdns/vultr v1.0.0/go.mod h1:8K1HJExcbeHS4YPkFHRZpqpXZzZ+DZAA0m0VikJgEqk= +github.com/libp2p/go-nat v1.0.1-0.20250821073202-01afc089f138 h1:YohuNPT/1k3VcThCQlBZ43PCPWPfMRS1zcxWBF2SLK8= +github.com/libp2p/go-nat v1.0.1-0.20250821073202-01afc089f138/go.mod h1:TXQg5tfSy+bUjnhT5728j5j/MBj7keIYqqZ1+8k/ui8= +github.com/libp2p/go-netroute v0.2.1 h1:V8kVrpD8GK0Riv15/7VN6RbUQ3URNZVosw7H2v9tksU= +github.com/libp2p/go-netroute v0.2.1/go.mod h1:hraioZr0fhBjG0ZRXJJ6Zj2IVEVNx6tDTFQfSmcq7mQ= github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= @@ -173,6 +185,7 @@ golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4 golang.org/x/exp v0.0.0-20240506185415-9bf2ced13842 h1:vr/HnozRka3pE4EsMEg1lgkXJkTFJCVUX+S/ZT6wYzM= golang.org/x/exp v0.0.0-20240506185415-9bf2ced13842/go.mod h1:XtvwrStGgqGPLc4cjQfWqZHG1YFdYs6swckp8vpsjnc= golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= +golang.org/x/lint v0.0.0-20200302205851-738671d3881b/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY= golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg= golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= @@ -226,6 +239,7 @@ golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGm golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20191216052735-49a3e744a425/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/tools v0.0.0-20200130002326-2f3ba24bd6e7/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= golang.org/x/tools v0.1.5/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= golang.org/x/tools v0.3.0/go.mod h1:/rWhSS2+zyEVwoJf8YAX6L2f0ntZ7Kn/mGgAWcipA5k= diff --git a/extras/go.mod b/extras/go.mod index 2374019..406bd5b 100644 --- a/extras/go.mod +++ b/extras/go.mod @@ -9,6 +9,7 @@ require ( github.com/apernet/quic-go v0.59.1-0.20260425001925-6c6cc9bcb716 github.com/database64128/tfo-go/v2 v2.2.2 github.com/hashicorp/golang-lru/v2 v2.0.5 + github.com/libp2p/go-nat v1.0.1-0.20250821073202-01afc089f138 github.com/miekg/dns v1.1.59 github.com/pion/stun/v3 v3.1.2 github.com/refraction-networking/utls v1.6.6 @@ -24,7 +25,12 @@ require ( github.com/cloudflare/circl v1.3.9 // indirect github.com/database64128/netx-go v0.0.0-20240905055117-62795b8b054a // indirect github.com/davecgh/go-spew v1.1.1 // indirect + github.com/google/gopacket v1.1.19 // indirect + github.com/huin/goupnp v1.2.0 // indirect + github.com/jackpal/go-nat-pmp v1.0.2 // indirect github.com/klauspost/compress v1.17.9 // indirect + github.com/koron/go-ssdp v0.0.4 // indirect + github.com/libp2p/go-netroute v0.2.1 // indirect github.com/patrickmn/go-cache v2.1.0+incompatible // indirect github.com/pion/dtls/v3 v3.1.2 // indirect github.com/pion/logging v0.2.4 // indirect diff --git a/extras/go.sum b/extras/go.sum index 3fa77c6..3c66aca 100644 --- a/extras/go.sum +++ b/extras/go.sum @@ -12,14 +12,26 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/gopacket v1.1.19 h1:ves8RnFZPGiFnTS0uPQStjwru6uO6h+nlr9j6fL7kF8= +github.com/google/gopacket v1.1.19/go.mod h1:iJ8V8n6KS+z2U1A8pUwu8bW5SyEMkXJB8Yo/Vo+TKTo= github.com/hashicorp/golang-lru/v2 v2.0.5 h1:wW7h1TG88eUIJ2i69gaE3uNVtEPIagzhGvHgwfx2Vm4= github.com/hashicorp/golang-lru/v2 v2.0.5/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= +github.com/huin/goupnp v1.2.0 h1:uOKW26NG1hsSSbXIZ1IR7XP9Gjd1U8pnLaCMgntmkmY= +github.com/huin/goupnp v1.2.0/go.mod h1:gnGPsThkYa7bFi/KWmEysQRf48l2dvR5bxr2OFckNX8= +github.com/jackpal/go-nat-pmp v1.0.2 h1:KzKSgb7qkJvOUTqYl9/Hg/me3pWgBmERKrTGD7BdWus= +github.com/jackpal/go-nat-pmp v1.0.2/go.mod h1:QPH045xvCAeXUZOxsnwmrtiCoxIr9eob+4orBN1SBKc= github.com/klauspost/compress v1.17.9 h1:6KIumPrER1LHsvBVuDa0r5xaG0Es51mhhB9BQB2qeMA= github.com/klauspost/compress v1.17.9/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw= +github.com/koron/go-ssdp v0.0.4 h1:1IDwrghSKYM7yLf7XCzbByg2sJ/JcNOZRXS2jczTwz0= +github.com/koron/go-ssdp v0.0.4/go.mod h1:oDXq+E5IL5q0U8uSBcoAXzTzInwy5lEgC91HoKtbmZk= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/libp2p/go-nat v1.0.1-0.20250821073202-01afc089f138 h1:YohuNPT/1k3VcThCQlBZ43PCPWPfMRS1zcxWBF2SLK8= +github.com/libp2p/go-nat v1.0.1-0.20250821073202-01afc089f138/go.mod h1:TXQg5tfSy+bUjnhT5728j5j/MBj7keIYqqZ1+8k/ui8= +github.com/libp2p/go-netroute v0.2.1 h1:V8kVrpD8GK0Riv15/7VN6RbUQ3URNZVosw7H2v9tksU= +github.com/libp2p/go-netroute v0.2.1/go.mod h1:hraioZr0fhBjG0ZRXJJ6Zj2IVEVNx6tDTFQfSmcq7mQ= github.com/miekg/dns v1.1.51/go.mod h1:2Z9d3CP1LQWihRZUf29mQ19yDThaI4DAYzte2CaQW5c= github.com/miekg/dns v1.1.59 h1:C9EXc/UToRwKLhK5wKU/I4QVsBUc8kE6MkHBkeypWZs= github.com/miekg/dns v1.1.59/go.mod h1:nZpewl5p6IvctfgrckopVx2OlSEHPRO/U4SYkRklrEk= @@ -57,15 +69,19 @@ go.uber.org/goleak v1.2.1/go.mod h1:qlT2yGI9QafXHhZZLxlSuNsMw3FFLxBr+tBRlmO1xH4= go.uber.org/mock v0.5.2 h1:LbtPTcP8A5k9WPXj54PPPbjcI4Y6lhyOZXn+VS7wNko= go.uber.org/mock v0.5.2/go.mod h1:wLlUxC2vVTPTaE3UD51E0BGOAElKrILxhVSDYQLld5o= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8= golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A= golang.org/x/exp v0.0.0-20240506185415-9bf2ced13842 h1:vr/HnozRka3pE4EsMEg1lgkXJkTFJCVUX+S/ZT6wYzM= golang.org/x/exp v0.0.0-20240506185415-9bf2ced13842/go.mod h1:XtvwrStGgqGPLc4cjQfWqZHG1YFdYs6swckp8vpsjnc= +golang.org/x/lint v0.0.0-20200302205851-738671d3881b/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY= +golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/mod v0.7.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.32.0 h1:9F4d3PHLljb6x//jOyokMv3eX+YDeepZSEo3mFJy93c= golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= @@ -73,11 +89,13 @@ golang.org/x/net v0.2.0/go.mod h1:KqCZLdyyvdV855qA2rE3GC2aiw5xGR5TEjj8smXukLY= golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o= golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= @@ -96,11 +114,13 @@ golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20200130002326-2f3ba24bd6e7/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= golang.org/x/tools v0.3.0/go.mod h1:/rWhSS2+zyEVwoJf8YAX6L2f0ntZ7Kn/mGgAWcipA5k= golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc= golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= google.golang.org/protobuf v1.34.1 h1:9ddQBjfCyZPOHPUiPxpYESBLc+T8P3E+Vo4IbKZgFWg= google.golang.org/protobuf v1.34.1/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/extras/realm/portmap.go b/extras/realm/portmap.go new file mode 100644 index 0000000..edd4d17 --- /dev/null +++ b/extras/realm/portmap.go @@ -0,0 +1,139 @@ +package realm + +import ( + "context" + "errors" + "fmt" + "net/netip" + "sync" + "time" + + "github.com/libp2p/go-nat" +) + +const ( + defaultPortMapTimeout = 10 * time.Second + defaultPortMapLifetime = 10 * time.Minute + + portMapDescription = "hysteria-realm" + portMapProtocol = "udp" +) + +var ErrInvalidPortMapConfig = errors.New("invalid port mapping config") + +type PortMapConfig struct { + Timeout time.Duration + Lifetime time.Duration +} + +func (c PortMapConfig) withDefaults() (PortMapConfig, error) { + if c.Timeout == 0 { + c.Timeout = defaultPortMapTimeout + } + if c.Timeout < 0 { + return c, fmt.Errorf("%w: timeout must not be negative", ErrInvalidPortMapConfig) + } + if c.Lifetime == 0 { + c.Lifetime = defaultPortMapLifetime + } + if c.Lifetime < 0 { + return c, fmt.Errorf("%w: lifetime must not be negative", ErrInvalidPortMapConfig) + } + return c, nil +} + +// PortMapper maintains a UDP port mapping on the local gateway via UPnP or +// NAT-PMP. It does not renew the mapping by itself; the caller is expected +// to call Renew periodically (typically every Lifetime/2). +type PortMapper struct { + gateway nat.NAT + internalPort int + config PortMapConfig + + mu sync.Mutex + externalAddr netip.AddrPort +} + +// NewPortMapper discovers the local gateway and maps internalPort for UDP. +// It blocks for up to 2x config.Timeout (discovery + mapping). +func NewPortMapper(ctx context.Context, internalPort int, config PortMapConfig) (*PortMapper, error) { + if internalPort <= 0 || internalPort > 65535 { + return nil, fmt.Errorf("%w: invalid internal port %d", ErrInvalidPortMapConfig, internalPort) + } + config, err := config.withDefaults() + if err != nil { + return nil, err + } + + discoverCtx, cancel := context.WithTimeout(ctx, config.Timeout) + gateway, err := nat.DiscoverGateway(discoverCtx) + cancel() + if err != nil { + return nil, fmt.Errorf("gateway discovery failed: %w", err) + } + + m := &PortMapper{ + gateway: gateway, + internalPort: internalPort, + config: config, + } + if _, err := m.Renew(ctx); err != nil { + return nil, err + } + return m, nil +} + +// Renew (re-)requests the port mapping and refreshes the external address. +// It reports whether the external address changed since the last call. +func (m *PortMapper) Renew(ctx context.Context) (bool, error) { + opCtx, cancel := context.WithTimeout(ctx, m.config.Timeout) + defer cancel() + externalPort, err := m.gateway.AddPortMapping(opCtx, portMapProtocol, m.internalPort, portMapDescription, m.config.Lifetime) + if err != nil { + return false, fmt.Errorf("add port mapping failed: %w", err) + } + externalIP, err := m.gateway.GetExternalAddress() + if err != nil { + return false, fmt.Errorf("get external address failed: %w", err) + } + addr, ok := netip.AddrFromSlice(externalIP) + if !ok || addr.IsUnspecified() || addr.IsLoopback() { + return false, fmt.Errorf("gateway returned unusable external address: %s", externalIP) + } + externalAddr := netip.AddrPortFrom(addr.Unmap(), uint16(externalPort)) + + m.mu.Lock() + changed := externalAddr != m.externalAddr + m.externalAddr = externalAddr + m.mu.Unlock() + return changed, nil +} + +// ExternalAddr returns the gateway's external IP and the mapped external port. +func (m *PortMapper) ExternalAddr() netip.AddrPort { + m.mu.Lock() + defer m.mu.Unlock() + return m.externalAddr +} + +// InternalPort returns the mapped local UDP port. +func (m *PortMapper) InternalPort() int { + return m.internalPort +} + +// Lifetime returns the effective mapping lease duration. +func (m *PortMapper) Lifetime() time.Duration { + return m.config.Lifetime +} + +// GatewayType returns the protocol used to talk to the gateway ("UPnP" or "NAT-PMP"). +func (m *PortMapper) GatewayType() string { + return m.gateway.Type() +} + +// Close removes the port mapping from the gateway. Best-effort. +func (m *PortMapper) Close() error { + ctx, cancel := context.WithTimeout(context.Background(), m.config.Timeout) + defer cancel() + return m.gateway.DeletePortMapping(ctx, portMapProtocol, m.internalPort) +} diff --git a/go.work.sum b/go.work.sum index b3b6316..8786716 100644 --- a/go.work.sum +++ b/go.work.sum @@ -37,7 +37,6 @@ github.com/apernet/quic-go v0.54.1-0.20260110201338-839e2640e302/go.mod h1:N1WIj github.com/apernet/quic-go v0.57.2-0.20260111184307-eec823306178/go.mod h1:N1WIjPphkqs4efXWuyDNQ6OjjIK04vM3h+bEgwV+eVU= github.com/apernet/quic-go v0.59.1-0.20260217092621-db4786c77a22 h1:00ziBGnLWQEcR9LThDwvxOznJJquJ9bYUdmBFnawLMU= github.com/apernet/quic-go v0.59.1-0.20260217092621-db4786c77a22/go.mod h1:Npbg8qBtAZlsAB3FWmqwlVh5jtVG6a4DlYsOylUpvzA= -github.com/apernet/quic-go v0.59.1-0.20260425001925-6c6cc9bcb716/go.mod h1:Npbg8qBtAZlsAB3FWmqwlVh5jtVG6a4DlYsOylUpvzA= github.com/armon/go-metrics v0.4.0 h1:yCQqn7dwca4ITXb+CbubHmedzaQYHhNhrEXLYUeEe8Q= github.com/armon/go-metrics v0.4.0/go.mod h1:E6amYzXo6aW1tqzoZGT755KkbgrJsSdpwZ+3JqfkOG4= github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973 h1:xJ4a3vCFaGF/jqvzLMYoU8P317H5OQ+Via4RmuPwCS0= @@ -107,6 +106,8 @@ github.com/google/go-github v17.0.0+incompatible h1:N0LgJ1j65A7kfXrZnUDaYCs/Sf4r github.com/google/go-github v17.0.0+incompatible/go.mod h1:zLgOLi98H3fifZn+44m+umXrS52loVEgC2AApnigrVQ= github.com/google/go-querystring v1.0.0 h1:Xkwi/a1rcvNg1PPYe5vI8GbeBY/jrVuDX5ASuANWTrk= github.com/google/go-querystring v1.0.0/go.mod h1:odCYkC5MyYFN7vkCjXpyrEuKhc/BUO6wN/zVPAxq5ck= +github.com/google/gopacket v1.1.19 h1:ves8RnFZPGiFnTS0uPQStjwru6uO6h+nlr9j6fL7kF8= +github.com/google/gopacket v1.1.19/go.mod h1:iJ8V8n6KS+z2U1A8pUwu8bW5SyEMkXJB8Yo/Vo+TKTo= github.com/google/martian v2.1.0+incompatible h1:/CP5g8u/VJHijgedC/Legn3BAbAaWPgecwXBIDzw5no= github.com/google/martian/v3 v3.1.0 h1:wCKgOCHuUEVfsaQLpPSJb7VdYCdTVZQAuOdYm1yc/60= github.com/google/renameio v0.1.0 h1:GOZbcHa3HfsPKPlmyPyN2KEohoMXOhdMbHrvbpl2QaA= @@ -137,7 +138,11 @@ github.com/hashicorp/golang-lru v0.5.4 h1:YDjusn29QI/Das2iO9M0BHnIbxPeyuCHsjMW+l github.com/hashicorp/golang-lru v0.5.4/go.mod h1:iADmTwqILo4mZ8BN3D2Q6+9jd8WM5uGBxy+E8yxSoD4= github.com/hashicorp/serf v0.10.1 h1:Z1H2J60yRKvfDYAOZLd2MU0ND4AH/WDz7xYHDWQsIPY= github.com/hashicorp/serf v0.10.1/go.mod h1:yL2t6BqATOLGc5HF7qbFkTfXoPIY0WZdWHfEvMqbG+4= +github.com/huin/goupnp v1.2.0 h1:uOKW26NG1hsSSbXIZ1IR7XP9Gjd1U8pnLaCMgntmkmY= +github.com/huin/goupnp v1.2.0/go.mod h1:gnGPsThkYa7bFi/KWmEysQRf48l2dvR5bxr2OFckNX8= github.com/ianlancetaylor/demangle v0.0.0-20200824232613-28f6c0f3b639 h1:mV02weKRL81bEnm8A0HT1/CAelMQDBuQIfLw8n+d6xI= +github.com/jackpal/go-nat-pmp v1.0.2 h1:KzKSgb7qkJvOUTqYl9/Hg/me3pWgBmERKrTGD7BdWus= +github.com/jackpal/go-nat-pmp v1.0.2/go.mod h1:QPH045xvCAeXUZOxsnwmrtiCoxIr9eob+4orBN1SBKc= github.com/jellevandenhooff/dkim v0.0.0-20150330215556-f50fe3d243e1 h1:ujPKutqRlJtcfWk6toYVYagwra7HQHbXOaS171b4Tg8= github.com/jellevandenhooff/dkim v0.0.0-20150330215556-f50fe3d243e1/go.mod h1:E0B/fFc00Y+Rasa88328GlI/XbtyysCtTHZS8h7IrBU= github.com/joho/godotenv v1.4.0 h1:3l4+N6zfMWnkbPEXKng2o2/MR5mSwTrBih4ZEkkz1lg= @@ -148,11 +153,17 @@ github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnr github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= github.com/jstemmer/go-junit-report v0.9.1 h1:6QPYqodiu3GuPL+7mfx+NwDdp2eTkp9IfEUpgAwUN0o= github.com/kisielk/gotool v1.0.0 h1:AV2c/EiW3KqPNT9ZKl07ehoAGi4C5/01Cfbblndcapg= +github.com/koron/go-ssdp v0.0.4 h1:1IDwrghSKYM7yLf7XCzbByg2sJ/JcNOZRXS2jczTwz0= +github.com/koron/go-ssdp v0.0.4/go.mod h1:oDXq+E5IL5q0U8uSBcoAXzTzInwy5lEgC91HoKtbmZk= github.com/kr/fs v0.1.0 h1:Jskdu9ieNAYnjxsi0LbQp1ulIKZV1LAFgK1tWhpZgl8= github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= github.com/kr/pty v1.1.1 h1:VkoXIwSboBpnk99O/KFauAEILuNHv5DVFKZMBN/gUgw= github.com/kr/pty v1.1.3 h1:/Um6a/ZmD5tF7peoOJ5oN5KMQ0DrGVQSXLNwyckutPk= github.com/kr/pty v1.1.3/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/libp2p/go-nat v0.2.0 h1:Tyz+bUFAYqGyJ/ppPPymMGbIgNRH+WqC5QrT5fKrrGk= +github.com/libp2p/go-nat v0.2.0/go.mod h1:3MJr+GRpRkyT65EpVPBstXLvOlAPzUVlG6Pwg9ohLJk= +github.com/libp2p/go-netroute v0.2.1 h1:V8kVrpD8GK0Riv15/7VN6RbUQ3URNZVosw7H2v9tksU= +github.com/libp2p/go-netroute v0.2.1/go.mod h1:hraioZr0fhBjG0ZRXJJ6Zj2IVEVNx6tDTFQfSmcq7mQ= github.com/lunixbochs/vtclean v1.0.0 h1:xu2sLAri4lGiovBDQKxl5mrXyESr3gUr5m5SM5+LVb8= github.com/lunixbochs/vtclean v1.0.0/go.mod h1:pHhQNgMf3btfWnGBVipUOjRYhoOsdGqdm/+2c2E2WMI= github.com/mailru/easyjson v0.0.0-20190312143242-1de009706dbe h1:W/GaMY0y69G4cFlmsC6B9sbuo2fP8OFP1ABjt4kPz+w=