commit b27336a908d6d2137315fc5e68578456de089324 Author: Pierre-Olivier Mercier Date: Tue Apr 21 21:50:49 2026 +0700 Initial commit diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..ea99603 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,14 @@ +FROM golang:1.25-alpine AS builder + +ARG CHECKER_VERSION=custom-build + +WORKDIR /src +COPY go.mod go.sum ./ +RUN go mod download +COPY . . +RUN CGO_ENABLED=0 go build -ldflags "-X main.Version=${CHECKER_VERSION}" -o /checker-tls . + +FROM scratch +COPY --from=builder /checker-tls /checker-tls +EXPOSE 8080 +ENTRYPOINT ["/checker-tls"] diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..bf1467e --- /dev/null +++ b/Makefile @@ -0,0 +1,25 @@ +CHECKER_NAME := checker-tls +CHECKER_IMAGE := happydomain/$(CHECKER_NAME) +CHECKER_VERSION ?= custom-build + +CHECKER_SOURCES := main.go $(wildcard checker/*.go) + +GO_LDFLAGS := -X main.Version=$(CHECKER_VERSION) + +.PHONY: all plugin docker clean + +all: $(CHECKER_NAME) + +$(CHECKER_NAME): $(CHECKER_SOURCES) + go build -ldflags "$(GO_LDFLAGS)" -o $@ . + +plugin: $(CHECKER_NAME).so + +$(CHECKER_NAME).so: $(CHECKER_SOURCES) $(wildcard plugin/*.go) + go build -buildmode=plugin -ldflags "$(GO_LDFLAGS)" -o $@ ./plugin/ + +docker: + docker build --build-arg CHECKER_VERSION=$(CHECKER_VERSION) -t $(CHECKER_IMAGE) . + +clean: + rm -f $(CHECKER_NAME) $(CHECKER_NAME).so diff --git a/README.md b/README.md new file mode 100644 index 0000000..586dacd --- /dev/null +++ b/README.md @@ -0,0 +1,77 @@ +# checker-tls + +TLS posture checker for happyDomain. + +Consumes `DiscoveredEndpoint` entries published by service checkers (xmpp, +srv, caldav, carddav, …) via `AutoFill: discovered_endpoints`, performs a +real TCP dial, optional STARTTLS upgrade, and TLS handshake on each, and +exports per-endpoint posture under the observation key `tls_probes`. + +## Supported endpoint types + +| `DiscoveredEndpoint.Type` | Handshake | +| --------------------------- | ---------------------------------------------- | +| `tls` | Direct TLS on connect | +| `starttls-smtp` | ESMTP EHLO + STARTTLS (RFC 3207) | +| `starttls-submission` | Same as `starttls-smtp` | +| `starttls-imap` | IMAP CAPABILITY + STARTTLS (RFC 3501) | +| `starttls-pop3` | POP3 CAPA + STLS (RFC 2595) | +| `starttls-xmpp-client` | XMPP c2s stream + `` (RFC 6120) | +| `starttls-xmpp-server` | XMPP s2s stream + `` (RFC 6120) | +| other `starttls-*` | Rejected with a `handshake_failed` issue | + +## Payload shape + +Observation data under `tls_probes`: + +```json +{ + "probes": { + "": { + "host": "example.net", + "port": 5222, + "endpoint": "example.net:5222", + "type": "starttls-xmpp-client", + "sni": "example.net", + "tls_version": "TLS1.3", + "cipher_suite": "TLS_AES_128_GCM_SHA256", + "hostname_match": true, + "chain_valid": true, + "not_after": "2026-07-01T00:00:00Z", + "issuer": "Let's Encrypt", + "issues": [] + } + }, + "collected_at": "2026-04-21T12:34:56Z" +} +``` + +Consumers pick their own endpoint from the map via +`RelatedObservation.EndpointID`. + +## Issues reported + +- `tcp_unreachable` — dial failed. +- `handshake_failed` — TLS handshake or STARTTLS upgrade failed. +- `starttls_not_offered` — server didn't advertise STARTTLS (severity depends + on `Meta["starttls"]` = `"required"` vs `"opportunistic"`). +- `chain_invalid` — leaf does not chain to a system-trusted root. +- `hostname_mismatch` — cert SANs don't cover the SNI. +- `expired` / `expiring_soon` — cert expiry posture. +- `weak_tls_version` — negotiated TLS < 1.2. + +## Options + +| Id | Type | Default | Description | +| ----------------- | ------ | ------- | ------------------------------------------------ | +| `probeTimeoutMs` | number | 10000 | Per-endpoint dial + handshake timeout in ms. | + +## Running + +```bash +# Plugin (loaded by happyDomain at startup) +make plugin + +# Standalone HTTP server +make && ./checker-tls -listen :8080 +``` diff --git a/checker/collect.go b/checker/collect.go new file mode 100644 index 0000000..8ce8087 --- /dev/null +++ b/checker/collect.go @@ -0,0 +1,70 @@ +package checker + +import ( + "context" + "fmt" + "log" + "strings" + "sync" + "time" + + sdk "git.happydns.org/checker-sdk-go/checker" +) + +// Collect reads the discovered endpoints auto-filled into +// opts[OptionEndpoints], filters the TLS-relevant ones, probes them +// concurrently (capped at MaxConcurrentProbes), and returns a TLSData payload +// keyed by endpoint Id so consumers (via RelatedObservation.EndpointID) can +// pick their own probe from the shared observation. +func (p *tlsProvider) Collect(ctx context.Context, opts sdk.CheckerOptions) (any, error) { + eps, ok := sdk.GetOption[[]endpointInput](opts, OptionEndpoints) + if !ok { + return nil, fmt.Errorf("no endpoints in options: did the host wire AutoFillDiscoveredEndpoints?") + } + + timeoutMs := sdk.GetIntOption(opts, OptionProbeTimeoutMs, DefaultProbeTimeoutMs) + if timeoutMs <= 0 { + timeoutMs = DefaultProbeTimeoutMs + } + timeout := time.Duration(timeoutMs) * time.Millisecond + + var filtered []endpointInput + for _, ep := range eps { + if !isTLSRelevant(ep.Type) { + continue + } + filtered = append(filtered, ep) + } + if len(filtered) == 0 { + return nil, fmt.Errorf("no TLS-relevant endpoints to probe (received %d)", len(eps)) + } + + probes := make(map[string]TLSProbe, len(filtered)) + var mu sync.Mutex + var wg sync.WaitGroup + sem := make(chan struct{}, MaxConcurrentProbes) + for _, ep := range filtered { + wg.Add(1) + sem <- struct{}{} + go func() { + defer wg.Done() + defer func() { <-sem }() + pr := probe(ctx, ep, timeout) + log.Printf("checker-tls: %s %s:%d → tls=%s issues=%d elapsed=%dms err=%q", + ep.Type, pr.Host, pr.Port, pr.TLSVersion, len(pr.Issues), pr.ElapsedMS, pr.Error) + mu.Lock() + probes[ep.Id] = pr + mu.Unlock() + }() + } + wg.Wait() + + return &TLSData{ + Probes: probes, + CollectedAt: time.Now(), + }, nil +} + +func isTLSRelevant(t string) bool { + return t == "tls" || strings.HasPrefix(t, "starttls-") +} diff --git a/checker/definition.go b/checker/definition.go new file mode 100644 index 0000000..51ba2b1 --- /dev/null +++ b/checker/definition.go @@ -0,0 +1,53 @@ +package checker + +import ( + "time" + + sdk "git.happydns.org/checker-sdk-go/checker" +) + +// Version is the checker version reported in CheckerDefinition.Version. +// It 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 stub TLS checker. +func Definition() *sdk.CheckerDefinition { + return &sdk.CheckerDefinition{ + ID: "tls", + Name: "TLS (stub)", + 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: "Discovered endpoints", + Description: "Endpoints published by other checkers for this domain; consumer filters by Type.", + AutoFill: sdk.AutoFillDiscoveredEndpoints, + Hide: true, + }, + }, + }, + Rules: []sdk.CheckRule{ + Rule(), + }, + Interval: &sdk.CheckIntervalSpec{ + Min: 1 * time.Minute, + Max: 1 * time.Hour, + Default: 10 * time.Minute, + }, + } +} diff --git a/checker/prober.go b/checker/prober.go new file mode 100644 index 0000000..aaa7aaf --- /dev/null +++ b/checker/prober.go @@ -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) +} + diff --git a/checker/prober_test.go b/checker/prober_test.go new file mode 100644 index 0000000..caaa1aa --- /dev/null +++ b/checker/prober_test.go @@ -0,0 +1,99 @@ +package checker + +import ( + "context" + "net" + "net/http/httptest" + "net/url" + "strconv" + "testing" + "time" +) + +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(), endpointInput{ + Id: "test-ep", + Type: "tls", + 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(), endpointInput{ + Id: "unreachable", + Type: "tls", + 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(), endpointInput{ + Id: "unknown-proto", + Type: "starttls-totallyfake", + Host: "127.0.0.1", + Port: uint16(addr.Port), + }, 2*time.Second) + + if probe.Error == "" { + t.Errorf("expected handshake error for unsupported starttls protocol") + } +} + diff --git a/checker/provider.go b/checker/provider.go new file mode 100644 index 0000000..0efda7f --- /dev/null +++ b/checker/provider.go @@ -0,0 +1,19 @@ +package checker + +import sdk "git.happydns.org/checker-sdk-go/checker" + +// Provider returns a new TLS stub observation provider. +func Provider() sdk.ObservationProvider { + return &tlsProvider{} +} + +type tlsProvider struct{} + +func (p *tlsProvider) Key() sdk.ObservationKey { + return ObservationKeyTLSProbes +} + +// Definition implements sdk.CheckerDefinitionProvider. +func (p *tlsProvider) Definition() *sdk.CheckerDefinition { + return Definition() +} diff --git a/checker/rule.go b/checker/rule.go new file mode 100644 index 0000000..d18f559 --- /dev/null +++ b/checker/rule.go @@ -0,0 +1,127 @@ +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 + ) + 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: + if worst != 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 +} diff --git a/checker/starttls.go b/checker/starttls.go new file mode 100644 index 0000000..b58ee23 --- /dev/null +++ b/checker/starttls.go @@ -0,0 +1,284 @@ +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)) + + // Read greeting. + greeting, err := rw.ReadString('\n') + if err != nil { + return fmt.Errorf("read greeting: %w", err) + } + _ = greeting + + 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(``, 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 opening and its . + 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, ``); 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 ()") + } + } + } +} diff --git a/checker/types.go b/checker/types.go new file mode 100644 index 0000000..7a0fdae --- /dev/null +++ b/checker/types.go @@ -0,0 +1,78 @@ +// 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"` +} + +// endpointInput is the local, permissive shape we decode opts["endpoints"] +// into. It captures the fields we care about regardless of whether the +// option arrived as a native []*happydns.DiscoveredEndpoint (plugin mode) +// or as a JSON array (HTTP mode), thanks to sdk.GetOption's round-trip. +type endpointInput struct { + Id string `json:"id"` + Type string `json:"type"` + Host string `json:"host"` + Port uint16 `json:"port"` + SNI string `json:"sni,omitempty"` + Meta map[string]any `json:"meta,omitempty"` +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..02e806f --- /dev/null +++ b/go.mod @@ -0,0 +1,5 @@ +module git.happydns.org/checker-tls + +go 1.25.0 + +require git.happydns.org/checker-sdk-go v0.0.1 diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..5282be1 --- /dev/null +++ b/go.sum @@ -0,0 +1,2 @@ +git.happydns.org/checker-sdk-go v0.0.1 h1:4RxCJr73HWKxjOyU/6NJMO8lXJmH0gMLA68EzTqLbQI= +git.happydns.org/checker-sdk-go v0.0.1/go.mod h1:aNAcfYFfbhvH9kJhE0Njp5GX0dQbxdRB0rJ0KvSC5nI= diff --git a/main.go b/main.go new file mode 100644 index 0000000..e4ba477 --- /dev/null +++ b/main.go @@ -0,0 +1,24 @@ +// Command checker-tls is the standalone binary for the stub TLS checker. +package main + +import ( + "flag" + "log" + + sdk "git.happydns.org/checker-sdk-go/checker" + tls "git.happydns.org/checker-tls/checker" +) + +var Version = "custom-build" + +var listenAddr = flag.String("listen", ":8080", "HTTP listen address") + +func main() { + flag.Parse() + tls.Version = Version + + server := sdk.NewServer(tls.Provider()) + if err := server.ListenAndServe(*listenAddr); err != nil { + log.Fatalf("server error: %v", err) + } +} diff --git a/plugin/plugin.go b/plugin/plugin.go new file mode 100644 index 0000000..b59a5bc --- /dev/null +++ b/plugin/plugin.go @@ -0,0 +1,14 @@ +// Command plugin is the happyDomain plugin entrypoint for the stub TLS checker. +package main + +import ( + sdk "git.happydns.org/checker-sdk-go/checker" + tls "git.happydns.org/checker-tls/checker" +) + +var Version = "custom-build" + +func NewCheckerPlugin() (*sdk.CheckerDefinition, sdk.ObservationProvider, error) { + tls.Version = Version + return tls.Definition(), tls.Provider(), nil +}