@@ -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
2122type SearchSequenceItemType int
@@ -37,12 +38,14 @@ type SearchSequenceItem struct {
3738type PluginForwardEntry struct {
3839 domain string
3940 sequence []SearchSequenceItem
41+ viaProxy bool
4042}
4143
4244type 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+
425548func parseResolvConf (filename string ) (servers []string , warnings []string , err error ) {
426549 data , err := os .ReadFile (filename )
427550 if err != nil {
0 commit comments