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
}