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

More split tunnel implementation

* Generalized custom DNS query logic to be used in multiple cases.
* Added tunneled DNS resolution.
* Added classification cache, using DNS TTL.
* Generalized authenticated data package routines for use with different payloads
* Use authenticated data package for routes data
* Renamed conn.go to net.go; contains general Psiphon networking logic
Rod Hynes 11 лет назад
Родитель
Сommit
6870aef89f

+ 4 - 22
psiphon/LookupIP.go

@@ -1,7 +1,7 @@
 // +build android linux
 
 /*
- * Copyright (c) 2014, Psiphon Inc.
+ * Copyright (c) 2015, Psiphon Inc.
  * All rights reserved.
  *
  * This program is free software: you can redistribute it and/or modify
@@ -24,7 +24,6 @@ package psiphon
 import (
 	"errors"
 	"fmt"
-	dns "github.com/Psiphon-Inc/dns"
 	"net"
 	"os"
 	"syscall"
@@ -98,25 +97,8 @@ func bindLookupIP(host string, config *DialConfig) (addrs []net.IP, err error) {
 		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)
 	}
 	if len(ipAddrs) < 1 {
-		return nil, ContextError(errors.New("no ip address"))
+		return nil, ContextError(errors.New("no IP address"))
 	}
 	// TODO: IPv6 support
 	var ip [4]byte

+ 28 - 27
psiphon/config.go

@@ -63,33 +63,34 @@ const (
 // so use the default; a non-nil pointer to 0 means no timeout.
 
 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
-	SplitTunnelRoutesUrlFormat         string
-	SplitTunnelDnsServer               string
+	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

+ 18 - 8
psiphon/controller.go

@@ -77,7 +77,7 @@ func NewController(config *Config) (controller *Controller, err error) {
 		DnsServerGetter:          config.DnsServerGetter,
 	}
 
-	return &Controller{
+	controller = &Controller{
 		config:    config,
 		sessionId: sessionId,
 		// componentFailureSignal receives a signal from a component (including socks and
@@ -97,8 +97,11 @@ func NewController(config *Config) (controller *Controller, err error) {
 		establishPendingConns:    new(Conns),
 		untunneledPendingConns:   untunneledPendingConns,
 		untunneledDialConfig:     untunneledDialConfig,
-		splitTunnelClassifier:    NewSplitTunnelClassifier(config, untunneledDialConfig),
-	}, nil
+	}
+
+	controller.splitTunnelClassifier = NewSplitTunnelClassifier(config, controller)
+
+	return controller, nil
 }
 
 // Run executes the controller. It launches components and then monitors
@@ -416,8 +419,7 @@ func (controller *Controller) registerTunnel(tunnel *Tunnel) bool {
 	// that assumption, the classifier will be re-Start()-ed here when
 	// the region has changed.
 	if len(controller.tunnels) == 1 {
-		controller.splitTunnelClassifier.Start(
-			tunnel, controller.untunneledDialConfig)
+		controller.splitTunnelClassifier.Start(tunnel)
 	}
 
 	return true
@@ -515,18 +517,26 @@ func (controller *Controller) isActiveTunnelServerEntry(serverEntry *ServerEntry
 // Dial selects an active tunnel and establishes a port forward
 // connection through the selected tunnel. Failure to connect is considered
 // 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()
 	if tunnel == nil {
 		return nil, ContextError(errors.New("no active tunnels"))
 	}
 
-	if controller.splitTunnelClassifier.IsUntunneled(remoteAddr) {
+	// 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 !alwaysTunnel && controller.splitTunnelClassifier.IsUntunneled(remoteAddr) {
 		// !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, downstreamConn)
+	tunneledConn, err := tunnel.Dial(remoteAddr, alwaysTunnel, downstreamConn)
 	if err != nil {
 		return nil, ContextError(err)
 	}

+ 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
 		// tunnel.
 		// TODO: connect timeout?
-		return tunneler.Dial(addr, nil)
+		return tunneler.Dial(addr, false, nil)
 	}
 	// TODO: also use http.Client, with its Timeout field?
 	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.
 	// This ensures that the downstream client (e.g., web browser) doesn't keep waiting on the
 	// 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 {
 		return ContextError(err)
 	}

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

@@ -29,6 +29,8 @@ import (
 	"strings"
 	"sync"
 	"time"
+
+	"github.com/Psiphon-Inc/dns"
 )
 
 // DialConfig contains parameters to determine the behavior
@@ -79,32 +81,6 @@ type DnsServerGetter interface {
 	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.
 type Dialer func(string, string) (net.Conn, error)
 
@@ -221,3 +197,61 @@ func HttpProxyConnect(rawConn net.Conn, addr string) (err error) {
 
 	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
+}

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

+ 3 - 52
psiphon/remoteServerList.go

@@ -20,26 +20,10 @@
 package psiphon
 
 import (
-	"crypto"
-	"crypto/rsa"
-	"crypto/sha256"
-	"crypto/x509"
-	"encoding/base64"
-	"encoding/json"
-	"errors"
 	"io/ioutil"
 	"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
 // config.RemoteServerListUrl; validates its digital signature using the
 // public key config.RemoteServerListSignaturePublicKey; and parses the
@@ -66,17 +50,13 @@ func FetchRemoteServerList(config *Config, dialConfig *DialConfig) (err error) {
 		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 {
 		return ContextError(err)
 	}
 
-	serverEntries, err := DecodeAndValidateServerEntryList(remoteServerList.Data)
+	serverEntries, err := DecodeAndValidateServerEntryList(remoteServerList)
 	if err != nil {
 		return ContextError(err)
 	}
@@ -88,32 +68,3 @@ func FetchRemoteServerList(config *Config, dialConfig *DialConfig) (err error) {
 
 	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
-}

+ 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.
 	// This ensures that the downstream client (e.g., web browser) doesn't keep waiting on the
 	// 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 {
 		return ContextError(err)
 	}

+ 170 - 35
psiphon/splitTunnel.go

@@ -20,11 +20,16 @@
 package psiphon
 
 import (
+	"bytes"
+	"compress/zlib"
+	"encoding/base64"
+	"errors"
 	"fmt"
 	"io/ioutil"
 	"net"
 	"net/http"
 	"sync"
+	"time"
 )
 
 // SplitTunnelClassifier determines whether a network destination
@@ -36,6 +41,10 @@ import (
 // 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.
@@ -55,21 +64,30 @@ import (
 // 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
-	dnsServerAddress     string
-	dnsDialConfig        *DialConfig
-	fetchRoutesWaitGroup *sync.WaitGroup
-	isRoutesSet          bool
+	mutex                    sync.RWMutex
+	fetchRoutesUrlFormat     string
+	routesSignaturePublicKey string
+	dnsServerAddress         string
+	dnsTunneler              Tunneler
+	fetchRoutesWaitGroup     *sync.WaitGroup
+	isRoutesSet              bool
+	cache                    map[string]*classification
+}
+
+type classification struct {
+	isUntunneled bool
+	expiry       time.Time
 }
 
-func NewSplitTunnelClassifier(config *Config, dnsDialConfig *DialConfig) *SplitTunnelClassifier {
+func NewSplitTunnelClassifier(config *Config, tunneler Tunneler) *SplitTunnelClassifier {
 	return &SplitTunnelClassifier{
-		fetchRoutesUrlFormat: config.SplitTunnelRoutesUrlFormat,
-		dnsServerAddress:     config.SplitTunnelDnsServer,
-		dnsDialConfig:        dnsDialConfig,
-		fetchRoutesWaitGroup: new(sync.WaitGroup),
-		isRoutesSet:          false,
+		fetchRoutesUrlFormat:     config.SplitTunnelRoutesUrlFormat,
+		routesSignaturePublicKey: config.SplitTunnelRoutesSignaturePublicKey,
+		dnsServerAddress:         config.SplitTunnelDnsServer,
+		dnsTunneler:              tunneler,
+		fetchRoutesWaitGroup:     new(sync.WaitGroup),
+		isRoutesSet:              false,
+		cache:                    make(map[string]*classification),
 	}
 }
 
@@ -77,8 +95,7 @@ func NewSplitTunnelClassifier(config *Config, dnsDialConfig *DialConfig) *SplitT
 // 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, dnsDialConfig *DialConfig) {
+func (classifier *SplitTunnelClassifier) Start(fetchRoutesTunnel *Tunnel) {
 
 	classifier.mutex.Lock()
 	defer classifier.mutex.Unlock()
@@ -86,6 +103,7 @@ func (classifier *SplitTunnelClassifier) Start(
 	classifier.isRoutesSet = false
 
 	if classifier.dnsServerAddress == "" ||
+		classifier.routesSignaturePublicKey == "" ||
 		classifier.fetchRoutesUrlFormat == "" {
 		// Split tunnel capability is not configured
 		return
@@ -118,19 +136,39 @@ func (classifier *SplitTunnelClassifier) Shutdown() {
 // 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. A multi-reader
-// lock is used to enable concurrent access.
+// 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()
-	defer classifier.mutex.RUnlock()
+	cachedClassification, ok := classifier.cache[targetAddress]
+	classifier.mutex.RUnlock()
+	if ok && cachedClassification.expiry.After(time.Now()) {
+		return cachedClassification.isUntunneled
+	}
 
-	if !classifier.isRoutesSet {
+	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)
 
-	// ***TODO***: implementation
+	isUntunneled := classifier.ipAddressInRoutes(ipAddr)
 
-	return false
+	// TODO: garbage collect expired items from cache?
+
+	classifier.mutex.Lock()
+	classifier.cache[targetAddress] = &classification{isUntunneled, expiry}
+	classifier.mutex.Unlock()
+
+	return isUntunneled
 }
 
 // setRoutes is a background routine that fetches routes data and installs it,
@@ -187,30 +225,72 @@ func (classifier *SplitTunnelClassifier) getRoutes(tunnel *Tunnel) (routesData [
 		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: %s", ContextError(err))
+		NoticeAlert("failed to request split tunnel routes package: %s", ContextError(err))
 		useCachedRoutes = true
-	} else {
+	}
+
+	if !useCachedRoutes {
 		defer response.Body.Close()
 		if response.StatusCode == http.StatusNotModified {
 			useCachedRoutes = true
-		} else {
-			routesData, err = ioutil.ReadAll(response.Body)
+		}
+	}
+
+	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 {
+		routesData, 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 read split tunnel routes: %s", ContextError(err))
-				useCachedRoutes = true
-			} else {
-				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
-					}
-				}
+				NoticeAlert("failed to cache split tunnel routes: %s", ContextError(err))
+				// Proceed with fetched data, even when we can't cache it
 			}
 		}
 	}
@@ -225,6 +305,14 @@ func (classifier *SplitTunnelClassifier) getRoutes(tunnel *Tunnel) (routesData [
 	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) {
@@ -237,3 +325,50 @@ func (classifier *SplitTunnelClassifier) installRoutes(routesData []byte) (err e
 
 	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()
+
+	// ***TODO***: implementation
+
+	return false
+}
+
+// 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(dnsServerAddress, 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
+}

+ 8 - 2
psiphon/tunnel.go

@@ -39,12 +39,15 @@ import (
 // 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
 // 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
 // explictly closed when the Dialed connection is closed. For instance, this
 // is used to close downstreamConn App<->LocalProxy connections when the related
 // LocalProxy<->SshPortForward connections close.
 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()
 }
 
@@ -192,7 +195,10 @@ func (tunnel *Tunnel) Close() {
 }
 
 // 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()
 	isClosed := tunnel.isClosed
 	tunnel.mutex.Unlock()