Skip to content
Closed
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 @@ -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
Comment on lines +18 to +20
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please note this PR requires a few (mostly minimal changes) in the psiphon-tunnel-core repo. Here you can see the changes: https://github.com/Psiphon-Labs/psiphon-tunnel-core/compare/staging-client...ssmirr:psiphon-tunnel-core:feat/inproxy-client-connected-callback?expand=1

If you are interested in merging this geo PR. we will need those changes to be also merged. I don't seem to have access for opening a pull request on that repository, so will need help.


# 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 @@ -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) |

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

var startCmd = &cobra.Command{
Expand Down Expand Up @@ -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() {
Expand Down Expand Up @@ -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)
Expand Down
4 changes: 4 additions & 0 deletions cli/go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
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 @@ -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=
Expand Down
68 changes: 56 additions & 12 deletions cli/internal/conduit/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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)
Expand All @@ -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) {
Expand Down Expand Up @@ -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
}

Expand Down Expand Up @@ -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)
}
}
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 @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -131,6 +133,7 @@ func LoadOrCreate(opts Options) (*Config, error) {
Verbosity: opts.Verbosity,
StatsFile: opts.StatsFile,
MetricsAddr: opts.MetricsAddr,
GeoEnabled: opts.GeoEnabled,
}, nil
}

Expand Down
Loading