From 087032f6ccffeef715f005c1d51e61e1e757ffc0 Mon Sep 17 00:00:00 2001 From: Pierre-Olivier Mercier Date: Sun, 19 Apr 2026 10:27:17 +0700 Subject: [PATCH] checker: add DiscoveryPublisher interface for cross-checker discovery Introduce a DiscoveryEntry struct and an optional DiscoveryPublisher interface that providers can co-implement to declare things worth probing by other checkers (TLS endpoints, HTTP probes, ACME challenges, DNSSEC keys, ...) without having to re-parse raw observations. DiscoveryEntry carries an opaque Payload: the SDK does not interpret it. Producers and consumers agree on the Payload schema through a separate contract (eg. a small shared Go package imported by both) identified by the free-form Type string. This keeps the SDK free of protocol-specific concepts; new entry families can appear without touching it. The /collect HTTP handler type-asserts the provider against DiscoveryPublisher immediately after Collect and forwards the resulting entries in ExternalCollectResponse.Entries. --- checker/server.go | 18 +++++++++++++--- checker/types.go | 54 +++++++++++++++++++++++++++++++++++++++++++++-- 2 files changed, 67 insertions(+), 5 deletions(-) diff --git a/checker/server.go b/checker/server.go index d9b7f61..db7f88c 100644 --- a/checker/server.go +++ b/checker/server.go @@ -256,9 +256,21 @@ func (s *Server) handleCollect(w http.ResponseWriter, r *http.Request) { return } - writeJSON(w, http.StatusOK, ExternalCollectResponse{ - Data: json.RawMessage(raw), - }) + resp := ExternalCollectResponse{Data: json.RawMessage(raw)} + + // Harvest discovery entries from the native Go value, before it goes + // out of scope. No re-parse; DiscoverEntries operates on the same + // object that was just marshaled above. + if dp, ok := s.provider.(DiscoveryPublisher); ok { + entries, derr := dp.DiscoverEntries(data) + if derr != nil { + log.Printf("DiscoverEntries failed: %v", derr) + } else { + resp.Entries = entries + } + } + + writeJSON(w, http.StatusOK, resp) } func (s *Server) handleEvaluate(w http.ResponseWriter, r *http.Request) { diff --git a/checker/types.go b/checker/types.go index 08f7431..2c26a63 100644 --- a/checker/types.go +++ b/checker/types.go @@ -41,6 +41,11 @@ const ( AutoFillZone = "zone" AutoFillServiceType = "service_type" AutoFillService = "service" + + // AutoFillDiscoveryEntries receives DiscoveryEntry records published by + // other checkers on the same target. The host does not pre-filter by + // Type; consumers pick the contracts they understand and ignore the rest. + AutoFillDiscoveryEntries = "discovery_entries" ) // CheckTarget identifies the resource a check applies to. Identifiers are @@ -314,8 +319,53 @@ type ExternalCollectRequest struct { // ExternalCollectResponse is returned by POST /collect on a remote checker endpoint. type ExternalCollectResponse struct { - Data json.RawMessage `json:"data,omitempty"` - Error string `json:"error,omitempty"` + Data json.RawMessage `json:"data,omitempty"` + Entries []DiscoveryEntry `json:"entries,omitempty"` + Error string `json:"error,omitempty"` +} + +// DiscoveryEntry is a single "thing worth probing" declared by a checker as a +// by-product of its collection, intended to be consumed by other checkers +// without having to re-parse raw observations. +// +// The SDK treats Payload as an opaque byte string: producer and consumer +// checkers agree on a schema through a separate contract (typically a small +// shared Go package imported by both). This keeps the SDK free of +// protocol-specific concepts; new entry families (TLS endpoint, HTTP probe, +// ACME challenge, DNSSEC key, …) can appear without touching it. +// +// Entries are ingested by happyDomain into a separate index. Each new +// collection from the same source atomically replaces the set of entries +// previously published for the same (producer, target) pair. +type DiscoveryEntry struct { + // Type names the contract Payload follows, e.g. "tls.endpoint" or + // "http.probe". Producers and consumers match on this string; the SDK + // does not interpret it. Stick to a reverse-DNS-ish convention so that + // independent contracts do not collide. + Type string `json:"type"` + + // Ref is a stable per-entry identifier chosen by the producer. The host + // uses it to dedupe entries across repeated collections and to link + // related observations back to this entry (RelatedObservation.Ref). Two + // producers may reuse the same Ref space; the host namespaces them by + // (producer, target). + Ref string `json:"ref"` + + // Payload is the entry-specific data, in the format defined by the + // contract named in Type. Opaque to the SDK. + Payload json.RawMessage `json:"payload"` +} + +// DiscoveryPublisher is an optional interface an ObservationProvider can +// co-implement to declare DiscoveryEntry records derived from the value it +// just collected. +// +// The host invokes DiscoverEntries immediately after Collect, passing the +// native Go value returned by Collect (no JSON round-trip). Implementations +// should therefore type-assert data to their concrete collection type and +// marshal each contract payload themselves. +type DiscoveryPublisher interface { + DiscoverEntries(data any) ([]DiscoveryEntry, error) } // ExternalEvaluateRequest is sent to POST /evaluate on a remote checker endpoint.