diff --git a/Dockerfile b/Dockerfile index c4abb45..716c7d1 100644 --- a/Dockerfile +++ b/Dockerfile @@ -10,8 +10,5 @@ RUN CGO_ENABLED=0 go build -tags standalone -ldflags "-X main.Version=${CHECKER_ FROM scratch COPY --from=builder /checker-tls /checker-tls -USER 65534:65534 EXPOSE 8080 -HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \ - CMD ["/checker-tls", "-healthcheck"] ENTRYPOINT ["/checker-tls"] diff --git a/checker/fetch.go b/checker/fetch.go index 1a078d1..1cdd816 100644 --- a/checker/fetch.go +++ b/checker/fetch.go @@ -68,7 +68,7 @@ func FetchChain(ctx context.Context, host string, port uint16, starttls string, tlsConn := tls.Client(conn, &tls.Config{ ServerName: host, - InsecureSkipVerify: true, // #nosec G402 -- intentional: caller receives the chain even when PKIX rejects it + InsecureSkipVerify: true, }) if err := tlsConn.HandshakeContext(dialCtx); err != nil { return nil, fmt.Errorf("tls handshake: %w", err) diff --git a/checker/prober.go b/checker/prober.go index 1aa25ee..74a05ef 100644 --- a/checker/prober.go +++ b/checker/prober.go @@ -172,7 +172,7 @@ func probe(ctx context.Context, ep contract.TLSEndpoint, timeout time.Duration) func handshake(conn net.Conn, ep contract.TLSEndpoint, sni string) (*tls.Conn, error) { cfg := &tls.Config{ ServerName: sni, - InsecureSkipVerify: true, // #nosec G402 -- intentional: chain verified separately in probe() + InsecureSkipVerify: true, } if ep.STARTTLS == "" { @@ -198,7 +198,7 @@ func handshake(conn net.Conn, ep contract.TLSEndpoint, sni string) (*tls.Conn, e } var ( - errStartTLSNotOffered = errors.New("starttls not advertised by server") + errStartTLSNotOffered = errors.New("starttls not advertised by server") errUnsupportedStartTLSProto = errors.New("unsupported starttls protocol") ) diff --git a/checker/rules_protocol.go b/checker/rules_protocol.go index 6153b38..f9e24ad 100644 --- a/checker/rules_protocol.go +++ b/checker/rules_protocol.go @@ -4,8 +4,6 @@ import ( "context" "crypto/tls" "fmt" - "sort" - "strings" sdk "git.happydns.org/checker-sdk-go/checker" ) @@ -83,38 +81,25 @@ func (r *cipherSuiteRule) Evaluate(ctx context.Context, obs sdk.ObservationGette return []sdk.CheckState{emptyCaseState("tls.cipher_suite.no_endpoints")} } - // Collapse per-endpoint cipher suites into a single info state. One - // row per endpoint drowns out actionable rules in the UI on domains - // with many endpoints; an aggregated list is enough for visibility. - suites := map[string]int{} - endpoints := map[string][]string{} + var out []sdk.CheckState for _, ref := range sortedRefs(data) { p := data.Probes[ref] if p.CipherSuite == "" { continue } - suites[p.CipherSuite]++ - endpoints[p.CipherSuite] = append(endpoints[p.CipherSuite], p.Endpoint) + out = append(out, sdk.CheckState{ + Status: sdk.StatusInfo, + Code: "tls.cipher_suite.negotiated", + Subject: subjectOf(p), + Message: fmt.Sprintf("Cipher suite %s negotiated.", p.CipherSuite), + Meta: metaOf(p), + }) } - if len(suites) == 0 { + if len(out) == 0 { return []sdk.CheckState{unknownState( "tls.cipher_suite.skipped", "No endpoint completed a TLS handshake.", )} } - names := make([]string, 0, len(suites)) - for s := range suites { - names = append(names, s) - } - sort.Strings(names) - parts := make([]string, 0, len(names)) - for _, n := range names { - parts = append(parts, fmt.Sprintf("%s (%d)", n, suites[n])) - } - return []sdk.CheckState{{ - Status: sdk.StatusInfo, - Code: "tls.cipher_suite.negotiated", - Message: "Negotiated cipher suites: " + strings.Join(parts, ", "), - Meta: map[string]any{"suites": endpoints}, - }} + return out } diff --git a/checker/starttls_imap.go b/checker/starttls_imap.go index 4c04010..777e38d 100644 --- a/checker/starttls_imap.go +++ b/checker/starttls_imap.go @@ -36,10 +36,6 @@ func starttlsIMAP(conn net.Conn, sni string) error { supportsSTARTTLS = true } if strings.HasPrefix(line, "A001 ") { - rest := strings.TrimSpace(line[len("A001 "):]) - if !strings.HasPrefix(strings.ToUpper(rest), "OK") { - return fmt.Errorf("CAPABILITY rejected by server: %s", rest) - } break } } diff --git a/checker/starttls_smtp.go b/checker/starttls_smtp.go index ccc0211..dfbaa19 100644 --- a/checker/starttls_smtp.go +++ b/checker/starttls_smtp.go @@ -7,11 +7,6 @@ import ( "strings" ) -// EHLOHostname is the hostname sent in the SMTP EHLO command during STARTTLS -// negotiation. Override it at startup (e.g. via -ldflags or programmatically) -// to match the identity of the host running the checker. -var EHLOHostname = "checker.localhost" - func init() { registerStartTLS("smtp", starttlsSMTP) registerStartTLS("submission", starttlsSMTP) @@ -25,7 +20,7 @@ func starttlsSMTP(conn net.Conn, sni string) error { return fmt.Errorf("read greeting: %w", err) } - if _, err := fmt.Fprintf(rw, "EHLO %s\r\n", EHLOHostname); err != nil { + 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 { diff --git a/checker/starttls_xmpp.go b/checker/starttls_xmpp.go index d8fbccf..dfed8f2 100644 --- a/checker/starttls_xmpp.go +++ b/checker/starttls_xmpp.go @@ -31,9 +31,6 @@ func starttlsXMPP(conn net.Conn, sni, ns string) error { dec := xml.NewDecoder(conn) // Read the inbound opening and its . - // A peer that opens with (or anything other than features) - // is not going to advertise STARTTLS — surface that immediately rather - // than spinning on tokens until the deadline fires. hasStartTLS := false outer: for { @@ -41,38 +38,29 @@ outer: if err != nil { return fmt.Errorf("read stream features: %w", err) } - se, ok := tok.(xml.StartElement) - if !ok { - continue - } - switch se.Name.Local { - case "stream": - // Outer opening. Continue reading children. - continue - case "features": - 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 + 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) } - if err := dec.Skip(); err != nil { - return fmt.Errorf("skip feature %q: %w", ee.Name.Local, err) - } - case xml.EndElement: - if ee.Name.Local == "features" { - break outer + switch ee := t2.(type) { + case xml.StartElement: + if ee.Name.Local == "starttls" { + hasStartTLS = true + } + if err := dec.Skip(); err != nil { + return fmt.Errorf("skip feature %q: %w", ee.Name.Local, err) + } + case xml.EndElement: + if ee.Name.Local == "features" { + break outer + } } } } - case "error": - return fmt.Errorf("server returned before features") - default: - return fmt.Errorf("%w: unexpected element %q before features", errStartTLSNotOffered, se.Name.Local) } } if !hasStartTLS { diff --git a/checker/types.go b/checker/types.go index 8cbf23d..0dfd8b3 100644 --- a/checker/types.go +++ b/checker/types.go @@ -78,11 +78,11 @@ type TLSProbe struct { // no certificate. NoPeerCert bool `json:"no_peer_cert,omitempty"` - HostnameMatch *bool `json:"hostname_match,omitempty"` - ChainValid *bool `json:"chain_valid,omitempty"` - ChainVerifyErr string `json:"chain_verify_err,omitempty"` - NotAfter time.Time `json:"not_after,omitempty"` - Issuer string `json:"issuer,omitempty"` + HostnameMatch *bool `json:"hostname_match,omitempty"` + ChainValid *bool `json:"chain_valid,omitempty"` + ChainVerifyErr string `json:"chain_verify_err,omitempty"` + NotAfter time.Time `json:"not_after,omitempty"` + Issuer string `json:"issuer,omitempty"` // IssuerDN is the leaf's issuer as an RFC 2253 DN string, suitable for // matching the CCADB CAA Identifiers CSV "Subject" column when the AKI // lookup misses. diff --git a/contract/contract.go b/contract/contract.go index 76f7e07..52f1be1 100644 --- a/contract/contract.go +++ b/contract/contract.go @@ -16,7 +16,6 @@ import ( "encoding/hex" "encoding/json" "fmt" - "strings" sdk "git.happydns.org/checker-sdk-go/checker" ) @@ -59,27 +58,10 @@ type TLSEndpoint struct { RequireSTARTTLS bool `json:"require,omitempty"` } -// Validate rejects endpoints that cannot be probed: empty Host or zero Port. -// STARTTLS dialect is intentionally not checked here (the checker surfaces -// unsupported dialects at runtime via the tls.starttls_dialect_supported -// rule), and SNI defaults to Host downstream. -func (ep TLSEndpoint) Validate() error { - if strings.TrimSpace(strings.TrimSuffix(ep.Host, ".")) == "" { - return fmt.Errorf("contract: TLSEndpoint.Host is required") - } - if ep.Port == 0 { - return fmt.Errorf("contract: TLSEndpoint.Port must be 1-65535") - } - return nil -} - // NewEntry wraps ep in an sdk.DiscoveryEntry with Type, a deterministic Ref // derived from ep, and a marshaled Payload. The returned entry can be // returned as-is from a DiscoveryPublisher implementation. func NewEntry(ep TLSEndpoint) (sdk.DiscoveryEntry, error) { - if err := ep.Validate(); err != nil { - return sdk.DiscoveryEntry{}, err - } payload, err := json.Marshal(ep) if err != nil { return sdk.DiscoveryEntry{}, fmt.Errorf("contract: marshal TLSEndpoint: %w", err) @@ -113,7 +95,7 @@ func Ref(ep TLSEndpoint) string { req = "1" } canonical := fmt.Sprintf("%s|%d|%s|%s|%s", ep.Host, ep.Port, sni, ep.STARTTLS, req) - sum := sha1.Sum([]byte(canonical)) // #nosec G401 G505 -- non-cryptographic stable key; see doc comment above + sum := sha1.Sum([]byte(canonical)) return hex.EncodeToString(sum[:8]) } @@ -127,9 +109,6 @@ func ParseEntry(e sdk.DiscoveryEntry) (TLSEndpoint, error) { if err := json.Unmarshal(e.Payload, &ep); err != nil { return TLSEndpoint{}, fmt.Errorf("contract: unmarshal TLSEndpoint: %w", err) } - if err := ep.Validate(); err != nil { - return TLSEndpoint{}, err - } return ep, nil } diff --git a/go.mod b/go.mod index 9656a31..bde2901 100644 --- a/go.mod +++ b/go.mod @@ -2,4 +2,4 @@ module git.happydns.org/checker-tls go 1.25.0 -require git.happydns.org/checker-sdk-go v1.5.0 +require git.happydns.org/checker-sdk-go v1.4.0 diff --git a/go.sum b/go.sum index c389c68..072aab1 100644 --- a/go.sum +++ b/go.sum @@ -1,2 +1,2 @@ -git.happydns.org/checker-sdk-go v1.5.0 h1:5uD5Cm6xJ+lwnhbJ09iCXGHbYS9zRh+Yh0NeBHkAPBY= -git.happydns.org/checker-sdk-go v1.5.0/go.mod h1:aNAcfYFfbhvH9kJhE0Njp5GX0dQbxdRB0rJ0KvSC5nI= +git.happydns.org/checker-sdk-go v1.4.0 h1:sO8EnF3suhNgYLRsbmCZWJOymH/oNMrOUqj3FEzJArs= +git.happydns.org/checker-sdk-go v1.4.0/go.mod h1:aNAcfYFfbhvH9kJhE0Njp5GX0dQbxdRB0rJ0KvSC5nI= diff --git a/main.go b/main.go index c967401..ae5167c 100644 --- a/main.go +++ b/main.go @@ -10,19 +10,11 @@ import ( var Version = "custom-build" -// EHLOHostname is set via -ldflags to identify this checker instance in SMTP -// EHLO greetings. Falls back to the package default ("checker.localhost") when -// left empty. -var EHLOHostname = "" - var listenAddr = flag.String("listen", ":8080", "HTTP listen address") func main() { flag.Parse() tls.Version = Version - if EHLOHostname != "" { - tls.EHLOHostname = EHLOHostname - } srv := server.New(tls.Provider()) if err := srv.ListenAndServe(*listenAddr); err != nil {