diff --git a/.drone-manifest.yml b/.drone-manifest.yml deleted file mode 100644 index af23b4d..0000000 --- a/.drone-manifest.yml +++ /dev/null @@ -1,22 +0,0 @@ -image: happydomain/checker-xmpp:{{#if build.tag}}{{trimPrefix "v" build.tag}}{{else}}latest{{/if}} -{{#if build.tags}} -tags: -{{#each build.tags}} - - {{this}} -{{/each}} -{{/if}} -manifests: - - image: happydomain/checker-xmpp:{{#if build.tag}}{{trimPrefix "v" build.tag}}-{{/if}}linux-amd64 - platform: - architecture: amd64 - os: linux - - image: happydomain/checker-xmpp:{{#if build.tag}}{{trimPrefix "v" build.tag}}-{{/if}}linux-arm64 - platform: - architecture: arm64 - os: linux - variant: v8 - - image: happydomain/checker-xmpp:{{#if build.tag}}{{trimPrefix "v" build.tag}}-{{/if}}linux-arm - platform: - architecture: arm - os: linux - variant: v7 diff --git a/.drone.yml b/.drone.yml deleted file mode 100644 index 2786e0b..0000000 --- a/.drone.yml +++ /dev/null @@ -1,187 +0,0 @@ ---- -kind: pipeline -type: docker -name: build-amd64 - -platform: - os: linux - arch: amd64 - -steps: - - name: checker build - image: golang:1-alpine - commands: - - apk add --no-cache git make - - make - environment: - CHECKER_VERSION: "${DRONE_BRANCH}-${DRONE_COMMIT}" - CGO_ENABLED: 0 - when: - event: - exclude: - - tag - - - name: checker build tag - image: golang:1-alpine - commands: - - apk add --no-cache git make - - make - environment: - CHECKER_VERSION: "${DRONE_SEMVER}" - CGO_ENABLED: 0 - when: - event: - - tag - - - name: publish on Docker Hub - image: plugins/docker - settings: - repo: happydomain/checker-xmpp - auto_tag: true - auto_tag_suffix: ${DRONE_STAGE_OS}-${DRONE_STAGE_ARCH} - dockerfile: Dockerfile - build_args: - - CHECKER_VERSION=${DRONE_BRANCH}-${DRONE_COMMIT} - username: - from_secret: docker_username - password: - from_secret: docker_password - when: - event: - exclude: - - tag - - - name: publish on Docker Hub (tag) - image: plugins/docker - settings: - repo: happydomain/checker-xmpp - auto_tag: true - auto_tag_suffix: ${DRONE_STAGE_OS}-${DRONE_STAGE_ARCH} - dockerfile: Dockerfile - build_args: - - CHECKER_VERSION=${DRONE_SEMVER} - username: - from_secret: docker_username - password: - from_secret: docker_password - when: - event: - - tag - -trigger: - branch: - exclude: - - renovate/* - event: - - cron - - push - - tag - ---- -kind: pipeline -type: docker -name: build-arm64 - -platform: - os: linux - arch: arm64 - -steps: - - name: checker build - image: golang:1-alpine - commands: - - apk add --no-cache git make - - make - environment: - CHECKER_VERSION: "${DRONE_BRANCH}-${DRONE_COMMIT}" - CGO_ENABLED: 0 - when: - event: - exclude: - - tag - - - name: checker build tag - image: golang:1-alpine - commands: - - apk add --no-cache git make - - make - environment: - CHECKER_VERSION: "${DRONE_SEMVER}" - CGO_ENABLED: 0 - when: - event: - - tag - - - name: publish on Docker Hub - image: plugins/docker - settings: - repo: happydomain/checker-xmpp - auto_tag: true - auto_tag_suffix: ${DRONE_STAGE_OS}-${DRONE_STAGE_ARCH} - dockerfile: Dockerfile - build_args: - - CHECKER_VERSION=${DRONE_BRANCH}-${DRONE_COMMIT} - username: - from_secret: docker_username - password: - from_secret: docker_password - when: - event: - exclude: - - tag - - - name: publish on Docker Hub (tag) - image: plugins/docker - settings: - repo: happydomain/checker-xmpp - auto_tag: true - auto_tag_suffix: ${DRONE_STAGE_OS}-${DRONE_STAGE_ARCH} - dockerfile: Dockerfile - build_args: - - CHECKER_VERSION=${DRONE_SEMVER} - username: - from_secret: docker_username - password: - from_secret: docker_password - when: - event: - - tag - -trigger: - event: - - cron - - push - - tag - ---- -kind: pipeline -name: docker-manifest - -platform: - os: linux - arch: arm64 - -steps: - - name: publish on Docker Hub - image: plugins/manifest - settings: - auto_tag: true - ignore_missing: true - spec: .drone-manifest.yml - username: - from_secret: docker_username - password: - from_secret: docker_password - -trigger: - branch: - exclude: - - renovate/* - event: - - cron - - push - - tag - -depends_on: - - build-amd64 - - build-arm64 diff --git a/Dockerfile b/Dockerfile index 3d64bbe..e63009b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -6,12 +6,9 @@ WORKDIR /src COPY go.mod go.sum ./ RUN go mod download COPY . . -RUN CGO_ENABLED=0 go build -tags standalone -ldflags "-X main.Version=${CHECKER_VERSION}" -o /checker-xmpp . +RUN CGO_ENABLED=0 go build -ldflags "-X main.Version=${CHECKER_VERSION}" -o /checker-xmpp . FROM scratch COPY --from=builder /checker-xmpp /checker-xmpp -USER 65534:65534 EXPOSE 8080 -HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \ - CMD ["/checker-xmpp", "-healthcheck"] ENTRYPOINT ["/checker-xmpp"] diff --git a/Makefile b/Makefile index 70dc422..2b4120f 100644 --- a/Makefile +++ b/Makefile @@ -11,7 +11,7 @@ GO_LDFLAGS := -X main.Version=$(CHECKER_VERSION) all: $(CHECKER_NAME) $(CHECKER_NAME): $(CHECKER_SOURCES) - go build -tags standalone -ldflags "$(GO_LDFLAGS)" -o $@ . + go build -ldflags "$(GO_LDFLAGS)" -o $@ . plugin: $(CHECKER_NAME).so @@ -22,7 +22,7 @@ docker: docker build --build-arg CHECKER_VERSION=$(CHECKER_VERSION) -t $(CHECKER_IMAGE) . test: - go test -tags standalone ./... + go test ./... clean: rm -f $(CHECKER_NAME) $(CHECKER_NAME).so diff --git a/README.md b/README.md index 31a97a4..7ae1ee0 100644 --- a/README.md +++ b/README.md @@ -82,21 +82,6 @@ make plugin Applies to services of type `abstract.XMPP`. -## Rules - -| Code | Description | Severity | -|----------------------------|-------------------------------------------------------------------------------------------------------------------|---------------------| -| `xmpp.srv_c2s` | Verifies that client-to-server SRV records (_xmpp-client / _xmpps-client / _jabber) are published and resolvable. | CRITICAL | -| `xmpp.srv_s2s` | Verifies that server-to-server SRV records (_xmpp-server / _xmpps-server) are published and resolvable. | CRITICAL | -| `xmpp.c2s_reachable` | Verifies that at least one client-to-server endpoint accepts TCP and completes TLS. | CRITICAL | -| `xmpp.s2s_reachable` | Verifies that at least one server-to-server endpoint accepts TCP and completes TLS. | CRITICAL | -| `xmpp.starttls_required` | Verifies that STARTTLS is advertised and required on every reachable c2s/s2s endpoint. | CRITICAL | -| `xmpp.sasl_mechanisms` | Reviews the c2s SASL mechanisms offer (presence of SCRAM, absence of password-equivalent PLAIN-only). | CRITICAL | -| `xmpp.s2s_dialback` | Verifies that s2s endpoints advertise dialback or SASL EXTERNAL after TLS (federation auth). | CRITICAL | -| `xmpp.ipv6_reachable` | Flags deployments that are only reachable over IPv4. | INFO | -| `xmpp.direct_tls` | Flags c2s deployments that do not publish XEP-0368 direct-TLS SRV records. | INFO | -| `xmpp.tls_quality` | Folds the downstream TLS checker findings (certificate chain, hostname match, expiry) onto the XMPP service. | CRITICAL | - ## License MIT (see `LICENSE`). Third-party attributions in `NOTICE`. diff --git a/checker/collect.go b/checker/collect.go index b7afcc5..3ad7880 100644 --- a/checker/collect.go +++ b/checker/collect.go @@ -24,21 +24,10 @@ 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 + InsecureSkipVerify: true, //nolint:gosec: cert validation is the TLS checker's job MinVersion: tls.VersionTLS10, } } @@ -50,9 +39,6 @@ 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 == "" { @@ -120,9 +106,7 @@ 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) - - // Collect intentionally does not populate data.Issues; judging the raw - // payload is the job of the CheckRules (see rules.go). + data.Issues = deriveIssues(data, wantC2S, wantS2S) return data, nil } @@ -411,31 +395,6 @@ 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) @@ -480,9 +439,6 @@ 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 { @@ -497,15 +453,212 @@ func computeCoverage(data *XMPPData) { } switch ep.Mode { case ModeClient: - // c2s is reachable if SASL was advertised OR if STARTTLS + // We consider c2s working 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: - // s2s reachable if TLS completed; the dialback/EXTERNAL - // posture judgment is expressed by a rule, not here. + // 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. 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 ed78fee..7d26d4a 100644 --- a/checker/definition.go +++ b/checker/definition.go @@ -10,7 +10,7 @@ import ( // by main / plugin. var Version = "built-in" -func (p *xmppProvider) Definition() *sdk.CheckerDefinition { +func Definition() *sdk.CheckerDefinition { return &sdk.CheckerDefinition{ ID: "xmpp", Name: "XMPP Server", @@ -45,7 +45,7 @@ func (p *xmppProvider) Definition() *sdk.CheckerDefinition { }, }, }, - Rules: Rules(), + Rules: []sdk.CheckRule{Rule()}, Interval: &sdk.CheckIntervalSpec{ Min: 5 * time.Minute, Max: 7 * 24 * time.Hour, diff --git a/checker/interactive.go b/checker/interactive.go index d723401..561ee91 100644 --- a/checker/interactive.go +++ b/checker/interactive.go @@ -1,5 +1,3 @@ -//go:build standalone - package checker import ( @@ -12,7 +10,7 @@ import ( sdk "git.happydns.org/checker-sdk-go/checker" ) -// RenderForm implements server.Interactive. +// RenderForm implements sdk.CheckerInteractive. func (p *xmppProvider) RenderForm() []sdk.CheckerOptionField { return []sdk.CheckerOptionField{ { @@ -38,16 +36,13 @@ func (p *xmppProvider) RenderForm() []sdk.CheckerOptionField { } } -// ParseForm implements server.Interactive. +// 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") } - if err := validateDomain(domain); err != nil { - return nil, err - } opts := sdk.CheckerOptions{"domain": domain} diff --git a/checker/issues.go b/checker/issues.go deleted file mode 100644 index 1e46f42..0000000 --- a/checker/issues.go +++ /dev/null @@ -1,206 +0,0 @@ -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 949e7dd..450ede7 100644 --- a/checker/provider.go +++ b/checker/provider.go @@ -18,6 +18,11 @@ 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 43c2146..cb6e29e 100644 --- a/checker/report.go +++ b/checker/report.go @@ -305,19 +305,12 @@ 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), rctx.States()) + view := buildReportData(&d, rctx.Related(TLSRelatedKey)) return renderReport(view) } @@ -329,15 +322,12 @@ func renderReport(view reportData) (string, error) { return buf.String(), nil } -func buildReportData(d *XMPPData, related []sdk.RelatedObservation, states []sdk.CheckState) reportData { +func buildReportData(d *XMPPData, related []sdk.RelatedObservation) reportData { + tlsIssues := tlsIssuesFromRelated(related) tlsByAddr := indexTLSByAddress(related) - // 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 + allIssues := append([]Issue(nil), d.Issues...) + allIssues = append(allIssues, tlsIssues...) view := reportData{ Domain: d.Domain, @@ -348,38 +338,35 @@ func buildReportData(d *XMPPData, related []sdk.RelatedObservation, states []sdk HasIPv6: d.Coverage.HasIPv6, WorkingC2S: d.Coverage.WorkingC2S, WorkingS2S: d.Coverage.WorkingS2S, - HasIssues: len(fixes) > 0, + HasIssues: len(allIssues) > 0, HasTLSPosture: len(tlsByAddr) > 0, } - // 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 - } + // 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" + } else { switch worst { - case sdk.StatusCrit, sdk.StatusError: + case SeverityCrit: view.StatusLabel = "FAIL" view.StatusClass = "fail" - case sdk.StatusWarn: + case SeverityWarn: view.StatusLabel = "WARN" view.StatusClass = "warn" - case sdk.StatusInfo: + default: view.StatusLabel = "INFO" view.StatusClass = "muted" - case sdk.StatusUnknown: - view.StatusLabel = "UNKNOWN" - view.StatusClass = "muted" - default: - view.StatusLabel = "OK" - view.StatusClass = "ok" } } @@ -394,8 +381,16 @@ func buildReportData(d *XMPPData, related []sdk.RelatedObservation, states []sdk return 2 } } - sort.SliceStable(fixes, func(i, j int) bool { return sevRank(fixes[i].Severity) < sevRank(fixes[j].Severity) }) - view.Fixes = fixes + 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, + }) + } // SRV rows. addSRV := func(prefix string, records []SRVRecord) { @@ -467,42 +462,6 @@ func buildReportData(d *XMPPData, related []sdk.RelatedObservation, states []sdk 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 f818c24..3b0886c 100644 --- a/checker/rule.go +++ b/checker/rule.go @@ -9,9 +9,21 @@ import ( sdk "git.happydns.org/checker-sdk-go/checker" ) -// validateXMPPOptions is the shared options validator for both the provider -// and the aggregate rule. -func validateXMPPOptions(opts sdk.CheckerOptions) error { +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 { 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"`) @@ -20,41 +32,29 @@ func validateXMPPOptions(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 { - data, errSt := loadXMPPData(ctx, obs) - if errSt != nil { - return []sdk.CheckState{*errSt} + 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", + }} } - wantC2S, wantS2S := wantsFromOpts(opts) - issues := deriveIssues(data, wantC2S, wantS2S) - // Fold related TLS observations into the aggregate so cert/chain - // problems surface on the XMPP service page. + 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. related, _ := obs.GetRelated(ctx, TLSRelatedKey) issues = append(issues, tlsIssuesFromRelated(related)...) + // Reduce issue list to the worst severity. worst := sdk.StatusOK - var critMsgs, warnMsgs []string + critMsgs, warnMsgs := []string{}, []string{} var firstCritCode, firstWarnCode string + for _, is := range issues { switch is.Severity { case SeverityCrit: @@ -76,6 +76,15 @@ 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 @@ -87,7 +96,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 "+joinModes(missing)+" endpoint") + critMsgs = append(critMsgs, "no working "+strings.Join(missing, "/")+" endpoint") if firstCritCode == "" { firstCritCode = CodeAllEndpointsDown } @@ -99,7 +108,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(issues), + "issue_count": len(data.Issues), } switch worst { @@ -127,17 +136,6 @@ 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 deleted file mode 100644 index cc5c9aa..0000000 --- a/checker/rules.go +++ /dev/null @@ -1,139 +0,0 @@ -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 deleted file mode 100644 index 1db6f94..0000000 --- a/checker/rules_helpers.go +++ /dev/null @@ -1,94 +0,0 @@ -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 deleted file mode 100644 index c9cdeff..0000000 --- a/checker/rules_reachable.go +++ /dev/null @@ -1,94 +0,0 @@ -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 70000f9..e7783f5 100644 --- a/checker/tls_related.go +++ b/checker/tls_related.go @@ -98,9 +98,6 @@ 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, @@ -138,10 +135,25 @@ func tlsIssuesFromRelated(related []sdk.RelatedObservation) []Issue { return out } -// 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. +// worstSeverity returns "crit" > "warn" > "info" across the TLS issues. 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 } @@ -152,7 +164,9 @@ func (v *tlsProbeView) worstSeverity() string { return SeverityCrit } if !v.NotAfter.IsZero() && time.Until(v.NotAfter) < 14*24*time.Hour { - return SeverityWarn + if worst != SeverityCrit { + return SeverityWarn + } } - return "" + return worst } diff --git a/checker/tls_related_test.go b/checker/tls_related_test.go index 5a13ef9..dbd3a46 100644 --- a/checker/tls_related_test.go +++ b/checker/tls_related_test.go @@ -136,7 +136,6 @@ func (s *stubReportCtx) Data() json.RawMessage { return s.data } func (s *stubReportCtx) Related(_ sdk.ObservationKey) []sdk.RelatedObservation { return s.related } -func (s *stubReportCtx) States() []sdk.CheckState { return nil } func mustJSON(t *testing.T, v any) json.RawMessage { t.Helper() @@ -197,7 +196,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.self_signed" || out[0].Severity != SeverityCrit { + if out[0].Code != "xmpp.tls.tls.self_signed" || out[0].Severity != SeverityCrit { t.Fatalf("unexpected first issue: %+v", out[0]) } } diff --git a/go.mod b/go.mod index 5621a7c..436824e 100644 --- a/go.mod +++ b/go.mod @@ -3,8 +3,8 @@ module git.happydns.org/checker-xmpp go 1.25.0 require ( - git.happydns.org/checker-sdk-go v1.5.0 - git.happydns.org/checker-tls v0.6.2 + git.happydns.org/checker-sdk-go v1.2.0 + git.happydns.org/checker-tls v0.2.0 github.com/miekg/dns v1.1.72 ) diff --git a/go.sum b/go.sum index 4faaeb7..60c7216 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.6.2 h1:8oKia1XlD+tklyqrwzmUgFH1Kw8VLSLLF9suZ7Qr14E= -git.happydns.org/checker-tls v0.6.2/go.mod h1:9tpnxg0iOwS+7If64DRG1jqYonUAgxOBuxwfF5mVkL4= +git.happydns.org/checker-sdk-go v1.2.0 h1:v4MpKAz0W3PwP+bxx3pya8w893sVH5xTD1of1cc0TV8= +git.happydns.org/checker-sdk-go v1.2.0/go.mod h1:aNAcfYFfbhvH9kJhE0Njp5GX0dQbxdRB0rJ0KvSC5nI= +git.happydns.org/checker-tls v0.2.0 h1:2dYpcePBylUc3le76fFlLbxraiLpGESmOhx4NfD7REM= +git.happydns.org/checker-tls v0.2.0/go.mod h1:0ZSG0CTP007SHBPE7qInESVIOcW+xgucHUhHgj6MeZ8= github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/miekg/dns v1.1.72 h1:vhmr+TF2A3tuoGNkLDFK9zi36F2LS+hKTRW0Uf8kbzI= diff --git a/main.go b/main.go index 8490d22..ba43cc8 100644 --- a/main.go +++ b/main.go @@ -4,7 +4,7 @@ import ( "flag" "log" - "git.happydns.org/checker-sdk-go/checker/server" + sdk "git.happydns.org/checker-sdk-go/checker" xmpp "git.happydns.org/checker-xmpp/checker" ) @@ -21,8 +21,8 @@ func main() { xmpp.Version = Version - srv := server.New(xmpp.Provider()) - if err := srv.ListenAndServe(*listenAddr); err != nil { + server := sdk.NewServer(xmpp.Provider()) + if err := server.ListenAndServe(*listenAddr); err != nil { log.Fatalf("server error: %v", err) } } diff --git a/plugin/plugin.go b/plugin/plugin.go index 6b2d590..d3a0175 100644 --- a/plugin/plugin.go +++ b/plugin/plugin.go @@ -15,6 +15,5 @@ var Version = "custom-build" // .so file. func NewCheckerPlugin() (*sdk.CheckerDefinition, sdk.ObservationProvider, error) { xmpp.Version = Version - prvd := xmpp.Provider() - return prvd.(sdk.CheckerDefinitionProvider).Definition(), prvd, nil + return xmpp.Definition(), xmpp.Provider(), nil }