diff --git a/checker/collect.go b/checker/collect.go index 3ad7880..b7afcc5 100644 --- a/checker/collect.go +++ b/checker/collect.go @@ -24,10 +24,21 @@ const ( tlsNS = "urn:ietf:params:xml:ns:xmpp-tls" ) +// tlsProbeConfig returns a deliberately permissive TLS config for probing. +// +// InsecureSkipVerify is intentional: certificate chain and hostname validation +// is the TLS checker's responsibility. This checker only observes which TLS +// versions and cipher suites a server accepts, then hands the endpoints to +// checker-tls for the actual certificate audit. +// +// MinVersion is set to TLS 1.0 so we can observe whether a server still +// accepts deprecated protocol versions: that is exactly what we want to +// report. A strict client config would prevent us from reaching those servers +// at all. func tlsProbeConfig(serverName string) *tls.Config { return &tls.Config{ ServerName: serverName, - InsecureSkipVerify: true, //nolint:gosec: cert validation is the TLS checker's job + InsecureSkipVerify: true, //nolint:gosec MinVersion: tls.VersionTLS10, } } @@ -39,6 +50,9 @@ func (p *xmppProvider) Collect(ctx context.Context, opts sdk.CheckerOptions) (an if domain == "" { return nil, fmt.Errorf("domain is required") } + if err := validateDomain(domain); err != nil { + return nil, err + } mode, _ := sdk.GetOption[string](opts, "mode") if mode == "" { @@ -106,7 +120,9 @@ func (p *xmppProvider) Collect(ctx context.Context, opts sdk.CheckerOptions) (an probeSet(ctx, data, domain, ModeServer, "_xmpps-server._tcp", data.SRV.ServerSecure, true, perEndpoint) computeCoverage(data) - data.Issues = deriveIssues(data, wantC2S, wantS2S) + + // Collect intentionally does not populate data.Issues; judging the raw + // payload is the job of the CheckRules (see rules.go). return data, nil } @@ -395,6 +411,31 @@ func expectProceed(dec *xml.Decoder) error { } } +// validateDomain enforces RFC 1123 hostname rules before the value is used in +// DNS lookups and embedded in the XMPP stream header. +func validateDomain(domain string) error { + if len(domain) > 253 { + return fmt.Errorf("domain name too long (max 253 characters, got %d)", len(domain)) + } + for _, label := range strings.Split(domain, ".") { + if len(label) == 0 { + return fmt.Errorf("domain contains an empty label") + } + if len(label) > 63 { + return fmt.Errorf("domain label %q exceeds 63 characters", label) + } + if label[0] == '-' || label[len(label)-1] == '-' { + return fmt.Errorf("domain label %q must not start or end with a hyphen", label) + } + for _, c := range label { + if !((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '-') { + return fmt.Errorf("domain label %q contains invalid character %q", label, c) + } + } + } + return nil +} + 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 +480,9 @@ func resolveAllInto(ctx context.Context, r *net.Resolver, records []SRVRecord) { } } +// computeCoverage walks raw endpoints and fills in the ReachabilitySpan +// aggregate. It is still part of Collect because coverage is a raw summary +// of what was actually reached, not a judgment (it has no severity). func computeCoverage(data *XMPPData) { for _, ep := range data.Endpoints { if ep.TCPConnected { @@ -453,212 +497,15 @@ func computeCoverage(data *XMPPData) { } switch ep.Mode { case ModeClient: - // We consider c2s working if SASL was advertised, OR if STARTTLS + // c2s is reachable 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. + // s2s reachable if TLS completed; the dialback/EXTERNAL + // posture judgment is expressed by a rule, not here. 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 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 -} diff --git a/checker/definition.go b/checker/definition.go index 7d26d4a..ed78fee 100644 --- a/checker/definition.go +++ b/checker/definition.go @@ -10,7 +10,7 @@ import ( // by main / plugin. var Version = "built-in" -func Definition() *sdk.CheckerDefinition { +func (p *xmppProvider) Definition() *sdk.CheckerDefinition { return &sdk.CheckerDefinition{ ID: "xmpp", Name: "XMPP Server", @@ -45,7 +45,7 @@ func Definition() *sdk.CheckerDefinition { }, }, }, - Rules: []sdk.CheckRule{Rule()}, + Rules: Rules(), Interval: &sdk.CheckIntervalSpec{ Min: 5 * time.Minute, Max: 7 * 24 * time.Hour, diff --git a/checker/interactive.go b/checker/interactive.go index f9ccaef..d723401 100644 --- a/checker/interactive.go +++ b/checker/interactive.go @@ -45,6 +45,9 @@ func (p *xmppProvider) ParseForm(r *http.Request) (sdk.CheckerOptions, error) { if domain == "" { return nil, errors.New("domain is required") } + if err := validateDomain(domain); err != nil { + return nil, err + } opts := sdk.CheckerOptions{"domain": domain} diff --git a/checker/issues.go b/checker/issues.go new file mode 100644 index 0000000..1e46f42 --- /dev/null +++ b/checker/issues.go @@ -0,0 +1,206 @@ +package checker + +import "strings" + +// deriveIssues walks a raw XMPPData and returns the full list of findings +// that rules (and the HTML report) may surface. +// +// It does not mutate data. It is intentionally pure so that rules can +// recompute their slice of the findings without having to stash anything +// into the observation payload. wantC2S / wantS2S restrict mode-scoped +// checks (SASL / direct-TLS / working-endpoint-coverage); SRV and per- +// endpoint findings are always emitted. +func deriveIssues(data *XMPPData, wantC2S, wantS2S 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 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. + 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.", + }) + } + + _ = wantS2S // kept for signature symmetry; s2s-specific rules are expressed via per-endpoint mode checks above + return issues +} diff --git a/checker/provider.go b/checker/provider.go index 450ede7..949e7dd 100644 --- a/checker/provider.go +++ b/checker/provider.go @@ -18,11 +18,6 @@ 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, diff --git a/checker/report.go b/checker/report.go index cb6e29e..43c2146 100644 --- a/checker/report.go +++ b/checker/report.go @@ -305,12 +305,19 @@ th { font-weight: 600; color: #6b7280; } // 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. +// +// The hint/fix section is driven exclusively by ctx.States(): it is the host +// that has already evaluated every rule and handed us the resulting +// CheckStates. The report never re-derives issues from the raw observation +// so there is no duplicated judgment logic. When States() is empty (for +// example a standalone render with no rule run), we still show the raw +// facts (SRV table, endpoint details) but drop the actionable hints. 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)) + view := buildReportData(&d, rctx.Related(TLSRelatedKey), rctx.States()) return renderReport(view) } @@ -322,12 +329,15 @@ func renderReport(view reportData) (string, error) { return buf.String(), nil } -func buildReportData(d *XMPPData, related []sdk.RelatedObservation) reportData { - tlsIssues := tlsIssuesFromRelated(related) +func buildReportData(d *XMPPData, related []sdk.RelatedObservation, states []sdk.CheckState) reportData { tlsByAddr := indexTLSByAddress(related) - allIssues := append([]Issue(nil), d.Issues...) - allIssues = append(allIssues, tlsIssues...) + // Fix list comes exclusively from the CheckStates the host evaluated. + // When no states were supplied (standalone renders, one-off tests), + // the hint section is skipped entirely: we show raw facts only, + // never re-judge the observation here. + fixes := fixesFromStates(states) + hasStates := len(states) > 0 view := reportData{ Domain: d.Domain, @@ -338,35 +348,38 @@ func buildReportData(d *XMPPData, related []sdk.RelatedObservation) reportData { HasIPv6: d.Coverage.HasIPv6, WorkingC2S: d.Coverage.WorkingC2S, WorkingS2S: d.Coverage.WorkingS2S, - HasIssues: len(allIssues) > 0, + HasIssues: len(fixes) > 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" + // Status banner: driven by the worst CheckState when available, + // otherwise a neutral label (data-only render). + if !hasStates { + view.StatusLabel = "DATA" + view.StatusClass = "muted" } else { + worst := sdk.StatusOK + for _, s := range states { + if s.Status > worst { + worst = s.Status + } + } switch worst { - case SeverityCrit: + case sdk.StatusCrit, sdk.StatusError: view.StatusLabel = "FAIL" view.StatusClass = "fail" - case SeverityWarn: + case sdk.StatusWarn: view.StatusLabel = "WARN" view.StatusClass = "warn" - default: + case sdk.StatusInfo: view.StatusLabel = "INFO" view.StatusClass = "muted" + case sdk.StatusUnknown: + view.StatusLabel = "UNKNOWN" + view.StatusClass = "muted" + default: + view.StatusLabel = "OK" + view.StatusClass = "ok" } } @@ -381,16 +394,8 @@ func buildReportData(d *XMPPData, related []sdk.RelatedObservation) reportData { 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, - }) - } + sort.SliceStable(fixes, func(i, j int) bool { return sevRank(fixes[i].Severity) < sevRank(fixes[j].Severity) }) + view.Fixes = fixes // SRV rows. addSRV := func(prefix string, records []SRVRecord) { @@ -462,6 +467,42 @@ func buildReportData(d *XMPPData, related []sdk.RelatedObservation) reportData { return view } +// fixesFromStates turns CheckStates handed to us by the host into the +// severity-tagged entries rendered in the "What to fix" section. It is +// intentionally the only source of hints on the report: the raw +// observation is never re-judged here. +func fixesFromStates(states []sdk.CheckState) []reportFix { + var out []reportFix + for _, s := range states { + var sev string + switch s.Status { + case sdk.StatusCrit, sdk.StatusError: + sev = SeverityCrit + case sdk.StatusWarn: + sev = SeverityWarn + case sdk.StatusInfo: + sev = SeverityInfo + default: + // OK / Unknown: not an actionable finding. + continue + } + fix := "" + if s.Meta != nil { + if v, ok := s.Meta["fix"].(string); ok { + fix = v + } + } + out = append(out, reportFix{ + Severity: sev, + Code: s.Code, + Message: s.Message, + Fix: fix, + Endpoint: s.Subject, + }) + } + return out +} + func modeLabel(m XMPPMode) string { switch m { case ModeClient: diff --git a/checker/rule.go b/checker/rule.go index 3b0886c..f818c24 100644 --- a/checker/rule.go +++ b/checker/rule.go @@ -9,21 +9,9 @@ import ( 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 { +// validateXMPPOptions is the shared options validator for both the provider +// and the aggregate rule. +func validateXMPPOptions(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"`) @@ -32,29 +20,41 @@ func (r *xmppRule) ValidateOptions(opts sdk.CheckerOptions) error { return nil } +// ValidateOptions implements sdk.OptionsValidator on the provider. +func (p *xmppProvider) ValidateOptions(opts sdk.CheckerOptions) error { + return validateXMPPOptions(opts) +} + +// xmppRule is a minimal back-compat aggregate rule. Newer deployments should +// prefer the split per-concern rules exposed by Rules(); this one is kept so +// existing tests that compose a single-status output keep working. +type xmppRule struct{} + +func (r *xmppRule) Name() string { return "xmpp_server" } +func (r *xmppRule) Description() string { + return "Aggregate XMPP posture (prefer the per-concern rules)." +} + +func (r *xmppRule) ValidateOptions(opts sdk.CheckerOptions) error { + return validateXMPPOptions(opts) +} + 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", - }} + data, errSt := loadXMPPData(ctx, obs) + if errSt != nil { + return []sdk.CheckState{*errSt} } + wantC2S, wantS2S := wantsFromOpts(opts) + issues := deriveIssues(data, wantC2S, wantS2S) - 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. + // Fold related TLS observations into the aggregate so cert/chain + // problems surface on the XMPP service page. 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 critMsgs, warnMsgs []string var firstCritCode, firstWarnCode string - for _, is := range issues { switch is.Severity { case SeverityCrit: @@ -76,15 +76,6 @@ func (r *xmppRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, opts } } - 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 @@ -96,7 +87,7 @@ func (r *xmppRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, opts if wantS2S && !data.Coverage.WorkingS2S { missing = append(missing, "s2s") } - critMsgs = append(critMsgs, "no working "+strings.Join(missing, "/")+" endpoint") + critMsgs = append(critMsgs, "no working "+joinModes(missing)+" endpoint") if firstCritCode == "" { firstCritCode = CodeAllEndpointsDown } @@ -108,7 +99,7 @@ func (r *xmppRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, opts "has_ipv4": data.Coverage.HasIPv4, "has_ipv6": data.Coverage.HasIPv6, "endpoints": len(data.Endpoints), - "issue_count": len(data.Issues), + "issue_count": len(issues), } switch worst { @@ -136,6 +127,17 @@ func (r *xmppRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, opts } } +func joinModes(ms []string) string { + switch len(ms) { + case 0: + return "" + case 1: + return ms[0] + default: + return ms[0] + "/" + ms[1] + } +} + func joinTop(msgs []string, n int) string { if len(msgs) == 0 { return "" diff --git a/checker/rules.go b/checker/rules.go new file mode 100644 index 0000000..cc5c9aa --- /dev/null +++ b/checker/rules.go @@ -0,0 +1,139 @@ +package checker + +import ( + "context" + + sdk "git.happydns.org/checker-sdk-go/checker" +) + +// Rule returns a single aggregate rule covering the whole XMPP posture. +// Kept for backwards compatibility with callers that expect exactly one +// CheckRule; prefer Rules() which splits concerns into individual rules. +func Rule() sdk.CheckRule { + return &xmppRule{} +} + +// Rules returns the full list of CheckRules exposed by the XMPP checker, +// one per concern so callers can see at a glance which checks passed and +// which did not, instead of looking up Code on a single monolithic rule. +func Rules() []sdk.CheckRule { + return []sdk.CheckRule{ + &simpleXMPPConcernRule{ + name: "xmpp.srv_c2s", + description: "Verifies that client-to-server SRV records (_xmpp-client / _xmpps-client / _jabber) are published and resolvable.", + codes: []string{CodeNoSRV, CodeSRVServfail, CodeLegacyJabber}, + passCode: "xmpp.srv_c2s.ok", + passMessage: "Client-to-server SRV records are published and resolve cleanly.", + modeFilter: modeFilterC2S, + }, + &simpleXMPPConcernRule{ + name: "xmpp.srv_s2s", + description: "Verifies that server-to-server SRV records (_xmpp-server / _xmpps-server) are published and resolvable.", + codes: []string{CodeNoSRV, CodeSRVServfail}, + passCode: "xmpp.srv_s2s.ok", + passMessage: "Server-to-server SRV records are published and resolve cleanly.", + modeFilter: modeFilterS2S, + }, + &c2sReachableRule{}, + &s2sReachableRule{}, + &simpleXMPPConcernRule{ + name: "xmpp.starttls_required", + description: "Verifies that STARTTLS is advertised and required on every reachable c2s/s2s endpoint.", + codes: []string{CodeStartTLSMissing, CodeStartTLSNotRequired, CodeStartTLSFailed}, + passCode: "xmpp.starttls_required.ok", + passMessage: "STARTTLS is offered and required on every reachable endpoint.", + }, + &simpleXMPPConcernRule{ + name: "xmpp.sasl_mechanisms", + description: "Reviews the c2s SASL mechanisms offer (presence of SCRAM, absence of password-equivalent PLAIN-only).", + codes: []string{CodeSASLPlainOnly, CodeSASLNoSCRAM, CodeSASLNoSCRAMPlus}, + passCode: "xmpp.sasl_mechanisms.ok", + passMessage: "c2s advertises a strong SASL mechanism (SCRAM family).", + modeFilter: modeFilterC2S, + }, + &simpleXMPPConcernRule{ + name: "xmpp.s2s_dialback", + description: "Verifies that s2s endpoints advertise dialback or SASL EXTERNAL after TLS (federation auth).", + codes: []string{CodeS2SNoAuth, CodeS2SProbeIncomplete}, + passCode: "xmpp.s2s_dialback.ok", + passMessage: "Every reachable s2s endpoint advertises dialback or SASL EXTERNAL.", + modeFilter: modeFilterS2S, + }, + &simpleXMPPConcernRule{ + name: "xmpp.ipv6_reachable", + description: "Flags deployments that are only reachable over IPv4.", + codes: []string{CodeNoIPv6}, + passCode: "xmpp.ipv6_reachable.ok", + passMessage: "At least one endpoint is reachable over IPv6.", + }, + &simpleXMPPConcernRule{ + name: "xmpp.direct_tls", + description: "Flags c2s deployments that do not publish XEP-0368 direct-TLS SRV records.", + codes: []string{CodeNoDirectTLS}, + passCode: "xmpp.direct_tls.ok", + passMessage: "XEP-0368 direct-TLS SRV records are published for c2s.", + modeFilter: modeFilterC2S, + }, + &tlsQualityRule{}, + } +} + +// modeFilter lets a rule short-circuit to "skipped" when the selected mode +// excludes the concern (e.g. c2s-specific rule running in mode=s2s). +type modeFilter func(wantC2S, wantS2S bool) bool + +func modeFilterC2S(wantC2S, _ bool) bool { return wantC2S } +func modeFilterS2S(_, wantS2S bool) bool { return wantS2S } + +// simpleXMPPConcernRule covers the common shape: "derive the issue list, +// keep the ones matching these codes, emit them as states or a single pass +// state when none match". +type simpleXMPPConcernRule struct { + name string + description string + codes []string + passCode string + passMessage string + modeFilter modeFilter // optional +} + +func (r *simpleXMPPConcernRule) Name() string { return r.name } +func (r *simpleXMPPConcernRule) Description() string { return r.description } + +func (r *simpleXMPPConcernRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, opts sdk.CheckerOptions) []sdk.CheckState { + data, errSt := loadXMPPData(ctx, obs) + if errSt != nil { + return []sdk.CheckState{*errSt} + } + wantC2S, wantS2S := wantsFromOpts(opts) + if r.modeFilter != nil && !r.modeFilter(wantC2S, wantS2S) { + return []sdk.CheckState{notTestedState(r.name+".skipped", "Not applicable to the selected mode.")} + } + issues := filterIssuesByCodes(deriveIssues(data, wantC2S, wantS2S), r.codes...) + if len(issues) == 0 { + return []sdk.CheckState{passState(r.passCode, r.passMessage)} + } + return statesFromIssues(issues) +} + +// tlsQualityRule folds findings from a downstream TLS checker into XMPP +// output, so cert chain / hostname / expiry problems show up on the XMPP +// service page without needing a separate glance at the TLS report. +type tlsQualityRule struct{} + +func (r *tlsQualityRule) Name() string { return "xmpp.tls_quality" } +func (r *tlsQualityRule) Description() string { + return "Folds the downstream TLS checker findings (certificate chain, hostname match, expiry) onto the XMPP service." +} + +func (r *tlsQualityRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, _ sdk.CheckerOptions) []sdk.CheckState { + related, _ := obs.GetRelated(ctx, TLSRelatedKey) + if len(related) == 0 { + return []sdk.CheckState{notTestedState("xmpp.tls_quality.skipped", "No related TLS observation available (no TLS checker downstream, or no probe yet).")} + } + issues := tlsIssuesFromRelated(related) + if len(issues) == 0 { + return []sdk.CheckState{passState("xmpp.tls_quality.ok", "Downstream TLS checker reports no issues on the XMPP endpoints.")} + } + return statesFromIssues(issues) +} diff --git a/checker/rules_helpers.go b/checker/rules_helpers.go new file mode 100644 index 0000000..1db6f94 --- /dev/null +++ b/checker/rules_helpers.go @@ -0,0 +1,94 @@ +package checker + +import ( + "context" + "fmt" + + sdk "git.happydns.org/checker-sdk-go/checker" +) + +// loadXMPPData fetches the XMPP observation. On error, returns a CheckState +// the caller should emit to short-circuit its rule. +func loadXMPPData(ctx context.Context, obs sdk.ObservationGetter) (*XMPPData, *sdk.CheckState) { + var data XMPPData + if err := obs.Get(ctx, ObservationKeyXMPP, &data); err != nil { + return nil, &sdk.CheckState{ + Status: sdk.StatusError, + Message: fmt.Sprintf("failed to load XMPP observation: %v", err), + Code: "xmpp.observation_error", + } + } + return &data, nil +} + +// wantsFromOpts reads the "mode" option and returns (wantC2S, wantS2S). +// Defaults to "both" when unset or invalid. +func wantsFromOpts(opts sdk.CheckerOptions) (bool, bool) { + mode, _ := sdk.GetOption[string](opts, "mode") + if mode == "" { + mode = "both" + } + return mode != "s2s", mode != "c2s" +} + +// statesFromIssues turns a list of derived Issues into CheckStates. +func statesFromIssues(issues []Issue) []sdk.CheckState { + out := make([]sdk.CheckState, 0, len(issues)) + for _, is := range issues { + out = append(out, issueToState(is)) + } + return out +} + +func issueToState(is Issue) sdk.CheckState { + st := sdk.CheckState{ + Status: severityToStatus(is.Severity), + Message: is.Message, + Code: is.Code, + Subject: is.Endpoint, + } + if is.Fix != "" { + st.Meta = map[string]any{"fix": is.Fix} + } + return st +} + +func passState(code, message string) sdk.CheckState { + return sdk.CheckState{Status: sdk.StatusOK, Message: message, Code: code} +} + +func notTestedState(code, message string) sdk.CheckState { + return sdk.CheckState{Status: sdk.StatusUnknown, Message: message, Code: code} +} + +func severityToStatus(sev string) sdk.Status { + switch sev { + case SeverityCrit: + return sdk.StatusCrit + case SeverityWarn: + return sdk.StatusWarn + case SeverityInfo: + return sdk.StatusInfo + default: + return sdk.StatusOK + } +} + +// filterIssuesByCodes returns only the issues whose Code is in the given set, +// preserving their original order. +func filterIssuesByCodes(issues []Issue, codes ...string) []Issue { + if len(codes) == 0 || len(issues) == 0 { + return nil + } + set := make(map[string]struct{}, len(codes)) + for _, c := range codes { + set[c] = struct{}{} + } + var out []Issue + for _, is := range issues { + if _, ok := set[is.Code]; ok { + out = append(out, is) + } + } + return out +} diff --git a/checker/rules_reachable.go b/checker/rules_reachable.go new file mode 100644 index 0000000..c9cdeff --- /dev/null +++ b/checker/rules_reachable.go @@ -0,0 +1,94 @@ +package checker + +import ( + "context" + + sdk "git.happydns.org/checker-sdk-go/checker" +) + +// c2sReachableRule verifies that at least one client-to-server endpoint +// is reachable (TCP + TLS) and that no discovered c2s endpoint is down. +type c2sReachableRule struct{} + +func (r *c2sReachableRule) Name() string { return "xmpp.c2s_reachable" } +func (r *c2sReachableRule) Description() string { + return "Verifies that at least one client-to-server endpoint accepts TCP and completes TLS." +} + +func (r *c2sReachableRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, opts sdk.CheckerOptions) []sdk.CheckState { + return evaluateReachable(ctx, obs, opts, ModeClient) +} + +// s2sReachableRule is the s2s counterpart. +type s2sReachableRule struct{} + +func (r *s2sReachableRule) Name() string { return "xmpp.s2s_reachable" } +func (r *s2sReachableRule) Description() string { + return "Verifies that at least one server-to-server endpoint accepts TCP and completes TLS." +} + +func (r *s2sReachableRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, opts sdk.CheckerOptions) []sdk.CheckState { + return evaluateReachable(ctx, obs, opts, ModeServer) +} + +func evaluateReachable(ctx context.Context, obs sdk.ObservationGetter, opts sdk.CheckerOptions, mode XMPPMode) []sdk.CheckState { + data, errSt := loadXMPPData(ctx, obs) + if errSt != nil { + return []sdk.CheckState{*errSt} + } + wantC2S, wantS2S := wantsFromOpts(opts) + if mode == ModeClient && !wantC2S { + return []sdk.CheckState{notTestedState("xmpp.c2s_reachable.skipped", "c2s not in scope for the selected mode.")} + } + if mode == ModeServer && !wantS2S { + return []sdk.CheckState{notTestedState("xmpp.s2s_reachable.skipped", "s2s not in scope for the selected mode.")} + } + + // Per-endpoint TCP unreachable states for this mode. + var states []sdk.CheckState + anyForMode := false + for _, ep := range data.Endpoints { + if ep.Mode != mode { + continue + } + anyForMode = true + if !ep.TCPConnected && ep.Error != "" { + states = append(states, sdk.CheckState{ + Status: sdk.StatusWarn, + Message: "Cannot reach " + ep.Address + ": " + ep.Error + ".", + Code: CodeTCPUnreachable, + Subject: ep.Address, + Meta: map[string]any{"fix": "Verify firewall rules and that the XMPP server is listening on this address."}, + }) + } + } + + if !anyForMode { + return []sdk.CheckState{{ + Status: sdk.StatusCrit, + Message: "No " + string(mode) + " endpoint discovered to probe.", + Code: CodeNoSRV, + }} + } + + working := data.Coverage.WorkingC2S + if mode == ModeServer { + working = data.Coverage.WorkingS2S + } + if !working { + states = append(states, sdk.CheckState{ + Status: sdk.StatusCrit, + Message: "No working " + string(mode) + " endpoint (TCP + TLS).", + Code: CodeAllEndpointsDown, + }) + } + + if len(states) == 0 { + code := "xmpp.c2s_reachable.ok" + if mode == ModeServer { + code = "xmpp.s2s_reachable.ok" + } + return []sdk.CheckState{passState(code, "At least one "+string(mode)+" endpoint is reachable and completes TLS.")} + } + return states +} diff --git a/checker/tls_related.go b/checker/tls_related.go index e7783f5..70000f9 100644 --- a/checker/tls_related.go +++ b/checker/tls_related.go @@ -98,6 +98,9 @@ func tlsIssuesFromRelated(related []sdk.RelatedObservation) []Issue { if code == "" { code = "tls.unknown" } + // Strip a leading "tls." prefix to avoid the double-prefix + // "xmpp.tls.tls.*" when the TLS checker already uses that namespace. + code = strings.TrimPrefix(code, "tls.") out = append(out, Issue{ Code: "xmpp.tls." + code, Severity: sev, @@ -135,25 +138,10 @@ func tlsIssuesFromRelated(related []sdk.RelatedObservation) []Issue { return out } -// worstSeverity returns "crit" > "warn" > "info" across the TLS issues. +// worstSeverity synthesises a severity from the structured flags on the probe. +// It is only called from the flag-only path in tlsIssuesFromRelated (when +// v.Issues is empty), so there is no issue list to iterate over. 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 } @@ -164,9 +152,7 @@ func (v *tlsProbeView) worstSeverity() string { return SeverityCrit } if !v.NotAfter.IsZero() && time.Until(v.NotAfter) < 14*24*time.Hour { - if worst != SeverityCrit { - return SeverityWarn - } + return SeverityWarn } - return worst + return "" } diff --git a/checker/tls_related_test.go b/checker/tls_related_test.go index 1a187d7..5a13ef9 100644 --- a/checker/tls_related_test.go +++ b/checker/tls_related_test.go @@ -197,7 +197,7 @@ func TestTLSIssuesFromRelated_StructuredIssues(t *testing.T) { 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 { + if out[0].Code != "xmpp.tls.self_signed" || out[0].Severity != SeverityCrit { t.Fatalf("unexpected first issue: %+v", out[0]) } } diff --git a/go.mod b/go.mod index 11a5638..5621a7c 100644 --- a/go.mod +++ b/go.mod @@ -4,7 +4,7 @@ go 1.25.0 require ( git.happydns.org/checker-sdk-go v1.5.0 - git.happydns.org/checker-tls v0.2.0 + git.happydns.org/checker-tls v0.6.2 github.com/miekg/dns v1.1.72 ) diff --git a/go.sum b/go.sum index 38fd6be..4faaeb7 100644 --- a/go.sum +++ b/go.sum @@ -1,7 +1,7 @@ git.happydns.org/checker-sdk-go v1.5.0 h1:5uD5Cm6xJ+lwnhbJ09iCXGHbYS9zRh+Yh0NeBHkAPBY= git.happydns.org/checker-sdk-go v1.5.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= +git.happydns.org/checker-tls v0.6.2 h1:8oKia1XlD+tklyqrwzmUgFH1Kw8VLSLLF9suZ7Qr14E= +git.happydns.org/checker-tls v0.6.2/go.mod h1:9tpnxg0iOwS+7If64DRG1jqYonUAgxOBuxwfF5mVkL4= 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= diff --git a/plugin/plugin.go b/plugin/plugin.go index d3a0175..6b2d590 100644 --- a/plugin/plugin.go +++ b/plugin/plugin.go @@ -15,5 +15,6 @@ var Version = "custom-build" // .so file. func NewCheckerPlugin() (*sdk.CheckerDefinition, sdk.ObservationProvider, error) { xmpp.Version = Version - return xmpp.Definition(), xmpp.Provider(), nil + prvd := xmpp.Provider() + return prvd.(sdk.CheckerDefinitionProvider).Definition(), prvd, nil }