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 }