diff --git a/docs/agent/llms-full.txt b/docs/agent/llms-full.txt index ddfbcca8..952a3328 100644 --- a/docs/agent/llms-full.txt +++ b/docs/agent/llms-full.txt @@ -73,6 +73,8 @@ CAA, NS, SOA, PTR, TLSA, HTTPS, SVCB. A few behaviors are easy to miss: - CNAME and ALIAS are single-valued. Extra values submitted alongside one are silently dropped, not rejected. +- ALIAS cannot be used at a wildcard name (`*` or `*.app`): it would never + resolve, so the API rejects it. Use a CNAME at a wildcard instead. - TXT content is quoted and semicolons are escaped automatically. - SOA's serial number defaults to a date-stamped value if not set. - TTL defaults to 300 seconds when the customer doesn't set one. Nothing diff --git a/internal/webhook/dnsrecordset_validating.go b/internal/webhook/dnsrecordset_validating.go index 6c2079e4..249829fb 100644 --- a/internal/webhook/dnsrecordset_validating.go +++ b/internal/webhook/dnsrecordset_validating.go @@ -6,6 +6,7 @@ import ( "context" "fmt" "sort" + "strings" apierrors "k8s.io/apimachinery/pkg/api/errors" "k8s.io/apimachinery/pkg/types" @@ -29,10 +30,16 @@ type DNSRecordSetValidator struct { var _ admission.Validator[*dnsv1alpha1.DNSRecordSet] = &DNSRecordSetValidator{} func (v *DNSRecordSetValidator) ValidateCreate(ctx context.Context, rs *dnsv1alpha1.DNSRecordSet) (admission.Warnings, error) { + if err := refuseWildcardAlias(nil, rs); err != nil { + return nil, err + } return nil, v.refuseClaimedOwnerNames(ctx, nil, rs) } func (v *DNSRecordSetValidator) ValidateUpdate(ctx context.Context, oldRS, newRS *dnsv1alpha1.DNSRecordSet) (admission.Warnings, error) { + if err := refuseWildcardAlias(oldRS, newRS); err != nil { + return nil, err + } return nil, v.refuseClaimedOwnerNames(ctx, oldRS, newRS) } @@ -40,6 +47,43 @@ func (v *DNSRecordSetValidator) ValidateDelete(context.Context, *dnsv1alpha1.DNS return nil, nil } +func refuseWildcardAlias(oldRS, rs *dnsv1alpha1.DNSRecordSet) error { + if rs.Spec.RecordType != dnsv1alpha1.RRTypeALIAS { + return nil + } + + stored := map[string]struct{}{} + if oldRS != nil && oldRS.Spec.RecordType == dnsv1alpha1.RRTypeALIAS { + for _, rec := range oldRS.Spec.Records { + stored[rec.Name] = struct{}{} + } + } + + var errs field.ErrorList + for i, rec := range rs.Spec.Records { + if !isWildcardName(rec.Name) { + continue + } + if _, ok := stored[rec.Name]; ok { + continue + } + errs = append(errs, field.Invalid( + field.NewPath("spec", "records").Index(i).Child("name"), + rec.Name, + fmt.Sprintf("ALIAS records cannot be used at wildcard names; use a CNAME for %q", rec.Name), + )) + } + if len(errs) == 0 { + return nil + } + return apierrors.NewInvalid( + dnsv1alpha1.GroupVersion.WithKind("DNSRecordSet").GroupKind(), rs.Name, errs) +} + +func isWildcardName(name string) bool { + return name == "*" || strings.HasPrefix(name, "*.") +} + func (v *DNSRecordSetValidator) refuseClaimedOwnerNames(ctx context.Context, oldRS, rs *dnsv1alpha1.DNSRecordSet) error { if rs.Spec.DNSZoneRef.Name == "" || len(rs.Spec.Records) == 0 { return nil diff --git a/internal/webhook/dnsrecordset_validating_test.go b/internal/webhook/dnsrecordset_validating_test.go index 6085a76e..3689f676 100644 --- a/internal/webhook/dnsrecordset_validating_test.go +++ b/internal/webhook/dnsrecordset_validating_test.go @@ -219,6 +219,59 @@ func TestDNSRecordSetValidator_ValidateCreate(t *testing.T) { } } +func TestDNSRecordSetValidator_WildcardAlias(t *testing.T) { + t.Parallel() + + scheme := validatorScheme(t) + zone := &dnsv1alpha1.DNSZone{ + ObjectMeta: metav1.ObjectMeta{Name: "my-zone", Namespace: "default"}, + Spec: dnsv1alpha1.DNSZoneSpec{DomainName: "example.com"}, + } + base := time.Date(2026, 8, 1, 0, 0, 0, 0, time.UTC) + rs := func(rrType dnsv1alpha1.RRType, owners ...string) *dnsv1alpha1.DNSRecordSet { + return recordSet("set", "my-zone", rrType, base, owners...) + } + const hint = "use a CNAME for" + + tests := []struct { + name string + oldRS *dnsv1alpha1.DNSRecordSet + newRS *dnsv1alpha1.DNSRecordSet + wantRefused bool + wantIn []string + }{ + {name: "ALIAS at bare wildcard is refused on create", newRS: rs(dnsv1alpha1.RRTypeALIAS, "*"), wantRefused: true, wantIn: []string{hint, `"*"`, "spec.records[0].name"}}, + {name: "ALIAS at nested wildcard is refused on create", newRS: rs(dnsv1alpha1.RRTypeALIAS, "*.app"), wantRefused: true, wantIn: []string{hint, `"*.app"`}}, + {name: "ALIAS at fqdn wildcard is refused on create", newRS: rs(dnsv1alpha1.RRTypeALIAS, "*.example.com."), wantRefused: true, wantIn: []string{hint}}, + {name: "wildcard among valid names is refused with its index", newRS: rs(dnsv1alpha1.RRTypeALIAS, "www", "*.app"), wantRefused: true, wantIn: []string{"spec.records[1].name"}}, + {name: "CNAME at wildcard is accepted", newRS: rs(dnsv1alpha1.RRTypeCNAME, "*.app")}, + {name: "A at wildcard is accepted", newRS: rs(dnsv1alpha1.RRTypeA, "*")}, + {name: "AAAA at wildcard is accepted", newRS: rs(dnsv1alpha1.RRTypeAAAA, "*.app")}, + {name: "ALIAS at apex is accepted", newRS: rs(dnsv1alpha1.RRTypeALIAS, "@")}, + {name: "ALIAS at exact name is accepted", newRS: rs(dnsv1alpha1.RRTypeALIAS, "www")}, + {name: "ALIAS with a star inside a label is accepted", newRS: rs(dnsv1alpha1.RRTypeALIAS, "a*b")}, + {name: "adding a wildcard ALIAS on update is refused", oldRS: rs(dnsv1alpha1.RRTypeALIAS, "www"), newRS: rs(dnsv1alpha1.RRTypeALIAS, "www", "*"), wantRefused: true, wantIn: []string{hint, "spec.records[1].name"}}, + {name: "changing type to ALIAS at a wildcard is refused", oldRS: rs(dnsv1alpha1.RRTypeCNAME, "*.app"), newRS: rs(dnsv1alpha1.RRTypeALIAS, "*.app"), wantRefused: true, wantIn: []string{hint}}, + {name: "a stored wildcard ALIAS stays editable", oldRS: rs(dnsv1alpha1.RRTypeALIAS, "*.app"), newRS: rs(dnsv1alpha1.RRTypeALIAS, "*.app", "www")}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + v := &DNSRecordSetValidator{ + Client: fake.NewClientBuilder().WithScheme(scheme).WithRuntimeObjects(zone).Build(), + } + var err error + if tc.oldRS == nil { + _, err = v.ValidateCreate(context.Background(), tc.newRS) + } else { + _, err = v.ValidateUpdate(context.Background(), tc.oldRS, tc.newRS) + } + assertRefusal(t, err, tc.wantRefused, tc.wantIn) + }) + } +} + func TestDNSRecordSetValidator_ValidateUpdate(t *testing.T) { t.Parallel()