From 0f3932383a1bb95b743026475727ee5a031a27d9 Mon Sep 17 00:00:00 2001 From: WSL Aydin Home Date: Sun, 25 Jan 2026 16:00:30 -0700 Subject: [PATCH 1/5] feat(cli): add --geo flag for client location tracking Adds optional geo tracking via tcpdump + geoiplookup. When enabled, stats.json includes country breakdown. Usage: conduit start --geo --stats-file --- cli/README.md | 31 +++++ cli/cmd/start.go | 3 + cli/internal/conduit/service.go | 38 ++++-- cli/internal/config/config.go | 3 + cli/internal/geo/geo.go | 224 ++++++++++++++++++++++++++++++++ 5 files changed, 288 insertions(+), 11 deletions(-) create mode 100644 cli/internal/geo/geo.go diff --git a/cli/README.md b/cli/README.md index 07781d97..e29b1561 100644 --- a/cli/README.md +++ b/cli/README.md @@ -52,8 +52,39 @@ conduit start --psiphon-config ./psiphon_config.json -vv | `--max-clients, -m` | 200 | Maximum concurrent clients (1-1000) | | `--bandwidth, -b` | 5 | Bandwidth limit per peer in Mbps (1-40) | | `--data-dir, -d` | `./data` | Directory for keys and state | +| `--stats-file, -s` | - | Persist stats to JSON file | +| `--geo` | false | Enable client location tracking (requires tcpdump, geoip-bin) | | `-v` | - | Verbose output (use `-vv` for debug) | +## Geo Stats + +Track where your clients are connecting from: + +```bash +# Requires: apt install tcpdump geoip-bin +sudo conduit start --geo --stats-file +``` + +When `--geo` is enabled, the stats.json file includes client locations: + +```json +{ + "connectingClients": 5, + "connectedClients": 12, + "totalBytesUp": 1234567, + "totalBytesDown": 9876543, + "uptimeSeconds": 3600, + "isLive": true, + "geo": [ + {"code": "IR", "country": "Iran", "count": 234}, + {"code": "DE", "country": "Germany", "count": 45} + ], + "timestamp": "2026-01-25T15:44:00Z" +} +``` + +Geo stats update every 60 seconds via tcpdump packet capture. + ## Building ```bash diff --git a/cli/cmd/start.go b/cli/cmd/start.go index ea68a81a..acfbc66f 100644 --- a/cli/cmd/start.go +++ b/cli/cmd/start.go @@ -37,6 +37,7 @@ var ( bandwidthMbps float64 psiphonConfigPath string statsFilePath string + geoEnabled bool ) var startCmd = &cobra.Command{ @@ -63,6 +64,7 @@ func init() { startCmd.Flags().Float64VarP(&bandwidthMbps, "bandwidth", "b", config.DefaultBandwidthMbps, "total bandwidth limit in Mbps (-1 for unlimited)") 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().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() { @@ -103,6 +105,7 @@ func runStart(cmd *cobra.Command, args []string) error { BandwidthMbps: bandwidthMbps, Verbosity: Verbosity(), StatsFile: resolvedStatsFile, + GeoEnabled: geoEnabled, }) if err != nil { return fmt.Errorf("failed to load configuration: %w", err) diff --git a/cli/internal/conduit/service.go b/cli/internal/conduit/service.go index 8e158d1c..015afbcb 100644 --- a/cli/internal/conduit/service.go +++ b/cli/internal/conduit/service.go @@ -30,15 +30,17 @@ import ( "time" "github.com/Psiphon-Inc/conduit/cli/internal/config" + "github.com/Psiphon-Inc/conduit/cli/internal/geo" "github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon" ) // Service represents the Conduit inproxy service type Service struct { - config *config.Config - controller *psiphon.Controller - stats *Stats - mu sync.RWMutex + config *config.Config + controller *psiphon.Controller + stats *Stats + geoCollector *geo.Collector + mu sync.RWMutex } // Stats tracks proxy activity statistics @@ -53,13 +55,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 @@ -74,6 +77,16 @@ 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 { + if s.config.GeoEnabled { + s.geoCollector = geo.NewCollector(60 * time.Second) + 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 psiphon.SetNoticeWriter(psiphon.NewNoticeReceiver( func(notice []byte) { @@ -338,6 +351,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 abfa4f80..870cdde7 100644 --- a/cli/internal/config/config.go +++ b/cli/internal/config/config.go @@ -50,6 +50,7 @@ type Options struct { BandwidthMbps float64 Verbosity int // 0=normal, 1=verbose, 2+=debug StatsFile string // Path to write stats JSON file (empty = disabled) + GeoEnabled bool // Enable geo tracking via tcpdump } // Config represents the validated configuration for the Conduit service @@ -63,6 +64,7 @@ type Config struct { PsiphonConfigData []byte // Embedded config data (if used) Verbosity int // 0=normal, 1=verbose, 2+=debug StatsFile string // Path to write stats JSON file (empty = disabled) + GeoEnabled bool // Enable geo tracking via tcpdump } // persistedKey represents the key data saved to disk @@ -128,6 +130,7 @@ func LoadOrCreate(opts Options) (*Config, error) { PsiphonConfigData: psiphonConfigData, Verbosity: opts.Verbosity, StatsFile: opts.StatsFile, + GeoEnabled: opts.GeoEnabled, }, nil } diff --git a/cli/internal/geo/geo.go b/cli/internal/geo/geo.go new file mode 100644 index 00000000..84fec99b --- /dev/null +++ b/cli/internal/geo/geo.go @@ -0,0 +1,224 @@ +/* + * 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 via tcpdump and geoiplookup +package geo + +import ( + "bufio" + "context" + "fmt" + "os/exec" + "regexp" + "sort" + "strings" + "sync" + "time" +) + +// Result represents a country with its client count +type Result struct { + Code string `json:"code"` + Country string `json:"country"` + Count int `json:"count"` +} + +// Collector continuously collects geo stats in the background +type Collector struct { + mu sync.RWMutex + results []Result + interval time.Duration + iface string + packets int + timeout int +} + +// NewCollector creates a new geo stats collector +func NewCollector(interval time.Duration) *Collector { + return &Collector{ + interval: interval, + iface: "any", + packets: 500, + timeout: 30, + } +} + +// Start begins collecting geo stats in the background +func (c *Collector) Start(ctx context.Context) error { + if err := CheckDependencies(); err != nil { + return err + } + go c.run(ctx) + return nil +} + +func (c *Collector) run(ctx context.Context) { + c.collect() + ticker := time.NewTicker(c.interval) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + c.collect() + } + } +} + +func (c *Collector) collect() { + ips, err := CaptureIPs(c.iface, c.packets, c.timeout) + if err != nil || len(ips) == 0 { + return + } + + results, err := LookupIPs(ips) + if err != nil { + return + } + + c.mu.Lock() + c.results = results + c.mu.Unlock() +} + +// GetResults returns the current geo stats +func (c *Collector) GetResults() []Result { + c.mu.RLock() + defer c.mu.RUnlock() + if c.results == nil { + return []Result{} + } + out := make([]Result, len(c.results)) + for i, r := range c.results { + out[i] = r + } + return out +} + +// CheckDependencies verifies tcpdump and geoiplookup are installed +func CheckDependencies() error { + if _, err := exec.LookPath("tcpdump"); err != nil { + return fmt.Errorf("tcpdump not found (apt install tcpdump)") + } + if _, err := exec.LookPath("geoiplookup"); err != nil { + return fmt.Errorf("geoiplookup not found (apt install geoip-bin)") + } + return nil +} + +func CaptureIPs(iface string, packets, timeout int) ([]string, error) { + cmd := exec.Command("timeout", fmt.Sprintf("%d", timeout), "tcpdump", + "-ni", iface, "-c", fmt.Sprintf("%d", packets), "inbound and (tcp or udp)") + stdout, err := cmd.StdoutPipe() + if err != nil { + return nil, err + } + if err := cmd.Start(); err != nil { + return nil, err + } + + ipSet := make(map[string]struct{}) + ipRegex := regexp.MustCompile(`(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})`) + scanner := bufio.NewScanner(stdout) + for scanner.Scan() { + line := scanner.Text() + if !strings.Contains(line, " In ") { + continue + } + if m := ipRegex.FindStringSubmatch(line); len(m) > 0 && !isPrivateIP(m[1]) { + ipSet[m[1]] = struct{}{} + } + } + cmd.Wait() + + ips := make([]string, 0, len(ipSet)) + for ip := range ipSet { + ips = append(ips, ip) + } + return ips, nil +} + +func LookupIPs(ips []string) ([]Result, error) { + counts := make(map[string]int) + names := make(map[string]string) + re := regexp.MustCompile(`GeoIP Country Edition: ([A-Z]{2}), (.+)`) + + for _, ip := range ips { + out, err := exec.Command("geoiplookup", ip).Output() + if err != nil { + continue + } + if m := re.FindStringSubmatch(string(out)); len(m) == 3 { + counts[m[1]]++ + names[m[1]] = normalizeCountry(m[2]) + } + } + + results := make([]Result, 0, len(counts)) + for code, count := range counts { + results = append(results, Result{Code: code, Country: names[code], Count: count}) + } + sort.Slice(results, func(i, j int) bool { return results[i].Count > results[j].Count }) + return results, nil +} + +func isPrivateIP(ip string) bool { + return strings.HasPrefix(ip, "10.") || + strings.HasPrefix(ip, "172.16.") || strings.HasPrefix(ip, "172.17.") || + strings.HasPrefix(ip, "172.18.") || strings.HasPrefix(ip, "172.19.") || + strings.HasPrefix(ip, "172.20.") || strings.HasPrefix(ip, "172.21.") || + strings.HasPrefix(ip, "172.22.") || strings.HasPrefix(ip, "172.23.") || + strings.HasPrefix(ip, "172.24.") || strings.HasPrefix(ip, "172.25.") || + strings.HasPrefix(ip, "172.26.") || strings.HasPrefix(ip, "172.27.") || + strings.HasPrefix(ip, "172.28.") || strings.HasPrefix(ip, "172.29.") || + strings.HasPrefix(ip, "172.30.") || strings.HasPrefix(ip, "172.31.") || + strings.HasPrefix(ip, "192.168.") || + strings.HasPrefix(ip, "127.") || + ip == "0.0.0.0" +} + +func normalizeCountry(name string) string { + mapping := map[string]string{ + "Iran, Islamic Republic of": "Iran", + "Korea, Republic of": "South Korea", + "Korea, Democratic People's Republic of": "North Korea", + "Russian Federation": "Russia", + "United States": "USA", + "United Kingdom": "UK", + "United Arab Emirates": "UAE", + "Viet Nam": "Vietnam", + "Taiwan, Province of China": "Taiwan", + "Syrian Arab Republic": "Syria", + "Venezuela, Bolivarian Republic of": "Venezuela", + "Tanzania, United Republic of": "Tanzania", + "Congo, The Democratic Republic of the": "DR Congo", + "Moldova, Republic of": "Moldova", + "Palestine, State of": "Palestine", + "Lao People's Democratic Republic": "Laos", + "Micronesia, Federated States of": "Micronesia", + "Macedonia, the Former Yugoslav Republic of": "North Macedonia", + "Bolivia, Plurinational State of": "Bolivia", + "Brunei Darussalam": "Brunei", + } + if short, ok := mapping[name]; ok { + return short + } + return name +} From 709d340bea1451472542e6b105560bb973a1c079 Mon Sep 17 00:00:00 2001 From: WSL Aydin Home Date: Sun, 25 Jan 2026 16:21:20 -0700 Subject: [PATCH 2/5] fix: simplify isPrivateIP check --- cli/internal/geo/geo.go | 21 +++++++++------------ 1 file changed, 9 insertions(+), 12 deletions(-) diff --git a/cli/internal/geo/geo.go b/cli/internal/geo/geo.go index 84fec99b..297fe1b0 100644 --- a/cli/internal/geo/geo.go +++ b/cli/internal/geo/geo.go @@ -180,18 +180,15 @@ func LookupIPs(ips []string) ([]Result, error) { } func isPrivateIP(ip string) bool { - return strings.HasPrefix(ip, "10.") || - strings.HasPrefix(ip, "172.16.") || strings.HasPrefix(ip, "172.17.") || - strings.HasPrefix(ip, "172.18.") || strings.HasPrefix(ip, "172.19.") || - strings.HasPrefix(ip, "172.20.") || strings.HasPrefix(ip, "172.21.") || - strings.HasPrefix(ip, "172.22.") || strings.HasPrefix(ip, "172.23.") || - strings.HasPrefix(ip, "172.24.") || strings.HasPrefix(ip, "172.25.") || - strings.HasPrefix(ip, "172.26.") || strings.HasPrefix(ip, "172.27.") || - strings.HasPrefix(ip, "172.28.") || strings.HasPrefix(ip, "172.29.") || - strings.HasPrefix(ip, "172.30.") || strings.HasPrefix(ip, "172.31.") || - strings.HasPrefix(ip, "192.168.") || - strings.HasPrefix(ip, "127.") || - ip == "0.0.0.0" + if strings.HasPrefix(ip, "10.") || strings.HasPrefix(ip, "192.168.") || strings.HasPrefix(ip, "127.") { + return true + } + if strings.HasPrefix(ip, "172.") { + var b int + fmt.Sscanf(ip, "172.%d.", &b) + return b >= 16 && b <= 31 + } + return false } func normalizeCountry(name string) string { From d158624c96998895b8854ac04d67c7617fbe1787 Mon Sep 17 00:00:00 2001 From: Samim Mirhosseini Date: Thu, 29 Jan 2026 02:24:52 -0500 Subject: [PATCH 3/5] Replace tcpdump-based geo tracking with GeoIP2 database MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces external tcpdump + geoiplookup dependencies with pure Go implementation using MaxMind GeoLite2 database. No sudo required, cross-platform, all 249 countries supported. Connection tracking flow: Connect ──► OnConnectionEstablished ──► ConnectIP(ip) │ ▼ GeoIP2 lookup + live++ │ │ (connection active...) │ Close ───► OnConnectionClosed ──► DisconnectIP(ip, bytesUp, bytesDown) │ ▼ live-- + accumulate bytes Stats per country: - count: currently connected (live) - count_total: unique IPs since start - bytes_up/down: total bandwidth since start TURN relay connections tracked as code=RELAY (country unknown). Example stats.json geo output: { "geo": [ { "code": "IR", "country": "Iran", "count": 3, "count_total": 47, "bytes_up": 524288000, "bytes_down": 2684354560 }, { "code": "RELAY", "country": "Unknown (TURN Relay)", "count": 1, "count_total": 8, "bytes_up": 52428800, "bytes_down": 268435456 } ] } --- cli/Makefile | 5 +- cli/go.mod | 5 +- cli/go.sum | 10 +- cli/internal/conduit/service.go | 28 ++- cli/internal/geo/database.go | 123 ++++++++++++ cli/internal/geo/geo.go | 322 ++++++++++++++++++-------------- 6 files changed, 344 insertions(+), 149 deletions(-) create mode 100644 cli/internal/geo/database.go diff --git a/cli/Makefile b/cli/Makefile index cdd5b336..09f6b8bf 100644 --- a/cli/Makefile +++ b/cli/Makefile @@ -18,8 +18,9 @@ GOPATH := $(shell go env GOPATH) GO := $(shell which go$(GO_INSTALL_VERSION) 2>/dev/null || which go1.24 2>/dev/null || echo "$(GOPATH)/bin/go$(GO_INSTALL_VERSION)") # 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/go.mod b/cli/go.mod index ff276307..57f2318f 100644 --- a/cli/go.mod +++ b/cli/go.mod @@ -3,10 +3,10 @@ module github.com/Psiphon-Inc/conduit/cli go 1.24.0 require ( + github.com/oschwald/geoip2-golang v1.11.0 github.com/spf13/cobra v1.8.1 github.com/tyler-smith/go-bip39 v1.1.0 golang.org/x/crypto v0.39.0 - golang.org/x/term v0.32.0 ) require ( @@ -60,6 +60,7 @@ require ( github.com/miekg/dns v1.1.56 // indirect github.com/mroth/weightedrand v1.0.0 // 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 @@ -119,6 +120,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 f279899b..d8178361 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= @@ -157,10 +155,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 015afbcb..b5304231 100644 --- a/cli/internal/conduit/service.go +++ b/cli/internal/conduit/service.go @@ -32,6 +32,7 @@ import ( "github.com/Psiphon-Inc/conduit/cli/internal/config" "github.com/Psiphon-Inc/conduit/cli/internal/geo" "github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon" + "github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common/inproxy" ) // Service represents the Conduit inproxy service @@ -78,7 +79,8 @@ 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 { if s.config.GeoEnabled { - s.geoCollector = geo.NewCollector(60 * time.Second) + 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 @@ -191,6 +193,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 } 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 index 297fe1b0..83c9dd66 100644 --- a/cli/internal/geo/geo.go +++ b/cli/internal/geo/geo.go @@ -17,205 +17,249 @@ * */ -// Package geo provides client geolocation via tcpdump and geoiplookup +// Package geo provides client geolocation using MaxMind GeoLite2 database package geo import ( - "bufio" "context" "fmt" - "os/exec" - "regexp" + "net" "sort" - "strings" "sync" "time" + + "github.com/oschwald/geoip2-golang" ) -// Result represents a country with its client count +// Result represents a country with connection stats type Result struct { - Code string `json:"code"` - Country string `json:"country"` - Count int `json:"count"` + 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 continuously collects geo stats in the background +// Collector collects geo stats type Collector struct { - mu sync.RWMutex - results []Result - interval time.Duration - iface string - packets int - timeout int + 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(interval time.Duration) *Collector { +func NewCollector(dbPath string) *Collector { return &Collector{ - interval: interval, - iface: "any", - packets: 500, - timeout: 30, + 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 := CheckDependencies(); err != nil { - return err + 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) } - go c.run(ctx) + c.db = db + + go c.autoUpdate(ctx) + return nil } -func (c *Collector) run(ctx context.Context) { - c.collect() - ticker := time.NewTicker(c.interval) - defer ticker.Stop() - for { - select { - case <-ctx.Done(): - return - case <-ticker.C: - c.collect() - } +// 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 } -func (c *Collector) collect() { - ips, err := CaptureIPs(c.iface, c.packets, c.timeout) - if err != nil || len(ips) == 0 { +// 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 } - results, err := LookupIPs(ips) - if err != nil { + c.mu.Lock() + defer c.mu.Unlock() + + if c.db == nil { return } - c.mu.Lock() - c.results = results - c.mu.Unlock() -} - -// GetResults returns the current geo stats -func (c *Collector) GetResults() []Result { - c.mu.RLock() - defer c.mu.RUnlock() - if c.results == nil { - return []Result{} + record, err := c.db.Country(ip) + if err != nil || record.Country.IsoCode == "" { + return } - out := make([]Result, len(c.results)) - for i, r := range c.results { - out[i] = r + + 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 } - return out + + cd.live++ + cd.totalIPs[ipStr] = struct{}{} } -// CheckDependencies verifies tcpdump and geoiplookup are installed -func CheckDependencies() error { - if _, err := exec.LookPath("tcpdump"); err != nil { - return fmt.Errorf("tcpdump not found (apt install tcpdump)") - } - if _, err := exec.LookPath("geoiplookup"); err != nil { - return fmt.Errorf("geoiplookup not found (apt install geoip-bin)") +// 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 } - return nil -} -func CaptureIPs(iface string, packets, timeout int) ([]string, error) { - cmd := exec.Command("timeout", fmt.Sprintf("%d", timeout), "tcpdump", - "-ni", iface, "-c", fmt.Sprintf("%d", packets), "inbound and (tcp or udp)") - stdout, err := cmd.StdoutPipe() - if err != nil { - return nil, err + c.mu.Lock() + defer c.mu.Unlock() + + if c.db == nil { + return } - if err := cmd.Start(); err != nil { - return nil, err + + record, err := c.db.Country(ip) + if err != nil || record.Country.IsoCode == "" { + return } - ipSet := make(map[string]struct{}) - ipRegex := regexp.MustCompile(`(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})`) - scanner := bufio.NewScanner(stdout) - for scanner.Scan() { - line := scanner.Text() - if !strings.Contains(line, " In ") { - continue + 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 } - if m := ipRegex.FindStringSubmatch(line); len(m) > 0 && !isPrivateIP(m[1]) { - ipSet[m[1]] = struct{}{} + cd = &countryData{ + name: name, + totalIPs: make(map[string]struct{}), } + c.countries[code] = cd } - cmd.Wait() - ips := make([]string, 0, len(ipSet)) - for ip := range ipSet { - ips = append(ips, ip) + if cd.live > 0 { + cd.live-- } - return ips, nil + cd.totalIPs[ipStr] = struct{}{} + cd.bytesUp += bytesUp + cd.bytesDown += bytesDown } -func LookupIPs(ips []string) ([]Result, error) { - counts := make(map[string]int) - names := make(map[string]string) - re := regexp.MustCompile(`GeoIP Country Edition: ([A-Z]{2}), (.+)`) +// 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{}{} +} - for _, ip := range ips { - out, err := exec.Command("geoiplookup", ip).Output() - if err != nil { - continue - } - if m := re.FindStringSubmatch(string(out)); len(m) == 3 { - counts[m[1]]++ - names[m[1]] = normalizeCountry(m[2]) - } +// 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 +} - results := make([]Result, 0, len(counts)) - for code, count := range counts { - results = append(results, Result{Code: code, Country: names[code], Count: count}) +// 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() + } } - sort.Slice(results, func(i, j int) bool { return results[i].Count > results[j].Count }) - return results, nil } -func isPrivateIP(ip string) bool { - if strings.HasPrefix(ip, "10.") || strings.HasPrefix(ip, "192.168.") || strings.HasPrefix(ip, "127.") { - return true +// 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, + }) } - if strings.HasPrefix(ip, "172.") { - var b int - fmt.Sscanf(ip, "172.%d.", &b) - return b >= 16 && b <= 31 + + // 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, + }) } - return false + + sort.Slice(results, func(i, j int) bool { + return results[i].Count > results[j].Count + }) + + return results } -func normalizeCountry(name string) string { - mapping := map[string]string{ - "Iran, Islamic Republic of": "Iran", - "Korea, Republic of": "South Korea", - "Korea, Democratic People's Republic of": "North Korea", - "Russian Federation": "Russia", - "United States": "USA", - "United Kingdom": "UK", - "United Arab Emirates": "UAE", - "Viet Nam": "Vietnam", - "Taiwan, Province of China": "Taiwan", - "Syrian Arab Republic": "Syria", - "Venezuela, Bolivarian Republic of": "Venezuela", - "Tanzania, United Republic of": "Tanzania", - "Congo, The Democratic Republic of the": "DR Congo", - "Moldova, Republic of": "Moldova", - "Palestine, State of": "Palestine", - "Lao People's Democratic Republic": "Laos", - "Micronesia, Federated States of": "Micronesia", - "Macedonia, the Former Yugoslav Republic of": "North Macedonia", - "Bolivia, Plurinational State of": "Bolivia", - "Brunei Darussalam": "Brunei", - } - if short, ok := mapping[name]; ok { - return short - } - return name +// isPrivateIP checks if an IP is private/internal +func isPrivateIP(ip net.IP) bool { + return ip.IsLoopback() || ip.IsPrivate() || ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() } From a954bdb5a717692069f944364f550082aeddf709 Mon Sep 17 00:00:00 2001 From: Samim Mirhosseini Date: Thu, 29 Jan 2026 02:48:05 -0500 Subject: [PATCH 4/5] Update README: geo tracking no longer requires tcpdump/sudo --- cli/README.md | 44 +++++++++++++++++++++++++++++++++++++------- 1 file changed, 37 insertions(+), 7 deletions(-) diff --git a/cli/README.md b/cli/README.md index e29b1561..4931ac0c 100644 --- a/cli/README.md +++ b/cli/README.md @@ -53,7 +53,7 @@ conduit start --psiphon-config ./psiphon_config.json -vv | `--bandwidth, -b` | 5 | Bandwidth limit per peer in Mbps (1-40) | | `--data-dir, -d` | `./data` | Directory for keys and state | | `--stats-file, -s` | - | Persist stats to JSON file | -| `--geo` | false | Enable client location tracking (requires tcpdump, geoip-bin) | +| `--geo` | false | Enable client geolocation tracking | | `-v` | - | Verbose output (use `-vv` for debug) | ## Geo Stats @@ -61,11 +61,12 @@ conduit start --psiphon-config ./psiphon_config.json -vv Track where your clients are connecting from: ```bash -# Requires: apt install tcpdump geoip-bin -sudo conduit start --geo --stats-file +conduit start --geo --stats-file stats.json --psiphon-config ./psiphon_config.json ``` -When `--geo` is enabled, the stats.json file includes client locations: +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 { @@ -76,14 +77,43 @@ When `--geo` is enabled, the stats.json file includes client locations: "uptimeSeconds": 3600, "isLive": true, "geo": [ - {"code": "IR", "country": "Iran", "count": 234}, - {"code": "DE", "country": "Germany", "count": 45} + { + "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" } ``` -Geo stats update every 60 seconds via tcpdump packet capture. +| 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 | + +Note: Connections through TURN relay servers appear as `RELAY` since the actual client country cannot be determined. ## Building From 844a73b7568e5f84921af080b675266d6cc707ac Mon Sep 17 00:00:00 2001 From: Samim Mirhosseini Date: Thu, 29 Jan 2026 03:12:44 -0500 Subject: [PATCH 5/5] Document geo stats behavior and remove debug logging --- cli/README.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/cli/README.md b/cli/README.md index 4931ac0c..5155d135 100644 --- a/cli/README.md +++ b/cli/README.md @@ -113,7 +113,10 @@ Example `stats.json`: | `bytes_up` | Total bytes uploaded since start | | `bytes_down` | Total bytes downloaded since start | -Note: Connections through TURN relay servers appear as `RELAY` since the actual client country cannot be determined. +**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