Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions docs/agent/llms-full.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
44 changes: 44 additions & 0 deletions internal/webhook/dnsrecordset_validating.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"context"
"fmt"
"sort"
"strings"

apierrors "k8s.io/apimachinery/pkg/api/errors"
"k8s.io/apimachinery/pkg/types"
Expand All @@ -29,17 +30,60 @@ 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)
}

func (v *DNSRecordSetValidator) ValidateDelete(context.Context, *dnsv1alpha1.DNSRecordSet) (admission.Warnings, error) {
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
Expand Down
53 changes: 53 additions & 0 deletions internal/webhook/dnsrecordset_validating_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand Down
Loading