254 lines
7 KiB
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
|
|
}
|