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