Compare commits
7 commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c8c6e4d8b4 | |||
| 457ea67f66 | |||
| 946ec446d2 | |||
| df0d429150 | |||
| 3473387a59 | |||
| 22406158e1 | |||
| e7ea37bcf2 |
21 changed files with 957 additions and 319 deletions
22
.drone-manifest.yml
Normal file
22
.drone-manifest.yml
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
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
|
||||||
187
.drone.yml
Normal file
187
.drone.yml
Normal file
|
|
@ -0,0 +1,187 @@
|
||||||
|
---
|
||||||
|
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
|
||||||
|
|
@ -6,9 +6,12 @@ WORKDIR /src
|
||||||
COPY go.mod go.sum ./
|
COPY go.mod go.sum ./
|
||||||
RUN go mod download
|
RUN go mod download
|
||||||
COPY . .
|
COPY . .
|
||||||
RUN CGO_ENABLED=0 go build -ldflags "-X main.Version=${CHECKER_VERSION}" -o /checker-xmpp .
|
RUN CGO_ENABLED=0 go build -tags standalone -ldflags "-X main.Version=${CHECKER_VERSION}" -o /checker-xmpp .
|
||||||
|
|
||||||
FROM scratch
|
FROM scratch
|
||||||
COPY --from=builder /checker-xmpp /checker-xmpp
|
COPY --from=builder /checker-xmpp /checker-xmpp
|
||||||
|
USER 65534:65534
|
||||||
EXPOSE 8080
|
EXPOSE 8080
|
||||||
|
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
|
||||||
|
CMD ["/checker-xmpp", "-healthcheck"]
|
||||||
ENTRYPOINT ["/checker-xmpp"]
|
ENTRYPOINT ["/checker-xmpp"]
|
||||||
|
|
|
||||||
4
Makefile
4
Makefile
|
|
@ -11,7 +11,7 @@ GO_LDFLAGS := -X main.Version=$(CHECKER_VERSION)
|
||||||
all: $(CHECKER_NAME)
|
all: $(CHECKER_NAME)
|
||||||
|
|
||||||
$(CHECKER_NAME): $(CHECKER_SOURCES)
|
$(CHECKER_NAME): $(CHECKER_SOURCES)
|
||||||
go build -ldflags "$(GO_LDFLAGS)" -o $@ .
|
go build -tags standalone -ldflags "$(GO_LDFLAGS)" -o $@ .
|
||||||
|
|
||||||
plugin: $(CHECKER_NAME).so
|
plugin: $(CHECKER_NAME).so
|
||||||
|
|
||||||
|
|
@ -22,7 +22,7 @@ docker:
|
||||||
docker build --build-arg CHECKER_VERSION=$(CHECKER_VERSION) -t $(CHECKER_IMAGE) .
|
docker build --build-arg CHECKER_VERSION=$(CHECKER_VERSION) -t $(CHECKER_IMAGE) .
|
||||||
|
|
||||||
test:
|
test:
|
||||||
go test ./...
|
go test -tags standalone ./...
|
||||||
|
|
||||||
clean:
|
clean:
|
||||||
rm -f $(CHECKER_NAME) $(CHECKER_NAME).so
|
rm -f $(CHECKER_NAME) $(CHECKER_NAME).so
|
||||||
|
|
|
||||||
15
README.md
15
README.md
|
|
@ -82,6 +82,21 @@ make plugin
|
||||||
|
|
||||||
Applies to services of type `abstract.XMPP`.
|
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
|
## License
|
||||||
|
|
||||||
MIT (see `LICENSE`). Third-party attributions in `NOTICE`.
|
MIT (see `LICENSE`). Third-party attributions in `NOTICE`.
|
||||||
|
|
|
||||||
|
|
@ -24,10 +24,21 @@ const (
|
||||||
tlsNS = "urn:ietf:params:xml:ns:xmpp-tls"
|
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 {
|
func tlsProbeConfig(serverName string) *tls.Config {
|
||||||
return &tls.Config{
|
return &tls.Config{
|
||||||
ServerName: serverName,
|
ServerName: serverName,
|
||||||
InsecureSkipVerify: true, //nolint:gosec: cert validation is the TLS checker's job
|
InsecureSkipVerify: true, //nolint:gosec
|
||||||
MinVersion: tls.VersionTLS10,
|
MinVersion: tls.VersionTLS10,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -39,6 +50,9 @@ func (p *xmppProvider) Collect(ctx context.Context, opts sdk.CheckerOptions) (an
|
||||||
if domain == "" {
|
if domain == "" {
|
||||||
return nil, fmt.Errorf("domain is required")
|
return nil, fmt.Errorf("domain is required")
|
||||||
}
|
}
|
||||||
|
if err := validateDomain(domain); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
mode, _ := sdk.GetOption[string](opts, "mode")
|
mode, _ := sdk.GetOption[string](opts, "mode")
|
||||||
if mode == "" {
|
if mode == "" {
|
||||||
|
|
@ -106,7 +120,9 @@ func (p *xmppProvider) Collect(ctx context.Context, opts sdk.CheckerOptions) (an
|
||||||
probeSet(ctx, data, domain, ModeServer, "_xmpps-server._tcp", data.SRV.ServerSecure, true, perEndpoint)
|
probeSet(ctx, data, domain, ModeServer, "_xmpps-server._tcp", data.SRV.ServerSecure, true, perEndpoint)
|
||||||
|
|
||||||
computeCoverage(data)
|
computeCoverage(data)
|
||||||
data.Issues = deriveIssues(data, wantC2S, wantS2S)
|
|
||||||
|
// Collect intentionally does not populate data.Issues; judging the raw
|
||||||
|
// payload is the job of the CheckRules (see rules.go).
|
||||||
|
|
||||||
return data, nil
|
return data, nil
|
||||||
}
|
}
|
||||||
|
|
@ -395,6 +411,31 @@ func expectProceed(dec *xml.Decoder) error {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// validateDomain enforces RFC 1123 hostname rules before the value is used in
|
||||||
|
// DNS lookups and embedded in the XMPP stream header.
|
||||||
|
func validateDomain(domain string) error {
|
||||||
|
if len(domain) > 253 {
|
||||||
|
return fmt.Errorf("domain name too long (max 253 characters, got %d)", len(domain))
|
||||||
|
}
|
||||||
|
for _, label := range strings.Split(domain, ".") {
|
||||||
|
if len(label) == 0 {
|
||||||
|
return fmt.Errorf("domain contains an empty label")
|
||||||
|
}
|
||||||
|
if len(label) > 63 {
|
||||||
|
return fmt.Errorf("domain label %q exceeds 63 characters", label)
|
||||||
|
}
|
||||||
|
if label[0] == '-' || label[len(label)-1] == '-' {
|
||||||
|
return fmt.Errorf("domain label %q must not start or end with a hyphen", label)
|
||||||
|
}
|
||||||
|
for _, c := range label {
|
||||||
|
if !((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '-') {
|
||||||
|
return fmt.Errorf("domain label %q contains invalid character %q", label, c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func lookupSRV(ctx context.Context, r *net.Resolver, prefix, domain string) ([]SRVRecord, error) {
|
func lookupSRV(ctx context.Context, r *net.Resolver, prefix, domain string) ([]SRVRecord, error) {
|
||||||
name := prefix + dns.Fqdn(domain)
|
name := prefix + dns.Fqdn(domain)
|
||||||
_, records, err := r.LookupSRV(ctx, "", "", name)
|
_, records, err := r.LookupSRV(ctx, "", "", name)
|
||||||
|
|
@ -439,6 +480,9 @@ func resolveAllInto(ctx context.Context, r *net.Resolver, records []SRVRecord) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// computeCoverage walks raw endpoints and fills in the ReachabilitySpan
|
||||||
|
// aggregate. It is still part of Collect because coverage is a raw summary
|
||||||
|
// of what was actually reached, not a judgment (it has no severity).
|
||||||
func computeCoverage(data *XMPPData) {
|
func computeCoverage(data *XMPPData) {
|
||||||
for _, ep := range data.Endpoints {
|
for _, ep := range data.Endpoints {
|
||||||
if ep.TCPConnected {
|
if ep.TCPConnected {
|
||||||
|
|
@ -453,212 +497,15 @@ func computeCoverage(data *XMPPData) {
|
||||||
}
|
}
|
||||||
switch ep.Mode {
|
switch ep.Mode {
|
||||||
case ModeClient:
|
case ModeClient:
|
||||||
// We consider c2s working if SASL was advertised, OR if STARTTLS
|
// c2s is reachable if SASL was advertised OR if STARTTLS
|
||||||
// completed but features couldn't be read (benign for probes).
|
// completed but features couldn't be read (benign for probes).
|
||||||
if len(ep.SASLMechanisms) > 0 || !ep.FeaturesRead {
|
if len(ep.SASLMechanisms) > 0 || !ep.FeaturesRead {
|
||||||
data.Coverage.WorkingC2S = true
|
data.Coverage.WorkingC2S = true
|
||||||
}
|
}
|
||||||
case ModeServer:
|
case ModeServer:
|
||||||
// Similarly, s2s is "working" if TLS completed. A misconfigured
|
// s2s reachable if TLS completed; the dialback/EXTERNAL
|
||||||
// server that advertised TLS but no dialback/EXTERNAL is reported
|
// posture judgment is expressed by a rule, not here.
|
||||||
// via the xmpp.s2s.no_auth issue, not via coverage.
|
|
||||||
data.Coverage.WorkingS2S = true
|
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 <required/> 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
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,7 @@ import (
|
||||||
// by main / plugin.
|
// by main / plugin.
|
||||||
var Version = "built-in"
|
var Version = "built-in"
|
||||||
|
|
||||||
func Definition() *sdk.CheckerDefinition {
|
func (p *xmppProvider) Definition() *sdk.CheckerDefinition {
|
||||||
return &sdk.CheckerDefinition{
|
return &sdk.CheckerDefinition{
|
||||||
ID: "xmpp",
|
ID: "xmpp",
|
||||||
Name: "XMPP Server",
|
Name: "XMPP Server",
|
||||||
|
|
@ -45,7 +45,7 @@ func Definition() *sdk.CheckerDefinition {
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
Rules: []sdk.CheckRule{Rule()},
|
Rules: Rules(),
|
||||||
Interval: &sdk.CheckIntervalSpec{
|
Interval: &sdk.CheckIntervalSpec{
|
||||||
Min: 5 * time.Minute,
|
Min: 5 * time.Minute,
|
||||||
Max: 7 * 24 * time.Hour,
|
Max: 7 * 24 * time.Hour,
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,5 @@
|
||||||
|
//go:build standalone
|
||||||
|
|
||||||
package checker
|
package checker
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
|
@ -10,7 +12,7 @@ import (
|
||||||
sdk "git.happydns.org/checker-sdk-go/checker"
|
sdk "git.happydns.org/checker-sdk-go/checker"
|
||||||
)
|
)
|
||||||
|
|
||||||
// RenderForm implements sdk.CheckerInteractive.
|
// RenderForm implements server.Interactive.
|
||||||
func (p *xmppProvider) RenderForm() []sdk.CheckerOptionField {
|
func (p *xmppProvider) RenderForm() []sdk.CheckerOptionField {
|
||||||
return []sdk.CheckerOptionField{
|
return []sdk.CheckerOptionField{
|
||||||
{
|
{
|
||||||
|
|
@ -36,13 +38,16 @@ func (p *xmppProvider) RenderForm() []sdk.CheckerOptionField {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ParseForm implements sdk.CheckerInteractive.
|
// ParseForm implements server.Interactive.
|
||||||
func (p *xmppProvider) ParseForm(r *http.Request) (sdk.CheckerOptions, error) {
|
func (p *xmppProvider) ParseForm(r *http.Request) (sdk.CheckerOptions, error) {
|
||||||
domain := strings.TrimSpace(r.FormValue("domain"))
|
domain := strings.TrimSpace(r.FormValue("domain"))
|
||||||
domain = strings.TrimSuffix(domain, ".")
|
domain = strings.TrimSuffix(domain, ".")
|
||||||
if domain == "" {
|
if domain == "" {
|
||||||
return nil, errors.New("domain is required")
|
return nil, errors.New("domain is required")
|
||||||
}
|
}
|
||||||
|
if err := validateDomain(domain); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
opts := sdk.CheckerOptions{"domain": domain}
|
opts := sdk.CheckerOptions{"domain": domain}
|
||||||
|
|
||||||
|
|
|
||||||
206
checker/issues.go
Normal file
206
checker/issues.go
Normal file
|
|
@ -0,0 +1,206 @@
|
||||||
|
package checker
|
||||||
|
|
||||||
|
import "strings"
|
||||||
|
|
||||||
|
// deriveIssues walks a raw XMPPData and returns the full list of findings
|
||||||
|
// that rules (and the HTML report) may surface.
|
||||||
|
//
|
||||||
|
// It does not mutate data. It is intentionally pure so that rules can
|
||||||
|
// recompute their slice of the findings without having to stash anything
|
||||||
|
// into the observation payload. wantC2S / wantS2S restrict mode-scoped
|
||||||
|
// checks (SASL / direct-TLS / working-endpoint-coverage); SRV and per-
|
||||||
|
// endpoint findings are always emitted.
|
||||||
|
func deriveIssues(data *XMPPData, wantC2S, wantS2S bool) []Issue {
|
||||||
|
var issues []Issue
|
||||||
|
|
||||||
|
// 1. No SRV published.
|
||||||
|
if data.SRV.FallbackProbed {
|
||||||
|
issues = append(issues, Issue{
|
||||||
|
Code: CodeNoSRV,
|
||||||
|
Severity: SeverityCrit,
|
||||||
|
Message: "No XMPP SRV records found for " + data.Domain + ".",
|
||||||
|
Fix: "Publish _xmpp-client._tcp." + data.Domain + " and _xmpp-server._tcp." + data.Domain + " SRV records.",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Legacy _jabber.
|
||||||
|
if len(data.SRV.Jabber) > 0 {
|
||||||
|
issues = append(issues, Issue{
|
||||||
|
Code: CodeLegacyJabber,
|
||||||
|
Severity: SeverityWarn,
|
||||||
|
Message: "Obsolete _jabber._tcp SRV record still published.",
|
||||||
|
Fix: "Remove _jabber._tcp records; _xmpp-client._tcp supersedes them.",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. SRV lookup errors (real DNS failures, not NXDOMAIN).
|
||||||
|
for prefix, msg := range data.SRV.Errors {
|
||||||
|
issues = append(issues, Issue{
|
||||||
|
Code: CodeSRVServfail,
|
||||||
|
Severity: SeverityWarn,
|
||||||
|
Message: "DNS lookup failed for " + prefix + data.Domain + ": " + msg,
|
||||||
|
Fix: "Check the authoritative DNS servers for this domain.",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Endpoint-level issues.
|
||||||
|
allDown := true
|
||||||
|
sawSCRAM := map[XMPPMode]bool{}
|
||||||
|
sawSCRAMPlus := map[XMPPMode]bool{}
|
||||||
|
sawPlainOnly := map[XMPPMode]bool{}
|
||||||
|
sawAnyWorking := map[XMPPMode]bool{}
|
||||||
|
|
||||||
|
for _, ep := range data.Endpoints {
|
||||||
|
if ep.TCPConnected && ep.STARTTLSUpgraded {
|
||||||
|
allDown = false
|
||||||
|
sawAnyWorking[ep.Mode] = true
|
||||||
|
}
|
||||||
|
if ep.TCPConnected && ep.StreamOpened && !ep.DirectTLS {
|
||||||
|
if !ep.STARTTLSOffered {
|
||||||
|
issues = append(issues, Issue{
|
||||||
|
Code: CodeStartTLSMissing,
|
||||||
|
Severity: SeverityCrit,
|
||||||
|
Message: "STARTTLS not advertised on " + ep.Address + " (" + ep.SRVPrefix + ").",
|
||||||
|
Fix: "Enable STARTTLS in the XMPP server configuration and require it for all connections.",
|
||||||
|
Endpoint: ep.Address,
|
||||||
|
})
|
||||||
|
} else if !ep.STARTTLSRequired {
|
||||||
|
issues = append(issues, Issue{
|
||||||
|
Code: CodeStartTLSNotRequired,
|
||||||
|
Severity: SeverityWarn,
|
||||||
|
Message: "STARTTLS offered but not <required/> 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
|
||||||
|
}
|
||||||
|
|
@ -18,11 +18,6 @@ func (p *xmppProvider) Key() sdk.ObservationKey {
|
||||||
return ObservationKeyXMPP
|
return ObservationKeyXMPP
|
||||||
}
|
}
|
||||||
|
|
||||||
// Definition implements sdk.CheckerDefinitionProvider.
|
|
||||||
func (p *xmppProvider) Definition() *sdk.CheckerDefinition {
|
|
||||||
return Definition()
|
|
||||||
}
|
|
||||||
|
|
||||||
// DiscoverEntries implements sdk.DiscoveryPublisher.
|
// DiscoverEntries implements sdk.DiscoveryPublisher.
|
||||||
//
|
//
|
||||||
// It publishes TLS endpoint contract entries for every SRV target we found,
|
// It publishes TLS endpoint contract entries for every SRV target we found,
|
||||||
|
|
|
||||||
|
|
@ -305,12 +305,19 @@ th { font-weight: 600; color: #6b7280; }
|
||||||
// GetHTMLReport implements sdk.CheckerHTMLReporter. It folds in related TLS
|
// GetHTMLReport implements sdk.CheckerHTMLReporter. It folds in related TLS
|
||||||
// observations so the XMPP service page shows cert posture directly, without
|
// observations so the XMPP service page shows cert posture directly, without
|
||||||
// the user having to open a separate TLS report.
|
// 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) {
|
func (p *xmppProvider) GetHTMLReport(rctx sdk.ReportContext) (string, error) {
|
||||||
var d XMPPData
|
var d XMPPData
|
||||||
if err := json.Unmarshal(rctx.Data(), &d); err != nil {
|
if err := json.Unmarshal(rctx.Data(), &d); err != nil {
|
||||||
return "", fmt.Errorf("unmarshal xmpp observation: %w", err)
|
return "", fmt.Errorf("unmarshal xmpp observation: %w", err)
|
||||||
}
|
}
|
||||||
view := buildReportData(&d, rctx.Related(TLSRelatedKey))
|
view := buildReportData(&d, rctx.Related(TLSRelatedKey), rctx.States())
|
||||||
return renderReport(view)
|
return renderReport(view)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -322,12 +329,15 @@ func renderReport(view reportData) (string, error) {
|
||||||
return buf.String(), nil
|
return buf.String(), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func buildReportData(d *XMPPData, related []sdk.RelatedObservation) reportData {
|
func buildReportData(d *XMPPData, related []sdk.RelatedObservation, states []sdk.CheckState) reportData {
|
||||||
tlsIssues := tlsIssuesFromRelated(related)
|
|
||||||
tlsByAddr := indexTLSByAddress(related)
|
tlsByAddr := indexTLSByAddress(related)
|
||||||
|
|
||||||
allIssues := append([]Issue(nil), d.Issues...)
|
// Fix list comes exclusively from the CheckStates the host evaluated.
|
||||||
allIssues = append(allIssues, tlsIssues...)
|
// When no states were supplied (standalone renders, one-off tests),
|
||||||
|
// the hint section is skipped entirely: we show raw facts only,
|
||||||
|
// never re-judge the observation here.
|
||||||
|
fixes := fixesFromStates(states)
|
||||||
|
hasStates := len(states) > 0
|
||||||
|
|
||||||
view := reportData{
|
view := reportData{
|
||||||
Domain: d.Domain,
|
Domain: d.Domain,
|
||||||
|
|
@ -338,35 +348,38 @@ func buildReportData(d *XMPPData, related []sdk.RelatedObservation) reportData {
|
||||||
HasIPv6: d.Coverage.HasIPv6,
|
HasIPv6: d.Coverage.HasIPv6,
|
||||||
WorkingC2S: d.Coverage.WorkingC2S,
|
WorkingC2S: d.Coverage.WorkingC2S,
|
||||||
WorkingS2S: d.Coverage.WorkingS2S,
|
WorkingS2S: d.Coverage.WorkingS2S,
|
||||||
HasIssues: len(allIssues) > 0,
|
HasIssues: len(fixes) > 0,
|
||||||
HasTLSPosture: len(tlsByAddr) > 0,
|
HasTLSPosture: len(tlsByAddr) > 0,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Status banner.
|
// Status banner: driven by the worst CheckState when available,
|
||||||
worst := SeverityInfo
|
// otherwise a neutral label (data-only render).
|
||||||
for _, is := range allIssues {
|
if !hasStates {
|
||||||
if is.Severity == SeverityCrit {
|
view.StatusLabel = "DATA"
|
||||||
worst = SeverityCrit
|
view.StatusClass = "muted"
|
||||||
break
|
|
||||||
}
|
|
||||||
if is.Severity == SeverityWarn {
|
|
||||||
worst = SeverityWarn
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if len(allIssues) == 0 {
|
|
||||||
view.StatusLabel = "OK"
|
|
||||||
view.StatusClass = "ok"
|
|
||||||
} else {
|
} else {
|
||||||
|
worst := sdk.StatusOK
|
||||||
|
for _, s := range states {
|
||||||
|
if s.Status > worst {
|
||||||
|
worst = s.Status
|
||||||
|
}
|
||||||
|
}
|
||||||
switch worst {
|
switch worst {
|
||||||
case SeverityCrit:
|
case sdk.StatusCrit, sdk.StatusError:
|
||||||
view.StatusLabel = "FAIL"
|
view.StatusLabel = "FAIL"
|
||||||
view.StatusClass = "fail"
|
view.StatusClass = "fail"
|
||||||
case SeverityWarn:
|
case sdk.StatusWarn:
|
||||||
view.StatusLabel = "WARN"
|
view.StatusLabel = "WARN"
|
||||||
view.StatusClass = "warn"
|
view.StatusClass = "warn"
|
||||||
default:
|
case sdk.StatusInfo:
|
||||||
view.StatusLabel = "INFO"
|
view.StatusLabel = "INFO"
|
||||||
view.StatusClass = "muted"
|
view.StatusClass = "muted"
|
||||||
|
case sdk.StatusUnknown:
|
||||||
|
view.StatusLabel = "UNKNOWN"
|
||||||
|
view.StatusClass = "muted"
|
||||||
|
default:
|
||||||
|
view.StatusLabel = "OK"
|
||||||
|
view.StatusClass = "ok"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -381,16 +394,8 @@ func buildReportData(d *XMPPData, related []sdk.RelatedObservation) reportData {
|
||||||
return 2
|
return 2
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
sort.SliceStable(allIssues, func(i, j int) bool { return sevRank(allIssues[i].Severity) < sevRank(allIssues[j].Severity) })
|
sort.SliceStable(fixes, func(i, j int) bool { return sevRank(fixes[i].Severity) < sevRank(fixes[j].Severity) })
|
||||||
for _, is := range allIssues {
|
view.Fixes = fixes
|
||||||
view.Fixes = append(view.Fixes, reportFix{
|
|
||||||
Severity: is.Severity,
|
|
||||||
Code: is.Code,
|
|
||||||
Message: is.Message,
|
|
||||||
Fix: is.Fix,
|
|
||||||
Endpoint: is.Endpoint,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// SRV rows.
|
// SRV rows.
|
||||||
addSRV := func(prefix string, records []SRVRecord) {
|
addSRV := func(prefix string, records []SRVRecord) {
|
||||||
|
|
@ -462,6 +467,42 @@ func buildReportData(d *XMPPData, related []sdk.RelatedObservation) reportData {
|
||||||
return view
|
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 {
|
func modeLabel(m XMPPMode) string {
|
||||||
switch m {
|
switch m {
|
||||||
case ModeClient:
|
case ModeClient:
|
||||||
|
|
|
||||||
|
|
@ -9,21 +9,9 @@ import (
|
||||||
sdk "git.happydns.org/checker-sdk-go/checker"
|
sdk "git.happydns.org/checker-sdk-go/checker"
|
||||||
)
|
)
|
||||||
|
|
||||||
func Rule() sdk.CheckRule {
|
// validateXMPPOptions is the shared options validator for both the provider
|
||||||
return &xmppRule{}
|
// and the aggregate rule.
|
||||||
}
|
func validateXMPPOptions(opts sdk.CheckerOptions) error {
|
||||||
|
|
||||||
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 v, ok := opts["mode"]; ok {
|
||||||
if s, ok := v.(string); ok && s != "" && !slices.Contains(validModes, s) {
|
if s, ok := v.(string); ok && s != "" && !slices.Contains(validModes, s) {
|
||||||
return fmt.Errorf(`mode must be "c2s", "s2s", or "both"`)
|
return fmt.Errorf(`mode must be "c2s", "s2s", or "both"`)
|
||||||
|
|
@ -32,29 +20,41 @@ func (r *xmppRule) ValidateOptions(opts sdk.CheckerOptions) error {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *xmppRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, opts sdk.CheckerOptions) []sdk.CheckState {
|
// ValidateOptions implements sdk.OptionsValidator on the provider.
|
||||||
var data XMPPData
|
func (p *xmppProvider) ValidateOptions(opts sdk.CheckerOptions) error {
|
||||||
if err := obs.Get(ctx, ObservationKeyXMPP, &data); err != nil {
|
return validateXMPPOptions(opts)
|
||||||
return []sdk.CheckState{{
|
|
||||||
Status: sdk.StatusError,
|
|
||||||
Message: fmt.Sprintf("failed to load XMPP observation: %v", err),
|
|
||||||
Code: "xmpp.observation_error",
|
|
||||||
}}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
issues := append([]Issue(nil), data.Issues...)
|
// 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{}
|
||||||
|
|
||||||
// Fold related TLS observations (from a downstream TLS checker, if any)
|
func (r *xmppRule) Name() string { return "xmpp_server" }
|
||||||
// into the XMPP issue list so cert/chain problems show up on the XMPP
|
func (r *xmppRule) Description() string {
|
||||||
// service page without requiring a separate glance at the TLS checker.
|
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}
|
||||||
|
}
|
||||||
|
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.
|
||||||
related, _ := obs.GetRelated(ctx, TLSRelatedKey)
|
related, _ := obs.GetRelated(ctx, TLSRelatedKey)
|
||||||
issues = append(issues, tlsIssuesFromRelated(related)...)
|
issues = append(issues, tlsIssuesFromRelated(related)...)
|
||||||
|
|
||||||
// Reduce issue list to the worst severity.
|
|
||||||
worst := sdk.StatusOK
|
worst := sdk.StatusOK
|
||||||
critMsgs, warnMsgs := []string{}, []string{}
|
var critMsgs, warnMsgs []string
|
||||||
var firstCritCode, firstWarnCode string
|
var firstCritCode, firstWarnCode string
|
||||||
|
|
||||||
for _, is := range issues {
|
for _, is := range issues {
|
||||||
switch is.Severity {
|
switch is.Severity {
|
||||||
case SeverityCrit:
|
case SeverityCrit:
|
||||||
|
|
@ -76,15 +76,6 @@ func (r *xmppRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, opts
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
mode, _ := sdk.GetOption[string](opts, "mode")
|
|
||||||
if mode == "" {
|
|
||||||
mode = "both"
|
|
||||||
}
|
|
||||||
wantC2S := mode != "s2s"
|
|
||||||
wantS2S := mode != "c2s"
|
|
||||||
|
|
||||||
// Even without issues, the check isn't OK unless we got at least one
|
|
||||||
// working endpoint in each requested mode.
|
|
||||||
if (wantC2S && !data.Coverage.WorkingC2S) || (wantS2S && !data.Coverage.WorkingS2S) {
|
if (wantC2S && !data.Coverage.WorkingC2S) || (wantS2S && !data.Coverage.WorkingS2S) {
|
||||||
if worst < sdk.StatusCrit {
|
if worst < sdk.StatusCrit {
|
||||||
worst = sdk.StatusCrit
|
worst = sdk.StatusCrit
|
||||||
|
|
@ -96,7 +87,7 @@ func (r *xmppRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, opts
|
||||||
if wantS2S && !data.Coverage.WorkingS2S {
|
if wantS2S && !data.Coverage.WorkingS2S {
|
||||||
missing = append(missing, "s2s")
|
missing = append(missing, "s2s")
|
||||||
}
|
}
|
||||||
critMsgs = append(critMsgs, "no working "+strings.Join(missing, "/")+" endpoint")
|
critMsgs = append(critMsgs, "no working "+joinModes(missing)+" endpoint")
|
||||||
if firstCritCode == "" {
|
if firstCritCode == "" {
|
||||||
firstCritCode = CodeAllEndpointsDown
|
firstCritCode = CodeAllEndpointsDown
|
||||||
}
|
}
|
||||||
|
|
@ -108,7 +99,7 @@ func (r *xmppRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, opts
|
||||||
"has_ipv4": data.Coverage.HasIPv4,
|
"has_ipv4": data.Coverage.HasIPv4,
|
||||||
"has_ipv6": data.Coverage.HasIPv6,
|
"has_ipv6": data.Coverage.HasIPv6,
|
||||||
"endpoints": len(data.Endpoints),
|
"endpoints": len(data.Endpoints),
|
||||||
"issue_count": len(data.Issues),
|
"issue_count": len(issues),
|
||||||
}
|
}
|
||||||
|
|
||||||
switch worst {
|
switch worst {
|
||||||
|
|
@ -136,6 +127,17 @@ func (r *xmppRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, opts
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func joinModes(ms []string) string {
|
||||||
|
switch len(ms) {
|
||||||
|
case 0:
|
||||||
|
return ""
|
||||||
|
case 1:
|
||||||
|
return ms[0]
|
||||||
|
default:
|
||||||
|
return ms[0] + "/" + ms[1]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func joinTop(msgs []string, n int) string {
|
func joinTop(msgs []string, n int) string {
|
||||||
if len(msgs) == 0 {
|
if len(msgs) == 0 {
|
||||||
return ""
|
return ""
|
||||||
|
|
|
||||||
139
checker/rules.go
Normal file
139
checker/rules.go
Normal file
|
|
@ -0,0 +1,139 @@
|
||||||
|
package checker
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
sdk "git.happydns.org/checker-sdk-go/checker"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Rule returns a single aggregate rule covering the whole XMPP posture.
|
||||||
|
// Kept for backwards compatibility with callers that expect exactly one
|
||||||
|
// CheckRule; prefer Rules() which splits concerns into individual rules.
|
||||||
|
func Rule() sdk.CheckRule {
|
||||||
|
return &xmppRule{}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rules returns the full list of CheckRules exposed by the XMPP checker,
|
||||||
|
// one per concern so callers can see at a glance which checks passed and
|
||||||
|
// which did not, instead of looking up Code on a single monolithic rule.
|
||||||
|
func Rules() []sdk.CheckRule {
|
||||||
|
return []sdk.CheckRule{
|
||||||
|
&simpleXMPPConcernRule{
|
||||||
|
name: "xmpp.srv_c2s",
|
||||||
|
description: "Verifies that client-to-server SRV records (_xmpp-client / _xmpps-client / _jabber) are published and resolvable.",
|
||||||
|
codes: []string{CodeNoSRV, CodeSRVServfail, CodeLegacyJabber},
|
||||||
|
passCode: "xmpp.srv_c2s.ok",
|
||||||
|
passMessage: "Client-to-server SRV records are published and resolve cleanly.",
|
||||||
|
modeFilter: modeFilterC2S,
|
||||||
|
},
|
||||||
|
&simpleXMPPConcernRule{
|
||||||
|
name: "xmpp.srv_s2s",
|
||||||
|
description: "Verifies that server-to-server SRV records (_xmpp-server / _xmpps-server) are published and resolvable.",
|
||||||
|
codes: []string{CodeNoSRV, CodeSRVServfail},
|
||||||
|
passCode: "xmpp.srv_s2s.ok",
|
||||||
|
passMessage: "Server-to-server SRV records are published and resolve cleanly.",
|
||||||
|
modeFilter: modeFilterS2S,
|
||||||
|
},
|
||||||
|
&c2sReachableRule{},
|
||||||
|
&s2sReachableRule{},
|
||||||
|
&simpleXMPPConcernRule{
|
||||||
|
name: "xmpp.starttls_required",
|
||||||
|
description: "Verifies that STARTTLS is advertised and required on every reachable c2s/s2s endpoint.",
|
||||||
|
codes: []string{CodeStartTLSMissing, CodeStartTLSNotRequired, CodeStartTLSFailed},
|
||||||
|
passCode: "xmpp.starttls_required.ok",
|
||||||
|
passMessage: "STARTTLS is offered and required on every reachable endpoint.",
|
||||||
|
},
|
||||||
|
&simpleXMPPConcernRule{
|
||||||
|
name: "xmpp.sasl_mechanisms",
|
||||||
|
description: "Reviews the c2s SASL mechanisms offer (presence of SCRAM, absence of password-equivalent PLAIN-only).",
|
||||||
|
codes: []string{CodeSASLPlainOnly, CodeSASLNoSCRAM, CodeSASLNoSCRAMPlus},
|
||||||
|
passCode: "xmpp.sasl_mechanisms.ok",
|
||||||
|
passMessage: "c2s advertises a strong SASL mechanism (SCRAM family).",
|
||||||
|
modeFilter: modeFilterC2S,
|
||||||
|
},
|
||||||
|
&simpleXMPPConcernRule{
|
||||||
|
name: "xmpp.s2s_dialback",
|
||||||
|
description: "Verifies that s2s endpoints advertise dialback or SASL EXTERNAL after TLS (federation auth).",
|
||||||
|
codes: []string{CodeS2SNoAuth, CodeS2SProbeIncomplete},
|
||||||
|
passCode: "xmpp.s2s_dialback.ok",
|
||||||
|
passMessage: "Every reachable s2s endpoint advertises dialback or SASL EXTERNAL.",
|
||||||
|
modeFilter: modeFilterS2S,
|
||||||
|
},
|
||||||
|
&simpleXMPPConcernRule{
|
||||||
|
name: "xmpp.ipv6_reachable",
|
||||||
|
description: "Flags deployments that are only reachable over IPv4.",
|
||||||
|
codes: []string{CodeNoIPv6},
|
||||||
|
passCode: "xmpp.ipv6_reachable.ok",
|
||||||
|
passMessage: "At least one endpoint is reachable over IPv6.",
|
||||||
|
},
|
||||||
|
&simpleXMPPConcernRule{
|
||||||
|
name: "xmpp.direct_tls",
|
||||||
|
description: "Flags c2s deployments that do not publish XEP-0368 direct-TLS SRV records.",
|
||||||
|
codes: []string{CodeNoDirectTLS},
|
||||||
|
passCode: "xmpp.direct_tls.ok",
|
||||||
|
passMessage: "XEP-0368 direct-TLS SRV records are published for c2s.",
|
||||||
|
modeFilter: modeFilterC2S,
|
||||||
|
},
|
||||||
|
&tlsQualityRule{},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// modeFilter lets a rule short-circuit to "skipped" when the selected mode
|
||||||
|
// excludes the concern (e.g. c2s-specific rule running in mode=s2s).
|
||||||
|
type modeFilter func(wantC2S, wantS2S bool) bool
|
||||||
|
|
||||||
|
func modeFilterC2S(wantC2S, _ bool) bool { return wantC2S }
|
||||||
|
func modeFilterS2S(_, wantS2S bool) bool { return wantS2S }
|
||||||
|
|
||||||
|
// simpleXMPPConcernRule covers the common shape: "derive the issue list,
|
||||||
|
// keep the ones matching these codes, emit them as states or a single pass
|
||||||
|
// state when none match".
|
||||||
|
type simpleXMPPConcernRule struct {
|
||||||
|
name string
|
||||||
|
description string
|
||||||
|
codes []string
|
||||||
|
passCode string
|
||||||
|
passMessage string
|
||||||
|
modeFilter modeFilter // optional
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *simpleXMPPConcernRule) Name() string { return r.name }
|
||||||
|
func (r *simpleXMPPConcernRule) Description() string { return r.description }
|
||||||
|
|
||||||
|
func (r *simpleXMPPConcernRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, opts sdk.CheckerOptions) []sdk.CheckState {
|
||||||
|
data, errSt := loadXMPPData(ctx, obs)
|
||||||
|
if errSt != nil {
|
||||||
|
return []sdk.CheckState{*errSt}
|
||||||
|
}
|
||||||
|
wantC2S, wantS2S := wantsFromOpts(opts)
|
||||||
|
if r.modeFilter != nil && !r.modeFilter(wantC2S, wantS2S) {
|
||||||
|
return []sdk.CheckState{notTestedState(r.name+".skipped", "Not applicable to the selected mode.")}
|
||||||
|
}
|
||||||
|
issues := filterIssuesByCodes(deriveIssues(data, wantC2S, wantS2S), r.codes...)
|
||||||
|
if len(issues) == 0 {
|
||||||
|
return []sdk.CheckState{passState(r.passCode, r.passMessage)}
|
||||||
|
}
|
||||||
|
return statesFromIssues(issues)
|
||||||
|
}
|
||||||
|
|
||||||
|
// tlsQualityRule folds findings from a downstream TLS checker into XMPP
|
||||||
|
// output, so cert chain / hostname / expiry problems show up on the XMPP
|
||||||
|
// service page without needing a separate glance at the TLS report.
|
||||||
|
type tlsQualityRule struct{}
|
||||||
|
|
||||||
|
func (r *tlsQualityRule) Name() string { return "xmpp.tls_quality" }
|
||||||
|
func (r *tlsQualityRule) Description() string {
|
||||||
|
return "Folds the downstream TLS checker findings (certificate chain, hostname match, expiry) onto the XMPP service."
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *tlsQualityRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, _ sdk.CheckerOptions) []sdk.CheckState {
|
||||||
|
related, _ := obs.GetRelated(ctx, TLSRelatedKey)
|
||||||
|
if len(related) == 0 {
|
||||||
|
return []sdk.CheckState{notTestedState("xmpp.tls_quality.skipped", "No related TLS observation available (no TLS checker downstream, or no probe yet).")}
|
||||||
|
}
|
||||||
|
issues := tlsIssuesFromRelated(related)
|
||||||
|
if len(issues) == 0 {
|
||||||
|
return []sdk.CheckState{passState("xmpp.tls_quality.ok", "Downstream TLS checker reports no issues on the XMPP endpoints.")}
|
||||||
|
}
|
||||||
|
return statesFromIssues(issues)
|
||||||
|
}
|
||||||
94
checker/rules_helpers.go
Normal file
94
checker/rules_helpers.go
Normal file
|
|
@ -0,0 +1,94 @@
|
||||||
|
package checker
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
sdk "git.happydns.org/checker-sdk-go/checker"
|
||||||
|
)
|
||||||
|
|
||||||
|
// loadXMPPData fetches the XMPP observation. On error, returns a CheckState
|
||||||
|
// the caller should emit to short-circuit its rule.
|
||||||
|
func loadXMPPData(ctx context.Context, obs sdk.ObservationGetter) (*XMPPData, *sdk.CheckState) {
|
||||||
|
var data XMPPData
|
||||||
|
if err := obs.Get(ctx, ObservationKeyXMPP, &data); err != nil {
|
||||||
|
return nil, &sdk.CheckState{
|
||||||
|
Status: sdk.StatusError,
|
||||||
|
Message: fmt.Sprintf("failed to load XMPP observation: %v", err),
|
||||||
|
Code: "xmpp.observation_error",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return &data, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// wantsFromOpts reads the "mode" option and returns (wantC2S, wantS2S).
|
||||||
|
// Defaults to "both" when unset or invalid.
|
||||||
|
func wantsFromOpts(opts sdk.CheckerOptions) (bool, bool) {
|
||||||
|
mode, _ := sdk.GetOption[string](opts, "mode")
|
||||||
|
if mode == "" {
|
||||||
|
mode = "both"
|
||||||
|
}
|
||||||
|
return mode != "s2s", mode != "c2s"
|
||||||
|
}
|
||||||
|
|
||||||
|
// statesFromIssues turns a list of derived Issues into CheckStates.
|
||||||
|
func statesFromIssues(issues []Issue) []sdk.CheckState {
|
||||||
|
out := make([]sdk.CheckState, 0, len(issues))
|
||||||
|
for _, is := range issues {
|
||||||
|
out = append(out, issueToState(is))
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func issueToState(is Issue) sdk.CheckState {
|
||||||
|
st := sdk.CheckState{
|
||||||
|
Status: severityToStatus(is.Severity),
|
||||||
|
Message: is.Message,
|
||||||
|
Code: is.Code,
|
||||||
|
Subject: is.Endpoint,
|
||||||
|
}
|
||||||
|
if is.Fix != "" {
|
||||||
|
st.Meta = map[string]any{"fix": is.Fix}
|
||||||
|
}
|
||||||
|
return st
|
||||||
|
}
|
||||||
|
|
||||||
|
func passState(code, message string) sdk.CheckState {
|
||||||
|
return sdk.CheckState{Status: sdk.StatusOK, Message: message, Code: code}
|
||||||
|
}
|
||||||
|
|
||||||
|
func notTestedState(code, message string) sdk.CheckState {
|
||||||
|
return sdk.CheckState{Status: sdk.StatusUnknown, Message: message, Code: code}
|
||||||
|
}
|
||||||
|
|
||||||
|
func severityToStatus(sev string) sdk.Status {
|
||||||
|
switch sev {
|
||||||
|
case SeverityCrit:
|
||||||
|
return sdk.StatusCrit
|
||||||
|
case SeverityWarn:
|
||||||
|
return sdk.StatusWarn
|
||||||
|
case SeverityInfo:
|
||||||
|
return sdk.StatusInfo
|
||||||
|
default:
|
||||||
|
return sdk.StatusOK
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// filterIssuesByCodes returns only the issues whose Code is in the given set,
|
||||||
|
// preserving their original order.
|
||||||
|
func filterIssuesByCodes(issues []Issue, codes ...string) []Issue {
|
||||||
|
if len(codes) == 0 || len(issues) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
set := make(map[string]struct{}, len(codes))
|
||||||
|
for _, c := range codes {
|
||||||
|
set[c] = struct{}{}
|
||||||
|
}
|
||||||
|
var out []Issue
|
||||||
|
for _, is := range issues {
|
||||||
|
if _, ok := set[is.Code]; ok {
|
||||||
|
out = append(out, is)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
94
checker/rules_reachable.go
Normal file
94
checker/rules_reachable.go
Normal file
|
|
@ -0,0 +1,94 @@
|
||||||
|
package checker
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
sdk "git.happydns.org/checker-sdk-go/checker"
|
||||||
|
)
|
||||||
|
|
||||||
|
// c2sReachableRule verifies that at least one client-to-server endpoint
|
||||||
|
// is reachable (TCP + TLS) and that no discovered c2s endpoint is down.
|
||||||
|
type c2sReachableRule struct{}
|
||||||
|
|
||||||
|
func (r *c2sReachableRule) Name() string { return "xmpp.c2s_reachable" }
|
||||||
|
func (r *c2sReachableRule) Description() string {
|
||||||
|
return "Verifies that at least one client-to-server endpoint accepts TCP and completes TLS."
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *c2sReachableRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, opts sdk.CheckerOptions) []sdk.CheckState {
|
||||||
|
return evaluateReachable(ctx, obs, opts, ModeClient)
|
||||||
|
}
|
||||||
|
|
||||||
|
// s2sReachableRule is the s2s counterpart.
|
||||||
|
type s2sReachableRule struct{}
|
||||||
|
|
||||||
|
func (r *s2sReachableRule) Name() string { return "xmpp.s2s_reachable" }
|
||||||
|
func (r *s2sReachableRule) Description() string {
|
||||||
|
return "Verifies that at least one server-to-server endpoint accepts TCP and completes TLS."
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *s2sReachableRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, opts sdk.CheckerOptions) []sdk.CheckState {
|
||||||
|
return evaluateReachable(ctx, obs, opts, ModeServer)
|
||||||
|
}
|
||||||
|
|
||||||
|
func evaluateReachable(ctx context.Context, obs sdk.ObservationGetter, opts sdk.CheckerOptions, mode XMPPMode) []sdk.CheckState {
|
||||||
|
data, errSt := loadXMPPData(ctx, obs)
|
||||||
|
if errSt != nil {
|
||||||
|
return []sdk.CheckState{*errSt}
|
||||||
|
}
|
||||||
|
wantC2S, wantS2S := wantsFromOpts(opts)
|
||||||
|
if mode == ModeClient && !wantC2S {
|
||||||
|
return []sdk.CheckState{notTestedState("xmpp.c2s_reachable.skipped", "c2s not in scope for the selected mode.")}
|
||||||
|
}
|
||||||
|
if mode == ModeServer && !wantS2S {
|
||||||
|
return []sdk.CheckState{notTestedState("xmpp.s2s_reachable.skipped", "s2s not in scope for the selected mode.")}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Per-endpoint TCP unreachable states for this mode.
|
||||||
|
var states []sdk.CheckState
|
||||||
|
anyForMode := false
|
||||||
|
for _, ep := range data.Endpoints {
|
||||||
|
if ep.Mode != mode {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
anyForMode = true
|
||||||
|
if !ep.TCPConnected && ep.Error != "" {
|
||||||
|
states = append(states, sdk.CheckState{
|
||||||
|
Status: sdk.StatusWarn,
|
||||||
|
Message: "Cannot reach " + ep.Address + ": " + ep.Error + ".",
|
||||||
|
Code: CodeTCPUnreachable,
|
||||||
|
Subject: ep.Address,
|
||||||
|
Meta: map[string]any{"fix": "Verify firewall rules and that the XMPP server is listening on this address."},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !anyForMode {
|
||||||
|
return []sdk.CheckState{{
|
||||||
|
Status: sdk.StatusCrit,
|
||||||
|
Message: "No " + string(mode) + " endpoint discovered to probe.",
|
||||||
|
Code: CodeNoSRV,
|
||||||
|
}}
|
||||||
|
}
|
||||||
|
|
||||||
|
working := data.Coverage.WorkingC2S
|
||||||
|
if mode == ModeServer {
|
||||||
|
working = data.Coverage.WorkingS2S
|
||||||
|
}
|
||||||
|
if !working {
|
||||||
|
states = append(states, sdk.CheckState{
|
||||||
|
Status: sdk.StatusCrit,
|
||||||
|
Message: "No working " + string(mode) + " endpoint (TCP + TLS).",
|
||||||
|
Code: CodeAllEndpointsDown,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(states) == 0 {
|
||||||
|
code := "xmpp.c2s_reachable.ok"
|
||||||
|
if mode == ModeServer {
|
||||||
|
code = "xmpp.s2s_reachable.ok"
|
||||||
|
}
|
||||||
|
return []sdk.CheckState{passState(code, "At least one "+string(mode)+" endpoint is reachable and completes TLS.")}
|
||||||
|
}
|
||||||
|
return states
|
||||||
|
}
|
||||||
|
|
@ -98,6 +98,9 @@ func tlsIssuesFromRelated(related []sdk.RelatedObservation) []Issue {
|
||||||
if code == "" {
|
if code == "" {
|
||||||
code = "tls.unknown"
|
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{
|
out = append(out, Issue{
|
||||||
Code: "xmpp.tls." + code,
|
Code: "xmpp.tls." + code,
|
||||||
Severity: sev,
|
Severity: sev,
|
||||||
|
|
@ -135,25 +138,10 @@ func tlsIssuesFromRelated(related []sdk.RelatedObservation) []Issue {
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
// worstSeverity returns "crit" > "warn" > "info" across the TLS issues.
|
// worstSeverity synthesises a severity from the structured flags on the probe.
|
||||||
|
// It is only called from the flag-only path in tlsIssuesFromRelated (when
|
||||||
|
// v.Issues is empty), so there is no issue list to iterate over.
|
||||||
func (v *tlsProbeView) worstSeverity() string {
|
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 {
|
if v.ChainValid != nil && !*v.ChainValid {
|
||||||
return SeverityCrit
|
return SeverityCrit
|
||||||
}
|
}
|
||||||
|
|
@ -164,9 +152,7 @@ func (v *tlsProbeView) worstSeverity() string {
|
||||||
return SeverityCrit
|
return SeverityCrit
|
||||||
}
|
}
|
||||||
if !v.NotAfter.IsZero() && time.Until(v.NotAfter) < 14*24*time.Hour {
|
if !v.NotAfter.IsZero() && time.Until(v.NotAfter) < 14*24*time.Hour {
|
||||||
if worst != SeverityCrit {
|
|
||||||
return SeverityWarn
|
return SeverityWarn
|
||||||
}
|
}
|
||||||
}
|
return ""
|
||||||
return worst
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -136,6 +136,7 @@ func (s *stubReportCtx) Data() json.RawMessage { return s.data }
|
||||||
func (s *stubReportCtx) Related(_ sdk.ObservationKey) []sdk.RelatedObservation {
|
func (s *stubReportCtx) Related(_ sdk.ObservationKey) []sdk.RelatedObservation {
|
||||||
return s.related
|
return s.related
|
||||||
}
|
}
|
||||||
|
func (s *stubReportCtx) States() []sdk.CheckState { return nil }
|
||||||
|
|
||||||
func mustJSON(t *testing.T, v any) json.RawMessage {
|
func mustJSON(t *testing.T, v any) json.RawMessage {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
@ -196,7 +197,7 @@ func TestTLSIssuesFromRelated_StructuredIssues(t *testing.T) {
|
||||||
if len(out) != 2 {
|
if len(out) != 2 {
|
||||||
t.Fatalf("expected 2 issues, got %d", len(out))
|
t.Fatalf("expected 2 issues, got %d", len(out))
|
||||||
}
|
}
|
||||||
if out[0].Code != "xmpp.tls.tls.self_signed" || out[0].Severity != SeverityCrit {
|
if out[0].Code != "xmpp.tls.self_signed" || out[0].Severity != SeverityCrit {
|
||||||
t.Fatalf("unexpected first issue: %+v", out[0])
|
t.Fatalf("unexpected first issue: %+v", out[0])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
4
go.mod
4
go.mod
|
|
@ -3,8 +3,8 @@ module git.happydns.org/checker-xmpp
|
||||||
go 1.25.0
|
go 1.25.0
|
||||||
|
|
||||||
require (
|
require (
|
||||||
git.happydns.org/checker-sdk-go v1.2.0
|
git.happydns.org/checker-sdk-go v1.5.0
|
||||||
git.happydns.org/checker-tls v0.2.0
|
git.happydns.org/checker-tls v0.6.2
|
||||||
github.com/miekg/dns v1.1.72
|
github.com/miekg/dns v1.1.72
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
8
go.sum
8
go.sum
|
|
@ -1,7 +1,7 @@
|
||||||
git.happydns.org/checker-sdk-go v1.2.0 h1:v4MpKAz0W3PwP+bxx3pya8w893sVH5xTD1of1cc0TV8=
|
git.happydns.org/checker-sdk-go v1.5.0 h1:5uD5Cm6xJ+lwnhbJ09iCXGHbYS9zRh+Yh0NeBHkAPBY=
|
||||||
git.happydns.org/checker-sdk-go v1.2.0/go.mod h1:aNAcfYFfbhvH9kJhE0Njp5GX0dQbxdRB0rJ0KvSC5nI=
|
git.happydns.org/checker-sdk-go v1.5.0/go.mod h1:aNAcfYFfbhvH9kJhE0Njp5GX0dQbxdRB0rJ0KvSC5nI=
|
||||||
git.happydns.org/checker-tls v0.2.0 h1:2dYpcePBylUc3le76fFlLbxraiLpGESmOhx4NfD7REM=
|
git.happydns.org/checker-tls v0.6.2 h1:8oKia1XlD+tklyqrwzmUgFH1Kw8VLSLLF9suZ7Qr14E=
|
||||||
git.happydns.org/checker-tls v0.2.0/go.mod h1:0ZSG0CTP007SHBPE7qInESVIOcW+xgucHUhHgj6MeZ8=
|
git.happydns.org/checker-tls v0.6.2/go.mod h1:9tpnxg0iOwS+7If64DRG1jqYonUAgxOBuxwfF5mVkL4=
|
||||||
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
|
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
|
||||||
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||||
github.com/miekg/dns v1.1.72 h1:vhmr+TF2A3tuoGNkLDFK9zi36F2LS+hKTRW0Uf8kbzI=
|
github.com/miekg/dns v1.1.72 h1:vhmr+TF2A3tuoGNkLDFK9zi36F2LS+hKTRW0Uf8kbzI=
|
||||||
|
|
|
||||||
6
main.go
6
main.go
|
|
@ -4,7 +4,7 @@ import (
|
||||||
"flag"
|
"flag"
|
||||||
"log"
|
"log"
|
||||||
|
|
||||||
sdk "git.happydns.org/checker-sdk-go/checker"
|
"git.happydns.org/checker-sdk-go/checker/server"
|
||||||
xmpp "git.happydns.org/checker-xmpp/checker"
|
xmpp "git.happydns.org/checker-xmpp/checker"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -21,8 +21,8 @@ func main() {
|
||||||
|
|
||||||
xmpp.Version = Version
|
xmpp.Version = Version
|
||||||
|
|
||||||
server := sdk.NewServer(xmpp.Provider())
|
srv := server.New(xmpp.Provider())
|
||||||
if err := server.ListenAndServe(*listenAddr); err != nil {
|
if err := srv.ListenAndServe(*listenAddr); err != nil {
|
||||||
log.Fatalf("server error: %v", err)
|
log.Fatalf("server error: %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -15,5 +15,6 @@ var Version = "custom-build"
|
||||||
// .so file.
|
// .so file.
|
||||||
func NewCheckerPlugin() (*sdk.CheckerDefinition, sdk.ObservationProvider, error) {
|
func NewCheckerPlugin() (*sdk.CheckerDefinition, sdk.ObservationProvider, error) {
|
||||||
xmpp.Version = Version
|
xmpp.Version = Version
|
||||||
return xmpp.Definition(), xmpp.Provider(), nil
|
prvd := xmpp.Provider()
|
||||||
|
return prvd.(sdk.CheckerDefinitionProvider).Definition(), prvd, nil
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue