checker-http/checker/interactive.go

254 lines
7 KiB
Go

// 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
}