mirror of
https://github.com/tailscale/tailscale.git
synced 2024-11-29 04:55:31 +00:00
tailcfg, ipn/ipnlocal, net/dns: forward exit node DNS on Unix to system DNS
Updates #1713 Change-Id: I4c073fec0992d9e01a9a4ce97087d5af0efdc68d Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
This commit is contained in:
parent
d9c21936c3
commit
135580a5a8
@ -344,3 +344,48 @@ func TestDNSConfigForNetmap(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestAllowExitNodeDNSProxyToServeName(t *testing.T) {
|
||||||
|
b := &LocalBackend{}
|
||||||
|
if b.allowExitNodeDNSProxyToServeName("google.com") {
|
||||||
|
t.Fatal("unexpected true on backend with nil NetMap")
|
||||||
|
}
|
||||||
|
|
||||||
|
b.netMap = &netmap.NetworkMap{
|
||||||
|
DNS: tailcfg.DNSConfig{
|
||||||
|
ExitNodeFilteredSet: []string{
|
||||||
|
".ts.net",
|
||||||
|
"some.exact.bad",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
// Allow by default:
|
||||||
|
{"google.com", true},
|
||||||
|
{"GOOGLE.com", true},
|
||||||
|
|
||||||
|
// Rejected by suffix:
|
||||||
|
{"foo.TS.NET", false},
|
||||||
|
{"foo.ts.net", false},
|
||||||
|
|
||||||
|
// Suffix doesn't match
|
||||||
|
{"ts.net", true},
|
||||||
|
|
||||||
|
// Rejected by exact match:
|
||||||
|
{"some.exact.bad", false},
|
||||||
|
{"SOME.EXACT.BAD", false},
|
||||||
|
|
||||||
|
// But a prefix is okay.
|
||||||
|
{"prefix-okay.some.exact.bad", true},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
got := b.allowExitNodeDNSProxyToServeName(tt.name)
|
||||||
|
if got != tt.want {
|
||||||
|
t.Errorf("for %q = %v; want %v", tt.name, got, tt.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
@ -3053,3 +3053,34 @@ func (b *LocalBackend) OfferingExitNode() bool {
|
|||||||
}
|
}
|
||||||
return def4 && def6
|
return def4 && def6
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// allowExitNodeDNSProxyToServeName reports whether the Exit Node DNS
|
||||||
|
// proxy is allowed to serve responses for the provided DNS name.
|
||||||
|
func (b *LocalBackend) allowExitNodeDNSProxyToServeName(name string) bool {
|
||||||
|
b.mu.Lock()
|
||||||
|
defer b.mu.Unlock()
|
||||||
|
nm := b.netMap
|
||||||
|
if nm == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
name = strings.ToLower(name)
|
||||||
|
for _, bad := range nm.DNS.ExitNodeFilteredSet {
|
||||||
|
if bad == "" {
|
||||||
|
// Invalid, ignore.
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if bad[0] == '.' {
|
||||||
|
// Entries beginning with a dot are suffix matches.
|
||||||
|
if dnsname.HasSuffix(name, bad) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// Otherwise entries are exact matches. They're
|
||||||
|
// guaranteed to be lowercase already.
|
||||||
|
if name == bad {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
@ -832,7 +832,7 @@ func (h *peerAPIHandler) handleDNSQuery(w http.ResponseWriter, r *http.Request)
|
|||||||
|
|
||||||
ctx, cancel := context.WithTimeout(r.Context(), arbitraryTimeout)
|
ctx, cancel := context.WithTimeout(r.Context(), arbitraryTimeout)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
res, err := h.ps.resolver.HandleExitNodeDNSQuery(ctx, q, h.remoteAddr)
|
res, err := h.ps.resolver.HandleExitNodeDNSQuery(ctx, q, h.remoteAddr, h.ps.b.allowExitNodeDNSProxyToServeName)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.logf("handleDNS fwd error: %v", err)
|
h.logf("handleDNS fwd error: %v", err)
|
||||||
if err := ctx.Err(); err != nil {
|
if err := ctx.Err(); err != nil {
|
||||||
@ -918,14 +918,19 @@ func writePrettyDNSReply(w io.Writer, res []byte) (err error) {
|
|||||||
j, _ := json.Marshal(struct {
|
j, _ := json.Marshal(struct {
|
||||||
Error string
|
Error string
|
||||||
}{err.Error()})
|
}{err.Error()})
|
||||||
|
j = append(j, '\n')
|
||||||
w.Write(j)
|
w.Write(j)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
var p dnsmessage.Parser
|
var p dnsmessage.Parser
|
||||||
if _, err := p.Start(res); err != nil {
|
hdr, err := p.Start(res)
|
||||||
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
if hdr.RCode != dnsmessage.RCodeSuccess {
|
||||||
|
return fmt.Errorf("DNS RCode = %v", hdr.RCode)
|
||||||
|
}
|
||||||
if err := p.SkipAllQuestions(); err != nil {
|
if err := p.SkipAllQuestions(); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
@ -580,9 +580,9 @@ func (f *forwarder) forward(query packet) error {
|
|||||||
// It either sends to responseChan and returns nil, or returns a
|
// It either sends to responseChan and returns nil, or returns a
|
||||||
// non-nil error (without sending to the channel).
|
// non-nil error (without sending to the channel).
|
||||||
//
|
//
|
||||||
// If backupResolvers are specified, they're used in the case that no
|
// If resolvers is non-empty, it's used explicitly (notably, for exit
|
||||||
// upstreams are available.
|
// node DNS proxy queries), otherwise f.resolvers is used.
|
||||||
func (f *forwarder) forwardWithDestChan(ctx context.Context, query packet, responseChan chan<- packet, backupResolvers ...resolverAndDelay) error {
|
func (f *forwarder) forwardWithDestChan(ctx context.Context, query packet, responseChan chan<- packet, resolvers ...resolverAndDelay) error {
|
||||||
metricDNSFwd.Add(1)
|
metricDNSFwd.Add(1)
|
||||||
domain, err := nameFromQuery(query.bs)
|
domain, err := nameFromQuery(query.bs)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@ -601,13 +601,12 @@ func (f *forwarder) forwardWithDestChan(ctx context.Context, query packet, respo
|
|||||||
|
|
||||||
clampEDNSSize(query.bs, maxResponseBytes)
|
clampEDNSSize(query.bs, maxResponseBytes)
|
||||||
|
|
||||||
resolvers := f.resolvers(domain)
|
|
||||||
if len(resolvers) == 0 {
|
if len(resolvers) == 0 {
|
||||||
resolvers = backupResolvers
|
resolvers = f.resolvers(domain)
|
||||||
}
|
if len(resolvers) == 0 {
|
||||||
if len(resolvers) == 0 {
|
metricDNSFwdErrorNoUpstream.Add(1)
|
||||||
metricDNSFwdErrorNoUpstream.Add(1)
|
return errNoUpstreams
|
||||||
return errNoUpstreams
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fq := &forwardQuery{
|
fq := &forwardQuery{
|
||||||
|
@ -8,11 +8,14 @@
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bufio"
|
"bufio"
|
||||||
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
|
"net"
|
||||||
|
"os"
|
||||||
"runtime"
|
"runtime"
|
||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
@ -20,12 +23,15 @@
|
|||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"go4.org/mem"
|
||||||
dns "golang.org/x/net/dns/dnsmessage"
|
dns "golang.org/x/net/dns/dnsmessage"
|
||||||
"inet.af/netaddr"
|
"inet.af/netaddr"
|
||||||
|
"tailscale.com/net/tsaddr"
|
||||||
"tailscale.com/types/dnstype"
|
"tailscale.com/types/dnstype"
|
||||||
"tailscale.com/types/logger"
|
"tailscale.com/types/logger"
|
||||||
"tailscale.com/util/clientmetric"
|
"tailscale.com/util/clientmetric"
|
||||||
"tailscale.com/util/dnsname"
|
"tailscale.com/util/dnsname"
|
||||||
|
"tailscale.com/util/lineread"
|
||||||
"tailscale.com/wgengine/monitor"
|
"tailscale.com/wgengine/monitor"
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -303,48 +309,83 @@ func (r *Resolver) NextResponse() (packet []byte, to netaddr.IPPort, err error)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// parseExitNodeQuery parses a DNS request packet.
|
||||||
|
// It returns nil if it's malformed or lacking a question.
|
||||||
|
func parseExitNodeQuery(q []byte) *response {
|
||||||
|
p := dnsParserPool.Get().(*dnsParser)
|
||||||
|
defer dnsParserPool.Put(p)
|
||||||
|
p.zeroParser()
|
||||||
|
defer p.zeroParser()
|
||||||
|
if err := p.parseQuery(q); err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return p.response()
|
||||||
|
}
|
||||||
|
|
||||||
// HandleExitNodeDNSQuery handles a DNS query that arrived from a peer
|
// HandleExitNodeDNSQuery handles a DNS query that arrived from a peer
|
||||||
// via the peerapi's DoH server. This is only used when the local
|
// via the peerapi's DoH server. This is only used when the local
|
||||||
// node is being an exit node.
|
// node is being an exit node.
|
||||||
func (r *Resolver) HandleExitNodeDNSQuery(ctx context.Context, q []byte, from netaddr.IPPort) (res []byte, err error) {
|
//
|
||||||
metricDNSQueryForPeer.Add(1)
|
// The provided allowName callback is whether a DNS query for a name
|
||||||
|
// (as found by parsing q) is allowed.
|
||||||
|
//
|
||||||
|
// In most (all?) cases, err will be nil. A bogus DNS query q will
|
||||||
|
// still result in a response DNS packet (saying there's a failure)
|
||||||
|
// and a nil error.
|
||||||
|
// TODO: figure out if we even need an error result.
|
||||||
|
func (r *Resolver) HandleExitNodeDNSQuery(ctx context.Context, q []byte, from netaddr.IPPort, allowName func(name string) bool) (res []byte, err error) {
|
||||||
|
metricDNSExitProxyQuery.Add(1)
|
||||||
ch := make(chan packet, 1)
|
ch := make(chan packet, 1)
|
||||||
|
|
||||||
err = r.forwarder.forwardWithDestChan(ctx, packet{q, from}, ch)
|
resp := parseExitNodeQuery(q)
|
||||||
if err == errNoUpstreams {
|
if resp == nil {
|
||||||
// Handle to the system resolver.
|
return nil, errors.New("bad query")
|
||||||
switch runtime.GOOS {
|
}
|
||||||
case "linux":
|
name := resp.Question.Name.String()
|
||||||
// Assume for now that we don't have an upstream because
|
if !allowName(name) {
|
||||||
// they're using systemd-resolved and we're in Split DNS mode
|
metricDNSExitProxyErrorName.Add(1)
|
||||||
// where we don't know the base config.
|
resp.Header.RCode = dns.RCodeRefused
|
||||||
//
|
return marshalResponse(resp)
|
||||||
// TODO(bradfitz): this is a lazy assumption. Do better, and
|
}
|
||||||
// maybe move the HandleExitNodeDNSQuery method to the dns.Manager
|
|
||||||
// instead? But this works for now.
|
switch runtime.GOOS {
|
||||||
err = r.forwarder.forwardWithDestChan(ctx, packet{q, from}, ch, resolverAndDelay{
|
default:
|
||||||
name: dnstype.Resolver{
|
return nil, errors.New("unsupported exit node OS")
|
||||||
Addr: "127.0.0.1:53",
|
case "windows":
|
||||||
},
|
// TODO: use DnsQueryEx and write to ch.
|
||||||
})
|
// See https://docs.microsoft.com/en-us/windows/win32/api/windns/nf-windns-dnsqueryex.
|
||||||
default:
|
return nil, errors.New("TODO: windows exit node suport")
|
||||||
// TODO(bradfitz): if we're on an exit node
|
case "darwin":
|
||||||
// on, say, Windows, we need to parse the DNS
|
// /etc/resolv.conf is a lie and only says one upstream DNS
|
||||||
// packet in q and call OS-native APIs for
|
// but for now that's probably good enough. Later we'll
|
||||||
// each question. But we'll want to strip out
|
// want to blend in everything from scutil --dns.
|
||||||
// questions for MagicDNS names probably, so
|
fallthrough
|
||||||
// they don't loop back into
|
case "linux", "freebsd", "openbsd", "illumos":
|
||||||
// 100.100.100.100. We don't want to resolve
|
nameserver, err := stubResolverForOS()
|
||||||
// MagicDNS names across Tailnets once we
|
if err != nil {
|
||||||
// permit sharing exit nodes.
|
r.logf("stubResolverForOS: %v", err)
|
||||||
//
|
metricDNSExitProxyErrorResolvConf.Add(1)
|
||||||
// For now, just return an error.
|
return nil, err
|
||||||
|
}
|
||||||
|
// TODO: more than 1 resolver from /etc/resolv.conf?
|
||||||
|
|
||||||
|
var resolvers []resolverAndDelay
|
||||||
|
if nameserver == tsaddr.TailscaleServiceIP() {
|
||||||
|
// If resolv.conf says 100.100.100.100, it's coming right back to us anyway
|
||||||
|
// so avoid the loop through the kernel and just do what we
|
||||||
|
// would've done anyway. By not passing any resolvers, the forwarder
|
||||||
|
// will use its default ones from our DNS config.
|
||||||
|
} else {
|
||||||
|
resolvers = []resolverAndDelay{{
|
||||||
|
name: dnstype.Resolver{Addr: net.JoinHostPort(nameserver.String(), "53")},
|
||||||
|
}}
|
||||||
|
}
|
||||||
|
|
||||||
|
err = r.forwarder.forwardWithDestChan(ctx, packet{q, from}, ch, resolvers...)
|
||||||
|
if err != nil {
|
||||||
|
metricDNSExitProxyErrorForward.Add(1)
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
metricDNSQueryForPeerError.Add(1)
|
|
||||||
return nil, err
|
|
||||||
}
|
}
|
||||||
select {
|
select {
|
||||||
case p, ok := <-ch:
|
case p, ok := <-ch:
|
||||||
@ -357,6 +398,59 @@ func (r *Resolver) HandleExitNodeDNSQuery(ctx context.Context, q []byte, from ne
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type resolvConfCache struct {
|
||||||
|
mod time.Time
|
||||||
|
size int64
|
||||||
|
ip netaddr.IP
|
||||||
|
// TODO: inode/dev?
|
||||||
|
}
|
||||||
|
|
||||||
|
// resolvConfCacheValue contains the most recent stat metadata and parsed
|
||||||
|
// version of /etc/resolv.conf.
|
||||||
|
var resolvConfCacheValue atomic.Value // of resolvConfCache
|
||||||
|
|
||||||
|
var errEmptyResolvConf = errors.New("resolv.conf has no nameservers")
|
||||||
|
|
||||||
|
// stubResolverForOS returns the IP address of the first nameserver in
|
||||||
|
// /etc/resolv.conf.
|
||||||
|
func stubResolverForOS() (ip netaddr.IP, err error) {
|
||||||
|
fi, err := os.Stat("/etc/resolv.conf")
|
||||||
|
if err != nil {
|
||||||
|
return netaddr.IP{}, err
|
||||||
|
}
|
||||||
|
cur := resolvConfCache{
|
||||||
|
mod: fi.ModTime(),
|
||||||
|
size: fi.Size(),
|
||||||
|
}
|
||||||
|
if c, ok := resolvConfCacheValue.Load().(resolvConfCache); ok && c.mod == cur.mod && c.size == cur.size {
|
||||||
|
return c.ip, nil
|
||||||
|
}
|
||||||
|
err = lineread.File("/etc/resolv.conf", func(line []byte) error {
|
||||||
|
if !ip.IsZero() {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
line = bytes.TrimSpace(line)
|
||||||
|
if len(line) == 0 || line[0] == '#' {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if mem.HasPrefix(mem.B(line), mem.S("nameserver ")) {
|
||||||
|
s := strings.TrimSpace(strings.TrimPrefix(string(line), "nameserver "))
|
||||||
|
ip, err = netaddr.ParseIP(s)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return netaddr.IP{}, err
|
||||||
|
}
|
||||||
|
if !ip.IsValid() {
|
||||||
|
return netaddr.IP{}, errEmptyResolvConf
|
||||||
|
}
|
||||||
|
cur.ip = ip
|
||||||
|
resolvConfCacheValue.Store(cur)
|
||||||
|
return ip, nil
|
||||||
|
}
|
||||||
|
|
||||||
// resolveLocal returns an IP for the given domain, if domain is in
|
// resolveLocal returns an IP for the given domain, if domain is in
|
||||||
// the local hosts map and has an IP corresponding to the requested
|
// the local hosts map and has an IP corresponding to the requested
|
||||||
// typ (A, AAAA, ALL).
|
// typ (A, AAAA, ALL).
|
||||||
@ -538,6 +632,7 @@ func (p *dnsParser) zeroParser() { p.parser = dns.Parser{} }
|
|||||||
// p.Question.
|
// p.Question.
|
||||||
func (p *dnsParser) parseQuery(query []byte) error {
|
func (p *dnsParser) parseQuery(query []byte) error {
|
||||||
defer p.zeroParser()
|
defer p.zeroParser()
|
||||||
|
p.zeroParser()
|
||||||
var err error
|
var err error
|
||||||
p.Header, err = p.parser.Start(query)
|
p.Header, err = p.parser.Start(query)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@ -837,8 +932,10 @@ func (r *Resolver) respond(query []byte) ([]byte, error) {
|
|||||||
metricDNSMagicDNSSuccessName = clientmetric.NewCounter("dns_query_magic_success_name")
|
metricDNSMagicDNSSuccessName = clientmetric.NewCounter("dns_query_magic_success_name")
|
||||||
metricDNSMagicDNSSuccessReverse = clientmetric.NewCounter("dns_query_magic_success_reverse")
|
metricDNSMagicDNSSuccessReverse = clientmetric.NewCounter("dns_query_magic_success_reverse")
|
||||||
|
|
||||||
metricDNSQueryForPeer = clientmetric.NewCounter("dns_query_peerapi")
|
metricDNSExitProxyQuery = clientmetric.NewCounter("dns_exit_node_query")
|
||||||
metricDNSQueryForPeerError = clientmetric.NewCounter("dns_query_peerapi_error")
|
metricDNSExitProxyErrorName = clientmetric.NewCounter("dns_exit_node_error_name")
|
||||||
|
metricDNSExitProxyErrorForward = clientmetric.NewCounter("dns_exit_node_error_forward")
|
||||||
|
metricDNSExitProxyErrorResolvConf = clientmetric.NewCounter("dns_exit_node_error_resolvconf")
|
||||||
|
|
||||||
metricDNSFwd = clientmetric.NewCounter("dns_query_fwd")
|
metricDNSFwd = clientmetric.NewCounter("dns_query_fwd")
|
||||||
metricDNSFwdDropBonjour = clientmetric.NewCounter("dns_query_fwd_drop_bonjour")
|
metricDNSFwdDropBonjour = clientmetric.NewCounter("dns_query_fwd_drop_bonjour")
|
||||||
|
@ -907,6 +907,21 @@ type DNSConfig struct {
|
|||||||
// ExtraRecords contains extra DNS records to add to the
|
// ExtraRecords contains extra DNS records to add to the
|
||||||
// MagicDNS config.
|
// MagicDNS config.
|
||||||
ExtraRecords []DNSRecord `json:",omitempty"`
|
ExtraRecords []DNSRecord `json:",omitempty"`
|
||||||
|
|
||||||
|
// ExitNodeFilteredSuffixes are the the DNS suffixes that the
|
||||||
|
// node, when being an exit node DNS proxy, should not answer.
|
||||||
|
//
|
||||||
|
// The entries do not contain trailing periods and are always
|
||||||
|
// all lowercase.
|
||||||
|
//
|
||||||
|
// If an entry starts with a period, it's a suffix match (but
|
||||||
|
// suffix ".a.b" doesn't match "a.b"; a prefix is required).
|
||||||
|
//
|
||||||
|
// If an entry does not start with a period, it's an exact
|
||||||
|
// match.
|
||||||
|
//
|
||||||
|
// Matches are case insensitive.
|
||||||
|
ExitNodeFilteredSet []string
|
||||||
}
|
}
|
||||||
|
|
||||||
// DNSRecord is an extra DNS record to add to MagicDNS.
|
// DNSRecord is an extra DNS record to add to MagicDNS.
|
||||||
|
@ -208,20 +208,22 @@ func (src *DNSConfig) Clone() *DNSConfig {
|
|||||||
dst.Nameservers = append(src.Nameservers[:0:0], src.Nameservers...)
|
dst.Nameservers = append(src.Nameservers[:0:0], src.Nameservers...)
|
||||||
dst.CertDomains = append(src.CertDomains[:0:0], src.CertDomains...)
|
dst.CertDomains = append(src.CertDomains[:0:0], src.CertDomains...)
|
||||||
dst.ExtraRecords = append(src.ExtraRecords[:0:0], src.ExtraRecords...)
|
dst.ExtraRecords = append(src.ExtraRecords[:0:0], src.ExtraRecords...)
|
||||||
|
dst.ExitNodeFilteredSet = append(src.ExitNodeFilteredSet[:0:0], src.ExitNodeFilteredSet...)
|
||||||
return dst
|
return dst
|
||||||
}
|
}
|
||||||
|
|
||||||
// A compilation failure here means this code must be regenerated, with the command at the top of this file.
|
// A compilation failure here means this code must be regenerated, with the command at the top of this file.
|
||||||
var _DNSConfigCloneNeedsRegeneration = DNSConfig(struct {
|
var _DNSConfigCloneNeedsRegeneration = DNSConfig(struct {
|
||||||
Resolvers []dnstype.Resolver
|
Resolvers []dnstype.Resolver
|
||||||
Routes map[string][]dnstype.Resolver
|
Routes map[string][]dnstype.Resolver
|
||||||
FallbackResolvers []dnstype.Resolver
|
FallbackResolvers []dnstype.Resolver
|
||||||
Domains []string
|
Domains []string
|
||||||
Proxied bool
|
Proxied bool
|
||||||
Nameservers []netaddr.IP
|
Nameservers []netaddr.IP
|
||||||
PerDomain bool
|
PerDomain bool
|
||||||
CertDomains []string
|
CertDomains []string
|
||||||
ExtraRecords []DNSRecord
|
ExtraRecords []DNSRecord
|
||||||
|
ExitNodeFilteredSet []string
|
||||||
}{})
|
}{})
|
||||||
|
|
||||||
// Clone makes a deep copy of RegisterResponse.
|
// Clone makes a deep copy of RegisterResponse.
|
||||||
|
Loading…
Reference in New Issue
Block a user