orchard
mirrorEvery site I host, in one repo, along with the Cloudflare Tunnel and Caddy that front them. It's all Go, Vite, and SQLite, and it runs on a desktop at home with nothing listening on an inbound port.
blogbuncaddycloudflare-tunneldockergogolanghomelabhtml-templatemonorepoself-hostedseosqlitestatic-sitetypstuptime-monitoringviteweb-analytics
1package main
2
3import (
4 "compress/gzip"
5 "fmt"
6 "io"
7 "log/slog"
8 "net/http"
9 "net/netip"
10 "os"
11 "path/filepath"
12 "sync"
13 "time"
14
15 "github.com/oschwald/maxminddb-golang/v2"
16)
17
18// GeoIP resolves a visitor address to a country, region and city against DB-IP
19// City Lite. It is safe for concurrent use, and a missing database disables
20// enrichment rather than failing the boot.
21type GeoIP struct {
22 path string
23
24 mu sync.RWMutex
25 reader *maxminddb.Reader
26}
27
28// GeoLookup is the enrichment written onto a session_start event.
29type GeoLookup struct {
30 Country string
31 Region string
32 City string
33 Lat float64
34 Lon float64
35 HasLoc bool
36}
37
38// cityRecord is the part of the GeoIP2-City schema this app uses; v2 of the
39// reader ships no record structs. DB-IP Lite follows MaxMind's schema.
40type cityRecord struct {
41 Country struct {
42 ISOCode string `maxminddb:"iso_code"`
43 } `maxminddb:"country"`
44 Subdivisions []struct {
45 ISOCode string `maxminddb:"iso_code"`
46 Names map[string]string `maxminddb:"names"`
47 } `maxminddb:"subdivisions"`
48 City struct {
49 Names map[string]string `maxminddb:"names"`
50 } `maxminddb:"city"`
51 Location struct {
52 Latitude float64 `maxminddb:"latitude"`
53 Longitude float64 `maxminddb:"longitude"`
54 } `maxminddb:"location"`
55}
56
57// LoadGeoIP opens the database if it is there, and returns a working but inert
58// GeoIP if it is not.
59func LoadGeoIP(path string) *GeoIP {
60 g := &GeoIP{path: path}
61 if r, err := maxminddb.Open(path); err == nil {
62 g.reader = r
63 slog.Info(fmt.Sprintf("geoip loaded from %s", path))
64 } else {
65 slog.Info(fmt.Sprintf("geoip unavailable at %s (%v); country enrichment is off until a refresh lands", path, err))
66 }
67 return g
68}
69
70// Reload swaps in a freshly downloaded database. The reader mmaps the file, so
71// closing it while a lookup is in flight would unmap memory being read.
72func (g *GeoIP) Reload() bool {
73 r, err := maxminddb.Open(g.path)
74 if err != nil {
75 slog.Info(fmt.Sprintf("geoip reload: %v", err))
76 return false
77 }
78 g.mu.Lock()
79 old := g.reader
80 g.reader = r
81 g.mu.Unlock()
82 if old != nil {
83 _ = old.Close()
84 }
85 slog.Info(fmt.Sprintf("geoip reloaded from %s", g.path))
86 return true
87}
88
89// Lookup resolves one address. The read lock has to span the decode, which is
90// what touches the mapped bytes.
91func (g *GeoIP) Lookup(ip netip.Addr) (GeoLookup, bool) {
92 g.mu.RLock()
93 defer g.mu.RUnlock()
94 if g.reader == nil {
95 return GeoLookup{}, false
96 }
97
98 var rec cityRecord
99 res := g.reader.Lookup(ip)
100 if !res.Found() {
101 return GeoLookup{}, false
102 }
103 if err := res.Decode(&rec); err != nil {
104 return GeoLookup{}, false
105 }
106
107 out := GeoLookup{
108 Country: rec.Country.ISOCode,
109 City: rec.City.Names["en"],
110 }
111 if len(rec.Subdivisions) > 0 {
112 sub := rec.Subdivisions[0]
113 // The admin-1 topojson matches on the English name; the ISO code is
114 // a fallback that will not join to a shape.
115 if n := sub.Names["en"]; n != "" {
116 out.Region = n
117 } else {
118 out.Region = sub.ISOCode
119 }
120 }
121 if rec.Location.Latitude != 0 || rec.Location.Longitude != 0 {
122 out.Lat = rec.Location.Latitude
123 out.Lon = rec.Location.Longitude
124 out.HasLoc = true
125 }
126 return out, true
127}
128
129// geoipMaxAge follows DB-IP, which publishes monthly.
130const geoipMaxAge = 30 * 24 * time.Hour
131
132// EnsureGeoIPDB downloads a fresh database when the local one is missing or
133// stale, and reports whether a new file was written. It walks back two months
134// because DB-IP publishes each month's file some hours into the first.
135func EnsureGeoIPDB(dest string) (bool, error) {
136 if info, err := os.Stat(dest); err == nil {
137 if time.Since(info.ModTime()) < geoipMaxAge {
138 return false, nil
139 }
140 }
141
142 now := time.Now().UTC()
143 var lastErr error
144 for offset := 0; offset < 3; offset++ {
145 target := now.AddDate(0, -offset, 0)
146 url := fmt.Sprintf("https://download.db-ip.com/free/dbip-city-lite-%d-%02d.mmdb.gz",
147 target.Year(), int(target.Month()))
148 if err := downloadGeoIP(url, dest); err != nil {
149 slog.Info(fmt.Sprintf("geoip download %d-%02d: %v", target.Year(), int(target.Month()), err))
150 lastErr = err
151 continue
152 }
153 slog.Info(fmt.Sprintf("geoip downloaded from %s", url))
154 return true, nil
155 }
156 if lastErr == nil {
157 lastErr = fmt.Errorf("no candidate months")
158 }
159 return false, lastErr
160}
161
162// downloadGeoIP fetches, decompresses, validates and atomically installs the
163// database. Validation happens before the rename, because a truncated file at
164// dest would carry a fresh mtime and defeat the staleness check for a month.
165func downloadGeoIP(url, dest string) error {
166 req, err := http.NewRequest(http.MethodGet, url, nil)
167 if err != nil {
168 return err
169 }
170 client := &http.Client{Timeout: 10 * time.Minute}
171 resp, err := client.Do(req)
172 if err != nil {
173 return err
174 }
175 defer resp.Body.Close()
176 if resp.StatusCode != http.StatusOK {
177 return fmt.Errorf("http %s", resp.Status)
178 }
179
180 gz, err := gzip.NewReader(resp.Body)
181 if err != nil {
182 return fmt.Errorf("gzip: %w", err)
183 }
184 defer gz.Close()
185
186 if err := os.MkdirAll(filepath.Dir(dest), 0o755); err != nil {
187 return err
188 }
189 tmp := dest + ".tmp"
190 f, err := os.Create(tmp)
191 if err != nil {
192 return err
193 }
194 if _, err := io.Copy(f, gz); err != nil {
195 f.Close()
196 os.Remove(tmp)
197 return err
198 }
199 if err := f.Close(); err != nil {
200 os.Remove(tmp)
201 return err
202 }
203
204 r, err := maxminddb.Open(tmp)
205 if err != nil {
206 os.Remove(tmp)
207 return fmt.Errorf("downloaded mmdb failed validation: %w", err)
208 }
209 _ = r.Close()
210
211 return os.Rename(tmp, dest)
212}