From 37ab26b4159decf708b917cb069f19771beb45be Mon Sep 17 00:00:00 2001 From: Daniel Redetzke Date: Sat, 3 Oct 2026 19:21:10 +0300 Subject: [PATCH] Add connection history per peer with country and network lookup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- README.md | 6 +- api.go | 27 +++ app.css | 1 + app.js | 50 +++++- config.go | 7 +- geo.go | 270 +++++++++++++++++++++++++++++ go.mod | 1 + go.sum | 2 + ios/GHOSTWIRE/Format.swift | 10 ++ ios/GHOSTWIRE/Models.swift | 39 +++++ ios/GHOSTWIRE/PeerDetailView.swift | 73 ++++++++ ios/GHOSTWIRE/SettingsView.swift | 13 +- main.go | 9 +- main_test.go | 72 ++++++++ stats.go | 148 ++++++++++++++-- 15 files changed, 707 insertions(+), 21 deletions(-) create mode 100644 geo.go diff --git a/README.md b/README.md index f3665aa..b221b32 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,7 @@ manages peers (add, change, disable, remove) and records traffic per peer. as `wg syncconf`, so connected peers stay connected. - **Logs:** written to `GHOSTWIRE.jsonl`, rotated at 10 MB with 5 old files kept by default (Settings → Data retention). - **Traffic history:** kept in `stats.json`: hourly for 48 h and daily for 400 days by default (Settings → Data retention). +- **Connection history:** every online session per peer, with start, duration, address and traffic. A new session starts when a device changes networks. Country and network operator come from the free [DB-IP Lite](https://db-ip.com) databases (CC BY 4.0). GHOSTWIRE downloads them monthly (about 20 MB) and looks addresses up locally, so peer addresses never leave the server. You can switch this off under Settings → Data retention. - **Client private keys are never stored.** A config is shown once, as a download or QR code. "Issue new config" makes new keys. @@ -109,7 +110,8 @@ After editing `config.json` by hand, run `sudo systemctl reload ghostwire`. |---|---| | `GHOSTWIRE` | the program | | `config.json` | all settings, server key, peers, token hashes (0600) | -| `stats.json` | traffic history per peer | +| `stats.json` | traffic and connection history per peer | +| `geo-country.mmdb`, `geo-asn.mmdb` | DB-IP Lite databases for country and network lookups | | `GHOSTWIRE.jsonl` | log, one JSON object per line. Changes carry `"audit":true` | | `acme/`, `tls/` | certificates | @@ -128,7 +130,7 @@ GET /server PATCH /server POST /server/rotate-key GET /server GET /peers POST /peers (returns the config and QR once) GET /peers/{id} PATCH /peers/{id} DELETE /peers/{id} POST /peers/{id}/enable | /disable | /issue-config -GET /peers/{id}/stats?range=… +GET /peers/{id}/stats?range=… GET /peers/{id}/sessions?limit=100 GET /settings PATCH /settings POST /restart GET /logs?level=&limit=&audit=1 GET /logs/download admin: GET|POST /tokens · DELETE /tokens/{id} · GET /backup · POST /restore diff --git a/api.go b/api.go index be71dbc..e5a3fad 100644 --- a/api.go +++ b/api.go @@ -26,6 +26,7 @@ type App struct { tls *webTLS logPath string logw *rotatingWriter // nil in tests + geo *Geo // nil in tests started time.Time shutdown func() // graceful stop; systemd restarts the service } @@ -124,6 +125,7 @@ func (a *App) routes() http.Handler { g("POST /api/v1/peers/{id}/disable", a.setEnabled(false)) g("POST /api/v1/peers/{id}/issue-config", a.issueConfig) g("GET /api/v1/peers/{id}/stats", a.peerStats) + g("GET /api/v1/peers/{id}/sessions", a.peerSessions) // Full-access tokens (the iOS app) may change app settings and read logs. // Password, tokens and backups stay with the admin account. @@ -806,6 +808,7 @@ func (a *App) getSettings(w http.ResponseWriter, r *http.Request) { "web": cfg.Web, "log": cfg.Log, "stats": cfg.Stats, + "geo": a.geoStatus(), "adminUsername": cfg.Admin.Username, "fingerprint": a.tls.Fingerprint(), "logPath": a.logPath, @@ -980,4 +983,28 @@ func (a *App) applyRuntime(c *Config) { if a.logw != nil { a.logw.SetLimits(c.Log.MaxSizeMB, c.Log.MaxFiles) } + a.geo.SetEnabled(c.Stats.geoEnabled()) +} + +func (a *App) geoStatus() GeoStatus { + if a.geo == nil { + return GeoStatus{} + } + return a.geo.Status() +} + +// peerSessions returns the connection history, newest first. +func (a *App) peerSessions(w http.ResponseWriter, r *http.Request) { + if _, p := a.store.Get().peerByID(r.PathValue("id")); p == nil { + writeJSON(w, http.StatusNotFound, map[string]string{"error": "no such peer"}) + return + } + limit := 100 + if _, err := fmt.Sscan(r.URL.Query().Get("limit"), &limit); err != nil || limit < 1 || limit > 1000 { + limit = 100 + } + writeJSON(w, http.StatusOK, map[string]any{ + "sessions": a.stats.Sessions(r.PathValue("id"), limit), + "attribution": "IP geolocation by DB-IP (https://db-ip.com), CC BY 4.0", + }) } diff --git a/app.css b/app.css index 7e75d52..6e0cf60 100644 --- a/app.css +++ b/app.css @@ -94,6 +94,7 @@ h1 { margin: 0; font-size: 26px; font-weight: 600; letter-spacing: -0.01em; over .pills { display: flex; flex-wrap: wrap; gap: 6px; } /* status */ +.cc { display: inline-block; margin-left: 6px; padding: 0 5px; border: 1px solid var(--line); border-radius: 4px; font-size: 11px; color: var(--ink-2); vertical-align: 1px; } .badge { display: inline-flex; align-items: center; gap: 6px; font-size: 12px; font-weight: 500; padding: 3px 10px; border-radius: 999px; background: #efefeb; color: #3d3e42; white-space: nowrap; } .dot { width: 8px; height: 8px; border-radius: 50%; background: #9a9b97; display: inline-block; flex: none; } .dot.ok { background: var(--good); } diff --git a/app.js b/app.js index b3db1d8..6157375 100644 --- a/app.js +++ b/app.js @@ -124,6 +124,23 @@ return ts + ' ' + String(l.level).padEnd(5) + ' ' + l.msg + (rest ? ' ' + rest : ''); } + // "Germany · Deutsche Telekom AG", "Local network" or "". + function fmtLocation(g) { + if (!g) return ''; + return [g.countryName || g.country, g.network].filter(Boolean).join(' · '); + } + + function fmtDuration(sec) { + if (sec < 60) return 'under 1 min'; + const m = Math.round(sec / 60); + if (m < 60) return m + ' min'; + const h = Math.floor(m / 60); + if (h < 48) return h + ' h ' + (m % 60) + ' min'; + return Math.round(h / 24) + ' days'; + } + + const fmtStamp = (iso) => new Date(iso).toLocaleString(undefined, { day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit' }); + const badge = (st) => h('span', { class: 'badge' }, h('span', { class: st.dot }), st.label); // ---------- API ---------- @@ -503,7 +520,8 @@ h('td', null, h('a', { href: '#/peers/' + p.id }, h('strong', null, p.name)), p.note ? h('div', { class: 'note' }, p.note) : null), h('td', { class: 'mono' }, p.ipv4), h('td', null, badge(peerState(p))), - h('td', { class: 'mono muted' }, p.stats.endpoint || '–'), + h('td', { class: 'mono muted' }, p.stats.endpoint || '–', + p.stats.location && p.stats.location.country ? h('span', { class: 'cc', title: fmtLocation(p.stats.location) }, p.stats.location.country) : null), h('td', { class: 'num' }, fmtBytes(p.stats.down30d)), h('td', { class: 'num' }, fmtBytes(p.stats.up30d)), h('td', null, h('input', { type: 'checkbox', class: 'sw', checked: p.enabled, 'aria-label': (p.enabled ? 'Disable ' : 'Enable ') + p.name, onChange: (e) => toggle(p, e.target.checked) })), @@ -663,7 +681,8 @@ // ---------- peer detail ---------- async function viewPeer(wrap, id) { - const [p, srv] = await Promise.all([api('GET', '/peers/' + id), api('GET', '/server')]); + const [p, srv, sess] = await Promise.all([api('GET', '/peers/' + id), api('GET', '/server'), api('GET', '/peers/' + id + '/sessions?limit=50')]); + const sessions = sess.sessions; let range = '7d'; const st = peerState(p); const traffic = h('div'); @@ -757,6 +776,7 @@ h('dl', { class: 'kv' }, h('dt', null, 'Tunnel address'), h('dd', { class: 'mono' }, p.ipv4 + '/32', p.ipv6 ? [h('br'), p.ipv6 + '/128'] : null), h('dt', null, 'Endpoint'), h('dd', { class: 'mono' }, p.stats.endpoint || '–'), + h('dt', null, 'Location'), h('dd', null, fmtLocation(p.stats.location) || '–'), h('dt', null, 'Latest handshake'), h('dd', null, ago(p.stats.lastHandshake)), h('dt', null, 'Public key'), h('dd', { class: 'mono' }, p.publicKey), h('dt', null, 'Preshared key'), h('dd', null, p.hasPresharedKey ? 'Set' : 'None'), @@ -769,6 +789,24 @@ h('button', { type: 'button', class: 'btn', onClick: pasteKey }, 'Use a key from the device…')), h('p', { class: 'hint', style: { margin: '12px 0 0' } }, p.configIssued ? 'Last issued ' + fmtDate(p.configIssued) + '.' : 'Created with a key from the device.'))), + h('section', { class: 'card flush', 'aria-labelledby': 'hist' }, + h('div', { class: 'cardhead' }, h('h2', { id: 'hist' }, 'Connection history'), + h('span', { class: 'hint' }, 'Newest first · a new row starts when the device changes networks')), + sessions.length ? h('div', { class: 'tbl' }, h('table', null, + h('thead', null, h('tr', null, h('th', null, 'Started'), h('th', null, 'Duration'), h('th', null, 'From'), h('th', null, 'Address'), + h('th', { class: 'num' }, 'Download'), h('th', { class: 'num' }, 'Upload'))), + h('tbody', null, sessions.map((se) => h('tr', null, + h('td', null, fmtStamp(se.start)), + h('td', null, se.open ? [h('span', { class: 'badge' }, h('span', { class: 'dot ok' }), 'Online now'), ' ', fmtDuration(se.seconds)] : fmtDuration(se.seconds)), + h('td', null, fmtLocation(se.geo) || h('span', { class: 'muted' }, 'Unknown')), + h('td', { class: 'mono muted' }, se.ip), + h('td', { class: 'num' }, fmtBytes(se.down)), + h('td', { class: 'num' }, fmtBytes(se.up))))))) + : h('p', { class: 'empty' }, 'No connections recorded yet.'), + h('p', { class: 'hint', style: { margin: '4px 12px 12px' } }, 'Country and network: ', + h('a', { href: 'https://db-ip.com', target: '_blank', rel: 'noopener' }, 'IP Geolocation by DB-IP'), + '. Kept as long as the daily traffic history.')), + h('form', { class: 'card', onSubmit: save }, h('h2', null, 'Settings'), h('p', { class: 'lead' }, 'Name and address changes apply immediately. DNS, AllowedIPs and keepalive are part of the client config: they take effect after the config is issued again.'), @@ -1051,6 +1089,8 @@ const logFiles = h('input', { id: 'rf', type: 'number', min: '1', max: '100', value: s.log.maxFiles, inputMode: 'numeric' }); const hourly = presetSelect('rh', s.stats.hourlyHours, [[24, '1 day'], [48, '2 days'], [168, '7 days'], [336, '14 days'], [744, '31 days']], 'hours'); const daily = presetSelect('rd', s.stats.dailyDays, [[30, '30 days'], [90, '90 days'], [180, '6 months'], [400, '13 months'], [730, '2 years'], [1825, '5 years'], [3660, '10 years']], 'days'); + const geo = h('input', { type: 'checkbox', checked: s.stats.geoip !== false }); + const geoStatus = s.geo && s.geo.updated ? 'Database from ' + fmtDate(s.geo.updated) + '.' : 'Not downloaded yet.'; const diskHint = h('span', { class: 'hint' }); const drawDiskHint = () => { const mb = Number(logSize.value) * (Number(logFiles.value) + 1); @@ -1069,7 +1109,7 @@ try { await api('PATCH', '/settings', { log: { ...s.log, maxSizeMB: next.maxSizeMB, maxFiles: next.maxFiles }, - stats: { hourlyHours: next.hourlyHours, dailyDays: next.dailyDays }, + stats: { hourlyHours: next.hourlyHours, dailyDays: next.dailyDays, geoip: geo.checked }, }); toast('Retention saved'); render(); @@ -1144,7 +1184,9 @@ h('div', { class: 'field' }, h('label', { htmlFor: 'rs' }, 'Log file size (MB)'), logSize, h('span', { class: 'hint' }, 'The log starts a new file at this size. 1–1000')), h('div', { class: 'field' }, h('label', { htmlFor: 'rf' }, 'Old log files kept'), logFiles, diskHint), h('div', { class: 'field' }, h('label', { htmlFor: 'rh' }, 'Hourly traffic history'), hourly, h('span', { class: 'hint' }, 'Used by the 24-hour charts')), - h('div', { class: 'field' }, h('label', { htmlFor: 'rd' }, 'Daily traffic history'), daily, h('span', { class: 'hint' }, 'Used by the 7- and 30-day charts. All-time totals are always kept'))), + h('div', { class: 'field' }, h('label', { htmlFor: 'rd' }, 'Daily traffic history'), daily, h('span', { class: 'hint' }, 'Used by the 7- and 30-day charts and the connection history. All-time totals are always kept'))), + h('label', { class: 'check section' }, geo, h('span', null, 'Show country and network of peer addresses', h('br'), + h('span', { class: 'hint' }, 'Downloads the free DB-IP Lite databases (about 20 MB) once a month and looks addresses up on this server only. ' + geoStatus))), retErr, h('div', { class: 'formfoot' }, h('button', { type: 'submit', class: 'btn primary' }, 'Save retention'))), diff --git a/config.go b/config.go index 06201f2..1d8a44a 100644 --- a/config.go +++ b/config.go @@ -32,9 +32,14 @@ type Config struct { // StatsConfig sets how long traffic history is kept in stats.json. type StatsConfig struct { HourlyHours int `json:"hourlyHours"` // hourly buckets, for the 24 h charts - DailyDays int `json:"dailyDays"` // daily buckets, for the 7/30/90 day charts + DailyDays int `json:"dailyDays"` // daily buckets and connection history + // GeoIP looks up country and network of peer addresses in the DB-IP Lite + // databases, downloaded monthly. Default on. + GeoIP *bool `json:"geoip,omitempty"` } +func (c StatsConfig) geoEnabled() bool { return c.GeoIP == nil || *c.GeoIP } + // Limits for the retention settings. const ( minLogSizeMB, maxLogSizeMB = 1, 1000 diff --git a/geo.go b/geo.go new file mode 100644 index 0000000..af24cc7 --- /dev/null +++ b/geo.go @@ -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() + } + } +} diff --git a/go.mod b/go.mod index 1d371b8..fd1d0d0 100644 --- a/go.mod +++ b/go.mod @@ -17,6 +17,7 @@ require ( github.com/mdlayher/genetlink v1.3.2 // indirect github.com/mdlayher/netlink v1.7.3-0.20250113171957-fbb4dce95f42 // indirect github.com/mdlayher/socket v0.5.1 // indirect + github.com/oschwald/maxminddb-golang v1.13.1 // indirect github.com/vishvananda/netns v0.0.5 // indirect golang.org/x/net v0.58.0 // indirect golang.org/x/sync v0.23.0 // indirect diff --git a/go.sum b/go.sum index c33844b..87cd4f6 100644 --- a/go.sum +++ b/go.sum @@ -10,6 +10,8 @@ github.com/mdlayher/socket v0.5.1 h1:VZaqt6RkGkt2OE9l3GcC6nZkqD3xKeQLyfleW/uBcos github.com/mdlayher/socket v0.5.1/go.mod h1:TjPLHI1UgwEv5J1B5q0zTZq12A/6H7nKmtTanQE37IQ= github.com/mikioh/ipaddr v0.0.0-20190404000644-d465c8ab6721 h1:RlZweED6sbSArvlE924+mUcZuXKLBHA35U7LN621Bws= github.com/mikioh/ipaddr v0.0.0-20190404000644-d465c8ab6721/go.mod h1:Ickgr2WtCLZ2MDGd4Gr0geeCH5HybhRJbonOgQpvSxc= +github.com/oschwald/maxminddb-golang v1.13.1 h1:G3wwjdN9JmIK2o/ermkHM+98oX5fS+k5MbwsmL4MRQE= +github.com/oschwald/maxminddb-golang v1.13.1/go.mod h1:K4pgV9N/GcK694KSTmVSDTODk4IsCNThNdTmnaBZ/F8= github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e h1:MRM5ITcdelLK2j1vwZ3Je0FKVCfqOLp5zO6trqMLYs0= github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e/go.mod h1:XV66xRDqSt+GTGFMVlhk3ULuV0y9ZmzeVGR4mloJI3M= github.com/vishvananda/netlink v1.3.1 h1:3AEMt62VKqz90r0tmNhog0r/PpWKmrEShJU0wJW6bV0= diff --git a/ios/GHOSTWIRE/Format.swift b/ios/GHOSTWIRE/Format.swift index 7102cfc..7078532 100644 --- a/ios/GHOSTWIRE/Format.swift +++ b/ios/GHOSTWIRE/Format.swift @@ -33,6 +33,16 @@ func fmtDate(_ date: Date?) -> String { return date.formatted(date: .abbreviated, time: .omitted) } +/// "35 min", "2 h 5 min", "3 days". +func fmtDuration(_ seconds: Int64) -> String { + if seconds < 60 { return "under 1 min" } + let m = Int((Double(seconds) / 60).rounded()) + if m < 60 { return "\(m) min" } + let h = m / 60 + if h < 48 { return "\(h) h \(m % 60) min" } + return "\(Int((Double(h) / 24).rounded())) days" +} + /// Label of a chart point: "3 h ago" for hours, "Sat 3 Oct" for days. func pointLabel(_ p: StatPoint, range: String) -> String { if range == "24h" { diff --git a/ios/GHOSTWIRE/Models.swift b/ios/GHOSTWIRE/Models.swift index 0d75916..ea00692 100644 --- a/ios/GHOSTWIRE/Models.swift +++ b/ios/GHOSTWIRE/Models.swift @@ -61,6 +61,38 @@ nonisolated struct PeerStats: Decodable, Hashable { let lastHandshake: Date? let endpoint: String let down24h, up24h, down30d, up30d, downTotal, upTotal: Int64 + let location: GeoInfo? +} + +/// Country and network of an address, from the server's DB-IP lookup. +nonisolated struct GeoInfo: Decodable, Hashable { + let country: String? + let countryName: String? + let asn: Int? + let network: String? + + /// "Germany · Deutsche Telekom AG" or "Local network". + var label: String { + [countryName ?? country, network].compactMap { $0 }.filter { !$0.isEmpty }.joined(separator: " · ") + } +} + +/// One row of a peer's connection history. +nonisolated struct ConnSession: Decodable, Identifiable, Hashable { + let start: Date + let end: Date + let open: Bool + let seconds: Int64 + let endpoint: String + let ip: String + let geo: GeoInfo? + let down: Int64 + let up: Int64 + var id: String { "\(start.timeIntervalSince1970)-\(ip)" } +} + +nonisolated struct SessionsResponse: Decodable { + let sessions: [ConnSession] } nonisolated struct Peer: Decodable, Identifiable, Hashable { @@ -166,6 +198,12 @@ nonisolated struct LogSettings: Codable, Equatable { nonisolated struct StatsSettings: Codable, Equatable { var hourlyHours: Int var dailyDays: Int + var geoip: Bool? +} + +nonisolated struct GeoStatus: Decodable { + let enabled: Bool + let updated: Date? } nonisolated struct AppSettings: Decodable { @@ -175,6 +213,7 @@ nonisolated struct AppSettings: Decodable { var adminUsername: String var fingerprint: String var logPath: String + var geo: GeoStatus? } nonisolated struct SettingsResult: Decodable { diff --git a/ios/GHOSTWIRE/PeerDetailView.swift b/ios/GHOSTWIRE/PeerDetailView.swift index eece95d..0f527f2 100644 --- a/ios/GHOSTWIRE/PeerDetailView.swift +++ b/ios/GHOSTWIRE/PeerDetailView.swift @@ -15,8 +15,11 @@ struct PeerDetailView: View { @State private var askKey = false @State private var deviceKey = "" @State private var editing = false + @State private var sessions: [ConnSession] = [] + @State private var allSessions = false var body: some View { + ScrollViewReader { proxy in ScrollView { VStack(spacing: 16) { if let error { Notice(text: error, isError: true) } @@ -24,6 +27,7 @@ struct PeerDetailView: View { header(p) traffic connection(p) + history.id("history") clientConfig(p) settings(p) } else if error == nil { @@ -33,6 +37,13 @@ struct PeerDetailView: View { .padding(16) } .background(Color.gwGround) + #if DEBUG + // Development: `-scrollToHistory YES` for screenshots of the history. + .task(id: sessions.count) { + if UserDefaults.standard.bool(forKey: "scrollToHistory"), !sessions.isEmpty { proxy.scrollTo("history", anchor: .top) } + } + #endif + } .navigationTitle(peer?.name ?? "Peer") .navigationBarTitleDisplayMode(.inline) .toolbar { @@ -107,6 +118,7 @@ struct PeerDetailView: View { SectionTitle(text: "Connection") KV(key: "Tunnel address", value: p.ipv4 + "/32" + (p.ipv6.map { "\n" + $0 + "/128" } ?? ""), mono: true) KV(key: "Endpoint", value: p.stats.endpoint.isEmpty ? "–" : p.stats.endpoint, mono: true) + KV(key: "Location", value: p.stats.location?.label.isEmpty == false ? p.stats.location!.label : "–") KV(key: "Latest handshake", value: ago(p.stats.lastHandshake)) KV(key: "Public key", value: p.publicKey, mono: true) KV(key: "Preshared key", value: p.hasPresharedKey ? "Set" : "None") @@ -115,6 +127,66 @@ struct PeerDetailView: View { .card() } + private var history: some View { + VStack(alignment: .leading, spacing: 0) { + SectionTitle(text: "Connection history") + Text("Newest first. A new row starts when the device changes networks.") + .font(.caption) + .foregroundStyle(Color.gwText2) + .padding(.bottom, 8) + if sessions.isEmpty { + Text("No connections recorded yet.").font(.footnote).foregroundStyle(Color.gwText2).padding(.vertical, 6) + } + let shown = allSessions ? sessions : Array(sessions.prefix(8)) + ForEach(shown) { se in + HStack(alignment: .top, spacing: 12) { + VStack(alignment: .leading, spacing: 4) { + HStack(spacing: 6) { + Text(se.start.formatted(.dateTime.day().month(.abbreviated).hour().minute())) + .font(.subheadline.weight(.medium)) + if se.open { + HStack(spacing: 4) { + Circle().fill(Color.gwGood).frame(width: 6, height: 6) + Text("online") + } + .font(.caption.weight(.medium)) + .padding(.horizontal, 6).padding(.vertical, 2) + .background(Color.gwBadge, in: Capsule()) + } + } + Text(se.geo?.label.isEmpty == false ? se.geo!.label : "Unknown location") + .font(.footnote) + .foregroundStyle(se.geo == nil ? Color.gwText2 : Color.gwText) + Text(se.ip + " · " + fmtDuration(se.seconds)) + .font(.mono(.caption)) + .foregroundStyle(Color.gwText2) + } + Spacer(minLength: 8) + VStack(alignment: .trailing, spacing: 4) { + Text("↓ " + fmtBytes(se.down)).font(.footnote.monospacedDigit()) + Text("↑ " + fmtBytes(se.up)).font(.footnote.monospacedDigit()).foregroundStyle(Color.gwText2) + } + } + .padding(.vertical, 8) + .accessibilityElement(children: .combine) + if se.id != shown.last?.id { Divider() } + } + if sessions.count > 8 { + Button(allSessions ? "Show fewer" : "Show all \(sessions.count)") { allSessions.toggle() } + .font(.footnote.weight(.medium)) + .padding(.top, 8) + } + HStack(spacing: 4) { + Text("Country and network:") + Link("IP Geolocation by DB-IP", destination: URL(string: "https://db-ip.com")!).underline() + } + .font(.caption2) + .foregroundStyle(Color.gwText2) + .padding(.top, 10) + } + .card() + } + private func clientConfig(_ p: Peer) -> some View { VStack(alignment: .leading, spacing: 12) { SectionTitle(text: "Client configuration") @@ -154,6 +226,7 @@ struct PeerDetailView: View { (peer, server) = try await (p, s) error = nil await loadStats() + if let r: SessionsResponse = try? await api.get("/peers/\(peerID)/sessions?limit=100") { sessions = r.sessions } } catch { self.error = session.message(for: error) } diff --git a/ios/GHOSTWIRE/SettingsView.swift b/ios/GHOSTWIRE/SettingsView.swift index 73d8c51..b90a2ec 100644 --- a/ios/GHOSTWIRE/SettingsView.swift +++ b/ios/GHOSTWIRE/SettingsView.swift @@ -139,6 +139,12 @@ struct SettingsView: View { Picker("Daily traffic history", selection: s.dailyDays) { ForEach(options(Self.daily, current: stats!.dailyDays, unit: "days"), id: \.0) { Text($0.1).tag($0.0) } } + Toggle(isOn: Binding(get: { stats!.geoip ?? true }, set: { stats!.geoip = $0 })) { + VStack(alignment: .leading, spacing: 2) { + Text("Show country and network") + Text(geoStatusText).font(.caption).foregroundStyle(Color.gwText2) + } + } Button("Save retention") { guard let old = settings, let log, let stats else { return } if log.maxFiles < old.log.maxFiles || stats.hourlyHours < old.stats.hourlyHours || stats.dailyDays < old.stats.dailyDays { @@ -151,7 +157,7 @@ struct SettingsView: View { } header: { Text("Data retention") } footer: { - Text("The log uses up to \(log!.maxSizeMB * (log!.maxFiles + 1)) MB on disk. All-time traffic totals are always kept. Applies immediately.") + Text("The log uses up to \(log!.maxSizeMB * (log!.maxFiles + 1)) MB on disk. Connection history is kept as long as the daily traffic history; all-time totals are always kept. Country and network come from the free DB-IP Lite databases, downloaded monthly and looked up on the server only. Applies immediately.") } } @@ -171,6 +177,11 @@ struct SettingsView: View { } } + private var geoStatusText: String { + guard let updated = settings?.geo?.updated else { return "Database not downloaded yet" } + return "Database from \(fmtDate(updated))" + } + private func options(_ presets: [(Int, String)], current: Int, unit: String) -> [(Int, String)] { presets.contains { $0.0 == current } ? presets : (presets + [(current, "\(current) \(unit)")]).sorted { $0.0 < $1.0 } } diff --git a/main.go b/main.go index f5935b9..8e97462 100644 --- a/main.go +++ b/main.go @@ -187,6 +187,10 @@ func run(configPath string) error { return fmt.Errorf("open stats: %w", err) } + geo := newGeo(dataDir, cfg.Stats.geoEnabled()) + defer geo.Close() + stats.geo = geo + webTLS, err := setupTLS(cfg, dataDir) if err != nil { slog.Error("tls setup failed", "err", err) @@ -200,13 +204,14 @@ func run(configPath string) error { auth := newAuth(store) app := &App{ store: store, kernel: kernel, recon: recon, stats: stats, auth: auth, tls: webTLS, - logPath: logPath, logw: logw, started: time.Now(), shutdown: shutdown, + logPath: logPath, logw: logw, geo: geo, started: time.Now(), shutdown: shutdown, } var wg sync.WaitGroup - wg.Add(2) + wg.Add(3) go func() { defer wg.Done(); recon.Run(stop) }() go func() { defer wg.Done(); stats.Run(stop) }() + go func() { defer wg.Done(); geo.Run(stop) }() go func() { t := time.NewTicker(10 * time.Minute) defer t.Stop() diff --git a/main_test.go b/main_test.go index 2982454..3f45d7d 100644 --- a/main_test.go +++ b/main_test.go @@ -430,3 +430,75 @@ func TestRetentionValidation(t *testing.T) { } } } + +func TestSessions(t *testing.T) { + s := &Stats{data: statsFile{Peers: map[string]*peerStats{}}} + ps := &peerStats{} + t0 := time.Date(2026, 10, 3, 12, 0, 0, 0, time.UTC) + at := func(min int) time.Time { return t0.Add(time.Duration(min) * time.Minute) } + smp := func(min int, ep string) PeerSample { return PeerSample{Endpoint: ep, LastHandshake: at(min)} } + + s.track(ps, smp(0, "192.168.1.20:5000"), 10, 100, at(0)) // online from home + s.track(ps, smp(2, "192.168.1.20:5001"), 10, 100, at(2)) // same network, new port + if len(ps.Sessions) != 1 || ps.Sessions[0].Rx != 20 || ps.Sessions[0].Endpoint != "192.168.1.20:5001" { + t.Fatalf("same network should continue the session: %+v", ps.Sessions) + } + if g := ps.Sessions[0].Geo; g == nil || g.Network != "Local network" { + t.Fatalf("private address should be the local network: %+v", g) + } + s.track(ps, smp(4, "198.51.100.7:6000"), 5, 50, at(4)) // roamed to mobile + if len(ps.Sessions) != 2 || ps.Sessions[0].Open || !ps.Sessions[1].Open { + t.Fatalf("roaming should start a new session: %+v", ps.Sessions) + } + s.track(ps, smp(4, "198.51.100.7:6000"), 0, 0, at(10)) // no handshake for 6 min + if ps.openSession() != nil { + t.Fatal("session should end when the peer goes quiet") + } + if ps.Sessions[1].End != at(4) { + t.Fatalf("end should be the last time seen online, got %v", ps.Sessions[1].End) + } + + // A peer missing from the kernel (disabled) gets its session closed, and + // sessions older than the daily retention are pruned. + ps.Sessions = append(ps.Sessions, connSession{Start: at(20), End: at(20), Open: true, Endpoint: "198.51.100.7:6000"}) + s.data.Peers["p"] = ps + s.prune(StatsConfig{HourlyHours: 24, DailyDays: 7}, t0.AddDate(0, 0, 30)) + if len(ps.Sessions) != 1 || !ps.Sessions[0].Open { + t.Fatalf("prune should keep only the open session: %+v", ps.Sessions) + } + if v := s.Sessions("p", 10); len(v) != 1 || v[0].IP != "198.51.100.7" { + t.Fatalf("sessions view: %+v", v) + } +} + +func TestGeoLookupWithoutDatabase(t *testing.T) { + var g *Geo // no databases: private addresses still resolve + if info := g.Lookup("10.1.2.3:51820"); info == nil || info.Network != "Local network" { + t.Fatalf("private: %+v", info) + } + if info := g.Lookup("[2001:db8::1]:51820"); info != nil { + t.Fatalf("public without database should be unknown: %+v", info) + } + g2 := newGeo(t.TempDir(), false) + if info := g2.Lookup("203.0.113.9:1"); info != nil { + t.Fatalf("disabled: %+v", info) + } +} + +// TestGeoDatabase runs only with GHOSTWIRE_GEO_DIR pointing at a folder with +// downloaded geo-country.mmdb and geo-asn.mmdb. +func TestGeoDatabase(t *testing.T) { + dir := os.Getenv("GHOSTWIRE_GEO_DIR") + if dir == "" { + t.Skip("GHOSTWIRE_GEO_DIR not set") + } + g := newGeo(dir, true) + defer g.Close() + for _, ep := range []string{"9.9.9.9:53", "1.1.1.1:53", "[2620:fe::fe]:53"} { + info := g.Lookup(ep) + if info == nil || info.Country == "" || info.Network == "" { + t.Errorf("%s: %+v", ep, info) + } + t.Logf("%s → %+v", ep, info) + } +} diff --git a/stats.go b/stats.go index 3b3407b..bde01d0 100644 --- a/stats.go +++ b/stats.go @@ -4,6 +4,7 @@ import ( "encoding/json" "errors" "log/slog" + "net" "os" "sync" "time" @@ -27,14 +28,77 @@ type bucket struct { } type peerStats struct { - LastRx int64 `json:"lastRx"` // last raw counter values - LastTx int64 `json:"lastTx"` - TotalRx int64 `json:"totalRx"` - TotalTx int64 `json:"totalTx"` - LastHandshake time.Time `json:"lastHandshake"` - Endpoint string `json:"endpoint"` - Hourly []bucket `json:"hourly"` - Daily []bucket `json:"daily"` + LastRx int64 `json:"lastRx"` // last raw counter values + LastTx int64 `json:"lastTx"` + TotalRx int64 `json:"totalRx"` + TotalTx int64 `json:"totalTx"` + LastHandshake time.Time `json:"lastHandshake"` + Endpoint string `json:"endpoint"` + Hourly []bucket `json:"hourly"` + Daily []bucket `json:"daily"` + Sessions []connSession `json:"sessions,omitempty"` +} + +// session is one stretch of a peer being online from one address. When the +// device changes networks (Wi-Fi to mobile), a new session starts. +type connSession struct { + Start time.Time `json:"start"` + End time.Time `json:"end"` // last time the peer was seen online + Open bool `json:"open,omitempty"` + Endpoint string `json:"endpoint"` + Rx int64 `json:"rx"` + Tx int64 `json:"tx"` + Geo *GeoInfo `json:"geo,omitempty"` // looked up when the session started +} + +const maxSessions = 1000 // per peer, besides the retention window + +func (ps *peerStats) openSession() *connSession { + if n := len(ps.Sessions); n > 0 && ps.Sessions[n-1].Open { + return &ps.Sessions[n-1] + } + return nil +} + +func hostOf(endpoint string) string { + if h, _, err := net.SplitHostPort(endpoint); err == nil { + return h + } + return endpoint +} + +// track updates the session list after a sample. Online means a handshake +// within the online window, as in the peer lists. +func (s *Stats) track(ps *peerStats, smp PeerSample, rx, tx int64, now time.Time) { + cur := ps.openSession() + online := !smp.LastHandshake.IsZero() && now.Sub(smp.LastHandshake) < onlineWindow + if !online { + if cur != nil { + cur.Open = false + } + return + } + if cur != nil && smp.Endpoint != "" && hostOf(cur.Endpoint) != hostOf(smp.Endpoint) { + cur.Open = false // roamed to another network + cur = nil + } + if cur == nil { + start := smp.LastHandshake + if start.After(now) { + start = now + } + ps.Sessions = append(ps.Sessions, connSession{Start: start, Open: true, Endpoint: smp.Endpoint, Geo: s.geo.Lookup(smp.Endpoint)}) + if len(ps.Sessions) > maxSessions { + ps.Sessions = append([]connSession(nil), ps.Sessions[len(ps.Sessions)-maxSessions:]...) + } + cur = &ps.Sessions[len(ps.Sessions)-1] + } + if smp.Endpoint != "" { + cur.Endpoint = smp.Endpoint // same network, the port may change + } + cur.End = now + cur.Rx += rx + cur.Tx += tx } type statsFile struct { @@ -49,6 +113,7 @@ type Stats struct { dirty bool store *Store kernel Kernel + geo *Geo // nil: no country and network lookups } func openStats(path string, store *Store, k Kernel) (*Stats, error) { @@ -106,10 +171,18 @@ func (s *Stats) prune(c StatsConfig, now time.Time) { y, m, d := now.Date() dayCut := time.Date(y, m, d-(c.DailyDays-1), 0, 0, 0, 0, now.Location()).Unix() for _, ps := range s.data.Peers { - h, dl := len(ps.Hourly), len(ps.Daily) + h, dl, sl := len(ps.Hourly), len(ps.Daily), len(ps.Sessions) ps.Hourly = dropBefore(ps.Hourly, hourCut) ps.Daily = dropBefore(ps.Daily, dayCut) - if len(ps.Hourly) != h || len(ps.Daily) != dl { + // Connection history is kept as long as the daily traffic history. + i := 0 + for i < len(ps.Sessions) && !ps.Sessions[i].Open && ps.Sessions[i].End.Unix() < dayCut { + i++ + } + if i > 0 { + ps.Sessions = append([]connSession(nil), ps.Sessions[i:]...) + } + if len(ps.Hourly) != h || len(ps.Daily) != dl || len(ps.Sessions) != sl { s.dirty = true } } @@ -131,6 +204,7 @@ func (s *Stats) sample() { now := time.Now() s.mu.Lock() defer s.mu.Unlock() + seen := map[string]bool{} for _, smp := range samples { id := idByKey[smp.PublicKey] if id == "" { @@ -141,6 +215,7 @@ func (s *Stats) sample() { ps = &peerStats{} s.data.Peers[id] = ps } + seen[id] = true dRx, dTx := smp.RxBytes-ps.LastRx, smp.TxBytes-ps.LastTx if dRx < 0 || dTx < 0 { // counters were reset dRx, dTx = smp.RxBytes, smp.TxBytes @@ -158,12 +233,19 @@ func (s *Stats) sample() { if smp.Endpoint != "" { ps.Endpoint = smp.Endpoint } + s.track(ps, smp, dRx, dTx, now) s.dirty = true } - for id := range s.data.Peers { + for id, ps := range s.data.Peers { if !exists[id] { delete(s.data.Peers, id) s.dirty = true + continue + } + // Disabled peers are not in the kernel any more: end their session. + if cur := ps.openSession(); cur != nil && !seen[id] { + cur.Open = false + s.dirty = true } } s.prune(cfg.Stats, now) @@ -274,6 +356,7 @@ type PeerSummary struct { Up30d int64 `json:"up30d"` DownTotal int64 `json:"downTotal"` UpTotal int64 `json:"upTotal"` + Location *GeoInfo `json:"location"` // of the current or last endpoint } func sumPoints(pts []Point) (down, up int64) { @@ -298,6 +381,49 @@ func (s *Stats) Summary(id string) PeerSummary { out.LastHandshake = &t out.Online = time.Since(t) < onlineWindow } + if cur := ps.openSession(); cur != nil && cur.Geo != nil && hostOf(cur.Endpoint) == hostOf(ps.Endpoint) { + out.Location = cur.Geo + } + } + if out.Location == nil && out.Endpoint != "" { + out.Location = s.geo.Lookup(out.Endpoint) + } + return out +} + +// SessionView is one row of a peer's connection history, from the peer's +// point of view (down = downloaded by the peer). +type SessionView struct { + Start time.Time `json:"start"` + End time.Time `json:"end"` + Open bool `json:"open"` + Seconds int64 `json:"seconds"` + Endpoint string `json:"endpoint"` + IP string `json:"ip"` + Geo *GeoInfo `json:"geo"` + Down int64 `json:"down"` + Up int64 `json:"up"` +} + +// Sessions returns up to limit sessions, newest first. +func (s *Stats) Sessions(id string, limit int) []SessionView { + s.mu.Lock() + var list []connSession + if ps := s.data.Peers[id]; ps != nil { + list = append(list, ps.Sessions...) + } + s.mu.Unlock() + out := []SessionView{} + for i := len(list) - 1; i >= 0 && len(out) < limit; i-- { + se := list[i] + geo := se.Geo + if geo == nil { + geo = s.geo.Lookup(se.Endpoint) // database was missing when it started + } + out = append(out, SessionView{ + Start: se.Start, End: se.End, Open: se.Open, Seconds: int64(se.End.Sub(se.Start).Seconds()), + Endpoint: se.Endpoint, IP: hostOf(se.Endpoint), Geo: geo, Down: se.Tx, Up: se.Rx, + }) } return out }