Browse Source

Merge branch 'master' into indistinguishable-tls

Rod Hynes 9 years ago
parent
commit
30f9599ffa
44 changed files with 1784 additions and 450 deletions
  1. 2 0
      .travis.yml
  2. 3 3
      ConsoleClient/make.bash
  3. 1 0
      ConsoleClient/privatePlugins.go
  4. 10 15
      MobileLibrary/iOS/PsiphonTunnel/PsiphonTunnel/PsiphonTunnel.h
  5. 6 8
      MobileLibrary/iOS/PsiphonTunnel/PsiphonTunnel/PsiphonTunnel.m
  6. 1 1
      MobileLibrary/iOS/PsiphonTunnel/PsiphonTunnel/Reachability/Reachability.m
  7. 1 0
      MobileLibrary/psi/privatePlugins.go
  8. 2 2
      MobileLibrary/psi/psi.go
  9. 1 1
      Server/Dockerfile
  10. 1 1
      Server/Dockerfile-binary-builder
  11. 381 0
      Server/deploy_ad-hoc_server.sh
  12. 6 5
      psiphon/TCPConn.go
  13. 129 69
      psiphon/TCPConn_bind.go
  14. 1 1
      psiphon/TCPConn_nobind.go
  15. 102 75
      psiphon/common/osl/osl.go
  16. 10 16
      psiphon/common/osl/osl_test.go
  17. 127 47
      psiphon/common/osl/paver/main.go
  18. 66 0
      psiphon/common/pluginProtocol.go
  19. 67 0
      psiphon/common/userAgentPicker.go
  20. 26 6
      psiphon/config.go
  21. 1 1
      psiphon/controller.go
  22. 37 24
      psiphon/controller_test.go
  23. 29 15
      psiphon/dataStore.go
  24. 13 5
      psiphon/feedback.go
  25. 2 6
      psiphon/meekConn.go
  26. 21 6
      psiphon/net.go
  27. 43 26
      psiphon/notice.go
  28. 5 1
      psiphon/remoteServerList.go
  29. 27 9
      psiphon/remoteServerList_test.go
  30. 5 5
      psiphon/server/api.go
  31. 7 1
      psiphon/server/config.go
  32. 81 35
      psiphon/server/log.go
  33. 33 18
      psiphon/server/psinet/psinet.go
  34. 135 0
      psiphon/server/psinet/psinet_test.go
  35. 11 3
      psiphon/server/server_test.go
  36. 0 2
      psiphon/server/services.go
  37. 12 2
      psiphon/server/tunnelServer.go
  38. 1 1
      psiphon/server/udp.go
  39. 22 2
      psiphon/serverApi.go
  40. 4 0
      psiphon/splitTunnel.go
  41. 84 36
      psiphon/tunnel.go
  42. 7 2
      psiphon/upgradeDownload.go
  43. 246 0
      psiphon/userAgent_test.go
  44. 15 0
      psiphon/utils.go

+ 2 - 0
.travis.yml

@@ -14,12 +14,14 @@ script:
 - go test -race -v ./common/protocol
 - go test -race -v ./transferstats
 - go test -race -v ./server
+- go test -race -v ./server/psinet
 - go test -race -v
 - go test -v -covermode=count -coverprofile=common.coverprofile ./common
 - go test -v -covermode=count -coverprofile=osl.coverprofile ./common/osl
 - go test -v -covermode=count -coverprofile=protocol.coverprofile ./common/protocol
 - go test -v -covermode=count -coverprofile=transferstats.coverprofile ./transferstats
 - go test -v -covermode=count -coverprofile=server.coverprofile ./server
+- go test -v -covermode=count -coverprofile=psinet.coverprofile ./server/psinet
 - go test -v -covermode=count -coverprofile=psiphon.coverprofile
 - $HOME/gopath/bin/gover
 - $HOME/gopath/bin/goveralls -coverprofile=gover.coverprofile -service=travis-ci -repotoken $COVERALLS_TOKEN

+ 3 - 3
ConsoleClient/make.bash

@@ -59,7 +59,7 @@ fi
 build_for_windows () {
   echo "...Getting project dependencies (via go get) for Windows. Parameter is: '$1'"
   GOOS=windows go get -d -v -tags "$WINDOWS_BUILD_TAGS" ./...
-  prepare_build $WINDOWS_BUILD_TAGS
+  prepare_build "$WINDOWS_BUILD_TAGS"
   if [ $? != 0 ]; then
     echo "....'go get' failed, exiting"
     exit $?
@@ -114,7 +114,7 @@ build_for_windows () {
 build_for_linux () {
   echo "Getting project dependencies (via go get) for Linux. Parameter is: '$1'"
   GOOS=linux go get -d -v -tags "$LINUX_BUILD_TAGS" ./...
-  prepare_build $LINUX_BUILD_TAGS
+  prepare_build "$LINUX_BUILD_TAGS"
   if [ $? != 0 ]; then
     echo "...'go get' failed, exiting"
     exit $?
@@ -165,7 +165,7 @@ build_for_linux () {
 build_for_osx () {
   echo "Getting project dependencies (via go get) for OSX"
   GOOS=darwin go get -d -v -tags "$OSX_BUILD_TAGS" ./...
-  prepare_build $OSX_BUILD_TAGS
+  prepare_build "$OSX_BUILD_TAGS"
   if [ $? != 0 ]; then
     echo "..'go get' failed, exiting"
     exit $?

+ 1 - 0
ConsoleClient/privatePlugins.go

@@ -22,5 +22,6 @@
 package main
 
 import (
+	_ "github.com/Psiphon-Inc/psiphon-tunnel-core-private-plugins/client_plugins"
 	_ "github.com/Psiphon-Inc/psiphon-tunnel-core-private-plugins/common_plugins"
 )

+ 10 - 15
MobileLibrary/iOS/PsiphonTunnel/PsiphonTunnel/PsiphonTunnel.h

@@ -64,11 +64,8 @@ FOUNDATION_EXPORT const unsigned char PsiphonTunnelVersionString[];
  - `ClientPlatform`: Should not be set by most library consumers.
  - `UpstreamProxyUrl`
  - `EmitDiagnosticNotices`
- - `LocalHttpProxyPort` // TODO: Should this be set-able for iOS?
- - `LocalSocksProxyPort` // TODO: Should this be set-able for iOS?
  - `EgressRegion`
  - `EstablishTunnelTimeoutSeconds`
- - `TunnelWholeDevice`: For stats purposes, but must be accurate. Defaults to 0 (false).
  - Should only be set if the Psiphon library is handling upgrade downloading (which it usually is _not_):
    - `UpgradeDownloadUrl`
    - `UpgradeDownloadClientVersionHeader`
@@ -82,6 +79,11 @@ FOUNDATION_EXPORT const unsigned char PsiphonTunnelVersionString[];
    - `PsiphonApiServerTimeoutSeconds`
    - `FetchRoutesTimeoutSeconds`
    - `HttpProxyOriginServerTimeoutSeconds`
+ - Fields which should only be set by Psiphon proper:
+   - `LocalHttpProxyPort`
+   - `LocalSocksProxyPort`
+   - `TunnelWholeDevice`: For stats purposes, but must be accurate. Defaults to 0 (false).
+
  @endcode
 
  @note All other config fields must not be set.
@@ -203,34 +205,31 @@ FOUNDATION_EXPORT const unsigned char PsiphonTunnelVersionString[];
  */
 - (void)onBytesTransferred:(int64_t)sent :(int64_t)received;
 
-// TODO: Only applicable to Psiphon proper?
 /*!
  Called when tunnel-core discovers a home page associated with this client.
  If there are no home pages, it will not be called. May be called more than
  once, for multiple home pages.
+ Note: This is probably only applicable to Psiphon Inc.'s apps.
  @param url  The URL of the home page.
  Swift: @code func onHomepage(_ url: String) @endcode
  */
 - (void)onHomepage:(NSString * _Nonnull)url;
 
-// TODO: Only applicable to Psiphon proper?
 /*!
  Called if the current version of the client is the latest (i.e., there is no upgrade available).
+ Note: This is probably only applicable to Psiphon Inc.'s apps.
  Swift: @code func onClientIsLatestVersion() @endcode
  */
 - (void)onClientIsLatestVersion;
 
-// TODO: Only applicable to Psiphon proper?
 /*!
  Called when a client upgrade has been downloaded.
  @param filename  The name of the file containing the upgrade.
+ Note: This is probably only applicable to Psiphon Inc.'s apps.
  Swift: @code func onClientUpgradeDownloaded(_ filename: String) @endcode
  */
 - (void)onClientUpgradeDownloaded:(NSString * _Nonnull)filename;
 
-// TODO: Applies to iOS?
-//func onClientVerificationRequired(nonce: String, ttlSeconds: Int, resetCache: Bool)
-
 @end
 
 /*!
@@ -261,17 +260,13 @@ FOUNDATION_EXPORT const unsigned char PsiphonTunnelVersionString[];
  Upload a feedback package to Psiphon Inc. The app collects feedback and diagnostics information in a particular format, then calls this function to upload it for later investigation.
  @note The key, server, path, and headers must be provided by Psiphon Inc.
  @param feedbackJson  The feedback and diagnostics data to upload.
- @param connectionConfigJson  This function may create a tunnel to perform the upload, and this configuration is used to create that tunnel.
  @param b64EncodedPublicKey  The key that will be used to encrypt the payload before uploading.
- @param uploadServer  The server to which the data will be uploaded.
- @param uploadPath  The path on the server to which the data will be loaded.
+ @param uploadServer  The server and path to which the data will be uploaded.
  @param uploadServerHeaders  The request headers that will be used when uploading.
  */
-+ (void)sendFeedback:(NSString * _Nonnull)feedbackJson
-    connectionConfig:(NSString * _Nonnull)connectionConfigJson
+- (void)sendFeedback:(NSString * _Nonnull)feedbackJson
            publicKey:(NSString * _Nonnull)b64EncodedPublicKey
         uploadServer:(NSString * _Nonnull)uploadServer
-          uploadPath:(NSString * _Nonnull)uploadPath
  uploadServerHeaders:(NSString * _Nonnull)uploadServerHeaders;
 
 @end

+ 6 - 8
MobileLibrary/iOS/PsiphonTunnel/PsiphonTunnel/PsiphonTunnel.m

@@ -108,13 +108,15 @@
 }
 
 // See comment in header.
-+ (void)sendFeedback:(NSString * _Nonnull)feedbackJson
-    connectionConfig:(NSString * _Nonnull)connectionConfigJson
+- (void)sendFeedback:(NSString * _Nonnull)feedbackJson
            publicKey:(NSString * _Nonnull)b64EncodedPublicKey
         uploadServer:(NSString * _Nonnull)uploadServer
-          uploadPath:(NSString * _Nonnull)uploadPath
  uploadServerHeaders:(NSString * _Nonnull)uploadServerHeaders {
-    GoPsiSendFeedback(connectionConfigJson, feedbackJson, b64EncodedPublicKey, uploadServer, uploadPath, uploadServerHeaders);
+    NSString *connectionConfigJson = [self getConfig];
+    if (connectionConfigJson == nil) {
+       [self logMessage:@"Error getting config for feedback upload"];
+    }
+    GoPsiSendFeedback(connectionConfigJson, feedbackJson, b64EncodedPublicKey, uploadServer, @"", uploadServerHeaders);
 }
 
 
@@ -278,13 +280,10 @@
     // * UpgradeDownloadFilename
     // * timeout fields
     
-    // TODO: Is LocalSocksProxyPort relevant for iOS?
-    
     //
     // Fill in the rest of the values.
     //
     
-    // TODO: Should be configurable?
     config[@"EmitBytesTransferred"] = [NSNumber numberWithBool:TRUE];
 
     config[@"DeviceRegion"] = [PsiphonTunnel getDeviceRegion];
@@ -307,7 +306,6 @@
     // Some of them require default values.
     //
     
-    // TODO: After updating tunnel-core in the framework, verify that this value is getting through to Kibana.
     if (config[@"ClientPlatform"] == nil) {
         config[@"ClientPlatform"] = @"iOS-Library";
     }

+ 1 - 1
MobileLibrary/iOS/PsiphonTunnel/PsiphonTunnel/Reachability/Reachability.m

@@ -25,7 +25,7 @@ NSString *kReachabilityChangedNotification = @"kNetworkReachabilityChangedNotifi
 
 #pragma mark - Supporting functions
 
-#define kShouldPrintReachabilityFlags 1
+#define kShouldPrintReachabilityFlags 0
 
 static void PrintReachabilityFlags(SCNetworkReachabilityFlags flags, const char* comment)
 {

+ 1 - 0
MobileLibrary/psi/privatePlugins.go

@@ -22,5 +22,6 @@
 package psi
 
 import (
+	_ "github.com/Psiphon-Inc/psiphon-tunnel-core-private-plugins/client_plugins"
 	_ "github.com/Psiphon-Inc/psiphon-tunnel-core-private-plugins/common_plugins"
 )

+ 2 - 2
MobileLibrary/psi/psi.go

@@ -147,8 +147,8 @@ func SetClientVerificationPayload(clientVerificationPayload string) {
 func SendFeedback(configJson, diagnosticsJson, b64EncodedPublicKey, uploadServer, uploadPath, uploadServerHeaders string) {
 	err := psiphon.SendFeedback(configJson, diagnosticsJson, b64EncodedPublicKey, uploadServer, uploadPath, uploadServerHeaders)
 	if err != nil {
-		psiphon.NoticeAlert("failed to upload feedback: %s", err)
+		psiphon.NoticeAlert("Failed to upload feedback: %s", err)
 	} else {
-		psiphon.NoticeInfo("feedback uploaded successfully")
+		psiphon.NoticeInfo("Feedback uploaded successfully")
 	}
 }

+ 1 - 1
Server/Dockerfile

@@ -1,4 +1,4 @@
-FROM alpine:latest
+FROM alpine:3.4
 
 MAINTAINER Psiphon Inc. <info@psiphon.ca>
 LABEL Description="Alpine Linux based Psiphon Tunnel-Core Server" Vendor="Psiphon Inc." Version="1.0"

+ 1 - 1
Server/Dockerfile-binary-builder

@@ -1,4 +1,4 @@
-FROM alpine:latest
+FROM alpine:3.4
 
 ENV GOLANG_VERSION 1.7.3
 ENV GOLANG_SRC_URL https://golang.org/dl/go$GOLANG_VERSION.src.tar.gz

+ 381 - 0
Server/deploy_ad-hoc_server.sh

@@ -0,0 +1,381 @@
+#!/bin/bash
+
+# Make sure the script is run as root user
+if [[ ${EUID} -ne 0 ]]; then
+  echo "This script must be run as root"
+  exit 1
+fi
+
+usage () {
+	echo "Usage: $0 -a <install|remove> -h <host> [-p <port (default 22)>]"
+	echo " Uses Docker and SSH to build an ad-hoc server container and deploy it"
+	echo " (via the 'install' action) to a previously configured host. The process"
+	echo " can be reversed by use of the 'remove' action, which restores the host"
+	echo " to its previous configuration."
+	echo ""
+	echo " This script must be run with root privilges (for access to the docker daemon)"
+	echo " You will be prompted for SSH credentials and host-key authentication as needed"
+	echo ""
+	echo " Options:"
+	echo "  -a/--action"
+	echo "   Action. Allowed actions are: 'install', 'remove'. Mandatory"
+	echo "  -u/--user"
+	echo "   Username to connect with. Default 'root'. Optional, but user must belong to the docker group"
+	echo "  -h/--host"
+	echo "   Host (or IP Address) to connect to. Mandatory"
+	echo "  -p/--port"
+	echo "   Port to connect to. Default 22. Optional"
+	echo ""
+
+  exit 1
+}
+
+generate_temporary_credentials () {
+	echo "..Generating temporary 4096 bit RSA keypair"
+	ssh-keygen -t rsa -b 4096 -C "temp-psiphond-ad_hoc" -f psiphond-ad_hoc -N ""
+	PUBLIC_KEY=$(cat psiphond-ad_hoc.pub)
+
+	echo "${PUBLIC_KEY}" | ssh -o PreferredAuthentications=interactive,password -p $PORT $USER@$HOST cat >> $HOME/.ssh/authorized_keys
+	if [ $? -ne 0 ]; then
+		echo "...Failed"
+		return 1
+	fi
+}
+
+destroy_temporary_credentials () {
+	echo "..Removing the temporary key from the remote host"
+	PUBLIC_KEY=$(cat psiphond-ad_hoc.pub)
+  ssh -i psiphond-ad_hoc -o IdentitiesOnly=yes -p $PORT $USER@$HOST sed -i '/temp-psiphond-ad_hoc/d' $HOME/.ssh/authorized_keys
+	if [ $? -ne 0 ]; then
+		echo "...Failed"
+		return 1
+	fi
+
+	echo "..Removing the local temporary keys"
+	rm psiphond-ad_hoc psiphond-ad_hoc.pub
+	if [ $? -ne 0 ]; then
+		echo "...Failed"
+		return 1
+	fi
+}
+
+docker_build_builder () {
+	echo "..Building the docker container 'psiphond-build'"
+	docker build -f Dockerfile-binary-builder -t psiphond-builder .
+	if [ $? -ne 0 ]; then
+		echo "...Failed"
+		return 1
+	fi
+}
+
+docker_build_psiphond_binary () {
+	echo "..Building 'psiphond' binary"
+  cd .. && docker run --rm -v $PWD/.:/go/src/github.com/Psiphon-Labs/psiphon-tunnel-core psiphond-builder
+	if [ $? -ne 0 ]; then
+		echo "...Failed"
+    cd $BASE
+		return 1
+	fi
+
+  cd $BASE
+	stat psiphond > /dev/null 2>&1
+	if [ $? -ne 0 ]; then
+		echo "...'psiphond' binary file not found"
+		return 1
+	fi
+
+}
+
+docker_build_psiphond_container () {
+	echo "..Building the '${CONTAINER_TAG}' container"
+	docker build -t ${CONTAINER_TAG} .
+	if [ $? -ne 0 ]; then
+		echo "...Failed"
+		return 1
+	fi
+}
+
+save_image () {
+	echo "..Saving docker image to archive"
+	docker save ${CONTAINER_TAG} | gzip > $ARCHIVE
+	if [ $? -ne 0 ]; then
+		echo "...Failed"
+		return 1
+	fi
+
+	stat $ARCHIVE > /dev/null 2>&1
+	if [ $? -ne 0 ]; then
+		echo "...'${ARCHIVE}' not found"
+		return 1
+	fi
+}
+
+put_and_load_image () {
+	echo "..Copying '${ARCHIVE}' to '${HOST}:/tmp'"
+	scp -i psiphond-ad_hoc -o IdentitiesOnly=yes -P $PORT $ARCHIVE $USER@$HOST:/tmp/
+	if [ $? -ne 0 ]; then
+		echo "...Failed"
+		return 1
+	fi
+
+	echo "..Loading image into remote docker"
+  # Variable interpolation into the remote script doesn't always work as expected, use fixed paths here instead
+	ssh -i psiphond-ad_hoc -o IdentitiesOnly=yes -p $PORT $USER@$HOST zcat /tmp/psiphond-ad-hoc.tar.gz | docker load && rm /tmp/psiphond-ad-hoc.tar.gz
+	if [ $? -ne 0 ]; then
+		echo "...Failed"
+		return 1
+	fi
+}
+
+remove_image () {
+	echo "..Removing image from remote docker"
+	ssh -i psiphond-ad_hoc -o IdentitiesOnly=yes -p $PORT $USER@$HOST docker rmi ${CONTAINER_TAG}
+	if [ $? -ne 0 ]; then
+		echo "...Failed"
+		return 1
+	fi
+}
+
+put_systemd_dropin () {
+	echo "..Creating systemd unit drop-in"
+	cat <<- EOF > ad-hoc.conf
+	[Service]
+	Environment=DOCKER_CONTENT_TRUST=0
+
+	# Clear previous pre-start command before setting new one
+	# Execute these commands prior to starting the service
+	# "-" before the command means errors are not fatal to service startup
+	ExecStartPre=
+	ExecStartPre=-/usr/bin/docker stop %p-run
+	ExecStartPre=-/usr/bin/docker rm %p-run
+
+	# Clear previous start command before setting new one
+	ExecStart=
+	ExecStart=/usr/bin/docker run --rm $CONTAINER_PORT_STRING $CONTAINER_VOLUME_STRING $CONTAINER_ULIMIT_STRING $CONTAINER_SYSCTL_STRING --name %p-run ${CONTAINER_TAG}
+	EOF
+
+	ssh -i psiphond-ad_hoc -o IdentitiesOnly=yes -p $PORT $USER@$HOST mkdir -p /etc/systemd/system/psiphond.service.d
+	echo "..Ensuring drop-in directory is available"
+	if [ $? -ne 0 ]; then
+		echo "...Failed"
+		return 1
+	fi
+
+	scp -i psiphond-ad_hoc -o IdentitiesOnly=yes -P $PORT ad-hoc.conf $USER@$HOST:/etc/systemd/system/psiphond.service.d/
+	echo "..Copying drop-in to remote host"
+	if [ $? -ne 0 ]; then
+		echo "...Failed"
+		return 1
+	fi
+
+	rm ad-hoc.conf
+}
+
+remove_systemd_dropin () {
+	echo "..Removing systemd unit drop-in"
+	ssh -i psiphond-ad_hoc -o IdentitiesOnly=yes -p $PORT $USER@$HOST [ ! -f /etc/systemd/system/psiphond.service.d/ad-hoc.conf ] || rm /etc/systemd/system/psiphond.service.d/ad-hoc.conf
+	if [ $? -ne 0 ]; then
+		echo "...Failed"
+		return 1
+	fi
+}
+
+reload_systemd () {
+	echo "..Reloading systemd unit file cache"
+	ssh -i psiphond-ad_hoc -o IdentitiesOnly=yes -p $PORT $USER@$HOST systemctl daemon-reload
+	if [ $? -ne 0 ]; then
+		echo "...Failed"
+		return 1
+	fi
+}
+
+restart_psiphond () {
+	echo "..Restarting the 'psiphond' service"
+	ssh -i psiphond-ad_hoc -o IdentitiesOnly=yes -p $PORT $USER@$HOST systemctl restart psiphond
+	if [ $? -ne 0 ]; then
+		echo "...Failed"
+		return 1
+	fi
+}
+
+# Locate and change to the directory containing the script
+BASE=$( cd "$(dirname "$0")" ; pwd -P )
+cd $BASE
+
+# Validate that we're in a git repository and store the revision
+REV=$(git rev-parse --short HEAD)
+if [ $? -ne 0 ]; then
+	echo "Could not store git revision, aborting"
+	exit 1
+fi
+
+# Parse command line arguments
+while [[ $# -gt 1 ]]; do
+	key="$1"
+
+	case $key in
+		-a|--action)
+			ACTION="$2"
+			shift
+
+			if [ "${ACTION}" != "install" ] && [ "${ACTION}" != "remove" ]; then
+				echo "Got: '${ACTION}', Expected one of: 'install', or 'remove', aborting."
+				exit 1
+			fi
+
+			;;
+		-u|--user)
+			USER="$2"
+			shift
+			;;
+		-h|--host)
+			HOST="$2"
+			shift
+			;;
+		-p|--port)
+			PORT="$2"
+			shift
+			;;
+		*)
+			usage
+			;;
+	esac
+	shift
+done
+
+# Validate all mandatory parameters were set
+if [ -z $ACTION ]; then
+	echo "Action is a required parameter, aborting."
+  echo ""
+  usage
+fi
+if [ -z $HOST ]; then
+	echo "Host is a required parameter, aborting."
+  echo ""
+  usage
+fi
+
+# Set default values for unset optional paramters
+if [ -z $USER ]; then
+  USER=root
+fi
+if [ -z $PORT ]; then
+	PORT=22
+fi
+
+# Set up other global variables
+TIMESTAMP=$(date +'%Y-%m-%d_%H-%M')
+CONTAINER_TAG="psiphond/ad-hoc:${TIMESTAMP}"
+ARCHIVE="psiphond-ad-hoc.tar.gz"
+
+# Display choices and pause
+echo "[$(date)] Ad-Hoc psiphond deploy starting."
+echo ""
+echo "Configuration:"
+echo " - Action: ${ACTION}"
+echo " - User: ${USER}"
+echo " - Host: ${HOST}"
+echo " - Port: ${PORT}"
+echo " - Containter Tag: ${CONTAINER_TAG}"
+echo " - Archive Name: ${ARCHIVE}"
+echo ""
+echo "Pausing 5 seconds to allow for ^C prior to starting"
+sleep 5
+
+generate_temporary_credentials
+if [ $? -ne 0 ]; then
+	echo "Inability to generate temporary credentials is fatal, aborting"
+	exit 1
+fi
+
+if [ "${ACTION}" == "install" ]; then
+	docker_build_builder
+	if [ $? -ne 0 ]; then
+		echo "...Aborting"
+    destroy_temporary_credentials
+		exit 1
+	fi
+
+	docker_build_psiphond_binary
+	if [ $? -ne 0 ]; then
+		echo "...Aborting"
+    destroy_temporary_credentials
+		exit 1
+	fi
+
+	docker_build_psiphond_container
+	if [ $? -ne 0 ]; then
+		echo "...Aborting"
+    destroy_temporary_credentials
+		exit 1
+	fi
+
+	save_image
+	if [ $? -ne 0 ]; then
+		echo "...Aborting"
+    destroy_temporary_credentials
+		exit 1
+	fi
+
+	put_and_load_image
+	if [ $? -ne 0 ]; then
+		echo "...Aborting"
+    destroy_temporary_credentials
+		exit 1
+	fi
+
+	put_systemd_dropin
+	if [ $? -ne 0 ]; then
+		echo "...Aborting"
+    destroy_temporary_credentials
+		exit 1
+	fi
+
+	reload_systemd
+	if [ $? -ne 0 ]; then
+		echo "...Aborting"
+    destroy_temporary_credentials
+		exit 1
+	fi
+
+	restart_psiphond
+	if [ $? -ne 0 ]; then
+		echo "...Aborting"
+    destroy_temporary_credentials
+		exit 1
+	fi
+elif [ "${ACTION}" == "remove" ]; then
+	remove_systemd_dropin
+	if [ $? -ne 0 ]; then
+		echo "...Aborting"
+    destroy_temporary_credentials
+		exit 1
+	fi
+
+	reload_systemd
+	if [ $? -ne 0 ]; then
+		echo "...Aborting"
+    destroy_temporary_credentials
+		exit 1
+	fi
+
+	restart_psiphond
+	if [ $? -ne 0 ]; then
+		echo "...Aborting"
+    destroy_temporary_credentials
+		exit 1
+	fi
+
+	remove_image
+	if [ $? -ne 0 ]; then
+		echo "...Aborting"
+    destroy_temporary_credentials
+		exit 1
+	fi
+else
+	echo "Parameter validation passed, but action was not 'install' or 'remove', aborting"
+	exit 1
+fi
+
+destroy_temporary_credentials
+echo "[$(date)] Ad-Hoc psiphond deploy ended."

+ 6 - 5
psiphon/TCPConn.go

@@ -131,9 +131,9 @@ func interruptibleTCPDial(addr string, config *DialConfig) (*TCPConn, error) {
 		var netConn net.Conn
 		var err error
 		if config.UpstreamProxyUrl != "" {
-			netConn, err = proxiedTcpDial(addr, config, conn.dialResult)
+			netConn, err = proxiedTcpDial(addr, config)
 		} else {
-			netConn, err = tcpDial(addr, config, conn.dialResult)
+			netConn, err = tcpDial(addr, config)
 		}
 
 		// Mutex is necessary for referencing conn.isClosed and conn.Conn as
@@ -172,15 +172,16 @@ func interruptibleTCPDial(addr string, config *DialConfig) (*TCPConn, error) {
 
 // proxiedTcpDial wraps a tcpDial call in an upstreamproxy dial.
 func proxiedTcpDial(
-	addr string, config *DialConfig, dialResult chan error) (net.Conn, error) {
+	addr string, config *DialConfig) (net.Conn, error) {
 	dialer := func(network, addr string) (net.Conn, error) {
-		return tcpDial(addr, config, dialResult)
+		return tcpDial(addr, config)
 	}
+
 	upstreamDialer := upstreamproxy.NewProxyDialFunc(
 		&upstreamproxy.UpstreamProxyConfig{
 			ForwardDialFunc: dialer,
 			ProxyURIString:  config.UpstreamProxyUrl,
-			CustomHeaders:   config.UpstreamProxyCustomHeaders,
+			CustomHeaders:   config.CustomHeaders,
 		})
 	netConn, err := upstreamDialer("tcp", addr)
 	if _, ok := err.(*upstreamproxy.Error); ok {

+ 129 - 69
psiphon/TCPConn_bind.go

@@ -24,12 +24,14 @@ package psiphon
 import (
 	"errors"
 	"fmt"
+	"math/rand"
 	"net"
 	"os"
 	"strconv"
 	"syscall"
-	"time"
 
+	"github.com/Psiphon-Inc/goarista/monotime"
+	"github.com/Psiphon-Inc/goselect"
 	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common"
 )
 
@@ -37,19 +39,9 @@ import (
 //
 // To implement socket device binding, the lower-level syscall APIs are used.
 // The sequence of syscalls in this implementation are taken from:
-// https://code.google.com/p/go/issues/detail?id=6966
-func tcpDial(addr string, config *DialConfig, dialResult chan error) (net.Conn, error) {
-
-	// Like interruption, this timeout doesn't stop this connection goroutine,
-	// it just unblocks the calling interruptibleTCPDial.
-	if config.ConnectTimeout != 0 {
-		time.AfterFunc(config.ConnectTimeout, func() {
-			select {
-			case dialResult <- errors.New("connect timeout"):
-			default:
-			}
-		})
-	}
+// https://github.com/golang/go/issues/6966
+// (originally: https://code.google.com/p/go/issues/detail?id=6966)
+func tcpDial(addr string, config *DialConfig) (net.Conn, error) {
 
 	// Get the remote IP and port, resolving a domain name if necessary
 	host, strPort, err := net.SplitHostPort(addr)
@@ -68,70 +60,138 @@ func tcpDial(addr string, config *DialConfig, dialResult chan error) (net.Conn,
 		return nil, common.ContextError(errors.New("no IP address"))
 	}
 
-	// Select an IP at random from the list, so we're not always
-	// trying the same IP (when > 1) which may be blocked.
-	// TODO: retry all IPs until one connects? For now, this retry
-	// will happen on subsequent TCPDial calls, when a different IP
-	// is selected.
-	index, err := common.MakeSecureRandomInt(len(ipAddrs))
-	if err != nil {
-		return nil, common.ContextError(err)
-	}
+	// Iterate over a pseudorandom permutation of the destination
+	// IPs and attempt connections.
+	//
+	// Only continue retrying as long as the initial ConnectTimeout
+	// has not expired. Unlike net.Dial, we do not fractionalize the
+	// timeout, as the ConnectTimeout is generally intended to apply
+	// to a single attempt. So these serial retries are most useful
+	// in cases of immediate failure, such as "no route to host"
+	// errors when a host resolves to both IPv4 and IPv6 but IPv6
+	// addresses are unreachable.
+	// Retries at higher levels cover other cases: e.g.,
+	// Controller.remoteServerListFetcher will retry its entire
+	// operation and tcpDial will try a new permutation; or similarly,
+	// Controller.establishCandidateGenerator will retry a candidate
+	// tunnel server dials.
 
-	var ipv4 [4]byte
-	var ipv6 [16]byte
-	var domain int
-	ipAddr := ipAddrs[index]
-
-	// Get address type (IPv4 or IPv6)
-	if ipAddr != nil && ipAddr.To4() != nil {
-		copy(ipv4[:], ipAddr.To4())
-		domain = syscall.AF_INET
-	} else if ipAddr != nil && ipAddr.To16() != nil {
-		copy(ipv6[:], ipAddr.To16())
-		domain = syscall.AF_INET6
-	} else {
-		return nil, common.ContextError(fmt.Errorf("Got invalid IP address: %s", ipAddr.String()))
-	}
+	permutedIndexes := rand.Perm(len(ipAddrs))
 
-	// Create a socket and bind to device, when configured to do so
-	socketFd, err := syscall.Socket(domain, syscall.SOCK_STREAM, 0)
-	if err != nil {
-		return nil, common.ContextError(err)
+	lastErr := errors.New("unknown error")
+
+	var deadline monotime.Time
+	if config.ConnectTimeout != 0 {
+		deadline = monotime.Now().Add(config.ConnectTimeout)
 	}
 
-	if config.DeviceBinder != nil {
-		// WARNING: this potentially violates the direction to not call into
-		// external components after the Controller may have been stopped.
-		// TODO: rework DeviceBinder as an internal 'service' which can trap
-		// external calls when they should not be made?
-		err = config.DeviceBinder.BindToDevice(socketFd)
+	for iteration, index := range permutedIndexes {
+
+		if iteration > 0 && deadline != 0 && monotime.Now().After(deadline) {
+			// lastErr should be set by the previous iteration
+			break
+		}
+
+		// Get address type (IPv4 or IPv6)
+
+		var ipv4 [4]byte
+		var ipv6 [16]byte
+		var domain int
+		var sockAddr syscall.Sockaddr
+
+		ipAddr := ipAddrs[index]
+		if ipAddr != nil && ipAddr.To4() != nil {
+			copy(ipv4[:], ipAddr.To4())
+			domain = syscall.AF_INET
+		} else if ipAddr != nil && ipAddr.To16() != nil {
+			copy(ipv6[:], ipAddr.To16())
+			domain = syscall.AF_INET6
+		} else {
+			lastErr = common.ContextError(fmt.Errorf("Got invalid IP address: %s", ipAddr.String()))
+			continue
+		}
+		if domain == syscall.AF_INET {
+			sockAddr = &syscall.SockaddrInet4{Addr: ipv4, Port: port}
+		} else if domain == syscall.AF_INET6 {
+			sockAddr = &syscall.SockaddrInet6{Addr: ipv6, Port: port}
+		}
+
+		// Create a socket and bind to device, when configured to do so
+
+		socketFd, err := syscall.Socket(domain, syscall.SOCK_STREAM, 0)
+		if err != nil {
+			lastErr = common.ContextError(err)
+			continue
+		}
+
+		if config.DeviceBinder != nil {
+			// WARNING: this potentially violates the direction to not call into
+			// external components after the Controller may have been stopped.
+			// TODO: rework DeviceBinder as an internal 'service' which can trap
+			// external calls when they should not be made?
+			err = config.DeviceBinder.BindToDevice(socketFd)
+			if err != nil {
+				syscall.Close(socketFd)
+				lastErr = common.ContextError(fmt.Errorf("BindToDevice failed: %s", err))
+				continue
+			}
+		}
+
+		// Connect socket to the server's IP address
+
+		err = syscall.SetNonblock(socketFd, true)
 		if err != nil {
 			syscall.Close(socketFd)
-			return nil, common.ContextError(fmt.Errorf("BindToDevice failed: %s", err))
+			lastErr = common.ContextError(err)
+			continue
 		}
-	}
 
-	// Connect socket to the server's IP address
-	if domain == syscall.AF_INET {
-		sockAddr := syscall.SockaddrInet4{Addr: ipv4, Port: port}
-		err = syscall.Connect(socketFd, &sockAddr)
-	} else if domain == syscall.AF_INET6 {
-		sockAddr := syscall.SockaddrInet6{Addr: ipv6, Port: port}
-		err = syscall.Connect(socketFd, &sockAddr)
-	}
-	if err != nil {
-		syscall.Close(socketFd)
-		return nil, common.ContextError(err)
-	}
+		err = syscall.Connect(socketFd, sockAddr)
+		if err != nil {
+			if errno, ok := err.(syscall.Errno); !ok || errno != syscall.EINPROGRESS {
+				syscall.Close(socketFd)
+				lastErr = common.ContextError(err)
+				continue
+			}
+		}
 
-	// Convert the socket fd to a net.Conn
-	file := os.NewFile(uintptr(socketFd), "")
-	netConn, err := net.FileConn(file) // net.FileConn() dups socketFd
-	file.Close()                       // file.Close() closes socketFd
-	if err != nil {
-		return nil, common.ContextError(err)
+		fdset := &goselect.FDSet{}
+		fdset.Set(uintptr(socketFd))
+
+		timeout := config.ConnectTimeout
+		if config.ConnectTimeout == 0 {
+			timeout = -1
+		}
+
+		err = goselect.Select(socketFd+1, nil, fdset, nil, timeout)
+		if err != nil {
+			lastErr = common.ContextError(err)
+			continue
+		}
+		if !fdset.IsSet(uintptr(socketFd)) {
+			lastErr = common.ContextError(errors.New("file descriptor not set"))
+			continue
+		}
+
+		err = syscall.SetNonblock(socketFd, false)
+		if err != nil {
+			syscall.Close(socketFd)
+			lastErr = common.ContextError(err)
+			continue
+		}
+
+		// Convert the socket fd to a net.Conn
+
+		file := os.NewFile(uintptr(socketFd), "")
+		netConn, err := net.FileConn(file) // net.FileConn() dups socketFd
+		file.Close()                       // file.Close() closes socketFd
+		if err != nil {
+			lastErr = common.ContextError(err)
+			continue
+		}
+
+		return netConn, nil
 	}
 
-	return netConn, nil
+	return nil, lastErr
 }

+ 1 - 1
psiphon/TCPConn_nobind.go

@@ -29,7 +29,7 @@ import (
 )
 
 // tcpDial is the platform-specific part of interruptibleTCPDial
-func tcpDial(addr string, config *DialConfig, dialResult chan error) (net.Conn, error) {
+func tcpDial(addr string, config *DialConfig) (net.Conn, error) {
 
 	if config.DeviceBinder != nil {
 		return nil, common.ContextError(errors.New("psiphon.interruptibleTCPDial with DeviceBinder not supported"))

+ 102 - 75
psiphon/common/osl/osl.go

@@ -43,6 +43,7 @@ import (
 	"net/url"
 	"path"
 	"path/filepath"
+	"strings"
 	"sync"
 	"sync/atomic"
 	"time"
@@ -535,7 +536,7 @@ func (state *ClientSeedState) issueSLOKs() {
 			slok, ok := state.scheme.derivedSLOKCache[*ref]
 			state.scheme.derivedSLOKCacheMutex.RUnlock()
 			if !ok {
-				slok = deriveSLOK(state.scheme, ref)
+				slok = state.scheme.deriveSLOK(ref)
 				state.scheme.derivedSLOKCacheMutex.Lock()
 				state.scheme.derivedSLOKCache[*ref] = slok
 				state.scheme.derivedSLOKCacheMutex.Unlock()
@@ -569,31 +570,6 @@ func getSLOKTime(seedPeriodNanoseconds int64) int64 {
 	return time.Now().UTC().Truncate(time.Duration(seedPeriodNanoseconds)).UnixNano()
 }
 
-// deriveSLOK produces SLOK secret keys and IDs using HKDF-Expand
-// defined in https://tools.ietf.org/html/rfc5869.
-func deriveSLOK(
-	scheme *Scheme, ref *slokReference) *SLOK {
-
-	timeBytes := make([]byte, 8)
-	binary.LittleEndian.PutUint64(timeBytes, uint64(ref.Time.UnixNano()))
-
-	key := deriveKeyHKDF(
-		scheme.MasterKey,
-		[]byte(ref.PropagationChannelID),
-		[]byte(ref.SeedSpecID),
-		timeBytes)
-
-	// TODO: is ID derivation cryptographically sound?
-	id := deriveKeyHKDF(
-		scheme.MasterKey,
-		key)
-
-	return &SLOK{
-		ID:  id,
-		Key: key,
-	}
-}
-
 // GetSeedPayload issues any pending SLOKs and returns the accumulated
 // SLOKs for a given client. psiphond will calls this when it receives
 // signalIssueSLOKs which is the trigger to check for new SLOKs.
@@ -631,6 +607,44 @@ func (state *ClientSeedState) ClearSeedPayload() {
 	state.payloadSLOKs = nil
 }
 
+// deriveSLOK produces SLOK secret keys and IDs using HKDF-Expand
+// defined in https://tools.ietf.org/html/rfc5869.
+func (scheme *Scheme) deriveSLOK(ref *slokReference) *SLOK {
+
+	timeBytes := make([]byte, 8)
+	binary.LittleEndian.PutUint64(timeBytes, uint64(ref.Time.UnixNano()))
+
+	key := deriveKeyHKDF(
+		scheme.MasterKey,
+		[]byte(ref.PropagationChannelID),
+		[]byte(ref.SeedSpecID),
+		timeBytes)
+
+	// TODO: is ID derivation cryptographically sound?
+	id := deriveKeyHKDF(
+		scheme.MasterKey,
+		key)
+
+	return &SLOK{
+		ID:  id,
+		Key: key,
+	}
+}
+
+// GetOSLDuration returns the total time duration of an OSL,
+// which is a function of the scheme's SeedPeriodNanoSeconds,
+// the duration of a single SLOK, and the scheme's SeedPeriodKeySplits,
+// the number of SLOKs associated with an OSL.
+func (scheme *Scheme) GetOSLDuration() time.Duration {
+	slokTimePeriodsPerOSL := 1
+	for _, keySplit := range scheme.SeedPeriodKeySplits {
+		slokTimePeriodsPerOSL *= keySplit.Total
+	}
+
+	return time.Duration(
+		int64(slokTimePeriodsPerOSL) * scheme.SeedPeriodNanoseconds)
+}
+
 // PaveFile describes an OSL data file to be paved to an out-of-band
 // distribution drop site. There are two types of files: a registry,
 // which describes how to assemble keys for OSLs, and the encrypted
@@ -677,6 +691,16 @@ type KeyShares struct {
 	KeyShares   []*KeyShares
 }
 
+type PaveLogInfo struct {
+	FileName             string
+	SchemeIndex          int
+	PropagationChannelID string
+	OSLID                string
+	OSLTime              time.Time
+	OSLDuration          time.Duration
+	ServerEntryCount     int
+}
+
 // Pave creates the full set of OSL files, for all schemes in the
 // configuration, to be dropped in an out-of-band distribution site.
 // Only OSLs for the propagation channel ID associated with the
@@ -686,14 +710,14 @@ type KeyShares struct {
 // the client functions GetRegistryURL and GetOSLFileURL.
 //
 // Pave returns a pave file for the entire registry of all OSLs from
-// epoch. It only returns pave files for OSLs referenced in
-// paveServerEntries. paveServerEntries is a list of maps, one for each
-// scheme, from the first SLOK time period identifying an OSL to a
-// payload to encrypt and pave.
-// The registry file spec MD5 checksum values are populated only for
-// OSLs referenced in paveServerEntries. To ensure a registry is fully
-// populated with hashes for skipping redownloading, all OSLs should
-// be paved.
+// epoch to endTime, and a pave file for each OSL. paveServerEntries is
+// a map from hex-encoded OSL IDs to server entries to pave into that OSL.
+// When entries are found, OSL will contain those entries, newline
+// seperated. Otherwise the OSL will still be issued, but be empty.
+//
+// As OSLs outside the epoch-endTime range will no longer appear in
+// the registry, Pave is intended to be used to create the full set
+// of OSLs for a distribution site; i.e., not incrementally.
 //
 // Automation is responsible for consistently distributing server entries
 // to OSLs in the case where OSLs are repaved in subsequent calls.
@@ -702,8 +726,8 @@ func (config *Config) Pave(
 	propagationChannelID string,
 	signingPublicKey string,
 	signingPrivateKey string,
-	paveServerEntries []map[time.Time]string,
-	logCallback func(int, time.Time, string)) ([]*PaveFile, error) {
+	paveServerEntries map[string][]string,
+	logCallback func(*PaveLogInfo)) ([]*PaveFile, error) {
 
 	config.ReloadableFile.RLock()
 	defer config.ReloadableFile.RUnlock()
@@ -712,19 +736,13 @@ func (config *Config) Pave(
 
 	registry := &Registry{}
 
-	if len(paveServerEntries) != len(config.Schemes) {
-		return nil, common.ContextError(errors.New("invalid paveServerEntries"))
-	}
-
 	for schemeIndex, scheme := range config.Schemes {
+		if common.Contains(scheme.PropagationChannelIDs, propagationChannelID) {
 
-		slokTimePeriodsPerOSL := 1
-		for _, keySplit := range scheme.SeedPeriodKeySplits {
-			slokTimePeriodsPerOSL *= keySplit.Total
-		}
+			oslDuration := scheme.GetOSLDuration()
 
-		if common.Contains(scheme.PropagationChannelIDs, propagationChannelID) {
 			oslTime := scheme.epoch
+
 			for !oslTime.After(endTime) {
 
 				firstSLOKTime := oslTime
@@ -734,43 +752,52 @@ func (config *Config) Pave(
 					return nil, common.ContextError(err)
 				}
 
-				registry.FileSpecs = append(registry.FileSpecs, fileSpec)
+				hexEncodedOSLID := hex.EncodeToString(fileSpec.ID)
 
-				serverEntries, ok := paveServerEntries[schemeIndex][oslTime]
-				if ok {
+				registry.FileSpecs = append(registry.FileSpecs, fileSpec)
 
-					signedServerEntries, err := common.WriteAuthenticatedDataPackage(
-						serverEntries,
-						signingPublicKey,
-						signingPrivateKey)
-					if err != nil {
-						return nil, common.ContextError(err)
-					}
+				serverEntryCount := len(paveServerEntries[hexEncodedOSLID])
 
-					boxedServerEntries, err := box(fileKey, common.Compress(signedServerEntries))
-					if err != nil {
-						return nil, common.ContextError(err)
-					}
+				// serverEntries will be "" when nothing is found in paveServerEntries
+				serverEntries := strings.Join(paveServerEntries[hexEncodedOSLID], "\n")
 
-					md5sum := md5.Sum(boxedServerEntries)
-					fileSpec.MD5Sum = md5sum[:]
+				signedServerEntries, err := common.WriteAuthenticatedDataPackage(
+					serverEntries,
+					signingPublicKey,
+					signingPrivateKey)
+				if err != nil {
+					return nil, common.ContextError(err)
+				}
 
-					fileName := fmt.Sprintf(
-						OSL_FILENAME_FORMAT, hex.EncodeToString(fileSpec.ID))
+				boxedServerEntries, err := box(fileKey, common.Compress(signedServerEntries))
+				if err != nil {
+					return nil, common.ContextError(err)
+				}
 
-					paveFiles = append(paveFiles, &PaveFile{
-						Name:     fileName,
-						Contents: boxedServerEntries,
+				md5sum := md5.Sum(boxedServerEntries)
+				fileSpec.MD5Sum = md5sum[:]
+
+				fileName := fmt.Sprintf(
+					OSL_FILENAME_FORMAT, hexEncodedOSLID)
+
+				paveFiles = append(paveFiles, &PaveFile{
+					Name:     fileName,
+					Contents: boxedServerEntries,
+				})
+
+				if logCallback != nil {
+					logCallback(&PaveLogInfo{
+						FileName:             fileName,
+						SchemeIndex:          schemeIndex,
+						PropagationChannelID: propagationChannelID,
+						OSLID:                hexEncodedOSLID,
+						OSLTime:              oslTime,
+						OSLDuration:          oslDuration,
+						ServerEntryCount:     serverEntryCount,
 					})
-
-					if logCallback != nil {
-						logCallback(schemeIndex, oslTime, fileName)
-					}
 				}
 
-				oslTime = oslTime.Add(
-					time.Duration(
-						int64(slokTimePeriodsPerOSL) * scheme.SeedPeriodNanoseconds))
+				oslTime = oslTime.Add(oslDuration)
 			}
 		}
 	}
@@ -811,7 +838,7 @@ func makeOSLFileSpec(
 		SeedSpecID:           string(scheme.SeedSpecs[0].ID),
 		Time:                 firstSLOKTime,
 	}
-	firstSLOK := deriveSLOK(scheme, ref)
+	firstSLOK := scheme.deriveSLOK(ref)
 	oslID := firstSLOK.ID
 
 	fileKey, err := common.MakeSecureRandomBytes(KEY_LENGTH_BYTES)
@@ -922,7 +949,7 @@ func divideKeyWithSeedSpecSLOKs(
 			SeedSpecID:           string(seedSpec.ID),
 			Time:                 *nextSLOKTime,
 		}
-		slok := deriveSLOK(scheme, ref)
+		slok := scheme.deriveSLOK(ref)
 
 		boxedShare, err := box(slok.Key, shares[index])
 		if err != nil {

+ 10 - 16
psiphon/common/osl/osl_test.go

@@ -21,6 +21,7 @@ package osl
 
 import (
 	"encoding/base64"
+	"encoding/hex"
 	"fmt"
 	"net"
 	"testing"
@@ -317,31 +318,25 @@ func TestOSL(t *testing.T) {
 
 			// Dummy server entry payloads will be the OSL ID, which the following
 			// tests use to verify that the correct OSL file decrypts successfully.
-			paveServerEntries := make([]map[time.Time]string, len(config.Schemes))
-			for schemeIndex, scheme := range config.Schemes {
+			paveServerEntries := make(map[string][]string)
+			for _, scheme := range config.Schemes {
 
-				paveServerEntries[schemeIndex] = make(map[time.Time]string)
-
-				slokTimePeriodsPerOSL := 1
-				for _, keySplit := range scheme.SeedPeriodKeySplits {
-					slokTimePeriodsPerOSL *= keySplit.Total
-				}
+				oslDuration := scheme.GetOSLDuration()
 
 				oslTime := scheme.epoch
 				for oslTime.Before(endTime) {
+
 					firstSLOKRef := &slokReference{
 						PropagationChannelID: propagationChannelID,
 						SeedSpecID:           string(scheme.SeedSpecs[0].ID),
 						Time:                 oslTime,
 					}
-					firstSLOK := deriveSLOK(scheme, firstSLOKRef)
+					firstSLOK := scheme.deriveSLOK(firstSLOKRef)
 					oslID := firstSLOK.ID
-					paveServerEntries[schemeIndex][oslTime] =
-						base64.StdEncoding.EncodeToString(oslID)
+					paveServerEntries[hex.EncodeToString(oslID)] =
+						[]string{base64.StdEncoding.EncodeToString(oslID)}
 
-					oslTime = oslTime.Add(
-						time.Duration(
-							int64(slokTimePeriodsPerOSL) * scheme.SeedPeriodNanoseconds))
+					oslTime = oslTime.Add(oslDuration)
 				}
 			}
 
@@ -492,8 +487,7 @@ func TestOSL(t *testing.T) {
 			for _, timePeriod := range testCase.issueSLOKTimePeriods {
 				for _, seedSpecIndex := range testCase.issueSLOKSeedSpecIndexes {
 
-					slok := deriveSLOK(
-						testCase.scheme,
+					slok := testCase.scheme.deriveSLOK(
 						&slokReference{
 							PropagationChannelID: testCase.propagationChannelID,
 							SeedSpecID:           string(testCase.scheme.SeedSpecs[seedSpecIndex].ID),

+ 127 - 47
psiphon/common/osl/paver/main.go

@@ -22,6 +22,7 @@ package main
 import (
 	"crypto/x509"
 	"encoding/base64"
+	"encoding/json"
 	"encoding/pem"
 	"flag"
 	"fmt"
@@ -36,22 +37,33 @@ import (
 func main() {
 
 	var configFilename string
-	flag.StringVar(&configFilename, "config", "", "OSL configuration file")
+	flag.StringVar(&configFilename, "config", "", "OSL configuration filename")
 
 	var offset time.Duration
-	flag.DurationVar(&offset, "offset", 0, "pave OSL start time (offset from now)")
+	flag.DurationVar(
+		&offset, "offset", 0,
+		"pave OSL start time (offset from now); default, 0, selects earliest epoch")
 
 	var period time.Duration
-	flag.DurationVar(&period, "period", 0, "pave OSL total period (starting from offset)")
+	flag.DurationVar(
+		&period, "period", 0,
+		"pave OSL total period (starting from offset); default, 0, selects at least one OSL period from now for all schemes")
 
 	var signingKeyPairFilename string
-	flag.StringVar(&signingKeyPairFilename, "key", "", "signing public key pair")
+	flag.StringVar(&signingKeyPairFilename, "key", "", "signing public key pair filename")
+
+	var payloadFilename string
+	flag.StringVar(&payloadFilename, "payload", "", "server entries to pave into OSLs")
 
 	var destinationDirectory string
-	flag.StringVar(&destinationDirectory, "output", "", "destination directory for output files")
+	flag.StringVar(
+		&destinationDirectory, "output", "",
+		"destination directory for output files; when omitted, no files are written (dry run mode)")
 
 	flag.Parse()
 
+	// load config
+
 	configJSON, err := ioutil.ReadFile(configFilename)
 	if err != nil {
 		fmt.Printf("failed loading configuration file: %s\n", err)
@@ -64,6 +76,8 @@ func main() {
 		os.Exit(1)
 	}
 
+	// load key pair
+
 	keyPairPEM, err := ioutil.ReadFile(signingKeyPairFilename)
 	if err != nil {
 		fmt.Printf("failed loading signing public key pair file: %s\n", err)
@@ -97,47 +111,84 @@ func main() {
 	signingPublicKey := base64.StdEncoding.EncodeToString(publicKeyBytes)
 	signingPrivateKey := base64.StdEncoding.EncodeToString(privateKeyBytes)
 
-	paveTime := time.Now().UTC()
-	startTime := paveTime.Add(offset)
-	endTime := startTime.Add(period)
-
-	schemeOSLTimePeriods := make(map[int]time.Duration)
-	for index, scheme := range config.Schemes {
-		slokTimePeriodsPerOSL := 1
-		for _, keySplit := range scheme.SeedPeriodKeySplits {
-			slokTimePeriodsPerOSL *= keySplit.Total
+	// load payload
+
+	paveServerEntries := make(map[string][]string)
+
+	pavedPayloadOSLID := make(map[string]bool)
+
+	if payloadFilename != "" {
+		payloadJSON, err := ioutil.ReadFile(payloadFilename)
+		if err != nil {
+			fmt.Printf("failed loading payload file: %s\n", err)
+			os.Exit(1)
 		}
-		schemeOSLTimePeriods[index] =
-			time.Duration(scheme.SeedPeriodNanoseconds * int64(slokTimePeriodsPerOSL))
-	}
 
-	allPropagationChannelIDs := make(map[string][]int)
-	for index, scheme := range config.Schemes {
-		for _, propagationChannelID := range scheme.PropagationChannelIDs {
-			allPropagationChannelIDs[propagationChannelID] =
-				append(allPropagationChannelIDs[propagationChannelID], index)
+		var payload []*struct {
+			OSLIDs      []string
+			ServerEntry string
 		}
-	}
 
-	for propagationChannelID, schemeIndexes := range allPropagationChannelIDs {
+		err = json.Unmarshal(payloadJSON, &payload)
+		if err != nil {
+			fmt.Printf("failed unmarshaling payload file: %s\n", err)
+			os.Exit(1)
+		}
 
-		paveServerEntries := make([]map[time.Time]string, len(config.Schemes))
+		for _, item := range payload {
+			for _, oslID := range item.OSLIDs {
+				paveServerEntries[oslID] = append(
+					paveServerEntries[oslID], item.ServerEntry)
+				pavedPayloadOSLID[oslID] = false
+			}
+		}
+	}
 
-		for _, index := range schemeIndexes {
+	// determine pave time range
 
-			paveServerEntries[index] = make(map[time.Time]string)
+	paveTime := time.Now().UTC()
 
-			oslTime, _ := time.Parse(time.RFC3339, config.Schemes[index].Epoch)
-			for !oslTime.After(endTime) {
-				if !oslTime.Before(startTime) {
-					paveServerEntries[index][oslTime] = ""
-				}
-				oslTime = oslTime.Add(schemeOSLTimePeriods[index])
+	var startTime, endTime time.Time
+
+	if offset != 0 {
+		startTime = paveTime.Add(offset)
+	} else {
+		// Default to the earliest scheme epoch.
+		startTime = paveTime
+		for _, scheme := range config.Schemes {
+			epoch, _ := time.Parse(time.RFC3339, scheme.Epoch)
+			if epoch.Before(startTime) {
+				startTime = epoch
 			}
+		}
+	}
 
-			fmt.Printf("Paving propagation channel %s, scheme #%d, [%s - %s], %s\n",
-				propagationChannelID, index, startTime, endTime, schemeOSLTimePeriods[index])
+	if period != 0 {
+		endTime = startTime.Add(period)
+	} else {
+		// Default to at least one OSL period after "now",
+		// considering all schemes.
+		endTime = paveTime
+		for _, scheme := range config.Schemes {
+			oslDuration := scheme.GetOSLDuration()
+			if endTime.Add(oslDuration).After(endTime) {
+				endTime = endTime.Add(oslDuration)
+			}
 		}
+	}
+
+	// build list of all participating propagation channel IDs
+
+	allPropagationChannelIDs := make(map[string]bool)
+	for _, scheme := range config.Schemes {
+		for _, propagationChannelID := range scheme.PropagationChannelIDs {
+			allPropagationChannelIDs[propagationChannelID] = true
+		}
+	}
+
+	// pave a directory for each propagation channel
+
+	for propagationChannelID, _ := range allPropagationChannelIDs {
 
 		paveFiles, err := config.Pave(
 			endTime,
@@ -145,29 +196,58 @@ func main() {
 			signingPublicKey,
 			signingPrivateKey,
 			paveServerEntries,
-			func(schemeIndex int, oslTime time.Time, fileName string) {
-				fmt.Printf("\tPaved scheme %d %s: %s\n", schemeIndex, oslTime, fileName)
+			func(logInfo *osl.PaveLogInfo) {
+				pavedPayloadOSLID[logInfo.OSLID] = true
+				fmt.Printf(
+					"paved %s: scheme %d, propagation channel ID %s, "+
+						"OSL time %s, OSL duration %s, server entries: %d\n",
+					logInfo.FileName,
+					logInfo.SchemeIndex,
+					logInfo.PropagationChannelID,
+					logInfo.OSLTime,
+					logInfo.OSLDuration,
+					logInfo.ServerEntryCount)
 			})
 		if err != nil {
 			fmt.Printf("failed paving: %s\n", err)
 			os.Exit(1)
 		}
 
-		directory := filepath.Join(destinationDirectory, propagationChannelID)
+		if destinationDirectory != "" {
 
-		err = os.MkdirAll(directory, 0755)
-		if err != nil {
-			fmt.Printf("failed creating output directory: %s\n", err)
-			os.Exit(1)
-		}
+			directory := filepath.Join(destinationDirectory, propagationChannelID)
 
-		for _, paveFile := range paveFiles {
-			filename := filepath.Join(directory, paveFile.Name)
-			err = ioutil.WriteFile(filename, paveFile.Contents, 0755)
+			err = os.MkdirAll(directory, 0755)
 			if err != nil {
-				fmt.Printf("error writing output file: %s\n", err)
+				fmt.Printf("failed creating output directory: %s\n", err)
 				os.Exit(1)
 			}
+
+			for _, paveFile := range paveFiles {
+				filename := filepath.Join(directory, paveFile.Name)
+				err = ioutil.WriteFile(filename, paveFile.Contents, 0755)
+				if err != nil {
+					fmt.Printf("error writing output file: %s\n", err)
+					os.Exit(1)
+				}
+			}
+		}
+	}
+
+	// fail if payload contains OSL IDs not in the config and time range
+
+	unknown := false
+	for oslID, paved := range pavedPayloadOSLID {
+		if !paved {
+			fmt.Printf(
+				"ignored %d server entries for unknown OSL ID: %s\n",
+				len(paveServerEntries[oslID]),
+				oslID)
+			unknown = true
 		}
 	}
+	if unknown {
+		fmt.Printf("payload contains unknown OSL IDs\n")
+		os.Exit(1)
+	}
 }

+ 66 - 0
psiphon/common/pluginProtocol.go

@@ -0,0 +1,66 @@
+/*
+ * Copyright (c) 2017, 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 common
+
+import (
+	"io"
+	"net"
+	"sync/atomic"
+)
+
+var registeredPluginProtocolDialer atomic.Value
+
+// PluginProtocolNetDialer is a base network dialer that's used
+// by PluginProtocolDialer to make its IP network connections. This
+// is used, for example, to create TCPConns as the base TCP
+// connections used by the plugin protocol.
+type PluginProtocolNetDialer func(network, addr string) (net.Conn, error)
+
+// PluginProtocolDialer creates a connection to addr over a
+// plugin protocol. It uses netDialer to create its base network
+// connection(s) and sends its log messages to loggerOutput.
+// PluginProtocolDialer returns true if it attempts to create
+// a connection, or false if it decides not to attempt a connection.
+type PluginProtocolDialer func(
+	loggerOutput io.Writer,
+	netDialer PluginProtocolNetDialer,
+	addr string) (
+	bool, net.Conn, error)
+
+// RegisterPluginProtocol sets the current plugin protocol
+// dialer.
+func RegisterPluginProtocol(protcolDialer PluginProtocolDialer) {
+	registeredPluginProtocolDialer.Store(protcolDialer)
+}
+
+// DialPluginProtocol uses the current plugin protocol dialer,
+// if set, to connect to addr over the plugin protocol.
+func DialPluginProtocol(
+	loggerOutput io.Writer,
+	netDialer PluginProtocolNetDialer,
+	addr string) (
+	bool, net.Conn, error) {
+
+	dialer := registeredPluginProtocolDialer.Load()
+	if dialer != nil {
+		return dialer.(PluginProtocolDialer)(loggerOutput, netDialer, addr)
+	}
+	return false, nil, nil
+}

+ 67 - 0
psiphon/common/userAgentPicker.go

@@ -0,0 +1,67 @@
+/*
+ * Copyright (c) 2017, 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 common
+
+import (
+	"net/http"
+	"sync/atomic"
+)
+
+var registeredUserAgentPicker atomic.Value
+
+func RegisterUserAgentPicker(picker func() string) {
+	registeredUserAgentPicker.Store(picker)
+}
+
+func pickUserAgent() string {
+	picker := registeredUserAgentPicker.Load()
+	if picker != nil {
+		return picker.(func() string)()
+	}
+	return ""
+}
+
+// UserAgentIfUnset returns an http.Header object and a boolean
+// representing whether or not its User-Agent header was modified.
+// Any modifications are made to a copy of the original header map
+func UserAgentIfUnset(h http.Header) (http.Header, bool) {
+	var dialHeaders http.Header
+
+	if _, ok := h["User-Agent"]; !ok {
+		dialHeaders = make(map[string][]string)
+
+		if h != nil {
+			for k, v := range h {
+				dialHeaders[k] = make([]string, len(v))
+				copy(dialHeaders[k], v)
+			}
+		}
+
+		if FlipCoin() {
+			dialHeaders.Set("User-Agent", pickUserAgent())
+		} else {
+			dialHeaders.Set("User-Agent", "")
+		}
+
+		return dialHeaders, true
+	}
+
+	return h, false
+}

+ 26 - 6
psiphon/config.go

@@ -72,7 +72,7 @@ const (
 	PSIPHON_API_CLIENT_VERIFICATION_REQUEST_RETRY_PERIOD = 5 * time.Second
 	PSIPHON_API_CLIENT_VERIFICATION_REQUEST_MAX_RETRIES  = 10
 	FETCH_ROUTES_TIMEOUT_SECONDS                         = 60
-	DOWNLOAD_UPGRADE_TIMEOUT                             = 15 * time.Minute
+	DOWNLOAD_UPGRADE_TIMEOUT_SECONDS                     = 60
 	DOWNLOAD_UPGRADE_RETRY_PERIOD_SECONDS                = 30
 	DOWNLOAD_UPGRADE_STALE_PERIOD                        = 6 * time.Hour
 	IMPAIRED_PROTOCOL_CLASSIFICATION_DURATION            = 2 * time.Minute
@@ -238,9 +238,13 @@ type Config struct {
 	// https://github.com/Psiphon-Labs/psiphon-tunnel-core/tree/master/psiphon/upstreamproxy
 	UpstreamProxyUrl string
 
-	// UpstreamProxyCustomHeaders is a set of additional arbitrary HTTP headers that are
-	// added to all requests made through the upstream proxy specified by UpstreamProxyUrl
-	// NOTE: Only HTTP(s) proxies use this if specified
+	// CustomHeaders is a set of additional arbitrary HTTP headers that are
+	// added to all plaintext HTTP requests and requests made through an HTTP
+	// upstream proxy when specified by UpstreamProxyUrl.
+	CustomHeaders http.Header
+
+	// Deprecated: Use CustomHeaders. When CustomHeaders is
+	// not nil, this parameter is ignored.
 	UpstreamProxyCustomHeaders http.Header
 
 	// NetworkConnectivityChecker is an interface that enables the core tunnel to call
@@ -408,7 +412,7 @@ type Config struct {
 	TunnelSshKeepAlivePeriodicTimeoutSeconds *int
 
 	// FetchRemoteServerListTimeoutSeconds specifies a timeout value for remote server list
-	// HTTP request. Zero value means that request will not time out.
+	// HTTP requests. Zero value means that request will not time out.
 	// If omitted, the default value is FETCH_REMOTE_SERVER_LIST_TIMEOUT_SECONDS.
 	FetchRemoteServerListTimeoutSeconds *int
 
@@ -421,10 +425,15 @@ type Config struct {
 	PsiphonApiServerTimeoutSeconds *int
 
 	// FetchRoutesTimeoutSeconds specifies a timeout value for split tunnel routes
-	// HTTP request. Zero value means that request will not time out.
+	// HTTP requests. Zero value means that request will not time out.
 	// If omitted, the default value is FETCH_ROUTES_TIMEOUT_SECONDS.
 	FetchRoutesTimeoutSeconds *int
 
+	// UpgradeDownloadTimeoutSeconds specifies a timeout value for upgrade download
+	// HTTP requests. Zero value means that request will not time out.
+	// If omitted, the default value is DOWNLOAD_UPGRADE_TIMEOUT_SECONDS.
+	DownloadUpgradeTimeoutSeconds *int
+
 	// HttpProxyOriginServerTimeoutSeconds specifies an HTTP response header timeout
 	// value in various HTTP relays found in httpProxy.
 	// Zero value means that request will not time out.
@@ -537,6 +546,12 @@ func LoadConfig(configJson []byte) (*Config, error) {
 		config.TunnelPoolSize = TUNNEL_POOL_SIZE
 	}
 
+	if config.CustomHeaders == nil {
+		// Promote legacy parameter
+		config.CustomHeaders = config.UpstreamProxyCustomHeaders
+		config.UpstreamProxyCustomHeaders = nil
+	}
+
 	if config.NetworkConnectivityChecker != nil {
 		return nil, common.ContextError(
 			errors.New("NetworkConnectivityChecker interface must be set at runtime"))
@@ -665,6 +680,11 @@ func LoadConfig(configJson []byte) (*Config, error) {
 		config.FetchRoutesTimeoutSeconds = &defaultFetchRoutesTimeoutSeconds
 	}
 
+	if config.DownloadUpgradeTimeoutSeconds == nil {
+		defaultDownloadUpgradeTimeoutSeconds := DOWNLOAD_UPGRADE_TIMEOUT_SECONDS
+		config.DownloadUpgradeTimeoutSeconds = &defaultDownloadUpgradeTimeoutSeconds
+	}
+
 	if config.HttpProxyOriginServerTimeoutSeconds == nil {
 		defaultHttpProxyOriginServerTimeoutSeconds := HTTP_PROXY_ORIGIN_SERVER_TIMEOUT_SECONDS
 		config.HttpProxyOriginServerTimeoutSeconds = &defaultHttpProxyOriginServerTimeoutSeconds

+ 1 - 1
psiphon/controller.go

@@ -96,7 +96,7 @@ func NewController(config *Config) (controller *Controller, err error) {
 	untunneledPendingConns := new(common.Conns)
 	untunneledDialConfig := &DialConfig{
 		UpstreamProxyUrl:              config.UpstreamProxyUrl,
-		UpstreamProxyCustomHeaders:    config.UpstreamProxyCustomHeaders,
+		CustomHeaders:                 config.CustomHeaders,
 		PendingConns:                  untunneledPendingConns,
 		DeviceBinder:                  config.DeviceBinder,
 		DnsServerGetter:               config.DnsServerGetter,

+ 37 - 24
psiphon/controller_test.go

@@ -51,7 +51,7 @@ func TestMain(m *testing.M) {
 	var err error
 	testDataDirName, err = ioutil.TempDir("", "psiphon-controller-test")
 	if err != nil {
-		fmt.Printf("TempDir failed: %s", err)
+		fmt.Printf("TempDir failed: %s\n", err)
 		os.Exit(1)
 	}
 	defer os.RemoveAll(testDataDirName)
@@ -448,8 +448,6 @@ func controllerRun(t *testing.T, runConfig *controllerRunConfig) {
 	json.Unmarshal(configJSON, &modifyConfig)
 	modifyConfig["DataStoreDirectory"] = testDataDirName
 	modifyConfig["RemoteServerListDownloadFilename"] = filepath.Join(testDataDirName, "server_list_compressed")
-	modifyConfig["ObfuscatedServerListDownloadDirectory"] = testDataDirName
-	modifyConfig["ObfuscatedServerListRootURL"] = "http://127.0.0.1/osl" // will fail
 	modifyConfig["UpgradeDownloadFilename"] = filepath.Join(testDataDirName, "upgrade")
 	configJSON, _ = json.Marshal(modifyConfig)
 
@@ -486,7 +484,7 @@ func controllerRun(t *testing.T, runConfig *controllerRunConfig) {
 		config.UpstreamProxyUrl = disruptorProxyURL
 	} else if runConfig.useUpstreamProxy {
 		config.UpstreamProxyUrl = upstreamProxyURL
-		config.UpstreamProxyCustomHeaders = upstreamProxyCustomHeaders
+		config.CustomHeaders = upstreamProxyCustomHeaders
 	}
 
 	if runConfig.transformHostNames {
@@ -812,12 +810,11 @@ func (TestHostNameTransformer) TransformHostName(string) (string, bool) {
 
 func fetchAndVerifyWebsite(t *testing.T, httpProxyPort int) {
 
-	testUrl := "https://raw.githubusercontent.com/Psiphon-Labs/psiphon-tunnel-core/master/LICENSE"
+	testUrl := "https://psiphon.ca"
 	roundTripTimeout := 30 * time.Second
-	expectedResponsePrefix := "                    GNU GENERAL PUBLIC LICENSE"
-	expectedResponseSize := 35148
+	expectedResponseContains := "Psiphon"
 	checkResponse := func(responseBody string) bool {
-		return strings.HasPrefix(responseBody, expectedResponsePrefix) && len(responseBody) == expectedResponseSize
+		return strings.Contains(responseBody, expectedResponseContains)
 	}
 
 	// Test: use HTTP proxy
@@ -827,18 +824,20 @@ func fetchAndVerifyWebsite(t *testing.T, httpProxyPort int) {
 		t.Fatalf("error initializing proxied HTTP request: %s", err)
 	}
 
+	httpTransport := &http.Transport{
+		Proxy:             http.ProxyURL(proxyUrl),
+		DisableKeepAlives: true,
+	}
+
 	httpClient := &http.Client{
-		Transport: &http.Transport{
-			Proxy: http.ProxyURL(proxyUrl),
-		},
-		Timeout: roundTripTimeout,
+		Transport: httpTransport,
+		Timeout:   roundTripTimeout,
 	}
 
 	request, err := http.NewRequest("GET", testUrl, nil)
 	if err != nil {
 		t.Fatalf("error preparing proxied HTTP request: %s", err)
 	}
-	request.Close = true
 
 	response, err := httpClient.Do(request)
 	if err != nil {
@@ -860,8 +859,12 @@ func fetchAndVerifyWebsite(t *testing.T, httpProxyPort int) {
 
 	// Test: use direct URL proxy
 
+	httpTransport = &http.Transport{
+		DisableKeepAlives: true,
+	}
+
 	httpClient = &http.Client{
-		Transport: http.DefaultTransport,
+		Transport: httpTransport,
 		Timeout:   roundTripTimeout,
 	}
 
@@ -873,7 +876,6 @@ func fetchAndVerifyWebsite(t *testing.T, httpProxyPort int) {
 	if err != nil {
 		t.Fatalf("error preparing direct URL request: %s", err)
 	}
-	request.Close = true
 
 	response, err = httpClient.Do(request)
 	if err != nil {
@@ -890,11 +892,21 @@ func fetchAndVerifyWebsite(t *testing.T, httpProxyPort int) {
 		t.Fatalf("unexpected direct URL response")
 	}
 
+	// Delay before requesting from external service again
+	time.Sleep(1 * time.Second)
+
 	// Test: use tunneled URL proxy
 
-	response, err = httpClient.Get(
+	request, err = http.NewRequest(
+		"GET",
 		fmt.Sprintf("http://127.0.0.1:%d/tunneled/%s",
-			httpProxyPort, url.QueryEscape(testUrl)))
+			httpProxyPort, url.QueryEscape(testUrl)),
+		nil)
+	if err != nil {
+		t.Fatalf("error preparing tunneled URL request: %s", err)
+	}
+
+	response, err = httpClient.Do(request)
 	if err != nil {
 		t.Fatalf("error sending tunneled URL request: %s", err)
 	}
@@ -943,26 +955,27 @@ func initDisruptor() {
 	go func() {
 		listener, err := socks.ListenSocks("tcp", disruptorProxyAddress)
 		if err != nil {
-			fmt.Errorf("disruptor proxy listen error: %s", err)
+			fmt.Printf("disruptor proxy listen error: %s\n", err)
 			return
 		}
 		for {
 			localConn, err := listener.AcceptSocks()
 			if err != nil {
-				fmt.Errorf("disruptor proxy accept error: %s", err)
+				fmt.Printf("disruptor proxy accept error: %s\n", err)
 				return
 			}
 			go func() {
 				defer localConn.Close()
 				remoteConn, err := net.Dial("tcp", localConn.Req.Target)
 				if err != nil {
-					fmt.Errorf("disruptor proxy dial error: %s", err)
+					// TODO: log "err" without logging server IPs
+					fmt.Printf("disruptor proxy dial error\n")
 					return
 				}
 				defer remoteConn.Close()
 				err = localConn.Grant(&net.TCPAddr{IP: net.ParseIP("0.0.0.0"), Port: 0})
 				if err != nil {
-					fmt.Errorf("disruptor proxy grant error: %s", err)
+					fmt.Printf("disruptor proxy grant error: %s\n", err)
 					return
 				}
 
@@ -1012,7 +1025,7 @@ func initUpstreamProxy() {
 		proxy.OnRequest().DoFunc(
 			func(r *http.Request, ctx *goproxy.ProxyCtx) (*http.Request, *http.Response) {
 				if !hasExpectedCustomHeaders(r.Header) {
-					ctx.Logf("missing expected headers: %+v", ctx.Req.Header)
+					fmt.Printf("missing expected headers: %+v\n", ctx.Req.Header)
 					return nil, goproxy.NewResponse(r, goproxy.ContentTypeText, http.StatusUnauthorized, "")
 				}
 				return r, nil
@@ -1021,7 +1034,7 @@ func initUpstreamProxy() {
 		proxy.OnRequest().HandleConnectFunc(
 			func(host string, ctx *goproxy.ProxyCtx) (*goproxy.ConnectAction, string) {
 				if !hasExpectedCustomHeaders(ctx.Req.Header) {
-					ctx.Logf("missing expected headers: %+v", ctx.Req.Header)
+					fmt.Printf("missing expected headers: %+v\n", ctx.Req.Header)
 					return goproxy.RejectConnect, host
 				}
 				return goproxy.OkConnect, host
@@ -1029,7 +1042,7 @@ func initUpstreamProxy() {
 
 		err := http.ListenAndServe("127.0.0.1:2161", proxy)
 		if err != nil {
-			fmt.Printf("upstream proxy failed: %s", err)
+			fmt.Printf("upstream proxy failed: %s\n", err)
 		}
 	}()
 

+ 29 - 15
psiphon/dataStore.go

@@ -89,13 +89,37 @@ func InitDataStore(config *Config) (err error) {
 
 		filename := filepath.Join(config.DataStoreDirectory, DATA_STORE_FILENAME)
 		var db *bolt.DB
-		db, err = bolt.Open(filename, 0600, &bolt.Options{Timeout: 1 * time.Second})
 
-		// The datastore file may be corrupt, so attempt to delete and try again
-		if err != nil {
-			NoticeAlert("retry on initDataStore error: %s", err)
-			os.Remove(filename)
+		for retry := 0; retry < 3; retry++ {
+
+			if retry > 0 {
+				NoticeAlert("InitDataStore retry: %d", retry)
+			}
+
 			db, err = bolt.Open(filename, 0600, &bolt.Options{Timeout: 1 * time.Second})
+
+			// The datastore file may be corrupt, so attempt to delete and try again
+			if err != nil {
+				NoticeAlert("bolt.Open error: %s", err)
+				os.Remove(filename)
+				continue
+			}
+
+			// Run consistency checks on datastore and emit errors for diagnostics purposes
+			// We assume this will complete quickly for typical size Psiphon datastores.
+			err = db.View(func(tx *bolt.Tx) error {
+				return tx.SynchronousCheck()
+			})
+
+			// The datastore file may be corrupt, so attempt to delete and try again
+			if err != nil {
+				NoticeAlert("bolt.SynchronousCheck error: %s", err)
+				db.Close()
+				os.Remove(filename)
+				continue
+			}
+
+			break
 		}
 
 		if err != nil {
@@ -129,16 +153,6 @@ func InitDataStore(config *Config) (err error) {
 			return
 		}
 
-		// Run consistency checks on datastore and emit errors for diagnostics purposes
-		// We assume this will complete quickly for typical size Psiphon datastores.
-		db.View(func(tx *bolt.Tx) error {
-			err := <-tx.Check()
-			if err != nil {
-				NoticeAlert("boltdb Check(): %s", err)
-			}
-			return nil
-		})
-
 		singleton.db = db
 
 		// The migrateServerEntries function requires the data store is

+ 13 - 5
psiphon/feedback.go

@@ -110,7 +110,7 @@ func SendFeedback(configJson, diagnosticsJson, b64EncodedPublicKey, uploadServer
 
 	untunneledDialConfig := &DialConfig{
 		UpstreamProxyUrl:              config.UpstreamProxyUrl,
-		UpstreamProxyCustomHeaders:    config.UpstreamProxyCustomHeaders,
+		CustomHeaders:                 config.CustomHeaders,
 		PendingConns:                  nil,
 		DeviceBinder:                  nil,
 		IPv6Synthesizer:               nil,
@@ -139,19 +139,24 @@ func SendFeedback(configJson, diagnosticsJson, b64EncodedPublicKey, uploadServer
 	}
 
 	for i := 0; i < FEEDBACK_UPLOAD_MAX_RETRIES; i++ {
-		err := uploadFeedback(untunneledDialConfig, secureFeedback, url, headerPieces)
+		err = uploadFeedback(
+			untunneledDialConfig,
+			secureFeedback,
+			url,
+			MakePsiphonUserAgent(config),
+			headerPieces)
 		if err != nil {
-			NoticeAlert("failed to upload feedback: %s", err)
 			time.Sleep(FEEDBACK_UPLOAD_RETRY_DELAY_SECONDS * time.Second)
 		} else {
 			break
 		}
 	}
-	return nil
+
+	return err
 }
 
 // Attempt to upload feedback data to server.
-func uploadFeedback(config *DialConfig, feedbackData []byte, url string, headerPieces []string) error {
+func uploadFeedback(config *DialConfig, feedbackData []byte, url, userAgent string, headerPieces []string) error {
 	client, parsedUrl, err := MakeUntunneledHttpsClient(
 		config, nil, url, false, time.Duration(FEEDBACK_UPLOAD_TIMEOUT_SECONDS*time.Second))
 	if err != nil {
@@ -162,6 +167,9 @@ func uploadFeedback(config *DialConfig, feedbackData []byte, url string, headerP
 	if err != nil {
 		return common.ContextError(err)
 	}
+
+	req.Header.Set("User-Agent", userAgent)
+
 	req.Header.Set(headerPieces[0], headerPieces[1])
 
 	resp, err := client.Do(req)

+ 2 - 6
psiphon/meekConn.go

@@ -251,7 +251,7 @@ func DialMeek(
 		}
 		if proxyUrl != nil {
 			// Wrap transport with a transport that can perform HTTP proxy auth negotiation
-			transport, err = upstreamproxy.NewProxyAuthTransport(httpTransport, meekDialConfig.UpstreamProxyCustomHeaders)
+			transport, err = upstreamproxy.NewProxyAuthTransport(httpTransport, meekDialConfig.CustomHeaders)
 			if err != nil {
 				return nil, common.ContextError(err)
 			}
@@ -278,7 +278,7 @@ func DialMeek(
 		}
 	} else {
 		if proxyUrl == nil {
-			additionalHeaders = meekDialConfig.UpstreamProxyCustomHeaders
+			additionalHeaders = meekDialConfig.CustomHeaders
 		}
 	}
 
@@ -582,10 +582,6 @@ func (meek *MeekConn) roundTrip(sendPayload []byte) (io.ReadCloser, error) {
 			break
 		}
 
-		// Don't use the default user agent ("Go 1.1 package http").
-		// For now, just omit the header (net/http/request.go: "may be blank to not send the header").
-		request.Header.Set("User-Agent", "")
-
 		request.Header.Set("Content-Type", "application/octet-stream")
 
 		// Set additional headers to the HTTP request using the same method we use for adding

+ 21 - 6
psiphon/net.go

@@ -55,10 +55,10 @@ type DialConfig struct {
 	// supported, those protocols will not connect.
 	UpstreamProxyUrl string
 
-	// UpstreamProxyCustomHeader is a set of additional arbitrary HTTP headers that are
-	// added to all HTTP requests made through the upstream proxy specified by UpstreamProxyUrl
-	// in case of HTTP proxy
-	UpstreamProxyCustomHeaders http.Header
+	// CustomHeaders is a set of additional arbitrary HTTP headers that are
+	// added to all plaintext HTTP requests and requests made through an HTTP
+	// upstream proxy when specified by UpstreamProxyUrl.
+	CustomHeaders http.Header
 
 	ConnectTimeout time.Duration
 
@@ -408,6 +408,7 @@ func MakeDownloadHttpClient(
 func ResumeDownload(
 	httpClient *http.Client,
 	requestUrl string,
+	userAgent string,
 	downloadFilename string,
 	ifNoneMatchETag string) (int64, string, error) {
 
@@ -439,8 +440,20 @@ func ResumeDownload(
 		// that the controller's upgradeDownloader will shortly call DownloadUpgrade
 		// again.
 		if err != nil {
-			os.Remove(partialFilename)
-			os.Remove(partialETagFilename)
+
+			// On Windows, file must be closed before it can be deleted
+			file.Close()
+
+			tempErr := os.Remove(partialFilename)
+			if tempErr != nil && !os.IsNotExist(tempErr) {
+				NoticeAlert("reset partial download failed: %s", tempErr)
+			}
+
+			tempErr = os.Remove(partialETagFilename)
+			if tempErr != nil && !os.IsNotExist(tempErr) {
+				NoticeAlert("reset partial download ETag failed: %s", tempErr)
+			}
+
 			return 0, "", common.ContextError(
 				fmt.Errorf("failed to load partial download ETag: %s", err))
 		}
@@ -451,6 +464,8 @@ func ResumeDownload(
 		return 0, "", common.ContextError(err)
 	}
 
+	request.Header.Set("User-Agent", userAgent)
+
 	request.Header.Add("Range", fmt.Sprintf("bytes=%d-", fileInfo.Size()))
 
 	if partialETag != nil {

+ 43 - 26
psiphon/notice.go

@@ -167,27 +167,40 @@ func NoticeAvailableEgressRegions(regions []string) {
 		"AvailableEgressRegions", 0, "regions", sortedRegions)
 }
 
-// NoticeConnectingServer is details on a connection attempt
-func NoticeConnectingServer(ipAddress, region, protocol, directTCPDialAddress string, meekConfig *MeekConfig) {
-	if meekConfig == nil {
-		outputNotice("ConnectingServer", noticeIsDiagnostic,
+func noticeServerDialStats(noticeType, ipAddress, region, protocol string, tunnelDialStats *TunnelDialStats) {
+	if tunnelDialStats != nil {
+		outputNotice(noticeType, noticeIsDiagnostic,
 			"ipAddress", ipAddress,
 			"region", region,
 			"protocol", protocol,
-			"directTCPDialAddress", directTCPDialAddress)
+			"upstreamProxyType", tunnelDialStats.UpstreamProxyType,
+			"upstreamProxyCustomHeaderNames", strings.Join(tunnelDialStats.UpstreamProxyCustomHeaderNames, ","),
+			"meekDialAddress", tunnelDialStats.MeekDialAddress,
+			"meekDialAddress", tunnelDialStats.MeekDialAddress,
+			"meekResolvedIPAddress", tunnelDialStats.MeekResolvedIPAddress,
+			"meekSNIServerName", tunnelDialStats.MeekSNIServerName,
+			"meekHostHeader", tunnelDialStats.MeekHostHeader,
+			"meekTransformedHostName", tunnelDialStats.MeekTransformedHostName,
+			"selectedUserAgent", tunnelDialStats.SelectedUserAgent,
+			"userAgent", tunnelDialStats.UserAgent)
 	} else {
-		outputNotice("ConnectingServer", noticeIsDiagnostic,
+		outputNotice(noticeType, noticeIsDiagnostic,
 			"ipAddress", ipAddress,
 			"region", region,
-			"protocol", protocol,
-			"meekDialAddress", meekConfig.DialAddress,
-			"meekUseHTTPS", meekConfig.UseHTTPS,
-			"meekSNIServerName", meekConfig.SNIServerName,
-			"meekHostHeader", meekConfig.HostHeader,
-			"meekTransformedHostName", meekConfig.TransformedHostName)
+			"protocol", protocol)
 	}
 }
 
+// NoticeConnectingServer reports parameters and details for a single connection attempt
+func NoticeConnectingServer(ipAddress, region, protocol string, tunnelDialStats *TunnelDialStats) {
+	noticeServerDialStats("ConnectingServer", ipAddress, region, protocol, tunnelDialStats)
+}
+
+// NoticeConnectedServer reports parameters and details for a single successful connection
+func NoticeConnectedServer(ipAddress, region, protocol string, tunnelDialStats *TunnelDialStats) {
+	noticeServerDialStats("ConnectedServer", ipAddress, region, protocol, tunnelDialStats)
+}
+
 // NoticeActiveTunnel is a successful connection that is used as an active tunnel for port forwarding
 func NoticeActiveTunnel(ipAddress, protocol string) {
 	outputNotice("ActiveTunnel", noticeIsDiagnostic, "ipAddress", ipAddress, "protocol", protocol)
@@ -340,20 +353,6 @@ func NoticeLocalProxyError(proxyType string, err error) {
 		"LocalProxyError", noticeIsDiagnostic, "message", err.Error())
 }
 
-// NoticeConnectedTunnelDialStats reports extra network details for tunnel connections that required extra configuration.
-func NoticeConnectedTunnelDialStats(ipAddress string, tunnelDialStats *TunnelDialStats) {
-	outputNotice("ConnectedTunnelDialStats", noticeIsDiagnostic,
-		"ipAddress", ipAddress,
-		"upstreamProxyType", tunnelDialStats.UpstreamProxyType,
-		"upstreamProxyCustomHeaderNames", strings.Join(tunnelDialStats.UpstreamProxyCustomHeaderNames, ","),
-		"meekDialAddress", tunnelDialStats.MeekDialAddress,
-		"meekDialAddress", tunnelDialStats.MeekDialAddress,
-		"meekResolvedIPAddress", tunnelDialStats.MeekResolvedIPAddress,
-		"meekSNIServerName", tunnelDialStats.MeekSNIServerName,
-		"meekHostHeader", tunnelDialStats.MeekHostHeader,
-		"meekTransformedHostName", tunnelDialStats.MeekTransformedHostName)
-}
-
 // NoticeBuildInfo reports build version info.
 func NoticeBuildInfo() {
 	outputNotice("BuildInfo", 0, "buildInfo", common.GetBuildInfo())
@@ -502,3 +501,21 @@ func NewNoticeConsoleRewriter(writer io.Writer) *NoticeReceiver {
 			string(object.Data))
 	})
 }
+
+// NoticeWriter implements io.Writer and emits the contents of Write() calls
+// as Notices. This is to transform logger messages, if they can be redirected
+// to an io.Writer, to notices.
+type NoticeWriter struct {
+	noticeType string
+}
+
+// NewNoticeWriter initializes a new NoticeWriter
+func NewNoticeWriter(noticeType string) *NoticeWriter {
+	return &NoticeWriter{noticeType: noticeType}
+}
+
+// Write implements io.Writer.
+func (writer *NoticeWriter) Write(p []byte) (n int, err error) {
+	outputNotice(writer.noticeType, noticeIsDiagnostic, "message", string(p))
+	return len(p), nil
+}

+ 5 - 1
psiphon/remoteServerList.go

@@ -334,7 +334,11 @@ func downloadRemoteServerListFile(
 	}
 
 	n, responseETag, err := ResumeDownload(
-		httpClient, requestURL, destinationFilename, lastETag)
+		httpClient,
+		requestURL,
+		MakePsiphonUserAgent(config),
+		destinationFilename,
+		lastETag)
 
 	NoticeRemoteServerListResourceDownloadedBytes(sourceURL, n)
 

+ 27 - 9
psiphon/remoteServerList_test.go

@@ -74,6 +74,8 @@ func TestObfuscatedRemoteServerLists(t *testing.T) {
 			EnableSSHAPIRequests: true,
 			WebServerPort:        8001,
 			TunnelProtocolPorts:  map[string]int{"OSSH": 4001},
+			LogFilename:          filepath.Join(testDataDirName, "psiphond.log"),
+			LogLevel:             "debug",
 		})
 	if err != nil {
 		t.Fatalf("error generating server config: %s", err)
@@ -138,15 +140,30 @@ func TestObfuscatedRemoteServerLists(t *testing.T) {
 		t.Fatalf("error generating package keys: %s", err)
 	}
 
+	// First Pave() call is to get the OSL ID to pave into
+
+	oslID := ""
+
 	paveFiles, err := oslConfig.Pave(
 		epoch,
 		propagationChannelID,
 		signingPublicKey,
 		signingPrivateKey,
-		[]map[time.Time]string{
-			map[time.Time]string{
-				epoch: string(encodedServerEntry),
-			},
+		map[string][]string{},
+		func(logInfo *osl.PaveLogInfo) {
+			oslID = logInfo.OSLID
+		})
+	if err != nil {
+		t.Fatalf("error paving OSL files: %s", err)
+	}
+
+	paveFiles, err = oslConfig.Pave(
+		epoch,
+		propagationChannelID,
+		signingPublicKey,
+		signingPrivateKey,
+		map[string][]string{
+			oslID: []string{string(encodedServerEntry)},
 		},
 		nil)
 	if err != nil {
@@ -255,24 +272,24 @@ func TestObfuscatedRemoteServerLists(t *testing.T) {
 	go func() {
 		listener, err := socks.ListenSocks("tcp", disruptorProxyAddress)
 		if err != nil {
-			fmt.Errorf("disruptor proxy listen error: %s", err)
+			fmt.Printf("disruptor proxy listen error: %s\n", err)
 			return
 		}
 		for {
 			localConn, err := listener.AcceptSocks()
 			if err != nil {
-				fmt.Errorf("disruptor proxy accept error: %s", err)
+				fmt.Printf("disruptor proxy accept error: %s\n", err)
 				return
 			}
 			go func() {
 				remoteConn, err := net.Dial("tcp", localConn.Req.Target)
 				if err != nil {
-					fmt.Errorf("disruptor proxy dial error: %s", err)
+					fmt.Printf("disruptor proxy dial error: %s\n", err)
 					return
 				}
 				err = localConn.Grant(&net.TCPAddr{IP: net.ParseIP("0.0.0.0"), Port: 0})
 				if err != nil {
-					fmt.Errorf("disruptor proxy grant error: %s", err)
+					fmt.Printf("disruptor proxy grant error: %s\n", err)
 					return
 				}
 
@@ -356,7 +373,8 @@ func TestObfuscatedRemoteServerLists(t *testing.T) {
 			case "RemoteServerListResourceDownloadedBytes":
 				// TODO: check for resumed download for each URL
 				//url := payload["url"].(string)
-				printNotice = true
+				//printNotice = true
+				printNotice = false
 			case "RemoteServerListResourceDownloaded":
 				printNotice = true
 			}

+ 5 - 5
psiphon/server/api.go

@@ -22,6 +22,7 @@ package server
 import (
 	"crypto/subtle"
 	"encoding/json"
+	"errors"
 	"fmt"
 	"net"
 	"regexp"
@@ -97,9 +98,8 @@ func dispatchAPIRequestHandler(
 	// terminating in the case of a bug.
 	defer func() {
 		if e := recover(); e != nil {
-			reterr = common.ContextError(
-				fmt.Errorf(
-					"request handler panic: %s: %s", e, debug.Stack()))
+			log.LogPanicRecover(e, debug.Stack())
+			reterr = common.ContextError(errors.New("request handler panic"))
 		}
 	}()
 
@@ -509,6 +509,7 @@ var baseRequestParams = []requestParamSpec{
 	requestParamSpec{"sponsor_id", isHexDigits, 0},
 	requestParamSpec{"client_version", isIntString, 0},
 	requestParamSpec{"client_platform", isClientPlatform, 0},
+	requestParamSpec{"client_build_rev", isHexDigits, requestParamOptional},
 	requestParamSpec{"relay_protocol", isRelayProtocol, 0},
 	requestParamSpec{"tunnel_whole_device", isBooleanFlag, requestParamOptional},
 	requestParamSpec{"device_region", isRegionCode, requestParamOptional},
@@ -519,6 +520,7 @@ var baseRequestParams = []requestParamSpec{
 	requestParamSpec{"meek_sni_server_name", isDomain, requestParamOptional},
 	requestParamSpec{"meek_host_header", isHostHeader, requestParamOptional},
 	requestParamSpec{"meek_transformed_host_name", isBooleanFlag, requestParamOptional},
+	requestParamSpec{"user_agent", isAnyString, requestParamOptional},
 	requestParamSpec{"server_entry_region", isRegionCode, requestParamOptional},
 	requestParamSpec{"server_entry_source", isServerEntrySource, requestParamOptional},
 	requestParamSpec{"server_entry_timestamp", isISO8601Date, requestParamOptional},
@@ -620,8 +622,6 @@ func getRequestLogFields(
 	logFields := make(LogFields)
 
 	logFields["event_name"] = eventName
-	logFields["host_id"] = support.Config.HostID
-	logFields["build_rev"] = common.GetBuildInfo().BuildRev
 
 	// In psi_web, the space replacement was done to accommodate space
 	// delimited logging, which is no longer required; we retain the

+ 7 - 1
psiphon/server/config.go

@@ -339,6 +339,7 @@ func validateNetworkAddress(address string, requireIPaddress bool) error {
 // a generated server config.
 type GenerateConfigParams struct {
 	LogFilename          string
+	LogLevel             string
 	ServerIPAddress      string
 	WebServerPort        int
 	EnableSSHAPIRequests bool
@@ -486,8 +487,13 @@ func GenerateConfig(params *GenerateConfigParams) ([]byte, []byte, []byte, error
 	// Note: this config is intended for either testing or as an illustrative
 	// example or template and is not intended for production deployment.
 
+	logLevel := params.LogLevel
+	if logLevel == "" {
+		logLevel = "info"
+	}
+
 	config := &Config{
-		LogLevel:                       "info",
+		LogLevel:                       logLevel,
 		LogFilename:                    params.LogFilename,
 		GeoIPDatabaseFilenames:         nil,
 		HostID:                         "example-host-id",

+ 81 - 35
psiphon/server/log.go

@@ -26,6 +26,7 @@ import (
 	"io/ioutil"
 	go_log "log"
 	"os"
+	"sync"
 
 	"github.com/Psiphon-Inc/logrus"
 	"github.com/Psiphon-Inc/rotate-safe-writer"
@@ -43,39 +44,72 @@ type ContextLogger struct {
 type LogFields logrus.Fields
 
 // WithContext adds a "context" field containing the caller's
-// function name and source file line number. Use this function
-// when the log has no fields.
+// function name and source file line number; and "host_id" and
+// "build_rev" fields identifying this server and build.
+// Use this function when the log has no fields.
 func (logger *ContextLogger) WithContext() *logrus.Entry {
-	return log.WithFields(
+	return logger.WithFields(
 		logrus.Fields{
-			"context": common.GetParentContext(),
+			"context":   common.GetParentContext(),
+			"host_id":   logHostID,
+			"build_rev": logBuildRev,
 		})
 }
 
-// WithContextFields adds a "context" field containing the caller's
-// function name and source file line number. Use this function
-// when the log has fields. Note that any existing "context" field
-// will be renamed to "field.context".
-func (logger *ContextLogger) WithContextFields(fields LogFields) *logrus.Entry {
-	_, ok := fields["context"]
-	if ok {
+func renameLogFields(fields LogFields) {
+	if _, ok := fields["context"]; ok {
 		fields["fields.context"] = fields["context"]
 	}
+	if _, ok := fields["host_id"]; ok {
+		fields["fields.host_id"] = fields["host_id"]
+	}
+	if _, ok := fields["build_rev"]; ok {
+		fields["fields.build_rev"] = fields["build_rev"]
+	}
+}
+
+// WithContextFields adds a "context" field containing the caller's
+// function name and source file line number; and "host_id" and
+// "build_rev" fields identifying this server and build.
+// Use this function when the log has fields.
+// Note that any existing "context"/"host_id"/"build_rev" field will
+// be renamed to "field.<name>".
+func (logger *ContextLogger) WithContextFields(fields LogFields) *logrus.Entry {
+	renameLogFields(fields)
 	fields["context"] = common.GetParentContext()
-	return log.WithFields(logrus.Fields(fields))
+	fields["host_id"] = logHostID
+	fields["build_rev"] = logBuildRev
+	return logger.WithFields(logrus.Fields(fields))
 }
 
 // LogRawFieldsWithTimestamp directly logs the supplied fields adding only
-// an additional "timestamp" field. The stock "msg" and "level" fields are
+// an additional "timestamp" field; and "host_id" and "build_rev" fields
+// identifying this server and build. The stock "msg" and "level" fields are
 // omitted. This log is emitted at the Error level. This function exists to
 // support API logs which have neither a natural message nor severity; and
 // omitting these values here makes it easier to ship these logs to existing
 // API log consumers.
+// Note that any existing "context"/"host_id"/"build_rev" field will
+// be renamed to "field.<name>".
 func (logger *ContextLogger) LogRawFieldsWithTimestamp(fields LogFields) {
+	renameLogFields(fields)
+	fields["host_id"] = logHostID
+	fields["build_rev"] = logBuildRev
 	logger.WithFields(logrus.Fields(fields)).Error(
 		customJSONFormatterLogRawFieldsWithTimestamp)
 }
 
+// LogPanicRecover calls LogRawFieldsWithTimestamp with standard fields
+// for logging recovered panics.
+func (logger *ContextLogger) LogPanicRecover(recoverValue interface{}, stack []byte) {
+	log.LogRawFieldsWithTimestamp(
+		LogFields{
+			"event_name":    "panic",
+			"recover_value": recoverValue,
+			"stack":         string(stack),
+		})
+}
+
 // NewLogWriter returns an io.PipeWriter that can be used to write
 // to the global logger. Caller must Close() the writer.
 func NewLogWriter() *io.PipeWriter {
@@ -138,39 +172,51 @@ func (f *CustomJSONFormatter) Format(entry *logrus.Entry) ([]byte, error) {
 }
 
 var log *ContextLogger
+var logHostID, logBuildRev string
+var initLogging sync.Once
 
 // InitLogging configures a logger according to the specified
 // config params. If not called, the default logger set by the
 // package init() is used.
-// Concurrenty note: should only be called from the main
-// goroutine.
-func InitLogging(config *Config) error {
+// Concurrency notes: this should only be called from the main
+// goroutine; InitLogging only has effect on the first call, as
+// the logging facilities it initializes may be in use by other
+// goroutines after that point.
+func InitLogging(config *Config) (retErr error) {
 
-	level, err := logrus.ParseLevel(config.LogLevel)
-	if err != nil {
-		return common.ContextError(err)
-	}
+	initLogging.Do(func() {
 
-	var logWriter io.Writer
+		logHostID = config.HostID
+		logBuildRev = common.GetBuildInfo().BuildRev
 
-	if config.LogFilename != "" {
-		logWriter, err = rotate.NewRotatableFileWriter(config.LogFilename, 0666)
+		level, err := logrus.ParseLevel(config.LogLevel)
 		if err != nil {
-			return common.ContextError(err)
+			retErr = common.ContextError(err)
+			return
 		}
-	} else {
-		logWriter = os.Stderr
-	}
 
-	log = &ContextLogger{
-		&logrus.Logger{
-			Out:       logWriter,
-			Formatter: &CustomJSONFormatter{},
-			Level:     level,
-		},
-	}
+		var logWriter io.Writer
+
+		if config.LogFilename != "" {
+			logWriter, err = rotate.NewRotatableFileWriter(config.LogFilename, 0666)
+			if err != nil {
+				retErr = common.ContextError(err)
+				return
+			}
+		} else {
+			logWriter = os.Stderr
+		}
+
+		log = &ContextLogger{
+			&logrus.Logger{
+				Out:       logWriter,
+				Formatter: &CustomJSONFormatter{},
+				Level:     level,
+			},
+		}
+	})
 
-	return nil
+	return retErr
 }
 
 func init() {

+ 33 - 18
psiphon/server/psinet/psinet.go

@@ -274,7 +274,8 @@ func (db *Database) GetHttpsRequestRegexes(sponsorID string) []map[string]string
 }
 
 // DiscoverServers selects new encoded server entries to be "discovered" by
-// the client, using the discoveryValue as the input into the discovery algorithm.
+// the client, using the discoveryValue -- a function of the client's IP
+// address -- as the input into the discovery algorithm.
 // The server list (db.Servers) loaded from JSON is stored as an array instead of
 // a map to ensure servers are discovered deterministically. Each iteration over a
 // map in go is seeded with a random value which causes non-deterministic ordering.
@@ -307,7 +308,9 @@ func (db *Database) DiscoverServers(discoveryValue int) []string {
 			}
 		}
 	}
-	servers = selectServers(candidateServers, discoveryValue)
+
+	timeInSeconds := int(discoveryDate.Unix())
+	servers = selectServers(candidateServers, timeInSeconds, discoveryValue)
 
 	encodedServerEntries := make([]string, 0)
 
@@ -333,15 +336,14 @@ func (db *Database) DiscoverServers(discoveryValue int) []string {
 // 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 []Server, discoveryValue int) []Server {
+func selectServers(servers []Server, timeInSeconds, discoveryValue int) []Server {
 	TIME_GRANULARITY := 3600
 
 	if len(servers) == 0 {
 		return nil
 	}
 
-	// Current time truncated to an hour
-	timeInSeconds := int(time.Now().Unix())
+	// Time truncated to an hour
 	timeStrategyValue := timeInSeconds / TIME_GRANULARITY
 
 	// Divide servers into buckets. The bucket count is chosen such that the number
@@ -350,10 +352,21 @@ func selectServers(servers []Server, discoveryValue int) []Server {
 
 	// 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([]Server, 1)
@@ -368,24 +381,26 @@ func calculateBucketCount(length int) int {
 	return int(math.Ceil(math.Sqrt(float64(length))))
 }
 
-// Create bucketCount buckets.
-// Each bucket will be of size division or divison-1.
+// bucketizeServerList creates nearly equal sized slices of the input list.
 func bucketizeServerList(servers []Server, bucketCount int) [][]Server {
-	division := float64(len(servers)) / float64(bucketCount)
 
-	buckets := make([][]Server, bucketCount)
+	// This code creates the same partitions as legacy servers:
+	// https://bitbucket.org/psiphon/psiphon-circumvention-system/src/03bc1a7e51e7c85a816e370bb3a6c755fd9c6fee/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
 
-	var currentBucketIndex int = 0
-	var serverIndex int = 0
-	for _, server := range servers {
-		bucketEndIndex := int(math.Floor(division * (float64(currentBucketIndex) + 1)))
+	// TODO: this partition is constant for fixed Database content, so it could
+	// be done once and cached in the Database ReloadableFile reloadAction.
 
-		buckets[currentBucketIndex] = append(buckets[currentBucketIndex], server)
+	buckets := make([][]Server, bucketCount)
 
-		serverIndex++
-		if serverIndex > bucketEndIndex {
-			currentBucketIndex++
-		}
+	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

+ 135 - 0
psiphon/server/psinet/psinet_test.go

@@ -0,0 +1,135 @@
+/*
+ * Copyright (c) 2017, 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 psinet
+
+import (
+	"fmt"
+	"testing"
+	"time"
+)
+
+func TestDiscoveryBuckets(t *testing.T) {
+
+	checkBuckets := func(buckets [][]Server, expectedIDs [][]int) {
+		if len(buckets) != len(expectedIDs) {
+			t.Errorf(
+				"unexpected bucket count: got %d expected %d",
+				len(buckets), len(expectedIDs))
+			return
+		}
+		for i := 0; i < len(buckets); i++ {
+			if len(buckets[i]) != len(expectedIDs[i]) {
+				t.Errorf(
+					"unexpected bucket %d size: got %d expected %d",
+					i, len(buckets[i]), len(expectedIDs[i]))
+				return
+			}
+			for j := 0; j < len(buckets[i]); j++ {
+				expectedID := fmt.Sprintf("%d", expectedIDs[i][j])
+				if buckets[i][j].Id != expectedID {
+					t.Errorf(
+						"unexpected bucket %d item %d: got %s expected %s",
+						i, j, buckets[i][j].Id, expectedID)
+					return
+				}
+			}
+		}
+	}
+
+	// Partition test cases from:
+	// http://stackoverflow.com/questions/2659900/python-slicing-a-list-into-n-nearly-equal-length-partitions
+
+	servers := make([]Server, 0)
+	for i := 0; i < 105; i++ {
+		servers = append(servers, Server{Id: fmt.Sprintf("%d", 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.Id] = 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.Id]; !ok {
+				t.Errorf("unexpected missing discovery server: %s", bucketServer.Id)
+				return
+			}
+		}
+	})
+
+}

+ 11 - 3
psiphon/server/server_test.go

@@ -49,7 +49,7 @@ func TestMain(m *testing.M) {
 	var err error
 	testDataDirName, err = ioutil.TempDir("", "psiphon-server-test")
 	if err != nil {
-		fmt.Printf("TempDir failed: %s", err)
+		fmt.Printf("TempDir failed: %s\n", err)
 		os.Exit(1)
 	}
 	defer os.RemoveAll(testDataDirName)
@@ -302,7 +302,8 @@ func runServer(t *testing.T, runConfig *runServerConfig) {
 	serverConfig["PsinetDatabaseFilename"] = psinetFilename
 	serverConfig["TrafficRulesFilename"] = trafficRulesFilename
 	serverConfig["OSLConfigFilename"] = oslConfigFilename
-	serverConfig["LogLevel"] = "error"
+	serverConfig["LogFilename"] = filepath.Join(testDataDirName, "psiphond.log")
+	serverConfig["LogLevel"] = "debug"
 
 	serverConfigJSON, _ = json.Marshal(serverConfig)
 
@@ -716,7 +717,12 @@ func makeTunneledNTPRequestAttempt(
 
 	// Tunneled NTP request
 
-	go localUDPProxy(addrs[0][len(addrs[0])-4:], 123, nil)
+	waitGroup = new(sync.WaitGroup)
+	waitGroup.Add(1)
+	go localUDPProxy(
+		addrs[0][len(addrs[0])-4:],
+		123,
+		waitGroup)
 	// TODO: properly synchronize with local UDP proxy startup
 	time.Sleep(1 * time.Second)
 
@@ -767,6 +773,8 @@ func makeTunneledNTPRequestAttempt(
 		return fmt.Errorf("Unexpected NTP time: %s; local time: %s", ntpNow, now)
 	}
 
+	waitGroup.Wait()
+
 	return nil
 }
 

+ 0 - 2
psiphon/server/services.go

@@ -283,8 +283,6 @@ func logServerLoad(server *TunnelServer) {
 	runtime.ReadMemStats(&memStats)
 	fields := LogFields{
 		"event_name":    "server_load",
-		"build_rev":     common.GetBuildInfo().BuildRev,
-		"host_id":       server.sshServer.support.Config.HostID,
 		"num_goroutine": runtime.NumGoroutine(),
 		"mem_stats": map[string]interface{}{
 			"alloc":           memStats.Alloc,

+ 12 - 2
psiphon/server/tunnelServer.go

@@ -98,6 +98,9 @@ func NewTunnelServer(
 // forward tracks its bytes transferred. Overall per-client stats for connection duration,
 // GeoIP, number of port forwards, and bytes transferred are tracked and logged when the
 // client shuts down.
+//
+// Note: client handler goroutines may still be shutting down after Run() returns. See
+// comment in sshClient.stop(). TODO: fully synchronized shutdown.
 func (server *TunnelServer) Run() error {
 
 	type sshListener struct {
@@ -1237,10 +1240,14 @@ func (sshClient *sshClient) idleUDPPortForwardTimeout() time.Duration {
 const (
 	portForwardTypeTCP = iota
 	portForwardTypeUDP
+	portForwardTypeTransparentDNS
 )
 
 func (sshClient *sshClient) isPortForwardPermitted(
-	portForwardType int, remoteIP net.IP, port int) bool {
+	portForwardType int,
+	isTransparentDNSForwarding bool,
+	remoteIP net.IP,
+	port int) bool {
 
 	sshClient.Lock()
 	defer sshClient.Unlock()
@@ -1251,7 +1258,9 @@ func (sshClient *sshClient) isPortForwardPermitted(
 
 	// Disallow connection to loopback. This is a failsafe. The server
 	// should be run on a host with correctly configured firewall rules.
-	if remoteIP.IsLoopback() {
+	// And exception is made in the case of tranparent DNS forwarding,
+	// where the remoteIP has been rewritten.
+	if !isTransparentDNSForwarding && remoteIP.IsLoopback() {
 		return false
 	}
 
@@ -1423,6 +1432,7 @@ func (sshClient *sshClient) handleTCPChannel(
 	if !isWebServerPortForward &&
 		!sshClient.isPortForwardPermitted(
 			portForwardTypeTCP,
+			false,
 			lookupResult.IP,
 			portToConnect) {
 

+ 1 - 1
psiphon/server/udp.go

@@ -163,7 +163,7 @@ func (mux *udpPortForwardMultiplexer) run() {
 			}
 
 			if !mux.sshClient.isPortForwardPermitted(
-				portForwardTypeUDP, dialIP, int(message.remotePort)) {
+				portForwardTypeUDP, message.forwardDNS, dialIP, int(message.remotePort)) {
 				// The udpgw protocol has no error response, so
 				// we just discard the message and read another.
 				continue

+ 22 - 2
psiphon/serverApi.go

@@ -743,7 +743,14 @@ func (serverContext *ServerContext) DoClientVerificationRequest(
 func (serverContext *ServerContext) doGetRequest(
 	requestUrl string) (responseBody []byte, err error) {
 
-	response, err := serverContext.psiphonHttpsClient.Get(requestUrl)
+	request, err := http.NewRequest("GET", requestUrl, nil)
+	if err != nil {
+		return nil, common.ContextError(err)
+	}
+
+	request.Header.Set("User-Agent", MakePsiphonUserAgent(serverContext.tunnel.config))
+
+	response, err := serverContext.psiphonHttpsClient.Do(request)
 	if err == nil && response.StatusCode != http.StatusOK {
 		response.Body.Close()
 		err = fmt.Errorf("HTTP GET request failed with response code: %d", response.StatusCode)
@@ -764,7 +771,15 @@ func (serverContext *ServerContext) doGetRequest(
 func (serverContext *ServerContext) doPostRequest(
 	requestUrl string, bodyType string, body io.Reader) (responseBody []byte, err error) {
 
-	response, err := serverContext.psiphonHttpsClient.Post(requestUrl, bodyType, body)
+	request, err := http.NewRequest("POST", requestUrl, body)
+	if err != nil {
+		return nil, common.ContextError(err)
+	}
+
+	request.Header.Set("User-Agent", MakePsiphonUserAgent(serverContext.tunnel.config))
+	request.Header.Set("Content-Type", bodyType)
+
+	response, err := serverContext.psiphonHttpsClient.Do(request)
 	if err == nil && response.StatusCode != http.StatusOK {
 		response.Body.Close()
 		err = fmt.Errorf("HTTP POST request failed with response code: %d", response.StatusCode)
@@ -801,6 +816,7 @@ func (serverContext *ServerContext) getBaseParams() requestJSONObject {
 	// TODO: client_tunnel_core_version?
 	params["relay_protocol"] = tunnel.protocol
 	params["client_platform"] = tunnel.config.ClientPlatform
+	params["client_build_rev"] = common.GetBuildInfo().BuildRev
 	params["tunnel_whole_device"] = strconv.Itoa(tunnel.config.TunnelWholeDevice)
 
 	// The following parameters may be blank and must
@@ -833,6 +849,10 @@ func (serverContext *ServerContext) getBaseParams() requestJSONObject {
 			transformedHostName = "1"
 		}
 		params["meek_transformed_host_name"] = transformedHostName
+
+		if tunnel.dialStats.SelectedUserAgent {
+			params["user_agent"] = tunnel.dialStats.UserAgent
+		}
 	}
 
 	if tunnel.serverEntry.Region != "" {

+ 4 - 0
psiphon/splitTunnel.go

@@ -69,6 +69,7 @@ import (
 type SplitTunnelClassifier struct {
 	mutex                    sync.RWMutex
 	fetchRoutesUrlFormat     string
+	userAgent                string
 	routesSignaturePublicKey string
 	dnsServerAddress         string
 	dnsTunneler              Tunneler
@@ -86,6 +87,7 @@ type classification struct {
 func NewSplitTunnelClassifier(config *Config, tunneler Tunneler) *SplitTunnelClassifier {
 	return &SplitTunnelClassifier{
 		fetchRoutesUrlFormat:     config.SplitTunnelRoutesUrlFormat,
+		userAgent:                MakePsiphonUserAgent(config),
 		routesSignaturePublicKey: config.SplitTunnelRoutesSignaturePublicKey,
 		dnsServerAddress:         config.SplitTunnelDnsServer,
 		dnsTunneler:              tunneler,
@@ -221,6 +223,8 @@ func (classifier *SplitTunnelClassifier) getRoutes(tunnel *Tunnel) (routesData [
 		return nil, common.ContextError(err)
 	}
 
+	request.Header.Set("User-Agent", classifier.userAgent)
+
 	etag, err := GetSplitTunnelRoutesETag(tunnel.serverContext.clientRegion)
 	if err != nil {
 		return nil, common.ContextError(err)

+ 84 - 36
psiphon/tunnel.go

@@ -103,6 +103,8 @@ type TunnelDialStats struct {
 	MeekSNIServerName              string
 	MeekHostHeader                 string
 	MeekTransformedHostName        bool
+	SelectedUserAgent              bool
+	UserAgent                      string
 }
 
 // EstablishTunnel first makes a network transport connection to the
@@ -582,8 +584,10 @@ func dialSsh(
 	// So depending on which protocol is used, multiple layers are initialized.
 
 	useObfuscatedSsh := false
+	dialCustomHeaders := config.CustomHeaders
 	var directTCPDialAddress string
 	var meekConfig *MeekConfig
+	var selectedUserAgent bool
 	var err error
 
 	switch selectedProtocol {
@@ -602,12 +606,7 @@ func dialSsh(
 		}
 	}
 
-	NoticeConnectingServer(
-		serverEntry.IpAddress,
-		serverEntry.Region,
-		selectedProtocol,
-		directTCPDialAddress,
-		meekConfig)
+	dialCustomHeaders, selectedUserAgent = common.UserAgentIfUnset(config.CustomHeaders)
 
 	// Use an asynchronous callback to record the resolved IP address when
 	// dialing a domain name. Note that DialMeek doesn't immediately
@@ -621,10 +620,9 @@ func dialSsh(
 		resolvedIPAddress.Store(IPAddress)
 	}
 
-	// Create the base transport: meek or direct connection
 	dialConfig := &DialConfig{
 		UpstreamProxyUrl:              config.UpstreamProxyUrl,
-		UpstreamProxyCustomHeaders:    config.UpstreamProxyCustomHeaders,
+		CustomHeaders:                 dialCustomHeaders,
 		ConnectTimeout:                time.Duration(*config.TunnelConnectTimeoutSeconds) * time.Second,
 		PendingConns:                  pendingConns,
 		DeviceBinder:                  config.DeviceBinder,
@@ -635,6 +633,55 @@ func dialSsh(
 		DeviceRegion:                  config.DeviceRegion,
 		ResolvedIPCallback:            setResolvedIPAddress,
 	}
+
+	// Gather dial parameters for diagnostic logging and stats reporting
+
+	var dialStats *TunnelDialStats
+
+	if dialConfig.UpstreamProxyUrl != "" || meekConfig != nil {
+		dialStats = &TunnelDialStats{}
+
+		if selectedUserAgent {
+			dialStats.SelectedUserAgent = true
+			dialStats.UserAgent = dialConfig.CustomHeaders.Get("User-Agent")
+		}
+
+		if dialConfig.UpstreamProxyUrl != "" {
+
+			// Note: UpstreamProxyUrl will be validated in the dial
+			proxyURL, err := url.Parse(dialConfig.UpstreamProxyUrl)
+			if err == nil {
+				dialStats.UpstreamProxyType = proxyURL.Scheme
+			}
+
+			dialStats.UpstreamProxyCustomHeaderNames = make([]string, 0)
+			for name, _ := range dialConfig.CustomHeaders {
+				if selectedUserAgent && name == "User-Agent" {
+					continue
+				}
+				dialStats.UpstreamProxyCustomHeaderNames = append(dialStats.UpstreamProxyCustomHeaderNames, name)
+			}
+		}
+
+		if meekConfig != nil {
+			// Note: dialStats.MeekResolvedIPAddress isn't set until the dial begins,
+			// so it will always be blank in NoticeConnectingServer.
+			dialStats.MeekDialAddress = meekConfig.DialAddress
+			dialStats.MeekResolvedIPAddress = ""
+			dialStats.MeekSNIServerName = meekConfig.SNIServerName
+			dialStats.MeekHostHeader = meekConfig.HostHeader
+			dialStats.MeekTransformedHostName = meekConfig.TransformedHostName
+		}
+	}
+
+	NoticeConnectingServer(
+		serverEntry.IpAddress,
+		serverEntry.Region,
+		selectedProtocol,
+		dialStats)
+
+	// Create the base transport: meek or direct connection
+
 	var dialConn net.Conn
 	if meekConfig != nil {
 		dialConn, err = DialMeek(meekConfig, dialConfig)
@@ -642,7 +689,26 @@ func dialSsh(
 			return nil, common.ContextError(err)
 		}
 	} else {
-		dialConn, err = DialTCP(directTCPDialAddress, dialConfig)
+
+		tcpDialer := func(_, addr string) (net.Conn, error) {
+			return DialTCP(addr, dialConfig)
+		}
+
+		// For some direct connect servers, DialPluginProtocol
+		// will layer on another obfuscation protocol.
+		var dialedPlugin bool
+		dialedPlugin, dialConn, err = common.DialPluginProtocol(
+			NewNoticeWriter("DialPluginProtocol"),
+			tcpDialer,
+			directTCPDialAddress)
+
+		if dialedPlugin {
+			NoticeInfo("dialed plugin protocol for %s", serverEntry.IpAddress)
+		} else {
+			// Standard direct connection.
+			dialConn, err = tcpDialer("", directTCPDialAddress)
+		}
+
 		if err != nil {
 			return nil, common.ContextError(err)
 		}
@@ -749,36 +815,18 @@ func dialSsh(
 		return nil, common.ContextError(result.err)
 	}
 
-	var dialStats *TunnelDialStats
-
-	if dialConfig.UpstreamProxyUrl != "" || meekConfig != nil {
-		dialStats = &TunnelDialStats{}
-
-		if dialConfig.UpstreamProxyUrl != "" {
-
-			// Note: UpstreamProxyUrl should have parsed correctly in the dial
-			proxyURL, err := url.Parse(dialConfig.UpstreamProxyUrl)
-			if err == nil {
-				dialStats.UpstreamProxyType = proxyURL.Scheme
-			}
-
-			dialStats.UpstreamProxyCustomHeaderNames = make([]string, 0)
-			for name, _ := range dialConfig.UpstreamProxyCustomHeaders {
-				dialStats.UpstreamProxyCustomHeaderNames = append(dialStats.UpstreamProxyCustomHeaderNames, name)
-			}
-		}
+	// Update dial parameters determined during dial
 
-		if meekConfig != nil {
-			dialStats.MeekDialAddress = meekConfig.DialAddress
-			dialStats.MeekResolvedIPAddress = resolvedIPAddress.Load().(string)
-			dialStats.MeekSNIServerName = meekConfig.SNIServerName
-			dialStats.MeekHostHeader = meekConfig.HostHeader
-			dialStats.MeekTransformedHostName = meekConfig.TransformedHostName
-		}
-
-		NoticeConnectedTunnelDialStats(serverEntry.IpAddress, dialStats)
+	if dialStats != nil && meekConfig != nil {
+		dialStats.MeekResolvedIPAddress = resolvedIPAddress.Load().(string)
 	}
 
+	NoticeConnectedServer(
+		serverEntry.IpAddress,
+		serverEntry.Region,
+		selectedProtocol,
+		dialStats)
+
 	cleanupConn = nil
 
 	// Note: dialConn may be used to close the underlying network connection

+ 7 - 2
psiphon/upgradeDownload.go

@@ -24,6 +24,7 @@ import (
 	"net/http"
 	"os"
 	"strconv"
+	"time"
 
 	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common"
 )
@@ -80,7 +81,7 @@ func DownloadUpgrade(
 		untunneledDialConfig,
 		downloadURL,
 		skipVerify,
-		DOWNLOAD_UPGRADE_TIMEOUT)
+		time.Duration(*config.DownloadUpgradeTimeoutSeconds)*time.Second)
 
 	// If no handshake version is supplied, make an initial HEAD request
 	// to get the current version from the version header.
@@ -138,7 +139,11 @@ func DownloadUpgrade(
 		"%s.%s", config.UpgradeDownloadFilename, availableClientVersion)
 
 	n, _, err := ResumeDownload(
-		httpClient, requestUrl, downloadFilename, "")
+		httpClient,
+		requestUrl,
+		MakePsiphonUserAgent(config),
+		downloadFilename,
+		"")
 
 	NoticeClientUpgradeDownloadedBytes(n)
 

+ 246 - 0
psiphon/userAgent_test.go

@@ -0,0 +1,246 @@
+/*
+ * Copyright (c) 2017, 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 psiphon
+
+import (
+	"fmt"
+	"net/http"
+	"sync"
+	"testing"
+	"time"
+
+	"github.com/Psiphon-Inc/goproxy"
+	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common"
+	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/server"
+)
+
+// TODO: test that server receives and records correct user_agent value
+
+func TestOSSHUserAgent(t *testing.T) {
+	attemptConnectionsWithUserAgent(t, "OSSH", true)
+}
+
+func TestUnfrontedMeekUserAgent(t *testing.T) {
+	attemptConnectionsWithUserAgent(t, "UNFRONTED-MEEK-OSSH", false)
+}
+
+func TestUnfrontedMeekHTTPSUserAgent(t *testing.T) {
+	attemptConnectionsWithUserAgent(t, "UNFRONTED-MEEK-HTTPS-OSSH", true)
+}
+
+var mockUserAgents = []string{"UserAgentA", "UserAgentB"}
+var userAgentCountsMutex sync.Mutex
+var userAgentCounts map[string]int
+var initUserAgentCounter sync.Once
+
+func pickUserAgent() string {
+	index, _ := common.MakeSecureRandomInt(len(mockUserAgents))
+	return mockUserAgents[index]
+}
+
+func initMockUserAgentPicker() {
+	common.RegisterUserAgentPicker(pickUserAgent)
+}
+
+func resetUserAgentCounts() {
+	userAgentCountsMutex.Lock()
+	defer userAgentCountsMutex.Unlock()
+	userAgentCounts = make(map[string]int)
+}
+
+func countHTTPUserAgent(headers http.Header, isCONNECT bool) {
+	userAgentCountsMutex.Lock()
+	defer userAgentCountsMutex.Unlock()
+	if _, ok := headers["User-Agent"]; !ok {
+		userAgentCounts["BLANK"]++
+	} else if isCONNECT {
+		userAgentCounts["CONNECT-"+headers.Get("User-Agent")]++
+	} else {
+		userAgentCounts[headers.Get("User-Agent")]++
+	}
+}
+
+func countNoticeUserAgent(userAgent string) {
+	userAgentCountsMutex.Lock()
+	defer userAgentCountsMutex.Unlock()
+	userAgentCounts["NOTICE-"+userAgent]++
+}
+
+func checkUserAgentCounts(t *testing.T, isCONNECT bool) {
+	userAgentCountsMutex.Lock()
+	defer userAgentCountsMutex.Unlock()
+
+	for _, userAgent := range mockUserAgents {
+
+		if isCONNECT {
+			if userAgentCounts["CONNECT-"+userAgent] == 0 {
+				t.Fatalf("unexpected CONNECT user agent count of 0: %+v", userAgentCounts)
+				return
+			}
+		} else {
+
+			if userAgentCounts[userAgent] == 0 {
+				t.Fatalf("unexpected non-CONNECT user agent count of 0: %+v", userAgentCounts)
+				return
+			}
+		}
+
+		if userAgentCounts["NOTICE-"+userAgent] == 0 {
+			t.Fatalf("unexpected NOTICE user agent count of 0: %+v", userAgentCounts)
+			return
+		}
+	}
+
+	if userAgentCounts["BLANK"] == 0 {
+		t.Fatalf("unexpected BLANK user agent count of 0: %+v", userAgentCounts)
+		return
+	}
+
+	// TODO: check proportions
+	t.Logf("%+v", userAgentCounts)
+}
+
+func initUserAgentCounterUpstreamProxy() {
+	initUserAgentCounter.Do(func() {
+		go func() {
+			proxy := goproxy.NewProxyHttpServer()
+
+			proxy.OnRequest().DoFunc(
+				func(r *http.Request, ctx *goproxy.ProxyCtx) (*http.Request, *http.Response) {
+					countHTTPUserAgent(r.Header, false)
+					return nil, goproxy.NewResponse(r, goproxy.ContentTypeText, http.StatusUnauthorized, "")
+				})
+
+			proxy.OnRequest().HandleConnectFunc(
+				func(host string, ctx *goproxy.ProxyCtx) (*goproxy.ConnectAction, string) {
+					countHTTPUserAgent(ctx.Req.Header, true)
+					return goproxy.RejectConnect, host
+				})
+
+			err := http.ListenAndServe("127.0.0.1:2163", proxy)
+			if err != nil {
+				fmt.Printf("upstream proxy failed: %s\n", err)
+			}
+		}()
+
+		// TODO: more robust wait-until-listening
+		time.Sleep(1 * time.Second)
+	})
+}
+
+func attemptConnectionsWithUserAgent(
+	t *testing.T, tunnelProtocol string, isCONNECT bool) {
+
+	initMockUserAgentPicker()
+	initUserAgentCounterUpstreamProxy()
+	resetUserAgentCounts()
+
+	// create a server entry
+
+	var err error
+	serverIPaddress := ""
+	for _, interfaceName := range []string{"eth0", "en0"} {
+		serverIPaddress, err = common.GetInterfaceIPAddress(interfaceName)
+		if err == nil {
+			break
+		}
+	}
+	if err != nil {
+		t.Fatalf("error getting server IP address: %s", err)
+	}
+
+	_, _, encodedServerEntry, err := server.GenerateConfig(
+		&server.GenerateConfigParams{
+			ServerIPAddress:      serverIPaddress,
+			EnableSSHAPIRequests: true,
+			WebServerPort:        8000,
+			TunnelProtocolPorts:  map[string]int{tunnelProtocol: 4000},
+		})
+	if err != nil {
+		t.Fatalf("error generating server config: %s", err)
+	}
+
+	// attempt connections with client
+
+	// Connections are made through a mock upstream proxy that
+	// counts user agents. No server is running, and the upstream
+	// proxy rejects connections after counting the user agent.
+
+	// Note: calling LoadConfig ensures all *int config fields are initialized
+	clientConfigJSON := `
+    {
+        "ClientPlatform" : "Windows",
+        "ClientVersion" : "0",
+        "SponsorId" : "0",
+        "PropagationChannelId" : "0",
+        "ConnectionPoolSize" : 1,
+        "EstablishTunnelPausePeriodSeconds" : 1,
+        "DisableRemoteServerListFetcher" : true,
+        "TransformHostNames" : "never",
+        "UpstreamProxyUrl" : "http://127.0.0.1:2163"
+    }`
+	clientConfig, _ := LoadConfig([]byte(clientConfigJSON))
+
+	clientConfig.TargetServerEntry = string(encodedServerEntry)
+	clientConfig.TunnelProtocol = tunnelProtocol
+	clientConfig.DataStoreDirectory = testDataDirName
+
+	err = InitDataStore(clientConfig)
+	if err != nil {
+		t.Fatalf("error initializing client datastore: %s", err)
+	}
+
+	SetNoticeOutput(NewNoticeReceiver(
+		func(notice []byte) {
+			noticeType, payload, err := GetNotice(notice)
+			if err != nil {
+				return
+			}
+			if noticeType == "ConnectingServer" {
+				selectedUserAgent := payload["selectedUserAgent"].(bool)
+				userAgent := payload["userAgent"].(string)
+				if selectedUserAgent {
+					countNoticeUserAgent(userAgent)
+				}
+			}
+		}))
+
+	controller, err := NewController(clientConfig)
+	if err != nil {
+		t.Fatalf("error creating client controller: %s", err)
+	}
+
+	controllerShutdownBroadcast := make(chan struct{})
+	controllerWaitGroup := new(sync.WaitGroup)
+	controllerWaitGroup.Add(1)
+	go func() {
+		defer controllerWaitGroup.Done()
+		controller.Run(controllerShutdownBroadcast)
+	}()
+
+	// repeat attempts for long enough to select each user agent
+
+	time.Sleep(20 * time.Second)
+
+	close(controllerShutdownBroadcast)
+	controllerWaitGroup.Wait()
+
+	checkUserAgentCounts(t, isCONNECT)
+}

+ 15 - 0
psiphon/utils.go

@@ -32,6 +32,21 @@ import (
 	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common"
 )
 
+// MakePsiphonUserAgent constructs a User-Agent value to use for web service
+// requests made by the tunnel-core client. The User-Agent includes useful stats
+// information; it is to be used only for HTTPS requests, where the header
+// cannot be seen by an adversary.
+func MakePsiphonUserAgent(config *Config) string {
+	userAgent := "psiphon-tunnel-core"
+	if config.ClientVersion != "" {
+		userAgent += fmt.Sprintf("/%s", config.ClientVersion)
+	}
+	if config.ClientPlatform != "" {
+		userAgent += fmt.Sprintf(" (%s)", config.ClientPlatform)
+	}
+	return userAgent
+}
+
 func DecodeCertificate(encodedCertificate string) (certificate *x509.Certificate, err error) {
 	derEncodedCertificate, err := base64.StdEncoding.DecodeString(encodedCertificate)
 	if err != nil {