Compare commits

..

2 commits

Author SHA1 Message Date
ce59a976d5 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.
2026-05-19 22:12:31 +08:00
6719e21b51 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.
2026-05-19 22:12:26 +08:00
6 changed files with 100 additions and 53 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

@ -21,16 +21,17 @@ func (p *blacklistProvider) GetHTMLReport(ctx sdk.ReportContext) (string, error)
return "", fmt.Errorf("decode blacklist data: %w", err) return "", fmt.Errorf("decode blacklist data: %w", err)
} }
states := ctx.States()
view := reportView{ view := reportView{
Domain: data.Domain, Domain: data.Domain,
RegisteredDomain: data.RegisteredDomain, RegisteredDomain: data.RegisteredDomain,
CollectedAt: data.CollectedAt.Format("2006-01-02 15:04 MST"), CollectedAt: data.CollectedAt.Format("2006-01-02 15:04 MST"),
TotalHits: data.TotalHits(), TotalHits: data.TotalHits(),
Diagnoses: diagnose(&data), Diagnoses: diagnoseFromStates(states, &data),
Sections: buildSections(&data), Sections: buildSections(&data),
CSS: template.CSS(reportCSS), CSS: template.CSS(reportCSS),
} }
view.Headline, view.HeadlineClass = headline(view.TotalHits) view.Headline, view.HeadlineClass = headlineFromStates(states, view.TotalHits)
var b bytes.Buffer var b bytes.Buffer
if err := reportTemplate.Execute(&b, view); err != nil { if err := reportTemplate.Execute(&b, view); err != nil {
@ -129,6 +130,54 @@ func diagnose(d *BlacklistData) []Diagnosis {
return out 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 { func sevRank(s string) int {
switch s { switch s {
case SeverityCrit: case SeverityCrit:

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=