diff --git a/CHANGELOG.md b/CHANGELOG.md index 39124a66..b67bc8cc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,11 @@ See also the [v0.107.41 GitHub milestone][ms-v0.107.41]. NOTE: Add new changes BELOW THIS COMMENT. --> +### Added + +- Ability to specify multiple domain specific upstreams per line, e.g. + `[/domain1/../domain2/]upstream1 upstream2 .. upstreamN` ([#4977]). + ### Fixed - `$important,dnsrewrite` rules do not take precedence over allowlist rules @@ -30,6 +35,7 @@ NOTE: Add new changes BELOW THIS COMMENT. - Dark mode DNS rewrite background ([#6329]). - Issues with QUIC and HTTP/3 upstreams on Linux ([#6335]). +[#4977]: https://github.com/AdguardTeam/AdGuardHome/issues/4977 [#6204]: https://github.com/AdguardTeam/AdGuardHome/issues/6204 [#6329]: https://github.com/AdguardTeam/AdGuardHome/issues/6329 [#6335]: https://github.com/AdguardTeam/AdGuardHome/issues/6335 diff --git a/client/src/__locales/en.json b/client/src/__locales/en.json index 5f5a9ca8..f7c572ef 100644 --- a/client/src/__locales/en.json +++ b/client/src/__locales/en.json @@ -1,6 +1,7 @@ { "client_settings": "Client settings", "example_upstream_reserved": "an upstream <0>for specific domains;", + "example_multiple_upstreams_reserved": "multiple upstreams <0>for specific domains;", "example_upstream_comment": "a comment.", "upstream_parallel": "Use parallel queries to speed up resolving by querying all upstream servers simultaneously.", "parallel_requests": "Parallel requests", diff --git a/client/src/components/Settings/Dns/Upstream/Examples.js b/client/src/components/Settings/Dns/Upstream/Examples.js index a975e444..c35d65cc 100644 --- a/client/src/components/Settings/Dns/Upstream/Examples.js +++ b/client/src/components/Settings/Dns/Upstream/Examples.js @@ -137,6 +137,22 @@ const Examples = (props) => ( example_upstream_reserved +
  • + [/example.local/]94.140.14.140 2a10:50c0::1:ff: + Link + , + ]} + > + example_multiple_upstreams_reserved + +
  • {COMMENT_LINE_DEFAULT_TOKEN} comment: example_upstream_comment diff --git a/go.mod b/go.mod index 19be8199..289d740c 100644 --- a/go.mod +++ b/go.mod @@ -3,7 +3,7 @@ module github.com/AdguardTeam/AdGuardHome go 1.20 require ( - github.com/AdguardTeam/dnsproxy v0.56.2 + github.com/AdguardTeam/dnsproxy v0.56.3 github.com/AdguardTeam/golibs v0.17.2 github.com/AdguardTeam/urlfilter v0.17.3 github.com/NYTimes/gziphandler v1.1.1 diff --git a/go.sum b/go.sum index 9fda18ac..11cd3cca 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,5 @@ -github.com/AdguardTeam/dnsproxy v0.56.2 h1:+k1iUmp05QIqkgXWyPn70fki4FouHe6vHIyHguelKao= -github.com/AdguardTeam/dnsproxy v0.56.2/go.mod h1:ZvkbM71HwpilgkCnTubDiR4Ba6x5Qvnhy2iasMWaTDM= +github.com/AdguardTeam/dnsproxy v0.56.3 h1:WP1FooLfZQPHEH2SuwMtJsOurDt32rubGx0OddcsKT0= +github.com/AdguardTeam/dnsproxy v0.56.3/go.mod h1:ZvkbM71HwpilgkCnTubDiR4Ba6x5Qvnhy2iasMWaTDM= github.com/AdguardTeam/golibs v0.17.2 h1:vg6wHMjUKscnyPGRvxS5kAt7Uw4YxcJiITZliZ476W8= github.com/AdguardTeam/golibs v0.17.2/go.mod h1:DKhCIXHcUYtBhU8ibTLKh1paUL96n5zhQBlx763sj+U= github.com/AdguardTeam/urlfilter v0.17.3 h1:fg/ObbnO0Cv6aw0tW6N/ETDMhhNvmcUUOZ7HlmKC3rw= diff --git a/internal/dnsforward/access.go b/internal/dnsforward/access.go index 2c0d0dc4..21b4a758 100644 --- a/internal/dnsforward/access.go +++ b/internal/dnsforward/access.go @@ -182,6 +182,7 @@ func (s *Server) accessListJSON() (j accessListJSON) { } } +// handleAccessList handles requests to the GET /control/access/list endpoint. func (s *Server) handleAccessList(w http.ResponseWriter, r *http.Request) { aghhttp.WriteJSONResponseOK(w, r, s.accessListJSON()) } @@ -224,6 +225,7 @@ func validateStrUniq(clients []string) (uc aghalg.UniqChecker[string], err error return uc, uc.Validate() } +// handleAccessSet handles requests to the POST /control/access/set endpoint. func (s *Server) handleAccessSet(w http.ResponseWriter, r *http.Request) { list := &accessListJSON{} err := json.NewDecoder(r.Body).Decode(&list) diff --git a/internal/dnsforward/http.go b/internal/dnsforward/http.go index 02432ce2..99ced3ef 100644 --- a/internal/dnsforward/http.go +++ b/internal/dnsforward/http.go @@ -7,6 +7,7 @@ import ( "net/http" "net/netip" "strings" + "sync" "time" "github.com/AdguardTeam/AdGuardHome/internal/aghhttp" @@ -444,19 +445,10 @@ func newUpstreamConfig(upstreams []string) (conf *proxy.UpstreamConfig, err erro return nil, nil } - for _, u := range upstreams { - var ups string - var domains []string - ups, domains, err = separateUpstream(u) - if err != nil { - // Don't wrap the error since it's informative enough as is. - return nil, err - } - - _, err = validateUpstream(ups, domains) - if err != nil { - return nil, fmt.Errorf("validating upstream %q: %w", u, err) - } + err = validateUpstreamConfig(upstreams) + if err != nil { + // Don't wrap the error since it's informative enough as is. + return nil, err } conf, err = proxy.ParseUpstreamsConfig( @@ -467,6 +459,7 @@ func newUpstreamConfig(upstreams []string) (conf *proxy.UpstreamConfig, err erro }, ) if err != nil { + // Don't wrap the error since it's informative enough as is. return nil, err } else if len(conf.Upstreams) == 0 { return nil, errors.Error("no default upstreams specified") @@ -475,6 +468,31 @@ func newUpstreamConfig(upstreams []string) (conf *proxy.UpstreamConfig, err erro return conf, nil } +// validateUpstreamConfig validates each upstream from the upstream +// configuration and returns an error if any upstream is invalid. +// +// TODO(e.burkov): Move into aghnet or even into dnsproxy. +func validateUpstreamConfig(conf []string) (err error) { + for _, u := range conf { + var ups []string + var domains []string + ups, domains, err = separateUpstream(u) + if err != nil { + // Don't wrap the error since it's informative enough as is. + return err + } + + for _, addr := range ups { + _, err = validateUpstream(addr, domains) + if err != nil { + return fmt.Errorf("validating upstream %q: %w", addr, err) + } + } + } + + return nil +} + // ValidateUpstreams validates each upstream and returns an error if any // upstream is invalid or if there are no default upstreams specified. // @@ -567,12 +585,12 @@ func validateUpstream(u string, domains []string) (useDefault bool, err error) { return false, err } -// separateUpstream returns the upstream and the specified domains. domains is -// nil when the upstream is not domains-specific. Otherwise it may also be +// separateUpstream returns the upstreams and the specified domains. domains +// is nil when the upstream is not domains-specific. Otherwise it may also be // empty. -func separateUpstream(upstreamStr string) (ups string, domains []string, err error) { +func separateUpstream(upstreamStr string) (upstreams, domains []string, err error) { if !strings.HasPrefix(upstreamStr, "[/") { - return upstreamStr, nil, nil + return []string{upstreamStr}, nil, nil } defer func() { err = errors.Annotate(err, "bad upstream for domain %q: %w", upstreamStr) }() @@ -582,9 +600,9 @@ func separateUpstream(upstreamStr string) (ups string, domains []string, err err case 2: // Go on. case 1: - return "", nil, errors.Error("missing separator") + return nil, nil, errors.Error("missing separator") default: - return "", []string{}, errors.Error("duplicated separator") + return nil, nil, errors.Error("duplicated separator") } for i, host := range strings.Split(parts[0], "/") { @@ -594,13 +612,13 @@ func separateUpstream(upstreamStr string) (ups string, domains []string, err err err = netutil.ValidateDomainName(strings.TrimPrefix(host, "*.")) if err != nil { - return "", domains, fmt.Errorf("domain at index %d: %w", i, err) + return nil, nil, fmt.Errorf("domain at index %d: %w", i, err) } domains = append(domains, host) } - return parts[1], domains, nil + return strings.Fields(parts[1]), domains, nil } // healthCheckFunc is a signature of function to check if upstream exchanges @@ -683,30 +701,65 @@ func (err domainSpecificTestError) Error() (msg string) { return fmt.Sprintf("WARNING: %s", err.error) } -// parseUpstreamLine parses line and creates the [upstream.Upstream] using opts -// and information from [s.dnsFilter.EtcHosts]. It returns an error if the line -// is not a valid upstream line, see [upstream.AddressToUpstream]. It's a -// caller's responsibility to close u. -func (s *Server) parseUpstreamLine( +// checkDNS parses line, creates DNS upstreams using opts, and checks if the +// upstreams are exchanging correctly. It returns a map where key is an +// upstream address and value is "OK", if the upstream exchanges correctly, or +// text of the error. +func (s *Server) checkDNS( line string, opts *upstream.Options, -) (u upstream.Upstream, specific bool, err error) { - // Separate upstream from domains list. - upstreamAddr, domains, err := separateUpstream(line) + check healthCheckFunc, +) (result map[string]string) { + result = map[string]string{} + upstreams, domains, err := separateUpstream(line) if err != nil { - return nil, false, fmt.Errorf("wrong upstream format: %w", err) + return nil } - specific = len(domains) > 0 + specific := len(domains) > 0 - useDefault, err := validateUpstream(upstreamAddr, domains) - if err != nil { - return nil, specific, fmt.Errorf("wrong upstream format: %w", err) - } else if useDefault { - return nil, specific, nil + for _, upstreamAddr := range upstreams { + var useDefault bool + useDefault, err = validateUpstream(upstreamAddr, domains) + if err != nil { + err = fmt.Errorf("wrong upstream format: %w", err) + result[upstreamAddr] = err.Error() + + continue + } + + if useDefault { + continue + } + + log.Debug("dnsforward: checking if upstream %q works", upstreamAddr) + + err = s.checkUpstreamAddr(upstreamAddr, specific, opts, check) + if err != nil { + result[upstreamAddr] = err.Error() + } else { + result[upstreamAddr] = "OK" + } } - log.Debug("dnsforward: checking if upstream %q works", upstreamAddr) + return result +} + +// checkUpstreamAddr creates the DNS upstream using opts and information from +// [s.dnsFilter.EtcHosts]. Checks if the DNS upstream exchanges correctly. It +// returns an error if addr is not valid DNS upstream address or the upstream +// is not exchanging correctly. +func (s *Server) checkUpstreamAddr( + addr string, + specific bool, + opts *upstream.Options, + check healthCheckFunc, +) (err error) { + defer func() { + if err != nil && specific { + err = domainSpecificTestError{error: err} + } + }() opts = &upstream.Options{ Bootstrap: opts.Bootstrap, @@ -716,42 +769,25 @@ func (s *Server) parseUpstreamLine( // dnsFilter can be nil during application update. if s.dnsFilter != nil { - recs := s.dnsFilter.EtcHostsRecords(extractUpstreamHost(upstreamAddr)) + recs := s.dnsFilter.EtcHostsRecords(extractUpstreamHost(addr)) for _, rec := range recs { opts.ServerIPAddrs = append(opts.ServerIPAddrs, rec.Addr.AsSlice()) } sortNetIPAddrs(opts.ServerIPAddrs, opts.PreferIPv6) } - u, err = upstream.AddressToUpstream(upstreamAddr, opts) + + u, err := upstream.AddressToUpstream(addr, opts) if err != nil { - return nil, specific, fmt.Errorf("creating upstream for %q: %w", upstreamAddr, err) + return fmt.Errorf("creating upstream for %q: %w", addr, err) } - return u, specific, nil -} - -func (s *Server) checkDNS(line string, opts *upstream.Options, check healthCheckFunc) (err error) { - if IsCommentOrEmpty(line) { - return nil - } - - var u upstream.Upstream - var specific bool - defer func() { - if err != nil && specific { - err = domainSpecificTestError{error: err} - } - }() - - u, specific, err = s.parseUpstreamLine(line, opts) - if err != nil || u == nil { - return err - } defer func() { err = errors.WithDeferred(err, u.Close()) }() return check(u) } +// handleTestUpstreamDNS handles requests to the POST /control/test_upstream_dns +// endpoint. func (s *Server) handleTestUpstreamDNS(w http.ResponseWriter, r *http.Request) { req := &upstreamJSON{} err := json.NewDecoder(r.Body).Decode(req) @@ -761,6 +797,10 @@ func (s *Server) handleTestUpstreamDNS(w http.ResponseWriter, r *http.Request) { return } + req.Upstreams = stringutil.FilterOut(req.Upstreams, IsCommentOrEmpty) + req.FallbackDNS = stringutil.FilterOut(req.FallbackDNS, IsCommentOrEmpty) + req.PrivateUpstreams = stringutil.FilterOut(req.PrivateUpstreams, IsCommentOrEmpty) + opts := &upstream.Options{ Bootstrap: req.BootstrapDNS, Timeout: s.conf.UpstreamTimeout, @@ -770,54 +810,44 @@ func (s *Server) handleTestUpstreamDNS(w http.ResponseWriter, r *http.Request) { opts.Bootstrap = defaultBootstrap } - type upsCheckResult = struct { - err error - host string + wg := &sync.WaitGroup{} + m := &sync.Map{} + + // TODO(s.chzhen): Separate to a different structure/file. + worker := func(upstreamLine string, check healthCheckFunc) { + defer log.OnPanic("dnsforward: checking upstreams") + + res := s.checkDNS(upstreamLine, opts, check) + for ups, status := range res { + m.Store(ups, status) + } + + wg.Done() } - req.Upstreams = stringutil.FilterOut(req.Upstreams, IsCommentOrEmpty) - req.FallbackDNS = stringutil.FilterOut(req.FallbackDNS, IsCommentOrEmpty) - req.PrivateUpstreams = stringutil.FilterOut(req.PrivateUpstreams, IsCommentOrEmpty) - - upsNum := len(req.Upstreams) + len(req.FallbackDNS) + len(req.PrivateUpstreams) - result := make(map[string]string, upsNum) - resCh := make(chan upsCheckResult, upsNum) + wg.Add(len(req.Upstreams) + len(req.FallbackDNS) + len(req.PrivateUpstreams)) for _, ups := range req.Upstreams { - go func(ups string) { - resCh <- upsCheckResult{ - host: ups, - err: s.checkDNS(ups, opts, checkDNSUpstreamExc), - } - }(ups) + go worker(ups, checkDNSUpstreamExc) } for _, ups := range req.FallbackDNS { - go func(ups string) { - resCh <- upsCheckResult{ - host: ups, - err: s.checkDNS(ups, opts, checkDNSUpstreamExc), - } - }(ups) + go worker(ups, checkDNSUpstreamExc) } for _, ups := range req.PrivateUpstreams { - go func(ups string) { - resCh <- upsCheckResult{ - host: ups, - err: s.checkDNS(ups, opts, checkPrivateUpstreamExc), - } - }(ups) + go worker(ups, checkPrivateUpstreamExc) } - for i := 0; i < upsNum; i++ { - // TODO(e.burkov): The upstreams used for both common and private - // resolving should be reported separately. - pair := <-resCh - if pair.err != nil { - result[pair.host] = pair.err.Error() - } else { - result[pair.host] = "OK" - } - } + wg.Wait() + + result := map[string]string{} + m.Range(func(k, v any) bool { + ups := k.(string) + status := v.(string) + + result[ups] = status + + return true + }) aghhttp.WriteJSONResponseOK(w, r, result) } diff --git a/internal/dnsforward/http_test.go b/internal/dnsforward/http_test.go index 8524acc4..dfd1862a 100644 --- a/internal/dnsforward/http_test.go +++ b/internal/dnsforward/http_test.go @@ -49,13 +49,18 @@ func loadTestData(t *testing.T, casesFileName string, cases any) { require.NoError(t, err) } -const jsonExt = ".json" +const ( + jsonExt = ".json" + + // testBlockedRespTTL is the TTL for blocked responses to use in tests. + testBlockedRespTTL = 10 +) func TestDNSForwardHTTP_handleGetConfig(t *testing.T) { filterConf := &filtering.Config{ ProtectionEnabled: true, BlockingMode: filtering.BlockingModeDefault, - BlockedResponseTTL: 10, + BlockedResponseTTL: testBlockedRespTTL, SafeBrowsingEnabled: true, SafeBrowsingCacheSize: 1000, SafeSearchConf: filtering.SafeSearchConfig{Enabled: true}, @@ -133,7 +138,7 @@ func TestDNSForwardHTTP_handleSetConfig(t *testing.T) { filterConf := &filtering.Config{ ProtectionEnabled: true, BlockingMode: filtering.BlockingModeDefault, - BlockedResponseTTL: 10, + BlockedResponseTTL: testBlockedRespTTL, SafeBrowsingEnabled: true, SafeBrowsingCacheSize: 1000, SafeSearchConf: filtering.SafeSearchConfig{Enabled: true}, @@ -229,6 +234,9 @@ func TestDNSForwardHTTP_handleSetConfig(t *testing.T) { }, { name: "blocked_response_ttl", wantSet: "", + }, { + name: "multiple_domain_specific_upstreams", + wantSet: "", }} var data map[string]struct { @@ -250,6 +258,7 @@ func TestDNSForwardHTTP_handleSetConfig(t *testing.T) { s.dnsFilter.SetBlockingMode(filtering.BlockingModeDefault, netip.Addr{}, netip.Addr{}) s.conf = defaultConf s.conf.Config.EDNSClientSubnet = &EDNSClientSubnet{} + s.dnsFilter.SetBlockedResponseTTL(testBlockedRespTTL) }) rBody := io.NopCloser(bytes.NewReader(caseData.Req)) @@ -547,7 +556,7 @@ func TestServer_HandleTestUpstreamDNS(t *testing.T) { "upstream_dns": []string{"[/domain.example/]" + badUps}, }, wantResp: map[string]any{ - "[/domain.example/]" + badUps: `WARNING: couldn't communicate ` + + badUps: `WARNING: couldn't communicate ` + `with upstream: exchanging with ` + badUps + ` over tcp: ` + `dns: id mismatch`, }, @@ -585,6 +594,17 @@ func TestServer_HandleTestUpstreamDNS(t *testing.T) { goodUps: "OK", }, name: "fallback_comment_mix", + }, { + body: map[string]any{ + "upstream_dns": []string{"[/domain.example/]" + goodUps + " " + badUps}, + }, + wantResp: map[string]any{ + goodUps: "OK", + badUps: `WARNING: couldn't communicate ` + + `with upstream: exchanging with ` + badUps + ` over tcp: ` + + `dns: id mismatch`, + }, + name: "multiple_domain_specific_upstreams", }} for _, tc := range testCases { diff --git a/internal/dnsforward/testdata/TestDNSForwardHTTP_handleSetConfig.json b/internal/dnsforward/testdata/TestDNSForwardHTTP_handleSetConfig.json index b20e1adc..b94bb82f 100644 --- a/internal/dnsforward/testdata/TestDNSForwardHTTP_handleSetConfig.json +++ b/internal/dnsforward/testdata/TestDNSForwardHTTP_handleSetConfig.json @@ -839,5 +839,47 @@ "edns_cs_use_custom": false, "edns_cs_custom_ip": "" } + }, + "multiple_domain_specific_upstreams": { + "req": { + "upstream_dns": [ + "8.8.8.8:77", + "[/example.com/]8.8.4.4:77 9.9.9.10 https://1.1.1.1" + ] + }, + "want": { + "upstream_dns": [ + "8.8.8.8:77", + "[/example.com/]8.8.4.4:77 9.9.9.10 https://1.1.1.1" + ], + "upstream_dns_file": "", + "bootstrap_dns": [ + "9.9.9.10", + "149.112.112.10", + "2620:fe::10", + "2620:fe::fe:10" + ], + "fallback_dns": [], + "protection_enabled": true, + "protection_disabled_until": null, + "ratelimit": 0, + "blocking_mode": "default", + "blocking_ipv4": "", + "blocking_ipv6": "", + "blocked_response_ttl": 10, + "edns_cs_enabled": false, + "dnssec_enabled": false, + "disable_ipv6": false, + "upstream_mode": "", + "cache_size": 0, + "cache_ttl_min": 0, + "cache_ttl_max": 0, + "cache_optimistic": false, + "resolve_clients": false, + "use_private_ptr_resolvers": false, + "local_ptr_upstreams": [], + "edns_cs_use_custom": false, + "edns_cs_custom_ip": "" + } } }