Skip to content

Commit

Permalink
feat: plaintext dns forwarder
Browse files Browse the repository at this point in the history
  • Loading branch information
qdm12 committed Oct 24, 2024
1 parent 1576bc6 commit cfd3a94
Show file tree
Hide file tree
Showing 36 changed files with 680 additions and 18 deletions.
7 changes: 5 additions & 2 deletions .golangci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,12 @@ issues:
- path: pkg/provider/.*\.go
linters:
- dupl
- path: internal/config/.*\.go
linters:
- dupl
- linters:
- lll
source: "^\\t+(defaultDNSIPv6AddrPort|defaultDoTIPv6AddrPort|netip.AddrFrom16)\\(.+\\),$"
source: "^\\t+(defaultPlainIPv6AddrPort|defaultDoTIPv6AddrPort|netip.AddrFrom16)\\(.+\\),$"
- linters:
- ireturn
text: ".+returns interface \\(github\\.com\\/prometheus\\/client_golang\\/prometheus\\.[a-zA-Z]+\\)$"
Expand All @@ -22,7 +25,7 @@ issues:
text: "directive `\\/\\/nolint:ireturn` is unused for linter \"ireturn\""
- linters:
- goconst
path: pkg/do(t|h)/.+\.go
path: pkg/(plain|dot|doh)/.+\.go
text: "string `ipv6` has 3 occurrences, make it a constant"

linters:
Expand Down
2 changes: 2 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -73,8 +73,10 @@ ENV \
UPSTREAM_TYPE=DoT \
DOT_RESOLVERS=cloudflare,google \
DOH_RESOLVERS=cloudflare,google \
PLAIN_RESOLVERS=cloudflare,google \
DOT_TIMEOUT=3s \
DOH_TIMEOUT=3s \
PLAIN_TIMEOUT=3s \
LISTENING_ADDRESS=":53" \
LOG_LEVEL=info \
LOG_CALLER=hidden \
Expand Down
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -109,11 +109,13 @@ For example, the environment variable `UPSTREAM_TYPE` corresponds to the CLI fla

| Environment variable | Default | Description |
| --- | --- | --- |
| `UPSTREAM_TYPE` | `DoT` | Upstream DNS connection type: `DoT` for DNS over TLS or `DoH` for DNS over HTTPS |
| `UPSTREAM_TYPE` | `DoT` | Upstream DNS connection type: `DoT` for DNS over TLS, `DoH` for DNS over HTTPS or `plain` for plaintext |
| `DOT_RESOLVERS` | `cloudflare,google` | Comma separated list of DNS-over-TLS resolver providers from `cira family`, `cira private`, `cira protected`, `cleanbrowsing adult`, `cleanbrowsing family`, `cleanbrowsing security`, `cloudflare`, `cloudflare family`, `cloudflare security`, `google`, `libredns`, `quad9`, `quad9 secured`, `quad9 unsecured` and `quadrant` |
| `DOH_RESOLVERS` | `cloudflare,google` | Comma separated list of DNS-over-HTTPS resolver providers from `cira family`, `cira private`, `cira protected`, `cleanbrowsing adult`, `cleanbrowsing family`, `cleanbrowsing security`, `cloudflare`, `cloudflare family`, `cloudflare security`, `google`, `libredns`, `quad9`, `quad9 secured`, `quad9 unsecured` and `quadrant` |
| `PLAIN_RESOLVERS` | `cloudflare,google` | Comma separated list of DNS resolver providers from `cira family`, `cira private`, `cira protected`, `cleanbrowsing adult`, `cleanbrowsing family`, `cleanbrowsing security`, `cloudflare`, `cloudflare family`, `cloudflare security`, `google`, `libredns`, `quad9`, `quad9 secured`, `quad9 unsecured` and `quadrant` |
| `DOT_TIMEOUT` | `3s` | DNS over TLS dial timeout |
| `DOH_TIMEOUT` | `3s` | DNS over HTTPs exchange timeout |
| `PLAIN_TIMEOUT` | `3s` | Plain DNS exchange timeout |
| `BLOCK_MALICIOUS` | `on` | `on` or `off`, to block malicious IP addresses and malicious hostnames from being resolved |
| `BLOCK_SURVEILLANCE` | `off` | `on` or `off`, to block surveillance IP addresses and hostnames from being resolved |
| `BLOCK_ADS` | `off` | `on` or `off`, to block ads IP addresses and hostnames from being resolved |
Expand Down
67 changes: 67 additions & 0 deletions internal/config/plain.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
package config

import (
"fmt"
"time"

"github.com/qdm12/dns/v2/pkg/provider"
"github.com/qdm12/gosettings"
"github.com/qdm12/gosettings/reader"
"github.com/qdm12/gotree"
)

type Plain struct {
// UpstreamResolvers is a list of DNS upstream resolvers to use.
UpstreamResolvers []string
// Timeout is the maximum duration to wait for a response from
// upstream plaintext DNS servers. If left unset, it defaults to
// 1 second.
Timeout time.Duration
}

func (p *Plain) setDefaults() {
p.UpstreamResolvers = gosettings.DefaultSlice(p.UpstreamResolvers, []string{
provider.Cloudflare().Name,
provider.Google().Name,
})

p.Timeout = gosettings.DefaultComparable(p.Timeout, time.Second)
}

func (p *Plain) validate() (err error) {
err = checkUpstreamResolverNames(p.UpstreamResolvers)
if err != nil {
return fmt.Errorf("upstream resolvers: %w", err)
}

const minTimeout = time.Millisecond
if p.Timeout < minTimeout {
return fmt.Errorf("%w: %s must be at least %s",
ErrTimeoutTooSmall, p.Timeout, minTimeout)
}

return nil
}

func (p *Plain) String() string {
return p.ToLinesNode().String()
}

func (p *Plain) ToLinesNode() (node *gotree.Node) {
node = gotree.New("Plaintext:")

node.Appendf("Upstream resolvers: %s", andStrings(p.UpstreamResolvers))
node.Appendf("Request timeout: %s", p.Timeout)

return node
}

func (p *Plain) read(reader *reader.Reader) (err error) {
p.UpstreamResolvers = reader.CSV("PLAIN_RESOLVERS")
p.Timeout, err = reader.Duration("PLAIN_TIMEOUT")
if err != nil {
return err
}

return nil
}
12 changes: 11 additions & 1 deletion internal/config/settings.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ type Settings struct {
Cache Cache
DoH DoH
DoT DoT
Plain Plain
Log Log
MiddlewareLog MiddlewareLog
Metrics Metrics
Expand All @@ -45,6 +46,7 @@ func (s *Settings) SetDefaults() {
s.Cache.setDefaults()
s.DoH.setDefaults()
s.DoT.setDefaults()
s.Plain.setDefaults()
s.Log.setDefaults()
s.MiddlewareLog.setDefaults()
s.Metrics.setDefaults()
Expand All @@ -58,7 +60,7 @@ func (s *Settings) SetDefaults() {
var ErrUpdatePeriodTooShort = errors.New("update period is too short")

func (s *Settings) Validate() (err error) {
err = validate.IsOneOf(s.Upstream, "dot", "doh")
err = validate.IsOneOf(s.Upstream, "dot", "doh", "plain")
if err != nil {
return fmt.Errorf("upstream type: %w", err)
}
Expand All @@ -73,6 +75,7 @@ func (s *Settings) Validate() (err error) {
"cache": s.Cache.validate,
"DoH": s.DoH.validate,
"DoT": s.DoT.validate,
"plain": s.Plain.validate,
"log": s.Log.validate,
"middleware log": s.MiddlewareLog.validate,
"metrics": s.Metrics.validate,
Expand Down Expand Up @@ -110,6 +113,8 @@ func (s *Settings) ToLinesNode() (node *gotree.Node) {
node.AppendNode(s.DoT.ToLinesNode())
case "doh":
node.AppendNode(s.DoH.ToLinesNode())
case "plain":
node.AppendNode(s.Plain.ToLinesNode())
default:
panic(fmt.Sprintf("unknown upstream type: %s", s.Upstream))
}
Expand Down Expand Up @@ -161,6 +166,11 @@ func (s *Settings) Read(reader *reader.Reader, warner Warner) (err error) { //no
return fmt.Errorf("DoT settings: %w", err)
}

err = s.Plain.read(reader)
if err != nil {
return fmt.Errorf("plain settings: %w", err)
}

s.Log.read(reader)

err = s.MiddlewareLog.read(reader)
Expand Down
34 changes: 28 additions & 6 deletions internal/picker/picker.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,10 @@ func (p *Picker) DoTServer(servers []provider.DoTServer) provider.DoTServer {
return pickFromSlice(servers, p.rand)
}

func (p *Picker) PlainServer(servers []provider.PlainServer) provider.PlainServer {
return pickFromSlice(servers, p.rand)
}

func pickFromSlice[T any](slice []T, randSource *rand.Rand) (element T) { //nolint:ireturn
switch len(slice) {
case 0:
Expand All @@ -48,13 +52,31 @@ func pickFromSlice[T any](slice []T, randSource *rand.Rand) (element T) { //noli
// usually works on an IPv6 network, which is not true the other
// way around.
func (p *Picker) DoTAddrPort(server provider.DoTServer, ipv6 bool) netip.AddrPort {
count := len(server.IPv4)
return pickFromIPs(server.IPv4, server.IPv6, ipv6, p.rand)
}

// PlainAddrPort returns a randomly picked IP address and port
// from the given plain server. If ipv6 is true, IPv6 addresses
// are added to the pool of IP addresses to pick from, on top
// of all IPv4 addresses.
// Note IPv4 addresses are always in the pool of addresses,
// because some providers only have IPv4 addresses, and IPv4
// usually works on an IPv6 network, which is not true the other
// way around.
func (p *Picker) PlainAddrPort(server provider.PlainServer, ipv6 bool) netip.AddrPort {
return pickFromIPs(server.IPv4, server.IPv6, ipv6, p.rand)
}

func pickFromIPs(ipv4AddrPort, ipv6AddrPort []netip.AddrPort,
ipv6 bool, rand *rand.Rand,
) netip.AddrPort {
count := len(ipv4AddrPort)
if ipv6 {
count += len(server.IPv6)
count += len(ipv6AddrPort)
}
index := p.rand.Intn(count)
if index < len(server.IPv4) {
return server.IPv4[index]
index := rand.Intn(count)
if index < len(ipv4AddrPort) {
return ipv4AddrPort[index]
}
return server.IPv6[index-len(server.IPv4)]
return ipv6AddrPort[index-len(ipv4AddrPort)]
}
71 changes: 71 additions & 0 deletions pkg/plain/dialer.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
package plain

import (
"context"
"fmt"
"net"

"github.com/qdm12/dns/v2/internal/picker"
"github.com/qdm12/dns/v2/pkg/provider"
)

type Dialer struct {
picker *picker.Picker
servers []provider.PlainServer
ipv6 bool
netDialer *net.Dialer
warner Warner
metrics Metrics
}

func New(settings Settings) (dial *Dialer, err error) {
settings.SetDefaults()
err = settings.Validate()
if err != nil {
return nil, fmt.Errorf("validating settings: %w", err)
}

servers := make([]provider.PlainServer, len(settings.UpstreamResolvers))
for i, upstreamResolver := range settings.UpstreamResolvers {
servers[i] = upstreamResolver.Plain
}

return &Dialer{
picker: picker.New(),
servers: servers,
ipv6: settings.IPVersion == "ipv6",
netDialer: &net.Dialer{
Timeout: settings.Timeout,
},
warner: settings.Warner,
metrics: settings.Metrics,
}, nil
}

func (d *Dialer) String() string {
return "dns over plaintext"
}

func (d *Dialer) Dial(ctx context.Context, network, _ string) (
conn net.Conn, err error,
) {
serverAddress := pickAddress(d.picker, d.servers, d.ipv6)

udpConn, err := d.netDialer.DialContext(ctx, network, serverAddress)
if err != nil {
d.warner.Warn(err.Error())
d.metrics.PlainDialInc(serverAddress, "error")
return nil, err
}

d.metrics.PlainDialInc(serverAddress, "success")
return udpConn, nil
}

func pickAddress(picker *picker.Picker, servers []provider.PlainServer,
ipv6 bool,
) (address string) {
server := picker.PlainServer(servers)
addrPort := picker.PlainAddrPort(server, ipv6)
return addrPort.String()
}
41 changes: 41 additions & 0 deletions pkg/plain/dialer_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
package plain

import (
"testing"

"github.com/qdm12/dns/v2/internal/picker"
"github.com/qdm12/dns/v2/pkg/provider"
"github.com/stretchr/testify/assert"
)

func Test_pickAddress(t *testing.T) {
t.Parallel()

picker := picker.New()
servers := []provider.PlainServer{
provider.Cloudflare().Plain,
provider.Google().Plain,
}
const ipv6 = true

address := pickAddress(picker, servers, ipv6)

found := false
for _, server := range servers {
ips := server.IPv4
if ipv6 {
ips = append(ips, server.IPv6...)
}
for _, addrPort := range ips {
if addrPort.String() == address {
found = true
break
}
}
if found {
break
}
}

assert.True(t, found)
}
9 changes: 9 additions & 0 deletions pkg/plain/interfaces.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package plain

type Metrics interface {
PlainDialInc(address, outcome string)
}

type Warner interface {
Warn(s string)
}
10 changes: 10 additions & 0 deletions pkg/plain/metrics/noop/metrics.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
// Package noop defines a No-Op metric implementation for DoT.
package noop

type Metrics struct{}

func New() *Metrics {
return &Metrics{}
}

func (m *Metrics) PlainDialInc(string, string) {}
30 changes: 30 additions & 0 deletions pkg/plain/metrics/prometheus/counters.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
package prometheus

import (
"github.com/prometheus/client_golang/prometheus"
"github.com/qdm12/dns/v2/internal/metrics/prometheus/helpers"
prom "github.com/qdm12/dns/v2/pkg/metrics/prometheus"
)

type counters struct {
plainDial *prometheus.CounterVec
}

func newCounters(settings prom.Settings) (c *counters, err error) {
prefix := settings.Prefix
c = &counters{
plainDial: helpers.NewCounterVec(prefix, "plain_dials",
"Plain dials by address and outcome", []string{"address", "outcome"}),
}

err = helpers.Register(settings.Registry, c.plainDial)
if err != nil {
return nil, err
}

return c, nil
}

func (c *counters) PlainDialInc(address, outcome string) {
c.plainDial.WithLabelValues(address, outcome).Inc()
}
Loading

0 comments on commit cfd3a94

Please sign in to comment.