From 12ddec1d5c072d2d3711a1ef3e2768db03fe0bb9 Mon Sep 17 00:00:00 2001 From: Anton Antonov Date: Thu, 1 Oct 2026 18:23:44 +0300 Subject: [PATCH 1/2] wip: dynupdate setup Signed-off-by: Anton Antonov --- .go-version | 2 +- README.md | 53 ++++- go.mod | 197 +++++++++--------- go.sum | 490 ++++++++++++++++++++------------------------ integration_test.go | 6 +- 5 files changed, 371 insertions(+), 377 deletions(-) diff --git a/.go-version b/.go-version index c7c3f33..5db08bf 100644 --- a/.go-version +++ b/.go-version @@ -1 +1 @@ -1.26.2 +1.27.0 diff --git a/README.md b/README.md index 7b016ea..8887c34 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,7 @@ This repository currently contains the plugin structure and a development CoreDNS executable. The HTTP API is not implemented. Enabling `dynapi` returns an explicit startup error. -[CONTRIBUTING.md](CONTRIBUTING.md) explains local builds and checks. +See [CONTRIBUTING.md](CONTRIBUTING.md) for contribution guidelines. Contributions follow the [CoreDNS code of conduct](CODE_OF_CONDUCT.md) and [Apache-2.0 license](LICENSE). Report security concerns through the @@ -26,8 +26,8 @@ Contributions follow the [CoreDNS code of conduct](CODE_OF_CONDUCT.md) and For development, build the CoreDNS executable from this repository: ```sh -make -./coredns -plugins +make # Build CoreDNS with dynapi, dynupdate, and tsig. +./coredns -plugins # Check that all three plugins are included. ``` To include the plugin in another CoreDNS build, add this entry to `plugin.cfg` @@ -49,9 +49,50 @@ Replace `REVISION` with a commit or release tag. There is no dynapi release yet. `plugin.cfg.yaml` records the intended placement for external build tooling. The root executable sets the same placement in Go. -The current CoreDNS v1.14.7 dependency validates packaging only. It does not -include `dynupdate`. Runtime implementation requires a pinned CoreDNS revision -that provides the selected backend interface or the DNS UPDATE bridge. +The development executable includes `dynupdate` and `tsig` from a pinned +CoreDNS revision. The HTTP adapter is not implemented yet. + +### Configure the DNS backend + +`dynupdate` serves the records and persists changes. `tsig` authenticates DNS +UPDATE requests before dynupdate applies its permission rules. TSIG authenticates +DNS clients. The future HTTP API will have its own authentication. + +Create `example.org.zone` with the initial zone records: + +```dns +$ORIGIN example.org. ; Resolve relative names within this zone. +@ 60 IN SOA ns.example.org. hostmaster.example.org. 1 3600 600 86400 60 ; Define the zone and its initial serial. +@ 60 IN NS ns.example.org. ; Declare the authoritative nameserver. +ns 60 IN A 127.0.0.1 ; Point the nameserver at this local example. +``` + +Create a `Corefile`: + +```corefile +example.org:1053 { # Serve the example zone on an unprivileged port. + bind 127.0.0.1 # Keep this development server on loopback. + tsig { # Authenticate signed DNS requests. + secret update-key.example.org. {$DYNAPI_TSIG_SECRET} # Read the shared key from the environment. + require_opcode UPDATE # Reject unsigned DNS updates. + } # End TSIG configuration. + dynupdate { # Serve the writable authoritative zone. + file example.org.zone # Seed a new database with the initial zone. + database example.org.db # Preserve committed changes across restarts. + allow update-key.example.org. host.example.org. A AAAA # Restrict this key to one host's addresses. + } # End writable-zone configuration. +} # End the server block. +``` + +Start the DNS backend: + +```sh +export DYNAPI_TSIG_SECRET="$(openssl rand -base64 32)" # Generate a private shared key for this example. +./coredns -conf Corefile # Start the configured DNS server. +``` + +Keep the key if DNS update clients must continue using it after a restart. +The `dynapi` directive is omitted because the HTTP API is not implemented yet. ## Syntax diff --git a/go.mod b/go.mod index 2f7ee13..42ce9cb 100644 --- a/go.mod +++ b/go.mod @@ -1,16 +1,16 @@ module github.com/coredns/dynapi -go 1.25.0 +go 1.26.0 require ( github.com/coredns/caddy v1.1.4 - github.com/coredns/coredns v1.14.7 + github.com/coredns/coredns v1.14.8-0.20260930135946-f44a91377a0b ) require ( - cloud.google.com/go/auth v0.22.0 // indirect + cloud.google.com/go/auth v0.23.3 // indirect cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect - cloud.google.com/go/compute/metadata v0.9.0 // indirect + cloud.google.com/go/compute/metadata v0.9.1 // indirect github.com/Azure/azure-sdk-for-go v68.0.0+incompatible // indirect github.com/Azure/go-autorest v14.2.0+incompatible // indirect github.com/Azure/go-autorest/autorest v0.11.30 // indirect @@ -21,45 +21,40 @@ require ( github.com/Azure/go-autorest/autorest/to v0.2.0 // indirect github.com/Azure/go-autorest/logger v0.2.1 // indirect github.com/Azure/go-autorest/tracing v0.6.0 // indirect - github.com/DataDog/datadog-agent/comp/core/tagger/origindetection v0.77.0 // indirect - github.com/DataDog/datadog-agent/pkg/obfuscate v0.77.0 // indirect - github.com/DataDog/datadog-agent/pkg/opentelemetry-mapping-go/otlp/attributes v0.77.0 // indirect - github.com/DataDog/datadog-agent/pkg/proto v0.77.0 // indirect - github.com/DataDog/datadog-agent/pkg/remoteconfig/state v0.77.0 // indirect - github.com/DataDog/datadog-agent/pkg/template v0.77.0 // indirect - github.com/DataDog/datadog-agent/pkg/trace v0.77.0 // indirect - github.com/DataDog/datadog-agent/pkg/trace/log v0.77.0 // indirect - github.com/DataDog/datadog-agent/pkg/trace/otel v0.77.0 // indirect - github.com/DataDog/datadog-agent/pkg/trace/stats v0.77.0 // indirect - github.com/DataDog/datadog-agent/pkg/trace/traceutil v0.77.0 // indirect - github.com/DataDog/datadog-agent/pkg/util/log v0.77.0 // indirect - github.com/DataDog/datadog-agent/pkg/util/scrubber v0.77.0 // indirect - github.com/DataDog/datadog-agent/pkg/version v0.77.0 // indirect - github.com/DataDog/datadog-go/v5 v5.8.3 // indirect - github.com/DataDog/dd-trace-go/v2 v2.8.2 // indirect - github.com/DataDog/go-libddwaf/v4 v4.9.0 // indirect + github.com/DataDog/datadog-agent/comp/core/tagger/origindetection v0.82.0 // indirect + github.com/DataDog/datadog-agent/pkg/obfuscate v0.82.0 // indirect + github.com/DataDog/datadog-agent/pkg/opentelemetry-mapping-go/otlp/attributes v0.82.0 // indirect + github.com/DataDog/datadog-agent/pkg/proto v0.82.0 // indirect + github.com/DataDog/datadog-agent/pkg/remoteconfig/state v0.82.0 // indirect + github.com/DataDog/datadog-agent/pkg/trace v0.82.0 // indirect + github.com/DataDog/datadog-agent/pkg/trace/log v0.82.0 // indirect + github.com/DataDog/datadog-agent/pkg/trace/stats v0.82.0 // indirect + github.com/DataDog/datadog-agent/pkg/trace/traceutil v0.82.0 // indirect + github.com/DataDog/datadog-go/v5 v5.9.0 // indirect + github.com/DataDog/dd-trace-go/v2 v2.10.1 // indirect + github.com/DataDog/go-libddwaf/v5 v5.0.1 // indirect github.com/DataDog/go-runtime-metrics-internal v0.0.4-0.20260217080614-b0f4edc38a6d // indirect - github.com/DataDog/go-sqllexer v0.1.13 // indirect + github.com/DataDog/go-sqllexer v0.2.3 // indirect github.com/DataDog/go-tuf v1.1.1-0.5.2 // indirect github.com/DataDog/sketches-go v1.4.8 // indirect github.com/Microsoft/go-winio v0.6.2 // indirect github.com/apparentlymart/go-cidr v1.1.1 // indirect - github.com/aws/aws-sdk-go-v2 v1.43.4 // indirect - github.com/aws/aws-sdk-go-v2/config v1.32.35 // indirect - github.com/aws/aws-sdk-go-v2/credentials v1.19.34 // indirect - github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.35 // indirect - github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.35 // indirect - github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.35 // indirect - github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.36 // indirect - github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.15 // indirect - github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.35 // indirect - github.com/aws/aws-sdk-go-v2/service/route53 v1.65.6 // indirect - github.com/aws/aws-sdk-go-v2/service/secretsmanager v1.44.4 // indirect - github.com/aws/aws-sdk-go-v2/service/signin v1.5.4 // indirect - github.com/aws/aws-sdk-go-v2/service/sso v1.33.4 // indirect - github.com/aws/aws-sdk-go-v2/service/ssooidc v1.38.4 // indirect - github.com/aws/aws-sdk-go-v2/service/sts v1.45.4 // indirect - github.com/aws/smithy-go v1.27.6 // indirect + github.com/aws/aws-sdk-go-v2 v1.47.0 // indirect + github.com/aws/aws-sdk-go-v2/config v1.33.5 // indirect + github.com/aws/aws-sdk-go-v2/credentials v1.20.5 // indirect + github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.20.0 // indirect + github.com/aws/aws-sdk-go-v2/internal/configsources v1.5.3 // indirect + github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.8.3 // indirect + github.com/aws/aws-sdk-go-v2/internal/v4a v1.5.3 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.19 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.14.3 // indirect + github.com/aws/aws-sdk-go-v2/service/route53 v1.70.0 // indirect + github.com/aws/aws-sdk-go-v2/service/secretsmanager v1.50.0 // indirect + github.com/aws/aws-sdk-go-v2/service/signin v1.10.0 // indirect + github.com/aws/aws-sdk-go-v2/service/sso v1.38.0 // indirect + github.com/aws/aws-sdk-go-v2/service/ssooidc v1.43.0 // indirect + github.com/aws/aws-sdk-go-v2/service/sts v1.51.0 // indirect + github.com/aws/smithy-go v1.28.1 // indirect github.com/beorn7/perks v1.0.1 // indirect github.com/caddyserver/certmagic v0.25.4 // indirect github.com/caddyserver/zerossl v0.1.5 // indirect @@ -73,53 +68,60 @@ require ( github.com/dnstap/golang-dnstap v0.4.0 // indirect github.com/dustin/go-humanize v1.0.1 // indirect github.com/ebitengine/purego v0.10.0 // indirect - github.com/emicklei/go-restful/v3 v3.12.2 // indirect + github.com/emicklei/go-restful/v3 v3.13.0 // indirect github.com/expr-lang/expr v1.17.8 // indirect github.com/farsightsec/golang-framestream v0.3.0 // indirect - github.com/felixge/httpsnoop v1.0.4 // indirect + github.com/felixge/httpsnoop v1.1.0 // indirect github.com/flynn/go-shlex v0.0.0-20150515145356-3f9db97f8568 // indirect - github.com/fxamacker/cbor/v2 v2.9.0 // indirect + github.com/fxamacker/cbor/v2 v2.9.1 // indirect github.com/go-logr/logr v1.4.4 // indirect github.com/go-logr/stdr v1.2.2 // indirect github.com/go-ole/go-ole v1.3.0 // indirect - github.com/go-openapi/jsonpointer v0.21.0 // indirect - github.com/go-openapi/jsonreference v0.20.2 // indirect - github.com/go-openapi/swag v0.23.0 // indirect - github.com/gogo/protobuf v1.3.2 // indirect + github.com/go-openapi/jsonpointer v1.0.0 // indirect + github.com/go-openapi/jsonreference v1.0.0 // indirect + github.com/go-openapi/swag v0.27.1 // indirect + github.com/go-openapi/swag/cmdutils v0.27.1 // indirect + github.com/go-openapi/swag/conv v0.27.1 // indirect + github.com/go-openapi/swag/fileutils v0.27.1 // indirect + github.com/go-openapi/swag/jsonutils v0.27.1 // indirect + github.com/go-openapi/swag/loading v0.27.1 // indirect + github.com/go-openapi/swag/mangling v0.27.1 // indirect + github.com/go-openapi/swag/netutils v0.27.1 // indirect + github.com/go-openapi/swag/pools v0.27.1 // indirect + github.com/go-openapi/swag/stringutils v0.27.1 // indirect + github.com/go-openapi/swag/typeutils v0.27.1 // indirect + github.com/go-openapi/swag/yamlutils v0.27.1 // indirect github.com/golang-jwt/jwt/v4 v4.5.2 // indirect github.com/golang-jwt/jwt/v5 v5.3.1 // indirect github.com/golang/protobuf v1.5.4 // indirect github.com/google/gnostic-models v0.7.0 // indirect - github.com/google/go-cmp v0.7.0 // indirect - github.com/google/s2a-go v0.1.9 // indirect + github.com/google/s2a-go v0.1.10 // indirect github.com/google/uuid v1.6.0 // indirect - github.com/googleapis/enterprise-certificate-proxy v0.3.19 // indirect - github.com/googleapis/gax-go/v2 v2.23.0 // indirect + github.com/googleapis/enterprise-certificate-proxy v0.3.22 // indirect + github.com/googleapis/gax-go/v2 v2.24.1 // indirect github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 // indirect - github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.4 // indirect + github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0 // indirect github.com/grpc-ecosystem/grpc-opentracing v0.0.0-20180507213350-8e809c8a8645 // indirect github.com/hashicorp/cronexpr v1.1.3 // indirect github.com/hashicorp/errwrap v1.1.0 // indirect github.com/hashicorp/go-cleanhttp v0.5.2 // indirect github.com/hashicorp/go-multierror v1.1.1 // indirect github.com/hashicorp/go-rootcerts v1.0.2 // indirect - github.com/hashicorp/go-version v1.8.0 // indirect + github.com/hashicorp/go-version v1.9.0 // indirect github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect github.com/hashicorp/nomad/api v0.0.0-20250909143645-a3b86c697f38 // indirect github.com/infobloxopen/go-trees v0.0.0-20200715205103-96a057b8dfb9 // indirect - github.com/josharian/intern v1.0.0 // indirect github.com/jpillora/backoff v1.0.0 // indirect github.com/json-iterator/go v1.1.12 // indirect github.com/klauspost/compress v1.19.1 // indirect github.com/klauspost/cpuid/v2 v2.3.0 // indirect github.com/libdns/libdns v1.1.1 // indirect github.com/linkdata/deadlock v0.5.5 // indirect - github.com/lufia/plan9stats v0.0.0-20260216142805-b3301c5f2a88 // indirect - github.com/mailru/easyjson v0.7.7 // indirect + github.com/lufia/plan9stats v0.0.0-20260330125221-c963978e514e // indirect github.com/mdlayher/socket v0.6.0 // indirect github.com/mdlayher/vsock v1.3.0 // indirect github.com/mholt/acmez/v3 v3.1.6 // indirect - github.com/miekg/dns v1.1.72 // indirect + github.com/miekg/dns v1.1.73 // indirect github.com/minio/simdjson-go v0.4.5 // indirect github.com/mitchellh/go-homedir v1.1.0 // indirect github.com/mitchellh/mapstructure v1.5.1-0.20231216201459-8508981c8b6c // indirect @@ -131,8 +133,8 @@ require ( github.com/opentracing/opentracing-go v1.2.0 // indirect github.com/openzipkin-contrib/zipkin-go-opentracing v0.5.0 // indirect github.com/openzipkin/zipkin-go v0.4.3 // indirect - github.com/oschwald/geoip2-golang/v2 v2.2.0 // indirect - github.com/oschwald/maxminddb-golang/v2 v2.3.0 // indirect + github.com/oschwald/geoip2-golang/v2 v2.4.0 // indirect + github.com/oschwald/maxminddb-golang/v2 v2.6.0 // indirect github.com/outcaste-io/ristretto v0.2.3 // indirect github.com/petermattis/goid v0.0.0-20260226131333-17d1149c6ac6 // indirect github.com/philhofer/fwd v1.2.0 // indirect @@ -142,73 +144,72 @@ require ( github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect github.com/prometheus/client_golang v1.24.1 // indirect - github.com/prometheus/client_model v0.6.2 // indirect - github.com/prometheus/common v0.70.1 // indirect - github.com/prometheus/exporter-toolkit v0.17.1 // indirect + github.com/prometheus/client_model v0.6.3 // indirect + github.com/prometheus/common v0.71.0 // indirect + github.com/prometheus/exporter-toolkit v0.19.0 // indirect github.com/prometheus/procfs v0.21.1 // indirect github.com/puzpuzpuz/xsync/v3 v3.5.1 // indirect github.com/quic-go/qpack v0.6.0 // indirect - github.com/quic-go/quic-go v0.61.0 // indirect - github.com/secure-systems-lab/go-securesystemslib v0.10.0 // indirect - github.com/shirou/gopsutil/v4 v4.26.2 // indirect + github.com/quic-go/quic-go v0.63.0 // indirect + github.com/secure-systems-lab/go-securesystemslib v0.11.0 // indirect + github.com/shirou/gopsutil/v4 v4.26.6 // indirect github.com/spf13/pflag v1.0.10 // indirect - github.com/tinylib/msgp v1.6.3 // indirect + github.com/tinylib/msgp v1.6.4 // indirect github.com/tklauser/go-sysconf v0.3.16 // indirect github.com/tklauser/numcpus v0.11.0 // indirect github.com/trailofbits/go-mutexasserts v0.0.0-20250514102930-c1f3d2e37561 // indirect github.com/x448/float16 v0.8.4 // indirect github.com/yusufpapurcu/wmi v1.2.4 // indirect github.com/zeebo/blake3 v0.2.4 // indirect - go.etcd.io/etcd/api/v3 v3.6.13 // indirect - go.etcd.io/etcd/client/pkg/v3 v3.6.13 // indirect - go.etcd.io/etcd/client/v3 v3.6.13 // indirect + go.etcd.io/bbolt v1.5.0 // indirect + go.etcd.io/etcd/api/v3 v3.7.2 // indirect + go.etcd.io/etcd/client/pkg/v3 v3.7.2 // indirect + go.etcd.io/etcd/client/v3 v3.7.2 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect - go.opentelemetry.io/collector/component v1.51.1-0.20260205185216-81bc641f26c0 // indirect - go.opentelemetry.io/collector/featuregate v1.51.1-0.20260205185216-81bc641f26c0 // indirect - go.opentelemetry.io/collector/pdata v1.51.1-0.20260205185216-81bc641f26c0 // indirect - go.opentelemetry.io/collector/pdata/pprofile v0.145.1-0.20260205185216-81bc641f26c0 // indirect - go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.67.0 // indirect + go.opentelemetry.io/collector/component v1.61.0 // indirect + go.opentelemetry.io/collector/featuregate v1.61.0 // indirect + go.opentelemetry.io/collector/pdata v1.61.0 // indirect + go.opentelemetry.io/collector/pdata/pprofile v0.155.0 // indirect + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0 // indirect go.opentelemetry.io/otel v1.44.0 // indirect go.opentelemetry.io/otel/metric v1.44.0 // indirect go.opentelemetry.io/otel/trace v1.44.0 // indirect - go.opentelemetry.io/proto/otlp v1.9.0 // indirect + go.opentelemetry.io/proto/otlp v1.10.0 // indirect go.uber.org/atomic v1.11.0 // indirect go.uber.org/automaxprocs v1.6.0 // indirect + go.uber.org/mock v0.6.0 // indirect go.uber.org/multierr v1.11.0 // indirect go.uber.org/zap v1.28.0 // indirect go.uber.org/zap/exp v0.3.0 // indirect go.yaml.in/yaml/v2 v2.4.4 // indirect - go.yaml.in/yaml/v3 v3.0.4 // indirect - golang.org/x/crypto v0.54.0 // indirect - golang.org/x/exp v0.0.0-20260209203927-2842357ff358 // indirect - golang.org/x/mod v0.37.0 // indirect - golang.org/x/net v0.57.0 // indirect - golang.org/x/oauth2 v0.36.0 // indirect - golang.org/x/sync v0.22.0 // indirect - golang.org/x/sys v0.47.0 // indirect - golang.org/x/term v0.45.0 // indirect - golang.org/x/text v0.40.0 // indirect - golang.org/x/time v0.15.0 // indirect - golang.org/x/tools v0.47.0 // indirect + go.yaml.in/yaml/v3 v3.0.5 // indirect + golang.org/x/crypto v0.57.0 // indirect + golang.org/x/exp v0.0.0-20260529124908-c761662dc8c9 // indirect + golang.org/x/mod v0.41.0 // indirect + golang.org/x/net v0.59.0 // indirect + golang.org/x/oauth2 v0.37.0 // indirect + golang.org/x/sync v0.23.0 // indirect + golang.org/x/sys v0.48.0 // indirect + golang.org/x/term v0.46.0 // indirect + golang.org/x/text v0.42.0 // indirect + golang.org/x/time v0.16.0 // indirect golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da // indirect - google.golang.org/api v0.292.0 // indirect - google.golang.org/genproto/googleapis/api v0.0.0-20260630182238-925bb5da69e7 // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20260803160001-6ac0973c030d // indirect - google.golang.org/grpc v1.83.0 // indirect - google.golang.org/protobuf v1.36.11 // indirect + google.golang.org/api v0.299.0 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20260715232425-e75dac1f907d // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260921155816-b14227669459 // indirect + google.golang.org/grpc v1.84.0 // indirect + google.golang.org/protobuf v1.36.12 // indirect gopkg.in/evanphx/json-patch.v4 v4.13.0 // indirect gopkg.in/inf.v0 v0.9.1 // indirect - gopkg.in/ini.v1 v1.67.1 // indirect - gopkg.in/yaml.v3 v3.0.1 // indirect - k8s.io/api v0.35.4 // indirect - k8s.io/apimachinery v0.35.4 // indirect - k8s.io/client-go v0.35.4 // indirect + k8s.io/api v0.37.0 // indirect + k8s.io/apimachinery v0.37.0 // indirect + k8s.io/client-go v0.37.0 // indirect k8s.io/klog/v2 v2.140.0 // indirect - k8s.io/kube-openapi v0.0.0-20250910181357-589584f1c912 // indirect - k8s.io/utils v0.0.0-20251002143259-bc988d571ff4 // indirect + k8s.io/kube-openapi v0.0.0-20260721132016-d427ff9ee9ad // indirect + k8s.io/utils v0.0.0-20260626114624-be93311217bd // indirect sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 // indirect sigs.k8s.io/mcs-api v0.5.2 // indirect sigs.k8s.io/randfill v1.0.0 // indirect - sigs.k8s.io/structured-merge-diff/v6 v6.3.0 // indirect + sigs.k8s.io/structured-merge-diff/v6 v6.4.2 // indirect sigs.k8s.io/yaml v1.6.0 // indirect ) diff --git a/go.sum b/go.sum index 29a38a2..45730e8 100644 --- a/go.sum +++ b/go.sum @@ -1,9 +1,9 @@ -cloud.google.com/go/auth v0.22.0 h1:Xp9wAKkLoeaYb5pYZZoQGz4E9sdPxIbzS3gywZE3ciQ= -cloud.google.com/go/auth v0.22.0/go.mod h1:M9o2Oz+YI2jAfxewJgb1vyI3vceHF+eohmxyzmrl+9s= +cloud.google.com/go/auth v0.23.3 h1:UMK+oBtuNGMCR/6i6mmySUItqjOazpJrbmZyhGbGBWo= +cloud.google.com/go/auth v0.23.3/go.mod h1:fClbry28fo7XkxhSeT6AQtAVAp6Jy0fW9N99PoPNPFM= cloud.google.com/go/auth/oauth2adapt v0.2.8 h1:keo8NaayQZ6wimpNSmW5OPc283g65QNIiLpZnkHRbnc= cloud.google.com/go/auth/oauth2adapt v0.2.8/go.mod h1:XQ9y31RkqZCcwJWNSx2Xvric3RrU88hAYYbjDWYDL+c= -cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdBtwLoEkH9Zs= -cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10= +cloud.google.com/go/compute/metadata v0.9.1 h1:CTE1OWBQ0vnF5uHwdFAQJvMQ0Fi/KRcqqKTo9V0F8Ik= +cloud.google.com/go/compute/metadata v0.9.1/go.mod h1:NtnlvB6X3t4R6xSWyVX/ZWk493PCxGQlhI/iqxh4M8I= code.pfad.fr/check v1.1.0 h1:GWvjdzhSEgHvEHe2uJujDcpmZoySKuHQNrZMfzfO0bE= code.pfad.fr/check v1.1.0/go.mod h1:NiUH13DtYsb7xp5wll0U4SXx7KhXQVCtRgdC96IPfoM= github.com/Azure/azure-sdk-for-go v68.0.0+incompatible h1:fcYLmCpyNYRnvJbPerq7U0hS+6+I79yEDJBqVNcqUzU= @@ -31,87 +31,75 @@ github.com/Azure/go-autorest/logger v0.2.1 h1:IG7i4p/mDa2Ce4TRyAO8IHnVhAVF3RFU+Z github.com/Azure/go-autorest/logger v0.2.1/go.mod h1:T9E3cAhj2VqvPOtCYAvby9aBXkZmbF5NWuPV8+WeEW8= github.com/Azure/go-autorest/tracing v0.6.0 h1:TYi4+3m5t6K48TGI9AUdb+IzbnSxvnvUMfuitfgcfuo= github.com/Azure/go-autorest/tracing v0.6.0/go.mod h1:+vhtPC754Xsa23ID7GlGsrdKBpUA79WCAKPPZVC2DeU= -github.com/DataDog/datadog-agent/comp/core/tagger/origindetection v0.77.0 h1:Lu/HEo5svx/UwE7XWh8vOrEHCrVRsein9X1N0jGK5bo= -github.com/DataDog/datadog-agent/comp/core/tagger/origindetection v0.77.0/go.mod h1:+Ty3r23MjcmMSkr8JbFeqA3utgtc1wxsZ0KaQ9CzoWA= -github.com/DataDog/datadog-agent/pkg/obfuscate v0.77.0 h1:mrHaNnDAIOFAVYhCqDpkenUtbadswHN68ZlG5krv40o= -github.com/DataDog/datadog-agent/pkg/obfuscate v0.77.0/go.mod h1:E6RGAcEOr/d8wsV5/khYHvaHkijWex6dfNEvsBIgR7A= -github.com/DataDog/datadog-agent/pkg/opentelemetry-mapping-go/otlp/attributes v0.77.0 h1:g1d9d1CfG54WjXgvkysTFL9yjXexWeDbYssQaf1PG6c= -github.com/DataDog/datadog-agent/pkg/opentelemetry-mapping-go/otlp/attributes v0.77.0/go.mod h1:N/AB9VGpVwHxCcyX+7GLNYMfnTZvn65vp5cHb5Ed0ow= -github.com/DataDog/datadog-agent/pkg/proto v0.77.0 h1:21nDAKD+LdxZz2pMsLAQTZ+w9Z4JqecjKpt8xY1b7Ig= -github.com/DataDog/datadog-agent/pkg/proto v0.77.0/go.mod h1:g2QYJe1CheZdssiDQpSYWra9hORkh+S3WO8aOqDNLkg= -github.com/DataDog/datadog-agent/pkg/remoteconfig/state v0.77.0 h1:SxFGFN/Dd/uREaUTxuVTi0R7fRABzvUtu32YOXcjf6c= -github.com/DataDog/datadog-agent/pkg/remoteconfig/state v0.77.0/go.mod h1:TpW5ZwsQTrlRBPjtZH5/OFwpmOqxV/v2i9BiF4Xfcac= -github.com/DataDog/datadog-agent/pkg/template v0.77.0 h1:sUbTCoQyU9kXMc6/aDd4YTP2pe9PlNkgySM11ydMEbE= -github.com/DataDog/datadog-agent/pkg/template v0.77.0/go.mod h1:ZUjICHSlN0of0cmWrYk9Pof0DV0eqHSpTUK1NTnN26Y= -github.com/DataDog/datadog-agent/pkg/trace v0.77.0 h1:B7M6IW0sd60XnLfeP6HEneeR4lRnaNtI9bboU4R1cV0= -github.com/DataDog/datadog-agent/pkg/trace v0.77.0/go.mod h1:+7zMNPjHTDidiphECirrpq5jLK09S9kmLtGRv7di29Q= -github.com/DataDog/datadog-agent/pkg/trace/log v0.77.0 h1:2VY1byEA2XnYVg7+eLQSTgX2f76ZCf/AwpCBJDXCiDc= -github.com/DataDog/datadog-agent/pkg/trace/log v0.77.0/go.mod h1:thnxBOGfMU9uRlFUClXud6J7DdI8qWtElELSds5jqts= -github.com/DataDog/datadog-agent/pkg/trace/otel v0.77.0 h1:9zSto72E+wSETaKs47Yiq5D9du9H71IqWgbDJzGpzSs= -github.com/DataDog/datadog-agent/pkg/trace/otel v0.77.0/go.mod h1:IxBidgqUt8aBrKYq4VKynBHWYZYNoflk+0+m7w+lfbI= -github.com/DataDog/datadog-agent/pkg/trace/stats v0.77.0 h1:InA6JO5R8TFAUcKRxsdmIF1hJpVZqVO5Aqux+Iw/2V0= -github.com/DataDog/datadog-agent/pkg/trace/stats v0.77.0/go.mod h1:iZVotmInV8qaU6Q5h+tsKk4CBYupDcOgTzonzLlMi0k= -github.com/DataDog/datadog-agent/pkg/trace/traceutil v0.77.0 h1:b/2+uA/cG2xEV0LzgwnxloMFWe5sdJa3xtaQhSGN0+s= -github.com/DataDog/datadog-agent/pkg/trace/traceutil v0.77.0/go.mod h1:csT+8o3GOUjhKPs/GqWMb5Zh4iQpuZ/HZQ4Z5ls8Sak= -github.com/DataDog/datadog-agent/pkg/util/log v0.77.0 h1:YFa+8kIg2qQZca9zvowtwCPdHDhGMcTIF+PMIQsLSRs= -github.com/DataDog/datadog-agent/pkg/util/log v0.77.0/go.mod h1:DFK2U5RcB8/BcObgmVEZ4VxqXUi2t7y2svLTtJwQqeo= -github.com/DataDog/datadog-agent/pkg/util/scrubber v0.77.0 h1:dd0W9e39rv0R3DSgnaurVnQ43/jX/juqQPwLpGAJgFs= -github.com/DataDog/datadog-agent/pkg/util/scrubber v0.77.0/go.mod h1:nkhevws2pJvoXSGhjc8wuTbptNQ9ECRBjwVr4hSvoq0= -github.com/DataDog/datadog-agent/pkg/version v0.77.0 h1:fxpMWuoaRHS5vHzCNHftvJ6wdQrGhEmuozjjl8wZG5k= -github.com/DataDog/datadog-agent/pkg/version v0.77.0/go.mod h1:h9eJjfeTHlYYv+kzq6n3rQ07qXGirdCCacn1Ryu4TFQ= -github.com/DataDog/datadog-go/v5 v5.8.3 h1:s58CUJ9s8lezjhTNJO/SxkPBv2qZjS3ktpRSqGF5n0s= -github.com/DataDog/datadog-go/v5 v5.8.3/go.mod h1:K9kcYBlxkcPP8tvvjZZKs/m1edNAUFzBbdpTUKfCsuw= -github.com/DataDog/dd-trace-go/v2 v2.8.2 h1:ZqF2M7j5DPG7PxkJpLIjF4L62LU/QnI86oOSAZjQC/U= -github.com/DataDog/dd-trace-go/v2 v2.8.2/go.mod h1:o+fhXzd1mPT4Ji5YYcqIjORnNKWcS6m2eW4xqdJplRA= -github.com/DataDog/go-libddwaf/v4 v4.9.0 h1:a788e37iuH7sR9uIYHkulvTnp2FkXTiZ3yY/kuaHgZE= -github.com/DataDog/go-libddwaf/v4 v4.9.0/go.mod h1:/AZqP6zw3qGJK5mLrA0PkfK3UQDk1zCI2fUNCt4xftE= +github.com/DataDog/datadog-agent/comp/core/tagger/origindetection v0.82.0 h1:4cTEzpBezsfbHkn90BDPSYAWCahLqe/cpR5DSvRPrLU= +github.com/DataDog/datadog-agent/comp/core/tagger/origindetection v0.82.0/go.mod h1:6LC1ryDn2VNqF0iNapwcLLdsfoFUMnT4p+JPu6sEkHg= +github.com/DataDog/datadog-agent/pkg/obfuscate v0.82.0 h1:d0qTccmgcy/cCFlX7uz6m++2qtdHjG0cfj+Tj/w+TEo= +github.com/DataDog/datadog-agent/pkg/obfuscate v0.82.0/go.mod h1:pW+H9lCruFxEq4JrxTKvatD+7hIsEXNd8InZyBmVYco= +github.com/DataDog/datadog-agent/pkg/opentelemetry-mapping-go/otlp/attributes v0.82.0 h1:Ha7Paf9fvT7MaetK60K0JliVIPMHYtXsEoB8vhWSCwU= +github.com/DataDog/datadog-agent/pkg/opentelemetry-mapping-go/otlp/attributes v0.82.0/go.mod h1:r3lb6X8YsmcfsLV9uM6apiqpfRACnbbyConMjsN1Q4Y= +github.com/DataDog/datadog-agent/pkg/proto v0.82.0 h1:3IniGYr5au2P1/EFFl1WvqnE37NT5OJEe22alQ8o2Jw= +github.com/DataDog/datadog-agent/pkg/proto v0.82.0/go.mod h1:o59ZSwSu/xgqEo7w0+Fpp2TGZzkWeTsQij7bnEh55dE= +github.com/DataDog/datadog-agent/pkg/remoteconfig/state v0.82.0 h1:NdNElWE9+XmN8dAWSA6C2nlcW1BRALAGH1YBVERAKwA= +github.com/DataDog/datadog-agent/pkg/remoteconfig/state v0.82.0/go.mod h1:OMlz3Bu/jlUXBVNrDP1ZyYtxwa5KMCm1OGa40Z3M9hI= +github.com/DataDog/datadog-agent/pkg/trace v0.82.0 h1:WnRt36GQMiS3eUuzgQwZHE/ujn2+yboxMKeCu7yzRAA= +github.com/DataDog/datadog-agent/pkg/trace v0.82.0/go.mod h1:996SW3E0BGByfmHHMZ8oyiEbySp2LhpWwsfPXdYS31o= +github.com/DataDog/datadog-agent/pkg/trace/log v0.82.0 h1:pddABOKDqh7J5zPblVa9pgPLGewAbSXIOVSHIOzrfC4= +github.com/DataDog/datadog-agent/pkg/trace/log v0.82.0/go.mod h1:Vj76uL63Yu1mh+ZlcxInL6YiG7xP2i1NcrIGSP6mXzI= +github.com/DataDog/datadog-agent/pkg/trace/stats v0.82.0 h1:rh/E60HMZzzuSNkYhKKILDjnxWYyV/dmNmsM/IYdhxY= +github.com/DataDog/datadog-agent/pkg/trace/stats v0.82.0/go.mod h1:ewMjfw1fAMTSplgGzl4X7jr/K+0UtVQbp67UFbP8Gso= +github.com/DataDog/datadog-agent/pkg/trace/traceutil v0.82.0 h1:7qQAboJOj+eAj9caXwRgpuQiALYcL+spsU/gawTBevo= +github.com/DataDog/datadog-agent/pkg/trace/traceutil v0.82.0/go.mod h1:yZZL4wCytoVXUayJuNXznHduZ0INjSA8rw9E/98hhVM= +github.com/DataDog/datadog-go/v5 v5.9.0 h1:0rhs5wBov9Iz+xLXLk4maaReHvOANM1ijSm2IKWtKFs= +github.com/DataDog/datadog-go/v5 v5.9.0/go.mod h1:2SBt8zJu6r7sRQHZFMQ8oCukWTKj0ymwulmNgQzJ1JM= +github.com/DataDog/dd-trace-go/v2 v2.10.1 h1:wX/jJcs3Zh6ybwgdMwzRi5IlZ2NIJ6OgASXQA71Buig= +github.com/DataDog/dd-trace-go/v2 v2.10.1/go.mod h1:fHlAl/gA8UuEoPxIoh5xoWvpsH7WT0DBW79vgjgtAjU= +github.com/DataDog/go-libddwaf/v5 v5.0.1 h1:z63Ipq9BRVFjrd9ddF0OI1vL3iRLRKomAxq1oA4v7Z4= +github.com/DataDog/go-libddwaf/v5 v5.0.1/go.mod h1:lEBmHWNq/KHsd1RLOj8COojJ1WsZJ3rqpG4ZiFn48kI= github.com/DataDog/go-runtime-metrics-internal v0.0.4-0.20260217080614-b0f4edc38a6d h1:cH9Bm0tJ8FEQbA4FRi0iRm7Zr/5Lata/Or31c+Dth0E= github.com/DataDog/go-runtime-metrics-internal v0.0.4-0.20260217080614-b0f4edc38a6d/go.mod h1:yDuvU+Ak1TKwgd4K8DNcpJmUrrK8ONLkBMGNAppmBRk= -github.com/DataDog/go-sqllexer v0.1.13 h1:HhT2G21y7SDZYQx9i1b+3Sy/CHhESHet/YKMSm06XcE= -github.com/DataDog/go-sqllexer v0.1.13/go.mod h1:vOw7Ia7z+z6nl3zGZlLIZe0vQlPtCPR906WIPBJadxc= +github.com/DataDog/go-sqllexer v0.2.3 h1:VNUUVv4nCHbyHKKYwuj3HuRD2/hll8aYEkR5b4BW6nw= +github.com/DataDog/go-sqllexer v0.2.3/go.mod h1:3xTFXBU69vUikYpESggScvC0RKYA7ZIdVrIkLwUOWdE= github.com/DataDog/go-tuf v1.1.1-0.5.2 h1:YWvghV4ZvrQsPcUw8IOUMSDpqc3W5ruOIC+KJxPknv0= github.com/DataDog/go-tuf v1.1.1-0.5.2/go.mod h1:zBcq6f654iVqmkk8n2Cx81E1JnNTMOAx1UEO/wZR+P0= github.com/DataDog/sketches-go v1.4.8 h1:pFk9BNn+Rzv8IMIoPUttoOpOr3bJOqU3P6EP5wK+Lv8= github.com/DataDog/sketches-go v1.4.8/go.mod h1:a/wjRUqzqtGS8qRHRPDCs4EAQfmvPDZGDlMIF5mxXOE= -github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1Xbatp0= -github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= github.com/Microsoft/go-winio v0.5.0/go.mod h1:JPGBdM1cNvN/6ISo+n8V5iA4v8pBzdOpzfwIujj1a84= github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= 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/aws/aws-sdk-go-v2 v1.43.4 h1:b9FTvbRwy+JCsfp2Wp6wV/KbOx3Aj7nkoFb2cRX0IhE= -github.com/aws/aws-sdk-go-v2 v1.43.4/go.mod h1:70vwSy16txshwG+g55WkpgPKDIByzHI8ccBsOteo3bQ= -github.com/aws/aws-sdk-go-v2/config v1.32.35 h1:UEzXuET8E42lxBPijuACu/tEK7v5lFPlk0Q+GT5WD9E= -github.com/aws/aws-sdk-go-v2/config v1.32.35/go.mod h1:KaMtJpFa2JlL2BStjjHQVwQpzZEmw+ND/EgVrfFoo2g= -github.com/aws/aws-sdk-go-v2/credentials v1.19.34 h1:y6GkSmcv5myd1ngrYbGmiLlwQqB6TQhOuN/tbSSuWDY= -github.com/aws/aws-sdk-go-v2/credentials v1.19.34/go.mod h1:w3dTcnDVoQIewjo7JG45hduAToikiIFLC4FIO7fndvw= -github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.35 h1:+S7kbJoLDDQ5tE+lHrUBgMkzC8NLgsaioS2F3dVoFAE= -github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.35/go.mod h1:Ak7xXviIARfFdNUJ9Etb0bdVDt/KAvKjMGJVLWXDzik= -github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.35 h1:kzVuGlatQtYinwBJEEyLAbggepCoavosiaHHX9+fD+c= -github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.35/go.mod h1:0yLx0yEI+SfqeJMPvOtIEFoZbiQYXMGszBueiutQyaI= -github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.35 h1:WK6CjihTuLisCjSKKbildJ79sGZZgbBz3iNa7VsKIhU= -github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.35/go.mod h1:KYleN57luLoe97R7vTnx8PMcVrr9gAcRECtOjl91DNg= -github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.36 h1:jbGY4CXLzZElOXgGsexlC3Hi+3YM0rSmk4opFXKqg/k= -github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.36/go.mod h1:uBu/9aKsS/UQGc72RAt3y54kjgYQxmhut8ZD2dXCDNE= -github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.15 h1:JJLBQxwY+AFwuPAi5ivGc1ChnTdUt4cXMv7e76m2c/Y= -github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.15/go.mod h1:lQknBIe78MVL0cQOQDlag8KGflMbMEVFx9mB6O8ENvk= -github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.35 h1:BBEElKh4a+rKshvjrfpajTe9CbpZvrbb4Jkg2PB7RzA= -github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.35/go.mod h1:zaZk983w//8beSruBVec/mr4CmDwgZitW/qzGhAAX0g= -github.com/aws/aws-sdk-go-v2/service/route53 v1.65.6 h1:MDZUFQEVG3S6W+VmhQ9qlbprAuPZDfvvWZTg+HZU0o0= -github.com/aws/aws-sdk-go-v2/service/route53 v1.65.6/go.mod h1:mh85zjA/hf1PtItwFXA9Yb/2zgUCEYpkN9QmzrK4RNc= -github.com/aws/aws-sdk-go-v2/service/secretsmanager v1.44.4 h1:yCy8e5a6pNHJnqlPn/f9RZ2J0UMwlxA30MRiNedSwzo= -github.com/aws/aws-sdk-go-v2/service/secretsmanager v1.44.4/go.mod h1:6DFMltRxgqNNlO+UrKGSxl3fHSAqDjDkTPoGiCLrElI= -github.com/aws/aws-sdk-go-v2/service/signin v1.5.4 h1:cOJELVNrq5Q3Udry2GLuHUM7MhwpeaQRdYaoa6GI/yI= -github.com/aws/aws-sdk-go-v2/service/signin v1.5.4/go.mod h1:f4LxzKBtaTxD7xh3PiVg3CE1tchQemfmghaJr+NbK2c= -github.com/aws/aws-sdk-go-v2/service/sso v1.33.4 h1:AMW7a7S8iQaHjBYZdU3PCq4GKRPijTPRAc7e6XtEThY= -github.com/aws/aws-sdk-go-v2/service/sso v1.33.4/go.mod h1:QQNsFV1DVXoXcZt18FS8lI8rtUrlDyAuWZLQ5shunv4= -github.com/aws/aws-sdk-go-v2/service/ssooidc v1.38.4 h1:AsbZcJAQPRmHDJG8K1N0pof/1zPWjVT8TFlTWuGLSvo= -github.com/aws/aws-sdk-go-v2/service/ssooidc v1.38.4/go.mod h1:6imqztH0//t0mKbl6yWl7swSEl7F/w32oAmqB3vP1ag= -github.com/aws/aws-sdk-go-v2/service/sts v1.45.4 h1:w/AryDYMjSUANSQ2uoZxJovUsMTwWJNTv3IMex30Y+4= -github.com/aws/aws-sdk-go-v2/service/sts v1.45.4/go.mod h1:WeBiAa67azG7Su9Vf+ChGDBLiAozJCXzdjXiPBUwtbc= -github.com/aws/smithy-go v1.27.6 h1:0zjT8jgK3jbrTT7JJ3EE6JsMhX8JTrZ+f1sEndYDXrA= -github.com/aws/smithy-go v1.27.6/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc= +github.com/aws/aws-sdk-go-v2 v1.47.0 h1:0jsHallhJCeaU0Ko48c/3FK1ctOQ7NpzggxriJOQ8MQ= +github.com/aws/aws-sdk-go-v2 v1.47.0/go.mod h1:bttEH6JqnUL8LepvDVfdrds/fZ5bCIxzpe3abyUrhDU= +github.com/aws/aws-sdk-go-v2/config v1.33.5 h1:UA1dmokBFOLFoOyVBhO6HjM6edy0MIk5AZSkJVcksQw= +github.com/aws/aws-sdk-go-v2/config v1.33.5/go.mod h1:Dop8axzz0xx38GExIYWXdeyc8QQ7Cr+nPsxpD/LYy4U= +github.com/aws/aws-sdk-go-v2/credentials v1.20.5 h1:wklUVvHMc9xTQ3rcp49/ISpiMnhbCicJcA6n6S8m7J8= +github.com/aws/aws-sdk-go-v2/credentials v1.20.5/go.mod h1:fyEdrn6ccLFOkoK84j5bQyGTxp9zPt5l2XMhxf4DVZs= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.20.0 h1:AM4hHjww+PSFtt6E+UrBrPlZkWsePCLEt9AjkfQX+yM= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.20.0/go.mod h1:3x/yXezeQjpOvBb4jEMxrS8SXvpdvJ5abv6l5c1gWM8= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.5.3 h1:Hp/VgjP0BysR3OgLlR057Vz2LcbbVnoWeJ+3qWiS/fY= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.5.3/go.mod h1:nwGV5qw7F1IZPgxCvA/ph8N2TAuz+BkRG/bXn808qMA= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.8.3 h1:MUaM4f+kj1ZIBPZfUS8cxP1GKXXZtHJjAthy93AN7SM= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.8.3/go.mod h1:6YmVmEVRI5ZZzRjCSsb9SryKH0hAlMRdgA7kG9aDvBU= +github.com/aws/aws-sdk-go-v2/internal/v4a v1.5.3 h1:fuSCw4Z2qfRCztMPO3GXJNSiEp6Wee+WOLwrHHUMy9c= +github.com/aws/aws-sdk-go-v2/internal/v4a v1.5.3/go.mod h1:6SxcHheD1pPR5+kWm1wGvjlL/YqUsh267sAfEmN4K7A= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.19 h1:bAdDl/HkGCcGPoe25ToSHEw23VIxt6CT5fLcg111BKg= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.19/go.mod h1:KaUzbLxv4CeSxh6ZCl9B4m7CuFenS8kUEaDs+f/DQr4= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.14.3 h1:bON1rJf67TSTDCKg816AAIE4xSTtoo9tl0XRkO72R+I= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.14.3/go.mod h1:c5BBpjJcQXpfeq9iASyVKA3T6vX6B6LEXY4mL/gklDY= +github.com/aws/aws-sdk-go-v2/service/route53 v1.70.0 h1:VxLw9i321VscFgoYqfSkd2UdLcRVmp9tiv9xnk4VSIY= +github.com/aws/aws-sdk-go-v2/service/route53 v1.70.0/go.mod h1:ZFR4YYQvjghZDMjaAmpXRaO/qxfCns/kjsQtguzvQVU= +github.com/aws/aws-sdk-go-v2/service/secretsmanager v1.50.0 h1:xpgbxBPYQeVHrJni4vd3wq69elhr8cqrVSwd8dgPkaQ= +github.com/aws/aws-sdk-go-v2/service/secretsmanager v1.50.0/go.mod h1:HMOw7but3OQg86ARfV8Hvoc8h/kNiB3OQm1q6AwO27I= +github.com/aws/aws-sdk-go-v2/service/signin v1.10.0 h1:ZD5qFpWcaOKdTuhBi431pIDkCgrMkMlMT6jlpSPoIRI= +github.com/aws/aws-sdk-go-v2/service/signin v1.10.0/go.mod h1:8Nuuf+tR346PjJ3MvZPh9pekbLiLQFWJhzMXfwy7alA= +github.com/aws/aws-sdk-go-v2/service/sso v1.38.0 h1:JGeeBcMlhg1xtOXYpeCaTQBZObtXMPQCUqBcmr65NRA= +github.com/aws/aws-sdk-go-v2/service/sso v1.38.0/go.mod h1:XwteswG9EOMRFm73UT0t+MbTwyLxMrEXkU6e+v92Lzo= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.43.0 h1:obhahQXDEdVEv8y5bTKXR30LVaxYe1kyYM0L7l2Iq+k= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.43.0/go.mod h1:6twZZ/aXHNy1vXUO8koUbp++MYzMASkOgEBdkbJYmO0= +github.com/aws/aws-sdk-go-v2/service/sts v1.51.0 h1:Zpnqa6XtrNzXZnwbdCqHOXpXhMsa01ql/pcRQ1sb4hk= +github.com/aws/aws-sdk-go-v2/service/sts v1.51.0/go.mod h1:/8JRcdTt//hG0Q4BTmGbuOplT7ABe+5rdtqUHqXvYIM= +github.com/aws/smithy-go v1.28.1 h1:R/nXH00c8qcfCzQVELtRw+eLQWtzv+VAIEFJ1/xxXlQ= +github.com/aws/smithy-go v1.28.1/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= github.com/caddyserver/certmagic v0.25.4 h1:8eIXh0HC3MsGnNo8One+BCxMGTbe5zb/oz+2KsxBFQg= @@ -127,13 +115,12 @@ github.com/cihub/seelog v0.0.0-20170130134532-f561c5e57575 h1:kHaBemcxl8o/pQ5VM1 github.com/cihub/seelog v0.0.0-20170130134532-f561c5e57575/go.mod h1:9d6lWj8KzO/fd/NrVaLscBKmPigpZpn5YawRPw+e3Yo= github.com/coredns/caddy v1.1.4 h1:+Lls5xASB0QsA2jpCroCOwpPlb5GjIGlxdjXxdX0XVo= github.com/coredns/caddy v1.1.4/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/coredns/coredns v1.14.8-0.20260930135946-f44a91377a0b h1:GEapYuSZNSF4EAfU5KrcIRXdI3Xq38sVj/5Iuq0gE14= +github.com/coredns/coredns v1.14.8-0.20260930135946-f44a91377a0b/go.mod h1:ZHQrqjogDNE036LmQYJirMh0N1mUL0f4LC95FdcATLE= github.com/coreos/go-semver v0.3.1 h1:yi21YpKnrx1gt5R+la8n5WgS0kCrsPp33dmEyHReZr4= github.com/coreos/go-semver v0.3.1/go.mod h1:irMmmIw/7yzSRPWryHsK7EYSg09caPQL03VsM8rvUec= 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/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= @@ -152,18 +139,18 @@ github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkp github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= 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.12.2 h1:DhwDP0vY3k8ZzE0RunuJy8GhNpPL6zqLkDf9B/a0/xU= -github.com/emicklei/go-restful/v3 v3.12.2/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc= +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/expr-lang/expr v1.17.8 h1:W1loDTT+0PQf5YteHSTpju2qfUfNoBt4yw9+wOEU9VM= github.com/expr-lang/expr v1.17.8/go.mod h1:8/vRC7+7HBzESEqt5kKpYXxrxkr31SaO8r40VO/1IT4= 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/felixge/httpsnoop v1.1.0 h1:3YtUj32ZZkqZtt3sZZsClsymw/QDuVfpNhoA31zeORc= +github.com/felixge/httpsnoop v1.1.0/go.mod h1:Zqxgdd+1Rkcz8euOqdr7lqgCRJztwr5hp9vDSi5UZCE= 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/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM= -github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ= +github.com/fxamacker/cbor/v2 v2.9.1 h1:2rWm8B193Ll4VdjsJY28jxs70IdDsHRWgQYAI80+rMQ= +github.com/fxamacker/cbor/v2 v2.9.1/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ= github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA= github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= @@ -174,19 +161,40 @@ github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0= github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE= github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78= -github.com/go-openapi/jsonpointer v0.19.6/go.mod h1:osyAmYz/mB/C3I+WsTTSgw1ONzaLJoLCyoi6/zppojs= -github.com/go-openapi/jsonpointer v0.21.0 h1:YgdVicSA9vH5RiHs9TZW5oyafXZFc6+2Vc1rr/O9oNQ= -github.com/go-openapi/jsonpointer v0.21.0/go.mod h1:IUyH9l/+uyhIYQ/PXVA41Rexl+kOkAPDdXEYns6fzUY= -github.com/go-openapi/jsonreference v0.20.2 h1:3sVjiK66+uXK/6oQ8xgcRKcFgQ5KXa2KvnJRumpMGbE= -github.com/go-openapi/jsonreference v0.20.2/go.mod h1:Bl1zwGIM8/wsvqjsOQLJ/SH+En5Ap4rVB5KVcIDZG2k= -github.com/go-openapi/swag v0.22.3/go.mod h1:UzaqsxGiab7freDnrUUra0MwWfN/q7tE4j+VcZ0yl14= -github.com/go-openapi/swag v0.23.0 h1:vsEVJDUo2hPJ2tu0/Xc+4noaxyEffXNIs3cOULZ+GrE= -github.com/go-openapi/swag v0.23.0/go.mod h1:esZ8ITTYEsH1V2trKHjAN8Ai7xHb8RV+YSZ577vPjgQ= -github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572 h1:tfuBGBXKqDEevZMzYi5KSi8KkcZtzBcTgAUUtapy0OI= -github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI= -github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8= -github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= -github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= +github.com/go-openapi/jsonpointer v1.0.0 h1:kR9tHqY0CtZaOPVFm622dPVNhrvYpwr4uCxgL3h1H8s= +github.com/go-openapi/jsonpointer v1.0.0/go.mod h1:Z3rw7dWu1p9IgitXCFamSlA5lmDiklEB6vkaxcNZW5Y= +github.com/go-openapi/jsonreference v1.0.0 h1:jlmTr6torcd1YgDQvSfNmRtKzYDO4FGBkrAdlAVWnpY= +github.com/go-openapi/jsonreference v1.0.0/go.mod h1:jtwdyGbJk0Xhe5Y+rwtglQP6Sb1WZST4rT32LWB+sv0= +github.com/go-openapi/swag v0.27.1 h1:VotvOLWW8q/EAxB0YdsBBGC8XYyeL1YwBj2ungAGPNg= +github.com/go-openapi/swag v0.27.1/go.mod h1:GTkJPwHfhJp6MWr4/rCh64HVI3Ofu+tcsbfjfHmTxpE= +github.com/go-openapi/swag/cmdutils v0.27.1 h1:I7sYqaWVl5mq0NEmNQkAmFDyNin9ufvMX/p2zwtQaOE= +github.com/go-openapi/swag/cmdutils v0.27.1/go.mod h1:Sm1MVFMkF6guJJ+pQqHnQA3N0j9qALV3NxzDSv6bETM= +github.com/go-openapi/swag/conv v0.27.1 h1:8wi9ZG+olmY1wXphl93EWniPtbSPkXM/feH7FgjsvrU= +github.com/go-openapi/swag/conv v0.27.1/go.mod h1:QbqMivkpKhC3g1B1GGGOJ6ANewI3S62dbzYu3Duowqs= +github.com/go-openapi/swag/fileutils v0.27.1 h1:QQqBSoi5mW4XpU85nS0mLcA+zAE6vLzrb0QkmLKf9oM= +github.com/go-openapi/swag/fileutils v0.27.1/go.mod h1:VvJFZLTZS0AI854gEQz5tk7dBESdLjiNUMSZ/th2ry8= +github.com/go-openapi/swag/jsonutils v0.27.1 h1:SVgK3i4USzCU5mibOOS/l4ea2h9UQXy7J7RNLTjuXjU= +github.com/go-openapi/swag/jsonutils v0.27.1/go.mod h1:tdlEpZqdcQ17uj6J4YdK9vd8It5qWMwjWXOs0tjpRlk= +github.com/go-openapi/swag/jsonutils/fixtures_test v0.27.1 h1:mJu3COL9WEaZVp/Kf2PRMi7tPszPEJfSr/OO75ynCs8= +github.com/go-openapi/swag/jsonutils/fixtures_test v0.27.1/go.mod h1:mofwUWx70wvskwESqRJ//k/9kURmCgyJl5m5Ppoh5kY= +github.com/go-openapi/swag/loading v0.27.1 h1:/DxUgDXKbBX4bcn7r9uEXfJyzN5XpiJmZplzQTjrRCY= +github.com/go-openapi/swag/loading v0.27.1/go.mod h1:jvGh3iA2+zyUUycB5fgJWzeHnhrpvGnJJM0RVE9ZShE= +github.com/go-openapi/swag/mangling v0.27.1 h1:yC9D0HyUE8gbP+BfmGx9+AA89ikwZTMjESK3OnnoaqA= +github.com/go-openapi/swag/mangling v0.27.1/go.mod h1:jtBE2+V+3pILxOR7Vgce+Cwp6A2PgZbvVqfNntbVs0w= +github.com/go-openapi/swag/netutils v0.27.1 h1:mICMFoS82F5TZ4Zy3cqmcQk+BFeCp3Uyq3Np7GI0/qU= +github.com/go-openapi/swag/netutils v0.27.1/go.mod h1:J+WYyFMLtvtCGqa6jLv+YNUmIKI3ZRQRrvfNDMoQoEQ= +github.com/go-openapi/swag/pools v0.27.1 h1:9LeadcMyb2GJCbXX5hVQDbZ2Lq9TL4dCs/nx1j5DO0E= +github.com/go-openapi/swag/pools v0.27.1/go.mod h1:kVQefhSK5RWuRe7BXsL8htgBPAMpN7HDGpGEknqugeE= +github.com/go-openapi/swag/stringutils v0.27.1 h1:ZXePZ0r2p1qSjo8tD3Un4vFj8+FqlCkczxDrJIhYUp8= +github.com/go-openapi/swag/stringutils v0.27.1/go.mod h1:lzRN95CxXmA03XcDWHLOb6nOMcxCqR5rGY0lOgsfRoM= +github.com/go-openapi/swag/typeutils v0.27.1 h1:KSTdFlfnse4r6dP9IrEnwMldjE+zs71UeEB3//PtVXc= +github.com/go-openapi/swag/typeutils v0.27.1/go.mod h1:Srm0xFNRZ1Y+vCxJclo5qzx8aj+1pAKda/YfFPrG0dQ= +github.com/go-openapi/swag/yamlutils v0.27.1 h1:ftxv6xvXb1E3zohUc+okZ9nSqNb9StQX/FXnKZ98sQA= +github.com/go-openapi/swag/yamlutils v0.27.1/go.mod h1:bnxFIB1qewGRiZHypXGZ3fNgf13/0HfRgnS/iZBDrOo= +github.com/go-openapi/testify/enable/yaml/v2 v2.6.0 h1:gGHwAJ0R/5jU8BEGDbfRNR3hL68dAVi84WuOApp29B0= +github.com/go-openapi/testify/enable/yaml/v2 v2.6.0/go.mod h1:tY+St1SGq4NFl0QIqdTY4aEdbChAHxhyB77XQi9iJCo= +github.com/go-openapi/testify/v2 v2.6.0 h1:5PKH2HE7YJ/LuRPQGvSxBRlFXNQhSetBLlGAgUEu3ug= +github.com/go-openapi/testify/v2 v2.6.0/go.mod h1:SgsVHtfooshd0tublTtJ50FPKhujf47YRqauXXOUxfw= github.com/golang-jwt/jwt/v4 v4.0.0/go.mod h1:/xlHOz8bRuivTWchD4jCa+NbatV+wEUSzwAxVc6locg= github.com/golang-jwt/jwt/v4 v4.2.0/go.mod h1:/xlHOz8bRuivTWchD4jCa+NbatV+wEUSzwAxVc6locg= github.com/golang-jwt/jwt/v4 v4.5.0/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= @@ -195,8 +203,6 @@ github.com/golang-jwt/jwt/v4 v4.5.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= github.com/golang/mock v1.6.0/go.mod h1:p6yTPP+5HYm5mzsMV8JkE6ZKdX+/wYM6Hr+LicevLPs= -github.com/golang/mock v1.7.0-rc.1 h1:YojYx61/OLFsiv6Rw1Z96LpldJIy31o+UHmwAUMJ6/U= -github.com/golang/mock v1.7.0-rc.1/go.mod h1:s42URUywIqd+OcERslBJvOjepvNymP31m3q8d/GkuRs= 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= @@ -216,19 +222,19 @@ github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0= github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= 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/s2a-go v0.1.9 h1:LGD7gtMgezd8a/Xak7mEWL0PjoTQFvpRudN895yqKW0= -github.com/google/s2a-go v0.1.9/go.mod h1:YA0Ei2ZQL3acow2O62kdp9UlnvMmU7kA6Eutn0dXayM= +github.com/google/s2a-go v0.1.10 h1:EMp+aOuXN6l8cE/gjF5Bt+vyZxsUuyCWe9chDWR/+uU= +github.com/google/s2a-go v0.1.10/go.mod h1:pz4tyvwXvJLLbyrkh6FW1eS2zPUXMaTmyNhYtyP2tNw= 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/googleapis/enterprise-certificate-proxy v0.3.19 h1:mMOE7DN2+p76/EdIrmAy9B9bH+yC4563vmnJ34QR8i4= -github.com/googleapis/enterprise-certificate-proxy v0.3.19/go.mod h1:rSEsBUemEBZEexP2y6jPp16LUmUbjmSbcPMQizR0o4k= -github.com/googleapis/gax-go/v2 v2.23.0 h1:Tchl7qkvE7Ip3y+ztvNufYFvkfqTe7NfLTYGIdJRLuE= -github.com/googleapis/gax-go/v2 v2.23.0/go.mod h1:rBQKOVJCdb8IFEzg+FCwlt1LP/xMDGuqUXhUG+XMXEg= +github.com/googleapis/enterprise-certificate-proxy v0.3.22 h1:NU4XpII6jD+Dxcot94fqjE+AfJoE/lQP9q3faYGzC/c= +github.com/googleapis/enterprise-certificate-proxy v0.3.22/go.mod h1:L3D/IQExI6LqEjBdXcZQ1WluSgigQmSwBboFstVPM4w= +github.com/googleapis/gax-go/v2 v2.24.1 h1:AtqTN21IXMMWo99LiEVAiBfNNQmO40d8xUfZI640mc0= +github.com/googleapis/gax-go/v2 v2.24.1/go.mod h1:bWeBei0NVwaNZKb2y1HUBS7gLXIF3/Tu3pq7j8D2Tb0= github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 h1:JeSE6pjso5THxAzdVpqr6/geYxZytqFMBCOtn/ujyeo= github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674/go.mod h1:r4w70xmWCQKmi1ONH4KIaBptdivuRPyosB9RmPlGEwA= -github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.4 h1:kEISI/Gx67NzH3nJxAmY/dGac80kKZgZt134u7Y/k1s= -github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.4/go.mod h1:6Nz966r3vQYCqIzWsuEl9d7cf7mRhtDmm++sOxlnfxI= +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/cronexpr v1.1.3 h1:rl5IkxXN2m681EfivTlccqIryzYJSXRGRNa0xeG7NA4= @@ -242,31 +248,24 @@ github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+l github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM= github.com/hashicorp/go-rootcerts v1.0.2 h1:jzhAVGtqPKbwpyCPELlgNWhE1znq+qwJtW5Oi2viEzc= github.com/hashicorp/go-rootcerts v1.0.2/go.mod h1:pqUvnprVnM5bf7AOirdbb01K4ccR319Vf4pU3K5EGc8= -github.com/hashicorp/go-version v1.8.0 h1:KAkNb1HAiZd1ukkxDFGmokVZe1Xy9HG6NUp+bPle2i4= -github.com/hashicorp/go-version v1.8.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA= +github.com/hashicorp/go-version v1.9.0 h1:CeOIz6k+LoN3qX9Z0tyQrPtiB1DFYRPfCIBtaXPSCnA= +github.com/hashicorp/go-version v1.9.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA= 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/hashicorp/nomad/api v0.0.0-20250909143645-a3b86c697f38 h1:1LTbcTpGdSdbj0ee7YZHNe4R2XqxfyWwIkSGWRhgkfM= github.com/hashicorp/nomad/api v0.0.0-20250909143645-a3b86c697f38/go.mod h1:0Tdp+9HbvwrxprXv/LfYZ8P21bOl4oA8Afyet1kUvhI= github.com/infobloxopen/go-trees v0.0.0-20200715205103-96a057b8dfb9 h1:w66aaP3c6SIQ0pi3QH1Tb4AMO3aWoEPxd1CNvLphbkA= github.com/infobloxopen/go-trees v0.0.0-20200715205103-96a057b8dfb9/go.mod h1:BaIJzjD2ZnHmx2acPF6XfGLPzNCMiBbMRqJr+8/8uRI= -github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= -github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= 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/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= -github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= github.com/klauspost/compress v1.19.1 h1:VsB4HPswih7mmZ8WleSFQ75c/Ui1M4trX5oAsJnhSlk= github.com/klauspost/compress v1.19.1/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= -github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= -github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= -github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= 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= @@ -279,10 +278,8 @@ github.com/libdns/libdns v1.1.1 h1:wPrHrXILoSHKWJKGd0EiAVmiJbFShguILTg9leS/P/U= github.com/libdns/libdns v1.1.1/go.mod h1:4Bj9+5CQiNMVGf87wjX4CY3HQJypUHRuLvlsfsZqLWQ= github.com/linkdata/deadlock v0.5.5 h1:d6O+rzEqasSfamGDA8u7bjtaq7hOX8Ha4Zn36Wxrkvo= github.com/linkdata/deadlock v0.5.5/go.mod h1:tXb28stzAD3trzEEK0UJWC+rZKuobCoPktPYzebb1u0= -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/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= -github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= +github.com/lufia/plan9stats v0.0.0-20260330125221-c963978e514e h1:Q6MvJtQK/iRcRtzAscm/zF23XxJlbECiGPyRicsX+Ak= +github.com/lufia/plan9stats v0.0.0-20260330125221-c963978e514e/go.mod h1:autxFIvghDt3jPTLoqZ9OZ7s9qTGNAWmYCjVFWPX/zg= 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= @@ -290,8 +287,8 @@ github.com/mdlayher/vsock v1.3.0/go.mod h1:WsuksavOvwCnV5UqGHUkvAvCy+Dqy81y4goKQ github.com/mholt/acmez/v3 v3.1.6 h1:eGVQNObP0pBN4sxqrXeg7MYqTOWyoiYpQqITVWlrevk= github.com/mholt/acmez/v3 v3.1.6/go.mod h1:5nTPosTGosLxF3+LU4ygbgMRFDhbAVpqMI4+a4aHLBY= 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/miekg/dns v1.1.73 h1:uhT8nJxmTrPJYClxVxTCX+CVn6qnzSiybRk72Z6DgrE= +github.com/miekg/dns v1.1.73/go.mod h1:RW2Obtfd5NZHvOFe3zYG0W8koWOQtAzyHaLo8vASBuQ= github.com/minio/simdjson-go v0.4.5 h1:r4IQwjRGmWCQ2VeMc7fGiilu1z5du0gJ/I/FsKwgo5A= github.com/minio/simdjson-go v0.4.5/go.mod h1:eoNz0DcLQRyEDeaPr4Ru6JpjlZPzbA0IodxVJk8lO8E= github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y= @@ -308,14 +305,6 @@ github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq 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/onsi/ginkgo/v2 v2.27.2 h1:LzwLj0b89qtIy6SSASkzlNvX6WktqurSHwkk2ipF/Ns= -github.com/onsi/ginkgo/v2 v2.27.2/go.mod h1:ArE1D/XhNXBXCBkKOLkbsb2c81dQHCRcF5zwn/ykDRo= -github.com/onsi/gomega v1.38.2 h1:eZCjf2xjZAqe+LeWvKb5weQ+NcPwX84kqJ0cZNxok2A= -github.com/onsi/gomega v1.38.2/go.mod h1:W2MJcYxRGV63b418Ai34Ud0hEdTVXq9NW9+Sx6uXf3k= -github.com/open-telemetry/opentelemetry-collector-contrib/pkg/sampling v0.145.0 h1:7rdLY2Ewa1WVnjMfJTEKwQ5uPDHYeA1tqNPNROi957U= -github.com/open-telemetry/opentelemetry-collector-contrib/pkg/sampling v0.145.0/go.mod h1:jYlQAaJO4ZyJAW2jcKAbjN+nt5BRCyu49mlZv4Rui7U= -github.com/open-telemetry/opentelemetry-collector-contrib/processor/probabilisticsamplerprocessor v0.145.0 h1:12mxn+8YLeAjMZ1kLGulBcvHrdhRNUmxLVIDnaLkJbQ= -github.com/open-telemetry/opentelemetry-collector-contrib/processor/probabilisticsamplerprocessor v0.145.0/go.mod h1:V87HYJpfmvCeQ6Cjy3Q4xylxfCn2wVSS80wvv5ECc0s= github.com/opentracing-contrib/go-observer v0.0.0-20170622124052-a52f23424492 h1:lM6RxxfUMrYL/f8bWEUqdXrANWtrL7Nndbm9iFN0DlU= github.com/opentracing-contrib/go-observer v0.0.0-20170622124052-a52f23424492/go.mod h1:Ngi6UdF0k5OKD5t5wlmGhe/EDKPoUM3BXZSSfIuJbis= github.com/opentracing/opentracing-go v1.2.0 h1:uEJPy/1a5RIPAJ0Ov+OIO8OxWu77jEv+1B0VhjKrZUs= @@ -324,10 +313,10 @@ github.com/openzipkin-contrib/zipkin-go-opentracing v0.5.0 h1:uhcF5Jd7rP9DVEL10S github.com/openzipkin-contrib/zipkin-go-opentracing v0.5.0/go.mod h1:+oCZ5GXXr7KPI/DNOQORPTq5AWHfALJj9c72b0+YsEY= github.com/openzipkin/zipkin-go v0.4.3 h1:9EGwpqkgnwdEIJ+Od7QVSEIH+ocmm5nPat0G7sjsSdg= github.com/openzipkin/zipkin-go v0.4.3/go.mod h1:M9wCJZFWCo2RiY+o1eBCEMe0Dp2S5LDHcMZmk3RmK7c= -github.com/oschwald/geoip2-golang/v2 v2.2.0 h1:gdkhpnHQMiH9ymOI+zSB0QKFGH+n4TntNt7vz+TxGPY= -github.com/oschwald/geoip2-golang/v2 v2.2.0/go.mod h1:xW4tCeQiNU1gqMD1x7zEH2CDNM3d796Ls50yxYDaX0U= -github.com/oschwald/maxminddb-golang/v2 v2.3.0 h1:PnXjMGjkSQlwOBSyZ7hk6Fd75t7erkAhJNJgEhA3MQU= -github.com/oschwald/maxminddb-golang/v2 v2.3.0/go.mod h1:NSQvgFwPxODpBTJI5+5Ns1AAucnx7ggW9PSRRifAT1s= +github.com/oschwald/geoip2-golang/v2 v2.4.0 h1:JdVymxpwFf7o+3o53Sw2gCYBX8maA5DWxcgzNb14yJU= +github.com/oschwald/geoip2-golang/v2 v2.4.0/go.mod h1:VJW7lAC5Dw4WH42mjhUFkxf7+v3K1YOafLD8iBiszsc= +github.com/oschwald/maxminddb-golang/v2 v2.6.0 h1:pRlHCdJmc+4uxMOSthmKDt5HOw3JTX8TJZlhyP5ew0w= +github.com/oschwald/maxminddb-golang/v2 v2.6.0/go.mod h1:sjqpB3z2BZrMduDp9TAUTCkZDoT3nDhixUc4Dge2qRQ= github.com/outcaste-io/ristretto v0.2.3 h1:AK4zt/fJ76kjlYObOeNwh4T3asEuaCmp26pOvUOL9w0= github.com/outcaste-io/ristretto v0.2.3/go.mod h1:W8HywhmtlopSB1jeMg3JtdIhf+DYkLAr0VN/s4+MHac= github.com/petermattis/goid v0.0.0-20250813065127-a731cc31b4fe/go.mod h1:pxMtw7cyUw6B2bRH0ZBANSPg+AoSud1I1iyJHI69jH4= @@ -350,12 +339,12 @@ github.com/prashantv/gostub v1.1.0 h1:BTyx3RfQjRHnUWaGF9oQos79AlQ5k8WNktv7VGvVH4 github.com/prashantv/gostub v1.1.0/go.mod h1:A5zLQHz7ieHGG7is6LLXLz7I8+3LZzsrV0P1IAHhP5U= 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.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/client_model v0.6.3 h1:O0jaTVAYNxTHYInEPFJt5I3+sN8zqBtVMPTB1qyxiEo= +github.com/prometheus/client_model v0.6.3/go.mod h1:gpN5P9S7Rr6Yr92PiQ+Ixvhf6JZEkF1dnxsYL2aPBEM= +github.com/prometheus/common v0.71.0 h1:9KDAKb7Mj3HEVKyFCK6Dc/HIwlBzZIN2l7/lrHl3KK8= +github.com/prometheus/common v0.71.0/go.mod h1:CLJ5H8TEsGX8bl31BdMkfhIZ+QmZ9tBPPotUxUbfcmk= +github.com/prometheus/exporter-toolkit v0.19.0 h1:JljWCzE5naAiZ7Ukeb8PwjNbU+WwISuW0ktgdXMnMhc= +github.com/prometheus/exporter-toolkit v0.19.0/go.mod h1:kOoEK/7wbe2Ns33l7wYHOXDZAZ/XGLyJqoGwmJxK+QU= 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/puzpuzpuz/xsync/v3 v3.5.1 h1:GJYJZwO6IdxN/IKbneznS6yPkVC+c3zyY/j19c++5Fg= @@ -364,16 +353,16 @@ github.com/quic-go/go-ossfuzz-seeds v0.1.0 h1:APacT+iIaNF6fd8AGEiN3bT/Jtkd2jz4v4 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/quic-go/quic-go v0.63.0 h1:LIFGHI4PFUhhw2dDD1ARHdCff143ffMHwZtbnbuJ78A= +github.com/quic-go/quic-go v0.63.0/go.mod h1:RAro2j2yN9a9EiPACLHT9IB2NXCvGQmmo/alT0yYI0w= github.com/richardartoul/molecule v1.0.1-0.20240531184615-7ca0df43c0b3 h1:4+LEVOB87y175cLJC/mbsgKmoDOjrBldtXvioEy96WY= github.com/richardartoul/molecule v1.0.1-0.20240531184615-7ca0df43c0b3/go.mod h1:vl5+MqJ1nBINuSsUI2mGgH79UweUT/B5Fy8857PqyyI= 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/secure-systems-lab/go-securesystemslib v0.10.0 h1:l+H5ErcW0PAehBNrBxoGv1jjNpGYdZ9RcheFkB2WI14= -github.com/secure-systems-lab/go-securesystemslib v0.10.0/go.mod h1:MRKONWmRoFzPNQ9USRF9i1mc7MvAVvF1LlW8X5VWDvk= -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/secure-systems-lab/go-securesystemslib v0.11.0 h1:iuCR9kcMFD4QurdKrGvPLoKZLv9YvwPYVr0473BdtFs= +github.com/secure-systems-lab/go-securesystemslib v0.11.0/go.mod h1:+PMOTjUGwHj2vcZ+TFKlb1tXRbrdWE1LYDT5i9JC80Q= +github.com/shirou/gopsutil/v4 v4.26.6 h1:Mzr/npDtQC/xpeEuQKHZt8Zo9CmPvhTj8nkR8w5TLDs= +github.com/shirou/gopsutil/v4 v4.26.6/go.mod h1:LZ6ewCSkBqUpvSOf+LsTGnRinC6iaNUNMGBtDkJBaLQ= github.com/shoenig/test v1.12.1 h1:mLHfnMv7gmhhP44WrvT+nKSxKkPDiNkIuHGdIGI9RLU= github.com/shoenig/test v1.12.1/go.mod h1:UxJ6u/x2v/TNs/LoLxBNJRV9DiwBBKYxXSyczsBHFoI= github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0= @@ -384,33 +373,30 @@ github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3A github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= -github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= -github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/objx v0.5.3 h1:jmXUvGomnU1o3W/V5h2VEradbpJDwGrzugQQvL0POH4= +github.com/stretchr/objx v0.5.3/go.mod h1:rDQraq+vQZU7Fde9LOZLr8Tax6zZvy4kuNKF+QYS+U0= github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= -github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= -github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= -github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= -github.com/tinylib/msgp v1.6.3 h1:bCSxiTz386UTgyT1i0MSCvdbWjVW+8sG3PjkGsZQt4s= -github.com/tinylib/msgp v1.6.3/go.mod h1:RSp0LW9oSxFut3KzESt5Voq4GVWyS+PSulT77roAqEA= +github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE= +github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg= +github.com/tinylib/msgp v1.6.4 h1:mOwYbyYDLPj35mkA2BjjYejgJk9BuHxDdvRnb6v2ZcQ= +github.com/tinylib/msgp v1.6.4/go.mod h1:RSp0LW9oSxFut3KzESt5Voq4GVWyS+PSulT77roAqEA= 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/trailofbits/go-mutexasserts v0.0.0-20250514102930-c1f3d2e37561 h1:qqa3P9AtNn6RMe90l/lxd3eJWnIRxjI4eb5Rx8xqCLA= github.com/trailofbits/go-mutexasserts v0.0.0-20250514102930-c1f3d2e37561/go.mod h1:GA3+Mq3kt3tYAfM0WZCu7ofy+GW9PuGysHfhr+6JX7s= -github.com/vmihailenco/msgpack/v4 v4.3.13 h1:A2wsiTbvp63ilDaWmsk2wjx6xZdxQOvpiNlKBGKKXKI= -github.com/vmihailenco/msgpack/v4 v4.3.13/go.mod h1:gborTTJjAo/GWTqqRjrLCn9pgNN+NXzzngzBKDPIqw4= -github.com/vmihailenco/tagparser v0.1.2 h1:gnjoVuB/kljJ5wICEEOpx98oXMWPLj22G67Vbd1qPqc= -github.com/vmihailenco/tagparser v0.1.2/go.mod h1:OeAg3pn3UbLjkWt+rN9oFYB6u/cQgqMEUPoW2WPyhdI= +github.com/vmihailenco/msgpack/v5 v5.4.1 h1:cQriyiUvjTwOHg8QZaPihLWeRAAVoCpE00IUPn0Bjt8= +github.com/vmihailenco/msgpack/v5 v5.4.1/go.mod h1:GaZTsDaehaPpQVyxrf5mtQlH+pc21PIudVV/E3rRQok= +github.com/vmihailenco/tagparser/v2 v2.0.0 h1:y09buUbR+b5aycVFQs/g70pqKVZNBmxwAhO7/IwNM9g= +github.com/vmihailenco/tagparser/v2 v2.0.0/go.mod h1:Wri+At7QHww0WTrCBeu4J6bNtoV6mEfg5OIWRZA9qds= github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= -github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= -github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0= @@ -421,50 +407,30 @@ github.com/zeebo/blake3 v0.2.4 h1:KYQPkhpRtcqh0ssGYcKLG1JYvddkEA8QwCM/yBqhaZI= github.com/zeebo/blake3 v0.2.4/go.mod h1:7eeQ6d2iXWRGF6npfaxl2CU+xy2Fjo2gxeyZGCRUjcE= github.com/zeebo/pcg v1.0.1 h1:lyqfGeWiv4ahac6ttHs+I5hwtH/+1mrhlCtVNQM2kHo= github.com/zeebo/pcg v1.0.1/go.mod h1:09F0S9iiKrwn9rlI5yjLkmrug154/YRW6KnnXVDM/l4= -go.etcd.io/etcd/api/v3 v3.6.13 h1:AvHPZv15LYEe7tZDyFglv7xnbiuF6GMZpZqKpIzXTt0= -go.etcd.io/etcd/api/v3 v3.6.13/go.mod h1:X9+3gaKwzjlOxzo6TZ2u3b7HcHBcAL+Ph7EBPjI/VWk= -go.etcd.io/etcd/client/pkg/v3 v3.6.13 h1:7QeMOisYByx8dBA7/CKcwCaPWfjb5C0xpmrIov/8WyY= -go.etcd.io/etcd/client/pkg/v3 v3.6.13/go.mod h1:Dn2zUBOCu/6xYcd6iAjB7LgoY16OTQjDZfWHLwvuQj4= -go.etcd.io/etcd/client/v3 v3.6.13 h1:0E+9ZYGpMsi9KlOJVoCdONh9PUDawKDTy5mSNY8wOEI= -go.etcd.io/etcd/client/v3 v3.6.13/go.mod h1:rtVI3vwobljb8xlTGcp1Yhz7hBIuBWULXwB848kqJGw= +go.etcd.io/bbolt v1.5.0 h1:S7GAl7Fxv12yohbwFfIbQCGDWbQbtDGPET4P/bD4lxU= +go.etcd.io/bbolt v1.5.0/go.mod h1:mkltfYE5aUHQxUct9N9V+Kp7aSjFqjgrhcXIS70Lrdk= +go.etcd.io/etcd/api/v3 v3.7.2 h1:xgt/6el1LsPWWYNLkhMAK4tZm6dF+1sCqDecpE5gdbk= +go.etcd.io/etcd/api/v3 v3.7.2/go.mod h1:RoRCBRt9BfBff1pIGZLUVMiz7wu3bY+b2qLysGu1HY4= +go.etcd.io/etcd/client/pkg/v3 v3.7.2 h1:SVtlR7tiSVAYOQ4nWPIyFXb4RMgEcnzeAG9RQ8MoNDU= +go.etcd.io/etcd/client/pkg/v3 v3.7.2/go.mod h1:HsSux/B3ahgyw/D5+d4YbZqicOi0mEbuxm6lIUdjAoI= +go.etcd.io/etcd/client/v3 v3.7.2 h1:Z66GqDQDI7zPDfVSsIBqGSK4mJYLtv8ESwXa4mPf+wY= +go.etcd.io/etcd/client/v3 v3.7.2/go.mod h1:x03t1qMs4tGZirCDJlMuzPBJdQffXJImIyEjLhNBCsY= 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/collector/component v1.51.1-0.20260205185216-81bc641f26c0 h1:ZSlXxE90IY0Cl53RTqzyEgRgRPLTeTNBdGhaTmvj9eY= -go.opentelemetry.io/collector/component v1.51.1-0.20260205185216-81bc641f26c0/go.mod h1:944C7vEIdk13Pn1fBbyaU8C1qKf2XC0jRBlc69NAsRY= -go.opentelemetry.io/collector/component/componentstatus v0.145.0 h1:EwUZfSaagdpRXnlrb0TqReJXXW2p9HWBU5YiIeXPCAE= -go.opentelemetry.io/collector/component/componentstatus v0.145.0/go.mod h1:OiYb8rT4FtSJPFSGCKYvOaajdueDUTJZncixGrmy5aM= -go.opentelemetry.io/collector/component/componenttest v0.145.1-0.20260205185216-81bc641f26c0 h1:+VCK6wX/WN170dcaWJweRAkxpmAEyVucfrUV13NwUlY= -go.opentelemetry.io/collector/component/componenttest v0.145.1-0.20260205185216-81bc641f26c0/go.mod h1:U2wUjKMGwgqM49/q8ORkzzYzSWY2m6zpG/e606eK1wc= -go.opentelemetry.io/collector/consumer v1.51.1-0.20260205185216-81bc641f26c0 h1:WNkJ1bKnRVAEJtBm1bwEkoLG2x7GyANc3/OnErZJ338= -go.opentelemetry.io/collector/consumer v1.51.1-0.20260205185216-81bc641f26c0/go.mod h1:Erk6qdfVj+24QTrGCpurcrF+qdUlHkb4dgMy5wJxLvY= -go.opentelemetry.io/collector/consumer/consumertest v0.145.1-0.20260205185216-81bc641f26c0 h1:FHyDIlTbqt0Y6tDI9EbI3hr9uWthwkeLY7uGF1jZYqQ= -go.opentelemetry.io/collector/consumer/consumertest v0.145.1-0.20260205185216-81bc641f26c0/go.mod h1:IFc/FeaIHQClb8KK0aVn0tFDNMc+/MmfQ+aBT1cJNeo= -go.opentelemetry.io/collector/consumer/xconsumer v0.145.1-0.20260205185216-81bc641f26c0 h1:zg2Jqfy7n7o/LEmLsXB4sFhxWtOEMFCKRyQUFLFUS9M= -go.opentelemetry.io/collector/consumer/xconsumer v0.145.1-0.20260205185216-81bc641f26c0/go.mod h1:SryDCLP2ZaFeZJtA2CSksJ0XvjH8k3LmlfXvy/kC7Wc= -go.opentelemetry.io/collector/featuregate v1.51.1-0.20260205185216-81bc641f26c0 h1:fOXhfT2xKqNhfalTXaT/Wic9EBRK8+9ZH0y8phReQS4= -go.opentelemetry.io/collector/featuregate v1.51.1-0.20260205185216-81bc641f26c0/go.mod h1:/1bclXgP91pISaEeNulRxzzmzMTm4I5Xih2SnI4HRSo= -go.opentelemetry.io/collector/internal/componentalias v0.145.1-0.20260205185216-81bc641f26c0 h1:s4/vCxeIxgQpuWmX1AK1DRbZmEdmNBq925EKES8ebiI= -go.opentelemetry.io/collector/internal/componentalias v0.145.1-0.20260205185216-81bc641f26c0/go.mod h1:Z0TtMbzaMp2qhj1dw4toya8toyQzqoTF46/WhJXplVw= -go.opentelemetry.io/collector/internal/testutil v0.145.0 h1:H/KL0GH3kGqSMKxZvnQ0B0CulfO9xdTg4DZf28uV7fY= -go.opentelemetry.io/collector/internal/testutil v0.145.0/go.mod h1:YAD9EAkwh/l5asZNbEBEUCqEjoL1OKMjAMoPjPqH76c= -go.opentelemetry.io/collector/pdata v1.51.1-0.20260205185216-81bc641f26c0 h1:8tgf9W3aW3vFabyVxPNHKsaoyUytudfVOQbqZI9xBHQ= -go.opentelemetry.io/collector/pdata v1.51.1-0.20260205185216-81bc641f26c0/go.mod h1:GoX1bjKDR++mgFKdT7Hynv9+mdgQ1DDXbjs7/Ww209Q= -go.opentelemetry.io/collector/pdata/pprofile v0.145.1-0.20260205185216-81bc641f26c0 h1:MJcnK8txYZlqHZyfZ1rVf66kt5/kEveIdR6KX//BY1Y= -go.opentelemetry.io/collector/pdata/pprofile v0.145.1-0.20260205185216-81bc641f26c0/go.mod h1:a60GC7wQPhLAixWzKbbP51QLwwc+J0Cmp4SurOlhGUk= -go.opentelemetry.io/collector/pdata/testdata v0.145.0 h1:iFsxsCMtE3lnAc/5kZbhZHpRv1OMmM+O5ry46xdQHbg= -go.opentelemetry.io/collector/pdata/testdata v0.145.0/go.mod h1:0y2ERArdzqmYdJHdKLKue+AUubSEGlwK49F+23+Mbic= -go.opentelemetry.io/collector/pipeline v1.51.1-0.20260205185216-81bc641f26c0 h1:1KP5gXGF9qN1mEzJupZDUQVIND35qe/0Hy6Cptvdk0s= -go.opentelemetry.io/collector/pipeline v1.51.1-0.20260205185216-81bc641f26c0/go.mod h1:xUrAqiebzYbrgxyoXSkk6/Y3oi5Sy3im2iCA51LwUAI= -go.opentelemetry.io/collector/processor v1.51.0 h1:PKpCzkLQmqaW08TOVh/zM0qx07Ihq+DR5J/OBkPiL9o= -go.opentelemetry.io/collector/processor v1.51.0/go.mod h1:rtIPFS+EFRAkG+CSwtjxs2IsIkuZStObvALeueD02XI= -go.opentelemetry.io/collector/processor/processorhelper v0.145.0 h1:vXdv6lHz20Tm3ZEsg0i6jPZJBQgy9kzk/PuqWhHWiiM= -go.opentelemetry.io/collector/processor/processorhelper v0.145.0/go.mod h1:3Ecpe5jHRHGf24EvJHeJ/ekK/a1DLByyq0CSUxjjURg= -go.opentelemetry.io/collector/processor/processortest v0.145.0 h1:RDGBmyZnHk7XVK/EdLt/8iPWj+QLStbbVi1nFTNR01s= -go.opentelemetry.io/collector/processor/processortest v0.145.0/go.mod h1:WAvxAzSojkdoZB915Z1lsVHCPDJBb2fepjJBjenrzjg= -go.opentelemetry.io/collector/processor/xprocessor v0.145.0 h1:DaIE7MxRlg0OL1o2P0GQZtmZeExAmVso3qWv8S0RLps= -go.opentelemetry.io/collector/processor/xprocessor v0.145.0/go.mod h1:kUwRyKBU/kjCmXodd+0z7CpvcP0A9G9/QL+MaJt4U2o= -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/collector/component v1.61.0 h1:f2dUAPK1xu3FSY3QG2whG9PEEs+QgfdraoKQFyIwlSI= +go.opentelemetry.io/collector/component v1.61.0/go.mod h1:TFmz1NXfMDG4aKTAYcdi5gFntdAW/+Vq/iHYDoou/0E= +go.opentelemetry.io/collector/component/componenttest v0.155.0 h1:FfQQpYJnkNhNW5EPSD+vBiUL7Mwgkudrkr0LRYpi7HA= +go.opentelemetry.io/collector/component/componenttest v0.155.0/go.mod h1:MkXnGN4QH6El1GGTTOrDUqY8/p8Vkbfi0Non2Pmi0m4= +go.opentelemetry.io/collector/featuregate v1.61.0 h1:XtnQ/XPHLmw9zgg4Cjq/f0rgdqn7z1M10wnmGhgNbYk= +go.opentelemetry.io/collector/featuregate v1.61.0/go.mod h1:4ga1QBMPEejXXmpyJS8lmaRpknJ3Lb9Bvk6e420bUFU= +go.opentelemetry.io/collector/internal/testutil v0.155.0 h1:ExZ3lqM1e1Y83AAXKr6Xsw20v4LHW6GZ8VeLLQHiOrA= +go.opentelemetry.io/collector/internal/testutil v0.155.0/go.mod h1:Jkjs6rkqs973LqgZ0Fe3zrokQRKULYXPIf4HuqStiEE= +go.opentelemetry.io/collector/pdata v1.61.0 h1:EVfGB/9dcyMXhMsZ5kzKeGFJj8QWqvmZjgg4RMjnRhE= +go.opentelemetry.io/collector/pdata v1.61.0/go.mod h1:qYEsyeIJ9tWHb2jSR5HQ9/VmbCGVca+G+ZDAB8dFCMc= +go.opentelemetry.io/collector/pdata/pprofile v0.155.0 h1:13LsyUy9SN88xcqbz89kvDqyn6qQSF5RpvXJ/c84nrw= +go.opentelemetry.io/collector/pdata/pprofile v0.155.0/go.mod h1:wlPe4OkzIYSmd1bCgAzmbKMlPDwlXCOLjbG68Fn7SG0= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0 h1:8tvICD4vSTOOsNrsI4Ljf6C+6UKvpTEH5XY3JMoyPoo= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0/go.mod h1:z9+yiacE0IHRqM4qFfkbt/JYlmYXgss8GY/jXoNuPJI= go.opentelemetry.io/otel v1.44.0 h1:JjwHmHpA4iZ3wBxluu2fbbE7j4kqlE8jXyAyPXH7HqU= go.opentelemetry.io/otel v1.44.0/go.mod h1:BMgjTHL9WPRlRjL2oZCBTL4whCGtXch2H4BhOPIAyYc= go.opentelemetry.io/otel/metric v1.44.0 h1:1w0gILTcHdr3YI+ixLyjemwrVnsMURbTZFrSYCdDdmc= @@ -475,14 +441,14 @@ go.opentelemetry.io/otel/sdk/metric v1.44.0 h1:3LlKgI+VjbVsjNRFZJZAJ30WjXC5VkNRk go.opentelemetry.io/otel/sdk/metric v1.44.0/go.mod h1:5B5pMARnXxKhltooO4xUuCBorl65a4EpnTalObqOigA= go.opentelemetry.io/otel/trace v1.44.0 h1:jxF5CsGYCe74MCRx2X4g7WsY/VBKRqqpNvXlX/6gtIk= go.opentelemetry.io/otel/trace v1.44.0/go.mod h1:oLl1jrMQAVo6v3GAggN+1VH9VIz9iUSvW53sW1Q8PIE= -go.opentelemetry.io/proto/otlp v1.9.0 h1:l706jCMITVouPOqEnii2fIAuO3IVGBRPV5ICjceRb/A= -go.opentelemetry.io/proto/otlp v1.9.0/go.mod h1:xE+Cx5E/eEHw+ISFkwPLwCZefwVjY+pqKg1qcK03+/4= -go.opentelemetry.io/proto/slim/otlp v1.9.0 h1:fPVMv8tP3TrsqlkH1HWYUpbCY9cAIemx184VGkS6vlE= -go.opentelemetry.io/proto/slim/otlp v1.9.0/go.mod h1:xXdeJJ90Gqyll+orzUkY4bOd2HECo5JofeoLpymVqdI= -go.opentelemetry.io/proto/slim/otlp/collector/profiles/v1development v0.2.0 h1:o13nadWDNkH/quoDomDUClnQBpdQQ2Qqv0lQBjIXjE8= -go.opentelemetry.io/proto/slim/otlp/collector/profiles/v1development v0.2.0/go.mod h1:Gyb6Xe7FTi/6xBHwMmngGoHqL0w29Y4eW8TGFzpefGA= -go.opentelemetry.io/proto/slim/otlp/profiles/v1development v0.2.0 h1:EiUYvtwu6PMrMHVjcPfnsG3v+ajPkbUeH+IL93+QYyk= -go.opentelemetry.io/proto/slim/otlp/profiles/v1development v0.2.0/go.mod h1:mUUHKFiN2SST3AhJ8XhJxEoeVW12oqfXog0Bo8W3Ec4= +go.opentelemetry.io/proto/otlp v1.10.0 h1:IQRWgT5srOCYfiWnpqUYz9CVmbO8bFmKcwYxpuCSL2g= +go.opentelemetry.io/proto/otlp v1.10.0/go.mod h1:/CV4QoCR/S9yaPj8utp3lvQPoqMtxXdzn7ozvvozVqk= +go.opentelemetry.io/proto/slim/otlp v1.10.0 h1:iR97Vs/ZDR+y9TfuP9b1XBtdPWeC+OMslIBmhcLU7jM= +go.opentelemetry.io/proto/slim/otlp v1.10.0/go.mod h1:lV9250stpjYLPNA5viFabIgP2QlUGRT1GdTgAf8SIUk= +go.opentelemetry.io/proto/slim/otlp/collector/profiles/v1development v0.3.0 h1:RUF5rO0hAlgiJt1fzQVzcVs3vZVNHIcMLgOgG4rWNcQ= +go.opentelemetry.io/proto/slim/otlp/collector/profiles/v1development v0.3.0/go.mod h1:I89cynRj8y+383o7tEQVg2SVA6SRgDVIouWPUVXjx0U= +go.opentelemetry.io/proto/slim/otlp/profiles/v1development v0.3.0 h1:CQvJSldHRUN6Z8jsUeYv8J0lXRvygALXIzsmAeCcZE0= +go.opentelemetry.io/proto/slim/otlp/profiles/v1development v0.3.0/go.mod h1:xSQ+mEfJe/GjK1LXEyVOoSI1N9JV9ZI923X5kup43W4= go.uber.org/atomic v1.9.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE= go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0= @@ -500,55 +466,47 @@ go.uber.org/zap/exp v0.3.0 h1:6JYzdifzYkGmTdRR59oYH+Ng7k49H9qVpWwNSsGJj3U= go.uber.org/zap/exp v0.3.0/go.mod h1:5I384qq7XGxYyByIhHm6jg5CHkGY0nsTfbDLgDDlgJQ= 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= +go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw= +go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg= 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-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.0.0-20220722155217-630584e8d5aa/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= golang.org/x/crypto v0.17.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4= -golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw= -golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk= -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/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-20260529124908-c761662dc8c9 h1:4d4PbuBNwaxMXkXI8yiIYjydtMU+04RHeuSxJdgKftM= +golang.org/x/exp v0.0.0-20260529124908-c761662dc8c9/go.mod h1:d2fgXJLVs4dYDHUk5lwMIfzRzSrWCfGZb0ZqeLa/Vcw= golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg= -golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= -golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= 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.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ= -golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0= +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-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-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM= golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= 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= golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= -golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE= -golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU= -golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= -golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= +golang.org/x/net v0.59.0 h1:5zfYln+w5XCxwrnMMJPufRgNoXEaGxl0wo5GqPXyues= +golang.org/x/net v0.59.0/go.mod h1:2DA/G1UfVbCpQPeWTmMPGY7Cs2PkBkwu743bVX5PIVg= +golang.org/x/oauth2 v0.37.0 h1:JUlcxA8oAtauLfiH8FX2/FkAWHAdi0QtGCGc+hofE98= +golang.org/x/oauth2 v0.37.0/go.mod h1:IxwZNxUULJmpBFf9K/9NTMSIfZZuvuTy1gGxhigP/58= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= -golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +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-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/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-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -563,15 +521,15 @@ 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.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= -golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +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= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= golang.org/x/term v0.15.0/go.mod h1:BDl952bC7+uMoWR75FIrCDx79TPU9oHkTZ9yRbYOrX0= -golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0= -golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w= +golang.org/x/term v0.46.0 h1:3+OXuTbaKDgwk8jTi3aSLHRlmWqHEUDUtxnbFigO4YE= +golang.org/x/term v0.46.0/go.mod h1:+K02xbkittuwc0Am4abfA3Fc+XRGXkvBXNO88NCXPoc= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= @@ -579,20 +537,16 @@ golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= -golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs= -golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY= -golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= -golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= +golang.org/x/text v0.42.0 h1:JbOZXgfeCPU9gacVtYliJqOhD+zhrEqK4LfdpmlUZqI= +golang.org/x/text v0.42.0/go.mod h1:ojzP1Z+2QtioaF8DTtO8K5q7JWVVYwZKenzujK0Zd0E= +golang.org/x/time v0.16.0 h1:vMb6ptszcQMkcwiRTAuNNU50gom6++Q/6gY2hDM6VDE= +golang.org/x/time v0.16.0/go.mod h1:rVKOqvZeKvrDKTQiAHJ7wmwP0RzleSphoEA9RcdLA0s= 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.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= -golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= golang.org/x/tools v0.1.1/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= 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.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q= -golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA= 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= @@ -601,26 +555,24 @@ golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da h1:noIWHXmPHxILtqtCOPIhS golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da/go.mod h1:NDW/Ps6MPRej6fsCIbMTohpP40sJ/P/vI1MoTEGwX90= gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= -google.golang.org/api v0.292.0 h1:Ewiwo/GTtiaPZSNAZQUcWLh8AYDEoPmIXyJfeoTSMHU= -google.golang.org/api v0.292.0/go.mod h1:07kjmMnFGm2RQuCza2EZM/5N68G/fVvFb1xKjWqoFA0= -google.golang.org/appengine v1.6.8 h1:IhEN5q69dyKagZPYMSdIjS2HqprW324FRQZJcGqPAsM= -google.golang.org/appengine v1.6.8/go.mod h1:1jJ3jBArFh5pcgW8gCtRJnepW8FzD1V44FJffLiz/Ds= -google.golang.org/genproto v0.0.0-20260319201613-d00831a3d3e7 h1:XzmzkmB14QhVhgnawEVsOn6OFsnpyxNPRY9QV01dNB0= -google.golang.org/genproto v0.0.0-20260319201613-d00831a3d3e7/go.mod h1:L43LFes82YgSonw6iTXTxXUX1OlULt4AQtkik4ULL/I= -google.golang.org/genproto/googleapis/api v0.0.0-20260630182238-925bb5da69e7 h1:jQ9p21COKWjP3VwuFrNRiiOTMh3mPpN45R7SLrH/HUU= -google.golang.org/genproto/googleapis/api v0.0.0-20260630182238-925bb5da69e7/go.mod h1:KqHwBx2upmfa1XSi1WuRvC+2VGCLtooKkfmyvRbUmqA= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260803160001-6ac0973c030d h1:IL4hdHzcUv2l/gcg98/Rj3FbtE6axwqslOW8SW0C+S0= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260803160001-6ac0973c030d/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= -google.golang.org/grpc v1.83.0 h1:JeNZEKJFbQxArAMl+hiytHauacDNqJUllNfmIMmpqnQ= -google.golang.org/grpc v1.83.0/go.mod h1:kDyl6SKsiHKt0uylY5gtn5cEjkrIOhQOGDgIc4JGwzQ= +google.golang.org/api v0.299.0 h1:b3K+ydSMd0kh6TQI6bJyApRQfqQX2MfSOaVkpM59mJw= +google.golang.org/api v0.299.0/go.mod h1:zlR3GVA8b2R5nv5Ij9UWe37StVB3cxDD7DBFi4ZFsHw= +google.golang.org/genproto v0.0.0-20260715232425-e75dac1f907d h1:C9v1o0/4quuhOAfmRXA2j+we0PqZIp8traLdeogF3Ms= +google.golang.org/genproto v0.0.0-20260715232425-e75dac1f907d/go.mod h1:Wz2wFJntZFmLGo7pLDXZ3wYk5hyc0Mb+SkHhDDXT+lU= +google.golang.org/genproto/googleapis/api v0.0.0-20260715232425-e75dac1f907d h1:QwnJwPte4XXAkhPu26LTDIahnsMSUV0kK8HkxbC+Pc4= +google.golang.org/genproto/googleapis/api v0.0.0-20260715232425-e75dac1f907d/go.mod h1:WRrQ7/7N19PypuT0fxLOL5Lq0waoiRri4FbtHDEKrGE= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260921155816-b14227669459 h1:b0xCahf3FK2m2Cv0p4vTozGPWncCvLfwV86UNg8xWU8= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260921155816-b14227669459/go.mod h1:OaIUM3+LpYcK2GXM4FTmhWoIq371Owdr+Cc7/BsYHHc= +google.golang.org/grpc v1.84.0 h1:soMyaPJ8pAak5PIQ0DGBUir0XRo2fRoMqhNWMLlLxO0= +google.golang.org/grpc v1.84.0/go.mod h1:ljCht0DrxQrXBDRTZp52Qxh3Ffk8CdYm2sj4O2QN2C0= 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.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= -google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +google.golang.org/protobuf v1.36.12 h1:pJOKDDOyeXErUroCihFAd5LQuwXBSpVnKGrj5o/fwxc= +google.golang.org/protobuf v1.36.12/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= @@ -628,31 +580,29 @@ gopkg.in/evanphx/json-patch.v4 v4.13.0 h1:czT3CmqEaQ1aanPc5SdlgQrrEIb8w/wwCvWWnf gopkg.in/evanphx/json-patch.v4 v4.13.0/go.mod h1:p8EYWUEYMpynmqDbY58zCKCFZw8pRWMG4EsWvDvM72M= gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc= gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw= -gopkg.in/ini.v1 v1.67.1 h1:tVBILHy0R6e4wkYOn3XmiITt/hEVH4TFMYvAX2Ytz6k= -gopkg.in/ini.v1 v1.67.1/go.mod h1:x/cyOwCgZqOkJoDIJ3c1KNHMo10+nLGAhh+kn3Zizss= gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -k8s.io/api v0.35.4 h1:P7nFYKl5vo9AGUp1Z+Pmd3p2tA7bX2wbFWCvDeRv988= -k8s.io/api v0.35.4/go.mod h1:yl4lqySWOgYJJf9RERXKUwE9g2y+CkuwG+xmcOK8wXU= -k8s.io/apimachinery v0.35.4 h1:xtdom9RG7e+yDp71uoXoJDWEE2eOiHgeO4GdBzwWpds= -k8s.io/apimachinery v0.35.4/go.mod h1:NNi1taPOpep0jOj+oRha3mBJPqvi0hGdaV8TCqGQ+cc= -k8s.io/client-go v0.35.4 h1:DN6fyaGuzK64UvnKO5fOA6ymSjvfGAnCAHAR0C66kD8= -k8s.io/client-go v0.35.4/go.mod h1:2Pg9WpsS4NeOpoYTfHHfMxBG8zFMSAUi4O/qoiJC3nY= +k8s.io/api v0.37.0 h1:Z//Vj9N7RA/yS2sDmxyeo7h+RR4zbUrd2vrd3Z0TbB4= +k8s.io/api v0.37.0/go.mod h1:LKXgcJWMc+f4OLbP5SFR8rulEg07zZhpi/zMULiBImk= +k8s.io/apimachinery v0.37.0 h1:Np2AbDtf8x6RDHiD8T9LbKJ9gaegeVNa8yNm5FuGKm0= +k8s.io/apimachinery v0.37.0/go.mod h1:RN3nhprFSCxOi5Selxd7oMTXOe/c+ZbcE7Im+TS2zkE= +k8s.io/client-go v0.37.0 h1:nsN31fy8wBySuZ+QRnKmrjRSQLOG2rvoGN0tKd12zhQ= +k8s.io/client-go v0.37.0/go.mod h1:FcGqw+Ll/gNQiq+nPGY1Oyt9y7SgDh1d3MW3RFDEbn0= k8s.io/klog/v2 v2.140.0 h1:Tf+J3AH7xnUzZyVVXhTgGhEKnFqye14aadWv7bzXdzc= k8s.io/klog/v2 v2.140.0/go.mod h1:o+/RWfJ6PwpnFn7OyAG3QnO47BFsymfEfrz6XyYSSp0= -k8s.io/kube-openapi v0.0.0-20250910181357-589584f1c912 h1:Y3gxNAuB0OBLImH611+UDZcmKS3g6CthxToOb37KgwE= -k8s.io/kube-openapi v0.0.0-20250910181357-589584f1c912/go.mod h1:kdmbQkyfwUagLfXIad1y2TdrjPFWp2Q89B3qkRwf/pQ= -k8s.io/utils v0.0.0-20251002143259-bc988d571ff4 h1:SjGebBtkBqHFOli+05xYbK8YF1Dzkbzn+gDM4X9T4Ck= -k8s.io/utils v0.0.0-20251002143259-bc988d571ff4/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0= +k8s.io/kube-openapi v0.0.0-20260721132016-d427ff9ee9ad h1:oXImqH8mQNk7PmvzKhmN3ddJoY6OnyM225MXwGHPm0A= +k8s.io/kube-openapi v0.0.0-20260721132016-d427ff9ee9ad/go.mod h1:0/mqHCVhlumdJ3BhCfnjSZQE037nAhNodh1/hK0T8/I= +k8s.io/utils v0.0.0-20260626114624-be93311217bd h1:Ea7fgQ5we8Y9T0OX5o0dAHzQOBRI07D/dEYRaB9ZZEs= +k8s.io/utils v0.0.0-20260626114624-be93311217bd/go.mod h1:xDxuJ0whA3d0I4mf/C4ppKHxXynQ+fxnkmQH0vTHnuk= sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 h1:IpInykpT6ceI+QxKBbEflcR5EXP7sU1kvOlxwZh5txg= sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730/go.mod h1:mdzfpAEoE6DHQEN0uh9ZbOCuHbLK5wOm7dK4ctXE9Tg= sigs.k8s.io/mcs-api v0.5.2 h1:N+vrRiCIb0WJ0dxbBo7VfNv2WJigOHEo4gsLXpffVs8= sigs.k8s.io/mcs-api v0.5.2/go.mod h1:zZ5CK8uS6HaLkxY4HqsmcBHfzHuNMrY2uJy8T7jffK4= sigs.k8s.io/randfill v1.0.0 h1:JfjMILfT8A6RbawdsK2JXGBR5AQVfd+9TbzrlneTyrU= sigs.k8s.io/randfill v1.0.0/go.mod h1:XeLlZ/jmk4i1HRopwe7/aU3H5n1zNUcX6TM94b3QxOY= -sigs.k8s.io/structured-merge-diff/v6 v6.3.0 h1:jTijUJbW353oVOd9oTlifJqOGEkUw2jB/fXCbTiQEco= -sigs.k8s.io/structured-merge-diff/v6 v6.3.0/go.mod h1:M3W8sfWvn2HhQDIbGWj3S099YozAsymCo/wrT5ohRUE= +sigs.k8s.io/structured-merge-diff/v6 v6.4.2 h1:qdOxHwrl2Kaag1aQEarlYcOA9vSyGCp3CIki3aW8c4Q= +sigs.k8s.io/structured-merge-diff/v6 v6.4.2/go.mod h1:M3W8sfWvn2HhQDIbGWj3S099YozAsymCo/wrT5ohRUE= sigs.k8s.io/yaml v1.6.0 h1:G8fkbMSAFqgEFgh4b1wmtzDnioxFCUgTZhlbj5P9QYs= sigs.k8s.io/yaml v1.6.0/go.mod h1:796bPqUfzR/0jLAl6XjHl3Ck7MiyVv8dbTdyT3/pMf4= diff --git a/integration_test.go b/integration_test.go index 8d7f5a0..8cab9f0 100644 --- a/integration_test.go +++ b/integration_test.go @@ -24,8 +24,10 @@ func TestInstallation(t *testing.T) { if err != nil { t.Fatalf("listing plugins: %v\n%s", err, output) } - if !slices.Contains(strings.Fields(string(output)), "dynapi") { - t.Fatalf("dynapi is missing from the built executable:\n%s", output) + for _, plugin := range []string{"dynapi", "dynupdate", "tsig"} { + if !slices.Contains(strings.Fields(string(output)), plugin) { + t.Fatalf("%s is missing from the built executable:\n%s", plugin, output) + } } corefile := filepath.Join(t.TempDir(), "Corefile") if err := os.WriteFile(corefile, []byte("example.org:1053 {\n dynapi\n}\n"), 0600); err != nil { From 4b1ab137b6f8c20abafaf9bd03d30c6b0df5a1fa Mon Sep 17 00:00:00 2001 From: Anton Antonov Date: Thu, 1 Oct 2026 19:56:55 +0300 Subject: [PATCH 2/2] feat: dynapi http api Signed-off-by: Anton Antonov --- .github/workflows/ci.yml | 2 + .gitignore | 2 + .golangci.yml | 136 ++++++ Makefile | 29 +- README.md | 195 ++++++-- cmd/openapi/main.go | 20 + .../0001-use-signed-dns-for-record-access.md | 41 ++ ...d-a-coredns-record-management-interface.md | 55 +++ docs/architecture.md | 81 ++++ examples/Corefile | 30 ++ examples/README.md | 28 ++ examples/client/api_error.go | 16 + examples/client/client.go | 81 ++++ examples/client/main.go | 78 ++++ examples/client/record_set.go | 7 + examples/example.org.zone | 4 + go.mod | 8 +- go.sum | 22 + http_fixture_test.go | 376 +++++++++++++++ http_integration_test.go | 285 ++++++++++++ integration_test.go | 24 +- main.go | 3 + openapi.yaml | 437 ++++++++++++++++++ plugins/dynapi/api_error.go | 11 + plugins/dynapi/backend.go | 224 +++++++++ plugins/dynapi/backend_test.go | 130 ++++++ plugins/dynapi/blocking_backend_test.go | 30 ++ plugins/dynapi/config.go | 173 +++++++ plugins/dynapi/config_test.go | 199 ++++++++ plugins/dynapi/connection_pool.go | 149 ++++++ plugins/dynapi/connection_pool_test.go | 219 +++++++++ plugins/dynapi/dns_name.go | 39 ++ plugins/dynapi/dynapi.go | 125 +++++ plugins/dynapi/error_response.go | 90 ++++ plugins/dynapi/errors.go | 26 ++ plugins/dynapi/handler.go | 148 ++++++ plugins/dynapi/handler_benchmark_test.go | 50 ++ plugins/dynapi/handler_test.go | 400 ++++++++++++++++ plugins/dynapi/http_response.go | 37 ++ plugins/dynapi/openapi.go | 36 ++ plugins/dynapi/openapi_test.go | 70 +++ plugins/dynapi/operations.go | 108 +++++ plugins/dynapi/pooled_connection.go | 18 + plugins/dynapi/record_backend.go | 9 + plugins/dynapi/record_input.go | 48 ++ plugins/dynapi/record_payload.go | 99 ++++ plugins/dynapi/record_resource.go | 6 + plugins/dynapi/record_scan.go | 93 ++++ plugins/dynapi/record_scan_benchmark_test.go | 49 ++ plugins/dynapi/record_scan_test.go | 46 ++ plugins/dynapi/record_set.go | 53 +++ plugins/dynapi/record_set_benchmark_test.go | 40 ++ plugins/dynapi/record_set_test.go | 54 +++ plugins/dynapi/setup.go | 32 +- plugins/dynapi/test_backend_test.go | 32 ++ port_test.go | 31 ++ 56 files changed, 4789 insertions(+), 45 deletions(-) create mode 100644 .golangci.yml create mode 100644 cmd/openapi/main.go create mode 100644 docs/adr/0001-use-signed-dns-for-record-access.md create mode 100644 docs/adr/0002-add-a-coredns-record-management-interface.md create mode 100644 docs/architecture.md create mode 100644 examples/Corefile create mode 100644 examples/README.md create mode 100644 examples/client/api_error.go create mode 100644 examples/client/client.go create mode 100644 examples/client/main.go create mode 100644 examples/client/record_set.go create mode 100644 examples/example.org.zone create mode 100644 http_fixture_test.go create mode 100644 http_integration_test.go create mode 100644 openapi.yaml create mode 100644 plugins/dynapi/api_error.go create mode 100644 plugins/dynapi/backend.go create mode 100644 plugins/dynapi/backend_test.go create mode 100644 plugins/dynapi/blocking_backend_test.go create mode 100644 plugins/dynapi/config.go create mode 100644 plugins/dynapi/config_test.go create mode 100644 plugins/dynapi/connection_pool.go create mode 100644 plugins/dynapi/connection_pool_test.go create mode 100644 plugins/dynapi/dns_name.go create mode 100644 plugins/dynapi/dynapi.go create mode 100644 plugins/dynapi/error_response.go create mode 100644 plugins/dynapi/errors.go create mode 100644 plugins/dynapi/handler.go create mode 100644 plugins/dynapi/handler_benchmark_test.go create mode 100644 plugins/dynapi/handler_test.go create mode 100644 plugins/dynapi/http_response.go create mode 100644 plugins/dynapi/openapi.go create mode 100644 plugins/dynapi/openapi_test.go create mode 100644 plugins/dynapi/operations.go create mode 100644 plugins/dynapi/pooled_connection.go create mode 100644 plugins/dynapi/record_backend.go create mode 100644 plugins/dynapi/record_input.go create mode 100644 plugins/dynapi/record_payload.go create mode 100644 plugins/dynapi/record_resource.go create mode 100644 plugins/dynapi/record_scan.go create mode 100644 plugins/dynapi/record_scan_benchmark_test.go create mode 100644 plugins/dynapi/record_scan_test.go create mode 100644 plugins/dynapi/record_set.go create mode 100644 plugins/dynapi/record_set_benchmark_test.go create mode 100644 plugins/dynapi/record_set_test.go create mode 100644 plugins/dynapi/test_backend_test.go create mode 100644 port_test.go diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0520c45..852dd44 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -35,6 +35,8 @@ jobs: git diff --exit-code -- go.mod go.sum - name: Check installation in CoreDNS run: make integration + - name: Benchmark plugin + run: make benchmark dependency-review: if: github.event_name == 'pull_request' diff --git a/.gitignore b/.gitignore index 66cfb31..7172546 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,5 @@ coredns.exe coredns.exe~ Corefile /build/ +!/examples/Corefile +/examples/example.org.db* diff --git a/.golangci.yml b/.golangci.yml new file mode 100644 index 0000000..0bc9f89 --- /dev/null +++ b/.golangci.yml @@ -0,0 +1,136 @@ +version: "2" + +run: + timeout: 5m + build-tags: [integration, grpcnotrace] + tests: true + +linters: + default: all + disable: + - exhaustruct # Superseded by exhaustruct_v5. + - wsl # Superseded by wsl_v5. + - gomodguard # Superseded by gomodguard_v2. + - depguard # No import allowlist is defined for this plugin. + - goheader # CoreDNS does not require a license header in each Go file. + - noinlineerr # Keep standard Go scoped error checks. + - testpackage # Unit tests exercise private plugin configuration and handlers. + settings: + cyclop: + max-complexity: 10 + package-average: 5 + decorder: + dec-order: [const, type, var, func] + disable-dec-order-check: false + disable-init-func-first-check: false + disable-dec-num-check: false + disable-type-dec-num-check: false + disable-const-dec-num-check: false + disable-var-dec-num-check: false + errcheck: + exclude-functions: + - '(net.Conn).Close' # Socket cleanup cannot change a completed or cancelled transfer. + exhaustruct_v5: + explicit-mode: true + enforce-patterns: ['^github\.com/coredns/dynapi/.*'] + allow-empty: true + allow-empty-returns: true + allow-empty-declarations: true + funcorder: + constructor: true + struct-method: true + function: true + funlen: + lines: 60 + statements: 40 + ignore-comments: false + gocognit: + min-complexity: 15 + gocritic: + enabled-tags: [diagnostic, style, performance, experimental, opinionated] + disabled-checks: [unnamedResult] # nonamedreturns owns return-value style. + gocyclo: + min-complexity: 10 + govet: + enable-all: true + ireturn: + allow: + - anon + - error + - empty + - stdlib + - generic + - '^github\.com/miekg/dns\.RR$' + - '^github\.com/coredns/coredns/plugin\.Handler$' + lll: + line-length: 120 + maintidx: + under: 30 + nestif: + min-complexity: 4 + nolintlint: + require-explanation: true + require-specific: true + prealloc: + for-loops: true + revive: + enable-all-rules: true + directives: + - name: specify-disable-reason + rules: + - name: receiver-naming + - name: cognitive-complexity + arguments: [15] + - name: line-length-limit + arguments: [120] + - name: multiline-if-init + disabled: true # golines wraps calls in standard Go if statements. + - name: empty-lines + disabled: true # wsl_v5 owns blank line placement. + - name: function-length + disabled: true # funlen owns function length limits. + - name: add-constant + disabled: true # mnd checks numeric literals without flagging protocol strings. + wrapcheck: + ignore-sig-regexps: + - 'caddyfile.Dispenser.*(ArgErr|Err)' + - 'plugin.(Error|NextOrFailure)' + staticcheck: + checks: [all] + varnamelen: + ignore-names: [err, ok, ctx, id, ip, rr, r, w, t] + whitespace: + multi-if: true + multi-func: false # gofumpt removes blank lines at the start of functions. + wsl_v5: + default: all + disable: [leading-whitespace] # whitespace requires space after multiline conditions. + exclusions: + presets: [] + rules: + - path: '(^main\.go$|plugins/dynapi/setup\.go$)' + linters: [gochecknoinits] + text: 'init' + - path: 'plugins/dynapi/setup\.go$' + linters: [gochecknoglobals] + text: 'log' + - path: '_test\.go$' + linters: [exhaustruct_v5, goconst, mnd] + text: '.*' # Test fixtures intentionally use literal values and partial states. + - path: 'plugins/dynapi/config_test\.go$' + linters: [gosec] + text: 'G101' # Public test credentials are not real secrets. + +formatters: + enable: [gci, gofumpt, golines] + settings: + gci: + sections: [standard, default, localmodule] + gofumpt: + module-path: github.com/coredns/dynapi + golines: + max-len: 100 + +issues: + max-issues-per-linter: 0 + max-same-issues: 0 diff --git a/Makefile b/Makefile index 0c6c438..3685588 100644 --- a/Makefile +++ b/Makefile @@ -4,8 +4,10 @@ BINARY ?= coredns GITCOMMIT ?= development GO ?= go BUILDOPTS ?= -tags=grpcnotrace +GOLANGCI_LINT_VERSION := v2.14.0 +GOLANGCI_LINT ?= $(GO) run github.com/golangci/golangci-lint/v2/cmd/golangci-lint@$(GOLANGCI_LINT_VERSION) -.PHONY: all coredns build format format-check test vet verify integration +.PHONY: all coredns build format format-check test vet lint verify integration openapi openapi-check benchmark all: coredns @@ -15,10 +17,10 @@ coredns: build: coredns format: - gofmt -w . + $(GOLANGCI_LINT) fmt format-check: - @test -z "$$(gofmt -l .)" || { gofmt -l .; exit 1; } + $(GOLANGCI_LINT) fmt --diff test: $(GO) test -race $(BUILDOPTS) ./... @@ -26,10 +28,25 @@ test: vet: $(GO) vet $(BUILDOPTS) ./... -verify: format-check +lint: + $(GOLANGCI_LINT) config verify + $(GOLANGCI_LINT) run + +verify: format-check lint openapi-check $(GO) build $(BUILDOPTS) ./... $(GO) vet $(BUILDOPTS) ./... $(GO) test -race $(BUILDOPTS) ./... -integration: coredns - COREDNS_DYNAPI_BINARY="$(abspath $(BINARY))" $(GO) test -race -tags=integration,grpcnotrace -run '^TestInstallation$$' -count=1 . +integration: + $(GO) build -race $(BUILDOPTS) -o $(BINARY) . + COREDNS_DYNAPI_BINARY="$(abspath $(BINARY))" $(GO) test -race -tags=integration,grpcnotrace -run '^Test(Installation|HTTPAPI)$$' -count=1 . + +openapi: + @$(GO) run $(BUILDOPTS) ./cmd/openapi > openapi.yaml.tmp && mv openapi.yaml.tmp openapi.yaml + +openapi-check: + @set -eu; document=$$(mktemp); trap 'rm -f "$$document"' EXIT; \ + $(GO) run $(BUILDOPTS) ./cmd/openapi > "$$document"; diff -u openapi.yaml "$$document" + +benchmark: + $(GO) test $(BUILDOPTS) -run '^$$' -bench . -benchmem ./plugins/dynapi diff --git a/README.md b/README.md index 8887c34..ebac485 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# dynapi (work-in-progress) +# dynapi ## Name @@ -6,14 +6,13 @@ ## Description -*dynapi* is an external CoreDNS plugin under development. The planned API lets -applications read, replace, and delete A and AAAA record sets using JSON over -HTTP. The design builds on *dynupdate*, which owns DNS answers, record -validation, persistence, and update transactions. +*dynapi* is an external CoreDNS plugin. Applications can read, +replace, and delete A and AAAA record sets using an authenticated JSON HTTP API. +The plugin sends signed DNS UPDATE transactions to *dynupdate* over loopback +TCP. Dynupdate owns authoritative answers, permissions, persistence, and +atomic updates. The *tsig* plugin authenticates the DNS requests. -This repository currently contains the plugin structure and a development -CoreDNS executable. The HTTP API is not implemented. Enabling `dynapi` returns -an explicit startup error. +See [examples](examples/README.md) for a starter Corefile and annotated Go client. See [CONTRIBUTING.md](CONTRIBUTING.md) for contribution guidelines. @@ -21,9 +20,35 @@ Contributions follow the [CoreDNS code of conduct](CODE_OF_CONDUCT.md) and [Apache-2.0 license](LICENSE). Report security concerns through the [CoreDNS security policy](SECURITY.md). +## Architecture + +Dynapi runs inside CoreDNS and exposes a separate HTTP listener. It validates +requests and sends signed DNS requests over loopback TCP to the same server. +Dynupdate owns the records, write permissions, and persistence. + +```mermaid +flowchart LR + http[HTTP client] --> api + dns[DNS client] --> listener + subgraph CoreDNS + api[dynapi] -->|Signed AXFR / UPDATE over pooled TCP| listener[DNS listener] + listener --> plugins[tsig / transfer / dynupdate] + plugins --> database[(Zone database)] + end +``` + +GET reads a zone snapshot through AXFR. PUT and DELETE use DNS UPDATE +transactions. Ordinary DNS queries continue through the CoreDNS plugin chain. +The HTTP bearer token and DNS TSIG key are separate credentials. + +See the [architecture guide](docs/architecture.md) for request flow and limits, +and [ADR 0001](docs/adr/0001-use-signed-dns-for-record-access.md) for the design +decision and trade-offs. + ## Installation -For development, build the CoreDNS executable from this repository: +Build with Go 1.27 or newer. For development, build the CoreDNS executable +from this repository: ```sh make # Build CoreDNS with dynapi, dynupdate, and tsig. @@ -50,13 +75,14 @@ yet. `plugin.cfg.yaml` records the intended placement for external build tooling. The root executable sets the same placement in Go. The development executable includes `dynupdate` and `tsig` from a pinned -CoreDNS revision. The HTTP adapter is not implemented yet. +CoreDNS revision. Both the HTTP listener and DNS bridge use loopback addresses. -### Configure the DNS backend +### Configure the API and DNS backend `dynupdate` serves the records and persists changes. `tsig` authenticates DNS UPDATE requests before dynupdate applies its permission rules. TSIG authenticates -DNS clients. The future HTTP API will have its own authentication. +DNS clients. HTTP clients authenticate with a separate bearer token. GET uses +a signed AXFR transfer to read an exact record set from one zone snapshot. Create `example.org.zone` with the initial zone records: @@ -72,47 +98,162 @@ Create a `Corefile`: ```corefile example.org:1053 { # Serve the example zone on an unprivileged port. bind 127.0.0.1 # Keep this development server on loopback. + + dynapi 127.0.0.1:8080 { # Listen for authenticated HTTP requests on loopback. + token_env DYNAPI_TOKEN # Read the HTTP bearer token from the environment. + + upstream 127.0.0.1:1053 # Send DNS requests to this server block over TCP. + + identity update-key.example.org. # Use this key's dynupdate write permissions. + secret_env DYNAPI_TSIG_SECRET # Sign DNS requests with the shared TSIG key. + } + tsig { # Authenticate signed DNS requests. secret update-key.example.org. {$DYNAPI_TSIG_SECRET} # Read the shared key from the environment. + require_opcode UPDATE # Reject unsigned DNS updates. - } # End TSIG configuration. + require AXFR # Require authentication for the API's zone reads. + } + + transfer { # Let the API read the stored zone without wildcard expansion. + to 127.0.0.1 # Permit zone transfers only to loopback clients. + } + dynupdate { # Serve the writable authoritative zone. file example.org.zone # Seed a new database with the initial zone. database example.org.db # Preserve committed changes across restarts. + allow update-key.example.org. host.example.org. A AAAA # Restrict this key to one host's addresses. - } # End writable-zone configuration. -} # End the server block. + } +} ``` Start the DNS backend: ```sh +export DYNAPI_TOKEN="$(openssl rand -hex 32)" # Generate the HTTP bearer token. export DYNAPI_TSIG_SECRET="$(openssl rand -base64 32)" # Generate a private shared key for this example. ./coredns -conf Corefile # Start the configured DNS server. ``` -Keep the key if DNS update clients must continue using it after a restart. -The `dynapi` directive is omitted because the HTTP API is not implemented yet. +Keep both credentials when clients must continue using them after a restart. +The bearer token permits reads throughout this zone. Writes must match the +TSIG identity's `dynupdate allow` rules. Restart CoreDNS to change configuration. +This version rejects Corefile reloads while keeping the existing service running. ## Syntax -The Corefile syntax is still being designed. The only recognized directive -in this scaffold is `dynapi`, and it refuses startup. Backend configuration -and a complete example will follow when the API is implemented. +```corefile +dynapi [ADDRESS] { + token_env VARIABLE # Or token TOKEN to set the value directly. + upstream ADDRESS + max_requests 32 # Limit active HTTP requests and backend DNS connections. + identity KEY + secret_env VARIABLE # Or secret BASE64_SECRET to set the value directly. +} +``` + +The HTTP address defaults to `127.0.0.1:8080`. Both addresses must be literal +loopback IPs with ports. `upstream` must point to the DNS listener in the same +server block, which must contain `dynupdate`, `tsig`, and `transfer` for one zone. +DNS-over-TCP must be enabled. -## Examples +Configure exactly one form of each credential. Literal credentials are +sensitive, so protect the Corefile. Tokens must contain at least 32 characters +without whitespace. TSIG secrets must be base64 encoding at least 16 bytes. +The key uses HMAC-SHA256. -The planned HTTP resource is: +## API + +See [openapi.yaml](openapi.yaml) for the generated API specification, schemas, +and responses. Run `make openapi` after changing the operations or Go models. +`make verify` checks that the specification is current. ```text /v1/zones/{zone}/records/{name}/{type} ``` -A PUT request replaces the complete record set. Its proposed body is: +Names must be full names within the configured zone. A trailing dot is optional. +Wildcards and escaped names are not accepted. Types are A and AAAA. + +- GET returns the exact stored set as `{"ttl":60,"addresses":["192.0.2.10"]}`. +- PUT replaces the complete set atomically and returns its normalized payload. +- DELETE removes that type's set and returns 204, including when it is absent. -```json -{"ttl":60,"addresses":["192.0.2.10","192.0.2.11"]} +PUT requires `Content-Type: application/json`, an explicit TTL from 0 to +2147483647, and 1–256 addresses of the requested family. Duplicate addresses +are removed. IPv4-mapped and scoped IPv6 addresses are rejected. Request bodies +are limited to 64 KiB. JSON field names are case-sensitive. Duplicate keys, +unknown fields, and invalid UTF-8 are rejected. TTL controls DNS caching and +does not expire records. + +```sh +curl -X PUT http://127.0.0.1:8080/v1/zones/example.org/records/host.example.org/A \ + -H "Authorization: Bearer $DYNAPI_TOKEN" \ + -H 'Content-Type: application/json' \ + -d '{"ttl":60,"addresses":["192.0.2.10","192.0.2.11"]}' + +curl http://127.0.0.1:8080/v1/zones/example.org/records/host.example.org/A \ + -H "Authorization: Bearer $DYNAPI_TOKEN" + +curl -X DELETE http://127.0.0.1:8080/v1/zones/example.org/records/host.example.org/A \ + -H "Authorization: Bearer $DYNAPI_TOKEN" +``` + +Errors use `{"code":"invalid_address","error":"description"}`. Clients should +branch on the stable `code`, rather than the message. OpenAPI lists the codes +allowed for each status. Missing authentication returns 401, +malformed JSON or unknown fields 400, invalid values 422, missing sets 404, +unsupported methods 405, CNAME conflicts 409, oversized bodies 413, and +unsupported content types 415. Dynupdate +permission or capacity rejection returns 403. Transport and backend failures +return 502. Requests above `max_requests` return 503 immediately. The default +is 32. Set a positive integer to change it. The same limit bounds the pool of +reusable backend DNS connections. It does not limit idle HTTP connections or +requests per second. + +GET scans a full zone transfer, bounded to 10001 records including the repeated +SOA, and 16 MiB of DNS records. This first version is intended for small zones. +It does not expand wildcards or follow CNAMEs. Existing sets with mixed TTLs +return the lowest TTL. Reads do not reuse dynupdate's write permission rules. + +A failed connection or lost HTTP response can follow a committed write. +There are no automatic retries or conditional writes. External DNS caches +can keep older answers until their TTL expires. + +## Benchmarks + +Run the allocation and timing benchmarks: + +```sh +make benchmark +``` + +Local results with Go 1.27.0: + +```text +$ make benchmark +go test -tags=grpcnotrace -run '^$' -bench . -benchmem ./plugins/dynapi +goos: darwin +goarch: arm64 +pkg: github.com/coredns/dynapi/plugins/dynapi +cpu: Apple M5 Pro +BenchmarkServeHTTP/GET-18 643478 1846 ns/op 7191 B/op 36 allocs/op +BenchmarkServeHTTP/PUT-18 530602 2210 ns/op 7329 B/op 40 allocs/op +BenchmarkServeHTTP/DELETE-18 667378 1691 ns/op 7022 B/op 32 allocs/op +BenchmarkServeHTTP/unauthorized-18 884338 1415 ns/op 6831 B/op 29 allocs/op +BenchmarkServeHTTP/invalid_address-18 465476 2299 ns/op 7459 B/op 42 allocs/op +BenchmarkAdd/lowercase-18 171843 6861 ns/op 32 B/op 2 allocs/op +BenchmarkAdd/uppercase-18 175131 6856 ns/op 32 B/op 2 allocs/op +BenchmarkNormalize/A/1-18 44136915 26.78 ns/op 16 B/op 1 allocs/op +BenchmarkNormalize/A/256-18 144397 8308 ns/op 4864 B/op 1 allocs/op +BenchmarkNormalize/AAAA/1-18 20398362 58.08 ns/op 32 B/op 2 allocs/op +BenchmarkNormalize/AAAA/256-18 67185 17841 ns/op 4880 B/op 2 allocs/op +PASS +ok github.com/coredns/dynapi/plugins/dynapi 13.401s ``` -GET reads the exact stored record set. DELETE removes it. The initial types -are A and AAAA. TTL controls DNS caching and does not expire or delete records. +These benchmarks include request construction, authentication, routing, and +response encoding. They use one address and an in-memory backend, so they +exclude network, DNS transfer, and disk costs. Results vary by machine. +Normalization benchmarks also cover 1 and 256 addresses for both A and AAAA. diff --git a/cmd/openapi/main.go b/cmd/openapi/main.go new file mode 100644 index 0000000..22b9a13 --- /dev/null +++ b/cmd/openapi/main.go @@ -0,0 +1,20 @@ +// Package main exports dynapi's OpenAPI document without starting CoreDNS. +package main + +import ( + "log" + "os" + + "github.com/coredns/dynapi/plugins/dynapi" +) + +func main() { + document, err := dynapi.GenerateOpenAPI() + if err != nil { + log.Fatal(err) + } + + if _, err := os.Stdout.Write(document); err != nil { + log.Fatal(err) + } +} diff --git a/docs/adr/0001-use-signed-dns-for-record-access.md b/docs/adr/0001-use-signed-dns-for-record-access.md new file mode 100644 index 0000000..5556153 --- /dev/null +++ b/docs/adr/0001-use-signed-dns-for-record-access.md @@ -0,0 +1,41 @@ +# ADR 0001: Use signed DNS requests for record access + +Status: Accepted + +Date: 2026-10-01 + +## Decision + +Run dynapi inside CoreDNS. Read exact stored sets through signed AXFR. Replace +and delete sets atomically through signed DNS UPDATE over loopback TCP. +Dynupdate stores the zone and enforces write permissions. HTTP clients use a +bearer token. DNS requests use a separate TSIG key. + +AXFR reads stored records without expanding wildcards or following CNAMEs. PUT +checks for a conflicting CNAME in the same transaction. Reads follow transfer +permissions, which are separate from write permissions. + +Use `net/http` and generate OpenAPI from shared operation definitions and Go +models. The [backend](../../plugins/dynapi/backend.go) sends the DNS requests. + +## Rejected + +- A separate dynapi record store would duplicate persistence, permissions, and + authoritative DNS behavior. +- Direct calls into dynupdate would require an upstream interface with defined + transactions, permissions, and lifecycle rules. +- Ordinary DNS queries cannot reliably return exact stored sets because they + can expand wildcards or return aliases. +- A web framework adds machinery that three HTTP operations do not need. + +## Future + +GET scans the whole zone. Reads and writes reuse an exclusive TCP connection +from a pool bounded by `max_requests`, which defaults to 32. Failed connections +are discarded. Sustained load tests must guide further tuning. + +Conditional writes and safe Corefile reloads remain open. Dynapi does not retry +writes because a change may commit before its response is lost. + +[ADR 0002](0002-add-a-coredns-record-management-interface.md) outlines an upstream +management interface that could replace the DNS bridge and dynupdate dependency. diff --git a/docs/adr/0002-add-a-coredns-record-management-interface.md b/docs/adr/0002-add-a-coredns-record-management-interface.md new file mode 100644 index 0000000..c698144 --- /dev/null +++ b/docs/adr/0002-add-a-coredns-record-management-interface.md @@ -0,0 +1,55 @@ +# ADR 0002: Add a CoreDNS record management interface + +Status: Proposed + +Date: 2026-10-01 + +## Decision + +Add a Go interface to CoreDNS for reading, replacing, and deleting stored record +sets. Dynapi would call the zone provider directly. This would avoid loopback +DNS connections, full-zone transfers, and TSIG credentials in dynapi. + +```mermaid +flowchart LR + http[HTTP client] --> api[dynapi] + api --> management[CoreDNS management interface] + management --> provider[Writable zone provider] + dns[DNS client] --> listener[CoreDNS DNS listener] + listener --> provider + provider --> database[(Database)] +``` + +The zone provider stores the records and serves them through DNS. It must apply +changes atomically, persist them, check permissions, and reject CNAME conflicts. +CoreDNS would find providers by zone and manage their startup and shutdown. +Reads would return stored sets without expanding DNS answers. + +Removing dynupdate also requires a replacement writable zone provider. The +interface alone cannot store records. Dynupdate could implement it first while +dynapi switches to the shared interface. + +## Rejected + +- Adding only a public dynupdate method keeps dynapi tied to that plugin. +- Giving dynapi its own store creates separate ownership of HTTP mutations and + DNS answers. +- Allowing writes through every DNS plugin would treat read-only and computed + answers as stored records. +- Using only DNS UPDATE and AXFR keeps the DNS transport and whole-zone read + costs, even with connection pooling. + +## Future + +Agree the interface upstream before changing dynapi. Start with exact A and +AAAA sets and explicit errors. Define permissions, when a write is durable, +and how cached answers are invalidated. + +Define how reloads replace providers without sending requests to a retired +zone. Conditional writes need record revisions to prevent clients from +overwriting each other. + +Decide whether the writable provider ships with CoreDNS or as an optional +plugin. Keep the HTTP API unchanged during migration. Use the signed DNS adapter +in [ADR 0001](0001-use-signed-dns-for-record-access.md) until the interface and +provider are available. diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 0000000..39c02d6 --- /dev/null +++ b/docs/architecture.md @@ -0,0 +1,81 @@ +# Architecture + +Dynapi manages A and AAAA records in one zone through HTTP. It runs inside +CoreDNS. Dynupdate stores the records and serves DNS answers. Dynapi passes +ordinary DNS requests to the next plugin. + +```mermaid +flowchart TD + client[HTTP client] --> auth[Authentication and request limit] + auth --> validation[Routing and validation] + validation --> pool[DNS connection pool] + pool -->|Signed DNS over loopback TCP| listener[CoreDNS DNS listener] + listener --> tsig[TSIG authentication] + tsig -->|GET: AXFR| transfer[transfer] + transfer --> zone[dynupdate zone] + tsig -->|PUT / DELETE: UPDATE| zone + zone --> database[(Database)] +``` + +## Request flow + +The [HTTP handler](../plugins/dynapi/handler.go) checks the bearer token before +accepting work. It uses `net/http.ServeMux`, checks the zone, record name, and +address family, and sets a five-second deadline. PUT uses `encoding/json/v2` +and normalizes, sorts, and deduplicates addresses. + +The [DNS backend](../plugins/dynapi/backend.go) signs requests with the configured +TSIG key using HMAC-SHA256: + +- GET reads an AXFR zone snapshot and selects the exact name and type. It sorts + addresses and returns the lowest TTL without following CNAMEs or expanding + wildcards. +- PUT removes the old set and adds the new set in one DNS UPDATE transaction. + The transaction rejects an existing CNAME. +- DELETE removes only the requested type. Deleting an absent set succeeds. + +TSIG authenticates the DNS identity. Dynupdate checks its write permissions and +commits changes to the database. Transfer rules control reads. The HTTP token +allows reads across the zone, even when write permissions cover only some names. + +## Bounds and failures + +The handler accepts up to `max_requests` authenticated requests at once and +returns 503 for additional requests. The default is 32 and must be a positive +integer. This bounds backend DNS connections, not idle HTTP connections. We +have not established the best default. There is no requests-per-second quota. PUT accepts up to 64 KiB and 1 to 256 addresses. +AXFR scans stop above 10001 records, including the repeated SOA, or 16 MiB. + +Connection failures return 502. A write may commit before its response is lost, +so dynapi does not retry updates. Clients can use stable error codes. Concurrent +writes can overwrite each other. DNS caches can keep old answers until their +TTL expires. + +Reads and writes borrow TCP connections from a pool bounded by `max_requests`. +Each connection serves one operation at a time. Dynapi returns it only after +reading the full response. It discards failed or canceled connections and +retires connections before the DNS listener's idle timeout or query limit. +Shutdown closes idle and borrowed connections after HTTP draining. + +GET still scans the full zone. Sustained load tests must guide concurrency +changes. Handler benchmarks do not include the DNS or storage costs. + +## Lifecycle and API contract + +[Startup](../plugins/dynapi/dynapi.go) requires dynupdate for the zone, plus tsig +and transfer. The upstream must match the same server block's DNS listener. +Both addresses must be literal loopback IPs. Shutdown allows five seconds for +HTTP requests to finish, then forces the server closed if needed. Dynapi rejects +Corefile reloads and keeps the running service. Configuration changes require +a restart. + +[Operation definitions](../plugins/dynapi/operations.go) register routes and +generate OpenAPI from Go models. `make openapi` updates the specification. +`make verify` checks it for drift and runs lint, build, vet, and race checks. +Handlers and models still validate requests and need behavior tests. + +`make integration` runs CoreDNS with race detection. It checks HTTP changes +against UDP and TCP DNS replies, persistence after restart, permissions, CNAME +conflicts, concurrent clients, and rejected reloads. See +[ADR 0001](adr/0001-use-signed-dns-for-record-access.md) for why the adapter uses +the DNS protocol. diff --git a/examples/Corefile b/examples/Corefile new file mode 100644 index 0000000..c1f79ee --- /dev/null +++ b/examples/Corefile @@ -0,0 +1,30 @@ +example.org:1053 { # Use an unprivileged DNS port for this local starter. + bind 127.0.0.1 # Keep DNS and the bridge on loopback. + + dynapi 127.0.0.1:8080 { # Accept HTTP requests from local clients. + token_env DYNAPI_TOKEN # Use a separate credential for HTTP clients. + + upstream 127.0.0.1:1053 # Send signed DNS requests to this server block. + + identity update-key.example.org. # Match the TSIG key and write permissions below. + secret_env DYNAPI_TSIG_SECRET # Share this DNS credential with tsig. + } + + tsig { + secret update-key.example.org. {$DYNAPI_TSIG_SECRET} # Authenticate the bridge's DNS requests. + + require_opcode UPDATE # Reject unsigned changes. + require AXFR # Protect the zone snapshots used by GET. + } + + transfer { + to 127.0.0.1 # Allow zone reads only from local clients. + } + + dynupdate { + file example.org.zone # Seed a new database with this starter zone. + database example.org.db # Keep committed records across process restarts. + + allow update-key.example.org. host.example.org. A AAAA # Limit writes to the example host. + } +} diff --git a/examples/README.md b/examples/README.md new file mode 100644 index 0000000..e08642a --- /dev/null +++ b/examples/README.md @@ -0,0 +1,28 @@ +# Starter example + +Build from the repository root, then run the server from this directory so the +zone and database paths resolve here: + +```sh +make +export DYNAPI_TOKEN="$(openssl rand -hex 32)" # Share this HTTP token with the client. +export DYNAPI_TSIG_SECRET="$(openssl rand -base64 32)" # Keep this DNS key on the server. +cd examples +../coredns -conf Corefile +``` + +In another terminal, export the same `DYNAPI_TOKEN` and run from the repository root: + +```sh +go run ./examples/client +``` + +The annotated [Go client](client/main.go) uses only the standard library. It +handles a missing set by its stable error code, replaces `host.example.org`'s +A records, reads them back, then deletes them. Its requests have deadlines and +it does not automatically retry writes. A failed response can follow a committed +change. + +The [Corefile](Corefile) limits writes to that example host and exposes HTTP on +port 8080 and DNS on port 1053, both on loopback. The database persists in +`examples/example.org.db`. Keep the credentials when restarting the server. diff --git a/examples/client/api_error.go b/examples/client/api_error.go new file mode 100644 index 0000000..d37557b --- /dev/null +++ b/examples/client/api_error.go @@ -0,0 +1,16 @@ +package main + +import "fmt" + +// apiError preserves the stable code for programmatic decisions. +// Keep unknown codes as errors so newer server responses remain safe to handle. +type apiError struct { + Code string `json:"code"` + Message string `json:"error"` + + Status int `json:"-"` +} + +func (apiError *apiError) Error() string { + return fmt.Sprintf("HTTP %d (%s): %s", apiError.Status, apiError.Code, apiError.Message) +} diff --git a/examples/client/client.go b/examples/client/client.go new file mode 100644 index 0000000..5c884b5 --- /dev/null +++ b/examples/client/client.go @@ -0,0 +1,81 @@ +package main + +import ( + "bytes" + "context" + "encoding/json/v2" + "fmt" + "log" + "net/http" +) + +type client struct { + http *http.Client + endpoint string + token string +} + +func (client *client) request( + ctx context.Context, + method string, + set *recordSet, +) (recordSet, error) { + var body []byte + + if set != nil { + encoded, err := json.Marshal(set) + if err != nil { + return recordSet{}, fmt.Errorf("encode record set: %w", err) + } + + body = encoded + } + + request, err := http.NewRequestWithContext(ctx, method, client.endpoint, bytes.NewReader(body)) + if err != nil { + return recordSet{}, fmt.Errorf("create HTTP request: %w", err) + } + + request.Header.Set("Authorization", "Bearer "+client.token) + + if set != nil { + request.Header.Set("Content-Type", "application/json") + } + + // Do not retry failed writes automatically. A lost response can follow a commit. + response, err := client.http.Do(request) + if err != nil { + return recordSet{}, fmt.Errorf("send HTTP request; a write may have committed: %w", err) + } + + defer func() { + if err := response.Body.Close(); err != nil { + log.Printf("close response body: %v", err) + } + }() + + return client.decode(response) +} + +func (*client) decode(response *http.Response) (recordSet, error) { + if response.StatusCode == http.StatusNoContent { + return recordSet{}, nil + } + + if response.StatusCode != http.StatusOK { + failure := &apiError{Code: "", Message: "", Status: response.StatusCode} + if err := json.UnmarshalRead(response.Body, failure); err != nil { + return recordSet{}, fmt.Errorf("decode HTTP %d error: %w", response.StatusCode, err) + } + + return recordSet{}, failure + } + + var set recordSet + + if err := json.UnmarshalRead(response.Body, &set); err != nil { + return recordSet{}, fmt.Errorf("decode record set: %w", err) + } + + return set, nil +} diff --git a/examples/client/main.go b/examples/client/main.go new file mode 100644 index 0000000..a0b630d --- /dev/null +++ b/examples/client/main.go @@ -0,0 +1,78 @@ +// Package main demonstrates authenticated record management with net/http. +// Run it against examples/Corefile with DYNAPI_TOKEN set in the environment. +package main + +import ( + "context" + "errors" + "fmt" + "log" + "net/http" + "os" + "time" +) + +const ( + requestTimeout = 5 * time.Second + exampleTimeout = 20 * time.Second + exampleTTL = 60 +) + +func main() { + token := os.Getenv("DYNAPI_TOKEN") + if token == "" { + log.Fatal("DYNAPI_TOKEN is required") + } + + client := &client{ + http: &http.Client{Timeout: requestTimeout}, + endpoint: "http://127.0.0.1:8080/v1/zones/example.org/records/host.example.org/A", + token: token, + } + ctx, cancel := context.WithTimeout(context.Background(), exampleTimeout) + err := run(ctx, client) + + cancel() + + if err != nil { + log.Fatal(err) + } +} + +func run(ctx context.Context, client *client) error { + // A missing set is expected on the first run. Messages are for people. + // The stable code lets clients distinguish a missing set from a missing zone. + _, err := client.request(ctx, http.MethodGet, nil) + if err != nil { + failure, ok := errors.AsType[*apiError](err) + if !ok || failure.Code != "record_set_not_found" { + return err + } + } + + // PUT replaces every A address for this name. It leaves AAAA records intact. + desired := &recordSet{TTL: exampleTTL, Addresses: []string{"192.0.2.10", "192.0.2.11"}} + + stored, err := client.request(ctx, http.MethodPut, desired) + if err != nil { + return err + } + + log.Printf("PUT: ttl=%d addresses=%v", stored.TTL, stored.Addresses) + + stored, err = client.request(ctx, http.MethodGet, nil) + if err != nil { + return err + } + + log.Printf("GET: ttl=%d addresses=%v", stored.TTL, stored.Addresses) + + // DELETE succeeds even if the set is absent. A successful response has no body. + if _, err := client.request(ctx, http.MethodDelete, nil); err != nil { + return fmt.Errorf("delete example record set: %w", err) + } + + log.Print("DELETE: record set removed") + + return nil +} diff --git a/examples/client/record_set.go b/examples/client/record_set.go new file mode 100644 index 0000000..c46aa8f --- /dev/null +++ b/examples/client/record_set.go @@ -0,0 +1,7 @@ +package main + +// recordSet is the complete set of addresses for one name and DNS record type. +type recordSet struct { + Addresses []string `json:"addresses"` + TTL uint32 `json:"ttl"` +} diff --git a/examples/example.org.zone b/examples/example.org.zone new file mode 100644 index 0000000..d58118b --- /dev/null +++ b/examples/example.org.zone @@ -0,0 +1,4 @@ +$ORIGIN example.org. ; Resolve relative names within this zone. +@ 60 IN SOA ns.example.org. hostmaster.example.org. 1 3600 600 86400 60 ; Start the authoritative zone. +@ 60 IN NS ns.example.org. ; Declare its nameserver. +ns 60 IN A 127.0.0.1 ; Point the example nameserver at loopback. diff --git a/go.mod b/go.mod index 42ce9cb..4e25cae 100644 --- a/go.mod +++ b/go.mod @@ -1,10 +1,13 @@ module github.com/coredns/dynapi -go 1.26.0 +go 1.27.0 require ( github.com/coredns/caddy v1.1.4 github.com/coredns/coredns v1.14.8-0.20260930135946-f44a91377a0b + github.com/miekg/dns v1.1.73 + github.com/swaggest/jsonschema-go v0.3.78 + github.com/swaggest/openapi-go v0.2.61 ) require ( @@ -121,7 +124,6 @@ require ( github.com/mdlayher/socket v0.6.0 // indirect github.com/mdlayher/vsock v1.3.0 // indirect github.com/mholt/acmez/v3 v3.1.6 // indirect - github.com/miekg/dns v1.1.73 // indirect github.com/minio/simdjson-go v0.4.5 // indirect github.com/mitchellh/go-homedir v1.1.0 // indirect github.com/mitchellh/mapstructure v1.5.1-0.20231216201459-8508981c8b6c // indirect @@ -154,6 +156,7 @@ require ( github.com/secure-systems-lab/go-securesystemslib v0.11.0 // indirect github.com/shirou/gopsutil/v4 v4.26.6 // indirect github.com/spf13/pflag v1.0.10 // indirect + github.com/swaggest/refl v1.4.0 // indirect github.com/tinylib/msgp v1.6.4 // indirect github.com/tklauser/go-sysconf v0.3.16 // indirect github.com/tklauser/numcpus v0.11.0 // indirect @@ -201,6 +204,7 @@ require ( google.golang.org/protobuf v1.36.12 // indirect gopkg.in/evanphx/json-patch.v4 v4.13.0 // indirect gopkg.in/inf.v0 v0.9.1 // indirect + gopkg.in/yaml.v2 v2.4.0 // indirect k8s.io/api v0.37.0 // indirect k8s.io/apimachinery v0.37.0 // indirect k8s.io/client-go v0.37.0 // indirect diff --git a/go.sum b/go.sum index 45730e8..c2ab877 100644 --- a/go.sum +++ b/go.sum @@ -102,6 +102,10 @@ github.com/aws/smithy-go v1.28.1 h1:R/nXH00c8qcfCzQVELtRw+eLQWtzv+VAIEFJ1/xxXlQ= github.com/aws/smithy-go v1.28.1/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= +github.com/bool64/dev v0.2.43 h1:yQ7qiZVef6WtCl2vDYU0Y+qSq+0aBrQzY8KXkklk9cQ= +github.com/bool64/dev v0.2.43/go.mod h1:iJbh1y/HkunEPhgebWRNcs8wfGq7sjvJ6W5iabL8ACg= +github.com/bool64/shared v0.1.5 h1:fp3eUhBsrSjNCQPcSdQqZxxh9bBwrYiZ+zOKFkM0/2E= +github.com/bool64/shared v0.1.5/go.mod h1:081yz68YC9jeFB3+Bbmno2RFWvGKv1lPKkMP6MHJlPs= github.com/caddyserver/certmagic v0.25.4 h1:8eIXh0HC3MsGnNo8One+BCxMGTbe5zb/oz+2KsxBFQg= github.com/caddyserver/certmagic v0.25.4/go.mod h1:YVs43D5+H/Dckt4bTga1KSO/xYfFBfVZainGDywYPAA= github.com/caddyserver/zerossl v0.1.5 h1:dkvOjBAEEtY6LIGAHei7sw2UgqSD6TrWweXpV7lvEvE= @@ -254,6 +258,8 @@ github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= github.com/hashicorp/nomad/api v0.0.0-20250909143645-a3b86c697f38 h1:1LTbcTpGdSdbj0ee7YZHNe4R2XqxfyWwIkSGWRhgkfM= github.com/hashicorp/nomad/api v0.0.0-20250909143645-a3b86c697f38/go.mod h1:0Tdp+9HbvwrxprXv/LfYZ8P21bOl4oA8Afyet1kUvhI= +github.com/iancoleman/orderedmap v0.3.0 h1:5cbR2grmZR/DiVt+VJopEhtVs9YGInGIxAoMJn+Ichc= +github.com/iancoleman/orderedmap v0.3.0/go.mod h1:XuLcCUkdL5owUCQeF2Ue9uuw1EptkJDkXXS7VoV7XGE= github.com/infobloxopen/go-trees v0.0.0-20200715205103-96a057b8dfb9 h1:w66aaP3c6SIQ0pi3QH1Tb4AMO3aWoEPxd1CNvLphbkA= github.com/infobloxopen/go-trees v0.0.0-20200715205103-96a057b8dfb9/go.mod h1:BaIJzjD2ZnHmx2acPF6XfGLPzNCMiBbMRqJr+8/8uRI= github.com/jpillora/backoff v1.0.0 h1:uvFg412JmmHBHw7iwprIxkPMI+sGQ4kzOWsMeHnm2EA= @@ -361,6 +367,8 @@ github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0t github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/secure-systems-lab/go-securesystemslib v0.11.0 h1:iuCR9kcMFD4QurdKrGvPLoKZLv9YvwPYVr0473BdtFs= github.com/secure-systems-lab/go-securesystemslib v0.11.0/go.mod h1:+PMOTjUGwHj2vcZ+TFKlb1tXRbrdWE1LYDT5i9JC80Q= +github.com/sergi/go-diff v1.3.1 h1:xkr+Oxo4BOQKmkn/B9eMK0g5Kg/983T9DqqPHwYqD+8= +github.com/sergi/go-diff v1.3.1/go.mod h1:aMJSSKb2lpPvRNec0+w3fl7LP9IOFzdc9Pa4NFbPK1I= github.com/shirou/gopsutil/v4 v4.26.6 h1:Mzr/npDtQC/xpeEuQKHZt8Zo9CmPvhTj8nkR8w5TLDs= github.com/shirou/gopsutil/v4 v4.26.6/go.mod h1:LZ6ewCSkBqUpvSOf+LsTGnRinC6iaNUNMGBtDkJBaLQ= github.com/shoenig/test v1.12.1 h1:mLHfnMv7gmhhP44WrvT+nKSxKkPDiNkIuHGdIGI9RLU= @@ -383,6 +391,14 @@ github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE= github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg= +github.com/swaggest/assertjson v1.9.0 h1:dKu0BfJkIxv/xe//mkCrK5yZbs79jL7OVf9Ija7o2xQ= +github.com/swaggest/assertjson v1.9.0/go.mod h1:b+ZKX2VRiUjxfUIal0HDN85W0nHPAYUbYH5WkkSsFsU= +github.com/swaggest/jsonschema-go v0.3.78 h1:5+YFQrLxOR8z6CHvgtZc42WRy/Q9zRQQ4HoAxlinlHw= +github.com/swaggest/jsonschema-go v0.3.78/go.mod h1:4nniXBuE+FIGkOGuidjOINMH7OEqZK3HCSbfDuLRI0g= +github.com/swaggest/openapi-go v0.2.61 h1:psc+LE7pWhEjmJpmkti9tUmBPkkobdUNflBf5Ps6JSc= +github.com/swaggest/openapi-go v0.2.61/go.mod h1:786CwSwleh1IorB0nfwYGESWf83JgQh6fBc1PeJe4Iw= +github.com/swaggest/refl v1.4.0 h1:CftOSdTqRqs100xpFOT/Rifss5xBV/CT0S/FN60Xe9k= +github.com/swaggest/refl v1.4.0/go.mod h1:4uUVFVfPJ0NSX9FPwMPspeHos9wPFlCMGoPRllUbpvA= github.com/tinylib/msgp v1.6.4 h1:mOwYbyYDLPj35mkA2BjjYejgJk9BuHxDdvRnb6v2ZcQ= github.com/tinylib/msgp v1.6.4/go.mod h1:RSp0LW9oSxFut3KzESt5Voq4GVWyS+PSulT77roAqEA= github.com/tklauser/go-sysconf v0.3.16 h1:frioLaCQSsF5Cy1jgRBrzr6t502KIIwQ0MArYICU0nA= @@ -397,6 +413,10 @@ github.com/vmihailenco/tagparser/v2 v2.0.0 h1:y09buUbR+b5aycVFQs/g70pqKVZNBmxwAh github.com/vmihailenco/tagparser/v2 v2.0.0/go.mod h1:Wri+At7QHww0WTrCBeu4J6bNtoV6mEfg5OIWRZA9qds= github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= +github.com/yudai/gojsondiff v1.0.0 h1:27cbfqXLVEJ1o8I6v3y9lg8Ydm53EKqHXAOMxEGlCOA= +github.com/yudai/gojsondiff v1.0.0/go.mod h1:AY32+k2cwILAkW1fbgxQ5mUmMiZFgLIV+FBNExI05xg= +github.com/yudai/golcs v0.0.0-20170316035057-ecda9a501e82 h1:BHyfKlQyqbsFN5p3IfnEUduWvb9is428/nNb5L3U01M= +github.com/yudai/golcs v0.0.0-20170316035057-ecda9a501e82/go.mod h1:lgjkn3NuSvDfVJdfcVVdX+jpBxNmX4rDAzaS45IcYoM= github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0= @@ -581,6 +601,8 @@ gopkg.in/evanphx/json-patch.v4 v4.13.0/go.mod h1:p8EYWUEYMpynmqDbY58zCKCFZw8pRWM gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc= gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw= gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= +gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/http_fixture_test.go b/http_fixture_test.go new file mode 100644 index 0000000..e648f03 --- /dev/null +++ b/http_fixture_test.go @@ -0,0 +1,376 @@ +//go:build integration + +package main + +import ( + "encoding/base64" + "errors" + "fmt" + "io" + "net/http" + "os" + "os/exec" + "path/filepath" + "slices" + "strings" + "sync" + "testing" + "time" + + "github.com/miekg/dns" +) + +type httpFixture struct { + t *testing.T + client *http.Client + binary string + directory string + baseURL string + token string + secret string + dnsPort int + httpPort int + direct bool +} + +func newHTTPFixture(t *testing.T, direct bool) *httpFixture { + t.Helper() + + fixture := &httpFixture{t: t, direct: direct} + + fixture.binary = os.Getenv("COREDNS_DYNAPI_BINARY") + + if fixture.binary == "" { + t.Fatal("COREDNS_DYNAPI_BINARY is required. Run make integration.") + } + + fixture.directory = t.TempDir() + fixture.dnsPort, fixture.httpPort = unusedPort(t), unusedPort(t) + + for fixture.dnsPort == fixture.httpPort { + fixture.httpPort = unusedPort(t) + } + + fixture.token = strings.Repeat("a", 32) + fixture.secret = base64.StdEncoding.EncodeToString([]byte(strings.Repeat("s", 24))) + + fixture.configure() + + fixture.client = &http.Client{Timeout: 10 * time.Second} + t.Cleanup(fixture.client.CloseIdleConnections) + + fixture.baseURL = fmt.Sprintf( + "http://127.0.0.1:%d/v1/zones/example.org/records/", + fixture.httpPort, + ) + + return fixture +} + +func (httpFixture *httpFixture) request( + method, name, rrtype, body, auth string, +) (int, string) { + t := httpFixture.t + t.Helper() + + r, err := http.NewRequestWithContext( + t.Context(), + method, + httpFixture.baseURL+name+"/"+rrtype, + strings.NewReader(body), + ) + if err != nil { + t.Fatal(err) + } + + if auth != "" { + r.Header.Set("Authorization", "Bearer "+auth) + } + + r.Header.Set("Content-Type", "application/json") + + response, err := httpFixture.client.Do(r) + if err != nil { + t.Fatal(err) + } + defer closeTestBody(t, response.Body) + + bytes, err := io.ReadAll(response.Body) + if err != nil { + t.Fatal(err) + } + + return response.StatusCode, string(bytes) +} + +func (httpFixture *httpFixture) start() (*exec.Cmd, func()) { + t := httpFixture.t + t.Helper() + + logfile, err := os.CreateTemp(httpFixture.directory, "server-*.log") + if err != nil { + t.Fatal(err) + } + + t.Cleanup(func() { + if err := logfile.Close(); err != nil { + t.Error(err) + } + }) + + //nolint:gosec // The test runner supplies the CoreDNS executable. + cmd := exec.CommandContext(t.Context(), httpFixture.binary, "-conf", "Corefile") + + cmd.Dir = httpFixture.directory + + cmd.Env = append( + os.Environ(), + "GORACE=halt_on_error=1", + "DYNAPI_TEST_TOKEN="+httpFixture.token, + "DYNAPI_TEST_SECRET="+httpFixture.secret, + ) + cmd.Stdout, cmd.Stderr = logfile, logfile + + if err := cmd.Start(); err != nil { + t.Fatal(err) + } + + done := make(chan error, 1) + + go func() { done <- cmd.Wait() }() + + var once sync.Once + + stop := func() { + once.Do(func() { + stopTestProcess(t, cmd, done) + }) + } + t.Cleanup(stop) + + httpFixture.waitReady(logfile, done) + + return cmd, stop +} + +func (httpFixture *httpFixture) query(name string, rrtype uint16) *dns.Msg { + return httpFixture.queryNetwork(name, rrtype, "tcp") +} + +func (httpFixture *httpFixture) queryNetwork(name string, rrtype uint16, network string) *dns.Msg { + t := httpFixture.t + t.Helper() + + m := new(dns.Msg) + m.SetQuestion(dns.Fqdn(name), rrtype) + + response, _, err := (&dns.Client{Net: network, Timeout: 5 * time.Second}).ExchangeContext( + t.Context(), + m, + fmt.Sprintf("127.0.0.1:%d", httpFixture.dnsPort), + ) + if err != nil { + t.Fatal(err) + } + + return response +} + +func (httpFixture *httpFixture) checkDNS(rrtype uint16, want ...string) { + t := httpFixture.t + t.Helper() + + for _, network := range []string{"tcp", "udp"} { + response := httpFixture.queryNetwork("host.example.org", rrtype, network) + + var got []string + + for _, rr := range response.Answer { + switch rr := rr.(type) { + case *dns.A: + got = append(got, rr.A.String()) + case *dns.AAAA: + got = append(got, rr.AAAA.String()) + default: + t.Fatalf("unexpected DNS answer type: %T", rr) + } + } + + slices.Sort(got) + slices.Sort(want) + + if response.Rcode != dns.RcodeSuccess || !response.Authoritative || + !slices.Equal(got, want) { + + t.Fatalf("DNS answer=%s; want=%v", response, want) + } + } +} + +func (httpFixture *httpFixture) configure() { + t := httpFixture.t + t.Helper() + + corefile := fmt.Sprintf(`example.org:%d { + bind 127.0.0.1 + cache 30 + dynapi 127.0.0.1:%d { + token_env DYNAPI_TEST_TOKEN + identity update-key.example.org. + secret_env DYNAPI_TEST_SECRET + upstream 127.0.0.1:%d + max_requests 8 + } + tsig { + secret update-key.example.org. {$DYNAPI_TEST_SECRET} + require_opcode UPDATE + require AXFR + } + transfer { + to 127.0.0.1 + } + dynupdate { + file example.org.zone + database example.org.db + allow update-key.example.org. host.example.org. A AAAA + } +} +`, httpFixture.dnsPort, httpFixture.httpPort, httpFixture.dnsPort) + if httpFixture.direct { + corefile = strings.Replace( + corefile, + "token_env DYNAPI_TEST_TOKEN", + "token "+httpFixture.token, + 1, + ) + corefile = strings.Replace( + corefile, + "secret_env DYNAPI_TEST_SECRET", + "secret "+httpFixture.secret, + 1, + ) + } + + zone := `$ORIGIN example.org. +@ 60 IN SOA ns.example.org. hostmaster.example.org. 1 3600 600 86400 60 +@ 60 IN NS ns.example.org. +ns 60 IN A 127.0.0.1 +alias 60 IN CNAME ns.example.org. +*.wild 60 IN A 192.0.2.99 +` + + for name, contents := range map[string]string{"Corefile": corefile, "example.org.zone": zone} { + err := os.WriteFile(filepath.Join(httpFixture.directory, name), []byte(contents), 0o600) + if err != nil { + t.Fatal(err) + } + } +} + +func (httpFixture *httpFixture) waitReady(logfile *os.File, done chan error) { + t := httpFixture.t + t.Helper() + + deadline := time.Now().Add(10 * time.Second) + for time.Now().Before(deadline) { + if httpFixture.isReady() { + return + } + + select { + case err := <-done: + // Cleanup must still be able to observe process completion. + done <- err + + logs, _ := os.ReadFile(logfile.Name()) + t.Fatalf("CoreDNS exited: %v\n%s", err, logs) + default: + } + + time.Sleep(20 * time.Millisecond) + } + + logs, _ := os.ReadFile(logfile.Name()) + t.Fatalf("HTTP API did not become ready:\n%s", logs) +} + +func closeTestBody(t *testing.T, body io.Closer) { + t.Helper() + + err := body.Close() + if err != nil { + t.Error(err) + } +} + +func stopTestProcess(t *testing.T, cmd *exec.Cmd, done <-chan error) { + t.Helper() + + if err := cmd.Process.Signal( + os.Interrupt, + ); err != nil && + !errors.Is(err, os.ErrProcessDone) { + + t.Error(err) + } + + select { + case err := <-done: + if err != nil { + t.Errorf("CoreDNS exited with an error: %v", err) + } + case <-time.After(10 * time.Second): + t.Error("CoreDNS did not stop within 10 seconds") + + if err := cmd.Process.Kill(); err != nil && !errors.Is(err, os.ErrProcessDone) { + t.Error(err) + } + + <-done + } +} + +func (httpFixture *httpFixture) isReady() bool { + t := httpFixture.t + t.Helper() + + r, _ := http.NewRequestWithContext( + t.Context(), + http.MethodGet, + httpFixture.baseURL+"host.example.org/A", + http.NoBody, + ) + r.Header.Set("Authorization", "Bearer "+httpFixture.token) + + response, err := httpFixture.client.Do(r) + if err != nil { + return false + } + + _, copyErr := io.Copy(io.Discard, response.Body) + + closeErr := response.Body.Close() + if closeErr != nil { + t.Fatal(closeErr) + } + + if copyErr != nil { + t.Fatal(copyErr) + } + + return response.StatusCode == http.StatusNotFound || response.StatusCode == http.StatusOK +} + +func (httpFixture *httpFixture) checkDNSTTL(rrtype uint16, ttl uint32) { + t := httpFixture.t + t.Helper() + + for _, network := range []string{"tcp", "udp"} { + response := httpFixture.queryNetwork("host.example.org", rrtype, network) + for _, record := range response.Answer { + if record.Header().Ttl != ttl { + t.Fatalf("%s DNS TTL=%d; want=%d", network, record.Header().Ttl, ttl) + } + } + } +} diff --git a/http_integration_test.go b/http_integration_test.go new file mode 100644 index 0000000..964c667 --- /dev/null +++ b/http_integration_test.go @@ -0,0 +1,285 @@ +//go:build integration + +package main + +import ( + "encoding/json/v2" + "fmt" + "net" + "net/http" + "os/exec" + "slices" + "syscall" + "testing" + "time" + + "github.com/miekg/dns" +) + +func TestHTTPAPI(t *testing.T) { + t.Parallel() + + for _, mode := range []struct { + name string + direct bool + }{ + {"environment", false}, {"literal", true}, + } { + t.Run(mode.name, func(t *testing.T) { + t.Parallel() + checkHTTPAPI(t, mode.direct) + }) + } +} + +func checkHTTPAPI(t *testing.T, direct bool) { + t.Helper() + + fixture := newHTTPFixture(t, direct) + cmd, stop := fixture.start() + + checkInitialState(t, fixture) + checkReplacements(t, fixture) + checkConcurrentAPI(t, fixture) + checkPermissions(t, fixture) + checkUnsignedUpdate(t, fixture) + checkReload(t, fixture, cmd) + stop() + + _, stop = fixture.start() + fixture.checkDNS(dns.TypeA, "192.0.2.12") + fixture.checkDNS(dns.TypeAAAA, "2001:db8::10") + checkDeletion(t, fixture) + stop() +} + +func checkInitialState(t *testing.T, fixture *httpFixture) { + t.Helper() + + if status, _ := fixture.request( + http.MethodPut, + "host.example.org", + "A", + `{"ttl":60,"addresses":["192.0.2.10"]}`, + "", + ); status != 401 { + t.Fatalf("unauthorized status=%d", status) + } + + if response := fixture.query( + "host.example.org", + dns.TypeA, + ); response.Rcode != dns.RcodeNameError { + t.Fatalf("expected initial NXDOMAIN: %s", response) + } +} + +func checkReplacements(t *testing.T, fixture *httpFixture) { + t.Helper() + + for _, set := range []struct { + rrtype, body string + addresses []string + ttl uint32 + dnsType uint16 + }{ + { + "A", + `{"ttl":60,"addresses":["192.0.2.10","192.0.2.11"]}`, + []string{"192.0.2.10", "192.0.2.11"}, + 60, dns.TypeA, + }, + {"A", `{"ttl":120,"addresses":["192.0.2.12"]}`, []string{"192.0.2.12"}, 120, dns.TypeA}, + {"AAAA", `{"ttl":60,"addresses":["2001:db8::10"]}`, []string{"2001:db8::10"}, 60, dns.TypeAAAA}, + } { + status, body := fixture.request( + http.MethodPut, + "host.example.org", + set.rrtype, + set.body, + fixture.token, + ) + checkRecordResponse(t, status, body, set.addresses, set.ttl) + + fixture.checkDNS(set.dnsType, set.addresses...) + fixture.checkDNSTTL(set.dnsType, set.ttl) + + status, body = fixture.request( + http.MethodGet, + "host.example.org", + set.rrtype, + "", + fixture.token, + ) + + checkRecordResponse(t, status, body, set.addresses, set.ttl) + } +} + +func checkPermissions(t *testing.T, fixture *httpFixture) { + t.Helper() + + for _, test := range []struct { + method, name, body string + status int + }{ + {http.MethodPut, "denied.example.org", `{"ttl":60,"addresses":["192.0.2.15"]}`, 403}, + {http.MethodPut, "alias.example.org", `{"ttl":60,"addresses":["192.0.2.15"]}`, 409}, + {http.MethodGet, "alias.example.org", "", 404}, + {http.MethodGet, "new.wild.example.org", "", 404}, + {http.MethodPut, "host.example.org", `{"ttl":60,"addresses":["2001:db8::1"]}`, 422}, + } { + if status, body := fixture.request( + test.method, + test.name, + "A", + test.body, + fixture.token, + ); status != test.status { + t.Fatalf("%s %s: status=%d body=%s", test.method, test.name, status, body) + } + } + + if response := fixture.query("new.wild.example.org", dns.TypeA); len(response.Answer) != 1 { + t.Fatalf("wildcard DNS query failed: %s", response) + } +} + +func checkUnsignedUpdate(t *testing.T, fixture *httpFixture) { + t.Helper() + + unsigned := new(dns.Msg) + unsigned.SetUpdate("example.org.") + unsigned.Insert( + []dns.RR{ + &dns.A{ + Hdr: dns.RR_Header{ + Name: "host.example.org.", + Rrtype: dns.TypeA, + Class: dns.ClassINET, + Ttl: 60, + }, + A: net.ParseIP("192.0.2.55").To4(), + }, + }, + ) + + response, _, err := (&dns.Client{Net: "tcp", Timeout: 5 * time.Second}).Exchange( + unsigned, + fmt.Sprintf("127.0.0.1:%d", fixture.dnsPort), + ) + if err != nil || response.Rcode != dns.RcodeRefused { + t.Fatalf("unsigned UPDATE response=%v error=%v", response, err) + } + + fixture.checkDNS(dns.TypeA, "192.0.2.12") +} + +func checkReload(t *testing.T, fixture *httpFixture, cmd *exec.Cmd) { + t.Helper() + + err := cmd.Process.Signal(syscall.SIGUSR1) + if err != nil { + t.Fatal(err) + } + + // A refused reload must leave the existing listener and zone usable. + time.Sleep(100 * time.Millisecond) + + if status, body := fixture.request( + http.MethodGet, + "host.example.org", + "A", + "", + fixture.token, + ); status != 200 { + t.Fatalf("after refused reload: status=%d body=%s", status, body) + } +} + +func checkDeletion(t *testing.T, fixture *httpFixture) { + t.Helper() + + for range 2 { + if status, body := fixture.request( + http.MethodDelete, + "host.example.org", + "A", + "", + fixture.token, + ); status != 204 { + t.Fatalf("DELETE status=%d body=%s", status, body) + } + } + + if status, body := fixture.request( + http.MethodGet, + "host.example.org", + "A", + "", + fixture.token, + ); status != 404 { + t.Fatalf("deleted GET status=%d body=%s", status, body) + } + + fixture.checkDNS(dns.TypeA) + fixture.checkDNS(dns.TypeAAAA, "2001:db8::10") +} + +func checkRecordResponse(t *testing.T, status int, body string, addresses []string, ttl uint32) { + t.Helper() + + var got struct { + Addresses []string `json:"addresses"` + TTL uint32 `json:"ttl"` + } + + err := json.Unmarshal([]byte(body), &got) + + if err != nil || status != http.StatusOK || got.TTL != ttl || + !slices.Equal(got.Addresses, addresses) { + + t.Fatalf( + "HTTP status=%d body=%s; want addresses=%v ttl=%d; error=%v", + status, + body, + addresses, + ttl, + err, + ) + } +} + +func checkConcurrentAPI(t *testing.T, fixture *httpFixture) { + t.Helper() + + const clients = 8 + + t.Run("concurrent API requests", func(t *testing.T) { + for index := range clients { + t.Run(fmt.Sprintf("client-%d", index), func(t *testing.T) { + t.Parallel() + + concurrent := *fixture + + concurrent.t = t + + for _, method := range []string{http.MethodPut, http.MethodGet} { + body := "" + if method == http.MethodPut { + body = `{"ttl":120,"addresses":["192.0.2.12"]}` + } + + status, response := concurrent.request( + method, + "host.example.org", + "A", + body, + concurrent.token, + ) + checkRecordResponse(t, status, response, []string{"192.0.2.12"}, 120) + } + }) + } + }) +} diff --git a/integration_test.go b/integration_test.go index 8cab9f0..1c7a269 100644 --- a/integration_test.go +++ b/integration_test.go @@ -14,33 +14,49 @@ import ( ) func TestInstallation(t *testing.T) { + t.Parallel() + binary := os.Getenv("COREDNS_DYNAPI_BINARY") if binary == "" { t.Fatal("COREDNS_DYNAPI_BINARY is required. Run make integration.") } + ctx, cancel := context.WithTimeout(t.Context(), 10*time.Second) + defer cancel() + + //nolint:gosec // The test runner supplies the CoreDNS executable. output, err := exec.CommandContext(ctx, binary, "-plugins").CombinedOutput() if err != nil { t.Fatalf("listing plugins: %v\n%s", err, output) } + for _, plugin := range []string{"dynapi", "dynupdate", "tsig"} { if !slices.Contains(strings.Fields(string(output)), plugin) { t.Fatalf("%s is missing from the built executable:\n%s", plugin, output) } } + corefile := filepath.Join(t.TempDir(), "Corefile") - if err := os.WriteFile(corefile, []byte("example.org:1053 {\n dynapi\n}\n"), 0600); err != nil { - t.Fatal(err) + if writeErr := os.WriteFile( + corefile, + []byte("example.org:1053 {\n dynapi\n}\n"), + 0o600, + ); writeErr != nil { + t.Fatal(writeErr) } + + //nolint:gosec // The test runner supplies the CoreDNS executable. output, err = exec.CommandContext(ctx, binary, "-conf", corefile).CombinedOutput() if err == nil { - t.Fatal("the unfinished dynapi directive unexpectedly started") + t.Fatal("an unconfigured dynapi directive unexpectedly started") } + if ctx.Err() != nil { t.Fatalf("CoreDNS did not reject configuration before the timeout: %v", ctx.Err()) } - if !strings.Contains(string(output), "plugin/dynapi: HTTP record management is not implemented yet") { + + if !strings.Contains(string(output), "configure token or token_env") { t.Fatalf("unexpected startup failure: %v\n%s", err, output) } } diff --git a/main.go b/main.go index 3fb38dd..52a4d17 100644 --- a/main.go +++ b/main.go @@ -1,3 +1,4 @@ +// Package main builds CoreDNS with the dynapi plugin. package main import ( @@ -15,9 +16,11 @@ func init() { for i, directive := range dnsserver.Directives { if directive == "acl" { dnsserver.Directives = slices.Insert(dnsserver.Directives, i+1, "dynapi") + return } } + panic("cannot register dynapi: CoreDNS has no acl directive") } diff --git a/openapi.yaml b/openapi.yaml new file mode 100644 index 0000000..acaf565 --- /dev/null +++ b/openapi.yaml @@ -0,0 +1,437 @@ +openapi: 3.1.0 +info: + description: Manage address records through signed DNS requests. Reads cover the + configured zone. Writes follow dynupdate permissions. A lost response may follow + a committed write. There are no automatic retries or conditional writes. + title: CoreDNS dynapi + version: "1" +servers: +- url: http://127.0.0.1:8080 +paths: + /v1/zones/{zone}/records/{name}/{type}: + delete: + description: Leave other record types intact. Deleting an absent set succeeds. + operationId: deleteRecordSet + parameters: + - description: Configured zone. + in: path + name: zone + required: true + schema: + description: Configured zone. + maxLength: 254 + type: string + - description: Full owner name. + in: path + name: name + required: true + schema: + description: Full owner name. + maxLength: 254 + type: string + - description: A or AAAA. + in: path + name: type + required: true + schema: + description: A or AAAA. + pattern: ^(?:[aA]|[aA]{4})$ + type: string + responses: + "204": + description: No Content + "401": + content: + application/json: + schema: + $ref: '#/components/schemas/UnauthorizedResponse' + description: Unauthorized + "403": + content: + application/json: + schema: + $ref: '#/components/schemas/ForbiddenResponse' + description: Forbidden + "404": + content: + application/json: + schema: + $ref: '#/components/schemas/NotFoundResponse' + description: Not Found + "405": + content: + application/json: + schema: + $ref: '#/components/schemas/MethodNotAllowedResponse' + description: Method Not Allowed + "409": + content: + application/json: + schema: + $ref: '#/components/schemas/ConflictResponse' + description: Conflict + "422": + content: + application/json: + schema: + $ref: '#/components/schemas/UnprocessableEntityResponse' + description: Unprocessable Entity + "502": + content: + application/json: + schema: + $ref: '#/components/schemas/BadGatewayResponse' + description: Bad Gateway + "503": + content: + application/json: + schema: + $ref: '#/components/schemas/ServiceUnavailableResponse' + description: Service Unavailable + security: + - bearerAuth: [] + summary: Delete one record type's complete set + get: + description: 'Read a signed AXFR snapshot without wildcard expansion or CNAME + traversal. Return the lowest stored TTL. Limit: 10001 records, including the + repeated SOA, and 16777216 bytes.' + operationId: getRecordSet + parameters: + - description: Configured zone. + in: path + name: zone + required: true + schema: + description: Configured zone. + maxLength: 254 + type: string + - description: Full owner name. + in: path + name: name + required: true + schema: + description: Full owner name. + maxLength: 254 + type: string + - description: A or AAAA. + in: path + name: type + required: true + schema: + description: A or AAAA. + pattern: ^(?:[aA]|[aA]{4})$ + type: string + responses: + "200": + content: + application/json: + schema: + $ref: '#/components/schemas/DynapiRecordSet' + description: OK + "401": + content: + application/json: + schema: + $ref: '#/components/schemas/UnauthorizedResponse' + description: Unauthorized + "404": + content: + application/json: + schema: + $ref: '#/components/schemas/NotFoundResponse' + description: Not Found + "405": + content: + application/json: + schema: + $ref: '#/components/schemas/MethodNotAllowedResponse' + description: Method Not Allowed + "422": + content: + application/json: + schema: + $ref: '#/components/schemas/UnprocessableEntityResponse' + description: Unprocessable Entity + "502": + content: + application/json: + schema: + $ref: '#/components/schemas/BadGatewayResponse' + description: Bad Gateway + "503": + content: + application/json: + schema: + $ref: '#/components/schemas/ServiceUnavailableResponse' + description: Service Unavailable + security: + - bearerAuth: [] + summary: Read an exact stored record set + put: + description: 'Replace only the requested type. Body limit: 65536 bytes. Addresses + must match the type and are canonicalized, sorted, and deduplicated. IPv4-mapped + and scoped IPv6 addresses are rejected. TTL controls caching and does not + expire records.' + operationId: replaceRecordSet + parameters: + - description: Configured zone. + in: path + name: zone + required: true + schema: + description: Configured zone. + maxLength: 254 + type: string + - description: Full owner name. + in: path + name: name + required: true + schema: + description: Full owner name. + maxLength: 254 + type: string + - description: A or AAAA. + in: path + name: type + required: true + schema: + description: A or AAAA. + pattern: ^(?:[aA]|[aA]{4})$ + type: string + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/DynapiRecordPayload' + required: true + responses: + "200": + content: + application/json: + schema: + $ref: '#/components/schemas/DynapiRecordSet' + description: OK + "400": + content: + application/json: + schema: + $ref: '#/components/schemas/BadRequestResponse' + description: Bad Request + "401": + content: + application/json: + schema: + $ref: '#/components/schemas/UnauthorizedResponse' + description: Unauthorized + "403": + content: + application/json: + schema: + $ref: '#/components/schemas/ForbiddenResponse' + description: Forbidden + "404": + content: + application/json: + schema: + $ref: '#/components/schemas/NotFoundResponse' + description: Not Found + "405": + content: + application/json: + schema: + $ref: '#/components/schemas/MethodNotAllowedResponse' + description: Method Not Allowed + "409": + content: + application/json: + schema: + $ref: '#/components/schemas/ConflictResponse' + description: Conflict + "413": + content: + application/json: + schema: + $ref: '#/components/schemas/RequestEntityTooLargeResponse' + description: Request Entity Too Large + "415": + content: + application/json: + schema: + $ref: '#/components/schemas/UnsupportedMediaTypeResponse' + description: Unsupported Media Type + "422": + content: + application/json: + schema: + $ref: '#/components/schemas/UnprocessableEntityResponse' + description: Unprocessable Entity + "502": + content: + application/json: + schema: + $ref: '#/components/schemas/BadGatewayResponse' + description: Bad Gateway + "503": + content: + application/json: + schema: + $ref: '#/components/schemas/ServiceUnavailableResponse' + description: Service Unavailable + security: + - bearerAuth: [] + summary: Atomically replace a complete record set +components: + schemas: + BadGatewayResponse: + allOf: + - $ref: '#/components/schemas/DynapiErrorResponse' + - properties: + code: + enum: + - backend_failure + - backend_rejected + - read_limit_exceeded + type: string + type: object + BadRequestResponse: + allOf: + - $ref: '#/components/schemas/DynapiErrorResponse' + - properties: + code: + enum: + - invalid_json + type: string + type: object + ConflictResponse: + allOf: + - $ref: '#/components/schemas/DynapiErrorResponse' + - properties: + code: + enum: + - record_conflict + type: string + type: object + DynapiErrorResponse: + properties: + code: + type: string + error: + type: string + required: + - code + - error + type: object + DynapiRecordPayload: + additionalProperties: false + properties: + addresses: + items: + type: string + maxItems: 256 + minItems: 1 + type: array + ttl: + maximum: 2.147483647e+09 + minimum: 0 + type: integer + required: + - ttl + - addresses + type: object + DynapiRecordSet: + properties: + addresses: + items: + type: string + minItems: 1 + type: array + ttl: + maximum: 4.294967295e+09 + minimum: 0 + type: integer + required: + - addresses + - ttl + type: object + ForbiddenResponse: + allOf: + - $ref: '#/components/schemas/DynapiErrorResponse' + - properties: + code: + enum: + - update_denied + type: string + type: object + MethodNotAllowedResponse: + allOf: + - $ref: '#/components/schemas/DynapiErrorResponse' + - properties: + code: + enum: + - method_not_allowed + type: string + type: object + NotFoundResponse: + allOf: + - $ref: '#/components/schemas/DynapiErrorResponse' + - properties: + code: + enum: + - zone_not_found + - record_set_not_found + - resource_not_found + type: string + type: object + RequestEntityTooLargeResponse: + allOf: + - $ref: '#/components/schemas/DynapiErrorResponse' + - properties: + code: + enum: + - body_too_large + type: string + type: object + ServiceUnavailableResponse: + allOf: + - $ref: '#/components/schemas/DynapiErrorResponse' + - properties: + code: + enum: + - too_many_requests + type: string + type: object + UnauthorizedResponse: + allOf: + - $ref: '#/components/schemas/DynapiErrorResponse' + - properties: + code: + enum: + - unauthorized + type: string + type: object + UnprocessableEntityResponse: + allOf: + - $ref: '#/components/schemas/DynapiErrorResponse' + - properties: + code: + enum: + - invalid_name + - unsupported_record_type + - invalid_address + - invalid_record_set + type: string + type: object + UnsupportedMediaTypeResponse: + allOf: + - $ref: '#/components/schemas/DynapiErrorResponse' + - properties: + code: + enum: + - unsupported_content_type + type: string + type: object + securitySchemes: + bearerAuth: + description: Private token configured by token or token_env. + scheme: bearer + type: http diff --git a/plugins/dynapi/api_error.go b/plugins/dynapi/api_error.go new file mode 100644 index 0000000..67c6628 --- /dev/null +++ b/plugins/dynapi/api_error.go @@ -0,0 +1,11 @@ +package dynapi + +type apiError struct { + code string + message string + status int +} + +func (apiError *apiError) Error() string { + return apiError.message +} diff --git a/plugins/dynapi/backend.go b/plugins/dynapi/backend.go new file mode 100644 index 0000000..5a90478 --- /dev/null +++ b/plugins/dynapi/backend.go @@ -0,0 +1,224 @@ +package dynapi + +import ( + "context" + "fmt" + "net" + "net/http" + "slices" + "time" + + "github.com/coredns/coredns/core/dnsserver" + "github.com/miekg/dns" +) + +const ( + tsigFudge = 300 + poolIdleTimeoutDivisor = 2 + defaultDNSMaxQueries = 128 + defaultDNSIdleTimeout = 10 * time.Second +) + +type backend struct { + client *dns.Client + pool *connectionPool + zone string + address string + key string +} + +func newBackend(cfg *dnsserver.Config, options *config) *backend { + client := &dns.Client{ + Net: "tcp", Timeout: requestTimeout, + TsigSecret: map[string]string{options.identity: options.secret}, + } + idleTimeout := cfg.IdleTimeout + + if idleTimeout == 0 { + idleTimeout = defaultDNSIdleTimeout + } + + maxQueries := -1 + if cfg.MaxTCPQueries != nil { + maxQueries = *cfg.MaxTCPQueries + } + + if maxQueries == 0 { + maxQueries = defaultDNSMaxQueries + } + + return &backend{ + client: client, + zone: cfg.Zone, + address: options.upstream, + key: options.identity, + // Retire idle connections before CoreDNS's own idle deadline. + pool: newConnectionPool( + client, + options.upstream, + options.maxRequests, + idleTimeout/poolIdleTimeoutDivisor, + maxQueries, + ), + } +} + +func (backend *backend) read(ctx context.Context, name string, rrtype uint16) (recordSet, error) { + connection, err := backend.pool.acquire(ctx) + if err != nil { + return recordSet{}, err + } + + stop := context.AfterFunc(ctx, func() { _ = connection.Conn.Close() }) + + defer func() { + stopped := stop() + + connection.reusable = connection.reusable && stopped && ctx.Err() == nil + backend.pool.release(connection) + }() + + err = connection.SetWriteDeadline(time.Now().Add(requestTimeout)) + if err != nil { + return recordSet{}, fmt.Errorf("set transfer deadline: %w", err) + } + + message := new(dns.Msg) + message.SetAxfr(backend.zone) + backend.sign(message) + + transfer := &dns.Transfer{ + Conn: &dns.Conn{Conn: connection}, + TsigSecret: backend.client.TsigSecret, + ReadTimeout: requestTimeout, + } + + envelopes, err := transfer.In(message, backend.address) + if err != nil { + return recordSet{}, fmt.Errorf("start zone transfer: %w", err) + } + + var scan recordScan + + scan.name, scan.rrtype = name, rrtype + + if err := consumeTransfer(ctx, connection.Conn, envelopes, &scan); err != nil { + return recordSet{}, err + } + + connection.reusable = true + + if len(scan.records.Addresses) == 0 { + return recordSet{}, &apiError{ + status: http.StatusNotFound, + code: codeRecordSetNotFound, + message: "record set not found", + } + } + + slices.Sort(scan.records.Addresses) + + return scan.records, nil +} + +func (backend *backend) replace( + ctx context.Context, + name string, + rrtype uint16, + set recordSet, +) error { + updates := make([]dns.RR, 1, len(set.Addresses)+1) + + updates[0] = emptyRecord(name, rrtype, dns.ClassANY) + + for _, address := range set.Addresses { + header := dns.RR_Header{Name: name, Rrtype: rrtype, Class: dns.ClassINET, Ttl: set.TTL} + if rrtype == dns.TypeA { + updates = append(updates, &dns.A{Hdr: header, A: net.ParseIP(address).To4()}) + } else { + updates = append(updates, &dns.AAAA{Hdr: header, AAAA: net.ParseIP(address)}) + } + } + + // A CNAME can make RFC 2136 ignore an addition. Assert its absence in the + // same transaction so HTTP cannot report a successful ignored replacement. + prerequisites := []dns.RR{emptyRecord(name, dns.TypeCNAME, dns.ClassNONE)} + + return backend.update(ctx, prerequisites, updates) +} + +func (backend *backend) delete(ctx context.Context, name string, rrtype uint16) error { + return backend.update(ctx, nil, []dns.RR{emptyRecord(name, rrtype, dns.ClassANY)}) +} + +func (backend *backend) sign(message *dns.Msg) { + message.SetTsig(backend.key, dns.HmacSHA256, tsigFudge, time.Now().Unix()) +} + +func (backend *backend) update(ctx context.Context, prerequisites, updates []dns.RR) error { + message := new(dns.Msg) + message.SetUpdate(backend.zone) + + message.Answer, message.Ns = prerequisites, updates + backend.sign(message) + + connection, err := backend.pool.acquire(ctx) + if err != nil { + return err + } + + stop := context.AfterFunc(ctx, func() { _ = connection.Conn.Close() }) + + defer func() { + stopped := stop() + + connection.reusable = connection.reusable && stopped && ctx.Err() == nil + backend.pool.release(connection) + }() + + // A fresh DNS wrapper resets the TSIG request MAC between transactions. + response, _, err := backend.client.ExchangeWithConnContext( + ctx, + message, + &dns.Conn{Conn: connection}, + ) + if err != nil { + return fmt.Errorf("exchange DNS update: %w", err) + } + + if response.IsTsig() == nil { + return errUnsignedResponse + } + + connection.reusable = true + + return updateError(response.Rcode) +} + +func emptyRecord(name string, rrtype, class uint16) dns.RR { + return &dns.RFC3597{Hdr: dns.RR_Header{Name: name, Rrtype: rrtype, Class: class}} +} + +func updateError(code int) error { + switch code { + case dns.RcodeSuccess: + return nil + case dns.RcodeYXRrset, dns.RcodeYXDomain, dns.RcodeNXRrset, dns.RcodeNameError: + return &apiError{ + status: http.StatusConflict, + code: codeRecordConflict, + message: "record prerequisite failed", + } + case dns.RcodeRefused: + return &apiError{ + status: http.StatusForbidden, + code: codeUpdateDenied, message: "update denied by backend permissions or limits", + } + default: + return &apiError{ + status: http.StatusBadGateway, + code: codeBackendRejected, + message: "backend rejected the update", + } + } +} diff --git a/plugins/dynapi/backend_test.go b/plugins/dynapi/backend_test.go new file mode 100644 index 0000000..7e96197 --- /dev/null +++ b/plugins/dynapi/backend_test.go @@ -0,0 +1,130 @@ +package dynapi + +import ( + "context" + "net" + "testing" + "time" + + "github.com/miekg/dns" +) + +func TestRead(t *testing.T) { + t.Parallel() + + backend, peer := newBlockedBackend(t) + ctx, cancel := context.WithTimeout(t.Context(), time.Second) + + defer cancel() + + result := make(chan error, 1) + + go func() { + _, err := backend.read(ctx, "host.example.org.", dns.TypeA) + result <- err + }() + + request, err := peer.ReadMsg() + if err != nil { + t.Fatal(err) + } + + if request.Question[0].Qtype != dns.TypeAXFR { + t.Fatal("read did not request AXFR") + } + + cancel() + + if err := <-result; err == nil { + t.Fatal("canceled read succeeded") + } + + if len(backend.pool.connections) != 0 || len(backend.pool.idle) != 0 { + t.Fatal("canceled read returned a connection to the pool") + } +} + +func TestUpdate(t *testing.T) { + t.Parallel() + + for _, mode := range []string{"canceled", "lost response"} { + t.Run(mode, func(t *testing.T) { + t.Parallel() + + checkFailedUpdate(t, mode) + }) + } +} + +func checkFailedUpdate(t *testing.T, mode string) { + t.Helper() + + backend, peer := newBlockedBackend(t) + ctx, cancel := context.WithTimeout(t.Context(), time.Second) + + defer cancel() + + result := make(chan error, 1) + + go func() { result <- backend.update(ctx, nil, nil) }() + + request, err := peer.ReadMsg() + if err != nil { + t.Fatal(err) + } + + if request.Opcode != dns.OpcodeUpdate { + t.Fatal("write did not request UPDATE") + } + + if mode == "canceled" { + cancel() + } else if closeErr := peer.Close(); closeErr != nil { + t.Fatal(closeErr) + } + + if err := <-result; err == nil { + t.Fatal("failed update succeeded") + } + + if len(backend.pool.connections) != 0 || len(backend.pool.idle) != 0 { + t.Fatal("failed update returned a connection to the pool") + } +} + +func newBlockedBackend(t *testing.T) (*backend, *dns.Conn) { + t.Helper() + + const key = "update-key.example.org." + + client := &dns.Client{ + Net: "tcp", Timeout: time.Second, + TsigSecret: map[string]string{key: "YWJjZGVmZ2hpamtsbW5vcHFyc3R1dnd4"}, + } + pool := newConnectionPool(client, "127.0.0.1:0", 1, time.Second, -1) + local, remote := net.Pipe() + connection := &pooledConnection{Conn: local, lastUsed: time.Now()} + + pool.connections[connection] = struct{}{} + pool.idle = append(pool.idle, connection) + + t.Cleanup(func() { + pool.close() + + _ = remote.Close() + }) + + if err := remote.SetDeadline(time.Now().Add(requestTimeout)); err != nil { + t.Fatal(err) + } + + return &backend{ + client: client, + pool: pool, + zone: "example.org.", + key: key, + }, &dns.Conn{ + Conn: remote, + TsigSecret: client.TsigSecret, + } +} diff --git a/plugins/dynapi/blocking_backend_test.go b/plugins/dynapi/blocking_backend_test.go new file mode 100644 index 0000000..358247a --- /dev/null +++ b/plugins/dynapi/blocking_backend_test.go @@ -0,0 +1,30 @@ +package dynapi + +import ( + "context" + "fmt" +) + +type blockingBackend struct { + started chan struct{} + release chan struct{} +} + +func (blockingBackend *blockingBackend) read( + ctx context.Context, + _ string, + _ uint16, +) (recordSet, error) { + blockingBackend.started <- struct{}{} + + select { + case <-blockingBackend.release: + return recordSet{TTL: 60, Addresses: []string{"192.0.2.1"}}, nil + case <-ctx.Done(): + return recordSet{}, fmt.Errorf("wait for test backend: %w", ctx.Err()) + } +} + +func (*blockingBackend) replace(context.Context, string, uint16, recordSet) error { return nil } + +func (*blockingBackend) delete(context.Context, string, uint16) error { return nil } diff --git a/plugins/dynapi/config.go b/plugins/dynapi/config.go new file mode 100644 index 0000000..ebfd5da --- /dev/null +++ b/plugins/dynapi/config.go @@ -0,0 +1,173 @@ +package dynapi + +import ( + "encoding/base64" + "fmt" + "net" + "net/netip" + "os" + "strconv" + "strings" + + "github.com/coredns/caddy" + "github.com/coredns/coredns/plugin" +) + +const ( + defaultAddress = "127.0.0.1:8080" + defaultMaxRequests = 32 + minTokenLength = 32 + minSecretBytes = 16 + maxPort = 65535 +) + +type config struct { + address string + token string + identity string + secret string + upstream string + maxRequests int +} + +func (config *config) set(name, value string) error { + switch name { + case "token": + config.token = value + case "token_env": + config.token = os.Getenv(value) + case "identity": + config.identity = value + case "secret": + config.secret = value + case "secret_env": + config.secret = os.Getenv(value) + case "upstream": + config.upstream = value + case "max_requests": + number, err := strconv.Atoi(value) + if err != nil { + return fmt.Errorf("parse max_requests: %w", err) + } + + config.maxRequests = number + default: + return fmt.Errorf("%w: %q", errUnknownProperty, name) + } + + return nil +} + +func (config *config) validate(seen map[string]bool) error { + if config.maxRequests < 1 { + return errMaxRequestsConfiguration + } + + err := config.validateCredentials(seen) + if err != nil { + return err + } + + identity, valid := canonicalName(config.identity) + if !seen["identity"] || !valid { + return errIdentityConfiguration + } + + config.identity = identity + + if !seen["upstream"] { + return errUpstreamRequired + } + + if err := loopbackAddress(config.address); err != nil { + return err + } + + return loopbackAddress(config.upstream) +} + +func loopbackAddress(address string) error { + host, port, err := net.SplitHostPort(address) + if err != nil { + return errLoopbackAddress + } + + ip, err := netip.ParseAddr(host) + if err != nil || !ip.IsLoopback() || ip.Zone() != "" { + return errLoopbackAddress + } + + number, err := strconv.Atoi(port) + if err != nil || number < 1 || number > maxPort { + return errInvalidPort + } + + return nil +} + +func parse(controller *caddy.Controller) (config, error) { + var options config + + options.address = defaultAddress + options.maxRequests = defaultMaxRequests + + if !controller.Next() { + return options, controller.ArgErr() + } + + args := controller.RemainingArgs() + if len(args) > 1 { + return options, controller.ArgErr() + } + + if len(args) == 1 { + options.address = args[0] + } + + seen := map[string]bool{} + + for controller.NextBlock() { + name := controller.Val() + args := controller.RemainingArgs() + + if seen[name] || len(args) != 1 { + return options, controller.Err( + "each property requires one value and may appear only once", + ) + } + + seen[name] = true + + err := options.set(name, args[0]) + if err != nil { + return options, err + } + } + + if controller.Next() { + return options, plugin.ErrOnce + } + + err := options.validate(seen) + + return options, err +} + +func (config *config) validateCredentials(seen map[string]bool) error { + if seen["token"] == seen["token_env"] || len(config.token) < minTokenLength || + strings.ContainsAny(config.token, " \t\r\n") { + + return errTokenConfiguration + } + + if seen["secret"] == seen["secret_env"] { + return errSecretConfiguration + } + + secret, err := base64.StdEncoding.DecodeString(config.secret) + if err != nil || len(secret) < minSecretBytes { + return errInvalidSecret + } + + return nil +} diff --git a/plugins/dynapi/config_test.go b/plugins/dynapi/config_test.go new file mode 100644 index 0000000..06b97a9 --- /dev/null +++ b/plugins/dynapi/config_test.go @@ -0,0 +1,199 @@ +package dynapi + +import ( + "strings" + "testing" + + "github.com/coredns/caddy" +) + +//nolint:funlen,maintidx // Keep the cases local. Fixture data inflates size metrics. +func TestParse(t *testing.T) { + const ( + token = "abcdefghijklmnopqrstuvwxyz123456" + secret = "YWJjZGVmZ2hpamtsbW5vcHFyc3R1dnd4" + ) + + t.Setenv("DYNAPI_TEST_TOKEN", token) + t.Setenv("DYNAPI_TEST_SECRET", secret) + + valid := `dynapi 127.0.0.1:8080 { + token_env DYNAPI_TEST_TOKEN + identity update-key.example.org. + secret_env DYNAPI_TEST_SECRET + upstream 127.0.0.1:1053 +} +` + cases := []struct { + name, input, address string + maxRequests int + invalid bool + }{ + { + name: "environment credentials", + input: valid, + address: "127.0.0.1:8080", + maxRequests: defaultMaxRequests, + }, + { + name: "literal credentials", + input: `dynapi { + token ` + token + ` + identity update-key.example.org. + secret ` + secret + ` + upstream 127.0.0.1:1053 + }`, + address: defaultAddress, + maxRequests: defaultMaxRequests, + }, + { + name: "one active request", + input: strings.Replace(valid, "upstream", "max_requests 1\n upstream", 1), + address: defaultAddress, + maxRequests: 1, + }, + { + name: "custom request limit", + input: strings.Replace(valid, "upstream", "max_requests 64\n upstream", 1), + address: defaultAddress, + maxRequests: 64, + }, + { + name: "zero request limit", + input: strings.Replace(valid, "upstream", "max_requests 0\n upstream", 1), + invalid: true, + }, + { + name: "negative request limit", + input: strings.Replace(valid, "upstream", "max_requests -1\n upstream", 1), + invalid: true, + }, + { + name: "non-integer request limit", + input: strings.Replace(valid, "upstream", "max_requests 1.5\n upstream", 1), + invalid: true, + }, + { + name: "overflowing request limit", + input: strings.Replace( + valid, + "upstream", + "max_requests 99999999999999999999\n upstream", + 1, + ), + invalid: true, + }, + { + name: "duplicate request limit", + input: strings.Replace( + valid, + "upstream", + "max_requests 1\n max_requests 2\n upstream", + 1, + ), + invalid: true, + }, + {name: "missing settings", input: "dynapi", invalid: true}, + { + name: "non-loopback listener", + input: strings.Replace(valid, "127.0.0.1:8080", "0.0.0.0:8080", 1), + invalid: true, + }, + { + name: "upstream hostname", + input: strings.Replace(valid, "127.0.0.1:1053", "localhost:1053", 1), + invalid: true, + }, + { + name: "zero port", + input: strings.Replace(valid, "127.0.0.1:8080", "127.0.0.1:0", 1), + invalid: true, + }, + { + name: "short token", + input: strings.Replace(valid, "token_env DYNAPI_TEST_TOKEN", "token short", 1), + invalid: true, + }, + { + name: "invalid secret", + input: strings.Replace( + valid, + "secret_env DYNAPI_TEST_SECRET", + "secret invalid-base64", + 1, + ), + invalid: true, + }, + { + name: "wildcard identity", + input: strings.Replace( + valid, + "identity update-key.example.org.", + "identity *.example.org.", + 1, + ), + invalid: true, + }, + { + name: "duplicate token", + input: strings.Replace( + valid, + "token_env DYNAPI_TEST_TOKEN", + `token_env DYNAPI_TEST_TOKEN + token_env DYNAPI_TEST_TOKEN`, + 1, + ), + invalid: true, + }, + { + name: "conflicting tokens", + input: strings.Replace( + valid, + "token_env DYNAPI_TEST_TOKEN", + `token_env DYNAPI_TEST_TOKEN + token `+token, + 1, + ), + invalid: true, + }, + { + name: "conflicting secrets", + input: strings.Replace( + valid, + "secret_env DYNAPI_TEST_SECRET", + `secret_env DYNAPI_TEST_SECRET + secret `+secret, + 1, + ), + invalid: true, + }, + { + name: "unknown property", + input: strings.Replace(valid, "upstream 127.0.0.1:1053", "unknown value", 1), + invalid: true, + }, + {name: "duplicate directive", input: valid + valid, invalid: true}, + } + + //nolint:paralleltest // These cases share process-wide environment settings. + for _, test := range cases { + t.Run(test.name, func(t *testing.T) { + got, err := parse(caddy.NewTestController("dns", test.input)) + if (err != nil) != test.invalid { + t.Fatalf("parse error=%v; want invalid=%t", err, test.invalid) + } + + if test.invalid { + return + } + + want := config{ + address: test.address, token: token, identity: "update-key.example.org.", + secret: secret, upstream: "127.0.0.1:1053", maxRequests: test.maxRequests, + } + if got != want { + t.Fatal("parsed configuration does not match the supplied settings") + } + }) + } +} diff --git a/plugins/dynapi/connection_pool.go b/plugins/dynapi/connection_pool.go new file mode 100644 index 0000000..d6d0b18 --- /dev/null +++ b/plugins/dynapi/connection_pool.go @@ -0,0 +1,149 @@ +package dynapi + +import ( + "context" + "fmt" + "sync" + "time" + + "github.com/miekg/dns" +) + +type connectionPool struct { + client *dns.Client + connections map[*pooledConnection]struct{} + slots chan struct{} + done chan struct{} + address string + idle []*pooledConnection + idleTimeout time.Duration + maxQueries int + mutex sync.Mutex + closed bool +} + +func newConnectionPool(client *dns.Client, address string, limit int, + idleTimeout time.Duration, maxQueries int, +) *connectionPool { + return &connectionPool{ + client: client, address: address, idleTimeout: idleTimeout, maxQueries: maxQueries, + connections: make(map[*pooledConnection]struct{}), + idle: nil, mutex: sync.Mutex{}, closed: false, + slots: make(chan struct{}, limit), done: make(chan struct{}), + } +} + +func (connectionPool *connectionPool) acquire(ctx context.Context) (*pooledConnection, error) { + select { + case <-ctx.Done(): + return nil, fmt.Errorf("wait for DNS connection: %w", ctx.Err()) + case <-connectionPool.done: + return nil, errPoolClosed + case connectionPool.slots <- struct{}{}: + } + + connectionPool.mutex.Lock() + + if connectionPool.closed { + connectionPool.mutex.Unlock() + <-connectionPool.slots + + return nil, errPoolClosed + } + + for len(connectionPool.idle) > 0 { + index := len(connectionPool.idle) - 1 + connection := connectionPool.idle[index] + + connectionPool.idle[index] = nil + connectionPool.idle = connectionPool.idle[:index] + + if time.Since(connection.lastUsed) < connectionPool.idleTimeout && + (connectionPool.maxQueries < 0 || connection.queries < connectionPool.maxQueries) { + + connection.reusable = false + connectionPool.mutex.Unlock() + + return connection, nil + } + + delete(connectionPool.connections, connection) + + _ = connection.Conn.Close() + } + + connectionPool.mutex.Unlock() + + connection, err := connectionPool.dial(ctx) + if err != nil { + <-connectionPool.slots + } + + return connection, err +} + +func (connectionPool *connectionPool) dial(ctx context.Context) (*pooledConnection, error) { + conn, err := connectionPool.client.DialContext(ctx, connectionPool.address) + if err != nil { + return nil, fmt.Errorf("dial DNS backend: %w", err) + } + + connection := &pooledConnection{ + Conn: conn.Conn, + lastUsed: time.Time{}, + queries: 0, + reusable: false, + } + + connectionPool.mutex.Lock() + defer connectionPool.mutex.Unlock() + + if connectionPool.closed { + _ = connection.Conn.Close() + + return nil, errPoolClosed + } + + connectionPool.connections[connection] = struct{}{} + + return connection, nil +} + +func (connectionPool *connectionPool) release(connection *pooledConnection) { + connectionPool.mutex.Lock() + defer connectionPool.mutex.Unlock() + defer func() { <-connectionPool.slots }() + + if !connection.reusable || connectionPool.closed { + delete(connectionPool.connections, connection) + + _ = connection.Conn.Close() + + return + } + + connection.queries++ + + connection.lastUsed = time.Now() + connectionPool.idle = append(connectionPool.idle, connection) +} + +func (connectionPool *connectionPool) close() { + connectionPool.mutex.Lock() + defer connectionPool.mutex.Unlock() + + if connectionPool.closed { + return + } + + connectionPool.closed = true + close(connectionPool.done) + + for connection := range connectionPool.connections { + _ = connection.Conn.Close() + } + + clear(connectionPool.connections) + + connectionPool.idle = nil +} diff --git a/plugins/dynapi/connection_pool_test.go b/plugins/dynapi/connection_pool_test.go new file mode 100644 index 0000000..5d2bc32 --- /dev/null +++ b/plugins/dynapi/connection_pool_test.go @@ -0,0 +1,219 @@ +package dynapi + +import ( + "context" + "errors" + "net" + "testing" + "time" + + "github.com/miekg/dns" +) + +func TestAcquire(t *testing.T) { + t.Parallel() + t.Run("bounded and cancellable", checkAcquireLimit) + + for _, test := range []struct { + name string + maxQueries int + age time.Duration + }{ + {name: "expired idle connection", maxQueries: -1, age: 2 * time.Second}, + {name: "query limit", maxQueries: 1}, + } { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + + checkAcquireRetirement(t, test.maxQueries, test.age) + }) + } +} + +func TestRelease(t *testing.T) { + t.Parallel() + + pool := newTestConnectionPool(t, 1) + + connection, err := pool.acquire(t.Context()) + if err != nil { + t.Fatal(err) + } + + connection.reusable = true + pool.release(connection) + + next, err := pool.acquire(t.Context()) + if err != nil { + t.Fatal(err) + } + + if next != connection || next.reusable { + t.Fatal("connection reuse did not reset its state") + } + + pool.release(next) + + if deadlineErr := next.SetDeadline(time.Now()); !errors.Is(deadlineErr, net.ErrClosed) { + t.Fatalf("failed connection remains open: %v", deadlineErr) + } + + replacement, err := pool.acquire(t.Context()) + if err != nil { + t.Fatal(err) + } + + if replacement == connection { + t.Fatal("failed connection was reused") + } + + pool.release(replacement) +} + +func TestClose(t *testing.T) { + t.Parallel() + + pool := newTestConnectionPool(t, 2) + + idle, err := pool.acquire(t.Context()) + if err != nil { + t.Fatal(err) + } + + if closeErr := idle.Close(); closeErr != nil { + t.Fatal(closeErr) + } + + if deadlineErr := idle.SetDeadline(time.Time{}); deadlineErr != nil { + t.Fatalf("transfer closed a pooled socket: %v", deadlineErr) + } + + borrowed, err := pool.acquire(t.Context()) + if err != nil { + t.Fatal(err) + } + + idle.reusable = true + pool.release(idle) + pool.close() + pool.close() + + for _, connection := range []*pooledConnection{idle, borrowed} { + if err := connection.SetDeadline(time.Now()); !errors.Is(err, net.ErrClosed) { + t.Fatalf("shutdown left a socket open: %v", err) + } + } + + if _, err := pool.acquire(t.Context()); !errors.Is(err, errPoolClosed) { + t.Fatalf("closed acquire error=%v", err) + } + + pool.release(borrowed) +} + +func checkAcquireLimit(t *testing.T) { + t.Helper() + t.Parallel() + + pool := newTestConnectionPool(t, 1) + + connection, err := pool.acquire(t.Context()) + if err != nil { + t.Fatal(err) + } + + ctx, cancel := context.WithCancel(t.Context()) + cancel() + + if _, err := pool.acquire(ctx); !errors.Is(err, context.Canceled) { + t.Fatalf("saturated acquire error=%v", err) + } + + if len(pool.connections) != 1 { + t.Fatal("pool exceeded its limit") + } + + pool.release(connection) +} + +func checkAcquireRetirement(t *testing.T, maxQueries int, age time.Duration) { + t.Helper() + + pool := newTestConnectionPool(t, 1) + + pool.maxQueries = maxQueries + + connection, err := pool.acquire(t.Context()) + if err != nil { + t.Fatal(err) + } + + connection.reusable = true + pool.release(connection) + + connection.lastUsed = connection.lastUsed.Add(-age) + + next, err := pool.acquire(t.Context()) + if err != nil { + t.Fatal(err) + } + + if next == connection { + t.Fatal("retired connection was reused") + } + + if err := connection.SetDeadline(time.Now()); !errors.Is(err, net.ErrClosed) { + t.Fatalf("retired connection remains open: %v", err) + } + + pool.release(next) +} + +func newTestConnectionPool(t *testing.T, limit int) *connectionPool { + t.Helper() + + listener, err := (&net.ListenConfig{}).Listen(t.Context(), "tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + + pool := newConnectionPool( + &dns.Client{Net: "tcp"}, + listener.Addr().String(), + limit, + time.Second, + -1, + ) + done := make(chan struct{}) + + go func() { + defer close(done) + + var peers []net.Conn + + defer func() { + for _, peer := range peers { + _ = peer.Close() + } + }() + + for { + peer, err := listener.Accept() + if err != nil { + return + } + + peers = append(peers, peer) + } + }() + + t.Cleanup(func() { + pool.close() + + _ = listener.Close() + + <-done + }) + + return pool +} diff --git a/plugins/dynapi/dns_name.go b/plugins/dynapi/dns_name.go new file mode 100644 index 0000000..fa8e3a7 --- /dev/null +++ b/plugins/dynapi/dns_name.go @@ -0,0 +1,39 @@ +package dynapi + +import ( + "strings" + + "github.com/miekg/dns" +) + +func canonicalName(value string) (string, bool) { + name := strings.ToLower(dns.Fqdn(value)) + if len(name) > 254 || name == "." { + return "", false + } + + for label := range strings.SplitSeq(strings.TrimSuffix(name, "."), ".") { + if !validLabel(label) { + return "", false + } + } + + return name, true +} + +func validLabel(label string) bool { + if label == "" || len(label) > 63 { + return false + } + + for _, character := range label { + letter := character >= 'a' && character <= 'z' + digit := character >= '0' && character <= '9' + + if !letter && !digit && character != '-' && character != '_' { + return false + } + } + + return true +} diff --git a/plugins/dynapi/dynapi.go b/plugins/dynapi/dynapi.go new file mode 100644 index 0000000..8ed5bba --- /dev/null +++ b/plugins/dynapi/dynapi.go @@ -0,0 +1,125 @@ +package dynapi + +import ( + "context" + "errors" + "fmt" + "net" + "net/http" + "slices" + "sync/atomic" + "time" + + "github.com/coredns/coredns/core/dnsserver" + "github.com/coredns/coredns/plugin" + "github.com/coredns/coredns/plugin/dynupdate" + "github.com/miekg/dns" +) + +const ( + headerTimeout = 5 * time.Second + readTimeout = 10 * time.Second + writeTimeout = 15 * time.Second + idleTimeout = 30 * time.Second + maxHeaderBytes = 8 << 10 +) + +type dynAPI struct { + next plugin.Handler + server *http.Server + backend *backend + ready atomic.Bool +} + +// Name identifies the plugin in the CoreDNS handler chain. +func (*dynAPI) Name() string { return pluginName } + +// Ready reports whether the HTTP listener is accepting requests. +func (dynAPI *dynAPI) Ready() bool { return dynAPI.ready.Load() } + +// ServeDNS leaves ordinary DNS requests to the configured DNS plugins. +func (dynAPI *dynAPI) ServeDNS(ctx context.Context, w dns.ResponseWriter, r *dns.Msg) (int, error) { + return plugin.NextOrFailure(pluginName, dynAPI.next, ctx, w, r) +} + +func (dynAPI *dynAPI) serve(listener net.Listener) { + defer dynAPI.ready.Store(false) + + err := dynAPI.server.Serve(listener) + if err != nil && !errors.Is(err, http.ErrServerClosed) { + log.Errorf("HTTP listener failed: %v", err) + } +} + +func (dynAPI *dynAPI) start(cfg *dnsserver.Config, options *config) error { + if err := validateBackend(cfg, options); err != nil { + return plugin.Error(pluginName, err) + } + + listener, err := (&net.ListenConfig{}).Listen(context.Background(), "tcp", options.address) + if err != nil { + return fmt.Errorf("listen for HTTP requests: %w", err) + } + + dynAPI.backend = newBackend(cfg, options) + + dynAPI.server = &http.Server{ + Handler: newHandler(cfg.Zone, options.token, dynAPI.backend, options.maxRequests), + ReadHeaderTimeout: headerTimeout, + ReadTimeout: readTimeout, + WriteTimeout: writeTimeout, + IdleTimeout: idleTimeout, + MaxHeaderBytes: maxHeaderBytes, + } + dynAPI.ready.Store(true) + + go dynAPI.serve(listener) + + return nil +} + +func (dynAPI *dynAPI) stop() error { + dynAPI.ready.Store(false) + + if dynAPI.server == nil { + return nil + } + + defer dynAPI.backend.pool.close() + + ctx, cancel := context.WithTimeout(context.Background(), requestTimeout) + defer cancel() + + err := dynAPI.server.Shutdown(ctx) + if err != nil { + return fmt.Errorf("drain HTTP requests: %w", errors.Join(err, dynAPI.server.Close())) + } + + return nil +} + +func validateBackend(cfg *dnsserver.Config, options *config) error { + zone, valid := cfg.Handler("dynupdate").(*dynupdate.DynUpdate) + if !valid || zone.Zone != cfg.Zone { + return errDNSBackend + } + + if cfg.Handler("tsig") == nil || cfg.Handler("transfer") == nil { + return errBridgePlugins + } + + host, port, err := net.SplitHostPort(options.upstream) + if err != nil { + return fmt.Errorf("parse DNS upstream: %w", err) + } + + if port != cfg.Port { + return errDNSPort + } + + if !slices.Contains(cfg.ListenHosts, "") && !slices.Contains(cfg.ListenHosts, host) { + return errDNSHost + } + + return nil +} diff --git a/plugins/dynapi/error_response.go b/plugins/dynapi/error_response.go new file mode 100644 index 0000000..5bbe91c --- /dev/null +++ b/plugins/dynapi/error_response.go @@ -0,0 +1,90 @@ +package dynapi + +import ( + "net/http" + "strings" + + "github.com/swaggest/openapi-go" + "github.com/swaggest/openapi-go/openapi31" +) + +const ( + codeUnauthorized = "unauthorized" + codeInvalidJSON = "invalid_json" + codeUpdateDenied = "update_denied" + codeZoneNotFound = "zone_not_found" + codeRecordSetNotFound = "record_set_not_found" + codeResourceNotFound = "resource_not_found" + codeMethodNotAllowed = "method_not_allowed" + codeRecordConflict = "record_conflict" + codeBodyTooLarge = "body_too_large" + codeUnsupportedContentType = "unsupported_content_type" + codeInvalidName = "invalid_name" + codeUnsupportedRecordType = "unsupported_record_type" + codeInvalidAddress = "invalid_address" + codeInvalidRecordSet = "invalid_record_set" + codeBackendFailure = "backend_failure" + codeBackendRejected = "backend_rejected" + codeReadLimitExceeded = "read_limit_exceeded" + codeTooManyRequests = "too_many_requests" +) + +type errorResponse struct { + Code string `json:"code" required:"true"` + Error string `json:"error" required:"true"` +} + +func responseCodes() map[int][]string { + return map[int][]string{ + http.StatusBadRequest: {codeInvalidJSON}, + http.StatusUnauthorized: {codeUnauthorized}, + http.StatusForbidden: {codeUpdateDenied}, + http.StatusNotFound: { + codeZoneNotFound, + codeRecordSetNotFound, + codeResourceNotFound, + }, + http.StatusMethodNotAllowed: {codeMethodNotAllowed}, + http.StatusConflict: {codeRecordConflict}, + http.StatusRequestEntityTooLarge: {codeBodyTooLarge}, + http.StatusUnsupportedMediaType: {codeUnsupportedContentType}, + http.StatusUnprocessableEntity: { + codeInvalidName, codeUnsupportedRecordType, codeInvalidAddress, codeInvalidRecordSet, + }, + http.StatusBadGateway: { + codeBackendFailure, + codeBackendRejected, + codeReadLimitExceeded, + }, + http.StatusServiceUnavailable: {codeTooManyRequests}, + } +} + +func addErrorResponse( + context openapi.OperationContext, + reflector *openapi31.Reflector, + status int, +) { + context.AddRespStructure(errorResponse{}, openapi.WithHTTPStatus(status), + openapi.WithCustomize(func(content openapi.ContentOrReference) { + response, ok := content.(*openapi31.ResponseOrReference) + if !ok { + return + } + + media := response.ResponseEns().Content["application/json"] + name := strings.ReplaceAll(http.StatusText(status), " ", "") + "Response" + reflector.Spec.ComponentsEns().WithSchemasItem(name, map[string]any{ + "allOf": []any{ + media.Schema, + map[string]any{"type": "object", "properties": map[string]any{ + "code": map[string]any{"type": "string", "enum": responseCodes()[status]}, + }}, + }, + }) + + media.Schema = map[string]any{"$ref": "#/components/schemas/" + name} + response.ResponseEns().Content["application/json"] = media + }), + ) +} diff --git a/plugins/dynapi/errors.go b/plugins/dynapi/errors.go new file mode 100644 index 0000000..72b03cc --- /dev/null +++ b/plugins/dynapi/errors.go @@ -0,0 +1,26 @@ +package dynapi + +import "errors" + +var ( + errPoolClosed = errors.New("DNS connection pool is closed") + errMaxRequestsConfiguration = errors.New("max_requests must be a positive integer") + errUnknownProperty = errors.New("unknown dynapi property") + errTokenConfiguration = errors.New( + "configure token or token_env with a token of 32+ characters without whitespace", + ) + errIdentityConfiguration = errors.New( + "identity must be a literal dynupdate permission key name", + ) + errSecretConfiguration = errors.New("configure secret or secret_env for TSIG signing") + errInvalidSecret = errors.New("TSIG secret must be base64 encoding at least 16 bytes") + errUpstreamRequired = errors.New("upstream is required") + errLoopbackAddress = errors.New("address must be a literal loopback IP and port") + errInvalidPort = errors.New("port must be 1..65535") + errUnsignedResponse = errors.New("unsigned DNS update response") + errDNSBackend = errors.New("dynupdate must serve the same single zone") + errBridgePlugins = errors.New("tsig and transfer are required for the DNS bridge") + errDNSPort = errors.New("upstream must use this server block's DNS port") + errDNSHost = errors.New("upstream must use an address bound by this server block") + errRestartRequired = errors.New("configuration changes require a process restart") +) diff --git a/plugins/dynapi/handler.go b/plugins/dynapi/handler.go new file mode 100644 index 0000000..9a3d97c --- /dev/null +++ b/plugins/dynapi/handler.go @@ -0,0 +1,148 @@ +package dynapi + +import ( + "context" + "crypto/sha256" + "crypto/subtle" + "net/http" + "strings" + "time" +) + +const ( + requestTimeout = 5 * time.Second + recordPath = "/v1/zones/{zone}/records/{name}/{type}" +) + +type handler struct { + backend recordBackend + mux *http.ServeMux + slots chan struct{} + zone string + token [sha256.Size]byte +} + +func newHandler(zone, token string, records recordBackend, maxRequests int) *handler { + handler := &handler{ + backend: records, mux: http.NewServeMux(), + slots: make(chan struct{}, maxRequests), + zone: zone, + token: sha256.Sum256([]byte(token)), + } + for _, operation := range recordOperations() { + handler.mux.HandleFunc(operation.method+" "+recordPath, handler.serveRequest) + } + + handler.mux.HandleFunc("HEAD "+recordPath, handler.methodNotAllowed) + handler.mux.HandleFunc(recordPath, handler.methodNotAllowed) + handler.mux.HandleFunc("/", handler.notFound) + + return handler +} + +// ServeHTTP authenticates each request before resolving a record or admitting work. +func (handler *handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Cache-Control", "no-store") + + if !handler.authenticated(r) { + w.Header().Set("WWW-Authenticate", "Bearer") + writeError(w, http.StatusUnauthorized, codeUnauthorized, "a valid bearer token is required") + + return + } + + select { + case handler.slots <- struct{}{}: + defer func() { <-handler.slots }() + default: + writeError( + w, + http.StatusServiceUnavailable, + codeTooManyRequests, + "too many active requests", + ) + + return + } + + handler.mux.ServeHTTP(w, r) +} + +func (handler *handler) authenticated(r *http.Request) bool { + values := r.Header.Values("Authorization") + if len(values) != 1 || !strings.HasPrefix(values[0], "Bearer ") { + return false + } + + supplied := sha256.Sum256([]byte(strings.TrimPrefix(values[0], "Bearer "))) + + return subtle.ConstantTimeCompare(handler.token[:], supplied[:]) == 1 +} + +func (handler *handler) serveRecord( + ctx context.Context, w http.ResponseWriter, r *http.Request, resource recordResource, +) { + var ( + result recordSet + err error + ) + + switch r.Method { + case http.MethodGet: + result, err = handler.backend.read(ctx, resource.name, resource.rrtype) + case http.MethodPut: + result, err = decodeRecordSet(w, r, resource.rrtype) + if err == nil { + err = handler.backend.replace(ctx, resource.name, resource.rrtype, result) + } + case http.MethodDelete: + err = handler.backend.delete(ctx, resource.name, resource.rrtype) + default: + writeError(w, http.StatusMethodNotAllowed, codeMethodNotAllowed, "method not allowed") + + return + } + + if err != nil { + writeFailure(w, err) + + return + } + + if r.Method == http.MethodDelete { + w.WriteHeader(http.StatusNoContent) + + return + } + + writeJSON(w, http.StatusOK, result) +} + +func (handler *handler) serveRequest(w http.ResponseWriter, r *http.Request) { + input := recordInput{ + Zone: r.PathValue("zone"), + Name: r.PathValue("name"), + Type: r.PathValue("type"), + } + + resource, err := input.resource(handler.zone) + if err != nil { + writeFailure(w, err) + + return + } + + ctx, cancel := context.WithTimeout(r.Context(), requestTimeout) + defer cancel() + + handler.serveRecord(ctx, w, r, resource) +} + +func (*handler) methodNotAllowed(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Allow", "GET, PUT, DELETE") + writeError(w, http.StatusMethodNotAllowed, codeMethodNotAllowed, "method not allowed") +} + +func (*handler) notFound(w http.ResponseWriter, _ *http.Request) { + writeError(w, http.StatusNotFound, codeResourceNotFound, "resource not found") +} diff --git a/plugins/dynapi/handler_benchmark_test.go b/plugins/dynapi/handler_benchmark_test.go new file mode 100644 index 0000000..d8739db --- /dev/null +++ b/plugins/dynapi/handler_benchmark_test.go @@ -0,0 +1,50 @@ +package dynapi + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +// BenchmarkServeHTTP includes request construction and response encoding. +// The in-memory backend keeps DNS transfer and disk costs out of these timings. +func BenchmarkServeHTTP(b *testing.B) { + cases := []struct { + name, method, body, token string + status int + }{ + {"GET", http.MethodGet, "", "secret", http.StatusOK}, + {"PUT", http.MethodPut, `{"ttl":60,"addresses":["192.0.2.1"]}`, "secret", http.StatusOK}, + {"DELETE", http.MethodDelete, "", "secret", http.StatusNoContent}, + {"unauthorized", http.MethodGet, "", "wrong", http.StatusUnauthorized}, + {"invalid_address", http.MethodPut, `{"ttl":60,"addresses":["invalid"]}`, "secret", 422}, + } + + for _, test := range cases { + b.Run(test.name, func(b *testing.B) { + backend := &testBackend{result: recordSet{TTL: 60, Addresses: []string{"192.0.2.1"}}} + handler := newHandler("example.org.", "secret", backend, defaultMaxRequests) + + b.ReportAllocs() + + for b.Loop() { + request := httptest.NewRequestWithContext( + b.Context(), + test.method, + "/v1/zones/example.org/records/host.example.org/A", + strings.NewReader(test.body), + ) + request.Header.Set("Authorization", "Bearer "+test.token) + request.Header.Set("Content-Type", "application/json") + + response := httptest.NewRecorder() + handler.ServeHTTP(response, request) + + if response.Code != test.status { + b.Fatalf("status=%d; want %d", response.Code, test.status) + } + } + }) + } +} diff --git a/plugins/dynapi/handler_test.go b/plugins/dynapi/handler_test.go new file mode 100644 index 0000000..ffe60d0 --- /dev/null +++ b/plugins/dynapi/handler_test.go @@ -0,0 +1,400 @@ +package dynapi + +import ( + "encoding/json/v2" + "fmt" + "net/http" + "net/http/httptest" + "reflect" + "strings" + "sync" + "testing" + "time" +) + +//nolint:funlen,maintidx // Keep the cases local. Fixture data inflates size metrics. +func TestServeHTTP(t *testing.T) { + t.Parallel() + + for _, maxRequests := range []int{1, 3, defaultMaxRequests} { + t.Run(fmt.Sprintf("concurrent admission and recovery/%d", maxRequests), func(t *testing.T) { + t.Parallel() + checkConcurrentAdmission(t, maxRequests) + }) + } + + cases := []struct { + failure error + name, path, body, token, contentType, method, code string + result recordSet + status, writes, active int + }{ + { + method: http.MethodPut, name: "unauthorized", code: codeUnauthorized, + path: "/v1/zones/example.org/records/host.example.org/A", + body: `{"ttl":60,"addresses":["192.0.2.1"]}`, + token: "wrong", + contentType: "application/json", + status: 401, + }, + { + method: http.MethodPut, name: "outside zone", code: codeInvalidName, + path: "/v1/zones/example.org/records/host.other.org/A", + body: `{"ttl":60,"addresses":["192.0.2.1"]}`, + token: "secret", + contentType: "application/json", + status: 422, + }, + { + method: http.MethodPut, name: "suffix is not a label boundary", code: codeInvalidName, + path: "/v1/zones/example.org/records/notexample.org/A", + body: `{"ttl":60,"addresses":["192.0.2.1"]}`, + token: "secret", contentType: "application/json", status: 422, + }, + + { + method: http.MethodPut, name: "wildcard", code: codeInvalidName, + path: "/v1/zones/example.org/records/*.example.org/A", + body: `{"ttl":60,"addresses":["192.0.2.1"]}`, + token: "secret", + contentType: "application/json", + status: 422, + }, + { + method: http.MethodPut, name: "other zone", code: codeZoneNotFound, + path: "/v1/zones/other.org/records/host.other.org/A", + body: `{}`, + token: "secret", + contentType: "application/json", + status: 404, + }, + { + method: http.MethodPut, name: "wrong family", code: codeInvalidAddress, + path: "/v1/zones/example.org/records/host.example.org/A", + body: `{"ttl":60,"addresses":["2001:db8::1"]}`, + token: "secret", + contentType: "application/json", + status: 422, + }, + { + method: http.MethodPut, name: "mapped IPv6", code: codeInvalidAddress, + path: "/v1/zones/example.org/records/host.example.org/AAAA", + body: `{"ttl":60,"addresses":["::ffff:192.0.2.1"]}`, + token: "secret", + contentType: "application/json", + status: 422, + }, + { + method: http.MethodPut, name: "scoped IPv6", code: codeInvalidAddress, + path: "/v1/zones/example.org/records/host.example.org/AAAA", + body: `{"ttl":60,"addresses":["fe80::1%lo0"]}`, + token: "secret", + contentType: "application/json", + status: 422, + }, + { + method: http.MethodPut, name: "missing ttl", code: codeInvalidRecordSet, + path: "/v1/zones/example.org/records/host.example.org/A", + body: `{"addresses":["192.0.2.1"]}`, + token: "secret", + contentType: "application/json", + status: 422, + }, + { + method: http.MethodPut, name: "large ttl", code: codeInvalidRecordSet, + path: "/v1/zones/example.org/records/host.example.org/A", + body: `{"ttl":2147483648,"addresses":["192.0.2.1"]}`, + token: "secret", + contentType: "application/json", + status: 422, + }, + { + method: http.MethodPut, name: "unknown field", code: codeInvalidJSON, + path: "/v1/zones/example.org/records/host.example.org/A", + body: `{"ttl":60,"addresses":["192.0.2.1"],"extra":true}`, + token: "secret", + contentType: "application/json", + status: 400, + }, + { + method: http.MethodPut, name: "duplicate JSON key", code: codeInvalidJSON, + path: "/v1/zones/example.org/records/host.example.org/A", + body: `{"ttl":60,"ttl":120,"addresses":["192.0.2.1"]}`, + token: "secret", contentType: "application/json", status: http.StatusBadRequest, + }, + { + method: http.MethodPut, name: "case-sensitive JSON keys", code: codeInvalidJSON, + path: "/v1/zones/example.org/records/host.example.org/A", + body: `{"TTL":60,"addresses":["192.0.2.1"]}`, + token: "secret", contentType: "application/json", status: http.StatusBadRequest, + }, + { + method: http.MethodPut, name: "invalid UTF-8", code: codeInvalidJSON, + path: "/v1/zones/example.org/records/host.example.org/A", + body: "{\"ttl\":60,\"addresses\":[\"" + string([]byte{0xff}) + "\"]}", + token: "secret", contentType: "application/json", status: http.StatusBadRequest, + }, + { + method: http.MethodPut, name: "trailing whitespace", + path: "/v1/zones/example.org/records/host.example.org/A", + body: "{\"ttl\":60,\"addresses\":[\"192.0.2.1\"]} \n\t", + token: "secret", contentType: "application/json", status: http.StatusOK, writes: 1, + result: recordSet{TTL: 60, Addresses: []string{"192.0.2.1"}}, + }, + + { + method: http.MethodPut, name: "trailing JSON", code: codeInvalidJSON, + path: "/v1/zones/example.org/records/host.example.org/A", + body: `{"ttl":60,"addresses":["192.0.2.1"]}{}`, + token: "secret", + contentType: "application/json", + status: 400, + }, + { + method: http.MethodPut, name: "empty set", code: codeInvalidRecordSet, + path: "/v1/zones/example.org/records/host.example.org/A", + body: `{"ttl":60,"addresses":[]}`, + token: "secret", + contentType: "application/json", + status: 422, + }, + { + method: http.MethodPut, name: "unsupported type", code: codeUnsupportedRecordType, + path: "/v1/zones/example.org/records/host.example.org/TXT", + body: `{}`, + token: "secret", + contentType: "application/json", + status: 422, + }, + { + method: http.MethodPut, name: "wrong media type", code: codeUnsupportedContentType, + path: "/v1/zones/example.org/records/host.example.org/A", + body: `{}`, + token: "secret", + contentType: "text/plain", + status: 415, + }, + { + method: http.MethodPut, + name: "JSON with charset", + path: "/v1/zones/example.org/records/host.example.org/A", + body: `{"ttl":60,"addresses":["192.0.2.1"]}`, + token: "secret", + contentType: "application/json; charset=utf-8", + status: http.StatusOK, + writes: 1, + result: recordSet{TTL: 60, Addresses: []string{"192.0.2.1"}}, + }, + { + method: http.MethodPut, + name: "malformed content type", + code: codeUnsupportedContentType, + path: "/v1/zones/example.org/records/host.example.org/A", + body: `{"ttl":60,"addresses":["192.0.2.1"]}`, + token: "secret", + contentType: "application/json; charset", + status: http.StatusUnsupportedMediaType, + }, + + { + method: http.MethodPut, name: "large body", code: codeBodyTooLarge, + path: "/v1/zones/example.org/records/host.example.org/A", + body: `{"ttl":60,"addresses":["` + strings.Repeat("x", 64<<10) + `"]}`, + token: "secret", + contentType: "application/json", + status: 413, + }, + { + name: "normalize addresses", method: http.MethodPut, + path: "/v1/zones/EXAMPLE.ORG/records/Host.Example.Org/AAAA", + body: `{"ttl":0,"addresses":["2001:db8:0::2","2001:db8::1","2001:db8::2"]}`, + token: "secret", contentType: "application/json", status: http.StatusOK, writes: 1, + result: recordSet{TTL: 0, Addresses: []string{"2001:db8::1", "2001:db8::2"}}, + }, + { + name: "backend conflict", + code: codeRecordConflict, + method: http.MethodDelete, + path: "/v1/zones/example.org/records/host.example.org/A", + token: "secret", + status: http.StatusConflict, + writes: 1, + failure: &apiError{ + status: http.StatusConflict, + code: codeRecordConflict, + message: "CNAME exists", + }, + }, + { + name: "admission limit", code: codeTooManyRequests, method: http.MethodDelete, + path: "/v1/zones/example.org/records/host.example.org/A", token: "secret", + status: http.StatusServiceUnavailable, active: defaultMaxRequests, + }, + { + name: "missing set", + method: http.MethodGet, + code: codeRecordSetNotFound, + path: "/v1/zones/example.org/records/host.example.org/A", + token: "secret", + status: http.StatusNotFound, + failure: &apiError{ + status: http.StatusNotFound, + code: codeRecordSetNotFound, + message: "record set not found", + }, + }, + { + name: "transport failure", method: http.MethodGet, code: codeBackendFailure, + path: "/v1/zones/example.org/records/host.example.org/A", token: "secret", + status: http.StatusBadGateway, failure: errUnsignedResponse, + }, + { + name: "read", method: http.MethodGet, + path: "/v1/zones/example.org/records/host.example.org/A", token: "secret", + status: http.StatusOK, result: recordSet{TTL: 60, Addresses: []string{"192.0.2.1"}}, + }, + { + name: "delete", method: http.MethodDelete, + path: "/v1/zones/example.org/records/host.example.org/A", token: "secret", + status: http.StatusNoContent, writes: 1, + }, + { + name: "HEAD", code: codeMethodNotAllowed, method: http.MethodHead, + path: "/v1/zones/example.org/records/host.example.org/A", token: "secret", + status: http.StatusMethodNotAllowed, + }, + { + name: "unknown path", code: codeResourceNotFound, method: http.MethodGet, + path: "/unknown", token: "secret", status: http.StatusNotFound, + }, + { + name: "unsupported method", code: codeMethodNotAllowed, method: http.MethodPost, + path: "/v1/zones/example.org/records/host.example.org/A", token: "secret", + status: http.StatusMethodNotAllowed, + }, + } + + for _, test := range cases { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + + backend := &testBackend{err: test.failure, result: test.result} + handler := newHandler("example.org.", "secret", backend, defaultMaxRequests) + + handler.slots = make(chan struct{}, defaultMaxRequests-test.active) + + r := httptest.NewRequestWithContext( + t.Context(), + test.method, + test.path, + strings.NewReader(test.body), + ) + r.Header.Set("Authorization", "Bearer "+test.token) + r.Header.Set("Content-Type", test.contentType) + + w := httptest.NewRecorder() + handler.ServeHTTP(w, r) + + if w.Code != test.status || backend.writes != test.writes { + t.Fatalf("status=%d writes=%d body=%s; want status=%d writes=%d", + w.Code, backend.writes, w.Body, test.status, test.writes) + } + + checkErrorResponse(t, w.Body.Bytes(), test.code) + + if test.status != http.StatusOK { + return + } + + var got recordSet + + err := json.Unmarshal(w.Body.Bytes(), &got) + if err != nil || !reflect.DeepEqual(got, test.result) { + t.Fatalf("result=%+v error=%v; want %+v", got, err, test.result) + } + }) + } +} + +func checkErrorResponse(t *testing.T, body []byte, code string) { + t.Helper() + + if code == "" { + return + } + + var failure errorResponse + + if err := json.Unmarshal(body, &failure); err != nil { + t.Fatalf("decode error response: %v", err) + } + + if failure.Code != code || failure.Error == "" { + t.Fatalf("error=%+v; want code=%s and a message", failure, code) + } +} + +func checkConcurrentAdmission(t *testing.T, maxRequests int) { + t.Helper() + + const path = "/v1/zones/example.org/records/host.example.org/A" + + backend := &blockingBackend{ + started: make(chan struct{}, maxRequests), release: make(chan struct{}), + } + handler := newHandler("example.org.", "secret", backend, maxRequests) + + var requests sync.WaitGroup + + // Cleanup releases blocked readers even if an assertion fails. + defer requests.Wait() + defer func() { + select { + case <-backend.release: + default: + close(backend.release) + } + }() + + for range maxRequests { + requests.Go(func() { checkAdmittedRequest(t, handler) }) + } + + for range maxRequests { + select { + case <-backend.started: + case <-time.After(requestTimeout): + t.Fatal("requests did not reach the backend") + } + } + + response := httptest.NewRecorder() + request := httptest.NewRequestWithContext(t.Context(), http.MethodGet, path, http.NoBody) + request.Header.Set("Authorization", "Bearer secret") + handler.ServeHTTP(response, request) + + if response.Code != http.StatusServiceUnavailable { + t.Fatalf("saturated handler returned %d", response.Code) + } + + checkErrorResponse(t, response.Body.Bytes(), codeTooManyRequests) + + close(backend.release) + requests.Wait() + + checkAdmittedRequest(t, handler) +} + +func checkAdmittedRequest(t *testing.T, handler *handler) { + t.Helper() + + response := httptest.NewRecorder() + request := httptest.NewRequestWithContext(t.Context(), http.MethodGet, + "/v1/zones/example.org/records/host.example.org/A", http.NoBody) + request.Header.Set("Authorization", "Bearer secret") + handler.ServeHTTP(response, request) + + if response.Code != http.StatusOK { + t.Errorf("admitted request returned %d: %s", response.Code, response.Body) + } +} diff --git a/plugins/dynapi/http_response.go b/plugins/dynapi/http_response.go new file mode 100644 index 0000000..53ad495 --- /dev/null +++ b/plugins/dynapi/http_response.go @@ -0,0 +1,37 @@ +package dynapi + +import ( + "encoding/json/v2" + "errors" + "net/http" +) + +func writeError(w http.ResponseWriter, status int, code, message string) { + writeJSON(w, status, errorResponse{Code: code, Error: message}) +} + +func writeFailure(w http.ResponseWriter, err error) { + if failure, ok := errors.AsType[*apiError](err); ok { + writeError(w, failure.status, failure.code, failure.message) + + return + } + + // A lost response can follow a committed write. Do not suggest retrying it. + writeError( + w, + http.StatusBadGateway, + codeBackendFailure, + "backend request failed; a write may already have committed", + ) +} + +func writeJSON(w http.ResponseWriter, status int, value any) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + + err := json.MarshalWrite(w, value) + if err != nil { + log.Debugf("HTTP response could not be written: %v", err) + } +} diff --git a/plugins/dynapi/openapi.go b/plugins/dynapi/openapi.go new file mode 100644 index 0000000..e3ec316 --- /dev/null +++ b/plugins/dynapi/openapi.go @@ -0,0 +1,36 @@ +package dynapi + +import ( + "fmt" + + "github.com/swaggest/openapi-go/openapi31" +) + +// GenerateOpenAPI exports the same operations and models used by the HTTP listener. +func GenerateOpenAPI() ([]byte, error) { + reflector := openapi31.NewReflector() + reflector.Spec.Info.WithTitle("CoreDNS dynapi").WithVersion("1").WithDescription( + "Manage address records through signed DNS requests. Reads cover the configured zone. " + + "Writes follow dynupdate permissions. A lost response may follow a committed write. " + + "There are no automatic retries or conditional writes.") + reflector.Spec.SetHTTPBearerTokenSecurity( + "bearerAuth", + "", + "Private token configured by token or token_env.", + ) + + reflector.Spec.Servers = []openapi31.Server{{URL: "http://127.0.0.1:8080"}} + + for _, operation := range recordOperations() { + if err := operation.reflect(reflector); err != nil { + return nil, err + } + } + + document, err := reflector.Spec.MarshalYAML() + if err != nil { + return nil, fmt.Errorf("serialize OpenAPI: %w", err) + } + + return document, nil +} diff --git a/plugins/dynapi/openapi_test.go b/plugins/dynapi/openapi_test.go new file mode 100644 index 0000000..dc399ac --- /dev/null +++ b/plugins/dynapi/openapi_test.go @@ -0,0 +1,70 @@ +package dynapi + +import ( + "encoding/json/v2" + "net/http" + "reflect" + "strings" + "testing" + + "github.com/swaggest/jsonschema-go" + "github.com/swaggest/openapi-go/openapi31" +) + +func TestGenerateOpenAPI(t *testing.T) { + t.Parallel() + + document, err := GenerateOpenAPI() + if err != nil { + t.Fatal(err) + } + + var spec openapi31.Spec + + if err := spec.UnmarshalYAML(document); err != nil { + t.Fatal(err) + } + + for status, codes := range responseCodes() { + t.Run(http.StatusText(status), func(t *testing.T) { + t.Parallel() + + name := strings.ReplaceAll(http.StatusText(status), " ", "") + "Response" + + schema, err := json.Marshal(spec.Components.Schemas[name]) + if err != nil { + t.Fatal(err) + } + + checkResponseSchema(t, name, schema, codes) + }) + } +} + +func checkResponseSchema(t *testing.T, name string, schema []byte, codes []string) { + t.Helper() + + var response jsonschema.Schema + + if err := json.Unmarshal(schema, &response); err != nil { + t.Fatal(err) + } + + if len(response.AllOf) != 2 || response.AllOf[1].TypeObject == nil { + t.Fatalf("%s has no status-specific restriction: %s", name, schema) + } + + constraint := response.AllOf[1].TypeObject.Properties["code"].TypeObject + if constraint == nil { + t.Fatalf("%s has no code property: %s", name, schema) + } + + values := make([]any, len(codes)) + for index, code := range codes { + values[index] = code + } + + if !reflect.DeepEqual(constraint.Enum, values) { + t.Fatalf("%s does not restrict error codes to %v: %s", name, codes, schema) + } +} diff --git a/plugins/dynapi/operations.go b/plugins/dynapi/operations.go new file mode 100644 index 0000000..9277473 --- /dev/null +++ b/plugins/dynapi/operations.go @@ -0,0 +1,108 @@ +package dynapi + +import ( + "fmt" + "net/http" + + "github.com/swaggest/openapi-go" + "github.com/swaggest/openapi-go/openapi31" +) + +type operation struct { + method, id, summary, description string + errors []int + status int +} + +func (operation *operation) reflect(reflector *openapi31.Reflector) error { + context, err := reflector.NewOperationContext(operation.method, recordPath) + if err != nil { + return fmt.Errorf("create OpenAPI operation: %w", err) + } + + context.SetID(operation.id) + context.SetSummary(operation.summary) + context.SetDescription(operation.description) + context.AddSecurity("bearerAuth") + context.AddReqStructure(recordInput{}) + + if operation.method == http.MethodPut { + context.AddReqStructure( + recordPayload{}, + openapi.WithCustomize(func(content openapi.ContentOrReference) { + if body, ok := content.(*openapi31.RequestBodyOrReference); ok { + body.RequestBodyEns().WithRequired(true) + } + }), + ) + } + + if operation.status == http.StatusNoContent { + context.AddRespStructure(nil, openapi.WithHTTPStatus(operation.status)) + } else { + context.AddRespStructure(recordSet{}, openapi.WithHTTPStatus(operation.status)) + } + + for _, status := range operation.errors { + addErrorResponse(context, reflector, status) + } + + if err := reflector.AddOperation(context); err != nil { + return fmt.Errorf("reflect OpenAPI operation: %w", err) + } + + return nil +} + +func recordOperations() []operation { + common := []int{ + http.StatusUnauthorized, http.StatusNotFound, http.StatusMethodNotAllowed, + http.StatusUnprocessableEntity, http.StatusBadGateway, http.StatusServiceUnavailable, + } + + return []operation{ + { + method: http.MethodGet, + id: "getRecordSet", + summary: "Read an exact stored record set", + status: http.StatusOK, + errors: common, + description: fmt.Sprintf( + "Read a signed AXFR snapshot without wildcard expansion or CNAME traversal. "+ + "Return the lowest stored TTL. Limit: %d records, including the repeated SOA, and %d bytes.", + maxTransferRecords, + maxTransferBytes, + ), + }, + { + method: http.MethodPut, + id: "replaceRecordSet", + summary: "Atomically replace a complete record set", + status: http.StatusOK, + errors: append( + append([]int{}, common...), + http.StatusBadRequest, + http.StatusForbidden, + http.StatusConflict, + http.StatusRequestEntityTooLarge, + http.StatusUnsupportedMediaType, + ), + description: fmt.Sprintf("Replace only the requested type. Body limit: %d bytes. "+ + "Addresses must match the type and are canonicalized, sorted, and deduplicated. "+ + "IPv4-mapped and scoped IPv6 addresses are rejected. TTL controls caching and does not expire records.", + maxRequestBytes), + }, + { + method: http.MethodDelete, + id: "deleteRecordSet", + summary: "Delete one record type's complete set", + status: http.StatusNoContent, + errors: append( + append([]int{}, common...), + http.StatusForbidden, + http.StatusConflict, + ), + description: "Leave other record types intact. Deleting an absent set succeeds.", + }, + } +} diff --git a/plugins/dynapi/pooled_connection.go b/plugins/dynapi/pooled_connection.go new file mode 100644 index 0000000..b4cca7c --- /dev/null +++ b/plugins/dynapi/pooled_connection.go @@ -0,0 +1,18 @@ +package dynapi + +import ( + "net" + "time" +) + +type pooledConnection struct { + net.Conn + + lastUsed time.Time + queries int + reusable bool +} + +// Close leaves ownership with the pool because dns.Transfer closes its input. +// Cancellation and disposal close the underlying Conn directly. +func (*pooledConnection) Close() error { return nil } diff --git a/plugins/dynapi/record_backend.go b/plugins/dynapi/record_backend.go new file mode 100644 index 0000000..e399f7a --- /dev/null +++ b/plugins/dynapi/record_backend.go @@ -0,0 +1,9 @@ +package dynapi + +import "context" + +type recordBackend interface { + read(ctx context.Context, name string, rrtype uint16) (recordSet, error) + replace(ctx context.Context, name string, rrtype uint16, records recordSet) error + delete(ctx context.Context, name string, rrtype uint16) error +} diff --git a/plugins/dynapi/record_input.go b/plugins/dynapi/record_input.go new file mode 100644 index 0000000..a8339f1 --- /dev/null +++ b/plugins/dynapi/record_input.go @@ -0,0 +1,48 @@ +package dynapi + +import ( + "net/http" + "strings" + + "github.com/miekg/dns" +) + +type recordInput struct { + Zone string `description:"Configured zone." maxLength:"254" path:"zone"` + Name string `description:"Full owner name." maxLength:"254" path:"name"` + + Type string `description:"A or AAAA." path:"type" pattern:"^(?:[aA]|[aA]{4})$"` +} + +func (recordInput *recordInput) resource(origin string) (recordResource, error) { + zone, valid := canonicalName(recordInput.Zone) + if !valid || zone != origin { + return recordResource{}, &apiError{ + status: http.StatusNotFound, + code: codeZoneNotFound, + message: "zone not found", + } + } + + name, valid := canonicalName(recordInput.Name) + // Both names are literal and canonical, so a suffix with a label boundary + // is enough. DNS label parsing would allocate slices for each request. + prefix, inZone := strings.CutSuffix(name, zone) + if !valid || !inZone || (prefix != "" && !strings.HasSuffix(prefix, ".")) { + return recordResource{}, &apiError{ + status: http.StatusUnprocessableEntity, + code: codeInvalidName, + message: "name must be a literal name within the configured zone", + } + } + + rrtype := dns.StringToType[strings.ToUpper(recordInput.Type)] + if rrtype != dns.TypeA && rrtype != dns.TypeAAAA { + return recordResource{}, &apiError{ + status: http.StatusUnprocessableEntity, + code: codeUnsupportedRecordType, message: "type must be A or AAAA", + } + } + + return recordResource{name: name, rrtype: rrtype}, nil +} diff --git a/plugins/dynapi/record_payload.go b/plugins/dynapi/record_payload.go new file mode 100644 index 0000000..895b403 --- /dev/null +++ b/plugins/dynapi/record_payload.go @@ -0,0 +1,99 @@ +package dynapi + +import ( + "encoding/json/v2" + "errors" + "mime" + "net/http" + + "github.com/swaggest/jsonschema-go" +) + +const maxRequestBytes = 64 << 10 + +type recordPayload struct { + TTL *uint32 `json:"ttl"` + Addresses []string `json:"addresses"` +} + +// PrepareJSONSchema uses the same bounds checked by normalize. +func (*recordPayload) PrepareJSONSchema(schema *jsonschema.Schema) error { + ttl := schema.Properties["ttl"].TypeObject + addresses := schema.Properties["addresses"].TypeObject + + if ttl == nil || addresses == nil { + return nil + } + + schema.WithRequired("ttl", "addresses") + schema.AdditionalPropertiesEns().WithTypeBoolean(false) + ttl.WithType(jsonschema.Type{SimpleTypes: new(jsonschema.Integer)}). + WithMaximum(maxTTL) + addresses.WithType(jsonschema.Type{SimpleTypes: new(jsonschema.Array)}). + WithMinItems(1). + WithMaxItems(maxAddresses) + + return nil +} + +func decodeRecordSet(w http.ResponseWriter, r *http.Request, rrtype uint16) (recordSet, error) { + if contentType := r.Header.Get("Content-Type"); contentType != "application/json" { + mediaType, _, err := mime.ParseMediaType(contentType) + if err != nil || mediaType != "application/json" { + return recordSet{}, &apiError{ + status: http.StatusUnsupportedMediaType, + code: codeUnsupportedContentType, + message: "Content-Type must be application/json", + } + } + } + + r.Body = http.MaxBytesReader(w, r.Body, maxRequestBytes) + + var payload recordPayload + + if err := json.UnmarshalRead(r.Body, &payload, json.RejectUnknownMembers(true)); err != nil { + return recordSet{}, invalidBody(err) + } + + if payload.TTL == nil { + return recordSet{}, invalidRecordSet() + } + + result := recordSet{TTL: *payload.TTL, Addresses: payload.Addresses} + if err := result.normalize(rrtype); err != nil { + return recordSet{}, err + } + + return result, nil +} + +func invalidAddress() error { + return &apiError{ + status: http.StatusUnprocessableEntity, + code: codeInvalidAddress, message: "addresses must match the requested IP family", + } +} + +func invalidBody(err error) error { + if _, ok := errors.AsType[*http.MaxBytesError](err); ok { + return &apiError{ + status: http.StatusRequestEntityTooLarge, + code: codeBodyTooLarge, message: "request body exceeds 64 KiB", + } + } + + return &apiError{ + status: http.StatusBadRequest, + code: codeInvalidJSON, + message: "body must be one JSON object containing ttl and addresses", + } +} + +func invalidRecordSet() error { + return &apiError{ + status: http.StatusUnprocessableEntity, + code: codeInvalidRecordSet, + message: "ttl must be 0..2147483647 and addresses must contain 1..256 values", + } +} diff --git a/plugins/dynapi/record_resource.go b/plugins/dynapi/record_resource.go new file mode 100644 index 0000000..68caade --- /dev/null +++ b/plugins/dynapi/record_resource.go @@ -0,0 +1,6 @@ +package dynapi + +type recordResource struct { + name string + rrtype uint16 +} diff --git a/plugins/dynapi/record_scan.go b/plugins/dynapi/record_scan.go new file mode 100644 index 0000000..f539cfc --- /dev/null +++ b/plugins/dynapi/record_scan.go @@ -0,0 +1,93 @@ +package dynapi + +import ( + "context" + "fmt" + "net" + "net/http" + "strings" + + "github.com/miekg/dns" +) + +const ( + maxTransferRecords = 10001 + maxTransferBytes = 16 << 20 +) + +type recordScan struct { + name string + records recordSet + count int + bytes int + rrtype uint16 +} + +func (recordScan *recordScan) add(records []dns.RR) error { + for _, record := range records { + recordScan.count++ + + recordScan.bytes += dns.Len(record) + + if recordScan.count > maxTransferRecords || recordScan.bytes > maxTransferBytes { + return &apiError{ + status: http.StatusBadGateway, + code: codeReadLimitExceeded, message: "zone transfer exceeds the API read limit", + } + } + + header := record.Header() + if header.Rrtype == recordScan.rrtype && + strings.EqualFold(dns.Fqdn(header.Name), recordScan.name) { + + recordScan.addAddress(record) + } + } + + return nil +} + +func (recordScan *recordScan) addAddress(record dns.RR) { + if len(recordScan.records.Addresses) == 0 { + recordScan.records.TTL = record.Header().Ttl + } + + switch address := record.(type) { + case *dns.A: + recordScan.records.Addresses = append(recordScan.records.Addresses, address.A.String()) + case *dns.AAAA: + recordScan.records.Addresses = append(recordScan.records.Addresses, address.AAAA.String()) + default: + return + } + + recordScan.records.TTL = min(recordScan.records.TTL, record.Header().Ttl) +} + +func consumeTransfer( + ctx context.Context, conn net.Conn, envelopes <-chan *dns.Envelope, scan *recordScan, +) error { + var failure error + + // Drain after cancellation or failure so the transfer goroutine can finish. + for envelope := range envelopes { + if failure != nil { + continue + } + + switch { + case ctx.Err() != nil: + failure = fmt.Errorf("zone transfer canceled: %w", ctx.Err()) + case envelope.Error != nil: + failure = fmt.Errorf("receive zone transfer: %w", envelope.Error) + default: + failure = scan.add(envelope.RR) + } + + if failure != nil { + _ = conn.Close() + } + } + + return failure +} diff --git a/plugins/dynapi/record_scan_benchmark_test.go b/plugins/dynapi/record_scan_benchmark_test.go new file mode 100644 index 0000000..441fd6b --- /dev/null +++ b/plugins/dynapi/record_scan_benchmark_test.go @@ -0,0 +1,49 @@ +package dynapi + +import ( + "fmt" + "net" + "testing" + + "github.com/miekg/dns" +) + +func BenchmarkAdd(b *testing.B) { + const recordCount = 1000 + + for _, test := range []struct { + name, format, target string + }{ + {"lowercase", "other-%d.example.org.", "host.example.org."}, + {"uppercase", "OTHER-%d.EXAMPLE.ORG.", "HOST.EXAMPLE.ORG."}, + } { + records := make([]dns.RR, recordCount) + for index := range records { + records[index] = &dns.A{ + Hdr: dns.RR_Header{ + Name: fmt.Sprintf(test.format, index), + Rrtype: dns.TypeA, + Ttl: 60, + }, + A: net.ParseIP("192.0.2.1").To4(), + } + } + + records[0].Header().Name = test.target + + b.Run(test.name, func(b *testing.B) { + b.ReportAllocs() + + for b.Loop() { + scan := recordScan{name: "host.example.org.", rrtype: dns.TypeA} + if err := scan.add(records); err != nil { + b.Fatal(err) + } + + if len(scan.records.Addresses) != 1 { + b.Fatal("expected one matching record") + } + } + }) + } +} diff --git a/plugins/dynapi/record_scan_test.go b/plugins/dynapi/record_scan_test.go new file mode 100644 index 0000000..ffa053d --- /dev/null +++ b/plugins/dynapi/record_scan_test.go @@ -0,0 +1,46 @@ +package dynapi + +import ( + "testing" + + "github.com/miekg/dns" +) + +func TestAdd(t *testing.T) { + t.Parallel() + + for _, test := range []struct { + name, record string + matches bool + }{ + {"uppercase owner", "HOST.EXAMPLE.ORG. 60 IN A 192.0.2.1", true}, + {"mixed case owner", "Host.Example.Org. 60 IN A 192.0.2.1", true}, + {"different owner", "other.example.org. 60 IN A 192.0.2.1", false}, + {"wildcard owner", "*.example.org. 60 IN A 192.0.2.1", false}, + {"different type", "host.example.org. 60 IN AAAA 2001:db8::1", false}, + } { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + + record, err := dns.NewRR(test.record) + if err != nil { + t.Fatal(err) + } + + scan := recordScan{name: "host.example.org.", rrtype: dns.TypeA} + if err := scan.add([]dns.RR{record}); err != nil { + t.Fatal(err) + } + + matched := len(scan.records.Addresses) == 1 + if matched != test.matches || scan.count != 1 { + t.Fatalf( + "matched=%t count=%d; want matched=%t count=1", + matched, + scan.count, + test.matches, + ) + } + }) + } +} diff --git a/plugins/dynapi/record_set.go b/plugins/dynapi/record_set.go new file mode 100644 index 0000000..60e86f5 --- /dev/null +++ b/plugins/dynapi/record_set.go @@ -0,0 +1,53 @@ +package dynapi + +import ( + "net/netip" + "slices" + + "github.com/miekg/dns" +) + +const ( + maxIPStringLength = len("ffff:ffff:ffff:ffff:ffff:ffff:ffff:ffff") + maxAddresses = 256 + maxTTL = 1<<31 - 1 +) + +type recordSet struct { + Addresses []string `json:"addresses" minItems:"1" nullable:"false" required:"true"` + + TTL uint32 `json:"ttl" maximum:"4294967295" required:"true"` +} + +func (recordSet *recordSet) normalize(rrtype uint16) error { + if len(recordSet.Addresses) == 0 || len(recordSet.Addresses) > maxAddresses || + recordSet.TTL > maxTTL { + + return invalidRecordSet() + } + + for index, value := range recordSet.Addresses { + ip, err := netip.ParseAddr(value) + if err != nil || !matchingFamily(ip, rrtype) { + return invalidAddress() + } + + // Keep already-canonical strings instead of allocating replacements. + var buffer [maxIPStringLength]byte + + canonical := ip.AppendTo(buffer[:0]) + if string(canonical) != value { + recordSet.Addresses[index] = string(canonical) + } + } + + slices.Sort(recordSet.Addresses) + + recordSet.Addresses = slices.Compact(recordSet.Addresses) + + return nil +} + +func matchingFamily(ip netip.Addr, rrtype uint16) bool { + return ip.Zone() == "" && !ip.Is4In6() && (rrtype == dns.TypeA) == ip.Is4() +} diff --git a/plugins/dynapi/record_set_benchmark_test.go b/plugins/dynapi/record_set_benchmark_test.go new file mode 100644 index 0000000..13a2388 --- /dev/null +++ b/plugins/dynapi/record_set_benchmark_test.go @@ -0,0 +1,40 @@ +package dynapi + +import ( + "fmt" + "slices" + "testing" + + "github.com/miekg/dns" +) + +func BenchmarkNormalize(b *testing.B) { + cases := []struct { + name, format string + size int + rrtype uint16 + }{ + {"A/1", "192.0.2.%d", 1, dns.TypeA}, + {"A/256", "192.0.2.%d", maxAddresses, dns.TypeA}, + {"AAAA/1", "2001:db8::%x", 1, dns.TypeAAAA}, + {"AAAA/256", "2001:db8::%x", maxAddresses, dns.TypeAAAA}, + } + + for _, test := range cases { + b.Run(test.name, func(b *testing.B) { + addresses := make([]string, test.size) + for index := range addresses { + addresses[index] = fmt.Sprintf(test.format, test.size-index-1) + } + + b.ReportAllocs() + + for b.Loop() { + set := recordSet{TTL: 60, Addresses: slices.Clone(addresses)} + if err := set.normalize(test.rrtype); err != nil { + b.Fatal(err) + } + } + }) + } +} diff --git a/plugins/dynapi/record_set_test.go b/plugins/dynapi/record_set_test.go new file mode 100644 index 0000000..60cf563 --- /dev/null +++ b/plugins/dynapi/record_set_test.go @@ -0,0 +1,54 @@ +package dynapi + +import ( + "reflect" + "testing" + + "github.com/miekg/dns" +) + +func TestNormalize(t *testing.T) { + t.Parallel() + + cases := []struct { + name string + addresses, want []string + rrtype uint16 + }{ + {"canonical IPv4", []string{"192.0.2.1"}, []string{"192.0.2.1"}, dns.TypeA}, + {"canonical IPv6", []string{"2001:db8::1"}, []string{"2001:db8::1"}, dns.TypeAAAA}, + { + "expanded IPv6", + []string{"2001:0DB8:0000:0000:0000:0000:0000:0001"}, + []string{"2001:db8::1"}, + dns.TypeAAAA, + }, + { + "longest canonical IPv6", + []string{"FFFF:FFFF:FFFF:FFFF:FFFF:FFFF:FFFF:FFFF"}, + []string{"ffff:ffff:ffff:ffff:ffff:ffff:ffff:ffff"}, + dns.TypeAAAA, + }, + { + "normalize before deduplication", + []string{"2001:db8::2", "2001:0db8:0:0:0:0:0:1", "2001:db8::1"}, + []string{"2001:db8::1", "2001:db8::2"}, + dns.TypeAAAA, + }, + } + + for _, test := range cases { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + + set := recordSet{TTL: 60, Addresses: test.addresses} + if err := set.normalize(test.rrtype); err != nil { + t.Fatal(err) + } + + if !reflect.DeepEqual(set.Addresses, test.want) || set.TTL != 60 { + t.Fatalf("normalized set=%+v; want addresses=%v and ttl=60", set, test.want) + } + }) + } +} diff --git a/plugins/dynapi/setup.go b/plugins/dynapi/setup.go index c1d95fe..3ede7ca 100644 --- a/plugins/dynapi/setup.go +++ b/plugins/dynapi/setup.go @@ -1,18 +1,38 @@ -// Package dynapi registers an HTTP record-management plugin for CoreDNS. +// Package dynapi manages CoreDNS address records through an authenticated HTTP API. package dynapi import ( - "errors" - "github.com/coredns/caddy" + "github.com/coredns/coredns/core/dnsserver" "github.com/coredns/coredns/plugin" + clog "github.com/coredns/coredns/plugin/pkg/log" ) const pluginName = "dynapi" +var log = clog.NewWithPlugin(pluginName) + func init() { plugin.Register(pluginName, setup) } -func setup(_ *caddy.Controller) error { - // Reject configuration until HTTP requests can reach the update backend. - return plugin.Error(pluginName, errors.New("HTTP record management is not implemented yet")) +func setup(controller *caddy.Controller) error { + options, err := parse(controller) + if err != nil { + return plugin.Error(pluginName, err) + } + + cfg := dnsserver.GetConfig(controller) + api := &dynAPI{} + + cfg.AddPlugin(func(next plugin.Handler) plugin.Handler { + api.next = next + + return api + }) + controller.OnStartup(func() error { return api.start(cfg, &options) }) + // Reject reload before Caddy replaces the DNS backend. A restart is required + // until listener handoff and failed-reload recovery are implemented together. + controller.OnRestart(func() error { return plugin.Error(pluginName, errRestartRequired) }) + controller.OnShutdown(api.stop) + + return nil } diff --git a/plugins/dynapi/test_backend_test.go b/plugins/dynapi/test_backend_test.go new file mode 100644 index 0000000..61abde3 --- /dev/null +++ b/plugins/dynapi/test_backend_test.go @@ -0,0 +1,32 @@ +package dynapi + +import "context" + +type testBackend struct { + err error + result recordSet + writes int +} + +func (testBackend *testBackend) read(context.Context, string, uint16) (recordSet, error) { + return testBackend.result, testBackend.err +} + +func (testBackend *testBackend) replace( + _ context.Context, + _ string, + _ uint16, + result recordSet, +) error { + testBackend.writes++ + + testBackend.result = result + + return testBackend.err +} + +func (testBackend *testBackend) delete(context.Context, string, uint16) error { + testBackend.writes++ + + return testBackend.err +} diff --git a/port_test.go b/port_test.go new file mode 100644 index 0000000..d508f30 --- /dev/null +++ b/port_test.go @@ -0,0 +1,31 @@ +//go:build integration + +package main + +import ( + "net" + "testing" +) + +func unusedPort(t *testing.T) int { + t.Helper() + + listener, err := (&net.ListenConfig{}).Listen(t.Context(), "tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + + defer func() { + err := listener.Close() + if err != nil { + t.Error(err) + } + }() + + address, ok := listener.Addr().(*net.TCPAddr) + if !ok { + t.Fatal("expected a TCP listener") + } + + return address.Port +}