From 546902ccdb506698e6483d1cdd402444696daf34 Mon Sep 17 00:00:00 2001 From: tmgrask Date: Wed, 18 Feb 2026 08:23:49 -0500 Subject: [PATCH 1/6] consume enhanced InproxyProxyActivity notices in CLI; rework configuration setup for new parameters --- cli/GUIDE.md | 2 +- cli/Makefile | 2 +- cli/README.md | 4 +- cli/cmd/new_compartment_id.go | 55 ++++ cli/cmd/start.go | 77 ++++-- cli/docker-compose.limited-bandwidth.yml | 2 +- cli/docker-compose.yml | 2 +- cli/go.mod | 4 +- cli/go.sum | 4 +- cli/internal/conduit/service.go | 315 +++++++++++++++++----- cli/internal/conduit/service_test.go | 95 +++++++ cli/internal/config/compartment.go | 217 +++++++++++++++ cli/internal/config/compartment_test.go | 102 +++++++ cli/internal/config/config.go | 145 ++++++++-- cli/internal/config/config_test.go | 78 +++++- cli/internal/config/set_overrides.go | 224 +++++++++++++++ cli/internal/config/set_overrides_test.go | 40 +++ cli/internal/metrics/metrics.go | 84 +++++- cli/internal/metrics/registry_test.go | 8 +- cli/scripts/monitor/main.go | 4 +- 20 files changed, 1313 insertions(+), 151 deletions(-) create mode 100644 cli/cmd/new_compartment_id.go create mode 100644 cli/internal/conduit/service_test.go create mode 100644 cli/internal/config/compartment.go create mode 100644 cli/internal/config/compartment_test.go create mode 100644 cli/internal/config/set_overrides.go create mode 100644 cli/internal/config/set_overrides_test.go diff --git a/cli/GUIDE.md b/cli/GUIDE.md index 7ff2719c..4f6b65f3 100644 --- a/cli/GUIDE.md +++ b/cli/GUIDE.md @@ -11,7 +11,7 @@ Our backend connection brokers have a reputation system that is used to make sur ## Guidance for Conduit station infrastructure: - Spread your Conduits out across data centers/locations, IP diversity is very important. -- Don't overload your Conduit. Watch resource utilization, and reduce the --max-clients if you are seeing any signs of resource contention. +- Don't overload your Conduit. Watch resource utilization, and reduce the --max-common-clients if you are seeing any signs of resource contention. - Keep your station online consistently so that it has a strong reputation with the tunnel connection broker. We have observed that Conduit can support around 150-350 concurrent users for every 1 CPU and 2GB RAM. Factors like the CPU clock speed and network speed will determine where in this range your Conduit will perform best. diff --git a/cli/Makefile b/cli/Makefile index 01d77d06..54168f23 100644 --- a/cli/Makefile +++ b/cli/Makefile @@ -81,7 +81,7 @@ setup: check-go git clone --depth 1 --branch $(PSIPHON_BRANCH) $(PSIPHON_REPO) psiphon-tunnel-core; \ else \ echo "psiphon-tunnel-core already exists, updating..."; \ - cd psiphon-tunnel-core && git fetch origin $(PSIPHON_BRANCH) && git checkout $(PSIPHON_BRANCH) && git pull; \ + cd psiphon-tunnel-core && git fetch origin $(PSIPHON_BRANCH) && git checkout -B $(PSIPHON_BRANCH) FETCH_HEAD; \ fi $(GO) mod tidy diff --git a/cli/README.md b/cli/README.md index 596c5ac4..1f842029 100644 --- a/cli/README.md +++ b/cli/README.md @@ -49,7 +49,7 @@ The Makefile will automatically install Go 1.24.12 if not present. conduit start # Customize limits -conduit start --max-clients 20 --bandwidth 10 +conduit start --max-common-clients 20 --bandwidth 10 # Verbose output (info messages) conduit start -v @@ -60,7 +60,7 @@ conduit start -v | Flag | Default | Description | | ---------------------- | -------- | ------------------------------------------ | | `--psiphon-config, -c` | - | Path to Psiphon network configuration file | -| `--max-clients, -m` | 50 | Maximum concurrent clients | +| `--max-common-clients, -m` | 50 | Maximum concurrent common clients | | `--bandwidth, -b` | 40 | Bandwidth limit per peer in Mbps | | `--data-dir, -d` | `./data` | Directory for keys and state | | `--metrics-addr` | - | Prometheus metrics listen address | diff --git a/cli/cmd/new_compartment_id.go b/cli/cmd/new_compartment_id.go new file mode 100644 index 00000000..6928efc2 --- /dev/null +++ b/cli/cmd/new_compartment_id.go @@ -0,0 +1,55 @@ +package cmd + +import ( + "fmt" + "os" + + "github.com/Psiphon-Inc/conduit/cli/internal/config" + "github.com/spf13/cobra" +) + +var ( + compartmentNameDefault string + compartmentName string +) + +var newCompartmentIDCmd = &cobra.Command{ + Use: "new-compartment-id", + Short: "Create and persist a personal compartment ID", + Long: "Generate a personal compartment ID, save it to the data directory, and output a share token.", + RunE: runNewCompartmentID, +} + +func init() { + compartmentNameDefault = "conduit" + if host, err := os.Hostname(); err == nil && host != "" { + compartmentNameDefault = host + } + + rootCmd.AddCommand(newCompartmentIDCmd) + newCompartmentIDCmd.Flags().StringVarP(&compartmentName, "name", "n", compartmentNameDefault, "display name in share token") +} + +func runNewCompartmentID(cmd *cobra.Command, args []string) error { + dataDir := GetDataDir() + + compartmentID, err := config.GeneratePersonalCompartmentID() + if err != nil { + return err + } + + if err := config.SavePersonalCompartmentID(dataDir, compartmentID); err != nil { + return err + } + + shareToken, err := config.BuildPersonalPairingToken(compartmentID, compartmentName) + if err != nil { + return err + } + + fmt.Printf("Saved compartment ID to %s\n", config.PersonalCompartmentFilePath(dataDir)) + fmt.Println("Share token:") + fmt.Println(shareToken) + + return nil +} diff --git a/cli/cmd/start.go b/cli/cmd/start.go index 8d3a6dc9..fa3c1603 100644 --- a/cli/cmd/start.go +++ b/cli/cmd/start.go @@ -34,11 +34,14 @@ import ( ) var ( - maxClients int - bandwidthMbps float64 - psiphonConfigPath string - statsFilePath string - metricsAddr string + maxCommonClients int + maxPersonalClients int + compartmentID string + setOverrides []string + bandwidthMbps float64 + psiphonConfigPath string + statsFilePath string + metricsAddr string ) var startCmd = &cobra.Command{ @@ -61,7 +64,10 @@ PropagationChannelId, SponsorId, and broker specifications.` func init() { rootCmd.AddCommand(startCmd) - startCmd.Flags().IntVarP(&maxClients, "max-clients", "m", config.DefaultMaxClients, "maximum number of proxy clients (1-1000)") + startCmd.Flags().IntVarP(&maxCommonClients, "max-common-clients", "m", config.DefaultMaxClients, "maximum number of common proxy clients (0-1000)") + startCmd.Flags().IntVar(&maxPersonalClients, "max-personal-clients", 0, "maximum number of personal proxy clients (0-1000)") + startCmd.Flags().StringVar(&compartmentID, "compartment-id", "", "personal compartment ID or share token") + startCmd.Flags().StringArrayVar(&setOverrides, "set", nil, "override allowed config values (key=value), repeatable") 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" @@ -93,12 +99,37 @@ func runStart(cmd *cobra.Command, args []string) error { resolvedStatsFile = filepath.Join(GetDataDir(), resolvedStatsFile) } - maxClientsFromFlag := 0 - if cmd.Flags().Changed("max-clients") { - if maxClients < 1 { - return fmt.Errorf("max-clients must be between 1 and %d", config.MaxClientsLimit) + maxCommonClientsFromFlag := 0 + maxCommonClientsFromFlagSet := cmd.Flags().Changed("max-common-clients") + if maxCommonClientsFromFlagSet { + if maxCommonClients < 0 || maxCommonClients > config.MaxClientsLimit { + return fmt.Errorf("max-common-clients must be between 0 and %d", config.MaxClientsLimit) } - maxClientsFromFlag = maxClients + maxCommonClientsFromFlag = maxCommonClients + } + + maxPersonalClientsFromFlag := 0 + maxPersonalClientsFromFlagSet := cmd.Flags().Changed("max-personal-clients") + if maxPersonalClientsFromFlagSet { + if maxPersonalClients < 0 || maxPersonalClients > config.MaxClientsLimit { + return fmt.Errorf("max-personal-clients must be between 0 and %d", config.MaxClientsLimit) + } + maxPersonalClientsFromFlag = maxPersonalClients + } + + parsedSetOverrides, err := config.ParseSetOverrides(setOverrides) + if err != nil { + return err + } + + compartmentIDFromFlag := "" + compartmentIDFromFlagSet := cmd.Flags().Changed("compartment-id") + if compartmentIDFromFlagSet { + normalizedCompartmentID, err := config.NormalizePersonalCompartmentInput(compartmentID) + if err != nil { + return err + } + compartmentIDFromFlag = normalizedCompartmentID } bandwidthFromFlag := 0.0 @@ -113,15 +144,21 @@ func runStart(cmd *cobra.Command, args []string) error { // Load or create configuration (auto-generates keys on first run) cfg, err := config.LoadOrCreate(config.Options{ - DataDir: GetDataDir(), - PsiphonConfigPath: effectiveConfigPath, - UseEmbeddedConfig: useEmbedded, - MaxClients: maxClientsFromFlag, - BandwidthMbps: bandwidthFromFlag, - BandwidthSet: bandwidthFromFlagSet, - Verbosity: Verbosity(), - StatsFile: resolvedStatsFile, - MetricsAddr: metricsAddr, + DataDir: GetDataDir(), + PsiphonConfigPath: effectiveConfigPath, + UseEmbeddedConfig: useEmbedded, + MaxCommonClients: maxCommonClientsFromFlag, + MaxCommonClientsSet: maxCommonClientsFromFlagSet, + MaxPersonalClients: maxPersonalClientsFromFlag, + MaxPersonalClientsSet: maxPersonalClientsFromFlagSet, + CompartmentID: compartmentIDFromFlag, + CompartmentIDSet: compartmentIDFromFlagSet, + SetOverrides: parsedSetOverrides, + BandwidthMbps: bandwidthFromFlag, + BandwidthSet: bandwidthFromFlagSet, + Verbosity: Verbosity(), + StatsFile: resolvedStatsFile, + MetricsAddr: metricsAddr, }) if err != nil { return fmt.Errorf("failed to load configuration: %w", err) diff --git a/cli/docker-compose.limited-bandwidth.yml b/cli/docker-compose.limited-bandwidth.yml index 3337bace..46e2fa68 100644 --- a/cli/docker-compose.limited-bandwidth.yml +++ b/cli/docker-compose.limited-bandwidth.yml @@ -22,7 +22,7 @@ services: "127.0.0.1:9090", "--", # Note: "start" is added automatically by conduit-monitor - "--max-clients", + "--max-common-clients", "50", "--bandwidth", "50", diff --git a/cli/docker-compose.yml b/cli/docker-compose.yml index 343e36e2..026e278a 100644 --- a/cli/docker-compose.yml +++ b/cli/docker-compose.yml @@ -6,7 +6,7 @@ services: command: [ "start", - "--max-clients", + "--max-common-clients", "50", "--bandwidth", "50", diff --git a/cli/go.mod b/cli/go.mod index 32864ebc..f30d2bf3 100644 --- a/cli/go.mod +++ b/cli/go.mod @@ -127,8 +127,8 @@ require ( tailscale.com v1.58.2 // indirect ) -// Use staging-client branch for inproxy support -require github.com/Psiphon-Labs/psiphon-tunnel-core v0.0.0-20260202154140-a3384a551c62 +// Pin to psiphon-tunnel-core staging-client commit 85311186 +require github.com/Psiphon-Labs/psiphon-tunnel-core v1.0.11-0.20260218215855-85311186d6cc // 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 diff --git a/cli/go.sum b/cli/go.sum index 5931e09d..7e0bcfaa 100644 --- a/cli/go.sum +++ b/cli/go.sum @@ -26,8 +26,8 @@ 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-20260202154140-a3384a551c62 h1:xgWT75gdexO5guQJA8TYmduu3kq5pgyQcvZ8pKcQKHE= -github.com/Psiphon-Labs/psiphon-tunnel-core v0.0.0-20260202154140-a3384a551c62/go.mod h1:W9fXVIfNWvboMfrUywnrFsiS9vgHShmf7g//4s2RqsQ= +github.com/Psiphon-Labs/psiphon-tunnel-core v1.0.11-0.20260218215855-85311186d6cc h1:0ipFxMJkpTu75hFi7nh20S1gFBUyftDoNOVNjpQ+X1M= +github.com/Psiphon-Labs/psiphon-tunnel-core v1.0.11-0.20260218215855-85311186d6cc/go.mod h1:ErpCMHBHHdIzeDq08fJ8nAwj8Gwc5ym9nS/oesKQz20= 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-20260129182755-24497d415a8d h1:PlKwrArEuQOVqEmThSs9KsXMiBduP8MSu9rlWmQ4jgE= diff --git a/cli/internal/conduit/service.go b/cli/internal/conduit/service.go index 1d202fe5..b9b59038 100644 --- a/cli/internal/conduit/service.go +++ b/cli/internal/conduit/service.go @@ -25,6 +25,8 @@ import ( "encoding/json" "fmt" "os" + "sort" + "strings" "sync" "sync/atomic" "time" @@ -40,12 +42,9 @@ type Service struct { config *config.Config controller *psiphon.Controller stats *Stats + regionActivityTotals map[string]map[string]RegionActivityTotals metrics *metrics.Metrics mu sync.RWMutex - lastActivityLogTime time.Time - lastLoggedAnnouncing int - lastLoggedConnecting int - lastLoggedConnected int startTimeUnixNano int64 lastActiveUnixNano atomic.Int64 @@ -53,6 +52,21 @@ type Service struct { connectedClients atomic.Int64 } +const ( + regionScopePersonal = "personal" + regionScopeCommon = "common" + maxLoggedRegionsPerScope = 3 +) + +// RegionActivityTotals tracks accumulated per-region activity from +// InproxyProxyActivity per-notice deltas. +type RegionActivityTotals struct { + BytesUp int64 + BytesDown int64 + ConnectingClients int64 + ConnectedClients int64 +} + // Stats tracks proxy activity statistics type Stats struct { Announcing int @@ -85,6 +99,7 @@ func New(cfg *config.Config) (*Service, error) { stats: &Stats{ StartTime: time.Now(), }, + regionActivityTotals: make(map[string]map[string]RegionActivityTotals), } s.startTimeUnixNano = s.stats.StartTime.UnixNano() @@ -93,7 +108,7 @@ func New(cfg *config.Config) (*Service, error) { GetUptimeSeconds: s.getUptimeSeconds, GetIdleSeconds: s.getIdleSecondsFloat, }) - s.metrics.SetConfig(cfg.MaxClients, cfg.BandwidthBytesPerSecond) + s.metrics.SetConfig(cfg.MaxCommonClients, cfg.MaxPersonalClients, cfg.BandwidthBytesPerSecond) } return s, nil @@ -138,7 +153,7 @@ func (s *Service) Run(ctx context.Context) error { if s.config.BandwidthBytesPerSecond > 0 { bandwidthStr = fmt.Sprintf("%.0f Mbps", float64(s.config.BandwidthBytesPerSecond)*8/1000/1000) } - logging.Printf("[OK] Starting Psiphon Conduit (Max Clients: %d, Bandwidth: %s)\n", s.config.MaxClients, bandwidthStr) + logging.Printf("[OK] Starting Psiphon Conduit (Max Common Clients: %d, Max Personal Clients: %d, Bandwidth: %s)\n", s.config.MaxCommonClients, s.config.MaxPersonalClients, bandwidthStr) // Open the data store err = psiphon.OpenDataStore(&psiphon.Config{ @@ -190,10 +205,23 @@ func (s *Service) createPsiphonConfig() (*psiphon.Config, error) { // Client version - used by broker for compatibility configJSON["ClientVersion"] = "1" - // Inproxy mode settings - these override any values in the base config + // Apply --set overrides first. These are the remaining keys from --set + // that were NOT consumed during config resolution (e.g. reduced-hour + // settings, diagnostic flags). Keys that the config layer resolved + // (InproxyMaxCommonClients, InproxyMaxPersonalClients, etc.) have + // already been stripped and are written explicitly below, so any --set + // values for those keys were already folded into the resolved config. + for key, value := range s.config.SetOverrides { + configJSON[key] = value + } + + // Core inproxy mode settings configJSON["InproxyEnableProxy"] = true - configJSON["InproxyMaxClients"] = s.config.MaxClients - // Only set bandwidth limits if not unlimited (0 means unlimited) + configJSON["InproxyMaxCommonClients"] = s.config.MaxCommonClients + configJSON["InproxyMaxPersonalClients"] = s.config.MaxPersonalClients + if s.config.CompartmentID != "" { + configJSON["InproxyProxyPersonalCompartmentID"] = s.config.CompartmentID + } if s.config.BandwidthBytesPerSecond > 0 { configJSON["InproxyLimitUpstreamBytesPerSecond"] = s.config.BandwidthBytesPerSecond configJSON["InproxyLimitDownstreamBytesPerSecond"] = s.config.BandwidthBytesPerSecond @@ -244,6 +272,19 @@ func (s *Service) updateMetrics() { s.metrics.SetConnectedClients(s.stats.ConnectedClients) s.metrics.SetBytesUploaded(float64(s.stats.TotalBytesUp)) s.metrics.SetBytesDownloaded(float64(s.stats.TotalBytesDown)) + + for scope, byRegion := range s.regionActivityTotals { + for region, totals := range byRegion { + s.metrics.SetRegionActivity( + scope, + region, + totals.BytesUp, + totals.BytesDown, + totals.ConnectingClients, + totals.ConnectedClients, + ) + } + } } // getUptimeSeconds returns the uptime in seconds (thread-safe, for Prometheus scrape) @@ -297,23 +338,30 @@ func (s *Service) handleNotice(notice []byte) { switch noticeData.NoticeType { case "InproxyProxyActivity": s.mu.Lock() + prevAnnouncing := s.stats.Announcing prevConnecting := s.stats.ConnectingClients prevConnected := s.stats.ConnectedClients now := time.Now() - if v, ok := noticeData.Data["announcing"].(float64); ok { + if v, ok := int64FromValue(noticeData.Data["announcing"]); ok { s.stats.Announcing = int(v) } - if v, ok := noticeData.Data["connectingClients"].(float64); ok { + if v, ok := int64FromValue(noticeData.Data["connectingClients"]); ok { s.stats.ConnectingClients = int(v) } - if v, ok := noticeData.Data["connectedClients"].(float64); ok { + if v, ok := int64FromValue(noticeData.Data["connectedClients"]); ok { s.stats.ConnectedClients = int(v) } - if v, ok := noticeData.Data["bytesUp"].(float64); ok { - s.stats.TotalBytesUp += int64(v) + if v, ok := int64FromValue(noticeData.Data["bytesUp"]); ok { + s.stats.TotalBytesUp += v + } + if v, ok := int64FromValue(noticeData.Data["bytesDown"]); ok { + s.stats.TotalBytesDown += v } - if v, ok := noticeData.Data["bytesDown"].(float64); ok { - s.stats.TotalBytesDown += int64(v) + if values, ok := parseRegionActivity(noticeData.Data["personalRegionActivity"]); ok { + s.accumulateRegionActivityLocked(regionScopePersonal, values) + } + if values, ok := parseRegionActivity(noticeData.Data["commonRegionActivity"]); ok { + s.accumulateRegionActivityLocked(regionScopeCommon, values) } // Track last active time for idle calculation @@ -322,55 +370,43 @@ func (s *Service) handleNotice(notice []byte) { s.lastActiveUnixNano.Store(now.UnixNano()) } - becameLive := false if !s.stats.IsLive && (s.stats.Announcing > 0 || s.stats.ConnectingClients > 0 || s.stats.ConnectedClients > 0) { s.stats.IsLive = true if s.metrics != nil { s.metrics.SetIsLive(true) } - becameLive = true } - // Log if client counts changed - if s.stats.ConnectingClients != prevConnecting || s.stats.ConnectedClients != prevConnected { + // Log when announcing/connectivity state changes. + if s.stats.Announcing != prevAnnouncing || s.stats.ConnectingClients != prevConnecting || s.stats.ConnectedClients != prevConnected { s.logStats() } - shouldLog, announcingCount, connectingCount, connectedCount := s.shouldLogInproxyActivity(now) s.syncSnapshotLocked() s.updateMetrics() s.mu.Unlock() - if becameLive { - logging.Println("[OK] Announcing presence to Psiphon broker, you will see announcing=1 while bootstrapping is underway") - } - if shouldLog { - logging.Printf("[INFO] Inproxy activity: announcing=%d connecting=%d connected=%d\n", - announcingCount, - connectingCount, - connectedCount, - ) - } case "InproxyProxyTotalActivity": // Update stats from total activity notices s.mu.Lock() + prevAnnouncing := s.stats.Announcing prevConnecting := s.stats.ConnectingClients prevConnected := s.stats.ConnectedClients - if v, ok := noticeData.Data["announcing"].(float64); ok { + if v, ok := int64FromValue(noticeData.Data["announcing"]); ok { s.stats.Announcing = int(v) } - if v, ok := noticeData.Data["connectingClients"].(float64); ok { + if v, ok := int64FromValue(noticeData.Data["connectingClients"]); ok { s.stats.ConnectingClients = int(v) } - if v, ok := noticeData.Data["connectedClients"].(float64); ok { + if v, ok := int64FromValue(noticeData.Data["connectedClients"]); ok { s.stats.ConnectedClients = int(v) } - if v, ok := noticeData.Data["totalBytesUp"].(float64); ok { - s.stats.TotalBytesUp = int64(v) + if v, ok := int64FromValue(noticeData.Data["totalBytesUp"]); ok { + s.stats.TotalBytesUp = v } - if v, ok := noticeData.Data["totalBytesDown"].(float64); ok { - s.stats.TotalBytesDown = int64(v) + if v, ok := int64FromValue(noticeData.Data["totalBytesDown"]); ok { + s.stats.TotalBytesDown = v } // Track last active time for idle calculation @@ -380,17 +416,15 @@ func (s *Service) handleNotice(notice []byte) { s.lastActiveUnixNano.Store(now.UnixNano()) } - becameLive := false if !s.stats.IsLive && (s.stats.Announcing > 0 || s.stats.ConnectingClients > 0 || s.stats.ConnectedClients > 0) { s.stats.IsLive = true if s.metrics != nil { s.metrics.SetIsLive(true) } - becameLive = true } - // Log if client counts changed - if s.stats.ConnectingClients != prevConnecting || s.stats.ConnectedClients != prevConnected { + // Log when announcing/connectivity state changes. + if s.stats.Announcing != prevAnnouncing || s.stats.ConnectingClients != prevConnecting || s.stats.ConnectedClients != prevConnected { s.logStats() } @@ -398,9 +432,6 @@ func (s *Service) handleNotice(notice []byte) { s.updateMetrics() s.mu.Unlock() - if becameLive { - logging.Println("[OK] Announcing to Psiphon broker") - } case "Info": if msg, ok := noticeData.Data["message"].(string); ok { @@ -433,13 +464,16 @@ func (s *Service) handleNotice(notice []byte) { // logStats logs the current proxy statistics (must be called with lock held) func (s *Service) logStats() { uptime := time.Since(s.stats.StartTime).Truncate(time.Second) - fmt.Printf("%s [STATS] Connecting: %d | Connected: %d | Up: %s | Down: %s | Uptime: %s\n", + regionSummary := s.formatRegionActivityTotalsLocked() + fmt.Printf("%s [STATS] Announcing: %d | Connecting: %d | Connected: %d | Up: %s | Down: %s | Uptime: %s | Regions: %s\n", time.Now().Format("2006-01-02 15:04:05"), + s.stats.Announcing, s.stats.ConnectingClients, s.stats.ConnectedClients, formatBytes(s.stats.TotalBytesUp), formatBytes(s.stats.TotalBytesDown), formatDuration(uptime), + regionSummary, ) // Write stats to file if configured (copy data while locked, write async) @@ -465,33 +499,168 @@ func (s *Service) syncSnapshotLocked() { s.connectedClients.Store(int64(s.stats.ConnectedClients)) } -func (s *Service) shouldLogInproxyActivity(now time.Time) (bool, int, int, int) { - announcing := s.stats.Announcing - connecting := s.stats.ConnectingClients - connected := s.stats.ConnectedClients - - connectingChanged := connecting != s.lastLoggedConnecting - connectedChanged := connected != s.lastLoggedConnected - if connectingChanged || connectedChanged { - s.lastLoggedConnecting = connecting - s.lastLoggedConnected = connected - s.lastLoggedAnnouncing = announcing - s.lastActivityLogTime = now - return true, announcing, connecting, connected - } - - // If connecting/connected is unchanged, log every minute with the current - // number of announcing workers. Note that the scrapeable /metrics updates - // immediately when new data is received, this log is just to indicate - // activity in the terminal. - const inproxyActivityLogInterval = time.Minute * 1 - if s.lastActivityLogTime.IsZero() || now.Sub(s.lastActivityLogTime) >= inproxyActivityLogInterval { - s.lastLoggedAnnouncing = announcing - s.lastActivityLogTime = now - return true, announcing, connecting, connected - } - - return false, announcing, connecting, connected +// formatRegionActivityTotalsLocked returns an aggregated region summary string. +// Must be called with lock held. +func (s *Service) formatRegionActivityTotalsLocked() string { + personalSummary := formatRegionScopeTotals(s.regionActivityTotals[regionScopePersonal]) + commonSummary := formatRegionScopeTotals(s.regionActivityTotals[regionScopeCommon]) + + parts := make([]string, 0, 2) + if personalSummary != "-" { + parts = append(parts, fmt.Sprintf("personal[%s]", personalSummary)) + } + if commonSummary != "-" { + parts = append(parts, fmt.Sprintf("common[%s]", commonSummary)) + } + + if len(parts) == 0 { + return "none" + } + + return strings.Join(parts, " ") +} + +func formatRegionScopeTotals(byRegion map[string]RegionActivityTotals) string { + if len(byRegion) == 0 { + return "-" + } + + regions := make([]string, 0, len(byRegion)) + for region := range byRegion { + regions = append(regions, region) + } + sort.Slice(regions, func(i, j int) bool { + left := byRegion[regions[i]].BytesUp + byRegion[regions[i]].BytesDown + right := byRegion[regions[j]].BytesUp + byRegion[regions[j]].BytesDown + if left == right { + return regions[i] < regions[j] + } + return left > right + }) + + limit := len(regions) + if limit > maxLoggedRegionsPerScope { + limit = maxLoggedRegionsPerScope + } + + parts := make([]string, 0, limit+1) + for i := 0; i < limit; i++ { + region := regions[i] + totals := byRegion[region] + transferTotal := totals.BytesUp + totals.BytesDown + parts = append(parts, fmt.Sprintf( + "%s(conn:%d|traffic:%s)", + region, + totals.ConnectedClients, + formatBytes(transferTotal), + )) + } + + if len(regions) > limit { + parts = append(parts, fmt.Sprintf("(+%d more...)", len(regions)-limit)) + } + + return strings.Join(parts, ", ") +} + +// accumulateRegionActivityLocked accumulates per-region byte deltas and +// updates per-region client counts as latest values from the notice. +// Must be called with lock held. +func (s *Service) accumulateRegionActivityLocked(scope string, deltas map[string]RegionActivityTotals) { + if s.regionActivityTotals == nil { + s.regionActivityTotals = make(map[string]map[string]RegionActivityTotals) + } + if s.regionActivityTotals[scope] == nil { + s.regionActivityTotals[scope] = make(map[string]RegionActivityTotals) + } + + // Connecting/connected are latest values, not accumulated totals. + for region, totals := range s.regionActivityTotals[scope] { + totals.ConnectingClients = 0 + totals.ConnectedClients = 0 + s.regionActivityTotals[scope][region] = totals + } + + for region, delta := range deltas { + totals := s.regionActivityTotals[scope][region] + totals.BytesUp += delta.BytesUp + totals.BytesDown += delta.BytesDown + totals.ConnectingClients = delta.ConnectingClients + totals.ConnectedClients = delta.ConnectedClients + s.regionActivityTotals[scope][region] = totals + } +} + +func parseRegionActivity(raw interface{}) (map[string]RegionActivityTotals, bool) { + activityByRegion, ok := raw.(map[string]interface{}) + if !ok { + return nil, false + } + + parsed := make(map[string]RegionActivityTotals, len(activityByRegion)) + for region, value := range activityByRegion { + activityData, ok := value.(map[string]interface{}) + if !ok { + continue + } + + var totals RegionActivityTotals + if v, ok := int64FromValue(activityData["bytesUp"]); ok { + totals.BytesUp = v + } + if v, ok := int64FromValue(activityData["bytesDown"]); ok { + totals.BytesDown = v + } + if v, ok := int64FromValue(activityData["connectingClients"]); ok { + totals.ConnectingClients = v + } + if v, ok := int64FromValue(activityData["connectedClients"]); ok { + totals.ConnectedClients = v + } + + parsed[region] = totals + } + + return parsed, true +} + +func int64FromValue(value interface{}) (int64, bool) { + switch v := value.(type) { + case nil: + return 0, false + case int: + return int64(v), true + case int8: + return int64(v), true + case int16: + return int64(v), true + case int32: + return int64(v), true + case int64: + return v, true + case uint: + return int64(v), true + case uint8: + return int64(v), true + case uint16: + return int64(v), true + case uint32: + return int64(v), true + case uint64: + return int64(v), true + case float32: + return int64(v), true + case float64: + return int64(v), true + case json.Number: + parsed, err := v.Int64() + if err != nil { + return 0, false + } + return parsed, true + default: + return 0, false + } } // formatBytes formats bytes as a human-readable string diff --git a/cli/internal/conduit/service_test.go b/cli/internal/conduit/service_test.go new file mode 100644 index 00000000..17e4ff6a --- /dev/null +++ b/cli/internal/conduit/service_test.go @@ -0,0 +1,95 @@ +/* + * 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 conduit + +import ( + "testing" + "time" + + "github.com/Psiphon-Inc/conduit/cli/internal/config" +) + +func TestHandleNoticeInproxyProxyActivityAccumulatesRegionDeltas(t *testing.T) { + s := &Service{ + config: &config.Config{}, + stats: &Stats{StartTime: time.Now()}, + regionActivityTotals: make(map[string]map[string]RegionActivityTotals), + } + + notice1 := []byte(`{"noticeType":"InproxyProxyActivity","data":{"announcing":1,"connectingClients":2,"connectedClients":5,"bytesUp":12345,"bytesDown":67890,"personalRegionActivity":{"US":{"bytesUp":111,"bytesDown":222,"connectingClients":1,"connectedClients":2},"CA":{"bytesUp":0,"bytesDown":64,"connectingClients":0,"connectedClients":1}},"commonRegionActivity":{"BR":{"bytesUp":333,"bytesDown":444,"connectingClients":0,"connectedClients":3}}},"timestamp":"2026-02-10T12:34:56.789Z"}`) + notice2 := []byte(`{"noticeType":"InproxyProxyActivity","data":{"announcing":0,"connectingClients":1,"connectedClients":6,"bytesUp":55,"bytesDown":66,"personalRegionActivity":{"US":{"bytesUp":10,"bytesDown":20,"connectingClients":0,"connectedClients":1}},"commonRegionActivity":{"BR":{"bytesUp":7,"bytesDown":8,"connectingClients":1,"connectedClients":0},"DE":{"bytesUp":1,"bytesDown":2,"connectingClients":0,"connectedClients":1}}},"timestamp":"2026-02-10T12:35:56.789Z"}`) + + s.handleNotice(notice1) + s.handleNotice(notice2) + + stats := s.GetStats() + if stats.TotalBytesUp != 12400 { + t.Fatalf("unexpected total bytes up: got %d", stats.TotalBytesUp) + } + if stats.TotalBytesDown != 67956 { + t.Fatalf("unexpected total bytes down: got %d", stats.TotalBytesDown) + } + if stats.Announcing != 0 || stats.ConnectingClients != 1 || stats.ConnectedClients != 6 { + t.Fatalf("unexpected client state: announcing=%d connecting=%d connected=%d", stats.Announcing, stats.ConnectingClients, stats.ConnectedClients) + } + if !stats.IsLive { + t.Fatalf("expected IsLive to be true") + } + + personalUS := s.regionActivityTotals[regionScopePersonal]["US"] + if personalUS.BytesUp != 121 || personalUS.BytesDown != 242 || personalUS.ConnectingClients != 0 || personalUS.ConnectedClients != 1 { + t.Fatalf("unexpected personal US totals: %+v", personalUS) + } + + personalCA := s.regionActivityTotals[regionScopePersonal]["CA"] + if personalCA.BytesUp != 0 || personalCA.BytesDown != 64 || personalCA.ConnectingClients != 0 || personalCA.ConnectedClients != 0 { + t.Fatalf("unexpected personal CA totals: %+v", personalCA) + } + + commonBR := s.regionActivityTotals[regionScopeCommon]["BR"] + if commonBR.BytesUp != 340 || commonBR.BytesDown != 452 || commonBR.ConnectingClients != 1 || commonBR.ConnectedClients != 0 { + t.Fatalf("unexpected common BR totals: %+v", commonBR) + } + + commonDE := s.regionActivityTotals[regionScopeCommon]["DE"] + if commonDE.BytesUp != 1 || commonDE.BytesDown != 2 || commonDE.ConnectingClients != 0 || commonDE.ConnectedClients != 1 { + t.Fatalf("unexpected common DE totals: %+v", commonDE) + } +} + +func TestHandleNoticeInproxyProxyActivityWithoutRegionMaps(t *testing.T) { + s := &Service{ + config: &config.Config{}, + stats: &Stats{StartTime: time.Now()}, + regionActivityTotals: make(map[string]map[string]RegionActivityTotals), + } + + notice := []byte(`{"noticeType":"InproxyProxyActivity","data":{"announcing":1,"connectingClients":0,"connectedClients":0,"bytesUp":10,"bytesDown":20},"timestamp":"2026-02-10T12:34:56.789Z"}`) + s.handleNotice(notice) + + if len(s.regionActivityTotals) != 0 { + t.Fatalf("expected no region totals, got: %+v", s.regionActivityTotals) + } + + stats := s.GetStats() + if stats.TotalBytesUp != 10 || stats.TotalBytesDown != 20 { + t.Fatalf("unexpected totals: up=%d down=%d", stats.TotalBytesUp, stats.TotalBytesDown) + } +} diff --git a/cli/internal/config/compartment.go b/cli/internal/config/compartment.go new file mode 100644 index 00000000..77584c9e --- /dev/null +++ b/cli/internal/config/compartment.go @@ -0,0 +1,217 @@ +package config + +import ( + "crypto/rand" + "encoding/base64" + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" +) + +const personalCompartmentIDByteLength = 32 + +type personalPairingTokenData struct { + ID string `json:"id"` + Name string `json:"name"` +} + +type personalPairingTokenPayload struct { + V string `json:"v"` + Data personalPairingTokenData `json:"data"` +} + +type persistedCompartment struct { + ID string `json:"id"` +} + +// GeneratePersonalCompartmentID returns a base64 RawStd-encoded 32-byte +// personal compartment ID. +func GeneratePersonalCompartmentID() (string, error) { + raw := make([]byte, personalCompartmentIDByteLength) + if _, err := rand.Read(raw); err != nil { + return "", fmt.Errorf("failed to generate personal compartment id: %w", err) + } + return base64.RawStdEncoding.EncodeToString(raw), nil +} + +// ValidatePersonalCompartmentID validates the personal compartment ID contract. +func ValidatePersonalCompartmentID(id string) error { + id = strings.TrimSpace(id) + if id == "" { + return fmt.Errorf("compartment id is empty") + } + if len(id) != 43 { + return fmt.Errorf("compartment id must be exactly 43 characters") + } + if strings.ContainsAny(id, "-_=") { + return fmt.Errorf("compartment id must use unpadded standard base64 alphabet") + } + for i := 0; i < len(id); i++ { + c := id[i] + isAlphaNum := c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z' || c >= '0' && c <= '9' + if isAlphaNum || c == '+' || c == '/' { + continue + } + return fmt.Errorf("compartment id contains invalid character %q", c) + } + + decoded, err := base64.RawStdEncoding.DecodeString(id) + if err != nil { + return fmt.Errorf("invalid compartment id encoding: %w", err) + } + if len(decoded) != personalCompartmentIDByteLength { + return fmt.Errorf("compartment id must decode to exactly %d bytes", personalCompartmentIDByteLength) + } + + return nil +} + +// NormalizePersonalCompartmentInput accepts either a raw compartment ID or a +// v1 personal pairing token and returns the validated compartment ID. +func NormalizePersonalCompartmentInput(input string) (string, error) { + trimmed := strings.TrimSpace(input) + if err := ValidatePersonalCompartmentID(trimmed); err == nil { + return trimmed, nil + } + + payload, err := ParsePersonalPairingToken(trimmed) + if err != nil { + return "", fmt.Errorf("invalid compartment id or token: %w", err) + } + return payload.Data.ID, nil +} + +// BuildPersonalPairingToken returns a v1 personal pairing share token. +func BuildPersonalPairingToken(id, name string) (string, error) { + if err := ValidatePersonalCompartmentID(id); err != nil { + return "", err + } + if strings.TrimSpace(name) == "" { + return "", fmt.Errorf("name must be non-empty") + } + + payload := personalPairingTokenPayload{ + V: "1", + Data: personalPairingTokenData{ + ID: id, + Name: name, + }, + } + + data, err := json.Marshal(payload) + if err != nil { + return "", fmt.Errorf("failed to marshal token payload: %w", err) + } + + return base64.RawURLEncoding.EncodeToString(data), nil +} + +// ParsePersonalPairingToken validates and parses a v1 personal pairing token. +func ParsePersonalPairingToken(token string) (*personalPairingTokenPayload, error) { + token = strings.TrimSpace(token) + if token == "" { + return nil, fmt.Errorf("token is empty") + } + + decoded, err := base64.RawURLEncoding.DecodeString(token) + if err != nil { + return nil, fmt.Errorf("token must be base64url without padding: %w", err) + } + + var top map[string]json.RawMessage + if err := json.Unmarshal(decoded, &top); err != nil { + return nil, fmt.Errorf("token payload is not valid JSON: %w", err) + } + if len(top) != 2 { + return nil, fmt.Errorf("token payload must contain exactly keys v and data") + } + if _, ok := top["v"]; !ok { + return nil, fmt.Errorf("token payload missing key v") + } + if _, ok := top["data"]; !ok { + return nil, fmt.Errorf("token payload missing key data") + } + + var payload personalPairingTokenPayload + if err := json.Unmarshal(decoded, &payload); err != nil { + return nil, fmt.Errorf("invalid token payload: %w", err) + } + if payload.V != "1" { + return nil, fmt.Errorf("token version must be string \"1\"") + } + + var dataMap map[string]json.RawMessage + if err := json.Unmarshal(top["data"], &dataMap); err != nil { + return nil, fmt.Errorf("token data must be an object") + } + if len(dataMap) != 2 { + return nil, fmt.Errorf("token data must contain exactly keys id and name") + } + if _, ok := dataMap["id"]; !ok { + return nil, fmt.Errorf("token data missing key id") + } + if _, ok := dataMap["name"]; !ok { + return nil, fmt.Errorf("token data missing key name") + } + + if strings.TrimSpace(payload.Data.Name) == "" { + return nil, fmt.Errorf("token data.name must be non-empty") + } + if err := ValidatePersonalCompartmentID(payload.Data.ID); err != nil { + return nil, err + } + + return &payload, nil +} + +func personalCompartmentPath(dataDir string) string { + return filepath.Join(dataDir, personalCompartmentFileName) +} + +// SavePersonalCompartmentID writes the personal compartment ID to dataDir. +func SavePersonalCompartmentID(dataDir, id string) error { + if err := ValidatePersonalCompartmentID(id); err != nil { + return err + } + if err := os.MkdirAll(dataDir, 0700); err != nil { + return fmt.Errorf("failed to create data directory: %w", err) + } + + persisted := persistedCompartment{ID: id} + encoded, err := json.MarshalIndent(persisted, "", " ") + if err != nil { + return fmt.Errorf("failed to encode personal compartment file: %w", err) + } + + if err := os.WriteFile(personalCompartmentPath(dataDir), encoded, 0600); err != nil { + return fmt.Errorf("failed to write personal compartment file: %w", err) + } + + return nil +} + +// LoadPersonalCompartmentID loads the persisted personal compartment ID. +func LoadPersonalCompartmentID(dataDir string) (string, error) { + path := personalCompartmentPath(dataDir) + encoded, err := os.ReadFile(path) + if err != nil { + return "", err + } + + var persisted persistedCompartment + if err := json.Unmarshal(encoded, &persisted); err != nil { + return "", fmt.Errorf("failed to parse personal compartment file: %w", err) + } + if err := ValidatePersonalCompartmentID(persisted.ID); err != nil { + return "", fmt.Errorf("invalid persisted personal compartment id: %w", err) + } + + return persisted.ID, nil +} + +// PersonalCompartmentFilePath returns the location of persisted compartment ID. +func PersonalCompartmentFilePath(dataDir string) string { + return personalCompartmentPath(dataDir) +} diff --git a/cli/internal/config/compartment_test.go b/cli/internal/config/compartment_test.go new file mode 100644 index 00000000..34685cff --- /dev/null +++ b/cli/internal/config/compartment_test.go @@ -0,0 +1,102 @@ +package config + +import ( + "encoding/base64" + "encoding/json" + "strings" + "testing" +) + +func TestBuildAndParsePersonalPairingToken(t *testing.T) { + id, err := GeneratePersonalCompartmentID() + if err != nil { + t.Fatalf("GeneratePersonalCompartmentID: %v", err) + } + + token, err := BuildPersonalPairingToken(id, "my-station") + if err != nil { + t.Fatalf("BuildPersonalPairingToken: %v", err) + } + + payload, err := ParsePersonalPairingToken(token) + if err != nil { + t.Fatalf("ParsePersonalPairingToken: %v", err) + } + if payload.V != "1" { + t.Fatalf("unexpected version: %q", payload.V) + } + if payload.Data.ID != id { + t.Fatalf("unexpected id: %q", payload.Data.ID) + } + if payload.Data.Name != "my-station" { + t.Fatalf("unexpected name: %q", payload.Data.Name) + } +} + +func TestParsePersonalPairingTokenRejectsExtraKeys(t *testing.T) { + id, err := GeneratePersonalCompartmentID() + if err != nil { + t.Fatalf("GeneratePersonalCompartmentID: %v", err) + } + + payload := map[string]any{ + "v": "1", + "data": map[string]any{ + "id": id, + "name": "x", + "bad": true, + }, + } + encoded, err := json.Marshal(payload) + if err != nil { + t.Fatalf("json.Marshal: %v", err) + } + token := base64.RawURLEncoding.EncodeToString(encoded) + + _, err = ParsePersonalPairingToken(token) + if err == nil { + t.Fatalf("expected parse error") + } + if !strings.Contains(err.Error(), "exactly keys id and name") { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestNormalizePersonalCompartmentInputSupportsToken(t *testing.T) { + id, err := GeneratePersonalCompartmentID() + if err != nil { + t.Fatalf("GeneratePersonalCompartmentID: %v", err) + } + token, err := BuildPersonalPairingToken(id, "station") + if err != nil { + t.Fatalf("BuildPersonalPairingToken: %v", err) + } + + normalized, err := NormalizePersonalCompartmentInput(token) + if err != nil { + t.Fatalf("NormalizePersonalCompartmentInput: %v", err) + } + if normalized != id { + t.Fatalf("normalized id = %q, expected %q", normalized, id) + } +} + +func TestSaveAndLoadPersonalCompartmentID(t *testing.T) { + id, err := GeneratePersonalCompartmentID() + if err != nil { + t.Fatalf("GeneratePersonalCompartmentID: %v", err) + } + dataDir := t.TempDir() + + if err := SavePersonalCompartmentID(dataDir, id); err != nil { + t.Fatalf("SavePersonalCompartmentID: %v", err) + } + + loaded, err := LoadPersonalCompartmentID(dataDir) + if err != nil { + t.Fatalf("LoadPersonalCompartmentID: %v", err) + } + if loaded != id { + t.Fatalf("loaded id = %q, expected %q", loaded, id) + } +} diff --git a/cli/internal/config/config.go b/cli/internal/config/config.go index e82335a6..7f8540b5 100644 --- a/cli/internal/config/config.go +++ b/cli/internal/config/config.go @@ -26,6 +26,7 @@ import ( "fmt" "os" "path/filepath" + "strings" "github.com/Psiphon-Inc/conduit/cli/internal/crypto" "github.com/Psiphon-Inc/conduit/cli/internal/logging" @@ -39,27 +40,37 @@ const ( UnlimitedBandwidth = -1.0 // Special value for no bandwidth limit // File names for persisted data - keyFileName = "conduit_key.json" + keyFileName = "conduit_key.json" + personalCompartmentFileName = "personal_compartment.json" ) // Options represents CLI options passed to LoadOrCreate type Options struct { - DataDir string - PsiphonConfigPath string - UseEmbeddedConfig bool - MaxClients int - BandwidthMbps float64 - BandwidthSet bool - Verbosity int // 0=normal, 1+=verbose - StatsFile string // Path to write stats JSON file (empty = disabled) - MetricsAddr string // Address for Prometheus metrics endpoint (empty = disabled) + DataDir string + PsiphonConfigPath string + UseEmbeddedConfig bool + MaxCommonClients int + MaxCommonClientsSet bool + MaxPersonalClients int + MaxPersonalClientsSet bool + CompartmentID string + CompartmentIDSet bool + SetOverrides map[string]interface{} + BandwidthMbps float64 + BandwidthSet bool + Verbosity int // 0=normal, 1+=verbose + StatsFile string // Path to write stats JSON file (empty = disabled) + MetricsAddr string // Address for Prometheus metrics endpoint (empty = disabled) } // Config represents the validated configuration for the Conduit service type Config struct { KeyPair *crypto.KeyPair PrivateKeyBase64 string - MaxClients int + MaxCommonClients int + MaxPersonalClients int + CompartmentID string + SetOverrides map[string]interface{} BandwidthBytesPerSecond int DataDir string PsiphonConfigPath string @@ -107,9 +118,12 @@ func LoadOrCreate(opts Options) (*Config, error) { // Parse inproxy settings from config if available var inproxyConfig struct { - InproxyMaxClients *int `json:"InproxyMaxClients"` - InproxyLimitUpstreamBytesPerSecond *int `json:"InproxyLimitUpstreamBytesPerSecond"` - InproxyLimitDownstreamBytesPerSecond *int `json:"InproxyLimitDownstreamBytesPerSecond"` + InproxyMaxClients *int `json:"InproxyMaxClients"` + InproxyMaxCommonClients *int `json:"InproxyMaxCommonClients"` + InproxyMaxPersonalClients *int `json:"InproxyMaxPersonalClients"` + InproxyProxyPersonalCompartmentID string `json:"InproxyProxyPersonalCompartmentID"` + InproxyLimitUpstreamBytesPerSecond *int `json:"InproxyLimitUpstreamBytesPerSecond"` + InproxyLimitDownstreamBytesPerSecond *int `json:"InproxyLimitDownstreamBytesPerSecond"` } if len(psiphonConfigFileData) > 0 { if err := json.Unmarshal(psiphonConfigFileData, &inproxyConfig); err != nil { @@ -117,16 +131,98 @@ func LoadOrCreate(opts Options) (*Config, error) { } } - // Resolve max clients: flag > config > default - maxClients := opts.MaxClients - if maxClients == 0 && inproxyConfig.InproxyMaxClients != nil { - maxClients = *inproxyConfig.InproxyMaxClients + setOverrides := copyOverrides(opts.SetOverrides) + + setMaxCommonClients, hasSetMaxCommonClients, err := intOverrideValue(setOverrides, "InproxyMaxCommonClients") + if err != nil { + return nil, err + } + if !hasSetMaxCommonClients { + if v, ok, err := intOverrideValue(setOverrides, "InproxyMaxClients"); err != nil { + return nil, err + } else if ok { + setMaxCommonClients = v + hasSetMaxCommonClients = true + } + } + + setMaxPersonalClients, hasSetMaxPersonalClients, err := intOverrideValue(setOverrides, "InproxyMaxPersonalClients") + if err != nil { + return nil, err + } + + setCompartmentID, hasSetCompartmentID, err := stringOverrideValue(setOverrides, "InproxyProxyPersonalCompartmentID") + if err != nil { + return nil, err + } + + // Resolve max common clients: flag > --set > config fields. + // When only personal clients are configured and no common limit is specified, + // preserve common=0 instead of defaulting to 50. + maxCommonClients := 0 + if opts.MaxCommonClientsSet { + maxCommonClients = opts.MaxCommonClients + } else if hasSetMaxCommonClients { + maxCommonClients = setMaxCommonClients + } else { + switch { + case inproxyConfig.InproxyMaxCommonClients != nil: + maxCommonClients = *inproxyConfig.InproxyMaxCommonClients + case inproxyConfig.InproxyMaxClients != nil: + maxCommonClients = *inproxyConfig.InproxyMaxClients + case inproxyConfig.InproxyMaxPersonalClients != nil && *inproxyConfig.InproxyMaxPersonalClients > 0: + maxCommonClients = 0 + } + } + + maxPersonalClients := 0 + if opts.MaxPersonalClientsSet { + maxPersonalClients = opts.MaxPersonalClients + } else if hasSetMaxPersonalClients { + maxPersonalClients = setMaxPersonalClients + } else if inproxyConfig.InproxyMaxPersonalClients != nil { + maxPersonalClients = *inproxyConfig.InproxyMaxPersonalClients + } + + compartmentID := "" + if opts.CompartmentIDSet { + compartmentID = opts.CompartmentID + } else if hasSetCompartmentID { + compartmentID = setCompartmentID + } else { + persistedID, err := LoadPersonalCompartmentID(opts.DataDir) + if err == nil { + compartmentID = persistedID + } else if !os.IsNotExist(err) { + return nil, fmt.Errorf("failed to load personal compartment: %w", err) + } else if strings.TrimSpace(inproxyConfig.InproxyProxyPersonalCompartmentID) != "" { + compartmentID = inproxyConfig.InproxyProxyPersonalCompartmentID + } + } + if strings.TrimSpace(compartmentID) != "" { + normalizedCompartmentID, err := NormalizePersonalCompartmentInput(compartmentID) + if err != nil { + return nil, err + } + compartmentID = normalizedCompartmentID + } + + if maxCommonClients == 0 { + if maxPersonalClients == 0 { + maxCommonClients = DefaultMaxClients + } + } + if maxCommonClients < 0 || maxCommonClients > MaxClientsLimit { + return nil, fmt.Errorf("max-common-clients must be between 0 and %d", MaxClientsLimit) + } + if maxPersonalClients < 0 || maxPersonalClients > MaxClientsLimit { + return nil, fmt.Errorf("max-personal-clients must be between 0 and %d", MaxClientsLimit) } - if maxClients == 0 { - maxClients = DefaultMaxClients + if maxCommonClients+maxPersonalClients <= 0 { + return nil, fmt.Errorf("configured max clients must be > 0") } - if maxClients < 1 || maxClients > MaxClientsLimit { - return nil, fmt.Errorf("max-clients must be between 1 and %d", MaxClientsLimit) + if maxPersonalClients > 0 && strings.TrimSpace(compartmentID) == "" { + return nil, fmt.Errorf("create compartment first with new-compartment-id command") } // Resolve bandwidth: flag > config > default @@ -171,7 +267,10 @@ func LoadOrCreate(opts Options) (*Config, error) { return &Config{ KeyPair: keyPair, PrivateKeyBase64: privateKeyBase64, - MaxClients: maxClients, + MaxCommonClients: maxCommonClients, + MaxPersonalClients: maxPersonalClients, + CompartmentID: compartmentID, + SetOverrides: stripResolvedOverrides(setOverrides), BandwidthBytesPerSecond: bandwidthBytesPerSecond, DataDir: opts.DataDir, PsiphonConfigPath: opts.PsiphonConfigPath, diff --git a/cli/internal/config/config_test.go b/cli/internal/config/config_test.go index 8dfa5257..7004db33 100644 --- a/cli/internal/config/config_test.go +++ b/cli/internal/config/config_test.go @@ -3,6 +3,7 @@ package config import ( "os" "path/filepath" + "strings" "testing" ) @@ -24,8 +25,11 @@ func TestLoadOrCreatePrecedence(t *testing.T) { name string configJSON string opts Options - expectedMaxClients int + persistCompartment bool + expectedMaxCommon int + expectedMaxPersonal int expectedBandwidthBps int + expectedErrContains string }{ { name: "flag_overrides_config", @@ -35,11 +39,13 @@ func TestLoadOrCreatePrecedence(t *testing.T) { "InproxyLimitDownstreamBytesPerSecond": 900 }`, opts: Options{ - MaxClients: 123, - BandwidthSet: true, - BandwidthMbps: 10, + MaxCommonClients: 123, + MaxCommonClientsSet: true, + BandwidthSet: true, + BandwidthMbps: 10, }, - expectedMaxClients: 123, + expectedMaxCommon: 123, + expectedMaxPersonal: 0, expectedBandwidthBps: bandwidthBytes(10), }, { @@ -50,14 +56,47 @@ func TestLoadOrCreatePrecedence(t *testing.T) { "InproxyLimitDownstreamBytesPerSecond": 700 }`, opts: Options{}, - expectedMaxClients: 88, + expectedMaxCommon: 88, + expectedMaxPersonal: 0, expectedBandwidthBps: 700, }, + { + name: "new_common_and_personal_fields", + configJSON: `{ + "InproxyMaxCommonClients": 12, + "InproxyMaxPersonalClients": 3 +}`, + persistCompartment: true, + opts: Options{}, + expectedMaxCommon: 12, + expectedMaxPersonal: 3, + expectedBandwidthBps: bandwidthBytes(DefaultBandwidthMbps), + }, + { + name: "personal_only_keeps_common_zero", + configJSON: `{ + "InproxyMaxPersonalClients": 7 +}`, + persistCompartment: true, + opts: Options{}, + expectedMaxCommon: 0, + expectedMaxPersonal: 7, + expectedBandwidthBps: bandwidthBytes(DefaultBandwidthMbps), + }, + { + name: "personal_without_compartment_fails", + configJSON: `{ + "InproxyMaxPersonalClients": 2 +}`, + opts: Options{}, + expectedErrContains: "create compartment first with new-compartment-id command", + }, { name: "defaults_when_missing", configJSON: `{}`, opts: Options{}, - expectedMaxClients: DefaultMaxClients, + expectedMaxCommon: DefaultMaxClients, + expectedMaxPersonal: 0, expectedBandwidthBps: bandwidthBytes(DefaultBandwidthMbps), }, } @@ -67,17 +106,38 @@ func TestLoadOrCreatePrecedence(t *testing.T) { t.Run(test.name, func(t *testing.T) { dataDir := t.TempDir() configPath := writeTempConfig(t, dataDir, test.configJSON) + if test.persistCompartment { + compartmentID, err := GeneratePersonalCompartmentID() + if err != nil { + t.Fatalf("GeneratePersonalCompartmentID: %v", err) + } + if err := SavePersonalCompartmentID(dataDir, compartmentID); err != nil { + t.Fatalf("SavePersonalCompartmentID: %v", err) + } + } opts := test.opts opts.DataDir = dataDir opts.PsiphonConfigPath = configPath cfg, err := LoadOrCreate(opts) + if test.expectedErrContains != "" { + if err == nil { + t.Fatalf("expected error containing %q", test.expectedErrContains) + } + if !strings.Contains(err.Error(), test.expectedErrContains) { + t.Fatalf("LoadOrCreate error = %q, expected to contain %q", err.Error(), test.expectedErrContains) + } + return + } if err != nil { t.Fatalf("LoadOrCreate: %v", err) } - if cfg.MaxClients != test.expectedMaxClients { - t.Fatalf("MaxClients = %d, expected %d", cfg.MaxClients, test.expectedMaxClients) + if cfg.MaxCommonClients != test.expectedMaxCommon { + t.Fatalf("MaxCommonClients = %d, expected %d", cfg.MaxCommonClients, test.expectedMaxCommon) + } + if cfg.MaxPersonalClients != test.expectedMaxPersonal { + t.Fatalf("MaxPersonalClients = %d, expected %d", cfg.MaxPersonalClients, test.expectedMaxPersonal) } if cfg.BandwidthBytesPerSecond != test.expectedBandwidthBps { t.Fatalf("BandwidthBytesPerSecond = %d, expected %d", cfg.BandwidthBytesPerSecond, test.expectedBandwidthBps) diff --git a/cli/internal/config/set_overrides.go b/cli/internal/config/set_overrides.go new file mode 100644 index 00000000..8cf3d649 --- /dev/null +++ b/cli/internal/config/set_overrides.go @@ -0,0 +1,224 @@ +package config + +import ( + "bytes" + "encoding/json" + "fmt" + "strconv" + "strings" +) + +type overrideType int + +const ( + overrideInt overrideType = iota + overrideString + overrideBool +) + +var allowedSetKeys = map[string]overrideType{ + "EmitDiagnosticNotices": overrideBool, + "EmitInproxyProxyActivity": overrideBool, + "InproxyLimitDownstreamBytesPerSecond": overrideInt, + "InproxyLimitUpstreamBytesPerSecond": overrideInt, + "InproxyMaxClients": overrideInt, + "InproxyMaxCommonClients": overrideInt, + "InproxyMaxPersonalClients": overrideInt, + "InproxyProxyPersonalCompartmentID": overrideString, + "InproxyReducedEndTime": overrideString, + "InproxyReducedLimitDownstreamBytesPerSecond": overrideInt, + "InproxyReducedLimitUpstreamBytesPerSecond": overrideInt, + "InproxyReducedMaxCommonClients": overrideInt, + "InproxyReducedStartTime": overrideString, +} + +func copyOverrides(source map[string]interface{}) map[string]interface{} { + if len(source) == 0 { + return nil + } + clone := make(map[string]interface{}, len(source)) + for key, value := range source { + clone[key] = value + } + return clone +} + +func stripResolvedOverrides(overrides map[string]interface{}) map[string]interface{} { + if len(overrides) == 0 { + return nil + } + keys := []string{ + "InproxyMaxClients", + "InproxyMaxCommonClients", + "InproxyMaxPersonalClients", + "InproxyProxyPersonalCompartmentID", + } + clone := copyOverrides(overrides) + for _, key := range keys { + delete(clone, key) + } + if len(clone) == 0 { + return nil + } + return clone +} + +func intOverrideValue(overrides map[string]interface{}, key string) (int, bool, error) { + if len(overrides) == 0 { + return 0, false, nil + } + raw, ok := overrides[key] + if !ok { + return 0, false, nil + } + value, err := toInt(raw) + if err != nil { + return 0, false, fmt.Errorf("invalid --set value for %s: %w", key, err) + } + return value, true, nil +} + +func stringOverrideValue(overrides map[string]interface{}, key string) (string, bool, error) { + if len(overrides) == 0 { + return "", false, nil + } + raw, ok := overrides[key] + if !ok { + return "", false, nil + } + value, ok := raw.(string) + if !ok { + return "", false, fmt.Errorf("invalid --set value for %s: expected string", key) + } + return value, true, nil +} + +// ParseSetOverrides parses --set key=value flags into an allowlisted map. +// Values are validated against the expected type for each key at parse time. +func ParseSetOverrides(entries []string) (map[string]interface{}, error) { + if len(entries) == 0 { + return nil, nil + } + + overrides := make(map[string]interface{}, len(entries)) + for _, entry := range entries { + parts := strings.SplitN(entry, "=", 2) + if len(parts) != 2 { + return nil, fmt.Errorf("invalid --set entry %q: expected key=value", entry) + } + key := strings.TrimSpace(parts[0]) + if key == "" { + return nil, fmt.Errorf("invalid --set entry %q: key is empty", entry) + } + expectedType, ok := allowedSetKeys[key] + if !ok { + return nil, fmt.Errorf("unsupported --set key %q", key) + } + + value, err := parseOverrideValue(parts[1]) + if err != nil { + return nil, fmt.Errorf("invalid --set value for %s: %w", key, err) + } + + // Validate parsed value matches the expected type for this key. + switch expectedType { + case overrideInt: + if _, err := toInt(value); err != nil { + return nil, fmt.Errorf("invalid --set value for %s: expected integer", key) + } + case overrideBool: + if _, ok := value.(bool); !ok { + return nil, fmt.Errorf("invalid --set value for %s: expected true or false", key) + } + case overrideString: + if _, ok := value.(string); !ok { + return nil, fmt.Errorf("invalid --set value for %s: expected string", key) + } + } + + if key == "InproxyProxyPersonalCompartmentID" { + stringValue := value.(string) + normalized, err := NormalizePersonalCompartmentInput(stringValue) + if err != nil { + return nil, err + } + value = normalized + } + + overrides[key] = value + } + + return overrides, nil +} + +func parseOverrideValue(raw string) (interface{}, error) { + trimmed := strings.TrimSpace(raw) + if trimmed == "" { + return "", nil + } + + if shouldParseAsJSON(trimmed) { + decoder := json.NewDecoder(bytes.NewBufferString(trimmed)) + decoder.UseNumber() + var value interface{} + if err := decoder.Decode(&value); err != nil { + return nil, err + } + return value, nil + } + + return trimmed, nil +} + +func shouldParseAsJSON(value string) bool { + if value == "true" || value == "false" || value == "null" { + return true + } + if strings.HasPrefix(value, "{") || strings.HasPrefix(value, "[") || strings.HasPrefix(value, "\"") { + return true + } + if _, err := strconv.ParseInt(value, 10, 64); err == nil { + return true + } + if _, err := strconv.ParseFloat(value, 64); err == nil { + return true + } + return false +} + +func toInt(value interface{}) (int, error) { + switch v := value.(type) { + case int: + return v, nil + case int8: + return int(v), nil + case int16: + return int(v), nil + case int32: + return int(v), nil + case int64: + return int(v), nil + case uint: + return int(v), nil + case uint8: + return int(v), nil + case uint16: + return int(v), nil + case uint32: + return int(v), nil + case uint64: + return int(v), nil + case float32: + return int(v), nil + case float64: + return int(v), nil + case json.Number: + parsed, err := v.Int64() + if err != nil { + return 0, err + } + return int(parsed), nil + default: + return 0, fmt.Errorf("expected integer") + } +} diff --git a/cli/internal/config/set_overrides_test.go b/cli/internal/config/set_overrides_test.go new file mode 100644 index 00000000..477417ec --- /dev/null +++ b/cli/internal/config/set_overrides_test.go @@ -0,0 +1,40 @@ +package config + +import "testing" + +func TestParseSetOverrides(t *testing.T) { + id, err := GeneratePersonalCompartmentID() + if err != nil { + t.Fatalf("GeneratePersonalCompartmentID: %v", err) + } + token, err := BuildPersonalPairingToken(id, "station") + if err != nil { + t.Fatalf("BuildPersonalPairingToken: %v", err) + } + + overrides, err := ParseSetOverrides([]string{ + "InproxyMaxCommonClients=17", + "EmitInproxyProxyActivity=true", + "InproxyProxyPersonalCompartmentID=" + token, + }) + if err != nil { + t.Fatalf("ParseSetOverrides: %v", err) + } + + if got := overrides["InproxyMaxCommonClients"]; got == nil { + t.Fatalf("missing InproxyMaxCommonClients") + } + if got := overrides["EmitInproxyProxyActivity"]; got != true { + t.Fatalf("unexpected EmitInproxyProxyActivity: %v", got) + } + if got := overrides["InproxyProxyPersonalCompartmentID"]; got != id { + t.Fatalf("unexpected compartment id: %v", got) + } +} + +func TestParseSetOverridesRejectsUnsupportedKey(t *testing.T) { + _, err := ParseSetOverrides([]string{"UnknownField=1"}) + if err == nil { + t.Fatalf("expected error") + } +} diff --git a/cli/internal/metrics/metrics.go b/cli/internal/metrics/metrics.go index 61ea5285..70d18bed 100644 --- a/cli/internal/metrics/metrics.go +++ b/cli/internal/metrics/metrics.go @@ -40,14 +40,19 @@ const namespace = "conduit" // Metrics holds all Prometheus metrics for the Conduit service type Metrics struct { // Gauges - Announcing prometheus.Gauge - ConnectingClients prometheus.Gauge - ConnectedClients prometheus.Gauge - IsLive prometheus.Gauge - MaxClients prometheus.Gauge - BandwidthLimit prometheus.Gauge - BytesUploaded prometheus.Gauge - BytesDownloaded prometheus.Gauge + Announcing prometheus.Gauge + ConnectingClients prometheus.Gauge + ConnectedClients prometheus.Gauge + IsLive prometheus.Gauge + MaxCommonClients prometheus.Gauge + MaxPersonalClients prometheus.Gauge + BandwidthLimit prometheus.Gauge + BytesUploaded prometheus.Gauge + BytesDownloaded prometheus.Gauge + RegionBytesUp *prometheus.GaugeVec + RegionBytesDown *prometheus.GaugeVec + RegionConnecting *prometheus.GaugeVec + RegionConnected *prometheus.GaugeVec // Info BuildInfo *prometheus.GaugeVec @@ -106,11 +111,19 @@ func New(gaugeFuncs GaugeFuncs) *Metrics { }, registry, ), - MaxClients: newGauge( + MaxCommonClients: newGauge( prometheus.GaugeOpts{ Namespace: namespace, - Name: "max_clients", - Help: "Maximum number of proxy clients allowed", + Name: "max_common_clients", + Help: "Maximum number of common proxy clients allowed", + }, + registry, + ), + MaxPersonalClients: newGauge( + prometheus.GaugeOpts{ + Namespace: namespace, + Name: "max_personal_clients", + Help: "Maximum number of personal proxy clients allowed", }, registry, ), @@ -138,6 +151,42 @@ func New(gaugeFuncs GaugeFuncs) *Metrics { }, registry, ), + RegionBytesUp: newGaugeVec( + prometheus.GaugeOpts{ + Namespace: namespace, + Name: "region_bytes_uploaded", + Help: "Accumulated uploaded bytes by region and scope from InproxyProxyActivity deltas", + }, + []string{"scope", "region"}, + registry, + ), + RegionBytesDown: newGaugeVec( + prometheus.GaugeOpts{ + Namespace: namespace, + Name: "region_bytes_downloaded", + Help: "Accumulated downloaded bytes by region and scope from InproxyProxyActivity deltas", + }, + []string{"scope", "region"}, + registry, + ), + RegionConnecting: newGaugeVec( + prometheus.GaugeOpts{ + Namespace: namespace, + Name: "region_connecting_clients", + Help: "Latest connecting clients by region and scope from InproxyProxyActivity notices", + }, + []string{"scope", "region"}, + registry, + ), + RegionConnected: newGaugeVec( + prometheus.GaugeOpts{ + Namespace: namespace, + Name: "region_connected_clients", + Help: "Latest connected clients by region and scope from InproxyProxyActivity notices", + }, + []string{"scope", "region"}, + registry, + ), BuildInfo: newGaugeVec( prometheus.GaugeOpts{ Namespace: namespace, @@ -185,8 +234,9 @@ func New(gaugeFuncs GaugeFuncs) *Metrics { } // SetConfig sets the configuration-related metrics -func (m *Metrics) SetConfig(maxClients int, bandwidthBytesPerSecond int) { - m.MaxClients.Set(float64(maxClients)) +func (m *Metrics) SetConfig(maxCommonClients int, maxPersonalClients int, bandwidthBytesPerSecond int) { + m.MaxCommonClients.Set(float64(maxCommonClients)) + m.MaxPersonalClients.Set(float64(maxPersonalClients)) m.BandwidthLimit.Set(float64(bandwidthBytesPerSecond)) } @@ -224,6 +274,14 @@ func (m *Metrics) SetBytesDownloaded(bytes float64) { m.BytesDownloaded.Set(bytes) } +// SetRegionActivity sets accumulated per-region activity metrics. +func (m *Metrics) SetRegionActivity(scope, region string, bytesUp, bytesDown, connectingClients, connectedClients int64) { + m.RegionBytesUp.WithLabelValues(scope, region).Set(float64(bytesUp)) + m.RegionBytesDown.WithLabelValues(scope, region).Set(float64(bytesDown)) + m.RegionConnecting.WithLabelValues(scope, region).Set(float64(connectingClients)) + m.RegionConnected.WithLabelValues(scope, region).Set(float64(connectedClients)) +} + // StartServer starts the HTTP server for Prometheus metrics func (m *Metrics) StartServer(addr string) error { mux := http.NewServeMux() diff --git a/cli/internal/metrics/registry_test.go b/cli/internal/metrics/registry_test.go index 6d3bebf1..50a2879a 100644 --- a/cli/internal/metrics/registry_test.go +++ b/cli/internal/metrics/registry_test.go @@ -32,6 +32,7 @@ func TestRegistryWiring(t *testing.T) { GetUptimeSeconds: func() float64 { return 123 }, GetIdleSeconds: func() float64 { return 0 }, }) + m.SetRegionActivity("personal", "US", 1, 2, 3, 4) // gather registry metrics mfs, err := m.registry.Gather() @@ -56,10 +57,15 @@ func TestRegistryWiring(t *testing.T) { "conduit_connecting_clients", "conduit_connected_clients", "conduit_is_live", - "conduit_max_clients", + "conduit_max_common_clients", + "conduit_max_personal_clients", "conduit_bandwidth_limit_bytes_per_second", "conduit_bytes_uploaded", "conduit_bytes_downloaded", + "conduit_region_bytes_uploaded", + "conduit_region_bytes_downloaded", + "conduit_region_connecting_clients", + "conduit_region_connected_clients", "conduit_build_info", "conduit_uptime_seconds", "conduit_idle_seconds", diff --git a/cli/scripts/monitor/main.go b/cli/scripts/monitor/main.go index 2780b3ba..7d52bba7 100644 --- a/cli/scripts/monitor/main.go +++ b/cli/scripts/monitor/main.go @@ -317,9 +317,9 @@ func (s *Supervisor) Run() error { if isThrottled { log.Println("[INFO] Starting Conduit in THROTTLED mode") // Override flags for throttling - args = filterArgs(args, "--max-clients", "-m") + args = filterArgs(args, "--max-common-clients", "-m") args = filterArgs(args, "--bandwidth", "-b") - args = append(args, "--max-clients", fmt.Sprintf("%d", s.cfg.MinConnections)) + args = append(args, "--max-common-clients", fmt.Sprintf("%d", s.cfg.MinConnections)) args = append(args, "--bandwidth", fmt.Sprintf("%.0f", s.cfg.MinBandwidthMbps)) } else { log.Println("[INFO] Starting Conduit in NORMAL mode") From 2bbd1d8c9fb9e5e2b26aa2a03c6bcf86448c3d17 Mon Sep 17 00:00:00 2001 From: tmgrask Date: Fri, 20 Feb 2026 10:51:47 -0500 Subject: [PATCH 2/6] fix(cli): error on explicitly configured zero client limits instead of silently defaulting When an operator explicitly sets both max-common-clients and max-personal-clients to 0 (via flags, --set, or config file), fail with a clear error instead of silently overwriting common to 50. The default fallback is preserved only when nothing was explicitly configured. --- cli/internal/config/config.go | 21 +++++++++++++++------ cli/internal/config/config_test.go | 18 ++++++++++++++++++ 2 files changed, 33 insertions(+), 6 deletions(-) diff --git a/cli/internal/config/config.go b/cli/internal/config/config.go index 7f8540b5..61eb2c09 100644 --- a/cli/internal/config/config.go +++ b/cli/internal/config/config.go @@ -160,28 +160,38 @@ func LoadOrCreate(opts Options) (*Config, error) { // When only personal clients are configured and no common limit is specified, // preserve common=0 instead of defaulting to 50. maxCommonClients := 0 + commonClientsExplicit := false if opts.MaxCommonClientsSet { maxCommonClients = opts.MaxCommonClients + commonClientsExplicit = true } else if hasSetMaxCommonClients { maxCommonClients = setMaxCommonClients + commonClientsExplicit = true } else { switch { case inproxyConfig.InproxyMaxCommonClients != nil: maxCommonClients = *inproxyConfig.InproxyMaxCommonClients + commonClientsExplicit = true case inproxyConfig.InproxyMaxClients != nil: maxCommonClients = *inproxyConfig.InproxyMaxClients + commonClientsExplicit = true case inproxyConfig.InproxyMaxPersonalClients != nil && *inproxyConfig.InproxyMaxPersonalClients > 0: maxCommonClients = 0 + commonClientsExplicit = true } } maxPersonalClients := 0 + personalClientsExplicit := false if opts.MaxPersonalClientsSet { maxPersonalClients = opts.MaxPersonalClients + personalClientsExplicit = true } else if hasSetMaxPersonalClients { maxPersonalClients = setMaxPersonalClients + personalClientsExplicit = true } else if inproxyConfig.InproxyMaxPersonalClients != nil { maxPersonalClients = *inproxyConfig.InproxyMaxPersonalClients + personalClientsExplicit = true } compartmentID := "" @@ -207,11 +217,6 @@ func LoadOrCreate(opts Options) (*Config, error) { compartmentID = normalizedCompartmentID } - if maxCommonClients == 0 { - if maxPersonalClients == 0 { - maxCommonClients = DefaultMaxClients - } - } if maxCommonClients < 0 || maxCommonClients > MaxClientsLimit { return nil, fmt.Errorf("max-common-clients must be between 0 and %d", MaxClientsLimit) } @@ -219,7 +224,11 @@ func LoadOrCreate(opts Options) (*Config, error) { return nil, fmt.Errorf("max-personal-clients must be between 0 and %d", MaxClientsLimit) } if maxCommonClients+maxPersonalClients <= 0 { - return nil, fmt.Errorf("configured max clients must be > 0") + if commonClientsExplicit || personalClientsExplicit { + return nil, fmt.Errorf("at least one of --max-common-clients or --max-personal-clients must be greater than 0") + } + // Nothing was explicitly configured — apply the default. + maxCommonClients = DefaultMaxClients } if maxPersonalClients > 0 && strings.TrimSpace(compartmentID) == "" { return nil, fmt.Errorf("create compartment first with new-compartment-id command") diff --git a/cli/internal/config/config_test.go b/cli/internal/config/config_test.go index 7004db33..b5ce0c31 100644 --- a/cli/internal/config/config_test.go +++ b/cli/internal/config/config_test.go @@ -99,6 +99,24 @@ func TestLoadOrCreatePrecedence(t *testing.T) { expectedMaxPersonal: 0, expectedBandwidthBps: bandwidthBytes(DefaultBandwidthMbps), }, + { + name: "explicit_zero_common_zero_personal_errors", + configJSON: `{}`, + opts: Options{ + MaxCommonClients: 0, + MaxCommonClientsSet: true, + }, + expectedErrContains: "at least one of --max-common-clients or --max-personal-clients must be greater than 0", + }, + { + name: "config_both_zero_errors", + configJSON: `{ + "InproxyMaxCommonClients": 0, + "InproxyMaxPersonalClients": 0 +}`, + opts: Options{}, + expectedErrContains: "at least one of --max-common-clients or --max-personal-clients must be greater than 0", + }, } for _, test := range tests { From 3a80f70ee56799d26e247eeb1f1df9732cb2cf6f Mon Sep 17 00:00:00 2001 From: tmgrask Date: Fri, 20 Feb 2026 11:01:35 -0500 Subject: [PATCH 3/6] remove stats file now that we have prometheus --- cli/README.md | 210 ++++++++++++++++++++++---------- cli/cmd/start.go | 11 -- cli/internal/conduit/service.go | 46 ------- cli/internal/config/config.go | 3 - 4 files changed, 149 insertions(+), 121 deletions(-) diff --git a/cli/README.md b/cli/README.md index 1f842029..e558eee0 100644 --- a/cli/README.md +++ b/cli/README.md @@ -1,20 +1,43 @@ # Conduit CLI -Command-line interface for running a Psiphon Conduit node - a volunteer-run proxy that relays traffic for users in censored regions. +Command-line interface for running a Psiphon Conduit node, a volunteer-run proxy that relays traffic for users in censored regions. ## Quick Start -Want to run a Conduit station? Get the latest CLI release: https://github.com/Psiphon-Inc/conduit/releases +Want to run a Conduit station? Get the latest CLI release: +https://github.com/Psiphon-Inc/conduit/releases -Our official CLI releases include an embedded psiphon config. +Official CLI releases include an embedded Psiphon config. -Contact Psiphon (conduit-oss@psiphon.ca) to discuss custom configuration values. +Contact Psiphon (`conduit-oss@psiphon.ca`) to discuss custom configuration values. Conduit deployment guide: [GUIDE.md](./GUIDE.md) +### Common-only mode + +```bash +conduit start +``` + +### Personal compartment mode + +```bash +# Generate and persist a personal compartment ID, and print a share token +conduit new-compartment-id --name my-station + +# Enable personal clients (can be combined with common clients) +conduit start --max-common-clients 50 --max-personal-clients 10 +``` + +If you do not have `personal_compartment.json` in your data directory yet, you can also pass a compartment ID or share token directly: + +```bash +conduit start --max-personal-clients 10 --compartment-id "" +``` + ## Docker -Use the official Docker image, which includes an embedded Psiphon config. Docker Compose is a convenient way to run Conduit if you prefer a declarative setup. +Use the official Docker image, which includes an embedded Psiphon config. ```bash docker compose up @@ -22,97 +45,162 @@ docker compose up The compose file enables Prometheus metrics on `:9090` inside the container. To scrape from the host, publish the port or run Prometheus on the same Docker network and scrape `conduit:9090`. -## Building From Source +## Commands -```bash -# First time setup (clones required dependencies) -make setup +- `conduit start` - start the Conduit inproxy service +- `conduit new-compartment-id` - create and persist a personal compartment ID and output a share token +- `conduit ryve-claim` - output Conduit claim data for Ryve -# Build -make build +## Start Command Flags -# Run -./dist/conduit start --psiphon-config /path/to/psiphon_config.json -``` +| Flag | Default | Description | +| -------------------------- | ------- | --------------------------------------------------------------------------------- | +| `--psiphon-config, -c` | - | Path to Psiphon network config file (required when no embedded config is present) | +| `--max-common-clients, -m` | `50` | Maximum common proxy clients (`0-1000`) | +| `--max-personal-clients` | `0` | Maximum personal proxy clients (`0-1000`) | +| `--compartment-id` | - | Personal compartment ID or share token | +| `--bandwidth, -b` | `40` | Total bandwidth limit in Mbps (`-1` for unlimited) | +| `--set` | - | Override allowlisted config keys (`key=value`), repeatable | +| `--metrics-addr` | - | Prometheus metrics listen address (for example, `:9090`) | -## Requirements +Global flags: -- **Go 1.24.x** (Go 1.25+ is not supported due to psiphon-tls compatibility) -- Psiphon network configuration file (JSON) +- `--data-dir, -d` (default `./data`) +- `--verbose, -v` (repeatable count flag) -The Makefile will automatically install Go 1.24.12 if not present. +At least one of `--max-common-clients` or `--max-personal-clients` must be greater than `0`. -## Usage +## Personal Compartments -```bash -# Start with default settings -conduit start +When `--max-personal-clients` is greater than 0, Conduit needs a personal compartment ID. + +Use `conduit new-compartment-id` to: + +1. Generate a personal compartment ID. +2. Save it to `personal_compartment.json` in your data directory. +3. Print a v1 share token (for pairing). -# Customize limits -conduit start --max-common-clients 20 --bandwidth 10 +You can provide `--compartment-id` as either: -# Verbose output (info messages) -conduit start -v +- a raw compartment ID, or +- a share token (the CLI extracts and validates the ID) + +## `--set` Overrides + +`--set` supports a strict allowlist of config keys: + +- `EmitDiagnosticNotices` +- `EmitInproxyProxyActivity` +- `InproxyLimitDownstreamBytesPerSecond` +- `InproxyLimitUpstreamBytesPerSecond` +- `InproxyMaxClients` +- `InproxyMaxCommonClients` +- `InproxyMaxPersonalClients` +- `InproxyProxyPersonalCompartmentID` +- `InproxyReducedEndTime` +- `InproxyReducedLimitDownstreamBytesPerSecond` +- `InproxyReducedLimitUpstreamBytesPerSecond` +- `InproxyReducedMaxCommonClients` +- `InproxyReducedStartTime` + +Example: + +```bash +conduit start \ + --set EmitInproxyProxyActivity=true \ + --set InproxyReducedMaxCommonClients=10 ``` -### Options +## Metrics + +Enable metrics with `--metrics-addr`, then scrape `/metrics`. -| Flag | Default | Description | -| ---------------------- | -------- | ------------------------------------------ | -| `--psiphon-config, -c` | - | Path to Psiphon network configuration file | -| `--max-common-clients, -m` | 50 | Maximum concurrent common clients | -| `--bandwidth, -b` | 40 | Bandwidth limit per peer in Mbps | -| `--data-dir, -d` | `./data` | Directory for keys and state | -| `--metrics-addr` | - | Prometheus metrics listen address | -| `-v` | - | Verbose output | +The CLI exports core gauges such as: + +- `conduit_announcing` +- `conduit_connecting_clients` +- `conduit_connected_clients` +- `conduit_is_live` +- `conduit_max_common_clients` +- `conduit_max_personal_clients` +- `conduit_bytes_uploaded` +- `conduit_bytes_downloaded` + +It also exports per-region activity gauges labeled by `scope` (`common` or `personal`) and `region`: + +- `conduit_region_bytes_uploaded` +- `conduit_region_bytes_downloaded` +- `conduit_region_connecting_clients` +- `conduit_region_connected_clients` ## Traffic Throttling -For bandwidth-constrained environments (e.g., VPS with monthly quotas), Conduit supports automatic throttling via a separate supervisor monitor. +For bandwidth-constrained environments (for example, VPS with monthly quotas), Conduit supports automatic throttling via the `conduit-monitor` supervisor. -To use traffic throttling with Docker, use the `limited-bandwidth` compose file: +To use throttling with Docker: ```bash docker compose -f docker-compose.limited-bandwidth.yml up -d ``` -### Configuration - -Edit `docker-compose.limited-bandwidth.yml` to set your limits: +Edit `docker-compose.limited-bandwidth.yml` to set limits: ```yaml -command: - [ - "--traffic-limit", "500", # Total quota in GB - "--traffic-period", "30", # Time period in days - "--bandwidth-threshold", "80", # Throttle at 80% usage - "--min-connections", "10", # Reduced capacity when throttled - "--min-bandwidth", "10", # Reduced bandwidth when throttled - "--", # Separator - "start", # Conduit command - ... # Conduit flags +command: [ + "--traffic-limit", + "500", # Total quota in GB + "--traffic-period", + "30", # Time period in days + "--bandwidth-threshold", + "80", # Throttle at 80% usage + "--min-connections", + "10", # Reduced common clients when throttled + "--min-bandwidth", + "10", # Reduced bandwidth when throttled + "--", # Separator + "start", # Conduit command + ..., # Conduit flags ] ``` -### How It Works +How it works: -The supervisor monitors bandwidth usage and: -1. Runs Conduit at full capacity initially. -2. When the threshold is reached (e.g., 400GB of 500GB), it restarts Conduit with reduced capacity. -3. When the period ends, it resets usage and restarts Conduit at full capacity. -4. Ensures minimum limits (100GB/7days) to protect reputation. +1. Runs Conduit at full capacity. +2. When threshold is reached, restarts Conduit with reduced capacity. +3. At period end, resets usage and restores normal capacity. +4. Enforces minimum limits (`100GB` / `7 days`) to protect reputation. ## Data Directory 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. +- `conduit_key.json` - node identity keypair +- `personal_compartment.json` - persisted personal compartment ID +- `traffic_state.json` - traffic usage state (when throttling is enabled) + +The Psiphon broker tracks proxy reputation by key. Always use persistent storage for your data directory so your key and reputation survive restarts. + +## Building From Source + +```bash +# First time setup (clones required dependencies) +make setup + +# Build Conduit +make build + +# Run (when not using embedded config) +./dist/conduit start --psiphon-config /path/to/psiphon_config.json +``` + +## Requirements + +- Go `1.24.x` (Go `1.25+` is not supported due to `psiphon-tls` compatibility) +- Psiphon network configuration file (JSON), unless using an embedded-config build -- `traffic_state.json` - Traffic usage tracking (when throttling is enabled) - Tracks current period start time, bytes used, and throttle state. Persists across restarts. +The Makefile automatically installs Go `1.24.12` if not present. -## Building +## Build Targets ```bash # Build for current platform diff --git a/cli/cmd/start.go b/cli/cmd/start.go index fa3c1603..261a1c6f 100644 --- a/cli/cmd/start.go +++ b/cli/cmd/start.go @@ -24,7 +24,6 @@ import ( "fmt" "os" "os/signal" - "path/filepath" "syscall" "github.com/Psiphon-Inc/conduit/cli/internal/conduit" @@ -40,7 +39,6 @@ var ( setOverrides []string bandwidthMbps float64 psiphonConfigPath string - statsFilePath string metricsAddr string ) @@ -69,8 +67,6 @@ func init() { startCmd.Flags().StringVar(&compartmentID, "compartment-id", "", "personal compartment ID or share token") startCmd.Flags().StringArrayVar(&setOverrides, "set", nil, "override allowed config values (key=value), repeatable") 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().StringVar(&metricsAddr, "metrics-addr", "", "address for Prometheus metrics endpoint (e.g., :9090 or 127.0.0.1:9090)") startCmd.Flags().StringVarP(&psiphonConfigPath, "psiphon-config", "c", "", "path to Psiphon network config file (JSON)") } @@ -93,12 +89,6 @@ func runStart(cmd *cobra.Command, args []string) error { return fmt.Errorf("psiphon config required: use --psiphon-config flag or build with embedded config") } - // Resolve stats file path - if relative, place in data dir - resolvedStatsFile := statsFilePath - if resolvedStatsFile != "" && !filepath.IsAbs(resolvedStatsFile) { - resolvedStatsFile = filepath.Join(GetDataDir(), resolvedStatsFile) - } - maxCommonClientsFromFlag := 0 maxCommonClientsFromFlagSet := cmd.Flags().Changed("max-common-clients") if maxCommonClientsFromFlagSet { @@ -157,7 +147,6 @@ func runStart(cmd *cobra.Command, args []string) error { BandwidthMbps: bandwidthFromFlag, BandwidthSet: bandwidthFromFlagSet, Verbosity: Verbosity(), - StatsFile: resolvedStatsFile, MetricsAddr: metricsAddr, }) if err != nil { diff --git a/cli/internal/conduit/service.go b/cli/internal/conduit/service.go index b9b59038..e8163c82 100644 --- a/cli/internal/conduit/service.go +++ b/cli/internal/conduit/service.go @@ -79,19 +79,6 @@ type Stats struct { IsLive bool // Connected to broker and ready to accept clients } -// StatsJSON represents the JSON structure for persisted stats -type StatsJSON struct { - Announcing int `json:"announcing"` - ConnectingClients int `json:"connectingClients"` - ConnectedClients int `json:"connectedClients"` - TotalBytesUp int64 `json:"totalBytesUp"` - TotalBytesDown int64 `json:"totalBytesDown"` - UptimeSeconds int64 `json:"uptimeSeconds"` - IdleSeconds int64 `json:"idleSeconds"` - IsLive bool `json:"isLive"` - Timestamp string `json:"timestamp"` -} - // New creates a new Conduit service func New(cfg *config.Config) (*Service, error) { s := &Service{ @@ -475,22 +462,6 @@ func (s *Service) logStats() { formatDuration(uptime), regionSummary, ) - - // Write stats to file if configured (copy data while locked, write async) - if s.config.StatsFile != "" { - statsJSON := StatsJSON{ - Announcing: s.stats.Announcing, - ConnectingClients: s.stats.ConnectingClients, - ConnectedClients: s.stats.ConnectedClients, - TotalBytesUp: s.stats.TotalBytesUp, - TotalBytesDown: s.stats.TotalBytesDown, - UptimeSeconds: int64(time.Since(s.stats.StartTime).Seconds()), - IdleSeconds: int64(s.calcIdleSeconds()), - IsLive: s.stats.IsLive, - Timestamp: time.Now().Format(time.RFC3339), - } - go s.writeStatsToFile(statsJSON) - } } // syncSnapshotLocked updates atomic snapshot fields. Must be called with lock held. @@ -677,23 +648,6 @@ func formatBytes(bytes int64) string { return fmt.Sprintf("%.1f %cB", float64(bytes)/float64(div), "KMGTPE"[exp]) } -// writeStatsToFile writes stats to the configured JSON file asynchronously -func (s *Service) writeStatsToFile(statsJSON StatsJSON) { - data, err := json.MarshalIndent(statsJSON, "", " ") - if err != nil { - if s.config.Verbosity >= 1 { - logging.Printf("[ERROR] Failed to marshal stats: %v\n", err) - } - return - } - - if err := os.WriteFile(s.config.StatsFile, data, 0644); err != nil { - if s.config.Verbosity >= 1 { - logging.Printf("[ERROR] Failed to write stats file: %v\n", err) - } - } -} - // formatDuration formats duration in a human-readable way func formatDuration(d time.Duration) string { h := d / time.Hour diff --git a/cli/internal/config/config.go b/cli/internal/config/config.go index 61eb2c09..2068e294 100644 --- a/cli/internal/config/config.go +++ b/cli/internal/config/config.go @@ -59,7 +59,6 @@ type Options struct { BandwidthMbps float64 BandwidthSet bool Verbosity int // 0=normal, 1+=verbose - StatsFile string // Path to write stats JSON file (empty = disabled) MetricsAddr string // Address for Prometheus metrics endpoint (empty = disabled) } @@ -76,7 +75,6 @@ type Config struct { PsiphonConfigPath string PsiphonConfigData []byte // Embedded config data (if used) Verbosity int // 0=normal, 1+=verbose - StatsFile string // Path to write stats JSON file (empty = disabled) MetricsAddr string // Address for Prometheus metrics endpoint (empty = disabled) } @@ -285,7 +283,6 @@ func LoadOrCreate(opts Options) (*Config, error) { PsiphonConfigPath: opts.PsiphonConfigPath, PsiphonConfigData: psiphonConfigData, Verbosity: opts.Verbosity, - StatsFile: opts.StatsFile, MetricsAddr: opts.MetricsAddr, }, nil } From d72824c0de92e10c4acdb2bbe9f35ca8679b6c30 Mon Sep 17 00:00:00 2001 From: tmgrask Date: Fri, 20 Feb 2026 11:32:15 -0500 Subject: [PATCH 4/6] compartment name ux --- cli/README.md | 2 ++ cli/cmd/new_compartment_id.go | 20 ++++++++++++++++++-- cli/internal/config/compartment.go | 14 ++++++++++++-- cli/internal/config/compartment_test.go | 15 +++++++++++++++ 4 files changed, 47 insertions(+), 4 deletions(-) diff --git a/cli/README.md b/cli/README.md index e558eee0..58ad132f 100644 --- a/cli/README.md +++ b/cli/README.md @@ -80,6 +80,8 @@ Use `conduit new-compartment-id` to: 2. Save it to `personal_compartment.json` in your data directory. 3. Print a v1 share token (for pairing). +`--name` is limited to 32 characters. + You can provide `--compartment-id` as either: - a raw compartment ID, or diff --git a/cli/cmd/new_compartment_id.go b/cli/cmd/new_compartment_id.go index 6928efc2..a83eef64 100644 --- a/cli/cmd/new_compartment_id.go +++ b/cli/cmd/new_compartment_id.go @@ -3,14 +3,18 @@ package cmd import ( "fmt" "os" + "strings" + "unicode/utf8" "github.com/Psiphon-Inc/conduit/cli/internal/config" + "github.com/Psiphon-Inc/conduit/cli/internal/logging" "github.com/spf13/cobra" ) var ( compartmentNameDefault string compartmentName string + defaultNameFromHost bool ) var newCompartmentIDCmd = &cobra.Command{ @@ -24,15 +28,27 @@ func init() { compartmentNameDefault = "conduit" if host, err := os.Hostname(); err == nil && host != "" { compartmentNameDefault = host + defaultNameFromHost = true } rootCmd.AddCommand(newCompartmentIDCmd) - newCompartmentIDCmd.Flags().StringVarP(&compartmentName, "name", "n", compartmentNameDefault, "display name in share token") + newCompartmentIDCmd.Flags().StringVarP(&compartmentName, "name", "n", compartmentNameDefault, "display name in share token (max 32 chars)") } func runNewCompartmentID(cmd *cobra.Command, args []string) error { dataDir := GetDataDir() + name := strings.TrimSpace(compartmentName) + nameSetByUser := cmd.Flags().Changed("name") + + if !nameSetByUser && defaultNameFromHost { + logging.Printf("[INFO] Defaulting --name to hostname %q (use --name to override)\n", name) + } + + if utf8.RuneCountInString(name) > config.PersonalPairingNameMaxLength { + return fmt.Errorf("--name must be at most %d characters", config.PersonalPairingNameMaxLength) + } + compartmentID, err := config.GeneratePersonalCompartmentID() if err != nil { return err @@ -42,7 +58,7 @@ func runNewCompartmentID(cmd *cobra.Command, args []string) error { return err } - shareToken, err := config.BuildPersonalPairingToken(compartmentID, compartmentName) + shareToken, err := config.BuildPersonalPairingToken(compartmentID, name) if err != nil { return err } diff --git a/cli/internal/config/compartment.go b/cli/internal/config/compartment.go index 77584c9e..8c61325b 100644 --- a/cli/internal/config/compartment.go +++ b/cli/internal/config/compartment.go @@ -8,9 +8,15 @@ import ( "os" "path/filepath" "strings" + "unicode/utf8" ) -const personalCompartmentIDByteLength = 32 +const ( + personalCompartmentIDByteLength = 32 + // PersonalPairingNameMaxLength is the maximum allowed display name length + // in personal pairing tokens. + PersonalPairingNameMaxLength = 32 +) type personalPairingTokenData struct { ID string `json:"id"` @@ -88,9 +94,13 @@ func BuildPersonalPairingToken(id, name string) (string, error) { if err := ValidatePersonalCompartmentID(id); err != nil { return "", err } - if strings.TrimSpace(name) == "" { + name = strings.TrimSpace(name) + if name == "" { return "", fmt.Errorf("name must be non-empty") } + if utf8.RuneCountInString(name) > PersonalPairingNameMaxLength { + return "", fmt.Errorf("name must be at most %d characters", PersonalPairingNameMaxLength) + } payload := personalPairingTokenPayload{ V: "1", diff --git a/cli/internal/config/compartment_test.go b/cli/internal/config/compartment_test.go index 34685cff..3a6a36bc 100644 --- a/cli/internal/config/compartment_test.go +++ b/cli/internal/config/compartment_test.go @@ -81,6 +81,21 @@ func TestNormalizePersonalCompartmentInputSupportsToken(t *testing.T) { } } +func TestBuildPersonalPairingTokenRejectsLongName(t *testing.T) { + id, err := GeneratePersonalCompartmentID() + if err != nil { + t.Fatalf("GeneratePersonalCompartmentID: %v", err) + } + + _, err = BuildPersonalPairingToken(id, strings.Repeat("a", PersonalPairingNameMaxLength+1)) + if err == nil { + t.Fatalf("expected error for long name") + } + if !strings.Contains(err.Error(), "at most") { + t.Fatalf("unexpected error: %v", err) + } +} + func TestSaveAndLoadPersonalCompartmentID(t *testing.T) { id, err := GeneratePersonalCompartmentID() if err != nil { From 6c47120a39e042de1c7a0011d80f118610e4dabc Mon Sep 17 00:00:00 2001 From: tmgrask Date: Fri, 20 Feb 2026 12:55:43 -0500 Subject: [PATCH 5/6] periodic log when bytes transferred is changing --- cli/internal/conduit/service.go | 33 +++++++++++++++++++++++---------- 1 file changed, 23 insertions(+), 10 deletions(-) diff --git a/cli/internal/conduit/service.go b/cli/internal/conduit/service.go index e8163c82..8c3a247f 100644 --- a/cli/internal/conduit/service.go +++ b/cli/internal/conduit/service.go @@ -42,6 +42,7 @@ type Service struct { config *config.Config controller *psiphon.Controller stats *Stats + lastStatsLogAt time.Time regionActivityTotals map[string]map[string]RegionActivityTotals metrics *metrics.Metrics mu sync.RWMutex @@ -56,6 +57,7 @@ const ( regionScopePersonal = "personal" regionScopeCommon = "common" maxLoggedRegionsPerScope = 3 + bytesProgressLogInterval = 5 * time.Second ) // RegionActivityTotals tracks accumulated per-region activity from @@ -328,6 +330,8 @@ func (s *Service) handleNotice(notice []byte) { prevAnnouncing := s.stats.Announcing prevConnecting := s.stats.ConnectingClients prevConnected := s.stats.ConnectedClients + prevBytesUp := s.stats.TotalBytesUp + prevBytesDown := s.stats.TotalBytesDown now := time.Now() if v, ok := int64FromValue(noticeData.Data["announcing"]); ok { s.stats.Announcing = int(v) @@ -364,9 +368,13 @@ func (s *Service) handleNotice(notice []byte) { } } - // Log when announcing/connectivity state changes. - if s.stats.Announcing != prevAnnouncing || s.stats.ConnectingClients != prevConnecting || s.stats.ConnectedClients != prevConnected { - s.logStats() + stateChanged := s.stats.Announcing != prevAnnouncing || s.stats.ConnectingClients != prevConnecting || s.stats.ConnectedClients != prevConnected + bytesChanged := s.stats.TotalBytesUp != prevBytesUp || s.stats.TotalBytesDown != prevBytesDown + + // Log when announcing/connectivity changes, and periodically while + // bytes are changing so transfer progress stays visible. + if stateChanged || (bytesChanged && (s.lastStatsLogAt.IsZero() || now.Sub(s.lastStatsLogAt) >= bytesProgressLogInterval)) { + s.logStats(now) } s.syncSnapshotLocked() @@ -380,6 +388,9 @@ func (s *Service) handleNotice(notice []byte) { prevAnnouncing := s.stats.Announcing prevConnecting := s.stats.ConnectingClients prevConnected := s.stats.ConnectedClients + prevBytesUp := s.stats.TotalBytesUp + prevBytesDown := s.stats.TotalBytesDown + now := time.Now() if v, ok := int64FromValue(noticeData.Data["announcing"]); ok { s.stats.Announcing = int(v) } @@ -398,7 +409,6 @@ func (s *Service) handleNotice(notice []byte) { // Track last active time for idle calculation if s.stats.ConnectingClients > 0 || s.stats.ConnectedClients > 0 { - now := time.Now() s.stats.LastActiveTime = now s.lastActiveUnixNano.Store(now.UnixNano()) } @@ -410,9 +420,11 @@ func (s *Service) handleNotice(notice []byte) { } } - // Log when announcing/connectivity state changes. - if s.stats.Announcing != prevAnnouncing || s.stats.ConnectingClients != prevConnecting || s.stats.ConnectedClients != prevConnected { - s.logStats() + stateChanged := s.stats.Announcing != prevAnnouncing || s.stats.ConnectingClients != prevConnecting || s.stats.ConnectedClients != prevConnected + bytesChanged := s.stats.TotalBytesUp != prevBytesUp || s.stats.TotalBytesDown != prevBytesDown + + if stateChanged || (bytesChanged && (s.lastStatsLogAt.IsZero() || now.Sub(s.lastStatsLogAt) >= bytesProgressLogInterval)) { + s.logStats(now) } s.syncSnapshotLocked() @@ -449,11 +461,12 @@ func (s *Service) handleNotice(notice []byte) { } // logStats logs the current proxy statistics (must be called with lock held) -func (s *Service) logStats() { - uptime := time.Since(s.stats.StartTime).Truncate(time.Second) +func (s *Service) logStats(now time.Time) { + s.lastStatsLogAt = now + uptime := now.Sub(s.stats.StartTime).Truncate(time.Second) regionSummary := s.formatRegionActivityTotalsLocked() fmt.Printf("%s [STATS] Announcing: %d | Connecting: %d | Connected: %d | Up: %s | Down: %s | Uptime: %s | Regions: %s\n", - time.Now().Format("2006-01-02 15:04:05"), + now.Format("2006-01-02 15:04:05"), s.stats.Announcing, s.stats.ConnectingClients, s.stats.ConnectedClients, From 0fd6f0c0cc2d65f93d45faf9908e257b4b93e8e2 Mon Sep 17 00:00:00 2001 From: tmgrask Date: Fri, 20 Feb 2026 13:16:16 -0500 Subject: [PATCH 6/6] remove unused func --- cli/internal/conduit/service.go | 11 ----------- 1 file changed, 11 deletions(-) diff --git a/cli/internal/conduit/service.go b/cli/internal/conduit/service.go index 8c3a247f..317f3d65 100644 --- a/cli/internal/conduit/service.go +++ b/cli/internal/conduit/service.go @@ -301,17 +301,6 @@ func (s *Service) getIdleSecondsFloat() float64 { return time.Since(time.Unix(0, lastActive)).Seconds() } -// calcIdleSeconds calculates idle time. Must be called with lock held. -func (s *Service) calcIdleSeconds() float64 { - if s.stats.ConnectingClients > 0 || s.stats.ConnectedClients > 0 { - return 0 - } - if s.stats.LastActiveTime.IsZero() { - return time.Since(s.stats.StartTime).Seconds() - } - return time.Since(s.stats.LastActiveTime).Seconds() -} - // handleNotice processes notices from psiphon-tunnel-core func (s *Service) handleNotice(notice []byte) { var noticeData struct {