checker-email-keys/checker/interactive.go

175 lines
5 KiB
Go

//go:build standalone
package checker
import (
"context"
"encoding/json"
"fmt"
"net/http"
"strconv"
"strings"
"github.com/miekg/dns"
sdk "git.happydns.org/checker-sdk-go/checker"
"git.happydns.org/checker-sdk-go/checker/server"
)
// RenderForm implements server.Interactive. It exposes the minimal
// inputs needed to bootstrap a standalone OPENPGPKEY/SMIMEA check: an
// email address (the local part is hashed into the owner name) and a
// kind selector. The DNS resolver and severity-tuning options mirror
// the regular UserOpts so a human can override them on the form.
func (p *emailKeyProvider) RenderForm() []sdk.CheckerOptionField {
return []sdk.CheckerOptionField{
{
Id: "email",
Type: "string",
Label: "Email address",
Placeholder: "alice@example.com",
Description: "Address to look up. The local part is SHA-256-hashed per RFC 7929/8162; the domain part is the zone queried.",
Required: true,
},
{
Id: "kind",
Type: "string",
Label: "Record kind",
Default: KindOpenPGPKey,
Choices: []string{KindOpenPGPKey, KindSMIMEA},
},
{
Id: OptionResolver,
Type: "string",
Label: "DNS resolver",
Placeholder: "1.1.1.1",
Description: "Validating resolver to query (comma-separated list accepted). Defaults to the system resolver when empty.",
},
{
Id: OptionCertExpiryWarnDays,
Type: "number",
Label: "Expiry warning threshold (days)",
Description: "Emit a warning when the primary key or S/MIME certificate expires in less than this many days.",
Default: float64(30),
},
{
Id: OptionRequireDNSSEC,
Type: "bool",
Label: "Require DNSSEC",
Default: true,
},
{
Id: OptionRequireEmailProtection,
Type: "bool",
Label: "Require emailProtection EKU (SMIMEA only)",
Default: true,
},
}
}
// ParseForm implements server.Interactive. It validates the inputs,
// resolves the DNS record matching the requested kind, and returns the
// CheckerOptions that Collect expects, including a synthesised service
// envelope built from the live DNS answer.
func (p *emailKeyProvider) ParseForm(r *http.Request) (sdk.CheckerOptions, error) {
email := strings.TrimSpace(r.FormValue("email"))
if email == "" {
return nil, fmt.Errorf("email is required")
}
at := strings.LastIndex(email, "@")
if at <= 0 || at == len(email)-1 {
return nil, fmt.Errorf("email %q must be of the form local@domain", email)
}
username := email[:at]
domain := strings.TrimSuffix(strings.ToLower(email[at+1:]), ".")
kind := strings.TrimSpace(r.FormValue("kind"))
if kind == "" {
kind = KindOpenPGPKey
}
var (
svcType string
prefix string
qtype uint16
)
switch kind {
case KindOpenPGPKey:
svcType = ServiceOpenPGP
prefix = OpenPGPKeyPrefix
qtype = dns.TypeOPENPGPKEY
case KindSMIMEA:
svcType = ServiceSMimeCert
prefix = SMIMEACertPrefix
qtype = dns.TypeSMIMEA
default:
return nil, fmt.Errorf("unknown kind %q (expected %q or %q)", kind, KindOpenPGPKey, KindSMIMEA)
}
resolverOpt := strings.TrimSpace(r.FormValue(OptionResolver))
owner := dns.Fqdn(ownerHashHex(username) + "." + prefix + "." + domain)
ctx, cancel := context.WithTimeout(r.Context(), dnsTimeout*3)
defer cancel()
ans, err := lookup(ctx, resolvers(resolverOpt), owner, qtype)
if err != nil {
return nil, fmt.Errorf("DNS lookup for %s %s failed: %w", dns.TypeToString[qtype], owner, err)
}
if ans.Rcode == dns.RcodeNameError || len(ans.Records) == 0 {
return nil, fmt.Errorf("no %s record found at %s", dns.TypeToString[qtype], owner)
}
body := serviceBody{Username: username}
switch kind {
case KindOpenPGPKey:
rr, ok := ans.Records[0].(*dns.OPENPGPKEY)
if !ok {
return nil, fmt.Errorf("unexpected record type %T at %s", ans.Records[0], owner)
}
body.OpenPGP = rr
case KindSMIMEA:
rr, ok := ans.Records[0].(*dns.SMIMEA)
if !ok {
return nil, fmt.Errorf("unexpected record type %T at %s", ans.Records[0], owner)
}
body.SMIMEA = rr
}
bodyJSON, err := json.Marshal(body)
if err != nil {
return nil, fmt.Errorf("encode service body: %w", err)
}
svcMsg := serviceMessage{
Type: svcType,
Domain: dns.Fqdn(domain),
Service: bodyJSON,
}
opts := sdk.CheckerOptions{
"service": svcMsg,
"service_type": svcType,
"domain_name": domain,
}
if resolverOpt != "" {
opts[OptionResolver] = resolverOpt
}
if v := strings.TrimSpace(r.FormValue(OptionCertExpiryWarnDays)); v != "" {
opts[OptionCertExpiryWarnDays] = parseFloatOr(v, 30)
}
opts[OptionRequireDNSSEC] = r.FormValue(OptionRequireDNSSEC) == "true"
opts[OptionRequireEmailProtection] = r.FormValue(OptionRequireEmailProtection) == "true"
return opts, nil
}
// parseFloatOr parses a decimal string, returning fallback on error.
func parseFloatOr(s string, fallback float64) float64 {
f, err := strconv.ParseFloat(s, 64)
if err != nil {
return fallback
}
return f
}
// Compile-time assertion that the provider implements the optional interface.
var _ server.Interactive = (*emailKeyProvider)(nil)