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
7d5535fddf
39 changed files with 3179 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
|
||||
16
Dockerfile
Normal file
16
Dockerfile
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
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
|
||||
USER 65534:65534
|
||||
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-dav
|
||||
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
|
||||
142
caldav/collect.go
Normal file
142
caldav/collect.go
Normal file
|
|
@ -0,0 +1,142 @@
|
|||
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 is intentionally resilient: each phase records its outcome and we
|
||||
// keep going as long as the next phase has something to work with. Rules
|
||||
// later turn 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, obs.Discovery.ContextURL, 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
|
||||
|
||||
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
|
||||
|
||||
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,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Empty calendar-query against the first calendar: cheapest probe that
|
||||
// still exercises the REPORT pipeline end-to-end.
|
||||
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
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
func asHTTPClient(c *http.Client) webdav.HTTPClient { return c }
|
||||
41
caldav/definition.go
Normal file
41
caldav/definition.go
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
package caldav
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"git.happydns.org/checker-dav/internal/dav"
|
||||
sdk "git.happydns.org/checker-sdk-go/checker"
|
||||
)
|
||||
|
||||
// Version is overridden at link time by the standalone binary via -ldflags.
|
||||
var Version = "built-in"
|
||||
|
||||
func (p *caldavProvider) Definition() *sdk.CheckerDefinition {
|
||||
return &sdk.CheckerDefinition{
|
||||
ID: "caldav",
|
||||
Name: "CalDAV server",
|
||||
Version: Version,
|
||||
Availability: sdk.CheckerAvailability{
|
||||
ApplyToDomain: true,
|
||||
// Service scope keeps downstream TLS alerts on a dedicated
|
||||
// "CalDAV" page rather than the domain page. abstract.CalDAV
|
||||
// isn't in the catalog yet, so this is a no-op until it is.
|
||||
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,
|
||||
}
|
||||
}
|
||||
16
caldav/discovery.go
Normal file
16
caldav/discovery.go
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
package caldav
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"git.happydns.org/checker-dav/internal/dav"
|
||||
sdk "git.happydns.org/checker-sdk-go/checker"
|
||||
)
|
||||
|
||||
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
|
||||
}
|
||||
25
caldav/provider.go
Normal file
25
caldav/provider.go
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
package caldav
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"git.happydns.org/checker-dav/internal/dav"
|
||||
sdk "git.happydns.org/checker-sdk-go/checker"
|
||||
)
|
||||
|
||||
// Provider's return value also satisfies CheckerDefinitionProvider,
|
||||
// CheckerHTMLReporter, and EndpointDiscoverer; the SDK server probes for
|
||||
// those at runtime.
|
||||
func Provider() sdk.ObservationProvider {
|
||||
return &caldavProvider{}
|
||||
}
|
||||
|
||||
type caldavProvider struct{}
|
||||
|
||||
func (p *caldavProvider) Key() sdk.ObservationKey { return ObservationKey }
|
||||
|
||||
func (p *caldavProvider) RenderForm() []sdk.CheckerOptionField { return dav.InteractiveForm() }
|
||||
|
||||
func (p *caldavProvider) ParseForm(r *http.Request) (sdk.CheckerOptions, error) {
|
||||
return dav.ParseInteractiveForm(r)
|
||||
}
|
||||
22
caldav/report.go
Normal file
22
caldav/report.go
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
package caldav
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
sdk "git.happydns.org/checker-sdk-go/checker"
|
||||
|
||||
"git.happydns.org/checker-dav/internal/dav"
|
||||
)
|
||||
|
||||
// GetHTMLReport delegates to the shared renderer so CalDAV and CardDAV
|
||||
// produce visually identical reports. Downstream TLS probes attached via
|
||||
// ctx.Related(dav.TLSRelatedKey) are folded in.
|
||||
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))
|
||||
}
|
||||
9
caldav/types.go
Normal file
9
caldav/types.go
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
// Package caldav wires the CalDAV-specific options, collect pipeline,
|
||||
// rules, and HTML report on top of the shared helpers in internal/dav.
|
||||
package caldav
|
||||
|
||||
import "git.happydns.org/checker-dav/internal/dav"
|
||||
|
||||
const ObservationKey = "caldav"
|
||||
|
||||
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, obs.Discovery.ContextURL, 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 }
|
||||
39
carddav/definition.go
Normal file
39
carddav/definition.go
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
package carddav
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"git.happydns.org/checker-dav/internal/dav"
|
||||
sdk "git.happydns.org/checker-sdk-go/checker"
|
||||
)
|
||||
|
||||
var Version = "built-in"
|
||||
|
||||
func (p *carddavProvider) Definition() *sdk.CheckerDefinition {
|
||||
return &sdk.CheckerDefinition{
|
||||
ID: "carddav",
|
||||
Name: "CardDAV server",
|
||||
Version: Version,
|
||||
Availability: sdk.CheckerAvailability{
|
||||
ApplyToDomain: true,
|
||||
// See caldav/definition.go for the rationale; abstract.CardDAV
|
||||
// isn't in the catalog yet, so this is a no-op until it is.
|
||||
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,
|
||||
}
|
||||
}
|
||||
16
carddav/discovery.go
Normal file
16
carddav/discovery.go
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
package carddav
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"git.happydns.org/checker-dav/internal/dav"
|
||||
sdk "git.happydns.org/checker-sdk-go/checker"
|
||||
)
|
||||
|
||||
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
|
||||
}
|
||||
20
carddav/provider.go
Normal file
20
carddav/provider.go
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
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) RenderForm() []sdk.CheckerOptionField { return dav.InteractiveForm() }
|
||||
|
||||
func (p *carddavProvider) ParseForm(r *http.Request) (sdk.CheckerOptions, error) {
|
||||
return dav.ParseInteractiveForm(r)
|
||||
}
|
||||
20
carddav/report.go
Normal file
20
carddav/report.go
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
package carddav
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
sdk "git.happydns.org/checker-sdk-go/checker"
|
||||
|
||||
"git.happydns.org/checker-dav/internal/dav"
|
||||
)
|
||||
|
||||
// GetHTMLReport: see the CalDAV sibling.
|
||||
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))
|
||||
}
|
||||
9
carddav/types.go
Normal file
9
carddav/types.go
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
// Package carddav: see the CalDAV sibling. The two share everything except
|
||||
// the protocol-specific home-set and REPORT calls 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"
|
||||
"git.happydns.org/checker-sdk-go/checker/server"
|
||||
)
|
||||
|
||||
// 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
|
||||
srv := server.New(caldav.Provider())
|
||||
if err := srv.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"
|
||||
"git.happydns.org/checker-sdk-go/checker/server"
|
||||
)
|
||||
|
||||
// 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
|
||||
srv := server.New(carddav.Provider())
|
||||
if err := srv.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.3.0
|
||||
git.happydns.org/checker-tls v0.6.2
|
||||
)
|
||||
|
||||
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.3.0 h1:FG2kIhlJCzI0m35EhxSgn4UWc9M4ha6aZTeoChu4l7A=
|
||||
git.happydns.org/checker-sdk-go v1.3.0/go.mod h1:aNAcfYFfbhvH9kJhE0Njp5GX0dQbxdRB0rJ0KvSC5nI=
|
||||
git.happydns.org/checker-tls v0.6.2 h1:8oKia1XlD+tklyqrwzmUgFH1Kw8VLSLLF9suZ7Qr14E=
|
||||
git.happydns.org/checker-tls v0.6.2/go.mod h1:9tpnxg0iOwS+7If64DRG1jqYonUAgxOBuxwfF5mVkL4=
|
||||
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=
|
||||
47
internal/dav/client.go
Normal file
47
internal/dav/client.go
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
package dav
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// NewHTTPClient uses Go's default TLS validation; cert correctness is the
|
||||
// dedicated TLS checker's job, not ours.
|
||||
func NewHTTPClient(timeout time.Duration) *http.Client {
|
||||
return &http.Client{
|
||||
Timeout: timeout,
|
||||
}
|
||||
}
|
||||
|
||||
// basicAuthRoundTripper scopes Basic auth to a single host, so a redirect
|
||||
// to a different host won't leak credentials to a third party. Matches
|
||||
// curl's behaviour without --location-trusted.
|
||||
type basicAuthRoundTripper struct {
|
||||
user, pass string
|
||||
host string
|
||||
next http.RoundTripper
|
||||
}
|
||||
|
||||
func (b *basicAuthRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
if strings.EqualFold(req.URL.Host, b.host) {
|
||||
req.SetBasicAuth(b.user, b.pass)
|
||||
}
|
||||
return b.next.RoundTrip(req)
|
||||
}
|
||||
|
||||
// WithBasicAuth attaches credentials scoped to the host of contextURL.
|
||||
func WithBasicAuth(c *http.Client, contextURL, user, pass string) *http.Client {
|
||||
nc := *c
|
||||
base := c.Transport
|
||||
if base == nil {
|
||||
base = http.DefaultTransport
|
||||
}
|
||||
host := ""
|
||||
if u, err := url.Parse(contextURL); err == nil {
|
||||
host = u.Host
|
||||
}
|
||||
nc.Transport = &basicAuthRoundTripper{user: user, pass: pass, host: host, next: base}
|
||||
return &nc
|
||||
}
|
||||
209
internal/dav/discover.go
Normal file
209
internal/dav/discover.go
Normal file
|
|
@ -0,0 +1,209 @@
|
|||
package dav
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Discover resolves the DAV context URL per RFC 6764. Every leg is recorded
|
||||
// in the result even on failure so the report can pinpoint the broken step.
|
||||
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
|
||||
}
|
||||
|
||||
// Always probe /.well-known even if SRV would suffice: it's the #1
|
||||
// misconfig hotspot and we want to surface it.
|
||||
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"
|
||||
}
|
||||
|
||||
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 follows up to 5 redirects manually so we can record the
|
||||
// chain and the *first* status, since RFC 6764 §5 expects a 3xx and a 200
|
||||
// at this position is the misconfig we want to flag.
|
||||
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
|
||||
}
|
||||
// Snapshot disables the client's own redirect-following so we can
|
||||
// record each hop ourselves.
|
||||
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))
|
||||
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
|
||||
|
||||
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)
|
||||
}
|
||||
85
internal/dav/endpoints.go
Normal file
85
internal/dav/endpoints.go
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
package dav
|
||||
|
||||
import (
|
||||
"log"
|
||||
"net/url"
|
||||
"strconv"
|
||||
|
||||
sdk "git.happydns.org/checker-sdk-go/checker"
|
||||
tlsct "git.happydns.org/checker-tls/contract"
|
||||
)
|
||||
|
||||
// DiscoverEntries hands TLS endpoints to downstream checkers. SRV targets
|
||||
// are emitted alongside the context URL because they're the names operators
|
||||
// must actually put on the certificate, and they often differ from the
|
||||
// queried domain. SNI is always equal to Host: unlike XMPP (RFC 6120
|
||||
// §13.7.2.1), CalDAV/CardDAV has no source-vs-target split.
|
||||
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)
|
||||
}
|
||||
|
||||
if host, port, ok := hostPortFromURL(obs.Discovery.ContextURL); ok {
|
||||
add(host, port)
|
||||
}
|
||||
|
||||
// Every SRV target is reachable via priority/weight, so each one needs
|
||||
// its own valid certificate.
|
||||
for _, r := range obs.Discovery.SecureSRV {
|
||||
port := r.Port
|
||||
if port == 0 {
|
||||
port = 443
|
||||
}
|
||||
add(r.Target, port)
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
94
internal/dav/options.go
Normal file
94
internal/dav/options.go
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
package dav
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// ProbeOptions never treats a missing/incomplete DAV: header as a transport
|
||||
// error: severity is the caller rule's decision, not ours.
|
||||
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 matches case-insensitively 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
|
||||
}
|
||||
|
||||
func (o OptionsResult) AllowsMethod(m string) bool {
|
||||
for _, a := range o.AllowMethods {
|
||||
if strings.EqualFold(a, m) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// parseCSVHeader merges repeated headers (net/http keeps them separate)
|
||||
// into a single split-and-trimmed slice.
|
||||
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
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
125
internal/dav/options_shared.go
Normal file
125
internal/dav/options_shared.go
Normal file
|
|
@ -0,0 +1,125 @@
|
|||
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 mirrors UserOptions+DomainOptions+RunOptions for the
|
||||
// standalone /check page. Discovery happens inside Collect, so all the
|
||||
// human owes us is the domain.
|
||||
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),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
154
internal/dav/principal.go
Normal file
154
internal/dav/principal.go
Normal file
|
|
@ -0,0 +1,154 @@
|
|||
package dav
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/xml"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// FindPrincipal requires authenticated credentials on client.
|
||||
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
|
||||
}
|
||||
|
||||
// multistatus is intentionally a permissive subset: unknown elements are
|
||||
// ignored so server-specific extensions don't break parsing.
|
||||
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"`
|
||||
}
|
||||
|
||||
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 tuned for small single-resource probes; not for large listings.
|
||||
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()
|
||||
// 10 MiB cap: probes here read a handful of props on one resource; more
|
||||
// is either misbehaviour or an attempt at memory exhaustion.
|
||||
data, err := io.ReadAll(io.LimitReader(resp.Body, 10<<20))
|
||||
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()
|
||||
}
|
||||
459
internal/dav/report.go
Normal file
459
internal/dav/report.go
Normal file
|
|
@ -0,0 +1,459 @@
|
|||
package dav
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"html/template"
|
||||
"strings"
|
||||
|
||||
sdk "git.happydns.org/checker-sdk-go/checker"
|
||||
)
|
||||
|
||||
// RenderReport foregrounds the high-frequency failure modes (well-known
|
||||
// misconfig, missing DAV class, missing credentials, downstream TLS issues)
|
||||
// before the full per-phase evidence. tlsRelated is what the host stitched
|
||||
// from checker-tls; nil simply omits the TLS section.
|
||||
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)
|
||||
|
||||
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 pulls common misconfigurations to the top so operators
|
||||
// don't have to expand the 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 auto-opens when anything is non-OK so the failure is
|
||||
// visible without an extra click.
|
||||
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
|
||||
}
|
||||
|
||||
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, "; ")
|
||||
}
|
||||
|
||||
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; }
|
||||
`
|
||||
322
internal/dav/rules.go
Normal file
322
internal/dav/rules.go
Normal file
|
|
@ -0,0 +1,322 @@
|
|||
package dav
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
sdk "git.happydns.org/checker-sdk-go/checker"
|
||||
)
|
||||
|
||||
// Rules omits scheduling for CardDAV (CalDAV-only).
|
||||
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 picks the highest-severity state. Unknown only wins if every
|
||||
// rule was 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
|
||||
}
|
||||
|
||||
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 surfaces the #1 user-facing misconfig: a missing or
|
||||
// non-redirect /.well-known.
|
||||
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=200 is legal but 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.StatusInfo,
|
||||
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 covers reachability only; cert specifics are 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"}}
|
||||
}
|
||||
|
||||
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.
|
||||
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)}}
|
||||
}
|
||||
257
internal/dav/tls_related.go
Normal file
257
internal/dav/tls_related.go
Normal file
|
|
@ -0,0 +1,257 @@
|
|||
package dav
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
sdk "git.happydns.org/checker-sdk-go/checker"
|
||||
)
|
||||
|
||||
// TLSRelatedKey matches the cross-checker convention in
|
||||
// happydomain3/docs/checker-discovery-endpoint.md.
|
||||
const TLSRelatedKey sdk.ObservationKey = "tls_probes"
|
||||
|
||||
// tlsProbeView decodes only the fields we actually use; the full TLS schema
|
||||
// belongs to checker-tls and we don't want to track its evolution here.
|
||||
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"`
|
||||
}
|
||||
|
||||
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 hides the two payload shapes from callers.
|
||||
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 accepts both the keyed {"probes": {"<ref>": …}} shape
|
||||
// (current checker-tls output, picked by r.Ref) and a bare top-level probe
|
||||
// (legacy/test fixtures). Returns nil for anything else.
|
||||
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
|
||||
}
|
||||
|
||||
type TLSSummary struct {
|
||||
Address string
|
||||
TLSVersion string
|
||||
Status string // "ok", "warn", "fail", "info"
|
||||
Detail string
|
||||
NotAfter time.Time
|
||||
DaysRemaining int
|
||||
}
|
||||
|
||||
type tlsCallout struct {
|
||||
Severity string // "warn" or "crit"
|
||||
Title string
|
||||
Body string
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
149
internal/dav/types.go
Normal file
149
internal/dav/types.go
Normal file
|
|
@ -0,0 +1,149 @@
|
|||
// Package dav holds code shared by the CalDAV and CardDAV checkers:
|
||||
// discovery, OPTIONS probing, PROPFIND helpers, and report rendering.
|
||||
package dav
|
||||
|
||||
import "time"
|
||||
|
||||
// Kind is carried end-to-end through a run so shared helpers branch on it
|
||||
// rather than duplicating per-protocol code.
|
||||
type Kind string
|
||||
|
||||
const (
|
||||
KindCalDAV Kind = "caldav"
|
||||
KindCardDAV Kind = "carddav"
|
||||
)
|
||||
|
||||
// ServiceName returns the RFC 6764 SRV label, 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 ""
|
||||
}
|
||||
|
||||
func (k Kind) WellKnownPath() string {
|
||||
return "/.well-known/" + string(k)
|
||||
}
|
||||
|
||||
// RequiredCapability is the DAV: header token a compliant server must
|
||||
// advertise.
|
||||
func (k Kind) RequiredCapability() string {
|
||||
switch k {
|
||||
case KindCalDAV:
|
||||
return "calendar-access"
|
||||
case KindCardDAV:
|
||||
return "addressbook"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// Observation is what each checker persists. Scheduling is CalDAV-only and
|
||||
// left nil for CardDAV.
|
||||
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"`
|
||||
}
|
||||
|
||||
type SRVRecord struct {
|
||||
Target string `json:"target"`
|
||||
Port uint16 `json:"port"`
|
||||
Priority uint16 `json:"priority"`
|
||||
Weight uint16 `json:"weight"`
|
||||
}
|
||||
|
||||
// DiscoveryResult records every signal seen during lookup, even on failure,
|
||||
// so the report can pinpoint which leg of discovery broke.
|
||||
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 is intentionally minimal: cert validation is out of scope
|
||||
// here, a dedicated TLS checker owns it.
|
||||
type TransportResult struct {
|
||||
Reached bool `json:"reached"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
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.Skipped is set when no credentials were supplied; the
|
||||
// rule turns that into StatusUnknown rather than a failure.
|
||||
type PrincipalResult struct {
|
||||
Skipped bool `json:"skipped,omitempty"`
|
||||
URL string `json:"url,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
type HomeSetResult struct {
|
||||
Skipped bool `json:"skipped,omitempty"`
|
||||
URL string `json:"url,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
type CollectionsResult struct {
|
||||
Skipped bool `json:"skipped,omitempty"`
|
||||
Items []CollectionInfo `json:"items,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
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.
|
||||
type SchedulingResult struct {
|
||||
Advertised bool `json:"advertised"`
|
||||
InboxURL string `json:"inbox_url,omitempty"`
|
||||
OutboxURL string `json:"outbox_url,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
15
plugin/caldav/plugin.go
Normal file
15
plugin/caldav/plugin.go
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
// 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
|
||||
prvd := caldav.Provider()
|
||||
return prvd.(sdk.CheckerDefinitionProvider).Definition(), prvd, nil
|
||||
}
|
||||
15
plugin/carddav/plugin.go
Normal file
15
plugin/carddav/plugin.go
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
// 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
|
||||
prvd := carddav.Provider()
|
||||
return prvd.(sdk.CheckerDefinitionProvider).Definition(), prvd, nil
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue