-
Notifications
You must be signed in to change notification settings - Fork 259
/
splitTunnel.go
420 lines (356 loc) · 13.2 KB
/
splitTunnel.go
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
/*
* 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 (
"bytes"
"compress/zlib"
"encoding/base64"
"errors"
"fmt"
"io/ioutil"
"net"
"net/http"
"sync"
"time"
"github.com/Psiphon-Labs/goarista/monotime"
"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common"
"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common/parameters"
)
// 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
clientParameters *parameters.ClientParameters
userAgent string
dnsTunneler Tunneler
fetchRoutesWaitGroup *sync.WaitGroup
isRoutesSet bool
cache map[string]*classification
routes common.SubnetLookup
}
type classification struct {
isUntunneled bool
expiry monotime.Time
}
func NewSplitTunnelClassifier(config *Config, tunneler Tunneler) *SplitTunnelClassifier {
return &SplitTunnelClassifier{
clientParameters: config.clientParameters,
userAgent: MakePsiphonUserAgent(config),
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
p := classifier.clientParameters.Get()
dnsServerAddress := p.String(parameters.SplitTunnelDNSServer)
routesSignaturePublicKey := p.String(parameters.SplitTunnelRoutesSignaturePublicKey)
fetchRoutesUrlFormat := p.String(parameters.SplitTunnelRoutesURLFormat)
p = nil
if dnsServerAddress == "" ||
routesSignaturePublicKey == "" ||
fetchRoutesUrlFormat == "" {
// Split tunnel capability is not configured
return
}
if fetchRoutesTunnel.serverContext == nil {
// Tunnel has no serverContext
return
}
if fetchRoutesTunnel.serverContext.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
}
dnsServerAddress := classifier.clientParameters.Get().String(
parameters.SplitTunnelDNSServer)
if dnsServerAddress == "" {
// Split tunnel has been disabled.
return false
}
classifier.mutex.RLock()
cachedClassification, ok := classifier.cache[targetAddress]
classifier.mutex.RUnlock()
if ok && cachedClassification.expiry.After(monotime.Now()) {
return cachedClassification.isUntunneled
}
ipAddr, ttl, err := tunneledLookupIP(
dnsServerAddress, classifier.dnsTunneler, targetAddress)
if err != nil {
NoticeAlert("failed to resolve address for split tunnel classification: %s", err)
return false
}
expiry := monotime.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.serverContext.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) {
p := classifier.clientParameters.Get()
routesSignaturePublicKey := p.String(parameters.SplitTunnelRoutesSignaturePublicKey)
fetchRoutesUrlFormat := p.String(parameters.SplitTunnelRoutesURLFormat)
fetchTimeout := p.Duration(parameters.FetchSplitTunnelRoutesTimeout)
p = nil
url := fmt.Sprintf(fetchRoutesUrlFormat, tunnel.serverContext.clientRegion)
request, err := http.NewRequest("GET", url, nil)
if err != nil {
return nil, common.ContextError(err)
}
request.Header.Set("User-Agent", classifier.userAgent)
etag, err := GetSplitTunnelRoutesETag(tunnel.serverContext.clientRegion)
if err != nil {
return nil, common.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: fetchTimeout,
}
httpClient := &http.Client{
Transport: transport,
Timeout: fetchTimeout,
}
// 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 &&
(response.StatusCode != http.StatusOK && response.StatusCode != http.StatusNotModified) {
response.Body.Close()
err = fmt.Errorf("unexpected response status code: %d", response.StatusCode)
}
if err != nil {
NoticeAlert("failed to request split tunnel routes package: %s", common.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", common.ContextError(err))
useCachedRoutes = true
}
}
var encodedRoutesData string
if !useCachedRoutes {
encodedRoutesData, err = common.ReadAuthenticatedDataPackage(
routesDataPackage, false, routesSignaturePublicKey)
if err != nil {
NoticeAlert("failed to read split tunnel routes package: %s", common.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", common.ContextError(err))
useCachedRoutes = true
}
}
if !useCachedRoutes {
zlibReader, err := zlib.NewReader(bytes.NewReader(compressedRoutesData))
if err == nil {
routesData, err = ioutil.ReadAll(zlibReader)
zlibReader.Close()
}
if err != nil {
NoticeAlert("failed to decompress split tunnel routes: %s", common.ContextError(err))
useCachedRoutes = true
}
}
if !useCachedRoutes {
etag := response.Header.Get("ETag")
if etag != "" {
err := SetSplitTunnelRoutes(tunnel.serverContext.clientRegion, etag, routesData)
if err != nil {
NoticeAlert("failed to cache split tunnel routes: %s", common.ContextError(err))
// Proceed with fetched data, even when we can't cache it
}
}
}
if useCachedRoutes {
routesData, err = GetSplitTunnelRoutesData(tunnel.serverContext.clientRegion)
if err != nil {
return nil, common.ContextError(err)
}
if routesData == nil {
return nil, common.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 = common.NewSubnetLookupFromRoutes(routesData)
if err != nil {
return common.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)
}
// 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, common.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, common.ContextError(err)
}
ipAddrs, ttls, err := ResolveIP(host, conn)
if err != nil {
return nil, 0, common.ContextError(err)
}
if len(ipAddrs) < 1 {
return nil, 0, common.ContextError(errors.New("no IP address"))
}
return ipAddrs[0], ttls[0], nil
}