diff --git a/Dockerfile b/Dockerfile index e36384b..7600148 100644 --- a/Dockerfile +++ b/Dockerfile @@ -12,12 +12,15 @@ ARG TARGETOS ARG TARGETARCH ARG TARGETPLATFORM RUN CGO_ENABLED=0 GOOS=${TARGETOS} GOARCH=${TARGETARCH} go build -trimpath -ldflags="-s -w" -o /manager ./app/operator/cmd/manager \ - && go run ./scripts/verify-controller-architecture /manager "${TARGETPLATFORM}" + && go run ./scripts/verify-controller-architecture /manager "${TARGETPLATFORM}" \ + && CGO_ENABLED=0 GOOS=${TARGETOS} GOARCH=${TARGETARCH} go build -trimpath -ldflags="-s -w" -o /hosted-server ./app/hosted-server/cmd/server \ + && go run ./scripts/verify-controller-architecture /hosted-server "${TARGETPLATFORM}" FROM gcr.io/distroless/static:nonroot WORKDIR / COPY --from=builder /manager /manager +COPY --from=builder /hosted-server /hosted-server USER 65532:65532 ENTRYPOINT ["/manager"] diff --git a/README.md b/README.md index 7cfa014..5c29add 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,8 @@ dns-api is currently focused on: - Amazon Route 53 public hosted zones, and adoption of existing private hosted zones - Cloudflare full zones +- External verification of customer-managed DNS (opt-in, credentialless) +- [Hosted authoritative DNS](./docs/design/hosted-provider.md) served from Kubernetes snapshots (opt-in, credentialless) - `A`, `AAAA`, `TXT`, `CNAME`, `MX`, `CAA`, and delegated `NS` records - Route 53 `ALIAS` records - A Headlamp Desktop plugin for browsing and editing dns-api resources @@ -47,6 +49,16 @@ ghcr.io/appthrust/dns-api: For local development or source installs, use the chart in [deploy/charts/dns-api](./deploy/charts/dns-api). +The hosted provider is opt-in with `providers.hosted.enabled=true` and requires +webhook and cert-manager support for endpoint conversion. It runs the manager's +hosted controllers and a separate `/hosted-server` process from the same image. +The server Service exposes UDP/TCP port 53, forwarding to non-root port 5353. +Set `providers.hosted.server.service.type=LoadBalancer` and the Service VIP/glue +configuration when publishing an authority outside the cluster. Hosted +ZoneClasses omit `identityRef`; configure `nameServers` and delegate the zone +from its parent. See [Hosted Authoritative Runtime](./docs/design/provider-extension.md#hosted-authoritative-runtime) +for runtime settings, namespace scope, and endpoint TTL configuration. + ### Customer-managed DNS (external provider) The external provider is opt-in. It verifies customer-managed `A`, `AAAA`, and diff --git a/app/hosted-server/cmd/server/main.go b/app/hosted-server/cmd/server/main.go new file mode 100644 index 0000000..80ac27f --- /dev/null +++ b/app/hosted-server/cmd/server/main.go @@ -0,0 +1,29 @@ +package main + +import ( + "flag" + "os" + + "github.com/appthrust/dns-api/internal/go/providers/hosted/server" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/log/zap" +) + +func main() { + options := server.Options{} + flag.StringVar(&options.ListenAddr, "listen-address", ":5353", "Address for authoritative DNS over UDP and TCP.") + flag.StringVar(&options.WatchNamespace, "watch-namespace", "", "Namespace containing HostedZone snapshots; empty watches all namespaces.") + flag.IntVar(&options.MaxZones, "max-zones", 4096, "Maximum number of served zones.") + flag.IntVar(&options.UDPRateLimitPerSource, "udp-rate-limit", 1000, "Maximum UDP queries per second per source address.") + flag.StringVar(&options.MetricsAddr, "metrics-bind-address", ":8080", "Metrics listener address; 0 disables the listener.") + flag.StringVar(&options.HealthAddr, "health-probe-bind-address", ":8081", "Health listener address; 0 disables the listener.") + zapOptions := zap.Options{} + zapOptions.BindFlags(flag.CommandLine) + flag.Parse() + ctrl.SetLogger(zap.New(zap.UseFlagOptions(&zapOptions))) + + if err := server.Run(ctrl.SetupSignalHandler(), options); err != nil { + ctrl.Log.Error(err, "problem running hosted DNS server") + os.Exit(1) + } +} diff --git a/app/hosted-server/cmd/server/main_test.go b/app/hosted-server/cmd/server/main_test.go new file mode 100644 index 0000000..c830d92 --- /dev/null +++ b/app/hosted-server/cmd/server/main_test.go @@ -0,0 +1,84 @@ +package main + +import ( + "encoding/json" + "flag" + "os" + "os/exec" + "testing" +) + +// Exercise the real entrypoint's flags; help exits before the Kubernetes client +// and DNS listeners are constructed. +func TestServerFlagsProcess(t *testing.T) { + if os.Getenv("DNS_API_TEST_SERVER_FLAGS") != "1" { + return + } + for i, arg := range os.Args { + if arg == "--" { + os.Args = append([]string{os.Args[0]}, os.Args[i+1:]...) + break + } + } + flag.CommandLine = flag.NewFlagSet("hosted-server", flag.ExitOnError) + flag.CommandLine.Usage = func() { + values := map[string]string{} + flag.CommandLine.VisitAll(func(f *flag.Flag) { values[f.Name] = f.Value.String() }) + if err := json.NewEncoder(os.Stdout).Encode(values); err != nil { + os.Exit(1) + } + } + main() +} + +func TestServerFlags(t *testing.T) { + for _, test := range []struct { + name string + args []string + want map[string]string + }{ + { + name: "defaults", + want: map[string]string{ + "listen-address": ":5353", + "watch-namespace": "", + "max-zones": "4096", + "udp-rate-limit": "1000", + "metrics-bind-address": ":8080", + "health-probe-bind-address": ":8081", + }, + }, + { + name: "overrides", + args: []string{"--listen-address=:1053", "--watch-namespace=tenant-dns", "--max-zones=16", "--udp-rate-limit=25", "--metrics-bind-address=0", "--health-probe-bind-address=0"}, + want: map[string]string{ + "listen-address": ":1053", + "watch-namespace": "tenant-dns", + "max-zones": "16", + "udp-rate-limit": "25", + "metrics-bind-address": "0", + "health-probe-bind-address": "0", + }, + }, + } { + t.Run(test.name, func(t *testing.T) { + args := append([]string{"-test.run=^TestServerFlagsProcess$", "--"}, test.args...) + args = append(args, "--help") + cmd := exec.Command(os.Args[0], args...) + cmd.Env = append(os.Environ(), "DNS_API_TEST_SERVER_FLAGS=1") + output, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("server flag parsing failed: %v\n%s", err, output) + } + var actual map[string]string + if err := json.Unmarshal(output, &actual); err != nil { + t.Fatalf("decode server flags: %v\n%s", err, output) + } + for name, want := range test.want { + if actual[name] != want { + t.Errorf("--%s = %q, want %q", name, actual[name], want) + } + } + }) + } +} diff --git a/app/operator/cmd/manager/main.go b/app/operator/cmd/manager/main.go index 365baa7..3a0bf7f 100644 --- a/app/operator/cmd/manager/main.go +++ b/app/operator/cmd/manager/main.go @@ -9,6 +9,7 @@ import ( gatewayendpointcontroller "github.com/appthrust/dns-api/internal/go/apps/gatewayendpoint/controllers/gateway" serviceendpointcontroller "github.com/appthrust/dns-api/internal/go/apps/serviceendpoint/controllers/service" zoneunitcontroller "github.com/appthrust/dns-api/internal/go/core/controllers/zoneunit" + externalprobe "github.com/appthrust/dns-api/internal/go/core/dnsprobe" corewebhook "github.com/appthrust/dns-api/internal/go/core/webhook" cloudflareidentity "github.com/appthrust/dns-api/internal/go/providers/cloudflare/controllers/identity" cloudflarezoneclass "github.com/appthrust/dns-api/internal/go/providers/cloudflare/controllers/zoneclass" @@ -17,7 +18,9 @@ import ( externalzoneclass "github.com/appthrust/dns-api/internal/go/providers/external/controllers/zoneclass" externalzoneunit "github.com/appthrust/dns-api/internal/go/providers/external/controllers/zoneunit" externalconversion "github.com/appthrust/dns-api/internal/go/providers/external/conversion" - externalprobe "github.com/appthrust/dns-api/internal/go/providers/external/probe" + hostedzoneclass "github.com/appthrust/dns-api/internal/go/providers/hosted/controllers/zoneclass" + hostedzoneunit "github.com/appthrust/dns-api/internal/go/providers/hosted/controllers/zoneunit" + hostedconversion "github.com/appthrust/dns-api/internal/go/providers/hosted/conversion" route53identity "github.com/appthrust/dns-api/internal/go/providers/route53/controllers/identity" route53zoneclass "github.com/appthrust/dns-api/internal/go/providers/route53/controllers/zoneclass" route53zoneunit "github.com/appthrust/dns-api/internal/go/providers/route53/controllers/zoneunit" @@ -28,6 +31,7 @@ import ( endpointconversionv1alpha1 "github.com/appthrust/dns-api/pkg/go/api/endpoint/conversion/v1alpha1" endpointv1alpha1 "github.com/appthrust/dns-api/pkg/go/api/endpoint/v1alpha1" externalv1alpha1 "github.com/appthrust/dns-api/pkg/go/api/external/v1alpha1" + hostedv1alpha1 "github.com/appthrust/dns-api/pkg/go/api/hosted/v1alpha1" route53v1alpha1 "github.com/appthrust/dns-api/pkg/go/api/route53/v1alpha1" "k8s.io/apimachinery/pkg/runtime" utilruntime "k8s.io/apimachinery/pkg/util/runtime" @@ -80,6 +84,7 @@ func init() { utilruntime.Must(endpointconversionv1alpha1.AddToScheme(scheme)) utilruntime.Must(externalv1alpha1.AddToScheme(scheme)) utilruntime.Must(gatewayv1.Install(scheme)) + utilruntime.Must(hostedv1alpha1.AddToScheme(scheme)) utilruntime.Must(route53v1alpha1.AddToScheme(scheme)) } @@ -92,6 +97,7 @@ func main() { route53ControllerEnabled := envBoolOrDefault("ROUTE53_CONTROLLER_ENABLED", true) cloudflareControllerEnabled := envBoolOrDefault("CLOUDFLARE_CONTROLLER_ENABLED", true) externalControllerEnabled := envBoolOrDefault("EXTERNAL_CONTROLLER_ENABLED", false) + hostedControllerEnabled := envBoolOrDefault("HOSTED_CONTROLLER_ENABLED", false) route53EndpointConversionEnabled := envBoolOrDefault("ROUTE53_ENDPOINT_CONVERSION_ENABLED", true) endpointRecordSetNamespace := envOrDefault("ENDPOINT_RECORDSET_NAMESPACE", "") route53ControllerName := envOrDefault("ROUTE53_CONTROLLER_NAME", route53zoneunit.DefaultControllerName) @@ -103,6 +109,10 @@ func main() { externalControllerName := envOrDefault("EXTERNAL_CONTROLLER_NAME", externalv1alpha1.ControllerName) externalProviderName := envOrDefault("EXTERNAL_PROVIDER_NAME", externalv1alpha1.ProviderName) externalProviderVersion := envOrDefault("EXTERNAL_PROVIDER_VERSION", externalv1alpha1.ProviderVersion) + hostedControllerName := envOrDefault("HOSTED_CONTROLLER_NAME", hostedv1alpha1.ControllerName) + hostedProviderName := envOrDefault("HOSTED_PROVIDER_NAME", hostedv1alpha1.ProviderName) + hostedProviderVersion := envOrDefault("HOSTED_PROVIDER_VERSION", hostedv1alpha1.ProviderVersion) + hostedEndpointTTL := envUintOrDefault("HOSTED_ENDPOINT_TTL", uint(hostedv1alpha1.DefaultTTL)) var externalDNSQPS float64 var externalDNSBurst int @@ -127,12 +137,21 @@ func main() { flag.StringVar(&externalProviderVersion, "external-provider-version", externalProviderVersion, "Provider version handled by the external controller.") flag.Float64Var(&externalDNSQPS, "external-dns-qps", externalprobe.DefaultQPS, "Maximum external DNS query rate per controller process.") flag.IntVar(&externalDNSBurst, "external-dns-burst", externalprobe.DefaultBurst, "Maximum external DNS query burst per controller process.") + flag.BoolVar(&hostedControllerEnabled, "hosted-controller-enabled", hostedControllerEnabled, "Enable hosted authoritative DNS controllers and endpoint conversion.") + flag.StringVar(&hostedControllerName, "hosted-controller-name", hostedControllerName, "ZoneClass.spec.controllerName handled by the hosted controller.") + flag.StringVar(&hostedProviderName, "hosted-provider-name", hostedProviderName, "Provider.metadata.name handled by the hosted controller.") + flag.StringVar(&hostedProviderVersion, "hosted-provider-version", hostedProviderVersion, "Provider version handled by the hosted controller.") + flag.UintVar(&hostedEndpointTTL, "hosted-endpoint-ttl", hostedEndpointTTL, "TTL in seconds for hosted endpoint-converted records (1-86400).") zapOpts := zap.Options{Development: true} zapOpts.BindFlags(flag.CommandLine) flag.Parse() ctrl.SetLogger(zap.New(zap.UseFlagOptions(&zapOpts))) + if hostedControllerEnabled && (hostedEndpointTTL < 1 || hostedEndpointTTL > 86400) { + ctrl.Log.Error(nil, "hosted endpoint TTL must be between 1 and 86400 seconds") + os.Exit(1) + } mgr, err := ctrl.NewManager(ctrl.GetConfigOrDie(), ctrl.Options{ Scheme: scheme, @@ -246,6 +265,34 @@ func main() { } } + if hostedControllerEnabled { + if err := (&hostedzoneclass.ZoneClassReconciler{ + Client: mgr.GetClient(), + ControllerName: hostedControllerName, + ProviderName: hostedProviderName, + ProviderVersion: hostedProviderVersion, + }).SetupWithManager(mgr); err != nil { + ctrl.Log.Error(err, "unable to set up hosted ZoneClass controller") + os.Exit(1) + } + + hostedReconciler, err := hostedzoneunit.NewReconciler(mgr.GetClient(), hostedzoneunit.Options{ + ControllerName: hostedControllerName, + ProviderName: hostedProviderName, + ProviderVersion: hostedProviderVersion, + DefaultViews: []string{envOrDefault("HOSTED_SERVER_ADDRESS", + "dns-api-hosted-server."+envOrDefault("POD_NAMESPACE", "dns-api-system")+".svc:53")}, + }) + if err != nil { + ctrl.Log.Error(err, "unable to configure hosted DNS resolver") + os.Exit(1) + } + if err := hostedReconciler.SetupWithManager(mgr); err != nil { + ctrl.Log.Error(err, "unable to set up hosted ZoneUnit controller") + os.Exit(1) + } + } + if err := (&zoneunitcontroller.ZoneUnitCompositionReconciler{ Client: mgr.GetClient(), }).SetupWithManager(mgr); err != nil { @@ -307,6 +354,9 @@ func main() { if externalControllerEnabled { externalconversion.NewHandler().Register(mgr.GetWebhookServer()) } + if hostedControllerEnabled { + hostedconversion.NewHandler(uint32(hostedEndpointTTL)).Register(mgr.GetWebhookServer()) + } if err := mgr.AddHealthzCheck("healthz", healthz.Ping); err != nil { ctrl.Log.Error(err, "unable to set up health check") @@ -343,3 +393,16 @@ func envBoolOrDefault(name string, fallback bool) bool { } return enabled } + +func envUintOrDefault(name string, fallback uint) uint { + value := os.Getenv(name) + if value == "" { + return fallback + } + parsed, err := strconv.ParseUint(value, 10, 32) + if err != nil { + ctrl.Log.Error(err, "invalid unsigned integer environment variable", "name", name) + os.Exit(1) + } + return uint(parsed) +} diff --git a/app/operator/cmd/manager/main_test.go b/app/operator/cmd/manager/main_test.go index 26e9460..679b0fc 100644 --- a/app/operator/cmd/manager/main_test.go +++ b/app/operator/cmd/manager/main_test.go @@ -9,6 +9,7 @@ import ( "testing" externalv1alpha1 "github.com/appthrust/dns-api/pkg/go/api/external/v1alpha1" + hostedv1alpha1 "github.com/appthrust/dns-api/pkg/go/api/hosted/v1alpha1" ) func TestProviderEnablementEnvironmentDefaults(t *testing.T) { @@ -17,6 +18,7 @@ func TestProviderEnablementEnvironmentDefaults(t *testing.T) { "CLOUDFLARE_CONTROLLER_ENABLED": true, "ROUTE53_ENDPOINT_CONVERSION_ENABLED": true, "EXTERNAL_CONTROLLER_ENABLED": false, + "HOSTED_CONTROLLER_ENABLED": false, } { t.Run(name, func(t *testing.T) { for _, test := range []struct { @@ -42,6 +44,12 @@ func TestExternalSchemeRegistered(t *testing.T) { } } +func TestHostedSchemeRegistered(t *testing.T) { + if !scheme.IsVersionRegistered(hostedv1alpha1.SchemeGroupVersion) { + t.Fatal("hosted API scheme is not registered") + } +} + // Run the real manager's flag binding in a subprocess. Help exits before any // Kubernetes client, resolver, controller, or webhook is constructed. func TestManagerFlagsProcess(t *testing.T) { @@ -82,6 +90,11 @@ func TestManagerProviderFlags(t *testing.T) { "external-provider-version": "v1alpha1", "external-dns-qps": "40", "external-dns-burst": "80", + "hosted-controller-enabled": "false", + "hosted-controller-name": "hosted.dns.appthrust.io/controller", + "hosted-provider-name": "hosted.dns.appthrust.io", + "hosted-provider-version": "v1alpha1", + "hosted-endpoint-ttl": "60", } for _, mode := range []string{"defaults", "environment", "flags-override-environment"} { t.Run(mode, func(t *testing.T) { @@ -98,6 +111,8 @@ func TestManagerProviderFlags(t *testing.T) { if fallback == "false" { value = "true" } + } else if name == "hosted-endpoint-ttl" { + value = "120" } else { value = "environment-" + name } @@ -137,3 +152,16 @@ func TestManagerProviderFlags(t *testing.T) { }) } } + +func TestHostedEndpointTTLRejectedBeforeManagerStartup(t *testing.T) { + for _, value := range []string{"0", "86401"} { + t.Run(value, func(t *testing.T) { + cmd := exec.Command(os.Args[0], "-test.run=^TestManagerFlagsProcess$", "--", "--hosted-controller-enabled=true", "--hosted-endpoint-ttl="+value) + cmd.Env = append(os.Environ(), "DNS_API_TEST_MANAGER_FLAGS=1", "HOSTED_ENDPOINT_TTL=") + output, err := cmd.CombinedOutput() + if err == nil || !strings.Contains(string(output), "hosted endpoint TTL must be between 1 and 86400 seconds") { + t.Fatalf("unexpected invalid TTL result: %v\n%s", err, output) + } + }) + } +} diff --git a/app/operator/config/crd/bases/hosted.dns.appthrust.io_hostedzones.yaml b/app/operator/config/crd/bases/hosted.dns.appthrust.io_hostedzones.yaml new file mode 100644 index 0000000..7ebcdec --- /dev/null +++ b/app/operator/config/crd/bases/hosted.dns.appthrust.io_hostedzones.yaml @@ -0,0 +1,236 @@ +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: v0.17.2 + name: hostedzones.hosted.dns.appthrust.io +spec: + group: hosted.dns.appthrust.io + names: + kind: HostedZone + listKind: HostedZoneList + plural: hostedzones + singular: hostedzone + scope: Namespaced + versions: + - additionalPrinterColumns: + - jsonPath: .spec.domainName + name: Domain + type: string + - jsonPath: .spec.serial + name: Serial + type: integer + - jsonPath: .status.servers[*].endpoint + name: Servers + type: string + name: v1alpha1 + schema: + openAPIV3Schema: + description: HostedZone is the complete authoritative serving snapshot of + one ZoneUnit. + properties: + apiVersion: + description: |- + APIVersion defines the versioned schema of this representation of an object. + Servers should convert recognized schemas to the latest internal value, and + may reject unrecognized values. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources + type: string + kind: + description: |- + Kind is a string value representing the REST resource this object represents. + Servers may infer this from the endpoint the client submits requests to. + Cannot be updated. + In CamelCase. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds + type: string + metadata: + type: object + spec: + description: HostedZoneSpec is written only by the hosted controller. + properties: + contentDigest: + pattern: ^sha256:[0-9a-f]{64}$ + type: string + domainName: + maxLength: 253 + minLength: 1 + pattern: ^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?(\.[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?)*$ + type: string + nameServers: + items: + description: NameServer declares an authoritative name and its canonical + IP glue addresses. + properties: + addresses: + items: + maxLength: 45 + type: string + maxItems: 8 + minItems: 1 + type: array + name: + maxLength: 253 + minLength: 1 + pattern: ^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?(\.[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?)*$ + type: string + required: + - addresses + - name + type: object + x-kubernetes-validations: + - message: name must be a DNS name, not an IP address + rule: '!isIP(self.name)' + - message: addresses must contain canonical IP addresses + rule: self.addresses.all(a, isIP(a) && ip.isCanonical(a)) + maxItems: 8 + minItems: 1 + type: array + records: + description: Records includes apex NS and glue as well as accepted + record claims. + items: + description: HostedZoneRecord is one complete canonical presentation-format + RRset. + properties: + name: + description: Name is @ or a relative name; a wildcard is allowed + only as the first label. + maxLength: 253 + minLength: 1 + pattern: ^(@|\*|((\*\.)?[a-z0-9_]([a-z0-9_-]{0,61}[a-z0-9_])?(\.[a-z0-9_]([a-z0-9_-]{0,61}[a-z0-9_])?)*))$ + type: string + ttl: + format: int32 + maximum: 2147483647 + minimum: 1 + type: integer + type: + enum: + - A + - AAAA + - CNAME + - TXT + - MX + - CAA + - NS + type: string + values: + items: + type: string + maxItems: 64 + minItems: 1 + type: array + required: + - name + - ttl + - type + - values + type: object + maxItems: 10000 + type: array + x-kubernetes-list-map-keys: + - name + - type + x-kubernetes-list-type: map + serial: + description: Serial increases on each content change. + format: int32 + maximum: 4294967295 + minimum: 1 + type: integer + soa: + description: SOA is fully rendered and contains no implicit defaults. + properties: + expire: + format: int32 + maximum: 2419200 + minimum: 1 + type: integer + minimum: + format: int32 + maximum: 2419200 + minimum: 1 + type: integer + mname: + maxLength: 253 + minLength: 1 + pattern: ^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?(\.[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?)*$ + type: string + refresh: + format: int32 + maximum: 2419200 + minimum: 1 + type: integer + retry: + format: int32 + maximum: 2419200 + minimum: 1 + type: integer + rname: + maxLength: 253 + minLength: 1 + pattern: ^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?(\.[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?)*$ + type: string + required: + - expire + - minimum + - mname + - refresh + - retry + - rname + type: object + required: + - contentDigest + - domainName + - nameServers + - records + - serial + - soa + type: object + x-kubernetes-validations: + - message: serial must be greater than zero + rule: self.serial > 0 + status: + properties: + servers: + description: Servers is independently applied by servers using hosted-server/. + items: + description: HostedZoneServerStatus acknowledges the exact content + loaded by one server. + properties: + contentDigest: + pattern: ^sha256:[0-9a-f]{64}$ + type: string + endpoint: + description: Endpoint is the server pod identity, namespace/name, + not an IP endpoint. + minLength: 1 + type: string + observedAt: + format: date-time + type: string + serial: + format: int32 + maximum: 4294967295 + minimum: 1 + type: integer + required: + - contentDigest + - endpoint + - observedAt + - serial + type: object + type: array + x-kubernetes-list-map-keys: + - endpoint + x-kubernetes-list-type: map + type: object + required: + - spec + type: object + served: true + storage: true + subresources: + status: {} diff --git a/deploy/charts/dns-api/crds/hosted.dns.appthrust.io_hostedzones.yaml b/deploy/charts/dns-api/crds/hosted.dns.appthrust.io_hostedzones.yaml new file mode 100644 index 0000000..7ebcdec --- /dev/null +++ b/deploy/charts/dns-api/crds/hosted.dns.appthrust.io_hostedzones.yaml @@ -0,0 +1,236 @@ +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: v0.17.2 + name: hostedzones.hosted.dns.appthrust.io +spec: + group: hosted.dns.appthrust.io + names: + kind: HostedZone + listKind: HostedZoneList + plural: hostedzones + singular: hostedzone + scope: Namespaced + versions: + - additionalPrinterColumns: + - jsonPath: .spec.domainName + name: Domain + type: string + - jsonPath: .spec.serial + name: Serial + type: integer + - jsonPath: .status.servers[*].endpoint + name: Servers + type: string + name: v1alpha1 + schema: + openAPIV3Schema: + description: HostedZone is the complete authoritative serving snapshot of + one ZoneUnit. + properties: + apiVersion: + description: |- + APIVersion defines the versioned schema of this representation of an object. + Servers should convert recognized schemas to the latest internal value, and + may reject unrecognized values. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources + type: string + kind: + description: |- + Kind is a string value representing the REST resource this object represents. + Servers may infer this from the endpoint the client submits requests to. + Cannot be updated. + In CamelCase. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds + type: string + metadata: + type: object + spec: + description: HostedZoneSpec is written only by the hosted controller. + properties: + contentDigest: + pattern: ^sha256:[0-9a-f]{64}$ + type: string + domainName: + maxLength: 253 + minLength: 1 + pattern: ^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?(\.[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?)*$ + type: string + nameServers: + items: + description: NameServer declares an authoritative name and its canonical + IP glue addresses. + properties: + addresses: + items: + maxLength: 45 + type: string + maxItems: 8 + minItems: 1 + type: array + name: + maxLength: 253 + minLength: 1 + pattern: ^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?(\.[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?)*$ + type: string + required: + - addresses + - name + type: object + x-kubernetes-validations: + - message: name must be a DNS name, not an IP address + rule: '!isIP(self.name)' + - message: addresses must contain canonical IP addresses + rule: self.addresses.all(a, isIP(a) && ip.isCanonical(a)) + maxItems: 8 + minItems: 1 + type: array + records: + description: Records includes apex NS and glue as well as accepted + record claims. + items: + description: HostedZoneRecord is one complete canonical presentation-format + RRset. + properties: + name: + description: Name is @ or a relative name; a wildcard is allowed + only as the first label. + maxLength: 253 + minLength: 1 + pattern: ^(@|\*|((\*\.)?[a-z0-9_]([a-z0-9_-]{0,61}[a-z0-9_])?(\.[a-z0-9_]([a-z0-9_-]{0,61}[a-z0-9_])?)*))$ + type: string + ttl: + format: int32 + maximum: 2147483647 + minimum: 1 + type: integer + type: + enum: + - A + - AAAA + - CNAME + - TXT + - MX + - CAA + - NS + type: string + values: + items: + type: string + maxItems: 64 + minItems: 1 + type: array + required: + - name + - ttl + - type + - values + type: object + maxItems: 10000 + type: array + x-kubernetes-list-map-keys: + - name + - type + x-kubernetes-list-type: map + serial: + description: Serial increases on each content change. + format: int32 + maximum: 4294967295 + minimum: 1 + type: integer + soa: + description: SOA is fully rendered and contains no implicit defaults. + properties: + expire: + format: int32 + maximum: 2419200 + minimum: 1 + type: integer + minimum: + format: int32 + maximum: 2419200 + minimum: 1 + type: integer + mname: + maxLength: 253 + minLength: 1 + pattern: ^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?(\.[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?)*$ + type: string + refresh: + format: int32 + maximum: 2419200 + minimum: 1 + type: integer + retry: + format: int32 + maximum: 2419200 + minimum: 1 + type: integer + rname: + maxLength: 253 + minLength: 1 + pattern: ^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?(\.[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?)*$ + type: string + required: + - expire + - minimum + - mname + - refresh + - retry + - rname + type: object + required: + - contentDigest + - domainName + - nameServers + - records + - serial + - soa + type: object + x-kubernetes-validations: + - message: serial must be greater than zero + rule: self.serial > 0 + status: + properties: + servers: + description: Servers is independently applied by servers using hosted-server/. + items: + description: HostedZoneServerStatus acknowledges the exact content + loaded by one server. + properties: + contentDigest: + pattern: ^sha256:[0-9a-f]{64}$ + type: string + endpoint: + description: Endpoint is the server pod identity, namespace/name, + not an IP endpoint. + minLength: 1 + type: string + observedAt: + format: date-time + type: string + serial: + format: int32 + maximum: 4294967295 + minimum: 1 + type: integer + required: + - contentDigest + - endpoint + - observedAt + - serial + type: object + type: array + x-kubernetes-list-map-keys: + - endpoint + x-kubernetes-list-type: map + type: object + required: + - spec + type: object + served: true + storage: true + subresources: + status: {} diff --git a/deploy/charts/dns-api/hosted_contract_test.go b/deploy/charts/dns-api/hosted_contract_test.go new file mode 100644 index 0000000..cb83169 --- /dev/null +++ b/deploy/charts/dns-api/hosted_contract_test.go @@ -0,0 +1,288 @@ +package dnsapichart + +import ( + "fmt" + "os/exec" + "path/filepath" + "reflect" + "runtime" + "strconv" + "strings" + "testing" + + hostedmanifest "github.com/appthrust/dns-api/internal/go/providers/hosted/manifest" + appsv1 "k8s.io/api/apps/v1" + corev1 "k8s.io/api/core/v1" + rbacv1 "k8s.io/api/rbac/v1" + "k8s.io/apimachinery/pkg/util/intstr" + "sigs.k8s.io/yaml" +) + +func TestHostedDefaultsToDisabled(t *testing.T) { + rendered := renderChart(t) + if explicit := renderChart(t, "--set", "providers.hosted.enabled=false"); rendered != explicit { + t.Fatal("default render differs from explicitly disabled hosted provider") + } + for _, forbidden := range []string{"hosted.dns.appthrust.io", "# Source: dns-api/templates/provider-hosted.yaml", "# Source: dns-api/templates/hosted-server-", "--hosted-provider-", "--hosted-endpoint-ttl="} { + if strings.Contains(rendered, forbidden) { + t.Errorf("disabled render contains hosted declaration %q", forbidden) + } + } + if !strings.Contains(rendered, "--hosted-controller-enabled=false") { + t.Fatal("default manager must explicitly disable hosted") + } + renderChart(t, "--set", "webhook.enabled=false", "--set", "certManager.enabled=false") +} + +func TestHostedOnlyRender(t *testing.T) { + rendered := renderChart(t, "--set", "providers.hosted.enabled=true", "--set", "providers.route53.enabled=false", "--set", "providers.cloudflare.enabled=false") + assertManifestEqual(t, hostedmanifest.ProviderYAML, renderedDocument(t, rendered, "dns.appthrust.io/v1alpha1", "Provider")) + assertManifestEqual(t, hostedmanifest.CapabilityYAML, renderedDocument(t, rendered, "endpoint.dns.appthrust.io/v1alpha1", "EndpointProviderCapability")) + manager := namedDocument(t, rendered, "Deployment", "dns-api-controller-manager") + for _, arg := range []string{"--hosted-controller-enabled=true", "--hosted-controller-name=hosted.dns.appthrust.io/controller", "--hosted-provider-name=hosted.dns.appthrust.io", "--hosted-provider-version=v1alpha1", "--hosted-endpoint-ttl=60"} { + if !strings.Contains(manager, arg) { + t.Errorf("manager is missing %q", arg) + } + } + for _, kind := range []string{"Provider", "EndpointProviderCapability", "APIService"} { + if count := strings.Count(rendered, "kind: "+kind+"\n"); count != 1 { + t.Errorf("got %d %s declarations, want exactly one", count, kind) + } + } + var role rbacv1.ClusterRole + decodeHostedDocument(t, namedDocument(t, rendered, "ClusterRole", "dns-api-controller-manager-role"), &role) + var hostedRules []rbacv1.PolicyRule + for _, rule := range role.Rules { + for _, group := range rule.APIGroups { + if group == "hosted.dns.appthrust.io" || group == "endpoint.hosted.dns.appthrust.io" { + hostedRules = append(hostedRules, rule) + } + if strings.Contains(group, "route53") || strings.Contains(group, "cloudflare") { + t.Errorf("hosted-only role includes cloud group %q", group) + } + } + for _, resource := range rule.Resources { + if resource == "secrets" || strings.Contains(resource, "identities") || resource == "hostedzones/status" { + t.Errorf("manager has forbidden permission %q", resource) + } + } + } + want := []rbacv1.PolicyRule{ + {APIGroups: []string{"hosted.dns.appthrust.io"}, Resources: []string{"hostedzones"}, Verbs: []string{"create", "delete", "get", "list", "patch", "update", "watch"}}, + {APIGroups: []string{"endpoint.hosted.dns.appthrust.io"}, Resources: []string{"endpointrecordsetconversions"}, Verbs: []string{"create"}}, + } + if !reflect.DeepEqual(hostedRules, want) { + t.Fatalf("manager hosted permissions differ: %#v", hostedRules) + } + assertHostedTLS(t, rendered) +} + +func TestHostedServerRender(t *testing.T) { + for _, namespace := range []string{"", "tenant-dns"} { + t.Run("watchNamespace="+namespace, func(t *testing.T) { + rendered := renderChart(t, "--set", "providers.hosted.enabled=true", "--set", "providers.hosted.server.watchNamespace="+namespace) + var deployment appsv1.Deployment + decodeHostedDocument(t, namedDocument(t, rendered, "Deployment", "dns-api-hosted-server"), &deployment) + pod := deployment.Spec.Template.Spec + if deployment.Namespace != "appthrust-dns" || deployment.Spec.Replicas == nil || *deployment.Spec.Replicas != 1 || pod.ServiceAccountName != "dns-api-hosted-server" { + t.Fatalf("unexpected server deployment: %#v", deployment.Spec) + } + if pod.SecurityContext == nil || pod.SecurityContext.RunAsNonRoot == nil || !*pod.SecurityContext.RunAsNonRoot { + t.Fatal("server must run as non-root") + } + if len(pod.Containers) != 1 { + t.Fatalf("got %d server containers", len(pod.Containers)) + } + container := pod.Containers[0] + if !reflect.DeepEqual(container.Command, []string{"/hosted-server"}) || container.SecurityContext == nil || container.SecurityContext.ReadOnlyRootFilesystem == nil || !*container.SecurityContext.ReadOnlyRootFilesystem || container.SecurityContext.AllowPrivilegeEscalation == nil || *container.SecurityContext.AllowPrivilegeEscalation { + t.Fatalf("unexpected server command/security: %#v", container) + } + if container.ReadinessProbe.HTTPGet.Path != "/readyz" || container.LivenessProbe.HTTPGet.Path != "/healthz" { + t.Fatal("server health probes differ") + } + if !strings.Contains(strings.Join(container.Args, "\n"), "--watch-namespace="+namespace) { + t.Fatal("server watch namespace differs") + } + for name, field := range map[string]string{"POD_NAMESPACE": "metadata.namespace", "POD_NAME": "metadata.name"} { + found := false + for _, env := range container.Env { + if env.Name == name && env.ValueFrom != nil && env.ValueFrom.FieldRef != nil && env.ValueFrom.FieldRef.FieldPath == field { + found = true + } + } + if !found { + t.Errorf("missing downward API %s", name) + } + } + var service corev1.Service + decodeHostedDocument(t, namedDocument(t, rendered, "Service", "dns-api-hosted-server"), &service) + if service.Spec.Type != corev1.ServiceTypeClusterIP || !reflect.DeepEqual(service.Spec.Selector, deployment.Spec.Selector.MatchLabels) { + t.Fatalf("server service does not select server deployment: %#v", service.Spec) + } + for _, protocol := range []corev1.Protocol{corev1.ProtocolUDP, corev1.ProtocolTCP} { + found := false + for _, port := range service.Spec.Ports { + if port.Protocol == protocol && port.Port == 53 && port.TargetPort == intstr.FromInt32(5353) { + for _, containerPort := range container.Ports { + if containerPort.Protocol == protocol && containerPort.ContainerPort == 5353 { + found = true + } + } + } + } + if !found { + t.Errorf("missing port 53 -> 5353 for %s", protocol) + } + } + var manager appsv1.Deployment + decodeHostedDocument(t, namedDocument(t, rendered, "Deployment", "dns-api-controller-manager"), &manager) + if manager.Spec.Selector.MatchLabels["app.kubernetes.io/component"] == service.Spec.Selector["app.kubernetes.io/component"] { + t.Fatal("server and manager selectors overlap") + } + roleKind, bindingKind := "Role", "RoleBinding" + if namespace == "" { + roleKind, bindingKind = "ClusterRole", "ClusterRoleBinding" + } + var role rbacv1.Role + decodeHostedDocument(t, namedDocument(t, rendered, roleKind, "dns-api-hosted-server"), &role) + want := []rbacv1.PolicyRule{ + {APIGroups: []string{"hosted.dns.appthrust.io"}, Resources: []string{"hostedzones"}, Verbs: []string{"get", "list", "watch"}}, + {APIGroups: []string{"hosted.dns.appthrust.io"}, Resources: []string{"hostedzones/status"}, Verbs: []string{"patch"}}, + } + if role.Namespace != namespace || !reflect.DeepEqual(role.Rules, want) { + t.Fatalf("server permissions differ: %#v", role) + } + var binding rbacv1.RoleBinding + decodeHostedDocument(t, namedDocument(t, rendered, bindingKind, "dns-api-hosted-server"), &binding) + if binding.Namespace != namespace || binding.RoleRef.Kind != roleKind || binding.RoleRef.Name != role.Name || !reflect.DeepEqual(binding.Subjects, []rbacv1.Subject{{Kind: "ServiceAccount", Name: "dns-api-hosted-server", Namespace: "appthrust-dns"}}) { + t.Fatalf("server binding differs: %#v", binding) + } + namedDocument(t, rendered, "ServiceAccount", "dns-api-hosted-server") + }) + } +} + +func TestHostedCustomBindingsAndService(t *testing.T) { + rendered := renderChart(t, + "--set", "providers.hosted.enabled=true", "--set", "fullnameOverride=custom-dns", + "--set", "providers.hosted.controllerName=custom.example/controller", "--set", "providers.hosted.providerName=custom.example", "--set", "providers.hosted.providerVersion=v1beta1", "--set", "providers.hosted.endpointTTL=120", + "--set", "providers.hosted.server.replicas=2", "--set", "providers.hosted.server.service.type=LoadBalancer", "--set", "providers.hosted.server.service.loadBalancerIP=192.0.2.53", "--set", "providers.hosted.server.service.annotations.test=value", "--set", "providers.hosted.server.service.ipFamilies={IPv4}", + "--set", "providers.hosted.server.resources.requests.cpu=75m", "--set", "webhook.service.port=8443", + ) + manager := namedDocument(t, rendered, "Deployment", "custom-dns-controller-manager") + for _, arg := range []string{"--hosted-controller-name=custom.example/controller", "--hosted-provider-name=custom.example", "--hosted-provider-version=v1beta1", "--hosted-endpoint-ttl=120"} { + if !strings.Contains(manager, arg) { + t.Errorf("manager is missing %q", arg) + } + } + if !strings.Contains(manager, "name: HOSTED_SERVER_ADDRESS") || !strings.Contains(manager, "custom-dns-hosted-server.appthrust-dns.svc:53") { + t.Fatal("manager default verification view does not match renamed server Service") + } + provider := manifestMap(t, namedDocument(t, rendered, "Provider", "custom.example")) + if provider["spec"].(map[string]any)["versions"].([]any)[0].(map[string]any)["name"] != "v1beta1" { + t.Fatal("Provider version differs from binding") + } + capability := manifestMap(t, namedDocument(t, rendered, "EndpointProviderCapability", "hosted-v1beta1"))["spec"].(map[string]any) + if !reflect.DeepEqual(capability["provider"], map[string]any{"name": "custom.example", "version": "v1beta1"}) || !reflect.DeepEqual(capability["conversion"], map[string]any{"group": "endpoint.hosted.dns.appthrust.io", "version": "v1alpha1", "resource": "endpointrecordsetconversions"}) { + t.Fatalf("capability binding/GVR differs: %#v", capability) + } + var service corev1.Service + decodeHostedDocument(t, namedDocument(t, rendered, "Service", "custom-dns-hosted-server"), &service) + if service.Spec.Type != corev1.ServiceTypeLoadBalancer || service.Spec.LoadBalancerIP != "192.0.2.53" || service.Annotations["test"] != "value" || !reflect.DeepEqual(service.Spec.IPFamilies, []corev1.IPFamily{corev1.IPv4Protocol}) { + t.Fatalf("custom server service differs: %#v", service) + } + var deployment appsv1.Deployment + decodeHostedDocument(t, namedDocument(t, rendered, "Deployment", "custom-dns-hosted-server"), &deployment) + if *deployment.Spec.Replicas != 2 || deployment.Spec.Template.Spec.Containers[0].Resources.Requests.Cpu().String() != "75m" { + t.Fatal("custom server replicas/resources differ") + } + assertHostedTLS(t, rendered) +} + +func TestHostedRequiresWebhookAndCertManager(t *testing.T) { + for _, webhook := range []bool{false, true} { + for _, certManager := range []bool{false, true} { + if webhook && certManager { + continue + } + t.Run(fmt.Sprintf("webhook=%t/certManager=%t", webhook, certManager), func(t *testing.T) { + _, file, _, _ := runtime.Caller(0) + output, err := exec.Command("helm", "template", "dns-api", filepath.Dir(file), "--set", "providers.hosted.enabled=true", "--set", "webhook.enabled="+strconv.FormatBool(webhook), "--set", "certManager.enabled="+strconv.FormatBool(certManager)).CombinedOutput() + if err == nil || !strings.Contains(string(output), "providers.hosted.enabled requires webhook.enabled=true and certManager.enabled=true") { + t.Fatalf("unexpected TLS enablement result: %v\n%s", err, output) + } + }) + } + } +} + +func TestHostedRBACCanBeExternallyManaged(t *testing.T) { + rendered := renderChart(t, "--set", "providers.hosted.enabled=true", "--set", "rbac.create=false") + for _, kind := range []string{"Role", "RoleBinding", "ClusterRole", "ClusterRoleBinding"} { + if strings.Contains(rendered, "kind: "+kind+"\n") { + t.Errorf("rbac.create=false rendered %s", kind) + } + } + namedDocument(t, rendered, "ServiceAccount", "dns-api-hosted-server") +} + +func TestHostedCoexistsWithOtherProviders(t *testing.T) { + rendered := renderChart(t, "--set", "providers.hosted.enabled=true", "--set", "providers.external.enabled=true") + for _, name := range []string{"hosted.dns.appthrust.io", "external.dns.appthrust.io", "route53.dns.appthrust.io", "cloudflare.dns.appthrust.io"} { + namedDocument(t, rendered, "Provider", name) + } + for _, name := range []string{"hosted", "external", "route53"} { + namedDocument(t, rendered, "APIService", "v1alpha1.endpoint."+name+".dns.appthrust.io") + } + assertHostedTLS(t, rendered) +} + +func namedDocument(t *testing.T, rendered, kind, name string) string { + t.Helper() + for _, document := range strings.Split(rendered, "\n---") { + value := manifestMap(t, document) + metadata, ok := value["metadata"].(map[string]any) + if ok && value["kind"] == kind && metadata["name"] == name { + return document + } + } + t.Fatalf("rendered %s %s was not found", kind, name) + return "" +} + +func decodeHostedDocument(t *testing.T, document string, target any) { + t.Helper() + if err := yaml.Unmarshal([]byte(document), target); err != nil { + t.Fatal(err) + } +} + +func assertHostedTLS(t *testing.T, rendered string) { + t.Helper() + apiService := manifestMap(t, namedDocument(t, rendered, "APIService", "v1alpha1.endpoint.hosted.dns.appthrust.io")) + spec := apiService["spec"].(map[string]any) + if _, exists := spec["insecureSkipTLSVerify"]; exists { + t.Fatal("hosted APIService bypasses TLS verification") + } + if spec["group"] != "endpoint.hosted.dns.appthrust.io" || spec["version"] != "v1alpha1" { + t.Fatalf("hosted APIService GVR differs: %#v", spec) + } + certificate := manifestMap(t, renderedDocument(t, rendered, "cert-manager.io/v1", "Certificate")) + certMetadata := certificate["metadata"].(map[string]any) + annotation := apiService["metadata"].(map[string]any)["annotations"].(map[string]any)["cert-manager.io/inject-ca-from"] + if annotation != certMetadata["namespace"].(string)+"/"+certMetadata["name"].(string) { + t.Fatal("CA injection does not reference serving Certificate") + } + serviceRef := spec["service"].(map[string]any) + service := manifestMap(t, namedDocument(t, rendered, "Service", serviceRef["name"].(string))) + if serviceRef["namespace"] != service["metadata"].(map[string]any)["namespace"] || serviceRef["port"] != service["spec"].(map[string]any)["ports"].([]any)[0].(map[string]any)["port"] { + t.Fatal("APIService does not reference webhook Service") + } + wantDNSName := serviceRef["name"].(string) + "." + serviceRef["namespace"].(string) + ".svc" + for _, name := range certificate["spec"].(map[string]any)["dnsNames"].([]any) { + if name == wantDNSName { + return + } + } + t.Fatalf("Certificate does not cover webhook Service DNS name %s", wantDNSName) +} diff --git a/deploy/charts/dns-api/templates/_helpers.tpl b/deploy/charts/dns-api/templates/_helpers.tpl index 1054fd6..52316fe 100644 --- a/deploy/charts/dns-api/templates/_helpers.tpl +++ b/deploy/charts/dns-api/templates/_helpers.tpl @@ -57,3 +57,12 @@ app.kubernetes.io/component: controller-manager {{- printf "%s-selfsigned-issuer" (include "dns-api.fullname" .) | trunc 63 | trimSuffix "-" -}} {{- end -}} +{{- define "dns-api.hostedServerName" -}} +{{- printf "%s-hosted-server" (include "dns-api.fullname" .) | trunc 63 | trimSuffix "-" -}} +{{- end -}} + +{{- define "dns-api.hostedServerSelectorLabels" -}} +app.kubernetes.io/name: {{ include "dns-api.name" . }} +app.kubernetes.io/instance: {{ .Release.Name }} +app.kubernetes.io/component: hosted-server +{{- end -}} diff --git a/deploy/charts/dns-api/templates/apiservice-hosted-endpoint-conversion.yaml b/deploy/charts/dns-api/templates/apiservice-hosted-endpoint-conversion.yaml new file mode 100644 index 0000000..ebf84cc --- /dev/null +++ b/deploy/charts/dns-api/templates/apiservice-hosted-endpoint-conversion.yaml @@ -0,0 +1,22 @@ +{{- if .Values.providers.hosted.enabled }} +{{- if not (and .Values.webhook.enabled .Values.certManager.enabled) }} +{{- fail "providers.hosted.enabled requires webhook.enabled=true and certManager.enabled=true" }} +{{- end }} +apiVersion: apiregistration.k8s.io/v1 +kind: APIService +metadata: + name: v1alpha1.endpoint.hosted.dns.appthrust.io + labels: + {{- include "dns-api.labels" . | nindent 4 }} + annotations: + cert-manager.io/inject-ca-from: {{ .Release.Namespace }}/{{ include "dns-api.webhookCertificateName" . }} +spec: + group: endpoint.hosted.dns.appthrust.io + version: v1alpha1 + groupPriorityMinimum: 1000 + versionPriority: 15 + service: + name: {{ include "dns-api.webhookServiceName" . }} + namespace: {{ .Release.Namespace }} + port: {{ .Values.webhook.service.port }} +{{- end }} diff --git a/deploy/charts/dns-api/templates/deployment.yaml b/deploy/charts/dns-api/templates/deployment.yaml index 3eec920..795c343 100644 --- a/deploy/charts/dns-api/templates/deployment.yaml +++ b/deploy/charts/dns-api/templates/deployment.yaml @@ -52,12 +52,29 @@ spec: - --external-dns-qps={{ .Values.providers.external.dnsQPS }} - --external-dns-burst={{ .Values.providers.external.dnsBurst }} {{- end }} + - --hosted-controller-enabled={{ .Values.providers.hosted.enabled }} + {{- if .Values.providers.hosted.enabled }} + - {{ printf "--hosted-controller-name=%s" .Values.providers.hosted.controllerName | quote }} + - {{ printf "--hosted-provider-name=%s" .Values.providers.hosted.providerName | quote }} + - {{ printf "--hosted-provider-version=%s" .Values.providers.hosted.providerVersion | quote }} + - --hosted-endpoint-ttl={{ .Values.providers.hosted.endpointTTL }} + {{- end }} {{- range .Values.extraArgs }} - {{ . | quote }} {{- end }} - {{- with .Values.extraEnv }} + {{- if or .Values.providers.hosted.enabled .Values.extraEnv }} env: + {{- if .Values.providers.hosted.enabled }} + - name: POD_NAMESPACE + valueFrom: + fieldRef: + fieldPath: metadata.namespace + - name: HOSTED_SERVER_ADDRESS + value: {{ printf "%s.%s.svc:53" (include "dns-api.hostedServerName" .) .Release.Namespace | quote }} + {{- end }} + {{- with .Values.extraEnv }} {{- toYaml . | nindent 12 }} + {{- end }} {{- end }} {{- with .Values.securityContext }} securityContext: diff --git a/deploy/charts/dns-api/templates/endpointprovidercapability-hosted.yaml b/deploy/charts/dns-api/templates/endpointprovidercapability-hosted.yaml new file mode 100644 index 0000000..e068f38 --- /dev/null +++ b/deploy/charts/dns-api/templates/endpointprovidercapability-hosted.yaml @@ -0,0 +1,14 @@ +{{- if .Values.providers.hosted.enabled }} +apiVersion: endpoint.dns.appthrust.io/v1alpha1 +kind: EndpointProviderCapability +metadata: + name: {{ printf "hosted-%s" .Values.providers.hosted.providerVersion | quote }} +spec: + provider: + name: {{ .Values.providers.hosted.providerName | quote }} + version: {{ .Values.providers.hosted.providerVersion | quote }} + conversion: + group: endpoint.hosted.dns.appthrust.io + version: v1alpha1 + resource: endpointrecordsetconversions +{{- end }} diff --git a/deploy/charts/dns-api/templates/hosted-server-deployment.yaml b/deploy/charts/dns-api/templates/hosted-server-deployment.yaml new file mode 100644 index 0000000..e21f68e --- /dev/null +++ b/deploy/charts/dns-api/templates/hosted-server-deployment.yaml @@ -0,0 +1,92 @@ +{{- if .Values.providers.hosted.enabled }} +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ include "dns-api.hostedServerName" . }} + namespace: {{ .Release.Namespace }} + labels: + {{- include "dns-api.labels" . | nindent 4 }} + app.kubernetes.io/component: hosted-server +spec: + replicas: {{ .Values.providers.hosted.server.replicas }} + selector: + matchLabels: + {{- include "dns-api.hostedServerSelectorLabels" . | nindent 6 }} + template: + metadata: + labels: + {{- include "dns-api.hostedServerSelectorLabels" . | nindent 8 }} + spec: + serviceAccountName: {{ include "dns-api.hostedServerName" . }} + {{- with .Values.imagePullSecrets }} + imagePullSecrets: + {{- toYaml . | nindent 8 }} + {{- end }} + securityContext: + runAsNonRoot: true + runAsUser: 65532 + runAsGroup: 65532 + seccompProfile: + type: RuntimeDefault + containers: + - name: hosted-server + image: "{{ .Values.image.repository }}:{{ default .Chart.AppVersion .Values.image.tag }}" + imagePullPolicy: {{ .Values.image.pullPolicy }} + command: ["/hosted-server"] + args: + - --listen-address=:5353 + - {{ printf "--watch-namespace=%s" .Values.providers.hosted.server.watchNamespace | quote }} + - --metrics-bind-address=:{{ .Values.metrics.port }} + - --health-probe-bind-address=:{{ .Values.health.port }} + env: + - name: POD_NAMESPACE + valueFrom: + fieldRef: + fieldPath: metadata.namespace + - name: POD_NAME + valueFrom: + fieldRef: + fieldPath: metadata.name + securityContext: + allowPrivilegeEscalation: false + readOnlyRootFilesystem: true + capabilities: + drop: [ALL] + ports: + - name: dns-udp + containerPort: 5353 + protocol: UDP + - name: dns-tcp + containerPort: 5353 + protocol: TCP + - name: metrics + containerPort: {{ .Values.metrics.port }} + protocol: TCP + - name: healthz + containerPort: {{ .Values.health.port }} + protocol: TCP + readinessProbe: + httpGet: + path: /readyz + port: healthz + livenessProbe: + httpGet: + path: /healthz + port: healthz + {{- with .Values.providers.hosted.server.resources }} + resources: + {{- toYaml . | nindent 12 }} + {{- end }} + {{- with .Values.nodeSelector }} + nodeSelector: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.affinity }} + affinity: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.tolerations }} + tolerations: + {{- toYaml . | nindent 8 }} + {{- end }} +{{- end }} diff --git a/deploy/charts/dns-api/templates/hosted-server-rbac.yaml b/deploy/charts/dns-api/templates/hosted-server-rbac.yaml new file mode 100644 index 0000000..66a4f43 --- /dev/null +++ b/deploy/charts/dns-api/templates/hosted-server-rbac.yaml @@ -0,0 +1,57 @@ +{{- if .Values.providers.hosted.enabled }} +apiVersion: v1 +kind: ServiceAccount +metadata: + name: {{ include "dns-api.hostedServerName" . }} + namespace: {{ .Release.Namespace }} + labels: + {{- include "dns-api.labels" . | nindent 4 }} + app.kubernetes.io/component: hosted-server +{{- if .Values.rbac.create }} +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: {{ if .Values.providers.hosted.server.watchNamespace }}Role{{ else }}ClusterRole{{ end }} +metadata: + name: {{ include "dns-api.hostedServerName" . }} + {{- with .Values.providers.hosted.server.watchNamespace }} + namespace: {{ . | quote }} + {{- end }} + labels: + {{- include "dns-api.labels" . | nindent 4 }} + app.kubernetes.io/component: hosted-server +rules: + - apiGroups: + - hosted.dns.appthrust.io + resources: + - hostedzones + verbs: + - get + - list + - watch + - apiGroups: + - hosted.dns.appthrust.io + resources: + - hostedzones/status + verbs: + - patch +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: {{ if .Values.providers.hosted.server.watchNamespace }}RoleBinding{{ else }}ClusterRoleBinding{{ end }} +metadata: + name: {{ include "dns-api.hostedServerName" . }} + {{- with .Values.providers.hosted.server.watchNamespace }} + namespace: {{ . | quote }} + {{- end }} + labels: + {{- include "dns-api.labels" . | nindent 4 }} + app.kubernetes.io/component: hosted-server +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: {{ if .Values.providers.hosted.server.watchNamespace }}Role{{ else }}ClusterRole{{ end }} + name: {{ include "dns-api.hostedServerName" . }} +subjects: + - kind: ServiceAccount + name: {{ include "dns-api.hostedServerName" . }} + namespace: {{ .Release.Namespace }} +{{- end }} +{{- end }} diff --git a/deploy/charts/dns-api/templates/hosted-server-service.yaml b/deploy/charts/dns-api/templates/hosted-server-service.yaml new file mode 100644 index 0000000..ccf478f --- /dev/null +++ b/deploy/charts/dns-api/templates/hosted-server-service.yaml @@ -0,0 +1,34 @@ +{{- if .Values.providers.hosted.enabled }} +apiVersion: v1 +kind: Service +metadata: + name: {{ include "dns-api.hostedServerName" . }} + namespace: {{ .Release.Namespace }} + labels: + {{- include "dns-api.labels" . | nindent 4 }} + app.kubernetes.io/component: hosted-server + {{- with .Values.providers.hosted.server.service.annotations }} + annotations: + {{- toYaml . | nindent 4 }} + {{- end }} +spec: + type: {{ .Values.providers.hosted.server.service.type }} + {{- with .Values.providers.hosted.server.service.loadBalancerIP }} + loadBalancerIP: {{ . | quote }} + {{- end }} + {{- with .Values.providers.hosted.server.service.ipFamilies }} + ipFamilies: + {{- toYaml . | nindent 4 }} + {{- end }} + selector: + {{- include "dns-api.hostedServerSelectorLabels" . | nindent 4 }} + ports: + - name: dns-udp + port: 53 + targetPort: 5353 + protocol: UDP + - name: dns-tcp + port: 53 + targetPort: 5353 + protocol: TCP +{{- end }} diff --git a/deploy/charts/dns-api/templates/provider-hosted.yaml b/deploy/charts/dns-api/templates/provider-hosted.yaml new file mode 100644 index 0000000..97b2d25 --- /dev/null +++ b/deploy/charts/dns-api/templates/provider-hosted.yaml @@ -0,0 +1,314 @@ +{{- if .Values.providers.hosted.enabled }} +apiVersion: dns.appthrust.io/v1alpha1 +kind: Provider +metadata: + name: {{ .Values.providers.hosted.providerName | quote }} +spec: + display: + name: Hosted DNS + description: Serves zones authoritatively from Kubernetes; no external DNS provider. + versions: + - name: {{ .Values.providers.hosted.providerVersion | quote }} + served: true + storage: true + zoneClass: + schemas: + parameters: + openAPIV3Schema: + type: object + description: Authoritative nameservers, SOA policy, and read-only verification views. + additionalProperties: false + required: [nameServers] + properties: + nameServers: + type: array + description: Authoritative names and deterministic glue addresses. + minItems: 1 + maxItems: 8 + items: + type: object + additionalProperties: false + required: [name, addresses] + properties: + name: + type: string + description: Canonical lowercase FQDN without a trailing root dot. + minLength: 1 + maxLength: 253 + pattern: '^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?(\.[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?)*$' + addresses: + type: array + description: Canonical IPv4 or IPv6 glue addresses. + minItems: 1 + maxItems: 8 + items: + type: string + minLength: 2 + maxLength: 45 + soa: + type: object + description: SOA policy; mname is the first nameserver and serial is controller-owned. + additionalProperties: false + properties: + rname: + type: string + description: Canonical mailbox name; omitted means hostmaster.. + minLength: 1 + maxLength: 253 + pattern: '^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?(\.[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?)*$' + refresh: + type: integer + minimum: 1 + maximum: 2419200 + default: 3600 + retry: + type: integer + minimum: 1 + maximum: 2419200 + default: 900 + expire: + type: integer + minimum: 1 + maximum: 2419200 + default: 1209600 + minimum: + type: integer + minimum: 1 + maximum: 2419200 + default: 300 + defaultTTL: + type: integer + description: TTL of apex NS, glue, and endpoint-converted RRsets. + minimum: 1 + maximum: 86400 + default: 60 + verification: + type: object + additionalProperties: false + properties: + views: + type: array + description: Canonical IP:port DNS endpoints; omitted views use the hosted server Service. + maxItems: 3 + uniqueItems: true + items: + type: string + minLength: 3 + maxLength: 64 + pattern: '^(\[[0-9a-f:.]+\]|[0-9.]+):[1-9][0-9]{0,4}$' + delegation: + type: object + description: Resolver policy for read-only observation of parent NS delegation. + additionalProperties: false + properties: + resolver: + type: object + description: Resolver view used for every parent delegation query. + additionalProperties: false + properties: + mode: + type: string + description: Cluster uses pod DNS; Recursive uses every configured server. Omitted means Cluster. + enum: [Cluster, Recursive] + servers: + type: array + description: Explicit recursive DNS IP endpoints; never authoritative NS names. + maxItems: 3 + uniqueItems: true + items: + type: string + description: One recursive resolver endpoint as IPv4:port or [IPv6]:port. + minLength: 3 + maxLength: 64 + pattern: '^(\[[0-9a-f:.]+\]|[0-9.]+):[1-9][0-9]{0,4}$' + validationRules: + - rule: >- + self.spec.parameters.nameServers.all(n, + !isIP(n.name) && n.addresses.all(a, isIP(a) && ip.isCanonical(a))) + message: nameServers must contain DNS names and canonical IP addresses + - rule: >- + !has(self.spec.parameters.soa) || !has(self.spec.parameters.soa.rname) || + !isIP(self.spec.parameters.soa.rname) + message: soa.rname must be a DNS mailbox name, not an IP address + - rule: >- + !has(self.spec.parameters.verification) || !has(self.spec.parameters.verification.views) || + self.spec.parameters.verification.views.all(s, + cel.bind(host, s.startsWith('[') ? s.split(']')[0].substring(1) : s.split(':')[0], + cel.bind(port, s.startsWith('[') ? s.split(']:')[1] : s.split(':')[1], + isIP(host) && ip.isCanonical(host) && + (s.startsWith('[') ? ip(host).family() == 6 : ip(host).family() == 4) && + int(port) >= 1 && int(port) <= 65535))) + message: verification.views must contain canonical IP addresses and ports from 1 through 65535 + - rule: >- + has(self.spec.parameters.verification) && + has(self.spec.parameters.verification.delegation) && + has(self.spec.parameters.verification.delegation.resolver) && + has(self.spec.parameters.verification.delegation.resolver.mode) && + self.spec.parameters.verification.delegation.resolver.mode == 'Recursive' + ? (has(self.spec.parameters.verification.delegation.resolver.servers) && size(self.spec.parameters.verification.delegation.resolver.servers) > 0) + : (!has(self.spec.parameters.verification) || !has(self.spec.parameters.verification.delegation) || + !has(self.spec.parameters.verification.delegation.resolver) || + !has(self.spec.parameters.verification.delegation.resolver.servers) || + size(self.spec.parameters.verification.delegation.resolver.servers) == 0) + message: verification.delegation.resolver.servers must be nonempty for Recursive and empty for Cluster + - rule: >- + !has(self.spec.parameters.verification) || !has(self.spec.parameters.verification.delegation) || + !has(self.spec.parameters.verification.delegation.resolver) || + !has(self.spec.parameters.verification.delegation.resolver.servers) || + self.spec.parameters.verification.delegation.resolver.servers.all(s, + cel.bind(host, s.startsWith('[') ? s.split(']')[0].substring(1) : s.split(':')[0], + cel.bind(port, s.startsWith('[') ? s.split(']:')[1] : s.split(':')[1], + isIP(host) && ip.isCanonical(host) && + (s.startsWith('[') ? ip(host).family() == 6 : ip(host).family() == 4) && + int(port) >= 1 && int(port) <= 65535))) + message: verification.delegation.resolver.servers must contain canonical IP addresses and ports from 1 through 65535 + zone: + schemas: + adoption: &empty + openAPIV3Schema: + type: object + additionalProperties: false + properties: {} + statusProviderData: + openAPIV3Schema: + type: object + description: Current hosted snapshot and bounded serving and delegation observations. + additionalProperties: false + properties: + serial: + type: integer + description: Serial of the current desired hosted snapshot. + minimum: 1 + maximum: 4294967295 + contentDigest: + type: string + description: SHA-256 digest binding the desired serial to exact rendered zone content. + pattern: '^sha256:[0-9a-f]{64}$' + servers: + type: array + description: Up to sixty-four observed hosted server acknowledgements. + maxItems: 64 + items: + type: object + description: Exact snapshot acknowledged by one hosted server. + additionalProperties: false + required: [endpoint, serial, contentDigest, observedAt] + properties: + endpoint: + type: string + description: Server pod identity as namespace/name, not a DNS resolver endpoint. + minLength: 1 + serial: + type: integer + description: Snapshot serial loaded by this server. + minimum: 1 + maximum: 4294967295 + contentDigest: + type: string + description: SHA-256 digest of the exact snapshot loaded by this server. + pattern: '^sha256:[0-9a-f]{64}$' + observedAt: + type: string + description: Time at which this server acknowledged its loaded snapshot. + format: date-time + delegation: + type: object + description: Latest parent delegation observation; never changes Programmed. + additionalProperties: false + required: [observedNameServers, matches, lastProbeTime] + properties: + observedNameServers: + type: array + description: Up to sixteen observed apex NS names, normalized to lowercase without a trailing dot. + maxItems: 16 + items: + type: string + description: One normalized authoritative nameserver name observed at the zone apex. + maxLength: 253 + matches: + type: boolean + description: observed apex NS name set (lowercase, no trailing dot) equals the configured nameServers[].name set + lastProbeTime: + type: string + description: Time of the last completed delegation probe attempt, including failures. + format: date-time + error: + type: string + description: Bounded public diagnostic for a failed delegation probe; omitted on success. + maxLength: 512 + additionalConditionReasons: + - {conditionType: Programmed, status: 'False', reason: ServingPending, description: No hosted server has acknowledged the current snapshot.} + - {conditionType: Programmed, status: 'False', reason: SnapshotRejected, description: The API server rejected the snapshot or an unowned snapshot shadows the unit.} + recordSet: + supportedTypes: [A, AAAA, CNAME, TXT, MX, CAA, NS] + schemas: + options: *empty + adoption: *empty + statusProviderData: + openAPIV3Schema: + type: object + description: Snapshot publication evidence for this record set generation. + additionalProperties: false + required: [serial] + properties: + serial: + type: integer + description: First snapshot serial containing this record set generation. + minimum: 1 + maximum: 4294967295 + servedAt: + type: string + format: date-time + description: Time that a hosted server acknowledged this generation's snapshot; omitted while serving is pending. + additionalConditionReasons: + - {conditionType: Programmed, status: 'False', reason: ServingPending, description: No hosted server has acknowledged the snapshot containing this generation.} + validationRules: + - rule: "!(self.spec.name == '@' && self.spec.type == 'NS')" + message: apex NS is owned by the ZoneClass nameServers parameter + zoneUnit: + validationRules: + - rule: "!(self.name == '@' && self.type == 'NS') && !(other.name == '@' && other.type == 'NS')" + message: apex NS is owned by the ZoneClass nameServers parameter + recordWithdrawal: + supported: true + schemas: + statusProviderData: + openAPIV3Schema: + type: object + description: Bounded withdrawal diagnostics; complete typed evidence drives verification. + additionalProperties: false + properties: + views: + type: object + description: Sorted bounded diagnostic preview; equality uses the complete observation. + additionalProperties: false + required: [total, truncated] + properties: + values: + type: array + description: Up to sixteen typed values or diagnostic entries. + maxItems: 16 + items: + type: string + description: One bounded value or diagnostic entry. + maxLength: 512 + total: + type: integer + description: Total entries before truncation. + minimum: 0 + truncated: + type: boolean + description: Whether values omits entries from the complete observation. + additionalConditionReasons: + - {conditionType: Withdrawn, status: 'False', reason: DNSStillResolves, description: A required observation still returns the retired address.} + - {conditionType: Ready, status: 'False', reason: DNSStillResolves, description: A required observation still returns the retired address.} + - {conditionType: Withdrawn, status: 'False', reason: NegativeTTLNotElapsed, description: Cache safety and a separate confirming attempt are pending.} + - {conditionType: Ready, status: 'False', reason: NegativeTTLNotElapsed, description: Cache safety and a separate confirming attempt are pending.} + - {conditionType: Withdrawn, status: 'Unknown', reason: DNSProbeFailed, description: Required exclusion evidence is incomplete.} + - {conditionType: Ready, status: 'False', reason: DNSProbeFailed, description: Required exclusion evidence is incomplete.} + - {conditionType: Withdrawn, status: 'Unknown', reason: PolicyChanged, description: The binding or required view policy changed.} + - {conditionType: Ready, status: 'False', reason: PolicyChanged, description: The binding or required view policy changed.} + - {conditionType: Withdrawn, status: 'True', reason: WithdrawalVerified, description: Complete exclusion verified after the cache horizon.} + - {conditionType: Ready, status: 'True', reason: WithdrawalVerified, description: Complete exclusion verified after the cache horizon.} + - {conditionType: OverrideAccepted, status: 'True', reason: OperatorAuthorizedRelease, description: An exact separately authorized waiver accepts stale DNS risk.} +{{- end }} diff --git a/deploy/charts/dns-api/templates/rbac.yaml b/deploy/charts/dns-api/templates/rbac.yaml index 7d3a9e4..c4c2665 100644 --- a/deploy/charts/dns-api/templates/rbac.yaml +++ b/deploy/charts/dns-api/templates/rbac.yaml @@ -184,6 +184,26 @@ rules: verbs: - create {{- end }} + {{- if .Values.providers.hosted.enabled }} + - apiGroups: + - hosted.dns.appthrust.io + resources: + - hostedzones + verbs: + - create + - delete + - get + - list + - patch + - update + - watch + - apiGroups: + - endpoint.hosted.dns.appthrust.io + resources: + - endpointrecordsetconversions + verbs: + - create + {{- end }} {{- if .Values.gatewayEndpoint.enabled }} - apiGroups: - gateway.networking.k8s.io diff --git a/deploy/charts/dns-api/values.yaml b/deploy/charts/dns-api/values.yaml index 38a43f5..8f130c3 100644 --- a/deploy/charts/dns-api/values.yaml +++ b/deploy/charts/dns-api/values.yaml @@ -69,6 +69,27 @@ providers: providerVersion: v1alpha1 dnsQPS: 40 dnsBurst: 80 + hosted: + enabled: false + controllerName: hosted.dns.appthrust.io/controller + providerName: hosted.dns.appthrust.io + providerVersion: v1alpha1 + endpointTTL: 60 + server: + replicas: 1 + service: + type: ClusterIP + loadBalancerIP: "" + annotations: {} + ipFamilies: [] + watchNamespace: "" + resources: + requests: + cpu: 50m + memory: 64Mi + limits: + cpu: 500m + memory: 256Mi extraArgs: [] extraEnv: [] diff --git a/docs/design/README.md b/docs/design/README.md index 5e51522..a4ef2ae 100644 --- a/docs/design/README.md +++ b/docs/design/README.md @@ -12,6 +12,7 @@ - [Cloudflare Provider](./cloudflare-provider.md): Cloudflare identity, schema, ZoneClass parameters, provider data, reconciliation, deletion, and record mapping. - [Route 53 Provider](./route53-provider.md): Route 53 identity, provider settings, and record-set provider schema. - [External Provider](./external-provider.md): verify-only provider for customer-managed DNS: resolver policy, exact-set verification, conditions, scheduling bounds, deletion semantics, and conversion decision. +- [Hosted Provider](./hosted-provider.md): authoritative provider that renders `ZoneUnit` into `HostedZone` snapshots served by dns-api's own DNS server; credentialless, withdrawal-capable, no external DNS API. - [Gateway API Integration](./gateway-api-integration.md): `HTTPRoute` / `Gateway` DNS report, Zone selection, and generated `RecordSet` behavior. - [UI](./ui.md): reusable DNS API UI package, screens, workflows, components, and mockup design. - [Headlamp Desktop Plugin](./headlamp-plugin.md): Headlamp Desktop plugin package, distribution, and Desktop-specific behavior. diff --git a/docs/design/controllers-webhooks.md b/docs/design/controllers-webhooks.md index e4505c9..bd8bb96 100644 --- a/docs/design/controllers-webhooks.md +++ b/docs/design/controllers-webhooks.md @@ -39,9 +39,12 @@ Provider enablement gates runtime registrations and provider-only privileges, no | `providers.cloudflare.enabled` | `--cloudflare-controller-enabled` | `CLOUDFLARE_CONTROLLER_ENABLED` | true | | `providers.route53.endpoint.conversionAPI.enabled` | `--route53-endpoint-conversion-enabled` | `ROUTE53_ENDPOINT_CONVERSION_ENABLED` | true | | `providers.external.enabled` | `--external-controller-enabled` | `EXTERNAL_CONTROLLER_ENABLED` | false | +| `providers.hosted.enabled` | `--hosted-controller-enabled` | `HOSTED_CONTROLLER_ENABLED` | false | Each provider switch gates its identity, ZoneClass, and ZoneUnit controllers, credential-client construction, and identity spec/status admission handler. The chart gates matching identity RBAC and webhook entries. Secret get/list/watch belongs only to enabled Cloudflare; mounted serving certificates do not require Secret API reads. Route 53 conversion requires both Route 53 flags. Its chart flag, capability, APIService, and conversion-create permission additionally require the webhook Service to be enabled. The external switch gates the external ZoneClass/ZoneUnit controllers, the external Provider and `EndpointProviderCapability`, its conversion handler/APIService, and conversion-create RBAC; external has no identity controller, identity webhook, or credential reads, and its conversion API requires the webhook Service with cert-manager-managed serving TLS ([External Provider](external-provider.md#packaging-and-metrics)). +The hosted switch gates its ZoneClass/ZoneUnit controllers, Provider and `EndpointProviderCapability`, conversion handler/APIService, snapshot-write and conversion-create RBAC, and the separate authoritative server Deployment/Service/ServiceAccount. Like external, hosted has no identity or credential reads and requires the webhook Service with cert-manager-managed serving TLS. The server can only get/list/watch `HostedZone` and patch `HostedZone/status`; the manager has no hosted status-write permission. Empty `providers.hosted.server.watchNamespace` means all namespaces and uses a ClusterRole/ClusterRoleBinding. An explicit namespace uses a Role/RoleBinding in that namespace, bound to the server ServiceAccount in the release namespace. Hosted CRDs remain installed when disabled; no hosted runtime templates render. + Core composition, endpoint translation, namespace reads, and core schema/status admission remain enabled independently. Gateway/Service source switches retain their existing registration and permission gates. Scheme/CRD types remain available when a provider is disabled. Helm's default admission coverage matches Kustomize, including ZoneClass status and both identity spec/status endpoints. Kustomize's default installation enables Route 53 and Cloudflare, with external disabled. The opt-in `app/operator/config/external` overlay adds the external Provider, capability, cert-manager-backed APIService, manager arguments, and only its conversion-create permission; it does not add identity or Secret privileges. Custom disable overlays must remove the corresponding admission entries and privileges as well as setting manager flags. Disabling a provider does not complete existing finalizers. ## Implementation Modules diff --git a/docs/design/external-provider.md b/docs/design/external-provider.md index 8fefb44..6147396 100644 --- a/docs/design/external-provider.md +++ b/docs/design/external-provider.md @@ -152,7 +152,7 @@ The external ZoneClass controller matches controllerName and Provider binding, r The external ZoneUnit controller watches ZoneUnit, matching ZoneClass, Provider, and namespace-policy changes. It reconciles only the configured provider/storage version through the matching class. Core remains the sole composer and public Zone/RecordSet status writer. The provider respects `Allowed`, `DeletionRequested`, claim UID, and claim observedGeneration; it does not probe denied ledger items or use old deletion receipts for a new incarnation. -The local read-only test/runtime boundary is `Resolver.Probe(ctx context.Context, request ProbeRequest) (ProbeResult, error)`, with injected clock and jitter. Requests contain checked names, expected RRsets, and effective resolver policy; results contain per-view RCODE, answers, AA, TTL/SOA, and authority evidence. There are no DNS Create/Update/Delete methods. The provider does not use a generic cloud provider interface or call a cloud API. +The shared read-only test/runtime boundary lives in `internal/go/core/dnsprobe`: `dnsprobe.Resolver.Probe(ctx context.Context, request dnsprobe.ProbeRequest) (dnsprobe.ProbeResult, error)`, with injected clock and jitter. External and hosted providers reuse this QUERY-only helper, not a provider abstraction. `ProbeRequest.Policy` and `WireResolver.EffectivePolicy` use provider-neutral `dnsprobe.Policy{Mode, Servers, ExpectedNameservers}`; the external controller converts its API parameters with `config.ProbePolicy`. Results contain per-view RCODE, answers, AA, TTL/SOA, and authority evidence. Exact-set publication evaluation and external pending metrics belong to `internal/go/providers/external/verification`; shared withdrawal exclusion and wire transport do not import provider packages or APIs. There are no DNS Create/Update/Delete methods or cloud API calls. ## Zone Semantics @@ -361,10 +361,10 @@ Use the existing manager metrics registry/server: | Metric | Type and labels | | --- | --- | -| `dns_external_probes_total{outcome,qtype}` | Counter; qtype is A, AAAA, CNAME, NS, or SOA; outcome is verified, mismatch, negative, or failed. | -| `dns_external_probe_duration_seconds{outcome}` | Histogram of completed probe durations. | +| `dns_probe_total{provider,outcome,qtype}` | Shared counter; provider is the static controller identity (`external` or `hosted`), qtype identifies the query, and outcome is transport evidence `observed` or `failed`, not publication success. | +| `dns_probe_duration_seconds{provider,outcome}` | Shared histogram of completed probe durations. | | `dns_external_pending_records{reason}` | Gauge of active unverified record claims, using only the closed reason set above plus Reconciling. | -| `dns_external_probe_queue_delay_seconds` | Histogram of delay from due time to attempt start. | +| `dns_probe_queue_delay_seconds{provider}` | Shared histogram of delay from due time to attempt start. | No hostname, namespace, IP, UID, or resolver-address labels. Do not expose packet/credential content in logs or metrics. diff --git a/docs/design/hosted-provider.md b/docs/design/hosted-provider.md new file mode 100644 index 0000000..98b75fb --- /dev/null +++ b/docs/design/hosted-provider.md @@ -0,0 +1,150 @@ +# Hosted Provider + +## Purpose and Scope + +The hosted provider makes dns-api the authority for a zone. It is the first Core Provider that both writes records and answers DNS queries for them, without any cloud DNS API, credential, or customer-managed zone. The customer delegates a sub-zone once (NS plus glue in their parent zone); every later record change is served by dns-api itself. + +The provider is two runtime components that share one Kubernetes contract and nothing else: + +- The **hosted controller** is an ordinary Core Provider controller. It reads `ZoneUnit.spec`, decides the complete record content of each zone, writes it to a `HostedZone` serving snapshot, and reports `ZoneUnit.status`. It never speaks DNS on the wire except through the read-only probe used for withdrawal verification. +- The **hosted server** is an authoritative DNS server. It watches `HostedZone` snapshots, serves them over UDP and TCP, and reports the serial it serves on `HostedZone.status`. It never reads `Zone`, `RecordSet`, or `ZoneUnit` and never writes anything but its own status entry. + +Splitting decision from serving keeps the serving path free of composition logic and lets the server scale and restart independently. The `HostedZone` snapshot is the only coupling: the controller is its single spec writer; servers are its readers and the sole writers of their own status entries. + +This document defines the design, not evidence of a verified deployment. Platform wiring, VIP allocation, and the delegation acceptance proof are outside this provider's boundary. + +## Provider Declaration + +The Core Provider is `hosted.dns.appthrust.io/v1alpha1`: `Provider.metadata.name=hosted.dns.appthrust.io`, one served and storage version `v1alpha1`, default controller name `hosted.dns.appthrust.io/controller`. Runtime binding follows [provider-extension.md](provider-extension.md#provider): `--hosted-controller-name` / `HOSTED_CONTROLLER_NAME`, `--hosted-provider-name` / `HOSTED_PROVIDER_NAME`, `--hosted-provider-version` / `HOSTED_PROVIDER_VERSION`, and the enable switch `--hosted-controller-enabled` / `HOSTED_CONTROLLER_ENABLED` (default `false`) gated with manager registration, conversion handler, admission, and RBAC as [Provider Enablement](controllers-webhooks.md#provider-enablement) requires. + +| Field | Contract | +| --- | --- | +| `spec.display.name` | `Hosted DNS` | +| `spec.display.description` | `Serves zones authoritatively from Kubernetes; no external DNS provider.` | +| `spec.versions[].identity` | Omitted. Credentialless. | +| `spec.versions[].zoneClass.schemas.parameters` | The parameter schema below. | +| `spec.versions[].zone.schemas.adoption` | Empty-object schema. | +| `spec.versions[].recordSet.supportedTypes` | `A`, `AAAA`, `CNAME`, `TXT`, `MX`, `CAA`, `NS`. | +| `spec.versions[].recordSet.schemas.options` / `.adoption` | Empty-object schema. | +| `spec.versions[].recordSet.validationRules` | Rejects a `RecordSet` with `type: NS` at `@` (the apex NS RRset is owned by the class); the `zoneUnit` pair rule repeats it for composition. | +| `spec.versions[].zone.schemas.statusProviderData` / `.recordSet.schemas.statusProviderData` | Public serving data: zone `{serial, contentDigest, servers[] ≤64, delegation{observedNameServers[] ≤16, matches, lastProbeTime, error ≤512}}`; record set `{serial}`. | +| `spec.versions[].recordWithdrawal` | `supported: true` with the status schema and the eleven closed reason combinations defined for [external withdrawal](external-provider.md#withdrawal-verification). | +| `spec.versions[].zone.additionalConditionReasons` | `ServingPending`, `SnapshotRejected`. | +| `spec.versions[].recordSet.additionalConditionReasons` | `ServingPending`. | + +Hosted keeps every core record validation. Apex `NS` claims are rejected at admission by a `recordSet.validationRules` entry (ZoneUnit rules are pair-only and cannot see a lone claim): the apex NS RRset is owned by the zone's `nameServers` parameter, never by a `RecordSet`. + +## ZoneClass Parameters + +`ZoneClass.spec.identityRef` is omitted. + +| Field | Description and bounds | +| --- | --- | +| `nameServers[]` | Required, 1–8 items. Each has `name` (canonical lowercase FQDN without trailing dot) and `addresses[]` (1–8 canonical IPv4/IPv6 literals). These become the apex NS RRset and, for in-zone names, the glue A/AAAA RRsets of every zone created from the class. Addresses are required even for out-of-zone names so the platform can publish deterministic glue. | +| `soa.rname` | Mailbox name, default `hostmaster.`. | +| `soa.refresh`, `soa.retry`, `soa.expire`, `soa.minimum` | Seconds; defaults 3600, 900, 1209600, 300. `minimum` is the negative-caching TTL the withdrawal evaluator uses. 1–2419200. | +| `defaultTTL` | Seconds, default 60, 1–86400. Applied to NS, glue, and endpoint-converted record sets. | +| `verification.views[]` | Optional, at most 3 canonical `IP:port` endpoints the withdrawal evaluator queries. Omitted means the in-cluster hosted server Service (`..svc:53`). The platform sets its public VIP here so the withdrawal proof is taken from the address clients use. | +| `verification.delegation.resolver` | `mode` `Cluster` (default; the controller's `resolv.conf`) or `Recursive` with `servers[]` (at most 3 canonical `IP:port`), the same vocabulary as the external provider. The controller always probes `NS ` through it and reports `zone.provider.data.delegation{observedNameServers[], matches, lastProbeTime, error}`; `matches` is true when the observed apex NS name set (lowercase, no trailing dot) equals the configured `nameServers[].name` set. Observation only; it never changes `Programmed`. | + +SOA `mname` is `nameServers[0].name`. SOA serial is controller-owned (below). Changing `nameServers` or `soa` re-renders every zone of the class. + +## HostedZone Snapshot + +`HostedZone` (`hosted.dns.appthrust.io/v1alpha1`) is namespace-scoped and uses the same namespace and name as the `ZoneUnit`. The hosted controller creates, updates, and deletes it; it carries an ownerReference to the `ZoneUnit`. + +`spec`: + +- `domainName`: canonical zone apex. +- `serial`: uint32, strictly increasing per snapshot. The controller sets `max(previous+1, unixSeconds(now) mod 2^32)` on every content change and never decreases it. +- `soa`: rendered `mname`, `rname`, `refresh`, `retry`, `expire`, `minimum`. +- `nameServers[]`: apex NS names with glue addresses, from the class. +- `records[]`: complete rendered RRsets, map-list keyed by `name` (zone-relative, `@` for apex, leftmost `*` allowed) and `type`. Each has `ttl` and `values[]` (canonical presentation strings: addresses, lowercase FQDNs with trailing dot, quoted TXT chunks, `pref target` for MX, `flags tag "value"` for CAA). At most 10000 RRsets, 64 values each. Only `allowed` and non-`deletionRequested` ZoneUnit items are rendered; cleanup ledger items are omitted, which is how a hosted record is "deleted". +- `contentDigest`: `sha256:` over the canonical wire form of `soa`, `nameServers`, and `records`, so servers and the withdrawal evaluator can bind evidence to exact content. + +`status`: + +- `servers[]`: map-list keyed by `endpoint` (pod identity `namespace/name`), each with `serial`, `contentDigest`, `observedAt`. Each server owns exactly its own item through server-side apply with field manager `hosted-server/`, re-applies it every 30 s as a heartbeat (`observedAt`), and removes it on graceful shutdown. The controller never writes `status`; an item whose `observedAt` is older than the fixed `ServerFreshness` (3 minutes) is not fresh and is ignored. `Programmed=True` requires at least one fresh server and every fresh server at the current `serial` and `contentDigest`; the withdrawal evaluator's served term uses the same rule, so a replica that is alive but behind never produces a false exclusion proof. + +The snapshot is the provider's whole desired state. A server restarted from nothing reconstructs serving from snapshots alone; the controller restarted from nothing re-renders every `ZoneUnit` and converges serials without reading server state. + +## Controller Responsibility + +Per `ZoneUnit` with `spec.provider.name` equal to the bound provider name: + +1. Validate the class parameters again against the storage schema; on failure write `Zone` `Accepted=False/InvalidProviderPayload` (the core reason) and stop. +2. Render `records[]` from `spec.recordSets[]` (`allowed != false`, `deletionRequested == false`) plus apex NS and glue. Composition conflicts were already resolved by core; the renderer is total. +3. Compare `contentDigest` with the live `HostedZone`; if unchanged, do not write (no serial bump). Otherwise apply the snapshot with the next serial. +4. Report `ZoneUnit.status`: + - `zone.nameServers` = class `nameServers[].name`. + - `zone.conditions`: `Accepted=True/Accepted`; `Programmed=True/Programmed` once every fresh `status.servers[]` item (and at least one) reports the current `serial` and `contentDigest`; otherwise `Programmed=False/ServingPending` with message `Snapshot serial is not yet served by any hosted server.`; `Programmed=False/SnapshotRejected` when the API server rejects the snapshot (message bounded to 2048 bytes). + - `zone.provider.data`: `serial`, `contentDigest`, `servers[]` (endpoint, serial, observedAt; at most 64), optional `delegation {observedNameServers[], matches, lastProbeTime}`. + - `recordSets[]`: `Accepted=True/Accepted`; `Programmed` mirrors the zone-level serving state for the serial that first contained this item's generation; `deletionCompleted=true` for a `deletionRequested` item once a snapshot without it is served by at least one server. +5. Withdrawals: see below. + +The controller watches `HostedZone` status changes (predicate on `status.servers[]`) so serving acknowledgement requeues the unit without polling. + +Deletion of a `ZoneUnit` deletes the snapshot; servers stop answering for that apex (REFUSED) once the watch delivers the deletion. Retained withdrawal claims on a deleting unit are still verified. + +## Hosted Server + +`internal/go/providers/hosted/server` is a separate `main` target (`app/hosted-server/cmd/server`) built into the same image. It uses `github.com/miekg/dns` (already a dependency of the shared probe) and no other DNS library. + +Behavior: + +- Listens on UDP and TCP `:5353` as a non-root process; the Service maps port 53. EDNS0 advertised size 1232; UDP answers that exceed the client's size are truncated with TC, and TCP is served in full. +- `AA=1`, `RA=0`. Queries whose name is not at or below a served apex get `REFUSED`. ANY is answered with `HINFO RFC8482`. AXFR/IXFR are `REFUSED` in this version; `NOTIFY` is not sent. Secondary transfer is a future extension through the snapshot, not through the server. +- Answers SOA and NS at the apex with glue in the additional section; A/AAAA/CNAME/TXT/MX/CAA/NS at owner names; in-zone CNAME chains are followed up to 8 links; wildcard synthesis follows RFC 4592 for the leftmost `*` label, with an existing closer name blocking synthesis. NXDOMAIN and NODATA carry the SOA in the authority section with TTL `min(soa.ttl, soa.minimum)`. +- Case-insensitive matching; 0x20 case of the question name is preserved in the answer. +- Memory and CPU are bounded by the CRD bounds (10000 RRsets × 64 values per zone, zones limited by `--max-zones`, default 4096). Per-source-address UDP rate limiting (`--udp-rate-limit`, default 1000 qps/source) protects against amplification; responses never exceed the request's advertised size over UDP. +- Each server watches `HostedZone` with an informer scoped by `--watch-namespace` (empty = all) and swaps an immutable in-memory zone index atomically per event. After loading a snapshot it applies its `status.servers[]` entry. On watch loss it keeps serving the last index and reports nothing until the watch is back. +- Metrics: `dns_hosted_queries_total{zone,qtype,rcode}`, `dns_hosted_query_duration_seconds`, `dns_hosted_zone_serial{zone}`, `dns_hosted_zones_loaded`, `dns_hosted_udp_rate_limited_total`. +- Health: `/healthz` liveness is process health; `/readyz` is true once the initial list completed. A server with zero zones is ready (it answers REFUSED, which is the correct authoritative behavior for an empty authority). + +RBAC for the server ServiceAccount: `hostedzones` get/list/watch and `hostedzones/status` patch, granted through a ClusterRole when `watchNamespace` is empty and a namespaced Role otherwise. Nothing else. + +## Withdrawal Verification + +Hosted advertises `recordWithdrawal.supported=true`. Exclusion is proven at the authority, which is stronger than external's resolver-view proof because the provider knows exactly what it serves: + +```text +excluded(address) := address ∉ snapshot(records at checkedName, terminal A/AAAA) + ∧ ∀ view ∈ verification.views: probe(view, checkedName) shows address absent +``` + +The snapshot term is evaluated from the served snapshot (`status.servers[]` serial ≥ the first serial that omitted the address), not from the controller's intent. The probe term reuses the shared probe resolver (`internal/go/core/dnsprobe`, moved from `internal/go/providers/external/probe` with the external provider updated to import it; the package is a helper, not a provider abstraction). The server's negative-caching TTL is `min(SOA TTL, soa.minimum)` where the SOA TTL equals the apex NS TTL (`defaultTTL`); the evaluator conservatively uses `soa.minimum`, which is never smaller: `notBefore = t0 + max(retainedPositiveTTL, soa.minimum)`, and a confirming probe at or after `notBefore` is required. Conditions, reasons, messages, waiver echo, scheduling budget, and metrics (`dns_hosted_withdrawals_pending{reason}`) follow the external contract verbatim so platform consumers see one withdrawal vocabulary. + +## Endpoint Conversion + +App Provider capability `endpoint.hosted.dns.appthrust.io/v1alpha1`, resource `endpointrecordsetconversions`, with the same semantics as the external conversion: sorted canonical A then AAAA fragments or exactly one CNAME fragment, rejection of mixed targets, multiple distinct hostnames, apex CNAME, and incompatible preferences. The conversion request carries no ZoneClass context, so the fragment TTL comes from the manager flag `--hosted-endpoint-ttl` / `HOSTED_ENDPOINT_TTL` (chart `providers.hosted.endpointTTL`, default 60) rather than the class `defaultTTL`. An `EndpointProviderCapability` manifest registers it. + +## Admission and Security + +- The core webhook validates parameters, adoption, and options from the Provider schema; `HostedZone` has its own CRD CEL (serial > 0, record name/type canonical forms, value counts). +- `HostedZone` spec writes are restricted by RBAC to the hosted controller ServiceAccount; a user cannot inject records by creating a snapshot because the controller owns every snapshot named after a `ZoneUnit` and deletes unowned ones (`SnapshotRejected` is reported on the unit whose name is shadowed). +- The server has no write path except its status entry and no recursion, so it cannot be used as an open resolver or amplifier beyond the bounded UDP response size. +- No credential, no outbound network except the withdrawal/delegation probes to configured endpoints. + +## Packaging + +Chart values: + +| Value | Default | Effect | +| --- | --- | --- | +| `providers.hosted.enabled` | `false` | Controller registration, Provider/EndpointProviderCapability/APIService manifests, hosted RBAC, conversion handler. | +| `providers.hosted.controllerName` / `providerName` / `providerVersion` | contract defaults | Runtime binding. | +| `providers.hosted.server.replicas` | `1` | Hosted server Deployment. | +| `providers.hosted.server.service.type` | `ClusterIP` | `LoadBalancer` for a public VIP. | +| `providers.hosted.server.service.loadBalancerIP`, `.annotations`, `.ipFamilies` | unset | Deterministic VIP for glue. | +| `providers.hosted.server.watchNamespace` | `""` | Informer scope. | +| `providers.hosted.endpointTTL` | `60` | TTL of endpoint-converted fragments (see Endpoint Conversion). | +| `providers.hosted.server.resources` | requests 50m/64Mi, limits 500m/256Mi | Bounded by zone limits. | + +The CRD carrier gains `hostedzones.hosted.dns.appthrust.io`. + +## Tests + +- Unit: renderer (ZoneUnit → snapshot, digest stability, serial monotonicity, ledger omission), server (authoritative answers per RFC cases: apex SOA/NS/glue, NODATA, NXDOMAIN, wildcard with closer-name block, CNAME chain, TC/TCP, REFUSED, ANY, rate limit), withdrawal evaluator (snapshot ∧ view proof, `notBefore` from `soa.minimum`). +- Envtest: controller end to end with a fake server status writer: `ServingPending` → `Programmed`, `deletionCompleted` after omission is served, snapshot shadowing → `SnapshotRejected`. +- Local transport: real server process on a loopback port queried through the probe package; this is the same path the platform's delegation proof later exercises against a VIP. +- Chart: enablement gating renders nothing for hosted when disabled; APIService, RBAC, Deployment, and Service shapes when enabled. diff --git a/docs/design/provider-extension.md b/docs/design/provider-extension.md index 58bfc29..81262f2 100644 --- a/docs/design/provider-extension.md +++ b/docs/design/provider-extension.md @@ -140,7 +140,7 @@ Provider admission error messages identify the failing field path and state that Provider admission validates conversion CEL expressions separately. A conversion CEL expression must compile in the Provider conversion environment, where `self` is the provider-owned payload object and the expression result must be an object that can be validated against the storage target schema after defaulting. Conversion CEL expressions do not return `bool`. -`versions[].identity.resource` is required when `versions[].identity` is present. Route 53 and Cloudflare declare it; the external verification provider omits it. The declaration is discovery and contract metadata: core admission requires a nonempty `ZoneClass.spec.identityRef.name` if and only if the selected version declares identity, and rejects `identityRef` otherwise. It does not manufacture an identity for credentialless providers. UI can link to the declared identity kind, while the provider controller remains responsible for resolving that object, checking provider-specific readiness, and applying credential rules. +`versions[].identity.resource` is required when `versions[].identity` is present. Route 53 and Cloudflare declare it; the external verification and hosted authoritative providers omit it. The declaration is discovery and contract metadata: core admission requires a nonempty `ZoneClass.spec.identityRef.name` if and only if the selected version declares identity, and rejects `identityRef` otherwise. It does not manufacture an identity for credentialless providers. UI can link to the declared identity kind, while the provider controller remains responsible for resolving that object, checking provider-specific readiness, and applying credential rules. Provider admission validates `versions[].recordSet.supportedTypes` independently of any `RecordSet`. It rejects unknown types and types that do not have a core `RecordSet.spec` body in the current API version. A provider package must not claim `SRV` or another future type until the core API adds the matching body field, validation rules, examples, and provider schema support. This keeps `RecordSet.spec.type`, the required body field, and Provider capability metadata consistent. @@ -192,11 +192,11 @@ These validations cannot be disabled: `require-provider`, `require-zone-ref`, `r ### Record Withdrawal Capability -`spec.versions[].recordWithdrawal` is an optional `ProviderRecordWithdrawal` capability with `supported`, `schemas.statusProviderData.openAPIV3Schema`, and `additionalConditionReasons`. Omission or `supported=false` does not support independent withdrawal. Core checks both the served input and storage contracts before composing verification work. External advertises support; Route 53 and Cloudflare do not advertise or reconcile withdrawals. +`spec.versions[].recordWithdrawal` is an optional `ProviderRecordWithdrawal` capability with `supported`, `schemas.statusProviderData.openAPIV3Schema`, and `additionalConditionReasons`. Omission or `supported=false` does not support independent withdrawal. Core checks both the served input and storage contracts before composing verification work. External and hosted advertise support; Route 53 and Cloudflare do not advertise or reconcile withdrawals. The status schema validates public `RecordWithdrawal.status.provider.data` and `ZoneUnit.status.withdrawals[].provider.data`, not provider-private scheduler state. External exposes only a bounded `views` preview there; typed claim/Unit evidence, never truncated previews, drives decisions. -Core owns `Accepted=True/Accepted` and `Accepted=False` with `InvalidWithdrawal`, `NotAllowed`, or `UnsupportedProvider`. Withdrawal additional reasons use the closed contract: `Withdrawn=False` and `Ready=False` with `DNSStillResolves` or `NegativeTTLNotElapsed`; `Withdrawn=Unknown` and `Ready=False` with `DNSProbeFailed` or `PolicyChanged`; `Withdrawn=True` and `Ready=True` with `WithdrawalVerified`; and `OverrideAccepted=True/OperatorAuthorizedRelease`. External declares these eleven combinations. Admission validates schema fragments and rejects duplicate, core-acceptance, or out-of-contract reason combinations. +Core owns `Accepted=True/Accepted` and `Accepted=False` with `InvalidWithdrawal`, `NotAllowed`, or `UnsupportedProvider`. Withdrawal additional reasons use the closed contract: `Withdrawn=False` and `Ready=False` with `DNSStillResolves` or `NegativeTTLNotElapsed`; `Withdrawn=Unknown` and `Ready=False` with `DNSProbeFailed` or `PolicyChanged`; `Withdrawn=True` and `Ready=True` with `WithdrawalVerified`; and `OverrideAccepted=True/OperatorAuthorizedRelease`. External and hosted declare these eleven combinations. Admission validates schema fragments and rejects duplicate, core-acceptance, or out-of-contract reason combinations. ### Controller Responsibility @@ -221,7 +221,7 @@ Common code may be shared for provider reference parsing, Provider version resol `internal/go/providers/external` implements the credentialless `external.dns.appthrust.io/v1alpha1` contract. Its manifest advertises exactly A, AAAA, and CNAME. ZoneClass parameters choose Cluster pod DNS or up to three explicit Recursive IP:port views and optionally up to sixteen expected authoritative nameservers. All configured views must agree; there is no OS or public-resolver fallback. Provider OpenAPI/CEL rejects noncanonical endpoints, noncanonical nameservers, nonempty adoption/options, and incompatible resolver settings. -The read-only boundary is `probe.Resolver.Probe(context.Context, probe.ProbeRequest) (probe.ProbeResult, error)`. The wire implementation sends QUERY only, uses UDP then TCP on truncation, follows at most eight CNAME links, and checks both address families against complete exact sets. A wildcard uses a stable concrete witness derived from the ZoneUnit identity, Zone reference, and sorted claim UID/generation tuple; it is sampled evidence, not a guarantee about every descendant name. The provider consumes the core ledger, never writes claim specs/status, and never reads DNS credentials. +The shared read-only boundary is `dnsprobe.Resolver.Probe(context.Context, dnsprobe.ProbeRequest) (dnsprobe.ProbeResult, error)` in `internal/go/core/dnsprobe`, reused by external and hosted verification without introducing a provider abstraction. Its `dnsprobe.Policy{Mode, Servers, ExpectedNameservers}` is provider-neutral; external API parameters are converted at the provider boundary. The wire implementation sends QUERY only, uses UDP then TCP on truncation, follows at most eight CNAME links, and collects both address families. `internal/go/providers/external/verification` owns exact-set publication evaluation and external pending metrics; shared withdrawal exclusion stays in `dnsprobe`. Shared transport metrics use `dns_probe_*{provider}` and never imply publication success. A wildcard uses a stable concrete witness derived from the ZoneUnit identity, Zone reference, and sorted claim UID/generation tuple; it is sampled evidence, not a guarantee about every descendant name. The provider consumes the core ledger, never writes claim specs/status, and never reads DNS credentials. Manager wiring uses `zoneclass.ZoneClassReconciler.SetupWithManager`, `zoneunit.NewReconciler(client.Client, zoneunit.Options)` followed by `SetupWithManager`, and `conversion.NewHandler().Register(webhookServer)`. `Options` carries binding strings, optional injected Resolver/Clock/Jitter/EffectivePolicy, and QPS/Burst (defaults 40/80). The controller admits at most four names concurrently across units and thirty-two due names per pass. One cycle-local Zone probe shares NS/SOA and NS-host work across its names. It persists advisory due times/fingerprints and resumes a backlog after one second; completed names retain a 30–300 second polling floor/cap, ±10% jitter, and failure backoff of 30/60/120/300 seconds. @@ -230,6 +230,30 @@ Public verification data includes bounded typed actual/missing/extra previews, c The endpoint conversion group is `endpoint.external.dns.appthrust.io/v1alpha1`, resource `endpointrecordsetconversions`. It preserves request UID and name, emits sorted canonical A then AAAA fragments or one CNAME fragment, and uses TTL 300 without modifying customer TTLs. Mixed IP/hostname targets, multiple distinct hostname targets, incompatible preferences, and apex CNAME are rejected. Provider and capability declarations are in `internal/go/providers/external/manifest`; manager/chart registration is separate from these runtime packages. +### Hosted Authoritative Runtime + +`internal/go/providers/hosted` implements the credentialless `hosted.dns.appthrust.io/v1alpha1` contract. It serves A, AAAA, CNAME, TXT, MX, CAA, and NS from namespace-scoped `HostedZone` snapshots. Core owns composition; the hosted ZoneUnit controller is the snapshot spec writer, and each server owns only its own `status.servers[]` acknowledgement. `Programmed` requires at least one fresh acknowledgement and every fresh acknowledgement to match the current serial and content digest. `ZoneClass` has no `identityRef`; its `nameServers` supplies apex NS/glue and its SOA policy supplies negative caching. Withdrawal requires both served-snapshot exclusion and read-only DNS probe exclusion, followed by the cache horizon and a confirming probe. + +Manager wiring uses `zoneclass.ZoneClassReconciler.SetupWithManager`, `zoneunit.NewReconciler(client.Client, zoneunit.Options) (*zoneunit.ZoneReconciler, error)` followed by `SetupWithManager`, and `conversion.NewHandler(defaultTTL uint32).Register(webhookServer)`. `Options` carries `ControllerName`, `ProviderName`, `ProviderVersion`, optional injected `Resolver`, `Evaluator`, `Clock`, `Jitter`, `DefaultViews`, and `QPS`/`Burst`. Unset hooks select the shared `dnsprobe` wire resolver and hosted withdrawal evaluator; bindings default to the hosted API constants. The chart supplies `HOSTED_SERVER_ADDRESS=..svc:53`; direct manager execution defaults to `dns-api-hosted-server..svc:53` (`POD_NAMESPACE` defaults to `dns-api-system`). This address supplies the default verification view when class views are omitted. Service hostnames resolve to concrete IP:port views before probing; class-configured views remain canonical IP endpoints. + +| Chart value | Manager flag | Environment default | Default | +| --- | --- | --- | --- | +| `providers.hosted.enabled` | `--hosted-controller-enabled` | `HOSTED_CONTROLLER_ENABLED` | `false` | +| `providers.hosted.controllerName` | `--hosted-controller-name` | `HOSTED_CONTROLLER_NAME` | `hosted.dns.appthrust.io/controller` | +| `providers.hosted.providerName` | `--hosted-provider-name` | `HOSTED_PROVIDER_NAME` | `hosted.dns.appthrust.io` | +| `providers.hosted.providerVersion` | `--hosted-provider-version` | `HOSTED_PROVIDER_VERSION` | `v1alpha1` | +| `providers.hosted.endpointTTL` | `--hosted-endpoint-ttl` | `HOSTED_ENDPOINT_TTL` | `60` | + +Explicit flags override environment defaults. Endpoint TTL must be 1–86400 seconds. The conversion group is `endpoint.hosted.dns.appthrust.io/v1alpha1`, resource `endpointrecordsetconversions`, with sorted canonical A then AAAA fragments or one CNAME fragment and external's rejection rules. **Implementation deviation from the design's class `defaultTTL`:** conversion requests carry only the zone domain, not its class, so the handler cannot resolve the class TTL. Endpoint conversion therefore uses the manager's `--hosted-endpoint-ttl` rather than `ZoneClass.parameters.defaultTTL`; set it consistently with the classes served by that manager. Provider and capability source declarations live in `internal/go/providers/hosted/manifest`. + +The thin entrypoint is `app/hosted-server/cmd/server/main.go`, matching the repository's `app/operator/cmd/manager` layout. It calls `server.Run(ctx context.Context, options server.Options) error` and is built as `/hosted-server` into the manager image; the image entrypoint remains `/manager`. `server.Options` carries `ListenAddr` (`:5353`), `WatchNamespace` (empty means all namespaces), `MaxZones` (4096), `UDPRateLimitPerSource` (1000), `PodIdentity` (`namespace/name`, defaulted from `POD_NAMESPACE`/`POD_NAME`), `MetricsAddr` (`:8080`), `HealthAddr` (`:8081`), and optional `RESTConfig` (nil uses in-cluster credentials). Server flags are `--listen-address`, `--watch-namespace`, `--max-zones`, `--udp-rate-limit`, `--metrics-bind-address`, and `--health-probe-bind-address`; `"0"` disables either HTTP listener. The chart runs non-root with a read-only root filesystem, exposes UDP/TCP 53 → 5353, and probes `/healthz` and `/readyz`. + +Each server publishes endpoint `POD_NAMESPACE/POD_NAME` with SSA field manager `hosted-server/`. While its watch is connected, it refreshes unchanged acknowledgements every 30 seconds; watch loss cancels pending acknowledgements while the last snapshot remains served. The controller only reads these entries and ignores acknowledgements older than the fixed `ServerFreshness = 3m`, independent of SOA refresh, rather than pruning status. On graceful shutdown, the server applies an empty `servers` list with its own field manager to remove only its owned contribution. + +The server indexes only snapshots with a `ZoneUnit` ownerReference in `dns.appthrust.io/v1alpha1` carrying a UID. If snapshots in different namespaces claim the same apex, neither is served. Rejections increment `dns_hosted_snapshots_rejected_total{reason="unowned"|"apex_conflict"}`. SOA RR TTL is `soa.minimum`, independent of the rendered apex NS TTL, so negative responses expose the same cache horizon used by withdrawal verification. + +`providers.hosted.server` configures `replicas` (1), `service.type` (`ClusterIP`), `service.loadBalancerIP` (`""`), `service.annotations` (`{}`), `service.ipFamilies` (`[]`), `watchNamespace` (`""`), and `resources` (requests 50m/64Mi; limits 500m/256Mi). Empty watch scope requires a ClusterRole/ClusterRoleBinding; an explicit scope uses a Role/RoleBinding in that namespace. Both grant only `hostedzones` get/list/watch and `hostedzones/status` patch to the dedicated server ServiceAccount. With `rbac.create=false`, supply these permissions externally. The watched namespace must already exist. Hosted enablement requires both `webhook.enabled` and `certManager.enabled`; disabling hosted removes every hosted runtime template and its manager privileges, but not the CRD carrier. + ### Provider Declaration Example ```yaml diff --git a/internal/go/core/dnsprobe/evaluate.go b/internal/go/core/dnsprobe/evaluate.go new file mode 100644 index 0000000..2ae775b --- /dev/null +++ b/internal/go/core/dnsprobe/evaluate.go @@ -0,0 +1,161 @@ +package dnsprobe + +import ( + "fmt" + "slices" + "strings" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +type Decision struct { + Status metav1.ConditionStatus + Reason string + Message string + Actual []string + Missing []string + Extra []string + Chain []string + Views []string + Nameservers []string + TTL *uint32 + NegativeTTL *uint32 +} + +func Failed(name, view, failure string) Decision { + switch failure { + case "timeout", "SERVFAIL", "REFUSED", "transport", "invalid-response", "resolver-config", "chain-limit", "probe-limit": + default: + failure = "transport" + } + return Decision{Status: metav1.ConditionUnknown, Reason: "DNSProbeFailed", Message: Bounded(fmt.Sprintf("DNS verification unavailable for %s from %s: %s.", name, view, failure))} +} + +func EvaluateZone(request ProbeRequest, evidence *ZoneEvidence) Decision { + return evaluateZone(request, evidence, true) +} + +func evaluateZone(request ProbeRequest, evidence *ZoneEvidence, requireAuthorities bool) Decision { + if len(request.Policy.ExpectedNameservers) == 0 { + return Decision{Status: metav1.ConditionTrue, Reason: "VerificationConfigured", Message: Bounded(fmt.Sprintf("DNS verifier configured for %s; authoritative nameserver checks were not requested.", request.Zone))} + } + if evidence == nil || len(evidence.Views) < len(request.Policy.Servers) || len(evidence.Views) == 0 { + return Failed(request.Zone, "required-views", "invalid-response") + } + for _, server := range request.Policy.Servers { + if !slices.ContainsFunc(evidence.Views, func(view AuthorityObservation) bool { return view.ID == "recursive/"+server }) { + return Failed(request.Zone, "recursive/"+server, "invalid-response") + } + } + views := slices.Clone(evidence.Views) + slices.SortFunc(views, func(a, b AuthorityObservation) int { return strings.Compare(a.ID, b.ID) }) + decision := Decision{Status: metav1.ConditionTrue, Reason: "NameserversVerified"} + for _, view := range views { + decision.Views = append(decision.Views, view.ID) + if view.Failure != "" { + return Failed(request.Zone, view.ID, view.Failure) + } + for _, name := range view.Nameservers { + if !ValidName(CanonicalName(name)) { + return Failed(request.Zone, view.ID, "invalid-response") + } + } + decision.TTL = MinTTL(decision.TTL, view.TTL) + decision.NegativeTTL = MinTTL(decision.NegativeTTL, view.NegativeTTL) + } + first := views[0] + decision.Nameservers = Names(first.Nameservers) + decision.Actual = Typed("NS", decision.Nameservers) + for _, view := range views[1:] { + if !slices.Equal(decision.Nameservers, Names(view.Nameservers)) || CanonicalName(view.SOAOwner) != CanonicalName(first.SOAOwner) { + return Inconsistent(request.Zone, decision) + } + } + if CanonicalName(first.SOAOwner) != request.Zone || !slices.Equal(decision.Nameservers, Names(request.Policy.ExpectedNameservers)) { + decision.Status, decision.Reason = metav1.ConditionFalse, "NameserverMismatch" + decision.Missing, decision.Extra = Difference(Typed("NS", Names(request.Policy.ExpectedNameservers)), decision.Actual) + decision.Message = Bounded(fmt.Sprintf("DNS %s nameservers differ: expected %s; observed %s.", request.Zone, MessagePreview(Names(request.Policy.ExpectedNameservers)), MessagePreview(decision.Nameservers))) + return decision + } + if requireAuthorities && len(evidence.Authorities) == 0 { + return Failed(request.Zone, "required-authorities", "invalid-response") + } + if requireAuthorities { + for _, authority := range evidence.Authorities { + found := false + for _, view := range views { + if view.ID == "authority/"+authority && view.Authoritative { + found = true + break + } + } + if !found { + return Failed(request.Zone, "authority/"+authority, "invalid-response") + } + } + } + decision.Message = Bounded(fmt.Sprintf("DNS %s nameservers match expectations in every required view; no DNS changes were made.", request.Zone)) + return decision +} + +func Inconsistent(name string, decision Decision) Decision { + decision.Status, decision.Reason = metav1.ConditionFalse, "ResolutionInconsistent" + decision.Message = Bounded(fmt.Sprintf("DNS %s differs between required resolver/authority views %s.", name, MessagePreview(decision.Views))) + return decision +} + +func Typed(kind string, values []string) []string { + out := make([]string, len(values)) + for i, value := range values { + out[i] = kind + " " + value + } + return out +} + +func Difference(expected, actual []string) (missing, extra []string) { + for _, value := range expected { + if !slices.Contains(actual, value) { + missing = append(missing, value) + } + } + for _, value := range actual { + if !slices.Contains(expected, value) { + extra = append(extra, value) + } + } + slices.Sort(missing) + slices.Sort(extra) + return missing, extra +} + +func Preview(values []string) ValuesPreview { + result := ValuesPreview{Total: int32(len(values)), Truncated: len(values) > 16} + result.Values = slices.Clone(values[:min(len(values), 16)]) + for i, value := range result.Values { + if len(value) > 512 { + result.Values[i] = value[:512] + result.Truncated = true + } + } + return result +} + +func MessagePreview(values []string) string { + // The message preview is smaller than public data so two previews plus a name fit 2048 bytes. + shown := values[:min(len(values), 4)] + result := "[" + strings.Join(shown, ", ") + "]" + if len(values) > len(shown) { + result += fmt.Sprintf(" (+%d omitted)", len(values)-len(shown)) + } + if len(result) > 700 { + result = result[:680] + " (truncated)" + } + return result +} + +func Bounded(value string) string { + if len(value) > 2048 { + return value[:2032] + " (truncated)" + } + return value +} diff --git a/internal/go/providers/external/probe/limits_test.go b/internal/go/core/dnsprobe/limits_test.go similarity index 92% rename from internal/go/providers/external/probe/limits_test.go rename to internal/go/core/dnsprobe/limits_test.go index 54079c6..2139807 100644 --- a/internal/go/providers/external/probe/limits_test.go +++ b/internal/go/core/dnsprobe/limits_test.go @@ -1,4 +1,4 @@ -package probe +package dnsprobe import ( "context" @@ -14,14 +14,14 @@ import ( func TestWireChainBoundaries(t *testing.T) { for _, tc := range []struct { - name string - links int - loop bool - reason string + name string + links int + loop bool + failure string }{ - {"eight", 8, false, "ResolutionVerified"}, - {"nine", 9, false, "DNSProbeFailed"}, - {"loop", 2, true, "ResolutionMismatch"}, + {"eight", 8, false, ""}, + {"nine", 9, false, "chain-limit"}, + {"loop", 2, true, ""}, } { t.Run(tc.name, func(t *testing.T) { server := localDNS(t, func(w dns.ResponseWriter, request *dns.Msg) { @@ -46,9 +46,8 @@ func TestWireChainBoundaries(t *testing.T) { resolver, _ := NewWireResolver(WireOptions{}) session := wireSession{resolver: resolver} view := session.resolve(context.Background(), "link-0.example.test", server, "", "example.test") - decision := Evaluate(ProbeRequest{Name: "link-0.example.test", Expected: RRsets{A: []string{"192.0.2.1"}}}, ProbeResult{Views: []View{view}}) - if decision.Reason != tc.reason { - t.Fatalf("decision=%#v view=%#v", decision, view) + if view.Failure != tc.failure || view.Mismatch != tc.loop || (tc.failure == "" && !tc.loop && (len(view.A) != 1 || view.A[0] != "192.0.2.1")) { + t.Fatalf("view=%#v", view) } }) } @@ -150,7 +149,7 @@ func TestNameserverAddressFanoutFailsClosed(t *testing.T) { }) resolver, _ := NewWireResolver(WireOptions{}) request := ProbeRequest{Zone: "example.test", Name: "example.test", ZoneOnly: true} - request.Policy.Resolver.Servers = []string{server} + request.Policy.Servers = []string{server} request.Policy.ExpectedNameservers = []string{"ns.example.test"} result, err := resolver.Probe(context.Background(), request) if err != nil { diff --git a/internal/go/core/dnsprobe/metrics.go b/internal/go/core/dnsprobe/metrics.go new file mode 100644 index 0000000..bac0bb0 --- /dev/null +++ b/internal/go/core/dnsprobe/metrics.go @@ -0,0 +1,38 @@ +package dnsprobe + +import ( + "time" + + "github.com/prometheus/client_golang/prometheus" + "sigs.k8s.io/controller-runtime/pkg/metrics" +) + +var ( + probes = prometheus.NewCounterVec(prometheus.CounterOpts{Name: "dns_probe_total", Help: "Completed DNS observations by provider."}, []string{"provider", "outcome", "qtype"}) + duration = prometheus.NewHistogramVec(prometheus.HistogramOpts{Name: "dns_probe_duration_seconds", Help: "Admitted DNS probe duration by provider."}, []string{"provider", "outcome"}) + queueDelay = prometheus.NewHistogramVec(prometheus.HistogramOpts{Name: "dns_probe_queue_delay_seconds", Help: "Scheduling and limiter admission delay for DNS probes."}, []string{"provider"}) +) + +func init() { metrics.Registry.MustRegister(probes, duration, queueDelay) } + +func observeQueueDelay(provider string, delay time.Duration) { + queueDelay.WithLabelValues(provider).Observe(max(0, delay.Seconds())) +} + +// observationOutcome describes transport evidence only. Provider publication +// evaluators decide exact-set success after this shared read-only boundary. +func observationOutcome(result ProbeResult) string { + if result.Zone != nil { + for _, view := range result.Zone.Views { + if view.Failure != "" { + return "failed" + } + } + } + for _, view := range result.Views { + if view.Failure != "" || view.Mismatch { + return "failed" + } + } + return "observed" +} diff --git a/internal/go/core/dnsprobe/metrics_test.go b/internal/go/core/dnsprobe/metrics_test.go new file mode 100644 index 0000000..c408a41 --- /dev/null +++ b/internal/go/core/dnsprobe/metrics_test.go @@ -0,0 +1,67 @@ +package dnsprobe + +import ( + "context" + "testing" + + "github.com/miekg/dns" + "github.com/prometheus/client_golang/prometheus" +) + +func TestWireMetricsAttributeStaticProvider(t *testing.T) { + server := localDNS(t, func(w dns.ResponseWriter, request *dns.Msg) { + reply := new(dns.Msg) + reply.SetReply(request) + _ = w.WriteMsg(reply) + }) + for _, provider := range []string{"metric-test-external", "metric-test-hosted"} { + t.Cleanup(func() { + probes.DeleteLabelValues(provider, "observed", "A") + probes.DeleteLabelValues(provider, "observed", "AAAA") + duration.DeleteLabelValues(provider, "observed") + queueDelay.DeleteLabelValues(provider) + }) + resolver, err := NewWireResolver(WireOptions{Provider: provider}) + if err != nil { + t.Fatal(err) + } + if _, err := resolver.Probe(context.Background(), ProbeRequest{Name: "www.example.test", Policy: Policy{Mode: ModeRecursive, Servers: []string{server}}}); err != nil { + t.Fatal(err) + } + } + registry := prometheus.NewRegistry() + registry.MustRegister(probes, duration, queueDelay) + families, err := registry.Gather() + if err != nil { + t.Fatal(err) + } + counts := map[string]float64{} + for _, family := range families { + switch family.GetName() { + case "dns_probe_total", "dns_probe_duration_seconds", "dns_probe_queue_delay_seconds": + default: + t.Fatalf("provider-specific metric leaked into transport: %s", family.GetName()) + } + for _, metric := range family.Metric { + provider := "" + for _, label := range metric.Label { + switch label.GetName() { + case "provider": + provider = label.GetValue() + case "outcome", "qtype": + default: + t.Fatalf("unexpected metric label: %s", label.GetName()) + } + } + if provider == "" { + t.Fatal("transport metric lacks provider attribution") + } + if family.GetName() == "dns_probe_total" { + counts[provider] += metric.GetCounter().GetValue() + } + } + } + if counts["metric-test-external"] != 2 || counts["metric-test-hosted"] != 2 { + t.Fatalf("queries attributed incorrectly: %v", counts) + } +} diff --git a/internal/go/core/dnsprobe/policy.go b/internal/go/core/dnsprobe/policy.go new file mode 100644 index 0000000..d11370d --- /dev/null +++ b/internal/go/core/dnsprobe/policy.go @@ -0,0 +1,49 @@ +package dnsprobe + +import ( + "fmt" + "net/netip" + "regexp" + "slices" + "strings" +) + +const ( + ModeCluster = "Cluster" + ModeRecursive = "Recursive" +) + +// Policy is a provider-independent description of required DNS observations. +// Servers contains resolved canonical IP:port endpoints, never fallback views. +type Policy struct { + Mode string `json:"mode,omitempty"` + Servers []string `json:"servers,omitempty"` + ExpectedNameservers []string `json:"expectedNameservers,omitempty"` +} + +// ValuesPreview is bounded diagnostic data, never input to an evidence decision. +type ValuesPreview struct { + Values []string `json:"values,omitempty"` + Total int32 `json:"total"` + Truncated bool `json:"truncated"` +} + +var namePattern = regexp.MustCompile(`^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?(\.[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?)*$`) + +func ValidName(name string) bool { + _, ipErr := netip.ParseAddr(name) + return len(name) > 0 && len(name) <= 253 && namePattern.MatchString(name) && ipErr != nil +} + +func ValidateServers(servers []string) error { + if len(servers) == 0 || len(servers) > 3 { + return fmt.Errorf("resolver.servers must contain 1 through 3 endpoints") + } + for i, server := range servers { + address, err := netip.ParseAddrPort(server) + if err != nil || address.Port() == 0 || address.Addr().Zone() != "" || address.String() != server || strings.Contains(server, "%") || slices.Contains(servers[:i], server) { + return fmt.Errorf("resolver.servers must contain distinct canonical IP addresses and ports from 1 through 65535") + } + } + return nil +} diff --git a/internal/go/providers/external/probe/types.go b/internal/go/core/dnsprobe/types.go similarity index 90% rename from internal/go/providers/external/probe/types.go rename to internal/go/core/dnsprobe/types.go index 440a2b2..4dd789e 100644 --- a/internal/go/providers/external/probe/types.go +++ b/internal/go/core/dnsprobe/types.go @@ -1,5 +1,5 @@ -// Package probe observes DNS using QUERY only. It has no DNS mutation interface. -package probe +// Package dnsprobe observes DNS using QUERY only. It has no DNS mutation interface. +package dnsprobe import ( "context" @@ -10,8 +10,6 @@ import ( "slices" "strings" "time" - - externalv1alpha1 "github.com/appthrust/dns-api/pkg/go/api/external/v1alpha1" ) type Resolver interface { @@ -28,7 +26,7 @@ type ProbeRequest struct { Name string Zone string Expected RRsets - Policy externalv1alpha1.ExternalZoneClassParameters + Policy Policy // DueTime lets the wire resolver measure scheduling plus limiter queue delay once. DueTime time.Time // ZoneOnly collects apex/NS-host work once for this Zone's probe cycle. @@ -107,7 +105,7 @@ func Fingerprint(value any) string { return hex.EncodeToString(hash[:]) } -func minTTL(current *uint32, next *uint32) *uint32 { +func MinTTL(current *uint32, next *uint32) *uint32 { if next == nil { return current } diff --git a/internal/go/providers/external/probe/wire.go b/internal/go/core/dnsprobe/wire.go similarity index 82% rename from internal/go/providers/external/probe/wire.go rename to internal/go/core/dnsprobe/wire.go index abc1288..306f05a 100644 --- a/internal/go/providers/external/probe/wire.go +++ b/internal/go/core/dnsprobe/wire.go @@ -1,4 +1,4 @@ -package probe +package dnsprobe import ( "context" @@ -11,8 +11,6 @@ import ( "strings" "time" - "github.com/appthrust/dns-api/internal/go/providers/external/config" - externalv1alpha1 "github.com/appthrust/dns-api/pkg/go/api/external/v1alpha1" "github.com/miekg/dns" "golang.org/x/time/rate" ) @@ -30,12 +28,15 @@ type WireOptions struct { QPS float64 Burst int ResolvConf string + // Provider is the controller's static metric attribution, never a zone name. + Provider string } type WireResolver struct { limiter *rate.Limiter slots chan struct{} resolvConf string + provider string } func NewWireResolver(options WireOptions) (*WireResolver, error) { @@ -46,45 +47,48 @@ func NewWireResolver(options WireOptions) (*WireResolver, error) { options.Burst = DefaultBurst } if options.QPS <= 0 || math.IsNaN(options.QPS) || math.IsInf(options.QPS, 0) || options.Burst < 1 { - return nil, fmt.Errorf("external DNS QPS and burst must be positive and finite") + return nil, fmt.Errorf("DNS QPS and burst must be positive and finite") } if options.ResolvConf == "" { options.ResolvConf = "/etc/resolv.conf" } - return &WireResolver{limiter: rate.NewLimiter(rate.Limit(options.QPS), options.Burst), slots: make(chan struct{}, MaxConcurrentNames), resolvConf: options.ResolvConf}, nil + if options.Provider == "" { + options.Provider = "unspecified" + } + return &WireResolver{limiter: rate.NewLimiter(rate.Limit(options.QPS), options.Burst), slots: make(chan struct{}, MaxConcurrentNames), resolvConf: options.ResolvConf, provider: options.Provider}, nil } // EffectivePolicy rereads resolv.conf each reconcile so changes invalidate receipts. -func (r *WireResolver) EffectivePolicy(policy externalv1alpha1.ExternalZoneClassParameters) (externalv1alpha1.ExternalZoneClassParameters, error) { - policy.Resolver.Servers = slices.Clone(policy.Resolver.Servers) - if policy.Resolver.Mode == "" { - policy.Resolver.Mode = externalv1alpha1.ResolverModeCluster +func (r *WireResolver) EffectivePolicy(policy Policy) (Policy, error) { + policy.Servers = slices.Clone(policy.Servers) + if policy.Mode == "" { + policy.Mode = ModeCluster } - if policy.Resolver.Mode == externalv1alpha1.ResolverModeCluster { + if policy.Mode == ModeCluster { conf, err := dns.ClientConfigFromFile(r.resolvConf) if err != nil { return policy, errors.New("resolver-config") } - policy.Resolver.Servers = nil + policy.Servers = nil for _, host := range conf.Servers { ip, err := netip.ParseAddr(host) if err != nil || ip.Zone() != "" { return policy, errors.New("resolver-config") } - policy.Resolver.Servers = append(policy.Resolver.Servers, net.JoinHostPort(ip.String(), conf.Port)) + policy.Servers = append(policy.Servers, net.JoinHostPort(ip.String(), conf.Port)) } - slices.Sort(policy.Resolver.Servers) - policy.Resolver.Servers = slices.Compact(policy.Resolver.Servers) + slices.Sort(policy.Servers) + policy.Servers = slices.Compact(policy.Servers) } - if config.ValidateServers(policy.Resolver.Servers) != nil { + if ValidateServers(policy.Servers) != nil { return policy, errors.New("resolver-config") } - slices.Sort(policy.Resolver.Servers) + slices.Sort(policy.Servers) return policy, nil } func (r *WireResolver) Probe(ctx context.Context, request ProbeRequest) (ProbeResult, error) { - if config.ValidateServers(request.Policy.Resolver.Servers) != nil { + if ValidateServers(request.Policy.Servers) != nil { return ProbeResult{}, errors.New("resolver-config") } queued := time.Now() @@ -101,7 +105,7 @@ func (r *WireResolver) Probe(ctx context.Context, request ProbeRequest) (ProbeRe if err := r.limiter.Wait(ctx); err != nil { return ProbeResult{}, err } - ObserveQueueDelay(time.Since(queued)) + observeQueueDelay(r.provider, time.Since(queued)) ctx, cancel := context.WithTimeout(ctx, NameTimeout) defer cancel() start := time.Now() @@ -110,7 +114,7 @@ func (r *WireResolver) Probe(ctx context.Context, request ProbeRequest) (ProbeRe if request.ZoneOnly { result.Zone = session.zone(ctx, request) } else { - for _, server := range request.Policy.Resolver.Servers { + for _, server := range request.Policy.Servers { result.Views = append(result.Views, session.resolve(ctx, request.Name, server, "", request.Zone)) } if len(request.Policy.ExpectedNameservers) != 0 { @@ -118,7 +122,7 @@ func (r *WireResolver) Probe(ctx context.Context, request ProbeRequest) (ProbeRe return ProbeResult{}, errors.New("invalid-response") } for _, authority := range request.Evidence.Authorities { - view := session.resolve(ctx, request.Name, request.Policy.Resolver.Servers[0], authority, request.Zone) + view := session.resolve(ctx, request.Name, request.Policy.Servers[0], authority, request.Zone) result.Views = append(result.Views, view) foreign := request.WithdrawalAddress != "" for _, target := range view.Chain { @@ -128,24 +132,18 @@ func (r *WireResolver) Probe(ctx context.Context, request ProbeRequest) (ProbeRe } } if foreign { - for _, server := range request.Policy.Resolver.Servers[1:] { + for _, server := range request.Policy.Servers[1:] { result.Views = append(result.Views, session.resolve(ctx, request.Name, server, authority, request.Zone)) } } } } } - decision := Evaluate(request, result) - if request.ZoneOnly { - decision = EvaluateZone(request, result.Zone) - } else if request.WithdrawalAddress != "" { - decision = EvaluateWithdrawal(request, result).Decision - } - outcome := decision.Outcome() + outcome := observationOutcome(result) for qtype, count := range session.qtypes { - probes.WithLabelValues(outcome, dns.TypeToString[qtype]).Add(float64(count)) + probes.WithLabelValues(r.provider, outcome, dns.TypeToString[qtype]).Add(float64(count)) } - duration.WithLabelValues(outcome).Observe(time.Since(start).Seconds()) + duration.WithLabelValues(r.provider, outcome).Observe(time.Since(start).Seconds()) return result, nil } @@ -267,10 +265,10 @@ func (s *wireSession) resolve(ctx context.Context, name, recursive, authority, z default: continue } - view.TTL = minTTL(view.TTL, &ttl) + view.TTL = MinTTL(view.TTL, &ttl) } if reply.Rcode == dns.RcodeNameError || ownerAnswers == 0 { - view.NegativeTTL = minTTL(view.NegativeTTL, negativeTTL(reply)) + view.NegativeTTL = MinTTL(view.NegativeTTL, negativeTTL(reply)) } } if rcodes[0] != rcodes[1] { @@ -302,7 +300,7 @@ func negativeTTL(reply *dns.Msg) *uint32 { for _, rr := range reply.Ns { if soa, ok := rr.(*dns.SOA); ok { value := min(soa.Hdr.Ttl, soa.Minttl) - ttl = minTTL(ttl, &value) + ttl = MinTTL(ttl, &value) } } return ttl @@ -324,7 +322,7 @@ func (s *wireSession) apex(ctx context.Context, server, zone string, rd bool) Au return view } view.Authoritative = reply.Authoritative - view.NegativeTTL = minTTL(view.NegativeTTL, negativeTTL(reply)) + view.NegativeTTL = MinTTL(view.NegativeTTL, negativeTTL(reply)) for _, rr := range reply.Answer { if CanonicalName(rr.Header().Name) != zone { continue @@ -338,7 +336,7 @@ func (s *wireSession) apex(ctx context.Context, server, zone string, rd bool) Au default: continue } - view.TTL = minTTL(view.TTL, &ttl) + view.TTL = MinTTL(view.TTL, &ttl) } } view.Nameservers = Names(view.Nameservers) @@ -350,7 +348,7 @@ func (s *wireSession) zone(ctx context.Context, request ProbeRequest) *ZoneEvide if len(request.Policy.ExpectedNameservers) == 0 { return evidence } - for _, server := range request.Policy.Resolver.Servers { + for _, server := range request.Policy.Servers { evidence.Views = append(evidence.Views, s.apex(ctx, server, request.Zone, true)) } // A wrong delegation is completed evidence; never send to unverified authority destinations. @@ -359,7 +357,7 @@ func (s *wireSession) zone(ctx context.Context, request ProbeRequest) *ZoneEvide } for _, host := range request.Policy.ExpectedNameservers { var addresses []string - for viewIndex, server := range request.Policy.Resolver.Servers { + for viewIndex, server := range request.Policy.Servers { view := s.resolve(ctx, host, server, "", "") if view.Failure != "" || view.Mismatch || view.RCode != dns.RcodeSuccess || len(view.A)+len(view.AAAA) == 0 { failure := view.Failure @@ -369,7 +367,7 @@ func (s *wireSession) zone(ctx context.Context, request ProbeRequest) *ZoneEvide evidence.Views = append(evidence.Views, AuthorityObservation{ID: view.ID, Failure: failure}) return evidence } - evidence.Views[viewIndex].TTL = minTTL(evidence.Views[viewIndex].TTL, view.TTL) + evidence.Views[viewIndex].TTL = MinTTL(evidence.Views[viewIndex].TTL, view.TTL) addresses = append(addresses, view.A...) addresses = append(addresses, view.AAAA...) } diff --git a/internal/go/providers/external/probe/wire_test.go b/internal/go/core/dnsprobe/wire_test.go similarity index 81% rename from internal/go/providers/external/probe/wire_test.go rename to internal/go/core/dnsprobe/wire_test.go index 0bdc92e..6710cc9 100644 --- a/internal/go/providers/external/probe/wire_test.go +++ b/internal/go/core/dnsprobe/wire_test.go @@ -1,4 +1,4 @@ -package probe +package dnsprobe import ( "context" @@ -10,7 +10,6 @@ import ( "testing" "time" - externalv1alpha1 "github.com/appthrust/dns-api/pkg/go/api/external/v1alpha1" "github.com/miekg/dns" ) @@ -63,14 +62,13 @@ func TestWireTruncationAndNegativeSOA(t *testing.T) { if err != nil { t.Fatal(err) } - request := ProbeRequest{Name: "absent.example.test", Policy: externalv1alpha1.ExternalZoneClassParameters{Resolver: externalv1alpha1.ResolverPolicy{Mode: externalv1alpha1.ResolverModeRecursive, Servers: []string{server}}}, Expected: RRsets{A: []string{"192.0.2.1"}}} + request := ProbeRequest{Name: "absent.example.test", Policy: Policy{Mode: ModeRecursive, Servers: []string{server}}} result, err := resolver.Probe(context.Background(), request) if err != nil { t.Fatal(err) } - decision := Evaluate(request, result) - if decision.Reason != "DNSNameNotFound" || decision.NegativeTTL == nil || *decision.NegativeTTL != 120 { - t.Fatalf("decision = %#v", decision) + if len(result.Views) != 1 || result.Views[0].RCode != dns.RcodeNameError || result.Views[0].NegativeTTL == nil || *result.Views[0].NegativeTTL != 120 || len(result.Views[0].Answers) != 2 { + t.Fatalf("result = %#v", result) } if udpQueries.Load() != 2 || tcpQueries.Load() != 2 { t.Fatalf("UDP=%d TCP=%d", udpQueries.Load(), tcpQueries.Load()) @@ -114,7 +112,7 @@ func TestWireNoFallbackAndCancellation(t *testing.T) { _ = w.WriteMsg(reply) }) resolver, _ := NewWireResolver(WireOptions{}) - request := ProbeRequest{Name: "www.example.test", Policy: externalv1alpha1.ExternalZoneClassParameters{Resolver: externalv1alpha1.ResolverPolicy{Servers: []string{server}}}} + request := ProbeRequest{Name: "www.example.test", Policy: Policy{Servers: []string{server}}} result, err := resolver.Probe(context.Background(), request) if err != nil { t.Fatal(err) @@ -138,21 +136,21 @@ func TestClusterPolicyRereadAndBounds(t *testing.T) { t.Fatal(err) } resolver, _ := NewWireResolver(WireOptions{ResolvConf: path}) - first, err := resolver.EffectivePolicy(externalv1alpha1.ExternalZoneClassParameters{}) - if err != nil || len(first.Resolver.Servers) != 1 || first.Resolver.Servers[0] != "192.0.2.53:53" { + first, err := resolver.EffectivePolicy(Policy{}) + if err != nil || len(first.Servers) != 1 || first.Servers[0] != "192.0.2.53:53" { t.Fatalf("policy=%#v error=%v", first, err) } if err := os.WriteFile(path, []byte("nameserver 192.0.2.54\n"), 0600); err != nil { t.Fatal(err) } - second, err := resolver.EffectivePolicy(externalv1alpha1.ExternalZoneClassParameters{}) + second, err := resolver.EffectivePolicy(Policy{}) if err != nil || Fingerprint(first) == Fingerprint(second) { t.Fatal("resolv.conf change did not invalidate policy") } if err := os.WriteFile(path, []byte("nameserver 192.0.2.1\nnameserver 192.0.2.2\nnameserver 192.0.2.3\nnameserver 192.0.2.4\n"), 0600); err != nil { t.Fatal(err) } - if _, err := resolver.EffectivePolicy(externalv1alpha1.ExternalZoneClassParameters{}); err == nil { + if _, err := resolver.EffectivePolicy(Policy{}); err == nil { t.Fatal("oversized resolver set accepted") } } diff --git a/internal/go/providers/external/probe/withdrawal.go b/internal/go/core/dnsprobe/withdrawal.go similarity index 94% rename from internal/go/providers/external/probe/withdrawal.go rename to internal/go/core/dnsprobe/withdrawal.go index 8ddf88d..2ed39bb 100644 --- a/internal/go/providers/external/probe/withdrawal.go +++ b/internal/go/core/dnsprobe/withdrawal.go @@ -1,4 +1,4 @@ -package probe +package dnsprobe import ( "fmt" @@ -6,7 +6,6 @@ import ( "slices" "strings" - "github.com/appthrust/dns-api/internal/go/providers/external/config" "github.com/miekg/dns" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" ) @@ -45,7 +44,7 @@ func WithdrawalFailed(name, view, failure string) WithdrawalDecision { default: failure = "transport" } - return WithdrawalDecision{Decision: Decision{Status: metav1.ConditionUnknown, Reason: "DNSProbeFailed", Message: bounded(fmt.Sprintf("External DNS withdrawal cannot be verified for %s in %s: %s.", name, view, failure))}} + return WithdrawalDecision{Decision: Decision{Status: metav1.ConditionUnknown, Reason: "DNSProbeFailed", Message: Bounded(fmt.Sprintf("DNS withdrawal cannot be verified for %s in %s: %s.", name, view, failure))}} } // EvaluateWithdrawal proves only current exclusion. The reconciler additionally @@ -62,10 +61,10 @@ func EvaluateWithdrawal(request ProbeRequest, result ProbeResult) WithdrawalDeci decision.Cache = positiveWithdrawalEvidence(result, address) return decision } - if len(request.Policy.Resolver.Servers) == 0 || len(result.Views) == 0 { + if len(request.Policy.Servers) == 0 || len(result.Views) == 0 { return fail("required-views", "invalid-response") } - for _, server := range request.Policy.Resolver.Servers { + for _, server := range request.Policy.Servers { if !slices.ContainsFunc(result.Views, func(v View) bool { return v.ID == "recursive/"+server }) { return fail("recursive/"+server, "invalid-response") } @@ -76,7 +75,7 @@ func EvaluateWithdrawal(request ProbeRequest, result ProbeResult) WithdrawalDeci } for _, authority := range request.Evidence.Authorities { prefix := "authority/" + authority + "/via/" - for _, server := range request.Policy.Resolver.Servers { + for _, server := range request.Policy.Servers { if !slices.ContainsFunc(result.Views, func(v View) bool { return v.ID == prefix+server && v.Authoritative }) { return fail(prefix+server, "invalid-response") } @@ -101,7 +100,7 @@ func EvaluateWithdrawal(request ProbeRequest, result ProbeResult) WithdrawalDeci } if qtype != "" && decision.Status == metav1.ConditionTrue { decision.Status, decision.Reason = metav1.ConditionFalse, "DNSStillResolves" - decision.Message = bounded(fmt.Sprintf("DNS %s still resolves to retired address %s in %s (%s).", request.Name, address, view.ID, qtype)) + decision.Message = Bounded(fmt.Sprintf("DNS %s still resolves to retired address %s in %s (%s).", request.Name, address, view.ID, qtype)) } } return decision @@ -127,7 +126,7 @@ func withdrawalViewEvidence(name string, address netip.Addr, view View) ([]Withd owners := append([]string{CanonicalName(name)}, namesInOrder(view.Chain)...) seen := map[string]bool{} for _, owner := range owners { - if seen[owner] || !config.ValidName(owner) { + if seen[owner] || !ValidName(owner) { return nil, "", "invalid-response" } seen[owner] = true @@ -313,7 +312,7 @@ func answerEvidence(reply *dns.Msg, name string, qtype uint16) AnswerEvidence { continue } owner := CanonicalName(soa.Hdr.Name) - if (owner != "" && owner != name && !strings.HasSuffix(name, "."+owner)) || soa.Hdr.Class != dns.ClassINET || !config.ValidName(CanonicalName(soa.Ns)) || !config.ValidName(CanonicalName(soa.Mbox)) { + if (owner != "" && owner != name && !strings.HasSuffix(name, "."+owner)) || soa.Hdr.Class != dns.ClassINET || !ValidName(CanonicalName(soa.Ns)) || !ValidName(CanonicalName(soa.Mbox)) { answer.Failure = "invalid-response" continue } diff --git a/internal/go/providers/external/probe/withdrawal_test.go b/internal/go/core/dnsprobe/withdrawal_test.go similarity index 85% rename from internal/go/providers/external/probe/withdrawal_test.go rename to internal/go/core/dnsprobe/withdrawal_test.go index ecbe8d0..e280d0e 100644 --- a/internal/go/providers/external/probe/withdrawal_test.go +++ b/internal/go/core/dnsprobe/withdrawal_test.go @@ -1,4 +1,4 @@ -package probe +package dnsprobe import ( "fmt" @@ -6,7 +6,6 @@ import ( "slices" "testing" - externalv1alpha1 "github.com/appthrust/dns-api/pkg/go/api/external/v1alpha1" "github.com/miekg/dns" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" ) @@ -19,7 +18,7 @@ func withdrawalView(id string, a, aaaa []string, ttl uint32) View { } func TestEvaluateWithdrawal(t *testing.T) { - request := ProbeRequest{Name: "www.example.test", Zone: "example.test", WithdrawalAddress: "192.0.2.1", Policy: externalv1alpha1.ExternalZoneClassParameters{Resolver: externalv1alpha1.ResolverPolicy{Servers: []string{"192.0.2.53:53", "192.0.2.54:53"}}}} + request := ProbeRequest{Name: "www.example.test", Zone: "example.test", WithdrawalAddress: "192.0.2.1", Policy: Policy{Servers: []string{"192.0.2.53:53", "192.0.2.54:53"}}} cases := []struct { name string mutate func(*ProbeRequest, *[]View) @@ -75,7 +74,7 @@ func TestEvaluateWithdrawal(t *testing.T) { } func TestWithdrawalEvidenceOrderStable(t *testing.T) { - r := ProbeRequest{Name: "www.example.test", WithdrawalAddress: "192.0.2.1", Policy: externalv1alpha1.ExternalZoneClassParameters{Resolver: externalv1alpha1.ResolverPolicy{Servers: []string{"a", "b"}}}} + r := ProbeRequest{Name: "www.example.test", WithdrawalAddress: "192.0.2.1", Policy: Policy{Servers: []string{"a", "b"}}} result := ProbeResult{Views: []View{withdrawalView("recursive/a", nil, nil, 0), withdrawalView("recursive/b", []string{"192.0.2.3", "192.0.2.2"}, nil, 60)}} first := EvaluateWithdrawal(r, result) slices.Reverse(result.Views) @@ -90,7 +89,7 @@ func TestWithdrawalEvidenceOrderStable(t *testing.T) { func TestWithdrawalAuthorityAndChainEvidence(t *testing.T) { request := ProbeRequest{Name: "www.example.test", Zone: "example.test", WithdrawalAddress: "192.0.2.1", - Policy: externalv1alpha1.ExternalZoneClassParameters{Resolver: externalv1alpha1.ResolverPolicy{Servers: []string{"recursive"}}, ExpectedNameservers: []string{"ns.example.test"}}} + Policy: Policy{Servers: []string{"recursive"}, ExpectedNameservers: []string{"ns.example.test"}}} request.Evidence = &ZoneEvidence{Zone: request.Zone, PolicyFingerprint: Fingerprint(request.Policy), Authorities: []string{"192.0.2.53:53"}, Views: []AuthorityObservation{ {ID: "recursive/recursive", Nameservers: []string{"ns.example.test"}, SOAOwner: request.Zone}, {ID: "authority/192.0.2.53:53", Nameservers: []string{"ns.example.test"}, SOAOwner: request.Zone, Authoritative: true}, @@ -162,29 +161,10 @@ func TestWithdrawalQueryCacheEvidence(t *testing.T) { } func TestWithdrawalFailureRetainsLaterPositiveHorizon(t *testing.T) { - request := ProbeRequest{Name: "www.example.test", WithdrawalAddress: "192.0.2.1", Policy: externalv1alpha1.ExternalZoneClassParameters{Resolver: externalv1alpha1.ResolverPolicy{Servers: []string{"a", "b"}}}} + request := ProbeRequest{Name: "www.example.test", WithdrawalAddress: "192.0.2.1", Policy: Policy{Servers: []string{"a", "b"}}} result := ProbeResult{Views: []View{{ID: "recursive/a", Failure: "timeout"}, withdrawalView("recursive/b", []string{"192.0.2.1"}, nil, 3600)}} decision := EvaluateWithdrawal(request, result) if decision.Status != metav1.ConditionUnknown || len(decision.Cache) == 0 || decision.Cache[0].PositiveTTL == nil || *decision.Cache[0].PositiveTTL != 3600 { t.Fatalf("lost positive evidence=%#v", decision) } } - -func TestWithdrawalPendingMetricClosedReasons(t *testing.T) { - const unit = "test-withdrawal-metric" - t.Cleanup(func() { SetWithdrawalsPending(unit, nil) }) - SetWithdrawalsPending(unit, map[string]int{"private/unbounded": 1, "DNSStillResolves": 2}) - pendingMu.Lock() - counts := withdrawalUnits[unit] - if len(counts) != 2 || counts["DNSProbeFailed"] != 1 || counts["DNSStillResolves"] != 2 { - t.Errorf("unbounded reason labels: %#v", counts) - } - pendingMu.Unlock() - SetWithdrawalsPending(unit, nil) - pendingMu.Lock() - _, exists := withdrawalUnits[unit] - pendingMu.Unlock() - if exists { - t.Fatal("deleted unit retained pending metrics") - } -} diff --git a/internal/go/providers/external/config/config.go b/internal/go/providers/external/config/config.go index 531a98e..65cc166 100644 --- a/internal/go/providers/external/config/config.go +++ b/internal/go/providers/external/config/config.go @@ -5,24 +5,15 @@ import ( "bytes" "encoding/json" "fmt" - "net/netip" - "regexp" "slices" - "strings" + "github.com/appthrust/dns-api/internal/go/core/dnsprobe" "github.com/appthrust/dns-api/internal/go/core/providercontract" dnsv1alpha1 "github.com/appthrust/dns-api/pkg/go/api/dns/v1alpha1" externalv1alpha1 "github.com/appthrust/dns-api/pkg/go/api/external/v1alpha1" "k8s.io/apimachinery/pkg/runtime" ) -var namePattern = regexp.MustCompile(`^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?(\.[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?)*$`) - -func ValidName(name string) bool { - _, ipErr := netip.ParseAddr(name) - return len(name) > 0 && len(name) <= 253 && namePattern.MatchString(name) && ipErr != nil -} - func Binding(name, version string) dnsv1alpha1.ProviderReference { if name == "" { name = externalv1alpha1.ProviderName @@ -72,7 +63,7 @@ func Decode(raw runtime.RawExtension) (externalv1alpha1.ExternalZoneClassParamet return params, fmt.Errorf("resolver.servers must be empty for Cluster") } case externalv1alpha1.ResolverModeRecursive: - if err := ValidateServers(params.Resolver.Servers); err != nil { + if err := dnsprobe.ValidateServers(params.Resolver.Servers); err != nil { return params, err } default: @@ -82,7 +73,7 @@ func Decode(raw runtime.RawExtension) (externalv1alpha1.ExternalZoneClassParamet return params, fmt.Errorf("expectedNameservers must contain at most 16 names") } for i, name := range params.ExpectedNameservers { - if !ValidName(name) || slices.Contains(params.ExpectedNameservers[:i], name) { + if !dnsprobe.ValidName(name) || slices.Contains(params.ExpectedNameservers[:i], name) { return params, fmt.Errorf("expectedNameservers must contain distinct canonical DNS names") } } @@ -91,15 +82,7 @@ func Decode(raw runtime.RawExtension) (externalv1alpha1.ExternalZoneClassParamet return params, nil } -func ValidateServers(servers []string) error { - if len(servers) == 0 || len(servers) > 3 { - return fmt.Errorf("resolver.servers must contain 1 through 3 endpoints") - } - for i, server := range servers { - address, err := netip.ParseAddrPort(server) - if err != nil || address.Port() == 0 || address.Addr().Zone() != "" || address.String() != server || strings.Contains(server, "%") || slices.Contains(servers[:i], server) { - return fmt.Errorf("resolver.servers must contain distinct canonical IP addresses and ports from 1 through 65535") - } - } - return nil +// ProbePolicy converts provider API parameters at the provider boundary. +func ProbePolicy(params externalv1alpha1.ExternalZoneClassParameters) dnsprobe.Policy { + return dnsprobe.Policy{Mode: string(params.Resolver.Mode), Servers: params.Resolver.Servers, ExpectedNameservers: params.ExpectedNameservers} } diff --git a/internal/go/providers/external/controllers/zoneunit/controller.go b/internal/go/providers/external/controllers/zoneunit/controller.go index b3bfc9b..21c75f7 100644 --- a/internal/go/providers/external/controllers/zoneunit/controller.go +++ b/internal/go/providers/external/controllers/zoneunit/controller.go @@ -9,11 +9,11 @@ import ( "time" "github.com/appthrust/dns-api/internal/go/core/declaration" + probe "github.com/appthrust/dns-api/internal/go/core/dnsprobe" "github.com/appthrust/dns-api/internal/go/core/providercontract" "github.com/appthrust/dns-api/internal/go/providers/external/config" - "github.com/appthrust/dns-api/internal/go/providers/external/probe" + "github.com/appthrust/dns-api/internal/go/providers/external/verification" dnsv1alpha1 "github.com/appthrust/dns-api/pkg/go/api/dns/v1alpha1" - externalv1alpha1 "github.com/appthrust/dns-api/pkg/go/api/external/v1alpha1" corev1 "k8s.io/api/core/v1" "k8s.io/apimachinery/pkg/api/equality" apierrors "k8s.io/apimachinery/pkg/api/errors" @@ -35,7 +35,7 @@ type Options struct { // Jitter returns a value in [-1,1], scaled to +/-10 percent. Jitter func() float64 // EffectivePolicy resolves pod DNS without using the OS DNS lookup fallback. - EffectivePolicy func(externalv1alpha1.ExternalZoneClassParameters) (externalv1alpha1.ExternalZoneClassParameters, error) + EffectivePolicy func(probe.Policy) (probe.Policy, error) } type ZoneReconciler struct { @@ -47,7 +47,7 @@ type ZoneReconciler struct { func NewReconciler(k8sClient client.Client, options Options) (*ZoneReconciler, error) { if options.Resolver == nil { - wire, err := probe.NewWireResolver(probe.WireOptions{QPS: options.QPS, Burst: options.Burst}) + wire, err := probe.NewWireResolver(probe.WireOptions{QPS: options.QPS, Burst: options.Burst, Provider: "external"}) if err != nil { return nil, err } @@ -68,7 +68,7 @@ func NewReconciler(k8sClient client.Client, options Options) (*ZoneReconciler, e type environment struct { class dnsv1alpha1.ZoneClass provider dnsv1alpha1.Provider - policy externalv1alpha1.ExternalZoneClassParameters + policy probe.Policy fingerprint string policyError error } @@ -127,9 +127,9 @@ func (r *ZoneReconciler) observe(ctx context.Context, c client.Client, unit *dns if !emptyObject(unit.Spec.Zone.Adoption) { return nil, &acceptance{metav1.ConditionFalse, "InvalidProviderPayload", "external DNS does not support adoption"}, nil } - env.policy = params + env.policy = config.ProbePolicy(params) if r.Options.EffectivePolicy != nil { - env.policy, env.policyError = r.Options.EffectivePolicy(params) + env.policy, env.policyError = r.Options.EffectivePolicy(env.policy) } namespaceNames := []string{unit.Spec.Zone.Ref.Namespace} for _, item := range unit.Spec.RecordSets { @@ -155,7 +155,7 @@ func (r *ZoneReconciler) observe(ctx context.Context, c client.Client, unit *dns ProviderSpec dnsv1alpha1.ProviderSpec UnitUID string Zone dnsv1alpha1.ZoneUnitZoneSpec - Policy externalv1alpha1.ExternalZoneClassParameters + Policy probe.Policy NamespacePolicies map[string]map[string]string }{string(env.class.UID), env.class.Spec, string(env.provider.UID), env.provider.Spec, string(unit.UID), unit.Spec.Zone, env.policy, namespacePolicies}) return env, nil, nil @@ -165,8 +165,8 @@ func (r *ZoneReconciler) Reconcile(ctx context.Context, request ctrl.Request) (c var unit dnsv1alpha1.ZoneUnit if err := r.Get(ctx, request.NamespacedName, &unit); err != nil { if apierrors.IsNotFound(err) { - probe.SetPending(request.NamespacedName.String(), nil) - probe.SetWithdrawalsPending(request.NamespacedName.String(), nil) + verification.SetPending(request.NamespacedName.String(), nil) + verification.SetWithdrawalsPending(request.NamespacedName.String(), nil) } return ctrl.Result{}, client.IgnoreNotFound(err) } @@ -187,9 +187,9 @@ func (r *ZoneReconciler) Reconcile(ctx context.Context, request ctrl.Request) (c for i := range unit.Status.RecordSets { unit.Status.RecordSets[i].DeletionCompleted = unit.Spec.RecordSets[i].RecordSetUID != "" } - probe.SetPending(request.NamespacedName.String(), nil) + verification.SetPending(request.NamespacedName.String(), nil) if len(unit.Spec.Withdrawals) == 0 { - probe.SetWithdrawalsPending(request.NamespacedName.String(), nil) + verification.SetWithdrawalsPending(request.NamespacedName.String(), nil) return ctrl.Result{}, r.patch(ctx, base, &unit) } } @@ -280,7 +280,7 @@ func (r *ZoneReconciler) Reconcile(ctx context.Context, request ctrl.Request) (c } else if zoneDecision.Status == metav1.ConditionTrue { probeRequest := probe.ProbeRequest{Name: group.CheckedName, Zone: unit.Spec.Zone.DomainName, Expected: group.Expected, Policy: env.policy, Evidence: evidence, DueTime: dueTime} observed, probeErr := r.Options.Resolver.Probe(ctx, probeRequest) - decision = probe.Evaluate(probeRequest, observed) + decision = verification.Evaluate(probeRequest, observed) retainPublication(&state, group, probeRequest, observed, r.Options.Clock()) if probeErr != nil { decision = probe.Failed(group.CheckedName, "required-views", failureCode(probeErr)) diff --git a/internal/go/providers/external/controllers/zoneunit/controller_test.go b/internal/go/providers/external/controllers/zoneunit/controller_test.go index 902f845..3b1044a 100644 --- a/internal/go/providers/external/controllers/zoneunit/controller_test.go +++ b/internal/go/providers/external/controllers/zoneunit/controller_test.go @@ -9,8 +9,8 @@ import ( "testing" "time" + probe "github.com/appthrust/dns-api/internal/go/core/dnsprobe" "github.com/appthrust/dns-api/internal/go/providers/external/manifest" - "github.com/appthrust/dns-api/internal/go/providers/external/probe" dnsv1alpha1 "github.com/appthrust/dns-api/pkg/go/api/dns/v1alpha1" externalv1alpha1 "github.com/appthrust/dns-api/pkg/go/api/external/v1alpha1" corev1 "k8s.io/api/core/v1" @@ -304,7 +304,7 @@ func TestAggregateAllowlistAndFalsePrecedence(t *testing.T) { t.Fatalf("mapping=%s/%s", status, mapped) } } - data := publicData(externalv1alpha1.ExternalZoneClassParameters{Resolver: externalv1alpha1.ResolverPolicy{Mode: externalv1alpha1.ResolverModeRecursive}}, "www.example", false, probe.Decision{}, now, now.Add(time.Minute)) + data := publicData(probe.Policy{Mode: probe.ModeRecursive}, "www.example", false, probe.Decision{}, now, now.Add(time.Minute)) var public externalv1alpha1.ExternalVerificationData if err := json.Unmarshal(data.Data.Raw, &public); err != nil { t.Fatal(err) diff --git a/internal/go/providers/external/controllers/zoneunit/model.go b/internal/go/providers/external/controllers/zoneunit/model.go index 12672e0..70b613d 100644 --- a/internal/go/providers/external/controllers/zoneunit/model.go +++ b/internal/go/providers/external/controllers/zoneunit/model.go @@ -6,7 +6,7 @@ import ( "strings" "time" - "github.com/appthrust/dns-api/internal/go/providers/external/probe" + probe "github.com/appthrust/dns-api/internal/go/core/dnsprobe" dnsv1alpha1 "github.com/appthrust/dns-api/pkg/go/api/dns/v1alpha1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" ) diff --git a/internal/go/providers/external/controllers/zoneunit/status.go b/internal/go/providers/external/controllers/zoneunit/status.go index 21f129e..b72cc4f 100644 --- a/internal/go/providers/external/controllers/zoneunit/status.go +++ b/internal/go/providers/external/controllers/zoneunit/status.go @@ -5,7 +5,8 @@ import ( "slices" "time" - "github.com/appthrust/dns-api/internal/go/providers/external/probe" + probe "github.com/appthrust/dns-api/internal/go/core/dnsprobe" + "github.com/appthrust/dns-api/internal/go/providers/external/verification" dnsv1alpha1 "github.com/appthrust/dns-api/pkg/go/api/dns/v1alpha1" externalv1alpha1 "github.com/appthrust/dns-api/pkg/go/api/external/v1alpha1" "k8s.io/apimachinery/pkg/api/meta" @@ -54,11 +55,11 @@ func currentDecision(conditions []metav1.Condition) probe.Decision { return probe.Decision{Status: condition.Status, Reason: condition.Reason, Message: condition.Message} } -func publicData(policy externalv1alpha1.ExternalZoneClassParameters, name string, sampled bool, decision probe.Decision, now, next time.Time) *dnsv1alpha1.ProviderStatus { +func publicData(policy probe.Policy, name string, sampled bool, decision probe.Decision, now, next time.Time) *dnsv1alpha1.ProviderStatus { data := externalv1alpha1.ExternalVerificationData{ - ResolverMode: policy.Resolver.Mode, CheckedName: name, SampledWildcard: sampled, - Actual: probe.Preview(decision.Actual), Missing: probe.Preview(decision.Missing), Extra: probe.Preview(decision.Extra), - CNAMEChain: probe.Preview(decision.Chain), Views: probe.Preview(decision.Views), + ResolverMode: externalv1alpha1.ResolverMode(policy.Mode), CheckedName: name, SampledWildcard: sampled, + Actual: externalv1alpha1.VerificationValues(probe.Preview(decision.Actual)), Missing: externalv1alpha1.VerificationValues(probe.Preview(decision.Missing)), Extra: externalv1alpha1.VerificationValues(probe.Preview(decision.Extra)), + CNAMEChain: externalv1alpha1.VerificationValues(probe.Preview(decision.Chain)), Views: externalv1alpha1.VerificationValues(probe.Preview(decision.Views)), ObservedTTLSeconds: decision.TTL, NegativeTTLSeconds: decision.NegativeTTL, LastProbeTime: timePointer(now), NextProbeTime: timePointer(next), } @@ -71,7 +72,7 @@ func publicData(policy externalv1alpha1.ExternalZoneClassParameters, name string func timePointer(value time.Time) *metav1.Time { result := metav1.NewTime(value); return &result } -func setZoneResult(unit *dnsv1alpha1.ZoneUnit, policy externalv1alpha1.ExternalZoneClassParameters, decision probe.Decision, now, next time.Time) { +func setZoneResult(unit *dnsv1alpha1.ZoneUnit, policy probe.Policy, decision probe.Decision, now, next time.Time) { zone := unit.Status.Zone zone.NameServers = slices.Clone(decision.Nameservers) zone.Provider = publicData(policy, unit.Spec.Zone.DomainName, false, decision, now, next) @@ -79,7 +80,7 @@ func setZoneResult(unit *dnsv1alpha1.ZoneUnit, policy externalv1alpha1.ExternalZ setCondition(&zone.Conditions, "Programmed", decision.Status, decision.Reason, decision.Message, unit.Spec.Zone.ObservedGeneration, now) } -func setGroupResult(unit *dnsv1alpha1.ZoneUnit, group nameGroup, policy externalv1alpha1.ExternalZoneClassParameters, decision probe.Decision, now, next time.Time) { +func setGroupResult(unit *dnsv1alpha1.ZoneUnit, group nameGroup, policy probe.Policy, decision probe.Decision, now, next time.Time) { data := publicData(policy, group.CheckedName, group.SampledWildcard, decision, now, next) for _, index := range group.Items { item := &unit.Status.RecordSets[index] @@ -152,7 +153,7 @@ func (r *ZoneReconciler) updatePending(unit *dnsv1alpha1.ZoneUnit) { counts[decision.Reason]++ } } - probe.SetPending(unit.Namespace+"/"+unit.Name, counts) + verification.SetPending(unit.Namespace+"/"+unit.Name, counts) withdrawals := map[string]int{} for _, status := range unit.Status.Withdrawals { condition := meta.FindStatusCondition(status.Conditions, "Withdrawn") @@ -164,5 +165,5 @@ func (r *ZoneReconciler) updatePending(unit *dnsv1alpha1.ZoneUnit) { withdrawals[condition.Reason]++ } } - probe.SetWithdrawalsPending(unit.Namespace+"/"+unit.Name, withdrawals) + verification.SetWithdrawalsPending(unit.Namespace+"/"+unit.Name, withdrawals) } diff --git a/internal/go/providers/external/controllers/zoneunit/withdrawal.go b/internal/go/providers/external/controllers/zoneunit/withdrawal.go index 2fbff2a..2d4ed2c 100644 --- a/internal/go/providers/external/controllers/zoneunit/withdrawal.go +++ b/internal/go/providers/external/controllers/zoneunit/withdrawal.go @@ -9,7 +9,7 @@ import ( "strings" "time" - "github.com/appthrust/dns-api/internal/go/providers/external/probe" + probe "github.com/appthrust/dns-api/internal/go/core/dnsprobe" dnsv1alpha1 "github.com/appthrust/dns-api/pkg/go/api/dns/v1alpha1" externalv1alpha1 "github.com/appthrust/dns-api/pkg/go/api/external/v1alpha1" "k8s.io/apimachinery/pkg/api/meta" @@ -201,7 +201,7 @@ func (r *ZoneReconciler) probeWithdrawals(ctx context.Context, unit *dnsv1alpha1 setWithdrawalConditions(status, result, now) data, _ := json.Marshal(struct { Views externalv1alpha1.VerificationValues `json:"views"` - }{Views: probe.Preview(views)}) + }{Views: externalv1alpha1.VerificationValues(probe.Preview(views))}) status.Provider = &dnsv1alpha1.ProviderStatus{Data: runtime.RawExtension{Raw: data}} bindWithdrawalWaiver(status, item, now) status.ResultDigest = withdrawalResultDigest(unit, item, *status) @@ -215,6 +215,9 @@ func policyChanged(name string) probe.Decision { } func setWithdrawalConditions(status *dnsv1alpha1.ZoneUnitWithdrawalStatus, decision probe.Decision, now time.Time) { + if strings.HasPrefix(decision.Message, "DNS withdrawal ") { + decision.Message = "External " + decision.Message + } setCondition(&status.Conditions, "Withdrawn", decision.Status, decision.Reason, decision.Message, status.ObservedGeneration, now) ready := metav1.ConditionFalse if decision.Status == metav1.ConditionTrue { diff --git a/internal/go/providers/external/controllers/zoneunit/withdrawal_model.go b/internal/go/providers/external/controllers/zoneunit/withdrawal_model.go index 197ee0d..11c3acc 100644 --- a/internal/go/providers/external/controllers/zoneunit/withdrawal_model.go +++ b/internal/go/providers/external/controllers/zoneunit/withdrawal_model.go @@ -6,23 +6,21 @@ import ( "strings" "time" - "github.com/appthrust/dns-api/internal/go/providers/external/config" - "github.com/appthrust/dns-api/internal/go/providers/external/probe" + probe "github.com/appthrust/dns-api/internal/go/core/dnsprobe" dnsv1alpha1 "github.com/appthrust/dns-api/pkg/go/api/dns/v1alpha1" - externalv1alpha1 "github.com/appthrust/dns-api/pkg/go/api/external/v1alpha1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" ) // The independent claims are the obligation inventory. This private state only // retains cache bounds, policy floors and witnesses for those current claims. type withdrawalState struct { - Identity string `json:"identity"` - Policy string `json:"policy"` - Floor externalv1alpha1.ExternalZoneClassParameters `json:"floor"` - Views []string `json:"views,omitempty"` - CheckedNames []string `json:"checkedNames"` - Positive map[string]time.Time `json:"positive,omitempty"` - Negative map[string]uint32 `json:"negative,omitempty"` + Identity string `json:"identity"` + Policy string `json:"policy"` + Floor probe.Policy `json:"floor"` + Views []string `json:"views,omitempty"` + CheckedNames []string `json:"checkedNames"` + Positive map[string]time.Time `json:"positive,omitempty"` + Negative map[string]uint32 `json:"negative,omitempty"` // Preserve subsecond t0: metav1.Time's wire encoding has only second precision. WindowStart time.Time `json:"windowStart,omitempty"` Schedule schedule `json:"schedule"` @@ -80,7 +78,7 @@ func validWithdrawal(unit *dnsv1alpha1.ZoneUnit, item dnsv1alpha1.ZoneUnitWithdr } else { name += "." + unit.Spec.Zone.DomainName } - return config.ValidName(name) + return probe.ValidName(name) } func cacheKey(checked string, evidence probe.WithdrawalCacheEvidence) string { @@ -157,14 +155,14 @@ func publicationUnexpired(publication publicationEvidence, now time.Time) bool { return false } -func requiredWithdrawalViews(policy externalv1alpha1.ExternalZoneClassParameters, evidence *probe.ZoneEvidence) []string { +func requiredWithdrawalViews(policy probe.Policy, evidence *probe.ZoneEvidence) []string { var views []string - for _, server := range policy.Resolver.Servers { + for _, server := range policy.Servers { views = append(views, "recursive/"+server) } if len(policy.ExpectedNameservers) != 0 && evidence != nil { for _, authority := range evidence.Authorities { - for _, server := range policy.Resolver.Servers { + for _, server := range policy.Servers { views = append(views, "authority/"+authority+"/via/"+server) } } @@ -182,12 +180,12 @@ func containsAll(values, floor []string) bool { return true } -func policyWeakened(state withdrawalState, policy externalv1alpha1.ExternalZoneClassParameters, views []string) bool { - return state.MissingHistory || !containsAll(policy.Resolver.Servers, state.Floor.Resolver.Servers) || !containsAll(policy.ExpectedNameservers, state.Floor.ExpectedNameservers) || !containsAll(views, state.Views) +func policyWeakened(state withdrawalState, policy probe.Policy, views []string) bool { + return state.MissingHistory || !containsAll(policy.Servers, state.Floor.Servers) || !containsAll(policy.ExpectedNameservers, state.Floor.ExpectedNameservers) || !containsAll(views, state.Views) } -func addFloor(state *withdrawalState, policy externalv1alpha1.ExternalZoneClassParameters, views []string) { - state.Floor.Resolver.Servers = sortedUnion(state.Floor.Resolver.Servers, policy.Resolver.Servers) +func addFloor(state *withdrawalState, policy probe.Policy, views []string) { + state.Floor.Servers = sortedUnion(state.Floor.Servers, policy.Servers) state.Floor.ExpectedNameservers = sortedUnion(state.Floor.ExpectedNameservers, policy.ExpectedNameservers) state.Views = sortedUnion(state.Views, views) } diff --git a/internal/go/providers/external/controllers/zoneunit/withdrawal_test.go b/internal/go/providers/external/controllers/zoneunit/withdrawal_test.go index 0d151dc..6908d0d 100644 --- a/internal/go/providers/external/controllers/zoneunit/withdrawal_test.go +++ b/internal/go/providers/external/controllers/zoneunit/withdrawal_test.go @@ -8,7 +8,7 @@ import ( "testing" "time" - "github.com/appthrust/dns-api/internal/go/providers/external/probe" + probe "github.com/appthrust/dns-api/internal/go/core/dnsprobe" dnsv1alpha1 "github.com/appthrust/dns-api/pkg/go/api/dns/v1alpha1" "k8s.io/apimachinery/pkg/api/meta" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" @@ -23,7 +23,7 @@ func withdrawalItem(name, address string) dnsv1alpha1.ZoneUnitWithdrawal { func withdrawalAnswer(request probe.ProbeRequest, address string, ttl uint32) (probe.ProbeResult, error) { result := probe.ProbeResult{} - for _, server := range request.Policy.Resolver.Servers { + for _, server := range request.Policy.Servers { view := probe.View{ID: "recursive/" + server} for _, family := range []string{"A", "AAAA"} { answer := probe.AnswerEvidence{Name: request.Name, QType: family, NegativeTTL: &ttl} diff --git a/internal/go/providers/external/conversion/handler.go b/internal/go/providers/external/conversion/handler.go index 9c1ec6b..306d956 100644 --- a/internal/go/providers/external/conversion/handler.go +++ b/internal/go/providers/external/conversion/handler.go @@ -8,7 +8,7 @@ import ( "slices" "strings" - "github.com/appthrust/dns-api/internal/go/providers/external/config" + "github.com/appthrust/dns-api/internal/go/core/dnsprobe" dnsv1alpha1 "github.com/appthrust/dns-api/pkg/go/api/dns/v1alpha1" conversionv1alpha1 "github.com/appthrust/dns-api/pkg/go/api/endpoint/conversion/v1alpha1" endpointv1alpha1 "github.com/appthrust/dns-api/pkg/go/api/endpoint/v1alpha1" @@ -83,7 +83,7 @@ func convertEndpoint(input endpointv1alpha1.EndpointRecordSetConversionInput) ([ } case endpointv1alpha1.EndpointTargetTypeHostname: name := strings.TrimSuffix(strings.ToLower(target.Value), ".") - if !config.ValidName(name) { + if !dnsprobe.ValidName(name) { return nil, failure("InvalidInput", "hostname target must be a valid DNS name") } hosts = append(hosts, name) diff --git a/internal/go/providers/external/probe/evaluate.go b/internal/go/providers/external/probe/evaluate.go deleted file mode 100644 index 21778c8..0000000 --- a/internal/go/providers/external/probe/evaluate.go +++ /dev/null @@ -1,300 +0,0 @@ -package probe - -import ( - "fmt" - "slices" - "strings" - - "github.com/appthrust/dns-api/internal/go/providers/external/config" - externalv1alpha1 "github.com/appthrust/dns-api/pkg/go/api/external/v1alpha1" - "github.com/miekg/dns" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" -) - -type Decision struct { - Status metav1.ConditionStatus - Reason string - Message string - Actual []string - Missing []string - Extra []string - Chain []string - Views []string - Nameservers []string - TTL *uint32 - NegativeTTL *uint32 -} - -func (d Decision) Outcome() string { - if d.Status == metav1.ConditionUnknown { - return "failed" - } - if d.Status == metav1.ConditionTrue { - return "verified" - } - if d.Reason == "DNSNameNotFound" || d.Reason == "DNSNoData" { - return "negative" - } - return "mismatch" -} - -func Failed(name, view, failure string) Decision { - switch failure { - case "timeout", "SERVFAIL", "REFUSED", "transport", "invalid-response", "resolver-config", "chain-limit", "probe-limit": - default: - failure = "transport" - } - return Decision{Status: metav1.ConditionUnknown, Reason: "DNSProbeFailed", Message: bounded(fmt.Sprintf("DNS verification unavailable for %s from %s: %s.", name, view, failure))} -} - -func EvaluateZone(request ProbeRequest, evidence *ZoneEvidence) Decision { - return evaluateZone(request, evidence, true) -} - -func evaluateZone(request ProbeRequest, evidence *ZoneEvidence, requireAuthorities bool) Decision { - if len(request.Policy.ExpectedNameservers) == 0 { - return Decision{Status: metav1.ConditionTrue, Reason: "VerificationConfigured", Message: bounded(fmt.Sprintf("DNS verifier configured for %s; authoritative nameserver checks were not requested.", request.Zone))} - } - if evidence == nil || len(evidence.Views) < len(request.Policy.Resolver.Servers) || len(evidence.Views) == 0 { - return Failed(request.Zone, "required-views", "invalid-response") - } - for _, server := range request.Policy.Resolver.Servers { - if !slices.ContainsFunc(evidence.Views, func(view AuthorityObservation) bool { return view.ID == "recursive/"+server }) { - return Failed(request.Zone, "recursive/"+server, "invalid-response") - } - } - views := slices.Clone(evidence.Views) - slices.SortFunc(views, func(a, b AuthorityObservation) int { return strings.Compare(a.ID, b.ID) }) - decision := Decision{Status: metav1.ConditionTrue, Reason: "NameserversVerified"} - for _, view := range views { - decision.Views = append(decision.Views, view.ID) - if view.Failure != "" { - return Failed(request.Zone, view.ID, view.Failure) - } - for _, name := range view.Nameservers { - if !config.ValidName(CanonicalName(name)) { - return Failed(request.Zone, view.ID, "invalid-response") - } - } - decision.TTL = minTTL(decision.TTL, view.TTL) - decision.NegativeTTL = minTTL(decision.NegativeTTL, view.NegativeTTL) - } - first := views[0] - decision.Nameservers = Names(first.Nameservers) - decision.Actual = typed("NS", decision.Nameservers) - for _, view := range views[1:] { - if !slices.Equal(decision.Nameservers, Names(view.Nameservers)) || CanonicalName(view.SOAOwner) != CanonicalName(first.SOAOwner) { - return inconsistent(request.Zone, decision) - } - } - if CanonicalName(first.SOAOwner) != request.Zone || !slices.Equal(decision.Nameservers, Names(request.Policy.ExpectedNameservers)) { - decision.Status, decision.Reason = metav1.ConditionFalse, "NameserverMismatch" - decision.Missing, decision.Extra = difference(typed("NS", Names(request.Policy.ExpectedNameservers)), decision.Actual) - decision.Message = bounded(fmt.Sprintf("DNS %s nameservers differ: expected %s; observed %s.", request.Zone, preview(Names(request.Policy.ExpectedNameservers)), preview(decision.Nameservers))) - return decision - } - if requireAuthorities && len(evidence.Authorities) == 0 { - return Failed(request.Zone, "required-authorities", "invalid-response") - } - if requireAuthorities { - for _, authority := range evidence.Authorities { - found := false - for _, view := range views { - if view.ID == "authority/"+authority && view.Authoritative { - found = true - break - } - } - if !found { - return Failed(request.Zone, "authority/"+authority, "invalid-response") - } - } - } - decision.Message = bounded(fmt.Sprintf("DNS %s nameservers match expectations in every required view; no DNS changes were made.", request.Zone)) - return decision -} - -func Evaluate(request ProbeRequest, result ProbeResult) Decision { - if len(result.Views) == 0 || len(result.Views) < len(request.Policy.Resolver.Servers) { - return Failed(request.Name, "required-views", "invalid-response") - } - for _, server := range request.Policy.Resolver.Servers { - if !slices.ContainsFunc(result.Views, func(view View) bool { return view.ID == "recursive/"+server }) { - return Failed(request.Name, "recursive/"+server, "invalid-response") - } - } - if len(request.Policy.ExpectedNameservers) != 0 { - if request.Evidence == nil || len(request.Evidence.Authorities) == 0 { - return Failed(request.Name, "required-authorities", "invalid-response") - } - for _, authority := range request.Evidence.Authorities { - if !slices.ContainsFunc(result.Views, func(view View) bool { - return strings.HasPrefix(view.ID, "authority/"+authority+"/via/") && view.Authoritative - }) { - return Failed(request.Name, "authority/"+authority, "invalid-response") - } - } - } - views := slices.Clone(result.Views) - slices.SortFunc(views, func(a, b View) int { return strings.Compare(a.ID, b.ID) }) - decision := Decision{Status: metav1.ConditionTrue, Reason: "ResolutionVerified"} - for i := range views { - view := &views[i] - view.A, view.AAAA = Addresses(view.A), Addresses(view.AAAA) - view.Chain = slices.Clone(view.Chain) - seen := map[string]bool{CanonicalName(request.Name): true} - for j, name := range view.Chain { - name = CanonicalName(name) - view.Chain[j] = name - if seen[name] { - view.Mismatch = true - } - seen[name] = true - } - if len(view.Chain) > MaxCNAMEs { - view.Failure = "chain-limit" - } - if view.Failure != "" { - return Failed(request.Name, view.ID, view.Failure) - } - if view.RCode != dns.RcodeSuccess && view.RCode != dns.RcodeNameError { - failure := "invalid-response" - if view.RCode == dns.RcodeServerFailure { - failure = "SERVFAIL" - } - if view.RCode == dns.RcodeRefused { - failure = "REFUSED" - } - return Failed(request.Name, view.ID, failure) - } - decision.Views = append(decision.Views, view.ID) - decision.TTL = minTTL(decision.TTL, view.TTL) - decision.NegativeTTL = minTTL(decision.NegativeTTL, view.NegativeTTL) - } - if request.Evidence != nil { - for _, view := range request.Evidence.Views { - decision.TTL = minTTL(decision.TTL, view.TTL) - } - } - first := views[0] - decision.Actual = append(typed("A", first.A), typed("AAAA", first.AAAA)...) - decision.Chain = first.Chain - for _, view := range views[1:] { - if view.RCode != first.RCode || view.Mismatch != first.Mismatch || !slices.Equal(view.A, first.A) || !slices.Equal(view.AAAA, first.AAAA) || !slices.Equal(view.Chain, first.Chain) { - return inconsistent(request.Name, decision) - } - } - expected := append(typed("A", Addresses(request.Expected.A)), typed("AAAA", Addresses(request.Expected.AAAA))...) - if request.Expected.CNAME != "" { - expected = typed("CNAME", []string{CanonicalName(request.Expected.CNAME)}) - decision.Actual = nil - if len(first.Chain) > 0 { - decision.Actual = typed("CNAME", first.Chain[:1]) - } - } - decision.Missing, decision.Extra = difference(expected, decision.Actual) - if first.Mismatch { - return mismatch(request.Name, decision) - } - if first.RCode == dns.RcodeNameError { - decision.Status, decision.Reason = metav1.ConditionFalse, "DNSNameNotFound" - decision.Message = bounded(fmt.Sprintf("DNS %s returned NXDOMAIN from %s; expected %s.", request.Name, first.ID, preview(expected))) - return decision - } - if len(decision.Extra) != 0 { - return mismatch(request.Name, decision) - } - if request.Expected.CNAME != "" && len(decision.Missing) != 0 { - return mismatch(request.Name, decision) - } - qtype := "" - if len(request.Expected.A) > 0 && len(first.A) == 0 { - qtype = "A" - } - if qtype == "" && len(request.Expected.AAAA) > 0 && len(first.AAAA) == 0 { - qtype = "AAAA" - } - if request.Expected.CNAME != "" && len(first.A)+len(first.AAAA) == 0 { - qtype = "A or AAAA" - } - if qtype != "" { - decision.Status, decision.Reason = metav1.ConditionFalse, "DNSNoData" - decision.Message = bounded(fmt.Sprintf("DNS %s returned NOERROR with no %s answers; expected %s.", request.Name, qtype, preview(expected))) - return decision - } - if len(decision.Missing) != 0 { - decision.Status, decision.Reason = metav1.ConditionFalse, "ResolutionPartial" - decision.Message = bounded(fmt.Sprintf("DNS %s is missing expected targets %s; observed %s.", request.Name, preview(decision.Missing), preview(decision.Actual))) - return decision - } - decision.Message = bounded(fmt.Sprintf("DNS %s matches expected targets in every required resolver view; no DNS changes were made.", request.Name)) - return decision -} - -func inconsistent(name string, decision Decision) Decision { - decision.Status, decision.Reason = metav1.ConditionFalse, "ResolutionInconsistent" - decision.Message = bounded(fmt.Sprintf("DNS %s differs between required resolver/authority views %s.", name, preview(decision.Views))) - return decision -} - -func mismatch(name string, decision Decision) Decision { - decision.Status, decision.Reason = metav1.ConditionFalse, "ResolutionMismatch" - decision.Message = bounded(fmt.Sprintf("DNS %s targets differ: missing %s; unexpected %s.", name, preview(decision.Missing), preview(decision.Extra))) - return decision -} - -func typed(kind string, values []string) []string { - out := make([]string, len(values)) - for i, value := range values { - out[i] = kind + " " + value - } - return out -} - -func difference(expected, actual []string) (missing, extra []string) { - for _, value := range expected { - if !slices.Contains(actual, value) { - missing = append(missing, value) - } - } - for _, value := range actual { - if !slices.Contains(expected, value) { - extra = append(extra, value) - } - } - slices.Sort(missing) - slices.Sort(extra) - return missing, extra -} - -func Preview(values []string) externalv1alpha1.VerificationValues { - result := externalv1alpha1.VerificationValues{Total: int32(len(values)), Truncated: len(values) > 16} - result.Values = slices.Clone(values[:min(len(values), 16)]) - for i, value := range result.Values { - if len(value) > 512 { - result.Values[i] = value[:512] - result.Truncated = true - } - } - return result -} - -func preview(values []string) string { - // The message preview is smaller than public data so two previews plus a name fit 2048 bytes. - shown := values[:min(len(values), 4)] - result := "[" + strings.Join(shown, ", ") + "]" - if len(values) > len(shown) { - result += fmt.Sprintf(" (+%d omitted)", len(values)-len(shown)) - } - if len(result) > 700 { - result = result[:680] + " (truncated)" - } - return result -} - -func bounded(value string) string { - if len(value) > 2048 { - return value[:2032] + " (truncated)" - } - return value -} diff --git a/internal/go/providers/external/probe/evaluate_test.go b/internal/go/providers/external/probe/evaluate_test.go deleted file mode 100644 index 1535a3c..0000000 --- a/internal/go/providers/external/probe/evaluate_test.go +++ /dev/null @@ -1,118 +0,0 @@ -package probe - -import ( - "fmt" - "testing" - - externalv1alpha1 "github.com/appthrust/dns-api/pkg/go/api/external/v1alpha1" - "github.com/miekg/dns" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" -) - -func TestExactVerification(t *testing.T) { - cases := []struct { - name string - expected RRsets - views []View - reason string - }{ - {"exact", RRsets{A: []string{"192.0.2.1"}}, []View{{A: []string{"192.0.2.1"}}}, "ResolutionVerified"}, - {"normalized duplicate IPv6", RRsets{AAAA: []string{"2001:db8::1"}}, []View{{AAAA: []string{"2001:0DB8:0:0::1", "2001:db8::1"}}}, "ResolutionVerified"}, - {"unexpected family", RRsets{A: []string{"192.0.2.1"}}, []View{{A: []string{"192.0.2.1"}, AAAA: []string{"2001:db8::1"}}}, "ResolutionMismatch"}, - {"partial", RRsets{A: []string{"192.0.2.1", "192.0.2.2"}}, []View{{A: []string{"192.0.2.1"}}}, "ResolutionPartial"}, - {"wrong", RRsets{A: []string{"192.0.2.1"}}, []View{{A: []string{"192.0.2.2"}}}, "ResolutionMismatch"}, - {"NXDOMAIN", RRsets{A: []string{"192.0.2.1"}}, []View{{RCode: dns.RcodeNameError}}, "DNSNameNotFound"}, - {"NODATA", RRsets{A: []string{"192.0.2.1"}}, []View{{}}, "DNSNoData"}, - {"inconsistent", RRsets{A: []string{"192.0.2.1"}}, []View{{ID: "a", A: []string{"192.0.2.1"}}, {ID: "b", RCode: dns.RcodeNameError}}, "ResolutionInconsistent"}, - {"failure beats mismatch", RRsets{A: []string{"192.0.2.1"}}, []View{{ID: "a", A: []string{"192.0.2.2"}}, {ID: "b", Failure: "timeout"}}, "DNSProbeFailed"}, - {"SERVFAIL", RRsets{A: []string{"192.0.2.1"}}, []View{{RCode: dns.RcodeServerFailure}}, "DNSProbeFailed"}, - {"CNAME to IP", RRsets{A: []string{"192.0.2.1"}}, []View{{Chain: []string{"TARGET.EXAMPLE."}, A: []string{"192.0.2.1"}}}, "ResolutionVerified"}, - {"CNAME identity", RRsets{CNAME: "target.example"}, []View{{Chain: []string{"TARGET.EXAMPLE."}, A: []string{"192.0.2.1"}}}, "ResolutionVerified"}, - {"wrong CNAME same IP", RRsets{CNAME: "target.example"}, []View{{Chain: []string{"other.example"}, A: []string{"192.0.2.1"}}}, "ResolutionMismatch"}, - {"unusable CNAME", RRsets{CNAME: "target.example"}, []View{{Chain: []string{"target.example"}}}, "DNSNoData"}, - {"CNAME loop", RRsets{A: []string{"192.0.2.1"}}, []View{{Chain: []string{"target.example", "www.example"}, A: []string{"192.0.2.1"}}}, "ResolutionMismatch"}, - {"incompatible CNAME", RRsets{CNAME: "target.example"}, []View{{Chain: []string{"target.example"}, A: []string{"192.0.2.1"}, Mismatch: true}}, "ResolutionMismatch"}, - } - for _, tc := range cases { - t.Run(tc.name, func(t *testing.T) { - decision := Evaluate(ProbeRequest{Name: "www.example", Expected: tc.expected}, ProbeResult{Views: tc.views}) - if decision.Reason != tc.reason { - t.Fatalf("decision = %#v, want %s", decision, tc.reason) - } - if len(decision.Message) > 2048 { - t.Fatal("unbounded message") - } - }) - } -} - -func TestCNAMEChainBoundary(t *testing.T) { - for _, links := range []int{8, 9} { - chain := make([]string, links) - for i := range chain { - chain[i] = fmt.Sprintf("link-%d.example", i) - } - decision := Evaluate(ProbeRequest{Name: "www.example", Expected: RRsets{A: []string{"192.0.2.1"}}}, ProbeResult{Views: []View{{Chain: chain, A: []string{"192.0.2.1"}}}}) - want := "ResolutionVerified" - if links == 9 { - want = "DNSProbeFailed" - } - if decision.Reason != want { - t.Fatalf("links=%d: %#v", links, decision) - } - } -} - -func TestNameserverEvidence(t *testing.T) { - request := ProbeRequest{Zone: "example.test", Policy: externalv1alpha1.ExternalZoneClassParameters{Resolver: externalv1alpha1.ResolverPolicy{Servers: []string{"192.0.2.53:53"}}, ExpectedNameservers: []string{"ns.example.test"}}} - good := AuthorityObservation{ID: "recursive/192.0.2.53:53", Nameservers: []string{"NS.EXAMPLE.TEST."}, SOAOwner: "example.test"} - authority := AuthorityObservation{ID: "authority/192.0.2.54:53", Nameservers: []string{"ns.example.test"}, SOAOwner: "example.test", Authoritative: true} - cases := []struct { - name string - mutate func(*ZoneEvidence) - reason string - }{ - {"verified", func(*ZoneEvidence) {}, "NameserversVerified"}, - {"non apex", func(e *ZoneEvidence) { e.Views[0].SOAOwner = "test"; e.Views[1].SOAOwner = "test" }, "NameserverMismatch"}, - {"NS mismatch", func(e *ZoneEvidence) { - for i := range e.Views { - e.Views[i].Nameservers = []string{"wrong.example.test"} - } - }, "NameserverMismatch"}, - {"inconsistent", func(e *ZoneEvidence) { e.Views[1].Nameservers = []string{"wrong.example.test"} }, "ResolutionInconsistent"}, - {"missing AA", func(e *ZoneEvidence) { e.Views[1].Authoritative = false }, "DNSProbeFailed"}, - {"failed authority", func(e *ZoneEvidence) { e.Views[1].Failure = "REFUSED" }, "DNSProbeFailed"}, - {"missing authority", func(e *ZoneEvidence) { e.Views = e.Views[:1] }, "DNSProbeFailed"}, - } - for _, tc := range cases { - t.Run(tc.name, func(t *testing.T) { - evidence := &ZoneEvidence{Views: []AuthorityObservation{good, authority}, Authorities: []string{"192.0.2.54:53"}} - tc.mutate(evidence) - decision := EvaluateZone(request, evidence) - if decision.Reason != tc.reason { - t.Fatalf("decision = %#v, want %s", decision, tc.reason) - } - }) - } - request.Policy.ExpectedNameservers = nil - if decision := EvaluateZone(request, nil); decision.Status != metav1.ConditionTrue || len(decision.Nameservers) != 0 { - t.Fatalf("suffix = %#v", decision) - } -} - -func TestStatusPreviewDoesNotAffectEquality(t *testing.T) { - values := make([]string, 20) - for i := range values { - values[i] = fmt.Sprintf("192.0.2.%d", i+1) - } - observed := append([]string(nil), values...) - observed[19] = "198.51.100.1" - decision := Evaluate(ProbeRequest{Name: "www.example", Expected: RRsets{A: values}}, ProbeResult{Views: []View{{A: observed}}}) - if decision.Reason != "ResolutionMismatch" { - t.Fatalf("decision = %#v", decision) - } - preview := Preview(decision.Actual) - if preview.Total != 20 || !preview.Truncated || len(preview.Values) != 16 { - t.Fatalf("preview = %#v", preview) - } -} diff --git a/internal/go/providers/external/verification/evaluate.go b/internal/go/providers/external/verification/evaluate.go new file mode 100644 index 0000000..69d0b53 --- /dev/null +++ b/internal/go/providers/external/verification/evaluate.go @@ -0,0 +1,132 @@ +// Package verification owns external-provider exact-set publication semantics. +package verification + +import ( + "fmt" + "slices" + "strings" + + "github.com/appthrust/dns-api/internal/go/core/dnsprobe" + "github.com/miekg/dns" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +func Evaluate(request dnsprobe.ProbeRequest, result dnsprobe.ProbeResult) dnsprobe.Decision { + if len(result.Views) == 0 || len(result.Views) < len(request.Policy.Servers) { + return dnsprobe.Failed(request.Name, "required-views", "invalid-response") + } + for _, server := range request.Policy.Servers { + if !slices.ContainsFunc(result.Views, func(view dnsprobe.View) bool { return view.ID == "recursive/"+server }) { + return dnsprobe.Failed(request.Name, "recursive/"+server, "invalid-response") + } + } + if len(request.Policy.ExpectedNameservers) != 0 { + if request.Evidence == nil || len(request.Evidence.Authorities) == 0 { + return dnsprobe.Failed(request.Name, "required-authorities", "invalid-response") + } + for _, authority := range request.Evidence.Authorities { + if !slices.ContainsFunc(result.Views, func(view dnsprobe.View) bool { + return strings.HasPrefix(view.ID, "authority/"+authority+"/via/") && view.Authoritative + }) { + return dnsprobe.Failed(request.Name, "authority/"+authority, "invalid-response") + } + } + } + views := slices.Clone(result.Views) + slices.SortFunc(views, func(a, b dnsprobe.View) int { return strings.Compare(a.ID, b.ID) }) + decision := dnsprobe.Decision{Status: metav1.ConditionTrue, Reason: "ResolutionVerified"} + for i := range views { + view := &views[i] + view.A, view.AAAA = dnsprobe.Addresses(view.A), dnsprobe.Addresses(view.AAAA) + view.Chain = slices.Clone(view.Chain) + seen := map[string]bool{dnsprobe.CanonicalName(request.Name): true} + for j, name := range view.Chain { + name = dnsprobe.CanonicalName(name) + view.Chain[j] = name + if seen[name] { + view.Mismatch = true + } + seen[name] = true + } + if len(view.Chain) > dnsprobe.MaxCNAMEs { + view.Failure = "chain-limit" + } + if view.Failure != "" { + return dnsprobe.Failed(request.Name, view.ID, view.Failure) + } + if view.RCode != dns.RcodeSuccess && view.RCode != dns.RcodeNameError { + failure := "invalid-response" + if view.RCode == dns.RcodeServerFailure { + failure = "SERVFAIL" + } + if view.RCode == dns.RcodeRefused { + failure = "REFUSED" + } + return dnsprobe.Failed(request.Name, view.ID, failure) + } + decision.Views = append(decision.Views, view.ID) + decision.TTL = dnsprobe.MinTTL(decision.TTL, view.TTL) + decision.NegativeTTL = dnsprobe.MinTTL(decision.NegativeTTL, view.NegativeTTL) + } + if request.Evidence != nil { + for _, view := range request.Evidence.Views { + decision.TTL = dnsprobe.MinTTL(decision.TTL, view.TTL) + } + } + first := views[0] + decision.Actual = append(dnsprobe.Typed("A", first.A), dnsprobe.Typed("AAAA", first.AAAA)...) + decision.Chain = first.Chain + for _, view := range views[1:] { + if view.RCode != first.RCode || view.Mismatch != first.Mismatch || !slices.Equal(view.A, first.A) || !slices.Equal(view.AAAA, first.AAAA) || !slices.Equal(view.Chain, first.Chain) { + return dnsprobe.Inconsistent(request.Name, decision) + } + } + expected := append(dnsprobe.Typed("A", dnsprobe.Addresses(request.Expected.A)), dnsprobe.Typed("AAAA", dnsprobe.Addresses(request.Expected.AAAA))...) + if request.Expected.CNAME != "" { + expected = dnsprobe.Typed("CNAME", []string{dnsprobe.CanonicalName(request.Expected.CNAME)}) + decision.Actual = nil + if len(first.Chain) > 0 { + decision.Actual = dnsprobe.Typed("CNAME", first.Chain[:1]) + } + } + decision.Missing, decision.Extra = dnsprobe.Difference(expected, decision.Actual) + if first.Mismatch { + return mismatch(request.Name, decision) + } + if first.RCode == dns.RcodeNameError { + decision.Status, decision.Reason = metav1.ConditionFalse, "DNSNameNotFound" + decision.Message = dnsprobe.Bounded(fmt.Sprintf("DNS %s returned NXDOMAIN from %s; expected %s.", request.Name, first.ID, dnsprobe.MessagePreview(expected))) + return decision + } + if len(decision.Extra) != 0 || (request.Expected.CNAME != "" && len(decision.Missing) != 0) { + return mismatch(request.Name, decision) + } + qtype := "" + if len(request.Expected.A) > 0 && len(first.A) == 0 { + qtype = "A" + } + if qtype == "" && len(request.Expected.AAAA) > 0 && len(first.AAAA) == 0 { + qtype = "AAAA" + } + if request.Expected.CNAME != "" && len(first.A)+len(first.AAAA) == 0 { + qtype = "A or AAAA" + } + if qtype != "" { + decision.Status, decision.Reason = metav1.ConditionFalse, "DNSNoData" + decision.Message = dnsprobe.Bounded(fmt.Sprintf("DNS %s returned NOERROR with no %s answers; expected %s.", request.Name, qtype, dnsprobe.MessagePreview(expected))) + return decision + } + if len(decision.Missing) != 0 { + decision.Status, decision.Reason = metav1.ConditionFalse, "ResolutionPartial" + decision.Message = dnsprobe.Bounded(fmt.Sprintf("DNS %s is missing expected targets %s; observed %s.", request.Name, dnsprobe.MessagePreview(decision.Missing), dnsprobe.MessagePreview(decision.Actual))) + return decision + } + decision.Message = dnsprobe.Bounded(fmt.Sprintf("DNS %s matches expected targets in every required resolver view; no DNS changes were made.", request.Name)) + return decision +} + +func mismatch(name string, decision dnsprobe.Decision) dnsprobe.Decision { + decision.Status, decision.Reason = metav1.ConditionFalse, "ResolutionMismatch" + decision.Message = dnsprobe.Bounded(fmt.Sprintf("DNS %s targets differ: missing %s; unexpected %s.", name, dnsprobe.MessagePreview(decision.Missing), dnsprobe.MessagePreview(decision.Extra))) + return decision +} diff --git a/internal/go/providers/external/verification/evaluate_test.go b/internal/go/providers/external/verification/evaluate_test.go new file mode 100644 index 0000000..611c9cf --- /dev/null +++ b/internal/go/providers/external/verification/evaluate_test.go @@ -0,0 +1,118 @@ +package verification + +import ( + "fmt" + "testing" + + "github.com/appthrust/dns-api/internal/go/core/dnsprobe" + "github.com/miekg/dns" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +func TestExactVerification(t *testing.T) { + cases := []struct { + name string + expected dnsprobe.RRsets + views []dnsprobe.View + reason string + }{ + {"exact", dnsprobe.RRsets{A: []string{"192.0.2.1"}}, []dnsprobe.View{{A: []string{"192.0.2.1"}}}, "ResolutionVerified"}, + {"normalized duplicate IPv6", dnsprobe.RRsets{AAAA: []string{"2001:db8::1"}}, []dnsprobe.View{{AAAA: []string{"2001:0DB8:0:0::1", "2001:db8::1"}}}, "ResolutionVerified"}, + {"unexpected family", dnsprobe.RRsets{A: []string{"192.0.2.1"}}, []dnsprobe.View{{A: []string{"192.0.2.1"}, AAAA: []string{"2001:db8::1"}}}, "ResolutionMismatch"}, + {"partial", dnsprobe.RRsets{A: []string{"192.0.2.1", "192.0.2.2"}}, []dnsprobe.View{{A: []string{"192.0.2.1"}}}, "ResolutionPartial"}, + {"wrong", dnsprobe.RRsets{A: []string{"192.0.2.1"}}, []dnsprobe.View{{A: []string{"192.0.2.2"}}}, "ResolutionMismatch"}, + {"NXDOMAIN", dnsprobe.RRsets{A: []string{"192.0.2.1"}}, []dnsprobe.View{{RCode: dns.RcodeNameError}}, "DNSNameNotFound"}, + {"NODATA", dnsprobe.RRsets{A: []string{"192.0.2.1"}}, []dnsprobe.View{{}}, "DNSNoData"}, + {"inconsistent", dnsprobe.RRsets{A: []string{"192.0.2.1"}}, []dnsprobe.View{{ID: "a", A: []string{"192.0.2.1"}}, {ID: "b", RCode: dns.RcodeNameError}}, "ResolutionInconsistent"}, + {"failure beats mismatch", dnsprobe.RRsets{A: []string{"192.0.2.1"}}, []dnsprobe.View{{ID: "a", A: []string{"192.0.2.2"}}, {ID: "b", Failure: "timeout"}}, "DNSProbeFailed"}, + {"SERVFAIL", dnsprobe.RRsets{A: []string{"192.0.2.1"}}, []dnsprobe.View{{RCode: dns.RcodeServerFailure}}, "DNSProbeFailed"}, + {"CNAME to IP", dnsprobe.RRsets{A: []string{"192.0.2.1"}}, []dnsprobe.View{{Chain: []string{"TARGET.EXAMPLE."}, A: []string{"192.0.2.1"}}}, "ResolutionVerified"}, + {"CNAME identity", dnsprobe.RRsets{CNAME: "target.example"}, []dnsprobe.View{{Chain: []string{"TARGET.EXAMPLE."}, A: []string{"192.0.2.1"}}}, "ResolutionVerified"}, + {"wrong CNAME same IP", dnsprobe.RRsets{CNAME: "target.example"}, []dnsprobe.View{{Chain: []string{"other.example"}, A: []string{"192.0.2.1"}}}, "ResolutionMismatch"}, + {"unusable CNAME", dnsprobe.RRsets{CNAME: "target.example"}, []dnsprobe.View{{Chain: []string{"target.example"}}}, "DNSNoData"}, + {"CNAME loop", dnsprobe.RRsets{A: []string{"192.0.2.1"}}, []dnsprobe.View{{Chain: []string{"target.example", "www.example"}, A: []string{"192.0.2.1"}}}, "ResolutionMismatch"}, + {"incompatible CNAME", dnsprobe.RRsets{CNAME: "target.example"}, []dnsprobe.View{{Chain: []string{"target.example"}, A: []string{"192.0.2.1"}, Mismatch: true}}, "ResolutionMismatch"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + decision := Evaluate(dnsprobe.ProbeRequest{Name: "www.example", Expected: tc.expected}, dnsprobe.ProbeResult{Views: tc.views}) + if decision.Reason != tc.reason { + t.Fatalf("decision = %#v, want %s", decision, tc.reason) + } + if len(decision.Message) > 2048 { + t.Fatal("unbounded message") + } + }) + } +} + +func TestCNAMEChainBoundary(t *testing.T) { + for _, links := range []int{8, 9} { + chain := make([]string, links) + for i := range chain { + chain[i] = fmt.Sprintf("link-%d.example", i) + } + decision := Evaluate(dnsprobe.ProbeRequest{Name: "www.example", Expected: dnsprobe.RRsets{A: []string{"192.0.2.1"}}}, dnsprobe.ProbeResult{Views: []dnsprobe.View{{Chain: chain, A: []string{"192.0.2.1"}}}}) + want := "ResolutionVerified" + if links == 9 { + want = "DNSProbeFailed" + } + if decision.Reason != want { + t.Fatalf("links=%d: %#v", links, decision) + } + } +} + +func TestNameserverEvidence(t *testing.T) { + request := dnsprobe.ProbeRequest{Zone: "example.test", Policy: dnsprobe.Policy{Servers: []string{"192.0.2.53:53"}, ExpectedNameservers: []string{"ns.example.test"}}} + good := dnsprobe.AuthorityObservation{ID: "recursive/192.0.2.53:53", Nameservers: []string{"NS.EXAMPLE.TEST."}, SOAOwner: "example.test"} + authority := dnsprobe.AuthorityObservation{ID: "authority/192.0.2.54:53", Nameservers: []string{"ns.example.test"}, SOAOwner: "example.test", Authoritative: true} + cases := []struct { + name string + mutate func(*dnsprobe.ZoneEvidence) + reason string + }{ + {"verified", func(*dnsprobe.ZoneEvidence) {}, "NameserversVerified"}, + {"non apex", func(e *dnsprobe.ZoneEvidence) { e.Views[0].SOAOwner = "test"; e.Views[1].SOAOwner = "test" }, "NameserverMismatch"}, + {"NS mismatch", func(e *dnsprobe.ZoneEvidence) { + for i := range e.Views { + e.Views[i].Nameservers = []string{"wrong.example.test"} + } + }, "NameserverMismatch"}, + {"inconsistent", func(e *dnsprobe.ZoneEvidence) { e.Views[1].Nameservers = []string{"wrong.example.test"} }, "ResolutionInconsistent"}, + {"missing AA", func(e *dnsprobe.ZoneEvidence) { e.Views[1].Authoritative = false }, "DNSProbeFailed"}, + {"failed authority", func(e *dnsprobe.ZoneEvidence) { e.Views[1].Failure = "REFUSED" }, "DNSProbeFailed"}, + {"missing authority", func(e *dnsprobe.ZoneEvidence) { e.Views = e.Views[:1] }, "DNSProbeFailed"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + evidence := &dnsprobe.ZoneEvidence{Views: []dnsprobe.AuthorityObservation{good, authority}, Authorities: []string{"192.0.2.54:53"}} + tc.mutate(evidence) + decision := dnsprobe.EvaluateZone(request, evidence) + if decision.Reason != tc.reason { + t.Fatalf("decision = %#v, want %s", decision, tc.reason) + } + }) + } + request.Policy.ExpectedNameservers = nil + if decision := dnsprobe.EvaluateZone(request, nil); decision.Status != metav1.ConditionTrue || len(decision.Nameservers) != 0 { + t.Fatalf("suffix = %#v", decision) + } +} + +func TestStatusPreviewDoesNotAffectEquality(t *testing.T) { + values := make([]string, 20) + for i := range values { + values[i] = fmt.Sprintf("192.0.2.%d", i+1) + } + observed := append([]string(nil), values...) + observed[19] = "198.51.100.1" + decision := Evaluate(dnsprobe.ProbeRequest{Name: "www.example", Expected: dnsprobe.RRsets{A: values}}, dnsprobe.ProbeResult{Views: []dnsprobe.View{{A: observed}}}) + if decision.Reason != "ResolutionMismatch" { + t.Fatalf("decision = %#v", decision) + } + preview := dnsprobe.Preview(decision.Actual) + if preview.Total != 20 || !preview.Truncated || len(preview.Values) != 16 { + t.Fatalf("preview = %#v", preview) + } +} diff --git a/internal/go/providers/external/probe/metrics.go b/internal/go/providers/external/verification/metrics.go similarity index 73% rename from internal/go/providers/external/probe/metrics.go rename to internal/go/providers/external/verification/metrics.go index 830d510..be82354 100644 --- a/internal/go/providers/external/probe/metrics.go +++ b/internal/go/providers/external/verification/metrics.go @@ -1,17 +1,13 @@ -package probe +package verification import ( "sync" - "time" "github.com/prometheus/client_golang/prometheus" "sigs.k8s.io/controller-runtime/pkg/metrics" ) var ( - probes = prometheus.NewCounterVec(prometheus.CounterOpts{Name: "dns_external_probes_total", Help: "Completed external DNS observations."}, []string{"outcome", "qtype"}) - duration = prometheus.NewHistogramVec(prometheus.HistogramOpts{Name: "dns_external_probe_duration_seconds", Help: "Admitted external DNS probe duration."}, []string{"outcome"}) - queueDelay = prometheus.NewHistogram(prometheus.HistogramOpts{Name: "dns_external_probe_queue_delay_seconds", Help: "Scheduling and limiter admission delay for external DNS probes."}) pending = prometheus.NewGaugeVec(prometheus.GaugeOpts{Name: "dns_external_pending_records", Help: "Active unverified external record claims."}, []string{"reason"}) pendingMu sync.Mutex pendingUnits = map[string]map[string]int{} @@ -19,9 +15,7 @@ var ( withdrawalUnits = map[string]map[string]int{} ) -func init() { metrics.Registry.MustRegister(probes, duration, queueDelay, pending, withdrawalsPending) } - -func ObserveQueueDelay(delay time.Duration) { queueDelay.Observe(max(0, delay.Seconds())) } +func init() { metrics.Registry.MustRegister(pending, withdrawalsPending) } // SetPending replaces a unit's contribution; identities are internal keys, never labels. func SetPending(unit string, counts map[string]int) { diff --git a/internal/go/providers/external/verification/metrics_test.go b/internal/go/providers/external/verification/metrics_test.go new file mode 100644 index 0000000..aab2ef4 --- /dev/null +++ b/internal/go/providers/external/verification/metrics_test.go @@ -0,0 +1,22 @@ +package verification + +import "testing" + +func TestWithdrawalPendingMetricClosedReasons(t *testing.T) { + const unit = "test-withdrawal-metric" + t.Cleanup(func() { SetWithdrawalsPending(unit, nil) }) + SetWithdrawalsPending(unit, map[string]int{"private/unbounded": 1, "DNSStillResolves": 2}) + pendingMu.Lock() + counts := withdrawalUnits[unit] + if len(counts) != 2 || counts["DNSProbeFailed"] != 1 || counts["DNSStillResolves"] != 2 { + t.Errorf("unbounded reason labels: %#v", counts) + } + pendingMu.Unlock() + SetWithdrawalsPending(unit, nil) + pendingMu.Lock() + _, exists := withdrawalUnits[unit] + pendingMu.Unlock() + if exists { + t.Fatal("deleted unit retained pending metrics") + } +} diff --git a/internal/go/providers/hosted/controllers/zoneclass/controller.go b/internal/go/providers/hosted/controllers/zoneclass/controller.go new file mode 100644 index 0000000..9919aa9 --- /dev/null +++ b/internal/go/providers/hosted/controllers/zoneclass/controller.go @@ -0,0 +1,141 @@ +package zoneclass + +import ( + "context" + "encoding/json" + "errors" + "fmt" + + "github.com/appthrust/dns-api/internal/go/core/providercontract" + dnsv1alpha1 "github.com/appthrust/dns-api/pkg/go/api/dns/v1alpha1" + hostedv1alpha1 "github.com/appthrust/dns-api/pkg/go/api/hosted/v1alpha1" + "k8s.io/apimachinery/pkg/api/equality" + apierrors "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/api/meta" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/builder" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/handler" + "sigs.k8s.io/controller-runtime/pkg/predicate" + "sigs.k8s.io/controller-runtime/pkg/reconcile" +) + +type ZoneClassReconciler struct { + client.Client + // Reader provides real pagination for Provider dependency fan-out. + Reader client.Reader + ControllerName string + ProviderName string + ProviderVersion string +} + +func (r *ZoneClassReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { + var class dnsv1alpha1.ZoneClass + if err := r.Get(ctx, req.NamespacedName, &class); err != nil { + return ctrl.Result{}, client.IgnoreNotFound(err) + } + if class.Spec.ControllerName != controllerName(r.ControllerName) || !class.DeletionTimestamp.IsZero() { + return ctrl.Result{}, nil + } + var provider dnsv1alpha1.Provider + if err := r.Get(ctx, client.ObjectKey{Name: class.Spec.Provider.Name}, &provider); err != nil && !apierrors.IsNotFound(err) { + return ctrl.Result{}, err + } + status, reason, message := r.accept(&class, &provider) + base := class.DeepCopy() + meta.SetStatusCondition(&class.Status.Conditions, metav1.Condition{Type: "Accepted", Status: status, Reason: reason, Message: message, ObservedGeneration: class.Generation}) + if equality.Semantic.DeepEqual(base.Status, class.Status) { + return ctrl.Result{}, nil + } + return ctrl.Result{}, r.Status().Patch(ctx, &class, client.MergeFromWithOptions(base, client.MergeFromWithOptimisticLock{})) +} + +func (r *ZoneClassReconciler) accept(class *dnsv1alpha1.ZoneClass, provider *dnsv1alpha1.Provider) (metav1.ConditionStatus, string, string) { + binding := binding(r.ProviderName, r.ProviderVersion) + version, served := providercontract.ServedVersion(provider, class.Spec.Provider.Version) + if provider.Name != binding.Name || !served { + return metav1.ConditionFalse, "InvalidProvider", "ZoneClass provider is not handled by hosted controller" + } + storage, resolved := providercontract.StorageVersion(provider) + if !resolved { + return metav1.ConditionUnknown, "ProviderStorageVersionNotResolved", "provider storage version is not resolved" + } + if storage.Name != binding.Version || version.Identity != nil || storage.Identity != nil { + return metav1.ConditionFalse, "InvalidProvider", "hosted provider requires its configured credentialless storage version" + } + if class.Spec.IdentityRef != nil { + return metav1.ConditionFalse, "InvalidIdentityRef", "spec.identityRef must be omitted for hosted DNS" + } + if _, err := Parameters(provider, version, class); err != nil { + var payloadErr *providercontract.PayloadError + if errors.As(err, &payloadErr) && payloadErr.Kind == providercontract.ProviderContractNotReady { + return metav1.ConditionUnknown, "ProviderStorageVersionNotResolved", "provider storage version is not resolved" + } + return metav1.ConditionFalse, "InvalidParameters", err.Error() + } + return metav1.ConditionTrue, "Accepted", "ZoneClass is accepted by hosted controller" +} + +func (r *ZoneClassReconciler) SetupWithManager(mgr ctrl.Manager) error { + if r.Client == nil { + r.Client = mgr.GetClient() + } + r.Reader = mgr.GetAPIReader() + return ctrl.NewControllerManagedBy(mgr).Named("hosted-zoneclass"). + For(&dnsv1alpha1.ZoneClass{}, builder.WithPredicates(predicate.GenerationChangedPredicate{})). + Watches(&dnsv1alpha1.Provider{}, handler.EnqueueRequestsFromMapFunc(r.mapProvider), builder.WithPredicates(predicate.GenerationChangedPredicate{})).Complete(r) +} + +func (r *ZoneClassReconciler) mapProvider(ctx context.Context, obj client.Object) []reconcile.Request { + reader := r.Reader + if reader == nil { + reader = r.Client + } + var requests []reconcile.Request + continuation := "" + for { + var classes dnsv1alpha1.ZoneClassList + if err := reader.List(ctx, &classes, client.Limit(256), client.Continue(continuation)); err != nil { + return nil + } + for i := range classes.Items { + class := &classes.Items[i] + if class.Spec.Provider.Name == obj.GetName() && class.Spec.ControllerName == controllerName(r.ControllerName) { + requests = append(requests, reconcile.Request{NamespacedName: client.ObjectKeyFromObject(class)}) + } + } + continuation = classes.Continue + if continuation == "" { + return requests + } + } +} + +// Parameters converts and revalidates the payload against the Provider storage schema. +func Parameters(provider *dnsv1alpha1.Provider, version *dnsv1alpha1.ProviderVersion, class *dnsv1alpha1.ZoneClass) (hostedv1alpha1.HostedZoneClassParameters, error) { + var params hostedv1alpha1.HostedZoneClassParameters + raw, err := providercontract.ConvertZoneClassParametersToStorage(provider, version, class) + if err != nil { + return params, err + } + if err := json.Unmarshal(raw.Raw, ¶ms); err != nil { + return params, fmt.Errorf("invalid hosted parameters: %w", err) + } + return params, nil +} +func controllerName(name string) string { + if name == "" { + return hostedv1alpha1.ControllerName + } + return name +} +func binding(name, version string) dnsv1alpha1.ProviderReference { + if name == "" { + name = hostedv1alpha1.ProviderName + } + if version == "" { + version = hostedv1alpha1.ProviderVersion + } + return dnsv1alpha1.ProviderReference{Name: name, Version: version} +} diff --git a/internal/go/providers/hosted/controllers/zoneclass/controller_test.go b/internal/go/providers/hosted/controllers/zoneclass/controller_test.go new file mode 100644 index 0000000..e9fc7d1 --- /dev/null +++ b/internal/go/providers/hosted/controllers/zoneclass/controller_test.go @@ -0,0 +1,98 @@ +package zoneclass + +import ( + "context" + "encoding/json" + "testing" + + "github.com/appthrust/dns-api/internal/go/providers/hosted/manifest" + dnsv1alpha1 "github.com/appthrust/dns-api/pkg/go/api/dns/v1alpha1" + hostedv1alpha1 "github.com/appthrust/dns-api/pkg/go/api/hosted/v1alpha1" + "k8s.io/apimachinery/pkg/api/meta" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/client/fake" +) + +func classFixture() *dnsv1alpha1.ZoneClass { + return &dnsv1alpha1.ZoneClass{ObjectMeta: metav1.ObjectMeta{Namespace: "platform", Name: "hosted", Generation: 1}, Spec: dnsv1alpha1.ZoneClassSpec{Provider: dnsv1alpha1.ProviderReference{Name: hostedv1alpha1.ProviderName, Version: hostedv1alpha1.ProviderVersion}, ControllerName: hostedv1alpha1.ControllerName, Parameters: runtime.RawExtension{Raw: []byte(`{"nameServers":[{"name":"ns.example.test","addresses":["192.0.2.53"]}]}`)}}} +} + +func TestClassAcceptanceAndIdentityRejection(t *testing.T) { + provider, err := manifest.Provider() + if err != nil { + t.Fatal(err) + } + r := &ZoneClassReconciler{} + class := classFixture() + status, reason, message := r.accept(class, provider) + if status != metav1.ConditionTrue || reason != "Accepted" { + t.Fatalf("accept=%s/%s %s", status, reason, message) + } + for _, identity := range []string{`{}`, `{"name":"forbidden"}`} { + // JSON keeps this test source compatible with both sides of the optional-pointer cutover. + if err := json.Unmarshal([]byte(`{"spec":{"identityRef":`+identity+`}}`), class); err != nil { + t.Fatal(err) + } + status, reason, _ = r.accept(class, provider) + if status != metav1.ConditionFalse || reason != "InvalidIdentityRef" { + t.Fatalf("identity=%s accept=%s/%s", identity, status, reason) + } + } + class = classFixture() + class.Spec.Parameters.Raw = []byte(`{"nameServers":[{"name":"ns.example.test","addresses":[]}]}`) + if status, reason, _ := r.accept(class, provider); status != metav1.ConditionFalse || reason != "InvalidParameters" { + t.Fatalf("accept=%s/%s", status, reason) + } + provider.Spec.Versions[0].Storage = false + if status, reason, _ := r.accept(class, provider); status != metav1.ConditionUnknown || reason != "ProviderStorageVersionNotResolved" { + t.Fatalf("accept=%s/%s", status, reason) + } +} + +func TestClassScopeAndUnchangedStatus(t *testing.T) { + scheme := runtime.NewScheme() + if err := dnsv1alpha1.AddToScheme(scheme); err != nil { + t.Fatal(err) + } + provider, err := manifest.Provider() + if err != nil { + t.Fatal(err) + } + class := classFixture() + c := fake.NewClientBuilder().WithScheme(scheme).WithObjects(provider, class).WithStatusSubresource(&dnsv1alpha1.ZoneClass{}).Build() + r := &ZoneClassReconciler{Client: c} + request := ctrl.Request{NamespacedName: client.ObjectKeyFromObject(class)} + if _, err := r.Reconcile(context.Background(), request); err != nil { + t.Fatal(err) + } + if err := c.Get(context.Background(), request.NamespacedName, class); err != nil { + t.Fatal(err) + } + condition := meta.FindStatusCondition(class.Status.Conditions, "Accepted") + if condition == nil || condition.Status != metav1.ConditionTrue || condition.ObservedGeneration != 1 { + t.Fatalf("condition=%#v", condition) + } + version := class.ResourceVersion + if _, err := r.Reconcile(context.Background(), request); err != nil { + t.Fatal(err) + } + if err := c.Get(context.Background(), request.NamespacedName, class); err != nil { + t.Fatal(err) + } + if class.ResourceVersion != version { + t.Fatal("unchanged class status was patched") + } + r.ControllerName = "other.example/controller" + if _, err := r.Reconcile(context.Background(), request); err != nil { + t.Fatal(err) + } + if err := c.Get(context.Background(), request.NamespacedName, class); err != nil { + t.Fatal(err) + } + if class.ResourceVersion != version { + t.Fatal("foreign controller changed class") + } +} diff --git a/internal/go/providers/hosted/controllers/zoneunit/controller.go b/internal/go/providers/hosted/controllers/zoneunit/controller.go new file mode 100644 index 0000000..b3dbdbc --- /dev/null +++ b/internal/go/providers/hosted/controllers/zoneunit/controller.go @@ -0,0 +1,347 @@ +package zoneunit + +import ( + "context" + "encoding/json" + "fmt" + "math/rand/v2" + "os" + "time" + + "github.com/appthrust/dns-api/internal/go/core/declaration" + "github.com/appthrust/dns-api/internal/go/core/dnsprobe" + "github.com/appthrust/dns-api/internal/go/core/providercontract" + "github.com/appthrust/dns-api/internal/go/providers/hosted/controllers/zoneclass" + "github.com/appthrust/dns-api/internal/go/providers/hosted/render" + "github.com/appthrust/dns-api/internal/go/providers/hosted/withdrawal" + dnsv1alpha1 "github.com/appthrust/dns-api/pkg/go/api/dns/v1alpha1" + hostedv1alpha1 "github.com/appthrust/dns-api/pkg/go/api/hosted/v1alpha1" + "k8s.io/apimachinery/pkg/api/equality" + apierrors "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/api/meta" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" +) + +type Options struct { + ControllerName string + ProviderName string + ProviderVersion string + QPS float64 + Burst int + Resolver dnsprobe.Resolver + Evaluator withdrawal.Evaluator + // DefaultViews selects the hosted Service when class verification.views is empty. + // Unlike class views, explicit service hostnames are resolved before probing. + DefaultViews []string + Clock func() time.Time + // Jitter returns a value in [-1,1], scaled to +/-10 percent. + Jitter func() float64 +} + +type ZoneReconciler struct { + client.Client + Options Options + // Reader provides live policy fencing after DNS I/O and paginated watch fan-out. + Reader client.Reader + effectivePolicy func(dnsprobe.Policy) (dnsprobe.Policy, error) +} + +func NewReconciler(k8sClient client.Client, options Options) (*ZoneReconciler, error) { + if options.ControllerName == "" { + options.ControllerName = hostedv1alpha1.ControllerName + } + if options.ProviderName == "" { + options.ProviderName = hostedv1alpha1.ProviderName + } + if options.ProviderVersion == "" { + options.ProviderVersion = hostedv1alpha1.ProviderVersion + } + if options.Clock == nil { + options.Clock = time.Now + } + if options.Jitter == nil { + options.Jitter = func() float64 { return rand.Float64()*2 - 1 } + } + if options.Resolver == nil { + resolver, err := dnsprobe.NewWireResolver(dnsprobe.WireOptions{QPS: options.QPS, Burst: options.Burst, Provider: "hosted"}) + if err != nil { + return nil, err + } + options.Resolver = resolver + } + if options.Evaluator == nil { + options.Evaluator = withdrawal.New(options.Resolver, options.Clock) + } + if len(options.DefaultViews) == 0 { + namespace := os.Getenv("POD_NAMESPACE") + if namespace == "" { + namespace = "dns-api-system" + } + options.DefaultViews = []string{"dns-api-hosted-server." + namespace + ".svc:53"} + } + var effective func(dnsprobe.Policy) (dnsprobe.Policy, error) + if resolver, ok := options.Resolver.(interface { + EffectivePolicy(dnsprobe.Policy) (dnsprobe.Policy, error) + }); ok { + effective = resolver.EffectivePolicy + } else { + wire, err := dnsprobe.NewWireResolver(dnsprobe.WireOptions{QPS: options.QPS, Burst: options.Burst, Provider: "hosted"}) + if err != nil { + return nil, err + } + effective = wire.EffectivePolicy + } + return &ZoneReconciler{Client: k8sClient, Reader: k8sClient, Options: options, effectivePolicy: effective}, nil +} + +type environment struct { + class dnsv1alpha1.ZoneClass + provider dnsv1alpha1.Provider + params hostedv1alpha1.HostedZoneClassParameters + fingerprint string + delegationPolicy dnsprobe.Policy + delegationError error +} +type acceptance struct { + Status metav1.ConditionStatus + Reason, Message string +} + +func (r *ZoneReconciler) observe(ctx context.Context, reader client.Reader, unit *dnsv1alpha1.ZoneUnit) (*environment, *acceptance, error) { + env := &environment{} + ref := unit.Spec.Zone.ZoneClassRef + if err := reader.Get(ctx, client.ObjectKey{Namespace: ref.Namespace, Name: ref.Name}, &env.class); err != nil { + if apierrors.IsNotFound(err) { + return nil, &acceptance{metav1.ConditionFalse, "InvalidZoneClassRef", "referenced ZoneClass was not found"}, nil + } + return nil, nil, err + } + if env.class.Spec.ControllerName != r.Options.ControllerName { + return nil, nil, nil + } + if unit.Spec.Provider.Version != r.Options.ProviderVersion || env.class.Spec.Provider.Name != r.Options.ProviderName { + return nil, &acceptance{metav1.ConditionFalse, "InvalidProvider", "ZoneUnit provider is not handled by hosted controller"}, nil + } + if err := reader.Get(ctx, client.ObjectKey{Name: r.Options.ProviderName}, &env.provider); err != nil { + if apierrors.IsNotFound(err) { + return nil, &acceptance{metav1.ConditionFalse, "InvalidProvider", "referenced Provider was not found"}, nil + } + return nil, nil, err + } + version, served := providercontract.ServedVersion(&env.provider, env.class.Spec.Provider.Version) + storage, resolved := providercontract.StorageVersion(&env.provider) + if !resolved { + return nil, &acceptance{metav1.ConditionUnknown, "ProviderStorageVersionNotResolved", "provider storage version is not resolved"}, nil + } + if !served || storage.Name != r.Options.ProviderVersion || version.Identity != nil || storage.Identity != nil { + return nil, &acceptance{metav1.ConditionFalse, "InvalidProvider", "hosted provider requires its configured credentialless storage version"}, nil + } + if env.class.Spec.IdentityRef != nil { + return nil, &acceptance{metav1.ConditionFalse, "InvalidIdentityRef", "spec.identityRef must be omitted for hosted DNS"}, nil + } + params, err := zoneclass.Parameters(&env.provider, version, &env.class) + if err != nil { + return nil, &acceptance{metav1.ConditionFalse, "InvalidProviderPayload", boundedMessage(err.Error())}, nil + } + condition := meta.FindStatusCondition(env.class.Status.Conditions, "Accepted") + if !env.class.DeletionTimestamp.IsZero() || condition == nil || condition.Status != metav1.ConditionTrue || condition.ObservedGeneration != env.class.Generation { + return nil, &acceptance{metav1.ConditionFalse, "ZoneClassNotAccepted", "referenced ZoneClass is not accepted"}, nil + } + if allowed, err := declaration.ZoneAllowedByClass(ctx, readerClient{Client: r.Client, reader: reader}, &env.class, unit.Spec.Zone.Ref.Namespace); err != nil { + return nil, nil, err + } else if !allowed { + return nil, &acceptance{metav1.ConditionFalse, "NotAllowedByZoneClass", "Zone namespace is not allowed by ZoneClass"}, nil + } + env.params = params + env.delegationPolicy, env.delegationError = r.effectivePolicy(dnsprobe.Policy{Mode: params.Verification.Delegation.Resolver.Mode, Servers: params.Verification.Delegation.Resolver.Servers}) + env.fingerprint = dnsprobe.Fingerprint(struct { + ClassUID string + Class dnsv1alpha1.ZoneClassSpec + ProviderUID string + Provider dnsv1alpha1.ProviderSpec + Zone dnsv1alpha1.ZoneUnitZoneSpec + Delegation dnsprobe.Policy + DelegationFailed bool + }{string(env.class.UID), env.class.Spec, string(env.provider.UID), env.provider.Spec, unit.Spec.Zone, env.delegationPolicy, env.delegationError != nil}) + return env, nil, nil +} + +func (r *ZoneReconciler) Reconcile(ctx context.Context, request ctrl.Request) (ctrl.Result, error) { + var unit dnsv1alpha1.ZoneUnit + if err := r.Get(ctx, request.NamespacedName, &unit); err != nil { + if !apierrors.IsNotFound(err) { + return ctrl.Result{}, err + } + withdrawal.SetPending(request.NamespacedName.String(), nil) + return ctrl.Result{}, r.deleteSnapshot(ctx, request.NamespacedName) + } + if unit.Spec.Provider.Name != r.Options.ProviderName { + return ctrl.Result{}, r.deleteSnapshot(ctx, request.NamespacedName) + } + var snapshot hostedv1alpha1.HostedZone + err := r.Get(ctx, request.NamespacedName, &snapshot) + exists := err == nil + if err != nil && !apierrors.IsNotFound(err) { + return ctrl.Result{}, err + } + base := unit.DeepCopy() + now := r.Options.Clock() + if unit.Status.Zone == nil { + unit.Status.Zone = &dnsv1alpha1.ZoneUnitZoneStatus{} + } + if exists && !ownedSnapshot(&snapshot, &unit) { + if err := r.Delete(ctx, &snapshot, client.Preconditions{UID: &snapshot.UID, ResourceVersion: &snapshot.ResourceVersion}); err != nil && !apierrors.IsNotFound(err) { + return ctrl.Result{}, err + } + r.snapshotRejected(&unit, "HostedZone owner reference does not match ZoneUnit; the shadowing snapshot was deleted.", now) + return ctrl.Result{RequeueAfter: time.Second}, r.patch(ctx, base, &unit) + } + if !unit.DeletionTimestamp.IsZero() && exists { + if err := r.Delete(ctx, &snapshot, client.Preconditions{UID: &snapshot.UID, ResourceVersion: &snapshot.ResourceVersion}); err != nil && !apierrors.IsNotFound(err) { + return ctrl.Result{}, err + } + exists = false + } + env, rejected, err := r.observe(ctx, r.Client, &unit) + if err != nil { + return ctrl.Result{}, err + } + if rejected != nil { + r.reject(&unit, *rejected, now) + return ctrl.Result{RequeueAfter: 30 * time.Second}, r.patch(ctx, base, &unit) + } + if env == nil { + return ctrl.Result{}, nil + } + state := loadState(&unit) + if exists { + snapshot.Status.Servers = freshServers(snapshot.Status.Servers, now) + retainPublication(&state, snapshot.Spec, snapshot.Status.Servers, now) + } + renderUnit := unit + actualDigest := "" + if exists { + actualDigest = render.ContentDigest(snapshot.Spec) + renderUnit.Status.Zone = &dnsv1alpha1.ZoneUnitZoneStatus{Provider: providerData(map[string]any{"contentDigest": actualDigest})} + } + previous := uint32(0) + if exists { + previous = snapshot.Spec.Serial + } else if unit.Status.Zone.Provider != nil { + var data zoneData + if json.Unmarshal(unit.Status.Zone.Provider.Data.Raw, &data) == nil { + previous = data.Serial + } + } + desired, err := render.Render(&renderUnit, env.params, previous, now) + if err != nil { + r.snapshotRejected(&unit, err.Error(), now) + return ctrl.Result{RequeueAfter: 30 * time.Second}, r.patch(ctx, base, &unit) + } + if unit.DeletionTimestamp.IsZero() { + if !exists || snapshot.Spec.ContentDigest != desired.ContentDigest || actualDigest != desired.ContentDigest { + desiredSnapshot := &hostedv1alpha1.HostedZone{TypeMeta: metav1.TypeMeta{APIVersion: hostedv1alpha1.SchemeGroupVersion.String(), Kind: "HostedZone"}, ObjectMeta: metav1.ObjectMeta{Namespace: unit.Namespace, Name: unit.Name, OwnerReferences: []metav1.OwnerReference{*metav1.NewControllerRef(&unit, dnsv1alpha1.SchemeGroupVersion.WithKind("ZoneUnit"))}}, Spec: desired} + if exists { + desiredSnapshot.ResourceVersion = snapshot.ResourceVersion + } + if err := r.Patch(ctx, desiredSnapshot, client.Apply, client.FieldOwner("hosted-controller"), client.ForceOwnership); err != nil { + r.snapshotRejected(&unit, err.Error(), now) + if patchErr := r.patch(ctx, base, &unit); patchErr != nil { + return ctrl.Result{}, patchErr + } + return ctrl.Result{RequeueAfter: 30 * time.Second}, nil + } + // Keep observed acknowledgements, not a cache read after the successful apply. + snapshot.Spec = desired + snapshot.ObjectMeta = desiredSnapshot.ObjectMeta + exists = true + } + snapshot.Status.Servers = freshServers(snapshot.Status.Servers, now) + } else { + snapshot.Spec = desired + snapshot.Spec.Records = nil + snapshot.Status.Servers = nil + } + served := unit.DeletionTimestamp.IsZero() && snapshotServed(snapshot.Spec, snapshot.Status.Servers) + setResults(&unit, snapshot.Spec, snapshot.Status.Servers, served, now) + next := nextServerExpiry(snapshot.Status.Servers, now) + next = minDuration(next, r.observeDelegation(ctx, &unit, env, &state, now)) + withdrawalNext, err := r.reconcileWithdrawals(ctx, &unit, env, snapshot.Spec, snapshot.Status.Servers, &state, now) + if err != nil { + return ctrl.Result{}, err + } + next = minDuration(next, withdrawalNext) + if state.Probed { + fresh := &dnsv1alpha1.ZoneUnit{} + if err := r.Reader.Get(ctx, request.NamespacedName, fresh); err != nil { + return ctrl.Result{}, err + } + current, rejection, err := r.observe(ctx, r.Reader, fresh) + if err != nil { + return ctrl.Result{}, err + } + if fresh.UID != unit.UID || fresh.Generation != unit.Generation || !equality.Semantic.DeepEqual(fresh.Spec, unit.Spec) || !fresh.DeletionTimestamp.Equal(unit.DeletionTimestamp) || rejection != nil || current == nil || current.fingerprint != env.fingerprint { + return ctrl.Result{RequeueAfter: time.Second}, nil + } + } + saveState(&unit, state) + return ctrl.Result{RequeueAfter: next}, r.patch(ctx, base, &unit) +} + +func ownedSnapshot(snapshot *hostedv1alpha1.HostedZone, unit *dnsv1alpha1.ZoneUnit) bool { + owner := metav1.GetControllerOf(snapshot) + return owner != nil && owner.APIVersion == dnsv1alpha1.SchemeGroupVersion.String() && owner.Kind == "ZoneUnit" && owner.Name == unit.Name && owner.UID == unit.UID +} +func snapshotServed(spec hostedv1alpha1.HostedZoneSpec, servers []hostedv1alpha1.HostedZoneServerStatus) bool { + if len(servers) == 0 { + return false + } + for _, server := range servers { + if server.Serial != spec.Serial || server.ContentDigest != spec.ContentDigest { + return false + } + } + return true +} + +// ServerFreshness bounds authoritative serving acknowledgements independently +// of SOA refresh; servers renew observedAt every thirty seconds. +const ServerFreshness = 3 * time.Minute + +// Stale acknowledgements are ignored, never patched: each server is the sole +// writer of its own status entry. +func freshServers(servers []hostedv1alpha1.HostedZoneServerStatus, now time.Time) []hostedv1alpha1.HostedZoneServerStatus { + cutoff := now.Add(-ServerFreshness) + fresh := servers[:0] + for _, server := range servers { + if !server.ObservedAt.Time.Before(cutoff) && !server.ObservedAt.Time.After(now) { + fresh = append(fresh, server) + } + } + return fresh +} +func (r *ZoneReconciler) patch(ctx context.Context, base, unit *dnsv1alpha1.ZoneUnit) error { + unit.Status.ObservedGeneration = unit.Generation + if equality.Semantic.DeepEqual(base.Status, unit.Status) { + updatePending(unit) + return nil + } + if err := r.Status().Patch(ctx, unit, client.MergeFromWithOptions(base, client.MergeFromWithOptimisticLock{})); err != nil { + return err + } + updatePending(unit) + return nil +} +func pendingMessage(serial uint32) string { + return fmt.Sprintf("Snapshot serial %d is not yet served by every fresh hosted server.", serial) +} + +func (r *ZoneReconciler) deleteSnapshot(ctx context.Context, key client.ObjectKey) error { + var snapshot hostedv1alpha1.HostedZone + if err := r.Get(ctx, key, &snapshot); err != nil { + return client.IgnoreNotFound(err) + } + return client.IgnoreNotFound(r.Delete(ctx, &snapshot, client.Preconditions{UID: &snapshot.UID, ResourceVersion: &snapshot.ResourceVersion})) +} diff --git a/internal/go/providers/hosted/controllers/zoneunit/controller_test.go b/internal/go/providers/hosted/controllers/zoneunit/controller_test.go new file mode 100644 index 0000000..8e85413 --- /dev/null +++ b/internal/go/providers/hosted/controllers/zoneunit/controller_test.go @@ -0,0 +1,411 @@ +package zoneunit + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "slices" + "strings" + "testing" + "time" + + "github.com/appthrust/dns-api/internal/go/core/dnsprobe" + "github.com/appthrust/dns-api/internal/go/providers/hosted/manifest" + "github.com/appthrust/dns-api/internal/go/providers/hosted/withdrawal" + dnsv1alpha1 "github.com/appthrust/dns-api/pkg/go/api/dns/v1alpha1" + hostedv1alpha1 "github.com/appthrust/dns-api/pkg/go/api/hosted/v1alpha1" + corev1 "k8s.io/api/core/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/api/meta" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/types" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/client/fake" + "sigs.k8s.io/controller-runtime/pkg/event" +) + +// controller-runtime v0.23's fake does not implement CRD SSA. This adapter +// exercises the exact production Apply boundary and retains independent status. +// API-server merge ownership is intentionally not claimed by these fake tests. +type snapshotClient struct { + client.Client + applies int + reject error + t *testing.T +} + +func (c *snapshotClient) Patch(ctx context.Context, obj client.Object, patch client.Patch, opts ...client.PatchOption) error { + snapshot, ok := obj.(*hostedv1alpha1.HostedZone) + if !ok || patch.Type() != types.ApplyPatchType { + return c.Client.Patch(ctx, obj, patch, opts...) + } + c.applies++ + options := &client.PatchOptions{} + for _, option := range opts { + option.ApplyToPatch(options) + } + if options.FieldManager != "hosted-controller" { + c.t.Fatalf("fieldManager=%q", options.FieldManager) + } + if len(snapshot.Status.Servers) != 0 { + c.t.Fatal("controller applied server status") + } + if c.reject != nil { + return c.reject + } + var live hostedv1alpha1.HostedZone + err := c.Client.Get(ctx, client.ObjectKeyFromObject(obj), &live) + if apierrors.IsNotFound(err) { + return c.Client.Create(ctx, obj) + } + if err != nil { + return err + } + snapshot.ResourceVersion = live.ResourceVersion + snapshot.Status = live.Status + return c.Client.Update(ctx, obj) +} + +type fakeResolver struct { + calls int + answer func(dnsprobe.ProbeRequest) (dnsprobe.ProbeResult, error) +} + +func (f *fakeResolver) Probe(_ context.Context, in dnsprobe.ProbeRequest) (dnsprobe.ProbeResult, error) { + f.calls++ + if f.answer != nil { + return f.answer(in) + } + return dnsprobe.ProbeResult{}, nil +} + +func (f *fakeResolver) EffectivePolicy(policy dnsprobe.Policy) (dnsprobe.Policy, error) { + if policy.Mode == "" || policy.Mode == "Cluster" { + policy.Mode = "Cluster" + policy.Servers = []string{"192.0.2.53:53"} + } + return policy, nil +} + +type fakeEvaluator struct { + inputs []withdrawal.Input + evaluate func(withdrawal.Input) (withdrawal.Decision, error) +} + +func (f *fakeEvaluator) Evaluate(_ context.Context, in withdrawal.Input) (withdrawal.Decision, error) { + f.inputs = append(f.inputs, in) + if f.evaluate != nil { + return f.evaluate(in) + } + return withdrawal.Decision{Withdrawn: metav1.ConditionFalse, Ready: metav1.ConditionFalse, Reason: "NegativeTTLNotElapsed", Message: "cache horizon pending", NextProbe: in.Now.Add(30 * time.Second), State: &withdrawal.State{Identity: "test", LastProbe: in.Now}, RequiredViews: []string{"recursive/192.0.2.53:53"}, CheckedNames: []string{"www.example.test"}}, nil +} +func fixtureClass() *dnsv1alpha1.ZoneClass { + return &dnsv1alpha1.ZoneClass{ObjectMeta: metav1.ObjectMeta{Namespace: "app", Name: "hosted", UID: "class-1", Generation: 1}, Spec: dnsv1alpha1.ZoneClassSpec{ControllerName: hostedv1alpha1.ControllerName, Provider: dnsv1alpha1.ProviderReference{Name: hostedv1alpha1.ProviderName, Version: hostedv1alpha1.ProviderVersion}, Parameters: runtime.RawExtension{Raw: []byte(`{"nameServers":[{"name":"ns.example.test","addresses":["192.0.2.53"]}],"verification":{"views":["192.0.2.53:53"]}}`)}}, Status: dnsv1alpha1.ZoneClassStatus{Conditions: []metav1.Condition{{Type: "Accepted", Status: metav1.ConditionTrue, Reason: "Accepted", ObservedGeneration: 1, LastTransitionTime: metav1.NewTime(time.Unix(1, 0))}}}} +} +func fixtureUnit() *dnsv1alpha1.ZoneUnit { + ttl := int32(120) + return &dnsv1alpha1.ZoneUnit{ObjectMeta: metav1.ObjectMeta{Namespace: "app", Name: "zone", UID: "unit-1", Generation: 1}, Spec: dnsv1alpha1.ZoneUnitSpec{Provider: dnsv1alpha1.ProviderReference{Name: hostedv1alpha1.ProviderName, Version: hostedv1alpha1.ProviderVersion}, Zone: dnsv1alpha1.ZoneUnitZoneSpec{Ref: dnsv1alpha1.ObjectReference{Namespace: "app", Name: "zone"}, DomainName: "example.test", ObservedGeneration: 1, ZoneClassRef: dnsv1alpha1.ObjectReference{Namespace: "app", Name: "hosted"}, ZoneClassUID: "class-1", ZoneClassObservedGeneration: 1}, RecordSets: []dnsv1alpha1.ZoneUnitRecordSetSpec{{RecordSetNamespace: "app", RecordSetName: "www", RecordSetUID: "record-1", ObservedGeneration: 1, Name: "www", Type: dnsv1alpha1.RecordTypeA, TTL: &ttl, A: &dnsv1alpha1.ARecordSet{Addresses: []string{"192.0.2.10"}}}}}} +} +func newFixture(t *testing.T) (*ZoneReconciler, *snapshotClient, *time.Time, ctrl.Request) { + t.Helper() + scheme := runtime.NewScheme() + for _, add := range []func(*runtime.Scheme) error{dnsv1alpha1.AddToScheme, hostedv1alpha1.AddToScheme, corev1.AddToScheme} { + if err := add(scheme); err != nil { + t.Fatal(err) + } + } + provider, err := manifest.Provider() + if err != nil { + t.Fatal(err) + } + c := &snapshotClient{Client: fake.NewClientBuilder().WithScheme(scheme).WithObjects(provider, fixtureClass(), fixtureUnit()).WithStatusSubresource(&dnsv1alpha1.ZoneUnit{}, &dnsv1alpha1.ZoneClass{}, &hostedv1alpha1.HostedZone{}).Build(), t: t} + now := time.Unix(1000, 0).UTC() + r, err := NewReconciler(c, Options{Resolver: &fakeResolver{}, Evaluator: &fakeEvaluator{}, DefaultViews: []string{"192.0.2.53:53"}, Clock: func() time.Time { return now }, Jitter: func() float64 { return 0 }}) + if err != nil { + t.Fatal(err) + } + return r, c, &now, ctrl.Request{NamespacedName: client.ObjectKeyFromObject(fixtureUnit())} +} +func reconcileOnce(t *testing.T, r *ZoneReconciler, req ctrl.Request) (ctrl.Result, *dnsv1alpha1.ZoneUnit) { + t.Helper() + result, err := r.Reconcile(context.Background(), req) + if err != nil { + t.Fatal(err) + } + var unit dnsv1alpha1.ZoneUnit + if err := r.Get(context.Background(), req.NamespacedName, &unit); err != nil { + t.Fatal(err) + } + return result, &unit +} +func snapshotFor(t *testing.T, c client.Client, req ctrl.Request) *hostedv1alpha1.HostedZone { + t.Helper() + var snapshot hostedv1alpha1.HostedZone + if err := c.Get(context.Background(), req.NamespacedName, &snapshot); err != nil { + t.Fatal(err) + } + return &snapshot +} +func serve(t *testing.T, c client.Client, req ctrl.Request, now time.Time) { + t.Helper() + snapshot := snapshotFor(t, c, req) + snapshot.Status.Servers = []hostedv1alpha1.HostedZoneServerStatus{{Endpoint: "dns/server", Serial: snapshot.Spec.Serial, ContentDigest: snapshot.Spec.ContentDigest, ObservedAt: metav1.NewTime(now)}} + if err := c.Status().Update(context.Background(), snapshot); err != nil { + t.Fatal(err) + } +} +func assertCondition(t *testing.T, conditions []metav1.Condition, kind string, status metav1.ConditionStatus, reason string) { + t.Helper() + got := meta.FindStatusCondition(conditions, kind) + if got == nil || got.Status != status || got.Reason != reason { + t.Fatalf("%s=%+v want %s/%s", kind, got, status, reason) + } +} +func TestServingPendingProgrammedAndStableSerial(t *testing.T) { + r, c, now, req := newFixture(t) + _, unit := reconcileOnce(t, r, req) + assertCondition(t, unit.Status.Zone.Conditions, "Programmed", metav1.ConditionFalse, "ServingPending") + snapshot := snapshotFor(t, c, req) + if !ownedSnapshot(snapshot, unit) { + t.Fatal("snapshot owner missing") + } + if !slices.Equal(unit.Status.Zone.NameServers, []string{"ns.example.test"}) { + t.Fatal("nameservers missing") + } + if got := meta.FindStatusCondition(unit.Status.Zone.Conditions, "Programmed").Message; got != pendingMessage(snapshot.Spec.Serial) { + t.Fatal(got) + } + serve(t, c, req, *now) + _, unit = reconcileOnce(t, r, req) + assertCondition(t, unit.Status.Zone.Conditions, "Programmed", metav1.ConditionTrue, "Programmed") + assertCondition(t, unit.Status.RecordSets[0].Conditions, "Programmed", metav1.ConditionTrue, "Programmed") + rv := unit.ResourceVersion + _, stable := reconcileOnce(t, r, req) + if stable.ResourceVersion != rv || c.applies != 1 { + t.Fatalf("no-op changed rv/apply count: %s/%s %d\nbefore=%#v\nafter=%#v", rv, stable.ResourceVersion, c.applies, unit.Status, stable.Status) + } + sibling := unit.Spec.RecordSets[0] + sibling.RecordSetName = "other" + sibling.RecordSetUID = "other-uid" + sibling.Name = "other" + unit.Spec.RecordSets = append(unit.Spec.RecordSets, sibling) + unit.Generation++ + if err := c.Update(context.Background(), unit); err != nil { + t.Fatal(err) + } + _, unit = reconcileOnce(t, r, req) + assertCondition(t, unit.Status.Zone.Conditions, "Programmed", metav1.ConditionFalse, "ServingPending") + assertCondition(t, unit.Status.RecordSets[0].Conditions, "Programmed", metav1.ConditionTrue, "Programmed") + assertCondition(t, unit.Status.RecordSets[1].Conditions, "Programmed", metav1.ConditionFalse, "ServingPending") + if got := snapshotFor(t, c, req).Spec.Serial; got != snapshot.Spec.Serial+1 { + t.Fatalf("serial=%d", got) + } +} +func TestDeletionCompletedRequiresServedOmission(t *testing.T) { + r, c, now, req := newFixture(t) + reconcileOnce(t, r, req) + serve(t, c, req, *now) + _, unit := reconcileOnce(t, r, req) + unit.Spec.RecordSets[0].DeletionRequested = true + unit.Generation++ + if err := c.Update(context.Background(), unit); err != nil { + t.Fatal(err) + } + _, unit = reconcileOnce(t, r, req) + if unit.Status.RecordSets[0].DeletionCompleted { + t.Fatal("unserved omission completed cleanup") + } + snapshot := snapshotFor(t, c, req) + for _, record := range snapshot.Spec.Records { + if record.Name == "www" { + t.Fatal("ledger record retained") + } + } + serve(t, c, req, *now) + _, unit = reconcileOnce(t, r, req) + if !unit.Status.RecordSets[0].DeletionCompleted { + t.Fatal("served omission did not complete cleanup") + } + unit.Spec.RecordSets[0].RecordSetUID = "recreated" + unit.Spec.RecordSets[0].DeletionRequested = false + unit.Generation++ + if err := c.Update(context.Background(), unit); err != nil { + t.Fatal(err) + } + _, unit = reconcileOnce(t, r, req) + if unit.Status.RecordSets[0].DeletionCompleted { + t.Fatal("cleanup leaked across UID") + } +} +func TestSnapshotRejectionAndShadowDeletion(t *testing.T) { + r, c, _, req := newFixture(t) + c.reject = errors.New(strings.Repeat("æ‹’", 1000)) + _, unit := reconcileOnce(t, r, req) + assertCondition(t, unit.Status.Zone.Conditions, "Programmed", metav1.ConditionFalse, "SnapshotRejected") + if len(meta.FindStatusCondition(unit.Status.Zone.Conditions, "Programmed").Message) > 2048 { + t.Fatal("unbounded API error") + } + c.reject = nil + reconcileOnce(t, r, req) + snapshot := snapshotFor(t, c, req) + snapshot.OwnerReferences[0].UID = "foreign" + if err := c.Update(context.Background(), snapshot); err != nil { + t.Fatal(err) + } + _, unit = reconcileOnce(t, r, req) + assertCondition(t, unit.Status.Zone.Conditions, "Programmed", metav1.ConditionFalse, "SnapshotRejected") + if err := c.Get(context.Background(), req.NamespacedName, &hostedv1alpha1.HostedZone{}); !apierrors.IsNotFound(err) { + t.Fatalf("shadow not deleted: %v", err) + } + reconcileOnce(t, r, req) + if !ownedSnapshot(snapshotFor(t, c, req), unit) { + t.Fatal("shadow did not recover") + } +} +func TestStaleServersIgnoredWithoutStatusMutation(t *testing.T) { + r, c, now, req := newFixture(t) + reconcileOnce(t, r, req) + serve(t, c, req, *now) + snapshot := snapshotFor(t, c, req) + version := snapshot.ResourceVersion + *now = now.Add(ServerFreshness + time.Second) + _, unit := reconcileOnce(t, r, req) + assertCondition(t, unit.Status.Zone.Conditions, "Programmed", metav1.ConditionFalse, "ServingPending") + snapshot = snapshotFor(t, c, req) + if len(snapshot.Status.Servers) != 1 || snapshot.ResourceVersion != version { + t.Fatal("controller mutated server-owned status") + } + var data zoneData + if err := json.Unmarshal(unit.Status.Zone.Provider.Data.Raw, &data); err != nil { + t.Fatal(err) + } + if len(data.Servers) != 0 { + t.Fatal("stale server included in projection") + } + for i := range 70 { + snapshot.Status.Servers = append(snapshot.Status.Servers, hostedv1alpha1.HostedZoneServerStatus{Endpoint: fmt.Sprintf("dns/pod-%03d", i), Serial: snapshot.Spec.Serial, ContentDigest: snapshot.Spec.ContentDigest, ObservedAt: metav1.NewTime(*now)}) + } + if err := c.Status().Update(context.Background(), snapshot); err != nil { + t.Fatal(err) + } + _, unit = reconcileOnce(t, r, req) + _ = json.Unmarshal(unit.Status.Zone.Provider.Data.Raw, &data) + if len(data.Servers) != 64 { + t.Fatalf("preview=%d", len(data.Servers)) + } + assertCondition(t, unit.Status.Zone.Conditions, "Programmed", metav1.ConditionTrue, "Programmed") +} +func TestInvalidParametersAndDeletingUnit(t *testing.T) { + r, c, _, req := newFixture(t) + _, unit := reconcileOnce(t, r, req) + class := fixtureClass() + if err := c.Get(context.Background(), client.ObjectKeyFromObject(class), class); err != nil { + t.Fatal(err) + } + class.Spec.Parameters.Raw = []byte(`{"nameServers":[]}`) + if err := c.Update(context.Background(), class); err != nil { + t.Fatal(err) + } + _, unit = reconcileOnce(t, r, req) + assertCondition(t, unit.Status.Zone.Conditions, "Accepted", metav1.ConditionFalse, "InvalidProviderPayload") + unit.Finalizers = []string{"test/retain"} + if err := c.Update(context.Background(), unit); err != nil { + t.Fatal(err) + } + if err := c.Delete(context.Background(), unit); err != nil { + t.Fatal(err) + } + reconcileOnce(t, r, req) + if err := c.Get(context.Background(), req.NamespacedName, &hostedv1alpha1.HostedZone{}); !apierrors.IsNotFound(err) { + t.Fatalf("deleting unit retained snapshot: %v", err) + } +} +func TestSnapshotWatchPredicate(t *testing.T) { + old := &hostedv1alpha1.HostedZone{ObjectMeta: metav1.ObjectMeta{Generation: 1}} + current := old.DeepCopy() + p := snapshotPredicate() + if p.Update(event.UpdateEvent{ObjectOld: old, ObjectNew: current}) { + t.Fatal("unchanged status enqueued") + } + current.Status.Servers = []hostedv1alpha1.HostedZoneServerStatus{{Endpoint: "dns/server", Serial: 1}} + if !p.Update(event.UpdateEvent{ObjectOld: old, ObjectNew: current}) { + t.Fatal("serving acknowledgement lost") + } + old = current.DeepCopy() + current.Status.Servers[0].ObservedAt = metav1.NewTime(time.Unix(2, 0)) + if !p.Update(event.UpdateEvent{ObjectOld: old, ObjectNew: current}) { + t.Fatal("heartbeat affects staleness and must enqueue") + } +} + +func TestEveryFreshServerMustAcknowledge(t *testing.T) { + r, c, now, req := newFixture(t) + _, unit := reconcileOnce(t, r, req) + first := snapshotFor(t, c, req) + serve(t, c, req, *now) + _, unit = reconcileOnce(t, r, req) + unit.Spec.RecordSets[0].A.Addresses = []string{"192.0.2.20"} + unit.Spec.RecordSets[0].ObservedGeneration++ + unit.Generation++ + if err := c.Update(context.Background(), unit); err != nil { + t.Fatal(err) + } + reconcileOnce(t, r, req) + current := snapshotFor(t, c, req) + current.Status.Servers = []hostedv1alpha1.HostedZoneServerStatus{ + {Endpoint: "dns/fast", Serial: current.Spec.Serial, ContentDigest: current.Spec.ContentDigest, ObservedAt: metav1.NewTime(*now)}, + {Endpoint: "dns/slow", Serial: first.Spec.Serial, ContentDigest: first.Spec.ContentDigest, ObservedAt: metav1.NewTime(*now)}, + } + if err := c.Status().Update(context.Background(), current); err != nil { + t.Fatal(err) + } + _, unit = reconcileOnce(t, r, req) + assertCondition(t, unit.Status.Zone.Conditions, "Programmed", metav1.ConditionFalse, "ServingPending") + assertCondition(t, unit.Status.RecordSets[0].Conditions, "Programmed", metav1.ConditionFalse, "ServingPending") + current = snapshotFor(t, c, req) + current.Status.Servers[1].Serial = current.Spec.Serial + current.Status.Servers[1].ContentDigest = current.Spec.ContentDigest + if err := c.Status().Update(context.Background(), current); err != nil { + t.Fatal(err) + } + _, unit = reconcileOnce(t, r, req) + assertCondition(t, unit.Status.Zone.Conditions, "Programmed", metav1.ConditionTrue, "Programmed") + assertCondition(t, unit.Status.RecordSets[0].Conditions, "Programmed", metav1.ConditionTrue, "Programmed") +} + +func TestOrphanSnapshotDeleted(t *testing.T) { + r, c, _, req := newFixture(t) + orphan := &hostedv1alpha1.HostedZone{ObjectMeta: metav1.ObjectMeta{Namespace: req.Namespace, Name: "orphan"}} + if err := c.Create(context.Background(), orphan); err != nil { + t.Fatal(err) + } + orphanRequest := ctrl.Request{NamespacedName: client.ObjectKeyFromObject(orphan)} + if _, err := r.Reconcile(context.Background(), orphanRequest); err != nil { + t.Fatal(err) + } + if err := c.Get(context.Background(), orphanRequest.NamespacedName, &hostedv1alpha1.HostedZone{}); !apierrors.IsNotFound(err) { + t.Fatalf("orphan retained: %v", err) + } +} + +func TestDefaultDelegationAlwaysReported(t *testing.T) { + r, _, _, req := newFixture(t) + resolver := r.Options.Resolver.(*fakeResolver) + resolver.answer = func(in dnsprobe.ProbeRequest) (dnsprobe.ProbeResult, error) { + if in.Policy.Mode != "Cluster" || len(in.Policy.Servers) != 1 { + t.Fatalf("default delegation policy=%+v", in.Policy) + } + return dnsprobe.ProbeResult{Zone: &dnsprobe.ZoneEvidence{Views: []dnsprobe.AuthorityObservation{{Nameservers: []string{"NS.EXAMPLE.TEST."}}}}}, nil + } + _, unit := reconcileOnce(t, r, req) + var data zoneData + if err := json.Unmarshal(unit.Status.Zone.Provider.Data.Raw, &data); err != nil { + t.Fatal(err) + } + if data.Delegation == nil || !data.Delegation.Matches || data.Delegation.Error != "" || !slices.Equal(data.Delegation.ObservedNameServers, []string{"ns.example.test"}) { + t.Fatalf("delegation=%+v", data.Delegation) + } +} diff --git a/internal/go/providers/hosted/controllers/zoneunit/model.go b/internal/go/providers/hosted/controllers/zoneunit/model.go new file mode 100644 index 0000000..ed793bc --- /dev/null +++ b/internal/go/providers/hosted/controllers/zoneunit/model.go @@ -0,0 +1,238 @@ +package zoneunit + +import ( + "context" + "encoding/json" + "fmt" + "net" + "net/netip" + "reflect" + "slices" + "strings" + "time" + + "github.com/appthrust/dns-api/internal/go/core/dnsprobe" + "github.com/appthrust/dns-api/internal/go/providers/hosted/withdrawal" + dnsv1alpha1 "github.com/appthrust/dns-api/pkg/go/api/dns/v1alpha1" + hostedv1alpha1 "github.com/appthrust/dns-api/pkg/go/api/hosted/v1alpha1" + "sigs.k8s.io/controller-runtime/pkg/client" +) + +const MaxNamesPerPass = 32 + +type withdrawalSchedule struct { + Identity string `json:"identity"` + Policy string `json:"policy"` + Binding string `json:"binding,omitempty"` + OmissionSerial uint32 `json:"omissionSerial,omitempty"` + OmissionDigest string `json:"omissionDigest,omitempty"` + Next time.Time `json:"next"` + State *withdrawal.State `json:"state,omitempty"` +} +type schedulerState struct { + Withdrawals map[string]withdrawalSchedule `json:"withdrawals,omitempty"` + Publications map[string]time.Time `json:"publications,omitempty"` + Cursor string `json:"cursor,omitempty"` + DelegationPolicy string `json:"delegationPolicy,omitempty"` + DelegationNext time.Time `json:"delegationNext,omitempty"` + Probed bool `json:"-"` +} +type delegationData struct { + ObservedNameServers []string `json:"observedNameServers"` + Matches bool `json:"matches"` + LastProbeTime time.Time `json:"lastProbeTime"` + Error string `json:"error,omitempty"` +} + +func loadState(unit *dnsv1alpha1.ZoneUnit) schedulerState { + var state schedulerState + if unit.Status.Provider != nil { + _ = json.Unmarshal(unit.Status.Provider.State.Raw, &state) + } + if state.Withdrawals == nil { + state.Withdrawals = map[string]withdrawalSchedule{} + } + return state +} +func saveState(unit *dnsv1alpha1.ZoneUnit, state schedulerState) { + state.Probed = false + if len(state.Withdrawals) == 0 { + state.Withdrawals = nil + } + if len(state.Publications) == 0 { + state.Publications = nil + } + old := loadState(unit) + old.Probed = false + if len(old.Withdrawals) == 0 { + old.Withdrawals = nil + } + if reflect.DeepEqual(state, old) { + return + } + raw, _ := json.Marshal(state) + if unit.Status.Provider == nil { + unit.Status.Provider = &dnsv1alpha1.ProviderStatus{} + } + unit.Status.Provider.State.Raw = raw +} +func minDuration(a, b time.Duration) time.Duration { + if a == 0 { + return b + } + if b == 0 { + return a + } + return min(a, b) +} +func nextServerExpiry(servers []hostedv1alpha1.HostedZoneServerStatus, now time.Time) time.Duration { + var next time.Duration + for _, server := range servers { + next = minDuration(next, max(time.Second, server.ObservedAt.Add(ServerFreshness).Sub(now)+time.Second)) + } + return next +} +func (r *ZoneReconciler) observeDelegation(ctx context.Context, unit *dnsv1alpha1.ZoneUnit, env *environment, state *schedulerState, now time.Time) time.Duration { + if state.DelegationPolicy == env.fingerprint && state.DelegationNext.After(now) { + return state.DelegationNext.Sub(now) + } + expected := make([]string, len(env.params.NameServers)) + for i, server := range env.params.NameServers { + expected[i] = server.Name + } + expected = dnsprobe.Names(expected) + policy := env.delegationPolicy + policy.ExpectedNameservers = expected + request := dnsprobe.ProbeRequest{Name: unit.Spec.Zone.DomainName, Zone: unit.Spec.Zone.DomainName, ZoneOnly: true, DueTime: now, Policy: policy} + var observed dnsprobe.ProbeResult + err := env.delegationError + if err == nil { + observed, err = r.Options.Resolver.Probe(ctx, request) + } + data := delegationData{ObservedNameServers: []string{}, LastProbeTime: r.Options.Clock()} + if err != nil || observed.Zone == nil || len(observed.Zone.Views) != len(policy.Servers) { + data.Error = "DNSProbeFailed" + } + if observed.Zone != nil { + for _, view := range observed.Zone.Views { + data.ObservedNameServers = append(data.ObservedNameServers, view.Nameservers...) + if view.Failure != "" { + data.Error = "DNSProbeFailed" + } + } + } + data.ObservedNameServers = dnsprobe.Names(data.ObservedNameServers) + data.Matches = data.Error == "" && slices.Equal(data.ObservedNameServers, expected) + if len(data.ObservedNameServers) > 64 { + data.ObservedNameServers = data.ObservedNameServers[:64] + } + var public zoneData + _ = json.Unmarshal(unit.Status.Zone.Provider.Data.Raw, &public) + public.Delegation = &data + unit.Status.Zone.Provider = providerData(public) + state.DelegationPolicy = env.fingerprint + state.DelegationNext = data.LastProbeTime.Add(300 * time.Second) + state.Probed = true + return state.DelegationNext.Sub(now) +} + +// Explicit service discovery is only for the configured hosted Service, never a +// fallback for DNS observations. Evaluation receives concrete, bounded endpoints. +func resolveViews(ctx context.Context, configured []string) ([]string, error) { + ctx, cancel := context.WithTimeout(ctx, 5*time.Second) + defer cancel() + var views []string + for _, endpoint := range configured { + if address, err := netip.ParseAddrPort(endpoint); err == nil && address.Port() != 0 { + views = append(views, address.String()) + continue + } + host, port, err := net.SplitHostPort(endpoint) + if err != nil { + return nil, fmt.Errorf("invalid hosted service endpoint") + } + addresses, err := net.DefaultResolver.LookupIP(ctx, "ip", host) + if err != nil { + return nil, fmt.Errorf("hosted service discovery failed") + } + for _, address := range addresses { + views = append(views, net.JoinHostPort(address.String(), port)) + } + } + slices.Sort(views) + views = slices.Compact(views) + if len(views) == 0 || len(views) > 3 { + return nil, fmt.Errorf("hosted verification requires 1 through 3 resolved endpoints") + } + return views, nil +} + +type readerClient struct { + client.Client + reader client.Reader +} + +func (c readerClient) Get(ctx context.Context, key client.ObjectKey, obj client.Object, opts ...client.GetOption) error { + if c.reader == nil { + return c.Client.Get(ctx, key, obj, opts...) + } + return c.reader.Get(ctx, key, obj, opts...) +} + +// Retain the positive-cache horizon of each served owner/address, including +// CNAME-chain TTLs. A later claim receives only its exact name/address horizon. +func retainPublication(state *schedulerState, spec hostedv1alpha1.HostedZoneSpec, servers []hostedv1alpha1.HostedZoneServerStatus, now time.Time) { + for key, expires := range state.Publications { + if !expires.After(now) { + delete(state.Publications, key) + } + } + // A single replica can populate caches before the all-replica serving gate + // converges, so historical positive bounds deliberately use any exact ack. + if !slices.ContainsFunc(servers, func(server hostedv1alpha1.HostedZoneServerStatus) bool { + return server.Serial == spec.Serial && server.ContentDigest == spec.ContentDigest + }) { + return + } + byName := make(map[string][]hostedv1alpha1.HostedZoneRecord, len(spec.Records)) + for _, record := range spec.Records { + byName[record.Name] = append(byName[record.Name], record) + } + if state.Publications == nil { + state.Publications = map[string]time.Time{} + } + for name := range byName { + current := name + ttl := uint32(0) + for range 9 { + next := "" + for _, record := range byName[current] { + ttl = max(ttl, record.TTL) + if record.Type == dnsv1alpha1.RecordTypeCNAME && len(record.Values) == 1 { + target := dnsprobe.CanonicalName(record.Values[0]) + if target == spec.DomainName { + next = "@" + } else if strings.HasSuffix(target, "."+spec.DomainName) { + next = strings.TrimSuffix(target, "."+spec.DomainName) + } + } + } + for _, record := range byName[current] { + if record.Type != dnsv1alpha1.RecordTypeA && record.Type != dnsv1alpha1.RecordTypeAAAA { + continue + } + for _, address := range record.Values { + key := name + "/" + address + expires := now.Add(time.Duration(ttl) * time.Second) + if expires.After(state.Publications[key]) { + state.Publications[key] = expires + } + } + } + if next == "" { + break + } + current = next + } + } +} diff --git a/internal/go/providers/hosted/controllers/zoneunit/status.go b/internal/go/providers/hosted/controllers/zoneunit/status.go new file mode 100644 index 0000000..aa9d8a8 --- /dev/null +++ b/internal/go/providers/hosted/controllers/zoneunit/status.go @@ -0,0 +1,200 @@ +package zoneunit + +import ( + "encoding/json" + "slices" + "strings" + "time" + "unicode/utf8" + + "github.com/appthrust/dns-api/internal/go/core/dnsprobe" + dnsv1alpha1 "github.com/appthrust/dns-api/pkg/go/api/dns/v1alpha1" + hostedv1alpha1 "github.com/appthrust/dns-api/pkg/go/api/hosted/v1alpha1" + "k8s.io/apimachinery/pkg/api/equality" + "k8s.io/apimachinery/pkg/api/meta" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" +) + +func setCondition(conditions *[]metav1.Condition, kind string, status metav1.ConditionStatus, reason, message string, generation int64, now time.Time) { + meta.SetStatusCondition(conditions, metav1.Condition{Type: kind, Status: status, Reason: reason, Message: boundedMessage(message), ObservedGeneration: generation, LastTransitionTime: metav1.NewTime(now)}) +} +func boundedMessage(message string) string { + if len(message) <= 2048 { + return message + } + message = message[:2048] + for !utf8.ValidString(message) { + message = message[:len(message)-1] + } + return message +} +func providerData(value any) *dnsv1alpha1.ProviderStatus { + raw, _ := json.Marshal(value) + return &dnsv1alpha1.ProviderStatus{Data: runtime.RawExtension{Raw: raw}} +} +func timePointer(now time.Time) *metav1.Time { value := metav1.NewTime(now); return &value } + +type recordReceipt struct { + Fingerprint string `json:"fingerprint"` + FirstSerial uint32 `json:"firstSerial"` + ContentDigest string `json:"contentDigest"` + ServedAt *metav1.Time `json:"servedAt,omitempty"` +} +type serverData struct { + Endpoint string `json:"endpoint"` + Serial uint32 `json:"serial"` + ContentDigest string `json:"contentDigest"` + ObservedAt metav1.Time `json:"observedAt"` +} +type zoneData struct { + Serial uint32 `json:"serial"` + ContentDigest string `json:"contentDigest"` + Servers []serverData `json:"servers"` + Delegation *delegationData `json:"delegation,omitempty"` +} + +func setResults(unit *dnsv1alpha1.ZoneUnit, spec hostedv1alpha1.HostedZoneSpec, servers []hostedv1alpha1.HostedZoneServerStatus, served bool, now time.Time) { + zone := unit.Status.Zone + var data zoneData + if zone.Provider != nil { + _ = json.Unmarshal(zone.Provider.Data.Raw, &data) + } + previousData := data + data.Serial, data.ContentDigest = spec.Serial, spec.ContentDigest + servers = slices.Clone(servers) + slices.SortFunc(servers, func(a, b hostedv1alpha1.HostedZoneServerStatus) int { return strings.Compare(a.Endpoint, b.Endpoint) }) + data.Servers = make([]serverData, 0, min(64, len(servers))) + for _, server := range servers[:min(64, len(servers))] { + data.Servers = append(data.Servers, serverData{Endpoint: server.Endpoint, Serial: server.Serial, ContentDigest: server.ContentDigest, ObservedAt: server.ObservedAt}) + } + if zone.Provider == nil || !equality.Semantic.DeepEqual(previousData, data) { + zone.Provider = providerData(data) + } + zone.NameServers = make([]string, len(spec.NameServers)) + for i, server := range spec.NameServers { + zone.NameServers[i] = server.Name + } + setCondition(&zone.Conditions, "Accepted", metav1.ConditionTrue, "Accepted", "Hosted DNS Zone binding is accepted.", unit.Spec.Zone.ObservedGeneration, now) + status, reason, message := metav1.ConditionFalse, hostedv1alpha1.ReasonServingPending, pendingMessage(spec.Serial) + if served { + status, reason, message = metav1.ConditionTrue, "Programmed", "Hosted snapshot is served by every fresh hosted server." + } + setCondition(&zone.Conditions, "Programmed", status, reason, message, unit.Spec.Zone.ObservedGeneration, now) + prepareRecordReceipts(unit) + for i, item := range unit.Spec.RecordSets { + result := unit.Status.RecordSets[i] + var receipt recordReceipt + if result.Provider != nil { + _ = json.Unmarshal(result.Provider.State.Raw, &receipt) + } + previousReceipt := receipt + fingerprint := dnsprobe.Fingerprint(item) + if receipt.Fingerprint != fingerprint { + receipt = recordReceipt{Fingerprint: fingerprint, FirstSerial: spec.Serial, ContentDigest: spec.ContentDigest} + } + // Every fresh server must have reached a revision that contains this + // generation (or omits its cleanup item), not just the fastest replica. + itemServed := revisionServed(receipt.FirstSerial, receipt.ContentDigest, spec, servers) + if itemServed && receipt.ServedAt == nil { + receipt.ServedAt = timePointer(now) + } + result.DeletionCompleted = item.DeletionRequested && item.RecordSetUID != "" && itemServed + priorCondition := meta.FindStatusCondition(result.Conditions, "Programmed") + priorServed := priorCondition != nil && priorCondition.Status == metav1.ConditionTrue + if result.Provider == nil || !equality.Semantic.DeepEqual(receipt, previousReceipt) || priorServed != itemServed { + data := struct { + Serial uint32 `json:"serial"` + ServedAt *metav1.Time `json:"servedAt,omitempty"` + }{Serial: receipt.FirstSerial} + if itemServed { + data.ServedAt = receipt.ServedAt + } + result.Provider = providerData(data) + raw, _ := json.Marshal(receipt) + result.Provider.State.Raw = raw + } + setCondition(&result.Conditions, "Accepted", metav1.ConditionTrue, "Accepted", "Hosted DNS record intent is accepted.", item.ObservedGeneration, now) + recordStatus, recordReason, recordMessage := metav1.ConditionFalse, hostedv1alpha1.ReasonServingPending, pendingMessage(receipt.FirstSerial) + if itemServed { + recordStatus, recordReason, recordMessage = metav1.ConditionTrue, "Programmed", "Hosted record revision is served by every fresh hosted server." + } + if !item.IsAllowed() && !item.DeletionRequested { + recordStatus, recordReason, recordMessage = metav1.ConditionUnknown, "Reconciling", "Record intent is not allowed by composition policy." + setCondition(&result.Conditions, "Accepted", metav1.ConditionFalse, "DeniedByPolicy", recordMessage, item.ObservedGeneration, now) + } + setCondition(&result.Conditions, "Programmed", recordStatus, recordReason, recordMessage, item.ObservedGeneration, now) + unit.Status.RecordSets[i] = result + } + aggregateReason := "ProviderChangePending" + if served { + aggregateReason = "Programmed" + } + setCondition(&unit.Status.Conditions, "Programmed", status, aggregateReason, message, unit.Generation, now) +} + +func (r *ZoneReconciler) snapshotRejected(unit *dnsv1alpha1.ZoneUnit, message string, now time.Time) { + setCondition(&unit.Status.Zone.Conditions, "Accepted", metav1.ConditionTrue, "Accepted", "Hosted DNS Zone binding is accepted.", unit.Spec.Zone.ObservedGeneration, now) + setCondition(&unit.Status.Zone.Conditions, "Programmed", metav1.ConditionFalse, hostedv1alpha1.ReasonSnapshotRejected, message, unit.Spec.Zone.ObservedGeneration, now) + setCondition(&unit.Status.Conditions, "Programmed", metav1.ConditionFalse, "ProviderInvalidRequest", message, unit.Generation, now) + // No new record or withdrawal success can be issued for a rejected snapshot. + prepareRecordReceipts(unit) + for i := range unit.Status.RecordSets { + unit.Status.RecordSets[i].DeletionCompleted = false + setCondition(&unit.Status.RecordSets[i].Conditions, "Accepted", metav1.ConditionTrue, "Accepted", "Hosted DNS record intent is accepted.", unit.Status.RecordSets[i].ObservedGeneration, now) + setCondition(&unit.Status.RecordSets[i].Conditions, "Programmed", metav1.ConditionFalse, hostedv1alpha1.ReasonServingPending, "Hosted snapshot was rejected.", unit.Status.RecordSets[i].ObservedGeneration, now) + } + rejectWithdrawals(unit, now) +} +func (r *ZoneReconciler) reject(unit *dnsv1alpha1.ZoneUnit, rejected acceptance, now time.Time) { + unit.Status.Zone.Provider = nil + unit.Status.Zone.NameServers = nil + setCondition(&unit.Status.Zone.Conditions, "Accepted", rejected.Status, rejected.Reason, rejected.Message, unit.Spec.Zone.ObservedGeneration, now) + setCondition(&unit.Status.Zone.Conditions, "Programmed", metav1.ConditionUnknown, "Reconciling", rejected.Message, unit.Spec.Zone.ObservedGeneration, now) + setCondition(&unit.Status.Conditions, "Programmed", metav1.ConditionUnknown, "Reconciling", rejected.Message, unit.Generation, now) + prepareRecordReceipts(unit) + for i := range unit.Status.RecordSets { + result := &unit.Status.RecordSets[i] + result.DeletionCompleted = false + setCondition(&result.Conditions, "Accepted", metav1.ConditionFalse, "ZoneNotAccepted", rejected.Message, result.ObservedGeneration, now) + setCondition(&result.Conditions, "Programmed", metav1.ConditionUnknown, "Reconciling", rejected.Message, result.ObservedGeneration, now) + } + rejectWithdrawals(unit, now) +} + +func revisionServed(first uint32, digest string, current hostedv1alpha1.HostedZoneSpec, servers []hostedv1alpha1.HostedZoneServerStatus) bool { + if first == 0 || len(servers) == 0 { + return false + } + for _, server := range servers { + if server.Serial < first || server.Serial > current.Serial || server.ContentDigest == "" { + return false + } + if server.Serial == first && server.ContentDigest != digest { + return false + } + if server.Serial == current.Serial && server.ContentDigest != current.ContentDigest { + return false + } + } + return true +} + +func prepareRecordReceipts(unit *dnsv1alpha1.ZoneUnit) { + old := make(map[string]dnsv1alpha1.ZoneUnitRecordSetStatus, len(unit.Status.RecordSets)) + for _, item := range unit.Status.RecordSets { + old[item.RecordSetNamespace+"/"+item.RecordSetName] = item + } + unit.Status.RecordSets = nil + if len(unit.Spec.RecordSets) > 0 { + unit.Status.RecordSets = make([]dnsv1alpha1.ZoneUnitRecordSetStatus, len(unit.Spec.RecordSets)) + } + for i, item := range unit.Spec.RecordSets { + status := old[item.RecordSetNamespace+"/"+item.RecordSetName] + if status.RecordSetUID != item.RecordSetUID || status.ObservedGeneration != item.ObservedGeneration { + status = dnsv1alpha1.ZoneUnitRecordSetStatus{} + } + status.RecordSetNamespace, status.RecordSetName, status.RecordSetUID, status.ObservedGeneration = item.RecordSetNamespace, item.RecordSetName, item.RecordSetUID, item.ObservedGeneration + unit.Status.RecordSets[i] = status + } +} diff --git a/internal/go/providers/hosted/controllers/zoneunit/watches.go b/internal/go/providers/hosted/controllers/zoneunit/watches.go new file mode 100644 index 0000000..9898398 --- /dev/null +++ b/internal/go/providers/hosted/controllers/zoneunit/watches.go @@ -0,0 +1,80 @@ +package zoneunit + +import ( + "context" + + dnsv1alpha1 "github.com/appthrust/dns-api/pkg/go/api/dns/v1alpha1" + hostedv1alpha1 "github.com/appthrust/dns-api/pkg/go/api/hosted/v1alpha1" + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/api/equality" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/builder" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/controller" + "sigs.k8s.io/controller-runtime/pkg/event" + "sigs.k8s.io/controller-runtime/pkg/handler" + "sigs.k8s.io/controller-runtime/pkg/predicate" + "sigs.k8s.io/controller-runtime/pkg/reconcile" +) + +func (r *ZoneReconciler) SetupWithManager(mgr ctrl.Manager) error { + r.Reader = mgr.GetAPIReader() + return ctrl.NewControllerManagedBy(mgr).Named("hosted-zoneunit"). + WithOptions(controller.Options{MaxConcurrentReconciles: 4}). + For(&dnsv1alpha1.ZoneUnit{}, builder.WithPredicates(predicate.Funcs{UpdateFunc: func(e event.UpdateEvent) bool { + return e.ObjectOld.GetGeneration() != e.ObjectNew.GetGeneration() || !e.ObjectOld.GetDeletionTimestamp().Equal(e.ObjectNew.GetDeletionTimestamp()) + }})). + Watches(&hostedv1alpha1.HostedZone{}, handler.EnqueueRequestsFromMapFunc(func(_ context.Context, obj client.Object) []reconcile.Request { + return []reconcile.Request{{NamespacedName: client.ObjectKeyFromObject(obj)}} + }), builder.WithPredicates(snapshotPredicate())). + Watches(&dnsv1alpha1.ZoneClass{}, handler.EnqueueRequestsFromMapFunc(func(ctx context.Context, obj client.Object) []reconcile.Request { + return r.mapUnits(ctx, func(unit *dnsv1alpha1.ZoneUnit) bool { + ref := unit.Spec.Zone.ZoneClassRef + return ref.Namespace == obj.GetNamespace() && ref.Name == obj.GetName() + }) + })). + Watches(&dnsv1alpha1.Provider{}, handler.EnqueueRequestsFromMapFunc(func(ctx context.Context, obj client.Object) []reconcile.Request { + return r.mapUnits(ctx, func(unit *dnsv1alpha1.ZoneUnit) bool { return unit.Spec.Provider.Name == obj.GetName() }) + }), builder.WithPredicates(predicate.GenerationChangedPredicate{})). + Watches(&corev1.Namespace{}, handler.EnqueueRequestsFromMapFunc(func(ctx context.Context, obj client.Object) []reconcile.Request { + return r.mapUnits(ctx, func(unit *dnsv1alpha1.ZoneUnit) bool { return unit.Spec.Zone.Ref.Namespace == obj.GetName() }) + }), builder.WithPredicates(predicate.LabelChangedPredicate{})). + Complete(r) +} +func snapshotPredicate() predicate.Predicate { + return predicate.Funcs{UpdateFunc: func(e event.UpdateEvent) bool { + old, ok := e.ObjectOld.(*hostedv1alpha1.HostedZone) + if !ok { + return false + } + current, ok := e.ObjectNew.(*hostedv1alpha1.HostedZone) + if !ok { + return false + } + return old.Generation != current.Generation || !old.DeletionTimestamp.Equal(current.DeletionTimestamp) || !equality.Semantic.DeepEqual(old.OwnerReferences, current.OwnerReferences) || !equality.Semantic.DeepEqual(old.Status.Servers, current.Status.Servers) + }} +} +func (r *ZoneReconciler) mapUnits(ctx context.Context, matches func(*dnsv1alpha1.ZoneUnit) bool) []reconcile.Request { + reader := r.Reader + if reader == nil { + reader = r.Client + } + var requests []reconcile.Request + continuation := "" + for { + var units dnsv1alpha1.ZoneUnitList + if err := reader.List(ctx, &units, client.Limit(256), client.Continue(continuation)); err != nil { + return nil + } + for i := range units.Items { + unit := &units.Items[i] + if unit.Spec.Provider.Name == r.Options.ProviderName && matches(unit) { + requests = append(requests, reconcile.Request{NamespacedName: client.ObjectKeyFromObject(unit)}) + } + } + continuation = units.Continue + if continuation == "" { + return requests + } + } +} diff --git a/internal/go/providers/hosted/controllers/zoneunit/withdrawal.go b/internal/go/providers/hosted/controllers/zoneunit/withdrawal.go new file mode 100644 index 0000000..6087102 --- /dev/null +++ b/internal/go/providers/hosted/controllers/zoneunit/withdrawal.go @@ -0,0 +1,314 @@ +package zoneunit + +import ( + "context" + "net/netip" + "slices" + "strings" + "time" + + "github.com/appthrust/dns-api/internal/go/core/dnsprobe" + "github.com/appthrust/dns-api/internal/go/providers/hosted/withdrawal" + dnsv1alpha1 "github.com/appthrust/dns-api/pkg/go/api/dns/v1alpha1" + hostedv1alpha1 "github.com/appthrust/dns-api/pkg/go/api/hosted/v1alpha1" + "k8s.io/apimachinery/pkg/api/meta" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +func withdrawalKey(item dnsv1alpha1.ZoneUnitWithdrawal) string { + return item.ClaimNamespace + "/" + item.ClaimName +} +func withdrawalIdentity(unit *dnsv1alpha1.ZoneUnit, item dnsv1alpha1.ZoneUnitWithdrawal) string { + return dnsprobe.Fingerprint(struct { + UnitUID string + Zone dnsv1alpha1.ObjectReference + Claim dnsv1alpha1.WithdrawalObjectReference + Generation int64 + Name, Address string + }{string(unit.UID), unit.Spec.Zone.Ref, item.Claim, item.ObservedGeneration, item.Name, item.Address}) +} +func validWithdrawal(unit *dnsv1alpha1.ZoneUnit, item dnsv1alpha1.ZoneUnitWithdrawal) bool { + address, err := netip.ParseAddr(item.Address) + name := unit.Spec.Zone.DomainName + if item.Name != "@" && item.Name != "*" { + name = strings.TrimPrefix(item.Name, "*.") + "." + name + } + return err == nil && address.Zone() == "" && address.Unmap().String() == item.Address && item.Claim.UID != "" && item.ClaimNamespace != "" && item.ClaimName != "" && item.ClaimNamespace == item.Claim.Namespace && item.ClaimName == item.Claim.Name && item.ObservedGeneration > 0 && dnsprobe.ValidName(name) +} +func invalidateState(state *withdrawal.State) { + if state != nil { + state.AbsentSince = nil + state.NotBefore = nil + state.VerifiedAt = nil + state.WindowStart = time.Time{} + state.Negative = nil + } +} +func invalidateStatus(status *dnsv1alpha1.ZoneUnitWithdrawalStatus) { + status.AbsentSince = nil + status.NotBefore = nil + status.VerifiedAt = nil + status.ResultDigest = "" +} + +func (r *ZoneReconciler) reconcileWithdrawals(ctx context.Context, unit *dnsv1alpha1.ZoneUnit, env *environment, snapshot hostedv1alpha1.HostedZoneSpec, servers []hostedv1alpha1.HostedZoneServerStatus, state *schedulerState, now time.Time) (time.Duration, error) { + if len(unit.Spec.Withdrawals) == 0 { + unit.Status.Withdrawals = nil + state.Withdrawals = nil + state.Cursor = "" + return 0, nil + } + old := make(map[string]dnsv1alpha1.ZoneUnitWithdrawalStatus, len(unit.Status.Withdrawals)) + for _, status := range unit.Status.Withdrawals { + old[status.ClaimNamespace+"/"+status.ClaimName] = status + } + unit.Status.Withdrawals = make([]dnsv1alpha1.ZoneUnitWithdrawalStatus, len(unit.Spec.Withdrawals)) + active := make(map[string]bool, len(unit.Spec.Withdrawals)) + var indices []int + for i, item := range unit.Spec.Withdrawals { + key := withdrawalKey(item) + active[key] = true + status := old[key] + stored := state.Withdrawals[key] + identity := withdrawalIdentity(unit, item) + if status.Claim != item.Claim || status.ObservedGeneration != item.ObservedGeneration { + status = dnsv1alpha1.ZoneUnitWithdrawalStatus{} + } + if stored.Identity != identity { + missing := stored.Identity == "" && status.LastProbeTime != nil + stored = withdrawalSchedule{Identity: identity} + seed := &withdrawal.State{MissingHistory: missing} + if expires := state.Publications[item.Name+"/"+item.Address]; expires.After(now) { + seed.Positive = map[string]time.Time{"publication": expires} + } + if missing || len(seed.Positive) > 0 { + stored.State = seed + } + invalidateStatus(&status) + } + if withdrawal.SnapshotExcludes(snapshot, item) { + if stored.OmissionSerial == 0 { + stored.OmissionSerial, stored.OmissionDigest = snapshot.Serial, snapshot.ContentDigest + } + } else { + stored.OmissionSerial = 0 + stored.OmissionDigest = "" + } + served := revisionServed(stored.OmissionSerial, stored.OmissionDigest, snapshot, servers) + fingerprint := withdrawalFingerprint(env.fingerprint, snapshot.ContentDigest, served, item.Waiver) + status.ClaimNamespace, status.ClaimName, status.Claim, status.ObservedGeneration = item.ClaimNamespace, item.ClaimName, item.Claim, item.ObservedGeneration + status.SampledWildcard = item.Name == "*" || strings.HasPrefix(item.Name, "*.") + if !item.Allowed || !validWithdrawal(unit, item) || unit.Spec.Zone.ZoneClassUID != env.class.UID || unit.Spec.Zone.ZoneClassObservedGeneration != env.class.Generation { + invalidateStatus(&status) + invalidateState(stored.State) + stored.Policy = "" + status.WaiverRef = nil + meta.RemoveStatusCondition(&status.Conditions, "OverrideAccepted") + reason := "InvalidWithdrawal" + if !item.Allowed { + reason = "NotAllowed" + } + setCondition(&status.Conditions, "Accepted", metav1.ConditionFalse, reason, "Hosted DNS withdrawal binding is not accepted.", item.ObservedGeneration, now) + setWithdrawalDecision(&status, withdrawal.Decision{Withdrawn: metav1.ConditionUnknown, Ready: metav1.ConditionFalse, Reason: hostedv1alpha1.ReasonDNSProbeFailed, Message: "Hosted DNS withdrawal binding is not accepted."}, now) + } else { + setCondition(&status.Conditions, "Accepted", metav1.ConditionTrue, "Accepted", "Hosted DNS withdrawal intent is accepted.", item.ObservedGeneration, now) + if stored.Policy != fingerprint || !stored.Next.After(now) { + indices = append(indices, i) + } + if stored.Policy != fingerprint || meta.FindStatusCondition(status.Conditions, "Withdrawn") == nil { + invalidateStatus(&status) + setWithdrawalDecision(&status, withdrawal.Decision{Withdrawn: metav1.ConditionUnknown, Ready: metav1.ConditionFalse, Reason: hostedv1alpha1.ReasonDNSProbeFailed, Message: "Hosted withdrawal verification is pending current served snapshot and policy."}, now) + } + bindWithdrawalWaiver(&status, item, now) + if status.ResultDigest != "" { + status.ResultDigest = withdrawalResultDigest(unit, item, status) + } + } + state.Withdrawals[key] = stored + unit.Status.Withdrawals[i] = status + } + for key := range state.Withdrawals { + if !active[key] { + delete(state.Withdrawals, key) + } + } + slices.SortFunc(indices, func(a, b int) int { + return strings.Compare(withdrawalKey(unit.Spec.Withdrawals[a]), withdrawalKey(unit.Spec.Withdrawals[b])) + }) + start := 0 + for start < len(indices) && withdrawalKey(unit.Spec.Withdrawals[indices[start]]) <= state.Cursor { + start++ + } + var views []string + var viewErr error + if len(indices) > 0 { + views = env.params.Verification.Views + if len(views) == 0 { + views, viewErr = resolveViews(ctx, r.Options.DefaultViews) + } + } + budget := MaxNamesPerPass + for offset := range len(indices) { + index := indices[(start+offset)%len(indices)] + item := unit.Spec.Withdrawals[index] + key := withdrawalKey(item) + stored := state.Withdrawals[key] + cost := 1 + if stored.State != nil { + cost = max(1, len(stored.State.CheckedNames)) + } + if cost > budget { + break + } + budget -= cost + bindingChanged := stored.Binding != "" && stored.Binding != env.fingerprint + if bindingChanged { + invalidateState(stored.State) + } + decision := withdrawal.Decision{} + err := viewErr + if err == nil { + served := revisionServed(stored.OmissionSerial, stored.OmissionDigest, snapshot, servers) + decision, err = r.Options.Evaluator.Evaluate(ctx, withdrawal.Input{Claim: item, Snapshot: snapshot, Served: served, Policy: dnsprobe.Policy{Mode: "Recursive", Servers: views}, SOAMinimum: snapshot.SOA.Minimum, Now: now, Previous: stored.State}) + } + completed := r.Options.Clock() + if err != nil { + invalidateState(stored.State) + decision = withdrawal.Decision{Withdrawn: metav1.ConditionUnknown, Ready: metav1.ConditionFalse, Reason: hostedv1alpha1.ReasonDNSProbeFailed, Message: "Hosted DNS withdrawal probe failed in a required view.", State: stored.State, NextProbe: completed.Add(30 * time.Second)} + } + if bindingChanged { + invalidateState(decision.State) + decision.Withdrawn, decision.Ready, decision.Reason = metav1.ConditionUnknown, metav1.ConditionFalse, hostedv1alpha1.ReasonPolicyChanged + decision.Message = "Hosted DNS withdrawal policy changed; the prior required view policy remains a floor." + decision.AbsentSince, decision.NotBefore, decision.VerifiedAt = nil, nil, nil + } + delay := decision.NextProbe.Sub(completed) + delay = time.Duration(float64(delay) * (1 + max(-1, min(1, r.Options.Jitter()))*0.1)) + delay = max(30*time.Second, min(300*time.Second, delay)) + decision.NextProbe = completed.Add(delay) + if decision.State != nil { + decision.State.NextProbe = decision.NextProbe + } + status := &unit.Status.Withdrawals[index] + setWithdrawalDecision(status, decision, completed) + status.LastProbeTime, status.NextProbeTime = timePointer(completed), timePointer(decision.NextProbe) + status.PolicyFingerprint = decision.PolicyFingerprint + status.RequiredViews = slices.Clone(decision.RequiredViews) + status.CheckedNames = slices.Clone(decision.CheckedNames) + status.Provider = nil + if decision.ProviderData != nil { + status.Provider = providerData(decision.ProviderData) + } + status.WaiverRef = decision.WaiverRef + if decision.OverrideAccepted != nil { + condition := *decision.OverrideAccepted + meta.SetStatusCondition(&status.Conditions, condition) + } else { + meta.RemoveStatusCondition(&status.Conditions, "OverrideAccepted") + } + if err != nil { + bindWithdrawalWaiver(status, item, completed) + } + status.ResultDigest = withdrawalResultDigest(unit, item, *status) + served := revisionServed(stored.OmissionSerial, stored.OmissionDigest, snapshot, servers) + stored.Policy, stored.Next, stored.State = withdrawalFingerprint(env.fingerprint, snapshot.ContentDigest, served, item.Waiver), decision.NextProbe, decision.State + stored.Binding = env.fingerprint + state.Withdrawals[key] = stored + state.Cursor = key + state.Probed = true + } + var next time.Duration + for _, index := range indices { + stored := state.Withdrawals[withdrawalKey(unit.Spec.Withdrawals[index])] + next = minDuration(next, max(time.Second, stored.Next.Sub(now))) + } + for _, stored := range state.Withdrawals { + if stored.Next.After(now) { + next = minDuration(next, stored.Next.Sub(now)) + } + } + return next, nil +} +func setWithdrawalDecision(status *dnsv1alpha1.ZoneUnitWithdrawalStatus, decision withdrawal.Decision, now time.Time) { + status.AbsentSince, status.NotBefore, status.VerifiedAt = decision.AbsentSince, decision.NotBefore, decision.VerifiedAt + setCondition(&status.Conditions, "Withdrawn", decision.Withdrawn, decision.Reason, decision.Message, status.ObservedGeneration, now) + setCondition(&status.Conditions, "Ready", decision.Ready, decision.Reason, decision.Message, status.ObservedGeneration, now) +} +func withdrawalResultDigest(unit *dnsv1alpha1.ZoneUnit, item dnsv1alpha1.ZoneUnitWithdrawal, status dnsv1alpha1.ZoneUnitWithdrawalStatus) string { + status.ResultDigest = "" + status.Provider = nil + status.Conditions = slices.Clone(status.Conditions) + for i := range status.Conditions { + status.Conditions[i].LastTransitionTime = metav1.Time{} + } + slices.SortFunc(status.Conditions, func(a, b metav1.Condition) int { return strings.Compare(a.Type, b.Type) }) + return "sha256:" + dnsprobe.Fingerprint(struct { + Identity string + Provider dnsv1alpha1.ProviderReference + Status dnsv1alpha1.ZoneUnitWithdrawalStatus + }{withdrawalIdentity(unit, item), unit.Spec.Provider, status}) +} +func rejectWithdrawals(unit *dnsv1alpha1.ZoneUnit, now time.Time) { + if len(unit.Spec.Withdrawals) == 0 { + unit.Status.Withdrawals = nil + return + } + state := loadState(unit) + old := make(map[string]dnsv1alpha1.ZoneUnitWithdrawalStatus, len(unit.Status.Withdrawals)) + for _, status := range unit.Status.Withdrawals { + old[status.ClaimNamespace+"/"+status.ClaimName] = status + } + unit.Status.Withdrawals = make([]dnsv1alpha1.ZoneUnitWithdrawalStatus, len(unit.Spec.Withdrawals)) + for i, item := range unit.Spec.Withdrawals { + status := old[withdrawalKey(item)] + if status.Claim != item.Claim || status.ObservedGeneration != item.ObservedGeneration { + status = dnsv1alpha1.ZoneUnitWithdrawalStatus{} + } + status.ClaimNamespace, status.ClaimName, status.Claim, status.ObservedGeneration = item.ClaimNamespace, item.ClaimName, item.Claim, item.ObservedGeneration + invalidateStatus(&status) + status.WaiverRef = nil + meta.RemoveStatusCondition(&status.Conditions, "OverrideAccepted") + setCondition(&status.Conditions, "Accepted", metav1.ConditionFalse, "InvalidWithdrawal", "Hosted DNS withdrawal binding is not accepted.", item.ObservedGeneration, now) + setWithdrawalDecision(&status, withdrawal.Decision{Withdrawn: metav1.ConditionUnknown, Ready: metav1.ConditionFalse, Reason: hostedv1alpha1.ReasonDNSProbeFailed, Message: "Hosted DNS withdrawal binding is not accepted."}, now) + unit.Status.Withdrawals[i] = status + stored := state.Withdrawals[withdrawalKey(item)] + invalidateState(stored.State) + stored.Next = time.Time{} + state.Withdrawals[withdrawalKey(item)] = stored + } + saveState(unit, state) +} + +func updatePending(unit *dnsv1alpha1.ZoneUnit) { + counts := map[string]int{} + for _, status := range unit.Status.Withdrawals { + condition := meta.FindStatusCondition(status.Conditions, "Withdrawn") + if accepted := meta.FindStatusCondition(status.Conditions, "Accepted"); accepted != nil && accepted.Status != metav1.ConditionTrue { + counts[accepted.Reason]++ + } else if condition == nil { + counts[hostedv1alpha1.ReasonDNSProbeFailed]++ + } else if condition.Status != metav1.ConditionTrue { + counts[condition.Reason]++ + } + } + withdrawal.SetPending(unit.Namespace+"/"+unit.Name, counts) +} + +func withdrawalFingerprint(policy, digest string, served bool, waiver *dnsv1alpha1.ZoneUnitWithdrawalWaiver) string { + return dnsprobe.Fingerprint(struct { + Policy, Digest string + Served bool + Waiver *dnsv1alpha1.ZoneUnitWithdrawalWaiver + }{policy, digest, served, waiver}) +} + +func bindWithdrawalWaiver(status *dnsv1alpha1.ZoneUnitWithdrawalStatus, item dnsv1alpha1.ZoneUnitWithdrawal, now time.Time) { + status.WaiverRef = nil + waiver := item.Waiver + if waiver == nil || waiver.Name == "" || waiver.UID == "" || waiver.Digest == "" || waiver.ReservationRef.UID == "" { + meta.RemoveStatusCondition(&status.Conditions, "OverrideAccepted") + return + } + status.WaiverRef = &dnsv1alpha1.RecordWithdrawalWaiverReference{Name: waiver.Name, UID: waiver.UID, Digest: waiver.Digest, ReservationRef: waiver.ReservationRef} + setCondition(&status.Conditions, "OverrideAccepted", metav1.ConditionTrue, "OperatorAuthorizedRelease", "Core-validated withdrawal waiver is bound to its exact UID and immutable scope.", item.ObservedGeneration, now) +} diff --git a/internal/go/providers/hosted/controllers/zoneunit/withdrawal_test.go b/internal/go/providers/hosted/controllers/zoneunit/withdrawal_test.go new file mode 100644 index 0000000..2b4bb60 --- /dev/null +++ b/internal/go/providers/hosted/controllers/zoneunit/withdrawal_test.go @@ -0,0 +1,290 @@ +package zoneunit + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "testing" + "time" + + "github.com/appthrust/dns-api/internal/go/core/dnsprobe" + "github.com/appthrust/dns-api/internal/go/providers/hosted/withdrawal" + dnsv1alpha1 "github.com/appthrust/dns-api/pkg/go/api/dns/v1alpha1" + hostedv1alpha1 "github.com/appthrust/dns-api/pkg/go/api/hosted/v1alpha1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" + "sigs.k8s.io/controller-runtime/pkg/client" +) + +func claim(index int) dnsv1alpha1.ZoneUnitWithdrawal { + name := fmt.Sprintf("retire-%03d", index) + return dnsv1alpha1.ZoneUnitWithdrawal{ClaimNamespace: "app", ClaimName: name, Claim: dnsv1alpha1.WithdrawalObjectReference{Namespace: "app", Name: name, UID: types.UID(name)}, ObservedGeneration: 1, Name: "www", Address: "192.0.2.10", Allowed: true} +} +func TestWithdrawalFairBudgetAndPersistedState(t *testing.T) { + r, c, now, req := newFixture(t) + _, unit := reconcileOnce(t, r, req) + for i := range 65 { + unit.Spec.Withdrawals = append(unit.Spec.Withdrawals, claim(i)) + } + unit.Generation++ + if err := c.Update(context.Background(), unit); err != nil { + t.Fatal(err) + } + evaluator := r.Options.Evaluator.(*fakeEvaluator) + result, unit := reconcileOnce(t, r, req) + if len(evaluator.inputs) != 32 || result.RequeueAfter != time.Second { + t.Fatalf("calls=%d result=%+v", len(evaluator.inputs), result) + } + *now = now.Add(time.Second) + reconcileOnce(t, r, req) + *now = now.Add(time.Second) + _, unit = reconcileOnce(t, r, req) + if len(evaluator.inputs) != 65 { + t.Fatalf("unfair probes=%d", len(evaluator.inputs)) + } + for _, status := range unit.Status.Withdrawals { + if status.Claim.UID == "" || status.ResultDigest == "" || status.LastProbeTime == nil || status.NextProbeTime == nil { + t.Fatalf("unbound result=%+v", status) + } + } + *now = now.Add(30 * time.Second) + reconcileOnce(t, r, req) + if evaluator.inputs[65].Previous == nil { + t.Fatal("persisted evaluator history was not restored") + } +} +func TestWithdrawalSeesServedOmissionAndRetainsPositiveHorizon(t *testing.T) { + r, c, now, req := newFixture(t) + reconcileOnce(t, r, req) + serve(t, c, req, *now) + _, unit := reconcileOnce(t, r, req) + unit.Spec.RecordSets[0].DeletionRequested = true + unit.Spec.Withdrawals = []dnsv1alpha1.ZoneUnitWithdrawal{claim(0)} + unit.Generation++ + if err := c.Update(context.Background(), unit); err != nil { + t.Fatal(err) + } + evaluator := r.Options.Evaluator.(*fakeEvaluator) + _, unit = reconcileOnce(t, r, req) + if len(evaluator.inputs) != 1 || evaluator.inputs[0].Served { + t.Fatal("unserved omission authorized") + } + first := evaluator.inputs[0] + if first.Previous == nil || !first.Previous.Positive["publication"].Equal(now.Add(120*time.Second)) { + t.Fatalf("positive seed=%+v", first.Previous) + } + for _, record := range first.Snapshot.Records { + if record.Name == "www" { + t.Fatal("deleted ledger item passed as snapshot content") + } + } + serve(t, c, req, *now) + _, unit = reconcileOnce(t, r, req) + if len(evaluator.inputs) != 2 || !evaluator.inputs[1].Served || evaluator.inputs[1].SOAMinimum != 300 { + t.Fatalf("served input=%+v", evaluator.inputs) + } + unit.Spec.Withdrawals = nil + unit.Generation++ + if err := c.Update(context.Background(), unit); err != nil { + t.Fatal(err) + } + _, unit = reconcileOnce(t, r, req) + if len(unit.Status.Withdrawals) != 0 || len(loadState(unit).Withdrawals) != 0 { + t.Fatal("removed claims retained schedule/status") + } +} +func TestWithdrawalStalePolicyResultDiscarded(t *testing.T) { + r, c, _, req := newFixture(t) + _, unit := reconcileOnce(t, r, req) + unit.Spec.Withdrawals = []dnsv1alpha1.ZoneUnitWithdrawal{claim(0)} + unit.Generation++ + if err := c.Update(context.Background(), unit); err != nil { + t.Fatal(err) + } + evaluator := r.Options.Evaluator.(*fakeEvaluator) + evaluator.evaluate = func(in withdrawal.Input) (withdrawal.Decision, error) { + var current dnsv1alpha1.ZoneUnit + if err := c.Get(context.Background(), req.NamespacedName, ¤t); err != nil { + t.Fatal(err) + } + current.Generation++ + current.Spec.Withdrawals[0].Allowed = false + if err := c.Update(context.Background(), ¤t); err != nil { + t.Fatal(err) + } + return withdrawal.Decision{Withdrawn: metav1.ConditionTrue, Ready: metav1.ConditionTrue, Reason: "WithdrawalVerified", Message: "verified", NextProbe: in.Now.Add(30 * time.Second)}, nil + } + result, unit := reconcileOnce(t, r, req) + if result.RequeueAfter != time.Second || len(unit.Status.Withdrawals) != 0 { + t.Fatalf("stale result committed: %+v %+v", result, unit.Status.Withdrawals) + } +} +func TestWithdrawalErrorsAndInvalidBindingFailClosed(t *testing.T) { + r, c, _, req := newFixture(t) + _, unit := reconcileOnce(t, r, req) + unit.Spec.Withdrawals = []dnsv1alpha1.ZoneUnitWithdrawal{claim(0)} + unit.Generation++ + if err := c.Update(context.Background(), unit); err != nil { + t.Fatal(err) + } + evaluator := r.Options.Evaluator.(*fakeEvaluator) + evaluator.evaluate = func(withdrawal.Input) (withdrawal.Decision, error) { + return withdrawal.Decision{}, errors.New("private transport details") + } + _, unit = reconcileOnce(t, r, req) + status := unit.Status.Withdrawals[0] + assertCondition(t, status.Conditions, "Withdrawn", metav1.ConditionUnknown, "DNSProbeFailed") + if status.VerifiedAt != nil { + t.Fatal("failure retained proof") + } + unit.Spec.Zone.ZoneClassUID = "replaced-class" + unit.Generation++ + if err := c.Update(context.Background(), unit); err != nil { + t.Fatal(err) + } + _, unit = reconcileOnce(t, r, req) + assertCondition(t, unit.Status.Withdrawals[0].Conditions, "Accepted", metav1.ConditionFalse, "InvalidWithdrawal") + if len(evaluator.inputs) != 1 { + t.Fatal("invalid class binding reached evaluator") + } +} +func TestDelegationObservationDoesNotGateServing(t *testing.T) { + r, c, now, req := newFixture(t) + class := fixtureClass() + if err := c.Get(context.Background(), client.ObjectKeyFromObject(class), class); err != nil { + t.Fatal(err) + } + class.Spec.Parameters.Raw = []byte(`{"nameServers":[{"name":"ns.example.test","addresses":["192.0.2.53"]}],"verification":{"delegation":{"resolver":{"mode":"Recursive","servers":["192.0.2.54:53"]}}}}`) + if err := c.Update(context.Background(), class); err != nil { + t.Fatal(err) + } + resolver := r.Options.Resolver.(*fakeResolver) + resolver.answer = func(in dnsprobe.ProbeRequest) (dnsprobe.ProbeResult, error) { + if !in.ZoneOnly || in.Name != "example.test" { + t.Fatalf("delegation request=%+v", in) + } + return dnsprobe.ProbeResult{Zone: &dnsprobe.ZoneEvidence{Views: []dnsprobe.AuthorityObservation{{ID: "recursive/192.0.2.54:53", Nameservers: []string{"ns.other.test"}}}}}, nil + } + reconcileOnce(t, r, req) + serve(t, c, req, *now) + _, unit := reconcileOnce(t, r, req) + assertCondition(t, unit.Status.Zone.Conditions, "Programmed", metav1.ConditionTrue, "Programmed") + var data zoneData + _ = json.Unmarshal(unit.Status.Zone.Provider.Data.Raw, &data) + if data.Delegation == nil || data.Delegation.Matches || resolver.calls != 1 { + t.Fatalf("delegation=%+v calls=%d", data.Delegation, resolver.calls) + } + *now = now.Add(300 * time.Second) + serve(t, c, req, *now) + resolver.answer = func(dnsprobe.ProbeRequest) (dnsprobe.ProbeResult, error) { + return dnsprobe.ProbeResult{}, errors.New("private network details") + } + _, unit = reconcileOnce(t, r, req) + assertCondition(t, unit.Status.Zone.Conditions, "Programmed", metav1.ConditionTrue, "Programmed") + _ = json.Unmarshal(unit.Status.Zone.Provider.Data.Raw, &data) + if data.Delegation.Error != "DNSProbeFailed" { + t.Fatalf("failure=%+v", data.Delegation) + } +} +func TestDeletingUnitKeepsWithdrawalFailClosed(t *testing.T) { + r, c, _, req := newFixture(t) + _, unit := reconcileOnce(t, r, req) + unit.Spec.Withdrawals = []dnsv1alpha1.ZoneUnitWithdrawal{claim(0)} + unit.Finalizers = []string{"test/retain"} + unit.Generation++ + if err := c.Update(context.Background(), unit); err != nil { + t.Fatal(err) + } + if err := c.Delete(context.Background(), unit); err != nil { + t.Fatal(err) + } + reconcileOnce(t, r, req) + inputs := r.Options.Evaluator.(*fakeEvaluator).inputs + if len(inputs) != 1 || inputs[0].Served || len(inputs[0].Snapshot.Records) != 0 { + t.Fatalf("deleting withdrawal input=%+v", inputs) + } +} +func TestServerExpiryBoundary(t *testing.T) { + now := time.Unix(1000, 0) + servers := []hostedv1alpha1.HostedZoneServerStatus{{Endpoint: "boundary", ObservedAt: metav1.NewTime(now.Add(-ServerFreshness))}, {Endpoint: "stale", ObservedAt: metav1.NewTime(now.Add(-ServerFreshness - time.Second))}} + fresh := freshServers(servers, now) + if len(fresh) != 1 || fresh[0].Endpoint != "boundary" { + t.Fatalf("fresh=%+v", fresh) + } +} + +func TestWithdrawalRequiresAllFreshServersPastOmission(t *testing.T) { + r, c, now, req := newFixture(t) + reconcileOnce(t, r, req) + serve(t, c, req, *now) + _, unit := reconcileOnce(t, r, req) + before := snapshotFor(t, c, req) + unit.Spec.RecordSets[0].DeletionRequested = true + unit.Spec.Withdrawals = []dnsv1alpha1.ZoneUnitWithdrawal{claim(0)} + unit.Generation++ + if err := c.Update(context.Background(), unit); err != nil { + t.Fatal(err) + } + reconcileOnce(t, r, req) + omitted := snapshotFor(t, c, req) + omitted.Status.Servers = []hostedv1alpha1.HostedZoneServerStatus{ + {Endpoint: "dns/fast", Serial: omitted.Spec.Serial, ContentDigest: omitted.Spec.ContentDigest, ObservedAt: metav1.NewTime(*now)}, + {Endpoint: "dns/slow", Serial: before.Spec.Serial, ContentDigest: before.Spec.ContentDigest, ObservedAt: metav1.NewTime(*now)}, + } + if err := c.Status().Update(context.Background(), omitted); err != nil { + t.Fatal(err) + } + *now = now.Add(30 * time.Second) + reconcileOnce(t, r, req) + evaluator := r.Options.Evaluator.(*fakeEvaluator) + if evaluator.inputs[len(evaluator.inputs)-1].Served { + t.Fatal("fast replica alone authorized withdrawal") + } + serve(t, c, req, *now) + _, unit = reconcileOnce(t, r, req) + if !evaluator.inputs[len(evaluator.inputs)-1].Served { + t.Fatal("all replicas past omission did not authorize snapshot term") + } + sibling := unit.Spec.RecordSets[0] + sibling.RecordSetName, sibling.RecordSetUID, sibling.Name, sibling.DeletionRequested = "other", "other-uid", "other", false + unit.Spec.RecordSets = append(unit.Spec.RecordSets, sibling) + unit.Generation++ + if err := c.Update(context.Background(), unit); err != nil { + t.Fatal(err) + } + _, unit = reconcileOnce(t, r, req) + assertCondition(t, unit.Status.Zone.Conditions, "Programmed", metav1.ConditionFalse, "ServingPending") + if !evaluator.inputs[len(evaluator.inputs)-1].Served { + t.Fatal("unrelated pending revision retracted already served omission") + } +} + +func TestPolicyChangePreservesWithdrawalFloor(t *testing.T) { + r, c, _, req := newFixture(t) + _, unit := reconcileOnce(t, r, req) + unit.Spec.Withdrawals = []dnsv1alpha1.ZoneUnitWithdrawal{claim(0)} + unit.Generation++ + if err := c.Update(context.Background(), unit); err != nil { + t.Fatal(err) + } + evaluator := r.Options.Evaluator.(*fakeEvaluator) + evaluator.evaluate = func(in withdrawal.Input) (withdrawal.Decision, error) { + return withdrawal.Decision{Withdrawn: metav1.ConditionFalse, Ready: metav1.ConditionFalse, Reason: "NegativeTTLNotElapsed", Message: "waiting", NextProbe: in.Now.Add(30 * time.Second), State: &withdrawal.State{Identity: "bound", Views: []string{"recursive/192.0.2.53:53"}, SOAMinimum: 300}}, nil + } + reconcileOnce(t, r, req) + class := fixtureClass() + if err := c.Get(context.Background(), client.ObjectKeyFromObject(class), class); err != nil { + t.Fatal(err) + } + class.Spec.Parameters.Raw = []byte(`{"nameServers":[{"name":"ns.example.test","addresses":["192.0.2.53"]}],"verification":{"views":["192.0.2.54:53"]}}`) + if err := c.Update(context.Background(), class); err != nil { + t.Fatal(err) + } + _, unit = reconcileOnce(t, r, req) + last := evaluator.inputs[len(evaluator.inputs)-1] + if last.Previous == nil || len(last.Previous.Views) != 1 || last.Previous.Views[0] != "recursive/192.0.2.53:53" { + t.Fatalf("policy floor discarded: %+v", last.Previous) + } + assertCondition(t, unit.Status.Withdrawals[0].Conditions, "Withdrawn", metav1.ConditionUnknown, "PolicyChanged") +} diff --git a/internal/go/providers/hosted/conversion/handler.go b/internal/go/providers/hosted/conversion/handler.go new file mode 100644 index 0000000..7f323e5 --- /dev/null +++ b/internal/go/providers/hosted/conversion/handler.go @@ -0,0 +1,148 @@ +// Package conversion serves the hosted provider's endpoint conversion API. +package conversion + +import ( + "encoding/json" + "fmt" + "net/http" + "net/netip" + "slices" + "strings" + + "github.com/appthrust/dns-api/internal/go/core/dnsprobe" + dnsv1alpha1 "github.com/appthrust/dns-api/pkg/go/api/dns/v1alpha1" + conversionv1alpha1 "github.com/appthrust/dns-api/pkg/go/api/endpoint/conversion/v1alpha1" + endpointv1alpha1 "github.com/appthrust/dns-api/pkg/go/api/endpoint/v1alpha1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +const ( + GroupName = "endpoint.hosted.dns.appthrust.io" + Version = "v1alpha1" + Resource = "endpointrecordsetconversions" +) + +type Handler struct { + defaultTTL int32 +} + +// NewHandler uses defaultTTL seconds for every converted fragment. The caller +// supplies the hosted endpoint TTL (1–86400); conversion requests contain no +// ZoneClass reference from which to resolve a class-specific defaultTTL. +func NewHandler(defaultTTL uint32) *Handler { + return &Handler{defaultTTL: int32(defaultTTL)} +} + +func (h *Handler) Register(mux interface{ Register(string, http.Handler) }) { + mux.Register("/apis/"+GroupName+"/"+Version, h) + mux.Register("/apis/"+GroupName+"/"+Version+"/"+Resource, h) +} + +func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + path := "/apis/" + GroupName + "/" + Version + switch { + case r.Method == http.MethodGet && r.URL.Path == path: + writeJSON(w, http.StatusOK, metav1.APIResourceList{ + TypeMeta: metav1.TypeMeta{APIVersion: "v1", Kind: "APIResourceList"}, + GroupVersion: GroupName + "/" + Version, + APIResources: []metav1.APIResource{{Name: Resource, Kind: "EndpointRecordSetConversion", Verbs: []string{"create"}}}, + }) + case r.Method == http.MethodPost && r.URL.Path == path+"/"+Resource: + defer r.Body.Close() + var request conversionv1alpha1.EndpointRecordSetConversion + response := conversionv1alpha1.EndpointRecordSetConversion{TypeMeta: metav1.TypeMeta{APIVersion: GroupName + "/" + Version, Kind: "EndpointRecordSetConversion"}} + if err := json.NewDecoder(r.Body).Decode(&request); err != nil { + response.Status.Result = failure("InvalidInput", "request body is malformed") + writeJSON(w, http.StatusBadRequest, response) + return + } + response.ObjectMeta, response.Spec, response.Status.UID = request.ObjectMeta, request.Spec, request.Spec.UID + fragments, result := convertEndpoint(request.Spec.Input, h.defaultTTL) + response.Status.Result = result + if len(fragments) > 0 { + response.Status.Output = &endpointv1alpha1.EndpointRecordSetConversionOutput{Fragments: fragments} + } + writeJSON(w, http.StatusCreated, response) + default: + http.NotFound(w, r) + } +} + +func convertEndpoint(input endpointv1alpha1.EndpointRecordSetConversionInput, ttl int32) ([]endpointv1alpha1.RecordSetSpecFragment, conversionv1alpha1.EndpointRecordSetConversionResult) { + if input.Name == "" { + return nil, failure("InvalidInput", "input.name is required") + } + if len(input.Targets) == 0 { + return nil, failure("InvalidInput", "input.targets must not be empty") + } + var ipv4, ipv6, hosts []string + for _, target := range input.Targets { + switch target.Type { + case endpointv1alpha1.EndpointTargetTypeIPAddress: + ip, err := netip.ParseAddr(target.Value) + if err != nil || ip.Zone() != "" { + return nil, failure("InvalidInput", fmt.Sprintf("target %q is not a valid IP address", target.Value)) + } + ip = ip.Unmap() + if ip.Is4() { + ipv4 = append(ipv4, ip.String()) + } else { + ipv6 = append(ipv6, ip.String()) + } + case endpointv1alpha1.EndpointTargetTypeHostname: + name := strings.TrimSuffix(strings.ToLower(target.Value), ".") + if !dnsprobe.ValidName(name) { + return nil, failure("InvalidInput", "hostname target must be a valid DNS name") + } + hosts = append(hosts, name) + default: + return nil, failure("InvalidInput", "unsupported target type "+string(target.Type)) + } + } + slices.Sort(ipv4) + ipv4 = slices.Compact(ipv4) + slices.Sort(ipv6) + ipv6 = slices.Compact(ipv6) + slices.Sort(hosts) + hosts = slices.Compact(hosts) + if len(hosts) > 0 && len(ipv4)+len(ipv6) > 0 { + return nil, failure("UnsupportedTarget", "Hosted endpoint conversion does not support mixed hostname and IP targets") + } + if len(hosts) > 1 { + return nil, failure("UnsupportedTarget", "Hosted endpoint conversion requires exactly one hostname target") + } + if len(hosts) == 1 { + if input.Name == "@" { + return nil, failure("UnsupportedTarget", "Hosted endpoint conversion does not support CNAME at the zone apex") + } + if input.PreferredRecordType != "" && input.PreferredRecordType != endpointv1alpha1.EndpointRecordSetTypeCNAME { + return nil, failure("UnsupportedTarget", "preferredRecordType must be CNAME for a hostname target") + } + return []endpointv1alpha1.RecordSetSpecFragment{{Name: input.Name, Type: endpointv1alpha1.EndpointRecordSetTypeCNAME, TTL: &ttl, CNAME: &dnsv1alpha1.CNAMERecordSet{Target: hosts[0]}}}, success() + } + if input.PreferredRecordType != "" && !((input.PreferredRecordType == endpointv1alpha1.EndpointRecordSetTypeA && len(ipv6) == 0) || (input.PreferredRecordType == endpointv1alpha1.EndpointRecordSetTypeAAAA && len(ipv4) == 0)) { + return nil, failure("UnsupportedTarget", "preferredRecordType is incompatible with IP targets") + } + fragments := make([]endpointv1alpha1.RecordSetSpecFragment, 0, 2) + if len(ipv4) > 0 { + fragments = append(fragments, endpointv1alpha1.RecordSetSpecFragment{Name: input.Name, Type: endpointv1alpha1.EndpointRecordSetTypeA, TTL: &ttl, A: &dnsv1alpha1.ARecordSet{Addresses: ipv4}}) + } + if len(ipv6) > 0 { + fragments = append(fragments, endpointv1alpha1.RecordSetSpecFragment{Name: input.Name, Type: endpointv1alpha1.EndpointRecordSetTypeAAAA, TTL: &ttl, AAAA: &dnsv1alpha1.AAAARecordSet{Addresses: ipv6}}) + } + return fragments, success() +} + +func failure(reason, message string) conversionv1alpha1.EndpointRecordSetConversionResult { + return conversionv1alpha1.EndpointRecordSetConversionResult{Status: "Failure", Reason: reason, Message: message, Retryable: false} +} + +func success() conversionv1alpha1.EndpointRecordSetConversionResult { + return conversionv1alpha1.EndpointRecordSetConversionResult{Status: "Success", Reason: "Converted", Message: "converted endpoint targets to hosted DNS record sets"} +} + +func writeJSON(w http.ResponseWriter, status int, value any) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(value) +} diff --git a/internal/go/providers/hosted/conversion/handler_test.go b/internal/go/providers/hosted/conversion/handler_test.go new file mode 100644 index 0000000..b12bd4b --- /dev/null +++ b/internal/go/providers/hosted/conversion/handler_test.go @@ -0,0 +1,197 @@ +package conversion + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "reflect" + "slices" + "testing" + + conversionv1alpha1 "github.com/appthrust/dns-api/pkg/go/api/endpoint/conversion/v1alpha1" + endpointv1alpha1 "github.com/appthrust/dns-api/pkg/go/api/endpoint/v1alpha1" + hostedv1alpha1 "github.com/appthrust/dns-api/pkg/go/api/hosted/v1alpha1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "sigs.k8s.io/controller-runtime/pkg/webhook" +) + +const conversionPath = "/apis/endpoint.hosted.dns.appthrust.io/v1alpha1/endpointrecordsetconversions" + +func TestHandlerFramingAndCanonicalTargets(t *testing.T) { + input := endpointv1alpha1.EndpointRecordSetConversionInput{Name: "*.project", Targets: []endpointv1alpha1.EndpointTarget{ + {Type: endpointv1alpha1.EndpointTargetTypeIPAddress, Value: "2001:0db8:0:0::2"}, + {Type: endpointv1alpha1.EndpointTargetTypeIPAddress, Value: "192.0.2.2"}, + {Type: endpointv1alpha1.EndpointTargetTypeIPAddress, Value: "192.0.2.1"}, + {Type: endpointv1alpha1.EndpointTargetTypeIPAddress, Value: "::ffff:192.0.2.1"}, + {Type: endpointv1alpha1.EndpointTargetTypeIPAddress, Value: "2001:db8::1"}, + {Type: endpointv1alpha1.EndpointTargetTypeIPAddress, Value: "2001:db8::2"}, + }} + for _, ttl := range []uint32{hostedv1alpha1.DefaultTTL, 123, 86400} { + review := conversionv1alpha1.EndpointRecordSetConversion{ + ObjectMeta: metav1.ObjectMeta{Name: "conversion", Labels: map[string]string{"test": "hosted"}}, + Spec: conversionv1alpha1.EndpointRecordSetConversionSpec{UID: "request-1", Input: input}, + } + got, response := requestConversion(t, NewHandler(ttl), review) + if response.Code != http.StatusCreated || got.Status.UID != "request-1" || got.Status.Result.Status != "Success" || got.Status.Result.Reason != "Converted" || got.Status.Output == nil || got.Status.Result.Retryable { + t.Fatalf("response = %d %#v", response.Code, got) + } + if got.APIVersion != "endpoint.hosted.dns.appthrust.io/v1alpha1" || got.Kind != "EndpointRecordSetConversion" || !reflect.DeepEqual(got.ObjectMeta, review.ObjectMeta) || !reflect.DeepEqual(got.Spec, review.Spec) { + t.Fatalf("response framing = %#v", got) + } + if got.Status.Result.Message != "converted endpoint targets to hosted DNS record sets" { + t.Fatalf("message = %q", got.Status.Result.Message) + } + fragments := got.Status.Output.Fragments + if len(fragments) != 2 || fragments[0].Type != endpointv1alpha1.EndpointRecordSetTypeA || fragments[0].A == nil || fragments[1].Type != endpointv1alpha1.EndpointRecordSetTypeAAAA || fragments[1].AAAA == nil { + t.Fatalf("fragments = %#v", fragments) + } + if !slices.Equal(fragments[0].A.Addresses, []string{"192.0.2.1", "192.0.2.2"}) || !slices.Equal(fragments[1].AAAA.Addresses, []string{"2001:db8::1", "2001:db8::2"}) { + t.Fatalf("addresses = %v, %v", fragments[0].A.Addresses, fragments[1].AAAA.Addresses) + } + for _, fragment := range fragments { + if fragment.Name != input.Name || fragment.TTL == nil || *fragment.TTL != int32(ttl) || len(fragment.Options.Raw) != 0 { + t.Fatalf("fragment = %#v", fragment) + } + } + } +} + +func TestHostnameConversionAndFailures(t *testing.T) { + host := endpointv1alpha1.EndpointTarget{Type: endpointv1alpha1.EndpointTargetTypeHostname, Value: "TARGET.EXAMPLE."} + ip := endpointv1alpha1.EndpointTarget{Type: endpointv1alpha1.EndpointTargetTypeIPAddress, Value: "192.0.2.1"} + ipv6 := endpointv1alpha1.EndpointTarget{Type: endpointv1alpha1.EndpointTargetTypeIPAddress, Value: "2001:db8::1"} + cases := []struct { + name string + input endpointv1alpha1.EndpointRecordSetConversionInput + reason, message string + }{ + {"hostname", endpointv1alpha1.EndpointRecordSetConversionInput{Name: "www", Targets: []endpointv1alpha1.EndpointTarget{host, {Type: endpointv1alpha1.EndpointTargetTypeHostname, Value: "target.example"}}}, "Converted", "converted endpoint targets to hosted DNS record sets"}, + {"hostname preference", endpointv1alpha1.EndpointRecordSetConversionInput{Name: "www", Targets: []endpointv1alpha1.EndpointTarget{host}, PreferredRecordType: endpointv1alpha1.EndpointRecordSetTypeCNAME}, "Converted", "converted endpoint targets to hosted DNS record sets"}, + {"apex", endpointv1alpha1.EndpointRecordSetConversionInput{Name: "@", Targets: []endpointv1alpha1.EndpointTarget{host}}, "UnsupportedTarget", "Hosted endpoint conversion does not support CNAME at the zone apex"}, + {"mixed", endpointv1alpha1.EndpointRecordSetConversionInput{Name: "www", Targets: []endpointv1alpha1.EndpointTarget{host, ip}}, "UnsupportedTarget", "Hosted endpoint conversion does not support mixed hostname and IP targets"}, + {"multiple", endpointv1alpha1.EndpointRecordSetConversionInput{Name: "www", Targets: []endpointv1alpha1.EndpointTarget{host, {Type: endpointv1alpha1.EndpointTargetTypeHostname, Value: "other.example"}}}, "UnsupportedTarget", "Hosted endpoint conversion requires exactly one hostname target"}, + {"invalid address", endpointv1alpha1.EndpointRecordSetConversionInput{Name: "www", Targets: []endpointv1alpha1.EndpointTarget{{Type: endpointv1alpha1.EndpointTargetTypeIPAddress, Value: "bad"}}}, "InvalidInput", "target \"bad\" is not a valid IP address"}, + {"scoped address", endpointv1alpha1.EndpointRecordSetConversionInput{Name: "www", Targets: []endpointv1alpha1.EndpointTarget{{Type: endpointv1alpha1.EndpointTargetTypeIPAddress, Value: "fe80::1%eth0"}}}, "InvalidInput", "target \"fe80::1%eth0\" is not a valid IP address"}, + {"invalid hostname", endpointv1alpha1.EndpointRecordSetConversionInput{Name: "www", Targets: []endpointv1alpha1.EndpointTarget{{Type: endpointv1alpha1.EndpointTargetTypeHostname, Value: "bad_name.example"}}}, "InvalidInput", "hostname target must be a valid DNS name"}, + {"address as hostname", endpointv1alpha1.EndpointRecordSetConversionInput{Name: "www", Targets: []endpointv1alpha1.EndpointTarget{{Type: endpointv1alpha1.EndpointTargetTypeHostname, Value: "192.0.2.1"}}}, "InvalidInput", "hostname target must be a valid DNS name"}, + {"unknown target type", endpointv1alpha1.EndpointRecordSetConversionInput{Name: "www", Targets: []endpointv1alpha1.EndpointTarget{{Type: "Other", Value: "target.example"}}}, "InvalidInput", "unsupported target type Other"}, + {"hostname incompatible preference", endpointv1alpha1.EndpointRecordSetConversionInput{Name: "www", Targets: []endpointv1alpha1.EndpointTarget{host}, PreferredRecordType: endpointv1alpha1.EndpointRecordSetTypeA}, "UnsupportedTarget", "preferredRecordType must be CNAME for a hostname target"}, + {"IP CNAME preference", endpointv1alpha1.EndpointRecordSetConversionInput{Name: "www", Targets: []endpointv1alpha1.EndpointTarget{ip}, PreferredRecordType: endpointv1alpha1.EndpointRecordSetTypeCNAME}, "UnsupportedTarget", "preferredRecordType is incompatible with IP targets"}, + {"IPv4 AAAA preference", endpointv1alpha1.EndpointRecordSetConversionInput{Name: "www", Targets: []endpointv1alpha1.EndpointTarget{ip}, PreferredRecordType: endpointv1alpha1.EndpointRecordSetTypeAAAA}, "UnsupportedTarget", "preferredRecordType is incompatible with IP targets"}, + {"IPv6 A preference", endpointv1alpha1.EndpointRecordSetConversionInput{Name: "www", Targets: []endpointv1alpha1.EndpointTarget{ipv6}, PreferredRecordType: endpointv1alpha1.EndpointRecordSetTypeA}, "UnsupportedTarget", "preferredRecordType is incompatible with IP targets"}, + {"dual stack A preference", endpointv1alpha1.EndpointRecordSetConversionInput{Name: "www", Targets: []endpointv1alpha1.EndpointTarget{ip, ipv6}, PreferredRecordType: endpointv1alpha1.EndpointRecordSetTypeA}, "UnsupportedTarget", "preferredRecordType is incompatible with IP targets"}, + {"dual stack AAAA preference", endpointv1alpha1.EndpointRecordSetConversionInput{Name: "www", Targets: []endpointv1alpha1.EndpointTarget{ip, ipv6}, PreferredRecordType: endpointv1alpha1.EndpointRecordSetTypeAAAA}, "UnsupportedTarget", "preferredRecordType is incompatible with IP targets"}, + {"missing name", endpointv1alpha1.EndpointRecordSetConversionInput{Targets: []endpointv1alpha1.EndpointTarget{ip}}, "InvalidInput", "input.name is required"}, + {"missing targets", endpointv1alpha1.EndpointRecordSetConversionInput{Name: "www"}, "InvalidInput", "input.targets must not be empty"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + for _, ttl := range []uint32{hostedv1alpha1.DefaultTTL, 123} { + review := conversionv1alpha1.EndpointRecordSetConversion{Spec: conversionv1alpha1.EndpointRecordSetConversionSpec{UID: "request-1", Input: tc.input}} + got, response := requestConversion(t, NewHandler(ttl), review) + result := got.Status.Result + if response.Code != http.StatusCreated || got.Status.UID != review.Spec.UID || result.Reason != tc.reason || result.Retryable || result.Message != tc.message { + t.Fatalf("response = %d %#v", response.Code, got) + } + if tc.reason == "Converted" { + if result.Status != "Success" || got.Status.Output == nil || len(got.Status.Output.Fragments) != 1 { + t.Fatalf("response = %#v", got) + } + fragment := got.Status.Output.Fragments[0] + if fragment.Name != tc.input.Name || fragment.Type != endpointv1alpha1.EndpointRecordSetTypeCNAME || fragment.CNAME == nil || fragment.CNAME.Target != "target.example" || fragment.TTL == nil || *fragment.TTL != int32(ttl) || len(fragment.Options.Raw) != 0 { + t.Fatalf("fragment = %#v", fragment) + } + } else if result.Status != "Failure" || got.Status.Output != nil { + t.Fatalf("failure emitted output: %#v", got) + } + } + }) + } +} + +func TestIPConversionCompatiblePreferences(t *testing.T) { + for _, tc := range []struct { + address string + recordType endpointv1alpha1.EndpointRecordSetType + }{ + {"192.0.2.1", endpointv1alpha1.EndpointRecordSetTypeA}, + {"2001:db8::1", endpointv1alpha1.EndpointRecordSetTypeAAAA}, + } { + for _, preference := range []endpointv1alpha1.EndpointRecordSetType{"", tc.recordType} { + input := endpointv1alpha1.EndpointRecordSetConversionInput{Name: "@", Targets: []endpointv1alpha1.EndpointTarget{{Type: endpointv1alpha1.EndpointTargetTypeIPAddress, Value: tc.address}}, PreferredRecordType: preference} + got, _ := requestConversion(t, NewHandler(123), conversionv1alpha1.EndpointRecordSetConversion{Spec: conversionv1alpha1.EndpointRecordSetConversionSpec{UID: "request-1", Input: input}}) + if got.Status.Result.Status != "Success" || got.Status.Output == nil || len(got.Status.Output.Fragments) != 1 { + t.Fatalf("address %s preference %s: %#v", tc.address, preference, got) + } + fragment := got.Status.Output.Fragments[0] + if fragment.Type != tc.recordType || fragment.Name != "@" || fragment.TTL == nil || *fragment.TTL != 123 { + t.Fatalf("fragment = %#v", fragment) + } + } + } +} + +func TestDiscoveryAndMalformedRequest(t *testing.T) { + server := webhook.NewServer(webhook.Options{}) + NewHandler(hostedv1alpha1.DefaultTTL).Register(server) + discoveryPath := "/apis/endpoint.hosted.dns.appthrust.io/v1alpha1" + for _, tc := range []struct { + method, path, body string + code int + }{ + {http.MethodGet, discoveryPath, "", http.StatusOK}, + {http.MethodGet, conversionPath, "", http.StatusNotFound}, + {http.MethodPost, discoveryPath, "", http.StatusNotFound}, + {http.MethodPost, conversionPath, "{", http.StatusBadRequest}, + {http.MethodDelete, conversionPath, "", http.StatusNotFound}, + {http.MethodGet, "/apis/endpoint.external.dns.appthrust.io/v1alpha1", "", http.StatusNotFound}, + } { + response := httptest.NewRecorder() + server.WebhookMux().ServeHTTP(response, httptest.NewRequest(tc.method, tc.path, bytes.NewBufferString(tc.body))) + if response.Code != tc.code { + t.Fatalf("%s %s status = %d want %d", tc.method, tc.path, response.Code, tc.code) + } + switch tc.code { + case http.StatusOK: + var got metav1.APIResourceList + if err := json.Unmarshal(response.Body.Bytes(), &got); err != nil { + t.Fatal(err) + } + want := metav1.APIResourceList{ + TypeMeta: metav1.TypeMeta{APIVersion: "v1", Kind: "APIResourceList"}, + GroupVersion: "endpoint.hosted.dns.appthrust.io/v1alpha1", + APIResources: []metav1.APIResource{{Name: "endpointrecordsetconversions", Kind: "EndpointRecordSetConversion", Verbs: []string{"create"}}}, + } + if !reflect.DeepEqual(got, want) { + t.Fatalf("discovery = %#v, want %#v", got, want) + } + case http.StatusBadRequest: + var got conversionv1alpha1.EndpointRecordSetConversion + if err := json.Unmarshal(response.Body.Bytes(), &got); err != nil { + t.Fatal(err) + } + if got.APIVersion != "endpoint.hosted.dns.appthrust.io/v1alpha1" || got.Kind != "EndpointRecordSetConversion" || got.Status.Result.Status != "Failure" || got.Status.Result.Reason != "InvalidInput" || got.Status.Result.Message != "request body is malformed" || got.Status.Result.Retryable || got.Status.Output != nil { + t.Fatalf("malformed response = %#v", got) + } + } + } +} + +func requestConversion(t *testing.T, handler http.Handler, review conversionv1alpha1.EndpointRecordSetConversion) (conversionv1alpha1.EndpointRecordSetConversion, *httptest.ResponseRecorder) { + t.Helper() + body, err := json.Marshal(review) + if err != nil { + t.Fatal(err) + } + response := httptest.NewRecorder() + handler.ServeHTTP(response, httptest.NewRequest(http.MethodPost, conversionPath, bytes.NewReader(body))) + if response.Header().Get("Content-Type") != "application/json" { + t.Fatalf("Content-Type = %q", response.Header().Get("Content-Type")) + } + var got conversionv1alpha1.EndpointRecordSetConversion + if err := json.Unmarshal(response.Body.Bytes(), &got); err != nil { + t.Fatal(err) + } + return got, response +} diff --git a/internal/go/providers/hosted/manifest/capability.yaml b/internal/go/providers/hosted/manifest/capability.yaml new file mode 100644 index 0000000..f3d613b --- /dev/null +++ b/internal/go/providers/hosted/manifest/capability.yaml @@ -0,0 +1,12 @@ +apiVersion: endpoint.dns.appthrust.io/v1alpha1 +kind: EndpointProviderCapability +metadata: + name: hosted-v1alpha1 +spec: + provider: + name: hosted.dns.appthrust.io + version: v1alpha1 + conversion: + group: endpoint.hosted.dns.appthrust.io + version: v1alpha1 + resource: endpointrecordsetconversions diff --git a/internal/go/providers/hosted/manifest/manifest.go b/internal/go/providers/hosted/manifest/manifest.go new file mode 100644 index 0000000..815e796 --- /dev/null +++ b/internal/go/providers/hosted/manifest/manifest.go @@ -0,0 +1,23 @@ +// Package manifest contains the hosted Provider and endpoint capability declarations. +package manifest + +import ( + _ "embed" + + dnsv1alpha1 "github.com/appthrust/dns-api/pkg/go/api/dns/v1alpha1" + "sigs.k8s.io/yaml" +) + +//go:embed provider.yaml +var ProviderYAML []byte + +//go:embed capability.yaml +var CapabilityYAML []byte + +func Provider() (*dnsv1alpha1.Provider, error) { + var provider dnsv1alpha1.Provider + if err := yaml.UnmarshalStrict(ProviderYAML, &provider); err != nil { + return nil, err + } + return &provider, nil +} diff --git a/internal/go/providers/hosted/manifest/manifest_test.go b/internal/go/providers/hosted/manifest/manifest_test.go new file mode 100644 index 0000000..cb2762d --- /dev/null +++ b/internal/go/providers/hosted/manifest/manifest_test.go @@ -0,0 +1,379 @@ +package manifest + +import ( + "encoding/json" + "reflect" + "strings" + "testing" + + "github.com/appthrust/dns-api/internal/go/core/celruntime" + "github.com/appthrust/dns-api/internal/go/core/providercontract" + externalmanifest "github.com/appthrust/dns-api/internal/go/providers/external/manifest" + dnsv1alpha1 "github.com/appthrust/dns-api/pkg/go/api/dns/v1alpha1" + endpointv1alpha1 "github.com/appthrust/dns-api/pkg/go/api/endpoint/v1alpha1" + hostedv1alpha1 "github.com/appthrust/dns-api/pkg/go/api/hosted/v1alpha1" + "github.com/google/cel-go/cel" + apiextensions "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions" + apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" + apiservervalidation "k8s.io/apiextensions-apiserver/pkg/apiserver/validation" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "sigs.k8s.io/yaml" +) + +func TestHostedDeclaration(t *testing.T) { + provider, err := Provider() + if err != nil { + t.Fatal(err) + } + if provider.Name != hostedv1alpha1.ProviderName || provider.Kind != "Provider" || provider.APIVersion != "dns.appthrust.io/v1alpha1" || len(provider.Spec.Versions) != 1 { + t.Fatalf("provider=%#v", provider) + } + version := &provider.Spec.Versions[0] + if version.Name != hostedv1alpha1.ProviderVersion || version.Identity != nil || !version.Storage || !version.Served { + t.Fatalf("version=%#v", version) + } + wantTypes := []dnsv1alpha1.RecordType{dnsv1alpha1.RecordTypeA, dnsv1alpha1.RecordTypeAAAA, dnsv1alpha1.RecordTypeCNAME, dnsv1alpha1.RecordTypeTXT, dnsv1alpha1.RecordTypeMX, dnsv1alpha1.RecordTypeCAA, dnsv1alpha1.RecordTypeNS} + if !reflect.DeepEqual(version.RecordSet.SupportedTypes, wantTypes) { + t.Fatalf("supported types=%v want=%v", version.RecordSet.SupportedTypes, wantTypes) + } + assertReasons(t, version.Zone.AdditionalConditionReasons, []reasonKey{ + {dnsv1alpha1.ConditionType("Programmed"), metav1.ConditionFalse, hostedv1alpha1.ReasonServingPending}, + {dnsv1alpha1.ConditionType("Programmed"), metav1.ConditionFalse, hostedv1alpha1.ReasonSnapshotRejected}, + }) + assertReasons(t, version.RecordSet.AdditionalConditionReasons, []reasonKey{ + {dnsv1alpha1.ConditionType("Programmed"), metav1.ConditionFalse, hostedv1alpha1.ReasonServingPending}, + }) + if version.RecordWithdrawal == nil || !version.RecordWithdrawal.Supported || version.RecordWithdrawal.Schemas.StatusProviderData == nil { + t.Fatal("withdrawal capability and status schema must ship together") + } + assertReasons(t, version.RecordWithdrawal.AdditionalConditionReasons, []reasonKey{ + {"Withdrawn", metav1.ConditionFalse, hostedv1alpha1.ReasonDNSStillResolves}, + {"Ready", metav1.ConditionFalse, hostedv1alpha1.ReasonDNSStillResolves}, + {"Withdrawn", metav1.ConditionFalse, hostedv1alpha1.ReasonNegativeTTLNotElapsed}, + {"Ready", metav1.ConditionFalse, hostedv1alpha1.ReasonNegativeTTLNotElapsed}, + {"Withdrawn", metav1.ConditionUnknown, hostedv1alpha1.ReasonDNSProbeFailed}, + {"Ready", metav1.ConditionFalse, hostedv1alpha1.ReasonDNSProbeFailed}, + {"Withdrawn", metav1.ConditionUnknown, hostedv1alpha1.ReasonPolicyChanged}, + {"Ready", metav1.ConditionFalse, hostedv1alpha1.ReasonPolicyChanged}, + {"Withdrawn", metav1.ConditionTrue, hostedv1alpha1.ReasonWithdrawalVerified}, + {"Ready", metav1.ConditionTrue, hostedv1alpha1.ReasonWithdrawalVerified}, + {"OverrideAccepted", metav1.ConditionTrue, hostedv1alpha1.ReasonOperatorAuthorizedRelease}, + }) + external, err := externalmanifest.Provider() + if err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(version.RecordWithdrawal, external.Spec.Versions[0].RecordWithdrawal) { + t.Fatal("withdrawal schema and closed reason table differ from external") + } + if len(version.RecordSet.DisableValidations) != 0 || len(version.ZoneUnit.DisableValidations) != 0 { + t.Fatal("hosted must retain all core validations") + } + + var capability endpointv1alpha1.EndpointProviderCapability + if err := yaml.UnmarshalStrict(CapabilityYAML, &capability); err != nil { + t.Fatal(err) + } + if capability.Kind != "EndpointProviderCapability" || capability.APIVersion != "endpoint.dns.appthrust.io/v1alpha1" || capability.Name != "hosted-v1alpha1" || capability.Spec.Provider.Name != hostedv1alpha1.ProviderName || capability.Spec.Provider.Version != hostedv1alpha1.ProviderVersion || capability.Spec.Conversion.Group != "endpoint."+hostedv1alpha1.ProviderName || capability.Spec.Conversion.Version != hostedv1alpha1.ProviderVersion || capability.Spec.Conversion.Resource != "endpointrecordsetconversions" { + t.Fatalf("capability=%#v", capability) + } +} + +type reasonKey struct { + condition dnsv1alpha1.ConditionType + status metav1.ConditionStatus + reason string +} + +func assertReasons(t *testing.T, actual []dnsv1alpha1.ProviderConditionReason, want []reasonKey) { + t.Helper() + got := make([]reasonKey, len(actual)) + for i, reason := range actual { + got[i] = reasonKey{reason.ConditionType, reason.Status, reason.Reason} + } + if !reflect.DeepEqual(got, want) { + t.Fatalf("reasons=%v want=%v", got, want) + } +} + +func TestHostedParametersAndCEL(t *testing.T) { + provider, err := Provider() + if err != nil { + t.Fatal(err) + } + version := &provider.Spec.Versions[0] + for _, rule := range version.ZoneClass.ValidationRules { + if err := celruntime.CompileBool(rule.Rule, cel.Variable("self", cel.DynType)); err != nil { + t.Fatalf("compile %q: %v", rule.Rule, err) + } + } + const ns = `"nameServers":[{"name":"ns.example.test","addresses":["192.0.2.53","2001:db8::53"]}]` + cases := []struct { + name, parameters string + valid bool + }{ + {"minimal", `{` + ns + `}`, true}, + {"full", `{` + ns + `,"soa":{"rname":"hostmaster.example.test","refresh":3600,"retry":900,"expire":1209600,"minimum":300},"defaultTTL":60,"verification":{"views":["192.0.2.53:53","[2001:db8::53]:5353"],"delegation":{"resolver":{"mode":"Recursive","servers":["192.0.2.54:53"]}}}}`, true}, + {"empty optional objects", `{` + ns + `,"soa":{},"verification":{"delegation":{}}}`, true}, + {"empty delegation resolver", `{` + ns + `,"verification":{"delegation":{"resolver":{}}}}`, true}, + {"cluster delegation", `{` + ns + `,"verification":{"delegation":{"resolver":{"mode":"Cluster"}}}}`, true}, + {"cluster empty servers", `{` + ns + `,"verification":{"delegation":{"resolver":{"mode":"Cluster","servers":[]}}}}`, true}, + {"cluster explicit servers", `{` + ns + `,"verification":{"delegation":{"resolver":{"mode":"Cluster","servers":["192.0.2.54:53"]}}}}`, false}, + {"default mode explicit servers", `{` + ns + `,"verification":{"delegation":{"resolver":{"servers":["192.0.2.54:53"]}}}}`, false}, + {"recursive missing servers", `{` + ns + `,"verification":{"delegation":{"resolver":{"mode":"Recursive"}}}}`, false}, + {"invalid delegation mode", `{` + ns + `,"verification":{"delegation":{"resolver":{"mode":"Authoritative"}}}}`, false}, + {"obsolete resolvers rejected", `{` + ns + `,"verification":{"delegation":{"resolvers":["192.0.2.54:53"]}}}`, false}, + {"unnested resolver rejected", `{` + ns + `,"verification":{"delegation":{"mode":"Recursive","servers":["192.0.2.54:53"]}}}`, false}, + {"missing nameservers", `{}`, false}, + {"empty nameservers", `{"nameServers":[]}`, false}, + {"too many nameservers", `{"nameServers":[` + strings.Repeat(`{"name":"ns.example.test","addresses":["192.0.2.53"]},`, 8) + `{"name":"ns.example.test","addresses":["192.0.2.53"]}]}`, false}, + {"missing addresses", `{"nameServers":[{"name":"ns.example.test"}]}`, false}, + {"empty addresses", `{"nameServers":[{"name":"ns.example.test","addresses":[]}]}`, false}, + {"too many addresses", `{"nameServers":[{"name":"ns.example.test","addresses":[` + strings.Repeat(`"192.0.2.53",`, 8) + `"192.0.2.53"]}]}`, false}, + {"NS IP", strings.ReplaceAll(`{`+ns+`}`, "ns.example.test", "192.0.2.53"), false}, + {"uppercase NS", strings.ReplaceAll(`{`+ns+`}`, "ns.example.test", "NS.example.test"), false}, + {"root dot NS", strings.ReplaceAll(`{`+ns+`}`, "ns.example.test", "ns.example.test."), false}, + {"invalid address", strings.ReplaceAll(`{`+ns+`}`, "192.0.2.53", "999.0.2.53"), false}, + {"expanded IPv6", strings.ReplaceAll(`{`+ns+`}`, "2001:db8::53", "2001:db8:0:0:0:0:0:53"), false}, + {"uppercase IPv6", strings.ReplaceAll(`{`+ns+`}`, "2001:db8::53", "2001:DB8::53"), false}, + {"RName IP", `{` + ns + `,"soa":{"rname":"192.0.2.53"}}`, false}, + {"RName root dot", `{` + ns + `,"soa":{"rname":"hostmaster.example.test."}}`, false}, + {"TTL zero", `{` + ns + `,"defaultTTL":0}`, false}, + {"TTL ceiling", `{` + ns + `,"defaultTTL":86400}`, true}, + {"TTL overflow", `{` + ns + `,"defaultTTL":86401}`, false}, + {"unknown parameter", `{` + ns + `,"identityRef":{}}`, false}, + } + for _, key := range []string{"refresh", "retry", "expire", "minimum"} { + for _, boundary := range []struct { + value string + valid bool + }{{"0", false}, {"1", true}, {"2419200", true}, {"2419201", false}} { + cases = append(cases, struct { + name, parameters string + valid bool + }{key + boundary.value, `{` + ns + `,"soa":{"` + key + `":` + boundary.value + `}}`, boundary.valid}) + } + } + for _, field := range []string{"views", "delegation"} { + for _, endpoints := range []struct { + name, value string + valid bool + }{ + {"empty", `[]`, true}, + {"three", `["192.0.2.1:53","192.0.2.2:53","[2001:db8::53]:65535"]`, true}, + {"four", `["192.0.2.1:53","192.0.2.2:53","192.0.2.3:53","192.0.2.4:53"]`, false}, + {"bad IP", `["999.0.2.53:53"]`, false}, + {"hostname", `["dns.example.test:53"]`, false}, + {"port overflow", `["192.0.2.53:65536"]`, false}, + {"port zero", `["192.0.2.53:0"]`, false}, + {"leading zero port", `["192.0.2.53:053"]`, false}, + {"duplicate", `["192.0.2.53:53","192.0.2.53:53"]`, false}, + {"noncanonical IPv6", `["[2001:db8:0:0:0:0:0:53]:53"]`, false}, + {"bracketed IPv4", `["[192.0.2.53]:53"]`, false}, + } { + value := endpoints.value + valid := endpoints.valid + if field == "delegation" { + value = `{"resolver":{"mode":"Recursive","servers":` + value + `}}` + valid = valid && endpoints.name != "empty" + } + cases = append(cases, struct { + name, parameters string + valid bool + }{field + " " + endpoints.name, `{` + ns + `,"verification":{"` + field + `":` + value + `}}`, valid}) + } + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + class := &dnsv1alpha1.ZoneClass{Spec: dnsv1alpha1.ZoneClassSpec{Parameters: runtime.RawExtension{Raw: []byte(tc.parameters)}}} + _, schemaErr := providercontract.ConvertZoneClassParametersToStorage(provider, version, class) + valid := schemaErr == nil + if valid { + var params map[string]interface{} + if err := json.Unmarshal([]byte(tc.parameters), ¶ms); err != nil { + t.Fatal(err) + } + self := map[string]interface{}{"spec": map[string]interface{}{"parameters": params}} + for _, rule := range version.ZoneClass.ValidationRules { + ok, err := celruntime.EvalBool(rule.Rule, map[string]interface{}{"self": self}, cel.Variable("self", cel.DynType)) + if err != nil || !ok { + valid = false + break + } + } + } + if valid != tc.valid { + t.Fatalf("valid=%v want=%v schemaError=%v", valid, tc.valid, schemaErr) + } + }) + } +} + +func TestDefaultsMatchAPIConstants(t *testing.T) { + provider, err := Provider() + if err != nil { + t.Fatal(err) + } + var schema struct { + Properties map[string]struct { + Default uint32 `json:"default"` + Properties map[string]struct { + Default uint32 `json:"default"` + } `json:"properties"` + } `json:"properties"` + } + if err := json.Unmarshal(provider.Spec.Versions[0].ZoneClass.Schemas.Parameters.OpenAPIV3Schema.Raw, &schema); err != nil { + t.Fatal(err) + } + if schema.Properties["defaultTTL"].Default != hostedv1alpha1.DefaultTTL { + t.Fatal("defaultTTL differs from API default") + } + for key, want := range map[string]uint32{"refresh": hostedv1alpha1.DefaultSOARefresh, "retry": hostedv1alpha1.DefaultSOARetry, "expire": hostedv1alpha1.DefaultSOAExpire, "minimum": hostedv1alpha1.DefaultSOAMinimum} { + if schema.Properties["soa"].Properties[key].Default != want { + t.Fatalf("soa.%s default differs from %d", key, want) + } + } +} + +func TestApexNSRules(t *testing.T) { + provider, err := Provider() + if err != nil { + t.Fatal(err) + } + version := &provider.Spec.Versions[0] + if len(version.RecordSet.ValidationRules) != 1 || len(version.ZoneUnit.ValidationRules) != 1 { + t.Fatal("both admission and composition must reject apex NS claims") + } + for _, tc := range []struct { + name, recordType string + valid bool + }{{"@", "NS", false}, {"sub", "NS", true}, {"@", "A", true}} { + item := map[string]interface{}{"name": tc.name, "type": tc.recordType} + ok, err := celruntime.EvalBool(version.RecordSet.ValidationRules[0].Rule, map[string]interface{}{"self": map[string]interface{}{"spec": item}}, cel.Variable("self", cel.DynType)) + if err != nil || ok != tc.valid { + t.Fatalf("admission %v valid=%v error=%v", item, ok, err) + } + for _, reverse := range []bool{false, true} { + self, other := item, map[string]interface{}{"name": "@", "type": "TXT"} + if reverse { + self, other = other, self + } + ok, err := celruntime.EvalBool(version.ZoneUnit.ValidationRules[0].Rule, map[string]interface{}{"self": self, "other": other}, cel.Variable("self", cel.DynType), cel.Variable("other", cel.DynType)) + if err != nil || ok != tc.valid { + t.Fatalf("composition %v valid=%v error=%v", item, ok, err) + } + } + } +} + +func TestNonemptyProviderPayloadsRejected(t *testing.T) { + provider, err := Provider() + if err != nil { + t.Fatal(err) + } + version := &provider.Spec.Versions[0] + for _, target := range []providercontract.PayloadTarget{providercontract.TargetZone, providercontract.TargetRecordSet} { + input := providercontract.ConversionInput{Provider: provider, FromVersion: version, ToVersion: version, Target: target, Payload: providercontract.Payload{Adoption: runtime.RawExtension{Raw: []byte(`{"enabled":true}`)}}} + if _, err := providercontract.ConvertProviderPayloadToStorage(input); err == nil { + t.Fatalf("nonempty adoption accepted for %s", target) + } + } + input := providercontract.ConversionInput{Provider: provider, FromVersion: version, ToVersion: version, Target: providercontract.TargetRecordSet, Payload: providercontract.Payload{Options: runtime.RawExtension{Raw: []byte(`{"alias":{}}`)}}} + if _, err := providercontract.ConvertProviderPayloadToStorage(input); err == nil { + t.Fatal("nonempty options accepted") + } +} + +func TestStatusProviderDataSchemas(t *testing.T) { + provider, err := Provider() + if err != nil { + t.Fatal(err) + } + version := &provider.Spec.Versions[0] + const digest = "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + const server = `{"endpoint":"dns/server","serial":1,"contentDigest":"` + digest + `","observedAt":"2026-10-21T00:00:00Z"}` + const delegation = `"observedNameServers":["ns.example.test"],"matches":true,"lastProbeTime":"2026-10-21T00:00:00Z"` + cases := []struct { + name, payload string + zone bool + valid bool + }{ + {"zone complete", `{"serial":1,"contentDigest":"` + digest + `","servers":[` + server + `],"delegation":{` + delegation + `}}`, true, true}, + {"zone without delegation", `{"serial":1,"contentDigest":"` + digest + `","servers":[]}`, true, true}, + {"zone max serial", `{"serial":4294967295}`, true, true}, + {"zone serial overflow", `{"serial":4294967296}`, true, false}, + {"zone zero serial", `{"serial":0}`, true, false}, + {"zone malformed digest", `{"contentDigest":"bad"}`, true, false}, + {"server malformed digest", `{"servers":[` + strings.Replace(server, digest, "bad", 1) + `]}`, true, false}, + {"server missing digest", `{"servers":[{"endpoint":"dns/server","serial":1,"observedAt":"2026-10-21T00:00:00Z"}]}`, true, false}, + {"64 servers", `{"servers":[` + strings.Repeat(server+",", 63) + server + `]}`, true, true}, + {"65 servers", `{"servers":[` + strings.Repeat(server+",", 64) + server + `]}`, true, false}, + {"delegation error", `{"delegation":{` + delegation + `,"error":"DNSProbeFailed"}}`, true, true}, + {"512 error", `{"delegation":{` + delegation + `,"error":"` + strings.Repeat("x", 512) + `"}}`, true, true}, + {"513 error", `{"delegation":{` + delegation + `,"error":"` + strings.Repeat("x", 513) + `"}}`, true, false}, + {"16 nameservers", `{"delegation":{"observedNameServers":[` + strings.Repeat(`"ns.example.test",`, 15) + `"ns.example.test"],"matches":true,"lastProbeTime":"2026-10-21T00:00:00Z"}}`, true, true}, + {"17 nameservers", `{"delegation":{"observedNameServers":[` + strings.Repeat(`"ns.example.test",`, 16) + `"ns.example.test"],"matches":true,"lastProbeTime":"2026-10-21T00:00:00Z"}}`, true, false}, + {"obsolete delegation failure", `{"delegation":{` + delegation + `,"failure":"DNSProbeFailed"}}`, true, false}, + {"zone unknown data", `{"unexpected":true}`, true, false}, + {"record pending", `{"serial":1}`, false, true}, + {"record served", `{"serial":1,"servedAt":"2026-10-21T00:00:00Z"}`, false, true}, + {"record max serial", `{"serial":4294967295}`, false, true}, + {"record missing serial", `{}`, false, false}, + {"record zero serial", `{"serial":0}`, false, false}, + {"record serial overflow", `{"serial":4294967296}`, false, false}, + {"record invalid servedAt", `{"serial":1,"servedAt":"not-a-timestamp"}`, false, false}, + {"record unknown data", `{"serial":1,"unexpected":true}`, false, false}, + } + validators := make(map[bool]apiservervalidation.SchemaValidator, 2) + for zone, schema := range map[bool]*dnsv1alpha1.ProviderOpenAPISchema{ + true: version.Zone.Schemas.StatusProviderData, + false: version.RecordSet.Schemas.StatusProviderData, + } { + if schema == nil { + t.Fatalf("statusProviderData schema missing: zone=%v", zone) + } + var schemaV1 apiextensionsv1.JSONSchemaProps + if err := json.Unmarshal(schema.OpenAPIV3Schema.Raw, &schemaV1); err != nil { + t.Fatal(err) + } + assertSchemaDescriptions(t, schemaV1) + if zone && schemaV1.Properties["delegation"].Properties["matches"].Description != "observed apex NS name set (lowercase, no trailing dot) equals the configured nameServers[].name set" { + t.Fatal("delegation.matches must describe normalized apex NS set equality") + } + var internal apiextensions.JSONSchemaProps + if err := apiextensionsv1.Convert_v1_JSONSchemaProps_To_apiextensions_JSONSchemaProps(&schemaV1, &internal, nil); err != nil { + t.Fatal(err) + } + validator, _, err := apiservervalidation.NewSchemaValidator(&internal) + if err != nil { + t.Fatal(err) + } + validators[zone] = validator + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + var payload map[string]interface{} + if err := json.Unmarshal([]byte(tc.payload), &payload); err != nil { + t.Fatal(err) + } + result := validators[tc.zone].Validate(payload) + if result.IsValid() != tc.valid { + t.Fatalf("valid=%v want=%v errors=%v", result.IsValid(), tc.valid, result.Errors) + } + }) + } +} + +func assertSchemaDescriptions(t *testing.T, schema apiextensionsv1.JSONSchemaProps) { + t.Helper() + if schema.Description == "" { + t.Fatalf("public status schema is missing a description: %#v", schema) + } + for name, property := range schema.Properties { + t.Run(name, func(t *testing.T) { assertSchemaDescriptions(t, property) }) + } + if schema.Items != nil && schema.Items.Schema != nil { + assertSchemaDescriptions(t, *schema.Items.Schema) + } +} diff --git a/internal/go/providers/hosted/manifest/provider.yaml b/internal/go/providers/hosted/manifest/provider.yaml new file mode 100644 index 0000000..de466b3 --- /dev/null +++ b/internal/go/providers/hosted/manifest/provider.yaml @@ -0,0 +1,312 @@ +apiVersion: dns.appthrust.io/v1alpha1 +kind: Provider +metadata: + name: hosted.dns.appthrust.io +spec: + display: + name: Hosted DNS + description: Serves zones authoritatively from Kubernetes; no external DNS provider. + versions: + - name: v1alpha1 + served: true + storage: true + zoneClass: + schemas: + parameters: + openAPIV3Schema: + type: object + description: Authoritative nameservers, SOA policy, and read-only verification views. + additionalProperties: false + required: [nameServers] + properties: + nameServers: + type: array + description: Authoritative names and deterministic glue addresses. + minItems: 1 + maxItems: 8 + items: + type: object + additionalProperties: false + required: [name, addresses] + properties: + name: + type: string + description: Canonical lowercase FQDN without a trailing root dot. + minLength: 1 + maxLength: 253 + pattern: '^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?(\.[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?)*$' + addresses: + type: array + description: Canonical IPv4 or IPv6 glue addresses. + minItems: 1 + maxItems: 8 + items: + type: string + minLength: 2 + maxLength: 45 + soa: + type: object + description: SOA policy; mname is the first nameserver and serial is controller-owned. + additionalProperties: false + properties: + rname: + type: string + description: Canonical mailbox name; omitted means hostmaster.. + minLength: 1 + maxLength: 253 + pattern: '^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?(\.[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?)*$' + refresh: + type: integer + minimum: 1 + maximum: 2419200 + default: 3600 + retry: + type: integer + minimum: 1 + maximum: 2419200 + default: 900 + expire: + type: integer + minimum: 1 + maximum: 2419200 + default: 1209600 + minimum: + type: integer + minimum: 1 + maximum: 2419200 + default: 300 + defaultTTL: + type: integer + description: TTL of apex NS, glue, and endpoint-converted RRsets. + minimum: 1 + maximum: 86400 + default: 60 + verification: + type: object + additionalProperties: false + properties: + views: + type: array + description: Canonical IP:port DNS endpoints; omitted views use the hosted server Service. + maxItems: 3 + uniqueItems: true + items: + type: string + minLength: 3 + maxLength: 64 + pattern: '^(\[[0-9a-f:.]+\]|[0-9.]+):[1-9][0-9]{0,4}$' + delegation: + type: object + description: Resolver policy for read-only observation of parent NS delegation. + additionalProperties: false + properties: + resolver: + type: object + description: Resolver view used for every parent delegation query. + additionalProperties: false + properties: + mode: + type: string + description: Cluster uses pod DNS; Recursive uses every configured server. Omitted means Cluster. + enum: [Cluster, Recursive] + servers: + type: array + description: Explicit recursive DNS IP endpoints; never authoritative NS names. + maxItems: 3 + uniqueItems: true + items: + type: string + description: One recursive resolver endpoint as IPv4:port or [IPv6]:port. + minLength: 3 + maxLength: 64 + pattern: '^(\[[0-9a-f:.]+\]|[0-9.]+):[1-9][0-9]{0,4}$' + validationRules: + - rule: >- + self.spec.parameters.nameServers.all(n, + !isIP(n.name) && n.addresses.all(a, isIP(a) && ip.isCanonical(a))) + message: nameServers must contain DNS names and canonical IP addresses + - rule: >- + !has(self.spec.parameters.soa) || !has(self.spec.parameters.soa.rname) || + !isIP(self.spec.parameters.soa.rname) + message: soa.rname must be a DNS mailbox name, not an IP address + - rule: >- + !has(self.spec.parameters.verification) || !has(self.spec.parameters.verification.views) || + self.spec.parameters.verification.views.all(s, + cel.bind(host, s.startsWith('[') ? s.split(']')[0].substring(1) : s.split(':')[0], + cel.bind(port, s.startsWith('[') ? s.split(']:')[1] : s.split(':')[1], + isIP(host) && ip.isCanonical(host) && + (s.startsWith('[') ? ip(host).family() == 6 : ip(host).family() == 4) && + int(port) >= 1 && int(port) <= 65535))) + message: verification.views must contain canonical IP addresses and ports from 1 through 65535 + - rule: >- + has(self.spec.parameters.verification) && + has(self.spec.parameters.verification.delegation) && + has(self.spec.parameters.verification.delegation.resolver) && + has(self.spec.parameters.verification.delegation.resolver.mode) && + self.spec.parameters.verification.delegation.resolver.mode == 'Recursive' + ? (has(self.spec.parameters.verification.delegation.resolver.servers) && size(self.spec.parameters.verification.delegation.resolver.servers) > 0) + : (!has(self.spec.parameters.verification) || !has(self.spec.parameters.verification.delegation) || + !has(self.spec.parameters.verification.delegation.resolver) || + !has(self.spec.parameters.verification.delegation.resolver.servers) || + size(self.spec.parameters.verification.delegation.resolver.servers) == 0) + message: verification.delegation.resolver.servers must be nonempty for Recursive and empty for Cluster + - rule: >- + !has(self.spec.parameters.verification) || !has(self.spec.parameters.verification.delegation) || + !has(self.spec.parameters.verification.delegation.resolver) || + !has(self.spec.parameters.verification.delegation.resolver.servers) || + self.spec.parameters.verification.delegation.resolver.servers.all(s, + cel.bind(host, s.startsWith('[') ? s.split(']')[0].substring(1) : s.split(':')[0], + cel.bind(port, s.startsWith('[') ? s.split(']:')[1] : s.split(':')[1], + isIP(host) && ip.isCanonical(host) && + (s.startsWith('[') ? ip(host).family() == 6 : ip(host).family() == 4) && + int(port) >= 1 && int(port) <= 65535))) + message: verification.delegation.resolver.servers must contain canonical IP addresses and ports from 1 through 65535 + zone: + schemas: + adoption: &empty + openAPIV3Schema: + type: object + additionalProperties: false + properties: {} + statusProviderData: + openAPIV3Schema: + type: object + description: Current hosted snapshot and bounded serving and delegation observations. + additionalProperties: false + properties: + serial: + type: integer + description: Serial of the current desired hosted snapshot. + minimum: 1 + maximum: 4294967295 + contentDigest: + type: string + description: SHA-256 digest binding the desired serial to exact rendered zone content. + pattern: '^sha256:[0-9a-f]{64}$' + servers: + type: array + description: Up to sixty-four observed hosted server acknowledgements. + maxItems: 64 + items: + type: object + description: Exact snapshot acknowledged by one hosted server. + additionalProperties: false + required: [endpoint, serial, contentDigest, observedAt] + properties: + endpoint: + type: string + description: Server pod identity as namespace/name, not a DNS resolver endpoint. + minLength: 1 + serial: + type: integer + description: Snapshot serial loaded by this server. + minimum: 1 + maximum: 4294967295 + contentDigest: + type: string + description: SHA-256 digest of the exact snapshot loaded by this server. + pattern: '^sha256:[0-9a-f]{64}$' + observedAt: + type: string + description: Time at which this server acknowledged its loaded snapshot. + format: date-time + delegation: + type: object + description: Latest parent delegation observation; never changes Programmed. + additionalProperties: false + required: [observedNameServers, matches, lastProbeTime] + properties: + observedNameServers: + type: array + description: Up to sixteen observed apex NS names, normalized to lowercase without a trailing dot. + maxItems: 16 + items: + type: string + description: One normalized authoritative nameserver name observed at the zone apex. + maxLength: 253 + matches: + type: boolean + description: observed apex NS name set (lowercase, no trailing dot) equals the configured nameServers[].name set + lastProbeTime: + type: string + description: Time of the last completed delegation probe attempt, including failures. + format: date-time + error: + type: string + description: Bounded public diagnostic for a failed delegation probe; omitted on success. + maxLength: 512 + additionalConditionReasons: + - {conditionType: Programmed, status: 'False', reason: ServingPending, description: No hosted server has acknowledged the current snapshot.} + - {conditionType: Programmed, status: 'False', reason: SnapshotRejected, description: The API server rejected the snapshot or an unowned snapshot shadows the unit.} + recordSet: + supportedTypes: [A, AAAA, CNAME, TXT, MX, CAA, NS] + schemas: + options: *empty + adoption: *empty + statusProviderData: + openAPIV3Schema: + type: object + description: Snapshot publication evidence for this record set generation. + additionalProperties: false + required: [serial] + properties: + serial: + type: integer + description: First snapshot serial containing this record set generation. + minimum: 1 + maximum: 4294967295 + servedAt: + type: string + format: date-time + description: Time that a hosted server acknowledged this generation's snapshot; omitted while serving is pending. + additionalConditionReasons: + - {conditionType: Programmed, status: 'False', reason: ServingPending, description: No hosted server has acknowledged the snapshot containing this generation.} + validationRules: + - rule: "!(self.spec.name == '@' && self.spec.type == 'NS')" + message: apex NS is owned by the ZoneClass nameServers parameter + zoneUnit: + validationRules: + - rule: "!(self.name == '@' && self.type == 'NS') && !(other.name == '@' && other.type == 'NS')" + message: apex NS is owned by the ZoneClass nameServers parameter + recordWithdrawal: + supported: true + schemas: + statusProviderData: + openAPIV3Schema: + type: object + description: Bounded withdrawal diagnostics; complete typed evidence drives verification. + additionalProperties: false + properties: + views: + type: object + description: Sorted bounded diagnostic preview; equality uses the complete observation. + additionalProperties: false + required: [total, truncated] + properties: + values: + type: array + description: Up to sixteen typed values or diagnostic entries. + maxItems: 16 + items: + type: string + description: One bounded value or diagnostic entry. + maxLength: 512 + total: + type: integer + description: Total entries before truncation. + minimum: 0 + truncated: + type: boolean + description: Whether values omits entries from the complete observation. + additionalConditionReasons: + - {conditionType: Withdrawn, status: 'False', reason: DNSStillResolves, description: A required observation still returns the retired address.} + - {conditionType: Ready, status: 'False', reason: DNSStillResolves, description: A required observation still returns the retired address.} + - {conditionType: Withdrawn, status: 'False', reason: NegativeTTLNotElapsed, description: Cache safety and a separate confirming attempt are pending.} + - {conditionType: Ready, status: 'False', reason: NegativeTTLNotElapsed, description: Cache safety and a separate confirming attempt are pending.} + - {conditionType: Withdrawn, status: 'Unknown', reason: DNSProbeFailed, description: Required exclusion evidence is incomplete.} + - {conditionType: Ready, status: 'False', reason: DNSProbeFailed, description: Required exclusion evidence is incomplete.} + - {conditionType: Withdrawn, status: 'Unknown', reason: PolicyChanged, description: The binding or required view policy changed.} + - {conditionType: Ready, status: 'False', reason: PolicyChanged, description: The binding or required view policy changed.} + - {conditionType: Withdrawn, status: 'True', reason: WithdrawalVerified, description: Complete exclusion verified after the cache horizon.} + - {conditionType: Ready, status: 'True', reason: WithdrawalVerified, description: Complete exclusion verified after the cache horizon.} + - {conditionType: OverrideAccepted, status: 'True', reason: OperatorAuthorizedRelease, description: An exact separately authorized waiver accepts stale DNS risk.} diff --git a/internal/go/providers/hosted/render/digest.go b/internal/go/providers/hosted/render/digest.go new file mode 100644 index 0000000..ac65fa2 --- /dev/null +++ b/internal/go/providers/hosted/render/digest.go @@ -0,0 +1,88 @@ +package render + +import ( + "bytes" + "crypto/sha256" + "encoding/binary" + "encoding/hex" + "fmt" + "net/netip" + "slices" + + hostedv1alpha1 "github.com/appthrust/dns-api/pkg/go/api/hosted/v1alpha1" + "github.com/miekg/dns" +) + +// ContentDigest hashes sorted, uncompressed canonical DNS wire records. Serial +// is zeroed, so only content changes create a revision. An invalid wire record +// returns an empty digest and cannot be published by Render. +func ContentDigest(spec hostedv1alpha1.HostedZoneSpec) string { + wire := make([][]byte, 0, len(spec.Records)+1) + add := func(rr dns.RR) bool { + data := make([]byte, dns.Len(rr)) + n, err := dns.PackRR(rr, data, 0, nil, false) + if err != nil { + return false + } + wire = append(wire, data[:n]) + return true + } + soa := &dns.SOA{Hdr: dns.RR_Header{Name: dns.Fqdn(canonicalName(spec.DomainName)), Rrtype: dns.TypeSOA, Class: dns.ClassINET}, Ns: dns.Fqdn(canonicalName(spec.SOA.MName)), Mbox: dns.Fqdn(canonicalName(spec.SOA.RName)), Refresh: spec.SOA.Refresh, Retry: spec.SOA.Retry, Expire: spec.SOA.Expire, Minttl: spec.SOA.Minimum} + if !add(soa) { + return "" + } + // Name-server addresses outside the zone are not served glue, but they remain + // declared snapshot content and must be covered by the digest as well. + for _, server := range spec.NameServers { + if !add(&dns.NS{Hdr: dns.RR_Header{Name: soa.Hdr.Name, Rrtype: dns.TypeNS, Class: dns.ClassINET}, Ns: dns.Fqdn(canonicalName(server.Name))}) { + return "" + } + for _, address := range server.Addresses { + ip, err := netip.ParseAddr(address) + if err != nil { + return "" + } + kind := "AAAA" + if ip.Is4() { + kind = "A" + } + rr, err := dns.NewRR(fmt.Sprintf("%s 0 IN %s %s", dns.Fqdn(canonicalName(server.Name)), kind, ip.String())) + if err != nil || !add(rr) { + return "" + } + } + } + for _, record := range spec.Records { + name := canonicalName(record.Name) + "." + canonicalName(spec.DomainName) + if record.Name == "@" { + name = canonicalName(spec.DomainName) + } + for _, value := range record.Values { + rr, err := dns.NewRR(fmt.Sprintf("%s %d IN %s %s", dns.Fqdn(name), record.TTL, record.Type, value)) + if err != nil { + return "" + } + switch typed := rr.(type) { + case *dns.NS: + typed.Ns = dns.Fqdn(canonicalName(typed.Ns)) + case *dns.CNAME: + typed.Target = dns.Fqdn(canonicalName(typed.Target)) + case *dns.MX: + typed.Mx = dns.Fqdn(canonicalName(typed.Mx)) + } + if !add(rr) { + return "" + } + } + } + slices.SortFunc(wire, bytes.Compare) + wire = slices.CompactFunc(wire, bytes.Equal) + hash := sha256.New() + var size [4]byte + for _, rr := range wire { + binary.BigEndian.PutUint32(size[:], uint32(len(rr))) + _, _ = hash.Write(size[:]) + _, _ = hash.Write(rr) + } + return "sha256:" + hex.EncodeToString(hash.Sum(nil)) +} diff --git a/internal/go/providers/hosted/render/render.go b/internal/go/providers/hosted/render/render.go new file mode 100644 index 0000000..01c0450 --- /dev/null +++ b/internal/go/providers/hosted/render/render.go @@ -0,0 +1,243 @@ +// Package render builds the complete, deterministic hosted serving snapshot. +package render + +import ( + "encoding/json" + "fmt" + "math" + "net/netip" + "slices" + "strconv" + "strings" + "time" + + dnsv1alpha1 "github.com/appthrust/dns-api/pkg/go/api/dns/v1alpha1" + hostedv1alpha1 "github.com/appthrust/dns-api/pkg/go/api/hosted/v1alpha1" + "github.com/miekg/dns" +) + +// Render omits cleanup ledger entries. The previous digest, when available, is +// read from the unit's public zone receipt; the controller also compares the +// authoritative snapshot so a crash between snapshot and receipt cannot bump it. +func Render(unit *dnsv1alpha1.ZoneUnit, params hostedv1alpha1.HostedZoneClassParameters, previousSerial uint32, now time.Time) (hostedv1alpha1.HostedZoneSpec, error) { + var spec hostedv1alpha1.HostedZoneSpec + if unit == nil || len(params.NameServers) == 0 { + return spec, fmt.Errorf("hosted rendering requires a ZoneUnit and at least one name server") + } + spec.DomainName = canonicalName(unit.Spec.Zone.DomainName) + spec.SOA = hostedv1alpha1.SOA{MName: canonicalName(params.NameServers[0].Name), RName: canonicalName(params.SOA.RName), Refresh: defaultNumber(params.SOA.Refresh, hostedv1alpha1.DefaultSOARefresh), Retry: defaultNumber(params.SOA.Retry, hostedv1alpha1.DefaultSOARetry), Expire: defaultNumber(params.SOA.Expire, hostedv1alpha1.DefaultSOAExpire), Minimum: defaultNumber(params.SOA.Minimum, hostedv1alpha1.DefaultSOAMinimum)} + if spec.SOA.RName == "" { + spec.SOA.RName = "hostmaster." + spec.DomainName + } + ttl := defaultNumber(params.DefaultTTL, hostedv1alpha1.DefaultTTL) + records := make(map[string]hostedv1alpha1.HostedZoneRecord, len(unit.Spec.RecordSets)+1) + add := func(record hostedv1alpha1.HostedZoneRecord) error { + key := record.Name + "/" + string(record.Type) + if old, found := records[key]; found { + if old.TTL != record.TTL { + return fmt.Errorf("conflicting TTLs for %s", key) + } + record.Values = append(old.Values, record.Values...) + } + slices.Sort(record.Values) + record.Values = slices.Compact(record.Values) + if len(record.Values) > 64 { + return fmt.Errorf("record %s exceeds 64 values", key) + } + records[key] = record + return nil + } + for _, item := range unit.Spec.RecordSets { + if !item.IsAllowed() || item.DeletionRequested { + continue + } + if item.Name == "@" && item.Type == dnsv1alpha1.RecordTypeNS { + return spec, fmt.Errorf("apex NS is owned by nameServers") + } + record, err := renderRecord(item, ttl) + if err != nil { + return spec, err + } + if err = add(record); err != nil { + return spec, err + } + } + ns := hostedv1alpha1.HostedZoneRecord{Name: "@", Type: dnsv1alpha1.RecordTypeNS, TTL: ttl} + for _, server := range params.NameServers { + server.Name = canonicalName(server.Name) + server.Addresses = slices.Clone(server.Addresses) + for i, address := range server.Addresses { + ip, err := netip.ParseAddr(address) + if err != nil { + return spec, fmt.Errorf("invalid name server address %q", address) + } + server.Addresses[i] = ip.String() + if server.Name != spec.DomainName && !strings.HasSuffix(server.Name, "."+spec.DomainName) { + continue + } + name := strings.TrimSuffix(server.Name, "."+spec.DomainName) + if server.Name == spec.DomainName { + name = "@" + } + kind := dnsv1alpha1.RecordTypeAAAA + if ip.Is4() { + kind = dnsv1alpha1.RecordTypeA + } + if err := add(hostedv1alpha1.HostedZoneRecord{Name: name, Type: kind, TTL: ttl, Values: []string{ip.String()}}); err != nil { + return spec, err + } + } + slices.Sort(server.Addresses) + server.Addresses = slices.Compact(server.Addresses) + spec.NameServers = append(spec.NameServers, server) + ns.Values = append(ns.Values, dns.Fqdn(server.Name)) + } + slices.SortFunc(spec.NameServers, func(a, b hostedv1alpha1.NameServer) int { return strings.Compare(a.Name, b.Name) }) + if err := add(ns); err != nil { + return spec, err + } + if len(records) > 10000 { + return spec, fmt.Errorf("snapshot exceeds 10000 RRsets") + } + spec.Records = make([]hostedv1alpha1.HostedZoneRecord, 0, len(records)) + for _, record := range records { + spec.Records = append(spec.Records, record) + } + slices.SortFunc(spec.Records, func(a, b hostedv1alpha1.HostedZoneRecord) int { + if n := strings.Compare(a.Name, b.Name); n != 0 { + return n + } + return strings.Compare(string(a.Type), string(b.Type)) + }) + spec.ContentDigest = ContentDigest(spec) + if spec.ContentDigest == "" { + return spec, fmt.Errorf("snapshot contains invalid DNS wire data") + } + var previous struct { + ContentDigest string `json:"contentDigest"` + } + if unit.Status.Zone != nil && unit.Status.Zone.Provider != nil { + _ = json.Unmarshal(unit.Status.Zone.Provider.Data.Raw, &previous) + } + if previousSerial != 0 && previous.ContentDigest == spec.ContentDigest { + spec.Serial = previousSerial + return spec, nil + } + if previousSerial == math.MaxUint32 { + return spec, fmt.Errorf("hosted snapshot serial exhausted") + } + spec.Serial = max(previousSerial+1, uint32(now.Unix())) + return spec, nil +} + +func defaultNumber(value, fallback uint32) uint32 { + if value == 0 { + return fallback + } + return value +} +func canonicalName(value string) string { return strings.TrimSuffix(strings.ToLower(value), ".") } + +func renderRecord(item dnsv1alpha1.ZoneUnitRecordSetSpec, defaultTTL uint32) (hostedv1alpha1.HostedZoneRecord, error) { + record := hostedv1alpha1.HostedZoneRecord{Name: strings.ToLower(item.Name), Type: item.Type, TTL: defaultTTL} + if item.TTL != nil { + if *item.TTL < 1 { + return record, fmt.Errorf("invalid TTL for %s", item.Name) + } + record.TTL = uint32(*item.TTL) + } + switch item.Type { + case dnsv1alpha1.RecordTypeA: + if item.A != nil { + for _, value := range item.A.Addresses { + ip, err := netip.ParseAddr(value) + if err != nil || !ip.Is4() { + return record, fmt.Errorf("invalid A address %q", value) + } + record.Values = append(record.Values, ip.String()) + } + } + case dnsv1alpha1.RecordTypeAAAA: + if item.AAAA != nil { + for _, value := range item.AAAA.Addresses { + ip, err := netip.ParseAddr(value) + if err != nil || !ip.Is6() { + return record, fmt.Errorf("invalid AAAA address %q", value) + } + record.Values = append(record.Values, ip.String()) + } + } + case dnsv1alpha1.RecordTypeCNAME: + if item.CNAME != nil { + record.Values = []string{dns.Fqdn(canonicalName(item.CNAME.Target))} + } + case dnsv1alpha1.RecordTypeNS: + if item.NS != nil { + for _, value := range item.NS.NameServers { + record.Values = append(record.Values, dns.Fqdn(canonicalName(value))) + } + } + case dnsv1alpha1.RecordTypeTXT: + if item.TXT != nil { + for _, value := range item.TXT.Values { + record.Values = append(record.Values, quoteTXT(value)) + } + } + case dnsv1alpha1.RecordTypeMX: + if item.MX != nil { + for _, value := range item.MX.Records { + record.Values = append(record.Values, strconv.Itoa(int(value.Preference))+" "+dns.Fqdn(canonicalName(value.Exchange))) + } + } + case dnsv1alpha1.RecordTypeCAA: + if item.CAA != nil { + for _, value := range item.CAA.Records { + record.Values = append(record.Values, strconv.Itoa(int(value.Flags))+" "+strings.ToLower(value.Tag)+" "+quote(value.Value)) + } + } + default: + return record, fmt.Errorf("unsupported hosted record type %q", item.Type) + } + if len(record.Values) == 0 { + return record, fmt.Errorf("record %s/%s has no values", item.Name, item.Type) + } + return record, nil +} + +// DNS character strings use decimal byte escapes, not Go's hex/unicode escapes. +func quote(value string) string { + var out strings.Builder + out.WriteByte('"') + for i := range len(value) { + c := value[i] + switch { + case c == '"' || c == '\\': + out.WriteByte('\\') + out.WriteByte(c) + case c < 32 || c > 126: + out.WriteByte('\\') + out.WriteByte('0' + c/100) + out.WriteByte('0' + c/10%10) + out.WriteByte('0' + c%10) + default: + out.WriteByte(c) + } + } + out.WriteByte('"') + return out.String() +} +func quoteTXT(value string) string { + if len(value) <= 255 { + return quote(value) + } + var out strings.Builder + for len(value) > 0 { + n := min(255, len(value)) + if out.Len() > 0 { + out.WriteByte(' ') + } + out.WriteString(quote(value[:n])) + value = value[n:] + } + return out.String() +} diff --git a/internal/go/providers/hosted/render/render_test.go b/internal/go/providers/hosted/render/render_test.go new file mode 100644 index 0000000..3792d2c --- /dev/null +++ b/internal/go/providers/hosted/render/render_test.go @@ -0,0 +1,144 @@ +package render + +import ( + "encoding/json" + "math" + "reflect" + "slices" + "strings" + "testing" + "time" + + dnsv1alpha1 "github.com/appthrust/dns-api/pkg/go/api/dns/v1alpha1" + hostedv1alpha1 "github.com/appthrust/dns-api/pkg/go/api/hosted/v1alpha1" + "github.com/miekg/dns" + "k8s.io/apimachinery/pkg/runtime" +) + +func paramsFixture() hostedv1alpha1.HostedZoneClassParameters { + return hostedv1alpha1.HostedZoneClassParameters{NameServers: []hostedv1alpha1.NameServer{{Name: "ns.example.test", Addresses: []string{"2001:db8::53", "192.0.2.53"}}, {Name: "ns.other.test", Addresses: []string{"192.0.2.54"}}}} +} +func unitFixture() *dnsv1alpha1.ZoneUnit { + ttl := int32(123) + return &dnsv1alpha1.ZoneUnit{Spec: dnsv1alpha1.ZoneUnitSpec{Zone: dnsv1alpha1.ZoneUnitZoneSpec{DomainName: "example.test"}, RecordSets: []dnsv1alpha1.ZoneUnitRecordSetSpec{ + {Name: "a", Type: dnsv1alpha1.RecordTypeA, TTL: &ttl, A: &dnsv1alpha1.ARecordSet{Addresses: []string{"192.0.2.2", "192.0.2.1", "192.0.2.1"}}}, + {Name: "aaaa", Type: dnsv1alpha1.RecordTypeAAAA, TTL: &ttl, AAAA: &dnsv1alpha1.AAAARecordSet{Addresses: []string{"2001:0db8:0:0::1"}}}, + {Name: "cname", Type: dnsv1alpha1.RecordTypeCNAME, TTL: &ttl, CNAME: &dnsv1alpha1.CNAMERecordSet{Target: "TARGET.Example.TEST."}}, + {Name: "txt", Type: dnsv1alpha1.RecordTypeTXT, TTL: &ttl, TXT: &dnsv1alpha1.TXTRecordSet{Values: []string{"a\"b\\c\n", strings.Repeat("a", 256)}}}, + {Name: "mx", Type: dnsv1alpha1.RecordTypeMX, TTL: &ttl, MX: &dnsv1alpha1.MXRecordSet{Records: []dnsv1alpha1.MXRecord{{Preference: 10, Exchange: "MAIL.Example.TEST"}}}}, + {Name: "caa", Type: dnsv1alpha1.RecordTypeCAA, TTL: &ttl, CAA: &dnsv1alpha1.CAARecordSet{Records: []dnsv1alpha1.CAARecord{{Flags: 0, Tag: "issue", Value: "ca.example"}}}}, + {Name: "child", Type: dnsv1alpha1.RecordTypeNS, TTL: &ttl, NS: &dnsv1alpha1.NSRecordSet{NameServers: []string{"NS.Child.Example.TEST"}}}, + }}} +} +func TestCanonicalSnapshotAndLedgerOmission(t *testing.T) { + unit := unitFixture() + params := paramsFixture() + before := unit.DeepCopy() + paramsBefore := slices.Clone(params.NameServers[0].Addresses) + denied := false + unit.Spec.RecordSets = append(unit.Spec.RecordSets, dnsv1alpha1.ZoneUnitRecordSetSpec{Name: "denied", Allowed: &denied}, dnsv1alpha1.ZoneUnitRecordSetSpec{Name: "deleted", DeletionRequested: true}) + spec, err := Render(unit, params, 0, time.Unix(100, 0)) + if err != nil { + t.Fatal(err) + } + if spec.Serial != 100 || spec.SOA.MName != "ns.example.test" || spec.SOA.RName != "hostmaster.example.test" || spec.SOA.Minimum != 300 { + t.Fatalf("spec=%+v", spec) + } + values := map[string][]string{} + for _, record := range spec.Records { + values[record.Name+"/"+string(record.Type)] = record.Values + } + for key, want := range map[string][]string{"a/A": {"192.0.2.1", "192.0.2.2"}, "aaaa/AAAA": {"2001:db8::1"}, "cname/CNAME": {"target.example.test."}, "mx/MX": {"10 mail.example.test."}, "caa/CAA": {"0 issue \"ca.example\""}, "child/NS": {"ns.child.example.test."}, "@/NS": {"ns.example.test.", "ns.other.test."}, "ns/A": {"192.0.2.53"}, "ns/AAAA": {"2001:db8::53"}} { + if !slices.Equal(values[key], want) { + t.Fatalf("%s=%q want %q", key, values[key], want) + } + } + if len(spec.Records) != 10 { + t.Fatalf("RRsets=%d", len(spec.Records)) + } + if !reflect.DeepEqual(unit.Spec.RecordSets[:7], before.Spec.RecordSets) || !slices.Equal(params.NameServers[0].Addresses, paramsBefore) { + t.Fatal("renderer mutated input") + } + for _, value := range values["txt/TXT"] { + rr, err := dns.NewRR("txt.example.test. 123 IN TXT " + value) + if err != nil { + t.Fatal(err) + } + logical := strings.Join(rr.(*dns.TXT).Txt, "") + if logical != `a\"b\\c\010` && logical != strings.Repeat("a", 256) { + t.Fatalf("TXT=%q", logical) + } + } +} +func TestDigestOrderAndSerial(t *testing.T) { + unit := unitFixture() + params := paramsFixture() + now := time.Unix(100, 0) + first, err := Render(unit, params, 0, now) + if err != nil { + t.Fatal(err) + } + slices.Reverse(unit.Spec.RecordSets) + slices.Reverse(unit.Spec.RecordSets[6].A.Addresses) + slices.Reverse(params.NameServers[0].Addresses) + second, err := Render(unit, params, 100, now) + if err != nil { + t.Fatal(err) + } + if first.ContentDigest != second.ContentDigest || second.Serial != 101 { + t.Fatalf("first=%+v second=%+v", first, second) + } + raw, _ := json.Marshal(map[string]any{"contentDigest": first.ContentDigest}) + unit.Status.Zone = &dnsv1alpha1.ZoneUnitZoneStatus{Provider: &dnsv1alpha1.ProviderStatus{Data: runtime.RawExtension{Raw: raw}}} + unchanged, err := Render(unit, params, 100, time.Unix(200, 0)) + if err != nil || unchanged.Serial != 100 { + t.Fatalf("unchanged=%+v err=%v", unchanged, err) + } + changed := unchanged + changed.Serial = 900 + if ContentDigest(changed) != unchanged.ContentDigest { + t.Fatal("serial entered digest") + } + changed.SOA.Minimum++ + if ContentDigest(changed) == unchanged.ContentDigest { + t.Fatal("SOA absent from digest") + } + unit.Spec.RecordSets[6].A.Addresses = []string{"192.0.2.9"} + monotonic, err := Render(unit, params, 500, now) + if err != nil || monotonic.Serial != 501 { + t.Fatalf("serial=%d err=%v", monotonic.Serial, err) + } + if _, err := Render(unit, params, math.MaxUint32, now); err == nil || err.Error() != "hosted snapshot serial exhausted" { + t.Fatalf("overflow err=%v", err) + } +} +func TestDigestCanonicalWireAndInvalidInput(t *testing.T) { + spec, err := Render(unitFixture(), paramsFixture(), 0, time.Unix(100, 0)) + if err != nil { + t.Fatal(err) + } + copy := spec + copy.Records = slices.Clone(spec.Records) + slices.Reverse(copy.Records) + copy.NameServers = slices.Clone(spec.NameServers) + slices.Reverse(copy.NameServers) + if ContentDigest(copy) != spec.ContentDigest { + t.Fatal("ordering changed digest") + } + for i := range copy.Records { + if copy.Records[i].Type == dnsv1alpha1.RecordTypeCNAME { + copy.Records[i].Values = []string{"TARGET.EXAMPLE.TEST."} + } + } + if ContentDigest(copy) != spec.ContentDigest { + t.Fatal("DNS name case changed wire digest") + } + copy.Records[0].Type = dnsv1alpha1.RecordTypeA + copy.Records[0].Values = []string{"invalid RR data"} + if ContentDigest(copy) != "" { + t.Fatal("invalid presentation accepted") + } + if _, err := Render(nil, paramsFixture(), 0, time.Now()); err == nil { + t.Fatal("nil unit accepted") + } +} diff --git a/internal/go/providers/hosted/server/arbitration_test.go b/internal/go/providers/hosted/server/arbitration_test.go new file mode 100644 index 0000000..e848a9b --- /dev/null +++ b/internal/go/providers/hosted/server/arbitration_test.go @@ -0,0 +1,170 @@ +package server + +import ( + "context" + "testing" + + "github.com/miekg/dns" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +func TestOnlyZoneUnitOwnedSnapshotsAreAdmitted(t *testing.T) { + for _, tc := range []struct { + name string + owners []metav1.OwnerReference + }{ + {"missing", nil}, + {"wrong API", []metav1.OwnerReference{{APIVersion: "other/v1", Kind: "ZoneUnit", UID: "unit-1"}}}, + {"wrong kind", []metav1.OwnerReference{{APIVersion: "dns.appthrust.io/v1alpha1", Kind: "Zone", UID: "unit-1"}}}, + {"missing UID", []metav1.OwnerReference{{APIVersion: "dns.appthrust.io/v1alpha1", Kind: "ZoneUnit"}}}, + } { + t.Run(tc.name, func(t *testing.T) { + index := newZoneIndex(10, newMetrics()) + zone := testZone() + zone.OwnerReferences = tc.owners + if err := index.replace(zone); err == nil || err.Error() != "snapshot has no ZoneUnit owner reference" { + t.Fatalf("admission error = %v", err) + } + if index.lookup("www.example.test.") != nil || metricValue(t, index.metrics.rejected.WithLabelValues("unowned")) != 1 { + t.Fatal("unowned snapshot was not rejected and counted") + } + }) + } +} + +func TestInformerOwnershipRemovalStopsServingAndRetractsAcknowledgement(t *testing.T) { + zone := testZone() + zone.Generation = 1 + client, patches := fakeSnapshotClient(t, zoneObject(t, zone)) + index := newZoneIndex(10, newMetrics()) + w, err := newSnapshotWatcher(client, "dns", "dns/server-1", index) + if err != nil { + t.Fatal(err) + } + startWatcher(t, w) + assertOwnApply(t, receiveStatus(t, patches), 7) + zone.OwnerReferences = nil // Metadata-only update does not advance generation. + if _, err := client.Resource(hostedZones).Namespace("dns").Update(context.Background(), zoneObject(t, zone), metav1.UpdateOptions{}); err != nil { + t.Fatal(err) + } + removed := receiveStatus(t, patches) + if len(removed.object.Status.Servers) != 0 { + t.Fatal("unowned snapshot retained a serving acknowledgement") + } + if index.lookup("www.example.test.") != nil || metricValue(t, index.metrics.rejected.WithLabelValues("unowned")) != 1 { + t.Fatal("removing ownership did not remove authority") + } + zone.OwnerReferences = testZone().OwnerReferences + if _, err := client.Resource(hostedZones).Namespace("dns").Update(context.Background(), zoneObject(t, zone), metav1.UpdateOptions{}); err != nil { + t.Fatal(err) + } + assertOwnApply(t, receiveStatus(t, patches), 7) + if index.lookup("www.example.test.") == nil { + t.Fatal("restored ownership was ignored") + } +} + +func TestCrossNamespaceApexConflictRefusesBothAndRecovers(t *testing.T) { + first := testZone() + second := testZone() + second.Namespace = "other" + second.UID = "zone-2" + client, patches := fakeSnapshotClient(t, zoneObject(t, first), zoneObject(t, second)) + index := newZoneIndex(1, newMetrics()) + w, err := newSnapshotWatcher(client, "", "dns/server-1", index) + if err != nil { + t.Fatal(err) + } + startWatcher(t, w) + waitUntil(t, w.ready.Load) + assertRefused(t, index, "www.example.test.") + if metricValue(t, index.metrics.rejected.WithLabelValues("apex_conflict")) == 0 || metricValue(t, index.metrics.loaded) != 0 { + t.Fatal("conflict not counted or still loaded") + } + select { + case patch := <-patches: + t.Fatalf("conflicting snapshot was acknowledged: %#v", patch.object) + default: + } + if err := client.Resource(hostedZones).Namespace("other").Delete(context.Background(), second.Name, metav1.DeleteOptions{}); err != nil { + t.Fatal(err) + } + assertOwnApply(t, receiveStatus(t, patches), 7) + if index.lookup("www.example.test.") == nil { + t.Fatal("sole survivor was not loaded after conflict deletion") + } + // A second contender must quarantine an already acknowledged zone even + // when admitting another compiled snapshot would exceed MaxZones. + if _, err := client.Resource(hostedZones).Namespace("other").Create(context.Background(), zoneObject(t, second), metav1.CreateOptions{}); err != nil { + t.Fatal(err) + } + removed := receiveStatus(t, patches) + if len(removed.object.Status.Servers) != 0 { + t.Fatal("conflicted snapshot acknowledgement was not retracted") + } + assertRefused(t, index, "www.example.test.") + second.Spec.DomainName = "different.test" + if _, err := client.Resource(hostedZones).Namespace("other").Update(context.Background(), zoneObject(t, second), metav1.UpdateOptions{}); err != nil { + t.Fatal(err) + } + // The sole original owner recovers on rename as well as deletion. It may + // compete with the renamed zone for capacity, so free that unrelated slot. + if err := client.Resource(hostedZones).Namespace("other").Delete(context.Background(), second.Name, metav1.DeleteOptions{}); err != nil { + t.Fatal(err) + } + waitUntil(t, func() bool { return index.lookup("www.example.test.") != nil }) +} + +func TestConflictingChildApexDoesNotFallThroughToParent(t *testing.T) { + index := newZoneIndex(10, newMetrics()) + parent := testZone() + parent.Spec.DomainName = "test" + parent.Name = "parent" + if err := index.replace(parent); err != nil { + t.Fatal(err) + } + first := testZone() + if err := index.replace(first); err != nil { + t.Fatal(err) + } + second := first.DeepCopy() + second.Namespace = "other" + if err := index.replace(second); err == nil { + t.Fatal("duplicate accepted") + } + assertRefused(t, index, "www.example.test.") + if index.lookup("www.test.") == nil { + t.Fatal("conflict removed unrelated parent authority") + } +} + +func assertRefused(t *testing.T, index *zoneIndex, name string) { + t.Helper() + request := new(dns.Msg) + request.SetQuestion(name, dns.TypeA) + response, _ := (&handler{index: index}).answer(request) + if response.Rcode != dns.RcodeRefused || len(response.Answer) != 0 { + t.Fatalf("conflicted query = %v", response) + } +} + +func TestUnownedSnapshotCannotQuarantineAnOwnedApex(t *testing.T) { + owned := testZone() + unowned := owned.DeepCopy() + unowned.Namespace = "other" + unowned.OwnerReferences = nil + client, patches := fakeSnapshotClient(t, zoneObject(t, owned), zoneObject(t, unowned)) + index := newZoneIndex(10, newMetrics()) + w, err := newSnapshotWatcher(client, "", "dns/server-1", index) + if err != nil { + t.Fatal(err) + } + startWatcher(t, w) + assertOwnApply(t, receiveStatus(t, patches), 7) + if index.lookup("www.example.test.") == nil { + t.Fatal("unowned snapshot denied another namespace's authority") + } + if metricValue(t, index.metrics.rejected.WithLabelValues("apex_conflict")) != 0 || metricValue(t, index.metrics.rejected.WithLabelValues("unowned")) != 1 { + t.Fatal("incorrect rejection reason") + } +} diff --git a/internal/go/providers/hosted/server/handler.go b/internal/go/providers/hosted/server/handler.go new file mode 100644 index 0000000..6db4a0d --- /dev/null +++ b/internal/go/providers/hosted/server/handler.go @@ -0,0 +1,173 @@ +package server + +import ( + "net" + "time" + + "github.com/miekg/dns" +) + +const udpPayloadSize = 1232 + +type handler struct { + index *zoneIndex + limiter *sourceLimiter + metrics *serverMetrics +} + +func (h *handler) ServeDNS(writer dns.ResponseWriter, request *dns.Msg) { + started := time.Now() + _, udp := writer.RemoteAddr().(*net.UDPAddr) + if address, ok := writer.RemoteAddr().(*net.UDPAddr); ok && !h.limiter.allow(address.AddrPort().Addr().Unmap(), started) { + h.metrics.rateLimited.Inc() + return + } + response, apex := h.answer(request) + if udp { + size := dns.MinMsgSize + if opt := request.IsEdns0(); opt != nil { + size = min(udpPayloadSize, max(dns.MinMsgSize, int(opt.UDPSize()))) + } + response.Truncate(size) + } + _ = writer.WriteMsg(response) + qtype := "OTHER" + if len(request.Question) == 1 { + if name, ok := dns.TypeToString[request.Question[0].Qtype]; ok { + qtype = name + } + } + h.metrics.queries.WithLabelValues(apex, qtype, dns.RcodeToString[response.Rcode]).Inc() + h.metrics.duration.Observe(time.Since(started).Seconds()) +} + +func (h *handler) answer(request *dns.Msg) (*dns.Msg, string) { + response := new(dns.Msg) + response.SetReply(request) + response.Authoritative = true + response.RecursionAvailable = false + response.Compress = true + if opt := request.IsEdns0(); opt != nil { + response.SetEdns0(udpPayloadSize, false) + if opt.Version() != 0 { + response.Rcode = dns.RcodeBadVers + return response, "" + } + } + if request.Opcode != dns.OpcodeQuery { + response.Rcode = dns.RcodeNotImplemented + return response, "" + } + if len(request.Question) != 1 { + response.Rcode = dns.RcodeFormatError + return response, "" + } + question := request.Question[0] + name := dns.CanonicalName(question.Name) + z := h.index.lookup(name) + if z == nil || question.Qclass != dns.ClassINET { + response.Rcode = dns.RcodeRefused + return response, "" + } + if question.Qtype == dns.TypeAXFR || question.Qtype == dns.TypeIXFR { + response.Rcode = dns.RcodeRefused + return response, z.apex + } + if question.Qtype == dns.TypeANY { + response.Answer = []dns.RR{&dns.HINFO{Hdr: dns.RR_Header{Name: question.Name, Rrtype: dns.TypeHINFO, Class: dns.ClassINET, Ttl: z.soa.Hdr.Ttl}, Cpu: "RFC8482", Os: ""}} + return response, z.apex + } + ownerCase := question.Name + var seen [8]string + links := 0 + for { + rrsets, exists := z.lookup(name) + if !exists { + response.Rcode = dns.RcodeNameError + response.Ns = []dns.RR{z.negativeSOA} + break + } + if records := rrsets[question.Qtype]; len(records) != 0 { + response.Answer = appendWithOwner(response.Answer, records, ownerCase) + if name == z.apex && (question.Qtype == dns.TypeSOA || question.Qtype == dns.TypeNS) { + response.Extra = append(response.Extra, z.glue...) + } else { + response.Extra = appendAdditional(response.Extra, records, z) + } + break + } + cnames := rrsets[dns.TypeCNAME] + if len(cnames) == 0 { + response.Ns = []dns.RR{z.negativeSOA} + break + } + if links == len(seen) { + response.Answer = nil + response.Rcode = dns.RcodeServerFailure + break + } + for _, visited := range seen[:links] { + if visited == name { + response.Answer = nil + response.Rcode = dns.RcodeServerFailure + return response, z.apex + } + } + seen[links] = name + links++ + response.Answer = appendWithOwner(response.Answer, cnames, ownerCase) + target := cnames[0].(*dns.CNAME).Target + name = dns.CanonicalName(target) + ownerCase = target + // Do not recurse or chase outside this zone. A concurrent publication + // must not replace the immutable snapshot used by this query. + if !dns.IsSubDomain(z.apex, name) { + break + } + if authority := h.index.lookup(name); authority == nil || authority.apex != z.apex { + break + } + } + return response, z.apex +} + +func appendWithOwner(answer []dns.RR, records []dns.RR, owner string) []dns.RR { + for _, record := range records { + if record.Header().Name != owner { + record = dns.Copy(record) + record.Header().Name = owner + } + answer = append(answer, record) + } + return answer +} + +func appendAdditional(additional []dns.RR, records []dns.RR, z *zone) []dns.RR { + for _, record := range records { + var target string + switch record := record.(type) { + case *dns.NS: + target = record.Ns + case *dns.MX: + target = record.Mx + default: + continue + } + rrsets := z.owners[dns.CanonicalName(target)] + for _, kind := range []uint16{dns.TypeA, dns.TypeAAAA} { + for _, address := range rrsets[kind] { + duplicate := false + for _, present := range additional { + if present == address { + duplicate = true + break + } + } + if !duplicate { + additional = append(additional, address) + } + } + } + } + return additional +} diff --git a/internal/go/providers/hosted/server/handler_test.go b/internal/go/providers/hosted/server/handler_test.go new file mode 100644 index 0000000..d7a7f26 --- /dev/null +++ b/internal/go/providers/hosted/server/handler_test.go @@ -0,0 +1,257 @@ +package server + +import ( + "fmt" + "net" + "strings" + "testing" + "time" + + hostedv1alpha1 "github.com/appthrust/dns-api/pkg/go/api/hosted/v1alpha1" + "github.com/miekg/dns" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +func testZone() *hostedv1alpha1.HostedZone { + return &hostedv1alpha1.HostedZone{ + ObjectMeta: metav1.ObjectMeta{Namespace: "dns", Name: "example", UID: "zone-1", OwnerReferences: []metav1.OwnerReference{{APIVersion: "dns.appthrust.io/v1alpha1", Kind: "ZoneUnit", Name: "example", UID: "unit-1"}}}, + Spec: hostedv1alpha1.HostedZoneSpec{ + DomainName: "example.test", Serial: 7, ContentDigest: "sha256:" + strings.Repeat("a", 64), + SOA: hostedv1alpha1.SOA{MName: "ns.example.test", RName: "hostmaster.example.test", Refresh: 3600, Retry: 900, Expire: 1209600, Minimum: 30}, + NameServers: []hostedv1alpha1.NameServer{{Name: "ns.example.test", Addresses: []string{"192.0.2.53", "2001:db8::53"}}}, + Records: []hostedv1alpha1.HostedZoneRecord{ + {Name: "@", Type: "NS", TTL: 60, Values: []string{"ns.example.test."}}, + {Name: "ns", Type: "A", TTL: 60, Values: []string{"192.0.2.53"}}, + {Name: "ns", Type: "AAAA", TTL: 60, Values: []string{"2001:db8::53"}}, + {Name: "www", Type: "A", TTL: 90, Values: []string{"192.0.2.1"}}, + {Name: "www", Type: "AAAA", TTL: 90, Values: []string{"2001:db8::1"}}, + {Name: "alias", Type: "CNAME", TTL: 80, Values: []string{"www.example.test."}}, + {Name: "outside", Type: "CNAME", TTL: 80, Values: []string{"other.test."}}, + {Name: "dangling", Type: "CNAME", TTL: 80, Values: []string{"missing.example.test."}}, + {Name: "text", Type: "TXT", TTL: 70, Values: []string{`"hello" "world"`}}, + {Name: "mail", Type: "MX", TTL: 70, Values: []string{"10 www.example.test."}}, + {Name: "caa", Type: "CAA", TTL: 70, Values: []string{`0 issue "ca.example"`}}, + {Name: "sub", Type: "NS", TTL: 70, Values: []string{"ns.example.test."}}, + {Name: "*.wild", Type: "A", TTL: 70, Values: []string{"192.0.2.2"}}, + {Name: "child.block.wild", Type: "TXT", TTL: 70, Values: []string{`"block"`}}, + {Name: "*.aliaswild", Type: "CNAME", TTL: 70, Values: []string{"www.example.test."}}, + {Name: "loop", Type: "CNAME", TTL: 70, Values: []string{"loop.example.test."}}, + }, + }, + } +} + +func testHandler(t *testing.T, zone *hostedv1alpha1.HostedZone) *handler { + t.Helper() + index := newZoneIndex(4096, newMetrics()) + if err := index.replace(zone); err != nil { + t.Fatal(err) + } + return &handler{index: index, limiter: newSourceLimiter(1000), metrics: index.metrics} +} + +func TestAuthoritativeAnswers(t *testing.T) { + h := testHandler(t, testZone()) + cases := []struct { + name string + qtype uint16 + rcode int + types []uint16 + negative bool + glue int + }{ + {"example.test.", dns.TypeSOA, dns.RcodeSuccess, []uint16{dns.TypeSOA}, false, 2}, + {"example.test.", dns.TypeNS, dns.RcodeSuccess, []uint16{dns.TypeNS}, false, 2}, + {"WwW.ExAmPlE.TeSt.", dns.TypeA, dns.RcodeSuccess, []uint16{dns.TypeA}, false, 0}, + {"www.example.test.", dns.TypeAAAA, dns.RcodeSuccess, []uint16{dns.TypeAAAA}, false, 0}, + {"text.example.test.", dns.TypeTXT, dns.RcodeSuccess, []uint16{dns.TypeTXT}, false, 0}, + {"mail.example.test.", dns.TypeMX, dns.RcodeSuccess, []uint16{dns.TypeMX}, false, 2}, + {"caa.example.test.", dns.TypeCAA, dns.RcodeSuccess, []uint16{dns.TypeCAA}, false, 0}, + {"sub.example.test.", dns.TypeNS, dns.RcodeSuccess, []uint16{dns.TypeNS}, false, 2}, + {"alias.example.test.", dns.TypeA, dns.RcodeSuccess, []uint16{dns.TypeCNAME, dns.TypeA}, false, 0}, + {"alias.example.test.", dns.TypeCNAME, dns.RcodeSuccess, []uint16{dns.TypeCNAME}, false, 0}, + {"outside.example.test.", dns.TypeA, dns.RcodeSuccess, []uint16{dns.TypeCNAME}, false, 0}, + {"dangling.example.test.", dns.TypeA, dns.RcodeNameError, []uint16{dns.TypeCNAME}, true, 0}, + {"one.wild.example.test.", dns.TypeA, dns.RcodeSuccess, []uint16{dns.TypeA}, false, 0}, + {"one.two.wild.example.test.", dns.TypeA, dns.RcodeSuccess, []uint16{dns.TypeA}, false, 0}, + {"one.wild.example.test.", dns.TypeAAAA, dns.RcodeSuccess, nil, true, 0}, + {"wild.example.test.", dns.TypeA, dns.RcodeSuccess, nil, true, 0}, + {"one.block.wild.example.test.", dns.TypeA, dns.RcodeNameError, nil, true, 0}, + {"block.wild.example.test.", dns.TypeA, dns.RcodeSuccess, nil, true, 0}, + {"OnE.Aliaswild.Example.Test.", dns.TypeA, dns.RcodeSuccess, []uint16{dns.TypeCNAME, dns.TypeA}, false, 0}, + {"missing.example.test.", dns.TypeA, dns.RcodeNameError, nil, true, 0}, + {"www.example.test.", dns.TypeMX, dns.RcodeSuccess, nil, true, 0}, + {"www.example.test.", dns.TypeANY, dns.RcodeSuccess, []uint16{dns.TypeHINFO}, false, 0}, + {"example.test.", dns.TypeAXFR, dns.RcodeRefused, nil, false, 0}, + {"example.test.", dns.TypeIXFR, dns.RcodeRefused, nil, false, 0}, + {"notexample.test.", dns.TypeA, dns.RcodeRefused, nil, false, 0}, + {"loop.example.test.", dns.TypeA, dns.RcodeServerFailure, nil, false, 0}, + } + for _, tc := range cases { + t.Run(tc.name+dns.TypeToString[tc.qtype], func(t *testing.T) { + request := new(dns.Msg) + request.SetQuestion(tc.name, tc.qtype) + w := &responseRecorder{remote: &net.UDPAddr{IP: net.IPv4(192, 0, 2, 1), Port: 12345}} + h.ServeDNS(w, request) + response := w.msg + if response == nil || response.Rcode != tc.rcode || !response.Authoritative || response.RecursionAvailable || response.Question[0] != request.Question[0] { + t.Fatalf("response = %#v", response) + } + if len(response.Answer) != len(tc.types) { + t.Fatalf("answers = %v, want types %v", response.Answer, tc.types) + } + for i, kind := range tc.types { + if response.Answer[i].Header().Rrtype != kind { + t.Fatalf("answer %d = %v", i, response.Answer[i]) + } + } + if len(response.Answer) > 0 && response.Answer[0].Header().Name != tc.name { + t.Fatalf("question case lost: %v", response.Answer[0]) + } + if tc.negative { + if len(response.Ns) != 1 || response.Ns[0].Header().Rrtype != dns.TypeSOA || response.Ns[0].Header().Ttl != 30 { + t.Fatalf("negative SOA = %v", response.Ns) + } + } else if len(response.Ns) != 0 { + t.Fatalf("unexpected authority: %v", response.Ns) + } + if len(response.Extra) != tc.glue { + t.Fatalf("additional = %v, want %d", response.Extra, tc.glue) + } + if tc.qtype == dns.TypeANY { + hinfo := response.Answer[0].(*dns.HINFO) + if hinfo.Cpu != "RFC8482" || hinfo.Os != "" { + t.Fatalf("HINFO = %v", hinfo) + } + } + }) + } +} + +func TestCNAMEChainLimit(t *testing.T) { + for _, links := range []int{8, 9} { + t.Run(fmt.Sprint(links), func(t *testing.T) { + zone := testZone() + for i := range links { + zone.Spec.Records = append(zone.Spec.Records, hostedv1alpha1.HostedZoneRecord{Name: fmt.Sprintf("c%d", i), Type: "CNAME", TTL: 60, Values: []string{fmt.Sprintf("c%d.example.test.", i+1)}}) + } + zone.Spec.Records = append(zone.Spec.Records, hostedv1alpha1.HostedZoneRecord{Name: fmt.Sprintf("c%d", links), Type: "A", TTL: 60, Values: []string{"192.0.2.1"}}) + request := new(dns.Msg) + request.SetQuestion("c0.example.test.", dns.TypeA) + response, _ := testHandler(t, zone).answer(request) + if links == 8 && (response.Rcode != dns.RcodeSuccess || len(response.Answer) != 9) { + t.Fatalf("8 links = %v", response) + } + if links == 9 && (response.Rcode != dns.RcodeServerFailure || len(response.Answer) != 0) { + t.Fatalf("9 links = %v", response) + } + }) + } +} + +func TestLoopbackUDPAndTCP(t *testing.T) { + zone := testZone() + values := make([]string, 16) + for i := range values { + values[i] = fmt.Sprintf(`"%02d%s"`, i, strings.Repeat("x", 180)) + } + zone.Spec.Records = append(zone.Spec.Records, hostedv1alpha1.HostedZoneRecord{Name: "large", Type: "TXT", TTL: 60, Values: values}) + h := testHandler(t, zone) + address := serveLoopback(t, h) + for _, tc := range []struct { + network string + size uint16 + truncated bool + }{ + {"udp", 0, true}, {"udp", 600, true}, {"udp", 4096, true}, {"tcp", 512, false}, + } { + t.Run(fmt.Sprintf("%s-%d", tc.network, tc.size), func(t *testing.T) { + request := new(dns.Msg) + request.SetQuestion("LaRgE.Example.Test.", dns.TypeTXT) + if tc.size != 0 { + request.SetEdns0(tc.size, false) + } + client := &dns.Client{Net: tc.network, UDPSize: 4096, Timeout: 2 * time.Second} + connection, err := client.Dial(address) + if err != nil { + t.Fatal(err) + } + defer connection.Close() + if err := connection.SetDeadline(time.Now().Add(2 * time.Second)); err != nil { + t.Fatal(err) + } + if err := connection.WriteMsg(request); err != nil { + t.Fatal(err) + } + wire, err := connection.ReadMsgHeader(nil) + if err != nil { + t.Fatal(err) + } + response := new(dns.Msg) + if err := response.Unpack(wire); err != nil { + t.Fatal(err) + } + if response.Truncated != tc.truncated || !response.Authoritative || response.RecursionAvailable { + t.Fatalf("response = %v", response) + } + if tc.network == "tcp" && len(response.Answer) != 16 { + t.Fatalf("TCP omitted records: %d", len(response.Answer)) + } + if tc.network == "udp" { + limit := 512 + if tc.size != 0 { + limit = min(1232, max(512, int(tc.size))) + } + if err != nil || len(wire) > limit { + t.Fatalf("UDP size %d > %d: %v", len(wire), limit, err) + } + } + if tc.size != 0 && (response.IsEdns0() == nil || response.IsEdns0().UDPSize() != 1232) { + t.Fatalf("EDNS = %v", response.IsEdns0()) + } + }) + } +} + +func serveLoopback(t *testing.T, h dns.Handler) string { + t.Helper() + tcp, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + udp, err := net.ListenPacket("udp", tcp.Addr().String()) + if err != nil { + tcp.Close() + t.Fatal(err) + } + for _, server := range []*dns.Server{{Listener: tcp, Handler: h}, {PacketConn: udp, Handler: h, UDPSize: 1232}} { + ready := make(chan struct{}) + server.NotifyStartedFunc = func() { close(ready) } + go func() { + if err := server.ActivateAndServe(); err != nil { + t.Errorf("DNS serve: %v", err) + } + }() + select { + case <-ready: + case <-time.After(2 * time.Second): + t.Fatal("DNS listener not ready") + } + t.Cleanup(func() { _ = server.Shutdown() }) + } + return tcp.Addr().String() +} + +type responseRecorder struct { + remote net.Addr + msg *dns.Msg +} + +func (w *responseRecorder) LocalAddr() net.Addr { return &net.UDPAddr{Port: 5353} } +func (w *responseRecorder) RemoteAddr() net.Addr { return w.remote } +func (w *responseRecorder) WriteMsg(msg *dns.Msg) error { w.msg = msg.Copy(); return nil } +func (w *responseRecorder) Write(data []byte) (int, error) { return len(data), nil } +func (w *responseRecorder) Close() error { return nil } +func (w *responseRecorder) TsigStatus() error { return nil } +func (w *responseRecorder) TsigTimersOnly(bool) {} +func (w *responseRecorder) Hijack() {} diff --git a/internal/go/providers/hosted/server/metrics.go b/internal/go/providers/hosted/server/metrics.go new file mode 100644 index 0000000..ce939c1 --- /dev/null +++ b/internal/go/providers/hosted/server/metrics.go @@ -0,0 +1,31 @@ +package server + +import "github.com/prometheus/client_golang/prometheus" + +// The standalone server has its own registry, so independent Run instances and +// tests do not register duplicate collectors in controller-runtime's registry. +type serverMetrics struct { + registry *prometheus.Registry + queries *prometheus.CounterVec + duration prometheus.Histogram + serial *prometheus.GaugeVec + loaded prometheus.Gauge + rateLimited prometheus.Counter + rejected *prometheus.CounterVec +} + +func newMetrics() *serverMetrics { + m := &serverMetrics{ + registry: prometheus.NewRegistry(), + queries: prometheus.NewCounterVec(prometheus.CounterOpts{Name: "dns_hosted_queries_total", Help: "Completed authoritative DNS queries."}, []string{"zone", "qtype", "rcode"}), + duration: prometheus.NewHistogram(prometheus.HistogramOpts{Name: "dns_hosted_query_duration_seconds", Help: "Authoritative DNS query duration."}), + serial: prometheus.NewGaugeVec(prometheus.GaugeOpts{Name: "dns_hosted_zone_serial", Help: "Currently served zone serial."}, []string{"zone"}), + loaded: prometheus.NewGauge(prometheus.GaugeOpts{Name: "dns_hosted_zones_loaded", Help: "Number of authoritative snapshots loaded."}), + rateLimited: prometheus.NewCounter(prometheus.CounterOpts{Name: "dns_hosted_udp_rate_limited_total", Help: "UDP requests dropped by the per-source rate limiter."}), + rejected: prometheus.NewCounterVec(prometheus.CounterOpts{Name: "dns_hosted_snapshots_rejected_total", Help: "Snapshot events rejected by ownership or apex arbitration."}, []string{"reason"}), + } + m.registry.MustRegister(m.queries, m.duration, m.serial, m.loaded, m.rateLimited, m.rejected) + m.rejected.WithLabelValues("unowned").Add(0) + m.rejected.WithLabelValues("apex_conflict").Add(0) + return m +} diff --git a/internal/go/providers/hosted/server/ratelimit.go b/internal/go/providers/hosted/server/ratelimit.go new file mode 100644 index 0000000..5486bf6 --- /dev/null +++ b/internal/go/providers/hosted/server/ratelimit.go @@ -0,0 +1,56 @@ +package server + +import ( + "container/list" + "net/netip" + "sync" + "time" +) + +const maxRateLimitSources = 65536 +const sourceIdleTimeout = time.Minute + +type sourceBucket struct { + address netip.Addr + tokens float64 + updated time.Time +} + +type sourceLimiter struct { + mu sync.Mutex + rate float64 + sources map[netip.Addr]*list.Element + lru list.List +} + +func newSourceLimiter(rate int) *sourceLimiter { + return &sourceLimiter{rate: float64(rate), sources: make(map[netip.Addr]*list.Element)} +} + +// A source may burst one second's allowance. Idle entries are reclaimed, but +// active entries are never evicted to let rotating source addresses reset them. +func (l *sourceLimiter) allow(address netip.Addr, now time.Time) bool { + l.mu.Lock() + defer l.mu.Unlock() + if oldest := l.lru.Back(); oldest != nil && now.Sub(oldest.Value.(*sourceBucket).updated) >= sourceIdleTimeout { + delete(l.sources, oldest.Value.(*sourceBucket).address) + l.lru.Remove(oldest) + } + element := l.sources[address] + if element == nil { + if len(l.sources) >= maxRateLimitSources { + return false + } + element = l.lru.PushFront(&sourceBucket{address: address, tokens: l.rate, updated: now}) + l.sources[address] = element + } + bucket := element.Value.(*sourceBucket) + bucket.tokens = min(l.rate, bucket.tokens+max(0, now.Sub(bucket.updated).Seconds())*l.rate) + bucket.updated = now + l.lru.MoveToFront(element) + if bucket.tokens < 1 { + return false + } + bucket.tokens-- + return true +} diff --git a/internal/go/providers/hosted/server/server.go b/internal/go/providers/hosted/server/server.go new file mode 100644 index 0000000..9b21dc9 --- /dev/null +++ b/internal/go/providers/hosted/server/server.go @@ -0,0 +1,200 @@ +// Package server serves immutable HostedZone snapshots as authoritative DNS. +package server + +import ( + "context" + "errors" + "fmt" + "net" + "net/http" + "os" + "strings" + "sync" + "sync/atomic" + "time" + + "github.com/miekg/dns" + "github.com/prometheus/client_golang/prometheus/promhttp" + "k8s.io/client-go/dynamic" + "k8s.io/client-go/rest" +) + +// Options configures the standalone authoritative DNS process. Zero values use +// the documented defaults. Set an HTTP address to "0" to disable that listener. +type Options struct { + ListenAddr string + WatchNamespace string + MaxZones int + UDPRateLimitPerSource int + // PodIdentity defaults to POD_NAMESPACE + "/" + POD_NAME. Both are required + // when PodIdentity is not explicitly supplied. + PodIdentity string + MetricsAddr string + HealthAddr string + // RESTConfig defaults to the in-cluster ServiceAccount configuration. + RESTConfig *rest.Config +} + +func (o Options) defaults() (Options, error) { + if o.ListenAddr == "" { + o.ListenAddr = ":5353" + } + if o.MaxZones == 0 { + o.MaxZones = 4096 + } + if o.UDPRateLimitPerSource == 0 { + o.UDPRateLimitPerSource = 1000 + } + if o.MetricsAddr == "" { + o.MetricsAddr = ":8080" + } + if o.HealthAddr == "" { + o.HealthAddr = ":8081" + } + if o.PodIdentity == "" { + o.PodIdentity = os.Getenv("POD_NAMESPACE") + "/" + os.Getenv("POD_NAME") + } + identity := strings.Split(o.PodIdentity, "/") + if len(identity) != 2 || identity[0] == "" || identity[1] == "" { + return o, errors.New("pod identity must be namespace/name (set POD_NAMESPACE and POD_NAME)") + } + if o.MaxZones < 1 { + return o, errors.New("max zones must be positive") + } + if o.UDPRateLimitPerSource < 1 { + return o, errors.New("UDP rate limit per source must be positive") + } + return o, nil +} + +// Run serves UDP/TCP DNS, watches HostedZone snapshots, and exposes metrics and +// health endpoints until ctx is canceled. Cancellation is a graceful success; +// startup/listener failures are returned. Shutdown removes only this pod's SSA +// status contribution, with a bounded independent cleanup context. +func Run(ctx context.Context, options Options) error { + options, err := options.defaults() + if err != nil { + return err + } + config := options.RESTConfig + if config == nil { + config, err = rest.InClusterConfig() + if err != nil { + return fmt.Errorf("load hosted server Kubernetes configuration: %w", err) + } + } + client, err := dynamic.NewForConfig(config) + if err != nil { + return fmt.Errorf("create hosted snapshot client: %w", err) + } + metrics := newMetrics() + index := newZoneIndex(options.MaxZones, metrics) + watcher, err := newSnapshotWatcher(client, options.WatchNamespace, options.PodIdentity, index) + if err != nil { + return err + } + ctx, cancel := context.WithCancel(ctx) + defer cancel() + tcp, err := (&net.ListenConfig{}).Listen(ctx, "tcp", options.ListenAddr) + if err != nil { + return fmt.Errorf("listen hosted DNS TCP: %w", err) + } + defer tcp.Close() + udp, err := (&net.ListenConfig{}).ListenPacket(ctx, "udp", tcp.Addr().String()) + if err != nil { + return fmt.Errorf("listen hosted DNS UDP: %w", err) + } + defer udp.Close() + h := &handler{index: index, limiter: newSourceLimiter(options.UDPRateLimitPerSource), metrics: metrics} + started := make(chan struct{}, 2) + servers := []*dns.Server{ + {Listener: tcp, Handler: h, ReadTimeout: 5 * time.Second, WriteTimeout: 5 * time.Second, NotifyStartedFunc: func() { started <- struct{}{} }}, + {PacketConn: udp, Handler: h, UDPSize: udpPayloadSize, NotifyStartedFunc: func() { started <- struct{}{} }}, + } + type httpListener struct { + server *http.Server + listener net.Listener + } + var httpListeners []httpListener + defer func() { + for _, listener := range httpListeners { + _ = listener.listener.Close() + } + }() + for _, endpoint := range []struct { + address string + handler http.Handler + }{ + {options.MetricsAddr, promhttp.HandlerFor(metrics.registry, promhttp.HandlerOpts{})}, + {options.HealthAddr, healthHandler(&watcher.ready)}, + } { + if endpoint.address == "0" { + continue + } + listener, err := (&net.ListenConfig{}).Listen(ctx, "tcp", endpoint.address) + if err != nil { + return fmt.Errorf("listen hosted HTTP %s: %w", endpoint.address, err) + } + httpListeners = append(httpListeners, httpListener{server: &http.Server{Handler: endpoint.handler, ReadHeaderTimeout: 5 * time.Second, WriteTimeout: 10 * time.Second, IdleTimeout: 30 * time.Second}, listener: listener}) + } + failures := make(chan error, 4) + var workers sync.WaitGroup + defer func() { + cancel() + watcher.ready.Store(false) + shutdown, stop := context.WithTimeout(context.Background(), 5*time.Second) + defer stop() + for _, server := range servers { + _ = server.ShutdownContext(shutdown) + } + _ = tcp.Close() + _ = udp.Close() + for _, listener := range httpListeners { + _ = listener.server.Shutdown(shutdown) + _ = listener.server.Close() + } + workers.Wait() + cleanup, finish := context.WithTimeout(context.Background(), 5*time.Second) + defer finish() + watcher.removeOwnStatus(cleanup) + }() + for _, server := range servers { + workers.Go(func() { failures <- server.ActivateAndServe() }) + } + for _, listener := range httpListeners { + workers.Go(func() { failures <- listener.server.Serve(listener.listener) }) + } + for range servers { + select { + case <-started: + case <-ctx.Done(): + return nil + case err := <-failures: + return fmt.Errorf("start hosted listener: %w", err) + } + } + // Never publish a serving acknowledgement before both DNS transports listen. + workers.Go(func() { watcher.run(ctx) }) + select { + case <-ctx.Done(): + return nil + case err := <-failures: + if ctx.Err() != nil { + return nil + } + return fmt.Errorf("hosted listener stopped: %w", err) + } +} + +func healthHandler(ready *atomic.Bool) http.Handler { + mux := http.NewServeMux() + mux.HandleFunc("/healthz", func(writer http.ResponseWriter, _ *http.Request) { _, _ = writer.Write([]byte("ok\n")) }) + mux.HandleFunc("/readyz", func(writer http.ResponseWriter, _ *http.Request) { + if !ready.Load() { + http.Error(writer, "initial snapshot list has not completed", http.StatusServiceUnavailable) + return + } + _, _ = writer.Write([]byte("ok\n")) + }) + return mux +} diff --git a/internal/go/providers/hosted/server/server_test.go b/internal/go/providers/hosted/server/server_test.go new file mode 100644 index 0000000..1ac046f --- /dev/null +++ b/internal/go/providers/hosted/server/server_test.go @@ -0,0 +1,143 @@ +package server + +import ( + "context" + "encoding/json" + "io" + "net" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + hostedv1alpha1 "github.com/appthrust/dns-api/pkg/go/api/hosted/v1alpha1" + "github.com/miekg/dns" + "k8s.io/client-go/rest" +) + +func TestRunServesAndCleansUpOwnStatus(t *testing.T) { + zone := testZone() + zone.APIVersion = hostedv1alpha1.SchemeGroupVersion.String() + zone.Kind = "HostedZone" + zone.ResourceVersion = "1" + applies := make(chan hostedv1alpha1.HostedZone, 10) + api := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + writer.Header().Set("Content-Type", "application/json") + if request.URL.Path != "/apis/hosted.dns.appthrust.io/v1alpha1/namespaces/dns/hostedzones" && request.URL.Path != "/apis/hosted.dns.appthrust.io/v1alpha1/namespaces/dns/hostedzones/example/status" { + t.Errorf("unexpected API path %s", request.URL.Path) + http.NotFound(writer, request) + return + } + if request.Method == http.MethodPatch { + if request.URL.Query().Get("fieldManager") != "hosted-server/server-1" || request.Header.Get("Content-Type") != "application/apply-patch+yaml" { + t.Errorf("incorrect SSA request: %s %v", request.URL, request.Header) + } + var patch hostedv1alpha1.HostedZone + if err := json.NewDecoder(request.Body).Decode(&patch); err != nil { + t.Error(err) + } + applies <- patch + _ = json.NewEncoder(writer).Encode(patch) + return + } + if request.URL.Query().Get("watch") == "true" { + writer.WriteHeader(http.StatusOK) + if request.URL.Query().Get("sendInitialEvents") == "true" { + _ = json.NewEncoder(writer).Encode(map[string]any{"type": "ADDED", "object": zone}) + _ = json.NewEncoder(writer).Encode(map[string]any{"type": "BOOKMARK", "object": map[string]any{"apiVersion": zone.APIVersion, "kind": zone.Kind, "metadata": map[string]any{"resourceVersion": "1", "annotations": map[string]string{"k8s.io/initial-events-end": "true"}}}}) + } + writer.(http.Flusher).Flush() + <-request.Context().Done() + return + } + _ = json.NewEncoder(writer).Encode(map[string]any{"apiVersion": zone.APIVersion, "kind": "HostedZoneList", "metadata": map[string]string{"resourceVersion": "1"}, "items": []*hostedv1alpha1.HostedZone{zone}}) + })) + defer api.Close() + listen, health, metrics := availableAddress(t), availableAddress(t), availableAddress(t) + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + result := make(chan error, 1) + go func() { + result <- Run(ctx, Options{ListenAddr: listen, WatchNamespace: "dns", PodIdentity: "dns/server-1", MetricsAddr: metrics, HealthAddr: health, RESTConfig: &rest.Config{Host: api.URL}}) + }() + t.Cleanup(func() { + cancel() + select { + case err := <-result: + if err != nil { + t.Error(err) + } + case <-time.After(7 * time.Second): + t.Error("Run did not finish") + } + }) + select { + case patch := <-applies: + if len(patch.Status.Servers) != 1 || patch.Status.Servers[0].Serial != 7 { + t.Fatalf("initial status = %#v", patch.Status) + } + case err := <-result: + result <- err + t.Fatalf("Run stopped early: %v", err) + case <-time.After(3 * time.Second): + t.Fatal("Run did not load initial snapshot") + } + for _, network := range []string{"udp", "tcp"} { + request := new(dns.Msg) + request.SetQuestion("WwW.Example.Test.", dns.TypeA) + response, _, err := (&dns.Client{Net: network, Timeout: time.Second}).Exchange(request, listen) + if err != nil { + t.Fatal(err) + } + if len(response.Answer) != 1 || response.Answer[0].(*dns.A).A.String() != "192.0.2.1" || !response.Authoritative || response.Answer[0].Header().Name != request.Question[0].Name { + t.Fatalf("%s response = %v", network, response) + } + } + for _, path := range []string{"/healthz", "/readyz"} { + response, err := http.Get("http://" + health + path) + if err != nil { + t.Fatal(err) + } + _ = response.Body.Close() + if response.StatusCode != http.StatusOK { + t.Fatalf("%s status = %d", path, response.StatusCode) + } + } + response, err := http.Get("http://" + metrics + "/metrics") + if err != nil { + t.Fatal(err) + } + body, err := io.ReadAll(response.Body) + _ = response.Body.Close() + if err != nil { + t.Fatal(err) + } + for _, name := range []string{"dns_hosted_queries_total", "dns_hosted_query_duration_seconds", "dns_hosted_zone_serial", "dns_hosted_zones_loaded", "dns_hosted_udp_rate_limited_total", "dns_hosted_snapshots_rejected_total"} { + if !strings.Contains(string(body), name) { + t.Fatalf("missing metric %s", name) + } + } + cancel() + select { + case patch := <-applies: + if len(patch.Status.Servers) != 0 || patch.UID != zone.UID { + t.Fatalf("shutdown apply = %#v", patch) + } + case <-time.After(3 * time.Second): + t.Fatal("Run did not remove own acknowledgement") + } +} + +func availableAddress(t *testing.T) string { + t.Helper() + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + address := listener.Addr().String() + if err := listener.Close(); err != nil { + t.Fatal(err) + } + return address +} diff --git a/internal/go/providers/hosted/server/watch.go b/internal/go/providers/hosted/server/watch.go new file mode 100644 index 0000000..d84bed0 --- /dev/null +++ b/internal/go/providers/hosted/server/watch.go @@ -0,0 +1,345 @@ +package server + +import ( + "context" + "encoding/json" + "fmt" + "log/slog" + "reflect" + "strings" + "sync" + "sync/atomic" + "time" + + hostedv1alpha1 "github.com/appthrust/dns-api/pkg/go/api/hosted/v1alpha1" + "github.com/miekg/dns" + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/types" + "k8s.io/apimachinery/pkg/watch" + "k8s.io/client-go/dynamic" + "k8s.io/client-go/tools/cache" +) + +var hostedZones = hostedv1alpha1.SchemeGroupVersion.WithResource("hostedzones") + +type snapshotWatcher struct { + client dynamic.Interface + index *zoneIndex + identity string + fieldManager string + informer cache.SharedIndexInformer + registration cache.ResourceEventHandlerRegistration + ready atomic.Bool + wake chan struct{} + connectionMu sync.Mutex + connection context.Context + disconnect context.CancelFunc + acknowledged map[types.NamespacedName]acknowledgement // status worker; cleanup after it stops +} + +type acknowledgement struct { + uid types.UID + apex string +} + +func newSnapshotWatcher(client dynamic.Interface, namespace, identity string, index *zoneIndex) (*snapshotWatcher, error) { + w := &snapshotWatcher{client: client, identity: identity, fieldManager: "hosted-server/" + strings.SplitN(identity, "/", 2)[1], index: index, wake: make(chan struct{}, 1), acknowledged: make(map[types.NamespacedName]acknowledgement)} + resource := client.Resource(hostedZones).Namespace(namespace) + lw := &cache.ListWatch{ + ListWithContextFunc: func(ctx context.Context, options metav1.ListOptions) (runtime.Object, error) { + return resource.List(ctx, options) + }, + WatchFuncWithContext: func(ctx context.Context, options metav1.ListOptions) (watch.Interface, error) { + upstream, err := resource.Watch(ctx, options) + if err != nil { + return nil, err + } + connection, cancel := context.WithCancel(ctx) + w.connectionMu.Lock() + if w.disconnect != nil { + w.disconnect() + } + w.connection, w.disconnect = connection, cancel + w.connectionMu.Unlock() + w.signal() + tracked := &trackedWatch{upstream: upstream, result: make(chan watch.Event), cancel: cancel} + go tracked.forward(connection) + return tracked, nil + }, + } + w.informer = cache.NewSharedIndexInformer(cache.ToListWatcherWithWatchListSemantics(lw, client), &unstructured.Unstructured{}, 0, cache.Indexers{ + "apex": func(object any) ([]string, error) { + value, ok := object.(*unstructured.Unstructured) + if !ok || !hasZoneUnitOwner(value.GetOwnerReferences()) { + return nil, nil + } + apex := snapshotApex(value) + if apex == "" { + return nil, nil + } + return []string{apex}, nil + }, + }) + registration, err := w.informer.AddEventHandler(cache.ResourceEventHandlerFuncs{ + AddFunc: w.load, + UpdateFunc: func(old, next any) { + oldObject, oldOK := old.(*unstructured.Unstructured) + nextObject, nextOK := next.(*unstructured.Unstructured) + if oldOK && nextOK && oldObject.GetUID() == nextObject.GetUID() && reflect.DeepEqual(oldObject.GetOwnerReferences(), nextObject.GetOwnerReferences()) { + generation := oldObject.GetGeneration() + if generation > 0 && generation == nextObject.GetGeneration() { + return + } + // Fake clients and pre-generation objects need the structural + // comparison; real status updates never scan the record payload. + if generation == 0 && reflect.DeepEqual(oldObject.Object["spec"], nextObject.Object["spec"]) { + return + } + } + if oldOK && nextOK && hasZoneUnitOwner(oldObject.GetOwnerReferences()) && (snapshotApex(oldObject) != snapshotApex(nextObject) || !hasZoneUnitOwner(nextObject.GetOwnerReferences())) { + w.reconcileApex(snapshotApex(oldObject)) + } + w.load(next) + }, + DeleteFunc: func(object any) { + if tombstone, ok := object.(cache.DeletedFinalStateUnknown); ok { + object = tombstone.Obj + } + if object, ok := object.(*unstructured.Unstructured); ok && hasZoneUnitOwner(object.GetOwnerReferences()) { + w.index.remove(types.NamespacedName{Namespace: object.GetNamespace(), Name: object.GetName()}, object.GetUID()) + w.reconcileApex(snapshotApex(object)) + } + }, + }) + if err != nil { + return nil, fmt.Errorf("register hosted snapshot handler: %w", err) + } + w.registration = registration + return w, nil +} + +func (w *snapshotWatcher) load(object any) { + value, ok := object.(*unstructured.Unstructured) + if !ok { + return + } + if !hasZoneUnitOwner(value.GetOwnerReferences()) { + w.index.metrics.rejected.WithLabelValues("unowned").Inc() + w.index.remove(types.NamespacedName{Namespace: value.GetNamespace(), Name: value.GetName()}, value.GetUID()) + w.signal() + return + } + w.reconcileApex(snapshotApex(value)) +} + +func snapshotApex(object *unstructured.Unstructured) string { + domain, _, _ := unstructured.NestedString(object.Object, "spec", "domainName") + if domain == "" { + return "" + } + return dns.CanonicalName(domain) +} + +// The informer's secondary index arbitrates all observed owners, not only the +// zones admitted under MaxZones. Conflicting snapshots stay in the informer, +// never in the compiled serving index. Deleting/renaming a contender reloads +// the sole survivor without keeping a second copy of every snapshot. +func (w *snapshotWatcher) reconcileApex(apex string) { + if apex == "" { + return + } + defer w.signal() + objects, err := w.informer.GetIndexer().ByIndex("apex", apex) + if err != nil { + slog.Error("index hosted apex", "apex", apex, "error", err) + return + } + if len(objects) != 1 { + w.index.reconcileAbsentApex(apex, len(objects) > 1) + return + } + value := objects[0].(*unstructured.Unstructured) + var snapshot hostedv1alpha1.HostedZone + if err := runtime.DefaultUnstructuredConverter.FromUnstructured(value.Object, &snapshot); err != nil { + slog.Error("decode hosted snapshot", "namespace", value.GetNamespace(), "name", value.GetName(), "error", err) + return + } + if err := w.index.replace(&snapshot); err != nil { + slog.Error("load hosted snapshot", "namespace", snapshot.Namespace, "name", snapshot.Name, "error", err) + } +} + +func (w *snapshotWatcher) signal() { + select { + case w.wake <- struct{}{}: + default: + } +} + +func (w *snapshotWatcher) liveContext() context.Context { + w.connectionMu.Lock() + defer w.connectionMu.Unlock() + return w.connection +} + +func (w *snapshotWatcher) run(ctx context.Context) { + done := make(chan struct{}) + go func() { defer close(done); w.informer.RunWithContext(ctx) }() + defer func() { <-done }() + if !cache.WaitForCacheSync(ctx.Done(), w.registration.HasSynced) { + return + } + w.ready.Store(true) + defer w.ready.Store(false) + ticker := time.NewTicker(500 * time.Millisecond) + defer ticker.Stop() + // Unchanged snapshots heartbeat every 30 seconds while connected, within + // the controller's fixed three-minute serving freshness window. + reported := make(map[*zone]time.Time) + for { + w.reportDue(ctx, reported, time.Now()) + select { + case <-ctx.Done(): + return + case <-w.wake: + case <-ticker.C: + } + } +} + +func (w *snapshotWatcher) reportDue(ctx context.Context, reported map[*zone]time.Time, now time.Time) { + for z := range reported { + current, exists := w.index.zones.Load(z.apex) + if !exists || current != z { + delete(reported, z) + } + } + connection := w.liveContext() + if connection == nil || connection.Err() != nil || ctx.Err() != nil { + return + } + // A quarantined or unowned snapshot is no longer being served. Relinquish + // its old acknowledgement instead of waiting for the freshness window. + for key, acknowledged := range w.acknowledged { + if current, exists := w.index.zones.Load(acknowledged.apex); exists && current.(*zone).key == key && current.(*zone).uid == acknowledged.uid { + continue + } + requestContext, cancel := context.WithTimeout(connection, 5*time.Second) + err := w.applyStatus(requestContext, &zone{key: key, uid: acknowledged.uid, apex: acknowledged.apex}, nil) + cancel() + if err != nil && connection.Err() == nil { + slog.Warn("remove unserved hosted acknowledgement", "zone", acknowledged.apex, "error", err) + } + } + w.index.zones.Range(func(_, value any) bool { + z := value.(*zone) + const interval = 30 * time.Second + if now.Sub(reported[z]) < interval { + return true + } + if connection.Err() != nil || ctx.Err() != nil { + return false + } + requestContext, cancel := context.WithTimeout(connection, 5*time.Second) + err := w.applyStatus(requestContext, z, &hostedv1alpha1.HostedZoneServerStatus{Endpoint: w.identity, Serial: z.serial, ContentDigest: z.digest, ObservedAt: metav1.NewTime(now)}) + cancel() + if err != nil { + if connection.Err() == nil { + slog.Error("acknowledge hosted snapshot", "zone", z.apex, "error", err) + } + } else { + reported[z] = now + } + return true + }) +} + +func (w *snapshotWatcher) applyStatus(ctx context.Context, z *zone, status *hostedv1alpha1.HostedZoneServerStatus) error { + servers := []hostedv1alpha1.HostedZoneServerStatus{} + if status != nil { + servers = append(servers, *status) + } + // UID prevents an old in-flight acknowledgement or shutdown cleanup from + // attaching itself to a deleted-and-recreated snapshot with the same name. + patch := struct { + APIVersion string `json:"apiVersion"` + Kind string `json:"kind"` + Metadata metav1.ObjectMeta `json:"metadata"` + Status struct { + Servers []hostedv1alpha1.HostedZoneServerStatus `json:"servers"` + } `json:"status"` + }{APIVersion: hostedv1alpha1.SchemeGroupVersion.String(), Kind: "HostedZone", Metadata: metav1.ObjectMeta{Name: z.key.Name, Namespace: z.key.Namespace, UID: z.uid}} + patch.Status.Servers = servers + payload, err := json.Marshal(patch) + if err != nil { + return err + } + _, err = w.client.Resource(hostedZones).Namespace(z.key.Namespace).Patch(ctx, z.key.Name, types.ApplyPatchType, payload, metav1.PatchOptions{FieldManager: w.fieldManager}, "status") + if status == nil && apierrors.IsNotFound(err) { + err = nil + } + if err == nil { + if status == nil { + delete(w.acknowledged, z.key) + } else { + w.acknowledged[z.key] = acknowledgement{uid: z.uid, apex: z.apex} + } + } + return err +} + +func (w *snapshotWatcher) removeOwnStatus(ctx context.Context) { + for key, acknowledged := range w.acknowledged { + z := &zone{key: key, uid: acknowledged.uid, apex: acknowledged.apex} + if err := w.applyStatus(ctx, z, nil); err != nil { + slog.Warn("remove hosted server acknowledgement", "zone", z.apex, "error", err) + } + if ctx.Err() != nil { + return + } + } +} + +// trackedWatch closes its connection context on EOF, ERROR, or Stop. This +// cancels in-flight status patches as well as suppressing new acknowledgements, +// without discarding the last successfully loaded serving index. +type trackedWatch struct { + upstream watch.Interface + result chan watch.Event + cancel context.CancelFunc + once sync.Once +} + +func (w *trackedWatch) Stop() { w.once.Do(func() { w.cancel(); w.upstream.Stop() }) } +func (w *trackedWatch) ResultChan() <-chan watch.Event { return w.result } +func (w *trackedWatch) forward(ctx context.Context) { + defer close(w.result) + defer w.Stop() + for { + select { + case <-ctx.Done(): + return + case event, ok := <-w.upstream.ResultChan(): + if !ok { + return + } + if event.Type == watch.Error { + w.cancel() + // Delivery is best-effort: the reflector also reconnects on EOF. + select { + case w.result <- event: + default: + } + return + } + select { + case w.result <- event: + case <-ctx.Done(): + return + } + } + } +} diff --git a/internal/go/providers/hosted/server/watch_test.go b/internal/go/providers/hosted/server/watch_test.go new file mode 100644 index 0000000..41e04d5 --- /dev/null +++ b/internal/go/providers/hosted/server/watch_test.go @@ -0,0 +1,326 @@ +package server + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "sync/atomic" + "testing" + "time" + + hostedv1alpha1 "github.com/appthrust/dns-api/pkg/go/api/hosted/v1alpha1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/apimachinery/pkg/types" + "k8s.io/apimachinery/pkg/watch" + dynamicfake "k8s.io/client-go/dynamic/fake" + clienttesting "k8s.io/client-go/testing" +) + +type appliedStatus struct { + patch clienttesting.PatchAction + object hostedv1alpha1.HostedZone +} + +func fakeSnapshotClient(t *testing.T, objects ...runtime.Object) (*dynamicfake.FakeDynamicClient, <-chan appliedStatus) { + t.Helper() + client := dynamicfake.NewSimpleDynamicClientWithCustomListKinds(runtime.NewScheme(), map[schema.GroupVersionResource]string{hostedZones: "HostedZoneList"}, objects...) + patches := make(chan appliedStatus, 100) + // The dynamic fake has no CRD structured-merge schema. Capture the actual + // status apply request rather than pretending merge-patch proves SSA. + client.PrependReactor("patch", "hostedzones", func(action clienttesting.Action) (bool, runtime.Object, error) { + patch := action.(clienttesting.PatchAction) + var object hostedv1alpha1.HostedZone + if err := json.Unmarshal(patch.GetPatch(), &object); err != nil { + t.Error(err) + } + patches <- appliedStatus{patch: patch, object: object} + return true, zoneObject(t, &object), nil + }) + return client, patches +} + +func zoneObject(t *testing.T, zone *hostedv1alpha1.HostedZone) *unstructured.Unstructured { + t.Helper() + zone = zone.DeepCopy() + zone.APIVersion = hostedv1alpha1.SchemeGroupVersion.String() + zone.Kind = "HostedZone" + object, err := json.Marshal(zone) + if err != nil { + t.Fatal(err) + } + result := new(unstructured.Unstructured) + if err := result.UnmarshalJSON(object); err != nil { + t.Fatal(err) + } + result.SetAPIVersion(hostedv1alpha1.SchemeGroupVersion.String()) + result.SetKind("HostedZone") + return result +} + +func receiveStatus(t *testing.T, patches <-chan appliedStatus) appliedStatus { + t.Helper() + select { + case patch := <-patches: + return patch + case <-time.After(3 * time.Second): + t.Fatal("no serving status apply") + return appliedStatus{} + } +} + +func waitUntil(t *testing.T, condition func() bool) { + t.Helper() + deadline := time.NewTimer(3 * time.Second) + defer deadline.Stop() + ticker := time.NewTicker(5 * time.Millisecond) + defer ticker.Stop() + for !condition() { + select { + case <-ticker.C: + case <-deadline.C: + t.Fatal("condition did not become true") + } + } +} + +func startWatcher(t *testing.T, w *snapshotWatcher) { + t.Helper() + ctx, cancel := context.WithCancel(context.Background()) + done := make(chan struct{}) + go func() { defer close(done); w.run(ctx) }() + t.Cleanup(func() { + cancel() + select { + case <-done: + case <-time.After(3 * time.Second): + t.Error("watcher did not stop") + } + }) +} + +func TestInformerPublishesSnapshotAndAppliesOnlyOwnStatus(t *testing.T) { + zone := testZone() + zone.Status.Servers = []hostedv1alpha1.HostedZoneServerStatus{{Endpoint: "dns/other", Serial: 6}} + client, patches := fakeSnapshotClient(t, zoneObject(t, zone)) + index := newZoneIndex(10, newMetrics()) + w, err := newSnapshotWatcher(client, "dns", "dns/server-1", index) + if err != nil { + t.Fatal(err) + } + startWatcher(t, w) + initial := receiveStatus(t, patches) + assertOwnApply(t, initial, 7) + if !w.ready.Load() { + t.Fatal("initial list did not make server ready") + } + first := index.lookup("www.example.test.") + if first == nil || first.serial != 7 { + t.Fatalf("initial snapshot = %#v", first) + } + + zone.Spec.Serial = 8 + zone.Spec.ContentDigest = "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + zone.Spec.Records[3].Values[0] = "192.0.2.99" + if _, err := client.Resource(hostedZones).Namespace("dns").Update(context.Background(), zoneObject(t, zone), metav1.UpdateOptions{}); err != nil { + t.Fatal(err) + } + updated := receiveStatus(t, patches) + assertOwnApply(t, updated, 8) + second := index.lookup("www.example.test.") + if first == second || first.serial != 7 || second.serial != 8 || first.owners["www.example.test."][1][0].String() == second.owners["www.example.test."][1][0].String() { + t.Fatal("snapshot was mutated instead of atomically replaced") + } + + // Status-only events must neither recompile nor immediately acknowledge + // themselves, otherwise status writes create an unbounded feedback loop. + zone.Status.Servers = updated.object.Status.Servers + if _, err := client.Resource(hostedZones).Namespace("dns").UpdateStatus(context.Background(), zoneObject(t, zone), metav1.UpdateOptions{}); err != nil { + t.Fatal(err) + } + if err := client.Resource(hostedZones).Namespace("dns").Delete(context.Background(), zone.Name, metav1.DeleteOptions{}); err != nil { + t.Fatal(err) + } + waitUntil(t, func() bool { return index.lookup("www.example.test.") == nil }) + if !w.ready.Load() { + t.Fatal("empty authority became unready") + } + for _, action := range client.Actions() { + if action.GetNamespace() != "dns" { + t.Fatalf("watch escaped namespace: %#v", action) + } + } +} + +func assertOwnApply(t *testing.T, applied appliedStatus, serial uint32) { + t.Helper() + patch := applied.patch + if patch.GetPatchType() != types.ApplyPatchType || patch.GetSubresource() != "status" || patch.GetName() != "example" || patch.GetNamespace() != "dns" { + t.Fatalf("patch contract = %#v", patch) + } + options := patch.(interface{ GetPatchOptions() metav1.PatchOptions }).GetPatchOptions() + if options.FieldManager != "hosted-server/server-1" || options.Force != nil { + t.Fatalf("apply options = %#v", options) + } + object := applied.object + if object.UID != "zone-1" || object.APIVersion != hostedv1alpha1.SchemeGroupVersion.String() || object.Kind != "HostedZone" || len(object.Status.Servers) != 1 { + t.Fatalf("apply object = %#v", object) + } + status := object.Status.Servers[0] + if status.Endpoint != "dns/server-1" || status.Serial != serial || status.ObservedAt.IsZero() || status.ContentDigest == "" { + t.Fatalf("own status = %#v", status) + } + var fields map[string]json.RawMessage + if err := json.Unmarshal(patch.GetPatch(), &fields); err != nil { + t.Fatal(err) + } + if _, exists := fields["spec"]; exists { + t.Fatal("server tried to own snapshot spec") + } +} + +func TestInitialEmptyListIsReady(t *testing.T) { + client, _ := fakeSnapshotClient(t) + w, err := newSnapshotWatcher(client, "", "dns/server-1", newZoneIndex(10, newMetrics())) + if err != nil { + t.Fatal(err) + } + startWatcher(t, w) + waitUntil(t, w.ready.Load) + for _, action := range client.Actions() { + if action.GetNamespace() != "" { + t.Fatalf("empty scope is not cluster-wide: %#v", action) + } + } +} + +func TestWatchLossSuppressesReportsAndPreservesServing(t *testing.T) { + for _, event := range []string{"EOF", "ERROR", "STOP"} { + t.Run(event, func(t *testing.T) { + client, patches := fakeSnapshotClient(t) + index := newZoneIndex(10, newMetrics()) + if err := index.replace(testZone()); err != nil { + t.Fatal(err) + } + w, err := newSnapshotWatcher(client, "dns", "dns/server-1", index) + if err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + w.connection = ctx + upstream := watch.NewRaceFreeFake() + tracked := &trackedWatch{upstream: upstream, result: make(chan watch.Event), cancel: cancel} + go tracked.forward(ctx) + reported := make(map[*zone]time.Time) + now := time.Now() + w.reportDue(context.Background(), reported, now) + assertOwnApply(t, receiveStatus(t, patches), 7) + switch event { + case "EOF": + upstream.Stop() + case "ERROR": + upstream.Error(&metav1.Status{Status: "Failure"}) + case "STOP": + tracked.Stop() + } + select { + case <-ctx.Done(): + case <-time.After(time.Second): + t.Fatal("watch loss not detected") + } + w.reportDue(context.Background(), reported, now.Add(time.Hour)) + select { + case patch := <-patches: + t.Fatalf("reported after watch loss: %#v", patch) + default: + } + if index.lookup("www.example.test.") == nil { + t.Fatal("watch loss dropped serving snapshot") + } + // A newly connected watch resumes heartbeat acknowledgement. + reconnected, stop := context.WithCancel(context.Background()) + defer stop() + w.connection = reconnected + w.reportDue(context.Background(), reported, now.Add(time.Hour)) + assertOwnApply(t, receiveStatus(t, patches), 7) + }) + } +} + +func TestShutdownRelinquishesOnlyOwnSSAFields(t *testing.T) { + client, patches := fakeSnapshotClient(t) + index := newZoneIndex(10, newMetrics()) + if err := index.replace(testZone()); err != nil { + t.Fatal(err) + } + w, err := newSnapshotWatcher(client, "dns", "dns/server-1", index) + if err != nil { + t.Fatal(err) + } + w.connection = context.Background() + w.reportDue(context.Background(), make(map[*zone]time.Time), time.Now()) + assertOwnApply(t, receiveStatus(t, patches), 7) + w.removeOwnStatus(context.Background()) + applied := receiveStatus(t, patches) + if applied.patch.GetPatchType() != types.ApplyPatchType || applied.patch.GetSubresource() != "status" { + t.Fatalf("cleanup patch = %#v", applied.patch) + } + options := applied.patch.(interface{ GetPatchOptions() metav1.PatchOptions }).GetPatchOptions() + if options.FieldManager != "hosted-server/server-1" || options.Force != nil { + t.Fatalf("cleanup manager = %#v", options) + } + var body map[string]any + if err := json.Unmarshal(applied.patch.GetPatch(), &body); err != nil { + t.Fatal(err) + } + servers := body["status"].(map[string]any)["servers"].([]any) + if len(servers) != 0 || applied.object.UID != "zone-1" { + t.Fatalf("cleanup body = %s", applied.patch.GetPatch()) + } +} + +func TestHealthReadinessBoundary(t *testing.T) { + var ready atomic.Bool + handler := healthHandler(&ready) + for _, tc := range []struct { + path string + ready bool + code int + }{{"/healthz", false, 200}, {"/readyz", false, 503}, {"/readyz", true, 200}, {"/healthz", true, 200}} { + ready.Store(tc.ready) + recorder := httptest.NewRecorder() + handler.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, tc.path, nil)) + if recorder.Code != tc.code { + t.Fatalf("%s ready=%v: %d", tc.path, tc.ready, recorder.Code) + } + } +} + +func TestHeartbeatEveryThirtySeconds(t *testing.T) { + client, patches := fakeSnapshotClient(t) + index := newZoneIndex(10, newMetrics()) + if err := index.replace(testZone()); err != nil { + t.Fatal(err) + } + w, err := newSnapshotWatcher(client, "dns", "dns/server-1", index) + if err != nil { + t.Fatal(err) + } + w.connection = context.Background() + reported := make(map[*zone]time.Time) + now := time.Now() + w.reportDue(context.Background(), reported, now) + assertOwnApply(t, receiveStatus(t, patches), 7) + w.reportDue(context.Background(), reported, now.Add(30*time.Second-time.Millisecond)) + select { + case <-patches: + t.Fatal("unchanged snapshot was acknowledged before 30 seconds") + default: + } + w.reportDue(context.Background(), reported, now.Add(30*time.Second)) + assertOwnApply(t, receiveStatus(t, patches), 7) +} diff --git a/internal/go/providers/hosted/server/zone.go b/internal/go/providers/hosted/server/zone.go new file mode 100644 index 0000000..9f03991 --- /dev/null +++ b/internal/go/providers/hosted/server/zone.go @@ -0,0 +1,266 @@ +package server + +import ( + "fmt" + "net" + "strconv" + "strings" + "sync" + + hostedv1alpha1 "github.com/appthrust/dns-api/pkg/go/api/hosted/v1alpha1" + "github.com/miekg/dns" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" +) + +// zone is immutable after publication. Readers retain one snapshot throughout a +// query, including CNAME traversal, so an informer update cannot mix serials. +type zone struct { + key types.NamespacedName + uid types.UID + apex string + serial uint32 + digest string + owners map[string]map[uint16][]dns.RR + wildcards map[string]map[uint16][]dns.RR + soa *dns.SOA + negativeSOA *dns.SOA + glue []dns.RR +} + +func compileZone(snapshot *hostedv1alpha1.HostedZone) (*zone, error) { + spec := &snapshot.Spec + apex := dns.CanonicalName(spec.DomainName) + if _, valid := dns.IsDomainName(apex); !valid || spec.DomainName == "" || apex == "." || spec.Serial == 0 { + return nil, fmt.Errorf("invalid zone apex or serial") + } + z := &zone{key: types.NamespacedName{Namespace: snapshot.Namespace, Name: snapshot.Name}, uid: snapshot.UID, + apex: apex, serial: spec.Serial, digest: spec.ContentDigest, + owners: make(map[string]map[uint16][]dns.RR), wildcards: make(map[string]map[uint16][]dns.RR)} + z.owners[apex] = make(map[uint16][]dns.RR) + glueTTL := hostedv1alpha1.DefaultTTL + for _, record := range spec.Records { + owner := apex + if record.Name != "@" { + owner = dns.CanonicalName(record.Name + "." + apex) + } + if _, valid := dns.IsDomainName(owner); !valid || !dns.IsSubDomain(apex, owner) { + return nil, fmt.Errorf("invalid record owner %q", record.Name) + } + kind, ok := dns.StringToType[string(record.Type)] + if !ok || !supportedType(kind) { + return nil, fmt.Errorf("unsupported record type %q", record.Type) + } + if len(record.Values) == 0 { + return nil, fmt.Errorf("empty RRset at %s", owner) + } + if z.owners[owner] == nil { + z.owners[owner] = make(map[uint16][]dns.RR) + } + if len(z.owners[owner][kind]) != 0 { + return nil, fmt.Errorf("duplicate RRset at %s type %s", owner, record.Type) + } + rrs := make([]dns.RR, 0, len(record.Values)) + prefix := owner + " " + strconv.FormatUint(uint64(record.TTL), 10) + " IN " + string(record.Type) + " " + for _, value := range record.Values { + rr, err := dns.NewRR(prefix + value) + if err != nil { + return nil, fmt.Errorf("record %s %s: %w", owner, record.Type, err) + } + rrs = append(rrs, rr) + } + z.owners[owner][kind] = rrs + if owner == apex && kind == dns.TypeNS { + glueTTL = record.TTL + } + if strings.HasPrefix(owner, "*.") { + z.wildcards[owner[2:]] = z.owners[owner] + } + // Empty non-terminals exist for RFC 4592 closest-encloser matching. + for ancestor := parentName(owner); ancestor != "" && dns.IsSubDomain(apex, ancestor); ancestor = parentName(ancestor) { + if _, exists := z.owners[ancestor]; !exists { + z.owners[ancestor] = nil + } + if ancestor == apex { + break + } + } + } + for owner, rrsets := range z.owners { + if cnames := rrsets[dns.TypeCNAME]; len(cnames) != 0 && (len(cnames) != 1 || len(rrsets) != 1 || owner == apex) { + return nil, fmt.Errorf("CNAME must be exclusive at %s", owner) + } + } + for _, ns := range spec.NameServers { + name := dns.CanonicalName(ns.Name) + if _, valid := dns.IsDomainName(name); !valid || name == "." { + return nil, fmt.Errorf("invalid nameserver %q", ns.Name) + } + for _, address := range ns.Addresses { + ip := net.ParseIP(address) + if ip == nil { + return nil, fmt.Errorf("invalid glue address %q", address) + } + header := dns.RR_Header{Name: name, Class: dns.ClassINET, Ttl: glueTTL} + if v4 := ip.To4(); v4 != nil { + header.Rrtype = dns.TypeA + z.glue = append(z.glue, &dns.A{Hdr: header, A: v4}) + } else { + header.Rrtype = dns.TypeAAAA + z.glue = append(z.glue, &dns.AAAA{Hdr: header, AAAA: ip}) + } + } + } + if len(z.owners[apex][dns.TypeNS]) == 0 { + for _, ns := range spec.NameServers { + z.owners[apex][dns.TypeNS] = append(z.owners[apex][dns.TypeNS], &dns.NS{Hdr: dns.RR_Header{Name: apex, Rrtype: dns.TypeNS, Class: dns.ClassINET, Ttl: glueTTL}, Ns: dns.CanonicalName(ns.Name)}) + } + } + z.soa = &dns.SOA{Hdr: dns.RR_Header{Name: apex, Rrtype: dns.TypeSOA, Class: dns.ClassINET, Ttl: spec.SOA.Minimum}, + Ns: dns.CanonicalName(spec.SOA.MName), Mbox: dns.CanonicalName(spec.SOA.RName), Serial: spec.Serial, + Refresh: spec.SOA.Refresh, Retry: spec.SOA.Retry, Expire: spec.SOA.Expire, Minttl: spec.SOA.Minimum} + z.owners[apex][dns.TypeSOA] = []dns.RR{z.soa} + negative := *z.soa + negative.Hdr.Ttl = min(z.soa.Hdr.Ttl, spec.SOA.Minimum) + z.negativeSOA = &negative + return z, nil +} + +func supportedType(kind uint16) bool { + switch kind { + case dns.TypeA, dns.TypeAAAA, dns.TypeCNAME, dns.TypeTXT, dns.TypeMX, dns.TypeCAA, dns.TypeNS: + return true + } + return false +} + +func parentName(name string) string { + if name == "." { + return "" + } + index, end := dns.NextLabel(name, 0) + if end { + return "." + } + return name[index:] +} + +func (z *zone) lookup(name string) (map[uint16][]dns.RR, bool) { + if rrsets, exists := z.owners[name]; exists { + return rrsets, true + } + for ancestor := parentName(name); ancestor != "" && dns.IsSubDomain(z.apex, ancestor); ancestor = parentName(ancestor) { + if _, exists := z.owners[ancestor]; exists { + rrsets, wildcard := z.wildcards[ancestor] + return rrsets, wildcard + } + } + return nil, false +} + +// sync.Map publishes each compiled zone atomically without copying unrelated +// zones. mu serializes only index mutations and capacity/ownership bookkeeping. +type zoneIndex struct { + zones sync.Map // canonical apex -> *zone + blocked sync.Map // conflicting apexes must not fall through to a parent zone + mu sync.Mutex + keys map[types.NamespacedName]string + maxZones int + metrics *serverMetrics +} + +func newZoneIndex(maxZones int, metrics *serverMetrics) *zoneIndex { + return &zoneIndex{keys: make(map[types.NamespacedName]string), maxZones: maxZones, metrics: metrics} +} + +func (index *zoneIndex) replace(snapshot *hostedv1alpha1.HostedZone) error { + index.mu.Lock() + defer index.mu.Unlock() + key := types.NamespacedName{Namespace: snapshot.Namespace, Name: snapshot.Name} + if !hasZoneUnitOwner(snapshot.OwnerReferences) { + index.metrics.rejected.WithLabelValues("unowned").Inc() + if apex, exists := index.keys[key]; exists { + index.removeApexLocked(apex) + } + return fmt.Errorf("snapshot has no ZoneUnit owner reference") + } + oldApex, exists := index.keys[key] + apex := dns.CanonicalName(snapshot.Spec.DomainName) + if current, exists := index.zones.Load(apex); exists && current.(*zone).key != key { + index.blocked.Store(apex, struct{}{}) + index.removeApexLocked(apex) + index.metrics.rejected.WithLabelValues("apex_conflict").Inc() + return fmt.Errorf("zone apex %s has conflicting snapshots", apex) + } + if !exists && len(index.keys) >= index.maxZones { + return fmt.Errorf("hosted zone limit %d reached", index.maxZones) + } + compiled, err := compileZone(snapshot) + if err != nil { + return err + } + if exists && oldApex != apex { + index.removeApexLocked(oldApex) + } + index.zones.Store(apex, compiled) + index.keys[key] = apex + index.blocked.Delete(apex) + index.metrics.serial.WithLabelValues(apex).Set(float64(compiled.serial)) + index.metrics.loaded.Set(float64(len(index.keys))) + return nil +} + +func (index *zoneIndex) remove(key types.NamespacedName, uid types.UID) { + index.mu.Lock() + defer index.mu.Unlock() + apex, exists := index.keys[key] + if !exists { + return + } + if current, ok := index.zones.Load(apex); ok && current.(*zone).uid != uid { + return + } + index.removeApexLocked(apex) +} + +func hasZoneUnitOwner(owners []metav1.OwnerReference) bool { + for _, owner := range owners { + if owner.APIVersion == "dns.appthrust.io/v1alpha1" && owner.Kind == "ZoneUnit" && owner.UID != "" { + return true + } + } + return false +} + +func (index *zoneIndex) removeApexLocked(apex string) { + if current, exists := index.zones.LoadAndDelete(apex); exists { + delete(index.keys, current.(*zone).key) + index.metrics.serial.DeleteLabelValues(apex) + index.metrics.queries.DeletePartialMatch(map[string]string{"zone": apex}) + index.metrics.loaded.Set(float64(len(index.keys))) + } +} + +func (index *zoneIndex) reconcileAbsentApex(apex string, conflict bool) { + index.mu.Lock() + defer index.mu.Unlock() + if conflict { + index.blocked.Store(apex, struct{}{}) + index.metrics.rejected.WithLabelValues("apex_conflict").Inc() + } else { + index.blocked.Delete(apex) + } + index.removeApexLocked(apex) +} + +func (index *zoneIndex) lookup(name string) *zone { + for candidate := name; candidate != ""; candidate = parentName(candidate) { + if _, blocked := index.blocked.Load(candidate); blocked { + return nil + } + if value, ok := index.zones.Load(candidate); ok { + return value.(*zone) + } + } + return nil +} diff --git a/internal/go/providers/hosted/server/zone_test.go b/internal/go/providers/hosted/server/zone_test.go new file mode 100644 index 0000000..f7083a7 --- /dev/null +++ b/internal/go/providers/hosted/server/zone_test.go @@ -0,0 +1,185 @@ +package server + +import ( + "net" + "net/netip" + "testing" + "time" + + "github.com/miekg/dns" + "github.com/prometheus/client_golang/prometheus" + dto "github.com/prometheus/client_model/go" + "k8s.io/apimachinery/pkg/types" +) + +func TestZoneIndexBoundsAndReplacement(t *testing.T) { + index := newZoneIndex(1, newMetrics()) + original := testZone() + if err := index.replace(original); err != nil { + t.Fatal(err) + } + first := index.lookup("www.example.test.") + original.Spec.Records[3].Values[0] = "192.0.2.100" + if first.owners["www.example.test."][dns.TypeA][0].(*dns.A).A.String() != "192.0.2.1" { + t.Fatal("index aliases API object") + } + other := testZone() + other.Name = "other" + other.Spec.DomainName = "other.test" + if err := index.replace(other); err == nil { + t.Fatal("zone limit was not enforced") + } + if index.lookup("other.test.") != nil { + t.Fatal("rejected zone was published") + } + original.Spec.Serial++ + if err := index.replace(original); err != nil { + t.Fatalf("replacement counted against capacity: %v", err) + } + current := index.lookup("www.example.test.") + if current == first || current.serial != 8 || first.serial != 7 { + t.Fatal("replacement was not immutable") + } + broken := original.DeepCopy() + broken.Spec.Records[3].Values[0] = "not-an-ip" + if err := index.replace(broken); err == nil { + t.Fatal("invalid snapshot loaded") + } + if index.lookup("www.example.test.") != current { + t.Fatal("invalid update replaced last good snapshot") + } + index.remove(types.NamespacedName{Namespace: "dns", Name: "example"}, "old-uid") + if index.lookup("www.example.test.") == nil { + t.Fatal("old UID deleted new snapshot") + } + index.remove(current.key, current.uid) + if index.lookup("www.example.test.") != nil { + t.Fatal("deleted snapshot is still served") + } + if metricValue(t, index.metrics.loaded) != 0 { + t.Fatal("zone gauge was not decremented") + } +} + +func TestLongestApexAndDuplicateOwnership(t *testing.T) { + index := newZoneIndex(3, newMetrics()) + parent := testZone() + if err := index.replace(parent); err != nil { + t.Fatal(err) + } + child := testZone() + child.Name = "child" + child.Spec.DomainName = "child.example.test" + if err := index.replace(child); err != nil { + t.Fatal(err) + } + if got := index.lookup("www.child.example.test."); got == nil || got.apex != "child.example.test." { + t.Fatalf("longest apex = %#v", got) + } + duplicate := parent.DeepCopy() + duplicate.Namespace = "other" + if err := index.replace(duplicate); err == nil { + t.Fatal("duplicate apex stole authority") + } + if index.lookup("example.test.") != nil { + t.Fatal("conflicting apex retained an arbitrary winner") + } +} + +func TestNegativeTTLUsesSOATTLMinimum(t *testing.T) { + zone := testZone() + zone.Spec.SOA.Minimum = 300 + compiled, err := compileZone(zone) + if err != nil { + t.Fatal(err) + } + if compiled.soa.Hdr.Ttl != 300 || compiled.negativeSOA.Hdr.Ttl != 300 { + t.Fatalf("SOA TTL = %d negative = %d", compiled.soa.Hdr.Ttl, compiled.negativeSOA.Hdr.Ttl) + } +} + +func TestPerSourceTokenBucketAndUDPDrop(t *testing.T) { + now := time.Unix(1, 0) + limiter := newSourceLimiter(2) + one := netip.MustParseAddr("192.0.2.1") + two := netip.MustParseAddr("192.0.2.2") + if !limiter.allow(one, now) || !limiter.allow(one, now) || limiter.allow(one, now) { + t.Fatal("burst limit not enforced") + } + if !limiter.allow(two, now) { + t.Fatal("independent source shared a bucket") + } + if limiter.allow(one, now.Add(499*time.Millisecond)) || !limiter.allow(one, now.Add(500*time.Millisecond)) { + t.Fatal("token refill incorrect") + } + if !limiter.allow(one, now.Add(2*time.Second)) || !limiter.allow(one, now.Add(2*time.Second)) || limiter.allow(one, now.Add(2*time.Second)) { + t.Fatal("refill exceeded burst") + } + h := testHandler(t, testZone()) + h.limiter = newSourceLimiter(1) + request := new(dns.Msg) + request.SetQuestion("www.example.test.", dns.TypeA) + first := &responseRecorder{remote: &net.UDPAddr{IP: net.IPv4(192, 0, 2, 1), Port: 1000}} + h.ServeDNS(first, request) + second := &responseRecorder{remote: &net.UDPAddr{IP: net.IPv4(192, 0, 2, 1), Port: 2000}} + h.ServeDNS(second, request) + if first.msg == nil || second.msg != nil || metricValue(t, h.metrics.rateLimited) != 1 { + t.Fatal("UDP request was not dropped per source address") + } + tcp := &responseRecorder{remote: &net.TCPAddr{IP: net.IPv4(192, 0, 2, 1), Port: 2000}} + h.ServeDNS(tcp, request) + if tcp.msg == nil { + t.Fatal("UDP rate limit blocked TCP") + } +} + +func TestSourceTableIsBoundedAndIdleSourcesExpire(t *testing.T) { + limiter := newSourceLimiter(1) + now := time.Unix(1, 0) + for i := range maxRateLimitSources { + address := netip.AddrFrom4([4]byte{192, byte(i >> 16), byte(i >> 8), byte(i)}) + if !limiter.allow(address, now) { + t.Fatal("source table filled early") + } + } + newSource := netip.MustParseAddr("203.0.113.1") + if limiter.allow(newSource, now) || len(limiter.sources) != maxRateLimitSources { + t.Fatal("active source table is unbounded") + } + if !limiter.allow(newSource, now.Add(sourceIdleTimeout)) || len(limiter.sources) > maxRateLimitSources { + t.Fatal("idle source was not reclaimed") + } +} + +func TestServerOptionsDefaultsAndIdentity(t *testing.T) { + t.Setenv("POD_NAMESPACE", "dns") + t.Setenv("POD_NAME", "server-1") + options, err := (Options{}).defaults() + if err != nil { + t.Fatal(err) + } + if options.ListenAddr != ":5353" || options.WatchNamespace != "" || options.MaxZones != 4096 || options.UDPRateLimitPerSource != 1000 || options.PodIdentity != "dns/server-1" || options.MetricsAddr != ":8080" || options.HealthAddr != ":8081" || options.RESTConfig != nil { + t.Fatalf("defaults = %#v", options) + } + for _, options := range []Options{{MaxZones: -1}, {UDPRateLimitPerSource: -1}, {PodIdentity: "server"}, {PodIdentity: "/server"}, {PodIdentity: "dns/"}, {PodIdentity: "a/b/c"}} { + if _, err := options.defaults(); err == nil { + t.Fatalf("invalid options accepted: %#v", options) + } + } + t.Setenv("POD_NAMESPACE", "") + if _, err := (Options{}).defaults(); err == nil { + t.Fatal("missing pod environment accepted") + } +} + +func metricValue(t *testing.T, metric prometheus.Metric) float64 { + t.Helper() + value := new(dto.Metric) + if err := metric.Write(value); err != nil { + t.Fatal(err) + } + if value.Counter != nil { + return value.Counter.GetValue() + } + return value.Gauge.GetValue() +} diff --git a/internal/go/providers/hosted/withdrawal/evaluator.go b/internal/go/providers/hosted/withdrawal/evaluator.go new file mode 100644 index 0000000..bcc21b8 --- /dev/null +++ b/internal/go/providers/hosted/withdrawal/evaluator.go @@ -0,0 +1,327 @@ +// Package withdrawal verifies served-snapshot and all-view address exclusion. +package withdrawal + +import ( + "context" + "errors" + "fmt" + "net/netip" + "slices" + "strings" + "time" + + "github.com/appthrust/dns-api/internal/go/core/dnsprobe" + dnsv1alpha1 "github.com/appthrust/dns-api/pkg/go/api/dns/v1alpha1" + hostedv1alpha1 "github.com/appthrust/dns-api/pkg/go/api/hosted/v1alpha1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +type Evaluator interface { + Evaluate(ctx context.Context, in Input) (Decision, error) +} + +type Input struct { + Claim dnsv1alpha1.ZoneUnitWithdrawal + Snapshot hostedv1alpha1.HostedZoneSpec + Served bool + // Policy contains resolved verification views. Delegation observation uses + // a separate probe policy in the controller, not withdrawal authority gates. + Policy dnsprobe.Policy + SOAMinimum uint32 + Now time.Time + Previous *State +} + +type Decision struct { + Withdrawn metav1.ConditionStatus + Ready metav1.ConditionStatus + Reason string + Message string + AbsentSince *metav1.Time + NotBefore *metav1.Time + VerifiedAt *metav1.Time + CheckedNames []string + RequiredViews []string + ProviderData map[string]any + NextProbe time.Time + State *State + PolicyFingerprint string + WaiverRef *dnsv1alpha1.RecordWithdrawalWaiverReference + OverrideAccepted *metav1.Condition +} + +type evaluator struct { + resolver dnsprobe.Resolver + clock func() time.Time +} + +// New does not discover views or fall back to an unconfigured resolver. The +// controller supplies explicit IP:port endpoints, including the default Service. +func New(resolver dnsprobe.Resolver, clock func() time.Time) Evaluator { + if clock == nil { + clock = time.Now + } + return &evaluator{resolver: resolver, clock: clock} +} + +func (e *evaluator) Evaluate(ctx context.Context, in Input) (Decision, error) { + started := e.clock() + now := in.Now + if now.IsZero() { + now = started + } + state := initialState(in) + views := slices.Clone(in.Policy.Servers) + slices.Sort(views) + probePolicy := in.Policy + probePolicy.Servers = views + probePolicy.ExpectedNameservers = slices.Clone(probePolicy.ExpectedNameservers) + slices.Sort(probePolicy.ExpectedNameservers) + if probePolicy.Mode == "" { + probePolicy.Mode = dnsprobe.ModeRecursive + } + required := make([]string, len(views)) + for i, view := range views { + required[i] = "recursive/" + view + } + policy := dnsprobe.Fingerprint(struct { + Policy dnsprobe.Policy + SOAMinimum uint32 + SnapshotDigest string + }{probePolicy, in.SOAMinimum, in.Snapshot.ContentDigest}) + changed := state.Policy != "" && state.Policy != policy + weakened := state.MissingHistory || in.SOAMinimum < state.SOAMinimum + for _, floor := range state.Views { + weakened = weakened || !slices.Contains(required, floor) + } + if !state.LastProbe.IsZero() && (now.Before(state.LastProbe) || state.NextProbe.IsZero() || now.After(state.NextProbe.Add(300*time.Second))) { + state.invalidate() + } + if changed || weakened { + state.invalidate() + } + + decision := dnsprobe.Decision{Status: metav1.ConditionTrue, Reason: "WithdrawalVerified"} + valid := validClaim(in) + switch { + case !valid: + decision = failed(in.Claim.Name, "required-views", "invalid-response") + case e.resolver == nil || dnsprobe.ValidateServers(views) != nil: + decision = failed(in.Claim.Name, "configured-resolvers", "resolver-config") + default: + for _, name := range state.CheckedNames { + snapshot := snapshotExclusion(&in.Snapshot, name, in.Claim.Address) + state.retainPositive(name, snapshot.Cache, now) + decision = combine(decision, snapshot.Decision) + request := dnsprobe.ProbeRequest{Name: name, Zone: in.Snapshot.DomainName, Policy: probePolicy, DueTime: state.NextProbe, WithdrawalAddress: in.Claim.Address} + observed, err := e.resolver.Probe(ctx, request) + one := dnsprobe.EvaluateWithdrawal(request, observed) + elapsed := e.clock().Sub(started) + state.retainPositive(name, one.Cache, now.Add(max(elapsed, 0))) + if err != nil { + one.Decision = failed(name, "required-views", failureCode(err)) + } + decision = combine(decision, one.Decision) + } + if !in.Served && decision.Status == metav1.ConditionTrue { + decision = failed(in.Claim.Name, "served-snapshot", "invalid-response") + } + } + elapsed := e.clock().Sub(started) + if elapsed < 0 || elapsed > 300*time.Second { + decision = failed(in.Claim.Name, "required-views", "timeout") + } else { + now = now.Add(elapsed) + } + if !state.LastProbe.IsZero() && (now.Before(state.LastProbe) || state.NextProbe.IsZero() || now.After(state.NextProbe.Add(300*time.Second))) { + state.invalidate() + } + if changed || weakened { + decision = dnsprobe.Decision{Status: metav1.ConditionUnknown, Reason: "PolicyChanged", Message: fmt.Sprintf("Hosted DNS withdrawal policy changed for %s; the prior required view policy remains a floor.", in.Claim.Name)} + } + // Failed probes cannot lower the policy floor or discard retained positives. + if valid && !weakened && dnsprobe.ValidateServers(views) == nil { + state.Views = slices.Clone(required) + state.SOAMinimum = max(state.SOAMinimum, in.SOAMinimum) + } + state.Policy = policy + if decision.Status == metav1.ConditionTrue { + decision = state.window(in.SOAMinimum, now) + } else { + state.invalidate() + } + switch decision.Reason { + case "NegativeTTLNotElapsed": + decision.Message = fmt.Sprintf("Retired address %s is absent in every required view; waiting until %s for cache safety and a confirming probe.", in.Claim.Address, state.NotBefore.UTC().Format(time.RFC3339)) + case "WithdrawalVerified": + decision.Message = fmt.Sprintf("Retired address %s was verified absent from %s in every required view at %s.", in.Claim.Address, in.Claim.Name, now.UTC().Format(time.RFC3339)) + } + state.schedule(decision.Status, changed, now) + out := Decision{ + Withdrawn: decision.Status, Ready: metav1.ConditionFalse, Reason: decision.Reason, + Message: strings.ReplaceAll(decision.Message, "DNS withdrawal cannot", "Hosted DNS withdrawal cannot"), + AbsentSince: state.AbsentSince.DeepCopy(), NotBefore: state.NotBefore.DeepCopy(), VerifiedAt: state.VerifiedAt.DeepCopy(), + CheckedNames: slices.Clone(state.CheckedNames), RequiredViews: required, + ProviderData: map[string]any{"views": dnsprobe.Preview(required)}, NextProbe: state.NextProbe, + State: state, PolicyFingerprint: policy, + } + if len(out.Message) > 2048 { + out.Message = out.Message[:2048] + } + if out.Withdrawn == metav1.ConditionTrue { + out.Ready = metav1.ConditionTrue + } + if valid { + bindWaiver(&out, in.Claim, now) + } + return out, nil +} + +func validClaim(in Input) bool { + item := in.Claim + ip, err := netip.ParseAddr(item.Address) + if err != nil || ip.Zone() != "" || ip.Unmap().String() != item.Address || !item.Allowed || item.Claim.UID == "" || item.ClaimNamespace == "" || item.ClaimName == "" || item.Claim.Namespace != item.ClaimNamespace || item.Claim.Name != item.ClaimName || item.ObservedGeneration < 1 || in.Snapshot.ContentDigest == "" || !dnsprobe.ValidName(in.Snapshot.DomainName) { + return false + } + name := in.Snapshot.DomainName + if item.Name != "@" && item.Name != "*" { + name = strings.TrimPrefix(item.Name, "*.") + "." + name + } + return dnsprobe.ValidName(name) +} + +func failed(name, view, code string) dnsprobe.Decision { + return dnsprobe.WithdrawalFailed(name, view, code).Decision +} + +func failureCode(err error) string { + if errors.Is(err, context.DeadlineExceeded) { + return "timeout" + } + switch err.Error() { + case "resolver-config", "invalid-response", "probe-limit", "chain-limit": + return err.Error() + } + return "transport" +} + +func combine(current, next dnsprobe.Decision) dnsprobe.Decision { + if next.Status == metav1.ConditionUnknown || (next.Status == metav1.ConditionFalse && current.Status == metav1.ConditionTrue) { + return next + } + return current +} + +func bindWaiver(out *Decision, claim dnsv1alpha1.ZoneUnitWithdrawal, now time.Time) { + waiver := claim.Waiver + if waiver == nil || waiver.Name == "" || waiver.UID == "" || waiver.Digest == "" || waiver.ReservationRef.UID == "" { + return + } + out.WaiverRef = &dnsv1alpha1.RecordWithdrawalWaiverReference{Name: waiver.Name, UID: waiver.UID, Digest: waiver.Digest, ReservationRef: waiver.ReservationRef} + out.OverrideAccepted = &metav1.Condition{Type: "OverrideAccepted", Status: metav1.ConditionTrue, Reason: "OperatorAuthorizedRelease", Message: "Core-validated withdrawal waiver is bound to its exact UID and immutable scope.", ObservedGeneration: claim.ObservedGeneration, LastTransitionTime: metav1.NewTime(now)} +} + +// SnapshotExcludes evaluates only the local snapshot term. It is not serving +// evidence and never replaces the all-view probes and continuous absence window. +func SnapshotExcludes(spec hostedv1alpha1.HostedZoneSpec, claim dnsv1alpha1.ZoneUnitWithdrawal) bool { + in := Input{Snapshot: spec, Claim: claim} + if !validClaim(in) { + return false + } + name := checkedName(&in, claimIdentity(&in)) + return snapshotExclusion(&spec, name, claim.Address).Status == metav1.ConditionTrue +} + +// snapshotExclusion follows only links served by this snapshot. Foreign links +// require complete terminal evidence from every probe view, never a local guess. +func snapshotExclusion(spec *hostedv1alpha1.HostedZoneSpec, checked, address string) dnsprobe.WithdrawalDecision { + decision := dnsprobe.WithdrawalDecision{Decision: dnsprobe.Decision{Status: metav1.ConditionTrue, Reason: "WithdrawalVerified"}} + retired, _ := netip.ParseAddr(address) // validClaim has already checked it. + name, ttl := checked, uint32(0) + var visited [dnsprobe.MaxCNAMEs + 1]string + for depth := 0; depth <= dnsprobe.MaxCNAMEs; depth++ { + if name != spec.DomainName && !strings.HasSuffix(name, "."+spec.DomainName) { + return decision + } + if slices.Contains(visited[:depth], name) { + return dnsprobe.WithdrawalFailed(checked, "snapshot", "chain-limit") + } + visited[depth] = name + owner := snapshotOwner(spec, name) + var cname *hostedv1alpha1.HostedZoneRecord + for i := range spec.Records { + record := &spec.Records[i] + if record.Name != owner { + continue + } + if record.Type == "CNAME" { + cname = record + continue + } + if record.Type != "A" && record.Type != "AAAA" { + continue + } + for _, value := range record.Values { + ip, err := netip.ParseAddr(value) + if err != nil || ip.Zone() != "" || (record.Type == "A" && !ip.Is4()) || (record.Type == "AAAA" && !ip.Is6()) { + return dnsprobe.WithdrawalFailed(checked, "snapshot", "invalid-response") + } + if ip.Unmap() == retired { + bound := max(ttl, record.TTL) + decision.Cache = append(decision.Cache, dnsprobe.WithdrawalCacheEvidence{Name: name, View: "snapshot", QType: string(record.Type), PositiveTTL: &bound}) + decision.Status, decision.Reason = metav1.ConditionFalse, "DNSStillResolves" + decision.Message = fmt.Sprintf("DNS %s still resolves to retired address %s in snapshot (%s).", checked, address, record.Type) + } + } + } + if cname == nil || decision.Status == metav1.ConditionFalse { + return decision + } + if len(cname.Values) != 1 || !dnsprobe.ValidName(dnsprobe.CanonicalName(cname.Values[0])) { + return dnsprobe.WithdrawalFailed(checked, "snapshot", "invalid-response") + } + ttl = max(ttl, cname.TTL) + name = dnsprobe.CanonicalName(cname.Values[0]) + } + return dnsprobe.WithdrawalFailed(checked, "snapshot", "chain-limit") +} + +// Existing owners and empty nonterminals block wildcard synthesis. Otherwise +// only the wildcard below the closest existing encloser can answer (RFC 4592). +func snapshotOwner(spec *hostedv1alpha1.HostedZoneSpec, name string) string { + relative := "@" + if name != spec.DomainName { + relative = strings.TrimSuffix(name, "."+spec.DomainName) + } + if snapshotNodeExists(spec, relative) { + return relative + } + for relative != "@" { + _, parent, found := strings.Cut(relative, ".") + if !found { + parent = "@" + } + if snapshotNodeExists(spec, parent) { + if parent == "@" { + return "*" + } + return "*." + parent + } + relative = parent + } + return "@" +} + +func snapshotNodeExists(spec *hostedv1alpha1.HostedZoneSpec, relative string) bool { + if relative == "@" { + return true + } + suffix := "." + relative + for i := range spec.Records { + name := spec.Records[i].Name + if name == relative || strings.HasSuffix(name, suffix) { + return true + } + } + return false +} diff --git a/internal/go/providers/hosted/withdrawal/evaluator_test.go b/internal/go/providers/hosted/withdrawal/evaluator_test.go new file mode 100644 index 0000000..093d503 --- /dev/null +++ b/internal/go/providers/hosted/withdrawal/evaluator_test.go @@ -0,0 +1,338 @@ +package withdrawal + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "reflect" + "strings" + "testing" + "time" + + "github.com/appthrust/dns-api/internal/go/core/dnsprobe" + dnsv1alpha1 "github.com/appthrust/dns-api/pkg/go/api/dns/v1alpha1" + hostedv1alpha1 "github.com/appthrust/dns-api/pkg/go/api/hosted/v1alpha1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +type resolverFunc func(context.Context, dnsprobe.ProbeRequest) (dnsprobe.ProbeResult, error) + +func (f resolverFunc) Probe(ctx context.Context, request dnsprobe.ProbeRequest) (dnsprobe.ProbeResult, error) { + return f(ctx, request) +} + +func fixture() Input { + return Input{ + Claim: dnsv1alpha1.ZoneUnitWithdrawal{ClaimNamespace: "app", ClaimName: "retired", Claim: dnsv1alpha1.WithdrawalObjectReference{Namespace: "app", Name: "retired", UID: "claim-1"}, ObservedGeneration: 1, Name: "www", Address: "192.0.2.10", Allowed: true}, + Snapshot: hostedv1alpha1.HostedZoneSpec{DomainName: "example.com", Serial: 1, ContentDigest: "sha256:snapshot", SOA: hostedv1alpha1.SOA{Minimum: 60}}, + Served: true, Policy: dnsprobe.Policy{Mode: dnsprobe.ModeRecursive, Servers: []string{"192.0.2.53:53"}}, SOAMinimum: 60, + Now: time.Date(2026, 10, 21, 12, 0, 0, 0, time.UTC), + } +} + +func answers(request dnsprobe.ProbeRequest, address string, ttl uint32) dnsprobe.ProbeResult { + result := dnsprobe.ProbeResult{} + for _, server := range request.Policy.Servers { + view := dnsprobe.View{ID: "recursive/" + server} + for _, family := range []string{"A", "AAAA"} { + answer := dnsprobe.AnswerEvidence{Name: request.Name, QType: family, NegativeTTL: &ttl} + if family == "A" && address != "" { + answer.Addresses, answer.PositiveTTL = []string{address}, &ttl + view.A = []string{address} + } + view.Answers = append(view.Answers, answer) + } + result.Views = append(result.Views, view) + } + return result +} + +func evaluate(t *testing.T, in Input, resolver dnsprobe.Resolver) Decision { + t.Helper() + got, err := New(resolver, func() time.Time { return in.Now }).Evaluate(context.Background(), in) + if err != nil { + t.Fatal(err) + } + return got +} + +func absentResolver() dnsprobe.Resolver { + return resolverFunc(func(_ context.Context, request dnsprobe.ProbeRequest) (dnsprobe.ProbeResult, error) { + return answers(request, "", 1), nil + }) +} + +func assertDecision(t *testing.T, got Decision, status metav1.ConditionStatus, reason string) { + t.Helper() + ready := metav1.ConditionFalse + if status == metav1.ConditionTrue { + ready = metav1.ConditionTrue + } + if got.Withdrawn != status || got.Ready != ready || got.Reason != reason || got.Message == "" || len(got.Message) > 2048 { + t.Fatalf("decision=%+v, want %s/%s Ready=%s", got, status, reason, ready) + } +} + +func TestSnapshotAndEveryViewMustExclude(t *testing.T) { + for _, mode := range []string{"snapshot", "view", "not served", "absent"} { + t.Run(mode, func(t *testing.T) { + in := fixture() + in.Policy.Servers = append(in.Policy.Servers, "192.0.2.54:53") + if mode == "snapshot" { + in.Snapshot.Records = []hostedv1alpha1.HostedZoneRecord{{Name: "www", Type: "A", TTL: 600, Values: []string{in.Claim.Address}}} + } + in.Served = mode != "not served" + got := evaluate(t, in, resolverFunc(func(_ context.Context, request dnsprobe.ProbeRequest) (dnsprobe.ProbeResult, error) { + result := answers(request, "", 1) + if mode == "view" { + result.Views[1] = answers(request, in.Claim.Address, 600).Views[1] + } + return result, nil + })) + switch mode { + case "snapshot", "view": + assertDecision(t, got, metav1.ConditionFalse, "DNSStillResolves") + if got.AbsentSince != nil || got.VerifiedAt != nil || len(got.State.Positive) == 0 { + t.Fatal("positive evidence did not clear absence and retain its cache bound") + } + case "not served": + assertDecision(t, got, metav1.ConditionUnknown, "DNSProbeFailed") + default: + assertDecision(t, got, metav1.ConditionFalse, "NegativeTTLNotElapsed") + } + }) + } +} + +func TestAbsenceRequiresFreshConfirmationAfterSOAMinimum(t *testing.T) { + in := fixture() + in.Now = in.Now.Add(250 * time.Millisecond) + first := evaluate(t, in, absentResolver()) + assertDecision(t, first, metav1.ConditionFalse, "NegativeTTLNotElapsed") + horizon := in.Now.Truncate(time.Second).Add(61 * time.Second) + if first.NotBefore == nil || !first.NotBefore.Time.Equal(horizon) { + t.Fatalf("horizon=%v want %v", first.NotBefore, horizon) + } + // Public metav1.Time loses subsecond precision; private WindowStart must not. + data, err := json.Marshal(first.State) + if err != nil { + t.Fatal(err) + } + in.Previous = &State{} + if err := json.Unmarshal(data, in.Previous); err != nil { + t.Fatal(err) + } + in.Now = horizon.Add(-time.Second) + pending := evaluate(t, in, absentResolver()) + assertDecision(t, pending, metav1.ConditionFalse, "NegativeTTLNotElapsed") + if !pending.NotBefore.Equal(first.NotBefore) { + t.Fatal("repeated absence slid the horizon") + } + in.Previous, in.Now = pending.State, horizon + good := evaluate(t, in, absentResolver()) + assertDecision(t, good, metav1.ConditionTrue, "WithdrawalVerified") + if good.VerifiedAt == nil || good.VerifiedAt.Before(good.NotBefore) { + t.Fatal("confirmation precedes cache safety horizon") + } + if good.Message != fmt.Sprintf("Retired address %s was verified absent from www in every required view at %s.", in.Claim.Address, horizon.Format(time.RFC3339)) { + t.Fatalf("message=%s", good.Message) + } +} + +func TestZeroMinimumStillRequiresAnotherProbe(t *testing.T) { + in := fixture() + in.SOAMinimum = 0 + first := evaluate(t, in, absentResolver()) + in.Previous = first.State + assertDecision(t, evaluate(t, in, absentResolver()), metav1.ConditionFalse, "NegativeTTLNotElapsed") + in.Now = in.Now.Add(time.Second) + assertDecision(t, evaluate(t, in, absentResolver()), metav1.ConditionTrue, "WithdrawalVerified") +} + +func TestRetainedPositiveSurvivesFailureAndRestart(t *testing.T) { + in := fixture() + positive := evaluate(t, in, resolverFunc(func(_ context.Context, request dnsprobe.ProbeRequest) (dnsprobe.ProbeResult, error) { + return answers(request, in.Claim.Address, 3600), nil + })) + in.Previous, in.Now = positive.State, in.Now.Add(30*time.Second) + failed := evaluate(t, in, resolverFunc(func(_ context.Context, request dnsprobe.ProbeRequest) (dnsprobe.ProbeResult, error) { + return answers(request, "", 1), errors.New("private transport detail") + })) + assertDecision(t, failed, metav1.ConditionUnknown, "DNSProbeFailed") + if failed.Message != "Hosted DNS withdrawal cannot be verified for www.example.com in required-views: transport." { + t.Fatalf("message=%s", failed.Message) + } + in.Previous, in.Now = failed.State, in.Now.Add(30*time.Second) + pending := evaluate(t, in, absentResolver()) + if !pending.NotBefore.Time.Equal(fixture().Now.Add(time.Hour)) { + t.Fatalf("positive horizon capped or lost: %v", pending.NotBefore) + } +} + +func TestPolicyChangesInvalidateWindowAndPreserveFloor(t *testing.T) { + for _, change := range []string{"views", "minimum", "digest", "serial", "order"} { + t.Run(change, func(t *testing.T) { + in := fixture() + in.Policy.Servers = append(in.Policy.Servers, "192.0.2.54:53") + first := evaluate(t, in, absentResolver()) + in.Previous, in.Now = first.State, in.Now.Add(time.Minute) + switch change { + case "views": + in.Policy.Servers = in.Policy.Servers[:1] + case "minimum": + in.SOAMinimum-- + case "digest": + in.Snapshot.ContentDigest = "sha256:changed" + case "serial": + in.Snapshot.Serial++ + case "order": + in.Policy.Servers[0], in.Policy.Servers[1] = in.Policy.Servers[1], in.Policy.Servers[0] + } + got := evaluate(t, in, absentResolver()) + if change == "serial" || change == "order" { + assertDecision(t, got, metav1.ConditionTrue, "WithdrawalVerified") + return + } + assertDecision(t, got, metav1.ConditionUnknown, "PolicyChanged") + if got.Message != "Hosted DNS withdrawal policy changed for www; the prior required view policy remains a floor." || got.AbsentSince != nil { + t.Fatalf("policy decision=%+v", got) + } + in.Previous, in.Now = got.State, in.Now.Add(30*time.Second) + again := evaluate(t, in, absentResolver()) + if change == "digest" { + assertDecision(t, again, metav1.ConditionFalse, "NegativeTTLNotElapsed") + } else { + assertDecision(t, again, metav1.ConditionUnknown, "PolicyChanged") + } + }) + } +} + +func TestSnapshotCNAMEAndWildcardSemantics(t *testing.T) { + for _, mode := range []string{"chain", "loop", "foreign", "wildcard", "closer encloser", "empty nonterminal", "ipv6", "mapped ipv6"} { + t.Run(mode, func(t *testing.T) { + in := fixture() + in.Snapshot.Records = []hostedv1alpha1.HostedZoneRecord{{Name: "www", Type: "CNAME", TTL: 900, Values: []string{"target.example.com."}}, {Name: "target", Type: "A", TTL: 60, Values: []string{in.Claim.Address}}} + want := "DNSStillResolves" + switch mode { + case "loop": + in.Snapshot.Records[0].Values[0] = "www.example.com." + want = "DNSProbeFailed" + case "foreign": + in.Snapshot.Records[0].Values[0] = "other.example.net." + want = "NegativeTTLNotElapsed" + case "wildcard", "closer encloser", "empty nonterminal": + in.Claim.Name = "x.nested" + in.Snapshot.Records = []hostedv1alpha1.HostedZoneRecord{{Name: "*", Type: "A", TTL: 600, Values: []string{in.Claim.Address}}} + if mode == "closer encloser" { + in.Snapshot.Records = append(in.Snapshot.Records, hostedv1alpha1.HostedZoneRecord{Name: "nested", Type: "TXT", TTL: 60, Values: []string{"\"exists\""}}) + want = "NegativeTTLNotElapsed" + } + if mode == "empty nonterminal" { + in.Snapshot.Records = append(in.Snapshot.Records, hostedv1alpha1.HostedZoneRecord{Name: "child.x.nested", Type: "TXT", TTL: 60, Values: []string{"\"exists\""}}) + want = "NegativeTTLNotElapsed" + } + case "ipv6", "mapped ipv6": + address := "2001:db8::1" + in.Claim.Address = address + if mode == "mapped ipv6" { + in.Claim.Address = "192.0.2.10" + address = "::ffff:192.0.2.10" + } + in.Snapshot.Records[1].Type, in.Snapshot.Records[1].Values = "AAAA", []string{address} + } + got := evaluate(t, in, absentResolver()) + if excluded := SnapshotExcludes(in.Snapshot, in.Claim); excluded != (want == "NegativeTTLNotElapsed") { + t.Fatalf("snapshot helper excluded=%v, evaluator reason=%s", excluded, want) + } + status := metav1.ConditionFalse + if want == "DNSProbeFailed" { + status = metav1.ConditionUnknown + } + assertDecision(t, got, status, want) + if mode == "chain" && !got.State.Positive["www.example.com/snapshot/A"].Equal(in.Now.Add(900*time.Second)) { + t.Fatal("CNAME cache bound not retained") + } + }) + } +} + +func TestWaiverEchoDoesNotAuthorizeDNS(t *testing.T) { + in := fixture() + in.Claim.Waiver = &dnsv1alpha1.ZoneUnitWithdrawalWaiver{Name: "emergency", UID: "waiver-1", Digest: "sha256:scope", ReservationRef: dnsv1alpha1.WithdrawalObjectReference{Namespace: "infra", Name: "reservation", UID: "reservation-1"}} + first := evaluate(t, in, absentResolver()) + assertDecision(t, first, metav1.ConditionFalse, "NegativeTTLNotElapsed") + want := &dnsv1alpha1.RecordWithdrawalWaiverReference{Name: "emergency", UID: "waiver-1", Digest: "sha256:scope", ReservationRef: in.Claim.Waiver.ReservationRef} + if !reflect.DeepEqual(first.WaiverRef, want) || first.OverrideAccepted == nil || first.OverrideAccepted.Status != metav1.ConditionTrue || first.OverrideAccepted.Reason != "OperatorAuthorizedRelease" || first.OverrideAccepted.Message != "Core-validated withdrawal waiver is bound to its exact UID and immutable scope." { + t.Fatalf("waiver=%+v condition=%+v", first.WaiverRef, first.OverrideAccepted) + } + in.Previous, in.Claim.Waiver = first.State, nil + got := evaluate(t, in, absentResolver()) + if got.WaiverRef != nil || got.OverrideAccepted != nil { + t.Fatal("revoked waiver echoed") + } +} + +func TestFreshnessIdentityAndFailures(t *testing.T) { + for _, mode := range []string{"clock reversal", "stale", "claim recreated", "missing window", "missing history", "missing view", "failed probe"} { + t.Run(mode, func(t *testing.T) { + in := fixture() + first := evaluate(t, in, absentResolver()) + in.Previous, in.Now = first.State, in.Now.Add(time.Minute) + good := evaluate(t, in, absentResolver()) + in.Previous, in.Now = good.State, in.Now.Add(30*time.Second) + resolver := absentResolver() + want, status := "NegativeTTLNotElapsed", metav1.ConditionFalse + switch mode { + case "clock reversal": + in.Now = good.State.LastProbe.Add(-time.Second) + case "stale": + in.Now = good.NextProbe.Add(301 * time.Second) + case "claim recreated": + in.Claim.Claim.UID = "claim-2" + case "missing window": + in.Previous.WindowStart = time.Time{} + case "missing history": + in.Previous.MissingHistory = true + want, status = "PolicyChanged", metav1.ConditionUnknown + case "missing view": + resolver = resolverFunc(func(context.Context, dnsprobe.ProbeRequest) (dnsprobe.ProbeResult, error) { + return dnsprobe.ProbeResult{}, nil + }) + want, status = "DNSProbeFailed", metav1.ConditionUnknown + case "failed probe": + resolver = resolverFunc(func(context.Context, dnsprobe.ProbeRequest) (dnsprobe.ProbeResult, error) { + return dnsprobe.ProbeResult{}, context.DeadlineExceeded + }) + want, status = "DNSProbeFailed", metav1.ConditionUnknown + } + got := evaluate(t, in, resolver) + assertDecision(t, got, status, want) + if got.VerifiedAt != nil { + t.Fatal("stale success survived") + } + }) + } +} + +func TestWildcardWitnessAndPreviousStateRemainStable(t *testing.T) { + in := fixture() + in.Claim.Name = "*.project" + first := evaluate(t, in, absentResolver()) + if len(first.CheckedNames) != 1 || !strings.HasPrefix(first.CheckedNames[0], "dns-retire-") || !strings.HasSuffix(first.CheckedNames[0], ".project.example.com") { + t.Fatalf("names=%v", first.CheckedNames) + } + before, _ := json.Marshal(first.State) + in.Previous, in.Now = first.State, in.Now.Add(time.Minute) + evaluate(t, in, absentResolver()) + after, _ := json.Marshal(first.State) + if string(before) != string(after) { + t.Fatal("evaluation mutated previous state") + } + in.Claim.Claim.UID = "new-claim" + recreated := evaluate(t, in, absentResolver()) + if reflect.DeepEqual(recreated.CheckedNames, first.CheckedNames) { + t.Fatal("new claim reused old wildcard witness") + } +} diff --git a/internal/go/providers/hosted/withdrawal/metrics.go b/internal/go/providers/hosted/withdrawal/metrics.go new file mode 100644 index 0000000..86e00dc --- /dev/null +++ b/internal/go/providers/hosted/withdrawal/metrics.go @@ -0,0 +1,42 @@ +package withdrawal + +import ( + "sync" + + "github.com/prometheus/client_golang/prometheus" + "sigs.k8s.io/controller-runtime/pkg/metrics" +) + +var ( + pending = prometheus.NewGaugeVec(prometheus.GaugeOpts{Name: "dns_hosted_withdrawals_pending", Help: "Retained hosted DNS withdrawals awaiting verified exclusion."}, []string{"reason"}) + pendingMu sync.Mutex + pendingUnits = map[string]map[string]int{} +) + +func init() { metrics.Registry.MustRegister(pending) } + +// SetPending replaces one unit's contribution. Unit identities are internal +// keys, never labels. Passing nil removes the unit's contribution. +func SetPending(unitKey string, counts map[string]int) { + pendingMu.Lock() + defer pendingMu.Unlock() + for reason, count := range pendingUnits[unitKey] { + pending.WithLabelValues(reason).Sub(float64(count)) + } + delete(pendingUnits, unitKey) + next := map[string]int{} + for reason, count := range counts { + switch reason { + case "DNSStillResolves", "NegativeTTLNotElapsed", "DNSProbeFailed", "PolicyChanged", "InvalidWithdrawal", "NotAllowed", "UnsupportedProvider": + default: + reason = "DNSProbeFailed" + } + next[reason] += count + } + if len(next) != 0 { + pendingUnits[unitKey] = next + } + for reason, count := range next { + pending.WithLabelValues(reason).Add(float64(count)) + } +} diff --git a/internal/go/providers/hosted/withdrawal/metrics_test.go b/internal/go/providers/hosted/withdrawal/metrics_test.go new file mode 100644 index 0000000..ac14b24 --- /dev/null +++ b/internal/go/providers/hosted/withdrawal/metrics_test.go @@ -0,0 +1,62 @@ +package withdrawal + +import ( + "testing" + + "github.com/prometheus/client_golang/prometheus" +) + +func TestPendingMetricsReplaceAggregateAndClear(t *testing.T) { + const first, second = "app/first", "app/second" + t.Cleanup(func() { + SetPending(first, nil) + SetPending(second, nil) + }) + registry := prometheus.NewRegistry() + registry.MustRegister(pending) + values := func() map[string]float64 { + t.Helper() + families, err := registry.Gather() + if err != nil { + t.Fatal(err) + } + result := map[string]float64{} + for _, family := range families { + if family.GetName() != "dns_hosted_withdrawals_pending" { + t.Fatalf("unexpected metric %q", family.GetName()) + } + for _, metric := range family.Metric { + if len(metric.Label) != 1 || metric.Label[0].GetName() != "reason" { + t.Fatalf("unbounded labels: %v", metric.Label) + } + result[metric.Label[0].GetValue()] = metric.GetGauge().GetValue() + } + } + return result + } + counts := map[string]int{"DNSStillResolves": 2, "private/unbounded": 1} + SetPending(first, counts) + counts["DNSStillResolves"] = 99 // Caller-owned input must not mutate contributions. + SetPending(second, map[string]int{"DNSStillResolves": 3}) + got := values() + if got["DNSStillResolves"] != 5 || got["DNSProbeFailed"] != 1 || len(got) != 2 { + t.Fatalf("aggregate=%v", got) + } + SetPending(first, map[string]int{"NegativeTTLNotElapsed": 1}) + got = values() + if got["DNSStillResolves"] != 3 || got["DNSProbeFailed"] != 0 || got["NegativeTTLNotElapsed"] != 1 { + t.Fatalf("replacement=%v", got) + } + SetPending(first, nil) + SetPending(second, nil) + for reason, value := range values() { + if value != 0 { + t.Fatalf("cleared %s=%v", reason, value) + } + } + pendingMu.Lock() + defer pendingMu.Unlock() + if len(pendingUnits) != 0 { + t.Fatalf("unit contribution leaked: %v", pendingUnits) + } +} diff --git a/internal/go/providers/hosted/withdrawal/state.go b/internal/go/providers/hosted/withdrawal/state.go new file mode 100644 index 0000000..8f3af27 --- /dev/null +++ b/internal/go/providers/hosted/withdrawal/state.go @@ -0,0 +1,157 @@ +package withdrawal + +import ( + "maps" + "slices" + "strings" + "time" + + "github.com/appthrust/dns-api/internal/go/core/dnsprobe" + dnsv1alpha1 "github.com/appthrust/dns-api/pkg/go/api/dns/v1alpha1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +// State is private, identity-bound evidence persisted by the ZoneUnit controller. +// Positive expiry bounds and the required-view floor survive failed observations. +// WindowStart uses native time.Time to preserve subsecond precision in JSON. +// A controller applying scheduling jitter must persist the same NextProbe here. +// An empty Identity permits a positive-only publication seed supplied by the +// controller for this exact claim address/name. It never restores a window. +type State struct { + Identity string `json:"identity"` + Policy string `json:"policy"` + Views []string `json:"views,omitempty"` + SOAMinimum uint32 `json:"soaMinimum"` + CheckedNames []string `json:"checkedNames"` + Positive map[string]time.Time `json:"positive,omitempty"` + Negative map[string]uint32 `json:"negative,omitempty"` + WindowStart time.Time `json:"windowStart,omitempty"` + AbsentSince *metav1.Time `json:"absentSince,omitempty"` + NotBefore *metav1.Time `json:"notBefore,omitempty"` + VerifiedAt *metav1.Time `json:"verifiedAt,omitempty"` + LastProbe time.Time `json:"lastProbe,omitempty"` + NextProbe time.Time `json:"nextProbe,omitempty"` + Failures int `json:"failures,omitempty"` + MissingHistory bool `json:"missingHistory,omitempty"` +} + +func initialState(in Input) *State { + identity := claimIdentity(&in) + if in.Previous != nil && in.Previous.Identity == identity { + state := *in.Previous + state.Views = slices.Clone(state.Views) + state.CheckedNames = slices.Clone(state.CheckedNames) + state.Positive = maps.Clone(state.Positive) + state.Negative = maps.Clone(state.Negative) + state.AbsentSince = state.AbsentSince.DeepCopy() + state.NotBefore = state.NotBefore.DeepCopy() + state.VerifiedAt = state.VerifiedAt.DeepCopy() + state.MissingHistory = state.MissingHistory || len(state.CheckedNames) == 0 || (!state.LastProbe.IsZero() && state.Policy == "") + return &state + } + state := &State{Identity: identity, CheckedNames: []string{checkedName(&in, identity)}} + // An explicit missing-history marker must not be repaired by inventing a + // fresh floor, even when the controller cannot recover the old identity. + if in.Previous != nil { + state.MissingHistory = in.Previous.MissingHistory + if in.Previous.Identity == "" { + state.Positive = maps.Clone(in.Previous.Positive) + } + } + return state +} + +func claimIdentity(in *Input) string { + return dnsprobe.Fingerprint(struct { + Zone string + Claim dnsv1alpha1.WithdrawalObjectReference + Generation int64 + Name, Address string + }{in.Snapshot.DomainName, in.Claim.Claim, in.Claim.ObservedGeneration, in.Claim.Name, in.Claim.Address}) +} + +func checkedName(in *Input, identity string) string { + name := in.Snapshot.DomainName + if in.Claim.Name != "@" { + name = in.Claim.Name + "." + name + } + if in.Claim.Name == "*" || strings.HasPrefix(in.Claim.Name, "*.") { + name = "dns-retire-" + identity[:24] + strings.TrimPrefix(name, "*") + } + return name +} + +func (s *State) invalidate() { + s.AbsentSince, s.NotBefore, s.VerifiedAt = nil, nil, nil + s.WindowStart = time.Time{} + s.Negative = nil +} + +func (s *State) retainPositive(name string, evidence []dnsprobe.WithdrawalCacheEvidence, now time.Time) { + for _, item := range evidence { + if item.PositiveTTL == nil { + continue + } + if s.Positive == nil { + s.Positive = map[string]time.Time{} + } + key := name + "/" + item.View + "/" + item.QType + expires := now.Add(time.Duration(*item.PositiveTTL) * time.Second) + if expires.After(s.Positive[key]) { + s.Positive[key] = expires + } + } +} + +func (s *State) window(minimum uint32, now time.Time) dnsprobe.Decision { + if s.AbsentSince != nil && s.WindowStart.IsZero() { + s.invalidate() + } + confirming := s.AbsentSince != nil && !s.LastProbe.IsZero() && now.After(s.LastProbe) + if s.AbsentSince == nil { + s.AbsentSince = timePointer(now) + s.WindowStart = now + } + if s.Negative == nil { + s.Negative = map[string]uint32{} + } + s.Negative["soa.minimum"] = max(s.Negative["soa.minimum"], minimum) + notBefore := s.WindowStart.Add(time.Duration(s.Negative["soa.minimum"]) * time.Second) + if s.NotBefore != nil && s.NotBefore.After(notBefore) { + notBefore = s.NotBefore.Time + } + for _, expires := range s.Positive { + if expires.After(notBefore) { + notBefore = expires + } + } + // Public horizons must never round down during a Kubernetes status round-trip. + if notBefore.Nanosecond() != 0 { + notBefore = notBefore.Truncate(time.Second).Add(time.Second) + } + s.NotBefore = timePointer(notBefore) + if !confirming || now.Before(notBefore) { + s.VerifiedAt = nil + return dnsprobe.Decision{Status: metav1.ConditionFalse, Reason: "NegativeTTLNotElapsed"} + } + s.VerifiedAt = timePointer(now) + return dnsprobe.Decision{Status: metav1.ConditionTrue, Reason: "WithdrawalVerified"} +} + +func (s *State) schedule(status metav1.ConditionStatus, changed bool, now time.Time) { + interval := 30 * time.Second + if changed || status != metav1.ConditionUnknown { + s.Failures = 0 + } + if status == metav1.ConditionUnknown { + steps := [...]time.Duration{30 * time.Second, 60 * time.Second, 120 * time.Second, 300 * time.Second} + s.Failures = min(s.Failures+1, len(steps)) + interval = steps[max(s.Failures-1, 0)] + } + s.LastProbe, s.NextProbe = now, now.Add(interval) +} + +func timePointer(value time.Time) *metav1.Time { + t := metav1.NewTime(value) + return &t +} diff --git a/internal/go/providers/hosted/withdrawal/state_test.go b/internal/go/providers/hosted/withdrawal/state_test.go new file mode 100644 index 0000000..d8e3098 --- /dev/null +++ b/internal/go/providers/hosted/withdrawal/state_test.go @@ -0,0 +1,105 @@ +package withdrawal + +import ( + "context" + "errors" + "testing" + "time" + + "github.com/appthrust/dns-api/internal/go/core/dnsprobe" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +func TestPublicationSeedRetainsOnlyPositiveBounds(t *testing.T) { + in := fixture() + expires := in.Now.Add(time.Hour) + in.Previous = &State{ + Positive: map[string]time.Time{"www.example.com/publication/A": expires}, + Policy: "unrelated-policy", Views: []string{"unrelated-view"}, + AbsentSince: timePointer(in.Now.Add(-time.Hour)), WindowStart: in.Now.Add(-time.Hour), + VerifiedAt: timePointer(in.Now), NotBefore: timePointer(in.Now), + } + got := evaluate(t, in, absentResolver()) + assertDecision(t, got, metav1.ConditionFalse, "NegativeTTLNotElapsed") + if !got.NotBefore.Time.Equal(expires) || !got.AbsentSince.Time.Equal(in.Now) || got.VerifiedAt != nil { + t.Fatalf("seed reused window or lost positive: %+v", got) + } + got.State.Positive["www.example.com/publication/A"] = in.Now + if !in.Previous.Positive["www.example.com/publication/A"].Equal(expires) { + t.Fatal("seed map aliases caller state") + } + in.Previous.Identity = "another-claim" + got = evaluate(t, in, absentResolver()) + if !got.NotBefore.Time.Equal(in.Now.Add(time.Minute)) { + t.Fatal("mismatched claim inherited positive seed") + } +} + +func TestUnavailableBackoffAndCompletedReset(t *testing.T) { + in := fixture() + resolver := resolverFunc(func(context.Context, dnsprobe.ProbeRequest) (dnsprobe.ProbeResult, error) { + return dnsprobe.ProbeResult{}, errors.New("unavailable") + }) + for _, seconds := range []int{30, 60, 120, 300, 300} { + got := evaluate(t, in, resolver) + assertDecision(t, got, metav1.ConditionUnknown, "DNSProbeFailed") + if got.NextProbe.Sub(in.Now) != time.Duration(seconds)*time.Second { + t.Fatalf("next=%s want %ds", got.NextProbe.Sub(in.Now), seconds) + } + in.Previous, in.Now = got.State, got.NextProbe + } + got := evaluate(t, in, absentResolver()) + if got.State.Failures != 0 || got.NextProbe.Sub(in.Now) != 30*time.Second { + t.Fatal("completed observation did not reset backoff") + } +} + +func TestLateProbeCannotReuseWindow(t *testing.T) { + for _, elapsed := range []time.Duration{90 * time.Second, 301 * time.Second, -time.Second} { + t.Run(elapsed.String(), func(t *testing.T) { + in := fixture() + first := evaluate(t, in, absentResolver()) + in.Previous = first.State + in.Now = first.NextProbe.Add(250 * time.Second) + clock := in.Now + resolver := resolverFunc(func(_ context.Context, request dnsprobe.ProbeRequest) (dnsprobe.ProbeResult, error) { + clock = clock.Add(elapsed) + return answers(request, "", 1), nil + }) + got, err := New(resolver, func() time.Time { return clock }).Evaluate(context.Background(), in) + if err != nil { + t.Fatal(err) + } + if elapsed == 90*time.Second { + assertDecision(t, got, metav1.ConditionFalse, "NegativeTTLNotElapsed") + if !got.AbsentSince.Time.Equal(clock) { + t.Fatal("stale window reused by delayed observation") + } + } else { + assertDecision(t, got, metav1.ConditionUnknown, "DNSProbeFailed") + } + }) + } +} + +func TestMissingCheckedNamesCannotProveVacuousAbsence(t *testing.T) { + in := fixture() + first := evaluate(t, in, absentResolver()) + in.Previous, in.Now = first.State, in.Now.Add(time.Minute) + in.Previous.CheckedNames = nil + got := evaluate(t, in, absentResolver()) + assertDecision(t, got, metav1.ConditionUnknown, "PolicyChanged") +} + +func TestDeniedClaimDoesNotProbeOrEchoWaiver(t *testing.T) { + in := fixture() + in.Claim.Allowed = false + got := evaluate(t, in, resolverFunc(func(context.Context, dnsprobe.ProbeRequest) (dnsprobe.ProbeResult, error) { + t.Fatal("denied claim was probed") + return dnsprobe.ProbeResult{}, nil + })) + assertDecision(t, got, metav1.ConditionUnknown, "DNSProbeFailed") + if got.WaiverRef != nil || got.OverrideAccepted != nil { + t.Fatal("denied claim received override") + } +} diff --git a/pkg/go/api/hosted/v1alpha1/doc.go b/pkg/go/api/hosted/v1alpha1/doc.go new file mode 100644 index 0000000..3c52f3c --- /dev/null +++ b/pkg/go/api/hosted/v1alpha1/doc.go @@ -0,0 +1,6 @@ +// Package v1alpha1 contains the hosted provider's inline class parameters and +// HostedZone serving snapshots. The controller owns snapshot specs; each server +// owns only its status.servers entry, keyed by its namespace/name identity. +// +kubebuilder:object:generate=true +// +groupName=hosted.dns.appthrust.io +package v1alpha1 diff --git a/pkg/go/api/hosted/v1alpha1/register.go b/pkg/go/api/hosted/v1alpha1/register.go new file mode 100644 index 0000000..7f7f48d --- /dev/null +++ b/pkg/go/api/hosted/v1alpha1/register.go @@ -0,0 +1,19 @@ +package v1alpha1 + +import ( + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" +) + +const GroupName = ProviderName + +var SchemeGroupVersion = schema.GroupVersion{Group: GroupName, Version: ProviderVersion} +var SchemeBuilder = runtime.NewSchemeBuilder(addKnownTypes) +var AddToScheme = SchemeBuilder.AddToScheme + +func addKnownTypes(scheme *runtime.Scheme) error { + scheme.AddKnownTypes(SchemeGroupVersion, &HostedZone{}, &HostedZoneList{}) + metav1.AddToGroupVersion(scheme, SchemeGroupVersion) + return nil +} diff --git a/pkg/go/api/hosted/v1alpha1/types.go b/pkg/go/api/hosted/v1alpha1/types.go new file mode 100644 index 0000000..8127d29 --- /dev/null +++ b/pkg/go/api/hosted/v1alpha1/types.go @@ -0,0 +1,200 @@ +package v1alpha1 + +import ( + dnsv1alpha1 "github.com/appthrust/dns-api/pkg/go/api/dns/v1alpha1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +const ( + ProviderName = "hosted.dns.appthrust.io" + ProviderVersion = "v1alpha1" + ControllerName = ProviderName + "/controller" + + ReasonServingPending = "ServingPending" + ReasonSnapshotRejected = "SnapshotRejected" + ReasonDNSStillResolves = "DNSStillResolves" + ReasonNegativeTTLNotElapsed = "NegativeTTLNotElapsed" + ReasonDNSProbeFailed = "DNSProbeFailed" + ReasonPolicyChanged = "PolicyChanged" + ReasonWithdrawalVerified = "WithdrawalVerified" + ReasonOperatorAuthorizedRelease = "OperatorAuthorizedRelease" + + DefaultTTL uint32 = 60 + DefaultSOARefresh uint32 = 3600 + DefaultSOARetry uint32 = 900 + DefaultSOAExpire uint32 = 1209600 + DefaultSOAMinimum uint32 = 300 +) + +// HostedZoneClassParameters is an inline payload, not a Kubernetes resource. +// The Provider declaration validates it. Omitted numeric fields use the defaults +// above; an omitted SOA RName is rendered as hostmaster.. +type HostedZoneClassParameters struct { + NameServers []NameServer `json:"nameServers"` + SOA SOAParameters `json:"soa,omitempty"` + DefaultTTL uint32 `json:"defaultTTL,omitempty"` + Verification VerificationPolicy `json:"verification,omitempty"` +} + +// NameServer declares an authoritative name and its canonical IP glue addresses. +// +kubebuilder:validation:XValidation:rule="!isIP(self.name)",message="name must be a DNS name, not an IP address" +// +kubebuilder:validation:XValidation:rule="self.addresses.all(a, isIP(a) && ip.isCanonical(a))",message="addresses must contain canonical IP addresses" +type NameServer struct { + // +kubebuilder:validation:MinLength=1 + // +kubebuilder:validation:MaxLength=253 + // +kubebuilder:validation:Pattern=`^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?(\.[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?)*$` + Name string `json:"name"` + + // +kubebuilder:validation:MinItems=1 + // +kubebuilder:validation:MaxItems=8 + // +kubebuilder:validation:items:MaxLength=45 + Addresses []string `json:"addresses"` +} + +// SOAParameters is the class-level SOA policy; MName is nameServers[0].name. +type SOAParameters struct { + RName string `json:"rname,omitempty"` + Refresh uint32 `json:"refresh,omitempty"` + Retry uint32 `json:"retry,omitempty"` + Expire uint32 `json:"expire,omitempty"` + Minimum uint32 `json:"minimum,omitempty"` +} + +// VerificationPolicy selects independent canonical IP:port endpoints. Empty +// Views selects the hosted server Service; delegation uses its resolver policy. +type VerificationPolicy struct { + Views []string `json:"views,omitempty"` + Delegation DelegationPolicy `json:"delegation,omitempty"` +} + +// DelegationPolicy selects the resolver view for parent NS observation. +type DelegationPolicy struct { + Resolver ResolverPolicy `json:"resolver,omitempty"` +} + +// ResolverPolicy uses pod DNS in Cluster mode (the default). Recursive mode +// requires one to three canonical IP:port servers; Cluster leaves servers empty. +type ResolverPolicy struct { + Mode string `json:"mode,omitempty"` + Servers []string `json:"servers,omitempty"` +} + +// HostedZone is the complete authoritative serving snapshot of one ZoneUnit. +// +kubebuilder:object:root=true +// +kubebuilder:resource:scope=Namespaced +// +kubebuilder:subresource:status +// +kubebuilder:printcolumn:name="Domain",type=string,JSONPath=`.spec.domainName` +// +kubebuilder:printcolumn:name="Serial",type=integer,JSONPath=`.spec.serial` +// +kubebuilder:printcolumn:name="Servers",type=string,JSONPath=`.status.servers[*].endpoint` +type HostedZone struct { + metav1.TypeMeta `json:",inline"` + metav1.ObjectMeta `json:"metadata,omitempty"` + + Spec HostedZoneSpec `json:"spec"` + Status HostedZoneStatus `json:"status,omitempty"` +} + +// HostedZoneSpec is written only by the hosted controller. +// +kubebuilder:validation:XValidation:rule="self.serial > 0",message="serial must be greater than zero" +type HostedZoneSpec struct { + // +kubebuilder:validation:MinLength=1 + // +kubebuilder:validation:MaxLength=253 + // +kubebuilder:validation:Pattern=`^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?(\.[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?)*$` + DomainName string `json:"domainName"` + + // Serial increases on each content change. + // +kubebuilder:validation:Minimum=1 + // +kubebuilder:validation:Maximum=4294967295 + Serial uint32 `json:"serial"` + + SOA SOA `json:"soa"` + + // +kubebuilder:validation:MinItems=1 + // +kubebuilder:validation:MaxItems=8 + NameServers []NameServer `json:"nameServers"` + + // Records includes apex NS and glue as well as accepted record claims. + // +kubebuilder:validation:MaxItems=10000 + // +listType=map + // +listMapKey=name + // +listMapKey=type + Records []HostedZoneRecord `json:"records"` + + // +kubebuilder:validation:Pattern=`^sha256:[0-9a-f]{64}$` + ContentDigest string `json:"contentDigest"` +} + +// SOA is fully rendered and contains no implicit defaults. +type SOA struct { + // +kubebuilder:validation:MinLength=1 + // +kubebuilder:validation:MaxLength=253 + // +kubebuilder:validation:Pattern=`^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?(\.[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?)*$` + MName string `json:"mname"` + + // +kubebuilder:validation:MinLength=1 + // +kubebuilder:validation:MaxLength=253 + // +kubebuilder:validation:Pattern=`^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?(\.[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?)*$` + RName string `json:"rname"` + + // +kubebuilder:validation:Minimum=1 + // +kubebuilder:validation:Maximum=2419200 + Refresh uint32 `json:"refresh"` + // +kubebuilder:validation:Minimum=1 + // +kubebuilder:validation:Maximum=2419200 + Retry uint32 `json:"retry"` + // +kubebuilder:validation:Minimum=1 + // +kubebuilder:validation:Maximum=2419200 + Expire uint32 `json:"expire"` + // +kubebuilder:validation:Minimum=1 + // +kubebuilder:validation:Maximum=2419200 + Minimum uint32 `json:"minimum"` +} + +// HostedZoneRecord is one complete canonical presentation-format RRset. +type HostedZoneRecord struct { + // Name is @ or a relative name; a wildcard is allowed only as the first label. + // +kubebuilder:validation:MinLength=1 + // +kubebuilder:validation:MaxLength=253 + // +kubebuilder:validation:Pattern=`^(@|\*|((\*\.)?[a-z0-9_]([a-z0-9_-]{0,61}[a-z0-9_])?(\.[a-z0-9_]([a-z0-9_-]{0,61}[a-z0-9_])?)*))$` + Name string `json:"name"` + + // +kubebuilder:validation:Enum=A;AAAA;CNAME;TXT;MX;CAA;NS + Type dnsv1alpha1.RecordType `json:"type"` + + // +kubebuilder:validation:Minimum=1 + // +kubebuilder:validation:Maximum=2147483647 + TTL uint32 `json:"ttl"` + + // +kubebuilder:validation:MinItems=1 + // +kubebuilder:validation:MaxItems=64 + Values []string `json:"values"` +} + +type HostedZoneStatus struct { + // Servers is independently applied by servers using hosted-server/. + // +optional + // +listType=map + // +listMapKey=endpoint + Servers []HostedZoneServerStatus `json:"servers,omitempty"` +} + +// HostedZoneServerStatus acknowledges the exact content loaded by one server. +type HostedZoneServerStatus struct { + // Endpoint is the server pod identity, namespace/name, not an IP endpoint. + // +kubebuilder:validation:MinLength=1 + Endpoint string `json:"endpoint"` + // +kubebuilder:validation:Minimum=1 + // +kubebuilder:validation:Maximum=4294967295 + Serial uint32 `json:"serial"` + // +kubebuilder:validation:Pattern=`^sha256:[0-9a-f]{64}$` + ContentDigest string `json:"contentDigest"` + ObservedAt metav1.Time `json:"observedAt"` +} + +// HostedZoneList contains a list of HostedZone snapshots. +// +kubebuilder:object:root=true +type HostedZoneList struct { + metav1.TypeMeta `json:",inline"` + metav1.ListMeta `json:"metadata,omitempty"` + Items []HostedZone `json:"items"` +} diff --git a/pkg/go/api/hosted/v1alpha1/types_test.go b/pkg/go/api/hosted/v1alpha1/types_test.go new file mode 100644 index 0000000..72278bb --- /dev/null +++ b/pkg/go/api/hosted/v1alpha1/types_test.go @@ -0,0 +1,226 @@ +package v1alpha1 + +import ( + "context" + "encoding/json" + "os" + "strconv" + "strings" + "testing" + + apiextensions "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions" + apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" + structuralschema "k8s.io/apiextensions-apiserver/pkg/apiserver/schema" + structuralcel "k8s.io/apiextensions-apiserver/pkg/apiserver/schema/cel" + apiservervalidation "k8s.io/apiextensions-apiserver/pkg/apiserver/validation" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + k8sjson "k8s.io/apimachinery/pkg/util/json" + "k8s.io/apimachinery/pkg/util/validation/field" + "sigs.k8s.io/yaml" +) + +func TestSchemeAndDeepcopy(t *testing.T) { + scheme := runtime.NewScheme() + if err := AddToScheme(scheme); err != nil { + t.Fatal(err) + } + for _, kind := range []string{"HostedZone", "HostedZoneList"} { + if !scheme.Recognizes(SchemeGroupVersion.WithKind(kind)) { + t.Fatalf("missing CRD kind %s", kind) + } + } + for _, kind := range []string{"HostedIdentity", "HostedRecordSet", "HostedZoneClassParameters"} { + if scheme.Recognizes(SchemeGroupVersion.WithKind(kind)) { + t.Fatalf("unexpected CRD kind %s", kind) + } + } + params := &HostedZoneClassParameters{ + NameServers: []NameServer{{Name: "ns.example.test", Addresses: []string{"192.0.2.53"}}}, + Verification: VerificationPolicy{ + Views: []string{"192.0.2.53:53"}, + Delegation: DelegationPolicy{Resolver: ResolverPolicy{Mode: "Recursive", Servers: []string{"[2001:db8::53]:53"}}}, + }, + } + copy := params.DeepCopy() + copy.NameServers[0].Addresses[0] = "192.0.2.54" + copy.Verification.Views[0] = "192.0.2.54:53" + copy.Verification.Delegation.Resolver.Mode = "Cluster" + copy.Verification.Delegation.Resolver.Servers[0] = "[2001:db8::54]:53" + if params.NameServers[0].Addresses[0] != "192.0.2.53" || params.Verification.Views[0] != "192.0.2.53:53" || params.Verification.Delegation.Resolver.Mode != "Recursive" || params.Verification.Delegation.Resolver.Servers[0] != "[2001:db8::53]:53" { + t.Fatal("parameter deepcopy aliases source") + } + zone := &HostedZone{ + ObjectMeta: metav1.ObjectMeta{Labels: map[string]string{"owner": "controller"}}, + Spec: HostedZoneSpec{ + NameServers: params.NameServers, + Records: []HostedZoneRecord{{Name: "@", Type: "A", TTL: 60, Values: []string{"192.0.2.1"}}}, + }, + Status: HostedZoneStatus{Servers: []HostedZoneServerStatus{{Endpoint: "dns/server", Serial: 1}}}, + } + zoneCopy := zone.DeepCopyObject().(*HostedZone) + zoneCopy.Labels["owner"] = "other" + zoneCopy.Spec.NameServers[0].Addresses[0] = "192.0.2.54" + zoneCopy.Spec.Records[0].Values[0] = "192.0.2.2" + zoneCopy.Status.Servers[0].Serial = 2 + if zone.Labels["owner"] != "controller" || zone.Spec.NameServers[0].Addresses[0] != "192.0.2.53" || zone.Spec.Records[0].Values[0] != "192.0.2.1" || zone.Status.Servers[0].Serial != 1 { + t.Fatal("snapshot deepcopy aliases source") + } + list := &HostedZoneList{Items: []HostedZone{*zone}} + listCopy := list.DeepCopyObject().(*HostedZoneList) + listCopy.Items[0].Spec.Records[0].Values[0] = "192.0.2.3" + if list.Items[0].Spec.Records[0].Values[0] != "192.0.2.1" { + t.Fatal("list deepcopy aliases source") + } +} + +func TestSnapshotJSONContract(t *testing.T) { + const input = `{"spec":{"domainName":"example.test","serial":4294967295,"soa":{"mname":"ns.example.test","rname":"hostmaster.example.test","refresh":3600,"retry":900,"expire":1209600,"minimum":300},"nameServers":[{"name":"ns.example.test","addresses":["192.0.2.53"]}],"records":[{"name":"@","type":"A","ttl":60,"values":["192.0.2.1"]}],"contentDigest":"sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"},"status":{"servers":[{"endpoint":"dns/server","serial":4294967295,"contentDigest":"sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","observedAt":"2026-10-21T00:00:00Z"}]}}` + var zone HostedZone + if err := json.Unmarshal([]byte(input), &zone); err != nil { + t.Fatal(err) + } + if zone.Spec.DomainName != "example.test" || zone.Spec.Serial != ^uint32(0) || zone.Spec.SOA.MName != "ns.example.test" || zone.Spec.SOA.RName != "hostmaster.example.test" || zone.Spec.SOA.Minimum != 300 || zone.Spec.Records[0].TTL != 60 || zone.Status.Servers[0].Serial != zone.Spec.Serial || zone.Status.Servers[0].ObservedAt.IsZero() { + t.Fatalf("snapshot JSON fields were lost: %#v", zone) + } +} + +func TestGeneratedSnapshotSchema(t *testing.T) { + data, err := os.ReadFile("../../../../../app/operator/config/crd/bases/hosted.dns.appthrust.io_hostedzones.yaml") + if err != nil { + t.Fatal(err) + } + var crd apiextensionsv1.CustomResourceDefinition + if err := yaml.UnmarshalStrict(data, &crd); err != nil { + t.Fatal(err) + } + if crd.Spec.Group != GroupName || crd.Spec.Scope != apiextensionsv1.NamespaceScoped || len(crd.Spec.Versions) != 1 || crd.Spec.Versions[0].Name != ProviderVersion || crd.Spec.Versions[0].Subresources.Status == nil { + t.Fatalf("unexpected CRD contract: %#v", crd.Spec) + } + columns := crd.Spec.Versions[0].AdditionalPrinterColumns + if len(columns) != 3 || columns[0].Name != "Domain" || columns[1].Name != "Serial" || columns[2].Name != "Servers" { + t.Fatalf("unexpected printer columns: %#v", columns) + } + var converted apiextensions.JSONSchemaProps + if err := apiextensionsv1.Convert_v1_JSONSchemaProps_To_apiextensions_JSONSchemaProps(crd.Spec.Versions[0].Schema.OpenAPIV3Schema, &converted, nil); err != nil { + t.Fatal(err) + } + schema := &converted + spec := schema.Properties["spec"] + for _, entry := range []struct { + schema apiextensions.JSONSchemaProps + keys []string + }{{spec.Properties["records"], []string{"name", "type"}}, {schema.Properties["status"].Properties["servers"], []string{"endpoint"}}} { + if entry.schema.XListType == nil || *entry.schema.XListType != "map" || strings.Join(entry.schema.XListMapKeys, ",") != strings.Join(entry.keys, ",") { + t.Fatalf("map-list contract differs: %#v", entry.schema) + } + } + validator, _, err := apiservervalidation.NewSchemaValidator(schema) + if err != nil { + t.Fatal(err) + } + structural, err := structuralschema.NewStructural(schema) + if err != nil { + t.Fatal(err) + } + celValidator := structuralcel.NewValidator(structural, true, 1000000) + if celValidator == nil { + t.Fatal("snapshot CEL validations are missing") + } + cases := []struct { + name string + mutate func(*HostedZone) + valid bool + }{ + {"valid", func(*HostedZone) {}, true}, + {"max serial", func(z *HostedZone) { z.Spec.Serial = ^uint32(0) }, true}, + {"zero serial", func(z *HostedZone) { z.Spec.Serial = 0 }, false}, + {"uppercase domain", func(z *HostedZone) { z.Spec.DomainName = "Example.test" }, false}, + {"root-dot domain", func(z *HostedZone) { z.Spec.DomainName = "example.test." }, false}, + {"bad digest", func(z *HostedZone) { z.Spec.ContentDigest = "sha256:bad" }, false}, + {"uppercase digest", func(z *HostedZone) { z.Spec.ContentDigest = "sha256:" + strings.Repeat("A", 64) }, false}, + {"missing nameservers", func(z *HostedZone) { z.Spec.NameServers = []NameServer{} }, false}, + {"invalid glue", func(z *HostedZone) { z.Spec.NameServers[0].Addresses[0] = "999.0.2.53" }, false}, + {"noncanonical glue", func(z *HostedZone) { z.Spec.NameServers[0].Addresses[0] = "2001:DB8::53" }, false}, + {"IP nameserver", func(z *HostedZone) { z.Spec.NameServers[0].Name = "192.0.2.53" }, false}, + {"wildcard", func(z *HostedZone) { z.Spec.Records[0].Name = "*.team" }, true}, + {"apex wildcard", func(z *HostedZone) { z.Spec.Records[0].Name = "*" }, true}, + {"TXT underscore", func(z *HostedZone) { z.Spec.Records[0].Name = "_acme-challenge"; z.Spec.Records[0].Type = "TXT" }, true}, + {"relative", func(z *HostedZone) { z.Spec.Records[0].Name = "www.team" }, true}, + {"nonleftmost wildcard", func(z *HostedZone) { z.Spec.Records[0].Name = "team.*" }, false}, + {"partial wildcard", func(z *HostedZone) { z.Spec.Records[0].Name = "*team" }, false}, + {"root-dot owner", func(z *HostedZone) { z.Spec.Records[0].Name = "www." }, false}, + {"uppercase owner", func(z *HostedZone) { z.Spec.Records[0].Name = "WWW" }, false}, + {"unsupported type", func(z *HostedZone) { z.Spec.Records[0].Type = "PTR" }, false}, + {"zero TTL", func(z *HostedZone) { z.Spec.Records[0].TTL = 0 }, false}, + {"empty values", func(z *HostedZone) { z.Spec.Records[0].Values = []string{} }, false}, + {"64 values", func(z *HostedZone) { z.Spec.Records[0].Values = make([]string, 64) }, true}, + {"65 values", func(z *HostedZone) { z.Spec.Records[0].Values = make([]string, 65) }, false}, + {"10000 records", func(z *HostedZone) { z.Spec.Records = snapshotRecords(10000) }, true}, + {"too many records", func(z *HostedZone) { z.Spec.Records = snapshotRecords(10001) }, false}, + {"zero refresh", func(z *HostedZone) { z.Spec.SOA.Refresh = 0 }, false}, + {"SOA overflow", func(z *HostedZone) { z.Spec.SOA.Minimum = 2419201 }, false}, + {"status digest", func(z *HostedZone) { z.Status.Servers[0].ContentDigest = "bad" }, false}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + zone := &HostedZone{ + TypeMeta: metav1.TypeMeta{APIVersion: SchemeGroupVersion.String(), Kind: "HostedZone"}, + ObjectMeta: metav1.ObjectMeta{Name: "example", Namespace: "dns"}, + Spec: HostedZoneSpec{ + DomainName: "example.test", Serial: 1, + SOA: SOA{MName: "ns.example.test", RName: "hostmaster.example.test", Refresh: 3600, Retry: 900, Expire: 1209600, Minimum: 300}, + NameServers: []NameServer{{Name: "ns.example.test", Addresses: []string{"192.0.2.53"}}}, + Records: []HostedZoneRecord{{Name: "@", Type: "A", TTL: 60, Values: []string{"192.0.2.1"}}}, + ContentDigest: "sha256:" + strings.Repeat("a", 64), + }, + Status: HostedZoneStatus{Servers: []HostedZoneServerStatus{{Endpoint: "dns/server", Serial: 1, ContentDigest: "sha256:" + strings.Repeat("a", 64), ObservedAt: metav1.Now()}}}, + } + tc.mutate(zone) + data, err := json.Marshal(zone) + if err != nil { + t.Fatal(err) + } + var object map[string]interface{} + if err := k8sjson.Unmarshal(data, &object); err != nil { + t.Fatal(err) + } + result := validator.Validate(object) + valid := result.IsValid() + var celErrors field.ErrorList + if valid { + celErrors, _ = celValidator.Validate(context.Background(), field.NewPath(""), structural, object, nil, 1000000) + valid = len(celErrors) == 0 + } + if valid != tc.valid { + t.Fatalf("valid=%v want=%v schemaErrors=%v celErrors=%v", valid, tc.valid, result.Errors, celErrors) + } + }) + } +} + +func snapshotRecords(count int) []HostedZoneRecord { + records := make([]HostedZoneRecord, count) + for i := range records { + records[i] = HostedZoneRecord{Name: "record-" + strconv.Itoa(i), Type: "A", TTL: 60, Values: []string{"192.0.2.1"}} + } + return records +} + +func TestDelegationPolicyJSONContract(t *testing.T) { + const input = `{"resolver":{"mode":"Recursive","servers":["192.0.2.53:53","[2001:db8::53]:53"]}}` + var policy DelegationPolicy + if err := json.Unmarshal([]byte(input), &policy); err != nil { + t.Fatal(err) + } + if policy.Resolver.Mode != "Recursive" || len(policy.Resolver.Servers) != 2 || policy.Resolver.Servers[1] != "[2001:db8::53]:53" { + t.Fatalf("delegation resolver fields were lost: %#v", policy) + } + data, err := json.Marshal(policy) + if err != nil { + t.Fatal(err) + } + if string(data) != input { + t.Fatalf("delegation JSON=%s want=%s", data, input) + } +} diff --git a/pkg/go/api/hosted/v1alpha1/zz_generated.deepcopy.go b/pkg/go/api/hosted/v1alpha1/zz_generated.deepcopy.go new file mode 100644 index 0000000..2f45d97 --- /dev/null +++ b/pkg/go/api/hosted/v1alpha1/zz_generated.deepcopy.go @@ -0,0 +1,287 @@ +//go:build !ignore_autogenerated + +// Code generated by controller-gen. DO NOT EDIT. + +package v1alpha1 + +import ( + "k8s.io/apimachinery/pkg/runtime" +) + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *DelegationPolicy) DeepCopyInto(out *DelegationPolicy) { + *out = *in + in.Resolver.DeepCopyInto(&out.Resolver) +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new DelegationPolicy. +func (in *DelegationPolicy) DeepCopy() *DelegationPolicy { + if in == nil { + return nil + } + out := new(DelegationPolicy) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *HostedZone) DeepCopyInto(out *HostedZone) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) + in.Spec.DeepCopyInto(&out.Spec) + in.Status.DeepCopyInto(&out.Status) +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new HostedZone. +func (in *HostedZone) DeepCopy() *HostedZone { + if in == nil { + return nil + } + out := new(HostedZone) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *HostedZone) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *HostedZoneClassParameters) DeepCopyInto(out *HostedZoneClassParameters) { + *out = *in + if in.NameServers != nil { + in, out := &in.NameServers, &out.NameServers + *out = make([]NameServer, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } + out.SOA = in.SOA + in.Verification.DeepCopyInto(&out.Verification) +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new HostedZoneClassParameters. +func (in *HostedZoneClassParameters) DeepCopy() *HostedZoneClassParameters { + if in == nil { + return nil + } + out := new(HostedZoneClassParameters) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *HostedZoneList) DeepCopyInto(out *HostedZoneList) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ListMeta.DeepCopyInto(&out.ListMeta) + if in.Items != nil { + in, out := &in.Items, &out.Items + *out = make([]HostedZone, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new HostedZoneList. +func (in *HostedZoneList) DeepCopy() *HostedZoneList { + if in == nil { + return nil + } + out := new(HostedZoneList) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *HostedZoneList) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *HostedZoneRecord) DeepCopyInto(out *HostedZoneRecord) { + *out = *in + if in.Values != nil { + in, out := &in.Values, &out.Values + *out = make([]string, len(*in)) + copy(*out, *in) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new HostedZoneRecord. +func (in *HostedZoneRecord) DeepCopy() *HostedZoneRecord { + if in == nil { + return nil + } + out := new(HostedZoneRecord) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *HostedZoneServerStatus) DeepCopyInto(out *HostedZoneServerStatus) { + *out = *in + in.ObservedAt.DeepCopyInto(&out.ObservedAt) +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new HostedZoneServerStatus. +func (in *HostedZoneServerStatus) DeepCopy() *HostedZoneServerStatus { + if in == nil { + return nil + } + out := new(HostedZoneServerStatus) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *HostedZoneSpec) DeepCopyInto(out *HostedZoneSpec) { + *out = *in + out.SOA = in.SOA + if in.NameServers != nil { + in, out := &in.NameServers, &out.NameServers + *out = make([]NameServer, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } + if in.Records != nil { + in, out := &in.Records, &out.Records + *out = make([]HostedZoneRecord, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new HostedZoneSpec. +func (in *HostedZoneSpec) DeepCopy() *HostedZoneSpec { + if in == nil { + return nil + } + out := new(HostedZoneSpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *HostedZoneStatus) DeepCopyInto(out *HostedZoneStatus) { + *out = *in + if in.Servers != nil { + in, out := &in.Servers, &out.Servers + *out = make([]HostedZoneServerStatus, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new HostedZoneStatus. +func (in *HostedZoneStatus) DeepCopy() *HostedZoneStatus { + if in == nil { + return nil + } + out := new(HostedZoneStatus) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *NameServer) DeepCopyInto(out *NameServer) { + *out = *in + if in.Addresses != nil { + in, out := &in.Addresses, &out.Addresses + *out = make([]string, len(*in)) + copy(*out, *in) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new NameServer. +func (in *NameServer) DeepCopy() *NameServer { + if in == nil { + return nil + } + out := new(NameServer) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ResolverPolicy) DeepCopyInto(out *ResolverPolicy) { + *out = *in + if in.Servers != nil { + in, out := &in.Servers, &out.Servers + *out = make([]string, len(*in)) + copy(*out, *in) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ResolverPolicy. +func (in *ResolverPolicy) DeepCopy() *ResolverPolicy { + if in == nil { + return nil + } + out := new(ResolverPolicy) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *SOA) DeepCopyInto(out *SOA) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new SOA. +func (in *SOA) DeepCopy() *SOA { + if in == nil { + return nil + } + out := new(SOA) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *SOAParameters) DeepCopyInto(out *SOAParameters) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new SOAParameters. +func (in *SOAParameters) DeepCopy() *SOAParameters { + if in == nil { + return nil + } + out := new(SOAParameters) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *VerificationPolicy) DeepCopyInto(out *VerificationPolicy) { + *out = *in + if in.Views != nil { + in, out := &in.Views, &out.Views + *out = make([]string, len(*in)) + copy(*out, *in) + } + in.Delegation.DeepCopyInto(&out.Delegation) +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new VerificationPolicy. +func (in *VerificationPolicy) DeepCopy() *VerificationPolicy { + if in == nil { + return nil + } + out := new(VerificationPolicy) + in.DeepCopyInto(out) + return out +}