Initial commit

This commit is contained in:
nemunaire 2026-04-21 21:47:58 +07:00
commit d96ebc4d0e
19 changed files with 2537 additions and 0 deletions

2
.gitignore vendored Normal file
View file

@ -0,0 +1,2 @@
checker-xmpp
checker-xmpp.so

14
Dockerfile Normal file
View file

@ -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"]

21
LICENSE Normal file
View file

@ -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.

28
Makefile Normal file
View file

@ -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

25
NOTICE Normal file
View file

@ -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

87
README.md Normal file
View file

@ -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. `<stream:stream>` open, stream features parsing.
4. STARTTLS advertised (and `<required/>`).
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 `<domain>: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`.

664
checker/collect.go Normal file
View file

@ -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, `<starttls xmlns='`+tlsNS+`'/>`); 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
// <stream:features>. 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(`<?xml version='1.0'?><stream:stream xmlns='%s' xmlns:stream='%s' xmlns:db='jabber:server:dialback' version='1.0' to='%s'>`, ns, streamsNS, domain)
} else {
header = fmt.Sprintf(`<?xml version='1.0'?><stream:stream xmlns='%s' xmlns:stream='%s' version='1.0' to='%s'>`, 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 (<failure/>)")
}
}
}
}
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 <required/> 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
}

288
checker/collect_test.go Normal file
View file

@ -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 := `<stream:features xmlns:stream="http://etherx.jabber.org/streams">
<starttls xmlns='urn:ietf:params:xml:ns:xmpp-tls'><required/></starttls>
<mechanisms xmlns='urn:ietf:params:xml:ns:xmpp-sasl'>
<mechanism>SCRAM-SHA-256</mechanism>
<mechanism>SCRAM-SHA-1</mechanism>
<mechanism>PLAIN</mechanism>
</mechanisms>
</stream:features>`
feats := decodeFeaturesForTest(t, doc)
if feats.StartTLS == nil {
t.Fatal("expected starttls present")
}
if feats.StartTLS.Required == nil {
t.Fatal("expected starttls <required/>")
}
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 := `<stream:features xmlns:stream="http://etherx.jabber.org/streams">
<mechanisms xmlns='urn:ietf:params:xml:ns:xmpp-sasl'>
<mechanism>PLAIN</mechanism>
</mechanisms>
</stream:features>`
feats := decodeFeaturesForTest(t, doc)
if feats.StartTLS != nil {
t.Fatal("expected no starttls")
}
}
func TestReadFeatures_S2SDialback(t *testing.T) {
doc := `<stream:features xmlns:stream="http://etherx.jabber.org/streams">
<starttls xmlns='urn:ietf:params:xml:ns:xmpp-tls'><required/></starttls>
<dialback xmlns='urn:xmpp:features:dialback'/>
</stream:features>`
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
}

55
checker/definition.go Normal file
View file

@ -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,
},
}
}

89
checker/provider.go Normal file
View file

@ -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))
}

511
checker/report.go Normal file
View file

@ -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(`<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>XMPP Report: {{.Domain}}</title>
<style>
*, *::before, *::after { box-sizing: border-box; }
:root {
font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
font-size: 14px;
line-height: 1.5;
color: #1f2937;
background: #f3f4f6;
}
body { margin: 0; padding: 1rem; }
code { font-family: ui-monospace, monospace; font-size: .9em; }
h1 { margin: 0 0 .4rem; font-size: 1.15rem; font-weight: 700; }
h2 { font-size: 1rem; font-weight: 700; margin: 0 0 .6rem; }
h3 { font-size: .9rem; font-weight: 600; margin: 0 0 .4rem; }
.hd, .section, details {
background: #fff;
box-shadow: 0 1px 3px rgba(0,0,0,.08);
}
.hd { border-radius: 10px; padding: 1rem 1.25rem; margin-bottom: .75rem; }
.section { border-radius: 8px; padding: .85rem 1rem; margin-bottom: .6rem; }
details { border-radius: 8px; margin-bottom: .45rem; overflow: hidden; }
.badge {
display: inline-flex; align-items: center;
padding: .2em .65em; border-radius: 9999px;
font-size: .78rem; font-weight: 700; letter-spacing: .02em;
}
.ok { background: #d1fae5; color: #065f46; }
.warn { background: #fef3c7; color: #92400e; }
.fail { background: #fee2e2; color: #991b1b; }
.muted { background: #e5e7eb; color: #374151; }
.meta { color: #6b7280; font-size: .82rem; margin-top: .35rem; }
summary {
display: flex; align-items: center; gap: .5rem;
padding: .65rem 1rem; cursor: pointer; user-select: none; list-style: none;
}
summary::-webkit-details-marker { display: none; }
summary::before { content: "▶"; font-size: .65rem; color: #9ca3af; transition: transform .15s; flex-shrink: 0; }
details[open] > summary::before { transform: rotate(90deg); }
.conn-addr { font-weight: 600; flex: 1; font-size: .9rem; font-family: ui-monospace, monospace; }
.details-body { padding: .6rem 1rem .85rem; border-top: 1px solid #f3f4f6; }
table { border-collapse: collapse; width: 100%; font-size: .85rem; }
th, td { text-align: left; padding: .3rem .5rem; border-bottom: 1px solid #f3f4f6; vertical-align: top; }
th { font-weight: 600; color: #6b7280; }
.fix {
border-left: 3px solid #dc2626;
padding: .5rem .75rem; margin-bottom: .5rem;
background: #fef2f2; border-radius: 0 6px 6px 0;
}
.fix.warn { border-color: #f59e0b; background: #fffbeb; }
.fix.info { border-color: #3b82f6; background: #eff6ff; }
.fix .code { font-family: ui-monospace, monospace; font-size: .75rem; color: #6b7280; }
.fix .msg { font-weight: 600; margin: .1rem 0 .2rem; }
.fix .how { font-size: .88rem; }
.fix .ep { font-size: .78rem; color: #6b7280; font-family: ui-monospace, monospace; }
.chiprow { display: flex; flex-wrap: wrap; gap: .25rem; }
.chip {
display: inline-block; padding: .12em .5em;
background: #e0e7ff; color: #3730a3;
border-radius: 4px; font-size: .78rem; font-family: ui-monospace, monospace;
}
.chip.plain { background: #fee2e2; color: #991b1b; }
.chip.scram { background: #d1fae5; color: #065f46; }
.kv { display: grid; grid-template-columns: auto 1fr; gap: .3rem 1rem; font-size: .86rem; }
.kv dt { color: #6b7280; }
.kv dd { margin: 0; }
.note { color: #6b7280; font-size: .85rem; }
.footer { color: #6b7280; font-size: .78rem; text-align: center; margin-top: 1rem; padding-bottom: 2rem; }
.check-ok { color: #059669; }
.check-fail { color: #dc2626; }
</style>
</head>
<body>
<div class="hd">
<h1>XMPP: <code>{{.Domain}}</code></h1>
<span class="badge {{.StatusClass}}">{{.StatusLabel}}</span>
<div class="meta">
{{if .WorkingC2S}}<span class="badge ok" style="margin-right:.25rem">c2s OK</span>{{else}}<span class="badge fail" style="margin-right:.25rem">c2s FAIL</span>{{end}}
{{if .WorkingS2S}}<span class="badge ok" style="margin-right:.25rem">s2s OK</span>{{else}}<span class="badge fail" style="margin-right:.25rem">s2s FAIL</span>{{end}}
{{if .HasIPv4}}<span class="badge muted">IPv4</span>{{end}}
{{if .HasIPv6}}<span class="badge muted">IPv6</span>{{end}}
</div>
<div class="meta">Checked {{.RunAt}}</div>
</div>
{{if .HasIssues}}
<div class="section">
<h2>What to fix</h2>
{{range .Fixes}}
<div class="fix {{.Severity}}">
<div class="code">{{.Code}}{{if .Endpoint}} · {{.Endpoint}}{{end}}</div>
<div class="msg">{{.Message}}</div>
{{if .Fix}}<div class="how">&rarr; {{.Fix}}</div>{{end}}
</div>
{{end}}
</div>
{{end}}
<div class="section">
<h2>DNS / SRV</h2>
{{if .FallbackProbed}}
<p class="note">No SRV records published; fell back to probing the bare domain on default ports.</p>
{{else if .SRV}}
<table>
<tr><th>Record</th><th>Target</th><th>Port</th><th>Prio/Weight</th><th>IPv4</th><th>IPv6</th></tr>
{{range .SRV}}
<tr>
<td><code>{{.Prefix}}</code></td>
<td><code>{{.Target}}</code></td>
<td>{{.Port}}</td>
<td>{{.Priority}}/{{.Weight}}</td>
<td>{{range .IPv4}}<code>{{.}}</code> {{end}}</td>
<td>{{range .IPv6}}<code>{{.}}</code> {{end}}</td>
</tr>
{{end}}
</table>
{{else}}
<p class="note">No SRV records found.</p>
{{end}}
{{if .JabberLegacy}}<p class="note">&#9888; Obsolete <code>_jabber._tcp</code> records are still published.</p>{{end}}
</div>
{{if .Endpoints}}
<div class="section">
<h2>Endpoints ({{len .Endpoints}})</h2>
{{range .Endpoints}}
<details{{if .AnyFail}} open{{end}}>
<summary>
<span class="conn-addr">{{.ModeLabel}} · {{.Address}}{{if .DirectTLS}} · direct-TLS{{end}}</span>
<span class="badge {{.StatusClass}}">{{.StatusLabel}}</span>
</summary>
<div class="details-body">
<dl class="kv">
<dt>SRV</dt><dd><code>{{.SRVPrefix}}</code> &rarr; <code>{{.Target}}:{{.Port}}</code></dd>
<dt>Family</dt><dd>{{if .IsIPv6}}IPv6{{else}}IPv4{{end}}</dd>
<dt>TCP</dt><dd>{{if .TCPConnected}}<span class="check-ok">&#10003; connected</span>{{else}}<span class="check-fail">&#10007; failed</span>{{end}}</dd>
<dt>Stream</dt><dd>{{if .StreamOpened}}<span class="check-ok">&#10003; opened</span>{{if .StreamFrom}} (from=<code>{{.StreamFrom}}</code>){{end}}{{else}}<span class="check-fail">&#10007; not opened</span>{{end}}</dd>
{{if not .DirectTLS}}
<dt>STARTTLS</dt><dd>
{{if .STARTTLSOffered}}<span class="check-ok">&#10003; offered</span>{{else}}<span class="check-fail">&#10007; not offered</span>{{end}}
{{if .STARTTLSRequired}} &middot; <span class="check-ok">required</span>{{else if .STARTTLSOffered}} &middot; <span class="check-fail">not required</span>{{end}}
</dd>
{{end}}
<dt>TLS</dt><dd>{{if .STARTTLSUpgraded}}<span class="check-ok">&#10003; {{.TLSVersion}}{{if .TLSCipher}} &mdash; {{.TLSCipher}}{{end}}</span>{{else}}<span class="check-fail">&#10007; no TLS</span>{{end}}</dd>
{{if eq .Mode "c2s"}}
<dt>SASL</dt><dd>
{{if .SASLMechanisms}}
<div class="chiprow">
{{range .SASLMechanisms}}<span class="chip {{if eq . "PLAIN"}}plain{{else if hasPrefix . "SCRAM-"}}scram{{end}}">{{.}}</span>{{end}}
</div>
{{else}}<span class="note">none advertised</span>{{end}}
</dd>
{{end}}
{{if eq .Mode "s2s"}}
<dt>Federation</dt><dd>
{{if .DialbackOffered}}<span class="check-ok">&#10003; dialback</span>{{else}}<span class="check-fail">&#10007; no dialback</span>{{end}}
&middot;
{{if .SASLExternal}}<span class="check-ok">&#10003; SASL EXTERNAL</span>{{else}}<span class="check-fail">&#10007; no SASL EXTERNAL</span>{{end}}
</dd>
{{end}}
{{with .TLSPosture}}
<dt>TLS cert</dt><dd>
{{if .ChainValid}}
{{if deref .ChainValid}}<span class="check-ok">&#10003; chain valid</span>{{else}}<span class="check-fail">&#10007; chain invalid</span>{{end}}
{{end}}
{{if .HostnameMatch}}
&middot; {{if deref .HostnameMatch}}<span class="check-ok">&#10003; hostname match</span>{{else}}<span class="check-fail">&#10007; hostname mismatch</span>{{end}}
{{end}}
{{if not .NotAfter.IsZero}}
&middot; expires <code>{{.NotAfter.Format "2006-01-02"}}</code>
{{end}}
{{if not .CheckedAt.IsZero}}
<div class="note">TLS checked {{.CheckedAt.Format "2006-01-02 15:04 MST"}}</div>
{{end}}
{{range .Issues}}
<div class="fix {{.Severity}}" style="margin-top:.3rem">
<div class="code">{{.Code}}</div>
<div class="msg">{{.Message}}</div>
{{if .Fix}}<div class="how">&rarr; {{.Fix}}</div>{{end}}
</div>
{{end}}
</dd>
{{end}}
<dt>Duration</dt><dd>{{.ElapsedMS}} ms</dd>
{{if .Error}}<dt>Error</dt><dd><span class="check-fail">{{.Error}}</span></dd>{{end}}
</dl>
</div>
</details>
{{end}}
</div>
{{end}}
<p class="footer">{{if .HasTLSPosture}}Certificate posture above comes from the TLS checker, which probed the same endpoints after we discovered them.{{else}}For certificate chain, SAN match, expiry, and cipher posture, run the TLS checker on the same ports.{{end}}</p>
</body>
</html>`))
// 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
}

147
checker/rule.go Normal file
View file

@ -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)
}

172
checker/tls_related.go Normal file
View file

@ -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": {"<ref>": <probe>, …}}: 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. <probe>: 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
}

219
checker/tls_related_test.go Normal file
View file

@ -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, "<title>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])
}
}

135
checker/types.go Normal file
View file

@ -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"
)

17
go.mod Normal file
View file

@ -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
)

16
go.sum Normal file
View file

@ -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=

28
main.go Normal file
View file

@ -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)
}
}

19
plugin/plugin.go Normal file
View file

@ -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
}