package console // The console's GeoIP reader (geoip spec §11.3): it follows the generation // this box's own edge mirrors, checking /data/carlos/geoip/current once a // minute, so region guesses and the /geo endpoint answer from the published // database without a second download or a restart. import ( "errors" "log" "os" "path/filepath" "sync" "time" "github.com/carlosframework/platform/internal/geo" "github.com/carlosframework/platform/internal/geoip" ) // followerExpired is D1's rule; a var so a test can apply it to the // GeoIP2 fixtures, which never expire. var followerExpired = geoip.Expired type geoipFollower struct { dir string sw *geo.Swappable now func() time.Time mu sync.Mutex loadedSHA string lastErrLog time.Time } // NewGeoipFollower follows dir (the edge's local GeoIP directory) into sw. func NewGeoipFollower(dir string, sw *geo.Swappable) *geoipFollower { return &geoipFollower{dir: dir, sw: sw, now: time.Now} } // Tick is one check (spec §11.3's table). func (f *geoipFollower) Tick() { f.mu.Lock() defer f.mu.Unlock() // D1 on what is LOADED, first and whatever the local state says: an // unreadable or unopenable `current` must never keep an expired reader // serving (code review R3-1). if src, build, ok := f.sw.Loaded(); ok && followerExpired(src, build, f.now()) { f.drop("console: geoip database expired (GeoLite2, 30 days after build) and was closed") } raw, err := os.ReadFile(filepath.Join(f.dir, geoip.LocalCurrentFile)) var cur geoip.LocalCurrent if err == nil { cur, err = geoip.ParseLocalCurrent(raw) } switch { case errors.Is(err, os.ErrNotExist) || (err == nil && cur.SHA256 == ""): f.drop("") // off, or nothing published yet: the normal empty start return case err != nil: f.logHourly("console: geoip local state unreadable; keeping the loaded database") return case followerExpired(cur.DatabaseType, time.Unix(cur.BuildEpoch, 0), f.now()): f.drop("console: geoip database expired (GeoLite2, 30 days after build) and was closed") return case cur.SHA256 == f.loadedSHA: return } db, err := geo.OpenMMDBTrusted(filepath.Join(f.dir, cur.SHA256+".mmdb")) if err != nil { // The edge may be mid-swap; the next tick sees the settled state. f.logHourly("console: geoip generation named by the edge is unopenable; keeping the loaded database") return } if followerExpired(db.Source(), db.BuildEpoch(), f.now()) { db.Close() // the file's own metadata, not just what `current` claims f.drop("console: geoip database expired (GeoLite2, 30 days after build) and was closed") return } f.sw.Swap(db) f.loadedSHA = cur.SHA256 log.Printf("console: geoip database %s (%s) followed from this box's edge", cur.SHA256[:8], db.Source()) } func (f *geoipFollower) drop(msg string) { if _, _, loaded := f.sw.Loaded(); f.loadedSHA == "" && !loaded { return } f.sw.Swap(nil) f.loadedSHA = "" if msg != "" { log.Print(msg) } } func (f *geoipFollower) logHourly(msg string) { if now := f.now(); now.Sub(f.lastErrLog) >= time.Hour { f.lastErrLog = now log.Print(msg) } } // Run ticks once a minute, forever (process lifetime, like the console's // other loops started from cmd/console). func (f *geoipFollower) Run() { f.Tick() for range time.Tick(time.Minute) { f.Tick() } }