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.
This commit is contained in:
nemunaire 2026-04-19 10:27:17 +07:00
commit 087032f6cc
2 changed files with 67 additions and 5 deletions

View file

@ -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) {

View file

@ -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.