repos
/ orchard main

orchard

mirror

Every 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

5.3 KB · 213 lines · Go Raw History
  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}