Initial commit
This commit is contained in:
commit
d96ebc4d0e
19 changed files with 2537 additions and 0 deletions
2
.gitignore
vendored
Normal file
2
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
checker-xmpp
|
||||
checker-xmpp.so
|
||||
14
Dockerfile
Normal file
14
Dockerfile
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
FROM golang:1.25-alpine AS builder
|
||||
|
||||
ARG CHECKER_VERSION=custom-build
|
||||
|
||||
WORKDIR /src
|
||||
COPY go.mod go.sum ./
|
||||
RUN go mod download
|
||||
COPY . .
|
||||
RUN CGO_ENABLED=0 go build -ldflags "-X main.Version=${CHECKER_VERSION}" -o /checker-xmpp .
|
||||
|
||||
FROM scratch
|
||||
COPY --from=builder /checker-xmpp /checker-xmpp
|
||||
EXPOSE 8080
|
||||
ENTRYPOINT ["/checker-xmpp"]
|
||||
21
LICENSE
Normal file
21
LICENSE
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
MIT License
|
||||
|
||||
Copyright (c) 2026 The happyDomain Authors
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the “Software”), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
28
Makefile
Normal file
28
Makefile
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
CHECKER_NAME := checker-xmpp
|
||||
CHECKER_IMAGE := happydomain/$(CHECKER_NAME)
|
||||
CHECKER_VERSION ?= custom-build
|
||||
|
||||
CHECKER_SOURCES := main.go $(wildcard checker/*.go)
|
||||
|
||||
GO_LDFLAGS := -X main.Version=$(CHECKER_VERSION)
|
||||
|
||||
.PHONY: all plugin docker test clean
|
||||
|
||||
all: $(CHECKER_NAME)
|
||||
|
||||
$(CHECKER_NAME): $(CHECKER_SOURCES)
|
||||
go build -ldflags "$(GO_LDFLAGS)" -o $@ .
|
||||
|
||||
plugin: $(CHECKER_NAME).so
|
||||
|
||||
$(CHECKER_NAME).so: $(CHECKER_SOURCES) $(wildcard plugin/*.go)
|
||||
go build -buildmode=plugin -ldflags "$(GO_LDFLAGS)" -o $@ ./plugin/
|
||||
|
||||
docker:
|
||||
docker build --build-arg CHECKER_VERSION=$(CHECKER_VERSION) -t $(CHECKER_IMAGE) .
|
||||
|
||||
test:
|
||||
go test ./...
|
||||
|
||||
clean:
|
||||
rm -f $(CHECKER_NAME) $(CHECKER_NAME).so
|
||||
25
NOTICE
Normal file
25
NOTICE
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
checker-xmpp
|
||||
Copyright (c) 2026 The happyDomain Authors
|
||||
|
||||
This product is licensed under the MIT License (see LICENSE).
|
||||
|
||||
-------------------------------------------------------------------------------
|
||||
Third-party notices
|
||||
-------------------------------------------------------------------------------
|
||||
|
||||
This product includes software developed as part of the checker-sdk-go
|
||||
project (https://git.happydns.org/happyDomain/checker-sdk-go), licensed
|
||||
under the Apache License, Version 2.0:
|
||||
|
||||
checker-sdk-go
|
||||
Copyright 2020-2026 The happyDomain Authors
|
||||
|
||||
This product includes software from the miekg/dns project
|
||||
(https://github.com/miekg/dns), licensed under the BSD 3-Clause License:
|
||||
|
||||
Copyright (c) 2009 The Go Authors. All rights reserved.
|
||||
Copyright (c) 2011 Miek Gieben. All rights reserved.
|
||||
Copyright (c) 2014 CloudFlare. All rights reserved.
|
||||
|
||||
You may obtain a copy of the Apache License 2.0 at:
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
87
README.md
Normal file
87
README.md
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
# checker-xmpp
|
||||
|
||||
XMPP server checker for [happyDomain](https://www.happydomain.org/).
|
||||
|
||||
Probes a domain's XMPP deployment the same way
|
||||
[xmpp.net](https://xmpp.net/) does: SRV discovery, stream negotiation,
|
||||
STARTTLS, SASL mechanisms, federation auth (dialback / SASL EXTERNAL),
|
||||
and XEP-0368 direct-TLS. Produces an actionable HTML report with a
|
||||
remediation panel surfacing the most common real-world failures.
|
||||
|
||||
TLS certificate chain / SAN / expiry / cipher posture is **out of scope**:
|
||||
a dedicated TLS checker handles that. This checker only confirms that
|
||||
STARTTLS completes and records the negotiated TLS version/cipher for
|
||||
context.
|
||||
|
||||
We publish each probed endpoint as a `DiscoveryEntry` of type
|
||||
`tls.endpoint.v1` so that `checker-tls` (or any other consumer of that
|
||||
contract) can run TLS posture checks against them without redoing the
|
||||
SRV lookup. The entries are produced through
|
||||
`git.happydns.org/checker-tls/contract`, with `SNI` set to the bare JID
|
||||
domain; XMPP certificates must be valid for the source domain (RFC 6120
|
||||
§13.7.2.1), which is typically different from the SRV target hostname.
|
||||
`RequireSTARTTLS` is carried over from the STARTTLS-required posture we
|
||||
actually observed during probing, so an operator who requires STARTTLS
|
||||
will see a CRIT on the TLS side, not a WARN, if the server later drops
|
||||
it.
|
||||
|
||||
The TLS checker's resulting observations (under the `tls_probes` key)
|
||||
are folded back into our rule aggregation and HTML report via the SDK's
|
||||
`ObservationGetter.GetRelated` / `ReportContext.Related` path: a bad
|
||||
certificate on an XMPP endpoint shows up on the XMPP service page, not
|
||||
only in a separate TLS view. The matching between a probe and its XMPP
|
||||
endpoint is done on `RelatedObservation.Ref`, which carries the same
|
||||
value as `DiscoveryEntry.Ref` we emitted (computed deterministically by
|
||||
`contract.Ref`).
|
||||
|
||||
## What it checks
|
||||
|
||||
For each of `_xmpp-client._tcp`, `_xmpp-server._tcp`,
|
||||
`_xmpps-client._tcp`, `_xmpps-server._tcp` (and legacy `_jabber._tcp`):
|
||||
|
||||
1. SRV and A/AAAA resolution.
|
||||
2. TCP reachability.
|
||||
3. `<stream:stream>` open, stream features parsing.
|
||||
4. STARTTLS advertised (and `<required/>`).
|
||||
5. STARTTLS handshake success.
|
||||
6. Post-TLS SASL mechanism list (flags PLAIN-only, missing SCRAM).
|
||||
7. Server-to-server dialback / SASL EXTERNAL availability.
|
||||
8. XEP-0368 direct TLS (`_xmpps-*`) when published.
|
||||
9. IPv4 / IPv6 coverage.
|
||||
10. Fallback probe on `<domain>:5222`/`:5269` when no SRV is published.
|
||||
|
||||
## Usage
|
||||
|
||||
### Standalone HTTP server
|
||||
|
||||
```bash
|
||||
make
|
||||
./checker-xmpp -listen :8080
|
||||
```
|
||||
|
||||
### Docker
|
||||
|
||||
```bash
|
||||
make docker
|
||||
docker run -p 8080:8080 happydomain/checker-xmpp
|
||||
```
|
||||
|
||||
### happyDomain plugin
|
||||
|
||||
```bash
|
||||
make plugin
|
||||
```
|
||||
|
||||
## Options
|
||||
|
||||
| Scope | Id | Description |
|
||||
| ----- | ---------- | ----------------------------------------------------------- |
|
||||
| Run | `domain` | Domain to test (auto-filled from the service) |
|
||||
| Run | `mode` | `c2s`, `s2s`, or `both` (default) |
|
||||
| Run | `timeout` | Per-endpoint timeout in seconds (default `10`) |
|
||||
|
||||
Applies to services of type `abstract.XMPP`.
|
||||
|
||||
## License
|
||||
|
||||
MIT (see `LICENSE`). Third-party attributions in `NOTICE`.
|
||||
664
checker/collect.go
Normal file
664
checker/collect.go
Normal file
|
|
@ -0,0 +1,664 @@
|
|||
package checker
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"encoding/xml"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/miekg/dns"
|
||||
|
||||
sdk "git.happydns.org/checker-sdk-go/checker"
|
||||
)
|
||||
|
||||
const (
|
||||
streamsNS = "http://etherx.jabber.org/streams"
|
||||
clientNS = "jabber:client"
|
||||
serverNS = "jabber:server"
|
||||
tlsNS = "urn:ietf:params:xml:ns:xmpp-tls"
|
||||
)
|
||||
|
||||
func tlsProbeConfig(serverName string) *tls.Config {
|
||||
return &tls.Config{
|
||||
ServerName: serverName,
|
||||
InsecureSkipVerify: true, //nolint:gosec: cert validation is the TLS checker's job
|
||||
MinVersion: tls.VersionTLS10,
|
||||
}
|
||||
}
|
||||
|
||||
// Collect runs the full XMPP probe for a domain.
|
||||
func (p *xmppProvider) Collect(ctx context.Context, opts sdk.CheckerOptions) (any, error) {
|
||||
domain, _ := sdk.GetOption[string](opts, "domain")
|
||||
domain = strings.TrimSuffix(domain, ".")
|
||||
if domain == "" {
|
||||
return nil, fmt.Errorf("domain is required")
|
||||
}
|
||||
|
||||
mode, _ := sdk.GetOption[string](opts, "mode")
|
||||
if mode == "" {
|
||||
mode = "both"
|
||||
}
|
||||
timeoutSecs := sdk.GetFloatOption(opts, "timeout", 10)
|
||||
if timeoutSecs < 1 {
|
||||
timeoutSecs = 10
|
||||
}
|
||||
perEndpoint := time.Duration(timeoutSecs * float64(time.Second))
|
||||
|
||||
wantC2S := mode != "s2s"
|
||||
wantS2S := mode != "c2s"
|
||||
|
||||
data := &XMPPData{
|
||||
Domain: domain,
|
||||
RunAt: time.Now().UTC().Format(time.RFC3339),
|
||||
SRV: SRVLookup{Errors: map[string]string{}},
|
||||
}
|
||||
|
||||
resolver := net.DefaultResolver
|
||||
|
||||
lookupSets := []struct {
|
||||
prefix string
|
||||
want bool
|
||||
dst *[]SRVRecord
|
||||
}{
|
||||
{"_xmpp-client._tcp.", wantC2S, &data.SRV.Client},
|
||||
{"_xmpp-server._tcp.", wantS2S, &data.SRV.Server},
|
||||
{"_xmpps-client._tcp.", wantC2S, &data.SRV.ClientSecure},
|
||||
{"_xmpps-server._tcp.", wantS2S, &data.SRV.ServerSecure},
|
||||
{"_jabber._tcp.", wantC2S, &data.SRV.Jabber},
|
||||
}
|
||||
for _, ls := range lookupSets {
|
||||
if !ls.want {
|
||||
continue
|
||||
}
|
||||
records, err := lookupSRV(ctx, resolver, ls.prefix, domain)
|
||||
if err != nil {
|
||||
data.SRV.Errors[ls.prefix] = err.Error()
|
||||
continue
|
||||
}
|
||||
*ls.dst = records
|
||||
}
|
||||
|
||||
totalSRV := len(data.SRV.Client) + len(data.SRV.Server) + len(data.SRV.ClientSecure) + len(data.SRV.ServerSecure)
|
||||
if totalSRV == 0 {
|
||||
data.SRV.FallbackProbed = true
|
||||
if wantC2S {
|
||||
data.SRV.Client = []SRVRecord{{Target: domain, Port: 5222}}
|
||||
}
|
||||
if wantS2S {
|
||||
data.SRV.Server = []SRVRecord{{Target: domain, Port: 5269}}
|
||||
}
|
||||
}
|
||||
|
||||
resolveAllInto(ctx, resolver, data.SRV.Client)
|
||||
resolveAllInto(ctx, resolver, data.SRV.Server)
|
||||
resolveAllInto(ctx, resolver, data.SRV.ClientSecure)
|
||||
resolveAllInto(ctx, resolver, data.SRV.ServerSecure)
|
||||
|
||||
probeSet(ctx, data, domain, ModeClient, "_xmpp-client._tcp", data.SRV.Client, false, perEndpoint)
|
||||
probeSet(ctx, data, domain, ModeServer, "_xmpp-server._tcp", data.SRV.Server, false, perEndpoint)
|
||||
probeSet(ctx, data, domain, ModeClient, "_xmpps-client._tcp", data.SRV.ClientSecure, true, perEndpoint)
|
||||
probeSet(ctx, data, domain, ModeServer, "_xmpps-server._tcp", data.SRV.ServerSecure, true, perEndpoint)
|
||||
|
||||
computeCoverage(data)
|
||||
data.Issues = deriveIssues(data, wantC2S, wantS2S)
|
||||
|
||||
return data, nil
|
||||
}
|
||||
|
||||
func probeSet(ctx context.Context, data *XMPPData, domain string, mode XMPPMode, prefix string, records []SRVRecord, directTLS bool, timeout time.Duration) {
|
||||
for _, rec := range records {
|
||||
addrs := addressesForProbe(rec)
|
||||
if len(addrs) == 0 {
|
||||
ep := EndpointProbe{
|
||||
Mode: mode,
|
||||
SRVPrefix: prefix,
|
||||
Target: rec.Target,
|
||||
Port: rec.Port,
|
||||
DirectTLS: directTLS,
|
||||
Error: "no A/AAAA records for target",
|
||||
}
|
||||
data.Endpoints = append(data.Endpoints, ep)
|
||||
continue
|
||||
}
|
||||
for _, a := range addrs {
|
||||
ep := probeEndpoint(ctx, domain, mode, prefix, rec, a.ip, a.isV6, directTLS, timeout)
|
||||
data.Endpoints = append(data.Endpoints, ep)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type probeAddr struct {
|
||||
ip string
|
||||
isV6 bool
|
||||
}
|
||||
|
||||
func addressesForProbe(rec SRVRecord) []probeAddr {
|
||||
var out []probeAddr
|
||||
for _, ip := range rec.IPv4 {
|
||||
out = append(out, probeAddr{ip: ip, isV6: false})
|
||||
}
|
||||
for _, ip := range rec.IPv6 {
|
||||
out = append(out, probeAddr{ip: ip, isV6: true})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func probeEndpoint(ctx context.Context, domain string, mode XMPPMode, prefix string, rec SRVRecord, ip string, isV6, directTLS bool, timeout time.Duration) EndpointProbe {
|
||||
start := time.Now()
|
||||
result := EndpointProbe{
|
||||
Mode: mode,
|
||||
SRVPrefix: prefix,
|
||||
Target: rec.Target,
|
||||
Port: rec.Port,
|
||||
Address: net.JoinHostPort(ip, strconv.Itoa(int(rec.Port))),
|
||||
IsIPv6: isV6,
|
||||
DirectTLS: directTLS,
|
||||
}
|
||||
defer func() { result.ElapsedMS = time.Since(start).Milliseconds() }()
|
||||
|
||||
ns := clientNS
|
||||
if mode == ModeServer {
|
||||
ns = serverNS
|
||||
}
|
||||
|
||||
dialCtx, cancel := context.WithTimeout(ctx, timeout)
|
||||
defer cancel()
|
||||
|
||||
dialer := &net.Dialer{}
|
||||
rawConn, err := dialer.DialContext(dialCtx, "tcp", result.Address)
|
||||
if err != nil {
|
||||
result.Error = "tcp: " + err.Error()
|
||||
return result
|
||||
}
|
||||
result.TCPConnected = true
|
||||
defer rawConn.Close()
|
||||
_ = rawConn.SetDeadline(time.Now().Add(timeout))
|
||||
|
||||
var conn net.Conn = rawConn
|
||||
|
||||
if directTLS {
|
||||
tlsConn := tls.Client(rawConn, tlsProbeConfig(domain))
|
||||
if err := tlsConn.Handshake(); err != nil {
|
||||
result.Error = "tls-handshake: " + err.Error()
|
||||
return result
|
||||
}
|
||||
result.STARTTLSUpgraded = true
|
||||
state := tlsConn.ConnectionState()
|
||||
result.TLSVersion = tls.VersionName(state.Version)
|
||||
result.TLSCipher = tls.CipherSuiteName(state.CipherSuite)
|
||||
_ = tlsConn.SetDeadline(time.Now().Add(timeout))
|
||||
conn = tlsConn
|
||||
|
||||
feats, from, err := openStreamAndReadFeatures(conn, domain, ns, mode == ModeServer)
|
||||
if err != nil {
|
||||
result.Error = "stream: " + err.Error()
|
||||
return result
|
||||
}
|
||||
result.StreamOpened = true
|
||||
result.StreamFrom = from
|
||||
applyFeatures(&result, feats)
|
||||
return result
|
||||
}
|
||||
|
||||
dec, from, err := openStream(conn, domain, ns, mode == ModeServer)
|
||||
if err != nil {
|
||||
result.Error = "stream: " + err.Error()
|
||||
return result
|
||||
}
|
||||
result.StreamOpened = true
|
||||
result.StreamFrom = from
|
||||
|
||||
feats, err := readFeatures(dec)
|
||||
if err != nil {
|
||||
result.Error = "features: " + err.Error()
|
||||
return result
|
||||
}
|
||||
result.STARTTLSOffered = feats.StartTLS != nil
|
||||
if feats.StartTLS != nil && feats.StartTLS.Required != nil {
|
||||
result.STARTTLSRequired = true
|
||||
}
|
||||
|
||||
if !result.STARTTLSOffered {
|
||||
// Record any features seen in plaintext, but do not proceed; we
|
||||
// intentionally refuse to send SASL over a non-TLS channel.
|
||||
applyFeatures(&result, feats)
|
||||
return result
|
||||
}
|
||||
|
||||
if _, err := io.WriteString(conn, `<starttls xmlns='`+tlsNS+`'/>`); err != nil {
|
||||
result.Error = "starttls-write: " + err.Error()
|
||||
return result
|
||||
}
|
||||
if err := expectProceed(dec); err != nil {
|
||||
result.Error = "starttls-proceed: " + err.Error()
|
||||
return result
|
||||
}
|
||||
|
||||
tlsConn := tls.Client(rawConn, tlsProbeConfig(domain))
|
||||
_ = tlsConn.SetDeadline(time.Now().Add(timeout))
|
||||
if err := tlsConn.Handshake(); err != nil {
|
||||
result.Error = "tls-handshake: " + err.Error()
|
||||
return result
|
||||
}
|
||||
result.STARTTLSUpgraded = true
|
||||
state := tlsConn.ConnectionState()
|
||||
result.TLSVersion = tls.VersionName(state.Version)
|
||||
result.TLSCipher = tls.CipherSuiteName(state.CipherSuite)
|
||||
|
||||
_ = tlsConn.SetDeadline(time.Now().Add(timeout))
|
||||
feats2, _, err := openStreamAndReadFeatures(tlsConn, domain, ns, mode == ModeServer)
|
||||
if err != nil {
|
||||
result.Error = "post-tls stream: " + err.Error()
|
||||
return result
|
||||
}
|
||||
applyFeatures(&result, feats2)
|
||||
return result
|
||||
}
|
||||
|
||||
// applyFeatures copies parsed stream features into the probe result.
|
||||
func applyFeatures(ep *EndpointProbe, feats *streamFeatures) {
|
||||
if feats == nil {
|
||||
return
|
||||
}
|
||||
ep.FeaturesRead = true
|
||||
if feats.Mechanisms != nil {
|
||||
ep.SASLMechanisms = append(ep.SASLMechanisms, feats.Mechanisms.Mechanism...)
|
||||
for _, m := range feats.Mechanisms.Mechanism {
|
||||
if strings.EqualFold(m, "EXTERNAL") {
|
||||
ep.SASLExternal = true
|
||||
}
|
||||
}
|
||||
}
|
||||
if feats.Dialback != nil {
|
||||
ep.DialbackOffered = true
|
||||
}
|
||||
}
|
||||
|
||||
type streamFeatures struct {
|
||||
XMLName xml.Name `xml:"http://etherx.jabber.org/streams features"`
|
||||
StartTLS *startTLSEl
|
||||
Mechanisms *mechanismsEl
|
||||
Dialback *struct{} `xml:"urn:xmpp:features:dialback dialback"`
|
||||
}
|
||||
|
||||
type startTLSEl struct {
|
||||
XMLName xml.Name `xml:"urn:ietf:params:xml:ns:xmpp-tls starttls"`
|
||||
Required *struct{} `xml:"required"`
|
||||
}
|
||||
|
||||
type mechanismsEl struct {
|
||||
XMLName xml.Name `xml:"urn:ietf:params:xml:ns:xmpp-sasl mechanisms"`
|
||||
Mechanism []string `xml:"mechanism"`
|
||||
}
|
||||
|
||||
// openStreamAndReadFeatures performs the stream header exchange and parses
|
||||
// <stream:features>. Used both for the initial open and for the post-TLS
|
||||
// stream restart.
|
||||
func openStreamAndReadFeatures(conn io.ReadWriter, domain, ns string, server bool) (*streamFeatures, string, error) {
|
||||
dec, from, err := openStream(conn, domain, ns, server)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
feats, err := readFeatures(dec)
|
||||
if err != nil {
|
||||
return nil, from, err
|
||||
}
|
||||
return feats, from, nil
|
||||
}
|
||||
|
||||
func openStream(conn io.ReadWriter, domain, ns string, server bool) (*xml.Decoder, string, error) {
|
||||
var header string
|
||||
if server {
|
||||
header = fmt.Sprintf(`<?xml version='1.0'?><stream:stream xmlns='%s' xmlns:stream='%s' xmlns:db='jabber:server:dialback' version='1.0' to='%s'>`, ns, streamsNS, domain)
|
||||
} else {
|
||||
header = fmt.Sprintf(`<?xml version='1.0'?><stream:stream xmlns='%s' xmlns:stream='%s' version='1.0' to='%s'>`, ns, streamsNS, domain)
|
||||
}
|
||||
if _, err := io.WriteString(conn, header); err != nil {
|
||||
return nil, "", fmt.Errorf("write header: %w", err)
|
||||
}
|
||||
dec := xml.NewDecoder(conn)
|
||||
for {
|
||||
tok, err := dec.Token()
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("read header: %w", err)
|
||||
}
|
||||
switch t := tok.(type) {
|
||||
case xml.StartElement:
|
||||
if t.Name.Space == streamsNS && t.Name.Local == "stream" {
|
||||
var from string
|
||||
for _, a := range t.Attr {
|
||||
if a.Name.Local == "from" {
|
||||
from = a.Value
|
||||
}
|
||||
}
|
||||
return dec, from, nil
|
||||
}
|
||||
if t.Name.Space == streamsNS && t.Name.Local == "error" {
|
||||
_ = dec.Skip()
|
||||
return nil, "", errors.New("server returned stream:error on open")
|
||||
}
|
||||
return nil, "", fmt.Errorf("unexpected element %s", t.Name.Local)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func readFeatures(dec *xml.Decoder) (*streamFeatures, error) {
|
||||
for {
|
||||
tok, err := dec.Token()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read features: %w", err)
|
||||
}
|
||||
se, ok := tok.(xml.StartElement)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if se.Name.Space == streamsNS && se.Name.Local == "features" {
|
||||
var feats streamFeatures
|
||||
if err := dec.DecodeElement(&feats, &se); err != nil {
|
||||
return nil, fmt.Errorf("decode features: %w", err)
|
||||
}
|
||||
return &feats, nil
|
||||
}
|
||||
if se.Name.Space == streamsNS && se.Name.Local == "error" {
|
||||
_ = dec.Skip()
|
||||
return nil, errors.New("stream:error before features")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func expectProceed(dec *xml.Decoder) error {
|
||||
for {
|
||||
tok, err := dec.Token()
|
||||
if err != nil {
|
||||
return fmt.Errorf("read proceed: %w", err)
|
||||
}
|
||||
se, ok := tok.(xml.StartElement)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if se.Name.Space == tlsNS {
|
||||
switch se.Name.Local {
|
||||
case "proceed":
|
||||
_ = dec.Skip()
|
||||
return nil
|
||||
case "failure":
|
||||
_ = dec.Skip()
|
||||
return errors.New("server refused STARTTLS (<failure/>)")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func lookupSRV(ctx context.Context, r *net.Resolver, prefix, domain string) ([]SRVRecord, error) {
|
||||
name := prefix + dns.Fqdn(domain)
|
||||
_, records, err := r.LookupSRV(ctx, "", "", name)
|
||||
if err != nil {
|
||||
// Distinguish NXDOMAIN / no records from real errors.
|
||||
var dnsErr *net.DNSError
|
||||
if errors.As(err, &dnsErr) && (dnsErr.IsNotFound) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
// RFC 2782: single record "." with port 0 means "service explicitly not
|
||||
// available at this domain". We treat that as "no records" for probing.
|
||||
if len(records) == 1 && (records[0].Target == "." || records[0].Target == "") && records[0].Port == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
out := make([]SRVRecord, 0, len(records))
|
||||
for _, r := range records {
|
||||
out = append(out, SRVRecord{
|
||||
Target: strings.TrimSuffix(r.Target, "."),
|
||||
Port: r.Port,
|
||||
Priority: r.Priority,
|
||||
Weight: r.Weight,
|
||||
})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func resolveAllInto(ctx context.Context, r *net.Resolver, records []SRVRecord) {
|
||||
for i := range records {
|
||||
ips, err := r.LookupIPAddr(ctx, records[i].Target)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
for _, ip := range ips {
|
||||
if v4 := ip.IP.To4(); v4 != nil {
|
||||
records[i].IPv4 = append(records[i].IPv4, v4.String())
|
||||
} else {
|
||||
records[i].IPv6 = append(records[i].IPv6, ip.IP.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func computeCoverage(data *XMPPData) {
|
||||
for _, ep := range data.Endpoints {
|
||||
if ep.TCPConnected {
|
||||
if ep.IsIPv6 {
|
||||
data.Coverage.HasIPv6 = true
|
||||
} else {
|
||||
data.Coverage.HasIPv4 = true
|
||||
}
|
||||
}
|
||||
if !ep.STARTTLSUpgraded {
|
||||
continue
|
||||
}
|
||||
switch ep.Mode {
|
||||
case ModeClient:
|
||||
// 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:
|
||||
// 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 <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
|
||||
}
|
||||
288
checker/collect_test.go
Normal file
288
checker/collect_test.go
Normal file
|
|
@ -0,0 +1,288 @@
|
|||
package checker
|
||||
|
||||
import (
|
||||
"encoding/xml"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
tlsct "git.happydns.org/checker-tls/contract"
|
||||
)
|
||||
|
||||
func TestReadFeatures_WithStartTLSAndSCRAM(t *testing.T) {
|
||||
doc := `<stream:features xmlns:stream="http://etherx.jabber.org/streams">
|
||||
<starttls xmlns='urn:ietf:params:xml:ns:xmpp-tls'><required/></starttls>
|
||||
<mechanisms xmlns='urn:ietf:params:xml:ns:xmpp-sasl'>
|
||||
<mechanism>SCRAM-SHA-256</mechanism>
|
||||
<mechanism>SCRAM-SHA-1</mechanism>
|
||||
<mechanism>PLAIN</mechanism>
|
||||
</mechanisms>
|
||||
</stream:features>`
|
||||
feats := decodeFeaturesForTest(t, doc)
|
||||
if feats.StartTLS == nil {
|
||||
t.Fatal("expected starttls present")
|
||||
}
|
||||
if feats.StartTLS.Required == nil {
|
||||
t.Fatal("expected starttls <required/>")
|
||||
}
|
||||
if feats.Mechanisms == nil || len(feats.Mechanisms.Mechanism) != 3 {
|
||||
t.Fatalf("expected 3 mechanisms, got %+v", feats.Mechanisms)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadFeatures_NoSTARTTLS(t *testing.T) {
|
||||
doc := `<stream:features xmlns:stream="http://etherx.jabber.org/streams">
|
||||
<mechanisms xmlns='urn:ietf:params:xml:ns:xmpp-sasl'>
|
||||
<mechanism>PLAIN</mechanism>
|
||||
</mechanisms>
|
||||
</stream:features>`
|
||||
feats := decodeFeaturesForTest(t, doc)
|
||||
if feats.StartTLS != nil {
|
||||
t.Fatal("expected no starttls")
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadFeatures_S2SDialback(t *testing.T) {
|
||||
doc := `<stream:features xmlns:stream="http://etherx.jabber.org/streams">
|
||||
<starttls xmlns='urn:ietf:params:xml:ns:xmpp-tls'><required/></starttls>
|
||||
<dialback xmlns='urn:xmpp:features:dialback'/>
|
||||
</stream:features>`
|
||||
feats := decodeFeaturesForTest(t, doc)
|
||||
if feats.Dialback == nil {
|
||||
t.Fatal("expected dialback feature")
|
||||
}
|
||||
}
|
||||
|
||||
func decodeFeaturesForTest(t *testing.T, doc string) *streamFeatures {
|
||||
t.Helper()
|
||||
dec := xml.NewDecoder(strings.NewReader(doc))
|
||||
feats, err := readFeatures(dec)
|
||||
if err != nil {
|
||||
t.Fatalf("readFeatures: %v", err)
|
||||
}
|
||||
return feats
|
||||
}
|
||||
|
||||
func TestApplyFeatures_SASLExternal(t *testing.T) {
|
||||
ep := EndpointProbe{Mode: ModeServer}
|
||||
applyFeatures(&ep, &streamFeatures{
|
||||
Mechanisms: &mechanismsEl{Mechanism: []string{"EXTERNAL"}},
|
||||
})
|
||||
if !ep.SASLExternal {
|
||||
t.Fatal("expected SASLExternal to be set")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeriveIssues_NoSRV(t *testing.T) {
|
||||
d := &XMPPData{
|
||||
Domain: "example.com",
|
||||
SRV: SRVLookup{FallbackProbed: true},
|
||||
}
|
||||
is := deriveIssues(d, true, true)
|
||||
if !containsCode(is, CodeNoSRV) {
|
||||
t.Fatalf("expected %s in %+v", CodeNoSRV, is)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeriveIssues_LegacyJabber(t *testing.T) {
|
||||
d := &XMPPData{
|
||||
SRV: SRVLookup{
|
||||
Client: []SRVRecord{{Target: "xmpp.example.com", Port: 5222}},
|
||||
Jabber: []SRVRecord{{Target: "xmpp.example.com", Port: 5222}},
|
||||
},
|
||||
}
|
||||
is := deriveIssues(d, true, false)
|
||||
if !containsCode(is, CodeLegacyJabber) {
|
||||
t.Fatalf("expected %s in %+v", CodeLegacyJabber, is)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeriveIssues_StartTLSMissing(t *testing.T) {
|
||||
d := &XMPPData{
|
||||
SRV: SRVLookup{Client: []SRVRecord{{Target: "x", Port: 5222}}},
|
||||
Endpoints: []EndpointProbe{{
|
||||
Mode: ModeClient, Address: "1.2.3.4:5222",
|
||||
TCPConnected: true, StreamOpened: true, STARTTLSOffered: false,
|
||||
}},
|
||||
}
|
||||
is := deriveIssues(d, true, false)
|
||||
if !containsCode(is, CodeStartTLSMissing) {
|
||||
t.Fatalf("expected %s in %+v", CodeStartTLSMissing, is)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeriveIssues_PlainOnlyAndNoSCRAM(t *testing.T) {
|
||||
d := &XMPPData{
|
||||
SRV: SRVLookup{Client: []SRVRecord{{Target: "x", Port: 5222}}},
|
||||
Endpoints: []EndpointProbe{{
|
||||
Mode: ModeClient, Address: "1.2.3.4:5222",
|
||||
TCPConnected: true, StreamOpened: true,
|
||||
STARTTLSOffered: true, STARTTLSRequired: true, STARTTLSUpgraded: true,
|
||||
FeaturesRead: true, SASLMechanisms: []string{"PLAIN"},
|
||||
}},
|
||||
}
|
||||
is := deriveIssues(d, true, false)
|
||||
if !containsCode(is, CodeSASLPlainOnly) {
|
||||
t.Fatalf("expected %s in %+v", CodeSASLPlainOnly, is)
|
||||
}
|
||||
if !containsCode(is, CodeSASLNoSCRAM) {
|
||||
t.Fatalf("expected %s in %+v", CodeSASLNoSCRAM, is)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeriveIssues_S2SNoAuth(t *testing.T) {
|
||||
d := &XMPPData{
|
||||
SRV: SRVLookup{Server: []SRVRecord{{Target: "x", Port: 5269}}},
|
||||
Endpoints: []EndpointProbe{{
|
||||
Mode: ModeServer, Address: "1.2.3.4:5269",
|
||||
TCPConnected: true, StreamOpened: true,
|
||||
STARTTLSOffered: true, STARTTLSRequired: true, STARTTLSUpgraded: true,
|
||||
FeaturesRead: true,
|
||||
DialbackOffered: false, SASLExternal: false,
|
||||
}},
|
||||
}
|
||||
is := deriveIssues(d, false, true)
|
||||
if !containsCode(is, CodeS2SNoAuth) {
|
||||
t.Fatalf("expected %s in %+v", CodeS2SNoAuth, is)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeriveIssues_HappyPath(t *testing.T) {
|
||||
d := &XMPPData{
|
||||
SRV: SRVLookup{
|
||||
Client: []SRVRecord{{Target: "x", Port: 5222}},
|
||||
Server: []SRVRecord{{Target: "x", Port: 5269}},
|
||||
ClientSecure: []SRVRecord{{Target: "x", Port: 5223}},
|
||||
},
|
||||
Endpoints: []EndpointProbe{
|
||||
{
|
||||
Mode: ModeClient, Address: "1.2.3.4:5222",
|
||||
TCPConnected: true, StreamOpened: true,
|
||||
STARTTLSOffered: true, STARTTLSRequired: true, STARTTLSUpgraded: true,
|
||||
FeaturesRead: true,
|
||||
SASLMechanisms: []string{"SCRAM-SHA-256", "SCRAM-SHA-256-PLUS"},
|
||||
},
|
||||
{
|
||||
Mode: ModeServer, Address: "1.2.3.4:5269",
|
||||
TCPConnected: true, StreamOpened: true,
|
||||
STARTTLSOffered: true, STARTTLSRequired: true, STARTTLSUpgraded: true,
|
||||
FeaturesRead: true,
|
||||
DialbackOffered: true,
|
||||
},
|
||||
},
|
||||
}
|
||||
computeCoverage(d)
|
||||
is := deriveIssues(d, true, true)
|
||||
for _, i := range is {
|
||||
if i.Severity == SeverityCrit {
|
||||
t.Fatalf("unexpected crit issue %s: %s", i.Code, i.Message)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestComputeCoverage_Mixed(t *testing.T) {
|
||||
d := &XMPPData{
|
||||
Endpoints: []EndpointProbe{
|
||||
{Mode: ModeClient, TCPConnected: true, IsIPv6: false, STARTTLSUpgraded: true, SASLMechanisms: []string{"PLAIN"}},
|
||||
{Mode: ModeClient, TCPConnected: true, IsIPv6: true},
|
||||
},
|
||||
}
|
||||
computeCoverage(d)
|
||||
if !d.Coverage.HasIPv4 {
|
||||
t.Fatal("expected IPv4 true")
|
||||
}
|
||||
if !d.Coverage.HasIPv6 {
|
||||
t.Fatal("expected IPv6 true")
|
||||
}
|
||||
if !d.Coverage.WorkingC2S {
|
||||
t.Fatal("expected WorkingC2S")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiscoverEntries_AllSets(t *testing.T) {
|
||||
d := &XMPPData{
|
||||
Domain: "example.com",
|
||||
SRV: SRVLookup{
|
||||
Client: []SRVRecord{{Target: "xmpp.example.com", Port: 5222}},
|
||||
Server: []SRVRecord{{Target: "xmpp.example.com", Port: 5269}},
|
||||
ClientSecure: []SRVRecord{{Target: "xmpp.example.com", Port: 5223}},
|
||||
ServerSecure: []SRVRecord{{Target: "xmpp.example.com", Port: 5270}},
|
||||
Jabber: []SRVRecord{{Target: "xmpp.example.com", Port: 5222}}, // legacy, not emitted
|
||||
},
|
||||
Endpoints: []EndpointProbe{
|
||||
{Mode: ModeClient, Target: "xmpp.example.com", Port: 5222, STARTTLSRequired: true},
|
||||
{Mode: ModeServer, Target: "xmpp.example.com", Port: 5269, STARTTLSRequired: false},
|
||||
},
|
||||
}
|
||||
p := &xmppProvider{}
|
||||
raw, err := p.DiscoverEntries(d)
|
||||
if err != nil {
|
||||
t.Fatalf("DiscoverEntries: %v", err)
|
||||
}
|
||||
if len(raw) != 4 {
|
||||
t.Fatalf("expected 4 entries (legacy jabber excluded), got %d: %+v", len(raw), raw)
|
||||
}
|
||||
|
||||
type signature struct {
|
||||
starttls string
|
||||
host string
|
||||
port uint16
|
||||
}
|
||||
by := map[signature]tlsct.TLSEndpoint{}
|
||||
for i, e := range raw {
|
||||
if e.Type != tlsct.Type {
|
||||
t.Errorf("entry %d: Type=%q, want %q", i, e.Type, tlsct.Type)
|
||||
}
|
||||
ep, err := tlsct.ParseEntry(e)
|
||||
if err != nil {
|
||||
t.Fatalf("entry %d: ParseEntry: %v", i, err)
|
||||
}
|
||||
if ep.SNI != "example.com" {
|
||||
t.Errorf("entry %d: SNI=%q, want example.com", i, ep.SNI)
|
||||
}
|
||||
by[signature{ep.STARTTLS, ep.Host, ep.Port}] = ep
|
||||
}
|
||||
|
||||
c2s, ok := by[signature{"xmpp-client", "xmpp.example.com", 5222}]
|
||||
if !ok {
|
||||
t.Fatal("missing c2s entry")
|
||||
}
|
||||
if !c2s.RequireSTARTTLS {
|
||||
t.Errorf("c2s RequireSTARTTLS = false, want true")
|
||||
}
|
||||
|
||||
s2s, ok := by[signature{"xmpp-server", "xmpp.example.com", 5269}]
|
||||
if !ok {
|
||||
t.Fatal("missing s2s entry")
|
||||
}
|
||||
if s2s.RequireSTARTTLS {
|
||||
t.Errorf("s2s RequireSTARTTLS = true, want false (opportunistic)")
|
||||
}
|
||||
|
||||
directClient, ok := by[signature{"", "xmpp.example.com", 5223}]
|
||||
if !ok {
|
||||
t.Fatal("missing direct-TLS client entry")
|
||||
}
|
||||
if directClient.STARTTLS != "" || directClient.RequireSTARTTLS {
|
||||
t.Errorf("direct-TLS entry should carry no STARTTLS posture, got %+v", directClient)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiscoverEntries_WrongType(t *testing.T) {
|
||||
p := &xmppProvider{}
|
||||
eps, err := p.DiscoverEntries("not an XMPPData")
|
||||
if err != nil {
|
||||
t.Fatalf("expected nil error for wrong type, got %v", err)
|
||||
}
|
||||
if eps != nil {
|
||||
t.Fatalf("expected nil entries for wrong type, got %v", eps)
|
||||
}
|
||||
}
|
||||
|
||||
func containsCode(is []Issue, code string) bool {
|
||||
for _, i := range is {
|
||||
if i.Code == code {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
55
checker/definition.go
Normal file
55
checker/definition.go
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
package checker
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
sdk "git.happydns.org/checker-sdk-go/checker"
|
||||
)
|
||||
|
||||
// Version is reported in CheckerDefinition.Version. Overridden at build time
|
||||
// by main / plugin.
|
||||
var Version = "built-in"
|
||||
|
||||
func Definition() *sdk.CheckerDefinition {
|
||||
return &sdk.CheckerDefinition{
|
||||
ID: "xmpp",
|
||||
Name: "XMPP Server",
|
||||
Version: Version,
|
||||
Availability: sdk.CheckerAvailability{
|
||||
ApplyToService: true,
|
||||
LimitToServices: []string{"abstract.XMPP"},
|
||||
},
|
||||
HasHTMLReport: true,
|
||||
ObservationKeys: []sdk.ObservationKey{ObservationKeyXMPP},
|
||||
Options: sdk.CheckerOptionsDocumentation{
|
||||
RunOpts: []sdk.CheckerOptionDocumentation{
|
||||
{
|
||||
Id: "domain",
|
||||
Type: "string",
|
||||
Label: "Domain",
|
||||
AutoFill: sdk.AutoFillDomainName,
|
||||
Required: true,
|
||||
},
|
||||
{
|
||||
Id: "mode",
|
||||
Type: "string",
|
||||
Label: "Mode",
|
||||
Default: string(ModeBoth),
|
||||
Choices: validModes,
|
||||
},
|
||||
{
|
||||
Id: "timeout",
|
||||
Type: "number",
|
||||
Label: "Per-endpoint timeout (seconds)",
|
||||
Default: 10,
|
||||
},
|
||||
},
|
||||
},
|
||||
Rules: []sdk.CheckRule{Rule()},
|
||||
Interval: &sdk.CheckIntervalSpec{
|
||||
Min: 5 * time.Minute,
|
||||
Max: 7 * 24 * time.Hour,
|
||||
Default: 6 * time.Hour,
|
||||
},
|
||||
}
|
||||
}
|
||||
89
checker/provider.go
Normal file
89
checker/provider.go
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
package checker
|
||||
|
||||
import (
|
||||
"net"
|
||||
"strconv"
|
||||
|
||||
sdk "git.happydns.org/checker-sdk-go/checker"
|
||||
tlsct "git.happydns.org/checker-tls/contract"
|
||||
)
|
||||
|
||||
func Provider() sdk.ObservationProvider {
|
||||
return &xmppProvider{}
|
||||
}
|
||||
|
||||
type xmppProvider struct{}
|
||||
|
||||
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,
|
||||
// so a downstream TLS checker can verify the certificate chain / SAN /
|
||||
// expiry on each one without re-doing the SRV lookup. The XMPP checker
|
||||
// itself does not perform certificate verification; that posture lives in
|
||||
// the TLS checker.
|
||||
//
|
||||
// SNI is set to the bare JID domain rather than the SRV target, because XMPP
|
||||
// certificates must be valid for the source domain (RFC 6120 §13.7.2.1),
|
||||
// which is typically different from the SRV target hostname.
|
||||
func (p *xmppProvider) DiscoverEntries(data any) ([]sdk.DiscoveryEntry, error) {
|
||||
d, ok := data.(*XMPPData)
|
||||
if !ok || d == nil {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// Carry over the STARTTLS-required posture observed during probing.
|
||||
starttlsRequired := map[string]bool{}
|
||||
for _, ep := range d.Endpoints {
|
||||
if ep.STARTTLSRequired {
|
||||
starttlsRequired[endpointKey(ep.Target, ep.Port)] = true
|
||||
}
|
||||
}
|
||||
|
||||
var out []sdk.DiscoveryEntry
|
||||
emit := func(proto string, recs []SRVRecord, directTLS bool) error {
|
||||
for _, r := range recs {
|
||||
ep := tlsct.TLSEndpoint{
|
||||
Host: r.Target,
|
||||
Port: r.Port,
|
||||
SNI: d.Domain,
|
||||
}
|
||||
if !directTLS {
|
||||
ep.STARTTLS = proto
|
||||
ep.RequireSTARTTLS = starttlsRequired[endpointKey(r.Target, r.Port)]
|
||||
}
|
||||
entry, err := tlsct.NewEntry(ep)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
out = append(out, entry)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if err := emit("xmpp-client", d.SRV.Client, false); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := emit("xmpp-server", d.SRV.Server, false); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := emit("", d.SRV.ClientSecure, true); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := emit("", d.SRV.ServerSecure, true); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func endpointKey(host string, port uint16) string {
|
||||
return net.JoinHostPort(host, strconv.FormatUint(uint64(port), 10))
|
||||
}
|
||||
511
checker/report.go
Normal file
511
checker/report.go
Normal file
|
|
@ -0,0 +1,511 @@
|
|||
package checker
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
sdk "git.happydns.org/checker-sdk-go/checker"
|
||||
)
|
||||
|
||||
type reportFix struct {
|
||||
Severity string
|
||||
Code string
|
||||
Message string
|
||||
Fix string
|
||||
Endpoint string
|
||||
}
|
||||
|
||||
type reportEndpoint struct {
|
||||
Mode string
|
||||
ModeLabel string
|
||||
SRVPrefix string
|
||||
Target string
|
||||
Port uint16
|
||||
Address string
|
||||
IsIPv6 bool
|
||||
DirectTLS bool
|
||||
TCPConnected bool
|
||||
StreamOpened bool
|
||||
STARTTLSOffered bool
|
||||
STARTTLSRequired bool
|
||||
STARTTLSUpgraded bool
|
||||
TLSVersion string
|
||||
TLSCipher string
|
||||
SASLMechanisms []string
|
||||
DialbackOffered bool
|
||||
SASLExternal bool
|
||||
StreamFrom string
|
||||
ElapsedMS int64
|
||||
Error string
|
||||
|
||||
// TLS posture (from a related tls_probes observation, when available).
|
||||
TLSPosture *reportTLSPosture
|
||||
|
||||
// Rendering helpers.
|
||||
AnyFail bool
|
||||
StatusLabel string
|
||||
StatusClass string
|
||||
}
|
||||
|
||||
type reportTLSPosture struct {
|
||||
CheckedAt time.Time
|
||||
ChainValid *bool
|
||||
HostnameMatch *bool
|
||||
NotAfter time.Time
|
||||
Issues []reportFix
|
||||
}
|
||||
|
||||
type reportSRVEntry struct {
|
||||
Prefix string
|
||||
Target string
|
||||
Port uint16
|
||||
Priority uint16
|
||||
Weight uint16
|
||||
IPv4 []string
|
||||
IPv6 []string
|
||||
}
|
||||
|
||||
type reportData struct {
|
||||
Domain string
|
||||
RunAt string
|
||||
StatusLabel string
|
||||
StatusClass string
|
||||
HasIssues bool
|
||||
Fixes []reportFix
|
||||
SRV []reportSRVEntry
|
||||
FallbackProbed bool
|
||||
JabberLegacy bool
|
||||
Endpoints []reportEndpoint
|
||||
HasIPv4 bool
|
||||
HasIPv6 bool
|
||||
WorkingC2S bool
|
||||
WorkingS2S bool
|
||||
HasTLSPosture bool
|
||||
}
|
||||
|
||||
var reportTpl = template.Must(template.New("xmpp").Funcs(template.FuncMap{
|
||||
"hasPrefix": strings.HasPrefix,
|
||||
"deref": func(b *bool) bool { return b != nil && *b },
|
||||
}).Parse(`<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>XMPP Report: {{.Domain}}</title>
|
||||
<style>
|
||||
*, *::before, *::after { box-sizing: border-box; }
|
||||
:root {
|
||||
font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
color: #1f2937;
|
||||
background: #f3f4f6;
|
||||
}
|
||||
body { margin: 0; padding: 1rem; }
|
||||
code { font-family: ui-monospace, monospace; font-size: .9em; }
|
||||
h1 { margin: 0 0 .4rem; font-size: 1.15rem; font-weight: 700; }
|
||||
h2 { font-size: 1rem; font-weight: 700; margin: 0 0 .6rem; }
|
||||
h3 { font-size: .9rem; font-weight: 600; margin: 0 0 .4rem; }
|
||||
|
||||
.hd, .section, details {
|
||||
background: #fff;
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,.08);
|
||||
}
|
||||
.hd { border-radius: 10px; padding: 1rem 1.25rem; margin-bottom: .75rem; }
|
||||
.section { border-radius: 8px; padding: .85rem 1rem; margin-bottom: .6rem; }
|
||||
details { border-radius: 8px; margin-bottom: .45rem; overflow: hidden; }
|
||||
|
||||
.badge {
|
||||
display: inline-flex; align-items: center;
|
||||
padding: .2em .65em; border-radius: 9999px;
|
||||
font-size: .78rem; font-weight: 700; letter-spacing: .02em;
|
||||
}
|
||||
.ok { background: #d1fae5; color: #065f46; }
|
||||
.warn { background: #fef3c7; color: #92400e; }
|
||||
.fail { background: #fee2e2; color: #991b1b; }
|
||||
.muted { background: #e5e7eb; color: #374151; }
|
||||
|
||||
.meta { color: #6b7280; font-size: .82rem; margin-top: .35rem; }
|
||||
|
||||
summary {
|
||||
display: flex; align-items: center; gap: .5rem;
|
||||
padding: .65rem 1rem; cursor: pointer; user-select: none; list-style: none;
|
||||
}
|
||||
summary::-webkit-details-marker { display: none; }
|
||||
summary::before { content: "▶"; font-size: .65rem; color: #9ca3af; transition: transform .15s; flex-shrink: 0; }
|
||||
details[open] > summary::before { transform: rotate(90deg); }
|
||||
.conn-addr { font-weight: 600; flex: 1; font-size: .9rem; font-family: ui-monospace, monospace; }
|
||||
.details-body { padding: .6rem 1rem .85rem; border-top: 1px solid #f3f4f6; }
|
||||
|
||||
table { border-collapse: collapse; width: 100%; font-size: .85rem; }
|
||||
th, td { text-align: left; padding: .3rem .5rem; border-bottom: 1px solid #f3f4f6; vertical-align: top; }
|
||||
th { font-weight: 600; color: #6b7280; }
|
||||
|
||||
.fix {
|
||||
border-left: 3px solid #dc2626;
|
||||
padding: .5rem .75rem; margin-bottom: .5rem;
|
||||
background: #fef2f2; border-radius: 0 6px 6px 0;
|
||||
}
|
||||
.fix.warn { border-color: #f59e0b; background: #fffbeb; }
|
||||
.fix.info { border-color: #3b82f6; background: #eff6ff; }
|
||||
.fix .code { font-family: ui-monospace, monospace; font-size: .75rem; color: #6b7280; }
|
||||
.fix .msg { font-weight: 600; margin: .1rem 0 .2rem; }
|
||||
.fix .how { font-size: .88rem; }
|
||||
.fix .ep { font-size: .78rem; color: #6b7280; font-family: ui-monospace, monospace; }
|
||||
|
||||
.chiprow { display: flex; flex-wrap: wrap; gap: .25rem; }
|
||||
.chip {
|
||||
display: inline-block; padding: .12em .5em;
|
||||
background: #e0e7ff; color: #3730a3;
|
||||
border-radius: 4px; font-size: .78rem; font-family: ui-monospace, monospace;
|
||||
}
|
||||
.chip.plain { background: #fee2e2; color: #991b1b; }
|
||||
.chip.scram { background: #d1fae5; color: #065f46; }
|
||||
|
||||
.kv { display: grid; grid-template-columns: auto 1fr; gap: .3rem 1rem; font-size: .86rem; }
|
||||
.kv dt { color: #6b7280; }
|
||||
.kv dd { margin: 0; }
|
||||
|
||||
.note { color: #6b7280; font-size: .85rem; }
|
||||
.footer { color: #6b7280; font-size: .78rem; text-align: center; margin-top: 1rem; padding-bottom: 2rem; }
|
||||
.check-ok { color: #059669; }
|
||||
.check-fail { color: #dc2626; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div class="hd">
|
||||
<h1>XMPP: <code>{{.Domain}}</code></h1>
|
||||
<span class="badge {{.StatusClass}}">{{.StatusLabel}}</span>
|
||||
<div class="meta">
|
||||
{{if .WorkingC2S}}<span class="badge ok" style="margin-right:.25rem">c2s OK</span>{{else}}<span class="badge fail" style="margin-right:.25rem">c2s FAIL</span>{{end}}
|
||||
{{if .WorkingS2S}}<span class="badge ok" style="margin-right:.25rem">s2s OK</span>{{else}}<span class="badge fail" style="margin-right:.25rem">s2s FAIL</span>{{end}}
|
||||
{{if .HasIPv4}}<span class="badge muted">IPv4</span>{{end}}
|
||||
{{if .HasIPv6}}<span class="badge muted">IPv6</span>{{end}}
|
||||
</div>
|
||||
<div class="meta">Checked {{.RunAt}}</div>
|
||||
</div>
|
||||
|
||||
{{if .HasIssues}}
|
||||
<div class="section">
|
||||
<h2>What to fix</h2>
|
||||
{{range .Fixes}}
|
||||
<div class="fix {{.Severity}}">
|
||||
<div class="code">{{.Code}}{{if .Endpoint}} · {{.Endpoint}}{{end}}</div>
|
||||
<div class="msg">{{.Message}}</div>
|
||||
{{if .Fix}}<div class="how">→ {{.Fix}}</div>{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
<div class="section">
|
||||
<h2>DNS / SRV</h2>
|
||||
{{if .FallbackProbed}}
|
||||
<p class="note">No SRV records published; fell back to probing the bare domain on default ports.</p>
|
||||
{{else if .SRV}}
|
||||
<table>
|
||||
<tr><th>Record</th><th>Target</th><th>Port</th><th>Prio/Weight</th><th>IPv4</th><th>IPv6</th></tr>
|
||||
{{range .SRV}}
|
||||
<tr>
|
||||
<td><code>{{.Prefix}}</code></td>
|
||||
<td><code>{{.Target}}</code></td>
|
||||
<td>{{.Port}}</td>
|
||||
<td>{{.Priority}}/{{.Weight}}</td>
|
||||
<td>{{range .IPv4}}<code>{{.}}</code> {{end}}</td>
|
||||
<td>{{range .IPv6}}<code>{{.}}</code> {{end}}</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</table>
|
||||
{{else}}
|
||||
<p class="note">No SRV records found.</p>
|
||||
{{end}}
|
||||
{{if .JabberLegacy}}<p class="note">⚠ Obsolete <code>_jabber._tcp</code> records are still published.</p>{{end}}
|
||||
</div>
|
||||
|
||||
{{if .Endpoints}}
|
||||
<div class="section">
|
||||
<h2>Endpoints ({{len .Endpoints}})</h2>
|
||||
{{range .Endpoints}}
|
||||
<details{{if .AnyFail}} open{{end}}>
|
||||
<summary>
|
||||
<span class="conn-addr">{{.ModeLabel}} · {{.Address}}{{if .DirectTLS}} · direct-TLS{{end}}</span>
|
||||
<span class="badge {{.StatusClass}}">{{.StatusLabel}}</span>
|
||||
</summary>
|
||||
<div class="details-body">
|
||||
<dl class="kv">
|
||||
<dt>SRV</dt><dd><code>{{.SRVPrefix}}</code> → <code>{{.Target}}:{{.Port}}</code></dd>
|
||||
<dt>Family</dt><dd>{{if .IsIPv6}}IPv6{{else}}IPv4{{end}}</dd>
|
||||
<dt>TCP</dt><dd>{{if .TCPConnected}}<span class="check-ok">✓ connected</span>{{else}}<span class="check-fail">✗ failed</span>{{end}}</dd>
|
||||
<dt>Stream</dt><dd>{{if .StreamOpened}}<span class="check-ok">✓ opened</span>{{if .StreamFrom}} (from=<code>{{.StreamFrom}}</code>){{end}}{{else}}<span class="check-fail">✗ not opened</span>{{end}}</dd>
|
||||
{{if not .DirectTLS}}
|
||||
<dt>STARTTLS</dt><dd>
|
||||
{{if .STARTTLSOffered}}<span class="check-ok">✓ offered</span>{{else}}<span class="check-fail">✗ not offered</span>{{end}}
|
||||
{{if .STARTTLSRequired}} · <span class="check-ok">required</span>{{else if .STARTTLSOffered}} · <span class="check-fail">not required</span>{{end}}
|
||||
</dd>
|
||||
{{end}}
|
||||
<dt>TLS</dt><dd>{{if .STARTTLSUpgraded}}<span class="check-ok">✓ {{.TLSVersion}}{{if .TLSCipher}} — {{.TLSCipher}}{{end}}</span>{{else}}<span class="check-fail">✗ no TLS</span>{{end}}</dd>
|
||||
{{if eq .Mode "c2s"}}
|
||||
<dt>SASL</dt><dd>
|
||||
{{if .SASLMechanisms}}
|
||||
<div class="chiprow">
|
||||
{{range .SASLMechanisms}}<span class="chip {{if eq . "PLAIN"}}plain{{else if hasPrefix . "SCRAM-"}}scram{{end}}">{{.}}</span>{{end}}
|
||||
</div>
|
||||
{{else}}<span class="note">none advertised</span>{{end}}
|
||||
</dd>
|
||||
{{end}}
|
||||
{{if eq .Mode "s2s"}}
|
||||
<dt>Federation</dt><dd>
|
||||
{{if .DialbackOffered}}<span class="check-ok">✓ dialback</span>{{else}}<span class="check-fail">✗ no dialback</span>{{end}}
|
||||
·
|
||||
{{if .SASLExternal}}<span class="check-ok">✓ SASL EXTERNAL</span>{{else}}<span class="check-fail">✗ no SASL EXTERNAL</span>{{end}}
|
||||
</dd>
|
||||
{{end}}
|
||||
{{with .TLSPosture}}
|
||||
<dt>TLS cert</dt><dd>
|
||||
{{if .ChainValid}}
|
||||
{{if deref .ChainValid}}<span class="check-ok">✓ chain valid</span>{{else}}<span class="check-fail">✗ chain invalid</span>{{end}}
|
||||
{{end}}
|
||||
{{if .HostnameMatch}}
|
||||
· {{if deref .HostnameMatch}}<span class="check-ok">✓ hostname match</span>{{else}}<span class="check-fail">✗ hostname mismatch</span>{{end}}
|
||||
{{end}}
|
||||
{{if not .NotAfter.IsZero}}
|
||||
· expires <code>{{.NotAfter.Format "2006-01-02"}}</code>
|
||||
{{end}}
|
||||
{{if not .CheckedAt.IsZero}}
|
||||
<div class="note">TLS checked {{.CheckedAt.Format "2006-01-02 15:04 MST"}}</div>
|
||||
{{end}}
|
||||
{{range .Issues}}
|
||||
<div class="fix {{.Severity}}" style="margin-top:.3rem">
|
||||
<div class="code">{{.Code}}</div>
|
||||
<div class="msg">{{.Message}}</div>
|
||||
{{if .Fix}}<div class="how">→ {{.Fix}}</div>{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
</dd>
|
||||
{{end}}
|
||||
<dt>Duration</dt><dd>{{.ElapsedMS}} ms</dd>
|
||||
{{if .Error}}<dt>Error</dt><dd><span class="check-fail">{{.Error}}</span></dd>{{end}}
|
||||
</dl>
|
||||
</div>
|
||||
</details>
|
||||
{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
<p class="footer">{{if .HasTLSPosture}}Certificate posture above comes from the TLS checker, which probed the same endpoints after we discovered them.{{else}}For certificate chain, SAN match, expiry, and cipher posture, run the TLS checker on the same ports.{{end}}</p>
|
||||
|
||||
</body>
|
||||
</html>`))
|
||||
|
||||
// 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.
|
||||
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))
|
||||
return renderReport(view)
|
||||
}
|
||||
|
||||
func renderReport(view reportData) (string, error) {
|
||||
var buf strings.Builder
|
||||
if err := reportTpl.Execute(&buf, view); err != nil {
|
||||
return "", fmt.Errorf("render xmpp report: %w", err)
|
||||
}
|
||||
return buf.String(), nil
|
||||
}
|
||||
|
||||
func buildReportData(d *XMPPData, related []sdk.RelatedObservation) reportData {
|
||||
tlsIssues := tlsIssuesFromRelated(related)
|
||||
tlsByAddr := indexTLSByAddress(related)
|
||||
|
||||
allIssues := append([]Issue(nil), d.Issues...)
|
||||
allIssues = append(allIssues, tlsIssues...)
|
||||
|
||||
view := reportData{
|
||||
Domain: d.Domain,
|
||||
RunAt: d.RunAt,
|
||||
FallbackProbed: d.SRV.FallbackProbed,
|
||||
JabberLegacy: len(d.SRV.Jabber) > 0,
|
||||
HasIPv4: d.Coverage.HasIPv4,
|
||||
HasIPv6: d.Coverage.HasIPv6,
|
||||
WorkingC2S: d.Coverage.WorkingC2S,
|
||||
WorkingS2S: d.Coverage.WorkingS2S,
|
||||
HasIssues: len(allIssues) > 0,
|
||||
HasTLSPosture: len(tlsByAddr) > 0,
|
||||
}
|
||||
|
||||
// Status banner.
|
||||
worst := SeverityInfo
|
||||
for _, is := range allIssues {
|
||||
if is.Severity == SeverityCrit {
|
||||
worst = SeverityCrit
|
||||
break
|
||||
}
|
||||
if is.Severity == SeverityWarn {
|
||||
worst = SeverityWarn
|
||||
}
|
||||
}
|
||||
if len(allIssues) == 0 {
|
||||
view.StatusLabel = "OK"
|
||||
view.StatusClass = "ok"
|
||||
} else {
|
||||
switch worst {
|
||||
case SeverityCrit:
|
||||
view.StatusLabel = "FAIL"
|
||||
view.StatusClass = "fail"
|
||||
case SeverityWarn:
|
||||
view.StatusLabel = "WARN"
|
||||
view.StatusClass = "warn"
|
||||
default:
|
||||
view.StatusLabel = "INFO"
|
||||
view.StatusClass = "muted"
|
||||
}
|
||||
}
|
||||
|
||||
// Fix list: sort crit → warn → info, preserving order within each severity.
|
||||
sevRank := func(s string) int {
|
||||
switch s {
|
||||
case SeverityCrit:
|
||||
return 0
|
||||
case SeverityWarn:
|
||||
return 1
|
||||
default:
|
||||
return 2
|
||||
}
|
||||
}
|
||||
sort.SliceStable(allIssues, func(i, j int) bool { return sevRank(allIssues[i].Severity) < sevRank(allIssues[j].Severity) })
|
||||
for _, is := range allIssues {
|
||||
view.Fixes = append(view.Fixes, reportFix{
|
||||
Severity: is.Severity,
|
||||
Code: is.Code,
|
||||
Message: is.Message,
|
||||
Fix: is.Fix,
|
||||
Endpoint: is.Endpoint,
|
||||
})
|
||||
}
|
||||
|
||||
// SRV rows.
|
||||
addSRV := func(prefix string, records []SRVRecord) {
|
||||
for _, r := range records {
|
||||
view.SRV = append(view.SRV, reportSRVEntry{
|
||||
Prefix: prefix, Target: r.Target, Port: r.Port,
|
||||
Priority: r.Priority, Weight: r.Weight,
|
||||
IPv4: r.IPv4, IPv6: r.IPv6,
|
||||
})
|
||||
}
|
||||
}
|
||||
addSRV("_xmpp-client._tcp", d.SRV.Client)
|
||||
addSRV("_xmpp-server._tcp", d.SRV.Server)
|
||||
addSRV("_xmpps-client._tcp", d.SRV.ClientSecure)
|
||||
addSRV("_xmpps-server._tcp", d.SRV.ServerSecure)
|
||||
addSRV("_jabber._tcp", d.SRV.Jabber)
|
||||
|
||||
// Endpoint rows.
|
||||
for _, ep := range d.Endpoints {
|
||||
re := reportEndpoint{
|
||||
Mode: string(ep.Mode),
|
||||
ModeLabel: modeLabel(ep.Mode),
|
||||
SRVPrefix: ep.SRVPrefix,
|
||||
Target: ep.Target,
|
||||
Port: ep.Port,
|
||||
Address: ep.Address,
|
||||
IsIPv6: ep.IsIPv6,
|
||||
DirectTLS: ep.DirectTLS,
|
||||
TCPConnected: ep.TCPConnected,
|
||||
StreamOpened: ep.StreamOpened,
|
||||
STARTTLSOffered: ep.STARTTLSOffered,
|
||||
STARTTLSRequired: ep.STARTTLSRequired,
|
||||
STARTTLSUpgraded: ep.STARTTLSUpgraded,
|
||||
TLSVersion: ep.TLSVersion,
|
||||
TLSCipher: ep.TLSCipher,
|
||||
SASLMechanisms: ep.SASLMechanisms,
|
||||
DialbackOffered: ep.DialbackOffered,
|
||||
SASLExternal: ep.SASLExternal,
|
||||
StreamFrom: ep.StreamFrom,
|
||||
ElapsedMS: ep.ElapsedMS,
|
||||
Error: ep.Error,
|
||||
}
|
||||
if meta, hit := tlsByAddr[ep.Address]; hit {
|
||||
re.TLSPosture = meta
|
||||
} else if meta, hit := tlsByAddr[endpointKey(ep.Target, ep.Port)]; hit {
|
||||
re.TLSPosture = meta
|
||||
}
|
||||
ok := ep.TCPConnected && ep.STARTTLSUpgraded
|
||||
if ep.Mode == ModeServer {
|
||||
ok = ok && (ep.DialbackOffered || ep.SASLExternal)
|
||||
}
|
||||
if ep.Mode == ModeClient {
|
||||
ok = ok && len(ep.SASLMechanisms) > 0
|
||||
}
|
||||
re.AnyFail = !ok
|
||||
if ok {
|
||||
re.StatusLabel = "OK"
|
||||
re.StatusClass = "ok"
|
||||
} else if ep.TCPConnected {
|
||||
re.StatusLabel = "partial"
|
||||
re.StatusClass = "warn"
|
||||
} else {
|
||||
re.StatusLabel = "unreachable"
|
||||
re.StatusClass = "fail"
|
||||
}
|
||||
view.Endpoints = append(view.Endpoints, re)
|
||||
}
|
||||
|
||||
return view
|
||||
}
|
||||
|
||||
func modeLabel(m XMPPMode) string {
|
||||
switch m {
|
||||
case ModeClient:
|
||||
return "client"
|
||||
case ModeServer:
|
||||
return "server"
|
||||
default:
|
||||
return string(m)
|
||||
}
|
||||
}
|
||||
|
||||
// indexTLSByAddress returns a map keyed by "host:port" (and by the SRV
|
||||
// target:port when host is the target) pointing at a reportTLSPosture.
|
||||
// This lets the template match a related observation to the right endpoint.
|
||||
func indexTLSByAddress(related []sdk.RelatedObservation) map[string]*reportTLSPosture {
|
||||
out := map[string]*reportTLSPosture{}
|
||||
for _, r := range related {
|
||||
v := parseTLSRelated(r)
|
||||
if v == nil {
|
||||
continue
|
||||
}
|
||||
addr := v.address()
|
||||
if addr == "" {
|
||||
continue
|
||||
}
|
||||
posture := &reportTLSPosture{
|
||||
CheckedAt: r.CollectedAt,
|
||||
ChainValid: v.ChainValid,
|
||||
HostnameMatch: v.HostnameMatch,
|
||||
NotAfter: v.NotAfter,
|
||||
}
|
||||
for _, is := range v.Issues {
|
||||
sev := strings.ToLower(is.Severity)
|
||||
if sev != SeverityCrit && sev != SeverityWarn && sev != SeverityInfo {
|
||||
continue
|
||||
}
|
||||
posture.Issues = append(posture.Issues, reportFix{
|
||||
Severity: sev,
|
||||
Code: is.Code,
|
||||
Message: is.Message,
|
||||
Fix: is.Fix,
|
||||
})
|
||||
}
|
||||
out[addr] = posture
|
||||
}
|
||||
return out
|
||||
}
|
||||
147
checker/rule.go
Normal file
147
checker/rule.go
Normal file
|
|
@ -0,0 +1,147 @@
|
|||
package checker
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
sdk "git.happydns.org/checker-sdk-go/checker"
|
||||
)
|
||||
|
||||
func Rule() sdk.CheckRule {
|
||||
return &xmppRule{}
|
||||
}
|
||||
|
||||
type xmppRule struct{}
|
||||
|
||||
func (r *xmppRule) Name() string {
|
||||
return "xmpp_server"
|
||||
}
|
||||
|
||||
func (r *xmppRule) Description() string {
|
||||
return "Checks discovery, STARTTLS, SASL and federation auth of an XMPP server"
|
||||
}
|
||||
|
||||
func (r *xmppRule) ValidateOptions(opts sdk.CheckerOptions) error {
|
||||
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"`)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *xmppRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, opts sdk.CheckerOptions) sdk.CheckState {
|
||||
var data XMPPData
|
||||
if err := obs.Get(ctx, ObservationKeyXMPP, &data); err != nil {
|
||||
return sdk.CheckState{
|
||||
Status: sdk.StatusError,
|
||||
Message: fmt.Sprintf("failed to load XMPP observation: %v", err),
|
||||
Code: "xmpp.observation_error",
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
critMsgs, warnMsgs := []string{}, []string{}
|
||||
var firstCritCode, firstWarnCode string
|
||||
|
||||
for _, is := range issues {
|
||||
switch is.Severity {
|
||||
case SeverityCrit:
|
||||
if worst < sdk.StatusCrit {
|
||||
worst = sdk.StatusCrit
|
||||
}
|
||||
if firstCritCode == "" {
|
||||
firstCritCode = is.Code
|
||||
}
|
||||
critMsgs = append(critMsgs, is.Message)
|
||||
case SeverityWarn:
|
||||
if worst < sdk.StatusWarn {
|
||||
worst = sdk.StatusWarn
|
||||
}
|
||||
if firstWarnCode == "" {
|
||||
firstWarnCode = is.Code
|
||||
}
|
||||
warnMsgs = append(warnMsgs, is.Message)
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
var missing []string
|
||||
if wantC2S && !data.Coverage.WorkingC2S {
|
||||
missing = append(missing, "c2s")
|
||||
}
|
||||
if wantS2S && !data.Coverage.WorkingS2S {
|
||||
missing = append(missing, "s2s")
|
||||
}
|
||||
critMsgs = append(critMsgs, "no working "+strings.Join(missing, "/")+" endpoint")
|
||||
if firstCritCode == "" {
|
||||
firstCritCode = CodeAllEndpointsDown
|
||||
}
|
||||
}
|
||||
|
||||
meta := map[string]any{
|
||||
"working_c2s": data.Coverage.WorkingC2S,
|
||||
"working_s2s": data.Coverage.WorkingS2S,
|
||||
"has_ipv4": data.Coverage.HasIPv4,
|
||||
"has_ipv6": data.Coverage.HasIPv6,
|
||||
"endpoints": len(data.Endpoints),
|
||||
"issue_count": len(data.Issues),
|
||||
}
|
||||
|
||||
switch worst {
|
||||
case sdk.StatusOK:
|
||||
return sdk.CheckState{
|
||||
Status: sdk.StatusOK,
|
||||
Message: fmt.Sprintf("XMPP operational (c2s=%v, s2s=%v, %d endpoints)", data.Coverage.WorkingC2S, data.Coverage.WorkingS2S, len(data.Endpoints)),
|
||||
Code: "xmpp.ok",
|
||||
Meta: meta,
|
||||
}
|
||||
case sdk.StatusWarn:
|
||||
return sdk.CheckState{
|
||||
Status: sdk.StatusWarn,
|
||||
Message: "XMPP works with warnings: " + joinTop(warnMsgs, 2),
|
||||
Code: firstWarnCode,
|
||||
Meta: meta,
|
||||
}
|
||||
default:
|
||||
return sdk.CheckState{
|
||||
Status: sdk.StatusCrit,
|
||||
Message: "XMPP broken: " + joinTop(critMsgs, 2),
|
||||
Code: firstCritCode,
|
||||
Meta: meta,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func joinTop(msgs []string, n int) string {
|
||||
if len(msgs) == 0 {
|
||||
return ""
|
||||
}
|
||||
if len(msgs) <= n {
|
||||
return strings.Join(msgs, "; ")
|
||||
}
|
||||
return strings.Join(msgs[:n], "; ") + fmt.Sprintf(" (+%d more)", len(msgs)-n)
|
||||
}
|
||||
172
checker/tls_related.go
Normal file
172
checker/tls_related.go
Normal file
|
|
@ -0,0 +1,172 @@
|
|||
package checker
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
sdk "git.happydns.org/checker-sdk-go/checker"
|
||||
)
|
||||
|
||||
// TLSRelatedKey is the observation key we expect a TLS checker to publish
|
||||
// for the endpoints we discover. Matches the cross-checker convention
|
||||
// documented in the happyDomain plan.
|
||||
const TLSRelatedKey sdk.ObservationKey = "tls_probes"
|
||||
|
||||
// tlsProbeView is our local, permissive view of a TLS checker's payload.
|
||||
// We read only the fields we need and tolerate missing ones; the TLS
|
||||
// checker's full schema is owned by that checker.
|
||||
type tlsProbeView struct {
|
||||
Host string `json:"host,omitempty"`
|
||||
Port uint16 `json:"port,omitempty"`
|
||||
Endpoint string `json:"endpoint,omitempty"`
|
||||
TLSVersion string `json:"tls_version,omitempty"`
|
||||
CipherSuite string `json:"cipher_suite,omitempty"`
|
||||
HostnameMatch *bool `json:"hostname_match,omitempty"`
|
||||
ChainValid *bool `json:"chain_valid,omitempty"`
|
||||
NotAfter time.Time `json:"not_after,omitempty"`
|
||||
Issues []struct {
|
||||
Code string `json:"code"`
|
||||
Severity string `json:"severity"`
|
||||
Message string `json:"message,omitempty"`
|
||||
Fix string `json:"fix,omitempty"`
|
||||
} `json:"issues,omitempty"`
|
||||
}
|
||||
|
||||
// address returns the canonical "host:port" used as our matching key against
|
||||
// XMPP endpoints. Falls back to Endpoint when host/port are unset.
|
||||
func (v *tlsProbeView) address() string {
|
||||
if v.Endpoint != "" {
|
||||
return v.Endpoint
|
||||
}
|
||||
if v.Host != "" && v.Port != 0 {
|
||||
return net.JoinHostPort(v.Host, strconv.Itoa(int(v.Port)))
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// parseTLSRelated decodes a RelatedObservation as a TLS probe, gracefully
|
||||
// returning nil when the payload doesn't look like one.
|
||||
//
|
||||
// Two payload shapes are accepted:
|
||||
//
|
||||
// 1. {"probes": {"<ref>": <probe>, …}}: the current convention used by
|
||||
// checker-tls. The consumer picks its own probe via r.Ref so one
|
||||
// observation does not leak into another's report.
|
||||
// 2. <probe>: a single top-level probe object, kept for back-compat.
|
||||
func parseTLSRelated(r sdk.RelatedObservation) *tlsProbeView {
|
||||
var keyed struct {
|
||||
Probes map[string]tlsProbeView `json:"probes"`
|
||||
}
|
||||
if err := json.Unmarshal(r.Data, &keyed); err == nil && keyed.Probes != nil {
|
||||
if p, ok := keyed.Probes[r.Ref]; ok {
|
||||
return &p
|
||||
}
|
||||
return nil
|
||||
}
|
||||
var v tlsProbeView
|
||||
if err := json.Unmarshal(r.Data, &v); err != nil {
|
||||
return nil
|
||||
}
|
||||
return &v
|
||||
}
|
||||
|
||||
// tlsIssuesFromRelated converts downstream TLS observations into Issue
|
||||
// entries that slot into our own aggregation. When a TLS checker publishes
|
||||
// its own structured issues we forward them with a code prefix so the
|
||||
// origin is obvious. When it only exposes structured flags, we synthesize
|
||||
// one issue per probe.
|
||||
func tlsIssuesFromRelated(related []sdk.RelatedObservation) []Issue {
|
||||
var out []Issue
|
||||
for _, r := range related {
|
||||
v := parseTLSRelated(r)
|
||||
if v == nil {
|
||||
continue
|
||||
}
|
||||
addr := v.address()
|
||||
if len(v.Issues) > 0 {
|
||||
for _, is := range v.Issues {
|
||||
sev := strings.ToLower(is.Severity)
|
||||
switch sev {
|
||||
case SeverityCrit, SeverityWarn, SeverityInfo:
|
||||
default:
|
||||
continue
|
||||
}
|
||||
code := is.Code
|
||||
if code == "" {
|
||||
code = "tls.unknown"
|
||||
}
|
||||
out = append(out, Issue{
|
||||
Code: "xmpp.tls." + code,
|
||||
Severity: sev,
|
||||
Message: strings.TrimSpace("TLS on " + addr + ": " + is.Message),
|
||||
Fix: is.Fix,
|
||||
Endpoint: addr,
|
||||
})
|
||||
}
|
||||
continue
|
||||
}
|
||||
// Flag-only payload: synthesize a single summary issue.
|
||||
sev := v.worstSeverity()
|
||||
if sev == "" {
|
||||
continue
|
||||
}
|
||||
msg := "TLS issue reported on " + addr
|
||||
switch {
|
||||
case v.ChainValid != nil && !*v.ChainValid:
|
||||
msg = "Invalid certificate chain on " + addr
|
||||
case v.HostnameMatch != nil && !*v.HostnameMatch:
|
||||
msg = "Certificate does not cover the domain on " + addr
|
||||
case !v.NotAfter.IsZero() && time.Until(v.NotAfter) < 0:
|
||||
msg = "Certificate expired on " + addr + " (" + v.NotAfter.Format(time.RFC3339) + ")"
|
||||
case !v.NotAfter.IsZero() && time.Until(v.NotAfter) < 14*24*time.Hour:
|
||||
msg = "Certificate expiring soon on " + addr + " (" + v.NotAfter.Format(time.RFC3339) + ")"
|
||||
}
|
||||
out = append(out, Issue{
|
||||
Code: "xmpp.tls.probe",
|
||||
Severity: sev,
|
||||
Message: msg,
|
||||
Fix: "See the TLS checker report for details.",
|
||||
Endpoint: addr,
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
if v.HostnameMatch != nil && !*v.HostnameMatch {
|
||||
return SeverityCrit
|
||||
}
|
||||
if !v.NotAfter.IsZero() && time.Until(v.NotAfter) < 0 {
|
||||
return SeverityCrit
|
||||
}
|
||||
if !v.NotAfter.IsZero() && time.Until(v.NotAfter) < 14*24*time.Hour {
|
||||
if worst != SeverityCrit {
|
||||
return SeverityWarn
|
||||
}
|
||||
}
|
||||
return worst
|
||||
}
|
||||
219
checker/tls_related_test.go
Normal file
219
checker/tls_related_test.go
Normal file
|
|
@ -0,0 +1,219 @@
|
|||
package checker
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
sdk "git.happydns.org/checker-sdk-go/checker"
|
||||
)
|
||||
|
||||
// stubObsGetter is a minimal ObservationGetter that returns canned XMPPData
|
||||
// and a canned list of related observations.
|
||||
type stubObsGetter struct {
|
||||
xmpp XMPPData
|
||||
related []sdk.RelatedObservation
|
||||
relErr error
|
||||
}
|
||||
|
||||
func (s *stubObsGetter) Get(_ context.Context, key sdk.ObservationKey, dest any) error {
|
||||
if key != ObservationKeyXMPP {
|
||||
return nil
|
||||
}
|
||||
b, _ := json.Marshal(s.xmpp)
|
||||
return json.Unmarshal(b, dest)
|
||||
}
|
||||
|
||||
func (s *stubObsGetter) GetRelated(_ context.Context, _ sdk.ObservationKey) ([]sdk.RelatedObservation, error) {
|
||||
return s.related, s.relErr
|
||||
}
|
||||
|
||||
func mkTLSObs(t *testing.T, payload any) sdk.RelatedObservation {
|
||||
t.Helper()
|
||||
b, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal tls payload: %v", err)
|
||||
}
|
||||
return sdk.RelatedObservation{
|
||||
CheckerID: "tls",
|
||||
Key: TLSRelatedKey,
|
||||
Data: b,
|
||||
CollectedAt: time.Now(),
|
||||
Ref: "ep-1",
|
||||
}
|
||||
}
|
||||
|
||||
func TestRule_FoldsTLSCritIntoAggregate(t *testing.T) {
|
||||
obs := &stubObsGetter{
|
||||
xmpp: healthyXMPPData(),
|
||||
related: []sdk.RelatedObservation{
|
||||
mkTLSObs(t, map[string]any{
|
||||
"host": "xmpp.example.com",
|
||||
"port": 5222,
|
||||
"chain_valid": false,
|
||||
"hostname_match": true,
|
||||
}),
|
||||
},
|
||||
}
|
||||
states := (&xmppRule{}).Evaluate(context.Background(), obs, sdk.CheckerOptions{"domain": "example.com", "mode": "both"})
|
||||
state := states[0]
|
||||
if state.Status != sdk.StatusCrit {
|
||||
t.Fatalf("expected StatusCrit due to TLS chain invalid, got %s (%s)", state.Status, state.Message)
|
||||
}
|
||||
if !strings.Contains(state.Message, "xmpp.example.com:5222") && !strings.Contains(state.Message, "Invalid certificate") {
|
||||
t.Fatalf("expected TLS message in state, got %q", state.Message)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRule_IgnoresUnrelatedTLSObs(t *testing.T) {
|
||||
obs := &stubObsGetter{
|
||||
xmpp: healthyXMPPData(),
|
||||
related: nil,
|
||||
}
|
||||
states := (&xmppRule{}).Evaluate(context.Background(), obs, sdk.CheckerOptions{"domain": "example.com", "mode": "both"})
|
||||
state := states[0]
|
||||
if state.Status != sdk.StatusOK {
|
||||
t.Fatalf("expected StatusOK without related TLS issues, got %s (%s)", state.Status, state.Message)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHTMLReportCtx_IncludesTLSPosture(t *testing.T) {
|
||||
data := healthyXMPPData()
|
||||
p := &xmppProvider{}
|
||||
related := []sdk.RelatedObservation{
|
||||
mkTLSObs(t, map[string]any{
|
||||
"host": "xmpp.example.com",
|
||||
"port": 5222,
|
||||
"chain_valid": true,
|
||||
"hostname_match": true,
|
||||
"not_after": time.Now().Add(60 * 24 * time.Hour).Format(time.RFC3339),
|
||||
"tls_version": "TLS 1.3",
|
||||
}),
|
||||
}
|
||||
rctx := &stubReportCtx{data: mustJSON(t, data), related: related}
|
||||
html, err := p.GetHTMLReport(rctx)
|
||||
if err != nil {
|
||||
t.Fatalf("GetHTMLReport: %v", err)
|
||||
}
|
||||
if !strings.Contains(html, "chain valid") {
|
||||
t.Fatal("expected 'chain valid' in HTML, not found")
|
||||
}
|
||||
if !strings.Contains(html, "hostname match") {
|
||||
t.Fatal("expected 'hostname match' in HTML, not found")
|
||||
}
|
||||
if !strings.Contains(html, "TLS checker") {
|
||||
t.Fatal("expected TLS checker footer mention, not found")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHTMLReport_BackCompatNoRelated(t *testing.T) {
|
||||
data := healthyXMPPData()
|
||||
p := &xmppProvider{}
|
||||
// StaticReportContext mimics the host-side "no related observations" path
|
||||
// (e.g. /report HTTP handler on the remote checker).
|
||||
html, err := p.GetHTMLReport(sdk.StaticReportContext(mustJSON(t, data)))
|
||||
if err != nil {
|
||||
t.Fatalf("GetHTMLReport: %v", err)
|
||||
}
|
||||
// Renderer must still produce a valid document and must not include TLS
|
||||
// posture rows when no related observations were passed.
|
||||
if !strings.Contains(html, "<title>XMPP Report") {
|
||||
t.Fatal("expected report title in HTML")
|
||||
}
|
||||
if strings.Contains(html, "TLS cert") {
|
||||
t.Fatal("did not expect 'TLS cert' row without related observations")
|
||||
}
|
||||
}
|
||||
|
||||
type stubReportCtx struct {
|
||||
data json.RawMessage
|
||||
related []sdk.RelatedObservation
|
||||
}
|
||||
|
||||
func (s *stubReportCtx) Data() json.RawMessage { return s.data }
|
||||
func (s *stubReportCtx) Related(_ sdk.ObservationKey) []sdk.RelatedObservation {
|
||||
return s.related
|
||||
}
|
||||
|
||||
func mustJSON(t *testing.T, v any) json.RawMessage {
|
||||
t.Helper()
|
||||
b, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal: %v", err)
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
func healthyXMPPData() XMPPData {
|
||||
return XMPPData{
|
||||
Domain: "example.com",
|
||||
SRV: SRVLookup{
|
||||
Client: []SRVRecord{{Target: "xmpp.example.com", Port: 5222}},
|
||||
Server: []SRVRecord{{Target: "xmpp.example.com", Port: 5269}},
|
||||
},
|
||||
Endpoints: []EndpointProbe{
|
||||
{
|
||||
Mode: ModeClient, Target: "xmpp.example.com", Port: 5222,
|
||||
Address: "xmpp.example.com:5222",
|
||||
TCPConnected: true,
|
||||
StreamOpened: true,
|
||||
STARTTLSOffered: true,
|
||||
STARTTLSRequired: true,
|
||||
STARTTLSUpgraded: true,
|
||||
FeaturesRead: true,
|
||||
SASLMechanisms: []string{"SCRAM-SHA-256", "SCRAM-SHA-256-PLUS"},
|
||||
},
|
||||
{
|
||||
Mode: ModeServer, Target: "xmpp.example.com", Port: 5269,
|
||||
Address: "xmpp.example.com:5269",
|
||||
TCPConnected: true,
|
||||
StreamOpened: true,
|
||||
STARTTLSOffered: true,
|
||||
STARTTLSRequired: true,
|
||||
STARTTLSUpgraded: true,
|
||||
FeaturesRead: true,
|
||||
DialbackOffered: true,
|
||||
},
|
||||
},
|
||||
Coverage: ReachabilitySpan{HasIPv4: true, WorkingC2S: true, WorkingS2S: true},
|
||||
}
|
||||
}
|
||||
|
||||
func TestTLSIssuesFromRelated_StructuredIssues(t *testing.T) {
|
||||
related := []sdk.RelatedObservation{
|
||||
mkTLSObs(t, map[string]any{
|
||||
"host": "xmpp.example.com",
|
||||
"port": 5222,
|
||||
"issues": []map[string]any{
|
||||
{"code": "tls.self_signed", "severity": "crit", "message": "self-signed cert"},
|
||||
{"code": "tls.weak_cipher", "severity": "warn", "message": "weak cipher"},
|
||||
},
|
||||
}),
|
||||
}
|
||||
out := tlsIssuesFromRelated(related)
|
||||
if len(out) != 2 {
|
||||
t.Fatalf("expected 2 issues, got %d", len(out))
|
||||
}
|
||||
if out[0].Code != "xmpp.tls.tls.self_signed" || out[0].Severity != SeverityCrit {
|
||||
t.Fatalf("unexpected first issue: %+v", out[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestTLSIssuesFromRelated_FlagsOnly(t *testing.T) {
|
||||
related := []sdk.RelatedObservation{
|
||||
mkTLSObs(t, map[string]any{
|
||||
"host": "xmpp.example.com",
|
||||
"port": 5222,
|
||||
"hostname_match": false,
|
||||
}),
|
||||
}
|
||||
out := tlsIssuesFromRelated(related)
|
||||
if len(out) != 1 {
|
||||
t.Fatalf("expected 1 synthesized issue, got %d", len(out))
|
||||
}
|
||||
if out[0].Severity != SeverityCrit || !strings.Contains(out[0].Message, "does not cover") {
|
||||
t.Fatalf("unexpected synthesized issue: %+v", out[0])
|
||||
}
|
||||
}
|
||||
135
checker/types.go
Normal file
135
checker/types.go
Normal file
|
|
@ -0,0 +1,135 @@
|
|||
// Package checker implements the XMPP server checker for happyDomain.
|
||||
//
|
||||
// It probes a domain's XMPP deployment (SRV discovery, STARTTLS,
|
||||
// stream features, SASL mechanisms, dialback / SASL EXTERNAL,
|
||||
// XEP-0368 direct TLS) and reports actionable findings.
|
||||
//
|
||||
// TLS certificate chain / SAN / expiry / cipher posture is intentionally
|
||||
// out of scope; a dedicated TLS checker covers that.
|
||||
package checker
|
||||
|
||||
import (
|
||||
sdk "git.happydns.org/checker-sdk-go/checker"
|
||||
)
|
||||
|
||||
const ObservationKeyXMPP sdk.ObservationKey = "xmpp"
|
||||
|
||||
type XMPPMode string
|
||||
|
||||
const (
|
||||
ModeClient XMPPMode = "c2s"
|
||||
ModeServer XMPPMode = "s2s"
|
||||
ModeBoth XMPPMode = "both"
|
||||
)
|
||||
|
||||
var validModes = []string{string(ModeClient), string(ModeServer), string(ModeBoth)}
|
||||
|
||||
// XMPPData is the full observation stored per run.
|
||||
type XMPPData struct {
|
||||
Domain string `json:"domain"`
|
||||
RunAt string `json:"run_at"`
|
||||
SRV SRVLookup `json:"srv"`
|
||||
Endpoints []EndpointProbe `json:"endpoints"`
|
||||
Coverage ReachabilitySpan `json:"coverage"`
|
||||
Issues []Issue `json:"issues"`
|
||||
}
|
||||
|
||||
type SRVLookup struct {
|
||||
Client []SRVRecord `json:"client,omitempty"`
|
||||
Server []SRVRecord `json:"server,omitempty"`
|
||||
ClientSecure []SRVRecord `json:"client_secure,omitempty"`
|
||||
ServerSecure []SRVRecord `json:"server_secure,omitempty"`
|
||||
Jabber []SRVRecord `json:"jabber,omitempty"`
|
||||
// Errors per-set (keyed by record type like "_xmpp-client._tcp").
|
||||
Errors map[string]string `json:"errors,omitempty"`
|
||||
// FallbackProbed is true when no SRV was published and we probed the bare domain.
|
||||
FallbackProbed bool `json:"fallback_probed,omitempty"`
|
||||
}
|
||||
|
||||
type SRVRecord struct {
|
||||
Target string `json:"target"`
|
||||
Port uint16 `json:"port"`
|
||||
Priority uint16 `json:"priority"`
|
||||
Weight uint16 `json:"weight"`
|
||||
// IPv4 and IPv6 addresses resolved for the target (at probe time).
|
||||
IPv4 []string `json:"ipv4,omitempty"`
|
||||
IPv6 []string `json:"ipv6,omitempty"`
|
||||
}
|
||||
|
||||
// EndpointProbe is the result of probing one (mode, host, port, address) tuple.
|
||||
type EndpointProbe struct {
|
||||
Mode XMPPMode `json:"mode"`
|
||||
SRVPrefix string `json:"srv_prefix"`
|
||||
Target string `json:"target"`
|
||||
Port uint16 `json:"port"`
|
||||
Address string `json:"address"`
|
||||
IsIPv6 bool `json:"is_ipv6,omitempty"`
|
||||
DirectTLS bool `json:"direct_tls,omitempty"`
|
||||
|
||||
// What happened.
|
||||
TCPConnected bool `json:"tcp_connected"`
|
||||
StreamOpened bool `json:"stream_opened"`
|
||||
|
||||
STARTTLSOffered bool `json:"starttls_offered"`
|
||||
STARTTLSRequired bool `json:"starttls_required"`
|
||||
STARTTLSUpgraded bool `json:"starttls_upgraded"`
|
||||
|
||||
TLSVersion string `json:"tls_version,omitempty"`
|
||||
TLSCipher string `json:"tls_cipher,omitempty"`
|
||||
|
||||
// Post-TLS features.
|
||||
FeaturesRead bool `json:"features_read,omitempty"`
|
||||
SASLMechanisms []string `json:"sasl_mechanisms,omitempty"`
|
||||
DialbackOffered bool `json:"dialback_offered,omitempty"`
|
||||
SASLExternal bool `json:"sasl_external,omitempty"`
|
||||
|
||||
StreamFrom string `json:"stream_from,omitempty"`
|
||||
|
||||
ElapsedMS int64 `json:"elapsed_ms"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
type ReachabilitySpan struct {
|
||||
HasIPv4 bool `json:"has_ipv4"`
|
||||
HasIPv6 bool `json:"has_ipv6"`
|
||||
// WorkingC2S is true when at least one c2s endpoint completed TLS + advertised SASL.
|
||||
WorkingC2S bool `json:"working_c2s"`
|
||||
// WorkingS2S is true when at least one s2s endpoint completed TLS + advertised dialback or SASL EXTERNAL.
|
||||
WorkingS2S bool `json:"working_s2s"`
|
||||
}
|
||||
|
||||
// Issue is a structured finding attached to the observation so the rule and
|
||||
// the HTML report can both consume them without re-deriving logic.
|
||||
type Issue struct {
|
||||
Code string `json:"code"`
|
||||
Severity string `json:"severity"` // "info" | "warn" | "crit"
|
||||
Message string `json:"message"`
|
||||
Fix string `json:"fix,omitempty"`
|
||||
Endpoint string `json:"endpoint,omitempty"`
|
||||
}
|
||||
|
||||
// Severities (string for stable JSON, independent of sdk.Status numeric values).
|
||||
const (
|
||||
SeverityInfo = "info"
|
||||
SeverityWarn = "warn"
|
||||
SeverityCrit = "crit"
|
||||
)
|
||||
|
||||
// Issue codes.
|
||||
const (
|
||||
CodeNoSRV = "xmpp.no_srv"
|
||||
CodeSRVServfail = "xmpp.srv.servfail"
|
||||
CodeStartTLSMissing = "xmpp.starttls.missing"
|
||||
CodeStartTLSNotRequired = "xmpp.starttls.not_required"
|
||||
CodeStartTLSFailed = "xmpp.starttls.handshake_failed"
|
||||
CodeTCPUnreachable = "xmpp.tcp.unreachable"
|
||||
CodeSASLPlainOnly = "xmpp.sasl.plain_only"
|
||||
CodeSASLNoSCRAM = "xmpp.sasl.no_scram"
|
||||
CodeSASLNoSCRAMPlus = "xmpp.sasl.no_scram_plus"
|
||||
CodeS2SNoAuth = "xmpp.s2s.no_auth"
|
||||
CodeS2SProbeIncomplete = "xmpp.s2s.probe_incomplete"
|
||||
CodeLegacyJabber = "xmpp.legacy_jabber"
|
||||
CodeNoIPv6 = "xmpp.no_ipv6"
|
||||
CodeNoDirectTLS = "xmpp.no_direct_tls"
|
||||
CodeAllEndpointsDown = "xmpp.all_endpoints_down"
|
||||
)
|
||||
17
go.mod
Normal file
17
go.mod
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
module git.happydns.org/checker-xmpp
|
||||
|
||||
go 1.25.0
|
||||
|
||||
require (
|
||||
git.happydns.org/checker-sdk-go v1.1.0
|
||||
git.happydns.org/checker-tls v0.1.0
|
||||
github.com/miekg/dns v1.1.72
|
||||
)
|
||||
|
||||
require (
|
||||
golang.org/x/mod v0.33.0 // indirect
|
||||
golang.org/x/net v0.51.0 // indirect
|
||||
golang.org/x/sync v0.20.0 // indirect
|
||||
golang.org/x/sys v0.42.0 // indirect
|
||||
golang.org/x/tools v0.42.0 // indirect
|
||||
)
|
||||
16
go.sum
Normal file
16
go.sum
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
git.happydns.org/checker-sdk-go v0.0.1 h1:4RxCJr73HWKxjOyU/6NJMO8lXJmH0gMLA68EzTqLbQI=
|
||||
git.happydns.org/checker-sdk-go v0.0.1/go.mod h1:aNAcfYFfbhvH9kJhE0Njp5GX0dQbxdRB0rJ0KvSC5nI=
|
||||
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=
|
||||
github.com/miekg/dns v1.1.72/go.mod h1:+EuEPhdHOsfk6Wk5TT2CzssZdqkmFhf8r+aVyDEToIs=
|
||||
golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8=
|
||||
golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w=
|
||||
golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo=
|
||||
golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y=
|
||||
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
||||
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
|
||||
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k=
|
||||
golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0=
|
||||
28
main.go
Normal file
28
main.go
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"log"
|
||||
|
||||
sdk "git.happydns.org/checker-sdk-go/checker"
|
||||
xmpp "git.happydns.org/checker-xmpp/checker"
|
||||
)
|
||||
|
||||
// Version is the standalone binary's version. It defaults to "custom-build"
|
||||
// and is meant to be overridden by the CI at link time:
|
||||
//
|
||||
// go build -ldflags "-X main.Version=1.2.3" .
|
||||
var Version = "custom-build"
|
||||
|
||||
var listenAddr = flag.String("listen", ":8080", "HTTP listen address")
|
||||
|
||||
func main() {
|
||||
flag.Parse()
|
||||
|
||||
xmpp.Version = Version
|
||||
|
||||
server := sdk.NewServer(xmpp.Provider())
|
||||
if err := server.ListenAndServe(*listenAddr); err != nil {
|
||||
log.Fatalf("server error: %v", err)
|
||||
}
|
||||
}
|
||||
19
plugin/plugin.go
Normal file
19
plugin/plugin.go
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
// Command plugin is the happyDomain plugin entrypoint for the XMPP checker.
|
||||
//
|
||||
// It is built as a Go plugin (`go build -buildmode=plugin`) and loaded at
|
||||
// runtime by happyDomain.
|
||||
package main
|
||||
|
||||
import (
|
||||
sdk "git.happydns.org/checker-sdk-go/checker"
|
||||
xmpp "git.happydns.org/checker-xmpp/checker"
|
||||
)
|
||||
|
||||
var Version = "custom-build"
|
||||
|
||||
// NewCheckerPlugin is the symbol resolved by happyDomain when loading the
|
||||
// .so file.
|
||||
func NewCheckerPlugin() (*sdk.CheckerDefinition, sdk.ObservationProvider, error) {
|
||||
xmpp.Version = Version
|
||||
return xmpp.Definition(), xmpp.Provider(), nil
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue