diff --git a/.gitignore b/.gitignore deleted file mode 100644 index 428e3f6..0000000 --- a/.gitignore +++ /dev/null @@ -1,2 +0,0 @@ -checker-xmpp -checker-xmpp.so diff --git a/README.md b/README.md index 7ae1ee0..830c441 100644 --- a/README.md +++ b/README.md @@ -8,31 +8,17 @@ 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. -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`). +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`. ## What it checks diff --git a/checker/collect.go b/checker/collect.go index 3ad7880..b2b9f5e 100644 --- a/checker/collect.go +++ b/checker/collect.go @@ -24,14 +24,6 @@ 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") @@ -134,8 +126,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 { @@ -170,7 +162,7 @@ func probeEndpoint(ctx context.Context, domain string, mode XMPPMode, prefix str dialCtx, cancel := context.WithTimeout(ctx, timeout) defer cancel() - dialer := &net.Dialer{} + dialer := &net.Dialer{Timeout: timeout} rawConn, err := dialer.DialContext(dialCtx, "tcp", result.Address) if err != nil { result.Error = "tcp: " + err.Error() @@ -183,8 +175,12 @@ func probeEndpoint(ctx context.Context, domain string, mode XMPPMode, prefix str var conn net.Conn = rawConn if directTLS { - tlsConn := tls.Client(rawConn, tlsProbeConfig(domain)) - if err := tlsConn.Handshake(); err != nil { + 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 { result.Error = "tls-handshake: " + err.Error() return result } @@ -206,6 +202,7 @@ 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() @@ -225,12 +222,13 @@ 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 @@ -240,9 +238,14 @@ func probeEndpoint(ctx context.Context, domain string, mode XMPPMode, prefix str return result } - tlsConn := tls.Client(rawConn, tlsProbeConfig(domain)) + // 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.SetDeadline(time.Now().Add(timeout)) - if err := tlsConn.Handshake(); err != nil { + if err := tlsConn.HandshakeContext(dialCtx); err != nil { result.Error = "tls-handshake: " + err.Error() return result } @@ -280,6 +283,8 @@ func applyFeatures(ep *EndpointProbe, feats *streamFeatures) { } } +// ─── Stream / XML plumbing ──────────────────────────────────────────────────── + type streamFeatures struct { XMLName xml.Name `xml:"http://etherx.jabber.org/streams features"` StartTLS *startTLSEl @@ -395,6 +400,8 @@ 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) @@ -439,6 +446,8 @@ func resolveAllInto(ctx context.Context, r *net.Resolver, records []SRVRecord) { } } +// ─── Coverage + issues ──────────────────────────────────────────────────────── + func computeCoverage(data *XMPPData) { for _, ep := range data.Endpoints { if ep.TCPConnected { @@ -508,6 +517,9 @@ 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 @@ -579,7 +591,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". @@ -588,7 +600,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, }) @@ -596,7 +608,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 832f19f..3a0d290 100644 --- a/checker/collect_test.go +++ b/checker/collect_test.go @@ -4,8 +4,6 @@ import ( "encoding/xml" "strings" "testing" - - tlsct "git.happydns.org/checker-tls/contract" ) func TestReadFeatures_WithStartTLSAndSCRAM(t *testing.T) { @@ -198,7 +196,7 @@ func TestComputeCoverage_Mixed(t *testing.T) { } } -func TestDiscoverEntries_AllSets(t *testing.T) { +func TestDiscoverEndpoints_AllSets(t *testing.T) { d := &XMPPData{ Domain: "example.com", SRV: SRVLookup{ @@ -214,70 +212,59 @@ func TestDiscoverEntries_AllSets(t *testing.T) { }, } p := &xmppProvider{} - raw, err := p.DiscoverEntries(d) + eps, err := p.DiscoverEndpoints(d) if err != nil { - t.Fatalf("DiscoverEntries: %v", err) + t.Fatalf("DiscoverEndpoints: %v", err) } - if len(raw) != 4 { - t.Fatalf("expected 4 entries (legacy jabber excluded), got %d: %+v", len(raw), raw) + if len(eps) != 4 { + t.Fatalf("expected 4 endpoints (legacy jabber excluded), got %d: %+v", len(eps), eps) } - - 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) + 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) } - 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") + 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) } - if !c2s.RequireSTARTTLS { - t.Errorf("c2s RequireSTARTTLS = false, want true") + 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) } - - 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) + 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) } } -func TestDiscoverEntries_WrongType(t *testing.T) { +func TestDiscoverEndpoints_WrongType(t *testing.T) { p := &xmppProvider{} - eps, err := p.DiscoverEntries("not an XMPPData") + eps, err := p.DiscoverEndpoints("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) + t.Fatalf("expected nil endpoints 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 deleted file mode 100644 index 02c44b4..0000000 --- a/checker/interactive.go +++ /dev/null @@ -1,66 +0,0 @@ -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 450ede7..85242bd 100644 --- a/checker/provider.go +++ b/checker/provider.go @@ -1,11 +1,9 @@ package checker import ( - "net" "strconv" sdk "git.happydns.org/checker-sdk-go/checker" - tlsct "git.happydns.org/checker-tls/contract" ) func Provider() sdk.ObservationProvider { @@ -23,18 +21,17 @@ func (p *xmppProvider) Definition() *sdk.CheckerDefinition { return Definition() } -// DiscoverEntries implements sdk.DiscoveryPublisher. +// DiscoverEndpoints implements sdk.EndpointDiscoverer. // -// 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. +// 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. // // 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) { +func (p *xmppProvider) DiscoverEndpoints(data any) ([]sdk.DiscoveredEndpoint, error) { d, ok := data.(*XMPPData) if !ok || d == nil { return nil, nil @@ -48,42 +45,33 @@ func (p *xmppProvider) DiscoverEntries(data any) ([]sdk.DiscoveryEntry, error) { } } - var out []sdk.DiscoveryEntry - emit := func(proto string, recs []SRVRecord, directTLS bool) error { + var out []sdk.DiscoveredEndpoint + emit := func(epType string, recs []SRVRecord, directTLS bool) { for _, r := range recs { - ep := tlsct.TLSEndpoint{ + ep := sdk.DiscoveredEndpoint{ + Type: epType, Host: r.Target, Port: r.Port, SNI: d.Domain, } if !directTLS { - ep.STARTTLS = proto - ep.RequireSTARTTLS = starttlsRequired[endpointKey(r.Target, r.Port)] + mode := "opportunistic" + if starttlsRequired[endpointKey(r.Target, r.Port)] { + mode = "required" + } + ep.Meta = map[string]any{"starttls": mode} } - entry, err := tlsct.NewEntry(ep) - if err != nil { - return err - } - out = append(out, entry) + out = append(out, ep) } - 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 net.JoinHostPort(host, strconv.FormatUint(uint64(port), 10)) + return host + ":" + strconv.Itoa(int(port)) } diff --git a/checker/report.go b/checker/report.go index cb6e29e..014f570 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}}