From ffebdafccd6365a287bbba00119554e631eb0b66 Mon Sep 17 00:00:00 2001 From: Scot Wells Date: Mon, 12 Oct 2026 12:09:18 -0500 Subject: [PATCH 1/4] feat: Serve isolated private DNS zones on a shared fleet --- internal/internaldns/admission/handler.go | 8 +- internal/internaldns/model/model.go | 11 + internal/internaldns/model/private_records.go | 10 +- .../internaldns/serving/publication_hash.go | 25 ++ .../internaldns/serving/publication_state.go | 21 + internal/internaldns/serving/render.go | 395 ++++++++++++++++++ internal/internaldns/serving/render_test.go | 159 +++++++ internal/internaldns/serving/transaction.go | 324 ++++++++++++++ .../internaldns/serving/transaction_test.go | 262 ++++++++++++ internal/internaldns/serving/verify.go | 303 ++++++++++++++ internal/internaldns/serving/verify_test.go | 327 +++++++++++++++ internal/internaldns/serving/zone.go | 69 +++ .../serving/zone_regression_test.go | 65 +++ 13 files changed, 1975 insertions(+), 4 deletions(-) create mode 100644 internal/internaldns/serving/publication_hash.go create mode 100644 internal/internaldns/serving/publication_state.go create mode 100644 internal/internaldns/serving/render.go create mode 100644 internal/internaldns/serving/render_test.go create mode 100644 internal/internaldns/serving/transaction.go create mode 100644 internal/internaldns/serving/transaction_test.go create mode 100644 internal/internaldns/serving/verify.go create mode 100644 internal/internaldns/serving/verify_test.go create mode 100644 internal/internaldns/serving/zone.go create mode 100644 internal/internaldns/serving/zone_regression_test.go diff --git a/internal/internaldns/admission/handler.go b/internal/internaldns/admission/handler.go index f625740c..5df7263c 100644 --- a/internal/internaldns/admission/handler.go +++ b/internal/internaldns/admission/handler.go @@ -22,6 +22,8 @@ import ( kubeadmission "sigs.k8s.io/controller-runtime/pkg/webhook/admission" ) +const subresourceStatus = "status" + const contributionResource = "dnsrecordcontributions" const Path = "/validate-internal-dns" @@ -50,7 +52,7 @@ func (h *Handler) Handle(ctx context.Context, req kubeadmission.Request) kubeadm if req.Operation == admissionv1.Delete { return kubeadmission.Allowed("API server authorization applies") } - if req.SubResource == "status" && req.Resource.Resource != contributionResource { + if req.SubResource == subresourceStatus && req.Resource.Resource != contributionResource { return kubeadmission.Allowed("API server status authorization applies") } switch req.Resource.Resource { @@ -141,7 +143,7 @@ func (h *Handler) contribution(ctx context.Context, cl client.Client, clusterUID } } bindEpoch := false - if req.SubResource == "status" { + if req.SubResource == subresourceStatus { previous, current := old.Status, c.Status previous.Conditions, current.Conditions = nil, nil previous.PublishedRevision, current.PublishedRevision = 0, 0 @@ -200,7 +202,7 @@ func (h *Handler) contribution(ctx context.Context, cl client.Client, clusterUID } return nil } - if req.SubResource == "status" { + if req.SubResource == subresourceStatus { if !reflect.DeepEqual(old.Status.Conditions, c.Status.Conditions) || old.Status.PublishedRevision != c.Status.PublishedRevision { return fmt.Errorf("publisher observations cannot change publication results") } diff --git a/internal/internaldns/model/model.go b/internal/internaldns/model/model.go index a3bd58eb..ae4785fa 100644 --- a/internal/internaldns/model/model.go +++ b/internal/internaldns/model/model.go @@ -394,10 +394,21 @@ func (p PublicationPlan) Validate() error { } } seen := map[string]struct{}{} + ownerTypes := map[string]string{} for _, rr := range p.RRSets { owner := strings.ToLower(AbsoluteName(rr.Name)) rrType := strings.ToUpper(rr.Type) key := owner + "\x00" + rrType + if err := ValidatePrivateRecordOwner(owner, apex, rrType); err != nil { + return err + } + if other := ownerTypes[owner]; other != "" && (other == recordTypeCNAME || rrType == recordTypeCNAME) { + return fmt.Errorf("CNAME cannot coexist with other records at %s", owner) + } + ownerTypes[owner] = rrType + if rrType == recordTypeCNAME && len(rr.Records) != 1 { + return fmt.Errorf("CNAME at %s must contain one target", owner) + } if len(rr.Records) == 0 || rr.Type == "" { return fmt.Errorf("RRset %q is incomplete", key) } diff --git a/internal/internaldns/model/private_records.go b/internal/internaldns/model/private_records.go index e0a6d9af..93a9424c 100644 --- a/internal/internaldns/model/private_records.go +++ b/internal/internaldns/model/private_records.go @@ -5,17 +5,25 @@ import ( "strings" ) +// OwnershipMarkerType is reserved for serving-only NODATA metadata (RFC 6895 private use). +const OwnershipMarkerType = "TYPE65280" + +const recordTypeCNAME = "CNAME" + // ValidatePrivateRecordOwner reserves the authority metadata generated by the // regional BIND fleet. It applies only to internal DNS publication inputs. func ValidatePrivateRecordOwner(owner, apex, recordType string) error { owner = strings.TrimSpace(owner) + if strings.EqualFold(recordType, OwnershipMarkerType) { + return fmt.Errorf("private DNS ownership metadata is service-owned") + } if strings.EqualFold(recordType, "ALIAS") { return fmt.Errorf("private DNS does not support ALIAS flattening") } if strings.EqualFold(recordType, "SOA") { return fmt.Errorf("private zone SOA records are managed by the DNS service") } - if strings.EqualFold(recordType, "CNAME") && (owner == "" || owner == "@" || AbsoluteName(owner) == AbsoluteName(apex)) { + if strings.EqualFold(recordType, recordTypeCNAME) && (owner == "" || owner == "@" || AbsoluteName(owner) == AbsoluteName(apex)) { return fmt.Errorf("a private zone apex cannot be a CNAME") } return nil diff --git a/internal/internaldns/serving/publication_hash.go b/internal/internaldns/serving/publication_hash.go new file mode 100644 index 00000000..73b74d95 --- /dev/null +++ b/internal/internaldns/serving/publication_hash.go @@ -0,0 +1,25 @@ +// SPDX-License-Identifier: AGPL-3.0-only + +package serving + +import ( + "sort" + "strings" + "time" + + "go.miloapis.com/dns-operator/internal/internaldns/model" +) + +func effectivePlanHash(plan model.PublicationPlan, now time.Time) string { + var values []string + for _, rr := range plan.RRSets { + for _, r := range rr.Records { + if !r.Eligible || (r.ContributionUID != "" && !now.Before(r.ValidUntil)) { + continue + } + values = append(values, model.AbsoluteName(rr.Name)+"\x00"+strings.ToUpper(rr.Type)+"\x00"+r.Content) + } + } + sort.Strings(values) + return model.Hash([]byte(strings.Join(values, "\n"))) +} diff --git a/internal/internaldns/serving/publication_state.go b/internal/internaldns/serving/publication_state.go new file mode 100644 index 00000000..a87322b9 --- /dev/null +++ b/internal/internaldns/serving/publication_state.go @@ -0,0 +1,21 @@ +// SPDX-License-Identifier: AGPL-3.0-only + +package serving + +import ( + "go.miloapis.com/dns-operator/internal/internaldns/model" +) + +type fence struct { + Epoch uint64 `json:"epoch"` + Revision uint64 `json:"revision"` +} + +type publicationState struct { + Fence fence `json:"fence"` + Manifest model.PublicationManifest `json:"manifest"` + Plan model.PublicationPlan `json:"plan"` + LocalRevision uint64 `json:"localRevision,omitempty"` + EffectiveHash string `json:"effectiveHash,omitempty"` + Verified bool `json:"verified"` +} diff --git a/internal/internaldns/serving/render.go b/internal/internaldns/serving/render.go new file mode 100644 index 00000000..4037ae69 --- /dev/null +++ b/internal/internaldns/serving/render.go @@ -0,0 +1,395 @@ +package serving + +import ( + "bytes" + "errors" + "fmt" + "net" + "net/netip" + "path/filepath" + "sort" + "strconv" + "strings" + "time" + + "go.miloapis.com/dns-operator/internal/internaldns/model" +) + +type DNSDistRenderConfig struct { + Path string `json:"path"` + ListenAddress string `json:"listenAddress"` + ACLs []string `json:"acls"` + PoolName string `json:"poolName"` + Backends []model.Backend `json:"backends,omitempty"` +} + +type BINDRenderConfig struct { + Path string `json:"path"` + ListenAddress string `json:"listenAddress"` + Port uint16 `json:"port"` + ProxyPeers []string `json:"proxyPeers"` + DefaultCacheSize string `json:"defaultCacheSize,omitempty"` + MaxNegativeCacheTTLSeconds uint32 `json:"maxNegativeCacheTTLSeconds,omitempty"` + DNSSECValidation string `json:"dnssecValidation,omitempty"` + RNDCIncludePath string `json:"rndcIncludePath,omitempty"` + ControlAddress string `json:"controlAddress,omitempty"` + ControlPort uint16 `json:"controlPort,omitempty"` + ControlKeyName string `json:"controlKeyName,omitempty"` +} + +type RenderRole string + +const ( + RenderNode RenderRole = "node" + RenderCluster RenderRole = "cluster" + RenderBoth RenderRole = "both" + RenderRegionalBIND RenderRole = "regional-bind" + RenderRegionalDNSDist RenderRole = "regional-dnsdist" +) + +type RenderConfig struct { + ReadyMembers map[string]map[string]bool `json:"-"` + Role RenderRole `json:"role"` + NodeDNSDist DNSDistRenderConfig `json:"nodeDNSDist,omitempty"` + ClusterDNSDist DNSDistRenderConfig `json:"clusterDNSDist,omitempty"` + NodeBIND BINDRenderConfig `json:"nodeBIND,omitempty"` + ClusterBIND BINDRenderConfig `json:"clusterBIND,omitempty"` +} + +type Rendered struct{ Files map[string][]byte } + +func Render(c RenderConfig, snapshot model.ServingSnapshot, active map[string]bool) (Rendered, error) { + return renderPublications(c, snapshot, active, nil, time.Now()) +} + +func renderPublications(c RenderConfig, snapshot model.ServingSnapshot, active map[string]bool, publications map[string]publicationState, now time.Time) (Rendered, error) { + if err := snapshot.Validate(); err != nil { + return Rendered{}, err + } + if err := validateRenderConfig(c); err != nil { + return Rendered{}, err + } + bindings := append([]model.Binding(nil), snapshot.Bindings...) + sort.Slice(bindings, func(i, j int) bool { return bindings[i].BindingUID < bindings[j].BindingUID }) + nodeBackends := c.NodeDNSDist.Backends + clusterBackends := c.ClusterDNSDist.Backends + if len(nodeBackends) == 0 { + nodeBackends = collectBackends(bindings, func(b model.Binding) []model.Backend { return b.NodeBackends }) + } + if len(clusterBackends) == 0 { + clusterBackends = collectBackends(bindings, func(b model.Binding) []model.Backend { return b.ClusterBackends }) + } + if err := validateBackends(nodeBackends); err != nil { + return Rendered{}, fmt.Errorf("node backends: %w", err) + } + if err := validateBackends(clusterBackends); err != nil { + return Rendered{}, fmt.Errorf("cluster backends: %w", err) + } + files := map[string][]byte{} + role := c.Role + if role == "" { + role = RenderBoth + } + if role == RenderNode || role == RenderBoth { + files[c.NodeDNSDist.Path] = renderDNSDist(c.NodeDNSDist, nodeBackends, bindings, active, false, nil) + files[c.NodeBIND.Path] = renderNodeBIND(c.NodeBIND, bindings) + } + if role == RenderCluster || role == RenderBoth || role == RenderRegionalDNSDist { + files[c.ClusterDNSDist.Path] = renderDNSDist(c.ClusterDNSDist, clusterBackends, bindings, active, true, c.ReadyMembers) + } + if role == RenderCluster || role == RenderBoth || role == RenderRegionalBIND { + cluster, err := renderClusterBIND(c.ClusterBIND, bindings, publications, now, files) + if err != nil { + return Rendered{}, err + } + files[c.ClusterBIND.Path] = cluster + } + return Rendered{Files: files}, nil +} + +func validateRenderConfig(c RenderConfig) error { + role := c.Role + if role == "" { + role = RenderBoth + } + if role != RenderNode && role != RenderCluster && role != RenderBoth && role != RenderRegionalBIND && role != RenderRegionalDNSDist { + return fmt.Errorf("unsupported render role %q", role) + } + + dists := map[string]DNSDistRenderConfig{} + binds := map[string]BINDRenderConfig{} + if role == RenderNode || role == RenderBoth { + dists["node dnsdist"] = c.NodeDNSDist + binds["node BIND"] = c.NodeBIND + } + if role == RenderCluster || role == RenderBoth || role == RenderRegionalDNSDist { + dists["cluster dnsdist"] = c.ClusterDNSDist + } + if role == RenderCluster || role == RenderBoth || role == RenderRegionalBIND { + binds["cluster BIND"] = c.ClusterBIND + } + for label, d := range dists { + if !filepath.IsAbs(d.Path) || d.PoolName == "" || model.SafeToken(d.PoolName) != d.PoolName { + return fmt.Errorf("%s path and safe pool name are required", label) + } + if _, _, err := net.SplitHostPort(d.ListenAddress); err != nil { + return fmt.Errorf("%s invalid listen address: %w", label, err) + } + for _, acl := range d.ACLs { + if _, err := netip.ParsePrefix(acl); err != nil { + return fmt.Errorf("%s invalid ACL %q", label, acl) + } + } + if err := validateBackends(d.Backends); err != nil { + return fmt.Errorf("%s: %w", label, err) + } + } + for label, b := range binds { + if !filepath.IsAbs(b.Path) || b.Port == 0 { + return fmt.Errorf("%s absolute path and port are required", label) + } + if addr, err := netip.ParseAddr(b.ListenAddress); err != nil || addr.IsUnspecified() { + return fmt.Errorf("%s invalid listen address", label) + } + for _, peer := range b.ProxyPeers { + if _, err := netip.ParseAddr(peer); err != nil { + return fmt.Errorf("%s invalid proxy peer %q", label, peer) + } + } + if b.DefaultCacheSize == "" { + b.DefaultCacheSize = "16M" + } + if !safeNamedAtom(b.DefaultCacheSize) { + return fmt.Errorf("%s unsafe cache size", label) + } + if b.MaxNegativeCacheTTLSeconds > 60 { + return fmt.Errorf("%s maximum negative cache TTL may not exceed 60 seconds", label) + } + if b.DNSSECValidation != "" && b.DNSSECValidation != "auto" && b.DNSSECValidation != "yes" && b.DNSSECValidation != "no" { + return fmt.Errorf("%s invalid DNSSEC validation mode", label) + } + if b.RNDCIncludePath != "" && (!filepath.IsAbs(b.RNDCIncludePath) || strings.ContainsAny(b.RNDCIncludePath, "\"\n\r")) { + return fmt.Errorf("%s unsafe RNDC include", label) + } + if b.ControlAddress != "" { + if _, err := netip.ParseAddr(b.ControlAddress); err != nil || b.ControlPort == 0 || !safeNamedAtom(b.ControlKeyName) { + return fmt.Errorf("%s invalid controls", label) + } + } + } + return nil +} + +func validateBackends(backends []model.Backend) error { + members := map[string]string{} + endpoints := map[string]string{} + for _, b := range backends { + if b.MemberID == "" || b.Port == 0 { + return errors.New("incomplete backend") + } + if a, err := netip.ParseAddr(b.Address); err != nil || a.IsUnspecified() { + return fmt.Errorf("invalid backend %q", b.Address) + } + endpoint := net.JoinHostPort(b.Address, strconv.Itoa(int(b.Port))) + if _, ok := members[b.MemberID]; ok { + return fmt.Errorf("duplicate backend member %q", b.MemberID) + } + if old, ok := endpoints[endpoint]; ok { + return fmt.Errorf("backend endpoint %q belongs to both %q and %q", endpoint, old, b.MemberID) + } + members[b.MemberID] = endpoint + endpoints[endpoint] = b.MemberID + } + return nil +} + +func collectBackends(bindings []model.Binding, get func(model.Binding) []model.Backend) []model.Backend { + seen := map[string]model.Backend{} + for _, binding := range bindings { + for _, b := range get(binding) { + key := b.MemberID + "\x00" + net.JoinHostPort(b.Address, strconv.Itoa(int(b.Port))) + seen[key] = b + } + } + out := make([]model.Backend, 0, len(seen)) + for _, b := range seen { + out = append(out, b) + } + sort.Slice(out, func(i, j int) bool { return out[i].MemberID < out[j].MemberID }) + return out +} + +func renderDNSDist(c DNSDistRenderConfig, backends []model.Backend, bindings []model.Binding, active map[string]bool, cluster bool, eligible map[string]map[string]bool) []byte { + var out bytes.Buffer + fmt.Fprintf(&out, "setLocal(%q)\n", c.ListenAddress) + fmt.Fprint(&out, "setACL({") + for i, acl := range c.ACLs { + if i > 0 { + fmt.Fprint(&out, ",") + } + fmt.Fprintf(&out, "%q", acl) + } + fmt.Fprint(&out, "})\nsetSecurityPollSuffix(\"\")\n\n") + for _, b := range backends { + fmt.Fprintf(&out, "local server = newServer({address=%q, name=%q, pool=%q, useProxyProtocol=true, checkName=\".\", checkType=\"SOA\", mustResolve=false})\n", net.JoinHostPort(b.Address, strconv.Itoa(int(b.Port))), "b_"+model.OpaqueToken(b.MemberID), c.PoolName) + if cluster { + for _, binding := range bindings { + if active[binding.BindingUID] && (eligible == nil || eligible[binding.BindingUID][b.MemberID]) { + fmt.Fprintf(&out, "server:addPool(%q)\n", binding.ViewName()) + } + } + } + } + fmt.Fprint(&out, "\nlocal bindings = {\n") + for _, b := range bindings { + addr := b.ConsumerAddress + if cluster { + addr = b.ClusterAddress + } + fmt.Fprintf(&out, " [%q] = {context=%q, validUntil=%d, ready=%t},\n", addr, b.ViewName(), b.Authorization.ValidUntil.Unix(), active[b.BindingUID]) + } + fmt.Fprint(&out, "}\n\nfunction classifyVPC(dq)\n local binding = bindings[dq.localaddr:toString()]\n if binding == nil or os.time() >= binding.validUntil then\n return DNSAction.Refused, \"\"\n end\n dq:setTag(\"vpc-context\", binding.context)\n if not binding.ready then dq:setTag(\"context-unready\", \"true\") end\n return DNSAction.None, \"\"\nend\n\n") + fmt.Fprint(&out, "addAction(AllRule(), LuaAction(classifyVPC))\n") + fmt.Fprint(&out, "addAction(TagRule(\"context-unready\", \"true\"), RCodeAction(DNSRCode.SERVFAIL))\n") + if cluster { + for _, binding := range bindings { + fmt.Fprintf(&out, "getPool(%q)\naddAction(AndRule({TagRule(\"vpc-context\",%q),NotRule(PoolAvailableRule(%q))}), RCodeAction(DNSRCode.SERVFAIL))\naddAction(TagRule(\"vpc-context\",%q),PoolAction(%q))\n", binding.ViewName(), binding.ViewName(), binding.ViewName(), binding.ViewName(), binding.ViewName()) + } + fmt.Fprint(&out, "addAction(AllRule(),RCodeAction(DNSRCode.SERVFAIL))\n") + } else { + fmt.Fprintf(&out, "addAction(NotRule(PoolAvailableRule(%q)), RCodeAction(DNSRCode.SERVFAIL))\naddAction(AllRule(),PoolAction(%q))\n", c.PoolName, c.PoolName) + } + return out.Bytes() +} + +func renderOptions(out *bytes.Buffer, c BINDRenderConfig) { + fmt.Fprint(out, "options {\n directory \"/tmp\";\n") + directive := "listen-on" + if netip.MustParseAddr(c.ListenAddress).Is6() { + directive = "listen-on-v6" + } + fmt.Fprintf(out, " %s port %d proxy plain { %s; };\n", directive, c.Port, c.ListenAddress) + if directive == "listen-on" { + fmt.Fprint(out, " listen-on-v6 { none; };\n") + } else { + fmt.Fprint(out, " listen-on { none; };\n") + } + fmt.Fprint(out, " allow-proxy { ") + for _, p := range c.ProxyPeers { + fmt.Fprintf(out, "%s; ", p) + } + fmt.Fprint(out, "};\n") + fmt.Fprintf(out, " allow-proxy-on { %s; };\n", c.ListenAddress) + dnssec := c.DNSSECValidation + if dnssec == "" { + dnssec = "auto" + } + id := model.OpaqueToken(c.Path) + fmt.Fprintf(out, " dnssec-validation %s;\n", dnssec) + fmt.Fprint(out, " recursion yes;\n empty-zones-enable no;\n stale-answer-enable no;\n stale-cache-enable no;\n") + fmt.Fprintf(out, " pid-file \"/tmp/internal-dns-%s.pid\";\n session-keyfile \"/tmp/internal-dns-%s-session.key\";\n};\n\n", id, id) + if c.RNDCIncludePath != "" { + fmt.Fprintf(out, "include %q;\n", c.RNDCIncludePath) + } + if c.ControlAddress != "" { + fmt.Fprintf(out, "controls { inet %s port %d allow { %s; } keys { %q; }; };\n", c.ControlAddress, c.ControlPort, c.ControlAddress, c.ControlKeyName) + } +} + +func renderNodeBIND(c BINDRenderConfig, bindings []model.Binding) []byte { + var out bytes.Buffer + renderOptions(&out, c) + for _, b := range bindings { + renderViewStart(&out, b, b.ConsumerAddress, c.DefaultCacheSize, maxNegativeCacheTTL(c)) + fmt.Fprint(&out, " forward only;\n forwarders { ") + renderAddrPort(&out, b.ClusterAddress, b.Port) + fmt.Fprint(&out, " };\n};\n\n") + } + renderManagementAndReject(&out, c.ListenAddress) + return out.Bytes() +} + +func renderClusterBIND(c BINDRenderConfig, bindings []model.Binding, publications map[string]publicationState, now time.Time, files map[string][]byte) ([]byte, error) { + var out bytes.Buffer + renderOptions(&out, c) + for _, b := range bindings { + renderViewStart(&out, b, b.ClusterAddress, c.DefaultCacheSize, maxNegativeCacheTTL(c)) + for _, z := range b.Zones { + p, ok := publications[z.ZoneUID] + if !ok || p.Manifest.Tombstone || model.Compare(p.Fence.Epoch, p.Fence.Revision, z.RequiredPublicationEpoch, z.RequiredPublicationRevision) < 0 { + // Never recurse toward the public namespace when a private apex is missing. + // The front gate returns immediate SERVFAIL; direct BIND queries fail too. + fmt.Fprintf(&out, " zone %q { type forward; forward only; forwarders { 127.0.0.1 port 9; }; };\n", strings.TrimSuffix(model.AbsoluteName(z.Apex), ".")) + continue + } + data, err := renderZone(p, now) + if err != nil { + return nil, err + } + path := filepath.Join(filepath.Dir(c.Path), "zone-"+model.OpaqueToken(z.ZoneUID)+"-"+model.Hash(data)[:16]+".db") + files[path] = data + fmt.Fprintf(&out, " zone %q { type primary; file %q; allow-transfer { none; }; notify no; };\n", strings.TrimSuffix(model.AbsoluteName(z.Apex), "."), path) + } + fmt.Fprint(&out, "};\n\n") + } + renderManagementAndReject(&out, c.ListenAddress) + return out.Bytes(), nil +} + +func renderViewStart(out *bytes.Buffer, b model.Binding, dest, defaultCache string, maxNegativeCacheTTL uint32) { + cache := defaultCache + if cache == "" { + cache = "16M" + } + fmt.Fprintf(out, "view %q {\n match-clients { any; };\n match-destinations { %s; };\n allow-query { any; };\n allow-recursion { any; };\n allow-query-cache { any; };\n max-cache-size %s;\n max-ncache-ttl %d;\n", b.ViewName(), dest, cache, maxNegativeCacheTTL) + // Private authoritative zones are intentionally unsigned and have no DS in + // the public root chain. Keep DNSSEC validation enabled for public recursion, + // but exclude every private apex attached to this isolated view. + apices := make([]string, 0, len(b.Zones)) + seen := map[string]bool{} + for _, zone := range b.Zones { + apex := strings.TrimSuffix(model.AbsoluteName(zone.Apex), ".") + if !seen[apex] { + seen[apex] = true + apices = append(apices, apex) + } + } + if len(apices) > 0 { + sort.Strings(apices) + fmt.Fprint(out, " validate-except { ") + for _, apex := range apices { + fmt.Fprintf(out, "%q; ", apex) + } + fmt.Fprint(out, "};\n") + } +} + +func maxNegativeCacheTTL(c BINDRenderConfig) uint32 { + if c.MaxNegativeCacheTTLSeconds == 0 { + return 5 + } + return c.MaxNegativeCacheTTLSeconds +} + +func renderManagementAndReject(out *bytes.Buffer, listen string) { + fmt.Fprintf(out, "view \"management\" {\n match-clients { any; };\n match-destinations { %s; };\n recursion no;\n allow-query { any; };\n allow-query-cache { none; };\n};\n\n", listen) + fmt.Fprint(out, "view \"reject\" {\n match-clients { any; };\n match-destinations { any; };\n recursion no;\n allow-query { none; };\n allow-query-cache { none; };\n};\n") +} +func renderAddrPort(out *bytes.Buffer, addr string, port uint16) { + fmt.Fprint(out, addr) + if port != 53 { + fmt.Fprintf(out, " port %d", port) + } + fmt.Fprint(out, "; ") +} +func safeNamedAtom(s string) bool { + if s == "" { + return false + } + for _, r := range s { + if (r < 'a' || r > 'z') && (r < 'A' || r > 'Z') && (r < '0' || r > '9') && r != '_' && r != '-' { + return false + } + } + return true +} diff --git a/internal/internaldns/serving/render_test.go b/internal/internaldns/serving/render_test.go new file mode 100644 index 00000000..e2b8d0e7 --- /dev/null +++ b/internal/internaldns/serving/render_test.go @@ -0,0 +1,159 @@ +package serving + +import ( + "strings" + "testing" + "time" + + "go.miloapis.com/dns-operator/internal/internaldns/model" +) + +func renderFixture(t *testing.T) (RenderConfig, model.ServingSnapshot) { + t.Helper() + now := time.Now() + b := model.Binding{BindingUID: "bind-a", ProjectUID: "project-a", ContextUID: "vpc-a", BindingGeneration: 1, ConfigurationRevision: 3, ConsumerAddress: "10.253.0.51", ClusterAddress: "10.253.0.41", Port: 53, Transports: []model.Transport{model.TransportUDP, model.TransportTCP}, Zones: []model.ZoneAttachment{{ZoneUID: "zone-a", Apex: "prod.internal", RequiredPublicationEpoch: 1, RequiredPublicationRevision: 2}}, Authorization: model.Authorization{IssuerEpoch: 1, Revision: 2, ValidUntil: now.Add(time.Minute)}} + c := RenderConfig{NodeDNSDist: DNSDistRenderConfig{Path: "/tmp/node-dnsdist.conf", ListenAddress: "0.0.0.0:53", ACLs: []string{"10.253.0.0/24"}, PoolName: "node-shared", Backends: []model.Backend{{MemberID: "node-bind-0", Address: "10.253.0.30", Port: 5300}}}, ClusterDNSDist: DNSDistRenderConfig{Path: "/tmp/cluster-dnsdist.conf", ListenAddress: "0.0.0.0:53", ACLs: []string{"10.253.0.0/24"}, PoolName: "cluster-shared", Backends: []model.Backend{{MemberID: "cluster-bind-0", Address: "10.253.0.20", Port: 5300}}}, NodeBIND: BINDRenderConfig{Path: "/tmp/node-bind.conf", ListenAddress: "10.253.0.30", Port: 5300, ProxyPeers: []string{"10.253.0.50"}, DefaultCacheSize: "16M"}, ClusterBIND: BINDRenderConfig{Path: "/tmp/cluster-bind.conf", ListenAddress: "10.253.0.20", Port: 5300, ProxyPeers: []string{"10.253.0.40"}, DefaultCacheSize: "16M"}} + s := model.ServingSnapshot{Region: "east", Shard: "s1", ConfigurationEpoch: 1, ConfigurationRevision: 3, GeneratedAt: now, Bindings: []model.Binding{b}} + return c, s +} + +func TestRenderStagesViewsButActivatesOnlyReadyBindings(t *testing.T) { + t.Parallel() + c, s := renderFixture(t) + r, err := Render(c, s, map[string]bool{}) + if err != nil { + t.Fatal(err) + } + if bindingReadyInConfig(string(r.Files[c.NodeDNSDist.Path]), "10.253.0.51") { + t.Fatal("unready binding activated in dnsdist") + } + if !strings.Contains(string(r.Files[c.NodeBIND.Path]), "10.253.0.51") { + t.Fatal("view was not staged before activation") + } + r, err = Render(c, s, map[string]bool{"bind-a": true}) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(r.Files[c.NodeDNSDist.Path]), "10.253.0.51") { + t.Fatal("ready binding absent") + } + if strings.Contains(string(r.Files[c.NodeDNSDist.Path]), "newPacketCache") { + t.Fatal("dnsdist packet cache enabled") + } +} + +func TestRenderUsesOpaqueViewAndPrivatePROXYListener(t *testing.T) { + t.Parallel() + c, s := renderFixture(t) + r, err := Render(c, s, map[string]bool{"bind-a": true}) + if err != nil { + t.Fatal(err) + } + cluster := string(r.Files[c.ClusterBIND.Path]) + if strings.Contains(cluster, "vpc-a") || !strings.Contains(cluster, "proxy plain") || strings.Contains(cluster, "query-source") || !strings.Contains(cluster, "forward only") { + t.Fatalf("unsafe or incomplete cluster config:\n%s", cluster) + } + for path, config := range map[string]string{c.NodeBIND.Path: string(r.Files[c.NodeBIND.Path]), c.ClusterBIND.Path: cluster} { + if !strings.Contains(config, `dnssec-validation auto;`) || !strings.Contains(config, `validate-except { "prod.internal"; };`) { + t.Fatalf("%s does not retain public DNSSEC while excluding the private apex:\n%s", path, config) + } + if !strings.Contains(config, "max-ncache-ttl 5;") { + t.Fatalf("%s does not bound private negative cache retention:\n%s", path, config) + } + } +} + +func TestRenderConfiguresPrivateNegativeCacheBoundPerTier(t *testing.T) { + t.Parallel() + c, snapshot := renderFixture(t) + c.NodeBIND.MaxNegativeCacheTTLSeconds = 3 + c.ClusterBIND.MaxNegativeCacheTTLSeconds = 7 + rendered, err := Render(c, snapshot, map[string]bool{"bind-a": true}) + if err != nil { + t.Fatal(err) + } + if config := string(rendered.Files[c.NodeBIND.Path]); !strings.Contains(config, "max-ncache-ttl 3;") { + t.Fatalf("node view missing configured negative cache bound:\n%s", config) + } + if config := string(rendered.Files[c.ClusterBIND.Path]); !strings.Contains(config, "max-ncache-ttl 7;") { + t.Fatalf("cluster view missing configured negative cache bound:\n%s", config) + } + c.NodeBIND.MaxNegativeCacheTTLSeconds = 61 + if _, err := Render(c, snapshot, map[string]bool{"bind-a": true}); err == nil { + t.Fatal("unsafe negative cache retention was accepted") + } +} + +func TestRenderDNSSECExcludesEveryPrivateApexDeterministically(t *testing.T) { + t.Parallel() + c, snapshot := renderFixture(t) + snapshot.Bindings[0].Zones = append(snapshot.Bindings[0].Zones, + model.ZoneAttachment{ZoneUID: "zone-z", Apex: "z.private", RequiredPublicationEpoch: 1, RequiredPublicationRevision: 1}, + model.ZoneAttachment{ZoneUID: "zone-a2", Apex: "a.private", RequiredPublicationEpoch: 1, RequiredPublicationRevision: 1}, + ) + rendered, err := Render(c, snapshot, map[string]bool{"bind-a": true}) + if err != nil { + t.Fatal(err) + } + want := `validate-except { "a.private"; "prod.internal"; "z.private"; };` + for _, path := range []string{c.NodeBIND.Path, c.ClusterBIND.Path} { + if !strings.Contains(string(rendered.Files[path]), want) { + t.Fatalf("%s missing sorted multi-zone DNSSEC exclusions:\n%s", path, rendered.Files[path]) + } + } +} + +func TestRenderRoleOwnsOnlyItsTier(t *testing.T) { + t.Parallel() + for _, tc := range []struct { + role RenderRole + want []string + not []string + }{ + {RenderNode, []string{"/tmp/node-dnsdist.conf", "/tmp/node-bind.conf"}, []string{"/tmp/cluster-dnsdist.conf", "/tmp/cluster-bind.conf"}}, + {RenderCluster, []string{"/tmp/cluster-dnsdist.conf", "/tmp/cluster-bind.conf"}, []string{"/tmp/node-dnsdist.conf", "/tmp/node-bind.conf"}}, + } { + t.Run(string(tc.role), func(t *testing.T) { + c, s := renderFixture(t) + c.Role = tc.role + r, err := Render(c, s, map[string]bool{"bind-a": true}) + if err != nil { + t.Fatal(err) + } + for _, path := range tc.want { + if _, ok := r.Files[path]; !ok { + t.Fatalf("role %s did not render %s", tc.role, path) + } + } + for _, path := range tc.not { + if _, ok := r.Files[path]; ok { + t.Fatalf("role %s unexpectedly owns %s", tc.role, path) + } + } + }) + } +} + +func TestDNSDistIncludesDeadlineAndBackendHealthCheck(t *testing.T) { + t.Parallel() + c, s := renderFixture(t) + r, err := Render(c, s, map[string]bool{"bind-a": true}) + if err != nil { + t.Fatal(err) + } + conf := string(r.Files[c.NodeDNSDist.Path]) + for _, required := range []string{"validUntil=", "os.time() >= binding.validUntil", "checkName=\".\"", "useProxyProtocol=true"} { + if !strings.Contains(conf, required) { + t.Fatalf("dnsdist config missing %q:\n%s", required, conf) + } + } +} + +func bindingReadyInConfig(config, address string) bool { + for _, line := range strings.Split(config, "\n") { + if strings.Contains(line, "[\""+address+"\"]") && strings.Contains(line, "ready=true") { + return true + } + } + return false +} diff --git a/internal/internaldns/serving/transaction.go b/internal/internaldns/serving/transaction.go new file mode 100644 index 00000000..3043e7e9 --- /dev/null +++ b/internal/internaldns/serving/transaction.go @@ -0,0 +1,324 @@ +package serving + +import ( + "context" + "errors" + "fmt" + "os" + "os/exec" + "path/filepath" + "sort" + "strconv" + "strings" + "time" +) + +type Command struct { + Path string `json:"path"` + Args []string `json:"args,omitempty"` + WhenChanged []string `json:"whenChanged,omitempty"` +} + +type Runner interface { + Run(context.Context, Command) ([]byte, error) +} + +type ExecRunner struct{} + +func (ExecRunner) Run(ctx context.Context, c Command) ([]byte, error) { + if c.Path == "" { + return nil, errors.New("command path is required") + } + return exec.CommandContext(ctx, c.Path, c.Args...).CombinedOutput() +} + +type TransactionConfig struct { + StageDir string `json:"stageDir"` + CommandStageDir string `json:"commandStageDir,omitempty"` + Validate []Command `json:"validate"` + Reload []Command `json:"reload"` + FailClosed []Command `json:"failClosed,omitempty"` +} + +type FileTransaction struct { + Config TransactionConfig + Runner Runner +} +type oldFile struct { + data []byte + mode os.FileMode + existed bool +} + +func (t FileTransaction) Apply(ctx context.Context, files map[string][]byte) error { + return t.apply(ctx, files, false) +} + +// ApplyForce validates and reloads every configured daemon even when the +// installed bytes already match. It is used after fail-closed startup because +// file equality cannot prove that a stopped or firewalled process is active. +func (t FileTransaction) ApplyForce(ctx context.Context, files map[string][]byte) error { + return t.apply(ctx, files, true) +} + +func (t FileTransaction) apply(ctx context.Context, files map[string][]byte, force bool) error { + if t.Runner == nil { + t.Runner = ExecRunner{} + } + if !filepath.IsAbs(t.Config.StageDir) || len(files) == 0 { + return errors.New("absolute stage directory and rendered files are required") + } + if err := os.MkdirAll(t.Config.StageDir, 0o750); err != nil { + return err + } + stage, err := os.MkdirTemp(t.Config.StageDir, ".internal-dns-stage-") + if err != nil { + return err + } + // Validators commonly drop privileges inside their container. The staged + // directory and generated routing configuration contain no credentials, so + // make them traversable/readable by those service accounts. + if err := os.Chmod(stage, 0o755); err != nil { + return err + } + defer func() { _ = os.RemoveAll(stage) }() + paths := make([]string, 0, len(files)) + seenBase := map[string]bool{} + for path := range files { + if !filepath.IsAbs(path) { + return fmt.Errorf("rendered path %q is not absolute", path) + } + base := filepath.Base(path) + if seenBase[base] { + return fmt.Errorf("duplicate rendered basename %q", base) + } + seenBase[base] = true + paths = append(paths, path) + } + sort.Strings(paths) + old := map[string]oldFile{} + changed := map[string]bool{} + for _, path := range paths { + b, e := os.ReadFile(path) + if e == nil { + info, _ := os.Stat(path) + old[path] = oldFile{data: b, mode: info.Mode().Perm(), existed: true} + changed[path] = string(b) != string(files[path]) + } else if !errors.Is(e, os.ErrNotExist) { + return e + } else { + changed[path] = true + } + } + stagedPaths := make([]string, 0, len(paths)) + for _, path := range paths { + stagedPath := filepath.Join(stage, filepath.Base(path)) + stagedData := files[path] + if strings.HasSuffix(path, ".conf") { + // Validate the newly staged zone contents, never the previous live files. + commandStage := stage + if t.Config.CommandStageDir != "" { + commandStage = filepath.Join(t.Config.CommandStageDir, filepath.Base(stage)) + } + text := string(stagedData) + for renderedPath := range files { + if strings.HasSuffix(renderedPath, ".db") { + text = strings.ReplaceAll(text, strconv.Quote(renderedPath), strconv.Quote(filepath.Join(commandStage, filepath.Base(renderedPath)))) + } + } + stagedData = []byte(text) + } + if err := writeDurable(stagedPath, stagedData, 0o644); err != nil { + return err + } + stagedPaths = append(stagedPaths, stagedPath) + } + // Persist directory entries before a validator in another container reads + // this shared bind mount. File fsync alone does not make new names durable. + if err := syncDirs(stagedPaths); err != nil { + return err + } + commandStage := stage + if t.Config.CommandStageDir != "" { + commandStage = filepath.Join(t.Config.CommandStageDir, filepath.Base(stage)) + } + commandChanges := changed + if force { + commandChanges = nil + } + if err := t.commandsForChanges(ctx, t.Config.Validate, map[string]string{"{stageDir}": commandStage}, commandChanges); err != nil { + return fmt.Errorf("validate staged serving configuration: %w", err) + } + installed := make([]string, 0, len(paths)) + for _, path := range paths { + if !changed[path] { + continue + } + if err := os.MkdirAll(filepath.Dir(path), 0o750); err != nil { + _ = restoreFiles(installed, old) + return err + } + // Installed configuration refers to live zone paths; staging rewrites exist + // only for validation. Prepare original bytes before atomic rename. + if err := writeDurable(filepath.Join(stage, filepath.Base(path)), files[path], 0o644); err != nil { + return errors.Join(err, restoreFiles(installed, old)) + } + if err := os.Rename(filepath.Join(stage, filepath.Base(path)), path); err != nil { + _ = restoreFiles(installed, old) + return err + } + installed = append(installed, path) + } + if err := syncDirs(installed); err != nil { + rollbackErr := restoreFiles(installed, old) + return errors.Join(err, rollbackErr) + } + if err := t.commandsForChanges(ctx, t.Config.Reload, nil, commandChanges); err != nil { + rollbackErr := restoreFiles(paths, old) + reloadErr := t.commandsForChanges(ctx, t.Config.Reload, nil, commandChanges) + return errors.Join(fmt.Errorf("reload serving configuration: %w", err), rollbackErr, reloadErr) + } + return nil +} + +func (t FileTransaction) FailClosed(ctx context.Context) error { + if t.Runner == nil { + t.Runner = ExecRunner{} + } + return t.commands(ctx, t.Config.FailClosed, nil) +} + +func (t FileTransaction) commands(ctx context.Context, commands []Command, replacements map[string]string) error { + return t.commandsForChanges(ctx, commands, replacements, nil) +} + +func (t FileTransaction) commandsForChanges(ctx context.Context, commands []Command, replacements map[string]string, changed map[string]bool) error { + for _, c := range commands { + // Command is copied by value, but its Args slice would otherwise retain + // replacements in the configured transaction. Every Apply uses a fresh + // staging directory, so never mutate the shared backing array. + c.Args = append([]string(nil), c.Args...) + if changed != nil && len(c.WhenChanged) > 0 { + run := false + for _, path := range c.WhenChanged { + if changed[path] { + run = true + break + } + } + if !run { + continue + } + } + c.Path = replace(c.Path, replacements) + for i := range c.Args { + c.Args[i] = replace(c.Args[i], replacements) + } + out, err := t.Runner.Run(ctx, c) + if err != nil { + return fmt.Errorf("%s %s: %w: %s", c.Path, strings.Join(c.Args, " "), err, strings.TrimSpace(string(out))) + } + } + return nil +} +func replace(s string, r map[string]string) string { + for old, newValue := range r { + s = strings.ReplaceAll(s, old, newValue) + } + return s +} + +func writeDurable(path string, data []byte, mode os.FileMode) error { + f, err := os.OpenFile(path, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, mode) + if err != nil { + return err + } + if _, err = f.Write(data); err != nil { + _ = f.Close() + return err + } + if err = f.Sync(); err != nil { + _ = f.Close() + return err + } + return f.Close() +} + +// writeAtomicLease replaces the ephemeral watchdog lease through a unique +// temporary sibling. The file is synced before rename so a reader never accepts +// partially written JSON. The containing directory is deliberately not synced: +// metadata fsync on a container bind mount can block longer than the lease, and +// restart always gates before bootstrap so this lease never needs crash +// survival. +func writeAtomicLease(path string, data []byte, mode os.FileMode) error { + dir := filepath.Dir(path) + temporary, err := os.CreateTemp(dir, "."+filepath.Base(path)+".tmp-") + if err != nil { + return err + } + temporaryPath := temporary.Name() + defer func() { _ = os.Remove(temporaryPath) }() + if err := temporary.Chmod(mode); err != nil { + _ = temporary.Close() + return err + } + if _, err := temporary.Write(data); err != nil { + _ = temporary.Close() + return err + } + if err := temporary.Sync(); err != nil { + _ = temporary.Close() + return err + } + if err := temporary.Close(); err != nil { + return err + } + if err := os.Rename(temporaryPath, path); err != nil { + return err + } + return nil +} +func syncDirs(paths []string) error { + seen := map[string]bool{} + for _, path := range paths { + dir := filepath.Dir(path) + if seen[dir] { + continue + } + seen[dir] = true + f, err := os.Open(dir) + if err != nil { + return err + } + err = f.Sync() + closeErr := f.Close() + if err != nil { + return err + } + if closeErr != nil { + return closeErr + } + } + return nil +} +func restoreFiles(paths []string, old map[string]oldFile) error { + var errs []error + for _, path := range paths { + value := old[path] + if !value.existed { + if err := os.Remove(path); err != nil && !errors.Is(err, os.ErrNotExist) { + errs = append(errs, err) + } + continue + } + temp := path + fmt.Sprintf(".rollback-%d", time.Now().UnixNano()) + if err := writeDurable(temp, value.data, value.mode); err != nil { + errs = append(errs, err) + continue + } + if err := os.Rename(temp, path); err != nil { + errs = append(errs, err) + } + } + return errors.Join(errs...) +} diff --git a/internal/internaldns/serving/transaction_test.go b/internal/internaldns/serving/transaction_test.go new file mode 100644 index 00000000..4b55d313 --- /dev/null +++ b/internal/internaldns/serving/transaction_test.go @@ -0,0 +1,262 @@ +package serving + +import ( + "bytes" + "context" + "errors" + "os" + "path/filepath" + "strconv" + "strings" + "testing" +) + +type recordingRunner struct { + calls []Command + failAt int +} + +type readableConfigRunner struct{ t *testing.T } + +func (r readableConfigRunner) Run(_ context.Context, c Command) ([]byte, error) { + stageInfo, err := os.Stat(filepath.Dir(c.Args[0])) + if err != nil { + return nil, err + } + fileInfo, err := os.Stat(c.Args[0]) + if err != nil { + return nil, err + } + if stageInfo.Mode().Perm()&0o005 != 0o005 || fileInfo.Mode().Perm()&0o004 != 0o004 { + r.t.Fatalf("staged config is not service-readable: dir=%#o file=%#o", stageInfo.Mode().Perm(), fileInfo.Mode().Perm()) + } + return nil, nil +} + +func (r *recordingRunner) Run(_ context.Context, c Command) ([]byte, error) { + r.calls = append(r.calls, c) + if r.failAt == len(r.calls) { + return []byte("bad config"), errors.New("exit 1") + } + return nil, nil +} + +func TestFileTransactionValidatesBeforeInstall(t *testing.T) { + t.Parallel() + dir := t.TempDir() + final := filepath.Join(dir, "live", "named.conf") + runner := &recordingRunner{failAt: 1} + tx := FileTransaction{Config: TransactionConfig{StageDir: dir, Validate: []Command{{Path: "check", Args: []string{"{stageDir}/named.conf"}}}}, Runner: runner} + if err := tx.Apply(context.Background(), map[string][]byte{final: []byte("new")}); err == nil { + t.Fatal("invalid config installed") + } + if _, err := os.Stat(final); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("final file exists: %v", err) + } + if got := runner.calls[0].Args[0]; filepath.Base(filepath.Dir(got)) == "live" { + t.Fatalf("validator saw final path %q", got) + } +} + +func TestFileTransactionConfigIsReadableByServiceUID(t *testing.T) { + t.Parallel() + dir := t.TempDir() + final := filepath.Join(dir, "runtime", "dnsdist.conf") + tx := FileTransaction{ + Config: TransactionConfig{StageDir: dir, Validate: []Command{{Path: "check", Args: []string{"{stageDir}/dnsdist.conf"}}}}, + Runner: readableConfigRunner{t: t}, + } + if err := tx.Apply(context.Background(), map[string][]byte{final: []byte("setLocal('127.0.0.1:53')")}); err != nil { + t.Fatal(err) + } + info, err := os.Stat(final) + if err != nil { + t.Fatal(err) + } + if info.Mode().Perm() != 0o644 { + t.Fatalf("installed mode = %#o, want 0644", info.Mode().Perm()) + } +} + +func TestAtomicLeaseWriteReplacesCompleteFile(t *testing.T) { + t.Parallel() + dir := t.TempDir() + path := filepath.Join(dir, "lease.json") + if err := os.WriteFile(path, []byte("old"), 0o600); err != nil { + t.Fatal(err) + } + if err := writeAtomicLease(path, []byte(`{"memberID":"node-0"}`), 0o600); err != nil { + t.Fatal(err) + } + contents, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if string(contents) != `{"memberID":"node-0"}` { + t.Fatalf("atomic durable contents = %q", contents) + } + info, err := os.Stat(path) + if err != nil { + t.Fatal(err) + } + if info.Mode().Perm() != 0o600 { + t.Fatalf("atomic durable mode = %#o", info.Mode().Perm()) + } + temporary, err := filepath.Glob(filepath.Join(dir, ".lease.json.tmp-*")) + if err != nil { + t.Fatal(err) + } + if len(temporary) != 0 { + t.Fatalf("temporary lease files remain: %#v", temporary) + } +} + +func TestFileTransactionPreservesStagePlaceholderAcrossApplies(t *testing.T) { + t.Parallel() + dir := t.TempDir() + final := filepath.Join(dir, "runtime", "dnsdist.conf") + runner := &recordingRunner{} + tx := FileTransaction{ + Config: TransactionConfig{StageDir: dir, Validate: []Command{{Path: "check", Args: []string{"{stageDir}/dnsdist.conf"}}}}, + Runner: runner, + } + if err := tx.Apply(context.Background(), map[string][]byte{final: []byte("revision one")}); err != nil { + t.Fatal(err) + } + if err := tx.Apply(context.Background(), map[string][]byte{final: []byte("revision two")}); err != nil { + t.Fatal(err) + } + if len(runner.calls) != 2 { + t.Fatalf("validator calls = %d, want 2", len(runner.calls)) + } + first, second := runner.calls[0].Args[0], runner.calls[1].Args[0] + if first == second { + t.Fatalf("successive transactions reused staging path %q", first) + } + if tx.Config.Validate[0].Args[0] != "{stageDir}/dnsdist.conf" { + t.Fatalf("transaction config mutated to %q", tx.Config.Validate[0].Args[0]) + } +} + +func TestFileTransactionForceReloadsUnchangedFiles(t *testing.T) { + t.Parallel() + dir := t.TempDir() + final := filepath.Join(dir, "runtime", "dnsdist.conf") + if err := os.MkdirAll(filepath.Dir(final), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(final, []byte("unchanged"), 0o644); err != nil { + t.Fatal(err) + } + runner := &recordingRunner{} + tx := FileTransaction{Config: TransactionConfig{ + StageDir: dir, + Validate: []Command{{Path: "validate", Args: []string{"{stageDir}/dnsdist.conf"}, WhenChanged: []string{final}}}, + Reload: []Command{{Path: "reload", WhenChanged: []string{final}}}, + }, Runner: runner} + if err := tx.ApplyForce(context.Background(), map[string][]byte{final: []byte("unchanged")}); err != nil { + t.Fatal(err) + } + if len(runner.calls) != 2 || runner.calls[0].Path != "validate" || runner.calls[1].Path != "reload" { + t.Fatalf("forced activation calls = %#v", runner.calls) + } +} + +func TestFileTransactionRollsBackFailedReload(t *testing.T) { + t.Parallel() + dir := t.TempDir() + final := filepath.Join(dir, "named.conf") + if err := os.WriteFile(final, []byte("old"), 0o640); err != nil { + t.Fatal(err) + } + runner := &recordingRunner{failAt: 2} + tx := FileTransaction{Config: TransactionConfig{StageDir: dir, Validate: []Command{{Path: "check"}}, Reload: []Command{{Path: "reload"}}}, Runner: runner} + if err := tx.Apply(context.Background(), map[string][]byte{final: []byte("new")}); err == nil { + t.Fatal("reload failure accepted") + } + got, err := os.ReadFile(final) + if err != nil { + t.Fatal(err) + } + if string(got) != "old" { + t.Fatalf("rollback got %q", got) + } + if len(runner.calls) != 3 { + t.Fatalf("got %d commands, want validate + reload + rollback reload", len(runner.calls)) + } +} + +func TestFileTransactionReloadsOnlyChangedTier(t *testing.T) { + t.Parallel() + dir := t.TempDir() + dnsdist := filepath.Join(dir, "dnsdist.conf") + named := filepath.Join(dir, "named.conf") + if err := os.WriteFile(dnsdist, []byte("old deadline"), 0o640); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(named, []byte("unchanged"), 0o640); err != nil { + t.Fatal(err) + } + runner := &recordingRunner{} + tx := FileTransaction{Config: TransactionConfig{ + StageDir: dir, + Validate: []Command{ + {Path: "validate-dnsdist", Args: []string{"{stageDir}/dnsdist.conf"}, WhenChanged: []string{dnsdist}}, + {Path: "validate-bind", Args: []string{"{stageDir}/named.conf"}, WhenChanged: []string{named}}, + }, + Reload: []Command{ + {Path: "reload-dnsdist", WhenChanged: []string{dnsdist}}, + {Path: "reload-bind", WhenChanged: []string{named}}, + }, + }, Runner: runner} + if err := tx.Apply(context.Background(), map[string][]byte{dnsdist: []byte("new deadline"), named: []byte("unchanged")}); err != nil { + t.Fatal(err) + } + if len(runner.calls) != 2 || runner.calls[0].Path != "validate-dnsdist" || runner.calls[1].Path != "reload-dnsdist" { + t.Fatalf("reload calls = %#v", runner.calls) + } + if got, err := os.ReadFile(named); err != nil || string(got) != "unchanged" { + t.Fatalf("unchanged BIND file was disturbed: %q, %v", got, err) + } +} + +type transactionRunnerFunc func(context.Context, Command) ([]byte, error) + +func (f transactionRunnerFunc) Run(ctx context.Context, c Command) ([]byte, error) { return f(ctx, c) } + +func TestFileTransactionValidatesFreshStagedZoneReferences(t *testing.T) { + t.Parallel() + dir := t.TempDir() + config := filepath.Join(dir, "live", "named.conf") + zone := filepath.Join(dir, "live", "zone.db") + runner := transactionRunnerFunc(func(_ context.Context, c Command) ([]byte, error) { + data, err := os.ReadFile(c.Args[0]) + if err != nil { + return nil, err + } + staged := filepath.Join(filepath.Dir(c.Args[0]), "zone.db") + if !strings.Contains(string(data), strconv.Quote(staged)) || strings.Contains(string(data), strconv.Quote(zone)) { + t.Fatalf("validator sees live zone: %s", data) + } + content, err := os.ReadFile(staged) + if err != nil { + return nil, err + } + if string(content) != "new zone" { + t.Fatalf("staged content=%s", content) + } + return nil, nil + }) + tx := FileTransaction{Config: TransactionConfig{StageDir: filepath.Join(dir, "stage"), Validate: []Command{{Path: "named-checkconf", Args: []string{"{stageDir}/named.conf"}}}}, Runner: runner} + original := []byte("zone \"test.internal\" { type primary; file " + strconv.Quote(zone) + "; };\n") + if err := tx.Apply(context.Background(), map[string][]byte{config: original, zone: []byte("new zone")}); err != nil { + t.Fatal(err) + } + installed, err := os.ReadFile(config) + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(installed, original) { + t.Fatal("installed config retains temporary staging paths") + } +} diff --git a/internal/internaldns/serving/verify.go b/internal/internaldns/serving/verify.go new file mode 100644 index 00000000..4a59d572 --- /dev/null +++ b/internal/internaldns/serving/verify.go @@ -0,0 +1,303 @@ +package serving + +import ( + "context" + "encoding/binary" + "errors" + "fmt" + "io" + "net" + "net/netip" + "strconv" + "time" + + "github.com/miekg/dns" + "go.miloapis.com/dns-operator/internal/internaldns/model" +) + +const ( + networkTCP = "tcp" + networkUDP = "udp" +) + +type DNSVerifierConfig struct { + Timeout time.Duration `json:"timeout,omitempty"` + PublicName string `json:"publicName,omitempty"` + UseClusterAddress bool `json:"useClusterAddress,omitempty"` +} +type DNSVerifier struct{ Config DNSVerifierConfig } + +type resolverExchange func(context.Context, string, string, string, time.Duration) (*dns.Msg, error) + +// Verify probes the complete consumer-facing path over both required DNS +// transports. Every attached private apex must return a non-failure response. +func (v DNSVerifier) Verify(ctx context.Context, b model.Binding) error { + timeout := v.Config.Timeout + if timeout <= 0 { + timeout = 3 * time.Second + } + names := []string{} + for _, z := range b.Zones { + names = append(names, model.AbsoluteName(z.Apex)) + } + if v.Config.PublicName != "" { + names = append(names, model.AbsoluteName(v.Config.PublicName)) + } + if len(names) == 0 { + // A recursion-only context has no private SOA to prove. The root SOA + // exercises recursive resolution without choosing a product domain. + names = append(names, ".") + } + server := resolverProbeServer(v.Config, b) + return verifyResolverPath(ctx, server, names, timeout, exchangeResolverSOA) +} + +func resolverProbeServer(config DNSVerifierConfig, binding model.Binding) string { + address := binding.ConsumerAddress + if config.UseClusterAddress { + address = binding.ClusterAddress + } + return net.JoinHostPort(address, strconv.Itoa(int(binding.Port))) +} + +func exchangeResolverSOA(ctx context.Context, network, server, name string, timeout time.Duration) (*dns.Msg, error) { + client := &dns.Client{Net: network, Timeout: timeout} + msg := new(dns.Msg) + msg.SetQuestion(name, dns.TypeSOA) + answer, _, err := client.ExchangeContext(ctx, msg, server) + return answer, err +} + +func verifyResolverPath(ctx context.Context, server string, names []string, timeout time.Duration, exchange resolverExchange) error { + deadline := time.Now().Add(timeout) + for _, network := range []string{networkUDP, networkTCP} { + for _, name := range names { + var last error + for { + remaining := time.Until(deadline) + if remaining <= 0 { + return fmt.Errorf("%s probe %s through %s did not become ready within %s: %w", network, name, server, timeout, last) + } + attemptTimeout := min(remaining, 250*time.Millisecond) + answer, err := exchange(ctx, network, server, name, attemptTimeout) + switch { + case err != nil: + last = fmt.Errorf("exchange: %w", err) + case answer == nil: + last = errors.New("empty DNS response") + case answer.Rcode != dns.RcodeSuccess: + last = fmt.Errorf("returned %s", dns.RcodeToString[answer.Rcode]) + case !hasSOA(answer): + last = fmt.Errorf("returned no authoritative SOA (rcode %s)", dns.RcodeToString[answer.Rcode]) + default: + last = nil + } + if last == nil { + break + } + delay := min(100*time.Millisecond, time.Until(deadline)) + if delay <= 0 { + continue + } + timer := time.NewTimer(delay) + select { + case <-ctx.Done(): + timer.Stop() + return ctx.Err() + case <-timer.C: + } + } + } + } + return nil +} + +type PublicationView struct { + View string + DestinationAddress string + DestinationPort uint16 + Serial uint32 + FingerprintMailbox string +} +type PublicationVerifier interface { + VerifyPublication(context.Context, model.PublicationPlan, []PublicationView) error +} +type PublicationDNSVerifierConfig struct { + Server string `json:"server"` + Timeout time.Duration `json:"timeout,omitempty"` +} +type PublicationDNSVerifier struct{ Config PublicationDNSVerifierConfig } + +// Probe the member's BIND process directly with the protected destination +// identity. Querying the shared dnsdist pool could falsely prove another member. +func (v PublicationDNSVerifier) VerifyPublication(ctx context.Context, plan model.PublicationPlan, views []PublicationView) error { + if v.Config.Server == "" { + return errors.New("regional BIND probe server is required") + } + if len(views) == 0 { + return errors.New("publication is not installed in any authorized context") + } + timeout := v.Config.Timeout + if timeout <= 0 { + timeout = 3 * time.Second + } + return verifyPublicationViews(ctx, v.Config.Server, model.AbsoluteName(plan.Apex), views, timeout, exchangePublication) +} + +type publicationExchange func(context.Context, string, string, string, PublicationView, time.Duration) (*dns.Msg, error) + +// BIND reload may return before every view exposes its new zone. Retry the +// exact installed fingerprint while retaining one deadline for the entire proof. +func verifyPublicationViews(ctx context.Context, server, apex string, views []PublicationView, timeout time.Duration, exchange publicationExchange) error { + probeCtx, cancel := context.WithTimeout(ctx, timeout) + defer cancel() + deadline, _ := probeCtx.Deadline() + for _, view := range views { + for _, network := range []string{networkUDP, networkTCP} { + var last error + for { + if err := publicationDeadlineError(probeCtx, deadline); err != nil { + return fmt.Errorf("verify BIND view %s over %s within %s: %w (last response: %v)", view.View, network, timeout, err, last) + } + answer, err := exchange(probeCtx, network, server, apex, view, min(time.Until(deadline), 250*time.Millisecond)) + if err != nil { + last = fmt.Errorf("exchange: %w", err) + } else { + last = publicationAnswerError(answer, view) + } + // An exchange finishing after the total deadline cannot prove readiness. + if err := publicationDeadlineError(probeCtx, deadline); err != nil { + return fmt.Errorf("verify BIND view %s over %s within %s: %w (last response: %v)", view.View, network, timeout, err, last) + } + if last == nil { + break + } + timer := time.NewTimer(min(25*time.Millisecond, time.Until(deadline))) + select { + case <-probeCtx.Done(): + timer.Stop() + return fmt.Errorf("verify BIND view %s over %s within %s: %w (last response: %v)", view.View, network, timeout, probeCtx.Err(), last) + case <-timer.C: + } + } + } + } + return nil +} + +func publicationDeadlineError(ctx context.Context, deadline time.Time) error { + if err := ctx.Err(); err != nil { + return err + } + if !time.Now().Before(deadline) { + return context.DeadlineExceeded + } + return nil +} + +func publicationAnswerError(answer *dns.Msg, view PublicationView) error { + if answer == nil { + return errors.New("empty DNS response") + } + if answer.Rcode != dns.RcodeSuccess || !answer.Authoritative { + return errors.New("did not return authoritative success") + } + var observed []string + for _, rr := range answer.Answer { + if soa, ok := rr.(*dns.SOA); ok { + if soa.Serial == view.Serial && soa.Mbox == view.FingerprintMailbox { + return nil + } + observed = append(observed, fmt.Sprintf("serial %d fingerprint %s", soa.Serial, soa.Mbox)) + } + } + return fmt.Errorf("did not return installed publication serial %d and fingerprint %s; returned SOAs %v", view.Serial, view.FingerprintMailbox, observed) +} + +func exchangePublication(ctx context.Context, network, server, name string, view PublicationView, timeout time.Duration) (*dns.Msg, error) { + conn, err := (&net.Dialer{Timeout: timeout}).DialContext(ctx, network, server) + if err != nil { + return nil, err + } + defer func() { _ = conn.Close() }() + deadline := time.Now().Add(timeout) + if d, ok := ctx.Deadline(); ok && d.Before(deadline) { + deadline = d + } + if err := conn.SetDeadline(deadline); err != nil { + return nil, err + } + msg := new(dns.Msg) + msg.SetQuestion(name, dns.TypeSOA) + wire, err := msg.Pack() + if err != nil { + return nil, err + } + destination, err := netip.ParseAddr(view.DestinationAddress) + if err != nil { + return nil, err + } + // Source is immaterial for context selection; destination identity is trusted + // only because the direct peer is in BIND's protected proxy ACL. + header := []byte{13, 10, 13, 10, 0, 13, 10, 81, 85, 73, 84, 10, 0x21, 0, 0, 0} + proto := byte(2) + if network == networkTCP { + proto = 1 + } + if destination.Is4() { + header[13] = 0x10 | proto + header[15] = 12 + header = append(header, 127, 0, 0, 1) + ip := destination.As4() + header = append(header, ip[:]...) + } else { + header[13] = 0x20 | proto + header[15] = 36 + source := netip.IPv6Loopback().As16() + header = append(header, source[:]...) + ip := destination.As16() + header = append(header, ip[:]...) + } + header = binary.BigEndian.AppendUint16(header, 53000) + header = binary.BigEndian.AppendUint16(header, view.DestinationPort) + if network == networkTCP { + header = binary.BigEndian.AppendUint16(header, uint16(len(wire))) + } + if _, err := conn.Write(append(header, wire...)); err != nil { + return nil, err + } + var reply []byte + if network == networkTCP { + size := make([]byte, 2) + if _, err := io.ReadFull(conn, size); err != nil { + return nil, err + } + reply = make([]byte, binary.BigEndian.Uint16(size)) + if _, err := io.ReadFull(conn, reply); err != nil { + return nil, err + } + } else { + buf := make([]byte, 65535) + n, err := conn.Read(buf) + if err != nil { + return nil, err + } + reply = buf[:n] + } + answer := new(dns.Msg) + if err := answer.Unpack(reply); err != nil { + return nil, err + } + if answer.Id != msg.Id { + return nil, errors.New("DNS probe transaction ID mismatch") + } + return answer, nil +} +func hasSOA(answer *dns.Msg) bool { + for _, rr := range append(answer.Answer, answer.Ns...) { + if rr.Header().Rrtype == dns.TypeSOA { + return true + } + } + return false +} diff --git a/internal/internaldns/serving/verify_test.go b/internal/internaldns/serving/verify_test.go new file mode 100644 index 00000000..be86fd68 --- /dev/null +++ b/internal/internaldns/serving/verify_test.go @@ -0,0 +1,327 @@ +package serving + +import ( + "bytes" + "context" + "encoding/binary" + "errors" + "fmt" + "io" + "net" + "strings" + "testing" + "time" + + "github.com/miekg/dns" + "go.miloapis.com/dns-operator/internal/internaldns/model" +) + +func TestResolverProbeAddressMatchesServingTier(t *testing.T) { + t.Parallel() + binding := model.Binding{ConsumerAddress: "2001:db8:1::53", ClusterAddress: "2001:db8:2::53", Port: 53} + if got := resolverProbeServer(DNSVerifierConfig{}, binding); got != "[2001:db8:1::53]:53" { + t.Fatalf("node probe server = %q", got) + } + if got := resolverProbeServer(DNSVerifierConfig{UseClusterAddress: true}, binding); got != "[2001:db8:2::53]:53" { + t.Fatalf("cluster probe server = %q", got) + } +} + +func TestResolverVerifierRetriesTransientPoolWarmup(t *testing.T) { + t.Parallel() + udpAttempts := 0 + exchange := func(_ context.Context, network, _ string, name string, _ time.Duration) (*dns.Msg, error) { + if network == "udp" { + udpAttempts++ + if udpAttempts == 1 { + return nil, errors.New("connection refused") + } + if udpAttempts == 2 { + return &dns.Msg{MsgHdr: dns.MsgHdr{Rcode: dns.RcodeServerFailure}}, nil + } + } + return &dns.Msg{ + MsgHdr: dns.MsgHdr{Rcode: dns.RcodeSuccess}, + Answer: []dns.RR{&dns.SOA{Hdr: dns.RR_Header{Name: name, Rrtype: dns.TypeSOA, Class: dns.ClassINET, Ttl: 30}}}, + }, nil + } + if err := verifyResolverPath(context.Background(), "[fd00::53]:53", []string{"private.example."}, time.Second, exchange); err != nil { + t.Fatal(err) + } + if udpAttempts != 3 { + t.Fatalf("UDP attempts = %d, want transport error + SERVFAIL + success", udpAttempts) + } +} + +func TestResolverVerifierBoundsWarmupRetries(t *testing.T) { + t.Parallel() + started := time.Now() + err := verifyResolverPath(context.Background(), "127.0.0.1:53", []string{"private.example."}, 40*time.Millisecond, func(context.Context, string, string, string, time.Duration) (*dns.Msg, error) { + return &dns.Msg{MsgHdr: dns.MsgHdr{Rcode: dns.RcodeServerFailure}}, nil + }) + if err == nil { + t.Fatal("permanent SERVFAIL was accepted") + } + if elapsed := time.Since(started); elapsed > 250*time.Millisecond { + t.Fatalf("bounded verifier took %s", elapsed) + } +} + +func TestPublicationVerifierProvesMemberContextAndFingerprintOverBothTransports(t *testing.T) { + for _, wrong := range []bool{false, true} { + t.Run(fmt.Sprint(wrong), func(t *testing.T) { + tcp, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + defer func() { _ = tcp.Close() }() + udp, err := net.ListenPacket("udp", tcp.Addr().String()) + if err != nil { + t.Fatal(err) + } + defer func() { _ = udp.Close() }() + seen := make(chan string, 2) + response := func(wire []byte, network string) []byte { + if len(wire) < 28 || wire[12] != 0x21 || !bytes.Equal(wire[20:24], []byte{10, 20, 0, 1}) { + t.Errorf("probe lost protected destination: %x", wire) + return nil + } + request := new(dns.Msg) + payload := wire[28:] + if network == "tcp" { + payload = payload[2:] + } + if err := request.Unpack(payload); err != nil { + t.Error(err) + return nil + } + reply := new(dns.Msg) + reply.SetReply(request) + reply.Authoritative = true + mailbox := "a.b.publication.internal." + if wrong { + mailbox = "old.b.publication.internal." + } + reply.Answer = []dns.RR{&dns.SOA{Hdr: dns.RR_Header{Name: "prod.internal.", Rrtype: dns.TypeSOA, Class: dns.ClassINET, Ttl: 5}, Ns: "ns1.internal-dns.invalid.", Mbox: mailbox, Serial: 7}} + data, _ := reply.Pack() + seen <- network + return data + } + go func() { + buf := make([]byte, 65535) + n, peer, err := udp.ReadFrom(buf) + if err != nil { + return + } + data := response(buf[:n], "udp") + _, _ = udp.WriteTo(data, peer) + }() + go func() { + conn, err := tcp.Accept() + if err != nil { + return + } + defer func() { _ = conn.Close() }() + header := make([]byte, 28) + if _, err := io.ReadFull(conn, header); err != nil { + return + } + size := make([]byte, 2) + if _, err := io.ReadFull(conn, size); err != nil { + return + } + body := make([]byte, binary.BigEndian.Uint16(size)) + if _, err := io.ReadFull(conn, body); err != nil { + return + } + data := response(append(append(header, size...), body...), "tcp") + _, _ = conn.Write(append(binary.BigEndian.AppendUint16(nil, uint16(len(data))), data...)) + }() + verifier := PublicationDNSVerifier{Config: PublicationDNSVerifierConfig{Server: tcp.Addr().String(), Timeout: time.Second}} + err = verifier.VerifyPublication(context.Background(), model.PublicationPlan{Apex: "prod.internal"}, []PublicationView{{View: "context-a", DestinationAddress: "10.20.0.1", DestinationPort: 53, Serial: 7, FingerprintMailbox: "a.b.publication.internal."}}) + if wrong { + if err == nil { + t.Fatal("old publication mailbox accepted despite matching serial") + } + return + } + if err != nil { + t.Fatal(err) + } + if len(seen) != 2 { + t.Fatal("both DNS transports were not proved") + } + }) + } +} + +func TestPublicationVerifierHasOneDeadlineForAllContextsAndTransports(t *testing.T) { + t.Parallel() + tcp, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + defer func() { _ = tcp.Close() }() + udp, err := net.ListenPacket("udp", tcp.Addr().String()) + if err != nil { + t.Fatal(err) + } + defer func() { _ = udp.Close() }() + reply := func(wire []byte) []byte { + request := new(dns.Msg) + if err := request.Unpack(wire); err != nil { + t.Error(err) + return nil + } + answer := new(dns.Msg) + answer.SetReply(request) + answer.Authoritative = true + answer.Answer = []dns.RR{&dns.SOA{Hdr: dns.RR_Header{Name: "prod.internal.", Rrtype: dns.TypeSOA, Class: dns.ClassINET, Ttl: 5}, Ns: "ns1.internal-dns.invalid.", Mbox: "a.b.publication.internal.", Serial: 1}} + data, _ := answer.Pack() + return data + } + // Two complete slow successes precede an unresponsive third context. A + // per-exchange budget takes at least 300ms; one overall budget takes 100ms. + go func() { + for i := 0; i < 3; i++ { + buf := make([]byte, 65535) + n, peer, err := udp.ReadFrom(buf) + if err != nil { + return + } + if i == 2 { + return + } + time.Sleep(50 * time.Millisecond) + _, _ = udp.WriteTo(reply(buf[28:n]), peer) + } + }() + go func() { + for i := 0; i < 2; i++ { + conn, err := tcp.Accept() + if err != nil { + return + } + header := make([]byte, 30) + if _, err := io.ReadFull(conn, header); err != nil { + _ = conn.Close() + return + } + body := make([]byte, binary.BigEndian.Uint16(header[28:])) + if _, err := io.ReadFull(conn, body); err != nil { + _ = conn.Close() + return + } + time.Sleep(50 * time.Millisecond) + data := reply(body) + _, _ = conn.Write(append(binary.BigEndian.AppendUint16(nil, uint16(len(data))), data...)) + _ = conn.Close() + } + }() + views := make([]PublicationView, 100) + for i := range views { + views[i] = PublicationView{View: fmt.Sprint(i), DestinationAddress: "10.20.0.1", DestinationPort: 53, Serial: 1, FingerprintMailbox: "a.b.publication.internal."} + } + verifier := PublicationDNSVerifier{Config: PublicationDNSVerifierConfig{Server: tcp.Addr().String(), Timeout: 100 * time.Millisecond}} + started := time.Now() + if err := verifier.VerifyPublication(context.Background(), model.PublicationPlan{Apex: "prod.internal"}, views); err == nil { + t.Fatal("unresponsive member was verified") + } + if elapsed := time.Since(started); elapsed > 200*time.Millisecond { + t.Fatalf("context count reset query deadline: %s", elapsed) + } +} + +func TestPublicationVerifierRetriesExactVersionOnBothTransports(t *testing.T) { + t.Parallel() + view := PublicationView{View: "context-a", Serial: 7, FingerprintMailbox: "new.publication.internal."} + attempts := map[string]int{} + err := verifyPublicationViews(context.Background(), "member:53", "prod.internal.", []PublicationView{view}, time.Second, + func(_ context.Context, network, _, _ string, _ PublicationView, _ time.Duration) (*dns.Msg, error) { + attempts[network]++ + if attempts[network] == 1 { + return nil, nil + } + serial, mailbox := view.Serial, view.FingerprintMailbox + switch attempts[network] { + case 2: + serial-- + case 3: + mailbox = "old.publication.internal." + } + return &dns.Msg{MsgHdr: dns.MsgHdr{Authoritative: true}, Answer: []dns.RR{&dns.SOA{Serial: serial, Mbox: mailbox}}}, nil + }) + if err != nil { + t.Fatal(err) + } + if attempts["udp"] != 4 || attempts["tcp"] != 4 { + t.Fatalf("exact proof retries = %v, want nil + old serial + old fingerprint + success per transport", attempts) + } +} + +func TestPublicationVerifierBoundsRepeatedMismatchesAndReportsContext(t *testing.T) { + t.Parallel() + view := PublicationView{View: "context-stale", Serial: 7, FingerprintMailbox: "new.publication.internal."} + started := time.Now() + err := verifyPublicationViews(context.Background(), "member:53", "prod.internal.", []PublicationView{view}, 70*time.Millisecond, + func(context.Context, string, string, string, PublicationView, time.Duration) (*dns.Msg, error) { + return &dns.Msg{MsgHdr: dns.MsgHdr{Authoritative: true}, Answer: []dns.RR{&dns.SOA{Serial: 6, Mbox: "old.publication.internal."}}}, nil + }) + if !errors.Is(err, context.DeadlineExceeded) || !strings.Contains(err.Error(), "context-stale over udp") || !strings.Contains(err.Error(), "last response") { + t.Fatalf("mismatch deadline diagnostics = %v", err) + } + if elapsed := time.Since(started); elapsed > 200*time.Millisecond { + t.Fatalf("mismatch retries reset deadline: %s", elapsed) + } +} + +func TestPublicationVerifierRejectsSuccessAfterDeadline(t *testing.T) { + t.Parallel() + view := PublicationView{View: "context-late", Serial: 7, FingerprintMailbox: "new.publication.internal."} + err := verifyPublicationViews(context.Background(), "member:53", "prod.internal.", []PublicationView{view}, 10*time.Millisecond, + func(ctx context.Context, _, _, _ string, _ PublicationView, _ time.Duration) (*dns.Msg, error) { + <-ctx.Done() + return &dns.Msg{MsgHdr: dns.MsgHdr{Authoritative: true}, Answer: []dns.RR{&dns.SOA{Serial: view.Serial, Mbox: view.FingerprintMailbox}}}, nil + }) + if !errors.Is(err, context.DeadlineExceeded) { + t.Fatalf("late response proved readiness: %v", err) + } +} + +func TestZeroZoneContextProvesRecursionOverUDPAndTCP(t *testing.T) { + tcp, err := net.Listen(networkTCP, "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + udp, err := net.ListenPacket(networkUDP, tcp.Addr().String()) + if err != nil { + _ = tcp.Close() + t.Fatal(err) + } + seen := make(chan string, 2) + handler := dns.HandlerFunc(func(w dns.ResponseWriter, r *dns.Msg) { + if len(r.Question) != 1 || r.Question[0].Name != "." || r.Question[0].Qtype != dns.TypeSOA { + t.Errorf("unsafe recursion probe: %#v", r.Question) + } + reply := new(dns.Msg) + reply.SetReply(r) + reply.Answer = []dns.RR{&dns.SOA{Hdr: dns.RR_Header{Name: ".", Rrtype: dns.TypeSOA, Class: dns.ClassINET, Ttl: 5}, Ns: "a.root-servers.net.", Mbox: "nstld.verisign-grs.com.", Serial: 1, Refresh: 60, Retry: 30, Expire: 3600, Minttl: 5}} + if err := w.WriteMsg(reply); err != nil { + t.Error(err) + } + seen <- w.RemoteAddr().Network() + }) + tcpServer := &dns.Server{Listener: tcp, Handler: handler} + udpServer := &dns.Server{PacketConn: udp, Handler: handler} + go func() { _ = tcpServer.ActivateAndServe() }() + go func() { _ = udpServer.ActivateAndServe() }() + t.Cleanup(func() { _ = tcpServer.Shutdown(); _ = udpServer.Shutdown() }) + address := tcp.Addr().(*net.TCPAddr) + binding := model.Binding{ConsumerAddress: "127.0.0.1", Port: uint16(address.Port)} + if err := (DNSVerifier{Config: DNSVerifierConfig{Timeout: time.Second}}).Verify(context.Background(), binding); err != nil { + t.Fatal(err) + } + if len(seen) != 2 { + t.Fatalf("probe count=%d", len(seen)) + } +} diff --git a/internal/internaldns/serving/zone.go b/internal/internaldns/serving/zone.go new file mode 100644 index 00000000..b995bfab --- /dev/null +++ b/internal/internaldns/serving/zone.go @@ -0,0 +1,69 @@ +package serving + +import ( + "bytes" + "encoding/json" + "fmt" + "strconv" + "strings" + "time" + + "github.com/miekg/dns" + "go.miloapis.com/dns-operator/internal/internaldns/model" +) + +// publicationSerial proves the exact publication and locally enforced expiry +// content. It is unrelated to a wall clock and cannot renew contributor leases. +func publicationSerial(p publicationState, now time.Time) uint32 { + hash := publicationFingerprint(p, now) + serial, _ := strconv.ParseUint(hash[:8], 16, 32) + if serial == 0 { + serial = 1 + } + return uint32(serial) +} + +func publicationFingerprint(p publicationState, now time.Time) string { + data, _ := json.Marshal(p.Plan) + return model.Hash([]byte(fmt.Sprintf("%d/%d/%d/%s/%s", p.Fence.Epoch, p.Fence.Revision, p.LocalRevision, model.Hash(data), effectivePlanHash(p.Plan, now)))) +} +func publicationMailbox(p publicationState, now time.Time) string { + hash := publicationFingerprint(p, now) + return hash[:32] + "." + hash[32:] + ".publication.internal." +} +func renderZone(p publicationState, now time.Time) ([]byte, error) { + if err := p.Plan.Validate(); err != nil { + return nil, fmt.Errorf("invalid publication: %w", err) + } + var out bytes.Buffer + apex := model.AbsoluteName(p.Plan.Apex) + fmt.Fprintf(&out, "$ORIGIN %s\n$TTL 5\n@ IN SOA ns1.internal-dns.invalid. %s %d 60 30 3600 5\n@ IN NS ns1.internal-dns.invalid.\n", apex, publicationMailbox(p, now), publicationSerial(p, now)) + ownerHasRecord := map[string]bool{} + for _, rrset := range p.Plan.RRSets { + if strings.EqualFold(rrset.Type, "SOA") { + return nil, fmt.Errorf("publication SOA is reserved for version proof") + } + for _, record := range rrset.Records { + if !record.Eligible || (record.ContributionUID != "" && !now.Before(record.ValidUntil)) { + continue + } + line := fmt.Sprintf("%s %d IN %s %s", model.AbsoluteName(rrset.Name), rrset.TTL, strings.ToUpper(rrset.Type), record.Content) + rr, err := dns.NewRR(line) + if err != nil { + return nil, fmt.Errorf("invalid zone record: %w", err) + } + fmt.Fprintln(&out, rr.String()) + ownerHasRecord[model.AbsoluteName(rrset.Name)] = true + } + } + for _, owner := range p.Plan.Owners { + owner = model.AbsoluteName(owner) + if !ownerHasRecord[owner] { + // A private-use RR at the exact owner reserves the name without growing + // it or introducing a child that can collide with a tenant CNAME. + // It disappears whenever eligible records exist, including CNAMEs. + fmt.Fprintf(&out, "%s 5 IN %s \\# 1 00\n", owner, model.OwnershipMarkerType) + } + } + return out.Bytes(), nil +} diff --git a/internal/internaldns/serving/zone_regression_test.go b/internal/internaldns/serving/zone_regression_test.go new file mode 100644 index 00000000..d8ab91d0 --- /dev/null +++ b/internal/internaldns/serving/zone_regression_test.go @@ -0,0 +1,65 @@ +package serving + +import ( + "strings" + "testing" + "time" + + "github.com/miekg/dns" + "go.miloapis.com/dns-operator/internal/internaldns/model" +) + +func parseRenderedZone(t *testing.T, zone []byte) []dns.RR { + t.Helper() + parser := dns.NewZoneParser(strings.NewReader(string(zone)), "", "test-zone") + var records []dns.RR + for rr, ok := parser.Next(); ok; rr, ok = parser.Next() { + records = append(records, rr) + } + if err := parser.Err(); err != nil { + t.Fatal(err) + } + return records +} + +func TestNODATAOwnershipDoesNotGrowNamesOrCollideWithTenantCNAME(t *testing.T) { + now := time.Now() + owner := strings.Repeat("a", 63) + "." + strings.Repeat("b", 63) + "." + strings.Repeat("c", 63) + "." + strings.Repeat("d", 45) + ".x.internal." + plan := model.PublicationPlan{ZoneUID: "zone", Apex: "x.internal", Owners: []string{owner, "api.x.internal"}, RRSets: []model.RRSet{{Name: "_dns-ownership.api.x.internal", Type: "CNAME", TTL: 5, Records: []model.RRRecord{{Content: "target.example.", Eligible: true}}}}} + data, err := renderZone(publicationState{Plan: plan}, now) + if err != nil { + t.Fatal(err) + } + records := parseRenderedZone(t, data) + markers := map[string]bool{} + for _, rr := range records { + if rr.Header().Rrtype == 65280 { + markers[rr.Header().Name] = true + } + } + if !markers[owner] || !markers["api.x.internal."] { + t.Fatalf("missing ownership metadata: %v", markers) + } + plan.RRSets = append(plan.RRSets, model.RRSet{Name: "api.x.internal", Type: "CNAME", TTL: 5, Records: []model.RRRecord{{Content: "healthy.example.", Eligible: true}}}) + data, err = renderZone(publicationState{Plan: plan}, now) + if err != nil { + t.Fatal(err) + } + for _, rr := range parseRenderedZone(t, data) { + if rr.Header().Name == "api.x.internal." && rr.Header().Rrtype == 65280 { + t.Fatal("metadata coexists with recovered CNAME") + } + } +} + +func TestPublicationRejectsInvalidGeneratedAuthorityBeforeServing(t *testing.T) { + for _, sets := range [][]model.RRSet{ + {{Name: "api.x.internal", Type: "CNAME", TTL: 5, Records: []model.RRRecord{{Content: "target.example.", Eligible: true}}}, {Name: "api.x.internal", Type: "TXT", TTL: 5, Records: []model.RRRecord{{Content: `"conflict"`, Eligible: true}}}}, + {{Name: "api.x.internal", Type: model.OwnershipMarkerType, TTL: 5, Records: []model.RRRecord{{Content: `\# 1 00`, Eligible: true}}}}, + } { + plan := model.PublicationPlan{ZoneUID: "zone", Apex: "x.internal", RRSets: sets} + if err := plan.Validate(); err == nil { + t.Fatalf("invalid generated authority accepted: %#v", sets) + } + } +} From 8c8bce82343c6dacc9ac8b59977c2bd51b2ac4aa Mon Sep 17 00:00:00 2001 From: Scot Wells Date: Wed, 14 Oct 2026 16:59:13 -0500 Subject: [PATCH 2/4] feat: serve DNS contexts with CoreDNS --- go.mod | 54 ++- go.sum | 134 ++++++-- internal/internaldns/coredns/config.go | 118 +++++++ internal/internaldns/coredns/plugin.go | 179 ++++++++++ internal/internaldns/coredns/plugin_test.go | 320 ++++++++++++++++++ internal/internaldns/coredns/server.go | 89 +++++ internal/internaldns/coredns/transport.go | 146 ++++++++ internal/internaldns/model/private_records.go | 2 +- internal/internaldns/serving/render.go | 213 +++--------- internal/internaldns/serving/render_test.go | 100 +++--- internal/internaldns/serving/verify.go | 14 +- 11 files changed, 1098 insertions(+), 271 deletions(-) create mode 100644 internal/internaldns/coredns/config.go create mode 100644 internal/internaldns/coredns/plugin.go create mode 100644 internal/internaldns/coredns/plugin_test.go create mode 100644 internal/internaldns/coredns/server.go create mode 100644 internal/internaldns/coredns/transport.go diff --git a/go.mod b/go.mod index f7a6b5ad..e6bc0765 100644 --- a/go.mod +++ b/go.mod @@ -4,22 +4,22 @@ go 1.26.0 require ( github.com/docker/docker v28.5.1+incompatible - github.com/miekg/dns v1.1.68 + github.com/miekg/dns v1.1.72 github.com/modelcontextprotocol/go-sdk v1.7.0 github.com/onsi/ginkgo/v2 v2.27.2 github.com/onsi/gomega v1.38.2 github.com/projectdiscovery/dnsx v1.2.1 github.com/projectdiscovery/retryabledns v1.0.58 - github.com/prometheus/client_golang v1.23.2 + github.com/prometheus/client_golang v1.24.1 github.com/prometheus/client_model v0.6.2 github.com/stretchr/testify v1.11.1 go.datum.net/datumctl v0.18.1 go.datum.net/network-services-operator v0.9.0 go.miloapis.com/milo v0.35.0 golang.org/x/sync v0.23.0 - k8s.io/api v0.35.3 - k8s.io/apimachinery v0.35.3 - k8s.io/client-go v0.35.3 + k8s.io/api v0.35.4 + k8s.io/apimachinery v0.35.4 + k8s.io/client-go v0.35.4 sigs.k8s.io/controller-runtime v0.23.3 sigs.k8s.io/multicluster-runtime v0.23.3 ) @@ -29,6 +29,7 @@ require ( github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c // indirect github.com/Masterminds/semver/v3 v3.4.0 // indirect github.com/Microsoft/go-winio v0.6.2 // indirect + github.com/apparentlymart/go-cidr v1.1.1 // indirect github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 // indirect github.com/aymerick/douceur v0.2.0 // indirect github.com/cenkalti/backoff/v5 v5.0.3 // indirect @@ -36,11 +37,16 @@ require ( github.com/containerd/errdefs/pkg v0.3.0 // indirect github.com/containerd/log v0.1.0 // indirect github.com/containerd/platforms v0.2.1 // indirect + github.com/coredns/caddy v1.1.4-0.20250930002214-15135a999495 // indirect + github.com/coreos/go-systemd/v22 v22.7.0 // indirect github.com/cpuguy83/dockercfg v0.3.2 // indirect github.com/distribution/reference v0.6.0 // indirect + github.com/dnstap/golang-dnstap v0.4.0 // indirect github.com/docker/go-connections v0.6.0 // indirect github.com/docker/go-units v0.5.0 // indirect - github.com/ebitengine/purego v0.8.4 // indirect + github.com/ebitengine/purego v0.10.0 // indirect + github.com/farsightsec/golang-framestream v0.3.0 // indirect + github.com/flynn/go-shlex v0.0.0-20150515145356-3f9db97f8568 // indirect github.com/go-ole/go-ole v1.3.0 // indirect github.com/go-openapi/swag/cmdutils v0.25.4 // indirect github.com/go-openapi/swag/conv v0.25.4 // indirect @@ -53,11 +59,17 @@ require ( github.com/go-openapi/swag/stringutils v0.25.4 // indirect github.com/go-openapi/swag/typeutils v0.25.4 // indirect github.com/go-openapi/swag/yamlutils v0.25.4 // indirect + github.com/golang-jwt/jwt/v5 v5.3.1 // indirect github.com/google/jsonschema-go v0.4.3 // indirect github.com/gorilla/css v1.0.0 // indirect + github.com/grpc-ecosystem/grpc-opentracing v0.0.0-20180507213350-8e809c8a8645 // indirect + github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect + github.com/jpillora/backoff v1.0.0 // indirect github.com/klauspost/compress v1.20.0 // indirect - github.com/lufia/plan9stats v0.0.0-20220913051719-115f729f3c8c // indirect + github.com/lufia/plan9stats v0.0.0-20260216142805-b3301c5f2a88 // indirect github.com/magiconair/properties v1.8.10 // indirect + github.com/mdlayher/socket v0.6.0 // indirect + github.com/mdlayher/vsock v1.3.0 // indirect github.com/microcosm-cc/bluemonday v1.0.25 // indirect github.com/moby/docker-image-spec v1.3.1 // indirect github.com/moby/go-archive v0.3.0 // indirect @@ -67,28 +79,35 @@ require ( github.com/moby/sys/userns v0.1.0 // indirect github.com/moby/term v0.5.2 // indirect github.com/morikuni/aec v1.0.0 // indirect + github.com/mwitkow/go-conntrack v0.0.0-20190716064945-2f068394615f // indirect github.com/nats-io/nkeys v0.4.16 // indirect github.com/nats-io/nuid v1.0.1 // indirect github.com/opencontainers/go-digest v1.0.0 // indirect github.com/opencontainers/image-spec v1.1.1 // indirect - github.com/power-devops/perfstat v0.0.0-20221212215047-62379fc7944b // indirect + github.com/opentracing/opentracing-go v1.2.0 // indirect + github.com/pires/go-proxyproto v0.15.0 // indirect + github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect github.com/projectdiscovery/blackrock v0.0.1 // indirect github.com/projectdiscovery/cdncheck v1.0.9 // indirect github.com/projectdiscovery/utils v0.0.81 // indirect + github.com/prometheus/exporter-toolkit v0.17.1 // indirect + github.com/quic-go/qpack v0.6.0 // indirect + github.com/quic-go/quic-go v0.61.0 // indirect github.com/saintfish/chardet v0.0.0-20230101081208-5e3ef4b5456d // indirect github.com/segmentio/asm v1.1.3 // indirect github.com/segmentio/encoding v0.5.4 // indirect - github.com/shirou/gopsutil/v4 v4.25.6 // indirect + github.com/shirou/gopsutil/v4 v4.26.2 // indirect github.com/sirupsen/logrus v1.9.4 // indirect github.com/tidwall/gjson v1.18.0 // indirect github.com/tidwall/match v1.1.1 // indirect github.com/tidwall/pretty v1.2.1 // indirect - github.com/tklauser/go-sysconf v0.3.12 // indirect - github.com/tklauser/numcpus v0.6.1 // indirect + github.com/tklauser/go-sysconf v0.3.16 // indirect + github.com/tklauser/numcpus v0.11.0 // indirect github.com/weppos/publicsuffix-go v0.30.1-0.20230422193905-8fecedd899db // indirect github.com/yl2chen/cidranger v1.0.2 // indirect github.com/yosida95/uritemplate/v3 v3.0.2 // indirect github.com/yusufpapurcu/wmi v1.2.4 // indirect + go.uber.org/automaxprocs v1.6.0 // indirect golang.org/x/crypto v0.57.0 // indirect golang.org/x/mod v0.41.0 // indirect ) @@ -100,6 +119,7 @@ require ( github.com/blang/semver/v4 v4.0.0 // indirect github.com/cenkalti/backoff/v4 v4.3.0 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/coredns/coredns v1.14.7 github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/emicklei/go-restful/v3 v3.13.0 // indirect github.com/evanphx/json-patch/v5 v5.9.11 // indirect @@ -128,14 +148,14 @@ require ( github.com/nats-io/nats.go v1.54.0 github.com/pkg/errors v0.9.1 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect - github.com/prometheus/common v0.67.5 // indirect - github.com/prometheus/procfs v0.20.1 // indirect + github.com/prometheus/common v0.70.1 // indirect + github.com/prometheus/procfs v0.21.1 // indirect github.com/spf13/cobra v1.10.2 github.com/spf13/pflag v1.0.10 github.com/testcontainers/testcontainers-go v0.40.0 github.com/x448/float16 v0.8.4 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect - go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.62.0 // indirect + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.67.0 // indirect go.opentelemetry.io/otel v1.45.0 // indirect go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.45.0 // indirect go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.45.0 // indirect @@ -144,13 +164,13 @@ require ( go.opentelemetry.io/otel/trace v1.45.0 // indirect go.opentelemetry.io/proto/otlp v1.11.0 // indirect go.uber.org/multierr v1.11.0 // indirect - go.uber.org/zap v1.27.0 // indirect + go.uber.org/zap v1.28.0 // indirect go.yaml.in/yaml/v2 v2.4.4 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect - golang.org/x/exp v0.0.0-20250718183923-645b1fa84792 // indirect + golang.org/x/exp v0.0.0-20260209203927-2842357ff358 // indirect golang.org/x/net v0.60.0 // indirect golang.org/x/oauth2 v0.36.0 // indirect - golang.org/x/sys v0.48.0 // indirect + golang.org/x/sys v0.48.0 golang.org/x/term v0.46.0 golang.org/x/text v0.42.0 // indirect golang.org/x/time v0.15.0 // indirect diff --git a/go.sum b/go.sum index f4489ec8..ff22fd28 100644 --- a/go.sum +++ b/go.sum @@ -13,6 +13,8 @@ github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERo github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= github.com/antlr4-go/antlr/v4 v4.13.1 h1:SqQKkuVZ+zWkMMNkjy5FZe5mr5WURWnlpmOuzYWrPrQ= github.com/antlr4-go/antlr/v4 v4.13.1/go.mod h1:GKmUxMtwp6ZgGwZSva4eWPC5mS6vUAmOABFgjdkM7Nw= +github.com/apparentlymart/go-cidr v1.1.1 h1:oEEk8CE0HP0YpHxsegk/TaOtR2FLHdWv4p3eM4ceUwg= +github.com/apparentlymart/go-cidr v1.1.1/go.mod h1:EBcsNrHc3zQeuaeCeCtQruQm+n9/YjEn/vI25Lg7Gwc= github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 h1:DklsrG3dyBCFEj5IhUbnKptjxatkF07cF2ak3yi77so= github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2/go.mod h1:WaHUgvxTVq04UNunO+XhnAqY/wQc+bxr74GqbsZ/Jqw= github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk= @@ -35,6 +37,12 @@ github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I= github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo= github.com/containerd/platforms v0.2.1 h1:zvwtM3rz2YHPQsF2CHYM8+KtB5dvhISiXh5ZpSBQv6A= github.com/containerd/platforms v0.2.1/go.mod h1:XHCb+2/hzowdiut9rkudds9bE5yJ7npe7dG/wG+uFPw= +github.com/coredns/caddy v1.1.4-0.20250930002214-15135a999495 h1:JFeOmbjLnVRhvmLHyuO3M1pfXWlPWpwkdM8UqXZRtBg= +github.com/coredns/caddy v1.1.4-0.20250930002214-15135a999495/go.mod h1:A6ntJQlAWuQfFlsd9hvigKbo2WS0VUs2l1e2F+BawD4= +github.com/coredns/coredns v1.14.7 h1:UPDkn4QN+xyNfjz3U5ldgoLszDYpMZEJoy3+ze9au4Q= +github.com/coredns/coredns v1.14.7/go.mod h1:ABNpFbWAas3/CDYRyzlVYLqX/gSVq/5yDr7wm5fjafA= +github.com/coreos/go-systemd/v22 v22.7.0 h1:LAEzFkke61DFROc7zNLX/WA2i5J8gYqe0rSj9KI28KA= +github.com/coreos/go-systemd/v22 v22.7.0/go.mod h1:xNUYtjHu2EDXbsxz1i41wouACIwT7Ybq9o0BQhMwD0w= github.com/cpuguy83/dockercfg v0.3.2 h1:DlJTyZGBDlXqUZ2Dk2Q3xHs/FtnooJJVaad2S9GKorA= github.com/cpuguy83/dockercfg v0.3.2/go.mod h1:sugsbF4//dDlL/i+S+rtpIWp+5h0BHJHfjj5/jFyUJc= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= @@ -46,22 +54,28 @@ github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1 github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= +github.com/dnstap/golang-dnstap v0.4.0 h1:KRHBoURygdGtBjDI2w4HifJfMAhhOqDuktAokaSa234= +github.com/dnstap/golang-dnstap v0.4.0/go.mod h1:FqsSdH58NAmkAvKcpyxht7i4FoBjKu8E4JUPt8ipSUs= github.com/docker/docker v28.5.1+incompatible h1:Bm8DchhSD2J6PsFzxC35TZo4TLGR2PdW/E69rU45NhM= github.com/docker/docker v28.5.1+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= github.com/docker/go-connections v0.6.0 h1:LlMG9azAe1TqfR7sO+NJttz1gy6KO7VJBh+pMmjSD94= github.com/docker/go-connections v0.6.0/go.mod h1:AahvXYshr6JgfUJGdDCs2b5EZG/vmaMAntpSFH5BFKE= github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4= github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= -github.com/ebitengine/purego v0.8.4 h1:CF7LEKg5FFOsASUj0+QwaXf8Ht6TlFxg09+S9wz0omw= -github.com/ebitengine/purego v0.8.4/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ= +github.com/ebitengine/purego v0.10.0 h1:QIw4xfpWT6GWTzaW5XEKy3HXoqrJGx1ijYHzTF0/ISU= +github.com/ebitengine/purego v0.10.0/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ= github.com/emicklei/go-restful/v3 v3.13.0 h1:C4Bl2xDndpU6nJ4bc1jXd+uTmYPVUwkD6bFY/oTyCes= github.com/emicklei/go-restful/v3 v3.13.0/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc= github.com/evanphx/json-patch v5.9.11+incompatible h1:ixHHqfcGvxhWkniF1tWxBHA0yb4Z+d1UQi45df52xW8= github.com/evanphx/json-patch v5.9.11+incompatible/go.mod h1:50XU6AFN0ol/bzJsmQLiYLvXMP4fmwYFNcr97nuDLSk= github.com/evanphx/json-patch/v5 v5.9.11 h1:/8HVnzMq13/3x9TPvjG08wUGqBTmZBsCWzjTM0wiaDU= github.com/evanphx/json-patch/v5 v5.9.11/go.mod h1:3j+LviiESTElxA4p3EMKAB9HXj3/XEtnUf6OZxqIQTM= +github.com/farsightsec/golang-framestream v0.3.0 h1:/spFQHucTle/ZIPkYqrfshQqPe2VQEzesH243TjIwqA= +github.com/farsightsec/golang-framestream v0.3.0/go.mod h1:eNde4IQyEiA5br02AouhEHCu3p3UzrCdFR4LuQHklMI= github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= +github.com/flynn/go-shlex v0.0.0-20150515145356-3f9db97f8568 h1:BHsljHzVlRcyQhjrss6TZTdY2VfCqZPbv5k3iBFa2ZQ= +github.com/flynn/go-shlex v0.0.0-20150515145356-3f9db97f8568/go.mod h1:xEzjJPgXI435gkrCt3MPfRiAkVrwSbHsst4LCFVfpJc= github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM= @@ -124,6 +138,11 @@ github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63Y github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8= +github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA= +github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs= +github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w= +github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0= github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= @@ -134,6 +153,9 @@ github.com/google/cel-go v0.30.0 h1:ll54AkzKunWkBn9wSoiUXbFZXYZTkdJGNXTBXUoolGo= github.com/google/cel-go v0.30.0/go.mod h1:X0bD6iVNR8pkROSOoHVdgTkzmRcosof7WQqCD6wcMc8= github.com/google/gnostic-models v0.7.1 h1:SisTfuFKJSKM5CPZkffwi6coztzzeYUhc3v4yxLWH8c= github.com/google/gnostic-models v0.7.1/go.mod h1:whL5G0m6dmc5cPxKc5bdKdEN3UjI7OUGxBlw57miDrQ= +github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= +github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= +github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.8/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= @@ -149,16 +171,23 @@ github.com/google/jsonschema-go v0.4.3 h1:/DBOLZTfDow7pe2GmaJNhltueGTtDKICi8V8p+ github.com/google/jsonschema-go v0.4.3/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE= github.com/google/pprof v0.0.0-20250403155104-27863c87afa6 h1:BHT72Gu3keYf3ZEu2J0b1vyeLSOYI8bm5wbJM/8yDe8= github.com/google/pprof v0.0.0-20250403155104-27863c87afa6/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= +github.com/google/uuid v1.1.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/gorilla/css v1.0.0 h1:BQqNyPTi50JCFMTw/b67hByjMVXZRwGha6wxVGkeihY= github.com/gorilla/css v1.0.0/go.mod h1:Dn721qIggHpt4+EFCcTLTU/vk5ySda2ReITrtgBl60c= github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0 h1:5VipnvEpbqr2gA2VbM+nYVbkIF28c5ZQfqCBQ5g2xfk= github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0/go.mod h1:Hyl3n6Twe1hvtd9XUXDec4pTvgMSEixRuQKPTMH2bNs= +github.com/grpc-ecosystem/grpc-opentracing v0.0.0-20180507213350-8e809c8a8645 h1:MJG/KsmcqMwFAkh8mTnAwhyKoB+sTAnY4CACC110tbU= +github.com/grpc-ecosystem/grpc-opentracing v0.0.0-20180507213350-8e809c8a8645/go.mod h1:6iZfnjpejD4L/4DwD7NryNaJyCQdzwWwH2MWhCA90Kw= +github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= +github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/joshdk/go-junit v1.0.0 h1:S86cUKIdwBHWwA6xCmFlf3RTLfVXYQfvanM5Uh+K6GE= github.com/joshdk/go-junit v1.0.0/go.mod h1:TiiV0PqkaNfFXjEiyjWM3XXrhVyCa1K4Zfga6W52ung= +github.com/jpillora/backoff v1.0.0 h1:uvFg412JmmHBHw7iwprIxkPMI+sGQ4kzOWsMeHnm2EA= +github.com/jpillora/backoff v1.0.0/go.mod h1:J/6gKK9jxlEcS3zixgDgUAsiuZ7yrSoa/FX5e0EB2j4= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= github.com/klauspost/compress v1.20.0 h1:a3C1ke2ohxFymNlb2HWAHjDeKCI90scRskErZkR0ezA= @@ -169,18 +198,23 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= -github.com/lufia/plan9stats v0.0.0-20220913051719-115f729f3c8c h1:VtwQ41oftZwlMnOEbMWQtSEUgU64U4s+GHk7hZK+jtY= -github.com/lufia/plan9stats v0.0.0-20220913051719-115f729f3c8c/go.mod h1:JKx41uQRwqlTZabZc+kILPrO/3jlKnQ2Z8b7YiVw5cE= +github.com/lufia/plan9stats v0.0.0-20260216142805-b3301c5f2a88 h1:PTw+yKnXcOFCR6+8hHTyWBeQ/P4Nb7dd4/0ohEcWQuM= +github.com/lufia/plan9stats v0.0.0-20260216142805-b3301c5f2a88/go.mod h1:autxFIvghDt3jPTLoqZ9OZ7s9qTGNAWmYCjVFWPX/zg= github.com/magiconair/properties v1.8.10 h1:s31yESBquKXCV9a/ScB3ESkOjUYYv+X0rg8SYxI99mE= github.com/magiconair/properties v1.8.10/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= github.com/maruel/natural v1.1.1 h1:Hja7XhhmvEFhcByqDoHz9QZbkWey+COd9xWfCfn1ioo= github.com/maruel/natural v1.1.1/go.mod h1:v+Rfd79xlw1AgVBjbO0BEQmptqb5HvL/k9GRHB7ZKEg= +github.com/mdlayher/socket v0.6.0 h1:ScZPaAGyO1icQnbFrhPM8mnXyMu9qukC1K4ZoM2IQKU= +github.com/mdlayher/socket v0.6.0/go.mod h1:q7vozUAnxSqnjHc12Fik5yUKIzfZ8ITCfMkhOtE9z18= +github.com/mdlayher/vsock v1.3.0 h1:bqQfZ1OznI03y6YiXp2sze05RVdzLn/zsfjnjd4+ivI= +github.com/mdlayher/vsock v1.3.0/go.mod h1:WsuksavOvwCnV5UqGHUkvAvCy+Dqy81y4goKQTzxxNY= github.com/mfridman/tparse v0.18.0 h1:wh6dzOKaIwkUGyKgOntDW4liXSo37qg5AXbIhkMV3vE= github.com/mfridman/tparse v0.18.0/go.mod h1:gEvqZTuCgEhPbYk/2lS3Kcxg1GmTxxU7kTC8DvP0i/A= github.com/microcosm-cc/bluemonday v1.0.25 h1:4NEwSfiJ+Wva0VxN5B8OwMicaJvD8r9tlJWm9rtloEg= github.com/microcosm-cc/bluemonday v1.0.25/go.mod h1:ZIOjCQp1OrzBBPIJmfX4qDYFuhU02nx4bn030ixfHLE= -github.com/miekg/dns v1.1.68 h1:jsSRkNozw7G/mnmXULynzMNIsgY2dHC8LO6U6Ij2JEA= -github.com/miekg/dns v1.1.68/go.mod h1:fujopn7TB3Pu3JM69XaawiU0wqjpL9/8xGop5UrTPps= +github.com/miekg/dns v1.1.31/go.mod h1:KNUDUusw/aVsxyTYZM1oqvCicbwhgbNgztCETuNZ7xM= +github.com/miekg/dns v1.1.72 h1:vhmr+TF2A3tuoGNkLDFK9zi36F2LS+hKTRW0Uf8kbzI= +github.com/miekg/dns v1.1.72/go.mod h1:+EuEPhdHOsfk6Wk5TT2CzssZdqkmFhf8r+aVyDEToIs= github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0= github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo= github.com/moby/go-archive v0.3.0 h1:nos4BtzzUIqB406BgQnWGMI4qib9BZ8XUHU+ucv/n1c= @@ -209,6 +243,8 @@ github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A= github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= +github.com/mwitkow/go-conntrack v0.0.0-20190716064945-2f068394615f h1:KUppIJq7/+SVif2QVs3tOP0zanoHgBEVAwHxUSIzRqU= +github.com/mwitkow/go-conntrack v0.0.0-20190716064945-2f068394615f/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= github.com/nats-io/nats.go v1.54.0 h1:vsXoOxjHp/GmPUN+EcI7uOf/uB+iAP+kEsAFNQN0yzA= github.com/nats-io/nats.go v1.54.0/go.mod h1:y+DZoD1oBOYfZTU681eTUiUjI0vbqYGixNVFHcjHJ0k= github.com/nats-io/nkeys v0.4.16 h1:rd5oAuLOb8mnAycB0xleuEBNS1pVVnN0fv/FF34Eypg= @@ -223,13 +259,19 @@ github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8 github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040= github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M= +github.com/opentracing/opentracing-go v1.2.0 h1:uEJPy/1a5RIPAJ0Ov+OIO8OxWu77jEv+1B0VhjKrZUs= +github.com/opentracing/opentracing-go v1.2.0/go.mod h1:GxEUsuufX4nBwe+T+Wl9TAgYrxe9dPLANfrWvHYVTgc= +github.com/pires/go-proxyproto v0.15.0 h1:dTshmNbFm/D+0+sbrxUuddPOZ5Y0B7c5NhtsBkm6LqI= +github.com/pires/go-proxyproto v0.15.0/go.mod h1:OXsCrKwrK2tXS9YrI5tkHx5xaQlO8FH3lFW76orFh24= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/power-devops/perfstat v0.0.0-20221212215047-62379fc7944b h1:0LFwY6Q3gMACTjAbMZBjXAqTOzOwFaj2Ld6cjeQ7Rig= -github.com/power-devops/perfstat v0.0.0-20221212215047-62379fc7944b/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE= +github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU= +github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE= +github.com/prashantv/gostub v1.1.0 h1:BTyx3RfQjRHnUWaGF9oQos79AlQ5k8WNktv7VGvVH4g= +github.com/prashantv/gostub v1.1.0/go.mod h1:A5zLQHz7ieHGG7is6LLXLz7I8+3LZzsrV0P1IAHhP5U= github.com/projectdiscovery/blackrock v0.0.1 h1:lHQqhaaEFjgf5WkuItbpeCZv2DUIE45k0VbGJyft6LQ= github.com/projectdiscovery/blackrock v0.0.1/go.mod h1:ANUtjDfaVrqB453bzToU+YB4cUbvBRpLvEwoWIwlTss= github.com/projectdiscovery/cdncheck v1.0.9 h1:BS15gzj9gb5AVSKqTDzPamfSgStu7nJQOocUvrssFlg= @@ -240,14 +282,22 @@ github.com/projectdiscovery/retryabledns v1.0.58 h1:ut1FSB9+GZ6zQIlKJFLqIz2RZs81 github.com/projectdiscovery/retryabledns v1.0.58/go.mod h1:RobmKoNBgngAVE4H9REQtaLP1pa4TCyypHy1MWHT1mY= github.com/projectdiscovery/utils v0.0.81 h1:Cqz6uFncCKWRLqpVHWlnHXaRE3whzH32yZJa/1zOEzU= github.com/projectdiscovery/utils v0.0.81/go.mod h1:pTGvF08EXa07e2OM+tu8IcnxTeAT34bzAhSW/Efcens= -github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o= -github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg= +github.com/prometheus/client_golang v1.24.1 h1:JnJkREXzWxUdCuPFpIWZiPispT9xVV59uiuyR2bPlnU= +github.com/prometheus/client_golang v1.24.1/go.mod h1:F+oSRECHg4sse5ucfYpYDeIv/hu68Zo0uoHKetWnzcE= github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= -github.com/prometheus/common v0.67.5 h1:pIgK94WWlQt1WLwAC5j2ynLaBRDiinoAb86HZHTUGI4= -github.com/prometheus/common v0.67.5/go.mod h1:SjE/0MzDEEAyrdr5Gqc6G+sXI67maCxzaT3A2+HqjUw= -github.com/prometheus/procfs v0.20.1 h1:XwbrGOIplXW/AU3YhIhLODXMJYyC1isLFfYCsTEycfc= -github.com/prometheus/procfs v0.20.1/go.mod h1:o9EMBZGRyvDrSPH1RqdxhojkuXstoe4UlK79eF5TGGo= +github.com/prometheus/common v0.70.1 h1:1HvjP4D5oL3t8RsPlwxA9onvvStjtIHYE5XuuwOi/PY= +github.com/prometheus/common v0.70.1/go.mod h1:VdFUQDMZK3VLkurFUVhia6uys/0suUp86TJz5qbJRhc= +github.com/prometheus/exporter-toolkit v0.17.1 h1:psKN4wM7shBL/BxZkDHgm6YZJ3fAVG36+r86An/+7q0= +github.com/prometheus/exporter-toolkit v0.17.1/go.mod h1:dabwPJvxsC5+tsp2iolQrqBWZh+QlISKlYRpj9Hh5xk= +github.com/prometheus/procfs v0.21.1 h1:GljZCt+zSTS+NZq88cyQ1LjZ+RCHp3uVuabBWA5+OJI= +github.com/prometheus/procfs v0.21.1/go.mod h1:aB55Cww9pdSJVHk0hUf0inxWyyjPogFIjmHKYgMKmtY= +github.com/quic-go/go-ossfuzz-seeds v0.1.0 h1:APacT+iIaNF6fd8AGEiN3bT/Jtkd2jz4v4TzM7MFjy0= +github.com/quic-go/go-ossfuzz-seeds v0.1.0/go.mod h1:3IOHRbJIc+L6YKMwfDtJAM9Vj9k0YY4muhuyUYk5tbk= +github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8= +github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII= +github.com/quic-go/quic-go v0.61.0 h1:ui88A53s8MSVYLC56en0KQ17HARk+9986Dn0SBfKNvA= +github.com/quic-go/quic-go v0.61.0/go.mod h1:9So2anK4Tp22URSQq00k+Vo2PNkle96ycDPDHL4s9vs= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= @@ -257,8 +307,8 @@ github.com/segmentio/asm v1.1.3 h1:WM03sfUOENvvKexOLp+pCqgb/WDjsi7EK8gIsICtzhc= github.com/segmentio/asm v1.1.3/go.mod h1:Ld3L4ZXGNcSLRg4JBsZ3//1+f/TjYl0Mzen/DQy1EJg= github.com/segmentio/encoding v0.5.4 h1:OW1VRern8Nw6ITAtwSZ7Idrl3MXCFwXHPgqESYfvNt0= github.com/segmentio/encoding v0.5.4/go.mod h1:HS1ZKa3kSN32ZHVZ7ZLPLXWvOVIiZtyJnO1gPH1sKt0= -github.com/shirou/gopsutil/v4 v4.25.6 h1:kLysI2JsKorfaFPcYmcJqbzROzsBWEOAtw6A7dIfqXs= -github.com/shirou/gopsutil/v4 v4.25.6/go.mod h1:PfybzyydfZcN+JMMjkF6Zb8Mq1A/VcogFFg7hj50W9c= +github.com/shirou/gopsutil/v4 v4.26.2 h1:X8i6sicvUFih4BmYIGT1m2wwgw2VG9YgrDTi7cIRGUI= +github.com/shirou/gopsutil/v4 v4.26.2/go.mod h1:LZ6ewCSkBqUpvSOf+LsTGnRinC6iaNUNMGBtDkJBaLQ= github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w= github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g= github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= @@ -284,10 +334,10 @@ github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4= github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY= github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28= -github.com/tklauser/go-sysconf v0.3.12 h1:0QaGUFOdQaIVdPgfITYzaTegZvdCjmYO52cSFAEVmqU= -github.com/tklauser/go-sysconf v0.3.12/go.mod h1:Ho14jnntGE1fpdOqQEEaiKRpvIavV0hSfmBq8nJbHYI= -github.com/tklauser/numcpus v0.6.1 h1:ng9scYS7az0Bk4OZLvrNXNSAO2Pxr1XXRAPyjhIx+Fk= -github.com/tklauser/numcpus v0.6.1/go.mod h1:1XfjsgE2zo8GVw7POkMbHENHzVg3GzmoZ9fESEdAacY= +github.com/tklauser/go-sysconf v0.3.16 h1:frioLaCQSsF5Cy1jgRBrzr6t502KIIwQ0MArYICU0nA= +github.com/tklauser/go-sysconf v0.3.16/go.mod h1:/qNL9xxDhc7tx3HSRsLWNnuzbVfh3e7gh/BmM179nYI= +github.com/tklauser/numcpus v0.11.0 h1:nSTwhKH5e1dMNsCdVBukSZrURJRoHbSEQjdEbY+9RXw= +github.com/tklauser/numcpus v0.11.0/go.mod h1:z+LwcLq54uWZTX0u/bGobaV34u6V7KNlTZejzM6/3MQ= github.com/weppos/publicsuffix-go v0.30.1-0.20230422193905-8fecedd899db h1:/WcxBne+5CbtbgWd/sV2wbravmr4sT7y52ifQaCgoLs= github.com/weppos/publicsuffix-go v0.30.1-0.20230422193905-8fecedd899db/go.mod h1:aiQaH1XpzIfgrJq3S1iw7w+3EDbRP7mF5fmwUhWyRUs= github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= @@ -307,8 +357,8 @@ go.miloapis.com/milo v0.35.0 h1:pXXqEm8fHeMOS/qBGw2501ftfI3e4nUA/zMZQqUvlmA= go.miloapis.com/milo v0.35.0/go.mod h1:zMzwGXexUlPlFaZ9/4U+2DDbzPj4xzs9I76Z901g2sE= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= -go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.62.0 h1:Hf9xI/XLML9ElpiHVDNwvqI0hIFlzV8dgIr35kV1kRU= -go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.62.0/go.mod h1:NfchwuyNoMcZ5MLHwPrODwUF1HWCXWrL31s8gSAdIKY= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.67.0 h1:OyrsyzuttWTSur2qN/Lm0m2a8yqyIjUVBZcxFPuXq2o= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.67.0/go.mod h1:C2NGBr+kAB4bk3xtMXfZ94gqFDtg/GkI7e9zqGh5Beg= go.opentelemetry.io/otel v1.45.0 h1:pdrWmLHofpubmArBv1LgFSv1Z0Ie/ppdZzu+kUN5EeU= go.opentelemetry.io/otel v1.45.0/go.mod h1:XZxIqPapzEYnhNSScF5DIqXhm/rYi0FzCe2XddAwZfQ= go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.45.0 h1:QRefszxJmfPdjXUUm3j6iDzY03mTPXMjqErFqQ67vUg= @@ -327,30 +377,38 @@ go.opentelemetry.io/otel/trace v1.45.0 h1:l/mP6Uv7oNO7/TblbhpbgMidxhq1uO/rPsikOy go.opentelemetry.io/otel/trace v1.45.0/go.mod h1:qoJJA2xNMnxRrdISU/kLtfUH2wNeQbiv+jhs/CxI8bc= go.opentelemetry.io/proto/otlp v1.11.0 h1:5rrYs0Ykyj50sdU/JU0x8etU+LubXWb+gED6TbEdMIk= go.opentelemetry.io/proto/otlp v1.11.0/go.mod h1:SmVizdCOAm3XBtG1g1NnOdhW6jtddT72hLMhv8VwA8E= +go.uber.org/automaxprocs v1.6.0 h1:O3y2/QNTOdbF+e/dpXNNW7Rx2hZ4sTIPyybbxyNqTUs= +go.uber.org/automaxprocs v1.6.0/go.mod h1:ifeIMSnPZuznNm6jmdzmU3/bfk01Fe2fotchwEFJ8r8= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +go.uber.org/mock v0.5.2 h1:LbtPTcP8A5k9WPXj54PPPbjcI4Y6lhyOZXn+VS7wNko= +go.uber.org/mock v0.5.2/go.mod h1:wLlUxC2vVTPTaE3UD51E0BGOAElKrILxhVSDYQLld5o= go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= -go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8= -go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= +go.uber.org/zap v1.28.0 h1:IZzaP1Fv73/T/pBMLk4VutPl36uNC+OSUh3JLG3FIjo= +go.uber.org/zap v1.28.0/go.mod h1:rDLpOi171uODNm/mxFcuYWxDsqWSAVkFdX4XojSKg/Q= go.yaml.in/yaml/v2 v2.4.4 h1:tuyd0P+2Ont/d6e2rl3be67goVK4R6deVxCUX5vyPaQ= go.yaml.in/yaml/v2 v2.4.4/go.mod h1:gMZqIpDtDqOfM0uNfy0SkpRhvUryYH0Z6wdMYcacYXQ= go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20210817164053-32db794688a5/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.6.0/go.mod h1:OFC/31mSvZgRz0V1QTNCzfAI1aIRzbiufJtkMIlEp58= golang.org/x/crypto v0.57.0 h1:3ZVCjf8Ggz7zneR/EHRVx68Ctf+2pmIMP2UFhh9cC6M= golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZA= -golang.org/x/exp v0.0.0-20250718183923-645b1fa84792 h1:R9PFI6EUdfVKgwKjZef7QIwGcBKu86OEFpJ9nUEP2l4= -golang.org/x/exp v0.0.0-20250718183923-645b1fa84792/go.mod h1:A+z0yzpGtvnG90cToK5n2tu8UJVP2XUATh+r+sfOOOc= +golang.org/x/exp v0.0.0-20260209203927-2842357ff358 h1:kpfSV7uLwKJbFSEgNhWzGSL47NDSF/5pYYQw1V0ub6c= +golang.org/x/exp v0.0.0-20260209203927-2842357ff358/go.mod h1:R3t0oliuryB5eenPWl3rrQxwnNM3WTwnsRZZiXLAAW8= +golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.41.0 h1:qJmnOUb4YB+FsEuM3HcWucdZASCPGhsX6uljO6pog0c= golang.org/x/mod v0.41.0/go.mod h1:Ek9pY8RKWXwsWvd3rQiHYtMqkjSUV+s1Rj7j4H5Ur6o= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20190923162816-aa69164e4478/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= @@ -367,7 +425,9 @@ golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.23.0 h1:KameEIfc1IkluZyXWLn39Wd4tURc6GbCiISGiZm2bQk= golang.org/x/sync v0.23.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190924154521-2837fb4f24fe/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= @@ -377,8 +437,6 @@ golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo= golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= @@ -399,11 +457,13 @@ golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20191216052735-49a3e744a425/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= golang.org/x/tools v0.49.0 h1:3NI7VXzL9+1WZD52Dx2ttoPwD5DWrFGpl9mFZDlmisI= golang.org/x/tools v0.49.0/go.mod h1:SJNXV9DBKT0UbdttsQjbfJlAE/q+y36++zo3uL3N0Oo= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gomodules.xyz/jsonpatch/v2 v2.5.0 h1:JELs8RLM12qJGXU4u/TO3V25KW8GreMKl9pdkk14RM0= gomodules.xyz/jsonpatch/v2 v2.5.0/go.mod h1:AH3dM2RI6uoBZxn3LVrfvJ3E0/9dG4cSrbuBJT4moAY= @@ -416,6 +476,12 @@ google.golang.org/genproto/googleapis/rpc v0.0.0-20260803160001-6ac0973c030d h1: google.golang.org/genproto/googleapis/rpc v0.0.0-20260803160001-6ac0973c030d/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= google.golang.org/grpc v1.83.2 h1:EManeRomTObA0BU7I8vXgg/78uE5MJ9M8B39EX2WscU= google.golang.org/grpc v1.83.2/go.mod h1:YPI1hK3kDked6iHvgX3tR0y+nX/qpMFKhPgFsokw1S8= +google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= +google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= +google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM= +google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE= +google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo= +google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= google.golang.org/protobuf v1.28.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= @@ -434,16 +500,16 @@ gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q= gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA= -k8s.io/api v0.35.3 h1:pA2fiBc6+N9PDf7SAiluKGEBuScsTzd2uYBkA5RzNWQ= -k8s.io/api v0.35.3/go.mod h1:9Y9tkBcFwKNq2sxwZTQh1Njh9qHl81D0As56tu42GA4= +k8s.io/api v0.35.4 h1:P7nFYKl5vo9AGUp1Z+Pmd3p2tA7bX2wbFWCvDeRv988= +k8s.io/api v0.35.4/go.mod h1:yl4lqySWOgYJJf9RERXKUwE9g2y+CkuwG+xmcOK8wXU= k8s.io/apiextensions-apiserver v0.35.0 h1:3xHk2rTOdWXXJM+RDQZJvdx0yEOgC0FgQ1PlJatA5T4= k8s.io/apiextensions-apiserver v0.35.0/go.mod h1:E1Ahk9SADaLQ4qtzYFkwUqusXTcaV2uw3l14aqpL2LU= -k8s.io/apimachinery v0.35.3 h1:MeaUwQCV3tjKP4bcwWGgZ/cp/vpsRnQzqO6J6tJyoF8= -k8s.io/apimachinery v0.35.3/go.mod h1:jQCgFZFR1F4Ik7hvr2g84RTJSZegBc8yHgFWKn//hns= +k8s.io/apimachinery v0.35.4 h1:xtdom9RG7e+yDp71uoXoJDWEE2eOiHgeO4GdBzwWpds= +k8s.io/apimachinery v0.35.4/go.mod h1:NNi1taPOpep0jOj+oRha3mBJPqvi0hGdaV8TCqGQ+cc= k8s.io/apiserver v0.35.3 h1:D2eIcfJ05hEAEewoSDg+05e0aSRwx8Y4Agvd/wiomUI= k8s.io/apiserver v0.35.3/go.mod h1:JI0n9bHYzSgIxgIrfe21dbduJ9NHzKJ6RchcsmIKWKY= -k8s.io/client-go v0.35.3 h1:s1lZbpN4uI6IxeTM2cpdtrwHcSOBML1ODNTCCfsP1pg= -k8s.io/client-go v0.35.3/go.mod h1:RzoXkc0mzpWIDvBrRnD+VlfXP+lRzqQjCmKtiwZ8Q9c= +k8s.io/client-go v0.35.4 h1:DN6fyaGuzK64UvnKO5fOA6ymSjvfGAnCAHAR0C66kD8= +k8s.io/client-go v0.35.4/go.mod h1:2Pg9WpsS4NeOpoYTfHHfMxBG8zFMSAUi4O/qoiJC3nY= k8s.io/component-base v0.35.3 h1:mbKbzoIMy7JDWS/wqZobYW1JDVRn/RKRaoMQHP9c4P0= k8s.io/component-base v0.35.3/go.mod h1:IZ8LEG30kPN4Et5NeC7vjNv5aU73ku5MS15iZyvyMYk= k8s.io/klog/v2 v2.140.0 h1:Tf+J3AH7xnUzZyVVXhTgGhEKnFqye14aadWv7bzXdzc= diff --git a/internal/internaldns/coredns/config.go b/internal/internaldns/coredns/config.go new file mode 100644 index 00000000..116ba51b --- /dev/null +++ b/internal/internaldns/coredns/config.go @@ -0,0 +1,118 @@ +// SPDX-License-Identifier: AGPL-3.0-only + +package coredns + +import ( + "encoding/json" + "errors" + "fmt" + "net" + "net/netip" + "os" + "strconv" + "strings" + + "github.com/coredns/coredns/plugin/file" + "go.miloapis.com/dns-operator/internal/internaldns/model" +) + +type Config struct { + ListenAddress string `json:"listenAddress"` + ProxyPeers []string `json:"proxyPeers"` + PublicUpstreams []string `json:"publicUpstreams,omitempty"` + Contexts []Context `json:"contexts"` +} + +type Context struct { + ID string `json:"id"` + Destination string `json:"destination"` + Pending bool `json:"pending,omitempty"` + Upstreams []string `json:"upstreams,omitempty"` + Zones []Zone `json:"zones,omitempty"` +} + +type Zone struct { + Apex string `json:"apex"` + Pending bool `json:"pending,omitempty"` + Data string `json:"data,omitempty"` +} + +func Load(path string) (Config, error) { + data, err := os.ReadFile(path) + if err != nil { + return Config{}, err + } + return Decode(data) +} + +func Decode(data []byte) (Config, error) { + var c Config + if err := json.Unmarshal(data, &c); err != nil { + return Config{}, err + } + return c, c.Validate() +} + +func (c Config) Validate() error { + if err := endpoint(c.ListenAddress); err != nil { + return fmt.Errorf("CoreDNS listener: %w", err) + } + if len(c.ProxyPeers) == 0 { + return errors.New("CoreDNS requires trusted proxy peers") + } + for _, peer := range c.ProxyPeers { + if _, err := netip.ParseAddr(peer); err != nil { + return fmt.Errorf("CoreDNS proxy peer: %w", err) + } + } + for _, upstream := range c.PublicUpstreams { + if err := endpoint(upstream); err != nil { + return fmt.Errorf("CoreDNS public upstream: %w", err) + } + } + seen := map[string]bool{} + for _, context := range c.Contexts { + destination, err := netip.ParseAddr(context.Destination) + if err != nil { + return err + } + key := destination.Unmap().String() + if context.ID == "" || seen[key] { + return errors.New("CoreDNS contexts require an identity and a unique destination") + } + seen[key] = true + for _, upstream := range context.Upstreams { + if err := endpoint(upstream); err != nil { + return err + } + } + apices := map[string]bool{} + for _, zone := range context.Zones { + apex := model.AbsoluteName(zone.Apex) + if apex == "." || apices[apex] { + return errors.New("CoreDNS context zones require unique private apices") + } + apices[apex] = true + if zone.Pending || len(context.Upstreams) > 0 { + continue + } + if _, err := file.Parse(strings.NewReader(zone.Data), apex, "publication", -1); err != nil { + return fmt.Errorf("CoreDNS zone %s: %w", apex, err) + } + } + } + return nil +} + +func endpoint(value string) error { + host, port, err := net.SplitHostPort(value) + if err != nil { + return err + } + addr, err := netip.ParseAddr(host) + portNumber, portErr := strconv.ParseUint(port, 10, 16) + if err != nil || addr.IsUnspecified() || portErr != nil || portNumber == 0 { + return fmt.Errorf("concrete IP address and port required: %s", value) + } + return nil +} diff --git a/internal/internaldns/coredns/plugin.go b/internal/internaldns/coredns/plugin.go new file mode 100644 index 00000000..3818b710 --- /dev/null +++ b/internal/internaldns/coredns/plugin.go @@ -0,0 +1,179 @@ +// SPDX-License-Identifier: AGPL-3.0-only + +package coredns + +import ( + "context" + "errors" + "net" + "net/netip" + "strings" + "sync/atomic" + + "github.com/coredns/coredns/plugin" + "github.com/coredns/coredns/plugin/cache" + "github.com/coredns/coredns/plugin/file" + "github.com/coredns/coredns/plugin/forward" + "github.com/coredns/coredns/plugin/pkg/proxy" + "github.com/miekg/dns" + "go.miloapis.com/dns-operator/internal/internaldns/model" +) + +type contextState struct { + pending bool + pendingZones map[string]bool + zones *file.File + next plugin.Handler + public plugin.Handler +} + +type snapshot struct { + contexts map[string]*contextState + proxies []*proxy.Proxy +} + +func (s *snapshot) close() { + for _, proxy := range s.proxies { + proxy.Stop() + } +} + +type ContextPlugin struct { + state atomic.Pointer[snapshot] +} + +func (p *ContextPlugin) Name() string { return "dnscontext" } + +func compile(c Config) (*snapshot, error) { + if err := c.Validate(); err != nil { + return nil, err + } + s := &snapshot{contexts: map[string]*contextState{}} + public := s.forward(c.PublicUpstreams) + for _, context := range c.Contexts { + state := &contextState{pending: context.Pending, pendingZones: map[string]bool{}, zones: &file.File{Zones: file.Zones{Z: map[string]*file.Zone{}}}} + for _, zone := range context.Zones { + apex := model.AbsoluteName(zone.Apex) + state.pendingZones[apex] = zone.Pending + state.zones.Names = append(state.zones.Names, apex) + if zone.Pending || len(context.Upstreams) > 0 { + state.zones.Z[apex] = nil + continue + } + parsed, err := file.Parse(strings.NewReader(zone.Data), apex, "publication", -1) + if err != nil { + s.close() + return nil, err + } + state.zones.Z[apex] = parsed + } + state.next = s.forward(context.Upstreams) + if public != nil && state.next == nil { + cached := cache.New() + cached.Next = public + state.public = cached + } + s.contexts[netip.MustParseAddr(context.Destination).Unmap().String()] = state + } + return s, nil +} + +func (s *snapshot) forward(upstreams []string) plugin.Handler { + if len(upstreams) == 0 { + return nil + } + f := forward.New() + for _, upstream := range upstreams { + p := proxy.NewProxy("dnscontext", upstream, "dns") + f.SetProxy(p) + s.proxies = append(s.proxies, p) + } + return f +} + +func (p *ContextPlugin) Replace(c Config) error { + s, err := compile(c) + if err != nil { + return err + } + if previous := p.state.Swap(s); previous != nil { + previous.close() + } + return nil +} + +func (p *ContextPlugin) Close() { + if previous := p.state.Swap(nil); previous != nil { + previous.close() + } +} + +func (p *ContextPlugin) ServeDNS(ctx context.Context, w dns.ResponseWriter, r *dns.Msg) (int, error) { + state := p.state.Load() + if state == nil { + return dns.RcodeServerFailure, nil + } + destination := w.LocalAddr() + if addr, ok := w.RemoteAddr().(interface{ OriginalDestination() net.Addr }); ok { + destination = addr.OriginalDestination() + } + host, _, err := net.SplitHostPort(destination.String()) + if err != nil { + return dns.RcodeRefused, nil + } + context := state.contexts[host] + if context == nil { + return dns.RcodeRefused, nil + } + if context.pending { + return dns.RcodeServerFailure, nil + } + if len(r.Question) != 1 || r.Opcode != dns.OpcodeQuery || r.Question[0].Qclass != dns.ClassINET { + return dns.RcodeRefused, nil + } + if r.Question[0].Qtype == dns.TypeAXFR || r.Question[0].Qtype == dns.TypeIXFR { + return dns.RcodeRefused, nil + } + w = contextWriter{ResponseWriter: w, destination: destination, request: r, recursionAvailable: context.next != nil || context.public != nil} + apex := plugin.Zones(context.zones.Names).Matches(strings.ToLower(r.Question[0].Name)) + if apex != "" { + if context.pendingZones[apex] { + return dns.RcodeServerFailure, nil + } + if context.next != nil { + return context.next.ServeDNS(ctx, w, r) + } + return context.zones.ServeDNS(ctx, w, r) + } + if context.next != nil { + return context.next.ServeDNS(ctx, w, r) + } + if context.public == nil { + return dns.RcodeServerFailure, errors.New("public recursive upstream is unavailable") + } + return context.public.ServeDNS(ctx, w, r) +} + +type contextWriter struct { + dns.ResponseWriter + destination net.Addr + request *dns.Msg + recursionAvailable bool +} + +func (w contextWriter) LocalAddr() net.Addr { return w.destination } + +func (w contextWriter) WriteMsg(message *dns.Msg) error { + message = message.Copy() + if message.Authoritative { + message.RecursionAvailable = w.recursionAvailable + } + if w.RemoteAddr().Network() == "udp" { + size := uint16(dns.MinMsgSize) + if opt := w.request.IsEdns0(); opt != nil { + size = max(size, min(opt.UDPSize(), 4096)) + } + message.Truncate(int(size)) + } + return w.ResponseWriter.WriteMsg(message) +} diff --git a/internal/internaldns/coredns/plugin_test.go b/internal/internaldns/coredns/plugin_test.go new file mode 100644 index 00000000..f177f75b --- /dev/null +++ b/internal/internaldns/coredns/plugin_test.go @@ -0,0 +1,320 @@ +// SPDX-License-Identifier: AGPL-3.0-only + +package coredns + +import ( + "context" + "encoding/binary" + "encoding/json" + "net" + "net/netip" + "os" + "path/filepath" + "strings" + "sync/atomic" + "testing" + "time" + + "github.com/miekg/dns" +) + +func zoneData(address string) string { + return "$ORIGIN datum.internal.\n@ 2 IN SOA ns.internal. hostmaster.internal. 1 60 30 3600 2\n" + + "@ 2 IN NS ns.internal.\napp 2 IN A " + address + "\n" +} + +func fixture() Config { + return Config{ListenAddress: "127.0.0.1:5300", ProxyPeers: []string{"127.0.0.1"}, Contexts: []Context{ + {ID: "a", Destination: "fd53::a", Zones: []Zone{{Apex: "datum.internal.", Data: zoneData("192.0.2.10")}}}, + {ID: "b", Destination: "fd53::b", Zones: []Zone{{Apex: "datum.internal.", Data: zoneData("192.0.2.20")}}}, + }} +} + +type answerWriter struct { + dns.ResponseWriter + destination string + answer *dns.Msg +} + +func (w *answerWriter) LocalAddr() net.Addr { + return &net.UDPAddr{IP: net.ParseIP(w.destination), Port: 53} +} +func (w *answerWriter) RemoteAddr() net.Addr { + return &net.UDPAddr{IP: net.ParseIP("10.0.0.10"), Port: 1234} +} +func (w *answerWriter) WriteMsg(m *dns.Msg) error { w.answer = m; return nil } + +func query(t *testing.T, plugin *ContextPlugin, destination, name string) (*dns.Msg, int) { + t.Helper() + w := &answerWriter{destination: destination} + m := new(dns.Msg) + m.SetQuestion(name, dns.TypeA) + rcode, err := plugin.ServeDNS(context.Background(), w, m) + if err != nil { + t.Fatal(err) + } + return w.answer, rcode +} + +func address(m *dns.Msg) string { + if m != nil { + for _, rr := range m.Answer { + if a, ok := rr.(*dns.A); ok { + return a.A.String() + } + } + } + return "" +} + +func TestOverlappingZonesAndClientAddressesRemainIsolated(t *testing.T) { + t.Parallel() + p := &ContextPlugin{} + defer p.Close() + if err := p.Replace(fixture()); err != nil { + t.Fatal(err) + } + for i := 0; i < 10; i++ { + for _, tc := range []struct{ destination, want string }{{"fd53::a", "192.0.2.10"}, {"fd53::b", "192.0.2.20"}} { + answer, _ := query(t, p, tc.destination, "app.datum.internal.") + if address(answer) != tc.want { + t.Fatalf("%s received %v", tc.destination, answer) + } + } + } + if _, code := query(t, p, "fd53::c", "app.datum.internal."); code != dns.RcodeRefused { + t.Fatal("unknown context was served") + } +} + +func TestInvalidPublicationRetainsStateAndWithdrawalIsImmediate(t *testing.T) { + t.Parallel() + p := &ContextPlugin{} + defer p.Close() + c := fixture() + if err := p.Replace(c); err != nil { + t.Fatal(err) + } + c.Contexts[0].Zones[0].Data = "invalid" + if err := p.Replace(c); err == nil { + t.Fatal("invalid zone was accepted") + } + answer, _ := query(t, p, "fd53::a", "app.datum.internal.") + if address(answer) != "192.0.2.10" { + t.Fatal("invalid publication disturbed accepted state") + } + c.Contexts[0].Zones[0].Data = strings.ReplaceAll(zoneData("192.0.2.10"), "app 2 IN A 192.0.2.10\n", "") + if err := p.Replace(c); err != nil { + t.Fatal(err) + } + answer, _ = query(t, p, "fd53::a", "app.datum.internal.") + if answer == nil || answer.Rcode != dns.RcodeNameError || address(answer) != "" { + t.Fatal("withdrawn endpoint remained available") + } + answer, _ = query(t, p, "fd53::b", "app.datum.internal.") + if address(answer) != "192.0.2.20" { + t.Fatal("withdrawal changed another context") + } + c.Contexts[0].Zones[0].Pending = true + if err := p.Replace(c); err != nil { + t.Fatal(err) + } + if _, code := query(t, p, "fd53::a", "missing.datum.internal."); code != dns.RcodeServerFailure { + t.Fatal("pending private zone did not block public fallback") + } +} + +func TestPublicCachesRemainIsolatedAndPrivateMissesStayLocal(t *testing.T) { + t.Parallel() + packet, err := net.ListenPacket("udp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + var value atomic.Int32 + value.Store(1) + var queries atomic.Int32 + server := &dns.Server{PacketConn: packet, Handler: dns.HandlerFunc(func(w dns.ResponseWriter, r *dns.Msg) { + queries.Add(1) + answer := new(dns.Msg) + answer.SetReply(r) + answer.RecursionAvailable = true + answer.Answer = []dns.RR{&dns.A{Hdr: dns.RR_Header{Name: r.Question[0].Name, Rrtype: dns.TypeA, Class: dns.ClassINET, Ttl: 60}, A: net.IPv4(198, 51, 100, byte(value.Load()))}} + _ = w.WriteMsg(answer) + })} + go func() { _ = server.ActivateAndServe() }() + t.Cleanup(func() { _ = server.Shutdown(); _ = packet.Close() }) + c := fixture() + c.PublicUpstreams = []string{packet.LocalAddr().String()} + p := &ContextPlugin{} + defer p.Close() + if err := p.Replace(c); err != nil { + t.Fatal(err) + } + answer, _ := query(t, p, "fd53::a", "public.example.") + if address(answer) != "198.51.100.1" { + t.Fatal("public recursion failed", answer) + } + value.Store(2) + answer, _ = query(t, p, "fd53::b", "public.example.") + if address(answer) != "198.51.100.2" { + t.Fatal("another context's cached answer was returned", answer) + } + answer, _ = query(t, p, "fd53::a", "public.example.") + if address(answer) != "198.51.100.1" || queries.Load() != 2 { + t.Fatal("context cache was not retained", answer, queries.Load()) + } + answer, _ = query(t, p, "fd53::a", "missing.datum.internal.") + if answer == nil || answer.Rcode != dns.RcodeNameError || queries.Load() != 2 { + t.Fatal("private miss reached public recursion", answer, queries.Load()) + } +} + +func TestTrustedPROXYDestinationSurvivesUDPAndTCP(t *testing.T) { + t.Parallel() + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + c := fixture() + c.ListenAddress = listener.Addr().String() + for i := range c.Contexts { + c.Contexts[i].Zones[0].Data += "alias 2 IN CNAME target.apps.internal.\n" + address := []string{"192.0.2.10", "192.0.2.20"}[i] + c.Contexts[i].Zones = append(c.Contexts[i].Zones, Zone{Apex: "apps.internal.", Data: "$ORIGIN apps.internal.\n@ 2 IN SOA ns.internal. hostmaster.internal. 1 60 30 3600 2\n@ 2 IN NS ns.internal.\ntarget 2 IN A " + address + "\n"}) + } + _ = listener.Close() + path := filepath.Join(t.TempDir(), "contexts.json") + write := func(c Config) { + t.Helper() + data, _ := json.Marshal(c) + if err := os.WriteFile(path+".tmp", data, 0600); err != nil { + t.Fatal(err) + } + if err := os.Rename(path+".tmp", path); err != nil { + t.Fatal(err) + } + } + write(c) + ctx, cancel := context.WithCancel(context.Background()) + done := make(chan error, 1) + go func() { done <- Run(ctx, path) }() + t.Cleanup(func() { + cancel() + select { + case err := <-done: + if err != nil { + t.Error(err) + } + case <-time.After(7 * time.Second): + t.Error("CoreDNS did not stop") + } + }) + for _, network := range []string{"udp", "tcp"} { + for _, tc := range []struct{ destination, want string }{{"fd53::a", "192.0.2.10"}, {"fd53::b", "192.0.2.20"}} { + deadline := time.Now().Add(3 * time.Second) + for { + m, err := proxyQuery(network, c.ListenAddress, tc.destination) + if err == nil && address(m) == tc.want { + break + } + if time.Now().After(deadline) { + t.Fatalf("%s %s: %v %v", network, tc.destination, m, err) + } + time.Sleep(20 * time.Millisecond) + } + m, err := proxyQuery(network, c.ListenAddress, tc.destination, "alias.datum.internal.") + if err != nil || address(m) != tc.want { + t.Fatalf("CNAME changed context over %s: %v %v", network, m, err) + } + } + } + if err := os.Remove(path); err != nil { + t.Fatal(err) + } + time.Sleep(150 * time.Millisecond) + m, err := proxyQuery("udp", c.ListenAddress, "fd53::a") + if err != nil || address(m) != "192.0.2.10" { + t.Fatal("missing delivery file disturbed accepted state", m, err) + } + c.Contexts[0].Zones[0].Data = zoneData("192.0.2.30") + write(c) + deadline := time.Now().Add(3 * time.Second) + for { + m, err := proxyQuery("udp", c.ListenAddress, "fd53::a") + if err == nil && address(m) == "192.0.2.30" { + break + } + if time.Now().After(deadline) { + t.Fatal("publication did not activate", m, err) + } + time.Sleep(20 * time.Millisecond) + } +} + +func proxyQuery(network, server, destination string, names ...string) (*dns.Msg, error) { + conn, err := net.DialTimeout(network, server, 200*time.Millisecond) + if err != nil { + return nil, err + } + defer func() { _ = conn.Close() }() + _ = conn.SetDeadline(time.Now().Add(300 * time.Millisecond)) + header := append([]byte(nil), proxySignature...) + family := byte(0x22) + if network == "tcp" { + family = 0x21 + } + header = append(header, 0x21, family, 0, 36) + header = append(header, net.ParseIP("fd00::10").To16()...) + header = append(header, net.ParseIP(destination).To16()...) + header = append(header, 0x04, 0xd2, 0, 53) + message := new(dns.Msg) + name := "app.datum.internal." + if len(names) > 0 { + name = names[0] + } + message.SetQuestion(name, dns.TypeA) + if network == "tcp" { + if _, err := conn.Write(header); err != nil { + return nil, err + } + dnsConn := &dns.Conn{Conn: conn} + if err := dnsConn.WriteMsg(message); err != nil { + return nil, err + } + return dnsConn.ReadMsg() + } + data, _ := message.Pack() + if _, err := conn.Write(append(header, data...)); err != nil { + return nil, err + } + buffer := make([]byte, 4096) + n, err := conn.Read(buffer) + if err != nil { + return nil, err + } + answer := new(dns.Msg) + return answer, answer.Unpack(buffer[:n]) +} + +func TestMalformedAndWrongTransportHeadersAreRejected(t *testing.T) { + t.Parallel() + for _, data := range [][]byte{nil, make([]byte, 16), append(append([]byte(nil), proxySignature...), 0x20, 0x22, 0, 0)} { + if _, _, _, err := parseHeader(data, false); err == nil { + t.Fatal("invalid PROXY identity accepted") + } + } + header := append(append([]byte(nil), proxySignature...), 0x21, 0x12, 0, 12) + header = append(header, net.ParseIP("192.0.2.1").To4()...) + header = append(header, net.ParseIP("192.0.2.2").To4()...) + header = append(header, 0, 53, 0, 53) + if _, _, _, err := parseHeader(header, true); err == nil { + t.Fatal("UDP header accepted on TCP connection") + } + binary.BigEndian.PutUint16(header[14:16], 65535) + if _, _, _, err := parseHeader(header, false); err == nil { + t.Fatal("truncated PROXY header accepted") + } + if trusted(map[netip.Addr]bool{}, &net.UDPAddr{IP: net.ParseIP("127.0.0.1"), Port: 53}) { + t.Fatal("untrusted proxy peer accepted") + } +} diff --git a/internal/internaldns/coredns/server.go b/internal/internaldns/coredns/server.go new file mode 100644 index 00000000..1f08870e --- /dev/null +++ b/internal/internaldns/coredns/server.go @@ -0,0 +1,89 @@ +// SPDX-License-Identifier: AGPL-3.0-only + +package coredns + +import ( + "bytes" + "context" + "errors" + "fmt" + "log" + "net" + "net/netip" + "os" + "reflect" + "time" + + "github.com/coredns/coredns/core/dnsserver" + "github.com/coredns/coredns/plugin" +) + +func Run(ctx context.Context, path string) error { + previous, err := os.ReadFile(path) + if err != nil { + return err + } + cfg, err := Decode(previous) + if err != nil { + return err + } + p := &ContextPlugin{} + if err := p.Replace(cfg); err != nil { + return err + } + defer p.Close() + host, port, _ := net.SplitHostPort(cfg.ListenAddress) + server, err := dnsserver.NewServer("dns://"+cfg.ListenAddress, []*dnsserver.Config{{ + Zone: ".", Port: port, Transport: "dns", ListenHosts: []string{host}, + Plugin: []plugin.Plugin{func(plugin.Handler) plugin.Handler { return p }}, + }}) + if err != nil { + return err + } + peers := map[netip.Addr]bool{} + for _, peer := range cfg.ProxyPeers { + peers[netip.MustParseAddr(peer).Unmap()] = true + } + tcp, err := net.Listen("tcp", cfg.ListenAddress) + if err != nil { + return err + } + defer func() { _ = tcp.Close() }() + udp, err := net.ListenPacket("udp", cfg.ListenAddress) + if err != nil { + return err + } + defer func() { _ = udp.Close() }() + done := make(chan error, 2) + go func() { done <- server.Serve(&proxyListener{Listener: tcp, peers: peers}) }() + go func() { done <- server.ServePacket(&proxyPacketConn{PacketConn: udp, peers: peers}) }() + ticker := time.NewTicker(100 * time.Millisecond) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return server.Stop() + case err := <-done: + _ = server.Stop() + return fmt.Errorf("CoreDNS listener stopped: %w", errors.Join(err, errors.New("unexpected listener exit"))) + case <-ticker.C: + data, err := os.ReadFile(path) + if err != nil || bytes.Equal(previous, data) { + continue + } + next, err := Decode(data) + if err == nil && (next.ListenAddress != cfg.ListenAddress || !reflect.DeepEqual(next.ProxyPeers, cfg.ProxyPeers)) { + err = errors.New("CoreDNS listener and proxy peers require a process rollout") + } + if err == nil { + err = p.Replace(next) + } + if err != nil { + log.Printf("CoreDNS retains accepted state: %v", err) + previous = data + continue + } + previous = data + } + } +} diff --git a/internal/internaldns/coredns/transport.go b/internal/internaldns/coredns/transport.go new file mode 100644 index 00000000..db306ccb --- /dev/null +++ b/internal/internaldns/coredns/transport.go @@ -0,0 +1,146 @@ +// SPDX-License-Identifier: AGPL-3.0-only + +package coredns + +import ( + "bytes" + "encoding/binary" + "errors" + "io" + "net" + "net/netip" + "time" +) + +var proxySignature = []byte("\r\n\r\n\x00\r\nQUIT\n") + +type proxyAddress struct { + peer, source, destination net.Addr +} + +func (a *proxyAddress) Network() string { return a.peer.Network() } +func (a *proxyAddress) String() string { return a.source.String() } +func (a *proxyAddress) OriginalDestination() net.Addr { return a.destination } + +type proxyPacketConn struct { + net.PacketConn + peers map[netip.Addr]bool +} + +func (c *proxyPacketConn) ReadFrom(p []byte) (int, net.Addr, error) { + for { + n, peer, err := c.PacketConn.ReadFrom(p) + if err != nil { + return n, peer, err + } + if !trusted(c.peers, peer) { + continue + } + source, destination, size, err := parseHeader(p[:n], false) + if err != nil || size >= n { + continue + } + copy(p, p[size:n]) + return n - size, &proxyAddress{peer: peer, source: source, destination: destination}, nil + } +} + +func (c *proxyPacketConn) WriteTo(p []byte, peer net.Addr) (int, error) { + if addr, ok := peer.(*proxyAddress); ok { + peer = addr.peer + } + return c.PacketConn.WriteTo(p, peer) +} + +type proxyListener struct { + net.Listener + peers map[netip.Addr]bool +} + +func (l *proxyListener) Accept() (net.Conn, error) { + for { + conn, err := l.Listener.Accept() + if err != nil { + return nil, err + } + if !trusted(l.peers, conn.RemoteAddr()) { + _ = conn.Close() + continue + } + _ = conn.SetReadDeadline(time.Now().Add(2 * time.Second)) + header := make([]byte, 16) + if _, err := io.ReadFull(conn, header); err != nil || !bytes.Equal(header[:12], proxySignature) { + _ = conn.Close() + continue + } + size := int(binary.BigEndian.Uint16(header[14:16])) + if size > 4096 { + _ = conn.Close() + continue + } + body := make([]byte, size) + if _, err := io.ReadFull(conn, body); err != nil { + _ = conn.Close() + continue + } + source, destination, _, err := parseHeader(append(header, body...), true) + if err != nil { + _ = conn.Close() + continue + } + _ = conn.SetReadDeadline(time.Time{}) + return &proxyConn{Conn: conn, source: source, destination: destination}, nil + } +} + +type proxyConn struct { + net.Conn + source, destination net.Addr +} + +func (c *proxyConn) RemoteAddr() net.Addr { return c.source } +func (c *proxyConn) LocalAddr() net.Addr { return c.destination } + +func trusted(peers map[netip.Addr]bool, addr net.Addr) bool { + host, _, err := net.SplitHostPort(addr.String()) + if err != nil { + return false + } + ip, err := netip.ParseAddr(host) + return err == nil && peers[ip.Unmap()] +} + +func parseHeader(data []byte, tcp bool) (net.Addr, net.Addr, int, error) { + invalid := errors.New("a PROXYv2 address header for this transport is required") + if len(data) < 16 || !bytes.Equal(data[:12], proxySignature) || data[12] != 0x21 { + return nil, nil, 0, invalid + } + size := 16 + int(binary.BigEndian.Uint16(data[14:16])) + if size > len(data) || size > 4112 { + return nil, nil, 0, invalid + } + protocol := byte(2) + if tcp { + protocol = 1 + } + if data[13]&0x0f != protocol { + return nil, nil, 0, invalid + } + length := 4 + if data[13]>>4 == 2 { + length = 16 + } else if data[13]>>4 != 1 { + return nil, nil, 0, invalid + } + if size < 16+2*length+4 { + return nil, nil, 0, invalid + } + source := net.IP(append([]byte(nil), data[16:16+length]...)) + destination := net.IP(append([]byte(nil), data[16+length:16+2*length]...)) + sourcePort := int(binary.BigEndian.Uint16(data[16+2*length:])) + destinationPort := int(binary.BigEndian.Uint16(data[18+2*length:])) + if tcp { + return &net.TCPAddr{IP: source, Port: sourcePort}, &net.TCPAddr{IP: destination, Port: destinationPort}, size, nil + } + return &net.UDPAddr{IP: source, Port: sourcePort}, &net.UDPAddr{IP: destination, Port: destinationPort}, size, nil +} diff --git a/internal/internaldns/model/private_records.go b/internal/internaldns/model/private_records.go index 93a9424c..f04d3453 100644 --- a/internal/internaldns/model/private_records.go +++ b/internal/internaldns/model/private_records.go @@ -11,7 +11,7 @@ const OwnershipMarkerType = "TYPE65280" const recordTypeCNAME = "CNAME" // ValidatePrivateRecordOwner reserves the authority metadata generated by the -// regional BIND fleet. It applies only to internal DNS publication inputs. +// regional CoreDNS fleet. It applies only to internal DNS publication inputs. func ValidatePrivateRecordOwner(owner, apex, recordType string) error { owner = strings.TrimSpace(owner) if strings.EqualFold(recordType, OwnershipMarkerType) { diff --git a/internal/internaldns/serving/render.go b/internal/internaldns/serving/render.go index b74364c7..e61aa3ea 100644 --- a/internal/internaldns/serving/render.go +++ b/internal/internaldns/serving/render.go @@ -2,6 +2,7 @@ package serving import ( "bytes" + "encoding/json" "errors" "fmt" "net" @@ -9,8 +10,8 @@ import ( "path/filepath" "sort" "strconv" - "strings" + contextdns "go.miloapis.com/dns-operator/internal/internaldns/coredns" "go.miloapis.com/dns-operator/internal/internaldns/model" ) @@ -22,18 +23,12 @@ type DNSDistRenderConfig struct { Backends []model.Backend `json:"backends,omitempty"` } -type BINDRenderConfig struct { - Path string `json:"path"` - ListenAddress string `json:"listenAddress"` - Port uint16 `json:"port"` - ProxyPeers []string `json:"proxyPeers"` - DefaultCacheSize string `json:"defaultCacheSize,omitempty"` - MaxNegativeCacheTTLSeconds uint32 `json:"maxNegativeCacheTTLSeconds,omitempty"` - DNSSECValidation string `json:"dnssecValidation,omitempty"` - RNDCIncludePath string `json:"rndcIncludePath,omitempty"` - ControlAddress string `json:"controlAddress,omitempty"` - ControlPort uint16 `json:"controlPort,omitempty"` - ControlKeyName string `json:"controlKeyName,omitempty"` +type CoreDNSRenderConfig struct { + Path string `json:"path"` + ListenAddress string `json:"listenAddress"` + Port uint16 `json:"port"` + ProxyPeers []string `json:"proxyPeers"` + PublicUpstreams []string `json:"publicUpstreams,omitempty"` } type RenderRole string @@ -42,7 +37,7 @@ const ( RenderNode RenderRole = "node" RenderCluster RenderRole = "cluster" RenderBoth RenderRole = "both" - RenderRegionalBIND RenderRole = "regional-bind" + RenderRegionalCoreDNS RenderRole = "regional-coredns" RenderRegionalDNSDist RenderRole = "regional-dnsdist" ) @@ -52,8 +47,8 @@ type RenderConfig struct { Role RenderRole `json:"role"` NodeDNSDist DNSDistRenderConfig `json:"nodeDNSDist,omitempty"` ClusterDNSDist DNSDistRenderConfig `json:"clusterDNSDist,omitempty"` - NodeBIND BINDRenderConfig `json:"nodeBIND,omitempty"` - ClusterBIND BINDRenderConfig `json:"clusterBIND,omitempty"` + NodeCoreDNS CoreDNSRenderConfig `json:"nodeCoreDNS,omitempty"` + ClusterCoreDNS CoreDNSRenderConfig `json:"clusterCoreDNS,omitempty"` } type Rendered struct{ Files map[string][]byte } @@ -92,17 +87,17 @@ func renderPublications(c RenderConfig, snapshot model.ServingSnapshot, active m } if role == RenderNode || role == RenderBoth { files[c.NodeDNSDist.Path] = renderDNSDist(c.NodeDNSDist, nodeBackends, bindings, active, false, nil, c.ReadyZoneMembers) - files[c.NodeBIND.Path] = renderNodeBIND(c.NodeBIND, bindings) + files[c.NodeCoreDNS.Path] = renderNodeCoreDNS(c.NodeCoreDNS, bindings) } if role == RenderCluster || role == RenderBoth || role == RenderRegionalDNSDist { files[c.ClusterDNSDist.Path] = renderDNSDist(c.ClusterDNSDist, clusterBackends, bindings, active, true, c.ReadyMembers, c.ReadyZoneMembers) } - if role == RenderCluster || role == RenderBoth || role == RenderRegionalBIND { - cluster, err := renderClusterBIND(c.ClusterBIND, bindings, publications, files) + if role == RenderCluster || role == RenderBoth || role == RenderRegionalCoreDNS { + cluster, err := renderClusterCoreDNS(c.ClusterCoreDNS, bindings, publications, files) if err != nil { return Rendered{}, err } - files[c.ClusterBIND.Path] = cluster + files[c.ClusterCoreDNS.Path] = cluster } return Rendered{Files: files}, nil } @@ -112,21 +107,21 @@ func validateRenderConfig(c RenderConfig) error { if role == "" { role = RenderBoth } - if role != RenderNode && role != RenderCluster && role != RenderBoth && role != RenderRegionalBIND && role != RenderRegionalDNSDist { + if role != RenderNode && role != RenderCluster && role != RenderBoth && role != RenderRegionalCoreDNS && role != RenderRegionalDNSDist { return fmt.Errorf("unsupported render role %q", role) } dists := map[string]DNSDistRenderConfig{} - binds := map[string]BINDRenderConfig{} + binds := map[string]CoreDNSRenderConfig{} if role == RenderNode || role == RenderBoth { dists["node dnsdist"] = c.NodeDNSDist - binds["node BIND"] = c.NodeBIND + binds["node CoreDNS"] = c.NodeCoreDNS } if role == RenderCluster || role == RenderBoth || role == RenderRegionalDNSDist { dists["cluster dnsdist"] = c.ClusterDNSDist } - if role == RenderCluster || role == RenderBoth || role == RenderRegionalBIND { - binds["cluster BIND"] = c.ClusterBIND + if role == RenderCluster || role == RenderBoth || role == RenderRegionalCoreDNS { + binds["cluster CoreDNS"] = c.ClusterCoreDNS } for label, d := range dists { if !filepath.IsAbs(d.Path) || d.PoolName == "" || model.SafeToken(d.PoolName) != d.PoolName { @@ -156,25 +151,9 @@ func validateRenderConfig(c RenderConfig) error { return fmt.Errorf("%s invalid proxy peer %q", label, peer) } } - if b.DefaultCacheSize == "" { - b.DefaultCacheSize = "16M" - } - if !safeNamedAtom(b.DefaultCacheSize) { - return fmt.Errorf("%s unsafe cache size", label) - } - if b.MaxNegativeCacheTTLSeconds > 60 { - return fmt.Errorf("%s maximum negative cache TTL may not exceed 60 seconds", label) - } - if b.DNSSECValidation != "" && b.DNSSECValidation != "auto" && b.DNSSECValidation != "yes" && b.DNSSECValidation != "no" { - return fmt.Errorf("%s invalid DNSSEC validation mode", label) - } - if b.RNDCIncludePath != "" && (!filepath.IsAbs(b.RNDCIncludePath) || strings.ContainsAny(b.RNDCIncludePath, "\"\n\r")) { - return fmt.Errorf("%s unsafe RNDC include", label) - } - if b.ControlAddress != "" { - if _, err := netip.ParseAddr(b.ControlAddress); err != nil || b.ControlPort == 0 || !safeNamedAtom(b.ControlKeyName) { - return fmt.Errorf("%s invalid controls", label) - } + cfg := contextdns.Config{ListenAddress: net.JoinHostPort(b.ListenAddress, strconv.Itoa(int(b.Port))), ProxyPeers: b.ProxyPeers, PublicUpstreams: b.PublicUpstreams} + if err := cfg.Validate(); err != nil { + return fmt.Errorf("%s: %w", label, err) } } return nil @@ -280,138 +259,42 @@ func renderDNSDist(c DNSDistRenderConfig, backends []model.Backend, bindings []m return out.Bytes() } -func renderOptions(out *bytes.Buffer, c BINDRenderConfig) { - fmt.Fprint(out, "options {\n directory \"/tmp\";\n") - directive := "listen-on" - if netip.MustParseAddr(c.ListenAddress).Is6() { - directive = "listen-on-v6" - } - fmt.Fprintf(out, " %s port %d proxy plain { %s; };\n", directive, c.Port, c.ListenAddress) - if directive == "listen-on" { - fmt.Fprint(out, " listen-on-v6 { none; };\n") - } else { - fmt.Fprint(out, " listen-on { none; };\n") - } - fmt.Fprint(out, " allow-proxy { ") - for _, p := range c.ProxyPeers { - fmt.Fprintf(out, "%s; ", p) - } - fmt.Fprint(out, "};\n") - fmt.Fprintf(out, " allow-proxy-on { %s; };\n", c.ListenAddress) - dnssec := c.DNSSECValidation - if dnssec == "" { - dnssec = "auto" - } - id := model.OpaqueToken(c.Path) - fmt.Fprintf(out, " dnssec-validation %s;\n", dnssec) - fmt.Fprint(out, " recursion yes;\n empty-zones-enable no;\n stale-answer-enable no;\n stale-cache-enable no;\n") - fmt.Fprintf(out, " pid-file \"/tmp/internal-dns-%s.pid\";\n session-keyfile \"/tmp/internal-dns-%s-session.key\";\n};\n\n", id, id) - if c.RNDCIncludePath != "" { - fmt.Fprintf(out, "include %q;\n", c.RNDCIncludePath) - } - if c.ControlAddress != "" { - fmt.Fprintf(out, "controls { inet %s port %d allow { %s; } keys { %q; }; };\n", c.ControlAddress, c.ControlPort, c.ControlAddress, c.ControlKeyName) - } -} - -func renderNodeBIND(c BINDRenderConfig, bindings []model.Binding) []byte { - var out bytes.Buffer - renderOptions(&out, c) +func renderNodeCoreDNS(c CoreDNSRenderConfig, bindings []model.Binding) []byte { + cfg := coreDNSConfig(c) for _, b := range bindings { - renderViewStart(&out, b, b.ConsumerAddress, c.DefaultCacheSize, maxNegativeCacheTTL(c)) - fmt.Fprint(&out, " forward only;\n forwarders { ") - renderAddrPort(&out, b.ClusterAddress, b.Port) - fmt.Fprint(&out, " };\n};\n\n") + context := contextdns.Context{ID: b.ViewName(), Destination: b.ConsumerAddress, Pending: b.ConfigurationPending, Upstreams: []string{net.JoinHostPort(b.ClusterAddress, strconv.Itoa(int(b.Port)))}} + for _, zone := range b.Zones { + context.Zones = append(context.Zones, contextdns.Zone{Apex: model.AbsoluteName(zone.Apex), Pending: zone.Pending}) + } + cfg.Contexts = append(cfg.Contexts, context) } - renderManagementAndReject(&out, c.ListenAddress) - return out.Bytes() + data, _ := json.MarshalIndent(cfg, "", " ") + return data } -func renderClusterBIND(c BINDRenderConfig, bindings []model.Binding, publications map[string]publicationState, files map[string][]byte) ([]byte, error) { - var out bytes.Buffer - renderOptions(&out, c) +func renderClusterCoreDNS(c CoreDNSRenderConfig, bindings []model.Binding, publications map[string]publicationState, _ map[string][]byte) ([]byte, error) { + cfg := coreDNSConfig(c) for _, b := range bindings { - renderViewStart(&out, b, b.ClusterAddress, c.DefaultCacheSize, maxNegativeCacheTTL(c)) + context := contextdns.Context{ID: b.ViewName(), Destination: b.ClusterAddress, Pending: b.ConfigurationPending} for _, z := range b.Zones { + zone := contextdns.Zone{Apex: model.AbsoluteName(z.Apex)} p, ok := publications[z.ZoneUID] if z.Pending || !ok || p.Manifest.Tombstone || model.Compare(p.Fence.Epoch, p.Fence.Revision, z.RequiredPublicationEpoch, z.RequiredPublicationRevision) < 0 { - // Never recurse toward the public namespace when a private apex is missing. - // The front gate returns immediate SERVFAIL; direct BIND queries fail too. - fmt.Fprintf(&out, " zone %q { type forward; forward only; forwarders { 127.0.0.1 port 9; }; };\n", strings.TrimSuffix(model.AbsoluteName(z.Apex), ".")) - continue - } - data, err := renderZone(p) - if err != nil { - return nil, err + zone.Pending = true + } else { + data, err := renderZone(p) + if err != nil { + return nil, err + } + zone.Data = string(data) } - path := filepath.Join(filepath.Dir(c.Path), "zone-"+model.OpaqueToken(z.ZoneUID)+"-"+model.Hash(data)[:16]+".db") - files[path] = data - fmt.Fprintf(&out, " zone %q { type primary; file %q; allow-transfer { none; }; notify no; };\n", strings.TrimSuffix(model.AbsoluteName(z.Apex), "."), path) - } - fmt.Fprint(&out, "};\n\n") - } - renderManagementAndReject(&out, c.ListenAddress) - return out.Bytes(), nil -} - -func renderViewStart(out *bytes.Buffer, b model.Binding, dest, defaultCache string, maxNegativeCacheTTL uint32) { - if b.ConfigurationPending { - fmt.Fprintf(out, "view %q {\n match-clients { any; };\n match-destinations { %s; };\n recursion no;\n allow-query { none; };\n allow-recursion { none; };\n allow-query-cache { none; };\n", b.ViewName(), dest) - return - } - cache := defaultCache - if cache == "" { - cache = "16M" - } - fmt.Fprintf(out, "view %q {\n match-clients { any; };\n match-destinations { %s; };\n allow-query { any; };\n allow-recursion { any; };\n allow-query-cache { any; };\n max-cache-size %s;\n max-ncache-ttl %d;\n", b.ViewName(), dest, cache, maxNegativeCacheTTL) - // Private authoritative zones are intentionally unsigned and have no DS in - // the public root chain. Keep DNSSEC validation enabled for public recursion, - // but exclude every private apex attached to this isolated view. - apices := make([]string, 0, len(b.Zones)) - seen := map[string]bool{} - for _, zone := range b.Zones { - apex := strings.TrimSuffix(model.AbsoluteName(zone.Apex), ".") - if !seen[apex] { - seen[apex] = true - apices = append(apices, apex) - } - } - if len(apices) > 0 { - sort.Strings(apices) - fmt.Fprint(out, " validate-except { ") - for _, apex := range apices { - fmt.Fprintf(out, "%q; ", apex) + context.Zones = append(context.Zones, zone) } - fmt.Fprint(out, "};\n") + cfg.Contexts = append(cfg.Contexts, context) } + return json.MarshalIndent(cfg, "", " ") } -func maxNegativeCacheTTL(c BINDRenderConfig) uint32 { - if c.MaxNegativeCacheTTLSeconds == 0 { - return 5 - } - return c.MaxNegativeCacheTTLSeconds -} - -func renderManagementAndReject(out *bytes.Buffer, listen string) { - fmt.Fprintf(out, "view \"management\" {\n match-clients { any; };\n match-destinations { %s; };\n recursion no;\n allow-query { any; };\n allow-query-cache { none; };\n};\n\n", listen) - fmt.Fprint(out, "view \"reject\" {\n match-clients { any; };\n match-destinations { any; };\n recursion no;\n allow-query { none; };\n allow-query-cache { none; };\n};\n") -} -func renderAddrPort(out *bytes.Buffer, addr string, port uint16) { - fmt.Fprint(out, addr) - if port != 53 { - fmt.Fprintf(out, " port %d", port) - } - fmt.Fprint(out, "; ") -} -func safeNamedAtom(s string) bool { - if s == "" { - return false - } - for _, r := range s { - if (r < 'a' || r > 'z') && (r < 'A' || r > 'Z') && (r < '0' || r > '9') && r != '_' && r != '-' { - return false - } - } - return true +func coreDNSConfig(c CoreDNSRenderConfig) contextdns.Config { + return contextdns.Config{ListenAddress: net.JoinHostPort(c.ListenAddress, strconv.Itoa(int(c.Port))), ProxyPeers: c.ProxyPeers, PublicUpstreams: c.PublicUpstreams, Contexts: []contextdns.Context{}} } diff --git a/internal/internaldns/serving/render_test.go b/internal/internaldns/serving/render_test.go index 744bcd91..10634921 100644 --- a/internal/internaldns/serving/render_test.go +++ b/internal/internaldns/serving/render_test.go @@ -1,6 +1,8 @@ package serving import ( + "encoding/json" + contextdns "go.miloapis.com/dns-operator/internal/internaldns/coredns" "strings" "testing" "time" @@ -12,7 +14,7 @@ func renderFixture(t *testing.T) (RenderConfig, model.ServingSnapshot) { t.Helper() now := time.Now() b := model.Binding{BindingUID: "bind-a", ProjectUID: "project-a", ContextUID: "vpc-a", BindingGeneration: 1, ConfigurationRevision: 3, ConsumerAddress: "10.253.0.51", ClusterAddress: "10.253.0.41", Port: 53, Transports: []model.Transport{model.TransportUDP, model.TransportTCP}, Zones: []model.ZoneAttachment{{ZoneUID: "zone-a", Apex: "prod.internal", RequiredPublicationEpoch: 1, RequiredPublicationRevision: 2}}, Version: model.AccessVersion{Epoch: 1, Sequence: 2}} - c := RenderConfig{NodeDNSDist: DNSDistRenderConfig{Path: "/tmp/node-dnsdist.conf", ListenAddress: "0.0.0.0:53", ACLs: []string{"10.253.0.0/24"}, PoolName: "node-shared", Backends: []model.Backend{{MemberID: "node-bind-0", Address: "10.253.0.30", Port: 5300}}}, ClusterDNSDist: DNSDistRenderConfig{Path: "/tmp/cluster-dnsdist.conf", ListenAddress: "0.0.0.0:53", ACLs: []string{"10.253.0.0/24"}, PoolName: "cluster-shared", Backends: []model.Backend{{MemberID: "cluster-bind-0", Address: "10.253.0.20", Port: 5300}}}, NodeBIND: BINDRenderConfig{Path: "/tmp/node-bind.conf", ListenAddress: "10.253.0.30", Port: 5300, ProxyPeers: []string{"10.253.0.50"}, DefaultCacheSize: "16M"}, ClusterBIND: BINDRenderConfig{Path: "/tmp/cluster-bind.conf", ListenAddress: "10.253.0.20", Port: 5300, ProxyPeers: []string{"10.253.0.40"}, DefaultCacheSize: "16M"}} + c := RenderConfig{NodeDNSDist: DNSDistRenderConfig{Path: "/tmp/node-dnsdist.conf", ListenAddress: "0.0.0.0:53", ACLs: []string{"10.253.0.0/24"}, PoolName: "node-shared", Backends: []model.Backend{{MemberID: "node-coredns-0", Address: "10.253.0.30", Port: 5300}}}, ClusterDNSDist: DNSDistRenderConfig{Path: "/tmp/cluster-dnsdist.conf", ListenAddress: "0.0.0.0:53", ACLs: []string{"10.253.0.0/24"}, PoolName: "cluster-shared", Backends: []model.Backend{{MemberID: "cluster-coredns-0", Address: "10.253.0.20", Port: 5300}}}, NodeCoreDNS: CoreDNSRenderConfig{Path: "/tmp/node-coredns.json", ListenAddress: "10.253.0.30", Port: 5300, ProxyPeers: []string{"10.253.0.50"}}, ClusterCoreDNS: CoreDNSRenderConfig{Path: "/tmp/cluster-coredns.json", ListenAddress: "10.253.0.20", Port: 5300, ProxyPeers: []string{"10.253.0.40"}}} s := model.ServingSnapshot{Region: "east", Shard: "s1", ConfigurationEpoch: 1, ConfigurationRevision: 3, GeneratedAt: now, Bindings: []model.Binding{b}} return c, s } @@ -27,7 +29,7 @@ func TestRenderStagesViewsButActivatesOnlyReadyBindings(t *testing.T) { if bindingReadyInConfig(string(r.Files[c.NodeDNSDist.Path]), "10.253.0.51") { t.Fatal("unready binding activated in dnsdist") } - if !strings.Contains(string(r.Files[c.NodeBIND.Path]), "10.253.0.51") { + if !strings.Contains(string(r.Files[c.NodeCoreDNS.Path]), "10.253.0.51") { t.Fatal("view was not staged before activation") } r, err = Render(c, s, map[string]bool{"bind-a": true}) @@ -42,65 +44,44 @@ func TestRenderStagesViewsButActivatesOnlyReadyBindings(t *testing.T) { } } -func TestRenderUsesOpaqueViewAndPrivatePROXYListener(t *testing.T) { +func TestRenderCoreDNSPreservesContextAndPrivateZoneBoundary(t *testing.T) { t.Parallel() - c, s := renderFixture(t) - r, err := Render(c, s, map[string]bool{"bind-a": true}) + c, snapshot := renderFixture(t) + r, err := Render(c, snapshot, map[string]bool{"bind-a": true}) if err != nil { t.Fatal(err) } - cluster := string(r.Files[c.ClusterBIND.Path]) - if strings.Contains(cluster, "vpc-a") || !strings.Contains(cluster, "proxy plain") || strings.Contains(cluster, "query-source") || !strings.Contains(cluster, "forward only") { - t.Fatalf("unsafe or incomplete cluster config:\n%s", cluster) - } - for path, config := range map[string]string{c.NodeBIND.Path: string(r.Files[c.NodeBIND.Path]), c.ClusterBIND.Path: cluster} { - if !strings.Contains(config, `dnssec-validation auto;`) || !strings.Contains(config, `validate-except { "prod.internal"; };`) { - t.Fatalf("%s does not retain public DNSSEC while excluding the private apex:\n%s", path, config) + for _, path := range []string{c.NodeCoreDNS.Path, c.ClusterCoreDNS.Path} { + cfg := decodedCoreDNS(t, r.Files[path]) + if len(cfg.ProxyPeers) != 1 || len(cfg.Contexts) != 1 || cfg.Contexts[0].ID != snapshot.Bindings[0].ViewName() { + t.Fatalf("identity or proxy peers missing: %#v", cfg) } - if !strings.Contains(config, "max-ncache-ttl 5;") { - t.Fatalf("%s does not bound private negative cache retention:\n%s", path, config) + if len(cfg.Contexts[0].Zones) != 1 || cfg.Contexts[0].Zones[0].Apex != "prod.internal." { + t.Fatal("private apex missing") } } + cfg := decodedCoreDNS(t, r.Files[c.ClusterCoreDNS.Path]) + if !cfg.Contexts[0].Zones[0].Pending || cfg.Contexts[0].Zones[0].Data != "" { + t.Fatal("missing private publication can be served") + } } -func TestRenderConfiguresPrivateNegativeCacheBoundPerTier(t *testing.T) { +func TestRenderRejectsUntrustedCoreDNSListener(t *testing.T) { t.Parallel() c, snapshot := renderFixture(t) - c.NodeBIND.MaxNegativeCacheTTLSeconds = 3 - c.ClusterBIND.MaxNegativeCacheTTLSeconds = 7 - rendered, err := Render(c, snapshot, map[string]bool{"bind-a": true}) - if err != nil { - t.Fatal(err) - } - if config := string(rendered.Files[c.NodeBIND.Path]); !strings.Contains(config, "max-ncache-ttl 3;") { - t.Fatalf("node view missing configured negative cache bound:\n%s", config) - } - if config := string(rendered.Files[c.ClusterBIND.Path]); !strings.Contains(config, "max-ncache-ttl 7;") { - t.Fatalf("cluster view missing configured negative cache bound:\n%s", config) - } - c.NodeBIND.MaxNegativeCacheTTLSeconds = 61 - if _, err := Render(c, snapshot, map[string]bool{"bind-a": true}); err == nil { - t.Fatal("unsafe negative cache retention was accepted") + c.NodeCoreDNS.ProxyPeers = nil + if _, err := Render(c, snapshot, nil); err == nil { + t.Fatal("unprotected context listener accepted") } } -func TestRenderDNSSECExcludesEveryPrivateApexDeterministically(t *testing.T) { - t.Parallel() - c, snapshot := renderFixture(t) - snapshot.Bindings[0].Zones = append(snapshot.Bindings[0].Zones, - model.ZoneAttachment{ZoneUID: "zone-z", Apex: "z.private", RequiredPublicationEpoch: 1, RequiredPublicationRevision: 1}, - model.ZoneAttachment{ZoneUID: "zone-a2", Apex: "a.private", RequiredPublicationEpoch: 1, RequiredPublicationRevision: 1}, - ) - rendered, err := Render(c, snapshot, map[string]bool{"bind-a": true}) - if err != nil { +func decodedCoreDNS(t *testing.T, data []byte) contextdns.Config { + t.Helper() + var cfg contextdns.Config + if err := json.Unmarshal(data, &cfg); err != nil { t.Fatal(err) } - want := `validate-except { "a.private"; "prod.internal"; "z.private"; };` - for _, path := range []string{c.NodeBIND.Path, c.ClusterBIND.Path} { - if !strings.Contains(string(rendered.Files[path]), want) { - t.Fatalf("%s missing sorted multi-zone DNSSEC exclusions:\n%s", path, rendered.Files[path]) - } - } + return cfg } func TestRenderRoleOwnsOnlyItsTier(t *testing.T) { @@ -110,8 +91,8 @@ func TestRenderRoleOwnsOnlyItsTier(t *testing.T) { want []string not []string }{ - {RenderNode, []string{"/tmp/node-dnsdist.conf", "/tmp/node-bind.conf"}, []string{"/tmp/cluster-dnsdist.conf", "/tmp/cluster-bind.conf"}}, - {RenderCluster, []string{"/tmp/cluster-dnsdist.conf", "/tmp/cluster-bind.conf"}, []string{"/tmp/node-dnsdist.conf", "/tmp/node-bind.conf"}}, + {RenderNode, []string{"/tmp/node-dnsdist.conf", "/tmp/node-coredns.json"}, []string{"/tmp/cluster-dnsdist.conf", "/tmp/cluster-coredns.json"}}, + {RenderCluster, []string{"/tmp/cluster-dnsdist.conf", "/tmp/cluster-coredns.json"}, []string{"/tmp/node-dnsdist.conf", "/tmp/node-coredns.json"}}, } { t.Run(string(tc.role), func(t *testing.T) { c, s := renderFixture(t) @@ -160,3 +141,28 @@ func bindingReadyInConfig(config, address string) bool { } return false } + +func TestPendingConfigurationCannotBeActivatedByReadinessMap(t *testing.T) { + t.Parallel() + config, snapshot := renderFixture(t) + snapshot.Bindings[0].ConfigurationPending = true + snapshot.Bindings[0].Zones = nil + rendered, err := Render(config, snapshot, map[string]bool{"bind-a": true}) + if err != nil { + t.Fatal(err) + } + for _, path := range []string{config.NodeDNSDist.Path, config.ClusterDNSDist.Path} { + address := snapshot.Bindings[0].ConsumerAddress + if path == config.ClusterDNSDist.Path { + address = snapshot.Bindings[0].ClusterAddress + } + if bindingReadyInConfig(string(rendered.Files[path]), address) { + t.Fatal("readiness activated a pending context") + } + } + for _, path := range []string{config.NodeCoreDNS.Path, config.ClusterCoreDNS.Path} { + if !decodedCoreDNS(t, rendered.Files[path]).Contexts[0].Pending { + t.Fatal("pending CoreDNS context can recurse") + } + } +} diff --git a/internal/internaldns/serving/verify.go b/internal/internaldns/serving/verify.go index 47a1adce..a282af82 100644 --- a/internal/internaldns/serving/verify.go +++ b/internal/internaldns/serving/verify.go @@ -128,11 +128,11 @@ type PublicationDNSVerifierConfig struct { } type PublicationDNSVerifier struct{ Config PublicationDNSVerifierConfig } -// Probe the member's BIND process directly with the protected destination +// Probe the member's CoreDNS process directly with the protected destination // identity. Querying the shared dnsdist pool could falsely prove another member. func (v PublicationDNSVerifier) VerifyPublication(ctx context.Context, plan model.PublicationPlan, views []PublicationView) error { if v.Config.Server == "" { - return errors.New("regional BIND probe server is required") + return errors.New("regional CoreDNS probe server is required") } if len(views) == 0 { return errors.New("publication is not installed in any configured context") @@ -146,7 +146,7 @@ func (v PublicationDNSVerifier) VerifyPublication(ctx context.Context, plan mode type publicationExchange func(context.Context, string, string, string, PublicationView, time.Duration) (*dns.Msg, error) -// BIND reload may return before every view exposes its new zone. Retry the +// CoreDNS reload may return before every view exposes its new zone. Retry the // exact installed fingerprint while retaining one deadline for the entire proof. func verifyPublicationViews(ctx context.Context, server, apex string, views []PublicationView, timeout time.Duration, exchange publicationExchange) error { probeCtx, cancel := context.WithTimeout(ctx, timeout) @@ -157,7 +157,7 @@ func verifyPublicationViews(ctx context.Context, server, apex string, views []Pu var last error for { if err := publicationDeadlineError(probeCtx, deadline); err != nil { - return fmt.Errorf("verify BIND view %s over %s within %s: %w (last response: %v)", view.View, network, timeout, err, last) + return fmt.Errorf("verify CoreDNS view %s over %s within %s: %w (last response: %v)", view.View, network, timeout, err, last) } answer, err := exchange(probeCtx, network, server, apex, view, min(time.Until(deadline), 250*time.Millisecond)) if err != nil { @@ -167,7 +167,7 @@ func verifyPublicationViews(ctx context.Context, server, apex string, views []Pu } // An exchange finishing after the total deadline cannot prove readiness. if err := publicationDeadlineError(probeCtx, deadline); err != nil { - return fmt.Errorf("verify BIND view %s over %s within %s: %w (last response: %v)", view.View, network, timeout, err, last) + return fmt.Errorf("verify CoreDNS view %s over %s within %s: %w (last response: %v)", view.View, network, timeout, err, last) } if last == nil { break @@ -176,7 +176,7 @@ func verifyPublicationViews(ctx context.Context, server, apex string, views []Pu select { case <-probeCtx.Done(): timer.Stop() - return fmt.Errorf("verify BIND view %s over %s within %s: %w (last response: %v)", view.View, network, timeout, probeCtx.Err(), last) + return fmt.Errorf("verify CoreDNS view %s over %s within %s: %w (last response: %v)", view.View, network, timeout, probeCtx.Err(), last) case <-timer.C: } } @@ -238,7 +238,7 @@ func exchangePublication(ctx context.Context, network, server, name string, view return nil, err } // Source is immaterial for context selection; destination identity is trusted - // only because the direct peer is in BIND's protected proxy ACL. + // only because the direct peer is in CoreDNS's protected proxy ACL. header := []byte{13, 10, 13, 10, 0, 13, 10, 81, 85, 73, 84, 10, 0x21, 0, 0, 0} proto := byte(2) if network == networkTCP { From e39a3aacfbe30dfd899f393a3924e63d05838716 Mon Sep 17 00:00:00 2001 From: Scot Wells Date: Wed, 14 Oct 2026 17:15:38 -0500 Subject: [PATCH 3/4] fix: preserve identity across DNS health probes --- internal/internaldns/coredns/config.go | 26 ++++++++++++++++++++- internal/internaldns/coredns/plugin_test.go | 18 +++++++++++++- internal/internaldns/coredns/transport.go | 11 ++++++++- 3 files changed, 52 insertions(+), 3 deletions(-) diff --git a/internal/internaldns/coredns/config.go b/internal/internaldns/coredns/config.go index 116ba51b..a291ddbf 100644 --- a/internal/internaldns/coredns/config.go +++ b/internal/internaldns/coredns/config.go @@ -13,6 +13,7 @@ import ( "strings" "github.com/coredns/coredns/plugin/file" + "github.com/miekg/dns" "go.miloapis.com/dns-operator/internal/internaldns/model" ) @@ -66,17 +67,21 @@ func (c Config) Validate() error { } } for _, upstream := range c.PublicUpstreams { - if err := endpoint(upstream); err != nil { + if err := publicEndpoint(upstream); err != nil { return fmt.Errorf("CoreDNS public upstream: %w", err) } } seen := map[string]bool{} + listenerHost, _, _ := net.SplitHostPort(c.ListenAddress) for _, context := range c.Contexts { destination, err := netip.ParseAddr(context.Destination) if err != nil { return err } key := destination.Unmap().String() + if key == netip.MustParseAddr(listenerHost).Unmap().String() { + return errors.New("CoreDNS management listener cannot identify a tenant context") + } if context.ID == "" || seen[key] { return errors.New("CoreDNS contexts require an identity and a unique destination") } @@ -104,6 +109,25 @@ func (c Config) Validate() error { return nil } +func publicEndpoint(value string) error { + if err := endpoint(value); err == nil { + return nil + } + host, port, err := net.SplitHostPort(value) + if err != nil { + return err + } + if _, err := netip.ParseAddr(host); err == nil { + return endpoint(value) + } + _, valid := dns.IsDomainName(host) + portNumber, portErr := strconv.ParseUint(port, 10, 16) + if !valid || host == "" || host == "." || portErr != nil || portNumber == 0 { + return fmt.Errorf("public DNS upstream requires a hostname or IP and port: %s", value) + } + return nil +} + func endpoint(value string) error { host, port, err := net.SplitHostPort(value) if err != nil { diff --git a/internal/internaldns/coredns/plugin_test.go b/internal/internaldns/coredns/plugin_test.go index f177f75b..b53507a1 100644 --- a/internal/internaldns/coredns/plugin_test.go +++ b/internal/internaldns/coredns/plugin_test.go @@ -298,7 +298,7 @@ func proxyQuery(network, server, destination string, names ...string) (*dns.Msg, func TestMalformedAndWrongTransportHeadersAreRejected(t *testing.T) { t.Parallel() - for _, data := range [][]byte{nil, make([]byte, 16), append(append([]byte(nil), proxySignature...), 0x20, 0x22, 0, 0)} { + for _, data := range [][]byte{nil, make([]byte, 16), append(append([]byte(nil), proxySignature...), 0x22, 0x22, 0, 0)} { if _, _, _, err := parseHeader(data, false); err == nil { t.Fatal("invalid PROXY identity accepted") } @@ -318,3 +318,19 @@ func TestMalformedAndWrongTransportHeadersAreRejected(t *testing.T) { t.Fatal("untrusted proxy peer accepted") } } + +func TestLocalHealthHeaderCannotSelectAContext(t *testing.T) { + t.Parallel() + header := append(append([]byte(nil), proxySignature...), 0x20, 0, 0, 0) + for _, tcp := range []bool{false, true} { + source, destination, size, err := parseHeader(header, tcp) + if err != nil || source != nil || destination != nil || size != len(header) { + t.Fatal("LOCAL header acquired an asserted tenant identity") + } + } + c := fixture() + c.Contexts[0].Destination = "127.0.0.1" + if err := c.Validate(); err == nil { + t.Fatal("management listener can select a tenant context") + } +} diff --git a/internal/internaldns/coredns/transport.go b/internal/internaldns/coredns/transport.go index db306ccb..7f10ac2d 100644 --- a/internal/internaldns/coredns/transport.go +++ b/internal/internaldns/coredns/transport.go @@ -40,6 +40,9 @@ func (c *proxyPacketConn) ReadFrom(p []byte) (int, net.Addr, error) { if err != nil || size >= n { continue } + if source == nil { + source, destination = peer, c.LocalAddr() + } copy(p, p[size:n]) return n - size, &proxyAddress{peer: peer, source: source, destination: destination}, nil } @@ -88,6 +91,9 @@ func (l *proxyListener) Accept() (net.Conn, error) { _ = conn.Close() continue } + if source == nil { + source, destination = conn.RemoteAddr(), conn.LocalAddr() + } _ = conn.SetReadDeadline(time.Time{}) return &proxyConn{Conn: conn, source: source, destination: destination}, nil } @@ -112,13 +118,16 @@ func trusted(peers map[netip.Addr]bool, addr net.Addr) bool { func parseHeader(data []byte, tcp bool) (net.Addr, net.Addr, int, error) { invalid := errors.New("a PROXYv2 address header for this transport is required") - if len(data) < 16 || !bytes.Equal(data[:12], proxySignature) || data[12] != 0x21 { + if len(data) < 16 || !bytes.Equal(data[:12], proxySignature) || data[12] != 0x21 && data[12] != 0x20 { return nil, nil, 0, invalid } size := 16 + int(binary.BigEndian.Uint16(data[14:16])) if size > len(data) || size > 4112 { return nil, nil, 0, invalid } + if data[12] == 0x20 { + return nil, nil, size, nil + } protocol := byte(2) if tcp { protocol = 1 From a9501859bbb5e311a811c3eb8dc78990be5be897 Mon Sep 17 00:00:00 2001 From: Scot Wells Date: Wed, 14 Oct 2026 17:34:40 -0500 Subject: [PATCH 4/4] refactor: clarify DNS context selection --- internal/internaldns/coredns/plugin.go | 42 ++++++++++----------- internal/internaldns/serving/render_test.go | 3 +- 2 files changed, 23 insertions(+), 22 deletions(-) diff --git a/internal/internaldns/coredns/plugin.go b/internal/internaldns/coredns/plugin.go index 3818b710..6f5fe8f8 100644 --- a/internal/internaldns/coredns/plugin.go +++ b/internal/internaldns/coredns/plugin.go @@ -33,8 +33,8 @@ type snapshot struct { } func (s *snapshot) close() { - for _, proxy := range s.proxies { - proxy.Stop() + for _, upstreamProxy := range s.proxies { + upstreamProxy.Stop() } } @@ -50,13 +50,13 @@ func compile(c Config) (*snapshot, error) { } s := &snapshot{contexts: map[string]*contextState{}} public := s.forward(c.PublicUpstreams) - for _, context := range c.Contexts { - state := &contextState{pending: context.Pending, pendingZones: map[string]bool{}, zones: &file.File{Zones: file.Zones{Z: map[string]*file.Zone{}}}} - for _, zone := range context.Zones { + for _, definition := range c.Contexts { + state := &contextState{pending: definition.Pending, pendingZones: map[string]bool{}, zones: &file.File{Zones: file.Zones{Z: map[string]*file.Zone{}}}} + for _, zone := range definition.Zones { apex := model.AbsoluteName(zone.Apex) state.pendingZones[apex] = zone.Pending state.zones.Names = append(state.zones.Names, apex) - if zone.Pending || len(context.Upstreams) > 0 { + if zone.Pending || len(definition.Upstreams) > 0 { state.zones.Z[apex] = nil continue } @@ -67,13 +67,13 @@ func compile(c Config) (*snapshot, error) { } state.zones.Z[apex] = parsed } - state.next = s.forward(context.Upstreams) + state.next = s.forward(definition.Upstreams) if public != nil && state.next == nil { cached := cache.New() cached.Next = public state.public = cached } - s.contexts[netip.MustParseAddr(context.Destination).Unmap().String()] = state + s.contexts[netip.MustParseAddr(definition.Destination).Unmap().String()] = state } return s, nil } @@ -121,11 +121,11 @@ func (p *ContextPlugin) ServeDNS(ctx context.Context, w dns.ResponseWriter, r *d if err != nil { return dns.RcodeRefused, nil } - context := state.contexts[host] - if context == nil { + selected := state.contexts[host] + if selected == nil { return dns.RcodeRefused, nil } - if context.pending { + if selected.pending { return dns.RcodeServerFailure, nil } if len(r.Question) != 1 || r.Opcode != dns.OpcodeQuery || r.Question[0].Qclass != dns.ClassINET { @@ -134,24 +134,24 @@ func (p *ContextPlugin) ServeDNS(ctx context.Context, w dns.ResponseWriter, r *d if r.Question[0].Qtype == dns.TypeAXFR || r.Question[0].Qtype == dns.TypeIXFR { return dns.RcodeRefused, nil } - w = contextWriter{ResponseWriter: w, destination: destination, request: r, recursionAvailable: context.next != nil || context.public != nil} - apex := plugin.Zones(context.zones.Names).Matches(strings.ToLower(r.Question[0].Name)) + w = contextWriter{ResponseWriter: w, destination: destination, request: r, recursionAvailable: selected.next != nil || selected.public != nil} + apex := plugin.Zones(selected.zones.Names).Matches(strings.ToLower(r.Question[0].Name)) if apex != "" { - if context.pendingZones[apex] { + if selected.pendingZones[apex] { return dns.RcodeServerFailure, nil } - if context.next != nil { - return context.next.ServeDNS(ctx, w, r) + if selected.next != nil { + return selected.next.ServeDNS(ctx, w, r) } - return context.zones.ServeDNS(ctx, w, r) + return selected.zones.ServeDNS(ctx, w, r) } - if context.next != nil { - return context.next.ServeDNS(ctx, w, r) + if selected.next != nil { + return selected.next.ServeDNS(ctx, w, r) } - if context.public == nil { + if selected.public == nil { return dns.RcodeServerFailure, errors.New("public recursive upstream is unavailable") } - return context.public.ServeDNS(ctx, w, r) + return selected.public.ServeDNS(ctx, w, r) } type contextWriter struct { diff --git a/internal/internaldns/serving/render_test.go b/internal/internaldns/serving/render_test.go index 10634921..1887a1a5 100644 --- a/internal/internaldns/serving/render_test.go +++ b/internal/internaldns/serving/render_test.go @@ -2,11 +2,12 @@ package serving import ( "encoding/json" - contextdns "go.miloapis.com/dns-operator/internal/internaldns/coredns" "strings" "testing" "time" + contextdns "go.miloapis.com/dns-operator/internal/internaldns/coredns" + "go.miloapis.com/dns-operator/internal/internaldns/model" )