Compare commits
No commits in common. "v0.1.0" and "master" have entirely different histories.
35 changed files with 377 additions and 318 deletions
22
.drone-manifest.yml
Normal file
22
.drone-manifest.yml
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
image: happydomain/checker-dav:{{#if build.tag}}{{trimPrefix "v" build.tag}}{{else}}latest{{/if}}
|
||||
{{#if build.tags}}
|
||||
tags:
|
||||
{{#each build.tags}}
|
||||
- {{this}}
|
||||
{{/each}}
|
||||
{{/if}}
|
||||
manifests:
|
||||
- image: happydomain/checker-dav:{{#if build.tag}}{{trimPrefix "v" build.tag}}-{{/if}}linux-amd64
|
||||
platform:
|
||||
architecture: amd64
|
||||
os: linux
|
||||
- image: happydomain/checker-dav:{{#if build.tag}}{{trimPrefix "v" build.tag}}-{{/if}}linux-arm64
|
||||
platform:
|
||||
architecture: arm64
|
||||
os: linux
|
||||
variant: v8
|
||||
- image: happydomain/checker-dav:{{#if build.tag}}{{trimPrefix "v" build.tag}}-{{/if}}linux-arm
|
||||
platform:
|
||||
architecture: arm
|
||||
os: linux
|
||||
variant: v7
|
||||
187
.drone.yml
Normal file
187
.drone.yml
Normal file
|
|
@ -0,0 +1,187 @@
|
|||
---
|
||||
kind: pipeline
|
||||
type: docker
|
||||
name: build-amd64
|
||||
|
||||
platform:
|
||||
os: linux
|
||||
arch: amd64
|
||||
|
||||
steps:
|
||||
- name: checker build
|
||||
image: golang:1-alpine
|
||||
commands:
|
||||
- apk add --no-cache git make
|
||||
- make
|
||||
environment:
|
||||
CHECKER_VERSION: "${DRONE_BRANCH}-${DRONE_COMMIT}"
|
||||
CGO_ENABLED: 0
|
||||
when:
|
||||
event:
|
||||
exclude:
|
||||
- tag
|
||||
|
||||
- name: checker build tag
|
||||
image: golang:1-alpine
|
||||
commands:
|
||||
- apk add --no-cache git make
|
||||
- make
|
||||
environment:
|
||||
CHECKER_VERSION: "${DRONE_SEMVER}"
|
||||
CGO_ENABLED: 0
|
||||
when:
|
||||
event:
|
||||
- tag
|
||||
|
||||
- name: publish on Docker Hub
|
||||
image: plugins/docker
|
||||
settings:
|
||||
repo: happydomain/checker-dav
|
||||
auto_tag: true
|
||||
auto_tag_suffix: ${DRONE_STAGE_OS}-${DRONE_STAGE_ARCH}
|
||||
dockerfile: Dockerfile
|
||||
build_args:
|
||||
- CHECKER_VERSION=${DRONE_BRANCH}-${DRONE_COMMIT}
|
||||
username:
|
||||
from_secret: docker_username
|
||||
password:
|
||||
from_secret: docker_password
|
||||
when:
|
||||
event:
|
||||
exclude:
|
||||
- tag
|
||||
|
||||
- name: publish on Docker Hub (tag)
|
||||
image: plugins/docker
|
||||
settings:
|
||||
repo: happydomain/checker-dav
|
||||
auto_tag: true
|
||||
auto_tag_suffix: ${DRONE_STAGE_OS}-${DRONE_STAGE_ARCH}
|
||||
dockerfile: Dockerfile
|
||||
build_args:
|
||||
- CHECKER_VERSION=${DRONE_SEMVER}
|
||||
username:
|
||||
from_secret: docker_username
|
||||
password:
|
||||
from_secret: docker_password
|
||||
when:
|
||||
event:
|
||||
- tag
|
||||
|
||||
trigger:
|
||||
branch:
|
||||
exclude:
|
||||
- renovate/*
|
||||
event:
|
||||
- cron
|
||||
- push
|
||||
- tag
|
||||
|
||||
---
|
||||
kind: pipeline
|
||||
type: docker
|
||||
name: build-arm64
|
||||
|
||||
platform:
|
||||
os: linux
|
||||
arch: arm64
|
||||
|
||||
steps:
|
||||
- name: checker build
|
||||
image: golang:1-alpine
|
||||
commands:
|
||||
- apk add --no-cache git make
|
||||
- make
|
||||
environment:
|
||||
CHECKER_VERSION: "${DRONE_BRANCH}-${DRONE_COMMIT}"
|
||||
CGO_ENABLED: 0
|
||||
when:
|
||||
event:
|
||||
exclude:
|
||||
- tag
|
||||
|
||||
- name: checker build tag
|
||||
image: golang:1-alpine
|
||||
commands:
|
||||
- apk add --no-cache git make
|
||||
- make
|
||||
environment:
|
||||
CHECKER_VERSION: "${DRONE_SEMVER}"
|
||||
CGO_ENABLED: 0
|
||||
when:
|
||||
event:
|
||||
- tag
|
||||
|
||||
- name: publish on Docker Hub
|
||||
image: plugins/docker
|
||||
settings:
|
||||
repo: happydomain/checker-dav
|
||||
auto_tag: true
|
||||
auto_tag_suffix: ${DRONE_STAGE_OS}-${DRONE_STAGE_ARCH}
|
||||
dockerfile: Dockerfile
|
||||
build_args:
|
||||
- CHECKER_VERSION=${DRONE_BRANCH}-${DRONE_COMMIT}
|
||||
username:
|
||||
from_secret: docker_username
|
||||
password:
|
||||
from_secret: docker_password
|
||||
when:
|
||||
event:
|
||||
exclude:
|
||||
- tag
|
||||
|
||||
- name: publish on Docker Hub (tag)
|
||||
image: plugins/docker
|
||||
settings:
|
||||
repo: happydomain/checker-dav
|
||||
auto_tag: true
|
||||
auto_tag_suffix: ${DRONE_STAGE_OS}-${DRONE_STAGE_ARCH}
|
||||
dockerfile: Dockerfile
|
||||
build_args:
|
||||
- CHECKER_VERSION=${DRONE_SEMVER}
|
||||
username:
|
||||
from_secret: docker_username
|
||||
password:
|
||||
from_secret: docker_password
|
||||
when:
|
||||
event:
|
||||
- tag
|
||||
|
||||
trigger:
|
||||
event:
|
||||
- cron
|
||||
- push
|
||||
- tag
|
||||
|
||||
---
|
||||
kind: pipeline
|
||||
name: docker-manifest
|
||||
|
||||
platform:
|
||||
os: linux
|
||||
arch: arm64
|
||||
|
||||
steps:
|
||||
- name: publish on Docker Hub
|
||||
image: plugins/manifest
|
||||
settings:
|
||||
auto_tag: true
|
||||
ignore_missing: true
|
||||
spec: .drone-manifest.yml
|
||||
username:
|
||||
from_secret: docker_username
|
||||
password:
|
||||
from_secret: docker_password
|
||||
|
||||
trigger:
|
||||
branch:
|
||||
exclude:
|
||||
- renovate/*
|
||||
event:
|
||||
- cron
|
||||
- push
|
||||
- tag
|
||||
|
||||
depends_on:
|
||||
- build-amd64
|
||||
- build-arm64
|
||||
|
|
@ -11,5 +11,8 @@ RUN CGO_ENABLED=0 go build -ldflags "-X main.Version=${CHECKER_VERSION}" -o /che
|
|||
|
||||
FROM scratch
|
||||
COPY --from=builder /checker /checker
|
||||
USER 65534:65534
|
||||
EXPOSE 8080
|
||||
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
|
||||
CMD ["/checker", "-healthcheck"]
|
||||
ENTRYPOINT ["/checker"]
|
||||
|
|
|
|||
2
NOTICE
2
NOTICE
|
|
@ -1,4 +1,4 @@
|
|||
checker-dummy
|
||||
checker-dav
|
||||
Copyright (c) 2026 The happyDomain Authors
|
||||
|
||||
This product is licensed under the MIT License (see LICENSE).
|
||||
|
|
|
|||
30
README.md
30
README.md
|
|
@ -38,27 +38,27 @@ view; the default is a JSON metrics dump.
|
|||
|
||||
Both checkers accept the same options:
|
||||
|
||||
- `domain_name` (auto-filled) — required
|
||||
- `username`, `password` — optional Basic credentials; unlock authenticated
|
||||
- `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
|
||||
- `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),
|
||||
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
|
||||
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`,
|
||||
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`
|
||||
7. **REPORT probe**: issue a minimal `calendar-query` / `addressbook-query`
|
||||
against the first collection.
|
||||
8. **Scheduling** (CalDAV only) — if `calendar-schedule` is advertised,
|
||||
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:
|
||||
|
|
@ -71,5 +71,5 @@ The HTML report surfaces the most common failures at the top as callouts:
|
|||
|
||||
## 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
|
||||
- [`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
|
||||
|
|
|
|||
|
|
@ -11,11 +11,9 @@ import (
|
|||
"github.com/emersion/go-webdav/caldav"
|
||||
)
|
||||
|
||||
// Collect runs the full CalDAV probe pipeline for the target domain.
|
||||
//
|
||||
// The pipeline is deliberately resilient: every phase records its outcome into
|
||||
// the Observation and we keep going as long as we have something to probe
|
||||
// with. Rules later translate the captured state into CheckStates.
|
||||
// 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")
|
||||
|
|
@ -38,13 +36,13 @@ func (p *caldavProvider) Collect(ctx context.Context, opts sdk.CheckerOptions) (
|
|||
|
||||
anonClient := dav.NewHTTPClient(timeout)
|
||||
|
||||
// Phase 1 — Discovery
|
||||
// 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)
|
||||
// Phase 2: Transport + OPTIONS (no auth required)
|
||||
optsRes, err := dav.ProbeOptions(ctx, anonClient, obs.Discovery.ContextURL)
|
||||
obs.Options = optsRes
|
||||
if err != nil {
|
||||
|
|
@ -54,7 +52,7 @@ func (p *caldavProvider) Collect(ctx context.Context, opts sdk.CheckerOptions) (
|
|||
obs.Transport = dav.TransportResult{Reached: true}
|
||||
obs.Scheduling.Advertised = optsRes.HasCapability("calendar-schedule")
|
||||
|
||||
// Phase 3 — Authenticated probes
|
||||
// Phase 3: Authenticated probes
|
||||
if !obs.HasCredentials {
|
||||
obs.Principal.Skipped = true
|
||||
obs.HomeSet.Skipped = true
|
||||
|
|
@ -63,9 +61,8 @@ func (p *caldavProvider) Collect(ctx context.Context, opts sdk.CheckerOptions) (
|
|||
return obs, nil
|
||||
}
|
||||
|
||||
authClient := dav.WithBasicAuth(anonClient, user, pass)
|
||||
authClient := dav.WithBasicAuth(anonClient, obs.Discovery.ContextURL, user, pass)
|
||||
|
||||
// Principal.
|
||||
principal, err := dav.FindPrincipal(ctx, authClient, obs.Discovery.ContextURL)
|
||||
if err != nil {
|
||||
obs.Principal.Error = err.Error()
|
||||
|
|
@ -76,7 +73,6 @@ func (p *caldavProvider) Collect(ctx context.Context, opts sdk.CheckerOptions) (
|
|||
}
|
||||
obs.Principal.URL = principal
|
||||
|
||||
// Home-set (via go-webdav's CalDAV client).
|
||||
cal, err := caldav.NewClient(asHTTPClient(authClient), obs.Discovery.ContextURL)
|
||||
if err != nil {
|
||||
obs.HomeSet.Error = err.Error()
|
||||
|
|
@ -93,7 +89,6 @@ func (p *caldavProvider) Collect(ctx context.Context, opts sdk.CheckerOptions) (
|
|||
}
|
||||
obs.HomeSet.URL = home
|
||||
|
||||
// Collections.
|
||||
calendars, err := cal.FindCalendars(ctx, home)
|
||||
if err != nil {
|
||||
obs.Collections.Error = err.Error()
|
||||
|
|
@ -110,7 +105,8 @@ func (p *caldavProvider) Collect(ctx context.Context, opts sdk.CheckerOptions) (
|
|||
}
|
||||
}
|
||||
|
||||
// Report probe — empty calendar-query against the first calendar.
|
||||
// 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
|
||||
|
|
@ -131,7 +127,6 @@ func (p *caldavProvider) Collect(ctx context.Context, opts sdk.CheckerOptions) (
|
|||
obs.Report.Skipped = true
|
||||
}
|
||||
|
||||
// Scheduling inbox/outbox — only probe if advertised.
|
||||
if obs.Scheduling.Advertised {
|
||||
inbox, outbox, err := dav.FindScheduleURLs(ctx, authClient, principal)
|
||||
if err != nil {
|
||||
|
|
@ -144,6 +139,4 @@ func (p *caldavProvider) Collect(ctx context.Context, opts sdk.CheckerOptions) (
|
|||
return obs, nil
|
||||
}
|
||||
|
||||
// asHTTPClient adapts stdlib *http.Client to go-webdav's HTTPClient interface.
|
||||
// The interface has a single Do method so the conversion is free.
|
||||
func asHTTPClient(c *http.Client) webdav.HTTPClient { return c }
|
||||
|
|
|
|||
|
|
@ -7,28 +7,15 @@ import (
|
|||
sdk "git.happydns.org/checker-sdk-go/checker"
|
||||
)
|
||||
|
||||
// Version is the checker version reported in CheckerDefinition.Version.
|
||||
// Overridden at link time by the standalone binary via -ldflags.
|
||||
// Version is overridden at link time by the standalone binary via -ldflags.
|
||||
var Version = "built-in"
|
||||
|
||||
// Definition returns the CheckerDefinition for the CalDAV checker.
|
||||
func Definition() *sdk.CheckerDefinition {
|
||||
func (p *caldavProvider) Definition() *sdk.CheckerDefinition {
|
||||
return &sdk.CheckerDefinition{
|
||||
ID: "caldav",
|
||||
Name: "CalDAV server",
|
||||
Version: Version,
|
||||
Availability: sdk.CheckerAvailability{
|
||||
// The probe itself only needs a domain name (discovery runs on
|
||||
// the whole domain via /.well-known + SRV), so the checker is
|
||||
// always offered at domain scope.
|
||||
ApplyToDomain: true,
|
||||
|
||||
// Also offered at service scope so alerts — including the TLS
|
||||
// alerts derived from the endpoints we publish — surface on a
|
||||
// dedicated "CalDAV" service page rather than on the domain
|
||||
// page. The abstract.CalDAV service type does not exist in the
|
||||
// happyDomain service catalog yet; until it does, this has no
|
||||
// visible effect, but makes the intent explicit.
|
||||
ApplyToService: true,
|
||||
LimitToServices: []string{"abstract.CalDAV"},
|
||||
},
|
||||
|
|
|
|||
|
|
@ -7,9 +7,6 @@ import (
|
|||
sdk "git.happydns.org/checker-sdk-go/checker"
|
||||
)
|
||||
|
||||
// DiscoverEntries implements sdk.DiscoveryPublisher. The SDK server calls
|
||||
// this with the native Go value returned by Collect, so we just type-assert
|
||||
// and delegate to the shared helper.
|
||||
func (p *caldavProvider) DiscoverEntries(data any) ([]sdk.DiscoveryEntry, error) {
|
||||
obs, ok := data.(*dav.Observation)
|
||||
if !ok {
|
||||
|
|
|
|||
|
|
@ -7,12 +7,9 @@ import (
|
|||
sdk "git.happydns.org/checker-sdk-go/checker"
|
||||
)
|
||||
|
||||
// Provider returns the CalDAV observation provider.
|
||||
//
|
||||
// The returned value implements sdk.ObservationProvider, plus the optional
|
||||
// CheckerDefinitionProvider, CheckerHTMLReporter, and EndpointDiscoverer
|
||||
// interfaces so the SDK's HTTP server exposes /definition, /evaluate,
|
||||
// /report, and forwards discovered TLS endpoints to downstream checkers.
|
||||
// Provider's return value also satisfies CheckerDefinitionProvider,
|
||||
// CheckerHTMLReporter, and EndpointDiscoverer; the SDK server probes for
|
||||
// those at runtime.
|
||||
func Provider() sdk.ObservationProvider {
|
||||
return &caldavProvider{}
|
||||
}
|
||||
|
|
@ -21,8 +18,6 @@ type caldavProvider struct{}
|
|||
|
||||
func (p *caldavProvider) Key() sdk.ObservationKey { return ObservationKey }
|
||||
|
||||
func (p *caldavProvider) Definition() *sdk.CheckerDefinition { return Definition() }
|
||||
|
||||
func (p *caldavProvider) RenderForm() []sdk.CheckerOptionField { return dav.InteractiveForm() }
|
||||
|
||||
func (p *caldavProvider) ParseForm(r *http.Request) (sdk.CheckerOptions, error) {
|
||||
|
|
|
|||
|
|
@ -9,16 +9,9 @@ import (
|
|||
"git.happydns.org/checker-dav/internal/dav"
|
||||
)
|
||||
|
||||
// GetHTMLReport implements sdk.CheckerHTMLReporter on *caldavProvider.
|
||||
//
|
||||
// Delegated to the shared renderer in internal/dav so CalDAV and CardDAV
|
||||
// produce visually identical reports; the only differences are the title
|
||||
// and the set of phases (CalDAV includes Scheduling).
|
||||
//
|
||||
// Downstream TLS probes published for the endpoints we discovered are read
|
||||
// via ctx.Related(dav.TLSRelatedKey) and folded into the report (callouts +
|
||||
// dedicated TLS phase) — per
|
||||
// happydomain3/docs/checker-discovery-endpoint.md.
|
||||
// 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 {
|
||||
|
|
|
|||
|
|
@ -1,17 +1,9 @@
|
|||
// Package checker (imported as caldav by the standalone binary) implements
|
||||
// the CalDAV compliance and health checker for happyDomain.
|
||||
//
|
||||
// It is deliberately kept thin: discovery, OPTIONS, PROPFIND, and reporting
|
||||
// helpers live in git.happydns.org/checker-dav/internal/dav, so this package
|
||||
// only wires the CalDAV-specific options, collect pipeline, rules, and HTML
|
||||
// report together.
|
||||
// Package caldav 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"
|
||||
|
||||
// ObservationKey identifies CalDAV observations in happyDomain's store.
|
||||
const ObservationKey = "caldav"
|
||||
|
||||
// Data is the persisted observation shape. Callers read it back via
|
||||
// obs.Get(ctx, ObservationKey, &Data).
|
||||
type Data = dav.Observation
|
||||
|
|
|
|||
|
|
@ -32,13 +32,13 @@ func (p *carddavProvider) Collect(ctx context.Context, opts sdk.CheckerOptions)
|
|||
|
||||
anonClient := dav.NewHTTPClient(timeout)
|
||||
|
||||
// Phase 1 — Discovery
|
||||
// Phase 1: Discovery
|
||||
obs.Discovery = dav.Discover(ctx, anonClient, dav.KindCardDAV, domain, explicit)
|
||||
if obs.Discovery.ContextURL == "" {
|
||||
return obs, nil
|
||||
}
|
||||
|
||||
// Phase 2 — OPTIONS
|
||||
// Phase 2: OPTIONS
|
||||
optsRes, err := dav.ProbeOptions(ctx, anonClient, obs.Discovery.ContextURL)
|
||||
obs.Options = optsRes
|
||||
if err != nil {
|
||||
|
|
@ -47,7 +47,7 @@ func (p *carddavProvider) Collect(ctx context.Context, opts sdk.CheckerOptions)
|
|||
}
|
||||
obs.Transport = dav.TransportResult{Reached: true}
|
||||
|
||||
// Phase 3 — Authenticated
|
||||
// Phase 3: Authenticated
|
||||
if !obs.HasCredentials {
|
||||
obs.Principal.Skipped = true
|
||||
obs.HomeSet.Skipped = true
|
||||
|
|
@ -56,7 +56,7 @@ func (p *carddavProvider) Collect(ctx context.Context, opts sdk.CheckerOptions)
|
|||
return obs, nil
|
||||
}
|
||||
|
||||
authClient := dav.WithBasicAuth(anonClient, user, pass)
|
||||
authClient := dav.WithBasicAuth(anonClient, obs.Discovery.ContextURL, user, pass)
|
||||
|
||||
principal, err := dav.FindPrincipal(ctx, authClient, obs.Discovery.ContextURL)
|
||||
if err != nil {
|
||||
|
|
|
|||
|
|
@ -9,21 +9,12 @@ import (
|
|||
|
||||
var Version = "built-in"
|
||||
|
||||
func Definition() *sdk.CheckerDefinition {
|
||||
func (p *carddavProvider) Definition() *sdk.CheckerDefinition {
|
||||
return &sdk.CheckerDefinition{
|
||||
ID: "carddav",
|
||||
Name: "CardDAV server",
|
||||
Version: Version,
|
||||
Availability: sdk.CheckerAvailability{
|
||||
// Domain scope for the probe itself (discovery runs across the
|
||||
// whole domain via /.well-known + SRV).
|
||||
ApplyToDomain: true,
|
||||
|
||||
// Service scope so downstream TLS alerts attach to a dedicated
|
||||
// "CardDAV" service page instead of the domain page. See the
|
||||
// CalDAV sibling for the rationale; abstract.CardDAV is not in
|
||||
// the happyDomain service catalog yet but the intent is encoded
|
||||
// here ahead of time.
|
||||
ApplyToService: true,
|
||||
LimitToServices: []string{"abstract.CardDAV"},
|
||||
},
|
||||
|
|
|
|||
|
|
@ -7,8 +7,6 @@ import (
|
|||
sdk "git.happydns.org/checker-sdk-go/checker"
|
||||
)
|
||||
|
||||
// DiscoverEntries implements sdk.DiscoveryPublisher. See the CalDAV sibling
|
||||
// for the rationale — the shared helper produces the TLS discovery entries.
|
||||
func (p *carddavProvider) DiscoverEntries(data any) ([]sdk.DiscoveryEntry, error) {
|
||||
obs, ok := data.(*dav.Observation)
|
||||
if !ok {
|
||||
|
|
|
|||
|
|
@ -11,8 +11,7 @@ func Provider() sdk.ObservationProvider { return &carddavProvider{} }
|
|||
|
||||
type carddavProvider struct{}
|
||||
|
||||
func (p *carddavProvider) Key() sdk.ObservationKey { return ObservationKey }
|
||||
func (p *carddavProvider) Definition() *sdk.CheckerDefinition { return Definition() }
|
||||
func (p *carddavProvider) Key() sdk.ObservationKey { return ObservationKey }
|
||||
|
||||
func (p *carddavProvider) RenderForm() []sdk.CheckerOptionField { return dav.InteractiveForm() }
|
||||
|
||||
|
|
|
|||
|
|
@ -9,9 +9,7 @@ import (
|
|||
"git.happydns.org/checker-dav/internal/dav"
|
||||
)
|
||||
|
||||
// GetHTMLReport folds downstream TLS probes (published on our discovered
|
||||
// endpoints) into the CardDAV report via ctx.Related — see the CalDAV
|
||||
// sibling for the rationale.
|
||||
// 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 {
|
||||
|
|
|
|||
|
|
@ -1,7 +1,5 @@
|
|||
// Package checker (imported as carddav) implements the CardDAV compliance
|
||||
// and health checker for happyDomain. See the CalDAV sibling for the shape;
|
||||
// the two packages share ~everything except the protocol-specific home-set
|
||||
// and REPORT calls, which live in collect.go.
|
||||
// Package carddav: 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"
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ import (
|
|||
"log"
|
||||
|
||||
"git.happydns.org/checker-dav/caldav"
|
||||
sdk "git.happydns.org/checker-sdk-go/checker"
|
||||
"git.happydns.org/checker-sdk-go/checker/server"
|
||||
)
|
||||
|
||||
// Version is injected at link time via -ldflags "-X main.Version=...".
|
||||
|
|
@ -16,8 +16,8 @@ var listenAddr = flag.String("listen", ":8080", "HTTP listen address")
|
|||
func main() {
|
||||
flag.Parse()
|
||||
caldav.Version = Version
|
||||
server := sdk.NewServer(caldav.Provider())
|
||||
if err := server.ListenAndServe(*listenAddr); err != nil {
|
||||
srv := server.New(caldav.Provider())
|
||||
if err := srv.ListenAndServe(*listenAddr); err != nil {
|
||||
log.Fatalf("server error: %v", err)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ import (
|
|||
"log"
|
||||
|
||||
"git.happydns.org/checker-dav/carddav"
|
||||
sdk "git.happydns.org/checker-sdk-go/checker"
|
||||
"git.happydns.org/checker-sdk-go/checker/server"
|
||||
)
|
||||
|
||||
// Version is injected at link time via -ldflags "-X main.Version=...".
|
||||
|
|
@ -16,8 +16,8 @@ var listenAddr = flag.String("listen", ":8080", "HTTP listen address")
|
|||
func main() {
|
||||
flag.Parse()
|
||||
carddav.Version = Version
|
||||
server := sdk.NewServer(carddav.Provider())
|
||||
if err := server.ListenAndServe(*listenAddr); err != nil {
|
||||
srv := server.New(carddav.Provider())
|
||||
if err := srv.ListenAndServe(*listenAddr); err != nil {
|
||||
log.Fatalf("server error: %v", err)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
4
go.mod
4
go.mod
|
|
@ -3,8 +3,8 @@ module git.happydns.org/checker-dav
|
|||
go 1.25.0
|
||||
|
||||
require (
|
||||
git.happydns.org/checker-sdk-go v1.2.0
|
||||
git.happydns.org/checker-tls v0.2.0
|
||||
git.happydns.org/checker-sdk-go v1.5.0
|
||||
git.happydns.org/checker-tls v0.6.2
|
||||
)
|
||||
|
||||
require (
|
||||
|
|
|
|||
8
go.sum
8
go.sum
|
|
@ -1,7 +1,7 @@
|
|||
git.happydns.org/checker-sdk-go v1.2.0 h1:v4MpKAz0W3PwP+bxx3pya8w893sVH5xTD1of1cc0TV8=
|
||||
git.happydns.org/checker-sdk-go v1.2.0/go.mod h1:aNAcfYFfbhvH9kJhE0Njp5GX0dQbxdRB0rJ0KvSC5nI=
|
||||
git.happydns.org/checker-tls v0.2.0 h1:2dYpcePBylUc3le76fFlLbxraiLpGESmOhx4NfD7REM=
|
||||
git.happydns.org/checker-tls v0.2.0/go.mod h1:0ZSG0CTP007SHBPE7qInESVIOcW+xgucHUhHgj6MeZ8=
|
||||
git.happydns.org/checker-sdk-go v1.5.0 h1:5uD5Cm6xJ+lwnhbJ09iCXGHbYS9zRh+Yh0NeBHkAPBY=
|
||||
git.happydns.org/checker-sdk-go v1.5.0/go.mod h1:aNAcfYFfbhvH9kJhE0Njp5GX0dQbxdRB0rJ0KvSC5nI=
|
||||
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=
|
||||
|
|
|
|||
|
|
@ -2,38 +2,46 @@ package dav
|
|||
|
||||
import (
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// NewHTTPClient returns an http.Client with a sane default transport for
|
||||
// probing DAV servers. TLS certificate validation uses Go's default rules —
|
||||
// dedicated TLS correctness belongs in a separate checker.
|
||||
// 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 injects HTTP Basic credentials on every request so
|
||||
// callers can pass the same client through go-webdav's own API without losing
|
||||
// auth on internal redirects.
|
||||
// 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) {
|
||||
req.SetBasicAuth(b.user, b.pass)
|
||||
if strings.EqualFold(req.URL.Host, b.host) {
|
||||
req.SetBasicAuth(b.user, b.pass)
|
||||
}
|
||||
return b.next.RoundTrip(req)
|
||||
}
|
||||
|
||||
// WithBasicAuth clones c and attaches Basic credentials to the transport.
|
||||
func WithBasicAuth(c *http.Client, user, pass string) *http.Client {
|
||||
// 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
|
||||
}
|
||||
nc.Transport = &basicAuthRoundTripper{user: user, pass: pass, next: base}
|
||||
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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,13 +10,8 @@ import (
|
|||
"strings"
|
||||
)
|
||||
|
||||
// Discover resolves the DAV context URL for domain following RFC 6764:
|
||||
// /.well-known/{caldav,carddav} first (cheap and works for the common case),
|
||||
// then SRV/TXT. An explicit override shortcuts everything.
|
||||
//
|
||||
// The returned Observation.Discovery is fully populated with whatever was
|
||||
// learned along the way, even if every step fails — the report leans on the
|
||||
// captured evidence to tell the user which leg of the discovery broke.
|
||||
// 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{}
|
||||
|
||||
|
|
@ -26,8 +21,8 @@ func Discover(ctx context.Context, client *http.Client, kind Kind, domain, expli
|
|||
return res
|
||||
}
|
||||
|
||||
// 1. /.well-known — this is the #1 misconfig hotspot, so we always probe
|
||||
// it even if SRV below might have worked, to surface the mistake.
|
||||
// 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)
|
||||
|
|
@ -40,7 +35,6 @@ func Discover(ctx context.Context, client *http.Client, kind Kind, domain, expli
|
|||
res.Source = "well-known"
|
||||
}
|
||||
|
||||
// 2. SRV + TXT fallback (also informational even when well-known worked).
|
||||
discoverSRV(ctx, kind, domain, &res)
|
||||
|
||||
if res.ContextURL == "" && len(res.SecureSRV) > 0 {
|
||||
|
|
@ -59,10 +53,9 @@ func Discover(ctx context.Context, client *http.Client, kind Kind, domain, expli
|
|||
return res
|
||||
}
|
||||
|
||||
// followWellKnown issues a GET against path and follows up to 5 redirects
|
||||
// manually so we can capture the redirect chain. The well-known endpoint
|
||||
// SHOULD return a 3xx (RFC 6764 §5); returning 200 is a common misconfig we
|
||||
// want to flag, and 404 means the site-owner forgot to set it up.
|
||||
// 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
|
||||
|
|
@ -71,7 +64,8 @@ func followWellKnown(ctx context.Context, client *http.Client, u string) (finalU
|
|||
if reqErr != nil {
|
||||
return "", chain, firstCode, reqErr
|
||||
}
|
||||
// Use a no-redirect client snapshot so we observe each hop.
|
||||
// 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)
|
||||
|
|
@ -80,9 +74,6 @@ func followWellKnown(ctx context.Context, client *http.Client, u string) (finalU
|
|||
}
|
||||
resp.Body.Close()
|
||||
chain = append(chain, fmt.Sprintf("%d %s", resp.StatusCode, cur))
|
||||
// We track the *first* response code because the rule cares about
|
||||
// whether /.well-known itself redirected. A chain like 301→200 is
|
||||
// correct; a chain starting with 200 is the misconfig we flag.
|
||||
if i == 0 {
|
||||
firstCode = resp.StatusCode
|
||||
}
|
||||
|
|
@ -151,7 +142,6 @@ func discoverSRV(ctx context.Context, kind Kind, domain string, res *DiscoveryRe
|
|||
}
|
||||
res.PlaintextSRV = plainRes.records
|
||||
|
||||
// Pull the TXT path hint from whichever SRV target we plan to use.
|
||||
var txtName string
|
||||
if len(res.SecureSRV) > 0 {
|
||||
txtName = kind.ServiceName(true) + "._tcp." + trimTrailingDot(res.SecureSRV[0].Target)
|
||||
|
|
|
|||
|
|
@ -9,19 +9,11 @@ import (
|
|||
tlsct "git.happydns.org/checker-tls/contract"
|
||||
)
|
||||
|
||||
// DiscoverEntries derives TLS DiscoveryEntry records worth handing off to
|
||||
// downstream checkers (notably checker-tls) from a completed Observation.
|
||||
//
|
||||
// A CalDAV/CardDAV context URL always implies a direct-TLS HTTPS endpoint,
|
||||
// so we emit a single tls.endpoint.v1 entry for the resolved context URL's
|
||||
// host:port. If the endpoint was reached via SRV, we also surface each SRV
|
||||
// target as its own entry — those are the names operators actually need
|
||||
// certificates on, and they may differ from the queried domain.
|
||||
//
|
||||
// SNI is always populated (equal to Host for CalDAV/CardDAV, since — unlike
|
||||
// XMPP (RFC 6120 §13.7.2.1) — there is no mandated source-domain-vs-target
|
||||
// split: clients negotiate TLS for the hostname they connect to). We fill
|
||||
// the field unconditionally so consumers can rely on it being set.
|
||||
// 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
|
||||
|
|
@ -50,13 +42,12 @@ func DiscoverEntries(obs *Observation) []sdk.DiscoveryEntry {
|
|||
out = append(out, entry)
|
||||
}
|
||||
|
||||
// Primary endpoint: the resolved context URL.
|
||||
if host, port, ok := hostPortFromURL(obs.Discovery.ContextURL); ok {
|
||||
add(host, port)
|
||||
}
|
||||
|
||||
// Secondary endpoints: every TLS SRV target. Clients may connect to any
|
||||
// of them per weight/priority, and all of them need a valid certificate.
|
||||
// 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 {
|
||||
|
|
@ -68,9 +59,6 @@ func DiscoverEntries(obs *Observation) []sdk.DiscoveryEntry {
|
|||
return out
|
||||
}
|
||||
|
||||
// hostPortFromURL extracts the (host, port) pair from an absolute URL. The
|
||||
// port defaults to 443 for https and 80 for http. Returns ok=false for
|
||||
// malformed URLs so callers can silently skip them.
|
||||
func hostPortFromURL(raw string) (host string, port uint16, ok bool) {
|
||||
u, err := url.Parse(raw)
|
||||
if err != nil {
|
||||
|
|
|
|||
|
|
@ -36,7 +36,7 @@ func TestDiscoverEntries_contextURLOnly(t *testing.T) {
|
|||
if got[0].Host != "dav.example.com" || got[0].Port != 443 {
|
||||
t.Errorf("unexpected endpoint: %+v", got[0])
|
||||
}
|
||||
// Direct TLS — no STARTTLS upgrade.
|
||||
// Direct TLS; no STARTTLS upgrade.
|
||||
if got[0].STARTTLS != "" {
|
||||
t.Errorf("STARTTLS = %q, want empty (direct TLS)", got[0].STARTTLS)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,10 +7,8 @@ import (
|
|||
"strings"
|
||||
)
|
||||
|
||||
// ProbeOptions issues an HTTP OPTIONS against url and reports the parsed DAV
|
||||
// headers. A missing DAV: header, or one that does not contain the kind's
|
||||
// required capability, is not treated as a transport error here — the caller
|
||||
// rule decides severity from the parsed values.
|
||||
// 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)
|
||||
|
|
@ -42,8 +40,7 @@ func ProbeOptions(ctx context.Context, client *http.Client, url string) (Options
|
|||
return res, nil
|
||||
}
|
||||
|
||||
// HasCapability returns true when the OPTIONS response advertised cap in the
|
||||
// DAV: header. Matching is case-insensitive, per RFC 4918 §10.1.
|
||||
// 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) {
|
||||
|
|
@ -53,7 +50,6 @@ func (o OptionsResult) HasCapability(cap string) bool {
|
|||
return false
|
||||
}
|
||||
|
||||
// AllowsMethod returns true when the OPTIONS response's Allow: listed m.
|
||||
func (o OptionsResult) AllowsMethod(m string) bool {
|
||||
for _, a := range o.AllowMethods {
|
||||
if strings.EqualFold(a, m) {
|
||||
|
|
@ -63,9 +59,8 @@ func (o OptionsResult) AllowsMethod(m string) bool {
|
|||
return false
|
||||
}
|
||||
|
||||
// parseCSVHeader splits one or more header values on commas, trims, and drops
|
||||
// empties. Multiple headers of the same name (net/http preserves them) are
|
||||
// merged.
|
||||
// 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 {
|
||||
|
|
@ -78,8 +73,6 @@ func parseCSVHeader(values []string) []string {
|
|||
return out
|
||||
}
|
||||
|
||||
// authScheme returns the scheme token from a WWW-Authenticate header value
|
||||
// ("Basic realm=\"x\"" → "Basic"). Empty if the value is malformed.
|
||||
func authScheme(h string) string {
|
||||
h = strings.TrimSpace(h)
|
||||
if h == "" {
|
||||
|
|
|
|||
|
|
@ -28,7 +28,7 @@ func UserOptions() []sdk.CheckerOptionDocumentation {
|
|||
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.",
|
||||
Description: "Optional. Bypasses /.well-known and SRV discovery. Use for servers with a non-standard layout.",
|
||||
Placeholder: "https://dav.example.com/caldav/",
|
||||
},
|
||||
}
|
||||
|
|
@ -56,10 +56,9 @@ func RunOptions() []sdk.CheckerOptionDocumentation {
|
|||
}
|
||||
}
|
||||
|
||||
// InteractiveForm returns the fields shown on the standalone /check page.
|
||||
// Discovery (well-known + SRV) happens inside Collect, so the human only
|
||||
// needs to provide a domain plus the same optional knobs exposed to
|
||||
// happyDomain users.
|
||||
// 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{
|
||||
{
|
||||
|
|
@ -99,9 +98,6 @@ func InteractiveForm() []sdk.CheckerOptionField {
|
|||
}
|
||||
}
|
||||
|
||||
// ParseInteractiveForm turns the submitted /check form into CheckerOptions.
|
||||
// Collect already handles discovery, so there is no extra lookup to do
|
||||
// here beyond validating the inputs.
|
||||
func ParseInteractiveForm(r *http.Request) (sdk.CheckerOptions, error) {
|
||||
domain := strings.TrimSpace(r.FormValue("domain_name"))
|
||||
if domain == "" {
|
||||
|
|
|
|||
|
|
@ -10,8 +10,7 @@ import (
|
|||
"strings"
|
||||
)
|
||||
|
||||
// FindPrincipal requires credentials on client; a 401/403 from the server
|
||||
// bubbles up as the returned error.
|
||||
// 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:">
|
||||
|
|
@ -61,11 +60,8 @@ func FindScheduleURLs(ctx context.Context, client *http.Client, principalURL str
|
|||
return inbox, outbox, nil
|
||||
}
|
||||
|
||||
// ── raw PROPFIND ─────────────────────────────────────────────────────────────
|
||||
|
||||
// multistatus is the subset of the DAV:multistatus XML schema we need to read
|
||||
// principal URLs and scheduling hrefs. It is intentionally permissive — extra
|
||||
// elements are ignored, which makes us tolerant of server-specific extensions.
|
||||
// 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"`
|
||||
|
|
@ -88,8 +84,6 @@ type prop struct {
|
|||
type msProp struct {
|
||||
XMLName xml.Name
|
||||
Hrefs []string `xml:"href"`
|
||||
// A prop may also contain nested <current-user-principal><href>…</href></current-user-principal>,
|
||||
// which the flat Hrefs slice above captures via xml:"href" descent.
|
||||
}
|
||||
|
||||
func (p msProp) firstHref() string {
|
||||
|
|
@ -118,8 +112,7 @@ func (m *multistatus) principalHref() []string {
|
|||
return out
|
||||
}
|
||||
|
||||
// propFind is a small PROPFIND helper tuned for small single-resource probes.
|
||||
// It returns a parsed multistatus; transport-level failures bubble up as err.
|
||||
// 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 {
|
||||
|
|
@ -132,7 +125,9 @@ func propFind(ctx context.Context, client *http.Client, url, depth, body string)
|
|||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
data, err := io.ReadAll(resp.Body)
|
||||
// 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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,19 +8,10 @@ import (
|
|||
sdk "git.happydns.org/checker-sdk-go/checker"
|
||||
)
|
||||
|
||||
// RenderReport turns an Observation into a self-contained HTML document.
|
||||
//
|
||||
// The report foregrounds action items for the failure modes we see most often
|
||||
// (well-known misconfig, missing DAV capability, missing credentials,
|
||||
// downstream TLS issues on the endpoints we published) before showing the
|
||||
// full per-phase evidence.
|
||||
//
|
||||
// tlsRelated is the output of ctx.Related(TLSRelatedKey) at report time. Nil
|
||||
// is fine: the TLS section is simply omitted. This is how the happyDomain
|
||||
// cross-checker composition story (see
|
||||
// happydomain3/docs/checker-discovery-endpoint.md) surfaces certificate
|
||||
// alerts on the CalDAV/CardDAV service page rather than in a parallel TLS
|
||||
// dashboard.
|
||||
// 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
|
||||
|
|
@ -72,8 +63,6 @@ func buildReportData(o *Observation, title string, tlsRelated []sdk.RelatedObser
|
|||
d.Callouts = buildCallouts(o)
|
||||
d.Phases = buildPhases(o)
|
||||
|
||||
// Fold downstream TLS probes (published by checker-tls against the
|
||||
// endpoints we discovered) into the report.
|
||||
tlsSummaries, tlsCallouts := foldTLSRelated(tlsRelated)
|
||||
d.TLSSummaries = tlsSummaries
|
||||
for _, c := range tlsCallouts {
|
||||
|
|
@ -115,8 +104,8 @@ func hasSeverity(phases []phaseData, sev string) bool {
|
|||
return false
|
||||
}
|
||||
|
||||
// buildCallouts surfaces the most common misconfigurations at the top of the
|
||||
// report so operators don't have to read the full phase tree to find the fix.
|
||||
// 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
|
||||
|
|
@ -145,7 +134,7 @@ func buildCallouts(o *Observation) []calloutData {
|
|||
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),
|
||||
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()) {
|
||||
|
|
@ -171,7 +160,7 @@ func exampleContextURL(k Kind) string {
|
|||
func buildPhases(o *Observation) []phaseData {
|
||||
var phases []phaseData
|
||||
|
||||
// Phase 1 — Discovery
|
||||
// Phase 1: Discovery
|
||||
discovery := phaseData{Title: "Discovery"}
|
||||
discovery.Items = append(discovery.Items, itemFor(
|
||||
"/.well-known redirect",
|
||||
|
|
@ -205,7 +194,7 @@ func buildPhases(o *Observation) []phaseData {
|
|||
discovery.Open = hasItemSeverity(discovery.Items, "warn", "fail")
|
||||
phases = append(phases, discovery)
|
||||
|
||||
// Phase 2 — Transport + OPTIONS
|
||||
// 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, ""),
|
||||
|
|
@ -221,7 +210,7 @@ func buildPhases(o *Observation) []phaseData {
|
|||
transport.Open = hasItemSeverity(transport.Items, "warn", "fail")
|
||||
phases = append(phases, transport)
|
||||
|
||||
// Phase 3 — Authenticated
|
||||
// Phase 3: Authenticated
|
||||
auth := phaseData{Title: "Authenticated probes"}
|
||||
auth.Items = append(auth.Items,
|
||||
authItemFor("Principal", o.Principal.URL, o.Principal.Skipped, o.Principal.Error),
|
||||
|
|
@ -232,7 +221,7 @@ func buildPhases(o *Observation) []phaseData {
|
|||
auth.Open = hasItemSeverity(auth.Items, "warn", "fail")
|
||||
phases = append(phases, auth)
|
||||
|
||||
// Phase 4 — Scheduling (CalDAV only)
|
||||
// Phase 4: Scheduling (CalDAV only)
|
||||
if o.Kind == KindCalDAV && o.Scheduling != nil {
|
||||
sched := phaseData{Title: "Scheduling (CalDAV)"}
|
||||
if !o.Scheduling.Advertised {
|
||||
|
|
@ -251,15 +240,14 @@ func buildPhases(o *Observation) []phaseData {
|
|||
return phases
|
||||
}
|
||||
|
||||
// buildTLSPhase turns per-endpoint TLS summaries into a collapsible phase
|
||||
// rendered at the bottom of the report. Open when anything is non-OK so
|
||||
// operators don't need to expand it to see the problem.
|
||||
// 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)
|
||||
label = fmt.Sprintf("%s (%s)", s.Address, s.TLSVersion)
|
||||
}
|
||||
p.Items = append(p.Items, phaseItem{
|
||||
Label: label,
|
||||
|
|
@ -271,8 +259,6 @@ func buildTLSPhase(summaries []TLSSummary) phaseData {
|
|||
return p
|
||||
}
|
||||
|
||||
// ── small helpers used by buildPhases ────────────────────────────────────────
|
||||
|
||||
func wellKnownStatus(d DiscoveryResult) string {
|
||||
if d.Source == "explicit" {
|
||||
return "info"
|
||||
|
|
@ -416,8 +402,6 @@ func summariseSRV(rec []SRVRecord) string {
|
|||
return strings.Join(parts, "; ")
|
||||
}
|
||||
|
||||
// ── template ─────────────────────────────────────────────────────────────────
|
||||
|
||||
var reportTemplate = template.Must(template.New("dav").Parse(`<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
|
|
|
|||
|
|
@ -8,8 +8,7 @@ import (
|
|||
sdk "git.happydns.org/checker-sdk-go/checker"
|
||||
)
|
||||
|
||||
// Rules returns the default rule set for kind. CardDAV gets the full set
|
||||
// except `scheduling`, which only applies to CalDAV.
|
||||
// Rules omits scheduling for CardDAV (CalDAV-only).
|
||||
func Rules(kind Kind, obsKey sdk.ObservationKey) []sdk.CheckRule {
|
||||
rules := []sdk.CheckRule{
|
||||
&discoveryRule{obsKey: obsKey},
|
||||
|
|
@ -26,9 +25,8 @@ func Rules(kind Kind, obsKey sdk.ObservationKey) []sdk.CheckRule {
|
|||
return rules
|
||||
}
|
||||
|
||||
// WorstStatus is a CheckAggregator that picks the highest-severity state from
|
||||
// the individual rule outcomes. StatusUnknown does not degrade the result
|
||||
// unless every rule returned Unknown.
|
||||
// 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 {
|
||||
|
|
@ -60,8 +58,6 @@ func (WorstStatus) Aggregate(states []sdk.CheckState) sdk.CheckState {
|
|||
return out
|
||||
}
|
||||
|
||||
// ── individual rules ─────────────────────────────────────────────────────────
|
||||
|
||||
type baseRule struct {
|
||||
obsKey sdk.ObservationKey
|
||||
}
|
||||
|
|
@ -78,9 +74,8 @@ func (r *baseRule) get(ctx context.Context, obs sdk.ObservationGetter) (*Observa
|
|||
return &d, sdk.CheckState{}
|
||||
}
|
||||
|
||||
// discoveryRule checks that a context URL was resolved and that the
|
||||
// /.well-known endpoint is configured as a redirect (the #1 user-facing
|
||||
// misconfig we want to surface).
|
||||
// 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" }
|
||||
|
|
@ -98,8 +93,8 @@ func (r *discoveryRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter,
|
|||
Message: "could not resolve a context URL (no /.well-known redirect and no SRV record)",
|
||||
}}
|
||||
}
|
||||
// /.well-known returning 200 is legal per RFC but strongly discouraged —
|
||||
// many clients won't follow it. Warn, don't crit.
|
||||
// /.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,
|
||||
|
|
@ -109,7 +104,7 @@ func (r *discoveryRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter,
|
|||
}
|
||||
if disc.Source == "srv-txt" && disc.WellKnownError != "" {
|
||||
return []sdk.CheckState{{
|
||||
Status: sdk.StatusWarn,
|
||||
Status: sdk.StatusInfo,
|
||||
Code: "well_known_missing",
|
||||
Message: fmt.Sprintf("context URL resolved via SRV but /.well-known is broken: %s", disc.WellKnownError),
|
||||
}}
|
||||
|
|
@ -121,8 +116,7 @@ func (r *discoveryRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter,
|
|||
}}
|
||||
}
|
||||
|
||||
// transportRule reports only whether the context URL accepts HTTPS requests.
|
||||
// TLS specifics (cert chain, version) are explicitly out of scope.
|
||||
// transportRule covers reachability only; cert specifics are out of scope.
|
||||
type transportRule struct{ obsKey sdk.ObservationKey }
|
||||
|
||||
func (r *transportRule) Name() string { return "dav_transport" }
|
||||
|
|
@ -142,7 +136,6 @@ func (r *transportRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter,
|
|||
return []sdk.CheckState{{Status: sdk.StatusOK, Code: "transport_ok", Message: "HTTPS reachable"}}
|
||||
}
|
||||
|
||||
// optionsRule verifies the mandatory DAV class is advertised.
|
||||
type optionsRule struct {
|
||||
obsKey sdk.ObservationKey
|
||||
kind Kind
|
||||
|
|
@ -250,7 +243,7 @@ func (r *collectionsRule) Evaluate(ctx context.Context, obs sdk.ObservationGette
|
|||
return []sdk.CheckState{{
|
||||
Status: sdk.StatusWarn,
|
||||
Code: "collections_empty",
|
||||
Message: "home-set is empty — the account has no calendars/addressbooks",
|
||||
Message: "home-set is empty; the account has no calendars/addressbooks",
|
||||
}}
|
||||
}
|
||||
out := make([]sdk.CheckState, 0, len(c.Items))
|
||||
|
|
@ -296,8 +289,7 @@ func (r *reportRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, _
|
|||
return []sdk.CheckState{{Status: sdk.StatusOK, Code: "report_ok", Message: fmt.Sprintf("REPORT ok on %s", rep.ProbePath), Subject: rep.ProbePath}}
|
||||
}
|
||||
|
||||
// schedulingRule is CalDAV-only: if the server advertises calendar-schedule,
|
||||
// the principal should expose inbox/outbox URLs.
|
||||
// schedulingRule is CalDAV-only.
|
||||
type schedulingRule struct{ obsKey sdk.ObservationKey }
|
||||
|
||||
func (r *schedulingRule) Name() string { return "caldav_scheduling" }
|
||||
|
|
|
|||
|
|
@ -11,14 +11,12 @@ import (
|
|||
sdk "git.happydns.org/checker-sdk-go/checker"
|
||||
)
|
||||
|
||||
// TLSRelatedKey is the observation key we expect a TLS checker to publish for
|
||||
// the endpoints we discover. Matches the cross-checker convention documented
|
||||
// in happydomain3/docs/checker-discovery-endpoint.md.
|
||||
// TLSRelatedKey matches the cross-checker convention in
|
||||
// happydomain3/docs/checker-discovery-endpoint.md.
|
||||
const TLSRelatedKey sdk.ObservationKey = "tls_probes"
|
||||
|
||||
// tlsProbeView is a permissive decode of a TLS probe payload. We intentionally
|
||||
// only read the fields we need and tolerate missing ones — the TLS checker's
|
||||
// full schema is owned by that checker.
|
||||
// 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"`
|
||||
|
|
@ -55,8 +53,6 @@ type tlsProbeView struct {
|
|||
} `json:"issues,omitempty"`
|
||||
}
|
||||
|
||||
// address returns the canonical "host:port" string used to match a probe
|
||||
// against one of our discovered endpoints.
|
||||
func (v *tlsProbeView) address() string {
|
||||
if v.Endpoint != "" {
|
||||
return v.Endpoint
|
||||
|
|
@ -67,8 +63,7 @@ func (v *tlsProbeView) address() string {
|
|||
return ""
|
||||
}
|
||||
|
||||
// certExpiry normalises the two schema shapes into a single (t, ok) pair so
|
||||
// callers don't have to know which one the TLS checker emits.
|
||||
// certExpiry hides the two payload shapes from callers.
|
||||
func (v *tlsProbeView) certExpiry() (time.Time, bool) {
|
||||
if !v.NotAfter.IsZero() {
|
||||
return v.NotAfter, true
|
||||
|
|
@ -99,17 +94,9 @@ func (v *tlsProbeView) chainOK() (bool, bool) {
|
|||
return false, false
|
||||
}
|
||||
|
||||
// parseTLSRelated decodes a RelatedObservation as a TLS probe, returning nil
|
||||
// when the payload doesn't look like one.
|
||||
//
|
||||
// Two payload shapes are accepted:
|
||||
//
|
||||
// 1. {"probes": {"<ref>": <probe>, …}} — the current convention used by
|
||||
// checker-tls. Each consumer picks its own probe via r.Ref — the value
|
||||
// is the DiscoveryEntry.Ref that the producer originally emitted,
|
||||
// preserved by the host along the lineage chain.
|
||||
// 2. <probe> — a single top-level probe object, kept for back-compat with
|
||||
// callers that pre-date the keyed map and with unit-test fixtures.
|
||||
// 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"`
|
||||
|
|
@ -127,7 +114,6 @@ func parseTLSRelated(r sdk.RelatedObservation) *tlsProbeView {
|
|||
return &v
|
||||
}
|
||||
|
||||
// TLSSummary is what the HTML report renders for each probed endpoint.
|
||||
type TLSSummary struct {
|
||||
Address string
|
||||
TLSVersion string
|
||||
|
|
@ -137,17 +123,12 @@ type TLSSummary struct {
|
|||
DaysRemaining int
|
||||
}
|
||||
|
||||
// tlsCallout captures a cross-checker issue we want to foreground in the
|
||||
// "Action items" section of the HTML report.
|
||||
type tlsCallout struct {
|
||||
Severity string // "warn" or "crit"
|
||||
Title string
|
||||
Body string
|
||||
}
|
||||
|
||||
// foldTLSRelated walks the TLS probes and returns (1) a per-endpoint summary
|
||||
// for rendering, (2) callouts for the top of the report when there's anything
|
||||
// actionable. Callers pass both through the reportData pipeline.
|
||||
func foldTLSRelated(related []sdk.RelatedObservation) (summaries []TLSSummary, callouts []tlsCallout) {
|
||||
for _, r := range related {
|
||||
v := parseTLSRelated(r)
|
||||
|
|
@ -248,7 +229,7 @@ func buildTLSCallouts(v *tlsProbeView, addr string) []tlsCallout {
|
|||
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)),
|
||||
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{
|
||||
|
|
|
|||
|
|
@ -65,7 +65,7 @@ func TestFoldTLSRelated_explicitIssueWinsOverFlags(t *testing.T) {
|
|||
{"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 —
|
||||
// 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)
|
||||
|
|
|
|||
|
|
@ -1,14 +1,11 @@
|
|||
// Package dav holds the code shared between the CalDAV and CardDAV checkers:
|
||||
// discovery (SRV/TXT, /.well-known), OPTIONS probing, PROPFIND helpers, and
|
||||
// the HTML report CSS. The CalDAV/CardDAV-specific collect pipelines live in
|
||||
// their own packages and compose these helpers.
|
||||
// Package dav holds code shared by the CalDAV and CardDAV checkers:
|
||||
// discovery, OPTIONS probing, PROPFIND helpers, and report rendering.
|
||||
package dav
|
||||
|
||||
import "time"
|
||||
|
||||
// Kind distinguishes the two protocol flavours. A single Kind value is carried
|
||||
// end-to-end through a checker run so shared helpers can pick the right
|
||||
// service names, well-known paths, and required DAV classes.
|
||||
// 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 (
|
||||
|
|
@ -16,8 +13,8 @@ const (
|
|||
KindCardDAV Kind = "carddav"
|
||||
)
|
||||
|
||||
// ServiceName returns the RFC 6764 SRV service label for kind, with the
|
||||
// leading "_" but without the "_tcp" suffix.
|
||||
// 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:
|
||||
|
|
@ -34,13 +31,12 @@ func (k Kind) ServiceName(secure bool) string {
|
|||
return ""
|
||||
}
|
||||
|
||||
// WellKnownPath returns the RFC 6764 well-known path for kind.
|
||||
func (k Kind) WellKnownPath() string {
|
||||
return "/.well-known/" + string(k)
|
||||
}
|
||||
|
||||
// RequiredCapability is the string that must appear in the DAV: response
|
||||
// header for the server to qualify as a valid implementation.
|
||||
// RequiredCapability is the DAV: header token a compliant server must
|
||||
// advertise.
|
||||
func (k Kind) RequiredCapability() string {
|
||||
switch k {
|
||||
case KindCalDAV:
|
||||
|
|
@ -51,9 +47,8 @@ func (k Kind) RequiredCapability() string {
|
|||
return ""
|
||||
}
|
||||
|
||||
// Observation is the root data structure persisted by either checker. The
|
||||
// CalDAV-only fields (Scheduling) are populated for KindCalDAV runs and left
|
||||
// zero-valued for KindCardDAV.
|
||||
// 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"`
|
||||
|
|
@ -69,7 +64,6 @@ type Observation struct {
|
|||
CollectedAt time.Time `json:"collected_at"`
|
||||
}
|
||||
|
||||
// SRVRecord is a flat, JSON-friendly view of a DNS SRV answer.
|
||||
type SRVRecord struct {
|
||||
Target string `json:"target"`
|
||||
Port uint16 `json:"port"`
|
||||
|
|
@ -77,9 +71,8 @@ type SRVRecord struct {
|
|||
Weight uint16 `json:"weight"`
|
||||
}
|
||||
|
||||
// DiscoveryResult captures every signal we gathered while locating the
|
||||
// service: SRV secure/plaintext, TXT path hints, well-known redirects, and
|
||||
// the ultimately-resolved context URL.
|
||||
// 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"`
|
||||
|
|
@ -95,17 +88,13 @@ type DiscoveryResult struct {
|
|||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// TransportResult records whether the resolved context URL accepts HTTPS
|
||||
// requests. TLS certificate validation is out of scope (a dedicated TLS
|
||||
// checker covers it); we only report the raw transport-level error if any.
|
||||
// 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"`
|
||||
}
|
||||
|
||||
// OptionsResult captures the response to an OPTIONS request against the
|
||||
// context URL: which DAV: classes are advertised, which HTTP methods are in
|
||||
// Allow:, and which authentication schemes the server offered.
|
||||
type OptionsResult struct {
|
||||
StatusCode int `json:"status_code"`
|
||||
DAVClasses []string `json:"dav_classes,omitempty"`
|
||||
|
|
@ -115,24 +104,20 @@ type OptionsResult struct {
|
|||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// PrincipalResult holds the `current-user-principal` URL discovered after
|
||||
// authenticating. Skipped is set to true when no credentials were supplied
|
||||
// (the rule surfaces this as StatusUnknown).
|
||||
// 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"`
|
||||
}
|
||||
|
||||
// HomeSetResult holds the CalDAV `calendar-home-set` or CardDAV
|
||||
// `addressbook-home-set` URL for the authenticated principal.
|
||||
type HomeSetResult struct {
|
||||
Skipped bool `json:"skipped,omitempty"`
|
||||
URL string `json:"url,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// CollectionInfo describes a single discovered calendar or addressbook.
|
||||
type CollectionInfo struct {
|
||||
Path string `json:"path"`
|
||||
Name string `json:"name,omitempty"`
|
||||
|
|
@ -142,15 +127,12 @@ type CollectionInfo struct {
|
|||
SupportedAddressData []string `json:"supported_address_data,omitempty"` // CardDAV only
|
||||
}
|
||||
|
||||
// CollectionsResult is the enumerated content of the home-set.
|
||||
type CollectionsResult struct {
|
||||
Skipped bool `json:"skipped,omitempty"`
|
||||
Items []CollectionInfo `json:"items,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// ReportResult is the outcome of a minimal REPORT probe against the first
|
||||
// collection found (empty calendar-query/addressbook-query).
|
||||
type ReportResult struct {
|
||||
Skipped bool `json:"skipped,omitempty"`
|
||||
QueryOK bool `json:"query_ok,omitempty"`
|
||||
|
|
@ -158,8 +140,7 @@ type ReportResult struct {
|
|||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// SchedulingResult is CalDAV-only: presence of inbox/outbox when the server
|
||||
// advertises the `calendar-schedule` capability.
|
||||
// SchedulingResult is CalDAV-only.
|
||||
type SchedulingResult struct {
|
||||
Advertised bool `json:"advertised"`
|
||||
InboxURL string `json:"inbox_url,omitempty"`
|
||||
|
|
|
|||
|
|
@ -1,6 +1,4 @@
|
|||
// Command plugin is the happyDomain Go-plugin entrypoint for the CalDAV
|
||||
// checker. Built with `go build -buildmode=plugin` and loaded at runtime by
|
||||
// happyDomain.
|
||||
// Built with `go build -buildmode=plugin` and loaded at runtime by happyDomain.
|
||||
package main
|
||||
|
||||
import (
|
||||
|
|
@ -12,5 +10,6 @@ var Version = "custom-build"
|
|||
|
||||
func NewCheckerPlugin() (*sdk.CheckerDefinition, sdk.ObservationProvider, error) {
|
||||
caldav.Version = Version
|
||||
return caldav.Definition(), caldav.Provider(), nil
|
||||
prvd := caldav.Provider()
|
||||
return prvd.(sdk.CheckerDefinitionProvider).Definition(), prvd, nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,4 @@
|
|||
// Command plugin is the happyDomain Go-plugin entrypoint for the CardDAV
|
||||
// checker. Built with `go build -buildmode=plugin` and loaded at runtime by
|
||||
// happyDomain.
|
||||
// Built with `go build -buildmode=plugin` and loaded at runtime by happyDomain.
|
||||
package main
|
||||
|
||||
import (
|
||||
|
|
@ -12,5 +10,6 @@ var Version = "custom-build"
|
|||
|
||||
func NewCheckerPlugin() (*sdk.CheckerDefinition, sdk.ObservationProvider, error) {
|
||||
carddav.Version = Version
|
||||
return carddav.Definition(), carddav.Provider(), nil
|
||||
prvd := carddav.Provider()
|
||||
return prvd.(sdk.CheckerDefinitionProvider).Definition(), prvd, nil
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue