From 678bd27085caadd0086c60cd95ff8b2f59e86a40 Mon Sep 17 00:00:00 2001 From: Denozordec Date: Mon, 6 Apr 2026 16:59:59 +0700 Subject: [PATCH] feat: implement domain resolution via DoH in refresh pipeline. Enhance the collectModulePrefixRows function to resolve domain IPs using DNS over HTTPS (DoH) profiles, adding support for both A and AAAA record types. Introduce new helper functions for handling DoH requests and processing responses, improving domain management and IP prefix aggregation. --- internal/pipeline/refresh.go | 152 ++++++++++++++++++++++++++++++++++- 1 file changed, 150 insertions(+), 2 deletions(-) diff --git a/internal/pipeline/refresh.go b/internal/pipeline/refresh.go index 7930474..06aecdb 100644 --- a/internal/pipeline/refresh.go +++ b/internal/pipeline/refresh.go @@ -6,7 +6,9 @@ import ( "encoding/json" "fmt" "io" + "net/netip" "net/http" + "net/url" "os" "sort" "strconv" @@ -197,15 +199,161 @@ func collectModulePrefixRows(ctx context.Context, st store.Backend, hc *http.Cli } return rows, nil case "DOMAINS": - if _, err := st.ListDomainEntries(tenantID, moduleID); err != nil { + entries, err := st.ListDomainEntries(tenantID, moduleID) + if err != nil { return nil, err } - return nil, nil + var profile *store.DohProfile + if mod.DohProfileID != nil && strings.TrimSpace(*mod.DohProfileID) != "" { + profile, err = st.GetDohProfile(tenantID, strings.TrimSpace(*mod.DohProfileID)) + if err != nil { + return nil, fmt.Errorf("get doh profile: %w", err) + } + } + var rows []store.PrefixRow + seen := make(map[string]struct{}) + for _, e := range entries { + if e == nil { + continue + } + comm := e.CommunityID + if comm == nil && mod.DefaultCommunityID != nil { + c := *mod.DefaultCommunityID + comm = &c + } + addrs, err := resolveDomainIPs(ctx, hc, profile, e.FQDN) + if err != nil { + return nil, fmt.Errorf("resolve domain %q: %w", e.FQDN, err) + } + src := "domain:" + strings.TrimSpace(e.FQDN) + for _, ip := range addrs { + cidr := ipToHostPrefix(ip) + if cidr == "" { + continue + } + key := cidr + "|" + src + if _, ok := seen[key]; ok { + continue + } + seen[key] = struct{}{} + rows = append(rows, store.PrefixRow{ + Prefix: cidr, + CommunityID: comm, + Source: src, + }) + } + } + return rows, nil default: return nil, fmt.Errorf("unknown module type %q", mod.Type) } } +type dohJSONAnswer struct { + Type int `json:"type"` + Data string `json:"data"` +} + +type dohJSONResponse struct { + Answer []dohJSONAnswer `json:"Answer"` +} + +func resolveDomainIPs(ctx context.Context, hc *http.Client, profile *store.DohProfile, fqdn string) ([]netip.Addr, error) { + host := strings.TrimSpace(strings.TrimSuffix(fqdn, ".")) + if host == "" { + return nil, nil + } + if profile == nil || strings.TrimSpace(profile.URL) == "" { + return nil, nil + } + + timeout := 10 * time.Second + if profile.TimeoutMs != nil && *profile.TimeoutMs > 0 { + timeout = time.Duration(*profile.TimeoutMs) * time.Millisecond + } + dctx, cancel := context.WithTimeout(ctx, timeout) + defer cancel() + + // RFC8484 endpoint with JSON mode: ?name=&type=A/AAAA + v4, err4 := resolveDomainWithDOHJSON(dctx, hc, strings.TrimSpace(profile.URL), host, "A") + v6, err6 := resolveDomainWithDOHJSON(dctx, hc, strings.TrimSpace(profile.URL), host, "AAAA") + if err4 != nil && err6 != nil { + return nil, fmt.Errorf("doh failed for A and AAAA: %v; %v", err4, err6) + } + return uniqAddrs(append(v4, v6...)), nil +} + +func resolveDomainWithDOHJSON(ctx context.Context, hc *http.Client, baseURL, host, qtype string) ([]netip.Addr, error) { + u, err := url.Parse(baseURL) + if err != nil { + return nil, err + } + q := u.Query() + q.Set("name", host) + q.Set("type", qtype) + u.RawQuery = q.Encode() + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil) + if err != nil { + return nil, err + } + req.Header.Set("Accept", "application/dns-json") + + resp, err := hc.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(io.LimitReader(resp.Body, 1024)) + return nil, fmt.Errorf("doh status %s: %s", resp.Status, strings.TrimSpace(string(body))) + } + var payload dohJSONResponse + if err := json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(&payload); err != nil { + return nil, err + } + var out []netip.Addr + for _, ans := range payload.Answer { + if (qtype == "A" && ans.Type != 1) || (qtype == "AAAA" && ans.Type != 28) { + continue + } + ip, err := netip.ParseAddr(strings.TrimSpace(ans.Data)) + if err != nil { + continue + } + out = append(out, ip.Unmap()) + } + return uniqAddrs(out), nil +} + +func uniqAddrs(in []netip.Addr) []netip.Addr { + seen := make(map[string]struct{}, len(in)) + out := make([]netip.Addr, 0, len(in)) + for _, a := range in { + if !a.IsValid() { + continue + } + k := a.String() + if _, ok := seen[k]; ok { + continue + } + seen[k] = struct{}{} + out = append(out, a) + } + return out +} + +func ipToHostPrefix(ip netip.Addr) string { + if !ip.IsValid() { + return "" + } + bits := 128 + if ip.Is4() { + bits = 32 + } + return netip.PrefixFrom(ip, bits).Masked().String() +} + // aggregateTenantPrefixRows builds the union of materialized prefixes for all enabled modules. // The module that triggered refresh contributes freshRows; every other module is collected live from the store // (same logic as refresh). We do not reuse other modules' saved revisions as prefix sources, because each revision