Browse Source

Merge branch 'master' into inproxy

Rod Hynes 1 year ago
parent
commit
ad9a05a63c
49 changed files with 2898 additions and 510 deletions
  1. 1 1
      .github/workflows/tests.yml
  2. 1 1
      ClientLibrary/Dockerfile
  3. 2 2
      ClientLibrary/build-darwin.sh
  4. 1 1
      ConsoleClient/Dockerfile
  5. 1 1
      MobileLibrary/Android/Dockerfile
  6. 8 0
      MobileLibrary/go-mobile/cmd/gomobile/bind_iosapp.go
  7. 1 1
      MobileLibrary/iOS/build-psiphon-framework.sh
  8. 16 0
      README.md
  9. 1 1
      Server/Dockerfile-binary-builder
  10. 10 7
      go.mod
  11. 14 14
      go.sum
  12. 53 15
      psiphon/common/osl/osl.go
  13. 76 13
      psiphon/common/osl/osl_test.go
  14. 3 0
      psiphon/common/parameters/parameters.go
  15. 1 1
      psiphon/remoteServerList_test.go
  16. 1 19
      psiphon/server/api.go
  17. 166 0
      psiphon/server/discovery.go
  18. 182 0
      psiphon/server/discovery/classic.go
  19. 143 0
      psiphon/server/discovery/classic_test.go
  20. 114 0
      psiphon/server/discovery/consistent.go
  21. 67 0
      psiphon/server/discovery/consistent_test.go
  22. 254 0
      psiphon/server/discovery/discovery.go
  23. 374 0
      psiphon/server/discovery/discovery_test.go
  24. 2 1
      psiphon/server/geoip.go
  25. 51 126
      psiphon/server/psinet/psinet.go
  26. 20 128
      psiphon/server/psinet/psinet_test.go
  27. 186 14
      psiphon/server/server_test.go
  28. 38 0
      psiphon/server/services.go
  29. 9 3
      psiphon/server/tunnelServer.go
  30. 52 58
      vendor/golang.org/x/crypto/chacha20/chacha_ppc64le.s
  31. 6 8
      vendor/golang.org/x/crypto/internal/poly1305/sum_ppc64le.s
  32. 1 4
      vendor/golang.org/x/net/http/httpproxy/proxy.go
  33. 31 0
      vendor/golang.org/x/net/http2/frame.go
  34. 10 1
      vendor/golang.org/x/net/http2/pipe.go
  35. 7 6
      vendor/golang.org/x/net/http2/server.go
  36. 331 0
      vendor/golang.org/x/net/http2/testsync.go
  37. 246 61
      vendor/golang.org/x/net/http2/transport.go
  38. 1 1
      vendor/golang.org/x/sys/unix/aliases.go
  39. 1 1
      vendor/golang.org/x/sys/unix/mmap_nomremap.go
  40. 1 1
      vendor/golang.org/x/sys/unix/syscall_darwin_libSystem.go
  41. 7 5
      vendor/golang.org/x/sys/unix/syscall_freebsd.go
  42. 99 0
      vendor/golang.org/x/sys/unix/syscall_linux.go
  43. 8 0
      vendor/golang.org/x/sys/unix/syscall_zos_s390x.go
  44. 10 0
      vendor/golang.org/x/sys/unix/zsyscall_linux.go
  45. 60 0
      vendor/golang.org/x/sys/unix/ztypes_linux.go
  46. 82 0
      vendor/golang.org/x/sys/windows/syscall_windows.go
  47. 24 0
      vendor/golang.org/x/sys/windows/types_windows.go
  48. 117 9
      vendor/golang.org/x/sys/windows/zsyscall_windows.go
  49. 8 6
      vendor/modules.txt

+ 1 - 1
.github/workflows/tests.yml

@@ -15,7 +15,7 @@ jobs:
       fail-fast: false
       matrix:
         os: [ "ubuntu" ]
-        go: [ "1.21.8" ]
+        go: [ "1.21.9" ]
         test-type: [ "detector", "coverage", "memory", "custom-build-tags" ]
 
     runs-on: ${{ matrix.os }}-latest

+ 1 - 1
ClientLibrary/Dockerfile

@@ -21,7 +21,7 @@ RUN apt-get update -y && apt-get install -y --no-install-recommends \
 
 # Install Go.
 # NOTE: Go 1.10+ is required to build c-shared for windows (https://github.com/golang/go/commit/bb0bfd002ada7e3eb9198d4287b32c2fed6e8da6)
-ENV GOVERSION=go1.21.8 GOROOT=/usr/local/go GOPATH=/go PATH=$PATH:/usr/local/go/bin:/go/bin CGO_ENABLED=1
+ENV GOVERSION=go1.21.9 GOROOT=/usr/local/go GOPATH=/go PATH=$PATH:/usr/local/go/bin:/go/bin CGO_ENABLED=1
 
 RUN curl -L https://storage.googleapis.com/golang/$GOVERSION.linux-amd64.tar.gz -o /tmp/go.tar.gz \
    && tar -C /usr/local -xzf /tmp/go.tar.gz \

+ 2 - 2
ClientLibrary/build-darwin.sh

@@ -9,8 +9,8 @@ if [ -z ${2+x} ]; then BUILD_TAGS=""; else BUILD_TAGS="$2"; fi
 # Note:
 #   clangwrap.sh needs to be updated when the Go version changes.
 #   The last version was:
-#   https://github.com/golang/go/blob/go1.21.8/misc/ios/clangwrap.sh
-GO_VERSION_REQUIRED="1.21.8"
+#   https://github.com/golang/go/blob/go1.21.9/misc/ios/clangwrap.sh
+GO_VERSION_REQUIRED="1.21.9"
 
 BASE_DIR=$(cd "$(dirname "$0")" ; pwd -P)
 cd ${BASE_DIR}

+ 1 - 1
ConsoleClient/Dockerfile

@@ -22,7 +22,7 @@ RUN apt-get update -y && apt-get install -y --no-install-recommends \
   && rm -rf /var/lib/apt/lists/*
 
 # Install Go.
-ENV GOVERSION=go1.21.8 GOROOT=/usr/local/go GOPATH=/go PATH=$PATH:/usr/local/go/bin:/go/bin CGO_ENABLED=1
+ENV GOVERSION=go1.21.9 GOROOT=/usr/local/go GOPATH=/go PATH=$PATH:/usr/local/go/bin:/go/bin CGO_ENABLED=1
 
 RUN curl -L https://storage.googleapis.com/golang/$GOVERSION.linux-amd64.tar.gz -o /tmp/go.tar.gz \
    && tar -C /usr/local -xzf /tmp/go.tar.gz \

+ 1 - 1
MobileLibrary/Android/Dockerfile

@@ -23,7 +23,7 @@ RUN apt-get update -y && apt-get install -y --no-install-recommends \
   && rm -rf /var/lib/apt/lists/*
 
 # Install Go.
-ENV GOVERSION=go1.21.8 GOROOT=/usr/local/go GOPATH=/go PATH=$PATH:/usr/local/go/bin:/go/bin CGO_ENABLED=1
+ENV GOVERSION=go1.21.9 GOROOT=/usr/local/go GOPATH=/go PATH=$PATH:/usr/local/go/bin:/go/bin CGO_ENABLED=1
 
 RUN curl -L https://storage.googleapis.com/golang/$GOVERSION.linux-amd64.tar.gz -o /tmp/go.tar.gz \
   && tar -C /usr/local -xzf /tmp/go.tar.gz \

+ 8 - 0
MobileLibrary/go-mobile/cmd/gomobile/bind_iosapp.go

@@ -269,6 +269,14 @@ func goAppleBind(gobind string, pkgs []*packages.Package, targets []targetInfo)
 	xcframeworkArgs := []string{"-create-xcframework"}
 
 	for _, dir := range frameworkDirs {
+		// On macOS, a temporary directory starts with /var, which is a symbolic link to /private/var.
+		// And in gomobile, a temporary directory is usually used as a working directly.
+		// Unfortunately, xcodebuild in Xcode 15 seems to have a bug and might not be able to understand fullpaths with symbolic links.
+		// As a workaround, resolve the path with symbolic links by filepath.EvalSymlinks.
+		dir, err := filepath.EvalSymlinks(dir)
+		if err != nil {
+			return err
+		}
 		xcframeworkArgs = append(xcframeworkArgs, "-framework", dir)
 	}
 

+ 1 - 1
MobileLibrary/iOS/build-psiphon-framework.sh

@@ -17,7 +17,7 @@ set -e -u -x
 if [ -z ${1+x} ]; then BUILD_TAGS=""; else BUILD_TAGS="$1"; fi
 
 # Modify this value as we use newer Go versions.
-GO_VERSION_REQUIRED="1.21.8"
+GO_VERSION_REQUIRED="1.21.9"
 
 # At this time, psiphon-tunnel-core doesn't support modules
 export GO111MODULE=off

+ 16 - 0
README.md

@@ -146,6 +146,22 @@ $ ./ConsoleClient -config ./client.config
 Use the local SOCKS proxy (port 1080) or HTTP proxy (port 8080) to tunnel traffic.
 
 
+Using Psiphon with Go modules
+--------------------------------------------------------------------------------
+
+The github.com/Psiphon-Labs/psiphon-tunnel-core Go module may be imported into
+other Go programs. Due to legacy release tags predating use of Go modules in
+this repository, neither `go get ...@latest` nor `go get ...@tag` are
+supported at this time. To use the psiphon-tunnel-core Go module and its
+dependencies, reference a specific commit, or reference the `staging-client`
+branch, which is the client-side, production-ready branch:
+
+```
+% go get github.com/Psiphon-Labs/psiphon-tunnel-core@staging-client
+go: added github.com/Psiphon-Labs/psiphon-tunnel-core v1.0.11-0.20240424194431-3612a5a6fb4c
+```
+
+
 Acknowledgements
 --------------------------------------------------------------------------------
 

+ 1 - 1
Server/Dockerfile-binary-builder

@@ -1,6 +1,6 @@
 FROM alpine:3.18.4
 
-ENV GOLANG_VERSION 1.21.8
+ENV GOLANG_VERSION 1.21.9
 ENV GOLANG_SRC_URL https://golang.org/dl/go$GOLANG_VERSION.src.tar.gz
 
 RUN set -ex \

+ 10 - 7
go.mod

@@ -17,7 +17,9 @@ replace gitlab.com/yawning/obfs4.git => github.com/jmwample/obfs4 v0.0.0-2023072
 // psiphon/common/inproxy/newWebRTCConn for details.
 
 replace github.com/pion/dtls/v2 => ./replace/dtls
+
 replace github.com/pion/ice/v2 => ./replace/ice
+
 replace github.com/pion/webrtc/v3 => ./replace/webrtc
 
 require (
@@ -26,8 +28,8 @@ require (
 	github.com/Psiphon-Labs/bolt v0.0.0-20200624191537-23cedaef7ad7
 	github.com/Psiphon-Labs/consistent v0.0.0-20240322131436-20aaa4e05737
 	github.com/Psiphon-Labs/goptlib v0.0.0-20200406165125-c0e32a7a3464
-	github.com/Psiphon-Labs/psiphon-tls v0.0.0-20240305020009-09f917290799
-	github.com/Psiphon-Labs/quic-go v0.0.0-20240305203241-7c4a760d03cc
+	github.com/Psiphon-Labs/psiphon-tls v0.0.0-20240424193802-52b2602ec60c
+	github.com/Psiphon-Labs/quic-go v0.0.0-20240424181006-45545f5e1536
 	github.com/armon/go-proxyproto v0.0.0-20180202201750-5b7edb60ff5f
 	github.com/bifurcation/mint v0.0.0-20180306135233-198357931e61
 	github.com/bits-and-blooms/bloom/v3 v3.6.0
@@ -56,6 +58,7 @@ require (
 	github.com/pion/datachannel v1.5.5
 	github.com/pion/dtls/v2 v2.2.7
 	github.com/pion/ice/v2 v2.3.12
+	github.com/pion/logging v0.2.2
 	github.com/pion/sctp v1.8.12
 	github.com/pion/sdp/v3 v3.0.8
 	github.com/pion/stun v0.6.1
@@ -70,11 +73,11 @@ require (
 	github.com/syndtr/gocapability v0.0.0-20170704070218-db04d3cc01c8
 	github.com/wader/filtertransport v0.0.0-20200316221534-bdd9e61eee78
 	github.com/wlynxg/anet v0.0.1
-	golang.org/x/crypto v0.19.0
-	golang.org/x/net v0.21.0
+	golang.org/x/crypto v0.22.0
+	golang.org/x/net v0.24.0
 	golang.org/x/sync v0.3.0
-	golang.org/x/sys v0.17.0
-	golang.org/x/term v0.17.0
+	golang.org/x/sys v0.19.0
+	golang.org/x/term v0.19.0
 	golang.zx2c4.com/wireguard v0.0.0-20230325221338-052af4a8072b
 	tailscale.com v1.40.0
 )
@@ -106,7 +109,6 @@ require (
 	github.com/onsi/ginkgo/v2 v2.12.0 // indirect
 	github.com/pelletier/go-toml v1.9.5 // indirect
 	github.com/pion/interceptor v0.1.25 // indirect
-	github.com/pion/logging v0.2.2 // indirect
 	github.com/pion/mdns v0.0.9 // indirect
 	github.com/pion/randutil v0.1.0 // indirect
 	github.com/pion/rtcp v1.2.12 // indirect
@@ -122,6 +124,7 @@ require (
 	github.com/tailscale/goupnp v1.0.1-0.20210804011211-c64d0f06ea05 // indirect
 	github.com/x448/float16 v0.8.4 // indirect
 	gitlab.torproject.org/tpo/anti-censorship/pluggable-transports/goptlib v1.5.0 // indirect
+	go.uber.org/mock v0.4.0 // indirect
 	go4.org/mem v0.0.0-20210711025021-927187094b94 // indirect
 	golang.org/x/exp v0.0.0-20221205204356-47842c84f3db // indirect
 	golang.org/x/text v0.14.0 // indirect

+ 14 - 14
go.sum

@@ -18,10 +18,10 @@ github.com/Psiphon-Labs/consistent v0.0.0-20240322131436-20aaa4e05737 h1:QTMy7Uc
 github.com/Psiphon-Labs/consistent v0.0.0-20240322131436-20aaa4e05737/go.mod h1:Enj/Gszv2zCbuRbHbabmNvfO9EM+5kmaGj8CyjwNPlY=
 github.com/Psiphon-Labs/goptlib v0.0.0-20200406165125-c0e32a7a3464 h1:VmnMMMheFXwLV0noxYhbJbLmkV4iaVW3xNnj6xcCNHo=
 github.com/Psiphon-Labs/goptlib v0.0.0-20200406165125-c0e32a7a3464/go.mod h1:Pe5BqN2DdIdChorAXl6bDaQd/wghpCleJfid2NoSli0=
-github.com/Psiphon-Labs/psiphon-tls v0.0.0-20240305020009-09f917290799 h1:dHFQz6jeIr2RdtlioyGIdJw2UfKF7G+g7GYnQxhbgrk=
-github.com/Psiphon-Labs/psiphon-tls v0.0.0-20240305020009-09f917290799/go.mod h1:ECTyVpleBW9oR/iHi185js4Fs7YD5T8A6tujOUzltxs=
-github.com/Psiphon-Labs/quic-go v0.0.0-20240305203241-7c4a760d03cc h1:o9jpHz1Vuum0oasqBX4kKB8VQrR+VJzEJsBg6XAz5YU=
-github.com/Psiphon-Labs/quic-go v0.0.0-20240305203241-7c4a760d03cc/go.mod h1:1gvBCJ18gsMqvZXkPkq0u9/BQKvjNS5RFWwF5uLl2Ys=
+github.com/Psiphon-Labs/psiphon-tls v0.0.0-20240424193802-52b2602ec60c h1:+SEszyxW7yu+smufzSlAszj/WmOYJ054DJjb5jllulc=
+github.com/Psiphon-Labs/psiphon-tls v0.0.0-20240424193802-52b2602ec60c/go.mod h1:AaKKoshr8RI1LZTheeNDtNuZ39qNVPWVK4uir2c2XIs=
+github.com/Psiphon-Labs/quic-go v0.0.0-20240424181006-45545f5e1536 h1:pM5ex1QufkHV8lDR6Tc1Crk1bW5lYZjrFIJGZNBWE9k=
+github.com/Psiphon-Labs/quic-go v0.0.0-20240424181006-45545f5e1536/go.mod h1:2MTiPsgoOqWs3Bo6Xr3ElMBX6zzfjd3YkDFpQJLwHdQ=
 github.com/alexbrainman/sspi v0.0.0-20210105120005-909beea2cc74 h1:Kk6a4nehpJ3UuJRqlA3JxYxBZEqCeOmATOvrbT4p9RA=
 github.com/alexbrainman/sspi v0.0.0-20210105120005-909beea2cc74/go.mod h1:cEWa1LVoE5KvSD9ONXsZrj0z6KqySlCCNKHlLzbqAt4=
 github.com/andybalholm/brotli v1.0.5 h1:8uQZIdzKmjc/iuPu7O2ioW48L81FgatrcpfFmiq/cCs=
@@ -286,8 +286,8 @@ github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9dec
 github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
 gitlab.torproject.org/tpo/anti-censorship/pluggable-transports/goptlib v1.5.0 h1:rzdY78Ox2T+VlXcxGxELF+6VyUXlZBhmRqZu5etLm+c=
 gitlab.torproject.org/tpo/anti-censorship/pluggable-transports/goptlib v1.5.0/go.mod h1:70bhd4JKW/+1HLfm+TMrgHJsUHG4coelMWwiVEJ2gAg=
-go.uber.org/mock v0.3.0 h1:3mUxI1No2/60yUYax92Pt8eNOEecx2D3lcXZh2NEZJo=
-go.uber.org/mock v0.3.0/go.mod h1:a6FSlNadKUHUa9IP5Vyt1zh4fC7uAwxMutEAscFbkZc=
+go.uber.org/mock v0.4.0 h1:VcM4ZOtdbR4f6VXfiOpwpVJDL6lCReaZ6mw31wqh7KU=
+go.uber.org/mock v0.4.0/go.mod h1:a6FSlNadKUHUa9IP5Vyt1zh4fC7uAwxMutEAscFbkZc=
 go4.org/mem v0.0.0-20210711025021-927187094b94 h1:OAAkygi2Js191AJP1Ds42MhJRgeofeKGjuoUqNp1QC4=
 go4.org/mem v0.0.0-20210711025021-927187094b94/go.mod h1:reUoABIJ9ikfM5sgtSF3Wushcza7+WeD01VB9Lirh3g=
 golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
@@ -300,8 +300,8 @@ golang.org/x/crypto v0.10.0/go.mod h1:o4eNf7Ede1fv+hwOwZsTHl9EsPFO6q6ZvYR8vYfY45
 golang.org/x/crypto v0.11.0/go.mod h1:xgJhtzW8F9jGdVFWZESrid1U1bjeNy4zgy5cRr/CIio=
 golang.org/x/crypto v0.12.0/go.mod h1:NF0Gs7EO5K4qLn+Ylc+fih8BSTeIjAP05siRnAh98yw=
 golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
-golang.org/x/crypto v0.19.0 h1:ENy+Az/9Y1vSrlrvBSyna3PITt4tiZLf7sgCjZBX7Wo=
-golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
+golang.org/x/crypto v0.22.0 h1:g1v0xeRhjcugydODzvb3mEM9SQ0HGp9s/nh3COQ/C30=
+golang.org/x/crypto v0.22.0/go.mod h1:vr6Su+7cTlO45qkww3VDJlzDn0ctJvRgYbC2NvXHt+M=
 golang.org/x/exp v0.0.0-20221205204356-47842c84f3db h1:D/cFflL63o2KSLJIwjlcIt8PR064j/xsmdEJL/YvY/o=
 golang.org/x/exp v0.0.0-20221205204356-47842c84f3db/go.mod h1:CxIveKay+FTh1D0yPZemJVgC/95VzuuOLq5Qi4xnoYc=
 golang.org/x/lint v0.0.0-20200302205851-738671d3881b/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY=
@@ -333,8 +333,8 @@ golang.org/x/net v0.11.0/go.mod h1:2L/ixqYpgIVXmeoSA/4Lu7BzTG4KIyPIryS4IsOd1oQ=
 golang.org/x/net v0.13.0/go.mod h1:zEVYFnQC7m/vmpQFELhcD1EWkZlX69l4oqgmer6hfKA=
 golang.org/x/net v0.14.0/go.mod h1:PpSgVXXLK0OxS0F31C1/tv6XNguvCrnXIDrFMspZIUI=
 golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
-golang.org/x/net v0.21.0 h1:AQyQV4dYCvJ7vGmJyKki9+PBdyvhkSd8EIx/qb0AYv4=
-golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
+golang.org/x/net v0.24.0 h1:1PcaxkF854Fu3+lvBIx5SYn9wRlBzzcnHZSiaFFAb0w=
+golang.org/x/net v0.24.0/go.mod h1:2Q7sJY5mzlzWjKtYUEXSlBWCdyaioyXzRB2RtU8KVE8=
 golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
 golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
 golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
@@ -380,8 +380,8 @@ golang.org/x/sys v0.9.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.10.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.17.0 h1:25cE3gD+tdBA7lp7QfhuV+rJiE9YXTcS3VG1SqssI/Y=
-golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
+golang.org/x/sys v0.19.0 h1:q5f1RH2jigJ1MoAWp2KTp3gm5zAGFUTarQZ5U386+4o=
+golang.org/x/sys v0.19.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
 golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
 golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
 golang.org/x/term v0.1.0/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
@@ -392,8 +392,8 @@ golang.org/x/term v0.9.0/go.mod h1:M6DEAAIenWoTxdKrOltXcmDY3rSplQUkrvaDU5FcQyo=
 golang.org/x/term v0.10.0/go.mod h1:lpqdcUyK/oCiQxvxVrppt5ggO2KCZ5QblwqPnfZ6d5o=
 golang.org/x/term v0.11.0/go.mod h1:zC9APTIj3jG3FdV/Ons+XE1riIZXG4aZ4GTHiPZJPIU=
 golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU=
-golang.org/x/term v0.17.0 h1:mkTF7LCd6WGJNL3K1Ad7kwxNfYAW6a8a8QqtMblp/4U=
-golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
+golang.org/x/term v0.19.0 h1:+ThwsDv+tYfnJFhF4L8jITxu1tdTWRTZpdsWgEgjL6Q=
+golang.org/x/term v0.19.0/go.mod h1:2CuTdWZ7KHSQwUzKva0cbMg6q2DMI3Mmxp+gKJbskEk=
 golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
 golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
 golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=

+ 53 - 15
psiphon/common/osl/osl.go

@@ -103,7 +103,7 @@ type Scheme struct {
 	// SeedSpecs is the set of different client network activity patterns
 	// that will result in issuing SLOKs. For a given time period, a distinct
 	// SLOK is issued for each SeedSpec.
-	// Duplicate subnets may appear in multiple SeedSpecs.
+	// Duplicate subnets and ASNs may appear in multiple SeedSpecs.
 	SeedSpecs []*SeedSpec
 
 	// SeedSpecThreshold is the threshold scheme for combining SLOKs to
@@ -135,7 +135,7 @@ type Scheme struct {
 	//   SeedPeriodNanoseconds = 100,000,000 = 100 milliseconds
 	//   SeedPeriodKeySplits = [{10, 7}, {60, 5}]
 	//
-	//   In these scheme, up to 3 distinct SLOKs, one per spec, are issued
+	//   In this scheme, up to 3 distinct SLOKs, one per spec, are issued
 	//   every 100 milliseconds.
 	//
 	//   Distinct OSLs are paved for every minute (60 seconds). Each OSL
@@ -156,15 +156,16 @@ type Scheme struct {
 // SeedSpec defines a client traffic pattern that results in a seeded SLOK.
 // For each time period, a unique SLOK is issued to a client that meets the
 // traffic levels specified in Targets. All upstream port forward traffic to
-// UpstreamSubnets is counted towards the targets.
+// UpstreamSubnets and UpstreamASNs are counted towards the targets.
 //
 // ID is a SLOK key derivation component and must be 32 random bytes, base64
-// encoded. UpstreamSubnets is a list of CIDRs. Description is not used; it's
-// for JSON config file comments.
+// encoded. UpstreamSubnets is a list of CIDRs. UpstreamASNs is a list of
+// ASNs. Description is not used; it's for JSON config file comments.
 type SeedSpec struct {
 	Description     string
 	ID              []byte
 	UpstreamSubnets []string
+	UpstreamASNs    []string
 	Targets         TrafficValues
 }
 
@@ -213,7 +214,7 @@ type ClientSeedProgress struct {
 
 // ClientSeedPortForward map a client port forward, which is relaying
 // traffic to a specific upstream address, to all seed state progress
-// counters for SeedSpecs with subnets containing the upstream address.
+// counters for SeedSpecs with subnets and ASNs containing the upstream address.
 // As traffic is relayed through the port forwards, the bytes transferred
 // and duration count towards the progress of these SeedSpecs and
 // associated SLOKs.
@@ -342,6 +343,16 @@ func LoadConfig(configJSON []byte) (*Config, error) {
 			}
 
 			scheme.subnetLookups[index] = subnetLookup
+
+			// Ensure there are no duplicates.
+			ASNs := make(map[string]struct{}, len(seedSpec.UpstreamASNs))
+			for _, ASN := range seedSpec.UpstreamASNs {
+				if _, ok := ASNs[ASN]; ok {
+					return nil, errors.Tracef("invalid upstream ASNs, duplicate ASN: %s", ASN)
+				} else {
+					ASNs[ASN] = struct{}{}
+				}
+			}
 		}
 
 		if !isValidShamirSplit(len(scheme.SeedSpecs), scheme.SeedSpecThreshold) {
@@ -450,13 +461,14 @@ func (state *ClientSeedState) Resume(
 // NewClientSeedPortForward creates a new client port forward
 // traffic progress tracker. Port forward progress reported to the
 // ClientSeedPortForward is added to seed state progress for all
-// seed specs containing upstreamIPAddress in their subnets.
+// seed specs containing upstreamIPAddress in their subnets or ASNs.
 // The return value will be nil when activity for upstreamIPAddress
 // does not count towards any progress.
 // NewClientSeedPortForward may be invoked concurrently by many
 // psiphond port forward establishment goroutines.
 func (state *ClientSeedState) NewClientSeedPortForward(
-	upstreamIPAddress net.IP) *ClientSeedPortForward {
+	upstreamIPAddress net.IP,
+	lookupASN func(net.IP) string) *ClientSeedPortForward {
 
 	// Concurrency: access to ClientSeedState is unsynchronized
 	// but references only read-only fields.
@@ -467,18 +479,46 @@ func (state *ClientSeedState) NewClientSeedPortForward(
 
 	var progressReferences []progressReference
 
-	// Determine which seed spec subnets contain upstreamIPAddress
+	// Determine which seed spec subnets and ASNs contain upstreamIPAddress
 	// and point to the progress for each. When progress is reported,
 	// it is added directly to all of these TrafficValues instances.
-	// Assumes state.progress entries correspond 1-to-1 with
+	// Assumes state.seedProgress entries correspond 1-to-1 with
 	// state.scheme.subnetLookups.
 	// Note: this implementation assumes a small number of schemes and
 	// seed specs. For larger numbers, instead of N SubnetLookups, create
 	// a single SubnetLookup which returns, for a given IP address, all
 	// matching subnets and associated seed specs.
 	for seedProgressIndex, seedProgress := range state.seedProgress {
-		for trafficProgressIndex, subnetLookup := range seedProgress.scheme.subnetLookups {
-			if subnetLookup.ContainsIPAddress(upstreamIPAddress) {
+
+		var upstreamASN string
+		var upstreamASNSet bool
+
+		for trafficProgressIndex, seedSpec := range seedProgress.scheme.SeedSpecs {
+
+			matchesSeedSpec := false
+
+			// First check for subnet match before performing more expensive
+			// check for ASN match.
+			subnetLookup := seedProgress.scheme.subnetLookups[trafficProgressIndex]
+			matchesSeedSpec = subnetLookup.ContainsIPAddress(upstreamIPAddress)
+
+			if !matchesSeedSpec && lookupASN != nil {
+				// No subnet match. Check for ASN match.
+				if len(seedSpec.UpstreamASNs) > 0 {
+					// Lookup ASN on demand and only once.
+					if !upstreamASNSet {
+						upstreamASN = lookupASN(upstreamIPAddress)
+						upstreamASNSet = true
+					}
+					// TODO: use a map for faster lookups when the number of
+					// string values to compare against exceeds a threshold
+					// where benchmarks show maps are faster than looping
+					// through a string slice.
+					matchesSeedSpec = common.Contains(seedSpec.UpstreamASNs, upstreamASN)
+				}
+			}
+
+			if matchesSeedSpec {
 				progressReferences = append(
 					progressReferences,
 					progressReference{
@@ -671,9 +711,7 @@ func (state *ClientSeedState) GetSeedPayload() *SeedPayload {
 	state.issueSLOKs()
 
 	sloks := make([]*SLOK, len(state.payloadSLOKs))
-	for index, slok := range state.payloadSLOKs {
-		sloks[index] = slok
-	}
+	copy(sloks, state.payloadSLOKs)
 
 	return &SeedPayload{
 		SLOKs: sloks,

+ 76 - 13
psiphon/common/osl/osl_test.go

@@ -62,6 +62,7 @@ func TestOSL(t *testing.T) {
           "Description": "spec2",
           "ID" : "qvpIcORLE2Pi5TZmqRtVkEp+OKov0MhfsYPLNV7FYtI=",
           "UpstreamSubnets" : ["192.168.0.0/16", "10.0.0.0/8"],
+          "UpstreamASNs" : ["0000"],
           "Targets" :
           {
               "BytesRead" : 10,
@@ -171,11 +172,16 @@ func TestOSL(t *testing.T) {
 		t.Fatalf("LoadConfig failed: %s", err)
 	}
 
+	portForwardASN := new(string)
+	lookupASN := func(net.IP) string {
+		return *portForwardASN
+	}
+
 	t.Run("ineligible client, sufficient transfer", func(t *testing.T) {
 
 		clientSeedState := config.NewClientSeedState("US", "C5E8D2EDFD093B50D8D65CF59D0263CA", nil)
 
-		seedPortForward := clientSeedState.NewClientSeedPortForward(net.ParseIP("192.168.0.1"))
+		seedPortForward := clientSeedState.NewClientSeedPortForward(net.ParseIP("192.168.0.1"), lookupASN)
 
 		if seedPortForward != nil {
 			t.Fatalf("expected nil client seed port forward")
@@ -195,7 +201,7 @@ func TestOSL(t *testing.T) {
 
 	t.Run("eligible client, insufficient transfer", func(t *testing.T) {
 
-		clientSeedState.NewClientSeedPortForward(net.ParseIP("10.0.0.1")).UpdateProgress(5, 5, 5)
+		clientSeedState.NewClientSeedPortForward(net.ParseIP("10.0.0.1"), lookupASN).UpdateProgress(5, 5, 5)
 
 		if len(clientSeedState.GetSeedPayload().SLOKs) != 0 {
 			t.Fatalf("expected 0 SLOKs, got %d", len(clientSeedState.GetSeedPayload().SLOKs))
@@ -212,18 +218,18 @@ func TestOSL(t *testing.T) {
 
 		rolloverToNextSLOKTime()
 
-		clientSeedState.NewClientSeedPortForward(net.ParseIP("10.0.0.1")).UpdateProgress(5, 5, 5)
+		clientSeedState.NewClientSeedPortForward(net.ParseIP("10.0.0.1"), lookupASN).UpdateProgress(5, 5, 5)
 
 		if len(clientSeedState.GetSeedPayload().SLOKs) != 0 {
 			t.Fatalf("expected 0 SLOKs, got %d", len(clientSeedState.GetSeedPayload().SLOKs))
 		}
 	})
 
-	t.Run("eligible client, sufficient transfer, one port forward", func(t *testing.T) {
+	t.Run("eligible client, sufficient transfer, one port forward, match by ip", func(t *testing.T) {
 
 		rolloverToNextSLOKTime()
 
-		clientSeedPortForward := clientSeedState.NewClientSeedPortForward(net.ParseIP("10.0.0.1"))
+		clientSeedPortForward := clientSeedState.NewClientSeedPortForward(net.ParseIP("10.0.0.1"), lookupASN)
 
 		clientSeedPortForward.UpdateProgress(5, 5, 5)
 
@@ -240,13 +246,19 @@ func TestOSL(t *testing.T) {
 		}
 	})
 
-	t.Run("eligible client, sufficient transfer, multiple port forwards", func(t *testing.T) {
+	t.Run("eligible client, sufficient transfer, one port forward, match by asn", func(t *testing.T) {
 
 		rolloverToNextSLOKTime()
 
-		clientSeedState.NewClientSeedPortForward(net.ParseIP("10.0.0.1")).UpdateProgress(5, 5, 5)
+		*portForwardASN = "0000"
+
+		clientSeedPortForward := clientSeedState.NewClientSeedPortForward(net.ParseIP("11.0.0.1"), lookupASN)
+
+		clientSeedPortForward.UpdateProgress(5, 5, 5)
+
+		clientSeedPortForward.UpdateProgress(5, 5, 5)
 
-		clientSeedState.NewClientSeedPortForward(net.ParseIP("10.0.0.1")).UpdateProgress(5, 5, 5)
+		*portForwardASN = ""
 
 		select {
 		case <-signalIssueSLOKs:
@@ -260,13 +272,44 @@ func TestOSL(t *testing.T) {
 		}
 	})
 
-	t.Run("eligible client, sufficient transfer multiple SLOKs", func(t *testing.T) {
+	t.Run("eligible client, sufficient transfer, one port forward, match by ip and asn", func(t *testing.T) {
+
+		rolloverToNextSLOKTime()
+
+		*portForwardASN = "0000"
+
+		clientSeedPortForward := clientSeedState.NewClientSeedPortForward(net.ParseIP("10.0.0.1"), lookupASN)
+
+		clientSeedPortForward.UpdateProgress(5, 5, 5)
+
+		// Check that progress is not double counted.
+		if len(clientSeedState.GetSeedPayload().SLOKs) != 2 {
+			t.Fatalf("expected 2 SLOKs, got %d", len(clientSeedState.GetSeedPayload().SLOKs))
+		}
+
+		clientSeedPortForward.UpdateProgress(5, 5, 5)
+
+		*portForwardASN = ""
+
+		select {
+		case <-signalIssueSLOKs:
+		default:
+			t.Fatalf("expected issue SLOKs signal")
+		}
+
+		// Expect 3 SLOKS: 1 new, and 2 remaining in payload.
+		if len(clientSeedState.GetSeedPayload().SLOKs) != 3 {
+			t.Fatalf("expected 3 SLOKs, got %d", len(clientSeedState.GetSeedPayload().SLOKs))
+		}
+	})
+
+	t.Run("eligible client, sufficient transfer, multiple port forwards", func(t *testing.T) {
 
 		rolloverToNextSLOKTime()
 
-		clientSeedState.NewClientSeedPortForward(net.ParseIP("192.168.0.1")).UpdateProgress(5, 5, 5)
+		clientSeedState.NewClientSeedPortForward(net.ParseIP("10.0.0.1"), lookupASN).UpdateProgress(5, 5, 5)
 
-		clientSeedState.NewClientSeedPortForward(net.ParseIP("10.0.0.1")).UpdateProgress(5, 5, 5)
+		clientSeedState.NewClientSeedPortForward(net.ParseIP("10.0.0.1"), lookupASN).UpdateProgress(5, 5, 5)
 
 		select {
 		case <-signalIssueSLOKs:
@@ -274,12 +317,32 @@ func TestOSL(t *testing.T) {
 			t.Fatalf("expected issue SLOKs signal")
 		}
 
-		// Expect 4 SLOKS: 2 new, and 2 remaining in payload.
+		// Expect 4 SLOKS: 1 new, and 3 remaining in payload.
 		if len(clientSeedState.GetSeedPayload().SLOKs) != 4 {
 			t.Fatalf("expected 4 SLOKs, got %d", len(clientSeedState.GetSeedPayload().SLOKs))
 		}
 	})
 
+	t.Run("eligible client, sufficient transfer multiple SLOKs", func(t *testing.T) {
+
+		rolloverToNextSLOKTime()
+
+		clientSeedState.NewClientSeedPortForward(net.ParseIP("192.168.0.1"), lookupASN).UpdateProgress(5, 5, 5)
+
+		clientSeedState.NewClientSeedPortForward(net.ParseIP("10.0.0.1"), lookupASN).UpdateProgress(5, 5, 5)
+
+		select {
+		case <-signalIssueSLOKs:
+		default:
+			t.Fatalf("expected issue SLOKs signal")
+		}
+
+		// Expect 6 SLOKS: 2 new, and 4 remaining in payload.
+		if len(clientSeedState.GetSeedPayload().SLOKs) != 6 {
+			t.Fatalf("expected 6 SLOKs, got %d", len(clientSeedState.GetSeedPayload().SLOKs))
+		}
+	})
+
 	t.Run("clear payload", func(t *testing.T) {
 		clientSeedState.ClearSeedPayload()
 
@@ -305,7 +368,7 @@ func TestOSL(t *testing.T) {
 
 		clientSeedState := config.NewClientSeedState("US", "B4A780E67695595FA486E9B900EA7335", nil)
 
-		clientSeedPortForward := clientSeedState.NewClientSeedPortForward(net.ParseIP("192.168.0.1"))
+		clientSeedPortForward := clientSeedState.NewClientSeedPortForward(net.ParseIP("192.168.0.1"), lookupASN)
 
 		clientSeedPortForward.UpdateProgress(10, 10, 10)
 

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

@@ -368,6 +368,7 @@ const (
 	SteeringIPCacheTTL                                 = "SteeringIPCacheTTL"
 	SteeringIPCacheMaxEntries                          = "SteeringIPCacheMaxEntries"
 	SteeringIPProbability                              = "SteeringIPProbability"
+	ServerDiscoveryStrategy                            = "ServerDiscoveryStrategy"
 	InproxyAllowProxy                                  = "InproxyAllowProxy"
 	InproxyAllowClient                                 = "InproxyAllowClient"
 	InproxyAllowDomainFrontedDestinations              = "InproxyAllowDomainFrontedDestinations"
@@ -840,6 +841,8 @@ var defaultParameters = map[string]struct {
 	SteeringIPCacheMaxEntries: {value: 65536, minimum: 0},
 	SteeringIPProbability:     {value: 1.0, minimum: 0.0},
 
+	ServerDiscoveryStrategy: {value: "", flags: serverSideOnly},
+
 	// For inproxy tactics, there is no proxyOnly flag, since Psiphon apps may
 	// run both clients and inproxy proxies.
 	//

+ 1 - 1
psiphon/remoteServerList_test.go

@@ -228,7 +228,7 @@ func testObfuscatedRemoteServerLists(t *testing.T, omitMD5Sums bool) {
 	}
 
 	seedState := oslConfig.NewClientSeedState("", propagationChannelID, nil)
-	seedPortForward := seedState.NewClientSeedPortForward(net.ParseIP("0.0.0.0"))
+	seedPortForward := seedState.NewClientSeedPortForward(net.ParseIP("0.0.0.0"), nil)
 	seedPortForward.UpdateProgress(1, 1, 1)
 	payload := seedState.GetSeedPayload()
 	if len(payload.SLOKs) != 1 {

+ 1 - 19
psiphon/server/api.go

@@ -20,8 +20,6 @@
 package server
 
 import (
-	"crypto/hmac"
-	"crypto/sha256"
 	"encoding/base64"
 	"encoding/json"
 	"net"
@@ -363,8 +361,7 @@ func handshakeAPIRequestHandler(
 			return nil, errors.TraceNew("invalid client IP")
 		}
 
-		encodedServerList = db.DiscoverServers(
-			calculateDiscoveryValue(support.Config.DiscoveryValueHMACKey, IP))
+		encodedServerList = support.discovery.DiscoverServers(clientIP)
 	}
 
 	// When the client indicates that it used an out-of-date server entry for
@@ -560,21 +557,6 @@ func doHandshakeInproxyBrokerRelay(
 	return "", nil, errors.Tracef("exceeded %d relay round trips", inproxy.MaxRelayRoundTrips)
 }
 
-// calculateDiscoveryValue derives a value from the client IP address to be
-// used as input in the server discovery algorithm.
-// See https://github.com/Psiphon-Inc/psiphon-automation/tree/master/Automation/psi_ops_discovery.py
-// for full details.
-func calculateDiscoveryValue(discoveryValueHMACKey string, ipAddress net.IP) int {
-	// From: psi_ops_discovery.calculate_ip_address_strategy_value:
-	//     # Mix bits from all octets of the client IP address to determine the
-	//     # bucket. An HMAC is used to prevent pre-calculation of buckets for IPs.
-	//     return ord(hmac.new(HMAC_KEY, ip_address, hashlib.sha256).digest()[0])
-	// TODO: use 3-octet algorithm?
-	hash := hmac.New(sha256.New, []byte(discoveryValueHMACKey))
-	hash.Write([]byte(ipAddress.String()))
-	return int(hash.Sum(nil)[0])
-}
-
 // uniqueUserParams are the connected request parameters which are logged for
 // unique_user events.
 var uniqueUserParams = append(

+ 166 - 0
psiphon/server/discovery.go

@@ -0,0 +1,166 @@
+/*
+ * Copyright (c) 2024, 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 server
+
+import (
+	"net"
+	"sync"
+
+	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common/errors"
+	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common/parameters"
+	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/server/discovery"
+)
+
+const (
+	DISCOVERY_STRATEGY_CLASSIC    = "classic"
+	DISCOVERY_STRATEGY_CONSISTENT = "consistent"
+)
+
+// Discovery handles the discovery step of the "handshake" API request. It's
+// safe for concurrent usage.
+type Discovery struct {
+	support         *SupportServices
+	currentStrategy string
+	discovery       *discovery.Discovery
+
+	sync.RWMutex
+}
+
+func makeDiscovery(support *SupportServices) *Discovery {
+	return &Discovery{
+		support: support,
+	}
+}
+
+// Start starts discovery.
+func (d *Discovery) Start() error {
+
+	err := d.reload(false)
+	if err != nil {
+		return errors.Trace(err)
+	}
+
+	return nil
+}
+
+// reload reinitializes the underlying discovery component. If reloadedTactics
+// is set and the target discovery strategy has not changed, then the
+// underlying discovery component is not reinitialized.
+func (d *Discovery) reload(reloadedTactics bool) error {
+
+	// Determine which discovery strategy to use. Assumes no GeoIP targeting
+	// for the ServerDiscoveryStrategy tactic.
+
+	p, err := d.support.ServerTacticsParametersCache.Get(NewGeoIPData())
+	if err != nil {
+		return errors.Trace(err)
+	}
+
+	strategy := ""
+	if !p.IsNil() {
+		strategy = p.String(parameters.ServerDiscoveryStrategy)
+	}
+	if strategy == "" {
+		// No tactics are configured; default to consistent discovery.
+		strategy = DISCOVERY_STRATEGY_CONSISTENT
+	}
+
+	// Do not reinitialize underlying discovery component if only tactics have
+	// been reloaded and the discovery strategy has not changed.
+	if reloadedTactics && d.support.discovery.currentStrategy == strategy {
+		return nil
+	}
+
+	// Initialize new discovery strategy.
+	// TODO: do not reinitialize discovery if the discovery strategy and
+	// discovery servers have not changed.
+	var discoveryStrategy discovery.DiscoveryStrategy
+	if strategy == DISCOVERY_STRATEGY_CONSISTENT {
+		discoveryStrategy, err = discovery.NewConsistentHashingDiscovery()
+		if err != nil {
+			return errors.Trace(err)
+		}
+	} else if strategy == DISCOVERY_STRATEGY_CLASSIC {
+		discoveryStrategy, err = discovery.NewClassicDiscovery(
+			d.support.Config.DiscoveryValueHMACKey)
+		if err != nil {
+			return errors.Trace(err)
+		}
+	} else {
+		return errors.Tracef("unknown strategy %s", strategy)
+	}
+
+	// Initialize and set underlying discovery component. Replaces old
+	// component if discovery is already initialized.
+
+	oldDiscovery := d.discovery
+
+	discovery := discovery.MakeDiscovery(
+		d.support.PsinetDatabase.GetDiscoveryServers(),
+		discoveryStrategy)
+
+	discovery.Start()
+
+	d.Lock()
+
+	d.discovery = discovery
+	d.currentStrategy = strategy
+
+	d.Unlock()
+
+	// Ensure resources used by previous underlying discovery component are
+	// cleaned up.
+	// Note: a more efficient impementation would not recreate the underlying
+	// discovery instance if the discovery strategy has not changed, but
+	// instead would update the underlying set of discovery servers if the set
+	// of discovery servers has changed.
+	if oldDiscovery != nil {
+		oldDiscovery.Stop()
+	}
+
+	log.WithTraceFields(
+		LogFields{"discovery_strategy": strategy}).Infof("reloaded discovery")
+
+	return nil
+}
+
+// Stop stops discovery and cleans up underlying resources.
+func (d *Discovery) Stop() {
+	d.discovery.Stop()
+}
+
+// DiscoverServers selects new encoded server entries to be "discovered" by
+// the client, using the client's IP address as the input into the discovery
+// algorithm.
+func (d *Discovery) DiscoverServers(clientIP net.IP) []string {
+
+	d.RLock()
+	defer d.RUnlock()
+
+	servers := d.discovery.SelectServers(clientIP)
+
+	encodedServerEntries := make([]string, 0)
+
+	for _, server := range servers {
+		encodedServerEntries = append(encodedServerEntries, server.EncodedServerEntry)
+	}
+
+	return encodedServerEntries
+}

+ 182 - 0
psiphon/server/discovery/classic.go

@@ -0,0 +1,182 @@
+/*
+ * Copyright (c) 2024, 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 discovery
+
+import (
+	"crypto/hmac"
+	"crypto/sha256"
+	"math"
+	"net"
+	"sync"
+	"time"
+
+	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/server/psinet"
+)
+
+type classicDiscovery struct {
+	clk                   clock
+	buckets               [][]*psinet.DiscoveryServer
+	discoveryValueHMACKey string
+
+	sync.RWMutex
+}
+
+func NewClassicDiscovery(discoveryValueHMACKey string) (*classicDiscovery, error) {
+	return newClassicDiscovery(discoveryValueHMACKey, realClock{})
+}
+
+func newClassicDiscovery(discoveryValueHMACKey string, clk clock) (*classicDiscovery, error) {
+	return &classicDiscovery{
+		clk:                   clk,
+		discoveryValueHMACKey: discoveryValueHMACKey,
+	}, nil
+}
+
+func (c *classicDiscovery) serversChanged(servers []*psinet.DiscoveryServer) {
+
+	var buckets [][]*psinet.DiscoveryServer
+	if len(servers) != 0 {
+		// Divide servers into buckets. The bucket count is chosen such that the number
+		// of buckets and the number of items in each bucket are close (using sqrt).
+		// IP address selects the bucket, time selects the item in the bucket.
+		bucketCount := calculateBucketCount(len(servers))
+		buckets = bucketizeServerList(servers, bucketCount)
+	}
+	c.RWMutex.Lock()
+	c.buckets = buckets
+	c.RWMutex.Unlock()
+}
+
+func calculateDiscoveryValue(discoveryValueHMACKey string, ipAddress net.IP) int {
+	// From: psi_ops_discovery.calculate_ip_address_strategy_value:
+	//     # Mix bits from all octets of the client IP address to determine the
+	//     # bucket. An HMAC is used to prevent pre-calculation of buckets for IPs.
+	//     return ord(hmac.new(HMAC_KEY, ip_address, hashlib.sha256).digest()[0])
+	// TODO: use 3-octet algorithm?
+	hash := hmac.New(sha256.New, []byte(discoveryValueHMACKey))
+	hash.Write([]byte(ipAddress.String()))
+	return int(hash.Sum(nil)[0])
+}
+
+func (c *classicDiscovery) selectServers(clientIP net.IP) []*psinet.DiscoveryServer {
+	discoveryValue := calculateDiscoveryValue(c.discoveryValueHMACKey, clientIP)
+	return c.discoverServers(discoveryValue)
+}
+
+// discoverServers selects new encoded server entries to be "discovered" by
+// the client, using the discoveryValue -- a function of the client's IP
+// address -- as the input into the discovery algorithm.
+func (c *classicDiscovery) discoverServers(discoveryValue int) []*psinet.DiscoveryServer {
+
+	discoveryDate := c.clk.Now().UTC()
+
+	c.RWMutex.RLock()
+	buckets := c.buckets
+	c.RWMutex.RUnlock()
+
+	if len(buckets) == 0 {
+		return nil
+	}
+
+	timeInSeconds := int(discoveryDate.Unix())
+	servers := selectServers(buckets, timeInSeconds, discoveryValue, discoveryDate)
+
+	return servers
+}
+
+// Combine client IP address and time-of-day strategies to give out different
+// discovery servers to different clients. The aim is to achieve defense against
+// enumerability. We also want to achieve a degree of load balancing clients
+// and these strategies are expected to have reasonably random distribution,
+// even for a cluster of users coming from the same network.
+//
+// We only select one server: multiple results makes enumeration easier; the
+// strategies have a built-in load balancing effect; and date range discoverability
+// means a client will actually learn more servers later even if they happen to
+// always pick the same result at this point.
+//
+// This is a blended strategy: as long as there are enough servers to pick from,
+// both aspects determine which server is selected. IP address is given the
+// priority: if there are only a couple of servers, for example, IP address alone
+// determines the outcome.
+func selectServers(
+	buckets [][]*psinet.DiscoveryServer,
+	timeInSeconds,
+	discoveryValue int,
+	discoveryDate time.Time) []*psinet.DiscoveryServer {
+
+	TIME_GRANULARITY := 3600
+
+	// Time truncated to an hour
+	timeStrategyValue := timeInSeconds / TIME_GRANULARITY
+
+	// NOTE: this code assumes that the range of possible timeStrategyValues
+	// and discoveryValues are sufficient to index to all bucket items.
+
+	if len(buckets) == 0 {
+		return nil
+	}
+
+	bucket := buckets[discoveryValue%len(buckets)]
+
+	if len(bucket) == 0 {
+		return nil
+	}
+	server := bucket[timeStrategyValue%len(bucket)]
+
+	// Double check that server is discoverable at this time.
+	if discoveryDate.Before(server.DiscoveryDateRange[0]) ||
+		!discoveryDate.Before(server.DiscoveryDateRange[1]) {
+		return nil
+	}
+
+	serverList := make([]*psinet.DiscoveryServer, 1)
+	serverList[0] = server
+
+	return serverList
+}
+
+// Number of buckets such that first strategy picks among about the same number
+// of choices as the second strategy. Gives an edge to the "outer" strategy.
+func calculateBucketCount(length int) int {
+	return int(math.Ceil(math.Sqrt(float64(length))))
+}
+
+// bucketizeServerList creates nearly equal sized slices of the input list.
+func bucketizeServerList(servers []*psinet.DiscoveryServer, bucketCount int) [][]*psinet.DiscoveryServer {
+
+	// This code creates the same partitions as legacy servers:
+	// https://github.com/Psiphon-Inc/psiphon-automation/blob/685f91a85bcdb33a75a200d936eadcb0686eadd7/Automation/psi_ops_discovery.py
+	//
+	// Both use the same algorithm from:
+	// http://stackoverflow.com/questions/2659900/python-slicing-a-list-into-n-nearly-equal-length-partitions
+
+	buckets := make([][]*psinet.DiscoveryServer, bucketCount)
+
+	division := float64(len(servers)) / float64(bucketCount)
+
+	for i := 0; i < bucketCount; i++ {
+		start := int((division * float64(i)) + 0.5)
+		end := int((division * (float64(i) + 1)) + 0.5)
+		buckets[i] = servers[start:end]
+	}
+
+	return buckets
+}

+ 143 - 0
psiphon/server/discovery/classic_test.go

@@ -0,0 +1,143 @@
+/*
+ * Copyright (c) 2024, 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 discovery
+
+import (
+	"strconv"
+	"testing"
+	"time"
+
+	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/server/psinet"
+)
+
+func TestDiscoveryBuckets(t *testing.T) {
+
+	checkBuckets := func(buckets [][]*psinet.DiscoveryServer, expectedServerEntries [][]int) {
+		if len(buckets) != len(expectedServerEntries) {
+			t.Errorf(
+				"unexpected bucket count: got %d expected %d",
+				len(buckets), len(expectedServerEntries))
+			return
+		}
+		for i := 0; i < len(buckets); i++ {
+			if len(buckets[i]) != len(expectedServerEntries[i]) {
+				t.Errorf(
+					"unexpected bucket %d size: got %d expected %d",
+					i, len(buckets[i]), len(expectedServerEntries[i]))
+				return
+			}
+			for j := 0; j < len(buckets[i]); j++ {
+				expectedServerEntry := strconv.Itoa(expectedServerEntries[i][j])
+				if buckets[i][j].EncodedServerEntry != expectedServerEntry {
+					t.Errorf(
+						"unexpected bucket %d item %d: got %s expected %s",
+						i, j, buckets[i][j].EncodedServerEntry, expectedServerEntry)
+					return
+				}
+			}
+		}
+	}
+
+	// Partition test cases from:
+	// http://stackoverflow.com/questions/2659900/python-slicing-a-list-into-n-nearly-equal-length-partitions
+
+	servers := make([]*psinet.DiscoveryServer, 0)
+	for i := 0; i < 105; i++ {
+		servers = append(servers, &psinet.DiscoveryServer{
+			EncodedServerEntry: strconv.Itoa(i),
+			DiscoveryDateRange: []time.Time{time.Time{}, time.Now()},
+		})
+	}
+
+	t.Run("5 servers, 5 buckets", func(t *testing.T) {
+		checkBuckets(
+			bucketizeServerList(servers[0:5], 5),
+			[][]int{{0}, {1}, {2}, {3}, {4}})
+	})
+
+	t.Run("5 servers, 2 buckets", func(t *testing.T) {
+		checkBuckets(
+			bucketizeServerList(servers[0:5], 2),
+			[][]int{{0, 1, 2}, {3, 4}})
+	})
+
+	t.Run("5 servers, 3 buckets", func(t *testing.T) {
+		checkBuckets(
+			bucketizeServerList(servers[0:5], 3),
+			[][]int{{0, 1}, {2}, {3, 4}})
+	})
+
+	t.Run("105 servers, 10 buckets", func(t *testing.T) {
+		checkBuckets(
+			bucketizeServerList(servers, 10),
+			[][]int{
+				{0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10},
+				{11, 12, 13, 14, 15, 16, 17, 18, 19, 20},
+				{21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31},
+				{32, 33, 34, 35, 36, 37, 38, 39, 40, 41},
+				{42, 43, 44, 45, 46, 47, 48, 49, 50, 51, 52},
+				{53, 54, 55, 56, 57, 58, 59, 60, 61, 62},
+				{63, 64, 65, 66, 67, 68, 69, 70, 71, 72, 73},
+				{74, 75, 76, 77, 78, 79, 80, 81, 82, 83},
+				{84, 85, 86, 87, 88, 89, 90, 91, 92, 93, 94},
+				{95, 96, 97, 98, 99, 100, 101, 102, 103, 104},
+			})
+	})
+
+	t.Run("repeatedly discover with fixed IP address", func(t *testing.T) {
+
+		// For a IP address values, only one bucket should be used; with enough
+		// iterations, all and only the items in a single bucket should be discovered.
+
+		discoveredServers := make(map[string]bool)
+
+		// discoveryValue is derived from the client's IP address and indexes the bucket;
+		// a value of 0 always maps to the first bucket.
+		discoveryValue := 0
+
+		for i := 0; i < 1000; i++ {
+
+			buckets := bucketizeServerList(servers, calculateBucketCount(len(servers)))
+
+			for _, server := range selectServers(buckets, i*int(time.Hour/time.Second), discoveryValue, time.Time{}) {
+				discoveredServers[server.EncodedServerEntry] = true
+			}
+		}
+
+		bucketCount := calculateBucketCount(len(servers))
+
+		buckets := bucketizeServerList(servers, bucketCount)
+
+		if len(buckets[0]) != len(discoveredServers) {
+			t.Errorf(
+				"unexpected discovered server count: got %d expected %d",
+				len(discoveredServers), len(buckets[0]))
+			return
+		}
+
+		for _, bucketServer := range buckets[0] {
+			if _, ok := discoveredServers[bucketServer.EncodedServerEntry]; !ok {
+				t.Errorf("unexpected missing discovery server: %s", bucketServer.EncodedServerEntry)
+				return
+			}
+		}
+	})
+
+}

+ 114 - 0
psiphon/server/discovery/consistent.go

@@ -0,0 +1,114 @@
+/*
+ * Copyright (c) 2024, 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 discovery
+
+import (
+	"net"
+	"sync"
+
+	"github.com/Psiphon-Labs/consistent"
+	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/server/psinet"
+	"github.com/cespare/xxhash"
+)
+
+type hasher struct{}
+
+// consistent.Hasher implementation.
+func (h hasher) Sum64(data []byte) uint64 {
+	return xxhash.Sum64(data)
+}
+
+type consistentHashingDiscovery struct {
+	clk    clock
+	config *consistent.Config
+	ring   *consistent.Consistent
+
+	sync.RWMutex
+}
+
+func NewConsistentHashingDiscovery() (*consistentHashingDiscovery, error) {
+	return newConsistentHashingDiscovery(realClock{})
+}
+
+func newConsistentHashingDiscovery(clk clock) (*consistentHashingDiscovery, error) {
+	return &consistentHashingDiscovery{
+		clk: clk,
+		config: &consistent.Config{
+			PartitionCount:    0, // set in serversChanged
+			ReplicationFactor: 1, // ensure all servers are discoverable
+			Load:              1, // ensure all servers are discoverable
+			Hasher:            hasher{},
+		},
+	}, nil
+}
+
+func (c *consistentHashingDiscovery) serversChanged(newServers []*psinet.DiscoveryServer) {
+	if len(newServers) == 0 {
+		c.RWMutex.Lock()
+		c.ring = nil
+		c.RWMutex.Unlock()
+	} else {
+
+		members := make([]consistent.Member, len(newServers))
+		for i, server := range newServers {
+			members[i] = server
+		}
+
+		// Note: requires full reinitialization because we cannot change
+		// PartitionCount on the fly. Add/Remove do not update PartitionCount
+		// and updating ParitionCount is required to ensure that there is not
+		// a panic in the Psiphon-Labs/consistent package and that all servers
+		// are discoverable.
+		c.config.PartitionCount = len(newServers)
+
+		c.RWMutex.Lock()
+		c.ring = consistent.New(members, *c.config)
+		c.RWMutex.Unlock()
+	}
+}
+
+func (c *consistentHashingDiscovery) selectServers(clientIP net.IP) []*psinet.DiscoveryServer {
+
+	c.RWMutex.RLock()
+	defer c.RWMutex.RUnlock()
+
+	if c.ring == nil {
+		// No discoverable servers.
+		return nil
+	}
+
+	member := c.ring.LocateKey(clientIP)
+	if member == nil {
+		// Should never happen.
+		return nil
+	}
+
+	server := member.(*psinet.DiscoveryServer)
+
+	discoveryDate := c.clk.Now()
+
+	// Double check that server is discoverable at this time.
+	if discoveryDate.Before(server.DiscoveryDateRange[0]) ||
+		!discoveryDate.Before(server.DiscoveryDateRange[1]) {
+		return nil
+	}
+
+	return []*psinet.DiscoveryServer{server}
+}

+ 67 - 0
psiphon/server/discovery/consistent_test.go

@@ -0,0 +1,67 @@
+/*
+ * Copyright (c) 2024, 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 discovery
+
+import (
+	"strconv"
+	"testing"
+	"time"
+
+	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/server/psinet"
+)
+
+func TestConsistentHashingDiscovery(t *testing.T) {
+
+	serverIPs, err := nRandomIPs(100)
+	if err != nil {
+		t.Fatalf("nRandomIPs failed %s", err)
+	}
+
+	servers := make([]*psinet.DiscoveryServer, len(serverIPs))
+	for i := 0; i < len(servers); i++ {
+		servers[i] = newDiscoveryServer(strconv.Itoa(i), []time.Time{{}, time.Now().Add(1 * time.Hour)})
+	}
+
+	c, err := NewConsistentHashingDiscovery()
+	if err != nil {
+		t.Fatalf("newConsistentHashingDiscovery failed %s", err)
+	}
+	c.serversChanged(servers)
+
+	// For a single IP address value, only one server in a set of discovery
+	// servers should be discoverable.
+
+	discoveredServers := make(map[string]bool)
+
+	clientIP, err := randomIP()
+	if err != nil {
+		t.Fatalf("randomIP failed %s", err)
+	}
+
+	for i := 0; i < 1000; i++ {
+		for _, server := range c.selectServers(clientIP) {
+			discoveredServers[server.EncodedServerEntry] = true
+		}
+	}
+
+	if len(discoveredServers) != 1 {
+		t.Fatalf("expected to discover 1 server but discovered %d", len(discoveredServers))
+	}
+}

+ 254 - 0
psiphon/server/discovery/discovery.go

@@ -0,0 +1,254 @@
+/*
+ * Copyright (c) 2024, 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 discovery implements the Psiphon discovery algorithms.
+package discovery
+
+import (
+	"context"
+	"net"
+	"sync"
+	"time"
+
+	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/server/psinet"
+)
+
+// clock is an interface of functions required by discovery that exist in
+// the time package in the Go standard library, which enables using
+// implementations in tests that do not rely on the monotonic clock or wall
+// clock.
+type clock interface {
+	Now() time.Time
+	Until(t time.Time) time.Duration
+	After(d time.Duration) <-chan time.Time
+	NewTimer(d time.Duration) timer
+}
+
+// realClock implements clock using the time package in the Go standard library.
+type realClock struct{}
+
+func (realClock) Now() time.Time { return time.Now() }
+
+func (realClock) Until(t time.Time) time.Duration { return time.Until(t) }
+
+func (realClock) After(d time.Duration) <-chan time.Time { return time.After(d) }
+
+func (realClock) NewTimer(d time.Duration) timer { return &realTimer{t: time.NewTimer(d)} }
+
+// timer is an interface matching what Timer in the time package provides in
+// the Go standard library, which enables using implementations in tests that
+// do not rely on the monotonic clock or wall clock.
+type timer interface {
+	C() <-chan time.Time
+	Stop() bool
+	Reset(d time.Duration) bool
+}
+
+// realTimer implements timer using the time package in the Go standard library.
+type realTimer struct {
+	t *time.Timer
+}
+
+func (t *realTimer) C() <-chan time.Time {
+	return t.t.C
+}
+
+func (t *realTimer) Stop() bool {
+	return t.t.Stop()
+}
+
+func (t *realTimer) Reset(d time.Duration) bool {
+	return t.t.Reset(d)
+}
+
+// DiscoveryStrategy represents a discovery algorithm that selects server
+// entries to be "discovered" by a client. Implementations must be safe for
+// concurrent usage.
+type DiscoveryStrategy interface {
+	// selectServers selects discovery servers to give out to the client based
+	// on its IP address and, possibly, other strategies that are internal to
+	// the discovery strategy implementation.
+	selectServers(clientIP net.IP) []*psinet.DiscoveryServer
+	// serversChanged is called with the set of currently discoverable servers
+	// whever that set changes. The discovery strategy implementation must
+	// replace its set of discoverable servers with these servers.
+	serversChanged(servers []*psinet.DiscoveryServer)
+}
+
+// Discovery is the combination of a discovery strategy with a set of discovery
+// servers. It's safe for concurrent usage.
+type Discovery struct {
+	clk        clock
+	all        []*psinet.DiscoveryServer
+	strategy   DiscoveryStrategy
+	cancelFunc context.CancelFunc
+	wg         *sync.WaitGroup
+}
+
+// MakeDiscovery creates a new Discovery instance, which uses the specified
+// strategy with the given discovery servers.
+func MakeDiscovery(
+	servers []*psinet.DiscoveryServer,
+	strategy DiscoveryStrategy) *Discovery {
+
+	return makeDiscovery(realClock{}, servers, strategy)
+}
+
+func makeDiscovery(
+	clk clock,
+	servers []*psinet.DiscoveryServer,
+	strategy DiscoveryStrategy) *Discovery {
+
+	d := Discovery{
+		clk:      clk,
+		all:      servers,
+		strategy: strategy,
+		wg:       new(sync.WaitGroup),
+	}
+
+	return &d
+}
+
+// Start starts discovery. Servers are discoverable when the current time
+// falls within their discovery date range, i.e. DiscoveryDateRange[0] <=
+// clk.Now() < DiscoveryDateRange[1].
+func (d *Discovery) Start() {
+
+	current, nextUpdate := discoverableServers(d.all, d.clk)
+
+	d.strategy.serversChanged(current)
+
+	ctx, cancelFunc := context.WithCancel(context.Background())
+	d.cancelFunc = cancelFunc
+	d.wg.Add(1)
+
+	// Update the set of discovery servers used by the chosen discovery
+	// algorithm, and therefore discoverable with SelectServers, everytime a
+	// server enters, or exits, its discovery date range.
+	go func() {
+		for ctx.Err() == nil {
+			// Wait until the next time a server enters, or exits, its
+			// discovery date range.
+			//
+			// Warning: NewTimer uses the monotonic clock but discovery uses
+			// the wall clock. If there is wall clock drift, then it is
+			// possible that the wall clock surpasses nextUpdate or, more
+			// generally, by the wall clock time the set of discoverable
+			// servers should change before the timer fires. This scenario is
+			// not handled. One solution would be to periodically check if set
+			// of discoverable servers has changed in conjunction with using a
+			// timer.
+			t := d.clk.NewTimer(d.clk.Until(nextUpdate))
+
+			select {
+			case <-t.C():
+			case <-ctx.Done():
+				t.Stop()
+				continue
+			}
+			t.Stop()
+
+			// Note: servers with a discovery date range in the past are not
+			// removed from d.all in case the wall clock has drifted;
+			// otherwise, we risk removing them prematurely.
+			servers, nextUpdate := discoverableServers(d.all, d.clk)
+
+			// Update the set of discoverable servers.
+			d.strategy.serversChanged(servers)
+
+			if nextUpdate == (time.Time{}) {
+				// The discovery date range of all candidate discovery servers
+				// are in the past. No more serversChanged calls will be made
+				// to DiscoveryStrategy.
+				//
+				// Warning: at this point if the wall clock has drifted but
+				// will correct itself in the future such that the set of
+				// discoverable servers changes, then serversChanged will
+				// not be called on the discovery strategies with the new set
+				// of discoverable servers. One workaround for this scenario
+				// would be to periodically check if set of discoverable
+				// servers has changed after this point and restart this loop
+				// if they have.
+				break
+			}
+		}
+		d.wg.Done()
+	}()
+}
+
+// Stop stops discovery and cleans up underlying resources. Stop should be
+// invoked as soon as Discovery is no longer needed. Discovery should not be
+// used after this because the set of discoverable servers will no longer be
+// updated, so it may contain servers that are no longer discoverable and
+// exclude servers that are.
+func (d *Discovery) Stop() {
+	d.cancelFunc()
+	d.wg.Wait()
+}
+
+// SelectServers selects new server entries to be "discovered" by the client,
+// using the client's IP address as the input into the configured discovery
+// algorithm.
+func (d *Discovery) SelectServers(clientIP net.IP) []*psinet.DiscoveryServer {
+	return d.strategy.selectServers(clientIP)
+}
+
+// discoverableServers returns all servers in discoveryServers that are currently
+// eligible for discovery along with the next time that a server in
+// discoveryServers will enter, or exit, its discovery date range.
+func discoverableServers(
+	discoveryServers []*psinet.DiscoveryServer,
+	clk clock) (discoverableServers []*psinet.DiscoveryServer, nextUpdate time.Time) {
+
+	now := clk.Now().UTC()
+	discoverableServers = make([]*psinet.DiscoveryServer, 0)
+
+	var nextServerAdd time.Time
+	var nextServerRemove time.Time
+
+	for _, server := range discoveryServers {
+		if len(server.DiscoveryDateRange) == 2 {
+			if now.Before(server.DiscoveryDateRange[0]) {
+				// Next server that will enter its discovery date range.
+				if nextServerAdd == (time.Time{}) || server.DiscoveryDateRange[0].Before(nextServerAdd) {
+					nextServerAdd = server.DiscoveryDateRange[0]
+				}
+			} else if now.Before(server.DiscoveryDateRange[1]) {
+				discoverableServers = append(discoverableServers, server)
+
+				// Next server that will exit its discovery date range.
+				if nextServerRemove == (time.Time{}) || server.DiscoveryDateRange[1].Before(nextServerRemove) {
+					nextServerRemove = server.DiscoveryDateRange[1]
+				}
+			}
+		}
+	}
+
+	// The next time the set of servers eligible for discovery changes is
+	// whichever occurs first: the next time a server enters its discovery
+	// discovery date range or the next time a server exits its discovery
+	// date range.
+	nextUpdate = nextServerAdd
+	if nextServerAdd == (time.Time{}) ||
+		(nextServerRemove.Before(nextServerAdd) && nextServerRemove != (time.Time{})) {
+		nextUpdate = nextServerRemove
+	}
+
+	return discoverableServers, nextUpdate
+}

+ 374 - 0
psiphon/server/discovery/discovery_test.go

@@ -0,0 +1,374 @@
+/*
+ * Copyright (c) 2024, 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 discovery
+
+import (
+	"math/rand"
+	"net"
+	"sync"
+	"testing"
+	"time"
+
+	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common"
+	"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/server/psinet"
+)
+
+// Not safe for concurrent use.
+type testClock struct {
+	now    time.Time
+	update chan time.Time
+
+	sync.RWMutex
+}
+
+func NewTestClock(now time.Time) testClock {
+	return testClock{
+		now:    now,
+		update: make(chan time.Time),
+	}
+}
+
+func (clk *testClock) Now() time.Time {
+	clk.RWMutex.RLock()
+	defer clk.RWMutex.RUnlock()
+	return clk.now
+}
+
+func (clk *testClock) Until(t time.Time) time.Duration {
+	clk.RWMutex.RLock()
+	defer clk.RWMutex.RUnlock()
+	return t.Sub(clk.now)
+
+}
+
+func (clk *testClock) After(d time.Duration) <-chan time.Time {
+	t := clk.NewTimer(d)
+	return t.C()
+}
+
+func (clk *testClock) SetNow(now time.Time) {
+	clk.RWMutex.Lock()
+	clk.now = now
+	clk.RWMutex.Unlock()
+	select {
+	case clk.update <- now:
+	default:
+	}
+}
+
+// Not safe for concurrent use.
+func (clk *testClock) NewTimer(d time.Duration) timer {
+
+	clk.RWMutex.RLock()
+	start := clk.now
+	clk.RWMutex.RUnlock()
+
+	c := make(chan time.Time)
+	if d == 0 {
+		close(c)
+	} else {
+		go func() {
+			for {
+				now := <-clk.update
+				if now.Sub(start) >= d {
+					close(c)
+					break
+				}
+			}
+		}()
+	}
+
+	return &testTimer{
+		c: c,
+	}
+}
+
+type testTimer struct {
+	c <-chan time.Time
+}
+
+func (t *testTimer) C() <-chan time.Time {
+	return t.c
+}
+
+func (t *testTimer) Stop() bool {
+	return true
+}
+
+func (t *testTimer) Reset(d time.Duration) bool {
+	return false
+}
+
+type check struct {
+	t      time.Time // time check is performed
+	ips    []string  // server IP addresses expected to be discoverable
+	subset int       // if non-zero, then expect a subset of ips of this size to be discovered
+}
+
+type discoveryTest struct {
+	name                 string
+	newDiscoveryStrategy func(clk clock) (DiscoveryStrategy, error)
+	servers              []*psinet.DiscoveryServer
+	checks               []check
+}
+
+func runDiscoveryTest(tt *discoveryTest, now time.Time) error {
+
+	if len(tt.servers) == 0 {
+		return errors.TraceNew("test requires >=1 discovery servers")
+	}
+
+	clk := NewTestClock(now)
+
+	strategy, err := tt.newDiscoveryStrategy(&clk)
+	if err != nil {
+		return errors.Trace(err)
+	}
+
+	discovery := makeDiscovery(&clk, tt.servers, strategy)
+
+	discovery.Start()
+
+	for _, check := range tt.checks {
+		time.Sleep(1 * time.Second) // let async code complete
+		clk.SetNow(check.t)
+		time.Sleep(1 * time.Second) // let async code complete
+		discovered := discovery.SelectServers(net.IP{})
+		discoveredIPs := make([]string, len(discovered))
+		for i := range discovered {
+			serverEntry, err := protocol.DecodeServerEntry(discovered[i].EncodedServerEntry, "", "")
+			if err != nil {
+				return errors.Trace(err)
+			}
+			discoveredIPs[i] = serverEntry.IpAddress
+		}
+
+		matches := 0
+		for _, ip := range check.ips {
+			if common.Contains(discoveredIPs, ip) {
+				matches++
+			}
+		}
+
+		expectedMatches := len(check.ips)
+		if check.subset != 0 {
+			expectedMatches = check.subset
+		}
+
+		if expectedMatches != matches {
+			return errors.Tracef("expected %d of %s to be discovered at %s but discovered servers are %s", expectedMatches, check.ips, check.t, discoveredIPs)
+		}
+	}
+
+	discovery.Stop()
+
+	return nil
+}
+
+func TestDiscoveryTestClock(t *testing.T) {
+
+	now := time.Now()
+
+	serverIPs, err := nRandomIPs(4)
+	if err != nil {
+		t.Fatalf("nRandomIPs failed %s", err)
+	}
+
+	server1 := newDiscoveryServer(
+		serverIPs[0].String(),
+		[]time.Time{
+			now.Add(-1 * time.Second).UTC(),
+			now.Add(2 * time.Second).UTC(),
+		})
+	server2 := newDiscoveryServer(
+		serverIPs[1].String(),
+		[]time.Time{
+			now.Add(3 * time.Second).UTC(),
+			now.Add(5 * time.Second).UTC(),
+		})
+	server3 := newDiscoveryServer(
+		serverIPs[2].String(),
+		[]time.Time{
+			now.Add(5 * time.Second).UTC(),
+			now.Add(7 * time.Second).UTC(),
+		})
+	server4 := newDiscoveryServer(
+		serverIPs[3].String(),
+		[]time.Time{
+			now.Add(5 * time.Second).UTC(),
+			now.Add(7 * time.Second).UTC(),
+		})
+
+	tests := []discoveryTest{
+		{
+			name: "classic",
+			newDiscoveryStrategy: func(clk clock) (DiscoveryStrategy, error) {
+				return newClassicDiscovery("discoveryValueHMACKey", clk)
+			},
+			servers: []*psinet.DiscoveryServer{
+				server1,
+				server2,
+				server3,
+				server4,
+			},
+			checks: []check{
+				{
+					t:   now.Add(1 * time.Second),
+					ips: []string{server1.IPAddress},
+				},
+				// discovery end date is noninclusive
+				{
+					t:   now.Add(2 * time.Second),
+					ips: []string{},
+				},
+				// discovery start date is inclusive
+				{
+					t:   now.Add(3 * time.Second),
+					ips: []string{server2.IPAddress},
+				},
+				{
+					t:   now.Add(4 * time.Second),
+					ips: []string{server2.IPAddress},
+				},
+				{
+					t:      now.Add(6 * time.Second),
+					ips:    []string{server3.IPAddress, server4.IPAddress},
+					subset: 1,
+				},
+				{
+					t:   now.Add(8 * time.Second),
+					ips: []string{},
+				},
+			},
+		},
+		{
+			name: "consistent",
+			newDiscoveryStrategy: func(clk clock) (DiscoveryStrategy, error) {
+				return newConsistentHashingDiscovery(clk)
+			},
+			servers: []*psinet.DiscoveryServer{
+				server1,
+				server2,
+				server3,
+				server4,
+			},
+			checks: []check{
+				{
+					t:   now.Add(1 * time.Second),
+					ips: []string{server1.IPAddress},
+				},
+				// discovery end date is noninclusive
+				{
+					t:   now.Add(2 * time.Second),
+					ips: []string{},
+				},
+				// discovery start date is inclusive
+				{
+					t:   now.Add(3 * time.Second),
+					ips: []string{server2.IPAddress},
+				},
+				{
+					t:   now.Add(4 * time.Second),
+					ips: []string{server2.IPAddress},
+				},
+				{
+					t:      now.Add(6 * time.Second),
+					ips:    []string{server3.IPAddress, server4.IPAddress},
+					subset: 1,
+				},
+				{
+					t:   now.Add(8 * time.Second),
+					ips: []string{},
+				},
+			},
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+
+			err := runDiscoveryTest(&tt, now)
+			if err != nil {
+				t.Fatalf("runDiscoveryTest failed: %v", err)
+			}
+		})
+	}
+}
+
+func newDiscoveryServer(IPAddress string, discoveryDateRange []time.Time) *psinet.DiscoveryServer {
+
+	encoded, err := protocol.EncodeServerEntry(
+		&protocol.ServerEntry{
+			IpAddress: IPAddress,
+		},
+	)
+	if err != nil {
+		panic(err)
+	}
+
+	return &psinet.DiscoveryServer{
+		EncodedServerEntry: encoded,
+		DiscoveryDateRange: discoveryDateRange,
+		IPAddress:          IPAddress,
+	}
+}
+
+// randomIP returns a random IP address.
+func randomIP() (net.IP, error) {
+
+	r := make([]byte, 4)
+	_, err := rand.Read(r)
+	if err != nil {
+		return nil, errors.Trace(err)
+	}
+
+	return r, nil
+}
+
+// nRandomIPs returns numIPs unique random IPs.
+func nRandomIPs(numIPs int) ([]net.IP, error) {
+
+	ips := make([]net.IP, numIPs)
+	ipsSeen := make(map[string]struct{})
+
+	for i := 0; i < numIPs; i++ {
+
+		for {
+
+			ip, err := randomIP()
+			if err != nil {
+				return nil, errors.Trace(err)
+			}
+
+			if _, ok := ipsSeen[ip.String()]; ok {
+				continue
+			}
+
+			ipsSeen[ip.String()] = struct{}{}
+			ips[i] = ip
+
+			break
+		}
+	}
+
+	return ips, nil
+}

+ 2 - 1
psiphon/server/geoip.go

@@ -218,7 +218,8 @@ func (geoIP *GeoIPService) LookupIP(IP net.IP) GeoIPData {
 
 // LookupISPForIP determines a GeoIPData for a given client IP address. Only
 // ISP, ASN, and ASO fields will be populated. This lookup is faster than a
-// full lookup.
+// full lookup. Benchmarks show this lookup is <= ~1 microsecond against the
+// production geo IP database.
 func (geoIP *GeoIPService) LookupISPForIP(IP net.IP) GeoIPData {
 	return geoIP.lookupIP(IP, true)
 }

+ 51 - 126
psiphon/server/psinet/psinet.go

@@ -20,13 +20,12 @@
 // Package psinet implements psinet database services. The psinet database is a
 // JSON-format file containing information about the Psiphon network, including
 // sponsors, home pages, stats regexes, available upgrades, and other servers for
-// discovery. This package also implements the Psiphon discovery algorithm.
+// discovery.
 package psinet
 
 import (
 	"crypto/md5"
 	"encoding/json"
-	"math"
 	"math/rand"
 	"strconv"
 	"strings"
@@ -34,15 +33,18 @@ import (
 
 	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common"
 	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common/errors"
+	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common/protocol"
 )
 
 const (
 	MAX_DATABASE_AGE_FOR_SERVER_ENTRY_VALIDITY = 48 * time.Hour
 )
 
-// Database serves Psiphon API data requests. It's safe for
-// concurrent usage. The Reload function supports hot reloading
-// of Psiphon network data while the server is running.
+// Database serves Psiphon API data requests. The Reload function supports hot
+// reloading of Psiphon network data while the server is running.
+//
+// All of the methods on Database are thread-safe, but callers must not mutate
+// any returned data. The struct may be safely shared across goroutines.
 type Database struct {
 	common.ReloadableFile
 
@@ -59,6 +61,18 @@ type Database struct {
 type DiscoveryServer struct {
 	DiscoveryDateRange []time.Time `json:"discovery_date_range"`
 	EncodedServerEntry string      `json:"encoded_server_entry"`
+
+	IPAddress string `json:"-"`
+}
+
+// consistent.Member implementation.
+// TODO: move to discovery package. Requires bridging to a new type.
+func (s *DiscoveryServer) String() string {
+	// Other options:
+	// - Tag
+	// - EncodedServerEntry
+	// - ...
+	return s.IPAddress
 }
 
 type Sponsor struct {
@@ -123,6 +137,32 @@ func NewDatabase(filename string) (*Database, error) {
 				sponsor.domainBytesChecksum = checksum[:]
 			}
 
+			// Decode each encoded server entry for its IP address, which is used in
+			// the consistent.Member implementation in the discovery package.
+			//
+			// Also ensure that no servers share the same IP address, which is
+			// a requirement of consistent hashing discovery; otherwise it will
+			// panic in the underlying Psiphon-Labs/consistent package.
+			serverIPToDiagnosticID := make(map[string]string)
+			for i, server := range database.DiscoveryServers {
+
+				serverEntry, err := protocol.DecodeServerEntry(server.EncodedServerEntry, "", "")
+				if err != nil {
+					return errors.Trace(err)
+				}
+				if serverEntry.IpAddress == "" {
+					return errors.Tracef("unexpected empty IP address in server entry for %s ", serverEntry.GetDiagnosticID())
+				}
+
+				if diagnosticID, ok := serverIPToDiagnosticID[serverEntry.IpAddress]; ok {
+					return errors.Tracef("unexpected %s and %s shared the same IP address", diagnosticID, serverEntry.GetDiagnosticID())
+				} else {
+					serverIPToDiagnosticID[serverEntry.IpAddress] = serverEntry.GetDiagnosticID()
+				}
+
+				database.DiscoveryServers[i].IPAddress = serverEntry.IpAddress
+			}
+
 			return nil
 		})
 
@@ -335,127 +375,6 @@ func (db *Database) GetDomainBytesChecksum(sponsorID string) []byte {
 	return sponsor.domainBytesChecksum
 }
 
-// DiscoverServers selects new encoded server entries to be "discovered" by
-// the client, using the discoveryValue -- a function of the client's IP
-// address -- as the input into the discovery algorithm.
-func (db *Database) DiscoverServers(discoveryValue int) []string {
-	db.ReloadableFile.RLock()
-	defer db.ReloadableFile.RUnlock()
-
-	var servers []*DiscoveryServer
-
-	discoveryDate := time.Now().UTC()
-	candidateServers := make([]*DiscoveryServer, 0)
-
-	for _, server := range db.DiscoveryServers {
-		// All servers that are discoverable on this day are eligible for discovery
-		if len(server.DiscoveryDateRange) == 2 &&
-			discoveryDate.After(server.DiscoveryDateRange[0]) &&
-			discoveryDate.Before(server.DiscoveryDateRange[1]) {
-
-			candidateServers = append(candidateServers, server)
-		}
-	}
-
-	timeInSeconds := int(discoveryDate.Unix())
-	servers = selectServers(candidateServers, timeInSeconds, discoveryValue)
-
-	encodedServerEntries := make([]string, 0)
-
-	for _, server := range servers {
-		encodedServerEntries = append(encodedServerEntries, server.EncodedServerEntry)
-	}
-
-	return encodedServerEntries
-}
-
-// Combine client IP address and time-of-day strategies to give out different
-// discovery servers to different clients. The aim is to achieve defense against
-// enumerability. We also want to achieve a degree of load balancing clients
-// and these strategies are expected to have reasonably random distribution,
-// even for a cluster of users coming from the same network.
-//
-// We only select one server: multiple results makes enumeration easier; the
-// strategies have a built-in load balancing effect; and date range discoverability
-// means a client will actually learn more servers later even if they happen to
-// always pick the same result at this point.
-//
-// This is a blended strategy: as long as there are enough servers to pick from,
-// both aspects determine which server is selected. IP address is given the
-// priority: if there are only a couple of servers, for example, IP address alone
-// determines the outcome.
-func selectServers(
-	servers []*DiscoveryServer, timeInSeconds, discoveryValue int) []*DiscoveryServer {
-
-	TIME_GRANULARITY := 3600
-
-	if len(servers) == 0 {
-		return nil
-	}
-
-	// Time truncated to an hour
-	timeStrategyValue := timeInSeconds / TIME_GRANULARITY
-
-	// Divide servers into buckets. The bucket count is chosen such that the number
-	// of buckets and the number of items in each bucket are close (using sqrt).
-	// IP address selects the bucket, time selects the item in the bucket.
-
-	// NOTE: this code assumes that the range of possible timeStrategyValues
-	// and discoveryValues are sufficient to index to all bucket items.
-
-	bucketCount := calculateBucketCount(len(servers))
-
-	buckets := bucketizeServerList(servers, bucketCount)
-
-	if len(buckets) == 0 {
-		return nil
-	}
-
-	bucket := buckets[discoveryValue%len(buckets)]
-
-	if len(bucket) == 0 {
-		return nil
-	}
-
-	server := bucket[timeStrategyValue%len(bucket)]
-
-	serverList := make([]*DiscoveryServer, 1)
-	serverList[0] = server
-
-	return serverList
-}
-
-// Number of buckets such that first strategy picks among about the same number
-// of choices as the second strategy. Gives an edge to the "outer" strategy.
-func calculateBucketCount(length int) int {
-	return int(math.Ceil(math.Sqrt(float64(length))))
-}
-
-// bucketizeServerList creates nearly equal sized slices of the input list.
-func bucketizeServerList(servers []*DiscoveryServer, bucketCount int) [][]*DiscoveryServer {
-
-	// This code creates the same partitions as legacy servers:
-	// https://github.com/Psiphon-Inc/psiphon-automation/blob/685f91a85bcdb33a75a200d936eadcb0686eadd7/Automation/psi_ops_discovery.py
-	//
-	// Both use the same algorithm from:
-	// http://stackoverflow.com/questions/2659900/python-slicing-a-list-into-n-nearly-equal-length-partitions
-
-	// TODO: this partition is constant for fixed Database content, so it could
-	// be done once and cached in the Database ReloadableFile reloadAction.
-
-	buckets := make([][]*DiscoveryServer, bucketCount)
-
-	division := float64(len(servers)) / float64(bucketCount)
-
-	for i := 0; i < bucketCount; i++ {
-		start := int((division * float64(i)) + 0.5)
-		end := int((division * (float64(i) + 1)) + 0.5)
-		buckets[i] = servers[start:end]
-	}
-
-	return buckets
-}
-
 // IsValidServerEntryTag checks if the specified server entry tag is valid.
 func (db *Database) IsValidServerEntryTag(serverEntryTag string) bool {
 	db.ReloadableFile.RLock()
@@ -473,3 +392,9 @@ func (db *Database) IsValidServerEntryTag(serverEntryTag string) bool {
 	// The tag must be in the map and have the value "true".
 	return db.ValidServerEntryTags[serverEntryTag]
 }
+
+func (db *Database) GetDiscoveryServers() []*DiscoveryServer {
+	db.ReloadableFile.RLock()
+	defer db.ReloadableFile.RUnlock()
+	return db.DiscoveryServers
+}

+ 20 - 128
psiphon/server/psinet/psinet_test.go

@@ -25,9 +25,9 @@ import (
 	"io/ioutil"
 	"os"
 	"path/filepath"
-	"strconv"
 	"testing"
-	"time"
+
+	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common/protocol"
 )
 
 func TestDatabase(t *testing.T) {
@@ -38,7 +38,21 @@ func TestDatabase(t *testing.T) {
 	}
 	defer os.RemoveAll(testDataDirName)
 
-	databaseJSON := `
+	server1, err := protocol.EncodeServerEntry(&protocol.ServerEntry{
+		IpAddress: "1",
+	})
+	if err != nil {
+		t.Fatalf("EncodeServerEntry failed: %s\n", err)
+	}
+
+	server2, err := protocol.EncodeServerEntry(&protocol.ServerEntry{
+		IpAddress: "2",
+	})
+	if err != nil {
+		t.Fatalf("EncodeServerEntry failed: %s\n", err)
+	}
+
+	databaseJSON := fmt.Sprintf(`
     {
         "sponsors" : {
             "SPONSOR-ID" : {
@@ -92,16 +106,10 @@ func TestDatabase(t *testing.T) {
         },
 
         "discovery_servers" : [
-            {"discovery_date_range" : ["1900-01-01T00:00:00Z", "2000-01-01T00:00:00Z"], "encoded_server_entry" : "0"},
-            {"discovery_date_range" : ["1900-01-01T00:00:00Z", "2000-01-01T00:00:00Z"], "encoded_server_entry" : "0"},
-            {"discovery_date_range" : ["1900-01-01T00:00:00Z", "2000-01-01T00:00:00Z"], "encoded_server_entry" : "0"},
-            {"discovery_date_range" : ["1900-01-01T00:00:00Z", "2000-01-01T00:00:00Z"], "encoded_server_entry" : "0"},
-            {"discovery_date_range" : ["2000-01-01T00:00:00Z", "2100-01-01T00:00:00Z"], "encoded_server_entry" : "1"},
-            {"discovery_date_range" : ["2000-01-01T00:00:00Z", "2100-01-01T00:00:00Z"], "encoded_server_entry" : "1"},
-            {"discovery_date_range" : ["2000-01-01T00:00:00Z", "2100-01-01T00:00:00Z"], "encoded_server_entry" : "1"},
-            {"discovery_date_range" : ["2000-01-01T00:00:00Z", "2100-01-01T00:00:00Z"], "encoded_server_entry" : "1"}
+            {"discovery_date_range" : ["1900-01-01T00:00:00Z", "2000-01-01T00:00:00Z"], "encoded_server_entry" : "%s"},
+            {"discovery_date_range" : ["2000-01-01T00:00:00Z", "2100-01-01T00:00:00Z"], "encoded_server_entry" : "%s"}
         ]
-    }`
+    }`, server1, server2)
 
 	filename := filepath.Join(testDataDirName, "psinet.json")
 
@@ -214,13 +222,6 @@ func TestDatabase(t *testing.T) {
 		})
 	}
 
-	for i := 0; i < 1000; i++ {
-		encodedServerEntries := db.DiscoverServers(i)
-		if len(encodedServerEntries) != 1 || encodedServerEntries[0] != "1" {
-			t.Fatalf("unexpected discovery server list: %+v", encodedServerEntries)
-		}
-	}
-
 	if !db.IsValidServerEntryTag("SERVER-ENTRY-TAG") {
 		t.Fatalf("unexpected invalid server entry tag")
 	}
@@ -229,112 +230,3 @@ func TestDatabase(t *testing.T) {
 		t.Fatalf("unexpected valid server entry tag")
 	}
 }
-
-func TestDiscoveryBuckets(t *testing.T) {
-
-	checkBuckets := func(buckets [][]*DiscoveryServer, expectedServerEntries [][]int) {
-		if len(buckets) != len(expectedServerEntries) {
-			t.Errorf(
-				"unexpected bucket count: got %d expected %d",
-				len(buckets), len(expectedServerEntries))
-			return
-		}
-		for i := 0; i < len(buckets); i++ {
-			if len(buckets[i]) != len(expectedServerEntries[i]) {
-				t.Errorf(
-					"unexpected bucket %d size: got %d expected %d",
-					i, len(buckets[i]), len(expectedServerEntries[i]))
-				return
-			}
-			for j := 0; j < len(buckets[i]); j++ {
-				expectedServerEntry := strconv.Itoa(expectedServerEntries[i][j])
-				if buckets[i][j].EncodedServerEntry != expectedServerEntry {
-					t.Errorf(
-						"unexpected bucket %d item %d: got %s expected %s",
-						i, j, buckets[i][j].EncodedServerEntry, expectedServerEntry)
-					return
-				}
-			}
-		}
-	}
-
-	// Partition test cases from:
-	// http://stackoverflow.com/questions/2659900/python-slicing-a-list-into-n-nearly-equal-length-partitions
-
-	servers := make([]*DiscoveryServer, 0)
-	for i := 0; i < 105; i++ {
-		servers = append(servers, &DiscoveryServer{EncodedServerEntry: strconv.Itoa(i)})
-	}
-
-	t.Run("5 servers, 5 buckets", func(t *testing.T) {
-		checkBuckets(
-			bucketizeServerList(servers[0:5], 5),
-			[][]int{{0}, {1}, {2}, {3}, {4}})
-	})
-
-	t.Run("5 servers, 2 buckets", func(t *testing.T) {
-		checkBuckets(
-			bucketizeServerList(servers[0:5], 2),
-			[][]int{{0, 1, 2}, {3, 4}})
-	})
-
-	t.Run("5 servers, 3 buckets", func(t *testing.T) {
-		checkBuckets(
-			bucketizeServerList(servers[0:5], 3),
-			[][]int{{0, 1}, {2}, {3, 4}})
-	})
-
-	t.Run("105 servers, 10 buckets", func(t *testing.T) {
-		checkBuckets(
-			bucketizeServerList(servers, 10),
-			[][]int{
-				{0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10},
-				{11, 12, 13, 14, 15, 16, 17, 18, 19, 20},
-				{21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31},
-				{32, 33, 34, 35, 36, 37, 38, 39, 40, 41},
-				{42, 43, 44, 45, 46, 47, 48, 49, 50, 51, 52},
-				{53, 54, 55, 56, 57, 58, 59, 60, 61, 62},
-				{63, 64, 65, 66, 67, 68, 69, 70, 71, 72, 73},
-				{74, 75, 76, 77, 78, 79, 80, 81, 82, 83},
-				{84, 85, 86, 87, 88, 89, 90, 91, 92, 93, 94},
-				{95, 96, 97, 98, 99, 100, 101, 102, 103, 104},
-			})
-	})
-
-	t.Run("repeatedly discover with fixed IP address", func(t *testing.T) {
-
-		// For a IP address values, only one bucket should be used; with enough
-		// iterations, all and only the items in a single bucket should be discovered.
-
-		discoveredServers := make(map[string]bool)
-
-		// discoveryValue is derived from the client's IP address and indexes the bucket;
-		// a value of 0 always maps to the first bucket.
-		discoveryValue := 0
-
-		for i := 0; i < 1000; i++ {
-			for _, server := range selectServers(servers, i*int(time.Hour/time.Second), discoveryValue) {
-				discoveredServers[server.EncodedServerEntry] = true
-			}
-		}
-
-		bucketCount := calculateBucketCount(len(servers))
-
-		buckets := bucketizeServerList(servers, bucketCount)
-
-		if len(buckets[0]) != len(discoveredServers) {
-			t.Errorf(
-				"unexpected discovered server count: got %d expected %d",
-				len(discoveredServers), len(buckets[0]))
-			return
-		}
-
-		for _, bucketServer := range buckets[0] {
-			if _, ok := discoveredServers[bucketServer.EncodedServerEntry]; !ok {
-				t.Errorf("unexpected missing discovery server: %s", bucketServer.EncodedServerEntry)
-				return
-			}
-		}
-	})
-
-}

+ 186 - 14
psiphon/server/server_test.go

@@ -59,6 +59,7 @@ import (
 	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common/tactics"
 	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common/transforms"
 	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common/values"
+	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/server/psinet"
 	lrucache "github.com/cognusion/go-cache-lru"
 	"github.com/miekg/dns"
 	"golang.org/x/net/proxy"
@@ -412,6 +413,19 @@ func TestHotReload(t *testing.T) {
 		})
 }
 
+func TestHotReloadWithTactics(t *testing.T) {
+	runServer(t,
+		&runServerConfig{
+			tunnelProtocol:       "UNFRONTED-MEEK-OSSH",
+			enableSSHAPIRequests: true,
+			doHotReload:          true,
+			requireAuthorization: true,
+			doTunneledWebRequest: true,
+			doTunneledNTPRequest: true,
+			doLogHostProvider:    true,
+		})
+}
+
 func TestDefaultSponsorID(t *testing.T) {
 	runServer(t,
 		&runServerConfig{
@@ -784,6 +798,11 @@ func runServer(t *testing.T, runConfig *runServerConfig) {
 
 	// customize server config
 
+	discoveryServers, err := newDiscoveryServers([]string{"1.1.1.1", "2.2.2.2"})
+	if err != nil {
+		t.Fatalf("newDiscoveryServers failed: %s\n", err)
+	}
+
 	// Initialize prune server entry test cases and associated data to pave into psinet.
 	pruneServerEntryTestCases, psinetValidServerEntryTags, expectedNumPruneNotices :=
 		initializePruneServerEntriesTest(t, runConfig)
@@ -791,7 +810,7 @@ func runServer(t *testing.T, runConfig *runServerConfig) {
 	// Pave psinet with random values to test handshake homepages.
 	psinetFilename := filepath.Join(testDataDirName, "psinet.json")
 	sponsorID, expectedHomepageURL := pavePsinetDatabaseFile(
-		t, psinetFilename, "", runConfig.doDefaultSponsorID, true, psinetValidServerEntryTags)
+		t, psinetFilename, "", runConfig.doDefaultSponsorID, true, psinetValidServerEntryTags, discoveryServers)
 
 	// Pave OSL config for SLOK testing
 	oslConfigFilename := filepath.Join(testDataDirName, "osl_config.json")
@@ -812,6 +831,7 @@ func runServer(t *testing.T, runConfig *runServerConfig) {
 		livenessTestSize)
 
 	var tacticsConfigFilename string
+	var tacticsTunnelProtocol string
 	var inproxyTacticsParametersJSON string
 
 	// Only pave the tactics config when tactics are required. This exercises the
@@ -819,9 +839,10 @@ func runServer(t *testing.T, runConfig *runServerConfig) {
 	if doServerTactics {
 		tacticsConfigFilename = filepath.Join(testDataDirName, "tactics_config.json")
 
-		tacticsTunnelProtocol := runConfig.tunnelProtocol
 		if runConfig.clientTunnelProtocol != "" {
 			tacticsTunnelProtocol = runConfig.clientTunnelProtocol
+		} else {
+			tacticsTunnelProtocol = runConfig.tunnelProtocol
 		}
 
 		if doInproxy {
@@ -841,6 +862,7 @@ func runServer(t *testing.T, runConfig *runServerConfig) {
 			runConfig.doDestinationBytes,
 			runConfig.applyPrefix,
 			runConfig.forceFragmenting,
+			"classic",
 			inproxyTacticsParametersJSON)
 	}
 
@@ -938,6 +960,11 @@ func runServer(t *testing.T, runConfig *runServerConfig) {
 	uniqueUserLog := make(chan map[string]interface{}, 1)
 	domainBytesLog := make(chan map[string]interface{}, 1)
 	serverTunnelLog := make(chan map[string]interface{}, 1)
+	// Max 3 discovery logs:
+	// 1. server startup
+	// 2. hot reload of psinet db (runConfig.doHotReload)
+	// 3. hot reload of server tactics (runConfig.doHotReload && doServerTactics)
+	discoveryLog := make(chan map[string]interface{}, 3)
 
 	setLogCallback(func(log []byte) {
 
@@ -949,6 +976,12 @@ func runServer(t *testing.T, runConfig *runServerConfig) {
 		}
 
 		if logFields["event_name"] == nil {
+			if logFields["discovery_strategy"] != nil {
+				select {
+				case discoveryLog <- logFields:
+				default:
+				}
+			}
 			return
 		}
 
@@ -1043,9 +1076,16 @@ func runServer(t *testing.T, runConfig *runServerConfig) {
 
 	if runConfig.doHotReload {
 
+		// Change discovery servers. Tests that discovery switches over to
+		// these new servers.
+		discoveryServers, err = newDiscoveryServers([]string{"3.3.3.3"})
+		if err != nil {
+			t.Fatalf("newDiscoveryServers failed: %s\n", err)
+		}
+
 		// Pave new config files with different random values.
 		sponsorID, expectedHomepageURL = pavePsinetDatabaseFile(
-			t, psinetFilename, "", runConfig.doDefaultSponsorID, true, psinetValidServerEntryTags)
+			t, psinetFilename, "", runConfig.doDefaultSponsorID, true, psinetValidServerEntryTags, discoveryServers)
 
 		propagationChannelID = paveOSLConfigFile(t, oslConfigFilename)
 
@@ -1059,6 +1099,26 @@ func runServer(t *testing.T, runConfig *runServerConfig) {
 			runConfig.denyTrafficRules,
 			livenessTestSize)
 
+		if doServerTactics {
+			// Pave new tactics file with different discovery strategy. Tests
+			// that discovery switches over to the new strategy.
+			paveTacticsConfigFile(
+				t,
+				tacticsConfigFilename,
+				tacticsRequestPublicKey,
+				tacticsRequestPrivateKey,
+				tacticsRequestObfuscatedKey,
+				tacticsTunnelProtocol,
+				propagationChannelID,
+				livenessTestSize,
+				runConfig.doBurstMonitor,
+				runConfig.doDestinationBytes,
+				runConfig.applyPrefix,
+				runConfig.forceFragmenting,
+				"consistent",
+				inproxyTacticsParametersJSON)
+		}
+
 		p, _ := os.FindProcess(os.Getpid())
 		p.Signal(syscall.SIGUSR1)
 
@@ -1487,12 +1547,7 @@ func runServer(t *testing.T, runConfig *runServerConfig) {
 		// random homepage URLs will change, but this has no effect on the
 		// already connected client.
 		_, _ = pavePsinetDatabaseFile(
-			t, psinetFilename, sponsorID, runConfig.doDefaultSponsorID, false, psinetValidServerEntryTags)
-
-		tacticsTunnelProtocol := runConfig.tunnelProtocol
-		if runConfig.clientTunnelProtocol != "" {
-			tacticsTunnelProtocol = runConfig.clientTunnelProtocol
-		}
+			t, psinetFilename, sponsorID, runConfig.doDefaultSponsorID, false, psinetValidServerEntryTags, discoveryServers)
 
 		// Pave tactics without destination bytes.
 		paveTacticsConfigFile(
@@ -1508,6 +1563,7 @@ func runServer(t *testing.T, runConfig *runServerConfig) {
 			false,
 			runConfig.applyPrefix,
 			runConfig.forceFragmenting,
+			"consistent",
 			inproxyTacticsParametersJSON)
 
 		p, _ := os.FindProcess(os.Getpid())
@@ -1733,6 +1789,41 @@ func runServer(t *testing.T, runConfig *runServerConfig) {
 		}
 	}
 
+	// Check logs emitted by discovery.
+
+	var expectedDiscoveryStrategy []string
+
+	// Discovery emits 1 log on startup.
+	if doServerTactics {
+		expectedDiscoveryStrategy = append(expectedDiscoveryStrategy, "classic")
+	} else {
+		expectedDiscoveryStrategy = append(expectedDiscoveryStrategy, "consistent")
+	}
+	if runConfig.doHotReload {
+		if doServerTactics {
+			// Discovery emits 1 log when tactics are reloaded, which happens
+			// before the psinet database is reloaded.
+			expectedDiscoveryStrategy = append(expectedDiscoveryStrategy, "classic")
+		}
+		// Discovery emits 1 when the psinet database is reloaded.
+		expectedDiscoveryStrategy = append(expectedDiscoveryStrategy, "consistent")
+	}
+
+	for _, expectedStrategy := range expectedDiscoveryStrategy {
+		select {
+		case logFields := <-discoveryLog:
+			if strategy, ok := logFields["discovery_strategy"].(string); ok {
+				if strategy != expectedStrategy {
+					t.Fatalf("expected discovery strategy \"%s\"", expectedStrategy)
+				}
+			} else {
+				t.Fatalf("missing discovery_strategy field")
+			}
+		default:
+			t.Fatalf("missing discovery log")
+		}
+	}
+
 	// Check that datastore had retained/pruned server entries as expected.
 	checkPruneServerEntriesTest(t, runConfig, testDataDirName, pruneServerEntryTestCases)
 
@@ -1819,6 +1910,49 @@ func runServer(t *testing.T, runConfig *runServerConfig) {
 			t.Fatalf("unexpected cached steering IP: %v", entry)
 		}
 	}
+
+	// Check that the client discovered one of the discovery servers.
+
+	discoveredServers := make(map[string]*protocol.ServerEntry)
+
+	// Otherwise NewServerEntryIterator only returns TargetServerEntry.
+	clientConfig.TargetServerEntry = ""
+
+	_, iterator, err := psiphon.NewServerEntryIterator(clientConfig)
+	if err != nil {
+		t.Fatalf("NewServerEntryIterator failed: %s", err)
+	}
+	defer iterator.Close()
+
+	for {
+		serverEntry, err := iterator.Next()
+		if err != nil {
+			t.Fatalf("ServerIterator.Next failed: %s", err)
+		}
+		if serverEntry == nil {
+			break
+		}
+		discoveredServers[serverEntry.IpAddress] = serverEntry
+	}
+
+	foundOne := false
+	for _, server := range discoveryServers {
+
+		serverEntry, err := protocol.DecodeServerEntry(server.EncodedServerEntry, "", "")
+		if err != nil {
+			t.Fatalf("protocol.DecodeServerEntry failed: %s", err)
+		}
+
+		if v, ok := discoveredServers[serverEntry.IpAddress]; ok {
+			if v.Tag == serverEntry.Tag {
+				foundOne = true
+				break
+			}
+		}
+	}
+	if !foundOne {
+		t.Fatalf("expected client to discover at least one server")
+	}
 }
 
 func sendNotificationReceived(c chan<- struct{}) {
@@ -2751,7 +2885,8 @@ func pavePsinetDatabaseFile(
 	sponsorID string,
 	useDefaultSponsorID bool,
 	doDomainBytes bool,
-	validServerEntryTags []string) (string, string) {
+	validServerEntryTags []string,
+	discoveryServers []*psinet.DiscoveryServer) (string, string) {
 
 	if sponsorID == "" {
 		sponsorID = strings.ToUpper(prng.HexString(8))
@@ -2766,6 +2901,11 @@ func pavePsinetDatabaseFile(
 	fakePath := prng.HexString(4)
 	expectedHomepageURL := fmt.Sprintf("https://%s.com/%s", fakeDomain, fakePath)
 
+	discoverServersJSON, err := json.Marshal(discoveryServers)
+	if err != nil {
+		t.Fatalf("json.Marshal failed: %s\n", err)
+	}
+
 	psinetJSONFormat := `
     {
         "default_sponsor_id" : "%s",
@@ -2787,7 +2927,8 @@ func pavePsinetDatabaseFile(
         },
         "valid_server_entry_tags" : {
             %s
-        }
+        },
+        "discovery_servers" : %s
     }
 	`
 
@@ -2821,9 +2962,10 @@ func pavePsinetDatabaseFile(
 		expectedHomepageURL,
 		protocol.PSIPHON_API_ALERT_DISALLOWED_TRAFFIC,
 		actionURLsJSON,
-		validServerEntryTagsJSON)
+		validServerEntryTagsJSON,
+		discoverServersJSON)
 
-	err := ioutil.WriteFile(psinetFilename, []byte(psinetJSON), 0600)
+	err = ioutil.WriteFile(psinetFilename, []byte(psinetJSON), 0600)
 	if err != nil {
 		t.Fatalf("error paving psinet database file: %s", err)
 	}
@@ -3033,6 +3175,7 @@ func paveTacticsConfigFile(
 	doDestinationBytes bool,
 	applyOsshPrefix bool,
 	enableOsshPrefixFragmenting bool,
+	discoveryStategy string,
 	inproxyParametersJSON string) {
 
 	// Setting LimitTunnelProtocols passively exercises the
@@ -3084,7 +3227,8 @@ func paveTacticsConfigFile(
           "BPFClientTCPProbability" : 1.0,
           "ServerPacketManipulationSpecs" : [{"Name": "test-packetman-spec", "PacketSpecs": [["TCP-flags S"]]}],
           "ServerPacketManipulationProbability" : 1.0,
-          "ServerProtocolPacketManipulations": {"All" : ["test-packetman-spec"]}
+          "ServerProtocolPacketManipulations": {"All" : ["test-packetman-spec"]},
+          "ServerDiscoveryStrategy": "%s"
         }
       },
       "FilteredTactics" : [
@@ -3166,6 +3310,7 @@ func paveTacticsConfigFile(
 		livenessTestSize,
 		livenessTestSize,
 		livenessTestSize,
+		discoveryStategy,
 		propagationChannelID,
 		strings.ReplaceAll(testCustomHostNameRegex, `\`, `\\`),
 		tunnelProtocol)
@@ -3911,3 +4056,30 @@ func (f *flows) Write(p []byte) (n int, err error) {
 
 	return n, err
 }
+
+// newDiscoveryServers returns len(ipAddresses) discovery servers with the
+// given IP addresses and randomly generated tags.
+func newDiscoveryServers(ipAddresses []string) ([]*psinet.DiscoveryServer, error) {
+
+	servers := make([]*psinet.DiscoveryServer, len(ipAddresses))
+
+	for i, ipAddress := range ipAddresses {
+
+		encodedServer, err := protocol.EncodeServerEntry(&protocol.ServerEntry{
+			IpAddress: ipAddress,
+			Tag:       prng.HexString(16),
+		})
+		if err != nil {
+			return nil, errors.Trace(err)
+		}
+
+		servers[i] = &psinet.DiscoveryServer{
+			DiscoveryDateRange: []time.Time{
+				time.Now().Add(-time.Hour).UTC(),
+				time.Now().Add(time.Hour).UTC(),
+			},
+			EncodedServerEntry: encodedServer,
+		}
+	}
+	return servers, nil
+}

+ 38 - 0
psiphon/server/services.go

@@ -126,6 +126,8 @@ func RunServices(configJSON []byte) (retErr error) {
 		support.PacketManipulator = packetManipulator
 	}
 
+	support.discovery = makeDiscovery(support)
+
 	// After this point, errors should be delivered to the errors channel and
 	// orderly shutdown should flow through to the end of the function to ensure
 	// all workers are synchronously stopped.
@@ -157,6 +159,21 @@ func RunServices(configJSON []byte) (retErr error) {
 		}
 	}
 
+	err = support.discovery.Start()
+	if err != nil {
+		select {
+		case errorChannel <- err:
+		default:
+		}
+	} else {
+		waitGroup.Add(1)
+		go func() {
+			defer waitGroup.Done()
+			<-shutdownBroadcast
+			support.discovery.Stop()
+		}()
+	}
+
 	if config.RunLoadMonitor() {
 		waitGroup.Add(1)
 		go func() {
@@ -471,6 +488,8 @@ func logIrregularTunnel(
 // components, which allows these data components to be refreshed
 // without restarting the server process.
 type SupportServices struct {
+	// TODO: make all fields non-exported, none are accessed outside
+	// of this package.
 	Config                       *Config
 	TrafficRulesSet              *TrafficRulesSet
 	OSLConfig                    *osl.Config
@@ -484,6 +503,8 @@ type SupportServices struct {
 	PacketManipulator            *packetman.Manipulator
 	ReplayCache                  *ReplayCache
 	ServerTacticsParametersCache *ServerTacticsParametersCache
+
+	discovery *Discovery
 }
 
 // NewSupportServices initializes a new SupportServices.
@@ -561,6 +582,16 @@ func (support *SupportServices) Reload() {
 			support.Blocklist},
 		support.GeoIPService.Reloaders()...)
 
+	reloadDiscovery := func(reloadedTactics bool) {
+		err := support.discovery.reload(reloadedTactics)
+		if err != nil {
+			log.WithTraceFields(
+				LogFields{"error": errors.Trace(err)}).Warning(
+				"failed to reload discovery")
+			return
+		}
+	}
+
 	// Note: established clients aren't notified when tactics change after a
 	// reload; new tactics will be obtained on the next client handshake or
 	// tactics request.
@@ -586,15 +617,22 @@ func (support *SupportServices) Reload() {
 					"failed to reload packet manipulation specs")
 			}
 		}
+
+		reloadDiscovery(true)
 	}
 
 	// Take these actions only after the corresponding Reloader has reloaded.
 	// In both the traffic rules and OSL cases, there is some impact from state
 	// reset, so the reset should be avoided where possible.
+	//
+	// Note: if both tactics and psinet are reloaded at the same time and
+	// the discovery strategy tactic has changed, then discovery will be reloaded
+	// twice.
 	reloadPostActions := map[common.Reloader]func(){
 		support.TrafficRulesSet: func() { support.TunnelServer.ResetAllClientTrafficRules() },
 		support.OSLConfig:       func() { support.TunnelServer.ResetAllClientOSLConfigs() },
 		support.TacticsServer:   reloadTactics,
+		support.PsinetDatabase:  func() { reloadDiscovery(false) },
 	}
 
 	for _, reloader := range reloaders {

+ 9 - 3
psiphon/server/tunnelServer.go

@@ -2772,7 +2772,7 @@ func (sshClient *sshClient) handleTCPPortForwards(
 	//
 	//    The manager enforces the concurrent TCP dial limit: when at the limit, the
 	//    manager blocks waiting for the number of dials to drop below the limit before
-	//    dispatching the request to handleTCPPortForward(), which will run in its own
+	//    dispatching the request to handleTCPChannel(), which will run in its own
 	//    goroutine and will dial and relay the port forward.
 	//
 	//    The block delays the current request and also halts dequeuing of subsequent
@@ -2785,7 +2785,7 @@ func (sshClient *sshClient) handleTCPPortForwards(
 	//    the dial timeout. If the dial timeout has expired before the dial begins, the
 	//    port forward is rejected and a stat is recorded.
 	//
-	// 3. handleTCPPortForward() performs the port forward dial and relaying.
+	// 3. handleTCPChannel() performs the port forward dial and relaying.
 	//
 	//     a. Dial the target, using the dial timeout remaining after queue and blocking
 	//        time is deducted.
@@ -3974,7 +3974,13 @@ func (sshClient *sshClient) newClientSeedPortForward(IPAddress net.IP) *osl.Clie
 		return nil
 	}
 
-	return sshClient.oslClientSeedState.NewClientSeedPortForward(IPAddress)
+	lookupASN := func(IP net.IP) string {
+		// TODO: there are potentially multiple identical geo IP lookups per new
+		// port forward and flow, cache and use result of first lookup.
+		return sshClient.sshServer.support.GeoIPService.LookupISPForIP(IP).ASN
+	}
+
+	return sshClient.oslClientSeedState.NewClientSeedPortForward(IPAddress, lookupASN)
 }
 
 // getOSLSeedPayload returns a payload containing all seeded SLOKs for

+ 52 - 58
vendor/golang.org/x/crypto/chacha20/chacha_ppc64le.s

@@ -33,6 +33,9 @@
 #define CONSTBASE  R16
 #define BLOCKS R17
 
+// for VPERMXOR
+#define MASK  R18
+
 DATA consts<>+0x00(SB)/8, $0x3320646e61707865
 DATA consts<>+0x08(SB)/8, $0x6b20657479622d32
 DATA consts<>+0x10(SB)/8, $0x0000000000000001
@@ -53,7 +56,11 @@ DATA consts<>+0x80(SB)/8, $0x6b2065746b206574
 DATA consts<>+0x88(SB)/8, $0x6b2065746b206574
 DATA consts<>+0x90(SB)/8, $0x0000000100000000
 DATA consts<>+0x98(SB)/8, $0x0000000300000002
-GLOBL consts<>(SB), RODATA, $0xa0
+DATA consts<>+0xa0(SB)/8, $0x5566774411223300
+DATA consts<>+0xa8(SB)/8, $0xddeeffcc99aabb88
+DATA consts<>+0xb0(SB)/8, $0x6677445522330011
+DATA consts<>+0xb8(SB)/8, $0xeeffccddaabb8899
+GLOBL consts<>(SB), RODATA, $0xc0
 
 //func chaCha20_ctr32_vsx(out, inp *byte, len int, key *[8]uint32, counter *uint32)
 TEXT ·chaCha20_ctr32_vsx(SB),NOSPLIT,$64-40
@@ -70,6 +77,9 @@ TEXT ·chaCha20_ctr32_vsx(SB),NOSPLIT,$64-40
 	MOVD $48, R10
 	MOVD $64, R11
 	SRD $6, LEN, BLOCKS
+	// for VPERMXOR
+	MOVD $consts<>+0xa0(SB), MASK
+	MOVD $16, R20
 	// V16
 	LXVW4X (CONSTBASE)(R0), VS48
 	ADD $80,CONSTBASE
@@ -87,6 +97,10 @@ TEXT ·chaCha20_ctr32_vsx(SB),NOSPLIT,$64-40
 	// V28
 	LXVW4X (CONSTBASE)(R11), VS60
 
+	// Load mask constants for VPERMXOR
+	LXVW4X (MASK)(R0), V20
+	LXVW4X (MASK)(R20), V21
+
 	// splat slot from V19 -> V26
 	VSPLTW $0, V19, V26
 
@@ -97,7 +111,7 @@ TEXT ·chaCha20_ctr32_vsx(SB),NOSPLIT,$64-40
 
 	MOVD $10, R14
 	MOVD R14, CTR
-
+	PCALIGN $16
 loop_outer_vsx:
 	// V0, V1, V2, V3
 	LXVW4X (R0)(CONSTBASE), VS32
@@ -128,22 +142,17 @@ loop_outer_vsx:
 	VSPLTISW $12, V28
 	VSPLTISW $8, V29
 	VSPLTISW $7, V30
-
+	PCALIGN $16
 loop_vsx:
 	VADDUWM V0, V4, V0
 	VADDUWM V1, V5, V1
 	VADDUWM V2, V6, V2
 	VADDUWM V3, V7, V3
 
-	VXOR V12, V0, V12
-	VXOR V13, V1, V13
-	VXOR V14, V2, V14
-	VXOR V15, V3, V15
-
-	VRLW V12, V27, V12
-	VRLW V13, V27, V13
-	VRLW V14, V27, V14
-	VRLW V15, V27, V15
+	VPERMXOR V12, V0, V21, V12
+	VPERMXOR V13, V1, V21, V13
+	VPERMXOR V14, V2, V21, V14
+	VPERMXOR V15, V3, V21, V15
 
 	VADDUWM V8, V12, V8
 	VADDUWM V9, V13, V9
@@ -165,15 +174,10 @@ loop_vsx:
 	VADDUWM V2, V6, V2
 	VADDUWM V3, V7, V3
 
-	VXOR V12, V0, V12
-	VXOR V13, V1, V13
-	VXOR V14, V2, V14
-	VXOR V15, V3, V15
-
-	VRLW V12, V29, V12
-	VRLW V13, V29, V13
-	VRLW V14, V29, V14
-	VRLW V15, V29, V15
+	VPERMXOR V12, V0, V20, V12
+	VPERMXOR V13, V1, V20, V13
+	VPERMXOR V14, V2, V20, V14
+	VPERMXOR V15, V3, V20, V15
 
 	VADDUWM V8, V12, V8
 	VADDUWM V9, V13, V9
@@ -195,15 +199,10 @@ loop_vsx:
 	VADDUWM V2, V7, V2
 	VADDUWM V3, V4, V3
 
-	VXOR V15, V0, V15
-	VXOR V12, V1, V12
-	VXOR V13, V2, V13
-	VXOR V14, V3, V14
-
-	VRLW V15, V27, V15
-	VRLW V12, V27, V12
-	VRLW V13, V27, V13
-	VRLW V14, V27, V14
+	VPERMXOR V15, V0, V21, V15
+	VPERMXOR V12, V1, V21, V12
+	VPERMXOR V13, V2, V21, V13
+	VPERMXOR V14, V3, V21, V14
 
 	VADDUWM V10, V15, V10
 	VADDUWM V11, V12, V11
@@ -225,15 +224,10 @@ loop_vsx:
 	VADDUWM V2, V7, V2
 	VADDUWM V3, V4, V3
 
-	VXOR V15, V0, V15
-	VXOR V12, V1, V12
-	VXOR V13, V2, V13
-	VXOR V14, V3, V14
-
-	VRLW V15, V29, V15
-	VRLW V12, V29, V12
-	VRLW V13, V29, V13
-	VRLW V14, V29, V14
+	VPERMXOR V15, V0, V20, V15
+	VPERMXOR V12, V1, V20, V12
+	VPERMXOR V13, V2, V20, V13
+	VPERMXOR V14, V3, V20, V14
 
 	VADDUWM V10, V15, V10
 	VADDUWM V11, V12, V11
@@ -249,48 +243,48 @@ loop_vsx:
 	VRLW V6, V30, V6
 	VRLW V7, V30, V7
 	VRLW V4, V30, V4
-	BC   16, LT, loop_vsx
+	BDNZ   loop_vsx
 
 	VADDUWM V12, V26, V12
 
-	WORD $0x13600F8C		// VMRGEW V0, V1, V27
-	WORD $0x13821F8C		// VMRGEW V2, V3, V28
+	VMRGEW V0, V1, V27
+	VMRGEW V2, V3, V28
 
-	WORD $0x10000E8C		// VMRGOW V0, V1, V0
-	WORD $0x10421E8C		// VMRGOW V2, V3, V2
+	VMRGOW V0, V1, V0
+	VMRGOW V2, V3, V2
 
-	WORD $0x13A42F8C		// VMRGEW V4, V5, V29
-	WORD $0x13C63F8C		// VMRGEW V6, V7, V30
+	VMRGEW V4, V5, V29
+	VMRGEW V6, V7, V30
 
 	XXPERMDI VS32, VS34, $0, VS33
 	XXPERMDI VS32, VS34, $3, VS35
 	XXPERMDI VS59, VS60, $0, VS32
 	XXPERMDI VS59, VS60, $3, VS34
 
-	WORD $0x10842E8C		// VMRGOW V4, V5, V4
-	WORD $0x10C63E8C		// VMRGOW V6, V7, V6
+	VMRGOW V4, V5, V4
+	VMRGOW V6, V7, V6
 
-	WORD $0x13684F8C		// VMRGEW V8, V9, V27
-	WORD $0x138A5F8C		// VMRGEW V10, V11, V28
+	VMRGEW V8, V9, V27
+	VMRGEW V10, V11, V28
 
 	XXPERMDI VS36, VS38, $0, VS37
 	XXPERMDI VS36, VS38, $3, VS39
 	XXPERMDI VS61, VS62, $0, VS36
 	XXPERMDI VS61, VS62, $3, VS38
 
-	WORD $0x11084E8C		// VMRGOW V8, V9, V8
-	WORD $0x114A5E8C		// VMRGOW V10, V11, V10
+	VMRGOW V8, V9, V8
+	VMRGOW V10, V11, V10
 
-	WORD $0x13AC6F8C		// VMRGEW V12, V13, V29
-	WORD $0x13CE7F8C		// VMRGEW V14, V15, V30
+	VMRGEW V12, V13, V29
+	VMRGEW V14, V15, V30
 
 	XXPERMDI VS40, VS42, $0, VS41
 	XXPERMDI VS40, VS42, $3, VS43
 	XXPERMDI VS59, VS60, $0, VS40
 	XXPERMDI VS59, VS60, $3, VS42
 
-	WORD $0x118C6E8C		// VMRGOW V12, V13, V12
-	WORD $0x11CE7E8C		// VMRGOW V14, V15, V14
+	VMRGOW V12, V13, V12
+	VMRGOW V14, V15, V14
 
 	VSPLTISW $4, V27
 	VADDUWM V26, V27, V26
@@ -431,7 +425,7 @@ tail_vsx:
 	ADD $-1, R11, R12
 	ADD $-1, INP
 	ADD $-1, OUT
-
+	PCALIGN $16
 looptail_vsx:
 	// Copying the result to OUT
 	// in bytes.
@@ -439,7 +433,7 @@ looptail_vsx:
 	MOVBZU 1(INP), TMP
 	XOR    KEY, TMP, KEY
 	MOVBU  KEY, 1(OUT)
-	BC     16, LT, looptail_vsx
+	BDNZ   looptail_vsx
 
 	// Clear the stack values
 	STXVW4X VS48, (R11)(R0)

+ 6 - 8
vendor/golang.org/x/crypto/internal/poly1305/sum_ppc64le.s

@@ -19,15 +19,14 @@
 
 #define POLY1305_MUL(h0, h1, h2, r0, r1, t0, t1, t2, t3, t4, t5) \
 	MULLD  r0, h0, t0;  \
-	MULLD  r0, h1, t4;  \
 	MULHDU r0, h0, t1;  \
+	MULLD  r0, h1, t4;  \
 	MULHDU r0, h1, t5;  \
 	ADDC   t4, t1, t1;  \
 	MULLD  r0, h2, t2;  \
-	ADDZE  t5;          \
 	MULHDU r1, h0, t4;  \
 	MULLD  r1, h0, h0;  \
-	ADD    t5, t2, t2;  \
+	ADDE   t5, t2, t2;  \
 	ADDC   h0, t1, t1;  \
 	MULLD  h2, r1, t3;  \
 	ADDZE  t4, h0;      \
@@ -37,13 +36,11 @@
 	ADDE   t5, t3, t3;  \
 	ADDC   h0, t2, t2;  \
 	MOVD   $-4, t4;     \
-	MOVD   t0, h0;      \
-	MOVD   t1, h1;      \
 	ADDZE  t3;          \
-	ANDCC  $3, t2, h2;  \
-	AND    t2, t4, t0;  \
+	RLDICL $0, t2, $62, h2; \
+	AND    t2, t4, h0;  \
 	ADDC   t0, h0, h0;  \
-	ADDE   t3, h1, h1;  \
+	ADDE   t3, t1, h1;  \
 	SLD    $62, t3, t4; \
 	SRD    $2, t2;      \
 	ADDZE  h2;          \
@@ -75,6 +72,7 @@ TEXT ·update(SB), $0-32
 loop:
 	POLY1305_ADD(R4, R8, R9, R10, R20, R21, R22)
 
+	PCALIGN $16
 multiply:
 	POLY1305_MUL(R8, R9, R10, R11, R12, R16, R17, R18, R14, R20, R21)
 	ADD $-16, R5

+ 1 - 4
vendor/golang.org/x/net/http/httpproxy/proxy.go

@@ -149,10 +149,7 @@ func parseProxy(proxy string) (*url.URL, error) {
 	}
 
 	proxyURL, err := url.Parse(proxy)
-	if err != nil ||
-		(proxyURL.Scheme != "http" &&
-			proxyURL.Scheme != "https" &&
-			proxyURL.Scheme != "socks5") {
+	if err != nil || proxyURL.Scheme == "" || proxyURL.Host == "" {
 		// proxy was bogus. Try prepending "http://" to it and
 		// see if that parses correctly. If not, we fall
 		// through and complain about the original one.

+ 31 - 0
vendor/golang.org/x/net/http2/frame.go

@@ -1564,6 +1564,7 @@ func (fr *Framer) readMetaFrame(hf *HeadersFrame) (*MetaHeadersFrame, error) {
 		if size > remainSize {
 			hdec.SetEmitEnabled(false)
 			mh.Truncated = true
+			remainSize = 0
 			return
 		}
 		remainSize -= size
@@ -1576,6 +1577,36 @@ func (fr *Framer) readMetaFrame(hf *HeadersFrame) (*MetaHeadersFrame, error) {
 	var hc headersOrContinuation = hf
 	for {
 		frag := hc.HeaderBlockFragment()
+
+		// Avoid parsing large amounts of headers that we will then discard.
+		// If the sender exceeds the max header list size by too much,
+		// skip parsing the fragment and close the connection.
+		//
+		// "Too much" is either any CONTINUATION frame after we've already
+		// exceeded the max header list size (in which case remainSize is 0),
+		// or a frame whose encoded size is more than twice the remaining
+		// header list bytes we're willing to accept.
+		if int64(len(frag)) > int64(2*remainSize) {
+			if VerboseLogs {
+				log.Printf("http2: header list too large")
+			}
+			// It would be nice to send a RST_STREAM before sending the GOAWAY,
+			// but the structure of the server's frame writer makes this difficult.
+			return nil, ConnectionError(ErrCodeProtocol)
+		}
+
+		// Also close the connection after any CONTINUATION frame following an
+		// invalid header, since we stop tracking the size of the headers after
+		// an invalid one.
+		if invalid != nil {
+			if VerboseLogs {
+				log.Printf("http2: invalid header: %v", invalid)
+			}
+			// It would be nice to send a RST_STREAM before sending the GOAWAY,
+			// but the structure of the server's frame writer makes this difficult.
+			return nil, ConnectionError(ErrCodeProtocol)
+		}
+
 		if _, err := hdec.Write(frag); err != nil {
 			return nil, ConnectionError(ErrCodeCompression)
 		}

+ 10 - 1
vendor/golang.org/x/net/http2/pipe.go

@@ -77,7 +77,10 @@ func (p *pipe) Read(d []byte) (n int, err error) {
 	}
 }
 
-var errClosedPipeWrite = errors.New("write on closed buffer")
+var (
+	errClosedPipeWrite        = errors.New("write on closed buffer")
+	errUninitializedPipeWrite = errors.New("write on uninitialized buffer")
+)
 
 // Write copies bytes from p into the buffer and wakes a reader.
 // It is an error to write more data than the buffer can hold.
@@ -91,6 +94,12 @@ func (p *pipe) Write(d []byte) (n int, err error) {
 	if p.err != nil || p.breakErr != nil {
 		return 0, errClosedPipeWrite
 	}
+	// pipe.setBuffer is never invoked, leaving the buffer uninitialized.
+	// We shouldn't try to write to an uninitialized pipe,
+	// but returning an error is better than panicking.
+	if p.b == nil {
+		return 0, errUninitializedPipeWrite
+	}
 	return p.b.Write(d)
 }
 

+ 7 - 6
vendor/golang.org/x/net/http2/server.go

@@ -124,6 +124,7 @@ type Server struct {
 	// IdleTimeout specifies how long until idle clients should be
 	// closed with a GOAWAY frame. PING frames are not considered
 	// activity for the purposes of IdleTimeout.
+	// If zero or negative, there is no timeout.
 	IdleTimeout time.Duration
 
 	// MaxUploadBufferPerConnection is the size of the initial flow
@@ -434,7 +435,7 @@ func (s *Server) ServeConn(c net.Conn, opts *ServeConnOpts) {
 	// passes the connection off to us with the deadline already set.
 	// Write deadlines are set per stream in serverConn.newStream.
 	// Disarm the net.Conn write deadline here.
-	if sc.hs.WriteTimeout != 0 {
+	if sc.hs.WriteTimeout > 0 {
 		sc.conn.SetWriteDeadline(time.Time{})
 	}
 
@@ -924,7 +925,7 @@ func (sc *serverConn) serve() {
 	sc.setConnState(http.StateActive)
 	sc.setConnState(http.StateIdle)
 
-	if sc.srv.IdleTimeout != 0 {
+	if sc.srv.IdleTimeout > 0 {
 		sc.idleTimer = time.AfterFunc(sc.srv.IdleTimeout, sc.onIdleTimer)
 		defer sc.idleTimer.Stop()
 	}
@@ -1637,7 +1638,7 @@ func (sc *serverConn) closeStream(st *stream, err error) {
 	delete(sc.streams, st.id)
 	if len(sc.streams) == 0 {
 		sc.setConnState(http.StateIdle)
-		if sc.srv.IdleTimeout != 0 {
+		if sc.srv.IdleTimeout > 0 {
 			sc.idleTimer.Reset(sc.srv.IdleTimeout)
 		}
 		if h1ServerKeepAlivesDisabled(sc.hs) {
@@ -2017,7 +2018,7 @@ func (sc *serverConn) processHeaders(f *MetaHeadersFrame) error {
 	// similar to how the http1 server works. Here it's
 	// technically more like the http1 Server's ReadHeaderTimeout
 	// (in Go 1.8), though. That's a more sane option anyway.
-	if sc.hs.ReadTimeout != 0 {
+	if sc.hs.ReadTimeout > 0 {
 		sc.conn.SetReadDeadline(time.Time{})
 		st.readDeadline = time.AfterFunc(sc.hs.ReadTimeout, st.onReadTimeout)
 	}
@@ -2038,7 +2039,7 @@ func (sc *serverConn) upgradeRequest(req *http.Request) {
 
 	// Disable any read deadline set by the net/http package
 	// prior to the upgrade.
-	if sc.hs.ReadTimeout != 0 {
+	if sc.hs.ReadTimeout > 0 {
 		sc.conn.SetReadDeadline(time.Time{})
 	}
 
@@ -2116,7 +2117,7 @@ func (sc *serverConn) newStream(id, pusherID uint32, state streamState) *stream
 	st.flow.conn = &sc.flow // link to conn-level counter
 	st.flow.add(sc.initialStreamSendWindowSize)
 	st.inflow.init(sc.srv.initialStreamRecvWindowSize())
-	if sc.hs.WriteTimeout != 0 {
+	if sc.hs.WriteTimeout > 0 {
 		st.writeDeadline = time.AfterFunc(sc.hs.WriteTimeout, st.onWriteTimeout)
 	}
 

+ 331 - 0
vendor/golang.org/x/net/http2/testsync.go

@@ -0,0 +1,331 @@
+// Copyright 2024 The Go Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+package http2
+
+import (
+	"context"
+	"sync"
+	"time"
+)
+
+// testSyncHooks coordinates goroutines in tests.
+//
+// For example, a call to ClientConn.RoundTrip involves several goroutines, including:
+//   - the goroutine running RoundTrip;
+//   - the clientStream.doRequest goroutine, which writes the request; and
+//   - the clientStream.readLoop goroutine, which reads the response.
+//
+// Using testSyncHooks, a test can start a RoundTrip and identify when all these goroutines
+// are blocked waiting for some condition such as reading the Request.Body or waiting for
+// flow control to become available.
+//
+// The testSyncHooks also manage timers and synthetic time in tests.
+// This permits us to, for example, start a request and cause it to time out waiting for
+// response headers without resorting to time.Sleep calls.
+type testSyncHooks struct {
+	// active/inactive act as a mutex and condition variable.
+	//
+	//  - neither chan contains a value: testSyncHooks is locked.
+	//  - active contains a value: unlocked, and at least one goroutine is not blocked
+	//  - inactive contains a value: unlocked, and all goroutines are blocked
+	active   chan struct{}
+	inactive chan struct{}
+
+	// goroutine counts
+	total    int                     // total goroutines
+	condwait map[*sync.Cond]int      // blocked in sync.Cond.Wait
+	blocked  []*testBlockedGoroutine // otherwise blocked
+
+	// fake time
+	now    time.Time
+	timers []*fakeTimer
+
+	// Transport testing: Report various events.
+	newclientconn func(*ClientConn)
+	newstream     func(*clientStream)
+}
+
+// testBlockedGoroutine is a blocked goroutine.
+type testBlockedGoroutine struct {
+	f  func() bool   // blocked until f returns true
+	ch chan struct{} // closed when unblocked
+}
+
+func newTestSyncHooks() *testSyncHooks {
+	h := &testSyncHooks{
+		active:   make(chan struct{}, 1),
+		inactive: make(chan struct{}, 1),
+		condwait: map[*sync.Cond]int{},
+	}
+	h.inactive <- struct{}{}
+	h.now = time.Date(2000, 1, 1, 0, 0, 0, 0, time.UTC)
+	return h
+}
+
+// lock acquires the testSyncHooks mutex.
+func (h *testSyncHooks) lock() {
+	select {
+	case <-h.active:
+	case <-h.inactive:
+	}
+}
+
+// waitInactive waits for all goroutines to become inactive.
+func (h *testSyncHooks) waitInactive() {
+	for {
+		<-h.inactive
+		if !h.unlock() {
+			break
+		}
+	}
+}
+
+// unlock releases the testSyncHooks mutex.
+// It reports whether any goroutines are active.
+func (h *testSyncHooks) unlock() (active bool) {
+	// Look for a blocked goroutine which can be unblocked.
+	blocked := h.blocked[:0]
+	unblocked := false
+	for _, b := range h.blocked {
+		if !unblocked && b.f() {
+			unblocked = true
+			close(b.ch)
+		} else {
+			blocked = append(blocked, b)
+		}
+	}
+	h.blocked = blocked
+
+	// Count goroutines blocked on condition variables.
+	condwait := 0
+	for _, count := range h.condwait {
+		condwait += count
+	}
+
+	if h.total > condwait+len(blocked) {
+		h.active <- struct{}{}
+		return true
+	} else {
+		h.inactive <- struct{}{}
+		return false
+	}
+}
+
+// goRun starts a new goroutine.
+func (h *testSyncHooks) goRun(f func()) {
+	h.lock()
+	h.total++
+	h.unlock()
+	go func() {
+		defer func() {
+			h.lock()
+			h.total--
+			h.unlock()
+		}()
+		f()
+	}()
+}
+
+// blockUntil indicates that a goroutine is blocked waiting for some condition to become true.
+// It waits until f returns true before proceeding.
+//
+// Example usage:
+//
+//	h.blockUntil(func() bool {
+//		// Is the context done yet?
+//		select {
+//		case <-ctx.Done():
+//		default:
+//			return false
+//		}
+//		return true
+//	})
+//	// Wait for the context to become done.
+//	<-ctx.Done()
+//
+// The function f passed to blockUntil must be non-blocking and idempotent.
+func (h *testSyncHooks) blockUntil(f func() bool) {
+	if f() {
+		return
+	}
+	ch := make(chan struct{})
+	h.lock()
+	h.blocked = append(h.blocked, &testBlockedGoroutine{
+		f:  f,
+		ch: ch,
+	})
+	h.unlock()
+	<-ch
+}
+
+// broadcast is sync.Cond.Broadcast.
+func (h *testSyncHooks) condBroadcast(cond *sync.Cond) {
+	h.lock()
+	delete(h.condwait, cond)
+	h.unlock()
+	cond.Broadcast()
+}
+
+// broadcast is sync.Cond.Wait.
+func (h *testSyncHooks) condWait(cond *sync.Cond) {
+	h.lock()
+	h.condwait[cond]++
+	h.unlock()
+}
+
+// newTimer creates a new fake timer.
+func (h *testSyncHooks) newTimer(d time.Duration) timer {
+	h.lock()
+	defer h.unlock()
+	t := &fakeTimer{
+		hooks: h,
+		when:  h.now.Add(d),
+		c:     make(chan time.Time),
+	}
+	h.timers = append(h.timers, t)
+	return t
+}
+
+// afterFunc creates a new fake AfterFunc timer.
+func (h *testSyncHooks) afterFunc(d time.Duration, f func()) timer {
+	h.lock()
+	defer h.unlock()
+	t := &fakeTimer{
+		hooks: h,
+		when:  h.now.Add(d),
+		f:     f,
+	}
+	h.timers = append(h.timers, t)
+	return t
+}
+
+func (h *testSyncHooks) contextWithTimeout(ctx context.Context, d time.Duration) (context.Context, context.CancelFunc) {
+	ctx, cancel := context.WithCancel(ctx)
+	t := h.afterFunc(d, cancel)
+	return ctx, func() {
+		t.Stop()
+		cancel()
+	}
+}
+
+func (h *testSyncHooks) timeUntilEvent() time.Duration {
+	h.lock()
+	defer h.unlock()
+	var next time.Time
+	for _, t := range h.timers {
+		if next.IsZero() || t.when.Before(next) {
+			next = t.when
+		}
+	}
+	if d := next.Sub(h.now); d > 0 {
+		return d
+	}
+	return 0
+}
+
+// advance advances time and causes synthetic timers to fire.
+func (h *testSyncHooks) advance(d time.Duration) {
+	h.lock()
+	defer h.unlock()
+	h.now = h.now.Add(d)
+	timers := h.timers[:0]
+	for _, t := range h.timers {
+		t := t // remove after go.mod depends on go1.22
+		t.mu.Lock()
+		switch {
+		case t.when.After(h.now):
+			timers = append(timers, t)
+		case t.when.IsZero():
+			// stopped timer
+		default:
+			t.when = time.Time{}
+			if t.c != nil {
+				close(t.c)
+			}
+			if t.f != nil {
+				h.total++
+				go func() {
+					defer func() {
+						h.lock()
+						h.total--
+						h.unlock()
+					}()
+					t.f()
+				}()
+			}
+		}
+		t.mu.Unlock()
+	}
+	h.timers = timers
+}
+
+// A timer wraps a time.Timer, or a synthetic equivalent in tests.
+// Unlike time.Timer, timer is single-use: The timer channel is closed when the timer expires.
+type timer interface {
+	C() <-chan time.Time
+	Stop() bool
+	Reset(d time.Duration) bool
+}
+
+// timeTimer implements timer using real time.
+type timeTimer struct {
+	t *time.Timer
+	c chan time.Time
+}
+
+// newTimeTimer creates a new timer using real time.
+func newTimeTimer(d time.Duration) timer {
+	ch := make(chan time.Time)
+	t := time.AfterFunc(d, func() {
+		close(ch)
+	})
+	return &timeTimer{t, ch}
+}
+
+// newTimeAfterFunc creates an AfterFunc timer using real time.
+func newTimeAfterFunc(d time.Duration, f func()) timer {
+	return &timeTimer{
+		t: time.AfterFunc(d, f),
+	}
+}
+
+func (t timeTimer) C() <-chan time.Time        { return t.c }
+func (t timeTimer) Stop() bool                 { return t.t.Stop() }
+func (t timeTimer) Reset(d time.Duration) bool { return t.t.Reset(d) }
+
+// fakeTimer implements timer using fake time.
+type fakeTimer struct {
+	hooks *testSyncHooks
+
+	mu   sync.Mutex
+	when time.Time      // when the timer will fire
+	c    chan time.Time // closed when the timer fires; mutually exclusive with f
+	f    func()         // called when the timer fires; mutually exclusive with c
+}
+
+func (t *fakeTimer) C() <-chan time.Time { return t.c }
+
+func (t *fakeTimer) Stop() bool {
+	t.mu.Lock()
+	defer t.mu.Unlock()
+	stopped := t.when.IsZero()
+	t.when = time.Time{}
+	return stopped
+}
+
+func (t *fakeTimer) Reset(d time.Duration) bool {
+	if t.c != nil || t.f == nil {
+		panic("fakeTimer only supports Reset on AfterFunc timers")
+	}
+	t.mu.Lock()
+	defer t.mu.Unlock()
+	t.hooks.lock()
+	defer t.hooks.unlock()
+	active := !t.when.IsZero()
+	t.when = t.hooks.now.Add(d)
+	if !active {
+		t.hooks.timers = append(t.hooks.timers, t)
+	}
+	return active
+}

+ 246 - 61
vendor/golang.org/x/net/http2/transport.go

@@ -147,6 +147,12 @@ type Transport struct {
 	// waiting for their turn.
 	StrictMaxConcurrentStreams bool
 
+	// IdleConnTimeout is the maximum amount of time an idle
+	// (keep-alive) connection will remain idle before closing
+	// itself.
+	// Zero means no limit.
+	IdleConnTimeout time.Duration
+
 	// ReadIdleTimeout is the timeout after which a health check using ping
 	// frame will be carried out if no frame is received on the connection.
 	// Note that a ping response will is considered a received frame, so if
@@ -178,6 +184,8 @@ type Transport struct {
 
 	connPoolOnce  sync.Once
 	connPoolOrDef ClientConnPool // non-nil version of ConnPool
+
+	syncHooks *testSyncHooks
 }
 
 func (t *Transport) maxHeaderListSize() uint32 {
@@ -302,7 +310,7 @@ type ClientConn struct {
 	readerErr  error         // set before readerDone is closed
 
 	idleTimeout time.Duration // or 0 for never
-	idleTimer   *time.Timer
+	idleTimer   timer
 
 	mu              sync.Mutex // guards following
 	cond            *sync.Cond // hold mu; broadcast on flow/closed changes
@@ -344,6 +352,60 @@ type ClientConn struct {
 	werr error        // first write error that has occurred
 	hbuf bytes.Buffer // HPACK encoder writes into this
 	henc *hpack.Encoder
+
+	syncHooks *testSyncHooks // can be nil
+}
+
+// Hook points used for testing.
+// Outside of tests, cc.syncHooks is nil and these all have minimal implementations.
+// Inside tests, see the testSyncHooks function docs.
+
+// goRun starts a new goroutine.
+func (cc *ClientConn) goRun(f func()) {
+	if cc.syncHooks != nil {
+		cc.syncHooks.goRun(f)
+		return
+	}
+	go f()
+}
+
+// condBroadcast is cc.cond.Broadcast.
+func (cc *ClientConn) condBroadcast() {
+	if cc.syncHooks != nil {
+		cc.syncHooks.condBroadcast(cc.cond)
+	}
+	cc.cond.Broadcast()
+}
+
+// condWait is cc.cond.Wait.
+func (cc *ClientConn) condWait() {
+	if cc.syncHooks != nil {
+		cc.syncHooks.condWait(cc.cond)
+	}
+	cc.cond.Wait()
+}
+
+// newTimer creates a new time.Timer, or a synthetic timer in tests.
+func (cc *ClientConn) newTimer(d time.Duration) timer {
+	if cc.syncHooks != nil {
+		return cc.syncHooks.newTimer(d)
+	}
+	return newTimeTimer(d)
+}
+
+// afterFunc creates a new time.AfterFunc timer, or a synthetic timer in tests.
+func (cc *ClientConn) afterFunc(d time.Duration, f func()) timer {
+	if cc.syncHooks != nil {
+		return cc.syncHooks.afterFunc(d, f)
+	}
+	return newTimeAfterFunc(d, f)
+}
+
+func (cc *ClientConn) contextWithTimeout(ctx context.Context, d time.Duration) (context.Context, context.CancelFunc) {
+	if cc.syncHooks != nil {
+		return cc.syncHooks.contextWithTimeout(ctx, d)
+	}
+	return context.WithTimeout(ctx, d)
 }
 
 // clientStream is the state for a single HTTP/2 stream. One of these
@@ -425,7 +487,7 @@ func (cs *clientStream) abortStreamLocked(err error) {
 	// TODO(dneil): Clean up tests where cs.cc.cond is nil.
 	if cs.cc.cond != nil {
 		// Wake up writeRequestBody if it is waiting on flow control.
-		cs.cc.cond.Broadcast()
+		cs.cc.condBroadcast()
 	}
 }
 
@@ -435,7 +497,7 @@ func (cs *clientStream) abortRequestBodyWrite() {
 	defer cc.mu.Unlock()
 	if cs.reqBody != nil && cs.reqBodyClosed == nil {
 		cs.closeReqBodyLocked()
-		cc.cond.Broadcast()
+		cc.condBroadcast()
 	}
 }
 
@@ -445,10 +507,10 @@ func (cs *clientStream) closeReqBodyLocked() {
 	}
 	cs.reqBodyClosed = make(chan struct{})
 	reqBodyClosed := cs.reqBodyClosed
-	go func() {
+	cs.cc.goRun(func() {
 		cs.reqBody.Close()
 		close(reqBodyClosed)
-	}()
+	})
 }
 
 type stickyErrWriter struct {
@@ -537,15 +599,6 @@ func authorityAddr(scheme string, authority string) (addr string) {
 	return net.JoinHostPort(host, port)
 }
 
-var retryBackoffHook func(time.Duration) *time.Timer
-
-func backoffNewTimer(d time.Duration) *time.Timer {
-	if retryBackoffHook != nil {
-		return retryBackoffHook(d)
-	}
-	return time.NewTimer(d)
-}
-
 // RoundTripOpt is like RoundTrip, but takes options.
 func (t *Transport) RoundTripOpt(req *http.Request, opt RoundTripOpt) (*http.Response, error) {
 	if !(req.URL.Scheme == "https" || (req.URL.Scheme == "http" && t.AllowHTTP)) {
@@ -573,13 +626,27 @@ func (t *Transport) RoundTripOpt(req *http.Request, opt RoundTripOpt) (*http.Res
 				backoff := float64(uint(1) << (uint(retry) - 1))
 				backoff += backoff * (0.1 * mathrand.Float64())
 				d := time.Second * time.Duration(backoff)
-				timer := backoffNewTimer(d)
+				var tm timer
+				if t.syncHooks != nil {
+					tm = t.syncHooks.newTimer(d)
+					t.syncHooks.blockUntil(func() bool {
+						select {
+						case <-tm.C():
+						case <-req.Context().Done():
+						default:
+							return false
+						}
+						return true
+					})
+				} else {
+					tm = newTimeTimer(d)
+				}
 				select {
-				case <-timer.C:
+				case <-tm.C():
 					t.vlogf("RoundTrip retrying after failure: %v", roundTripErr)
 					continue
 				case <-req.Context().Done():
-					timer.Stop()
+					tm.Stop()
 					err = req.Context().Err()
 				}
 			}
@@ -658,6 +725,9 @@ func canRetryError(err error) bool {
 }
 
 func (t *Transport) dialClientConn(ctx context.Context, addr string, singleUse bool) (*ClientConn, error) {
+	if t.syncHooks != nil {
+		return t.newClientConn(nil, singleUse, t.syncHooks)
+	}
 	host, _, err := net.SplitHostPort(addr)
 	if err != nil {
 		return nil, err
@@ -666,7 +736,7 @@ func (t *Transport) dialClientConn(ctx context.Context, addr string, singleUse b
 	if err != nil {
 		return nil, err
 	}
-	return t.newClientConn(tconn, singleUse)
+	return t.newClientConn(tconn, singleUse, nil)
 }
 
 func (t *Transport) newTLSConfig(host string) *tls.Config {
@@ -732,10 +802,10 @@ func (t *Transport) maxEncoderHeaderTableSize() uint32 {
 }
 
 func (t *Transport) NewClientConn(c net.Conn) (*ClientConn, error) {
-	return t.newClientConn(c, t.disableKeepAlives())
+	return t.newClientConn(c, t.disableKeepAlives(), nil)
 }
 
-func (t *Transport) newClientConn(c net.Conn, singleUse bool) (*ClientConn, error) {
+func (t *Transport) newClientConn(c net.Conn, singleUse bool, hooks *testSyncHooks) (*ClientConn, error) {
 	cc := &ClientConn{
 		t:                     t,
 		tconn:                 c,
@@ -750,10 +820,15 @@ func (t *Transport) newClientConn(c net.Conn, singleUse bool) (*ClientConn, erro
 		wantSettingsAck:       true,
 		pings:                 make(map[[8]byte]chan struct{}),
 		reqHeaderMu:           make(chan struct{}, 1),
+		syncHooks:             hooks,
+	}
+	if hooks != nil {
+		hooks.newclientconn(cc)
+		c = cc.tconn
 	}
 	if d := t.idleConnTimeout(); d != 0 {
 		cc.idleTimeout = d
-		cc.idleTimer = time.AfterFunc(d, cc.onIdleTimeout)
+		cc.idleTimer = cc.afterFunc(d, cc.onIdleTimeout)
 	}
 	if VerboseLogs {
 		t.vlogf("http2: Transport creating client conn %p to %v", cc, c.RemoteAddr())
@@ -818,7 +893,7 @@ func (t *Transport) newClientConn(c net.Conn, singleUse bool) (*ClientConn, erro
 		return nil, cc.werr
 	}
 
-	go cc.readLoop()
+	cc.goRun(cc.readLoop)
 	return cc, nil
 }
 
@@ -826,7 +901,7 @@ func (cc *ClientConn) healthCheck() {
 	pingTimeout := cc.t.pingTimeout()
 	// We don't need to periodically ping in the health check, because the readLoop of ClientConn will
 	// trigger the healthCheck again if there is no frame received.
-	ctx, cancel := context.WithTimeout(context.Background(), pingTimeout)
+	ctx, cancel := cc.contextWithTimeout(context.Background(), pingTimeout)
 	defer cancel()
 	cc.vlogf("http2: Transport sending health check")
 	err := cc.Ping(ctx)
@@ -1056,7 +1131,7 @@ func (cc *ClientConn) Shutdown(ctx context.Context) error {
 	// Wait for all in-flight streams to complete or connection to close
 	done := make(chan struct{})
 	cancelled := false // guarded by cc.mu
-	go func() {
+	cc.goRun(func() {
 		cc.mu.Lock()
 		defer cc.mu.Unlock()
 		for {
@@ -1068,9 +1143,9 @@ func (cc *ClientConn) Shutdown(ctx context.Context) error {
 			if cancelled {
 				break
 			}
-			cc.cond.Wait()
+			cc.condWait()
 		}
-	}()
+	})
 	shutdownEnterWaitStateHook()
 	select {
 	case <-done:
@@ -1080,7 +1155,7 @@ func (cc *ClientConn) Shutdown(ctx context.Context) error {
 		cc.mu.Lock()
 		// Free the goroutine above
 		cancelled = true
-		cc.cond.Broadcast()
+		cc.condBroadcast()
 		cc.mu.Unlock()
 		return ctx.Err()
 	}
@@ -1118,7 +1193,7 @@ func (cc *ClientConn) closeForError(err error) {
 	for _, cs := range cc.streams {
 		cs.abortStreamLocked(err)
 	}
-	cc.cond.Broadcast()
+	cc.condBroadcast()
 	cc.mu.Unlock()
 	cc.closeConn()
 }
@@ -1215,6 +1290,10 @@ func (cc *ClientConn) decrStreamReservationsLocked() {
 }
 
 func (cc *ClientConn) RoundTrip(req *http.Request) (*http.Response, error) {
+	return cc.roundTrip(req, nil)
+}
+
+func (cc *ClientConn) roundTrip(req *http.Request, streamf func(*clientStream)) (*http.Response, error) {
 	ctx := req.Context()
 	cs := &clientStream{
 		cc:                   cc,
@@ -1229,9 +1308,23 @@ func (cc *ClientConn) RoundTrip(req *http.Request) (*http.Response, error) {
 		respHeaderRecv:       make(chan struct{}),
 		donec:                make(chan struct{}),
 	}
-	go cs.doRequest(req)
+	cc.goRun(func() {
+		cs.doRequest(req)
+	})
 
 	waitDone := func() error {
+		if cc.syncHooks != nil {
+			cc.syncHooks.blockUntil(func() bool {
+				select {
+				case <-cs.donec:
+				case <-ctx.Done():
+				case <-cs.reqCancel:
+				default:
+					return false
+				}
+				return true
+			})
+		}
 		select {
 		case <-cs.donec:
 			return nil
@@ -1292,7 +1385,24 @@ func (cc *ClientConn) RoundTrip(req *http.Request) (*http.Response, error) {
 		return err
 	}
 
+	if streamf != nil {
+		streamf(cs)
+	}
+
 	for {
+		if cc.syncHooks != nil {
+			cc.syncHooks.blockUntil(func() bool {
+				select {
+				case <-cs.respHeaderRecv:
+				case <-cs.abort:
+				case <-ctx.Done():
+				case <-cs.reqCancel:
+				default:
+					return false
+				}
+				return true
+			})
+		}
 		select {
 		case <-cs.respHeaderRecv:
 			return handleResponseHeaders()
@@ -1348,6 +1458,21 @@ func (cs *clientStream) writeRequest(req *http.Request) (err error) {
 	if cc.reqHeaderMu == nil {
 		panic("RoundTrip on uninitialized ClientConn") // for tests
 	}
+	var newStreamHook func(*clientStream)
+	if cc.syncHooks != nil {
+		newStreamHook = cc.syncHooks.newstream
+		cc.syncHooks.blockUntil(func() bool {
+			select {
+			case cc.reqHeaderMu <- struct{}{}:
+				<-cc.reqHeaderMu
+			case <-cs.reqCancel:
+			case <-ctx.Done():
+			default:
+				return false
+			}
+			return true
+		})
+	}
 	select {
 	case cc.reqHeaderMu <- struct{}{}:
 	case <-cs.reqCancel:
@@ -1372,6 +1497,10 @@ func (cs *clientStream) writeRequest(req *http.Request) (err error) {
 	}
 	cc.mu.Unlock()
 
+	if newStreamHook != nil {
+		newStreamHook(cs)
+	}
+
 	// TODO(bradfitz): this is a copy of the logic in net/http. Unify somewhere?
 	if !cc.t.disableCompression() &&
 		req.Header.Get("Accept-Encoding") == "" &&
@@ -1452,15 +1581,30 @@ func (cs *clientStream) writeRequest(req *http.Request) (err error) {
 	var respHeaderTimer <-chan time.Time
 	var respHeaderRecv chan struct{}
 	if d := cc.responseHeaderTimeout(); d != 0 {
-		timer := time.NewTimer(d)
+		timer := cc.newTimer(d)
 		defer timer.Stop()
-		respHeaderTimer = timer.C
+		respHeaderTimer = timer.C()
 		respHeaderRecv = cs.respHeaderRecv
 	}
 	// Wait until the peer half-closes its end of the stream,
 	// or until the request is aborted (via context, error, or otherwise),
 	// whichever comes first.
 	for {
+		if cc.syncHooks != nil {
+			cc.syncHooks.blockUntil(func() bool {
+				select {
+				case <-cs.peerClosed:
+				case <-respHeaderTimer:
+				case <-respHeaderRecv:
+				case <-cs.abort:
+				case <-ctx.Done():
+				case <-cs.reqCancel:
+				default:
+					return false
+				}
+				return true
+			})
+		}
 		select {
 		case <-cs.peerClosed:
 			return nil
@@ -1609,7 +1753,7 @@ func (cc *ClientConn) awaitOpenSlotForStreamLocked(cs *clientStream) error {
 			return nil
 		}
 		cc.pendingRequests++
-		cc.cond.Wait()
+		cc.condWait()
 		cc.pendingRequests--
 		select {
 		case <-cs.abort:
@@ -1871,8 +2015,24 @@ func (cs *clientStream) awaitFlowControl(maxBytes int) (taken int32, err error)
 			cs.flow.take(take)
 			return take, nil
 		}
-		cc.cond.Wait()
+		cc.condWait()
+	}
+}
+
+func validateHeaders(hdrs http.Header) string {
+	for k, vv := range hdrs {
+		if !httpguts.ValidHeaderFieldName(k) {
+			return fmt.Sprintf("name %q", k)
+		}
+		for _, v := range vv {
+			if !httpguts.ValidHeaderFieldValue(v) {
+				// Don't include the value in the error,
+				// because it may be sensitive.
+				return fmt.Sprintf("value for header %q", k)
+			}
+		}
 	}
+	return ""
 }
 
 var errNilRequestURL = errors.New("http2: Request.URI is nil")
@@ -1912,19 +2072,14 @@ func (cc *ClientConn) encodeHeaders(req *http.Request, addGzipHeader bool, trail
 		}
 	}
 
-	// Check for any invalid headers and return an error before we
+	// Check for any invalid headers+trailers and return an error before we
 	// potentially pollute our hpack state. (We want to be able to
 	// continue to reuse the hpack encoder for future requests)
-	for k, vv := range req.Header {
-		if !httpguts.ValidHeaderFieldName(k) {
-			return nil, fmt.Errorf("invalid HTTP header name %q", k)
-		}
-		for _, v := range vv {
-			if !httpguts.ValidHeaderFieldValue(v) {
-				// Don't include the value in the error, because it may be sensitive.
-				return nil, fmt.Errorf("invalid HTTP header value for header %q", k)
-			}
-		}
+	if err := validateHeaders(req.Header); err != "" {
+		return nil, fmt.Errorf("invalid HTTP header %s", err)
+	}
+	if err := validateHeaders(req.Trailer); err != "" {
+		return nil, fmt.Errorf("invalid HTTP trailer %s", err)
 	}
 
 	enumerateHeaders := func(f func(name, value string)) {
@@ -2143,7 +2298,7 @@ func (cc *ClientConn) forgetStreamID(id uint32) {
 	}
 	// Wake up writeRequestBody via clientStream.awaitFlowControl and
 	// wake up RoundTrip if there is a pending request.
-	cc.cond.Broadcast()
+	cc.condBroadcast()
 
 	closeOnIdle := cc.singleUse || cc.doNotReuse || cc.t.disableKeepAlives() || cc.goAway != nil
 	if closeOnIdle && cc.streamsReserved == 0 && len(cc.streams) == 0 {
@@ -2231,7 +2386,7 @@ func (rl *clientConnReadLoop) cleanup() {
 			cs.abortStreamLocked(err)
 		}
 	}
-	cc.cond.Broadcast()
+	cc.condBroadcast()
 	cc.mu.Unlock()
 }
 
@@ -2266,10 +2421,9 @@ func (rl *clientConnReadLoop) run() error {
 	cc := rl.cc
 	gotSettings := false
 	readIdleTimeout := cc.t.ReadIdleTimeout
-	var t *time.Timer
+	var t timer
 	if readIdleTimeout != 0 {
-		t = time.AfterFunc(readIdleTimeout, cc.healthCheck)
-		defer t.Stop()
+		t = cc.afterFunc(readIdleTimeout, cc.healthCheck)
 	}
 	for {
 		f, err := cc.fr.ReadFrame()
@@ -2684,7 +2838,7 @@ func (rl *clientConnReadLoop) processData(f *DataFrame) error {
 		})
 		return nil
 	}
-	if !cs.firstByte {
+	if !cs.pastHeaders {
 		cc.logf("protocol error: received DATA before a HEADERS frame")
 		rl.endStreamError(cs, StreamError{
 			StreamID: f.StreamID,
@@ -2867,7 +3021,7 @@ func (rl *clientConnReadLoop) processSettingsNoWrite(f *SettingsFrame) error {
 			for _, cs := range cc.streams {
 				cs.flow.add(delta)
 			}
-			cc.cond.Broadcast()
+			cc.condBroadcast()
 
 			cc.initialWindowSize = s.Val
 		case SettingHeaderTableSize:
@@ -2911,9 +3065,18 @@ func (rl *clientConnReadLoop) processWindowUpdate(f *WindowUpdateFrame) error {
 		fl = &cs.flow
 	}
 	if !fl.add(int32(f.Increment)) {
+		// For stream, the sender sends RST_STREAM with an error code of FLOW_CONTROL_ERROR
+		if cs != nil {
+			rl.endStreamError(cs, StreamError{
+				StreamID: f.StreamID,
+				Code:     ErrCodeFlowControl,
+			})
+			return nil
+		}
+
 		return ConnectionError(ErrCodeFlowControl)
 	}
-	cc.cond.Broadcast()
+	cc.condBroadcast()
 	return nil
 }
 
@@ -2955,24 +3118,38 @@ func (cc *ClientConn) Ping(ctx context.Context) error {
 		}
 		cc.mu.Unlock()
 	}
-	errc := make(chan error, 1)
-	go func() {
+	var pingError error
+	errc := make(chan struct{})
+	cc.goRun(func() {
 		cc.wmu.Lock()
 		defer cc.wmu.Unlock()
-		if err := cc.fr.WritePing(false, p); err != nil {
-			errc <- err
+		if pingError = cc.fr.WritePing(false, p); pingError != nil {
+			close(errc)
 			return
 		}
-		if err := cc.bw.Flush(); err != nil {
-			errc <- err
+		if pingError = cc.bw.Flush(); pingError != nil {
+			close(errc)
 			return
 		}
-	}()
+	})
+	if cc.syncHooks != nil {
+		cc.syncHooks.blockUntil(func() bool {
+			select {
+			case <-c:
+			case <-errc:
+			case <-ctx.Done():
+			case <-cc.readerDone:
+			default:
+				return false
+			}
+			return true
+		})
+	}
 	select {
 	case <-c:
 		return nil
-	case err := <-errc:
-		return err
+	case <-errc:
+		return pingError
 	case <-ctx.Done():
 		return ctx.Err()
 	case <-cc.readerDone:
@@ -3141,9 +3318,17 @@ func (rt noDialH2RoundTripper) RoundTrip(req *http.Request) (*http.Response, err
 }
 
 func (t *Transport) idleConnTimeout() time.Duration {
+	// to keep things backwards compatible, we use non-zero values of
+	// IdleConnTimeout, followed by using the IdleConnTimeout on the underlying
+	// http1 transport, followed by 0
+	if t.IdleConnTimeout != 0 {
+		return t.IdleConnTimeout
+	}
+
 	if t.t1 != nil {
 		return t.t1.IdleConnTimeout
 	}
+
 	return 0
 }
 

+ 1 - 1
vendor/golang.org/x/sys/unix/aliases.go

@@ -2,7 +2,7 @@
 // Use of this source code is governed by a BSD-style
 // license that can be found in the LICENSE file.
 
-//go:build (aix || darwin || dragonfly || freebsd || linux || netbsd || openbsd || solaris || zos) && go1.9
+//go:build aix || darwin || dragonfly || freebsd || linux || netbsd || openbsd || solaris || zos
 
 package unix
 

+ 1 - 1
vendor/golang.org/x/sys/unix/mmap_nomremap.go

@@ -2,7 +2,7 @@
 // Use of this source code is governed by a BSD-style
 // license that can be found in the LICENSE file.
 
-//go:build aix || darwin || dragonfly || freebsd || openbsd || solaris
+//go:build aix || darwin || dragonfly || freebsd || openbsd || solaris || zos
 
 package unix
 

+ 1 - 1
vendor/golang.org/x/sys/unix/syscall_darwin_libSystem.go

@@ -2,7 +2,7 @@
 // Use of this source code is governed by a BSD-style
 // license that can be found in the LICENSE file.
 
-//go:build darwin && go1.12
+//go:build darwin
 
 package unix
 

+ 7 - 5
vendor/golang.org/x/sys/unix/syscall_freebsd.go

@@ -13,6 +13,7 @@
 package unix
 
 import (
+	"errors"
 	"sync"
 	"unsafe"
 )
@@ -169,25 +170,26 @@ func Getfsstat(buf []Statfs_t, flags int) (n int, err error) {
 func Uname(uname *Utsname) error {
 	mib := []_C_int{CTL_KERN, KERN_OSTYPE}
 	n := unsafe.Sizeof(uname.Sysname)
-	if err := sysctl(mib, &uname.Sysname[0], &n, nil, 0); err != nil {
+	// Suppress ENOMEM errors to be compatible with the C library __xuname() implementation.
+	if err := sysctl(mib, &uname.Sysname[0], &n, nil, 0); err != nil && !errors.Is(err, ENOMEM) {
 		return err
 	}
 
 	mib = []_C_int{CTL_KERN, KERN_HOSTNAME}
 	n = unsafe.Sizeof(uname.Nodename)
-	if err := sysctl(mib, &uname.Nodename[0], &n, nil, 0); err != nil {
+	if err := sysctl(mib, &uname.Nodename[0], &n, nil, 0); err != nil && !errors.Is(err, ENOMEM) {
 		return err
 	}
 
 	mib = []_C_int{CTL_KERN, KERN_OSRELEASE}
 	n = unsafe.Sizeof(uname.Release)
-	if err := sysctl(mib, &uname.Release[0], &n, nil, 0); err != nil {
+	if err := sysctl(mib, &uname.Release[0], &n, nil, 0); err != nil && !errors.Is(err, ENOMEM) {
 		return err
 	}
 
 	mib = []_C_int{CTL_KERN, KERN_VERSION}
 	n = unsafe.Sizeof(uname.Version)
-	if err := sysctl(mib, &uname.Version[0], &n, nil, 0); err != nil {
+	if err := sysctl(mib, &uname.Version[0], &n, nil, 0); err != nil && !errors.Is(err, ENOMEM) {
 		return err
 	}
 
@@ -205,7 +207,7 @@ func Uname(uname *Utsname) error {
 
 	mib = []_C_int{CTL_HW, HW_MACHINE}
 	n = unsafe.Sizeof(uname.Machine)
-	if err := sysctl(mib, &uname.Machine[0], &n, nil, 0); err != nil {
+	if err := sysctl(mib, &uname.Machine[0], &n, nil, 0); err != nil && !errors.Is(err, ENOMEM) {
 		return err
 	}
 

+ 99 - 0
vendor/golang.org/x/sys/unix/syscall_linux.go

@@ -1849,6 +1849,105 @@ func Dup2(oldfd, newfd int) error {
 //sys	Fsmount(fd int, flags int, mountAttrs int) (fsfd int, err error)
 //sys	Fsopen(fsName string, flags int) (fd int, err error)
 //sys	Fspick(dirfd int, pathName string, flags int) (fd int, err error)
+
+//sys	fsconfig(fd int, cmd uint, key *byte, value *byte, aux int) (err error)
+
+func fsconfigCommon(fd int, cmd uint, key string, value *byte, aux int) (err error) {
+	var keyp *byte
+	if keyp, err = BytePtrFromString(key); err != nil {
+		return
+	}
+	return fsconfig(fd, cmd, keyp, value, aux)
+}
+
+// FsconfigSetFlag is equivalent to fsconfig(2) called
+// with cmd == FSCONFIG_SET_FLAG.
+//
+// fd is the filesystem context to act upon.
+// key the parameter key to set.
+func FsconfigSetFlag(fd int, key string) (err error) {
+	return fsconfigCommon(fd, FSCONFIG_SET_FLAG, key, nil, 0)
+}
+
+// FsconfigSetString is equivalent to fsconfig(2) called
+// with cmd == FSCONFIG_SET_STRING.
+//
+// fd is the filesystem context to act upon.
+// key the parameter key to set.
+// value is the parameter value to set.
+func FsconfigSetString(fd int, key string, value string) (err error) {
+	var valuep *byte
+	if valuep, err = BytePtrFromString(value); err != nil {
+		return
+	}
+	return fsconfigCommon(fd, FSCONFIG_SET_STRING, key, valuep, 0)
+}
+
+// FsconfigSetBinary is equivalent to fsconfig(2) called
+// with cmd == FSCONFIG_SET_BINARY.
+//
+// fd is the filesystem context to act upon.
+// key the parameter key to set.
+// value is the parameter value to set.
+func FsconfigSetBinary(fd int, key string, value []byte) (err error) {
+	if len(value) == 0 {
+		return EINVAL
+	}
+	return fsconfigCommon(fd, FSCONFIG_SET_BINARY, key, &value[0], len(value))
+}
+
+// FsconfigSetPath is equivalent to fsconfig(2) called
+// with cmd == FSCONFIG_SET_PATH.
+//
+// fd is the filesystem context to act upon.
+// key the parameter key to set.
+// path is a non-empty path for specified key.
+// atfd is a file descriptor at which to start lookup from or AT_FDCWD.
+func FsconfigSetPath(fd int, key string, path string, atfd int) (err error) {
+	var valuep *byte
+	if valuep, err = BytePtrFromString(path); err != nil {
+		return
+	}
+	return fsconfigCommon(fd, FSCONFIG_SET_PATH, key, valuep, atfd)
+}
+
+// FsconfigSetPathEmpty is equivalent to fsconfig(2) called
+// with cmd == FSCONFIG_SET_PATH_EMPTY. The same as
+// FconfigSetPath but with AT_PATH_EMPTY implied.
+func FsconfigSetPathEmpty(fd int, key string, path string, atfd int) (err error) {
+	var valuep *byte
+	if valuep, err = BytePtrFromString(path); err != nil {
+		return
+	}
+	return fsconfigCommon(fd, FSCONFIG_SET_PATH_EMPTY, key, valuep, atfd)
+}
+
+// FsconfigSetFd is equivalent to fsconfig(2) called
+// with cmd == FSCONFIG_SET_FD.
+//
+// fd is the filesystem context to act upon.
+// key the parameter key to set.
+// value is a file descriptor to be assigned to specified key.
+func FsconfigSetFd(fd int, key string, value int) (err error) {
+	return fsconfigCommon(fd, FSCONFIG_SET_FD, key, nil, value)
+}
+
+// FsconfigCreate is equivalent to fsconfig(2) called
+// with cmd == FSCONFIG_CMD_CREATE.
+//
+// fd is the filesystem context to act upon.
+func FsconfigCreate(fd int) (err error) {
+	return fsconfig(fd, FSCONFIG_CMD_CREATE, nil, nil, 0)
+}
+
+// FsconfigReconfigure is equivalent to fsconfig(2) called
+// with cmd == FSCONFIG_CMD_RECONFIGURE.
+//
+// fd is the filesystem context to act upon.
+func FsconfigReconfigure(fd int) (err error) {
+	return fsconfig(fd, FSCONFIG_CMD_RECONFIGURE, nil, nil, 0)
+}
+
 //sys	Getdents(fd int, buf []byte) (n int, err error) = SYS_GETDENTS64
 //sysnb	Getpgid(pid int) (pgid int, err error)
 

+ 8 - 0
vendor/golang.org/x/sys/unix/syscall_zos_s390x.go

@@ -1520,6 +1520,14 @@ func (m *mmapper) Munmap(data []byte) (err error) {
 	return nil
 }
 
+func Mmap(fd int, offset int64, length int, prot int, flags int) (data []byte, err error) {
+        return mapper.Mmap(fd, offset, length, prot, flags)
+}
+
+func Munmap(b []byte) (err error) {
+        return mapper.Munmap(b)
+}
+
 func Read(fd int, p []byte) (n int, err error) {
 	n, err = read(fd, p)
 	if raceenabled {

+ 10 - 0
vendor/golang.org/x/sys/unix/zsyscall_linux.go

@@ -906,6 +906,16 @@ func Fspick(dirfd int, pathName string, flags int) (fd int, err error) {
 
 // THIS FILE IS GENERATED BY THE COMMAND AT THE TOP; DO NOT EDIT
 
+func fsconfig(fd int, cmd uint, key *byte, value *byte, aux int) (err error) {
+	_, _, e1 := Syscall6(SYS_FSCONFIG, uintptr(fd), uintptr(cmd), uintptr(unsafe.Pointer(key)), uintptr(unsafe.Pointer(value)), uintptr(aux), 0)
+	if e1 != 0 {
+		err = errnoErr(e1)
+	}
+	return
+}
+
+// THIS FILE IS GENERATED BY THE COMMAND AT THE TOP; DO NOT EDIT
+
 func Getdents(fd int, buf []byte) (n int, err error) {
 	var _p0 unsafe.Pointer
 	if len(buf) > 0 {

+ 60 - 0
vendor/golang.org/x/sys/unix/ztypes_linux.go

@@ -836,6 +836,15 @@ const (
 	FSPICK_EMPTY_PATH       = 0x8
 
 	FSMOUNT_CLOEXEC = 0x1
+
+	FSCONFIG_SET_FLAG        = 0x0
+	FSCONFIG_SET_STRING      = 0x1
+	FSCONFIG_SET_BINARY      = 0x2
+	FSCONFIG_SET_PATH        = 0x3
+	FSCONFIG_SET_PATH_EMPTY  = 0x4
+	FSCONFIG_SET_FD          = 0x5
+	FSCONFIG_CMD_CREATE      = 0x6
+	FSCONFIG_CMD_RECONFIGURE = 0x7
 )
 
 type OpenHow struct {
@@ -1550,6 +1559,7 @@ const (
 	IFLA_DEVLINK_PORT                          = 0x3e
 	IFLA_GSO_IPV4_MAX_SIZE                     = 0x3f
 	IFLA_GRO_IPV4_MAX_SIZE                     = 0x40
+	IFLA_DPLL_PIN                              = 0x41
 	IFLA_PROTO_DOWN_REASON_UNSPEC              = 0x0
 	IFLA_PROTO_DOWN_REASON_MASK                = 0x1
 	IFLA_PROTO_DOWN_REASON_VALUE               = 0x2
@@ -1565,6 +1575,7 @@ const (
 	IFLA_INET6_ICMP6STATS                      = 0x6
 	IFLA_INET6_TOKEN                           = 0x7
 	IFLA_INET6_ADDR_GEN_MODE                   = 0x8
+	IFLA_INET6_RA_MTU                          = 0x9
 	IFLA_BR_UNSPEC                             = 0x0
 	IFLA_BR_FORWARD_DELAY                      = 0x1
 	IFLA_BR_HELLO_TIME                         = 0x2
@@ -1612,6 +1623,9 @@ const (
 	IFLA_BR_MCAST_MLD_VERSION                  = 0x2c
 	IFLA_BR_VLAN_STATS_PER_PORT                = 0x2d
 	IFLA_BR_MULTI_BOOLOPT                      = 0x2e
+	IFLA_BR_MCAST_QUERIER_STATE                = 0x2f
+	IFLA_BR_FDB_N_LEARNED                      = 0x30
+	IFLA_BR_FDB_MAX_LEARNED                    = 0x31
 	IFLA_BRPORT_UNSPEC                         = 0x0
 	IFLA_BRPORT_STATE                          = 0x1
 	IFLA_BRPORT_PRIORITY                       = 0x2
@@ -1649,6 +1663,14 @@ const (
 	IFLA_BRPORT_BACKUP_PORT                    = 0x22
 	IFLA_BRPORT_MRP_RING_OPEN                  = 0x23
 	IFLA_BRPORT_MRP_IN_OPEN                    = 0x24
+	IFLA_BRPORT_MCAST_EHT_HOSTS_LIMIT          = 0x25
+	IFLA_BRPORT_MCAST_EHT_HOSTS_CNT            = 0x26
+	IFLA_BRPORT_LOCKED                         = 0x27
+	IFLA_BRPORT_MAB                            = 0x28
+	IFLA_BRPORT_MCAST_N_GROUPS                 = 0x29
+	IFLA_BRPORT_MCAST_MAX_GROUPS               = 0x2a
+	IFLA_BRPORT_NEIGH_VLAN_SUPPRESS            = 0x2b
+	IFLA_BRPORT_BACKUP_NHID                    = 0x2c
 	IFLA_INFO_UNSPEC                           = 0x0
 	IFLA_INFO_KIND                             = 0x1
 	IFLA_INFO_DATA                             = 0x2
@@ -1670,6 +1692,9 @@ const (
 	IFLA_MACVLAN_MACADDR                       = 0x4
 	IFLA_MACVLAN_MACADDR_DATA                  = 0x5
 	IFLA_MACVLAN_MACADDR_COUNT                 = 0x6
+	IFLA_MACVLAN_BC_QUEUE_LEN                  = 0x7
+	IFLA_MACVLAN_BC_QUEUE_LEN_USED             = 0x8
+	IFLA_MACVLAN_BC_CUTOFF                     = 0x9
 	IFLA_VRF_UNSPEC                            = 0x0
 	IFLA_VRF_TABLE                             = 0x1
 	IFLA_VRF_PORT_UNSPEC                       = 0x0
@@ -1693,9 +1718,22 @@ const (
 	IFLA_XFRM_UNSPEC                           = 0x0
 	IFLA_XFRM_LINK                             = 0x1
 	IFLA_XFRM_IF_ID                            = 0x2
+	IFLA_XFRM_COLLECT_METADATA                 = 0x3
 	IFLA_IPVLAN_UNSPEC                         = 0x0
 	IFLA_IPVLAN_MODE                           = 0x1
 	IFLA_IPVLAN_FLAGS                          = 0x2
+	NETKIT_NEXT                                = -0x1
+	NETKIT_PASS                                = 0x0
+	NETKIT_DROP                                = 0x2
+	NETKIT_REDIRECT                            = 0x7
+	NETKIT_L2                                  = 0x0
+	NETKIT_L3                                  = 0x1
+	IFLA_NETKIT_UNSPEC                         = 0x0
+	IFLA_NETKIT_PEER_INFO                      = 0x1
+	IFLA_NETKIT_PRIMARY                        = 0x2
+	IFLA_NETKIT_POLICY                         = 0x3
+	IFLA_NETKIT_PEER_POLICY                    = 0x4
+	IFLA_NETKIT_MODE                           = 0x5
 	IFLA_VXLAN_UNSPEC                          = 0x0
 	IFLA_VXLAN_ID                              = 0x1
 	IFLA_VXLAN_GROUP                           = 0x2
@@ -1726,6 +1764,8 @@ const (
 	IFLA_VXLAN_GPE                             = 0x1b
 	IFLA_VXLAN_TTL_INHERIT                     = 0x1c
 	IFLA_VXLAN_DF                              = 0x1d
+	IFLA_VXLAN_VNIFILTER                       = 0x1e
+	IFLA_VXLAN_LOCALBYPASS                     = 0x1f
 	IFLA_GENEVE_UNSPEC                         = 0x0
 	IFLA_GENEVE_ID                             = 0x1
 	IFLA_GENEVE_REMOTE                         = 0x2
@@ -1740,6 +1780,7 @@ const (
 	IFLA_GENEVE_LABEL                          = 0xb
 	IFLA_GENEVE_TTL_INHERIT                    = 0xc
 	IFLA_GENEVE_DF                             = 0xd
+	IFLA_GENEVE_INNER_PROTO_INHERIT            = 0xe
 	IFLA_BAREUDP_UNSPEC                        = 0x0
 	IFLA_BAREUDP_PORT                          = 0x1
 	IFLA_BAREUDP_ETHERTYPE                     = 0x2
@@ -1752,6 +1793,8 @@ const (
 	IFLA_GTP_FD1                               = 0x2
 	IFLA_GTP_PDP_HASHSIZE                      = 0x3
 	IFLA_GTP_ROLE                              = 0x4
+	IFLA_GTP_CREATE_SOCKETS                    = 0x5
+	IFLA_GTP_RESTART_COUNT                     = 0x6
 	IFLA_BOND_UNSPEC                           = 0x0
 	IFLA_BOND_MODE                             = 0x1
 	IFLA_BOND_ACTIVE_SLAVE                     = 0x2
@@ -1781,6 +1824,9 @@ const (
 	IFLA_BOND_AD_ACTOR_SYSTEM                  = 0x1a
 	IFLA_BOND_TLB_DYNAMIC_LB                   = 0x1b
 	IFLA_BOND_PEER_NOTIF_DELAY                 = 0x1c
+	IFLA_BOND_AD_LACP_ACTIVE                   = 0x1d
+	IFLA_BOND_MISSED_MAX                       = 0x1e
+	IFLA_BOND_NS_IP6_TARGET                    = 0x1f
 	IFLA_BOND_AD_INFO_UNSPEC                   = 0x0
 	IFLA_BOND_AD_INFO_AGGREGATOR               = 0x1
 	IFLA_BOND_AD_INFO_NUM_PORTS                = 0x2
@@ -1796,6 +1842,7 @@ const (
 	IFLA_BOND_SLAVE_AD_AGGREGATOR_ID           = 0x6
 	IFLA_BOND_SLAVE_AD_ACTOR_OPER_PORT_STATE   = 0x7
 	IFLA_BOND_SLAVE_AD_PARTNER_OPER_PORT_STATE = 0x8
+	IFLA_BOND_SLAVE_PRIO                       = 0x9
 	IFLA_VF_INFO_UNSPEC                        = 0x0
 	IFLA_VF_INFO                               = 0x1
 	IFLA_VF_UNSPEC                             = 0x0
@@ -1854,8 +1901,16 @@ const (
 	IFLA_STATS_LINK_XSTATS_SLAVE               = 0x3
 	IFLA_STATS_LINK_OFFLOAD_XSTATS             = 0x4
 	IFLA_STATS_AF_SPEC                         = 0x5
+	IFLA_STATS_GETSET_UNSPEC                   = 0x0
+	IFLA_STATS_GET_FILTERS                     = 0x1
+	IFLA_STATS_SET_OFFLOAD_XSTATS_L3_STATS     = 0x2
 	IFLA_OFFLOAD_XSTATS_UNSPEC                 = 0x0
 	IFLA_OFFLOAD_XSTATS_CPU_HIT                = 0x1
+	IFLA_OFFLOAD_XSTATS_HW_S_INFO              = 0x2
+	IFLA_OFFLOAD_XSTATS_L3_STATS               = 0x3
+	IFLA_OFFLOAD_XSTATS_HW_S_INFO_UNSPEC       = 0x0
+	IFLA_OFFLOAD_XSTATS_HW_S_INFO_REQUEST      = 0x1
+	IFLA_OFFLOAD_XSTATS_HW_S_INFO_USED         = 0x2
 	IFLA_XDP_UNSPEC                            = 0x0
 	IFLA_XDP_FD                                = 0x1
 	IFLA_XDP_ATTACHED                          = 0x2
@@ -1885,6 +1940,11 @@ const (
 	IFLA_RMNET_UNSPEC                          = 0x0
 	IFLA_RMNET_MUX_ID                          = 0x1
 	IFLA_RMNET_FLAGS                           = 0x2
+	IFLA_MCTP_UNSPEC                           = 0x0
+	IFLA_MCTP_NET                              = 0x1
+	IFLA_DSA_UNSPEC                            = 0x0
+	IFLA_DSA_CONDUIT                           = 0x1
+	IFLA_DSA_MASTER                            = 0x1
 )
 
 const (

+ 82 - 0
vendor/golang.org/x/sys/windows/syscall_windows.go

@@ -165,6 +165,7 @@ func NewCallbackCDecl(fn interface{}) uintptr {
 //sys	CreateFile(name *uint16, access uint32, mode uint32, sa *SecurityAttributes, createmode uint32, attrs uint32, templatefile Handle) (handle Handle, err error) [failretval==InvalidHandle] = CreateFileW
 //sys	CreateNamedPipe(name *uint16, flags uint32, pipeMode uint32, maxInstances uint32, outSize uint32, inSize uint32, defaultTimeout uint32, sa *SecurityAttributes) (handle Handle, err error)  [failretval==InvalidHandle] = CreateNamedPipeW
 //sys	ConnectNamedPipe(pipe Handle, overlapped *Overlapped) (err error)
+//sys	DisconnectNamedPipe(pipe Handle) (err error)
 //sys	GetNamedPipeInfo(pipe Handle, flags *uint32, outSize *uint32, inSize *uint32, maxInstances *uint32) (err error)
 //sys	GetNamedPipeHandleState(pipe Handle, state *uint32, curInstances *uint32, maxCollectionCount *uint32, collectDataTimeout *uint32, userName *uint16, maxUserNameSize uint32) (err error) = GetNamedPipeHandleStateW
 //sys	SetNamedPipeHandleState(pipe Handle, state *uint32, maxCollectionCount *uint32, collectDataTimeout *uint32) (err error) = SetNamedPipeHandleState
@@ -348,8 +349,19 @@ func NewCallbackCDecl(fn interface{}) uintptr {
 //sys	SetProcessPriorityBoost(process Handle, disable bool) (err error) = kernel32.SetProcessPriorityBoost
 //sys	GetProcessWorkingSetSizeEx(hProcess Handle, lpMinimumWorkingSetSize *uintptr, lpMaximumWorkingSetSize *uintptr, flags *uint32)
 //sys	SetProcessWorkingSetSizeEx(hProcess Handle, dwMinimumWorkingSetSize uintptr, dwMaximumWorkingSetSize uintptr, flags uint32) (err error)
+//sys	ClearCommBreak(handle Handle) (err error)
+//sys	ClearCommError(handle Handle, lpErrors *uint32, lpStat *ComStat) (err error)
+//sys	EscapeCommFunction(handle Handle, dwFunc uint32) (err error)
+//sys	GetCommState(handle Handle, lpDCB *DCB) (err error)
+//sys	GetCommModemStatus(handle Handle, lpModemStat *uint32) (err error)
 //sys	GetCommTimeouts(handle Handle, timeouts *CommTimeouts) (err error)
+//sys	PurgeComm(handle Handle, dwFlags uint32) (err error)
+//sys	SetCommBreak(handle Handle) (err error)
+//sys	SetCommMask(handle Handle, dwEvtMask uint32) (err error)
+//sys	SetCommState(handle Handle, lpDCB *DCB) (err error)
 //sys	SetCommTimeouts(handle Handle, timeouts *CommTimeouts) (err error)
+//sys	SetupComm(handle Handle, dwInQueue uint32, dwOutQueue uint32) (err error)
+//sys	WaitCommEvent(handle Handle, lpEvtMask *uint32, lpOverlapped *Overlapped) (err error)
 //sys	GetActiveProcessorCount(groupNumber uint16) (ret uint32)
 //sys	GetMaximumProcessorCount(groupNumber uint16) (ret uint32)
 //sys	EnumWindows(enumFunc uintptr, param unsafe.Pointer) (err error) = user32.EnumWindows
@@ -1834,3 +1846,73 @@ func ResizePseudoConsole(pconsole Handle, size Coord) error {
 	// accept arguments that can be casted to uintptr, and Coord can't.
 	return resizePseudoConsole(pconsole, *((*uint32)(unsafe.Pointer(&size))))
 }
+
+// DCB constants. See https://learn.microsoft.com/en-us/windows/win32/api/winbase/ns-winbase-dcb.
+const (
+	CBR_110    = 110
+	CBR_300    = 300
+	CBR_600    = 600
+	CBR_1200   = 1200
+	CBR_2400   = 2400
+	CBR_4800   = 4800
+	CBR_9600   = 9600
+	CBR_14400  = 14400
+	CBR_19200  = 19200
+	CBR_38400  = 38400
+	CBR_57600  = 57600
+	CBR_115200 = 115200
+	CBR_128000 = 128000
+	CBR_256000 = 256000
+
+	DTR_CONTROL_DISABLE   = 0x00000000
+	DTR_CONTROL_ENABLE    = 0x00000010
+	DTR_CONTROL_HANDSHAKE = 0x00000020
+
+	RTS_CONTROL_DISABLE   = 0x00000000
+	RTS_CONTROL_ENABLE    = 0x00001000
+	RTS_CONTROL_HANDSHAKE = 0x00002000
+	RTS_CONTROL_TOGGLE    = 0x00003000
+
+	NOPARITY    = 0
+	ODDPARITY   = 1
+	EVENPARITY  = 2
+	MARKPARITY  = 3
+	SPACEPARITY = 4
+
+	ONESTOPBIT   = 0
+	ONE5STOPBITS = 1
+	TWOSTOPBITS  = 2
+)
+
+// EscapeCommFunction constants. See https://learn.microsoft.com/en-us/windows/win32/api/winbase/nf-winbase-escapecommfunction.
+const (
+	SETXOFF  = 1
+	SETXON   = 2
+	SETRTS   = 3
+	CLRRTS   = 4
+	SETDTR   = 5
+	CLRDTR   = 6
+	SETBREAK = 8
+	CLRBREAK = 9
+)
+
+// PurgeComm constants. See https://learn.microsoft.com/en-us/windows/win32/api/winbase/nf-winbase-purgecomm.
+const (
+	PURGE_TXABORT = 0x0001
+	PURGE_RXABORT = 0x0002
+	PURGE_TXCLEAR = 0x0004
+	PURGE_RXCLEAR = 0x0008
+)
+
+// SetCommMask constants. See https://learn.microsoft.com/en-us/windows/win32/api/winbase/nf-winbase-setcommmask.
+const (
+	EV_RXCHAR  = 0x0001
+	EV_RXFLAG  = 0x0002
+	EV_TXEMPTY = 0x0004
+	EV_CTS     = 0x0008
+	EV_DSR     = 0x0010
+	EV_RLSD    = 0x0020
+	EV_BREAK   = 0x0040
+	EV_ERR     = 0x0080
+	EV_RING    = 0x0100
+)

+ 24 - 0
vendor/golang.org/x/sys/windows/types_windows.go

@@ -3380,3 +3380,27 @@ type BLOB struct {
 	Size     uint32
 	BlobData *byte
 }
+
+type ComStat struct {
+	Flags    uint32
+	CBInQue  uint32
+	CBOutQue uint32
+}
+
+type DCB struct {
+	DCBlength  uint32
+	BaudRate   uint32
+	Flags      uint32
+	wReserved  uint16
+	XonLim     uint16
+	XoffLim    uint16
+	ByteSize   uint8
+	Parity     uint8
+	StopBits   uint8
+	XonChar    byte
+	XoffChar   byte
+	ErrorChar  byte
+	EofChar    byte
+	EvtChar    byte
+	wReserved1 uint16
+}

+ 117 - 9
vendor/golang.org/x/sys/windows/zsyscall_windows.go

@@ -188,6 +188,8 @@ var (
 	procAssignProcessToJobObject                             = modkernel32.NewProc("AssignProcessToJobObject")
 	procCancelIo                                             = modkernel32.NewProc("CancelIo")
 	procCancelIoEx                                           = modkernel32.NewProc("CancelIoEx")
+	procClearCommBreak                                       = modkernel32.NewProc("ClearCommBreak")
+	procClearCommError                                       = modkernel32.NewProc("ClearCommError")
 	procCloseHandle                                          = modkernel32.NewProc("CloseHandle")
 	procClosePseudoConsole                                   = modkernel32.NewProc("ClosePseudoConsole")
 	procConnectNamedPipe                                     = modkernel32.NewProc("ConnectNamedPipe")
@@ -212,7 +214,9 @@ var (
 	procDeleteProcThreadAttributeList                        = modkernel32.NewProc("DeleteProcThreadAttributeList")
 	procDeleteVolumeMountPointW                              = modkernel32.NewProc("DeleteVolumeMountPointW")
 	procDeviceIoControl                                      = modkernel32.NewProc("DeviceIoControl")
+	procDisconnectNamedPipe                                  = modkernel32.NewProc("DisconnectNamedPipe")
 	procDuplicateHandle                                      = modkernel32.NewProc("DuplicateHandle")
+	procEscapeCommFunction                                   = modkernel32.NewProc("EscapeCommFunction")
 	procExitProcess                                          = modkernel32.NewProc("ExitProcess")
 	procExpandEnvironmentStringsW                            = modkernel32.NewProc("ExpandEnvironmentStringsW")
 	procFindClose                                            = modkernel32.NewProc("FindClose")
@@ -236,6 +240,8 @@ var (
 	procGenerateConsoleCtrlEvent                             = modkernel32.NewProc("GenerateConsoleCtrlEvent")
 	procGetACP                                               = modkernel32.NewProc("GetACP")
 	procGetActiveProcessorCount                              = modkernel32.NewProc("GetActiveProcessorCount")
+	procGetCommModemStatus                                   = modkernel32.NewProc("GetCommModemStatus")
+	procGetCommState                                         = modkernel32.NewProc("GetCommState")
 	procGetCommTimeouts                                      = modkernel32.NewProc("GetCommTimeouts")
 	procGetCommandLineW                                      = modkernel32.NewProc("GetCommandLineW")
 	procGetComputerNameExW                                   = modkernel32.NewProc("GetComputerNameExW")
@@ -322,6 +328,7 @@ var (
 	procProcess32NextW                                       = modkernel32.NewProc("Process32NextW")
 	procProcessIdToSessionId                                 = modkernel32.NewProc("ProcessIdToSessionId")
 	procPulseEvent                                           = modkernel32.NewProc("PulseEvent")
+	procPurgeComm                                            = modkernel32.NewProc("PurgeComm")
 	procQueryDosDeviceW                                      = modkernel32.NewProc("QueryDosDeviceW")
 	procQueryFullProcessImageNameW                           = modkernel32.NewProc("QueryFullProcessImageNameW")
 	procQueryInformationJobObject                            = modkernel32.NewProc("QueryInformationJobObject")
@@ -335,6 +342,9 @@ var (
 	procResetEvent                                           = modkernel32.NewProc("ResetEvent")
 	procResizePseudoConsole                                  = modkernel32.NewProc("ResizePseudoConsole")
 	procResumeThread                                         = modkernel32.NewProc("ResumeThread")
+	procSetCommBreak                                         = modkernel32.NewProc("SetCommBreak")
+	procSetCommMask                                          = modkernel32.NewProc("SetCommMask")
+	procSetCommState                                         = modkernel32.NewProc("SetCommState")
 	procSetCommTimeouts                                      = modkernel32.NewProc("SetCommTimeouts")
 	procSetConsoleCursorPosition                             = modkernel32.NewProc("SetConsoleCursorPosition")
 	procSetConsoleMode                                       = modkernel32.NewProc("SetConsoleMode")
@@ -342,7 +352,6 @@ var (
 	procSetDefaultDllDirectories                             = modkernel32.NewProc("SetDefaultDllDirectories")
 	procSetDllDirectoryW                                     = modkernel32.NewProc("SetDllDirectoryW")
 	procSetEndOfFile                                         = modkernel32.NewProc("SetEndOfFile")
-	procSetFileValidData                                     = modkernel32.NewProc("SetFileValidData")
 	procSetEnvironmentVariableW                              = modkernel32.NewProc("SetEnvironmentVariableW")
 	procSetErrorMode                                         = modkernel32.NewProc("SetErrorMode")
 	procSetEvent                                             = modkernel32.NewProc("SetEvent")
@@ -351,6 +360,7 @@ var (
 	procSetFileInformationByHandle                           = modkernel32.NewProc("SetFileInformationByHandle")
 	procSetFilePointer                                       = modkernel32.NewProc("SetFilePointer")
 	procSetFileTime                                          = modkernel32.NewProc("SetFileTime")
+	procSetFileValidData                                     = modkernel32.NewProc("SetFileValidData")
 	procSetHandleInformation                                 = modkernel32.NewProc("SetHandleInformation")
 	procSetInformationJobObject                              = modkernel32.NewProc("SetInformationJobObject")
 	procSetNamedPipeHandleState                              = modkernel32.NewProc("SetNamedPipeHandleState")
@@ -361,6 +371,7 @@ var (
 	procSetStdHandle                                         = modkernel32.NewProc("SetStdHandle")
 	procSetVolumeLabelW                                      = modkernel32.NewProc("SetVolumeLabelW")
 	procSetVolumeMountPointW                                 = modkernel32.NewProc("SetVolumeMountPointW")
+	procSetupComm                                            = modkernel32.NewProc("SetupComm")
 	procSizeofResource                                       = modkernel32.NewProc("SizeofResource")
 	procSleepEx                                              = modkernel32.NewProc("SleepEx")
 	procTerminateJobObject                                   = modkernel32.NewProc("TerminateJobObject")
@@ -379,6 +390,7 @@ var (
 	procVirtualQueryEx                                       = modkernel32.NewProc("VirtualQueryEx")
 	procVirtualUnlock                                        = modkernel32.NewProc("VirtualUnlock")
 	procWTSGetActiveConsoleSessionId                         = modkernel32.NewProc("WTSGetActiveConsoleSessionId")
+	procWaitCommEvent                                        = modkernel32.NewProc("WaitCommEvent")
 	procWaitForMultipleObjects                               = modkernel32.NewProc("WaitForMultipleObjects")
 	procWaitForSingleObject                                  = modkernel32.NewProc("WaitForSingleObject")
 	procWriteConsoleW                                        = modkernel32.NewProc("WriteConsoleW")
@@ -1641,6 +1653,22 @@ func CancelIoEx(s Handle, o *Overlapped) (err error) {
 	return
 }
 
+func ClearCommBreak(handle Handle) (err error) {
+	r1, _, e1 := syscall.Syscall(procClearCommBreak.Addr(), 1, uintptr(handle), 0, 0)
+	if r1 == 0 {
+		err = errnoErr(e1)
+	}
+	return
+}
+
+func ClearCommError(handle Handle, lpErrors *uint32, lpStat *ComStat) (err error) {
+	r1, _, e1 := syscall.Syscall(procClearCommError.Addr(), 3, uintptr(handle), uintptr(unsafe.Pointer(lpErrors)), uintptr(unsafe.Pointer(lpStat)))
+	if r1 == 0 {
+		err = errnoErr(e1)
+	}
+	return
+}
+
 func CloseHandle(handle Handle) (err error) {
 	r1, _, e1 := syscall.Syscall(procCloseHandle.Addr(), 1, uintptr(handle), 0, 0)
 	if r1 == 0 {
@@ -1845,6 +1873,14 @@ func DeviceIoControl(handle Handle, ioControlCode uint32, inBuffer *byte, inBuff
 	return
 }
 
+func DisconnectNamedPipe(pipe Handle) (err error) {
+	r1, _, e1 := syscall.Syscall(procDisconnectNamedPipe.Addr(), 1, uintptr(pipe), 0, 0)
+	if r1 == 0 {
+		err = errnoErr(e1)
+	}
+	return
+}
+
 func DuplicateHandle(hSourceProcessHandle Handle, hSourceHandle Handle, hTargetProcessHandle Handle, lpTargetHandle *Handle, dwDesiredAccess uint32, bInheritHandle bool, dwOptions uint32) (err error) {
 	var _p0 uint32
 	if bInheritHandle {
@@ -1857,6 +1893,14 @@ func DuplicateHandle(hSourceProcessHandle Handle, hSourceHandle Handle, hTargetP
 	return
 }
 
+func EscapeCommFunction(handle Handle, dwFunc uint32) (err error) {
+	r1, _, e1 := syscall.Syscall(procEscapeCommFunction.Addr(), 2, uintptr(handle), uintptr(dwFunc), 0)
+	if r1 == 0 {
+		err = errnoErr(e1)
+	}
+	return
+}
+
 func ExitProcess(exitcode uint32) {
 	syscall.Syscall(procExitProcess.Addr(), 1, uintptr(exitcode), 0, 0)
 	return
@@ -2058,6 +2102,22 @@ func GetActiveProcessorCount(groupNumber uint16) (ret uint32) {
 	return
 }
 
+func GetCommModemStatus(handle Handle, lpModemStat *uint32) (err error) {
+	r1, _, e1 := syscall.Syscall(procGetCommModemStatus.Addr(), 2, uintptr(handle), uintptr(unsafe.Pointer(lpModemStat)), 0)
+	if r1 == 0 {
+		err = errnoErr(e1)
+	}
+	return
+}
+
+func GetCommState(handle Handle, lpDCB *DCB) (err error) {
+	r1, _, e1 := syscall.Syscall(procGetCommState.Addr(), 2, uintptr(handle), uintptr(unsafe.Pointer(lpDCB)), 0)
+	if r1 == 0 {
+		err = errnoErr(e1)
+	}
+	return
+}
+
 func GetCommTimeouts(handle Handle, timeouts *CommTimeouts) (err error) {
 	r1, _, e1 := syscall.Syscall(procGetCommTimeouts.Addr(), 2, uintptr(handle), uintptr(unsafe.Pointer(timeouts)), 0)
 	if r1 == 0 {
@@ -2810,6 +2870,14 @@ func PulseEvent(event Handle) (err error) {
 	return
 }
 
+func PurgeComm(handle Handle, dwFlags uint32) (err error) {
+	r1, _, e1 := syscall.Syscall(procPurgeComm.Addr(), 2, uintptr(handle), uintptr(dwFlags), 0)
+	if r1 == 0 {
+		err = errnoErr(e1)
+	}
+	return
+}
+
 func QueryDosDevice(deviceName *uint16, targetPath *uint16, max uint32) (n uint32, err error) {
 	r0, _, e1 := syscall.Syscall(procQueryDosDeviceW.Addr(), 3, uintptr(unsafe.Pointer(deviceName)), uintptr(unsafe.Pointer(targetPath)), uintptr(max))
 	n = uint32(r0)
@@ -2924,6 +2992,30 @@ func ResumeThread(thread Handle) (ret uint32, err error) {
 	return
 }
 
+func SetCommBreak(handle Handle) (err error) {
+	r1, _, e1 := syscall.Syscall(procSetCommBreak.Addr(), 1, uintptr(handle), 0, 0)
+	if r1 == 0 {
+		err = errnoErr(e1)
+	}
+	return
+}
+
+func SetCommMask(handle Handle, dwEvtMask uint32) (err error) {
+	r1, _, e1 := syscall.Syscall(procSetCommMask.Addr(), 2, uintptr(handle), uintptr(dwEvtMask), 0)
+	if r1 == 0 {
+		err = errnoErr(e1)
+	}
+	return
+}
+
+func SetCommState(handle Handle, lpDCB *DCB) (err error) {
+	r1, _, e1 := syscall.Syscall(procSetCommState.Addr(), 2, uintptr(handle), uintptr(unsafe.Pointer(lpDCB)), 0)
+	if r1 == 0 {
+		err = errnoErr(e1)
+	}
+	return
+}
+
 func SetCommTimeouts(handle Handle, timeouts *CommTimeouts) (err error) {
 	r1, _, e1 := syscall.Syscall(procSetCommTimeouts.Addr(), 2, uintptr(handle), uintptr(unsafe.Pointer(timeouts)), 0)
 	if r1 == 0 {
@@ -2989,14 +3081,6 @@ func SetEndOfFile(handle Handle) (err error) {
 	return
 }
 
-func SetFileValidData(handle Handle, validDataLength int64) (err error) {
-	r1, _, e1 := syscall.Syscall(procSetFileValidData.Addr(), 2, uintptr(handle), uintptr(validDataLength), 0)
-	if r1 == 0 {
-		err = errnoErr(e1)
-	}
-	return
-}
-
 func SetEnvironmentVariable(name *uint16, value *uint16) (err error) {
 	r1, _, e1 := syscall.Syscall(procSetEnvironmentVariableW.Addr(), 2, uintptr(unsafe.Pointer(name)), uintptr(unsafe.Pointer(value)), 0)
 	if r1 == 0 {
@@ -3060,6 +3144,14 @@ func SetFileTime(handle Handle, ctime *Filetime, atime *Filetime, wtime *Filetim
 	return
 }
 
+func SetFileValidData(handle Handle, validDataLength int64) (err error) {
+	r1, _, e1 := syscall.Syscall(procSetFileValidData.Addr(), 2, uintptr(handle), uintptr(validDataLength), 0)
+	if r1 == 0 {
+		err = errnoErr(e1)
+	}
+	return
+}
+
 func SetHandleInformation(handle Handle, mask uint32, flags uint32) (err error) {
 	r1, _, e1 := syscall.Syscall(procSetHandleInformation.Addr(), 3, uintptr(handle), uintptr(mask), uintptr(flags))
 	if r1 == 0 {
@@ -3145,6 +3237,14 @@ func SetVolumeMountPoint(volumeMountPoint *uint16, volumeName *uint16) (err erro
 	return
 }
 
+func SetupComm(handle Handle, dwInQueue uint32, dwOutQueue uint32) (err error) {
+	r1, _, e1 := syscall.Syscall(procSetupComm.Addr(), 3, uintptr(handle), uintptr(dwInQueue), uintptr(dwOutQueue))
+	if r1 == 0 {
+		err = errnoErr(e1)
+	}
+	return
+}
+
 func SizeofResource(module Handle, resInfo Handle) (size uint32, err error) {
 	r0, _, e1 := syscall.Syscall(procSizeofResource.Addr(), 2, uintptr(module), uintptr(resInfo), 0)
 	size = uint32(r0)
@@ -3291,6 +3391,14 @@ func WTSGetActiveConsoleSessionId() (sessionID uint32) {
 	return
 }
 
+func WaitCommEvent(handle Handle, lpEvtMask *uint32, lpOverlapped *Overlapped) (err error) {
+	r1, _, e1 := syscall.Syscall(procWaitCommEvent.Addr(), 3, uintptr(handle), uintptr(unsafe.Pointer(lpEvtMask)), uintptr(unsafe.Pointer(lpOverlapped)))
+	if r1 == 0 {
+		err = errnoErr(e1)
+	}
+	return
+}
+
 func waitForMultipleObjects(count uint32, handles uintptr, waitAll bool, waitMilliseconds uint32) (event uint32, err error) {
 	var _p0 uint32
 	if waitAll {

+ 8 - 6
vendor/modules.txt

@@ -23,10 +23,10 @@ github.com/Psiphon-Labs/consistent
 # github.com/Psiphon-Labs/goptlib v0.0.0-20200406165125-c0e32a7a3464
 ## explicit
 github.com/Psiphon-Labs/goptlib
-# github.com/Psiphon-Labs/psiphon-tls v0.0.0-20240305020009-09f917290799
+# github.com/Psiphon-Labs/psiphon-tls v0.0.0-20240424193802-52b2602ec60c
 ## explicit; go 1.21
 github.com/Psiphon-Labs/psiphon-tls
-# github.com/Psiphon-Labs/quic-go v0.0.0-20240305203241-7c4a760d03cc
+# github.com/Psiphon-Labs/quic-go v0.0.0-20240424181006-45545f5e1536
 ## explicit; go 1.21
 github.com/Psiphon-Labs/quic-go
 github.com/Psiphon-Labs/quic-go/http3
@@ -416,10 +416,12 @@ github.com/x448/float16
 # gitlab.torproject.org/tpo/anti-censorship/pluggable-transports/goptlib v1.5.0
 ## explicit; go 1.11
 gitlab.torproject.org/tpo/anti-censorship/pluggable-transports/goptlib
+# go.uber.org/mock v0.4.0
+## explicit; go 1.20
 # go4.org/mem v0.0.0-20210711025021-927187094b94
 ## explicit; go 1.14
 go4.org/mem
-# golang.org/x/crypto v0.19.0
+# golang.org/x/crypto v0.22.0
 ## explicit; go 1.18
 golang.org/x/crypto/blake2b
 golang.org/x/crypto/blake2s
@@ -442,7 +444,7 @@ golang.org/x/crypto/sha3
 golang.org/x/exp/constraints
 golang.org/x/exp/rand
 golang.org/x/exp/slices
-# golang.org/x/net v0.21.0
+# golang.org/x/net v0.24.0
 ## explicit; go 1.18
 golang.org/x/net/bpf
 golang.org/x/net/dns/dnsmessage
@@ -463,7 +465,7 @@ golang.org/x/net/trace
 # golang.org/x/sync v0.3.0
 ## explicit; go 1.17
 golang.org/x/sync/errgroup
-# golang.org/x/sys v0.17.0
+# golang.org/x/sys v0.19.0
 ## explicit; go 1.18
 golang.org/x/sys/cpu
 golang.org/x/sys/plan9
@@ -472,7 +474,7 @@ golang.org/x/sys/windows
 golang.org/x/sys/windows/registry
 golang.org/x/sys/windows/svc
 golang.org/x/sys/windows/svc/mgr
-# golang.org/x/term v0.17.0
+# golang.org/x/term v0.19.0
 ## explicit; go 1.18
 golang.org/x/term
 # golang.org/x/text v0.14.0