From 6719e21b51f07d5b2ac444544357e124076cb516 Mon Sep 17 00:00:00 2001 From: Pierre-Olivier Mercier Date: Sun, 17 May 2026 23:56:28 +0800 Subject: [PATCH 1/2] Use ctx.States() for headline and diagnoses in HTML report Drive listed-count and action-required cards from rule states when available; fall back to raw observation data when states are absent. --- checker/report.go | 53 +++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 51 insertions(+), 2 deletions(-) diff --git a/checker/report.go b/checker/report.go index c823cf7..5abd8a8 100644 --- a/checker/report.go +++ b/checker/report.go @@ -21,16 +21,17 @@ func (p *blacklistProvider) GetHTMLReport(ctx sdk.ReportContext) (string, error) return "", fmt.Errorf("decode blacklist data: %w", err) } + states := ctx.States() view := reportView{ Domain: data.Domain, RegisteredDomain: data.RegisteredDomain, CollectedAt: data.CollectedAt.Format("2006-01-02 15:04 MST"), TotalHits: data.TotalHits(), - Diagnoses: diagnose(&data), + Diagnoses: diagnoseFromStates(states, &data), Sections: buildSections(&data), CSS: template.CSS(reportCSS), } - view.Headline, view.HeadlineClass = headline(view.TotalHits) + view.Headline, view.HeadlineClass = headlineFromStates(states, view.TotalHits) var b bytes.Buffer if err := reportTemplate.Execute(&b, view); err != nil { @@ -129,6 +130,54 @@ func diagnose(d *BlacklistData) []Diagnosis { return out } +func headlineFromStates(states []sdk.CheckState, fallbackHits int) (string, string) { + if len(states) == 0 { + return headline(fallbackHits) + } + listed := 0 + for _, st := range states { + if st.Code == "source_listed" { + listed++ + } + } + return headline(listed) +} + +func diagnoseFromStates(states []sdk.CheckState, d *BlacklistData) []Diagnosis { + if len(states) == 0 { + return diagnose(d) + } + var out []Diagnosis + for _, st := range states { + if st.Status != sdk.StatusCrit && st.Status != sdk.StatusWarn { + continue + } + switch st.Code { + case "source_listed": + diag := Diagnosis{ + Severity: SeverityCrit, + Title: "Listed in " + st.Subject, + Detail: st.Message, + } + if v, ok := st.Meta["lookup_url"].(string); ok { + diag.LookupURL = v + } + if v, ok := st.Meta["removal_url"].(string); ok { + diag.RemovalURL = v + } + out = append(out, diag) + case "source_error", "source_resolver_blocked": + out = append(out, Diagnosis{ + Severity: SeverityWarn, + Title: "Could not query " + st.Subject, + Detail: st.Message, + }) + } + } + sort.SliceStable(out, func(i, j int) bool { return sevRank(out[i].Severity) < sevRank(out[j].Severity) }) + return out +} + func sevRank(s string) int { switch s { case SeverityCrit: From ce59a976d54922874f4ce114799e458a81e2183b Mon Sep 17 00:00:00 2001 From: Pierre-Olivier Mercier Date: Tue, 19 May 2026 22:10:32 +0800 Subject: [PATCH 2/2] Expose one rule per source with rule-scoped options 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. --- checker/collect.go | 5 +++ checker/definition.go | 12 ++----- checker/rule.go | 81 +++++++++++++++++++++---------------------- go.mod | 2 +- go.sum | 4 +-- 5 files changed, 51 insertions(+), 53 deletions(-) diff --git a/checker/collect.go b/checker/collect.go index 446a195..076f98e 100644 --- a/checker/collect.go +++ b/checker/collect.go @@ -38,6 +38,11 @@ func (p *blacklistProvider) Collect(ctx context.Context, opts sdk.CheckerOptions var wg sync.WaitGroup 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) go func(i int, s Source) { defer wg.Done() diff --git a/checker/definition.go b/checker/definition.go index 14c67e9..fcbd9aa 100644 --- a/checker/definition.go +++ b/checker/definition.go @@ -9,10 +9,9 @@ import ( // Version is overridden at link time by the standalone or plugin entrypoints. var Version = "built-in" -// Definition assembles the checker definition by aggregating each -// registered Source's options into the SDK's audience-grouped layout. -// Adding a source automatically adds its option fields here: no edit -// to this file needed. +// Definition assembles the checker definition. Per-source option fields +// live on each per-source rule (CheckRuleWithOptions); the global Options +// only carries the shared domain target. func Definition() *sdk.CheckerDefinition { opts := sdk.CheckerOptionsDocumentation{ 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{ ID: "blacklist", diff --git a/checker/rule.go b/checker/rule.go index 72d5c38..d904836 100644 --- a/checker/rule.go +++ b/checker/rule.go @@ -7,23 +7,39 @@ import ( sdk "git.happydns.org/checker-sdk-go/checker" ) -// Rules returns the rule set surfaced to happyDomain. After the -// registry refactor we expose a single, generic rule that emits one -// CheckState per source result: the per-source verdict lives in -// CheckState.Subject (the source name) and CheckState.Code carries the -// canonical hit / clean / disabled / error flavour. +// Rules returns one rule per registered Source. The host can enable or +// disable each independently, and each rule owns the option fields its +// Source contributes (CheckRuleWithOptions). The rule name is the +// Source ID; downstream a disabled rule both skips evaluation and +// skips the underlying network call in Collect. 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{} - -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." +type sourceRule struct { + src Source } -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 if err := obs.Get(ctx, ObservationKeyBlacklist, &data); err != nil { return []sdk.CheckState{{ @@ -33,25 +49,19 @@ func (*sourceRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, opts }} } - if len(data.Results) == 0 { - return []sdk.CheckState{{ - Status: sdk.StatusInfo, Message: "No reputation sources registered.", - Code: "blacklist_no_sources", - }} - } - - 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{} + var out []sdk.CheckState + for _, res := range data.Results { + if res.SourceID != r.src.ID() { + continue } - 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 } @@ -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 { switch sev { case SeverityCrit: diff --git a/go.mod b/go.mod index 042aaf7..cc71d5d 100644 --- a/go.mod +++ b/go.mod @@ -3,6 +3,6 @@ module git.happydns.org/checker-blacklist go 1.25.0 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 ) diff --git a/go.sum b/go.sum index 5631c56..d14c68e 100644 --- a/go.sum +++ b/go.sum @@ -1,4 +1,4 @@ -git.happydns.org/checker-sdk-go v1.5.0 h1:5uD5Cm6xJ+lwnhbJ09iCXGHbYS9zRh+Yh0NeBHkAPBY= -git.happydns.org/checker-sdk-go v1.5.0/go.mod h1:aNAcfYFfbhvH9kJhE0Njp5GX0dQbxdRB0rJ0KvSC5nI= +git.happydns.org/checker-sdk-go v1.8.0 h1:2lhcSc16rnCaszdQi1nerszb2c3fVh5XNS11pLrXuK4= +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/go.mod h1:di0qlW3YNM5oh6GqDGQr92MyTozJPmybPK4Ev/Gm31k=