forked from Psiphon-Labs/psiphon-tunnel-core
-
Notifications
You must be signed in to change notification settings - Fork 3
/
Copy pathtactics.go
executable file
·1853 lines (1527 loc) · 56 KB
/
tactics.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
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
/*
* Copyright (c) 2018, 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 tactics provides dynamic Psiphon client configuration based on GeoIP
attributes, API parameters, and speed test data. The tactics implementation
works in concert with the "parameters" package, allowing contextual
optimization of Psiphon client parameters; for example, customizing
NetworkLatencyMultiplier to adjust timeouts for clients on slow networks; or
customizing LimitTunnelProtocols and ConnectionWorkerPoolSize to circumvent
specific blocking conditions.
Clients obtain tactics from a Psiphon server. Tactics are configured with a hot-
reloadable, JSON format server config file. The config file specifies default
tactics for all clients as well as a list of filtered tactics. For each filter,
if the client's attributes satisfy the filter then additional tactics are merged
into the tactics set provided to the client.
Tactics configuration is optimized for a modest number of filters -- dozens --
and very many GeoIP matches in each filter.
A Psiphon client "tactics request" is an an untunneled, pre-establishment
request to obtain tactics, which will in turn be applied and used in the normal
tunnel establishment sequence; the tactics request may result in custom
timeouts, protocol selection, and other tunnel establishment behavior.
The client will delay its normal establishment sequence and launch a tactics
request only when it has no stored, valid tactics for its current network
context. The normal establishment sequence will begin, regardless of tactics
request outcome, after TacticsWaitPeriod; this ensures that the client will not
stall its establishment process when the tactics request cannot complete.
Tactics are configured with a TTL, which is converted to an expiry time on the
client when tactics are received and stored. When the client starts its
establishment sequence and finds stored, unexpired tactics, no tactics request
is made. The expiry time serves to prevent execess tactics requests and avoid a
fingerprintable network sequence that would result from always performing the
tactics request.
The client calls UseStoredTactics to check for stored tactics; and if none is
found (there is no record or it is expired) the client proceeds to call
FetchTactics to make the tactics request.
In the Psiphon client and server, the tactics request is transported using the
meek protocol. In this case, meek is configured as a simple HTTP round trip
transport and does not relay arbitrary streams of data and does not allocate
resources required for relay mode. On the Psiphon server, the same meek
component handles both tactics requests and tunnel relays. Anti-probing for
tactics endpoints are thus provided as usual by meek. A meek request is routed
based on an routing field in the obfuscated meek cookie.
As meek may be plaintext and as TLS certificate verification is sometimes
skipped, the tactics request payload is wrapped with NaCl box and further
wrapped in a padded obfuscator. Distinct request and response nonces are used to
mitigate replay attacks. Clients generate ephemeral NaCl key pairs and the
server public key is obtained from the server entry. The server entry also
contains capabilities indicating that a Psiphon server supports tactics requests
and which meek protocol is to be used.
The Psiphon client requests, stores, and applies distinct tactics based on its
current network context. The client uses platform-specific APIs to obtain a fine
grain network ID based on, for example BSSID for WiFi or MCC/MNC for mobile.
These values provides accurate detection of network context changes and can be
obtained from the client device without any network activity. As the network ID
is personally identifying, this ID is only used by the client and is never sent
to the Psiphon server. The client obtains the current network ID from a callback
made from tunnel-core to native client code.
Tactics returned to the Psiphon client are accompanied by a "tag" which is a
hash digest of the merged tactics data. This tag uniquely identifies the
tactics. The client reports the tactics it is employing through the
"applied_tactics" common metrics API parameter. When fetching new tactics, the
client reports the stored (and possibly expired) tactics it has through the
"stored_tactics" API parameter. The stored tactics tag is used to avoid
redownloading redundant tactics data; when the tactics response indicates the
tag is unchanged, no tactics data is returned and the client simply extends the
expiry of the data is already has.
The Psiphon handshake API returns tactics in its response. This enabled regular
tactics expiry extension without requiring any distinct tactics request or
tactics data transfer when the tag is unchanged. Psiphon clients that connect
regularly and successfully with make almost no untunnled tactics requests except
for new network IDs. Returning tactics in the handshake reponse also provides
tactics in the case where a client is unable to complete an untunneled tactics
request but can otherwise establish a tunnel. Clients will abort any outstanding
untunneled tactics requests or scheduled retries once a handshake has completed.
The client handshake request component calls SetTacticsAPIParameters to populate
the handshake request parameters with tactics inputs, and calls
HandleTacticsPayload to process the tactics payload in the handshake response.
The core tactics data is custom values for a subset of the parameters in
parameters.Parameters. A client takes the default Parameters, applies any
custom values set in its config file, and then applies any stored or received
tactics. Each time the tactics changes, this process is repeated so that
obsolete tactics parameters are not retained in the client's Parameters
instance.
Tactics has a probability parameter that is used in a weighted coin flip to
determine if the tactics is to be applied or skipped for the current client
session. This allows for experimenting with provisional tactics; and obtaining
non-tactic sample metrics in situations which would otherwise always use a
tactic.
Speed test data is used in filtered tactics for selection of parameters such as
timeouts.
A speed test sample records the RTT of an application-level round trip to a
Psiphon server -- either a meek HTTP round trip or an SSH request round trip.
The round trip should be preformed after an TCP, TLS, SSH, etc. handshake so
that the RTT includes only the application-level round trip. Each sample also
records the tunnel/meek protocol used, the Psiphon server region, and a
timestamp; these values may be used to filter out outliers or stale samples. The
samples record bytes up/down, although at this time the speed test is focused on
latency and the payload is simply anti-fingerprint padding and should not be
larger than an IP packet.
The Psiphon client records the latest SpeedTestMaxSampleCount speed test samples
for each network context. SpeedTestMaxSampleCount should be a modest size, as
each speed test sample is ~100 bytes when serialzied and all samples (for one
network ID) are loaded into memory and sent as API inputs to tactics and
handshake requests.
When a tactics request is initiated and there are no speed test samples for
current network ID, the tactics request is proceeded by a speed test round trip,
using the same meek round tripper, and that sample is stored and used for the
tactics request. with a speed test The client records additional samples taken
from regular SSH keep alive round trips and calls AddSpeedTestSample to store
these.
The client sends all its speed test samples, for the current network context, to
the server in tactics and handshake requests; this allows the server logic to
handle outliers and aggregation. Currently, filtered tactics support filerting
on speed test RTT maximum, minimum, and median.
*/
package tactics
import (
"bytes"
"context"
"crypto/md5"
"crypto/rand"
"encoding/base64"
"encoding/hex"
"encoding/json"
"fmt"
"io/ioutil"
"net/http"
"sort"
"time"
"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common"
"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common/errors"
"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common/obfuscator"
"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common/parameters"
"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common/prng"
"golang.org/x/crypto/nacl/box"
)
// TACTICS_PADDING_MAX_SIZE is used by the client as well as the server. This
// value is not a dynamic client parameter since a tactics request is made
// only when the client has no valid tactics, so no override of
// TACTICS_PADDING_MAX_SIZE can be applied.
const (
SPEED_TEST_END_POINT = "speedtest"
TACTICS_END_POINT = "tactics"
MAX_REQUEST_BODY_SIZE = 65536
SPEED_TEST_PADDING_MIN_SIZE = 0
SPEED_TEST_PADDING_MAX_SIZE = 256
TACTICS_PADDING_MAX_SIZE = 256
TACTICS_OBFUSCATED_KEY_SIZE = 32
SPEED_TEST_SAMPLES_PARAMETER_NAME = "speed_test_samples"
APPLIED_TACTICS_TAG_PARAMETER_NAME = "applied_tactics_tag"
STORED_TACTICS_TAG_PARAMETER_NAME = "stored_tactics_tag"
TACTICS_METRIC_EVENT_NAME = "tactics"
NEW_TACTICS_TAG_LOG_FIELD_NAME = "new_tactics_tag"
IS_TACTICS_REQUEST_LOG_FIELD_NAME = "is_tactics_request"
AGGREGATION_MINIMUM = "Minimum"
AGGREGATION_MAXIMUM = "Maximum"
AGGREGATION_MEDIAN = "Median"
)
var (
TACTICS_REQUEST_NONCE = []byte{1}
TACTICS_RESPONSE_NONCE = []byte{2}
)
// Server is a tactics server to be integrated with the Psiphon server meek and handshake
// components.
//
// The meek server calls HandleEndPoint to handle untunneled tactics and speed test requests.
// The handshake handler calls GetTacticsPayload to obtain a tactics payload to include with
// the handsake response.
//
// The Server is a reloadable file; its exported fields are read from the tactics configuration
// file.
//
// Each client will receive at least the DefaultTactics. Client GeoIP, API parameter, and speed
// test sample attributes are matched against all filters and the tactics corresponding to any
// matching filter are merged into the client tactics.
//
// The merge operation replaces any existing item in Parameter with a Parameter specified in
// the newest matching tactics. The TTL and Probability of the newest matching tactics is taken,
// although all but the DefaultTactics can omit the TTL and Probability fields.
type Server struct {
common.ReloadableFile
// RequestPublicKey is the Server's tactics request NaCl box public key.
RequestPublicKey []byte
// RequestPublicKey is the Server's tactics request NaCl box private key.
RequestPrivateKey []byte
// RequestObfuscatedKey is the tactics request obfuscation key.
RequestObfuscatedKey []byte
// DefaultTactics is the baseline tactics for all clients. It must include a
// TTL and Probability.
DefaultTactics Tactics
// FilteredTactics is an ordered list of filter/tactics pairs. For a client,
// each fltered tactics is checked in order and merged into the clients
// tactics if the client's attributes satisfy the filter.
FilteredTactics []struct {
Filter Filter
Tactics Tactics
}
// When no tactics configuration file is provided, there will be no
// request key material or default tactics, and the server will not
// support tactics. The loaded flag, set to true only when a configuration
// file has been successfully loaded, provides an explict check for this
// condition (vs., say, checking for a zero-value Server).
loaded bool
filterGeoIPScope int
filterRegionScopes map[string]int
logger common.Logger
logFieldFormatter common.APIParameterLogFieldFormatter
apiParameterValidator common.APIParameterValidator
}
const (
GeoIPScopeRegion = 1
GeoIPScopeISP = 2
GeoIPScopeASN = 4
GeoIPScopeCity = 8
)
// Filter defines a filter to match against client attributes.
// Each field within the filter is optional and may be omitted.
type Filter struct {
// Regions specifies a list of GeoIP regions/countries the client
// must match.
Regions []string
// ISPs specifies a list of GeoIP ISPs the client must match.
ISPs []string
// ASNs specifies a list of GeoIP ASNs the client must match.
ASNs []string
// Cities specifies a list of GeoIP Cities the client must match.
Cities []string
// APIParameters specifies API, e.g. handshake, parameter names and
// a list of values, one of which must be specified to match this
// filter. Only scalar string API parameters may be filtered.
// Values may be patterns containing the '*' wildcard.
APIParameters map[string][]string
// SpeedTestRTTMilliseconds specifies a Range filter field that the
// client speed test samples must satisfy.
SpeedTestRTTMilliseconds *Range
regionLookup map[string]bool
ispLookup map[string]bool
asnLookup map[string]bool
cityLookup map[string]bool
}
// Range is a filter field which specifies that the aggregation of
// the a client attribute is within specified upper and lower bounds.
// At least one bound must be specified.
//
// For example, Range is to aggregate and filter client speed test
// sample RTTs.
type Range struct {
// Aggregation may be "Maximum", "Minimum", or "Median"
Aggregation string
// AtLeast specifies a lower bound for the aggregarted
// client value.
AtLeast *int
// AtMost specifies an upper bound for the aggregarted
// client value.
AtMost *int
}
// Payload is the data to be returned to the client in response to a
// tactics request or in the handshake response.
type Payload struct {
// Tag is the hash tag of the accompanying Tactics. When the Tag
// is the same as the stored tag the client specified in its
// request, the Tactics will be empty as the client already has the
// correct data.
Tag string
// Tactics is a JSON-encoded Tactics struct and may be nil.
Tactics json.RawMessage
}
// Record is the tactics data persisted by the client. There is one
// record for each network ID.
type Record struct {
// The Tag is the hash of the tactics data and is used as the
// stored tag when making requests.
Tag string
// Expiry is the time when this perisisted tactics expires as
// determined by the client applying the TTL against its local
// clock when the tactics was stored.
Expiry time.Time
// Tactics is the core tactics data.
Tactics Tactics
}
// Tactics is the core tactics data. This is both what is set in
// in the server configuration file and what is stored and used
// by the cient.
type Tactics struct {
// TTL is a string duration (e.g., "24h", the syntax supported
// by time.ParseDuration). This specifies how long the client
// should use the accompanying tactics until it expires.
//
// The client stores the TTL to use for extending the tactics
// expiry when a tactics request or handshake response returns
// no tactics data when the tag is unchanged.
TTL string
// Probability specifies the probability [0.0 - 1.0] with which
// the client should apply the tactics in a new session.
Probability float64
// Parameters specify client parameters to override. These must
// be a subset of parameter.ClientParameter values and follow
// the corresponding data type and minimum value constraints.
Parameters map[string]interface{}
}
// Note: the SpeedTestSample json tags are selected to minimize marshaled
// size. In psiphond, for logging metrics, the field names are translated to
// more verbose values. psiphon/server.makeSpeedTestSamplesLogField currently
// hard-codes these same SpeedTestSample json tag values for that translation.
// SpeedTestSample is speed test data for a single RTT event.
type SpeedTestSample struct {
// Timestamp is the speed test event time, and may be used to discard
// stale samples. The server supplies the speed test timestamp. This
// value is truncated to the nearest hour as a privacy measure.
Timestamp time.Time `json:"s"`
// EndPointRegion is the region of the endpoint, the Psiphon server,
// used for the speed test. This may be used to exclude outlier samples
// using remote data centers.
EndPointRegion string `json:"r"`
// EndPointProtocol is the tactics or tunnel protocol use for the
// speed test round trip. The protocol may impact RTT.
EndPointProtocol string `json:"p"`
// All speed test samples should measure RTT as the time to complete
// an application-level round trip on top of a previously established
// tactics or tunnel prococol connection. The RTT should not include
// TCP, TLS, or SSH handshakes.
// This value is truncated to the nearest millisecond as a privacy
// measure.
RTTMilliseconds int `json:"t"`
// BytesUp is the size of the upstream payload in the round trip.
// Currently, the payload is limited to anti-fingerprint padding.
BytesUp int `json:"u"`
// BytesDown is the size of the downstream payload in the round trip.
// Currently, the payload is limited to anti-fingerprint padding.
BytesDown int `json:"d"`
}
// GenerateKeys generates a tactics request key pair and obfuscation key.
func GenerateKeys() (encodedRequestPublicKey, encodedRequestPrivateKey, encodedObfuscatedKey string, err error) {
requestPublicKey, requestPrivateKey, err := box.GenerateKey(rand.Reader)
if err != nil {
return "", "", "", errors.Trace(err)
}
obfuscatedKey, err := common.MakeSecureRandomBytes(TACTICS_OBFUSCATED_KEY_SIZE)
if err != nil {
return "", "", "", errors.Trace(err)
}
return base64.StdEncoding.EncodeToString(requestPublicKey[:]),
base64.StdEncoding.EncodeToString(requestPrivateKey[:]),
base64.StdEncoding.EncodeToString(obfuscatedKey[:]),
nil
}
// NewServer creates Server using the specified tactics configuration file.
//
// The logger and logFieldFormatter callbacks are used to log errors and
// metrics. The apiParameterValidator callback is used to validate client
// API parameters submitted to the tactics request.
func NewServer(
logger common.Logger,
logFieldFormatter common.APIParameterLogFieldFormatter,
apiParameterValidator common.APIParameterValidator,
configFilename string) (*Server, error) {
server := &Server{
logger: logger,
logFieldFormatter: logFieldFormatter,
apiParameterValidator: apiParameterValidator,
}
server.ReloadableFile = common.NewReloadableFile(
configFilename,
true,
func(fileContent []byte, _ time.Time) error {
var newServer Server
err := json.Unmarshal(fileContent, &newServer)
if err != nil {
return errors.Trace(err)
}
err = newServer.Validate()
if err != nil {
return errors.Trace(err)
}
// Modify actual traffic rules only after validation
server.RequestPublicKey = newServer.RequestPublicKey
server.RequestPrivateKey = newServer.RequestPrivateKey
server.RequestObfuscatedKey = newServer.RequestObfuscatedKey
server.DefaultTactics = newServer.DefaultTactics
server.FilteredTactics = newServer.FilteredTactics
server.initLookups()
server.loaded = true
return nil
})
_, err := server.Reload()
if err != nil {
return nil, errors.Trace(err)
}
return server, nil
}
// Validate checks for correct tactics configuration values.
func (server *Server) Validate() error {
// Key material must either be entirely omitted, or fully populated.
if len(server.RequestPublicKey) == 0 {
if len(server.RequestPrivateKey) != 0 ||
len(server.RequestObfuscatedKey) != 0 {
return errors.TraceNew("unexpected request key")
}
} else {
if len(server.RequestPublicKey) != 32 ||
len(server.RequestPrivateKey) != 32 ||
len(server.RequestObfuscatedKey) != TACTICS_OBFUSCATED_KEY_SIZE {
return errors.TraceNew("invalid request key")
}
}
// validateTactics validates either the defaultTactics, when filteredTactics
// is nil, or the filteredTactics otherwise. In the second case,
// defaultTactics must be passed in to validate filtered tactics references
// to default tactics parameters, such as CustomTLSProfiles or
// PacketManipulationSpecs.
//
// Limitation: references must point to the default tactics or the filtered
// tactics itself; referring to parameters in a previous filtered tactics is
// not suported.
validateTactics := func(defaultTactics, filteredTactics *Tactics) error {
tactics := defaultTactics
validatingDefault := true
if filteredTactics != nil {
tactics = filteredTactics
validatingDefault = false
}
// Allow "" for 0, even though ParseDuration does not.
var d time.Duration
if tactics.TTL != "" {
var err error
d, err = time.ParseDuration(tactics.TTL)
if err != nil {
return errors.Trace(err)
}
}
if d <= 0 {
if validatingDefault {
return errors.TraceNew("invalid duration")
}
// For merging logic, Normalize any 0 duration to "".
tactics.TTL = ""
}
if (validatingDefault && tactics.Probability == 0.0) ||
tactics.Probability < 0.0 ||
tactics.Probability > 1.0 {
return errors.TraceNew("invalid probability")
}
params, err := parameters.NewParameters(nil)
if err != nil {
return errors.Trace(err)
}
applyParameters := []map[string]interface{}{
defaultTactics.Parameters,
}
if filteredTactics != nil {
applyParameters = append(
applyParameters, filteredTactics.Parameters)
}
_, err = params.Set("", false, applyParameters...)
if err != nil {
return errors.Trace(err)
}
return nil
}
validateRange := func(r *Range) error {
if r == nil {
return nil
}
if (r.AtLeast == nil && r.AtMost == nil) ||
((r.AtLeast != nil && r.AtMost != nil) && *r.AtLeast > *r.AtMost) {
return errors.TraceNew("invalid range")
}
switch r.Aggregation {
case AGGREGATION_MINIMUM, AGGREGATION_MAXIMUM, AGGREGATION_MEDIAN:
default:
return errors.TraceNew("invalid aggregation")
}
return nil
}
err := validateTactics(&server.DefaultTactics, nil)
if err != nil {
return errors.Tracef("invalid default tactics: %s", err)
}
for i, filteredTactics := range server.FilteredTactics {
err := validateTactics(&server.DefaultTactics, &filteredTactics.Tactics)
if err == nil {
err = validateRange(filteredTactics.Filter.SpeedTestRTTMilliseconds)
}
// TODO: validate Filter.APIParameters names are valid?
if err != nil {
return errors.Tracef("invalid filtered tactics %d: %s", i, err)
}
}
return nil
}
const stringLookupThreshold = 5
// initLookups creates map lookups for filters where the number
// of string values to compare against exceeds a threshold where
// benchmarks show maps are faster than looping through a string
// slice.
func (server *Server) initLookups() {
server.filterGeoIPScope = 0
server.filterRegionScopes = make(map[string]int)
for _, filteredTactics := range server.FilteredTactics {
if len(filteredTactics.Filter.Regions) >= stringLookupThreshold {
filteredTactics.Filter.regionLookup = make(map[string]bool)
for _, region := range filteredTactics.Filter.Regions {
filteredTactics.Filter.regionLookup[region] = true
}
}
if len(filteredTactics.Filter.ISPs) >= stringLookupThreshold {
filteredTactics.Filter.ispLookup = make(map[string]bool)
for _, ISP := range filteredTactics.Filter.ISPs {
filteredTactics.Filter.ispLookup[ISP] = true
}
}
if len(filteredTactics.Filter.ASNs) >= stringLookupThreshold {
filteredTactics.Filter.asnLookup = make(map[string]bool)
for _, ASN := range filteredTactics.Filter.ASNs {
filteredTactics.Filter.asnLookup[ASN] = true
}
}
if len(filteredTactics.Filter.Cities) >= stringLookupThreshold {
filteredTactics.Filter.cityLookup = make(map[string]bool)
for _, city := range filteredTactics.Filter.Cities {
filteredTactics.Filter.cityLookup[city] = true
}
}
// Initialize the filter GeoIP scope fields used by GetFilterGeoIPScope.
//
// The basic case is, for example, when only Regions appear in filters, then
// only GeoIPScopeRegion is set.
//
// As an optimization, a regional map is populated so that, for example,
// GeoIPScopeRegion&GeoIPScopeISP will be set only for regions for which
// there is a filter with region and ISP, while other regions will set only
// GeoIPScopeRegion.
//
// When any ISP, ASN, or City appears in a filter without a Region,
// the regional map optimization is disabled.
if len(filteredTactics.Filter.Regions) == 0 {
disableRegionScope := false
if len(filteredTactics.Filter.ISPs) > 0 {
server.filterGeoIPScope |= GeoIPScopeISP
disableRegionScope = true
}
if len(filteredTactics.Filter.ASNs) > 0 {
server.filterGeoIPScope |= GeoIPScopeASN
disableRegionScope = true
}
if len(filteredTactics.Filter.Cities) > 0 {
server.filterGeoIPScope |= GeoIPScopeCity
disableRegionScope = true
}
if disableRegionScope && server.filterRegionScopes != nil {
for _, regionScope := range server.filterRegionScopes {
server.filterGeoIPScope |= regionScope
}
server.filterRegionScopes = nil
}
} else {
server.filterGeoIPScope |= GeoIPScopeRegion
if server.filterRegionScopes != nil {
regionScope := 0
if len(filteredTactics.Filter.ISPs) > 0 {
regionScope |= GeoIPScopeISP
}
if len(filteredTactics.Filter.ASNs) > 0 {
regionScope |= GeoIPScopeASN
}
if len(filteredTactics.Filter.Cities) > 0 {
regionScope |= GeoIPScopeCity
}
for _, region := range filteredTactics.Filter.Regions {
server.filterRegionScopes[region] |= regionScope
}
}
}
// TODO: add lookups for APIParameters?
// Not expected to be long lists of values.
}
}
// GetFilterGeoIPScope returns which GeoIP fields are relevent to tactics
// filters. The return value is a bit array containing some combination of
// the GeoIPScopeRegion, GeoIPScopeISP, GeoIPScopeASN, and GeoIPScopeCity
// flags. For the given geoIPData, all tactics filters reference only the
// flagged fields.
func (server *Server) GetFilterGeoIPScope(geoIPData common.GeoIPData) int {
scope := server.filterGeoIPScope
if server.filterRegionScopes != nil {
regionScope, ok := server.filterRegionScopes[geoIPData.Country]
if ok {
scope |= regionScope
}
}
return scope
}
// GetTacticsPayload assembles and returns a tactics payload for a client with
// the specified GeoIP, API parameters, and speed test attributes.
//
// The speed test samples are expected to be in apiParams, as is the stored
// tactics tag.
//
// Unless no tactics configuration was loaded, GetTacticsPayload will always
// return a payload for any client. When the client's stored tactics tag is
// identical to the assembled tactics, the Payload.Tactics is nil.
//
// Elements of the returned Payload, e.g., tactics parameters, will point to
// data in DefaultTactics and FilteredTactics and must not be modifed.
func (server *Server) GetTacticsPayload(
geoIPData common.GeoIPData,
apiParams common.APIParameters) (*Payload, error) {
// includeServerSideOnly is false: server-side only parameters are not
// used by the client, so including them wastes space and unnecessarily
// exposes the values.
tactics, err := server.GetTactics(false, geoIPData, apiParams)
if err != nil {
return nil, errors.Trace(err)
}
if tactics == nil {
return nil, nil
}
marshaledTactics, tag, err := marshalTactics(tactics)
if err != nil {
return nil, errors.Trace(err)
}
payload := &Payload{
Tag: tag,
}
// New clients should always send STORED_TACTICS_TAG_PARAMETER_NAME. When they have no
// stored tactics, the stored tag will be "" and not match payload.Tag and payload.Tactics
// will be sent.
//
// When new clients send a stored tag that matches payload.Tag, the client already has
// the correct data and payload.Tactics is not sent.
//
// Old clients will not send STORED_TACTICS_TAG_PARAMETER_NAME. In this case, do not
// send payload.Tactics as the client will not use it, will not store it, will not send
// back the new tag and so the handshake response will always contain wasteful tactics
// data.
sendPayloadTactics := true
clientStoredTag, err := getStringRequestParam(apiParams, STORED_TACTICS_TAG_PARAMETER_NAME)
// Old client or new client with same tag.
if err != nil || payload.Tag == clientStoredTag {
sendPayloadTactics = false
}
if sendPayloadTactics {
payload.Tactics = marshaledTactics
}
return payload, nil
}
func marshalTactics(tactics *Tactics) ([]byte, string, error) {
marshaledTactics, err := json.Marshal(tactics)
if err != nil {
return nil, "", errors.Trace(err)
}
// MD5 hash is used solely as a data checksum and not for any security purpose.
digest := md5.Sum(marshaledTactics)
tag := hex.EncodeToString(digest[:])
return marshaledTactics, tag, nil
}
// GetTacticsWithTag returns a GetTactics value along with the associated tag value.
func (server *Server) GetTacticsWithTag(
includeServerSideOnly bool,
geoIPData common.GeoIPData,
apiParams common.APIParameters) (*Tactics, string, error) {
tactics, err := server.GetTactics(
includeServerSideOnly, geoIPData, apiParams)
if err != nil {
return nil, "", errors.Trace(err)
}
if tactics == nil {
return nil, "", nil
}
_, tag, err := marshalTactics(tactics)
if err != nil {
return nil, "", errors.Trace(err)
}
return tactics, tag, nil
}
// GetTactics assembles and returns tactics data for a client with the
// specified GeoIP, API parameter, and speed test attributes.
//
// The tactics return value may be nil.
func (server *Server) GetTactics(
includeServerSideOnly bool,
geoIPData common.GeoIPData,
apiParams common.APIParameters) (*Tactics, error) {
server.ReloadableFile.RLock()
defer server.ReloadableFile.RUnlock()
if !server.loaded {
// No tactics configuration was loaded.
return nil, nil
}
tactics := server.DefaultTactics.clone(includeServerSideOnly)
var aggregatedValues map[string]int
for _, filteredTactics := range server.FilteredTactics {
if len(filteredTactics.Filter.Regions) > 0 {
if filteredTactics.Filter.regionLookup != nil {
if !filteredTactics.Filter.regionLookup[geoIPData.Country] {
continue
}
} else {
if !common.Contains(filteredTactics.Filter.Regions, geoIPData.Country) {
continue
}
}
}
if len(filteredTactics.Filter.ISPs) > 0 {
if filteredTactics.Filter.ispLookup != nil {
if !filteredTactics.Filter.ispLookup[geoIPData.ISP] {
continue
}
} else {
if !common.Contains(filteredTactics.Filter.ISPs, geoIPData.ISP) {
continue
}
}
}
if len(filteredTactics.Filter.ASNs) > 0 {
if filteredTactics.Filter.asnLookup != nil {
if !filteredTactics.Filter.asnLookup[geoIPData.ASN] {
continue
}
} else {
if !common.Contains(filteredTactics.Filter.ASNs, geoIPData.ASN) {
continue
}
}
}
if len(filteredTactics.Filter.Cities) > 0 {
if filteredTactics.Filter.cityLookup != nil {
if !filteredTactics.Filter.cityLookup[geoIPData.City] {
continue
}
} else {
if !common.Contains(filteredTactics.Filter.Cities, geoIPData.City) {
continue
}
}
}
if filteredTactics.Filter.APIParameters != nil {
mismatch := false
for name, values := range filteredTactics.Filter.APIParameters {
clientValue, err := getStringRequestParam(apiParams, name)
if err != nil || !common.ContainsWildcard(values, clientValue) {
mismatch = true
break
}
}
if mismatch {
continue
}
}
if filteredTactics.Filter.SpeedTestRTTMilliseconds != nil {
var speedTestSamples []SpeedTestSample
err := getJSONRequestParam(apiParams, SPEED_TEST_SAMPLES_PARAMETER_NAME, &speedTestSamples)
if err != nil {
// TODO: log speed test parameter errors?
// This API param is not explicitly validated elsewhere.
continue
}
// As there must be at least one Range bound, there must be data to aggregate.
if len(speedTestSamples) == 0 {
continue
}
if aggregatedValues == nil {
aggregatedValues = make(map[string]int)
}
// Note: here we could filter out outliers such as samples that are unusually old
// or client/endPoint region pair too distant.
// aggregate may mutate (sort) the speedTestSamples slice.
value := aggregate(
filteredTactics.Filter.SpeedTestRTTMilliseconds.Aggregation,
speedTestSamples,
aggregatedValues)
if filteredTactics.Filter.SpeedTestRTTMilliseconds.AtLeast != nil &&
value < *filteredTactics.Filter.SpeedTestRTTMilliseconds.AtLeast {
continue
}
if filteredTactics.Filter.SpeedTestRTTMilliseconds.AtMost != nil &&
value > *filteredTactics.Filter.SpeedTestRTTMilliseconds.AtMost {
continue
}
}
tactics.merge(includeServerSideOnly, &filteredTactics.Tactics)
// Continue to apply more matches. Last matching tactics has priority for any field.
}
return tactics, nil
}
// TODO: refactor this copy of psiphon/server.getStringRequestParam into common?
func getStringRequestParam(apiParams common.APIParameters, name string) (string, error) {
if apiParams[name] == nil {
return "", errors.Tracef("missing param: %s", name)
}
value, ok := apiParams[name].(string)
if !ok {
return "", errors.Tracef("invalid param: %s", name)
}
return value, nil
}
func getJSONRequestParam(apiParams common.APIParameters, name string, value interface{}) error {
if apiParams[name] == nil {
return errors.Tracef("missing param: %s", name)
}
// Remarshal the parameter from common.APIParameters, as the initial API parameter
// unmarshal will not have known the correct target type. I.e., instead of doing
// unmarhsal-into-struct, common.APIParameters will have an unmarshal-into-interface
// value as described here: https://golang.org/pkg/encoding/json/#Unmarshal.
jsonValue, err := json.Marshal(apiParams[name])
if err != nil {
return errors.Trace(err)
}
err = json.Unmarshal(jsonValue, value)
if err != nil {
return errors.Trace(err)
}
return nil
}
// aggregate may mutate (sort) the speedTestSamples slice.
func aggregate(
aggregation string,
speedTestSamples []SpeedTestSample,