From af04121ff6aa4976f5619dee92646ef261d02825 Mon Sep 17 00:00:00 2001 From: Pierre-Olivier Mercier Date: Thu, 23 Apr 2026 11:29:39 +0700 Subject: [PATCH] checker: expose standalone /check route via CheckerInteractive MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements RenderForm/ParseForm on the provider: users can hit /check with just a domain name; ParseForm resolves CAA records via direct DNS queries (walking up the label tree per RFC 8659) and hands the SDK a synthetic service payload so the standard Collect → Evaluate pipeline runs without a happyDomain host. TLS probes are not gathered here, so the rule reports StatusUnknown for the TLS cross-check in this mode. Co-Authored-By: Claude Opus 4.7 (1M context) --- checker/interactive.go | 118 +++++++++++++++++++++++++++++++++++++++++ go.mod | 9 ++++ go.sum | 13 +++++ 3 files changed, 140 insertions(+) create mode 100644 checker/interactive.go diff --git a/checker/interactive.go b/checker/interactive.go new file mode 100644 index 0000000..18ff51d --- /dev/null +++ b/checker/interactive.go @@ -0,0 +1,118 @@ +package checker + +import ( + "encoding/json" + "errors" + "fmt" + "net" + "net/http" + "strings" + + sdk "git.happydns.org/checker-sdk-go/checker" + "github.com/miekg/dns" +) + +// RenderForm describes the minimal input the standalone /check route +// accepts: just a domain name to resolve CAA records for. +func (p *caaProvider) RenderForm() []sdk.CheckerOptionField { + return []sdk.CheckerOptionField{ + { + Id: "domain", + Type: "string", + Label: "Domain name", + Placeholder: "example.com", + Required: true, + }, + } +} + +// ParseForm resolves CAA records for the submitted domain via direct +// DNS queries and packages them into the CheckerOptions shape Collect +// expects. TLS probes are not gathered here; the rule will report +// StatusUnknown for the TLS cross-check when used standalone. +func (p *caaProvider) ParseForm(r *http.Request) (sdk.CheckerOptions, error) { + domain := strings.TrimSpace(r.FormValue("domain")) + if domain == "" { + return nil, errors.New("domain is required") + } + domain = dns.Fqdn(domain) + + records, err := lookupCAA(domain) + if err != nil { + return nil, fmt.Errorf("CAA lookup for %s: %w", domain, err) + } + + payload := caaPolicyPayload{Records: make([]caaRecordPayload, 0, len(records))} + for _, rec := range records { + payload.Records = append(payload.Records, caaRecordPayload{ + Flag: rec.Flag, Tag: rec.Tag, Value: rec.Value, + }) + } + svcBody, err := json.Marshal(payload) + if err != nil { + return nil, fmt.Errorf("marshal CAA payload: %w", err) + } + + svc := serviceMessage{ + Type: serviceType, + Domain: strings.TrimSuffix(domain, "."), + Service: svcBody, + } + + return sdk.CheckerOptions{ + "domain": strings.TrimSuffix(domain, "."), + "service": svc, + }, nil +} + +// lookupCAA queries CAA records for fqdn using the system resolver. +// Walks up the label tree per RFC 8659 §3 until a record set is found +// or the zone apex is reached; returns an empty slice when none exist. +func lookupCAA(fqdn string) ([]CAARecord, error) { + resolver, err := systemResolver() + if err != nil { + return nil, err + } + + for name := fqdn; name != "" && name != "."; { + msg := new(dns.Msg) + msg.SetQuestion(name, dns.TypeCAA) + msg.RecursionDesired = true + + c := new(dns.Client) + in, _, err := c.Exchange(msg, resolver) + if err != nil { + return nil, err + } + if in.Rcode != dns.RcodeSuccess && in.Rcode != dns.RcodeNameError { + return nil, fmt.Errorf("rcode %s", dns.RcodeToString[in.Rcode]) + } + + var out []CAARecord + for _, rr := range in.Answer { + if caa, ok := rr.(*dns.CAA); ok { + out = append(out, CAARecord{Flag: caa.Flag, Tag: caa.Tag, Value: caa.Value}) + } + } + if len(out) > 0 { + return out, nil + } + + i := strings.IndexByte(name, '.') + if i < 0 || i >= len(name)-1 { + break + } + name = name[i+1:] + } + return nil, nil +} + +// systemResolver returns the first nameserver in /etc/resolv.conf as a +// host:port string suitable for dns.Client.Exchange. +func systemResolver() (string, error) { + cfg, err := dns.ClientConfigFromFile("/etc/resolv.conf") + if err != nil || len(cfg.Servers) == 0 { + return net.JoinHostPort("1.1.1.1", "53"), nil + } + return net.JoinHostPort(cfg.Servers[0], cfg.Port), nil +} diff --git a/go.mod b/go.mod index 2150bf5..b207bda 100644 --- a/go.mod +++ b/go.mod @@ -3,3 +3,12 @@ module git.happydns.org/checker-caa go 1.25.0 require git.happydns.org/checker-sdk-go v1.1.0 + +require ( + github.com/miekg/dns v1.1.72 // indirect + 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 +) diff --git a/go.sum b/go.sum index e69de29..17bb426 100644 --- a/go.sum +++ b/go.sum @@ -0,0 +1,13 @@ +git.happydns.org/checker-sdk-go v1.1.0/go.mod h1:aNAcfYFfbhvH9kJhE0Njp5GX0dQbxdRB0rJ0KvSC5nI= +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=