package console // The GeoIP feed (geoip spec §10): the console fetches new editions of // DB-IP Lite or GeoLite2 and publishes them through the same slot as // `carlos system geoip publish`, with two extra rules the feed may never // skip: the database's type must be the configured feed's, and an edition // must be newer than the last one this feed published. import ( "context" "crypto/sha256" "encoding/hex" "encoding/json" "errors" "io" "log" mrand "math/rand/v2" "net/http" "os" "strings" "time" "github.com/carlosframework/platform/internal/geoip" "github.com/carlosframework/platform/internal/release" ) const ( geoipFeedMaxCompressed = int64(512 << 20) geoipFeedMaxExpanded = int64(3 << 29) // 1.5 GiB geoipFeedMaxChecksum = 4 << 10 ) type geoipFeeder struct { pub *geoipPublisher store release.Store spec geoip.FeedSpec account string key string box string client *http.Client now func() time.Time maxExpanded int64 // Test seams; "" means the real hosts. dbipBase, maxmindBase string } // newGeoipFeeder reads the feed configuration. false means no feed: unset, // or misconfigured (logged once; the console starts either way). func newGeoipFeeder(cfg Config, pub *geoipPublisher) (*geoipFeeder, bool) { name := strings.TrimSpace(cfg.GeoipFeed) if name == "" || pub == nil { return nil, false } spec, ok := geoip.ParseFeed(name) if !ok { log.Printf("console: geoip feed misconfigured: CONSOLE_GEOIP_FEED %q is not one of dbip-city-lite, dbip-country-lite, geolite2-city, geolite2-country, geoip2-city, geoip2-country; the feed is off", name) return nil, false } if spec.Provider == "maxmind" && (cfg.GeoipMaxMindAccount == "" || cfg.GeoipMaxMindKey == "") { log.Printf("console: geoip feed misconfigured: %s needs CONSOLE_GEOIP_MAXMIND_ACCOUNT and CONSOLE_GEOIP_MAXMIND_KEY; the feed is off", name) return nil, false } return &geoipFeeder{ pub: pub, store: pub.store, spec: spec, account: cfg.GeoipMaxMindAccount, key: cfg.GeoipMaxMindKey, box: cfg.BoxLabel, client: &http.Client{Timeout: 15 * time.Minute, CheckRedirect: geoipFeedRedirect}, now: time.Now, maxExpanded: geoipFeedMaxExpanded, }, true } // geoipFeedRedirect follows at most 3 redirects, https only, and drops the // Basic credentials on any cross-host hop (MaxMind redirects downloads to // presigned R2 URLs; spec §10.2). func geoipFeedRedirect(req *http.Request, via []*http.Request) error { if len(via) > 3 { return errors.New("geoip feed: too many redirects") } if req.URL.Scheme != "https" { return errors.New("geoip feed: refusing a redirect away from https") } if len(via) > 0 && req.URL.Host != via[0].URL.Host { req.Header.Del("Authorization") } return nil } // geoipFeedFailure is a check that failed, with a fixed category for // feed.json and the log (never an error text: it could carry a URL). type geoipFeedFailure struct{ category string } func (f geoipFeedFailure) Error() string { return "geoip feed: " + f.category } func feedFail(category string) error { return geoipFeedFailure{category} } var errFeedNotFound = errors.New("geoip feed: no edition available yet") // Check is one feed check. It always writes feed.json. func (f *geoipFeeder) Check(ctx context.Context) error { // Status I/O gets its own short deadline, detached from the check's: a // check that ran out its 15 minutes on a stalled provider must still // record the failure, or seven timeouts never add up to an alert (code // review R2-2). sctx, scancel := context.WithTimeout(context.WithoutCancel(ctx), 30*time.Second) defer scancel() st, etag, known := f.readStatus(sctx) st.V, st.Feed, st.Box, st.LastCheck = 1, f.spec.Name, f.box, f.now().UTC().Truncate(time.Second) edition, err := f.check(ctx) var ff geoipFeedFailure switch { case err == nil && edition != "": st.LastSuccess, st.LastEdition = st.LastCheck, edition st.ConsecutiveFailures, st.ConsecutiveNotFound, st.LastFailure = 0, 0, "" case err == nil: // Nothing new: a success with nothing to do. st.ConsecutiveFailures, st.ConsecutiveNotFound, st.LastFailure = 0, 0, "" case errors.Is(err, errGeoipBusy): // Someone is publishing by hand: not a failure, try tomorrow. case errors.Is(err, errFeedNotFound): st.ConsecutiveNotFound++ st.LastFailure = "not found" case errors.As(err, &ff): st.ConsecutiveFailures++ st.LastFailure = ff.category log.Printf("console: geoip feed %s: edition %s rejected: %s", f.spec.Name, orNone(edition), ff.category) default: st.ConsecutiveFailures++ st.LastFailure = "store" log.Printf("console: geoip feed %s: check failed: store", f.spec.Name) } if !known { // The existing history could not be read: overwriting it would turn // seven recorded failures into one and clear a firing alert with no // success behind it (code review R3-2). Next check, then. log.Printf("console: geoip feed status unreadable; this check's outcome was not recorded") return err } wctx, wcancel := context.WithTimeout(context.WithoutCancel(ctx), 30*time.Second) defer wcancel() f.writeStatus(wctx, st, etag) return err } func orNone(s string) string { if s == "" { return "(none)" } return s } // readStatus reads feed.json capped (never whole: code review R2-1) and // returns its etag for the conditional write. "" etag = absent. // known=false means the existing object could not be read at all (a // transient store error): the caller must not overwrite it. func (f *geoipFeeder) readStatus(ctx context.Context) (st geoip.FeedStatus, etag string, known bool) { b, etag, err := readCappedKey(ctx, f.store, geoip.FeedKey, geoip.MaxPointerBytes) switch { case errors.Is(err, os.ErrNotExist): return geoip.FeedStatus{}, "", true // confirmed absent case errors.Is(err, errPointerTooLarge): return geoip.FeedStatus{}, etag, true // replace an oversized object, never decode it case err != nil: return geoip.FeedStatus{}, "", false } s, err := geoip.ParseFeedStatus(b) if err != nil || s.Feed != f.spec.Name { return geoip.FeedStatus{}, etag, true // garbage, or another feed's history: replace it } return s, etag, true } func (f *geoipFeeder) writeStatus(ctx context.Context, st geoip.FeedStatus, etag string) { b, _ := json.MarshalIndent(st, "", " ") if _, err := f.store.Put(ctx, geoip.FeedKey, b, etag); err != nil { if errors.Is(err, release.ErrCASConflict) && etag == "" { // Present but unreadable before (oversized under DirStore, or a // race): a delete then create-only put, best effort. f.store.Delete(ctx, geoip.FeedKey) if _, err := f.store.Put(ctx, geoip.FeedKey, b, ""); err == nil { return } } log.Printf("console: geoip feed status not written") } } // check returns the edition published ("" when there was nothing new). func (f *geoipFeeder) check(ctx context.Context) (string, error) { cur, _, present, curErr := f.pub.Current(ctx) ours := present && curErr == nil && cur.Origin == "feed:"+f.spec.Name if f.spec.Provider == "dbip" { return f.checkDBIP(ctx, cur, ours) } return f.checkMaxMind(ctx, cur, ours) } func (f *geoipFeeder) checkDBIP(ctx context.Context, cur geoip.Pointer, ours bool) (string, error) { now := f.now().UTC() this := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, time.UTC) if ours && cur.Edition == this.Format("2006-01") { return cur.Edition, nil // confirmed current: a success (code review R7-2) } months := []time.Time{this} if !ours { // No pointer of ours yet: last month is the best there is until // this month's appears. A pointer of ours already holds it. months = append(months, this.AddDate(0, -1, 0)) } for _, m := range months { res, err := f.get(ctx, geoip.DBIPURL(f.dbipBase, f.spec, m), false) if err != nil { return "", feedFail("download") } if res.StatusCode == http.StatusNotFound || res.StatusCode == http.StatusGone { res.Body.Close() continue } if res.StatusCode != http.StatusOK { res.Body.Close() return "", feedFail("download") } edition := m.Format("2006-01") err = f.publish(ctx, res.Body, edition, "", func(r io.Reader) (io.Reader, error) { return geoip.GunzipReader(r, f.maxExpanded) }) res.Body.Close() return edition, err } return "", errFeedNotFound } func (f *geoipFeeder) checkMaxMind(ctx context.Context, cur geoip.Pointer, ours bool) (string, error) { head, err := f.request(ctx, http.MethodHead, geoip.MaxMindURL(f.maxmindBase, f.spec, "tar.gz"), true) if err != nil { return "", feedFail("download") } head.Body.Close() if head.StatusCode == http.StatusNotFound || head.StatusCode == http.StatusGone { return "", errFeedNotFound } if head.StatusCode != http.StatusOK { return "", feedFail("download") } // The edition is the provider's Last-Modified, but only as a real HTTP // date, re-rendered by us: an arbitrary header value must never reach // the pointer or feed.json (code review R7-1). lm, err := http.ParseTime(head.Header.Get("Last-Modified")) if err != nil { return "", feedFail("download") } edition := lm.UTC().Format(http.TimeFormat) if ours && cur.Edition == edition { return edition, nil // confirmed current: a success (code review R7-2) } sumRes, err := f.get(ctx, geoip.MaxMindURL(f.maxmindBase, f.spec, "tar.gz.sha256"), true) if err != nil { return edition, feedFail("download") } sumBody, err := io.ReadAll(io.LimitReader(sumRes.Body, geoipFeedMaxChecksum+1)) sumRes.Body.Close() if err != nil || sumRes.StatusCode != http.StatusOK || len(sumBody) > geoipFeedMaxChecksum { return edition, feedFail("checksum") } want := strings.ToLower(strings.TrimSpace(strings.SplitN(string(sumBody), " ", 2)[0])) if !geoip.IsSHA256Hex(want) { return edition, feedFail("checksum") } res, err := f.get(ctx, geoip.MaxMindURL(f.maxmindBase, f.spec, "tar.gz"), true) if err != nil || res.StatusCode != http.StatusOK { if res != nil { res.Body.Close() } return edition, feedFail("download") } defer res.Body.Close() return edition, f.publish(ctx, res.Body, edition, want, func(r io.Reader) (io.Reader, error) { return geoip.TarGzMMDB(r, f.maxExpanded) }) } // publish streams body (capped, hashed) through open into the publish // slot. wantSHA, when set, is the archive's published SHA-256, checked // after the whole archive has been read and before anything is committed. func (f *geoipFeeder) publish(ctx context.Context, body io.Reader, edition, wantSHA string, open func(io.Reader) (io.Reader, error)) error { h := sha256.New() archive := io.TeeReader(geoip.CappedReader(body, geoipFeedMaxCompressed), h) db, err := open(archive) if err != nil { return classifyFeedErr(err, "invalid") } st, err := f.pub.Stage(ctx, db) if err != nil { if errors.Is(err, errGeoipBusy) { return err } return classifyFeedErr(err, "download") } if wantSHA != "" { // The tar walk stopped at the database; the checksum covers the // whole archive, so read the rest through the same capped tee. if _, err := io.Copy(io.Discard, archive); err != nil { f.pub.Abandon(st) return classifyFeedErr(err, "download") } if hex.EncodeToString(h.Sum(nil)) != wantSHA { f.pub.Abandon(st) return feedFail("checksum") } } _, err = f.pub.Commit(ctx, st, geoipPublishOpts{Origin: "feed:" + f.spec.Name, Edition: edition, RequireType: f.spec.Type, Monotonic: true}) if err == nil { return nil } msg := err.Error() switch { case strings.Contains(msg, "not newer"): return feedFail("not newer") case strings.Contains(msg, "the feed returned"): return feedFail("wrong type") case strings.Contains(msg, "expired"): return feedFail("expired") case errors.Is(err, errGeoipRefused): return feedFail("invalid") } return feedFail("store") } func classifyFeedErr(err error, fallback string) error { if errors.Is(err, geoip.ErrTooLarge) || errors.Is(err, errGeoipRefused) && strings.Contains(err.Error(), "larger than") { return feedFail("too large") } return feedFail(fallback) } func (f *geoipFeeder) get(ctx context.Context, url string, auth bool) (*http.Response, error) { return f.request(ctx, http.MethodGet, url, auth) } func (f *geoipFeeder) request(ctx context.Context, method, url string, auth bool) (*http.Response, error) { req, err := http.NewRequestWithContext(ctx, method, url, nil) if err != nil { return nil, err } if auth { // Basic auth in a header, never in the URL (spec §10.1). req.SetBasicAuth(f.account, f.key) } return f.client.Do(req) } // run is the feed's schedule: 5 minutes after start, then daily. func (f *geoipFeeder) run(ctx context.Context) { wait := 5 * time.Minute for { select { case <-ctx.Done(): return case <-time.After(wait): } cctx, cancel := context.WithTimeout(ctx, f.pub.deadline) f.Check(cctx) cancel() wait = 24*time.Hour + mrand.N(time.Hour) } } // clearFeedStatus removes feed.json when no feed is configured, so a feed // switched off never alerts anyone. func clearFeedStatus(ctx context.Context, s release.Store) error { if err := s.Delete(ctx, geoip.FeedKey); err != nil && !errors.Is(err, os.ErrNotExist) { return err } return nil } // retryClearFeedStatus keeps trying until feed.json is gone or ctx ends: // a failed delete must not leave a disabled feed's alert firing for good // (code review R6-1). Each attempt is bounded; the wait doubles from // first to max. func retryClearFeedStatus(ctx context.Context, s release.Store, first, max time.Duration) { wait := first for { actx, cancel := context.WithTimeout(ctx, 30*time.Second) err := clearFeedStatus(actx, s) cancel() if err == nil { return } log.Printf("console: geoip feed status could not be cleared; retrying in %s", wait) select { case <-ctx.Done(): return case <-time.After(wait): } if wait *= 2; wait > max { wait = max } } }