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
3// The GeoIP feed (geoip spec §10): the console fetches new editions of
4// DB-IP Lite or GeoLite2 and publishes them through the same slot as
5// `carlos system geoip publish`, with two extra rules the feed may never
6// skip: the database's type must be the configured feed's, and an edition
7// must be newer than the last one this feed published.
8
9import (
10 "context"
11 "crypto/sha256"
12 "encoding/hex"
13 "encoding/json"
14 "errors"
15 "io"
16 "log"
17 mrand "math/rand/v2"
18 "net/http"
19 "os"
20 "strings"
21 "time"
22
23 "github.com/carlosframework/platform/internal/geoip"
24 "github.com/carlosframework/platform/internal/release"
25)
26
27const (
28 geoipFeedMaxCompressed = int64(512 << 20)
29 geoipFeedMaxExpanded = int64(3 << 29) // 1.5 GiB
30 geoipFeedMaxChecksum = 4 << 10
31)
32
33type geoipFeeder struct {
34 pub *geoipPublisher
35 store release.Store
36 spec geoip.FeedSpec
37 account string
38 key string
39 box string
40 client *http.Client
41 now func() time.Time
42 maxExpanded int64
43 // Test seams; "" means the real hosts.
44 dbipBase, maxmindBase string
45}
46
47// newGeoipFeeder reads the feed configuration. false means no feed: unset,
48// or misconfigured (logged once; the console starts either way).
49func newGeoipFeeder(cfg Config, pub *geoipPublisher) (*geoipFeeder, bool) {
50 name := strings.TrimSpace(cfg.GeoipFeed)
51 if name == "" || pub == nil {
52 return nil, false
53 }
54 spec, ok := geoip.ParseFeed(name)
55 if !ok {
56 log.Printf("console: geoip feed misconfigured: CONSOLE_GEOIP_FEED %q is not one of dbip-city-lite, dbip-country-lite, geolite2-city, geolite2-country, geoip2-city, geoip2-country; the feed is off", name)
57 return nil, false
58 }
59 if spec.Provider == "maxmind" && (cfg.GeoipMaxMindAccount == "" || cfg.GeoipMaxMindKey == "") {
60 log.Printf("console: geoip feed misconfigured: %s needs CONSOLE_GEOIP_MAXMIND_ACCOUNT and CONSOLE_GEOIP_MAXMIND_KEY; the feed is off", name)
61 return nil, false
62 }
63 return &geoipFeeder{
64 pub: pub, store: pub.store, spec: spec,
65 account: cfg.GeoipMaxMindAccount, key: cfg.GeoipMaxMindKey, box: cfg.BoxLabel,
66 client: &http.Client{Timeout: 15 * time.Minute, CheckRedirect: geoipFeedRedirect},
67 now: time.Now,
68 maxExpanded: geoipFeedMaxExpanded,
69 }, true
70}
71
72// geoipFeedRedirect follows at most 3 redirects, https only, and drops the
73// Basic credentials on any cross-host hop (MaxMind redirects downloads to
74// presigned R2 URLs; spec §10.2).
75func geoipFeedRedirect(req *http.Request, via []*http.Request) error {
76 if len(via) > 3 {
77 return errors.New("geoip feed: too many redirects")
78 }
79 if req.URL.Scheme != "https" {
80 return errors.New("geoip feed: refusing a redirect away from https")
81 }
82 if len(via) > 0 && req.URL.Host != via[0].URL.Host {
83 req.Header.Del("Authorization")
84 }
85 return nil
86}
87
88// geoipFeedFailure is a check that failed, with a fixed category for
89// feed.json and the log (never an error text: it could carry a URL).
90type geoipFeedFailure struct{ category string }
91
92func (f geoipFeedFailure) Error() string { return "geoip feed: " + f.category }
93
94func feedFail(category string) error { return geoipFeedFailure{category} }
95
96var errFeedNotFound = errors.New("geoip feed: no edition available yet")
97
98// Check is one feed check. It always writes feed.json.
99func (f *geoipFeeder) Check(ctx context.Context) error {
100 // Status I/O gets its own short deadline, detached from the check's: a
101 // check that ran out its 15 minutes on a stalled provider must still
102 // record the failure, or seven timeouts never add up to an alert (code
103 // review R2-2).
104 sctx, scancel := context.WithTimeout(context.WithoutCancel(ctx), 30*time.Second)
105 defer scancel()
106 st, etag, known := f.readStatus(sctx)
107 st.V, st.Feed, st.Box, st.LastCheck = 1, f.spec.Name, f.box, f.now().UTC().Truncate(time.Second)
108 edition, err := f.check(ctx)
109 var ff geoipFeedFailure
110 switch {
111 case err == nil && edition != "":
112 st.LastSuccess, st.LastEdition = st.LastCheck, edition
113 st.ConsecutiveFailures, st.ConsecutiveNotFound, st.LastFailure = 0, 0, ""
114 case err == nil:
115 // Nothing new: a success with nothing to do.
116 st.ConsecutiveFailures, st.ConsecutiveNotFound, st.LastFailure = 0, 0, ""
117 case errors.Is(err, errGeoipBusy):
118 // Someone is publishing by hand: not a failure, try tomorrow.
119 case errors.Is(err, errFeedNotFound):
120 st.ConsecutiveNotFound++
121 st.LastFailure = "not found"
122 case errors.As(err, &ff):
123 st.ConsecutiveFailures++
124 st.LastFailure = ff.category
125 log.Printf("console: geoip feed %s: edition %s rejected: %s", f.spec.Name, orNone(edition), ff.category)
126 default:
127 st.ConsecutiveFailures++
128 st.LastFailure = "store"
129 log.Printf("console: geoip feed %s: check failed: store", f.spec.Name)
130 }
131 if !known {
132 // The existing history could not be read: overwriting it would turn
133 // seven recorded failures into one and clear a firing alert with no
134 // success behind it (code review R3-2). Next check, then.
135 log.Printf("console: geoip feed status unreadable; this check's outcome was not recorded")
136 return err
137 }
138 wctx, wcancel := context.WithTimeout(context.WithoutCancel(ctx), 30*time.Second)
139 defer wcancel()
140 f.writeStatus(wctx, st, etag)
141 return err
142}
143
144func orNone(s string) string {
145 if s == "" {
146 return "(none)"
147 }
148 return s
149}
150
151// readStatus reads feed.json capped (never whole: code review R2-1) and
152// returns its etag for the conditional write. "" etag = absent.
153// known=false means the existing object could not be read at all (a
154// transient store error): the caller must not overwrite it.
155func (f *geoipFeeder) readStatus(ctx context.Context) (st geoip.FeedStatus, etag string, known bool) {
156 b, etag, err := readCappedKey(ctx, f.store, geoip.FeedKey, geoip.MaxPointerBytes)
157 switch {
158 case errors.Is(err, os.ErrNotExist):
159 return geoip.FeedStatus{}, "", true // confirmed absent
160 case errors.Is(err, errPointerTooLarge):
161 return geoip.FeedStatus{}, etag, true // replace an oversized object, never decode it
162 case err != nil:
163 return geoip.FeedStatus{}, "", false
164 }
165 s, err := geoip.ParseFeedStatus(b)
166 if err != nil || s.Feed != f.spec.Name {
167 return geoip.FeedStatus{}, etag, true // garbage, or another feed's history: replace it
168 }
169 return s, etag, true
170}
171
172func (f *geoipFeeder) writeStatus(ctx context.Context, st geoip.FeedStatus, etag string) {
173 b, _ := json.MarshalIndent(st, "", " ")
174 if _, err := f.store.Put(ctx, geoip.FeedKey, b, etag); err != nil {
175 if errors.Is(err, release.ErrCASConflict) && etag == "" {
176 // Present but unreadable before (oversized under DirStore, or a
177 // race): a delete then create-only put, best effort.
178 f.store.Delete(ctx, geoip.FeedKey)
179 if _, err := f.store.Put(ctx, geoip.FeedKey, b, ""); err == nil {
180 return
181 }
182 }
183 log.Printf("console: geoip feed status not written")
184 }
185}
186
187// check returns the edition published ("" when there was nothing new).
188func (f *geoipFeeder) check(ctx context.Context) (string, error) {
189 cur, _, present, curErr := f.pub.Current(ctx)
190 ours := present && curErr == nil && cur.Origin == "feed:"+f.spec.Name
191 if f.spec.Provider == "dbip" {
192 return f.checkDBIP(ctx, cur, ours)
193 }
194 return f.checkMaxMind(ctx, cur, ours)
195}
196
197func (f *geoipFeeder) checkDBIP(ctx context.Context, cur geoip.Pointer, ours bool) (string, error) {
198 now := f.now().UTC()
199 this := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, time.UTC)
200 if ours && cur.Edition == this.Format("2006-01") {
201 return cur.Edition, nil // confirmed current: a success (code review R7-2)
202 }
203 months := []time.Time{this}
204 if !ours {
205 // No pointer of ours yet: last month is the best there is until
206 // this month's appears. A pointer of ours already holds it.
207 months = append(months, this.AddDate(0, -1, 0))
208 }
209 for _, m := range months {
210 res, err := f.get(ctx, geoip.DBIPURL(f.dbipBase, f.spec, m), false)
211 if err != nil {
212 return "", feedFail("download")
213 }
214 if res.StatusCode == http.StatusNotFound || res.StatusCode == http.StatusGone {
215 res.Body.Close()
216 continue
217 }
218 if res.StatusCode != http.StatusOK {
219 res.Body.Close()
220 return "", feedFail("download")
221 }
222 edition := m.Format("2006-01")
223 err = f.publish(ctx, res.Body, edition, "", func(r io.Reader) (io.Reader, error) {
224 return geoip.GunzipReader(r, f.maxExpanded)
225 })
226 res.Body.Close()
227 return edition, err
228 }
229 return "", errFeedNotFound
230}
231
232func (f *geoipFeeder) checkMaxMind(ctx context.Context, cur geoip.Pointer, ours bool) (string, error) {
233 head, err := f.request(ctx, http.MethodHead, geoip.MaxMindURL(f.maxmindBase, f.spec, "tar.gz"), true)
234 if err != nil {
235 return "", feedFail("download")
236 }
237 head.Body.Close()
238 if head.StatusCode == http.StatusNotFound || head.StatusCode == http.StatusGone {
239 return "", errFeedNotFound
240 }
241 if head.StatusCode != http.StatusOK {
242 return "", feedFail("download")
243 }
244 // The edition is the provider's Last-Modified, but only as a real HTTP
245 // date, re-rendered by us: an arbitrary header value must never reach
246 // the pointer or feed.json (code review R7-1).
247 lm, err := http.ParseTime(head.Header.Get("Last-Modified"))
248 if err != nil {
249 return "", feedFail("download")
250 }
251 edition := lm.UTC().Format(http.TimeFormat)
252 if ours && cur.Edition == edition {
253 return edition, nil // confirmed current: a success (code review R7-2)
254 }
255 sumRes, err := f.get(ctx, geoip.MaxMindURL(f.maxmindBase, f.spec, "tar.gz.sha256"), true)
256 if err != nil {
257 return edition, feedFail("download")
258 }
259 sumBody, err := io.ReadAll(io.LimitReader(sumRes.Body, geoipFeedMaxChecksum+1))
260 sumRes.Body.Close()
261 if err != nil || sumRes.StatusCode != http.StatusOK || len(sumBody) > geoipFeedMaxChecksum {
262 return edition, feedFail("checksum")
263 }
264 want := strings.ToLower(strings.TrimSpace(strings.SplitN(string(sumBody), " ", 2)[0]))
265 if !geoip.IsSHA256Hex(want) {
266 return edition, feedFail("checksum")
267 }
268 res, err := f.get(ctx, geoip.MaxMindURL(f.maxmindBase, f.spec, "tar.gz"), true)
269 if err != nil || res.StatusCode != http.StatusOK {
270 if res != nil {
271 res.Body.Close()
272 }
273 return edition, feedFail("download")
274 }
275 defer res.Body.Close()
276 return edition, f.publish(ctx, res.Body, edition, want, func(r io.Reader) (io.Reader, error) {
277 return geoip.TarGzMMDB(r, f.maxExpanded)
278 })
279}
280
281// publish streams body (capped, hashed) through open into the publish
282// slot. wantSHA, when set, is the archive's published SHA-256, checked
283// after the whole archive has been read and before anything is committed.
284func (f *geoipFeeder) publish(ctx context.Context, body io.Reader, edition, wantSHA string, open func(io.Reader) (io.Reader, error)) error {
285 h := sha256.New()
286 archive := io.TeeReader(geoip.CappedReader(body, geoipFeedMaxCompressed), h)
287 db, err := open(archive)
288 if err != nil {
289 return classifyFeedErr(err, "invalid")
290 }
291 st, err := f.pub.Stage(ctx, db)
292 if err != nil {
293 if errors.Is(err, errGeoipBusy) {
294 return err
295 }
296 return classifyFeedErr(err, "download")
297 }
298 if wantSHA != "" {
299 // The tar walk stopped at the database; the checksum covers the
300 // whole archive, so read the rest through the same capped tee.
301 if _, err := io.Copy(io.Discard, archive); err != nil {
302 f.pub.Abandon(st)
303 return classifyFeedErr(err, "download")
304 }
305 if hex.EncodeToString(h.Sum(nil)) != wantSHA {
306 f.pub.Abandon(st)
307 return feedFail("checksum")
308 }
309 }
310 _, err = f.pub.Commit(ctx, st, geoipPublishOpts{Origin: "feed:" + f.spec.Name, Edition: edition, RequireType: f.spec.Type, Monotonic: true})
311 if err == nil {
312 return nil
313 }
314 msg := err.Error()
315 switch {
316 case strings.Contains(msg, "not newer"):
317 return feedFail("not newer")
318 case strings.Contains(msg, "the feed returned"):
319 return feedFail("wrong type")
320 case strings.Contains(msg, "expired"):
321 return feedFail("expired")
322 case errors.Is(err, errGeoipRefused):
323 return feedFail("invalid")
324 }
325 return feedFail("store")
326}
327
328func classifyFeedErr(err error, fallback string) error {
329 if errors.Is(err, geoip.ErrTooLarge) || errors.Is(err, errGeoipRefused) && strings.Contains(err.Error(), "larger than") {
330 return feedFail("too large")
331 }
332 return feedFail(fallback)
333}
334
335func (f *geoipFeeder) get(ctx context.Context, url string, auth bool) (*http.Response, error) {
336 return f.request(ctx, http.MethodGet, url, auth)
337}
338
339func (f *geoipFeeder) request(ctx context.Context, method, url string, auth bool) (*http.Response, error) {
340 req, err := http.NewRequestWithContext(ctx, method, url, nil)
341 if err != nil {
342 return nil, err
343 }
344 if auth {
345 // Basic auth in a header, never in the URL (spec §10.1).
346 req.SetBasicAuth(f.account, f.key)
347 }
348 return f.client.Do(req)
349}
350
351// run is the feed's schedule: 5 minutes after start, then daily.
352func (f *geoipFeeder) run(ctx context.Context) {
353 wait := 5 * time.Minute
354 for {
355 select {
356 case <-ctx.Done():
357 return
358 case <-time.After(wait):
359 }
360 cctx, cancel := context.WithTimeout(ctx, f.pub.deadline)
361 f.Check(cctx)
362 cancel()
363 wait = 24*time.Hour + mrand.N(time.Hour)
364 }
365}
366
367// clearFeedStatus removes feed.json when no feed is configured, so a feed
368// switched off never alerts anyone.
369func clearFeedStatus(ctx context.Context, s release.Store) error {
370 if err := s.Delete(ctx, geoip.FeedKey); err != nil && !errors.Is(err, os.ErrNotExist) {
371 return err
372 }
373 return nil
374}
375
376// retryClearFeedStatus keeps trying until feed.json is gone or ctx ends:
377// a failed delete must not leave a disabled feed's alert firing for good
378// (code review R6-1). Each attempt is bounded; the wait doubles from
379// first to max.
380func retryClearFeedStatus(ctx context.Context, s release.Store, first, max time.Duration) {
381 wait := first
382 for {
383 actx, cancel := context.WithTimeout(ctx, 30*time.Second)
384 err := clearFeedStatus(actx, s)
385 cancel()
386 if err == nil {
387 return
388 }
389 log.Printf("console: geoip feed status could not be cleared; retrying in %s", wait)
390 select {
391 case <-ctx.Done():
392 return
393 case <-time.After(wait):
394 }
395 if wait *= 2; wait > max {
396 wait = max
397 }
398 }
399}
400