Initial commit
CalDAV and CardDAV checkers sharing a single Go module. Discovery follows RFC 6764 (/.well-known + SRV/TXT), authenticated probes cover principal, home-set, collections and a minimal REPORT query on top of go-webdav. Common shape in internal/dav/; CalDAV adds a scheduling rule. Surfaces its context URL (and each secure-SRV target) as TLS endpoints via the EndpointDiscoverer interface, so the dedicated TLS checker can pick them up without re-parsing observations. HTML report foregrounds common misconfigs (well-known returning 200, missing SRV, plaintext-only SRV, missing DAV capability, skipped auth phase) as action-item callouts before the full phase breakdown.
This commit is contained in:
commit
7eb0dbddc7
39 changed files with 3324 additions and 0 deletions
219
internal/dav/discover.go
Normal file
219
internal/dav/discover.go
Normal file
|
|
@ -0,0 +1,219 @@
|
|||
package dav
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Discover resolves the DAV context URL for domain following RFC 6764:
|
||||
// /.well-known/{caldav,carddav} first (cheap and works for the common case),
|
||||
// then SRV/TXT. An explicit override shortcuts everything.
|
||||
//
|
||||
// The returned Observation.Discovery is fully populated with whatever was
|
||||
// learned along the way, even if every step fails — the report leans on the
|
||||
// captured evidence to tell the user which leg of the discovery broke.
|
||||
func Discover(ctx context.Context, client *http.Client, kind Kind, domain, explicitURL string) DiscoveryResult {
|
||||
res := DiscoveryResult{}
|
||||
|
||||
if explicitURL != "" {
|
||||
res.ContextURL = explicitURL
|
||||
res.Source = "explicit"
|
||||
return res
|
||||
}
|
||||
|
||||
// 1. /.well-known — this is the #1 misconfig hotspot, so we always probe
|
||||
// it even if SRV below might have worked, to surface the mistake.
|
||||
wellKnown := "https://" + domain + kind.WellKnownPath()
|
||||
res.WellKnownURL = wellKnown
|
||||
ctxURL, chain, code, err := followWellKnown(ctx, client, wellKnown)
|
||||
res.WellKnownCode = code
|
||||
res.WellKnownChain = chain
|
||||
if err != nil {
|
||||
res.WellKnownError = err.Error()
|
||||
} else if ctxURL != "" {
|
||||
res.ContextURL = ctxURL
|
||||
res.Source = "well-known"
|
||||
}
|
||||
|
||||
// 2. SRV + TXT fallback (also informational even when well-known worked).
|
||||
discoverSRV(ctx, kind, domain, &res)
|
||||
|
||||
if res.ContextURL == "" && len(res.SecureSRV) > 0 {
|
||||
target := res.SecureSRV[0]
|
||||
path := res.TXTPath
|
||||
if path == "" {
|
||||
path = "/"
|
||||
}
|
||||
res.ContextURL = srvURL(target, path, true)
|
||||
res.Source = "srv-txt"
|
||||
}
|
||||
|
||||
if res.ContextURL == "" && res.Error == "" {
|
||||
res.Error = "could not resolve a context URL via /.well-known or SRV"
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
// followWellKnown issues a GET against path and follows up to 5 redirects
|
||||
// manually so we can capture the redirect chain. The well-known endpoint
|
||||
// SHOULD return a 3xx (RFC 6764 §5); returning 200 is a common misconfig we
|
||||
// want to flag, and 404 means the site-owner forgot to set it up.
|
||||
func followWellKnown(ctx context.Context, client *http.Client, u string) (finalURL string, chain []string, firstCode int, err error) {
|
||||
chain = make([]string, 0, 5)
|
||||
cur := u
|
||||
for i := 0; i < 5; i++ {
|
||||
req, reqErr := http.NewRequestWithContext(ctx, http.MethodGet, cur, nil)
|
||||
if reqErr != nil {
|
||||
return "", chain, firstCode, reqErr
|
||||
}
|
||||
// Use a no-redirect client snapshot so we observe each hop.
|
||||
c := *client
|
||||
c.CheckRedirect = func(_ *http.Request, _ []*http.Request) error { return http.ErrUseLastResponse }
|
||||
resp, doErr := c.Do(req)
|
||||
if doErr != nil {
|
||||
return "", chain, firstCode, doErr
|
||||
}
|
||||
resp.Body.Close()
|
||||
chain = append(chain, fmt.Sprintf("%d %s", resp.StatusCode, cur))
|
||||
// We track the *first* response code because the rule cares about
|
||||
// whether /.well-known itself redirected. A chain like 301→200 is
|
||||
// correct; a chain starting with 200 is the misconfig we flag.
|
||||
if i == 0 {
|
||||
firstCode = resp.StatusCode
|
||||
}
|
||||
|
||||
if resp.StatusCode >= 300 && resp.StatusCode < 400 {
|
||||
loc := resp.Header.Get("Location")
|
||||
if loc == "" {
|
||||
return "", chain, firstCode, errors.New("redirect with empty Location header")
|
||||
}
|
||||
next, parseErr := resolveLocation(cur, loc)
|
||||
if parseErr != nil {
|
||||
return "", chain, firstCode, parseErr
|
||||
}
|
||||
cur = next
|
||||
continue
|
||||
}
|
||||
|
||||
if resp.StatusCode == http.StatusOK {
|
||||
return cur, chain, firstCode, nil
|
||||
}
|
||||
|
||||
return "", chain, firstCode, fmt.Errorf("unexpected status %d", resp.StatusCode)
|
||||
}
|
||||
return "", chain, firstCode, errors.New("too many redirects")
|
||||
}
|
||||
|
||||
func resolveLocation(base, loc string) (string, error) {
|
||||
baseURL, err := url.Parse(base)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
locURL, err := url.Parse(loc)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return baseURL.ResolveReference(locURL).String(), nil
|
||||
}
|
||||
|
||||
func discoverSRV(ctx context.Context, kind Kind, domain string, res *DiscoveryResult) {
|
||||
resolver := net.DefaultResolver
|
||||
|
||||
type srvResult struct {
|
||||
records []SRVRecord
|
||||
err error
|
||||
}
|
||||
secureCh := make(chan srvResult, 1)
|
||||
plainCh := make(chan srvResult, 1)
|
||||
go func() {
|
||||
r, err := lookupSRV(ctx, resolver, kind.ServiceName(true), "tcp", domain)
|
||||
secureCh <- srvResult{r, err}
|
||||
}()
|
||||
go func() {
|
||||
r, err := lookupSRV(ctx, resolver, kind.ServiceName(false), "tcp", domain)
|
||||
plainCh <- srvResult{r, err}
|
||||
}()
|
||||
|
||||
secureRes := <-secureCh
|
||||
if secureRes.err != nil && !isNoSuchHost(secureRes.err) {
|
||||
res.SRVError = secureRes.err.Error()
|
||||
}
|
||||
res.SecureSRV = secureRes.records
|
||||
|
||||
plainRes := <-plainCh
|
||||
if plainRes.err != nil && !isNoSuchHost(plainRes.err) && res.SRVError == "" {
|
||||
res.SRVError = plainRes.err.Error()
|
||||
}
|
||||
res.PlaintextSRV = plainRes.records
|
||||
|
||||
// Pull the TXT path hint from whichever SRV target we plan to use.
|
||||
var txtName string
|
||||
if len(res.SecureSRV) > 0 {
|
||||
txtName = kind.ServiceName(true) + "._tcp." + trimTrailingDot(res.SecureSRV[0].Target)
|
||||
} else if len(res.PlaintextSRV) > 0 {
|
||||
txtName = kind.ServiceName(false) + "._tcp." + trimTrailingDot(res.PlaintextSRV[0].Target)
|
||||
}
|
||||
if txtName != "" {
|
||||
txts, err := resolver.LookupTXT(ctx, txtName)
|
||||
if err != nil && !isNoSuchHost(err) {
|
||||
res.TXTError = err.Error()
|
||||
}
|
||||
for _, t := range txts {
|
||||
if strings.HasPrefix(t, "path=") {
|
||||
res.TXTPath = strings.TrimPrefix(t, "path=")
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func lookupSRV(ctx context.Context, r *net.Resolver, service, proto, name string) ([]SRVRecord, error) {
|
||||
_, addrs, err := r.LookupSRV(ctx, strings.TrimPrefix(service, "_"), proto, name)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]SRVRecord, 0, len(addrs))
|
||||
for _, a := range addrs {
|
||||
out = append(out, SRVRecord{
|
||||
Target: trimTrailingDot(a.Target),
|
||||
Port: a.Port,
|
||||
Priority: a.Priority,
|
||||
Weight: a.Weight,
|
||||
})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func srvURL(r SRVRecord, path string, secure bool) string {
|
||||
scheme := "https"
|
||||
defaultPort := uint16(443)
|
||||
if !secure {
|
||||
scheme = "http"
|
||||
defaultPort = 80
|
||||
}
|
||||
host := r.Target
|
||||
if r.Port != defaultPort {
|
||||
host = fmt.Sprintf("%s:%d", r.Target, r.Port)
|
||||
}
|
||||
if !strings.HasPrefix(path, "/") {
|
||||
path = "/" + path
|
||||
}
|
||||
return scheme + "://" + host + path
|
||||
}
|
||||
|
||||
func trimTrailingDot(s string) string {
|
||||
return strings.TrimSuffix(s, ".")
|
||||
}
|
||||
|
||||
func isNoSuchHost(err error) bool {
|
||||
var dnsErr *net.DNSError
|
||||
if errors.As(err, &dnsErr) {
|
||||
return dnsErr.IsNotFound
|
||||
}
|
||||
return false
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue