Initial commit
CalDAV and CardDAV checkers sharing a single Go module. Discovery follows RFC 6764 (/.well-known + SRV/TXT), authenticated probes cover principal, home-set, collections and a minimal REPORT query on top of go-webdav. Common shape in internal/dav/; CalDAV adds a scheduling rule. Surfaces its context URL (and each secure-SRV target) as TLS endpoints via the EndpointDiscoverer interface, so the dedicated TLS checker can pick them up without re-parsing observations. HTML report foregrounds common misconfigs (well-known returning 200, missing SRV, plaintext-only SRV, missing DAV capability, skipped auth phase) as action-item callouts before the full phase breakdown.
This commit is contained in:
commit
7eb0dbddc7
39 changed files with 3324 additions and 0 deletions
4
.gitignore
vendored
Normal file
4
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
checker-caldav
|
||||
checker-caldav.so
|
||||
checker-carddav
|
||||
checker-carddav.so
|
||||
15
Dockerfile
Normal file
15
Dockerfile
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
FROM golang:1.25-alpine AS builder
|
||||
|
||||
ARG CHECKER_VERSION=custom-build
|
||||
ARG TARGET=checker-caldav
|
||||
|
||||
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 ./cmd/${TARGET}
|
||||
|
||||
FROM scratch
|
||||
COPY --from=builder /checker /checker
|
||||
EXPOSE 8080
|
||||
ENTRYPOINT ["/checker"]
|
||||
21
LICENSE
Normal file
21
LICENSE
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
MIT License
|
||||
|
||||
Copyright (c) 2026 The happyDomain Authors
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the “Software”), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
37
Makefile
Normal file
37
Makefile
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
CHECKER_VERSION ?= custom-build
|
||||
GO_LDFLAGS := -X main.Version=$(CHECKER_VERSION)
|
||||
|
||||
BINARIES := checker-caldav checker-carddav
|
||||
PLUGINS := $(addsuffix .so,$(BINARIES))
|
||||
|
||||
.PHONY: all test clean plugin docker docker-caldav docker-carddav $(BINARIES)
|
||||
|
||||
all: $(BINARIES)
|
||||
|
||||
checker-caldav:
|
||||
go build -ldflags "$(GO_LDFLAGS)" -o $@ ./cmd/checker-caldav
|
||||
|
||||
checker-carddav:
|
||||
go build -ldflags "$(GO_LDFLAGS)" -o $@ ./cmd/checker-carddav
|
||||
|
||||
plugin: $(PLUGINS)
|
||||
|
||||
checker-caldav.so:
|
||||
go build -buildmode=plugin -ldflags "$(GO_LDFLAGS)" -o $@ ./plugin/caldav
|
||||
|
||||
checker-carddav.so:
|
||||
go build -buildmode=plugin -ldflags "$(GO_LDFLAGS)" -o $@ ./plugin/carddav
|
||||
|
||||
test:
|
||||
go test ./...
|
||||
|
||||
docker: docker-caldav docker-carddav
|
||||
|
||||
docker-caldav:
|
||||
docker build --build-arg TARGET=checker-caldav --build-arg CHECKER_VERSION=$(CHECKER_VERSION) -t happydomain/checker-caldav .
|
||||
|
||||
docker-carddav:
|
||||
docker build --build-arg TARGET=checker-carddav --build-arg CHECKER_VERSION=$(CHECKER_VERSION) -t happydomain/checker-carddav .
|
||||
|
||||
clean:
|
||||
rm -f $(BINARIES) $(PLUGINS)
|
||||
26
NOTICE
Normal file
26
NOTICE
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
checker-dummy
|
||||
Copyright (c) 2026 The happyDomain Authors
|
||||
|
||||
This product is licensed under the MIT License (see LICENSE).
|
||||
|
||||
-------------------------------------------------------------------------------
|
||||
Third-party notices
|
||||
-------------------------------------------------------------------------------
|
||||
|
||||
This product includes software developed as part of the checker-sdk-go
|
||||
project (https://git.happydns.org/happyDomain/checker-sdk-go), licensed
|
||||
under the Apache License, Version 2.0:
|
||||
|
||||
checker-sdk-go
|
||||
Copyright 2020-2026 The happyDomain Authors
|
||||
|
||||
This product includes software developed as part of the happyDomain
|
||||
project (https://happydomain.org).
|
||||
|
||||
Portions of this code were originally written for the happyDomain
|
||||
server (licensed under AGPL-3.0 and a commercial license) and are
|
||||
made available there under the Apache License, Version 2.0 to enable
|
||||
a permissively licensed ecosystem of checker plugins.
|
||||
|
||||
You may obtain a copy of the Apache License 2.0 at:
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
75
README.md
Normal file
75
README.md
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
# checker-dav
|
||||
|
||||
happyDomain checkers for **CalDAV** (RFC 4791) and **CardDAV** (RFC 6352)
|
||||
servers. Discovery (RFC 6764) + OPTIONS + authenticated PROPFIND/REPORT
|
||||
probes, with an opinionated HTML report that foregrounds common misconfigs.
|
||||
|
||||
Two binaries are produced from this module:
|
||||
|
||||
| Binary | Checker ID | Entrypoint |
|
||||
|------------------|------------|---------------------------------|
|
||||
| `checker-caldav` | `caldav` | `./cmd/checker-caldav` |
|
||||
| `checker-carddav`| `carddav` | `./cmd/checker-carddav` |
|
||||
|
||||
Shared code lives in `internal/dav/`: discovery, OPTIONS parsing, raw-XML
|
||||
PROPFIND helpers, the rule set, and the HTML template.
|
||||
|
||||
## Build
|
||||
|
||||
```
|
||||
make # builds both binaries
|
||||
make checker-caldav # one binary
|
||||
make plugin # .so plugins for in-process loading
|
||||
make docker # both Docker images
|
||||
make test # unit tests
|
||||
```
|
||||
|
||||
## Run
|
||||
|
||||
```
|
||||
./checker-caldav -listen :8080
|
||||
```
|
||||
|
||||
The SDK exposes `/definition`, `/collect`, `/evaluate`, `/report`, and
|
||||
`/health` as usual. Pass `Accept: text/html` on `/report` to get the HTML
|
||||
view; the default is a JSON metrics dump.
|
||||
|
||||
## Options
|
||||
|
||||
Both checkers accept the same options:
|
||||
|
||||
- `domain_name` (auto-filled) — required
|
||||
- `username`, `password` — optional Basic credentials; unlock authenticated
|
||||
checks (principal, home-set, collections, REPORT probe)
|
||||
- `context_url` — optional explicit override, bypasses `/.well-known` + SRV
|
||||
- `timeout_seconds` — per-request HTTP timeout, default 10
|
||||
|
||||
## What is checked
|
||||
|
||||
1. **Discovery** — `/.well-known/{caldav,carddav}` (must 3xx, not 200),
|
||||
`_caldavs._tcp` / `_carddavs._tcp` SRV, TXT `path=` hint.
|
||||
2. **Transport** — HTTPS reachable. TLS certificate validation is
|
||||
deliberately out of scope — a dedicated TLS checker covers that.
|
||||
3. **OPTIONS** — `DAV:` advertises `calendar-access` or `addressbook`; Allow
|
||||
includes `PROPFIND` and `REPORT`; auth schemes captured for info.
|
||||
4. **Principal** — PROPFIND `current-user-principal` (auth required).
|
||||
5. **Home-set** — `calendar-home-set` / `addressbook-home-set`.
|
||||
6. **Collections** — enumerate, record properties (`supported-calendar-component-set`,
|
||||
`supported-address-data`, display name, description, max size).
|
||||
7. **REPORT probe** — issue a minimal `calendar-query` / `addressbook-query`
|
||||
against the first collection.
|
||||
8. **Scheduling** (CalDAV only) — if `calendar-schedule` is advertised,
|
||||
verify `schedule-inbox-URL` and `schedule-outbox-URL` on the principal.
|
||||
|
||||
The HTML report surfaces the most common failures at the top as callouts:
|
||||
|
||||
- `/.well-known` returns 200 instead of 301/302
|
||||
- No SRV and no well-known → service unreachable
|
||||
- Plaintext SRV record without secure counterpart
|
||||
- Server does not advertise the required DAV class (wrong endpoint or stripping proxy)
|
||||
- No credentials supplied → authenticated phase skipped
|
||||
|
||||
## Dependencies
|
||||
|
||||
- [`github.com/emersion/go-webdav`](https://github.com/emersion/go-webdav) — CalDAV/CardDAV client
|
||||
- [`git.happydns.org/checker-sdk-go`](https://git.happydns.org/happyDomain/checker-sdk-go) — checker SDK
|
||||
149
caldav/collect.go
Normal file
149
caldav/collect.go
Normal file
|
|
@ -0,0 +1,149 @@
|
|||
package caldav
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"git.happydns.org/checker-dav/internal/dav"
|
||||
sdk "git.happydns.org/checker-sdk-go/checker"
|
||||
webdav "github.com/emersion/go-webdav"
|
||||
"github.com/emersion/go-webdav/caldav"
|
||||
)
|
||||
|
||||
// Collect runs the full CalDAV probe pipeline for the target domain.
|
||||
//
|
||||
// The pipeline is deliberately resilient: every phase records its outcome into
|
||||
// the Observation and we keep going as long as we have something to probe
|
||||
// with. Rules later translate the captured state into CheckStates.
|
||||
func (p *caldavProvider) Collect(ctx context.Context, opts sdk.CheckerOptions) (any, error) {
|
||||
domain, _ := sdk.GetOption[string](opts, "domain_name")
|
||||
user, _ := sdk.GetOption[string](opts, "username")
|
||||
pass, _ := sdk.GetOption[string](opts, "password")
|
||||
explicit, _ := sdk.GetOption[string](opts, "context_url")
|
||||
timeoutSec := sdk.GetFloatOption(opts, "timeout_seconds", 10)
|
||||
|
||||
timeout := time.Duration(timeoutSec * float64(time.Second))
|
||||
if timeout <= 0 {
|
||||
timeout = 10 * time.Second
|
||||
}
|
||||
|
||||
obs := &dav.Observation{
|
||||
Kind: dav.KindCalDAV,
|
||||
Domain: domain,
|
||||
HasCredentials: user != "" && pass != "",
|
||||
CollectedAt: time.Now(),
|
||||
Scheduling: &dav.SchedulingResult{},
|
||||
}
|
||||
|
||||
anonClient := dav.NewHTTPClient(timeout)
|
||||
|
||||
// Phase 1 — Discovery
|
||||
obs.Discovery = dav.Discover(ctx, anonClient, dav.KindCalDAV, domain, explicit)
|
||||
if obs.Discovery.ContextURL == "" {
|
||||
return obs, nil
|
||||
}
|
||||
|
||||
// Phase 2 — Transport + OPTIONS (no auth required)
|
||||
optsRes, err := dav.ProbeOptions(ctx, anonClient, obs.Discovery.ContextURL)
|
||||
obs.Options = optsRes
|
||||
if err != nil {
|
||||
obs.Transport = dav.TransportResult{Error: err.Error()}
|
||||
return obs, nil
|
||||
}
|
||||
obs.Transport = dav.TransportResult{Reached: true}
|
||||
obs.Scheduling.Advertised = optsRes.HasCapability("calendar-schedule")
|
||||
|
||||
// Phase 3 — Authenticated probes
|
||||
if !obs.HasCredentials {
|
||||
obs.Principal.Skipped = true
|
||||
obs.HomeSet.Skipped = true
|
||||
obs.Collections.Skipped = true
|
||||
obs.Report.Skipped = true
|
||||
return obs, nil
|
||||
}
|
||||
|
||||
authClient := dav.WithBasicAuth(anonClient, user, pass)
|
||||
|
||||
// Principal.
|
||||
principal, err := dav.FindPrincipal(ctx, authClient, obs.Discovery.ContextURL)
|
||||
if err != nil {
|
||||
obs.Principal.Error = err.Error()
|
||||
obs.HomeSet.Skipped = true
|
||||
obs.Collections.Skipped = true
|
||||
obs.Report.Skipped = true
|
||||
return obs, nil
|
||||
}
|
||||
obs.Principal.URL = principal
|
||||
|
||||
// Home-set (via go-webdav's CalDAV client).
|
||||
cal, err := caldav.NewClient(asHTTPClient(authClient), obs.Discovery.ContextURL)
|
||||
if err != nil {
|
||||
obs.HomeSet.Error = err.Error()
|
||||
obs.Collections.Skipped = true
|
||||
obs.Report.Skipped = true
|
||||
return obs, nil
|
||||
}
|
||||
home, err := cal.FindCalendarHomeSet(ctx, principal)
|
||||
if err != nil {
|
||||
obs.HomeSet.Error = err.Error()
|
||||
obs.Collections.Skipped = true
|
||||
obs.Report.Skipped = true
|
||||
return obs, nil
|
||||
}
|
||||
obs.HomeSet.URL = home
|
||||
|
||||
// Collections.
|
||||
calendars, err := cal.FindCalendars(ctx, home)
|
||||
if err != nil {
|
||||
obs.Collections.Error = err.Error()
|
||||
obs.Report.Skipped = true
|
||||
} else {
|
||||
for _, c := range calendars {
|
||||
obs.Collections.Items = append(obs.Collections.Items, dav.CollectionInfo{
|
||||
Path: c.Path,
|
||||
Name: c.Name,
|
||||
Description: c.Description,
|
||||
MaxResourceSize: c.MaxResourceSize,
|
||||
SupportedComponentSet: c.SupportedComponentSet,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Report probe — empty calendar-query against the first calendar.
|
||||
if len(obs.Collections.Items) > 0 {
|
||||
first := obs.Collections.Items[0].Path
|
||||
obs.Report.ProbePath = first
|
||||
q := &caldav.CalendarQuery{
|
||||
CompRequest: caldav.CalendarCompRequest{
|
||||
Name: "VCALENDAR",
|
||||
Comps: []caldav.CalendarCompRequest{
|
||||
{Name: "VEVENT"},
|
||||
},
|
||||
},
|
||||
}
|
||||
if _, err := cal.QueryCalendar(ctx, first, q); err != nil {
|
||||
obs.Report.Error = err.Error()
|
||||
} else {
|
||||
obs.Report.QueryOK = true
|
||||
}
|
||||
} else {
|
||||
obs.Report.Skipped = true
|
||||
}
|
||||
|
||||
// Scheduling inbox/outbox — only probe if advertised.
|
||||
if obs.Scheduling.Advertised {
|
||||
inbox, outbox, err := dav.FindScheduleURLs(ctx, authClient, principal)
|
||||
if err != nil {
|
||||
obs.Scheduling.Error = err.Error()
|
||||
}
|
||||
obs.Scheduling.InboxURL = inbox
|
||||
obs.Scheduling.OutboxURL = outbox
|
||||
}
|
||||
|
||||
return obs, nil
|
||||
}
|
||||
|
||||
// asHTTPClient adapts stdlib *http.Client to go-webdav's HTTPClient interface.
|
||||
// The interface has a single Do method so the conversion is free.
|
||||
func asHTTPClient(c *http.Client) webdav.HTTPClient { return c }
|
||||
50
caldav/definition.go
Normal file
50
caldav/definition.go
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
package caldav
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"git.happydns.org/checker-dav/internal/dav"
|
||||
sdk "git.happydns.org/checker-sdk-go/checker"
|
||||
)
|
||||
|
||||
// Version is the checker version reported in CheckerDefinition.Version.
|
||||
// Overridden at link time by the standalone binary via -ldflags.
|
||||
var Version = "built-in"
|
||||
|
||||
// Definition returns the CheckerDefinition for the CalDAV checker.
|
||||
func Definition() *sdk.CheckerDefinition {
|
||||
return &sdk.CheckerDefinition{
|
||||
ID: "caldav",
|
||||
Name: "CalDAV server",
|
||||
Version: Version,
|
||||
Availability: sdk.CheckerAvailability{
|
||||
// The probe itself only needs a domain name (discovery runs on
|
||||
// the whole domain via /.well-known + SRV), so the checker is
|
||||
// always offered at domain scope.
|
||||
ApplyToDomain: true,
|
||||
|
||||
// Also offered at service scope so alerts — including the TLS
|
||||
// alerts derived from the endpoints we publish — surface on a
|
||||
// dedicated "CalDAV" service page rather than on the domain
|
||||
// page. The abstract.CalDAV service type does not exist in the
|
||||
// happyDomain service catalog yet; until it does, this has no
|
||||
// visible effect, but makes the intent explicit.
|
||||
ApplyToService: true,
|
||||
LimitToServices: []string{"abstract.CalDAV"},
|
||||
},
|
||||
ObservationKeys: []sdk.ObservationKey{ObservationKey},
|
||||
Options: sdk.CheckerOptionsDocumentation{
|
||||
UserOpts: dav.UserOptions(),
|
||||
DomainOpts: dav.DomainOptions(),
|
||||
RunOpts: dav.RunOptions(),
|
||||
},
|
||||
Rules: dav.Rules(dav.KindCalDAV, ObservationKey),
|
||||
Aggregator: dav.WorstStatus{},
|
||||
Interval: &sdk.CheckIntervalSpec{
|
||||
Min: 1 * time.Minute,
|
||||
Max: 1 * time.Hour,
|
||||
Default: 15 * time.Minute,
|
||||
},
|
||||
HasHTMLReport: true,
|
||||
}
|
||||
}
|
||||
19
caldav/discovery.go
Normal file
19
caldav/discovery.go
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
package caldav
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"git.happydns.org/checker-dav/internal/dav"
|
||||
sdk "git.happydns.org/checker-sdk-go/checker"
|
||||
)
|
||||
|
||||
// DiscoverEntries implements sdk.DiscoveryPublisher. The SDK server calls
|
||||
// this with the native Go value returned by Collect, so we just type-assert
|
||||
// and delegate to the shared helper.
|
||||
func (p *caldavProvider) DiscoverEntries(data any) ([]sdk.DiscoveryEntry, error) {
|
||||
obs, ok := data.(*dav.Observation)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("unexpected data type %T", data)
|
||||
}
|
||||
return dav.DiscoverEntries(obs), nil
|
||||
}
|
||||
30
caldav/provider.go
Normal file
30
caldav/provider.go
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
package caldav
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"git.happydns.org/checker-dav/internal/dav"
|
||||
sdk "git.happydns.org/checker-sdk-go/checker"
|
||||
)
|
||||
|
||||
// Provider returns the CalDAV observation provider.
|
||||
//
|
||||
// The returned value implements sdk.ObservationProvider, plus the optional
|
||||
// CheckerDefinitionProvider, CheckerHTMLReporter, and EndpointDiscoverer
|
||||
// interfaces so the SDK's HTTP server exposes /definition, /evaluate,
|
||||
// /report, and forwards discovered TLS endpoints to downstream checkers.
|
||||
func Provider() sdk.ObservationProvider {
|
||||
return &caldavProvider{}
|
||||
}
|
||||
|
||||
type caldavProvider struct{}
|
||||
|
||||
func (p *caldavProvider) Key() sdk.ObservationKey { return ObservationKey }
|
||||
|
||||
func (p *caldavProvider) Definition() *sdk.CheckerDefinition { return Definition() }
|
||||
|
||||
func (p *caldavProvider) RenderForm() []sdk.CheckerOptionField { return dav.InteractiveForm() }
|
||||
|
||||
func (p *caldavProvider) ParseForm(r *http.Request) (sdk.CheckerOptions, error) {
|
||||
return dav.ParseInteractiveForm(r)
|
||||
}
|
||||
29
caldav/report.go
Normal file
29
caldav/report.go
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
package caldav
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
sdk "git.happydns.org/checker-sdk-go/checker"
|
||||
|
||||
"git.happydns.org/checker-dav/internal/dav"
|
||||
)
|
||||
|
||||
// GetHTMLReport implements sdk.CheckerHTMLReporter on *caldavProvider.
|
||||
//
|
||||
// Delegated to the shared renderer in internal/dav so CalDAV and CardDAV
|
||||
// produce visually identical reports; the only differences are the title
|
||||
// and the set of phases (CalDAV includes Scheduling).
|
||||
//
|
||||
// Downstream TLS probes published for the endpoints we discovered are read
|
||||
// via ctx.Related(dav.TLSRelatedKey) and folded into the report (callouts +
|
||||
// dedicated TLS phase) — per
|
||||
// happydomain3/docs/checker-discovery-endpoint.md.
|
||||
func (p *caldavProvider) GetHTMLReport(ctx sdk.ReportContext) (string, error) {
|
||||
var d dav.Observation
|
||||
if err := json.Unmarshal(ctx.Data(), &d); err != nil {
|
||||
return "", fmt.Errorf("failed to unmarshal caldav report: %w", err)
|
||||
}
|
||||
d.Kind = dav.KindCalDAV
|
||||
return dav.RenderReport(&d, "CalDAV Server", ctx.Related(dav.TLSRelatedKey))
|
||||
}
|
||||
17
caldav/types.go
Normal file
17
caldav/types.go
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
// Package checker (imported as caldav by the standalone binary) implements
|
||||
// the CalDAV compliance and health checker for happyDomain.
|
||||
//
|
||||
// It is deliberately kept thin: discovery, OPTIONS, PROPFIND, and reporting
|
||||
// helpers live in git.happydns.org/checker-dav/internal/dav, so this package
|
||||
// only wires the CalDAV-specific options, collect pipeline, rules, and HTML
|
||||
// report together.
|
||||
package caldav
|
||||
|
||||
import "git.happydns.org/checker-dav/internal/dav"
|
||||
|
||||
// ObservationKey identifies CalDAV observations in happyDomain's store.
|
||||
const ObservationKey = "caldav"
|
||||
|
||||
// Data is the persisted observation shape. Callers read it back via
|
||||
// obs.Get(ctx, ObservationKey, &Data).
|
||||
type Data = dav.Observation
|
||||
125
carddav/collect.go
Normal file
125
carddav/collect.go
Normal file
|
|
@ -0,0 +1,125 @@
|
|||
package carddav
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"git.happydns.org/checker-dav/internal/dav"
|
||||
sdk "git.happydns.org/checker-sdk-go/checker"
|
||||
webdav "github.com/emersion/go-webdav"
|
||||
"github.com/emersion/go-webdav/carddav"
|
||||
)
|
||||
|
||||
func (p *carddavProvider) Collect(ctx context.Context, opts sdk.CheckerOptions) (any, error) {
|
||||
domain, _ := sdk.GetOption[string](opts, "domain_name")
|
||||
user, _ := sdk.GetOption[string](opts, "username")
|
||||
pass, _ := sdk.GetOption[string](opts, "password")
|
||||
explicit, _ := sdk.GetOption[string](opts, "context_url")
|
||||
timeoutSec := sdk.GetFloatOption(opts, "timeout_seconds", 10)
|
||||
|
||||
timeout := time.Duration(timeoutSec * float64(time.Second))
|
||||
if timeout <= 0 {
|
||||
timeout = 10 * time.Second
|
||||
}
|
||||
|
||||
obs := &dav.Observation{
|
||||
Kind: dav.KindCardDAV,
|
||||
Domain: domain,
|
||||
HasCredentials: user != "" && pass != "",
|
||||
CollectedAt: time.Now(),
|
||||
}
|
||||
|
||||
anonClient := dav.NewHTTPClient(timeout)
|
||||
|
||||
// Phase 1 — Discovery
|
||||
obs.Discovery = dav.Discover(ctx, anonClient, dav.KindCardDAV, domain, explicit)
|
||||
if obs.Discovery.ContextURL == "" {
|
||||
return obs, nil
|
||||
}
|
||||
|
||||
// Phase 2 — OPTIONS
|
||||
optsRes, err := dav.ProbeOptions(ctx, anonClient, obs.Discovery.ContextURL)
|
||||
obs.Options = optsRes
|
||||
if err != nil {
|
||||
obs.Transport = dav.TransportResult{Error: err.Error()}
|
||||
return obs, nil
|
||||
}
|
||||
obs.Transport = dav.TransportResult{Reached: true}
|
||||
|
||||
// Phase 3 — Authenticated
|
||||
if !obs.HasCredentials {
|
||||
obs.Principal.Skipped = true
|
||||
obs.HomeSet.Skipped = true
|
||||
obs.Collections.Skipped = true
|
||||
obs.Report.Skipped = true
|
||||
return obs, nil
|
||||
}
|
||||
|
||||
authClient := dav.WithBasicAuth(anonClient, user, pass)
|
||||
|
||||
principal, err := dav.FindPrincipal(ctx, authClient, obs.Discovery.ContextURL)
|
||||
if err != nil {
|
||||
obs.Principal.Error = err.Error()
|
||||
obs.HomeSet.Skipped = true
|
||||
obs.Collections.Skipped = true
|
||||
obs.Report.Skipped = true
|
||||
return obs, nil
|
||||
}
|
||||
obs.Principal.URL = principal
|
||||
|
||||
card, err := carddav.NewClient(asHTTPClient(authClient), obs.Discovery.ContextURL)
|
||||
if err != nil {
|
||||
obs.HomeSet.Error = err.Error()
|
||||
obs.Collections.Skipped = true
|
||||
obs.Report.Skipped = true
|
||||
return obs, nil
|
||||
}
|
||||
home, err := card.FindAddressBookHomeSet(ctx, principal)
|
||||
if err != nil {
|
||||
obs.HomeSet.Error = err.Error()
|
||||
obs.Collections.Skipped = true
|
||||
obs.Report.Skipped = true
|
||||
return obs, nil
|
||||
}
|
||||
obs.HomeSet.URL = home
|
||||
|
||||
books, err := card.FindAddressBooks(ctx, home)
|
||||
if err != nil {
|
||||
obs.Collections.Error = err.Error()
|
||||
obs.Report.Skipped = true
|
||||
} else {
|
||||
for _, b := range books {
|
||||
item := dav.CollectionInfo{
|
||||
Path: b.Path,
|
||||
Name: b.Name,
|
||||
Description: b.Description,
|
||||
MaxResourceSize: b.MaxResourceSize,
|
||||
}
|
||||
for _, d := range b.SupportedAddressData {
|
||||
item.SupportedAddressData = append(item.SupportedAddressData, d.ContentType+";"+d.Version)
|
||||
}
|
||||
obs.Collections.Items = append(obs.Collections.Items, item)
|
||||
}
|
||||
}
|
||||
|
||||
if len(obs.Collections.Items) > 0 {
|
||||
first := obs.Collections.Items[0].Path
|
||||
obs.Report.ProbePath = first
|
||||
q := &carddav.AddressBookQuery{
|
||||
DataRequest: carddav.AddressDataRequest{AllProp: true},
|
||||
Limit: 1,
|
||||
}
|
||||
if _, err := card.QueryAddressBook(ctx, first, q); err != nil {
|
||||
obs.Report.Error = err.Error()
|
||||
} else {
|
||||
obs.Report.QueryOK = true
|
||||
}
|
||||
} else {
|
||||
obs.Report.Skipped = true
|
||||
}
|
||||
|
||||
return obs, nil
|
||||
}
|
||||
|
||||
func asHTTPClient(c *http.Client) webdav.HTTPClient { return c }
|
||||
45
carddav/definition.go
Normal file
45
carddav/definition.go
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
package carddav
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"git.happydns.org/checker-dav/internal/dav"
|
||||
sdk "git.happydns.org/checker-sdk-go/checker"
|
||||
)
|
||||
|
||||
var Version = "built-in"
|
||||
|
||||
func Definition() *sdk.CheckerDefinition {
|
||||
return &sdk.CheckerDefinition{
|
||||
ID: "carddav",
|
||||
Name: "CardDAV server",
|
||||
Version: Version,
|
||||
Availability: sdk.CheckerAvailability{
|
||||
// Domain scope for the probe itself (discovery runs across the
|
||||
// whole domain via /.well-known + SRV).
|
||||
ApplyToDomain: true,
|
||||
|
||||
// Service scope so downstream TLS alerts attach to a dedicated
|
||||
// "CardDAV" service page instead of the domain page. See the
|
||||
// CalDAV sibling for the rationale; abstract.CardDAV is not in
|
||||
// the happyDomain service catalog yet but the intent is encoded
|
||||
// here ahead of time.
|
||||
ApplyToService: true,
|
||||
LimitToServices: []string{"abstract.CardDAV"},
|
||||
},
|
||||
ObservationKeys: []sdk.ObservationKey{ObservationKey},
|
||||
Options: sdk.CheckerOptionsDocumentation{
|
||||
UserOpts: dav.UserOptions(),
|
||||
DomainOpts: dav.DomainOptions(),
|
||||
RunOpts: dav.RunOptions(),
|
||||
},
|
||||
Rules: dav.Rules(dav.KindCardDAV, ObservationKey),
|
||||
Aggregator: dav.WorstStatus{},
|
||||
Interval: &sdk.CheckIntervalSpec{
|
||||
Min: 1 * time.Minute,
|
||||
Max: 1 * time.Hour,
|
||||
Default: 15 * time.Minute,
|
||||
},
|
||||
HasHTMLReport: true,
|
||||
}
|
||||
}
|
||||
18
carddav/discovery.go
Normal file
18
carddav/discovery.go
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
package carddav
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"git.happydns.org/checker-dav/internal/dav"
|
||||
sdk "git.happydns.org/checker-sdk-go/checker"
|
||||
)
|
||||
|
||||
// DiscoverEntries implements sdk.DiscoveryPublisher. See the CalDAV sibling
|
||||
// for the rationale — the shared helper produces the TLS discovery entries.
|
||||
func (p *carddavProvider) DiscoverEntries(data any) ([]sdk.DiscoveryEntry, error) {
|
||||
obs, ok := data.(*dav.Observation)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("unexpected data type %T", data)
|
||||
}
|
||||
return dav.DiscoverEntries(obs), nil
|
||||
}
|
||||
21
carddav/provider.go
Normal file
21
carddav/provider.go
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
package carddav
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"git.happydns.org/checker-dav/internal/dav"
|
||||
sdk "git.happydns.org/checker-sdk-go/checker"
|
||||
)
|
||||
|
||||
func Provider() sdk.ObservationProvider { return &carddavProvider{} }
|
||||
|
||||
type carddavProvider struct{}
|
||||
|
||||
func (p *carddavProvider) Key() sdk.ObservationKey { return ObservationKey }
|
||||
func (p *carddavProvider) Definition() *sdk.CheckerDefinition { return Definition() }
|
||||
|
||||
func (p *carddavProvider) RenderForm() []sdk.CheckerOptionField { return dav.InteractiveForm() }
|
||||
|
||||
func (p *carddavProvider) ParseForm(r *http.Request) (sdk.CheckerOptions, error) {
|
||||
return dav.ParseInteractiveForm(r)
|
||||
}
|
||||
22
carddav/report.go
Normal file
22
carddav/report.go
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
package carddav
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
sdk "git.happydns.org/checker-sdk-go/checker"
|
||||
|
||||
"git.happydns.org/checker-dav/internal/dav"
|
||||
)
|
||||
|
||||
// GetHTMLReport folds downstream TLS probes (published on our discovered
|
||||
// endpoints) into the CardDAV report via ctx.Related — see the CalDAV
|
||||
// sibling for the rationale.
|
||||
func (p *carddavProvider) GetHTMLReport(ctx sdk.ReportContext) (string, error) {
|
||||
var d dav.Observation
|
||||
if err := json.Unmarshal(ctx.Data(), &d); err != nil {
|
||||
return "", fmt.Errorf("failed to unmarshal carddav report: %w", err)
|
||||
}
|
||||
d.Kind = dav.KindCardDAV
|
||||
return dav.RenderReport(&d, "CardDAV Server", ctx.Related(dav.TLSRelatedKey))
|
||||
}
|
||||
11
carddav/types.go
Normal file
11
carddav/types.go
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
// Package checker (imported as carddav) implements the CardDAV compliance
|
||||
// and health checker for happyDomain. See the CalDAV sibling for the shape;
|
||||
// the two packages share ~everything except the protocol-specific home-set
|
||||
// and REPORT calls, which live in collect.go.
|
||||
package carddav
|
||||
|
||||
import "git.happydns.org/checker-dav/internal/dav"
|
||||
|
||||
const ObservationKey = "carddav"
|
||||
|
||||
type Data = dav.Observation
|
||||
23
cmd/checker-caldav/main.go
Normal file
23
cmd/checker-caldav/main.go
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"log"
|
||||
|
||||
"git.happydns.org/checker-dav/caldav"
|
||||
sdk "git.happydns.org/checker-sdk-go/checker"
|
||||
)
|
||||
|
||||
// Version is injected at link time via -ldflags "-X main.Version=...".
|
||||
var Version = "custom-build"
|
||||
|
||||
var listenAddr = flag.String("listen", ":8080", "HTTP listen address")
|
||||
|
||||
func main() {
|
||||
flag.Parse()
|
||||
caldav.Version = Version
|
||||
server := sdk.NewServer(caldav.Provider())
|
||||
if err := server.ListenAndServe(*listenAddr); err != nil {
|
||||
log.Fatalf("server error: %v", err)
|
||||
}
|
||||
}
|
||||
23
cmd/checker-carddav/main.go
Normal file
23
cmd/checker-carddav/main.go
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"log"
|
||||
|
||||
"git.happydns.org/checker-dav/carddav"
|
||||
sdk "git.happydns.org/checker-sdk-go/checker"
|
||||
)
|
||||
|
||||
// Version is injected at link time via -ldflags "-X main.Version=...".
|
||||
var Version = "custom-build"
|
||||
|
||||
var listenAddr = flag.String("listen", ":8080", "HTTP listen address")
|
||||
|
||||
func main() {
|
||||
flag.Parse()
|
||||
carddav.Version = Version
|
||||
server := sdk.NewServer(carddav.Provider())
|
||||
if err := server.ListenAndServe(*listenAddr); err != nil {
|
||||
log.Fatalf("server error: %v", err)
|
||||
}
|
||||
}
|
||||
15
go.mod
Normal file
15
go.mod
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
module git.happydns.org/checker-dav
|
||||
|
||||
go 1.25.0
|
||||
|
||||
require (
|
||||
git.happydns.org/checker-sdk-go v1.2.0
|
||||
git.happydns.org/checker-tls v0.2.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/emersion/go-ical v0.0.0-20240127095438-fc1c9d8fb2b6 // indirect
|
||||
github.com/emersion/go-vcard v0.0.0-20230815062825-8fda7d206ec9 // indirect
|
||||
github.com/emersion/go-webdav v0.7.0
|
||||
github.com/teambition/rrule-go v1.8.2 // indirect
|
||||
)
|
||||
12
go.sum
Normal file
12
go.sum
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
git.happydns.org/checker-sdk-go v1.2.0 h1:v4MpKAz0W3PwP+bxx3pya8w893sVH5xTD1of1cc0TV8=
|
||||
git.happydns.org/checker-sdk-go v1.2.0/go.mod h1:aNAcfYFfbhvH9kJhE0Njp5GX0dQbxdRB0rJ0KvSC5nI=
|
||||
git.happydns.org/checker-tls v0.2.0 h1:2dYpcePBylUc3le76fFlLbxraiLpGESmOhx4NfD7REM=
|
||||
git.happydns.org/checker-tls v0.2.0/go.mod h1:0ZSG0CTP007SHBPE7qInESVIOcW+xgucHUhHgj6MeZ8=
|
||||
github.com/emersion/go-ical v0.0.0-20240127095438-fc1c9d8fb2b6 h1:kHoSgklT8weIDl6R6xFpBJ5IioRdBU1v2X2aCZRVCcM=
|
||||
github.com/emersion/go-ical v0.0.0-20240127095438-fc1c9d8fb2b6/go.mod h1:BEksegNspIkjCQfmzWgsgbu6KdeJ/4LwUZs7DMBzjzw=
|
||||
github.com/emersion/go-vcard v0.0.0-20230815062825-8fda7d206ec9 h1:ATgqloALX6cHCranzkLb8/zjivwQ9DWWDCQRnxTPfaA=
|
||||
github.com/emersion/go-vcard v0.0.0-20230815062825-8fda7d206ec9/go.mod h1:HMJKR5wlh/ziNp+sHEDV2ltblO4JD2+IdDOWtGcQBTM=
|
||||
github.com/emersion/go-webdav v0.7.0 h1:cp6aBWXBf8Sjzguka9VJarr4XTkGc2IHxXI1Gq3TKpA=
|
||||
github.com/emersion/go-webdav v0.7.0/go.mod h1:mI8iBx3RAODwX7PJJ7qzsKAKs/vY429YfS2/9wKnDbQ=
|
||||
github.com/teambition/rrule-go v1.8.2 h1:lIjpjvWTj9fFUZCmuoVDrKVOtdiyzbzc93qTmRVe/J8=
|
||||
github.com/teambition/rrule-go v1.8.2/go.mod h1:Ieq5AbrKGciP1V//Wq8ktsTXwSwJHDD5mD/wLBGl3p4=
|
||||
39
internal/dav/client.go
Normal file
39
internal/dav/client.go
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
package dav
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
// NewHTTPClient returns an http.Client with a sane default transport for
|
||||
// probing DAV servers. TLS certificate validation uses Go's default rules —
|
||||
// dedicated TLS correctness belongs in a separate checker.
|
||||
func NewHTTPClient(timeout time.Duration) *http.Client {
|
||||
return &http.Client{
|
||||
Timeout: timeout,
|
||||
}
|
||||
}
|
||||
|
||||
// basicAuthRoundTripper injects HTTP Basic credentials on every request so
|
||||
// callers can pass the same client through go-webdav's own API without losing
|
||||
// auth on internal redirects.
|
||||
type basicAuthRoundTripper struct {
|
||||
user, pass string
|
||||
next http.RoundTripper
|
||||
}
|
||||
|
||||
func (b *basicAuthRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
req.SetBasicAuth(b.user, b.pass)
|
||||
return b.next.RoundTrip(req)
|
||||
}
|
||||
|
||||
// WithBasicAuth clones c and attaches Basic credentials to the transport.
|
||||
func WithBasicAuth(c *http.Client, user, pass string) *http.Client {
|
||||
nc := *c
|
||||
base := c.Transport
|
||||
if base == nil {
|
||||
base = http.DefaultTransport
|
||||
}
|
||||
nc.Transport = &basicAuthRoundTripper{user: user, pass: pass, next: base}
|
||||
return &nc
|
||||
}
|
||||
219
internal/dav/discover.go
Normal file
219
internal/dav/discover.go
Normal file
|
|
@ -0,0 +1,219 @@
|
|||
package dav
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Discover resolves the DAV context URL for domain following RFC 6764:
|
||||
// /.well-known/{caldav,carddav} first (cheap and works for the common case),
|
||||
// then SRV/TXT. An explicit override shortcuts everything.
|
||||
//
|
||||
// The returned Observation.Discovery is fully populated with whatever was
|
||||
// learned along the way, even if every step fails — the report leans on the
|
||||
// captured evidence to tell the user which leg of the discovery broke.
|
||||
func Discover(ctx context.Context, client *http.Client, kind Kind, domain, explicitURL string) DiscoveryResult {
|
||||
res := DiscoveryResult{}
|
||||
|
||||
if explicitURL != "" {
|
||||
res.ContextURL = explicitURL
|
||||
res.Source = "explicit"
|
||||
return res
|
||||
}
|
||||
|
||||
// 1. /.well-known — this is the #1 misconfig hotspot, so we always probe
|
||||
// it even if SRV below might have worked, to surface the mistake.
|
||||
wellKnown := "https://" + domain + kind.WellKnownPath()
|
||||
res.WellKnownURL = wellKnown
|
||||
ctxURL, chain, code, err := followWellKnown(ctx, client, wellKnown)
|
||||
res.WellKnownCode = code
|
||||
res.WellKnownChain = chain
|
||||
if err != nil {
|
||||
res.WellKnownError = err.Error()
|
||||
} else if ctxURL != "" {
|
||||
res.ContextURL = ctxURL
|
||||
res.Source = "well-known"
|
||||
}
|
||||
|
||||
// 2. SRV + TXT fallback (also informational even when well-known worked).
|
||||
discoverSRV(ctx, kind, domain, &res)
|
||||
|
||||
if res.ContextURL == "" && len(res.SecureSRV) > 0 {
|
||||
target := res.SecureSRV[0]
|
||||
path := res.TXTPath
|
||||
if path == "" {
|
||||
path = "/"
|
||||
}
|
||||
res.ContextURL = srvURL(target, path, true)
|
||||
res.Source = "srv-txt"
|
||||
}
|
||||
|
||||
if res.ContextURL == "" && res.Error == "" {
|
||||
res.Error = "could not resolve a context URL via /.well-known or SRV"
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
// followWellKnown issues a GET against path and follows up to 5 redirects
|
||||
// manually so we can capture the redirect chain. The well-known endpoint
|
||||
// SHOULD return a 3xx (RFC 6764 §5); returning 200 is a common misconfig we
|
||||
// want to flag, and 404 means the site-owner forgot to set it up.
|
||||
func followWellKnown(ctx context.Context, client *http.Client, u string) (finalURL string, chain []string, firstCode int, err error) {
|
||||
chain = make([]string, 0, 5)
|
||||
cur := u
|
||||
for i := 0; i < 5; i++ {
|
||||
req, reqErr := http.NewRequestWithContext(ctx, http.MethodGet, cur, nil)
|
||||
if reqErr != nil {
|
||||
return "", chain, firstCode, reqErr
|
||||
}
|
||||
// Use a no-redirect client snapshot so we observe each hop.
|
||||
c := *client
|
||||
c.CheckRedirect = func(_ *http.Request, _ []*http.Request) error { return http.ErrUseLastResponse }
|
||||
resp, doErr := c.Do(req)
|
||||
if doErr != nil {
|
||||
return "", chain, firstCode, doErr
|
||||
}
|
||||
resp.Body.Close()
|
||||
chain = append(chain, fmt.Sprintf("%d %s", resp.StatusCode, cur))
|
||||
// We track the *first* response code because the rule cares about
|
||||
// whether /.well-known itself redirected. A chain like 301→200 is
|
||||
// correct; a chain starting with 200 is the misconfig we flag.
|
||||
if i == 0 {
|
||||
firstCode = resp.StatusCode
|
||||
}
|
||||
|
||||
if resp.StatusCode >= 300 && resp.StatusCode < 400 {
|
||||
loc := resp.Header.Get("Location")
|
||||
if loc == "" {
|
||||
return "", chain, firstCode, errors.New("redirect with empty Location header")
|
||||
}
|
||||
next, parseErr := resolveLocation(cur, loc)
|
||||
if parseErr != nil {
|
||||
return "", chain, firstCode, parseErr
|
||||
}
|
||||
cur = next
|
||||
continue
|
||||
}
|
||||
|
||||
if resp.StatusCode == http.StatusOK {
|
||||
return cur, chain, firstCode, nil
|
||||
}
|
||||
|
||||
return "", chain, firstCode, fmt.Errorf("unexpected status %d", resp.StatusCode)
|
||||
}
|
||||
return "", chain, firstCode, errors.New("too many redirects")
|
||||
}
|
||||
|
||||
func resolveLocation(base, loc string) (string, error) {
|
||||
baseURL, err := url.Parse(base)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
locURL, err := url.Parse(loc)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return baseURL.ResolveReference(locURL).String(), nil
|
||||
}
|
||||
|
||||
func discoverSRV(ctx context.Context, kind Kind, domain string, res *DiscoveryResult) {
|
||||
resolver := net.DefaultResolver
|
||||
|
||||
type srvResult struct {
|
||||
records []SRVRecord
|
||||
err error
|
||||
}
|
||||
secureCh := make(chan srvResult, 1)
|
||||
plainCh := make(chan srvResult, 1)
|
||||
go func() {
|
||||
r, err := lookupSRV(ctx, resolver, kind.ServiceName(true), "tcp", domain)
|
||||
secureCh <- srvResult{r, err}
|
||||
}()
|
||||
go func() {
|
||||
r, err := lookupSRV(ctx, resolver, kind.ServiceName(false), "tcp", domain)
|
||||
plainCh <- srvResult{r, err}
|
||||
}()
|
||||
|
||||
secureRes := <-secureCh
|
||||
if secureRes.err != nil && !isNoSuchHost(secureRes.err) {
|
||||
res.SRVError = secureRes.err.Error()
|
||||
}
|
||||
res.SecureSRV = secureRes.records
|
||||
|
||||
plainRes := <-plainCh
|
||||
if plainRes.err != nil && !isNoSuchHost(plainRes.err) && res.SRVError == "" {
|
||||
res.SRVError = plainRes.err.Error()
|
||||
}
|
||||
res.PlaintextSRV = plainRes.records
|
||||
|
||||
// Pull the TXT path hint from whichever SRV target we plan to use.
|
||||
var txtName string
|
||||
if len(res.SecureSRV) > 0 {
|
||||
txtName = kind.ServiceName(true) + "._tcp." + trimTrailingDot(res.SecureSRV[0].Target)
|
||||
} else if len(res.PlaintextSRV) > 0 {
|
||||
txtName = kind.ServiceName(false) + "._tcp." + trimTrailingDot(res.PlaintextSRV[0].Target)
|
||||
}
|
||||
if txtName != "" {
|
||||
txts, err := resolver.LookupTXT(ctx, txtName)
|
||||
if err != nil && !isNoSuchHost(err) {
|
||||
res.TXTError = err.Error()
|
||||
}
|
||||
for _, t := range txts {
|
||||
if strings.HasPrefix(t, "path=") {
|
||||
res.TXTPath = strings.TrimPrefix(t, "path=")
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func lookupSRV(ctx context.Context, r *net.Resolver, service, proto, name string) ([]SRVRecord, error) {
|
||||
_, addrs, err := r.LookupSRV(ctx, strings.TrimPrefix(service, "_"), proto, name)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]SRVRecord, 0, len(addrs))
|
||||
for _, a := range addrs {
|
||||
out = append(out, SRVRecord{
|
||||
Target: trimTrailingDot(a.Target),
|
||||
Port: a.Port,
|
||||
Priority: a.Priority,
|
||||
Weight: a.Weight,
|
||||
})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func srvURL(r SRVRecord, path string, secure bool) string {
|
||||
scheme := "https"
|
||||
defaultPort := uint16(443)
|
||||
if !secure {
|
||||
scheme = "http"
|
||||
defaultPort = 80
|
||||
}
|
||||
host := r.Target
|
||||
if r.Port != defaultPort {
|
||||
host = fmt.Sprintf("%s:%d", r.Target, r.Port)
|
||||
}
|
||||
if !strings.HasPrefix(path, "/") {
|
||||
path = "/" + path
|
||||
}
|
||||
return scheme + "://" + host + path
|
||||
}
|
||||
|
||||
func trimTrailingDot(s string) string {
|
||||
return strings.TrimSuffix(s, ".")
|
||||
}
|
||||
|
||||
func isNoSuchHost(err error) bool {
|
||||
var dnsErr *net.DNSError
|
||||
if errors.As(err, &dnsErr) {
|
||||
return dnsErr.IsNotFound
|
||||
}
|
||||
return false
|
||||
}
|
||||
112
internal/dav/discover_test.go
Normal file
112
internal/dav/discover_test.go
Normal file
|
|
@ -0,0 +1,112 @@
|
|||
package dav
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestDiscover_wellKnownRedirect walks the happy path: /.well-known/caldav
|
||||
// returns a 301 to the real context URL.
|
||||
func TestDiscover_wellKnownRedirect(t *testing.T) {
|
||||
var hits []string
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
hits = append(hits, r.URL.Path)
|
||||
if r.URL.Path == "/.well-known/caldav" {
|
||||
http.Redirect(w, r, "/dav/", http.StatusMovedPermanently)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
|
||||
// Route "example.test/.well-known/caldav" through the test server.
|
||||
c := srv.Client()
|
||||
c.Transport = rewriteTransport{base: srv.URL, next: c.Transport}
|
||||
|
||||
res := Discover(context.Background(), c, KindCalDAV, "example.test", "")
|
||||
if res.Source != "well-known" {
|
||||
t.Errorf("source = %q, want well-known", res.Source)
|
||||
}
|
||||
if !strings.HasSuffix(res.ContextURL, "/dav/") {
|
||||
t.Errorf("context URL = %q", res.ContextURL)
|
||||
}
|
||||
if res.WellKnownCode != 301 {
|
||||
t.Errorf("expected 301 captured, got %d", res.WellKnownCode)
|
||||
}
|
||||
if len(res.WellKnownChain) < 1 {
|
||||
t.Error("expected redirect chain to be recorded")
|
||||
}
|
||||
}
|
||||
|
||||
// TestDiscover_wellKnownReturns200 reproduces the most common misconfig: the
|
||||
// server returns 200 on /.well-known/caldav instead of redirecting. Discover
|
||||
// must still set ContextURL (to the well-known URL) but WellKnownCode=200 so
|
||||
// the rule can emit the warning callout.
|
||||
func TestDiscover_wellKnownReturns200(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
|
||||
c := srv.Client()
|
||||
c.Transport = rewriteTransport{base: srv.URL, next: c.Transport}
|
||||
|
||||
res := Discover(context.Background(), c, KindCardDAV, "example.test", "")
|
||||
if res.WellKnownCode != 200 {
|
||||
t.Errorf("well-known code = %d, want 200", res.WellKnownCode)
|
||||
}
|
||||
if res.ContextURL == "" {
|
||||
t.Error("expected ContextURL to fall back to the well-known URL")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiscover_explicitOverride(t *testing.T) {
|
||||
res := Discover(context.Background(), http.DefaultClient, KindCalDAV, "example.test", "https://custom.example/dav/")
|
||||
if res.Source != "explicit" {
|
||||
t.Errorf("source: %q", res.Source)
|
||||
}
|
||||
if res.ContextURL != "https://custom.example/dav/" {
|
||||
t.Errorf("ctx: %q", res.ContextURL)
|
||||
}
|
||||
if res.WellKnownURL != "" {
|
||||
t.Errorf("should not have probed well-known, got %q", res.WellKnownURL)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiscover_redirectLoop(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// Always redirect to itself → triggers "too many redirects".
|
||||
http.Redirect(w, r, r.URL.Path, http.StatusFound)
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
|
||||
c := srv.Client()
|
||||
c.Transport = rewriteTransport{base: srv.URL, next: c.Transport}
|
||||
|
||||
res := Discover(context.Background(), c, KindCalDAV, "example.test", "")
|
||||
if res.WellKnownError == "" {
|
||||
t.Error("expected well-known error, got none")
|
||||
}
|
||||
}
|
||||
|
||||
// rewriteTransport rewrites any request URL's host to point at base so we can
|
||||
// exercise Discover() without setting up DNS. It preserves the original path.
|
||||
type rewriteTransport struct {
|
||||
base string
|
||||
next http.RoundTripper
|
||||
}
|
||||
|
||||
func (r rewriteTransport) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
baseURL, _ := url.Parse(r.base)
|
||||
req.URL.Scheme = baseURL.Scheme
|
||||
req.URL.Host = baseURL.Host
|
||||
next := r.next
|
||||
if next == nil {
|
||||
next = http.DefaultTransport
|
||||
}
|
||||
return next.RoundTrip(req)
|
||||
}
|
||||
97
internal/dav/endpoints.go
Normal file
97
internal/dav/endpoints.go
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
package dav
|
||||
|
||||
import (
|
||||
"log"
|
||||
"net/url"
|
||||
"strconv"
|
||||
|
||||
sdk "git.happydns.org/checker-sdk-go/checker"
|
||||
tlsct "git.happydns.org/checker-tls/contract"
|
||||
)
|
||||
|
||||
// DiscoverEntries derives TLS DiscoveryEntry records worth handing off to
|
||||
// downstream checkers (notably checker-tls) from a completed Observation.
|
||||
//
|
||||
// A CalDAV/CardDAV context URL always implies a direct-TLS HTTPS endpoint,
|
||||
// so we emit a single tls.endpoint.v1 entry for the resolved context URL's
|
||||
// host:port. If the endpoint was reached via SRV, we also surface each SRV
|
||||
// target as its own entry — those are the names operators actually need
|
||||
// certificates on, and they may differ from the queried domain.
|
||||
//
|
||||
// SNI is always populated (equal to Host for CalDAV/CardDAV, since — unlike
|
||||
// XMPP (RFC 6120 §13.7.2.1) — there is no mandated source-domain-vs-target
|
||||
// split: clients negotiate TLS for the hostname they connect to). We fill
|
||||
// the field unconditionally so consumers can rely on it being set.
|
||||
func DiscoverEntries(obs *Observation) []sdk.DiscoveryEntry {
|
||||
if obs == nil || obs.Discovery.ContextURL == "" {
|
||||
return nil
|
||||
}
|
||||
var out []sdk.DiscoveryEntry
|
||||
seen := map[string]struct{}{}
|
||||
|
||||
add := func(host string, port uint16) {
|
||||
if host == "" || port == 0 {
|
||||
return
|
||||
}
|
||||
key := host + ":" + strconv.Itoa(int(port))
|
||||
if _, dup := seen[key]; dup {
|
||||
return
|
||||
}
|
||||
seen[key] = struct{}{}
|
||||
entry, err := tlsct.NewEntry(tlsct.TLSEndpoint{
|
||||
Host: host,
|
||||
Port: port,
|
||||
SNI: host,
|
||||
})
|
||||
if err != nil {
|
||||
log.Printf("checker-dav: contract.NewEntry(%s:%d): %v", host, port, err)
|
||||
return
|
||||
}
|
||||
out = append(out, entry)
|
||||
}
|
||||
|
||||
// Primary endpoint: the resolved context URL.
|
||||
if host, port, ok := hostPortFromURL(obs.Discovery.ContextURL); ok {
|
||||
add(host, port)
|
||||
}
|
||||
|
||||
// Secondary endpoints: every TLS SRV target. Clients may connect to any
|
||||
// of them per weight/priority, and all of them need a valid certificate.
|
||||
for _, r := range obs.Discovery.SecureSRV {
|
||||
port := r.Port
|
||||
if port == 0 {
|
||||
port = 443
|
||||
}
|
||||
add(r.Target, port)
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
// hostPortFromURL extracts the (host, port) pair from an absolute URL. The
|
||||
// port defaults to 443 for https and 80 for http. Returns ok=false for
|
||||
// malformed URLs so callers can silently skip them.
|
||||
func hostPortFromURL(raw string) (host string, port uint16, ok bool) {
|
||||
u, err := url.Parse(raw)
|
||||
if err != nil {
|
||||
return "", 0, false
|
||||
}
|
||||
host = u.Hostname()
|
||||
if host == "" {
|
||||
return "", 0, false
|
||||
}
|
||||
if p := u.Port(); p != "" {
|
||||
n, convErr := strconv.ParseUint(p, 10, 16)
|
||||
if convErr != nil {
|
||||
return "", 0, false
|
||||
}
|
||||
return host, uint16(n), true
|
||||
}
|
||||
switch u.Scheme {
|
||||
case "https":
|
||||
return host, 443, true
|
||||
case "http":
|
||||
return host, 80, true
|
||||
}
|
||||
return "", 0, false
|
||||
}
|
||||
98
internal/dav/endpoints_test.go
Normal file
98
internal/dav/endpoints_test.go
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
package dav
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
tlsct "git.happydns.org/checker-tls/contract"
|
||||
)
|
||||
|
||||
// parseAll decodes DiscoverEntries output via the TLS contract. Malformed
|
||||
// entries fail the test so we notice drift quickly.
|
||||
func parseAll(t *testing.T, obs *Observation) []tlsct.TLSEndpoint {
|
||||
t.Helper()
|
||||
entries := DiscoverEntries(obs)
|
||||
eps, warnings := tlsct.ParseEntries(entries)
|
||||
if len(warnings) != 0 {
|
||||
t.Fatalf("unexpected decode warnings: %v", warnings)
|
||||
}
|
||||
out := make([]tlsct.TLSEndpoint, len(eps))
|
||||
for i, e := range eps {
|
||||
if e.Ref == "" {
|
||||
t.Errorf("entry %d has empty Ref", i)
|
||||
}
|
||||
out[i] = e.Endpoint
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func TestDiscoverEntries_contextURLOnly(t *testing.T) {
|
||||
obs := &Observation{
|
||||
Discovery: DiscoveryResult{ContextURL: "https://dav.example.com/caldav/"},
|
||||
}
|
||||
got := parseAll(t, obs)
|
||||
if len(got) != 1 {
|
||||
t.Fatalf("got %d endpoints, want 1: %+v", len(got), got)
|
||||
}
|
||||
if got[0].Host != "dav.example.com" || got[0].Port != 443 {
|
||||
t.Errorf("unexpected endpoint: %+v", got[0])
|
||||
}
|
||||
// Direct TLS — no STARTTLS upgrade.
|
||||
if got[0].STARTTLS != "" {
|
||||
t.Errorf("STARTTLS = %q, want empty (direct TLS)", got[0].STARTTLS)
|
||||
}
|
||||
// SNI must be set unconditionally, even when it is equal to Host.
|
||||
if got[0].SNI != "dav.example.com" {
|
||||
t.Errorf("SNI = %q, want dav.example.com", got[0].SNI)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiscoverEntries_nonDefaultPort(t *testing.T) {
|
||||
obs := &Observation{
|
||||
Discovery: DiscoveryResult{ContextURL: "https://dav.example.com:8443/caldav/"},
|
||||
}
|
||||
got := parseAll(t, obs)
|
||||
if len(got) != 1 || got[0].Port != 8443 {
|
||||
t.Fatalf("unexpected: %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiscoverEntries_srvTargets(t *testing.T) {
|
||||
// SRV pointing to a different name than the domain → we must surface
|
||||
// the SRV target too, because that's the hostname the cert needs to
|
||||
// cover.
|
||||
obs := &Observation{
|
||||
Discovery: DiscoveryResult{
|
||||
ContextURL: "https://dav.example.com/caldav/",
|
||||
SecureSRV: []SRVRecord{
|
||||
{Target: "dav-backend-1.example.net", Port: 443},
|
||||
{Target: "dav-backend-2.example.net", Port: 443},
|
||||
{Target: "dav.example.com", Port: 443}, // duplicate of context → deduped
|
||||
},
|
||||
},
|
||||
}
|
||||
got := parseAll(t, obs)
|
||||
if len(got) != 3 {
|
||||
t.Fatalf("expected 3 unique endpoints, got %d: %+v", len(got), got)
|
||||
}
|
||||
hosts := map[string]bool{}
|
||||
for _, e := range got {
|
||||
hosts[e.Host] = true
|
||||
if e.SNI != e.Host {
|
||||
t.Errorf("endpoint %+v: SNI=%q, want %q (equal to Host)", e, e.SNI, e.Host)
|
||||
}
|
||||
}
|
||||
for _, want := range []string{"dav.example.com", "dav-backend-1.example.net", "dav-backend-2.example.net"} {
|
||||
if !hosts[want] {
|
||||
t.Errorf("missing host %q in %+v", want, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiscoverEntries_emptyOnNoContextURL(t *testing.T) {
|
||||
if got := DiscoverEntries(&Observation{}); got != nil {
|
||||
t.Errorf("expected nil, got %+v", got)
|
||||
}
|
||||
if got := DiscoverEntries(nil); got != nil {
|
||||
t.Errorf("expected nil for nil obs, got %+v", got)
|
||||
}
|
||||
}
|
||||
101
internal/dav/options.go
Normal file
101
internal/dav/options.go
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
package dav
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// ProbeOptions issues an HTTP OPTIONS against url and reports the parsed DAV
|
||||
// headers. A missing DAV: header, or one that does not contain the kind's
|
||||
// required capability, is not treated as a transport error here — the caller
|
||||
// rule decides severity from the parsed values.
|
||||
func ProbeOptions(ctx context.Context, client *http.Client, url string) (OptionsResult, error) {
|
||||
res := OptionsResult{}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodOptions, url, nil)
|
||||
if err != nil {
|
||||
res.Error = err.Error()
|
||||
return res, err
|
||||
}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
res.Error = err.Error()
|
||||
return res, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
res.StatusCode = resp.StatusCode
|
||||
res.Server = resp.Header.Get("Server")
|
||||
res.DAVClasses = parseCSVHeader(resp.Header.Values("Dav"))
|
||||
res.AllowMethods = parseCSVHeader(resp.Header.Values("Allow"))
|
||||
|
||||
for _, h := range resp.Header.Values("Www-Authenticate") {
|
||||
if scheme := authScheme(h); scheme != "" {
|
||||
res.AuthSchemes = appendUnique(res.AuthSchemes, scheme)
|
||||
}
|
||||
}
|
||||
|
||||
if res.StatusCode >= 400 {
|
||||
res.Error = fmt.Sprintf("OPTIONS returned HTTP %d", res.StatusCode)
|
||||
}
|
||||
return res, nil
|
||||
}
|
||||
|
||||
// HasCapability returns true when the OPTIONS response advertised cap in the
|
||||
// DAV: header. Matching is case-insensitive, per RFC 4918 §10.1.
|
||||
func (o OptionsResult) HasCapability(cap string) bool {
|
||||
for _, c := range o.DAVClasses {
|
||||
if strings.EqualFold(c, cap) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// AllowsMethod returns true when the OPTIONS response's Allow: listed m.
|
||||
func (o OptionsResult) AllowsMethod(m string) bool {
|
||||
for _, a := range o.AllowMethods {
|
||||
if strings.EqualFold(a, m) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// parseCSVHeader splits one or more header values on commas, trims, and drops
|
||||
// empties. Multiple headers of the same name (net/http preserves them) are
|
||||
// merged.
|
||||
func parseCSVHeader(values []string) []string {
|
||||
var out []string
|
||||
for _, v := range values {
|
||||
for _, part := range strings.Split(v, ",") {
|
||||
if p := strings.TrimSpace(part); p != "" {
|
||||
out = append(out, p)
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// authScheme returns the scheme token from a WWW-Authenticate header value
|
||||
// ("Basic realm=\"x\"" → "Basic"). Empty if the value is malformed.
|
||||
func authScheme(h string) string {
|
||||
h = strings.TrimSpace(h)
|
||||
if h == "" {
|
||||
return ""
|
||||
}
|
||||
if i := strings.IndexAny(h, " \t"); i > 0 {
|
||||
return h[:i]
|
||||
}
|
||||
return h
|
||||
}
|
||||
|
||||
func appendUnique(s []string, v string) []string {
|
||||
for _, x := range s {
|
||||
if strings.EqualFold(x, v) {
|
||||
return s
|
||||
}
|
||||
}
|
||||
return append(s, v)
|
||||
}
|
||||
129
internal/dav/options_shared.go
Normal file
129
internal/dav/options_shared.go
Normal file
|
|
@ -0,0 +1,129 @@
|
|||
package dav
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
sdk "git.happydns.org/checker-sdk-go/checker"
|
||||
)
|
||||
|
||||
func UserOptions() []sdk.CheckerOptionDocumentation {
|
||||
return []sdk.CheckerOptionDocumentation{
|
||||
{
|
||||
Id: "username",
|
||||
Type: "string",
|
||||
Label: "Username",
|
||||
Description: "Optional. Supplying credentials unlocks authenticated checks (principal, home-set, collections, report probe).",
|
||||
},
|
||||
{
|
||||
Id: "password",
|
||||
Type: "string",
|
||||
Label: "Password or token",
|
||||
Description: "Optional. Paired with the username for HTTP Basic authentication.",
|
||||
Secret: true,
|
||||
},
|
||||
{
|
||||
Id: "context_url",
|
||||
Type: "string",
|
||||
Label: "Explicit context URL",
|
||||
Description: "Optional. Bypasses /.well-known and SRV discovery — use for servers with a non-standard layout.",
|
||||
Placeholder: "https://dav.example.com/caldav/",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func DomainOptions() []sdk.CheckerOptionDocumentation {
|
||||
return []sdk.CheckerOptionDocumentation{
|
||||
{
|
||||
Id: "domain_name",
|
||||
Label: "Domain name",
|
||||
AutoFill: sdk.AutoFillDomainName,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func RunOptions() []sdk.CheckerOptionDocumentation {
|
||||
return []sdk.CheckerOptionDocumentation{
|
||||
{
|
||||
Id: "timeout_seconds",
|
||||
Type: "number",
|
||||
Label: "Timeout (seconds)",
|
||||
Description: "Per-request HTTP timeout.",
|
||||
Default: float64(10),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// InteractiveForm returns the fields shown on the standalone /check page.
|
||||
// Discovery (well-known + SRV) happens inside Collect, so the human only
|
||||
// needs to provide a domain plus the same optional knobs exposed to
|
||||
// happyDomain users.
|
||||
func InteractiveForm() []sdk.CheckerOptionField {
|
||||
return []sdk.CheckerOptionField{
|
||||
{
|
||||
Id: "domain_name",
|
||||
Type: "string",
|
||||
Label: "Domain name",
|
||||
Placeholder: "example.com",
|
||||
Required: true,
|
||||
},
|
||||
{
|
||||
Id: "username",
|
||||
Type: "string",
|
||||
Label: "Username",
|
||||
Description: "Optional. Supplying credentials unlocks authenticated checks.",
|
||||
},
|
||||
{
|
||||
Id: "password",
|
||||
Type: "string",
|
||||
Label: "Password or token",
|
||||
Description: "Optional. Paired with the username for HTTP Basic auth.",
|
||||
Secret: true,
|
||||
},
|
||||
{
|
||||
Id: "context_url",
|
||||
Type: "string",
|
||||
Label: "Explicit context URL",
|
||||
Description: "Optional. Bypasses /.well-known and SRV discovery.",
|
||||
Placeholder: "https://dav.example.com/caldav/",
|
||||
},
|
||||
{
|
||||
Id: "timeout_seconds",
|
||||
Type: "number",
|
||||
Label: "Timeout (seconds)",
|
||||
Description: "Per-request HTTP timeout.",
|
||||
Default: float64(10),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// ParseInteractiveForm turns the submitted /check form into CheckerOptions.
|
||||
// Collect already handles discovery, so there is no extra lookup to do
|
||||
// here beyond validating the inputs.
|
||||
func ParseInteractiveForm(r *http.Request) (sdk.CheckerOptions, error) {
|
||||
domain := strings.TrimSpace(r.FormValue("domain_name"))
|
||||
if domain == "" {
|
||||
return nil, errors.New("domain name is required")
|
||||
}
|
||||
|
||||
opts := sdk.CheckerOptions{"domain_name": domain}
|
||||
if v := strings.TrimSpace(r.FormValue("username")); v != "" {
|
||||
opts["username"] = v
|
||||
}
|
||||
if v := r.FormValue("password"); v != "" {
|
||||
opts["password"] = v
|
||||
}
|
||||
if v := strings.TrimSpace(r.FormValue("context_url")); v != "" {
|
||||
opts["context_url"] = v
|
||||
}
|
||||
if v := strings.TrimSpace(r.FormValue("timeout_seconds")); v != "" {
|
||||
f, err := strconv.ParseFloat(v, 64)
|
||||
if err != nil {
|
||||
return nil, errors.New("timeout must be a number")
|
||||
}
|
||||
opts["timeout_seconds"] = f
|
||||
}
|
||||
return opts, nil
|
||||
}
|
||||
115
internal/dav/options_test.go
Normal file
115
internal/dav/options_test.go
Normal file
|
|
@ -0,0 +1,115 @@
|
|||
package dav
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestProbeOptions_parsesHeaders(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodOptions {
|
||||
t.Fatalf("expected OPTIONS, got %s", r.Method)
|
||||
}
|
||||
w.Header().Set("DAV", "1, 2, calendar-access, calendar-schedule")
|
||||
w.Header().Set("Allow", "OPTIONS, PROPFIND, REPORT, PUT")
|
||||
w.Header().Set("Server", "TestSrv/1.0")
|
||||
w.Header().Add("WWW-Authenticate", `Basic realm="test"`)
|
||||
w.Header().Add("WWW-Authenticate", `Digest realm="test", nonce="abc"`)
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
|
||||
res, err := ProbeOptions(context.Background(), srv.Client(), srv.URL)
|
||||
if err != nil {
|
||||
t.Fatalf("ProbeOptions: %v", err)
|
||||
}
|
||||
if !res.HasCapability("calendar-access") {
|
||||
t.Errorf("expected calendar-access in %v", res.DAVClasses)
|
||||
}
|
||||
if !res.HasCapability("CALENDAR-SCHEDULE") {
|
||||
t.Errorf("case-insensitive match failed for calendar-schedule")
|
||||
}
|
||||
if !res.AllowsMethod("REPORT") || !res.AllowsMethod("PROPFIND") {
|
||||
t.Errorf("expected REPORT and PROPFIND in %v", res.AllowMethods)
|
||||
}
|
||||
if len(res.AuthSchemes) != 2 {
|
||||
t.Errorf("expected 2 auth schemes, got %v", res.AuthSchemes)
|
||||
}
|
||||
if res.Server != "TestSrv/1.0" {
|
||||
t.Errorf("Server header: %q", res.Server)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProbeOptions_missingDAVHeader(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
|
||||
res, err := ProbeOptions(context.Background(), srv.Client(), srv.URL)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected transport error: %v", err)
|
||||
}
|
||||
if res.HasCapability("calendar-access") {
|
||||
t.Error("expected capability absent")
|
||||
}
|
||||
if len(res.DAVClasses) != 0 {
|
||||
t.Errorf("expected empty DAV classes, got %v", res.DAVClasses)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProbeOptions_errorStatus(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusServiceUnavailable)
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
|
||||
res, err := ProbeOptions(context.Background(), srv.Client(), srv.URL)
|
||||
if err != nil {
|
||||
t.Fatalf("transport err: %v", err)
|
||||
}
|
||||
if res.StatusCode != 503 {
|
||||
t.Errorf("status: %d", res.StatusCode)
|
||||
}
|
||||
if res.Error == "" {
|
||||
t.Error("expected Error to be set for 503")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseCSVHeader_mergeAndTrim(t *testing.T) {
|
||||
got := parseCSVHeader([]string{"1, 2 ,calendar-access", " calendar-schedule"})
|
||||
want := []string{"1", "2", "calendar-access", "calendar-schedule"}
|
||||
if !equalSlices(got, want) {
|
||||
t.Errorf("got %v want %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthScheme(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
`Basic realm="x"`: "Basic",
|
||||
"Bearer": "Bearer",
|
||||
`Digest realm="r", nonce="n"`: "Digest",
|
||||
"": "",
|
||||
" ": "",
|
||||
}
|
||||
for in, want := range cases {
|
||||
if got := authScheme(in); got != want {
|
||||
t.Errorf("authScheme(%q) = %q, want %q", in, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func equalSlices(a, b []string) bool {
|
||||
if len(a) != len(b) {
|
||||
return false
|
||||
}
|
||||
for i := range a {
|
||||
if !strings.EqualFold(a[i], b[i]) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
159
internal/dav/principal.go
Normal file
159
internal/dav/principal.go
Normal file
|
|
@ -0,0 +1,159 @@
|
|||
package dav
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/xml"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// FindPrincipal requires credentials on client; a 401/403 from the server
|
||||
// bubbles up as the returned error.
|
||||
func FindPrincipal(ctx context.Context, client *http.Client, contextURL string) (string, error) {
|
||||
body := `<?xml version="1.0" encoding="utf-8"?>
|
||||
<d:propfind xmlns:d="DAV:">
|
||||
<d:prop><d:current-user-principal/></d:prop>
|
||||
</d:propfind>`
|
||||
hrefs, err := propFind(ctx, client, contextURL, "0", body)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
for _, href := range hrefs.principalHref() {
|
||||
return resolveReference(contextURL, href), nil
|
||||
}
|
||||
return "", fmt.Errorf("no current-user-principal returned")
|
||||
}
|
||||
|
||||
func FindScheduleURLs(ctx context.Context, client *http.Client, principalURL string) (inbox, outbox string, err error) {
|
||||
body := `<?xml version="1.0" encoding="utf-8"?>
|
||||
<d:propfind xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav">
|
||||
<d:prop>
|
||||
<c:schedule-inbox-URL/>
|
||||
<c:schedule-outbox-URL/>
|
||||
</d:prop>
|
||||
</d:propfind>`
|
||||
resp, err := propFind(ctx, client, principalURL, "0", body)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
for _, r := range resp.Response {
|
||||
for _, ps := range r.Propstat {
|
||||
if !strings.Contains(ps.Status, "200") {
|
||||
continue
|
||||
}
|
||||
for _, p := range ps.Prop.Props {
|
||||
switch p.XMLName.Local {
|
||||
case "schedule-inbox-URL":
|
||||
if h := p.firstHref(); h != "" {
|
||||
inbox = resolveReference(principalURL, h)
|
||||
}
|
||||
case "schedule-outbox-URL":
|
||||
if h := p.firstHref(); h != "" {
|
||||
outbox = resolveReference(principalURL, h)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return inbox, outbox, nil
|
||||
}
|
||||
|
||||
// ── raw PROPFIND ─────────────────────────────────────────────────────────────
|
||||
|
||||
// multistatus is the subset of the DAV:multistatus XML schema we need to read
|
||||
// principal URLs and scheduling hrefs. It is intentionally permissive — extra
|
||||
// elements are ignored, which makes us tolerant of server-specific extensions.
|
||||
type multistatus struct {
|
||||
XMLName xml.Name `xml:"DAV: multistatus"`
|
||||
Response []msResponse `xml:"response"`
|
||||
}
|
||||
|
||||
type msResponse struct {
|
||||
Href string `xml:"href"`
|
||||
Propstat []propstat `xml:"propstat"`
|
||||
}
|
||||
|
||||
type propstat struct {
|
||||
Prop prop `xml:"prop"`
|
||||
Status string `xml:"status"`
|
||||
}
|
||||
|
||||
type prop struct {
|
||||
Props []msProp `xml:",any"`
|
||||
}
|
||||
|
||||
type msProp struct {
|
||||
XMLName xml.Name
|
||||
Hrefs []string `xml:"href"`
|
||||
// A prop may also contain nested <current-user-principal><href>…</href></current-user-principal>,
|
||||
// which the flat Hrefs slice above captures via xml:"href" descent.
|
||||
}
|
||||
|
||||
func (p msProp) firstHref() string {
|
||||
if len(p.Hrefs) > 0 {
|
||||
return p.Hrefs[0]
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (m *multistatus) principalHref() []string {
|
||||
var out []string
|
||||
for _, r := range m.Response {
|
||||
for _, ps := range r.Propstat {
|
||||
if !strings.Contains(ps.Status, "200") {
|
||||
continue
|
||||
}
|
||||
for _, pr := range ps.Prop.Props {
|
||||
if pr.XMLName.Local == "current-user-principal" {
|
||||
if h := pr.firstHref(); h != "" {
|
||||
out = append(out, h)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// propFind is a small PROPFIND helper tuned for small single-resource probes.
|
||||
// It returns a parsed multistatus; transport-level failures bubble up as err.
|
||||
func propFind(ctx context.Context, client *http.Client, url, depth, body string) (*multistatus, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, "PROPFIND", url, strings.NewReader(body))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/xml; charset=utf-8")
|
||||
req.Header.Set("Depth", depth)
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
data, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if resp.StatusCode != http.StatusMultiStatus && resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("PROPFIND returned HTTP %d", resp.StatusCode)
|
||||
}
|
||||
var ms multistatus
|
||||
if err := xml.Unmarshal(data, &ms); err != nil {
|
||||
return nil, fmt.Errorf("invalid multistatus: %w", err)
|
||||
}
|
||||
return &ms, nil
|
||||
}
|
||||
|
||||
func resolveReference(base, ref string) string {
|
||||
r, err := url.Parse(ref)
|
||||
if err != nil {
|
||||
return ref
|
||||
}
|
||||
b, err := url.Parse(base)
|
||||
if err != nil {
|
||||
return ref
|
||||
}
|
||||
return b.ResolveReference(r).String()
|
||||
}
|
||||
475
internal/dav/report.go
Normal file
475
internal/dav/report.go
Normal file
|
|
@ -0,0 +1,475 @@
|
|||
package dav
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"html/template"
|
||||
"strings"
|
||||
|
||||
sdk "git.happydns.org/checker-sdk-go/checker"
|
||||
)
|
||||
|
||||
// RenderReport turns an Observation into a self-contained HTML document.
|
||||
//
|
||||
// The report foregrounds action items for the failure modes we see most often
|
||||
// (well-known misconfig, missing DAV capability, missing credentials,
|
||||
// downstream TLS issues on the endpoints we published) before showing the
|
||||
// full per-phase evidence.
|
||||
//
|
||||
// tlsRelated is the output of ctx.Related(TLSRelatedKey) at report time. Nil
|
||||
// is fine: the TLS section is simply omitted. This is how the happyDomain
|
||||
// cross-checker composition story (see
|
||||
// happydomain3/docs/checker-discovery-endpoint.md) surfaces certificate
|
||||
// alerts on the CalDAV/CardDAV service page rather than in a parallel TLS
|
||||
// dashboard.
|
||||
func RenderReport(obs *Observation, title string, tlsRelated []sdk.RelatedObservation) (string, error) {
|
||||
data := buildReportData(obs, title, tlsRelated)
|
||||
var buf strings.Builder
|
||||
if err := reportTemplate.Execute(&buf, data); err != nil {
|
||||
return "", fmt.Errorf("render dav report: %w", err)
|
||||
}
|
||||
return buf.String(), nil
|
||||
}
|
||||
|
||||
type reportData struct {
|
||||
Title string
|
||||
Domain string
|
||||
Verdict string
|
||||
VerdictCls string
|
||||
Callouts []calloutData
|
||||
Phases []phaseData
|
||||
Raw string
|
||||
ShowSched bool
|
||||
Scheduling *SchedulingResult
|
||||
TLSSummaries []TLSSummary
|
||||
}
|
||||
|
||||
type calloutData struct {
|
||||
Title string
|
||||
Body string
|
||||
Severity string // "warn" or "crit"
|
||||
}
|
||||
|
||||
type phaseData struct {
|
||||
Title string
|
||||
Items []phaseItem
|
||||
Open bool
|
||||
}
|
||||
|
||||
type phaseItem struct {
|
||||
Label string
|
||||
Status string // "ok", "warn", "fail", "unk", "info"
|
||||
Detail string
|
||||
Mono string
|
||||
}
|
||||
|
||||
func buildReportData(o *Observation, title string, tlsRelated []sdk.RelatedObservation) reportData {
|
||||
d := reportData{
|
||||
Title: title,
|
||||
Domain: o.Domain,
|
||||
ShowSched: o.Kind == KindCalDAV,
|
||||
Scheduling: o.Scheduling,
|
||||
}
|
||||
d.Callouts = buildCallouts(o)
|
||||
d.Phases = buildPhases(o)
|
||||
|
||||
// Fold downstream TLS probes (published by checker-tls against the
|
||||
// endpoints we discovered) into the report.
|
||||
tlsSummaries, tlsCallouts := foldTLSRelated(tlsRelated)
|
||||
d.TLSSummaries = tlsSummaries
|
||||
for _, c := range tlsCallouts {
|
||||
d.Callouts = append(d.Callouts, calloutData{
|
||||
Severity: c.Severity,
|
||||
Title: c.Title,
|
||||
Body: c.Body,
|
||||
})
|
||||
}
|
||||
if len(tlsSummaries) > 0 {
|
||||
d.Phases = append(d.Phases, buildTLSPhase(tlsSummaries))
|
||||
}
|
||||
|
||||
switch {
|
||||
case hasSeverity(d.Phases, "fail"):
|
||||
d.Verdict = "Critical issues detected"
|
||||
d.VerdictCls = "fail"
|
||||
case hasSeverity(d.Phases, "warn"):
|
||||
d.Verdict = "Minor issues detected"
|
||||
d.VerdictCls = "warn"
|
||||
case hasSeverity(d.Phases, "unk") && !hasSeverity(d.Phases, "ok"):
|
||||
d.Verdict = "Could not evaluate without credentials"
|
||||
d.VerdictCls = "unk"
|
||||
default:
|
||||
d.Verdict = "All checks passed"
|
||||
d.VerdictCls = "ok"
|
||||
}
|
||||
return d
|
||||
}
|
||||
|
||||
func hasSeverity(phases []phaseData, sev string) bool {
|
||||
for _, p := range phases {
|
||||
for _, it := range p.Items {
|
||||
if it.Status == sev {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// buildCallouts surfaces the most common misconfigurations at the top of the
|
||||
// report so operators don't have to read the full phase tree to find the fix.
|
||||
func buildCallouts(o *Observation) []calloutData {
|
||||
var out []calloutData
|
||||
disc := o.Discovery
|
||||
if disc.WellKnownCode == 200 && disc.Source != "explicit" {
|
||||
out = append(out, calloutData{
|
||||
Severity: "warn",
|
||||
Title: fmt.Sprintf("%s returned 200 instead of a redirect", disc.WellKnownURL),
|
||||
Body: fmt.Sprintf("RFC 6764 expects the well-known endpoint to redirect (301/302) to your service's context URL, e.g. %s. Many clients will refuse to follow a 200 here.", exampleContextURL(o.Kind)),
|
||||
})
|
||||
}
|
||||
if disc.ContextURL == "" {
|
||||
out = append(out, calloutData{
|
||||
Severity: "crit",
|
||||
Title: "Service discovery failed",
|
||||
Body: fmt.Sprintf("No %s or SRV record (%s._tcp.%s) was found. Publish either a redirect at the well-known URL, or an SRV record pointing at your service.", disc.WellKnownURL, o.Kind.ServiceName(true), o.Domain),
|
||||
})
|
||||
}
|
||||
if len(disc.PlaintextSRV) > 0 && len(disc.SecureSRV) == 0 {
|
||||
out = append(out, calloutData{
|
||||
Severity: "warn",
|
||||
Title: "Plaintext SRV record without HTTPS counterpart",
|
||||
Body: fmt.Sprintf("Clients should prefer %s._tcp SRV records. Add an %s._tcp record pointing at your TLS endpoint.", o.Kind.ServiceName(false), o.Kind.ServiceName(true)),
|
||||
})
|
||||
}
|
||||
if o.Options.StatusCode != 0 && !o.Options.HasCapability(o.Kind.RequiredCapability()) {
|
||||
out = append(out, calloutData{
|
||||
Severity: "crit",
|
||||
Title: fmt.Sprintf("Server does not advertise %q", o.Kind.RequiredCapability()),
|
||||
Body: fmt.Sprintf("The DAV: response header is %q — this endpoint is not a %s server, or a reverse proxy is stripping headers.", strings.Join(o.Options.DAVClasses, ", "), o.Kind),
|
||||
})
|
||||
}
|
||||
if !o.HasCredentials && o.Discovery.ContextURL != "" && o.Options.HasCapability(o.Kind.RequiredCapability()) {
|
||||
out = append(out, calloutData{
|
||||
Severity: "warn",
|
||||
Title: "Authenticated checks were skipped",
|
||||
Body: "Provide a username and password in the checker settings to probe principals, home-sets, collection properties, and REPORT behaviour.",
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func exampleContextURL(k Kind) string {
|
||||
switch k {
|
||||
case KindCalDAV:
|
||||
return "/dav/calendars/"
|
||||
case KindCardDAV:
|
||||
return "/dav/addressbooks/"
|
||||
}
|
||||
return "/dav/"
|
||||
}
|
||||
|
||||
func buildPhases(o *Observation) []phaseData {
|
||||
var phases []phaseData
|
||||
|
||||
// Phase 1 — Discovery
|
||||
discovery := phaseData{Title: "Discovery"}
|
||||
discovery.Items = append(discovery.Items, itemFor(
|
||||
"/.well-known redirect",
|
||||
wellKnownStatus(o.Discovery),
|
||||
o.Discovery.WellKnownError,
|
||||
summariseChain(o.Discovery.WellKnownChain),
|
||||
))
|
||||
discovery.Items = append(discovery.Items, itemFor(
|
||||
fmt.Sprintf("SRV %s._tcp (TLS)", o.Kind.ServiceName(true)),
|
||||
srvStatus(o.Discovery.SecureSRV, o.Discovery.SRVError),
|
||||
o.Discovery.SRVError,
|
||||
summariseSRV(o.Discovery.SecureSRV),
|
||||
))
|
||||
if len(o.Discovery.PlaintextSRV) > 0 || o.Discovery.SRVError == "" {
|
||||
discovery.Items = append(discovery.Items, itemFor(
|
||||
fmt.Sprintf("SRV %s._tcp (plaintext)", o.Kind.ServiceName(false)),
|
||||
plainSRVStatus(o.Discovery.PlaintextSRV),
|
||||
"",
|
||||
summariseSRV(o.Discovery.PlaintextSRV),
|
||||
))
|
||||
}
|
||||
if o.Discovery.TXTPath != "" {
|
||||
discovery.Items = append(discovery.Items, itemFor("TXT path hint", "ok", "", o.Discovery.TXTPath))
|
||||
}
|
||||
discovery.Items = append(discovery.Items, itemFor(
|
||||
"Context URL",
|
||||
contextStatus(o.Discovery.ContextURL),
|
||||
"",
|
||||
o.Discovery.ContextURL,
|
||||
))
|
||||
discovery.Open = hasItemSeverity(discovery.Items, "warn", "fail")
|
||||
phases = append(phases, discovery)
|
||||
|
||||
// Phase 2 — Transport + OPTIONS
|
||||
transport := phaseData{Title: "Transport & OPTIONS"}
|
||||
transport.Items = append(transport.Items,
|
||||
itemFor("HTTPS reached", boolStatus(o.Transport.Reached, "crit"), o.Transport.Error, ""),
|
||||
itemFor("DAV classes", davStatus(o, o.Options), "", strings.Join(o.Options.DAVClasses, ", ")),
|
||||
itemFor("Allow methods", methodsStatus(o.Options), "", strings.Join(o.Options.AllowMethods, ", ")),
|
||||
)
|
||||
if len(o.Options.AuthSchemes) > 0 {
|
||||
transport.Items = append(transport.Items, itemFor("Auth schemes", "info", "", strings.Join(o.Options.AuthSchemes, ", ")))
|
||||
}
|
||||
if o.Options.Server != "" {
|
||||
transport.Items = append(transport.Items, itemFor("Server header", "info", "", o.Options.Server))
|
||||
}
|
||||
transport.Open = hasItemSeverity(transport.Items, "warn", "fail")
|
||||
phases = append(phases, transport)
|
||||
|
||||
// Phase 3 — Authenticated
|
||||
auth := phaseData{Title: "Authenticated probes"}
|
||||
auth.Items = append(auth.Items,
|
||||
authItemFor("Principal", o.Principal.URL, o.Principal.Skipped, o.Principal.Error),
|
||||
authItemFor("Home-set", o.HomeSet.URL, o.HomeSet.Skipped, o.HomeSet.Error),
|
||||
collectionsItemFor(o.Collections, o.Kind),
|
||||
reportItemFor(o.Report),
|
||||
)
|
||||
auth.Open = hasItemSeverity(auth.Items, "warn", "fail")
|
||||
phases = append(phases, auth)
|
||||
|
||||
// Phase 4 — Scheduling (CalDAV only)
|
||||
if o.Kind == KindCalDAV && o.Scheduling != nil {
|
||||
sched := phaseData{Title: "Scheduling (CalDAV)"}
|
||||
if !o.Scheduling.Advertised {
|
||||
sched.Items = append(sched.Items, itemFor("calendar-schedule advertised", "info", "", "not advertised"))
|
||||
} else {
|
||||
sched.Items = append(sched.Items,
|
||||
itemFor("calendar-schedule advertised", "ok", "", "advertised"),
|
||||
authItemFor("schedule-inbox-URL", o.Scheduling.InboxURL, o.Principal.Skipped, o.Scheduling.Error),
|
||||
authItemFor("schedule-outbox-URL", o.Scheduling.OutboxURL, o.Principal.Skipped, ""),
|
||||
)
|
||||
}
|
||||
sched.Open = hasItemSeverity(sched.Items, "warn", "fail")
|
||||
phases = append(phases, sched)
|
||||
}
|
||||
|
||||
return phases
|
||||
}
|
||||
|
||||
// buildTLSPhase turns per-endpoint TLS summaries into a collapsible phase
|
||||
// rendered at the bottom of the report. Open when anything is non-OK so
|
||||
// operators don't need to expand it to see the problem.
|
||||
func buildTLSPhase(summaries []TLSSummary) phaseData {
|
||||
p := phaseData{Title: "TLS (from checker-tls)"}
|
||||
for _, s := range summaries {
|
||||
label := s.Address
|
||||
if s.TLSVersion != "" {
|
||||
label = fmt.Sprintf("%s — %s", s.Address, s.TLSVersion)
|
||||
}
|
||||
p.Items = append(p.Items, phaseItem{
|
||||
Label: label,
|
||||
Status: s.Status,
|
||||
Detail: s.Detail,
|
||||
})
|
||||
}
|
||||
p.Open = hasItemSeverity(p.Items, "warn", "fail")
|
||||
return p
|
||||
}
|
||||
|
||||
// ── small helpers used by buildPhases ────────────────────────────────────────
|
||||
|
||||
func wellKnownStatus(d DiscoveryResult) string {
|
||||
if d.Source == "explicit" {
|
||||
return "info"
|
||||
}
|
||||
if d.WellKnownCode == 200 {
|
||||
return "warn"
|
||||
}
|
||||
if d.WellKnownCode >= 300 && d.WellKnownCode < 400 {
|
||||
return "ok"
|
||||
}
|
||||
return "fail"
|
||||
}
|
||||
|
||||
func srvStatus(rec []SRVRecord, errStr string) string {
|
||||
if len(rec) > 0 {
|
||||
return "ok"
|
||||
}
|
||||
if errStr != "" {
|
||||
return "fail"
|
||||
}
|
||||
return "warn"
|
||||
}
|
||||
|
||||
func plainSRVStatus(rec []SRVRecord) string {
|
||||
if len(rec) > 0 {
|
||||
return "warn" // plaintext SRV is legacy / discouraged
|
||||
}
|
||||
return "ok"
|
||||
}
|
||||
|
||||
func contextStatus(u string) string {
|
||||
if u == "" {
|
||||
return "fail"
|
||||
}
|
||||
return "ok"
|
||||
}
|
||||
|
||||
func davStatus(o *Observation, r OptionsResult) string {
|
||||
if r.HasCapability(o.Kind.RequiredCapability()) {
|
||||
return "ok"
|
||||
}
|
||||
return "fail"
|
||||
}
|
||||
|
||||
func methodsStatus(r OptionsResult) string {
|
||||
if r.AllowsMethod("PROPFIND") && r.AllowsMethod("REPORT") {
|
||||
return "ok"
|
||||
}
|
||||
return "warn"
|
||||
}
|
||||
|
||||
func boolStatus(ok bool, failSev string) string {
|
||||
if ok {
|
||||
return "ok"
|
||||
}
|
||||
return failSev
|
||||
}
|
||||
|
||||
func authItemFor(label, value string, skipped bool, errStr string) phaseItem {
|
||||
switch {
|
||||
case skipped:
|
||||
return phaseItem{Label: label, Status: "unk", Detail: "no credentials supplied"}
|
||||
case errStr != "":
|
||||
return phaseItem{Label: label, Status: "fail", Detail: errStr}
|
||||
case value == "":
|
||||
return phaseItem{Label: label, Status: "warn", Detail: "not returned"}
|
||||
default:
|
||||
return phaseItem{Label: label, Status: "ok", Mono: value}
|
||||
}
|
||||
}
|
||||
|
||||
func collectionsItemFor(c CollectionsResult, k Kind) phaseItem {
|
||||
label := "Calendars"
|
||||
if k == KindCardDAV {
|
||||
label = "Address books"
|
||||
}
|
||||
switch {
|
||||
case c.Skipped:
|
||||
return phaseItem{Label: label, Status: "unk", Detail: "no credentials supplied"}
|
||||
case c.Error != "":
|
||||
return phaseItem{Label: label, Status: "fail", Detail: c.Error}
|
||||
case len(c.Items) == 0:
|
||||
return phaseItem{Label: label, Status: "warn", Detail: "home-set is empty"}
|
||||
default:
|
||||
names := make([]string, 0, len(c.Items))
|
||||
for _, it := range c.Items {
|
||||
n := it.Name
|
||||
if n == "" {
|
||||
n = it.Path
|
||||
}
|
||||
names = append(names, n)
|
||||
}
|
||||
return phaseItem{Label: label, Status: "ok", Detail: fmt.Sprintf("%d found", len(c.Items)), Mono: strings.Join(names, ", ")}
|
||||
}
|
||||
}
|
||||
|
||||
func reportItemFor(r ReportResult) phaseItem {
|
||||
switch {
|
||||
case r.Skipped:
|
||||
return phaseItem{Label: "REPORT query", Status: "unk", Detail: "skipped"}
|
||||
case r.Error != "":
|
||||
return phaseItem{Label: "REPORT query", Status: "fail", Detail: r.Error}
|
||||
case !r.QueryOK:
|
||||
return phaseItem{Label: "REPORT query", Status: "warn", Detail: "unexpected response"}
|
||||
default:
|
||||
return phaseItem{Label: "REPORT query", Status: "ok", Mono: r.ProbePath}
|
||||
}
|
||||
}
|
||||
|
||||
func itemFor(label, status, errStr, mono string) phaseItem {
|
||||
it := phaseItem{Label: label, Status: status, Mono: mono}
|
||||
if errStr != "" {
|
||||
it.Detail = errStr
|
||||
}
|
||||
return it
|
||||
}
|
||||
|
||||
func hasItemSeverity(items []phaseItem, sevs ...string) bool {
|
||||
for _, it := range items {
|
||||
for _, s := range sevs {
|
||||
if it.Status == s {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func summariseChain(chain []string) string {
|
||||
return strings.Join(chain, " → ")
|
||||
}
|
||||
|
||||
func summariseSRV(rec []SRVRecord) string {
|
||||
if len(rec) == 0 {
|
||||
return ""
|
||||
}
|
||||
parts := make([]string, 0, len(rec))
|
||||
for _, r := range rec {
|
||||
parts = append(parts, fmt.Sprintf("%s:%d (prio %d, weight %d)", r.Target, r.Port, r.Priority, r.Weight))
|
||||
}
|
||||
return strings.Join(parts, "; ")
|
||||
}
|
||||
|
||||
// ── template ─────────────────────────────────────────────────────────────────
|
||||
|
||||
var reportTemplate = template.Must(template.New("dav").Parse(`<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{{.Title}} Report</title>
|
||||
<style>` + ReportCSS + `</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div class="hd">
|
||||
<h1>{{.Title}}</h1>
|
||||
<span class="badge {{.VerdictCls}}">{{.Verdict}}</span>
|
||||
{{if .Domain}}<div class="verdict">Domain: <code>{{.Domain}}</code></div>{{end}}
|
||||
</div>
|
||||
|
||||
{{if .Callouts}}
|
||||
<div class="callouts">
|
||||
{{range .Callouts}}
|
||||
<div class="callout {{if eq .Severity "crit"}}crit{{end}}">
|
||||
<h3>{{.Title}}</h3>
|
||||
<p>{{.Body}}</p>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
{{range .Phases}}
|
||||
<details{{if .Open}} open{{end}}>
|
||||
<summary><span class="phase-title">{{.Title}}</span></summary>
|
||||
<div class="details-body">
|
||||
<table>
|
||||
{{range .Items}}
|
||||
<tr>
|
||||
<td style="width:1.5rem">
|
||||
{{if eq .Status "ok"}}<span class="check-ok">✓</span>
|
||||
{{else if eq .Status "warn"}}<span class="check-warn">⚠</span>
|
||||
{{else if eq .Status "fail"}}<span class="check-fail">✗</span>
|
||||
{{else if eq .Status "unk"}}<span class="check-unk">?</span>
|
||||
{{else}}<span class="check-unk">i</span>{{end}}
|
||||
</td>
|
||||
<td style="width:45%">{{.Label}}</td>
|
||||
<td>
|
||||
{{if .Mono}}<code>{{.Mono}}</code>{{end}}
|
||||
{{if .Detail}}<div class="note">{{.Detail}}</div>{{end}}
|
||||
</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</table>
|
||||
</div>
|
||||
</details>
|
||||
{{end}}
|
||||
|
||||
</body>
|
||||
</html>`))
|
||||
105
internal/dav/report_css.go
Normal file
105
internal/dav/report_css.go
Normal file
|
|
@ -0,0 +1,105 @@
|
|||
package dav
|
||||
|
||||
// ReportCSS is the shared stylesheet embedded in both checkers' HTML reports.
|
||||
// Lifted (with minor edits) from checker-matrix so the whole happyDomain
|
||||
// checker fleet has a consistent visual language.
|
||||
const ReportCSS = `
|
||||
*, *::before, *::after { box-sizing: border-box; }
|
||||
:root {
|
||||
font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
color: #1f2937;
|
||||
background: #f3f4f6;
|
||||
}
|
||||
body { margin: 0; padding: 1rem; }
|
||||
code { font-family: ui-monospace, monospace; font-size: .9em; }
|
||||
h1 { margin: 0 0 .4rem; font-size: 1.15rem; font-weight: 700; }
|
||||
h2 { font-size: 1rem; font-weight: 700; margin: 0 0 .6rem; }
|
||||
h3 { font-size: .9rem; font-weight: 600; margin: 0 0 .4rem; }
|
||||
|
||||
.hd {
|
||||
background: #fff;
|
||||
border-radius: 10px;
|
||||
padding: 1rem 1.25rem;
|
||||
margin-bottom: .75rem;
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,.08);
|
||||
}
|
||||
.verdict { color: #4b5563; margin-top: .35rem; font-size: .9rem; }
|
||||
|
||||
.badge {
|
||||
display: inline-flex; align-items: center;
|
||||
padding: .2em .65em;
|
||||
border-radius: 9999px;
|
||||
font-size: .78rem; font-weight: 700;
|
||||
letter-spacing: .02em;
|
||||
}
|
||||
.ok { background: #d1fae5; color: #065f46; }
|
||||
.warn { background: #fef3c7; color: #92400e; }
|
||||
.fail { background: #fee2e2; color: #991b1b; }
|
||||
.unk { background: #e5e7eb; color: #374151; }
|
||||
.info { background: #dbeafe; color: #1e40af; }
|
||||
|
||||
.section {
|
||||
background: #fff;
|
||||
border-radius: 8px;
|
||||
padding: .85rem 1rem;
|
||||
margin-bottom: .6rem;
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,.07);
|
||||
}
|
||||
|
||||
.callouts { display: flex; flex-direction: column; gap: .5rem; margin-bottom: .75rem; }
|
||||
.callout {
|
||||
background: #fff7ed;
|
||||
border-left: 4px solid #f97316;
|
||||
border-radius: 6px;
|
||||
padding: .7rem .9rem;
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,.06);
|
||||
}
|
||||
.callout.crit { background: #fef2f2; border-color: #dc2626; }
|
||||
.callout h3 { margin: 0 0 .2rem; }
|
||||
.callout p { margin: .15rem 0; font-size: .88rem; color: #374151; }
|
||||
|
||||
details {
|
||||
background: #fff;
|
||||
border-radius: 8px;
|
||||
margin-bottom: .45rem;
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,.07);
|
||||
overflow: hidden;
|
||||
}
|
||||
summary {
|
||||
display: flex; align-items: center; gap: .5rem;
|
||||
padding: .65rem 1rem;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
list-style: none;
|
||||
}
|
||||
summary::-webkit-details-marker { display: none; }
|
||||
summary::before {
|
||||
content: "▶";
|
||||
font-size: .65rem;
|
||||
color: #9ca3af;
|
||||
transition: transform .15s;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
details[open] > summary::before { transform: rotate(90deg); }
|
||||
.phase-title { flex: 1; font-weight: 600; }
|
||||
.details-body { padding: .6rem 1rem .85rem; border-top: 1px solid #f3f4f6; }
|
||||
|
||||
table { border-collapse: collapse; width: 100%; font-size: .85rem; }
|
||||
th, td { text-align: left; padding: .3rem .5rem; border-bottom: 1px solid #f3f4f6; vertical-align: top; }
|
||||
th { font-weight: 600; color: #6b7280; }
|
||||
|
||||
.check-ok { color: #059669; font-weight: 700; }
|
||||
.check-warn { color: #d97706; font-weight: 700; }
|
||||
.check-fail { color: #dc2626; font-weight: 700; }
|
||||
.check-unk { color: #6b7280; font-weight: 700; }
|
||||
|
||||
.errmsg { color: #dc2626; font-size: .85rem; margin: .25rem 0 0; }
|
||||
.note { color: #6b7280; font-size: .85rem; }
|
||||
|
||||
ul { margin: .25rem 0; padding-left: 1.2rem; }
|
||||
li { margin-bottom: .15rem; }
|
||||
|
||||
pre { background: #f9fafb; padding: .5rem; border-radius: 4px; overflow-x: auto; font-size: .8rem; }
|
||||
`
|
||||
330
internal/dav/rules.go
Normal file
330
internal/dav/rules.go
Normal file
|
|
@ -0,0 +1,330 @@
|
|||
package dav
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
sdk "git.happydns.org/checker-sdk-go/checker"
|
||||
)
|
||||
|
||||
// Rules returns the default rule set for kind. CardDAV gets the full set
|
||||
// except `scheduling`, which only applies to CalDAV.
|
||||
func Rules(kind Kind, obsKey sdk.ObservationKey) []sdk.CheckRule {
|
||||
rules := []sdk.CheckRule{
|
||||
&discoveryRule{obsKey: obsKey},
|
||||
&transportRule{obsKey: obsKey},
|
||||
&optionsRule{obsKey: obsKey, kind: kind},
|
||||
&principalRule{obsKey: obsKey},
|
||||
&homeSetRule{obsKey: obsKey},
|
||||
&collectionsRule{obsKey: obsKey, kind: kind},
|
||||
&reportRule{obsKey: obsKey},
|
||||
}
|
||||
if kind == KindCalDAV {
|
||||
rules = append(rules, &schedulingRule{obsKey: obsKey})
|
||||
}
|
||||
return rules
|
||||
}
|
||||
|
||||
// WorstStatus is a CheckAggregator that picks the highest-severity state from
|
||||
// the individual rule outcomes. StatusUnknown does not degrade the result
|
||||
// unless every rule returned Unknown.
|
||||
type WorstStatus struct{}
|
||||
|
||||
func (WorstStatus) Aggregate(states []sdk.CheckState) sdk.CheckState {
|
||||
if len(states) == 0 {
|
||||
return sdk.CheckState{Status: sdk.StatusUnknown, Message: "no rules evaluated"}
|
||||
}
|
||||
ranks := map[sdk.Status]int{
|
||||
sdk.StatusOK: 1,
|
||||
sdk.StatusInfo: 2,
|
||||
sdk.StatusUnknown: 3,
|
||||
sdk.StatusWarn: 4,
|
||||
sdk.StatusCrit: 5,
|
||||
sdk.StatusError: 6,
|
||||
}
|
||||
worst := states[0]
|
||||
worstRank := ranks[worst.Status]
|
||||
var msgs []string
|
||||
for _, s := range states {
|
||||
if r := ranks[s.Status]; r > worstRank {
|
||||
worstRank = r
|
||||
worst = s
|
||||
}
|
||||
if s.Message != "" {
|
||||
msgs = append(msgs, s.Message)
|
||||
}
|
||||
}
|
||||
out := sdk.CheckState{Status: worst.Status, Code: "aggregate"}
|
||||
out.Message = strings.Join(msgs, "; ")
|
||||
return out
|
||||
}
|
||||
|
||||
// ── individual rules ─────────────────────────────────────────────────────────
|
||||
|
||||
type baseRule struct {
|
||||
obsKey sdk.ObservationKey
|
||||
}
|
||||
|
||||
func (r *baseRule) get(ctx context.Context, obs sdk.ObservationGetter) (*Observation, sdk.CheckState) {
|
||||
var d Observation
|
||||
if err := obs.Get(ctx, r.obsKey, &d); err != nil {
|
||||
return nil, sdk.CheckState{
|
||||
Status: sdk.StatusError,
|
||||
Message: fmt.Sprintf("failed to load observation: %v", err),
|
||||
Code: "observation_missing",
|
||||
}
|
||||
}
|
||||
return &d, sdk.CheckState{}
|
||||
}
|
||||
|
||||
// discoveryRule checks that a context URL was resolved and that the
|
||||
// /.well-known endpoint is configured as a redirect (the #1 user-facing
|
||||
// misconfig we want to surface).
|
||||
type discoveryRule struct{ obsKey sdk.ObservationKey }
|
||||
|
||||
func (r *discoveryRule) Name() string { return "dav_discovery" }
|
||||
func (r *discoveryRule) Description() string { return "Service discovery via /.well-known and SRV" }
|
||||
func (r *discoveryRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, _ sdk.CheckerOptions) []sdk.CheckState {
|
||||
d, errState := (&baseRule{obsKey: r.obsKey}).get(ctx, obs)
|
||||
if d == nil {
|
||||
return []sdk.CheckState{errState}
|
||||
}
|
||||
disc := d.Discovery
|
||||
if disc.ContextURL == "" {
|
||||
return []sdk.CheckState{{
|
||||
Status: sdk.StatusCrit,
|
||||
Code: "discovery_failed",
|
||||
Message: "could not resolve a context URL (no /.well-known redirect and no SRV record)",
|
||||
}}
|
||||
}
|
||||
// /.well-known returning 200 is legal per RFC but strongly discouraged —
|
||||
// many clients won't follow it. Warn, don't crit.
|
||||
if disc.WellKnownCode == 200 && disc.Source != "explicit" {
|
||||
return []sdk.CheckState{{
|
||||
Status: sdk.StatusWarn,
|
||||
Code: "well_known_not_redirect",
|
||||
Message: fmt.Sprintf("%s returned 200 instead of a 301/302 redirect", disc.WellKnownURL),
|
||||
}}
|
||||
}
|
||||
if disc.Source == "srv-txt" && disc.WellKnownError != "" {
|
||||
return []sdk.CheckState{{
|
||||
Status: sdk.StatusWarn,
|
||||
Code: "well_known_missing",
|
||||
Message: fmt.Sprintf("context URL resolved via SRV but /.well-known is broken: %s", disc.WellKnownError),
|
||||
}}
|
||||
}
|
||||
return []sdk.CheckState{{
|
||||
Status: sdk.StatusOK,
|
||||
Code: "discovery_ok",
|
||||
Message: fmt.Sprintf("context URL %s (via %s)", disc.ContextURL, disc.Source),
|
||||
}}
|
||||
}
|
||||
|
||||
// transportRule reports only whether the context URL accepts HTTPS requests.
|
||||
// TLS specifics (cert chain, version) are explicitly out of scope.
|
||||
type transportRule struct{ obsKey sdk.ObservationKey }
|
||||
|
||||
func (r *transportRule) Name() string { return "dav_transport" }
|
||||
func (r *transportRule) Description() string { return "HTTPS connection to the context URL" }
|
||||
func (r *transportRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, _ sdk.CheckerOptions) []sdk.CheckState {
|
||||
d, errState := (&baseRule{obsKey: r.obsKey}).get(ctx, obs)
|
||||
if d == nil {
|
||||
return []sdk.CheckState{errState}
|
||||
}
|
||||
if !d.Transport.Reached {
|
||||
return []sdk.CheckState{{
|
||||
Status: sdk.StatusCrit,
|
||||
Code: "transport_failed",
|
||||
Message: fmt.Sprintf("HTTPS connection failed: %s", d.Transport.Error),
|
||||
}}
|
||||
}
|
||||
return []sdk.CheckState{{Status: sdk.StatusOK, Code: "transport_ok", Message: "HTTPS reachable"}}
|
||||
}
|
||||
|
||||
// optionsRule verifies the mandatory DAV class is advertised.
|
||||
type optionsRule struct {
|
||||
obsKey sdk.ObservationKey
|
||||
kind Kind
|
||||
}
|
||||
|
||||
func (r *optionsRule) Name() string { return "dav_options" }
|
||||
func (r *optionsRule) Description() string {
|
||||
return "HTTP OPTIONS advertises the required DAV capability"
|
||||
}
|
||||
func (r *optionsRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, _ sdk.CheckerOptions) []sdk.CheckState {
|
||||
d, errState := (&baseRule{obsKey: r.obsKey}).get(ctx, obs)
|
||||
if d == nil {
|
||||
return []sdk.CheckState{errState}
|
||||
}
|
||||
o := d.Options
|
||||
if o.Error != "" && len(o.DAVClasses) == 0 {
|
||||
return []sdk.CheckState{{
|
||||
Status: sdk.StatusCrit,
|
||||
Code: "options_failed",
|
||||
Message: fmt.Sprintf("OPTIONS request failed: %s", o.Error),
|
||||
}}
|
||||
}
|
||||
cap := r.kind.RequiredCapability()
|
||||
if !o.HasCapability(cap) {
|
||||
return []sdk.CheckState{{
|
||||
Status: sdk.StatusCrit,
|
||||
Code: "capability_missing",
|
||||
Message: fmt.Sprintf("server does not advertise %q in DAV: header (got %v)", cap, o.DAVClasses),
|
||||
}}
|
||||
}
|
||||
if !o.AllowsMethod("PROPFIND") || !o.AllowsMethod("REPORT") {
|
||||
return []sdk.CheckState{{
|
||||
Status: sdk.StatusWarn,
|
||||
Code: "methods_missing",
|
||||
Message: fmt.Sprintf("Allow: header missing PROPFIND or REPORT (got %v)", o.AllowMethods),
|
||||
}}
|
||||
}
|
||||
return []sdk.CheckState{{
|
||||
Status: sdk.StatusOK,
|
||||
Code: "options_ok",
|
||||
Message: fmt.Sprintf("DAV: %s", strings.Join(o.DAVClasses, ", ")),
|
||||
}}
|
||||
}
|
||||
|
||||
type principalRule struct{ obsKey sdk.ObservationKey }
|
||||
|
||||
func (r *principalRule) Name() string { return "dav_principal" }
|
||||
func (r *principalRule) Description() string { return "Principal URL discovery (authenticated)" }
|
||||
func (r *principalRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, _ sdk.CheckerOptions) []sdk.CheckState {
|
||||
d, errState := (&baseRule{obsKey: r.obsKey}).get(ctx, obs)
|
||||
if d == nil {
|
||||
return []sdk.CheckState{errState}
|
||||
}
|
||||
p := d.Principal
|
||||
if p.Skipped {
|
||||
return []sdk.CheckState{{Status: sdk.StatusUnknown, Code: "principal_skipped", Message: "no credentials supplied"}}
|
||||
}
|
||||
if p.Error != "" {
|
||||
return []sdk.CheckState{{Status: sdk.StatusCrit, Code: "principal_failed", Message: p.Error}}
|
||||
}
|
||||
return []sdk.CheckState{{Status: sdk.StatusOK, Code: "principal_ok", Message: p.URL}}
|
||||
}
|
||||
|
||||
type homeSetRule struct{ obsKey sdk.ObservationKey }
|
||||
|
||||
func (r *homeSetRule) Name() string { return "dav_home_set" }
|
||||
func (r *homeSetRule) Description() string { return "Home-set discovered from the principal" }
|
||||
func (r *homeSetRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, _ sdk.CheckerOptions) []sdk.CheckState {
|
||||
d, errState := (&baseRule{obsKey: r.obsKey}).get(ctx, obs)
|
||||
if d == nil {
|
||||
return []sdk.CheckState{errState}
|
||||
}
|
||||
h := d.HomeSet
|
||||
if h.Skipped {
|
||||
return []sdk.CheckState{{Status: sdk.StatusUnknown, Code: "home_set_skipped", Message: "no credentials supplied"}}
|
||||
}
|
||||
if h.Error != "" {
|
||||
return []sdk.CheckState{{Status: sdk.StatusCrit, Code: "home_set_failed", Message: h.Error}}
|
||||
}
|
||||
return []sdk.CheckState{{Status: sdk.StatusOK, Code: "home_set_ok", Message: h.URL}}
|
||||
}
|
||||
|
||||
type collectionsRule struct {
|
||||
obsKey sdk.ObservationKey
|
||||
kind Kind
|
||||
}
|
||||
|
||||
func (r *collectionsRule) Name() string { return "dav_collections" }
|
||||
func (r *collectionsRule) Description() string {
|
||||
return "Calendar/addressbook collections enumerate and expose required properties"
|
||||
}
|
||||
func (r *collectionsRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, _ sdk.CheckerOptions) []sdk.CheckState {
|
||||
d, errState := (&baseRule{obsKey: r.obsKey}).get(ctx, obs)
|
||||
if d == nil {
|
||||
return []sdk.CheckState{errState}
|
||||
}
|
||||
c := d.Collections
|
||||
if c.Skipped {
|
||||
return []sdk.CheckState{{Status: sdk.StatusUnknown, Code: "collections_skipped", Message: "no credentials supplied"}}
|
||||
}
|
||||
if c.Error != "" {
|
||||
return []sdk.CheckState{{Status: sdk.StatusCrit, Code: "collections_failed", Message: c.Error}}
|
||||
}
|
||||
if len(c.Items) == 0 {
|
||||
return []sdk.CheckState{{
|
||||
Status: sdk.StatusWarn,
|
||||
Code: "collections_empty",
|
||||
Message: "home-set is empty — the account has no calendars/addressbooks",
|
||||
}}
|
||||
}
|
||||
out := make([]sdk.CheckState, 0, len(c.Items))
|
||||
for _, it := range c.Items {
|
||||
msg := it.Name
|
||||
if msg == "" {
|
||||
msg = it.Path
|
||||
}
|
||||
if r.kind == KindCalDAV && len(it.SupportedComponentSet) > 0 {
|
||||
msg = fmt.Sprintf("%s (components: %s)", msg, strings.Join(it.SupportedComponentSet, ", "))
|
||||
} else if r.kind == KindCardDAV && len(it.SupportedAddressData) > 0 {
|
||||
msg = fmt.Sprintf("%s (address data: %s)", msg, strings.Join(it.SupportedAddressData, ", "))
|
||||
}
|
||||
out = append(out, sdk.CheckState{
|
||||
Status: sdk.StatusOK,
|
||||
Code: "collection_ok",
|
||||
Subject: it.Path,
|
||||
Message: msg,
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
type reportRule struct{ obsKey sdk.ObservationKey }
|
||||
|
||||
func (r *reportRule) Name() string { return "dav_report" }
|
||||
func (r *reportRule) Description() string { return "Server accepts a minimal REPORT query" }
|
||||
func (r *reportRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, _ sdk.CheckerOptions) []sdk.CheckState {
|
||||
d, errState := (&baseRule{obsKey: r.obsKey}).get(ctx, obs)
|
||||
if d == nil {
|
||||
return []sdk.CheckState{errState}
|
||||
}
|
||||
rep := d.Report
|
||||
if rep.Skipped {
|
||||
return []sdk.CheckState{{Status: sdk.StatusUnknown, Code: "report_skipped", Message: "no credentials supplied or no collection to probe"}}
|
||||
}
|
||||
if rep.Error != "" {
|
||||
return []sdk.CheckState{{Status: sdk.StatusCrit, Code: "report_failed", Message: rep.Error, Subject: rep.ProbePath}}
|
||||
}
|
||||
if !rep.QueryOK {
|
||||
return []sdk.CheckState{{Status: sdk.StatusWarn, Code: "report_query_not_ok", Message: "REPORT query returned an unexpected response", Subject: rep.ProbePath}}
|
||||
}
|
||||
return []sdk.CheckState{{Status: sdk.StatusOK, Code: "report_ok", Message: fmt.Sprintf("REPORT ok on %s", rep.ProbePath), Subject: rep.ProbePath}}
|
||||
}
|
||||
|
||||
// schedulingRule is CalDAV-only: if the server advertises calendar-schedule,
|
||||
// the principal should expose inbox/outbox URLs.
|
||||
type schedulingRule struct{ obsKey sdk.ObservationKey }
|
||||
|
||||
func (r *schedulingRule) Name() string { return "caldav_scheduling" }
|
||||
func (r *schedulingRule) Description() string {
|
||||
return "Scheduling inbox/outbox present when calendar-schedule is advertised"
|
||||
}
|
||||
func (r *schedulingRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, _ sdk.CheckerOptions) []sdk.CheckState {
|
||||
d, errState := (&baseRule{obsKey: r.obsKey}).get(ctx, obs)
|
||||
if d == nil {
|
||||
return []sdk.CheckState{errState}
|
||||
}
|
||||
s := d.Scheduling
|
||||
if s == nil || !s.Advertised {
|
||||
return []sdk.CheckState{{Status: sdk.StatusInfo, Code: "scheduling_not_advertised", Message: "server does not advertise calendar-schedule"}}
|
||||
}
|
||||
if d.Principal.Skipped {
|
||||
return []sdk.CheckState{{Status: sdk.StatusUnknown, Code: "scheduling_skipped", Message: "no credentials supplied"}}
|
||||
}
|
||||
if s.Error != "" {
|
||||
return []sdk.CheckState{{Status: sdk.StatusWarn, Code: "scheduling_probe_failed", Message: s.Error}}
|
||||
}
|
||||
if s.InboxURL == "" || s.OutboxURL == "" {
|
||||
return []sdk.CheckState{{
|
||||
Status: sdk.StatusWarn,
|
||||
Code: "scheduling_urls_missing",
|
||||
Message: "calendar-schedule advertised but schedule-inbox-URL/schedule-outbox-URL missing",
|
||||
}}
|
||||
}
|
||||
return []sdk.CheckState{{Status: sdk.StatusOK, Code: "scheduling_ok", Message: fmt.Sprintf("inbox=%s outbox=%s", s.InboxURL, s.OutboxURL)}}
|
||||
}
|
||||
276
internal/dav/tls_related.go
Normal file
276
internal/dav/tls_related.go
Normal file
|
|
@ -0,0 +1,276 @@
|
|||
package dav
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
sdk "git.happydns.org/checker-sdk-go/checker"
|
||||
)
|
||||
|
||||
// TLSRelatedKey is the observation key we expect a TLS checker to publish for
|
||||
// the endpoints we discover. Matches the cross-checker convention documented
|
||||
// in happydomain3/docs/checker-discovery-endpoint.md.
|
||||
const TLSRelatedKey sdk.ObservationKey = "tls_probes"
|
||||
|
||||
// tlsProbeView is a permissive decode of a TLS probe payload. We intentionally
|
||||
// only read the fields we need and tolerate missing ones — the TLS checker's
|
||||
// full schema is owned by that checker.
|
||||
type tlsProbeView struct {
|
||||
Host string `json:"host,omitempty"`
|
||||
Port uint16 `json:"port,omitempty"`
|
||||
Endpoint string `json:"endpoint,omitempty"`
|
||||
TLSVersion string `json:"tls_version,omitempty"`
|
||||
CipherSuite string `json:"cipher_suite,omitempty"`
|
||||
|
||||
HostnameMatch *bool `json:"hostname_match,omitempty"`
|
||||
ChainValid *bool `json:"chain_valid,omitempty"`
|
||||
NotAfter time.Time `json:"not_after,omitempty"`
|
||||
|
||||
// Alternative shape used by the reference checker-tls payload sketched
|
||||
// in the docs: cert.{notAfter, sanMatch, chainValid, daysRemaining}.
|
||||
Cert *struct {
|
||||
NotAfter time.Time `json:"notAfter,omitempty"`
|
||||
SANMatch *bool `json:"sanMatch,omitempty"`
|
||||
ChainValid *bool `json:"chainValid,omitempty"`
|
||||
DaysRemaining *int `json:"daysRemaining,omitempty"`
|
||||
SubjectCN string `json:"subjectCN,omitempty"`
|
||||
IssuerCN string `json:"issuerCN,omitempty"`
|
||||
} `json:"cert,omitempty"`
|
||||
|
||||
Rules []struct {
|
||||
Code string `json:"code,omitempty"`
|
||||
Status string `json:"status,omitempty"`
|
||||
Message string `json:"message,omitempty"`
|
||||
} `json:"rules,omitempty"`
|
||||
|
||||
Issues []struct {
|
||||
Code string `json:"code,omitempty"`
|
||||
Severity string `json:"severity,omitempty"`
|
||||
Message string `json:"message,omitempty"`
|
||||
Fix string `json:"fix,omitempty"`
|
||||
} `json:"issues,omitempty"`
|
||||
}
|
||||
|
||||
// address returns the canonical "host:port" string used to match a probe
|
||||
// against one of our discovered endpoints.
|
||||
func (v *tlsProbeView) address() string {
|
||||
if v.Endpoint != "" {
|
||||
return v.Endpoint
|
||||
}
|
||||
if v.Host != "" && v.Port != 0 {
|
||||
return net.JoinHostPort(v.Host, strconv.Itoa(int(v.Port)))
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// certExpiry normalises the two schema shapes into a single (t, ok) pair so
|
||||
// callers don't have to know which one the TLS checker emits.
|
||||
func (v *tlsProbeView) certExpiry() (time.Time, bool) {
|
||||
if !v.NotAfter.IsZero() {
|
||||
return v.NotAfter, true
|
||||
}
|
||||
if v.Cert != nil && !v.Cert.NotAfter.IsZero() {
|
||||
return v.Cert.NotAfter, true
|
||||
}
|
||||
return time.Time{}, false
|
||||
}
|
||||
|
||||
func (v *tlsProbeView) hostnameOK() (bool, bool) {
|
||||
if v.HostnameMatch != nil {
|
||||
return *v.HostnameMatch, true
|
||||
}
|
||||
if v.Cert != nil && v.Cert.SANMatch != nil {
|
||||
return *v.Cert.SANMatch, true
|
||||
}
|
||||
return false, false
|
||||
}
|
||||
|
||||
func (v *tlsProbeView) chainOK() (bool, bool) {
|
||||
if v.ChainValid != nil {
|
||||
return *v.ChainValid, true
|
||||
}
|
||||
if v.Cert != nil && v.Cert.ChainValid != nil {
|
||||
return *v.Cert.ChainValid, true
|
||||
}
|
||||
return false, false
|
||||
}
|
||||
|
||||
// parseTLSRelated decodes a RelatedObservation as a TLS probe, returning nil
|
||||
// when the payload doesn't look like one.
|
||||
//
|
||||
// Two payload shapes are accepted:
|
||||
//
|
||||
// 1. {"probes": {"<ref>": <probe>, …}} — the current convention used by
|
||||
// checker-tls. Each consumer picks its own probe via r.Ref — the value
|
||||
// is the DiscoveryEntry.Ref that the producer originally emitted,
|
||||
// preserved by the host along the lineage chain.
|
||||
// 2. <probe> — a single top-level probe object, kept for back-compat with
|
||||
// callers that pre-date the keyed map and with unit-test fixtures.
|
||||
func parseTLSRelated(r sdk.RelatedObservation) *tlsProbeView {
|
||||
var keyed struct {
|
||||
Probes map[string]tlsProbeView `json:"probes"`
|
||||
}
|
||||
if err := json.Unmarshal(r.Data, &keyed); err == nil && keyed.Probes != nil {
|
||||
if p, ok := keyed.Probes[r.Ref]; ok {
|
||||
return &p
|
||||
}
|
||||
return nil
|
||||
}
|
||||
var v tlsProbeView
|
||||
if err := json.Unmarshal(r.Data, &v); err != nil {
|
||||
return nil
|
||||
}
|
||||
return &v
|
||||
}
|
||||
|
||||
// TLSSummary is what the HTML report renders for each probed endpoint.
|
||||
type TLSSummary struct {
|
||||
Address string
|
||||
TLSVersion string
|
||||
Status string // "ok", "warn", "fail", "info"
|
||||
Detail string
|
||||
NotAfter time.Time
|
||||
DaysRemaining int
|
||||
}
|
||||
|
||||
// tlsCallout captures a cross-checker issue we want to foreground in the
|
||||
// "Action items" section of the HTML report.
|
||||
type tlsCallout struct {
|
||||
Severity string // "warn" or "crit"
|
||||
Title string
|
||||
Body string
|
||||
}
|
||||
|
||||
// foldTLSRelated walks the TLS probes and returns (1) a per-endpoint summary
|
||||
// for rendering, (2) callouts for the top of the report when there's anything
|
||||
// actionable. Callers pass both through the reportData pipeline.
|
||||
func foldTLSRelated(related []sdk.RelatedObservation) (summaries []TLSSummary, callouts []tlsCallout) {
|
||||
for _, r := range related {
|
||||
v := parseTLSRelated(r)
|
||||
if v == nil {
|
||||
continue
|
||||
}
|
||||
sum := buildTLSSummary(v)
|
||||
summaries = append(summaries, sum)
|
||||
callouts = append(callouts, buildTLSCallouts(v, sum.Address)...)
|
||||
}
|
||||
return summaries, callouts
|
||||
}
|
||||
|
||||
func buildTLSSummary(v *tlsProbeView) TLSSummary {
|
||||
s := TLSSummary{Address: v.address(), TLSVersion: v.TLSVersion, Status: "ok"}
|
||||
|
||||
if t, ok := v.certExpiry(); ok {
|
||||
s.NotAfter = t
|
||||
days := int(time.Until(t) / (24 * time.Hour))
|
||||
if v.Cert != nil && v.Cert.DaysRemaining != nil {
|
||||
days = *v.Cert.DaysRemaining
|
||||
}
|
||||
s.DaysRemaining = days
|
||||
switch {
|
||||
case days < 0:
|
||||
s.Status = "fail"
|
||||
s.Detail = fmt.Sprintf("certificate expired %d day(s) ago", -days)
|
||||
case days < 14:
|
||||
s.Status = "warn"
|
||||
s.Detail = fmt.Sprintf("certificate expires in %d day(s)", days)
|
||||
default:
|
||||
s.Detail = fmt.Sprintf("certificate valid for %d day(s)", days)
|
||||
}
|
||||
}
|
||||
|
||||
if ok, has := v.hostnameOK(); has && !ok {
|
||||
s.Status = "fail"
|
||||
s.Detail = "certificate does not cover the endpoint hostname"
|
||||
}
|
||||
if ok, has := v.chainOK(); has && !ok {
|
||||
s.Status = "fail"
|
||||
s.Detail = "certificate chain validation failed"
|
||||
}
|
||||
|
||||
// Explicit issues from the TLS checker outrank our inferred status.
|
||||
for _, iss := range v.Issues {
|
||||
sev := strings.ToLower(iss.Severity)
|
||||
switch sev {
|
||||
case "crit":
|
||||
s.Status = "fail"
|
||||
case "warn":
|
||||
if s.Status != "fail" {
|
||||
s.Status = "warn"
|
||||
}
|
||||
}
|
||||
if iss.Message != "" {
|
||||
s.Detail = iss.Message
|
||||
}
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func buildTLSCallouts(v *tlsProbeView, addr string) []tlsCallout {
|
||||
var out []tlsCallout
|
||||
|
||||
// Structured issues from the TLS checker are the preferred source.
|
||||
for _, iss := range v.Issues {
|
||||
sev := strings.ToLower(iss.Severity)
|
||||
if sev != "crit" && sev != "warn" {
|
||||
continue
|
||||
}
|
||||
callout := tlsCallout{
|
||||
Severity: sev,
|
||||
Title: fmt.Sprintf("TLS on %s: %s", addr, strings.TrimSpace(iss.Message)),
|
||||
}
|
||||
if callout.Title == "TLS on "+addr+": " {
|
||||
callout.Title = "TLS issue on " + addr
|
||||
}
|
||||
if iss.Fix != "" {
|
||||
callout.Body = iss.Fix
|
||||
} else {
|
||||
callout.Body = "See the TLS checker report for details."
|
||||
}
|
||||
out = append(out, callout)
|
||||
}
|
||||
if len(out) > 0 {
|
||||
return out
|
||||
}
|
||||
|
||||
// Fallback: synthesize callouts from structured flags.
|
||||
if t, ok := v.certExpiry(); ok {
|
||||
days := int(time.Until(t) / (24 * time.Hour))
|
||||
if v.Cert != nil && v.Cert.DaysRemaining != nil {
|
||||
days = *v.Cert.DaysRemaining
|
||||
}
|
||||
switch {
|
||||
case days < 0:
|
||||
out = append(out, tlsCallout{
|
||||
Severity: "crit",
|
||||
Title: fmt.Sprintf("Certificate on %s has expired", addr),
|
||||
Body: fmt.Sprintf("Renew it — clients will refuse to connect. Expired %d day(s) ago (valid until %s).", -days, t.Format(time.RFC3339)),
|
||||
})
|
||||
case days < 14:
|
||||
out = append(out, tlsCallout{
|
||||
Severity: "warn",
|
||||
Title: fmt.Sprintf("Certificate on %s expires in %d day(s)", addr, days),
|
||||
Body: fmt.Sprintf("Schedule a renewal. Currently valid until %s.", t.Format(time.RFC3339)),
|
||||
})
|
||||
}
|
||||
}
|
||||
if ok, has := v.chainOK(); has && !ok {
|
||||
out = append(out, tlsCallout{
|
||||
Severity: "crit",
|
||||
Title: fmt.Sprintf("Broken certificate chain on %s", addr),
|
||||
Body: "The TLS checker could not validate the chain. Ensure the server sends the full intermediate chain.",
|
||||
})
|
||||
}
|
||||
if ok, has := v.hostnameOK(); has && !ok {
|
||||
out = append(out, tlsCallout{
|
||||
Severity: "crit",
|
||||
Title: fmt.Sprintf("Certificate does not cover %s", addr),
|
||||
Body: "Add the hostname to the certificate's SANs or point the service at a cert that covers it.",
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
82
internal/dav/tls_related_test.go
Normal file
82
internal/dav/tls_related_test.go
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
package dav
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
sdk "git.happydns.org/checker-sdk-go/checker"
|
||||
)
|
||||
|
||||
func relatedFrom(t *testing.T, payload any) sdk.RelatedObservation {
|
||||
t.Helper()
|
||||
b, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal: %v", err)
|
||||
}
|
||||
return sdk.RelatedObservation{Key: TLSRelatedKey, Data: b}
|
||||
}
|
||||
|
||||
func TestFoldTLSRelated_expiringCertProducesCallout(t *testing.T) {
|
||||
exp := time.Now().Add(5 * 24 * time.Hour)
|
||||
related := []sdk.RelatedObservation{relatedFrom(t, map[string]any{
|
||||
"host": "dav.example.com",
|
||||
"port": 443,
|
||||
"not_after": exp,
|
||||
})}
|
||||
sums, callouts := foldTLSRelated(related)
|
||||
|
||||
if len(sums) != 1 || sums[0].Address != "dav.example.com:443" || sums[0].Status != "warn" {
|
||||
t.Fatalf("summary: %+v", sums)
|
||||
}
|
||||
if len(callouts) != 1 || callouts[0].Severity != "warn" {
|
||||
t.Fatalf("expected a warn callout, got %+v", callouts)
|
||||
}
|
||||
if !strings.Contains(callouts[0].Title, "expires in") {
|
||||
t.Errorf("callout title: %q", callouts[0].Title)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFoldTLSRelated_expiredCertCrit(t *testing.T) {
|
||||
exp := time.Now().Add(-2 * 24 * time.Hour)
|
||||
_, callouts := foldTLSRelated([]sdk.RelatedObservation{relatedFrom(t, map[string]any{
|
||||
"host": "dav.example.com", "port": 443, "not_after": exp,
|
||||
})})
|
||||
if len(callouts) != 1 || callouts[0].Severity != "crit" {
|
||||
t.Fatalf("expected crit for expired cert, got %+v", callouts)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFoldTLSRelated_chainInvalid(t *testing.T) {
|
||||
_, callouts := foldTLSRelated([]sdk.RelatedObservation{relatedFrom(t, map[string]any{
|
||||
"host": "dav.example.com", "port": 443, "chain_valid": false,
|
||||
})})
|
||||
if len(callouts) != 1 || callouts[0].Severity != "crit" {
|
||||
t.Fatalf("expected crit for broken chain, got %+v", callouts)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFoldTLSRelated_explicitIssueWinsOverFlags(t *testing.T) {
|
||||
_, callouts := foldTLSRelated([]sdk.RelatedObservation{relatedFrom(t, map[string]any{
|
||||
"host": "dav.example.com", "port": 443,
|
||||
"chain_valid": false, // would normally synthesize a callout
|
||||
"issues": []map[string]any{
|
||||
{"code": "weak_cipher", "severity": "warn", "message": "TLS 1.0 offered", "fix": "disable TLS <1.2"},
|
||||
},
|
||||
})})
|
||||
// When explicit issues exist, we do not also emit synthesized callouts —
|
||||
// the TLS checker is the source of truth for severity and wording.
|
||||
if len(callouts) != 1 || callouts[0].Severity != "warn" {
|
||||
t.Fatalf("want single warn callout, got %+v", callouts)
|
||||
}
|
||||
if !strings.Contains(callouts[0].Body, "disable TLS") {
|
||||
t.Errorf("fix text lost: %q", callouts[0].Body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFoldTLSRelated_empty(t *testing.T) {
|
||||
if sums, callouts := foldTLSRelated(nil); sums != nil || callouts != nil {
|
||||
t.Errorf("expected nil,nil on nil input, got %+v %+v", sums, callouts)
|
||||
}
|
||||
}
|
||||
168
internal/dav/types.go
Normal file
168
internal/dav/types.go
Normal file
|
|
@ -0,0 +1,168 @@
|
|||
// Package dav holds the code shared between the CalDAV and CardDAV checkers:
|
||||
// discovery (SRV/TXT, /.well-known), OPTIONS probing, PROPFIND helpers, and
|
||||
// the HTML report CSS. The CalDAV/CardDAV-specific collect pipelines live in
|
||||
// their own packages and compose these helpers.
|
||||
package dav
|
||||
|
||||
import "time"
|
||||
|
||||
// Kind distinguishes the two protocol flavours. A single Kind value is carried
|
||||
// end-to-end through a checker run so shared helpers can pick the right
|
||||
// service names, well-known paths, and required DAV classes.
|
||||
type Kind string
|
||||
|
||||
const (
|
||||
KindCalDAV Kind = "caldav"
|
||||
KindCardDAV Kind = "carddav"
|
||||
)
|
||||
|
||||
// ServiceName returns the RFC 6764 SRV service label for kind, with the
|
||||
// leading "_" but without the "_tcp" suffix.
|
||||
func (k Kind) ServiceName(secure bool) string {
|
||||
switch k {
|
||||
case KindCalDAV:
|
||||
if secure {
|
||||
return "_caldavs"
|
||||
}
|
||||
return "_caldav"
|
||||
case KindCardDAV:
|
||||
if secure {
|
||||
return "_carddavs"
|
||||
}
|
||||
return "_carddav"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// WellKnownPath returns the RFC 6764 well-known path for kind.
|
||||
func (k Kind) WellKnownPath() string {
|
||||
return "/.well-known/" + string(k)
|
||||
}
|
||||
|
||||
// RequiredCapability is the string that must appear in the DAV: response
|
||||
// header for the server to qualify as a valid implementation.
|
||||
func (k Kind) RequiredCapability() string {
|
||||
switch k {
|
||||
case KindCalDAV:
|
||||
return "calendar-access"
|
||||
case KindCardDAV:
|
||||
return "addressbook"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// Observation is the root data structure persisted by either checker. The
|
||||
// CalDAV-only fields (Scheduling) are populated for KindCalDAV runs and left
|
||||
// zero-valued for KindCardDAV.
|
||||
type Observation struct {
|
||||
Kind Kind `json:"kind"`
|
||||
Domain string `json:"domain"`
|
||||
HasCredentials bool `json:"has_credentials"`
|
||||
Discovery DiscoveryResult `json:"discovery"`
|
||||
Transport TransportResult `json:"transport"`
|
||||
Options OptionsResult `json:"options"`
|
||||
Principal PrincipalResult `json:"principal"`
|
||||
HomeSet HomeSetResult `json:"home_set"`
|
||||
Collections CollectionsResult `json:"collections"`
|
||||
Report ReportResult `json:"report"`
|
||||
Scheduling *SchedulingResult `json:"scheduling,omitempty"`
|
||||
CollectedAt time.Time `json:"collected_at"`
|
||||
}
|
||||
|
||||
// SRVRecord is a flat, JSON-friendly view of a DNS SRV answer.
|
||||
type SRVRecord struct {
|
||||
Target string `json:"target"`
|
||||
Port uint16 `json:"port"`
|
||||
Priority uint16 `json:"priority"`
|
||||
Weight uint16 `json:"weight"`
|
||||
}
|
||||
|
||||
// DiscoveryResult captures every signal we gathered while locating the
|
||||
// service: SRV secure/plaintext, TXT path hints, well-known redirects, and
|
||||
// the ultimately-resolved context URL.
|
||||
type DiscoveryResult struct {
|
||||
SecureSRV []SRVRecord `json:"secure_srv,omitempty"`
|
||||
PlaintextSRV []SRVRecord `json:"plaintext_srv,omitempty"`
|
||||
SRVError string `json:"srv_error,omitempty"`
|
||||
TXTPath string `json:"txt_path,omitempty"`
|
||||
TXTError string `json:"txt_error,omitempty"`
|
||||
WellKnownURL string `json:"well_known_url,omitempty"`
|
||||
WellKnownCode int `json:"well_known_code,omitempty"`
|
||||
WellKnownChain []string `json:"well_known_chain,omitempty"`
|
||||
WellKnownError string `json:"well_known_error,omitempty"`
|
||||
ContextURL string `json:"context_url,omitempty"`
|
||||
Source string `json:"source,omitempty"` // "explicit", "well-known", "srv-txt"
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// TransportResult records whether the resolved context URL accepts HTTPS
|
||||
// requests. TLS certificate validation is out of scope (a dedicated TLS
|
||||
// checker covers it); we only report the raw transport-level error if any.
|
||||
type TransportResult struct {
|
||||
Reached bool `json:"reached"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// OptionsResult captures the response to an OPTIONS request against the
|
||||
// context URL: which DAV: classes are advertised, which HTTP methods are in
|
||||
// Allow:, and which authentication schemes the server offered.
|
||||
type OptionsResult struct {
|
||||
StatusCode int `json:"status_code"`
|
||||
DAVClasses []string `json:"dav_classes,omitempty"`
|
||||
AllowMethods []string `json:"allow_methods,omitempty"`
|
||||
AuthSchemes []string `json:"auth_schemes,omitempty"`
|
||||
Server string `json:"server,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// PrincipalResult holds the `current-user-principal` URL discovered after
|
||||
// authenticating. Skipped is set to true when no credentials were supplied
|
||||
// (the rule surfaces this as StatusUnknown).
|
||||
type PrincipalResult struct {
|
||||
Skipped bool `json:"skipped,omitempty"`
|
||||
URL string `json:"url,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// HomeSetResult holds the CalDAV `calendar-home-set` or CardDAV
|
||||
// `addressbook-home-set` URL for the authenticated principal.
|
||||
type HomeSetResult struct {
|
||||
Skipped bool `json:"skipped,omitempty"`
|
||||
URL string `json:"url,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// CollectionInfo describes a single discovered calendar or addressbook.
|
||||
type CollectionInfo struct {
|
||||
Path string `json:"path"`
|
||||
Name string `json:"name,omitempty"`
|
||||
Description string `json:"description,omitempty"`
|
||||
MaxResourceSize int64 `json:"max_resource_size,omitempty"`
|
||||
SupportedComponentSet []string `json:"supported_component_set,omitempty"` // CalDAV only
|
||||
SupportedAddressData []string `json:"supported_address_data,omitempty"` // CardDAV only
|
||||
}
|
||||
|
||||
// CollectionsResult is the enumerated content of the home-set.
|
||||
type CollectionsResult struct {
|
||||
Skipped bool `json:"skipped,omitempty"`
|
||||
Items []CollectionInfo `json:"items,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// ReportResult is the outcome of a minimal REPORT probe against the first
|
||||
// collection found (empty calendar-query/addressbook-query).
|
||||
type ReportResult struct {
|
||||
Skipped bool `json:"skipped,omitempty"`
|
||||
QueryOK bool `json:"query_ok,omitempty"`
|
||||
ProbePath string `json:"probe_path,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// SchedulingResult is CalDAV-only: presence of inbox/outbox when the server
|
||||
// advertises the `calendar-schedule` capability.
|
||||
type SchedulingResult struct {
|
||||
Advertised bool `json:"advertised"`
|
||||
InboxURL string `json:"inbox_url,omitempty"`
|
||||
OutboxURL string `json:"outbox_url,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
16
plugin/caldav/plugin.go
Normal file
16
plugin/caldav/plugin.go
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
// Command plugin is the happyDomain Go-plugin entrypoint for the CalDAV
|
||||
// checker. Built with `go build -buildmode=plugin` and loaded at runtime by
|
||||
// happyDomain.
|
||||
package main
|
||||
|
||||
import (
|
||||
"git.happydns.org/checker-dav/caldav"
|
||||
sdk "git.happydns.org/checker-sdk-go/checker"
|
||||
)
|
||||
|
||||
var Version = "custom-build"
|
||||
|
||||
func NewCheckerPlugin() (*sdk.CheckerDefinition, sdk.ObservationProvider, error) {
|
||||
caldav.Version = Version
|
||||
return caldav.Definition(), caldav.Provider(), nil
|
||||
}
|
||||
16
plugin/carddav/plugin.go
Normal file
16
plugin/carddav/plugin.go
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
// Command plugin is the happyDomain Go-plugin entrypoint for the CardDAV
|
||||
// checker. Built with `go build -buildmode=plugin` and loaded at runtime by
|
||||
// happyDomain.
|
||||
package main
|
||||
|
||||
import (
|
||||
"git.happydns.org/checker-dav/carddav"
|
||||
sdk "git.happydns.org/checker-sdk-go/checker"
|
||||
)
|
||||
|
||||
var Version = "custom-build"
|
||||
|
||||
func NewCheckerPlugin() (*sdk.CheckerDefinition, sdk.ObservationProvider, error) {
|
||||
carddav.Version = Version
|
||||
return carddav.Definition(), carddav.Provider(), nil
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue