Skip to content

Commit c11e884

Browse files
committed
Support $PROXY in forwarding rules
Fixes DNSCrypt#2441
1 parent 3c9e7bf commit c11e884

2 files changed

Lines changed: 149 additions & 16 deletions

File tree

dnscrypt-proxy/example-forwarding-rules.txt

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,12 @@
1414
## resolv.conf syntax); only 'nameserver' lines are parsed, other
1515
## options are ignored; name of <file> mustn't contain any commas (,)
1616

17+
## A server list starting with the $PROXY: prefix is reached through the
18+
## proxy configured with the `proxy` option in the main configuration file,
19+
## which must then be set. The prefix applies to every server on the line,
20+
## and proxied forwarding always uses DNS-over-TCP. $PROXY: cannot be
21+
## combined with the keywords above.
22+
1723
## In order to enable this feature, the "forwarding_rules" property needs to
1824
## be set to this file name inside the main configuration file.
1925

@@ -42,6 +48,10 @@
4248
## Forward queries for example.com and *.example.com to 9.9.9.9 and 8.8.8.8
4349
# example.com 9.9.9.9,8.8.8.8
4450

51+
## Forward queries for example.org and *.example.org to 9.9.9.9 and 8.8.8.8,
52+
## reached over TCP through the proxy set with the `proxy` option
53+
# example.org $PROXY:9.9.9.9,8.8.8.8
54+
4555
## Forward queries to a resolver using IPv6
4656
# ipv6.example.com [2001:DB8::42]
4757

dnscrypt-proxy/plugin_forward.go

Lines changed: 139 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import (
1616
"codeberg.org/miekg/dns"
1717
"github.com/jedisct1/dlog"
1818
"github.com/lifenjoiner/dhcpdns"
19+
netproxy "golang.org/x/net/proxy"
1920
)
2021

2122
type SearchSequenceItemType int
@@ -37,12 +38,14 @@ type SearchSequenceItem struct {
3738
type PluginForwardEntry struct {
3839
domain string
3940
sequence []SearchSequenceItem
41+
viaProxy bool
4042
}
4143

4244
type PluginForward struct {
4345
forwardMap []PluginForwardEntry
4446
bootstrapResolvers []string
4547
dhcpdns []*dhcpdns.Detector
48+
proxyDialer netproxy.Dialer
4649

4750
// Hot-reloading support
4851
rwLock sync.RWMutex
@@ -65,6 +68,9 @@ func (plugin *PluginForward) Init(proxy *Proxy) error {
6568

6669
if proxy.xTransport != nil {
6770
plugin.bootstrapResolvers = proxy.xTransport.bootstrapResolvers
71+
if proxy.xTransport.proxyDialer != nil {
72+
plugin.proxyDialer = *proxy.xTransport.proxyDialer
73+
}
6874
}
6975

7076
lines, err := ReadTextFile(plugin.configFile)
@@ -121,6 +127,17 @@ func (plugin *PluginForward) parseForwardFile(lines string) (bool, []PluginForwa
121127
)
122128
}
123129
domain = strings.ToLower(domain)
130+
viaProxy := false
131+
if strings.HasPrefix(serversStr, "$PROXY:") {
132+
viaProxy = true
133+
serversStr = strings.TrimSpace(serversStr[len("$PROXY:"):])
134+
if plugin.proxyDialer == nil {
135+
return false, nil, fmt.Errorf(
136+
"Forwarding rule at line %d uses $PROXY:, but no proxy is available. The `proxy` option must be configured in the main configuration file",
137+
1+lineNo,
138+
)
139+
}
140+
}
124141
var sequence []SearchSequenceItem
125142
for server := range strings.SplitSeq(serversStr, ",") {
126143
server = strings.TrimSpace(server)
@@ -194,9 +211,26 @@ func (plugin *PluginForward) parseForwardFile(lines string) (bool, []PluginForwa
194211
}
195212
}
196213
}
214+
if viaProxy {
215+
if len(sequence) == 0 {
216+
return false, nil, fmt.Errorf(
217+
"Syntax error for a forwarding rule at line %d. $PROXY: requires at least one valid server address",
218+
1+lineNo,
219+
)
220+
}
221+
for i := range sequence {
222+
if sequence[i].typ != Explicit {
223+
return false, nil, fmt.Errorf(
224+
"Syntax error for a forwarding rule at line %d. $PROXY: can only be combined with explicit server IP addresses",
225+
1+lineNo,
226+
)
227+
}
228+
}
229+
}
197230
forwardMap = append(forwardMap, PluginForwardEntry{
198231
domain: domain,
199232
sequence: sequence,
233+
viaProxy: viaProxy,
200234
})
201235
}
202236

@@ -286,6 +320,7 @@ func (plugin *PluginForward) Eval(pluginsState *PluginsState, msg *dns.Msg) erro
286320
// Use read lock for thread-safe access to forwardMap
287321
plugin.rwLock.RLock()
288322
var sequence []SearchSequenceItem
323+
viaProxy := false
289324
for _, candidate := range plugin.forwardMap {
290325
candidateLen := len(candidate.domain)
291326
if candidateLen > qNameLen {
@@ -295,6 +330,7 @@ func (plugin *PluginForward) Eval(pluginsState *PluginsState, msg *dns.Msg) erro
295330
(candidateLen == qNameLen || (qName[qNameLen-candidateLen-1] == '.'))) ||
296331
(candidate.domain == ".") {
297332
sequence = candidate.sequence
333+
viaProxy = candidate.viaProxy
298334
break
299335
}
300336
}
@@ -377,30 +413,22 @@ func (plugin *PluginForward) Eval(pluginsState *PluginsState, msg *dns.Msg) erro
377413
break
378414
}
379415
tries--
380-
dlog.Debugf("Forwarding [%s] to [%s]", qName, server)
381-
client := dns.Client{}
382-
ctx, cancel := context.WithTimeout(context.Background(), pluginsState.timeout)
383416

384417
// Create a clean copy of the message without Extra section for forwarding
385418
forwardMsg := msg.Copy()
386419
forwardMsg.Extra = nil
387420
forwardMsg.Data = nil // Clear packed data so Exchange will re-pack without Extra
388421

389-
respMsg, _, err = client.Exchange(ctx, forwardMsg, pluginsState.serverProto, server)
390-
if err != nil && (respMsg == nil || !respMsg.Truncated) {
391-
cancel()
392-
continue
422+
if viaProxy {
423+
dlog.Debugf("Forwarding [%s] to [%s] using DNS-over-TCP through the configured proxy", qName, server)
424+
respMsg, err = plugin.exchangeViaProxy(forwardMsg, server, pluginsState.timeout)
425+
} else {
426+
dlog.Debugf("Forwarding [%s] to [%s]", qName, server)
427+
respMsg, err = plugin.exchangeDirect(forwardMsg, pluginsState.serverProto, server, pluginsState.timeout)
393428
}
394-
if respMsg != nil && respMsg.Truncated {
395-
cancel()
396-
ctx, cancel = context.WithTimeout(context.Background(), pluginsState.timeout)
397-
respMsg, _, err = client.Exchange(ctx, forwardMsg, "tcp", server)
398-
if err != nil {
399-
cancel()
400-
continue
401-
}
429+
if err != nil {
430+
continue
402431
}
403-
cancel()
404432
if err := validateResponseQuestion(forwardMsg, respMsg); err != nil {
405433
continue
406434
}
@@ -422,6 +450,101 @@ func (plugin *PluginForward) Eval(pluginsState *PluginsState, msg *dns.Msg) erro
422450
return err
423451
}
424452

453+
// exchangeDirect sends a query to the selected upstream server using the current
454+
// protocol, retrying a truncated UDP response over TCP.
455+
func (plugin *PluginForward) exchangeDirect(
456+
forwardMsg *dns.Msg,
457+
serverProto string,
458+
server string,
459+
timeout time.Duration,
460+
) (*dns.Msg, error) {
461+
client := dns.Client{}
462+
ctx, cancel := context.WithTimeout(context.Background(), timeout)
463+
respMsg, _, err := client.Exchange(ctx, forwardMsg, serverProto, server)
464+
cancel()
465+
if err != nil && (respMsg == nil || !respMsg.Truncated) {
466+
return nil, err
467+
}
468+
if respMsg != nil && respMsg.Truncated {
469+
ctx, cancel = context.WithTimeout(context.Background(), timeout)
470+
respMsg, _, err = client.Exchange(ctx, forwardMsg, "tcp", server)
471+
cancel()
472+
if err != nil {
473+
return nil, err
474+
}
475+
}
476+
return respMsg, nil
477+
}
478+
479+
// deadlineCappedConn caps every deadline set on the underlying connection, so helpers
480+
// that reset deadlines cannot extend I/O beyond the query timeout.
481+
type deadlineCappedConn struct {
482+
net.Conn
483+
deadline time.Time
484+
}
485+
486+
func (c *deadlineCappedConn) capped(t time.Time) time.Time {
487+
if t.IsZero() || t.After(c.deadline) {
488+
return c.deadline
489+
}
490+
return t
491+
}
492+
493+
func (c *deadlineCappedConn) SetDeadline(t time.Time) error {
494+
return c.Conn.SetDeadline(c.capped(t))
495+
}
496+
497+
func (c *deadlineCappedConn) SetReadDeadline(t time.Time) error {
498+
return c.Conn.SetReadDeadline(c.capped(t))
499+
}
500+
501+
func (c *deadlineCappedConn) SetWriteDeadline(t time.Time) error {
502+
return c.Conn.SetWriteDeadline(c.capped(t))
503+
}
504+
505+
// exchangeViaProxy performs a DNS-over-TCP exchange through the proxy configured with the
506+
// top-level `proxy` option. The connection is created per query and always closed here.
507+
func (plugin *PluginForward) exchangeViaProxy(
508+
forwardMsg *dns.Msg,
509+
server string,
510+
timeout time.Duration,
511+
) (*dns.Msg, error) {
512+
if plugin.proxyDialer == nil {
513+
return nil, errors.New("proxied forwarding requires the `proxy` option to be configured")
514+
}
515+
ctx, cancel := context.WithTimeout(context.Background(), timeout)
516+
defer cancel()
517+
var conn net.Conn
518+
var err error
519+
if contextDialer, ok := plugin.proxyDialer.(netproxy.ContextDialer); ok {
520+
conn, err = contextDialer.DialContext(ctx, "tcp", server)
521+
} else {
522+
conn, err = plugin.proxyDialer.Dial("tcp", server)
523+
}
524+
if err != nil {
525+
return nil, fmt.Errorf("unable to reach [%s] through the proxy: %w", server, err)
526+
}
527+
defer conn.Close()
528+
deadline, _ := ctx.Deadline()
529+
remaining := time.Until(deadline)
530+
if remaining <= 0 {
531+
return nil, fmt.Errorf("timeout while connecting to [%s] through the proxy", server)
532+
}
533+
// ExchangeWithConn dereferences the client transport and replaces the connection's
534+
// read and write deadlines with its own timeouts, so the transport must be non-nil
535+
// and the wrapper is what keeps the query timeout an end-to-end bound.
536+
conn = &deadlineCappedConn{Conn: conn, deadline: deadline}
537+
if err := conn.SetDeadline(deadline); err != nil {
538+
return nil, err
539+
}
540+
client := dns.Client{Transport: &dns.Transport{ReadTimeout: remaining, WriteTimeout: remaining}}
541+
respMsg, _, err := client.ExchangeWithConn(ctx, forwardMsg, conn)
542+
if err != nil {
543+
return nil, fmt.Errorf("proxied exchange with [%s] failed: %w", server, err)
544+
}
545+
return respMsg, nil
546+
}
547+
425548
func parseResolvConf(filename string) (servers []string, warnings []string, err error) {
426549
data, err := os.ReadFile(filename)
427550
if err != nil {

0 commit comments

Comments
 (0)