From 23d2cafaad0e643c2717208e95eeb5d51ce301c8 Mon Sep 17 00:00:00 2001 From: Pierre-Olivier Mercier Date: Wed, 29 Apr 2026 17:35:23 +0700 Subject: [PATCH 1/6] checker: build owner FQDN from subdomain + apex at service scope --- checker/collect.go | 13 +++++++++---- checker/definition.go | 7 +++++-- 2 files changed, 14 insertions(+), 6 deletions(-) diff --git a/checker/collect.go b/checker/collect.go index 426dbc4..c30da19 100644 --- a/checker/collect.go +++ b/checker/collect.go @@ -57,19 +57,24 @@ func (p *aliasProvider) Collect(ctx context.Context, opts sdk.CheckerOptions) (a // resolveOwner prefers the "service" option because its dns.CNAME owner is // authoritative; subdomain + domain_name is the fallback for ad-hoc forms. func resolveOwner(opts sdk.CheckerOptions) (string, error) { + parent, _ := sdk.GetOption[string](opts, "domain_name") + parent = strings.TrimSuffix(parent, ".") + if svcMsg, ok := sdk.GetOption[serviceMessage](opts, "service"); ok && len(svcMsg.Service) > 0 { var c cnameService - if err := json.Unmarshal(svcMsg.Service, &c); err == nil && c.Record != nil && c.Record.Hdr.Name != "" { - return lowerFQDN(c.Record.Hdr.Name), nil + if err := json.Unmarshal(svcMsg.Service, &c); err == nil && c.Record != nil { + // svcMsg.Domain holds the subdomain (relative to apex); the + // record's Hdr.Name is relative to that mount point. Build the + // origin first, then join the record name into it. + origin := sdk.JoinRelative(strings.TrimSuffix(svcMsg.Domain, "."), parent) + return lowerFQDN(sdk.JoinRelative(c.Record.Hdr.Name, origin)), nil } } - parent, _ := sdk.GetOption[string](opts, "domain_name") sub, _ := sdk.GetOption[string](opts, "subdomain") if parent == "" { return "", fmt.Errorf("missing 'domain_name' option") } - parent = strings.TrimSuffix(parent, ".") if sub == "" || sub == "@" { return lowerFQDN(parent), nil } diff --git a/checker/definition.go b/checker/definition.go index cd036ef..772d966 100644 --- a/checker/definition.go +++ b/checker/definition.go @@ -17,8 +17,6 @@ func Definition() *sdk.CheckerDefinition { Version: Version, Availability: sdk.CheckerAvailability{ ApplyToService: true, - ApplyToDomain: true, - ApplyToZone: true, LimitToServices: []string{ "svcs.CNAME", "svcs.SpecialCNAME", @@ -65,6 +63,11 @@ func Definition() *sdk.CheckerDefinition { Label: "Service", AutoFill: sdk.AutoFillService, }, + { + Id: "domain_name", + Label: "Parent domain name", + AutoFill: sdk.AutoFillDomainName, + }, }, }, Rules: []sdk.CheckRule{ From fde892a95830ddb33e8bf3e6056669e60dce7b94 Mon Sep 17 00:00:00 2001 From: Pierre-Olivier Mercier Date: Sun, 10 May 2026 18:59:15 +0800 Subject: [PATCH 2/6] Add CI/CD pipeline --- .drone-manifest.yml | 22 ++++++ .drone.yml | 187 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 209 insertions(+) create mode 100644 .drone-manifest.yml create mode 100644 .drone.yml diff --git a/.drone-manifest.yml b/.drone-manifest.yml new file mode 100644 index 0000000..a594605 --- /dev/null +++ b/.drone-manifest.yml @@ -0,0 +1,22 @@ +image: happydomain/checker-alias:{{#if build.tag}}{{trimPrefix "v" build.tag}}{{else}}latest{{/if}} +{{#if build.tags}} +tags: +{{#each build.tags}} + - {{this}} +{{/each}} +{{/if}} +manifests: + - image: happydomain/checker-alias:{{#if build.tag}}{{trimPrefix "v" build.tag}}-{{/if}}linux-amd64 + platform: + architecture: amd64 + os: linux + - image: happydomain/checker-alias:{{#if build.tag}}{{trimPrefix "v" build.tag}}-{{/if}}linux-arm64 + platform: + architecture: arm64 + os: linux + variant: v8 + - image: happydomain/checker-alias:{{#if build.tag}}{{trimPrefix "v" build.tag}}-{{/if}}linux-arm + platform: + architecture: arm + os: linux + variant: v7 diff --git a/.drone.yml b/.drone.yml new file mode 100644 index 0000000..4f7ab71 --- /dev/null +++ b/.drone.yml @@ -0,0 +1,187 @@ +--- +kind: pipeline +type: docker +name: build-amd64 + +platform: + os: linux + arch: amd64 + +steps: + - name: checker build + image: golang:1-alpine + commands: + - apk add --no-cache git make + - make + environment: + CHECKER_VERSION: "${DRONE_BRANCH}-${DRONE_COMMIT}" + CGO_ENABLED: 0 + when: + event: + exclude: + - tag + + - name: checker build tag + image: golang:1-alpine + commands: + - apk add --no-cache git make + - make + environment: + CHECKER_VERSION: "${DRONE_SEMVER}" + CGO_ENABLED: 0 + when: + event: + - tag + + - name: publish on Docker Hub + image: plugins/docker + settings: + repo: happydomain/checker-alias + auto_tag: true + auto_tag_suffix: ${DRONE_STAGE_OS}-${DRONE_STAGE_ARCH} + dockerfile: Dockerfile + build_args: + - CHECKER_VERSION=${DRONE_BRANCH}-${DRONE_COMMIT} + username: + from_secret: docker_username + password: + from_secret: docker_password + when: + event: + exclude: + - tag + + - name: publish on Docker Hub (tag) + image: plugins/docker + settings: + repo: happydomain/checker-alias + auto_tag: true + auto_tag_suffix: ${DRONE_STAGE_OS}-${DRONE_STAGE_ARCH} + dockerfile: Dockerfile + build_args: + - CHECKER_VERSION=${DRONE_SEMVER} + username: + from_secret: docker_username + password: + from_secret: docker_password + when: + event: + - tag + +trigger: + branch: + exclude: + - renovate/* + event: + - cron + - push + - tag + +--- +kind: pipeline +type: docker +name: build-arm64 + +platform: + os: linux + arch: arm64 + +steps: + - name: checker build + image: golang:1-alpine + commands: + - apk add --no-cache git make + - make + environment: + CHECKER_VERSION: "${DRONE_BRANCH}-${DRONE_COMMIT}" + CGO_ENABLED: 0 + when: + event: + exclude: + - tag + + - name: checker build tag + image: golang:1-alpine + commands: + - apk add --no-cache git make + - make + environment: + CHECKER_VERSION: "${DRONE_SEMVER}" + CGO_ENABLED: 0 + when: + event: + - tag + + - name: publish on Docker Hub + image: plugins/docker + settings: + repo: happydomain/checker-alias + auto_tag: true + auto_tag_suffix: ${DRONE_STAGE_OS}-${DRONE_STAGE_ARCH} + dockerfile: Dockerfile + build_args: + - CHECKER_VERSION=${DRONE_BRANCH}-${DRONE_COMMIT} + username: + from_secret: docker_username + password: + from_secret: docker_password + when: + event: + exclude: + - tag + + - name: publish on Docker Hub (tag) + image: plugins/docker + settings: + repo: happydomain/checker-alias + auto_tag: true + auto_tag_suffix: ${DRONE_STAGE_OS}-${DRONE_STAGE_ARCH} + dockerfile: Dockerfile + build_args: + - CHECKER_VERSION=${DRONE_SEMVER} + username: + from_secret: docker_username + password: + from_secret: docker_password + when: + event: + - tag + +trigger: + event: + - cron + - push + - tag + +--- +kind: pipeline +name: docker-manifest + +platform: + os: linux + arch: arm64 + +steps: + - name: publish on Docker Hub + image: plugins/manifest + settings: + auto_tag: true + ignore_missing: true + spec: .drone-manifest.yml + username: + from_secret: docker_username + password: + from_secret: docker_password + +trigger: + branch: + exclude: + - renovate/* + event: + - cron + - push + - tag + +depends_on: + - build-amd64 + - build-arm64 From 56db4cc59d3b85fc21b0db0cfabbbabcfe19767a Mon Sep 17 00:00:00 2001 From: Pierre-Olivier Mercier Date: Sun, 10 May 2026 20:04:23 +0800 Subject: [PATCH 3/6] Go mod update --- go.mod | 12 ++++++------ go.sum | 24 ++++++++++++------------ 2 files changed, 18 insertions(+), 18 deletions(-) diff --git a/go.mod b/go.mod index 4e885c2..d411f44 100644 --- a/go.mod +++ b/go.mod @@ -3,14 +3,14 @@ module git.happydns.org/checker-alias go 1.25.0 require ( - git.happydns.org/checker-sdk-go v1.5.0 + git.happydns.org/checker-sdk-go v1.7.0 github.com/miekg/dns v1.1.72 ) require ( - golang.org/x/mod v0.31.0 // indirect - golang.org/x/net v0.48.0 // indirect - golang.org/x/sync v0.19.0 // indirect - golang.org/x/sys v0.39.0 // indirect - golang.org/x/tools v0.40.0 // indirect + golang.org/x/mod v0.36.0 // indirect + golang.org/x/net v0.54.0 // indirect + golang.org/x/sync v0.20.0 // indirect + golang.org/x/sys v0.44.0 // indirect + golang.org/x/tools v0.45.0 // indirect ) diff --git a/go.sum b/go.sum index 2a80023..d9be40e 100644 --- a/go.sum +++ b/go.sum @@ -1,16 +1,16 @@ -git.happydns.org/checker-sdk-go v1.5.0 h1:5uD5Cm6xJ+lwnhbJ09iCXGHbYS9zRh+Yh0NeBHkAPBY= -git.happydns.org/checker-sdk-go v1.5.0/go.mod h1:aNAcfYFfbhvH9kJhE0Njp5GX0dQbxdRB0rJ0KvSC5nI= +git.happydns.org/checker-sdk-go v1.7.0 h1:dSgo2js5mfXluLc6x0WWZ0MQULd9XV2GI9z0Usk+Qgw= +git.happydns.org/checker-sdk-go v1.7.0/go.mod h1:aNAcfYFfbhvH9kJhE0Njp5GX0dQbxdRB0rJ0KvSC5nI= github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/miekg/dns v1.1.72 h1:vhmr+TF2A3tuoGNkLDFK9zi36F2LS+hKTRW0Uf8kbzI= github.com/miekg/dns v1.1.72/go.mod h1:+EuEPhdHOsfk6Wk5TT2CzssZdqkmFhf8r+aVyDEToIs= -golang.org/x/mod v0.31.0 h1:HaW9xtz0+kOcWKwli0ZXy79Ix+UW/vOfmWI5QVd2tgI= -golang.org/x/mod v0.31.0/go.mod h1:43JraMp9cGx1Rx3AqioxrbrhNsLl2l/iNAvuBkrezpg= -golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU= -golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY= -golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= -golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= -golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk= -golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= -golang.org/x/tools v0.40.0 h1:yLkxfA+Qnul4cs9QA3KnlFu0lVmd8JJfoq+E41uSutA= -golang.org/x/tools v0.40.0/go.mod h1:Ik/tzLRlbscWpqqMRjyWYDisX8bG13FrdXp3o4Sr9lc= +golang.org/x/mod v0.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4= +golang.org/x/mod v0.36.0/go.mod h1:moc6ELqsWcOw5Ef3xVprK5ul/MvtVvkIXLziUOICjUQ= +golang.org/x/net v0.54.0 h1:2zJIZAxAHV/OHCDTCOHAYehQzLfSXuf/5SoL/Dv6w/w= +golang.org/x/net v0.54.0/go.mod h1:Sj4oj8jK6XmHpBZU/zWHw3BV3abl4Kvi+Ut7cQcY+cQ= +golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= +golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ= +golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/tools v0.45.0 h1:18qN3FAooORvApf5XjCXgsuayZOEtXf6JK18I3+ONa8= +golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0= From 52a3e56c4f69d44b0b77c2de2597693eaf26b71d Mon Sep 17 00:00:00 2001 From: Pierre-Olivier Mercier Date: Fri, 15 May 2026 17:30:15 +0800 Subject: [PATCH 4/6] checker: rework target_resolvable to check existence (NOERROR) instead of A/AAAA --- checker/rules_chain.go | 36 +++++++----------------------------- checker/rules_common.go | 7 +++---- checker/rules_test.go | 13 +++++++------ 3 files changed, 17 insertions(+), 39 deletions(-) diff --git a/checker/rules_chain.go b/checker/rules_chain.go index 12e91b6..e3da350 100644 --- a/checker/rules_chain.go +++ b/checker/rules_chain.go @@ -189,24 +189,10 @@ type targetResolvableRule struct{} func (targetResolvableRule) Name() string { return "target_resolvable" } func (targetResolvableRule) Description() string { - return "Verifies that the final target of the alias chain publishes at least one A or AAAA record." + return "Verifies that the final target of the alias chain exists in DNS (returns NOERROR, not NXDOMAIN)." } -func (targetResolvableRule) Options() sdk.CheckerOptionsDocumentation { - return sdk.CheckerOptionsDocumentation{ - UserOpts: []sdk.CheckerOptionDocumentation{ - { - Id: "requireResolvableTarget", - Type: "bool", - Label: "Require resolvable target", - Description: "When enabled, a chain whose final target returns no A/AAAA is reported as critical (otherwise a warning).", - Default: defaultRequireResolvableTarget, - }, - }, - } -} - -func (targetResolvableRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, opts sdk.CheckerOptions) []sdk.CheckState { +func (targetResolvableRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, _ sdk.CheckerOptions) []sdk.CheckState { data, errState := loadAlias(ctx, obs) if errState != nil { return errState @@ -217,22 +203,14 @@ func (targetResolvableRule) Evaluate(ctx context.Context, obs sdk.ObservationGet if data.ChainTerminated.Reason != TermOK { return skipped("chain did not terminate normally") } - if len(data.FinalA) > 0 || len(data.FinalAAAA) > 0 { - return okState(data.FinalTarget, fmt.Sprintf("target %s resolves to %d address(es)", data.FinalTarget, len(data.FinalA)+len(data.FinalAAAA))) - } - status := sdk.StatusWarn - if sdk.GetBoolOption(opts, "requireResolvableTarget", defaultRequireResolvableTarget) { - status = sdk.StatusCrit - } - rcode := data.FinalRcode - if rcode == "" { - rcode = "no A/AAAA" + if data.FinalRcode != "NXDOMAIN" { + return okState(data.FinalTarget, fmt.Sprintf("target %s exists in DNS", data.FinalTarget)) } return []sdk.CheckState{withHint(sdk.CheckState{ - Status: status, + Status: sdk.StatusCrit, Subject: data.FinalTarget, - Message: fmt.Sprintf("final target %s does not resolve to an address (%s)", data.FinalTarget, rcode), - }, "Point the alias at a name that publishes at least one A or AAAA record, or fix the upstream zone.")} + Message: fmt.Sprintf("final target %s does not exist (NXDOMAIN)", data.FinalTarget), + }, "The alias points at a name that does not exist; create the missing record or update the alias target.")} } type multipleRecordsRule struct{} diff --git a/checker/rules_common.go b/checker/rules_common.go index b267fba..345cfc7 100644 --- a/checker/rules_common.go +++ b/checker/rules_common.go @@ -9,10 +9,9 @@ import ( // Defaults are centralised so Definition's docs and runtime reads cannot drift. const ( - defaultMaxChainLength = 8 - defaultMinTargetTTL = 60 - defaultRequireResolvableTarget = true - defaultAllowApexCNAME = false + defaultMaxChainLength = 8 + defaultMinTargetTTL = 60 + defaultAllowApexCNAME = false defaultRecognizeApexFlattening = true // hintKey is the CheckState.Meta key the HTML report reads to render the diff --git a/checker/rules_test.go b/checker/rules_test.go index 95a1c28..2c8452f 100644 --- a/checker/rules_test.go +++ b/checker/rules_test.go @@ -290,24 +290,25 @@ func TestCnameDnssecRule(t *testing.T) { } func TestTargetResolvableRule(t *testing.T) { - t.Run("ok", func(t *testing.T) { + t.Run("ok when NOERROR with A record", func(t *testing.T) { d := apexKnownData() d.ChainTerminated.Reason = TermOK d.FinalTarget = "target." d.FinalA = []string{"1.2.3.4"} assertSingle(t, run(targetResolvableRule{}, d, nil), sdk.StatusOK) }) - t.Run("crit by default", func(t *testing.T) { + t.Run("ok when NOERROR with no A/AAAA (e.g. service label)", func(t *testing.T) { d := apexKnownData() d.ChainTerminated.Reason = TermOK - d.FinalTarget = "target." - assertSingle(t, run(targetResolvableRule{}, d, nil), sdk.StatusCrit) + d.FinalTarget = "_2772._tcp.znc.example." + assertSingle(t, run(targetResolvableRule{}, d, nil), sdk.StatusOK) }) - t.Run("warn when requireResolvableTarget=false", func(t *testing.T) { + t.Run("crit when NXDOMAIN", func(t *testing.T) { d := apexKnownData() d.ChainTerminated.Reason = TermOK d.FinalTarget = "target." - assertSingle(t, run(targetResolvableRule{}, d, sdk.CheckerOptions{"requireResolvableTarget": false}), sdk.StatusWarn) + d.FinalRcode = "NXDOMAIN" + assertSingle(t, run(targetResolvableRule{}, d, nil), sdk.StatusCrit) }) t.Run("skip when chain did not terminate normally", func(t *testing.T) { d := apexKnownData() From 1493ef4d3f8fb2d5b535f0a0c1887dd7c7b13f31 Mon Sep 17 00:00:00 2001 From: Pierre-Olivier Mercier Date: Fri, 15 May 2026 17:37:10 +0800 Subject: [PATCH 5/6] report: move synthetic ALIAS hop from collector to report view --- checker/collect.go | 6 ------ checker/report.go | 12 ++++++++++-- 2 files changed, 10 insertions(+), 8 deletions(-) diff --git a/checker/collect.go b/checker/collect.go index c30da19..5658218 100644 --- a/checker/collect.go +++ b/checker/collect.go @@ -400,12 +400,6 @@ func observeApex(ctx context.Context, data *AliasData, servers []string, apex st if (hasA || hasAAAA) && !data.ApexHasCNAME { data.ApexFlattening = true - // Synthesize a pseudo-hop so the report's chain view shows the ALIAS - // indirection that would otherwise be invisible from the wire. - data.Chain = append(data.Chain, ChainHop{ - Owner: lowerFQDN(apex), - Kind: KindALIAS, - }) } } diff --git a/checker/report.go b/checker/report.go index b8c58e7..3f98385 100644 --- a/checker/report.go +++ b/checker/report.go @@ -104,7 +104,15 @@ func buildReportView(data *AliasData, states []sdk.CheckState) *reportView { v.FinalAddresses = append(v.FinalAddresses, data.FinalA...) v.FinalAddresses = append(v.FinalAddresses, data.FinalAAAA...) - for i, h := range data.Chain { + chain := data.Chain + if data.ApexFlattening { + chain = append(chain, ChainHop{ + Owner: data.Apex, + Kind: KindALIAS, + }) + } + + for i, h := range chain { step := chainStep{ Index: i + 1, Owner: h.Owner, @@ -112,7 +120,7 @@ func buildReportView(data *AliasData, states []sdk.CheckState) *reportView { Target: h.Target, TTL: h.TTL, Server: h.Server, - IsLast: i == len(data.Chain)-1, + IsLast: i == len(chain)-1, } switch h.Kind { case KindCNAME: From c5c13960d52d4b398d3529046c3c45021fbd8fdd Mon Sep 17 00:00:00 2001 From: Pierre-Olivier Mercier Date: Sat, 16 May 2026 21:35:53 +0800 Subject: [PATCH 6/6] checker: add dname_coexistence rule and refactor sibling probing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extract querySiblings from observeCoexistence so both CNAME and DNAME coexistence checks share the same parallel RRset scan. Add observeDNAMECoexistence (called from Collect) that populates AliasData.DNAMECoexistence for each DNAME node in DNAMESubstitutions. Add the dname_coexistence rule (RFC 6672 §2.3) that flags any sibling RRsets at a DNAME owner as CRIT, with matching tests. --- checker/collect.go | 54 ++++++++++++++++++++++++++++-------- checker/definition.go | 1 + checker/rules_coexistence.go | 35 +++++++++++++++++++++++ checker/rules_test.go | 32 +++++++++++++++++++++ checker/types.go | 2 ++ 5 files changed, 112 insertions(+), 12 deletions(-) diff --git a/checker/collect.go b/checker/collect.go index 5658218..c3f2a15 100644 --- a/checker/collect.go +++ b/checker/collect.go @@ -49,6 +49,7 @@ func (p *aliasProvider) Collect(ctx context.Context, opts sdk.CheckerOptions) (a } observeCoexistence(ctx, data, servers, owner) + observeDNAMECoexistence(ctx, data, servers) observeDNSSEC(ctx, data, servers, apex, owner) return data, nil @@ -403,20 +404,18 @@ func observeApex(ctx context.Context, data *AliasData, servers []string, apex st } } -func observeCoexistence(ctx context.Context, data *AliasData, servers []string, owner string) { - if !data.OwnerHasCNAME { - return - } - - siblings := []uint16{ +// querySiblings returns RRsets of common types that sit alongside a CNAME or DNAME at owner. +// Filter on owner+type: a DNAME-synthesized CNAME would otherwise count as a sibling. +func querySiblings(ctx context.Context, servers []string, owner string) []CoexistingRRset { + candidates := []uint16{ dns.TypeA, dns.TypeAAAA, dns.TypeMX, dns.TypeTXT, dns.TypeNS, dns.TypeSRV, dns.TypeCAA, } seen := map[string]uint32{} var mu sync.Mutex var wg sync.WaitGroup - wg.Add(len(siblings)) - for _, qt := range siblings { + wg.Add(len(candidates)) + for _, qt := range candidates { go func() { defer wg.Done() q := dns.Question{Name: owner, Qtype: qt, Qclass: dns.ClassINET} @@ -424,8 +423,6 @@ func observeCoexistence(ctx context.Context, data *AliasData, servers []string, if err != nil || r == nil { return } - // Filter on owner+type because a DNAME-synthesized CNAME would - // otherwise count as a sibling of every queried type. for _, rr := range r.Answer { if rr.Header().Rrtype != qt { continue @@ -441,9 +438,42 @@ func observeCoexistence(ctx context.Context, data *AliasData, servers []string, }() } wg.Wait() - + var out []CoexistingRRset for t, ttl := range seen { - data.Coexisting = append(data.Coexisting, CoexistingRRset{Type: t, TTL: ttl}) + out = append(out, CoexistingRRset{Type: t, TTL: ttl}) + } + return out +} + +func observeCoexistence(ctx context.Context, data *AliasData, servers []string, owner string) { + if !data.OwnerHasCNAME { + return + } + data.Coexisting = querySiblings(ctx, servers, owner) +} + +func observeDNAMECoexistence(ctx context.Context, data *AliasData, servers []string) { + if len(data.DNAMESubstitutions) == 0 { + return + } + results := make(map[string][]CoexistingRRset, len(data.DNAMESubstitutions)) + var mu sync.Mutex + var wg sync.WaitGroup + wg.Add(len(data.DNAMESubstitutions)) + for _, hop := range data.DNAMESubstitutions { + go func() { + defer wg.Done() + siblings := querySiblings(ctx, servers, hop.Owner) + if len(siblings) > 0 { + mu.Lock() + results[hop.Owner] = siblings + mu.Unlock() + } + }() + } + wg.Wait() + if len(results) > 0 { + data.DNAMECoexistence = results } } diff --git a/checker/definition.go b/checker/definition.go index 772d966..38af530 100644 --- a/checker/definition.go +++ b/checker/definition.go @@ -80,6 +80,7 @@ func Definition() *sdk.CheckerDefinition { cnameAtApexRule{}, apexFlatteningRule{}, cnameCoexistenceRule{}, + dnameCoexistenceRule{}, cnameDnssecRule{}, targetResolvableRule{}, multipleRecordsRule{}, diff --git a/checker/rules_coexistence.go b/checker/rules_coexistence.go index 252c987..d305031 100644 --- a/checker/rules_coexistence.go +++ b/checker/rules_coexistence.go @@ -7,6 +7,41 @@ import ( sdk "git.happydns.org/checker-sdk-go/checker" ) +type dnameCoexistenceRule struct{} + +func (dnameCoexistenceRule) Name() string { return "dname_coexistence" } +func (dnameCoexistenceRule) Description() string { + return "Flags RRsets that sit at the same owner as a DNAME (RFC 6672 §2.3)." +} + +func (dnameCoexistenceRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, _ sdk.CheckerOptions) []sdk.CheckState { + data, errState := loadAlias(ctx, obs) + if errState != nil { + return errState + } + if !apexKnown(data) { + return skipped("apex lookup failed") + } + if len(data.DNAMESubstitutions) == 0 { + return skipped("no DNAME in chain") + } + if len(data.DNAMECoexistence) == 0 { + return okState(data.Owner, "all DNAME nodes have no sibling records") + } + var out []sdk.CheckState + for owner, coexisting := range data.DNAMECoexistence { + for _, rr := range coexisting { + out = append(out, withHint(sdk.CheckState{ + Status: sdk.StatusCrit, + Subject: owner, + Message: fmt.Sprintf("%s and DNAME both exist at %s (RFC 6672 §2.3)", rr.Type, owner), + Code: rr.Type, + }, "Remove the sibling record or move it under a different label; a DNAME owner must not carry other data.")) + } + } + return out +} + type cnameCoexistenceRule struct{} func (cnameCoexistenceRule) Name() string { return "cname_coexistence" } diff --git a/checker/rules_test.go b/checker/rules_test.go index 2c8452f..1d2d92f 100644 --- a/checker/rules_test.go +++ b/checker/rules_test.go @@ -266,6 +266,38 @@ func TestCnameCoexistenceRule(t *testing.T) { }) } +func TestDnameCoexistenceRule(t *testing.T) { + t.Run("skip when no DNAME in chain", func(t *testing.T) { + d := apexKnownData() + assertSkipped(t, run(dnameCoexistenceRule{}, d, nil), "no DNAME in chain") + }) + t.Run("ok when DNAME has no siblings", func(t *testing.T) { + d := apexKnownData() + d.DNAMESubstitutions = []ChainHop{{Owner: "old.example.com.", Kind: KindDNAME, Target: "new.example.com."}} + assertSingle(t, run(dnameCoexistenceRule{}, d, nil), sdk.StatusOK) + }) + t.Run("crit when DNAME has siblings", func(t *testing.T) { + d := apexKnownData() + d.DNAMESubstitutions = []ChainHop{{Owner: "old.example.com.", Kind: KindDNAME, Target: "new.example.com."}} + d.DNAMECoexistence = map[string][]CoexistingRRset{ + "old.example.com.": {{Type: "MX"}, {Type: "A"}}, + } + states := run(dnameCoexistenceRule{}, d, nil) + if len(states) != 2 { + t.Fatalf("want 2 states, got %d: %+v", len(states), states) + } + for _, s := range states { + if s.Status != sdk.StatusCrit { + t.Fatalf("want CRIT, got %v", s.Status) + } + } + }) + t.Run("skip when apex unknown", func(t *testing.T) { + d := &AliasData{Owner: "x.", ApexLookupError: "boom"} + assertSkipped(t, run(dnameCoexistenceRule{}, d, nil), "apex") + }) +} + func TestCnameDnssecRule(t *testing.T) { t.Run("skip unsigned zone", func(t *testing.T) { d := apexKnownData() diff --git a/checker/types.go b/checker/types.go index 724ec27..ecfa074 100644 --- a/checker/types.go +++ b/checker/types.go @@ -70,6 +70,8 @@ type AliasData struct { // Coexisting is populated only when Owner has a CNAME. Coexisting []CoexistingRRset `json:"coexisting,omitempty"` + // DNAMECoexistence maps each DNAME owner (from DNAMESubstitutions) to its sibling RRsets. + DNAMECoexistence map[string][]CoexistingRRset `json:"dname_coexistence,omitempty"` OwnerIsApex bool `json:"owner_is_apex,omitempty"` OwnerHasCNAME bool `json:"owner_has_cname,omitempty"`