mirror of
https://gitlab.torproject.org/tpo/anti-censorship/pluggable-transports/snowflake.git
synced 2025-10-13 11:11:30 -04:00
1053 lines
33 KiB
Go
1053 lines
33 KiB
Go
package main
|
|
|
|
import (
|
|
"bytes"
|
|
"container/heap"
|
|
"encoding/hex"
|
|
"fmt"
|
|
"io"
|
|
"log"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"os"
|
|
"sync"
|
|
"sync/atomic"
|
|
"testing"
|
|
"time"
|
|
|
|
. "github.com/smartystreets/goconvey/convey"
|
|
"gitlab.torproject.org/tpo/anti-censorship/pluggable-transports/snowflake/v2/common/amp"
|
|
"gitlab.torproject.org/tpo/anti-censorship/pluggable-transports/snowflake/v2/common/messages"
|
|
)
|
|
|
|
func NullLogger() *log.Logger {
|
|
logger := log.New(os.Stdout, "", 0)
|
|
logger.SetOutput(io.Discard)
|
|
return logger
|
|
}
|
|
|
|
var promOnce sync.Once
|
|
|
|
var (
|
|
sdp = "v=0\r\n" +
|
|
"o=- 123456789 987654321 IN IP4 0.0.0.0\r\n" +
|
|
"s=-\r\n" +
|
|
"t=0 0\r\n" +
|
|
"a=fingerprint:sha-256 12:34\r\n" +
|
|
"a=extmap-allow-mixed\r\n" +
|
|
"a=group:BUNDLE 0\r\n" +
|
|
"m=application 9 UDP/DTLS/SCTP webrtc-datachannel\r\n" +
|
|
"c=IN IP4 0.0.0.0\r\n" +
|
|
"a=setup:actpass\r\n" +
|
|
"a=mid:0\r\n" +
|
|
"a=sendrecv\r\n" +
|
|
"a=sctp-port:5000\r\n" +
|
|
"a=ice-ufrag:CoVEaiFXRGVzshXG\r\n" +
|
|
"a=ice-pwd:aOrOZXraTfFKzyeBxIXYYKjSgRVPGhUx\r\n" +
|
|
"a=candidate:1000 1 udp 2000 8.8.8.8 3000 typ host\r\n" +
|
|
"a=end-of-candidates\r\n"
|
|
|
|
rawOffer = `{"type":"offer","sdp":"v=0\r\no=- 4358805017720277108 2 IN IP4 0.0.0.0\r\ns=-\r\nt=0 0\r\na=group:BUNDLE data\r\na=msid-semantic: WMS\r\nm=application 56688 DTLS/SCTP 5000\r\nc=IN IP4 0.0.0.0\r\na=candidate:3769337065 1 udp 2122260223 129.97.208.23 56688 typ host generation 0 network-id 1 network-cost 50\r\na=candidate:2921887769 1 tcp 1518280447 129.97.208.23 35441 typ host tcptype passive generation 0 network-id 1 network-cost 50\r\na=ice-ufrag:aMAZ\r\na=ice-pwd:jcHb08Jjgrazp2dzjdrvPPvV\r\na=ice-options:trickle\r\na=fingerprint:sha-256 C8:88:EE:B9:E7:02:2E:21:37:ED:7A:D1:EB:2B:A3:15:A2:3B:5B:1C:3D:D4:D5:1F:06:CF:52:40:03:F8:DD:66\r\na=setup:actpass\r\na=mid:data\r\na=sctpmap:5000 webrtc-datachannel 1024\r\n"}`
|
|
sid = "ymbcCMto7KHNGYlp"
|
|
)
|
|
|
|
func createClientOffer(sdp, nat, fingerprint string) (*bytes.Reader, error) {
|
|
clientRequest := &messages.ClientPollRequest{
|
|
Offer: sdp,
|
|
NAT: nat,
|
|
Fingerprint: fingerprint,
|
|
}
|
|
encOffer, err := clientRequest.EncodeClientPollRequest()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
offer := bytes.NewReader(encOffer)
|
|
return offer, nil
|
|
}
|
|
|
|
func createProxyAnswer(sdp, sid string) (*bytes.Reader, error) {
|
|
proxyRequest, err := messages.EncodeAnswerRequest(sdp, sid)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
answer := bytes.NewReader(proxyRequest)
|
|
return answer, nil
|
|
}
|
|
|
|
func decodeAMPArmorToString(r io.Reader) (string, error) {
|
|
dec, err := amp.NewArmorDecoder(r)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
p, err := io.ReadAll(dec)
|
|
return string(p), err
|
|
}
|
|
|
|
func TestBroker(t *testing.T) {
|
|
|
|
defaultBridgeValue, _ := hex.DecodeString("2B280B23E1107BB62ABFC40DDCC8824814F80A72")
|
|
var defaultBridge [20]byte
|
|
copy(defaultBridge[:], defaultBridgeValue)
|
|
|
|
Convey("Context", t, func() {
|
|
buf := new(bytes.Buffer)
|
|
ctx := NewBrokerContext(log.New(buf, "", 0), "snowflake.torproject.net")
|
|
i := &IPC{ctx}
|
|
|
|
Convey("Adds Snowflake", func() {
|
|
So(ctx.snowflakes.Len(), ShouldEqual, 0)
|
|
So(len(ctx.idToSnowflake), ShouldEqual, 0)
|
|
ctx.AddSnowflake("foo", "", NATUnrestricted, 0)
|
|
So(ctx.snowflakes.Len(), ShouldEqual, 1)
|
|
So(len(ctx.idToSnowflake), ShouldEqual, 1)
|
|
})
|
|
|
|
Convey("Broker goroutine matches clients with proxies", func() {
|
|
p := new(ProxyPoll)
|
|
p.id = "test"
|
|
p.natType = "unrestricted"
|
|
p.offerChannel = make(chan *ClientOffer)
|
|
go func(ctx *BrokerContext) {
|
|
ctx.proxyPolls <- p
|
|
close(ctx.proxyPolls)
|
|
}(ctx)
|
|
ctx.Broker()
|
|
So(ctx.snowflakes.Len(), ShouldEqual, 1)
|
|
snowflake := heap.Pop(ctx.snowflakes).(*Snowflake)
|
|
snowflake.offerChannel <- &ClientOffer{sdp: []byte("test offer")}
|
|
offer := <-p.offerChannel
|
|
So(ctx.idToSnowflake["test"], ShouldNotBeNil)
|
|
So(offer.sdp, ShouldResemble, []byte("test offer"))
|
|
So(ctx.snowflakes.Len(), ShouldEqual, 0)
|
|
})
|
|
|
|
Convey("Request an offer from the Snowflake Heap", func() {
|
|
done := make(chan *ClientOffer)
|
|
go func() {
|
|
offer := ctx.RequestOffer("test", "", NATUnrestricted, 0)
|
|
done <- offer
|
|
}()
|
|
request := <-ctx.proxyPolls
|
|
request.offerChannel <- &ClientOffer{sdp: []byte("test offer")}
|
|
offer := <-done
|
|
So(offer.sdp, ShouldResemble, []byte("test offer"))
|
|
})
|
|
|
|
Convey("Responds to HTTP client offers...", func() {
|
|
w := httptest.NewRecorder()
|
|
data, err := createClientOffer(sdp, NATUnknown, "")
|
|
r, err := http.NewRequest("POST", "snowflake.broker/client", data)
|
|
So(err, ShouldBeNil)
|
|
|
|
Convey("with error when no snowflakes are available.", func() {
|
|
clientOffers(i, w, r)
|
|
So(w.Code, ShouldEqual, http.StatusOK)
|
|
So(w.Body.String(), ShouldEqual, `{"error":"no snowflake proxies currently available"}`)
|
|
|
|
// Ensure that denial is correctly recorded in metrics
|
|
ctx.metrics.printMetrics()
|
|
So(buf.String(), ShouldContainSubstring, `client-denied-count 8
|
|
client-restricted-denied-count 8
|
|
client-unrestricted-denied-count 0
|
|
client-snowflake-match-count 0
|
|
client-snowflake-timeout-count 0
|
|
client-http-count 8
|
|
client-http-ips ??=8
|
|
client-ampcache-count 0
|
|
client-ampcache-ips
|
|
client-sqs-count 0
|
|
client-sqs-ips
|
|
`)
|
|
})
|
|
|
|
Convey("with a proxy answer if available.", func() {
|
|
done := make(chan bool)
|
|
// Prepare a fake proxy to respond with.
|
|
snowflake := ctx.AddSnowflake("test", "", NATUnrestricted, 0)
|
|
go func() {
|
|
clientOffers(i, w, r)
|
|
done <- true
|
|
}()
|
|
offer := <-snowflake.offerChannel
|
|
So(offer.sdp, ShouldResemble, []byte(sdp))
|
|
snowflake.answerChannel <- "test answer"
|
|
<-done
|
|
So(w.Body.String(), ShouldEqual, `{"answer":"test answer"}`)
|
|
So(w.Code, ShouldEqual, http.StatusOK)
|
|
|
|
// Ensure that match is correctly recorded in metrics
|
|
ctx.metrics.printMetrics()
|
|
So(buf.String(), ShouldContainSubstring, `client-denied-count 0
|
|
client-restricted-denied-count 0
|
|
client-unrestricted-denied-count 0
|
|
client-snowflake-match-count 8
|
|
client-snowflake-timeout-count 0
|
|
client-http-count 8
|
|
client-http-ips ??=8
|
|
client-ampcache-count 0
|
|
client-ampcache-ips
|
|
client-sqs-count 0
|
|
client-sqs-ips
|
|
`)
|
|
})
|
|
|
|
Convey("with unrestricted proxy to unrestricted client if there are no restricted proxies", func() {
|
|
snowflake := ctx.AddSnowflake("test", "", NATUnrestricted, 0)
|
|
offerData, err := createClientOffer(sdp, NATUnrestricted, "")
|
|
So(err, ShouldBeNil)
|
|
r, err := http.NewRequest("POST", "snowflake.broker/client", offerData)
|
|
|
|
done := make(chan bool)
|
|
go func() {
|
|
clientOffers(i, w, r)
|
|
done <- true
|
|
}()
|
|
|
|
select {
|
|
case <-snowflake.offerChannel:
|
|
case <-time.After(250 * time.Millisecond):
|
|
So(false, ShouldBeTrue)
|
|
return
|
|
}
|
|
snowflake.answerChannel <- "test answer"
|
|
|
|
<-done
|
|
So(w.Body.String(), ShouldEqual, `{"answer":"test answer"}`)
|
|
})
|
|
|
|
Convey("Times out when no proxy responds.", func() {
|
|
if testing.Short() {
|
|
return
|
|
}
|
|
done := make(chan bool)
|
|
snowflake := ctx.AddSnowflake("fake", "", NATUnrestricted, 0)
|
|
go func() {
|
|
clientOffers(i, w, r)
|
|
// Takes a few seconds here...
|
|
done <- true
|
|
}()
|
|
offer := <-snowflake.offerChannel
|
|
So(offer.sdp, ShouldResemble, []byte(sdp))
|
|
<-done
|
|
So(w.Code, ShouldEqual, http.StatusOK)
|
|
So(w.Body.String(), ShouldEqual, `{"error":"timed out waiting for answer!"}`)
|
|
})
|
|
})
|
|
|
|
Convey("Responds to HTTP legacy client offers...", func() {
|
|
w := httptest.NewRecorder()
|
|
// legacy offer starts with {
|
|
offer := bytes.NewReader([]byte(fmt.Sprintf(`{%v}`, sdp)))
|
|
r, err := http.NewRequest("POST", "snowflake.broker/client", offer)
|
|
So(err, ShouldBeNil)
|
|
r.Header.Set("Snowflake-NAT-TYPE", "restricted")
|
|
|
|
Convey("with 503 when no snowflakes are available.", func() {
|
|
clientOffers(i, w, r)
|
|
So(w.Code, ShouldEqual, http.StatusServiceUnavailable)
|
|
So(w.Body.String(), ShouldEqual, "")
|
|
|
|
// Ensure that denial is correctly recorded in metrics
|
|
ctx.metrics.printMetrics()
|
|
So(buf.String(), ShouldContainSubstring, `client-denied-count 8
|
|
client-restricted-denied-count 8
|
|
client-unrestricted-denied-count 0
|
|
client-snowflake-match-count 0
|
|
client-snowflake-timeout-count 0
|
|
client-http-count 8
|
|
client-http-ips ??=8
|
|
client-ampcache-count 0
|
|
client-ampcache-ips
|
|
client-sqs-count 0
|
|
client-sqs-ips
|
|
`)
|
|
})
|
|
|
|
Convey("with a proxy answer if available.", func() {
|
|
done := make(chan bool)
|
|
// Prepare a fake proxy to respond with.
|
|
snowflake := ctx.AddSnowflake("fake", "", NATUnrestricted, 0)
|
|
go func() {
|
|
clientOffers(i, w, r)
|
|
done <- true
|
|
}()
|
|
offer := <-snowflake.offerChannel
|
|
So(offer.sdp, ShouldResemble, []byte(fmt.Sprintf(`{%v}`, sdp)))
|
|
snowflake.answerChannel <- "fake answer"
|
|
<-done
|
|
So(w.Body.String(), ShouldEqual, "fake answer")
|
|
So(w.Code, ShouldEqual, http.StatusOK)
|
|
|
|
// Ensure that match is correctly recorded in metrics
|
|
ctx.metrics.printMetrics()
|
|
So(buf.String(), ShouldContainSubstring, `client-denied-count 0
|
|
client-restricted-denied-count 0
|
|
client-unrestricted-denied-count 0
|
|
client-snowflake-match-count 8
|
|
client-snowflake-timeout-count 0
|
|
client-http-count 8
|
|
client-http-ips ??=8
|
|
client-ampcache-count 0
|
|
client-ampcache-ips
|
|
client-sqs-count 0
|
|
client-sqs-ips
|
|
`)
|
|
})
|
|
|
|
Convey("Times out when no proxy responds.", func() {
|
|
if testing.Short() {
|
|
return
|
|
}
|
|
done := make(chan bool)
|
|
snowflake := ctx.AddSnowflake("fake", "", NATUnrestricted, 0)
|
|
go func() {
|
|
clientOffers(i, w, r)
|
|
// Takes a few seconds here...
|
|
done <- true
|
|
}()
|
|
offer := <-snowflake.offerChannel
|
|
So(offer.sdp, ShouldResemble, []byte(fmt.Sprintf(`{%v}`, sdp)))
|
|
<-done
|
|
So(w.Code, ShouldEqual, http.StatusGatewayTimeout)
|
|
})
|
|
|
|
})
|
|
|
|
Convey("Responds to AMP client offers...", func() {
|
|
w := httptest.NewRecorder()
|
|
encPollReq := []byte("1.0\n{\"offer\": \"fake\", \"nat\": \"unknown\"}")
|
|
r, err := http.NewRequest("GET", "/amp/client/"+amp.EncodePath(encPollReq), nil)
|
|
So(err, ShouldBeNil)
|
|
|
|
Convey("with status 200 when request is badly formatted.", func() {
|
|
r, err := http.NewRequest("GET", "/amp/client/bad", nil)
|
|
So(err, ShouldBeNil)
|
|
ampClientOffers(i, w, r)
|
|
body, err := decodeAMPArmorToString(w.Body)
|
|
So(err, ShouldBeNil)
|
|
So(body, ShouldEqual, `{"error":"cannot decode URL path"}`)
|
|
})
|
|
|
|
Convey("with error when no snowflakes are available.", func() {
|
|
ampClientOffers(i, w, r)
|
|
So(w.Code, ShouldEqual, http.StatusOK)
|
|
body, err := decodeAMPArmorToString(w.Body)
|
|
So(err, ShouldBeNil)
|
|
So(body, ShouldEqual, `{"error":"no snowflake proxies currently available"}`)
|
|
|
|
// Ensure that denial is correctly recorded in metrics
|
|
ctx.metrics.printMetrics()
|
|
So(buf.String(), ShouldContainSubstring, `client-denied-count 8
|
|
client-restricted-denied-count 8
|
|
client-unrestricted-denied-count 0
|
|
client-snowflake-match-count 0
|
|
client-snowflake-timeout-count 0
|
|
client-http-count 0
|
|
client-http-ips
|
|
client-ampcache-count 8
|
|
client-ampcache-ips ??=8
|
|
client-sqs-count 0
|
|
client-sqs-ips
|
|
`)
|
|
})
|
|
|
|
Convey("with a proxy answer if available.", func() {
|
|
done := make(chan bool)
|
|
// Prepare a fake proxy to respond with.
|
|
snowflake := ctx.AddSnowflake("fake", "", NATUnrestricted, 0)
|
|
go func() {
|
|
ampClientOffers(i, w, r)
|
|
done <- true
|
|
}()
|
|
offer := <-snowflake.offerChannel
|
|
So(offer.sdp, ShouldResemble, []byte("fake"))
|
|
snowflake.answerChannel <- "fake answer"
|
|
<-done
|
|
body, err := decodeAMPArmorToString(w.Body)
|
|
So(err, ShouldBeNil)
|
|
So(body, ShouldEqual, `{"answer":"fake answer"}`)
|
|
So(w.Code, ShouldEqual, http.StatusOK)
|
|
|
|
// Ensure that match is correctly recorded in metrics
|
|
ctx.metrics.printMetrics()
|
|
So(buf.String(), ShouldContainSubstring, `client-denied-count 0
|
|
client-restricted-denied-count 0
|
|
client-unrestricted-denied-count 0
|
|
client-snowflake-match-count 8
|
|
client-snowflake-timeout-count 0
|
|
client-http-count 0
|
|
client-http-ips
|
|
client-ampcache-count 8
|
|
client-ampcache-ips ??=8
|
|
client-sqs-count 0
|
|
client-sqs-ips
|
|
`)
|
|
})
|
|
|
|
Convey("Times out when no proxy responds.", func() {
|
|
if testing.Short() {
|
|
return
|
|
}
|
|
done := make(chan bool)
|
|
snowflake := ctx.AddSnowflake("fake", "", NATUnrestricted, 0)
|
|
go func() {
|
|
ampClientOffers(i, w, r)
|
|
// Takes a few seconds here...
|
|
done <- true
|
|
}()
|
|
offer := <-snowflake.offerChannel
|
|
So(offer.sdp, ShouldResemble, []byte("fake"))
|
|
<-done
|
|
So(w.Code, ShouldEqual, http.StatusOK)
|
|
body, err := decodeAMPArmorToString(w.Body)
|
|
So(err, ShouldBeNil)
|
|
So(body, ShouldEqual, `{"error":"timed out waiting for answer!"}`)
|
|
})
|
|
|
|
Convey("and correctly geolocates remote addr.", func() {
|
|
err := ctx.metrics.LoadGeoipDatabases("test_geoip", "test_geoip6")
|
|
So(err, ShouldBeNil)
|
|
clientRequest := &messages.ClientPollRequest{
|
|
Offer: rawOffer,
|
|
NAT: NATUnknown,
|
|
Fingerprint: "",
|
|
}
|
|
encOffer, err := clientRequest.EncodeClientPollRequest()
|
|
So(err, ShouldBeNil)
|
|
r, err = http.NewRequest("GET", "/amp/client/"+amp.EncodePath(encOffer), nil)
|
|
So(err, ShouldBeNil)
|
|
ampClientOffers(i, w, r)
|
|
So(w.Code, ShouldEqual, http.StatusOK)
|
|
body, err := decodeAMPArmorToString(w.Body)
|
|
So(err, ShouldBeNil)
|
|
So(body, ShouldEqual, `{"error":"no snowflake proxies currently available"}`)
|
|
|
|
ctx.metrics.printMetrics()
|
|
So(buf.String(), ShouldContainSubstring, `client-denied-count 8
|
|
client-restricted-denied-count 8
|
|
client-unrestricted-denied-count 0
|
|
client-snowflake-match-count 0
|
|
client-snowflake-timeout-count 0
|
|
client-http-count 0
|
|
client-http-ips
|
|
client-ampcache-count 8
|
|
client-ampcache-ips CA=8
|
|
client-sqs-count 0
|
|
client-sqs-ips
|
|
`)
|
|
})
|
|
|
|
})
|
|
|
|
Convey("Responds to proxy polls...", func() {
|
|
done := make(chan bool)
|
|
w := httptest.NewRecorder()
|
|
data := bytes.NewReader([]byte(`{"Sid":"ymbcCMto7KHNGYlp","Version":"1.0", "AcceptedRelayPattern": "snowflake.torproject.net"}`))
|
|
r, err := http.NewRequest("POST", "snowflake.broker/proxy", data)
|
|
So(err, ShouldBeNil)
|
|
|
|
Convey("with a client offer if available.", func() {
|
|
go func(i *IPC) {
|
|
proxyPolls(i, w, r)
|
|
done <- true
|
|
}(i)
|
|
// Pass a fake client offer to this proxy
|
|
p := <-ctx.proxyPolls
|
|
So(p.id, ShouldEqual, "ymbcCMto7KHNGYlp")
|
|
p.offerChannel <- &ClientOffer{sdp: []byte("fake offer"), fingerprint: defaultBridge[:]}
|
|
<-done
|
|
So(w.Code, ShouldEqual, http.StatusOK)
|
|
So(w.Body.String(), ShouldEqual, `{"Status":"client match","Offer":"fake offer","NAT":"","RelayURL":"wss://snowflake.torproject.net/"}`)
|
|
})
|
|
|
|
Convey("return empty 200 OK when no client offer is available.", func() {
|
|
go func(i *IPC) {
|
|
proxyPolls(i, w, r)
|
|
done <- true
|
|
}(i)
|
|
p := <-ctx.proxyPolls
|
|
So(p.id, ShouldEqual, "ymbcCMto7KHNGYlp")
|
|
// nil means timeout
|
|
p.offerChannel <- nil
|
|
<-done
|
|
So(w.Body.String(), ShouldEqual, `{"Status":"no match","Offer":"","NAT":"","RelayURL":""}`)
|
|
So(w.Code, ShouldEqual, http.StatusOK)
|
|
})
|
|
})
|
|
|
|
Convey("Responds to proxy answers...", func() {
|
|
done := make(chan bool)
|
|
s := ctx.AddSnowflake(sid, "", NATUnrestricted, 0)
|
|
w := httptest.NewRecorder()
|
|
|
|
data, err := createProxyAnswer(sdp, sid)
|
|
So(err, ShouldBeNil)
|
|
|
|
Convey("by passing to the client if valid.", func() {
|
|
r, err := http.NewRequest("POST", "snowflake.broker/answer", data)
|
|
So(err, ShouldBeNil)
|
|
go func(i *IPC) {
|
|
proxyAnswers(i, w, r)
|
|
done <- true
|
|
}(i)
|
|
answer := <-s.answerChannel
|
|
<-done
|
|
So(w.Code, ShouldEqual, http.StatusOK)
|
|
So(answer, ShouldResemble, sdp)
|
|
})
|
|
|
|
Convey("with client gone status if the proxy ID is not recognized", func() {
|
|
data, err := createProxyAnswer(sdp, "invalid")
|
|
r, err := http.NewRequest("POST", "snowflake.broker/answer", data)
|
|
So(err, ShouldBeNil)
|
|
proxyAnswers(i, w, r)
|
|
So(w.Code, ShouldEqual, http.StatusOK)
|
|
b, err := io.ReadAll(w.Body)
|
|
So(err, ShouldBeNil)
|
|
So(b, ShouldResemble, []byte(`{"Status":"client gone"}`))
|
|
})
|
|
|
|
Convey("with error if the proxy gives invalid answer", func() {
|
|
data := bytes.NewReader(nil)
|
|
r, err := http.NewRequest("POST", "snowflake.broker/answer", data)
|
|
So(err, ShouldBeNil)
|
|
proxyAnswers(i, w, r)
|
|
So(w.Code, ShouldEqual, http.StatusBadRequest)
|
|
})
|
|
|
|
Convey("with error if the proxy writes too much data", func() {
|
|
data := bytes.NewReader(make([]byte, 100001))
|
|
r, err := http.NewRequest("POST", "snowflake.broker/answer", data)
|
|
So(err, ShouldBeNil)
|
|
proxyAnswers(i, w, r)
|
|
So(w.Code, ShouldEqual, http.StatusBadRequest)
|
|
})
|
|
|
|
})
|
|
|
|
})
|
|
|
|
Convey("End-To-End", t, func() {
|
|
ctx := NewBrokerContext(NullLogger(), "snowflake.torproject.net")
|
|
i := &IPC{ctx}
|
|
|
|
Convey("Check for client/proxy data race", func() {
|
|
proxy_done := make(chan bool)
|
|
client_done := make(chan bool)
|
|
|
|
go ctx.Broker()
|
|
|
|
// Make proxy poll
|
|
wp := httptest.NewRecorder()
|
|
datap := bytes.NewReader([]byte(`{"Sid":"ymbcCMto7KHNGYlp","Version":"1.0","AcceptedRelayPattern":"snowflake.torproject.net"}`))
|
|
rp, err := http.NewRequest("POST", "snowflake.broker/proxy", datap)
|
|
So(err, ShouldBeNil)
|
|
|
|
go func(i *IPC) {
|
|
proxyPolls(i, wp, rp)
|
|
proxy_done <- true
|
|
}(i)
|
|
|
|
// Client offer
|
|
wc := httptest.NewRecorder()
|
|
datac, err := createClientOffer(sdp, NATUnknown, "")
|
|
So(err, ShouldBeNil)
|
|
rc, err := http.NewRequest("POST", "snowflake.broker/client", datac)
|
|
So(err, ShouldBeNil)
|
|
|
|
go func() {
|
|
clientOffers(i, wc, rc)
|
|
client_done <- true
|
|
}()
|
|
|
|
<-proxy_done
|
|
So(wp.Code, ShouldEqual, http.StatusOK)
|
|
|
|
// Proxy answers
|
|
wp = httptest.NewRecorder()
|
|
datap, err = createProxyAnswer(sdp, sid)
|
|
So(err, ShouldBeNil)
|
|
rp, err = http.NewRequest("POST", "snowflake.broker/answer", datap)
|
|
So(err, ShouldBeNil)
|
|
go func(i *IPC) {
|
|
proxyAnswers(i, wp, rp)
|
|
proxy_done <- true
|
|
}(i)
|
|
|
|
<-proxy_done
|
|
<-client_done
|
|
|
|
})
|
|
|
|
Convey("Ensure correct snowflake brokering", func() {
|
|
done := make(chan bool)
|
|
polled := make(chan bool)
|
|
|
|
// Proxy polls with its ID first...
|
|
dataP := bytes.NewReader([]byte(`{"Sid":"ymbcCMto7KHNGYlp","Version":"1.0","AcceptedRelayPattern":"snowflake.torproject.net"}`))
|
|
wP := httptest.NewRecorder()
|
|
rP, err := http.NewRequest("POST", "snowflake.broker/proxy", dataP)
|
|
So(err, ShouldBeNil)
|
|
go func() {
|
|
proxyPolls(i, wP, rP)
|
|
polled <- true
|
|
}()
|
|
|
|
// Manually do the Broker goroutine action here for full control.
|
|
p := <-ctx.proxyPolls
|
|
So(p.id, ShouldEqual, "ymbcCMto7KHNGYlp")
|
|
s := ctx.AddSnowflake(p.id, "", NATUnrestricted, 0)
|
|
go func() {
|
|
offer := <-s.offerChannel
|
|
p.offerChannel <- offer
|
|
}()
|
|
So(ctx.idToSnowflake["ymbcCMto7KHNGYlp"], ShouldNotBeNil)
|
|
|
|
// Client request blocks until proxy answer arrives.
|
|
wC := httptest.NewRecorder()
|
|
dataC, err := createClientOffer(sdp, NATUnknown, "")
|
|
So(err, ShouldBeNil)
|
|
rC, err := http.NewRequest("POST", "snowflake.broker/client", dataC)
|
|
So(err, ShouldBeNil)
|
|
go func() {
|
|
clientOffers(i, wC, rC)
|
|
done <- true
|
|
}()
|
|
|
|
<-polled
|
|
So(wP.Code, ShouldEqual, http.StatusOK)
|
|
So(wP.Body.String(), ShouldResemble, fmt.Sprintf(`{"Status":"client match","Offer":%#q,"NAT":"unknown","RelayURL":"wss://snowflake.torproject.net/"}`, sdp))
|
|
So(ctx.idToSnowflake[sid], ShouldNotBeNil)
|
|
|
|
// Follow up with the answer request afterwards
|
|
wA := httptest.NewRecorder()
|
|
dataA, err := createProxyAnswer(sdp, sid)
|
|
So(err, ShouldBeNil)
|
|
rA, err := http.NewRequest("POST", "snowflake.broker/answer", dataA)
|
|
So(err, ShouldBeNil)
|
|
proxyAnswers(i, wA, rA)
|
|
So(wA.Code, ShouldEqual, http.StatusOK)
|
|
|
|
<-done
|
|
So(wC.Code, ShouldEqual, http.StatusOK)
|
|
So(wC.Body.String(), ShouldEqual, fmt.Sprintf(`{"answer":%#q}`, sdp))
|
|
})
|
|
})
|
|
}
|
|
|
|
func TestSnowflakeHeap(t *testing.T) {
|
|
Convey("SnowflakeHeap", t, func() {
|
|
h := new(SnowflakeHeap)
|
|
heap.Init(h)
|
|
So(h.Len(), ShouldEqual, 0)
|
|
s1 := new(Snowflake)
|
|
s2 := new(Snowflake)
|
|
s3 := new(Snowflake)
|
|
s4 := new(Snowflake)
|
|
s1.clients = 4
|
|
s2.clients = 5
|
|
s3.clients = 3
|
|
s4.clients = 1
|
|
|
|
heap.Push(h, s1)
|
|
So(h.Len(), ShouldEqual, 1)
|
|
heap.Push(h, s2)
|
|
So(h.Len(), ShouldEqual, 2)
|
|
heap.Push(h, s3)
|
|
So(h.Len(), ShouldEqual, 3)
|
|
heap.Push(h, s4)
|
|
So(h.Len(), ShouldEqual, 4)
|
|
|
|
heap.Remove(h, 0)
|
|
So(h.Len(), ShouldEqual, 3)
|
|
|
|
r := heap.Pop(h).(*Snowflake)
|
|
So(h.Len(), ShouldEqual, 2)
|
|
So(r.clients, ShouldEqual, 3)
|
|
So(r.index, ShouldEqual, -1)
|
|
|
|
r = heap.Pop(h).(*Snowflake)
|
|
So(h.Len(), ShouldEqual, 1)
|
|
So(r.clients, ShouldEqual, 4)
|
|
So(r.index, ShouldEqual, -1)
|
|
|
|
r = heap.Pop(h).(*Snowflake)
|
|
So(h.Len(), ShouldEqual, 0)
|
|
So(r.clients, ShouldEqual, 5)
|
|
So(r.index, ShouldEqual, -1)
|
|
})
|
|
}
|
|
|
|
func TestInvalidGeoipFile(t *testing.T) {
|
|
Convey("Geoip", t, func() {
|
|
// Make sure things behave properly if geoip file fails to load
|
|
ctx := NewBrokerContext(NullLogger(), "")
|
|
if err := ctx.metrics.LoadGeoipDatabases("invalid_filename", "invalid_filename6"); err != nil {
|
|
log.Printf("loading geo ip databases returned error: %v", err)
|
|
}
|
|
ctx.metrics.UpdateProxyStats("127.0.0.1", "", NATUnrestricted)
|
|
So(ctx.metrics.geoipdb, ShouldBeNil)
|
|
|
|
})
|
|
}
|
|
|
|
func TestMetrics(t *testing.T) {
|
|
Convey("Test metrics...", t, func() {
|
|
done := make(chan bool)
|
|
buf := new(bytes.Buffer)
|
|
ctx := NewBrokerContext(log.New(buf, "", 0), "snowflake.torproject.net")
|
|
i := &IPC{ctx}
|
|
|
|
err := ctx.metrics.LoadGeoipDatabases("test_geoip", "test_geoip6")
|
|
So(err, ShouldBeNil)
|
|
|
|
//Test addition of proxy polls
|
|
Convey("for proxy polls", func() {
|
|
w := httptest.NewRecorder()
|
|
data := bytes.NewReader([]byte("{\"Sid\":\"ymbcCMto7KHNGYlp\",\"Version\":\"1.0\",\"AcceptedRelayPattern\":\"snowflake.torproject.net\"}"))
|
|
r, err := http.NewRequest("POST", "snowflake.broker/proxy", data)
|
|
r.RemoteAddr = "129.97.208.23" //CA geoip
|
|
So(err, ShouldBeNil)
|
|
go func(i *IPC) {
|
|
proxyPolls(i, w, r)
|
|
done <- true
|
|
}(i)
|
|
p := <-ctx.proxyPolls //manually unblock poll
|
|
p.offerChannel <- nil
|
|
<-done
|
|
|
|
w = httptest.NewRecorder()
|
|
data = bytes.NewReader([]byte(`{"Sid":"ymbcCMto7KHNGYlp","Version":"1.0","Type":"standalone","AcceptedRelayPattern":"snowflake.torproject.net"}`))
|
|
r, err = http.NewRequest("POST", "snowflake.broker/proxy", data)
|
|
r.RemoteAddr = "129.97.208.24" //CA geoip
|
|
So(err, ShouldBeNil)
|
|
go func(i *IPC) {
|
|
proxyPolls(i, w, r)
|
|
done <- true
|
|
}(i)
|
|
p = <-ctx.proxyPolls //manually unblock poll
|
|
p.offerChannel <- nil
|
|
<-done
|
|
|
|
w = httptest.NewRecorder()
|
|
data = bytes.NewReader([]byte(`{"Sid":"ymbcCMto7KHNGYlp","Version":"1.0","Type":"badge","AcceptedRelayPattern":"snowflake.torproject.net"}`))
|
|
r, err = http.NewRequest("POST", "snowflake.broker/proxy", data)
|
|
r.RemoteAddr = "129.97.208.25" //CA geoip
|
|
So(err, ShouldBeNil)
|
|
go func(i *IPC) {
|
|
proxyPolls(i, w, r)
|
|
done <- true
|
|
}(i)
|
|
p = <-ctx.proxyPolls //manually unblock poll
|
|
p.offerChannel <- nil
|
|
<-done
|
|
|
|
w = httptest.NewRecorder()
|
|
data = bytes.NewReader([]byte(`{"Sid":"ymbcCMto7KHNGYlp","Version":"1.0","Type":"webext","AcceptedRelayPattern":"snowflake.torproject.net"}`))
|
|
r, err = http.NewRequest("POST", "snowflake.broker/proxy", data)
|
|
r.RemoteAddr = "129.97.208.26" //CA geoip
|
|
So(err, ShouldBeNil)
|
|
go func(i *IPC) {
|
|
proxyPolls(i, w, r)
|
|
done <- true
|
|
}(i)
|
|
p = <-ctx.proxyPolls //manually unblock poll
|
|
p.offerChannel <- nil
|
|
<-done
|
|
ctx.metrics.printMetrics()
|
|
|
|
metricsStr := buf.String()
|
|
So(metricsStr, ShouldStartWith, "snowflake-stats-end "+time.Now().UTC().Format("2006-01-02 15:04:05")+" (86400 s)\nsnowflake-ips CA=4\n")
|
|
So(metricsStr, ShouldContainSubstring, "\nsnowflake-ips-standalone 1\n")
|
|
So(metricsStr, ShouldContainSubstring, "\nsnowflake-ips-badge 1\n")
|
|
So(metricsStr, ShouldContainSubstring, "\nsnowflake-ips-webext 1\n")
|
|
So(metricsStr, ShouldEndWith, `snowflake-ips-total 4
|
|
snowflake-idle-count 8
|
|
snowflake-proxy-poll-with-relay-url-count 8
|
|
snowflake-proxy-poll-without-relay-url-count 0
|
|
snowflake-proxy-rejected-for-relay-url-count 0
|
|
client-denied-count 0
|
|
client-restricted-denied-count 0
|
|
client-unrestricted-denied-count 0
|
|
client-snowflake-match-count 0
|
|
client-snowflake-timeout-count 0
|
|
client-http-count 0
|
|
client-http-ips
|
|
client-ampcache-count 0
|
|
client-ampcache-ips
|
|
client-sqs-count 0
|
|
client-sqs-ips
|
|
snowflake-ips-nat-restricted 0
|
|
snowflake-ips-nat-unrestricted 0
|
|
snowflake-ips-nat-unknown 4
|
|
`)
|
|
})
|
|
|
|
//Test addition of client failures
|
|
Convey("for no proxies available", func() {
|
|
w := httptest.NewRecorder()
|
|
data, err := createClientOffer(sdp, NATUnknown, "")
|
|
So(err, ShouldBeNil)
|
|
r, err := http.NewRequest("POST", "snowflake.broker/client", data)
|
|
r.RemoteAddr = "129.97.208.23:8888" //CA geoip
|
|
So(err, ShouldBeNil)
|
|
|
|
clientOffers(i, w, r)
|
|
|
|
ctx.metrics.printMetrics()
|
|
So(buf.String(), ShouldContainSubstring, `client-denied-count 8
|
|
client-restricted-denied-count 8
|
|
client-unrestricted-denied-count 0
|
|
client-snowflake-match-count 0
|
|
client-snowflake-timeout-count 0
|
|
client-http-count 8
|
|
client-http-ips CA=8
|
|
client-ampcache-count 0
|
|
client-ampcache-ips
|
|
client-sqs-count 0
|
|
client-sqs-ips `)
|
|
|
|
// Test reset
|
|
buf.Reset()
|
|
ctx.metrics.printMetrics()
|
|
So(buf.String(), ShouldContainSubstring, "\nsnowflake-ips \n")
|
|
So(buf.String(), ShouldContainSubstring, "\nsnowflake-ips-standalone 0\n")
|
|
So(buf.String(), ShouldContainSubstring, "\nsnowflake-ips-badge 0\n")
|
|
So(buf.String(), ShouldContainSubstring, "\nsnowflake-ips-webext 0\n")
|
|
So(buf.String(), ShouldContainSubstring, `snowflake-ips-total 0
|
|
snowflake-idle-count 0
|
|
snowflake-proxy-poll-with-relay-url-count 0
|
|
snowflake-proxy-poll-without-relay-url-count 0
|
|
snowflake-proxy-rejected-for-relay-url-count 0
|
|
client-denied-count 0
|
|
client-restricted-denied-count 0
|
|
client-unrestricted-denied-count 0
|
|
client-snowflake-match-count 0
|
|
client-snowflake-timeout-count 0
|
|
client-http-count 0
|
|
client-http-ips
|
|
client-ampcache-count 0
|
|
client-ampcache-ips
|
|
client-sqs-count 0
|
|
client-sqs-ips
|
|
snowflake-ips-nat-restricted 0
|
|
snowflake-ips-nat-unrestricted 0
|
|
snowflake-ips-nat-unknown 0
|
|
`)
|
|
})
|
|
//Test addition of client matches
|
|
Convey("for client-proxy match", func() {
|
|
w := httptest.NewRecorder()
|
|
data, err := createClientOffer(sdp, NATUnknown, "")
|
|
So(err, ShouldBeNil)
|
|
r, err := http.NewRequest("POST", "snowflake.broker/client", data)
|
|
So(err, ShouldBeNil)
|
|
|
|
// Prepare a fake proxy to respond with.
|
|
snowflake := ctx.AddSnowflake("fake", "", NATUnrestricted, 0)
|
|
go func() {
|
|
clientOffers(i, w, r)
|
|
done <- true
|
|
}()
|
|
offer := <-snowflake.offerChannel
|
|
So(offer.sdp, ShouldResemble, []byte(sdp))
|
|
snowflake.answerChannel <- "fake answer"
|
|
<-done
|
|
|
|
ctx.metrics.printMetrics()
|
|
So(buf.String(), ShouldContainSubstring, "client-denied-count 0\nclient-restricted-denied-count 0\nclient-unrestricted-denied-count 0\nclient-snowflake-match-count 8")
|
|
})
|
|
//Test rounding boundary
|
|
Convey("binning boundary", func() {
|
|
w := httptest.NewRecorder()
|
|
data, err := createClientOffer(sdp, NATRestricted, "")
|
|
So(err, ShouldBeNil)
|
|
r, err := http.NewRequest("POST", "snowflake.broker/client", data)
|
|
So(err, ShouldBeNil)
|
|
clientOffers(i, w, r)
|
|
|
|
w = httptest.NewRecorder()
|
|
data, err = createClientOffer(sdp, NATRestricted, "")
|
|
So(err, ShouldBeNil)
|
|
r, err = http.NewRequest("POST", "snowflake.broker/client", data)
|
|
So(err, ShouldBeNil)
|
|
clientOffers(i, w, r)
|
|
|
|
w = httptest.NewRecorder()
|
|
data, err = createClientOffer(sdp, NATRestricted, "")
|
|
So(err, ShouldBeNil)
|
|
r, err = http.NewRequest("POST", "snowflake.broker/client", data)
|
|
So(err, ShouldBeNil)
|
|
clientOffers(i, w, r)
|
|
|
|
w = httptest.NewRecorder()
|
|
data, err = createClientOffer(sdp, NATRestricted, "")
|
|
So(err, ShouldBeNil)
|
|
r, err = http.NewRequest("POST", "snowflake.broker/client", data)
|
|
So(err, ShouldBeNil)
|
|
clientOffers(i, w, r)
|
|
|
|
w = httptest.NewRecorder()
|
|
data, err = createClientOffer(sdp, NATRestricted, "")
|
|
So(err, ShouldBeNil)
|
|
r, err = http.NewRequest("POST", "snowflake.broker/client", data)
|
|
So(err, ShouldBeNil)
|
|
clientOffers(i, w, r)
|
|
|
|
w = httptest.NewRecorder()
|
|
data, err = createClientOffer(sdp, NATRestricted, "")
|
|
So(err, ShouldBeNil)
|
|
r, err = http.NewRequest("POST", "snowflake.broker/client", data)
|
|
So(err, ShouldBeNil)
|
|
clientOffers(i, w, r)
|
|
|
|
w = httptest.NewRecorder()
|
|
data, err = createClientOffer(sdp, NATRestricted, "")
|
|
So(err, ShouldBeNil)
|
|
r, err = http.NewRequest("POST", "snowflake.broker/client", data)
|
|
So(err, ShouldBeNil)
|
|
clientOffers(i, w, r)
|
|
|
|
w = httptest.NewRecorder()
|
|
data, err = createClientOffer(sdp, NATRestricted, "")
|
|
So(err, ShouldBeNil)
|
|
r, err = http.NewRequest("POST", "snowflake.broker/client", data)
|
|
So(err, ShouldBeNil)
|
|
clientOffers(i, w, r)
|
|
|
|
w = httptest.NewRecorder()
|
|
data, err = createClientOffer(sdp, NATRestricted, "")
|
|
So(err, ShouldBeNil)
|
|
r, err = http.NewRequest("POST", "snowflake.broker/client", data)
|
|
So(err, ShouldBeNil)
|
|
clientOffers(i, w, r)
|
|
|
|
buf.Reset()
|
|
ctx.metrics.printMetrics()
|
|
So(buf.String(), ShouldContainSubstring, "client-denied-count 16\nclient-restricted-denied-count 16\nclient-unrestricted-denied-count 0\n")
|
|
})
|
|
|
|
//Test unique ip
|
|
Convey("proxy counts by unique ip", func() {
|
|
w := httptest.NewRecorder()
|
|
data := bytes.NewReader([]byte(`{"Sid":"ymbcCMto7KHNGYlp","Version":"1.0","AcceptedRelayPattern":"snowflake.torproject.net"}`))
|
|
r, err := http.NewRequest("POST", "snowflake.broker/proxy", data)
|
|
r.RemoteAddr = "129.97.208.23:8888" //CA geoip
|
|
So(err, ShouldBeNil)
|
|
go func(i *IPC) {
|
|
proxyPolls(i, w, r)
|
|
done <- true
|
|
}(i)
|
|
p := <-ctx.proxyPolls //manually unblock poll
|
|
p.offerChannel <- nil
|
|
<-done
|
|
|
|
data = bytes.NewReader([]byte(`{"Sid":"ymbcCMto7KHNGYlp","Version":"1.0","AcceptedRelayPattern":"snowflake.torproject.net"}`))
|
|
r, err = http.NewRequest("POST", "snowflake.broker/proxy", data)
|
|
if err != nil {
|
|
log.Printf("unable to get NewRequest with error: %v", err)
|
|
}
|
|
r.RemoteAddr = "129.97.208.23:8888" //CA geoip
|
|
go func(i *IPC) {
|
|
proxyPolls(i, w, r)
|
|
done <- true
|
|
}(i)
|
|
p = <-ctx.proxyPolls //manually unblock poll
|
|
p.offerChannel <- nil
|
|
<-done
|
|
|
|
ctx.metrics.printMetrics()
|
|
metricsStr := buf.String()
|
|
So(metricsStr, ShouldContainSubstring, "snowflake-ips CA=1\n")
|
|
So(metricsStr, ShouldContainSubstring, "snowflake-ips-total 1\n")
|
|
})
|
|
//Test NAT types
|
|
Convey("proxy counts by NAT type", func() {
|
|
w := httptest.NewRecorder()
|
|
data := bytes.NewReader([]byte(`{"Sid":"ymbcCMto7KHNGYlp","Version":"1.2","Type":"unknown","NAT":"restricted","AcceptedRelayPattern":"snowflake.torproject.net"}`))
|
|
r, err := http.NewRequest("POST", "snowflake.broker/proxy", data)
|
|
r.RemoteAddr = "129.97.208.23:8888" //CA geoip
|
|
So(err, ShouldBeNil)
|
|
go func(i *IPC) {
|
|
proxyPolls(i, w, r)
|
|
done <- true
|
|
}(i)
|
|
p := <-ctx.proxyPolls //manually unblock poll
|
|
p.offerChannel <- nil
|
|
<-done
|
|
|
|
data = bytes.NewReader([]byte(`{"Sid":"ymbcCMto7KHNGYlp","Version":"1.2","Type":"unknown","NAT":"unrestricted","AcceptedRelayPattern":"snowflake.torproject.net"}`))
|
|
r, err = http.NewRequest("POST", "snowflake.broker/proxy", data)
|
|
if err != nil {
|
|
log.Printf("unable to get NewRequest with error: %v", err)
|
|
}
|
|
r.RemoteAddr = "129.97.208.24:8888" //CA geoip
|
|
go func(i *IPC) {
|
|
proxyPolls(i, w, r)
|
|
done <- true
|
|
}(i)
|
|
p = <-ctx.proxyPolls //manually unblock poll
|
|
p.offerChannel <- nil
|
|
<-done
|
|
|
|
ctx.metrics.printMetrics()
|
|
So(buf.String(), ShouldContainSubstring, "snowflake-ips-nat-restricted 1\nsnowflake-ips-nat-unrestricted 1\nsnowflake-ips-nat-unknown 0")
|
|
})
|
|
|
|
Convey("client failures by NAT type", func() {
|
|
w := httptest.NewRecorder()
|
|
data, err := createClientOffer(sdp, NATRestricted, "")
|
|
So(err, ShouldBeNil)
|
|
r, err := http.NewRequest("POST", "snowflake.broker/client", data)
|
|
So(err, ShouldBeNil)
|
|
|
|
clientOffers(i, w, r)
|
|
|
|
ctx.metrics.printMetrics()
|
|
So(buf.String(), ShouldContainSubstring, "client-denied-count 8\nclient-restricted-denied-count 8\nclient-unrestricted-denied-count 0\nclient-snowflake-match-count 0")
|
|
|
|
buf.Reset()
|
|
|
|
data, err = createClientOffer(sdp, NATUnrestricted, "")
|
|
So(err, ShouldBeNil)
|
|
r, err = http.NewRequest("POST", "snowflake.broker/client", data)
|
|
So(err, ShouldBeNil)
|
|
|
|
clientOffers(i, w, r)
|
|
|
|
ctx.metrics.printMetrics()
|
|
So(buf.String(), ShouldContainSubstring, "client-denied-count 8\nclient-restricted-denied-count 0\nclient-unrestricted-denied-count 8\nclient-snowflake-match-count 0")
|
|
|
|
buf.Reset()
|
|
|
|
data, err = createClientOffer(sdp, NATUnknown, "")
|
|
So(err, ShouldBeNil)
|
|
r, err = http.NewRequest("POST", "snowflake.broker/client", data)
|
|
So(err, ShouldBeNil)
|
|
|
|
clientOffers(i, w, r)
|
|
|
|
ctx.metrics.printMetrics()
|
|
So(buf.String(), ShouldContainSubstring, "client-denied-count 8\nclient-restricted-denied-count 8\nclient-unrestricted-denied-count 0\nclient-snowflake-match-count 0")
|
|
})
|
|
Convey("for country stats order", func() {
|
|
stats := new(sync.Map)
|
|
for cc, count := range map[string]uint64{
|
|
"IT": 50,
|
|
"FR": 200,
|
|
"TZ": 100,
|
|
"CN": 250,
|
|
"RU": 150,
|
|
"CA": 1,
|
|
"BE": 1,
|
|
"PH": 1,
|
|
} {
|
|
stats.LoadOrStore(cc, new(uint64))
|
|
val, _ := stats.Load(cc)
|
|
ptr := val.(*uint64)
|
|
atomic.AddUint64(ptr, count)
|
|
}
|
|
So(displayCountryStats(stats, false), ShouldEqual, "CN=250,FR=200,RU=150,TZ=100,IT=50,BE=1,CA=1,PH=1")
|
|
})
|
|
})
|
|
}
|