Просмотр исходного кода

Refactor client library into a separate package

adam-p 7 лет назад
Родитель
Сommit
e3cd8abc20

+ 2 - 0
.travis.yml

@@ -29,6 +29,7 @@ script:
 - go test -race -v ./server
 - go test -race -v ./server/psinet
 - go test -race -v ../Server/logging/analysis
+- go test -race -v ../ClientLibrary/clientlib
 - go test -race -v
 - go test -v -covermode=count -coverprofile=common.coverprofile ./common
 - go test -v -covermode=count -coverprofile=accesscontrol.coverprofile ./common/accesscontrol
@@ -46,6 +47,7 @@ script:
 - 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=analysis.coverprofile ../Server/logging/analysis
+- go test -v -covermode=count -coverprofile=clientlib.coverprofile ../ClientLibrary/clientlib
 - go test -v -covermode=count -coverprofile=psiphon.coverprofile
 - go test -v ./memory_test -run TestReconnectTunnel
 - go test -v ./memory_test -run TestRestartController

+ 168 - 234
ClientLibrary/PsiphonTunnel.go

@@ -1,92 +1,140 @@
+/*
+ * Copyright (c) 2018, Psiphon Inc.
+ * All rights reserved.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+
 package main
 
-// #include <stdlib.h>
+/*
+#include <stdlib.h>
+#include <stdint.h>
+
+// For descriptions of fields, see below.
+// Additional information can also be found in the Parameters structure in clientlib.go.
+struct Parameters {
+	size_t sizeofStruct; // Must be set to sizeof(Parameters); helps with ABI compatibiity
+	char *dataRootDirectory;
+	char *clientPlatform;
+	char *networkID;
+	int32_t *establishTunnelTimeoutSeconds;
+};
+*/
 import "C"
 
 import (
 	"context"
 	"encoding/json"
-	"errors"
 	"fmt"
-	"sync"
 	"time"
 	"unsafe"
 
-	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon"
+	"github.com/Psiphon-Labs/psiphon-tunnel-core/ClientLibrary/clientlib"
 	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common"
-	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common/protocol"
 )
 
+/*
+If/when new fields are added to the C Parameters struct, we can use this code to ensure
+ABI compatibility. We'll take these steps:
+1. Copy the old struct into a new `ParametersV1`. The new struct will be `Parameters`.
+2. Uncomment the code below. It will not compile (link, specifically) if the size of
+   `Parameters` is the same as the size of `ParametersV1`.
+   - If the compile fails, padding may need to be added to `Parameters` to force it to be
+     a different size than `ParametersV1`.
+3. In `Start`, we'll check the value of `sizeofStruct` to determine which version of
+   `Parameters` the caller is using, and behave according.
+4. Do similar kinds of things for V2, V3, etc.
+*/
+/*
+func nonexistentFunction()
+func init() {
+	if C.sizeof_struct_Parameters == C.sizeof_struct_ParametersV1 {
+		// There is only an attempt to link this nonexistent function if the struct sizes
+		// are the same. So they must not be.
+		nonexistentFunction()
+	}
+}
+*/
+
 type startResultCode int
 
 const (
-	startResultCodeSuccess startResultCode = iota
-	startResultCodeTimeout
-	startResultCodeOtherError
+	startResultCodeSuccess    startResultCode = 0
+	startResultCodeTimeout                    = 1
+	startResultCodeOtherError                 = 2
 )
 
 type noticeEvent struct {
-	Data       map[string]interface{} `json:"data"`
-	NoticeType string                 `json:"noticeType"`
+	Data       map[string]interface{}
+	NoticeType string
 }
 
 type startResult struct {
-	Code           startResultCode `json:"result_code"`
-	BootstrapTime  float64         `json:"bootstrap_time,omitempty"`
-	ErrorString    string          `json:"error,omitempty"`
-	HttpProxyPort  int             `json:"http_proxy_port,omitempty"`
-	SocksProxyPort int             `json:"socks_proxy_port,omitempty"`
+	Code           startResultCode
+	ConnectTimeMS  int64  `json:",omitempty"`
+	Error          string `json:",omitempty"`
+	HTTPProxyPort  int    `json:",omitempty"`
+	SOCKSProxyPort int    `json:",omitempty"`
 }
 
-type psiphonTunnel struct {
-	controllerWaitGroup sync.WaitGroup
-	controllerCtx       context.Context
-	stopController      context.CancelFunc
-	httpProxyPort       int
-	socksProxyPort      int
-}
-
-var tunnel psiphonTunnel
+var tunnel *clientlib.PsiphonTunnel
 
 // Memory managed by PsiphonTunnel which is allocated in Start and freed in Stop
 var managedStartResult *C.char
 
-//export psiphon_tunnel_start
+//export PsiphonTunnelStart
 //
 // ******************************* WARNING ********************************
 // The underlying memory referenced by the return value of Start is managed
 // by PsiphonTunnel and attempting to free it explicitly will cause the
-// program to crash. This memory is freed once Stop is called.
+// program to crash. This memory is freed once Stop is called, or if Start
+// is called again.
 // ************************************************************************
 //
-// Start starts the controller and returns once either of the following has occured: an active tunnel has been
-// established, the timeout has elapsed before an active tunnel could be established or an error has occured.
+// Start starts the controller and returns once one of the following has occured:
+// an active tunnel has been established, the timeout has elapsed before an active tunnel
+// could be established, or an error has occured.
 //
-// Start returns a startResult object serialized as a JSON string in the form of a null-terminated buffer of C chars.
+// Start returns a startResult object serialized as a JSON string in the form of a
+// null-terminated buffer of C chars.
 // Start will return,
 // On success:
 //   {
-//     "result_code": 0,
-//     "bootstrap_time": <time_to_establish_tunnel>,
-//     "http_proxy_port": <http_proxy_port_num>,
-//     "socks_proxy_port": <socks_proxy_port_num>
+//     "Code": 0,
+//     "ConnectTimeMS": <milliseconds to establish tunnel>,
+//     "HTTPProxyPort": <http proxy port number>,
+//     "SOCKSProxyPort": <socks proxy port number>
 //   }
 //
 // On timeout:
-//  {
-//    "result_code": 1,
-//    "error": <error message>
-//  }
+//   {
+//     "Code": 1,
+//     "Error": <error message>
+//   }
 //
 // On other error:
 //   {
-//     "result_code": 2,
-//     "error": <error message>
+//     "Code": 2,
+//     "Error": <error message>
 //   }
 //
-// clientPlatform should be of the form OS_OSVersion_BundleIdentifier where both the OSVersion and BundleIdentifier
-// fields are optional. If clientPlatform is set to an empty string the "ClientPlatform" field in the provided json
-// config will be used instead.
+// Parameters.clientPlatform should be of the form OS_OSVersion_BundleIdentifier where
+// both the OSVersion and BundleIdentifier fields are optional. If clientPlatform is set
+// to an empty string the "ClientPlatform" field in the provided JSON config will be
+// used instead.
 //
 // Provided below are links to platform specific code which can be used to find some of the above fields:
 //   Android:
@@ -102,228 +150,110 @@ var managedStartResult *C.char
 //   "iOS_11.4_com.example.exampleApp"
 //   "Windows"
 //
-// networkID must be a non-empty string and follow the format specified by
+// Parameters.networkID must be a non-empty string and follow the format specified by:
 // https://godoc.org/github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon#NetworkIDGetter.
-//
-// Provided below are links to platform specific code which can be used to generate valid network identifier strings:
+// Provided below are links to platform specific code which can be used to generate
+// valid network identifier strings:
 //   Android:
 //     - https://github.com/Psiphon-Labs/psiphon-tunnel-core/blob/3d344194d21b250e0f18ededa4b4459a373b0690/MobileLibrary/Android/PsiphonTunnel/PsiphonTunnel.java#L371
 //   iOS:
 //     - https://github.com/Psiphon-Labs/psiphon-tunnel-core/blob/3d344194d21b250e0f18ededa4b4459a373b0690/MobileLibrary/iOS/PsiphonTunnel/PsiphonTunnel/PsiphonTunnel.m#L1105
 //
-// timeout specifies a time limit after which to stop attempting to connect and return an error if an active tunnel
-// has not been established. A timeout of 0 will result in no timeout condition and the controller will attempt to
-// establish an active tunnel indefinitely (or until psiphon_tunnel_stop is called). Timeout values >= 0 override
-// the optional `EstablishTunnelTimeoutSeconds` config field.
-func psiphon_tunnel_start(cConfigJSON, cEmbeddedServerEntryList, cClientPlatform, cNetworkID *C.char, timeout *int64) *C.char {
-
+// Parameters.establishTunnelTimeoutSeconds specifies a time limit after which to stop
+// attempting to connect and return an error if an active tunnel has not been established.
+// A timeout of 0 will result in no timeout condition and the controller will attempt to
+// establish an active tunnel indefinitely (or until PsiphonTunnelStop is called).
+// Timeout values >= 0 override the optional `EstablishTunnelTimeoutSeconds` config field;
+// null causes the config value to be used.
+func PsiphonTunnelStart(cConfigJSON, cEmbeddedServerEntryList *C.char, cParams *C.struct_Parameters) *C.char {
 	// Stop any active tunnels
+	PsiphonTunnelStop()
 
-	psiphon_tunnel_stop()
-
-	// Validate timeout value
-
-	if timeout != nil && *timeout < 0 {
-		managedStartResult = startErrorJson(errors.New("Timeout value must be non-negative"))
+	if cConfigJSON == nil {
+		err := common.ContextError(fmt.Errorf("configJSON is required"))
+		managedStartResult = startErrorJSON(err)
 		return managedStartResult
 	}
 
-	// NOTE: all arguments which are still referenced once Start returns should be copied onto the Go heap
-	//       to ensure that they don't disappear later on and cause Go to crash.
-
-	configJSON := C.GoString(cConfigJSON)
-	embeddedServerEntryList := C.GoString(cEmbeddedServerEntryList)
-	clientPlatform := C.GoString(cClientPlatform)
-	networkID := C.GoString(cNetworkID)
-
-	// Load provided config
-
-	config, err := psiphon.LoadConfig([]byte(configJSON))
-	if err != nil {
-		managedStartResult = startErrorJson(err)
+	if cParams == nil {
+		err := common.ContextError(fmt.Errorf("params is required"))
+		managedStartResult = startErrorJSON(err)
 		return managedStartResult
 	}
 
-	// Set network ID
-
-	config.NetworkID = networkID
-
-	// Set client platform
-
-	config.ClientPlatform = clientPlatform
-
-	// Set timeout
-
-	if timeout != nil {
-		// timeout overrides optional timeout field in config
-		*config.EstablishTunnelTimeoutSeconds = 0
-	}
-
-	// All config fields should be set before calling commit
-	err = config.Commit()
-	if err != nil {
-		managedStartResult = startErrorJson(err)
+	if cParams.sizeofStruct != C.sizeof_struct_Parameters {
+		err := common.ContextError(fmt.Errorf("sizeofStruct does not match sizeof(Parameters)"))
+		managedStartResult = startErrorJSON(err)
 		return managedStartResult
 	}
 
-	// Setup signals
-
-	connected := make(chan bool)
-
-	testError := make(chan error)
-
-	// Set up notice handling
-
-	psiphon.SetNoticeWriter(psiphon.NewNoticeReceiver(
-		func(notice []byte) {
-
-			var event noticeEvent
-
-			err := json.Unmarshal(notice, &event)
-			if err != nil {
-				err = errors.New(fmt.Sprintf("Failed to unmarshal json: %s", err.Error()))
-				select {
-				case testError <- err:
-				default:
-				}
-			}
-
-			if event.NoticeType == "ListeningHttpProxyPort" {
-				port := event.Data["port"].(float64)
-				tunnel.httpProxyPort = int(port)
-			} else if event.NoticeType == "ListeningSocksProxyPort" {
-				port := event.Data["port"].(float64)
-				tunnel.socksProxyPort = int(port)
-			} else if event.NoticeType == "Tunnels" {
-				count := event.Data["count"].(float64)
-				if count > 0 {
-					select {
-					case connected <- true:
-					default:
-					}
-				}
-			}
-		}))
-
-	// Initialize data store
+	// NOTE: all arguments which may be referenced once Start returns must be copied onto
+	// the Go heap to ensure that they don't disappear later on and cause Go to crash.
+	configJSON := []byte(C.GoString(cConfigJSON))
+	embeddedServerEntryList := C.GoString(cEmbeddedServerEntryList)
 
-	err = psiphon.OpenDataStore(config)
-	if err != nil {
-		managedStartResult = startErrorJson(err)
-		return managedStartResult
+	params := clientlib.Parameters{}
+	if cParams.dataRootDirectory != nil {
+		v := C.GoString(cParams.dataRootDirectory)
+		params.DataRootDirectory = &v
 	}
-
-	// Store embedded server entries
-
-	serverEntries, err := protocol.DecodeServerEntryList(
-		embeddedServerEntryList,
-		common.GetCurrentTimestamp(),
-		protocol.SERVER_ENTRY_SOURCE_EMBEDDED)
-	if err != nil {
-		managedStartResult = startErrorJson(err)
-		return managedStartResult
+	if cParams.clientPlatform != nil {
+		v := C.GoString(cParams.clientPlatform)
+		params.ClientPlatform = &v
 	}
-
-	err = psiphon.StoreServerEntries(config, serverEntries, false)
-	if err != nil {
-		managedStartResult = startErrorJson(err)
-		return managedStartResult
+	if cParams.networkID != nil {
+		v := C.GoString(cParams.networkID)
+		params.NetworkID = &v
 	}
-
-	// Run Psiphon
-
-	controller, err := psiphon.NewController(config)
-	if err != nil {
-		managedStartResult = startErrorJson(err)
-		return managedStartResult
+	if cParams.establishTunnelTimeoutSeconds != nil {
+		v := int(*cParams.establishTunnelTimeoutSeconds)
+		params.EstablishTunnelTimeoutSeconds = &v
 	}
 
-	tunnel.controllerCtx, tunnel.stopController = context.WithCancel(context.Background())
-
-	// Set start time
-
 	startTime := time.Now()
 
-	optionalTimeout := make(chan error)
-
-	if timeout != nil && *timeout != 0 {
-		// Setup timeout signal
-
-		runtimeTimeout := time.Duration(*timeout) * time.Second
-
-		timeoutSignal, cancelTimeout := context.WithTimeout(context.Background(), runtimeTimeout)
-
-		defer cancelTimeout()
+	// Start the tunnel connection
+	var err error
+	tunnel, err = clientlib.StartTunnel(
+		context.Background(), configJSON, embeddedServerEntryList, params, nil, nil)
 
-		go func() {
-			<-timeoutSignal.Done()
-			optionalTimeout <- timeoutSignal.Err()
-		}()
-	}
-
-	// Run test
-
-	var result startResult
-
-	tunnel.controllerWaitGroup.Add(1)
-	go func() {
-		defer tunnel.controllerWaitGroup.Done()
-		controller.Run(tunnel.controllerCtx)
-
-		select {
-		case testError <- errors.New("controller.Run exited unexpectedly"):
-		default:
-		}
-	}()
-
-	// Wait for an active tunnel, timeout or error
-
-	select {
-	case <-connected:
-		result.Code = startResultCodeSuccess
-		result.BootstrapTime = secondsBeforeNow(startTime)
-		result.HttpProxyPort = tunnel.httpProxyPort
-		result.SocksProxyPort = tunnel.socksProxyPort
-	case err := <-optionalTimeout:
-		result.Code = startResultCodeTimeout
-		if err != nil {
-			result.ErrorString = fmt.Sprintf("Timeout occured before Psiphon connected: %s", err.Error())
+	if err != nil {
+		if err == clientlib.ErrTimeout {
+			managedStartResult = marshalStartResult(startResult{
+				Code:  startResultCodeTimeout,
+				Error: fmt.Sprintf("Timeout occurred before Psiphon connected: %s", err.Error()),
+			})
+		} else {
+			managedStartResult = marshalStartResult(startResult{
+				Code:  startResultCodeOtherError,
+				Error: err.Error(),
+			})
 		}
-		tunnel.stopController()
-	case err := <-testError:
-		result.Code = startResultCodeOtherError
-		result.ErrorString = err.Error()
-		tunnel.stopController()
+		return managedStartResult
 	}
 
-	// Return result
-	managedStartResult = marshalStartResult(result)
-
+	// Success
+	managedStartResult = marshalStartResult(startResult{
+		Code:           startResultCodeSuccess,
+		ConnectTimeMS:  int64(time.Now().Sub(startTime) / time.Millisecond),
+		HTTPProxyPort:  tunnel.HTTPProxyPort,
+		SOCKSProxyPort: tunnel.SOCKSProxyPort,
+	})
 	return managedStartResult
 }
 
-//export psiphon_tunnel_stop
+//export PsiphonTunnelStop
 //
 // Stop stops the controller if it is running and waits for it to clean up and exit.
 //
 // Stop should always be called after a successful call to Start to ensure the
-// controller is not left running.
-func psiphon_tunnel_stop() {
+// controller is not left running and memory is released.
+// It is safe to call this function when the tunnel is not running.
+func PsiphonTunnelStop() {
 	freeManagedStartResult()
-
-	if tunnel.stopController != nil {
-		tunnel.stopController()
+	if tunnel != nil {
+		tunnel.Stop()
 	}
-
-	tunnel.controllerWaitGroup.Wait()
-
-	psiphon.CloseDataStore()
-}
-
-// secondsBeforeNow returns the delta seconds of the current time subtract startTime.
-func secondsBeforeNow(startTime time.Time) float64 {
-	delta := time.Now().Sub(startTime)
-	return delta.Seconds()
 }
 
 // marshalStartResult serializes a startResult object as a JSON string in the form
@@ -331,25 +261,29 @@ func secondsBeforeNow(startTime time.Time) float64 {
 func marshalStartResult(result startResult) *C.char {
 	resultJSON, err := json.Marshal(result)
 	if err != nil {
-		return C.CString(fmt.Sprintf("{\"result_code\":%d, \"error\": \"%s\"}", startResultCodeOtherError, err.Error()))
+		err = common.ContextErrorMsg(err, "json.Marshal failed")
+		// Fail back to manually constructing the JSON
+		return C.CString(fmt.Sprintf("{\"Code\":%d, \"Error\": \"%s\"}",
+			startResultCodeOtherError, err.Error()))
 	}
 
 	return C.CString(string(resultJSON))
 }
 
-// startErrorJson returns a startResult object serialized as a JSON string in the form
-// of a null-terminated buffer of C chars. The object's return result code will be set to
-// startResultCodeOtherError (2) and its error string set to the error string of the provided error.
+// startErrorJSON returns a startResult object serialized as a JSON string in the form of
+// a null-terminated buffer of C chars. The object's return result code will be set to
+// startResultCodeOtherError (2) and its error string set to the error string of the
+// provided error.
 //
 // The JSON will be in the form of:
 // {
-//   "result_code": 2,
-//   "error": <error message>
+//   "Code": 2,
+//   "Error": <error message>
 // }
-func startErrorJson(err error) *C.char {
+func startErrorJSON(err error) *C.char {
 	var result startResult
 	result.Code = startResultCodeOtherError
-	result.ErrorString = err.Error()
+	result.Error = err.Error()
 
 	return marshalStartResult(result)
 }

+ 3 - 2
ClientLibrary/build-darwin.sh

@@ -146,8 +146,9 @@ build_for_macos () {
   fi
   prepare_build darwin
 
-  TARGET_ARCH=386
-  CGO_ENABLED=1 GOOS=darwin GOARCH="${TARGET_ARCH}" go build -buildmode=c-shared -ldflags "-s ${LDFLAGS}" -tags "${BUILD_TAGS}" -o "${MACOS_BUILD_DIR}/${TARGET_ARCH}/libpsiphontunnel.dylib" PsiphonTunnel.go
+  # i386 is deprecated for macOS and does not link
+  #TARGET_ARCH=386
+  #CGO_ENABLED=1 GOOS=darwin GOARCH="${TARGET_ARCH}" go build -buildmode=c-shared -ldflags "-s ${LDFLAGS}" -tags "${BUILD_TAGS}" -o "${MACOS_BUILD_DIR}/${TARGET_ARCH}/libpsiphontunnel.dylib" PsiphonTunnel.go
 
   TARGET_ARCH=amd64
   CGO_ENABLED=1 GOOS=darwin GOARCH="${TARGET_ARCH}" go build -buildmode=c-shared -ldflags "-s ${LDFLAGS}" -tags "${BUILD_TAGS}" -o "${MACOS_BUILD_DIR}/${TARGET_ARCH}/libpsiphontunnel.dylib" PsiphonTunnel.go

+ 282 - 0
ClientLibrary/clientlib/clientlib.go

@@ -0,0 +1,282 @@
+/*
+ * Copyright (c) 2018, 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 clientlib
+
+import (
+	"context"
+	"encoding/json"
+	"errors"
+	"fmt"
+	"path/filepath"
+	"sync"
+
+	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon"
+	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common"
+	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common/protocol"
+)
+
+// Parameters provide an easier way to modify the tunnel config at runtime.
+type Parameters struct {
+	// Used as the directory for the datastore, remote server list, and obfuscasted
+	// server list.
+	// Empty string means the default will be used (current working directory).
+	// nil means the values in the config file will be used.
+	// Optional, but strongly recommended.
+	DataRootDirectory *string
+
+	// Overrides config.ClientPlatform. See config.go for details.
+	// nil means the value in the config file will be used.
+	// Optional, but strongly recommended.
+	ClientPlatform *string
+
+	// Overrides config.NetworkID. For details see:
+	// https://godoc.org/github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon#NetworkIDGetter
+	// nil means the value in the config file will be used. (If not set in the config,
+	// an error will result.)
+	// Empty string will produce an error.
+	// Optional, but strongly recommended.
+	NetworkID *string
+
+	// Overrides config.EstablishTunnelTimeoutSeconds. See config.go for details.
+	// nil means the EstablishTunnelTimeoutSeconds value in the config file will be used.
+	// If there's no such value in the config file, the default will be used.
+	// Zero means there will be no timeout.
+	// Optional.
+	EstablishTunnelTimeoutSeconds *int
+}
+
+// PsiphonTunnel is the tunnel object. It can be used for stopping the tunnel and
+// retrieving proxy ports.
+type PsiphonTunnel struct {
+	controllerWaitGroup sync.WaitGroup
+	stopController      context.CancelFunc
+
+	// The port on which the HTTP proxy is running
+	HTTPProxyPort int
+	// The port on which the SOCKS proxy is running
+	SOCKSProxyPort int
+}
+
+// ClientParametersDelta allows for fine-grained modification of parameters.ClientParameters.
+// NOTE: Ordinary users of this library should never need this.
+type ClientParametersDelta map[string]interface{}
+
+// NoticeEvent represents the notices emitted by tunnel core. It will be passed to
+// noticeReceiver, if supplied.
+// NOTE: Ordinary users of this library should never need this.
+type NoticeEvent struct {
+	Data map[string]interface{} `json:"data"`
+	Type string                 `json:"noticeType"`
+}
+
+// ErrTimeout is returned when the tunnel connection attempt fails due to timeout
+var ErrTimeout = errors.New("clientlib: tunnel connection timeout")
+
+// StartTunnel makes a Psiphon tunnel connection. It returns an error if the connection
+// was not successful. If the returned error is nil, the returned tunnel can be used
+// to find out the proxy ports and subsequently stop the tunnel.
+// ctx may be cancelable, if the caller wants to be able to interrupt the connection
+// attempt, or context.Background().
+// configJSON will be passed to psiphon.LoadConfig to configure the tunnel. Required.
+// embeddedServerEntryList is the encoded embedded server entry list. It is optional.
+// params are config values that typically need to be overridden at runtime.
+// paramsDelta contains changes that will be applied to the ClientParameters.
+// NOTE: Ordinary users of this library should never need this and should pass nil.
+// noticeReceiver, if non-nil, will be called for each notice emitted by tunnel core.
+// NOTE: Ordinary users of this library should never need this and should pass nil.
+func StartTunnel(ctx context.Context,
+	configJSON []byte, embeddedServerEntryList string,
+	params Parameters, paramsDelta ClientParametersDelta,
+	noticeReceiver func(NoticeEvent)) (tunnel *PsiphonTunnel, err error) {
+
+	config, err := psiphon.LoadConfig(configJSON)
+	if err != nil {
+		return nil, common.ContextErrorMsg(err, "failed to load config file")
+	}
+
+	// Use params.DataRootDirectory to set related config values.
+	if params.DataRootDirectory != nil {
+		config.DataStoreDirectory = *params.DataRootDirectory
+		config.ObfuscatedServerListDownloadDirectory = *params.DataRootDirectory
+
+		if *params.DataRootDirectory == "" {
+			config.RemoteServerListDownloadFilename = ""
+		} else {
+			config.RemoteServerListDownloadFilename = filepath.Join(*params.DataRootDirectory, "server_list_compressed")
+		}
+	}
+
+	if params.NetworkID != nil {
+		config.NetworkID = *params.NetworkID
+	}
+	if config.NetworkID == "" {
+		return nil, common.ContextError(fmt.Errorf("networkID must be non-empty"))
+	}
+
+	if params.ClientPlatform != nil {
+		config.ClientPlatform = *params.ClientPlatform
+	} // else use the value in config
+
+	if params.EstablishTunnelTimeoutSeconds != nil {
+		config.EstablishTunnelTimeoutSeconds = params.EstablishTunnelTimeoutSeconds
+	} // else use the value in config
+
+	// config.Commit must be called before calling config.SetClientParameters
+	// or attempting to connect.
+	err = config.Commit()
+	if err != nil {
+		return nil, common.ContextErrorMsg(err, "config.Commit failed")
+	}
+
+	// If supplied, apply the client parameters delta
+	if len(paramsDelta) > 0 {
+		err = config.SetClientParameters("", false, paramsDelta)
+		if err != nil {
+			return nil, common.ContextErrorMsg(
+				err, fmt.Sprintf("SetClientParameters failed for delta: %v", paramsDelta))
+		}
+	}
+
+	err = psiphon.OpenDataStore(config)
+	if err != nil {
+		return nil, common.ContextErrorMsg(err, "failed to open data store")
+	}
+	// Make sure we close the datastore in case of error
+	defer func() {
+		if err != nil {
+			psiphon.CloseDataStore()
+		}
+	}()
+
+	// Store embedded server entries
+	serverEntries, err := protocol.DecodeServerEntryList(
+		embeddedServerEntryList,
+		common.GetCurrentTimestamp(),
+		protocol.SERVER_ENTRY_SOURCE_EMBEDDED)
+	if err != nil {
+		return nil, common.ContextErrorMsg(err, "failed to decode server entry list")
+	}
+
+	err = psiphon.StoreServerEntries(config, serverEntries, false)
+	if err != nil {
+		return nil, common.ContextErrorMsg(err, "failed to store server entries")
+	}
+
+	// Will receive a value when the tunnel has successfully connected.
+	connected := make(chan struct{})
+	// Will receive a value if the tunnel times out trying to connect.
+	timedOut := make(chan struct{})
+	// Will receive a value if an error occurs during the connection sequence.
+	errored := make(chan error)
+
+	// Create the tunnel object
+	tunnel = new(PsiphonTunnel)
+
+	// Set up notice handling
+	psiphon.SetNoticeWriter(psiphon.NewNoticeReceiver(
+		func(notice []byte) {
+			var event NoticeEvent
+			err := json.Unmarshal(notice, &event)
+			if err != nil {
+				// This is unexpected and probably indicates something fatal has occurred.
+				// We'll interpret it as a connection error and abort.
+				err = common.ContextErrorMsg(err, "failed to unmarshal notice JSON")
+				select {
+				case errored <- err:
+				default:
+				}
+				return
+			}
+
+			if event.Type == "ListeningHttpProxyPort" {
+				port := event.Data["port"].(float64)
+				tunnel.HTTPProxyPort = int(port)
+			} else if event.Type == "ListeningSocksProxyPort" {
+				port := event.Data["port"].(float64)
+				tunnel.SOCKSProxyPort = int(port)
+			} else if event.Type == "EstablishTunnelTimeout" {
+				select {
+				case timedOut <- struct{}{}:
+				default:
+				}
+			} else if event.Type == "Tunnels" {
+				count := event.Data["count"].(float64)
+				if count > 0 {
+					select {
+					case connected <- struct{}{}:
+					default:
+					}
+				}
+			}
+
+			// Some users of this package may need to add special processing of notices.
+			// If the caller has requested it, we'll pass on the notices.
+			if noticeReceiver != nil {
+				noticeReceiver(event)
+			}
+		}))
+
+	// Create the Psiphon controller
+	controller, err := psiphon.NewController(config)
+	if err != nil {
+		return nil, common.ContextErrorMsg(err, "psiphon.NewController failed")
+	}
+
+	// Create a cancelable context that will be used for stopping the tunnel
+	var controllerCtx context.Context
+	controllerCtx, tunnel.stopController = context.WithCancel(ctx)
+
+	// Begin tunnel connection
+	tunnel.controllerWaitGroup.Add(1)
+	go func() {
+		defer tunnel.controllerWaitGroup.Done()
+
+		// Start the tunnel. Only returns on error (or internal timeout).
+		controller.Run(controllerCtx)
+
+		select {
+		case errored <- errors.New("controller.Run exited unexpectedly"):
+		default:
+		}
+	}()
+
+	// Wait for an active tunnel, timeout, or error
+	select {
+	case <-connected:
+		return tunnel, nil
+	case <-timedOut:
+		tunnel.Stop()
+		return nil, ErrTimeout
+	case err := <-errored:
+		tunnel.Stop()
+		return nil, common.ContextErrorMsg(err, "tunnel start produced error")
+	}
+}
+
+// Stop stops/disconnects/shuts down the tunnel. It is safe to call when not connected.
+func (tunnel *PsiphonTunnel) Stop() {
+	if tunnel.stopController != nil {
+		tunnel.stopController()
+	}
+
+	tunnel.controllerWaitGroup.Wait()
+
+	psiphon.CloseDataStore()
+}

+ 152 - 0
ClientLibrary/clientlib/clientlib_test.go

@@ -0,0 +1,152 @@
+/*
+ * Copyright (c) 2018, 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 clientlib
+
+import (
+	"context"
+	"flag"
+	"fmt"
+	"io/ioutil"
+	"os"
+	"testing"
+	"time"
+)
+
+var testDataDirName string
+
+func TestMain(m *testing.M) {
+	flag.Parse()
+
+	var err error
+	testDataDirName, err = ioutil.TempDir("", "psiphon-clientlib-test")
+	if err != nil {
+		fmt.Printf("TempDir failed: %s\n", err)
+		os.Exit(1)
+	}
+	defer os.RemoveAll(testDataDirName)
+
+	os.Exit(m.Run())
+}
+
+func getConfigJSON(t *testing.T) []byte {
+	configJSON, err := ioutil.ReadFile("../../psiphon/controller_test.config")
+	if err != nil {
+		// Skip, don't fail, if config file is not present
+		t.Skipf("error loading configuration file: %s", err)
+	}
+
+	return configJSON
+}
+
+func TestStartTunnel(t *testing.T) {
+	// TODO: More comprehensive tests. This is only a smoke test.
+
+	configJSON := getConfigJSON(t)
+	clientPlatform := "clientlib_test.go"
+	networkID := "UNKNOWN"
+	timeout := 10
+
+	// Cancels the context after a duration. Pass 0 for no cancel.
+	// (Note that cancelling causes an error, not a timeout.)
+	contextGetter := func(cancelAfter time.Duration) func() context.Context {
+		return func() context.Context {
+			if cancelAfter == 0 {
+				return context.Background()
+			}
+
+			ctx, ctxCancel := context.WithCancel(context.Background())
+			go func() {
+				time.Sleep(cancelAfter)
+				ctxCancel()
+			}()
+			return ctx
+		}
+	}
+
+	type args struct {
+		ctxGetter               func() context.Context
+		configJSON              []byte
+		embeddedServerEntryList string
+		params                  Parameters
+		paramsDelta             ClientParametersDelta
+		noticeReceiver          func(NoticeEvent)
+	}
+	tests := []struct {
+		name       string
+		args       args
+		wantTunnel bool
+		wantErr    bool
+	}{
+		{
+			name: "Success: simple",
+			args: args{
+				ctxGetter:               contextGetter(0),
+				configJSON:              configJSON,
+				embeddedServerEntryList: "",
+				params: Parameters{
+					DataRootDirectory:             &testDataDirName,
+					ClientPlatform:                &clientPlatform,
+					NetworkID:                     &networkID,
+					EstablishTunnelTimeoutSeconds: &timeout,
+				},
+				paramsDelta:    nil,
+				noticeReceiver: nil,
+			},
+			wantTunnel: true,
+			wantErr:    false,
+		},
+		{
+			name: "Failure: timeout",
+			args: args{
+				ctxGetter:               contextGetter(10 * time.Millisecond),
+				configJSON:              configJSON,
+				embeddedServerEntryList: "",
+				params: Parameters{
+					DataRootDirectory:             &testDataDirName,
+					ClientPlatform:                &clientPlatform,
+					NetworkID:                     &networkID,
+					EstablishTunnelTimeoutSeconds: &timeout,
+				},
+				paramsDelta:    nil,
+				noticeReceiver: nil,
+			},
+			wantTunnel: false,
+			wantErr:    true,
+		},
+	}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			gotTunnel, err := StartTunnel(tt.args.ctxGetter(),
+				tt.args.configJSON, tt.args.embeddedServerEntryList,
+				tt.args.params, tt.args.paramsDelta, tt.args.noticeReceiver)
+			if (err != nil) != tt.wantErr {
+				t.Fatalf("StartTunnel() error = %v, wantErr %v", err, tt.wantErr)
+				return
+			}
+			if (gotTunnel != nil) != tt.wantTunnel {
+				t.Errorf("StartTunnel() gotTunnel = %v, wantTunnel %v", err, tt.wantTunnel)
+			}
+
+			if gotTunnel != nil {
+				gotTunnel.Stop()
+			}
+		})
+	}
+}

+ 19 - 8
ClientLibrary/example/main.c

@@ -14,7 +14,7 @@ char *read_file(char *filename) {
     }
 
     fseek(fp, 0, SEEK_END);
-    size = ftell(fp); 
+    size = ftell(fp);
 
     rewind(fp);
     buffer = malloc((size + 1) * sizeof(*buffer));
@@ -26,7 +26,7 @@ char *read_file(char *filename) {
 }
 
 int main(int argc, char *argv[]) {
-    
+
     // load config
     char * const default_config = "psiphon_config";
 
@@ -44,7 +44,7 @@ int main(int argc, char *argv[]) {
     }
 
     // set server list
-    char *serverList = "";
+    char *server_list = "";
 
     // set client platform
     char * const os = "OSName"; // "Android", "iOS", "Windows", etc.
@@ -57,20 +57,31 @@ int main(int argc, char *argv[]) {
     // set network ID
     char * const network_id = "TEST";
 
-    // set timout
-    long long *timeout = (long long*)malloc(sizeof(long long));
-    *timeout = (long long)60;
+    // set timeout
+    int32_t timeout = 60;
+
+    struct Parameters params;
+    params.sizeofStruct = sizeof(struct Parameters);
+    params.dataRootDirectory = ".";
+    params.clientPlatform = client_platform;
+    params.networkID = network_id;
+    params.establishTunnelTimeoutSeconds = &timeout;
 
     // connect 5 times
     for (int i = 0; i < 5; i++) {
         // start will return once Psiphon connects or does not connect for timeout seconds
-        char *result = psiphon_tunnel_start(psiphon_config, serverList, client_platform, network_id, timeout);
+        char *result = PsiphonTunnelStart(psiphon_config, server_list, &params);
 
         // print results
         printf("Result: %s\n", result);
 
         // The underlying memory of `result` is managed by PsiphonTunnel and is freed in Stop
-        psiphon_tunnel_stop();
+        PsiphonTunnelStop();
     }
+
+    free(client_platform);
+    client_platform = NULL;
+    free(psiphon_config);
+    psiphon_config = NULL;
 }