Compare commits

...

No commits in common. "2c2fb0712989a94b0075eb8dcea91eece2976eca" and "bda66e47ebbcd735e5341ad401ff7f0f4ef8117c" have entirely different histories.

15 changed files with 255 additions and 160 deletions

2
.gitignore vendored Normal file
View file

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

View file

@ -8,17 +8,31 @@ STARTTLS, SASL mechanisms, federation auth (dialback / SASL EXTERNAL),
and XEP-0368 direct-TLS. Produces an actionable HTML report with a and XEP-0368 direct-TLS. Produces an actionable HTML report with a
remediation panel surfacing the most common real-world failures. remediation panel surfacing the most common real-world failures.
TLS certificate chain / SAN / expiry / cipher posture is **out of scope** TLS certificate chain / SAN / expiry / cipher posture is **out of scope**:
a dedicated TLS checker handles that. This checker only confirms that a dedicated TLS checker handles that. This checker only confirms that
STARTTLS completes and records the negotiated TLS version/cipher for STARTTLS completes and records the negotiated TLS version/cipher for
context. context.
When a TLS checker runs against the endpoints we publish via We publish each probed endpoint as a `DiscoveryEntry` of type
`EndpointDiscoverer`, its observations are automatically folded into our `tls.endpoint.v1` so that `checker-tls` (or any other consumer of that
rule aggregation and HTML report via the SDK's `GetRelated` / contract) can run TLS posture checks against them without redoing the
`CheckerHTMLReporterCtx` composition path — so a bad cert on an XMPP SRV lookup. The entries are produced through
endpoint shows up on the XMPP service page, not only in a separate TLS `git.happydns.org/checker-tls/contract`, with `SNI` set to the bare JID
view. The expected observation key is `tls_probes`. 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 ## What it checks

View file

@ -24,6 +24,14 @@ const (
tlsNS = "urn:ietf:params:xml:ns:xmpp-tls" 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. // Collect runs the full XMPP probe for a domain.
func (p *xmppProvider) Collect(ctx context.Context, opts sdk.CheckerOptions) (any, error) { func (p *xmppProvider) Collect(ctx context.Context, opts sdk.CheckerOptions) (any, error) {
domain, _ := sdk.GetOption[string](opts, "domain") domain, _ := sdk.GetOption[string](opts, "domain")
@ -126,8 +134,8 @@ func probeSet(ctx context.Context, data *XMPPData, domain string, mode XMPPMode,
} }
type probeAddr struct { type probeAddr struct {
ip string ip string
isV6 bool isV6 bool
} }
func addressesForProbe(rec SRVRecord) []probeAddr { func addressesForProbe(rec SRVRecord) []probeAddr {
@ -162,7 +170,7 @@ func probeEndpoint(ctx context.Context, domain string, mode XMPPMode, prefix str
dialCtx, cancel := context.WithTimeout(ctx, timeout) dialCtx, cancel := context.WithTimeout(ctx, timeout)
defer cancel() defer cancel()
dialer := &net.Dialer{Timeout: timeout} dialer := &net.Dialer{}
rawConn, err := dialer.DialContext(dialCtx, "tcp", result.Address) rawConn, err := dialer.DialContext(dialCtx, "tcp", result.Address)
if err != nil { if err != nil {
result.Error = "tcp: " + err.Error() result.Error = "tcp: " + err.Error()
@ -175,12 +183,8 @@ func probeEndpoint(ctx context.Context, domain string, mode XMPPMode, prefix str
var conn net.Conn = rawConn var conn net.Conn = rawConn
if directTLS { if directTLS {
tlsConn := tls.Client(rawConn, &tls.Config{ tlsConn := tls.Client(rawConn, tlsProbeConfig(domain))
ServerName: domain, if err := tlsConn.Handshake(); err != nil {
InsecureSkipVerify: true, //nolint:gosec — cert validation is the TLS checker's job
MinVersion: tls.VersionTLS10,
})
if err := tlsConn.HandshakeContext(dialCtx); err != nil {
result.Error = "tls-handshake: " + err.Error() result.Error = "tls-handshake: " + err.Error()
return result return result
} }
@ -202,7 +206,6 @@ func probeEndpoint(ctx context.Context, domain string, mode XMPPMode, prefix str
return result return result
} }
// STARTTLS path.
dec, from, err := openStream(conn, domain, ns, mode == ModeServer) dec, from, err := openStream(conn, domain, ns, mode == ModeServer)
if err != nil { if err != nil {
result.Error = "stream: " + err.Error() result.Error = "stream: " + err.Error()
@ -222,13 +225,12 @@ func probeEndpoint(ctx context.Context, domain string, mode XMPPMode, prefix str
} }
if !result.STARTTLSOffered { if !result.STARTTLSOffered {
// Record any features seen in plaintext, but do not proceed we // Record any features seen in plaintext, but do not proceed; we
// intentionally refuse to send SASL over a non-TLS channel. // intentionally refuse to send SASL over a non-TLS channel.
applyFeatures(&result, feats) applyFeatures(&result, feats)
return result return result
} }
// Request STARTTLS.
if _, err := io.WriteString(conn, `<starttls xmlns='`+tlsNS+`'/>`); err != nil { if _, err := io.WriteString(conn, `<starttls xmlns='`+tlsNS+`'/>`); err != nil {
result.Error = "starttls-write: " + err.Error() result.Error = "starttls-write: " + err.Error()
return result return result
@ -238,14 +240,9 @@ func probeEndpoint(ctx context.Context, domain string, mode XMPPMode, prefix str
return result return result
} }
// Upgrade. tlsConn := tls.Client(rawConn, tlsProbeConfig(domain))
tlsConn := tls.Client(rawConn, &tls.Config{
ServerName: domain,
InsecureSkipVerify: true, //nolint:gosec — cert validation is the TLS checker's job
MinVersion: tls.VersionTLS10,
})
_ = tlsConn.SetDeadline(time.Now().Add(timeout)) _ = tlsConn.SetDeadline(time.Now().Add(timeout))
if err := tlsConn.HandshakeContext(dialCtx); err != nil { if err := tlsConn.Handshake(); err != nil {
result.Error = "tls-handshake: " + err.Error() result.Error = "tls-handshake: " + err.Error()
return result return result
} }
@ -283,8 +280,6 @@ func applyFeatures(ep *EndpointProbe, feats *streamFeatures) {
} }
} }
// ─── Stream / XML plumbing ────────────────────────────────────────────────────
type streamFeatures struct { type streamFeatures struct {
XMLName xml.Name `xml:"http://etherx.jabber.org/streams features"` XMLName xml.Name `xml:"http://etherx.jabber.org/streams features"`
StartTLS *startTLSEl StartTLS *startTLSEl
@ -400,8 +395,6 @@ func expectProceed(dec *xml.Decoder) error {
} }
} }
// ─── DNS ──────────────────────────────────────────────────────────────────────
func lookupSRV(ctx context.Context, r *net.Resolver, prefix, domain string) ([]SRVRecord, error) { func lookupSRV(ctx context.Context, r *net.Resolver, prefix, domain string) ([]SRVRecord, error) {
name := prefix + dns.Fqdn(domain) name := prefix + dns.Fqdn(domain)
_, records, err := r.LookupSRV(ctx, "", "", name) _, records, err := r.LookupSRV(ctx, "", "", name)
@ -446,8 +439,6 @@ func resolveAllInto(ctx context.Context, r *net.Resolver, records []SRVRecord) {
} }
} }
// ─── Coverage + issues ────────────────────────────────────────────────────────
func computeCoverage(data *XMPPData) { func computeCoverage(data *XMPPData) {
for _, ep := range data.Endpoints { for _, ep := range data.Endpoints {
if ep.TCPConnected { if ep.TCPConnected {
@ -517,9 +508,6 @@ func deriveIssues(data *XMPPData, wantC2S, _ bool) []Issue {
sawAnyWorking := map[XMPPMode]bool{} sawAnyWorking := map[XMPPMode]bool{}
for _, ep := range data.Endpoints { for _, ep := range data.Endpoints {
if ep.DirectTLS {
// direct-TLS endpoints don't contribute to STARTTLS-missing logic
}
if ep.TCPConnected && ep.STARTTLSUpgraded { if ep.TCPConnected && ep.STARTTLSUpgraded {
allDown = false allDown = false
sawAnyWorking[ep.Mode] = true sawAnyWorking[ep.Mode] = true
@ -591,7 +579,7 @@ func deriveIssues(data *XMPPData, wantC2S, _ bool) []Issue {
sawPlainOnly[ep.Mode] = true sawPlainOnly[ep.Mode] = true
} }
} }
// S2S auth posture only meaningful if we actually parsed the // S2S auth posture, only meaningful if we actually parsed the
// post-TLS features. Many public servers don't respond fully to // post-TLS features. Many public servers don't respond fully to
// anonymous s2s probes; in that case we emit a probe_incomplete // anonymous s2s probes; in that case we emit a probe_incomplete
// info instead of falsely asserting "no auth". // info instead of falsely asserting "no auth".
@ -600,7 +588,7 @@ func deriveIssues(data *XMPPData, wantC2S, _ bool) []Issue {
issues = append(issues, Issue{ issues = append(issues, Issue{
Code: CodeS2SProbeIncomplete, Code: CodeS2SProbeIncomplete,
Severity: SeverityInfo, Severity: SeverityInfo,
Message: "Could not read post-TLS stream features on " + ep.Address + " server may require an authenticated origin for s2s.", 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.", Fix: "This is often benign for well-run public servers. Try from a real federating host if in doubt.",
Endpoint: ep.Address, Endpoint: ep.Address,
}) })
@ -608,7 +596,7 @@ func deriveIssues(data *XMPPData, wantC2S, _ bool) []Issue {
issues = append(issues, Issue{ issues = append(issues, Issue{
Code: CodeS2SNoAuth, Code: CodeS2SNoAuth,
Severity: SeverityCrit, Severity: SeverityCrit,
Message: "No dialback or SASL EXTERNAL advertised on " + ep.Address + " after TLS federation will fail.", 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.", Fix: "Enable server-to-server dialback, or provision a cert usable for SASL EXTERNAL.",
Endpoint: ep.Address, Endpoint: ep.Address,
}) })

View file

@ -4,6 +4,8 @@ import (
"encoding/xml" "encoding/xml"
"strings" "strings"
"testing" "testing"
tlsct "git.happydns.org/checker-tls/contract"
) )
func TestReadFeatures_WithStartTLSAndSCRAM(t *testing.T) { func TestReadFeatures_WithStartTLSAndSCRAM(t *testing.T) {
@ -196,7 +198,7 @@ func TestComputeCoverage_Mixed(t *testing.T) {
} }
} }
func TestDiscoverEndpoints_AllSets(t *testing.T) { func TestDiscoverEntries_AllSets(t *testing.T) {
d := &XMPPData{ d := &XMPPData{
Domain: "example.com", Domain: "example.com",
SRV: SRVLookup{ SRV: SRVLookup{
@ -212,59 +214,70 @@ func TestDiscoverEndpoints_AllSets(t *testing.T) {
}, },
} }
p := &xmppProvider{} p := &xmppProvider{}
eps, err := p.DiscoverEndpoints(d) raw, err := p.DiscoverEntries(d)
if err != nil { if err != nil {
t.Fatalf("DiscoverEndpoints: %v", err) t.Fatalf("DiscoverEntries: %v", err)
} }
if len(eps) != 4 { if len(raw) != 4 {
t.Fatalf("expected 4 endpoints (legacy jabber excluded), got %d: %+v", len(eps), eps) t.Fatalf("expected 4 entries (legacy jabber excluded), got %d: %+v", len(raw), raw)
} }
by := map[string]int{}
for i, e := range eps { type signature struct {
by[e.Type+":"+e.Host+":"+itoa(int(e.Port))] = i starttls string
if e.SNI != "example.com" { host string
t.Errorf("endpoint %d: SNI=%q, want example.com", i, e.SNI) 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 := eps[by["starttls-xmpp-client:xmpp.example.com:5222"]]
if c2s.Meta["starttls"] != "required" { c2s, ok := by[signature{"xmpp-client", "xmpp.example.com", 5222}]
t.Errorf("c2s starttls meta = %v, want required", c2s.Meta) if !ok {
t.Fatal("missing c2s entry")
} }
s2s := eps[by["starttls-xmpp-server:xmpp.example.com:5269"]] if !c2s.RequireSTARTTLS {
if s2s.Meta["starttls"] != "opportunistic" { t.Errorf("c2s RequireSTARTTLS = false, want true")
t.Errorf("s2s starttls meta = %v, want opportunistic", s2s.Meta)
} }
direct := eps[by["tls:xmpp.example.com:5223"]]
if direct.Meta != nil { s2s, ok := by[signature{"xmpp-server", "xmpp.example.com", 5269}]
t.Errorf("direct-TLS endpoint should not carry starttls meta, got %v", direct.Meta) 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 TestDiscoverEndpoints_WrongType(t *testing.T) { func TestDiscoverEntries_WrongType(t *testing.T) {
p := &xmppProvider{} p := &xmppProvider{}
eps, err := p.DiscoverEndpoints("not an XMPPData") eps, err := p.DiscoverEntries("not an XMPPData")
if err != nil { if err != nil {
t.Fatalf("expected nil error for wrong type, got %v", err) t.Fatalf("expected nil error for wrong type, got %v", err)
} }
if eps != nil { if eps != nil {
t.Fatalf("expected nil endpoints for wrong type, got %v", eps) t.Fatalf("expected nil entries for wrong type, got %v", eps)
} }
} }
func itoa(i int) string {
if i == 0 {
return "0"
}
var buf [6]byte
pos := len(buf)
for i > 0 {
pos--
buf[pos] = byte('0' + i%10)
i /= 10
}
return string(buf[pos:])
}
func containsCode(is []Issue, code string) bool { func containsCode(is []Issue, code string) bool {
for _, i := range is { for _, i := range is {
if i.Code == code { if i.Code == code {

66
checker/interactive.go Normal file
View file

@ -0,0 +1,66 @@
package checker
import (
"errors"
"net/http"
"strconv"
"strings"
sdk "git.happydns.org/checker-sdk-go/checker"
)
// RenderForm implements sdk.CheckerInteractive.
func (p *xmppProvider) RenderForm() []sdk.CheckerOptionField {
return []sdk.CheckerOptionField{
{
Id: "domain",
Type: "string",
Label: "Domain",
Placeholder: "example.com",
Required: true,
},
{
Id: "mode",
Type: "string",
Label: "Mode",
Default: "both",
Choices: []string{"c2s", "s2s", "both"},
},
{
Id: "timeout",
Type: "number",
Label: "Per-endpoint timeout (seconds)",
Default: 10,
},
}
}
// ParseForm implements sdk.CheckerInteractive.
func (p *xmppProvider) ParseForm(r *http.Request) (sdk.CheckerOptions, error) {
domain := strings.TrimSpace(r.FormValue("domain"))
domain = strings.TrimSuffix(domain, ".")
if domain == "" {
return nil, errors.New("domain is required")
}
opts := sdk.CheckerOptions{"domain": domain}
if mode := strings.TrimSpace(r.FormValue("mode")); mode != "" {
switch mode {
case "c2s", "s2s", "both":
opts["mode"] = mode
default:
return nil, errors.New("mode must be one of: c2s, s2s, both")
}
}
if to := strings.TrimSpace(r.FormValue("timeout")); to != "" {
v, err := strconv.ParseFloat(to, 64)
if err != nil {
return nil, errors.New("timeout must be a number")
}
opts["timeout"] = v
}
return opts, nil
}

View file

@ -1,9 +1,11 @@
package checker package checker
import ( import (
"net"
"strconv" "strconv"
sdk "git.happydns.org/checker-sdk-go/checker" sdk "git.happydns.org/checker-sdk-go/checker"
tlsct "git.happydns.org/checker-tls/contract"
) )
func Provider() sdk.ObservationProvider { func Provider() sdk.ObservationProvider {
@ -21,17 +23,18 @@ func (p *xmppProvider) Definition() *sdk.CheckerDefinition {
return Definition() return Definition()
} }
// DiscoverEndpoints implements sdk.EndpointDiscoverer. // DiscoverEntries implements sdk.DiscoveryPublisher.
// //
// It publishes the (host, port) pairs of every SRV target we found, so a // It publishes TLS endpoint contract entries for every SRV target we found,
// downstream TLS checker can verify the certificate chain / SAN / expiry on // so a downstream TLS checker can verify the certificate chain / SAN /
// each one without re-doing the SRV lookup. The XMPP checker itself does not // expiry on each one without re-doing the SRV lookup. The XMPP checker
// perform certificate verification — that posture lives in the TLS 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 // 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), // certificates must be valid for the source domain (RFC 6120 §13.7.2.1),
// which is typically different from the SRV target hostname. // which is typically different from the SRV target hostname.
func (p *xmppProvider) DiscoverEndpoints(data any) ([]sdk.DiscoveredEndpoint, error) { func (p *xmppProvider) DiscoverEntries(data any) ([]sdk.DiscoveryEntry, error) {
d, ok := data.(*XMPPData) d, ok := data.(*XMPPData)
if !ok || d == nil { if !ok || d == nil {
return nil, nil return nil, nil
@ -45,33 +48,42 @@ func (p *xmppProvider) DiscoverEndpoints(data any) ([]sdk.DiscoveredEndpoint, er
} }
} }
var out []sdk.DiscoveredEndpoint var out []sdk.DiscoveryEntry
emit := func(epType string, recs []SRVRecord, directTLS bool) { emit := func(proto string, recs []SRVRecord, directTLS bool) error {
for _, r := range recs { for _, r := range recs {
ep := sdk.DiscoveredEndpoint{ ep := tlsct.TLSEndpoint{
Type: epType,
Host: r.Target, Host: r.Target,
Port: r.Port, Port: r.Port,
SNI: d.Domain, SNI: d.Domain,
} }
if !directTLS { if !directTLS {
mode := "opportunistic" ep.STARTTLS = proto
if starttlsRequired[endpointKey(r.Target, r.Port)] { ep.RequireSTARTTLS = starttlsRequired[endpointKey(r.Target, r.Port)]
mode = "required"
}
ep.Meta = map[string]any{"starttls": mode}
} }
out = append(out, ep) 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
} }
emit("starttls-xmpp-client", d.SRV.Client, false)
emit("starttls-xmpp-server", d.SRV.Server, false)
emit("tls", d.SRV.ClientSecure, true)
emit("tls", d.SRV.ServerSecure, true)
return out, nil return out, nil
} }
func endpointKey(host string, port uint16) string { func endpointKey(host string, port uint16) string {
return host + ":" + strconv.Itoa(int(port)) return net.JoinHostPort(host, strconv.FormatUint(uint64(port), 10))
} }

View file

@ -95,7 +95,7 @@ var reportTpl = template.Must(template.New("xmpp").Funcs(template.FuncMap{
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>XMPP Report {{.Domain}}</title> <title>XMPP Report: {{.Domain}}</title>
<style> <style>
*, *::before, *::after { box-sizing: border-box; } *, *::before, *::after { box-sizing: border-box; }
:root { :root {
@ -179,7 +179,7 @@ th { font-weight: 600; color: #6b7280; }
<body> <body>
<div class="hd"> <div class="hd">
<h1>XMPP <code>{{.Domain}}</code></h1> <h1>XMPP: <code>{{.Domain}}</code></h1>
<span class="badge {{.StatusClass}}">{{.StatusLabel}}</span> <span class="badge {{.StatusClass}}">{{.StatusLabel}}</span>
<div class="meta"> <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 .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}}
@ -206,7 +206,7 @@ th { font-weight: 600; color: #6b7280; }
<div class="section"> <div class="section">
<h2>DNS / SRV</h2> <h2>DNS / SRV</h2>
{{if .FallbackProbed}} {{if .FallbackProbed}}
<p class="note">No SRV records published fell back to probing the bare domain on default ports.</p> <p class="note">No SRV records published; fell back to probing the bare domain on default ports.</p>
{{else if .SRV}} {{else if .SRV}}
<table> <table>
<tr><th>Record</th><th>Target</th><th>Port</th><th>Prio/Weight</th><th>IPv4</th><th>IPv6</th></tr> <tr><th>Record</th><th>Target</th><th>Port</th><th>Prio/Weight</th><th>IPv4</th><th>IPv6</th></tr>

View file

@ -31,14 +31,14 @@ func (r *xmppRule) ValidateOptions(opts sdk.CheckerOptions) error {
return nil return nil
} }
func (r *xmppRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, opts sdk.CheckerOptions) sdk.CheckState { func (r *xmppRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, opts sdk.CheckerOptions) []sdk.CheckState {
var data XMPPData var data XMPPData
if err := obs.Get(ctx, ObservationKeyXMPP, &data); err != nil { if err := obs.Get(ctx, ObservationKeyXMPP, &data); err != nil {
return sdk.CheckState{ return []sdk.CheckState{{
Status: sdk.StatusError, Status: sdk.StatusError,
Message: fmt.Sprintf("failed to load XMPP observation: %v", err), Message: fmt.Sprintf("failed to load XMPP observation: %v", err),
Code: "xmpp.observation_error", Code: "xmpp.observation_error",
} }}
} }
issues := append([]Issue(nil), data.Issues...) issues := append([]Issue(nil), data.Issues...)
@ -88,7 +88,7 @@ func (r *xmppRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, opts
if worst < sdk.StatusCrit { if worst < sdk.StatusCrit {
worst = sdk.StatusCrit worst = sdk.StatusCrit
} }
missing := []string{} var missing []string
if wantC2S && !data.Coverage.WorkingC2S { if wantC2S && !data.Coverage.WorkingC2S {
missing = append(missing, "c2s") missing = append(missing, "c2s")
} }
@ -102,36 +102,36 @@ func (r *xmppRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, opts
} }
meta := map[string]any{ meta := map[string]any{
"working_c2s": data.Coverage.WorkingC2S, "working_c2s": data.Coverage.WorkingC2S,
"working_s2s": data.Coverage.WorkingS2S, "working_s2s": data.Coverage.WorkingS2S,
"has_ipv4": data.Coverage.HasIPv4, "has_ipv4": data.Coverage.HasIPv4,
"has_ipv6": data.Coverage.HasIPv6, "has_ipv6": data.Coverage.HasIPv6,
"endpoints": len(data.Endpoints), "endpoints": len(data.Endpoints),
"issue_count": len(data.Issues), "issue_count": len(data.Issues),
} }
switch worst { switch worst {
case sdk.StatusOK: case sdk.StatusOK:
return sdk.CheckState{ return []sdk.CheckState{{
Status: sdk.StatusOK, Status: sdk.StatusOK,
Message: fmt.Sprintf("XMPP operational (c2s=%v, s2s=%v, %d endpoints)", data.Coverage.WorkingC2S, data.Coverage.WorkingS2S, len(data.Endpoints)), Message: fmt.Sprintf("XMPP operational (c2s=%v, s2s=%v, %d endpoints)", data.Coverage.WorkingC2S, data.Coverage.WorkingS2S, len(data.Endpoints)),
Code: "xmpp.ok", Code: "xmpp.ok",
Meta: meta, Meta: meta,
} }}
case sdk.StatusWarn: case sdk.StatusWarn:
return sdk.CheckState{ return []sdk.CheckState{{
Status: sdk.StatusWarn, Status: sdk.StatusWarn,
Message: "XMPP works with warnings: " + joinTop(warnMsgs, 2), Message: "XMPP works with warnings: " + joinTop(warnMsgs, 2),
Code: firstWarnCode, Code: firstWarnCode,
Meta: meta, Meta: meta,
} }}
default: default:
return sdk.CheckState{ return []sdk.CheckState{{
Status: sdk.StatusCrit, Status: sdk.StatusCrit,
Message: "XMPP broken: " + joinTop(critMsgs, 2), Message: "XMPP broken: " + joinTop(critMsgs, 2),
Code: firstCritCode, Code: firstCritCode,
Meta: meta, Meta: meta,
} }}
} }
} }

View file

@ -2,7 +2,6 @@ package checker
import ( import (
"encoding/json" "encoding/json"
"log"
"net" "net"
"strconv" "strconv"
"strings" "strings"
@ -17,7 +16,7 @@ import (
const TLSRelatedKey sdk.ObservationKey = "tls_probes" const TLSRelatedKey sdk.ObservationKey = "tls_probes"
// tlsProbeView is our local, permissive view of a TLS checker's payload. // 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 // We read only the fields we need and tolerate missing ones; the TLS
// checker's full schema is owned by that checker. // checker's full schema is owned by that checker.
type tlsProbeView struct { type tlsProbeView struct {
Host string `json:"host,omitempty"` Host string `json:"host,omitempty"`
@ -53,17 +52,16 @@ func (v *tlsProbeView) address() string {
// //
// Two payload shapes are accepted: // Two payload shapes are accepted:
// //
// 1. {"probes": {"<endpointId>": <probe>, …}} — the current convention // 1. {"probes": {"<ref>": <probe>, …}}: the current convention used by
// used by checker-tls. The consumer picks its own probe via // checker-tls. The consumer picks its own probe via r.Ref so one
// r.EndpointID so one observation does not leak into another's report. // observation does not leak into another's report.
// 2. <probe> a single top-level probe object, kept for back-compat. // 2. <probe>: a single top-level probe object, kept for back-compat.
func parseTLSRelated(r sdk.RelatedObservation) *tlsProbeView { func parseTLSRelated(r sdk.RelatedObservation) *tlsProbeView {
log.Printf("xmpp parseTLSRelated: endpointID=%q data=%s", r.EndpointID, r.Data)
var keyed struct { var keyed struct {
Probes map[string]tlsProbeView `json:"probes"` Probes map[string]tlsProbeView `json:"probes"`
} }
if err := json.Unmarshal(r.Data, &keyed); err == nil && keyed.Probes != nil { if err := json.Unmarshal(r.Data, &keyed); err == nil && keyed.Probes != nil {
if p, ok := keyed.Probes[r.EndpointID]; ok { if p, ok := keyed.Probes[r.Ref]; ok {
return &p return &p
} }
return nil return nil

View file

@ -41,7 +41,7 @@ func mkTLSObs(t *testing.T, payload any) sdk.RelatedObservation {
Key: TLSRelatedKey, Key: TLSRelatedKey,
Data: b, Data: b,
CollectedAt: time.Now(), CollectedAt: time.Now(),
EndpointID: "ep-1", Ref: "ep-1",
} }
} }

View file

@ -5,7 +5,7 @@
// XEP-0368 direct TLS) and reports actionable findings. // XEP-0368 direct TLS) and reports actionable findings.
// //
// TLS certificate chain / SAN / expiry / cipher posture is intentionally // TLS certificate chain / SAN / expiry / cipher posture is intentionally
// out of scope a dedicated TLS checker covers that. // out of scope; a dedicated TLS checker covers that.
package checker package checker
import ( import (
@ -23,12 +23,12 @@ const (
// XMPPData is the full observation stored per run. // XMPPData is the full observation stored per run.
type XMPPData struct { type XMPPData struct {
Domain string `json:"domain"` Domain string `json:"domain"`
RunAt string `json:"run_at"` RunAt string `json:"run_at"`
SRV SRVLookup `json:"srv"` SRV SRVLookup `json:"srv"`
Endpoints []EndpointProbe `json:"endpoints"` Endpoints []EndpointProbe `json:"endpoints"`
Coverage ReachabilitySpan `json:"coverage"` Coverage ReachabilitySpan `json:"coverage"`
Issues []Issue `json:"issues"` Issues []Issue `json:"issues"`
} }
type SRVLookup struct { type SRVLookup struct {
@ -55,17 +55,17 @@ type SRVRecord struct {
// EndpointProbe is the result of probing one (mode, host, port, address) tuple. // EndpointProbe is the result of probing one (mode, host, port, address) tuple.
type EndpointProbe struct { type EndpointProbe struct {
Mode XMPPMode `json:"mode"` Mode XMPPMode `json:"mode"`
SRVPrefix string `json:"srv_prefix"` SRVPrefix string `json:"srv_prefix"`
Target string `json:"target"` Target string `json:"target"`
Port uint16 `json:"port"` Port uint16 `json:"port"`
Address string `json:"address"` Address string `json:"address"`
IsIPv6 bool `json:"is_ipv6,omitempty"` IsIPv6 bool `json:"is_ipv6,omitempty"`
DirectTLS bool `json:"direct_tls,omitempty"` DirectTLS bool `json:"direct_tls,omitempty"`
// What happened. // What happened.
TCPConnected bool `json:"tcp_connected"` TCPConnected bool `json:"tcp_connected"`
StreamOpened bool `json:"stream_opened"` StreamOpened bool `json:"stream_opened"`
STARTTLSOffered bool `json:"starttls_offered"` STARTTLSOffered bool `json:"starttls_offered"`
STARTTLSRequired bool `json:"starttls_required"` STARTTLSRequired bool `json:"starttls_required"`
@ -114,20 +114,19 @@ const (
// Issue codes. // Issue codes.
const ( const (
CodeNoSRV = "xmpp.no_srv" CodeNoSRV = "xmpp.no_srv"
CodeSRVServfail = "xmpp.srv.servfail" CodeSRVServfail = "xmpp.srv.servfail"
CodeStartTLSMissing = "xmpp.starttls.missing" CodeStartTLSMissing = "xmpp.starttls.missing"
CodeStartTLSNotRequired = "xmpp.starttls.not_required" CodeStartTLSNotRequired = "xmpp.starttls.not_required"
CodeStartTLSFailed = "xmpp.starttls.handshake_failed" CodeStartTLSFailed = "xmpp.starttls.handshake_failed"
CodeStreamOpenFailed = "xmpp.stream.open_failed" CodeTCPUnreachable = "xmpp.tcp.unreachable"
CodeTCPUnreachable = "xmpp.tcp.unreachable" CodeSASLPlainOnly = "xmpp.sasl.plain_only"
CodeSASLPlainOnly = "xmpp.sasl.plain_only" CodeSASLNoSCRAM = "xmpp.sasl.no_scram"
CodeSASLNoSCRAM = "xmpp.sasl.no_scram" CodeSASLNoSCRAMPlus = "xmpp.sasl.no_scram_plus"
CodeSASLNoSCRAMPlus = "xmpp.sasl.no_scram_plus" CodeS2SNoAuth = "xmpp.s2s.no_auth"
CodeS2SNoAuth = "xmpp.s2s.no_auth" CodeS2SProbeIncomplete = "xmpp.s2s.probe_incomplete"
CodeS2SProbeIncomplete = "xmpp.s2s.probe_incomplete" CodeLegacyJabber = "xmpp.legacy_jabber"
CodeLegacyJabber = "xmpp.legacy_jabber" CodeNoIPv6 = "xmpp.no_ipv6"
CodeNoIPv6 = "xmpp.no_ipv6" CodeNoDirectTLS = "xmpp.no_direct_tls"
CodeNoDirectTLS = "xmpp.no_direct_tls" CodeAllEndpointsDown = "xmpp.all_endpoints_down"
CodeAllEndpointsDown = "xmpp.all_endpoints_down"
) )

3
go.mod
View file

@ -3,7 +3,8 @@ module git.happydns.org/checker-xmpp
go 1.25.0 go 1.25.0
require ( require (
git.happydns.org/checker-sdk-go v0.0.1 git.happydns.org/checker-sdk-go v1.2.0
git.happydns.org/checker-tls v0.2.0
github.com/miekg/dns v1.1.72 github.com/miekg/dns v1.1.72
) )

6
go.sum
View file

@ -1,5 +1,7 @@
git.happydns.org/checker-sdk-go v0.0.1 h1:4RxCJr73HWKxjOyU/6NJMO8lXJmH0gMLA68EzTqLbQI= git.happydns.org/checker-sdk-go v1.2.0 h1:v4MpKAz0W3PwP+bxx3pya8w893sVH5xTD1of1cc0TV8=
git.happydns.org/checker-sdk-go v0.0.1/go.mod h1:aNAcfYFfbhvH9kJhE0Njp5GX0dQbxdRB0rJ0KvSC5nI= git.happydns.org/checker-sdk-go v1.2.0/go.mod h1:aNAcfYFfbhvH9kJhE0Njp5GX0dQbxdRB0rJ0KvSC5nI=
git.happydns.org/checker-tls v0.2.0 h1:2dYpcePBylUc3le76fFlLbxraiLpGESmOhx4NfD7REM=
git.happydns.org/checker-tls v0.2.0/go.mod h1:0ZSG0CTP007SHBPE7qInESVIOcW+xgucHUhHgj6MeZ8=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= 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/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 h1:vhmr+TF2A3tuoGNkLDFK9zi36F2LS+hKTRW0Uf8kbzI=

View file

@ -4,8 +4,8 @@ import (
"flag" "flag"
"log" "log"
xmpp "git.happydns.org/checker-xmpp/checker"
sdk "git.happydns.org/checker-sdk-go/checker" 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" // Version is the standalone binary's version. It defaults to "custom-build"

View file

@ -5,8 +5,8 @@
package main package main
import ( import (
xmpp "git.happydns.org/checker-xmpp/checker"
sdk "git.happydns.org/checker-sdk-go/checker" sdk "git.happydns.org/checker-sdk-go/checker"
xmpp "git.happydns.org/checker-xmpp/checker"
) )
var Version = "custom-build" var Version = "custom-build"