diff --git a/.drone-manifest.yml b/.drone-manifest.yml new file mode 100644 index 0000000..1046193 --- /dev/null +++ b/.drone-manifest.yml @@ -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 diff --git a/.drone.yml b/.drone.yml new file mode 100644 index 0000000..142d61e --- /dev/null +++ b/.drone.yml @@ -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 diff --git a/Dockerfile b/Dockerfile index 447bd68..a56c3b0 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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"] diff --git a/Makefile b/Makefile index faf351c..6a80145 100644 --- a/Makefile +++ b/Makefile @@ -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 diff --git a/README.md b/README.md index 7be7c78..66e5041 100644 --- a/README.md +++ b/README.md @@ -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 | diff --git a/checker/collect.go b/checker/collect.go index 9bc6340..e3175c4 100644 --- a/checker/collect.go +++ b/checker/collect.go @@ -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) } diff --git a/checker/collect_test.go b/checker/collect_test.go new file mode 100644 index 0000000..b5d4a4b --- /dev/null +++ b/checker/collect_test.go @@ -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") + } +} diff --git a/checker/definition.go b/checker/definition.go index 4e5ec7c..cc296fe 100644 --- a/checker/definition.go +++ b/checker/definition.go @@ -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, diff --git a/checker/interactive.go b/checker/interactive.go index 4c00bd5..9b35b79 100644 --- a/checker/interactive.go +++ b/checker/interactive.go @@ -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{ diff --git a/checker/provider.go b/checker/provider.go index de7a43d..111b30c 100644 --- a/checker/provider.go +++ b/checker/provider.go @@ -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() -} diff --git a/checker/report.go b/checker/report.go index 7952ae0..ad88a39 100644 --- a/checker/report.go +++ b/checker/report.go @@ -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, }) } diff --git a/checker/rule.go b/checker/rule.go index 8184137..8506856 100644 --- a/checker/rule.go +++ b/checker/rule.go @@ -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} } diff --git a/checker/rules_connection.go b/checker/rules_connection.go new file mode 100644 index 0000000..4cfa7ba --- /dev/null +++ b/checker/rules_connection.go @@ -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 +} diff --git a/checker/rules_federation.go b/checker/rules_federation.go new file mode 100644 index 0000000..60b8e34 --- /dev/null +++ b/checker/rules_federation.go @@ -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)} +} diff --git a/checker/rules_srv.go b/checker/rules_srv.go new file mode 100644 index 0000000..e42de47 --- /dev/null +++ b/checker/rules_srv.go @@ -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)))} +} diff --git a/checker/rules_test.go b/checker/rules_test.go new file mode 100644 index 0000000..c865d58 --- /dev/null +++ b/checker/rules_test.go @@ -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 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 +} diff --git a/checker/rules_version.go b/checker/rules_version.go new file mode 100644 index 0000000..552dd68 --- /dev/null +++ b/checker/rules_version.go @@ -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} +} diff --git a/checker/rules_wellknown.go b/checker/rules_wellknown.go new file mode 100644 index 0000000..163693d --- /dev/null +++ b/checker/rules_wellknown.go @@ -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))} +} diff --git a/go.mod b/go.mod index 8c5c78c..53001e2 100644 --- a/go.mod +++ b/go.mod @@ -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 diff --git a/go.sum b/go.sum index 272600a..c389c68 100644 --- a/go.sum +++ b/go.sum @@ -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= diff --git a/main.go b/main.go index 2d19c41..22aae3a 100644 --- a/main.go +++ b/main.go @@ -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) } } diff --git a/plugin/plugin.go b/plugin/plugin.go index 3ce631f..c0dc6dd 100644 --- a/plugin/plugin.go +++ b/plugin/plugin.go @@ -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 }