Ver Fonte

Merge pull request #77 from rod-hynes/master

Split Tunnel feature
Rod Hynes há 11 anos atrás
pai
commit
70ae996ec9

+ 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
 }
 }

+ 1 - 1
psiphon/TCPConn_unix.go

@@ -90,7 +90,7 @@ 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"))
 	}
 	}
 	// TODO: IPv6 support
 	// TODO: IPv6 support
 	var ip [4]byte
 	var ip [4]byte

+ 29 - 25
psiphon/config.go

@@ -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

+ 68 - 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)
 	}
 	}

+ 51 - 0
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);
@@ -520,6 +524,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)
 	}
 	}

+ 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
+}

+ 10 - 0
psiphon/notice.go

@@ -159,6 +159,16 @@ 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)
+}
+
 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
+}

+ 4 - 61
psiphon/remoteServerList.go

@@ -20,41 +20,17 @@
 package psiphon
 package psiphon
 
 
 import (
 import (
-	"crypto"
-	"crypto/rsa"
-	"crypto/sha256"
-	"crypto/x509"
-	"encoding/base64"
-	"encoding/json"
-	"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,
-	}
 	transport := &http.Transport{
 	transport := &http.Transport{
 		Dial: NewTCPDialer(dialConfig),
 		Dial: NewTCPDialer(dialConfig),
 	}
 	}
@@ -74,17 +50,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 +68,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)

+ 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)
 	}
 	}

+ 493 - 0
psiphon/splitTunnel.go

@@ -0,0 +1,493 @@
+/*
+ * 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 (
+	"bufio"
+	"bytes"
+	"compress/zlib"
+	"encoding/base64"
+	"encoding/binary"
+	"errors"
+	"fmt"
+	"io/ioutil"
+	"net"
+	"net/http"
+	"sort"
+	"strings"
+	"sync"
+	"time"
+)
+
+// 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.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
+	}
+}
+
+// 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
+}
+
+// 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()
+
+	return classifier.routes.ContainsIpAddress(ipAddr)
+}
+
+// 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.Split(bufio.ScanLines)
+	for scanner.Scan() {
+		s := strings.Split(scanner.Text(), "\t")
+		if len(s) != 2 {
+			continue
+		}
+
+		ip := parseIPv4(s[0])
+		mask := parseIPv4Mask(s[1])
+		if ip == nil || mask == nil {
+			continue
+		}
+
+		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 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]
+}
+
+// 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)
+}
+
+// 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 {
+
+	// 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))
+	}
+}

+ 8 - 2
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()