[PATCH wireguard-go] conn: allow StdNetBind to bind to specific host addresses

Mukul Sabharwal <[email protected]> Sun, 10 May 2026 19:58:10 +0000
Newsgroups com.zx2c4.lists.wireguard
Message-ID <[email protected]>
Currently StdNetBind opens its IPv4 and IPv6 sockets on the unspecified
address (0.0.0.0 and [::]). Embedders that want the WireGuard listener
restricted to a specific local address — for example an Android
application that wants its inbound peers to reach it only over Wi-Fi
and never over the cellular interface, or a host with multiple
interfaces where listening on a single one is preferable for routing
or security reasons — currently have to reimplement Bind from scratch
just to substitute a different bind address, losing the recvmmsg /
sendmmsg / GSO optimizations along the way.

Add a NewStdNetBindWithBindHost(host4, host6 string) constructor that
records optional per-family bind hosts on the StdNetBind. Open() passes
them through to listenNet, which now uses net.JoinHostPort instead of
the hardcoded ":port" wildcard. Empty strings preserve the existing
any-address behavior, so NewStdNetBind() is unchanged for existing
callers.

Tests verify that an explicit loopback host pins both v4 and v6
listeners to loopback, and that the default constructor still binds to
the unspecified address.

Signed-off-by: Mukul Sabharwal <[email protected]>
---
 conn/bind_std.go      | 35 ++++++++++++++++++++++++++++----
 conn/bind_std_test.go | 46 +++++++++++++++++++++++++++++++++++++++++++
 2 files changed, 77 insertions(+), 4 deletions(-)

diff --git a/conn/bind_std.go b/conn/bind_std.go
index f5c8816..9291b4b 100644
--- a/conn/bind_std.go
+++ b/conn/bind_std.go
@@ -46,10 +46,37 @@ type StdNetBind struct {
 
 	blackhole4 bool
 	blackhole6 bool
+
+	// Optional bind hosts for the IPv4 and IPv6 listeners. An empty
+	// string preserves the historical behavior of binding to the
+	// unspecified address (0.0.0.0 / [::]). These are read in Open()
+	// under mu and not mutated thereafter.
+	bindHost4 string
+	bindHost6 string
 }
 
 func NewStdNetBind() Bind {
+	return newStdNetBind("", "")
+}
+
+// NewStdNetBindWithBindHost returns a StdNetBind whose IPv4 / IPv6 listeners
+// bind to the supplied hosts instead of the unspecified address. An empty
+// string for either argument preserves the default any-address behavior for
+// that family. This is useful on hosts with multiple addresses where the
+// caller wants the WireGuard listener pinned to one of them — e.g. an
+// Android app that wants its inbound peers to reach it only over Wi-Fi
+// and never over the cellular interface.
+//
+// The hosts must parse as literal IP addresses (no DNS lookups are
+// performed). They are not validated until Open() is called.
+func NewStdNetBindWithBindHost(host4, host6 string) Bind {
+	return newStdNetBind(host4, host6)
+}
+
+func newStdNetBind(host4, host6 string) Bind {
 	return &StdNetBind{
+		bindHost4: host4,
+		bindHost6: host6,
 		udpAddrPool: sync.Pool{
 			New: func() any {
 				return &net.UDPAddr{
@@ -119,8 +146,8 @@ func (e *StdNetEndpoint) DstToString() string {
 	return e.AddrPort.String()
 }
 
-func listenNet(network string, port int) (*net.UDPConn, int, error) {
-	conn, err := listenConfig().ListenPacket(context.Background(), network, ":"+strconv.Itoa(port))
+func listenNet(network, host string, port int) (*net.UDPConn, int, error) {
+	conn, err := listenConfig().ListenPacket(context.Background(), network, net.JoinHostPort(host, strconv.Itoa(port)))
 	if err != nil {
 		return nil, 0, err
 	}
@@ -156,13 +183,13 @@ again:
 	var v4pc *ipv4.PacketConn
 	var v6pc *ipv6.PacketConn
 
-	v4conn, port, err = listenNet("udp4", port)
+	v4conn, port, err = listenNet("udp4", s.bindHost4, port)
 	if err != nil && !errors.Is(err, syscall.EAFNOSUPPORT) {
 		return nil, 0, err
 	}
 
 	// Listen on the same port as we're using for ipv4.
-	v6conn, port, err = listenNet("udp6", port)
+	v6conn, port, err = listenNet("udp6", s.bindHost6, port)
 	if uport == 0 && errors.Is(err, syscall.EADDRINUSE) && tries < 100 {
 		v4conn.Close()
 		tries++
diff --git a/conn/bind_std_test.go b/conn/bind_std_test.go
index 34a3c9a..e5d6650 100644
--- a/conn/bind_std_test.go
+++ b/conn/bind_std_test.go
@@ -27,6 +27,52 @@ func TestStdNetBindReceiveFuncAfterClose(t *testing.T) {
 	}
 }
 
+func TestStdNetBindWithBindHost(t *testing.T) {
+	bind := NewStdNetBindWithBindHost("127.0.0.1", "::1").(*StdNetBind)
+	if _, _, err := bind.Open(0); err != nil {
+		t.Fatal(err)
+	}
+	defer bind.Close()
+	if bind.ipv4 == nil {
+		t.Fatal("ipv4 listener not opened")
+	}
+	la4, ok := bind.ipv4.LocalAddr().(*net.UDPAddr)
+	if !ok {
+		t.Fatalf("ipv4 LocalAddr is not *net.UDPAddr: %T", bind.ipv4.LocalAddr())
+	}
+	if !la4.IP.IsLoopback() {
+		t.Errorf("ipv4 listener bound to %v, want loopback", la4.IP)
+	}
+	if bind.ipv6 != nil {
+		la6, ok := bind.ipv6.LocalAddr().(*net.UDPAddr)
+		if !ok {
+			t.Fatalf("ipv6 LocalAddr is not *net.UDPAddr: %T", bind.ipv6.LocalAddr())
+		}
+		if !la6.IP.IsLoopback() {
+			t.Errorf("ipv6 listener bound to %v, want loopback", la6.IP)
+		}
+	}
+}
+
+func TestStdNetBindDefaultBindHost(t *testing.T) {
+	// Empty host strings must preserve the historical wildcard behavior.
+	bind := NewStdNetBind().(*StdNetBind)
+	if _, _, err := bind.Open(0); err != nil {
+		t.Fatal(err)
+	}
+	defer bind.Close()
+	if bind.ipv4 == nil {
+		t.Fatal("ipv4 listener not opened")
+	}
+	la4, ok := bind.ipv4.LocalAddr().(*net.UDPAddr)
+	if !ok {
+		t.Fatalf("ipv4 LocalAddr is not *net.UDPAddr: %T", bind.ipv4.LocalAddr())
+	}
+	if !la4.IP.IsUnspecified() {
+		t.Errorf("ipv4 listener bound to %v, want unspecified", la4.IP)
+	}
+}
+
 func mockSetGSOSize(control *[]byte, gsoSize uint16) {
 	*control = (*control)[:cap(*control)]
 	binary.LittleEndian.PutUint16(*control, gsoSize)
-- 
2.53.0