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