175 lines
5 KiB
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)
|