commit d96ebc4d0e20da0db1fb5a547a7c6ce3d592c466 Author: Pierre-Olivier Mercier Date: Tue Apr 21 21:47:58 2026 +0700 Initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..428e3f6 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +checker-xmpp +checker-xmpp.so diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..e63009b --- /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-xmpp . + +FROM scratch +COPY --from=builder /checker-xmpp /checker-xmpp +EXPOSE 8080 +ENTRYPOINT ["/checker-xmpp"] diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..07d44d8 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 The happyDomain Authors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the “Software”), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..2b4120f --- /dev/null +++ b/Makefile @@ -0,0 +1,28 @@ +CHECKER_NAME := checker-xmpp +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 test 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) . + +test: + go test ./... + +clean: + rm -f $(CHECKER_NAME) $(CHECKER_NAME).so diff --git a/NOTICE b/NOTICE new file mode 100644 index 0000000..5b87799 --- /dev/null +++ b/NOTICE @@ -0,0 +1,25 @@ +checker-xmpp +Copyright (c) 2026 The happyDomain Authors + +This product is licensed under the MIT License (see LICENSE). + +------------------------------------------------------------------------------- +Third-party notices +------------------------------------------------------------------------------- + +This product includes software developed as part of the checker-sdk-go +project (https://git.happydns.org/happyDomain/checker-sdk-go), licensed +under the Apache License, Version 2.0: + + checker-sdk-go + Copyright 2020-2026 The happyDomain Authors + +This product includes software from the miekg/dns project +(https://github.com/miekg/dns), licensed under the BSD 3-Clause License: + + Copyright (c) 2009 The Go Authors. All rights reserved. + Copyright (c) 2011 Miek Gieben. All rights reserved. + Copyright (c) 2014 CloudFlare. All rights reserved. + +You may obtain a copy of the Apache License 2.0 at: + http://www.apache.org/licenses/LICENSE-2.0 diff --git a/README.md b/README.md new file mode 100644 index 0000000..7ae1ee0 --- /dev/null +++ b/README.md @@ -0,0 +1,87 @@ +# checker-xmpp + +XMPP server checker for [happyDomain](https://www.happydomain.org/). + +Probes a domain's XMPP deployment the same way +[xmpp.net](https://xmpp.net/) does: SRV discovery, stream negotiation, +STARTTLS, SASL mechanisms, federation auth (dialback / SASL EXTERNAL), +and XEP-0368 direct-TLS. Produces an actionable HTML report with a +remediation panel surfacing the most common real-world failures. + +TLS certificate chain / SAN / expiry / cipher posture is **out of scope**: +a dedicated TLS checker handles that. This checker only confirms that +STARTTLS completes and records the negotiated TLS version/cipher for +context. + +We publish each probed endpoint as a `DiscoveryEntry` of type +`tls.endpoint.v1` so that `checker-tls` (or any other consumer of that +contract) can run TLS posture checks against them without redoing the +SRV lookup. The entries are produced through +`git.happydns.org/checker-tls/contract`, with `SNI` set to the bare JID +domain; XMPP certificates must be valid for the source domain (RFC 6120 +§13.7.2.1), which is typically different from the SRV target hostname. +`RequireSTARTTLS` is carried over from the STARTTLS-required posture we +actually observed during probing, so an operator who requires STARTTLS +will see a CRIT on the TLS side, not a WARN, if the server later drops +it. + +The TLS checker's resulting observations (under the `tls_probes` key) +are folded back into our rule aggregation and HTML report via the SDK's +`ObservationGetter.GetRelated` / `ReportContext.Related` path: a bad +certificate on an XMPP endpoint shows up on the XMPP service page, not +only in a separate TLS view. The matching between a probe and its XMPP +endpoint is done on `RelatedObservation.Ref`, which carries the same +value as `DiscoveryEntry.Ref` we emitted (computed deterministically by +`contract.Ref`). + +## What it checks + +For each of `_xmpp-client._tcp`, `_xmpp-server._tcp`, +`_xmpps-client._tcp`, `_xmpps-server._tcp` (and legacy `_jabber._tcp`): + +1. SRV and A/AAAA resolution. +2. TCP reachability. +3. `` open, stream features parsing. +4. STARTTLS advertised (and ``). +5. STARTTLS handshake success. +6. Post-TLS SASL mechanism list (flags PLAIN-only, missing SCRAM). +7. Server-to-server dialback / SASL EXTERNAL availability. +8. XEP-0368 direct TLS (`_xmpps-*`) when published. +9. IPv4 / IPv6 coverage. +10. Fallback probe on `:5222`/`:5269` when no SRV is published. + +## Usage + +### Standalone HTTP server + +```bash +make +./checker-xmpp -listen :8080 +``` + +### Docker + +```bash +make docker +docker run -p 8080:8080 happydomain/checker-xmpp +``` + +### happyDomain plugin + +```bash +make plugin +``` + +## Options + +| Scope | Id | Description | +| ----- | ---------- | ----------------------------------------------------------- | +| Run | `domain` | Domain to test (auto-filled from the service) | +| Run | `mode` | `c2s`, `s2s`, or `both` (default) | +| Run | `timeout` | Per-endpoint timeout in seconds (default `10`) | + +Applies to services of type `abstract.XMPP`. + +## License + +MIT (see `LICENSE`). Third-party attributions in `NOTICE`. diff --git a/checker/collect.go b/checker/collect.go new file mode 100644 index 0000000..3ad7880 --- /dev/null +++ b/checker/collect.go @@ -0,0 +1,664 @@ +package checker + +import ( + "context" + "crypto/tls" + "encoding/xml" + "errors" + "fmt" + "io" + "net" + "strconv" + "strings" + "time" + + "github.com/miekg/dns" + + sdk "git.happydns.org/checker-sdk-go/checker" +) + +const ( + streamsNS = "http://etherx.jabber.org/streams" + clientNS = "jabber:client" + serverNS = "jabber:server" + tlsNS = "urn:ietf:params:xml:ns:xmpp-tls" +) + +func tlsProbeConfig(serverName string) *tls.Config { + return &tls.Config{ + ServerName: serverName, + InsecureSkipVerify: true, //nolint:gosec: cert validation is the TLS checker's job + MinVersion: tls.VersionTLS10, + } +} + +// Collect runs the full XMPP probe for a domain. +func (p *xmppProvider) Collect(ctx context.Context, opts sdk.CheckerOptions) (any, error) { + domain, _ := sdk.GetOption[string](opts, "domain") + domain = strings.TrimSuffix(domain, ".") + if domain == "" { + return nil, fmt.Errorf("domain is required") + } + + mode, _ := sdk.GetOption[string](opts, "mode") + if mode == "" { + mode = "both" + } + timeoutSecs := sdk.GetFloatOption(opts, "timeout", 10) + if timeoutSecs < 1 { + timeoutSecs = 10 + } + perEndpoint := time.Duration(timeoutSecs * float64(time.Second)) + + wantC2S := mode != "s2s" + wantS2S := mode != "c2s" + + data := &XMPPData{ + Domain: domain, + RunAt: time.Now().UTC().Format(time.RFC3339), + SRV: SRVLookup{Errors: map[string]string{}}, + } + + resolver := net.DefaultResolver + + lookupSets := []struct { + prefix string + want bool + dst *[]SRVRecord + }{ + {"_xmpp-client._tcp.", wantC2S, &data.SRV.Client}, + {"_xmpp-server._tcp.", wantS2S, &data.SRV.Server}, + {"_xmpps-client._tcp.", wantC2S, &data.SRV.ClientSecure}, + {"_xmpps-server._tcp.", wantS2S, &data.SRV.ServerSecure}, + {"_jabber._tcp.", wantC2S, &data.SRV.Jabber}, + } + for _, ls := range lookupSets { + if !ls.want { + continue + } + records, err := lookupSRV(ctx, resolver, ls.prefix, domain) + if err != nil { + data.SRV.Errors[ls.prefix] = err.Error() + continue + } + *ls.dst = records + } + + totalSRV := len(data.SRV.Client) + len(data.SRV.Server) + len(data.SRV.ClientSecure) + len(data.SRV.ServerSecure) + if totalSRV == 0 { + data.SRV.FallbackProbed = true + if wantC2S { + data.SRV.Client = []SRVRecord{{Target: domain, Port: 5222}} + } + if wantS2S { + data.SRV.Server = []SRVRecord{{Target: domain, Port: 5269}} + } + } + + resolveAllInto(ctx, resolver, data.SRV.Client) + resolveAllInto(ctx, resolver, data.SRV.Server) + resolveAllInto(ctx, resolver, data.SRV.ClientSecure) + resolveAllInto(ctx, resolver, data.SRV.ServerSecure) + + probeSet(ctx, data, domain, ModeClient, "_xmpp-client._tcp", data.SRV.Client, false, perEndpoint) + probeSet(ctx, data, domain, ModeServer, "_xmpp-server._tcp", data.SRV.Server, false, perEndpoint) + probeSet(ctx, data, domain, ModeClient, "_xmpps-client._tcp", data.SRV.ClientSecure, true, perEndpoint) + probeSet(ctx, data, domain, ModeServer, "_xmpps-server._tcp", data.SRV.ServerSecure, true, perEndpoint) + + computeCoverage(data) + data.Issues = deriveIssues(data, wantC2S, wantS2S) + + return data, nil +} + +func probeSet(ctx context.Context, data *XMPPData, domain string, mode XMPPMode, prefix string, records []SRVRecord, directTLS bool, timeout time.Duration) { + for _, rec := range records { + addrs := addressesForProbe(rec) + if len(addrs) == 0 { + ep := EndpointProbe{ + Mode: mode, + SRVPrefix: prefix, + Target: rec.Target, + Port: rec.Port, + DirectTLS: directTLS, + Error: "no A/AAAA records for target", + } + data.Endpoints = append(data.Endpoints, ep) + continue + } + for _, a := range addrs { + ep := probeEndpoint(ctx, domain, mode, prefix, rec, a.ip, a.isV6, directTLS, timeout) + data.Endpoints = append(data.Endpoints, ep) + } + } +} + +type probeAddr struct { + ip string + isV6 bool +} + +func addressesForProbe(rec SRVRecord) []probeAddr { + var out []probeAddr + for _, ip := range rec.IPv4 { + out = append(out, probeAddr{ip: ip, isV6: false}) + } + for _, ip := range rec.IPv6 { + out = append(out, probeAddr{ip: ip, isV6: true}) + } + return out +} + +func probeEndpoint(ctx context.Context, domain string, mode XMPPMode, prefix string, rec SRVRecord, ip string, isV6, directTLS bool, timeout time.Duration) EndpointProbe { + start := time.Now() + result := EndpointProbe{ + Mode: mode, + SRVPrefix: prefix, + Target: rec.Target, + Port: rec.Port, + Address: net.JoinHostPort(ip, strconv.Itoa(int(rec.Port))), + IsIPv6: isV6, + DirectTLS: directTLS, + } + defer func() { result.ElapsedMS = time.Since(start).Milliseconds() }() + + ns := clientNS + if mode == ModeServer { + ns = serverNS + } + + dialCtx, cancel := context.WithTimeout(ctx, timeout) + defer cancel() + + dialer := &net.Dialer{} + rawConn, err := dialer.DialContext(dialCtx, "tcp", result.Address) + if err != nil { + result.Error = "tcp: " + err.Error() + return result + } + result.TCPConnected = true + defer rawConn.Close() + _ = rawConn.SetDeadline(time.Now().Add(timeout)) + + var conn net.Conn = rawConn + + if directTLS { + tlsConn := tls.Client(rawConn, tlsProbeConfig(domain)) + if err := tlsConn.Handshake(); err != nil { + result.Error = "tls-handshake: " + err.Error() + return result + } + result.STARTTLSUpgraded = true + state := tlsConn.ConnectionState() + result.TLSVersion = tls.VersionName(state.Version) + result.TLSCipher = tls.CipherSuiteName(state.CipherSuite) + _ = tlsConn.SetDeadline(time.Now().Add(timeout)) + conn = tlsConn + + feats, from, err := openStreamAndReadFeatures(conn, domain, ns, mode == ModeServer) + if err != nil { + result.Error = "stream: " + err.Error() + return result + } + result.StreamOpened = true + result.StreamFrom = from + applyFeatures(&result, feats) + return result + } + + dec, from, err := openStream(conn, domain, ns, mode == ModeServer) + if err != nil { + result.Error = "stream: " + err.Error() + return result + } + result.StreamOpened = true + result.StreamFrom = from + + feats, err := readFeatures(dec) + if err != nil { + result.Error = "features: " + err.Error() + return result + } + result.STARTTLSOffered = feats.StartTLS != nil + if feats.StartTLS != nil && feats.StartTLS.Required != nil { + result.STARTTLSRequired = true + } + + if !result.STARTTLSOffered { + // Record any features seen in plaintext, but do not proceed; we + // intentionally refuse to send SASL over a non-TLS channel. + applyFeatures(&result, feats) + return result + } + + if _, err := io.WriteString(conn, ``); err != nil { + result.Error = "starttls-write: " + err.Error() + return result + } + if err := expectProceed(dec); err != nil { + result.Error = "starttls-proceed: " + err.Error() + return result + } + + tlsConn := tls.Client(rawConn, tlsProbeConfig(domain)) + _ = tlsConn.SetDeadline(time.Now().Add(timeout)) + if err := tlsConn.Handshake(); err != nil { + result.Error = "tls-handshake: " + err.Error() + return result + } + result.STARTTLSUpgraded = true + state := tlsConn.ConnectionState() + result.TLSVersion = tls.VersionName(state.Version) + result.TLSCipher = tls.CipherSuiteName(state.CipherSuite) + + _ = tlsConn.SetDeadline(time.Now().Add(timeout)) + feats2, _, err := openStreamAndReadFeatures(tlsConn, domain, ns, mode == ModeServer) + if err != nil { + result.Error = "post-tls stream: " + err.Error() + return result + } + applyFeatures(&result, feats2) + return result +} + +// applyFeatures copies parsed stream features into the probe result. +func applyFeatures(ep *EndpointProbe, feats *streamFeatures) { + if feats == nil { + return + } + ep.FeaturesRead = true + if feats.Mechanisms != nil { + ep.SASLMechanisms = append(ep.SASLMechanisms, feats.Mechanisms.Mechanism...) + for _, m := range feats.Mechanisms.Mechanism { + if strings.EqualFold(m, "EXTERNAL") { + ep.SASLExternal = true + } + } + } + if feats.Dialback != nil { + ep.DialbackOffered = true + } +} + +type streamFeatures struct { + XMLName xml.Name `xml:"http://etherx.jabber.org/streams features"` + StartTLS *startTLSEl + Mechanisms *mechanismsEl + Dialback *struct{} `xml:"urn:xmpp:features:dialback dialback"` +} + +type startTLSEl struct { + XMLName xml.Name `xml:"urn:ietf:params:xml:ns:xmpp-tls starttls"` + Required *struct{} `xml:"required"` +} + +type mechanismsEl struct { + XMLName xml.Name `xml:"urn:ietf:params:xml:ns:xmpp-sasl mechanisms"` + Mechanism []string `xml:"mechanism"` +} + +// openStreamAndReadFeatures performs the stream header exchange and parses +// . Used both for the initial open and for the post-TLS +// stream restart. +func openStreamAndReadFeatures(conn io.ReadWriter, domain, ns string, server bool) (*streamFeatures, string, error) { + dec, from, err := openStream(conn, domain, ns, server) + if err != nil { + return nil, "", err + } + feats, err := readFeatures(dec) + if err != nil { + return nil, from, err + } + return feats, from, nil +} + +func openStream(conn io.ReadWriter, domain, ns string, server bool) (*xml.Decoder, string, error) { + var header string + if server { + header = fmt.Sprintf(``, ns, streamsNS, domain) + } else { + header = fmt.Sprintf(``, ns, streamsNS, domain) + } + if _, err := io.WriteString(conn, header); err != nil { + return nil, "", fmt.Errorf("write header: %w", err) + } + dec := xml.NewDecoder(conn) + for { + tok, err := dec.Token() + if err != nil { + return nil, "", fmt.Errorf("read header: %w", err) + } + switch t := tok.(type) { + case xml.StartElement: + if t.Name.Space == streamsNS && t.Name.Local == "stream" { + var from string + for _, a := range t.Attr { + if a.Name.Local == "from" { + from = a.Value + } + } + return dec, from, nil + } + if t.Name.Space == streamsNS && t.Name.Local == "error" { + _ = dec.Skip() + return nil, "", errors.New("server returned stream:error on open") + } + return nil, "", fmt.Errorf("unexpected element %s", t.Name.Local) + } + } +} + +func readFeatures(dec *xml.Decoder) (*streamFeatures, error) { + for { + tok, err := dec.Token() + if err != nil { + return nil, fmt.Errorf("read features: %w", err) + } + se, ok := tok.(xml.StartElement) + if !ok { + continue + } + if se.Name.Space == streamsNS && se.Name.Local == "features" { + var feats streamFeatures + if err := dec.DecodeElement(&feats, &se); err != nil { + return nil, fmt.Errorf("decode features: %w", err) + } + return &feats, nil + } + if se.Name.Space == streamsNS && se.Name.Local == "error" { + _ = dec.Skip() + return nil, errors.New("stream:error before features") + } + } +} + +func expectProceed(dec *xml.Decoder) error { + for { + tok, err := dec.Token() + if err != nil { + return fmt.Errorf("read proceed: %w", err) + } + se, ok := tok.(xml.StartElement) + if !ok { + continue + } + if se.Name.Space == tlsNS { + switch se.Name.Local { + case "proceed": + _ = dec.Skip() + return nil + case "failure": + _ = dec.Skip() + return errors.New("server refused STARTTLS ()") + } + } + } +} + +func lookupSRV(ctx context.Context, r *net.Resolver, prefix, domain string) ([]SRVRecord, error) { + name := prefix + dns.Fqdn(domain) + _, records, err := r.LookupSRV(ctx, "", "", name) + if err != nil { + // Distinguish NXDOMAIN / no records from real errors. + var dnsErr *net.DNSError + if errors.As(err, &dnsErr) && (dnsErr.IsNotFound) { + return nil, nil + } + return nil, err + } + // RFC 2782: single record "." with port 0 means "service explicitly not + // available at this domain". We treat that as "no records" for probing. + if len(records) == 1 && (records[0].Target == "." || records[0].Target == "") && records[0].Port == 0 { + return nil, nil + } + out := make([]SRVRecord, 0, len(records)) + for _, r := range records { + out = append(out, SRVRecord{ + Target: strings.TrimSuffix(r.Target, "."), + Port: r.Port, + Priority: r.Priority, + Weight: r.Weight, + }) + } + return out, nil +} + +func resolveAllInto(ctx context.Context, r *net.Resolver, records []SRVRecord) { + for i := range records { + ips, err := r.LookupIPAddr(ctx, records[i].Target) + if err != nil { + continue + } + for _, ip := range ips { + if v4 := ip.IP.To4(); v4 != nil { + records[i].IPv4 = append(records[i].IPv4, v4.String()) + } else { + records[i].IPv6 = append(records[i].IPv6, ip.IP.String()) + } + } + } +} + +func computeCoverage(data *XMPPData) { + for _, ep := range data.Endpoints { + if ep.TCPConnected { + if ep.IsIPv6 { + data.Coverage.HasIPv6 = true + } else { + data.Coverage.HasIPv4 = true + } + } + if !ep.STARTTLSUpgraded { + continue + } + switch ep.Mode { + case ModeClient: + // We consider c2s working if SASL was advertised, OR if STARTTLS + // completed but features couldn't be read (benign for probes). + if len(ep.SASLMechanisms) > 0 || !ep.FeaturesRead { + data.Coverage.WorkingC2S = true + } + case ModeServer: + // Similarly, s2s is "working" if TLS completed. A misconfigured + // server that advertised TLS but no dialback/EXTERNAL is reported + // via the xmpp.s2s.no_auth issue, not via coverage. + data.Coverage.WorkingS2S = true + } + } +} + +func deriveIssues(data *XMPPData, wantC2S, _ bool) []Issue { + var issues []Issue + + // 1. No SRV published. + if data.SRV.FallbackProbed { + issues = append(issues, Issue{ + Code: CodeNoSRV, + Severity: SeverityCrit, + Message: "No XMPP SRV records found for " + data.Domain + ".", + Fix: "Publish _xmpp-client._tcp." + data.Domain + " and _xmpp-server._tcp." + data.Domain + " SRV records.", + }) + } + + // 2. Legacy _jabber. + if len(data.SRV.Jabber) > 0 { + issues = append(issues, Issue{ + Code: CodeLegacyJabber, + Severity: SeverityWarn, + Message: "Obsolete _jabber._tcp SRV record still published.", + Fix: "Remove _jabber._tcp records; _xmpp-client._tcp supersedes them.", + }) + } + + // 3. SRV lookup errors (real DNS failures, not NXDOMAIN). + for prefix, msg := range data.SRV.Errors { + issues = append(issues, Issue{ + Code: CodeSRVServfail, + Severity: SeverityWarn, + Message: "DNS lookup failed for " + prefix + data.Domain + ": " + msg, + Fix: "Check the authoritative DNS servers for this domain.", + }) + } + + // 4. Endpoint-level issues. + allDown := true + sawSCRAM := map[XMPPMode]bool{} + sawSCRAMPlus := map[XMPPMode]bool{} + sawPlainOnly := map[XMPPMode]bool{} + sawAnyWorking := map[XMPPMode]bool{} + + for _, ep := range data.Endpoints { + if ep.TCPConnected && ep.STARTTLSUpgraded { + allDown = false + sawAnyWorking[ep.Mode] = true + } + if ep.TCPConnected && ep.StreamOpened && !ep.DirectTLS { + if !ep.STARTTLSOffered { + issues = append(issues, Issue{ + Code: CodeStartTLSMissing, + Severity: SeverityCrit, + Message: "STARTTLS not advertised on " + ep.Address + " (" + ep.SRVPrefix + ").", + Fix: "Enable STARTTLS in the XMPP server configuration and require it for all connections.", + Endpoint: ep.Address, + }) + } else if !ep.STARTTLSRequired { + issues = append(issues, Issue{ + Code: CodeStartTLSNotRequired, + Severity: SeverityWarn, + Message: "STARTTLS offered but not on " + ep.Address + ".", + Fix: "Set the server to require TLS (e.g. `c2s_require_encryption = true` in Prosody, `starttls_required` in ejabberd).", + Endpoint: ep.Address, + }) + } + } + if ep.TCPConnected && !ep.STARTTLSUpgraded && ep.STARTTLSOffered && ep.Error != "" { + issues = append(issues, Issue{ + Code: CodeStartTLSFailed, + Severity: SeverityCrit, + Message: "STARTTLS handshake failed on " + ep.Address + ": " + ep.Error + ".", + Fix: "Run the TLS checker on this port for cert and cipher details.", + Endpoint: ep.Address, + }) + } + if !ep.TCPConnected && ep.Error != "" { + issues = append(issues, Issue{ + Code: CodeTCPUnreachable, + Severity: SeverityWarn, + Message: "Cannot reach " + ep.Address + ": " + ep.Error + ".", + Fix: "Verify firewall rules and that the XMPP server is listening on this address.", + Endpoint: ep.Address, + }) + } + // SASL posture (c2s only). + if ep.Mode == ModeClient && ep.STARTTLSUpgraded && len(ep.SASLMechanisms) > 0 { + hasSCRAM := false + hasSCRAMPlus := false + hasPlain := false + nonPlain := false + for _, m := range ep.SASLMechanisms { + u := strings.ToUpper(m) + if strings.HasPrefix(u, "SCRAM-") { + hasSCRAM = true + if strings.HasSuffix(u, "-PLUS") { + hasSCRAMPlus = true + } + } + if u == "PLAIN" { + hasPlain = true + } else { + nonPlain = true + } + } + if hasSCRAM { + sawSCRAM[ep.Mode] = true + } + if hasSCRAMPlus { + sawSCRAMPlus[ep.Mode] = true + } + if hasPlain && !nonPlain { + sawPlainOnly[ep.Mode] = true + } + } + // S2S auth posture, only meaningful if we actually parsed the + // post-TLS features. Many public servers don't respond fully to + // anonymous s2s probes; in that case we emit a probe_incomplete + // info instead of falsely asserting "no auth". + if ep.Mode == ModeServer && ep.STARTTLSUpgraded { + if !ep.FeaturesRead { + issues = append(issues, Issue{ + Code: CodeS2SProbeIncomplete, + Severity: SeverityInfo, + Message: "Could not read post-TLS stream features on " + ep.Address + "; server may require an authenticated origin for s2s.", + Fix: "This is often benign for well-run public servers. Try from a real federating host if in doubt.", + Endpoint: ep.Address, + }) + } else if !ep.DialbackOffered && !ep.SASLExternal { + issues = append(issues, Issue{ + Code: CodeS2SNoAuth, + Severity: SeverityCrit, + Message: "No dialback or SASL EXTERNAL advertised on " + ep.Address + " after TLS; federation will fail.", + Fix: "Enable server-to-server dialback, or provision a cert usable for SASL EXTERNAL.", + Endpoint: ep.Address, + }) + } + } + } + + if len(data.Endpoints) > 0 && allDown { + issues = append(issues, Issue{ + Code: CodeAllEndpointsDown, + Severity: SeverityCrit, + Message: "None of the XMPP endpoints could complete STARTTLS.", + Fix: "Verify the server is running and reachable on the published SRV ports.", + }) + } + + if wantC2S && sawAnyWorking[ModeClient] { + if !sawSCRAM[ModeClient] { + issues = append(issues, Issue{ + Code: CodeSASLNoSCRAM, + Severity: SeverityWarn, + Message: "No SCRAM-SHA-* SASL mechanism offered on c2s.", + Fix: "Enable SCRAM-SHA-256 (and SCRAM-SHA-1 for compatibility).", + }) + } + if !sawSCRAMPlus[ModeClient] { + issues = append(issues, Issue{ + Code: CodeSASLNoSCRAMPlus, + Severity: SeverityInfo, + Message: "No SCRAM-SHA-*-PLUS offered (channel binding).", + Fix: "Enable SCRAM-SHA-256-PLUS to protect against TLS MITM.", + }) + } + if sawPlainOnly[ModeClient] { + issues = append(issues, Issue{ + Code: CodeSASLPlainOnly, + Severity: SeverityCrit, + Message: "Only SASL PLAIN is offered on c2s.", + Fix: "Enable SCRAM-SHA-256 so credentials are not sent as a password-equivalent hash.", + }) + } + } + + // IPv6 coverage. + if data.Coverage.HasIPv4 && !data.Coverage.HasIPv6 { + issues = append(issues, Issue{ + Code: CodeNoIPv6, + Severity: SeverityInfo, + Message: "No IPv6 endpoint reachable.", + Fix: "Publish AAAA records for the SRV targets.", + }) + } + + // XEP-0368 direct TLS coverage. + if wantC2S && sawAnyWorking[ModeClient] && len(data.SRV.ClientSecure) == 0 { + issues = append(issues, Issue{ + Code: CodeNoDirectTLS, + Severity: SeverityInfo, + Message: "No XEP-0368 direct-TLS SRV record (_xmpps-client._tcp) published.", + Fix: "Publish _xmpps-client._tcp SRV records pointing at port 5223 to allow TLS from the first byte.", + }) + } + + return issues +} diff --git a/checker/collect_test.go b/checker/collect_test.go new file mode 100644 index 0000000..832f19f --- /dev/null +++ b/checker/collect_test.go @@ -0,0 +1,288 @@ +package checker + +import ( + "encoding/xml" + "strings" + "testing" + + tlsct "git.happydns.org/checker-tls/contract" +) + +func TestReadFeatures_WithStartTLSAndSCRAM(t *testing.T) { + doc := ` + + + SCRAM-SHA-256 + SCRAM-SHA-1 + PLAIN + +` + feats := decodeFeaturesForTest(t, doc) + if feats.StartTLS == nil { + t.Fatal("expected starttls present") + } + if feats.StartTLS.Required == nil { + t.Fatal("expected starttls ") + } + if feats.Mechanisms == nil || len(feats.Mechanisms.Mechanism) != 3 { + t.Fatalf("expected 3 mechanisms, got %+v", feats.Mechanisms) + } +} + +func TestReadFeatures_NoSTARTTLS(t *testing.T) { + doc := ` + + PLAIN + +` + feats := decodeFeaturesForTest(t, doc) + if feats.StartTLS != nil { + t.Fatal("expected no starttls") + } +} + +func TestReadFeatures_S2SDialback(t *testing.T) { + doc := ` + + +` + feats := decodeFeaturesForTest(t, doc) + if feats.Dialback == nil { + t.Fatal("expected dialback feature") + } +} + +func decodeFeaturesForTest(t *testing.T, doc string) *streamFeatures { + t.Helper() + dec := xml.NewDecoder(strings.NewReader(doc)) + feats, err := readFeatures(dec) + if err != nil { + t.Fatalf("readFeatures: %v", err) + } + return feats +} + +func TestApplyFeatures_SASLExternal(t *testing.T) { + ep := EndpointProbe{Mode: ModeServer} + applyFeatures(&ep, &streamFeatures{ + Mechanisms: &mechanismsEl{Mechanism: []string{"EXTERNAL"}}, + }) + if !ep.SASLExternal { + t.Fatal("expected SASLExternal to be set") + } +} + +func TestDeriveIssues_NoSRV(t *testing.T) { + d := &XMPPData{ + Domain: "example.com", + SRV: SRVLookup{FallbackProbed: true}, + } + is := deriveIssues(d, true, true) + if !containsCode(is, CodeNoSRV) { + t.Fatalf("expected %s in %+v", CodeNoSRV, is) + } +} + +func TestDeriveIssues_LegacyJabber(t *testing.T) { + d := &XMPPData{ + SRV: SRVLookup{ + Client: []SRVRecord{{Target: "xmpp.example.com", Port: 5222}}, + Jabber: []SRVRecord{{Target: "xmpp.example.com", Port: 5222}}, + }, + } + is := deriveIssues(d, true, false) + if !containsCode(is, CodeLegacyJabber) { + t.Fatalf("expected %s in %+v", CodeLegacyJabber, is) + } +} + +func TestDeriveIssues_StartTLSMissing(t *testing.T) { + d := &XMPPData{ + SRV: SRVLookup{Client: []SRVRecord{{Target: "x", Port: 5222}}}, + Endpoints: []EndpointProbe{{ + Mode: ModeClient, Address: "1.2.3.4:5222", + TCPConnected: true, StreamOpened: true, STARTTLSOffered: false, + }}, + } + is := deriveIssues(d, true, false) + if !containsCode(is, CodeStartTLSMissing) { + t.Fatalf("expected %s in %+v", CodeStartTLSMissing, is) + } +} + +func TestDeriveIssues_PlainOnlyAndNoSCRAM(t *testing.T) { + d := &XMPPData{ + SRV: SRVLookup{Client: []SRVRecord{{Target: "x", Port: 5222}}}, + Endpoints: []EndpointProbe{{ + Mode: ModeClient, Address: "1.2.3.4:5222", + TCPConnected: true, StreamOpened: true, + STARTTLSOffered: true, STARTTLSRequired: true, STARTTLSUpgraded: true, + FeaturesRead: true, SASLMechanisms: []string{"PLAIN"}, + }}, + } + is := deriveIssues(d, true, false) + if !containsCode(is, CodeSASLPlainOnly) { + t.Fatalf("expected %s in %+v", CodeSASLPlainOnly, is) + } + if !containsCode(is, CodeSASLNoSCRAM) { + t.Fatalf("expected %s in %+v", CodeSASLNoSCRAM, is) + } +} + +func TestDeriveIssues_S2SNoAuth(t *testing.T) { + d := &XMPPData{ + SRV: SRVLookup{Server: []SRVRecord{{Target: "x", Port: 5269}}}, + Endpoints: []EndpointProbe{{ + Mode: ModeServer, Address: "1.2.3.4:5269", + TCPConnected: true, StreamOpened: true, + STARTTLSOffered: true, STARTTLSRequired: true, STARTTLSUpgraded: true, + FeaturesRead: true, + DialbackOffered: false, SASLExternal: false, + }}, + } + is := deriveIssues(d, false, true) + if !containsCode(is, CodeS2SNoAuth) { + t.Fatalf("expected %s in %+v", CodeS2SNoAuth, is) + } +} + +func TestDeriveIssues_HappyPath(t *testing.T) { + d := &XMPPData{ + SRV: SRVLookup{ + Client: []SRVRecord{{Target: "x", Port: 5222}}, + Server: []SRVRecord{{Target: "x", Port: 5269}}, + ClientSecure: []SRVRecord{{Target: "x", Port: 5223}}, + }, + Endpoints: []EndpointProbe{ + { + Mode: ModeClient, Address: "1.2.3.4:5222", + TCPConnected: true, StreamOpened: true, + STARTTLSOffered: true, STARTTLSRequired: true, STARTTLSUpgraded: true, + FeaturesRead: true, + SASLMechanisms: []string{"SCRAM-SHA-256", "SCRAM-SHA-256-PLUS"}, + }, + { + Mode: ModeServer, Address: "1.2.3.4:5269", + TCPConnected: true, StreamOpened: true, + STARTTLSOffered: true, STARTTLSRequired: true, STARTTLSUpgraded: true, + FeaturesRead: true, + DialbackOffered: true, + }, + }, + } + computeCoverage(d) + is := deriveIssues(d, true, true) + for _, i := range is { + if i.Severity == SeverityCrit { + t.Fatalf("unexpected crit issue %s: %s", i.Code, i.Message) + } + } +} + +func TestComputeCoverage_Mixed(t *testing.T) { + d := &XMPPData{ + Endpoints: []EndpointProbe{ + {Mode: ModeClient, TCPConnected: true, IsIPv6: false, STARTTLSUpgraded: true, SASLMechanisms: []string{"PLAIN"}}, + {Mode: ModeClient, TCPConnected: true, IsIPv6: true}, + }, + } + computeCoverage(d) + if !d.Coverage.HasIPv4 { + t.Fatal("expected IPv4 true") + } + if !d.Coverage.HasIPv6 { + t.Fatal("expected IPv6 true") + } + if !d.Coverage.WorkingC2S { + t.Fatal("expected WorkingC2S") + } +} + +func TestDiscoverEntries_AllSets(t *testing.T) { + d := &XMPPData{ + Domain: "example.com", + SRV: SRVLookup{ + Client: []SRVRecord{{Target: "xmpp.example.com", Port: 5222}}, + Server: []SRVRecord{{Target: "xmpp.example.com", Port: 5269}}, + ClientSecure: []SRVRecord{{Target: "xmpp.example.com", Port: 5223}}, + ServerSecure: []SRVRecord{{Target: "xmpp.example.com", Port: 5270}}, + Jabber: []SRVRecord{{Target: "xmpp.example.com", Port: 5222}}, // legacy, not emitted + }, + Endpoints: []EndpointProbe{ + {Mode: ModeClient, Target: "xmpp.example.com", Port: 5222, STARTTLSRequired: true}, + {Mode: ModeServer, Target: "xmpp.example.com", Port: 5269, STARTTLSRequired: false}, + }, + } + p := &xmppProvider{} + raw, err := p.DiscoverEntries(d) + if err != nil { + t.Fatalf("DiscoverEntries: %v", err) + } + if len(raw) != 4 { + t.Fatalf("expected 4 entries (legacy jabber excluded), got %d: %+v", len(raw), raw) + } + + type signature struct { + starttls string + host string + port uint16 + } + by := map[signature]tlsct.TLSEndpoint{} + for i, e := range raw { + if e.Type != tlsct.Type { + t.Errorf("entry %d: Type=%q, want %q", i, e.Type, tlsct.Type) + } + ep, err := tlsct.ParseEntry(e) + if err != nil { + t.Fatalf("entry %d: ParseEntry: %v", i, err) + } + if ep.SNI != "example.com" { + t.Errorf("entry %d: SNI=%q, want example.com", i, ep.SNI) + } + by[signature{ep.STARTTLS, ep.Host, ep.Port}] = ep + } + + c2s, ok := by[signature{"xmpp-client", "xmpp.example.com", 5222}] + if !ok { + t.Fatal("missing c2s entry") + } + if !c2s.RequireSTARTTLS { + t.Errorf("c2s RequireSTARTTLS = false, want true") + } + + s2s, ok := by[signature{"xmpp-server", "xmpp.example.com", 5269}] + if !ok { + t.Fatal("missing s2s entry") + } + if s2s.RequireSTARTTLS { + t.Errorf("s2s RequireSTARTTLS = true, want false (opportunistic)") + } + + directClient, ok := by[signature{"", "xmpp.example.com", 5223}] + if !ok { + t.Fatal("missing direct-TLS client entry") + } + if directClient.STARTTLS != "" || directClient.RequireSTARTTLS { + t.Errorf("direct-TLS entry should carry no STARTTLS posture, got %+v", directClient) + } +} + +func TestDiscoverEntries_WrongType(t *testing.T) { + p := &xmppProvider{} + eps, err := p.DiscoverEntries("not an XMPPData") + if err != nil { + t.Fatalf("expected nil error for wrong type, got %v", err) + } + if eps != nil { + t.Fatalf("expected nil entries for wrong type, got %v", eps) + } +} + +func containsCode(is []Issue, code string) bool { + for _, i := range is { + if i.Code == code { + return true + } + } + return false +} diff --git a/checker/definition.go b/checker/definition.go new file mode 100644 index 0000000..7d26d4a --- /dev/null +++ b/checker/definition.go @@ -0,0 +1,55 @@ +package checker + +import ( + "time" + + sdk "git.happydns.org/checker-sdk-go/checker" +) + +// Version is reported in CheckerDefinition.Version. Overridden at build time +// by main / plugin. +var Version = "built-in" + +func Definition() *sdk.CheckerDefinition { + return &sdk.CheckerDefinition{ + ID: "xmpp", + Name: "XMPP Server", + Version: Version, + Availability: sdk.CheckerAvailability{ + ApplyToService: true, + LimitToServices: []string{"abstract.XMPP"}, + }, + HasHTMLReport: true, + ObservationKeys: []sdk.ObservationKey{ObservationKeyXMPP}, + Options: sdk.CheckerOptionsDocumentation{ + RunOpts: []sdk.CheckerOptionDocumentation{ + { + Id: "domain", + Type: "string", + Label: "Domain", + AutoFill: sdk.AutoFillDomainName, + Required: true, + }, + { + Id: "mode", + Type: "string", + Label: "Mode", + Default: string(ModeBoth), + Choices: validModes, + }, + { + Id: "timeout", + Type: "number", + Label: "Per-endpoint timeout (seconds)", + Default: 10, + }, + }, + }, + Rules: []sdk.CheckRule{Rule()}, + Interval: &sdk.CheckIntervalSpec{ + Min: 5 * time.Minute, + Max: 7 * 24 * time.Hour, + Default: 6 * time.Hour, + }, + } +} diff --git a/checker/provider.go b/checker/provider.go new file mode 100644 index 0000000..450ede7 --- /dev/null +++ b/checker/provider.go @@ -0,0 +1,89 @@ +package checker + +import ( + "net" + "strconv" + + sdk "git.happydns.org/checker-sdk-go/checker" + tlsct "git.happydns.org/checker-tls/contract" +) + +func Provider() sdk.ObservationProvider { + return &xmppProvider{} +} + +type xmppProvider struct{} + +func (p *xmppProvider) Key() sdk.ObservationKey { + return ObservationKeyXMPP +} + +// Definition implements sdk.CheckerDefinitionProvider. +func (p *xmppProvider) Definition() *sdk.CheckerDefinition { + return Definition() +} + +// DiscoverEntries implements sdk.DiscoveryPublisher. +// +// It publishes TLS endpoint contract entries for every SRV target we found, +// so a downstream TLS checker can verify the certificate chain / SAN / +// expiry on each one without re-doing the SRV lookup. The XMPP checker +// itself does not perform certificate verification; that posture lives in +// the TLS checker. +// +// SNI is set to the bare JID domain rather than the SRV target, because XMPP +// certificates must be valid for the source domain (RFC 6120 §13.7.2.1), +// which is typically different from the SRV target hostname. +func (p *xmppProvider) DiscoverEntries(data any) ([]sdk.DiscoveryEntry, error) { + d, ok := data.(*XMPPData) + if !ok || d == nil { + return nil, nil + } + + // Carry over the STARTTLS-required posture observed during probing. + starttlsRequired := map[string]bool{} + for _, ep := range d.Endpoints { + if ep.STARTTLSRequired { + starttlsRequired[endpointKey(ep.Target, ep.Port)] = true + } + } + + var out []sdk.DiscoveryEntry + emit := func(proto string, recs []SRVRecord, directTLS bool) error { + for _, r := range recs { + ep := tlsct.TLSEndpoint{ + Host: r.Target, + Port: r.Port, + SNI: d.Domain, + } + if !directTLS { + ep.STARTTLS = proto + ep.RequireSTARTTLS = starttlsRequired[endpointKey(r.Target, r.Port)] + } + entry, err := tlsct.NewEntry(ep) + if err != nil { + return err + } + out = append(out, entry) + } + return nil + } + if err := emit("xmpp-client", d.SRV.Client, false); err != nil { + return nil, err + } + if err := emit("xmpp-server", d.SRV.Server, false); err != nil { + return nil, err + } + if err := emit("", d.SRV.ClientSecure, true); err != nil { + return nil, err + } + if err := emit("", d.SRV.ServerSecure, true); err != nil { + return nil, err + } + + return out, nil +} + +func endpointKey(host string, port uint16) string { + return net.JoinHostPort(host, strconv.FormatUint(uint64(port), 10)) +} diff --git a/checker/report.go b/checker/report.go new file mode 100644 index 0000000..cb6e29e --- /dev/null +++ b/checker/report.go @@ -0,0 +1,511 @@ +package checker + +import ( + "encoding/json" + "fmt" + "html/template" + "sort" + "strings" + "time" + + sdk "git.happydns.org/checker-sdk-go/checker" +) + +type reportFix struct { + Severity string + Code string + Message string + Fix string + Endpoint string +} + +type reportEndpoint struct { + Mode string + ModeLabel string + SRVPrefix string + Target string + Port uint16 + Address string + IsIPv6 bool + DirectTLS bool + TCPConnected bool + StreamOpened bool + STARTTLSOffered bool + STARTTLSRequired bool + STARTTLSUpgraded bool + TLSVersion string + TLSCipher string + SASLMechanisms []string + DialbackOffered bool + SASLExternal bool + StreamFrom string + ElapsedMS int64 + Error string + + // TLS posture (from a related tls_probes observation, when available). + TLSPosture *reportTLSPosture + + // Rendering helpers. + AnyFail bool + StatusLabel string + StatusClass string +} + +type reportTLSPosture struct { + CheckedAt time.Time + ChainValid *bool + HostnameMatch *bool + NotAfter time.Time + Issues []reportFix +} + +type reportSRVEntry struct { + Prefix string + Target string + Port uint16 + Priority uint16 + Weight uint16 + IPv4 []string + IPv6 []string +} + +type reportData struct { + Domain string + RunAt string + StatusLabel string + StatusClass string + HasIssues bool + Fixes []reportFix + SRV []reportSRVEntry + FallbackProbed bool + JabberLegacy bool + Endpoints []reportEndpoint + HasIPv4 bool + HasIPv6 bool + WorkingC2S bool + WorkingS2S bool + HasTLSPosture bool +} + +var reportTpl = template.Must(template.New("xmpp").Funcs(template.FuncMap{ + "hasPrefix": strings.HasPrefix, + "deref": func(b *bool) bool { return b != nil && *b }, +}).Parse(` + + + + +XMPP Report: {{.Domain}} + + + + +
+

XMPP: {{.Domain}}

+ {{.StatusLabel}} +
+ {{if .WorkingC2S}}c2s OK{{else}}c2s FAIL{{end}} + {{if .WorkingS2S}}s2s OK{{else}}s2s FAIL{{end}} + {{if .HasIPv4}}IPv4{{end}} + {{if .HasIPv6}}IPv6{{end}} +
+
Checked {{.RunAt}}
+
+ +{{if .HasIssues}} +
+

What to fix

+ {{range .Fixes}} +
+
{{.Code}}{{if .Endpoint}} · {{.Endpoint}}{{end}}
+
{{.Message}}
+ {{if .Fix}}
→ {{.Fix}}
{{end}} +
+ {{end}} +
+{{end}} + +
+

DNS / SRV

+ {{if .FallbackProbed}} +

No SRV records published; fell back to probing the bare domain on default ports.

+ {{else if .SRV}} + + + {{range .SRV}} + + + + + + + + + {{end}} +
RecordTargetPortPrio/WeightIPv4IPv6
{{.Prefix}}{{.Target}}{{.Port}}{{.Priority}}/{{.Weight}}{{range .IPv4}}{{.}} {{end}}{{range .IPv6}}{{.}} {{end}}
+ {{else}} +

No SRV records found.

+ {{end}} + {{if .JabberLegacy}}

⚠ Obsolete _jabber._tcp records are still published.

{{end}} +
+ +{{if .Endpoints}} +
+

Endpoints ({{len .Endpoints}})

+ {{range .Endpoints}} + + + {{.ModeLabel}} · {{.Address}}{{if .DirectTLS}} · direct-TLS{{end}} + {{.StatusLabel}} + +
+
+
SRV
{{.SRVPrefix}}{{.Target}}:{{.Port}}
+
Family
{{if .IsIPv6}}IPv6{{else}}IPv4{{end}}
+
TCP
{{if .TCPConnected}}✓ connected{{else}}✗ failed{{end}}
+
Stream
{{if .StreamOpened}}✓ opened{{if .StreamFrom}} (from={{.StreamFrom}}){{end}}{{else}}✗ not opened{{end}}
+ {{if not .DirectTLS}} +
STARTTLS
+ {{if .STARTTLSOffered}}✓ offered{{else}}✗ not offered{{end}} + {{if .STARTTLSRequired}} · required{{else if .STARTTLSOffered}} · not required{{end}} +
+ {{end}} +
TLS
{{if .STARTTLSUpgraded}}✓ {{.TLSVersion}}{{if .TLSCipher}} — {{.TLSCipher}}{{end}}{{else}}✗ no TLS{{end}}
+ {{if eq .Mode "c2s"}} +
SASL
+ {{if .SASLMechanisms}} +
+ {{range .SASLMechanisms}}{{.}}{{end}} +
+ {{else}}none advertised{{end}} +
+ {{end}} + {{if eq .Mode "s2s"}} +
Federation
+ {{if .DialbackOffered}}✓ dialback{{else}}✗ no dialback{{end}} + · + {{if .SASLExternal}}✓ SASL EXTERNAL{{else}}✗ no SASL EXTERNAL{{end}} +
+ {{end}} + {{with .TLSPosture}} +
TLS cert
+ {{if .ChainValid}} + {{if deref .ChainValid}}✓ chain valid{{else}}✗ chain invalid{{end}} + {{end}} + {{if .HostnameMatch}} + · {{if deref .HostnameMatch}}✓ hostname match{{else}}✗ hostname mismatch{{end}} + {{end}} + {{if not .NotAfter.IsZero}} + · expires {{.NotAfter.Format "2006-01-02"}} + {{end}} + {{if not .CheckedAt.IsZero}} +
TLS checked {{.CheckedAt.Format "2006-01-02 15:04 MST"}}
+ {{end}} + {{range .Issues}} +
+
{{.Code}}
+
{{.Message}}
+ {{if .Fix}}
→ {{.Fix}}
{{end}} +
+ {{end}} +
+ {{end}} +
Duration
{{.ElapsedMS}} ms
+ {{if .Error}}
Error
{{.Error}}
{{end}} +
+
+ + {{end}} +
+{{end}} + + + + +`)) + +// GetHTMLReport implements sdk.CheckerHTMLReporter. It folds in related TLS +// observations so the XMPP service page shows cert posture directly, without +// the user having to open a separate TLS report. +func (p *xmppProvider) GetHTMLReport(rctx sdk.ReportContext) (string, error) { + var d XMPPData + if err := json.Unmarshal(rctx.Data(), &d); err != nil { + return "", fmt.Errorf("unmarshal xmpp observation: %w", err) + } + view := buildReportData(&d, rctx.Related(TLSRelatedKey)) + return renderReport(view) +} + +func renderReport(view reportData) (string, error) { + var buf strings.Builder + if err := reportTpl.Execute(&buf, view); err != nil { + return "", fmt.Errorf("render xmpp report: %w", err) + } + return buf.String(), nil +} + +func buildReportData(d *XMPPData, related []sdk.RelatedObservation) reportData { + tlsIssues := tlsIssuesFromRelated(related) + tlsByAddr := indexTLSByAddress(related) + + allIssues := append([]Issue(nil), d.Issues...) + allIssues = append(allIssues, tlsIssues...) + + view := reportData{ + Domain: d.Domain, + RunAt: d.RunAt, + FallbackProbed: d.SRV.FallbackProbed, + JabberLegacy: len(d.SRV.Jabber) > 0, + HasIPv4: d.Coverage.HasIPv4, + HasIPv6: d.Coverage.HasIPv6, + WorkingC2S: d.Coverage.WorkingC2S, + WorkingS2S: d.Coverage.WorkingS2S, + HasIssues: len(allIssues) > 0, + HasTLSPosture: len(tlsByAddr) > 0, + } + + // Status banner. + worst := SeverityInfo + for _, is := range allIssues { + if is.Severity == SeverityCrit { + worst = SeverityCrit + break + } + if is.Severity == SeverityWarn { + worst = SeverityWarn + } + } + if len(allIssues) == 0 { + view.StatusLabel = "OK" + view.StatusClass = "ok" + } else { + switch worst { + case SeverityCrit: + view.StatusLabel = "FAIL" + view.StatusClass = "fail" + case SeverityWarn: + view.StatusLabel = "WARN" + view.StatusClass = "warn" + default: + view.StatusLabel = "INFO" + view.StatusClass = "muted" + } + } + + // Fix list: sort crit → warn → info, preserving order within each severity. + sevRank := func(s string) int { + switch s { + case SeverityCrit: + return 0 + case SeverityWarn: + return 1 + default: + return 2 + } + } + sort.SliceStable(allIssues, func(i, j int) bool { return sevRank(allIssues[i].Severity) < sevRank(allIssues[j].Severity) }) + for _, is := range allIssues { + view.Fixes = append(view.Fixes, reportFix{ + Severity: is.Severity, + Code: is.Code, + Message: is.Message, + Fix: is.Fix, + Endpoint: is.Endpoint, + }) + } + + // SRV rows. + addSRV := func(prefix string, records []SRVRecord) { + for _, r := range records { + view.SRV = append(view.SRV, reportSRVEntry{ + Prefix: prefix, Target: r.Target, Port: r.Port, + Priority: r.Priority, Weight: r.Weight, + IPv4: r.IPv4, IPv6: r.IPv6, + }) + } + } + addSRV("_xmpp-client._tcp", d.SRV.Client) + addSRV("_xmpp-server._tcp", d.SRV.Server) + addSRV("_xmpps-client._tcp", d.SRV.ClientSecure) + addSRV("_xmpps-server._tcp", d.SRV.ServerSecure) + addSRV("_jabber._tcp", d.SRV.Jabber) + + // Endpoint rows. + for _, ep := range d.Endpoints { + re := reportEndpoint{ + Mode: string(ep.Mode), + ModeLabel: modeLabel(ep.Mode), + SRVPrefix: ep.SRVPrefix, + Target: ep.Target, + Port: ep.Port, + Address: ep.Address, + IsIPv6: ep.IsIPv6, + DirectTLS: ep.DirectTLS, + TCPConnected: ep.TCPConnected, + StreamOpened: ep.StreamOpened, + STARTTLSOffered: ep.STARTTLSOffered, + STARTTLSRequired: ep.STARTTLSRequired, + STARTTLSUpgraded: ep.STARTTLSUpgraded, + TLSVersion: ep.TLSVersion, + TLSCipher: ep.TLSCipher, + SASLMechanisms: ep.SASLMechanisms, + DialbackOffered: ep.DialbackOffered, + SASLExternal: ep.SASLExternal, + StreamFrom: ep.StreamFrom, + ElapsedMS: ep.ElapsedMS, + Error: ep.Error, + } + if meta, hit := tlsByAddr[ep.Address]; hit { + re.TLSPosture = meta + } else if meta, hit := tlsByAddr[endpointKey(ep.Target, ep.Port)]; hit { + re.TLSPosture = meta + } + ok := ep.TCPConnected && ep.STARTTLSUpgraded + if ep.Mode == ModeServer { + ok = ok && (ep.DialbackOffered || ep.SASLExternal) + } + if ep.Mode == ModeClient { + ok = ok && len(ep.SASLMechanisms) > 0 + } + re.AnyFail = !ok + if ok { + re.StatusLabel = "OK" + re.StatusClass = "ok" + } else if ep.TCPConnected { + re.StatusLabel = "partial" + re.StatusClass = "warn" + } else { + re.StatusLabel = "unreachable" + re.StatusClass = "fail" + } + view.Endpoints = append(view.Endpoints, re) + } + + return view +} + +func modeLabel(m XMPPMode) string { + switch m { + case ModeClient: + return "client" + case ModeServer: + return "server" + default: + return string(m) + } +} + +// indexTLSByAddress returns a map keyed by "host:port" (and by the SRV +// target:port when host is the target) pointing at a reportTLSPosture. +// This lets the template match a related observation to the right endpoint. +func indexTLSByAddress(related []sdk.RelatedObservation) map[string]*reportTLSPosture { + out := map[string]*reportTLSPosture{} + for _, r := range related { + v := parseTLSRelated(r) + if v == nil { + continue + } + addr := v.address() + if addr == "" { + continue + } + posture := &reportTLSPosture{ + CheckedAt: r.CollectedAt, + ChainValid: v.ChainValid, + HostnameMatch: v.HostnameMatch, + NotAfter: v.NotAfter, + } + for _, is := range v.Issues { + sev := strings.ToLower(is.Severity) + if sev != SeverityCrit && sev != SeverityWarn && sev != SeverityInfo { + continue + } + posture.Issues = append(posture.Issues, reportFix{ + Severity: sev, + Code: is.Code, + Message: is.Message, + Fix: is.Fix, + }) + } + out[addr] = posture + } + return out +} diff --git a/checker/rule.go b/checker/rule.go new file mode 100644 index 0000000..29ed887 --- /dev/null +++ b/checker/rule.go @@ -0,0 +1,147 @@ +package checker + +import ( + "context" + "fmt" + "slices" + "strings" + + sdk "git.happydns.org/checker-sdk-go/checker" +) + +func Rule() sdk.CheckRule { + return &xmppRule{} +} + +type xmppRule struct{} + +func (r *xmppRule) Name() string { + return "xmpp_server" +} + +func (r *xmppRule) Description() string { + return "Checks discovery, STARTTLS, SASL and federation auth of an XMPP server" +} + +func (r *xmppRule) ValidateOptions(opts sdk.CheckerOptions) error { + if v, ok := opts["mode"]; ok { + if s, ok := v.(string); ok && s != "" && !slices.Contains(validModes, s) { + return fmt.Errorf(`mode must be "c2s", "s2s", or "both"`) + } + } + return nil +} + +func (r *xmppRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, opts sdk.CheckerOptions) sdk.CheckState { + var data XMPPData + if err := obs.Get(ctx, ObservationKeyXMPP, &data); err != nil { + return sdk.CheckState{ + Status: sdk.StatusError, + Message: fmt.Sprintf("failed to load XMPP observation: %v", err), + Code: "xmpp.observation_error", + } + } + + issues := append([]Issue(nil), data.Issues...) + + // Fold related TLS observations (from a downstream TLS checker, if any) + // into the XMPP issue list so cert/chain problems show up on the XMPP + // service page without requiring a separate glance at the TLS checker. + related, _ := obs.GetRelated(ctx, TLSRelatedKey) + issues = append(issues, tlsIssuesFromRelated(related)...) + + // Reduce issue list to the worst severity. + worst := sdk.StatusOK + critMsgs, warnMsgs := []string{}, []string{} + var firstCritCode, firstWarnCode string + + for _, is := range issues { + switch is.Severity { + case SeverityCrit: + if worst < sdk.StatusCrit { + worst = sdk.StatusCrit + } + if firstCritCode == "" { + firstCritCode = is.Code + } + critMsgs = append(critMsgs, is.Message) + case SeverityWarn: + if worst < sdk.StatusWarn { + worst = sdk.StatusWarn + } + if firstWarnCode == "" { + firstWarnCode = is.Code + } + warnMsgs = append(warnMsgs, is.Message) + } + } + + mode, _ := sdk.GetOption[string](opts, "mode") + if mode == "" { + mode = "both" + } + wantC2S := mode != "s2s" + wantS2S := mode != "c2s" + + // Even without issues, the check isn't OK unless we got at least one + // working endpoint in each requested mode. + if (wantC2S && !data.Coverage.WorkingC2S) || (wantS2S && !data.Coverage.WorkingS2S) { + if worst < sdk.StatusCrit { + worst = sdk.StatusCrit + } + var missing []string + if wantC2S && !data.Coverage.WorkingC2S { + missing = append(missing, "c2s") + } + if wantS2S && !data.Coverage.WorkingS2S { + missing = append(missing, "s2s") + } + critMsgs = append(critMsgs, "no working "+strings.Join(missing, "/")+" endpoint") + if firstCritCode == "" { + firstCritCode = CodeAllEndpointsDown + } + } + + meta := map[string]any{ + "working_c2s": data.Coverage.WorkingC2S, + "working_s2s": data.Coverage.WorkingS2S, + "has_ipv4": data.Coverage.HasIPv4, + "has_ipv6": data.Coverage.HasIPv6, + "endpoints": len(data.Endpoints), + "issue_count": len(data.Issues), + } + + switch worst { + case sdk.StatusOK: + return sdk.CheckState{ + Status: sdk.StatusOK, + Message: fmt.Sprintf("XMPP operational (c2s=%v, s2s=%v, %d endpoints)", data.Coverage.WorkingC2S, data.Coverage.WorkingS2S, len(data.Endpoints)), + Code: "xmpp.ok", + Meta: meta, + } + case sdk.StatusWarn: + return sdk.CheckState{ + Status: sdk.StatusWarn, + Message: "XMPP works with warnings: " + joinTop(warnMsgs, 2), + Code: firstWarnCode, + Meta: meta, + } + default: + return sdk.CheckState{ + Status: sdk.StatusCrit, + Message: "XMPP broken: " + joinTop(critMsgs, 2), + Code: firstCritCode, + Meta: meta, + } + } +} + +func joinTop(msgs []string, n int) string { + if len(msgs) == 0 { + return "" + } + if len(msgs) <= n { + return strings.Join(msgs, "; ") + } + return strings.Join(msgs[:n], "; ") + fmt.Sprintf(" (+%d more)", len(msgs)-n) +} diff --git a/checker/tls_related.go b/checker/tls_related.go new file mode 100644 index 0000000..e7783f5 --- /dev/null +++ b/checker/tls_related.go @@ -0,0 +1,172 @@ +package checker + +import ( + "encoding/json" + "net" + "strconv" + "strings" + "time" + + sdk "git.happydns.org/checker-sdk-go/checker" +) + +// TLSRelatedKey is the observation key we expect a TLS checker to publish +// for the endpoints we discover. Matches the cross-checker convention +// documented in the happyDomain plan. +const TLSRelatedKey sdk.ObservationKey = "tls_probes" + +// tlsProbeView is our local, permissive view of a TLS checker's payload. +// We read only the fields we need and tolerate missing ones; the TLS +// checker's full schema is owned by that checker. +type tlsProbeView struct { + Host string `json:"host,omitempty"` + Port uint16 `json:"port,omitempty"` + Endpoint string `json:"endpoint,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"` + Issues []struct { + Code string `json:"code"` + Severity string `json:"severity"` + Message string `json:"message,omitempty"` + Fix string `json:"fix,omitempty"` + } `json:"issues,omitempty"` +} + +// address returns the canonical "host:port" used as our matching key against +// XMPP endpoints. Falls back to Endpoint when host/port are unset. +func (v *tlsProbeView) address() string { + if v.Endpoint != "" { + return v.Endpoint + } + if v.Host != "" && v.Port != 0 { + return net.JoinHostPort(v.Host, strconv.Itoa(int(v.Port))) + } + return "" +} + +// parseTLSRelated decodes a RelatedObservation as a TLS probe, gracefully +// returning nil when the payload doesn't look like one. +// +// Two payload shapes are accepted: +// +// 1. {"probes": {"": , …}}: the current convention used by +// checker-tls. The consumer picks its own probe via r.Ref so one +// observation does not leak into another's report. +// 2. : a single top-level probe object, kept for back-compat. +func parseTLSRelated(r sdk.RelatedObservation) *tlsProbeView { + var keyed struct { + Probes map[string]tlsProbeView `json:"probes"` + } + if err := json.Unmarshal(r.Data, &keyed); err == nil && keyed.Probes != nil { + if p, ok := keyed.Probes[r.Ref]; ok { + return &p + } + return nil + } + var v tlsProbeView + if err := json.Unmarshal(r.Data, &v); err != nil { + return nil + } + return &v +} + +// tlsIssuesFromRelated converts downstream TLS observations into Issue +// entries that slot into our own aggregation. When a TLS checker publishes +// its own structured issues we forward them with a code prefix so the +// origin is obvious. When it only exposes structured flags, we synthesize +// one issue per probe. +func tlsIssuesFromRelated(related []sdk.RelatedObservation) []Issue { + var out []Issue + for _, r := range related { + v := parseTLSRelated(r) + if v == nil { + continue + } + addr := v.address() + if len(v.Issues) > 0 { + for _, is := range v.Issues { + sev := strings.ToLower(is.Severity) + switch sev { + case SeverityCrit, SeverityWarn, SeverityInfo: + default: + continue + } + code := is.Code + if code == "" { + code = "tls.unknown" + } + out = append(out, Issue{ + Code: "xmpp.tls." + code, + Severity: sev, + Message: strings.TrimSpace("TLS on " + addr + ": " + is.Message), + Fix: is.Fix, + Endpoint: addr, + }) + } + continue + } + // Flag-only payload: synthesize a single summary issue. + sev := v.worstSeverity() + if sev == "" { + continue + } + msg := "TLS issue reported on " + addr + switch { + case v.ChainValid != nil && !*v.ChainValid: + msg = "Invalid certificate chain on " + addr + case v.HostnameMatch != nil && !*v.HostnameMatch: + msg = "Certificate does not cover the domain on " + addr + case !v.NotAfter.IsZero() && time.Until(v.NotAfter) < 0: + msg = "Certificate expired on " + addr + " (" + v.NotAfter.Format(time.RFC3339) + ")" + case !v.NotAfter.IsZero() && time.Until(v.NotAfter) < 14*24*time.Hour: + msg = "Certificate expiring soon on " + addr + " (" + v.NotAfter.Format(time.RFC3339) + ")" + } + out = append(out, Issue{ + Code: "xmpp.tls.probe", + Severity: sev, + Message: msg, + Fix: "See the TLS checker report for details.", + Endpoint: addr, + }) + } + return out +} + +// worstSeverity returns "crit" > "warn" > "info" across the TLS issues. +func (v *tlsProbeView) worstSeverity() string { + worst := "" + for _, is := range v.Issues { + switch strings.ToLower(is.Severity) { + case SeverityCrit: + return SeverityCrit + case SeverityWarn: + if worst != SeverityCrit { + worst = SeverityWarn + } + case SeverityInfo: + if worst == "" { + worst = SeverityInfo + } + } + } + // Synthesize a worst severity from structured flags if no explicit + // issues list was given (defensive against minimalist TLS checkers). + if v.ChainValid != nil && !*v.ChainValid { + return SeverityCrit + } + if v.HostnameMatch != nil && !*v.HostnameMatch { + return SeverityCrit + } + if !v.NotAfter.IsZero() && time.Until(v.NotAfter) < 0 { + return SeverityCrit + } + if !v.NotAfter.IsZero() && time.Until(v.NotAfter) < 14*24*time.Hour { + if worst != SeverityCrit { + return SeverityWarn + } + } + return worst +} diff --git a/checker/tls_related_test.go b/checker/tls_related_test.go new file mode 100644 index 0000000..dbd3a46 --- /dev/null +++ b/checker/tls_related_test.go @@ -0,0 +1,219 @@ +package checker + +import ( + "context" + "encoding/json" + "strings" + "testing" + "time" + + sdk "git.happydns.org/checker-sdk-go/checker" +) + +// stubObsGetter is a minimal ObservationGetter that returns canned XMPPData +// and a canned list of related observations. +type stubObsGetter struct { + xmpp XMPPData + related []sdk.RelatedObservation + relErr error +} + +func (s *stubObsGetter) Get(_ context.Context, key sdk.ObservationKey, dest any) error { + if key != ObservationKeyXMPP { + return nil + } + b, _ := json.Marshal(s.xmpp) + return json.Unmarshal(b, dest) +} + +func (s *stubObsGetter) GetRelated(_ context.Context, _ sdk.ObservationKey) ([]sdk.RelatedObservation, error) { + return s.related, s.relErr +} + +func mkTLSObs(t *testing.T, payload any) sdk.RelatedObservation { + t.Helper() + b, err := json.Marshal(payload) + if err != nil { + t.Fatalf("marshal tls payload: %v", err) + } + return sdk.RelatedObservation{ + CheckerID: "tls", + Key: TLSRelatedKey, + Data: b, + CollectedAt: time.Now(), + Ref: "ep-1", + } +} + +func TestRule_FoldsTLSCritIntoAggregate(t *testing.T) { + obs := &stubObsGetter{ + xmpp: healthyXMPPData(), + related: []sdk.RelatedObservation{ + mkTLSObs(t, map[string]any{ + "host": "xmpp.example.com", + "port": 5222, + "chain_valid": false, + "hostname_match": true, + }), + }, + } + states := (&xmppRule{}).Evaluate(context.Background(), obs, sdk.CheckerOptions{"domain": "example.com", "mode": "both"}) + state := states[0] + if state.Status != sdk.StatusCrit { + t.Fatalf("expected StatusCrit due to TLS chain invalid, got %s (%s)", state.Status, state.Message) + } + if !strings.Contains(state.Message, "xmpp.example.com:5222") && !strings.Contains(state.Message, "Invalid certificate") { + t.Fatalf("expected TLS message in state, got %q", state.Message) + } +} + +func TestRule_IgnoresUnrelatedTLSObs(t *testing.T) { + obs := &stubObsGetter{ + xmpp: healthyXMPPData(), + related: nil, + } + states := (&xmppRule{}).Evaluate(context.Background(), obs, sdk.CheckerOptions{"domain": "example.com", "mode": "both"}) + state := states[0] + if state.Status != sdk.StatusOK { + t.Fatalf("expected StatusOK without related TLS issues, got %s (%s)", state.Status, state.Message) + } +} + +func TestHTMLReportCtx_IncludesTLSPosture(t *testing.T) { + data := healthyXMPPData() + p := &xmppProvider{} + related := []sdk.RelatedObservation{ + mkTLSObs(t, map[string]any{ + "host": "xmpp.example.com", + "port": 5222, + "chain_valid": true, + "hostname_match": true, + "not_after": time.Now().Add(60 * 24 * time.Hour).Format(time.RFC3339), + "tls_version": "TLS 1.3", + }), + } + rctx := &stubReportCtx{data: mustJSON(t, data), related: related} + html, err := p.GetHTMLReport(rctx) + if err != nil { + t.Fatalf("GetHTMLReport: %v", err) + } + if !strings.Contains(html, "chain valid") { + t.Fatal("expected 'chain valid' in HTML, not found") + } + if !strings.Contains(html, "hostname match") { + t.Fatal("expected 'hostname match' in HTML, not found") + } + if !strings.Contains(html, "TLS checker") { + t.Fatal("expected TLS checker footer mention, not found") + } +} + +func TestHTMLReport_BackCompatNoRelated(t *testing.T) { + data := healthyXMPPData() + p := &xmppProvider{} + // StaticReportContext mimics the host-side "no related observations" path + // (e.g. /report HTTP handler on the remote checker). + html, err := p.GetHTMLReport(sdk.StaticReportContext(mustJSON(t, data))) + if err != nil { + t.Fatalf("GetHTMLReport: %v", err) + } + // Renderer must still produce a valid document and must not include TLS + // posture rows when no related observations were passed. + if !strings.Contains(html, "XMPP Report") { + t.Fatal("expected report title in HTML") + } + if strings.Contains(html, "TLS cert") { + t.Fatal("did not expect 'TLS cert' row without related observations") + } +} + +type stubReportCtx struct { + data json.RawMessage + related []sdk.RelatedObservation +} + +func (s *stubReportCtx) Data() json.RawMessage { return s.data } +func (s *stubReportCtx) Related(_ sdk.ObservationKey) []sdk.RelatedObservation { + return s.related +} + +func mustJSON(t *testing.T, v any) json.RawMessage { + t.Helper() + b, err := json.Marshal(v) + if err != nil { + t.Fatalf("marshal: %v", err) + } + return b +} + +func healthyXMPPData() XMPPData { + return XMPPData{ + Domain: "example.com", + SRV: SRVLookup{ + Client: []SRVRecord{{Target: "xmpp.example.com", Port: 5222}}, + Server: []SRVRecord{{Target: "xmpp.example.com", Port: 5269}}, + }, + Endpoints: []EndpointProbe{ + { + Mode: ModeClient, Target: "xmpp.example.com", Port: 5222, + Address: "xmpp.example.com:5222", + TCPConnected: true, + StreamOpened: true, + STARTTLSOffered: true, + STARTTLSRequired: true, + STARTTLSUpgraded: true, + FeaturesRead: true, + SASLMechanisms: []string{"SCRAM-SHA-256", "SCRAM-SHA-256-PLUS"}, + }, + { + Mode: ModeServer, Target: "xmpp.example.com", Port: 5269, + Address: "xmpp.example.com:5269", + TCPConnected: true, + StreamOpened: true, + STARTTLSOffered: true, + STARTTLSRequired: true, + STARTTLSUpgraded: true, + FeaturesRead: true, + DialbackOffered: true, + }, + }, + Coverage: ReachabilitySpan{HasIPv4: true, WorkingC2S: true, WorkingS2S: true}, + } +} + +func TestTLSIssuesFromRelated_StructuredIssues(t *testing.T) { + related := []sdk.RelatedObservation{ + mkTLSObs(t, map[string]any{ + "host": "xmpp.example.com", + "port": 5222, + "issues": []map[string]any{ + {"code": "tls.self_signed", "severity": "crit", "message": "self-signed cert"}, + {"code": "tls.weak_cipher", "severity": "warn", "message": "weak cipher"}, + }, + }), + } + out := tlsIssuesFromRelated(related) + if len(out) != 2 { + t.Fatalf("expected 2 issues, got %d", len(out)) + } + if out[0].Code != "xmpp.tls.tls.self_signed" || out[0].Severity != SeverityCrit { + t.Fatalf("unexpected first issue: %+v", out[0]) + } +} + +func TestTLSIssuesFromRelated_FlagsOnly(t *testing.T) { + related := []sdk.RelatedObservation{ + mkTLSObs(t, map[string]any{ + "host": "xmpp.example.com", + "port": 5222, + "hostname_match": false, + }), + } + out := tlsIssuesFromRelated(related) + if len(out) != 1 { + t.Fatalf("expected 1 synthesized issue, got %d", len(out)) + } + if out[0].Severity != SeverityCrit || !strings.Contains(out[0].Message, "does not cover") { + t.Fatalf("unexpected synthesized issue: %+v", out[0]) + } +} diff --git a/checker/types.go b/checker/types.go new file mode 100644 index 0000000..94474b0 --- /dev/null +++ b/checker/types.go @@ -0,0 +1,135 @@ +// Package checker implements the XMPP server checker for happyDomain. +// +// It probes a domain's XMPP deployment (SRV discovery, STARTTLS, +// stream features, SASL mechanisms, dialback / SASL EXTERNAL, +// XEP-0368 direct TLS) and reports actionable findings. +// +// TLS certificate chain / SAN / expiry / cipher posture is intentionally +// out of scope; a dedicated TLS checker covers that. +package checker + +import ( + sdk "git.happydns.org/checker-sdk-go/checker" +) + +const ObservationKeyXMPP sdk.ObservationKey = "xmpp" + +type XMPPMode string + +const ( + ModeClient XMPPMode = "c2s" + ModeServer XMPPMode = "s2s" + ModeBoth XMPPMode = "both" +) + +var validModes = []string{string(ModeClient), string(ModeServer), string(ModeBoth)} + +// XMPPData is the full observation stored per run. +type XMPPData struct { + Domain string `json:"domain"` + RunAt string `json:"run_at"` + SRV SRVLookup `json:"srv"` + Endpoints []EndpointProbe `json:"endpoints"` + Coverage ReachabilitySpan `json:"coverage"` + Issues []Issue `json:"issues"` +} + +type SRVLookup struct { + Client []SRVRecord `json:"client,omitempty"` + Server []SRVRecord `json:"server,omitempty"` + ClientSecure []SRVRecord `json:"client_secure,omitempty"` + ServerSecure []SRVRecord `json:"server_secure,omitempty"` + Jabber []SRVRecord `json:"jabber,omitempty"` + // Errors per-set (keyed by record type like "_xmpp-client._tcp"). + Errors map[string]string `json:"errors,omitempty"` + // FallbackProbed is true when no SRV was published and we probed the bare domain. + FallbackProbed bool `json:"fallback_probed,omitempty"` +} + +type SRVRecord struct { + Target string `json:"target"` + Port uint16 `json:"port"` + Priority uint16 `json:"priority"` + Weight uint16 `json:"weight"` + // IPv4 and IPv6 addresses resolved for the target (at probe time). + IPv4 []string `json:"ipv4,omitempty"` + IPv6 []string `json:"ipv6,omitempty"` +} + +// EndpointProbe is the result of probing one (mode, host, port, address) tuple. +type EndpointProbe struct { + Mode XMPPMode `json:"mode"` + SRVPrefix string `json:"srv_prefix"` + Target string `json:"target"` + Port uint16 `json:"port"` + Address string `json:"address"` + IsIPv6 bool `json:"is_ipv6,omitempty"` + DirectTLS bool `json:"direct_tls,omitempty"` + + // What happened. + TCPConnected bool `json:"tcp_connected"` + StreamOpened bool `json:"stream_opened"` + + STARTTLSOffered bool `json:"starttls_offered"` + STARTTLSRequired bool `json:"starttls_required"` + STARTTLSUpgraded bool `json:"starttls_upgraded"` + + TLSVersion string `json:"tls_version,omitempty"` + TLSCipher string `json:"tls_cipher,omitempty"` + + // Post-TLS features. + FeaturesRead bool `json:"features_read,omitempty"` + SASLMechanisms []string `json:"sasl_mechanisms,omitempty"` + DialbackOffered bool `json:"dialback_offered,omitempty"` + SASLExternal bool `json:"sasl_external,omitempty"` + + StreamFrom string `json:"stream_from,omitempty"` + + ElapsedMS int64 `json:"elapsed_ms"` + Error string `json:"error,omitempty"` +} + +type ReachabilitySpan struct { + HasIPv4 bool `json:"has_ipv4"` + HasIPv6 bool `json:"has_ipv6"` + // WorkingC2S is true when at least one c2s endpoint completed TLS + advertised SASL. + WorkingC2S bool `json:"working_c2s"` + // WorkingS2S is true when at least one s2s endpoint completed TLS + advertised dialback or SASL EXTERNAL. + WorkingS2S bool `json:"working_s2s"` +} + +// Issue is a structured finding attached to the observation so the rule and +// the HTML report can both consume them without re-deriving logic. +type Issue struct { + Code string `json:"code"` + Severity string `json:"severity"` // "info" | "warn" | "crit" + Message string `json:"message"` + Fix string `json:"fix,omitempty"` + Endpoint string `json:"endpoint,omitempty"` +} + +// Severities (string for stable JSON, independent of sdk.Status numeric values). +const ( + SeverityInfo = "info" + SeverityWarn = "warn" + SeverityCrit = "crit" +) + +// Issue codes. +const ( + CodeNoSRV = "xmpp.no_srv" + CodeSRVServfail = "xmpp.srv.servfail" + CodeStartTLSMissing = "xmpp.starttls.missing" + CodeStartTLSNotRequired = "xmpp.starttls.not_required" + CodeStartTLSFailed = "xmpp.starttls.handshake_failed" + CodeTCPUnreachable = "xmpp.tcp.unreachable" + CodeSASLPlainOnly = "xmpp.sasl.plain_only" + CodeSASLNoSCRAM = "xmpp.sasl.no_scram" + CodeSASLNoSCRAMPlus = "xmpp.sasl.no_scram_plus" + CodeS2SNoAuth = "xmpp.s2s.no_auth" + CodeS2SProbeIncomplete = "xmpp.s2s.probe_incomplete" + CodeLegacyJabber = "xmpp.legacy_jabber" + CodeNoIPv6 = "xmpp.no_ipv6" + CodeNoDirectTLS = "xmpp.no_direct_tls" + CodeAllEndpointsDown = "xmpp.all_endpoints_down" +) diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..e181a72 --- /dev/null +++ b/go.mod @@ -0,0 +1,17 @@ +module git.happydns.org/checker-xmpp + +go 1.25.0 + +require ( + git.happydns.org/checker-sdk-go v1.1.0 + git.happydns.org/checker-tls v0.1.0 + github.com/miekg/dns v1.1.72 +) + +require ( + golang.org/x/mod v0.33.0 // indirect + golang.org/x/net v0.51.0 // indirect + golang.org/x/sync v0.20.0 // indirect + golang.org/x/sys v0.42.0 // indirect + golang.org/x/tools v0.42.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..5ff535c --- /dev/null +++ b/go.sum @@ -0,0 +1,16 @@ +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= +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/miekg/dns v1.1.72 h1:vhmr+TF2A3tuoGNkLDFK9zi36F2LS+hKTRW0Uf8kbzI= +github.com/miekg/dns v1.1.72/go.mod h1:+EuEPhdHOsfk6Wk5TT2CzssZdqkmFhf8r+aVyDEToIs= +golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8= +golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w= +golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo= +golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y= +golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= +golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= +golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k= +golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0= diff --git a/main.go b/main.go new file mode 100644 index 0000000..ba43cc8 --- /dev/null +++ b/main.go @@ -0,0 +1,28 @@ +package main + +import ( + "flag" + "log" + + sdk "git.happydns.org/checker-sdk-go/checker" + xmpp "git.happydns.org/checker-xmpp/checker" +) + +// Version is the standalone binary's version. It defaults to "custom-build" +// and is meant to be overridden by the CI at link time: +// +// go build -ldflags "-X main.Version=1.2.3" . +var Version = "custom-build" + +var listenAddr = flag.String("listen", ":8080", "HTTP listen address") + +func main() { + flag.Parse() + + xmpp.Version = Version + + server := sdk.NewServer(xmpp.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..d3a0175 --- /dev/null +++ b/plugin/plugin.go @@ -0,0 +1,19 @@ +// Command plugin is the happyDomain plugin entrypoint for the XMPP checker. +// +// It is built as a Go plugin (`go build -buildmode=plugin`) and loaded at +// runtime by happyDomain. +package main + +import ( + sdk "git.happydns.org/checker-sdk-go/checker" + xmpp "git.happydns.org/checker-xmpp/checker" +) + +var Version = "custom-build" + +// NewCheckerPlugin is the symbol resolved by happyDomain when loading the +// .so file. +func NewCheckerPlugin() (*sdk.CheckerDefinition, sdk.ObservationProvider, error) { + xmpp.Version = Version + return xmpp.Definition(), xmpp.Provider(), nil +}