| new file mode 100644 |
| index 000000000..f75668643 |
| --- /dev/null |
| +++ b/internal/console/geoippublish.go |
| @@ -0,0 +1,291 @@ |
| +package console |
| + |
| +import ( |
| + "context" |
| + "crypto/sha256" |
| + "encoding/hex" |
| + "encoding/json" |
| + "errors" |
| + "fmt" |
| + "io" |
| + "log" |
| + "net" |
| + "os" |
| + "path/filepath" |
| + "strings" |
| + "sync" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/geo" |
| + "github.com/carlosframework/platform/internal/geoip" |
| + "github.com/carlosframework/platform/internal/release" |
| +) |
| + |
| +// geoipProbeAddrs is the country probe (spec §4.1 step 4): at least three |
| +// must answer with a country. A variable so tests can use addresses the |
| +// MaxMind fixtures hold (FACTS.md). |
| +var geoipProbeAddrs = []string{"8.8.8.8", "1.1.1.1", "9.9.9.9", "208.67.222.222", "2001:4860:4860::8888"} |
| + |
| +const geoipProbeNeed = 3 |
| + |
| +var ( |
| + errGeoipBusy = errors.New("a geoip publish is already in progress") |
| + errGeoipRefused = errors.New("geoip database refused") |
| +) |
| + |
| +func refused(category string) error { return fmt.Errorf("%w: %s", errGeoipRefused, category) } |
| + |
| +// geoipPublisher is the console's single writer of control/geoip/ (spec §3). |
| +// mu is held through cleanup by every publish, off, sweep and expiry, and a |
| +// publish that finds it held answers errGeoipBusy rather than queueing. |
| +type geoipPublisher struct { |
| + mu sync.Mutex |
| + store release.Store |
| + tmpRoot string |
| + maxBytes int64 |
| + deadline time.Duration |
| + now func() time.Time |
| +} |
| + |
| +type geoipPublishOpts struct { |
| + Origin, Edition string |
| + Force bool |
| +} |
| + |
| +func newGeoipPublisher(store release.Store, tmpRoot string) *geoipPublisher { |
| + return &geoipPublisher{store: store, tmpRoot: tmpRoot, maxBytes: geoip.MaxBlobBytes, |
| + deadline: 15 * time.Minute, now: time.Now} |
| +} |
| + |
| +func marshalPointer(p geoip.Pointer) ([]byte, error) { return json.MarshalIndent(p, "", " ") } |
| + |
| +// Current reads the pointer. present=false means no pointer (feature off). |
| +func (p *geoipPublisher) Current(ctx context.Context) (geoip.Pointer, string, bool, error) { |
| + raw, etag, err := p.store.Get(ctx, geoip.PointerKey) |
| + if errors.Is(err, os.ErrNotExist) { |
| + return geoip.Pointer{}, "", false, nil |
| + } |
| + if err != nil { |
| + return geoip.Pointer{}, "", false, err |
| + } |
| + ptr, err := geoip.ParsePointer(raw) |
| + if err != nil { |
| + return geoip.Pointer{}, etag, true, err |
| + } |
| + return ptr, etag, true, nil |
| +} |
| + |
| +// geoipStaged is an upload on disk awaiting Commit. Whoever holds one owns |
| +// the publish slot (p.mu) and the temporary directory: Commit or Abandon |
| +// releases both (spec §4.2, codex R5-1). |
| +type geoipStaged struct { |
| + dir, path, sha string |
| + size int64 |
| +} |
| + |
| +// Stage reserves the one publish slot BEFORE reading any of src, then |
| +// streams src to a temporary file, hashing and capping as it copies. On |
| +// success the slot stays held for Commit; on failure it is released. |
| +func (p *geoipPublisher) Stage(ctx context.Context, src io.Reader) (*geoipStaged, error) { |
| + if !p.mu.TryLock() { |
| + return nil, errGeoipBusy |
| + } |
| + dir, err := os.MkdirTemp(p.tmpRoot, "geoip-publish-*") |
| + if err != nil { |
| + p.mu.Unlock() |
| + return nil, err |
| + } |
| + st := &geoipStaged{dir: dir, path: filepath.Join(dir, "db.mmdb")} |
| + st.sha, st.size, err = copyCapped(ctx, st.path, src, p.maxBytes) |
| + if err != nil { |
| + p.Abandon(st) |
| + return nil, err |
| + } |
| + return st, nil |
| +} |
| + |
| +// Abandon releases a staged upload that will not be committed. |
| +func (p *geoipPublisher) Abandon(st *geoipStaged) { |
| + os.RemoveAll(st.dir) |
| + p.mu.Unlock() |
| +} |
| + |
| +// Publish is Stage then Commit, under one deadline. |
| +func (p *geoipPublisher) Publish(ctx context.Context, src io.Reader, o geoipPublishOpts) (geoip.Pointer, error) { |
| + ctx, cancel := context.WithTimeout(ctx, p.deadline) |
| + defer cancel() |
| + st, err := p.Stage(ctx, src) |
| + if err != nil { |
| + return geoip.Pointer{}, err |
| + } |
| + return p.Commit(ctx, st, o) |
| +} |
| + |
| +// Commit is spec §4.1 steps 3-6 on a staged upload. It always removes the |
| +// staged directory and releases the slot. |
| +func (p *geoipPublisher) Commit(ctx context.Context, st *geoipStaged, o geoipPublishOpts) (geoip.Pointer, error) { |
| + defer p.Abandon(st) |
| + if _, ok := ctx.Deadline(); !ok { |
| + var cancel context.CancelFunc |
| + ctx, cancel = context.WithTimeout(ctx, p.deadline) |
| + defer cancel() |
| + } |
| + db, err := geo.OpenMMDB(st.path) |
| + if err != nil { |
| + return geoip.Pointer{}, refused("not a valid MaxMind-format database") |
| + } |
| + defer db.Close() |
| + if !hasCountryData(db) { |
| + return geoip.Pointer{}, refused("the database has no country data") |
| + } |
| + cur, etag, present, curErr := p.Current(ctx) |
| + switch { |
| + case curErr != nil && !present: |
| + return geoip.Pointer{}, curErr // the store itself failed |
| + case curErr != nil: |
| + // A pointer exists but does not parse: replace it (etag still guards). |
| + log.Printf("console: geoip publish: the current pointer is unreadable and will be replaced") |
| + case present && !o.Force && cur.DatabaseType == db.Source() && db.NodeCount() < cur.NodeCount/2: |
| + return geoip.Pointer{}, refused("fewer than half the current database's nodes (use -force if intended)") |
| + } |
| + next := geoip.Pointer{V: 1, SHA256: st.sha, Key: geoip.BlobKey(st.sha), Size: st.size, |
| + DatabaseType: db.Source(), BuildEpoch: db.BuildEpoch().Unix(), NodeCount: db.NodeCount(), |
| + Published: p.now().UTC().Truncate(time.Second), Origin: o.Origin, Edition: o.Edition} |
| + f, err := os.Open(st.path) |
| + if err != nil { |
| + return geoip.Pointer{}, err |
| + } |
| + defer f.Close() |
| + // A CAS conflict on the blob means a blob with this exact name, so these |
| + // exact bytes, already exists: carry on (spec §3 step 2). |
| + if _, err := release.PutObjectFrom(ctx, p.store, next.Key, f, st.size, ""); err != nil && !errors.Is(err, release.ErrCASConflict) { |
| + return geoip.Pointer{}, err |
| + } |
| + body, err := marshalPointer(next) |
| + if err != nil { |
| + return geoip.Pointer{}, err |
| + } |
| + if !present { |
| + etag = "" |
| + } |
| + if _, err := p.store.Put(ctx, geoip.PointerKey, body, etag); err != nil { |
| + return geoip.Pointer{}, fmt.Errorf("pointer write: %w", err) |
| + } |
| + if err := p.sweepLocked(ctx, next.Key); err != nil { |
| + log.Printf("console: geoip sweep after publish: %v", err) |
| + } |
| + log.Printf("console: geoip published %s (%s, %s)", st.sha[:8], next.DatabaseType, next.Origin) |
| + return next, nil |
| +} |
| + |
| +// copyCapped streams src into path, hashing, refusing more than max bytes |
| +// as it copies (codex R1-7). |
| +func copyCapped(ctx context.Context, path string, src io.Reader, max int64) (string, int64, error) { |
| + f, err := os.OpenFile(path, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600) |
| + if err != nil { |
| + return "", 0, err |
| + } |
| + defer f.Close() |
| + h := sha256.New() |
| + n, err := io.Copy(io.MultiWriter(f, h), io.LimitReader(geoipCtxReader{ctx, src}, max+1)) |
| + if err != nil { |
| + return "", 0, err |
| + } |
| + if n > max { |
| + return "", 0, refused(fmt.Sprintf("larger than %d bytes", max)) |
| + } |
| + if n == 0 { |
| + return "", 0, refused("empty upload") |
| + } |
| + if err := f.Sync(); err != nil { |
| + return "", 0, err |
| + } |
| + return hex.EncodeToString(h.Sum(nil)), n, nil |
| +} |
| + |
| +type geoipCtxReader struct { |
| + ctx context.Context |
| + r io.Reader |
| +} |
| + |
| +func (c geoipCtxReader) Read(b []byte) (int, error) { |
| + if err := c.ctx.Err(); err != nil { |
| + return 0, err |
| + } |
| + return c.r.Read(b) |
| +} |
| + |
| +func hasCountryData(db *geo.MMDB) bool { |
| + n := 0 |
| + for _, a := range geoipProbeAddrs { |
| + if r, ok := db.Lookup(net.ParseIP(a)); ok && r.Country != "" { |
| + n++ |
| + } |
| + } |
| + return n >= geoipProbeNeed |
| +} |
| + |
| +// Off deletes the pointer, then every blob (spec §3). Unconditional delete: |
| +// safe under the single-writer mutex (codex R2-6). |
| +func (p *geoipPublisher) Off(ctx context.Context) error { |
| + p.mu.Lock() |
| + defer p.mu.Unlock() |
| + if err := p.store.Delete(ctx, geoip.PointerKey); err != nil && !errors.Is(err, os.ErrNotExist) { |
| + return err |
| + } |
| + return p.sweepLocked(ctx, "") |
| +} |
| + |
| +// Sweep deletes every blob the pointer does not name (orphans from a crash |
| +// between blob upload and pointer write). Run at startup and daily. |
| +func (p *geoipPublisher) Sweep(ctx context.Context) error { |
| + p.mu.Lock() |
| + defer p.mu.Unlock() |
| + cur, _, present, err := p.Current(ctx) |
| + if err != nil { |
| + return err |
| + } |
| + keep := "" |
| + if present && !cur.Expired { |
| + keep = cur.Key |
| + } |
| + return p.sweepLocked(ctx, keep) |
| +} |
| + |
| +func (p *geoipPublisher) sweepLocked(ctx context.Context, keep string) error { |
| + keys, err := p.store.List(ctx, geoip.Prefix) |
| + if err != nil { |
| + return err |
| + } |
| + var firstErr error |
| + for _, k := range keys { |
| + if !strings.HasSuffix(k, ".mmdb") || k == keep { |
| + continue |
| + } |
| + if err := p.store.Delete(ctx, k); err != nil && !errors.Is(err, os.ErrNotExist) && firstErr == nil { |
| + firstErr = err |
| + } |
| + } |
| + return firstErr |
| +} |
| + |
| +// ExpireIfDue applies D1 to the bucket: a GeoLite2 pointer past expiry is |
| +// rewritten with expired=true and its blob destroyed. |
| +func (p *geoipPublisher) ExpireIfDue(ctx context.Context, now time.Time) (bool, error) { |
| + p.mu.Lock() |
| + defer p.mu.Unlock() |
| + cur, etag, present, err := p.Current(ctx) |
| + if err != nil || !present || cur.Expired || !geoip.Expired(cur.DatabaseType, cur.Build(), now) { |
| + return false, err |
| + } |
| + cur.Expired = true |
| + body, err := marshalPointer(cur) |
| + if err != nil { |
| + return false, err |
| + } |
| + if _, err := p.store.Put(ctx, geoip.PointerKey, body, etag); err != nil { |
| + return false, err |
| + } |
| + log.Printf("console: geoip database %s expired (GeoLite2, 30 days after build) and was removed", cur.SHA256[:8]) |
| + return true, p.sweepLocked(ctx, "") |
| +} |