Eugene Fryntov 11 rokov pred
rodič
commit
9a99d0161f

+ 7 - 5
AndroidLibrary/psi/psi.go

@@ -57,16 +57,18 @@ func Start(configJson, embeddedServerEntryList string, provider PsiphonProvider)
 	config.DeviceBinder = provider
 	config.DeviceBinder = provider
 	config.DnsServerGetter = provider
 	config.DnsServerGetter = provider
 
 
-	err = psiphon.InitDataStore(config)
-	if err != nil {
-		return fmt.Errorf("error initializing datastore: %s", err)
-	}
-
 	psiphon.SetNoticeOutput(psiphon.NewNoticeReceiver(
 	psiphon.SetNoticeOutput(psiphon.NewNoticeReceiver(
 		func(notice []byte) {
 		func(notice []byte) {
 			provider.Notice(string(notice))
 			provider.Notice(string(notice))
 		}))
 		}))
 
 
+	// TODO: should following errors be Notices?
+
+	err = psiphon.InitDataStore(config)
+	if err != nil {
+		return fmt.Errorf("error initializing datastore: %s", err)
+	}
+
 	serverEntries, err := psiphon.DecodeAndValidateServerEntryList(embeddedServerEntryList)
 	serverEntries, err := psiphon.DecodeAndValidateServerEntryList(embeddedServerEntryList)
 	if err != nil {
 	if err != nil {
 		log.Fatalf("error decoding embedded server entry list: %s", err)
 		log.Fatalf("error decoding embedded server entry list: %s", err)

+ 36 - 18
ConsoleClient/psiphonClient.go

@@ -23,7 +23,6 @@ import (
 	"flag"
 	"flag"
 	"io"
 	"io"
 	"io/ioutil"
 	"io/ioutil"
-	"log"
 	"os"
 	"os"
 	"os/signal"
 	"os/signal"
 	"runtime/pprof"
 	"runtime/pprof"
@@ -50,42 +49,56 @@ func main() {
 
 
 	flag.Parse()
 	flag.Parse()
 
 
+	// Initialize default Notice output (stderr)
+
+	var noticeWriter io.Writer
+	noticeWriter = os.Stderr
+	if formatNotices {
+		noticeWriter = psiphon.NewNoticeConsoleRewriter(noticeWriter)
+	}
+	psiphon.SetNoticeOutput(noticeWriter)
+
 	// Handle required config file parameter
 	// Handle required config file parameter
 
 
 	if configFilename == "" {
 	if configFilename == "" {
-		log.Fatalf("configuration file is required")
+		psiphon.NoticeError("configuration file is required")
+		os.Exit(1)
 	}
 	}
 	configFileContents, err := ioutil.ReadFile(configFilename)
 	configFileContents, err := ioutil.ReadFile(configFilename)
 	if err != nil {
 	if err != nil {
-		log.Fatalf("error loading configuration file: %s", err)
+		psiphon.NoticeError("error loading configuration file: %s", err)
+		os.Exit(1)
 	}
 	}
 	config, err := psiphon.LoadConfig(configFileContents)
 	config, err := psiphon.LoadConfig(configFileContents)
 	if err != nil {
 	if err != nil {
-		log.Fatalf("error processing configuration file: %s", err)
+		psiphon.NoticeError("error processing configuration file: %s", err)
+		os.Exit(1)
 	}
 	}
 
 
-	// Initialize notice output; use logfile, if configured
+	// When a logfile is configured, reinitialize Notice output
 
 
-	var noticeWriter io.Writer
-	noticeWriter = os.Stderr
 	if config.LogFilename != "" {
 	if config.LogFilename != "" {
 		logFile, err := os.OpenFile(config.LogFilename, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0600)
 		logFile, err := os.OpenFile(config.LogFilename, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0600)
 		if err != nil {
 		if err != nil {
-			log.Fatalf("error opening log file: %s", err)
+			psiphon.NoticeError("error opening log file: %s", err)
+			os.Exit(1)
 		}
 		}
 		defer logFile.Close()
 		defer logFile.Close()
+		var noticeWriter io.Writer
+		noticeWriter = logFile
+		if formatNotices {
+			noticeWriter = psiphon.NewNoticeConsoleRewriter(noticeWriter)
+		}
+		psiphon.SetNoticeOutput(noticeWriter)
 	}
 	}
-	if formatNotices {
-		noticeWriter = psiphon.NewNoticeConsoleRewriter(noticeWriter)
-	}
-	psiphon.SetNoticeOutput(noticeWriter)
 
 
 	// Handle optional profiling parameter
 	// Handle optional profiling parameter
 
 
 	if profileFilename != "" {
 	if profileFilename != "" {
 		profileFile, err := os.Create(profileFilename)
 		profileFile, err := os.Create(profileFilename)
 		if err != nil {
 		if err != nil {
-			log.Fatalf("error opening profile file: %s", err)
+			psiphon.NoticeError("error opening profile file: %s", err)
+			os.Exit(1)
 		}
 		}
 		pprof.StartCPUProfile(profileFile)
 		pprof.StartCPUProfile(profileFile)
 		defer pprof.StopCPUProfile()
 		defer pprof.StopCPUProfile()
@@ -95,7 +108,8 @@ func main() {
 
 
 	err = psiphon.InitDataStore(config)
 	err = psiphon.InitDataStore(config)
 	if err != nil {
 	if err != nil {
-		log.Fatalf("error initializing datastore: %s", err)
+		psiphon.NoticeError("error initializing datastore: %s", err)
+		os.Exit(1)
 	}
 	}
 
 
 	// Handle optional embedded server list file parameter
 	// Handle optional embedded server list file parameter
@@ -105,18 +119,21 @@ func main() {
 	if embeddedServerEntryListFilename != "" {
 	if embeddedServerEntryListFilename != "" {
 		serverEntryList, err := ioutil.ReadFile(embeddedServerEntryListFilename)
 		serverEntryList, err := ioutil.ReadFile(embeddedServerEntryListFilename)
 		if err != nil {
 		if err != nil {
-			log.Fatalf("error loading embedded server entry list file: %s", err)
+			psiphon.NoticeError("error loading embedded server entry list file: %s", err)
+			os.Exit(1)
 		}
 		}
 		// TODO: stream embedded server list data? also, the cast makaes an unnecessary copy of a large buffer?
 		// TODO: stream embedded server list data? also, the cast makaes an unnecessary copy of a large buffer?
 		serverEntries, err := psiphon.DecodeAndValidateServerEntryList(string(serverEntryList))
 		serverEntries, err := psiphon.DecodeAndValidateServerEntryList(string(serverEntryList))
 		if err != nil {
 		if err != nil {
-			log.Fatalf("error decoding embedded server entry list file: %s", err)
+			psiphon.NoticeError("error decoding embedded server entry list file: %s", err)
+			os.Exit(1)
 		}
 		}
 		// Since embedded server list entries may become stale, they will not
 		// Since embedded server list entries may become stale, they will not
 		// overwrite existing stored entries for the same server.
 		// overwrite existing stored entries for the same server.
 		err = psiphon.StoreServerEntries(serverEntries, false)
 		err = psiphon.StoreServerEntries(serverEntries, false)
 		if err != nil {
 		if err != nil {
-			log.Fatalf("error storing embedded server entry list data: %s", err)
+			psiphon.NoticeError("error storing embedded server entry list data: %s", err)
+			os.Exit(1)
 		}
 		}
 	}
 	}
 
 
@@ -124,7 +141,8 @@ func main() {
 
 
 	controller, err := psiphon.NewController(config)
 	controller, err := psiphon.NewController(config)
 	if err != nil {
 	if err != nil {
-		log.Fatalf("error creating controller: %s", err)
+		psiphon.NoticeError("error creating controller: %s", err)
+		os.Exit(1)
 	}
 	}
 
 
 	controllerStopSignal := make(chan struct{}, 1)
 	controllerStopSignal := make(chan struct{}, 1)

+ 4 - 24
psiphon/LookupIP.go

@@ -1,7 +1,7 @@
 // +build android linux
 // +build android linux
 
 
 /*
 /*
- * Copyright (c) 2014, Psiphon Inc.
+ * Copyright (c) 2015, Psiphon Inc.
  * All rights reserved.
  * All rights reserved.
  *
  *
  * This program is free software: you can redistribute it and/or modify
  * This program is free software: you can redistribute it and/or modify
@@ -24,15 +24,12 @@ package psiphon
 import (
 import (
 	"errors"
 	"errors"
 	"fmt"
 	"fmt"
-	dns "github.com/Psiphon-Inc/dns"
 	"net"
 	"net"
 	"os"
 	"os"
 	"syscall"
 	"syscall"
 	"time"
 	"time"
 )
 )
 
 
-const DNS_PORT = 53
-
 // LookupIP resolves a hostname. When BindToDevice is not required, it
 // LookupIP resolves a hostname. When BindToDevice is not required, it
 // simply uses net.LookupIP.
 // simply uses net.LookupIP.
 // When BindToDevice is required, LookupIP explicitly creates a UDP
 // When BindToDevice is required, LookupIP explicitly creates a UDP
@@ -98,25 +95,8 @@ func bindLookupIP(host string, config *DialConfig) (addrs []net.IP, err error) {
 		conn.SetWriteDeadline(time.Now().Add(config.ConnectTimeout))
 		conn.SetWriteDeadline(time.Now().Add(config.ConnectTimeout))
 	}
 	}
 
 
-	// Make the DNS query
-	// TODO: make interruptible?
-	dnsConn := &dns.Conn{Conn: conn}
-	defer dnsConn.Close()
-	query := new(dns.Msg)
-	query.SetQuestion(dns.Fqdn(host), dns.TypeA)
-	query.RecursionDesired = true
-	dnsConn.WriteMsg(query)
+	// TODO: make conn interruptible?
 
 
-	// Process the response
-	response, err := dnsConn.ReadMsg()
-	if err != nil {
-		return nil, ContextError(err)
-	}
-	addrs = make([]net.IP, 0)
-	for _, answer := range response.Answer {
-		if a, ok := answer.(*dns.A); ok {
-			addrs = append(addrs, a.A)
-		}
-	}
-	return addrs, nil
+	addrs, _, err = ResolveIP(host, conn)
+	return
 }
 }

+ 22 - 3
psiphon/TCPConn_unix.go

@@ -73,6 +73,14 @@ func interruptibleTCPDial(addr string, config *DialConfig) (conn *TCPConn, err e
 	dialAddr := addr
 	dialAddr := addr
 	if config.UpstreamHttpProxyAddress != "" {
 	if config.UpstreamHttpProxyAddress != "" {
 		dialAddr = config.UpstreamHttpProxyAddress
 		dialAddr = config.UpstreamHttpProxyAddress
+
+		// Report connection errors in a notice, as user may have input
+		// invalid proxy address or credential
+		defer func() {
+			if err != nil {
+				NoticeUpstreamProxyError(err)
+			}
+		}()
 	}
 	}
 
 
 	// Get the remote IP and port, resolving a domain name if necessary
 	// Get the remote IP and port, resolving a domain name if necessary
@@ -90,11 +98,22 @@ func interruptibleTCPDial(addr string, config *DialConfig) (conn *TCPConn, err e
 		return nil, ContextError(err)
 		return nil, ContextError(err)
 	}
 	}
 	if len(ipAddrs) < 1 {
 	if len(ipAddrs) < 1 {
-		return nil, ContextError(errors.New("no ip address"))
+		return nil, 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 := MakeSecureRandomInt(len(ipAddrs))
+	if err != nil {
+		return nil, ContextError(err)
+	}
+
 	// TODO: IPv6 support
 	// TODO: IPv6 support
 	var ip [4]byte
 	var ip [4]byte
-	copy(ip[:], ipAddrs[0].To4())
+	copy(ip[:], ipAddrs[index].To4())
 
 
 	// Enable interruption
 	// Enable interruption
 	conn = &TCPConn{
 	conn = &TCPConn{
@@ -158,7 +177,7 @@ func interruptibleTCPDial(addr string, config *DialConfig) (conn *TCPConn, err e
 	// Going through upstream HTTP proxy
 	// Going through upstream HTTP proxy
 	if config.UpstreamHttpProxyAddress != "" {
 	if config.UpstreamHttpProxyAddress != "" {
 		// This call can be interrupted by closing the pending conn
 		// This call can be interrupted by closing the pending conn
-		err := HttpProxyConnect(conn, addr)
+		err = HttpProxyConnect(conn, addr)
 		if err != nil {
 		if err != nil {
 			return nil, ContextError(err)
 			return nil, ContextError(err)
 		}
 		}

+ 7 - 2
psiphon/TCPConn_windows.go

@@ -73,8 +73,13 @@ func interruptibleTCPDial(addr string, config *DialConfig) (conn *TCPConn, err e
 
 
 		netConn, err := net.DialTimeout("tcp", dialAddr, config.ConnectTimeout)
 		netConn, err := net.DialTimeout("tcp", dialAddr, config.ConnectTimeout)
 
 
-		if err == nil && config.UpstreamHttpProxyAddress != "" {
-			err = HttpProxyConnect(netConn, addr)
+		if config.UpstreamHttpProxyAddress != "" {
+			if err == nil {
+				err = HttpProxyConnect(netConn, addr)
+			}
+			if err != nil {
+				NoticeUpstreamProxyError(err)
+			}
 		}
 		}
 		if err != nil {
 		if err != nil {
 			netConn = nil
 			netConn = nil

+ 30 - 34
psiphon/config.go

@@ -29,7 +29,7 @@ import (
 // TODO: allow all params to be configured
 // TODO: allow all params to be configured
 
 
 const (
 const (
-	VERSION                                      = "0.0.7"
+	VERSION                                      = "0.0.9"
 	DATA_STORE_FILENAME                          = "psiphon.db"
 	DATA_STORE_FILENAME                          = "psiphon.db"
 	CONNECTION_WORKER_POOL_SIZE                  = 10
 	CONNECTION_WORKER_POOL_SIZE                  = 10
 	TUNNEL_POOL_SIZE                             = 1
 	TUNNEL_POOL_SIZE                             = 1
@@ -55,6 +55,7 @@ const (
 	PSIPHON_API_STATUS_REQUEST_PADDING_MAX_BYTES = 256
 	PSIPHON_API_STATUS_REQUEST_PADDING_MAX_BYTES = 256
 	PSIPHON_API_CONNECTED_REQUEST_PERIOD         = 24 * time.Hour
 	PSIPHON_API_CONNECTED_REQUEST_PERIOD         = 24 * time.Hour
 	PSIPHON_API_CONNECTED_REQUEST_RETRY_PERIOD   = 5 * time.Second
 	PSIPHON_API_CONNECTED_REQUEST_RETRY_PERIOD   = 5 * time.Second
+	FETCH_ROUTES_TIMEOUT                         = 10 * time.Second
 )
 )
 
 
 // To distinguish omitted timeout params from explicit 0 value timeout
 // To distinguish omitted timeout params from explicit 0 value timeout
@@ -62,31 +63,34 @@ const (
 // so use the default; a non-nil pointer to 0 means no timeout.
 // so use the default; a non-nil pointer to 0 means no timeout.
 
 
 type Config struct {
 type Config struct {
-	LogFilename                        string
-	DataStoreDirectory                 string
-	DataStoreTempDirectory             string
-	PropagationChannelId               string
-	SponsorId                          string
-	RemoteServerListUrl                string
-	RemoteServerListSignaturePublicKey string
-	ClientVersion                      string
-	ClientPlatform                     string
-	TunnelWholeDevice                  int
-	EgressRegion                       string
-	TunnelProtocol                     string
-	EstablishTunnelTimeoutSeconds      *int
-	LocalSocksProxyPort                int
-	LocalHttpProxyPort                 int
-	ConnectionWorkerPoolSize           int
-	TunnelPoolSize                     int
-	PortForwardFailureThreshold        int
-	UpstreamHttpProxyAddress           string
-	NetworkConnectivityChecker         NetworkConnectivityChecker
-	DeviceBinder                       DeviceBinder
-	DnsServerGetter                    DnsServerGetter
-	TargetServerEntry                  string
-	DisableApi                         bool
-	DisableRemoteServerListFetcher     bool
+	LogFilename                         string
+	DataStoreDirectory                  string
+	DataStoreTempDirectory              string
+	PropagationChannelId                string
+	SponsorId                           string
+	RemoteServerListUrl                 string
+	RemoteServerListSignaturePublicKey  string
+	ClientVersion                       string
+	ClientPlatform                      string
+	TunnelWholeDevice                   int
+	EgressRegion                        string
+	TunnelProtocol                      string
+	EstablishTunnelTimeoutSeconds       *int
+	LocalSocksProxyPort                 int
+	LocalHttpProxyPort                  int
+	ConnectionWorkerPoolSize            int
+	TunnelPoolSize                      int
+	PortForwardFailureThreshold         int
+	UpstreamHttpProxyAddress            string
+	NetworkConnectivityChecker          NetworkConnectivityChecker
+	DeviceBinder                        DeviceBinder
+	DnsServerGetter                     DnsServerGetter
+	TargetServerEntry                   string
+	DisableApi                          bool
+	DisableRemoteServerListFetcher      bool
+	SplitTunnelRoutesUrlFormat          string
+	SplitTunnelRoutesSignaturePublicKey string
+	SplitTunnelDnsServer                string
 }
 }
 
 
 // LoadConfig parses and validates a JSON format Psiphon config JSON
 // LoadConfig parses and validates a JSON format Psiphon config JSON
@@ -107,14 +111,6 @@ func LoadConfig(configJson []byte) (*Config, error) {
 		return nil, ContextError(
 		return nil, ContextError(
 			errors.New("sponsor ID is missing from the configuration file"))
 			errors.New("sponsor ID is missing from the configuration file"))
 	}
 	}
-	if config.RemoteServerListUrl == "" {
-		return nil, ContextError(
-			errors.New("remote server list URL is missing from the configuration file"))
-	}
-	if config.RemoteServerListSignaturePublicKey == "" {
-		return nil, ContextError(
-			errors.New("remote server list signature public key is missing from the configuration file"))
-	}
 
 
 	if config.DataStoreDirectory == "" {
 	if config.DataStoreDirectory == "" {
 		config.DataStoreDirectory, err = os.Getwd()
 		config.DataStoreDirectory, err = os.Getwd()

+ 72 - 9
psiphon/controller.go

@@ -51,7 +51,9 @@ type Controller struct {
 	stopEstablishingBroadcast chan struct{}
 	stopEstablishingBroadcast chan struct{}
 	candidateServerEntries    chan *ServerEntry
 	candidateServerEntries    chan *ServerEntry
 	establishPendingConns     *Conns
 	establishPendingConns     *Conns
-	fetchRemotePendingConns   *Conns
+	untunneledPendingConns    *Conns
+	untunneledDialConfig      *DialConfig
+	splitTunnelClassifier     *SplitTunnelClassifier
 }
 }
 
 
 // NewController initializes a new controller.
 // NewController initializes a new controller.
@@ -64,7 +66,18 @@ func NewController(config *Config) (controller *Controller, err error) {
 		return nil, ContextError(err)
 		return nil, ContextError(err)
 	}
 	}
 
 
-	return &Controller{
+	// untunneledPendingConns may be used to interrupt the fetch remote server list
+	// request and other untunneled connection establishments. BindToDevice may be
+	// used to exclude these requests and connection from VPN routing.
+	untunneledPendingConns := new(Conns)
+	untunneledDialConfig := &DialConfig{
+		UpstreamHttpProxyAddress: config.UpstreamHttpProxyAddress,
+		PendingConns:             untunneledPendingConns,
+		DeviceBinder:             config.DeviceBinder,
+		DnsServerGetter:          config.DnsServerGetter,
+	}
+
+	controller = &Controller{
 		config:    config,
 		config:    config,
 		sessionId: sessionId,
 		sessionId: sessionId,
 		// componentFailureSignal receives a signal from a component (including socks and
 		// componentFailureSignal receives a signal from a component (including socks and
@@ -82,8 +95,13 @@ func NewController(config *Config) (controller *Controller, err error) {
 		startedConnectedReporter: false,
 		startedConnectedReporter: false,
 		isEstablishing:           false,
 		isEstablishing:           false,
 		establishPendingConns:    new(Conns),
 		establishPendingConns:    new(Conns),
-		fetchRemotePendingConns:  new(Conns),
-	}, nil
+		untunneledPendingConns:   untunneledPendingConns,
+		untunneledDialConfig:     untunneledDialConfig,
+	}
+
+	controller.splitTunnelClassifier = NewSplitTunnelClassifier(config, controller)
+
+	return controller, nil
 }
 }
 
 
 // Run executes the controller. It launches components and then monitors
 // Run executes the controller. It launches components and then monitors
@@ -142,9 +160,11 @@ func (controller *Controller) Run(shutdownBroadcast <-chan struct{}) {
 
 
 	close(controller.shutdownBroadcast)
 	close(controller.shutdownBroadcast)
 	controller.establishPendingConns.CloseAll()
 	controller.establishPendingConns.CloseAll()
-	controller.fetchRemotePendingConns.CloseAll()
+	controller.untunneledPendingConns.CloseAll()
 	controller.runWaitGroup.Wait()
 	controller.runWaitGroup.Wait()
 
 
+	controller.splitTunnelClassifier.Shutdown()
+
 	NoticeInfo("exiting controller")
 	NoticeInfo("exiting controller")
 }
 }
 
 
@@ -172,7 +192,7 @@ loop:
 		}
 		}
 
 
 		err := FetchRemoteServerList(
 		err := FetchRemoteServerList(
-			controller.config, controller.fetchRemotePendingConns)
+			controller.config, controller.untunneledDialConfig)
 
 
 		var duration time.Duration
 		var duration time.Duration
 		if err != nil {
 		if err != nil {
@@ -386,6 +406,22 @@ func (controller *Controller) registerTunnel(tunnel *Tunnel) bool {
 	controller.establishedOnce = true
 	controller.establishedOnce = true
 	controller.tunnels = append(controller.tunnels, tunnel)
 	controller.tunnels = append(controller.tunnels, tunnel)
 	NoticeTunnels(len(controller.tunnels))
 	NoticeTunnels(len(controller.tunnels))
+
+	// The split tunnel classifier is started once the first tunnel is
+	// established. This first tunnel is passed in to be used to make
+	// the routes data request.
+	// A long-running controller may run while the host device is present
+	// in different regions. In this case, we want the split tunnel logic
+	// to switch to routes for new regions and not classify traffic based
+	// on routes installed for older regions.
+	// We assume that when regions change, the host network will also
+	// change, and so all tunnels will fail and be re-established. Under
+	// that assumption, the classifier will be re-Start()-ed here when
+	// the region has changed.
+	if len(controller.tunnels) == 1 {
+		controller.splitTunnelClassifier.Start(tunnel)
+	}
+
 	return true
 	return true
 }
 }
 
 
@@ -465,7 +501,7 @@ func (controller *Controller) getNextActiveTunnel() (tunnel *Tunnel) {
 	return nil
 	return nil
 }
 }
 
 
-// isActiveTunnelServerEntries is used to check if there's already
+// isActiveTunnelServerEntry is used to check if there's already
 // an existing tunnel to a candidate server.
 // an existing tunnel to a candidate server.
 func (controller *Controller) isActiveTunnelServerEntry(serverEntry *ServerEntry) bool {
 func (controller *Controller) isActiveTunnelServerEntry(serverEntry *ServerEntry) bool {
 	controller.tunnelMutex.Lock()
 	controller.tunnelMutex.Lock()
@@ -481,13 +517,36 @@ func (controller *Controller) isActiveTunnelServerEntry(serverEntry *ServerEntry
 // Dial selects an active tunnel and establishes a port forward
 // Dial selects an active tunnel and establishes a port forward
 // connection through the selected tunnel. Failure to connect is considered
 // connection through the selected tunnel. Failure to connect is considered
 // a port foward failure, for the purpose of monitoring tunnel health.
 // a port foward failure, for the purpose of monitoring tunnel health.
-func (controller *Controller) Dial(remoteAddr string, downstreamConn net.Conn) (conn net.Conn, err error) {
+func (controller *Controller) Dial(
+	remoteAddr string, alwaysTunnel bool, downstreamConn net.Conn) (conn net.Conn, err error) {
+
 	tunnel := controller.getNextActiveTunnel()
 	tunnel := controller.getNextActiveTunnel()
 	if tunnel == nil {
 	if tunnel == nil {
 		return nil, ContextError(errors.New("no active tunnels"))
 		return nil, ContextError(errors.New("no active tunnels"))
 	}
 	}
 
 
-	tunneledConn, err := tunnel.Dial(remoteAddr, downstreamConn)
+	// Perform split tunnel classification when feature is enabled, and if the remote
+	// address is classified as untunneled, dial directly.
+	if !alwaysTunnel && controller.config.SplitTunnelDnsServer != "" {
+
+		host, _, err := net.SplitHostPort(remoteAddr)
+		if err != nil {
+			return nil, ContextError(err)
+		}
+
+		// Note: a possible optimization, when split tunnel is active and IsUntunneled performs
+		// a DNS resolution in order to make its classification, is to reuse that IP address in
+		// the following Dials so they do not need to make their own resolutions. However, the
+		// way this is currently implemented ensures that, e.g., DNS geo load balancing occurs
+		// relative to the outbound network.
+
+		if controller.splitTunnelClassifier.IsUntunneled(host) {
+			// !TODO! track downstreamConn and close it when the DialTCP conn closes, as with tunnel.Dial conns?
+			return DialTCP(remoteAddr, controller.untunneledDialConfig)
+		}
+	}
+
+	tunneledConn, err := tunnel.Dial(remoteAddr, alwaysTunnel, downstreamConn)
 	if err != nil {
 	if err != nil {
 		return nil, ContextError(err)
 		return nil, ContextError(err)
 	}
 	}
@@ -570,6 +629,10 @@ loop:
 				// Completed this iteration
 				// Completed this iteration
 				break
 				break
 			}
 			}
+
+			// TODO: here we could generate multiple candidates from the
+			// server entry when there are many MeekFrontingAddresses.
+
 			select {
 			select {
 			case controller.candidateServerEntries <- serverEntry:
 			case controller.candidateServerEntries <- serverEntry:
 			case <-controller.stopEstablishingBroadcast:
 			case <-controller.stopEstablishingBroadcast:

+ 71 - 3
psiphon/dataStore.go

@@ -83,6 +83,10 @@ func InitDataStore(config *Config) (err error) {
             (serverEntryId text not null,
             (serverEntryId text not null,
              protocol text not null,
              protocol text not null,
              primary key (serverEntryId, protocol));
              primary key (serverEntryId, protocol));
+        create table if not exists splitTunnelRoutes
+            (region text not null primary key,
+             etag text not null,
+             data blob not null);
         create table if not exists keyValue
         create table if not exists keyValue
             (key text not null primary key,
             (key text not null primary key,
              value text not null);
              value text not null);
@@ -182,7 +186,10 @@ func StoreServerEntry(serverEntry *ServerEntry, replaceIfExists bool) error {
 			return ContextError(err)
 			return ContextError(err)
 		}
 		}
 		if serverEntryExists && !replaceIfExists {
 		if serverEntryExists && !replaceIfExists {
-			NoticeInfo("ignored update for server %s", serverEntry.IpAddress)
+			// Disabling this notice, for now, as it generates too much noise
+			// in diagnostics with clients that always submit embedded servers
+			// to the core on each run.
+			// NoticeInfo("ignored update for server %s", serverEntry.IpAddress)
 			return nil
 			return nil
 		}
 		}
 		_, err = transaction.Exec(`
 		_, err = transaction.Exec(`
@@ -406,7 +413,7 @@ func (iterator *ServerEntryIterator) Next() (serverEntry *ServerEntry, err error
 	if iterator.isTargetServerEntryIterator {
 	if iterator.isTargetServerEntryIterator {
 		if iterator.hasNextTargetServerEntry {
 		if iterator.hasNextTargetServerEntry {
 			iterator.hasNextTargetServerEntry = false
 			iterator.hasNextTargetServerEntry = false
-			return iterator.targetServerEntry, nil
+			return MakeCompatibleServerEntry(iterator.targetServerEntry), nil
 		}
 		}
 		return nil, nil
 		return nil, nil
 	}
 	}
@@ -430,7 +437,21 @@ func (iterator *ServerEntryIterator) Next() (serverEntry *ServerEntry, err error
 	if err != nil {
 	if err != nil {
 		return nil, ContextError(err)
 		return nil, ContextError(err)
 	}
 	}
-	return serverEntry, nil
+
+	return MakeCompatibleServerEntry(serverEntry), nil
+}
+
+// MakeCompatibleServerEntry provides backwards compatibility with old server entries
+// which have a single meekFrontingDomain and not a meekFrontingAddresses array.
+// By copying this one meekFrontingDomain into meekFrontingAddresses, this client effectively
+// uses that single value as legacy clients do.
+func MakeCompatibleServerEntry(serverEntry *ServerEntry) *ServerEntry {
+	if len(serverEntry.MeekFrontingAddresses) == 0 && serverEntry.MeekFrontingDomain != "" {
+		serverEntry.MeekFrontingAddresses =
+			append(serverEntry.MeekFrontingAddresses, serverEntry.MeekFrontingDomain)
+	}
+
+	return serverEntry
 }
 }
 
 
 func makeServerEntryWhereClause(
 func makeServerEntryWhereClause(
@@ -520,6 +541,53 @@ func GetServerEntryIpAddresses() (ipAddresses []string, err error) {
 	return ipAddresses, nil
 	return ipAddresses, nil
 }
 }
 
 
+// SetSplitTunnelRoutes updates the cached routes data for
+// the given region. The associated etag is also stored and
+// used to make efficient web requests for updates to the data.
+func SetSplitTunnelRoutes(region, etag string, data []byte) error {
+	return transactionWithRetry(func(transaction *sql.Tx) error {
+		_, err := transaction.Exec(`
+            insert or replace into splitTunnelRoutes (region, etag, data)
+            values (?, ?, ?);
+            `, region, etag, data)
+		if err != nil {
+			// Note: ContextError() would break canRetry()
+			return err
+		}
+		return nil
+	})
+}
+
+// GetSplitTunnelRoutesETag retrieves the etag for cached routes
+// data for the specified region. If not found, it returns an empty string value.
+func GetSplitTunnelRoutesETag(region string) (etag string, err error) {
+	checkInitDataStore()
+	rows := singleton.db.QueryRow("select etag from splitTunnelRoutes where region = ?;", region)
+	err = rows.Scan(&etag)
+	if err == sql.ErrNoRows {
+		return "", nil
+	}
+	if err != nil {
+		return "", ContextError(err)
+	}
+	return etag, nil
+}
+
+// GetSplitTunnelRoutesData retrieves the cached routes data
+// for the specified region. If not found, it returns a nil value.
+func GetSplitTunnelRoutesData(region string) (data []byte, err error) {
+	checkInitDataStore()
+	rows := singleton.db.QueryRow("select data from splitTunnelRoutes where region = ?;", region)
+	err = rows.Scan(&data)
+	if err == sql.ErrNoRows {
+		return nil, nil
+	}
+	if err != nil {
+		return nil, ContextError(err)
+	}
+	return data, nil
+}
+
 // SetKeyValue stores a key/value pair.
 // SetKeyValue stores a key/value pair.
 func SetKeyValue(key, value string) error {
 func SetKeyValue(key, value string) error {
 	return transactionWithRetry(func(transaction *sql.Tx) error {
 	return transactionWithRetry(func(transaction *sql.Tx) error {

+ 2 - 2
psiphon/httpProxy.go

@@ -54,7 +54,7 @@ func NewHttpProxy(config *Config, tunneler Tunneler) (proxy *HttpProxy, err erro
 		// association between a downstream client connection and a particular
 		// association between a downstream client connection and a particular
 		// tunnel.
 		// tunnel.
 		// TODO: connect timeout?
 		// TODO: connect timeout?
-		return tunneler.Dial(addr, nil)
+		return tunneler.Dial(addr, false, nil)
 	}
 	}
 	// TODO: also use http.Client, with its Timeout field?
 	// TODO: also use http.Client, with its Timeout field?
 	transport := &http.Transport{
 	transport := &http.Transport{
@@ -199,7 +199,7 @@ func (proxy *HttpProxy) httpConnectHandler(localConn net.Conn, target string) (e
 	// Setting downstreamConn so localConn.Close() will be called when remoteConn.Close() is called.
 	// Setting downstreamConn so localConn.Close() will be called when remoteConn.Close() is called.
 	// This ensures that the downstream client (e.g., web browser) doesn't keep waiting on the
 	// This ensures that the downstream client (e.g., web browser) doesn't keep waiting on the
 	// open connection for data which will never arrive.
 	// open connection for data which will never arrive.
-	remoteConn, err := proxy.tunneler.Dial(target, localConn)
+	remoteConn, err := proxy.tunneler.Dial(target, false, localConn)
 	if err != nil {
 	if err != nil {
 		return ContextError(err)
 		return ContextError(err)
 	}
 	}

+ 67 - 15
psiphon/meekConn.go

@@ -44,14 +44,16 @@ import (
 // https://bitbucket.org/psiphon/psiphon-circumvention-system/src/default/go/meek-client/meek-client.go
 // https://bitbucket.org/psiphon/psiphon-circumvention-system/src/default/go/meek-client/meek-client.go
 
 
 const (
 const (
-	MEEK_PROTOCOL_VERSION      = 2
-	MEEK_COOKIE_MAX_PADDING    = 32
-	MAX_SEND_PAYLOAD_LENGTH    = 65536
-	FULL_RECEIVE_BUFFER_LENGTH = 4194304
-	READ_PAYLOAD_CHUNK_LENGTH  = 65536
-	MIN_POLL_INTERVAL          = 100 * time.Millisecond
-	MAX_POLL_INTERVAL          = 5 * time.Second
-	POLL_INTERNAL_MULTIPLIER   = 1.5
+	MEEK_PROTOCOL_VERSION          = 2
+	MEEK_COOKIE_MAX_PADDING        = 32
+	MAX_SEND_PAYLOAD_LENGTH        = 65536
+	FULL_RECEIVE_BUFFER_LENGTH     = 4194304
+	READ_PAYLOAD_CHUNK_LENGTH      = 65536
+	MIN_POLL_INTERVAL              = 100 * time.Millisecond
+	MAX_POLL_INTERVAL              = 5 * time.Second
+	POLL_INTERNAL_MULTIPLIER       = 1.5
+	MEEK_ROUND_TRIP_RETRY_DEADLINE = 1 * time.Second
+	MEEK_ROUND_TRIP_RETRY_DELAY    = 50 * time.Millisecond
 )
 )
 
 
 // MeekConn is a network connection that tunnels TCP over HTTP and supports "fronting". Meek sends
 // MeekConn is a network connection that tunnels TCP over HTTP and supports "fronting". Meek sends
@@ -88,10 +90,11 @@ type MeekConn struct {
 // persistent HTTP connections are used for performance). This function does not
 // persistent HTTP connections are used for performance). This function does not
 // wait for the connection to be "established" before returning. A goroutine
 // wait for the connection to be "established" before returning. A goroutine
 // is spawned which will eventually start HTTP polling.
 // is spawned which will eventually start HTTP polling.
-// useFronting assumes caller has already checked server entry capabilities.
+// When frontingAddress is not "", fronting is used. This option assumes caller has
+// already checked server entry capabilities.
 func DialMeek(
 func DialMeek(
 	serverEntry *ServerEntry, sessionId string,
 	serverEntry *ServerEntry, sessionId string,
-	useFronting bool, config *DialConfig) (meek *MeekConn, err error) {
+	frontingAddress string, config *DialConfig) (meek *MeekConn, err error) {
 
 
 	// Configure transport
 	// Configure transport
 	// Note: MeekConn has its own PendingConns to manage the underlying HTTP transport connections,
 	// Note: MeekConn has its own PendingConns to manage the underlying HTTP transport connections,
@@ -109,18 +112,51 @@ func DialMeek(
 	var dialer Dialer
 	var dialer Dialer
 	var proxyUrl func(*http.Request) (*url.URL, error)
 	var proxyUrl func(*http.Request) (*url.URL, error)
 
 
-	if useFronting {
+	if frontingAddress != "" {
 		// In this case, host is not what is dialed but is what ends up in the HTTP Host header
 		// In this case, host is not what is dialed but is what ends up in the HTTP Host header
 		host = serverEntry.MeekFrontingHost
 		host = serverEntry.MeekFrontingHost
+
 		// Custom TLS dialer:
 		// Custom TLS dialer:
-		//  - ignores the HTTP request address and uses the fronting domain
-		//  - disables SNI -- SNI breaks fronting when used with CDNs that support SNI on the server side.
+		//
+		//  1. ignores the HTTP request address and uses the fronting domain
+		//  2. disables SNI -- SNI breaks fronting when used with CDNs that support SNI on the server side.
+		//  3. skips verifying the server cert.
+		//
+		// Reasoning for #3:
+		//
+		// With a TLS MiM attack in place, and server certs verified, we'll fail to connect because the client
+		// will refuse to connect. That's not a successful outcome.
+		//
+		// With a MiM attack in place, and server certs not verified, we'll fail to connect if the MiM is actively
+		// targeting Psiphon and classifying the HTTP traffic by Host header or payload signature.
+		//
+		// However, in the case of a passive MiM that's just recording traffic or an active MiM that's targeting
+		// something other than Psiphon, the client will connect. This is a successful outcome.
+		//
+		// What is exposed to the MiM? The Host header does not contain a Psiphon server IP address, just an
+		// unrelated, randomly generated domain name which cannot be used to block direct connections. The
+		// Psiphon server IP is sent over meek, but it's in the encrypted cookie.
+		//
+		// The payload (user traffic) gets its confidentiality and integrity from the underlying SSH protocol.
+		// So, nothing is leaked to the MiM apart from signatures which could be used to classify the traffic
+		// as Psiphon to possibly block it; but note that not revealing that the client is Psiphon is outside
+		// our threat model; we merely seek to evade mass blocking by taking steps that require progressively
+		// more effort to block.
+		//
+		// There is a subtle attack remaining: an adversary that can MiM some CDNs but not others (and so can
+		// classify Psiphon traffic on some CDNs but not others) may throttle non-MiM CDNs so that our server
+		// selection always chooses tunnels to the MiM CDN (without any server cert verification, we won't
+		// exclusively connect to non-MiM CDNs); then the adversary kills the underlying TCP connection after
+		// some short period. This is similar to the "unidentified protocol" attack outlined in selectProtocol().
+		// A similar weighted selection defense may be appropriate.
+
 		dialer = NewCustomTLSDialer(
 		dialer = NewCustomTLSDialer(
 			&CustomTLSConfig{
 			&CustomTLSConfig{
 				Dial:           NewTCPDialer(meekConfig),
 				Dial:           NewTCPDialer(meekConfig),
 				Timeout:        meekConfig.ConnectTimeout,
 				Timeout:        meekConfig.ConnectTimeout,
-				FrontingAddr:   fmt.Sprintf("%s:%d", serverEntry.MeekFrontingDomain, 443),
+				FrontingAddr:   fmt.Sprintf("%s:%d", frontingAddress, 443),
 				SendServerName: false,
 				SendServerName: false,
+				SkipVerify:     true,
 			})
 			})
 	} else {
 	} else {
 		// In this case, host is both what is dialed and what ends up in the HTTP Host header
 		// In this case, host is both what is dialed and what ends up in the HTTP Host header
@@ -439,16 +475,27 @@ func (meek *MeekConn) roundTrip(sendPayload []byte) (receivedPayload io.ReadClos
 	}
 	}
 	// Don't use the default user agent ("Go 1.1 package http").
 	// 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").
 	// 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("User-Agent", "")
 	request.Header.Set("Content-Type", "application/octet-stream")
 	request.Header.Set("Content-Type", "application/octet-stream")
 	request.AddCookie(meek.cookie)
 	request.AddCookie(meek.cookie)
 
 
 	// The retry mitigates intermittent failures between the client and front/server.
 	// The retry mitigates intermittent failures between the client and front/server.
+	//
 	// Note: Retry will only be effective if entire request failed (underlying transport protocol
 	// Note: Retry will only be effective if entire request failed (underlying transport protocol
 	// such as SSH will fail if extra bytes are replayed in either direction due to partial relay
 	// such as SSH will fail if extra bytes are replayed in either direction due to partial relay
 	// success followed by retry).
 	// success followed by retry).
+	// We retry when still within a brief deadline and wait for a short time before re-dialing.
+	//
+	// TODO: in principle, we could retry for min(TUNNEL_WRITE_TIMEOUT, meek-server.MAX_SESSION_STALENESS),
+	// i.e., as long as the underlying tunnel has not timed out and as long as the server has not
+	// expired the current meek session. Presently not doing this to avoid excessive connection attempts
+	// through the first hop. In addition, this will require additional support for timely shutdown.
+
+	retryDeadline := time.Now().Add(MEEK_ROUND_TRIP_RETRY_DEADLINE)
+
 	var response *http.Response
 	var response *http.Response
-	for retry := 0; retry <= 1; retry++ {
+	for {
 
 
 		// The http.Transport.RoundTrip is run in a goroutine to enable cancelling a request in-flight.
 		// The http.Transport.RoundTrip is run in a goroutine to enable cancelling a request in-flight.
 		type roundTripResponse struct {
 		type roundTripResponse struct {
@@ -476,6 +523,11 @@ func (meek *MeekConn) roundTrip(sendPayload []byte) (receivedPayload io.ReadClos
 		if err == nil {
 		if err == nil {
 			break
 			break
 		}
 		}
+
+		if time.Now().After(retryDeadline) {
+			break
+		}
+		time.Sleep(MEEK_ROUND_TRIP_RETRY_DELAY)
 	}
 	}
 	if err != nil {
 	if err != nil {
 		return nil, ContextError(err)
 		return nil, ContextError(err)

+ 62 - 26
psiphon/conn.go → psiphon/net.go

@@ -29,8 +29,12 @@ import (
 	"strings"
 	"strings"
 	"sync"
 	"sync"
 	"time"
 	"time"
+
+	"github.com/Psiphon-Inc/dns"
 )
 )
 
 
+const DNS_PORT = 53
+
 // DialConfig contains parameters to determine the behavior
 // DialConfig contains parameters to determine the behavior
 // of a Psiphon dialer (TCPDial, MeekDial, etc.)
 // of a Psiphon dialer (TCPDial, MeekDial, etc.)
 type DialConfig struct {
 type DialConfig struct {
@@ -79,32 +83,6 @@ type DnsServerGetter interface {
 	GetDnsServer() string
 	GetDnsServer() string
 }
 }
 
 
-// WaitForNetworkConnectivity uses a NetworkConnectivityChecker to
-// periodically check for network connectivity. It returns true if
-// no NetworkConnectivityChecker is provided (waiting is disabled)
-// or if NetworkConnectivityChecker.HasNetworkConnectivity() indicates
-// connectivity. It polls the checker once a second. If a stop is
-// broadcast, false is returned.
-func WaitForNetworkConnectivity(
-	connectivityChecker NetworkConnectivityChecker, stopBroadcast <-chan struct{}) bool {
-	if connectivityChecker == nil || 1 == connectivityChecker.HasNetworkConnectivity() {
-		return true
-	}
-	NoticeInfo("waiting for network connectivity")
-	ticker := time.NewTicker(1 * time.Second)
-	for {
-		if 1 == connectivityChecker.HasNetworkConnectivity() {
-			return true
-		}
-		select {
-		case <-ticker.C:
-			// Check again
-		case <-stopBroadcast:
-			return false
-		}
-	}
-}
-
 // Dialer is a custom dialer compatible with http.Transport.Dial.
 // Dialer is a custom dialer compatible with http.Transport.Dial.
 type Dialer func(string, string) (net.Conn, error)
 type Dialer func(string, string) (net.Conn, error)
 
 
@@ -221,3 +199,61 @@ func HttpProxyConnect(rawConn net.Conn, addr string) (err error) {
 
 
 	return nil
 	return nil
 }
 }
+
+// WaitForNetworkConnectivity uses a NetworkConnectivityChecker to
+// periodically check for network connectivity. It returns true if
+// no NetworkConnectivityChecker is provided (waiting is disabled)
+// or if NetworkConnectivityChecker.HasNetworkConnectivity() indicates
+// connectivity. It polls the checker once a second. If a stop is
+// broadcast, false is returned.
+func WaitForNetworkConnectivity(
+	connectivityChecker NetworkConnectivityChecker, stopBroadcast <-chan struct{}) bool {
+	if connectivityChecker == nil || 1 == connectivityChecker.HasNetworkConnectivity() {
+		return true
+	}
+	NoticeInfo("waiting for network connectivity")
+	ticker := time.NewTicker(1 * time.Second)
+	for {
+		if 1 == connectivityChecker.HasNetworkConnectivity() {
+			return true
+		}
+		select {
+		case <-ticker.C:
+			// Check again
+		case <-stopBroadcast:
+			return false
+		}
+	}
+}
+
+// ResolveIP uses a custom dns stack to make a DNS query over the
+// given TCP or UDP conn. This is used, e.g., when we need to ensure
+// that a DNS connection bypasses a VPN interface (BindToDevice) or
+// when we need to ensure that a DNS connection is tunneled.
+// Caller must set timeouts or interruptibility as required for conn.
+func ResolveIP(host string, conn net.Conn) (addrs []net.IP, ttls []time.Duration, err error) {
+
+	// Send the DNS query
+	dnsConn := &dns.Conn{Conn: conn}
+	defer dnsConn.Close()
+	query := new(dns.Msg)
+	query.SetQuestion(dns.Fqdn(host), dns.TypeA)
+	query.RecursionDesired = true
+	dnsConn.WriteMsg(query)
+
+	// Process the response
+	response, err := dnsConn.ReadMsg()
+	if err != nil {
+		return nil, nil, ContextError(err)
+	}
+	addrs = make([]net.IP, 0)
+	ttls = make([]time.Duration, 0)
+	for _, answer := range response.Answer {
+		if a, ok := answer.(*dns.A); ok {
+			addrs = append(addrs, a.A)
+			ttl := time.Duration(a.Hdr.Ttl) * time.Second
+			ttls = append(ttls, ttl)
+		}
+	}
+	return addrs, ttls, nil
+}

+ 23 - 2
psiphon/notice.go

@@ -110,9 +110,9 @@ func NoticeCandidateServers(region, protocol string, count int) {
 }
 }
 
 
 // NoticeConnectingServer is details on a connection attempt
 // NoticeConnectingServer is details on a connection attempt
-func NoticeConnectingServer(ipAddress, region, protocol, frontingDomain string) {
+func NoticeConnectingServer(ipAddress, region, protocol, frontingAddress string) {
 	outputNotice("ConnectingServer", false, "ipAddress", ipAddress, "region",
 	outputNotice("ConnectingServer", false, "ipAddress", ipAddress, "region",
-		region, "protocol", protocol, "frontingDomain", frontingDomain)
+		region, "protocol", protocol, "frontingAddress", frontingAddress)
 }
 }
 
 
 // NoticeActiveTunnel is a successful connection that is used as an active tunnel for port forwarding
 // NoticeActiveTunnel is a successful connection that is used as an active tunnel for port forwarding
@@ -159,6 +159,27 @@ func NoticeTunnels(count int) {
 	outputNotice("Tunnels", false, "count", count)
 	outputNotice("Tunnels", false, "count", count)
 }
 }
 
 
+// NoticeUntunneled indicates than an address has been classified as untunneled and is being
+// accessed directly.
+//
+// Note: "address" should remain private; this notice should only be used for alerting
+// users, not for diagnostics logs.
+//
+func NoticeUntunneled(address string) {
+	outputNotice("Untunneled", true, "address", address)
+}
+
+// NoticeSplitTunnelRegion reports that split tunnel is on for the given region.
+func NoticeSplitTunnelRegion(region string) {
+	outputNotice("SplitTunnelRegion", true, "region", region)
+}
+
+// NoticeUpstreamProxyError reports an error when connecting to an upstream proxy. The
+// user may have input, for example, an incorrect address or incorrect credentials.
+func NoticeUpstreamProxyError(err error) {
+	outputNotice("UpstreamProxyError", true, "message", fmt.Sprintf("%s", err))
+}
+
 type noticeObject struct {
 type noticeObject struct {
 	NoticeType string          `json:"noticeType"`
 	NoticeType string          `json:"noticeType"`
 	Data       json.RawMessage `json:"data"`
 	Data       json.RawMessage `json:"data"`

+ 78 - 0
psiphon/package.go

@@ -0,0 +1,78 @@
+/*
+ * Copyright (c) 2015, 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 (
+	"crypto"
+	"crypto/rsa"
+	"crypto/sha256"
+	"crypto/x509"
+	"encoding/base64"
+	"encoding/json"
+	"errors"
+)
+
+// AuthenticatedDataPackage is a JSON record containing some Psiphon data
+// payload, such as list of Psiphon server entries. As it may be downloaded
+// from various sources, it is digitally signed so that the data may be
+// authenticated.
+type AuthenticatedDataPackage struct {
+	Data                   string `json:"data"`
+	SigningPublicKeyDigest string `json:"signingPublicKeyDigest"`
+	Signature              string `json:"signature"`
+}
+
+func ReadAuthenticatedDataPackage(
+	rawPackage []byte, signingPublicKey string) (data string, err error) {
+
+	var authenticatedDataPackage *AuthenticatedDataPackage
+	err = json.Unmarshal(rawPackage, &authenticatedDataPackage)
+	if err != nil {
+		return "", ContextError(err)
+	}
+
+	derEncodedPublicKey, err := base64.StdEncoding.DecodeString(signingPublicKey)
+	if err != nil {
+		return "", ContextError(err)
+	}
+	publicKey, err := x509.ParsePKIXPublicKey(derEncodedPublicKey)
+	if err != nil {
+		return "", ContextError(err)
+	}
+	rsaPublicKey, ok := publicKey.(*rsa.PublicKey)
+	if !ok {
+		return "", ContextError(errors.New("unexpected signing public key type"))
+	}
+	signature, err := base64.StdEncoding.DecodeString(authenticatedDataPackage.Signature)
+	if err != nil {
+		return "", ContextError(err)
+	}
+	// TODO: can distinguish signed-with-different-key from other errors:
+	// match digest(publicKey) against authenticatedDataPackage.SigningPublicKeyDigest
+	hash := sha256.New()
+	hash.Write([]byte(authenticatedDataPackage.Data))
+	digest := hash.Sum(nil)
+	err = rsa.VerifyPKCS1v15(rsaPublicKey, crypto.SHA256, digest, signature)
+	if err != nil {
+		return "", ContextError(err)
+	}
+
+	return authenticatedDataPackage.Data, nil
+}

+ 10 - 59
psiphon/remoteServerList.go

@@ -20,41 +20,25 @@
 package psiphon
 package psiphon
 
 
 import (
 import (
-	"crypto"
-	"crypto/rsa"
-	"crypto/sha256"
-	"crypto/x509"
-	"encoding/base64"
-	"encoding/json"
 	"errors"
 	"errors"
 	"io/ioutil"
 	"io/ioutil"
 	"net/http"
 	"net/http"
 )
 )
 
 
-// RemoteServerList is a JSON record containing a list of Psiphon server
-// entries. As it may be downloaded from various sources, it is digitally
-// signed so that the data may be authenticated.
-type RemoteServerList struct {
-	Data                   string `json:"data"`
-	SigningPublicKeyDigest string `json:"signingPublicKeyDigest"`
-	Signature              string `json:"signature"`
-}
-
 // FetchRemoteServerList downloads a remote server list JSON record from
 // FetchRemoteServerList downloads a remote server list JSON record from
 // config.RemoteServerListUrl; validates its digital signature using the
 // config.RemoteServerListUrl; validates its digital signature using the
 // public key config.RemoteServerListSignaturePublicKey; and parses the
 // public key config.RemoteServerListSignaturePublicKey; and parses the
 // data field into ServerEntry records.
 // data field into ServerEntry records.
-func FetchRemoteServerList(config *Config, pendingConns *Conns) (err error) {
+func FetchRemoteServerList(config *Config, dialConfig *DialConfig) (err error) {
 	NoticeInfo("fetching remote server list")
 	NoticeInfo("fetching remote server list")
 
 
-	// Note: pendingConns may be used to interrupt the fetch remote server list
-	// request. BindToDevice may be used to exclude requests from VPN routing.
-	dialConfig := &DialConfig{
-		UpstreamHttpProxyAddress: config.UpstreamHttpProxyAddress,
-		PendingConns:             pendingConns,
-		DeviceBinder:             config.DeviceBinder,
-		DnsServerGetter:          config.DnsServerGetter,
+	if config.RemoteServerListUrl == "" {
+		return ContextError(errors.New("remote server list URL is blank"))
+	}
+	if config.RemoteServerListSignaturePublicKey == "" {
+		return ContextError(errors.New("remote server list signature public key blank"))
 	}
 	}
+
 	transport := &http.Transport{
 	transport := &http.Transport{
 		Dial: NewTCPDialer(dialConfig),
 		Dial: NewTCPDialer(dialConfig),
 	}
 	}
@@ -74,17 +58,13 @@ func FetchRemoteServerList(config *Config, pendingConns *Conns) (err error) {
 		return ContextError(err)
 		return ContextError(err)
 	}
 	}
 
 
-	var remoteServerList *RemoteServerList
-	err = json.Unmarshal(body, &remoteServerList)
-	if err != nil {
-		return ContextError(err)
-	}
-	err = validateRemoteServerList(config, remoteServerList)
+	remoteServerList, err := ReadAuthenticatedDataPackage(
+		body, config.RemoteServerListSignaturePublicKey)
 	if err != nil {
 	if err != nil {
 		return ContextError(err)
 		return ContextError(err)
 	}
 	}
 
 
-	serverEntries, err := DecodeAndValidateServerEntryList(remoteServerList.Data)
+	serverEntries, err := DecodeAndValidateServerEntryList(remoteServerList)
 	if err != nil {
 	if err != nil {
 		return ContextError(err)
 		return ContextError(err)
 	}
 	}
@@ -96,32 +76,3 @@ func FetchRemoteServerList(config *Config, pendingConns *Conns) (err error) {
 
 
 	return nil
 	return nil
 }
 }
-
-func validateRemoteServerList(config *Config, remoteServerList *RemoteServerList) (err error) {
-	derEncodedPublicKey, err := base64.StdEncoding.DecodeString(config.RemoteServerListSignaturePublicKey)
-	if err != nil {
-		return ContextError(err)
-	}
-	publicKey, err := x509.ParsePKIXPublicKey(derEncodedPublicKey)
-	if err != nil {
-		return ContextError(err)
-	}
-	rsaPublicKey, ok := publicKey.(*rsa.PublicKey)
-	if !ok {
-		return ContextError(errors.New("unexpected RemoteServerListSignaturePublicKey key type"))
-	}
-	signature, err := base64.StdEncoding.DecodeString(remoteServerList.Signature)
-	if err != nil {
-		return ContextError(err)
-	}
-	// TODO: can detect if signed with different key --
-	// match digest(publicKey) against remoteServerList.signingPublicKeyDigest
-	hash := sha256.New()
-	hash.Write([]byte(remoteServerList.Data))
-	digest := hash.Sum(nil)
-	err = rsa.VerifyPKCS1v15(rsaPublicKey, crypto.SHA256, digest, signature)
-	if err != nil {
-		return ContextError(err)
-	}
-	return nil
-}

+ 4 - 0
psiphon/serverApi.go

@@ -44,6 +44,7 @@ type Session struct {
 	psiphonHttpsClient *http.Client
 	psiphonHttpsClient *http.Client
 	statsRegexps       *transferstats.Regexps
 	statsRegexps       *transferstats.Regexps
 	statsServerId      string
 	statsServerId      string
+	clientRegion       string
 }
 }
 
 
 // MakeSessionId creates a new session ID. Making the session ID is not done
 // MakeSessionId creates a new session ID. Making the session ID is not done
@@ -204,12 +205,15 @@ func (session *Session) doHandshakeRequest() error {
 		PageViewRegexes      []map[string]string `json:"page_view_regexes"`
 		PageViewRegexes      []map[string]string `json:"page_view_regexes"`
 		HttpsRequestRegexes  []map[string]string `json:"https_request_regexes"`
 		HttpsRequestRegexes  []map[string]string `json:"https_request_regexes"`
 		EncodedServerList    []string            `json:"encoded_server_list"`
 		EncodedServerList    []string            `json:"encoded_server_list"`
+		ClientRegion         string              `json:"client_region"`
 	}
 	}
 	err = json.Unmarshal(configLine, &handshakeConfig)
 	err = json.Unmarshal(configLine, &handshakeConfig)
 	if err != nil {
 	if err != nil {
 		return ContextError(err)
 		return ContextError(err)
 	}
 	}
 
 
+	session.clientRegion = handshakeConfig.ClientRegion
+
 	// Store discovered server entries
 	// Store discovered server entries
 	for _, encodedServerEntry := range handshakeConfig.EncodedServerList {
 	for _, encodedServerEntry := range handshakeConfig.EncodedServerList {
 		serverEntry, err := DecodeServerEntry(encodedServerEntry)
 		serverEntry, err := DecodeServerEntry(encodedServerEntry)

+ 3 - 1
psiphon/serverEntry.go

@@ -49,8 +49,9 @@ type ServerEntry struct {
 	MeekServerPort                int      `json:"meekServerPort"`
 	MeekServerPort                int      `json:"meekServerPort"`
 	MeekCookieEncryptionPublicKey string   `json:"meekCookieEncryptionPublicKey"`
 	MeekCookieEncryptionPublicKey string   `json:"meekCookieEncryptionPublicKey"`
 	MeekObfuscatedKey             string   `json:"meekObfuscatedKey"`
 	MeekObfuscatedKey             string   `json:"meekObfuscatedKey"`
-	MeekFrontingDomain            string   `json:"meekFrontingDomain"`
 	MeekFrontingHost              string   `json:"meekFrontingHost"`
 	MeekFrontingHost              string   `json:"meekFrontingHost"`
+	MeekFrontingDomain            string   `json:"meekFrontingDomain"`
+	MeekFrontingAddresses         []string `json:"meekFrontingAddresses"`
 }
 }
 
 
 // DecodeServerEntry extracts server entries from the encoding
 // DecodeServerEntry extracts server entries from the encoding
@@ -70,6 +71,7 @@ func DecodeServerEntry(encodedServerEntry string) (serverEntry *ServerEntry, err
 	if err != nil {
 	if err != nil {
 		return nil, ContextError(err)
 		return nil, ContextError(err)
 	}
 	}
+
 	return serverEntry, nil
 	return serverEntry, nil
 }
 }
 
 

+ 1 - 1
psiphon/socksProxy.go

@@ -80,7 +80,7 @@ func (proxy *SocksProxy) socksConnectionHandler(localConn *socks.SocksConn) (err
 	// Using downstreamConn so localConn.Close() will be called when remoteConn.Close() is called.
 	// Using downstreamConn so localConn.Close() will be called when remoteConn.Close() is called.
 	// This ensures that the downstream client (e.g., web browser) doesn't keep waiting on the
 	// This ensures that the downstream client (e.g., web browser) doesn't keep waiting on the
 	// open connection for data which will never arrive.
 	// open connection for data which will never arrive.
-	remoteConn, err := proxy.tunneler.Dial(localConn.Req.Target, localConn)
+	remoteConn, err := proxy.tunneler.Dial(localConn.Req.Target, false, localConn)
 	if err != nil {
 	if err != nil {
 		return ContextError(err)
 		return ContextError(err)
 	}
 	}

+ 464 - 55
psiphon/splitTunnel.go

@@ -1,34 +1,375 @@
+/*
+ * Copyright (c) 2015, 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
 package psiphon
 
 
 import (
 import (
 	"bufio"
 	"bufio"
 	"bytes"
 	"bytes"
+	"compress/zlib"
+	"encoding/base64"
 	"encoding/binary"
 	"encoding/binary"
+	"errors"
+	"fmt"
+	"io/ioutil"
 	"net"
 	"net"
+	"net/http"
 	"sort"
 	"sort"
 	"strings"
 	"strings"
+	"sync"
+	"time"
 )
 )
 
 
-// SplitTunnelDirector determines whether a destination should be
-// accessed through a tunnel or accessed directly.
-type SplitTunnelDirector struct {
-	localNetworks []*net.IPNet
+// SplitTunnelClassifier determines whether a network destination
+// should be accessed through a tunnel or accessed directly.
+//
+// The classifier uses tables of IP address data, routes data,
+// to determine if a given IP is to be tunneled or not. If presented
+// with a hostname, the classifier performs a tunneled (uncensored)
+// DNS request to first determine the IP address for that hostname;
+// then a classification is made based on the IP address.
+//
+// Classification results (both the hostname resolution and the
+// following IP address classification) are cached for the duration
+// of the DNS record TTL.
+//
+// Classification is by geographical region (country code). When the
+// split tunnel feature is configured to be on, and if the IP
+// address is within the user's region, it may be accessed untunneled.
+// Otherwise, the IP address must be accessed through a tunnel. The
+// user's current region is revealed to a Tunnel via the Psiphon server
+// API handshake.
+//
+// When a Tunnel has a blank region (e.g., when DisableApi is set and
+// the tunnel registers without performing a handshake) then no routes
+// data is set and all IP addresses are classified as requiring tunneling.
+//
+// Split tunnel is made on a best effort basis. After the classifier is
+// started, but before routes data is available for the given region,
+// all IP addresses will be classified as requiring tunneling.
+//
+// Routes data is fetched asynchronously after Start() is called. Routes
+// data is cached in the data store so it need not be downloaded in full
+// when fresh data is in the cache.
+type SplitTunnelClassifier struct {
+	mutex                    sync.RWMutex
+	fetchRoutesUrlFormat     string
+	routesSignaturePublicKey string
+	dnsServerAddress         string
+	dnsTunneler              Tunneler
+	fetchRoutesWaitGroup     *sync.WaitGroup
+	isRoutesSet              bool
+	cache                    map[string]*classification
+	routes                   networkList
+}
+
+type classification struct {
+	isUntunneled bool
+	expiry       time.Time
+}
+
+func NewSplitTunnelClassifier(config *Config, tunneler Tunneler) *SplitTunnelClassifier {
+	return &SplitTunnelClassifier{
+		fetchRoutesUrlFormat:     config.SplitTunnelRoutesUrlFormat,
+		routesSignaturePublicKey: config.SplitTunnelRoutesSignaturePublicKey,
+		dnsServerAddress:         config.SplitTunnelDnsServer,
+		dnsTunneler:              tunneler,
+		fetchRoutesWaitGroup:     new(sync.WaitGroup),
+		isRoutesSet:              false,
+		cache:                    make(map[string]*classification),
+	}
+}
+
+// Start resets the state of the classifier. In the default state,
+// all IP addresses are classified as requiring tunneling. With
+// sufficient configuration and region info, this function starts
+// a goroutine to asynchronously fetch and install the routes data.
+func (classifier *SplitTunnelClassifier) Start(fetchRoutesTunnel *Tunnel) {
+
+	classifier.mutex.Lock()
+	defer classifier.mutex.Unlock()
+
+	classifier.isRoutesSet = false
+
+	if classifier.dnsServerAddress == "" ||
+		classifier.routesSignaturePublicKey == "" ||
+		classifier.fetchRoutesUrlFormat == "" {
+		// Split tunnel capability is not configured
+		return
+	}
+
+	if fetchRoutesTunnel.session == nil {
+		// Tunnel has no session
+		return
+	}
+
+	if fetchRoutesTunnel.session.clientRegion == "" {
+		// Split tunnel region is unknown
+		return
+	}
+
+	classifier.fetchRoutesWaitGroup.Add(1)
+	go classifier.setRoutes(fetchRoutesTunnel)
+}
+
+// Shutdown waits until the background setRoutes() goroutine is finished.
+// There is no explicit shutdown signal sent to setRoutes() -- instead
+// we assume that in an overall shutdown situation, the tunnel used for
+// network access in setRoutes() is closed and network events won't delay
+// the completion of the goroutine.
+func (classifier *SplitTunnelClassifier) Shutdown() {
+	classifier.mutex.Lock()
+	defer classifier.mutex.Unlock()
+
+	if classifier.fetchRoutesWaitGroup != nil {
+		classifier.fetchRoutesWaitGroup.Wait()
+		classifier.fetchRoutesWaitGroup = nil
+		classifier.isRoutesSet = false
+	}
+}
+
+// IsUntunneled takes a destination hostname or IP address and determines
+// if it should be accessed through a tunnel. When a hostname is presented, it
+// is first resolved to an IP address which can be matched against the routes data.
+// Multiple goroutines may invoke RequiresTunnel simultaneously. Multi-reader
+// locks are used in the implementation to enable concurrent access, with no locks
+// held during network access.
+func (classifier *SplitTunnelClassifier) IsUntunneled(targetAddress string) bool {
+
+	if !classifier.hasRoutes() {
+		return false
+	}
+
+	classifier.mutex.RLock()
+	cachedClassification, ok := classifier.cache[targetAddress]
+	classifier.mutex.RUnlock()
+	if ok && cachedClassification.expiry.After(time.Now()) {
+		return cachedClassification.isUntunneled
+	}
+
+	ipAddr, ttl, err := tunneledLookupIP(
+		classifier.dnsServerAddress, classifier.dnsTunneler, targetAddress)
+	if err != nil {
+		NoticeAlert("failed to resolve address for split tunnel classification: %s", err)
+		return false
+	}
+	expiry := time.Now().Add(ttl)
+
+	isUntunneled := classifier.ipAddressInRoutes(ipAddr)
+
+	// TODO: garbage collect expired items from cache?
+
+	classifier.mutex.Lock()
+	classifier.cache[targetAddress] = &classification{isUntunneled, expiry}
+	classifier.mutex.Unlock()
+
+	if isUntunneled {
+		NoticeUntunneled(targetAddress)
+	}
+
+	return isUntunneled
+}
+
+// setRoutes is a background routine that fetches routes data and installs it,
+// which sets the isRoutesSet flag, indicating that IP addresses may now be classified.
+func (classifier *SplitTunnelClassifier) setRoutes(tunnel *Tunnel) {
+	defer classifier.fetchRoutesWaitGroup.Done()
+
+	// Note: a possible optimization is to install cached routes
+	// before making the request. That would ensure some split
+	// tunneling for the duration of the request.
+
+	routesData, err := classifier.getRoutes(tunnel)
+	if err != nil {
+		NoticeAlert("failed to get split tunnel routes: %s", err)
+		return
+	}
+
+	err = classifier.installRoutes(routesData)
+	if err != nil {
+		NoticeAlert("failed to install split tunnel routes: %s", err)
+		return
+	}
+
+	NoticeSplitTunnelRegion(tunnel.session.clientRegion)
+}
+
+// getRoutes makes a web request to download fresh routes data for the
+// given region, as indicated by the tunnel. It uses web caching, If-None-Match/ETag,
+// to save downloading known routes data repeatedly. If the web request
+// fails and cached routes data is present, that cached data is returned.
+func (classifier *SplitTunnelClassifier) getRoutes(tunnel *Tunnel) (routesData []byte, err error) {
+
+	url := fmt.Sprintf(classifier.fetchRoutesUrlFormat, tunnel.session.clientRegion)
+	request, err := http.NewRequest("GET", url, nil)
+	if err != nil {
+		return nil, ContextError(err)
+	}
+
+	etag, err := GetSplitTunnelRoutesETag(tunnel.session.clientRegion)
+	if err != nil {
+		return nil, ContextError(err)
+	}
+	if etag != "" {
+		request.Header.Add("If-None-Match", etag)
+	}
+
+	tunneledDialer := func(_, addr string) (conn net.Conn, err error) {
+		return tunnel.sshClient.Dial("tcp", addr)
+	}
+	transport := &http.Transport{
+		Dial: tunneledDialer,
+		ResponseHeaderTimeout: FETCH_ROUTES_TIMEOUT,
+	}
+	httpClient := &http.Client{
+		Transport: transport,
+		Timeout:   FETCH_ROUTES_TIMEOUT,
+	}
+
+	// At this time, the largest uncompressed routes data set is ~1MB. For now,
+	// the processing pipeline is done all in-memory.
+
+	useCachedRoutes := false
+
+	response, err := httpClient.Do(request)
+	if err != nil {
+		NoticeAlert("failed to request split tunnel routes package: %s", ContextError(err))
+		useCachedRoutes = true
+	}
+
+	if !useCachedRoutes {
+		defer response.Body.Close()
+		if response.StatusCode == http.StatusNotModified {
+			useCachedRoutes = true
+		}
+	}
+
+	var routesDataPackage []byte
+	if !useCachedRoutes {
+		routesDataPackage, err = ioutil.ReadAll(response.Body)
+		if err != nil {
+			NoticeAlert("failed to download split tunnel routes package: %s", ContextError(err))
+			useCachedRoutes = true
+		}
+	}
+
+	var encodedRoutesData string
+	if !useCachedRoutes {
+		encodedRoutesData, err = ReadAuthenticatedDataPackage(
+			routesDataPackage, classifier.routesSignaturePublicKey)
+		if err != nil {
+			NoticeAlert("failed to read split tunnel routes package: %s", ContextError(err))
+			useCachedRoutes = true
+		}
+	}
+
+	var compressedRoutesData []byte
+	if !useCachedRoutes {
+		compressedRoutesData, err = base64.StdEncoding.DecodeString(encodedRoutesData)
+		if err != nil {
+			NoticeAlert("failed to decode split tunnel routes: %s", ContextError(err))
+			useCachedRoutes = true
+		}
+	}
+
+	if !useCachedRoutes {
+		bytesReader := bytes.NewReader(compressedRoutesData)
+		zlibReader, err := zlib.NewReader(bytesReader)
+		if err == nil {
+			routesData, err = ioutil.ReadAll(zlibReader)
+			zlibReader.Close()
+		}
+		if err != nil {
+			NoticeAlert("failed to decompress split tunnel routes: %s", ContextError(err))
+			useCachedRoutes = true
+		}
+	}
+
+	if !useCachedRoutes {
+		etag := response.Header.Get("ETag")
+		if etag != "" {
+			err := SetSplitTunnelRoutes(tunnel.session.clientRegion, etag, routesData)
+			if err != nil {
+				NoticeAlert("failed to cache split tunnel routes: %s", ContextError(err))
+				// Proceed with fetched data, even when we can't cache it
+			}
+		}
+	}
+
+	if useCachedRoutes {
+		routesData, err = GetSplitTunnelRoutesData(tunnel.session.clientRegion)
+		if err != nil {
+			return nil, ContextError(err)
+		}
+		if routesData == nil {
+			return nil, ContextError(errors.New("no cached routes"))
+		}
+	}
+
+	return routesData, nil
+}
+
+// hasRoutes checks if the classifier has routes installed.
+func (classifier *SplitTunnelClassifier) hasRoutes() bool {
+	classifier.mutex.RLock()
+	defer classifier.mutex.RUnlock()
+
+	return classifier.isRoutesSet
+}
+
+// installRoutes parses the raw routes data and creates data structures
+// for fast in-memory classification.
+func (classifier *SplitTunnelClassifier) installRoutes(routesData []byte) (err error) {
+	classifier.mutex.Lock()
+	defer classifier.mutex.Unlock()
+
+	classifier.routes, err = NewNetworkList(routesData)
+	if err != nil {
+		return ContextError(err)
+	}
+
+	classifier.isRoutesSet = true
+
+	return nil
 }
 }
 
 
-// NewSplitTunnelDirector creates a new SplitTunnelDirector, initializing
-// it with routes data which maps out the ranges of IP addresses which should
-// be excluded from tunneling. dnsServerAddress is used when a hostname must
-// be resolved prior to making a determination. dnsDialConfig is used when
-// making the connection to dnsServerAddress.
-func NewSplitTunnelDirector(routesData []byte) (director *SplitTunnelDirector, err error) {
+// ipAddressInRoutes searches for a split tunnel candidate IP address in the routes data.
+func (classifier *SplitTunnelClassifier) ipAddressInRoutes(ipAddr net.IP) bool {
+	classifier.mutex.RLock()
+	defer classifier.mutex.RUnlock()
 
 
-	// TODO: implementation
-	dir := &SplitTunnelDirector{}
-	dir.initRoutesData(routesData)
-	return dir, nil
+	return classifier.routes.ContainsIpAddress(ipAddr)
 }
 }
 
 
-func (director *SplitTunnelDirector) initRoutesData(routesData []byte) {
+// networkList is a sorted list of network ranges. It's used to
+// lookup candidate IP addresses for split tunnel classification.
+// networkList implements Sort.Interface.
+type networkList []net.IPNet
+
+// NewNetworkList parses text routes data and produces a networkList
+// for fast ContainsIpAddress lookup.
+// The input format is expected to be text lines where each line
+// is, e.g., "1.2.3.0\t255.255.255.0\n"
+func NewNetworkList(routesData []byte) (networkList, error) {
+
+	// Parse text routes data
+	var list networkList
 	scanner := bufio.NewScanner(bytes.NewReader(routesData))
 	scanner := bufio.NewScanner(bytes.NewReader(routesData))
 	scanner.Split(bufio.ScanLines)
 	scanner.Split(bufio.ScanLines)
 	for scanner.Scan() {
 	for scanner.Scan() {
@@ -37,55 +378,123 @@ func (director *SplitTunnelDirector) initRoutesData(routesData []byte) {
 			continue
 			continue
 		}
 		}
 
 
-		nwIP := ParseIPv4(s[0])
-		nwMask := ParseIPv4Mask(s[1])
-
-		if nwIP == nil || nwMask == nil {
+		ip := parseIPv4(s[0])
+		mask := parseIPv4Mask(s[1])
+		if ip == nil || mask == nil {
 			continue
 			continue
 		}
 		}
 
 
-		director.localNetworks = append(director.localNetworks, &net.IPNet{IP: nwIP.Mask(nwMask), Mask: nwMask})
-		// sort and remove duplicates from our networks array
-		// so we could run binary search against it
-		sort.Sort(NetworkSorter(director.localNetworks))
-		director.removeDuplicates()
-	}
-}
-
-// Adapted from
-// http://openmymind.net/2011/7/15/Learning-Go-By-Benchmarking-Set-Implementation/
-func (director *SplitTunnelDirector) removeDuplicates() {
-	length := len(director.localNetworks) - 1
-	for i := 0; i < length; i++ {
-		for j := i + 1; j <= length; j++ {
-			if director.localNetworks[i].IP.Equal(director.localNetworks[j].IP) {
-				director.localNetworks[j] = director.localNetworks[length]
-				director.localNetworks = director.localNetworks[0:length]
-				length--
-				j--
-			}
-		}
+		list = append(list, net.IPNet{IP: ip.Mask(mask), Mask: mask})
+	}
+	if len(list) == 0 {
+		return nil, ContextError(errors.New("Routes data contains no networks"))
 	}
 	}
+
+	// Sort data for fast lookup
+	sort.Sort(list)
+
+	return list, nil
 }
 }
 
 
-func (director *SplitTunnelDirector) isLocalAddress(addr net.IP) bool {
-	length := len(director.localNetworks)
-	addrValue := binary.BigEndian.Uint32(addr.To4())
-	idx := sort.Search(length, func(i int) bool {
-		nwValue := binary.BigEndian.Uint32(director.localNetworks[i].IP)
-		return nwValue > addrValue
-	})
+func parseIPv4(s string) net.IP {
+	ip := net.ParseIP(s)
+	if ip == nil {
+		return nil
+	}
+	return ip.To4()
+}
+
+func parseIPv4Mask(s string) net.IPMask {
+	ip := parseIPv4(s)
+	if ip == nil {
+		return nil
+	}
+	mask := net.IPMask(ip)
+	if bits, size := mask.Size(); bits == 0 || size == 0 {
+		return nil
+	}
+	return mask
+}
+
+// Len implementes Sort.Interface
+func (list networkList) Len() int {
+	return len(list)
+}
+
+// Swap implementes Sort.Interface
+func (list networkList) Swap(i, j int) {
+	list[i], list[j] = list[j], list[i]
+}
 
 
-	return idx > 0 && director.localNetworks[idx-1].IP.Equal(addr.Mask(director.localNetworks[idx-1].Mask))
+// Less implementes Sort.Interface
+func (list networkList) Less(i, j int) bool {
+	return binary.BigEndian.Uint32(list[i].IP) < binary.BigEndian.Uint32(list[j].IP)
 }
 }
 
 
-type NetworkSorter []*net.IPNet
+// ContainsIpAddress performs a binary search on the networkList to
+// find a network containing the candidate IP address.
+func (list networkList) ContainsIpAddress(addr net.IP) bool {
 
 
-func (ns NetworkSorter) Len() int      { return len(ns) }
-func (ns NetworkSorter) Swap(i, j int) { ns[i], ns[j] = ns[j], ns[i] }
-func (ns NetworkSorter) Less(i, j int) bool {
-	nwa := binary.BigEndian.Uint32(ns[i].IP)
-	nwb := binary.BigEndian.Uint32(ns[j].IP)
-	return nwa < nwb
+	// Search criteria
+	//
+	// The following conditions are satisfied when address_IP is in the network:
+	// 1. address_IP ^ network_mask == network_IP ^ network_mask
+	// 2. address_IP >= network_IP.
+	// We are also assuming that network ranges do not overlap.
+	//
+	// For an ascending array of networks, the sort.Search returns the smallest
+	// index idx for which condition network_IP > address_IP is satisfied, so we
+	// are checking whether or not adrress_IP belongs to the network[idx-1].
+
+	// Edge conditions check
+	//
+	// idx == 0 means that address_IP is  lesser than the first (smallest) network_IP
+	// thus never satisfies search condition 2.
+	// idx == array_length means that address_IP is larger than the last (largest)
+	// network_IP so we need to check the last element for condition 1.
+
+	addrValue := binary.BigEndian.Uint32(addr.To4())
+	index := sort.Search(len(list), func(i int) bool {
+		networkValue := binary.BigEndian.Uint32(list[i].IP)
+		return networkValue > addrValue
+	})
+	return index > 0 && list[index-1].IP.Equal(addr.Mask(list[index-1].Mask))
 }
 }
 
 
+// tunneledLookupIP resolves a split tunnel candidate hostname with a tunneled
+// DNS request.
+func tunneledLookupIP(
+	dnsServerAddress string, dnsTunneler Tunneler, host string) (addr net.IP, ttl time.Duration, err error) {
+
+	ipAddr := net.ParseIP(host)
+	if ipAddr != nil {
+		// maxDuration from golang.org/src/time/time.go
+		return ipAddr, time.Duration(1<<63 - 1), nil
+	}
+
+	// dnsServerAddress must be an IP address
+	ipAddr = net.ParseIP(dnsServerAddress)
+	if ipAddr == nil {
+		return nil, 0, ContextError(errors.New("invalid IP address"))
+	}
+
+	// Dial's alwaysTunnel is set to true to ensure this connection
+	// is tunneled (also ensures this code path isn't circular).
+	// Assumes tunnel dialer conn configures timeouts and interruptibility.
+
+	conn, err := dnsTunneler.Dial(fmt.Sprintf(
+		"%s:%d", dnsServerAddress, DNS_PORT), true, nil)
+	if err != nil {
+		return nil, 0, ContextError(err)
+	}
+
+	ipAddrs, ttls, err := ResolveIP(host, conn)
+	if err != nil {
+		return nil, 0, ContextError(err)
+	}
+	if len(ipAddrs) < 1 {
+		return nil, 0, ContextError(errors.New("no IP address"))
+	}
+
+	return ipAddrs[0], ttls[0], nil
+}

+ 38 - 0
psiphon/splitTunnel_test.go

@@ -0,0 +1,38 @@
+package psiphon
+
+import (
+	"encoding/binary"
+	"io/ioutil"
+	"math/rand"
+	"net"
+	"testing"
+)
+
+var list networkList
+var isLocalAddr bool
+
+func Benchmark_NewNetworkList(b *testing.B) {
+
+	routesData, err := ioutil.ReadFile("test_routes.dat")
+	if err != nil {
+		b.Skipf("can't load test routes file: %s", err)
+	}
+
+	for n := 0; n < b.N; n++ {
+		list, _ = NewNetworkList(routesData)
+	}
+}
+
+func Benchmark_containsRandomAddr(b *testing.B) {
+
+	if list == nil {
+		b.Skipf("no test routes file")
+	}
+
+	rand.Seed(0)
+	for n := 0; n < b.N; n++ {
+		ip := make([]byte, 4)
+		binary.BigEndian.PutUint32(ip, rand.Uint32())
+		isLocalAddr = list.ContainsIpAddress(net.IP(ip))
+	}
+}

+ 10 - 5
psiphon/tlsDialer.go

@@ -104,6 +104,9 @@ type CustomTLSConfig struct {
 	// (tlsdialer functionality)
 	// (tlsdialer functionality)
 	SendServerName bool
 	SendServerName bool
 
 
+	// SkipVerify completely disables server certificate verification.
+	SkipVerify bool
+
 	// VerifyLegacyCertificate is a special case self-signed server
 	// VerifyLegacyCertificate is a special case self-signed server
 	// certificate case. Ignores IP SANs and basic constraints. No
 	// certificate case. Ignores IP SANs and basic constraints. No
 	// certificate chain. Just checks that the server presented the
 	// certificate chain. Just checks that the server presented the
@@ -192,11 +195,13 @@ func CustomTLSDial(network, addr string, config *CustomTLSConfig) (*tls.Conn, er
 		err = <-errChannel
 		err = <-errChannel
 	}
 	}
 
 
-	if err == nil && config.VerifyLegacyCertificate != nil {
-		err = verifyLegacyCertificate(conn, config.VerifyLegacyCertificate)
-	} else if err == nil && !config.SendServerName && !tlsConfig.InsecureSkipVerify {
-		// Manually verify certificates
-		err = verifyServerCerts(conn, serverName, tlsConfigCopy)
+	if !config.SkipVerify {
+		if err == nil && config.VerifyLegacyCertificate != nil {
+			err = verifyLegacyCertificate(conn, config.VerifyLegacyCertificate)
+		} else if err == nil && !config.SendServerName && !tlsConfig.InsecureSkipVerify {
+			// Manually verify certificates
+			err = verifyServerCerts(conn, serverName, tlsConfigCopy)
+		}
 	}
 	}
 
 
 	if err != nil {
 	if err != nil {

+ 48 - 10
psiphon/tunnel.go

@@ -39,12 +39,15 @@ import (
 // Components which use this interface may be serviced by a single Tunnel instance,
 // Components which use this interface may be serviced by a single Tunnel instance,
 // or a Controller which manages a pool of tunnels, or any other object which
 // or a Controller which manages a pool of tunnels, or any other object which
 // implements Tunneler.
 // implements Tunneler.
+// alwaysTunnel indicates that the connection should always be tunneled. If this
+// is not set, the connection may be made directly, depending on split tunnel
+// classification, when that feature is supported and active.
 // downstreamConn is an optional parameter which specifies a connection to be
 // downstreamConn is an optional parameter which specifies a connection to be
 // explictly closed when the Dialed connection is closed. For instance, this
 // explictly closed when the Dialed connection is closed. For instance, this
 // is used to close downstreamConn App<->LocalProxy connections when the related
 // is used to close downstreamConn App<->LocalProxy connections when the related
 // LocalProxy<->SshPortForward connections close.
 // LocalProxy<->SshPortForward connections close.
 type Tunneler interface {
 type Tunneler interface {
-	Dial(remoteAddr string, downstreamConn net.Conn) (conn net.Conn, err error)
+	Dial(remoteAddr string, alwaysTunnel bool, downstreamConn net.Conn) (conn net.Conn, err error)
 	SignalComponentFailure()
 	SignalComponentFailure()
 }
 }
 
 
@@ -192,7 +195,10 @@ func (tunnel *Tunnel) Close() {
 }
 }
 
 
 // Dial establishes a port forward connection through the tunnel
 // Dial establishes a port forward connection through the tunnel
-func (tunnel *Tunnel) Dial(remoteAddr string, downstreamConn net.Conn) (conn net.Conn, err error) {
+// This Dial doesn't support split tunnel, so alwaysTunnel is not referenced
+func (tunnel *Tunnel) Dial(
+	remoteAddr string, alwaysTunnel bool, downstreamConn net.Conn) (conn net.Conn, err error) {
+
 	tunnel.mutex.Lock()
 	tunnel.mutex.Lock()
 	isClosed := tunnel.isClosed
 	isClosed := tunnel.isClosed
 	tunnel.mutex.Unlock()
 	tunnel.mutex.Unlock()
@@ -289,17 +295,38 @@ func selectProtocol(config *Config, serverEntry *ServerEntry) (selectedProtocol
 		}
 		}
 		selectedProtocol = config.TunnelProtocol
 		selectedProtocol = config.TunnelProtocol
 	} else {
 	} else {
-		// Order of SupportedTunnelProtocols is default preference order
+		// Pick at random from the supported protocols. This ensures that we'll eventually
+		// try all possible protocols. Depending on network configuration, it may be the
+		// case that some protocol is only available through multi-capability servers,
+		// and a simplr ranked preference of protocols could lead to that protocol never
+		// being selected.
+
+		// TODO: this is a good spot to apply protocol selection weightings. This would be
+		// to defend against an attack where the adversary, for example, classifies OSSH as
+		// an "unidentified" protocol; allows it to connect; but then kills the underlying
+		// TCP connection after a short time. Since OSSH has less latency than other protocols
+		// that may bypass an "unidentified" filter, other protocols which would be otherwise
+		// classified and not killed might never be selected for use.
+		// So one proposed defense is to add negative selection weights to the protocol
+		// associated with failed tunnels (controller.failedTunnels) with short session
+		// durations.
+
+		candidateProtocols := make([]string, 0)
 		for _, protocol := range SupportedTunnelProtocols {
 		for _, protocol := range SupportedTunnelProtocols {
 			requiredCapability := strings.TrimSuffix(protocol, "-OSSH")
 			requiredCapability := strings.TrimSuffix(protocol, "-OSSH")
 			if Contains(serverEntry.Capabilities, requiredCapability) {
 			if Contains(serverEntry.Capabilities, requiredCapability) {
-				selectedProtocol = protocol
-				break
+				candidateProtocols = append(candidateProtocols, protocol)
 			}
 			}
 		}
 		}
-		if selectedProtocol == "" {
+		if len(candidateProtocols) == 0 {
 			return "", ContextError(fmt.Errorf("server does not have any supported capabilities"))
 			return "", ContextError(fmt.Errorf("server does not have any supported capabilities"))
 		}
 		}
+
+		index, err := MakeSecureRandomInt(len(candidateProtocols))
+		if err != nil {
+			return "", ContextError(err)
+		}
+		selectedProtocol = candidateProtocols[index]
 	}
 	}
 	return selectedProtocol, nil
 	return selectedProtocol, nil
 }
 }
@@ -334,15 +361,26 @@ func dialSsh(
 		port = serverEntry.SshPort
 		port = serverEntry.SshPort
 	}
 	}
 
 
-	frontingDomain := ""
+	frontingAddress := ""
 	if useFronting {
 	if useFronting {
-		frontingDomain = serverEntry.MeekFrontingDomain
+
+		// Randomly select, for this connection attempt, one front address for
+		// fronting-capable servers.
+
+		if len(serverEntry.MeekFrontingAddresses) == 0 {
+			return nil, nil, nil, ContextError(errors.New("MeekFrontingAddresses is empty"))
+		}
+		index, err := MakeSecureRandomInt(len(serverEntry.MeekFrontingAddresses))
+		if err != nil {
+			return nil, nil, nil, ContextError(err)
+		}
+		frontingAddress = serverEntry.MeekFrontingAddresses[index]
 	}
 	}
 	NoticeConnectingServer(
 	NoticeConnectingServer(
 		serverEntry.IpAddress,
 		serverEntry.IpAddress,
 		serverEntry.Region,
 		serverEntry.Region,
 		selectedProtocol,
 		selectedProtocol,
-		frontingDomain)
+		frontingAddress)
 
 
 	// Create the base transport: meek or direct connection
 	// Create the base transport: meek or direct connection
 	dialConfig := &DialConfig{
 	dialConfig := &DialConfig{
@@ -355,7 +393,7 @@ func dialSsh(
 		DnsServerGetter:          config.DnsServerGetter,
 		DnsServerGetter:          config.DnsServerGetter,
 	}
 	}
 	if useMeek {
 	if useMeek {
-		conn, err = DialMeek(serverEntry, sessionId, useFronting, dialConfig)
+		conn, err = DialMeek(serverEntry, sessionId, frontingAddress, dialConfig)
 		if err != nil {
 		if err != nil {
 			return nil, nil, nil, ContextError(err)
 			return nil, nil, nil, ContextError(err)
 		}
 		}