mirror of
https://github.com/pocket-id/pocket-id.git
synced 2026-08-24 21:17:31 +00:00
177 lines
5.0 KiB
Go
177 lines
5.0 KiB
Go
package geolite
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"log/slog"
|
|
"net"
|
|
"net/netip"
|
|
"os"
|
|
"sync"
|
|
|
|
"github.com/oschwald/maxminddb-golang/v2"
|
|
|
|
"github.com/pocket-id/pocket-id/backend/internal/utils"
|
|
)
|
|
|
|
// The GeoLite2 City database is kept on disk and memory-mapped (the format is optimized for random access)
|
|
//
|
|
// The database file is considered cache, not state: it is a copy of a public artifact that any replica can rebuild on its own, so nothing is lost when a node goes away, and every replica keeps its own without needing to replicate anything
|
|
// It is also the supported way to supply a database by hand, which is what air-gapped deployments do: the file is watched, so replacing it takes effect without a restart
|
|
|
|
// internalNetworkCountry is reported for addresses that aren't routable on the public Internet
|
|
const internalNetworkCountry = "Internal Network"
|
|
|
|
// Service resolves IP addresses to locations, against a memory-mapped GeoLite2 City database
|
|
type Service struct {
|
|
log *slog.Logger
|
|
dbPath string
|
|
|
|
// mu guards the fields below
|
|
// A lookup holds it for reading throughout, so a reload can't unmap the database from under it
|
|
mu sync.RWMutex
|
|
// db is the database currently mapped, or nil when there is no readable database at dbPath
|
|
db *maxminddb.Reader
|
|
// dbModTime and dbSize identify the file that was mapped, so a reload of an unchanged file is skipped
|
|
dbModTime int64
|
|
dbSize int64
|
|
}
|
|
|
|
func newService(log *slog.Logger, dbPath string) *Service {
|
|
return &Service{
|
|
log: log,
|
|
dbPath: dbPath,
|
|
}
|
|
}
|
|
|
|
// GetLocationByIP returns the country and city of the given IP address
|
|
// Both are empty when the address isn't in the database, or when no database is available
|
|
func (s *Service) GetLocationByIP(_ context.Context, ipAddress string) (country string, city string, err error) {
|
|
if ipAddress == "" {
|
|
return "", "", nil
|
|
}
|
|
|
|
// Check the IP address against known private IP ranges, which can be short-circuited
|
|
ip := net.ParseIP(ipAddress)
|
|
if ip != nil {
|
|
switch {
|
|
case utils.IsLocalIPv6(ip):
|
|
return internalNetworkCountry, "LAN", nil
|
|
case utils.IsTailscaleIP(ip):
|
|
return internalNetworkCountry, "Tailscale", nil
|
|
case utils.IsPrivateIP(ip):
|
|
return internalNetworkCountry, "LAN", nil
|
|
case utils.IsLocalhostIP(ip):
|
|
return internalNetworkCountry, "localhost", nil
|
|
}
|
|
}
|
|
|
|
addr, err := netip.ParseAddr(ipAddress)
|
|
if err != nil {
|
|
return "", "", fmt.Errorf("failed to parse IP address: %w", err)
|
|
}
|
|
|
|
// The read lock is held for the whole lookup, including decoding, because the record is decoded straight out of the mapped file
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
|
|
if s.db == nil {
|
|
// No database is available
|
|
return "", "", nil
|
|
}
|
|
|
|
result := s.db.Lookup(addr)
|
|
if !result.Found() {
|
|
return "", "", nil
|
|
}
|
|
|
|
var record geoLiteRecord
|
|
err = result.Decode(&record)
|
|
if err != nil {
|
|
return "", "", fmt.Errorf("failed to decode database record: %w", err)
|
|
}
|
|
|
|
return record.Country.Names["en"], record.City.Names["en"], nil
|
|
}
|
|
|
|
// geoLiteRecord is the subset of a GeoLite2 City record that Pocket ID uses
|
|
type geoLiteRecord struct {
|
|
City struct {
|
|
Names map[string]string `maxminddb:"names"`
|
|
} `maxminddb:"city"`
|
|
Country struct {
|
|
Names map[string]string `maxminddb:"names"`
|
|
} `maxminddb:"country"`
|
|
}
|
|
|
|
// load maps the database at dbPath, replacing the one currently mapped
|
|
// A file that is already mapped is left alone, so reloading after an unrelated change is free
|
|
func (s *Service) load(ctx context.Context) error {
|
|
info, err := os.Stat(s.dbPath)
|
|
if err != nil {
|
|
if os.IsNotExist(err) {
|
|
// There's no database to map yet: lookups return no location until one shows up
|
|
s.unload()
|
|
return nil
|
|
}
|
|
|
|
return fmt.Errorf("failed to stat the GeoLite2 City database: %w", err)
|
|
}
|
|
|
|
modTime, size := info.ModTime().UnixNano(), info.Size()
|
|
|
|
s.mu.RLock()
|
|
unchanged := s.db != nil && s.dbModTime == modTime && s.dbSize == size
|
|
s.mu.RUnlock()
|
|
|
|
if unchanged {
|
|
return nil
|
|
}
|
|
|
|
db, err := maxminddb.Open(s.dbPath)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to open the GeoLite2 City database: %w", err)
|
|
}
|
|
|
|
s.mu.Lock()
|
|
old := s.db
|
|
s.db = db
|
|
s.dbModTime = modTime
|
|
s.dbSize = size
|
|
s.mu.Unlock()
|
|
|
|
// The previous database is unmapped only once no lookup can still be reading it, which the write lock above guarantees
|
|
closeDatabase(old)
|
|
|
|
s.log.InfoContext(ctx, "Loaded the GeoLite2 City database",
|
|
slog.String("path", s.dbPath),
|
|
slog.Time("modTime", info.ModTime()),
|
|
slog.Int64("size", size),
|
|
)
|
|
|
|
return nil
|
|
}
|
|
|
|
// unload drops the database currently mapped, so lookups stop returning locations from a file that is no longer there
|
|
func (s *Service) unload() {
|
|
s.mu.Lock()
|
|
old := s.db
|
|
s.db = nil
|
|
s.dbModTime = 0
|
|
s.dbSize = 0
|
|
s.mu.Unlock()
|
|
|
|
closeDatabase(old)
|
|
}
|
|
|
|
// closeDatabase unmaps a database
|
|
// The caller must have already made it unreachable to lookups, since unmapping a database that is still being read would crash the process
|
|
func closeDatabase(db *maxminddb.Reader) {
|
|
if db == nil {
|
|
return
|
|
}
|
|
|
|
// Unmapping only fails if the mapping is already gone, which is not something the caller can act on
|
|
_ = db.Close()
|
|
}
|