carlos / platform Public

Clone
git clone https://amadan.net/carlos/platform

Plain git — no account needed to clone.

Download

Download this file

1package console
2
3import (
4 "context"
5 "crypto/sha256"
6 "encoding/hex"
7 "encoding/json"
8 "errors"
9 "fmt"
10 "io"
11 "log"
12 "os"
13 "path/filepath"
14 "strings"
15 "sync"
16 "time"
17
18 "github.com/carlosframework/platform/internal/geoip"
19 "github.com/carlosframework/platform/internal/release"
20)
21
22// geoipProbeAddrs is the country probe (spec §4.1 step 4): at least three
23// must answer with a country. A variable so tests can use addresses the
24// MaxMind fixtures hold (FACTS.md).
25var geoipProbeAddrs = []string{"8.8.8.8", "1.1.1.1", "9.9.9.9", "208.67.222.222", "2001:4860:4860::8888"}
26
27const geoipProbeNeed = 3
28
29var (
30 errGeoipBusy = errors.New("a geoip publish is already in progress")
31 errGeoipRefused = errors.New("geoip database refused")
32)
33
34func refused(category string) error { return fmt.Errorf("%w: %s", errGeoipRefused, category) }
35
36// geoipPublisher is the console's single writer of control/geoip/ (spec §3).
37// mu is held through cleanup by every publish, off, sweep and expiry, and a
38// publish that finds it held answers errGeoipBusy rather than queueing.
39type geoipPublisher struct {
40 mu sync.Mutex
41 store release.Store
42 tmpRoot string
43 maxBytes int64
44 deadline time.Duration
45 now func() time.Time
46 // expired is D1's rule, a field so a test can apply it to the GeoIP2
47 // fixtures (which never expire) without a GeoLite2 file in the repo.
48 expired func(dbType string, build, now time.Time) bool
49 // storeTimeout bounds a single small bucket read made while holding,
50 // or about to hold, the publish slot.
51 storeTimeout time.Duration
52}
53
54type geoipPublishOpts struct {
55 Origin, Edition string
56 Force bool
57 // RequireType refuses a database of any other type (the feed's check,
58 // spec §10.3, including on the first publish). Monotonic refuses a
59 // build that is not newer than the current pointer's when that pointer
60 // came from the same Origin. The feed sets both and never Force.
61 RequireType string
62 Monotonic bool
63}
64
65// newGeoipPublisher clears tmpRoot (the dedicated geoip-staging directory)
66// before anything can stage into it: a console that crashed mid-upload or
67// mid-job must not leave its 1 GiB behind for good (spec §4.2, codex R6-2).
68func newGeoipPublisher(store release.Store, tmpRoot string) *geoipPublisher {
69 if tmpRoot != "" {
70 if err := os.RemoveAll(tmpRoot); err != nil {
71 log.Printf("console: geoip staging directory could not be cleared: %v", err)
72 }
73 if err := os.MkdirAll(tmpRoot, 0o700); err != nil {
74 log.Printf("console: geoip staging directory could not be created: %v", err)
75 }
76 }
77 return &geoipPublisher{store: store, tmpRoot: tmpRoot, maxBytes: geoip.MaxBlobBytes,
78 deadline: 15 * time.Minute, now: time.Now, expired: geoip.Expired, storeTimeout: 30 * time.Second}
79}
80
81func marshalPointer(p geoip.Pointer) ([]byte, error) { return json.MarshalIndent(p, "", " ") }
82
83// Current reads the pointer. present=false means no pointer (feature off).
84func (p *geoipPublisher) Current(ctx context.Context) (geoip.Pointer, string, bool, error) {
85 raw, etag, err := readPointerCapped(ctx, p.store)
86 if errors.Is(err, os.ErrNotExist) {
87 return geoip.Pointer{}, "", false, nil
88 }
89 if errors.Is(err, errPointerTooLarge) {
90 // Present but not a pointer anyone wrote: replaceable, never decoded.
91 return geoip.Pointer{}, etag, true, err
92 }
93 if err != nil {
94 return geoip.Pointer{}, "", false, err
95 }
96 ptr, err := geoip.ParsePointer(raw)
97 if err != nil {
98 return geoip.Pointer{}, etag, true, err
99 }
100 return ptr, etag, true, nil
101}
102
103// geoipStaged is an upload on disk awaiting Commit. Whoever holds one owns
104// the publish slot (p.mu) and the temporary directory: Commit or Abandon
105// releases both (spec §4.2, codex R5-1).
106type geoipStaged struct {
107 dir, path, sha string
108 size int64
109 // The pointer as it stood when the slot was taken (spec §3 step 1):
110 // every comparison is against this read, and the pointer write is
111 // conditional on its etag, so a foreign write during the upload is
112 // refused rather than overwritten.
113 cur geoip.Pointer
114 curEtag string
115 curPresent bool
116 curErr error
117}
118
119// Stage reserves the one publish slot BEFORE reading any of src, then
120// streams src to a temporary file, hashing and capping as it copies. On
121// success the slot stays held for Commit; on failure it is released.
122func (p *geoipPublisher) Stage(ctx context.Context, src io.Reader) (*geoipStaged, error) {
123 if !p.mu.TryLock() {
124 return nil, errGeoipBusy
125 }
126 dir, err := os.MkdirTemp(p.tmpRoot, "geoip-publish-*")
127 if err != nil {
128 p.mu.Unlock()
129 return nil, err
130 }
131 st := &geoipStaged{dir: dir, path: filepath.Join(dir, "db.mmdb")}
132 // The pointer read is bounded on its own: the caller's context may be
133 // an HTTP request whose only deadline is on reading its body (code
134 // review R4-1).
135 rctx, rcancel := context.WithTimeout(ctx, p.storeTimeout)
136 st.cur, st.curEtag, st.curPresent, st.curErr = p.Current(rctx)
137 rcancel()
138 if st.curErr != nil && !st.curPresent {
139 p.Abandon(st)
140 return nil, st.curErr // the store itself failed
141 }
142 st.sha, st.size, err = copyCapped(ctx, st.path, src, p.maxBytes)
143 if err != nil {
144 p.Abandon(st)
145 return nil, err
146 }
147 return st, nil
148}
149
150// Abandon releases a staged upload that will not be committed.
151func (p *geoipPublisher) Abandon(st *geoipStaged) {
152 os.RemoveAll(st.dir)
153 p.mu.Unlock()
154}
155
156var errPointerTooLarge = errors.New("the object at the geoip pointer key is larger than a pointer can be")
157
158// readPointerCapped reads control/geoip/current.json streamed and capped at
159// geoip.MaxPointerBytes, so an oversized object there can never be pulled
160// into the console's memory (code review R1-3). The etag is the store's;
161// DirStore cannot report one from Open, and its etag is defined as the
162// content's sha256 (release.DirStore, etagOf), so it is computed here from
163// the bytes, which are complete whenever they are under the cap.
164func readPointerCapped(ctx context.Context, s release.Store) ([]byte, string, error) {
165 return readCappedKey(ctx, s, geoip.PointerKey, geoip.MaxPointerBytes)
166}
167
168// readCappedKey is readPointerCapped for any small control object.
169func readCappedKey(ctx context.Context, s release.Store, key string, max int) ([]byte, string, error) {
170 rc, etag, err := release.OpenObject(ctx, s, key)
171 if err != nil {
172 return nil, "", err
173 }
174 defer rc.Close()
175 b, err := io.ReadAll(io.LimitReader(rc, int64(max)+1))
176 if err != nil {
177 return nil, "", err
178 }
179 if len(b) > max {
180 if etag == "" {
181 // Without the store's etag a conditional replace cannot be
182 // aimed at it; only off (unconditional delete) clears it.
183 return nil, "", errPointerTooLarge
184 }
185 return nil, etag, errPointerTooLarge
186 }
187 if etag == "" {
188 sum := sha256.Sum256(b)
189 etag = hex.EncodeToString(sum[:])
190 }
191 return b, etag, nil
192}
193
194// Publish is Stage then Commit, under one deadline.
195func (p *geoipPublisher) Publish(ctx context.Context, src io.Reader, o geoipPublishOpts) (geoip.Pointer, error) {
196 ctx, cancel := context.WithTimeout(ctx, p.deadline)
197 defer cancel()
198 st, err := p.Stage(ctx, src)
199 if err != nil {
200 return geoip.Pointer{}, err
201 }
202 return p.Commit(ctx, st, o)
203}
204
205// Commit is spec §4.1 steps 3-6 on a staged upload. It always removes the
206// staged directory and releases the slot.
207func (p *geoipPublisher) Commit(ctx context.Context, st *geoipStaged, o geoipPublishOpts) (geoip.Pointer, error) {
208 defer p.Abandon(st)
209 if _, ok := ctx.Deadline(); !ok {
210 var cancel context.CancelFunc
211 ctx, cancel = context.WithTimeout(ctx, p.deadline)
212 defer cancel()
213 }
214 // The file is untrusted: it is parsed only in a child process (code
215 // review R9), never here.
216 db, err := verifyInChild(ctx, st.path, geoipProbeAddrs)
217 switch {
218 case ctx.Err() != nil:
219 return geoip.Pointer{}, ctx.Err()
220 case errors.Is(err, errGeoipVerifyTimeout):
221 return geoip.Pointer{}, refused("the database took too long to verify")
222 case errors.Is(err, errGeoipVerifyTree):
223 return geoip.Pointer{}, refused("the database's search tree is malformed")
224 case err != nil:
225 return geoip.Pointer{}, refused("not a valid MaxMind-format database")
226 }
227 if db.Countries < geoipProbeNeed {
228 return geoip.Pointer{}, refused("the database has no country data")
229 }
230 dbBuild := time.Unix(db.BuildEpoch, 0).UTC()
231 // D1 at the door (codex R7-1): an already-expired GeoLite2 would replace
232 // a usable database with one every edge must reject. force never
233 // overrides it; the check runs again just before the pointer write.
234 if p.expired(db.Source, dbBuild, p.now()) {
235 return geoip.Pointer{}, refused("the database is already expired (GeoLite2, 30 days after build)")
236 }
237 if o.RequireType != "" && db.Source != o.RequireType {
238 return geoip.Pointer{}, refused("the feed returned a " + db.Source + " database, not " + o.RequireType)
239 }
240 cur, etag, present, curErr := st.cur, st.curEtag, st.curPresent, st.curErr
241 if o.Monotonic && present && curErr == nil && cur.Origin == o.Origin && !dbBuild.After(cur.Build()) {
242 return geoip.Pointer{}, refused("not newer than the published edition")
243 }
244 switch {
245 case curErr != nil:
246 // A pointer exists but does not parse: replace it (etag still guards).
247 log.Printf("console: geoip publish: the current pointer is unreadable and will be replaced")
248 case present && !o.Force && cur.DatabaseType == db.Source && db.NodeCount < cur.NodeCount/2:
249 return geoip.Pointer{}, refused("fewer than half the current database's nodes (use -force if intended)")
250 }
251 next := geoip.Pointer{V: 1, SHA256: st.sha, Key: geoip.BlobKey(st.sha), Size: st.size,
252 DatabaseType: db.Source, BuildEpoch: db.BuildEpoch, NodeCount: db.NodeCount,
253 Published: p.now().UTC().Truncate(time.Second), Origin: o.Origin, Edition: o.Edition}
254 f, err := os.Open(st.path)
255 if err != nil {
256 return geoip.Pointer{}, err
257 }
258 defer f.Close()
259 // A CAS conflict on the blob means an object already sits at this
260 // content-addressed name. It is only the same bytes if it hashes to the
261 // name: a truncated or foreign object there must never be published
262 // (spec §3 step 2, plan review R1-6).
263 if _, err := release.PutObjectFrom(ctx, p.store, next.Key, f, st.size, ""); errors.Is(err, release.ErrCASConflict) {
264 if err := p.verifyExistingBlob(ctx, next.Key, st.sha, st.size); err != nil {
265 return geoip.Pointer{}, err
266 }
267 } else if err != nil {
268 return geoip.Pointer{}, err
269 }
270 body, err := marshalPointer(next)
271 if err != nil {
272 return geoip.Pointer{}, err
273 }
274 if len(body) > geoip.MaxPointerBytes {
275 // Every reader caps the pointer at this size: one larger would
276 // publish a database no edge can ever read (code review R7-1).
277 return geoip.Pointer{}, refused("the pointer would be larger than readers accept")
278 }
279 if !present {
280 etag = ""
281 }
282 if p.expired(next.DatabaseType, next.Build(), p.now()) {
283 return geoip.Pointer{}, refused("the database expired while it was being published (GeoLite2, 30 days after build)")
284 }
285 if _, err := p.store.Put(ctx, geoip.PointerKey, body, etag); err != nil {
286 return geoip.Pointer{}, fmt.Errorf("pointer write: %w", err)
287 }
288 if err := p.sweepLocked(ctx, next.Key); err != nil {
289 log.Printf("console: geoip sweep after publish: %v", err)
290 }
291 log.Printf("console: geoip published %s (%s, %s)", st.sha[:8], next.DatabaseType, next.Origin)
292 return next, nil
293}
294
295// copyCapped streams src into path, hashing, refusing more than max bytes
296// as it copies (codex R1-7).
297func copyCapped(ctx context.Context, path string, src io.Reader, max int64) (string, int64, error) {
298 f, err := os.OpenFile(path, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600)
299 if err != nil {
300 return "", 0, err
301 }
302 defer f.Close()
303 h := sha256.New()
304 n, err := io.Copy(io.MultiWriter(f, h), io.LimitReader(geoipCtxReader{ctx, src}, max+1))
305 if err != nil {
306 return "", 0, err
307 }
308 if n > max {
309 return "", 0, refused(fmt.Sprintf("larger than %d bytes", max))
310 }
311 if n == 0 {
312 return "", 0, refused("empty upload")
313 }
314 if err := f.Sync(); err != nil {
315 return "", 0, err
316 }
317 return hex.EncodeToString(h.Sum(nil)), n, nil
318}
319
320type geoipCtxReader struct {
321 ctx context.Context
322 r io.Reader
323}
324
325func (c geoipCtxReader) Read(b []byte) (int, error) {
326 if err := c.ctx.Err(); err != nil {
327 return 0, err
328 }
329 return c.r.Read(b)
330}
331
332// verifyExistingBlob streams the object at key and checks it is exactly
333// sha/size. Rare (a republish of the same file), so the read is acceptable.
334func (p *geoipPublisher) verifyExistingBlob(ctx context.Context, key, sha string, size int64) error {
335 rc, _, err := release.OpenObject(ctx, p.store, key)
336 if err != nil {
337 return fmt.Errorf("existing blob unreadable: %w", err)
338 }
339 defer rc.Close()
340 h := sha256.New()
341 n, err := io.Copy(h, io.LimitReader(rc, size+1))
342 if err != nil {
343 return fmt.Errorf("existing blob unreadable: %w", err)
344 }
345 if n != size || hex.EncodeToString(h.Sum(nil)) != sha {
346 return refused("a different object already sits at this database's blob key")
347 }
348 return nil
349}
350
351// Off deletes the pointer, then every blob (spec §3). Unconditional delete:
352// safe under the single-writer mutex (codex R2-6).
353func (p *geoipPublisher) Off(ctx context.Context) error {
354 // Never waits behind a publish (codex R9-2): a slow upload or job can
355 // hold the slot for minutes, longer than any caller's timeout. The
356 // operator gets a 409 and runs off again when the publish finishes.
357 if !p.mu.TryLock() {
358 return errGeoipBusy
359 }
360 defer p.mu.Unlock()
361 ctx, cancel := context.WithTimeout(ctx, 2*time.Minute)
362 defer cancel()
363 if err := p.store.Delete(ctx, geoip.PointerKey); err != nil && !errors.Is(err, os.ErrNotExist) {
364 return err
365 }
366 return p.sweepLocked(ctx, "")
367}
368
369// Sweep deletes every blob the pointer does not name (orphans from a crash
370// between blob upload and pointer write). Run at startup and daily.
371func (p *geoipPublisher) Sweep(ctx context.Context) error {
372 p.mu.Lock()
373 defer p.mu.Unlock()
374 cur, _, present, err := p.Current(ctx)
375 if err != nil {
376 return err
377 }
378 keep := ""
379 if present && !cur.Expired {
380 keep = cur.Key
381 }
382 return p.sweepLocked(ctx, keep)
383}
384
385func (p *geoipPublisher) sweepLocked(ctx context.Context, keep string) error {
386 keys, err := p.store.List(ctx, geoip.Prefix)
387 if err != nil {
388 return err
389 }
390 var firstErr error
391 for _, k := range keys {
392 if !strings.HasSuffix(k, ".mmdb") || k == keep {
393 continue
394 }
395 if err := p.store.Delete(ctx, k); err != nil && !errors.Is(err, os.ErrNotExist) && firstErr == nil {
396 firstErr = err
397 }
398 }
399 return firstErr
400}
401
402// ExpireIfDue applies D1 to the bucket: a GeoLite2 pointer past expiry is
403// rewritten with expired=true and its blob destroyed.
404func (p *geoipPublisher) ExpireIfDue(ctx context.Context, now time.Time) (bool, error) {
405 p.mu.Lock()
406 defer p.mu.Unlock()
407 cur, etag, present, err := p.Current(ctx)
408 if err != nil || !present || cur.Expired || !geoip.Expired(cur.DatabaseType, cur.Build(), now) {
409 return false, err
410 }
411 cur.Expired = true
412 body, err := marshalPointer(cur)
413 if err != nil {
414 return false, err
415 }
416 if _, err := p.store.Put(ctx, geoip.PointerKey, body, etag); err != nil {
417 return false, err
418 }
419 log.Printf("console: geoip database %s expired (GeoLite2, 30 days after build) and was removed", cur.SHA256[:8])
420 return true, p.sweepLocked(ctx, "")
421}
422