Files
Daniel Redetzke 37ab26b415 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
2026-10-03 19:21:10 +03:00

271 lines
6.4 KiB
Go

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()
}
}
}