diff --git a/cli/Makefile b/cli/Makefile
index 9eac3296..0080a875 100644
--- a/cli/Makefile
+++ b/cli/Makefile
@@ -15,8 +15,9 @@ GO_REQUIRED_VERSION := 1.24
GO := $(shell which go)
# Psiphon tunnel-core branch with inproxy support
-PSIPHON_BRANCH ?= staging-client
-PSIPHON_REPO := https://github.com/Psiphon-Labs/psiphon-tunnel-core.git
+# Using ssmirr fork with OnConnectionEstablished callback until upstreamed
+PSIPHON_BRANCH ?= feat/inproxy-client-connected-callback
+PSIPHON_REPO := https://github.com/ssmirr/psiphon-tunnel-core.git
# Compute build-info and invoke go build:
# $(1) = output path
diff --git a/cli/README.md b/cli/README.md
index d8e60d68..eed61390 100644
--- a/cli/README.md
+++ b/cli/README.md
@@ -61,6 +61,8 @@ conduit start -v
| `--max-clients, -m` | 50 | Maximum concurrent clients |
| `--bandwidth, -b` | 40 | Bandwidth limit per peer in Mbps |
| `--data-dir, -d` | `./data` | Directory for keys and state |
+| `--stats-file, -s` | - | Persist stats to JSON file |
+| `--geo` | false | Enable client geolocation tracking |
| `--metrics-addr` | - | Prometheus metrics listen address |
| `-v` | - | Verbose output (use `-vv` for debug) |
@@ -71,6 +73,68 @@ Keys and state are stored in the data directory (default: `./data`):
- `conduit_key.json` - Node identity keypair
The Psiphon broker tracks proxy reputation by key. Always use a persistent volume to preserve your key across container restarts, otherwise you'll start with zero reputation and may not receive client connections for some time.
+## Geo Stats
+
+Track where your clients are connecting from:
+
+```bash
+conduit start --geo --stats-file stats.json --psiphon-config ./psiphon_config.json
+```
+
+On first run, the GeoLite2 database (~6MB) is automatically downloaded. Stats are updated in real-time as clients connect and disconnect.
+
+Example `stats.json`:
+
+```json
+{
+ "connectingClients": 5,
+ "connectedClients": 12,
+ "totalBytesUp": 1234567,
+ "totalBytesDown": 9876543,
+ "uptimeSeconds": 3600,
+ "isLive": true,
+ "geo": [
+ {
+ "code": "IR",
+ "country": "Iran",
+ "count": 3,
+ "count_total": 47,
+ "bytes_up": 524288000,
+ "bytes_down": 2684354560
+ },
+ {
+ "code": "CN",
+ "country": "China",
+ "count": 1,
+ "count_total": 23,
+ "bytes_up": 314572800,
+ "bytes_down": 1610612736
+ },
+ {
+ "code": "RELAY",
+ "country": "Unknown (TURN Relay)",
+ "count": 1,
+ "count_total": 8,
+ "bytes_up": 52428800,
+ "bytes_down": 268435456
+ }
+ ],
+ "timestamp": "2026-01-25T15:44:00Z"
+}
+```
+
+| Field | Description |
+|-------|-------------|
+| `count` | Currently connected clients |
+| `count_total` | Total unique clients since start |
+| `bytes_up` | Total bytes uploaded since start |
+| `bytes_down` | Total bytes downloaded since start |
+
+**Notes:**
+- Connections through TURN relay servers appear as `RELAY` since the actual client country cannot be determined.
+- The `connectedClients` field is reported by the Psiphon broker and may differ slightly from the sum of geo `count` values, which are tracked locally via WebRTC callbacks.
+- Bandwidth (`bytes_up`/`bytes_down`) is attributed to a country when the connection closes. Active connections contribute to `totalBytesUp`/`totalBytesDown` but won't appear in geo stats until they disconnect.
+
## Building
```bash
diff --git a/cli/cmd/start.go b/cli/cmd/start.go
index b1fdbcfa..f486a18d 100644
--- a/cli/cmd/start.go
+++ b/cli/cmd/start.go
@@ -38,6 +38,7 @@ var (
psiphonConfigPath string
statsFilePath string
metricsAddr string
+ geoEnabled bool
)
var startCmd = &cobra.Command{
@@ -65,6 +66,7 @@ func init() {
startCmd.Flags().StringVarP(&statsFilePath, "stats-file", "s", "", "persist stats to JSON file (default: stats.json in data dir if flag used without value)")
startCmd.Flags().Lookup("stats-file").NoOptDefVal = "stats.json"
startCmd.Flags().StringVar(&metricsAddr, "metrics-addr", "", "address for Prometheus metrics endpoint (e.g., :9090 or 127.0.0.1:9090)")
+ startCmd.Flags().BoolVar(&geoEnabled, "geo", false, "enable client location tracking (requires tcpdump, geoip-bin)")
// Only show --psiphon-config flag if no config is embedded
if !config.HasEmbeddedConfig() {
@@ -106,6 +108,7 @@ func runStart(cmd *cobra.Command, args []string) error {
Verbosity: Verbosity(),
StatsFile: resolvedStatsFile,
MetricsAddr: metricsAddr,
+ GeoEnabled: geoEnabled,
})
if err != nil {
return fmt.Errorf("failed to load configuration: %w", err)
diff --git a/cli/go.mod b/cli/go.mod
index c237bb4f..9edad562 100644
--- a/cli/go.mod
+++ b/cli/go.mod
@@ -4,6 +4,7 @@ go 1.24.0
require (
filippo.io/edwards25519 v1.1.0
+ github.com/oschwald/geoip2-golang v1.11.0
github.com/prometheus/client_golang v1.23.2
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e
github.com/spf13/cobra v1.8.1
@@ -64,6 +65,7 @@ require (
github.com/mroth/weightedrand v1.0.0 // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/onsi/ginkgo/v2 v2.12.0 // indirect
+ github.com/oschwald/maxminddb-golang v1.13.0 // indirect
github.com/pelletier/go-toml v1.9.5 // indirect
github.com/pion/datachannel v1.5.5 // indirect
github.com/pion/dtls/v2 v2.2.7 // indirect
@@ -127,6 +129,8 @@ require (
// Use staging-client branch for inproxy support
require github.com/Psiphon-Labs/psiphon-tunnel-core v0.0.0-20251128193008-996f485b1e13
+replace github.com/Psiphon-Labs/psiphon-tunnel-core => ./psiphon-tunnel-core
+
// Required for PSIPHON_ENABLE_INPROXY build tag - use Psiphon's forked pion libraries
// These are automatically set up by 'make setup' which clones psiphon-tunnel-core
replace github.com/pion/dtls/v2 => ./psiphon-tunnel-core/replace/dtls
diff --git a/cli/go.sum b/cli/go.sum
index 6e1481a2..d3c8322a 100644
--- a/cli/go.sum
+++ b/cli/go.sum
@@ -26,8 +26,6 @@ github.com/Psiphon-Labs/goptlib v0.0.0-20200406165125-c0e32a7a3464 h1:VmnMMMheFX
github.com/Psiphon-Labs/goptlib v0.0.0-20200406165125-c0e32a7a3464/go.mod h1:Pe5BqN2DdIdChorAXl6bDaQd/wghpCleJfid2NoSli0=
github.com/Psiphon-Labs/psiphon-tls v0.0.0-20250318183125-2a2fae2db378 h1:LqI8cxnYxgUKLLvv+XZKpxZAQcov6xhEKgC82FdvG/k=
github.com/Psiphon-Labs/psiphon-tls v0.0.0-20250318183125-2a2fae2db378/go.mod h1:7ZUnPnWT5z8J8hxfsVjKHYK77Zme/Y0If1b/zeziiJs=
-github.com/Psiphon-Labs/psiphon-tunnel-core v0.0.0-20251128193008-996f485b1e13 h1:Z9O1DbTcguYdrLzocCiuflFqMY9Y6RplHZ+pR+85DGg=
-github.com/Psiphon-Labs/psiphon-tunnel-core v0.0.0-20251128193008-996f485b1e13/go.mod h1:bY2QL80agFrj1Gv7bWLwWSl157BBmBE8lgmkOUMt8Ec=
github.com/Psiphon-Labs/quic-go v0.0.0-20250527153145-79fe45fb83b1 h1:zD7JvZCV8gjvtI0AZmE81Ffc/v7A+qwU1/YfUmN/Flk=
github.com/Psiphon-Labs/quic-go v0.0.0-20250527153145-79fe45fb83b1/go.mod h1:rONdWgPMbFjyyBai7gB1IBF4pT9r4l0GyiDst5XR1SY=
github.com/Psiphon-Labs/utls v0.0.0-20250623193530-396869e9cd87 h1:h/OnQpPMwC7pKN9YQTJ+vQATjchta6kgumJNnkJBq1k=
@@ -163,10 +161,10 @@ github.com/onsi/ginkgo/v2 v2.12.0 h1:UIVDowFPwpg6yMUpPjGkYvf06K3RAiJXUhCxEwQVHRI
github.com/onsi/ginkgo/v2 v2.12.0/go.mod h1:ZNEzXISYlqpb8S36iN71ifqLi3vVD1rVJGvWRCJOUpQ=
github.com/onsi/gomega v1.27.10 h1:naR28SdDFlqrG6kScpT8VWpu1xWY5nJRCF3XaYyBjhI=
github.com/onsi/gomega v1.27.10/go.mod h1:RsS8tutOdbdgzbPtzzATp12yT7kM5I5aElG3evPbQ0M=
-github.com/oschwald/geoip2-golang v1.9.0 h1:uvD3O6fXAXs+usU+UGExshpdP13GAqp4GBrzN7IgKZc=
-github.com/oschwald/geoip2-golang v1.9.0/go.mod h1:BHK6TvDyATVQhKNbQBdrj9eAvuwOMi2zSFXizL3K81Y=
-github.com/oschwald/maxminddb-golang v1.12.0 h1:9FnTOD0YOhP7DGxGsq4glzpGy5+w7pq50AS6wALUMYs=
-github.com/oschwald/maxminddb-golang v1.12.0/go.mod h1:q0Nob5lTCqyQ8WT6FYgS1L7PXKVVbgiymefNwIjPzgY=
+github.com/oschwald/geoip2-golang v1.11.0 h1:hNENhCn1Uyzhf9PTmquXENiWS6AlxAEnBII6r8krA3w=
+github.com/oschwald/geoip2-golang v1.11.0/go.mod h1:P9zG+54KPEFOliZ29i7SeYZ/GM6tfEL+rgSn03hYuUo=
+github.com/oschwald/maxminddb-golang v1.13.0 h1:R8xBorY71s84yO06NgTmQvqvTvlS/bnYZrrWX1MElnU=
+github.com/oschwald/maxminddb-golang v1.13.0/go.mod h1:BU0z8BfFVhi1LQaonTwwGQlsHUEu9pWNdMfmq4ztm0o=
github.com/patrickmn/go-cache v2.1.0+incompatible h1:HRMgzkcYKYpi3C8ajMPV8OFXaaRUnok+kx1WdO15EQc=
github.com/patrickmn/go-cache v2.1.0+incompatible/go.mod h1:3Qf8kWWT7OJRJbdiICTKqZju1ZixQ/KpMGzzAfe6+WQ=
github.com/pebbe/zmq4 v1.2.10 h1:wQkqRZ3CZeABIeidr3e8uQZMMH5YAykA/WN0L5zkd1c=
diff --git a/cli/internal/conduit/service.go b/cli/internal/conduit/service.go
index deef3fc4..b55ef2ce 100644
--- a/cli/internal/conduit/service.go
+++ b/cli/internal/conduit/service.go
@@ -30,17 +30,20 @@ import (
"time"
"github.com/Psiphon-Inc/conduit/cli/internal/config"
+ "github.com/Psiphon-Inc/conduit/cli/internal/geo"
"github.com/Psiphon-Inc/conduit/cli/internal/metrics"
"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon"
+ "github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common/inproxy"
)
// Service represents the Conduit inproxy service
type Service struct {
- config *config.Config
- controller *psiphon.Controller
- stats *Stats
- metrics *metrics.Metrics
- mu sync.RWMutex
+ config *config.Config
+ controller *psiphon.Controller
+ stats *Stats
+ metrics *metrics.Metrics
+ geoCollector *geo.Collector
+ mu sync.RWMutex
}
// Stats tracks proxy activity statistics
@@ -55,13 +58,14 @@ type Stats struct {
// StatsJSON represents the JSON structure for persisted stats
type StatsJSON struct {
- ConnectingClients int `json:"connectingClients"`
- ConnectedClients int `json:"connectedClients"`
- TotalBytesUp int64 `json:"totalBytesUp"`
- TotalBytesDown int64 `json:"totalBytesDown"`
- UptimeSeconds int64 `json:"uptimeSeconds"`
- IsLive bool `json:"isLive"`
- Timestamp string `json:"timestamp"`
+ ConnectingClients int `json:"connectingClients"`
+ ConnectedClients int `json:"connectedClients"`
+ TotalBytesUp int64 `json:"totalBytesUp"`
+ TotalBytesDown int64 `json:"totalBytesDown"`
+ UptimeSeconds int64 `json:"uptimeSeconds"`
+ IsLive bool `json:"isLive"`
+ Geo []geo.Result `json:"geo,omitempty"`
+ Timestamp string `json:"timestamp"`
}
// New creates a new Conduit service
@@ -83,6 +87,7 @@ func New(cfg *config.Config) (*Service, error) {
// Run starts the Conduit inproxy service and blocks until context is cancelled
func (s *Service) Run(ctx context.Context) error {
+ // Start Prometheus metrics server if configured
if s.metrics != nil && s.config.MetricsAddr != "" {
if err := s.metrics.StartServer(s.config.MetricsAddr); err != nil {
return fmt.Errorf("failed to start metrics server: %w", err)
@@ -101,6 +106,18 @@ func (s *Service) Run(ctx context.Context) error {
}()
}
+ // Initialize geo tracking if enabled
+ if s.config.GeoEnabled {
+ dbPath := s.config.DataDir + "/GeoLite2-Country.mmdb"
+ s.geoCollector = geo.NewCollector(dbPath)
+ if err := s.geoCollector.Start(ctx); err != nil {
+ fmt.Printf("[WARN] Geo disabled: %v\n", err)
+ s.geoCollector = nil
+ } else {
+ fmt.Println("[GEO] Tracking enabled")
+ }
+ }
+
// Set up notice handling FIRST - before any psiphon calls
if err := psiphon.SetNoticeWriter(psiphon.NewNoticeReceiver(
func(notice []byte) {
@@ -207,6 +224,30 @@ func (s *Service) createPsiphonConfig() (*psiphon.Config, error) {
return nil, fmt.Errorf("failed to commit config: %w", err)
}
+ // Set up geo tracking callback if enabled
+ if s.geoCollector != nil {
+ psiphonConfig.OnInproxyConnectionEstablished = func(local, remote inproxy.ConnectionStats) {
+ if remote.IP == "" {
+ return
+ }
+ if remote.CandidateType == "relay" {
+ s.geoCollector.ConnectRelay(remote.IP)
+ } else {
+ s.geoCollector.ConnectIP(remote.IP)
+ }
+ }
+ psiphonConfig.OnInproxyConnectionClosed = func(remote *inproxy.ConnectionStats, bw *inproxy.BandwidthStats) {
+ if remote == nil || remote.IP == "" || bw == nil {
+ return
+ }
+ if remote.CandidateType == "relay" {
+ s.geoCollector.DisconnectRelay(remote.IP, bw.BytesUp, bw.BytesDown)
+ } else {
+ s.geoCollector.DisconnectIP(remote.IP, bw.BytesUp, bw.BytesDown)
+ }
+ }
+ }
+
return psiphonConfig, nil
}
@@ -389,6 +430,9 @@ func (s *Service) logStats() {
IsLive: s.stats.IsLive,
Timestamp: time.Now().Format(time.RFC3339),
}
+ if s.geoCollector != nil {
+ statsJSON.Geo = s.geoCollector.GetResults()
+ }
go s.writeStatsToFile(statsJSON)
}
}
diff --git a/cli/internal/config/config.go b/cli/internal/config/config.go
index 4ccc4bbe..542631ac 100644
--- a/cli/internal/config/config.go
+++ b/cli/internal/config/config.go
@@ -51,6 +51,7 @@ type Options struct {
Verbosity int // 0=normal, 1=verbose, 2+=debug
StatsFile string // Path to write stats JSON file (empty = disabled)
MetricsAddr string // Address for Prometheus metrics endpoint (empty = disabled)
+ GeoEnabled bool // Enable geo tracking via tcpdump
}
// Config represents the validated configuration for the Conduit service
@@ -65,6 +66,7 @@ type Config struct {
Verbosity int // 0=normal, 1=verbose, 2+=debug
StatsFile string // Path to write stats JSON file (empty = disabled)
MetricsAddr string // Address for Prometheus metrics endpoint (empty = disabled)
+ GeoEnabled bool // Enable geo tracking via tcpdump
}
// persistedKey represents the key data saved to disk
@@ -131,6 +133,7 @@ func LoadOrCreate(opts Options) (*Config, error) {
Verbosity: opts.Verbosity,
StatsFile: opts.StatsFile,
MetricsAddr: opts.MetricsAddr,
+ GeoEnabled: opts.GeoEnabled,
}, nil
}
diff --git a/cli/internal/geo/database.go b/cli/internal/geo/database.go
new file mode 100644
index 00000000..b8f4bc94
--- /dev/null
+++ b/cli/internal/geo/database.go
@@ -0,0 +1,123 @@
+/*
+ * Copyright (c) 2026, Psiphon Inc.
+ * All rights reserved.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ *
+ */
+
+package geo
+
+import (
+ "fmt"
+ "io"
+ "net/http"
+ "os"
+ "path/filepath"
+ "time"
+)
+
+const (
+ // MaxMind GeoLite2 Free Database (no account required)
+ // This is a direct download link for the GeoLite2-Country database
+ geoLite2URL = "https://github.com/P3TERX/GeoLite.mmdb/raw/download/GeoLite2-Country.mmdb"
+
+ maxDownloadSize = 10 * 1024 * 1024 // 10MB max
+ downloadTimeout = 30 * time.Second
+)
+
+// EnsureDatabase checks if the GeoIP database exists, downloads if missing
+func EnsureDatabase(dbPath string) error {
+ // Check if database already exists
+ if _, err := os.Stat(dbPath); err == nil {
+ return nil
+ }
+
+ // Database doesn't exist, download it
+ fmt.Printf("[GEO] Downloading GeoLite2 database...\n")
+ return downloadDatabase(dbPath)
+}
+
+// UpdateDatabase checks if database needs updating and downloads new version
+func UpdateDatabase(dbPath string) error {
+ // Check file modification time
+ info, err := os.Stat(dbPath)
+ if err != nil {
+ // Database doesn't exist, download it
+ return downloadDatabase(dbPath)
+ }
+
+ // Only update if older than 7 days
+ if time.Since(info.ModTime()) < 7*24*time.Hour {
+ return nil
+ }
+
+ fmt.Printf("[GEO] Updating GeoLite2 database...\n")
+
+ // Download to temporary file first
+ tmpPath := dbPath + ".tmp"
+ if err := downloadDatabase(tmpPath); err != nil {
+ return err
+ }
+
+ // Replace old database with new one
+ if err := os.Rename(tmpPath, dbPath); err != nil {
+ os.Remove(tmpPath)
+ return fmt.Errorf("failed to replace database: %w", err)
+ }
+
+ return nil
+}
+
+// downloadDatabase downloads the GeoLite2 database
+func downloadDatabase(destPath string) error {
+ // Ensure directory exists
+ dir := filepath.Dir(destPath)
+ if err := os.MkdirAll(dir, 0755); err != nil {
+ return fmt.Errorf("failed to create directory: %w", err)
+ }
+
+ // Create HTTP client with timeout
+ client := &http.Client{
+ Timeout: downloadTimeout,
+ }
+
+ // Download the database
+ resp, err := client.Get(geoLite2URL)
+ if err != nil {
+ return fmt.Errorf("failed to download database: %w", err)
+ }
+ defer resp.Body.Close()
+
+ if resp.StatusCode != http.StatusOK {
+ return fmt.Errorf("download failed with status: %d", resp.StatusCode)
+ }
+
+ // Create destination file
+ out, err := os.Create(destPath)
+ if err != nil {
+ return fmt.Errorf("failed to create file: %w", err)
+ }
+ defer out.Close()
+
+ // Copy with size limit
+ written, err := io.Copy(out, io.LimitReader(resp.Body, maxDownloadSize))
+ if err != nil {
+ os.Remove(destPath)
+ return fmt.Errorf("failed to write database: %w", err)
+ }
+
+ fmt.Printf("[GEO] Downloaded %d bytes\n", written)
+ return nil
+}
diff --git a/cli/internal/geo/geo.go b/cli/internal/geo/geo.go
new file mode 100644
index 00000000..83c9dd66
--- /dev/null
+++ b/cli/internal/geo/geo.go
@@ -0,0 +1,265 @@
+/*
+ * Copyright (c) 2026, Psiphon Inc.
+ * All rights reserved.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ *
+ */
+
+// Package geo provides client geolocation using MaxMind GeoLite2 database
+package geo
+
+import (
+ "context"
+ "fmt"
+ "net"
+ "sort"
+ "sync"
+ "time"
+
+ "github.com/oschwald/geoip2-golang"
+)
+
+// Result represents a country with connection stats
+type Result struct {
+ Code string `json:"code"`
+ Country string `json:"country"`
+ Count int `json:"count"` // Currently connected clients
+ CountTotal int `json:"count_total"` // Total unique clients since start
+ BytesUp int64 `json:"bytes_up"` // Total bytes since start
+ BytesDown int64 `json:"bytes_down"` // Total bytes since start
+}
+
+// countryData stores stats per country
+type countryData struct {
+ name string
+ live int // currently open connections
+ totalIPs map[string]struct{} // all unique IPs ever seen
+ bytesUp int64
+ bytesDown int64
+}
+
+// Collector collects geo stats
+type Collector struct {
+ mu sync.RWMutex
+ countries map[string]*countryData // country code -> data
+ relayLive int // currently open relay connections
+ relayAll map[string]struct{} // all unique relay IPs ever seen
+ relayUp int64
+ relayDown int64
+ db *geoip2.Reader
+ dbPath string
+}
+
+// NewCollector creates a new geo stats collector
+func NewCollector(dbPath string) *Collector {
+ return &Collector{
+ dbPath: dbPath,
+ countries: make(map[string]*countryData),
+ relayAll: make(map[string]struct{}),
+ }
+}
+
+// Start begins collecting geo stats in the background
+func (c *Collector) Start(ctx context.Context) error {
+ if err := EnsureDatabase(c.dbPath); err != nil {
+ return fmt.Errorf("failed to ensure database: %w", err)
+ }
+
+ db, err := geoip2.Open(c.dbPath)
+ if err != nil {
+ return fmt.Errorf("failed to open GeoIP database: %w", err)
+ }
+ c.db = db
+
+ go c.autoUpdate(ctx)
+
+ return nil
+}
+
+// Stop closes the database
+func (c *Collector) Stop() error {
+ c.mu.Lock()
+ defer c.mu.Unlock()
+ if c.db != nil {
+ return c.db.Close()
+ }
+ return nil
+}
+
+// ConnectIP records a new connection from an IP (call when connection opens)
+func (c *Collector) ConnectIP(ipStr string) {
+ ip := net.ParseIP(ipStr)
+ if ip == nil || isPrivateIP(ip) {
+ return
+ }
+
+ c.mu.Lock()
+ defer c.mu.Unlock()
+
+ if c.db == nil {
+ return
+ }
+
+ record, err := c.db.Country(ip)
+ if err != nil || record.Country.IsoCode == "" {
+ return
+ }
+
+ code := record.Country.IsoCode
+ cd, exists := c.countries[code]
+ if !exists {
+ name := code
+ if countryName, ok := record.Country.Names["en"]; ok && countryName != "" {
+ name = countryName
+ }
+ cd = &countryData{
+ name: name,
+ totalIPs: make(map[string]struct{}),
+ }
+ c.countries[code] = cd
+ }
+
+ cd.live++
+ cd.totalIPs[ipStr] = struct{}{}
+}
+
+// DisconnectIP records bandwidth and closes connection (call when connection closes)
+func (c *Collector) DisconnectIP(ipStr string, bytesUp, bytesDown int64) {
+ ip := net.ParseIP(ipStr)
+ if ip == nil || isPrivateIP(ip) {
+ return
+ }
+
+ c.mu.Lock()
+ defer c.mu.Unlock()
+
+ if c.db == nil {
+ return
+ }
+
+ record, err := c.db.Country(ip)
+ if err != nil || record.Country.IsoCode == "" {
+ return
+ }
+
+ code := record.Country.IsoCode
+ cd, exists := c.countries[code]
+ if !exists {
+ // Shouldn't happen, but handle gracefully
+ name := code
+ if countryName, ok := record.Country.Names["en"]; ok && countryName != "" {
+ name = countryName
+ }
+ cd = &countryData{
+ name: name,
+ totalIPs: make(map[string]struct{}),
+ }
+ c.countries[code] = cd
+ }
+
+ if cd.live > 0 {
+ cd.live--
+ }
+ cd.totalIPs[ipStr] = struct{}{}
+ cd.bytesUp += bytesUp
+ cd.bytesDown += bytesDown
+}
+
+// ConnectRelay records a new relay connection (call when connection opens)
+func (c *Collector) ConnectRelay(ipStr string) {
+ c.mu.Lock()
+ defer c.mu.Unlock()
+ c.relayLive++
+ c.relayAll[ipStr] = struct{}{}
+}
+
+// DisconnectRelay records bandwidth and closes relay connection (call when connection closes)
+func (c *Collector) DisconnectRelay(ipStr string, bytesUp, bytesDown int64) {
+ c.mu.Lock()
+ defer c.mu.Unlock()
+ if c.relayLive > 0 {
+ c.relayLive--
+ }
+ c.relayAll[ipStr] = struct{}{}
+ c.relayUp += bytesUp
+ c.relayDown += bytesDown
+}
+
+// autoUpdate checks for database updates once per day
+func (c *Collector) autoUpdate(ctx context.Context) {
+ ticker := time.NewTicker(24 * time.Hour)
+ defer ticker.Stop()
+
+ for {
+ select {
+ case <-ctx.Done():
+ return
+ case <-ticker.C:
+ if err := UpdateDatabase(c.dbPath); err != nil {
+ continue
+ }
+ c.mu.Lock()
+ if c.db != nil {
+ c.db.Close()
+ }
+ db, err := geoip2.Open(c.dbPath)
+ if err == nil {
+ c.db = db
+ }
+ c.mu.Unlock()
+ }
+ }
+}
+
+// GetResults returns the current geo stats (includes relay as special entry)
+func (c *Collector) GetResults() []Result {
+ c.mu.RLock()
+ defer c.mu.RUnlock()
+
+ results := make([]Result, 0, len(c.countries)+1)
+ for code, cd := range c.countries {
+ results = append(results, Result{
+ Code: code,
+ Country: cd.name,
+ Count: cd.live,
+ CountTotal: len(cd.totalIPs),
+ BytesUp: cd.bytesUp,
+ BytesDown: cd.bytesDown,
+ })
+ }
+
+ // Add relay stats as special entry if any relay connections occurred
+ if len(c.relayAll) > 0 || c.relayLive > 0 {
+ results = append(results, Result{
+ Code: "RELAY",
+ Country: "Unknown (TURN Relay)",
+ Count: c.relayLive,
+ CountTotal: len(c.relayAll),
+ BytesUp: c.relayUp,
+ BytesDown: c.relayDown,
+ })
+ }
+
+ sort.Slice(results, func(i, j int) bool {
+ return results[i].Count > results[j].Count
+ })
+
+ return results
+}
+
+// isPrivateIP checks if an IP is private/internal
+func isPrivateIP(ip net.IP) bool {
+ return ip.IsLoopback() || ip.IsPrivate() || ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast()
+}