package main import ( "context" "fmt" "io" "net" "net/http" "strings" "time" "go.uber.org/zap" "scrabble/gateway/internal/config" "scrabble/gateway/internal/ratelimit" ) const ( // blocklistFetchTimeout bounds one feed fetch. blocklistFetchTimeout = 30 * time.Second // maxBlocklistBytes caps the fetched feed (Spamhaus DROP is well under 1 MiB; the cap stops a // hostile or misconfigured URL from streaming unbounded data into memory). maxBlocklistBytes = 8 << 20 ) // parseAllowlist parses the never-block entries (CIDRs or bare IPs, a bare IP read as a /32) into // networks. A malformed entry is a fatal config error — the operator must fix it, not silently lose // a protected range. func parseAllowlist(entries []string) ([]*net.IPNet, error) { out := make([]*net.IPNet, 0, len(entries)) for _, e := range entries { s := strings.TrimSpace(e) if s == "" { continue } if !strings.Contains(s, "/") { s += "/32" } _, n, err := net.ParseCIDR(s) if err != nil { return nil, fmt.Errorf("gateway: GATEWAY_BLOCKLIST_ALLOW: %q: %w", e, err) } out = append(out, n) } return out, nil } // runBlocklistRefresher keeps the community IP blocklist feed current: it fetches immediately, then // every cfg.Refresh, and applies each outcome through ratelimit.ApplyRefresh (keep-last-good on a // transient failure; drop the feed once it goes stale, fail-open). It returns when ctx is cancelled. func runBlocklistRefresher(ctx context.Context, bl *ratelimit.Blocklist, cfg config.BlocklistConfig, log *zap.Logger) { client := &http.Client{Timeout: blocklistFetchTimeout} for { cidrs, err := fetchBlocklist(ctx, client, cfg.URL) switch ratelimit.ApplyRefresh(bl, cidrs, err, time.Now(), cfg.MaxStaleness) { case ratelimit.RefreshUpdated: log.Info("blocklist refreshed", zap.String("url", cfg.URL), zap.Int("entries", bl.Len())) case ratelimit.RefreshKept: log.Warn("blocklist refresh failed; keeping the last-good feed", zap.Error(err)) case ratelimit.RefreshDropped: log.Warn("blocklist refresh failed and the feed is stale; dropped it (fail-open)", zap.Error(err)) } select { case <-ctx.Done(): return case <-time.After(cfg.Refresh): } } } // fetchBlocklist GETs the feed and parses it, bounded by the fetch timeout and the size cap. func fetchBlocklist(ctx context.Context, client *http.Client, url string) ([]*net.IPNet, error) { req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) if err != nil { return nil, err } resp, err := client.Do(req) if err != nil { return nil, err } defer func() { _ = resp.Body.Close() }() if resp.StatusCode != http.StatusOK { return nil, fmt.Errorf("gateway: blocklist fetch %s: status %d", url, resp.StatusCode) } return ratelimit.ParseDROP(io.LimitReader(resp.Body, maxBlocklistBytes)) }