Initial commit
This commit is contained in:
commit
542ebdea34
40 changed files with 4592 additions and 0 deletions
254
checker/interactive.go
Normal file
254
checker/interactive.go
Normal file
|
|
@ -0,0 +1,254 @@
|
|||
// 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
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue