Compare commits

...

8 commits

Author SHA1 Message Date
86b5207a8f Add CI/CD pipeline
All checks were successful
continuous-integration/drone/push Build is passing
2026-05-10 19:16:15 +08:00
4079a92868 Include rules section 2026-04-30 08:55:18 +07:00
8a62846170 docker: add HEALTHCHECK probing /health
The binary doubles as its own healthcheck client via the SDK's
-healthcheck flag, so the probe works in the scratch image
(no shell, no curl, no wget).
2026-04-26 11:01:37 +07:00
2af16d3ab9 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 04:13:55 +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
23 changed files with 925 additions and 89 deletions

22
.drone-manifest.yml Normal file
View file

@ -0,0 +1,22 @@
image: happydomain/checker-matrix:{{#if build.tag}}{{trimPrefix "v" build.tag}}{{else}}latest{{/if}}
{{#if build.tags}}
tags:
{{#each build.tags}}
- {{this}}
{{/each}}
{{/if}}
manifests:
- image: happydomain/checker-matrix:{{#if build.tag}}{{trimPrefix "v" build.tag}}-{{/if}}linux-amd64
platform:
architecture: amd64
os: linux
- image: happydomain/checker-matrix:{{#if build.tag}}{{trimPrefix "v" build.tag}}-{{/if}}linux-arm64
platform:
architecture: arm64
os: linux
variant: v8
- image: happydomain/checker-matrix:{{#if build.tag}}{{trimPrefix "v" build.tag}}-{{/if}}linux-arm
platform:
architecture: arm
os: linux
variant: v7

187
.drone.yml Normal file
View file

@ -0,0 +1,187 @@
---
kind: pipeline
type: docker
name: build-amd64
platform:
os: linux
arch: amd64
steps:
- name: checker build
image: golang:1-alpine
commands:
- apk add --no-cache git make
- make
environment:
CHECKER_VERSION: "${DRONE_BRANCH}-${DRONE_COMMIT}"
CGO_ENABLED: 0
when:
event:
exclude:
- tag
- name: checker build tag
image: golang:1-alpine
commands:
- apk add --no-cache git make
- make
environment:
CHECKER_VERSION: "${DRONE_SEMVER}"
CGO_ENABLED: 0
when:
event:
- tag
- name: publish on Docker Hub
image: plugins/docker
settings:
repo: happydomain/checker-matrix
auto_tag: true
auto_tag_suffix: ${DRONE_STAGE_OS}-${DRONE_STAGE_ARCH}
dockerfile: Dockerfile
build_args:
- CHECKER_VERSION=${DRONE_BRANCH}-${DRONE_COMMIT}
username:
from_secret: docker_username
password:
from_secret: docker_password
when:
event:
exclude:
- tag
- name: publish on Docker Hub (tag)
image: plugins/docker
settings:
repo: happydomain/checker-matrix
auto_tag: true
auto_tag_suffix: ${DRONE_STAGE_OS}-${DRONE_STAGE_ARCH}
dockerfile: Dockerfile
build_args:
- CHECKER_VERSION=${DRONE_SEMVER}
username:
from_secret: docker_username
password:
from_secret: docker_password
when:
event:
- tag
trigger:
branch:
exclude:
- renovate/*
event:
- cron
- push
- tag
---
kind: pipeline
type: docker
name: build-arm64
platform:
os: linux
arch: arm64
steps:
- name: checker build
image: golang:1-alpine
commands:
- apk add --no-cache git make
- make
environment:
CHECKER_VERSION: "${DRONE_BRANCH}-${DRONE_COMMIT}"
CGO_ENABLED: 0
when:
event:
exclude:
- tag
- name: checker build tag
image: golang:1-alpine
commands:
- apk add --no-cache git make
- make
environment:
CHECKER_VERSION: "${DRONE_SEMVER}"
CGO_ENABLED: 0
when:
event:
- tag
- name: publish on Docker Hub
image: plugins/docker
settings:
repo: happydomain/checker-matrix
auto_tag: true
auto_tag_suffix: ${DRONE_STAGE_OS}-${DRONE_STAGE_ARCH}
dockerfile: Dockerfile
build_args:
- CHECKER_VERSION=${DRONE_BRANCH}-${DRONE_COMMIT}
username:
from_secret: docker_username
password:
from_secret: docker_password
when:
event:
exclude:
- tag
- name: publish on Docker Hub (tag)
image: plugins/docker
settings:
repo: happydomain/checker-matrix
auto_tag: true
auto_tag_suffix: ${DRONE_STAGE_OS}-${DRONE_STAGE_ARCH}
dockerfile: Dockerfile
build_args:
- CHECKER_VERSION=${DRONE_SEMVER}
username:
from_secret: docker_username
password:
from_secret: docker_password
when:
event:
- tag
trigger:
event:
- cron
- push
- tag
---
kind: pipeline
name: docker-manifest
platform:
os: linux
arch: arm64
steps:
- name: publish on Docker Hub
image: plugins/manifest
settings:
auto_tag: true
ignore_missing: true
spec: .drone-manifest.yml
username:
from_secret: docker_username
password:
from_secret: docker_password
trigger:
branch:
exclude:
- renovate/*
event:
- cron
- push
- tag
depends_on:
- build-amd64
- build-arm64

View file

@ -6,9 +6,13 @@ 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
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD ["/checker-matrix", "-healthcheck"]
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

@ -54,6 +54,17 @@ Set the `endpoint` admin option for the `matrixim` checker to the URL of
the running checker-matrix server (e.g., `http://checker-matrix:8080`).
happyDomain will delegate observation collection to this endpoint.
## Rules
| Code | Description | Severity |
|------------------------------|---------------------------------------------------------------------------------------------------|---------------------|
| `matrix.connection_reachable`| Checks that every discovered federation endpoint accepts an inbound connection. | CRITICAL |
| `matrix.federation_ok` | Reports the overall federation status returned by the Matrix Federation Tester. | CRITICAL |
| `matrix.srv_records` | Checks that the Matrix SRV lookup (`_matrix-fed._tcp` / `_matrix._tcp`) succeeded or was skipped. | CRITICAL |
| `matrix.tls_checks` | Reviews the TLS posture on every reachable federation endpoint (chain, hostname, Ed25519 key). | CRITICAL |
| `matrix.version` | Checks that the homeserver responds to `/_matrix/federation/v1/version` with name and version. | WARNING |
| `matrix.well_known` | Checks that `/.well-known/matrix/server` (if published) is valid and points at the server_name. | CRITICAL |
## Options
| Scope | Id | Description |

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

@ -17,7 +17,7 @@ import (
var Version = "built-in"
// Definition returns the CheckerDefinition for the matrix federation checker.
func Definition() *sdk.CheckerDefinition {
func (p *matrixProvider) Definition() *sdk.CheckerDefinition {
return &sdk.CheckerDefinition{
ID: "matrixim",
Name: "Matrix Federation Tester",
@ -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

@ -14,8 +14,3 @@ type matrixProvider struct{}
func (p *matrixProvider) Key() sdk.ObservationKey {
return ObservationKeyMatrix
}
// Definition implements sdk.CheckerDefinitionProvider.
func (p *matrixProvider) Definition() *sdk.CheckerDefinition {
return Definition()
}

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.5.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.5.0 h1:5uD5Cm6xJ+lwnhbJ09iCXGHbYS9zRh+Yh0NeBHkAPBY=
git.happydns.org/checker-sdk-go v1.5.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)
}
}

View file

@ -20,5 +20,6 @@ var Version = "custom-build"
// that the host will register in its global registries.
func NewCheckerPlugin() (*sdk.CheckerDefinition, sdk.ObservationProvider, error) {
matrix.Version = Version
return matrix.Definition(), matrix.Provider(), nil
prvd := matrix.Provider()
return prvd.(sdk.CheckerDefinitionProvider).Definition(), prvd, nil
}