diff --git a/README.MD b/README.MD
index 9b65229f..f83a44ce 100644
--- a/README.MD
+++ b/README.MD
@@ -714,7 +714,7 @@ These settings are critical on the server:
| Parameter | Sample Value | Allowed Values / Real Behavior | Full Explanation |
| :--- | :--- | :--- | :--- |
-| `UDP_HOST` | `"0.0.0.0"` | if empty, this value is used | Address where the DNS server binds.
`0.0.0.0` means listen on all interfaces. |
+| `UDP_HOST` | `""` | empty for the family-neutral wildcard, or an unbracketed IP literal | Address where the DNS server binds.
The empty default lets Go choose a generic UDP wildcard. It accepts IPv4 and IPv6 through one socket where IPv4-mapped IPv6 is supported; otherwise Go selects an available family. Explicit `0.0.0.0` and `::` wildcards retain their IPv4 or IPv6 family where mapped dual-stack sockets are unavailable. A concrete address restricts the listener to that family. |
| `UDP_PORT` | `53` | `1..65535` | UDP port used by the server.
In most deployments this should remain `53` so resolvers can query it directly. |
| `UDP_READERS` | `4` | auto-default if `<=0` | Number of goroutines reading directly from the UDP socket.
A larger number may help on very busy servers, but beyond a point it only increases context switching. |
| `DNS_REQUEST_WORKERS` | `8` | auto-default if `<=0` | Number of workers that take requests from the front-door queue and pass them into the session/decode layer. |
diff --git a/README_ES.MD b/README_ES.MD
index 5de4f2a8..c5d8d1f9 100644
--- a/README_ES.MD
+++ b/README_ES.MD
@@ -716,7 +716,7 @@ Estos ajustes son críticos en el servidor:
| Parámetro | Valor de ejemplo | Valores permitidos / Comportamiento real | Explicación completa |
| :--- | :--- | :--- | :--- |
-| `UDP_HOST` | `"0.0.0.0"` | si está vacío, se usa este valor | Dirección donde se vincula el servidor DNS.
`0.0.0.0` significa escuchar en todas las interfaces. |
+| `UDP_HOST` | `""` | vacío para el comodín independiente de la familia, o un literal IP sin corchetes | Dirección donde se vincula el servidor DNS.
El valor vacío predeterminado permite que Go elija un comodín UDP genérico. Acepta IPv4 e IPv6 mediante un solo socket donde se admite IPv6 con mapeo de IPv4; de lo contrario, Go selecciona una familia disponible. Los comodines explícitos `0.0.0.0` y `::` conservan su familia IPv4 o IPv6 cuando no hay sockets de doble pila mapeados. Una dirección concreta limita el listener a esa familia. |
| `UDP_PORT` | `53` | `1..65535` | Puerto UDP usado por el servidor.
En la mayoría de los despliegues debe permanecer en `53` para que los resolutores puedan consultarlo directamente. |
| `UDP_READERS` | `4` | valor automático por defecto si es `<=0` | Número de goroutines que leen directamente del socket UDP.
Un número mayor puede ayudar en servidores muy concurridos, pero a partir de cierto punto solo aumenta el cambio de contexto. |
| `DNS_REQUEST_WORKERS` | `8` | valor automático por defecto si es `<=0` | Número de workers que toman solicitudes de la cola de entrada y las pasan a la capa de sesión/decodificación. |
diff --git a/README_FA.MD b/README_FA.MD
index a73ae7c8..b08c4b27 100644
--- a/README_FA.MD
+++ b/README_FA.MD
@@ -685,7 +685,7 @@ sudo journalctl -u masterdnsvpn-client -f
| پارامتر | مقدار نمونه | مقادیر مجاز / رفتار واقعی | توضیح کامل |
| :--- | :--- | :--- | :--- |
-| `UDP_HOST` | `"0.0.0.0"` | اگر خالی باشد همین مقدار استفاده میشود | آدرسی که سرور DNS روی آن bind میشود.
`0.0.0.0` یعنی روی همه interfaceها گوش بدهد. |
+| `UDP_HOST` | `""` | خالی برای wildcard مستقل از خانواده، یا IP بدون براکت | آدرسی که سرور DNS روی آن bind میشود.
مقدار خالی پیشفرض اجازه میدهد Go یک wildcard عمومی UDP انتخاب کند. در سیستمهای دارای پشتیبانی IPv6 نگاشتشده از IPv4، یک socket هر دو IPv4 و IPv6 را میپذیرد؛ در غیر این صورت Go یک خانوادهٔ موجود را انتخاب میکند. wildcardهای صریح `0.0.0.0` و `::` در نبود socket دوپشتهٔ نگاشتشده، خانوادهٔ IPv4 یا IPv6 خود را حفظ میکنند. یک آدرس مشخص، listener را به همان خانواده محدود میکند. |
| `UDP_PORT` | `53` | `1..65535` | پورت UDP سرور است.
بهطور معمول باید همان `53` باشد تا resolverها بتوانند مستقیماً به آن query بفرستند. |
| `UDP_READERS` | `4` | اگر `<=0` باشد auto-default | تعداد goroutineهای خواندن مستقیم از socket UDP.
عدد بالاتر در سرورهای پر ترافیک مفید است، ولی از یک حد به بعد فقط context switching را زیاد میکند. |
| `DNS_REQUEST_WORKERS` | `8` | اگر `<=0` باشد auto-default | تعداد workerهایی که requestهای ورودی را از front-door queue برمیدارند و به لایه session/decode میدهند. |
diff --git a/README_IT.MD b/README_IT.MD
index 21941e24..5c19f927 100644
--- a/README_IT.MD
+++ b/README_IT.MD
@@ -716,7 +716,7 @@ Queste impostazioni sono critiche sul server:
| Parametro | Valore di Esempio | Valori Consentiti / Comportamento Reale | Spiegazione Completa |
| :--- | :--- | :--- | :--- |
-| `UDP_HOST` | `"0.0.0.0"` | se vuoto, viene usato questo valore | Indirizzo su cui il server DNS si lega.
`0.0.0.0` significa ascoltare su tutte le interfacce. |
+| `UDP_HOST` | `""` | vuoto per il wildcard indipendente dalla famiglia, oppure un indirizzo IP letterale senza parentesi | Indirizzo su cui il server DNS si lega.
Il valore vuoto predefinito lascia che Go scelga un wildcard UDP generico. Accetta IPv4 e IPv6 tramite un solo socket dove è supportato IPv6 con mapping IPv4; altrimenti Go seleziona una famiglia disponibile. I wildcard espliciti `0.0.0.0` e `::` mantengono la propria famiglia IPv4 o IPv6 quando i socket dual-stack mappati non sono disponibili. Un indirizzo concreto limita il listener a quella famiglia. |
| `UDP_PORT` | `53` | `1..65535` | Porta UDP usata dal server.
Nella maggior parte delle distribuzioni questa dovrebbe rimanere `53` così che i resolver possano interrogarla direttamente. |
| `UDP_READERS` | `4` | predefinito automatico se `<=0` | Numero di goroutine che leggono direttamente dal socket UDP.
Un numero maggiore può essere d'aiuto su server molto trafficati, ma oltre un certo punto aumenta solo il context switching. |
| `DNS_REQUEST_WORKERS` | `8` | predefinito automatico se `<=0` | Numero di worker che prelevano le richieste dalla coda di ingresso e le passano al livello di sessione/decodifica. |
diff --git a/README_RU.MD b/README_RU.MD
index d7d06191..9607fca7 100644
--- a/README_RU.MD
+++ b/README_RU.MD
@@ -719,7 +719,7 @@ Copy-Item client_resolvers.simple client_resolvers.txt
| Парамент | Пример значения | Значения | Полное описание |
| :--- | :--- | :--- | :--- |
-| `UDP_HOST` | `"0.0.0.0"` | если поле пустое, используется значение по умолчанию. | Адрес, на котором привязан DNS-сервер.
`0.0.0.0` означает прослушивание на всех интерфейсах. |
+| `UDP_HOST` | `""` | пустое значение для wildcard без привязки к семейству или IP-литерал без скобок | Адрес, на котором привязан DNS-сервер.
Пустое значение по умолчанию позволяет Go выбрать универсальный UDP wildcard. При поддержке IPv4-mapped IPv6 один сокет принимает IPv4 и IPv6; иначе Go выбирает доступное семейство. Явные wildcard `0.0.0.0` и `::` сохраняют свое семейство IPv4 или IPv6, когда mapped dual-stack сокеты недоступны. Конкретный адрес ограничивает listener этим семейством. |
| `UDP_PORT` | `53` | `1..65535` | UDP-порт, используемый сервером.
В большинстве случаев должно быть `53`, чтобы резолверы могли напрямую отправлять запросы на этот порт. |
| `UDP_READERS` | `4` | По умолчанию если `<=0` | Количество горутин, осуществляющих чтение непосредственно из UDP-сокета.
Большее количество может помочь на очень загруженных серверах, но после определенного предела это только увеличивает количество переключений контекста. |
| `DNS_REQUEST_WORKERS` | `8` | По умолчанию если `<=0` | Количество обработчиков, которые принимают запросы из входной очереди и передают их на уровень сеанса/декодирования. |
diff --git a/README_ZH.MD b/README_ZH.MD
index 97b20612..64570125 100644
--- a/README_ZH.MD
+++ b/README_ZH.MD
@@ -716,7 +716,7 @@ Copy-Item client_resolvers.simple client_resolvers.txt
| 参数 | 示例值 | 允许的值 / 实际行为 | 完整说明 |
| :--- | :--- | :--- | :--- |
-| `UDP_HOST` | `"0.0.0.0"` | 若为空则使用此值 | DNS 服务器绑定的地址。
`0.0.0.0` 表示监听所有网络接口。 |
+| `UDP_HOST` | `""` | 留空表示与地址族无关的通配地址,或填写不带方括号的 IP 字面量 | DNS 服务器绑定的地址。
默认空值让 Go 选择通用 UDP 通配地址。在支持 IPv4 映射 IPv6 的系统上,一个套接字可同时接收 IPv4 和 IPv6;否则 Go 会选择可用的地址族。当映射双栈套接字不可用时,显式通配地址 `0.0.0.0` 和 `::` 会保留各自的 IPv4 或 IPv6 地址族。指定具体地址会将监听器限制为该地址族。 |
| `UDP_PORT` | `53` | `1..65535` | 服务器使用的 UDP 端口。
在大多数部署中应保持为 `53`,以便解析器能直接查询它。 |
| `UDP_READERS` | `4` | 若 `<=0` 则使用自动默认值 | 直接从 UDP 套接字读取的 goroutine 数量。
较大的数值在非常繁忙的服务器上可能有帮助,但超过某个点后只会增加上下文切换。 |
| `DNS_REQUEST_WORKERS` | `8` | 若 `<=0` 则使用自动默认值 | 从前门队列中取出请求并将其传入会话/解码层的工作线程数量。 |
diff --git a/internal/config/server.go b/internal/config/server.go
index 927e9b2a..0dfc1fb6 100644
--- a/internal/config/server.go
+++ b/internal/config/server.go
@@ -10,6 +10,7 @@ package config
import (
"flag"
"fmt"
+ "net"
"os"
"path/filepath"
"reflect"
@@ -114,7 +115,7 @@ type ServerConfigFlagBinder struct {
func defaultServerConfig() ServerConfig {
return ServerConfig{
ProtocolType: "SOCKS5",
- UDPHost: "0.0.0.0",
+ UDPHost: "",
UDPPort: 53,
UDPReaders: 4,
SocketBufferSize: 8 * 1024 * 1024,
@@ -281,8 +282,8 @@ func finalizeServerConfig(cfg ServerConfig) (ServerConfig, error) {
return cfg, fmt.Errorf("invalid PROTOCOL_TYPE: %q", cfg.ProtocolType)
}
- if cfg.UDPHost == "" {
- cfg.UDPHost = "0.0.0.0"
+ if cfg.UDPHost != "" && net.ParseIP(cfg.UDPHost) == nil {
+ return cfg, fmt.Errorf("invalid UDP_HOST %q: must be an unbracketed IP literal", cfg.UDPHost)
}
if cfg.UDPPort <= 0 || cfg.UDPPort > 65535 {
@@ -472,7 +473,7 @@ func finalizeServerConfig(cfg ServerConfig) (ServerConfig, error) {
}
func (c ServerConfig) Address() string {
- return fmt.Sprintf("%s:%d", c.UDPHost, c.UDPPort)
+ return net.JoinHostPort(c.UDPHost, strconv.Itoa(c.UDPPort))
}
func (c ServerConfig) DropLogInterval() time.Duration {
diff --git a/internal/config/server_test.go b/internal/config/server_test.go
index f489041e..86ba6dfc 100644
--- a/internal/config/server_test.go
+++ b/internal/config/server_test.go
@@ -15,6 +15,63 @@ import (
"testing"
)
+func TestServerConfigAddress(t *testing.T) {
+ tests := []struct {
+ name string
+ host string
+ want string
+ }{
+ {name: "family-neutral wildcard", host: "", want: ":53"},
+ {name: "IPv4", host: "0.0.0.0", want: "0.0.0.0:53"},
+ {name: "IPv6", host: "::", want: "[::]:53"},
+ {name: "concrete IPv6", host: "2001:db8::1", want: "[2001:db8::1]:53"},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ cfg := ServerConfig{UDPHost: tt.host, UDPPort: 53}
+ if got := cfg.Address(); got != tt.want {
+ t.Fatalf("Address()=%q want=%q", got, tt.want)
+ }
+ })
+ }
+}
+
+func TestServerConfigUDPHostValidation(t *testing.T) {
+ tests := []struct {
+ name string
+ host string
+ want string
+ wantErr bool
+ }{
+ {name: "empty uses family-neutral wildcard", want: ""},
+ {name: "IPv4 literal", host: "0.0.0.0", want: "0.0.0.0"},
+ {name: "IPv6 literal", host: "2001:db8::1", want: "2001:db8::1"},
+ {name: "non-IP value", host: "not-an-ip", wantErr: true},
+ {name: "bracketed IPv6 literal", host: "[::]", wantErr: true},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ cfg := defaultServerConfig()
+ cfg.UDPHost = tt.host
+ got, err := finalizeServerConfig(cfg)
+ if tt.wantErr {
+ if err == nil {
+ t.Fatalf("finalizeServerConfig(%q) unexpectedly succeeded", tt.host)
+ }
+ return
+ }
+ if err != nil {
+ t.Fatalf("finalizeServerConfig(%q) returned error: %v", tt.host, err)
+ }
+ if got.UDPHost != tt.want {
+ t.Fatalf("UDPHost=%q want=%q", got.UDPHost, tt.want)
+ }
+ })
+ }
+}
+
func TestLoadServerConfigWithOverridesAppliesFlagPrecedence(t *testing.T) {
dir := t.TempDir()
configPath := filepath.Join(dir, "server_config.toml")
@@ -270,6 +327,9 @@ func TestLoadServerConfigFromJSONBase64AppliesDefaults(t *testing.T) {
if cfg.UDPPort != 5301 {
t.Fatalf("unexpected JSON base64 UDP port: got=%d want=%d", cfg.UDPPort, 5301)
}
+ if cfg.UDPHost != "" {
+ t.Fatalf("unexpected default UDP host: got=%q want empty", cfg.UDPHost)
+ }
if cfg.MaxPacketsPerBatch != defaultServerConfig().MaxPacketsPerBatch {
t.Fatalf("expected default max packets per batch to apply: got=%d want=%d", cfg.MaxPacketsPerBatch, defaultServerConfig().MaxPacketsPerBatch)
}
diff --git a/internal/udpserver/server_runtime.go b/internal/udpserver/server_runtime.go
index 6cd98b20..0cf4364a 100644
--- a/internal/udpserver/server_runtime.go
+++ b/internal/udpserver/server_runtime.go
@@ -10,6 +10,7 @@ package udpserver
import (
"context"
"errors"
+ "fmt"
"net"
"sync"
"time"
@@ -28,8 +29,15 @@ func (s *Server) configureSocketBuffers(conn *net.UDPConn) {
}
func (s *Server) openUDPListeners() ([]*net.UDPConn, error) {
+ var ip net.IP
+ if s.cfg.UDPHost != "" {
+ ip = net.ParseIP(s.cfg.UDPHost)
+ if ip == nil {
+ return nil, fmt.Errorf("invalid UDP_HOST %q: must be an unbracketed IP literal", s.cfg.UDPHost)
+ }
+ }
addr := &net.UDPAddr{
- IP: net.ParseIP(s.cfg.UDPHost),
+ IP: ip,
Port: s.cfg.UDPPort,
}
desired := s.cfg.EffectiveUDPReaders()
diff --git a/internal/udpserver/server_runtime_linux_test.go b/internal/udpserver/server_runtime_linux_test.go
new file mode 100644
index 00000000..08084d72
--- /dev/null
+++ b/internal/udpserver/server_runtime_linux_test.go
@@ -0,0 +1,156 @@
+//go:build linux
+
+package udpserver
+
+import (
+ "net"
+ "testing"
+ "time"
+
+ "masterdnsvpn-go/internal/config"
+ "masterdnsvpn-go/internal/logger"
+)
+
+const dualStackTestTimeout = 2 * time.Second
+
+func TestDefaultUDPListenerIsDualStack(t *testing.T) {
+ probe, err := net.ListenUDP("udp", &net.UDPAddr{IP: net.IPv6unspecified})
+ if err != nil {
+ t.Skipf("IPv6 wildcard unavailable: %v", err)
+ }
+ defer probe.Close()
+ probeAddr := probe.LocalAddr().(*net.UDPAddr)
+ probeClient, err := net.DialUDP("udp4", nil, &net.UDPAddr{
+ IP: net.IPv4(127, 0, 0, 1),
+ Port: probeAddr.Port,
+ })
+ if err != nil {
+ t.Skipf("IPv4 loopback unavailable: %v", err)
+ }
+ defer probeClient.Close()
+ probeDeadline := time.Now().Add(dualStackTestTimeout)
+ if err := probe.SetReadDeadline(probeDeadline); err != nil {
+ t.Skipf("set mapped-IPv6 probe read deadline: %v", err)
+ }
+ if err := probeClient.SetWriteDeadline(probeDeadline); err != nil {
+ t.Skipf("set mapped-IPv6 probe write deadline: %v", err)
+ }
+ if _, err := probeClient.Write([]byte("probe")); err != nil {
+ t.Skipf("IPv4-mapped IPv6 unavailable: %v", err)
+ }
+ probeBuffer := make([]byte, 5)
+ n, _, err := probe.ReadFromUDP(probeBuffer)
+ if err != nil {
+ t.Skipf("IPv4-mapped IPv6 unavailable: %v", err)
+ }
+ if string(probeBuffer[:n]) != "probe" {
+ t.Fatalf("mapped-IPv6 probe received %q", probeBuffer[:n])
+ }
+
+ server := New(config.ServerConfig{
+ UDPHost: "",
+ UDPPort: 0,
+ UDPReaders: 1,
+ SocketBufferSize: 64 * 1024,
+ }, logger.New("Dual Stack UDP Test", "ERROR"), nil)
+ conns, err := server.openUDPListeners()
+ if err != nil {
+ t.Fatalf("openUDPListeners failed: %v", err)
+ }
+ if len(conns) == 0 {
+ t.Fatal("openUDPListeners returned no listeners")
+ }
+ listener := conns[0]
+ t.Cleanup(func() { _ = listener.Close() })
+ for i, conn := range conns {
+ localAddr := conn.LocalAddr().(*net.UDPAddr)
+ if localAddr.IP.To4() != nil {
+ t.Fatalf("wildcard listener %d is IPv4-only: %s", i, localAddr)
+ }
+ if i != 0 {
+ _ = conn.Close()
+ }
+ }
+ localAddr := listener.LocalAddr().(*net.UDPAddr)
+
+ tests := []struct {
+ name string
+ network string
+ ip net.IP
+ }{
+ {name: "IPv4", network: "udp4", ip: net.IPv4(127, 0, 0, 1)},
+ {name: "IPv6", network: "udp6", ip: net.IPv6loopback},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ target := &net.UDPAddr{IP: tt.ip, Port: localAddr.Port}
+ client, err := net.DialUDP(tt.network, nil, target)
+ if err != nil {
+ t.Fatalf("dial %s: %v", target, err)
+ }
+ defer client.Close()
+
+ deadline := time.Now().Add(dualStackTestTimeout)
+ if err := listener.SetDeadline(deadline); err != nil {
+ t.Fatalf("set listener deadline: %v", err)
+ }
+ if err := client.SetDeadline(deadline); err != nil {
+ t.Fatalf("set client deadline: %v", err)
+ }
+
+ payload := []byte(tt.name)
+ if _, err := client.Write(payload); err != nil {
+ t.Fatalf("write to %s: %v", target, err)
+ }
+ buffer := make([]byte, 64)
+ n, peer, err := listener.ReadFromUDP(buffer)
+ if err != nil {
+ t.Fatalf("read from %s: %v", target, err)
+ }
+ if string(buffer[:n]) != tt.name {
+ t.Fatalf("listener received %q want=%q", buffer[:n], payload)
+ }
+ if _, err := listener.WriteToUDP(buffer[:n], peer); err != nil {
+ t.Fatalf("reply to %s: %v", peer, err)
+ }
+ n, err = client.Read(buffer)
+ if err != nil {
+ t.Fatalf("read reply from %s: %v", target, err)
+ }
+ if string(buffer[:n]) != tt.name {
+ t.Fatalf("client received %q want=%q", buffer[:n], payload)
+ }
+ })
+ }
+}
+
+func TestExplicitIPv6WildcardUsesIPv6Socket(t *testing.T) {
+ probe, err := net.ListenUDP("udp6", &net.UDPAddr{IP: net.IPv6loopback})
+ if err != nil {
+ t.Skipf("IPv6 loopback unavailable: %v", err)
+ }
+ _ = probe.Close()
+
+ server := New(config.ServerConfig{
+ UDPHost: "::",
+ UDPPort: 0,
+ UDPReaders: 1,
+ SocketBufferSize: 64 * 1024,
+ }, logger.New("IPv6 Wildcard UDP Test", "ERROR"), nil)
+ conns, err := server.openUDPListeners()
+ if err != nil {
+ t.Fatalf("openUDPListeners failed: %v", err)
+ }
+ if len(conns) == 0 {
+ t.Fatal("openUDPListeners returned no listeners")
+ }
+ for _, conn := range conns {
+ t.Cleanup(func() { _ = conn.Close() })
+ }
+ for i, conn := range conns {
+ if localAddr := conn.LocalAddr().(*net.UDPAddr); localAddr.IP.To4() != nil {
+ t.Fatalf("explicit IPv6 wildcard listener %d became IPv4-only: %s", i, localAddr)
+ }
+ }
+}
diff --git a/internal/udpserver/server_runtime_test.go b/internal/udpserver/server_runtime_test.go
new file mode 100644
index 00000000..aa92ee04
--- /dev/null
+++ b/internal/udpserver/server_runtime_test.go
@@ -0,0 +1,18 @@
+package udpserver
+
+import (
+ "testing"
+
+ "masterdnsvpn-go/internal/config"
+)
+
+func TestOpenUDPListenersRejectsInvalidUDPHost(t *testing.T) {
+ for _, host := range []string{"not-an-ip", "[::]"} {
+ t.Run(host, func(t *testing.T) {
+ server := &Server{cfg: config.ServerConfig{UDPHost: host}}
+ if _, err := server.openUDPListeners(); err == nil {
+ t.Fatalf("openUDPListeners(%q) unexpectedly succeeded", host)
+ }
+ })
+ }
+}
diff --git a/server_config.toml.simple b/server_config.toml.simple
index b37d66b0..9234f58b 100644
--- a/server_config.toml.simple
+++ b/server_config.toml.simple
@@ -40,8 +40,10 @@ SUPPORTED_DOWNLOAD_COMPRESSION_TYPES = [0, 1, 2, 3]
# - Defines front-door queueing and worker limits before session handling.
# ------------------------------------------------------------------------------
-# UDP bind endpoint.
-UDP_HOST = "0.0.0.0"
+# UDP bind endpoint. Leave empty for Go's family-neutral wildcard, which uses
+# one dual-stack socket where IPv4-mapped IPv6 is supported and otherwise
+# selects an available family. Non-empty values must be unbracketed IP literals.
+UDP_HOST = ""
UDP_PORT = 53
# UDP readers, DNS workers, and front-door request queue are smart-sized