package geoip import ( "encoding/json" "fmt" "strings" "time" ) // FeedSpec is one supported feed (spec §10.1): the env name, the database // type it must deliver, and its provider. type FeedSpec struct{ Name, Type, Provider string } var feeds = map[string]FeedSpec{ "dbip-city-lite": {"dbip-city-lite", "DBIP-City-Lite", "dbip"}, "dbip-country-lite": {"dbip-country-lite", "DBIP-Country-Lite", "dbip"}, "geolite2-city": {"geolite2-city", "GeoLite2-City", "maxmind"}, "geolite2-country": {"geolite2-country", "GeoLite2-Country", "maxmind"}, // The paid upgrade (Paul, 2026-10-07): MaxMind's commercial databases, // the same download API and credentials. Not GeoLite2, so no D1 expiry, // and no attribution the platform knows of: the operator's MaxMind // licence governs. "geoip2-city": {"geoip2-city", "GeoIP2-City", "maxmind"}, "geoip2-country": {"geoip2-country", "GeoIP2-Country", "maxmind"}, } // ParseFeed resolves CONSOLE_GEOIP_FEED. func ParseFeed(name string) (FeedSpec, bool) { s, ok := feeds[strings.TrimSpace(name)] return s, ok } // DBIPURL is the month's DB-IP Lite download (spec §10.2). func DBIPURL(base string, spec FeedSpec, month time.Time) string { if base == "" { base = "https://download.db-ip.com" } kind := strings.TrimSuffix(strings.TrimPrefix(spec.Name, "dbip-"), "-lite") // city | country return fmt.Sprintf("%s/free/dbip-%s-lite-%s.mmdb.gz", base, kind, month.UTC().Format("2006-01")) } // MaxMindURL is the GeoLite2 permalink; suffix is "tar.gz" or "tar.gz.sha256". func MaxMindURL(base string, spec FeedSpec, suffix string) string { if base == "" { base = "https://download.maxmind.com" } return fmt.Sprintf("%s/geoip/databases/%s/download?suffix=%s", base, spec.Type, suffix) } // FeedStatus is control/geoip/feed.json, written by the console after every // check (spec §10.4). Box names the console's box: the one box whose mirror // raises the feed reason, so it fires once. type FeedStatus struct { V int `json:"v"` Feed string `json:"feed"` Box string `json:"box"` LastCheck time.Time `json:"last_check"` LastSuccess time.Time `json:"last_success,omitempty"` LastEdition string `json:"last_edition,omitempty"` ConsecutiveFailures int `json:"consecutive_failures"` LastFailure string `json:"last_failure,omitempty"` // a fixed category, never an error text ConsecutiveNotFound int `json:"consecutive_not_found"` } // FeedNotFoundAlert is how many failing checks in a row raise the alert. const FeedNotFoundAlert = 7 // ParseFeedStatus decodes feed.json. func ParseFeedStatus(raw []byte) (FeedStatus, error) { var s FeedStatus if err := json.Unmarshal(raw, &s); err != nil { return FeedStatus{}, fmt.Errorf("geoip feed status: %w", err) } if s.V != 1 { return FeedStatus{}, fmt.Errorf("geoip feed status: version %d: %w", s.V, ErrUnknownVersion) } return s, nil } // Failing is the alert's feed reason: 7 failed or not-found checks in a row. func (s FeedStatus) Failing() bool { return s.ConsecutiveFailures >= FeedNotFoundAlert || s.ConsecutiveNotFound >= FeedNotFoundAlert }