Initial commit
This commit is contained in:
commit
f27b7397f7
20 changed files with 1471 additions and 0 deletions
63
checker/collect.go
Normal file
63
checker/collect.go
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
package checker
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
sdk "git.happydns.org/checker-sdk-go/checker"
|
||||
"git.happydns.org/checker-tls/contract"
|
||||
)
|
||||
|
||||
func (p *tlsProvider) Collect(ctx context.Context, opts sdk.CheckerOptions) (any, error) {
|
||||
raw, ok := sdk.GetOption[[]sdk.DiscoveryEntry](opts, OptionEndpoints)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("no discovery entries in options: did the host wire AutoFillDiscoveryEntries?")
|
||||
}
|
||||
|
||||
timeoutMs := sdk.GetIntOption(opts, OptionProbeTimeoutMs, DefaultProbeTimeoutMs)
|
||||
if timeoutMs <= 0 {
|
||||
timeoutMs = DefaultProbeTimeoutMs
|
||||
}
|
||||
timeout := time.Duration(timeoutMs) * time.Millisecond
|
||||
|
||||
entries, warnings := contract.ParseEntries(raw)
|
||||
for _, w := range warnings {
|
||||
log.Printf("checker-tls: discarding malformed entry: %v", w)
|
||||
}
|
||||
// An empty entry set is not an error: it is the steady state on any
|
||||
// target where no producer has published yet, and the first run after
|
||||
// a fresh publication when the producer hasn't finished its own cycle.
|
||||
// The rule surfaces this as StatusUnknown rather than StatusError so a
|
||||
// freshly-enrolled domain doesn't flap red.
|
||||
if len(entries) == 0 {
|
||||
return &TLSData{Probes: map[string]TLSProbe{}, CollectedAt: time.Now()}, nil
|
||||
}
|
||||
|
||||
probes := make(map[string]TLSProbe, len(entries))
|
||||
var mu sync.Mutex
|
||||
var wg sync.WaitGroup
|
||||
sem := make(chan struct{}, MaxConcurrentProbes)
|
||||
for _, e := range entries {
|
||||
wg.Add(1)
|
||||
sem <- struct{}{}
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
defer func() { <-sem }()
|
||||
pr := probe(ctx, e.Endpoint, timeout)
|
||||
log.Printf("checker-tls: %s %s:%d → tls=%s issues=%d elapsed=%dms err=%q",
|
||||
pr.Type, pr.Host, pr.Port, pr.TLSVersion, len(pr.Issues), pr.ElapsedMS, pr.Error)
|
||||
mu.Lock()
|
||||
probes[e.Ref] = pr
|
||||
mu.Unlock()
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
return &TLSData{
|
||||
Probes: probes,
|
||||
CollectedAt: time.Now(),
|
||||
}, nil
|
||||
}
|
||||
52
checker/definition.go
Normal file
52
checker/definition.go
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
package checker
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
sdk "git.happydns.org/checker-sdk-go/checker"
|
||||
)
|
||||
|
||||
// Version defaults to "built-in"; standalone and plugin builds override it via
|
||||
// -ldflags "-X .../checker.Version=...".
|
||||
var Version = "built-in"
|
||||
|
||||
// Definition returns the CheckerDefinition for the TLS checker.
|
||||
func (p *tlsProvider) Definition() *sdk.CheckerDefinition {
|
||||
return &sdk.CheckerDefinition{
|
||||
ID: "tls",
|
||||
Name: "TLS",
|
||||
Version: Version,
|
||||
Availability: sdk.CheckerAvailability{
|
||||
ApplyToDomain: true,
|
||||
},
|
||||
ObservationKeys: []sdk.ObservationKey{ObservationKeyTLSProbes},
|
||||
Options: sdk.CheckerOptionsDocumentation{
|
||||
UserOpts: []sdk.CheckerOptionDocumentation{
|
||||
{
|
||||
Id: OptionProbeTimeoutMs,
|
||||
Type: "number",
|
||||
Label: "Per-endpoint probe timeout (ms)",
|
||||
Description: "Maximum time allowed for dial + STARTTLS + TLS handshake on a single endpoint.",
|
||||
Default: float64(DefaultProbeTimeoutMs),
|
||||
},
|
||||
},
|
||||
RunOpts: []sdk.CheckerOptionDocumentation{
|
||||
{
|
||||
Id: OptionEndpoints,
|
||||
Label: "Discovery entries",
|
||||
Description: "Entries published by other checkers for this domain; this checker decodes the tls.endpoint.v1 contract and ignores the rest.",
|
||||
AutoFill: sdk.AutoFillDiscoveryEntries,
|
||||
Hide: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
Rules: []sdk.CheckRule{
|
||||
Rule(),
|
||||
},
|
||||
Interval: &sdk.CheckIntervalSpec{
|
||||
Min: 6 * time.Hour,
|
||||
Max: 7 * 24 * time.Hour,
|
||||
Default: 24 * time.Hour,
|
||||
},
|
||||
}
|
||||
}
|
||||
231
checker/prober.go
Normal file
231
checker/prober.go
Normal file
|
|
@ -0,0 +1,231 @@
|
|||
package checker
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.happydns.org/checker-tls/contract"
|
||||
)
|
||||
|
||||
// probeTypeString renders the TLSProbe.Type string from a TLSEndpoint.
|
||||
// Observation consumers already parse this field in its "tls" /
|
||||
// "starttls-<proto>" shape; the contract-level split of direct vs.
|
||||
// STARTTLS is collapsed back here so the wire format of tls_probes
|
||||
// stays unchanged.
|
||||
func probeTypeString(ep contract.TLSEndpoint) string {
|
||||
if ep.STARTTLS == "" {
|
||||
return "tls"
|
||||
}
|
||||
return "starttls-" + ep.STARTTLS
|
||||
}
|
||||
|
||||
// probe performs a TLS handshake (or STARTTLS upgrade + handshake) on the
|
||||
// given endpoint and returns a populated TLSProbe. It never returns an error:
|
||||
// transport/handshake failures are recorded on the probe so the caller can
|
||||
// still surface them in the report.
|
||||
func probe(ctx context.Context, ep contract.TLSEndpoint, timeout time.Duration) TLSProbe {
|
||||
start := time.Now()
|
||||
host := strings.TrimSuffix(ep.Host, ".")
|
||||
addr := net.JoinHostPort(host, strconv.Itoa(int(ep.Port)))
|
||||
sni := ep.SNI
|
||||
if sni == "" {
|
||||
sni = host
|
||||
}
|
||||
|
||||
p := TLSProbe{
|
||||
Host: host,
|
||||
Port: ep.Port,
|
||||
Endpoint: addr,
|
||||
Type: probeTypeString(ep),
|
||||
SNI: sni,
|
||||
}
|
||||
|
||||
dialCtx, cancel := context.WithTimeout(ctx, timeout)
|
||||
defer cancel()
|
||||
|
||||
d := &net.Dialer{}
|
||||
conn, err := d.DialContext(dialCtx, "tcp", addr)
|
||||
if err != nil {
|
||||
p.Error = "dial: " + err.Error()
|
||||
p.Issues = append(p.Issues, Issue{
|
||||
Code: "tcp_unreachable",
|
||||
Severity: SeverityCrit,
|
||||
Message: fmt.Sprintf("Cannot open TCP connection to %s: %v", addr, err),
|
||||
Fix: "Check DNS, firewall, and that the service listens on this port.",
|
||||
})
|
||||
p.ElapsedMS = time.Since(start).Milliseconds()
|
||||
return p
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
if deadline, ok := dialCtx.Deadline(); ok {
|
||||
_ = conn.SetDeadline(deadline)
|
||||
}
|
||||
|
||||
tlsConn, err := handshake(conn, ep, sni)
|
||||
if err != nil {
|
||||
p.Error = err.Error()
|
||||
p.Issues = append(p.Issues, classifyHandshakeError(ep, err))
|
||||
p.ElapsedMS = time.Since(start).Milliseconds()
|
||||
return p
|
||||
}
|
||||
defer tlsConn.Close()
|
||||
|
||||
state := tlsConn.ConnectionState()
|
||||
p.TLSVersion = tls.VersionName(state.Version)
|
||||
p.CipherSuite = tls.CipherSuiteName(state.CipherSuite)
|
||||
|
||||
if len(state.PeerCertificates) == 0 {
|
||||
p.Issues = append(p.Issues, Issue{
|
||||
Code: "no_peer_cert",
|
||||
Severity: SeverityCrit,
|
||||
Message: "Server presented no certificate.",
|
||||
})
|
||||
p.ElapsedMS = time.Since(start).Milliseconds()
|
||||
return p
|
||||
}
|
||||
|
||||
leaf := state.PeerCertificates[0]
|
||||
p.NotAfter = leaf.NotAfter
|
||||
p.Issuer = leaf.Issuer.CommonName
|
||||
p.Subject = leaf.Subject.CommonName
|
||||
p.DNSNames = append(p.DNSNames, leaf.DNSNames...)
|
||||
|
||||
hostnameMatch := leaf.VerifyHostname(sni) == nil
|
||||
p.HostnameMatch = &hostnameMatch
|
||||
|
||||
// Chain verification against system roots, using intermediates presented
|
||||
// by the server. We run this independently from Go's tls.Config
|
||||
// verification so we can report a dedicated "chain invalid" issue rather
|
||||
// than failing the whole handshake.
|
||||
intermediates := x509.NewCertPool()
|
||||
for _, c := range state.PeerCertificates[1:] {
|
||||
intermediates.AddCert(c)
|
||||
}
|
||||
now := time.Now()
|
||||
_, verifyErr := leaf.Verify(x509.VerifyOptions{
|
||||
DNSName: sni,
|
||||
Intermediates: intermediates,
|
||||
CurrentTime: now,
|
||||
})
|
||||
chainValid := verifyErr == nil
|
||||
p.ChainValid = &chainValid
|
||||
if !chainValid {
|
||||
msg := "Invalid certificate chain"
|
||||
if verifyErr != nil {
|
||||
msg = "Invalid certificate chain: " + verifyErr.Error()
|
||||
}
|
||||
p.Issues = append(p.Issues, Issue{
|
||||
Code: "chain_invalid",
|
||||
Severity: SeverityCrit,
|
||||
Message: msg,
|
||||
Fix: "Serve the full intermediate chain and ensure the root is trusted.",
|
||||
})
|
||||
}
|
||||
if !hostnameMatch {
|
||||
p.Issues = append(p.Issues, Issue{
|
||||
Code: "hostname_mismatch",
|
||||
Severity: SeverityCrit,
|
||||
Message: fmt.Sprintf("Certificate does not cover %q (SANs: %s)", sni, strings.Join(leaf.DNSNames, ", ")),
|
||||
Fix: "Re-issue the certificate with a matching SAN.",
|
||||
})
|
||||
}
|
||||
if leaf.NotAfter.Before(now) {
|
||||
p.Issues = append(p.Issues, Issue{
|
||||
Code: "expired",
|
||||
Severity: SeverityCrit,
|
||||
Message: "Certificate expired on " + leaf.NotAfter.Format(time.RFC3339),
|
||||
Fix: "Renew the certificate.",
|
||||
})
|
||||
} else if leaf.NotAfter.Sub(now) < 14*24*time.Hour {
|
||||
p.Issues = append(p.Issues, Issue{
|
||||
Code: "expiring_soon",
|
||||
Severity: SeverityWarn,
|
||||
Message: "Certificate expires in less than 14 days (" + leaf.NotAfter.Format(time.RFC3339) + ")",
|
||||
Fix: "Renew before expiry.",
|
||||
})
|
||||
}
|
||||
if state.Version < tls.VersionTLS12 {
|
||||
p.Issues = append(p.Issues, Issue{
|
||||
Code: "weak_tls_version",
|
||||
Severity: SeverityWarn,
|
||||
Message: "Negotiated TLS version " + p.TLSVersion + " is below the recommended TLS 1.2.",
|
||||
Fix: "Disable TLS 1.0/1.1 on the server.",
|
||||
})
|
||||
}
|
||||
|
||||
p.ElapsedMS = time.Since(start).Milliseconds()
|
||||
return p
|
||||
}
|
||||
|
||||
// handshake performs STARTTLS upgrade (when ep.STARTTLS is non-empty) and
|
||||
// then a TLS handshake. InsecureSkipVerify is true on purpose: we verify
|
||||
// the chain separately in probe so an invalid chain becomes a structured
|
||||
// Issue rather than aborting the handshake.
|
||||
func handshake(conn net.Conn, ep contract.TLSEndpoint, sni string) (*tls.Conn, error) {
|
||||
cfg := &tls.Config{
|
||||
ServerName: sni,
|
||||
InsecureSkipVerify: true,
|
||||
}
|
||||
|
||||
if ep.STARTTLS == "" {
|
||||
tlsConn := tls.Client(conn, cfg)
|
||||
if err := tlsConn.Handshake(); err != nil {
|
||||
return nil, fmt.Errorf("tls-handshake: %w", err)
|
||||
}
|
||||
return tlsConn, nil
|
||||
}
|
||||
|
||||
up, ok := starttlsUpgraders[ep.STARTTLS]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("unsupported starttls protocol %q", ep.STARTTLS)
|
||||
}
|
||||
if err := up(conn, sni); err != nil {
|
||||
return nil, fmt.Errorf("starttls-%s: %w", ep.STARTTLS, err)
|
||||
}
|
||||
tlsConn := tls.Client(conn, cfg)
|
||||
if err := tlsConn.Handshake(); err != nil {
|
||||
return nil, fmt.Errorf("tls-handshake-after-starttls: %w", err)
|
||||
}
|
||||
return tlsConn, nil
|
||||
}
|
||||
|
||||
// classifyHandshakeError converts a dial/handshake error into a structured
|
||||
// Issue, distinguishing "server doesn't offer STARTTLS" (which is opportunistic
|
||||
// for some endpoints) from hard failures.
|
||||
func classifyHandshakeError(ep contract.TLSEndpoint, err error) Issue {
|
||||
msg := err.Error()
|
||||
|
||||
if ep.STARTTLS != "" && isStartTLSUnsupported(err) {
|
||||
sev := SeverityWarn
|
||||
if ep.RequireSTARTTLS {
|
||||
sev = SeverityCrit
|
||||
}
|
||||
return Issue{
|
||||
Code: "starttls_not_offered",
|
||||
Severity: sev,
|
||||
Message: fmt.Sprintf("Server on %s:%d does not advertise STARTTLS: %s", ep.Host, ep.Port, msg),
|
||||
Fix: "Enable STARTTLS on the server or publish a direct-TLS endpoint.",
|
||||
}
|
||||
}
|
||||
|
||||
return Issue{
|
||||
Code: "handshake_failed",
|
||||
Severity: SeverityCrit,
|
||||
Message: fmt.Sprintf("TLS handshake failed on %s:%d: %s", ep.Host, ep.Port, msg),
|
||||
Fix: "Inspect the server's TLS configuration and certificate.",
|
||||
}
|
||||
}
|
||||
|
||||
var errStartTLSNotOffered = errors.New("starttls not advertised by server")
|
||||
|
||||
func isStartTLSUnsupported(err error) bool {
|
||||
return errors.Is(err, errStartTLSNotOffered)
|
||||
}
|
||||
95
checker/prober_test.go
Normal file
95
checker/prober_test.go
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
package checker
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.happydns.org/checker-tls/contract"
|
||||
)
|
||||
|
||||
func TestProbe_DirectTLS_OK(t *testing.T) {
|
||||
srv := httptest.NewTLSServer(nil)
|
||||
defer srv.Close()
|
||||
|
||||
u, _ := url.Parse(srv.URL)
|
||||
host, portStr, _ := net.SplitHostPort(u.Host)
|
||||
port, _ := strconv.ParseUint(portStr, 10, 16)
|
||||
|
||||
probe := probe(context.Background(), contract.TLSEndpoint{
|
||||
Host: host,
|
||||
Port: uint16(port),
|
||||
SNI: host,
|
||||
}, 5*time.Second)
|
||||
|
||||
if probe.Error != "" {
|
||||
t.Fatalf("unexpected error: %s", probe.Error)
|
||||
}
|
||||
if probe.TLSVersion == "" {
|
||||
t.Errorf("expected TLSVersion, got empty")
|
||||
}
|
||||
if probe.CipherSuite == "" {
|
||||
t.Errorf("expected CipherSuite, got empty")
|
||||
}
|
||||
if probe.ChainValid == nil || *probe.ChainValid {
|
||||
t.Errorf("httptest self-signed chain should NOT be valid (chain_valid=%v)", probe.ChainValid)
|
||||
}
|
||||
if probe.HostnameMatch == nil {
|
||||
t.Errorf("expected HostnameMatch to be populated")
|
||||
}
|
||||
if probe.NotAfter.IsZero() {
|
||||
t.Errorf("expected NotAfter populated")
|
||||
}
|
||||
}
|
||||
|
||||
func TestProbe_TCPUnreachable(t *testing.T) {
|
||||
// Grab a free port then immediately close it so we know nothing listens.
|
||||
l, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
addr := l.Addr().(*net.TCPAddr)
|
||||
_ = l.Close()
|
||||
|
||||
probe := probe(context.Background(), contract.TLSEndpoint{
|
||||
Host: "127.0.0.1",
|
||||
Port: uint16(addr.Port),
|
||||
}, 1*time.Second)
|
||||
|
||||
if probe.Error == "" {
|
||||
t.Errorf("expected an error for unreachable port")
|
||||
}
|
||||
if len(probe.Issues) == 0 || probe.Issues[0].Code != "tcp_unreachable" {
|
||||
t.Errorf("expected tcp_unreachable issue, got %+v", probe.Issues)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProbe_UnsupportedStartTLSProto(t *testing.T) {
|
||||
// Listen so the dial succeeds, but the type maps to an unknown proto.
|
||||
l, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer l.Close()
|
||||
go func() {
|
||||
c, err := l.Accept()
|
||||
if err == nil {
|
||||
c.Close()
|
||||
}
|
||||
}()
|
||||
|
||||
addr := l.Addr().(*net.TCPAddr)
|
||||
probe := probe(context.Background(), contract.TLSEndpoint{
|
||||
Host: "127.0.0.1",
|
||||
Port: uint16(addr.Port),
|
||||
STARTTLS: "totallyfake",
|
||||
}, 2*time.Second)
|
||||
|
||||
if probe.Error == "" {
|
||||
t.Errorf("expected handshake error for unsupported starttls protocol")
|
||||
}
|
||||
}
|
||||
14
checker/provider.go
Normal file
14
checker/provider.go
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
package checker
|
||||
|
||||
import sdk "git.happydns.org/checker-sdk-go/checker"
|
||||
|
||||
// Provider returns a new TLS observation provider.
|
||||
func Provider() sdk.ObservationProvider {
|
||||
return &tlsProvider{}
|
||||
}
|
||||
|
||||
type tlsProvider struct{}
|
||||
|
||||
func (p *tlsProvider) Key() sdk.ObservationKey {
|
||||
return ObservationKeyTLSProbes
|
||||
}
|
||||
137
checker/rule.go
Normal file
137
checker/rule.go
Normal file
|
|
@ -0,0 +1,137 @@
|
|||
package checker
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
sdk "git.happydns.org/checker-sdk-go/checker"
|
||||
)
|
||||
|
||||
// Rule returns the rule that aggregates per-endpoint TLS probe outcomes into
|
||||
// a single status for this checker run.
|
||||
func Rule() sdk.CheckRule {
|
||||
return &tlsRule{}
|
||||
}
|
||||
|
||||
type tlsRule struct{}
|
||||
|
||||
func (r *tlsRule) Name() string { return "tls_posture" }
|
||||
|
||||
func (r *tlsRule) Description() string {
|
||||
return "Summarises TLS handshake, certificate validity, hostname match and expiry across all probed endpoints"
|
||||
}
|
||||
|
||||
func (r *tlsRule) ValidateOptions(opts sdk.CheckerOptions) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *tlsRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, opts sdk.CheckerOptions) sdk.CheckState {
|
||||
var data TLSData
|
||||
if err := obs.Get(ctx, ObservationKeyTLSProbes, &data); err != nil {
|
||||
return sdk.CheckState{
|
||||
Status: sdk.StatusError,
|
||||
Message: fmt.Sprintf("Failed to read tls_probes: %v", err),
|
||||
Code: "tls_observation_error",
|
||||
}
|
||||
}
|
||||
|
||||
var (
|
||||
total = len(data.Probes)
|
||||
okCount int
|
||||
warnCount int
|
||||
critCount int
|
||||
firstCrit string
|
||||
firstWarn string
|
||||
)
|
||||
|
||||
// Steady state when no producer has published entries for this target
|
||||
// yet (or when the last producer run cleared them). Report Unknown so
|
||||
// we don't flap red during the eventual-consistency window between a
|
||||
// fresh enrollment and the first producer cycle.
|
||||
if total == 0 {
|
||||
return sdk.CheckState{
|
||||
Status: sdk.StatusUnknown,
|
||||
Message: "No TLS endpoints have been discovered for this target yet",
|
||||
Code: "tls_no_endpoints",
|
||||
}
|
||||
}
|
||||
for _, p := range data.Probes {
|
||||
worst, critMsg, warnMsg := summarize(p.Issues)
|
||||
switch worst {
|
||||
case SeverityCrit:
|
||||
critCount++
|
||||
if firstCrit == "" {
|
||||
firstCrit = fmt.Sprintf("%s (%s)", p.Endpoint, critMsg)
|
||||
}
|
||||
case SeverityWarn:
|
||||
warnCount++
|
||||
if firstWarn == "" {
|
||||
firstWarn = fmt.Sprintf("%s (%s)", p.Endpoint, warnMsg)
|
||||
}
|
||||
default:
|
||||
okCount++
|
||||
}
|
||||
}
|
||||
|
||||
meta := map[string]any{
|
||||
"probes": total,
|
||||
"ok": okCount,
|
||||
"warn": warnCount,
|
||||
"crit": critCount,
|
||||
}
|
||||
|
||||
switch {
|
||||
case critCount > 0:
|
||||
return sdk.CheckState{
|
||||
Status: sdk.StatusCrit,
|
||||
Message: fmt.Sprintf("%d/%d TLS endpoint(s) have critical issues: %s", critCount, total, firstCrit),
|
||||
Code: "tls_critical",
|
||||
Meta: meta,
|
||||
}
|
||||
case warnCount > 0:
|
||||
return sdk.CheckState{
|
||||
Status: sdk.StatusWarn,
|
||||
Message: fmt.Sprintf("%d/%d TLS endpoint(s) have warnings: %s", warnCount, total, firstWarn),
|
||||
Code: "tls_warning",
|
||||
Meta: meta,
|
||||
}
|
||||
default:
|
||||
return sdk.CheckState{
|
||||
Status: sdk.StatusOK,
|
||||
Message: fmt.Sprintf("%d TLS endpoint(s) OK", total),
|
||||
Code: "tls_ok",
|
||||
Meta: meta,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// summarize walks the issues once and returns (worst severity, first
|
||||
// critical message, first warning message). Picking the messages during the
|
||||
// same pass avoids a second iteration in the caller.
|
||||
func summarize(issues []Issue) (worst, firstCrit, firstWarn string) {
|
||||
for _, is := range issues {
|
||||
msg := is.Message
|
||||
if msg == "" {
|
||||
msg = is.Code
|
||||
}
|
||||
switch is.Severity {
|
||||
case SeverityCrit:
|
||||
worst = SeverityCrit
|
||||
if firstCrit == "" {
|
||||
firstCrit = msg
|
||||
}
|
||||
case SeverityWarn:
|
||||
if worst == "" || worst == SeverityInfo {
|
||||
worst = SeverityWarn
|
||||
}
|
||||
if firstWarn == "" {
|
||||
firstWarn = msg
|
||||
}
|
||||
case SeverityInfo:
|
||||
if worst == "" {
|
||||
worst = SeverityInfo
|
||||
}
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
281
checker/starttls.go
Normal file
281
checker/starttls.go
Normal file
|
|
@ -0,0 +1,281 @@
|
|||
package checker
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"encoding/xml"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// starttlsUpgrader performs the plaintext portion of a STARTTLS upgrade on
|
||||
// conn, leaving conn ready for tls.Client(conn, …).Handshake(). On success
|
||||
// the returned function returns nil; on failure it returns a descriptive
|
||||
// error (wrap errStartTLSNotOffered when the server advertises no STARTTLS).
|
||||
type starttlsUpgrader func(conn net.Conn, sni string) error
|
||||
|
||||
var starttlsUpgraders = map[string]starttlsUpgrader{
|
||||
"smtp": starttlsSMTP,
|
||||
"submission": starttlsSMTP,
|
||||
"imap": starttlsIMAP,
|
||||
"pop3": starttlsPOP3,
|
||||
"xmpp-client": starttlsXMPPClient,
|
||||
"xmpp-server": starttlsXMPPServer,
|
||||
}
|
||||
|
||||
// starttlsSMTP implements ESMTP EHLO + STARTTLS (RFC 3207).
|
||||
func starttlsSMTP(conn net.Conn, sni string) error {
|
||||
rw := bufio.NewReadWriter(bufio.NewReader(conn), bufio.NewWriter(conn))
|
||||
|
||||
if err := readSMTPGreeting(rw.Reader); err != nil {
|
||||
return fmt.Errorf("read greeting: %w", err)
|
||||
}
|
||||
|
||||
if _, err := rw.WriteString("EHLO checker.happydomain.org\r\n"); err != nil {
|
||||
return fmt.Errorf("write ehlo: %w", err)
|
||||
}
|
||||
if err := rw.Flush(); err != nil {
|
||||
return fmt.Errorf("flush ehlo: %w", err)
|
||||
}
|
||||
lines, err := readSMTPResponse(rw.Reader)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read ehlo: %w", err)
|
||||
}
|
||||
if !hasSTARTTLSExt(lines) {
|
||||
return fmt.Errorf("%w: EHLO did not advertise STARTTLS", errStartTLSNotOffered)
|
||||
}
|
||||
|
||||
if _, err := rw.WriteString("STARTTLS\r\n"); err != nil {
|
||||
return fmt.Errorf("write starttls: %w", err)
|
||||
}
|
||||
if err := rw.Flush(); err != nil {
|
||||
return fmt.Errorf("flush starttls: %w", err)
|
||||
}
|
||||
resp, err := readSMTPResponse(rw.Reader)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read starttls: %w", err)
|
||||
}
|
||||
if len(resp) == 0 || !strings.HasPrefix(resp[0], "220") {
|
||||
return fmt.Errorf("server refused STARTTLS: %s", strings.Join(resp, " / "))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func readSMTPGreeting(r *bufio.Reader) error {
|
||||
_, err := readSMTPResponse(r)
|
||||
return err
|
||||
}
|
||||
|
||||
// readSMTPResponse reads one multi-line SMTP response (lines with "NNN-" are
|
||||
// continuation, "NNN " terminates).
|
||||
func readSMTPResponse(r *bufio.Reader) ([]string, error) {
|
||||
var out []string
|
||||
for {
|
||||
line, err := r.ReadString('\n')
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
line = strings.TrimRight(line, "\r\n")
|
||||
out = append(out, line)
|
||||
if len(line) < 4 || line[3] == ' ' {
|
||||
return out, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func hasSTARTTLSExt(lines []string) bool {
|
||||
for _, l := range lines {
|
||||
if len(l) < 4 {
|
||||
continue
|
||||
}
|
||||
rest := strings.ToUpper(strings.TrimSpace(l[4:]))
|
||||
if rest == "STARTTLS" || strings.HasPrefix(rest, "STARTTLS ") {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// starttlsIMAP implements RFC 3501 STARTTLS.
|
||||
func starttlsIMAP(conn net.Conn, sni string) error {
|
||||
rw := bufio.NewReadWriter(bufio.NewReader(conn), bufio.NewWriter(conn))
|
||||
|
||||
if _, err := rw.ReadString('\n'); err != nil {
|
||||
return fmt.Errorf("read greeting: %w", err)
|
||||
}
|
||||
|
||||
if _, err := rw.WriteString("A001 CAPABILITY\r\n"); err != nil {
|
||||
return fmt.Errorf("write CAPABILITY: %w", err)
|
||||
}
|
||||
if err := rw.Flush(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
supportsSTARTTLS := false
|
||||
for {
|
||||
line, err := rw.ReadString('\n')
|
||||
if err != nil {
|
||||
return fmt.Errorf("read CAPABILITY: %w", err)
|
||||
}
|
||||
if strings.Contains(strings.ToUpper(line), "STARTTLS") {
|
||||
supportsSTARTTLS = true
|
||||
}
|
||||
if strings.HasPrefix(line, "A001 ") {
|
||||
break
|
||||
}
|
||||
}
|
||||
if !supportsSTARTTLS {
|
||||
return fmt.Errorf("%w: IMAP CAPABILITY did not advertise STARTTLS", errStartTLSNotOffered)
|
||||
}
|
||||
|
||||
if _, err := rw.WriteString("A002 STARTTLS\r\n"); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := rw.Flush(); err != nil {
|
||||
return err
|
||||
}
|
||||
for {
|
||||
line, err := rw.ReadString('\n')
|
||||
if err != nil {
|
||||
return fmt.Errorf("read STARTTLS response: %w", err)
|
||||
}
|
||||
if strings.HasPrefix(line, "A002 OK") {
|
||||
return nil
|
||||
}
|
||||
if strings.HasPrefix(line, "A002 ") {
|
||||
return fmt.Errorf("server refused STARTTLS: %s", strings.TrimSpace(line))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// starttlsPOP3 implements RFC 2595 STLS.
|
||||
func starttlsPOP3(conn net.Conn, sni string) error {
|
||||
rw := bufio.NewReadWriter(bufio.NewReader(conn), bufio.NewWriter(conn))
|
||||
|
||||
greeting, err := rw.ReadString('\n')
|
||||
if err != nil {
|
||||
return fmt.Errorf("read greeting: %w", err)
|
||||
}
|
||||
if !strings.HasPrefix(greeting, "+OK") {
|
||||
return fmt.Errorf("unexpected POP3 greeting: %s", strings.TrimSpace(greeting))
|
||||
}
|
||||
|
||||
if _, err := rw.WriteString("CAPA\r\n"); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := rw.Flush(); err != nil {
|
||||
return err
|
||||
}
|
||||
first, err := rw.ReadString('\n')
|
||||
if err != nil {
|
||||
return fmt.Errorf("read CAPA: %w", err)
|
||||
}
|
||||
supportsSTLS := false
|
||||
if strings.HasPrefix(first, "+OK") {
|
||||
for {
|
||||
line, err := rw.ReadString('\n')
|
||||
if err != nil {
|
||||
return fmt.Errorf("read CAPA body: %w", err)
|
||||
}
|
||||
line = strings.TrimRight(line, "\r\n")
|
||||
if line == "." {
|
||||
break
|
||||
}
|
||||
if strings.EqualFold(line, "STLS") {
|
||||
supportsSTLS = true
|
||||
}
|
||||
}
|
||||
}
|
||||
if !supportsSTLS {
|
||||
return fmt.Errorf("%w: POP3 CAPA did not advertise STLS", errStartTLSNotOffered)
|
||||
}
|
||||
|
||||
if _, err := rw.WriteString("STLS\r\n"); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := rw.Flush(); err != nil {
|
||||
return err
|
||||
}
|
||||
resp, err := rw.ReadString('\n')
|
||||
if err != nil {
|
||||
return fmt.Errorf("read STLS response: %w", err)
|
||||
}
|
||||
if !strings.HasPrefix(resp, "+OK") {
|
||||
return fmt.Errorf("server refused STLS: %s", strings.TrimSpace(resp))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// starttlsXMPPClient implements RFC 6120 STARTTLS for c2s streams.
|
||||
func starttlsXMPPClient(conn net.Conn, sni string) error {
|
||||
return starttlsXMPP(conn, sni, "jabber:client")
|
||||
}
|
||||
|
||||
// starttlsXMPPServer implements RFC 6120 STARTTLS for s2s streams.
|
||||
func starttlsXMPPServer(conn net.Conn, sni string) error {
|
||||
return starttlsXMPP(conn, sni, "jabber:server")
|
||||
}
|
||||
|
||||
func starttlsXMPP(conn net.Conn, sni, ns string) error {
|
||||
header := fmt.Sprintf(`<?xml version='1.0'?><stream:stream xmlns='%s' xmlns:stream='http://etherx.jabber.org/streams' version='1.0' to='%s'>`, ns, sni)
|
||||
if _, err := io.WriteString(conn, header); err != nil {
|
||||
return fmt.Errorf("write stream header: %w", err)
|
||||
}
|
||||
|
||||
dec := xml.NewDecoder(conn)
|
||||
|
||||
// Read the inbound <stream:stream> opening and its <stream:features>.
|
||||
hasStartTLS := false
|
||||
for {
|
||||
tok, err := dec.Token()
|
||||
if err != nil {
|
||||
return fmt.Errorf("read stream features: %w", err)
|
||||
}
|
||||
if se, ok := tok.(xml.StartElement); ok {
|
||||
if se.Name.Local == "features" {
|
||||
// Scan features children.
|
||||
for {
|
||||
t2, err := dec.Token()
|
||||
if err != nil {
|
||||
return fmt.Errorf("read features body: %w", err)
|
||||
}
|
||||
switch ee := t2.(type) {
|
||||
case xml.StartElement:
|
||||
if ee.Name.Local == "starttls" {
|
||||
hasStartTLS = true
|
||||
}
|
||||
_ = dec.Skip()
|
||||
case xml.EndElement:
|
||||
if ee.Name.Local == "features" {
|
||||
goto doneFeatures
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
doneFeatures:
|
||||
if !hasStartTLS {
|
||||
return fmt.Errorf("%w: XMPP features did not advertise starttls", errStartTLSNotOffered)
|
||||
}
|
||||
|
||||
if _, err := io.WriteString(conn, `<starttls xmlns='urn:ietf:params:xml:ns:xmpp-tls'/>`); err != nil {
|
||||
return fmt.Errorf("write starttls: %w", err)
|
||||
}
|
||||
|
||||
for {
|
||||
tok, err := dec.Token()
|
||||
if err != nil {
|
||||
return fmt.Errorf("read proceed: %w", err)
|
||||
}
|
||||
if se, ok := tok.(xml.StartElement); ok {
|
||||
switch se.Name.Local {
|
||||
case "proceed":
|
||||
return nil
|
||||
case "failure":
|
||||
return fmt.Errorf("server refused STARTTLS (<failure/>)")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
65
checker/types.go
Normal file
65
checker/types.go
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
// Package checker implements a TLS checker for happyDomain. See README for
|
||||
// the payload shape and consumer contract.
|
||||
package checker
|
||||
|
||||
import "time"
|
||||
|
||||
// ObservationKeyTLSProbes is the observation key this checker writes.
|
||||
const ObservationKeyTLSProbes = "tls_probes"
|
||||
|
||||
// Option ids on CheckerOptions.
|
||||
const (
|
||||
OptionEndpoints = "endpoints"
|
||||
OptionProbeTimeoutMs = "probeTimeoutMs"
|
||||
)
|
||||
|
||||
// Defaults shared between the definition's Default field and the runtime
|
||||
// fallback when probeTimeoutMs is unset or invalid.
|
||||
const (
|
||||
DefaultProbeTimeoutMs = 10000
|
||||
// MaxConcurrentProbes caps parallel probes per collect run to avoid
|
||||
// exhausting file descriptors on domains with many endpoints.
|
||||
MaxConcurrentProbes = 32
|
||||
)
|
||||
|
||||
// Severity values used in Issue.Severity (lowercase, ascii).
|
||||
const (
|
||||
SeverityCrit = "crit"
|
||||
SeverityWarn = "warn"
|
||||
SeverityInfo = "info"
|
||||
)
|
||||
|
||||
// TLSData is the full collected payload written under ObservationKeyTLSProbes.
|
||||
type TLSData struct {
|
||||
Probes map[string]TLSProbe `json:"probes"`
|
||||
CollectedAt time.Time `json:"collected_at"`
|
||||
}
|
||||
|
||||
// TLSProbe captures the outcome of probing a single endpoint. Field names
|
||||
// mirror what consumers already parse (checker-xmpp's tlsProbeView).
|
||||
type TLSProbe struct {
|
||||
Host string `json:"host"`
|
||||
Port uint16 `json:"port"`
|
||||
Endpoint string `json:"endpoint"`
|
||||
Type string `json:"type"`
|
||||
SNI string `json:"sni,omitempty"`
|
||||
TLSVersion string `json:"tls_version,omitempty"`
|
||||
CipherSuite string `json:"cipher_suite,omitempty"`
|
||||
HostnameMatch *bool `json:"hostname_match,omitempty"`
|
||||
ChainValid *bool `json:"chain_valid,omitempty"`
|
||||
NotAfter time.Time `json:"not_after,omitempty"`
|
||||
Issuer string `json:"issuer,omitempty"`
|
||||
Subject string `json:"subject,omitempty"`
|
||||
DNSNames []string `json:"dns_names,omitempty"`
|
||||
ElapsedMS int64 `json:"elapsed_ms,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
Issues []Issue `json:"issues,omitempty"`
|
||||
}
|
||||
|
||||
// Issue is a single TLS finding surfaced to the consumer.
|
||||
type Issue struct {
|
||||
Code string `json:"code"`
|
||||
Severity string `json:"severity"`
|
||||
Message string `json:"message,omitempty"`
|
||||
Fix string `json:"fix,omitempty"`
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue