Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 18 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,18 @@ Using DoT:
-pubkey-file server.pub -domain t.example.com -listen 127.0.0.1:7000
```

Using multiple resolvers:

```sh
./vaydns-client \
-doh https://dns.google/dns-query \
-doh https://cloudflare-dns.com/dns-query \
-dot one.one.one.one:853 \
-pubkey-file server.pub -domain t.example.com -listen 127.0.0.1:7000
```

Repeat `-doh`, `-dot`, and `-udp` to enable multi-resolver mode. The client spreads queries across healthy resolvers, routes around resolvers that stop returning usable responses, and probes unhealthy resolvers for recovery. Use `-log-level debug` to see the per-resolver health table.

### 5. Test

```sh
Expand Down Expand Up @@ -143,13 +155,15 @@ sudo ip6tables -t nat -I PREROUTING -i eth0 -p udp --dport 53 -j REDIRECT --to-p

### Client flags

#### Transport (pick one)
#### Transport (one or more)

| Flag | Description |
| ----------- | ------------------------------------------------ |
| `-doh URL` | Use DNS over HTTPS with the given resolver URL |
| `-dot ADDR` | Use DNS over TLS with the given resolver address |
| `-udp ADDR` | Use plaintext UDP DNS (no covertness) |
| `-doh URL` | Use DNS over HTTPS with the given resolver URL. Repeatable |
| `-dot ADDR` | Use DNS over TLS with the given resolver address. Repeatable |
| `-udp ADDR` | Use plaintext UDP DNS (no covertness). Repeatable |

When more than one resolver is configured, VayDNS uses multi-resolver mode with round-robin selection, health-based routing, and recovery probes. Transport types may be mixed in one client command.

#### Required

Expand Down
113 changes: 39 additions & 74 deletions client/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,11 +16,17 @@
// t.InitiateSmuxSession()
// stream, _ := t.OpenStream() // returns net.Conn
// defer t.Close()
//
// Multi-resolver usage (spread queries across multiple DNS resolvers):
//
// r1, _ := client.NewResolver(client.ResolverTypeUDP, "8.8.8.8:53")
// r2, _ := client.NewResolver(client.ResolverTypeDOH, "https://1.1.1.1/dns-query")
// ts, _ := client.NewTunnelServer("t.example.com", "pubkey-hex")
// t, _ := client.NewTunnelMulti([]client.Resolver{r1, r2}, ts)
// t.ListenAndServe("127.0.0.1:7000")
package client

import (
"context"
"crypto/tls"
"errors"
"fmt"
"io"
Expand Down Expand Up @@ -190,7 +196,7 @@ func (ts *TunnelServer) effectiveMaxQnameLen() int {
// either call the step-by-step Initiate* methods (for embedding in frameworks
// like xray-core) or call ListenAndServe for a fully managed session.
type Tunnel struct {
Resolver Resolver
Resolvers []Resolver
TunnelServer TunnelServer

// Session configuration. Zero values use defaults.
Expand All @@ -217,11 +223,22 @@ type Tunnel struct {
remoteAddr net.Addr
}

// NewTunnel creates a Tunnel with the given resolver and server configuration.
// Zero-value fields use sensible defaults.
// NewTunnel creates a Tunnel with a single resolver and server configuration.
// For multiple resolvers, use NewTunnelMulti.
func NewTunnel(resolver Resolver, tunnelServer TunnelServer) (*Tunnel, error) {
return NewTunnelMulti([]Resolver{resolver}, tunnelServer)
}

// NewTunnelMulti creates a Tunnel with multiple resolvers and server
// configuration. When more than one resolver is provided, the client
// multiplexes queries across them with health-based routing.
// Zero-value fields use sensible defaults.
func NewTunnelMulti(resolvers []Resolver, tunnelServer TunnelServer) (*Tunnel, error) {
if len(resolvers) == 0 {
return nil, fmt.Errorf("at least one resolver is required")
}
t := &Tunnel{
Resolver: resolver,
Resolvers: resolvers,
TunnelServer: tunnelServer,
}
t.wireConfig = tunnelServer.wireConfig()
Expand Down Expand Up @@ -293,79 +310,28 @@ func (t *Tunnel) effectiveKCPWindowSize() int {
// InitiateResolverConnection creates the underlying transport connection
// based on the Resolver configuration.
func (t *Tunnel) InitiateResolverConnection() error {
r := t.Resolver
switch r.ResolverType {
case ResolverTypeUDP:
addr, err := net.ResolveUDPAddr("udp", r.ResolverAddr)
if err != nil {
return err
}
t.remoteAddr = addr
if r.UDPSharedSocket {
lc := net.ListenConfig{Control: r.DialerControl}
conn, err := lc.ListenPacket(context.Background(), "udp", ":0")
if err != nil {
return err
}
t.resolverConn = conn
} else {
workers := r.UDPWorkers
if workers <= 0 {
workers = DefaultUDPWorkers
}
timeout := r.UDPTimeout
if timeout <= 0 {
timeout = DefaultUDPResponseTimeout
}
conn, forgedStats, err := NewUDPPacketConn(addr, r.DialerControl, workers, timeout, !r.UDPAcceptErrors, t.effectivePacketQueueSize(), t.effectiveQueueOverflowMode())
if err != nil {
return err
}
t.forgedStats = forgedStats
t.resolverConn = conn
}
return nil

case ResolverTypeDOH:
t.remoteAddr = turbotunnel.DummyAddr{}
var rt http.RoundTripper
if r.RoundTripper != nil {
rt = r.RoundTripper
} else if r.UTLSClientHelloID != nil {
rt = NewUTLSRoundTripper(nil, r.UTLSClientHelloID)
} else {
rt = http.DefaultTransport
}
conn, err := NewHTTPPacketConn(rt, r.ResolverAddr, 8, t.effectivePacketQueueSize(), t.effectiveQueueOverflowMode())
if len(t.Resolvers) > 1 {
conn, err := NewMultiResolver(t.Resolvers, t.effectivePacketQueueSize(), t.effectiveQueueOverflowMode())
if err != nil {
return err
}
t.resolverConn = conn
return nil

case ResolverTypeDOT:
t.remoteAddr = turbotunnel.DummyAddr{}
var dialTLSContext func(ctx context.Context, network, addr string) (net.Conn, error)
if r.UTLSClientHelloID != nil {
id := r.UTLSClientHelloID
dialTLSContext = func(ctx context.Context, network, addr string) (net.Conn, error) {
return UTLSDialContext(ctx, network, addr, nil, id)
}
} else {
dialTLSContext = func(ctx context.Context, network, addr string) (net.Conn, error) {
return tls.DialWithDialer(&net.Dialer{}, network, addr, nil)
}
}
conn, err := NewTLSPacketConn(r.ResolverAddr, dialTLSContext, t.effectivePacketQueueSize(), t.effectiveQueueOverflowMode())
if err != nil {
return err
}
t.resolverConn = conn
// In multi-resolver mode, each entry owns a per-resolver
// ForgedStats. t.forgedStats stays nil so DNSPacketConn
// creates an unlabeled catch-all (which should rarely fire
// since MultiResolver filters forged responses upstream).
return nil

default:
return fmt.Errorf("unsupported resolver type: %s", r.ResolverType)
}
r := t.Resolvers[0]
t.forgedStats = &ForgedStats{Label: r.ResolverAddr}
conn, addr, err := GetResolverConnection(r, t.effectivePacketQueueSize(), t.effectiveQueueOverflowMode(), t.forgedStats)
if err != nil {
return err
}
t.resolverConn = conn
t.remoteAddr = addr
return nil
}

// InitiateDNSPacketConn wraps the resolver connection with DNS encoding.
Expand Down Expand Up @@ -882,10 +848,9 @@ func NewOutbound(resolvers []Resolver, tunnelServers []TunnelServer) *Outbound {
// Start begins accepting connections on bind and forwarding them through the
// first resolver/server pair.
func (o *Outbound) Start(bind string) error {
resolver := o.Resolvers[0]
tunnelServer := o.TunnelServers[0]

tunnel, err := NewTunnel(resolver, tunnelServer)
tunnel, err := NewTunnelMulti(o.Resolvers, tunnelServer)
Comment on lines 848 to +853
if err != nil {
return fmt.Errorf("failed to create tunnel: %w", err)
}
Expand Down
31 changes: 23 additions & 8 deletions client/dns.go
Original file line number Diff line number Diff line change
Expand Up @@ -100,10 +100,17 @@ func (rl *RateLimiter) Wait() {
}
}

// ForgedStats tracks forged DNS response counters. It is shared between
// UDPPacketConn (per-query mode) and DNSPacketConn (shared socket mode) so
// that forged response visibility is consistent regardless of transport.
// ForgedStats tracks forged DNS response counters for a specific source.
// In single-resolver mode, one instance is shared between UDPPacketConn
// (per-query mode) and DNSPacketConn so milestone logs fire on a unified
// count. In multi-resolver mode, each entry holds its own labeled instance
// so operators can see which resolver is being targeted by injection.
type ForgedStats struct {
// Label identifies the counter's source in log lines — typically the
// resolver address (e.g. "8.8.8.8:53"). An empty label is allowed
// and produces an unlabeled log line (used by DNSPacketConn's catch-all
// safety net when no resolver attribution is available).
Label string
Total uint64
SERVFAIL uint64
NXDOMAIN uint64
Expand All @@ -124,11 +131,19 @@ func (s *ForgedStats) Record(rcode uint16) {
}
total := atomic.AddUint64(&s.Total, 1)
if forgedInfoMilestone(total) {
log.Infof("forged DNS responses: total=%d, SERVFAIL=%d, NXDOMAIN=%d, other=%d",
total,
atomic.LoadUint64(&s.SERVFAIL),
atomic.LoadUint64(&s.NXDOMAIN),
atomic.LoadUint64(&s.Other))
if s.Label != "" {
log.Infof("forged DNS responses from %s: total=%d, SERVFAIL=%d, NXDOMAIN=%d, other=%d",
s.Label, total,
atomic.LoadUint64(&s.SERVFAIL),
atomic.LoadUint64(&s.NXDOMAIN),
atomic.LoadUint64(&s.Other))
} else {
log.Infof("forged DNS responses: total=%d, SERVFAIL=%d, NXDOMAIN=%d, other=%d",
total,
atomic.LoadUint64(&s.SERVFAIL),
atomic.LoadUint64(&s.NXDOMAIN),
atomic.LoadUint64(&s.Other))
}
}
}

Expand Down
Loading
Loading