// This file is part of the happyDomain (R) project. // Copyright (c) 2020-2026 happyDomain // Authors: Pierre-Olivier Mercier, et al. //go:build standalone package checker import ( "encoding/json" "errors" "fmt" "net" "net/http" "os" "strconv" "strings" "github.com/miekg/dns" sdk "git.happydns.org/checker-sdk-go/checker" happydns "git.happydns.org/happyDomain/model" "git.happydns.org/happyDomain/services/abstract" ) // RenderForm implements server.Interactive: the human-facing form // exposed at GET /check when the checker runs as a standalone binary. func (p *httpProvider) RenderForm() []sdk.CheckerOptionField { return []sdk.CheckerOptionField{ { Id: "domain", Type: "string", Label: "Host name", Placeholder: "www.example.com", Required: true, Description: "The HTTP/HTTPS server hostname to probe. A/AAAA records are looked up live.", }, { Id: OptionProbeTimeoutMs, Type: "number", Label: "Per-request timeout (ms)", Description: "Maximum time allowed for a single HTTP/HTTPS request.", Default: float64(DefaultProbeTimeoutMs), }, { Id: OptionMaxRedirects, Type: "number", Label: "Max redirects to follow", Description: "Stop following redirects after this many hops.", Default: float64(DefaultMaxRedirects), }, { Id: OptionUserAgent, Type: "string", Label: "User-Agent", Description: "User-Agent header sent with every request.", Default: DefaultUserAgent, }, { Id: OptionRequireHTTPS, Type: "bool", Label: "Require HTTPS", Description: "Plain HTTP must redirect to HTTPS.", Default: true, }, { Id: OptionRequireHSTS, Type: "bool", Label: "Require HSTS", Description: "HTTPS responses must include a Strict-Transport-Security header.", Default: true, }, { Id: OptionMinHSTSMaxAgeDays, Type: "number", Label: "Min HSTS max-age (days)", Description: "Minimum acceptable max-age value (in days) for HSTS.", Default: float64(DefaultMinHSTSMaxAge), }, { Id: OptionRequireCSP, Type: "bool", Label: "Require Content-Security-Policy", Description: "HTTPS responses must include a Content-Security-Policy header.", Default: false, }, } } // ParseForm implements server.Interactive: resolves the submitted // hostname into an abstract.Server payload and wraps it in the // ServiceMessage shape that Collect expects. func (p *httpProvider) ParseForm(r *http.Request) (sdk.CheckerOptions, error) { domain := strings.TrimSpace(r.FormValue("domain")) domain = strings.TrimSuffix(domain, ".") if domain == "" { return nil, errors.New("host name is required") } fqdn := dns.Fqdn(domain) resolver, err := systemResolver() if err != nil { return nil, fmt.Errorf("resolver: %w", err) } server := &abstract.Server{} if a, err := lookupA(resolver, fqdn); err != nil { return nil, fmt.Errorf("A lookup for %s: %w", domain, err) } else if a != nil { server.A = a } if aaaa, err := lookupAAAA(resolver, fqdn); err != nil { return nil, fmt.Errorf("AAAA lookup for %s: %w", domain, err) } else if aaaa != nil { server.AAAA = aaaa } if server.A == nil && server.AAAA == nil { return nil, fmt.Errorf("no A/AAAA records found for %s", domain) } svcBody, err := json.Marshal(server) if err != nil { return nil, fmt.Errorf("marshal abstract.Server: %w", err) } opts := sdk.CheckerOptions{ OptionService: happydns.ServiceMessage{ ServiceMeta: happydns.ServiceMeta{ Type: "abstract.Server", Domain: domain, }, Service: svcBody, }, } if raw := strings.TrimSpace(r.FormValue(OptionProbeTimeoutMs)); raw != "" { v, err := strconv.ParseFloat(raw, 64) if err != nil { return nil, errors.New("timeout must be a number") } opts[OptionProbeTimeoutMs] = v } if raw := strings.TrimSpace(r.FormValue(OptionMaxRedirects)); raw != "" { v, err := strconv.ParseFloat(raw, 64) if err != nil { return nil, errors.New("max redirects must be a number") } opts[OptionMaxRedirects] = v } if v := strings.TrimSpace(r.FormValue(OptionUserAgent)); v != "" { opts[OptionUserAgent] = v } if raw := strings.TrimSpace(r.FormValue(OptionMinHSTSMaxAgeDays)); raw != "" { v, err := strconv.ParseFloat(raw, 64) if err != nil { return nil, errors.New("HSTS max-age must be a number") } opts[OptionMinHSTSMaxAgeDays] = v } opts[OptionRequireHTTPS] = parseInteractiveBool(r, OptionRequireHTTPS, true) opts[OptionRequireHSTS] = parseInteractiveBool(r, OptionRequireHSTS, true) opts[OptionRequireCSP] = parseInteractiveBool(r, OptionRequireCSP, false) return opts, nil } // parseInteractiveBool reads a checkbox-style field. HTML forms omit // unchecked checkboxes entirely, so a missing key means false if the // form was submitted (detected via the required "domain" field). func parseInteractiveBool(r *http.Request, key string, def bool) bool { if _, ok := r.Form[key]; !ok { if _, submitted := r.Form["domain"]; submitted { return false } return def } v := strings.ToLower(strings.TrimSpace(r.FormValue(key))) switch v { case "", "0", "false", "off", "no": return false default: return true } } // systemResolver picks a DNS server to send explicit A/AAAA queries to. // Resolution order: // 1. CHECKER_DNS_RESOLVER env var (host or host:port) // 2. The OS resolver config when one exists (resolvConfPath) // 3. 1.1.1.1:53 as a last-resort public fallback func systemResolver() (string, error) { if env := strings.TrimSpace(os.Getenv("CHECKER_DNS_RESOLVER")); env != "" { if _, _, err := net.SplitHostPort(env); err != nil { env = net.JoinHostPort(env, "53") } return env, nil } if path := resolvConfPath(); path != "" { if cfg, err := dns.ClientConfigFromFile(path); err == nil && len(cfg.Servers) > 0 { return net.JoinHostPort(cfg.Servers[0], cfg.Port), nil } } return net.JoinHostPort("1.1.1.1", "53"), nil } func resolvConfPath() string { for _, p := range []string{"/etc/resolv.conf"} { if _, err := os.Stat(p); err == nil { return p } } return "" } func dnsExchange(resolver, name string, qtype uint16) (*dns.Msg, error) { msg := new(dns.Msg) msg.SetQuestion(name, qtype) 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]) } return in, nil } func lookupA(resolver, fqdn string) (*dns.A, error) { in, err := dnsExchange(resolver, fqdn, dns.TypeA) if err != nil { return nil, err } for _, rr := range in.Answer { if a, ok := rr.(*dns.A); ok { return a, nil } } return nil, nil } func lookupAAAA(resolver, fqdn string) (*dns.AAAA, error) { in, err := dnsExchange(resolver, fqdn, dns.TypeAAAA) if err != nil { return nil, err } for _, rr := range in.Answer { if aaaa, ok := rr.(*dns.AAAA); ok { return aaaa, nil } } return nil, nil }