Compare commits

...

5 commits

Author SHA1 Message Date
f5056f6929 checker: harden HTTP collection and stabilize report ordering
Validate the federation tester URI placeholder, escape the domain, set
a client timeout, cap the response body, and ship CA certificates in
the scratch image so HTTPS calls succeed. Sort hosts, connection
reports, and errors when rendering so output is deterministic, and
deduplicate TLS problems. Drop the deprecated aggregate Rule() and add
tests for collection and rules.
2026-04-26 03:58:23 +07:00
0fee494294 checker: report skipped TLS rule as StatusUnknown
When no endpoint is reached, the TLS posture cannot be assessed —
this is a non-evaluation, not an informational finding.
2026-04-26 03:58:19 +07:00
d19bda771d Run container as non-root user
Add USER 65534:65534 to the scratch runtime image so the checker
process does not run as root.
2026-04-26 03:58:16 +07:00
e4b6481d32 checker: split monolithic rule into per-concern rules
Replace the single matrix_federation rule with individual rules for
federation status, well-known delegation, SRV records, connection
reachability, TLS checks, and homeserver version, so the UI surfaces a
clear checklist. Drop the incorrect well-known/server_name equality
check: m.server points at the delegated federation endpoint, which is
intentionally distinct from server_name.
2026-04-26 03:58:15 +07:00
2bd0ae99bd Migrate to checker-sdk-go v1.3.0 with standalone build tag
The SDK split the HTTP server scaffolding into the new
checker-sdk-go/checker/server subpackage. Update main.go to import
server and call server.New, and isolate the interactive form code
behind the standalone build tag so plugin/builtin builds skip
net/http entirely.
2026-04-26 03:58:13 +07:00
18 changed files with 700 additions and 82 deletions

View file

@ -6,9 +6,11 @@ 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-matrix .
RUN CGO_ENABLED=0 go build -tags standalone -ldflags "-X main.Version=${CHECKER_VERSION}" -o /checker-matrix .
FROM scratch
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ca-certificates.crt
COPY --from=builder /checker-matrix /checker-matrix
USER 65534:65534
EXPOSE 8080
ENTRYPOINT ["/checker-matrix"]

View file

@ -6,12 +6,12 @@ CHECKER_SOURCES := main.go $(wildcard checker/*.go)
GO_LDFLAGS := -X main.Version=$(CHECKER_VERSION)
.PHONY: all plugin docker clean
.PHONY: all plugin docker test clean
all: $(CHECKER_NAME)
$(CHECKER_NAME): $(CHECKER_SOURCES)
go build -ldflags "$(GO_LDFLAGS)" -o $@ .
go build -tags standalone -ldflags "$(GO_LDFLAGS)" -o $@ .
plugin: $(CHECKER_NAME).so
@ -21,5 +21,8 @@ $(CHECKER_NAME).so: $(CHECKER_SOURCES) $(wildcard plugin/*.go)
docker:
docker build --build-arg CHECKER_VERSION=$(CHECKER_VERSION) -t $(CHECKER_IMAGE) .
test:
go test -tags standalone ./...
clean:
rm -f $(CHECKER_NAME) $(CHECKER_NAME).so

View file

@ -4,12 +4,23 @@ import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"time"
sdk "git.happydns.org/checker-sdk-go/checker"
)
const (
defaultTesterURI = "https://federationtester.matrix.org/api/report?server_name=%s"
collectHTTPTimeout = 30 * time.Second
maxResponseBodySize = 5 << 20 // 5 MiB
)
var collectHTTPClient = &http.Client{Timeout: collectHTTPTimeout}
func (p *matrixProvider) Collect(ctx context.Context, opts sdk.CheckerOptions) (any, error) {
domain, _ := opts["serviceDomain"].(string)
if domain == "" {
@ -19,15 +30,20 @@ func (p *matrixProvider) Collect(ctx context.Context, opts sdk.CheckerOptions) (
testerURI, _ := opts["federationTesterServer"].(string)
if testerURI == "" {
testerURI = "https://federationtester.matrix.org/api/report?server_name=%s"
testerURI = defaultTesterURI
}
if !strings.Contains(testerURI, "%s") {
return nil, fmt.Errorf("federationTesterServer must contain a %%s placeholder for the domain")
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, fmt.Sprintf(testerURI, domain), nil)
reqURL := fmt.Sprintf(testerURI, url.QueryEscape(domain))
req, err := http.NewRequestWithContext(ctx, http.MethodGet, reqURL, nil)
if err != nil {
return nil, fmt.Errorf("unable to build the request: %w", err)
}
resp, err := http.DefaultClient.Do(req)
resp, err := collectHTTPClient.Do(req)
if err != nil {
return nil, fmt.Errorf("unable to perform the test: %w", err)
}
@ -38,7 +54,7 @@ func (p *matrixProvider) Collect(ctx context.Context, opts sdk.CheckerOptions) (
}
var data MatrixFederationData
if err := json.NewDecoder(resp.Body).Decode(&data); err != nil {
if err := json.NewDecoder(io.LimitReader(resp.Body, maxResponseBodySize)).Decode(&data); err != nil {
return nil, fmt.Errorf("failed to decode federation tester response: %w", err)
}

91
checker/collect_test.go Normal file
View file

@ -0,0 +1,91 @@
package checker
import (
"context"
"net/http"
"net/http/httptest"
"strings"
"testing"
sdk "git.happydns.org/checker-sdk-go/checker"
)
func TestCollectMissingDomain(t *testing.T) {
p := &matrixProvider{}
if _, err := p.Collect(context.Background(), sdk.CheckerOptions{}); err == nil {
t.Fatal("expected error when serviceDomain is empty")
}
}
func TestCollectSuccess(t *testing.T) {
const body = `{
"WellKnownResult": {"m.server": "matrix.example.org:8448", "result": ""},
"DNSResult": {"SRVSkipped": false, "SRVRecords": [{"Target": "matrix.example.org.", "Port": 8448, "Priority": 10, "Weight": 5}]},
"ConnectionReports": {"1.2.3.4:8448": {"Checks": {"AllChecksOK": true, "MatchingServerName": true, "FutureValidUntilTS": true, "HasEd25519Key": true, "AllEd25519ChecksOK": true, "ValidCertificates": true}}},
"ConnectionErrors": {},
"Version": {"name": "Synapse", "version": "1.100.0"},
"FederationOK": true
}`
var gotURL string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotURL = r.URL.String()
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(body))
}))
defer srv.Close()
p := &matrixProvider{}
out, err := p.Collect(context.Background(), sdk.CheckerOptions{
"serviceDomain": "example.org.",
"federationTesterServer": srv.URL + "/api/report?server_name=%s",
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !strings.Contains(gotURL, "server_name=example.org") {
t.Errorf("unexpected URL %q", gotURL)
}
data, ok := out.(*MatrixFederationData)
if !ok || data == nil {
t.Fatalf("expected *MatrixFederationData, got %T", out)
}
if !data.FederationOK {
t.Error("expected FederationOK=true")
}
if data.Version.Name != "Synapse" {
t.Errorf("unexpected version name %q", data.Version.Name)
}
}
func TestCollectNon2xx(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusBadGateway)
}))
defer srv.Close()
p := &matrixProvider{}
_, err := p.Collect(context.Background(), sdk.CheckerOptions{
"serviceDomain": "example.org",
"federationTesterServer": srv.URL + "/?s=%s",
})
if err == nil {
t.Fatal("expected error on 502 response")
}
}
func TestCollectMalformedJSON(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.Write([]byte("not json"))
}))
defer srv.Close()
p := &matrixProvider{}
_, err := p.Collect(context.Background(), sdk.CheckerOptions{
"serviceDomain": "example.org",
"federationTesterServer": srv.URL + "/?s=%s",
})
if err == nil {
t.Fatal("expected decode error")
}
}

View file

@ -50,9 +50,7 @@ func Definition() *sdk.CheckerDefinition {
},
},
},
Rules: []sdk.CheckRule{
Rule(),
},
Rules: Rules(),
Interval: &sdk.CheckIntervalSpec{
Min: 5 * time.Minute,
Max: 7 * 24 * time.Hour,

View file

@ -1,3 +1,5 @@
//go:build standalone
package checker
import (
@ -8,7 +10,7 @@ import (
sdk "git.happydns.org/checker-sdk-go/checker"
)
// RenderForm implements sdk.CheckerInteractive.
// RenderForm implements server.Interactive.
func (p *matrixProvider) RenderForm() []sdk.CheckerOptionField {
return []sdk.CheckerOptionField{
{
@ -29,11 +31,11 @@ func (p *matrixProvider) RenderForm() []sdk.CheckerOptionField {
}
}
// ParseForm implements sdk.CheckerInteractive.
// ParseForm implements server.Interactive.
func (p *matrixProvider) ParseForm(r *http.Request) (sdk.CheckerOptions, error) {
domain := strings.TrimSpace(r.FormValue("serviceDomain"))
if domain == "" {
return nil, errors.New("Matrix domain is required")
return nil, errors.New("matrix domain is required")
}
opts := sdk.CheckerOptions{

View file

@ -4,6 +4,7 @@ import (
"encoding/json"
"fmt"
"html/template"
"sort"
"strings"
sdk "git.happydns.org/checker-sdk-go/checker"
@ -326,7 +327,13 @@ func (p *matrixProvider) GetHTMLReport(ctx sdk.ReportContext) (string, error) {
}
// Hosts
for name, h := range r.DNSResult.Hosts {
hostNames := make([]string, 0, len(r.DNSResult.Hosts))
for name := range r.DNSResult.Hosts {
hostNames = append(hostNames, name)
}
sort.Strings(hostNames)
for _, name := range hostNames {
h := r.DNSResult.Hosts[name]
data.Hosts = append(data.Hosts, matrixHostData{
Name: name,
CName: h.CName,
@ -335,7 +342,13 @@ func (p *matrixProvider) GetHTMLReport(ctx sdk.ReportContext) (string, error) {
}
// Successful connections
for addr, cr := range r.ConnectionReports {
connAddrs := make([]string, 0, len(r.ConnectionReports))
for addr := range r.ConnectionReports {
connAddrs = append(connAddrs, addr)
}
sort.Strings(connAddrs)
for _, addr := range connAddrs {
cr := r.ConnectionReports[addr]
conn := matrixConnectionData{
Address: addr,
TLSVersion: cr.Cipher.Version,
@ -363,10 +376,15 @@ func (p *matrixProvider) GetHTMLReport(ctx sdk.ReportContext) (string, error) {
}
// Failed connections
for addr, ce := range r.ConnectionErrors {
errAddrs := make([]string, 0, len(r.ConnectionErrors))
for addr := range r.ConnectionErrors {
errAddrs = append(errAddrs, addr)
}
sort.Strings(errAddrs)
for _, addr := range errAddrs {
data.ConnectionErrors = append(data.ConnectionErrors, matrixConnErrData{
Address: addr,
Message: ce.Message,
Message: r.ConnectionErrors[addr].Message,
})
}

View file

@ -3,79 +3,54 @@ package checker
import (
"context"
"fmt"
"strings"
sdk "git.happydns.org/checker-sdk-go/checker"
)
// Rule returns a new matrix federation check rule.
func Rule() sdk.CheckRule {
return &matrixRule{}
// Rules returns the full list of CheckRules exposed by the Matrix checker.
// Each rule covers a single concern so the UI can show a clear checklist
// rather than a single monolithic pass/fail line.
func Rules() []sdk.CheckRule {
return []sdk.CheckRule{
&federationOKRule{},
&wellKnownRule{},
&srvRecordsRule{},
&connectionReachableRule{},
&tlsChecksRule{},
&versionRule{},
}
}
type matrixRule struct{}
func (r *matrixRule) Name() string {
return "matrix_federation"
}
func (r *matrixRule) Description() string {
return "Checks whether Matrix federation is working correctly"
}
func (r *matrixRule) ValidateOptions(opts sdk.CheckerOptions) error {
return nil
}
func (r *matrixRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, opts sdk.CheckerOptions) []sdk.CheckState {
// loadMatrixData fetches the Matrix observation. On error returns a
// CheckState the caller should emit to short-circuit its rule.
func loadMatrixData(ctx context.Context, obs sdk.ObservationGetter) (*MatrixFederationData, *sdk.CheckState) {
var data MatrixFederationData
if err := obs.Get(ctx, ObservationKeyMatrix, &data); err != nil {
return []sdk.CheckState{{
return nil, &sdk.CheckState{
Status: sdk.StatusError,
Message: fmt.Sprintf("Failed to get Matrix federation data: %v", err),
Code: "matrix_federation_error",
}}
}
domain, _ := opts["serviceDomain"].(string)
domain = strings.TrimSuffix(domain, ".")
if data.FederationOK {
version := strings.TrimSpace(data.Version.Name + " " + data.Version.Version)
return []sdk.CheckState{{
Status: sdk.StatusOK,
Message: fmt.Sprintf("Running %s", version),
Code: "matrix_federation_ok",
Meta: map[string]any{
"version": version,
},
}}
}
var statusLine string
if data.DNSResult.SRVError != nil && data.WellKnownResult.Result != "" {
statusLine = fmt.Sprintf("%s OR %s", data.DNSResult.SRVError.Message, data.WellKnownResult.Result)
} else if len(data.ConnectionErrors) > 0 {
var msg strings.Builder
for srv, cerr := range data.ConnectionErrors {
if msg.Len() > 0 {
msg.WriteString("; ")
}
msg.WriteString(srv)
msg.WriteString(": ")
msg.WriteString(cerr.Message)
Code: "matrix.observation_error",
}
statusLine = fmt.Sprintf("Connection errors: %s", msg.String())
} else if data.WellKnownResult.Server != domain {
statusLine = fmt.Sprintf("Bad homeserver_name: got %s, expected %s", data.WellKnownResult.Server, domain)
} else {
statusLine = fmt.Sprintf("Federation broken. Check https://federationtester.matrix.org/#%s", domain)
}
return []sdk.CheckState{{
Status: sdk.StatusCrit,
Message: statusLine,
Code: "matrix_federation_fail",
}}
return &data, nil
}
func passState(code, message string) sdk.CheckState {
return sdk.CheckState{Status: sdk.StatusOK, Message: message, Code: code}
}
func infoState(code, message string) sdk.CheckState {
return sdk.CheckState{Status: sdk.StatusInfo, Message: message, Code: code}
}
func warnState(code, message string) sdk.CheckState {
return sdk.CheckState{Status: sdk.StatusWarn, Message: message, Code: code}
}
func critState(code, message string) sdk.CheckState {
return sdk.CheckState{Status: sdk.StatusCrit, Message: message, Code: code}
}
func unknownState(code, message string) sdk.CheckState {
return sdk.CheckState{Status: sdk.StatusUnknown, Message: message, Code: code}
}

View file

@ -0,0 +1,47 @@
package checker
import (
"context"
"fmt"
"sort"
sdk "git.happydns.org/checker-sdk-go/checker"
)
// connectionReachableRule checks that every federation endpoint returned
// by DNS accepted the TLS connection the tester attempted.
type connectionReachableRule struct{}
func (r *connectionReachableRule) Name() string { return "matrix.connection_reachable" }
func (r *connectionReachableRule) Description() string {
return "Checks that every discovered federation endpoint accepts an inbound connection."
}
func (r *connectionReachableRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, _ sdk.CheckerOptions) []sdk.CheckState {
data, errSt := loadMatrixData(ctx, obs)
if errSt != nil {
return []sdk.CheckState{*errSt}
}
if len(data.ConnectionErrors) == 0 && len(data.ConnectionReports) == 0 {
return []sdk.CheckState{unknownState("matrix.connection_reachable.unknown", "No endpoint was probed by the federation tester.")}
}
if len(data.ConnectionErrors) == 0 {
return []sdk.CheckState{passState("matrix.connection_reachable.ok", fmt.Sprintf("All %d endpoint(s) accepted the connection.", len(data.ConnectionReports)))}
}
addrs := make([]string, 0, len(data.ConnectionErrors))
for addr := range data.ConnectionErrors {
addrs = append(addrs, addr)
}
sort.Strings(addrs)
out := make([]sdk.CheckState, 0, len(addrs))
for _, addr := range addrs {
st := critState("matrix.connection_reachable.fail", data.ConnectionErrors[addr].Message)
st.Subject = addr
out = append(out, st)
}
return out
}

View file

@ -0,0 +1,67 @@
package checker
import (
"context"
"fmt"
"sort"
"strings"
sdk "git.happydns.org/checker-sdk-go/checker"
)
// federationOKRule reflects the overall FederationOK flag reported by the
// Matrix Federation Tester. Other rules isolate specific concerns; this
// rule is the global verdict so callers get a single-line answer to
// "does this homeserver federate?".
type federationOKRule struct{}
func (r *federationOKRule) Name() string { return "matrix.federation_ok" }
func (r *federationOKRule) Description() string {
return "Reports the overall federation status returned by the Matrix Federation Tester."
}
func (r *federationOKRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, opts sdk.CheckerOptions) []sdk.CheckState {
data, errSt := loadMatrixData(ctx, obs)
if errSt != nil {
return []sdk.CheckState{*errSt}
}
domain, _ := opts["serviceDomain"].(string)
domain = strings.TrimSuffix(domain, ".")
if data.FederationOK {
version := strings.TrimSpace(data.Version.Name + " " + data.Version.Version)
st := passState("matrix.federation_ok.ok", "Matrix federation is working.")
if version != "" {
st.Message = fmt.Sprintf("Matrix federation is working (running %s).", version)
st.Meta = map[string]any{"version": version}
}
return []sdk.CheckState{st}
}
var statusLine string
switch {
case data.DNSResult.SRVError != nil && data.WellKnownResult.Result != "":
statusLine = fmt.Sprintf("%s; %s", data.DNSResult.SRVError.Message, data.WellKnownResult.Result)
case len(data.ConnectionErrors) > 0:
srvs := make([]string, 0, len(data.ConnectionErrors))
for srv := range data.ConnectionErrors {
srvs = append(srvs, srv)
}
sort.Strings(srvs)
var msg strings.Builder
for _, srv := range srvs {
if msg.Len() > 0 {
msg.WriteString("; ")
}
msg.WriteString(srv)
msg.WriteString(": ")
msg.WriteString(data.ConnectionErrors[srv].Message)
}
statusLine = fmt.Sprintf("Connection errors: %s", msg.String())
default:
statusLine = fmt.Sprintf("Federation broken. Check https://federationtester.matrix.org/#%s", domain)
}
return []sdk.CheckState{critState("matrix.federation_ok.fail", statusLine)}
}

48
checker/rules_srv.go Normal file
View file

@ -0,0 +1,48 @@
package checker
import (
"context"
"fmt"
sdk "git.happydns.org/checker-sdk-go/checker"
)
// srvRecordsRule checks _matrix-fed._tcp / _matrix._tcp SRV delegation: was
// the lookup successful, and does it yield at least one record (or was it
// legitimately skipped because of a CNAME/well-known path)?
type srvRecordsRule struct{}
func (r *srvRecordsRule) Name() string { return "matrix.srv_records" }
func (r *srvRecordsRule) Description() string {
return "Checks that the Matrix SRV lookup (_matrix-fed._tcp / _matrix._tcp) succeeded or was legitimately skipped."
}
func (r *srvRecordsRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, _ sdk.CheckerOptions) []sdk.CheckState {
data, errSt := loadMatrixData(ctx, obs)
if errSt != nil {
return []sdk.CheckState{*errSt}
}
dns := data.DNSResult
if dns.SRVError != nil {
return []sdk.CheckState{critState("matrix.srv_records.error", fmt.Sprintf("SRV lookup error: %s", dns.SRVError.Message))}
}
if dns.SRVSkipped {
msg := "SRV lookup skipped by the federation tester."
if dns.SRVCName != "" {
msg = fmt.Sprintf("SRV lookup skipped (CNAME: %s).", dns.SRVCName)
}
return []sdk.CheckState{unknownState("matrix.srv_records.skipped", msg)}
}
if len(dns.SRVRecords) == 0 {
return []sdk.CheckState{infoState(
"matrix.srv_records.absent",
"No Matrix SRV records published (federation may still work via well-known).",
)}
}
return []sdk.CheckState{passState("matrix.srv_records.ok", fmt.Sprintf("%d SRV record(s) published.", len(dns.SRVRecords)))}
}

178
checker/rules_test.go Normal file
View file

@ -0,0 +1,178 @@
package checker
import (
"context"
"encoding/json"
"strings"
"testing"
sdk "git.happydns.org/checker-sdk-go/checker"
)
type stubObs struct {
data *MatrixFederationData
err error
}
func (s stubObs) Get(_ context.Context, _ sdk.ObservationKey, dest any) error {
if s.err != nil {
return s.err
}
b, err := json.Marshal(s.data)
if err != nil {
return err
}
return json.Unmarshal(b, dest)
}
func (s stubObs) GetRelated(_ context.Context, _ sdk.ObservationKey) ([]sdk.RelatedObservation, error) {
return nil, nil
}
func eval(t *testing.T, rule sdk.CheckRule, data *MatrixFederationData, opts sdk.CheckerOptions) []sdk.CheckState {
t.Helper()
return rule.Evaluate(context.Background(), stubObs{data: data}, opts)
}
func TestFederationOKRulePass(t *testing.T) {
data := &MatrixFederationData{FederationOK: true}
data.Version.Name = "Synapse"
data.Version.Version = "1.100.0"
got := eval(t, &federationOKRule{}, data, sdk.CheckerOptions{"serviceDomain": "example.org."})
if len(got) != 1 || got[0].Status != sdk.StatusOK {
t.Fatalf("expected single OK state, got %+v", got)
}
if !strings.Contains(got[0].Message, "Synapse 1.100.0") {
t.Errorf("expected version in message, got %q", got[0].Message)
}
}
func TestFederationOKRuleFailDeterministicOrder(t *testing.T) {
data := &MatrixFederationData{}
data.ConnectionErrors = map[string]struct {
Message string `json:"Message"`
}{
"z.example:8448": {Message: "boom z"},
"a.example:8448": {Message: "boom a"},
"m.example:8448": {Message: "boom m"},
}
first := eval(t, &federationOKRule{}, data, nil)[0].Message
for range 5 {
if eval(t, &federationOKRule{}, data, nil)[0].Message != first {
t.Fatal("federation_ok message not stable across runs")
}
}
idxA := strings.Index(first, "a.example")
idxM := strings.Index(first, "m.example")
idxZ := strings.Index(first, "z.example")
if !(idxA < idxM && idxM < idxZ) {
t.Errorf("expected sorted order a<m<z, got %q", first)
}
}
func TestConnectionReachableUnknownWhenNoEndpoints(t *testing.T) {
got := eval(t, &connectionReachableRule{}, &MatrixFederationData{}, nil)
if len(got) != 1 || got[0].Status != sdk.StatusUnknown {
t.Fatalf("expected single Unknown state, got %+v", got)
}
}
func TestConnectionReachableSortedFailures(t *testing.T) {
data := &MatrixFederationData{}
data.ConnectionErrors = map[string]struct {
Message string `json:"Message"`
}{
"b:1": {Message: "b err"},
"a:1": {Message: "a err"},
}
got := eval(t, &connectionReachableRule{}, data, nil)
if len(got) != 2 {
t.Fatalf("expected 2 states, got %d", len(got))
}
if got[0].Subject != "a:1" || got[1].Subject != "b:1" {
t.Errorf("subjects not sorted: %q, %q", got[0].Subject, got[1].Subject)
}
}
func TestSRVRecordsSkipped(t *testing.T) {
data := &MatrixFederationData{}
data.DNSResult.SRVSkipped = true
data.DNSResult.SRVCName = "matrix.example.org."
got := eval(t, &srvRecordsRule{}, data, nil)
if len(got) != 1 || got[0].Status != sdk.StatusUnknown {
t.Fatalf("expected Unknown, got %+v", got)
}
}
func TestVersionRulePass(t *testing.T) {
data := &MatrixFederationData{}
data.Version.Name = "Dendrite"
data.Version.Version = "0.13.0"
got := eval(t, &versionRule{}, data, nil)
if len(got) != 1 || got[0].Status != sdk.StatusOK {
t.Fatalf("expected OK, got %+v", got)
}
}
func TestVersionRuleError(t *testing.T) {
data := &MatrixFederationData{}
data.Version.Error = "connection refused"
got := eval(t, &versionRule{}, data, nil)
if len(got) != 1 || got[0].Status != sdk.StatusWarn {
t.Fatalf("expected Warn, got %+v", got)
}
}
func TestWellKnownAbsent(t *testing.T) {
got := eval(t, &wellKnownRule{}, &MatrixFederationData{}, nil)
if len(got) != 1 || got[0].Status != sdk.StatusInfo {
t.Fatalf("expected Info, got %+v", got)
}
}
func TestTLSChecksDedupAndSorted(t *testing.T) {
data := &MatrixFederationData{}
data.ConnectionReports = map[string]struct {
Certificates []struct {
SubjectCommonName string `json:"SubjectCommonName"`
IssuerCommonName string `json:"IssuerCommonName"`
SHA256Fingerprint string `json:"SHA256Fingerprint"`
DNSNames []string `json:"DNSNames"`
} `json:"Certificates"`
Cipher struct {
Version string `json:"Version"`
CipherSuite string `json:"CipherSuite"`
} `json:"Cipher"`
Checks struct {
AllChecksOK bool `json:"AllChecksOK"`
MatchingServerName bool `json:"MatchingServerName"`
FutureValidUntilTS bool `json:"FutureValidUntilTS"`
HasEd25519Key bool `json:"HasEd25519Key"`
AllEd25519ChecksOK bool `json:"AllEd25519ChecksOK"`
ValidCertificates bool `json:"ValidCertificates"`
} `json:"Checks"`
Errors []string `json:"Errors"`
}{
"b:8448": {Errors: []string{"server name does not match certificate"}},
"a:8448": {Errors: []string{"server name does not match certificate", "server name does not match certificate"}},
}
got := eval(t, &tlsChecksRule{}, data, nil)
if len(got) != 2 {
t.Fatalf("expected 2 states, got %d", len(got))
}
if got[0].Subject != "a:8448" || got[1].Subject != "b:8448" {
t.Errorf("subjects not sorted: %q, %q", got[0].Subject, got[1].Subject)
}
if strings.Count(got[0].Message, "server name does not match certificate") != 1 {
t.Errorf("expected dedup, got %q", got[0].Message)
}
}
func TestLoadMatrixDataObservationError(t *testing.T) {
rule := &federationOKRule{}
got := rule.Evaluate(context.Background(), stubObs{err: context.Canceled}, nil)
if len(got) != 1 || got[0].Status != sdk.StatusError {
t.Fatalf("expected Error state, got %+v", got)
}
}

90
checker/rules_tls.go Normal file
View file

@ -0,0 +1,90 @@
package checker
import (
"context"
"fmt"
"sort"
"strings"
sdk "git.happydns.org/checker-sdk-go/checker"
)
// tlsChecksRule reviews the TLS-level findings the federation tester
// reports for every endpoint it managed to reach: certificate validity,
// matching server name, future expiry, presence of an Ed25519 key, and so
// on. One CheckState is emitted per reachable endpoint so the UI can pin
// the outcome on the exact address.
type tlsChecksRule struct{}
func (r *tlsChecksRule) Name() string { return "matrix.tls_checks" }
func (r *tlsChecksRule) Description() string {
return "Reviews the TLS posture on every reachable federation endpoint (certificate chain, hostname match, Ed25519 key, …)."
}
func (r *tlsChecksRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, _ sdk.CheckerOptions) []sdk.CheckState {
data, errSt := loadMatrixData(ctx, obs)
if errSt != nil {
return []sdk.CheckState{*errSt}
}
if len(data.ConnectionReports) == 0 {
return []sdk.CheckState{unknownState("matrix.tls_checks.skipped", "No endpoint reached: TLS posture could not be assessed.")}
}
addrs := make([]string, 0, len(data.ConnectionReports))
for addr := range data.ConnectionReports {
addrs = append(addrs, addr)
}
sort.Strings(addrs)
out := make([]sdk.CheckState, 0, len(addrs))
for _, addr := range addrs {
cr := data.ConnectionReports[addr]
var problems []string
seen := make(map[string]struct{})
add := func(p string) {
if p == "" {
return
}
if _, ok := seen[p]; ok {
return
}
seen[p] = struct{}{}
problems = append(problems, p)
}
if !cr.Checks.MatchingServerName {
add("server name does not match certificate")
}
if !cr.Checks.FutureValidUntilTS {
add("certificate expired or near expiry")
}
if !cr.Checks.ValidCertificates {
add("certificate chain is invalid")
}
if !cr.Checks.HasEd25519Key {
add("no Ed25519 signing key advertised")
}
if !cr.Checks.AllEd25519ChecksOK {
add("Ed25519 key verification failed")
}
for _, e := range cr.Errors {
add(e)
}
if len(problems) == 0 && cr.Checks.AllChecksOK {
st := passState("matrix.tls_checks.ok", "All TLS checks passed.")
st.Subject = addr
out = append(out, st)
continue
}
msg := "TLS checks failed."
if len(problems) > 0 {
msg = fmt.Sprintf("TLS checks failed: %s.", strings.Join(problems, "; "))
}
st := critState("matrix.tls_checks.fail", msg)
st.Subject = addr
out = append(out, st)
}
return out
}

40
checker/rules_version.go Normal file
View file

@ -0,0 +1,40 @@
package checker
import (
"context"
"fmt"
"strings"
sdk "git.happydns.org/checker-sdk-go/checker"
)
// versionRule reports whether the federation tester could fetch the
// homeserver version string. The test probe reaches /_matrix/federation/v1/version,
// so a failure here hints at a federation-path problem even when the rest
// of the federation handshake looks healthy.
type versionRule struct{}
func (r *versionRule) Name() string { return "matrix.version" }
func (r *versionRule) Description() string {
return "Checks that the homeserver responds to /_matrix/federation/v1/version and reports its name and version."
}
func (r *versionRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, _ sdk.CheckerOptions) []sdk.CheckState {
data, errSt := loadMatrixData(ctx, obs)
if errSt != nil {
return []sdk.CheckState{*errSt}
}
if data.Version.Error != "" {
return []sdk.CheckState{warnState("matrix.version.error", fmt.Sprintf("Homeserver /version probe failed: %s", data.Version.Error))}
}
version := strings.TrimSpace(data.Version.Name + " " + data.Version.Version)
if version == "" {
return []sdk.CheckState{infoState("matrix.version.unknown", "Homeserver did not return a version string.")}
}
st := passState("matrix.version.ok", fmt.Sprintf("Homeserver running %s.", version))
st.Meta = map[string]any{"version": version}
return []sdk.CheckState{st}
}

View file

@ -0,0 +1,43 @@
package checker
import (
"context"
"fmt"
"strings"
sdk "git.happydns.org/checker-sdk-go/checker"
)
// wellKnownRule checks the /.well-known/matrix/server delegation: was a
// delegation published, did it resolve, and does it point back at the
// expected server_name?
type wellKnownRule struct{}
func (r *wellKnownRule) Name() string { return "matrix.well_known" }
func (r *wellKnownRule) Description() string {
return "Checks that /.well-known/matrix/server (if published) is valid and points at the expected server_name."
}
func (r *wellKnownRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, opts sdk.CheckerOptions) []sdk.CheckState {
data, errSt := loadMatrixData(ctx, obs)
if errSt != nil {
return []sdk.CheckState{*errSt}
}
wk := data.WellKnownResult
// Nothing published: the host may rely on SRV only. Mark informational.
if wk.Server == "" && wk.Result == "" {
return []sdk.CheckState{infoState("matrix.well_known.absent", "No /.well-known/matrix/server delegation published (federation may still work via SRV).")}
}
// Published but the tester flagged an error string.
if wk.Server == "" && wk.Result != "" {
if strings.Contains(strings.ToLower(wk.Result), "no .well-known") {
return []sdk.CheckState{unknownState("matrix.well_known.absent", "No /.well-known/matrix/server delegation found (federation may still work via SRV).")}
}
return []sdk.CheckState{critState("matrix.well_known.error", fmt.Sprintf("Well-known delegation error: %s", wk.Result))}
}
return []sdk.CheckState{passState("matrix.well_known.ok", fmt.Sprintf("Well-known delegation resolves to %s.", wk.Server))}
}

2
go.mod
View file

@ -2,4 +2,4 @@ module git.happydns.org/checker-matrix
go 1.25.0
require git.happydns.org/checker-sdk-go v1.2.0
require git.happydns.org/checker-sdk-go v1.3.0

4
go.sum
View file

@ -1,2 +1,2 @@
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-sdk-go v1.3.0 h1:FG2kIhlJCzI0m35EhxSgn4UWc9M4ha6aZTeoChu4l7A=
git.happydns.org/checker-sdk-go v1.3.0/go.mod h1:aNAcfYFfbhvH9kJhE0Njp5GX0dQbxdRB0rJ0KvSC5nI=

View file

@ -5,7 +5,7 @@ import (
"log"
matrix "git.happydns.org/checker-matrix/checker"
sdk "git.happydns.org/checker-sdk-go/checker"
"git.happydns.org/checker-sdk-go/checker/server"
)
// Version is the standalone binary's version. It defaults to "custom-build"
@ -23,8 +23,8 @@ func main() {
// CheckerDefinition.Version.
matrix.Version = Version
server := sdk.NewServer(matrix.Provider())
if err := server.ListenAndServe(*listenAddr); err != nil {
srv := server.New(matrix.Provider())
if err := srv.ListenAndServe(*listenAddr); err != nil {
log.Fatalf("server error: %v", err)
}
}