Initial commit

This commit is contained in:
nemunaire 2026-04-21 21:50:49 +07:00
commit b27336a908
15 changed files with 1122 additions and 0 deletions

231
checker/prober.go Normal file
View file

@ -0,0 +1,231 @@
package checker
import (
"context"
"crypto/tls"
"crypto/x509"
"errors"
"fmt"
"net"
"strconv"
"strings"
"time"
)
// 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 endpointInput, 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: ep.Type,
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...)
// Hostname verification.
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)
}
_, verifyErr := leaf.Verify(x509.VerifyOptions{
DNSName: sni,
Intermediates: intermediates,
CurrentTime: time.Now(),
})
chainValid := verifyErr == nil
p.ChainValid = &chainValid
// Build structured issues.
now := time.Now()
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.Type requires it) 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 endpointInput, sni string) (*tls.Conn, error) {
cfg := &tls.Config{
ServerName: sni,
InsecureSkipVerify: true,
}
if ep.Type == "tls" {
tlsConn := tls.Client(conn, cfg)
if err := tlsConn.Handshake(); err != nil {
return nil, fmt.Errorf("tls-handshake: %w", err)
}
return tlsConn, nil
}
if !strings.HasPrefix(ep.Type, "starttls-") {
return nil, fmt.Errorf("unsupported endpoint type %q", ep.Type)
}
proto := strings.TrimPrefix(ep.Type, "starttls-")
up, ok := starttlsUpgraders[proto]
if !ok {
return nil, fmt.Errorf("unsupported starttls protocol %q", proto)
}
if err := up(conn, sni); err != nil {
return nil, fmt.Errorf("starttls-%s: %w", proto, 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 endpointInput, err error) Issue {
msg := err.Error()
opportunistic := false
if v, ok := ep.Meta["starttls"]; ok {
if s, ok := v.(string); ok && s == "opportunistic" {
opportunistic = true
}
}
if strings.HasPrefix(ep.Type, "starttls-") && isStartTLSUnsupported(err) {
sev := SeverityCrit
if opportunistic {
sev = SeverityWarn
}
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)
}