package checker import ( "context" "encoding/json" "fmt" "io" "net/http" "strings" "time" sdk "git.happydns.org/checker-sdk-go/checker" ) const disconnectFeedURL = "https://s3.amazonaws.com/lists.disconnect.me/services.json" func init() { Register(&disconnectSource{ cache: newFeedCache(24*time.Hour, disconnectFetch(disconnectFeedURL)), }) } type disconnectSource struct{ cache *feedCache } func (*disconnectSource) ID() string { return "disconnect" } func (*disconnectSource) Name() string { return "Disconnect.me" } func (*disconnectSource) Options() SourceOptions { return SourceOptions{ User: []sdk.CheckerOptionField{ { Id: "enable_disconnect", Type: "bool", Label: "Use the Disconnect.me tracking-protection list", Description: "Check the domain against the Disconnect.me blocklist used by Firefox Enhanced Tracking Protection, Brave, and uBlock Origin.", Default: true, }, }, } } func (s *disconnectSource) Query(ctx context.Context, domain, registered string, opts sdk.CheckerOptions) []SourceResult { if !sdk.GetBoolOption(opts, "enable_disconnect", true) { return disabledResult(s.ID(), s.Name()) } if registered == "" { return []SourceResult{{SourceID: s.ID(), SourceName: s.Name(), Enabled: true}} } matches, size, fetched, err := s.cache.lookup(ctx, registered) res := SourceResult{ SourceID: s.ID(), SourceName: s.Name(), Enabled: true, Reference: "https://disconnect.me/trackerprotection", Details: mustJSON(map[string]any{"feed_size": size, "fetched_at": fetched}), } if err != nil { res.Error = err.Error() } seenCategory := map[string]bool{} for _, m := range matches { parts := strings.SplitN(m, "|", 2) category, company := parts[0], "" if len(parts) == 2 { company = parts[1] } if !seenCategory[category] { seenCategory[category] = true res.Reasons = append(res.Reasons, category) } extra := map[string]string{} if company != "" { extra["company"] = company } res.Evidence = append(res.Evidence, Evidence{ Label: "Category", Value: category, Status: strings.ToLower(category), Extra: extra, }) } return []SourceResult{res} } func (*disconnectSource) Evaluate(r SourceResult) (bool, string) { return evidenceEval(r, SeverityWarn) } func (*disconnectSource) Diagnose(res SourceResult) Diagnosis { return Diagnosis{ Severity: SeverityWarn, Title: "Listed in Disconnect.me tracking-protection blocklist", Detail: fmt.Sprintf( "Category: %s. This domain appears in the Disconnect.me list used by Firefox Enhanced Tracking Protection, Brave, and uBlock Origin. Browsers and privacy tools will block third-party requests to this domain, which may affect analytics, ad delivery, or embedded widgets. The list is maintained by Disconnect.me; contact them if you believe the classification is incorrect.", joinNonEmpty(res.Reasons, ", "), ), Fix: "https://disconnect.me/contact", FixIsURL: true, } } // disconnectJSON mirrors the structure of services.json. type disconnectJSON struct { Categories map[string][]map[string]map[string][]string `json:"categories"` } func disconnectFetch(feedURL string) func(context.Context) ([]string, map[string][]string, error) { return func(ctx context.Context) ([]string, map[string][]string, error) { reqCtx, cancel := context.WithTimeout(ctx, 30*time.Second) defer cancel() req, err := http.NewRequestWithContext(reqCtx, http.MethodGet, feedURL, nil) if err != nil { return nil, nil, err } req.Header.Set("User-Agent", "happydomain-checker-blacklist/1.0") resp, err := sharedHTTPClient.Do(req) if err != nil { return nil, nil, err } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return nil, nil, fmt.Errorf("disconnect HTTP %d", resp.StatusCode) } raw, err := io.ReadAll(io.LimitReader(resp.Body, 32<<20)) if err != nil { return nil, nil, fmt.Errorf("disconnect read: %w", err) } var data disconnectJSON if err := json.Unmarshal(raw, &data); err != nil { return nil, nil, fmt.Errorf("disconnect parse: %w", err) } byHost := make(map[string][]string, 4096) for category, entities := range data.Categories { for _, entity := range entities { for company, sites := range entity { for _, domains := range sites { for _, d := range domains { d = strings.ToLower(strings.TrimSuffix(d, ".")) if d == "" { continue } entry := category + "|" + company byHost[d] = append(byHost[d], entry) } } } } } urls := make([]string, 0, len(byHost)) for d := range byHost { urls = append(urls, d) } return urls, byHost, nil } }