//go:build standalone package checker import ( "context" "encoding/json" "errors" "fmt" "net" "net/http" "strings" "time" sdk "git.happydns.org/checker-sdk-go/checker" "github.com/miekg/dns" ) // dnsLookupTimeout caps a single CAA query so the standalone HTTP // handler can't be hung by a slow or hostile resolver. const dnsLookupTimeout = 5 * time.Second 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 via direct DNS. TLS probes are not // gathered here; the rule reports StatusUnknown for the 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) bare := strings.TrimSuffix(domain, ".") records, err := lookupCAA(r.Context(), 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: bare, Service: svcBody, } return sdk.CheckerOptions{ "domain": bare, "service": svc, }, nil } // lookupCAA queries CAA records for fqdn using the system resolver. // Per RFC 8659 ยง3, climbing the label tree only continues on empty // NOERROR; NXDOMAIN terminates the walk. func lookupCAA(ctx context.Context, fqdn string) ([]CAARecord, error) { resolver := systemResolver() c := &dns.Client{Timeout: dnsLookupTimeout} for name := fqdn; name != "" && name != "."; { msg := new(dns.Msg) msg.SetQuestion(name, dns.TypeCAA) msg.RecursionDesired = true in, _, err := c.ExchangeContext(ctx, msg, resolver) if err != nil { return nil, err } if in.Rcode == dns.RcodeNameError { return nil, nil } if in.Rcode != dns.RcodeSuccess { 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. Falls back to // 1.1.1.1:53 when resolv.conf is missing, unreadable, or empty. func systemResolver() string { cfg, err := dns.ClientConfigFromFile("/etc/resolv.conf") if err != nil || len(cfg.Servers) == 0 { return net.JoinHostPort("1.1.1.1", "53") } return net.JoinHostPort(cfg.Servers[0], cfg.Port) }