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