Add connection history per peer with country and network lookup
- The stats sampler records sessions per peer: start, end, address and
traffic. A session ends when the peer goes quiet or is disabled; a new
one starts when the device changes networks. Stored in stats.json and
kept as long as the daily traffic history (max 1000 per peer).
- Country and network operator come from the free DB-IP Lite databases
(CC BY 4.0), downloaded monthly and looked up locally, so peer
addresses never leave the server. Settings → Data retention can switch
this off, which deletes the databases.
- API: GET /peers/{id}/sessions; peer stats include the current location;
settings include the database status.
- Web UI and iOS app: connection history card, location line, country
code in the peer list (web), switch in data retention.
Claude-Session: https://claude.ai/code/session_01RAnLbyQZ5ZTA7KqwXP98nw
This commit is contained in:
@@ -0,0 +1,270 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"compress/gzip"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/netip"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/oschwald/maxminddb-golang"
|
||||
)
|
||||
|
||||
// Country and network (autonomous system) of peer endpoints come from the
|
||||
// free DB-IP Lite databases (CC BY 4.0, https://db-ip.com). They are
|
||||
// downloaded once a month and searched locally, so endpoint addresses never
|
||||
// leave the server.
|
||||
|
||||
// GeoInfo describes where an address is.
|
||||
type GeoInfo struct {
|
||||
Country string `json:"country,omitempty"` // ISO code, e.g. "DE"
|
||||
CountryName string `json:"countryName,omitempty"` // e.g. "Germany"
|
||||
ASN uint `json:"asn,omitempty"`
|
||||
Network string `json:"network,omitempty"` // operator, e.g. "Deutsche Telekom AG"
|
||||
}
|
||||
|
||||
const (
|
||||
geoMaxAge = 32 * 24 * time.Hour // DB-IP publishes monthly
|
||||
geoCheckFreq = 24 * time.Hour
|
||||
geoBaseURL = "https://download.db-ip.com/free/dbip-%s-lite-%s.mmdb.gz"
|
||||
)
|
||||
|
||||
type Geo struct {
|
||||
dir string
|
||||
enabled atomic.Bool
|
||||
kick chan struct{}
|
||||
|
||||
mu sync.RWMutex
|
||||
country *maxminddb.Reader
|
||||
asn *maxminddb.Reader
|
||||
}
|
||||
|
||||
func newGeo(dir string, enabled bool) *Geo {
|
||||
g := &Geo{dir: dir, kick: make(chan struct{}, 1)}
|
||||
g.enabled.Store(enabled)
|
||||
if enabled {
|
||||
g.open()
|
||||
}
|
||||
return g
|
||||
}
|
||||
|
||||
func (g *Geo) path(kind string) string { return filepath.Join(g.dir, "geo-"+kind+".mmdb") }
|
||||
|
||||
// open (re)loads whichever database files exist.
|
||||
func (g *Geo) open() {
|
||||
load := func(kind string) *maxminddb.Reader {
|
||||
r, err := maxminddb.Open(g.path(kind))
|
||||
if err != nil {
|
||||
if !errors.Is(err, os.ErrNotExist) {
|
||||
slog.Warn("geo database unreadable", "file", g.path(kind), "err", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
return r
|
||||
}
|
||||
c, a := load("country"), load("asn")
|
||||
g.mu.Lock()
|
||||
old := []*maxminddb.Reader{g.country, g.asn}
|
||||
g.country, g.asn = c, a
|
||||
g.mu.Unlock()
|
||||
for _, r := range old {
|
||||
if r != nil {
|
||||
r.Close()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// SetEnabled turns lookups and monthly downloads on or off.
|
||||
func (g *Geo) SetEnabled(on bool) {
|
||||
if g == nil || g.enabled.Swap(on) == on {
|
||||
return
|
||||
}
|
||||
select {
|
||||
case g.kick <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
// Lookup returns where ipPort ("203.0.113.7:51820" or a bare address) is.
|
||||
// Private addresses are reported as the local network.
|
||||
func (g *Geo) Lookup(ipPort string) *GeoInfo {
|
||||
host := ipPort
|
||||
if h, _, err := net.SplitHostPort(ipPort); err == nil {
|
||||
host = h
|
||||
}
|
||||
ip, err := netip.ParseAddr(host)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
ip = ip.Unmap()
|
||||
if ip.IsPrivate() || ip.IsLoopback() || ip.IsLinkLocalUnicast() || ip.IsUnspecified() || isCGNAT(ip) {
|
||||
return &GeoInfo{Network: "Local network"}
|
||||
}
|
||||
if g == nil || !g.enabled.Load() {
|
||||
return nil
|
||||
}
|
||||
g.mu.RLock()
|
||||
defer g.mu.RUnlock()
|
||||
info := GeoInfo{}
|
||||
if g.country != nil {
|
||||
var rec struct {
|
||||
Country struct {
|
||||
ISOCode string `maxminddb:"iso_code"`
|
||||
Names map[string]string `maxminddb:"names"`
|
||||
} `maxminddb:"country"`
|
||||
}
|
||||
if g.country.Lookup(net.IP(ip.AsSlice()), &rec) == nil {
|
||||
info.Country, info.CountryName = rec.Country.ISOCode, rec.Country.Names["en"]
|
||||
}
|
||||
}
|
||||
if g.asn != nil {
|
||||
var rec struct {
|
||||
Number uint `maxminddb:"autonomous_system_number"`
|
||||
Org string `maxminddb:"autonomous_system_organization"`
|
||||
}
|
||||
if g.asn.Lookup(net.IP(ip.AsSlice()), &rec) == nil {
|
||||
info.ASN, info.Network = rec.Number, rec.Org
|
||||
}
|
||||
}
|
||||
if info == (GeoInfo{}) {
|
||||
return nil
|
||||
}
|
||||
return &info
|
||||
}
|
||||
|
||||
var cgnat = netip.MustParsePrefix("100.64.0.0/10")
|
||||
|
||||
func isCGNAT(ip netip.Addr) bool { return cgnat.Contains(ip) }
|
||||
|
||||
// GeoStatus is shown in the settings.
|
||||
type GeoStatus struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
Updated *time.Time `json:"updated"` // date of the country database file
|
||||
}
|
||||
|
||||
func (g *Geo) Status() GeoStatus {
|
||||
st := GeoStatus{Enabled: g.enabled.Load()}
|
||||
if fi, err := os.Stat(g.path("country")); err == nil {
|
||||
t := fi.ModTime()
|
||||
st.Updated = &t
|
||||
}
|
||||
return st
|
||||
}
|
||||
|
||||
// Run downloads missing or outdated databases daily while enabled, and
|
||||
// deletes them when the feature is switched off.
|
||||
func (g *Geo) Run(stop <-chan struct{}) {
|
||||
t := time.NewTicker(geoCheckFreq)
|
||||
defer t.Stop()
|
||||
for {
|
||||
g.maintain()
|
||||
select {
|
||||
case <-stop:
|
||||
return
|
||||
case <-t.C:
|
||||
case <-g.kick:
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (g *Geo) maintain() {
|
||||
if !g.enabled.Load() {
|
||||
g.mu.Lock()
|
||||
for _, r := range []*maxminddb.Reader{g.country, g.asn} {
|
||||
if r != nil {
|
||||
r.Close()
|
||||
}
|
||||
}
|
||||
g.country, g.asn = nil, nil
|
||||
g.mu.Unlock()
|
||||
for _, kind := range []string{"country", "asn"} {
|
||||
_ = os.Remove(g.path(kind))
|
||||
}
|
||||
return
|
||||
}
|
||||
changed := false
|
||||
for _, kind := range []string{"country", "asn"} {
|
||||
if fi, err := os.Stat(g.path(kind)); err == nil && time.Since(fi.ModTime()) < geoMaxAge {
|
||||
continue
|
||||
}
|
||||
if err := g.download(kind); err != nil {
|
||||
slog.Warn("geo database download failed", "db", kind, "err", err)
|
||||
continue
|
||||
}
|
||||
changed = true
|
||||
slog.Info("geo database updated", "db", kind)
|
||||
}
|
||||
if changed {
|
||||
g.open()
|
||||
}
|
||||
}
|
||||
|
||||
// download fetches this month's database, or last month's if this month's
|
||||
// is not published yet, and replaces the local file atomically.
|
||||
func (g *Geo) download(kind string) error {
|
||||
now := time.Now().UTC()
|
||||
var lastErr error
|
||||
for _, month := range []time.Time{now, now.AddDate(0, -1, 0)} {
|
||||
url := fmt.Sprintf(geoBaseURL, kind, month.Format("2006-01"))
|
||||
if lastErr = g.fetch(url, g.path(kind)); lastErr == nil {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return lastErr
|
||||
}
|
||||
|
||||
func (g *Geo) fetch(url, dest string) error {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
|
||||
defer cancel()
|
||||
req, _ := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("%s: HTTP %d", url, resp.StatusCode)
|
||||
}
|
||||
zr, err := gzip.NewReader(resp.Body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tmp, err := os.CreateTemp(filepath.Dir(dest), ".geo-*")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer os.Remove(tmp.Name())
|
||||
if _, err := io.Copy(tmp, io.LimitReader(zr, 512<<20)); err != nil {
|
||||
tmp.Close()
|
||||
return err
|
||||
}
|
||||
if err := tmp.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
// Refuse a file that is not a readable database.
|
||||
r, err := maxminddb.Open(tmp.Name())
|
||||
if err != nil {
|
||||
return fmt.Errorf("downloaded file is not a valid database: %w", err)
|
||||
}
|
||||
r.Close()
|
||||
return os.Rename(tmp.Name(), dest)
|
||||
}
|
||||
|
||||
func (g *Geo) Close() {
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
for _, r := range []*maxminddb.Reader{g.country, g.asn} {
|
||||
if r != nil {
|
||||
r.Close()
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user