Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions cli/Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
64 changes: 64 additions & 0 deletions cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,8 +52,72 @@ 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 geolocation tracking |
| `-v` | - | Verbose output (use `-vv` for debug) |

## 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
Expand Down
3 changes: 3 additions & 0 deletions cli/cmd/start.go
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ var (
bandwidthMbps float64
psiphonConfigPath string
statsFilePath string
geoEnabled bool
)

var startCmd = &cobra.Command{
Expand All @@ -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() {
Expand Down Expand Up @@ -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)
Expand Down
5 changes: 4 additions & 1 deletion cli/go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
10 changes: 4 additions & 6 deletions cli/go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -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=
Expand Down Expand Up @@ -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=
Expand Down
64 changes: 53 additions & 11 deletions cli/internal/conduit/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,15 +30,18 @@ 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"
"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
mu sync.RWMutex
config *config.Config
controller *psiphon.Controller
stats *Stats
geoCollector *geo.Collector
mu sync.RWMutex
}

// Stats tracks proxy activity statistics
Expand All @@ -53,13 +56,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
Expand All @@ -74,6 +78,17 @@ 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 {
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
psiphon.SetNoticeWriter(psiphon.NewNoticeReceiver(
func(notice []byte) {
Expand Down Expand Up @@ -178,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
}

Expand Down Expand Up @@ -338,6 +377,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)
}
}
Expand Down
3 changes: 3 additions & 0 deletions cli/internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -128,6 +130,7 @@ func LoadOrCreate(opts Options) (*Config, error) {
PsiphonConfigData: psiphonConfigData,
Verbosity: opts.Verbosity,
StatsFile: opts.StatsFile,
GeoEnabled: opts.GeoEnabled,
}, nil
}

Expand Down
Loading