Expose one rule per source with rule-scoped options
All checks were successful
continuous-integration/drone/tag Build is passing
continuous-integration/drone/push Build is passing

Each registered Source now becomes its own CheckRule (name = source ID)
implementing CheckRuleWithOptions, so the host can toggle blacklists
individually and the per-source option fields show up under the rule
that owns them instead of one flat global option list.

Collect honours the host's per-rule enable map (via the SDK's
RuleEnabled context helper) and skips the network call for disabled
sources entirely, not just their evaluation.
This commit is contained in:
nemunaire 2026-05-19 22:10:32 +08:00
commit ce59a976d5
5 changed files with 49 additions and 51 deletions

View file

@ -38,6 +38,11 @@ func (p *blacklistProvider) Collect(ctx context.Context, opts sdk.CheckerOptions
var wg sync.WaitGroup var wg sync.WaitGroup
for i, s := range sources { for i, s := range sources {
// The host disables a source by disabling its rule (rule name == source ID).
// Skip the network call entirely; Evaluate is short-circuited host-side.
if !sdk.RuleEnabled(ctx, s.ID()) {
continue
}
wg.Add(1) wg.Add(1)
go func(i int, s Source) { go func(i int, s Source) {
defer wg.Done() defer wg.Done()

View file

@ -9,10 +9,9 @@ import (
// Version is overridden at link time by the standalone or plugin entrypoints. // Version is overridden at link time by the standalone or plugin entrypoints.
var Version = "built-in" var Version = "built-in"
// Definition assembles the checker definition by aggregating each // Definition assembles the checker definition. Per-source option fields
// registered Source's options into the SDK's audience-grouped layout. // live on each per-source rule (CheckRuleWithOptions); the global Options
// Adding a source automatically adds its option fields here: no edit // only carries the shared domain target.
// to this file needed.
func Definition() *sdk.CheckerDefinition { func Definition() *sdk.CheckerDefinition {
opts := sdk.CheckerOptionsDocumentation{ opts := sdk.CheckerOptionsDocumentation{
DomainOpts: []sdk.CheckerOptionDocumentation{ DomainOpts: []sdk.CheckerOptionDocumentation{
@ -23,11 +22,6 @@ func Definition() *sdk.CheckerDefinition {
}, },
}, },
} }
for _, s := range Sources() {
o := s.Options()
opts.AdminOpts = append(opts.AdminOpts, o.Admin...)
opts.UserOpts = append(opts.UserOpts, o.User...)
}
return &sdk.CheckerDefinition{ return &sdk.CheckerDefinition{
ID: "blacklist", ID: "blacklist",

View file

@ -7,23 +7,39 @@ import (
sdk "git.happydns.org/checker-sdk-go/checker" sdk "git.happydns.org/checker-sdk-go/checker"
) )
// Rules returns the rule set surfaced to happyDomain. After the // Rules returns one rule per registered Source. The host can enable or
// registry refactor we expose a single, generic rule that emits one // disable each independently, and each rule owns the option fields its
// CheckState per source result: the per-source verdict lives in // Source contributes (CheckRuleWithOptions). The rule name is the
// CheckState.Subject (the source name) and CheckState.Code carries the // Source ID; downstream a disabled rule both skips evaluation and
// canonical hit / clean / disabled / error flavour. // skips the underlying network call in Collect.
func Rules() []sdk.CheckRule { func Rules() []sdk.CheckRule {
return []sdk.CheckRule{&sourceRule{}} srcs := Sources()
rules := make([]sdk.CheckRule, 0, len(srcs))
for _, s := range srcs {
rules = append(rules, &sourceRule{src: s})
}
return rules
} }
type sourceRule struct{} type sourceRule struct {
src Source
func (*sourceRule) Name() string { return "source_listed" }
func (*sourceRule) Description() string {
return "Emits one state per reputation source: Critical/Warning when the source flags the domain, OK when clean, Info when the source is disabled, and Warning on transient query errors."
} }
func (*sourceRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, opts sdk.CheckerOptions) []sdk.CheckState { func (r *sourceRule) Name() string { return r.src.ID() }
func (r *sourceRule) Description() string {
return fmt.Sprintf("%s reputation lookup. Emits Critical/Warning when the source flags the domain, OK when clean, Info when the source is disabled or not configured, and Warning on transient query errors.", r.src.Name())
}
func (r *sourceRule) Options() sdk.CheckerOptionsDocumentation {
o := r.src.Options()
return sdk.CheckerOptionsDocumentation{
AdminOpts: o.Admin,
UserOpts: o.User,
}
}
func (r *sourceRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, opts sdk.CheckerOptions) []sdk.CheckState {
var data BlacklistData var data BlacklistData
if err := obs.Get(ctx, ObservationKeyBlacklist, &data); err != nil { if err := obs.Get(ctx, ObservationKeyBlacklist, &data); err != nil {
return []sdk.CheckState{{ return []sdk.CheckState{{
@ -33,25 +49,19 @@ func (*sourceRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, opts
}} }}
} }
if len(data.Results) == 0 { var out []sdk.CheckState
return []sdk.CheckState{{ for _, res := range data.Results {
Status: sdk.StatusInfo, Message: "No reputation sources registered.", if res.SourceID != r.src.ID() {
Code: "blacklist_no_sources", continue
}}
}
byID := make(map[string]Source, len(Sources()))
for _, s := range Sources() {
byID[s.ID()] = s
}
out := make([]sdk.CheckState, 0, len(data.Results))
for _, r := range data.Results {
src, ok := byID[r.SourceID]
if !ok {
src = unknownSource{}
} }
out = append(out, evaluateOne(r, src)) out = append(out, evaluateOne(res, r.src))
}
if len(out) == 0 {
return []sdk.CheckState{{
Status: sdk.StatusUnknown,
Message: r.src.Name() + ": no result (source skipped or disabled).",
Code: "source_disabled",
}}
} }
return out return out
} }
@ -105,17 +115,6 @@ func evaluateOne(r SourceResult, src Source) sdk.CheckState {
} }
} }
// unknownSource is a sentinel used when a SourceResult references a source ID
// that is no longer in the registry. Evaluate always returns (false, "").
type unknownSource struct{}
func (unknownSource) ID() string { return "" }
func (unknownSource) Name() string { return "unknown" }
func (unknownSource) Options() SourceOptions { return SourceOptions{} }
func (unknownSource) Query(_ context.Context, _, _ string, _ sdk.CheckerOptions) []SourceResult { return nil }
func (unknownSource) Diagnose(_ SourceResult) Diagnosis { return Diagnosis{} }
func (unknownSource) Evaluate(_ SourceResult) (bool, string) { return false, "" }
func severityToStatus(sev string) sdk.Status { func severityToStatus(sev string) sdk.Status {
switch sev { switch sev {
case SeverityCrit: case SeverityCrit:

2
go.mod
View file

@ -3,6 +3,6 @@ module git.happydns.org/checker-blacklist
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.8.0
golang.org/x/net v0.34.0 golang.org/x/net v0.34.0
) )

4
go.sum
View file

@ -1,4 +1,4 @@
git.happydns.org/checker-sdk-go v1.5.0 h1:5uD5Cm6xJ+lwnhbJ09iCXGHbYS9zRh+Yh0NeBHkAPBY= git.happydns.org/checker-sdk-go v1.8.0 h1:2lhcSc16rnCaszdQi1nerszb2c3fVh5XNS11pLrXuK4=
git.happydns.org/checker-sdk-go v1.5.0/go.mod h1:aNAcfYFfbhvH9kJhE0Njp5GX0dQbxdRB0rJ0KvSC5nI= git.happydns.org/checker-sdk-go v1.8.0/go.mod h1:aNAcfYFfbhvH9kJhE0Njp5GX0dQbxdRB0rJ0KvSC5nI=
golang.org/x/net v0.34.0 h1:Mb7Mrk043xzHgnRM88suvJFwzVrRfHEHJEl5/71CKw0= golang.org/x/net v0.34.0 h1:Mb7Mrk043xzHgnRM88suvJFwzVrRfHEHJEl5/71CKw0=
golang.org/x/net v0.34.0/go.mod h1:di0qlW3YNM5oh6GqDGQr92MyTozJPmybPK4Ev/Gm31k= golang.org/x/net v0.34.0/go.mod h1:di0qlW3YNM5oh6GqDGQr92MyTozJPmybPK4Ev/Gm31k=