Browse Source

Merge branch 'master' into staging-server

Rod Hynes 3 weeks ago
parent
commit
0dc1469975
56 changed files with 2454 additions and 337 deletions
  1. 12 10
      .github/workflows/tests.yml
  2. 2 2
      ClientLibrary/make.bash
  3. 19 3
      ConsoleClient/main.go
  4. 70 2
      MobileLibrary/Android/PsiphonTunnel/PsiphonTunnel.java
  5. 2 2
      MobileLibrary/Android/make.bash
  6. 18 1
      MobileLibrary/iOS/PsiphonTunnel/PsiphonTunnel/PsiphonTunnel.h
  7. 25 5
      MobileLibrary/iOS/PsiphonTunnel/PsiphonTunnel/PsiphonTunnel.m
  8. 21 1
      MobileLibrary/psi/psi.go
  9. 5 5
      go.mod
  10. 2 8
      go.sum
  11. 54 29
      psiphon/common/inproxy/api.go
  12. 72 9
      psiphon/common/inproxy/broker.go
  13. 1 1
      psiphon/common/inproxy/discoverySTUN.go
  14. 42 17
      psiphon/common/inproxy/discovery_test.go
  15. 0 4
      psiphon/common/inproxy/doc.go
  16. 2 2
      psiphon/common/inproxy/inproxy_disabled.go
  17. 1 1
      psiphon/common/inproxy/inproxy_enabled.go
  18. 138 13
      psiphon/common/inproxy/inproxy_test.go
  19. 44 7
      psiphon/common/inproxy/matcher.go
  20. 21 2
      psiphon/common/inproxy/matcher_test.go
  21. 1 1
      psiphon/common/inproxy/portmapper.go
  22. 1 1
      psiphon/common/inproxy/portmapper_android.go
  23. 1 1
      psiphon/common/inproxy/portmapper_other.go
  24. 516 101
      psiphon/common/inproxy/proxy.go
  25. 111 0
      psiphon/common/inproxy/reduced_test.go
  26. 1 1
      psiphon/common/inproxy/sdp_test.go
  27. 7 2
      psiphon/common/inproxy/session.go
  28. 1 1
      psiphon/common/inproxy/webrtc.go
  29. 4 0
      psiphon/common/parameters/parameters.go
  30. 6 0
      psiphon/common/protocol/protocol.go
  31. 1 3
      psiphon/common/protocol/serverEntry.go
  32. 16 0
      psiphon/common/push/converter/README.md
  33. 229 0
      psiphon/common/push/converter/main.go
  34. 332 0
      psiphon/common/push/push.go
  35. 250 0
      psiphon/common/push/push_test.go
  36. 10 0
      psiphon/common/utils.go
  37. 137 5
      psiphon/config.go
  38. 137 48
      psiphon/controller.go
  39. 4 3
      psiphon/controller_test.go
  40. 22 0
      psiphon/inproxy.go
  41. 11 2
      psiphon/notice.go
  42. 1 1
      psiphon/server/config.go
  43. 7 2
      psiphon/server/destBytes_test.go
  44. 2 1
      psiphon/server/meek.go
  45. 13 3
      psiphon/server/pb/psiphond/inproxy_broker.pb.go
  46. 1 0
      psiphon/server/proto/ca.psiphon.psiphond/inproxy_broker.proto
  47. 9 7
      psiphon/server/server_test.go
  48. 14 7
      psiphon/server/tunnelServer.go
  49. 3 5
      replace/dtls/pkg/crypto/ciphersuite/ccm.go
  50. 19 0
      replace/dtls/pkg/crypto/ciphersuite/ciphersuite.go
  51. 3 6
      replace/dtls/pkg/crypto/ciphersuite/gcm.go
  52. 7 0
      vendor/github.com/Psiphon-Labs/utls/handshake_client.go
  53. 3 5
      vendor/github.com/pion/dtls/v2/pkg/crypto/ciphersuite/ccm.go
  54. 19 0
      vendor/github.com/pion/dtls/v2/pkg/crypto/ciphersuite/ciphersuite.go
  55. 3 6
      vendor/github.com/pion/dtls/v2/pkg/crypto/ciphersuite/gcm.go
  56. 1 1
      vendor/modules.txt

+ 12 - 10
.github/workflows/tests.yml

@@ -81,7 +81,7 @@ jobs:
           go test -v -race ./psiphon/common/crypto/ssh
           go test -v -race ./psiphon/common/crypto/ssh
           go test -v -race ./psiphon/common/dsl
           go test -v -race ./psiphon/common/dsl
           go test -v -race ./psiphon/common/fragmentor
           go test -v -race ./psiphon/common/fragmentor
-          go test -v -race -tags "PSIPHON_ENABLE_INPROXY" ./psiphon/common/inproxy
+          go test -v -race ./psiphon/common/inproxy
           go test -v -race ./psiphon/common/regen
           go test -v -race ./psiphon/common/regen
           go test -v -race ./psiphon/common/monotime
           go test -v -race ./psiphon/common/monotime
           go test -v -race ./psiphon/common/obfuscator
           go test -v -race ./psiphon/common/obfuscator
@@ -89,6 +89,7 @@ jobs:
           sudo -E env "PATH=$PATH" go test -v -race -tags "PSIPHON_RUN_PACKET_MANIPULATOR_TEST" ./psiphon/common/packetman
           sudo -E env "PATH=$PATH" go test -v -race -tags "PSIPHON_RUN_PACKET_MANIPULATOR_TEST" ./psiphon/common/packetman
           go test -v -race ./psiphon/common/parameters
           go test -v -race ./psiphon/common/parameters
           go test -v -race ./psiphon/common/protocol
           go test -v -race ./psiphon/common/protocol
+          go test -v -race ./psiphon/common/push
           go test -v -race ./psiphon/common/quic
           go test -v -race ./psiphon/common/quic
           go test -v -race ./psiphon/common/resolver
           go test -v -race ./psiphon/common/resolver
           go test -v -race ./psiphon/common/tactics
           go test -v -race ./psiphon/common/tactics
@@ -96,7 +97,7 @@ jobs:
           go test -v -race ./psiphon/common/values
           go test -v -race ./psiphon/common/values
           go test -v -race ./psiphon/common/wildcard
           go test -v -race ./psiphon/common/wildcard
           go test -v -race ./psiphon/transferstats
           go test -v -race ./psiphon/transferstats
-          sudo -E env "PATH=$PATH" go test -v -timeout 30m -race -tags "PSIPHON_ENABLE_INPROXY PSIPHON_RUN_PACKET_MANIPULATOR_TEST" ./psiphon/server
+          sudo -E env "PATH=$PATH" go test -v -timeout 30m -race -tags "PSIPHON_RUN_PACKET_MANIPULATOR_TEST" ./psiphon/server
           go test -v -race ./psiphon/server/psinet
           go test -v -race ./psiphon/server/psinet
           go test -v -timeout 30m -race ./psiphon
           go test -v -timeout 30m -race ./psiphon
           go test -v -race ./ClientLibrary/clientlib
           go test -v -race ./ClientLibrary/clientlib
@@ -116,7 +117,7 @@ jobs:
           go test -v -covermode=count -coverprofile=ssh.coverprofile ./psiphon/common/crypto/ssh
           go test -v -covermode=count -coverprofile=ssh.coverprofile ./psiphon/common/crypto/ssh
           go test -v -covermode=count -coverprofile=fragmentor.coverprofile ./psiphon/common/fragmentor
           go test -v -covermode=count -coverprofile=fragmentor.coverprofile ./psiphon/common/fragmentor
           go test -v -covermode=count -coverprofile=dsl.coverprofile ./psiphon/common/dsl
           go test -v -covermode=count -coverprofile=dsl.coverprofile ./psiphon/common/dsl
-          go test -v -covermode=count -tags "PSIPHON_ENABLE_INPROXY" -coverprofile=inproxy.coverprofile ./psiphon/common/inproxy
+          go test -v -covermode=count -coverprofile=inproxy.coverprofile ./psiphon/common/inproxy
           go test -v -covermode=count -coverprofile=regen.coverprofile ./psiphon/common/regen
           go test -v -covermode=count -coverprofile=regen.coverprofile ./psiphon/common/regen
           go test -v -covermode=count -coverprofile=monotime.coverprofile ./psiphon/common/monotime
           go test -v -covermode=count -coverprofile=monotime.coverprofile ./psiphon/common/monotime
           go test -v -covermode=count -coverprofile=obfuscator.coverprofile ./psiphon/common/obfuscator
           go test -v -covermode=count -coverprofile=obfuscator.coverprofile ./psiphon/common/obfuscator
@@ -124,6 +125,7 @@ jobs:
           sudo -E env "PATH=$PATH" go test -v -covermode=count -coverprofile=packetman.coverprofile -tags "PSIPHON_RUN_PACKET_MANIPULATOR_TEST" ./psiphon/common/packetman
           sudo -E env "PATH=$PATH" go test -v -covermode=count -coverprofile=packetman.coverprofile -tags "PSIPHON_RUN_PACKET_MANIPULATOR_TEST" ./psiphon/common/packetman
           go test -v -covermode=count -coverprofile=parameters.coverprofile ./psiphon/common/parameters
           go test -v -covermode=count -coverprofile=parameters.coverprofile ./psiphon/common/parameters
           go test -v -covermode=count -coverprofile=protocol.coverprofile ./psiphon/common/protocol
           go test -v -covermode=count -coverprofile=protocol.coverprofile ./psiphon/common/protocol
+          go test -v -covermode=count -coverprofile=push.coverprofile ./psiphon/common/push
           go test -v -covermode=count -coverprofile=quic.coverprofile ./psiphon/common/quic
           go test -v -covermode=count -coverprofile=quic.coverprofile ./psiphon/common/quic
           go test -v -covermode=count -coverprofile=resolver.coverprofile ./psiphon/common/resolver
           go test -v -covermode=count -coverprofile=resolver.coverprofile ./psiphon/common/resolver
           go test -v -covermode=count -coverprofile=tactics.coverprofile ./psiphon/common/tactics
           go test -v -covermode=count -coverprofile=tactics.coverprofile ./psiphon/common/tactics
@@ -131,7 +133,7 @@ jobs:
           go test -v -covermode=count -coverprofile=values.coverprofile ./psiphon/common/values
           go test -v -covermode=count -coverprofile=values.coverprofile ./psiphon/common/values
           go test -v -covermode=count -coverprofile=wildcard.coverprofile ./psiphon/common/wildcard
           go test -v -covermode=count -coverprofile=wildcard.coverprofile ./psiphon/common/wildcard
           go test -v -covermode=count -coverprofile=transferstats.coverprofile ./psiphon/transferstats
           go test -v -covermode=count -coverprofile=transferstats.coverprofile ./psiphon/transferstats
-          sudo -E env "PATH=$PATH" go test -v -timeout 30m -covermode=count -coverprofile=server.coverprofile -tags "PSIPHON_ENABLE_INPROXY PSIPHON_RUN_PACKET_MANIPULATOR_TEST" ./psiphon/server
+          sudo -E env "PATH=$PATH" go test -v -timeout 30m -covermode=count -coverprofile=server.coverprofile -tags "PSIPHON_RUN_PACKET_MANIPULATOR_TEST" ./psiphon/server
           go test -v -covermode=count -coverprofile=psinet.coverprofile ./psiphon/server/psinet
           go test -v -covermode=count -coverprofile=psinet.coverprofile ./psiphon/server/psinet
           go test -v -timeout 30m -covermode=count -coverprofile=psiphon.coverprofile ./psiphon
           go test -v -timeout 30m -covermode=count -coverprofile=psiphon.coverprofile ./psiphon
           go test -v -covermode=count -coverprofile=clientlib.coverprofile ./ClientLibrary/clientlib
           go test -v -covermode=count -coverprofile=clientlib.coverprofile ./ClientLibrary/clientlib
@@ -144,7 +146,7 @@ jobs:
         if: ${{ matrix.test-type == 'protobuf' }}
         if: ${{ matrix.test-type == 'protobuf' }}
         run: |
         run: |
           cd ${{ github.workspace }}/go/src/github.com/Psiphon-Labs/psiphon-tunnel-core
           cd ${{ github.workspace }}/go/src/github.com/Psiphon-Labs/psiphon-tunnel-core
-          sudo -E env "PATH=$PATH" go test -v -timeout 30m -race -tags "PSIPHON_RUN_PROTOBUF_LOGGING_TEST PSIPHON_ENABLE_INPROXY PSIPHON_RUN_PACKET_MANIPULATOR_TEST" ./psiphon/server
+          sudo -E env "PATH=$PATH" go test -v -timeout 30m -race -tags "PSIPHON_RUN_PROTOBUF_LOGGING_TEST PSIPHON_RUN_PACKET_MANIPULATOR_TEST" ./psiphon/server
 
 
       - name: Run memory tests
       - name: Run memory tests
         if: ${{ matrix.test-type == 'memory' }}
         if: ${{ matrix.test-type == 'memory' }}
@@ -158,7 +160,7 @@ jobs:
         run: |
         run: |
           cd ${{ github.workspace }}/go/src/github.com/Psiphon-Labs/psiphon-tunnel-core/ConsoleClient
           cd ${{ github.workspace }}/go/src/github.com/Psiphon-Labs/psiphon-tunnel-core/ConsoleClient
           go build -a -v -tags ""
           go build -a -v -tags ""
-          go build -a -v -tags "PSIPHON_ENABLE_INPROXY"
+          go build -a -v -tags "PSIPHON_DISABLE_INPROXY"
           go build -a -v -tags "PSIPHON_DISABLE_QUIC"
           go build -a -v -tags "PSIPHON_DISABLE_QUIC"
           go build -a -v -tags "PSIPHON_DISABLE_GQUIC"
           go build -a -v -tags "PSIPHON_DISABLE_GQUIC"
           go build -a -v -tags "PSIPHON_ENABLE_REFRACTION_NETWORKING"
           go build -a -v -tags "PSIPHON_ENABLE_REFRACTION_NETWORKING"
@@ -176,13 +178,13 @@ jobs:
         if: ${{ matrix.test-type == 'code-vetting' }}
         if: ${{ matrix.test-type == 'code-vetting' }}
         run: |
         run: |
           cd ${{ github.workspace }}/go/src/github.com/Psiphon-Labs/psiphon-tunnel-core
           cd ${{ github.workspace }}/go/src/github.com/Psiphon-Labs/psiphon-tunnel-core
-          go vet -tags "PSIPHON_ENABLE_INPROXY PSIPHON_ENABLE_REFRACTION_NETWORKING" ./psiphon/... ./ClientLibrary/... ./ConsoleClient/... ./MobileLibrary/psi ./Server/...
+          go vet -tags "PSIPHON_ENABLE_REFRACTION_NETWORKING" ./psiphon/... ./ClientLibrary/... ./ConsoleClient/... ./MobileLibrary/psi ./Server/...
 
 
       - name: Build and run ConsoleClient to invoke the panic-on-fail, init-time portmapper dependency check (see psiphon/common/inproxy/portmapper.go)
       - name: Build and run ConsoleClient to invoke the panic-on-fail, init-time portmapper dependency check (see psiphon/common/inproxy/portmapper.go)
         if: ${{ matrix.test-type == 'dependency-check' }}
         if: ${{ matrix.test-type == 'dependency-check' }}
         run: |
         run: |
           cd ${{ github.workspace }}/go/src/github.com/Psiphon-Labs/psiphon-tunnel-core/ConsoleClient
           cd ${{ github.workspace }}/go/src/github.com/Psiphon-Labs/psiphon-tunnel-core/ConsoleClient
-          go build -a -v -tags "PSIPHON_ENABLE_INPROXY"
+          go build -a -v
           ./ConsoleClient --version
           ./ConsoleClient --version
 
 
       # License check ignore cases:
       # License check ignore cases:
@@ -202,5 +204,5 @@ jobs:
         if: ${{ matrix.test-type == 'code-vetting' }}
         if: ${{ matrix.test-type == 'code-vetting' }}
         run: |
         run: |
           cd ${{ github.workspace }}/go/src/github.com/Psiphon-Labs/psiphon-tunnel-core
           cd ${{ github.workspace }}/go/src/github.com/Psiphon-Labs/psiphon-tunnel-core
-          GOFLAGS="-tags=PSIPHON_ENABLE_INPROXY,PSIPHON_ENABLE_REFRACTION_NETWORKING" go run github.com/google/go-licenses@latest check --ignore=github.com/Psiphon-Labs,github.com/Psiphon-Inc,github.com/oschwald/maxminddb-golang,github.com/shoenig/go-m1cpu,github.com/sergeyfrolov/bsbuffer --allowed_licenses=Apache-2.0,Apache-3,BSD-2-Clause,BSD-3-Clause,BSD-4-Clause,CC0-1.0,ISC,MIT ./...
-          GOFLAGS="-tags=PSIPHON_ENABLE_INPROXY,PSIPHON_ENABLE_REFRACTION_NETWORKING" go run github.com/google/go-licenses@latest check --ignore=github.com/Psiphon-Labs,github.com/Psiphon-Inc,github.com/sergeyfrolov/bsbuffer --allowed_licenses=Apache-2.0,Apache-3,BSD-2-Clause,BSD-3-Clause,BSD-4-Clause,CC0-1.0,ISC,MIT ./psiphon ./psiphon/common/... ./ClientLibrary/... ./ConsoleClient/... ./MobileLibrary/psi
+          GOFLAGS="-tags=PSIPHON_ENABLE_REFRACTION_NETWORKING" go run github.com/google/go-licenses@latest check --ignore=github.com/Psiphon-Labs,github.com/Psiphon-Inc,github.com/oschwald/maxminddb-golang,github.com/shoenig/go-m1cpu,github.com/sergeyfrolov/bsbuffer --allowed_licenses=Apache-2.0,Apache-3,BSD-2-Clause,BSD-3-Clause,BSD-4-Clause,CC0-1.0,ISC,MIT ./...
+          GOFLAGS="-tags=PSIPHON_ENABLE_REFRACTION_NETWORKING" go run github.com/google/go-licenses@latest check --ignore=github.com/Psiphon-Labs,github.com/Psiphon-Inc,github.com/sergeyfrolov/bsbuffer --allowed_licenses=Apache-2.0,Apache-3,BSD-2-Clause,BSD-3-Clause,BSD-4-Clause,CC0-1.0,ISC,MIT ./psiphon ./psiphon/common/... ./ClientLibrary/... ./ConsoleClient/... ./MobileLibrary/psi

+ 2 - 2
ClientLibrary/make.bash

@@ -51,10 +51,10 @@ build_for_android () {
 
 
   prepare_build android
   prepare_build android
 
 
-  # Required workaround for an PSIPHON_ENABLE_INPROXY dependency:
+  # Required workaround for a !PSIPHON_DISABLE_INPROXY dependency:
   # https://github.com/wlynxg/anet/tree/5501d401a269290292909e6cc75f105571f97cfa?tab=readme-ov-file#how-to-build-with-go-1230-or-later
   # https://github.com/wlynxg/anet/tree/5501d401a269290292909e6cc75f105571f97cfa?tab=readme-ov-file#how-to-build-with-go-1230-or-later
   #
   #
-  # TODO: conditional on PSIPHON_ENABLE_INPROXY build tag?
+  # TODO: conditional on !PSIPHON_DISABLE_INPROXY build tag?
   ANDROID_LDFLAGS="-checklinkname=0 $LDFLAGS"
   ANDROID_LDFLAGS="-checklinkname=0 $LDFLAGS"
 
 
   TARGET_ARCH=arm
   TARGET_ARCH=arm

+ 19 - 3
ConsoleClient/main.go

@@ -51,6 +51,9 @@ func main() {
 	var embeddedServerEntryListFilename string
 	var embeddedServerEntryListFilename string
 	flag.StringVar(&embeddedServerEntryListFilename, "serverList", "", "embedded server entry list input file")
 	flag.StringVar(&embeddedServerEntryListFilename, "serverList", "", "embedded server entry list input file")
 
 
+	var pushPayloadFilename string
+	flag.StringVar(&pushPayloadFilename, "pushPayload", "", "server entry push payload input file")
+
 	var formatNotices bool
 	var formatNotices bool
 	flag.BoolVar(&formatNotices, "formatNotices", false, "emit notices in human-readable format")
 	flag.BoolVar(&formatNotices, "formatNotices", false, "emit notices in human-readable format")
 
 
@@ -224,6 +227,7 @@ func main() {
 		// Tunnel mode
 		// Tunnel mode
 		worker = &TunnelWorker{
 		worker = &TunnelWorker{
 			embeddedServerEntryListFilename: embeddedServerEntryListFilename,
 			embeddedServerEntryListFilename: embeddedServerEntryListFilename,
+			pushPayloadFilename:             pushPayloadFilename,
 		}
 		}
 	}
 	}
 
 
@@ -336,6 +340,7 @@ type Worker interface {
 // TunnelWorker is the Worker protocol implementation used for tunnel mode.
 // TunnelWorker is the Worker protocol implementation used for tunnel mode.
 type TunnelWorker struct {
 type TunnelWorker struct {
 	embeddedServerEntryListFilename string
 	embeddedServerEntryListFilename string
+	pushPayloadFilename             string
 	embeddedServerListWaitGroup     *sync.WaitGroup
 	embeddedServerListWaitGroup     *sync.WaitGroup
 	controller                      *psiphon.Controller
 	controller                      *psiphon.Controller
 }
 }
@@ -347,8 +352,7 @@ func (w *TunnelWorker) Init(ctx context.Context, config *psiphon.Config) error {
 
 
 	err := psiphon.OpenDataStore(config)
 	err := psiphon.OpenDataStore(config)
 	if err != nil {
 	if err != nil {
-		psiphon.NoticeError("error initializing datastore: %s", err)
-		os.Exit(1)
+		return errors.Trace(err)
 	}
 	}
 
 
 	// If specified, the embedded server list is loaded and stored. When there
 	// If specified, the embedded server list is loaded and stored. When there
@@ -389,11 +393,23 @@ func (w *TunnelWorker) Init(ctx context.Context, config *psiphon.Config) error {
 
 
 	controller, err := psiphon.NewController(config)
 	controller, err := psiphon.NewController(config)
 	if err != nil {
 	if err != nil {
-		psiphon.NoticeError("error creating controller: %s", err)
 		return errors.Trace(err)
 		return errors.Trace(err)
 	}
 	}
 	w.controller = controller
 	w.controller = controller
 
 
+	// Import a server entry push payload. This is primarily for testing and
+	// is always executed synchronously.
+	if w.pushPayloadFilename != "" {
+		payload, err := os.ReadFile(w.pushPayloadFilename)
+		if err != nil {
+			return errors.Trace(err)
+		}
+		if !controller.ImportPushPayload(payload) {
+			// Error details emitted as notices
+			return errors.TraceNew("import push payload failed")
+		}
+	}
+
 	return nil
 	return nil
 }
 }
 
 

+ 70 - 2
MobileLibrary/Android/PsiphonTunnel/PsiphonTunnel.java

@@ -48,6 +48,9 @@ import java.util.ArrayList;
 import java.util.Collection;
 import java.util.Collection;
 import java.util.Collections;
 import java.util.Collections;
 import java.util.Enumeration;
 import java.util.Enumeration;
+import java.util.HashMap;
+import java.util.Iterator;
+import java.util.Map;
 import java.util.List;
 import java.util.List;
 import java.util.Locale;
 import java.util.Locale;
 import java.util.concurrent.CountDownLatch;
 import java.util.concurrent.CountDownLatch;
@@ -66,6 +69,18 @@ import psi.PsiphonProviderNetwork;
 import psi.PsiphonProviderNoticeHandler;
 import psi.PsiphonProviderNoticeHandler;
 
 
 public class PsiphonTunnel {
 public class PsiphonTunnel {
+
+    /**
+     * A point-in-time snapshot of per-region proxy activity metrics.
+     * Used in onInproxyProxyActivity
+     */
+    public static class RegionActivitySnapshot {
+        public long bytesUp;
+        public long bytesDown;
+        public int connectingClients;
+        public int connectedClients;
+    }
+
     public interface HostLogger {
     public interface HostLogger {
         default void onDiagnosticMessage(String message) {}
         default void onDiagnosticMessage(String message) {}
     }
     }
@@ -135,12 +150,22 @@ public class PsiphonTunnel {
          * Called when tunnel-core reports proxy usage statistics.
          * Called when tunnel-core reports proxy usage statistics.
          * By default onInproxyProxyActivity is disabled. Enable it by setting
          * By default onInproxyProxyActivity is disabled. Enable it by setting
          * EmitInproxyProxyActivity to true in the Psiphon config.
          * EmitInproxyProxyActivity to true in the Psiphon config.
+         * @param announcing Number of new clients the proxy is accepting.
          * @param connectingClients Number of clients connecting to the proxy.
          * @param connectingClients Number of clients connecting to the proxy.
          * @param connectedClients Number of clients currently connected to the proxy.
          * @param connectedClients Number of clients currently connected to the proxy.
          * @param bytesUp  Bytes uploaded through the proxy since the last report.
          * @param bytesUp  Bytes uploaded through the proxy since the last report.
          * @param bytesDown Bytes downloaded through the proxy since the last report.
          * @param bytesDown Bytes downloaded through the proxy since the last report.
+         * @param personalRegionActivity Per-region activity metrics for personal proxy clients
+         * @param commonRegionActivity Per-region activity metrics for common proxy clients
          */
          */
-        default void onInproxyProxyActivity(int connectingClients, int connectedClients,long bytesUp, long bytesDown) {}
+        default void onInproxyProxyActivity(
+            int announcing, 
+            int connectingClients, 
+            int connectedClients,
+            long bytesUp, 
+            long bytesDown,
+            Map<String, RegionActivitySnapshot> personalRegionActivity,
+            Map<String, RegionActivitySnapshot> commonRegionActivity) {}
         /**
         /**
          * Called when tunnel-core reports connected server region information.
          * Called when tunnel-core reports connected server region information.
          * @param region The server region received.
          * @param region The server region received.
@@ -264,6 +289,19 @@ public class PsiphonTunnel {
         return Psi.importExchangePayload(payload);
         return Psi.importExchangePayload(payload);
     }
     }
 
 
+    // importPushPayload imports a server entry push payload. If no tunnel is
+    // currently connected, this operation will reset tunnel establishment
+    // with imported server entries prioritized appropriately. The push
+    // payload parameters must be set in the Psiphon config, and Psiphon must
+    // be started.
+    //
+    // Returns true if the import succeeded and false on any error. Error
+    // details are logged to diagnostics. If an import is partially
+    // successful, the imported server entries are retained and prioritized.
+    public boolean importPushPayload(byte[] payload) {
+        return Psi.importPushPayload(payload);
+    }
+
     // Writes Go runtime profile information to a set of files in the specifiec output directory.
     // Writes Go runtime profile information to a set of files in the specifiec output directory.
     // cpuSampleDurationSeconds and blockSampleDurationSeconds determines how to long to wait and
     // cpuSampleDurationSeconds and blockSampleDurationSeconds determines how to long to wait and
     // sample profiles that require active sampling. When set to 0, these profiles are skipped.
     // sample profiles that require active sampling. When set to 0, these profiles are skipped.
@@ -922,11 +960,18 @@ public class PsiphonTunnel {
                 mHostService.onInproxyMustUpgrade();
                 mHostService.onInproxyMustUpgrade();
             } else if (noticeType.equals("InproxyProxyActivity")) {
             } else if (noticeType.equals("InproxyProxyActivity")) {
                 JSONObject data = notice.getJSONObject("data");
                 JSONObject data = notice.getJSONObject("data");
+                Map<String, RegionActivitySnapshot> personalRegionActivity =
+                        parseRegionActivity(data.getJSONObject("personalRegionActivity"));
+                Map<String, RegionActivitySnapshot> commonRegionActivity =
+                        parseRegionActivity(data.getJSONObject("commonRegionActivity"));
                 mHostService.onInproxyProxyActivity(
                 mHostService.onInproxyProxyActivity(
+                        data.getInt("announcing"),
                         data.getInt("connectingClients"),
                         data.getInt("connectingClients"),
                         data.getInt("connectedClients"),
                         data.getInt("connectedClients"),
                         data.getLong("bytesUp"),
                         data.getLong("bytesUp"),
-                        data.getLong("bytesDown"));
+                        data.getLong("bytesDown"),
+                        personalRegionActivity,
+                        commonRegionActivity);
             }
             }
 
 
             if (diagnostic) {
             if (diagnostic) {
@@ -939,6 +984,29 @@ public class PsiphonTunnel {
         }
         }
     }
     }
 
 
+    private static Map<String, RegionActivitySnapshot> parseRegionActivity(
+            JSONObject json) throws JSONException {
+        // creates a Map and populates it with the data from all available
+        // regions. This function also makes sure that the map is never null
+        if (json == null) {
+            return Collections.emptyMap();
+        }
+
+        Map<String, RegionActivitySnapshot> result = new HashMap<>();
+        Iterator<String> keys = json.keys();
+        while (keys.hasNext()) {
+            String region = keys.next();
+            JSONObject regionData = json.getJSONObject(region);
+            RegionActivitySnapshot snapshot = new RegionActivitySnapshot();
+            snapshot.bytesUp = regionData.getLong("bytesUp");
+            snapshot.bytesDown = regionData.getLong("bytesDown");
+            snapshot.connectingClients = regionData.getInt("connectingClients");
+            snapshot.connectedClients = regionData.getInt("connectedClients");
+            result.put(region, snapshot);
+        }
+        return result;
+    }
+
     private static String getDeviceRegion(Context context) {
     private static String getDeviceRegion(Context context) {
         String region = "";
         String region = "";
         TelephonyManager telephonyManager = (TelephonyManager)context.getSystemService(Context.TELEPHONY_SERVICE);
         TelephonyManager telephonyManager = (TelephonyManager)context.getSystemService(Context.TELEPHONY_SERVICE);

+ 2 - 2
MobileLibrary/Android/make.bash

@@ -21,10 +21,10 @@ BUILDREPO="https://github.com/Psiphon-Labs/psiphon-tunnel-core.git"
 BUILDREV=$(git rev-parse --short HEAD)
 BUILDREV=$(git rev-parse --short HEAD)
 GOVERSION=$(go version | perl -ne '/go version (.*?) / && print $1')
 GOVERSION=$(go version | perl -ne '/go version (.*?) / && print $1')
 
 
-# -checklinkname=0 is a required workaround for an PSIPHON_ENABLE_INPROXY dependency:
+# -checklinkname=0 is a required workaround for an in-proxy dependency:
 # https://github.com/wlynxg/anet/tree/5501d401a269290292909e6cc75f105571f97cfa?tab=readme-ov-file#how-to-build-with-go-1230-or-later
 # https://github.com/wlynxg/anet/tree/5501d401a269290292909e6cc75f105571f97cfa?tab=readme-ov-file#how-to-build-with-go-1230-or-later
 #
 #
-# TODO: conditional on PSIPHON_ENABLE_INPROXY build tag?
+# TODO: conditional on !PSIPHON_DISABLE_INPROXY build tag?
 
 
 # 16KB page size alignment for Android compatibility
 # 16KB page size alignment for Android compatibility
 export CGO_LDFLAGS="${CGO_LDFLAGS:-} -Wl,-z,max-page-size=16384,-z,common-page-size=16384"
 export CGO_LDFLAGS="${CGO_LDFLAGS:-} -Wl,-z,max-page-size=16384,-z,common-page-size=16384"

+ 18 - 1
MobileLibrary/iOS/PsiphonTunnel/PsiphonTunnel/PsiphonTunnel.h

@@ -312,12 +312,16 @@ followed by a tunnel-core shutdown.
  Called when tunnel-core reports in-proxy usage statistics
  Called when tunnel-core reports in-proxy usage statistics
  By default onInproxyProxyActivity is disabled. Enable it by setting
  By default onInproxyProxyActivity is disabled. Enable it by setting
  EmitInproxyProxyActivity to true in the Psiphon config.
  EmitInproxyProxyActivity to true in the Psiphon config.
+ @param announcing Number of new clients the proxy is accepting.
  @param connectingClients Number of clients connecting to the proxy.
  @param connectingClients Number of clients connecting to the proxy.
  @param connectedClients Number of clients currently connected to the proxy.
  @param connectedClients Number of clients currently connected to the proxy.
  @param bytesUp Bytes uploaded through the proxy since the last report.
  @param bytesUp Bytes uploaded through the proxy since the last report.
  @param bytesDown Bytes downloaded through the proxy since the last report.
  @param bytesDown Bytes downloaded through the proxy since the last report.
  */
  */
-- (void)onInproxyProxyActivity:(int)connectingClients
+// TODO: Add personalRegionActivity and commonRegionActivity parameters
+// to match the new fields in the InproxyProxyActivity notice.
+- (void)onInproxyProxyActivity:(int)announcing
+              connectingClients:(int)connectingClients
               connectedClients:(int)connectedClients
               connectedClients:(int)connectedClients
                        bytesUp:(long)bytesUp
                        bytesUp:(long)bytesUp
                      bytesDown:(long)bytesDown;
                      bytesDown:(long)bytesDown;
@@ -436,6 +440,19 @@ Returns the path where the rotated notices file will be created.
  */
  */
 - (long)getPacketTunnelMTU;
 - (long)getPacketTunnelMTU;
 
 
+/*!
+ importPushPayload imports a server entry push payload. If no tunnel is
+ currently connected, this operation will reset tunnel establishment
+ with imported server entries prioritized appropriately. The push
+ payload parameters must be set in the Psiphon config, and Psiphon must
+ be started.
+
+ Returns true if the import succeeded and false on any error. Error
+ details are logged to diagnostics. If an import is partially
+ successful, the imported server entries are retained and prioritized.
+ */
+- (BOOL)importPushPayload:(NSData * _Nonnull)payload;
+
 /*!
 /*!
  Provides the tunnel-core build info json as a string. See the tunnel-core build info code for details https://github.com/Psiphon-Labs/psiphon-tunnel-core/blob/master/psiphon/common/buildinfo.go.
  Provides the tunnel-core build info json as a string. See the tunnel-core build info code for details https://github.com/Psiphon-Labs/psiphon-tunnel-core/blob/master/psiphon/common/buildinfo.go.
  @return  The build info json as a string.
  @return  The build info json as a string.

+ 25 - 5
MobileLibrary/iOS/PsiphonTunnel/PsiphonTunnel/PsiphonTunnel.m

@@ -461,6 +461,11 @@ typedef NS_ERROR_ENUM(PsiphonTunnelErrorDomain, PsiphonTunnelErrorCode) {
     return GoPsiGetPacketTunnelMTU();
     return GoPsiGetPacketTunnelMTU();
 }
 }
 
 
+// See comment in header.
+- (BOOL)importPushPayload:(NSData * _Nonnull)payload {
+    return GoPsiImportPushPayload(payload);
+}
+
 // See comment in header.
 // See comment in header.
 + (NSString * _Nonnull)getBuildInfo {
 + (NSString * _Nonnull)getBuildInfo {
     return GoPsiGetBuildInfo();
     return GoPsiGetBuildInfo();
@@ -749,7 +754,9 @@ typedef NS_ERROR_ENUM(PsiphonTunnelErrorDomain, PsiphonTunnelErrorCode) {
     
     
     // If RemoteServerListUrl/RemoteServerListURLs and RemoteServerListSignaturePublicKey
     // If RemoteServerListUrl/RemoteServerListURLs and RemoteServerListSignaturePublicKey
     // are absent, we'll just leave them out, but we'll log about it.
     // are absent, we'll just leave them out, but we'll log about it.
-    if ((config[@"RemoteServerListUrl"] == nil && config[@"RemoteServerListURLs"] == nil) ||
+    if ((config[@"RemoteServerListUrl"] == nil &&
+         config[@"RemoteServerListURLs"] == nil &&
+         config[@"AdditionalParameters"] == nil) ||
         config[@"RemoteServerListSignaturePublicKey"] == nil) {
         config[@"RemoteServerListSignaturePublicKey"] == nil) {
         logMessage(@"Remote server list functionality will be disabled");
         logMessage(@"Remote server list functionality will be disabled");
     }
     }
@@ -780,7 +787,10 @@ typedef NS_ERROR_ENUM(PsiphonTunnelErrorDomain, PsiphonTunnelErrorCode) {
     
     
     // If ObfuscatedServerListRootURL/ObfuscatedServerListRootURLs is absent,
     // If ObfuscatedServerListRootURL/ObfuscatedServerListRootURLs is absent,
     // we'll leave it out, but log the absence.
     // we'll leave it out, but log the absence.
-    if (config[@"ObfuscatedServerListRootURL"] == nil && config[@"ObfuscatedServerListRootURLs"] == nil) {
+    if (config[@"ObfuscatedServerListRootURL"] == nil &&
+        config[@"ObfuscatedServerListRootURLs"] == nil &&
+        config[@"AdditionalParameters"] == nil) {
+
         logMessage(@"Obfuscated server list functionality will be disabled");
         logMessage(@"Obfuscated server list functionality will be disabled");
     }
     }
 
 
@@ -1186,17 +1196,27 @@ typedef NS_ERROR_ENUM(PsiphonTunnelErrorDomain, PsiphonTunnelErrorCode) {
         }
         }
     }
     }
     else if ([noticeType isEqualToString:@"InproxyProxyActivity"]) {
     else if ([noticeType isEqualToString:@"InproxyProxyActivity"]) {
+        // TODO: Parse and forward personalRegionActivity and
+        // commonRegionActivity. This should be done when the conduit iOS app
+        // supports the tunnel functionality correctly
+        id announcing = [notice valueForKeyPath:@"data.announcing"];
         id connectingClients = [notice valueForKeyPath:@"data.connectingClients"];
         id connectingClients = [notice valueForKeyPath:@"data.connectingClients"];
         id connectedClients = [notice valueForKeyPath:@"data.connectedClients"];
         id connectedClients = [notice valueForKeyPath:@"data.connectedClients"];
         id bytesUp = [notice valueForKeyPath:@"data.bytesUp"];
         id bytesUp = [notice valueForKeyPath:@"data.bytesUp"];
         id bytesDown = [notice valueForKeyPath:@"data.bytesDown"];
         id bytesDown = [notice valueForKeyPath:@"data.bytesDown"];
-        if (![connectingClients isKindOfClass:[NSNumber class]] || ![connectedClients isKindOfClass:[NSNumber class]] || ![bytesUp isKindOfClass:[NSNumber class]] || ![bytesDown isKindOfClass:[NSNumber class]]) {
+        if (![announcing isKindOfClass:[NSNumber class]] ||
+            ![connectingClients isKindOfClass:[NSNumber class]] ||
+            ![connectedClients isKindOfClass:[NSNumber class]] ||
+            ![bytesUp isKindOfClass:[NSNumber class]] ||
+            ![bytesDown isKindOfClass:[NSNumber class]]) {
             [self logMessage:[NSString stringWithFormat: @"InproxyProxyActivity notice has invalid data types: %@", noticeJSON]];
             [self logMessage:[NSString stringWithFormat: @"InproxyProxyActivity notice has invalid data types: %@", noticeJSON]];
             return;
             return;
         }
         }
-        if ([self.tunneledAppDelegate respondsToSelector:@selector(onInproxyProxyActivity:connectedClients:bytesUp:bytesDown:)]) {
+        if ([self.tunneledAppDelegate respondsToSelector:@selector(onInproxyProxyActivity:connectingClients:connectedClients:bytesUp:bytesDown:)]) {
             dispatch_sync(self->callbackQueue, ^{
             dispatch_sync(self->callbackQueue, ^{
-                [self.tunneledAppDelegate onInproxyProxyActivity:[connectingClients intValue] connectedClients:[connectedClients intValue] bytesUp:[bytesUp longValue] bytesDown:[bytesDown longValue]];
+                [self.tunneledAppDelegate onInproxyProxyActivity: [announcing intValue]
+                    connectingClients:[connectingClients intValue] connectedClients:[connectedClients intValue]
+                    bytesUp:[bytesUp longValue] bytesDown:[bytesDown longValue]];
             });
             });
         }
         }
     }
     }

+ 21 - 1
MobileLibrary/psi/psi.go

@@ -345,7 +345,7 @@ func ExportExchangePayload() string {
 // If an import occurs when Psiphon is working to establsh a tunnel, the newly
 // If an import occurs when Psiphon is working to establsh a tunnel, the newly
 // imported server entry is prioritized.
 // imported server entry is prioritized.
 //
 //
-// The return value indicates a successful import. If the import failed, a a
+// The return value indicates a successful import. If the import failed, a
 // diagnostic notice has been logged.
 // diagnostic notice has been logged.
 func ImportExchangePayload(payload string) bool {
 func ImportExchangePayload(payload string) bool {
 
 
@@ -359,6 +359,26 @@ func ImportExchangePayload(payload string) bool {
 	return controller.ImportExchangePayload(payload)
 	return controller.ImportExchangePayload(payload)
 }
 }
 
 
+// ImportPushPayload imports a server entry push payload.
+//
+// If an import occurs when Psiphon is working to establsh a tunnel, the
+// imported server entries are prioritized as indicated in the payload.
+//
+// Returns true if the import succeeded and false on any error. Error
+// details are logged to diagnostics. If an import is partially
+// successful, the imported server entries are retained and prioritized.
+func ImportPushPayload(payload []byte) bool {
+
+	controllerMutex.Lock()
+	defer controllerMutex.Unlock()
+
+	if controller == nil {
+		return false
+	}
+
+	return controller.ImportPushPayload(payload)
+}
+
 var sendFeedbackMutex sync.Mutex
 var sendFeedbackMutex sync.Mutex
 var sendFeedbackCtx context.Context
 var sendFeedbackCtx context.Context
 var stopSendFeedback context.CancelFunc
 var stopSendFeedback context.CancelFunc

+ 5 - 5
go.mod

@@ -21,9 +21,9 @@ replace gitlab.com/yawning/obfs4.git => github.com/jmwample/obfs4 v0.0.0-2023072
 // respectively, containing Psiphon customizations. See comments in
 // respectively, containing Psiphon customizations. See comments in
 // psiphon/common/inproxy/newWebRTCConn for details.
 // psiphon/common/inproxy/newWebRTCConn for details.
 //
 //
-// The following replaces are required only when the build tags
-// PSIPHON_ENABLE_REFRACTION_NETWORKING (dtls/v2 only) or
-// PSIPHON_ENABLE_INPROXY are specified.
+// The following replaces are required only when the build tag
+// PSIPHON_ENABLE_REFRACTION_NETWORKING is specified (dtls/v2 only) or
+// PSIPHON_DISABLE_INPROXY is not specified.
 
 
 replace github.com/pion/dtls/v2 => ./replace/dtls
 replace github.com/pion/dtls/v2 => ./replace/dtls
 
 
@@ -42,8 +42,9 @@ require (
 	github.com/Psiphon-Labs/goptlib v0.0.0-20200406165125-c0e32a7a3464
 	github.com/Psiphon-Labs/goptlib v0.0.0-20200406165125-c0e32a7a3464
 	github.com/Psiphon-Labs/psiphon-tls v0.0.0-20250318183125-2a2fae2db378
 	github.com/Psiphon-Labs/psiphon-tls v0.0.0-20250318183125-2a2fae2db378
 	github.com/Psiphon-Labs/quic-go v0.0.0-20250527153145-79fe45fb83b1
 	github.com/Psiphon-Labs/quic-go v0.0.0-20250527153145-79fe45fb83b1
-	github.com/Psiphon-Labs/utls v0.0.0-20250623193530-396869e9cd87
+	github.com/Psiphon-Labs/utls v0.0.0-20260129182755-24497d415a8d
 	github.com/armon/go-proxyproto v0.0.0-20180202201750-5b7edb60ff5f
 	github.com/armon/go-proxyproto v0.0.0-20180202201750-5b7edb60ff5f
+	github.com/axiomhq/hyperloglog v0.2.6
 	github.com/bifurcation/mint v0.0.0-20180306135233-198357931e61
 	github.com/bifurcation/mint v0.0.0-20180306135233-198357931e61
 	github.com/bits-and-blooms/bloom/v3 v3.6.0
 	github.com/bits-and-blooms/bloom/v3 v3.6.0
 	github.com/cespare/xxhash v1.1.0
 	github.com/cespare/xxhash v1.1.0
@@ -105,7 +106,6 @@ require (
 	github.com/AndreasBriese/bbloom v0.0.0-20190825152654-46b345b51c96 // indirect
 	github.com/AndreasBriese/bbloom v0.0.0-20190825152654-46b345b51c96 // indirect
 	github.com/alexbrainman/sspi v0.0.0-20231016080023-1a75b4708caa // indirect
 	github.com/alexbrainman/sspi v0.0.0-20231016080023-1a75b4708caa // indirect
 	github.com/andybalholm/brotli v1.1.1 // indirect
 	github.com/andybalholm/brotli v1.1.1 // indirect
-	github.com/axiomhq/hyperloglog v0.2.6 // indirect
 	github.com/bits-and-blooms/bitset v1.10.0 // indirect
 	github.com/bits-and-blooms/bitset v1.10.0 // indirect
 	github.com/cespare/xxhash/v2 v2.3.0 // indirect
 	github.com/cespare/xxhash/v2 v2.3.0 // indirect
 	github.com/cloudflare/circl v1.6.1 // indirect
 	github.com/cloudflare/circl v1.6.1 // indirect

+ 2 - 8
go.sum

@@ -16,12 +16,6 @@ github.com/OneOfOne/xxhash v1.2.2 h1:KMrpdQIwFcEqXDklaen+P1axHaj9BSKzvpUUfnHldSE
 github.com/OneOfOne/xxhash v1.2.2/go.mod h1:HSdplMjZKSmBqAxg5vPj2TmRDmfkzw+cTzAElWljhcU=
 github.com/OneOfOne/xxhash v1.2.2/go.mod h1:HSdplMjZKSmBqAxg5vPj2TmRDmfkzw+cTzAElWljhcU=
 github.com/Psiphon-Inc/rotate-safe-writer v0.0.0-20210303140923-464a7a37606e h1:NPfqIbzmijrl0VclX2t8eO5EPBhqe47LLGKpRrcVjXk=
 github.com/Psiphon-Inc/rotate-safe-writer v0.0.0-20210303140923-464a7a37606e h1:NPfqIbzmijrl0VclX2t8eO5EPBhqe47LLGKpRrcVjXk=
 github.com/Psiphon-Inc/rotate-safe-writer v0.0.0-20210303140923-464a7a37606e/go.mod h1:ZdY5pBfat/WVzw3eXbIf7N1nZN0XD5H5+X8ZMDWbCs4=
 github.com/Psiphon-Inc/rotate-safe-writer v0.0.0-20210303140923-464a7a37606e/go.mod h1:ZdY5pBfat/WVzw3eXbIf7N1nZN0XD5H5+X8ZMDWbCs4=
-github.com/Psiphon-Inc/uds-ipc v0.0.0-20251003205000-ec2282b63bb4 h1:o6yYfSWOQ3A8GVd/9Pv8V2FwuVyDJ2gjsHSWnv2Igis=
-github.com/Psiphon-Inc/uds-ipc v0.0.0-20251003205000-ec2282b63bb4/go.mod h1:R8TGG+OXumorJdpAcSFE4SVTT2frMFEGY43Zc/44sNk=
-github.com/Psiphon-Inc/uds-ipc v0.0.0-20251003212312-ed8477de04f5 h1:ZR+pf49zi/729cdcMmaqKxtqpde04+QF3DaVFoE6xyM=
-github.com/Psiphon-Inc/uds-ipc v0.0.0-20251003212312-ed8477de04f5/go.mod h1:R8TGG+OXumorJdpAcSFE4SVTT2frMFEGY43Zc/44sNk=
-github.com/Psiphon-Inc/uds-ipc v0.0.0-20251007150957-166e89b034be h1:TDXrQ1eVlmc/eB3WofOXgYfDKYeiY19+ZCQCkH/6PcU=
-github.com/Psiphon-Inc/uds-ipc v0.0.0-20251007150957-166e89b034be/go.mod h1:R8TGG+OXumorJdpAcSFE4SVTT2frMFEGY43Zc/44sNk=
 github.com/Psiphon-Inc/uds-ipc v1.0.1 h1:K3Z0cS1XfzDdhxWTIwh/hiLrkRR83ZxUo2bqgBOGuZE=
 github.com/Psiphon-Inc/uds-ipc v1.0.1 h1:K3Z0cS1XfzDdhxWTIwh/hiLrkRR83ZxUo2bqgBOGuZE=
 github.com/Psiphon-Inc/uds-ipc v1.0.1/go.mod h1:R8TGG+OXumorJdpAcSFE4SVTT2frMFEGY43Zc/44sNk=
 github.com/Psiphon-Inc/uds-ipc v1.0.1/go.mod h1:R8TGG+OXumorJdpAcSFE4SVTT2frMFEGY43Zc/44sNk=
 github.com/Psiphon-Labs/bolt v0.0.0-20200624191537-23cedaef7ad7 h1:Hx/NCZTnvoKZuIBwSmxE58KKoNLXIGG6hBJYN7pj9Ag=
 github.com/Psiphon-Labs/bolt v0.0.0-20200624191537-23cedaef7ad7 h1:Hx/NCZTnvoKZuIBwSmxE58KKoNLXIGG6hBJYN7pj9Ag=
@@ -34,8 +28,8 @@ github.com/Psiphon-Labs/psiphon-tls v0.0.0-20250318183125-2a2fae2db378 h1:LqI8cx
 github.com/Psiphon-Labs/psiphon-tls v0.0.0-20250318183125-2a2fae2db378/go.mod h1:7ZUnPnWT5z8J8hxfsVjKHYK77Zme/Y0If1b/zeziiJs=
 github.com/Psiphon-Labs/psiphon-tls v0.0.0-20250318183125-2a2fae2db378/go.mod h1:7ZUnPnWT5z8J8hxfsVjKHYK77Zme/Y0If1b/zeziiJs=
 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 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/quic-go v0.0.0-20250527153145-79fe45fb83b1/go.mod h1:rONdWgPMbFjyyBai7gB1IBF4pT9r4l0GyiDst5XR1SY=
-github.com/Psiphon-Labs/utls v0.0.0-20250623193530-396869e9cd87 h1:h/OnQpPMwC7pKN9YQTJ+vQATjchta6kgumJNnkJBq1k=
-github.com/Psiphon-Labs/utls v0.0.0-20250623193530-396869e9cd87/go.mod h1:1vv0gVAzq9e2XYkW8HAKrmtuuZrBdDixQFx5H22KAjI=
+github.com/Psiphon-Labs/utls v0.0.0-20260129182755-24497d415a8d h1:PlKwrArEuQOVqEmThSs9KsXMiBduP8MSu9rlWmQ4jgE=
+github.com/Psiphon-Labs/utls v0.0.0-20260129182755-24497d415a8d/go.mod h1:1vv0gVAzq9e2XYkW8HAKrmtuuZrBdDixQFx5H22KAjI=
 github.com/alexbrainman/sspi v0.0.0-20231016080023-1a75b4708caa h1:LHTHcTQiSGT7VVbI0o4wBRNQIgn917usHWOd6VAffYI=
 github.com/alexbrainman/sspi v0.0.0-20231016080023-1a75b4708caa h1:LHTHcTQiSGT7VVbI0o4wBRNQIgn917usHWOd6VAffYI=
 github.com/alexbrainman/sspi v0.0.0-20231016080023-1a75b4708caa/go.mod h1:cEWa1LVoE5KvSD9ONXsZrj0z6KqySlCCNKHlLzbqAt4=
 github.com/alexbrainman/sspi v0.0.0-20231016080023-1a75b4708caa/go.mod h1:cEWa1LVoE5KvSD9ONXsZrj0z6KqySlCCNKHlLzbqAt4=
 github.com/andybalholm/brotli v1.1.1 h1:PR2pgnyFznKEugtsUo0xLdDop5SKXd5Qf5ysW+7XdTA=
 github.com/andybalholm/brotli v1.1.1 h1:PR2pgnyFznKEugtsUo0xLdDop5SKXd5Qf5ysW+7XdTA=

+ 54 - 29
psiphon/common/inproxy/api.go

@@ -255,18 +255,21 @@ func (p NetworkProtocol) IsStream() bool {
 // ProxyMetrics are network topolology and resource metrics provided by a
 // ProxyMetrics are network topolology and resource metrics provided by a
 // proxy to a broker. The broker uses this information when matching proxies
 // proxy to a broker. The broker uses this information when matching proxies
 // and clients.
 // and clients.
+// Limitation: Currently, there is no MaxReducedPersonalClients config, as
+// We assumed that users would not want the personal connections to be reduced.
 type ProxyMetrics struct {
 type ProxyMetrics struct {
 	BaseAPIParameters             protocol.PackedAPIParameters `cbor:"1,keyasint,omitempty"`
 	BaseAPIParameters             protocol.PackedAPIParameters `cbor:"1,keyasint,omitempty"`
 	ProtocolVersion               int32                        `cbor:"2,keyasint,omitempty"`
 	ProtocolVersion               int32                        `cbor:"2,keyasint,omitempty"`
 	NATType                       NATType                      `cbor:"3,keyasint,omitempty"`
 	NATType                       NATType                      `cbor:"3,keyasint,omitempty"`
 	PortMappingTypes              PortMappingTypes             `cbor:"4,keyasint,omitempty"`
 	PortMappingTypes              PortMappingTypes             `cbor:"4,keyasint,omitempty"`
-	MaxClients                    int32                        `cbor:"6,keyasint,omitempty"`
+	MaxCommonClients              int32                        `cbor:"6,keyasint,omitempty"`
 	ConnectingClients             int32                        `cbor:"7,keyasint,omitempty"`
 	ConnectingClients             int32                        `cbor:"7,keyasint,omitempty"`
 	ConnectedClients              int32                        `cbor:"8,keyasint,omitempty"`
 	ConnectedClients              int32                        `cbor:"8,keyasint,omitempty"`
 	LimitUpstreamBytesPerSecond   int64                        `cbor:"9,keyasint,omitempty"`
 	LimitUpstreamBytesPerSecond   int64                        `cbor:"9,keyasint,omitempty"`
 	LimitDownstreamBytesPerSecond int64                        `cbor:"10,keyasint,omitempty"`
 	LimitDownstreamBytesPerSecond int64                        `cbor:"10,keyasint,omitempty"`
 	PeakUpstreamBytesPerSecond    int64                        `cbor:"11,keyasint,omitempty"`
 	PeakUpstreamBytesPerSecond    int64                        `cbor:"11,keyasint,omitempty"`
 	PeakDownstreamBytesPerSecond  int64                        `cbor:"12,keyasint,omitempty"`
 	PeakDownstreamBytesPerSecond  int64                        `cbor:"12,keyasint,omitempty"`
+	MaxPersonalClients            int32                        `cbor:"13,keyasint,omitempty"`
 }
 }
 
 
 // ClientMetrics are network topolology metrics provided by a client to a
 // ClientMetrics are network topolology metrics provided by a client to a
@@ -296,16 +299,22 @@ type ClientMetrics struct {
 // overhead, proxies with multiple workers should designate just one worker
 // overhead, proxies with multiple workers should designate just one worker
 // to set CheckTactics.
 // to set CheckTactics.
 //
 //
+// When PreCheckTactics is set, the broker checks tactics as with
+// CheckTactics, but responds immediately without awaiting a match. This
+// option enables the proxy to quickly establish the shared Noise protocol
+// session and launch all workers.
+//
 // The proxy's session public key is an implicit and cryptographically
 // The proxy's session public key is an implicit and cryptographically
 // verified proxy ID.
 // verified proxy ID.
 type ProxyAnnounceRequest struct {
 type ProxyAnnounceRequest struct {
 	PersonalCompartmentIDs []ID          `cbor:"1,keyasint,omitempty"`
 	PersonalCompartmentIDs []ID          `cbor:"1,keyasint,omitempty"`
 	Metrics                *ProxyMetrics `cbor:"2,keyasint,omitempty"`
 	Metrics                *ProxyMetrics `cbor:"2,keyasint,omitempty"`
 	CheckTactics           bool          `cbor:"3,keyasint,omitempty"`
 	CheckTactics           bool          `cbor:"3,keyasint,omitempty"`
+	PreCheckTactics        bool          `cbor:"4,keyasint,omitempty"`
 }
 }
 
 
 // WebRTCSessionDescription is compatible with pion/webrtc.SessionDescription
 // WebRTCSessionDescription is compatible with pion/webrtc.SessionDescription
-// and facilitates the PSIPHON_ENABLE_INPROXY build tag exclusion of pion
+// and facilitates the PSIPHON_DISABLE_INPROXY build tag exclusion of pion
 // dependencies.
 // dependencies.
 type WebRTCSessionDescription struct {
 type WebRTCSessionDescription struct {
 	Type int    `cbor:"1,keyasint,omitempty"`
 	Type int    `cbor:"1,keyasint,omitempty"`
@@ -349,6 +358,7 @@ type ProxyAnnounceResponse struct {
 	TrafficShapingParameters    *TrafficShapingParameters `cbor:"10,keyasint,omitempty"`
 	TrafficShapingParameters    *TrafficShapingParameters `cbor:"10,keyasint,omitempty"`
 	NetworkProtocol             NetworkProtocol           `cbor:"11,keyasint,omitempty"`
 	NetworkProtocol             NetworkProtocol           `cbor:"11,keyasint,omitempty"`
 	DestinationAddress          string                    `cbor:"12,keyasint,omitempty"`
 	DestinationAddress          string                    `cbor:"12,keyasint,omitempty"`
+	ClientRegion                string                    `cbor:"15,keyasint,omitempty"`
 }
 }
 
 
 // ClientOfferRequest is an API request sent from a client to a broker,
 // ClientOfferRequest is an API request sent from a client to a broker,
@@ -451,8 +461,11 @@ type ProxyAnswerRequest struct {
 	// SelectedProtocolVersion int32 `cbor:"2,keyasint,omitempty"`
 	// SelectedProtocolVersion int32 `cbor:"2,keyasint,omitempty"`
 }
 }
 
 
-// ProxyAnswerResponse is the acknowledgement for a ProxyAnswerRequest.
+// ProxyAnswerResponse is the acknowledgement for a ProxyAnswerRequest. If
+// NoAwaitingClient is indicated, then the client was no longer awaiting the
+// answer and the proxy should abandon the connection attempt.
 type ProxyAnswerResponse struct {
 type ProxyAnswerResponse struct {
+	NoAwaitingClient bool `cbor:"1,keyasint,omitempty"`
 }
 }
 
 
 // ClientRelayedPacketRequest is an API request sent from a client to a
 // ClientRelayedPacketRequest is an API request sent from a client to a
@@ -662,7 +675,9 @@ func (metrics *ProxyMetrics) ValidateAndGetParametersAndLogFields(
 	logFields[logFieldPrefix+"protocol_version"] = metrics.ProtocolVersion
 	logFields[logFieldPrefix+"protocol_version"] = metrics.ProtocolVersion
 	logFields[logFieldPrefix+"nat_type"] = metrics.NATType
 	logFields[logFieldPrefix+"nat_type"] = metrics.NATType
 	logFields[logFieldPrefix+"port_mapping_types"] = metrics.PortMappingTypes
 	logFields[logFieldPrefix+"port_mapping_types"] = metrics.PortMappingTypes
-	logFields[logFieldPrefix+"max_clients"] = metrics.MaxClients
+	logFields[logFieldPrefix+"max_common_clients"] = metrics.MaxCommonClients
+	logFields[logFieldPrefix+"max_personal_clients"] = metrics.MaxPersonalClients
+	logFields[logFieldPrefix+"max_clients"] = metrics.MaxCommonClients + metrics.MaxPersonalClients
 	logFields[logFieldPrefix+"connecting_clients"] = metrics.ConnectingClients
 	logFields[logFieldPrefix+"connecting_clients"] = metrics.ConnectingClients
 	logFields[logFieldPrefix+"connected_clients"] = metrics.ConnectedClients
 	logFields[logFieldPrefix+"connected_clients"] = metrics.ConnectedClients
 	logFields[logFieldPrefix+"limit_upstream_bytes_per_second"] = metrics.LimitUpstreamBytesPerSecond
 	logFields[logFieldPrefix+"limit_upstream_bytes_per_second"] = metrics.LimitUpstreamBytesPerSecond
@@ -890,7 +905,8 @@ func (params *TrafficShapingParameters) Validate() error {
 }
 }
 
 
 // ValidateAndGetLogFields validates the ProxyAnswerRequest and returns
 // ValidateAndGetLogFields validates the ProxyAnswerRequest and returns
-// common.LogFields for logging.
+// common.LogFields for logging. A nil filteredSDP is returned when
+// ProxyAnswerRequest.AnswerError is set.
 func (request *ProxyAnswerRequest) ValidateAndGetLogFields(
 func (request *ProxyAnswerRequest) ValidateAndGetLogFields(
 	lookupGeoIP LookupGeoIP,
 	lookupGeoIP LookupGeoIP,
 	baseAPIParameterValidator common.APIParameterValidator,
 	baseAPIParameterValidator common.APIParameterValidator,
@@ -898,27 +914,34 @@ func (request *ProxyAnswerRequest) ValidateAndGetLogFields(
 	geoIPData common.GeoIPData,
 	geoIPData common.GeoIPData,
 	proxyAnnouncementHasPersonalCompartmentIDs bool) ([]byte, common.LogFields, error) {
 	proxyAnnouncementHasPersonalCompartmentIDs bool) ([]byte, common.LogFields, error) {
 
 
-	// The proxy answer SDP must contain at least one ICE candidate.
-	errorOnNoCandidates := true
-
-	// The proxy answer SDP may include RFC 1918/4193 private IP addresses in
-	// personal pairing mode. filterSDPAddresses should not filter out
-	// private IP addresses based on the broker's local interfaces; this
-	// filtering occurs on the client that receives the SDP.
-	allowPrivateIPAddressCandidates := proxyAnnouncementHasPersonalCompartmentIDs
-	filterPrivateIPAddressCandidates := false
-
-	// Proxy answer SDP candidate addresses must match the country and ASN of
-	// the proxy. Don't facilitate connections to arbitrary destinations.
-	filteredSDP, sdpMetrics, err := filterSDPAddresses(
-		[]byte(request.ProxyAnswerSDP.SDP),
-		errorOnNoCandidates,
-		lookupGeoIP,
-		geoIPData,
-		allowPrivateIPAddressCandidates,
-		filterPrivateIPAddressCandidates)
-	if err != nil {
-		return nil, nil, errors.Trace(err)
+	var filteredSDP []byte
+	var sdpMetrics *webRTCSDPMetrics
+	var err error
+
+	if request.AnswerError == "" {
+
+		// The proxy answer SDP must contain at least one ICE candidate.
+		errorOnNoCandidates := true
+
+		// The proxy answer SDP may include RFC 1918/4193 private IP addresses in
+		// personal pairing mode. filterSDPAddresses should not filter out
+		// private IP addresses based on the broker's local interfaces; this
+		// filtering occurs on the client that receives the SDP.
+		allowPrivateIPAddressCandidates := proxyAnnouncementHasPersonalCompartmentIDs
+		filterPrivateIPAddressCandidates := false
+
+		// Proxy answer SDP candidate addresses must match the country and ASN of
+		// the proxy. Don't facilitate connections to arbitrary destinations.
+		filteredSDP, sdpMetrics, err = filterSDPAddresses(
+			[]byte(request.ProxyAnswerSDP.SDP),
+			errorOnNoCandidates,
+			lookupGeoIP,
+			geoIPData,
+			allowPrivateIPAddressCandidates,
+			filterPrivateIPAddressCandidates)
+		if err != nil {
+			return nil, nil, errors.Trace(err)
+		}
 	}
 	}
 
 
 	// The proxy's self-reported ICECandidateTypes are used instead of the
 	// The proxy's self-reported ICECandidateTypes are used instead of the
@@ -935,10 +958,12 @@ func (request *ProxyAnswerRequest) ValidateAndGetLogFields(
 
 
 	logFields["connection_id"] = request.ConnectionID
 	logFields["connection_id"] = request.ConnectionID
 	logFields["ice_candidate_types"] = request.ICECandidateTypes
 	logFields["ice_candidate_types"] = request.ICECandidateTypes
-	logFields["has_IPv6"] = sdpMetrics.hasIPv6
-	logFields["has_private_IP"] = sdpMetrics.hasPrivateIP
-	logFields["filtered_ice_candidates"] = sdpMetrics.filteredICECandidates
 	logFields["answer_error"] = request.AnswerError
 	logFields["answer_error"] = request.AnswerError
+	if sdpMetrics != nil {
+		logFields["has_IPv6"] = sdpMetrics.hasIPv6
+		logFields["has_private_IP"] = sdpMetrics.hasPrivateIP
+		logFields["filtered_ice_candidates"] = sdpMetrics.filteredICECandidates
+	}
 
 
 	return filteredSDP, logFields, nil
 	return filteredSDP, logFields, nil
 }
 }

+ 72 - 9
psiphon/common/inproxy/broker.go

@@ -22,6 +22,7 @@ package inproxy
 import (
 import (
 	"context"
 	"context"
 	std_errors "errors"
 	std_errors "errors"
+	"fmt"
 	"net"
 	"net"
 	"strconv"
 	"strconv"
 	"sync"
 	"sync"
@@ -406,6 +407,7 @@ func (b *Broker) SetLimits(
 	matcherOfferLimitEntryCount int,
 	matcherOfferLimitEntryCount int,
 	matcherOfferRateLimitQuantity int,
 	matcherOfferRateLimitQuantity int,
 	matcherOfferRateLimitInterval time.Duration,
 	matcherOfferRateLimitInterval time.Duration,
+	matcherOfferMinimumDeadline time.Duration,
 	maxCompartmentIDs int,
 	maxCompartmentIDs int,
 	dslRequestRateLimitQuantity int,
 	dslRequestRateLimitQuantity int,
 	dslRequestRateLimitInterval time.Duration) {
 	dslRequestRateLimitInterval time.Duration) {
@@ -417,7 +419,8 @@ func (b *Broker) SetLimits(
 		matcherAnnouncementNonlimitedProxyIDs,
 		matcherAnnouncementNonlimitedProxyIDs,
 		matcherOfferLimitEntryCount,
 		matcherOfferLimitEntryCount,
 		matcherOfferRateLimitQuantity,
 		matcherOfferRateLimitQuantity,
-		matcherOfferRateLimitInterval)
+		matcherOfferRateLimitInterval,
+		matcherOfferMinimumDeadline)
 
 
 	b.maxCompartmentIDs.Store(
 	b.maxCompartmentIDs.Store(
 		int64(common.ValueOrDefault(maxCompartmentIDs, MaxCompartmentIDs)))
 		int64(common.ValueOrDefault(maxCompartmentIDs, MaxCompartmentIDs)))
@@ -729,16 +732,21 @@ func (b *Broker) handleProxyAnnounce(
 	// existing, cached tactics. In the case where tactics have changed,
 	// existing, cached tactics. In the case where tactics have changed,
 	// don't enqueue the proxy announcement and return no-match so that the
 	// don't enqueue the proxy announcement and return no-match so that the
 	// proxy can store and apply the new tactics before announcing again.
 	// proxy can store and apply the new tactics before announcing again.
+	//
+	// For PreCheckTactics requests, an immediate no-match response is
+	// returned even when there are no new tactics.
 
 
 	var tacticsPayload []byte
 	var tacticsPayload []byte
-	if announceRequest.CheckTactics {
+	if announceRequest.CheckTactics || announceRequest.PreCheckTactics {
 		tacticsPayload, newTacticsTag, err =
 		tacticsPayload, newTacticsTag, err =
 			b.config.GetTacticsPayload(geoIPData, apiParams)
 			b.config.GetTacticsPayload(geoIPData, apiParams)
 		if err != nil {
 		if err != nil {
 			return nil, errors.Trace(err)
 			return nil, errors.Trace(err)
 		}
 		}
 
 
-		if tacticsPayload != nil && newTacticsTag != "" {
+		if (tacticsPayload != nil && newTacticsTag != "") ||
+			announceRequest.PreCheckTactics {
+
 			responsePayload, err := MarshalProxyAnnounceResponse(
 			responsePayload, err := MarshalProxyAnnounceResponse(
 				&ProxyAnnounceResponse{
 				&ProxyAnnounceResponse{
 					TacticsPayload: tacticsPayload,
 					TacticsPayload: tacticsPayload,
@@ -756,11 +764,23 @@ func (b *Broker) handleProxyAnnounce(
 	// such as censored locations, from announcing. Proxies with personal
 	// such as censored locations, from announcing. Proxies with personal
 	// compartment IDs are always allowed, as they will be used only by
 	// compartment IDs are always allowed, as they will be used only by
 	// clients specifically configured to use them.
 	// clients specifically configured to use them.
+	//
+	// AllowProxy is not enforced until after CheckTactics/PreCheckTactics
+	// cases, which may return an immediate response. This allows proxies to
+	// download new tactics that may set AllowProxy, which well-behaved
+	// proxies can enforce locally as well.
 
 
 	if !hasPersonalCompartmentIDs &&
 	if !hasPersonalCompartmentIDs &&
 		!b.config.AllowProxy(geoIPData) {
 		!b.config.AllowProxy(geoIPData) {
 
 
-		return nil, errors.TraceNew("proxy disallowed")
+		// Send a "limited" response so the proxy backs off.
+		limitedErr = errors.TraceNew("proxy disallowed")
+		responsePayload, err := MarshalProxyAnnounceResponse(
+			&ProxyAnnounceResponse{Limited: true})
+		if err != nil {
+			return nil, errors.Trace(err)
+		}
+		return responsePayload, nil
 	}
 	}
 
 
 	// Assign this proxy to a common compartment ID, unless it has specified a
 	// Assign this proxy to a common compartment ID, unless it has specified a
@@ -952,6 +972,7 @@ func (b *Broker) handleProxyAnnounce(
 			TrafficShapingParameters:    clientOffer.TrafficShapingParameters,
 			TrafficShapingParameters:    clientOffer.TrafficShapingParameters,
 			NetworkProtocol:             clientOffer.NetworkProtocol,
 			NetworkProtocol:             clientOffer.NetworkProtocol,
 			DestinationAddress:          clientOffer.DestinationAddress,
 			DestinationAddress:          clientOffer.DestinationAddress,
+			ClientRegion:                clientOffer.Properties.GeoIPData.Country,
 		})
 		})
 	if err != nil {
 	if err != nil {
 		return nil, errors.Trace(err)
 		return nil, errors.Trace(err)
@@ -1089,7 +1110,15 @@ func (b *Broker) handleClientOffer(
 	if !hasPersonalCompartmentIDs &&
 	if !hasPersonalCompartmentIDs &&
 		!b.config.AllowClient(geoIPData) {
 		!b.config.AllowClient(geoIPData) {
 
 
-		return nil, errors.TraceNew("client disallowed")
+		// Send a "limited" response so the client retains its broker client
+		// and doesn't retry the offer.
+		limitedErr = errors.TraceNew("client disallowed")
+		responsePayload, err := MarshalClientOfferResponse(
+			&ClientOfferResponse{Limited: true})
+		if err != nil {
+			return nil, errors.Trace(err)
+		}
+		return responsePayload, nil
 	}
 	}
 
 
 	// Validate that the proxy destination specified by the client is a valid
 	// Validate that the proxy destination specified by the client is a valid
@@ -1184,7 +1213,8 @@ func (b *Broker) handleClientOffer(
 		var limitError *MatcherLimitError
 		var limitError *MatcherLimitError
 		limited := std_errors.As(err, &limitError)
 		limited := std_errors.As(err, &limitError)
 
 
-		timeout := offerCtx.Err() == context.DeadlineExceeded
+		timeout := offerCtx.Err() == context.DeadlineExceeded ||
+			std_errors.Is(err, errOfferDropped)
 
 
 		// A no-match response is sent in the case of a timeout awaiting a
 		// A no-match response is sent in the case of a timeout awaiting a
 		// match. The faster-failing rate or entry limiting case also results
 		// match. The faster-failing rate or entry limiting case also results
@@ -1204,6 +1234,13 @@ func (b *Broker) handleClientOffer(
 			// InproxyClientOfferRequestTimeout in tactics, should be configured
 			// InproxyClientOfferRequestTimeout in tactics, should be configured
 			// so that the broker will timeout first and have an opportunity to
 			// so that the broker will timeout first and have an opportunity to
 			// send this response before the client times out.
 			// send this response before the client times out.
+			//
+			// In the errOfferDropped case, the matcher dropped the offer due
+			// to age. While this is distinct from a timeout after a
+			// completed match, the same timed_out log field is set. The
+			// cases can be distinguished based on elapsed_time, as the
+			// dropped cases will have an elapsed_time less than
+			// InproxyBrokerClientOfferTimeout.
 			timedOut = true
 			timedOut = true
 		}
 		}
 
 
@@ -1363,8 +1400,10 @@ func (b *Broker) handleProxyAnswer(
 		if answerError != "" {
 		if answerError != "" {
 			// This is a proxy-reported error that occurred while creating the answer.
 			// This is a proxy-reported error that occurred while creating the answer.
 			logFields["answer_error"] = answerError
 			logFields["answer_error"] = answerError
+			logFields["error"] = fmt.Sprintf("proxy answer error: %s", answerError)
 		}
 		}
 		if retErr != nil {
 		if retErr != nil {
+			// For the error field, retErr takes precedence over answerError
 			logFields["error"] = retErr.Error()
 			logFields["error"] = retErr.Error()
 		}
 		}
 		logFields.Add(transportLogFields)
 		logFields.Add(transportLogFields)
@@ -1392,6 +1431,18 @@ func (b *Broker) handleProxyAnswer(
 	hasPersonalCompartmentIDs, err := b.matcher.AnnouncementHasPersonalCompartmentIDs(
 	hasPersonalCompartmentIDs, err := b.matcher.AnnouncementHasPersonalCompartmentIDs(
 		initiatorID, answerRequest.ConnectionID)
 		initiatorID, answerRequest.ConnectionID)
 	if err != nil {
 	if err != nil {
+
+		if std_errors.Is(err, errNoPendingAnswer) {
+			// Return a response. This avoids returning a
+			// broker-client-resetting 404 in this case.
+			responsePayload, err := MarshalProxyAnswerResponse(
+				&ProxyAnswerResponse{NoAwaitingClient: true})
+			if err != nil {
+				return nil, errors.Trace(err)
+			}
+			return responsePayload, nil
+		}
+
 		return nil, errors.Trace(err)
 		return nil, errors.Trace(err)
 	}
 	}
 
 
@@ -1406,9 +1457,6 @@ func (b *Broker) handleProxyAnswer(
 		return nil, errors.Trace(err)
 		return nil, errors.Trace(err)
 	}
 	}
 
 
-	answerSDP := answerRequest.ProxyAnswerSDP
-	answerSDP.SDP = string(filteredSDP)
-
 	if answerRequest.AnswerError != "" {
 	if answerRequest.AnswerError != "" {
 
 
 		// The proxy failed to create an answer.
 		// The proxy failed to create an answer.
@@ -1424,6 +1472,9 @@ func (b *Broker) handleProxyAnswer(
 		// Note that neither ProxyID nor ProxyIP is returned to the client.
 		// Note that neither ProxyID nor ProxyIP is returned to the client.
 		// These fields are used internally in the matcher.
 		// These fields are used internally in the matcher.
 
 
+		answerSDP := answerRequest.ProxyAnswerSDP
+		answerSDP.SDP = string(filteredSDP)
+
 		proxyAnswer = &MatchAnswer{
 		proxyAnswer = &MatchAnswer{
 			ProxyIP:        proxyIP,
 			ProxyIP:        proxyIP,
 			ProxyID:        initiatorID,
 			ProxyID:        initiatorID,
@@ -1433,6 +1484,18 @@ func (b *Broker) handleProxyAnswer(
 
 
 		err = b.matcher.Answer(proxyAnswer)
 		err = b.matcher.Answer(proxyAnswer)
 		if err != nil {
 		if err != nil {
+
+			if std_errors.Is(err, errNoPendingAnswer) {
+				// Return a response. This avoids returning a
+				// broker-client-resetting 404 in this case.
+				responsePayload, err := MarshalProxyAnswerResponse(
+					&ProxyAnswerResponse{NoAwaitingClient: true})
+				if err != nil {
+					return nil, errors.Trace(err)
+				}
+				return responsePayload, nil
+			}
+
 			return nil, errors.Trace(err)
 			return nil, errors.Trace(err)
 		}
 		}
 	}
 	}

+ 1 - 1
psiphon/common/inproxy/discoverySTUN.go

@@ -1,4 +1,4 @@
-//go:build PSIPHON_ENABLE_INPROXY
+//go:build !PSIPHON_DISABLE_INPROXY
 
 
 /*
 /*
  * Copyright (c) 2024, Psiphon Inc.
  * Copyright (c) 2024, Psiphon Inc.

+ 42 - 17
psiphon/common/inproxy/discovery_test.go

@@ -1,4 +1,4 @@
-//go:build PSIPHON_ENABLE_INPROXY
+//go:build !PSIPHON_DISABLE_INPROXY
 
 
 /*
 /*
  * Copyright (c) 2023, Psiphon Inc.
  * Copyright (c) 2023, Psiphon Inc.
@@ -23,13 +23,27 @@ package inproxy
 
 
 import (
 import (
 	"context"
 	"context"
+	"fmt"
 	"sync/atomic"
 	"sync/atomic"
 	"testing"
 	"testing"
 
 
+	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common/errors"
 	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/internal/testutils"
 	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/internal/testutils"
 )
 )
 
 
 func TestNATDiscovery(t *testing.T) {
 func TestNATDiscovery(t *testing.T) {
+	// Since this test can fail due to external network conditions, retry.
+	var err error
+	for try := 0; try < 2; try++ {
+		err = runTestNATDiscovery()
+		if err == nil {
+			return
+		}
+	}
+	t.Error(err.Error())
+}
+
+func runTestNATDiscovery() error {
 
 
 	// TODO: run local STUN and port mapping servers to test against, along
 	// TODO: run local STUN and port mapping servers to test against, along
 	// with iptables rules to simulate NAT conditions
 	// with iptables rules to simulate NAT conditions
@@ -54,48 +68,48 @@ func TestNATDiscovery(t *testing.T) {
 		},
 		},
 
 
 		stunServerAddressSucceeded: func(RFC5780 bool, address string) {
 		stunServerAddressSucceeded: func(RFC5780 bool, address string) {
-			atomic.AddInt32(&stunServerAddressSucceededCallCount, 1)
-			if address != stunServerAddress {
-				t.Errorf("unexpected STUN server address")
+			if address == stunServerAddress {
+				atomic.AddInt32(&stunServerAddressSucceededCallCount, 1)
 			}
 			}
 		},
 		},
 
 
 		stunServerAddressFailed: func(RFC5780 bool, address string) {
 		stunServerAddressFailed: func(RFC5780 bool, address string) {
-			atomic.AddInt32(&stunServerAddressFailedCallCount, 1)
-			if address != stunServerAddress {
-				t.Errorf("unexpected STUN server address")
+			if address == stunServerAddress {
+				atomic.AddInt32(&stunServerAddressFailedCallCount, 1)
 			}
 			}
 		},
 		},
 	}
 	}
 
 
-	checkCallCounts := func(a, b, c, d int32) {
+	checkCallCounts := func(a, b, c, d int32) error {
 		callCount := atomic.LoadInt32(&setNATTypeCallCount)
 		callCount := atomic.LoadInt32(&setNATTypeCallCount)
 		if callCount != a {
 		if callCount != a {
-			t.Errorf(
+			return errors.Tracef(
 				"unexpected setNATType call count: %d",
 				"unexpected setNATType call count: %d",
 				callCount)
 				callCount)
 		}
 		}
 
 
 		callCount = atomic.LoadInt32(&setPortMappingTypesCallCount)
 		callCount = atomic.LoadInt32(&setPortMappingTypesCallCount)
 		if callCount != b {
 		if callCount != b {
-			t.Errorf(
+			return errors.Tracef(
 				"unexpected setPortMappingTypes call count: %d",
 				"unexpected setPortMappingTypes call count: %d",
 				callCount)
 				callCount)
 		}
 		}
 
 
 		callCount = atomic.LoadInt32(&stunServerAddressSucceededCallCount)
 		callCount = atomic.LoadInt32(&stunServerAddressSucceededCallCount)
 		if callCount != c {
 		if callCount != c {
-			t.Errorf(
+			return errors.Tracef(
 				"unexpected stunServerAddressSucceeded call count: %d",
 				"unexpected stunServerAddressSucceeded call count: %d",
 				callCount)
 				callCount)
 		}
 		}
 
 
 		callCount = atomic.LoadInt32(&stunServerAddressFailedCallCount)
 		callCount = atomic.LoadInt32(&stunServerAddressFailedCallCount)
 		if callCount != d {
 		if callCount != d {
-			t.Errorf(
+			return errors.Tracef(
 				"unexpected stunServerAddressFailedCallCount call count: %d",
 				"unexpected stunServerAddressFailedCallCount call count: %d",
 				callCount)
 				callCount)
 		}
 		}
+
+		return nil
 	}
 	}
 
 
 	config := &NATDiscoverConfig{
 	config := &NATDiscoverConfig{
@@ -109,7 +123,10 @@ func TestNATDiscovery(t *testing.T) {
 
 
 	NATDiscover(context.Background(), config)
 	NATDiscover(context.Background(), config)
 
 
-	checkCallCounts(1, 0, 1, 0)
+	err := checkCallCounts(1, 0, 1, 0)
+	if err != nil {
+		return errors.Trace(err)
+	}
 
 
 	// Should do port mapping only
 	// Should do port mapping only
 
 
@@ -118,7 +135,10 @@ func TestNATDiscovery(t *testing.T) {
 
 
 	NATDiscover(context.Background(), config)
 	NATDiscover(context.Background(), config)
 
 
-	checkCallCounts(1, 1, 1, 0)
+	err = checkCallCounts(1, 1, 1, 0)
+	if err != nil {
+		return errors.Trace(err)
+	}
 
 
 	// Should skip both and use values cached in WebRTCDialCoordinator
 	// Should skip both and use values cached in WebRTCDialCoordinator
 
 
@@ -127,8 +147,13 @@ func TestNATDiscovery(t *testing.T) {
 
 
 	NATDiscover(context.Background(), config)
 	NATDiscover(context.Background(), config)
 
 
-	checkCallCounts(1, 1, 1, 0)
+	err = checkCallCounts(1, 1, 1, 0)
+	if err != nil {
+		return errors.Trace(err)
+	}
+
+	fmt.Printf("NAT Type: %s\n", coordinator.NATType())
+	fmt.Printf("Port Mapping Types: %s\n", coordinator.PortMappingTypes())
 
 
-	t.Logf("NAT Type: %s", coordinator.NATType())
-	t.Logf("Port Mapping Types: %s", coordinator.PortMappingTypes())
+	return nil
 }
 }

+ 0 - 4
psiphon/common/inproxy/doc.go

@@ -142,9 +142,5 @@ optionally, and integrated with its tactics, data store, and logging. The
 broker is designed to be bundled with the Psiphon server, psiphond, and, like
 broker is designed to be bundled with the Psiphon server, psiphond, and, like
 tactics requests, run under MeekServer; and use the tactics, psinet database,
 tactics requests, run under MeekServer; and use the tactics, psinet database,
 GeoIP services, and logging services provided by psiphond.
 GeoIP services, and logging services provided by psiphond.
-
-The build tag PSIPHON_ENABLE_INPROXY must be specified in order to enable
-in-proxy components. Without this build tag, the components are disabled and
-larger dependencies are not referenced and excluded from builds.
 */
 */
 package inproxy
 package inproxy

+ 2 - 2
psiphon/common/inproxy/inproxy_disabled.go

@@ -1,4 +1,4 @@
-//go:build !PSIPHON_ENABLE_INPROXY
+//go:build PSIPHON_DISABLE_INPROXY
 
 
 /*
 /*
  * Copyright (c) 2024, Psiphon Inc.
  * Copyright (c) 2024, Psiphon Inc.
@@ -34,7 +34,7 @@ import (
 // The inproxy package has a broad API that referenced throughout the psiphon
 // The inproxy package has a broad API that referenced throughout the psiphon
 // and psiphon/server packages.
 // and psiphon/server packages.
 //
 //
-// When PSIPHON_ENABLE_INPROXY is not specified, inproxy components are
+// When PSIPHON_DISABLE_INPROXY is specified, inproxy components are
 // disabled and large dependencies, including pion and tailscale, are not
 // disabled and large dependencies, including pion and tailscale, are not
 // referenced and excluded from builds. The stub types and functions here are
 // referenced and excluded from builds. The stub types and functions here are
 // sufficient to omit all pion and tailscale references. The remaining, broad
 // sufficient to omit all pion and tailscale references. The remaining, broad

+ 1 - 1
psiphon/common/inproxy/inproxy_enabled.go

@@ -1,4 +1,4 @@
-//go:build PSIPHON_ENABLE_INPROXY
+//go:build !PSIPHON_DISABLE_INPROXY
 
 
 /*
 /*
  * Copyright (c) 2024, Psiphon Inc.
  * Copyright (c) 2024, Psiphon Inc.

+ 138 - 13
psiphon/common/inproxy/inproxy_test.go

@@ -1,4 +1,4 @@
-//go:build PSIPHON_ENABLE_INPROXY
+//go:build !PSIPHON_DISABLE_INPROXY
 
 
 /*
 /*
  * Copyright (c) 2023, Psiphon Inc.
  * Copyright (c) 2023, Psiphon Inc.
@@ -81,6 +81,8 @@ func runTestInproxy(doMustUpgrade bool) error {
 
 
 	testCompartmentID, _ := MakeID()
 	testCompartmentID, _ := MakeID()
 	testCommonCompartmentIDs := []ID{testCompartmentID}
 	testCommonCompartmentIDs := []ID{testCompartmentID}
+	personalCompartmentID, _ := MakeID()
+	testPersonalCompartmentIDs := []ID{personalCompartmentID}
 
 
 	testNetworkID := "NETWORK-ID-1"
 	testNetworkID := "NETWORK-ID-1"
 	testNetworkType := NetworkTypeUnknown
 	testNetworkType := NetworkTypeUnknown
@@ -109,6 +111,13 @@ func runTestInproxy(doMustUpgrade bool) error {
 	roundTripperFailed := func(RoundTripper) { atomic.AddInt32(&roundTripperFailedCount, 1) }
 	roundTripperFailed := func(RoundTripper) { atomic.AddInt32(&roundTripperFailedCount, 1) }
 	noMatch := func(RoundTripper) {}
 	noMatch := func(RoundTripper) {}
 
 
+	// Per-region activity tracking for testing the region activity feature.
+	var regionTrackingMutex sync.Mutex
+	seenCommonRegions := make(map[string]bool)
+	seenPersonalRegions := make(map[string]bool)
+	var totalCommonRegionBytesUp, totalCommonRegionBytesDown int64
+	var totalPersonalRegionBytesUp, totalPersonalRegionBytesDown int64
+
 	var receivedProxyMustUpgrade chan struct{}
 	var receivedProxyMustUpgrade chan struct{}
 	var receivedClientMustUpgrade chan struct{}
 	var receivedClientMustUpgrade chan struct{}
 	if doMustUpgrade {
 	if doMustUpgrade {
@@ -481,6 +490,7 @@ func runTestInproxy(doMustUpgrade bool) error {
 			brokerClientPrivateKey:      proxyPrivateKey,
 			brokerClientPrivateKey:      proxyPrivateKey,
 			brokerPublicKey:             brokerPublicKey,
 			brokerPublicKey:             brokerPublicKey,
 			brokerRootObfuscationSecret: brokerRootObfuscationSecret,
 			brokerRootObfuscationSecret: brokerRootObfuscationSecret,
+			personalCompartmentIDs:      testPersonalCompartmentIDs,
 			brokerClientRoundTripper: newHTTPRoundTripper(
 			brokerClientRoundTripper: newHTTPRoundTripper(
 				brokerListener.Addr().String(), "proxy"),
 				brokerListener.Addr().String(), "proxy"),
 			brokerClientRoundTripperSucceeded: roundTripperSucceded,
 			brokerClientRoundTripperSucceeded: roundTripperSucceded,
@@ -552,16 +562,40 @@ func runTestInproxy(doMustUpgrade bool) error {
 
 
 			HandleTacticsPayload: makeHandleTacticsPayload(proxyPrivateKey, tacticsNetworkID),
 			HandleTacticsPayload: makeHandleTacticsPayload(proxyPrivateKey, tacticsNetworkID),
 
 
-			MaxClients:                    proxyMaxClients,
+			MaxCommonClients:              proxyMaxClients,
+			MaxPersonalClients:            proxyMaxClients,
 			LimitUpstreamBytesPerSecond:   bytesToSend / targetElapsedSeconds,
 			LimitUpstreamBytesPerSecond:   bytesToSend / targetElapsedSeconds,
 			LimitDownstreamBytesPerSecond: bytesToSend / targetElapsedSeconds,
 			LimitDownstreamBytesPerSecond: bytesToSend / targetElapsedSeconds,
 
 
-			ActivityUpdater: func(connectingClients int32, connectedClients int32,
-				bytesUp int64, bytesDown int64, bytesDuration time.Duration) {
+			ActivityUpdater: func(
+				announcing int32,
+				connectingClients int32, connectedClients int32,
+				bytesUp int64, bytesDown int64, bytesDuration time.Duration,
+				personalRegionActivity map[string]RegionActivitySnapshot,
+				commonRegionActivity map[string]RegionActivitySnapshot) {
 
 
-				fmt.Printf("[%s][%s] ACTIVITY: %d connecting, %d connected, %d up, %d down\n",
+				fmt.Printf("[%s][%s] ACTIVITY: %d announcing, %d connecting, %d connected, %d up, %d down\n",
 					time.Now().UTC().Format(time.RFC3339), name,
 					time.Now().UTC().Format(time.RFC3339), name,
-					connectingClients, connectedClients, bytesUp, bytesDown)
+					announcing, connectingClients, connectedClients, bytesUp, bytesDown)
+
+				regionTrackingMutex.Lock()
+				for region, stats := range commonRegionActivity {
+					seenCommonRegions[region] = true
+					totalCommonRegionBytesUp += stats.BytesUp
+					totalCommonRegionBytesDown += stats.BytesDown
+					fmt.Printf("[%s][%s] COMMON REGION %s: connecting=%d, connected=%d, up=%d, down=%d\n",
+						time.Now().UTC().Format(time.RFC3339), name, region,
+						stats.ConnectingClients, stats.ConnectedClients, stats.BytesUp, stats.BytesDown)
+				}
+				for region, stats := range personalRegionActivity {
+					seenPersonalRegions[region] = true
+					totalPersonalRegionBytesUp += stats.BytesUp
+					totalPersonalRegionBytesDown += stats.BytesDown
+					fmt.Printf("[%s][%s] PERSONAL REGION %s: connecting=%d, connected=%d, up=%d, down=%d\n",
+						time.Now().UTC().Format(time.RFC3339), name, region,
+						stats.ConnectingClients, stats.ConnectedClients, stats.BytesUp, stats.BytesDown)
+				}
+				regionTrackingMutex.Unlock()
 			},
 			},
 
 
 			MustUpgrade: func() {
 			MustUpgrade: func() {
@@ -779,7 +813,9 @@ func runTestInproxy(doMustUpgrade bool) error {
 	}
 	}
 
 
 	newClientBrokerClient := func(
 	newClientBrokerClient := func(
-		disableWaitToShareSession bool) (*BrokerClient, error) {
+		disableWaitToShareSession bool,
+		commonCompartmentIDs []ID,
+		personalCompartmentIDs []ID) (*BrokerClient, error) {
 
 
 		clientPrivateKey, err := GenerateSessionPrivateKey()
 		clientPrivateKey, err := GenerateSessionPrivateKey()
 		if err != nil {
 		if err != nil {
@@ -790,7 +826,8 @@ func runTestInproxy(doMustUpgrade bool) error {
 			networkID:   testNetworkID,
 			networkID:   testNetworkID,
 			networkType: testNetworkType,
 			networkType: testNetworkType,
 
 
-			commonCompartmentIDs: testCommonCompartmentIDs,
+			commonCompartmentIDs:   commonCompartmentIDs,
+			personalCompartmentIDs: personalCompartmentIDs,
 
 
 			disableWaitToShareSession: disableWaitToShareSession,
 			disableWaitToShareSession: disableWaitToShareSession,
 
 
@@ -883,12 +920,22 @@ func runTestInproxy(doMustUpgrade bool) error {
 		return webRTCCoordinator, nil
 		return webRTCCoordinator, nil
 	}
 	}
 
 
-	sharedBrokerClient, err := newClientBrokerClient(false)
+	sharedCommonBrokerClient, err := newClientBrokerClient(false, testCommonCompartmentIDs, nil)
+	if err != nil {
+		return errors.Trace(err)
+	}
+
+	sharedCommonBrokerClientDisableWait, err := newClientBrokerClient(true, testCommonCompartmentIDs, nil)
 	if err != nil {
 	if err != nil {
 		return errors.Trace(err)
 		return errors.Trace(err)
 	}
 	}
 
 
-	sharedBrokerClientDisableWait, err := newClientBrokerClient(true)
+	sharedPersonalBrokerClient, err := newClientBrokerClient(false, nil, testPersonalCompartmentIDs)
+	if err != nil {
+		return errors.Trace(err)
+	}
+
+	sharedPersonalBrokerClientDisableWait, err := newClientBrokerClient(true, nil, testPersonalCompartmentIDs)
 	if err != nil {
 	if err != nil {
 		return errors.Trace(err)
 		return errors.Trace(err)
 	}
 	}
@@ -902,16 +949,34 @@ func runTestInproxy(doMustUpgrade bool) error {
 		isMobile := i%4 == 0
 		isMobile := i%4 == 0
 		useMediaStreams := i%4 < 2
 		useMediaStreams := i%4 < 2
 
 
+		// First half of clients are personal, second half are common.
+		isPersonalClient := i < numClients/2
+
 		// Exercise BrokerClients shared by multiple clients, but also create
 		// Exercise BrokerClients shared by multiple clients, but also create
 		// several broker clients.
 		// several broker clients.
+		//
+		// Per-region testing is handled by the HTTP server alternating regions
+		// based on request count.
 		var brokerClient *BrokerClient
 		var brokerClient *BrokerClient
 		switch i % 3 {
 		switch i % 3 {
 		case 0:
 		case 0:
-			brokerClient = sharedBrokerClient
+			if isPersonalClient {
+				brokerClient = sharedPersonalBrokerClient
+			} else {
+				brokerClient = sharedCommonBrokerClient
+			}
 		case 1:
 		case 1:
-			brokerClient = sharedBrokerClientDisableWait
+			if isPersonalClient {
+				brokerClient = sharedPersonalBrokerClientDisableWait
+			} else {
+				brokerClient = sharedCommonBrokerClientDisableWait
+			}
 		case 2:
 		case 2:
-			brokerClient, err = newClientBrokerClient(true)
+			if isPersonalClient {
+				brokerClient, err = newClientBrokerClient(true, nil, testPersonalCompartmentIDs)
+			} else {
+				brokerClient, err = newClientBrokerClient(true, testCommonCompartmentIDs, nil)
+			}
 			if err != nil {
 			if err != nil {
 				return errors.Trace(err)
 				return errors.Trace(err)
 			}
 			}
@@ -1006,6 +1071,40 @@ func runTestInproxy(doMustUpgrade bool) error {
 		if atomic.LoadInt32(&roundTripperFailedCount) > 0 {
 		if atomic.LoadInt32(&roundTripperFailedCount) > 0 {
 			return errors.TraceNew("unexpected round tripper failed count")
 			return errors.TraceNew("unexpected round tripper failed count")
 		}
 		}
+
+		// Check per-region activity tracking
+		regionTrackingMutex.Lock()
+		if !seenCommonRegions["REGION-A"] {
+			regionTrackingMutex.Unlock()
+			return errors.TraceNew("expected to see REGION-A in common region activity")
+		}
+		if !seenCommonRegions["REGION-B"] {
+			regionTrackingMutex.Unlock()
+			return errors.TraceNew("expected to see REGION-B in common region activity")
+		}
+		if totalCommonRegionBytesUp == 0 {
+			regionTrackingMutex.Unlock()
+			return errors.TraceNew("expected non-zero per-region bytes up")
+		}
+		if totalCommonRegionBytesDown == 0 {
+			regionTrackingMutex.Unlock()
+			return errors.TraceNew("expected non-zero per-region bytes down")
+		}
+		if !seenPersonalRegions["REGION-A"] && !seenPersonalRegions["REGION-B"] {
+			regionTrackingMutex.Unlock()
+			return errors.TraceNew("expected to see personal region activity")
+		}
+		if totalPersonalRegionBytesUp == 0 {
+			regionTrackingMutex.Unlock()
+			return errors.TraceNew("expected non-zero personal per-region bytes up")
+		}
+		if totalPersonalRegionBytesDown == 0 {
+			regionTrackingMutex.Unlock()
+			return errors.TraceNew("expected non-zero personal per-region bytes down")
+		}
+		fmt.Printf("Per-region test passed: seen regions %v, total bytes up=%d, down=%d\n",
+			seenCommonRegions, totalCommonRegionBytesUp, totalCommonRegionBytesDown)
+		regionTrackingMutex.Unlock()
 	}
 	}
 
 
 	// Await shutdowns
 	// Await shutdowns
@@ -1023,14 +1122,40 @@ func runTestInproxy(doMustUpgrade bool) error {
 
 
 func runHTTPServer(listener net.Listener, broker *Broker) error {
 func runHTTPServer(listener net.Listener, broker *Broker) error {
 
 
+	// Track client regions by RemoteAddr
+	var clientRegionsMutex sync.Mutex
+	clientRegions := make(map[string]string)
+	var clientRegionCount atomic.Int32
+
 	handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
 	handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
 
 
 		// For this test, clients set the path to "/client" and proxies
 		// For this test, clients set the path to "/client" and proxies
 		// set the path to "/proxy" and we use that to create stub GeoIP
 		// set the path to "/proxy" and we use that to create stub GeoIP
 		// data to pass the not-same-ASN condition.
 		// data to pass the not-same-ASN condition.
+		//
+		// For per-region testing, each client gets an alternating sticky region
+		// (REGION-A or REGION-B) based on its RemoteAddr
 		var geoIPData common.GeoIPData
 		var geoIPData common.GeoIPData
 		geoIPData.ASN = r.URL.Path
 		geoIPData.ASN = r.URL.Path
 
 
+		path := r.URL.Path
+		if strings.HasPrefix(path, "/client") {
+			clientRegionsMutex.Lock()
+			clientRegion, ok := clientRegions[r.RemoteAddr]
+			if !ok {
+				if clientRegionCount.Add(1)%2 == 0 {
+					clientRegion = "REGION-A"
+				} else {
+					clientRegion = "REGION-B"
+				}
+				clientRegions[r.RemoteAddr] = clientRegion
+			}
+			clientRegionsMutex.Unlock()
+			geoIPData.Country = clientRegion
+		} else if strings.HasPrefix(path, "/proxy") {
+			geoIPData.Country = "PROXY-REGION"
+		}
+
 		requestPayload, err := ioutil.ReadAll(
 		requestPayload, err := ioutil.ReadAll(
 			http.MaxBytesReader(w, r.Body, BrokerMaxRequestBodySize))
 			http.MaxBytesReader(w, r.Body, BrokerMaxRequestBodySize))
 		if err != nil {
 		if err != nil {

+ 44 - 7
psiphon/common/inproxy/matcher.go

@@ -102,6 +102,7 @@ type Matcher struct {
 	offerLimitEntryCount     int
 	offerLimitEntryCount     int
 	offerRateLimitQuantity   int
 	offerRateLimitQuantity   int
 	offerRateLimitInterval   time.Duration
 	offerRateLimitInterval   time.Duration
+	offerMinimumDeadline     time.Duration
 
 
 	matchSignal chan struct{}
 	matchSignal chan struct{}
 
 
@@ -124,6 +125,7 @@ type MatcherConfig struct {
 	OfferLimitEntryCount   int
 	OfferLimitEntryCount   int
 	OfferRateLimitQuantity int
 	OfferRateLimitQuantity int
 	OfferRateLimitInterval time.Duration
 	OfferRateLimitInterval time.Duration
+	OfferMinimumDeadline   time.Duration
 
 
 	// Proxy quality state.
 	// Proxy quality state.
 	ProxyQualityState *ProxyQualityState
 	ProxyQualityState *ProxyQualityState
@@ -220,6 +222,7 @@ type MatchAnswer struct {
 // MatchMetrics records statistics about the match queue state at the time a
 // MatchMetrics records statistics about the match queue state at the time a
 // match is made.
 // match is made.
 type MatchMetrics struct {
 type MatchMetrics struct {
+	OfferDeadline          time.Duration
 	OfferMatchIndex        int
 	OfferMatchIndex        int
 	OfferQueueSize         int
 	OfferQueueSize         int
 	AnnouncementMatchIndex int
 	AnnouncementMatchIndex int
@@ -233,6 +236,7 @@ func (metrics *MatchMetrics) GetMetrics() common.LogFields {
 		return nil
 		return nil
 	}
 	}
 	return common.LogFields{
 	return common.LogFields{
+		"offer_deadline":           int64(metrics.OfferDeadline / time.Millisecond),
 		"offer_match_index":        metrics.OfferMatchIndex,
 		"offer_match_index":        metrics.OfferMatchIndex,
 		"offer_queue_size":         metrics.OfferQueueSize,
 		"offer_queue_size":         metrics.OfferQueueSize,
 		"announcement_match_index": metrics.AnnouncementMatchIndex,
 		"announcement_match_index": metrics.AnnouncementMatchIndex,
@@ -283,6 +287,11 @@ func (offerEntry *offerEntry) getMatchMetrics() *MatchMetrics {
 type answerInfo struct {
 type answerInfo struct {
 	announcement *MatchAnnouncement
 	announcement *MatchAnnouncement
 	answer       *MatchAnswer
 	answer       *MatchAnswer
+
+	// offerDropped is sent to Offer's answer channel when the offer has been
+	// dropped by the matcher due to age. This allows Offer to return
+	// immediately on drop and the request handler to log this outcome.
+	offerDropped bool
 }
 }
 
 
 // pendingAnswer represents an answer that is expected to arrive from a
 // pendingAnswer represents an answer that is expected to arrive from a
@@ -333,7 +342,8 @@ func NewMatcher(config *MatcherConfig) *Matcher {
 		config.AnnouncementNonlimitedProxyIDs,
 		config.AnnouncementNonlimitedProxyIDs,
 		config.OfferLimitEntryCount,
 		config.OfferLimitEntryCount,
 		config.OfferRateLimitQuantity,
 		config.OfferRateLimitQuantity,
-		config.OfferRateLimitInterval)
+		config.OfferRateLimitInterval,
+		config.OfferMinimumDeadline)
 
 
 	return m
 	return m
 }
 }
@@ -350,7 +360,8 @@ func (m *Matcher) SetLimits(
 	announcementNonlimitedProxyIDs []ID,
 	announcementNonlimitedProxyIDs []ID,
 	offerLimitEntryCount int,
 	offerLimitEntryCount int,
 	offerRateLimitQuantity int,
 	offerRateLimitQuantity int,
-	offerRateLimitInterval time.Duration) {
+	offerRateLimitInterval time.Duration,
+	offerMinimumDeadline time.Duration) {
 
 
 	nonlimitedProxyIDs := make(map[ID]struct{})
 	nonlimitedProxyIDs := make(map[ID]struct{})
 	for _, proxyID := range announcementNonlimitedProxyIDs {
 	for _, proxyID := range announcementNonlimitedProxyIDs {
@@ -368,6 +379,7 @@ func (m *Matcher) SetLimits(
 	m.offerLimitEntryCount = offerLimitEntryCount
 	m.offerLimitEntryCount = offerLimitEntryCount
 	m.offerRateLimitQuantity = offerRateLimitQuantity
 	m.offerRateLimitQuantity = offerRateLimitQuantity
 	m.offerRateLimitInterval = offerRateLimitInterval
 	m.offerRateLimitInterval = offerRateLimitInterval
+	m.offerMinimumDeadline = offerMinimumDeadline
 	m.offerQueueMutex.Unlock()
 	m.offerQueueMutex.Unlock()
 }
 }
 
 
@@ -473,6 +485,8 @@ func (m *Matcher) Announce(
 	return clientOffer, announcementEntry.getMatchMetrics(), nil
 	return clientOffer, announcementEntry.getMatchMetrics(), nil
 }
 }
 
 
+var errOfferDropped = std_errors.New("offer dropped")
+
 // Offer enqueues the client offer and blocks until it is matched with a
 // Offer enqueues the client offer and blocks until it is matched with a
 // returned announcement or ctx is done. The caller must not mutate the offer
 // returned announcement or ctx is done. The caller must not mutate the offer
 // or its properties after calling Announce.
 // or its properties after calling Announce.
@@ -546,6 +560,11 @@ func (m *Matcher) Offer(
 			offerEntry.getMatchMetrics(), errors.TraceNew("no answer")
 			offerEntry.getMatchMetrics(), errors.TraceNew("no answer")
 	}
 	}
 
 
+	if proxyAnswerInfo.offerDropped {
+		return nil, nil,
+			offerEntry.getMatchMetrics(), errOfferDropped
+	}
+
 	// This is a sanity check and not expected to fail.
 	// This is a sanity check and not expected to fail.
 	if !proxyAnswerInfo.answer.ConnectionID.Equal(
 	if !proxyAnswerInfo.answer.ConnectionID.Equal(
 		proxyAnswerInfo.announcement.ConnectionID) {
 		proxyAnswerInfo.announcement.ConnectionID) {
@@ -559,6 +578,8 @@ func (m *Matcher) Offer(
 		nil
 		nil
 }
 }
 
 
+var errNoPendingAnswer = std_errors.New("no pending answer")
+
 // AnnouncementHasPersonalCompartmentIDs looks for a pending answer for an
 // AnnouncementHasPersonalCompartmentIDs looks for a pending answer for an
 // announcement identified by the specified proxy ID and connection ID and
 // announcement identified by the specified proxy ID and connection ID and
 // returns whether the announcement has personal compartment IDs, indicating
 // returns whether the announcement has personal compartment IDs, indicating
@@ -573,7 +594,7 @@ func (m *Matcher) AnnouncementHasPersonalCompartmentIDs(
 	if !ok {
 	if !ok {
 		// The input IDs don't correspond to a pending answer, or the client
 		// The input IDs don't correspond to a pending answer, or the client
 		// is no longer awaiting the response.
 		// is no longer awaiting the response.
-		return false, errors.TraceNew("no pending answer")
+		return false, errors.Trace(errNoPendingAnswer)
 	}
 	}
 
 
 	pendingAnswer := pendingAnswerValue.(*pendingAnswer)
 	pendingAnswer := pendingAnswerValue.(*pendingAnswer)
@@ -599,7 +620,7 @@ func (m *Matcher) Answer(
 	if !ok {
 	if !ok {
 		// The input IDs don't correspond to a pending answer, or the client
 		// The input IDs don't correspond to a pending answer, or the client
 		// is no longer awaiting the response.
 		// is no longer awaiting the response.
-		return errors.TraceNew("no pending answer")
+		return errors.Trace(errNoPendingAnswer)
 	}
 	}
 
 
 	m.pendingAnswers.Delete(key)
 	m.pendingAnswers.Delete(key)
@@ -677,15 +698,30 @@ func (m *Matcher) matchAllOffers() {
 
 
 		offerEntry := offer.Value.(*offerEntry)
 		offerEntry := offer.Value.(*offerEntry)
 
 
-		// Skip and remove this offer if its deadline has already passed.
-		// There is no signal to the awaiting Offer function, as it will exit
-		// based on the same ctx.
+		// Skip and remove this offer if its deadline has already passed or
+		// the context is canceled. There is no signal to the awaiting Offer
+		// function, as it will exit based on the same ctx.
 
 
 		if offerEntry.ctx.Err() != nil {
 		if offerEntry.ctx.Err() != nil {
 			m.removeOfferEntry(false, offerEntry)
 			m.removeOfferEntry(false, offerEntry)
 			continue
 			continue
 		}
 		}
 
 
+		offerDeadline, _ := offerEntry.ctx.Deadline()
+		untilOfferDeadline := time.Until(offerDeadline)
+
+		// Drop this offer if it no longer has a sufficient remaining deadline
+		// for the proxy answer phase. This case signals Offer's answerChan
+		// so it can return immediately.
+
+		if m.offerMinimumDeadline > 0 &&
+			untilOfferDeadline < m.offerMinimumDeadline {
+
+			m.removeOfferEntry(false, offerEntry)
+			offerEntry.answerChan <- &answerInfo{offerDropped: true}
+			continue
+		}
+
 		announcementEntry, announcementMatchIndex := m.matchOffer(offerEntry)
 		announcementEntry, announcementMatchIndex := m.matchOffer(offerEntry)
 		if announcementEntry == nil {
 		if announcementEntry == nil {
 			continue
 			continue
@@ -698,6 +734,7 @@ func (m *Matcher) matchAllOffers() {
 		// were inspected before matching.
 		// were inspected before matching.
 
 
 		matchMetrics := &MatchMetrics{
 		matchMetrics := &MatchMetrics{
+			OfferDeadline:          untilOfferDeadline,
 			OfferMatchIndex:        offerIndex,
 			OfferMatchIndex:        offerIndex,
 			OfferQueueSize:         m.offerQueue.Len(),
 			OfferQueueSize:         m.offerQueue.Len(),
 			AnnouncementMatchIndex: announcementMatchIndex,
 			AnnouncementMatchIndex: announcementMatchIndex,

+ 21 - 2
psiphon/common/inproxy/matcher_test.go

@@ -47,6 +47,7 @@ func runTestMatcher() error {
 	limitEntryCount := 50
 	limitEntryCount := 50
 	rateLimitQuantity := 100
 	rateLimitQuantity := 100
 	rateLimitInterval := 1000 * time.Millisecond
 	rateLimitInterval := 1000 * time.Millisecond
+	minimumDeadline := 1 * time.Hour
 
 
 	logger := testutils.NewTestLogger()
 	logger := testutils.NewTestLogger()
 
 
@@ -338,7 +339,7 @@ func runTestMatcher() error {
 
 
 	m.SetLimits(
 	m.SetLimits(
 		0, rateLimitQuantity, rateLimitInterval, []ID{},
 		0, rateLimitQuantity, rateLimitInterval, []ID{},
-		0, rateLimitQuantity, rateLimitInterval)
+		0, rateLimitQuantity, rateLimitInterval, 0)
 
 
 	time.Sleep(rateLimitInterval)
 	time.Sleep(rateLimitInterval)
 
 
@@ -389,11 +390,29 @@ func runTestMatcher() error {
 		return errors.Tracef("unexpected result: %v", err)
 		return errors.Tracef("unexpected result: %v", err)
 	}
 	}
 
 
+	// Test: offer dropped due to minimum deadline
+
+	m.SetLimits(
+		0, rateLimitQuantity, rateLimitInterval, []ID{},
+		0, rateLimitQuantity, rateLimitInterval, minimumDeadline)
+
 	time.Sleep(rateLimitInterval)
 	time.Sleep(rateLimitInterval)
 
 
+	go proxyFunc(proxyResultChan, proxyIP, matchProperties, 10*time.Millisecond, nil, true)
+	go clientFunc(clientResultChan, clientIP, matchProperties, 10*time.Millisecond)
+
+	err = <-proxyResultChan
+
+	err = <-clientResultChan
+	if err == nil || !strings.HasSuffix(err.Error(), errOfferDropped.Error()) {
+		return errors.Tracef("unexpected result: %v", err)
+	}
+
 	m.SetLimits(
 	m.SetLimits(
 		limitEntryCount, rateLimitQuantity, rateLimitInterval, []ID{},
 		limitEntryCount, rateLimitQuantity, rateLimitInterval, []ID{},
-		limitEntryCount, rateLimitQuantity, rateLimitInterval)
+		limitEntryCount, rateLimitQuantity, rateLimitInterval, 0)
+
+	time.Sleep(rateLimitInterval)
 
 
 	// Test: basic match
 	// Test: basic match
 
 

+ 1 - 1
psiphon/common/inproxy/portmapper.go

@@ -1,4 +1,4 @@
-//go:build PSIPHON_ENABLE_INPROXY
+//go:build !PSIPHON_DISABLE_INPROXY
 
 
 /*
 /*
  * Copyright (c) 2023, Psiphon Inc.
  * Copyright (c) 2023, Psiphon Inc.

+ 1 - 1
psiphon/common/inproxy/portmapper_android.go

@@ -1,4 +1,4 @@
-//go:build PSIPHON_ENABLE_INPROXY && android
+//go:build !PSIPHON_DISABLE_INPROXY && android
 
 
 /*
 /*
  * Copyright (c) 2023, Psiphon Inc.
  * Copyright (c) 2023, Psiphon Inc.

+ 1 - 1
psiphon/common/inproxy/portmapper_other.go

@@ -1,4 +1,4 @@
-//go:build PSIPHON_ENABLE_INPROXY && !android
+//go:build !PSIPHON_DISABLE_INPROXY && !android
 
 
 /*
 /*
  * Copyright (c) 2023, Psiphon Inc.
  * Copyright (c) 2023, Psiphon Inc.

+ 516 - 101
psiphon/common/inproxy/proxy.go

@@ -50,11 +50,13 @@ type Proxy struct {
 	bytesDown         atomic.Int64
 	bytesDown         atomic.Int64
 	peakBytesUp       atomic.Int64
 	peakBytesUp       atomic.Int64
 	peakBytesDown     atomic.Int64
 	peakBytesDown     atomic.Int64
-	connectingClients int32
-	connectedClients  int32
+	announcing        atomic.Int32
+	connectingClients atomic.Int32
+	connectedClients  atomic.Int32
 
 
 	config                *ProxyConfig
 	config                *ProxyConfig
 	activityUpdateWrapper *activityUpdateWrapper
 	activityUpdateWrapper *activityUpdateWrapper
+	lastAnnouncing        int32
 	lastConnectingClients int32
 	lastConnectingClients int32
 	lastConnectedClients  int32
 	lastConnectedClients  int32
 
 
@@ -65,6 +67,24 @@ type Proxy struct {
 	nextAnnounceMutex        sync.Mutex
 	nextAnnounceMutex        sync.Mutex
 	nextAnnounceBrokerClient *BrokerClient
 	nextAnnounceBrokerClient *BrokerClient
 	nextAnnounceNotBefore    time.Time
 	nextAnnounceNotBefore    time.Time
+
+	useReducedSettings bool
+	reducedStartMinute int
+	reducedEndMinute   int
+
+	personalStatsMutex     sync.Mutex
+	personalRegionActivity map[string]*RegionActivity
+
+	commonStatsMutex     sync.Mutex
+	commonRegionActivity map[string]*RegionActivity
+}
+
+// RegionActivity holds metrics per-region for more detailed metric collection.
+type RegionActivity struct {
+	bytesUp           atomic.Int64
+	bytesDown         atomic.Int64
+	connectingClients atomic.Int32
+	connectedClients  atomic.Int32
 }
 }
 
 
 // TODO: add PublicNetworkAddress/ListenNetworkAddress to facilitate manually
 // TODO: add PublicNetworkAddress/ListenNetworkAddress to facilitate manually
@@ -132,9 +152,13 @@ type ProxyConfig struct {
 	// restarting the proxy.
 	// restarting the proxy.
 	MustUpgrade func()
 	MustUpgrade func()
 
 
-	// MaxClients is the maximum number of clients that are allowed to connect
-	// to the proxy. Must be > 0.
-	MaxClients int
+	// MaxCommonClients (formerly MaxClients) is the maximum number of common
+	// clients that are allowed to connect to the proxy. Must be > 0.
+	MaxCommonClients int
+
+	// MaxPersonalClients is the maximum number of personal clients that are
+	// allowed to connect to the proxy. Must be > 0.
+	MaxPersonalClients int
 
 
 	// LimitUpstreamBytesPerSecond limits the upstream data transfer rate for
 	// LimitUpstreamBytesPerSecond limits the upstream data transfer rate for
 	// a single client. When 0, there is no limit.
 	// a single client. When 0, there is no limit.
@@ -144,32 +168,114 @@ type ProxyConfig struct {
 	// for a single client. When 0, there is no limit.
 	// for a single client. When 0, there is no limit.
 	LimitDownstreamBytesPerSecond int
 	LimitDownstreamBytesPerSecond int
 
 
+	// ReducedStartTime specifies the local time of day (HH:MM, 24-hour, UTC)
+	// at which reduced client settings begin.
+	ReducedStartTime string
+
+	// ReducedEndTime specifies the local time of day (HH:MM, 24-hour, UTC) at
+	// which reduced client settings end.
+	ReducedEndTime string
+
+	// ReducedMaxCommonClients specifies the maximum number of common clients
+	// that are allowed to connect to the proxy during the reduced time range.
+	//
+	// Limitation: We currently do not support ReducedMaxPersonalClients.
+	// We assume that due to the importance of personal clients, users
+	// always prefer to have them connected.
+	//
+	// Clients connected when the reduced settings begin will not be
+	// disconnected.
+	ReducedMaxCommonClients int
+
+	// ReducedLimitUpstreamBytesPerSecond limits the upstream data transfer
+	// rate for a single client during the reduced time range. When 0,
+	// LimitUpstreamBytesPerSecond is the limit.
+	//
+	// Rates for clients already connected when the reduced settings begin or
+	// end will not change.
+	ReducedLimitUpstreamBytesPerSecond int
+
+	// ReducedLimitDownstreamBytesPerSecond limits the downstream data
+	// transfer rate for a single client during the reduced time range. When
+	// 0, LimitDownstreamBytesPerSecond is the limit.
+	//
+	// Rates for clients already connected when the reduced settings begin or
+	// end will not change.
+	ReducedLimitDownstreamBytesPerSecond int
+
 	// ActivityUpdater specifies an ActivityUpdater for activity associated
 	// ActivityUpdater specifies an ActivityUpdater for activity associated
 	// with this proxy.
 	// with this proxy.
 	ActivityUpdater ActivityUpdater
 	ActivityUpdater ActivityUpdater
 }
 }
 
 
-// ActivityUpdater is a callback that is invoked when clients connect and
-// disconnect and periodically with data transfer updates (unless idle). This
-// callback may be used to update an activity UI. This callback should post
-// this data to another thread or handler and return immediately and not
-// block on UI updates.
+// RegionActivitySnapshot holds a point-in-time copy of per-region metrics.
+// This is used for the ActivityUpdater callback and notice serialization.
+type RegionActivitySnapshot struct {
+	BytesUp           int64 `json:"bytesUp"`
+	BytesDown         int64 `json:"bytesDown"`
+	ConnectingClients int32 `json:"connectingClients"`
+	ConnectedClients  int32 `json:"connectedClients"`
+}
+
+// ActivityUpdater is a callback that is invoked when the proxy announces
+// availability, when clients connect and disconnect, and periodically with
+// data transfer updates (unless idle). This callback may be used to update
+// an activity UI. This callback should post this data to another thread or
+// handler and return immediately and not block on UI updates.
+//
+// The personalRegionActivity and commonRegionActivity parameters contain per-region
+// metrics (bytes transferred, connecting/connected counts) segmented by client
+// region.
 type ActivityUpdater func(
 type ActivityUpdater func(
+	announcing int32,
 	connectingClients int32,
 	connectingClients int32,
 	connectedClients int32,
 	connectedClients int32,
 	bytesUp int64,
 	bytesUp int64,
 	bytesDown int64,
 	bytesDown int64,
-	bytesDuration time.Duration)
+	bytesDuration time.Duration,
+	personalRegionActivitySnapshot map[string]RegionActivitySnapshot,
+	commonRegionActivitySnapshot map[string]RegionActivitySnapshot)
 
 
 // NewProxy initializes a new Proxy with the specified configuration.
 // NewProxy initializes a new Proxy with the specified configuration.
 func NewProxy(config *ProxyConfig) (*Proxy, error) {
 func NewProxy(config *ProxyConfig) (*Proxy, error) {
 
 
-	if config.MaxClients <= 0 {
-		return nil, errors.TraceNew("invalid MaxClients")
+	// Check if there are no clients who can connect
+	if config.MaxCommonClients+config.MaxPersonalClients <= 0 {
+		return nil, errors.TraceNew("invalid MaxCommonClients")
 	}
 	}
 
 
 	p := &Proxy{
 	p := &Proxy{
-		config: config,
+		config:                 config,
+		personalRegionActivity: make(map[string]*RegionActivity),
+		commonRegionActivity:   make(map[string]*RegionActivity),
+	}
+
+	if config.ReducedStartTime != "" ||
+		config.ReducedEndTime != "" ||
+		config.ReducedMaxCommonClients > 0 {
+
+		startMinute, err := common.ParseTimeOfDayMinutes(config.ReducedStartTime)
+		if err != nil {
+			return nil, errors.Tracef("invalid ReducedStartTime: %v", err)
+		}
+
+		endMinute, err := common.ParseTimeOfDayMinutes(config.ReducedEndTime)
+		if err != nil {
+			return nil, errors.Tracef("invalid ReducedEndTime: %v", err)
+		}
+
+		if startMinute == endMinute {
+			return nil, errors.TraceNew("invalid ReducedStartTime/ReducedEndTime")
+		}
+
+		if config.ReducedMaxCommonClients <= 0 ||
+			config.ReducedMaxCommonClients > config.MaxCommonClients {
+			return nil, errors.TraceNew("invalid ReducedMaxCommonClients")
+		}
+
+		p.useReducedSettings = true
+		p.reducedStartMinute = startMinute
+		p.reducedEndMinute = endMinute
 	}
 	}
 
 
 	p.activityUpdateWrapper = &activityUpdateWrapper{p: p}
 	p.activityUpdateWrapper = &activityUpdateWrapper{p: p}
@@ -190,6 +296,24 @@ func (w *activityUpdateWrapper) UpdateProgress(bytesRead, bytesWritten int64, _
 	w.p.bytesDown.Add(bytesRead)
 	w.p.bytesDown.Add(bytesRead)
 }
 }
 
 
+// connectionActivityWrapper implements common.ActivityUpdater for a single
+// connection. It caches the RegionActivity pointer to enable atomic updates
+// with no mutex locking.
+type connectionActivityWrapper struct {
+	p              *Proxy
+	regionActivity *RegionActivity
+}
+
+func (w *connectionActivityWrapper) UpdateProgress(bytesRead, bytesWritten int64, _ int64) {
+	w.p.bytesUp.Add(bytesWritten)
+	w.p.bytesDown.Add(bytesRead)
+
+	if w.regionActivity != nil {
+		w.regionActivity.bytesUp.Add(bytesWritten)
+		w.regionActivity.bytesDown.Add(bytesRead)
+	}
+}
+
 // Run runs the proxy. The proxy sends requests to the Broker announcing its
 // Run runs the proxy. The proxy sends requests to the Broker announcing its
 // availability; the Broker matches the proxy with clients, and facilitates
 // availability; the Broker matches the proxy with clients, and facilitates
 // an exchange of WebRTC connection information; the proxy and each client
 // an exchange of WebRTC connection information; the proxy and each client
@@ -206,12 +330,39 @@ func (p *Proxy) Run(ctx context.Context) {
 
 
 	proxyWaitGroup := new(sync.WaitGroup)
 	proxyWaitGroup := new(sync.WaitGroup)
 
 
+	// Capture activity updates every second, which is the required frequency
+	// for PeakUp/DownstreamBytesPerSecond. This is also a reasonable
+	// frequency for invoking the ActivityUpdater and updating UI widgets.
+
+	proxyWaitGroup.Add(1)
+	go func() {
+		defer proxyWaitGroup.Done()
+
+		p.lastAnnouncing = 0
+		p.lastConnectingClients = 0
+		p.lastConnectedClients = 0
+
+		activityUpdatePeriod := 1 * time.Second
+		ticker := time.NewTicker(activityUpdatePeriod)
+		defer ticker.Stop()
+
+	loop:
+		for {
+			select {
+			case <-ticker.C:
+				p.activityUpdate(activityUpdatePeriod)
+			case <-ctx.Done():
+				break loop
+			}
+		}
+	}()
+
 	// Launch the first proxy worker, passing a signal to be triggered once
 	// Launch the first proxy worker, passing a signal to be triggered once
 	// the very first announcement round trip is complete. The first round
 	// the very first announcement round trip is complete. The first round
 	// trip is awaited so that:
 	// trip is awaited so that:
 	//
 	//
 	// - The first announce response will arrive with any new tactics,
 	// - The first announce response will arrive with any new tactics,
-	//   which may be applied before launching additions workers.
+	//   which may be applied before launching additional workers.
 	//
 	//
 	// - The first worker gets no announcement delay and is also guaranteed to
 	// - The first worker gets no announcement delay and is also guaranteed to
 	//   be the shared session establisher. Since the announcement delays are
 	//   be the shared session establisher. Since the announcement delays are
@@ -220,7 +371,21 @@ func (p *Proxy) Run(ctx context.Context) {
 	//   session establisher to be a different worker than the no-delay worker.
 	//   session establisher to be a different worker than the no-delay worker.
 	//
 	//
 	// The first worker is the only proxy worker which sets
 	// The first worker is the only proxy worker which sets
-	// ProxyAnnounceRequest.CheckTactics.
+	// ProxyAnnounceRequest.CheckTactics/PreCheckTactics. PreCheckTactics is
+	// used on the first announcement so the request returns immediately
+	// without awaiting a match. This allows all workers to be launched
+	// quickly.
+
+	commonProxiesToCreate, personalProxiesToCreate :=
+		p.config.MaxCommonClients, p.config.MaxPersonalClients
+
+	// Doing this outside of the go routine to avoid race conditions
+	firstWorkerIsPersonal := p.config.MaxCommonClients <= 0
+	if firstWorkerIsPersonal {
+		personalProxiesToCreate -= 1
+	} else {
+		commonProxiesToCreate -= 1
+	}
 
 
 	signalFirstAnnounceCtx, signalFirstAnnounceDone :=
 	signalFirstAnnounceCtx, signalFirstAnnounceDone :=
 		context.WithCancel(context.Background())
 		context.WithCancel(context.Background())
@@ -228,7 +393,7 @@ func (p *Proxy) Run(ctx context.Context) {
 	proxyWaitGroup.Add(1)
 	proxyWaitGroup.Add(1)
 	go func() {
 	go func() {
 		defer proxyWaitGroup.Done()
 		defer proxyWaitGroup.Done()
-		p.proxyClients(ctx, signalFirstAnnounceDone)
+		p.proxyClients(ctx, signalFirstAnnounceDone, false, firstWorkerIsPersonal)
 	}()
 	}()
 
 
 	select {
 	select {
@@ -239,86 +404,136 @@ func (p *Proxy) Run(ctx context.Context) {
 
 
 	// Launch the remaining workers.
 	// Launch the remaining workers.
 
 
-	for i := 0; i < p.config.MaxClients-1; i++ {
+	for i := 0; i < commonProxiesToCreate; i++ {
+		isPersonal := false
+
+		// When reduced settings are in effect, a subset of workers will pause
+		// during the reduced time period. Since ReducedMaxCommonClients > 0 the
+		// first proxy worker is never paused.
+		workerNum := i + 1
+		reducedPause := p.useReducedSettings &&
+			workerNum >= p.config.ReducedMaxCommonClients
+
 		proxyWaitGroup.Add(1)
 		proxyWaitGroup.Add(1)
-		go func() {
+		go func(reducedPause bool) {
 			defer proxyWaitGroup.Done()
 			defer proxyWaitGroup.Done()
-			p.proxyClients(ctx, nil)
-		}()
+			p.proxyClients(ctx, nil, reducedPause, isPersonal)
+		}(reducedPause)
 	}
 	}
 
 
-	// Capture activity updates every second, which is the required frequency
-	// for PeakUp/DownstreamBytesPerSecond. This is also a reasonable
-	// frequency for invoking the ActivityUpdater and updating UI widgets.
-
-	p.lastConnectingClients = 0
-	p.lastConnectedClients = 0
+	for i := 0; i < personalProxiesToCreate; i++ {
+		// Limitation: There are no reduced settings for personal proxies
+		isPersonal := true
 
 
-	activityUpdatePeriod := 1 * time.Second
-	ticker := time.NewTicker(activityUpdatePeriod)
-	defer ticker.Stop()
-
-loop:
-	for {
-		select {
-		case <-ticker.C:
-			p.activityUpdate(activityUpdatePeriod)
-		case <-ctx.Done():
-			break loop
-		}
+		proxyWaitGroup.Add(1)
+		go func() {
+			defer proxyWaitGroup.Done()
+			p.proxyClients(ctx, nil, false, isPersonal)
+		}()
 	}
 	}
 
 
 	proxyWaitGroup.Wait()
 	proxyWaitGroup.Wait()
 }
 }
 
 
-// getAnnounceDelayParameters is a helper that fetches the proxy announcement
-// delay parameters from the current broker client.
-//
-// getAnnounceDelayParameters is used to configure a delay when
-// proxyOneClient fails. As having no broker clients is a possible
-// proxyOneClient failure case, GetBrokerClient errors are ignored here and
-// defaults used in that case.
-func (p *Proxy) getAnnounceDelayParameters() (time.Duration, time.Duration, float64) {
-	brokerClient, err := p.config.GetBrokerClient()
-	if err != nil {
-		return proxyAnnounceDelay, proxyAnnounceMaxBackoffDelay, proxyAnnounceDelayJitter
-	}
-	brokerCoordinator := brokerClient.GetBrokerDialCoordinator()
-	return common.ValueOrDefault(brokerCoordinator.AnnounceDelay(), proxyAnnounceDelay),
-		common.ValueOrDefault(brokerCoordinator.AnnounceMaxBackoffDelay(), proxyAnnounceMaxBackoffDelay),
-		common.ValueOrDefault(brokerCoordinator.AnnounceDelayJitter(), proxyAnnounceDelayJitter)
-
-}
-
 func (p *Proxy) activityUpdate(period time.Duration) {
 func (p *Proxy) activityUpdate(period time.Duration) {
 
 
-	connectingClients := atomic.LoadInt32(&p.connectingClients)
-	connectedClients := atomic.LoadInt32(&p.connectedClients)
+	// Concurrency: activityUpdate is called by only the single goroutine
+	// created in Run.
+
+	announcing := p.announcing.Load()
+	connectingClients := p.connectingClients.Load()
+	connectedClients := p.connectedClients.Load()
 	bytesUp := p.bytesUp.Swap(0)
 	bytesUp := p.bytesUp.Swap(0)
 	bytesDown := p.bytesDown.Swap(0)
 	bytesDown := p.bytesDown.Swap(0)
 
 
 	greaterThanSwapInt64(&p.peakBytesUp, bytesUp)
 	greaterThanSwapInt64(&p.peakBytesUp, bytesUp)
 	greaterThanSwapInt64(&p.peakBytesDown, bytesDown)
 	greaterThanSwapInt64(&p.peakBytesDown, bytesDown)
 
 
-	clientsChanged := connectingClients != p.lastConnectingClients ||
+	personalRegionActivity := p.snapshotAndResetRegionActivity(
+		&p.personalStatsMutex, p.personalRegionActivity)
+
+	commonRegionActivity := p.snapshotAndResetRegionActivity(
+		&p.commonStatsMutex, p.commonRegionActivity)
+
+	stateChanged := announcing != p.lastAnnouncing ||
+		connectingClients != p.lastConnectingClients ||
 		connectedClients != p.lastConnectedClients
 		connectedClients != p.lastConnectedClients
 
 
+	p.lastAnnouncing = announcing
 	p.lastConnectingClients = connectingClients
 	p.lastConnectingClients = connectingClients
 	p.lastConnectedClients = connectedClients
 	p.lastConnectedClients = connectedClients
 
 
-	if !clientsChanged &&
+	if !stateChanged &&
 		bytesUp == 0 &&
 		bytesUp == 0 &&
 		bytesDown == 0 {
 		bytesDown == 0 {
-		// Skip the activity callback on idle bytes or no change in client counts.
+		// Skip the activity callback on idle bytes or no change in worker state.
 		return
 		return
 	}
 	}
 
 
 	p.config.ActivityUpdater(
 	p.config.ActivityUpdater(
+		announcing,
 		connectingClients,
 		connectingClients,
 		connectedClients,
 		connectedClients,
 		bytesUp,
 		bytesUp,
 		bytesDown,
 		bytesDown,
-		period)
+		period,
+		personalRegionActivity,
+		commonRegionActivity)
+}
+
+// getOrCreateRegionActivity returns the RegionActivity for a region, creating it
+// if needed. This should be called once at connection start to avoid multiple
+// lock usage.
+func (p *Proxy) getOrCreateRegionActivity(region string, isPersonal bool) *RegionActivity {
+	var mutex *sync.Mutex
+	var statsMap map[string]*RegionActivity
+	if isPersonal {
+		mutex = &p.personalStatsMutex
+		statsMap = p.personalRegionActivity
+	} else {
+		mutex = &p.commonStatsMutex
+		statsMap = p.commonRegionActivity
+	}
+	mutex.Lock()
+	defer mutex.Unlock()
+	stats, exists := statsMap[region]
+	if !exists {
+		stats = &RegionActivity{}
+		statsMap[region] = stats
+	}
+	return stats
+}
+
+// snapshotAndResetRegionActivity creates a copy of region stats with bytes reset
+// to zero, and prunes any entries that have no active connections and zero
+// bytes. The snapshot mechanism allows us to avoid holding locks during the
+// callback invocation.
+func (p *Proxy) snapshotAndResetRegionActivity(
+	mutex *sync.Mutex,
+	statsMap map[string]*RegionActivity,
+) map[string]RegionActivitySnapshot {
+	mutex.Lock()
+	defer mutex.Unlock()
+	result := make(map[string]RegionActivitySnapshot, len(statsMap))
+	regionsToDelete := []string{}
+	for region, stats := range statsMap {
+		snapshot := RegionActivitySnapshot{
+			BytesUp:           stats.bytesUp.Swap(0),
+			BytesDown:         stats.bytesDown.Swap(0),
+			ConnectingClients: stats.connectingClients.Load(),
+			ConnectedClients:  stats.connectedClients.Load(),
+		}
+		if snapshot.BytesUp > 0 || snapshot.BytesDown > 0 ||
+			snapshot.ConnectingClients > 0 || snapshot.ConnectedClients > 0 {
+			result[region] = snapshot
+		} else {
+			regionsToDelete = append(regionsToDelete, region)
+		}
+	}
+	for _, region := range regionsToDelete {
+		delete(statsMap, region)
+	}
+	return result
 }
 }
 
 
 func greaterThanSwapInt64(addr *atomic.Int64, new int64) bool {
 func greaterThanSwapInt64(addr *atomic.Int64, new int64) bool {
@@ -333,8 +548,95 @@ func greaterThanSwapInt64(addr *atomic.Int64, new int64) bool {
 	return false
 	return false
 }
 }
 
 
+func (p *Proxy) isReducedUntil() (int, time.Time) {
+	if !p.useReducedSettings {
+		return p.config.MaxCommonClients, time.Time{}
+	}
+
+	now := time.Now().UTC()
+	minute := now.Hour()*60 + now.Minute()
+
+	isReduced := false
+	if p.reducedStartMinute < p.reducedEndMinute {
+		isReduced = minute >= p.reducedStartMinute && minute < p.reducedEndMinute
+	} else {
+		isReduced = minute >= p.reducedStartMinute || minute < p.reducedEndMinute
+	}
+
+	if !isReduced {
+		return p.config.MaxCommonClients, time.Time{}
+	}
+
+	endHour := p.reducedEndMinute / 60
+	endMinute := p.reducedEndMinute % 60
+	endTime := time.Date(
+		now.Year(),
+		now.Month(),
+		now.Day(),
+		endHour,
+		endMinute,
+		0,
+		0,
+		now.Location(),
+	)
+	if !endTime.After(now) {
+		endTime = endTime.AddDate(0, 0, 1)
+	}
+	return p.config.ReducedMaxCommonClients, endTime
+}
+
+func (p *Proxy) getLimits() (int, int, common.RateLimits) {
+
+	rateLimits := common.RateLimits{
+		ReadBytesPerSecond:  int64(p.config.LimitUpstreamBytesPerSecond),
+		WriteBytesPerSecond: int64(p.config.LimitDownstreamBytesPerSecond),
+	}
+
+	maxCommonClients, reducedUntil := p.isReducedUntil()
+	if !reducedUntil.IsZero() {
+
+		upstream := p.config.ReducedLimitUpstreamBytesPerSecond
+		if upstream == 0 {
+			upstream = p.config.LimitUpstreamBytesPerSecond
+		}
+
+		downstream := p.config.ReducedLimitDownstreamBytesPerSecond
+		if downstream == 0 {
+			downstream = p.config.LimitDownstreamBytesPerSecond
+		}
+
+		rateLimits = common.RateLimits{
+			ReadBytesPerSecond:  int64(upstream),
+			WriteBytesPerSecond: int64(downstream),
+		}
+	}
+
+	maxPersonalClients := p.config.MaxPersonalClients
+
+	return maxCommonClients, maxPersonalClients, rateLimits
+}
+
+// getAnnounceDelayParameters is a helper that fetches the proxy announcement
+// delay parameters from the current broker client.
+//
+// getAnnounceDelayParameters is used to configure a delay when
+// proxyOneClient fails. As having no broker clients is a possible
+// proxyOneClient failure case, GetBrokerClient errors are ignored here and
+// defaults used in that case.
+func (p *Proxy) getAnnounceDelayParameters() (time.Duration, time.Duration, float64) {
+	brokerClient, err := p.config.GetBrokerClient()
+	if err != nil {
+		return proxyAnnounceDelay, proxyAnnounceMaxBackoffDelay, proxyAnnounceDelayJitter
+	}
+	brokerCoordinator := brokerClient.GetBrokerDialCoordinator()
+	return common.ValueOrDefault(brokerCoordinator.AnnounceDelay(), proxyAnnounceDelay),
+		common.ValueOrDefault(brokerCoordinator.AnnounceMaxBackoffDelay(), proxyAnnounceMaxBackoffDelay),
+		common.ValueOrDefault(brokerCoordinator.AnnounceDelayJitter(), proxyAnnounceDelayJitter)
+
+}
+
 func (p *Proxy) proxyClients(
 func (p *Proxy) proxyClients(
-	ctx context.Context, signalAnnounceDone func()) {
+	ctx context.Context, signalAnnounceDone func(), reducedPause bool, isPersonal bool) {
 
 
 	// Proxy one client, repeating until ctx is done.
 	// Proxy one client, repeating until ctx is done.
 	//
 	//
@@ -375,12 +677,39 @@ func (p *Proxy) proxyClients(
 		return false
 		return false
 	}
 	}
 
 
+	preCheckTacticsDone := false
+
 	for ctx.Err() == nil {
 	for ctx.Err() == nil {
 
 
 		if !p.config.WaitForNetworkConnectivity() {
 		if !p.config.WaitForNetworkConnectivity() {
 			break
 			break
 		}
 		}
 
 
+		// Pause designated workers during the reduced time range. In-flight
+		// announces are not interrupted and connected clients are not
+		// disconnected, so there is a gradual transition into reduced mode.
+
+		if reducedPause {
+			_, reducedUntil := p.isReducedUntil()
+			if !reducedUntil.IsZero() {
+
+				pauseDuration := time.Until(reducedUntil)
+				p.config.Logger.WithTraceFields(common.LogFields{
+					"duration": pauseDuration.String(),
+				}).Info("pause worker")
+
+				timer := time.NewTimer(pauseDuration)
+				select {
+				case <-timer.C:
+				case <-ctx.Done():
+				}
+				timer.Stop()
+				if ctx.Err() != nil {
+					break
+				}
+			}
+		}
+
 		if time.Since(startLogSampleTime) >= proxyAnnounceLogSamplePeriod {
 		if time.Since(startLogSampleTime) >= proxyAnnounceLogSamplePeriod {
 			logAnnounceCount = proxyAnnounceLogSampleSize
 			logAnnounceCount = proxyAnnounceLogSampleSize
 			logErrorsCount = proxyAnnounceLogSampleSize
 			logErrorsCount = proxyAnnounceLogSampleSize
@@ -389,7 +718,7 @@ func (p *Proxy) proxyClients(
 		}
 		}
 
 
 		backOff, err := p.proxyOneClient(
 		backOff, err := p.proxyOneClient(
-			ctx, logAnnounce, signalAnnounceDone)
+			ctx, logAnnounce, &preCheckTacticsDone, signalAnnounceDone, isPersonal)
 
 
 		if !backOff || err == nil {
 		if !backOff || err == nil {
 			failureDelayFactor = 1
 			failureDelayFactor = 1
@@ -514,7 +843,9 @@ func (p *Proxy) doNetworkDiscovery(
 func (p *Proxy) proxyOneClient(
 func (p *Proxy) proxyOneClient(
 	ctx context.Context,
 	ctx context.Context,
 	logAnnounce func() bool,
 	logAnnounce func() bool,
-	signalAnnounceDone func()) (bool, error) {
+	preCheckTacticsDone *bool,
+	signalAnnounceDone func(),
+	isPersonal bool) (bool, error) {
 
 
 	// Cancel/close this connection immediately if the network changes.
 	// Cancel/close this connection immediately if the network changes.
 	if p.config.GetCurrentNetworkContext != nil {
 	if p.config.GetCurrentNetworkContext != nil {
@@ -588,7 +919,10 @@ func (p *Proxy) proxyOneClient(
 
 
 	// Only the first worker, which has signalAnnounceDone configured, checks
 	// Only the first worker, which has signalAnnounceDone configured, checks
 	// for tactics.
 	// for tactics.
-	checkTactics := signalAnnounceDone != nil
+	checkTactics := signalAnnounceDone != nil && *preCheckTacticsDone
+	preCheckTactics := signalAnnounceDone != nil && !*preCheckTacticsDone
+
+	maxCommonClients, maxPersonalClients, rateLimits := p.getLimits()
 
 
 	// Get the base Psiphon API parameters and additional proxy metrics,
 	// Get the base Psiphon API parameters and additional proxy metrics,
 	// including performance information, which is sent to the broker in the
 	// including performance information, which is sent to the broker in the
@@ -601,7 +935,12 @@ func (p *Proxy) proxyOneClient(
 	// with the original network ID.
 	// with the original network ID.
 
 
 	metrics, tacticsNetworkID, compressTactics, err := p.getMetrics(
 	metrics, tacticsNetworkID, compressTactics, err := p.getMetrics(
-		checkTactics, brokerCoordinator, webRTCCoordinator)
+		checkTactics || preCheckTactics,
+		brokerCoordinator,
+		webRTCCoordinator,
+		maxCommonClients,
+		maxPersonalClients,
+		rateLimits)
 	if err != nil {
 	if err != nil {
 		return backOff, errors.Trace(err)
 		return backOff, errors.Trace(err)
 	}
 	}
@@ -609,9 +948,9 @@ func (p *Proxy) proxyOneClient(
 	// Set a delay before announcing, to stagger the announce request times.
 	// Set a delay before announcing, to stagger the announce request times.
 	// The delay helps to avoid triggering rate limits or similar errors from
 	// The delay helps to avoid triggering rate limits or similar errors from
 	// any intermediate CDN between the proxy and the broker; and provides a
 	// any intermediate CDN between the proxy and the broker; and provides a
-	// nudge towards better load balancing across multiple large MaxClients
-	// proxies, as the broker primarily matches enqueued announces in FIFO
-	// order, since older announces expire earlier.
+	// nudge towards better load balancing across multiple large
+	// MaxCommonClients proxies, as the broker primarily matches enqueued
+	// announces in FIFO order, since older announces expire earlier.
 	//
 	//
 	// The delay is intended to be applied after doNetworkDiscovery, which has
 	// The delay is intended to be applied after doNetworkDiscovery, which has
 	// no reason to be delayed; and also after any waitToShareSession delay,
 	// no reason to be delayed; and also after any waitToShareSession delay,
@@ -652,8 +991,16 @@ func (p *Proxy) proxyOneClient(
 	//
 	//
 	// ProxyAnnounce applies an additional request timeout to facilitate
 	// ProxyAnnounce applies an additional request timeout to facilitate
 	// long-polling.
 	// long-polling.
+
+	p.announcing.Add(1)
+
 	announceStartTime := time.Now()
 	announceStartTime := time.Now()
-	personalCompartmentIDs := brokerCoordinator.PersonalCompartmentIDs()
+
+	// Ignore the personalCompartmentIDs if this proxy is not personal
+	var personalCompartmentIDs []ID
+	if isPersonal {
+		personalCompartmentIDs = brokerCoordinator.PersonalCompartmentIDs()
+	}
 	announceResponse, err := brokerClient.ProxyAnnounce(
 	announceResponse, err := brokerClient.ProxyAnnounce(
 		ctx,
 		ctx,
 		requestDelay,
 		requestDelay,
@@ -661,6 +1008,7 @@ func (p *Proxy) proxyOneClient(
 			PersonalCompartmentIDs: personalCompartmentIDs,
 			PersonalCompartmentIDs: personalCompartmentIDs,
 			Metrics:                metrics,
 			Metrics:                metrics,
 			CheckTactics:           checkTactics,
 			CheckTactics:           checkTactics,
+			PreCheckTactics:        preCheckTactics,
 		})
 		})
 	if logAnnounce() {
 	if logAnnounce() {
 		p.config.Logger.WithTraceFields(common.LogFields{
 		p.config.Logger.WithTraceFields(common.LogFields{
@@ -668,6 +1016,9 @@ func (p *Proxy) proxyOneClient(
 			"elapsedTime": time.Since(announceStartTime).String(),
 			"elapsedTime": time.Since(announceStartTime).String(),
 		}).Info("announcement request")
 		}).Info("announcement request")
 	}
 	}
+
+	p.announcing.Add(-1)
+
 	if err != nil {
 	if err != nil {
 		return backOff, errors.Trace(err)
 		return backOff, errors.Trace(err)
 	}
 	}
@@ -690,12 +1041,16 @@ func (p *Proxy) proxyOneClient(
 		}
 		}
 	}
 	}
 
 
-	// Signal that the announce round trip is complete. At this point, the
-	// broker Noise session should be established and any fresh tactics
-	// applied.
+	// Signal that the announce round trip is complete, allowing other workers
+	// to launch. At this point, the broker Noise session should be established
+	// and any fresh tactics applied. Also toggle preCheckTacticsDone since
+	// there's no need to retry PreCheckTactics once a round trip succeeds.
 	if signalAnnounceDone != nil {
 	if signalAnnounceDone != nil {
 		signalAnnounceDone()
 		signalAnnounceDone()
 	}
 	}
+	if preCheckTactics {
+		*preCheckTacticsDone = true
+	}
 
 
 	// MustUpgrade has precedence over other cases, to ensure the callback is
 	// MustUpgrade has precedence over other cases, to ensure the callback is
 	// invoked. Trigger back-off back off when rate/entry limited or must
 	// invoked. Trigger back-off back off when rate/entry limited or must
@@ -716,10 +1071,24 @@ func (p *Proxy) proxyOneClient(
 
 
 	} else if announceResponse.NoMatch {
 	} else if announceResponse.NoMatch {
 
 
+		// No backoff for no-match.
+		//
+		// This is also the expected response for CheckTactics with a tactics
+		// payload and PreCheckTactics with or without a tactics payload,
+		// distinct cases which should not back off.
+
 		return backOff, errors.TraceNew("no match")
 		return backOff, errors.TraceNew("no match")
 
 
 	}
 	}
 
 
+	if preCheckTactics && !announceResponse.NoMatch {
+
+		// Sanity check: the broker should always respond with no-match for
+		// PreCheckTactics.
+
+		return backOff, errors.TraceNew("unexpected PreCheckTactics response")
+	}
+
 	if announceResponse.SelectedProtocolVersion < ProtocolVersion1 ||
 	if announceResponse.SelectedProtocolVersion < ProtocolVersion1 ||
 		(announceResponse.UseMediaStreams &&
 		(announceResponse.UseMediaStreams &&
 			announceResponse.SelectedProtocolVersion < ProtocolVersion2) ||
 			announceResponse.SelectedProtocolVersion < ProtocolVersion2) ||
@@ -731,23 +1100,36 @@ func (p *Proxy) proxyOneClient(
 			announceResponse.SelectedProtocolVersion)
 			announceResponse.SelectedProtocolVersion)
 	}
 	}
 
 
+	clientRegion := announceResponse.ClientRegion
+	var regionActivity *RegionActivity
+	if clientRegion != "" {
+		regionActivity = p.getOrCreateRegionActivity(clientRegion, isPersonal)
+	}
+
+	// Create per-connection activity wrapper with cached regionActivity pointer
+	connActivityWrapper := &connectionActivityWrapper{
+		p:              p,
+		regionActivity: regionActivity,
+	}
+
 	// Trigger back-off if the following WebRTC operations fail to establish a
 	// Trigger back-off if the following WebRTC operations fail to establish a
 	// connections.
 	// connections.
-	//
-	// Limitation: the proxy answer request to the broker may fail due to the
-	// non-back-off reasons documented above for the proxy announcment request;
-	// however, these should be unlikely assuming that the broker client is
-	// using a persistent transport connection.
 
 
 	backOff = true
 	backOff = true
 
 
 	// For activity updates, indicate that a client connection is now underway.
 	// For activity updates, indicate that a client connection is now underway.
 
 
-	atomic.AddInt32(&p.connectingClients, 1)
+	p.connectingClients.Add(1)
+	if regionActivity != nil {
+		regionActivity.connectingClients.Add(1)
+	}
 	connected := false
 	connected := false
 	defer func() {
 	defer func() {
 		if !connected {
 		if !connected {
-			atomic.AddInt32(&p.connectingClients, -1)
+			p.connectingClients.Add(-1)
+			if regionActivity != nil {
+				regionActivity.connectingClients.Add(-1)
+			}
 		}
 		}
 	}()
 	}()
 
 
@@ -796,7 +1178,7 @@ func (p *Proxy) proxyOneClient(
 
 
 	// Send answer request with SDP or error.
 	// Send answer request with SDP or error.
 
 
-	_, err = brokerClient.ProxyAnswer(
+	answerResponse, err := brokerClient.ProxyAnswer(
 		ctx,
 		ctx,
 		&ProxyAnswerRequest{
 		&ProxyAnswerRequest{
 			ConnectionID:      announceResponse.ConnectionID,
 			ConnectionID:      announceResponse.ConnectionID,
@@ -809,6 +1191,11 @@ func (p *Proxy) proxyOneClient(
 			// Prioritize returning any WebRTC error for logging.
 			// Prioritize returning any WebRTC error for logging.
 			return backOff, webRTCErr
 			return backOff, webRTCErr
 		}
 		}
+
+		// Don't backoff if the answer request fails due to possible transient
+		// request transport errors.
+
+		backOff = false
 		return backOff, errors.Trace(err)
 		return backOff, errors.Trace(err)
 	}
 	}
 
 
@@ -818,6 +1205,22 @@ func (p *Proxy) proxyOneClient(
 		return backOff, webRTCErr
 		return backOff, webRTCErr
 	}
 	}
 
 
+	// Exit if the client was no longer awaiting the answer. There is no
+	// backoff in this case, and there's no error, as the proxy did not fail
+	// as it's not an unexpected outcome.
+	//
+	// Limitation: it's possible that the announce request responds quickly
+	// and the matched client offer is already close to timing out. The
+	// answer request will also respond quickly. There's an increased chance
+	// of hitting rate limits in this fast turn around scenario. This outcome
+	// is mitigated by InproxyBrokerMatcherOfferMinimumDeadline.
+
+	if answerResponse.NoAwaitingClient {
+
+		backOff = false
+		return backOff, nil
+	}
+
 	// Await the WebRTC connection.
 	// Await the WebRTC connection.
 
 
 	// We could concurrently dial the destination, to have that network
 	// We could concurrently dial the destination, to have that network
@@ -881,10 +1284,17 @@ func (p *Proxy) proxyOneClient(
 	// For activity updates, indicate that a client connection is established.
 	// For activity updates, indicate that a client connection is established.
 
 
 	connected = true
 	connected = true
-	atomic.AddInt32(&p.connectingClients, -1)
-	atomic.AddInt32(&p.connectedClients, 1)
+	p.connectingClients.Add(-1)
+	p.connectedClients.Add(1)
+	if regionActivity != nil {
+		regionActivity.connectingClients.Add(-1)
+		regionActivity.connectedClients.Add(1)
+	}
 	defer func() {
 	defer func() {
-		atomic.AddInt32(&p.connectedClients, -1)
+		p.connectedClients.Add(-1)
+		if regionActivity != nil {
+			regionActivity.connectedClients.Add(-1)
+		}
 	}()
 	}()
 
 
 	// Throttle the relay connection.
 	// Throttle the relay connection.
@@ -892,17 +1302,18 @@ func (p *Proxy) proxyOneClient(
 	// Here, each client gets LimitUp/DownstreamBytesPerSecond. Proxy
 	// Here, each client gets LimitUp/DownstreamBytesPerSecond. Proxy
 	// operators may to want to limit their bandwidth usage with a single
 	// operators may to want to limit their bandwidth usage with a single
 	// up/down value, an overall limit. The ProxyConfig can simply be
 	// up/down value, an overall limit. The ProxyConfig can simply be
-	// generated by dividing the limit by MaxClients. This approach favors
-	// performance stability: each client gets the same throttling limits
-	// regardless of how many other clients are connected.
+	// generated by dividing the limit by MaxCommonClients + MaxPersonalClients.
+	// This approach favors performance stability: each client gets the
+	// same throttling limits regardless of how many other clients are connected.
+	//
+	// Rate limits are applied only when a client connection is established;
+	// connected clients retain their initial limits even when reduced time
+	// starts or ends.
 
 
 	destinationConn = common.NewThrottledConn(
 	destinationConn = common.NewThrottledConn(
 		destinationConn,
 		destinationConn,
 		announceResponse.NetworkProtocol.IsStream(),
 		announceResponse.NetworkProtocol.IsStream(),
-		common.RateLimits{
-			ReadBytesPerSecond:  int64(p.config.LimitUpstreamBytesPerSecond),
-			WriteBytesPerSecond: int64(p.config.LimitDownstreamBytesPerSecond),
-		})
+		rateLimits)
 
 
 	// Hook up bytes transferred counting for activity updates.
 	// Hook up bytes transferred counting for activity updates.
 
 
@@ -920,7 +1331,7 @@ func (p *Proxy) proxyOneClient(
 			proxyRelayInactivityTimeout)
 			proxyRelayInactivityTimeout)
 
 
 	destinationConn, err = common.NewActivityMonitoredConn(
 	destinationConn, err = common.NewActivityMonitoredConn(
-		destinationConn, inactivityTimeout, false, nil, p.activityUpdateWrapper)
+		destinationConn, inactivityTimeout, false, nil, connActivityWrapper)
 	if err != nil {
 	if err != nil {
 		return backOff, errors.Trace(err)
 		return backOff, errors.Trace(err)
 	}
 	}
@@ -1012,7 +1423,10 @@ func (p *Proxy) proxyOneClient(
 func (p *Proxy) getMetrics(
 func (p *Proxy) getMetrics(
 	includeTacticsParameters bool,
 	includeTacticsParameters bool,
 	brokerCoordinator BrokerDialCoordinator,
 	brokerCoordinator BrokerDialCoordinator,
-	webRTCCoordinator WebRTCDialCoordinator) (
+	webRTCCoordinator WebRTCDialCoordinator,
+	maxCommonClients int,
+	maxPersonalClients int,
+	rateLimits common.RateLimits) (
 	*ProxyMetrics, string, bool, error) {
 	*ProxyMetrics, string, bool, error) {
 
 
 	// tacticsNetworkID records the exact network ID that corresponds to the
 	// tacticsNetworkID records the exact network ID that corresponds to the
@@ -1040,11 +1454,12 @@ func (p *Proxy) getMetrics(
 		ProtocolVersion:               LatestProtocolVersion,
 		ProtocolVersion:               LatestProtocolVersion,
 		NATType:                       webRTCCoordinator.NATType(),
 		NATType:                       webRTCCoordinator.NATType(),
 		PortMappingTypes:              webRTCCoordinator.PortMappingTypes(),
 		PortMappingTypes:              webRTCCoordinator.PortMappingTypes(),
-		MaxClients:                    int32(p.config.MaxClients),
-		ConnectingClients:             atomic.LoadInt32(&p.connectingClients),
-		ConnectedClients:              atomic.LoadInt32(&p.connectedClients),
-		LimitUpstreamBytesPerSecond:   int64(p.config.LimitUpstreamBytesPerSecond),
-		LimitDownstreamBytesPerSecond: int64(p.config.LimitDownstreamBytesPerSecond),
+		MaxCommonClients:              int32(maxCommonClients),
+		MaxPersonalClients:            int32(maxPersonalClients),
+		ConnectingClients:             p.connectingClients.Load(),
+		ConnectedClients:              p.connectedClients.Load(),
+		LimitUpstreamBytesPerSecond:   rateLimits.ReadBytesPerSecond,
+		LimitDownstreamBytesPerSecond: rateLimits.WriteBytesPerSecond,
 		PeakUpstreamBytesPerSecond:    p.peakBytesUp.Load(),
 		PeakUpstreamBytesPerSecond:    p.peakBytesUp.Load(),
 		PeakDownstreamBytesPerSecond:  p.peakBytesDown.Load(),
 		PeakDownstreamBytesPerSecond:  p.peakBytesDown.Load(),
 	}, tacticsNetworkID, compressTactics, nil
 	}, tacticsNetworkID, compressTactics, nil

+ 111 - 0
psiphon/common/inproxy/reduced_test.go

@@ -0,0 +1,111 @@
+/*
+ * 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 <http://www.gnu.org/licenses/>.
+ *
+ */
+
+package inproxy
+
+import (
+	"testing"
+	"time"
+
+	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common/errors"
+)
+
+func TestReduced(t *testing.T) {
+	err := runTestReduced()
+	if err != nil {
+		t.Error(errors.Trace(err).Error())
+	}
+}
+
+func runTestReduced() error {
+
+	now := time.Now().UTC()
+	minuteOfDay := now.Hour()*60 + now.Minute()
+
+	addMinutes := func(minute, delta int) int {
+		m := (minute + delta) % (24 * 60)
+		if m < 0 {
+			m += 24 * 60
+		}
+		return m
+	}
+
+	// Test: inside reduced period
+
+	start := addMinutes(minuteOfDay, -60)
+	end := addMinutes(minuteOfDay, 60)
+
+	config := &ProxyConfig{
+		MaxCommonClients:                     10,
+		ReducedMaxCommonClients:              5,
+		LimitUpstreamBytesPerSecond:          100,
+		LimitDownstreamBytesPerSecond:        200,
+		ReducedLimitUpstreamBytesPerSecond:   10,
+		ReducedLimitDownstreamBytesPerSecond: 20,
+	}
+
+	config.ReducedStartTime = time.Unix(int64(start*60), 0).UTC().Format("15:04")
+	config.ReducedEndTime = time.Unix(int64(end*60), 0).UTC().Format("15:04")
+
+	p, err := NewProxy(config)
+	if err != nil {
+		return errors.Trace(err)
+	}
+
+	maxClients1, until := p.isReducedUntil()
+	maxClients2, _, limits := p.getLimits()
+
+	if maxClients1 != 5 || maxClients2 != 5 {
+		return errors.TraceNew("unexpected maxClients")
+	}
+	if until.IsZero() || time.Until(until) <= 0 {
+		return errors.TraceNew("unexpected until")
+	}
+	if limits.ReadBytesPerSecond != 10 || limits.WriteBytesPerSecond != 20 {
+		return errors.TraceNew("unexpected rate limits")
+	}
+
+	// Test: outside reduced period
+
+	start = addMinutes(minuteOfDay, 60)
+	end = addMinutes(minuteOfDay, 120)
+
+	config.ReducedStartTime = time.Unix(int64(start*60), 0).UTC().Format("15:04")
+	config.ReducedEndTime = time.Unix(int64(end*60), 0).UTC().Format("15:04")
+
+	p, err = NewProxy(config)
+	if err != nil {
+		return errors.Trace(err)
+	}
+
+	maxClients1, until = p.isReducedUntil()
+	maxClients2, _, limits = p.getLimits()
+
+	if maxClients1 != 10 || maxClients2 != 10 {
+		return errors.TraceNew("unexpected maxClients")
+	}
+	if !until.IsZero() {
+		return errors.TraceNew("unexpected until")
+	}
+	if limits.ReadBytesPerSecond != 100 || limits.WriteBytesPerSecond != 200 {
+		return errors.TraceNew("unexpected rate limits")
+	}
+
+	return nil
+}

+ 1 - 1
psiphon/common/inproxy/sdp_test.go

@@ -1,4 +1,4 @@
-//go:build PSIPHON_ENABLE_INPROXY
+//go:build !PSIPHON_DISABLE_INPROXY
 
 
 /*
 /*
  * Copyright (c) 2024, Psiphon Inc.
  * Copyright (c) 2024, Psiphon Inc.

+ 7 - 2
psiphon/common/inproxy/session.go

@@ -1237,7 +1237,7 @@ func (s *ResponderSessions) touchSession(sessionID ID, session *session) {
 	s.mutex.Lock()
 	s.mutex.Lock()
 	defer s.mutex.Unlock()
 	defer s.mutex.Unlock()
 
 
-	if !session.hasUnexpectedInitiatorPublicKey() {
+	if session.hasUnexpectedInitiatorPublicKey() {
 
 
 		// In this case, SetKnownInitiatorPublicKeys was called concurrent to
 		// In this case, SetKnownInitiatorPublicKeys was called concurrent to
 		// HandlePacket, after HandlePacket's getSession, and now the known
 		// HandlePacket, after HandlePacket's getSession, and now the known
@@ -1306,9 +1306,14 @@ func (s *ResponderSessions) getSession(sessionID ID) (*session, error) {
 		return nil, errors.Trace(err)
 		return nil, errors.Trace(err)
 	}
 	}
 
 
+	TTL := lrucache.DefaultExpiration
+	if !s.applyTTL {
+		TTL = lrucache.NoExpiration
+	}
+
 	s.mutex.Lock()
 	s.mutex.Lock()
 	err = s.sessions.Add(
 	err = s.sessions.Add(
-		strSessionID, session, lrucache.DefaultExpiration)
+		strSessionID, session, TTL)
 	s.mutex.Unlock()
 	s.mutex.Unlock()
 
 
 	if err != nil {
 	if err != nil {

+ 1 - 1
psiphon/common/inproxy/webrtc.go

@@ -1,4 +1,4 @@
-//go:build PSIPHON_ENABLE_INPROXY
+//go:build !PSIPHON_DISABLE_INPROXY
 
 
 /*
 /*
  * Copyright (c) 2023, Psiphon Inc.
  * Copyright (c) 2023, Psiphon Inc.

+ 4 - 0
psiphon/common/parameters/parameters.go

@@ -409,6 +409,7 @@ const (
 	InproxyAllBrokerSpecs                              = "InproxyAllBrokerSpecs"
 	InproxyAllBrokerSpecs                              = "InproxyAllBrokerSpecs"
 	InproxyBrokerSpecs                                 = "InproxyBrokerSpecs"
 	InproxyBrokerSpecs                                 = "InproxyBrokerSpecs"
 	InproxyPersonalPairingBrokerSpecs                  = "InproxyPersonalPairingBrokerSpecs"
 	InproxyPersonalPairingBrokerSpecs                  = "InproxyPersonalPairingBrokerSpecs"
+	InproxyPersonalPairingMaxBrokerSpecCount           = "InproxyPersonalPairingMaxBrokerSpecCount"
 	InproxyProxyBrokerSpecs                            = "InproxyProxyBrokerSpecs"
 	InproxyProxyBrokerSpecs                            = "InproxyProxyBrokerSpecs"
 	InproxyProxyPersonalPairingBrokerSpecs             = "InproxyProxyPersonalPairingBrokerSpecs"
 	InproxyProxyPersonalPairingBrokerSpecs             = "InproxyProxyPersonalPairingBrokerSpecs"
 	InproxyClientBrokerSpecs                           = "InproxyClientBrokerSpecs"
 	InproxyClientBrokerSpecs                           = "InproxyClientBrokerSpecs"
@@ -427,6 +428,7 @@ const (
 	InproxyBrokerMatcherOfferLimitEntryCount           = "InproxyBrokerMatcherOfferLimitEntryCount"
 	InproxyBrokerMatcherOfferLimitEntryCount           = "InproxyBrokerMatcherOfferLimitEntryCount"
 	InproxyBrokerMatcherOfferRateLimitQuantity         = "InproxyBrokerMatcherOfferRateLimitQuantity"
 	InproxyBrokerMatcherOfferRateLimitQuantity         = "InproxyBrokerMatcherOfferRateLimitQuantity"
 	InproxyBrokerMatcherOfferRateLimitInterval         = "InproxyBrokerMatcherOfferRateLimitInterval"
 	InproxyBrokerMatcherOfferRateLimitInterval         = "InproxyBrokerMatcherOfferRateLimitInterval"
+	InproxyBrokerMatcherOfferMinimumDeadline           = "InproxyBrokerMatcherOfferMinimumDeadline"
 	InproxyBrokerMatcherPrioritizeProxiesProbability   = "InproxyBrokerMatcherPrioritizeProxiesProbability"
 	InproxyBrokerMatcherPrioritizeProxiesProbability   = "InproxyBrokerMatcherPrioritizeProxiesProbability"
 	InproxyBrokerMatcherPrioritizeProxiesFilter        = "InproxyBrokerMatcherPrioritizeProxiesFilter"
 	InproxyBrokerMatcherPrioritizeProxiesFilter        = "InproxyBrokerMatcherPrioritizeProxiesFilter"
 	InproxyBrokerMatcherPrioritizeProxiesMinVersion    = "InproxyBrokerMatcherPrioritizeProxiesMinVersion"
 	InproxyBrokerMatcherPrioritizeProxiesMinVersion    = "InproxyBrokerMatcherPrioritizeProxiesMinVersion"
@@ -1041,6 +1043,7 @@ var defaultParameters = map[string]struct {
 	InproxyAllBrokerSpecs:                              {value: InproxyBrokerSpecsValue{}, flags: serverSideOnly},
 	InproxyAllBrokerSpecs:                              {value: InproxyBrokerSpecsValue{}, flags: serverSideOnly},
 	InproxyBrokerSpecs:                                 {value: InproxyBrokerSpecsValue{}},
 	InproxyBrokerSpecs:                                 {value: InproxyBrokerSpecsValue{}},
 	InproxyPersonalPairingBrokerSpecs:                  {value: InproxyBrokerSpecsValue{}},
 	InproxyPersonalPairingBrokerSpecs:                  {value: InproxyBrokerSpecsValue{}},
+	InproxyPersonalPairingMaxBrokerSpecCount:           {value: 3, minimum: 0},
 	InproxyProxyBrokerSpecs:                            {value: InproxyBrokerSpecsValue{}},
 	InproxyProxyBrokerSpecs:                            {value: InproxyBrokerSpecsValue{}},
 	InproxyProxyPersonalPairingBrokerSpecs:             {value: InproxyBrokerSpecsValue{}},
 	InproxyProxyPersonalPairingBrokerSpecs:             {value: InproxyBrokerSpecsValue{}},
 	InproxyClientBrokerSpecs:                           {value: InproxyBrokerSpecsValue{}},
 	InproxyClientBrokerSpecs:                           {value: InproxyBrokerSpecsValue{}},
@@ -1059,6 +1062,7 @@ var defaultParameters = map[string]struct {
 	InproxyBrokerMatcherOfferLimitEntryCount:           {value: 10, minimum: 0, flags: serverSideOnly},
 	InproxyBrokerMatcherOfferLimitEntryCount:           {value: 10, minimum: 0, flags: serverSideOnly},
 	InproxyBrokerMatcherOfferRateLimitQuantity:         {value: 50, minimum: 0, flags: serverSideOnly},
 	InproxyBrokerMatcherOfferRateLimitQuantity:         {value: 50, minimum: 0, flags: serverSideOnly},
 	InproxyBrokerMatcherOfferRateLimitInterval:         {value: 1 * time.Minute, minimum: time.Duration(0), flags: serverSideOnly},
 	InproxyBrokerMatcherOfferRateLimitInterval:         {value: 1 * time.Minute, minimum: time.Duration(0), flags: serverSideOnly},
+	InproxyBrokerMatcherOfferMinimumDeadline:           {value: 1 * time.Second, minimum: time.Duration(0), flags: serverSideOnly},
 	InproxyBrokerMatcherPrioritizeProxiesProbability:   {value: 1.0, minimum: 0.0, flags: serverSideOnly},
 	InproxyBrokerMatcherPrioritizeProxiesProbability:   {value: 1.0, minimum: 0.0, flags: serverSideOnly},
 	InproxyBrokerMatcherPrioritizeProxiesFilter:        {value: KeyStrings{}, flags: serverSideOnly},
 	InproxyBrokerMatcherPrioritizeProxiesFilter:        {value: KeyStrings{}, flags: serverSideOnly},
 	InproxyBrokerMatcherPrioritizeProxiesMinVersion:    {value: 0, minimum: 0, flags: serverSideOnly},
 	InproxyBrokerMatcherPrioritizeProxiesMinVersion:    {value: 0, minimum: 0, flags: serverSideOnly},

+ 6 - 0
psiphon/common/protocol/protocol.go

@@ -62,6 +62,7 @@ const (
 	SERVER_ENTRY_SOURCE_OBFUSCATED = "OBFUSCATED"
 	SERVER_ENTRY_SOURCE_OBFUSCATED = "OBFUSCATED"
 	SERVER_ENTRY_SOURCE_EXCHANGED  = "EXCHANGED"
 	SERVER_ENTRY_SOURCE_EXCHANGED  = "EXCHANGED"
 	SERVER_ENTRY_SOURCE_DSL        = "DSL-*"
 	SERVER_ENTRY_SOURCE_DSL        = "DSL-*"
+	SERVER_ENTRY_SOURCE_PUSH       = "PUSH-*"
 
 
 	CAPABILITY_SSH_API_REQUESTS            = "ssh-api-requests"
 	CAPABILITY_SSH_API_REQUESTS            = "ssh-api-requests"
 	CAPABILITY_UNTUNNELED_WEB_API_REQUESTS = "handshake"
 	CAPABILITY_UNTUNNELED_WEB_API_REQUESTS = "handshake"
@@ -116,6 +117,11 @@ var SupportedServerEntrySources = []string{
 	SERVER_ENTRY_SOURCE_OBFUSCATED,
 	SERVER_ENTRY_SOURCE_OBFUSCATED,
 	SERVER_ENTRY_SOURCE_EXCHANGED,
 	SERVER_ENTRY_SOURCE_EXCHANGED,
 	SERVER_ENTRY_SOURCE_DSL,
 	SERVER_ENTRY_SOURCE_DSL,
+	SERVER_ENTRY_SOURCE_PUSH,
+}
+
+func PushServerEntrySource(source string) string {
+	return "PUSH-" + source
 }
 }
 
 
 func AllowServerEntrySourceWithUpstreamProxy(source string) bool {
 func AllowServerEntrySourceWithUpstreamProxy(source string) bool {

+ 1 - 3
psiphon/common/protocol/serverEntry.go

@@ -1078,9 +1078,7 @@ func encodeServerEntry(
 // used by remote server lists and Psiphon server handshake requests.
 // used by remote server lists and Psiphon server handshake requests.
 //
 //
 // The resulting ServerEntry.LocalSource is populated with serverEntrySource,
 // The resulting ServerEntry.LocalSource is populated with serverEntrySource,
-// which should be one of SERVER_ENTRY_SOURCE_EMBEDDED, SERVER_ENTRY_SOURCE_REMOTE,
-// SERVER_ENTRY_SOURCE_DISCOVERY, SERVER_ENTRY_SOURCE_TARGET,
-// SERVER_ENTRY_SOURCE_OBFUSCATED.
+// which should be one of SupportedServerEntrySources.
 // ServerEntry.LocalTimestamp is populated with the provided timestamp, which
 // ServerEntry.LocalTimestamp is populated with the provided timestamp, which
 // should be a RFC 3339 formatted string. These local fields are stored with the
 // should be a RFC 3339 formatted string. These local fields are stored with the
 // server entry and reported to the server as stats (a coarse granularity timestamp
 // server entry and reported to the server as stats (a coarse granularity timestamp

+ 16 - 0
psiphon/common/push/converter/README.md

@@ -0,0 +1,16 @@
+# converter
+
+Example usage:
+
+```
+PSIPHON_PUSH_PAYLOAD_OBFUSCATION_KEY=<base64> \
+PSIPHON_PUSH_PAYLOAD_SIGNATURE_PUBLIC_KEY=<base64> \
+PSIPHON_PUSH_PAYLOAD_SIGNATURE_PRIVATE_KEY=<base64> \
+./converter -config <config-filename> -TTL <duration> -source <source description> -prioritize <input-filename>
+```
+
+* Converter is a tool that converts server lists to and from push payloads. Output is emitted to stdout.
+* The type of input file is determined automatically; if the input is a valid server list, it is converted to a push payload; otherwise the input is treated as a push payload and converted to a server list.
+* If an optional Psiphon config file input is provided, the key values, except for `PSIPHON_PUSH_PAYLOAD_SIGNATURE_PRIVATE_KEY`, will be read from the config parameters, if present.
+* `PSIPHON_PUSH_PAYLOAD_SIGNATURE_PRIVATE_KEY`, `TTL`, `source`, `prioritize`, and optional padding inputs are used only when converting to a push payload.
+* Converter does not check individual server entry signatures.

+ 229 - 0
psiphon/common/push/converter/main.go

@@ -0,0 +1,229 @@
+/*
+ * 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 <http://www.gnu.org/licenses/>.
+ *
+ */
+
+package main
+
+import (
+	"flag"
+	"fmt"
+	"io"
+	"os"
+	"strings"
+	"time"
+
+	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon"
+	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common/errors"
+	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common/protocol"
+	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common/push"
+)
+
+func main() {
+
+	var configFile string
+	flag.StringVar(&configFile, "config", "", "Psiphon config file")
+
+	var ttl time.Duration
+	flag.DurationVar(&ttl, "TTL", 24*time.Hour, "payload TTL")
+
+	var source string
+	flag.StringVar(&source, "source", "push-converter", "payload source")
+
+	var prioritize bool
+	flag.BoolVar(&prioritize, "prioritize", false, "prioritize dials for all payload server entries")
+
+	var minPadding int
+	flag.IntVar(&minPadding, "minPadding", 0, "min obfuscated payload padding")
+
+	var maxPadding int
+	flag.IntVar(&maxPadding, "maxPadding", 0, "max obfuscated payload padding")
+
+	flag.Parse()
+
+	obfuscationKey := os.Getenv("PSIPHON_PUSH_PAYLOAD_OBFUSCATION_KEY")
+	signaturePublicKey := os.Getenv("PSIPHON_PUSH_PAYLOAD_SIGNATURE_PUBLIC_KEY")
+	signaturePrivateKey := os.Getenv("PSIPHON_PUSH_PAYLOAD_SIGNATURE_PRIVATE_KEY")
+
+	if configFile != "" {
+		config, err := loadConfig(configFile)
+		if err != nil {
+			fmt.Fprintln(os.Stderr, errors.Trace(err))
+			os.Exit(1)
+		}
+		if config.PushPayloadObfuscationKey != "" {
+			obfuscationKey = config.PushPayloadObfuscationKey
+		}
+		if config.PushPayloadSignaturePublicKey != "" {
+			signaturePublicKey = config.PushPayloadSignaturePublicKey
+		}
+	}
+
+	inputFile := flag.Arg(0)
+	if inputFile == "" {
+		flag.PrintDefaults()
+		os.Exit(1)
+	}
+
+	err := convert(
+		obfuscationKey,
+		minPadding,
+		maxPadding,
+		signaturePublicKey,
+		signaturePrivateKey,
+		inputFile,
+		ttl,
+		source,
+		prioritize)
+	if err != nil {
+		fmt.Fprintln(os.Stderr, errors.Trace(err))
+		os.Exit(1)
+	}
+
+	os.Exit(0)
+}
+
+func convert(
+	obfuscationKey string,
+	minPadding int,
+	maxPadding int,
+	signaturePublicKey string,
+	signaturePrivateKey string,
+	inputFile string,
+	ttl time.Duration,
+	source string,
+	prioritize bool) error {
+
+	input, err := os.ReadFile(inputFile)
+	if err != nil {
+		return errors.Trace(err)
+	}
+
+	// If the input file is a valid server entry list, convert to a push
+	// payload. Otherwise assume the input is a push payload and convert to a
+	// server entry list.
+
+	serverEntryFields, err := decodeServerEntryList(string(input))
+	if err == nil {
+
+		var prioritizedServerEntries []*push.PrioritizedServerEntry
+		for _, serverEntry := range serverEntryFields {
+			packed, err := protocol.EncodePackedServerEntryFields(serverEntry)
+			if err != nil {
+				return errors.Trace(err)
+			}
+
+			prioritizedServerEntries = append(prioritizedServerEntries,
+				&push.PrioritizedServerEntry{
+					ServerEntryFields: packed,
+					Source:            source,
+					PrioritizeDial:    prioritize,
+				})
+		}
+
+		payloads, err := push.MakePushPayloads(
+			obfuscationKey,
+			minPadding,
+			maxPadding,
+			signaturePublicKey,
+			signaturePrivateKey,
+			ttl,
+			[][]*push.PrioritizedServerEntry{
+				prioritizedServerEntries})
+		if err != nil {
+			return errors.Trace(err)
+		}
+
+		os.Stdout.Write(payloads[0])
+		return nil
+	}
+
+	var serverList []string
+	importer := func(
+		packed protocol.PackedServerEntryFields,
+		_ string,
+		_ bool) error {
+
+		serverEntryFields, err := protocol.DecodePackedServerEntryFields(packed)
+		if err != nil {
+			return errors.Trace(err)
+		}
+
+		serverEntry, err := protocol.EncodeServerEntryFields(serverEntryFields)
+		if err != nil {
+			return errors.Trace(err)
+		}
+
+		serverList = append(serverList, serverEntry)
+		return nil
+	}
+
+	_, err = push.ImportPushPayload(
+		obfuscationKey,
+		signaturePublicKey,
+		input,
+		importer)
+	if err != nil {
+		return errors.Trace(err)
+	}
+
+	os.Stdout.Write([]byte(strings.Join(serverList, "\n")))
+	return nil
+}
+
+// decodeServerEntryList is equivalent to protocol.DecodeServerEntryList
+// without local field initialization/validation.
+func decodeServerEntryList(
+	encodedServerEntryList string) ([]protocol.ServerEntryFields, error) {
+
+	serverEntries := make([]protocol.ServerEntryFields, 0)
+	for _, encodedServerEntry := range strings.Split(
+		encodedServerEntryList, "\n") {
+
+		if len(encodedServerEntry) == 0 {
+			continue
+		}
+
+		serverEntryFields, err := protocol.DecodeServerEntryFields(
+			encodedServerEntry, "", "")
+		if err != nil {
+			return nil, errors.Trace(err)
+		}
+
+		serverEntries = append(serverEntries, serverEntryFields)
+	}
+	return serverEntries, nil
+}
+
+func loadConfig(configFile string) (*psiphon.Config, error) {
+
+	psiphon.SetNoticeWriter(io.Discard)
+
+	configJSON, err := os.ReadFile(configFile)
+	if err != nil {
+		return nil, errors.Trace(err)
+	}
+	config, err := psiphon.LoadConfig(configJSON)
+	if err != nil {
+		return nil, errors.Trace(err)
+	}
+	err = config.Commit(false)
+	if err != nil {
+		return nil, errors.Trace(err)
+	}
+	return config, nil
+}

+ 332 - 0
psiphon/common/push/push.go

@@ -0,0 +1,332 @@
+/*
+ * 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 <http://www.gnu.org/licenses/>.
+ *
+ */
+
+// Package push implements server entry push payloads, which support pushing
+// server entries to clients through external distribution channels. Push
+// payloads use the compact packed CBOR server entry representation.
+//
+// Each server entry has an optional prioritize dial flag which is equivalent
+// to dsl.VersionedServerEntryTag.PrioritizedDial.
+//
+// Payloads include an expiry date to ensure freshness and mitigate replay
+// attacks. The entire payload is digitally signed, and an obfuscation layer
+// is added on top.
+package push
+
+import (
+	"bytes"
+	"crypto/aes"
+	"crypto/cipher"
+	"crypto/ed25519"
+	"crypto/rand"
+	"crypto/sha256"
+	"encoding/base64"
+	"time"
+
+	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common/errors"
+	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common/prng"
+	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common/protocol"
+	"github.com/fxamacker/cbor/v2"
+)
+
+const (
+	obfuscationKeySize           = 32
+	signaturePublicKeyDigestSize = 8
+)
+
+// Payload is a push payload, consisting of a list of server entries. To
+// ensure stale server entries and stale dial prioritizations are not
+// imported, the list has an expiry timestamp.
+type Payload struct {
+	Expires                  time.Time                 `cbor:"1,keyasint,omitempty"`
+	PrioritizedServerEntries []*PrioritizedServerEntry `cbor:"2,keyasint,omitempty"`
+}
+
+// SignedPayload is Payload with a digital signature.
+type SignedPayload struct {
+	Signature []byte `cbor:"1,keyasint,omitempty"`
+	Payload   []byte `cbor:"2,keyasint,omitempty"`
+	Padding   []byte `cbor:"3,keyasint,omitempty"`
+}
+
+// PrioritizedServerEntry is a server entry paired with a server entry source
+// description and a dial prioritization indication. PrioritizeDial is
+// equivalent to DSL prioritized dials.
+type PrioritizedServerEntry struct {
+	ServerEntryFields protocol.PackedServerEntryFields `cbor:"1,keyasint,omitempty"`
+	Source            string                           `cbor:"2,keyasint,omitempty"`
+	PrioritizeDial    bool                             `cbor:"3,keyasint,omitempty"`
+}
+
+// ServerEntryImporter is a callback that is invoked for each server entry in
+// an imported push payload.
+type ServerEntryImporter func(
+	packedServerEntryFields protocol.PackedServerEntryFields,
+	source string,
+	prioritizeDial bool) error
+
+// GenerateKeys generates a new obfuscation key and signature key pair for
+// push payloads.
+func GenerateKeys() (
+	payloadObfuscationKey string,
+	payloadSignaturePublicKey string,
+	payloadSignaturePrivateKey string,
+	err error) {
+
+	obfuscationKey := make([]byte, obfuscationKeySize)
+	_, err = rand.Read(obfuscationKey)
+	if err != nil {
+		return "", "", "", errors.Trace(err)
+	}
+
+	publicKey, privateKey, err := ed25519.GenerateKey(rand.Reader)
+	if err != nil {
+		return "", "", "", errors.Trace(err)
+	}
+
+	return base64.StdEncoding.EncodeToString(obfuscationKey),
+		base64.StdEncoding.EncodeToString(publicKey),
+		base64.StdEncoding.EncodeToString(privateKey),
+		nil
+}
+
+// ImportPushPayload imports the input push payload. The ServerEntryImporter
+// callback is invoked for each imported server entry and its associated
+// source and prioritizeDial data.
+func ImportPushPayload(
+	payloadObfuscationKey string,
+	payloadSignaturePublicKey string,
+	obfuscatedPayload []byte,
+	serverEntryImporter ServerEntryImporter) (int, error) {
+
+	obfuscationKey, err := base64.StdEncoding.DecodeString(
+		payloadObfuscationKey)
+	if err == nil && len(obfuscationKey) != obfuscationKeySize {
+		err = errors.TraceNew("unexpected obfuscation key size")
+	}
+	if err != nil {
+		return 0, errors.Trace(err)
+	}
+
+	publicKey, err := base64.StdEncoding.DecodeString(
+		payloadSignaturePublicKey)
+	if err == nil && len(publicKey) != ed25519.PublicKeySize {
+		err = errors.TraceNew("unexpected signature public key size")
+	}
+	if err != nil {
+		return 0, errors.Trace(err)
+	}
+
+	blockCipher, err := aes.NewCipher(obfuscationKey)
+	if err != nil {
+		return 0, errors.Trace(err)
+	}
+
+	aead, err := cipher.NewGCM(blockCipher)
+	if err != nil {
+		return 0, errors.Trace(err)
+	}
+
+	if len(obfuscatedPayload) < aead.NonceSize() {
+		return 0, errors.TraceNew("missing nonce")
+	}
+
+	cborSignedPayload, err := aead.Open(
+		nil,
+		obfuscatedPayload[:aead.NonceSize()],
+		obfuscatedPayload[aead.NonceSize():],
+		nil)
+	if err != nil {
+		return 0, errors.Trace(err)
+	}
+
+	var signedPayload SignedPayload
+	err = cbor.Unmarshal(cborSignedPayload, &signedPayload)
+	if err != nil {
+		return 0, errors.Trace(err)
+	}
+
+	if len(signedPayload.Signature) !=
+		signaturePublicKeyDigestSize+ed25519.SignatureSize {
+
+		return 0, errors.TraceNew("invalid signature size")
+	}
+
+	publicKeyDigest := sha256.Sum256(publicKey)
+	expectedPublicKeyID := publicKeyDigest[:signaturePublicKeyDigestSize]
+
+	if !bytes.Equal(
+		expectedPublicKeyID,
+		signedPayload.Signature[:signaturePublicKeyDigestSize]) {
+
+		return 0, errors.TraceNew("unexpected signature public key ID")
+	}
+
+	if !ed25519.Verify(
+		publicKey,
+		signedPayload.Payload,
+		signedPayload.Signature[signaturePublicKeyDigestSize:]) {
+
+		return 0, errors.TraceNew("invalid signature")
+	}
+
+	var payload Payload
+	err = cbor.Unmarshal(signedPayload.Payload, &payload)
+	if err != nil {
+		return 0, errors.Trace(err)
+	}
+
+	if payload.Expires.Before(time.Now().UTC()) {
+		return 0, errors.TraceNew("payload expired")
+	}
+
+	imported := 0
+	for _, prioritizedServerEntry := range payload.PrioritizedServerEntries {
+		err := serverEntryImporter(
+			prioritizedServerEntry.ServerEntryFields,
+			prioritizedServerEntry.Source,
+			prioritizedServerEntry.PrioritizeDial)
+		if err != nil {
+			return imported, errors.Trace(err)
+		}
+		imported += 1
+	}
+
+	return imported, nil
+}
+
+// MakePushPayloads generates batches of push payloads.
+func MakePushPayloads(
+	payloadObfuscationKey string,
+	minPadding int,
+	maxPadding int,
+	payloadSignaturePublicKey string,
+	payloadSignaturePrivateKey string,
+	TTL time.Duration,
+	prioritizedServerEntries [][]*PrioritizedServerEntry) ([][]byte, error) {
+
+	obfuscationKey, err := base64.StdEncoding.DecodeString(
+		payloadObfuscationKey)
+	if err == nil && len(obfuscationKey) != obfuscationKeySize {
+		err = errors.TraceNew("unexpected obfuscation key size")
+	}
+	if err != nil {
+		return nil, errors.Trace(err)
+	}
+
+	publicKey, err := base64.StdEncoding.DecodeString(
+		payloadSignaturePublicKey)
+	if err == nil && len(publicKey) != ed25519.PublicKeySize {
+		err = errors.TraceNew("unexpected signature public key size")
+	}
+	if err != nil {
+		return nil, errors.Trace(err)
+	}
+
+	privateKey, err := base64.StdEncoding.DecodeString(
+		payloadSignaturePrivateKey)
+	if err == nil && len(privateKey) != ed25519.PrivateKeySize {
+		err = errors.TraceNew("unexpected signature private key size")
+	}
+	if err != nil {
+		return nil, errors.Trace(err)
+	}
+
+	expires := time.Now().Add(TTL).UTC()
+
+	maxPaddingLimit := 65535
+	if minPadding > maxPadding || maxPadding > maxPaddingLimit {
+		return nil, errors.TraceNew("invalid min/max padding")
+	}
+
+	blockCipher, err := aes.NewCipher(obfuscationKey)
+	if err != nil {
+		return nil, errors.Trace(err)
+	}
+
+	aead, err := cipher.NewGCM(blockCipher)
+	if err != nil {
+		return nil, errors.Trace(err)
+	}
+
+	publicKeyDigest := sha256.Sum256(publicKey)
+	publicKeyID := publicKeyDigest[:signaturePublicKeyDigestSize]
+
+	// Reuse buffers to reduce some allocations.
+	var signatureBuffer []byte
+	var obfuscationBuffer []byte
+	nonceBuffer := make([]byte, aead.NonceSize())
+	var paddingBuffer []byte
+
+	obfuscatedPayloads := [][]byte{}
+
+	for _, p := range prioritizedServerEntries {
+
+		payload := Payload{
+			Expires:                  expires,
+			PrioritizedServerEntries: p,
+		}
+
+		cborPayload, err := protocol.CBOREncoding.Marshal(&payload)
+		if err != nil {
+			return nil, errors.Trace(err)
+		}
+
+		signature := ed25519.Sign(privateKey, cborPayload)
+
+		signatureBuffer = signatureBuffer[:0]
+		signatureBuffer = append(signatureBuffer, publicKeyID...)
+		signatureBuffer = append(signatureBuffer, signature...)
+
+		signedPayload := SignedPayload{
+			Signature: signatureBuffer,
+			Payload:   cborPayload,
+		}
+
+		// Padding is an optional part of the obfuscation layer.
+		if maxPadding > 0 {
+			paddingSize := prng.Range(minPadding, maxPadding)
+			if paddingBuffer == nil {
+				paddingBuffer = make([]byte, maxPaddingLimit)
+			}
+			if paddingSize > 0 {
+				signedPayload.Padding = paddingBuffer[0:paddingSize]
+			}
+		}
+
+		cborSignedPayload, err := protocol.CBOREncoding.
+			Marshal(&signedPayload)
+		if err != nil {
+			return nil, errors.Trace(err)
+		}
+
+		// The faster common/prng is appropriate for obfuscation.
+		prng.Read(nonceBuffer[:])
+
+		obfuscationBuffer = obfuscationBuffer[:0]
+		obfuscationBuffer = append(obfuscationBuffer, nonceBuffer...)
+		obfuscationBuffer = aead.Seal(
+			obfuscationBuffer, nonceBuffer[:], cborSignedPayload, nil)
+
+		obfuscatedPayloads = append(
+			obfuscatedPayloads, append([]byte(nil), obfuscationBuffer...))
+	}
+
+	return obfuscatedPayloads, nil
+}

+ 250 - 0
psiphon/common/push/push_test.go

@@ -0,0 +1,250 @@
+/*
+ * 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 <http://www.gnu.org/licenses/>.
+ *
+ */
+
+package push
+
+import (
+	"fmt"
+	"strings"
+	"testing"
+	"time"
+
+	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common/errors"
+	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common/prng"
+	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common/protocol"
+)
+
+func TestPush(t *testing.T) {
+
+	err := runTestPush()
+	if err != nil {
+		t.Fatal(err.Error())
+	}
+}
+
+func runTestPush() error {
+
+	obfuscationKey, publicKey, privateKey, err := GenerateKeys()
+	if err != nil {
+		return errors.Trace(err)
+	}
+
+	minPadding := 0
+	maxPadding := 65535
+
+	_, incorrectPublicKey, incorrectPrivateKey, err := GenerateKeys()
+	if err != nil {
+		return errors.Trace(err)
+	}
+
+	var serverEntries []*PrioritizedServerEntry
+
+	for i := 0; i < 128; i++ {
+
+		serverEntry := &protocol.ServerEntry{
+			Tag:                  prng.Base64String(32),
+			IpAddress:            fmt.Sprintf("192.0.2.%d", i),
+			SshUsername:          prng.HexString(8),
+			SshPassword:          prng.HexString(32),
+			SshHostKey:           prng.Base64String(280),
+			SshObfuscatedPort:    prng.Range(1, 65535),
+			SshObfuscatedKey:     prng.HexString(32),
+			Capabilities:         []string{"OSSH"},
+			Region:               prng.HexString(1),
+			ProviderID:           strings.ToUpper(prng.HexString(8)),
+			ConfigurationVersion: 0,
+			Signature:            prng.Base64String(80),
+		}
+
+		serverEntryFields, err := serverEntry.GetServerEntryFields()
+		if err != nil {
+			return errors.Trace(err)
+		}
+
+		packed, err := protocol.EncodePackedServerEntryFields(serverEntryFields)
+		if err != nil {
+			return errors.Trace(err)
+		}
+
+		serverEntries = append(serverEntries, &PrioritizedServerEntry{
+			ServerEntryFields: packed,
+			Source:            fmt.Sprintf("source-%d", i),
+			PrioritizeDial:    i < 32 || i >= 96,
+		})
+	}
+
+	// Test: successful import
+
+	pushServerEntries := [][]*PrioritizedServerEntry{
+		serverEntries[0:32], serverEntries[32:64],
+		serverEntries[64:96], serverEntries[96:128],
+	}
+
+	payloads, err := MakePushPayloads(
+		obfuscationKey,
+		minPadding,
+		maxPadding,
+		publicKey,
+		privateKey,
+		1*time.Hour,
+		pushServerEntries)
+	if err != nil {
+		return errors.Trace(err)
+	}
+
+	if len(payloads) != len(pushServerEntries) {
+		return errors.TraceNew("unexpected payload count")
+	}
+
+	expectPrioritizeDial := true
+
+	importer := func(
+		packedServerEntryFields protocol.PackedServerEntryFields,
+		source string,
+		prioritizeDial bool) error {
+
+		serverEntryFields, err := protocol.DecodePackedServerEntryFields(packedServerEntryFields)
+		if err != nil {
+			return errors.Trace(err)
+		}
+		if !strings.HasPrefix(serverEntryFields["ipAddress"].(string), "192.0.2") {
+			return errors.TraceNew("unexpected server entry IP address")
+		}
+		if prioritizeDial != expectPrioritizeDial {
+			return errors.TraceNew("unexpected prioritize dial")
+		}
+		return nil
+	}
+
+	for i, payload := range payloads {
+
+		expectPrioritizeDial = i == 0 || i == 3
+
+		n, err := ImportPushPayload(
+			obfuscationKey,
+			publicKey,
+			payload,
+			importer)
+		if err != nil {
+			return errors.Trace(err)
+		}
+
+		if n != 32 {
+			return errors.TraceNew("unexpected import count")
+		}
+	}
+
+	// Test: expired
+
+	payloads, err = MakePushPayloads(
+		obfuscationKey,
+		minPadding,
+		maxPadding,
+		publicKey,
+		privateKey,
+		1*time.Microsecond,
+		pushServerEntries)
+	if err != nil {
+		return errors.Trace(err)
+	}
+
+	time.Sleep(10 * time.Millisecond)
+
+	_, err = ImportPushPayload(
+		obfuscationKey,
+		publicKey,
+		payloads[0],
+		importer)
+	if err == nil {
+		return errors.TraceNew("unexpected success")
+	}
+
+	// Test: invalid signature
+
+	payloads, err = MakePushPayloads(
+		obfuscationKey,
+		minPadding,
+		maxPadding,
+		publicKey,
+		incorrectPrivateKey,
+		1*time.Hour,
+		pushServerEntries)
+	if err != nil {
+		return errors.Trace(err)
+	}
+
+	_, err = ImportPushPayload(
+		obfuscationKey,
+		publicKey,
+		payloads[0],
+		importer)
+	if err == nil {
+		return errors.TraceNew("unexpected success")
+	}
+
+	// Test: wrong signature key
+
+	payloads, err = MakePushPayloads(
+		obfuscationKey,
+		minPadding,
+		maxPadding,
+		publicKey,
+		privateKey,
+		1*time.Hour,
+		pushServerEntries)
+	if err != nil {
+		return errors.Trace(err)
+	}
+
+	_, err = ImportPushPayload(
+		obfuscationKey,
+		incorrectPublicKey,
+		payloads[0],
+		importer)
+	if err == nil {
+		return errors.TraceNew("unexpected success")
+	}
+
+	// Test: mutate obfuscation layer
+
+	payloads, err = MakePushPayloads(
+		obfuscationKey,
+		minPadding,
+		maxPadding,
+		publicKey,
+		privateKey,
+		1*time.Hour,
+		pushServerEntries)
+	if err != nil {
+		return errors.Trace(err)
+	}
+
+	payloads[0][0] = ^payloads[0][0]
+
+	_, err = ImportPushPayload(
+		obfuscationKey,
+		publicKey,
+		payloads[0],
+		importer)
+	if err == nil {
+		return errors.TraceNew("unexpected success")
+	}
+
+	return nil
+}

+ 10 - 0
psiphon/common/utils.go

@@ -133,6 +133,16 @@ func TruncateTimestampToHour(timestamp string) string {
 	return t.Truncate(1 * time.Hour).Format(time.RFC3339)
 	return t.Truncate(1 * time.Hour).Format(time.RFC3339)
 }
 }
 
 
+// ParseTimeOfDayMinutes parses a time of day in HH:MM 24-hour format and
+// returns the number of minutes since midnight.
+func ParseTimeOfDayMinutes(value string) (int, error) {
+	t, err := time.Parse("15:04", value)
+	if err != nil {
+		return 0, errors.Trace(err)
+	}
+	return t.Hour()*60 + t.Minute(), nil
+}
+
 const (
 const (
 	CompressionNone = int32(0)
 	CompressionNone = int32(0)
 	CompressionZlib = int32(1)
 	CompressionZlib = int32(1)

+ 137 - 5
psiphon/config.go

@@ -241,6 +241,11 @@ type Config struct {
 	// recommended.
 	// recommended.
 	ConnectionWorkerPoolSize int `json:",omitempty"`
 	ConnectionWorkerPoolSize int `json:",omitempty"`
 
 
+	// ConnectionWorkerPoolMaxSize specifies a cap for ConnectionWorkerPoolSize
+	// that will not be exceeded by any tactics setting. This is intended for
+	// use in low memory environments. If omitted or when 0, it has no effect.
+	ConnectionWorkerPoolMaxSize int `json:",omitempty"`
+
 	// DisableConnectionWorkerPool forces ConnectionWorkerPoolSize to 0; this
 	// DisableConnectionWorkerPool forces ConnectionWorkerPoolSize to 0; this
 	// may be used to load cached tactics or perform an untunneled tactics
 	// may be used to load cached tactics or perform an untunneled tactics
 	// request and then post tactics-related notices, including Application
 	// request and then post tactics-related notices, including Application
@@ -653,10 +658,24 @@ type Config struct {
 	// ephemeral key will be generated.
 	// ephemeral key will be generated.
 	InproxyProxySessionPrivateKey string `json:",omitempty"`
 	InproxyProxySessionPrivateKey string `json:",omitempty"`
 
 
-	// InproxyMaxClients specifies the maximum number of in-proxy clients to
-	// be proxied concurrently. Must be > 0 when InproxyEnableProxy is set.
+	// InproxyMaxClients specifies the maximum number of common in-proxy
+	// clients to be proxied concurrently. When InproxyEnableProxy is set,
+	// it can only be 0 when InProxyMaxPersonalClients is > 0.
+	//
+	// Deprecated: Use InproxyMaxCommonClients. When InproxyMaxCommonClients
+	// is not nil, this parameter is ignored.
 	InproxyMaxClients int `json:",omitempty"`
 	InproxyMaxClients int `json:",omitempty"`
 
 
+	// InproxyMaxCommonClients specifies the maximum number of common
+	// in-proxy clients to be proxied concurrently. When InproxyEnableProxy
+	// is set, it can only be 0 when InProxyMaxPersonalClients is > 0.
+	InproxyMaxCommonClients int `json:",omitempty"`
+
+	// InproxyMaxPersonalClients specifies the maximum number of personal
+	// in-proxy clients to be proxied concurrently. When InproxyEnableProxy
+	// is set, it can only be 0 when InProxyMaxCommonClients is > 0.
+	InproxyMaxPersonalClients int `json:",omitempty"`
+
 	// InproxyLimitUpstreamBytesPerSecond specifies the upstream byte transfer
 	// InproxyLimitUpstreamBytesPerSecond specifies the upstream byte transfer
 	// rate limit for each proxied client. When 0, there is no limit.
 	// rate limit for each proxied client. When 0, there is no limit.
 	InproxyLimitUpstreamBytesPerSecond int `json:",omitempty"`
 	InproxyLimitUpstreamBytesPerSecond int `json:",omitempty"`
@@ -665,6 +684,50 @@ type Config struct {
 	// transfer rate limit for each proxied client. When 0, there is no limit.
 	// transfer rate limit for each proxied client. When 0, there is no limit.
 	InproxyLimitDownstreamBytesPerSecond int `json:",omitempty"`
 	InproxyLimitDownstreamBytesPerSecond int `json:",omitempty"`
 
 
+	// InproxyReducedStartTime specifies the local time of day(HH:MM, 24-hour,
+	// UTC) at which reduced in-proxy settings begin.
+	InproxyReducedStartTime string `json:",omitempty"`
+
+	// InproxyReducedEndTime specifies the local time of day (HH:MM, 24-hour,
+	// UTC) at which reduced in-proxy settings end.
+	InproxyReducedEndTime string `json:",omitempty"`
+
+	// InproxyReducedMaxClients specifies the maximum number of common
+	// in-proxy clients to be proxied concurrently during the reduced
+	// time range. When set, must be > 0 and <= InproxyMaxCommonClients.
+	//
+	// Clients connected when the reduced settings begin will not be
+	// disconnected, so InproxyReducedMaxClients is a soft limit.
+	//
+	// Deprecated: Use InproxyReducedMaxCommon Clients. When
+	// InproxyMaxCommonClients is not nil, this parameter is ignored.
+	InproxyReducedMaxClients int `json:",omitempty"`
+
+	// InproxyReducedMaxCommonClients specifies the maximum number of
+	// common in-proxy clients to be proxied concurrently during the
+	// reduced time range. When set, must be > 0 and
+	// <= InproxyMaxCommonClients.
+	//
+	// Clients connected when the reduced settings begin will not be
+	// disconnected, so InproxyReducedMaxCommonClients is a soft limit.
+	InproxyReducedMaxCommonClients int `json:",omitempty"`
+
+	// InproxyReducedLimitUpstreamBytesPerSecond specifies the upstream byte
+	// transfer rate limit for each proxied client during the reduced time
+	// range. When 0, InproxyLimitUpstreamBytesPerSecond is the limit.
+	//
+	// Rates for clients already connected when the reduced settings begin or
+	// end will not change.
+	InproxyReducedLimitUpstreamBytesPerSecond int `json:",omitempty"`
+
+	// InproxyReducedLimitDownstreamBytesPerSecond specifies the downstream byte
+	// transfer rate limit for each proxied client during the reduced time
+	// range. When 0, InproxyLimitDownstreamBytesPerSecond is the limit.
+	//
+	// Rates for clients already connected when the reduced settings begin or
+	// end will not change.
+	InproxyReducedLimitDownstreamBytesPerSecond int `json:",omitempty"`
+
 	// InproxyProxyPersonalCompartmentID specifies the personal compartment
 	// InproxyProxyPersonalCompartmentID specifies the personal compartment
 	// ID used by an in-proxy proxy. Personal compartment IDs are
 	// ID used by an in-proxy proxy. Personal compartment IDs are
 	// distributed from proxy operators to client users out-of-band and
 	// distributed from proxy operators to client users out-of-band and
@@ -702,6 +765,16 @@ type Config struct {
 	// temporary tunnels.
 	// temporary tunnels.
 	DisableDSLFetcher bool `json:",omitempty"`
 	DisableDSLFetcher bool `json:",omitempty"`
 
 
+	// PushPayloadObfuscationKey is a base64-encoded, secret key value used to
+	// deobfuscate push payloads. This value is supplied by the Psiphon
+	// Network. Required for push payload imports.
+	PushPayloadObfuscationKey string `json:",omitempty"`
+
+	// PushPayloadSignaturePublicKey is a base64-encoded, public key value
+	// used to verify push payloads. This value is supplied by the Psiphon
+	// Network. Required for push payload imports.
+	PushPayloadSignaturePublicKey string `json:",omitempty"`
+
 	//
 	//
 	// The following parameters are deprecated.
 	// The following parameters are deprecated.
 	//
 	//
@@ -1041,6 +1114,7 @@ type Config struct {
 	InproxyPersonalPairingBrokerSpecs                       parameters.InproxyBrokerSpecsValue               `json:",omitempty"`
 	InproxyPersonalPairingBrokerSpecs                       parameters.InproxyBrokerSpecsValue               `json:",omitempty"`
 	InproxyProxyBrokerSpecs                                 parameters.InproxyBrokerSpecsValue               `json:",omitempty"`
 	InproxyProxyBrokerSpecs                                 parameters.InproxyBrokerSpecsValue               `json:",omitempty"`
 	InproxyProxyPersonalPairingBrokerSpecs                  parameters.InproxyBrokerSpecsValue               `json:",omitempty"`
 	InproxyProxyPersonalPairingBrokerSpecs                  parameters.InproxyBrokerSpecsValue               `json:",omitempty"`
+	InproxyPersonalPairingMaxBrokerSpecCount                *int                                             `json:",omitempty"`
 	InproxyClientBrokerSpecs                                parameters.InproxyBrokerSpecsValue               `json:",omitempty"`
 	InproxyClientBrokerSpecs                                parameters.InproxyBrokerSpecsValue               `json:",omitempty"`
 	InproxyClientPersonalPairingBrokerSpecs                 parameters.InproxyBrokerSpecsValue               `json:",omitempty"`
 	InproxyClientPersonalPairingBrokerSpecs                 parameters.InproxyBrokerSpecsValue               `json:",omitempty"`
 	InproxyReplayBrokerDialParametersTTLSeconds             *int                                             `json:",omitempty"`
 	InproxyReplayBrokerDialParametersTTLSeconds             *int                                             `json:",omitempty"`
@@ -1389,6 +1463,14 @@ func (config *Config) Commit(migrateFromLegacyFields bool) error {
 		config.MigrateUpgradeDownloadFilename = config.UpgradeDownloadFilename
 		config.MigrateUpgradeDownloadFilename = config.UpgradeDownloadFilename
 	}
 	}
 
 
+	if config.InproxyMaxClients != 0 && config.InproxyMaxCommonClients == 0 {
+		config.InproxyMaxCommonClients = config.InproxyMaxClients
+	}
+
+	if config.InproxyReducedMaxClients != 0 && config.InproxyReducedMaxCommonClients == 0 {
+		config.InproxyReducedMaxCommonClients = config.InproxyReducedMaxClients
+	}
+
 	// Supply default values.
 	// Supply default values.
 
 
 	// Create datastore directory.
 	// Create datastore directory.
@@ -1490,8 +1572,50 @@ func (config *Config) Commit(migrateFromLegacyFields bool) error {
 		return errors.TraceNew("invalid ObfuscatedSSHAlgorithms")
 		return errors.TraceNew("invalid ObfuscatedSSHAlgorithms")
 	}
 	}
 
 
-	if config.InproxyEnableProxy && config.InproxyMaxClients <= 0 {
-		return errors.TraceNew("invalid InproxyMaxClients")
+	if config.InproxyEnableProxy {
+
+		if config.InproxyMaxCommonClients+config.InproxyMaxPersonalClients <= 0 {
+			return errors.TraceNew("invalid InproxyMaxCommonClients and InproxyMaxPersonalClients")
+		}
+
+		if len(config.InproxyProxyPersonalCompartmentID) > 0 && config.InproxyMaxPersonalClients <= 0 {
+			return errors.TraceNew("invalid InproxyMaxPersonalClients when personal compartment IDs are provided")
+		}
+
+		if config.InproxyReducedStartTime != "" ||
+			config.InproxyReducedEndTime != "" ||
+			config.InproxyReducedMaxCommonClients > 0 {
+
+			startMinute, err := common.ParseTimeOfDayMinutes(config.InproxyReducedStartTime)
+			if err != nil {
+				return errors.Tracef("invalid InproxyReducedStartTime: %v", err)
+			}
+
+			endMinute, err := common.ParseTimeOfDayMinutes(config.InproxyReducedEndTime)
+			if err != nil {
+				return errors.Tracef("invalid InproxyReducedEndTime: %v", err)
+			}
+
+			// Reduced all day is not a valid configuration.
+			if startMinute == endMinute {
+				return errors.TraceNew("invalid InproxyReducedStartTime/InproxyReducedEndTime")
+			}
+
+			if config.InproxyReducedMaxCommonClients <= 0 ||
+				config.InproxyReducedMaxCommonClients > config.InproxyMaxCommonClients {
+				return errors.TraceNew("invalid InproxyReducedMaxCommonClients")
+			}
+
+			// InproxyReducedLimitUpstream/DownstreamBytesPerSecond don't necessarily
+			// need to be less than InproxyLimitUpstream/DownstreamBytesPerSecond.
+
+			if config.InproxyReducedLimitUpstreamBytesPerSecond == 0 {
+				config.InproxyReducedLimitUpstreamBytesPerSecond = config.InproxyLimitUpstreamBytesPerSecond
+			}
+			if config.InproxyReducedLimitDownstreamBytesPerSecond == 0 {
+				config.InproxyReducedLimitDownstreamBytesPerSecond = config.InproxyLimitDownstreamBytesPerSecond
+			}
+		}
 	}
 	}
 
 
 	if !config.DisableTunnels &&
 	if !config.DisableTunnels &&
@@ -1509,7 +1633,7 @@ func (config *Config) Commit(migrateFromLegacyFields bool) error {
 		!inproxy.Enabled() {
 		!inproxy.Enabled() {
 
 
 		// When in-proxy personal pairing mode is on, fail if the build was
 		// When in-proxy personal pairing mode is on, fail if the build was
-		// made without the PSIPHON_ENABLE_INPROXY build tag.
+		// made with the PSIPHON_DISABLE_INPROXY build tag.
 		//
 		//
 		// Note that this check could also be enforced in the case of a
 		// Note that this check could also be enforced in the case of a
 		// LimitTunnelProtocols.IsOnlyInproxyTunnelProtocols configuration,
 		// LimitTunnelProtocols.IsOnlyInproxyTunnelProtocols configuration,
@@ -2665,6 +2789,10 @@ func (config *Config) makeConfigParameters() map[string]interface{} {
 		applyParameters[parameters.InproxyProxyPersonalPairingBrokerSpecs] = config.InproxyProxyPersonalPairingBrokerSpecs
 		applyParameters[parameters.InproxyProxyPersonalPairingBrokerSpecs] = config.InproxyProxyPersonalPairingBrokerSpecs
 	}
 	}
 
 
+	if config.InproxyPersonalPairingMaxBrokerSpecCount != nil {
+		applyParameters[parameters.InproxyPersonalPairingMaxBrokerSpecCount] = *config.InproxyPersonalPairingMaxBrokerSpecCount
+	}
+
 	if len(config.InproxyClientBrokerSpecs) > 0 {
 	if len(config.InproxyClientBrokerSpecs) > 0 {
 		applyParameters[parameters.InproxyClientBrokerSpecs] = config.InproxyClientBrokerSpecs
 		applyParameters[parameters.InproxyClientBrokerSpecs] = config.InproxyClientBrokerSpecs
 	}
 	}
@@ -3674,6 +3802,10 @@ func (config *Config) setDialParametersHash() {
 		hash.Write([]byte("InproxyProxyPersonalPairingBrokerSpecs"))
 		hash.Write([]byte("InproxyProxyPersonalPairingBrokerSpecs"))
 		hash.Write([]byte(fmt.Sprintf("%+v", config.InproxyProxyPersonalPairingBrokerSpecs)))
 		hash.Write([]byte(fmt.Sprintf("%+v", config.InproxyProxyPersonalPairingBrokerSpecs)))
 	}
 	}
+	if config.InproxyPersonalPairingMaxBrokerSpecCount != nil {
+		hash.Write([]byte("InproxyPersonalPairingMaxBrokerSpecCount"))
+		binary.Write(hash, binary.LittleEndian, int64(*config.InproxyPersonalPairingMaxBrokerSpecCount))
+	}
 	if len(config.InproxyClientBrokerSpecs) > 0 {
 	if len(config.InproxyClientBrokerSpecs) > 0 {
 		hash.Write([]byte("InproxyClientBrokerSpecs"))
 		hash.Write([]byte("InproxyClientBrokerSpecs"))
 		hash.Write([]byte(fmt.Sprintf("%+v", config.InproxyClientBrokerSpecs)))
 		hash.Write([]byte(fmt.Sprintf("%+v", config.InproxyClientBrokerSpecs)))

+ 137 - 48
psiphon/controller.go

@@ -42,6 +42,7 @@ import (
 	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common/parameters"
 	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common/parameters"
 	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common/prng"
 	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common/prng"
 	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common/protocol"
 	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common/protocol"
+	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common/push"
 	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common/resolver"
 	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common/resolver"
 	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common/tactics"
 	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common/tactics"
 	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common/tun"
 	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common/tun"
@@ -587,7 +588,7 @@ func (controller *Controller) ExportExchangePayload() string {
 // See the comment for psiphon.ImportExchangePayload for more details about
 // See the comment for psiphon.ImportExchangePayload for more details about
 // the import.
 // the import.
 //
 //
-// When the import is successful, a signal is set to trigger a restart any
+// When the import is successful, a signal is set to trigger a restart of any
 // establishment in progress. This will cause the newly imported server entry
 // establishment in progress. This will cause the newly imported server entry
 // to be prioritized, which it otherwise would not be in later establishment
 // to be prioritized, which it otherwise would not be in later establishment
 // rounds. The establishment process continues after ImportExchangePayload
 // rounds. The establishment process continues after ImportExchangePayload
@@ -615,6 +616,58 @@ func (controller *Controller) ImportExchangePayload(payload string) bool {
 	return true
 	return true
 }
 }
 
 
+// ImportPushPayload imports a server entry push payload.
+//
+// When the import is successful, a signal is set to trigger a restart of any
+// establishment in progress. This will cause imported server entries to be
+// prioritized as indicated in the payload. The establishment process
+// continues after ImportPushPayload returns.
+//
+// If the client already has a connected tunnel, or a tunnel connection is
+// established concurrently with the import, the signal has no effect as the
+// overall goal is establish _any_ connection.
+func (controller *Controller) ImportPushPayload(payload []byte) bool {
+
+	importer := func(
+		packedServerEntryFields protocol.PackedServerEntryFields,
+		source string,
+		prioritizeDial bool) error {
+
+		err := DSLStoreServerEntry(
+			controller.config.ServerEntrySignaturePublicKey,
+			packedServerEntryFields,
+			protocol.PushServerEntrySource(source),
+			prioritizeDial,
+			controller.config.GetNetworkID())
+		if err != nil {
+			return errors.Trace(err)
+		}
+
+		return nil
+	}
+
+	n, err := push.ImportPushPayload(
+		controller.config.PushPayloadObfuscationKey,
+		controller.config.PushPayloadSignaturePublicKey,
+		payload,
+		importer)
+
+	if err != nil {
+		NoticeWarning("push payload: %d imported, %v", n, err)
+	} else {
+		NoticeInfo("push payload: %d imported", n)
+	}
+
+	if n > 0 {
+		select {
+		case controller.signalRestartEstablishing <- struct{}{}:
+		default:
+		}
+	}
+
+	return err == nil
+}
+
 // remoteServerListFetcher fetches an out-of-band list of server entries
 // remoteServerListFetcher fetches an out-of-band list of server entries
 // for more tunnel candidates. It fetches when signalled, with retries
 // for more tunnel candidates. It fetches when signalled, with retries
 // on failure.
 // on failure.
@@ -2120,33 +2173,10 @@ func (controller *Controller) launchEstablishing() {
 	controller.establishInproxyForceSelectionCount =
 	controller.establishInproxyForceSelectionCount =
 		p.Int(parameters.InproxyTunnelProtocolForceSelectionCount)
 		p.Int(parameters.InproxyTunnelProtocolForceSelectionCount)
 
 
-	// ConnectionWorkerPoolSize may be set by tactics.
-	//
-	// In-proxy personal pairing mode uses a distinct parameter which is
-	// typically configured to a lower number, limiting concurrent load and
-	// announcement consumption for personal proxies.
+	// workerPoolSize is the number of concurrent establishTunnelWorkers, each
+	// dialing tunnels.
 
 
-	var workerPoolSize int
-	if controller.config.IsInproxyClientPersonalPairingMode() {
-		workerPoolSize = p.Int(parameters.InproxyPersonalPairingConnectionWorkerPoolSize)
-	} else {
-		workerPoolSize = p.Int(parameters.ConnectionWorkerPoolSize)
-	}
-
-	// When TargetServerEntry is used, override any worker pool size config or
-	// tactic parameter and use a pool size of 1. The typical use case for
-	// TargetServerEntry is to test a specific server with a single connection
-	// attempt. Furthermore, too many concurrent attempts to connect to the
-	// same server will trigger rate limiting.
-	if controller.config.TargetServerEntry != "" {
-		workerPoolSize = 1
-	}
-
-	// When DisableConnectionWorkerPool is set, no tunnel establishment
-	// workers are run. See Config.DisableConnectionWorkerPool.
-	if controller.config.DisableConnectionWorkerPool {
-		workerPoolSize = 0
-	}
+	workerPoolSize := controller.getConnectionWorkerPoolSize(p)
 
 
 	// TunnelPoolSize may be set by tactics, subject to local constraints. A pool
 	// TunnelPoolSize may be set by tactics, subject to local constraints. A pool
 	// size of one is forced in packet tunnel mode or when using a
 	// size of one is forced in packet tunnel mode or when using a
@@ -2234,6 +2264,49 @@ func (controller *Controller) launchEstablishing() {
 	go controller.establishCandidateGenerator()
 	go controller.establishCandidateGenerator()
 }
 }
 
 
+func (controller *Controller) getConnectionWorkerPoolSize(
+	p parameters.ParametersAccessor) int {
+
+	// ConnectionWorkerPoolSize may be set by tactics.
+	//
+	// In-proxy personal pairing mode uses a distinct parameter which is
+	// typically configured to a lower number, limiting concurrent load and
+	// announcement consumption for personal proxies.
+	//
+	// ConnectionWorkerPoolMaxSize is a config-only cap on the worker pool
+	// size which may be set in low memory environments.
+
+	var workerPoolSize int
+	if controller.config.IsInproxyClientPersonalPairingMode() {
+		workerPoolSize = p.Int(parameters.InproxyPersonalPairingConnectionWorkerPoolSize)
+	} else {
+		workerPoolSize = p.Int(parameters.ConnectionWorkerPoolSize)
+	}
+
+	if controller.config.ConnectionWorkerPoolMaxSize > 0 &&
+		workerPoolSize > controller.config.ConnectionWorkerPoolMaxSize {
+
+		workerPoolSize = controller.config.ConnectionWorkerPoolMaxSize
+	}
+
+	// When TargetServerEntry is used, override any worker pool size config or
+	// tactic parameter and use a pool size of 1. The typical use case for
+	// TargetServerEntry is to test a specific server with a single connection
+	// attempt. Furthermore, too many concurrent attempts to connect to the
+	// same server will trigger rate limiting.
+	if controller.config.TargetServerEntry != "" {
+		workerPoolSize = 1
+	}
+
+	// When DisableConnectionWorkerPool is set, no tunnel establishment
+	// workers are run. See Config.DisableConnectionWorkerPool.
+	if controller.config.DisableConnectionWorkerPool {
+		workerPoolSize = 0
+	}
+
+	return workerPoolSize
+}
+
 func (controller *Controller) doConstraintsScan() {
 func (controller *Controller) doConstraintsScan() {
 
 
 	// Scan over server entries in order to check and adjust any initial
 	// Scan over server entries in order to check and adjust any initial
@@ -2768,7 +2841,7 @@ loop:
 			// broken, the error should persist and eventually get posted.
 			// broken, the error should persist and eventually get posted.
 
 
 			p := controller.config.GetParameters().Get()
 			p := controller.config.GetParameters().Get()
-			workerPoolSize := p.Int(parameters.ConnectionWorkerPoolSize)
+			workerPoolSize := controller.getConnectionWorkerPoolSize(p)
 			minWaitDuration := p.Duration(parameters.UpstreamProxyErrorMinWaitDuration)
 			minWaitDuration := p.Duration(parameters.UpstreamProxyErrorMinWaitDuration)
 			maxWaitDuration := p.Duration(parameters.UpstreamProxyErrorMaxWaitDuration)
 			maxWaitDuration := p.Duration(parameters.UpstreamProxyErrorMaxWaitDuration)
 			p.Close()
 			p.Close()
@@ -3224,15 +3297,19 @@ func (controller *Controller) runInproxyProxy() {
 	debugLogging := controller.config.InproxyEnableWebRTCDebugLogging
 	debugLogging := controller.config.InproxyEnableWebRTCDebugLogging
 
 
 	var lastActivityNotice time.Time
 	var lastActivityNotice time.Time
+	var lastAnnouncing int32
 	var lastActivityConnectingClients, lastActivityConnectedClients int32
 	var lastActivityConnectingClients, lastActivityConnectedClients int32
 	var lastActivityConnectingClientsTotal, lastActivityConnectedClientsTotal int32
 	var lastActivityConnectingClientsTotal, lastActivityConnectedClientsTotal int32
 	var activityTotalBytesUp, activityTotalBytesDown int64
 	var activityTotalBytesUp, activityTotalBytesDown int64
 	activityUpdater := func(
 	activityUpdater := func(
+		announcing int32,
 		connectingClients int32,
 		connectingClients int32,
 		connectedClients int32,
 		connectedClients int32,
 		bytesUp int64,
 		bytesUp int64,
 		bytesDown int64,
 		bytesDown int64,
-		_ time.Duration) {
+		_ time.Duration,
+		personalRegionActivity map[string]inproxy.RegionActivitySnapshot,
+		commonRegionActivity map[string]inproxy.RegionActivitySnapshot) {
 
 
 		// This emit logic mirrors the logic for NoticeBytesTransferred and
 		// This emit logic mirrors the logic for NoticeBytesTransferred and
 		// NoticeTotalBytesTransferred in tunnel.operateTunnel.
 		// NoticeTotalBytesTransferred in tunnel.operateTunnel.
@@ -3244,13 +3321,15 @@ func (controller *Controller) runInproxyProxy() {
 		// activity display.
 		// activity display.
 
 
 		if controller.config.EmitInproxyProxyActivity &&
 		if controller.config.EmitInproxyProxyActivity &&
-			(bytesUp > 0 || bytesDown > 0) ||
-			connectingClients != lastActivityConnectingClients ||
-			connectedClients != lastActivityConnectedClients {
+			(bytesUp > 0 || bytesDown > 0 ||
+				announcing != lastAnnouncing ||
+				connectingClients != lastActivityConnectingClients ||
+				connectedClients != lastActivityConnectedClients) {
 
 
 			NoticeInproxyProxyActivity(
 			NoticeInproxyProxyActivity(
-				connectingClients, connectedClients, bytesUp, bytesDown)
+				announcing, connectingClients, connectedClients, bytesUp, bytesDown, personalRegionActivity, commonRegionActivity)
 
 
+			lastAnnouncing = announcing
 			lastActivityConnectingClients = connectingClients
 			lastActivityConnectingClients = connectingClients
 			lastActivityConnectedClients = connectedClients
 			lastActivityConnectedClients = connectedClients
 		}
 		}
@@ -3262,13 +3341,17 @@ func (controller *Controller) runInproxyProxy() {
 		// transferred since starting; in addition to the current number of
 		// transferred since starting; in addition to the current number of
 		// connecting and connected clients, whenever that changes. This
 		// connecting and connected clients, whenever that changes. This
 		// notice is for diagnostics.
 		// notice is for diagnostics.
+		//
+		// Changes in announcing count are frequent and don't trigger
+		// InproxyProxyTotalActivity; the current announcing count is
+		// recorded as a snapshot.
 
 
 		if lastActivityNotice.Add(activityNoticePeriod).Before(time.Now()) ||
 		if lastActivityNotice.Add(activityNoticePeriod).Before(time.Now()) ||
 			connectingClients != lastActivityConnectingClientsTotal ||
 			connectingClients != lastActivityConnectingClientsTotal ||
 			connectedClients != lastActivityConnectedClientsTotal {
 			connectedClients != lastActivityConnectedClientsTotal {
 
 
 			NoticeInproxyProxyTotalActivity(
 			NoticeInproxyProxyTotalActivity(
-				connectingClients, connectedClients,
+				announcing, connectingClients, connectedClients,
 				activityTotalBytesUp, activityTotalBytesDown)
 				activityTotalBytesUp, activityTotalBytesDown)
 			lastActivityNotice = time.Now()
 			lastActivityNotice = time.Now()
 
 
@@ -3278,19 +3361,25 @@ func (controller *Controller) runInproxyProxy() {
 	}
 	}
 
 
 	config := &inproxy.ProxyConfig{
 	config := &inproxy.ProxyConfig{
-		Logger:                        NoticeCommonLogger(debugLogging),
-		EnableWebRTCDebugLogging:      debugLogging,
-		WaitForNetworkConnectivity:    controller.inproxyWaitForNetworkConnectivity,
-		GetCurrentNetworkContext:      controller.getCurrentNetworkContext,
-		GetBrokerClient:               controller.inproxyGetProxyBrokerClient,
-		GetBaseAPIParameters:          controller.inproxyGetProxyAPIParameters,
-		MakeWebRTCDialCoordinator:     controller.inproxyMakeProxyWebRTCDialCoordinator,
-		HandleTacticsPayload:          controller.inproxyHandleProxyTacticsPayload,
-		MaxClients:                    controller.config.InproxyMaxClients,
-		LimitUpstreamBytesPerSecond:   controller.config.InproxyLimitUpstreamBytesPerSecond,
-		LimitDownstreamBytesPerSecond: controller.config.InproxyLimitDownstreamBytesPerSecond,
-		MustUpgrade:                   controller.config.OnInproxyMustUpgrade,
-		ActivityUpdater:               activityUpdater,
+		Logger:                               NoticeCommonLogger(debugLogging),
+		EnableWebRTCDebugLogging:             debugLogging,
+		WaitForNetworkConnectivity:           controller.inproxyWaitForNetworkConnectivity,
+		GetCurrentNetworkContext:             controller.getCurrentNetworkContext,
+		GetBrokerClient:                      controller.inproxyGetProxyBrokerClient,
+		GetBaseAPIParameters:                 controller.inproxyGetProxyAPIParameters,
+		MakeWebRTCDialCoordinator:            controller.inproxyMakeProxyWebRTCDialCoordinator,
+		HandleTacticsPayload:                 controller.inproxyHandleProxyTacticsPayload,
+		MaxCommonClients:                     controller.config.InproxyMaxCommonClients,
+		MaxPersonalClients:                   controller.config.InproxyMaxPersonalClients,
+		LimitUpstreamBytesPerSecond:          controller.config.InproxyLimitUpstreamBytesPerSecond,
+		LimitDownstreamBytesPerSecond:        controller.config.InproxyLimitDownstreamBytesPerSecond,
+		ReducedStartTime:                     controller.config.InproxyReducedStartTime,
+		ReducedEndTime:                       controller.config.InproxyReducedEndTime,
+		ReducedMaxCommonClients:              controller.config.InproxyReducedMaxCommonClients,
+		ReducedLimitUpstreamBytesPerSecond:   controller.config.InproxyReducedLimitUpstreamBytesPerSecond,
+		ReducedLimitDownstreamBytesPerSecond: controller.config.InproxyReducedLimitDownstreamBytesPerSecond,
+		MustUpgrade:                          controller.config.OnInproxyMustUpgrade,
+		ActivityUpdater:                      activityUpdater,
 	}
 	}
 
 
 	proxy, err := inproxy.NewProxy(config)
 	proxy, err := inproxy.NewProxy(config)
@@ -3306,7 +3395,7 @@ func (controller *Controller) runInproxyProxy() {
 
 
 	// Emit one last NoticeInproxyProxyTotalActivity with the final byte counts.
 	// Emit one last NoticeInproxyProxyTotalActivity with the final byte counts.
 	NoticeInproxyProxyTotalActivity(
 	NoticeInproxyProxyTotalActivity(
-		lastActivityConnectingClients, lastActivityConnectedClients,
+		lastAnnouncing, lastActivityConnectingClients, lastActivityConnectedClients,
 		activityTotalBytesUp, activityTotalBytesDown)
 		activityTotalBytesUp, activityTotalBytesDown)
 
 
 	NoticeInfo("inproxy proxy: stopped")
 	NoticeInfo("inproxy proxy: stopped")

+ 4 - 3
psiphon/controller_test.go

@@ -156,9 +156,6 @@ func TestTLSOSSH(t *testing.T) {
 }
 }
 
 
 func TestShadowsocks(t *testing.T) {
 func TestShadowsocks(t *testing.T) {
-
-	t.Skipf("temporarily disabled")
-
 	controllerRun(t,
 	controllerRun(t,
 		&controllerRunConfig{
 		&controllerRunConfig{
 			protocol:                 protocol.TUNNEL_PROTOCOL_SHADOWSOCKS_OSSH,
 			protocol:                 protocol.TUNNEL_PROTOCOL_SHADOWSOCKS_OSSH,
@@ -322,6 +319,7 @@ func TestFrontedQUIC(t *testing.T) {
 
 
 func TestInproxyOSSH(t *testing.T) {
 func TestInproxyOSSH(t *testing.T) {
 
 
+	t.Skipf("temporarily disabled")
 	if !inproxy.Enabled() {
 	if !inproxy.Enabled() {
 		t.Skip("In-proxy is not enabled")
 		t.Skip("In-proxy is not enabled")
 	}
 	}
@@ -336,6 +334,7 @@ func TestInproxyOSSH(t *testing.T) {
 
 
 func TestInproxyQUICOSSH(t *testing.T) {
 func TestInproxyQUICOSSH(t *testing.T) {
 
 
+	t.Skipf("temporarily disabled")
 	if !inproxy.Enabled() {
 	if !inproxy.Enabled() {
 		t.Skip("In-proxy is not enabled")
 		t.Skip("In-proxy is not enabled")
 	}
 	}
@@ -350,6 +349,7 @@ func TestInproxyQUICOSSH(t *testing.T) {
 
 
 func TestInproxyUnfrontedMeekHTTPS(t *testing.T) {
 func TestInproxyUnfrontedMeekHTTPS(t *testing.T) {
 
 
+	t.Skipf("temporarily disabled")
 	if !inproxy.Enabled() {
 	if !inproxy.Enabled() {
 		t.Skip("In-proxy is not enabled")
 		t.Skip("In-proxy is not enabled")
 	}
 	}
@@ -363,6 +363,7 @@ func TestInproxyUnfrontedMeekHTTPS(t *testing.T) {
 
 
 func TestInproxyTLSOSSH(t *testing.T) {
 func TestInproxyTLSOSSH(t *testing.T) {
 
 
+	t.Skipf("temporarily disabled")
 	if !inproxy.Enabled() {
 	if !inproxy.Enabled() {
 		t.Skip("In-proxy is not enabled")
 		t.Skip("In-proxy is not enabled")
 	}
 	}

+ 22 - 0
psiphon/inproxy.go

@@ -434,6 +434,28 @@ func NewInproxyBrokerClientInstance(
 		PRNG := prng.NewPRNGWithSeed(&seed)
 		PRNG := prng.NewPRNGWithSeed(&seed)
 
 
 		permutedIndexes := PRNG.Perm(len(brokerSpecs))
 		permutedIndexes := PRNG.Perm(len(brokerSpecs))
+
+		// Minimize rendezvous time by reducing the number of brokers this
+		// personal compartment ID maps over to. With a reduced number of
+		// possible brokers, the client and proxy have fewer brokers to check
+		// after fail overs.
+		//
+		// Given that permutedIndexes is a randomized shuffle, each personal
+		// compartment ID will map to a different set of reduced brokers,
+		// preserving overall broker load balancing.
+		//
+		// InproxyPersonalPairingMaxBrokerSpecCount will be configured high
+		// enough to also preserve reasonable availability when brokers fail.
+		// When InproxyPersonalPairingMaxBrokerSpecCount is 0, there is no max.
+		//
+		// This scheme depends on the len(personalCompartmentIDs) <= 1
+		// constraint checked above.
+
+		maxBrokerSpecs := p.Int(parameters.InproxyPersonalPairingMaxBrokerSpecCount)
+		if maxBrokerSpecs > 0 && len(permutedIndexes) > maxBrokerSpecs {
+			permutedIndexes = permutedIndexes[:maxBrokerSpecs]
+		}
+
 		selectedIndex := permutedIndexes[brokerSelectCount%len(permutedIndexes)]
 		selectedIndex := permutedIndexes[brokerSelectCount%len(permutedIndexes)]
 		brokerSpecs = brokerSpecs[selectedIndex : selectedIndex+1]
 		brokerSpecs = brokerSpecs[selectedIndex : selectedIndex+1]
 
 

+ 11 - 2
psiphon/notice.go

@@ -35,6 +35,7 @@ import (
 	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common"
 	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common"
 	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common/buildinfo"
 	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common/buildinfo"
 	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common/errors"
 	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common/errors"
+	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common/inproxy"
 	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common/parameters"
 	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common/parameters"
 	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common/protocol"
 	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common/protocol"
 	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common/stacktrace"
 	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common/stacktrace"
@@ -1151,23 +1152,30 @@ func NoticeInproxyMustUpgrade() {
 // with EmitInproxyProxyActivity for functionality such as traffic display;
 // with EmitInproxyProxyActivity for functionality such as traffic display;
 // and this frequent notice is not intended to be included with feedback.
 // and this frequent notice is not intended to be included with feedback.
 func NoticeInproxyProxyActivity(
 func NoticeInproxyProxyActivity(
+	announcing int32,
 	connectingClients int32,
 	connectingClients int32,
 	connectedClients int32,
 	connectedClients int32,
 	bytesUp int64,
 	bytesUp int64,
-	bytesDown int64) {
+	bytesDown int64,
+	personalRegionActivity map[string]inproxy.RegionActivitySnapshot,
+	commonRegionActivity map[string]inproxy.RegionActivitySnapshot) {
 
 
 	singletonNoticeLogger.outputNotice(
 	singletonNoticeLogger.outputNotice(
 		"InproxyProxyActivity", noticeIsNotDiagnostic,
 		"InproxyProxyActivity", noticeIsNotDiagnostic,
+		"announcing", announcing,
 		"connectingClients", connectingClients,
 		"connectingClients", connectingClients,
 		"connectedClients", connectedClients,
 		"connectedClients", connectedClients,
 		"bytesUp", bytesUp,
 		"bytesUp", bytesUp,
-		"bytesDown", bytesDown)
+		"bytesDown", bytesDown,
+		"personalRegionActivity", personalRegionActivity,
+		"commonRegionActivity", commonRegionActivity)
 }
 }
 
 
 // NoticeInproxyProxyTotalActivity reports how many proxied bytes have been
 // NoticeInproxyProxyTotalActivity reports how many proxied bytes have been
 // transferred in total up to this point; in addition to current connection
 // transferred in total up to this point; in addition to current connection
 // status. This is a diagnostic notice.
 // status. This is a diagnostic notice.
 func NoticeInproxyProxyTotalActivity(
 func NoticeInproxyProxyTotalActivity(
+	announcing int32,
 	connectingClients int32,
 	connectingClients int32,
 	connectedClients int32,
 	connectedClients int32,
 	totalBytesUp int64,
 	totalBytesUp int64,
@@ -1175,6 +1183,7 @@ func NoticeInproxyProxyTotalActivity(
 
 
 	singletonNoticeLogger.outputNotice(
 	singletonNoticeLogger.outputNotice(
 		"InproxyProxyTotalActivity", noticeIsDiagnostic,
 		"InproxyProxyTotalActivity", noticeIsDiagnostic,
+		"announcing", announcing,
 		"connectingClients", connectingClients,
 		"connectingClients", connectingClients,
 		"connectedClients", connectedClients,
 		"connectedClients", connectedClients,
 		"totalBytesUp", totalBytesUp,
 		"totalBytesUp", totalBytesUp,

+ 1 - 1
psiphon/server/config.go

@@ -800,7 +800,7 @@ func LoadConfig(configJSON []byte) (*Config, error) {
 
 
 		if protocol.TunnelProtocolUsesInproxy(tunnelProtocol) && !inproxy.Enabled() {
 		if protocol.TunnelProtocolUsesInproxy(tunnelProtocol) && !inproxy.Enabled() {
 			// Note that, technically, it may be possible to allow this case,
 			// Note that, technically, it may be possible to allow this case,
-			// since PSIPHON_ENABLE_INPROXY is currently required only for
+			// since !PSIPHON_DISABLE_INPROXY is currently required only for
 			// client/proxy-side WebRTC functionality, although that could change.
 			// client/proxy-side WebRTC functionality, although that could change.
 			return nil, errors.TraceNew("inproxy implementation is not enabled")
 			return nil, errors.TraceNew("inproxy implementation is not enabled")
 		}
 		}

+ 7 - 2
psiphon/server/destBytes_test.go

@@ -79,7 +79,12 @@ func runTestDestBytes() error {
 	setLogCallback(logCallback)
 	setLogCallback(logCallback)
 	defer setLogCallback(nil)
 	defer setLogCallback(nil)
 
 
-	const logPeriod = 250 * time.Millisecond
+	// Test can fail if the following addBytes/Sleep loop isn't synchronzied
+	// with the destBytesLogger timer.
+	//
+	// TODO: use time/synctest in Go 1.25+
+
+	const logPeriod = 500 * time.Millisecond
 
 
 	destBytesLogger := newDestBytesLogger(&SupportServices{
 	destBytesLogger := newDestBytesLogger(&SupportServices{
 		Config: &Config{
 		Config: &Config{
@@ -236,7 +241,7 @@ func runTestDestBytes() error {
 		return nil
 		return nil
 	}
 	}
 
 
-	for i := 0; i < 3; i++ {
+	for i := 0; i < 2; i++ {
 
 
 		addBytes()
 		addBytes()
 
 

+ 2 - 1
psiphon/server/meek.go

@@ -319,7 +319,7 @@ func NewMeekServer(
 
 
 		if !inproxy.Enabled() {
 		if !inproxy.Enabled() {
 			// Note that, technically, it may be possible to allow this case,
 			// Note that, technically, it may be possible to allow this case,
-			// since PSIPHON_ENABLE_INPROXY is currently required only for
+			// since !PSIPHON_DISABLE_INPROXY is currently required only for
 			// client/proxy-side WebRTC functionality, although that could change.
 			// client/proxy-side WebRTC functionality, although that could change.
 			return nil, errors.TraceNew("inproxy implementation is not enabled")
 			return nil, errors.TraceNew("inproxy implementation is not enabled")
 		}
 		}
@@ -1895,6 +1895,7 @@ func (server *MeekServer) inproxyReloadTactics() error {
 		p.Int(parameters.InproxyBrokerMatcherOfferLimitEntryCount),
 		p.Int(parameters.InproxyBrokerMatcherOfferLimitEntryCount),
 		p.Int(parameters.InproxyBrokerMatcherOfferRateLimitQuantity),
 		p.Int(parameters.InproxyBrokerMatcherOfferRateLimitQuantity),
 		p.Duration(parameters.InproxyBrokerMatcherOfferRateLimitInterval),
 		p.Duration(parameters.InproxyBrokerMatcherOfferRateLimitInterval),
+		p.Duration(parameters.InproxyBrokerMatcherOfferMinimumDeadline),
 		p.Int(parameters.InproxyMaxCompartmentIDListLength),
 		p.Int(parameters.InproxyMaxCompartmentIDListLength),
 		p.Int(parameters.InproxyBrokerDSLRequestRateLimitQuantity),
 		p.Int(parameters.InproxyBrokerDSLRequestRateLimitQuantity),
 		p.Duration(parameters.InproxyBrokerDSLRequestRateLimitInterval))
 		p.Duration(parameters.InproxyBrokerDSLRequestRateLimitInterval))

+ 13 - 3
psiphon/server/pb/psiphond/inproxy_broker.pb.go

@@ -63,6 +63,7 @@ type InproxyBroker struct {
 	TimedOut                      *bool                  `protobuf:"varint,136,opt,name=timed_out,json=timedOut,proto3,oneof" json:"timed_out,omitempty"`
 	TimedOut                      *bool                  `protobuf:"varint,136,opt,name=timed_out,json=timedOut,proto3,oneof" json:"timed_out,omitempty"`
 	MeekServerHttpVersion         *string                `protobuf:"bytes,137,opt,name=meek_server_http_version,json=meekServerHttpVersion,proto3,oneof" json:"meek_server_http_version,omitempty"`
 	MeekServerHttpVersion         *string                `protobuf:"bytes,137,opt,name=meek_server_http_version,json=meekServerHttpVersion,proto3,oneof" json:"meek_server_http_version,omitempty"`
 	PendingAnswersSize            *int64                 `protobuf:"varint,138,opt,name=pending_answers_size,json=pendingAnswersSize,proto3,oneof" json:"pending_answers_size,omitempty"`
 	PendingAnswersSize            *int64                 `protobuf:"varint,138,opt,name=pending_answers_size,json=pendingAnswersSize,proto3,oneof" json:"pending_answers_size,omitempty"`
+	OfferDeadline                 *int64                 `protobuf:"varint,139,opt,name=offer_deadline,json=offerDeadline,proto3,oneof" json:"offer_deadline,omitempty"`
 	unknownFields                 protoimpl.UnknownFields
 	unknownFields                 protoimpl.UnknownFields
 	sizeCache                     protoimpl.SizeCache
 	sizeCache                     protoimpl.SizeCache
 }
 }
@@ -377,11 +378,18 @@ func (x *InproxyBroker) GetPendingAnswersSize() int64 {
 	return 0
 	return 0
 }
 }
 
 
+func (x *InproxyBroker) GetOfferDeadline() int64 {
+	if x != nil && x.OfferDeadline != nil {
+		return *x.OfferDeadline
+	}
+	return 0
+}
+
 var File_ca_psiphon_psiphond_inproxy_broker_proto protoreflect.FileDescriptor
 var File_ca_psiphon_psiphond_inproxy_broker_proto protoreflect.FileDescriptor
 
 
 const file_ca_psiphon_psiphond_inproxy_broker_proto_rawDesc = "" +
 const file_ca_psiphon_psiphond_inproxy_broker_proto_rawDesc = "" +
 	"\n" +
 	"\n" +
-	"(ca.psiphon.psiphond/inproxy_broker.proto\x12\x13ca.psiphon.psiphond\x1a%ca.psiphon.psiphond/base_params.proto\"\x93\x16\n" +
+	"(ca.psiphon.psiphond/inproxy_broker.proto\x12\x13ca.psiphon.psiphond\x1a%ca.psiphon.psiphond/base_params.proto\"\xd3\x16\n" +
 	"\rInproxyBroker\x12E\n" +
 	"\rInproxyBroker\x12E\n" +
 	"\vbase_params\x18\x01 \x01(\v2\x1f.ca.psiphon.psiphond.BaseParamsH\x00R\n" +
 	"\vbase_params\x18\x01 \x01(\v2\x1f.ca.psiphon.psiphond.BaseParamsH\x00R\n" +
 	"baseParams\x88\x01\x01\x12=\n" +
 	"baseParams\x88\x01\x01\x12=\n" +
@@ -426,7 +434,8 @@ const file_ca_psiphon_psiphond_inproxy_broker_proto_rawDesc = "" +
 	"\x12stored_tactics_tag\x18\x87\x01 \x01(\tH R\x10storedTacticsTag\x88\x01\x01\x12!\n" +
 	"\x12stored_tactics_tag\x18\x87\x01 \x01(\tH R\x10storedTacticsTag\x88\x01\x01\x12!\n" +
 	"\ttimed_out\x18\x88\x01 \x01(\bH!R\btimedOut\x88\x01\x01\x12=\n" +
 	"\ttimed_out\x18\x88\x01 \x01(\bH!R\btimedOut\x88\x01\x01\x12=\n" +
 	"\x18meek_server_http_version\x18\x89\x01 \x01(\tH\"R\x15meekServerHttpVersion\x88\x01\x01\x126\n" +
 	"\x18meek_server_http_version\x18\x89\x01 \x01(\tH\"R\x15meekServerHttpVersion\x88\x01\x01\x126\n" +
-	"\x14pending_answers_size\x18\x8a\x01 \x01(\x03H#R\x12pendingAnswersSize\x88\x01\x01B\x0e\n" +
+	"\x14pending_answers_size\x18\x8a\x01 \x01(\x03H#R\x12pendingAnswersSize\x88\x01\x01\x12+\n" +
+	"\x0eoffer_deadline\x18\x8b\x01 \x01(\x03H$R\rofferDeadline\x88\x01\x01B\x0e\n" +
 	"\f_base_paramsB\x1b\n" +
 	"\f_base_paramsB\x1b\n" +
 	"\x19_announcement_match_indexB\x1a\n" +
 	"\x19_announcement_match_indexB\x1a\n" +
 	"\x18_announcement_queue_sizeB\x0f\n" +
 	"\x18_announcement_queue_sizeB\x0f\n" +
@@ -465,7 +474,8 @@ const file_ca_psiphon_psiphond_inproxy_broker_proto_rawDesc = "" +
 	"\n" +
 	"\n" +
 	"_timed_outB\x1b\n" +
 	"_timed_outB\x1b\n" +
 	"\x19_meek_server_http_versionB\x17\n" +
 	"\x19_meek_server_http_versionB\x17\n" +
-	"\x15_pending_answers_sizeBHZFgithub.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/server/pb/psiphondb\x06proto3"
+	"\x15_pending_answers_sizeB\x11\n" +
+	"\x0f_offer_deadlineBHZFgithub.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/server/pb/psiphondb\x06proto3"
 
 
 var (
 var (
 	file_ca_psiphon_psiphond_inproxy_broker_proto_rawDescOnce sync.Once
 	file_ca_psiphon_psiphond_inproxy_broker_proto_rawDescOnce sync.Once

+ 1 - 0
psiphon/server/proto/ca.psiphon.psiphond/inproxy_broker.proto

@@ -50,4 +50,5 @@ message InproxyBroker {
     optional bool timed_out = 136;
     optional bool timed_out = 136;
     optional string meek_server_http_version = 137;
     optional string meek_server_http_version = 137;
     optional int64 pending_answers_size = 138;
     optional int64 pending_answers_size = 138;
+    optional int64 offer_deadline = 139;
 }
 }

+ 9 - 7
psiphon/server/server_test.go

@@ -1726,7 +1726,9 @@ func runServer(t *testing.T, runConfig *runServerConfig) {
 		clientConfig.InproxySkipAwaitFullyConnected = true
 		clientConfig.InproxySkipAwaitFullyConnected = true
 
 
 		clientConfig.InproxyProxySessionPrivateKey = inproxyTestConfig.proxySessionPrivateKey
 		clientConfig.InproxyProxySessionPrivateKey = inproxyTestConfig.proxySessionPrivateKey
-		clientConfig.InproxyMaxClients = 1
+		clientConfig.InproxyMaxClients = 1 // Deprecated; kept to make sure nothing breaks.
+		clientConfig.InproxyMaxCommonClients = 1
+		clientConfig.InproxyMaxPersonalClients = 0
 		clientConfig.InproxyLimitUpstreamBytesPerSecond = 0
 		clientConfig.InproxyLimitUpstreamBytesPerSecond = 0
 		clientConfig.InproxyLimitDownstreamBytesPerSecond = 0
 		clientConfig.InproxyLimitDownstreamBytesPerSecond = 0
 		clientConfig.ServerEntrySignaturePublicKey = inproxyTestConfig.brokerServerEntrySignaturePublicKey
 		clientConfig.ServerEntrySignaturePublicKey = inproxyTestConfig.brokerServerEntrySignaturePublicKey
@@ -1738,6 +1740,7 @@ func runServer(t *testing.T, runConfig *runServerConfig) {
 
 
 			clientConfig.InproxyClientPersonalCompartmentID = inproxyTestConfig.personalCompartmentID
 			clientConfig.InproxyClientPersonalCompartmentID = inproxyTestConfig.personalCompartmentID
 			clientConfig.InproxyProxyPersonalCompartmentID = inproxyTestConfig.personalCompartmentID
 			clientConfig.InproxyProxyPersonalCompartmentID = inproxyTestConfig.personalCompartmentID
+			clientConfig.InproxyMaxPersonalClients = 1
 		}
 		}
 
 
 		// Simulate a CDN adding required HTTP headers by injecting them at
 		// Simulate a CDN adding required HTTP headers by injecting them at
@@ -2004,13 +2007,12 @@ func runServer(t *testing.T, runConfig *runServerConfig) {
 
 
 			case "InproxyProxyTotalActivity":
 			case "InproxyProxyTotalActivity":
 
 
-				// This assumes that both non-zero bytes up and down are
-				// reported in at least same notice, although there's some
-				// unlikely chance it's only one or the other.
+				// Bytes aren't checked here since there's a chance that the
+				// last infrequent InproxyProxyTotalActivity notice is posted
+				// before any upstream relay.
+
 				connectedClients := int(payload["connectedClients"].(float64))
 				connectedClients := int(payload["connectedClients"].(float64))
-				bytesUp := int(payload["totalBytesUp"].(float64))
-				bytesDown := int(payload["totalBytesDown"].(float64))
-				if connectedClients == 1 && bytesUp > 0 && bytesDown > 0 {
+				if connectedClients == 1 {
 					sendNotificationReceived(inproxyActivity)
 					sendNotificationReceived(inproxyActivity)
 				}
 				}
 
 

+ 14 - 7
psiphon/server/tunnelServer.go

@@ -2122,13 +2122,17 @@ type destinationBytesMetrics struct {
 }
 }
 
 
 func (d *destinationBytesMetrics) UpdateProgress(
 func (d *destinationBytesMetrics) UpdateProgress(
-	downstreamBytes, upstreamBytes, _ int64) {
+	bytesRead, bytesWritten, _ int64) {
 
 
 	// Concurrency: UpdateProgress may be called without holding the sshClient
 	// Concurrency: UpdateProgress may be called without holding the sshClient
-	// lock; all accesses to bytesUp/bytesDown must use atomic operations.
+	// lock; all accesses to d.bytesUp/bytesDown must use atomic operations.
+
+	// Bytes read from the egress destination become bytes sent down to the
+	// client and bytes written to the egress destination are bytes up from
+	// the client.
 
 
-	atomic.AddInt64(&d.bytesUp, upstreamBytes)
-	atomic.AddInt64(&d.bytesDown, downstreamBytes)
+	atomic.AddInt64(&d.bytesUp, bytesWritten)
+	atomic.AddInt64(&d.bytesDown, bytesRead)
 }
 }
 
 
 func (d *destinationBytesMetrics) getBytesUp() int64 {
 func (d *destinationBytesMetrics) getBytesUp() int64 {
@@ -2225,7 +2229,7 @@ func newInproxyProxyQualityTracker(
 }
 }
 
 
 func (t *inproxyProxyQualityTracker) UpdateProgress(
 func (t *inproxyProxyQualityTracker) UpdateProgress(
-	downstreamBytes, upstreamBytes, _ int64) {
+	bytesRead, bytesWritten, _ int64) {
 
 
 	// Concurrency: UpdateProgress may be called concurrently; all accesses to
 	// Concurrency: UpdateProgress may be called concurrently; all accesses to
 	// mutated fields use atomic operations.
 	// mutated fields use atomic operations.
@@ -2236,8 +2240,11 @@ func (t *inproxyProxyQualityTracker) UpdateProgress(
 		return
 		return
 	}
 	}
 
 
-	bytesUp := t.bytesUp.Add(upstreamBytes)
-	bytesDown := t.bytesDown.Add(downstreamBytes)
+	// Bytes read from the proxied tunnel are upstream and bytes written to
+	// the proxied tunnel are downstream.
+
+	bytesUp := t.bytesUp.Add(bytesRead)
+	bytesDown := t.bytesDown.Add(bytesWritten)
 
 
 	if (t.targetBytesUp == 0 || bytesUp >= t.targetBytesUp) &&
 	if (t.targetBytesUp == 0 || bytesUp >= t.targetBytesUp) &&
 		(t.targetBytesDown == 0 || bytesDown >= t.targetBytesDown) &&
 		(t.targetBytesDown == 0 || bytesDown >= t.targetBytesDown) &&

+ 3 - 5
replace/dtls/pkg/crypto/ciphersuite/ccm.go

@@ -5,7 +5,6 @@ package ciphersuite
 
 
 import (
 import (
 	"crypto/aes"
 	"crypto/aes"
-	"crypto/rand"
 	"encoding/binary"
 	"encoding/binary"
 	"fmt"
 	"fmt"
 
 
@@ -65,10 +64,9 @@ func (c *CCM) Encrypt(pkt *recordlayer.RecordLayer, raw []byte) ([]byte, error)
 	payload := raw[recordlayer.HeaderSize:]
 	payload := raw[recordlayer.HeaderSize:]
 	raw = raw[:recordlayer.HeaderSize]
 	raw = raw[:recordlayer.HeaderSize]
 
 
-	nonce := append(append([]byte{}, c.localWriteIV[:4]...), make([]byte, 8)...)
-	if _, err := rand.Read(nonce[4:]); err != nil {
-		return nil, err
-	}
+	// [Psiphon]
+	// See comment in generateAEADNonce.
+	nonce := generateAEADNonce(c.localWriteIV, &pkt.Header)
 
 
 	additionalData := generateAEADAdditionalData(&pkt.Header, len(payload))
 	additionalData := generateAEADAdditionalData(&pkt.Header, len(payload))
 	encryptedPayload := c.localCCM.Seal(nil, nonce, payload, additionalData)
 	encryptedPayload := c.localCCM.Seal(nil, nonce, payload, additionalData)

+ 19 - 0
replace/dtls/pkg/crypto/ciphersuite/ciphersuite.go

@@ -33,6 +33,25 @@ func generateAEADAdditionalData(h *recordlayer.Header, payloadLen int) []byte {
 	return additionalData[:]
 	return additionalData[:]
 }
 }
 
 
+// [Psiphon]
+// Backport of https://github.com/pion/dtls/commit/61762dee8217991882c5eb79856b9e7a73ee349f. In
+// newer pion/dtls, AEAD nonce generation has moved to common code in aead.encrypt. In this older
+// fork, apply the fix to both CCM.Encrypt and GCM.Encrypt via this helper function.
+//
+// As in the upstream fix, nonce generation is changed from random bytes, which is more vulnerable
+// to reuse, to instead follow the scheme recommended in
+// https://www.rfc-editor.org/rfc/rfc9325#name-nonce-reuse-in-tls-12.
+func generateAEADNonce(writeIV []byte, h *recordlayer.Header) []byte {
+	if gcmNonceLength != 12 || ccmNonceLength != 12 {
+		panic("unexpected nonce length")
+	}
+	nonce := make([]byte, 12)
+	copy(nonce, writeIV[:4])
+	seq64 := (uint64(h.Epoch) << 48) | (h.SequenceNumber & 0x0000ffffffffffff)
+	binary.BigEndian.PutUint64(nonce[4:], seq64)
+	return nonce
+}
+
 // examinePadding returns, in constant time, the length of the padding to remove
 // examinePadding returns, in constant time, the length of the padding to remove
 // from the end of payload. It also returns a byte which is equal to 255 if the
 // from the end of payload. It also returns a byte which is equal to 255 if the
 // padding was valid and 0 otherwise. See RFC 2246, Section 6.2.3.2.
 // padding was valid and 0 otherwise. See RFC 2246, Section 6.2.3.2.

+ 3 - 6
replace/dtls/pkg/crypto/ciphersuite/gcm.go

@@ -6,7 +6,6 @@ package ciphersuite
 import (
 import (
 	"crypto/aes"
 	"crypto/aes"
 	"crypto/cipher"
 	"crypto/cipher"
-	"crypto/rand"
 	"encoding/binary"
 	"encoding/binary"
 	"fmt"
 	"fmt"
 
 
@@ -58,11 +57,9 @@ func (g *GCM) Encrypt(pkt *recordlayer.RecordLayer, raw []byte) ([]byte, error)
 	payload := raw[recordlayer.HeaderSize:]
 	payload := raw[recordlayer.HeaderSize:]
 	raw = raw[:recordlayer.HeaderSize]
 	raw = raw[:recordlayer.HeaderSize]
 
 
-	nonce := make([]byte, gcmNonceLength)
-	copy(nonce, g.localWriteIV[:4])
-	if _, err := rand.Read(nonce[4:]); err != nil {
-		return nil, err
-	}
+	// [Psiphon]
+	// See comment in generateAEADNonce.
+	nonce := generateAEADNonce(g.localWriteIV, &pkt.Header)
 
 
 	additionalData := generateAEADAdditionalData(&pkt.Header, len(payload))
 	additionalData := generateAEADAdditionalData(&pkt.Header, len(payload))
 	encryptedPayload := g.localGCM.Seal(nil, nonce, payload, additionalData)
 	encryptedPayload := g.localGCM.Seal(nil, nonce, payload, additionalData)

+ 7 - 0
vendor/github.com/Psiphon-Labs/utls/handshake_client.go

@@ -471,6 +471,13 @@ func (c *Conn) loadSession(hello *clientHelloMsg) (
 	}
 	}
 	session = cs.session
 	session = cs.session
 
 
+	// [Psiphon]
+	// Mitigate a race condition where utls.SessionTicketExtension.InitializeByUtls
+	// mutates the shared cache entry. Only a shallow copy is required.
+	sessionCopy := *session
+	session = &sessionCopy
+	// [Psiphon]
+
 	// Check that version used for the previous session is still valid.
 	// Check that version used for the previous session is still valid.
 	versOk := false
 	versOk := false
 	for _, v := range hello.supportedVersions {
 	for _, v := range hello.supportedVersions {

+ 3 - 5
vendor/github.com/pion/dtls/v2/pkg/crypto/ciphersuite/ccm.go

@@ -5,7 +5,6 @@ package ciphersuite
 
 
 import (
 import (
 	"crypto/aes"
 	"crypto/aes"
-	"crypto/rand"
 	"encoding/binary"
 	"encoding/binary"
 	"fmt"
 	"fmt"
 
 
@@ -65,10 +64,9 @@ func (c *CCM) Encrypt(pkt *recordlayer.RecordLayer, raw []byte) ([]byte, error)
 	payload := raw[recordlayer.HeaderSize:]
 	payload := raw[recordlayer.HeaderSize:]
 	raw = raw[:recordlayer.HeaderSize]
 	raw = raw[:recordlayer.HeaderSize]
 
 
-	nonce := append(append([]byte{}, c.localWriteIV[:4]...), make([]byte, 8)...)
-	if _, err := rand.Read(nonce[4:]); err != nil {
-		return nil, err
-	}
+	// [Psiphon]
+	// See comment in generateAEADNonce.
+	nonce := generateAEADNonce(c.localWriteIV, &pkt.Header)
 
 
 	additionalData := generateAEADAdditionalData(&pkt.Header, len(payload))
 	additionalData := generateAEADAdditionalData(&pkt.Header, len(payload))
 	encryptedPayload := c.localCCM.Seal(nil, nonce, payload, additionalData)
 	encryptedPayload := c.localCCM.Seal(nil, nonce, payload, additionalData)

+ 19 - 0
vendor/github.com/pion/dtls/v2/pkg/crypto/ciphersuite/ciphersuite.go

@@ -33,6 +33,25 @@ func generateAEADAdditionalData(h *recordlayer.Header, payloadLen int) []byte {
 	return additionalData[:]
 	return additionalData[:]
 }
 }
 
 
+// [Psiphon]
+// Backport of https://github.com/pion/dtls/commit/61762dee8217991882c5eb79856b9e7a73ee349f. In
+// newer pion/dtls, AEAD nonce generation has moved to common code in aead.encrypt. In this older
+// fork, apply the fix to both CCM.Encrypt and GCM.Encrypt via this helper function.
+//
+// As in the upstream fix, nonce generation is changed from random bytes, which is more vulnerable
+// to reuse, to instead follow the scheme recommended in
+// https://www.rfc-editor.org/rfc/rfc9325#name-nonce-reuse-in-tls-12.
+func generateAEADNonce(writeIV []byte, h *recordlayer.Header) []byte {
+	if gcmNonceLength != 12 || ccmNonceLength != 12 {
+		panic("unexpected nonce length")
+	}
+	nonce := make([]byte, 12)
+	copy(nonce, writeIV[:4])
+	seq64 := (uint64(h.Epoch) << 48) | (h.SequenceNumber & 0x0000ffffffffffff)
+	binary.BigEndian.PutUint64(nonce[4:], seq64)
+	return nonce
+}
+
 // examinePadding returns, in constant time, the length of the padding to remove
 // examinePadding returns, in constant time, the length of the padding to remove
 // from the end of payload. It also returns a byte which is equal to 255 if the
 // from the end of payload. It also returns a byte which is equal to 255 if the
 // padding was valid and 0 otherwise. See RFC 2246, Section 6.2.3.2.
 // padding was valid and 0 otherwise. See RFC 2246, Section 6.2.3.2.

+ 3 - 6
vendor/github.com/pion/dtls/v2/pkg/crypto/ciphersuite/gcm.go

@@ -6,7 +6,6 @@ package ciphersuite
 import (
 import (
 	"crypto/aes"
 	"crypto/aes"
 	"crypto/cipher"
 	"crypto/cipher"
-	"crypto/rand"
 	"encoding/binary"
 	"encoding/binary"
 	"fmt"
 	"fmt"
 
 
@@ -58,11 +57,9 @@ func (g *GCM) Encrypt(pkt *recordlayer.RecordLayer, raw []byte) ([]byte, error)
 	payload := raw[recordlayer.HeaderSize:]
 	payload := raw[recordlayer.HeaderSize:]
 	raw = raw[:recordlayer.HeaderSize]
 	raw = raw[:recordlayer.HeaderSize]
 
 
-	nonce := make([]byte, gcmNonceLength)
-	copy(nonce, g.localWriteIV[:4])
-	if _, err := rand.Read(nonce[4:]); err != nil {
-		return nil, err
-	}
+	// [Psiphon]
+	// See comment in generateAEADNonce.
+	nonce := generateAEADNonce(g.localWriteIV, &pkt.Header)
 
 
 	additionalData := generateAEADAdditionalData(&pkt.Header, len(payload))
 	additionalData := generateAEADAdditionalData(&pkt.Header, len(payload))
 	encryptedPayload := g.localGCM.Seal(nil, nonce, payload, additionalData)
 	encryptedPayload := g.localGCM.Seal(nil, nonce, payload, additionalData)

+ 1 - 1
vendor/modules.txt

@@ -60,7 +60,7 @@ github.com/Psiphon-Labs/quic-go/internal/utils/ringbuffer
 github.com/Psiphon-Labs/quic-go/internal/wire
 github.com/Psiphon-Labs/quic-go/internal/wire
 github.com/Psiphon-Labs/quic-go/logging
 github.com/Psiphon-Labs/quic-go/logging
 github.com/Psiphon-Labs/quic-go/quicvarint
 github.com/Psiphon-Labs/quic-go/quicvarint
-# github.com/Psiphon-Labs/utls v0.0.0-20250623193530-396869e9cd87
+# github.com/Psiphon-Labs/utls v0.0.0-20260129182755-24497d415a8d
 ## explicit; go 1.23
 ## explicit; go 1.23
 github.com/Psiphon-Labs/utls
 github.com/Psiphon-Labs/utls
 github.com/Psiphon-Labs/utls/byteorder
 github.com/Psiphon-Labs/utls/byteorder