Compare commits
No commits in common. "bda66e47ebbcd735e5341ad401ff7f0f4ef8117c" and "2c2fb0712989a94b0075eb8dcea91eece2976eca" have entirely different histories.
bda66e47eb
...
2c2fb07129
15 changed files with 160 additions and 255 deletions
2
.gitignore
vendored
2
.gitignore
vendored
|
|
@ -1,2 +0,0 @@
|
|||
checker-xmpp
|
||||
checker-xmpp.so
|
||||
30
README.md
30
README.md
|
|
@ -8,31 +8,17 @@ 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
|
||||
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`).
|
||||
When a TLS checker runs against the endpoints we publish via
|
||||
`EndpointDiscoverer`, its observations are automatically folded into our
|
||||
rule aggregation and HTML report via the SDK's `GetRelated` /
|
||||
`CheckerHTMLReporterCtx` composition path — so a bad cert on an XMPP
|
||||
endpoint shows up on the XMPP service page, not only in a separate TLS
|
||||
view. The expected observation key is `tls_probes`.
|
||||
|
||||
## What it checks
|
||||
|
||||
|
|
|
|||
|
|
@ -24,14 +24,6 @@ const (
|
|||
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")
|
||||
|
|
@ -134,8 +126,8 @@ func probeSet(ctx context.Context, data *XMPPData, domain string, mode XMPPMode,
|
|||
}
|
||||
|
||||
type probeAddr struct {
|
||||
ip string
|
||||
isV6 bool
|
||||
ip string
|
||||
isV6 bool
|
||||
}
|
||||
|
||||
func addressesForProbe(rec SRVRecord) []probeAddr {
|
||||
|
|
@ -170,7 +162,7 @@ func probeEndpoint(ctx context.Context, domain string, mode XMPPMode, prefix str
|
|||
dialCtx, cancel := context.WithTimeout(ctx, timeout)
|
||||
defer cancel()
|
||||
|
||||
dialer := &net.Dialer{}
|
||||
dialer := &net.Dialer{Timeout: timeout}
|
||||
rawConn, err := dialer.DialContext(dialCtx, "tcp", result.Address)
|
||||
if err != nil {
|
||||
result.Error = "tcp: " + err.Error()
|
||||
|
|
@ -183,8 +175,12 @@ func probeEndpoint(ctx context.Context, domain string, mode XMPPMode, prefix str
|
|||
var conn net.Conn = rawConn
|
||||
|
||||
if directTLS {
|
||||
tlsConn := tls.Client(rawConn, tlsProbeConfig(domain))
|
||||
if err := tlsConn.Handshake(); err != nil {
|
||||
tlsConn := tls.Client(rawConn, &tls.Config{
|
||||
ServerName: domain,
|
||||
InsecureSkipVerify: true, //nolint:gosec — cert validation is the TLS checker's job
|
||||
MinVersion: tls.VersionTLS10,
|
||||
})
|
||||
if err := tlsConn.HandshakeContext(dialCtx); err != nil {
|
||||
result.Error = "tls-handshake: " + err.Error()
|
||||
return result
|
||||
}
|
||||
|
|
@ -206,6 +202,7 @@ func probeEndpoint(ctx context.Context, domain string, mode XMPPMode, prefix str
|
|||
return result
|
||||
}
|
||||
|
||||
// STARTTLS path.
|
||||
dec, from, err := openStream(conn, domain, ns, mode == ModeServer)
|
||||
if err != nil {
|
||||
result.Error = "stream: " + err.Error()
|
||||
|
|
@ -225,12 +222,13 @@ func probeEndpoint(ctx context.Context, domain string, mode XMPPMode, prefix str
|
|||
}
|
||||
|
||||
if !result.STARTTLSOffered {
|
||||
// Record any features seen in plaintext, but do not proceed; we
|
||||
// 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
|
||||
}
|
||||
|
||||
// Request STARTTLS.
|
||||
if _, err := io.WriteString(conn, `<starttls xmlns='`+tlsNS+`'/>`); err != nil {
|
||||
result.Error = "starttls-write: " + err.Error()
|
||||
return result
|
||||
|
|
@ -240,9 +238,14 @@ func probeEndpoint(ctx context.Context, domain string, mode XMPPMode, prefix str
|
|||
return result
|
||||
}
|
||||
|
||||
tlsConn := tls.Client(rawConn, tlsProbeConfig(domain))
|
||||
// Upgrade.
|
||||
tlsConn := tls.Client(rawConn, &tls.Config{
|
||||
ServerName: domain,
|
||||
InsecureSkipVerify: true, //nolint:gosec — cert validation is the TLS checker's job
|
||||
MinVersion: tls.VersionTLS10,
|
||||
})
|
||||
_ = tlsConn.SetDeadline(time.Now().Add(timeout))
|
||||
if err := tlsConn.Handshake(); err != nil {
|
||||
if err := tlsConn.HandshakeContext(dialCtx); err != nil {
|
||||
result.Error = "tls-handshake: " + err.Error()
|
||||
return result
|
||||
}
|
||||
|
|
@ -280,6 +283,8 @@ func applyFeatures(ep *EndpointProbe, feats *streamFeatures) {
|
|||
}
|
||||
}
|
||||
|
||||
// ─── Stream / XML plumbing ────────────────────────────────────────────────────
|
||||
|
||||
type streamFeatures struct {
|
||||
XMLName xml.Name `xml:"http://etherx.jabber.org/streams features"`
|
||||
StartTLS *startTLSEl
|
||||
|
|
@ -395,6 +400,8 @@ func expectProceed(dec *xml.Decoder) error {
|
|||
}
|
||||
}
|
||||
|
||||
// ─── DNS ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
func lookupSRV(ctx context.Context, r *net.Resolver, prefix, domain string) ([]SRVRecord, error) {
|
||||
name := prefix + dns.Fqdn(domain)
|
||||
_, records, err := r.LookupSRV(ctx, "", "", name)
|
||||
|
|
@ -439,6 +446,8 @@ func resolveAllInto(ctx context.Context, r *net.Resolver, records []SRVRecord) {
|
|||
}
|
||||
}
|
||||
|
||||
// ─── Coverage + issues ────────────────────────────────────────────────────────
|
||||
|
||||
func computeCoverage(data *XMPPData) {
|
||||
for _, ep := range data.Endpoints {
|
||||
if ep.TCPConnected {
|
||||
|
|
@ -508,6 +517,9 @@ func deriveIssues(data *XMPPData, wantC2S, _ bool) []Issue {
|
|||
sawAnyWorking := map[XMPPMode]bool{}
|
||||
|
||||
for _, ep := range data.Endpoints {
|
||||
if ep.DirectTLS {
|
||||
// direct-TLS endpoints don't contribute to STARTTLS-missing logic
|
||||
}
|
||||
if ep.TCPConnected && ep.STARTTLSUpgraded {
|
||||
allDown = false
|
||||
sawAnyWorking[ep.Mode] = true
|
||||
|
|
@ -579,7 +591,7 @@ func deriveIssues(data *XMPPData, wantC2S, _ bool) []Issue {
|
|||
sawPlainOnly[ep.Mode] = true
|
||||
}
|
||||
}
|
||||
// S2S auth posture, only meaningful if we actually parsed the
|
||||
// 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".
|
||||
|
|
@ -588,7 +600,7 @@ func deriveIssues(data *XMPPData, wantC2S, _ bool) []Issue {
|
|||
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.",
|
||||
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,
|
||||
})
|
||||
|
|
@ -596,7 +608,7 @@ func deriveIssues(data *XMPPData, wantC2S, _ bool) []Issue {
|
|||
issues = append(issues, Issue{
|
||||
Code: CodeS2SNoAuth,
|
||||
Severity: SeverityCrit,
|
||||
Message: "No dialback or SASL EXTERNAL advertised on " + ep.Address + " after TLS; federation will fail.",
|
||||
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,
|
||||
})
|
||||
|
|
|
|||
|
|
@ -4,8 +4,6 @@ import (
|
|||
"encoding/xml"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
tlsct "git.happydns.org/checker-tls/contract"
|
||||
)
|
||||
|
||||
func TestReadFeatures_WithStartTLSAndSCRAM(t *testing.T) {
|
||||
|
|
@ -198,7 +196,7 @@ func TestComputeCoverage_Mixed(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestDiscoverEntries_AllSets(t *testing.T) {
|
||||
func TestDiscoverEndpoints_AllSets(t *testing.T) {
|
||||
d := &XMPPData{
|
||||
Domain: "example.com",
|
||||
SRV: SRVLookup{
|
||||
|
|
@ -214,70 +212,59 @@ func TestDiscoverEntries_AllSets(t *testing.T) {
|
|||
},
|
||||
}
|
||||
p := &xmppProvider{}
|
||||
raw, err := p.DiscoverEntries(d)
|
||||
eps, err := p.DiscoverEndpoints(d)
|
||||
if err != nil {
|
||||
t.Fatalf("DiscoverEntries: %v", err)
|
||||
t.Fatalf("DiscoverEndpoints: %v", err)
|
||||
}
|
||||
if len(raw) != 4 {
|
||||
t.Fatalf("expected 4 entries (legacy jabber excluded), got %d: %+v", len(raw), raw)
|
||||
if len(eps) != 4 {
|
||||
t.Fatalf("expected 4 endpoints (legacy jabber excluded), got %d: %+v", len(eps), eps)
|
||||
}
|
||||
|
||||
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)
|
||||
by := map[string]int{}
|
||||
for i, e := range eps {
|
||||
by[e.Type+":"+e.Host+":"+itoa(int(e.Port))] = i
|
||||
if e.SNI != "example.com" {
|
||||
t.Errorf("endpoint %d: SNI=%q, want example.com", i, e.SNI)
|
||||
}
|
||||
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")
|
||||
c2s := eps[by["starttls-xmpp-client:xmpp.example.com:5222"]]
|
||||
if c2s.Meta["starttls"] != "required" {
|
||||
t.Errorf("c2s starttls meta = %v, want required", c2s.Meta)
|
||||
}
|
||||
if !c2s.RequireSTARTTLS {
|
||||
t.Errorf("c2s RequireSTARTTLS = false, want true")
|
||||
s2s := eps[by["starttls-xmpp-server:xmpp.example.com:5269"]]
|
||||
if s2s.Meta["starttls"] != "opportunistic" {
|
||||
t.Errorf("s2s starttls meta = %v, want opportunistic", s2s.Meta)
|
||||
}
|
||||
|
||||
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)
|
||||
direct := eps[by["tls:xmpp.example.com:5223"]]
|
||||
if direct.Meta != nil {
|
||||
t.Errorf("direct-TLS endpoint should not carry starttls meta, got %v", direct.Meta)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiscoverEntries_WrongType(t *testing.T) {
|
||||
func TestDiscoverEndpoints_WrongType(t *testing.T) {
|
||||
p := &xmppProvider{}
|
||||
eps, err := p.DiscoverEntries("not an XMPPData")
|
||||
eps, err := p.DiscoverEndpoints("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)
|
||||
t.Fatalf("expected nil endpoints for wrong type, got %v", eps)
|
||||
}
|
||||
}
|
||||
|
||||
func itoa(i int) string {
|
||||
if i == 0 {
|
||||
return "0"
|
||||
}
|
||||
var buf [6]byte
|
||||
pos := len(buf)
|
||||
for i > 0 {
|
||||
pos--
|
||||
buf[pos] = byte('0' + i%10)
|
||||
i /= 10
|
||||
}
|
||||
return string(buf[pos:])
|
||||
}
|
||||
|
||||
func containsCode(is []Issue, code string) bool {
|
||||
for _, i := range is {
|
||||
if i.Code == code {
|
||||
|
|
|
|||
|
|
@ -1,66 +0,0 @@
|
|||
package checker
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
sdk "git.happydns.org/checker-sdk-go/checker"
|
||||
)
|
||||
|
||||
// RenderForm implements sdk.CheckerInteractive.
|
||||
func (p *xmppProvider) RenderForm() []sdk.CheckerOptionField {
|
||||
return []sdk.CheckerOptionField{
|
||||
{
|
||||
Id: "domain",
|
||||
Type: "string",
|
||||
Label: "Domain",
|
||||
Placeholder: "example.com",
|
||||
Required: true,
|
||||
},
|
||||
{
|
||||
Id: "mode",
|
||||
Type: "string",
|
||||
Label: "Mode",
|
||||
Default: "both",
|
||||
Choices: []string{"c2s", "s2s", "both"},
|
||||
},
|
||||
{
|
||||
Id: "timeout",
|
||||
Type: "number",
|
||||
Label: "Per-endpoint timeout (seconds)",
|
||||
Default: 10,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// ParseForm implements sdk.CheckerInteractive.
|
||||
func (p *xmppProvider) ParseForm(r *http.Request) (sdk.CheckerOptions, error) {
|
||||
domain := strings.TrimSpace(r.FormValue("domain"))
|
||||
domain = strings.TrimSuffix(domain, ".")
|
||||
if domain == "" {
|
||||
return nil, errors.New("domain is required")
|
||||
}
|
||||
|
||||
opts := sdk.CheckerOptions{"domain": domain}
|
||||
|
||||
if mode := strings.TrimSpace(r.FormValue("mode")); mode != "" {
|
||||
switch mode {
|
||||
case "c2s", "s2s", "both":
|
||||
opts["mode"] = mode
|
||||
default:
|
||||
return nil, errors.New("mode must be one of: c2s, s2s, both")
|
||||
}
|
||||
}
|
||||
|
||||
if to := strings.TrimSpace(r.FormValue("timeout")); to != "" {
|
||||
v, err := strconv.ParseFloat(to, 64)
|
||||
if err != nil {
|
||||
return nil, errors.New("timeout must be a number")
|
||||
}
|
||||
opts["timeout"] = v
|
||||
}
|
||||
|
||||
return opts, nil
|
||||
}
|
||||
|
|
@ -1,11 +1,9 @@
|
|||
package checker
|
||||
|
||||
import (
|
||||
"net"
|
||||
"strconv"
|
||||
|
||||
sdk "git.happydns.org/checker-sdk-go/checker"
|
||||
tlsct "git.happydns.org/checker-tls/contract"
|
||||
)
|
||||
|
||||
func Provider() sdk.ObservationProvider {
|
||||
|
|
@ -23,18 +21,17 @@ func (p *xmppProvider) Definition() *sdk.CheckerDefinition {
|
|||
return Definition()
|
||||
}
|
||||
|
||||
// DiscoverEntries implements sdk.DiscoveryPublisher.
|
||||
// DiscoverEndpoints implements sdk.EndpointDiscoverer.
|
||||
//
|
||||
// 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.
|
||||
// It publishes the (host, port) pairs of 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) {
|
||||
func (p *xmppProvider) DiscoverEndpoints(data any) ([]sdk.DiscoveredEndpoint, error) {
|
||||
d, ok := data.(*XMPPData)
|
||||
if !ok || d == nil {
|
||||
return nil, nil
|
||||
|
|
@ -48,42 +45,33 @@ func (p *xmppProvider) DiscoverEntries(data any) ([]sdk.DiscoveryEntry, error) {
|
|||
}
|
||||
}
|
||||
|
||||
var out []sdk.DiscoveryEntry
|
||||
emit := func(proto string, recs []SRVRecord, directTLS bool) error {
|
||||
var out []sdk.DiscoveredEndpoint
|
||||
emit := func(epType string, recs []SRVRecord, directTLS bool) {
|
||||
for _, r := range recs {
|
||||
ep := tlsct.TLSEndpoint{
|
||||
ep := sdk.DiscoveredEndpoint{
|
||||
Type: epType,
|
||||
Host: r.Target,
|
||||
Port: r.Port,
|
||||
SNI: d.Domain,
|
||||
}
|
||||
if !directTLS {
|
||||
ep.STARTTLS = proto
|
||||
ep.RequireSTARTTLS = starttlsRequired[endpointKey(r.Target, r.Port)]
|
||||
mode := "opportunistic"
|
||||
if starttlsRequired[endpointKey(r.Target, r.Port)] {
|
||||
mode = "required"
|
||||
}
|
||||
ep.Meta = map[string]any{"starttls": mode}
|
||||
}
|
||||
entry, err := tlsct.NewEntry(ep)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
out = append(out, entry)
|
||||
out = append(out, ep)
|
||||
}
|
||||
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
|
||||
}
|
||||
emit("starttls-xmpp-client", d.SRV.Client, false)
|
||||
emit("starttls-xmpp-server", d.SRV.Server, false)
|
||||
emit("tls", d.SRV.ClientSecure, true)
|
||||
emit("tls", d.SRV.ServerSecure, true)
|
||||
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func endpointKey(host string, port uint16) string {
|
||||
return net.JoinHostPort(host, strconv.FormatUint(uint64(port), 10))
|
||||
return host + ":" + strconv.Itoa(int(port))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -95,7 +95,7 @@ var reportTpl = template.Must(template.New("xmpp").Funcs(template.FuncMap{
|
|||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>XMPP Report: {{.Domain}}</title>
|
||||
<title>XMPP Report — {{.Domain}}</title>
|
||||
<style>
|
||||
*, *::before, *::after { box-sizing: border-box; }
|
||||
:root {
|
||||
|
|
@ -179,7 +179,7 @@ th { font-weight: 600; color: #6b7280; }
|
|||
<body>
|
||||
|
||||
<div class="hd">
|
||||
<h1>XMPP: <code>{{.Domain}}</code></h1>
|
||||
<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}}
|
||||
|
|
@ -206,7 +206,7 @@ th { font-weight: 600; color: #6b7280; }
|
|||
<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>
|
||||
<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>
|
||||
|
|
|
|||
|
|
@ -31,14 +31,14 @@ func (r *xmppRule) ValidateOptions(opts sdk.CheckerOptions) error {
|
|||
return nil
|
||||
}
|
||||
|
||||
func (r *xmppRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, opts sdk.CheckerOptions) []sdk.CheckState {
|
||||
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{{
|
||||
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...)
|
||||
|
|
@ -88,7 +88,7 @@ func (r *xmppRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, opts
|
|||
if worst < sdk.StatusCrit {
|
||||
worst = sdk.StatusCrit
|
||||
}
|
||||
var missing []string
|
||||
missing := []string{}
|
||||
if wantC2S && !data.Coverage.WorkingC2S {
|
||||
missing = append(missing, "c2s")
|
||||
}
|
||||
|
|
@ -102,36 +102,36 @@ func (r *xmppRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, opts
|
|||
}
|
||||
|
||||
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),
|
||||
"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{{
|
||||
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{{
|
||||
return sdk.CheckState{
|
||||
Status: sdk.StatusWarn,
|
||||
Message: "XMPP works with warnings: " + joinTop(warnMsgs, 2),
|
||||
Code: firstWarnCode,
|
||||
Meta: meta,
|
||||
}}
|
||||
}
|
||||
default:
|
||||
return []sdk.CheckState{{
|
||||
return sdk.CheckState{
|
||||
Status: sdk.StatusCrit,
|
||||
Message: "XMPP broken: " + joinTop(critMsgs, 2),
|
||||
Code: firstCritCode,
|
||||
Meta: meta,
|
||||
}}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ package checker
|
|||
|
||||
import (
|
||||
"encoding/json"
|
||||
"log"
|
||||
"net"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
|
@ -16,7 +17,7 @@ import (
|
|||
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
|
||||
// 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"`
|
||||
|
|
@ -52,16 +53,17 @@ func (v *tlsProbeView) address() string {
|
|||
//
|
||||
// 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.
|
||||
// 1. {"probes": {"<endpointId>": <probe>, …}} — the current convention
|
||||
// used by checker-tls. The consumer picks its own probe via
|
||||
// r.EndpointID 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 {
|
||||
log.Printf("xmpp parseTLSRelated: endpointID=%q data=%s", r.EndpointID, r.Data)
|
||||
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 {
|
||||
if p, ok := keyed.Probes[r.EndpointID]; ok {
|
||||
return &p
|
||||
}
|
||||
return nil
|
||||
|
|
|
|||
|
|
@ -41,7 +41,7 @@ func mkTLSObs(t *testing.T, payload any) sdk.RelatedObservation {
|
|||
Key: TLSRelatedKey,
|
||||
Data: b,
|
||||
CollectedAt: time.Now(),
|
||||
Ref: "ep-1",
|
||||
EndpointID: "ep-1",
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@
|
|||
// 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.
|
||||
// out of scope — a dedicated TLS checker covers that.
|
||||
package checker
|
||||
|
||||
import (
|
||||
|
|
@ -23,12 +23,12 @@ const (
|
|||
|
||||
// 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"`
|
||||
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 {
|
||||
|
|
@ -55,17 +55,17 @@ type SRVRecord struct {
|
|||
|
||||
// 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"`
|
||||
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"`
|
||||
TCPConnected bool `json:"tcp_connected"`
|
||||
StreamOpened bool `json:"stream_opened"`
|
||||
|
||||
STARTTLSOffered bool `json:"starttls_offered"`
|
||||
STARTTLSRequired bool `json:"starttls_required"`
|
||||
|
|
@ -114,19 +114,20 @@ const (
|
|||
|
||||
// Issue codes.
|
||||
const (
|
||||
CodeNoSRV = "xmpp.no_srv"
|
||||
CodeSRVServfail = "xmpp.srv.servfail"
|
||||
CodeStartTLSMissing = "xmpp.starttls.missing"
|
||||
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"
|
||||
CodeStartTLSFailed = "xmpp.starttls.handshake_failed"
|
||||
CodeStreamOpenFailed = "xmpp.stream.open_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"
|
||||
)
|
||||
|
|
|
|||
3
go.mod
3
go.mod
|
|
@ -3,8 +3,7 @@ module git.happydns.org/checker-xmpp
|
|||
go 1.25.0
|
||||
|
||||
require (
|
||||
git.happydns.org/checker-sdk-go v1.2.0
|
||||
git.happydns.org/checker-tls v0.2.0
|
||||
git.happydns.org/checker-sdk-go v0.0.1
|
||||
github.com/miekg/dns v1.1.72
|
||||
)
|
||||
|
||||
|
|
|
|||
6
go.sum
6
go.sum
|
|
@ -1,7 +1,5 @@
|
|||
git.happydns.org/checker-sdk-go v1.2.0 h1:v4MpKAz0W3PwP+bxx3pya8w893sVH5xTD1of1cc0TV8=
|
||||
git.happydns.org/checker-sdk-go v1.2.0/go.mod h1:aNAcfYFfbhvH9kJhE0Njp5GX0dQbxdRB0rJ0KvSC5nI=
|
||||
git.happydns.org/checker-tls v0.2.0 h1:2dYpcePBylUc3le76fFlLbxraiLpGESmOhx4NfD7REM=
|
||||
git.happydns.org/checker-tls v0.2.0/go.mod h1:0ZSG0CTP007SHBPE7qInESVIOcW+xgucHUhHgj6MeZ8=
|
||||
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=
|
||||
|
|
|
|||
2
main.go
2
main.go
|
|
@ -4,8 +4,8 @@ import (
|
|||
"flag"
|
||||
"log"
|
||||
|
||||
sdk "git.happydns.org/checker-sdk-go/checker"
|
||||
xmpp "git.happydns.org/checker-xmpp/checker"
|
||||
sdk "git.happydns.org/checker-sdk-go/checker"
|
||||
)
|
||||
|
||||
// Version is the standalone binary's version. It defaults to "custom-build"
|
||||
|
|
|
|||
|
|
@ -5,8 +5,8 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
sdk "git.happydns.org/checker-sdk-go/checker"
|
||||
xmpp "git.happydns.org/checker-xmpp/checker"
|
||||
sdk "git.happydns.org/checker-sdk-go/checker"
|
||||
)
|
||||
|
||||
var Version = "custom-build"
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue