-
-
Notifications
You must be signed in to change notification settings - Fork 1.5k
[client] Add unicast UPnP discovery for port forwarding. #6650
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from 1 commit
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,155 @@ | ||
| package upnp | ||
|
|
||
| import ( | ||
| "context" | ||
| "fmt" | ||
| "math" | ||
| "math/rand" | ||
| "net" | ||
| "sync" | ||
| "time" | ||
|
|
||
| "github.com/huin/goupnp" | ||
| "github.com/libp2p/go-nat" | ||
| ) | ||
|
|
||
| var _ nat.NAT = (*NAT)(nil) | ||
|
|
||
| // igdClient is the subset of the goupnp IGD service client API used for port mappings. | ||
| type igdClient interface { | ||
| GetExternalIPAddressCtx(ctx context.Context) (string, error) | ||
| AddPortMappingCtx(ctx context.Context, remoteHost string, externalPort uint16, protocol string, internalPort uint16, internalClient string, enabled bool, description string, leaseDuration uint32) error | ||
| DeletePortMappingCtx(ctx context.Context, remoteHost string, externalPort uint16, protocol string) error | ||
| GetNATRSIPStatusCtx(ctx context.Context) (rsipAvailable bool, natEnabled bool, err error) | ||
| } | ||
|
|
||
| // NAT implements the go-nat NAT interface using a UPnP IGD discovered via | ||
| // unicast SSDP. Port mapping semantics match go-nat's UPnP implementation: | ||
| // a random external port is chosen and remembered per internal port so | ||
| // renewals keep the same mapping. | ||
| type NAT struct { | ||
| client igdClient | ||
| natType string | ||
| root *goupnp.RootDevice | ||
|
|
||
| mu sync.Mutex | ||
| ports map[int]int // internal port -> mapped external port | ||
| } | ||
|
|
||
| // Type returns the kind of NAT port mapping service that is used. | ||
| func (n *NAT) Type() string { | ||
| return n.natType | ||
| } | ||
|
|
||
| // GetDeviceAddress returns the internal address of the gateway device. | ||
| func (n *NAT) GetDeviceAddress() (net.IP, error) { | ||
| addr, err := net.ResolveUDPAddr("udp4", n.root.URLBase.Host) | ||
| if err != nil { | ||
| return nil, err | ||
| } | ||
| return addr.IP, nil | ||
| } | ||
|
|
||
| // GetExternalAddress returns the external address of the gateway device. | ||
| func (n *NAT) GetExternalAddress() (net.IP, error) { | ||
| ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) | ||
| defer cancel() | ||
|
|
||
| ipString, err := n.client.GetExternalIPAddressCtx(ctx) | ||
| if err != nil { | ||
| return nil, err | ||
| } | ||
|
|
||
| ip := net.ParseIP(ipString) | ||
| if ip == nil { | ||
| return nil, nat.ErrNoExternalAddress | ||
| } | ||
| return ip, nil | ||
| } | ||
|
|
||
| // GetInternalAddress returns the local address used to reach the gateway device. | ||
| func (n *NAT) GetInternalAddress() (net.IP, error) { | ||
| conn, err := net.Dial("udp4", n.root.URLBase.Host) | ||
| if err != nil { | ||
| return nil, err | ||
| } | ||
| defer func() { | ||
| _ = conn.Close() | ||
| }() | ||
|
|
||
| localAddr, ok := conn.LocalAddr().(*net.UDPAddr) | ||
| if !ok { | ||
| return nil, nat.ErrNoInternalAddress | ||
| } | ||
| return localAddr.IP, nil | ||
| } | ||
|
|
||
| // AddPortMapping maps a port on the local host to an external port. | ||
| func (n *NAT) AddPortMapping(ctx context.Context, protocol string, internalPort int, description string, timeout time.Duration) (int, error) { | ||
| proto, err := mapProtocol(protocol) | ||
| if err != nil { | ||
| return 0, err | ||
| } | ||
|
|
||
| internalIP, err := n.GetInternalAddress() | ||
| if err != nil { | ||
| return 0, fmt.Errorf("get internal address: %w", err) | ||
| } | ||
| leaseDuration := uint32(timeout / time.Second) | ||
|
|
||
| n.mu.Lock() | ||
| externalPort := n.ports[internalPort] | ||
| n.mu.Unlock() | ||
|
|
||
| if externalPort > 0 { | ||
| err = n.client.AddPortMappingCtx(ctx, "", uint16(externalPort), proto, uint16(internalPort), internalIP.String(), true, description, leaseDuration) | ||
| if err == nil { | ||
| return externalPort, nil | ||
| } | ||
| } | ||
|
|
||
| for range 3 { | ||
| port := randomPort() | ||
| err = n.client.AddPortMappingCtx(ctx, "", uint16(port), proto, uint16(internalPort), internalIP.String(), true, description, leaseDuration) | ||
| if err == nil { | ||
| n.mu.Lock() | ||
| n.ports[internalPort] = port | ||
| n.mu.Unlock() | ||
| return port, nil | ||
| } | ||
| } | ||
| return 0, err | ||
| } | ||
|
|
||
| // DeletePortMapping removes a port mapping. | ||
| func (n *NAT) DeletePortMapping(ctx context.Context, protocol string, internalPort int) error { | ||
| proto, err := mapProtocol(protocol) | ||
| if err != nil { | ||
| return err | ||
| } | ||
|
|
||
| n.mu.Lock() | ||
| externalPort := n.ports[internalPort] | ||
| delete(n.ports, internalPort) | ||
| n.mu.Unlock() | ||
|
|
||
| if externalPort == 0 { | ||
| return nil | ||
| } | ||
| return n.client.DeletePortMappingCtx(ctx, "", uint16(externalPort), proto) | ||
| } | ||
|
Comment on lines
+124
to
+140
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift
Consider persisting the external port outside the ephemeral 🧰 Tools🪛 ast-grep (0.44.0)[warning] 138-138: Narrowing a non-constant integer to a smaller fixed-width type (int8/int16/int32, uint8/uint16/uint32) can silently overflow or wrap, yielding negative or truncated values that are dangerous in size, length, or index logic. Validate the source value is within the target type's range before converting (e.g. bounds-check, or use a checked helper), and avoid narrowing untrusted or len()/parsed values. (integer-overflow-narrowing-conversion-go) 🤖 Prompt for AI Agents |
||
|
|
||
| func mapProtocol(s string) (string, error) { | ||
| switch s { | ||
| case "udp": | ||
| return "UDP", nil | ||
| case "tcp": | ||
| return "TCP", nil | ||
| default: | ||
| return "", fmt.Errorf("invalid protocol: %s", s) | ||
| } | ||
| } | ||
|
|
||
| func randomPort() int { | ||
| return rand.Intn(math.MaxUint16-10000) + 10000 | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🩺 Stability & Availability | 🟠 Major | ⚡ Quick win
Unbounded host resolution on untrusted IGD data.
GetDeviceAddress/GetInternalAddressresolven.root.URLBase.Hostvianet.ResolveUDPAddr/net.Dialwith no timeout, unlikeGetExternalAddresswhich wraps its call in a 10s context.URLBase.Hostcomes from the untrusted device-description XML returned by whatever answered the SSDP search, so if it's a hostname (not a literal IP) this can trigger an unbounded DNS lookup.🛡️ Suggested fix avoiding DNS resolution entirely
func (n *NAT) GetDeviceAddress() (net.IP, error) { - addr, err := net.ResolveUDPAddr("udp4", n.root.URLBase.Host) + host, _, err := net.SplitHostPort(n.root.URLBase.Host) if err != nil { return nil, err } - return addr.IP, nil + ip := net.ParseIP(host) + if ip == nil { + return nil, fmt.Errorf("device URLBase host is not an IP: %s", host) + } + return ip, nil }Also applies to: 71-85
🤖 Prompt for AI Agents