Compare commits

..

No commits in common. "master" and "v0.1.0" have entirely different histories.

23 changed files with 89 additions and 925 deletions

View file

@ -1,22 +0,0 @@
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

View file

@ -1,187 +0,0 @@
---
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,13 +6,9 @@ WORKDIR /src
COPY go.mod go.sum ./ COPY go.mod go.sum ./
RUN go mod download RUN go mod download
COPY . . COPY . .
RUN CGO_ENABLED=0 go build -tags standalone -ldflags "-X main.Version=${CHECKER_VERSION}" -o /checker-matrix . RUN CGO_ENABLED=0 go build -ldflags "-X main.Version=${CHECKER_VERSION}" -o /checker-matrix .
FROM scratch FROM scratch
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ca-certificates.crt
COPY --from=builder /checker-matrix /checker-matrix COPY --from=builder /checker-matrix /checker-matrix
USER 65534:65534
EXPOSE 8080 EXPOSE 8080
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD ["/checker-matrix", "-healthcheck"]
ENTRYPOINT ["/checker-matrix"] ENTRYPOINT ["/checker-matrix"]

View file

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

View file

@ -54,17 +54,6 @@ Set the `endpoint` admin option for the `matrixim` checker to the URL of
the running checker-matrix server (e.g., `http://checker-matrix:8080`). the running checker-matrix server (e.g., `http://checker-matrix:8080`).
happyDomain will delegate observation collection to this endpoint. 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 ## Options
| Scope | Id | Description | | Scope | Id | Description |

View file

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

View file

@ -1,91 +0,0 @@
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" var Version = "built-in"
// Definition returns the CheckerDefinition for the matrix federation checker. // Definition returns the CheckerDefinition for the matrix federation checker.
func (p *matrixProvider) Definition() *sdk.CheckerDefinition { func Definition() *sdk.CheckerDefinition {
return &sdk.CheckerDefinition{ return &sdk.CheckerDefinition{
ID: "matrixim", ID: "matrixim",
Name: "Matrix Federation Tester", Name: "Matrix Federation Tester",
@ -50,7 +50,9 @@ func (p *matrixProvider) Definition() *sdk.CheckerDefinition {
}, },
}, },
}, },
Rules: Rules(), Rules: []sdk.CheckRule{
Rule(),
},
Interval: &sdk.CheckIntervalSpec{ Interval: &sdk.CheckIntervalSpec{
Min: 5 * time.Minute, Min: 5 * time.Minute,
Max: 7 * 24 * time.Hour, Max: 7 * 24 * time.Hour,

View file

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

View file

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

View file

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

View file

@ -3,54 +3,79 @@ package checker
import ( import (
"context" "context"
"fmt" "fmt"
"strings"
sdk "git.happydns.org/checker-sdk-go/checker" sdk "git.happydns.org/checker-sdk-go/checker"
) )
// Rules returns the full list of CheckRules exposed by the Matrix checker. // Rule returns a new matrix federation check rule.
// Each rule covers a single concern so the UI can show a clear checklist func Rule() sdk.CheckRule {
// rather than a single monolithic pass/fail line. return &matrixRule{}
func Rules() []sdk.CheckRule {
return []sdk.CheckRule{
&federationOKRule{},
&wellKnownRule{},
&srvRecordsRule{},
&connectionReachableRule{},
&tlsChecksRule{},
&versionRule{},
}
} }
// loadMatrixData fetches the Matrix observation. On error returns a type matrixRule struct{}
// CheckState the caller should emit to short-circuit its rule.
func loadMatrixData(ctx context.Context, obs sdk.ObservationGetter) (*MatrixFederationData, *sdk.CheckState) { 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 {
var data MatrixFederationData var data MatrixFederationData
if err := obs.Get(ctx, ObservationKeyMatrix, &data); err != nil { if err := obs.Get(ctx, ObservationKeyMatrix, &data); err != nil {
return nil, &sdk.CheckState{ return []sdk.CheckState{{
Status: sdk.StatusError, Status: sdk.StatusError,
Message: fmt.Sprintf("Failed to get Matrix federation data: %v", err), Message: fmt.Sprintf("Failed to get Matrix federation data: %v", err),
Code: "matrix.observation_error", 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,
},
}}
} }
return &data, nil
}
func passState(code, message string) sdk.CheckState { var statusLine string
return sdk.CheckState{Status: sdk.StatusOK, Message: message, Code: code}
}
func infoState(code, message string) sdk.CheckState { if data.DNSResult.SRVError != nil && data.WellKnownResult.Result != "" {
return sdk.CheckState{Status: sdk.StatusInfo, Message: message, Code: code} 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)
}
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)
}
func warnState(code, message string) sdk.CheckState { return []sdk.CheckState{{
return sdk.CheckState{Status: sdk.StatusWarn, Message: message, Code: code} Status: sdk.StatusCrit,
} Message: statusLine,
Code: "matrix_federation_fail",
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

@ -1,47 +0,0 @@
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

@ -1,67 +0,0 @@
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)}
}

View file

@ -1,48 +0,0 @@
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)))}
}

View file

@ -1,178 +0,0 @@
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)
}
}

View file

@ -1,90 +0,0 @@
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
}

View file

@ -1,40 +0,0 @@
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

@ -1,43 +0,0 @@
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 go 1.25.0
require git.happydns.org/checker-sdk-go v1.5.0 require git.happydns.org/checker-sdk-go v1.2.0

4
go.sum
View file

@ -1,2 +1,2 @@
git.happydns.org/checker-sdk-go v1.5.0 h1:5uD5Cm6xJ+lwnhbJ09iCXGHbYS9zRh+Yh0NeBHkAPBY= git.happydns.org/checker-sdk-go v1.2.0 h1:v4MpKAz0W3PwP+bxx3pya8w893sVH5xTD1of1cc0TV8=
git.happydns.org/checker-sdk-go v1.5.0/go.mod h1:aNAcfYFfbhvH9kJhE0Njp5GX0dQbxdRB0rJ0KvSC5nI= git.happydns.org/checker-sdk-go v1.2.0/go.mod h1:aNAcfYFfbhvH9kJhE0Njp5GX0dQbxdRB0rJ0KvSC5nI=

View file

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

View file

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