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/dsl
           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/monotime
           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
           go test -v -race ./psiphon/common/parameters
           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/resolver
           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/wildcard
           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 -timeout 30m -race ./psiphon
           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=fragmentor.coverprofile ./psiphon/common/fragmentor
           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=monotime.coverprofile ./psiphon/common/monotime
           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
           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=push.coverprofile ./psiphon/common/push
           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=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=wildcard.coverprofile ./psiphon/common/wildcard
           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 -timeout 30m -covermode=count -coverprofile=psiphon.coverprofile ./psiphon
           go test -v -covermode=count -coverprofile=clientlib.coverprofile ./ClientLibrary/clientlib
@@ -144,7 +146,7 @@ jobs:
         if: ${{ matrix.test-type == 'protobuf' }}
         run: |
           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
         if: ${{ matrix.test-type == 'memory' }}
@@ -158,7 +160,7 @@ jobs:
         run: |
           cd ${{ github.workspace }}/go/src/github.com/Psiphon-Labs/psiphon-tunnel-core/ConsoleClient
           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_GQUIC"
           go build -a -v -tags "PSIPHON_ENABLE_REFRACTION_NETWORKING"
@@ -176,13 +178,13 @@ jobs:
         if: ${{ matrix.test-type == 'code-vetting' }}
         run: |
           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)
         if: ${{ matrix.test-type == 'dependency-check' }}
         run: |
           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
 
       # License check ignore cases:
@@ -202,5 +204,5 @@ jobs:
         if: ${{ matrix.test-type == 'code-vetting' }}
         run: |
           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
 
-  # 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
   #
-  # TODO: conditional on PSIPHON_ENABLE_INPROXY build tag?
+  # TODO: conditional on !PSIPHON_DISABLE_INPROXY build tag?
   ANDROID_LDFLAGS="-checklinkname=0 $LDFLAGS"
 
   TARGET_ARCH=arm

+ 19 - 3
ConsoleClient/main.go

@@ -51,6 +51,9 @@ func main() {
 	var embeddedServerEntryListFilename string
 	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
 	flag.BoolVar(&formatNotices, "formatNotices", false, "emit notices in human-readable format")
 
@@ -224,6 +227,7 @@ func main() {
 		// Tunnel mode
 		worker = &TunnelWorker{
 			embeddedServerEntryListFilename: embeddedServerEntryListFilename,
+			pushPayloadFilename:             pushPayloadFilename,
 		}
 	}
 
@@ -336,6 +340,7 @@ type Worker interface {
 // TunnelWorker is the Worker protocol implementation used for tunnel mode.
 type TunnelWorker struct {
 	embeddedServerEntryListFilename string
+	pushPayloadFilename             string
 	embeddedServerListWaitGroup     *sync.WaitGroup
 	controller                      *psiphon.Controller
 }
@@ -347,8 +352,7 @@ func (w *TunnelWorker) Init(ctx context.Context, config *psiphon.Config) error {
 
 	err := psiphon.OpenDataStore(config)
 	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
@@ -389,11 +393,23 @@ func (w *TunnelWorker) Init(ctx context.Context, config *psiphon.Config) error {
 
 	controller, err := psiphon.NewController(config)
 	if err != nil {
-		psiphon.NoticeError("error creating controller: %s", err)
 		return errors.Trace(err)
 	}
 	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
 }
 

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

@@ -48,6 +48,9 @@ import java.util.ArrayList;
 import java.util.Collection;
 import java.util.Collections;
 import java.util.Enumeration;
+import java.util.HashMap;
+import java.util.Iterator;
+import java.util.Map;
 import java.util.List;
 import java.util.Locale;
 import java.util.concurrent.CountDownLatch;
@@ -66,6 +69,18 @@ import psi.PsiphonProviderNetwork;
 import psi.PsiphonProviderNoticeHandler;
 
 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 {
         default void onDiagnosticMessage(String message) {}
     }
@@ -135,12 +150,22 @@ public class PsiphonTunnel {
          * Called when tunnel-core reports proxy usage statistics.
          * By default onInproxyProxyActivity is disabled. Enable it by setting
          * 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 connectedClients Number of clients currently connected to the proxy.
          * @param bytesUp  Bytes uploaded 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.
          * @param region The server region received.
@@ -264,6 +289,19 @@ public class PsiphonTunnel {
         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.
     // cpuSampleDurationSeconds and blockSampleDurationSeconds determines how to long to wait and
     // sample profiles that require active sampling. When set to 0, these profiles are skipped.
@@ -922,11 +960,18 @@ public class PsiphonTunnel {
                 mHostService.onInproxyMustUpgrade();
             } else if (noticeType.equals("InproxyProxyActivity")) {
                 JSONObject data = notice.getJSONObject("data");
+                Map<String, RegionActivitySnapshot> personalRegionActivity =
+                        parseRegionActivity(data.getJSONObject("personalRegionActivity"));
+                Map<String, RegionActivitySnapshot> commonRegionActivity =
+                        parseRegionActivity(data.getJSONObject("commonRegionActivity"));
                 mHostService.onInproxyProxyActivity(
+                        data.getInt("announcing"),
                         data.getInt("connectingClients"),
                         data.getInt("connectedClients"),
                         data.getLong("bytesUp"),
-                        data.getLong("bytesDown"));
+                        data.getLong("bytesDown"),
+                        personalRegionActivity,
+                        commonRegionActivity);
             }
 
             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) {
         String region = "";
         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)
 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
 #
-# TODO: conditional on PSIPHON_ENABLE_INPROXY build tag?
+# TODO: conditional on !PSIPHON_DISABLE_INPROXY build tag?
 
 # 16KB page size alignment for Android compatibility
 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
  By default onInproxyProxyActivity is disabled. Enable it by setting
  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 connectedClients Number of clients currently connected to the proxy.
  @param bytesUp Bytes uploaded 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
                        bytesUp:(long)bytesUp
                      bytesDown:(long)bytesDown;
@@ -436,6 +440,19 @@ Returns the path where the rotated notices file will be created.
  */
 - (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.
  @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();
 }
 
+// See comment in header.
+- (BOOL)importPushPayload:(NSData * _Nonnull)payload {
+    return GoPsiImportPushPayload(payload);
+}
+
 // See comment in header.
 + (NSString * _Nonnull)getBuildInfo {
     return GoPsiGetBuildInfo();
@@ -749,7 +754,9 @@ typedef NS_ERROR_ENUM(PsiphonTunnelErrorDomain, PsiphonTunnelErrorCode) {
     
     // If RemoteServerListUrl/RemoteServerListURLs and RemoteServerListSignaturePublicKey
     // 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) {
         logMessage(@"Remote server list functionality will be disabled");
     }
@@ -780,7 +787,10 @@ typedef NS_ERROR_ENUM(PsiphonTunnelErrorDomain, PsiphonTunnelErrorCode) {
     
     // If ObfuscatedServerListRootURL/ObfuscatedServerListRootURLs is absent,
     // 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");
     }
 
@@ -1186,17 +1196,27 @@ typedef NS_ERROR_ENUM(PsiphonTunnelErrorDomain, PsiphonTunnelErrorCode) {
         }
     }
     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 connectedClients = [notice valueForKeyPath:@"data.connectedClients"];
         id bytesUp = [notice valueForKeyPath:@"data.bytesUp"];
         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]];
             return;
         }
-        if ([self.tunneledAppDelegate respondsToSelector:@selector(onInproxyProxyActivity:connectedClients:bytesUp:bytesDown:)]) {
+        if ([self.tunneledAppDelegate respondsToSelector:@selector(onInproxyProxyActivity:connectingClients:connectedClients:bytesUp:bytesDown:)]) {
             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
 // 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.
 func ImportExchangePayload(payload string) bool {
 
@@ -359,6 +359,26 @@ func ImportExchangePayload(payload string) bool {
 	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 sendFeedbackCtx context.Context
 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
 // 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
 
@@ -42,8 +42,9 @@ require (
 	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/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/axiomhq/hyperloglog v0.2.6
 	github.com/bifurcation/mint v0.0.0-20180306135233-198357931e61
 	github.com/bits-and-blooms/bloom/v3 v3.6.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/alexbrainman/sspi v0.0.0-20231016080023-1a75b4708caa // 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/cespare/xxhash/v2 v2.3.0 // 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/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/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/go.mod h1:R8TGG+OXumorJdpAcSFE4SVTT2frMFEGY43Zc/44sNk=
 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/quic-go v0.0.0-20250527153145-79fe45fb83b1 h1:zD7JvZCV8gjvtI0AZmE81Ffc/v7A+qwU1/YfUmN/Flk=
 github.com/Psiphon-Labs/quic-go v0.0.0-20250527153145-79fe45fb83b1/go.mod h1:rONdWgPMbFjyyBai7gB1IBF4pT9r4l0GyiDst5XR1SY=
-github.com/Psiphon-Labs/utls v0.0.0-20250623193530-396869e9cd87 h1:h/OnQpPMwC7pKN9YQTJ+vQATjchta6kgumJNnkJBq1k=
-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/go.mod h1:cEWa1LVoE5KvSD9ONXsZrj0z6KqySlCCNKHlLzbqAt4=
 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
 // proxy to a broker. The broker uses this information when matching proxies
 // 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 {
 	BaseAPIParameters             protocol.PackedAPIParameters `cbor:"1,keyasint,omitempty"`
 	ProtocolVersion               int32                        `cbor:"2,keyasint,omitempty"`
 	NATType                       NATType                      `cbor:"3,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"`
 	ConnectedClients              int32                        `cbor:"8,keyasint,omitempty"`
 	LimitUpstreamBytesPerSecond   int64                        `cbor:"9,keyasint,omitempty"`
 	LimitDownstreamBytesPerSecond int64                        `cbor:"10,keyasint,omitempty"`
 	PeakUpstreamBytesPerSecond    int64                        `cbor:"11,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
@@ -296,16 +299,22 @@ type ClientMetrics struct {
 // overhead, proxies with multiple workers should designate just one worker
 // 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
 // verified proxy ID.
 type ProxyAnnounceRequest struct {
 	PersonalCompartmentIDs []ID          `cbor:"1,keyasint,omitempty"`
 	Metrics                *ProxyMetrics `cbor:"2,keyasint,omitempty"`
 	CheckTactics           bool          `cbor:"3,keyasint,omitempty"`
+	PreCheckTactics        bool          `cbor:"4,keyasint,omitempty"`
 }
 
 // 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.
 type WebRTCSessionDescription struct {
 	Type int    `cbor:"1,keyasint,omitempty"`
@@ -349,6 +358,7 @@ type ProxyAnnounceResponse struct {
 	TrafficShapingParameters    *TrafficShapingParameters `cbor:"10,keyasint,omitempty"`
 	NetworkProtocol             NetworkProtocol           `cbor:"11,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,
@@ -451,8 +461,11 @@ type ProxyAnswerRequest struct {
 	// 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 {
+	NoAwaitingClient bool `cbor:"1,keyasint,omitempty"`
 }
 
 // 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+"nat_type"] = metrics.NATType
 	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+"connected_clients"] = metrics.ConnectedClients
 	logFields[logFieldPrefix+"limit_upstream_bytes_per_second"] = metrics.LimitUpstreamBytesPerSecond
@@ -890,7 +905,8 @@ func (params *TrafficShapingParameters) Validate() error {
 }
 
 // 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(
 	lookupGeoIP LookupGeoIP,
 	baseAPIParameterValidator common.APIParameterValidator,
@@ -898,27 +914,34 @@ func (request *ProxyAnswerRequest) ValidateAndGetLogFields(
 	geoIPData common.GeoIPData,
 	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
@@ -935,10 +958,12 @@ func (request *ProxyAnswerRequest) ValidateAndGetLogFields(
 
 	logFields["connection_id"] = request.ConnectionID
 	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
+	if sdpMetrics != nil {
+		logFields["has_IPv6"] = sdpMetrics.hasIPv6
+		logFields["has_private_IP"] = sdpMetrics.hasPrivateIP
+		logFields["filtered_ice_candidates"] = sdpMetrics.filteredICECandidates
+	}
 
 	return filteredSDP, logFields, nil
 }

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

@@ -22,6 +22,7 @@ package inproxy
 import (
 	"context"
 	std_errors "errors"
+	"fmt"
 	"net"
 	"strconv"
 	"sync"
@@ -406,6 +407,7 @@ func (b *Broker) SetLimits(
 	matcherOfferLimitEntryCount int,
 	matcherOfferRateLimitQuantity int,
 	matcherOfferRateLimitInterval time.Duration,
+	matcherOfferMinimumDeadline time.Duration,
 	maxCompartmentIDs int,
 	dslRequestRateLimitQuantity int,
 	dslRequestRateLimitInterval time.Duration) {
@@ -417,7 +419,8 @@ func (b *Broker) SetLimits(
 		matcherAnnouncementNonlimitedProxyIDs,
 		matcherOfferLimitEntryCount,
 		matcherOfferRateLimitQuantity,
-		matcherOfferRateLimitInterval)
+		matcherOfferRateLimitInterval,
+		matcherOfferMinimumDeadline)
 
 	b.maxCompartmentIDs.Store(
 		int64(common.ValueOrDefault(maxCompartmentIDs, MaxCompartmentIDs)))
@@ -729,16 +732,21 @@ func (b *Broker) handleProxyAnnounce(
 	// existing, cached tactics. In the case where tactics have changed,
 	// don't enqueue the proxy announcement and return no-match so that the
 	// 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
-	if announceRequest.CheckTactics {
+	if announceRequest.CheckTactics || announceRequest.PreCheckTactics {
 		tacticsPayload, newTacticsTag, err =
 			b.config.GetTacticsPayload(geoIPData, apiParams)
 		if err != nil {
 			return nil, errors.Trace(err)
 		}
 
-		if tacticsPayload != nil && newTacticsTag != "" {
+		if (tacticsPayload != nil && newTacticsTag != "") ||
+			announceRequest.PreCheckTactics {
+
 			responsePayload, err := MarshalProxyAnnounceResponse(
 				&ProxyAnnounceResponse{
 					TacticsPayload: tacticsPayload,
@@ -756,11 +764,23 @@ func (b *Broker) handleProxyAnnounce(
 	// such as censored locations, from announcing. Proxies with personal
 	// compartment IDs are always allowed, as they will be used only by
 	// 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 &&
 		!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
@@ -952,6 +972,7 @@ func (b *Broker) handleProxyAnnounce(
 			TrafficShapingParameters:    clientOffer.TrafficShapingParameters,
 			NetworkProtocol:             clientOffer.NetworkProtocol,
 			DestinationAddress:          clientOffer.DestinationAddress,
+			ClientRegion:                clientOffer.Properties.GeoIPData.Country,
 		})
 	if err != nil {
 		return nil, errors.Trace(err)
@@ -1089,7 +1110,15 @@ func (b *Broker) handleClientOffer(
 	if !hasPersonalCompartmentIDs &&
 		!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
@@ -1184,7 +1213,8 @@ func (b *Broker) handleClientOffer(
 		var limitError *MatcherLimitError
 		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
 		// 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
 			// so that the broker will timeout first and have an opportunity to
 			// 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
 		}
 
@@ -1363,8 +1400,10 @@ func (b *Broker) handleProxyAnswer(
 		if answerError != "" {
 			// This is a proxy-reported error that occurred while creating the answer.
 			logFields["answer_error"] = answerError
+			logFields["error"] = fmt.Sprintf("proxy answer error: %s", answerError)
 		}
 		if retErr != nil {
+			// For the error field, retErr takes precedence over answerError
 			logFields["error"] = retErr.Error()
 		}
 		logFields.Add(transportLogFields)
@@ -1392,6 +1431,18 @@ func (b *Broker) handleProxyAnswer(
 	hasPersonalCompartmentIDs, err := b.matcher.AnnouncementHasPersonalCompartmentIDs(
 		initiatorID, answerRequest.ConnectionID)
 	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)
 	}
 
@@ -1406,9 +1457,6 @@ func (b *Broker) handleProxyAnswer(
 		return nil, errors.Trace(err)
 	}
 
-	answerSDP := answerRequest.ProxyAnswerSDP
-	answerSDP.SDP = string(filteredSDP)
-
 	if answerRequest.AnswerError != "" {
 
 		// 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.
 		// These fields are used internally in the matcher.
 
+		answerSDP := answerRequest.ProxyAnswerSDP
+		answerSDP.SDP = string(filteredSDP)
+
 		proxyAnswer = &MatchAnswer{
 			ProxyIP:        proxyIP,
 			ProxyID:        initiatorID,
@@ -1433,6 +1484,18 @@ func (b *Broker) handleProxyAnswer(
 
 		err = b.matcher.Answer(proxyAnswer)
 		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)
 		}
 	}

+ 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.

+ 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.
@@ -23,13 +23,27 @@ package inproxy
 
 import (
 	"context"
+	"fmt"
 	"sync/atomic"
 	"testing"
 
+	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common/errors"
 	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/internal/testutils"
 )
 
 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
 	// with iptables rules to simulate NAT conditions
@@ -54,48 +68,48 @@ func TestNATDiscovery(t *testing.T) {
 		},
 
 		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) {
-			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)
 		if callCount != a {
-			t.Errorf(
+			return errors.Tracef(
 				"unexpected setNATType call count: %d",
 				callCount)
 		}
 
 		callCount = atomic.LoadInt32(&setPortMappingTypesCallCount)
 		if callCount != b {
-			t.Errorf(
+			return errors.Tracef(
 				"unexpected setPortMappingTypes call count: %d",
 				callCount)
 		}
 
 		callCount = atomic.LoadInt32(&stunServerAddressSucceededCallCount)
 		if callCount != c {
-			t.Errorf(
+			return errors.Tracef(
 				"unexpected stunServerAddressSucceeded call count: %d",
 				callCount)
 		}
 
 		callCount = atomic.LoadInt32(&stunServerAddressFailedCallCount)
 		if callCount != d {
-			t.Errorf(
+			return errors.Tracef(
 				"unexpected stunServerAddressFailedCallCount call count: %d",
 				callCount)
 		}
+
+		return nil
 	}
 
 	config := &NATDiscoverConfig{
@@ -109,7 +123,10 @@ func TestNATDiscovery(t *testing.T) {
 
 	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
 
@@ -118,7 +135,10 @@ func TestNATDiscovery(t *testing.T) {
 
 	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
 
@@ -127,8 +147,13 @@ func TestNATDiscovery(t *testing.T) {
 
 	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
 tactics requests, run under MeekServer; and use the tactics, psinet database,
 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

+ 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.
@@ -34,7 +34,7 @@ import (
 // The inproxy package has a broad API that referenced throughout the psiphon
 // 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
 // referenced and excluded from builds. The stub types and functions here are
 // 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.

+ 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.
@@ -81,6 +81,8 @@ func runTestInproxy(doMustUpgrade bool) error {
 
 	testCompartmentID, _ := MakeID()
 	testCommonCompartmentIDs := []ID{testCompartmentID}
+	personalCompartmentID, _ := MakeID()
+	testPersonalCompartmentIDs := []ID{personalCompartmentID}
 
 	testNetworkID := "NETWORK-ID-1"
 	testNetworkType := NetworkTypeUnknown
@@ -109,6 +111,13 @@ func runTestInproxy(doMustUpgrade bool) error {
 	roundTripperFailed := func(RoundTripper) { atomic.AddInt32(&roundTripperFailedCount, 1) }
 	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 receivedClientMustUpgrade chan struct{}
 	if doMustUpgrade {
@@ -481,6 +490,7 @@ func runTestInproxy(doMustUpgrade bool) error {
 			brokerClientPrivateKey:      proxyPrivateKey,
 			brokerPublicKey:             brokerPublicKey,
 			brokerRootObfuscationSecret: brokerRootObfuscationSecret,
+			personalCompartmentIDs:      testPersonalCompartmentIDs,
 			brokerClientRoundTripper: newHTTPRoundTripper(
 				brokerListener.Addr().String(), "proxy"),
 			brokerClientRoundTripperSucceeded: roundTripperSucceded,
@@ -552,16 +562,40 @@ func runTestInproxy(doMustUpgrade bool) error {
 
 			HandleTacticsPayload: makeHandleTacticsPayload(proxyPrivateKey, tacticsNetworkID),
 
-			MaxClients:                    proxyMaxClients,
+			MaxCommonClients:              proxyMaxClients,
+			MaxPersonalClients:            proxyMaxClients,
 			LimitUpstreamBytesPerSecond:   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,
-					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() {
@@ -779,7 +813,9 @@ func runTestInproxy(doMustUpgrade bool) error {
 	}
 
 	newClientBrokerClient := func(
-		disableWaitToShareSession bool) (*BrokerClient, error) {
+		disableWaitToShareSession bool,
+		commonCompartmentIDs []ID,
+		personalCompartmentIDs []ID) (*BrokerClient, error) {
 
 		clientPrivateKey, err := GenerateSessionPrivateKey()
 		if err != nil {
@@ -790,7 +826,8 @@ func runTestInproxy(doMustUpgrade bool) error {
 			networkID:   testNetworkID,
 			networkType: testNetworkType,
 
-			commonCompartmentIDs: testCommonCompartmentIDs,
+			commonCompartmentIDs:   commonCompartmentIDs,
+			personalCompartmentIDs: personalCompartmentIDs,
 
 			disableWaitToShareSession: disableWaitToShareSession,
 
@@ -883,12 +920,22 @@ func runTestInproxy(doMustUpgrade bool) error {
 		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 {
 		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 {
 		return errors.Trace(err)
 	}
@@ -902,16 +949,34 @@ func runTestInproxy(doMustUpgrade bool) error {
 		isMobile := i%4 == 0
 		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
 		// several broker clients.
+		//
+		// Per-region testing is handled by the HTTP server alternating regions
+		// based on request count.
 		var brokerClient *BrokerClient
 		switch i % 3 {
 		case 0:
-			brokerClient = sharedBrokerClient
+			if isPersonalClient {
+				brokerClient = sharedPersonalBrokerClient
+			} else {
+				brokerClient = sharedCommonBrokerClient
+			}
 		case 1:
-			brokerClient = sharedBrokerClientDisableWait
+			if isPersonalClient {
+				brokerClient = sharedPersonalBrokerClientDisableWait
+			} else {
+				brokerClient = sharedCommonBrokerClientDisableWait
+			}
 		case 2:
-			brokerClient, err = newClientBrokerClient(true)
+			if isPersonalClient {
+				brokerClient, err = newClientBrokerClient(true, nil, testPersonalCompartmentIDs)
+			} else {
+				brokerClient, err = newClientBrokerClient(true, testCommonCompartmentIDs, nil)
+			}
 			if err != nil {
 				return errors.Trace(err)
 			}
@@ -1006,6 +1071,40 @@ func runTestInproxy(doMustUpgrade bool) error {
 		if atomic.LoadInt32(&roundTripperFailedCount) > 0 {
 			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
@@ -1023,14 +1122,40 @@ func runTestInproxy(doMustUpgrade bool) 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) {
 
 		// For this test, clients set the path to "/client" and proxies
 		// set the path to "/proxy" and we use that to create stub GeoIP
 		// 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
 		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(
 			http.MaxBytesReader(w, r.Body, BrokerMaxRequestBodySize))
 		if err != nil {

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

@@ -102,6 +102,7 @@ type Matcher struct {
 	offerLimitEntryCount     int
 	offerRateLimitQuantity   int
 	offerRateLimitInterval   time.Duration
+	offerMinimumDeadline     time.Duration
 
 	matchSignal chan struct{}
 
@@ -124,6 +125,7 @@ type MatcherConfig struct {
 	OfferLimitEntryCount   int
 	OfferRateLimitQuantity int
 	OfferRateLimitInterval time.Duration
+	OfferMinimumDeadline   time.Duration
 
 	// Proxy quality state.
 	ProxyQualityState *ProxyQualityState
@@ -220,6 +222,7 @@ type MatchAnswer struct {
 // MatchMetrics records statistics about the match queue state at the time a
 // match is made.
 type MatchMetrics struct {
+	OfferDeadline          time.Duration
 	OfferMatchIndex        int
 	OfferQueueSize         int
 	AnnouncementMatchIndex int
@@ -233,6 +236,7 @@ func (metrics *MatchMetrics) GetMetrics() common.LogFields {
 		return nil
 	}
 	return common.LogFields{
+		"offer_deadline":           int64(metrics.OfferDeadline / time.Millisecond),
 		"offer_match_index":        metrics.OfferMatchIndex,
 		"offer_queue_size":         metrics.OfferQueueSize,
 		"announcement_match_index": metrics.AnnouncementMatchIndex,
@@ -283,6 +287,11 @@ func (offerEntry *offerEntry) getMatchMetrics() *MatchMetrics {
 type answerInfo struct {
 	announcement *MatchAnnouncement
 	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
@@ -333,7 +342,8 @@ func NewMatcher(config *MatcherConfig) *Matcher {
 		config.AnnouncementNonlimitedProxyIDs,
 		config.OfferLimitEntryCount,
 		config.OfferRateLimitQuantity,
-		config.OfferRateLimitInterval)
+		config.OfferRateLimitInterval,
+		config.OfferMinimumDeadline)
 
 	return m
 }
@@ -350,7 +360,8 @@ func (m *Matcher) SetLimits(
 	announcementNonlimitedProxyIDs []ID,
 	offerLimitEntryCount int,
 	offerRateLimitQuantity int,
-	offerRateLimitInterval time.Duration) {
+	offerRateLimitInterval time.Duration,
+	offerMinimumDeadline time.Duration) {
 
 	nonlimitedProxyIDs := make(map[ID]struct{})
 	for _, proxyID := range announcementNonlimitedProxyIDs {
@@ -368,6 +379,7 @@ func (m *Matcher) SetLimits(
 	m.offerLimitEntryCount = offerLimitEntryCount
 	m.offerRateLimitQuantity = offerRateLimitQuantity
 	m.offerRateLimitInterval = offerRateLimitInterval
+	m.offerMinimumDeadline = offerMinimumDeadline
 	m.offerQueueMutex.Unlock()
 }
 
@@ -473,6 +485,8 @@ func (m *Matcher) Announce(
 	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
 // returned announcement or ctx is done. The caller must not mutate the offer
 // or its properties after calling Announce.
@@ -546,6 +560,11 @@ func (m *Matcher) Offer(
 			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.
 	if !proxyAnswerInfo.answer.ConnectionID.Equal(
 		proxyAnswerInfo.announcement.ConnectionID) {
@@ -559,6 +578,8 @@ func (m *Matcher) Offer(
 		nil
 }
 
+var errNoPendingAnswer = std_errors.New("no pending answer")
+
 // AnnouncementHasPersonalCompartmentIDs looks for a pending answer for an
 // announcement identified by the specified proxy ID and connection ID and
 // returns whether the announcement has personal compartment IDs, indicating
@@ -573,7 +594,7 @@ func (m *Matcher) AnnouncementHasPersonalCompartmentIDs(
 	if !ok {
 		// The input IDs don't correspond to a pending answer, or the client
 		// is no longer awaiting the response.
-		return false, errors.TraceNew("no pending answer")
+		return false, errors.Trace(errNoPendingAnswer)
 	}
 
 	pendingAnswer := pendingAnswerValue.(*pendingAnswer)
@@ -599,7 +620,7 @@ func (m *Matcher) Answer(
 	if !ok {
 		// The input IDs don't correspond to a pending answer, or the client
 		// is no longer awaiting the response.
-		return errors.TraceNew("no pending answer")
+		return errors.Trace(errNoPendingAnswer)
 	}
 
 	m.pendingAnswers.Delete(key)
@@ -677,15 +698,30 @@ func (m *Matcher) matchAllOffers() {
 
 		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 {
 			m.removeOfferEntry(false, offerEntry)
 			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)
 		if announcementEntry == nil {
 			continue
@@ -698,6 +734,7 @@ func (m *Matcher) matchAllOffers() {
 		// were inspected before matching.
 
 		matchMetrics := &MatchMetrics{
+			OfferDeadline:          untilOfferDeadline,
 			OfferMatchIndex:        offerIndex,
 			OfferQueueSize:         m.offerQueue.Len(),
 			AnnouncementMatchIndex: announcementMatchIndex,

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

@@ -47,6 +47,7 @@ func runTestMatcher() error {
 	limitEntryCount := 50
 	rateLimitQuantity := 100
 	rateLimitInterval := 1000 * time.Millisecond
+	minimumDeadline := 1 * time.Hour
 
 	logger := testutils.NewTestLogger()
 
@@ -338,7 +339,7 @@ func runTestMatcher() error {
 
 	m.SetLimits(
 		0, rateLimitQuantity, rateLimitInterval, []ID{},
-		0, rateLimitQuantity, rateLimitInterval)
+		0, rateLimitQuantity, rateLimitInterval, 0)
 
 	time.Sleep(rateLimitInterval)
 
@@ -389,11 +390,29 @@ func runTestMatcher() error {
 		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)
 
+	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(
 		limitEntryCount, rateLimitQuantity, rateLimitInterval, []ID{},
-		limitEntryCount, rateLimitQuantity, rateLimitInterval)
+		limitEntryCount, rateLimitQuantity, rateLimitInterval, 0)
+
+	time.Sleep(rateLimitInterval)
 
 	// 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.

+ 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.

+ 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.

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

@@ -50,11 +50,13 @@ type Proxy struct {
 	bytesDown         atomic.Int64
 	peakBytesUp       atomic.Int64
 	peakBytesDown     atomic.Int64
-	connectingClients int32
-	connectedClients  int32
+	announcing        atomic.Int32
+	connectingClients atomic.Int32
+	connectedClients  atomic.Int32
 
 	config                *ProxyConfig
 	activityUpdateWrapper *activityUpdateWrapper
+	lastAnnouncing        int32
 	lastConnectingClients int32
 	lastConnectedClients  int32
 
@@ -65,6 +67,24 @@ type Proxy struct {
 	nextAnnounceMutex        sync.Mutex
 	nextAnnounceBrokerClient *BrokerClient
 	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
@@ -132,9 +152,13 @@ type ProxyConfig struct {
 	// restarting the proxy.
 	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
 	// 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.
 	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
 	// with this proxy.
 	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(
+	announcing int32,
 	connectingClients int32,
 	connectedClients int32,
 	bytesUp 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.
 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{
-		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}
@@ -190,6 +296,24 @@ func (w *activityUpdateWrapper) UpdateProgress(bytesRead, bytesWritten int64, _
 	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
 // availability; the Broker matches the proxy with clients, and facilitates
 // 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)
 
+	// 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
 	// the very first announcement round trip is complete. The first round
 	// trip is awaited so that:
 	//
 	// - 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
 	//   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.
 	//
 	// 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 :=
 		context.WithCancel(context.Background())
@@ -228,7 +393,7 @@ func (p *Proxy) Run(ctx context.Context) {
 	proxyWaitGroup.Add(1)
 	go func() {
 		defer proxyWaitGroup.Done()
-		p.proxyClients(ctx, signalFirstAnnounceDone)
+		p.proxyClients(ctx, signalFirstAnnounceDone, false, firstWorkerIsPersonal)
 	}()
 
 	select {
@@ -239,86 +404,136 @@ func (p *Proxy) Run(ctx context.Context) {
 
 	// 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)
-		go func() {
+		go func(reducedPause bool) {
 			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()
 }
 
-// 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) {
 
-	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)
 	bytesDown := p.bytesDown.Swap(0)
 
 	greaterThanSwapInt64(&p.peakBytesUp, bytesUp)
 	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
 
+	p.lastAnnouncing = announcing
 	p.lastConnectingClients = connectingClients
 	p.lastConnectedClients = connectedClients
 
-	if !clientsChanged &&
+	if !stateChanged &&
 		bytesUp == 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
 	}
 
 	p.config.ActivityUpdater(
+		announcing,
 		connectingClients,
 		connectedClients,
 		bytesUp,
 		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 {
@@ -333,8 +548,95 @@ func greaterThanSwapInt64(addr *atomic.Int64, new int64) bool {
 	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(
-	ctx context.Context, signalAnnounceDone func()) {
+	ctx context.Context, signalAnnounceDone func(), reducedPause bool, isPersonal bool) {
 
 	// Proxy one client, repeating until ctx is done.
 	//
@@ -375,12 +677,39 @@ func (p *Proxy) proxyClients(
 		return false
 	}
 
+	preCheckTacticsDone := false
+
 	for ctx.Err() == nil {
 
 		if !p.config.WaitForNetworkConnectivity() {
 			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 {
 			logAnnounceCount = proxyAnnounceLogSampleSize
 			logErrorsCount = proxyAnnounceLogSampleSize
@@ -389,7 +718,7 @@ func (p *Proxy) proxyClients(
 		}
 
 		backOff, err := p.proxyOneClient(
-			ctx, logAnnounce, signalAnnounceDone)
+			ctx, logAnnounce, &preCheckTacticsDone, signalAnnounceDone, isPersonal)
 
 		if !backOff || err == nil {
 			failureDelayFactor = 1
@@ -514,7 +843,9 @@ func (p *Proxy) doNetworkDiscovery(
 func (p *Proxy) proxyOneClient(
 	ctx context.Context,
 	logAnnounce func() bool,
-	signalAnnounceDone func()) (bool, error) {
+	preCheckTacticsDone *bool,
+	signalAnnounceDone func(),
+	isPersonal bool) (bool, error) {
 
 	// Cancel/close this connection immediately if the network changes.
 	if p.config.GetCurrentNetworkContext != nil {
@@ -588,7 +919,10 @@ func (p *Proxy) proxyOneClient(
 
 	// Only the first worker, which has signalAnnounceDone configured, checks
 	// 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,
 	// including performance information, which is sent to the broker in the
@@ -601,7 +935,12 @@ func (p *Proxy) proxyOneClient(
 	// with the original network ID.
 
 	metrics, tacticsNetworkID, compressTactics, err := p.getMetrics(
-		checkTactics, brokerCoordinator, webRTCCoordinator)
+		checkTactics || preCheckTactics,
+		brokerCoordinator,
+		webRTCCoordinator,
+		maxCommonClients,
+		maxPersonalClients,
+		rateLimits)
 	if err != nil {
 		return backOff, errors.Trace(err)
 	}
@@ -609,9 +948,9 @@ func (p *Proxy) proxyOneClient(
 	// Set a delay before announcing, to stagger the announce request times.
 	// The delay helps to avoid triggering rate limits or similar errors from
 	// 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
 	// 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
 	// long-polling.
+
+	p.announcing.Add(1)
+
 	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(
 		ctx,
 		requestDelay,
@@ -661,6 +1008,7 @@ func (p *Proxy) proxyOneClient(
 			PersonalCompartmentIDs: personalCompartmentIDs,
 			Metrics:                metrics,
 			CheckTactics:           checkTactics,
+			PreCheckTactics:        preCheckTactics,
 		})
 	if logAnnounce() {
 		p.config.Logger.WithTraceFields(common.LogFields{
@@ -668,6 +1016,9 @@ func (p *Proxy) proxyOneClient(
 			"elapsedTime": time.Since(announceStartTime).String(),
 		}).Info("announcement request")
 	}
+
+	p.announcing.Add(-1)
+
 	if err != nil {
 		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 {
 		signalAnnounceDone()
 	}
+	if preCheckTactics {
+		*preCheckTacticsDone = true
+	}
 
 	// MustUpgrade has precedence over other cases, to ensure the callback is
 	// invoked. Trigger back-off back off when rate/entry limited or must
@@ -716,10 +1071,24 @@ func (p *Proxy) proxyOneClient(
 
 	} 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")
 
 	}
 
+	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 ||
 		(announceResponse.UseMediaStreams &&
 			announceResponse.SelectedProtocolVersion < ProtocolVersion2) ||
@@ -731,23 +1100,36 @@ func (p *Proxy) proxyOneClient(
 			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
 	// 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
 
 	// 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
 	defer func() {
 		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.
 
-	_, err = brokerClient.ProxyAnswer(
+	answerResponse, err := brokerClient.ProxyAnswer(
 		ctx,
 		&ProxyAnswerRequest{
 			ConnectionID:      announceResponse.ConnectionID,
@@ -809,6 +1191,11 @@ func (p *Proxy) proxyOneClient(
 			// Prioritize returning any WebRTC error for logging.
 			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)
 	}
 
@@ -818,6 +1205,22 @@ func (p *Proxy) proxyOneClient(
 		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.
 
 	// 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.
 
 	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() {
-		atomic.AddInt32(&p.connectedClients, -1)
+		p.connectedClients.Add(-1)
+		if regionActivity != nil {
+			regionActivity.connectedClients.Add(-1)
+		}
 	}()
 
 	// Throttle the relay connection.
@@ -892,17 +1302,18 @@ func (p *Proxy) proxyOneClient(
 	// Here, each client gets LimitUp/DownstreamBytesPerSecond. Proxy
 	// operators may to want to limit their bandwidth usage with a single
 	// 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,
 		announceResponse.NetworkProtocol.IsStream(),
-		common.RateLimits{
-			ReadBytesPerSecond:  int64(p.config.LimitUpstreamBytesPerSecond),
-			WriteBytesPerSecond: int64(p.config.LimitDownstreamBytesPerSecond),
-		})
+		rateLimits)
 
 	// Hook up bytes transferred counting for activity updates.
 
@@ -920,7 +1331,7 @@ func (p *Proxy) proxyOneClient(
 			proxyRelayInactivityTimeout)
 
 	destinationConn, err = common.NewActivityMonitoredConn(
-		destinationConn, inactivityTimeout, false, nil, p.activityUpdateWrapper)
+		destinationConn, inactivityTimeout, false, nil, connActivityWrapper)
 	if err != nil {
 		return backOff, errors.Trace(err)
 	}
@@ -1012,7 +1423,10 @@ func (p *Proxy) proxyOneClient(
 func (p *Proxy) getMetrics(
 	includeTacticsParameters bool,
 	brokerCoordinator BrokerDialCoordinator,
-	webRTCCoordinator WebRTCDialCoordinator) (
+	webRTCCoordinator WebRTCDialCoordinator,
+	maxCommonClients int,
+	maxPersonalClients int,
+	rateLimits common.RateLimits) (
 	*ProxyMetrics, string, bool, error) {
 
 	// tacticsNetworkID records the exact network ID that corresponds to the
@@ -1040,11 +1454,12 @@ func (p *Proxy) getMetrics(
 		ProtocolVersion:               LatestProtocolVersion,
 		NATType:                       webRTCCoordinator.NATType(),
 		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(),
 		PeakDownstreamBytesPerSecond:  p.peakBytesDown.Load(),
 	}, 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.

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

@@ -1237,7 +1237,7 @@ func (s *ResponderSessions) touchSession(sessionID ID, session *session) {
 	s.mutex.Lock()
 	defer s.mutex.Unlock()
 
-	if !session.hasUnexpectedInitiatorPublicKey() {
+	if session.hasUnexpectedInitiatorPublicKey() {
 
 		// In this case, SetKnownInitiatorPublicKeys was called concurrent to
 		// 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)
 	}
 
+	TTL := lrucache.DefaultExpiration
+	if !s.applyTTL {
+		TTL = lrucache.NoExpiration
+	}
+
 	s.mutex.Lock()
 	err = s.sessions.Add(
-		strSessionID, session, lrucache.DefaultExpiration)
+		strSessionID, session, TTL)
 	s.mutex.Unlock()
 
 	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.

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

@@ -409,6 +409,7 @@ const (
 	InproxyAllBrokerSpecs                              = "InproxyAllBrokerSpecs"
 	InproxyBrokerSpecs                                 = "InproxyBrokerSpecs"
 	InproxyPersonalPairingBrokerSpecs                  = "InproxyPersonalPairingBrokerSpecs"
+	InproxyPersonalPairingMaxBrokerSpecCount           = "InproxyPersonalPairingMaxBrokerSpecCount"
 	InproxyProxyBrokerSpecs                            = "InproxyProxyBrokerSpecs"
 	InproxyProxyPersonalPairingBrokerSpecs             = "InproxyProxyPersonalPairingBrokerSpecs"
 	InproxyClientBrokerSpecs                           = "InproxyClientBrokerSpecs"
@@ -427,6 +428,7 @@ const (
 	InproxyBrokerMatcherOfferLimitEntryCount           = "InproxyBrokerMatcherOfferLimitEntryCount"
 	InproxyBrokerMatcherOfferRateLimitQuantity         = "InproxyBrokerMatcherOfferRateLimitQuantity"
 	InproxyBrokerMatcherOfferRateLimitInterval         = "InproxyBrokerMatcherOfferRateLimitInterval"
+	InproxyBrokerMatcherOfferMinimumDeadline           = "InproxyBrokerMatcherOfferMinimumDeadline"
 	InproxyBrokerMatcherPrioritizeProxiesProbability   = "InproxyBrokerMatcherPrioritizeProxiesProbability"
 	InproxyBrokerMatcherPrioritizeProxiesFilter        = "InproxyBrokerMatcherPrioritizeProxiesFilter"
 	InproxyBrokerMatcherPrioritizeProxiesMinVersion    = "InproxyBrokerMatcherPrioritizeProxiesMinVersion"
@@ -1041,6 +1043,7 @@ var defaultParameters = map[string]struct {
 	InproxyAllBrokerSpecs:                              {value: InproxyBrokerSpecsValue{}, flags: serverSideOnly},
 	InproxyBrokerSpecs:                                 {value: InproxyBrokerSpecsValue{}},
 	InproxyPersonalPairingBrokerSpecs:                  {value: InproxyBrokerSpecsValue{}},
+	InproxyPersonalPairingMaxBrokerSpecCount:           {value: 3, minimum: 0},
 	InproxyProxyBrokerSpecs:                            {value: InproxyBrokerSpecsValue{}},
 	InproxyProxyPersonalPairingBrokerSpecs:             {value: InproxyBrokerSpecsValue{}},
 	InproxyClientBrokerSpecs:                           {value: InproxyBrokerSpecsValue{}},
@@ -1059,6 +1062,7 @@ var defaultParameters = map[string]struct {
 	InproxyBrokerMatcherOfferLimitEntryCount:           {value: 10, minimum: 0, flags: serverSideOnly},
 	InproxyBrokerMatcherOfferRateLimitQuantity:         {value: 50, minimum: 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},
 	InproxyBrokerMatcherPrioritizeProxiesFilter:        {value: KeyStrings{}, 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_EXCHANGED  = "EXCHANGED"
 	SERVER_ENTRY_SOURCE_DSL        = "DSL-*"
+	SERVER_ENTRY_SOURCE_PUSH       = "PUSH-*"
 
 	CAPABILITY_SSH_API_REQUESTS            = "ssh-api-requests"
 	CAPABILITY_UNTUNNELED_WEB_API_REQUESTS = "handshake"
@@ -116,6 +117,11 @@ var SupportedServerEntrySources = []string{
 	SERVER_ENTRY_SOURCE_OBFUSCATED,
 	SERVER_ENTRY_SOURCE_EXCHANGED,
 	SERVER_ENTRY_SOURCE_DSL,
+	SERVER_ENTRY_SOURCE_PUSH,
+}
+
+func PushServerEntrySource(source string) string {
+	return "PUSH-" + source
 }
 
 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.
 //
 // 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
 // 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

+ 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)
 }
 
+// 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 (
 	CompressionNone = int32(0)
 	CompressionZlib = int32(1)

+ 137 - 5
psiphon/config.go

@@ -241,6 +241,11 @@ type Config struct {
 	// recommended.
 	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
 	// may be used to load cached tactics or perform an untunneled tactics
 	// request and then post tactics-related notices, including Application
@@ -653,10 +658,24 @@ type Config struct {
 	// ephemeral key will be generated.
 	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"`
 
+	// 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
 	// rate limit for each proxied client. When 0, there is no limit.
 	InproxyLimitUpstreamBytesPerSecond int `json:",omitempty"`
@@ -665,6 +684,50 @@ type Config struct {
 	// transfer rate limit for each proxied client. When 0, there is no limit.
 	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
 	// ID used by an in-proxy proxy. Personal compartment IDs are
 	// distributed from proxy operators to client users out-of-band and
@@ -702,6 +765,16 @@ type Config struct {
 	// temporary tunnels.
 	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.
 	//
@@ -1041,6 +1114,7 @@ type Config struct {
 	InproxyPersonalPairingBrokerSpecs                       parameters.InproxyBrokerSpecsValue               `json:",omitempty"`
 	InproxyProxyBrokerSpecs                                 parameters.InproxyBrokerSpecsValue               `json:",omitempty"`
 	InproxyProxyPersonalPairingBrokerSpecs                  parameters.InproxyBrokerSpecsValue               `json:",omitempty"`
+	InproxyPersonalPairingMaxBrokerSpecCount                *int                                             `json:",omitempty"`
 	InproxyClientBrokerSpecs                                parameters.InproxyBrokerSpecsValue               `json:",omitempty"`
 	InproxyClientPersonalPairingBrokerSpecs                 parameters.InproxyBrokerSpecsValue               `json:",omitempty"`
 	InproxyReplayBrokerDialParametersTTLSeconds             *int                                             `json:",omitempty"`
@@ -1389,6 +1463,14 @@ func (config *Config) Commit(migrateFromLegacyFields bool) error {
 		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.
 
 	// Create datastore directory.
@@ -1490,8 +1572,50 @@ func (config *Config) Commit(migrateFromLegacyFields bool) error {
 		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 &&
@@ -1509,7 +1633,7 @@ func (config *Config) Commit(migrateFromLegacyFields bool) error {
 		!inproxy.Enabled() {
 
 		// 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
 		// LimitTunnelProtocols.IsOnlyInproxyTunnelProtocols configuration,
@@ -2665,6 +2789,10 @@ func (config *Config) makeConfigParameters() map[string]interface{} {
 		applyParameters[parameters.InproxyProxyPersonalPairingBrokerSpecs] = config.InproxyProxyPersonalPairingBrokerSpecs
 	}
 
+	if config.InproxyPersonalPairingMaxBrokerSpecCount != nil {
+		applyParameters[parameters.InproxyPersonalPairingMaxBrokerSpecCount] = *config.InproxyPersonalPairingMaxBrokerSpecCount
+	}
+
 	if len(config.InproxyClientBrokerSpecs) > 0 {
 		applyParameters[parameters.InproxyClientBrokerSpecs] = config.InproxyClientBrokerSpecs
 	}
@@ -3674,6 +3802,10 @@ func (config *Config) setDialParametersHash() {
 		hash.Write([]byte("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 {
 		hash.Write([]byte("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/prng"
 	"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/tactics"
 	"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
 // 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
 // to be prioritized, which it otherwise would not be in later establishment
 // rounds. The establishment process continues after ImportExchangePayload
@@ -615,6 +616,58 @@ func (controller *Controller) ImportExchangePayload(payload string) bool {
 	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
 // for more tunnel candidates. It fetches when signalled, with retries
 // on failure.
@@ -2120,33 +2173,10 @@ func (controller *Controller) launchEstablishing() {
 	controller.establishInproxyForceSelectionCount =
 		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
 	// size of one is forced in packet tunnel mode or when using a
@@ -2234,6 +2264,49 @@ func (controller *Controller) launchEstablishing() {
 	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() {
 
 	// 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.
 
 			p := controller.config.GetParameters().Get()
-			workerPoolSize := p.Int(parameters.ConnectionWorkerPoolSize)
+			workerPoolSize := controller.getConnectionWorkerPoolSize(p)
 			minWaitDuration := p.Duration(parameters.UpstreamProxyErrorMinWaitDuration)
 			maxWaitDuration := p.Duration(parameters.UpstreamProxyErrorMaxWaitDuration)
 			p.Close()
@@ -3224,15 +3297,19 @@ func (controller *Controller) runInproxyProxy() {
 	debugLogging := controller.config.InproxyEnableWebRTCDebugLogging
 
 	var lastActivityNotice time.Time
+	var lastAnnouncing int32
 	var lastActivityConnectingClients, lastActivityConnectedClients int32
 	var lastActivityConnectingClientsTotal, lastActivityConnectedClientsTotal int32
 	var activityTotalBytesUp, activityTotalBytesDown int64
 	activityUpdater := func(
+		announcing int32,
 		connectingClients int32,
 		connectedClients int32,
 		bytesUp 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
 		// NoticeTotalBytesTransferred in tunnel.operateTunnel.
@@ -3244,13 +3321,15 @@ func (controller *Controller) runInproxyProxy() {
 		// activity display.
 
 		if controller.config.EmitInproxyProxyActivity &&
-			(bytesUp > 0 || bytesDown > 0) ||
-			connectingClients != lastActivityConnectingClients ||
-			connectedClients != lastActivityConnectedClients {
+			(bytesUp > 0 || bytesDown > 0 ||
+				announcing != lastAnnouncing ||
+				connectingClients != lastActivityConnectingClients ||
+				connectedClients != lastActivityConnectedClients) {
 
 			NoticeInproxyProxyActivity(
-				connectingClients, connectedClients, bytesUp, bytesDown)
+				announcing, connectingClients, connectedClients, bytesUp, bytesDown, personalRegionActivity, commonRegionActivity)
 
+			lastAnnouncing = announcing
 			lastActivityConnectingClients = connectingClients
 			lastActivityConnectedClients = connectedClients
 		}
@@ -3262,13 +3341,17 @@ func (controller *Controller) runInproxyProxy() {
 		// transferred since starting; in addition to the current number of
 		// connecting and connected clients, whenever that changes. This
 		// 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()) ||
 			connectingClients != lastActivityConnectingClientsTotal ||
 			connectedClients != lastActivityConnectedClientsTotal {
 
 			NoticeInproxyProxyTotalActivity(
-				connectingClients, connectedClients,
+				announcing, connectingClients, connectedClients,
 				activityTotalBytesUp, activityTotalBytesDown)
 			lastActivityNotice = time.Now()
 
@@ -3278,19 +3361,25 @@ func (controller *Controller) runInproxyProxy() {
 	}
 
 	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)
@@ -3306,7 +3395,7 @@ func (controller *Controller) runInproxyProxy() {
 
 	// Emit one last NoticeInproxyProxyTotalActivity with the final byte counts.
 	NoticeInproxyProxyTotalActivity(
-		lastActivityConnectingClients, lastActivityConnectedClients,
+		lastAnnouncing, lastActivityConnectingClients, lastActivityConnectedClients,
 		activityTotalBytesUp, activityTotalBytesDown)
 
 	NoticeInfo("inproxy proxy: stopped")

+ 4 - 3
psiphon/controller_test.go

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

+ 22 - 0
psiphon/inproxy.go

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

+ 1 - 1
psiphon/server/config.go

@@ -800,7 +800,7 @@ func LoadConfig(configJSON []byte) (*Config, error) {
 
 		if protocol.TunnelProtocolUsesInproxy(tunnelProtocol) && !inproxy.Enabled() {
 			// 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.
 			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)
 	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{
 		Config: &Config{
@@ -236,7 +241,7 @@ func runTestDestBytes() error {
 		return nil
 	}
 
-	for i := 0; i < 3; i++ {
+	for i := 0; i < 2; i++ {
 
 		addBytes()
 

+ 2 - 1
psiphon/server/meek.go

@@ -319,7 +319,7 @@ func NewMeekServer(
 
 		if !inproxy.Enabled() {
 			// 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.
 			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.InproxyBrokerMatcherOfferRateLimitQuantity),
 		p.Duration(parameters.InproxyBrokerMatcherOfferRateLimitInterval),
+		p.Duration(parameters.InproxyBrokerMatcherOfferMinimumDeadline),
 		p.Int(parameters.InproxyMaxCompartmentIDListLength),
 		p.Int(parameters.InproxyBrokerDSLRequestRateLimitQuantity),
 		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"`
 	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"`
+	OfferDeadline                 *int64                 `protobuf:"varint,139,opt,name=offer_deadline,json=offerDeadline,proto3,oneof" json:"offer_deadline,omitempty"`
 	unknownFields                 protoimpl.UnknownFields
 	sizeCache                     protoimpl.SizeCache
 }
@@ -377,11 +378,18 @@ func (x *InproxyBroker) GetPendingAnswersSize() int64 {
 	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
 
 const file_ca_psiphon_psiphond_inproxy_broker_proto_rawDesc = "" +
 	"\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" +
 	"\vbase_params\x18\x01 \x01(\v2\x1f.ca.psiphon.psiphond.BaseParamsH\x00R\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" +
 	"\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" +
-	"\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" +
 	"\x19_announcement_match_indexB\x1a\n" +
 	"\x18_announcement_queue_sizeB\x0f\n" +
@@ -465,7 +474,8 @@ const file_ca_psiphon_psiphond_inproxy_broker_proto_rawDesc = "" +
 	"\n" +
 	"_timed_outB\x1b\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 (
 	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 string meek_server_http_version = 137;
     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.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.InproxyLimitDownstreamBytesPerSecond = 0
 		clientConfig.ServerEntrySignaturePublicKey = inproxyTestConfig.brokerServerEntrySignaturePublicKey
@@ -1738,6 +1740,7 @@ func runServer(t *testing.T, runConfig *runServerConfig) {
 
 			clientConfig.InproxyClientPersonalCompartmentID = inproxyTestConfig.personalCompartmentID
 			clientConfig.InproxyProxyPersonalCompartmentID = inproxyTestConfig.personalCompartmentID
+			clientConfig.InproxyMaxPersonalClients = 1
 		}
 
 		// Simulate a CDN adding required HTTP headers by injecting them at
@@ -2004,13 +2007,12 @@ func runServer(t *testing.T, runConfig *runServerConfig) {
 
 			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))
-				bytesUp := int(payload["totalBytesUp"].(float64))
-				bytesDown := int(payload["totalBytesDown"].(float64))
-				if connectedClients == 1 && bytesUp > 0 && bytesDown > 0 {
+				if connectedClients == 1 {
 					sendNotificationReceived(inproxyActivity)
 				}
 

+ 14 - 7
psiphon/server/tunnelServer.go

@@ -2122,13 +2122,17 @@ type destinationBytesMetrics struct {
 }
 
 func (d *destinationBytesMetrics) UpdateProgress(
-	downstreamBytes, upstreamBytes, _ int64) {
+	bytesRead, bytesWritten, _ int64) {
 
 	// 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 {
@@ -2225,7 +2229,7 @@ func newInproxyProxyQualityTracker(
 }
 
 func (t *inproxyProxyQualityTracker) UpdateProgress(
-	downstreamBytes, upstreamBytes, _ int64) {
+	bytesRead, bytesWritten, _ int64) {
 
 	// Concurrency: UpdateProgress may be called concurrently; all accesses to
 	// mutated fields use atomic operations.
@@ -2236,8 +2240,11 @@ func (t *inproxyProxyQualityTracker) UpdateProgress(
 		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) &&
 		(t.targetBytesDown == 0 || bytesDown >= t.targetBytesDown) &&

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

@@ -5,7 +5,6 @@ package ciphersuite
 
 import (
 	"crypto/aes"
-	"crypto/rand"
 	"encoding/binary"
 	"fmt"
 
@@ -65,10 +64,9 @@ func (c *CCM) Encrypt(pkt *recordlayer.RecordLayer, raw []byte) ([]byte, error)
 	payload := 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))
 	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[:]
 }
 
+// [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
 // 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.

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

@@ -6,7 +6,6 @@ package ciphersuite
 import (
 	"crypto/aes"
 	"crypto/cipher"
-	"crypto/rand"
 	"encoding/binary"
 	"fmt"
 
@@ -58,11 +57,9 @@ func (g *GCM) Encrypt(pkt *recordlayer.RecordLayer, raw []byte) ([]byte, error)
 	payload := 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))
 	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
 
+	// [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.
 	versOk := false
 	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 (
 	"crypto/aes"
-	"crypto/rand"
 	"encoding/binary"
 	"fmt"
 
@@ -65,10 +64,9 @@ func (c *CCM) Encrypt(pkt *recordlayer.RecordLayer, raw []byte) ([]byte, error)
 	payload := 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))
 	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[:]
 }
 
+// [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
 // 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.

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

@@ -6,7 +6,6 @@ package ciphersuite
 import (
 	"crypto/aes"
 	"crypto/cipher"
-	"crypto/rand"
 	"encoding/binary"
 	"fmt"
 
@@ -58,11 +57,9 @@ func (g *GCM) Encrypt(pkt *recordlayer.RecordLayer, raw []byte) ([]byte, error)
 	payload := 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))
 	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/logging
 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
 github.com/Psiphon-Labs/utls
 github.com/Psiphon-Labs/utls/byteorder