diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..428e3f6
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,2 @@
+checker-xmpp
+checker-xmpp.so
diff --git a/README.md b/README.md
index 830c441..7ae1ee0 100644
--- a/README.md
+++ b/README.md
@@ -8,17 +8,31 @@ 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
+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.
-When a TLS checker runs against the endpoints we publish via
-`EndpointDiscoverer`, its observations are automatically folded into our
-rule aggregation and HTML report via the SDK's `GetRelated` /
-`CheckerHTMLReporterCtx` composition path — so a bad cert on an XMPP
-endpoint shows up on the XMPP service page, not only in a separate TLS
-view. The expected observation key is `tls_probes`.
+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
diff --git a/checker/collect.go b/checker/collect.go
index b2b9f5e..3ad7880 100644
--- a/checker/collect.go
+++ b/checker/collect.go
@@ -24,6 +24,14 @@ const (
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")
@@ -126,8 +134,8 @@ func probeSet(ctx context.Context, data *XMPPData, domain string, mode XMPPMode,
}
type probeAddr struct {
- ip string
- isV6 bool
+ ip string
+ isV6 bool
}
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)
defer cancel()
- dialer := &net.Dialer{Timeout: timeout}
+ dialer := &net.Dialer{}
rawConn, err := dialer.DialContext(dialCtx, "tcp", result.Address)
if err != nil {
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
if directTLS {
- tlsConn := tls.Client(rawConn, &tls.Config{
- ServerName: domain,
- InsecureSkipVerify: true, //nolint:gosec — cert validation is the TLS checker's job
- MinVersion: tls.VersionTLS10,
- })
- if err := tlsConn.HandshakeContext(dialCtx); err != nil {
+ tlsConn := tls.Client(rawConn, tlsProbeConfig(domain))
+ if err := tlsConn.Handshake(); err != nil {
result.Error = "tls-handshake: " + err.Error()
return result
}
@@ -202,7 +206,6 @@ func probeEndpoint(ctx context.Context, domain string, mode XMPPMode, prefix str
return result
}
- // STARTTLS path.
dec, from, err := openStream(conn, domain, ns, mode == ModeServer)
if err != nil {
result.Error = "stream: " + err.Error()
@@ -222,13 +225,12 @@ func probeEndpoint(ctx context.Context, domain string, mode XMPPMode, prefix str
}
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.
applyFeatures(&result, feats)
return result
}
- // Request STARTTLS.
if _, err := io.WriteString(conn, ``); err != nil {
result.Error = "starttls-write: " + err.Error()
return result
@@ -238,14 +240,9 @@ func probeEndpoint(ctx context.Context, domain string, mode XMPPMode, prefix str
return result
}
- // Upgrade.
- tlsConn := tls.Client(rawConn, &tls.Config{
- ServerName: domain,
- InsecureSkipVerify: true, //nolint:gosec — cert validation is the TLS checker's job
- MinVersion: tls.VersionTLS10,
- })
+ tlsConn := tls.Client(rawConn, tlsProbeConfig(domain))
_ = 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()
return result
}
@@ -283,8 +280,6 @@ func applyFeatures(ep *EndpointProbe, feats *streamFeatures) {
}
}
-// ─── Stream / XML plumbing ────────────────────────────────────────────────────
-
type streamFeatures struct {
XMLName xml.Name `xml:"http://etherx.jabber.org/streams features"`
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) {
name := prefix + dns.Fqdn(domain)
_, 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) {
for _, ep := range data.Endpoints {
if ep.TCPConnected {
@@ -517,9 +508,6 @@ func deriveIssues(data *XMPPData, wantC2S, _ bool) []Issue {
sawAnyWorking := map[XMPPMode]bool{}
for _, ep := range data.Endpoints {
- if ep.DirectTLS {
- // direct-TLS endpoints don't contribute to STARTTLS-missing logic
- }
if ep.TCPConnected && ep.STARTTLSUpgraded {
allDown = false
sawAnyWorking[ep.Mode] = true
@@ -591,7 +579,7 @@ func deriveIssues(data *XMPPData, wantC2S, _ bool) []Issue {
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
// anonymous s2s probes; in that case we emit a probe_incomplete
// info instead of falsely asserting "no auth".
@@ -600,7 +588,7 @@ func deriveIssues(data *XMPPData, wantC2S, _ bool) []Issue {
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.",
+ 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,
})
@@ -608,7 +596,7 @@ func deriveIssues(data *XMPPData, wantC2S, _ bool) []Issue {
issues = append(issues, Issue{
Code: CodeS2SNoAuth,
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.",
Endpoint: ep.Address,
})
diff --git a/checker/collect_test.go b/checker/collect_test.go
index 3a0d290..832f19f 100644
--- a/checker/collect_test.go
+++ b/checker/collect_test.go
@@ -4,6 +4,8 @@ import (
"encoding/xml"
"strings"
"testing"
+
+ tlsct "git.happydns.org/checker-tls/contract"
)
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{
Domain: "example.com",
SRV: SRVLookup{
@@ -212,59 +214,70 @@ func TestDiscoverEndpoints_AllSets(t *testing.T) {
},
}
p := &xmppProvider{}
- eps, err := p.DiscoverEndpoints(d)
+ raw, err := p.DiscoverEntries(d)
if err != nil {
- t.Fatalf("DiscoverEndpoints: %v", err)
+ t.Fatalf("DiscoverEntries: %v", err)
}
- if len(eps) != 4 {
- t.Fatalf("expected 4 endpoints (legacy jabber excluded), got %d: %+v", len(eps), eps)
+ if len(raw) != 4 {
+ t.Fatalf("expected 4 entries (legacy jabber excluded), got %d: %+v", len(raw), raw)
}
- by := map[string]int{}
- for i, e := range eps {
- by[e.Type+":"+e.Host+":"+itoa(int(e.Port))] = i
- if e.SNI != "example.com" {
- t.Errorf("endpoint %d: SNI=%q, want example.com", i, e.SNI)
+
+ 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 := eps[by["starttls-xmpp-client:xmpp.example.com:5222"]]
- if c2s.Meta["starttls"] != "required" {
- t.Errorf("c2s starttls meta = %v, want required", c2s.Meta)
+
+ c2s, ok := by[signature{"xmpp-client", "xmpp.example.com", 5222}]
+ if !ok {
+ t.Fatal("missing c2s entry")
}
- s2s := eps[by["starttls-xmpp-server:xmpp.example.com:5269"]]
- if s2s.Meta["starttls"] != "opportunistic" {
- t.Errorf("s2s starttls meta = %v, want opportunistic", s2s.Meta)
+ if !c2s.RequireSTARTTLS {
+ t.Errorf("c2s RequireSTARTTLS = false, want true")
}
- direct := eps[by["tls:xmpp.example.com:5223"]]
- if direct.Meta != nil {
- t.Errorf("direct-TLS endpoint should not carry starttls meta, got %v", direct.Meta)
+
+ 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 TestDiscoverEndpoints_WrongType(t *testing.T) {
+func TestDiscoverEntries_WrongType(t *testing.T) {
p := &xmppProvider{}
- eps, err := p.DiscoverEndpoints("not an XMPPData")
+ 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 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 {
for _, i := range is {
if i.Code == code {
diff --git a/checker/interactive.go b/checker/interactive.go
new file mode 100644
index 0000000..02c44b4
--- /dev/null
+++ b/checker/interactive.go
@@ -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
+}
diff --git a/checker/provider.go b/checker/provider.go
index 85242bd..450ede7 100644
--- a/checker/provider.go
+++ b/checker/provider.go
@@ -1,9 +1,11 @@
package checker
import (
+ "net"
"strconv"
sdk "git.happydns.org/checker-sdk-go/checker"
+ tlsct "git.happydns.org/checker-tls/contract"
)
func Provider() sdk.ObservationProvider {
@@ -21,17 +23,18 @@ func (p *xmppProvider) Definition() *sdk.CheckerDefinition {
return Definition()
}
-// DiscoverEndpoints implements sdk.EndpointDiscoverer.
+// DiscoverEntries implements sdk.DiscoveryPublisher.
//
-// It publishes the (host, port) pairs of 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.
+// 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) DiscoverEndpoints(data any) ([]sdk.DiscoveredEndpoint, error) {
+func (p *xmppProvider) DiscoverEntries(data any) ([]sdk.DiscoveryEntry, error) {
d, ok := data.(*XMPPData)
if !ok || d == nil {
return nil, nil
@@ -45,33 +48,42 @@ func (p *xmppProvider) DiscoverEndpoints(data any) ([]sdk.DiscoveredEndpoint, er
}
}
- var out []sdk.DiscoveredEndpoint
- emit := func(epType string, recs []SRVRecord, directTLS bool) {
+ var out []sdk.DiscoveryEntry
+ emit := func(proto string, recs []SRVRecord, directTLS bool) error {
for _, r := range recs {
- ep := sdk.DiscoveredEndpoint{
- Type: epType,
+ ep := tlsct.TLSEndpoint{
Host: r.Target,
Port: r.Port,
SNI: d.Domain,
}
if !directTLS {
- mode := "opportunistic"
- if starttlsRequired[endpointKey(r.Target, r.Port)] {
- mode = "required"
- }
- ep.Meta = map[string]any{"starttls": mode}
+ ep.STARTTLS = proto
+ ep.RequireSTARTTLS = starttlsRequired[endpointKey(r.Target, r.Port)]
}
- 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
}
func endpointKey(host string, port uint16) string {
- return host + ":" + strconv.Itoa(int(port))
+ return net.JoinHostPort(host, strconv.FormatUint(uint64(port), 10))
}
diff --git a/checker/report.go b/checker/report.go
index 014f570..cb6e29e 100644
--- a/checker/report.go
+++ b/checker/report.go
@@ -95,7 +95,7 @@ var reportTpl = template.Must(template.New("xmpp").Funcs(template.FuncMap{
-XMPP Report — {{.Domain}}
+XMPP Report: {{.Domain}}