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

Merge pull request #144 from rod-hynes/master

New client stats
Rod Hynes 10 лет назад
Родитель
Сommit
f8d65036d2

+ 4 - 1
AndroidLibrary/psi/psi.go

@@ -75,7 +75,10 @@ func Start(
 		return fmt.Errorf("error initializing datastore: %s", err)
 	}
 
-	serverEntries, err := psiphon.DecodeAndValidateServerEntryList(embeddedServerEntryList)
+	serverEntries, err := psiphon.DecodeAndValidateServerEntryList(
+		embeddedServerEntryList,
+		psiphon.GetCurrentTimestamp(),
+		psiphon.SERVER_ENTRY_SOURCE_EMBEDDED)
 	if err != nil {
 		return fmt.Errorf("error decoding embedded server entry list: %s", err)
 	}

+ 4 - 1
ConsoleClient/psiphonClient.go

@@ -135,7 +135,10 @@ func main() {
 				return
 			}
 			// TODO: stream embedded server list data? also, the cast makes an unnecessary copy of a large buffer?
-			serverEntries, err := psiphon.DecodeAndValidateServerEntryList(string(serverEntryList))
+			serverEntries, err := psiphon.DecodeAndValidateServerEntryList(
+				string(serverEntryList),
+				psiphon.GetCurrentTimestamp(),
+				psiphon.SERVER_ENTRY_SOURCE_EMBEDDED)
 			if err != nil {
 				psiphon.NoticeError("error decoding embedded server entry list file: %s", err)
 				return

+ 21 - 0
SampleApps/Psibot/app/src/main/java/ca/psiphon/PsiphonTunnel.java

@@ -27,6 +27,7 @@ import android.net.NetworkInfo;
 import android.net.VpnService;
 import android.os.Build;
 import android.os.ParcelFileDescriptor;
+import android.telephony.TelephonyManager;
 import android.util.Base64;
 
 import org.apache.http.conn.util.InetAddressUtils;
@@ -375,6 +376,8 @@ public class PsiphonTunnel extends Psi.PsiphonProvider.Stub {
             mHostService.onDiagnosticMessage(e.getMessage());
         }
 
+        json.put("DeviceRegion", getDeviceRegion(mHostService.getContext()));
+
         return json.toString();
     }
 
@@ -537,6 +540,24 @@ public class PsiphonTunnel extends Psi.PsiphonProvider.Stub {
         }
     }
 
+    private static String getDeviceRegion(Context context) {
+        String region = "";
+        TelephonyManager telephonyManager = (TelephonyManager)context.getSystemService(Context.TELEPHONY_SERVICE);
+        if (telephonyManager != null) {
+            region = telephonyManager.getSimCountryIso();
+            if (region.length() == 0 && telephonyManager.getPhoneType() != TelephonyManager.PHONE_TYPE_CDMA) {
+                region = telephonyManager.getNetworkCountryIso();
+            }
+        }
+        if (region.length() == 0) {
+            Locale defaultLocale = Locale.getDefault();
+            if (defaultLocale != null) {
+                region = defaultLocale.getCountry();
+            }
+        }
+        return region.toUpperCase();
+    }
+
     //----------------------------------------------------------------------------------------------
     // Tun2Socks
     //----------------------------------------------------------------------------------------------

+ 22 - 0
SampleApps/TunneledWebView/app/src/main/java/ca/psiphon/PsiphonTunnel.java

@@ -24,6 +24,7 @@ import android.content.Context;
 import android.net.ConnectivityManager;
 import android.net.NetworkInfo;
 import android.os.Build;
+import android.telephony.TelephonyManager;
 import android.util.Base64;
 
 import org.json.JSONArray;
@@ -43,6 +44,7 @@ import java.security.cert.X509Certificate;
 import java.util.ArrayList;
 import java.util.Enumeration;
 import java.util.List;
+import java.util.Locale;
 import java.util.concurrent.atomic.AtomicBoolean;
 
 import go.psi.Psi;
@@ -224,6 +226,8 @@ public class PsiphonTunnel extends Psi.PsiphonProvider.Stub {
             mTunneledApp.onDiagnosticMessage(e.getMessage());
         }
 
+        json.put("DeviceRegion", getDeviceRegion(mTunneledApp.getContext()));
+
         return json.toString();
     }
 
@@ -381,4 +385,22 @@ public class PsiphonTunnel extends Psi.PsiphonProvider.Stub {
             throw new Exception(errorMessage, e);
         }
     }
+
+    private static String getDeviceRegion(Context context) {
+        String region = "";
+        TelephonyManager telephonyManager = (TelephonyManager)context.getSystemService(Context.TELEPHONY_SERVICE);
+        if (telephonyManager != null) {
+            region = telephonyManager.getSimCountryIso();
+            if (region.length() == 0 && telephonyManager.getPhoneType() != TelephonyManager.PHONE_TYPE_CDMA) {
+                region = telephonyManager.getNetworkCountryIso();
+            }
+        }
+        if (region.length() == 0) {
+            Locale defaultLocale = Locale.getDefault();
+            if (defaultLocale != null) {
+                region = defaultLocale.getCountry();
+            }
+        }
+        return region.toUpperCase();
+    }
 }

+ 6 - 0
psiphon/TCPConn.go

@@ -61,6 +61,12 @@ func makeTCPDialer(config *DialConfig) func(network, addr string) (net.Conn, err
 		if err != nil {
 			return nil, ContextError(err)
 		}
+		if config.ResolvedIPCallback != nil {
+			host, _, err := net.SplitHostPort(conn.RemoteAddr().String())
+			if err == nil {
+				config.ResolvedIPCallback(host)
+			}
+		}
 		return conn, nil
 	}
 }

+ 8 - 0
psiphon/config.go

@@ -281,6 +281,14 @@ type Config struct {
 	// 1-2 minutes, when the tunnel is idle. If the SSH keepalive times out, the tunnel
 	// is considered to have failed.
 	DisablePeriodicSshKeepAlive bool
+
+	// DeviceRegion is the optional, reported region the host device is running in.
+	// This input value should be a ISO 3166-1 alpha-2 country code. The device region
+	// is reported to the server in the connected request and recorded for Psiphon
+	// stats.
+	// When provided, this value may be used, pre-connection, to select performance
+	// or circumvention optimization strategies for the given region.
+	DeviceRegion string
 }
 
 // LoadConfig parses and validates a JSON format Psiphon config JSON

+ 1 - 0
psiphon/controller.go

@@ -91,6 +91,7 @@ func NewController(config *Config) (controller *Controller, err error) {
 		DnsServerGetter:               config.DnsServerGetter,
 		UseIndistinguishableTLS:       config.UseIndistinguishableTLS,
 		TrustedCACertificatesFilename: config.TrustedCACertificatesFilename,
+		DeviceRegion:                  config.DeviceRegion,
 	}
 
 	controller = &Controller{

+ 2 - 1
psiphon/dataStore.go

@@ -396,7 +396,8 @@ func NewServerEntryIterator(config *Config) (iterator *ServerEntryIterator, err
 
 // newTargetServerEntryIterator is a helper for initializing the TargetServerEntry case
 func newTargetServerEntryIterator(config *Config) (iterator *ServerEntryIterator, err error) {
-	serverEntry, err := DecodeServerEntry(config.TargetServerEntry)
+	serverEntry, err := DecodeServerEntry(
+		config.TargetServerEntry, GetCurrentTimestamp(), SERVER_ENTRY_SOURCE_TARGET)
 	if err != nil {
 		return nil, err
 	}

+ 12 - 0
psiphon/net.go

@@ -83,6 +83,18 @@ type DialConfig struct {
 	// SSL_CTX_load_verify_locations.
 	// Only applies to UseIndistinguishableTLS connections.
 	TrustedCACertificatesFilename string
+
+	// DeviceRegion is the reported region the host device is running in.
+	// When set, this value may be used, pre-connection, to select performance
+	// or circumvention optimization strategies for the given region.
+	DeviceRegion string
+
+	// ResolvedIPCallback, when set, is called with the IP address that was
+	// dialed. This is either the specified IP address in the dial address,
+	// or the resolved IP address in the case where the dial address is a
+	// domain name.
+	// The callback may be invoked by a concurrent goroutine.
+	ResolvedIPCallback func(string)
 }
 
 // DeviceBinder defines the interface to the external BindToDevice provider

+ 9 - 0
psiphon/notice.go

@@ -214,6 +214,15 @@ func NoticeTotalBytesTransferred(ipAddress string, sent, received int64) {
 	outputNotice("TotalBytesTransferred", false, "ipAddress", ipAddress, "sent", sent, "received", received)
 }
 
+// NoticeFrontedMeekStats reports extra network details for a
+// FRONTED-MEEK-OSSH tunnel connection.
+func NoticeFrontedMeekStats(ipAddress string, frontedMeekStats *FrontedMeekStats) {
+	outputNotice("NoticeFrontedMeekStats", false, "ipAddress", ipAddress,
+		"frontingAddress", frontedMeekStats.frontingAddress,
+		"resolvedIPAddress", frontedMeekStats.resolvedIPAddress,
+		"enabledSNI", frontedMeekStats.enabledSNI)
+}
+
 // NoticeLocalProxyError reports a local proxy error message. Repetitive
 // errors for a given proxy type are suppressed.
 func NoticeLocalProxyError(proxyType string, err error) {

+ 4 - 1
psiphon/remoteServerList.go

@@ -86,7 +86,10 @@ func FetchRemoteServerList(config *Config, dialConfig *DialConfig) (err error) {
 		return ContextError(err)
 	}
 
-	serverEntries, err := DecodeAndValidateServerEntryList(remoteServerList)
+	serverEntries, err := DecodeAndValidateServerEntryList(
+		remoteServerList,
+		GetCurrentTimestamp(),
+		SERVER_ENTRY_SOURCE_REMOTE)
 	if err != nil {
 		return ContextError(err)
 	}

+ 59 - 1
psiphon/serverApi.go

@@ -51,6 +51,15 @@ type ServerContext struct {
 	serverHandshakeTimestamp string
 }
 
+// FrontedMeekStats holds extra stats that are only gathered for
+// FRONTED-MEEK-OSSH. Specifically, which fronting address was selected, what
+// IP address a fronting domain resolved to, and whether SNI was enabled.
+type FrontedMeekStats struct {
+	frontingAddress   string
+	resolvedIPAddress string
+	enabledSNI        bool
+}
+
 // nextTunnelNumber is a monotonically increasing number assigned to each
 // successive tunnel connection. The sessionId and tunnelNumber together
 // form a globally unique identifier for tunnels, which is used for
@@ -152,11 +161,18 @@ func (serverContext *ServerContext) doHandshakeRequest() error {
 	var decodedServerEntries []*ServerEntry
 
 	// Store discovered server entries
+	// We use the server's time, as it's available here, for the server entry
+	// timestamp since this is more reliable than the client time.
 	for _, encodedServerEntry := range handshakeConfig.EncodedServerList {
-		serverEntry, err := DecodeServerEntry(encodedServerEntry)
+
+		serverEntry, err := DecodeServerEntry(
+			encodedServerEntry,
+			TruncateTimestampToHour(handshakeConfig.ServerTimestamp),
+			SERVER_ENTRY_SOURCE_DISCOVERY)
 		if err != nil {
 			return ContextError(err)
 		}
+
 		err = ValidateServerEntry(serverEntry)
 		if err != nil {
 			// Skip this entry and continue with the next one
@@ -599,6 +615,48 @@ func makeBaseRequestUrl(tunnel *Tunnel, port, sessionId string) string {
 	requestUrl.WriteString(tunnel.config.ClientPlatform)
 	requestUrl.WriteString("&tunnel_whole_device=")
 	requestUrl.WriteString(strconv.Itoa(tunnel.config.TunnelWholeDevice))
+
+	// The following parameters may be blank and must
+	// not be sent to the server if blank.
+
+	if tunnel.config.DeviceRegion != "" {
+		requestUrl.WriteString("&device_region=")
+		requestUrl.WriteString(tunnel.config.DeviceRegion)
+	}
+
+	if tunnel.frontedMeekStats != nil {
+		requestUrl.WriteString("&fronting_address=")
+		requestUrl.WriteString(tunnel.frontedMeekStats.frontingAddress)
+		requestUrl.WriteString("&fronting_resolved_ip_address=")
+		requestUrl.WriteString(tunnel.frontedMeekStats.resolvedIPAddress)
+		requestUrl.WriteString("&fronting_enabled_sni=")
+		if tunnel.frontedMeekStats.enabledSNI {
+			requestUrl.WriteString("1")
+		} else {
+			requestUrl.WriteString("0")
+		}
+	}
+
+	if tunnel.serverEntry.Region != "" {
+		requestUrl.WriteString("&server_entry_region=")
+		requestUrl.WriteString(tunnel.serverEntry.Region)
+	}
+
+	if tunnel.serverEntry.LocalSource != "" {
+		requestUrl.WriteString("&server_entry_source=")
+		requestUrl.WriteString(tunnel.serverEntry.LocalSource)
+	}
+
+	// As with last_connected, this timestamp stat, which may be
+	// a precise handshake request server timestamp, is truncated
+	// to hour granularity to avoid introducing a reconstructable
+	// cross-session user trace into server logs.
+	localServerEntryTimestamp := TruncateTimestampToHour(tunnel.serverEntry.LocalTimestamp)
+	if localServerEntryTimestamp != "" {
+		requestUrl.WriteString("&server_entry_timestamp=")
+		requestUrl.WriteString(localServerEntryTimestamp)
+	}
+
 	return requestUrl.String()
 }
 

+ 41 - 5
psiphon/serverEntry.go

@@ -46,8 +46,8 @@ var SupportedTunnelProtocols = []string{
 }
 
 // ServerEntry represents a Psiphon server. It contains information
-// about how to estalish a tunnel connection to the server through
-// several protocols. ServerEntry are JSON records downloaded from
+// about how to establish a tunnel connection to the server through
+// several protocols. Server entries are JSON records downloaded from
 // various sources.
 type ServerEntry struct {
 	IpAddress                     string   `json:"ipAddress"`
@@ -69,8 +69,23 @@ type ServerEntry struct {
 	MeekFrontingDomain            string   `json:"meekFrontingDomain"`
 	MeekFrontingAddresses         []string `json:"meekFrontingAddresses"`
 	MeekFrontingAddressesRegex    string   `json:"meekFrontingAddressesRegex"`
+
+	// These local fields are not expected to be present in downloaded server
+	// entries. They are added by the client to record and report stats about
+	// how and when server entries are obtained.
+	LocalSource    string `json:"localSource"`
+	LocalTimestamp string `json:"localTimestamp"`
 }
 
+type ServerEntrySource string
+
+const (
+	SERVER_ENTRY_SOURCE_EMBEDDED  ServerEntrySource = "EMBEDDED"
+	SERVER_ENTRY_SOURCE_REMOTE    ServerEntrySource = "REMOTE"
+	SERVER_ENTRY_SOURCE_DISCOVERY ServerEntrySource = "DISCOVERY"
+	SERVER_ENTRY_SOURCE_TARGET    ServerEntrySource = "TARGET"
+)
+
 // SupportsProtocol returns true if and only if the ServerEntry has
 // the necessary capability to support the specified tunnel protocol.
 func (serverEntry *ServerEntry) SupportsProtocol(protocol string) bool {
@@ -127,22 +142,39 @@ func (serverEntry *ServerEntry) GetDirectWebRequestPorts() []string {
 
 // DecodeServerEntry extracts server entries from the encoding
 // used by remote server lists and Psiphon server handshake requests.
-func DecodeServerEntry(encodedServerEntry string) (serverEntry *ServerEntry, err error) {
+//
+// The resulting ServerEntry.LocalSource is populated with serverEntrySource,
+// which should be one of SERVER_ENTRY_SOURCE_EMBEDDED, SERVER_ENTRY_SOURCE_REMOTE,
+// SERVER_ENTRY_SOURCE_DISCOVERY, SERVER_ENTRY_SOURCE_TARGET.
+// ServerEntry.LocalTimestamp is populated with the provided timestamp, which
+// should be a RFC 3339 formatted string. These local fields are stored with the
+// server entry and reported to the server as stats (a coarse granularity timestamp
+// is reported).
+func DecodeServerEntry(
+	encodedServerEntry, timestamp string,
+	serverEntrySource ServerEntrySource) (serverEntry *ServerEntry, err error) {
+
 	hexDecodedServerEntry, err := hex.DecodeString(encodedServerEntry)
 	if err != nil {
 		return nil, ContextError(err)
 	}
+
 	// Skip past legacy format (4 space delimited fields) and just parse the JSON config
 	fields := bytes.SplitN(hexDecodedServerEntry, []byte(" "), 5)
 	if len(fields) != 5 {
 		return nil, ContextError(errors.New("invalid encoded server entry"))
 	}
+
 	serverEntry = new(ServerEntry)
 	err = json.Unmarshal(fields[4], &serverEntry)
 	if err != nil {
 		return nil, ContextError(err)
 	}
 
+	// NOTE: if the source JSON happens to have values in these fields, they get clobbered.
+	serverEntry.LocalSource = string(serverEntrySource)
+	serverEntry.LocalTimestamp = timestamp
+
 	return serverEntry, nil
 }
 
@@ -166,7 +198,11 @@ func ValidateServerEntry(serverEntry *ServerEntry) error {
 // DecodeAndValidateServerEntryList extracts server entries from the list encoding
 // used by remote server lists and Psiphon server handshake requests.
 // Each server entry is validated and invalid entries are skipped.
-func DecodeAndValidateServerEntryList(encodedServerEntryList string) (serverEntries []*ServerEntry, err error) {
+// See DecodeServerEntry for note on serverEntrySource/timestamp.
+func DecodeAndValidateServerEntryList(
+	encodedServerEntryList, timestamp string,
+	serverEntrySource ServerEntrySource) (serverEntries []*ServerEntry, err error) {
+
 	serverEntries = make([]*ServerEntry, 0)
 	for _, encodedServerEntry := range strings.Split(encodedServerEntryList, "\n") {
 		if len(encodedServerEntry) == 0 {
@@ -174,7 +210,7 @@ func DecodeAndValidateServerEntryList(encodedServerEntryList string) (serverEntr
 		}
 
 		// TODO: skip this entry and continue if can't decode?
-		serverEntry, err := DecodeServerEntry(encodedServerEntry)
+		serverEntry, err := DecodeServerEntry(encodedServerEntry, timestamp, serverEntrySource)
 		if err != nil {
 			return nil, ContextError(err)
 		}

+ 4 - 2
psiphon/serverEntry_test.go

@@ -40,7 +40,8 @@ func TestDecodeAndValidateServerEntryList(t *testing.T) {
 		hex.EncodeToString([]byte(_INVALID_WINDOWS_REGISTRY_LEGACY_SERVER_ENTRY)) + "\n" +
 		hex.EncodeToString([]byte(_INVALID_MALFORMED_IP_ADDRESS_SERVER_ENTRY))
 
-	serverEntries, err := DecodeAndValidateServerEntryList(testEncodedServerEntryList)
+	serverEntries, err := DecodeAndValidateServerEntryList(
+		testEncodedServerEntryList, GetCurrentTimestamp(), SERVER_ENTRY_SOURCE_EMBEDDED)
 	if err != nil {
 		t.Error(err.Error())
 		t.FailNow()
@@ -62,7 +63,8 @@ func TestInvalidServerEntries(t *testing.T) {
 
 	for _, testCase := range testCases {
 		encodedServerEntry := hex.EncodeToString([]byte(testCase))
-		serverEntry, err := DecodeServerEntry(encodedServerEntry)
+		serverEntry, err := DecodeServerEntry(
+			encodedServerEntry, GetCurrentTimestamp(), SERVER_ENTRY_SOURCE_EMBEDDED)
 		if err != nil {
 			t.Error(err.Error())
 		}

+ 7 - 2
psiphon/tlsDialer.go

@@ -174,8 +174,13 @@ func CustomTLSDial(network, addr string, config *CustomTLSConfig) (net.Conn, err
 
 	if config.SendServerName && config.VerifyLegacyCertificate == nil {
 		// Set the ServerName and rely on the usual logic in
-		// tls.Conn.Handshake() to do its verification
-		tlsConfig.ServerName = hostname
+		// tls.Conn.Handshake() to do its verification.
+		// Explicitly exclude IPs:
+		// - "Literal IPv4 and IPv6 addresses are not permitted": https://tools.ietf.org/html/rfc6066#page-6.
+		// - OpenSSL does not appear to enforce this rule itself.
+		if net.ParseIP(hostname) == nil {
+			tlsConfig.ServerName = hostname
+		}
 	} else {
 		// No SNI.
 		// Disable verification in tls.Conn.Handshake().  We'll verify manually

+ 43 - 13
psiphon/tunnel.go

@@ -28,6 +28,7 @@ import (
 	"io"
 	"net"
 	"sync"
+	"sync/atomic"
 	"time"
 
 	regen "github.com/Psiphon-Inc/goregen"
@@ -77,6 +78,7 @@ type Tunnel struct {
 	signalPortForwardFailure chan struct{}
 	totalPortForwardFailures int
 	startTime                time.Time
+	frontedMeekStats         *FrontedMeekStats
 }
 
 // EstablishTunnel first makes a network transport connection to the
@@ -103,7 +105,7 @@ func EstablishTunnel(
 	}
 
 	// Build transport layers and establish SSH connection
-	conn, sshClient, err := dialSsh(
+	conn, sshClient, frontedMeekStats, err := dialSsh(
 		config, pendingConns, serverEntry, selectedProtocol, sessionId)
 	if err != nil {
 		return nil, ContextError(err)
@@ -132,6 +134,7 @@ func EstablishTunnel(
 		// A buffer allows at least one signal to be sent even when the receiver is
 		// not listening. Senders should not block.
 		signalPortForwardFailure: make(chan struct{}, 1),
+		frontedMeekStats:         frontedMeekStats,
 	}
 
 	// Create a new Psiphon API server context for this tunnel. This includes
@@ -331,13 +334,15 @@ func selectProtocol(config *Config, serverEntry *ServerEntry) (selectedProtocol
 	return selectedProtocol, nil
 }
 
-// dialSsh is a helper that builds the transport layers and establishes the SSH connection
+// dialSsh is a helper that builds the transport layers and establishes the SSH connection.
+// When  FRONTED-MEEK-OSSH is selected, additional FrontedMeekStats are recorded and returned.
 func dialSsh(
 	config *Config,
 	pendingConns *Conns,
 	serverEntry *ServerEntry,
 	selectedProtocol,
-	sessionId string) (conn net.Conn, sshClient *ssh.Client, err error) {
+	sessionId string) (
+	conn net.Conn, sshClient *ssh.Client, frontedMeekStats *FrontedMeekStats, err error) {
 
 	// The meek protocols tunnel obfuscated SSH. Obfuscated SSH is layered on top of SSH.
 	// So depending on which protocol is used, multiple layers are initialized.
@@ -376,7 +381,7 @@ func dialSsh(
 
 			frontingAddress, err = regen.Generate(serverEntry.MeekFrontingAddressesRegex)
 			if err != nil {
-				return nil, nil, ContextError(err)
+				return nil, nil, nil, ContextError(err)
 			}
 		} else {
 
@@ -384,21 +389,34 @@ func dialSsh(
 			// fronting-capable servers.
 
 			if len(serverEntry.MeekFrontingAddresses) == 0 {
-				return nil, nil, ContextError(errors.New("MeekFrontingAddresses is empty"))
+				return nil, nil, nil, ContextError(errors.New("MeekFrontingAddresses is empty"))
 			}
 			index, err := MakeSecureRandomInt(len(serverEntry.MeekFrontingAddresses))
 			if err != nil {
-				return nil, nil, ContextError(err)
+				return nil, nil, nil, ContextError(err)
 			}
 			frontingAddress = serverEntry.MeekFrontingAddresses[index]
 		}
 	}
+
 	NoticeConnectingServer(
 		serverEntry.IpAddress,
 		serverEntry.Region,
 		selectedProtocol,
 		frontingAddress)
 
+	// Use an asynchronous callback to record the resolved IP address when
+	// dialing a domain name. Note that DialMeek doesn't immediately
+	// establish any HTTPS connections, so the resolved IP address won't be
+	// reported until during/after ssh session establishment (the ssh traffic
+	// is meek payload). So don't Load() the IP address value until after that
+	// has completed to ensure a result.
+	var resolvedIPAddress atomic.Value
+	resolvedIPAddress.Store("")
+	setResolvedIPAddress := func(IPAddress string) {
+		resolvedIPAddress.Store(IPAddress)
+	}
+
 	// Create the base transport: meek or direct connection
 	dialConfig := &DialConfig{
 		UpstreamProxyUrl:              config.UpstreamProxyUrl,
@@ -408,16 +426,18 @@ func dialSsh(
 		DnsServerGetter:               config.DnsServerGetter,
 		UseIndistinguishableTLS:       config.UseIndistinguishableTLS,
 		TrustedCACertificatesFilename: config.TrustedCACertificatesFilename,
+		DeviceRegion:                  config.DeviceRegion,
+		ResolvedIPCallback:            setResolvedIPAddress,
 	}
 	if useMeek {
 		conn, err = DialMeek(serverEntry, sessionId, useMeekHttps, frontingAddress, dialConfig)
 		if err != nil {
-			return nil, nil, ContextError(err)
+			return nil, nil, nil, ContextError(err)
 		}
 	} else {
 		conn, err = DialTCP(fmt.Sprintf("%s:%d", serverEntry.IpAddress, port), dialConfig)
 		if err != nil {
-			return nil, nil, ContextError(err)
+			return nil, nil, nil, ContextError(err)
 		}
 	}
 
@@ -435,14 +455,14 @@ func dialSsh(
 	if useObfuscatedSsh {
 		sshConn, err = NewObfuscatedSshConn(conn, serverEntry.SshObfuscatedKey)
 		if err != nil {
-			return nil, nil, ContextError(err)
+			return nil, nil, nil, ContextError(err)
 		}
 	}
 
 	// Now establish the SSH session over the sshConn transport
 	expectedPublicKey, err := base64.StdEncoding.DecodeString(serverEntry.SshHostKey)
 	if err != nil {
-		return nil, nil, ContextError(err)
+		return nil, nil, nil, ContextError(err)
 	}
 	sshCertChecker := &ssh.CertChecker{
 		HostKeyFallback: func(addr string, remote net.Addr, publicKey ssh.PublicKey) error {
@@ -458,7 +478,7 @@ func dialSsh(
 			SshPassword string `json:"SshPassword"`
 		}{sessionId, serverEntry.SshPassword})
 	if err != nil {
-		return nil, nil, ContextError(err)
+		return nil, nil, nil, ContextError(err)
 	}
 	sshClientConfig := &ssh.ClientConfig{
 		User: serverEntry.SshUsername,
@@ -502,10 +522,20 @@ func dialSsh(
 
 	result := <-resultChannel
 	if result.err != nil {
-		return nil, nil, ContextError(result.err)
+		return nil, nil, nil, ContextError(result.err)
+	}
+
+	if selectedProtocol == TUNNEL_PROTOCOL_FRONTED_MEEK {
+		frontedMeekStats = &FrontedMeekStats{
+			frontingAddress:   frontingAddress,
+			resolvedIPAddress: resolvedIPAddress.Load().(string),
+			enabledSNI:        false,
+		}
+
+		NoticeFrontedMeekStats(serverEntry.IpAddress, frontedMeekStats)
 	}
 
-	return conn, result.sshClient, nil
+	return conn, result.sshClient, frontedMeekStats, nil
 }
 
 // operateTunnel monitors the health of the tunnel and performs

+ 27 - 0
psiphon/utils.go

@@ -46,6 +46,15 @@ func Contains(list []string, target string) bool {
 	return false
 }
 
+// FlipCoin is a helper function that randomly
+// returns true or false. If the underlying random
+// number generator fails, FlipCoin still returns
+// a result.
+func FlipCoin() bool {
+	randomInt, _ := MakeSecureRandomInt(2)
+	return randomInt == 1
+}
+
 // MakeSecureRandomInt is a helper function that wraps
 // MakeSecureRandomInt64.
 func MakeSecureRandomInt(max int) (int, error) {
@@ -212,3 +221,21 @@ func (writer *SyncFileWriter) Write(p []byte) (n int, err error) {
 	}
 	return
 }
+
+// GetCurrentTimestamp returns the current time in UTC as
+// an RFC 3339 formatted string.
+func GetCurrentTimestamp() string {
+	return time.Now().UTC().Format(time.RFC3339)
+}
+
+// TruncateTimestampToHour truncates an RFC 3339 formatted string
+// to hour granularity. If the input is not a valid format, the
+// result is "".
+func TruncateTimestampToHour(timestamp string) string {
+	t, err := time.Parse(time.RFC3339, timestamp)
+	if err != nil {
+		NoticeAlert("failed to truncate timestamp: %s", err)
+		return ""
+	}
+	return t.Truncate(1 * time.Hour).Format(time.RFC3339)
+}