| 1 | package geoip |
| 2 | |
| 3 | import ( |
| 4 | "encoding/json" |
| 5 | "fmt" |
| 6 | "strings" |
| 7 | "time" |
| 8 | ) |
| 9 | |
| 10 | // FeedSpec is one supported feed (spec §10.1): the env name, the database |
| 11 | // type it must deliver, and its provider. |
| 12 | type FeedSpec struct{ Name, Type, Provider string } |
| 13 | |
| 14 | var feeds = map[string]FeedSpec{ |
| 15 | "dbip-city-lite": {"dbip-city-lite", "DBIP-City-Lite", "dbip"}, |
| 16 | "dbip-country-lite": {"dbip-country-lite", "DBIP-Country-Lite", "dbip"}, |
| 17 | "geolite2-city": {"geolite2-city", "GeoLite2-City", "maxmind"}, |
| 18 | "geolite2-country": {"geolite2-country", "GeoLite2-Country", "maxmind"}, |
| 19 | // The paid upgrade (Paul, 2026-10-07): MaxMind's commercial databases, |
| 20 | // the same download API and credentials. Not GeoLite2, so no D1 expiry, |
| 21 | // and no attribution the platform knows of: the operator's MaxMind |
| 22 | // licence governs. |
| 23 | "geoip2-city": {"geoip2-city", "GeoIP2-City", "maxmind"}, |
| 24 | "geoip2-country": {"geoip2-country", "GeoIP2-Country", "maxmind"}, |
| 25 | } |
| 26 | |
| 27 | // ParseFeed resolves CONSOLE_GEOIP_FEED. |
| 28 | func ParseFeed(name string) (FeedSpec, bool) { |
| 29 | s, ok := feeds[strings.TrimSpace(name)] |
| 30 | return s, ok |
| 31 | } |
| 32 | |
| 33 | // DBIPURL is the month's DB-IP Lite download (spec §10.2). |
| 34 | func DBIPURL(base string, spec FeedSpec, month time.Time) string { |
| 35 | if base == "" { |
| 36 | base = "https://download.db-ip.com" |
| 37 | } |
| 38 | kind := strings.TrimSuffix(strings.TrimPrefix(spec.Name, "dbip-"), "-lite") // city | country |
| 39 | return fmt.Sprintf("%s/free/dbip-%s-lite-%s.mmdb.gz", base, kind, month.UTC().Format("2006-01")) |
| 40 | } |
| 41 | |
| 42 | // MaxMindURL is the GeoLite2 permalink; suffix is "tar.gz" or "tar.gz.sha256". |
| 43 | func MaxMindURL(base string, spec FeedSpec, suffix string) string { |
| 44 | if base == "" { |
| 45 | base = "https://download.maxmind.com" |
| 46 | } |
| 47 | return fmt.Sprintf("%s/geoip/databases/%s/download?suffix=%s", base, spec.Type, suffix) |
| 48 | } |
| 49 | |
| 50 | // FeedStatus is control/geoip/feed.json, written by the console after every |
| 51 | // check (spec §10.4). Box names the console's box: the one box whose mirror |
| 52 | // raises the feed reason, so it fires once. |
| 53 | type FeedStatus struct { |
| 54 | V int `json:"v"` |
| 55 | Feed string `json:"feed"` |
| 56 | Box string `json:"box"` |
| 57 | LastCheck time.Time `json:"last_check"` |
| 58 | LastSuccess time.Time `json:"last_success,omitempty"` |
| 59 | LastEdition string `json:"last_edition,omitempty"` |
| 60 | ConsecutiveFailures int `json:"consecutive_failures"` |
| 61 | LastFailure string `json:"last_failure,omitempty"` // a fixed category, never an error text |
| 62 | ConsecutiveNotFound int `json:"consecutive_not_found"` |
| 63 | } |
| 64 | |
| 65 | // FeedNotFoundAlert is how many failing checks in a row raise the alert. |
| 66 | const FeedNotFoundAlert = 7 |
| 67 | |
| 68 | // ParseFeedStatus decodes feed.json. |
| 69 | func ParseFeedStatus(raw []byte) (FeedStatus, error) { |
| 70 | var s FeedStatus |
| 71 | if err := json.Unmarshal(raw, &s); err != nil { |
| 72 | return FeedStatus{}, fmt.Errorf("geoip feed status: %w", err) |
| 73 | } |
| 74 | if s.V != 1 { |
| 75 | return FeedStatus{}, fmt.Errorf("geoip feed status: version %d: %w", s.V, ErrUnknownVersion) |
| 76 | } |
| 77 | return s, nil |
| 78 | } |
| 79 | |
| 80 | // Failing is the alert's feed reason: 7 failed or not-found checks in a row. |
| 81 | func (s FeedStatus) Failing() bool { |
| 82 | return s.ConsecutiveFailures >= FeedNotFoundAlert || s.ConsecutiveNotFound >= FeedNotFoundAlert |
| 83 | } |
| 84 | |