diff --git a/.gitignore b/.gitignore index b5474f1..1bae622 100644 --- a/.gitignore +++ b/.gitignore @@ -6,14 +6,19 @@ datadir/ broker/broker client/client +server-webrtc/server-webrtc server/server -proxy/proxy -probetest/probetest +proxy-go/proxy-go snowflake.log +proxy/test +proxy/build +proxy/node_modules +proxy/spec/support +proxy/webext/snowflake.js +proxy/webext/popup.js +proxy/webext/embed.html +proxy/webext/embed.css +proxy/webext/assets/ +proxy/webext/_locales/ ignore/ - -# from running the vagrant setup -/.vagrant/ -/sdk-tools-linux-*.zip* -/android-ndk-* -/tools/ \ No newline at end of file +npm-debug.log diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index e03c8c4..b7fd956 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -1,205 +1,29 @@ +image: golang:1.10-stretch -variables: - DEBIAN_FRONTEND: noninteractive - REPRODUCIBLE_FLAGS: -trimpath -ldflags=-buildid= +cache: + paths: + - .gradle/wrapper + - .gradle/caches -# set up apt for automated use -.apt-template: &apt-template -- export LC_ALL=C.UTF-8 -- export DEBIAN_FRONTEND=noninteractive -- echo Etc/UTC > /etc/timezone -- echo 'quiet "1";' - 'APT::Install-Recommends "0";' - 'APT::Install-Suggests "0";' - 'APT::Acquire::Retries "20";' - 'APT::Get::Assume-Yes "true";' - 'Dpkg::Use-Pty "0";' - > /etc/apt/apt.conf.d/99gitlab -- apt-get update -- apt-get dist-upgrade +before_script: + # Create symbolic links under $GOPATH, this is needed for local build + - export src=$GOPATH/src + - mkdir -p $src/git.torproject.org/pluggable-transports + - mkdir -p $src/gitlab.com/$CI_PROJECT_NAMESPACE + - ln -s $CI_PROJECT_DIR $src/git.torproject.org/pluggable-transports/snowflake.git + - ln -s $CI_PROJECT_DIR $src/gitlab.com/$CI_PROJECT_PATH +build: + script: + - apt-get -qy update + - apt-get -qy install libx11-dev + - cd $src/gitlab.com/$CI_PROJECT_PATH/client + - go get ./... + - go build ./... + - go vet ./... + - go test -v -race ./... -# Set things up to use the OS-native packages for Go. Anything that -# is downloaded by go during the `go fmt` stage is not coming from the -# Debian/Ubuntu repo. So those would need to be packaged for this to -# make it into Debian and/or Ubuntu. -.debian-native-template: &debian-native-template - variables: - GOPATH: /usr/share/gocode - before_script: - - apt-get update - - apt-get -qy install --no-install-recommends - build-essential - ca-certificates - git - golang - golang-github-cheekybits-genny-dev - golang-github-jtolds-gls-dev - golang-github-klauspost-reedsolomon-dev - golang-github-lucas-clemente-quic-go-dev - golang-github-smartystreets-assertions-dev - golang-github-smartystreets-goconvey-dev - golang-github-tjfoc-gmsm-dev - golang-github-xtaci-kcp-dev - golang-github-xtaci-smux-dev - golang-golang-x-crypto-dev - golang-golang-x-net-dev - golang-goptlib-dev - golang-golang-x-sys-dev - golang-golang-x-text-dev - golang-golang-x-xerrors-dev - lbzip2 - -# use Go installed as part of the official, Debian-based Docker images -.golang-docker-debian-template: &golang-docker-debian-template - before_script: - - apt-get update - - apt-get -qy install --no-install-recommends - ca-certificates - git - lbzip2 - -.go-test: &go-test - - test -z "$(go fmt ./...)" - - go vet ./... - - go test -v -race ./... - - - cd $CI_PROJECT_DIR/client/ - - go get - - go build $REPRODUCIBLE_FLAGS - -.test-template: &test-template - artifacts: - name: "${CI_PROJECT_PATH}_${CI_JOB_STAGE}_${CI_JOB_ID}_${CI_COMMIT_REF_NAME}_${CI_COMMIT_SHA}" - paths: - - client/*.aar - - client/*.jar - - client/client - expire_in: 1 week - when: on_success - after_script: - - echo "Download debug artifacts from https://gitlab.com/${CI_PROJECT_PATH}/-/jobs" +after_script: # this file changes every time but should not be cached - rm -f $GRADLE_USER_HOME/caches/modules-2/modules-2.lock - - rm -rf $GRADLE_USER_HOME/caches/*/plugin-resolution/ - -# -- jobs ------------------------------------------------------------ - -android: - image: debian:bullseye-backports - variables: - ANDROID_HOME: /usr/lib/android-sdk - GOPATH: "/go" - LANG: C.UTF-8 - PATH: "/go/bin:/usr/lib/go-1.16/bin:/usr/bin:/bin" - cache: - paths: - - .gradle/wrapper - - .gradle/caches - <<: *test-template - before_script: - - *apt-template - - apt-get install - android-sdk-platform-23 - android-sdk-platform-tools - build-essential - curl - default-jdk-headless - git - gnupg - unzip - wget - - apt-get install -t bullseye-backports golang-1.16 - - - ndk=android-ndk-r21e-linux-x86_64.zip - - wget --continue --no-verbose https://dl.google.com/android/repository/$ndk - - echo "ad7ce5467e18d40050dc51b8e7affc3e635c85bd8c59be62de32352328ed467e $ndk" > $ndk.sha256 - - sha256sum -c $ndk.sha256 - - unzip -q $ndk - - rm ${ndk}* - - mv android-ndk-* $ANDROID_HOME/ndk-bundle/ - - - chmod -R a+rX $ANDROID_HOME - - script: - - *go-test - - export GRADLE_USER_HOME=$CI_PROJECT_DIR/.gradle - - go version - - go env - - - go get golang.org/x/mobile/cmd/gomobile - - go get golang.org/x/mobile/cmd/gobind - - go install golang.org/x/mobile/cmd/gobind - - go install golang.org/x/mobile/cmd/gomobile - - gomobile init - - - cd $CI_PROJECT_DIR/client - # gomobile builds a shared library not a CLI executable - - sed -i 's,^package main$,package snowflakeclient,' *.go - - go get golang.org/x/mobile/bind - - gomobile bind -v -target=android $REPRODUCIBLE_FLAGS . - -go-1.16: - image: golang:1.16-stretch - <<: *golang-docker-debian-template - <<: *test-template - script: - - *go-test - -go-1.17: - image: golang:1.17-stretch - <<: *golang-docker-debian-template - <<: *test-template - script: - - *go-test - -go-1.18: - image: golang:1.18-stretch - <<: *golang-docker-debian-template - <<: *test-template - script: - - *go-test - -debian-testing: - image: debian:testing - <<: *debian-native-template - <<: *test-template - script: - - *go-test - -generate_tarball: - stage: deploy - image: golang:1.17-stretch - rules: - - if: $CI_COMMIT_TAG - script: - - go mod vendor - - tar czf ${CI_PROJECT_NAME}-${CI_COMMIT_TAG#v}.tar.gz --transform "s,^,${CI_PROJECT_NAME}-${CI_COMMIT_TAG#v}/," * - after_script: - - echo TAR_JOB_ID=$CI_JOB_ID >> generate_tarball.env - artifacts: - paths: - - ${CI_PROJECT_NAME}-${CI_COMMIT_TAG#v}.tar.gz - reports: - dotenv: generate_tarball.env - -release-job: - stage: deploy - image: registry.gitlab.com/gitlab-org/release-cli:latest - rules: - - if: $CI_COMMIT_TAG - needs: - - job: generate_tarball - artifacts: true - script: - - echo "running release_job" - release: - name: 'Release $CI_COMMIT_TAG' - description: 'Created using the release-cli' - tag_name: '$CI_COMMIT_TAG' - ref: '$CI_COMMIT_TAG' - assets: - links: - - name: '${CI_PROJECT_NAME}-${CI_COMMIT_TAG#v}.tar.gz' - url: '${CI_PROJECT_URL}/-/jobs/${TAR_JOB_ID}/artifacts/file/${CI_PROJECT_NAME}-${CI_COMMIT_TAG#v}.tar.gz' - + - rm -fr $GRADLE_USER_HOME/caches/*/plugin-resolution/ diff --git a/.gitmodules b/.gitmodules index e69de29..5f79304 100644 --- a/.gitmodules +++ b/.gitmodules @@ -0,0 +1,4 @@ +[submodule "proxy/translation"] + path = proxy/translation + url = https://git.torproject.org/translation.git + branch = snowflakeaddon-messages.json_completed diff --git a/.travis.yml b/.travis.yml index a2c8880..9ed48bb 100644 --- a/.travis.yml +++ b/.travis.yml @@ -2,12 +2,43 @@ language: go dist: xenial -go_import_path: git.torproject.org/pluggable-transports/snowflake.git/v2 +go_import_path: git.torproject.org/pluggable-transports/snowflake.git + +addons: + apt: + sources: + - ubuntu-toolchain-r-test + packages: + - g++-5 + - gcc-5 go: - - 1.13.x + - 1.10.x + +env: + - TRAVIS_NODE_VERSION="8" CC="gcc-5" CXX="g++-5" + +before_install: + - nvm install $TRAVIS_NODE_VERSION + +install: + - go get -u github.com/smartystreets/goconvey + - go get -u github.com/keroserene/go-webrtc + - go get -u github.com/pion/webrtc + - go get -u github.com/dchest/uniuri + - go get -u git.torproject.org/pluggable-transports/goptlib.git + - go get -u git.torproject.org/pluggable-transports/websocket.git/websocket + - go get -u google.golang.org/appengine + - go get -u golang.org/x/crypto/acme/autocert + - go get -u golang.org/x/net/http2 + - pushd proxy + - npm install + - popd script: - test -z "$(go fmt ./...)" - go vet ./... - go test -v -race ./... + - cd proxy + - npm run lint + - npm test diff --git a/ChangeLog b/ChangeLog deleted file mode 100644 index 59c5c89..0000000 --- a/ChangeLog +++ /dev/null @@ -1,65 +0,0 @@ -Changes in version v2.3.0 - 2022-06-23 -- Issue 40146: Avoid performing two NAT probe tests at startup -- Issue 40134: Log messages from client NAT check failures are confusing -- Issue 34075: Implement metrics to measure snowflake churn -- Issue 28651: Prepare all pieces of the snowflake pipeline for a second snowflake bridge -- Issue 40129: Distributed Snowflake Server Support - -Changes in version v2.2.0 - 2022-05-25 - -- Issue 40099: Initialize SnowflakeListener.closed -- Add connection failure events for proxy timeouts -- Issue 40103: Fix proxy logging verb tense -- Fix up and downstream metrics output for proxy -- Issue 40095: uTLS for broker negotiation -- Forward bridge fingerprint from client to broker (WIP, Issue 28651) -- Issue 40104: Make it easier to configure proxy type -- Remove version from ClientPollRequest -- Issue 40124: Move tor-specific code out of library -- Issue 40115: Scrub pt event logs -- Issue 40127: Bump webrtc and dtls library versions -- Bump version of webrtc and dtls to fix dtls CVEs -- Issue 40141: Ensure library calls of events can be scrubbed - -Changes in version v2.1.0 - 2022-02-08 - -- Issue 40098: Remove support for legacy one shot mode -- Issue 40079: Make connection summary at proxy privacy preserving -- Issue 40076: Add snowflake event API for notifications of connection events -- Issue 40084: Increase capacity of client address map at the server -- Issue 40060: Further clean up snowflake server logs -- Issue 40089: Validate proxy and client supplied strings at broker -- Issue 40014: Update version of DTLS library to include fingerprinting fixes -- Issue 40075: Support recurring NAT type check in standalone proxy - - -Changes in version v2.0.0 - 2021-11-04 - -- Turn the standalone snowflake proxy code into a library -- Clean up and reworked the snowflake client and server library code -- Unify broker/bridge domains to *.torproject.net -- Updates to the snowflake library documentation -- New package functions to define and set a rendezvous method with the -broker -- Factor out the broker geoip code into its own external library -- Bug fix to check error calls in preparePeerConnection -- Bug fixes in snowflake tests -- Issue 40059: add the ability to pass in snowflake arguments through SOCKS -- Increase buffer sizes for sending and receiving snowflake data -- Issue 25985: rendezvous with the broker using AMP cache -- Issue 40055: wait for the full poll interval between proxy polls - -Changes in version v1.1.0 - 2021-07-13 - -- Refactors of the Snowflake broker code -- Refactors of the Snowflake proxy code -- Issue 40048: assign proxies based on self-reported client load -- Issue 40052: fixed a memory leak in the server accept loop -- Version bump of kcp and smux libraries -- Bug fix to pass the correct client address to the Snowflake bridge metrics -counter -- Bug fixes to prevent race conditions in the Snowflake client - -Changes in version v1.0.0 - 2021-06-07 - -- Initial release. diff --git a/LICENSE b/LICENSE index 42f6296..f700c98 100644 --- a/LICENSE +++ b/LICENSE @@ -3,7 +3,7 @@ ================================================================================ Copyright (c) 2016, Serene Han, Arlo Breault -Copyright (c) 2019-2020, The Tor Project, Inc +All rights reserved. Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: diff --git a/README.md b/README.md index 9a1c958..fdb039c 100644 --- a/README.md +++ b/README.md @@ -8,47 +8,95 @@ Pluggable Transport using WebRTC, inspired by Flashproxy. **Table of Contents** -- [Structure of this Repository](#structure-of-this-repository) - [Usage](#usage) - - [Using Snowflake with Tor](#using-snowflake-with-tor) - - [Running a Snowflake Proxy](#running-a-snowflake-proxy) - - [Using the Snowflake Library with Other Applications](#using-the-snowflake-library-with-other-applications) -- [Test Environment](#test-environment) + - [Dependencies](#dependencies) + - [More Info](#more-info) + - [Building](#building) + - [Test Environment](#test-environment) - [FAQ](#faq) -- [More info and links](#more-info-and-links) +- [Appendix](#appendix) + - [-- Testing directly via WebRTC Server --](#---testing-directly-via-webrtc-server---) -### Structure of this Repository - -- `broker/` contains code for the Snowflake broker -- `doc/` contains Snowflake documentation and manpages -- `client/` contains the Tor pluggable transport client and client library code -- `common/` contains generic libraries used by multiple pieces of Snowflake -- `proxy/` contains code for the Go standalone Snowflake proxy -- `probetest/` contains code for a NAT probetesting service -- `server/` contains the Tor pluggable transport server and server library code - ### Usage -Snowflake is currently deployed as a pluggable transport for Tor. +``` +cd client/ +go get +go build +tor -f torrc +``` +This should start the client plugin, bootstrapping to 100% using WebRTC. -#### Using Snowflake with Tor +#### Dependencies -To use the Snowflake client with Tor, you will need to add the appropriate `Bridge` and `ClientTransportPlugin` lines to your [torrc](https://2019.www.torproject.org/docs/tor-manual.html.en) file. See the [client README](client) for more information on building and running the Snowflake client. +Client: +- [pion/webrtc](https://github.com/pion/webrtc) +- Go 1.10+ -#### Running a Snowflake Proxy +Proxy: +- JavaScript -You can contribute to Snowflake by running a Snowflake proxy. We have the option to run a proxy in your browser or as a standalone Go program. See our [community documentation](https://community.torproject.org/relay/setup/snowflake/) for more details. +--- -#### Using the Snowflake Library with Other Applications +#### More Info -Snowflake can be used as a Go API, and adheres to the [v2.1 pluggable transports specification](). For more information on using the Snowflake Go library, see the [Snowflake library documentation](doc/using-the-snowflake-library.md). +Tor can plug in the Snowflake client via a correctly configured `torrc`. +For example: -### Test Environment +``` +ClientTransportPlugin snowflake exec ./client \ +-url https://snowflake-broker.azureedge.net/ \ +-front ajax.aspnetcdn.com \ +-ice stun:stun.l.google.com:19302 +-max 3 +``` + +The flags `-url` and `-front` allow the Snowflake client to speak to the Broker, +in order to get connected with some volunteer's browser proxy. `-ice` is a +comma-separated list of ICE servers, which are required for NAT traversal. + +For logging, run `tail -F snowflake.log` in a second terminal. + +You can modify the `torrc` to use your own broker: + +``` +ClientTransportPlugin snowflake exec ./client --meek +``` + + +#### Building + +This describes how to build the in-browser snowflake. For the client, see Usage, +above. + +The client will only work if there are browser snowflakes available. +To run your own: + +``` +cd proxy/ +npm run build +``` + +Then, start a local http server in the `proxy/build/` in any way you like. +For instance: + +``` +cd build/ +python -m http.server +``` + +Then, open a browser tab to `http://127.0.0.1:8000/embed.html` to view +the debug-console of the snowflake., +So long as that tab is open, you are an ephemeral Tor bridge. + + +#### Test Environment There is a Docker-based test environment at https://github.com/cohosh/snowbox. + ### FAQ **Q: How does it work?** @@ -84,16 +132,22 @@ manual port forwarding! It utilizes the "ICE" negotiation via WebRTC, and also involves a great abundance of ephemeral and short-lived (and special!) volunteer proxies... -### More info and links +### Appendix -We have more documentation in the [Snowflake wiki](https://gitlab.torproject.org/tpo/anti-censorship/pluggable-transports/snowflake/-/wikis/home) and at https://snowflake.torproject.org/. +##### -- Testing with Standalone Proxy -- +``` +cd proxy-go +go build +./proxy-go +``` -##### -- Android AAR Reproducible Build Setup -- +##### -- Testing directly via WebRTC Server -- -Using `gomobile` it is possible to build snowflake as shared libraries for all -the architectures supported by Android. This is in the _.gitlab-ci.yml_, which -runs in GitLab CI. It is also possible to run this setup in a Virtual Machine -using [vagrant](https://www.vagrantup.com/). Just run `vagrant up` and it will -create and provision the VM. `vagrant ssh` to get into the VM to use it as a -development environment. +See server-webrtc/README.md for information on connecting directly to a +WebRTC server transport plugin, bypassing the Broker and browser proxy. + +More documentation on the way. + +Also available at: +[torproject.org/pluggable-transports/snowflake](https://gitweb.torproject.org/pluggable-transports/snowflake.git/) diff --git a/Vagrantfile b/Vagrantfile deleted file mode 100644 index 36a31fe..0000000 --- a/Vagrantfile +++ /dev/null @@ -1,67 +0,0 @@ -require 'pathname' -require 'tempfile' -require 'yaml' - -srvpath = Pathname.new(File.dirname(__FILE__)).realpath -configfile = YAML.load_file(File.join(srvpath, "/.gitlab-ci.yml")) -remote_url = 'https://git.torproject.org/pluggable-transports/snowflake.git' - -# set up essential environment variables -env = configfile['variables'] -env = env.merge(configfile['android']['variables']) -env['CI_PROJECT_DIR'] = '/builds/tpo/anti-censorship/pluggable-transports/snowflake' -env_file = Tempfile.new('env') -File.chmod(0644, env_file.path) -env.each do |k,v| - env_file.write("export #{k}='#{v}'\n") -end -env_file.rewind - -sourcepath = '/etc/profile.d/env.sh' -header = "#!/bin/bash -ex\nsource #{sourcepath}\ncd $CI_PROJECT_DIR\n" - -before_script_file = Tempfile.new('before_script') -File.chmod(0755, before_script_file.path) -before_script_file.write(header) -configfile['android']['before_script'].flatten.each do |line| - before_script_file.write(line) - before_script_file.write("\n") -end -before_script_file.rewind - -script_file = Tempfile.new('script') -File.chmod(0755, script_file.path) -script_file.write(header) -configfile['android']['script'].flatten.each do |line| - script_file.write(line) - script_file.write("\n") -end -script_file.rewind - -Vagrant.configure("2") do |config| - config.vm.box = "debian/bullseye64" - config.vm.synced_folder '.', '/vagrant', disabled: true - config.vm.provision "file", source: env_file.path, destination: 'env.sh' - config.vm.provision :shell, inline: <<-SHELL - set -ex - mv ~vagrant/env.sh #{sourcepath} - source #{sourcepath} - test -d /go || mkdir /go - mkdir -p $(dirname $CI_PROJECT_DIR) - chown -R vagrant.vagrant $(dirname $CI_PROJECT_DIR) - apt-get update - apt-get -qy install --no-install-recommends git - git clone #{remote_url} $CI_PROJECT_DIR - chmod -R a+rX,u+w /go $CI_PROJECT_DIR - chown -R vagrant.vagrant /go $CI_PROJECT_DIR -SHELL - config.vm.provision "file", source: before_script_file.path, destination: 'before_script.sh' - config.vm.provision "file", source: script_file.path, destination: 'script.sh' - config.vm.provision :shell, inline: '/home/vagrant/before_script.sh' - config.vm.provision :shell, privileged: false, inline: '/home/vagrant/script.sh' - - # remove this or comment it out to use VirtualBox instead of libvirt - config.vm.provider :libvirt do |libvirt| - libvirt.memory = 1536 - end -end diff --git a/appengine/README b/appengine/README new file mode 100644 index 0000000..797b0f9 --- /dev/null +++ b/appengine/README @@ -0,0 +1,28 @@ +This component runs on Google App Engine. It reflects domain-fronted +requests from a client to the Snowflake broker. + +You need the Go App Engine SDK in order to deploy the app. + https://cloud.google.com/sdk/docs/#linux +After unpacking, install the app-engine-go component: + google-cloud-sdk/bin/gcloud components install app-engine-go + +To test locally, run + google-cloud-sdk/bin/dev_appserver.py app.yaml +The app will be running at http://127.0.0.1:8080/. + +To deploy to App Engine, first create a new project and app. You have to +think of a unique name (marked as "" in the commands). You only +have to do the "create" step once; subsequent times you can go straight +to the "deploy" step. The "gcloud auth login" command will open a +browser window so you can log in to a Google account. + google-cloud-sdk/bin/gcloud auth login + google-cloud-sdk/bin/gcloud projects create + google-cloud-sdk/bin/gcloud app create --project= +Then to deploy the project, run: + google-cloud-sdk/bin/gcloud app deploy --project= + +To configure the Snowflake client to talk to the App Engine app, provide +"https://.appspot.com/" as the --url option. + UseBridges 1 + Bridge snowflake 0.0.2.0:1 + ClientTransportPlugin snowflake exec ./client -url https://.appspot.com/ -front www.google.com diff --git a/appengine/app.yaml b/appengine/app.yaml new file mode 100644 index 0000000..44df436 --- /dev/null +++ b/appengine/app.yaml @@ -0,0 +1,7 @@ +runtime: go +api_version: go1 + +handlers: +- url: /.* + script: _go_app + secure: always diff --git a/appengine/reflect.go b/appengine/reflect.go new file mode 100644 index 0000000..58d8a67 --- /dev/null +++ b/appengine/reflect.go @@ -0,0 +1,111 @@ +// A web app for Google App Engine that proxies HTTP requests and responses to +// the Snowflake broker. +package reflect + +import ( + "context" + "io" + "net/http" + "net/url" + "time" + + "google.golang.org/appengine" + "google.golang.org/appengine/log" + "google.golang.org/appengine/urlfetch" +) + +const ( + forwardURL = "https://snowflake-broker.bamsoftware.com/" + // A timeout of 0 means to use the App Engine default (5 seconds). + urlFetchTimeout = 20 * time.Second +) + +var ctx context.Context + +// Join two URL paths. +func pathJoin(a, b string) string { + if len(a) > 0 && a[len(a)-1] == '/' { + a = a[:len(a)-1] + } + if len(b) == 0 || b[0] != '/' { + b = "/" + b + } + return a + b +} + +// We reflect only a whitelisted set of header fields. Otherwise, we may copy +// headers like Transfer-Encoding that interfere with App Engine's own +// hop-by-hop headers. +var reflectedHeaderFields = []string{ + "Content-Type", + "X-Session-Id", +} + +// Make a copy of r, with the URL being changed to be relative to forwardURL, +// and including only the headers in reflectedHeaderFields. +func copyRequest(r *http.Request) (*http.Request, error) { + u, err := url.Parse(forwardURL) + if err != nil { + return nil, err + } + // Append the requested path to the path in forwardURL, so that + // forwardURL can be something like "https://example.com/reflect". + u.Path = pathJoin(u.Path, r.URL.Path) + c, err := http.NewRequest(r.Method, u.String(), r.Body) + if err != nil { + return nil, err + } + for _, key := range reflectedHeaderFields { + values, ok := r.Header[key] + if ok { + for _, value := range values { + c.Header.Add(key, value) + } + } + } + return c, nil +} + +func handler(w http.ResponseWriter, r *http.Request) { + ctx = appengine.NewContext(r) + fr, err := copyRequest(r) + if err != nil { + log.Errorf(ctx, "copyRequest: %s", err) + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + if urlFetchTimeout != 0 { + var cancel context.CancelFunc + ctx, cancel = context.WithTimeout(ctx, urlFetchTimeout) + defer cancel() + } + // Use urlfetch.Transport directly instead of urlfetch.Client because we + // want only a single HTTP transaction, not following redirects. + transport := urlfetch.Transport{ + Context: ctx, + } + resp, err := transport.RoundTrip(fr) + if err != nil { + log.Errorf(ctx, "RoundTrip: %s", err) + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + defer resp.Body.Close() + for _, key := range reflectedHeaderFields { + values, ok := resp.Header[key] + if ok { + for _, value := range values { + w.Header().Add(key, value) + } + } + } + w.WriteHeader(resp.StatusCode) + n, err := io.Copy(w, resp.Body) + if err != nil { + log.Errorf(ctx, "io.Copy after %d bytes: %s", n, err) + } +} + +func init() { + http.HandleFunc("/", handler) +} diff --git a/broker/README.md b/broker/README.md index 1e0a763..fb6181e 100644 --- a/broker/README.md +++ b/broker/README.md @@ -1,12 +1,3 @@ - - -**Table of Contents** - -- [Overview](#overview) -- [Running your own](#running-your-own) - - - This is the Broker component of Snowflake. ### Overview diff --git a/broker/amp.go b/broker/amp.go deleted file mode 100644 index 36987a7..0000000 --- a/broker/amp.go +++ /dev/null @@ -1,76 +0,0 @@ -package main - -import ( - "log" - "net/http" - "strings" - - "git.torproject.org/pluggable-transports/snowflake.git/v2/common/amp" - "git.torproject.org/pluggable-transports/snowflake.git/v2/common/messages" -) - -// ampClientOffers is the AMP-speaking endpoint for client poll messages, -// intended for access via an AMP cache. In contrast to the other clientOffers, -// the client's encoded poll message is stored in the URL path rather than the -// HTTP request body (because an AMP cache does not support POST), and the -// encoded client poll response is sent back as AMP-armored HTML. -func ampClientOffers(i *IPC, w http.ResponseWriter, r *http.Request) { - // The encoded client poll message immediately follows the /amp/client/ - // path prefix, so this function unfortunately needs to be aware of and - // remote its own routing prefix. - path := strings.TrimPrefix(r.URL.Path, "/amp/client/") - if path == r.URL.Path { - // The path didn't start with the expected prefix. This probably - // indicates an internal bug. - log.Println("ampClientOffers: unexpected prefix in path") - w.WriteHeader(http.StatusInternalServerError) - return - } - - var encPollReq []byte - var response []byte - var err error - - encPollReq, err = amp.DecodePath(path) - if err == nil { - arg := messages.Arg{ - Body: encPollReq, - RemoteAddr: "", - } - err = i.ClientOffers(arg, &response) - } else { - response, err = (&messages.ClientPollResponse{ - Error: "cannot decode URL path", - }).EncodePollResponse() - } - - if err != nil { - // We couldn't even construct a JSON object containing an error - // message :( Nothing to do but signal an error at the HTTP - // layer. The AMP cache will translate this 500 status into a - // 404 status. - // https://amp.dev/documentation/guides-and-tutorials/learn/amp-caches-and-cors/amp-cache-urls/#redirect-%26-error-handling - log.Printf("ampClientOffers: %v", err) - w.WriteHeader(http.StatusInternalServerError) - return - } - - w.Header().Set("Content-Type", "text/html") - // Attempt to hint to an AMP cache not to waste resources caching this - // document. "The Google AMP Cache considers any document fresh for at - // least 15 seconds." - // https://developers.google.com/amp/cache/overview#google-amp-cache-updates - w.Header().Set("Cache-Control", "max-age=15") - w.WriteHeader(http.StatusOK) - - enc, err := amp.NewArmorEncoder(w) - if err != nil { - log.Printf("amp.NewArmorEncoder: %v", err) - return - } - defer enc.Close() - - if _, err := enc.Write(response); err != nil { - log.Printf("ampClientOffers: unable to write answer: %v", err) - } -} diff --git a/broker/bridge-list.go b/broker/bridge-list.go deleted file mode 100644 index 4433a12..0000000 --- a/broker/bridge-list.go +++ /dev/null @@ -1,94 +0,0 @@ -/* (*BridgeListHolderFileBased).LoadBridgeInfo loads a Snowflake Server bridge info description file, - its format is as follows: - - This file should be in newline-delimited JSON format(https://jsonlines.org/). - For each line, the format of json data should be in the format of: - {"displayName":"default", "webSocketAddress":"wss://snowflake.torproject.net/", "fingerprint":"2B280B23E1107BB62ABFC40DDCC8824814F80A72"} - - displayName:string is the name of this bridge. This value is not currently used programmatically. - - webSocketAddress:string is the WebSocket URL of this bridge. - This will be the address proxy used to connect to this snowflake server. - - fingerprint:string is the identifier of the bridge. - This will be used by a client to identify the bridge it wishes to connect to. - - The existence of ANY other fields is NOT permitted. - - The file will be considered invalid if there is at least one invalid json record. - In this case, an error will be returned, and none of the records will be loaded. -*/ - -package main - -import ( - "bufio" - "bytes" - "encoding/json" - "errors" - "io" - "sync" - - "git.torproject.org/pluggable-transports/snowflake.git/v2/common/bridgefingerprint" -) - -var ErrBridgeNotFound = errors.New("bridge not found") - -func NewBridgeListHolder() BridgeListHolderFileBased { - return &bridgeListHolder{} -} - -type bridgeListHolder struct { - bridgeInfo map[bridgefingerprint.Fingerprint]BridgeInfo - accessBridgeInfo sync.RWMutex -} - -type BridgeListHolder interface { - GetBridgeInfo(bridgefingerprint.Fingerprint) (BridgeInfo, error) -} - -type BridgeListHolderFileBased interface { - BridgeListHolder - LoadBridgeInfo(reader io.Reader) error -} - -type BridgeInfo struct { - DisplayName string `json:"displayName"` - WebSocketAddress string `json:"webSocketAddress"` - Fingerprint string `json:"fingerprint"` -} - -func (h *bridgeListHolder) GetBridgeInfo(fingerprint bridgefingerprint.Fingerprint) (BridgeInfo, error) { - h.accessBridgeInfo.RLock() - defer h.accessBridgeInfo.RUnlock() - if bridgeInfo, ok := h.bridgeInfo[fingerprint]; ok { - return bridgeInfo, nil - } - return BridgeInfo{}, ErrBridgeNotFound -} - -func (h *bridgeListHolder) LoadBridgeInfo(reader io.Reader) error { - bridgeInfoMap := map[bridgefingerprint.Fingerprint]BridgeInfo{} - inputScanner := bufio.NewScanner(reader) - for inputScanner.Scan() { - inputLine := inputScanner.Bytes() - bridgeInfo := BridgeInfo{} - decoder := json.NewDecoder(bytes.NewReader(inputLine)) - decoder.DisallowUnknownFields() - if err := decoder.Decode(&bridgeInfo); err != nil { - return err - } - - var bridgeFingerprint bridgefingerprint.Fingerprint - var err error - if bridgeFingerprint, err = bridgefingerprint.FingerprintFromHexString(bridgeInfo.Fingerprint); err != nil { - return err - } - - bridgeInfoMap[bridgeFingerprint] = bridgeInfo - } - h.accessBridgeInfo.Lock() - defer h.accessBridgeInfo.Unlock() - h.bridgeInfo = bridgeInfoMap - return nil -} diff --git a/broker/bridge-list_test.go b/broker/bridge-list_test.go deleted file mode 100644 index 4b53821..0000000 --- a/broker/bridge-list_test.go +++ /dev/null @@ -1,64 +0,0 @@ -package main - -import ( - "bytes" - "encoding/hex" - "git.torproject.org/pluggable-transports/snowflake.git/v2/common/bridgefingerprint" - . "github.com/smartystreets/goconvey/convey" - "testing" -) - -const DefaultBridges = `{"displayName":"default", "webSocketAddress":"wss://snowflake.torproject.org", "fingerprint":"2B280B23E1107BB62ABFC40DDCC8824814F80A72"} -` - -const ImaginaryBridges = `{"displayName":"default", "webSocketAddress":"wss://snowflake.torproject.org", "fingerprint":"2B280B23E1107BB62ABFC40DDCC8824814F80A72"} -{"displayName":"imaginary-1", "webSocketAddress":"wss://imaginary-1-snowflake.torproject.org", "fingerprint":"2B280B23E1107BB62ABFC40DDCC8824814F80B00"} -{"displayName":"imaginary-2", "webSocketAddress":"wss://imaginary-2-snowflake.torproject.org", "fingerprint":"2B280B23E1107BB62ABFC40DDCC8824814F80B01"} -{"displayName":"imaginary-3", "webSocketAddress":"wss://imaginary-3-snowflake.torproject.org", "fingerprint":"2B280B23E1107BB62ABFC40DDCC8824814F80B02"} -{"displayName":"imaginary-4", "webSocketAddress":"wss://imaginary-4-snowflake.torproject.org", "fingerprint":"2B280B23E1107BB62ABFC40DDCC8824814F80B03"} -{"displayName":"imaginary-5", "webSocketAddress":"wss://imaginary-5-snowflake.torproject.org", "fingerprint":"2B280B23E1107BB62ABFC40DDCC8824814F80B04"} -{"displayName":"imaginary-6", "webSocketAddress":"wss://imaginary-6-snowflake.torproject.org", "fingerprint":"2B280B23E1107BB62ABFC40DDCC8824814F80B05"} -{"displayName":"imaginary-7", "webSocketAddress":"wss://imaginary-7-snowflake.torproject.org", "fingerprint":"2B280B23E1107BB62ABFC40DDCC8824814F80B06"} -{"displayName":"imaginary-8", "webSocketAddress":"wss://imaginary-8-snowflake.torproject.org", "fingerprint":"2B280B23E1107BB62ABFC40DDCC8824814F80B07"} -{"displayName":"imaginary-9", "webSocketAddress":"wss://imaginary-9-snowflake.torproject.org", "fingerprint":"2B280B23E1107BB62ABFC40DDCC8824814F80B08"} -{"displayName":"imaginary-10", "webSocketAddress":"wss://imaginary-10-snowflake.torproject.org", "fingerprint":"2B280B23E1107BB62ABFC40DDCC8824814F80B09"} -` - -func TestBridgeLoad(t *testing.T) { - Convey("load default list", t, func() { - bridgeList := NewBridgeListHolder() - So(bridgeList.LoadBridgeInfo(bytes.NewReader([]byte(DefaultBridges))), ShouldBeNil) - { - bridgeFingerprint := [20]byte{} - { - n, err := hex.Decode(bridgeFingerprint[:], []byte("2B280B23E1107BB62ABFC40DDCC8824814F80A72")) - So(n, ShouldEqual, 20) - So(err, ShouldBeNil) - } - Fingerprint, err := bridgefingerprint.FingerprintFromBytes(bridgeFingerprint[:]) - So(err, ShouldBeNil) - bridgeInfo, err := bridgeList.GetBridgeInfo(Fingerprint) - So(err, ShouldBeNil) - So(bridgeInfo.DisplayName, ShouldEqual, "default") - So(bridgeInfo.WebSocketAddress, ShouldEqual, "wss://snowflake.torproject.org") - } - }) - Convey("load imaginary list", t, func() { - bridgeList := NewBridgeListHolder() - So(bridgeList.LoadBridgeInfo(bytes.NewReader([]byte(ImaginaryBridges))), ShouldBeNil) - { - bridgeFingerprint := [20]byte{} - { - n, err := hex.Decode(bridgeFingerprint[:], []byte("2B280B23E1107BB62ABFC40DDCC8824814F80B07")) - So(n, ShouldEqual, 20) - So(err, ShouldBeNil) - } - Fingerprint, err := bridgefingerprint.FingerprintFromBytes(bridgeFingerprint[:]) - So(err, ShouldBeNil) - bridgeInfo, err := bridgeList.GetBridgeInfo(Fingerprint) - So(err, ShouldBeNil) - So(bridgeInfo.DisplayName, ShouldEqual, "imaginary-8") - So(bridgeInfo.WebSocketAddress, ShouldEqual, "wss://imaginary-8-snowflake.torproject.org") - } - }) -} diff --git a/broker/broker.go b/broker/broker.go index c356de9..2a253b0 100644 --- a/broker/broker.go +++ b/broker/broker.go @@ -6,56 +6,43 @@ SessionDescriptions in order to negotiate a WebRTC connection. package main import ( - "bytes" "container/heap" "crypto/tls" "flag" - "git.torproject.org/pluggable-transports/snowflake.git/v2/common/bridgefingerprint" - "git.torproject.org/pluggable-transports/snowflake.git/v2/common/ipsetsink" - "git.torproject.org/pluggable-transports/snowflake.git/v2/common/ipsetsink/sinkcluster" + "fmt" "io" + "io/ioutil" "log" + "net" "net/http" "os" "os/signal" "strings" - "sync" "syscall" "time" - "git.torproject.org/pluggable-transports/snowflake.git/v2/common/namematcher" - "git.torproject.org/pluggable-transports/snowflake.git/v2/common/safelog" - "github.com/prometheus/client_golang/prometheus" - "github.com/prometheus/client_golang/prometheus/promhttp" + "git.torproject.org/pluggable-transports/snowflake.git/common/safelog" "golang.org/x/crypto/acme/autocert" ) +const ( + ClientTimeout = 10 + ProxyTimeout = 10 + readLimit = 100000 //Maximum number of bytes to be read from an HTTP request +) + type BrokerContext struct { - snowflakes *SnowflakeHeap - restrictedSnowflakes *SnowflakeHeap - // Maps keeping track of snowflakeIDs required to match SDP answers from - // the second http POST. Restricted snowflakes can only be matched up with - // clients behind an unrestricted NAT. + snowflakes *SnowflakeHeap + // Map keeping track of snowflakeIDs required to match SDP answers from + // the second http POST. idToSnowflake map[string]*Snowflake - // Synchronization for the snowflake map and heap - snowflakeLock sync.Mutex proxyPolls chan *ProxyPoll metrics *Metrics - - bridgeList BridgeListHolderFileBased - allowedRelayPattern string - presumedPatternForLegacyClient string -} - -func (ctx *BrokerContext) GetBridgeInfo(fingerprint bridgefingerprint.Fingerprint) (BridgeInfo, error) { - return ctx.bridgeList.GetBridgeInfo(fingerprint) } func NewBrokerContext(metricsLogger *log.Logger) *BrokerContext { snowflakes := new(SnowflakeHeap) heap.Init(snowflakes) - rSnowflakes := new(SnowflakeHeap) - heap.Init(rSnowflakes) metrics, err := NewMetrics(metricsLogger) if err != nil { @@ -66,40 +53,58 @@ func NewBrokerContext(metricsLogger *log.Logger) *BrokerContext { panic("Failed to create metrics") } - bridgeListHolder := NewBridgeListHolder() - - const DefaultBridges = `{"displayName":"default", "webSocketAddress":"wss://snowflake.torproject.net/", "fingerprint":"2B280B23E1107BB62ABFC40DDCC8824814F80A72"} -` - bridgeListHolder.LoadBridgeInfo(bytes.NewReader([]byte(DefaultBridges))) - return &BrokerContext{ - snowflakes: snowflakes, - restrictedSnowflakes: rSnowflakes, - idToSnowflake: make(map[string]*Snowflake), - proxyPolls: make(chan *ProxyPoll), - metrics: metrics, - bridgeList: bridgeListHolder, + snowflakes: snowflakes, + idToSnowflake: make(map[string]*Snowflake), + proxyPolls: make(chan *ProxyPoll), + metrics: metrics, } } +// Implements the http.Handler interface +type SnowflakeHandler struct { + *BrokerContext + handle func(*BrokerContext, http.ResponseWriter, *http.Request) +} + +// Implements the http.Handler interface +type MetricsHandler struct { + logFilename string + handle func(string, http.ResponseWriter, *http.Request) +} + +func (sh SnowflakeHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Access-Control-Allow-Origin", "*") + w.Header().Set("Access-Control-Allow-Headers", "Origin, X-Session-ID") + // Return early if it's CORS preflight. + if "OPTIONS" == r.Method { + return + } + sh.handle(sh.BrokerContext, w, r) +} + +func (mh MetricsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Access-Control-Allow-Origin", "*") + w.Header().Set("Access-Control-Allow-Headers", "Origin, X-Session-ID") + // Return early if it's CORS preflight. + if "OPTIONS" == r.Method { + return + } + mh.handle(mh.logFilename, w, r) +} + // Proxies may poll for client offers concurrently. type ProxyPoll struct { id string - proxyType string - natType string - clients int - offerChannel chan *ClientOffer + offerChannel chan []byte } // Registers a Snowflake and waits for some Client to send an offer, // as part of the polling logic of the proxy handler. -func (ctx *BrokerContext) RequestOffer(id string, proxyType string, natType string, clients int) *ClientOffer { +func (ctx *BrokerContext) RequestOffer(id string) []byte { request := new(ProxyPoll) request.id = id - request.proxyType = proxyType - request.natType = natType - request.clients = clients - request.offerChannel = make(chan *ClientOffer) + request.offerChannel = make(chan []byte) ctx.proxyPolls <- request // Block until an offer is available, or timeout which sends a nil offer. offer := <-request.offerChannel @@ -111,7 +116,7 @@ func (ctx *BrokerContext) RequestOffer(id string, proxyType string, natType stri // client offer or nil on timeout / none are available. func (ctx *BrokerContext) Broker() { for request := range ctx.proxyPolls { - snowflake := ctx.AddSnowflake(request.id, request.proxyType, request.natType, request.clients) + snowflake := ctx.AddSnowflake(request.id) // Wait for a client to avail an offer to the snowflake. go func(request *ProxyPoll) { select { @@ -119,18 +124,10 @@ func (ctx *BrokerContext) Broker() { request.offerChannel <- offer case <-time.After(time.Second * ProxyTimeout): // This snowflake is no longer available to serve clients. - ctx.snowflakeLock.Lock() - defer ctx.snowflakeLock.Unlock() - if snowflake.index != -1 { - if request.natType == NATUnrestricted { - heap.Remove(ctx.snowflakes, snowflake.index) - } else { - heap.Remove(ctx.restrictedSnowflakes, snowflake.index) - } - ctx.metrics.promMetrics.AvailableProxies.With(prometheus.Labels{"nat": request.natType, "type": request.proxyType}).Dec() - delete(ctx.idToSnowflake, snowflake.id) - close(request.offerChannel) - } + // TODO: Fix race using a delete channel + heap.Remove(ctx.snowflakes, snowflake.index) + delete(ctx.idToSnowflake, snowflake.id) + request.offerChannel <- nil } }(request) } @@ -139,49 +136,165 @@ func (ctx *BrokerContext) Broker() { // Create and add a Snowflake to the heap. // Required to keep track of proxies between providing them // with an offer and awaiting their second POST with an answer. -func (ctx *BrokerContext) AddSnowflake(id string, proxyType string, natType string, clients int) *Snowflake { +func (ctx *BrokerContext) AddSnowflake(id string) *Snowflake { snowflake := new(Snowflake) snowflake.id = id - snowflake.clients = clients - snowflake.proxyType = proxyType - snowflake.natType = natType - snowflake.offerChannel = make(chan *ClientOffer) - snowflake.answerChannel = make(chan string) - ctx.snowflakeLock.Lock() - if natType == NATUnrestricted { - heap.Push(ctx.snowflakes, snowflake) - } else { - heap.Push(ctx.restrictedSnowflakes, snowflake) - } - ctx.metrics.promMetrics.AvailableProxies.With(prometheus.Labels{"nat": natType, "type": proxyType}).Inc() + snowflake.clients = 0 + snowflake.offerChannel = make(chan []byte) + snowflake.answerChannel = make(chan []byte) + heap.Push(ctx.snowflakes, snowflake) ctx.idToSnowflake[id] = snowflake - ctx.snowflakeLock.Unlock() return snowflake } -func (ctx *BrokerContext) InstallBridgeListProfile(reader io.Reader, relayPattern, presumedPatternForLegacyClient string) error { - if err := ctx.bridgeList.LoadBridgeInfo(reader); err != nil { - return err +/* +For snowflake proxies to request a client from the Broker. +*/ +func proxyPolls(ctx *BrokerContext, w http.ResponseWriter, r *http.Request) { + id := r.Header.Get("X-Session-ID") + body, err := ioutil.ReadAll(http.MaxBytesReader(w, r.Body, readLimit)) + if nil != err { + log.Println("Invalid data.") + w.WriteHeader(http.StatusBadRequest) + return + } + if string(body) != id { + log.Println("Mismatched IDs!") + w.WriteHeader(http.StatusBadRequest) + return + } + + // Log geoip stats + remoteIP, _, err := net.SplitHostPort(r.RemoteAddr) + if err != nil { + log.Println("Error processing proxy IP: ", err.Error()) + } else { + ctx.metrics.UpdateCountryStats(remoteIP) + } + + // Wait for a client to avail an offer to the snowflake, or timeout if nil. + offer := ctx.RequestOffer(id) + if nil == offer { + ctx.metrics.proxyIdleCount++ + w.WriteHeader(http.StatusGatewayTimeout) + return + } + log.Println("Passing client offer to snowflake.") + if _, err := w.Write(offer); err != nil { + log.Printf("proxyPolls unable to write offer with error: %v", err) } - ctx.allowedRelayPattern = relayPattern - ctx.presumedPatternForLegacyClient = presumedPatternForLegacyClient - return nil } -func (ctx *BrokerContext) CheckProxyRelayPattern(pattern string, nonSupported bool) bool { - if nonSupported { - pattern = ctx.presumedPatternForLegacyClient +/* +Expects a WebRTC SDP offer in the Request to give to an assigned +snowflake proxy, which responds with the SDP answer to be sent in +the HTTP response back to the client. +*/ +func clientOffers(ctx *BrokerContext, w http.ResponseWriter, r *http.Request) { + startTime := time.Now() + offer, err := ioutil.ReadAll(http.MaxBytesReader(w, r.Body, readLimit)) + if nil != err { + log.Println("Invalid data.") + w.WriteHeader(http.StatusBadRequest) + return + } + // Immediately fail if there are no snowflakes available. + if ctx.snowflakes.Len() <= 0 { + ctx.metrics.clientDeniedCount++ + w.WriteHeader(http.StatusServiceUnavailable) + return + } + // Otherwise, find the most available snowflake proxy, and pass the offer to it. + // Delete must be deferred in order to correctly process answer request later. + snowflake := heap.Pop(ctx.snowflakes).(*Snowflake) + defer delete(ctx.idToSnowflake, snowflake.id) + snowflake.offerChannel <- offer + + // Wait for the answer to be returned on the channel or timeout. + select { + case answer := <-snowflake.answerChannel: + ctx.metrics.clientProxyMatchCount++ + if _, err := w.Write(answer); err != nil { + log.Printf("unable to write answer with error: %v", err) + } + // Initial tracking of elapsed time. + ctx.metrics.clientRoundtripEstimate = time.Since(startTime) / + time.Millisecond + case <-time.After(time.Second * ClientTimeout): + log.Println("Client: Timed out.") + w.WriteHeader(http.StatusGatewayTimeout) + if _, err := w.Write([]byte("timed out waiting for answer!")); err != nil { + log.Printf("unable to write timeout error, failed with error: %v", err) + } } - proxyPattern := namematcher.NewNameMatcher(pattern) - brokerPattern := namematcher.NewNameMatcher(ctx.allowedRelayPattern) - return proxyPattern.IsSupersetOf(brokerPattern) } -// Client offer contains an SDP, bridge fingerprint and the NAT type of the client -type ClientOffer struct { - natType string - sdp []byte - fingerprint []byte +/* +Expects snowflake proxes which have previously successfully received +an offer from proxyHandler to respond with an answer in an HTTP POST, +which the broker will pass back to the original client. +*/ +func proxyAnswers(ctx *BrokerContext, w http.ResponseWriter, r *http.Request) { + id := r.Header.Get("X-Session-ID") + snowflake, ok := ctx.idToSnowflake[id] + if !ok || nil == snowflake { + // The snowflake took too long to respond with an answer, so its client + // disappeared / the snowflake is no longer recognized by the Broker. + w.WriteHeader(http.StatusGone) + return + } + body, err := ioutil.ReadAll(http.MaxBytesReader(w, r.Body, readLimit)) + if nil != err || nil == body || len(body) <= 0 { + log.Println("Invalid data.") + w.WriteHeader(http.StatusBadRequest) + return + } + + snowflake.answerChannel <- body +} + +func debugHandler(ctx *BrokerContext, w http.ResponseWriter, r *http.Request) { + s := fmt.Sprintf("current snowflakes available: %d\n", ctx.snowflakes.Len()) + + var browsers, standalones int + for _, snowflake := range ctx.idToSnowflake { + if len(snowflake.id) < 16 { + browsers++ + } else { + standalones++ + } + } + s += fmt.Sprintf("\tstandalone proxies: %d", standalones) + s += fmt.Sprintf("\n\tbrowser proxies: %d", browsers) + if _, err := w.Write([]byte(s)); err != nil { + log.Printf("writing proxy information returned error: %v ", err) + } +} + +func robotsTxtHandler(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/plain; charset=utf-8") + if _, err := w.Write([]byte("User-agent: *\nDisallow: /\n")); err != nil { + log.Printf("robotsTxtHandler unable to write, with this error: %v", err) + } +} + +func metricsHandler(metricsFilename string, w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/plain; charset=utf-8") + + if metricsFilename == "" { + http.NotFound(w, r) + return + } + metricsFile, err := os.OpenFile(metricsFilename, os.O_RDONLY, 0644) + if err != nil { + log.Println("Error opening metrics file for reading") + http.NotFound(w, r) + return + } + + if _, err := io.Copy(w, metricsFile); err != nil { + log.Printf("copying metricsFile returned error: %v", err) + } } func main() { @@ -191,14 +304,10 @@ func main() { var addr string var geoipDatabase string var geoip6Database string - var bridgeListFilePath, allowedRelayPattern, presumedPatternForLegacyClient string var disableTLS bool var certFilename, keyFilename string var disableGeoip bool var metricsFilename string - var ipCountFilename, ipCountMaskingKey string - var ipCountInterval time.Duration - var unsafeLogging bool flag.StringVar(&acmeEmail, "acme-email", "", "optional contact email for Let's Encrypt notifications") flag.StringVar(&acmeHostnamesCommas, "acme-hostnames", "", "comma-separated hostnames for TLS certificate") @@ -208,27 +317,16 @@ func main() { flag.StringVar(&addr, "addr", ":443", "address to listen on") flag.StringVar(&geoipDatabase, "geoipdb", "/usr/share/tor/geoip", "path to correctly formatted geoip database mapping IPv4 address ranges to country codes") flag.StringVar(&geoip6Database, "geoip6db", "/usr/share/tor/geoip6", "path to correctly formatted geoip database mapping IPv6 address ranges to country codes") - flag.StringVar(&bridgeListFilePath, "bridge-list-path", "", "file path for bridgeListFile") - flag.StringVar(&allowedRelayPattern, "allowed-relay-pattern", "", "allowed pattern for relay host name") - flag.StringVar(&presumedPatternForLegacyClient, "default-relay-pattern", "", "presumed pattern for legacy client") flag.BoolVar(&disableTLS, "disable-tls", false, "don't use HTTPS") flag.BoolVar(&disableGeoip, "disable-geoip", false, "don't use geoip for stats collection") flag.StringVar(&metricsFilename, "metrics-log", "", "path to metrics logging output") - flag.StringVar(&ipCountFilename, "ip-count-log", "", "path to ip count logging output") - flag.StringVar(&ipCountMaskingKey, "ip-count-mask", "", "masking key for ip count logging") - flag.DurationVar(&ipCountInterval, "ip-count-interval", time.Hour, "time interval between each chunk") - flag.BoolVar(&unsafeLogging, "unsafe-logging", false, "prevent logs from being scrubbed") flag.Parse() var err error var metricsFile io.Writer var logOutput io.Writer = os.Stderr - if unsafeLogging { - log.SetOutput(logOutput) - } else { - // We want to send the log output through our scrubber first - log.SetOutput(&safelog.LogScrubber{Output: logOutput}) - } + //We want to send the log output through our scrubber first + log.SetOutput(&safelog.LogScrubber{Output: logOutput}) log.SetFlags(log.LstdFlags | log.LUTC) @@ -246,17 +344,6 @@ func main() { ctx := NewBrokerContext(metricsLogger) - if bridgeListFilePath != "" { - bridgeListFile, err := os.Open(bridgeListFilePath) - if err != nil { - log.Fatal(err.Error()) - } - err = ctx.InstallBridgeListProfile(bridgeListFile, allowedRelayPattern, presumedPatternForLegacyClient) - if err != nil { - log.Fatal(err.Error()) - } - } - if !disableGeoip { err = ctx.metrics.LoadGeoipDatabases(geoipDatabase, geoip6Database) if err != nil { @@ -264,30 +351,15 @@ func main() { } } - if ipCountFilename != "" { - ipCountFile, err := os.OpenFile(ipCountFilename, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) - - if err != nil { - log.Fatal(err.Error()) - } - ipSetSink := ipsetsink.NewIPSetSink(ipCountMaskingKey) - ctx.metrics.distinctIPWriter = sinkcluster.NewClusterWriter(ipCountFile, ipCountInterval, ipSetSink) - } - go ctx.Broker() - i := &IPC{ctx} - http.HandleFunc("/robots.txt", robotsTxtHandler) - http.Handle("/proxy", SnowflakeHandler{i, proxyPolls}) - http.Handle("/client", SnowflakeHandler{i, clientOffers}) - http.Handle("/answer", SnowflakeHandler{i, proxyAnswers}) - http.Handle("/debug", SnowflakeHandler{i, debugHandler}) + http.Handle("/proxy", SnowflakeHandler{ctx, proxyPolls}) + http.Handle("/client", SnowflakeHandler{ctx, clientOffers}) + http.Handle("/answer", SnowflakeHandler{ctx, proxyAnswers}) + http.Handle("/debug", SnowflakeHandler{ctx, debugHandler}) http.Handle("/metrics", MetricsHandler{metricsFilename, metricsHandler}) - http.Handle("/prometheus", promhttp.HandlerFor(ctx.metrics.promMetrics.registry, promhttp.HandlerOpts{})) - - http.Handle("/amp/client/", SnowflakeHandler{i, ampClientOffers}) server := http.Server{ Addr: addr, diff --git a/broker/geoip.go b/broker/geoip.go new file mode 100644 index 0000000..708cdad --- /dev/null +++ b/broker/geoip.go @@ -0,0 +1,240 @@ +/* +This code is for loading database data that maps ip addresses to countries +for collecting and presenting statistics on snowflake use that might alert us +to censorship events. + +The functions here are heavily based off of how tor maintains and searches their +geoip database + +The tables used for geoip data must be structured as follows: + +Recognized line format for IPv4 is: + INTIPLOW,INTIPHIGH,CC + where INTIPLOW and INTIPHIGH are IPv4 addresses encoded as big-endian 4-byte unsigned + integers, and CC is a country code. + +Note that the IPv4 line format + "INTIPLOW","INTIPHIGH","CC","CC3","COUNTRY NAME" +is not currently supported. + +Recognized line format for IPv6 is: + IPV6LOW,IPV6HIGH,CC + where IPV6LOW and IPV6HIGH are IPv6 addresses and CC is a country code. + +It also recognizes, and skips over, blank lines and lines that start +with '#' (comments). + +*/ +package main + +import ( + "bufio" + "bytes" + "crypto/sha1" + "encoding/hex" + "fmt" + "io" + "log" + "net" + "os" + "sort" + "strconv" + "strings" + "sync" +) + +type GeoIPTable interface { + parseEntry(string) (*GeoIPEntry, error) + Len() int + Append(GeoIPEntry) + ElementAt(int) GeoIPEntry + Lock() + Unlock() +} + +type GeoIPEntry struct { + ipLow net.IP + ipHigh net.IP + country string +} + +type GeoIPv4Table struct { + table []GeoIPEntry + + lock sync.Mutex // synchronization for geoip table accesses and reloads +} + +type GeoIPv6Table struct { + table []GeoIPEntry + + lock sync.Mutex // synchronization for geoip table accesses and reloads +} + +func (table *GeoIPv4Table) Len() int { return len(table.table) } +func (table *GeoIPv6Table) Len() int { return len(table.table) } + +func (table *GeoIPv4Table) Append(entry GeoIPEntry) { + (*table).table = append(table.table, entry) +} +func (table *GeoIPv6Table) Append(entry GeoIPEntry) { + (*table).table = append(table.table, entry) +} + +func (table *GeoIPv4Table) ElementAt(i int) GeoIPEntry { return table.table[i] } +func (table *GeoIPv6Table) ElementAt(i int) GeoIPEntry { return table.table[i] } + +func (table *GeoIPv4Table) Lock() { (*table).lock.Lock() } +func (table *GeoIPv6Table) Lock() { (*table).lock.Lock() } + +func (table *GeoIPv4Table) Unlock() { (*table).lock.Unlock() } +func (table *GeoIPv6Table) Unlock() { (*table).lock.Unlock() } + +// Convert a geoip IP address represented as a big-endian unsigned integer to net.IP +func geoipStringToIP(ipStr string) (net.IP, error) { + ip, err := strconv.ParseUint(ipStr, 10, 32) + if err != nil { + return net.IPv4(0, 0, 0, 0), fmt.Errorf("error parsing IP %s", ipStr) + } + var bytes [4]byte + bytes[0] = byte(ip & 0xFF) + bytes[1] = byte((ip >> 8) & 0xFF) + bytes[2] = byte((ip >> 16) & 0xFF) + bytes[3] = byte((ip >> 24) & 0xFF) + + return net.IPv4(bytes[3], bytes[2], bytes[1], bytes[0]), nil +} + +//Parses a line in the provided geoip file that corresponds +//to an address range and a two character country code +func (table *GeoIPv4Table) parseEntry(candidate string) (*GeoIPEntry, error) { + + if candidate[0] == '#' { + return nil, nil + } + + parsedCandidate := strings.Split(candidate, ",") + + if len(parsedCandidate) != 3 { + return nil, fmt.Errorf("provided geoip file is incorrectly formatted. Could not parse line:\n%s", parsedCandidate) + } + + low, err := geoipStringToIP(parsedCandidate[0]) + if err != nil { + return nil, err + } + high, err := geoipStringToIP(parsedCandidate[1]) + if err != nil { + return nil, err + } + + geoipEntry := &GeoIPEntry{ + ipLow: low, + ipHigh: high, + country: parsedCandidate[2], + } + + return geoipEntry, nil +} + +//Parses a line in the provided geoip file that corresponds +//to an address range and a two character country code +func (table *GeoIPv6Table) parseEntry(candidate string) (*GeoIPEntry, error) { + + if candidate[0] == '#' { + return nil, nil + } + + parsedCandidate := strings.Split(candidate, ",") + + if len(parsedCandidate) != 3 { + return nil, fmt.Errorf("") + } + + low := net.ParseIP(parsedCandidate[0]) + if low == nil { + return nil, fmt.Errorf("") + } + high := net.ParseIP(parsedCandidate[1]) + if high == nil { + return nil, fmt.Errorf("") + } + + geoipEntry := &GeoIPEntry{ + ipLow: low, + ipHigh: high, + country: parsedCandidate[2], + } + + return geoipEntry, nil +} + +//Loads provided geoip file into our tables +//Entries are stored in a table +func GeoIPLoadFile(table GeoIPTable, pathname string) error { + //open file + geoipFile, err := os.Open(pathname) + if err != nil { + return err + } + defer geoipFile.Close() + + hash := sha1.New() + + table.Lock() + defer table.Unlock() + + hashedFile := io.TeeReader(geoipFile, hash) + + //read in strings and call parse function + scanner := bufio.NewScanner(hashedFile) + for scanner.Scan() { + entry, err := table.parseEntry(scanner.Text()) + if err != nil { + return fmt.Errorf("provided geoip file is incorrectly formatted. Line is: %+q", scanner.Text()) + } + + if entry != nil { + table.Append(*entry) + } + + } + if err := scanner.Err(); err != nil { + return err + } + + sha1Hash := hex.EncodeToString(hash.Sum(nil)) + log.Println("Using geoip file ", pathname, " with checksum", sha1Hash) + log.Println("Loaded ", table.Len(), " entries into table") + + return nil +} + +//Returns the country location of an IPv4 or IPv6 address, and a boolean value +//that indicates whether the IP address was present in the geoip database +func GetCountryByAddr(table GeoIPTable, ip net.IP) (string, bool) { + + table.Lock() + defer table.Unlock() + + //look IP up in database + index := sort.Search(table.Len(), func(i int) bool { + entry := table.ElementAt(i) + return (bytes.Compare(ip.To16(), entry.ipHigh.To16()) <= 0) + }) + + if index == table.Len() { + return "", false + } + + // check to see if addr is in the range specified by the returned index + // search on IPs in invalid ranges (e.g., 127.0.0.0/8) will return the + //country code of the next highest range + entry := table.ElementAt(index) + if !(bytes.Compare(ip.To16(), entry.ipLow.To16()) >= 0 && + bytes.Compare(ip.To16(), entry.ipHigh.To16()) <= 0) { + return "", false + } + + return table.ElementAt(index).country, true + +} diff --git a/broker/http.go b/broker/http.go deleted file mode 100644 index 2f81f1d..0000000 --- a/broker/http.go +++ /dev/null @@ -1,235 +0,0 @@ -package main - -import ( - "errors" - "io" - "io/ioutil" - "log" - "net/http" - "os" - - "git.torproject.org/pluggable-transports/snowflake.git/v2/common/messages" -) - -const ( - readLimit = 100000 // Maximum number of bytes to be read from an HTTP request -) - -// Implements the http.Handler interface -type SnowflakeHandler struct { - *IPC - handle func(*IPC, http.ResponseWriter, *http.Request) -} - -func (sh SnowflakeHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Access-Control-Allow-Origin", "*") - w.Header().Set("Access-Control-Allow-Headers", "Origin, X-Session-ID") - // Return early if it's CORS preflight. - if "OPTIONS" == r.Method { - return - } - sh.handle(sh.IPC, w, r) -} - -// Implements the http.Handler interface -type MetricsHandler struct { - logFilename string - handle func(string, http.ResponseWriter, *http.Request) -} - -func (mh MetricsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Access-Control-Allow-Origin", "*") - w.Header().Set("Access-Control-Allow-Headers", "Origin, X-Session-ID") - // Return early if it's CORS preflight. - if "OPTIONS" == r.Method { - return - } - mh.handle(mh.logFilename, w, r) -} - -func robotsTxtHandler(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "text/plain; charset=utf-8") - if _, err := w.Write([]byte("User-agent: *\nDisallow: /\n")); err != nil { - log.Printf("robotsTxtHandler unable to write, with this error: %v", err) - } -} - -func metricsHandler(metricsFilename string, w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "text/plain; charset=utf-8") - - if metricsFilename == "" { - http.NotFound(w, r) - return - } - metricsFile, err := os.OpenFile(metricsFilename, os.O_RDONLY, 0644) - if err != nil { - log.Println("Error opening metrics file for reading") - http.NotFound(w, r) - return - } - - if _, err := io.Copy(w, metricsFile); err != nil { - log.Printf("copying metricsFile returned error: %v", err) - } -} - -func debugHandler(i *IPC, w http.ResponseWriter, r *http.Request) { - var response string - - err := i.Debug(new(interface{}), &response) - if err != nil { - log.Println(err) - w.WriteHeader(http.StatusInternalServerError) - return - } - - if _, err := w.Write([]byte(response)); err != nil { - log.Printf("writing proxy information returned error: %v ", err) - } -} - -/* -For snowflake proxies to request a client from the Broker. -*/ -func proxyPolls(i *IPC, w http.ResponseWriter, r *http.Request) { - body, err := ioutil.ReadAll(http.MaxBytesReader(w, r.Body, readLimit)) - if err != nil { - log.Println("Invalid data.", err.Error()) - w.WriteHeader(http.StatusBadRequest) - return - } - - arg := messages.Arg{ - Body: body, - RemoteAddr: r.RemoteAddr, - } - - var response []byte - err = i.ProxyPolls(arg, &response) - switch { - case err == nil: - case errors.Is(err, messages.ErrBadRequest): - w.WriteHeader(http.StatusBadRequest) - return - case errors.Is(err, messages.ErrInternal): - fallthrough - default: - log.Println(err) - w.WriteHeader(http.StatusInternalServerError) - return - } - - if _, err := w.Write(response); err != nil { - log.Printf("proxyPolls unable to write offer with error: %v", err) - } -} - -/* -Expects a WebRTC SDP offer in the Request to give to an assigned -snowflake proxy, which responds with the SDP answer to be sent in -the HTTP response back to the client. -*/ -func clientOffers(i *IPC, w http.ResponseWriter, r *http.Request) { - body, err := ioutil.ReadAll(http.MaxBytesReader(w, r.Body, readLimit)) - if err != nil { - log.Printf("Error reading client request: %s", err.Error()) - w.WriteHeader(http.StatusBadRequest) - return - } - - // Handle the legacy version - // - // We support two client message formats. The legacy format is for backwards - // combatability and relies heavily on HTTP headers and status codes to convey - // information. - isLegacy := false - if len(body) > 0 && body[0] == '{' { - isLegacy = true - req := messages.ClientPollRequest{ - Offer: string(body), - NAT: r.Header.Get("Snowflake-NAT-Type"), - } - body, err = req.EncodeClientPollRequest() - if err != nil { - log.Printf("Error shimming the legacy request: %s", err.Error()) - w.WriteHeader(http.StatusInternalServerError) - return - } - } - - arg := messages.Arg{ - Body: body, - RemoteAddr: "", - } - - var response []byte - err = i.ClientOffers(arg, &response) - if err != nil { - log.Println(err) - w.WriteHeader(http.StatusInternalServerError) - return - } - - if isLegacy { - resp, err := messages.DecodeClientPollResponse(response) - if err != nil { - log.Println(err) - w.WriteHeader(http.StatusInternalServerError) - return - } - switch resp.Error { - case "": - response = []byte(resp.Answer) - case messages.StrNoProxies: - w.WriteHeader(http.StatusServiceUnavailable) - return - case messages.StrTimedOut: - w.WriteHeader(http.StatusGatewayTimeout) - return - default: - panic("unknown error") - } - } - - if _, err := w.Write(response); err != nil { - log.Printf("clientOffers unable to write answer with error: %v", err) - } -} - -/* -Expects snowflake proxes which have previously successfully received -an offer from proxyHandler to respond with an answer in an HTTP POST, -which the broker will pass back to the original client. -*/ -func proxyAnswers(i *IPC, w http.ResponseWriter, r *http.Request) { - body, err := ioutil.ReadAll(http.MaxBytesReader(w, r.Body, readLimit)) - if err != nil { - log.Println("Invalid data.", err.Error()) - w.WriteHeader(http.StatusBadRequest) - return - } - - arg := messages.Arg{ - Body: body, - RemoteAddr: "", - } - - var response []byte - err = i.ProxyAnswers(arg, &response) - switch { - case err == nil: - case errors.Is(err, messages.ErrBadRequest): - w.WriteHeader(http.StatusBadRequest) - return - case errors.Is(err, messages.ErrInternal): - fallthrough - default: - log.Println(err) - w.WriteHeader(http.StatusInternalServerError) - return - } - - if _, err := w.Write(response); err != nil { - log.Printf("proxyAnswers unable to write answer response with error: %v", err) - } -} diff --git a/broker/ipc.go b/broker/ipc.go deleted file mode 100644 index 14db4d8..0000000 --- a/broker/ipc.go +++ /dev/null @@ -1,282 +0,0 @@ -package main - -import ( - "container/heap" - "encoding/hex" - "fmt" - "git.torproject.org/pluggable-transports/snowflake.git/v2/common/bridgefingerprint" - "log" - "net" - "time" - - "git.torproject.org/pluggable-transports/snowflake.git/v2/common/messages" - "github.com/prometheus/client_golang/prometheus" -) - -const ( - ClientTimeout = 10 - ProxyTimeout = 10 - - NATUnknown = "unknown" - NATRestricted = "restricted" - NATUnrestricted = "unrestricted" -) - -type IPC struct { - ctx *BrokerContext -} - -func (i *IPC) Debug(_ interface{}, response *string) error { - var unknowns int - var natRestricted, natUnrestricted, natUnknown int - proxyTypes := make(map[string]int) - - i.ctx.snowflakeLock.Lock() - s := fmt.Sprintf("current snowflakes available: %d\n", len(i.ctx.idToSnowflake)) - for _, snowflake := range i.ctx.idToSnowflake { - if messages.KnownProxyTypes[snowflake.proxyType] { - proxyTypes[snowflake.proxyType]++ - } else { - unknowns++ - } - - switch snowflake.natType { - case NATRestricted: - natRestricted++ - case NATUnrestricted: - natUnrestricted++ - default: - natUnknown++ - } - - } - i.ctx.snowflakeLock.Unlock() - - for pType, num := range proxyTypes { - s += fmt.Sprintf("\t%s proxies: %d\n", pType, num) - } - s += fmt.Sprintf("\tunknown proxies: %d", unknowns) - - s += fmt.Sprintf("\nNAT Types available:") - s += fmt.Sprintf("\n\trestricted: %d", natRestricted) - s += fmt.Sprintf("\n\tunrestricted: %d", natUnrestricted) - s += fmt.Sprintf("\n\tunknown: %d", natUnknown) - - *response = s - return nil -} - -func (i *IPC) ProxyPolls(arg messages.Arg, response *[]byte) error { - sid, proxyType, natType, clients, relayPattern, relayPatternSupported, err := messages.DecodeProxyPollRequestWithRelayPrefix(arg.Body) - if err != nil { - return messages.ErrBadRequest - } - - if !relayPatternSupported { - i.ctx.metrics.lock.Lock() - i.ctx.metrics.proxyPollWithoutRelayURLExtension++ - i.ctx.metrics.promMetrics.ProxyPollWithoutRelayURLExtensionTotal.With(prometheus.Labels{"nat": natType, "type": proxyType}).Inc() - i.ctx.metrics.lock.Unlock() - } else { - i.ctx.metrics.lock.Lock() - i.ctx.metrics.proxyPollWithRelayURLExtension++ - i.ctx.metrics.promMetrics.ProxyPollWithRelayURLExtensionTotal.With(prometheus.Labels{"nat": natType, "type": proxyType}).Inc() - i.ctx.metrics.lock.Unlock() - } - - if !i.ctx.CheckProxyRelayPattern(relayPattern, !relayPatternSupported) { - i.ctx.metrics.lock.Lock() - i.ctx.metrics.proxyPollRejectedWithRelayURLExtension++ - i.ctx.metrics.promMetrics.ProxyPollRejectedForRelayURLExtensionTotal.With(prometheus.Labels{"nat": natType, "type": proxyType}).Inc() - i.ctx.metrics.lock.Unlock() - - log.Printf("bad request: rejected relay pattern from proxy = %v", messages.ErrBadRequest) - b, err := messages.EncodePollResponseWithRelayURL("", false, "", "", "incorrect relay pattern") - *response = b - if err != nil { - return messages.ErrInternal - } - return nil - } - - // Log geoip stats - remoteIP, _, err := net.SplitHostPort(arg.RemoteAddr) - if err != nil { - log.Println("Error processing proxy IP: ", err.Error()) - } else { - i.ctx.metrics.lock.Lock() - i.ctx.metrics.UpdateCountryStats(remoteIP, proxyType, natType) - i.ctx.metrics.RecordIPAddress(remoteIP) - i.ctx.metrics.lock.Unlock() - } - - var b []byte - - // Wait for a client to avail an offer to the snowflake, or timeout if nil. - offer := i.ctx.RequestOffer(sid, proxyType, natType, clients) - - if offer == nil { - i.ctx.metrics.lock.Lock() - i.ctx.metrics.proxyIdleCount++ - i.ctx.metrics.promMetrics.ProxyPollTotal.With(prometheus.Labels{"nat": natType, "status": "idle"}).Inc() - i.ctx.metrics.lock.Unlock() - - b, err = messages.EncodePollResponse("", false, "") - if err != nil { - return messages.ErrInternal - } - - *response = b - return nil - } - - i.ctx.metrics.promMetrics.ProxyPollTotal.With(prometheus.Labels{"nat": natType, "status": "matched"}).Inc() - var relayURL string - bridgeFingerprint, err := bridgefingerprint.FingerprintFromBytes(offer.fingerprint) - if err != nil { - return messages.ErrBadRequest - } - if info, err := i.ctx.bridgeList.GetBridgeInfo(bridgeFingerprint); err != nil { - return err - } else { - relayURL = info.WebSocketAddress - } - b, err = messages.EncodePollResponseWithRelayURL(string(offer.sdp), true, offer.natType, relayURL, "") - if err != nil { - return messages.ErrInternal - } - *response = b - - return nil -} - -func sendClientResponse(resp *messages.ClientPollResponse, response *[]byte) error { - data, err := resp.EncodePollResponse() - if err != nil { - log.Printf("error encoding answer") - return messages.ErrInternal - } else { - *response = []byte(data) - return nil - } -} - -func (i *IPC) ClientOffers(arg messages.Arg, response *[]byte) error { - startTime := time.Now() - - req, err := messages.DecodeClientPollRequest(arg.Body) - if err != nil { - return sendClientResponse(&messages.ClientPollResponse{Error: err.Error()}, response) - } - - offer := &ClientOffer{ - natType: req.NAT, - sdp: []byte(req.Offer), - } - - fingerprint, err := hex.DecodeString(req.Fingerprint) - if err != nil { - return sendClientResponse(&messages.ClientPollResponse{Error: err.Error()}, response) - } - - BridgeFingerprint, err := bridgefingerprint.FingerprintFromBytes(fingerprint) - if err != nil { - return sendClientResponse(&messages.ClientPollResponse{Error: err.Error()}, response) - } - - if _, err := i.ctx.GetBridgeInfo(BridgeFingerprint); err != nil { - return err - } - - offer.fingerprint = BridgeFingerprint.ToBytes() - - snowflake := i.matchSnowflake(offer.natType) - if snowflake != nil { - snowflake.offerChannel <- offer - } else { - i.ctx.metrics.lock.Lock() - i.ctx.metrics.clientDeniedCount++ - i.ctx.metrics.promMetrics.ClientPollTotal.With(prometheus.Labels{"nat": offer.natType, "status": "denied"}).Inc() - if offer.natType == NATUnrestricted { - i.ctx.metrics.clientUnrestrictedDeniedCount++ - } else { - i.ctx.metrics.clientRestrictedDeniedCount++ - } - i.ctx.metrics.lock.Unlock() - resp := &messages.ClientPollResponse{Error: messages.StrNoProxies} - return sendClientResponse(resp, response) - } - - // Wait for the answer to be returned on the channel or timeout. - select { - case answer := <-snowflake.answerChannel: - i.ctx.metrics.lock.Lock() - i.ctx.metrics.clientProxyMatchCount++ - i.ctx.metrics.promMetrics.ClientPollTotal.With(prometheus.Labels{"nat": offer.natType, "status": "matched"}).Inc() - i.ctx.metrics.lock.Unlock() - resp := &messages.ClientPollResponse{Answer: answer} - err = sendClientResponse(resp, response) - // Initial tracking of elapsed time. - i.ctx.metrics.clientRoundtripEstimate = time.Since(startTime) / time.Millisecond - case <-time.After(time.Second * ClientTimeout): - log.Println("Client: Timed out.") - resp := &messages.ClientPollResponse{Error: messages.StrTimedOut} - err = sendClientResponse(resp, response) - } - - i.ctx.snowflakeLock.Lock() - i.ctx.metrics.promMetrics.AvailableProxies.With(prometheus.Labels{"nat": snowflake.natType, "type": snowflake.proxyType}).Dec() - delete(i.ctx.idToSnowflake, snowflake.id) - i.ctx.snowflakeLock.Unlock() - - return err -} - -func (i *IPC) matchSnowflake(natType string) *Snowflake { - // Only hand out known restricted snowflakes to unrestricted clients - var snowflakeHeap *SnowflakeHeap - if natType == NATUnrestricted { - snowflakeHeap = i.ctx.restrictedSnowflakes - } else { - snowflakeHeap = i.ctx.snowflakes - } - - i.ctx.snowflakeLock.Lock() - defer i.ctx.snowflakeLock.Unlock() - - if snowflakeHeap.Len() > 0 { - return heap.Pop(snowflakeHeap).(*Snowflake) - } else { - return nil - } -} - -func (i *IPC) ProxyAnswers(arg messages.Arg, response *[]byte) error { - answer, id, err := messages.DecodeAnswerRequest(arg.Body) - if err != nil || answer == "" { - return messages.ErrBadRequest - } - - var success = true - i.ctx.snowflakeLock.Lock() - snowflake, ok := i.ctx.idToSnowflake[id] - i.ctx.snowflakeLock.Unlock() - if !ok || snowflake == nil { - // The snowflake took too long to respond with an answer, so its client - // disappeared / the snowflake is no longer recognized by the Broker. - success = false - } - - b, err := messages.EncodeAnswerResponse(success) - if err != nil { - log.Printf("Error encoding answer: %s", err.Error()) - return messages.ErrInternal - } - *response = b - - if success { - snowflake.answerChannel <- answer - } - - return nil -} diff --git a/broker/metrics.go b/broker/metrics.go index 77b7675..15a4a88 100644 --- a/broker/metrics.go +++ b/broker/metrics.go @@ -1,93 +1,84 @@ /* -We export metrics in the format specified in our broker spec: -https://gitweb.torproject.org/pluggable-transports/snowflake.git/tree/doc/broker-spec.txt +We export metrics in the following format: + + "snowflake-stats-end" YYYY-MM-DD HH:MM:SS (NSEC s) NL + [At most once.] + + YYYY-MM-DD HH:MM:SS defines the end of the included measurement + interval of length NSEC seconds (86400 seconds by default). + + "snowflake-ips" CC=NUM,CC=NUM,... NL + [At most once.] + + List of mappings from two-letter country codes to the number of + unique IP addresses of snowflake proxies that have polled. + + "snowflake-ips-total" NUM NL + [At most once.] + + A count of the total number of unique IP addresses of snowflake + proxies that have polled. + + "snowflake-idle-count" NUM NL + [At most once.] + + A count of the number of times a proxy has polled but received + no client offer, rounded up to the nearest multiple of 8. + + "client-denied-count" NUM NL + [At most once.] + + A count of the number of times a client has requested a proxy + from the broker but no proxies were available, rounded up to + the nearest multiple of 8. + + "client-snowflake-match-count" NUM NL + [At most once.] + + A count of the number of times a client successfully received a + proxy from the broker, rounded up to the nearest multiple of 8. */ package main import ( + // "golang.org/x/net/internal/timeseries" "fmt" "log" "math" "net" - "sort" "sync" "time" - - "git.torproject.org/pluggable-transports/snowflake.git/v2/common/ipsetsink/sinkcluster" - "git.torproject.org/pluggable-transports/snowflake.git/v2/common/messages" - "github.com/prometheus/client_golang/prometheus" - "gitlab.torproject.org/tpo/anti-censorship/geoip" ) -const ( - prometheusNamespace = "snowflake" - metricsResolution = 60 * 60 * 24 * time.Second //86400 seconds +var ( + once sync.Once ) +const metricsResolution = 60 * 60 * 24 * time.Second //86400 seconds + type CountryStats struct { - // map[proxyType][address]bool - proxies map[string]map[string]bool - unknown map[string]bool - - natRestricted map[string]bool - natUnrestricted map[string]bool - natUnknown map[string]bool - + addrs map[string]bool counts map[string]int } // Implements Observable type Metrics struct { logger *log.Logger - geoipdb *geoip.Geoip + tablev4 *GeoIPv4Table + tablev6 *GeoIPv6Table - distinctIPWriter *sinkcluster.ClusterWriter - - countryStats CountryStats - clientRoundtripEstimate time.Duration - proxyIdleCount uint - clientDeniedCount uint - clientRestrictedDeniedCount uint - clientUnrestrictedDeniedCount uint - clientProxyMatchCount uint - - proxyPollWithRelayURLExtension uint - proxyPollWithoutRelayURLExtension uint - proxyPollRejectedWithRelayURLExtension uint - - // synchronization for access to snowflake metrics - lock sync.Mutex - - promMetrics *PromMetrics -} - -type record struct { - cc string - count int -} -type records []record - -func (r records) Len() int { return len(r) } -func (r records) Swap(i, j int) { r[i], r[j] = r[j], r[i] } -func (r records) Less(i, j int) bool { - if r[i].count == r[j].count { - return r[i].cc > r[j].cc - } - return r[i].count < r[j].count + countryStats CountryStats + clientRoundtripEstimate time.Duration + proxyIdleCount uint + clientDeniedCount uint + clientProxyMatchCount uint } func (s CountryStats) Display() string { output := "" - - // Use the records struct to sort our counts map by value. - rs := records{} for cc, count := range s.counts { - rs = append(rs, record{cc: cc, count: count}) - } - sort.Sort(sort.Reverse(rs)) - for _, r := range rs { - output += fmt.Sprintf("%s=%d,", r.cc, r.count) + output += fmt.Sprintf("%s=%d,", cc, count) } // cut off trailing "," @@ -98,80 +89,73 @@ func (s CountryStats) Display() string { return output } -func (m *Metrics) UpdateCountryStats(addr string, proxyType string, natType string) { +func (m *Metrics) UpdateCountryStats(addr string) { var country string var ok bool - addresses, ok := m.countryStats.proxies[proxyType] - if !ok { - if m.countryStats.unknown[addr] { - return - } - m.countryStats.unknown[addr] = true - } else { - if addresses[addr] { - return - } - addresses[addr] = true + if m.countryStats.addrs[addr] { + return } ip := net.ParseIP(addr) - if m.geoipdb == nil { - return + if ip.To4() != nil { + //This is an IPv4 address + if m.tablev4 == nil { + return + } + country, ok = GetCountryByAddr(m.tablev4, ip) + } else { + if m.tablev6 == nil { + return + } + country, ok = GetCountryByAddr(m.tablev6, ip) } - country, ok = m.geoipdb.GetCountryByAddr(ip) + if !ok { country = "??" } + + //update map of unique ips and counts m.countryStats.counts[country]++ - - m.promMetrics.ProxyTotal.With(prometheus.Labels{ - "nat": natType, - "type": proxyType, - "cc": country, - }).Inc() - - switch natType { - case NATRestricted: - m.countryStats.natRestricted[addr] = true - case NATUnrestricted: - m.countryStats.natUnrestricted[addr] = true - default: - m.countryStats.natUnknown[addr] = true - } + m.countryStats.addrs[addr] = true } func (m *Metrics) LoadGeoipDatabases(geoipDB string, geoip6DB string) error { // Load geoip databases - var err error log.Println("Loading geoip databases") - m.geoipdb, err = geoip.New(geoipDB, geoip6DB) - return err + tablev4 := new(GeoIPv4Table) + err := GeoIPLoadFile(tablev4, geoipDB) + if err != nil { + m.tablev4 = nil + return err + } + m.tablev4 = tablev4 + + tablev6 := new(GeoIPv6Table) + err = GeoIPLoadFile(tablev6, geoip6DB) + if err != nil { + m.tablev6 = nil + return err + } + m.tablev6 = tablev6 + return nil } func NewMetrics(metricsLogger *log.Logger) (*Metrics, error) { m := new(Metrics) m.countryStats = CountryStats{ - counts: make(map[string]int), - proxies: make(map[string]map[string]bool), - unknown: make(map[string]bool), - natRestricted: make(map[string]bool), - natUnrestricted: make(map[string]bool), - natUnknown: make(map[string]bool), - } - for pType := range messages.KnownProxyTypes { - m.countryStats.proxies[pType] = make(map[string]bool) + counts: make(map[string]int), + addrs: make(map[string]bool), } m.logger = metricsLogger - m.promMetrics = initPrometheus() - // Write to log file every day with updated metrics - go m.logMetrics() + // Write to log file every hour with updated metrics + go once.Do(m.logMetrics) return m, nil } @@ -186,154 +170,24 @@ func (m *Metrics) logMetrics() { } func (m *Metrics) printMetrics() { - m.lock.Lock() m.logger.Println("snowflake-stats-end", time.Now().UTC().Format("2006-01-02 15:04:05"), fmt.Sprintf("(%d s)", int(metricsResolution.Seconds()))) m.logger.Println("snowflake-ips", m.countryStats.Display()) - total := len(m.countryStats.unknown) - for pType, addresses := range m.countryStats.proxies { - m.logger.Printf("snowflake-ips-%s %d\n", pType, len(addresses)) - total += len(addresses) - } - m.logger.Println("snowflake-ips-total", total) + m.logger.Println("snowflake-ips-total", len(m.countryStats.addrs)) m.logger.Println("snowflake-idle-count", binCount(m.proxyIdleCount)) - m.logger.Println("snowflake-proxy-poll-with-relay-url-count", binCount(m.proxyPollWithRelayURLExtension)) - m.logger.Println("snowflake-proxy-poll-without-relay-url-count", binCount(m.proxyPollWithoutRelayURLExtension)) - m.logger.Println("snowflake-proxy-rejected-for-relay-url-count", binCount(m.proxyPollRejectedWithRelayURLExtension)) m.logger.Println("client-denied-count", binCount(m.clientDeniedCount)) - m.logger.Println("client-restricted-denied-count", binCount(m.clientRestrictedDeniedCount)) - m.logger.Println("client-unrestricted-denied-count", binCount(m.clientUnrestrictedDeniedCount)) m.logger.Println("client-snowflake-match-count", binCount(m.clientProxyMatchCount)) - m.logger.Println("snowflake-ips-nat-restricted", len(m.countryStats.natRestricted)) - m.logger.Println("snowflake-ips-nat-unrestricted", len(m.countryStats.natUnrestricted)) - m.logger.Println("snowflake-ips-nat-unknown", len(m.countryStats.natUnknown)) - m.lock.Unlock() } // Restores all metrics to original values func (m *Metrics) zeroMetrics() { m.proxyIdleCount = 0 m.clientDeniedCount = 0 - m.clientRestrictedDeniedCount = 0 - m.clientUnrestrictedDeniedCount = 0 - m.proxyPollRejectedWithRelayURLExtension = 0 - m.proxyPollWithRelayURLExtension = 0 - m.proxyPollWithoutRelayURLExtension = 0 m.clientProxyMatchCount = 0 m.countryStats.counts = make(map[string]int) - for pType := range m.countryStats.proxies { - m.countryStats.proxies[pType] = make(map[string]bool) - } - m.countryStats.unknown = make(map[string]bool) - m.countryStats.natRestricted = make(map[string]bool) - m.countryStats.natUnrestricted = make(map[string]bool) - m.countryStats.natUnknown = make(map[string]bool) + m.countryStats.addrs = make(map[string]bool) } // Rounds up a count to the nearest multiple of 8. func binCount(count uint) uint { return uint((math.Ceil(float64(count) / 8)) * 8) } - -type PromMetrics struct { - registry *prometheus.Registry - ProxyTotal *prometheus.CounterVec - ProxyPollTotal *RoundedCounterVec - ClientPollTotal *RoundedCounterVec - AvailableProxies *prometheus.GaugeVec - - ProxyPollWithRelayURLExtensionTotal *RoundedCounterVec - ProxyPollWithoutRelayURLExtensionTotal *RoundedCounterVec - - ProxyPollRejectedForRelayURLExtensionTotal *RoundedCounterVec -} - -// Initialize metrics for prometheus exporter -func initPrometheus() *PromMetrics { - promMetrics := &PromMetrics{} - - promMetrics.registry = prometheus.NewRegistry() - - promMetrics.ProxyTotal = prometheus.NewCounterVec( - prometheus.CounterOpts{ - Namespace: prometheusNamespace, - Name: "proxy_total", - Help: "The number of unique snowflake IPs", - }, - []string{"type", "nat", "cc"}, - ) - - promMetrics.AvailableProxies = prometheus.NewGaugeVec( - prometheus.GaugeOpts{ - Namespace: prometheusNamespace, - Name: "available_proxies", - Help: "The number of currently available snowflake proxies", - }, - []string{"type", "nat"}, - ) - - promMetrics.ProxyPollTotal = NewRoundedCounterVec( - prometheus.CounterOpts{ - Namespace: prometheusNamespace, - Name: "rounded_proxy_poll_total", - Help: "The number of snowflake proxy polls, rounded up to a multiple of 8", - }, - []string{"nat", "status"}, - ) - - promMetrics.ProxyPollWithRelayURLExtensionTotal = NewRoundedCounterVec( - prometheus.CounterOpts{ - Namespace: prometheusNamespace, - Name: "rounded_proxy_poll_with_relay_url_extension_total", - Help: "The number of snowflake proxy polls with Relay URL Extension, rounded up to a multiple of 8", - }, - []string{"nat", "type"}, - ) - - promMetrics.ProxyPollWithoutRelayURLExtensionTotal = NewRoundedCounterVec( - prometheus.CounterOpts{ - Namespace: prometheusNamespace, - Name: "rounded_proxy_poll_without_relay_url_extension_total", - Help: "The number of snowflake proxy polls without Relay URL Extension, rounded up to a multiple of 8", - }, - []string{"nat", "type"}, - ) - - promMetrics.ProxyPollRejectedForRelayURLExtensionTotal = NewRoundedCounterVec( - prometheus.CounterOpts{ - Namespace: prometheusNamespace, - Name: "rounded_proxy_poll_rejected_relay_url_extension_total", - Help: "The number of snowflake proxy polls rejected by Relay URL Extension, rounded up to a multiple of 8", - }, - []string{"nat", "type"}, - ) - - promMetrics.ClientPollTotal = NewRoundedCounterVec( - prometheus.CounterOpts{ - Namespace: prometheusNamespace, - Name: "rounded_client_poll_total", - Help: "The number of snowflake client polls, rounded up to a multiple of 8", - }, - []string{"nat", "status"}, - ) - - // We need to register our metrics so they can be exported. - promMetrics.registry.MustRegister( - promMetrics.ClientPollTotal, promMetrics.ProxyPollTotal, - promMetrics.ProxyTotal, promMetrics.AvailableProxies, - promMetrics.ProxyPollWithRelayURLExtensionTotal, - promMetrics.ProxyPollWithoutRelayURLExtensionTotal, - promMetrics.ProxyPollRejectedForRelayURLExtensionTotal, - ) - - return promMetrics -} - -func (m *Metrics) RecordIPAddress(ip string) { - if m.distinctIPWriter != nil { - m.distinctIPWriter.AddIPToSet(ip) - } -} - -func (m *Metrics) SetIPAddressRecorder(recorder *sinkcluster.ClusterWriter) { - m.distinctIPWriter = recorder -} diff --git a/broker/prometheus.go b/broker/prometheus.go deleted file mode 100644 index d7592ec..0000000 --- a/broker/prometheus.go +++ /dev/null @@ -1,83 +0,0 @@ -/* -Implements some additional prometheus metrics that we need for privacy preserving -counts of users and proxies -*/ - -package main - -import ( - "sync/atomic" - - "github.com/prometheus/client_golang/prometheus" - dto "github.com/prometheus/client_model/go" - "google.golang.org/protobuf/proto" -) - -// New Prometheus counter type that produces rounded counts of metrics -// for privacy preserving reasons -type RoundedCounter interface { - prometheus.Metric - - Inc() -} - -type roundedCounter struct { - total uint64 //reflects the true count - value uint64 //reflects the rounded count - - desc *prometheus.Desc - labelPairs []*dto.LabelPair -} - -// Implements the RoundedCounter interface -func (c *roundedCounter) Inc() { - atomic.AddUint64(&c.total, 1) - if c.total > c.value { - atomic.AddUint64(&c.value, 8) - } -} - -// Implements the prometheus.Metric interface -func (c *roundedCounter) Desc() *prometheus.Desc { - return c.desc -} - -// Implements the prometheus.Metric interface -func (c *roundedCounter) Write(m *dto.Metric) error { - m.Label = c.labelPairs - - m.Counter = &dto.Counter{Value: proto.Float64(float64(c.value))} - return nil -} - -// New prometheus vector type that will track RoundedCounter metrics -// accross multiple labels -type RoundedCounterVec struct { - *prometheus.MetricVec -} - -func NewRoundedCounterVec(opts prometheus.CounterOpts, labelNames []string) *RoundedCounterVec { - desc := prometheus.NewDesc( - prometheus.BuildFQName(opts.Namespace, opts.Subsystem, opts.Name), - opts.Help, - labelNames, - opts.ConstLabels, - ) - return &RoundedCounterVec{ - MetricVec: prometheus.NewMetricVec(desc, func(lvs ...string) prometheus.Metric { - if len(lvs) != len(labelNames) { - panic("inconsistent cardinality") - } - return &roundedCounter{desc: desc, labelPairs: prometheus.MakeLabelPairs(desc, lvs)} - }), - } -} - -// Helper function to return the underlying RoundedCounter metric from MetricVec -func (v *RoundedCounterVec) With(labels prometheus.Labels) RoundedCounter { - metric, err := v.GetMetricWith(labels) - if err != nil { - panic(err) - } - return metric.(RoundedCounter) -} diff --git a/broker/snowflake-broker_test.go b/broker/snowflake-broker_test.go index a72f3ac..4c78ecd 100644 --- a/broker/snowflake-broker_test.go +++ b/broker/snowflake-broker_test.go @@ -3,18 +3,15 @@ package main import ( "bytes" "container/heap" - "encoding/hex" - "io" "io/ioutil" "log" + "net" "net/http" "net/http/httptest" "os" - "sync" "testing" "time" - "git.torproject.org/pluggable-transports/snowflake.git/v2/common/amp" . "github.com/smartystreets/goconvey/convey" ) @@ -24,31 +21,15 @@ func NullLogger() *log.Logger { return logger } -var promOnce sync.Once - -func decodeAMPArmorToString(r io.Reader) (string, error) { - dec, err := amp.NewArmorDecoder(r) - if err != nil { - return "", err - } - p, err := ioutil.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() { ctx := NewBrokerContext(NullLogger()) - i := &IPC{ctx} Convey("Adds Snowflake", func() { So(ctx.snowflakes.Len(), ShouldEqual, 0) So(len(ctx.idToSnowflake), ShouldEqual, 0) - ctx.AddSnowflake("foo", "", NATUnrestricted, 0) + ctx.AddSnowflake("foo") So(ctx.snowflakes.Len(), ShouldEqual, 1) So(len(ctx.idToSnowflake), ShouldEqual, 1) }) @@ -56,8 +37,7 @@ func TestBroker(t *testing.T) { Convey("Broker goroutine matches clients with proxies", func() { p := new(ProxyPoll) p.id = "test" - p.natType = "unrestricted" - p.offerChannel = make(chan *ClientOffer) + p.offerChannel = make(chan []byte) go func(ctx *BrokerContext) { ctx.proxyPolls <- p close(ctx.proxyPolls) @@ -65,82 +45,33 @@ func TestBroker(t *testing.T) { ctx.Broker() So(ctx.snowflakes.Len(), ShouldEqual, 1) snowflake := heap.Pop(ctx.snowflakes).(*Snowflake) - snowflake.offerChannel <- &ClientOffer{sdp: []byte("test offer")} + snowflake.offerChannel <- []byte("test offer") offer := <-p.offerChannel So(ctx.idToSnowflake["test"], ShouldNotBeNil) - So(offer.sdp, ShouldResemble, []byte("test offer")) + So(offer, ShouldResemble, []byte("test offer")) So(ctx.snowflakes.Len(), ShouldEqual, 0) }) Convey("Request an offer from the Snowflake Heap", func() { - done := make(chan *ClientOffer) + done := make(chan []byte) go func() { - offer := ctx.RequestOffer("test", "", NATUnrestricted, 0) + offer := ctx.RequestOffer("test") done <- offer }() request := <-ctx.proxyPolls - request.offerChannel <- &ClientOffer{sdp: []byte("test offer")} + request.offerChannel <- []byte("test offer") offer := <-done - So(offer.sdp, ShouldResemble, []byte("test offer")) + So(offer, ShouldResemble, []byte("test offer")) }) - Convey("Responds to HTTP client offers...", func() { + Convey("Responds to client offers...", func() { w := httptest.NewRecorder() - data := bytes.NewReader( - []byte("1.0\n{\"offer\": \"fake\", \"nat\": \"unknown\"}")) + data := bytes.NewReader([]byte("test")) 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"}`) - }) - - 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("fake")) - snowflake.answerChannel <- "fake answer" - <-done - So(w.Body.String(), ShouldEqual, `{"answer":"fake answer"}`) - So(w.Code, ShouldEqual, http.StatusOK) - }) - - 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("fake")) - <-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() - data := bytes.NewReader([]byte("{test}")) - r, err := http.NewRequest("POST", "snowflake.broker/client", data) - So(err, ShouldBeNil) - r.Header.Set("Snowflake-NAT-TYPE", "restricted") - Convey("with 503 when no snowflakes are available.", func() { - clientOffers(i, w, r) + clientOffers(ctx, w, r) So(w.Code, ShouldEqual, http.StatusServiceUnavailable) So(w.Body.String(), ShouldEqual, "") }) @@ -148,14 +79,14 @@ func TestBroker(t *testing.T) { 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) + snowflake := ctx.AddSnowflake("fake") go func() { - clientOffers(i, w, r) + clientOffers(ctx, w, r) done <- true }() offer := <-snowflake.offerChannel - So(offer.sdp, ShouldResemble, []byte("{test}")) - snowflake.answerChannel <- "fake answer" + So(offer, ShouldResemble, []byte("test")) + snowflake.answerChannel <- []byte("fake answer") <-done So(w.Body.String(), ShouldEqual, "fake answer") So(w.Code, ShouldEqual, http.StatusOK) @@ -166,271 +97,154 @@ func TestBroker(t *testing.T) { return } done := make(chan bool) - snowflake := ctx.AddSnowflake("fake", "", NATUnrestricted, 0) + snowflake := ctx.AddSnowflake("fake") go func() { - clientOffers(i, w, r) + clientOffers(ctx, w, r) // Takes a few seconds here... done <- true }() offer := <-snowflake.offerChannel - So(offer.sdp, ShouldResemble, []byte("{test}")) + So(offer, ShouldResemble, []byte("test")) <-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"}`) - }) - - 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) - }) - - 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("Responds to proxy polls...", func() { done := make(chan bool) w := httptest.NewRecorder() - data := bytes.NewReader([]byte(`{"Sid":"ymbcCMto7KHNGYlp","Version":"1.0"}`)) + data := bytes.NewReader([]byte("test")) r, err := http.NewRequest("POST", "snowflake.broker/proxy", data) + r.Header.Set("X-Session-ID", "test") So(err, ShouldBeNil) Convey("with a client offer if available.", func() { - go func(i *IPC) { - proxyPolls(i, w, r) + go func(ctx *BrokerContext) { + proxyPolls(ctx, w, r) done <- true - }(i) + }(ctx) // 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[:]} + So(p.id, ShouldEqual, "test") + p.offerChannel <- []byte("fake offer") <-done So(w.Code, ShouldEqual, http.StatusOK) - So(w.Body.String(), ShouldEqual, `{"Status":"client match","Offer":"fake offer","NAT":"","RelayURL":"wss://snowflake.torproject.net/"}`) + So(w.Body.String(), ShouldEqual, "fake offer") }) - Convey("return empty 200 OK when no client offer is available.", func() { - go func(i *IPC) { - proxyPolls(i, w, r) + Convey("times out when no client offer is available.", func() { + go func(ctx *BrokerContext) { + proxyPolls(ctx, w, r) done <- true - }(i) + }(ctx) p := <-ctx.proxyPolls - So(p.id, ShouldEqual, "ymbcCMto7KHNGYlp") + So(p.id, ShouldEqual, "test") // nil means timeout p.offerChannel <- nil <-done - So(w.Body.String(), ShouldEqual, `{"Status":"no match","Offer":"","NAT":"","RelayURL":""}`) - So(w.Code, ShouldEqual, http.StatusOK) + So(w.Body.String(), ShouldEqual, "") + So(w.Code, ShouldEqual, http.StatusGatewayTimeout) }) }) Convey("Responds to proxy answers...", func() { - done := make(chan bool) - s := ctx.AddSnowflake("test", "", NATUnrestricted, 0) + s := ctx.AddSnowflake("test") w := httptest.NewRecorder() - data := bytes.NewReader([]byte(`{"Version":"1.0","Sid":"test","Answer":"test"}`)) + data := bytes.NewReader([]byte("fake answer")) 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) + r.Header.Set("X-Session-ID", "test") + go func(ctx *BrokerContext) { + proxyAnswers(ctx, w, r) + }(ctx) answer := <-s.answerChannel - <-done So(w.Code, ShouldEqual, http.StatusOK) - So(answer, ShouldResemble, "test") + So(answer, ShouldResemble, []byte("fake answer")) }) - Convey("with client gone status if the proxy is not recognized", func() { - data = bytes.NewReader([]byte(`{"Version":"1.0","Sid":"invalid","Answer":"test"}`)) - r, err := http.NewRequest("POST", "snowflake.broker/answer", data) + Convey("with error if the proxy is not recognized", func() { + r, err := http.NewRequest("POST", "snowflake.broker/answer", nil) So(err, ShouldBeNil) - proxyAnswers(i, w, r) - So(w.Code, ShouldEqual, http.StatusOK) - b, err := ioutil.ReadAll(w.Body) - So(err, ShouldBeNil) - So(b, ShouldResemble, []byte(`{"Status":"client gone"}`)) - + r.Header.Set("X-Session-ID", "invalid") + proxyAnswers(ctx, w, r) + So(w.Code, ShouldEqual, http.StatusGone) }) Convey("with error if the proxy gives invalid answer", func() { data := bytes.NewReader(nil) r, err := http.NewRequest("POST", "snowflake.broker/answer", data) + r.Header.Set("X-Session-ID", "test") So(err, ShouldBeNil) - proxyAnswers(i, w, r) + proxyAnswers(ctx, 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) + r.Header.Set("X-Session-ID", "test") So(err, ShouldBeNil) - proxyAnswers(i, w, r) + proxyAnswers(ctx, w, r) So(w.Code, ShouldEqual, http.StatusBadRequest) }) }) - }) Convey("End-To-End", t, func() { + done := make(chan bool) + polled := make(chan bool) ctx := NewBrokerContext(NullLogger()) - i := &IPC{ctx} - Convey("Check for client/proxy data race", func() { - proxy_done := make(chan bool) - client_done := make(chan bool) + // Proxy polls with its ID first... + dataP := bytes.NewReader([]byte("test")) + wP := httptest.NewRecorder() + rP, err := http.NewRequest("POST", "snowflake.broker/proxy", dataP) + So(err, ShouldBeNil) + rP.Header.Set("X-Session-ID", "test") + go func() { + proxyPolls(ctx, wP, rP) + polled <- true + }() - go ctx.Broker() + // Manually do the Broker goroutine action here for full control. + p := <-ctx.proxyPolls + So(p.id, ShouldEqual, "test") + s := ctx.AddSnowflake(p.id) + go func() { + offer := <-s.offerChannel + p.offerChannel <- offer + }() + So(ctx.idToSnowflake["test"], ShouldNotBeNil) - // Make proxy poll - wp := httptest.NewRecorder() - datap := bytes.NewReader([]byte(`{"Sid":"ymbcCMto7KHNGYlp","Version":"1.0"}`)) - rp, err := http.NewRequest("POST", "snowflake.broker/proxy", datap) - So(err, ShouldBeNil) + // Client request blocks until proxy answer arrives. + dataC := bytes.NewReader([]byte("fake offer")) + wC := httptest.NewRecorder() + rC, err := http.NewRequest("POST", "snowflake.broker/client", dataC) + So(err, ShouldBeNil) + go func() { + clientOffers(ctx, wC, rC) + done <- true + }() - go func(i *IPC) { - proxyPolls(i, wp, rp) - proxy_done <- true - }(i) + <-polled + So(wP.Code, ShouldEqual, http.StatusOK) + So(wP.Body.String(), ShouldResemble, "fake offer") + So(ctx.idToSnowflake["test"], ShouldNotBeNil) + // Follow up with the answer request afterwards + wA := httptest.NewRecorder() + dataA := bytes.NewReader([]byte("fake answer")) + rA, err := http.NewRequest("POST", "snowflake.broker/proxy", dataA) + So(err, ShouldBeNil) + rA.Header.Set("X-Session-ID", "test") + proxyAnswers(ctx, wA, rA) + So(wA.Code, ShouldEqual, http.StatusOK) - // Client offer - wc := httptest.NewRecorder() - datac := bytes.NewReader([]byte("test")) - 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 = bytes.NewReader([]byte(`{"Version":"1.0","Sid":"ymbcCMto7KHNGYlp","Answer":"test"}`)) - 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"}`)) - 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. - dataC := bytes.NewReader( - []byte("1.0\n{\"offer\": \"fake\", \"nat\": \"unknown\"}")) - wC := httptest.NewRecorder() - 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, `{"Status":"client match","Offer":"fake","NAT":"unknown","RelayURL":"wss://snowflake.torproject.net/"}`) - So(ctx.idToSnowflake["ymbcCMto7KHNGYlp"], ShouldNotBeNil) - // Follow up with the answer request afterwards - wA := httptest.NewRecorder() - dataA := bytes.NewReader([]byte(`{"Version":"1.0","Sid":"ymbcCMto7KHNGYlp","Answer":"test"}`)) - 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, `{"answer":"test"}`) - }) + <-done + So(wC.Code, ShouldEqual, http.StatusOK) + So(wC.Body.String(), ShouldEqual, "fake answer") }) } @@ -477,25 +291,116 @@ func TestSnowflakeHeap(t *testing.T) { }) } -func TestInvalidGeoipFile(t *testing.T) { +func TestGeoip(t *testing.T) { Convey("Geoip", t, func() { + tv4 := new(GeoIPv4Table) + err := GeoIPLoadFile(tv4, "test_geoip") + So(err, ShouldEqual, nil) + tv6 := new(GeoIPv6Table) + err = GeoIPLoadFile(tv6, "test_geoip6") + So(err, ShouldEqual, nil) + + Convey("IPv4 Country Mapping Tests", func() { + for _, test := range []struct { + addr, cc string + ok bool + }{ + { + "129.97.208.23", //uwaterloo + "CA", + true, + }, + { + "127.0.0.1", + "", + false, + }, + { + "255.255.255.255", + "", + false, + }, + { + "0.0.0.0", + "", + false, + }, + { + "223.252.127.255", //test high end of range + "JP", + true, + }, + { + "223.252.127.255", //test low end of range + "JP", + true, + }, + } { + country, ok := GetCountryByAddr(tv4, net.ParseIP(test.addr)) + So(country, ShouldEqual, test.cc) + So(ok, ShouldResemble, test.ok) + } + }) + + Convey("IPv6 Country Mapping Tests", func() { + for _, test := range []struct { + addr, cc string + ok bool + }{ + { + "2620:101:f000:0:250:56ff:fe80:168e", //uwaterloo + "CA", + true, + }, + { + "fd00:0:0:0:0:0:0:1", + "", + false, + }, + { + "0:0:0:0:0:0:0:0", + "", + false, + }, + { + "ffff:ffff:ffff:ffff:ffff:ffff:ffff:ffff", + "", + false, + }, + { + "2a07:2e47:ffff:ffff:ffff:ffff:ffff:ffff", //test high end of range + "FR", + true, + }, + { + "2a07:2e40::", //test low end of range + "FR", + true, + }, + } { + country, ok := GetCountryByAddr(tv6, net.ParseIP(test.addr)) + So(country, ShouldEqual, test.cc) + So(ok, ShouldResemble, test.ok) + } + }) + // 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.UpdateCountryStats("127.0.0.1", "", NATUnrestricted) - So(ctx.metrics.geoipdb, ShouldEqual, nil) + ctx.metrics.UpdateCountryStats("127.0.0.1") + So(ctx.metrics.tablev4, ShouldEqual, nil) }) } func TestMetrics(t *testing.T) { + Convey("Test metrics...", t, func() { done := make(chan bool) buf := new(bytes.Buffer) ctx := NewBrokerContext(log.New(buf, "", 0)) - i := &IPC{ctx} err := ctx.metrics.LoadGeoipDatabases("test_geoip", "test_geoip6") So(err, ShouldEqual, nil) @@ -503,299 +408,120 @@ func TestMetrics(t *testing.T) { //Test addition of proxy polls Convey("for proxy polls", func() { w := httptest.NewRecorder() - data := bytes.NewReader([]byte("{\"Sid\":\"ymbcCMto7KHNGYlp\",\"Version\":\"1.0\"}")) + data := bytes.NewReader([]byte("test")) r, err := http.NewRequest("POST", "snowflake.broker/proxy", data) + r.Header.Set("X-Session-ID", "test") r.RemoteAddr = "129.97.208.23:8888" //CA geoip So(err, ShouldBeNil) - go func(i *IPC) { - proxyPolls(i, w, r) + go func(ctx *BrokerContext) { + proxyPolls(ctx, w, r) done <- true - }(i) + }(ctx) p := <-ctx.proxyPolls //manually unblock poll p.offerChannel <- nil <-done - w = httptest.NewRecorder() - data = bytes.NewReader([]byte(`{"Sid":"ymbcCMto7KHNGYlp","Version":"1.0","Type":"standalone"}`)) - 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 - - w = httptest.NewRecorder() - data = bytes.NewReader([]byte(`{"Sid":"ymbcCMto7KHNGYlp","Version":"1.0","Type":"badge"}`)) - 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 - - w = httptest.NewRecorder() - data = bytes.NewReader([]byte(`{"Sid":"ymbcCMto7KHNGYlp","Version":"1.0","Type":"webext"}`)) - 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 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, "\nsnowflake-ips-total 4\nsnowflake-idle-count 8\nsnowflake-proxy-poll-with-relay-url-count 0\nsnowflake-proxy-poll-without-relay-url-count 8\nsnowflake-proxy-rejected-for-relay-url-count 0\nclient-denied-count 0\nclient-restricted-denied-count 0\nclient-unrestricted-denied-count 0\nclient-snowflake-match-count 0\nsnowflake-ips-nat-restricted 0\nsnowflake-ips-nat-unrestricted 0\nsnowflake-ips-nat-unknown 1\n") + So(buf.String(), ShouldResemble, "snowflake-stats-end "+time.Now().UTC().Format("2006-01-02 15:04:05")+" (86400 s)\nsnowflake-ips CA=1\nsnowflake-ips-total 1\nsnowflake-idle-count 8\nclient-denied-count 0\nclient-snowflake-match-count 0\n") }) //Test addition of client failures Convey("for no proxies available", func() { w := httptest.NewRecorder() - data := bytes.NewReader( - []byte("1.0\n{\"offer\": \"fake\", \"nat\": \"unknown\"}")) + data := bytes.NewReader([]byte("test")) r, err := http.NewRequest("POST", "snowflake.broker/client", data) So(err, ShouldBeNil) - clientOffers(i, w, r) + clientOffers(ctx, 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") + So(buf.String(), ShouldResemble, "snowflake-stats-end "+time.Now().UTC().Format("2006-01-02 15:04:05")+" (86400 s)\nsnowflake-ips \nsnowflake-ips-total 0\nsnowflake-idle-count 0\nclient-denied-count 8\nclient-snowflake-match-count 0\n") // Test reset buf.Reset() ctx.metrics.zeroMetrics() 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, "\nsnowflake-ips-total 0\nsnowflake-idle-count 0\nsnowflake-proxy-poll-with-relay-url-count 0\nsnowflake-proxy-poll-without-relay-url-count 0\nsnowflake-proxy-rejected-for-relay-url-count 0\nclient-denied-count 0\nclient-restricted-denied-count 0\nclient-unrestricted-denied-count 0\nclient-snowflake-match-count 0\nsnowflake-ips-nat-restricted 0\nsnowflake-ips-nat-unrestricted 0\nsnowflake-ips-nat-unknown 0\n") + So(buf.String(), ShouldResemble, "snowflake-stats-end "+time.Now().UTC().Format("2006-01-02 15:04:05")+" (86400 s)\nsnowflake-ips \nsnowflake-ips-total 0\nsnowflake-idle-count 0\nclient-denied-count 0\nclient-snowflake-match-count 0\n") }) //Test addition of client matches Convey("for client-proxy match", func() { w := httptest.NewRecorder() - data := bytes.NewReader( - []byte("1.0\n{\"offer\": \"fake\", \"nat\": \"unknown\"}")) + data := bytes.NewReader([]byte("test")) 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) + snowflake := ctx.AddSnowflake("fake") go func() { - clientOffers(i, w, r) + clientOffers(ctx, w, r) done <- true }() offer := <-snowflake.offerChannel - So(offer.sdp, ShouldResemble, []byte("fake")) - snowflake.answerChannel <- "fake answer" + So(offer, ShouldResemble, []byte("test")) + snowflake.answerChannel <- []byte("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") + So(buf.String(), ShouldResemble, "snowflake-stats-end "+time.Now().UTC().Format("2006-01-02 15:04:05")+" (86400 s)\nsnowflake-ips \nsnowflake-ips-total 0\nsnowflake-idle-count 0\nclient-denied-count 0\nclient-snowflake-match-count 8\n") }) //Test rounding boundary Convey("binning boundary", func() { w := httptest.NewRecorder() - data := bytes.NewReader( - []byte("1.0\n{\"offer\": \"fake\", \"nat\": \"restricted\"}")) + data := bytes.NewReader([]byte("test")) r, err := http.NewRequest("POST", "snowflake.broker/client", data) So(err, ShouldBeNil) - clientOffers(i, w, r) - w = httptest.NewRecorder() - data = bytes.NewReader( - []byte("1.0\n{\"offer\": \"fake\", \"nat\": \"restricted\"}")) - r, err = http.NewRequest("POST", "snowflake.broker/client", data) - So(err, ShouldBeNil) - clientOffers(i, w, r) - w = httptest.NewRecorder() - data = bytes.NewReader( - []byte("1.0\n{\"offer\": \"fake\", \"nat\": \"restricted\"}")) - r, err = http.NewRequest("POST", "snowflake.broker/client", data) - So(err, ShouldBeNil) - clientOffers(i, w, r) - w = httptest.NewRecorder() - data = bytes.NewReader( - []byte("1.0\n{\"offer\": \"fake\", \"nat\": \"restricted\"}")) - r, err = http.NewRequest("POST", "snowflake.broker/client", data) - So(err, ShouldBeNil) - clientOffers(i, w, r) - w = httptest.NewRecorder() - data = bytes.NewReader( - []byte("1.0\n{\"offer\": \"fake\", \"nat\": \"restricted\"}")) - r, err = http.NewRequest("POST", "snowflake.broker/client", data) - So(err, ShouldBeNil) - clientOffers(i, w, r) - w = httptest.NewRecorder() - data = bytes.NewReader( - []byte("1.0\n{\"offer\": \"fake\", \"nat\": \"restricted\"}")) - r, err = http.NewRequest("POST", "snowflake.broker/client", data) - So(err, ShouldBeNil) - clientOffers(i, w, r) - w = httptest.NewRecorder() - data = bytes.NewReader( - []byte("1.0\n{\"offer\": \"fake\", \"nat\": \"restricted\"}")) - r, err = http.NewRequest("POST", "snowflake.broker/client", data) - So(err, ShouldBeNil) - clientOffers(i, w, r) - w = httptest.NewRecorder() - data = bytes.NewReader( - []byte("1.0\n{\"offer\": \"fake\", \"nat\": \"restricted\"}")) - r, err = http.NewRequest("POST", "snowflake.broker/client", data) - So(err, ShouldBeNil) - clientOffers(i, w, r) + clientOffers(ctx, w, r) + clientOffers(ctx, w, r) + clientOffers(ctx, w, r) + clientOffers(ctx, w, r) + clientOffers(ctx, w, r) + clientOffers(ctx, w, r) + clientOffers(ctx, w, r) + clientOffers(ctx, w, r) ctx.metrics.printMetrics() - So(buf.String(), ShouldContainSubstring, "client-denied-count 8\nclient-restricted-denied-count 8\nclient-unrestricted-denied-count 0\n") + So(buf.String(), ShouldResemble, "snowflake-stats-end "+time.Now().UTC().Format("2006-01-02 15:04:05")+" (86400 s)\nsnowflake-ips \nsnowflake-ips-total 0\nsnowflake-idle-count 0\nclient-denied-count 8\nclient-snowflake-match-count 0\n") - w = httptest.NewRecorder() - data = bytes.NewReader( - []byte("1.0\n{\"offer\": \"fake\", \"nat\": \"restricted\"}")) - r, err = http.NewRequest("POST", "snowflake.broker/client", data) - So(err, ShouldBeNil) - clientOffers(i, w, r) + clientOffers(ctx, 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") + So(buf.String(), ShouldResemble, "snowflake-stats-end "+time.Now().UTC().Format("2006-01-02 15:04:05")+" (86400 s)\nsnowflake-ips \nsnowflake-ips-total 0\nsnowflake-idle-count 0\nclient-denied-count 16\nclient-snowflake-match-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"}`)) + data := bytes.NewReader([]byte("test")) r, err := http.NewRequest("POST", "snowflake.broker/proxy", data) + r.Header.Set("X-Session-ID", "test") r.RemoteAddr = "129.97.208.23:8888" //CA geoip So(err, ShouldBeNil) - go func(i *IPC) { - proxyPolls(i, w, r) + go func(ctx *BrokerContext) { + proxyPolls(ctx, w, r) done <- true - }(i) + }(ctx) p := <-ctx.proxyPolls //manually unblock poll p.offerChannel <- nil <-done - data = bytes.NewReader([]byte(`{"Sid":"ymbcCMto7KHNGYlp","Version":"1.0"}`)) + data = bytes.NewReader([]byte("test")) r, err = http.NewRequest("POST", "snowflake.broker/proxy", data) if err != nil { log.Printf("unable to get NewRequest with error: %v", err) } + r.Header.Set("X-Session-ID", "test") r.RemoteAddr = "129.97.208.23:8888" //CA geoip - go func(i *IPC) { - proxyPolls(i, w, r) + go func(ctx *BrokerContext) { + proxyPolls(ctx, w, r) done <- true - }(i) + }(ctx) 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"}`)) - 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 - - ctx.metrics.printMetrics() - So(buf.String(), ShouldContainSubstring, "snowflake-ips-nat-restricted 1\nsnowflake-ips-nat-unrestricted 0\nsnowflake-ips-nat-unknown 0") - - data = bytes.NewReader([]byte(`{"Sid":"ymbcCMto7KHNGYlp","Version":"1.2","Type":"unknown","NAT":"unrestricted"}`)) - 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") - }) - //Test client failures by NAT type - Convey("client failures by NAT type", func() { - w := httptest.NewRecorder() - data := bytes.NewReader( - []byte("1.0\n{\"offer\": \"fake\", \"nat\": \"restricted\"}")) - 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() - ctx.metrics.zeroMetrics() - - data = bytes.NewReader( - []byte("1.0\n{\"offer\": \"fake\", \"nat\": \"unrestricted\"}")) - 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() - ctx.metrics.zeroMetrics() - - data = bytes.NewReader( - []byte("1.0\n{\"offer\": \"fake\", \"nat\": \"unknown\"}")) - 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 := map[string]int{ - "IT": 50, - "FR": 200, - "TZ": 100, - "CN": 250, - "RU": 150, - "CA": 1, - "BE": 1, - "PH": 1, - } - ctx.metrics.countryStats.counts = stats - So(ctx.metrics.countryStats.Display(), ShouldEqual, "CN=250,FR=200,RU=150,TZ=100,IT=50,BE=1,CA=1,PH=1") + So(buf.String(), ShouldResemble, "snowflake-stats-end "+time.Now().UTC().Format("2006-01-02 15:04:05")+" (86400 s)\nsnowflake-ips CA=1\nsnowflake-ips-total 1\nsnowflake-idle-count 8\nclient-denied-count 0\nclient-snowflake-match-count 0\n") }) }) } diff --git a/broker/snowflake-heap.go b/broker/snowflake-heap.go index 80c1f57..419956f 100644 --- a/broker/snowflake-heap.go +++ b/broker/snowflake-heap.go @@ -10,10 +10,8 @@ over the offer and answer channels. */ type Snowflake struct { id string - proxyType string - natType string - offerChannel chan *ClientOffer - answerChannel chan string + offerChannel chan []byte + answerChannel chan []byte clients int index int } diff --git a/client/README.md b/client/README.md index c6f4bda..50bdba3 100644 --- a/client/README.md +++ b/client/README.md @@ -1,110 +1,20 @@ - - -**Table of Contents** - -- [Dependencies](#dependencies) -- [Building the Snowflake client](#building-the-snowflake-client) -- [Running the Snowflake client with Tor](#running-the-snowflake-client-with-tor) - - - This is the Tor client component of Snowflake. -It is based on the [goptlib](https://gitweb.torproject.org/pluggable-transports/goptlib.git/) pluggable transports library for Tor. +It is based on goptlib. +### Flags -### Dependencies - -- Go 1.13+ -- We use the [pion/webrtc](https://github.com/pion/webrtc) library for WebRTC communication with Snowflake proxies. Note: running `go get` will fetch this dependency automatically during the build process. - -### Building the Snowflake client - -To build the Snowflake client, make sure you are in the `client/` directory, and then run: - +The client uses these following `torrc` options by default: ``` -go get -go build -``` - -### Running the Snowflake client with Tor - -The Snowflake client can be configured with either command line options or SOCKS options. We have a few example `torrc` files in this directory. We recommend the following `torrc` options by default: -``` -UseBridges 1 - ClientTransportPlugin snowflake exec ./client \ --url https://snowflake-broker.torproject.net.global.prod.fastly.net/ \ --front cdn.sstatic.net \ --ice stun:stun.voip.blackberry.com:3478,stun:stun.altar.com.pl:3478,stun:stun.antisip.com:3478,stun:stun.bluesip.net:3478,stun:stun.dus.net:3478,stun:stun.epygi.com:3478,stun:stun.sonetel.com:3478,stun:stun.sonetel.net:3478,stun:stun.stunprotocol.org:3478,stun:stun.uls.co.za:3478,stun:stun.voipgate.com:3478,stun:stun.voys.nl:3478 - -Bridge snowflake 192.0.2.3:1 +-url https://snowflake-broker.azureedge.net/ \ +-front ajax.aspnetcdn.com \ +-ice stun:stun.l.google.com:19302 ``` -`-url` is the URL of a broker instance. If you would like to try out Snowflake with your own broker, simply provide the URL of your broker instance with this option. +`-url` should be the URL of a Broker instance. -`-front` is an optional front domain for the broker request. +`-front` is an optional front domain for the Broker request. -`-ice` is a comma-separated list of ICE servers. These can be STUN or TURN servers. We recommend using servers that have implemented NAT discovery. See our wiki page on [NAT traversal](https://gitlab.torproject.org/tpo/anti-censorship/pluggable-transports/snowflake/-/wikis/NAT-matching) for more information. - -To bootstrap Tor, run: -``` -tor -f torrc -``` -This should start the client plugin, bootstrapping to 100% using WebRTC. - -### Registration methods - -The Snowflake client supports a few different ways of communicating with the broker. -This initial step is sometimes called rendezvous. - -#### Domain fronting HTTPS - -For domain fronting rendezvous, use the `-url` and `-front` command-line options together. -[Domain fronting](https://www.bamsoftware.com/papers/fronting/) -hides the externally visible domain name from an external observer, -making it appear that the Snowflake client is communicating with some server -other than the Snowflake broker. - -* `-url` is the HTTPS URL of a forwarder to the broker, on some service that supports domain fronting, such as a CDN. -* `-front` is the domain name to show externally. It must be another domain on the same service. - -Example: -``` --url https://snowflake-broker.torproject.net.global.prod.fastly.net/ \ --front cdn.sstatic.net \ -``` - -#### AMP cache - -For AMP cache rendezvous, use the `-url`, `-ampcache`, and `-front` command-line options together. -[AMP](https://amp.dev/documentation/) is a standard for web pages for mobile computers. -An [AMP cache](https://amp.dev/documentation/guides-and-tutorials/learn/amp-caches-and-cors/how_amp_pages_are_cached/) -is a cache and proxy specialized for AMP pages. -The Snowflake broker has the ability to make its client registration responses look like AMP pages, -so it can be accessed through an AMP cache. -When you use AMP cache rendezvous, it appears to an observer that the Snowflake client -is accessing an AMP cache, or some other domain operated by the same organization. -You still need to use the `-front` command-line option, because the -[format of AMP cache URLs](https://amp.dev/documentation/guides-and-tutorials/learn/amp-caches-and-cors/amp-cache-urls/) -would otherwise reveal the domain name of the broker. - -There is only one AMP cache that works with this option, -the Google AMP cache at https://cdn.ampproject.org/. - -* `-url` is the HTTPS URL of the broker. -* `-ampcache` is `https://cdn.ampproject.org/`. -* `-front` is any Google domain, such as `www.google.com`. - -Example: -``` --url https://snowflake-broker.torproject.net/ \ --ampcache https://cdn.ampproject.org/ \ --front www.google.com \ -``` - -#### Direct access - -It is also possible to access the broker directly using HTTPS, without domain fronting, -for testing purposes. This mode is not suitable for circumvention, because the -broker is easily blocked by its address. +`-ice` is a comma-separated list of ICE servers. These can be STUN or TURN +servers. diff --git a/client/lib/interfaces.go b/client/lib/interfaces.go index e8a5cf6..609e610 100644 --- a/client/lib/interfaces.go +++ b/client/lib/interfaces.go @@ -1,24 +1,56 @@ -package snowflake_client +package lib -// Tongue is an interface for catching Snowflakes. (aka the remote dialer) -type Tongue interface { - // Catch makes a connection to a new snowflake. - Catch() (*WebRTCPeer, error) +import ( + "io" + "net" +) - // GetMax returns the maximum number of snowflakes a client can have. - GetMax() int +type Connector interface { + Connect() error } -// SnowflakeCollector is an interface for managing a client's collection of snowflakes. +type Resetter interface { + Reset() + WaitForReset() +} + +// Interface for a single remote WebRTC peer. +// In the Client context, "Snowflake" refers to the remote browser proxy. +type Snowflake interface { + io.ReadWriteCloser + Resetter + Connector +} + +// Interface for catching Snowflakes. (aka the remote dialer) +type Tongue interface { + Catch() (Snowflake, error) +} + +// Interface for collecting some number of Snowflakes, for passing along +// ultimately to the SOCKS handler. type SnowflakeCollector interface { - // Collect adds a snowflake to the collection. - // The implementation of Collect should decide how to connect to and maintain - // the connection to the WebRTCPeer. - Collect() (*WebRTCPeer, error) - // Pop removes and returns the most available snowflake from the collection. - Pop() *WebRTCPeer + // Add a Snowflake to the collection. + // Implementation should decide how to connect and maintain the webRTCConn. + Collect() (Snowflake, error) - // Melted returns a channel that will signal when the collector has stopped. + // Remove and return the most available Snowflake from the collection. + Pop() Snowflake + + // Signal when the collector has stopped collecting. Melted() <-chan struct{} } + +// Interface to adapt to goptlib's SocksConn struct. +type SocksConnector interface { + Grant(*net.TCPAddr) error + Reject() error + net.Conn +} + +// Interface for the Snowflake's transport. (Typically just webrtc.DataChannel) +type SnowflakeDataChannel interface { + io.Closer + Send([]byte) error +} diff --git a/client/lib/lib_test.go b/client/lib/lib_test.go index e1b6427..4e9e2c7 100644 --- a/client/lib/lib_test.go +++ b/client/lib/lib_test.go @@ -1,26 +1,58 @@ -package snowflake_client +package lib import ( + "bytes" "fmt" + "io/ioutil" "net" + "net/http" "testing" - "time" - "git.torproject.org/pluggable-transports/snowflake.git/v2/common/event" + "github.com/pion/webrtc" . "github.com/smartystreets/goconvey/convey" ) -type FakeDialer struct { - max int +type MockDataChannel struct { + destination bytes.Buffer + done chan bool } -func (w FakeDialer) Catch() (*WebRTCPeer, error) { +func (m *MockDataChannel) Send(data []byte) error { + m.destination.Write(data) + m.done <- true + return nil +} + +func (*MockDataChannel) Close() error { return nil } + +type MockResponse struct{} + +func (m *MockResponse) Read(p []byte) (int, error) { + p = []byte(`{"type":"answer","sdp":"fake"}`) + return 0, nil +} +func (m *MockResponse) Close() error { return nil } + +type MockTransport struct { + statusOverride int + body []byte +} + +// Just returns a response with fake SDP answer. +func (m *MockTransport) RoundTrip(req *http.Request) (*http.Response, error) { + s := ioutil.NopCloser(bytes.NewReader(m.body)) + r := &http.Response{ + StatusCode: m.statusOverride, + Body: s, + } + return r, nil +} + +type FakeDialer struct{} + +func (w FakeDialer) Catch() (Snowflake, error) { fmt.Println("Caught a dummy snowflake.") - return &WebRTCPeer{closed: make(chan struct{})}, nil -} - -func (w FakeDialer) GetMax() int { - return w.max + return &WebRTCPeer{}, nil } type FakeSocksConn struct { @@ -34,23 +66,29 @@ func (f FakeSocksConn) Reject() error { } func (f FakeSocksConn) Grant(addr *net.TCPAddr) error { return nil } +type FakePeers struct{ toRelease *WebRTCPeer } + +func (f FakePeers) Collect() (Snowflake, error) { return &WebRTCPeer{}, nil } +func (f FakePeers) Pop() Snowflake { return nil } +func (f FakePeers) Melted() <-chan struct{} { return nil } + func TestSnowflakeClient(t *testing.T) { Convey("Peers", t, func() { Convey("Can construct", func() { - d := &FakeDialer{max: 1} - p, _ := NewPeers(d) - So(p.Tongue.GetMax(), ShouldEqual, 1) + p := NewPeers(1) + So(p.capacity, ShouldEqual, 1) So(p.snowflakeChan, ShouldNotBeNil) So(cap(p.snowflakeChan), ShouldEqual, 1) }) Convey("Collecting a Snowflake requires a Tongue.", func() { - p, err := NewPeers(nil) + p := NewPeers(1) + _, err := p.Collect() So(err, ShouldNotBeNil) + So(p.Count(), ShouldEqual, 0) // Set the dialer so that collection is possible. - d := &FakeDialer{max: 1} - p, err = NewPeers(d) + p.Tongue = FakeDialer{} _, err = p.Collect() So(err, ShouldBeNil) So(p.Count(), ShouldEqual, 1) @@ -60,7 +98,8 @@ func TestSnowflakeClient(t *testing.T) { Convey("Collection continues until capacity.", func() { c := 5 - p, _ := NewPeers(FakeDialer{max: c}) + p := NewPeers(c) + p.Tongue = FakeDialer{} // Fill up to capacity. for i := 0; i < c; i++ { fmt.Println("Adding snowflake ", i) @@ -74,7 +113,7 @@ func TestSnowflakeClient(t *testing.T) { So(err, ShouldNotBeNil) So(p.Count(), ShouldEqual, c) - // But popping allows it to continue. + // But popping and closing allows it to continue. s := p.Pop() s.Close() So(s, ShouldNotBeNil) @@ -86,7 +125,8 @@ func TestSnowflakeClient(t *testing.T) { }) Convey("Count correctly purges peers marked for deletion.", func() { - p, _ := NewPeers(FakeDialer{max: 5}) + p := NewPeers(4) + p.Tongue = FakeDialer{} p.Collect() p.Collect() p.Collect() @@ -102,9 +142,9 @@ func TestSnowflakeClient(t *testing.T) { Convey("End Closes all peers.", func() { cnt := 5 - p, _ := NewPeers(FakeDialer{max: cnt}) + p := NewPeers(cnt) for i := 0; i < cnt; i++ { - p.activePeers.PushBack(&WebRTCPeer{closed: make(chan struct{})}) + p.activePeers.PushBack(&WebRTCPeer{}) } So(p.Count(), ShouldEqual, cnt) p.End() @@ -113,7 +153,8 @@ func TestSnowflakeClient(t *testing.T) { }) Convey("Pop skips over closed peers.", func() { - p, _ := NewPeers(FakeDialer{max: 4}) + p := NewPeers(4) + p.Tongue = FakeDialer{} wc1, _ := p.Collect() wc2, _ := p.Collect() wc3, _ := p.Collect() @@ -131,95 +172,162 @@ func TestSnowflakeClient(t *testing.T) { So(r, ShouldEqual, wc4) }) - Convey("Terminate Connect() loop", func() { - p, _ := NewPeers(FakeDialer{max: 4}) - go func() { - for { - p.Collect() - select { - case <-p.Melted(): - return - default: - } - } - }() - <-time.After(10 * time.Second) + }) - p.End() - <-p.Melted() - So(p.Count(), ShouldEqual, 0) + Convey("Snowflake", t, func() { + + SkipConvey("Handler Grants correctly", func() { + socks := &FakeSocksConn{} + snowflakes := &FakePeers{} + + So(socks.rejected, ShouldEqual, false) + snowflakes.toRelease = nil + Handler(socks, snowflakes) + So(socks.rejected, ShouldEqual, true) }) + Convey("WebRTC Connection", func() { + c := NewWebRTCPeer(nil, nil) + So(c.buffer.Bytes(), ShouldEqual, nil) + + Convey("Can construct a WebRTCConn", func() { + s := NewWebRTCPeer(nil, nil) + So(s, ShouldNotBeNil) + So(s.offerChannel, ShouldNotBeNil) + So(s.answerChannel, ShouldNotBeNil) + s.Close() + }) + + Convey("Write buffers when datachannel is nil", func() { + c.Write([]byte("test")) + c.transport = nil + So(c.buffer.Bytes(), ShouldResemble, []byte("test")) + }) + + Convey("Write sends to datachannel when not nil", func() { + mock := new(MockDataChannel) + c.transport = mock + mock.done = make(chan bool, 1) + c.Write([]byte("test")) + <-mock.done + So(c.buffer.Bytes(), ShouldEqual, nil) + So(mock.destination.Bytes(), ShouldResemble, []byte("test")) + }) + + Convey("Exchange SDP sets remote description", func() { + c.offerChannel = make(chan *webrtc.SessionDescription, 1) + c.answerChannel = make(chan *webrtc.SessionDescription, 1) + + c.config = &webrtc.Configuration{} + c.preparePeerConnection() + + c.offerChannel <- nil + answer := deserializeSessionDescription( + `{"type":"answer","sdp":""}`) + c.answerChannel <- answer + c.exchangeSDP() + }) + + SkipConvey("Exchange SDP fails on nil answer", func() { + c.reset = make(chan struct{}) + c.offerChannel = make(chan *webrtc.SessionDescription, 1) + c.answerChannel = make(chan *webrtc.SessionDescription, 1) + c.offerChannel <- nil + c.answerChannel <- nil + c.exchangeSDP() + <-c.reset + }) + + }) }) Convey("Dialers", t, func() { Convey("Can construct WebRTCDialer.", func() { - broker := &BrokerChannel{} - d := NewWebRTCDialer(broker, nil, 1) + broker := &BrokerChannel{Host: "test"} + d := NewWebRTCDialer(broker, nil) So(d, ShouldNotBeNil) So(d.BrokerChannel, ShouldNotBeNil) + So(d.BrokerChannel.Host, ShouldEqual, "test") + }) + Convey("WebRTCDialer cannot Catch a snowflake with nil broker.", func() { + d := NewWebRTCDialer(nil, nil) + conn, err := d.Catch() + So(conn, ShouldBeNil) + So(err, ShouldNotBeNil) }) SkipConvey("WebRTCDialer can Catch a snowflake.", func() { - broker := &BrokerChannel{} - d := NewWebRTCDialer(broker, nil, 1) + broker := &BrokerChannel{Host: "test"} + d := NewWebRTCDialer(broker, nil) conn, err := d.Catch() So(conn, ShouldBeNil) So(err, ShouldNotBeNil) }) }) -} + Convey("Rendezvous", t, func() { + transport := &MockTransport{ + http.StatusOK, + []byte(`{"type":"answer","sdp":"fake"}`), + } + fakeOffer := deserializeSessionDescription(`{"type":"offer","sdp":"test"}`) -func TestWebRTCPeer(t *testing.T) { - Convey("WebRTCPeer", t, func(c C) { - p := &WebRTCPeer{closed: make(chan struct{}), - eventsLogger: event.NewSnowflakeEventDispatcher()} - Convey("checks for staleness", func() { - go p.checkForStaleness(time.Second) - <-time.After(2 * time.Second) - So(p.Closed(), ShouldEqual, true) + Convey("Construct BrokerChannel with no front domain", func() { + b := NewBrokerChannel("test.broker", "", transport) + So(b.url, ShouldNotBeNil) + So(b.url.Path, ShouldResemble, "test.broker") + So(b.transport, ShouldNotBeNil) + }) + + Convey("Construct BrokerChannel *with* front domain", func() { + b := NewBrokerChannel("test.broker", "front", transport) + So(b.url, ShouldNotBeNil) + So(b.url.Path, ShouldResemble, "test.broker") + So(b.url.Host, ShouldResemble, "front") + So(b.transport, ShouldNotBeNil) + }) + + Convey("BrokerChannel.Negotiate responds with answer", func() { + b := NewBrokerChannel("test.broker", "", transport) + answer, err := b.Negotiate(fakeOffer) + So(err, ShouldBeNil) + So(answer, ShouldNotBeNil) + So(answer.SDP, ShouldResemble, "fake") + }) + + Convey("BrokerChannel.Negotiate fails with 503", func() { + b := NewBrokerChannel("test.broker", "", + &MockTransport{http.StatusServiceUnavailable, []byte("\n")}) + answer, err := b.Negotiate(fakeOffer) + So(err, ShouldNotBeNil) + So(answer, ShouldBeNil) + So(err.Error(), ShouldResemble, BrokerError503) + }) + + Convey("BrokerChannel.Negotiate fails with 400", func() { + b := NewBrokerChannel("test.broker", "", + &MockTransport{http.StatusBadRequest, []byte("\n")}) + answer, err := b.Negotiate(fakeOffer) + So(err, ShouldNotBeNil) + So(answer, ShouldBeNil) + So(err.Error(), ShouldResemble, BrokerError400) + }) + + Convey("BrokerChannel.Negotiate fails with large read", func() { + b := NewBrokerChannel("test.broker", "", + &MockTransport{http.StatusOK, make([]byte, 100001, 100001)}) + answer, err := b.Negotiate(fakeOffer) + So(err, ShouldNotBeNil) + So(answer, ShouldBeNil) + So(err.Error(), ShouldResemble, "unexpected EOF") + }) + + Convey("BrokerChannel.Negotiate fails with unexpected error", func() { + b := NewBrokerChannel("test.broker", "", + &MockTransport{123, []byte("")}) + answer, err := b.Negotiate(fakeOffer) + So(err, ShouldNotBeNil) + So(answer, ShouldBeNil) + So(err.Error(), ShouldResemble, BrokerErrorUnexpected) }) }) } - -func TestICEServerParser(t *testing.T) { - Convey("Test parsing of ICE servers", t, func() { - for _, test := range []struct { - input []string - urls [][]string - length int - }{ - { - []string{"stun:stun.l.google.com:19302"}, - [][]string{[]string{"stun:stun.l.google.com:19302"}}, - 1, - }, - { - []string{"stun:stun.l.google.com:19302", "stun.ekiga.net"}, - [][]string{[]string{"stun:stun.l.google.com:19302"}, []string{"stun.ekiga.net"}}, - 2, - }, - { - []string{"stun:stun.l.google.com:19302", "stun.ekiga.net"}, - [][]string{[]string{"stun:stun.l.google.com:19302"}, []string{"stun.ekiga.net"}}, - 2, - }, - } { - servers := parseIceServers(test.input) - - if test.urls == nil { - So(servers, ShouldBeNil) - } else { - So(servers, ShouldNotBeNil) - } - - So(len(servers), ShouldEqual, test.length) - - for _, server := range servers { - So(test.urls, ShouldContain, server.URLs) - } - - } - - }) -} diff --git a/client/lib/peers.go b/client/lib/peers.go index 1c39425..5493bfd 100644 --- a/client/lib/peers.go +++ b/client/lib/peers.go @@ -1,14 +1,13 @@ -package snowflake_client +package lib import ( "container/list" "errors" "fmt" "log" - "sync" ) -// Peers is a container that keeps track of multiple WebRTC remote peers. +// Container which keeps track of multiple WebRTC remote peers. // Implements |SnowflakeCollector|. // // Maintaining a set of pre-connected Peers with fresh but inactive datachannels @@ -21,50 +20,38 @@ import ( // version of Snowflake) type Peers struct { Tongue - bytesLogger bytesLogger + BytesLogger - snowflakeChan chan *WebRTCPeer + snowflakeChan chan Snowflake activePeers *list.List + capacity int melt chan struct{} - - collectLock sync.Mutex } -// NewPeers constructs a fresh container of remote peers. -func NewPeers(tongue Tongue) (*Peers, error) { - p := &Peers{} +// Construct a fresh container of remote peers. +func NewPeers(max int) *Peers { + p := &Peers{capacity: max} // Use buffered go channel to pass snowflakes onwards to the SOCKS handler. - if tongue == nil { - return nil, errors.New("missing Tongue to catch Snowflakes with") - } - p.snowflakeChan = make(chan *WebRTCPeer, tongue.GetMax()) + p.snowflakeChan = make(chan Snowflake, max) p.activePeers = list.New() - p.melt = make(chan struct{}) - p.Tongue = tongue - return p, nil + p.melt = make(chan struct{}, 1) + return p } -// Collect connects to and adds a new remote peer as part of |SnowflakeCollector| interface. -func (p *Peers) Collect() (*WebRTCPeer, error) { - // Engage the Snowflake Catching interface, which must be available. - p.collectLock.Lock() - defer p.collectLock.Unlock() - select { - case <-p.melt: - return nil, fmt.Errorf("Snowflakes have melted") - default: +// As part of |SnowflakeCollector| interface. +func (p *Peers) Collect() (Snowflake, error) { + cnt := p.Count() + s := fmt.Sprintf("Currently at [%d/%d]", cnt, p.capacity) + if cnt >= p.capacity { + s = fmt.Sprintf("At capacity [%d/%d]", cnt, p.capacity) + return nil, errors.New(s) } + log.Println("WebRTC: Collecting a new Snowflake.", s) + // Engage the Snowflake Catching interface, which must be available. if nil == p.Tongue { return nil, errors.New("missing Tongue to catch Snowflakes with") } - cnt := p.Count() - capacity := p.Tongue.GetMax() - s := fmt.Sprintf("Currently at [%d/%d]", cnt, capacity) - if cnt >= capacity { - return nil, fmt.Errorf("At capacity [%d/%d]", cnt, capacity) - } - log.Println("WebRTC: Collecting a new Snowflake.", s) // BUG: some broker conflict here. connection, err := p.Tongue.Catch() if nil != err { @@ -76,30 +63,32 @@ func (p *Peers) Collect() (*WebRTCPeer, error) { return connection, nil } -// Pop blocks until an available, valid snowflake appears. -// Pop will return nil after End has been called. -func (p *Peers) Pop() *WebRTCPeer { - for { - snowflake, ok := <-p.snowflakeChan +// As part of |SnowflakeCollector| interface. +func (p *Peers) Pop() Snowflake { + // Blocks until an available, valid snowflake appears. + var snowflake Snowflake + var ok bool + for nil == snowflake { + snowflake, ok = <-p.snowflakeChan + conn := snowflake.(*WebRTCPeer) if !ok { return nil } - if snowflake.Closed() { - continue + if conn.closed { + snowflake = nil } - // Set to use the same rate-limited traffic logger to keep consistency. - snowflake.bytesLogger = p.bytesLogger - return snowflake } + // Set to use the same rate-limited traffic logger to keep consistency. + snowflake.(*WebRTCPeer).BytesLogger = p.BytesLogger + return snowflake } -// Melted returns a channel that will close when peers stop being collected. -// Melted is a necessary part of |SnowflakeCollector| interface. +// As part of |SnowflakeCollector| interface. func (p *Peers) Melted() <-chan struct{} { return p.melt } -// Count returns the total available Snowflakes (including the active ones) +// Returns total available Snowflakes (including the active one) // The count only reduces when connections themselves close, rather than when // they are popped. func (p *Peers) Count() int { @@ -112,20 +101,17 @@ func (p *Peers) purgeClosedPeers() { next := e.Next() conn := e.Value.(*WebRTCPeer) // Purge those marked for deletion. - if conn.Closed() { + if conn.closed { p.activePeers.Remove(e) } e = next } } -// End closes all active connections to Peers contained here, and stops the -// collection of future Peers. +// Close all Peers contained here. func (p *Peers) End() { - close(p.melt) - p.collectLock.Lock() - defer p.collectLock.Unlock() close(p.snowflakeChan) + p.melt <- struct{}{} cnt := p.Count() for e := p.activePeers.Front(); e != nil; { next := e.Next() @@ -134,5 +120,5 @@ func (p *Peers) End() { p.activePeers.Remove(e) e = next } - log.Printf("WebRTC: melted all %d snowflakes.", cnt) + log.Println("WebRTC: melted all", cnt, "snowflakes.") } diff --git a/client/lib/rendezvous.go b/client/lib/rendezvous.go index 38e4620..0c7225b 100644 --- a/client/lib/rendezvous.go +++ b/client/lib/rendezvous.go @@ -1,195 +1,154 @@ // WebRTC rendezvous requires the exchange of SessionDescriptions between // peers in order to establish a PeerConnection. +// +// This file contains the one method currently available to Snowflake: +// +// - Domain-fronted HTTP signaling. The Broker automatically exchange offers +// and answers between this client and some remote WebRTC proxy. -package snowflake_client +package lib import ( + "bytes" "errors" - "fmt" - + "io" + "io/ioutil" "log" "net/http" - "sync" - "time" + "net/url" - "git.torproject.org/pluggable-transports/snowflake.git/v2/common/event" - "git.torproject.org/pluggable-transports/snowflake.git/v2/common/messages" - "git.torproject.org/pluggable-transports/snowflake.git/v2/common/nat" - "git.torproject.org/pluggable-transports/snowflake.git/v2/common/util" - utlsutil "git.torproject.org/pluggable-transports/snowflake.git/v2/common/utls" - "github.com/pion/webrtc/v3" - utls "github.com/refraction-networking/utls" + "github.com/pion/webrtc" ) const ( - brokerErrorUnexpected string = "Unexpected error, no answer." + BrokerError503 string = "No snowflake proxies currently available." + BrokerError400 string = "You sent an invalid offer in the request." + BrokerErrorUnexpected string = "Unexpected error, no answer." readLimit = 100000 //Maximum number of bytes to be read from an HTTP response ) -// RendezvousMethod represents a way of communicating with the broker: sending -// an encoded client poll request (SDP offer) and receiving an encoded client -// poll response (SDP answer) in return. RendezvousMethod is used by -// BrokerChannel, which is in charge of encoding and decoding, and all other -// tasks that are independent of the rendezvous method. -type RendezvousMethod interface { - Exchange([]byte) ([]byte, error) -} - -// BrokerChannel uses a RendezvousMethod to communicate with the Snowflake broker. -// The BrokerChannel is responsible for encoding and decoding SDP offers and answers; -// RendezvousMethod is responsible for the exchange of encoded information. +// Signalling Channel to the Broker. type BrokerChannel struct { - Rendezvous RendezvousMethod - keepLocalAddresses bool - natType string - lock sync.Mutex - BridgeFingerprint string + // The Host header to put in the HTTP request (optional and may be + // different from the host name in URL). + Host string + url *url.URL + transport http.RoundTripper // Used to make all requests. } // We make a copy of DefaultTransport because we want the default Dial // and TLSHandshakeTimeout settings. But we want to disable the default // ProxyFromEnvironment setting. -func createBrokerTransport() http.RoundTripper { +func CreateBrokerTransport() http.RoundTripper { transport := http.DefaultTransport.(*http.Transport) transport.Proxy = nil - transport.ResponseHeaderTimeout = 15 * time.Second return transport } -func newBrokerChannelFromConfig(config ClientConfig) (*BrokerChannel, error) { - log.Println("Rendezvous using Broker at:", config.BrokerURL) - - if config.FrontDomain != "" { - log.Println("Domain fronting using:", config.FrontDomain) +// Construct a new BrokerChannel, where: +// |broker| is the full URL of the facilitating program which assigns proxies +// to clients, and |front| is the option fronting domain. +func NewBrokerChannel(broker string, front string, transport http.RoundTripper) *BrokerChannel { + targetURL, err := url.Parse(broker) + if nil != err { + return nil + } + log.Println("Rendezvous using Broker at:", broker) + bc := new(BrokerChannel) + bc.url = targetURL + if "" != front { // Optional front domain. + log.Println("Domain fronting using:", front) + bc.Host = bc.url.Host + bc.url.Host = front } - brokerTransport := createBrokerTransport() - - if config.UTLSClientID != "" { - utlsClientHelloID, err := utlsutil.NameToUTLSID(config.UTLSClientID) - if err != nil { - return nil, fmt.Errorf("unable to create broker channel: %v", err) - } - utlsConfig := &utls.Config{} - brokerTransport = utlsutil.NewUTLSHTTPRoundTripper(utlsClientHelloID, utlsConfig, brokerTransport, config.UTLSRemoveSNI) - } - - var rendezvous RendezvousMethod - var err error - if config.AmpCacheURL != "" { - log.Println("Through AMP cache at:", config.AmpCacheURL) - rendezvous, err = newAMPCacheRendezvous( - config.BrokerURL, config.AmpCacheURL, config.FrontDomain, - brokerTransport) - } else { - rendezvous, err = newHTTPRendezvous( - config.BrokerURL, config.FrontDomain, brokerTransport) - } - if err != nil { - return nil, err - } - - return &BrokerChannel{ - Rendezvous: rendezvous, - keepLocalAddresses: config.KeepLocalAddresses, - natType: nat.NATUnknown, - BridgeFingerprint: config.BridgeFingerprint, - }, nil + bc.transport = transport + return bc } -// Negotiate uses a RendezvousMethod to send the client's WebRTC SDP offer -// and receive a snowflake proxy WebRTC SDP answer in return. +func limitedRead(r io.Reader, limit int64) ([]byte, error) { + p, err := ioutil.ReadAll(&io.LimitedReader{R: r, N: limit + 1}) + if err != nil { + return p, err + } else if int64(len(p)) == limit+1 { + return p[0:limit], io.ErrUnexpectedEOF + } + return p, err +} + +// Roundtrip HTTP POST using WebRTC SessionDescriptions. +// +// Send an SDP offer to the broker, which assigns a proxy and responds +// with an SDP answer from a designated remote WebRTC peer. func (bc *BrokerChannel) Negotiate(offer *webrtc.SessionDescription) ( *webrtc.SessionDescription, error) { - // Ideally, we could specify an `RTCIceTransportPolicy` that would handle - // this for us. However, "public" was removed from the draft spec. - // See https://developer.mozilla.org/en-US/docs/Web/API/RTCConfiguration#RTCIceTransportPolicy_enum - if !bc.keepLocalAddresses { - offer = &webrtc.SessionDescription{ - Type: offer.Type, - SDP: util.StripLocalAddresses(offer.SDP), + log.Println("Negotiating via BrokerChannel...\nTarget URL: ", + bc.Host, "\nFront URL: ", bc.url.Host) + data := bytes.NewReader([]byte(serializeSessionDescription(offer))) + // Suffix with broker's client registration handler. + clientURL := bc.url.ResolveReference(&url.URL{Path: "client"}) + request, err := http.NewRequest("POST", clientURL.String(), data) + if nil != err { + return nil, err + } + if "" != bc.Host { // Set true host if necessary. + request.Host = bc.Host + } + resp, err := bc.transport.RoundTrip(request) + if nil != err { + return nil, err + } + defer resp.Body.Close() + log.Printf("BrokerChannel Response:\n%s\n\n", resp.Status) + + switch resp.StatusCode { + case http.StatusOK: + body, err := limitedRead(resp.Body, readLimit) + if nil != err { + return nil, err } - } - offerSDP, err := util.SerializeSessionDescription(offer) - if err != nil { - return nil, err - } + answer := deserializeSessionDescription(string(body)) + return answer, nil - // Encode the client poll request. - bc.lock.Lock() - req := &messages.ClientPollRequest{ - Offer: offerSDP, - NAT: bc.natType, - Fingerprint: bc.BridgeFingerprint, + case http.StatusServiceUnavailable: + return nil, errors.New(BrokerError503) + case http.StatusBadRequest: + return nil, errors.New(BrokerError400) + default: + return nil, errors.New(BrokerErrorUnexpected) } - encReq, err := req.EncodeClientPollRequest() - bc.lock.Unlock() - if err != nil { - return nil, err - } - - // Do the exchange using our RendezvousMethod. - encResp, err := bc.Rendezvous.Exchange(encReq) - if err != nil { - return nil, err - } - log.Printf("Received answer: %s", string(encResp)) - - // Decode the client poll response. - resp, err := messages.DecodeClientPollResponse(encResp) - if err != nil { - return nil, err - } - if resp.Error != "" { - return nil, errors.New(resp.Error) - } - return util.DeserializeSessionDescription(resp.Answer) } -// SetNATType sets the NAT type of the client so we can send it to the WebRTC broker. -func (bc *BrokerChannel) SetNATType(NATType string) { - bc.lock.Lock() - bc.natType = NATType - bc.lock.Unlock() - log.Printf("NAT Type: %s", NATType) -} - -// WebRTCDialer implements the |Tongue| interface to catch snowflakes, using BrokerChannel. +// Implements the |Tongue| interface to catch snowflakes, using BrokerChannel. type WebRTCDialer struct { *BrokerChannel webrtcConfig *webrtc.Configuration - max int - - eventLogger event.SnowflakeEventReceiver } -func NewWebRTCDialer(broker *BrokerChannel, iceServers []webrtc.ICEServer, max int) *WebRTCDialer { - return NewWebRTCDialerWithEvents(broker, iceServers, max, nil) -} - -// NewWebRTCDialerWithEvents constructs a new WebRTCDialer. -func NewWebRTCDialerWithEvents(broker *BrokerChannel, iceServers []webrtc.ICEServer, max int, eventLogger event.SnowflakeEventReceiver) *WebRTCDialer { - config := webrtc.Configuration{ - ICEServers: iceServers, +func NewWebRTCDialer( + broker *BrokerChannel, iceServers []webrtc.ICEServer) *WebRTCDialer { + var config webrtc.Configuration + if iceServers != nil { + config = webrtc.Configuration{ + ICEServers: iceServers, + } + } else { + config = webrtc.Configuration{} } - return &WebRTCDialer{ BrokerChannel: broker, webrtcConfig: &config, - max: max, - - eventLogger: eventLogger, } } -// Catch initializes a WebRTC Connection by signaling through the BrokerChannel. -func (w WebRTCDialer) Catch() (*WebRTCPeer, error) { - // TODO: [#25591] Fetch ICE server information from Broker. - // TODO: [#25596] Consider TURN servers here too. - return NewWebRTCPeerWithEvents(w.webrtcConfig, w.BrokerChannel, w.eventLogger) -} - -// GetMax returns the maximum number of snowflakes to collect. -func (w WebRTCDialer) GetMax() int { - return w.max +// Initialize a WebRTC Connection by signaling through the broker. +func (w WebRTCDialer) Catch() (Snowflake, error) { + if nil == w.BrokerChannel { + return nil, errors.New("cannot Dial WebRTC without a BrokerChannel") + } + // TODO: [#3] Fetch ICE server information from Broker. + // TODO: [#18] Consider TURN servers here too. + connection := NewWebRTCPeer(w.webrtcConfig, w.BrokerChannel) + err := connection.Connect() + return connection, err } diff --git a/client/lib/rendezvous_ampcache.go b/client/lib/rendezvous_ampcache.go deleted file mode 100644 index 59456ae..0000000 --- a/client/lib/rendezvous_ampcache.go +++ /dev/null @@ -1,124 +0,0 @@ -package snowflake_client - -import ( - "errors" - "io" - "io/ioutil" - "log" - "net/http" - "net/url" - - "git.torproject.org/pluggable-transports/snowflake.git/v2/common/amp" -) - -// ampCacheRendezvous is a RendezvousMethod that communicates with the -// .../amp/client route of the broker, optionally over an AMP cache proxy, and -// with optional domain fronting. -type ampCacheRendezvous struct { - brokerURL *url.URL - cacheURL *url.URL // Optional AMP cache URL. - front string // Optional front domain to replace url.Host in requests. - transport http.RoundTripper // Used to make all requests. -} - -// newAMPCacheRendezvous creates a new ampCacheRendezvous that contacts the -// broker at the given URL, optionally proxying through an AMP cache, and with -// an optional front domain. transport is the http.RoundTripper used to make all -// requests. -func newAMPCacheRendezvous(broker, cache, front string, transport http.RoundTripper) (*ampCacheRendezvous, error) { - brokerURL, err := url.Parse(broker) - if err != nil { - return nil, err - } - var cacheURL *url.URL - if cache != "" { - var err error - cacheURL, err = url.Parse(cache) - if err != nil { - return nil, err - } - } - return &CacheRendezvous{ - brokerURL: brokerURL, - cacheURL: cacheURL, - front: front, - transport: transport, - }, nil -} - -func (r *ampCacheRendezvous) Exchange(encPollReq []byte) ([]byte, error) { - log.Println("Negotiating via AMP cache rendezvous...") - log.Println("Broker URL:", r.brokerURL) - log.Println("AMP cache URL:", r.cacheURL) - log.Println("Front domain:", r.front) - - // We cannot POST a body through an AMP cache, so instead we GET and - // encode the client poll request message into the URL. - reqURL := r.brokerURL.ResolveReference(&url.URL{ - Path: "amp/client/" + amp.EncodePath(encPollReq), - }) - - if r.cacheURL != nil { - // Rewrite reqURL to its AMP cache version. - var err error - reqURL, err = amp.CacheURL(reqURL, r.cacheURL, "c") - if err != nil { - return nil, err - } - } - - req, err := http.NewRequest("GET", reqURL.String(), nil) - if err != nil { - return nil, err - } - - if r.front != "" { - // Do domain fronting. Replace the domain in the URL's with the - // front, and store the original domain the HTTP Host header. - req.Host = req.URL.Host - req.URL.Host = r.front - } - - resp, err := r.transport.RoundTrip(req) - if err != nil { - return nil, err - } - defer resp.Body.Close() - - log.Printf("AMP cache rendezvous response: %s", resp.Status) - if resp.StatusCode != http.StatusOK { - // A non-200 status indicates an error: - // * If the broker returns a page with invalid AMP, then the AMP - // cache returns a redirect that would bypass the cache. - // * If the broker returns a 5xx status, the AMP cache - // translates it to a 404. - // https://amp.dev/documentation/guides-and-tutorials/learn/amp-caches-and-cors/amp-cache-urls/#redirect-%26-error-handling - return nil, errors.New(brokerErrorUnexpected) - } - if _, err := resp.Location(); err == nil { - // The Google AMP Cache may return a "silent redirect" with - // status 200, a Location header set, and a JavaScript redirect - // in the body. The redirect points directly at the origin - // server for the request (bypassing the AMP cache). We do not - // follow redirects nor execute JavaScript, but in any case we - // cannot extract information from this response and can only - // treat it as an error. - return nil, errors.New(brokerErrorUnexpected) - } - - lr := io.LimitReader(resp.Body, readLimit+1) - dec, err := amp.NewArmorDecoder(lr) - if err != nil { - return nil, err - } - encPollResp, err := ioutil.ReadAll(dec) - if err != nil { - return nil, err - } - if lr.(*io.LimitedReader).N == 0 { - // We hit readLimit while decoding AMP armor, that's an error. - return nil, io.ErrUnexpectedEOF - } - - return encPollResp, err -} diff --git a/client/lib/rendezvous_http.go b/client/lib/rendezvous_http.go deleted file mode 100644 index fd80e7f..0000000 --- a/client/lib/rendezvous_http.go +++ /dev/null @@ -1,77 +0,0 @@ -package snowflake_client - -import ( - "bytes" - "errors" - "io" - "io/ioutil" - "log" - "net/http" - "net/url" -) - -// httpRendezvous is a RendezvousMethod that communicates with the .../client -// route of the broker over HTTP or HTTPS, with optional domain fronting. -type httpRendezvous struct { - brokerURL *url.URL - front string // Optional front domain to replace url.Host in requests. - transport http.RoundTripper // Used to make all requests. -} - -// newHTTPRendezvous creates a new httpRendezvous that contacts the broker at -// the given URL, with an optional front domain. transport is the -// http.RoundTripper used to make all requests. -func newHTTPRendezvous(broker, front string, transport http.RoundTripper) (*httpRendezvous, error) { - brokerURL, err := url.Parse(broker) - if err != nil { - return nil, err - } - return &httpRendezvous{ - brokerURL: brokerURL, - front: front, - transport: transport, - }, nil -} - -func (r *httpRendezvous) Exchange(encPollReq []byte) ([]byte, error) { - log.Println("Negotiating via HTTP rendezvous...") - log.Println("Target URL: ", r.brokerURL.Host) - log.Println("Front URL: ", r.front) - - // Suffix the path with the broker's client registration handler. - reqURL := r.brokerURL.ResolveReference(&url.URL{Path: "client"}) - req, err := http.NewRequest("POST", reqURL.String(), bytes.NewReader(encPollReq)) - if err != nil { - return nil, err - } - - if r.front != "" { - // Do domain fronting. Replace the domain in the URL's with the - // front, and store the original domain the HTTP Host header. - req.Host = req.URL.Host - req.URL.Host = r.front - } - - resp, err := r.transport.RoundTrip(req) - if err != nil { - return nil, err - } - defer resp.Body.Close() - - log.Printf("HTTP rendezvous response: %s", resp.Status) - if resp.StatusCode != http.StatusOK { - return nil, errors.New(brokerErrorUnexpected) - } - - return limitedRead(resp.Body, readLimit) -} - -func limitedRead(r io.Reader, limit int64) ([]byte, error) { - p, err := ioutil.ReadAll(&io.LimitedReader{R: r, N: limit + 1}) - if err != nil { - return p, err - } else if int64(len(p)) == limit+1 { - return p[0:limit], io.ErrUnexpectedEOF - } - return p, err -} diff --git a/client/lib/rendezvous_test.go b/client/lib/rendezvous_test.go deleted file mode 100644 index 21b9f57..0000000 --- a/client/lib/rendezvous_test.go +++ /dev/null @@ -1,273 +0,0 @@ -package snowflake_client - -import ( - "bytes" - "errors" - "fmt" - "io" - "io/ioutil" - "net/http" - "testing" - - "git.torproject.org/pluggable-transports/snowflake.git/v2/common/amp" - "git.torproject.org/pluggable-transports/snowflake.git/v2/common/messages" - "git.torproject.org/pluggable-transports/snowflake.git/v2/common/nat" - . "github.com/smartystreets/goconvey/convey" -) - -// mockTransport's RoundTrip method returns a response with a fake status and -// body. -type mockTransport struct { - statusCode int - body []byte -} - -func (t *mockTransport) RoundTrip(req *http.Request) (*http.Response, error) { - return &http.Response{ - Status: fmt.Sprintf("%d %s", t.statusCode, http.StatusText(t.statusCode)), - StatusCode: t.statusCode, - Body: ioutil.NopCloser(bytes.NewReader(t.body)), - }, nil -} - -// errorTransport's RoundTrip method returns an error. -type errorTransport struct { - err error -} - -func (t errorTransport) RoundTrip(req *http.Request) (*http.Response, error) { - return nil, t.err -} - -// makeEncPollReq returns an encoded client poll request containing a given -// offer. -func makeEncPollReq(offer string) []byte { - encPollReq, err := (&messages.ClientPollRequest{ - Offer: offer, - NAT: nat.NATUnknown, - }).EncodeClientPollRequest() - if err != nil { - panic(err) - } - return encPollReq -} - -// makeEncPollResp returns an encoded client poll response with given answer and -// error strings. -func makeEncPollResp(answer, errorStr string) []byte { - encPollResp, err := (&messages.ClientPollResponse{ - Answer: answer, - Error: errorStr, - }).EncodePollResponse() - if err != nil { - panic(err) - } - return encPollResp -} - -var fakeEncPollReq = makeEncPollReq(`{"type":"offer","sdp":"test"}`) - -func TestHTTPRendezvous(t *testing.T) { - Convey("HTTP rendezvous", t, func() { - Convey("Construct httpRendezvous with no front domain", func() { - transport := &mockTransport{http.StatusOK, []byte{}} - rend, err := newHTTPRendezvous("http://test.broker", "", transport) - So(err, ShouldBeNil) - So(rend.brokerURL, ShouldNotBeNil) - So(rend.brokerURL.Host, ShouldResemble, "test.broker") - So(rend.front, ShouldResemble, "") - So(rend.transport, ShouldEqual, transport) - }) - - Convey("Construct httpRendezvous *with* front domain", func() { - transport := &mockTransport{http.StatusOK, []byte{}} - rend, err := newHTTPRendezvous("http://test.broker", "front", transport) - So(err, ShouldBeNil) - So(rend.brokerURL, ShouldNotBeNil) - So(rend.brokerURL.Host, ShouldResemble, "test.broker") - So(rend.front, ShouldResemble, "front") - So(rend.transport, ShouldEqual, transport) - }) - - Convey("httpRendezvous.Exchange responds with answer", func() { - fakeEncPollResp := makeEncPollResp( - `{"answer": "{\"type\":\"answer\",\"sdp\":\"fake\"}" }`, - "", - ) - rend, err := newHTTPRendezvous("http://test.broker", "", - &mockTransport{http.StatusOK, fakeEncPollResp}) - So(err, ShouldBeNil) - answer, err := rend.Exchange(fakeEncPollReq) - So(err, ShouldBeNil) - So(answer, ShouldResemble, fakeEncPollResp) - }) - - Convey("httpRendezvous.Exchange responds with no answer", func() { - fakeEncPollResp := makeEncPollResp( - "", - `{"error": "no snowflake proxies currently available"}`, - ) - rend, err := newHTTPRendezvous("http://test.broker", "", - &mockTransport{http.StatusOK, fakeEncPollResp}) - So(err, ShouldBeNil) - answer, err := rend.Exchange(fakeEncPollReq) - So(err, ShouldBeNil) - So(answer, ShouldResemble, fakeEncPollResp) - }) - - Convey("httpRendezvous.Exchange fails with unexpected HTTP status code", func() { - rend, err := newHTTPRendezvous("http://test.broker", "", - &mockTransport{http.StatusInternalServerError, []byte{}}) - So(err, ShouldBeNil) - answer, err := rend.Exchange(fakeEncPollReq) - So(err, ShouldNotBeNil) - So(answer, ShouldBeNil) - So(err.Error(), ShouldResemble, brokerErrorUnexpected) - }) - - Convey("httpRendezvous.Exchange fails with error", func() { - transportErr := errors.New("error") - rend, err := newHTTPRendezvous("http://test.broker", "", - &errorTransport{err: transportErr}) - So(err, ShouldBeNil) - answer, err := rend.Exchange(fakeEncPollReq) - So(err, ShouldEqual, transportErr) - So(answer, ShouldBeNil) - }) - - Convey("httpRendezvous.Exchange fails with large read", func() { - rend, err := newHTTPRendezvous("http://test.broker", "", - &mockTransport{http.StatusOK, make([]byte, readLimit+1)}) - So(err, ShouldBeNil) - _, err = rend.Exchange(fakeEncPollReq) - So(err, ShouldEqual, io.ErrUnexpectedEOF) - }) - }) -} - -func ampArmorEncode(p []byte) []byte { - var buf bytes.Buffer - enc, err := amp.NewArmorEncoder(&buf) - if err != nil { - panic(err) - } - _, err = enc.Write(p) - if err != nil { - panic(err) - } - err = enc.Close() - if err != nil { - panic(err) - } - return buf.Bytes() -} - -func TestAMPCacheRendezvous(t *testing.T) { - Convey("AMP cache rendezvous", t, func() { - Convey("Construct ampCacheRendezvous with no cache and no front domain", func() { - transport := &mockTransport{http.StatusOK, []byte{}} - rend, err := newAMPCacheRendezvous("http://test.broker", "", "", transport) - So(err, ShouldBeNil) - So(rend.brokerURL, ShouldNotBeNil) - So(rend.brokerURL.String(), ShouldResemble, "http://test.broker") - So(rend.cacheURL, ShouldBeNil) - So(rend.front, ShouldResemble, "") - So(rend.transport, ShouldEqual, transport) - }) - - Convey("Construct ampCacheRendezvous with cache and no front domain", func() { - transport := &mockTransport{http.StatusOK, []byte{}} - rend, err := newAMPCacheRendezvous("http://test.broker", "https://amp.cache/", "", transport) - So(err, ShouldBeNil) - So(rend.brokerURL, ShouldNotBeNil) - So(rend.brokerURL.String(), ShouldResemble, "http://test.broker") - So(rend.cacheURL, ShouldNotBeNil) - So(rend.cacheURL.String(), ShouldResemble, "https://amp.cache/") - So(rend.front, ShouldResemble, "") - So(rend.transport, ShouldEqual, transport) - }) - - Convey("Construct ampCacheRendezvous with no cache and front domain", func() { - transport := &mockTransport{http.StatusOK, []byte{}} - rend, err := newAMPCacheRendezvous("http://test.broker", "", "front", transport) - So(err, ShouldBeNil) - So(rend.brokerURL, ShouldNotBeNil) - So(rend.brokerURL.String(), ShouldResemble, "http://test.broker") - So(rend.cacheURL, ShouldBeNil) - So(rend.front, ShouldResemble, "front") - So(rend.transport, ShouldEqual, transport) - }) - - Convey("Construct ampCacheRendezvous with cache and front domain", func() { - transport := &mockTransport{http.StatusOK, []byte{}} - rend, err := newAMPCacheRendezvous("http://test.broker", "https://amp.cache/", "front", transport) - So(err, ShouldBeNil) - So(rend.brokerURL, ShouldNotBeNil) - So(rend.brokerURL.String(), ShouldResemble, "http://test.broker") - So(rend.cacheURL, ShouldNotBeNil) - So(rend.cacheURL.String(), ShouldResemble, "https://amp.cache/") - So(rend.front, ShouldResemble, "front") - So(rend.transport, ShouldEqual, transport) - }) - - Convey("ampCacheRendezvous.Exchange responds with answer", func() { - fakeEncPollResp := makeEncPollResp( - `{"answer": "{\"type\":\"answer\",\"sdp\":\"fake\"}" }`, - "", - ) - rend, err := newAMPCacheRendezvous("http://test.broker", "", "", - &mockTransport{http.StatusOK, ampArmorEncode(fakeEncPollResp)}) - So(err, ShouldBeNil) - answer, err := rend.Exchange(fakeEncPollReq) - So(err, ShouldBeNil) - So(answer, ShouldResemble, fakeEncPollResp) - }) - - Convey("ampCacheRendezvous.Exchange responds with no answer", func() { - fakeEncPollResp := makeEncPollResp( - "", - `{"error": "no snowflake proxies currently available"}`, - ) - rend, err := newAMPCacheRendezvous("http://test.broker", "", "", - &mockTransport{http.StatusOK, ampArmorEncode(fakeEncPollResp)}) - So(err, ShouldBeNil) - answer, err := rend.Exchange(fakeEncPollReq) - So(err, ShouldBeNil) - So(answer, ShouldResemble, fakeEncPollResp) - }) - - Convey("ampCacheRendezvous.Exchange fails with unexpected HTTP status code", func() { - rend, err := newAMPCacheRendezvous("http://test.broker", "", "", - &mockTransport{http.StatusInternalServerError, []byte{}}) - So(err, ShouldBeNil) - answer, err := rend.Exchange(fakeEncPollReq) - So(err, ShouldNotBeNil) - So(answer, ShouldBeNil) - So(err.Error(), ShouldResemble, brokerErrorUnexpected) - }) - - Convey("ampCacheRendezvous.Exchange fails with error", func() { - transportErr := errors.New("error") - rend, err := newAMPCacheRendezvous("http://test.broker", "", "", - &errorTransport{err: transportErr}) - So(err, ShouldBeNil) - answer, err := rend.Exchange(fakeEncPollReq) - So(err, ShouldEqual, transportErr) - So(answer, ShouldBeNil) - }) - - Convey("ampCacheRendezvous.Exchange fails with large read", func() { - // readLimit should apply to the raw HTTP body, not the - // encoded bytes. Encode readLimit bytes—the encoded - // size will be larger—and try to read the body. It - // should fail. - rend, err := newAMPCacheRendezvous("http://test.broker", "", "", - &mockTransport{http.StatusOK, ampArmorEncode(make([]byte, readLimit))}) - So(err, ShouldBeNil) - _, err = rend.Exchange(fakeEncPollReq) - // We may get io.ErrUnexpectedEOF here, or something - // like "missing tag". - So(err, ShouldNotBeNil) - }) - }) -} diff --git a/client/lib/snowflake.go b/client/lib/snowflake.go index 7231d86..2e68e36 100644 --- a/client/lib/snowflake.go +++ b/client/lib/snowflake.go @@ -1,368 +1,68 @@ -/* -Package snowflake_client implements functionality necessary for a client to establish a connection -to a server using Snowflake. - -Included in the package is a Transport type that implements the Pluggable Transports v2.1 Go API -specification. To use Snowflake, you must first create a client from a configuration: - - config := snowflake_client.ClientConfig{ - BrokerURL: "https://snowflake-broker.example.com", - FrontDomain: "https://friendlyfrontdomain.net", - // ... - } - transport, err := snowflake_client.NewSnowflakeClient(config) - if err != nil { - // handle error - } - -The Dial function connects to a Snowflake server: - - conn, err := transport.Dial() - if err != nil { - // handle error - } - defer conn.Close() - -*/ -package snowflake_client +package lib import ( - "context" "errors" + "io" "log" - "math/rand" "net" - "strings" - "time" - - "git.torproject.org/pluggable-transports/snowflake.git/v2/common/event" - "git.torproject.org/pluggable-transports/snowflake.git/v2/common/nat" - "git.torproject.org/pluggable-transports/snowflake.git/v2/common/turbotunnel" - "github.com/pion/webrtc/v3" - "github.com/xtaci/kcp-go/v5" - "github.com/xtaci/smux" + "sync" ) const ( - // ReconnectTimeout is the time a Snowflake client will wait before collecting - // more snowflakes. - ReconnectTimeout = 10 * time.Second - // SnowflakeTimeout is the time a Snowflake client will wait before determining that - // a remote snowflake has been disconnected. If no new messages are sent or received - // in this time period, the client will terminate the connection with the remote - // peer and collect a new snowflake. - SnowflakeTimeout = 20 * time.Second - // DataChannelTimeout is how long the client will wait for the OnOpen callback - // on a newly created DataChannel. - DataChannelTimeout = 10 * time.Second - - // WindowSize is the number of packets in the send and receive window of a KCP connection. - WindowSize = 65535 - // StreamSize controls the maximum amount of in flight data between a client and server. - StreamSize = 1048576 //1MB + ReconnectTimeout = 10 + SnowflakeTimeout = 30 ) -type dummyAddr struct{} - -func (addr dummyAddr) Network() string { return "dummy" } -func (addr dummyAddr) String() string { return "dummy" } - -// Transport is a structure with methods that conform to the Go PT v2.1 API -// https://github.com/Pluggable-Transports/Pluggable-Transports-spec/blob/master/releases/PTSpecV2.1/Pluggable%20Transport%20Specification%20v2.1%20-%20Go%20Transport%20API.pdf -type Transport struct { - dialer *WebRTCDialer - - // EventDispatcher is the event bus for snowflake events. - // When an important event happens, it will be distributed here. - eventDispatcher event.SnowflakeEventDispatcher -} - -// ClientConfig defines how the SnowflakeClient will connect to the broker and Snowflake proxies. -type ClientConfig struct { - // BrokerURL is the full URL of the Snowflake broker that the client will connect to. - BrokerURL string - // AmpCacheURL is the full URL of a valid AMP cache. A nonzero value indicates - // that AMP cache will be used as the rendezvous method with the broker. - AmpCacheURL string - // FrontDomain is a the full URL of an optional front domain that can be used with either - // the AMP cache or HTTP domain fronting rendezvous method. - FrontDomain string - // ICEAddresses are a slice of ICE server URLs that will be used for NAT traversal and - // the creation of the client's WebRTC SDP offer. - ICEAddresses []string - // KeepLocalAddresses is an optional setting that will prevent the removal of local or - // invalid addresses from the client's SDP offer. This is useful for local deployments - // and testing. - KeepLocalAddresses bool - // Max is the maximum number of snowflake proxy peers that the client should attempt to - // connect to. Defaults to 1. - Max int - // UTLSClientID is the type of user application that snowflake should imitate. - // If an empty value is provided, it will use Go's default TLS implementation - UTLSClientID string - // UTLSRemoveSNI is the flag to control whether SNI should be removed from Client Hello - // when uTLS is used. - UTLSRemoveSNI bool - // BridgeFingerprint is the fingerprint of the bridge that the client will eventually - // connect to, as specified in the Bridge line of the torrc. - BridgeFingerprint string -} - -// NewSnowflakeClient creates a new Snowflake transport client that can spawn multiple -// Snowflake connections. -// -// brokerURL and frontDomain are the urls for the broker host and domain fronting host -// iceAddresses are the STUN/TURN urls needed for WebRTC negotiation -// keepLocalAddresses is a flag to enable sending local network addresses (for testing purposes) -// max is the maximum number of snowflakes the client should gather for each SOCKS connection -func NewSnowflakeClient(config ClientConfig) (*Transport, error) { - - log.Println("\n\n\n --- Starting Snowflake Client ---") - - iceServers := parseIceServers(config.ICEAddresses) - // chooses a random subset of servers from inputs - rand.Seed(time.Now().UnixNano()) - rand.Shuffle(len(iceServers), func(i, j int) { - iceServers[i], iceServers[j] = iceServers[j], iceServers[i] - }) - if len(iceServers) > 2 { - iceServers = iceServers[:(len(iceServers)+1)/2] - } - log.Printf("Using ICE servers:") - for _, server := range iceServers { - log.Printf("url: %v", strings.Join(server.URLs, " ")) - } - - // Rendezvous with broker using the given parameters. - broker, err := newBrokerChannelFromConfig(config) - if err != nil { - return nil, err - } - go updateNATType(iceServers, broker) - - max := 1 - if config.Max > max { - max = config.Max - } - eventsLogger := event.NewSnowflakeEventDispatcher() - transport := &Transport{dialer: NewWebRTCDialerWithEvents(broker, iceServers, max, eventsLogger), eventDispatcher: eventsLogger} - - return transport, nil -} - -// Dial creates a new Snowflake connection. -// Dial starts the collection of snowflakes and returns a SnowflakeConn that is a -// wrapper around a smux.Stream that will reliably deliver data to a Snowflake -// server through one or more snowflake proxies. -func (t *Transport) Dial() (net.Conn, error) { - // Cleanup functions to run before returning, in case of an error. - var cleanup []func() - defer func() { - // Run cleanup in reverse order, as defer does. - for i := len(cleanup) - 1; i >= 0; i-- { - cleanup[i]() +// Given an accepted SOCKS connection, establish a WebRTC connection to the +// remote peer and exchange traffic. +func Handler(socks SocksConnector, snowflakes SnowflakeCollector) error { + // Obtain an available WebRTC remote. May block. + snowflake := snowflakes.Pop() + if nil == snowflake { + if err := socks.Reject(); err != nil { + log.Printf("socks.Reject returned error: %v", err) } + + return errors.New("handler: Received invalid Snowflake") + } + defer socks.Close() + defer snowflake.Close() + log.Println("---- Handler: snowflake assigned ----") + err := socks.Grant(&net.TCPAddr{IP: net.IPv4zero, Port: 0}) + if err != nil { + return err + } + + go func() { + // When WebRTC resets, close the SOCKS connection too. + snowflake.WaitForReset() + socks.Close() }() - // Prepare to collect remote WebRTC peers. - snowflakes, err := NewPeers(t.dialer) - if err != nil { - return nil, err - } - cleanup = append(cleanup, func() { snowflakes.End() }) - - // Use a real logger to periodically output how much traffic is happening. - snowflakes.bytesLogger = newBytesSyncLogger() - - log.Printf("---- SnowflakeConn: begin collecting snowflakes ---") - go connectLoop(snowflakes) - - // Create a new smux session - log.Printf("---- SnowflakeConn: starting a new session ---") - pconn, sess, err := newSession(snowflakes) - if err != nil { - return nil, err - } - cleanup = append(cleanup, func() { - pconn.Close() - sess.Close() - }) - - // On the smux session we overlay a stream. - stream, err := sess.OpenStream() - if err != nil { - return nil, err - } - // Begin exchanging data. - log.Printf("---- SnowflakeConn: begin stream %v ---", stream.ID()) - cleanup = append(cleanup, func() { stream.Close() }) - - // All good, clear the cleanup list. - cleanup = nil - return &SnowflakeConn{Stream: stream, sess: sess, pconn: pconn, snowflakes: snowflakes}, nil -} -func (t *Transport) AddSnowflakeEventListener(receiver event.SnowflakeEventReceiver) { - t.eventDispatcher.AddSnowflakeEventListener(receiver) + // Begin exchanging data. Either WebRTC or localhost SOCKS will close first. + // In eithercase, this closes the handler and induces a new handler. + copyLoop(socks, snowflake) + log.Println("---- Handler: closed ---") + return nil } -func (t *Transport) RemoveSnowflakeEventListener(receiver event.SnowflakeEventReceiver) { - t.eventDispatcher.RemoveSnowflakeEventListener(receiver) -} - -// SetRendezvousMethod sets the rendezvous method to the Snowflake broker. -func (t *Transport) SetRendezvousMethod(r RendezvousMethod) { - t.dialer.Rendezvous = r -} - -// SnowflakeConn is a reliable connection to a snowflake server that implements net.Conn. -type SnowflakeConn struct { - *smux.Stream - sess *smux.Session - pconn net.PacketConn - snowflakes *Peers -} - -// Close closes the connection. -// -// The collection of snowflake proxies for this connection is stopped. -func (conn *SnowflakeConn) Close() error { - log.Printf("---- SnowflakeConn: closed stream %v ---", conn.ID()) - conn.Stream.Close() - log.Printf("---- SnowflakeConn: end collecting snowflakes ---") - conn.snowflakes.End() - conn.pconn.Close() - log.Printf("---- SnowflakeConn: discarding finished session ---") - conn.sess.Close() - return nil //TODO: return errors if any of the above do -} - -// loop through all provided STUN servers until we exhaust the list or find -// one that is compatible with RFC 5780 -func updateNATType(servers []webrtc.ICEServer, broker *BrokerChannel) { - - var restrictedNAT bool - var err error - for _, server := range servers { - addr := strings.TrimPrefix(server.URLs[0], "stun:") - restrictedNAT, err = nat.CheckIfRestrictedNAT(addr) - - if err != nil { - log.Printf("Warning: NAT checking failed for server at %s: %s", addr, err) - } else { - if restrictedNAT { - broker.SetNATType(nat.NATRestricted) - } else { - broker.SetNATType(nat.NATUnrestricted) - } - break +// Exchanges bytes between two ReadWriters. +// (In this case, between a SOCKS and WebRTC connection.) +func copyLoop(WebRTC, SOCKS io.ReadWriter) { + var wg sync.WaitGroup + wg.Add(2) + go func() { + if _, err := io.Copy(SOCKS, WebRTC); err != nil { + log.Printf("copying WebRTC to SOCKS resulted in error: %v", err) } - } - if err != nil { - broker.SetNATType(nat.NATUnknown) - } -} - -// Returns a slice of webrtc.ICEServer given a slice of addresses -func parseIceServers(addresses []string) []webrtc.ICEServer { - var servers []webrtc.ICEServer - if len(addresses) == 0 { - return nil - } - for _, url := range addresses { - url = strings.TrimSpace(url) - servers = append(servers, webrtc.ICEServer{ - URLs: []string{url}, - }) - } - return servers -} - -// newSession returns a new smux.Session and the net.PacketConn it is running -// over. The net.PacketConn successively connects through Snowflake proxies -// pulled from snowflakes. -func newSession(snowflakes SnowflakeCollector) (net.PacketConn, *smux.Session, error) { - clientID := turbotunnel.NewClientID() - - // We build a persistent KCP session on a sequence of ephemeral WebRTC - // connections. This dialContext tells RedialPacketConn how to get a new - // WebRTC connection when the previous one dies. Inside each WebRTC - // connection, we use encapsulationPacketConn to encode packets into a - // stream. - dialContext := func(ctx context.Context) (net.PacketConn, error) { - log.Printf("redialing on same connection") - // Obtain an available WebRTC remote. May block. - conn := snowflakes.Pop() - if conn == nil { - return nil, errors.New("handler: Received invalid Snowflake") - } - log.Println("---- Handler: snowflake assigned ----") - // Send the magic Turbo Tunnel token. - _, err := conn.Write(turbotunnel.Token[:]) - if err != nil { - return nil, err - } - // Send ClientID prefix. - _, err = conn.Write(clientID[:]) - if err != nil { - return nil, err - } - return newEncapsulationPacketConn(dummyAddr{}, dummyAddr{}, conn), nil - } - pconn := turbotunnel.NewRedialPacketConn(dummyAddr{}, dummyAddr{}, dialContext) - - // conn is built on the underlying RedialPacketConn—when one WebRTC - // connection dies, another one will be found to take its place. The - // sequence of packets across multiple WebRTC connections drives the KCP - // engine. - conn, err := kcp.NewConn2(dummyAddr{}, nil, 0, 0, pconn) - if err != nil { - pconn.Close() - return nil, nil, err - } - // Permit coalescing the payloads of consecutive sends. - conn.SetStreamMode(true) - // Set the maximum send and receive window sizes to a high number - // Removes KCP bottlenecks: https://gitlab.torproject.org/tpo/anti-censorship/pluggable-transports/snowflake/-/issues/40026 - conn.SetWindowSize(WindowSize, WindowSize) - // Disable the dynamic congestion window (limit only by the - // maximum of local and remote static windows). - conn.SetNoDelay( - 0, // default nodelay - 0, // default interval - 0, // default resend - 1, // nc=1 => congestion window off - ) - // On the KCP connection we overlay an smux session and stream. - smuxConfig := smux.DefaultConfig() - smuxConfig.Version = 2 - smuxConfig.KeepAliveTimeout = 10 * time.Minute - smuxConfig.MaxStreamBuffer = StreamSize - - sess, err := smux.Client(conn, smuxConfig) - if err != nil { - conn.Close() - pconn.Close() - return nil, nil, err - } - - return pconn, sess, err -} - -// Maintain |SnowflakeCapacity| number of available WebRTC connections, to -// transfer to the Tor SOCKS handler when needed. -func connectLoop(snowflakes SnowflakeCollector) { - for { - timer := time.After(ReconnectTimeout) - _, err := snowflakes.Collect() - if err != nil { - log.Printf("WebRTC: %v Retrying...", err) - } - select { - case <-timer: - continue - case <-snowflakes.Melted(): - log.Println("ConnectLoop: stopped.") - return - } - } + wg.Done() + }() + go func() { + if _, err := io.Copy(WebRTC, SOCKS); err != nil { + log.Printf("copying SOCKS to WebRTC resulted in error: %v", err) + } + wg.Done() + }() + wg.Wait() + log.Println("copy loop ended") } diff --git a/client/lib/turbotunnel.go b/client/lib/turbotunnel.go deleted file mode 100644 index 8cdce42..0000000 --- a/client/lib/turbotunnel.go +++ /dev/null @@ -1,68 +0,0 @@ -package snowflake_client - -import ( - "bufio" - "errors" - "io" - "net" - "time" - - "git.torproject.org/pluggable-transports/snowflake.git/v2/common/encapsulation" -) - -var errNotImplemented = errors.New("not implemented") - -// encapsulationPacketConn implements the net.PacketConn interface over an -// io.ReadWriteCloser stream, using the encapsulation package to represent -// packets in a stream. -type encapsulationPacketConn struct { - io.ReadWriteCloser - localAddr net.Addr - remoteAddr net.Addr - bw *bufio.Writer -} - -// NewEncapsulationPacketConn makes -func newEncapsulationPacketConn( - localAddr, remoteAddr net.Addr, - conn io.ReadWriteCloser, -) *encapsulationPacketConn { - return &encapsulationPacketConn{ - ReadWriteCloser: conn, - localAddr: localAddr, - remoteAddr: remoteAddr, - bw: bufio.NewWriter(conn), - } -} - -// ReadFrom reads an encapsulated packet from the stream. -func (c *encapsulationPacketConn) ReadFrom(p []byte) (int, net.Addr, error) { - data, err := encapsulation.ReadData(c.ReadWriteCloser) - if err != nil { - return 0, c.remoteAddr, err - } - return copy(p, data), c.remoteAddr, nil -} - -// WriteTo writes an encapsulated packet to the stream. -func (c *encapsulationPacketConn) WriteTo(p []byte, addr net.Addr) (int, error) { - // addr is ignored. - _, err := encapsulation.WriteData(c.bw, p) - if err == nil { - err = c.bw.Flush() - } - if err != nil { - return 0, err - } - return len(p), nil -} - -// LocalAddr returns the localAddr value that was passed to -// NewEncapsulationPacketConn. -func (c *encapsulationPacketConn) LocalAddr() net.Addr { - return c.localAddr -} - -func (c *encapsulationPacketConn) SetDeadline(t time.Time) error { return errNotImplemented } -func (c *encapsulationPacketConn) SetReadDeadline(t time.Time) error { return errNotImplemented } -func (c *encapsulationPacketConn) SetWriteDeadline(t time.Time) error { return errNotImplemented } diff --git a/client/lib/util.go b/client/lib/util.go index 42c8f97..f93bcbe 100644 --- a/client/lib/util.go +++ b/client/lib/util.go @@ -1,70 +1,137 @@ -package snowflake_client +package lib import ( + "encoding/json" "log" "time" + + "github.com/pion/webrtc" ) const ( - LogTimeInterval = 5 * time.Second + LogTimeInterval = 5 ) -type bytesLogger interface { - addOutbound(int) - addInbound(int) +type BytesLogger interface { + Log() + AddOutbound(int) + AddInbound(int) } -// Default bytesLogger does nothing. -type bytesNullLogger struct{} +// Default BytesLogger does nothing. +type BytesNullLogger struct{} -func (b bytesNullLogger) addOutbound(amount int) {} -func (b bytesNullLogger) addInbound(amount int) {} +func (b BytesNullLogger) Log() {} +func (b BytesNullLogger) AddOutbound(amount int) {} +func (b BytesNullLogger) AddInbound(amount int) {} -// bytesSyncLogger uses channels to safely log from multiple sources with output +// BytesSyncLogger uses channels to safely log from multiple sources with output // occuring at reasonable intervals. -type bytesSyncLogger struct { - outboundChan chan int - inboundChan chan int +type BytesSyncLogger struct { + OutboundChan chan int + InboundChan chan int + Outbound int + Inbound int + OutEvents int + InEvents int + IsLogging bool } -// newBytesSyncLogger returns a new bytesSyncLogger and starts it loggin. -func newBytesSyncLogger() *bytesSyncLogger { - b := &bytesSyncLogger{ - outboundChan: make(chan int, 5), - inboundChan: make(chan int, 5), +func (b *BytesSyncLogger) Log() { + b.IsLogging = true + var amount int + output := func() { + log.Printf("Traffic Bytes (in|out): %d | %d -- (%d OnMessages, %d Sends)", + b.Inbound, b.Outbound, b.InEvents, b.OutEvents) + b.Outbound = 0 + b.OutEvents = 0 + b.Inbound = 0 + b.InEvents = 0 } - go b.log() - return b -} - -func (b *bytesSyncLogger) log() { - var outbound, inbound, outEvents, inEvents int - ticker := time.NewTicker(LogTimeInterval) + last := time.Now() for { select { - case <-ticker.C: - if outEvents > 0 || inEvents > 0 { - log.Printf("Traffic Bytes (in|out): %d | %d -- (%d OnMessages, %d Sends)", - inbound, outbound, inEvents, outEvents) + case amount = <-b.OutboundChan: + b.Outbound += amount + b.OutEvents++ + if time.Since(last) > time.Second*LogTimeInterval { + last = time.Now() + output() + } + case amount = <-b.InboundChan: + b.Inbound += amount + b.InEvents++ + if time.Since(last) > time.Second*LogTimeInterval { + last = time.Now() + output() + } + case <-time.After(time.Second * LogTimeInterval): + if b.InEvents > 0 || b.OutEvents > 0 { + output() } - outbound = 0 - outEvents = 0 - inbound = 0 - inEvents = 0 - case amount := <-b.outboundChan: - outbound += amount - outEvents++ - case amount := <-b.inboundChan: - inbound += amount - inEvents++ } } } -func (b *bytesSyncLogger) addOutbound(amount int) { - b.outboundChan <- amount +func (b *BytesSyncLogger) AddOutbound(amount int) { + if !b.IsLogging { + return + } + b.OutboundChan <- amount } -func (b *bytesSyncLogger) addInbound(amount int) { - b.inboundChan <- amount +func (b *BytesSyncLogger) AddInbound(amount int) { + if !b.IsLogging { + return + } + b.InboundChan <- amount +} +func deserializeSessionDescription(msg string) *webrtc.SessionDescription { + var parsed map[string]interface{} + err := json.Unmarshal([]byte(msg), &parsed) + if nil != err { + log.Println(err) + return nil + } + if _, ok := parsed["type"]; !ok { + log.Println("Cannot deserialize SessionDescription without type field.") + return nil + } + if _, ok := parsed["sdp"]; !ok { + log.Println("Cannot deserialize SessionDescription without sdp field.") + return nil + } + + var stype webrtc.SDPType + switch parsed["type"].(string) { + default: + log.Println("Unknown SDP type") + return nil + case "offer": + stype = webrtc.SDPTypeOffer + case "pranswer": + stype = webrtc.SDPTypePranswer + case "answer": + stype = webrtc.SDPTypeAnswer + case "rollback": + stype = webrtc.SDPTypeRollback + } + + if err != nil { + log.Println(err) + return nil + } + return &webrtc.SessionDescription{ + Type: stype, + SDP: parsed["sdp"].(string), + } +} + +func serializeSessionDescription(desc *webrtc.SessionDescription) string { + bytes, err := json.Marshal(*desc) + if nil != err { + log.Println(err) + return "" + } + return string(bytes) } diff --git a/client/lib/webrtc.go b/client/lib/webrtc.go index 01990e0..9d1ba37 100644 --- a/client/lib/webrtc.go +++ b/client/lib/webrtc.go @@ -1,82 +1,67 @@ -package snowflake_client +package lib import ( - "crypto/rand" - "encoding/hex" + "bytes" "errors" "io" "log" "sync" "time" - "git.torproject.org/pluggable-transports/snowflake.git/v2/common/event" - "github.com/pion/ice/v2" - "github.com/pion/webrtc/v3" + "github.com/dchest/uniuri" + "github.com/pion/webrtc" ) -// WebRTCPeer represents a WebRTC connection to a remote snowflake proxy. +// Remote WebRTC peer. +// Implements the |Snowflake| interface, which includes +// |io.ReadWriter|, |Resetter|, and |Connector|. // -// Each WebRTCPeer only ever has one DataChannel that is used as the peer's transport. +// Handles preparation of go-webrtc PeerConnection. Only ever has +// one DataChannel. type WebRTCPeer struct { id string + config *webrtc.Configuration pc *webrtc.PeerConnection - transport *webrtc.DataChannel + transport SnowflakeDataChannel // Holds the WebRTC DataChannel. + broker *BrokerChannel - recvPipe *io.PipeReader - writePipe *io.PipeWriter + offerChannel chan *webrtc.SessionDescription + answerChannel chan *webrtc.SessionDescription + errorChannel chan error + recvPipe *io.PipeReader + writePipe *io.PipeWriter + lastReceive time.Time + buffer bytes.Buffer + reset chan struct{} - mu sync.Mutex // protects the following: - lastReceive time.Time + closed bool - open chan struct{} // Channel to notify when datachannel opens - closed chan struct{} + lock sync.Mutex // Synchronization for DataChannel destruction + once sync.Once // Synchronization for PeerConnection destruction - once sync.Once // Synchronization for PeerConnection destruction - - bytesLogger bytesLogger - eventsLogger event.SnowflakeEventReceiver + BytesLogger } +// Construct a WebRTC PeerConnection. func NewWebRTCPeer(config *webrtc.Configuration, - broker *BrokerChannel) (*WebRTCPeer, error) { - return NewWebRTCPeerWithEvents(config, broker, nil) -} - -// NewWebRTCPeerWithEvents constructs a WebRTC PeerConnection to a snowflake proxy. -// -// The creation of the peer handles the signaling to the Snowflake broker, including -// the exchange of SDP information, the creation of a PeerConnection, and the establishment -// of a DataChannel to the Snowflake proxy. -func NewWebRTCPeerWithEvents(config *webrtc.Configuration, - broker *BrokerChannel, eventsLogger event.SnowflakeEventReceiver) (*WebRTCPeer, error) { - if eventsLogger == nil { - eventsLogger = event.NewSnowflakeEventDispatcher() - } - + broker *BrokerChannel) *WebRTCPeer { connection := new(WebRTCPeer) - { - var buf [8]byte - if _, err := rand.Read(buf[:]); err != nil { - panic(err) - } - connection.id = "snowflake-" + hex.EncodeToString(buf[:]) - } - connection.closed = make(chan struct{}) + connection.id = "snowflake-" + uniuri.New() + connection.config = config + connection.broker = broker + connection.offerChannel = make(chan *webrtc.SessionDescription, 1) + connection.answerChannel = make(chan *webrtc.SessionDescription, 1) + // Error channel is mostly for reporting during the initial SDP offer + // creation & local description setting, which happens asynchronously. + connection.errorChannel = make(chan error, 1) + connection.reset = make(chan struct{}, 1) // Override with something that's not NullLogger to have real logging. - connection.bytesLogger = &bytesNullLogger{} + connection.BytesLogger = &BytesNullLogger{} // Pipes remain the same even when DataChannel gets switched. connection.recvPipe, connection.writePipe = io.Pipe() - - connection.eventsLogger = eventsLogger - - err := connection.connect(config, broker) - if err != nil { - connection.Close() - return nil, err - } - return connection, nil + return connection } // Read bytes from local SOCKS. @@ -88,145 +73,200 @@ func (c *WebRTCPeer) Read(b []byte) (int, error) { // Writes bytes out to remote WebRTC. // As part of |io.ReadWriter| func (c *WebRTCPeer) Write(b []byte) (int, error) { - err := c.transport.Send(b) - if err != nil { - return 0, err + c.lock.Lock() + defer c.lock.Unlock() + c.BytesLogger.AddOutbound(len(b)) + // TODO: Buffering could be improved / separated out of WebRTCPeer. + if nil == c.transport { + log.Printf("Buffered %d bytes --> WebRTC", len(b)) + c.buffer.Write(b) + } else { + c.transport.Send(b) } - c.bytesLogger.addOutbound(len(b)) return len(b), nil } -// Closed returns a boolean indicated whether the peer is closed. -func (c *WebRTCPeer) Closed() bool { - select { - case <-c.closed: - return true - default: - } - return false -} - -// Close closes the connection the snowflake proxy. +// As part of |Snowflake| func (c *WebRTCPeer) Close() error { c.once.Do(func() { - close(c.closed) + c.closed = true c.cleanup() + c.Reset() log.Printf("WebRTC: Closing") }) return nil } +// As part of |Resetter| +func (c *WebRTCPeer) Reset() { + if nil == c.reset { + return + } + c.reset <- struct{}{} +} + +// As part of |Resetter| +func (c *WebRTCPeer) WaitForReset() { <-c.reset } + // Prevent long-lived broken remotes. // Should also update the DataChannel in underlying go-webrtc's to make Closes // more immediate / responsive. -func (c *WebRTCPeer) checkForStaleness(timeout time.Duration) { - c.mu.Lock() +func (c *WebRTCPeer) checkForStaleness() { c.lastReceive = time.Now() - c.mu.Unlock() for { - c.mu.Lock() - lastReceive := c.lastReceive - c.mu.Unlock() - if time.Since(lastReceive) > timeout { - log.Printf("WebRTC: No messages received for %v -- closing stale connection.", - timeout) - err := errors.New("no messages received, closing stale connection") - c.eventsLogger.OnNewSnowflakeEvent(event.EventOnSnowflakeConnectionFailed{Error: err}) + if c.closed { + return + } + if time.Since(c.lastReceive).Seconds() > SnowflakeTimeout { + log.Println("WebRTC: No messages received for", SnowflakeTimeout, + "seconds -- closing stale connection.") c.Close() return } - select { - case <-c.closed: - return - case <-time.After(time.Second): - } + <-time.After(time.Second) } } -func (c *WebRTCPeer) connect(config *webrtc.Configuration, broker *BrokerChannel) error { +// As part of |Connector| interface. +func (c *WebRTCPeer) Connect() error { log.Println(c.id, " connecting...") // TODO: When go-webrtc is more stable, it's possible that a new // PeerConnection won't need to be re-prepared each time. - err := c.preparePeerConnection(config) - localDescription := c.pc.LocalDescription() - c.eventsLogger.OnNewSnowflakeEvent(event.EventOnOfferCreated{ - WebRTCLocalDescription: localDescription, - Error: err, - }) + err := c.preparePeerConnection() if err != nil { return err } - - answer, err := broker.Negotiate(localDescription) - c.eventsLogger.OnNewSnowflakeEvent(event.EventOnBrokerRendezvous{ - WebRTCRemoteDescription: answer, - Error: err, - }) + err = c.establishDataChannel() + if err != nil { + // nolint: golint + return errors.New("WebRTC: Could not establish DataChannel") + } + err = c.exchangeSDP() if err != nil { return err } - log.Printf("Received Answer.\n") - err = c.pc.SetRemoteDescription(*answer) - if nil != err { - log.Println("WebRTC: Unable to SetRemoteDescription:", err) - return err - } - - // Wait for the datachannel to open or time out - select { - case <-c.open: - case <-time.After(DataChannelTimeout): - c.transport.Close() - err = errors.New("timeout waiting for DataChannel.OnOpen") - c.eventsLogger.OnNewSnowflakeEvent(event.EventOnSnowflakeConnectionFailed{Error: err}) - return err - } - - go c.checkForStaleness(SnowflakeTimeout) + go c.checkForStaleness() return nil } -// preparePeerConnection creates a new WebRTC PeerConnection and returns it -// after ICE candidate gathering is complete.. -func (c *WebRTCPeer) preparePeerConnection(config *webrtc.Configuration) error { - var err error +// Create and prepare callbacks on a new WebRTC PeerConnection. +func (c *WebRTCPeer) preparePeerConnection() error { + if nil != c.pc { + if err := c.pc.Close(); err != nil { + log.Printf("c.pc.Close returned error: %v", err) + } + c.pc = nil + } + s := webrtc.SettingEngine{} - s.SetICEMulticastDNSMode(ice.MulticastDNSModeDisabled) + s.SetTrickle(true) api := webrtc.NewAPI(webrtc.WithSettingEngine(s)) - c.pc, err = api.NewPeerConnection(*config) + pc, err := api.NewPeerConnection(*c.config) if err != nil { log.Printf("NewPeerConnection ERROR: %s", err) return err } + // Prepare PeerConnection callbacks. + // Allow candidates to accumulate until ICEGatheringStateComplete. + pc.OnICECandidate(func(candidate *webrtc.ICECandidate) { + if candidate == nil { + log.Printf("WebRTC: Done gathering candidates") + } else { + log.Printf("WebRTC: Got ICE candidate: %s", candidate.String()) + } + }) + pc.OnICEGatheringStateChange(func(state webrtc.ICEGathererState) { + if state == webrtc.ICEGathererStateComplete { + log.Println("WebRTC: ICEGatheringStateComplete") + c.offerChannel <- pc.LocalDescription() + } + }) + // This callback is not expected, as the Client initiates the creation + // of the data channel, not the remote peer. + pc.OnDataChannel(func(channel *webrtc.DataChannel) { + log.Println("OnDataChannel") + panic("Unexpected OnDataChannel!") + }) + c.pc = pc + go func() { + offer, err := pc.CreateOffer(nil) + // TODO: Potentially timeout and retry if ICE isn't working. + if err != nil { + c.errorChannel <- err + return + } + log.Println("WebRTC: Created offer") + err = pc.SetLocalDescription(offer) + if err != nil { + c.errorChannel <- err + return + } + log.Println("WebRTC: Set local description") + }() + log.Println("WebRTC: PeerConnection created.") + return nil +} + +// Create a WebRTC DataChannel locally. +func (c *WebRTCPeer) establishDataChannel() error { + c.lock.Lock() + defer c.lock.Unlock() + if c.transport != nil { + panic("Unexpected datachannel already exists!") + } ordered := true dataChannelOptions := &webrtc.DataChannelInit{ Ordered: &ordered, } - // We must create the data channel before creating an offer - // https://github.com/pion/webrtc/wiki/Release-WebRTC@v3.0.0 dc, err := c.pc.CreateDataChannel(c.id, dataChannelOptions) + // Triggers "OnNegotiationNeeded" on the PeerConnection, which will prepare + // an SDP offer while other goroutines operating on this struct handle the + // signaling. Eventually fires "OnOpen". if err != nil { log.Printf("CreateDataChannel ERROR: %s", err) return err } dc.OnOpen(func() { - c.eventsLogger.OnNewSnowflakeEvent(event.EventOnSnowflakeConnected{}) + c.lock.Lock() + defer c.lock.Unlock() log.Println("WebRTC: DataChannel.OnOpen") - close(c.open) + if nil != c.transport { + panic("WebRTC: transport already exists.") + } + // Flush buffered outgoing SOCKS data if necessary. + if c.buffer.Len() > 0 { + dc.Send(c.buffer.Bytes()) + log.Println("Flushed", c.buffer.Len(), "bytes.") + c.buffer.Reset() + } + // Then enable the datachannel. + c.transport = dc }) dc.OnClose(func() { - log.Println("WebRTC: DataChannel.OnClose") + c.lock.Lock() + // Future writes will go to the buffer until a new DataChannel is available. + if nil == c.transport { + // Closed locally, as part of a reset. + log.Println("WebRTC: DataChannel.OnClose [locally]") + c.lock.Unlock() + return + } + // Closed remotely, need to reset everything. + // Disable the DataChannel as a write destination. + log.Println("WebRTC: DataChannel.OnClose [remotely]") + c.transport = nil + dc.Close() + // Unlock before Close'ing, since it calls cleanup and asks for the + // lock to check if the transport needs to be be deleted. + c.lock.Unlock() c.Close() }) - dc.OnError(func(err error) { - c.eventsLogger.OnNewSnowflakeEvent(event.EventOnSnowflakeConnectionFailed{Error: err}) - }) dc.OnMessage(func(msg webrtc.DataChannelMessage) { if len(msg.Data) <= 0 { log.Println("0 length message---") } + c.BytesLogger.AddInbound(len(msg.Data)) n, err := c.writePipe.Write(msg.Data) - c.bytesLogger.addInbound(n) if err != nil { // TODO: Maybe shouldn't actually close. log.Println("Error writing to SOCKS pipe") @@ -234,46 +274,93 @@ func (c *WebRTCPeer) preparePeerConnection(config *webrtc.Configuration) error { log.Printf("c.writePipe.CloseWithError returned error: %v", inerr) } } - c.mu.Lock() + if n != len(msg.Data) { + log.Println("Error: short write") + panic("short write") + } c.lastReceive = time.Now() - c.mu.Unlock() }) - c.transport = dc - c.open = make(chan struct{}) log.Println("WebRTC: DataChannel created.") - - // Allow candidates to accumulate until ICEGatheringStateComplete. - done := webrtc.GatheringCompletePromise(c.pc) - offer, err := c.pc.CreateOffer(nil) - // TODO: Potentially timeout and retry if ICE isn't working. - if err != nil { - log.Println("Failed to prepare offer", err) - c.pc.Close() - return err - } - log.Println("WebRTC: Created offer") - err = c.pc.SetLocalDescription(offer) - if err != nil { - log.Println("Failed to prepare offer", err) - c.pc.Close() - return err - } - log.Println("WebRTC: Set local description") - - <-done // Wait for ICE candidate gathering to complete. - log.Println("WebRTC: PeerConnection created.") return nil } -// cleanup closes all channels and transports -func (c *WebRTCPeer) cleanup() { - // Close this side of the SOCKS pipe. - if c.writePipe != nil { // c.writePipe can be nil in tests. - c.writePipe.Close() +func (c *WebRTCPeer) sendOfferToBroker() { + if nil == c.broker { + return } + offer := c.pc.LocalDescription() + answer, err := c.broker.Negotiate(offer) + if nil != err || nil == answer { + log.Printf("BrokerChannel Error: %s", err) + answer = nil + } + c.answerChannel <- answer +} + +// Block until an SDP offer is available, send it to either +// the Broker or signal pipe, then await for the SDP answer. +func (c *WebRTCPeer) exchangeSDP() error { + select { + case <-c.offerChannel: + case err := <-c.errorChannel: + log.Println("Failed to prepare offer", err) + c.Close() + return err + } + // Keep trying the same offer until a valid answer arrives. + var ok bool + var answer *webrtc.SessionDescription + for nil == answer { + go c.sendOfferToBroker() + answer, ok = <-c.answerChannel // Blocks... + if !ok || nil == answer { + log.Printf("Failed to retrieve answer. Retrying in %d seconds", ReconnectTimeout) + <-time.After(time.Second * ReconnectTimeout) + answer = nil + } + } + log.Printf("Received Answer.\n") + err := c.pc.SetRemoteDescription(*answer) + if nil != err { + log.Println("WebRTC: Unable to SetRemoteDescription:", err) + return err + } + return nil +} + +// Close all channels and transports +func (c *WebRTCPeer) cleanup() { + if nil != c.offerChannel { + close(c.offerChannel) + } + if nil != c.answerChannel { + close(c.answerChannel) + } + if nil != c.errorChannel { + close(c.errorChannel) + } + // Close this side of the SOCKS pipe. + if nil != c.writePipe { + c.writePipe.Close() + c.writePipe = nil + } + c.lock.Lock() if nil != c.transport { log.Printf("WebRTC: closing DataChannel") - c.transport.Close() + dataChannel := c.transport + // Setting transport to nil *before* dc Close indicates to OnClose that + // this was locally triggered. + c.transport = nil + // Release the lock before calling DeleteDataChannel (which in turn + // calls Close on the dataChannel), but after nil'ing out the transport, + // since otherwise we'll end up in the onClose handler in a deadlock. + c.lock.Unlock() + if c.pc == nil { + panic("DataChannel w/o PeerConnection, not good.") + } + dataChannel.(*webrtc.DataChannel).Close() + } else { + c.lock.Unlock() } if nil != c.pc { log.Printf("WebRTC: closing PeerConnection") @@ -281,5 +368,6 @@ func (c *WebRTCPeer) cleanup() { if nil != err { log.Printf("Error closing peerconnection...") } + c.pc = nil } } diff --git a/client/snowflake.go b/client/snowflake.go index 2cb6549..959f83c 100644 --- a/client/snowflake.go +++ b/client/snowflake.go @@ -10,170 +10,101 @@ import ( "os" "os/signal" "path/filepath" - "strconv" "strings" - "sync" "syscall" + "time" pt "git.torproject.org/pluggable-transports/goptlib.git" - sf "git.torproject.org/pluggable-transports/snowflake.git/v2/client/lib" - "git.torproject.org/pluggable-transports/snowflake.git/v2/common/event" - "git.torproject.org/pluggable-transports/snowflake.git/v2/common/safelog" + sf "git.torproject.org/pluggable-transports/snowflake.git/client/lib" + "git.torproject.org/pluggable-transports/snowflake.git/common/safelog" + "github.com/pion/webrtc" ) const ( DefaultSnowflakeCapacity = 1 ) -type ptEventLogger struct { -} - -func NewPTEventLogger() event.SnowflakeEventReceiver { - return &ptEventLogger{} -} - -func (p ptEventLogger) OnNewSnowflakeEvent(e event.SnowflakeEvent) { - pt.Log(pt.LogSeverityNotice, e.String()) -} - -// Exchanges bytes between two ReadWriters. -// (In this case, between a SOCKS connection and a snowflake transport conn) -func copyLoop(socks, sfconn io.ReadWriter) { - done := make(chan struct{}, 2) - go func() { - if _, err := io.Copy(socks, sfconn); err != nil { - log.Printf("copying Snowflake to SOCKS resulted in error: %v", err) - } - done <- struct{}{} - }() - go func() { - if _, err := io.Copy(sfconn, socks); err != nil { - log.Printf("copying SOCKS to Snowflake resulted in error: %v", err) - } - done <- struct{}{} - }() - <-done - log.Println("copy loop ended") -} - -// Accept local SOCKS connections and connect to a Snowflake connection -func socksAcceptLoop(ln *pt.SocksListener, config sf.ClientConfig, shutdown chan struct{}, wg *sync.WaitGroup) { - defer ln.Close() +// Maintain |SnowflakeCapacity| number of available WebRTC connections, to +// transfer to the Tor SOCKS handler when needed. +func ConnectLoop(snowflakes sf.SnowflakeCollector) { for { + // Check if ending is necessary. + _, err := snowflakes.Collect() + if nil != err { + log.Println("WebRTC:", err, + " Retrying in", sf.ReconnectTimeout, "seconds...") + } + select { + case <-time.After(time.Second * sf.ReconnectTimeout): + continue + case <-snowflakes.Melted(): + log.Println("ConnectLoop: stopped.") + return + } + } +} + +// Accept local SOCKS connections and pass them to the handler. +func socksAcceptLoop(ln *pt.SocksListener, snowflakes sf.SnowflakeCollector) { + defer ln.Close() + log.Println("Started SOCKS listener.") + for { + log.Println("SOCKS listening...") conn, err := ln.AcceptSocks() if err != nil { - if err, ok := err.(net.Error); ok && err.Temporary() { + if e, ok := err.(net.Error); ok && e.Temporary() { continue } log.Printf("SOCKS accept error: %s", err) break } - log.Printf("SOCKS accepted: %v", conn.Req) - wg.Add(1) - go func() { - defer wg.Done() - defer conn.Close() - - // Check to see if our command line options are overriden by SOCKS options - if arg, ok := conn.Req.Args.Get("ampcache"); ok { - config.AmpCacheURL = arg - } - if arg, ok := conn.Req.Args.Get("front"); ok { - config.FrontDomain = arg - } - if arg, ok := conn.Req.Args.Get("ice"); ok { - config.ICEAddresses = strings.Split(strings.TrimSpace(arg), ",") - } - if arg, ok := conn.Req.Args.Get("max"); ok { - max, err := strconv.Atoi(arg) - if err != nil { - conn.Reject() - log.Println("Invalid SOCKS arg: max=", arg) - return - } - config.Max = max - } - if arg, ok := conn.Req.Args.Get("url"); ok { - config.BrokerURL = arg - } - if arg, ok := conn.Req.Args.Get("utls-nosni"); ok { - switch strings.ToLower(arg) { - case "true": - fallthrough - case "yes": - config.UTLSRemoveSNI = true - } - } - if arg, ok := conn.Req.Args.Get("utls-imitate"); ok { - config.UTLSClientID = arg - } - if arg, ok := conn.Req.Args.Get("fingerprint"); ok { - config.BridgeFingerprint = arg - } - transport, err := sf.NewSnowflakeClient(config) - if err != nil { - conn.Reject() - log.Println("Failed to start snowflake transport: ", err) - return - } - transport.AddSnowflakeEventListener(NewPTEventLogger()) - err = conn.Grant(&net.TCPAddr{IP: net.IPv4zero, Port: 0}) - if err != nil { - log.Printf("conn.Grant error: %s", err) - return - } - - handler := make(chan struct{}) - go func() { - defer close(handler) - sconn, err := transport.Dial() - if err != nil { - log.Printf("dial error: %s", err) - return - } - defer sconn.Close() - // copy between the created Snowflake conn and the SOCKS conn - copyLoop(conn, sconn) - }() - select { - case <-shutdown: - log.Println("Received shutdown signal") - case <-handler: - log.Println("Handler ended") - } - return - }() + log.Println("SOCKS accepted: ", conn.Req) + err = sf.Handler(conn, snowflakes) + if err != nil { + log.Printf("handler error: %s", err) + } } } +//s is a comma-separated list of ICE server URLs +func parseIceServers(s string) []webrtc.ICEServer { + var servers []webrtc.ICEServer + log.Println(s) + s = strings.TrimSpace(s) + if len(s) == 0 { + return nil + } + urls := strings.Split(s, ",") + log.Printf("Using ICE Servers:") + for _, url := range urls { + log.Printf("url: %s", url) + servers = append(servers, webrtc.ICEServer{ + URLs: []string{url}, + }) + } + return servers +} + func main() { iceServersCommas := flag.String("ice", "", "comma-separated list of ICE servers") brokerURL := flag.String("url", "", "URL of signaling broker") frontDomain := flag.String("front", "", "front domain") - ampCacheURL := flag.String("ampcache", "", "URL of AMP cache to use as a proxy for signaling") logFilename := flag.String("log", "", "name of log file") - logToStateDir := flag.Bool("log-to-state-dir", false, "resolve the log file relative to tor's pt state dir") - keepLocalAddresses := flag.Bool("keep-local-addresses", false, "keep local LAN address ICE candidates") - unsafeLogging := flag.Bool("unsafe-logging", false, "prevent logs from being scrubbed") + logToStateDir := flag.Bool("logToStateDir", false, "resolve the log file relative to tor's pt state dir") max := flag.Int("max", DefaultSnowflakeCapacity, "capacity for number of multiplexed WebRTC peers") - - // Deprecated - oldLogToStateDir := flag.Bool("logToStateDir", false, "use -log-to-state-dir instead") - oldKeepLocalAddresses := flag.Bool("keepLocalAddresses", false, "use -keep-local-addresses instead") - flag.Parse() log.SetFlags(log.LstdFlags | log.LUTC) - // Don't write to stderr; versions of tor earlier than about 0.3.5.6 do - // not read from the pipe, and eventually we will deadlock because the - // buffer is full. + // Don't write to stderr; versions of tor earlier than about + // 0.3.5.6 do not read from the pipe, and eventually we will + // deadlock because the buffer is full. // https://bugs.torproject.org/26360 // https://bugs.torproject.org/25600#comment:14 var logOutput = ioutil.Discard if *logFilename != "" { - if *logToStateDir || *oldLogToStateDir { + if *logToStateDir { stateDir, err := pt.MakeStateDir() if err != nil { log.Fatal(err) @@ -188,23 +119,36 @@ func main() { defer logFile.Close() logOutput = logFile } - if *unsafeLogging { - log.SetOutput(logOutput) - } else { - // We want to send the log output through our scrubber first - log.SetOutput(&safelog.LogScrubber{Output: logOutput}) - } + //We want to send the log output through our scrubber first + log.SetOutput(&safelog.LogScrubber{Output: logOutput}) - iceAddresses := strings.Split(strings.TrimSpace(*iceServersCommas), ",") + log.Println("\n\n\n --- Starting Snowflake Client ---") - config := sf.ClientConfig{ - BrokerURL: *brokerURL, - AmpCacheURL: *ampCacheURL, - FrontDomain: *frontDomain, - ICEAddresses: iceAddresses, - KeepLocalAddresses: *keepLocalAddresses || *oldKeepLocalAddresses, - Max: *max, + iceServers := parseIceServers(*iceServersCommas) + + // Prepare to collect remote WebRTC peers. + snowflakes := sf.NewPeers(*max) + + // Use potentially domain-fronting broker to rendezvous. + broker := sf.NewBrokerChannel(*brokerURL, *frontDomain, sf.CreateBrokerTransport()) + snowflakes.Tongue = sf.NewWebRTCDialer(broker, iceServers) + + if nil == snowflakes.Tongue { + log.Fatal("Unable to prepare rendezvous method.") + return } + // Use a real logger to periodically output how much traffic is happening. + snowflakes.BytesLogger = &sf.BytesSyncLogger{ + InboundChan: make(chan int, 5), + OutboundChan: make(chan int, 5), + Inbound: 0, + Outbound: 0, + InEvents: 0, + OutEvents: 0, + } + go snowflakes.BytesLogger.Log() + + go ConnectLoop(snowflakes) // Begin goptlib client process. ptInfo, err := pt.ClientSetup(nil) @@ -212,27 +156,30 @@ func main() { log.Fatal(err) } if ptInfo.ProxyURL != nil { - pt.ProxyError("proxy is not supported") + if err := pt.ProxyError("proxy is not supported"); err != nil { + log.Printf("call to pt.ProxyError generated error: %v", err) + } os.Exit(1) } listeners := make([]net.Listener, 0) - shutdown := make(chan struct{}) - var wg sync.WaitGroup for _, methodName := range ptInfo.MethodNames { switch methodName { case "snowflake": // TODO: Be able to recover when SOCKS dies. ln, err := pt.ListenSocks("tcp", "127.0.0.1:0") if err != nil { - pt.CmethodError(methodName, err.Error()) + if inerr := pt.CmethodError(methodName, err.Error()); inerr != nil { + log.Printf("handling error generated by pt.ListenSocks with pt.CmethodError generated error: %v", inerr) + } break } - log.Printf("Started SOCKS listener at %v.", ln.Addr()) - go socksAcceptLoop(ln, config, shutdown, &wg) + go socksAcceptLoop(ln, snowflakes) pt.Cmethod(methodName, ln.Version(), ln.Addr()) listeners = append(listeners, ln) default: - pt.CmethodError(methodName, "no such method") + if err := pt.CmethodError(methodName, "no such method"); err != nil { + log.Printf("calling pt.CmethodError generated error: %v", err) + } } } pt.CmethodsDone() @@ -252,15 +199,13 @@ func main() { }() } - // Wait for a signal. + // keep track of handlers and wait for a signal <-sigChan - log.Println("stopping snowflake") - // Signal received, shut down. + // signal received, shut down for _, ln := range listeners { ln.Close() } - close(shutdown) - wg.Wait() + snowflakes.End() log.Println("snowflake is done.") } diff --git a/client/torrc b/client/torrc index aee4df1..6acf1c4 100644 --- a/client/torrc +++ b/client/torrc @@ -1,8 +1,10 @@ UseBridges 1 DataDirectory datadir -ClientTransportPlugin snowflake exec ./client -log snowflake.log +ClientTransportPlugin snowflake exec ./client \ +-url https://snowflake-broker.azureedge.net/ \ +-front ajax.aspnetcdn.com \ +-ice stun:stun.l.google.com:19302 \ +-max 3 -Bridge snowflake 192.0.2.3:1 2B280B23E1107BB62ABFC40DDCC8824814F80A72 fingerprint=2B280B23E1107BB62ABFC40DDCC8824814F80A72 url=https://snowflake-broker.torproject.net.global.prod.fastly.net/ front=cdn.sstatic.net ice=stun:stun.voip.blackberry.com:3478,stun:stun.altar.com.pl:3478,stun:stun.antisip.com:3478,stun:stun.bluesip.net:3478,stun:stun.dus.net:3478,stun:stun.epygi.com:3478,stun:stun.sonetel.com:3478,stun:stun.sonetel.net:3478,stun:stun.stunprotocol.org:3478,stun:stun.uls.co.za:3478,stun:stun.voipgate.com:3478,stun:stun.voys.nl:3478 - -SocksPort auto +Bridge snowflake 0.0.3.0:1 diff --git a/client/torrc-localhost b/client/torrc-localhost new file mode 100644 index 0000000..7d539fb --- /dev/null +++ b/client/torrc-localhost @@ -0,0 +1,7 @@ +UseBridges 1 +DataDirectory datadir + +ClientTransportPlugin snowflake exec ./client \ +-url http://localhost:8080/ \ + +Bridge snowflake 0.0.3.0:1 diff --git a/client/torrc.localhost b/client/torrc.localhost deleted file mode 100644 index e09f94c..0000000 --- a/client/torrc.localhost +++ /dev/null @@ -1,6 +0,0 @@ -UseBridges 1 -DataDirectory datadir - -ClientTransportPlugin snowflake exec ./client -keep-local-addresses - -Bridge snowflake 192.0.2.3:1 url=http://localhost:8080/ diff --git a/common/amp/armor_decoder.go b/common/amp/armor_decoder.go deleted file mode 100644 index fed44a6..0000000 --- a/common/amp/armor_decoder.go +++ /dev/null @@ -1,136 +0,0 @@ -package amp - -import ( - "bufio" - "bytes" - "encoding/base64" - "fmt" - "io" - - "golang.org/x/net/html" -) - -// ErrUnknownVersion is the error returned when the first character inside the -// element encoding (but outside the base64 encoding) is not '0'. -type ErrUnknownVersion byte - -func (err ErrUnknownVersion) Error() string { - return fmt.Sprintf("unknown armor version indicator %+q", byte(err)) -} - -func isASCIIWhitespace(b byte) bool { - switch b { - // https://infra.spec.whatwg.org/#ascii-whitespace - case '\x09', '\x0a', '\x0c', '\x0d', '\x20': - return true - default: - return false - } -} - -func splitASCIIWhitespace(data []byte, atEOF bool) (advance int, token []byte, err error) { - var i, j int - // Skip initial whitespace. - for i = 0; i < len(data); i++ { - if !isASCIIWhitespace(data[i]) { - break - } - } - // Look for next whitespace. - for j = i; j < len(data); j++ { - if isASCIIWhitespace(data[j]) { - return j + 1, data[i:j], nil - } - } - // We reached the end of data without finding more whitespace. Only - // consider it a token if we are at EOF. - if atEOF && i < j { - return j, data[i:j], nil - } - // Otherwise, request more data. - return i, nil, nil -} - -func decodeToWriter(w io.Writer, r io.Reader) (int64, error) { - tokenizer := html.NewTokenizer(r) - // Set a memory limit on token sizes, otherwise the tokenizer will - // buffer text indefinitely if it is not broken up by other token types. - tokenizer.SetMaxBuf(elementSizeLimit) - active := false - total := int64(0) - for { - tt := tokenizer.Next() - switch tt { - case html.ErrorToken: - err := tokenizer.Err() - if err == io.EOF { - err = nil - } - if err == nil && active { - return total, fmt.Errorf("missing tag") - } - return total, err - case html.TextToken: - if active { - // Re-join the separate chunks of text and - // feed them to the decoder. - scanner := bufio.NewScanner(bytes.NewReader(tokenizer.Text())) - scanner.Split(splitASCIIWhitespace) - for scanner.Scan() { - n, err := w.Write(scanner.Bytes()) - total += int64(n) - if err != nil { - return total, err - } - } - if err := scanner.Err(); err != nil { - return total, err - } - } - case html.StartTagToken: - tn, _ := tokenizer.TagName() - if string(tn) == "pre" { - if active { - // nesting not allowed - return total, fmt.Errorf("unexpected %s", tokenizer.Token()) - } - active = true - } - case html.EndTagToken: - tn, _ := tokenizer.TagName() - if string(tn) == "pre" { - if !active { - // stray end tag - return total, fmt.Errorf("unexpected %s", tokenizer.Token()) - } - active = false - } - } - } -} - -// NewArmorDecoder returns a new AMP armor decoder. -func NewArmorDecoder(r io.Reader) (io.Reader, error) { - pr, pw := io.Pipe() - go func() { - _, err := decodeToWriter(pw, r) - pw.CloseWithError(err) - }() - - // The first byte inside the element encoding is a server–client - // protocol version indicator. - var version [1]byte - _, err := pr.Read(version[:]) - if err != nil { - pr.CloseWithError(err) - return nil, err - } - switch version[0] { - case '0': - return base64.NewDecoder(base64.StdEncoding, pr), nil - default: - err := ErrUnknownVersion(version[0]) - pr.CloseWithError(err) - return nil, err - } -} diff --git a/common/amp/armor_encoder.go b/common/amp/armor_encoder.go deleted file mode 100644 index 5d6b0ae..0000000 --- a/common/amp/armor_encoder.go +++ /dev/null @@ -1,176 +0,0 @@ -package amp - -import ( - "encoding/base64" - "io" -) - -// https://amp.dev/boilerplate/ -// https://amp.dev/documentation/guides-and-tutorials/learn/spec/amp-boilerplate/?format=websites -// https://amp.dev/documentation/guides-and-tutorials/learn/spec/amphtml/?format=websites#the-amp-html-format -const ( - boilerplateStart = ` - - - - - - - - - -` - boilerplateEnd = ` -` -) - -const ( - // We restrict the amount of text may go inside an HTML element, in - // order to limit the amount a decoder may have to buffer. - elementSizeLimit = 32 * 1024 - - // The payload is conceptually a long base64-encoded string, but we - // break the string into short chunks separated by whitespace. This is - // to protect against modification by AMP caches, which reportedly may - // truncate long words in text: - // https://bugs.torproject.org/tpo/anti-censorship/pluggable-transports/snowflake/25985#note_2592348 - bytesPerChunk = 32 - - // We set the number of chunks per element so as to stay under - // elementSizeLimit. Here, we assume that there is 1 byte of whitespace - // after each chunk (with an additional whitespace byte at the beginning - // of the element). - chunksPerElement = (elementSizeLimit - 1) / (bytesPerChunk + 1) -) - -// The AMP armor encoder is a chain of a base64 encoder (base64.NewEncoder) and -// an HTML element encoder (elementEncoder). A top-level encoder (armorEncoder) -// coordinates these two, and handles prepending and appending the AMP -// boilerplate. armorEncoder's Write method writes data into the base64 encoder, -// where it makes its way through the chain. - -// NewArmorEncoder returns a new AMP armor encoder. Anything written to the -// returned io.WriteCloser will be encoded and written to w. The caller must -// call Close to flush any partially written data and output the AMP boilerplate -// trailer. -func NewArmorEncoder(w io.Writer) (io.WriteCloser, error) { - // Immediately write the AMP boilerplate header. - _, err := w.Write([]byte(boilerplateStart)) - if err != nil { - return nil, err - } - - element := &elementEncoder{w: w} - // Write a server–client protocol version indicator, outside the base64 - // layer. - _, err = element.Write([]byte{'0'}) - if err != nil { - return nil, err - } - - base64 := base64.NewEncoder(base64.StdEncoding, element) - return &armorEncoder{ - w: w, - element: element, - base64: base64, - }, nil -} - -type armorEncoder struct { - base64 io.WriteCloser - element *elementEncoder - w io.Writer -} - -func (enc *armorEncoder) Write(p []byte) (int, error) { - // Write into the chain base64 | element | w. - return enc.base64.Write(p) -} - -func (enc *armorEncoder) Close() error { - // Close the base64 encoder first, to flush out any buffered data and - // the final padding. - err := enc.base64.Close() - if err != nil { - return err - } - - // Next, close the element encoder, to close any open elements. - err = enc.element.Close() - if err != nil { - return err - } - - // Finally, output the AMP boilerplate trailer. - _, err = enc.w.Write([]byte(boilerplateEnd)) - if err != nil { - return err - } - - return nil -} - -// elementEncoder arranges written data into pre elements, with the text within -// separated into chunks. It does no HTML encoding, so data written must not -// contain any bytes that are meaningful in HTML. -type elementEncoder struct { - w io.Writer - chunkCounter int - elementCounter int -} - -func (enc *elementEncoder) Write(p []byte) (n int, err error) { - total := 0 - for len(p) > 0 { - if enc.elementCounter == 0 && enc.chunkCounter == 0 { - _, err := enc.w.Write([]byte("
\n"))
-			if err != nil {
-				return total, err
-			}
-		}
-
-		n := bytesPerChunk - enc.chunkCounter
-		if n > len(p) {
-			n = len(p)
-		}
-		nn, err := enc.w.Write(p[:n])
-		if err != nil {
-			return total, err
-		}
-		total += nn
-		p = p[n:]
-
-		enc.chunkCounter += n
-		if enc.chunkCounter >= bytesPerChunk {
-			enc.chunkCounter = 0
-			enc.elementCounter += 1
-			nn, err = enc.w.Write([]byte("\n"))
-			if err != nil {
-				return total, err
-			}
-			total += nn
-		}
-
-		if enc.elementCounter >= chunksPerElement {
-			enc.elementCounter = 0
-			nn, err = enc.w.Write([]byte("
\n")) - if err != nil { - return total, err - } - total += nn - } - } - return total, nil -} - -func (enc *elementEncoder) Close() error { - var err error - if !(enc.elementCounter == 0 && enc.chunkCounter == 0) { - if enc.chunkCounter == 0 { - _, err = enc.w.Write([]byte("\n")) - } else { - _, err = enc.w.Write([]byte("\n\n")) - } - } - return err -} diff --git a/common/amp/armor_test.go b/common/amp/armor_test.go deleted file mode 100644 index fc7561e..0000000 --- a/common/amp/armor_test.go +++ /dev/null @@ -1,227 +0,0 @@ -package amp - -import ( - "io" - "io/ioutil" - "math/rand" - "strings" - "testing" -) - -func armorDecodeToString(src string) (string, error) { - dec, err := NewArmorDecoder(strings.NewReader(src)) - if err != nil { - return "", err - } - p, err := ioutil.ReadAll(dec) - return string(p), err -} - -func TestArmorDecoder(t *testing.T) { - for _, test := range []struct { - input string - expectedOutput string - expectedErr bool - }{ - {` -
-0
-
-`, - "", - false, - }, - {` -
-0aGVsbG8gd29ybGQK
-
-`, - "hello world\n", - false, - }, - // bad version indicator - {` -
-1aGVsbG8gd29ybGQK
-
-`, - "", - true, - }, - // text outside
 elements
-		{`
-0aGVsbG8gd29ybGQK
-blah blah blah
-
-0aGVsbG8gd29ybGQK
-
-0aGVsbG8gd29ybGQK -blah blah blah -`, - "hello world\n", - false, - }, - {` -
-0QUJDREV
-GR0hJSkt
-MTU5PUFF
-SU1RVVld
-
-junk -
-YWVowMTI
-zNDU2Nzg
-5Cg
-=
-
-
-=
-
-`, - "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789\n", - false, - }, - // no
 elements, hence no version indicator
-		{`
-aGVsbG8gd29ybGQK
-blah blah blah
-aGVsbG8gd29ybGQK
-aGVsbG8gd29ybGQK
-blah blah blah
-`,
-			"",
-			true,
-		},
-		// empty 
 elements, hence no version indicator
-		{`
-aGVsbG8gd29ybGQK
-blah blah blah
-
   
-aGVsbG8gd29ybGQK -aGVsbG8gd29ybGQK

-blah blah blah
-`,
-			"",
-			true,
-		},
-		// other elements inside 
-		{
-			"blah 
0aGVsb

G8gd29

ybGQK
", - "hello world\n", - false, - }, - // HTML comment - { - "blah ", - "", - true, - }, - // all kinds of ASCII whitespace - { - "blah
\x200\x09aG\x0aV\x0csb\x0dG8\x20gd29ybGQK
", - "hello world\n", - false, - }, - - // bad padding - {` -
-0QUJDREV
-GR0hJSkt
-MTU5PUFF
-SU1RVVld
-
-junk -
-YWVowMTI
-zNDU2Nzg
-5Cg
-=
-
-`, - "", - true, - }, - /* - // per-chunk base64 - // test disabled because Go stdlib handles this incorrectly: - // https://github.com/golang/go/issues/31626 - { - "
QQ==
Qg==
", - "", - true, - }, - */ - // missing
- { - "blah
0aGVsbG8gd29ybGQK",
-			"",
-			true,
-		},
-		// nested 
-		{
-			"blah 
0aGVsb
G8gd29
ybGQK
", - "", - true, - }, - } { - output, err := armorDecodeToString(test.input) - if test.expectedErr && err == nil { - t.Errorf("%+q → (%+q, %v), expected error", test.input, output, err) - continue - } - if !test.expectedErr && err != nil { - t.Errorf("%+q → (%+q, %v), expected no error", test.input, output, err) - continue - } - if !test.expectedErr && output != test.expectedOutput { - t.Errorf("%+q → (%+q, %v), expected (%+q, %v)", - test.input, output, err, test.expectedOutput, nil) - continue - } - } -} - -func armorRoundTrip(s string) (string, error) { - var encoded strings.Builder - enc, err := NewArmorEncoder(&encoded) - if err != nil { - return "", err - } - _, err = io.Copy(enc, strings.NewReader(s)) - if err != nil { - return "", err - } - err = enc.Close() - if err != nil { - return "", err - } - return armorDecodeToString(encoded.String()) -} - -func TestArmorRoundTrip(t *testing.T) { - lengths := make([]int, 0) - // Test short strings and lengths around elementSizeLimit thresholds. - for i := 0; i < bytesPerChunk*2; i++ { - lengths = append(lengths, i) - } - for i := -10; i < +10; i++ { - lengths = append(lengths, elementSizeLimit+i) - lengths = append(lengths, 2*elementSizeLimit+i) - } - for _, n := range lengths { - buf := make([]byte, n) - rand.Read(buf) - input := string(buf) - output, err := armorRoundTrip(input) - if err != nil { - t.Errorf("length %d → error %v", n, err) - continue - } - if output != input { - t.Errorf("length %d → %+q", n, output) - continue - } - } -} diff --git a/common/amp/cache.go b/common/amp/cache.go deleted file mode 100644 index 102993f..0000000 --- a/common/amp/cache.go +++ /dev/null @@ -1,178 +0,0 @@ -package amp - -import ( - "crypto/sha256" - "encoding/base32" - "fmt" - "net" - "net/url" - "path" - "strings" - - "golang.org/x/net/idna" -) - -// domainPrefixBasic does the basic domain prefix conversion. Does not do any -// IDNA mapping, such as https://www.unicode.org/reports/tr46/. -// -// https://amp.dev/documentation/guides-and-tutorials/learn/amp-caches-and-cors/amp-cache-urls/#basic-algorithm -func domainPrefixBasic(domain string) (string, error) { - // 1. Punycode Decode the publisher domain. - prefix, err := idna.ToUnicode(domain) - if err != nil { - return "", err - } - - // 2. Replace any "-" (hyphen) character in the output of step 1 with - // "--" (two hyphens). - prefix = strings.Replace(prefix, "-", "--", -1) - - // 3. Replace any "." (dot) character in the output of step 2 with "-" - // (hyphen). - prefix = strings.Replace(prefix, ".", "-", -1) - - // 4. If the output of step 3 has a "-" (hyphen) at both positions 3 and - // 4, then to the output of step 3, add a prefix of "0-" and add a - // suffix of "-0". - if len(prefix) >= 4 && prefix[2] == '-' && prefix[3] == '-' { - prefix = "0-" + prefix + "-0" - } - - // 5. Punycode Encode the output of step 3. - return idna.ToASCII(prefix) -} - -// Lower-case base32 without padding. -var fallbackBase32Encoding = base32.NewEncoding("abcdefghijklmnopqrstuvwxyz234567").WithPadding(base32.NoPadding) - -// domainPrefixFallback does the fallback domain prefix conversion. The returned -// base32 domain uses lower-case letters. -// -// https://amp.dev/documentation/guides-and-tutorials/learn/amp-caches-and-cors/amp-cache-urls/#fallback-algorithm -func domainPrefixFallback(domain string) string { - // The algorithm specification does not say what, exactly, we are to - // take the SHA-256 of. domain is notionally an abstract Unicode - // string, not a byte sequence. While - // https://github.com/ampproject/amp-toolbox/blob/84cb3057e5f6c54d64369ddd285db1cb36237ee8/packages/cache-url/lib/AmpCurlUrlGenerator.js#L62 - // says "Take the SHA256 of the punycode view of the domain," in reality - // it hashes the UTF-8 encoding of the domain, without Punycode: - // https://github.com/ampproject/amp-toolbox/blob/84cb3057e5f6c54d64369ddd285db1cb36237ee8/packages/cache-url/lib/AmpCurlUrlGenerator.js#L141 - // https://github.com/ampproject/amp-toolbox/blob/84cb3057e5f6c54d64369ddd285db1cb36237ee8/packages/cache-url/lib/browser/Sha256.js#L24 - // We do the same here, hashing the raw bytes of domain, presumed to be - // UTF-8. - - // 1. Hash the publisher's domain using SHA256. - h := sha256.Sum256([]byte(domain)) - - // 2. Base32 Escape the output of step 1. - // 3. Remove the last 4 characters from the output of step 2, which are - // always "=" (equals) characters. - return fallbackBase32Encoding.EncodeToString(h[:]) -} - -// domainPrefix computes the domain prefix of an AMP cache URL. -// -// https://amp.dev/documentation/guides-and-tutorials/learn/amp-caches-and-cors/amp-cache-urls/#domain-name-prefix -func domainPrefix(domain string) string { - // https://amp.dev/documentation/guides-and-tutorials/learn/amp-caches-and-cors/amp-cache-urls/#combined-algorithm - // 1. Run the Basic Algorithm. If the output is a valid DNS label, - // [append the Cache domain suffix and] return. Otherwise continue to - // step 2. - prefix, err := domainPrefixBasic(domain) - // "A domain prefix is not a valid DNS label if it is longer than 63 - // characters" - if err == nil && len(prefix) <= 63 { - return prefix - } - // 2. Run the Fallback Algorithm. [Append the Cache domain suffix and] - // return. - return domainPrefixFallback(domain) -} - -// CacheURL computes the AMP cache URL for the publisher URL pubURL, using the -// AMP cache at cacheURL. contentType is a string such as "c" or "i" that -// indicates what type of serving the AMP cache is to perform. The Scheme of -// pubURL must be "http" or "https". The Port of pubURL, if any, must match the -// default for the scheme. cacheURL may not have RawQuery, Fragment, or -// RawFragment set, because the resulting URL's query and fragment are taken -// from the publisher URL. -// -// https://amp.dev/documentation/guides-and-tutorials/learn/amp-caches-and-cors/amp-cache-urls/ -func CacheURL(pubURL, cacheURL *url.URL, contentType string) (*url.URL, error) { - // The cache URL subdomain, including the domain prefix corresponding to - // the publisher URL's domain. - resultHost := domainPrefix(pubURL.Hostname()) + "." + cacheURL.Hostname() - if cacheURL.Port() != "" { - resultHost = net.JoinHostPort(resultHost, cacheURL.Port()) - } - - // https://amp.dev/documentation/guides-and-tutorials/learn/amp-caches-and-cors/amp-cache-urls/#url-path - // The first part of the path is the cache URL's own path, if any. - pathComponents := []string{cacheURL.EscapedPath()} - // The next path component is the content type. We cannot encode an - // empty content type, because it would result in consecutive path - // separators, which would semantically combine into a single separator. - if contentType == "" { - return nil, fmt.Errorf("invalid content type %+q", contentType) - } - pathComponents = append(pathComponents, url.PathEscape(contentType)) - // Then, we add an "s" path component, if the publisher URL scheme is - // "https". - switch pubURL.Scheme { - case "http": - // Do nothing. - case "https": - pathComponents = append(pathComponents, "s") - default: - return nil, fmt.Errorf("invalid scheme %+q in publisher URL", pubURL.Scheme) - } - // The next path component is the publisher URL's host. The AMP cache - // URL format specification is not clear about whether other - // subcomponents of the authority (namely userinfo and port) may appear - // here. We adopt a policy of forbidding userinfo, and requiring that - // the port be the default for the scheme (and then we omit the port - // entirely from the returned URL). - if pubURL.User != nil { - return nil, fmt.Errorf("publisher URL may not contain userinfo") - } - if port := pubURL.Port(); port != "" { - if !((pubURL.Scheme == "http" && port == "80") || (pubURL.Scheme == "https" && port == "443")) { - return nil, fmt.Errorf("publisher URL port %+q is not the default for scheme %+q", port, pubURL.Scheme) - } - } - // As with the content type, we cannot encode an empty host, because - // that would result in an empty path component. - if pubURL.Hostname() == "" { - return nil, fmt.Errorf("invalid host %+q in publisher URL", pubURL.Hostname()) - } - pathComponents = append(pathComponents, url.PathEscape(pubURL.Hostname())) - // Finally, we append the remainder of the original escaped path from - // the publisher URL. - pathComponents = append(pathComponents, pubURL.EscapedPath()) - - resultRawPath := path.Join(pathComponents...) - resultPath, err := url.PathUnescape(resultRawPath) - if err != nil { - return nil, err - } - - // The query and fragment of the returned URL always come from pubURL. - // Any query or fragment of cacheURL would be ignored. Return an error - // if either is set. - if cacheURL.RawQuery != "" { - return nil, fmt.Errorf("cache URL may not contain a query") - } - if cacheURL.Fragment != "" { - return nil, fmt.Errorf("cache URL may not contain a fragment") - } - - return &url.URL{ - Scheme: cacheURL.Scheme, - User: cacheURL.User, - Host: resultHost, - Path: resultPath, - RawPath: resultRawPath, - RawQuery: pubURL.RawQuery, - Fragment: pubURL.Fragment, - }, nil -} diff --git a/common/amp/cache_test.go b/common/amp/cache_test.go deleted file mode 100644 index 45950fd..0000000 --- a/common/amp/cache_test.go +++ /dev/null @@ -1,320 +0,0 @@ -package amp - -import ( - "bytes" - "net/url" - "testing" - - "golang.org/x/net/idna" -) - -func TestDomainPrefixBasic(t *testing.T) { - // Tests expecting no error. - for _, test := range []struct { - domain, expected string - }{ - {"", ""}, - {"xn--", ""}, - {"...", "---"}, - - // Should not apply mappings such as case folding and - // normalization. - {"b\u00fccher.de", "xn--bcher-de-65a"}, - {"B\u00fccher.de", "xn--Bcher-de-65a"}, - {"bu\u0308cher.de", "xn--bucher-de-hkf"}, - - // Check some that differ between IDNA 2003 and IDNA 2008. - // https://unicode.org/reports/tr46/#Deviations - // https://util.unicode.org/UnicodeJsps/idna.jsp - {"faß.de", "xn--fa-de-mqa"}, - {"βόλοσ.com", "xn---com-4ld8c2a6a8e"}, - - // Lengths of 63 and 64. 64 is too long for a DNS label, but - // domainPrefixBasic is not expected to check for that. - {"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}, - {"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}, - - // https://amp.dev/documentation/guides-and-tutorials/learn/amp-caches-and-cors/amp-cache-urls/#basic-algorithm - {"example.com", "example-com"}, - {"foo.example.com", "foo-example-com"}, - {"foo-example.com", "foo--example-com"}, - {"xn--57hw060o.com", "xn---com-p33b41770a"}, - {"\u26a1\U0001f60a.com", "xn---com-p33b41770a"}, - {"en-us.example.com", "0-en--us-example-com-0"}, - } { - output, err := domainPrefixBasic(test.domain) - if err != nil || output != test.expected { - t.Errorf("%+q → (%+q, %v), expected (%+q, %v)", - test.domain, output, err, test.expected, nil) - } - } - - // Tests expecting an error. - for _, domain := range []string{ - "xn---", - } { - output, err := domainPrefixBasic(domain) - if err == nil || output != "" { - t.Errorf("%+q → (%+q, %v), expected (%+q, non-nil)", - domain, output, err, "") - } - } -} - -func TestDomainPrefixFallback(t *testing.T) { - for _, test := range []struct { - domain, expected string - }{ - { - "", - "4oymiquy7qobjgx36tejs35zeqt24qpemsnzgtfeswmrw6csxbkq", - }, - { - "example.com", - "un42n5xov642kxrxrqiyanhcoupgql5lt4wtbkyt2ijflbwodfdq", - }, - - // These checked against the output of - // https://github.com/ampproject/amp-toolbox/tree/84cb3057e5f6c54d64369ddd285db1cb36237ee8/packages/cache-url, - // using the widget at - // https://amp.dev/documentation/guides-and-tutorials/learn/amp-caches-and-cors/amp-cache-urls/#url-format. - { - "000000000000000000000000000000000000000000000000000000000000.com", - "stejanx4hsijaoj4secyecy4nvqodk56kw72whwcmvdbtucibf5a", - }, - { - "00000000000000000000000000000000000000000000000000000000000a.com", - "jdcvbsorpnc3hcjrhst56nfm6ymdpovlawdbm2efyxpvlt4cpbya", - }, - { - "00000000000000000000000000000000000000000000000000000000000\u03bb.com", - "qhzqeumjkfpcpuic3vqruyjswcr7y7gcm3crqyhhywvn3xrhchfa", - }, - } { - output := domainPrefixFallback(test.domain) - if output != test.expected { - t.Errorf("%+q → %+q, expected %+q", - test.domain, output, test.expected) - } - } -} - -// Checks that domainPrefix chooses domainPrefixBasic or domainPrefixFallback as -// appropriate; i.e., always returns string that is a valid DNS label and is -// IDNA-decodable. -func TestDomainPrefix(t *testing.T) { - // A validating IDNA profile, which checks label length and that the - // label contains only certain ASCII characters. It does not do the - // ValidateLabels check, because that depends on the input having - // certain properties. - profile := idna.New( - idna.VerifyDNSLength(true), - idna.StrictDomainName(true), - ) - for _, domain := range []string{ - "example.com", - "\u0314example.com", - "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", // 63 bytes - "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", // 64 bytes - "xn--57hw060o.com", - "a b c", - } { - output := domainPrefix(domain) - if bytes.IndexByte([]byte(output), '.') != -1 { - t.Errorf("%+q → %+q contains a dot", domain, output) - } - _, err := profile.ToUnicode(output) - if err != nil { - t.Errorf("%+q → error %v", domain, err) - } - } -} - -func mustParseURL(rawurl string) *url.URL { - u, err := url.Parse(rawurl) - if err != nil { - panic(err) - } - return u -} - -func TestCacheURL(t *testing.T) { - // Tests expecting no error. - for _, test := range []struct { - pub string - cache string - contentType string - expected string - }{ - // With or without trailing slash on pubURL. - { - "http://example.com/", - "https://amp.cache/", - "c", - "https://example-com.amp.cache/c/example.com", - }, - { - "http://example.com", - "https://amp.cache/", - "c", - "https://example-com.amp.cache/c/example.com", - }, - // https pubURL. - { - "https://example.com/", - "https://amp.cache/", - "c", - "https://example-com.amp.cache/c/s/example.com", - }, - // The content type should be escaped if necessary. - { - "http://example.com/", - "https://amp.cache/", - "/", - "https://example-com.amp.cache/%2F/example.com", - }, - // Retain pubURL path, query, and fragment, including escaping. - { - "http://example.com/my%2Fpath/index.html?a=1#fragment", - "https://amp.cache/", - "c", - "https://example-com.amp.cache/c/example.com/my%2Fpath/index.html?a=1#fragment", - }, - // Retain scheme, userinfo, port, and path of cacheURL, escaping - // whatever is necessary. - { - "http://example.com", - "http://cache%2Fuser:cache%40pass@amp.cache:123/with/../../path/..%2f../", - "c", - "http://cache%2Fuser:cache%40pass@example-com.amp.cache:123/path/..%2f../c/example.com", - }, - // Port numbers in pubURL are allowed, if they're the default - // for scheme. - { - "http://example.com:80/", - "https://amp.cache/", - "c", - "https://example-com.amp.cache/c/example.com", - }, - { - "https://example.com:443/", - "https://amp.cache/", - "c", - "https://example-com.amp.cache/c/s/example.com", - }, - // "?" at the end of cacheURL is okay, as long as the query is - // empty. - { - "http://example.com/", - "https://amp.cache/?", - "c", - "https://example-com.amp.cache/c/example.com", - }, - - // https://developers.google.com/amp/cache/overview#example-requesting-document-using-tls - { - "https://example.com/amp_document.html", - "https://cdn.ampproject.org/", - "c", - "https://example-com.cdn.ampproject.org/c/s/example.com/amp_document.html", - }, - // https://developers.google.com/amp/cache/overview#example-requesting-image-using-plain-http - { - "http://example.com/logo.png", - "https://cdn.ampproject.org/", - "i", - "https://example-com.cdn.ampproject.org/i/example.com/logo.png", - }, - // https://developers.google.com/amp/cache/overview#query-parameter-example - { - "https://example.com/g?value=Hello%20World", - "https://cdn.ampproject.org/", - "c", - "https://example-com.cdn.ampproject.org/c/s/example.com/g?value=Hello%20World", - }, - } { - pubURL := mustParseURL(test.pub) - cacheURL := mustParseURL(test.cache) - outputURL, err := CacheURL(pubURL, cacheURL, test.contentType) - if err != nil { - t.Errorf("%+q %+q %+q → error %v", - test.pub, test.cache, test.contentType, err) - continue - } - if outputURL.String() != test.expected { - t.Errorf("%+q %+q %+q → %+q, expected %+q", - test.pub, test.cache, test.contentType, outputURL, test.expected) - continue - } - } - - // Tests expecting an error. - for _, test := range []struct { - pub string - cache string - contentType string - }{ - // Empty content type. - { - "http://example.com/", - "https://amp.cache/", - "", - }, - // Empty host. - { - "http:///index.html", - "https://amp.cache/", - "c", - }, - // Empty scheme. - { - "//example.com/", - "https://amp.cache/", - "c", - }, - // Unrecognized scheme. - { - "ftp://example.com/", - "https://amp.cache/", - "c", - }, - // Wrong port number for scheme. - { - "http://example.com:443/", - "https://amp.cache/", - "c", - }, - // userinfo in pubURL. - { - "http://user@example.com/", - "https://amp.cache/", - "c", - }, - { - "http://user:pass@example.com/", - "https://amp.cache/", - "c", - }, - // cacheURL may not contain a query. - { - "http://example.com/", - "https://amp.cache/?a=1", - "c", - }, - // cacheURL may not contain a fragment. - { - "http://example.com/", - "https://amp.cache/#fragment", - "c", - }, - } { - pubURL := mustParseURL(test.pub) - cacheURL := mustParseURL(test.cache) - outputURL, err := CacheURL(pubURL, cacheURL, test.contentType) - if err == nil { - t.Errorf("%+q %+q %+q → %+q, expected error", - test.pub, test.cache, test.contentType, outputURL) - continue - } - } -} diff --git a/common/amp/doc.go b/common/amp/doc.go deleted file mode 100644 index 1387114..0000000 --- a/common/amp/doc.go +++ /dev/null @@ -1,88 +0,0 @@ -/* -Package amp provides functions for working with the AMP (Accelerated Mobile -Pages) subset of HTML, and conveying binary data through an AMP cache. - -AMP cache - -The CacheURL function takes a plain URL and converts it to be accessed through a -given AMP cache. - -The EncodePath and DecodePath functions provide a way to encode data into the -suffix of a URL path. AMP caches do not support HTTP POST, but encoding data -into a URL path with GET is an alternative means of sending data to the server. -The format of an encoded path is: - 0<0 or more bytes, including slash>/ -That is: -* "0", a format version number, which controls the interpretation of the rest of -the path. Only the first byte matters as a version indicator (not the whole -first path component). -* Any number of slash or non-slash bytes. These may be used as padding or to -prevent cache collisions in the AMP cache. -* A final slash. -* base64 encoding of the data, using the URL-safe alphabet (which does not -include slash). - -For example, an encoding of the string "This is path-encoded data." is the -following. The "lgWHcwhXFjUm" following the format version number is random -padding that will be ignored on decoding. - 0lgWHcwhXFjUm/VGhpcyBpcyBwYXRoLWVuY29kZWQgZGF0YS4 - -It is the caller's responsibility to add or remove any directory path prefix -before calling EncodePath or DecodePath. - -AMP armor - -AMP armor is a data encoding scheme that that satisfies the requirements of the -AMP (Accelerated Mobile Pages) subset of HTML, and survives modification by an -AMP cache. For the requirements of AMP HTML, see -https://amp.dev/documentation/guides-and-tutorials/learn/spec/amphtml/. -For modifications that may be made by an AMP cache, see -https://github.com/ampproject/amphtml/blob/main/docs/spec/amp-cache-modifications.md. - -The encoding is based on ones created by Ivan Markin. See codec/amp/ in -https://github.com/nogoegst/amper and discussion at -https://bugs.torproject.org/tpo/anti-censorship/pluggable-transports/snowflake/25985. - -The encoding algorithm works as follows. Base64-encode the input. Prepend the -input with the byte '0'; this is a protocol version indicator that the decoder -can use to determine how to interpret the bytes that follow. Split the base64 -into fixed-size chunks separated by whitespace. Take up to 1024 chunks at a -time, and wrap them in a pre element. Then, situate the markup so far within the -body of the AMP HTML boilerplate. The decoding algorithm is to scan the HTML for -pre elements, split their text contents on whitespace and concatenate, then -base64 decode. The base64 encoding uses the standard alphabet, with normal "=" -padding (https://tools.ietf.org/html/rfc4648#section-4). - -The reason for splitting the base64 into chunks is that AMP caches reportedly -truncate long strings that are not broken by whitespace: -https://bugs.torproject.org/tpo/anti-censorship/pluggable-transports/snowflake/25985#note_2592348. -The characters that may separate the chunks are the ASCII whitespace characters -(https://infra.spec.whatwg.org/#ascii-whitespace) "\x09", "\x0a", "\x0c", -"\x0d", and "\x20". The reason for separating the chunks into pre elements is to -limit the amount of text a decoder may have to buffer while parsing the HTML. -Each pre element may contain at most 64 KB of text. pre elements may not be -nested. - -Example - -The following is the result of encoding the string -"This was encoded with AMP armor.": - - - - - - - - - - - -
-	0VGhpcyB3YXMgZW5jb2RlZCB3aXRoIEF
-	NUCBhcm1vci4=
-	
- - -*/ -package amp diff --git a/common/amp/path.go b/common/amp/path.go deleted file mode 100644 index 5903694..0000000 --- a/common/amp/path.go +++ /dev/null @@ -1,44 +0,0 @@ -package amp - -import ( - "crypto/rand" - "encoding/base64" - "fmt" - "strings" -) - -// EncodePath encodes data in a way that is suitable for the suffix of an AMP -// cache URL. -func EncodePath(data []byte) string { - var cacheBreaker [9]byte - _, err := rand.Read(cacheBreaker[:]) - if err != nil { - panic(err) - } - b64 := base64.RawURLEncoding.EncodeToString - return "0" + b64(cacheBreaker[:]) + "/" + b64(data) -} - -// DecodePath decodes data from a path suffix as encoded by EncodePath. The path -// must have already been trimmed of any directory prefix (as might be present -// in, e.g., an HTTP request). That is, the first character of path should be -// the "0" message format indicator. -func DecodePath(path string) ([]byte, error) { - if len(path) < 1 { - return nil, fmt.Errorf("missing format indicator") - } - version := path[0] - rest := path[1:] - switch version { - case '0': - // Ignore everything else up to and including the final slash - // (there must be at least one slash). - i := strings.LastIndexByte(rest, '/') - if i == -1 { - return nil, fmt.Errorf("missing data") - } - return base64.RawURLEncoding.DecodeString(rest[i+1:]) - default: - return nil, fmt.Errorf("unknown format indicator %q", version) - } -} diff --git a/common/amp/path_test.go b/common/amp/path_test.go deleted file mode 100644 index 20e4ccf..0000000 --- a/common/amp/path_test.go +++ /dev/null @@ -1,54 +0,0 @@ -package amp - -import ( - "testing" -) - -func TestDecodePath(t *testing.T) { - for _, test := range []struct { - path string - expectedData string - expectedErrStr string - }{ - {"", "", "missing format indicator"}, - {"0", "", "missing data"}, - {"0foobar", "", "missing data"}, - {"/0/YWJj", "", "unknown format indicator '/'"}, - - {"0/", "", ""}, - {"0foobar/", "", ""}, - {"0/YWJj", "abc", ""}, - {"0///YWJj", "abc", ""}, - {"0foobar/YWJj", "abc", ""}, - {"0/foobar/YWJj", "abc", ""}, - } { - data, err := DecodePath(test.path) - if test.expectedErrStr != "" { - if err == nil || err.Error() != test.expectedErrStr { - t.Errorf("%+q expected error %+q, got %+q", - test.path, test.expectedErrStr, err) - } - } else if err != nil { - t.Errorf("%+q expected no error, got %+q", test.path, err) - } else if string(data) != test.expectedData { - t.Errorf("%+q expected data %+q, got %+q", - test.path, test.expectedData, data) - } - } -} - -func TestPathRoundTrip(t *testing.T) { - for _, data := range []string{ - "", - "\x00", - "/", - "hello world", - } { - decoded, err := DecodePath(EncodePath([]byte(data))) - if err != nil { - t.Errorf("%+q roundtripped with error %v", data, err) - } else if string(decoded) != data { - t.Errorf("%+q roundtripped to %+q", data, decoded) - } - } -} diff --git a/common/bridgefingerprint/fingerprint.go b/common/bridgefingerprint/fingerprint.go deleted file mode 100644 index 1a89773..0000000 --- a/common/bridgefingerprint/fingerprint.go +++ /dev/null @@ -1,30 +0,0 @@ -package bridgefingerprint - -import ( - "encoding/hex" - "errors" -) - -type Fingerprint string - -var ErrBridgeFingerprintInvalid = errors.New("bridge fingerprint invalid") - -func FingerprintFromBytes(bytes []byte) (Fingerprint, error) { - n := len(bytes) - if n != 20 && n != 32 { - return Fingerprint(""), ErrBridgeFingerprintInvalid - } - return Fingerprint(bytes), nil -} - -func FingerprintFromHexString(hexString string) (Fingerprint, error) { - decoded, err := hex.DecodeString(hexString) - if err != nil { - return "", err - } - return FingerprintFromBytes(decoded) -} - -func (f Fingerprint) ToBytes() []byte { - return []byte(f) -} diff --git a/common/encapsulation/encapsulation.go b/common/encapsulation/encapsulation.go deleted file mode 100644 index bfe9b5b..0000000 --- a/common/encapsulation/encapsulation.go +++ /dev/null @@ -1,194 +0,0 @@ -// Package encapsulation implements a way of encoding variable-size chunks of -// data and padding into a byte stream. -// -// Each chunk of data or padding starts with a variable-size length prefix. One -// bit ("d") in the first byte of the prefix indicates whether the chunk -// represents data or padding (1=data, 0=padding). Another bit ("c" for -// "continuation") is the indicates whether there are more bytes in the length -// prefix. The remaining 6 bits ("x") encode part of the length value. -// dcxxxxxx -// If the continuation bit is set, then the next byte is also part of the length -// prefix. It lacks the "d" bit, has its own "c" bit, and 7 value-carrying bits -// ("y"). -// cyyyyyyy -// The length is decoded by concatenating value-carrying bits, from left to -// right, of all value-carrying bits, up to and including the first byte whose -// "c" bit is 0. Although in principle this encoding would allow for length -// prefixes of any size, length prefixes are arbitrarily limited to 3 bytes and -// any attempt to read or write a longer one is an error. These are therefore -// the only valid formats: -// 00xxxxxx xxxxxx₂ bytes of padding -// 10xxxxxx xxxxxx₂ bytes of data -// 01xxxxxx 0yyyyyyy xxxxxxyyyyyyy₂ bytes of padding -// 11xxxxxx 0yyyyyyy xxxxxxyyyyyyy₂ bytes of data -// 01xxxxxx 1yyyyyyy 0zzzzzzz xxxxxxyyyyyyyzzzzzzz₂ bytes of padding -// 11xxxxxx 1yyyyyyy 0zzzzzzz xxxxxxyyyyyyyzzzzzzz₂ bytes of data -// The maximum encodable length is 11111111111111111111₂ = 0xfffff = 1048575. -// There is no requirement to use a length prefix of minimum size; i.e. 00000100 -// and 01000000 00000100 are both valid encodings of the value 4. -// -// After the length prefix follow that many bytes of padding or data. There are -// no restrictions on the value of bytes comprising padding. -// -// The idea for this encapsulation is sketched here: -// https://github.com/net4people/bbs/issues/9#issuecomment-524095186 -package encapsulation - -import ( - "errors" - "io" - "io/ioutil" -) - -// ErrTooLong is the error returned when an encoded length prefix is longer than -// 3 bytes, or when ReadData receives an input whose length is too large to -// encode in a 3-byte length prefix. -var ErrTooLong = errors.New("length prefix is too long") - -// ReadData returns a new slice with the contents of the next available data -// chunk, skipping over any padding chunks that may come first. The returned -// error value is nil if and only if a data chunk was present and was read in -// its entirety. The returned error is io.EOF only if r ended before the first -// byte of a length prefix. If r ended in the middle of a length prefix or -// data/padding, the returned error is io.ErrUnexpectedEOF. -func ReadData(r io.Reader) ([]byte, error) { - for { - var b [1]byte - _, err := r.Read(b[:]) - if err != nil { - // This is the only place we may return a real io.EOF. - return nil, err - } - isData := (b[0] & 0x80) != 0 - moreLength := (b[0] & 0x40) != 0 - n := int(b[0] & 0x3f) - for i := 0; moreLength; i++ { - if i >= 2 { - return nil, ErrTooLong - } - _, err := r.Read(b[:]) - if err == io.EOF { - err = io.ErrUnexpectedEOF - } - if err != nil { - return nil, err - } - moreLength = (b[0] & 0x80) != 0 - n = (n << 7) | int(b[0]&0x7f) - } - if isData { - p := make([]byte, n) - _, err := io.ReadFull(r, p) - if err == io.EOF { - err = io.ErrUnexpectedEOF - } - if err != nil { - return nil, err - } - return p, err - } else { - _, err := io.CopyN(ioutil.Discard, r, int64(n)) - if err == io.EOF { - err = io.ErrUnexpectedEOF - } - if err != nil { - return nil, err - } - } - } -} - -// dataPrefixForLength returns a length prefix for the given length, with the -// "d" bit set to 1. -func dataPrefixForLength(n int) ([]byte, error) { - switch { - case (n>>0)&0x3f == (n >> 0): - return []byte{0x80 | byte((n>>0)&0x3f)}, nil - case (n>>7)&0x3f == (n >> 7): - return []byte{0xc0 | byte((n>>7)&0x3f), byte((n >> 0) & 0x7f)}, nil - case (n>>14)&0x3f == (n >> 14): - return []byte{0xc0 | byte((n>>14)&0x3f), 0x80 | byte((n>>7)&0x7f), byte((n >> 0) & 0x7f)}, nil - default: - return nil, ErrTooLong - } -} - -// WriteData encodes a data chunk into w. It returns the total number of bytes -// written; i.e., including the length prefix. The error is ErrTooLong if the -// length of data cannot fit into a length prefix. -func WriteData(w io.Writer, data []byte) (int, error) { - prefix, err := dataPrefixForLength(len(data)) - if err != nil { - return 0, err - } - total := 0 - n, err := w.Write(prefix) - total += n - if err != nil { - return total, err - } - n, err = w.Write(data) - total += n - return total, err -} - -var paddingBuffer = make([]byte, 1024) - -// WritePadding encodes padding chunks, whose total size (including their own -// length prefixes) is n. Returns the total number of bytes written to w, which -// will be exactly n unless there was an error. The error cannot be ErrTooLong -// because this function will write multiple padding chunks if necessary to -// reach the requested size. Panics if n is negative. -func WritePadding(w io.Writer, n int) (int, error) { - if n < 0 { - panic("negative length") - } - total := 0 - for n > 0 { - p := len(paddingBuffer) - if p > n { - p = n - } - n -= p - var prefix []byte - switch { - case ((p-1)>>0)&0x3f == ((p - 1) >> 0): - p = p - 1 - prefix = []byte{byte((p >> 0) & 0x3f)} - case ((p-2)>>7)&0x3f == ((p - 2) >> 7): - p = p - 2 - prefix = []byte{0x40 | byte((p>>7)&0x3f), byte((p >> 0) & 0x7f)} - case ((p-3)>>14)&0x3f == ((p - 3) >> 14): - p = p - 3 - prefix = []byte{0x40 | byte((p>>14)&0x3f), 0x80 | byte((p>>7)&0x3f), byte((p >> 0) & 0x7f)} - } - nn, err := w.Write(prefix) - total += nn - if err != nil { - return total, err - } - nn, err = w.Write(paddingBuffer[:p]) - total += nn - if err != nil { - return total, err - } - } - return total, nil -} - -// MaxDataForSize returns the length of the longest slice that can pe passed to -// WriteData, whose total encoded size (including length prefix) is no larger -// than n. Call this to find out if a chunk of data will fit into a length -// budget. Panics if n == 0. -func MaxDataForSize(n int) int { - if n == 0 { - panic("zero length") - } - prefix, err := dataPrefixForLength(n) - if err == ErrTooLong { - return (1 << (6 + 7 + 7)) - 1 - 3 - } else if err != nil { - panic(err) - } - return n - len(prefix) -} diff --git a/common/encapsulation/encapsulation_test.go b/common/encapsulation/encapsulation_test.go deleted file mode 100644 index 333abb4..0000000 --- a/common/encapsulation/encapsulation_test.go +++ /dev/null @@ -1,330 +0,0 @@ -package encapsulation - -import ( - "bytes" - "io" - "math/rand" - "testing" -) - -// Return a byte slice with non-trivial contents. -func pseudorandomBuffer(n int) []byte { - source := rand.NewSource(0) - p := make([]byte, n) - for i := 0; i < len(p); i++ { - p[i] = byte(source.Int63() & 0xff) - } - return p -} - -func mustWriteData(w io.Writer, p []byte) int { - n, err := WriteData(w, p) - if err != nil { - panic(err) - } - return n -} - -func mustWritePadding(w io.Writer, n int) int { - n, err := WritePadding(w, n) - if err != nil { - panic(err) - } - return n -} - -// Test that ReadData(WriteData()) recovers the original data. -func TestRoundtrip(t *testing.T) { - // Test above and below interesting thresholds. - for _, i := range []int{ - 0x00, 0x01, - 0x3e, 0x3f, 0x40, 0x41, - 0xfe, 0xff, 0x100, 0x101, - 0x1ffe, 0x1fff, 0x2000, 0x2001, - 0xfffe, 0xffff, 0x10000, 0x10001, - 0xffffe, 0xfffff, - } { - original := pseudorandomBuffer(i) - var enc bytes.Buffer - n, err := WriteData(&enc, original) - if err != nil { - t.Fatalf("size %d, WriteData returned error %v", i, err) - } - if enc.Len() != n { - t.Fatalf("size %d, returned length was %d, written length was %d", - i, n, enc.Len()) - } - inverse, err := ReadData(&enc) - if err != nil { - t.Fatalf("size %d, ReadData returned error %v", i, err) - } - if !bytes.Equal(inverse, original) { - t.Fatalf("size %d, got <%x>, expected <%x>", i, inverse, original) - } - } -} - -// Test that WritePadding writes exactly as much as requested. -func TestPaddingLength(t *testing.T) { - // Test above and below interesting thresholds. WritePadding also gets - // values above 0xfffff, the maximum value of a single length prefix. - for _, i := range []int{ - 0x00, 0x01, - 0x3f, 0x40, 0x41, 0x42, - 0xff, 0x100, 0x101, 0x102, - 0x2000, 0x2001, 0x2002, 0x2003, - 0x10000, 0x10001, 0x10002, 0x10003, - 0x100001, 0x100002, 0x100003, 0x100004, - } { - var enc bytes.Buffer - n, err := WritePadding(&enc, i) - if err != nil { - t.Fatalf("size %d, WritePadding returned error %v", i, err) - } - if n != i { - t.Fatalf("requested %d bytes, returned %d", i, n) - } - if enc.Len() != n { - t.Fatalf("requested %d bytes, wrote %d bytes", i, enc.Len()) - } - } -} - -// Test that ReadData skips over padding. -func TestSkipPadding(t *testing.T) { - var data = [][]byte{{}, {}, []byte("hello"), {}, []byte("world")} - var enc bytes.Buffer - mustWritePadding(&enc, 10) - mustWritePadding(&enc, 100) - mustWriteData(&enc, data[0]) - mustWriteData(&enc, data[1]) - mustWritePadding(&enc, 10) - mustWriteData(&enc, data[2]) - mustWriteData(&enc, data[3]) - mustWritePadding(&enc, 10) - mustWriteData(&enc, data[4]) - mustWritePadding(&enc, 10) - mustWritePadding(&enc, 10) - for i, expected := range data { - actual, err := ReadData(&enc) - if err != nil { - t.Fatalf("slice %d, got error %v, expected %v", i, err, nil) - } - if !bytes.Equal(actual, expected) { - t.Fatalf("slice %d, got <%x>, expected <%x>", i, actual, expected) - } - } - p, err := ReadData(&enc) - if p != nil || err != io.EOF { - t.Fatalf("got (<%x>, %v), expected (%v, %v)", p, err, nil, io.EOF) - } -} - -// Test that EOF before a length prefix returns io.EOF. -func TestEOF(t *testing.T) { - p, err := ReadData(bytes.NewReader(nil)) - if p != nil || err != io.EOF { - t.Fatalf("got (<%x>, %v), expected (%v, %v)", p, err, nil, io.EOF) - } -} - -// Test that an EOF while reading a length prefix, or while reading the -// subsequent data/padding, returns io.ErrUnexpectedEOF. -func TestUnexpectedEOF(t *testing.T) { - for _, test := range [][]byte{ - {0x40}, // expecting a second length byte - {0xc0}, // expecting a second length byte - {0x41, 0x80}, // expecting a third length byte - {0xc1, 0x80}, // expecting a third length byte - {0x02}, // expecting 2 bytes of padding - {0x82}, // expecting 2 bytes of data - {0x02, 'X'}, // expecting 1 byte of padding - {0x82, 'X'}, // expecting 1 byte of data - {0x41, 0x00}, // expecting 128 bytes of padding - {0xc1, 0x00}, // expecting 128 bytes of data - {0x41, 0x00, 'X'}, // expecting 127 bytes of padding - {0xc1, 0x00, 'X'}, // expecting 127 bytes of data - {0x41, 0x80, 0x00}, // expecting 32768 bytes of padding - {0xc1, 0x80, 0x00}, // expecting 32768 bytes of data - {0x41, 0x80, 0x00, 'X'}, // expecting 32767 bytes of padding - {0xc1, 0x80, 0x00, 'X'}, // expecting 32767 bytes of data - } { - p, err := ReadData(bytes.NewReader(test)) - if p != nil || err != io.ErrUnexpectedEOF { - t.Fatalf("<%x> got (<%x>, %v), expected (%v, %v)", test, p, err, nil, io.ErrUnexpectedEOF) - } - } -} - -// Test that length encodings that are longer than they could be are still -// interpreted. -func TestNonMinimalLengthEncoding(t *testing.T) { - for _, test := range []struct { - enc []byte - expected []byte - }{ - {[]byte{0x81, 'X'}, []byte("X")}, - {[]byte{0xc0, 0x01, 'X'}, []byte("X")}, - {[]byte{0xc0, 0x80, 0x01, 'X'}, []byte("X")}, - } { - p, err := ReadData(bytes.NewReader(test.enc)) - if err != nil { - t.Fatalf("<%x> got error %v, expected %v", test.enc, err, nil) - } - if !bytes.Equal(p, test.expected) { - t.Fatalf("<%x> got <%x>, expected <%x>", test.enc, p, test.expected) - } - } -} - -// Test that ReadData only reads up to 3 bytes of length prefix. -func TestReadLimits(t *testing.T) { - // Test the maximum length that's possible with 3 bytes of length - // prefix. - maxLength := (0x3f << 14) | (0x7f << 7) | 0x7f - data := bytes.Repeat([]byte{'X'}, maxLength) - prefix := []byte{0xff, 0xff, 0x7f} // encodes 0xfffff - p, err := ReadData(bytes.NewReader(append(prefix, data...))) - if err != nil { - t.Fatalf("got error %v, expected %v", err, nil) - } - if !bytes.Equal(p, data) { - t.Fatalf("got %d bytes unequal to %d bytes", len(p), len(data)) - } - // Test a 4-byte prefix. - prefix = []byte{0xc0, 0xc0, 0x80, 0x80} // encodes 0x100000 - data = bytes.Repeat([]byte{'X'}, maxLength+1) - p, err = ReadData(bytes.NewReader(append(prefix, data...))) - if p != nil || err != ErrTooLong { - t.Fatalf("got (<%x>, %v), expected (%v, %v)", p, err, nil, ErrTooLong) - } - // Test that 4 bytes don't work, even when they encode an integer that - // would fix in 3 bytes. - prefix = []byte{0xc0, 0x80, 0x80, 0x80} // encodes 0x0 - data = []byte{} - p, err = ReadData(bytes.NewReader(append(prefix, data...))) - if p != nil || err != ErrTooLong { - t.Fatalf("got (<%x>, %v), expected (%v, %v)", p, err, nil, ErrTooLong) - } - - // Do the same tests with padding lengths. - data = []byte("hello") - prefix = []byte{0x7f, 0xff, 0x7f} // encodes 0xfffff - padding := bytes.Repeat([]byte{'X'}, maxLength) - enc := bytes.NewBuffer(append(prefix, padding...)) - mustWriteData(enc, data) - p, err = ReadData(enc) - if err != nil { - t.Fatalf("got error %v, expected %v", err, nil) - } - if !bytes.Equal(p, data) { - t.Fatalf("got <%x>, expected <%x>", p, data) - } - prefix = []byte{0x40, 0xc0, 0x80, 0x80} // encodes 0x100000 - padding = bytes.Repeat([]byte{'X'}, maxLength+1) - enc = bytes.NewBuffer(append(prefix, padding...)) - mustWriteData(enc, data) - p, err = ReadData(enc) - if p != nil || err != ErrTooLong { - t.Fatalf("got (<%x>, %v), expected (%v, %v)", p, err, nil, ErrTooLong) - } - prefix = []byte{0x40, 0x80, 0x80, 0x80} // encodes 0x0 - padding = []byte{} - enc = bytes.NewBuffer(append(prefix, padding...)) - mustWriteData(enc, data) - p, err = ReadData(enc) - if p != nil || err != ErrTooLong { - t.Fatalf("got (<%x>, %v), expected (%v, %v)", p, err, nil, ErrTooLong) - } -} - -// Test that WriteData and WritePadding only accept lengths that can be encoded -// in up to 3 bytes of length prefix. -func TestWriteLimits(t *testing.T) { - maxLength := (0x3f << 14) | (0x7f << 7) | 0x7f - var enc bytes.Buffer - n, err := WriteData(&enc, bytes.Repeat([]byte{'X'}, maxLength)) - if n != maxLength+3 || err != nil { - t.Fatalf("got (%d, %v), expected (%d, %v)", n, err, maxLength, nil) - } - enc.Reset() - n, err = WriteData(&enc, bytes.Repeat([]byte{'X'}, maxLength+1)) - if n != 0 || err != ErrTooLong { - t.Fatalf("got (%d, %v), expected (%d, %v)", n, err, 0, ErrTooLong) - } - - // Padding gets an extra 3 bytes because the prefix is counted as part - // of the length. - enc.Reset() - n, err = WritePadding(&enc, maxLength+3) - if n != maxLength+3 || err != nil { - t.Fatalf("got (%d, %v), expected (%d, %v)", n, err, maxLength+3, nil) - } - // Writing a too-long padding is okay because WritePadding will break it - // into smaller chunks. - enc.Reset() - n, err = WritePadding(&enc, maxLength+4) - if n != maxLength+4 || err != nil { - t.Fatalf("got (%d, %v), expected (%d, %v)", n, err, maxLength+4, nil) - } -} - -// Test that WritePadding panics when given a negative length. -func TestNegativeLength(t *testing.T) { - for _, n := range []int{-1, ^0} { - var enc bytes.Buffer - panicked, nn, err := testNegativeLengthSub(t, &enc, n) - if !panicked { - t.Fatalf("WritePadding(%d) returned (%d, %v) instead of panicking", n, nn, err) - } - } -} - -// Calls WritePadding(w, n) and augments the return value with a flag indicating -// whether the call panicked. -func testNegativeLengthSub(t *testing.T, w io.Writer, n int) (panicked bool, nn int, err error) { - defer func() { - if r := recover(); r != nil { - panicked = true - } - }() - t.Helper() - nn, err = WritePadding(w, n) - return false, n, err -} - -// Test that MaxDataForSize panics when given a 0 length. -func TestMaxDataForSizeZero(t *testing.T) { - defer func() { - if r := recover(); r == nil { - t.Fatal("didn't panic") - } - }() - MaxDataForSize(0) -} - -// Test thresholds of available sizes for MaxDataForSize. -func TestMaxDataForSize(t *testing.T) { - for _, test := range []struct { - size int - expected int - }{ - {0x01, 0x00}, - {0x02, 0x01}, - {0x3f, 0x3e}, - {0x40, 0x3e}, - {0x41, 0x3f}, - {0x1fff, 0x1ffd}, - {0x2000, 0x1ffd}, - {0x2001, 0x1ffe}, - {0xfffff, 0xffffc}, - {0x100000, 0xffffc}, - {0x100001, 0xffffc}, - {0x7fffffff, 0xffffc}, - } { - max := MaxDataForSize(test.size) - if max != test.expected { - t.Fatalf("size %d, got %d, expected %d", test.size, max, test.expected) - } - } -} diff --git a/common/event/bus.go b/common/event/bus.go deleted file mode 100644 index 7e45779..0000000 --- a/common/event/bus.go +++ /dev/null @@ -1,39 +0,0 @@ -package event - -import "sync" - -func NewSnowflakeEventDispatcher() SnowflakeEventDispatcher { - return &eventBus{lock: &sync.Mutex{}} -} - -type eventBus struct { - lock *sync.Mutex - listeners []SnowflakeEventReceiver -} - -func (e *eventBus) OnNewSnowflakeEvent(event SnowflakeEvent) { - e.lock.Lock() - defer e.lock.Unlock() - for _, v := range e.listeners { - v.OnNewSnowflakeEvent(event) - } -} - -func (e *eventBus) AddSnowflakeEventListener(receiver SnowflakeEventReceiver) { - e.lock.Lock() - defer e.lock.Unlock() - e.listeners = append(e.listeners, receiver) -} - -func (e *eventBus) RemoveSnowflakeEventListener(receiver SnowflakeEventReceiver) { - e.lock.Lock() - defer e.lock.Unlock() - var newListeners []SnowflakeEventReceiver - for _, v := range e.listeners { - if v != receiver { - newListeners = append(newListeners, v) - } - } - e.listeners = newListeners - return -} diff --git a/common/event/bus_test.go b/common/event/bus_test.go deleted file mode 100644 index df40d3d..0000000 --- a/common/event/bus_test.go +++ /dev/null @@ -1,32 +0,0 @@ -package event - -import ( - "github.com/stretchr/testify/assert" - "testing" -) - -type stubReceiver struct { - counter int -} - -func (s *stubReceiver) OnNewSnowflakeEvent(event SnowflakeEvent) { - s.counter++ -} - -func TestBusDispatch(t *testing.T) { - EventBus := NewSnowflakeEventDispatcher() - StubReceiverA := &stubReceiver{} - StubReceiverB := &stubReceiver{} - EventBus.AddSnowflakeEventListener(StubReceiverA) - EventBus.AddSnowflakeEventListener(StubReceiverB) - assert.Equal(t, 0, StubReceiverA.counter) - assert.Equal(t, 0, StubReceiverB.counter) - EventBus.OnNewSnowflakeEvent(EventOnSnowflakeConnected{}) - assert.Equal(t, 1, StubReceiverA.counter) - assert.Equal(t, 1, StubReceiverB.counter) - EventBus.RemoveSnowflakeEventListener(StubReceiverB) - EventBus.OnNewSnowflakeEvent(EventOnSnowflakeConnected{}) - assert.Equal(t, 2, StubReceiverA.counter) - assert.Equal(t, 1, StubReceiverB.counter) - -} diff --git a/common/event/interface.go b/common/event/interface.go deleted file mode 100644 index 968b270..0000000 --- a/common/event/interface.go +++ /dev/null @@ -1,85 +0,0 @@ -package event - -import ( - "fmt" - - "git.torproject.org/pluggable-transports/snowflake.git/v2/common/safelog" - "github.com/pion/webrtc/v3" -) - -type SnowflakeEvent interface { - IsSnowflakeEvent() - String() string -} - -type EventOnOfferCreated struct { - SnowflakeEvent - WebRTCLocalDescription *webrtc.SessionDescription - Error error -} - -func (e EventOnOfferCreated) String() string { - if e.Error != nil { - scrubbed := safelog.Scrub([]byte(e.Error.Error())) - return fmt.Sprintf("offer creation failure %s", scrubbed) - } - return "offer created" -} - -type EventOnBrokerRendezvous struct { - SnowflakeEvent - WebRTCRemoteDescription *webrtc.SessionDescription - Error error -} - -func (e EventOnBrokerRendezvous) String() string { - if e.Error != nil { - scrubbed := safelog.Scrub([]byte(e.Error.Error())) - return fmt.Sprintf("broker failure %s", scrubbed) - } - return "broker rendezvous peer received" -} - -type EventOnSnowflakeConnected struct { - SnowflakeEvent -} - -func (e EventOnSnowflakeConnected) String() string { - return "connected" -} - -type EventOnSnowflakeConnectionFailed struct { - SnowflakeEvent - Error error -} - -func (e EventOnSnowflakeConnectionFailed) String() string { - scrubbed := safelog.Scrub([]byte(e.Error.Error())) - return fmt.Sprintf("trying a new proxy: %s", scrubbed) -} - -type EventOnProxyConnectionOver struct { - SnowflakeEvent - InboundTraffic int - OutboundTraffic int -} - -func (e EventOnProxyConnectionOver) String() string { - return fmt.Sprintf("Proxy connection closed (↑ %d, ↓ %d)", e.InboundTraffic, e.OutboundTraffic) -} - -type SnowflakeEventReceiver interface { - // OnNewSnowflakeEvent notify receiver about a new event - // This method MUST not block - OnNewSnowflakeEvent(event SnowflakeEvent) -} - -type SnowflakeEventDispatcher interface { - SnowflakeEventReceiver - // AddSnowflakeEventListener allow receiver(s) to receive event notification - // when OnNewSnowflakeEvent is called on the dispatcher. - // Every event listener added will be called when an event is received by the dispatcher. - // The order each listener is called is undefined. - AddSnowflakeEventListener(receiver SnowflakeEventReceiver) - RemoveSnowflakeEventListener(receiver SnowflakeEventReceiver) -} diff --git a/common/ipsetsink/sink.go b/common/ipsetsink/sink.go deleted file mode 100644 index 168d061..0000000 --- a/common/ipsetsink/sink.go +++ /dev/null @@ -1,55 +0,0 @@ -package ipsetsink - -import ( - "bytes" - "crypto/hmac" - "encoding/binary" - "hash" - - "github.com/clarkduvall/hyperloglog" - "golang.org/x/crypto/sha3" -) - -func NewIPSetSink(maskingKey string) *IPSetSink { - countDistinct, _ := hyperloglog.NewPlus(18) - return &IPSetSink{ - ipMaskingKey: maskingKey, - countDistinct: countDistinct, - } -} - -type IPSetSink struct { - ipMaskingKey string - countDistinct *hyperloglog.HyperLogLogPlus -} - -func (s *IPSetSink) maskIPAddress(ipAddress string) []byte { - hmacIPMasker := hmac.New(func() hash.Hash { - return sha3.New256() - }, []byte(s.ipMaskingKey)) - hmacIPMasker.Write([]byte(ipAddress)) - return hmacIPMasker.Sum(nil) -} - -func (s *IPSetSink) AddIPToSet(ipAddress string) { - s.countDistinct.Add(truncatedHash64FromBytes{hashValue(s.maskIPAddress(ipAddress))}) -} - -func (s *IPSetSink) Dump() ([]byte, error) { - return s.countDistinct.GobEncode() -} - -func (s *IPSetSink) Reset() { - s.countDistinct.Clear() -} - -type hashValue []byte -type truncatedHash64FromBytes struct { - hashValue -} - -func (c truncatedHash64FromBytes) Sum64() uint64 { - var value uint64 - binary.Read(bytes.NewReader(c.hashValue), binary.BigEndian, &value) - return value -} diff --git a/common/ipsetsink/sink_test.go b/common/ipsetsink/sink_test.go deleted file mode 100644 index 00ae965..0000000 --- a/common/ipsetsink/sink_test.go +++ /dev/null @@ -1,47 +0,0 @@ -package ipsetsink - -import ( - "fmt" - "github.com/clarkduvall/hyperloglog" - "testing" -) -import . "github.com/smartystreets/goconvey/convey" - -func TestSinkInit(t *testing.T) { - Convey("Context", t, func() { - sink := NewIPSetSink("demo") - sink.AddIPToSet("test1") - sink.AddIPToSet("test2") - data, err := sink.Dump() - So(err, ShouldBeNil) - structure, err := hyperloglog.NewPlus(18) - So(err, ShouldBeNil) - err = structure.GobDecode(data) - So(err, ShouldBeNil) - count := structure.Count() - So(count, ShouldBeBetweenOrEqual, 1, 3) - }) -} - -func TestSinkCounting(t *testing.T) { - Convey("Context", t, func() { - for itemCount := 300; itemCount <= 10000; itemCount += 200 { - sink := NewIPSetSink("demo") - for i := 0; i <= itemCount; i++ { - sink.AddIPToSet(fmt.Sprintf("demo%v", i)) - } - for i := 0; i <= itemCount; i++ { - sink.AddIPToSet(fmt.Sprintf("demo%v", i)) - } - data, err := sink.Dump() - So(err, ShouldBeNil) - structure, err := hyperloglog.NewPlus(18) - So(err, ShouldBeNil) - err = structure.GobDecode(data) - So(err, ShouldBeNil) - count := structure.Count() - So((float64(count)/float64(itemCount))-1.0, ShouldAlmostEqual, 0, 0.01) - } - - }) -} diff --git a/common/ipsetsink/sinkcluster/common.go b/common/ipsetsink/sinkcluster/common.go deleted file mode 100644 index 4360f70..0000000 --- a/common/ipsetsink/sinkcluster/common.go +++ /dev/null @@ -1,24 +0,0 @@ -package sinkcluster - -/* ClusterWriter, and (ClusterCountResult).Count output a streamed IP set journal file to remember distinct IP address - - its format is as follows: - - This file should be in newline-delimited JSON format(https://jsonlines.org/). - For each line, the format of json data should be in the format of: - {"recordingStart":"2022-05-30T14:38:44.678610091Z","recordingEnd":"2022-05-30T14:39:48.157630926Z","recorded":""} - - recordingStart:datetime is the time this chunk of recording start. - - recordingEnd:datetime is the time this chunk of recording end. - - recorded is the checkpoint data generated by hyperloglog. -*/ - -import "time" - -type SinkEntry struct { - RecordingStart time.Time `json:"recordingStart"` - RecordingEnd time.Time `json:"recordingEnd"` - Recorded []byte `json:"recorded"` -} diff --git a/common/ipsetsink/sinkcluster/reader.go b/common/ipsetsink/sinkcluster/reader.go deleted file mode 100644 index 3f7a08f..0000000 --- a/common/ipsetsink/sinkcluster/reader.go +++ /dev/null @@ -1,60 +0,0 @@ -package sinkcluster - -import ( - "bufio" - "encoding/json" - "github.com/clarkduvall/hyperloglog" - "io" - "time" -) - -func NewClusterCounter(from time.Time, to time.Time) *ClusterCounter { - return &ClusterCounter{from: from, to: to} -} - -type ClusterCounter struct { - from time.Time - to time.Time -} - -type ClusterCountResult struct { - Sum uint64 - ChunkIncluded int64 -} - -func (c ClusterCounter) Count(reader io.Reader) (*ClusterCountResult, error) { - result := ClusterCountResult{} - counter, err := hyperloglog.NewPlus(18) - if err != nil { - return nil, err - } - inputScanner := bufio.NewScanner(reader) - for inputScanner.Scan() { - inputLine := inputScanner.Bytes() - sinkInfo := SinkEntry{} - if err := json.Unmarshal(inputLine, &sinkInfo); err != nil { - return nil, err - } - - if (sinkInfo.RecordingStart.Before(c.from) && !sinkInfo.RecordingStart.Equal(c.from)) || - sinkInfo.RecordingEnd.After(c.to) { - continue - } - - restoredCounter, err := hyperloglog.NewPlus(18) - if err != nil { - return nil, err - } - err = restoredCounter.GobDecode(sinkInfo.Recorded) - if err != nil { - return nil, err - } - result.ChunkIncluded++ - err = counter.Merge(restoredCounter) - if err != nil { - return nil, err - } - } - result.Sum = counter.Count() - return &result, nil -} diff --git a/common/ipsetsink/sinkcluster/writer.go b/common/ipsetsink/sinkcluster/writer.go deleted file mode 100644 index 1c409e8..0000000 --- a/common/ipsetsink/sinkcluster/writer.go +++ /dev/null @@ -1,68 +0,0 @@ -package sinkcluster - -import ( - "bytes" - "encoding/json" - "io" - "log" - "time" - - "git.torproject.org/pluggable-transports/snowflake.git/v2/common/ipsetsink" -) - -func NewClusterWriter(writer WriteSyncer, writeInterval time.Duration, sink *ipsetsink.IPSetSink) *ClusterWriter { - c := &ClusterWriter{ - writer: writer, - lastWriteTime: time.Now(), - writeInterval: writeInterval, - current: sink, - } - return c -} - -type ClusterWriter struct { - writer WriteSyncer - lastWriteTime time.Time - writeInterval time.Duration - current *ipsetsink.IPSetSink -} - -type WriteSyncer interface { - Sync() error - io.Writer -} - -func (c *ClusterWriter) WriteIPSetToDisk() { - currentTime := time.Now() - data, err := c.current.Dump() - if err != nil { - log.Println("unable able to write ipset to file:", err) - return - } - entry := &SinkEntry{ - RecordingStart: c.lastWriteTime, - RecordingEnd: currentTime, - Recorded: data, - } - jsonData, err := json.Marshal(entry) - if err != nil { - log.Println("unable able to write ipset to file:", err) - return - } - jsonData = append(jsonData, byte('\n')) - _, err = io.Copy(c.writer, bytes.NewReader(jsonData)) - if err != nil { - log.Println("unable able to write ipset to file:", err) - return - } - c.writer.Sync() - c.lastWriteTime = currentTime - c.current.Reset() -} - -func (c *ClusterWriter) AddIPToSet(ipAddress string) { - if c.lastWriteTime.Add(c.writeInterval).Before(time.Now()) { - c.WriteIPSetToDisk() - } - c.current.AddIPToSet(ipAddress) -} diff --git a/common/ipsetsink/sinkcluster/writer_test.go b/common/ipsetsink/sinkcluster/writer_test.go deleted file mode 100644 index 5319cb2..0000000 --- a/common/ipsetsink/sinkcluster/writer_test.go +++ /dev/null @@ -1,33 +0,0 @@ -package sinkcluster - -import ( - "bytes" - "io" - "testing" - "time" - - "git.torproject.org/pluggable-transports/snowflake.git/v2/common/ipsetsink" - - . "github.com/smartystreets/goconvey/convey" -) - -type writerStub struct { - io.Writer -} - -func (w writerStub) Sync() error { - return nil -} - -func TestSinkWriter(t *testing.T) { - - Convey("Context", t, func() { - buffer := bytes.NewBuffer(nil) - writerStubInst := &writerStub{buffer} - sink := ipsetsink.NewIPSetSink("demo") - clusterWriter := NewClusterWriter(writerStubInst, time.Minute, sink) - clusterWriter.AddIPToSet("1") - clusterWriter.WriteIPSetToDisk() - So(buffer.Bytes(), ShouldNotBeNil) - }) -} diff --git a/common/messages/client.go b/common/messages/client.go deleted file mode 100644 index af63e08..0000000 --- a/common/messages/client.go +++ /dev/null @@ -1,151 +0,0 @@ -//Package for communication with the snowflake broker - -//import "git.torproject.org/pluggable-transports/snowflake.git/v2/common/messages" -package messages - -import ( - "bytes" - "encoding/json" - "fmt" - "git.torproject.org/pluggable-transports/snowflake.git/v2/common/bridgefingerprint" - - "git.torproject.org/pluggable-transports/snowflake.git/v2/common/nat" -) - -const ClientVersion = "1.0" - -/* Client--Broker protocol v1.x specification: - -All messages contain the version number -followed by a new line and then the message body - := \n - := . - := | - -There are two different types of body messages, -each encoded in JSON format - -== ClientPollRequest == - := -{ - offer: - [nat: (unknown|restricted|unrestricted)] - [fingerprint: ] -} - -The NAT field is optional, and if it is missing a -value of "unknown" will be assumed. The fingerprint -is also optional and, if absent, will be assigned the -fingerprint of the default bridge. - -== ClientPollResponse == - := -{ - [answer: ] - [error: ] -} - -If the broker succeeded in matching the client with a proxy, -the answer field MUST contain a valid SDP answer, and the -error field MUST be empty. If the answer field is empty, the -error field MUST contain a string explaining with a reason -for the error. - -*/ - -// The bridge fingerprint to assume, for client poll requests that do not -// specify a fingerprint. Before #28651, there was only one bridge with one -// fingerprint, which all clients expected to be connected to implicitly. -// If a client is old enough that it does not specify a fingerprint, this is -// the fingerprint it expects. Clients that do set a fingerprint in the -// SOCKS params will also be assumed to want to connect to the default bridge. -const defaultBridgeFingerprint = "2B280B23E1107BB62ABFC40DDCC8824814F80A72" - -type ClientPollRequest struct { - Offer string `json:"offer"` - NAT string `json:"nat"` - Fingerprint string `json:"fingerprint"` -} - -// Encodes a poll message from a snowflake client -func (req *ClientPollRequest) EncodeClientPollRequest() ([]byte, error) { - if req.Fingerprint == "" { - req.Fingerprint = defaultBridgeFingerprint - } - body, err := json.Marshal(req) - if err != nil { - return nil, err - } - return append([]byte(ClientVersion+"\n"), body...), nil -} - -// Decodes a poll message from a snowflake client -func DecodeClientPollRequest(data []byte) (*ClientPollRequest, error) { - parts := bytes.SplitN(data, []byte("\n"), 2) - - if len(parts) < 2 { - // no version number found - return nil, fmt.Errorf("unsupported message version") - } - - var message ClientPollRequest - - if string(parts[0]) != ClientVersion { - return nil, fmt.Errorf("unsupported message version") - } - - err := json.Unmarshal(parts[1], &message) - if err != nil { - return nil, err - } - - if message.Offer == "" { - return nil, fmt.Errorf("no supplied offer") - } - - if message.Fingerprint == "" { - message.Fingerprint = defaultBridgeFingerprint - } - - if _, err := bridgefingerprint.FingerprintFromHexString(message.Fingerprint); err != nil { - return nil, fmt.Errorf("cannot decode fingerprint") - } - - switch message.NAT { - case "": - message.NAT = nat.NATUnknown - case nat.NATUnknown: - case nat.NATRestricted: - case nat.NATUnrestricted: - default: - return nil, fmt.Errorf("invalid NAT type") - } - - return &message, nil -} - -type ClientPollResponse struct { - Answer string `json:"answer,omitempty"` - Error string `json:"error,omitempty"` -} - -// Encodes a poll response for a snowflake client -func (resp *ClientPollResponse) EncodePollResponse() ([]byte, error) { - return json.Marshal(resp) -} - -// Decodes a poll response for a snowflake client -// If the Error field is empty, the Answer should be non-empty -func DecodeClientPollResponse(data []byte) (*ClientPollResponse, error) { - var message ClientPollResponse - - err := json.Unmarshal(data, &message) - if err != nil { - return nil, err - } - if message.Error == "" && message.Answer == "" { - return nil, fmt.Errorf("received empty broker response") - } - - return &message, nil -} diff --git a/common/messages/ipc.go b/common/messages/ipc.go deleted file mode 100644 index 3250742..0000000 --- a/common/messages/ipc.go +++ /dev/null @@ -1,19 +0,0 @@ -package messages - -import ( - "errors" -) - -type Arg struct { - Body []byte - RemoteAddr string -} - -var ( - ErrBadRequest = errors.New("bad request") - ErrInternal = errors.New("internal error") - ErrExtraInfo = errors.New("client sent extra info") - - StrTimedOut = "timed out waiting for answer!" - StrNoProxies = "no snowflake proxies currently available" -) diff --git a/common/messages/messages_test.go b/common/messages/messages_test.go deleted file mode 100644 index 2fc76df..0000000 --- a/common/messages/messages_test.go +++ /dev/null @@ -1,472 +0,0 @@ -package messages - -import ( - "encoding/json" - "fmt" - "testing" - - . "github.com/smartystreets/goconvey/convey" -) - -func TestDecodeProxyPollRequest(t *testing.T) { - Convey("Context", t, func() { - for _, test := range []struct { - sid string - proxyType string - natType string - clients int - data string - err error - - acceptedRelayPattern string - }{ - { - //Version 1.0 proxy message - sid: "ymbcCMto7KHNGYlp", - proxyType: "unknown", - natType: "unknown", - clients: 0, - data: `{"Sid":"ymbcCMto7KHNGYlp","Version":"1.0"}`, - err: nil, - }, - { - //Version 1.1 proxy message - sid: "ymbcCMto7KHNGYlp", - proxyType: "standalone", - natType: "unknown", - clients: 0, - data: `{"Sid":"ymbcCMto7KHNGYlp","Version":"1.1","Type":"standalone"}`, - err: nil, - }, - { - //Version 1.2 proxy message - sid: "ymbcCMto7KHNGYlp", - proxyType: "standalone", - natType: "restricted", - clients: 0, - data: `{"Sid":"ymbcCMto7KHNGYlp","Version":"1.2","Type":"standalone", "NAT":"restricted"}`, - err: nil, - }, - { - //Version 1.2 proxy message with clients - sid: "ymbcCMto7KHNGYlp", - proxyType: "standalone", - natType: "restricted", - clients: 24, - data: `{"Sid":"ymbcCMto7KHNGYlp","Version":"1.2","Type":"standalone", "NAT":"restricted","Clients":24}`, - err: nil, - }, - { - //Version 1.3 proxy message with clients and proxyURL - sid: "ymbcCMto7KHNGYlp", - proxyType: "standalone", - natType: "restricted", - clients: 24, - acceptedRelayPattern: "snowfalke.torproject.org", - data: `{"Sid":"ymbcCMto7KHNGYlp","Version":"1.2","Type":"standalone", "NAT":"restricted","Clients":24, "AcceptedRelayPattern":"snowfalke.torproject.org"}`, - err: nil, - }, - { - //Version 0.X proxy message: - sid: "", - proxyType: "", - natType: "", - clients: 0, - data: "", - err: &json.SyntaxError{}, - }, - { - sid: "", - proxyType: "", - natType: "", - clients: 0, - data: `{"Sid":"ymbcCMto7KHNGYlp"}`, - err: fmt.Errorf(""), - }, - { - sid: "", - proxyType: "", - natType: "", - clients: 0, - data: "{}", - err: fmt.Errorf(""), - }, - { - sid: "", - proxyType: "", - natType: "", - clients: 0, - data: `{"Version":"1.0"}`, - err: fmt.Errorf(""), - }, - { - sid: "", - proxyType: "", - natType: "", - clients: 0, - data: `{"Version":"2.0"}`, - err: fmt.Errorf(""), - }, - } { - sid, proxyType, natType, clients, relayPattern, _, err := DecodeProxyPollRequestWithRelayPrefix([]byte(test.data)) - So(sid, ShouldResemble, test.sid) - So(proxyType, ShouldResemble, test.proxyType) - So(natType, ShouldResemble, test.natType) - So(clients, ShouldEqual, test.clients) - So(relayPattern, ShouldResemble, test.acceptedRelayPattern) - So(err, ShouldHaveSameTypeAs, test.err) - } - - }) -} - -func TestEncodeProxyPollRequests(t *testing.T) { - Convey("Context", t, func() { - b, err := EncodeProxyPollRequest("ymbcCMto7KHNGYlp", "standalone", "unknown", 16) - So(err, ShouldEqual, nil) - sid, proxyType, natType, clients, err := DecodeProxyPollRequest(b) - So(sid, ShouldEqual, "ymbcCMto7KHNGYlp") - So(proxyType, ShouldEqual, "standalone") - So(natType, ShouldEqual, "unknown") - So(clients, ShouldEqual, 16) - So(err, ShouldEqual, nil) - }) -} - -func TestDecodeProxyPollResponse(t *testing.T) { - Convey("Context", t, func() { - for _, test := range []struct { - offer string - data string - relayURL string - err error - }{ - { - offer: "fake offer", - data: `{"Status":"client match","Offer":"fake offer","NAT":"unknown"}`, - err: nil, - }, - { - offer: "fake offer", - data: `{"Status":"client match","Offer":"fake offer","NAT":"unknown", "RelayURL":"wss://snowflake.torproject.org/proxy"}`, - relayURL: "wss://snowflake.torproject.org/proxy", - err: nil, - }, - { - offer: "", - data: `{"Status":"no match"}`, - err: nil, - }, - { - offer: "", - data: `{"Status":"client match"}`, - err: fmt.Errorf("no supplied offer"), - }, - { - offer: "", - data: `{"Test":"test"}`, - err: fmt.Errorf(""), - }, - } { - offer, _, relayURL, err := DecodePollResponseWithRelayURL([]byte(test.data)) - So(err, ShouldHaveSameTypeAs, test.err) - So(offer, ShouldResemble, test.offer) - So(relayURL, ShouldResemble, test.relayURL) - } - - }) -} - -func TestEncodeProxyPollResponse(t *testing.T) { - Convey("Context", t, func() { - b, err := EncodePollResponse("fake offer", true, "restricted") - So(err, ShouldEqual, nil) - offer, natType, err := DecodePollResponse(b) - So(offer, ShouldEqual, "fake offer") - So(natType, ShouldEqual, "restricted") - So(err, ShouldEqual, nil) - - b, err = EncodePollResponse("", false, "unknown") - So(err, ShouldEqual, nil) - offer, natType, err = DecodePollResponse(b) - So(offer, ShouldEqual, "") - So(natType, ShouldEqual, "unknown") - So(err, ShouldEqual, nil) - }) -} - -func TestEncodeProxyPollResponseWithProxyURL(t *testing.T) { - Convey("Context", t, func() { - b, err := EncodePollResponseWithRelayURL("fake offer", true, "restricted", "wss://test/", "") - So(err, ShouldBeNil) - offer, natType, err := DecodePollResponse(b) - So(err, ShouldNotBeNil) - - offer, natType, relay, err := DecodePollResponseWithRelayURL(b) - So(offer, ShouldEqual, "fake offer") - So(natType, ShouldEqual, "restricted") - So(relay, ShouldEqual, "wss://test/") - So(err, ShouldBeNil) - - b, err = EncodePollResponse("", false, "unknown") - So(err, ShouldBeNil) - offer, natType, relay, err = DecodePollResponseWithRelayURL(b) - So(offer, ShouldEqual, "") - So(natType, ShouldEqual, "unknown") - So(err, ShouldBeNil) - - b, err = EncodePollResponseWithRelayURL("fake offer", false, "restricted", "wss://test/", "test error reason") - So(err, ShouldBeNil) - offer, natType, relay, err = DecodePollResponseWithRelayURL(b) - So(err, ShouldNotBeNil) - So(err.Error(), ShouldContainSubstring, "test error reason") - }) -} -func TestDecodeProxyAnswerRequest(t *testing.T) { - Convey("Context", t, func() { - for _, test := range []struct { - answer string - sid string - data string - err error - }{ - { - "test", - "test", - `{"Version":"1.0","Sid":"test","Answer":"test"}`, - nil, - }, - { - "", - "", - `{"type":"offer","sdp":"v=0\r\no=- 4358805017720277108 2 IN IP4 [scrubbed]\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 [scrubbed]\r\na=candidate:3769337065 1 udp 2122260223 [scrubbed] 56688 typ host generation 0 network-id 1 network-cost 50\r\na=candidate:2921887769 1 tcp 1518280447 [scrubbed] 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"}`, - fmt.Errorf(""), - }, - { - "", - "", - `{"Version":"1.0","Answer":"test"}`, - fmt.Errorf(""), - }, - { - "", - "", - `{"Version":"1.0","Sid":"test"}`, - fmt.Errorf(""), - }, - } { - answer, sid, err := DecodeAnswerRequest([]byte(test.data)) - So(answer, ShouldResemble, test.answer) - So(sid, ShouldResemble, test.sid) - So(err, ShouldHaveSameTypeAs, test.err) - } - - }) -} - -func TestEncodeProxyAnswerRequest(t *testing.T) { - Convey("Context", t, func() { - b, err := EncodeAnswerRequest("test answer", "test sid") - So(err, ShouldEqual, nil) - answer, sid, err := DecodeAnswerRequest(b) - So(answer, ShouldEqual, "test answer") - So(sid, ShouldEqual, "test sid") - So(err, ShouldEqual, nil) - }) -} - -func TestDecodeProxyAnswerResponse(t *testing.T) { - Convey("Context", t, func() { - for _, test := range []struct { - success bool - data string - err error - }{ - { - true, - `{"Status":"success"}`, - nil, - }, - { - false, - `{"Status":"client gone"}`, - nil, - }, - { - false, - `{"Test":"test"}`, - fmt.Errorf(""), - }, - } { - success, err := DecodeAnswerResponse([]byte(test.data)) - So(success, ShouldResemble, test.success) - So(err, ShouldHaveSameTypeAs, test.err) - } - - }) -} - -func TestEncodeProxyAnswerResponse(t *testing.T) { - Convey("Context", t, func() { - b, err := EncodeAnswerResponse(true) - So(err, ShouldEqual, nil) - success, err := DecodeAnswerResponse(b) - So(success, ShouldEqual, true) - So(err, ShouldEqual, nil) - - b, err = EncodeAnswerResponse(false) - So(err, ShouldEqual, nil) - success, err = DecodeAnswerResponse(b) - So(success, ShouldEqual, false) - So(err, ShouldEqual, nil) - }) -} - -func TestDecodeClientPollRequest(t *testing.T) { - Convey("Context", t, func() { - for _, test := range []struct { - natType string - offer string - data string - err error - }{ - { - //version 1.0 client message - "unknown", - "fake", - `1.0 -{"nat":"unknown","offer":"fake"}`, - nil, - }, - { - //version 1.0 client message - "unknown", - "fake", - `1.0 -{"offer":"fake"}`, - nil, - }, - { - //unknown version - "", - "", - `{"version":"2.0"}`, - fmt.Errorf(""), - }, - { - //no offer - "", - "", - `1.0 -{"nat":"unknown"}`, - fmt.Errorf(""), - }, - } { - req, err := DecodeClientPollRequest([]byte(test.data)) - So(err, ShouldHaveSameTypeAs, test.err) - if test.err == nil { - So(req.NAT, ShouldResemble, test.natType) - So(req.Offer, ShouldResemble, test.offer) - } - } - - }) -} - -func TestEncodeClientPollRequests(t *testing.T) { - Convey("Context", t, func() { - for i, test := range []struct { - natType string - offer string - fingerprint string - err error - }{ - { - "unknown", - "fake", - "", - nil, - }, - { - "unknown", - "fake", - defaultBridgeFingerprint, - nil, - }, - { - "unknown", - "fake", - "123123", - fmt.Errorf(""), - }, - } { - req1 := &ClientPollRequest{ - NAT: test.natType, - Offer: test.offer, - Fingerprint: test.fingerprint, - } - b, err := req1.EncodeClientPollRequest() - So(err, ShouldEqual, nil) - req2, err := DecodeClientPollRequest(b) - So(err, ShouldHaveSameTypeAs, test.err) - if test.err == nil { - So(req2.Offer, ShouldEqual, req1.Offer) - So(req2.NAT, ShouldEqual, req1.NAT) - fingerprint := test.fingerprint - if i == 0 { - fingerprint = defaultBridgeFingerprint - } - So(req2.Fingerprint, ShouldEqual, fingerprint) - } - } - }) -} - -func TestDecodeClientPollResponse(t *testing.T) { - Convey("Context", t, func() { - for _, test := range []struct { - answer string - msg string - data string - }{ - { - "fake answer", - "", - `{"answer":"fake answer"}`, - }, - { - "", - "no snowflakes", - `{"error":"no snowflakes"}`, - }, - } { - resp, err := DecodeClientPollResponse([]byte(test.data)) - So(err, ShouldBeNil) - So(resp.Answer, ShouldResemble, test.answer) - So(resp.Error, ShouldResemble, test.msg) - } - - }) -} - -func TestEncodeClientPollResponse(t *testing.T) { - Convey("Context", t, func() { - resp1 := &ClientPollResponse{ - Answer: "fake answer", - } - b, err := resp1.EncodePollResponse() - So(err, ShouldEqual, nil) - resp2, err := DecodeClientPollResponse(b) - So(err, ShouldEqual, nil) - So(resp1, ShouldResemble, resp2) - - resp1 = &ClientPollResponse{ - Error: "failed", - } - b, err = resp1.EncodePollResponse() - So(err, ShouldEqual, nil) - resp2, err = DecodeClientPollResponse(b) - So(err, ShouldEqual, nil) - So(resp1, ShouldResemble, resp2) - }) -} diff --git a/common/messages/proxy.go b/common/messages/proxy.go deleted file mode 100644 index b135c34..0000000 --- a/common/messages/proxy.go +++ /dev/null @@ -1,310 +0,0 @@ -//Package for communication with the snowflake broker - -//import "git.torproject.org/pluggable-transports/snowflake.git/v2/common/messages" -package messages - -import ( - "encoding/json" - "errors" - "fmt" - "strings" - - "git.torproject.org/pluggable-transports/snowflake.git/v2/common/nat" -) - -const ( - version = "1.3" - ProxyUnknown = "unknown" -) - -var KnownProxyTypes = map[string]bool{ - "standalone": true, - "webext": true, - "badge": true, - "iptproxy": true, -} - -/* Version 1.3 specification: - -== ProxyPollRequest == -{ - Sid: [generated session id of proxy], - Version: 1.3, - Type: ["badge"|"webext"|"standalone"], - NAT: ["unknown"|"restricted"|"unrestricted"], - Clients: [number of current clients, rounded down to multiples of 8], - AcceptedRelayPattern: [a pattern representing accepted set of relay domains] -} - -== ProxyPollResponse == -1) If a client is matched: -HTTP 200 OK -{ - Status: "client match", - { - type: offer, - sdp: [WebRTC SDP] - }, - NAT: ["unknown"|"restricted"|"unrestricted"], - RelayURL: [the WebSocket URL proxy should connect to relay Snowflake traffic] -} - -2) If a client is not matched: -HTTP 200 OK - -{ - Status: "no match" -} - -3) If the request is malformed: -HTTP 400 BadRequest - -== ProxyAnswerRequest == -{ - Sid: [generated session id of proxy], - Version: 1.3, - Answer: - { - type: answer, - sdp: [WebRTC SDP] - } -} - -== ProxyAnswerResponse == -1) If the client retrieved the answer: -HTTP 200 OK - -{ - Status: "success" -} - -2) If the client left: -HTTP 200 OK - -{ - Status: "client gone" -} - -3) If the request is malformed: -HTTP 400 BadRequest - -*/ - -type ProxyPollRequest struct { - Sid string - Version string - Type string - NAT string - Clients int - - AcceptedRelayPattern *string -} - -func EncodeProxyPollRequest(sid string, proxyType string, natType string, clients int) ([]byte, error) { - return EncodeProxyPollRequestWithRelayPrefix(sid, proxyType, natType, clients, "") -} - -func EncodeProxyPollRequestWithRelayPrefix(sid string, proxyType string, natType string, clients int, relayPattern string) ([]byte, error) { - return json.Marshal(ProxyPollRequest{ - Sid: sid, - Version: version, - Type: proxyType, - NAT: natType, - Clients: clients, - AcceptedRelayPattern: &relayPattern, - }) -} - -func DecodeProxyPollRequest(data []byte) (sid string, proxyType string, natType string, clients int, err error) { - var relayPrefix string - sid, proxyType, natType, clients, relayPrefix, _, err = DecodeProxyPollRequestWithRelayPrefix(data) - if relayPrefix != "" { - return "", "", "", 0, ErrExtraInfo - } - return -} - -// Decodes a poll message from a snowflake proxy and returns the -// sid, proxy type, nat type and clients of the proxy on success -// and an error if it failed -func DecodeProxyPollRequestWithRelayPrefix(data []byte) ( - sid string, proxyType string, natType string, clients int, relayPrefix string, relayPrefixAware bool, err error) { - var message ProxyPollRequest - - err = json.Unmarshal(data, &message) - if err != nil { - return - } - - majorVersion := strings.Split(message.Version, ".")[0] - if majorVersion != "1" { - err = fmt.Errorf("using unknown version") - return - } - - // Version 1.x requires an Sid - if message.Sid == "" { - err = fmt.Errorf("no supplied session id") - return - } - - switch message.NAT { - case "": - message.NAT = nat.NATUnknown - case nat.NATUnknown: - case nat.NATRestricted: - case nat.NATUnrestricted: - default: - err = fmt.Errorf("invalid NAT type") - return - } - - // we don't reject polls with an unknown proxy type because we encourage - // projects that embed proxy code to include their own type - if !KnownProxyTypes[message.Type] { - message.Type = ProxyUnknown - } - var acceptedRelayPattern = "" - if message.AcceptedRelayPattern != nil { - acceptedRelayPattern = *message.AcceptedRelayPattern - } - return message.Sid, message.Type, message.NAT, message.Clients, - acceptedRelayPattern, message.AcceptedRelayPattern != nil, nil -} - -type ProxyPollResponse struct { - Status string - Offer string - NAT string - - RelayURL string -} - -func EncodePollResponse(offer string, success bool, natType string) ([]byte, error) { - return EncodePollResponseWithRelayURL(offer, success, natType, "", "no match") -} - -func EncodePollResponseWithRelayURL(offer string, success bool, natType, relayURL, failReason string) ([]byte, error) { - if success { - return json.Marshal(ProxyPollResponse{ - Status: "client match", - Offer: offer, - NAT: natType, - RelayURL: relayURL, - }) - - } - return json.Marshal(ProxyPollResponse{ - Status: failReason, - }) -} -func DecodePollResponse(data []byte) (string, string, error) { - offer, natType, relayURL, err := DecodePollResponseWithRelayURL(data) - if relayURL != "" { - return "", "", ErrExtraInfo - } - return offer, natType, err -} - -// Decodes a poll response from the broker and returns an offer and the client's NAT type -// If there is a client match, the returned offer string will be non-empty -func DecodePollResponseWithRelayURL(data []byte) (string, string, string, error) { - var message ProxyPollResponse - - err := json.Unmarshal(data, &message) - if err != nil { - return "", "", "", err - } - if message.Status == "" { - return "", "", "", fmt.Errorf("received invalid data") - } - - err = nil - if message.Status == "client match" { - if message.Offer == "" { - return "", "", "", fmt.Errorf("no supplied offer") - } - } else { - message.Offer = "" - if message.Status != "no match" { - err = errors.New(message.Status) - } - } - - natType := message.NAT - if natType == "" { - natType = "unknown" - } - - return message.Offer, natType, message.RelayURL, err -} - -type ProxyAnswerRequest struct { - Version string - Sid string - Answer string -} - -func EncodeAnswerRequest(answer string, sid string) ([]byte, error) { - return json.Marshal(ProxyAnswerRequest{ - Version: version, - Sid: sid, - Answer: answer, - }) -} - -// Returns the sdp answer and proxy sid -func DecodeAnswerRequest(data []byte) (string, string, error) { - var message ProxyAnswerRequest - - err := json.Unmarshal(data, &message) - if err != nil { - return "", "", err - } - - majorVersion := strings.Split(message.Version, ".")[0] - if majorVersion != "1" { - return "", "", fmt.Errorf("using unknown version") - } - - if message.Sid == "" || message.Answer == "" { - return "", "", fmt.Errorf("no supplied sid or answer") - } - - return message.Answer, message.Sid, nil -} - -type ProxyAnswerResponse struct { - Status string -} - -func EncodeAnswerResponse(success bool) ([]byte, error) { - if success { - return json.Marshal(ProxyAnswerResponse{ - Status: "success", - }) - - } - return json.Marshal(ProxyAnswerResponse{ - Status: "client gone", - }) -} - -func DecodeAnswerResponse(data []byte) (bool, error) { - var message ProxyAnswerResponse - var success bool - - err := json.Unmarshal(data, &message) - if err != nil { - return success, err - } - if message.Status == "" { - return success, fmt.Errorf("received invalid data") - } - - if message.Status == "success" { - success = true - } - - return success, nil -} diff --git a/common/namematcher/matcher.go b/common/namematcher/matcher.go deleted file mode 100644 index afcdbff..0000000 --- a/common/namematcher/matcher.go +++ /dev/null @@ -1,31 +0,0 @@ -package namematcher - -import "strings" - -func NewNameMatcher(rule string) NameMatcher { - rule = strings.TrimSuffix(rule, "$") - return NameMatcher{suffix: strings.TrimPrefix(rule, "^"), exact: strings.HasPrefix(rule, "^")} -} - -func IsValidRule(rule string) bool { - return strings.HasSuffix(rule, "$") -} - -type NameMatcher struct { - exact bool - suffix string -} - -func (m *NameMatcher) IsSupersetOf(matcher NameMatcher) bool { - if m.exact { - return matcher.exact && m.suffix == matcher.suffix - } - return strings.HasSuffix(matcher.suffix, m.suffix) -} - -func (m *NameMatcher) IsMember(s string) bool { - if m.exact { - return s == m.suffix - } - return strings.HasSuffix(s, m.suffix) -} diff --git a/common/namematcher/matcher_test.go b/common/namematcher/matcher_test.go deleted file mode 100644 index 08d089c..0000000 --- a/common/namematcher/matcher_test.go +++ /dev/null @@ -1,55 +0,0 @@ -package namematcher - -import "testing" - -import . "github.com/smartystreets/goconvey/convey" - -func TestMatchMember(t *testing.T) { - testingVector := []struct { - matcher string - target string - expects bool - }{ - {matcher: "", target: "", expects: true}, - {matcher: "^snowflake.torproject.net$", target: "snowflake.torproject.net", expects: true}, - {matcher: "^snowflake.torproject.net$", target: "faketorproject.net", expects: false}, - {matcher: "snowflake.torproject.net$", target: "faketorproject.net", expects: false}, - {matcher: "snowflake.torproject.net$", target: "snowflake.torproject.net", expects: true}, - {matcher: "snowflake.torproject.net$", target: "imaginary-01-snowflake.torproject.net", expects: true}, - {matcher: "snowflake.torproject.net$", target: "imaginary-aaa-snowflake.torproject.net", expects: true}, - {matcher: "snowflake.torproject.net$", target: "imaginary-aaa-snowflake.faketorproject.net", expects: false}, - } - for _, v := range testingVector { - t.Run(v.matcher+"<>"+v.target, func(t *testing.T) { - Convey("test", t, func() { - matcher := NewNameMatcher(v.matcher) - So(matcher.IsMember(v.target), ShouldEqual, v.expects) - }) - }) - } -} - -func TestMatchSubset(t *testing.T) { - testingVector := []struct { - matcher string - target string - expects bool - }{ - {matcher: "", target: "", expects: true}, - {matcher: "^snowflake.torproject.net$", target: "^snowflake.torproject.net$", expects: true}, - {matcher: "snowflake.torproject.net$", target: "^snowflake.torproject.net$", expects: true}, - {matcher: "snowflake.torproject.net$", target: "snowflake.torproject.net$", expects: true}, - {matcher: "snowflake.torproject.net$", target: "testing-snowflake.torproject.net$", expects: true}, - {matcher: "snowflake.torproject.net$", target: "^testing-snowflake.torproject.net$", expects: true}, - {matcher: "snowflake.torproject.net$", target: "", expects: false}, - } - for _, v := range testingVector { - t.Run(v.matcher+"<>"+v.target, func(t *testing.T) { - Convey("test", t, func() { - matcher := NewNameMatcher(v.matcher) - target := NewNameMatcher(v.target) - So(matcher.IsSupersetOf(target), ShouldEqual, v.expects) - }) - }) - } -} diff --git a/common/nat/nat.go b/common/nat/nat.go deleted file mode 100644 index 81a82bc..0000000 --- a/common/nat/nat.go +++ /dev/null @@ -1,231 +0,0 @@ -/* -The majority of this code is taken from a utility I wrote for pion/stun -https://github.com/pion/stun/blob/master/cmd/stun-nat-behaviour/main.go - -Copyright 2018 Pion LLC - -Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -*/ - -package nat - -import ( - "errors" - "fmt" - "log" - "net" - "time" - - "github.com/pion/stun" -) - -var ErrTimedOut = errors.New("timed out waiting for response") - -const ( - NATUnknown = "unknown" - NATRestricted = "restricted" - NATUnrestricted = "unrestricted" -) - -// This function checks the NAT mapping and filtering -// behaviour and returns true if the NAT is restrictive -// (address-dependent mapping and/or port-dependent filtering) -// and false if the NAT is unrestrictive (meaning it -// will work with most other NATs), -func CheckIfRestrictedNAT(server string) (bool, error) { - return isRestrictedMapping(server) -} - -// Performs two tests from RFC 5780 to determine whether the mapping type -// of the client's NAT is address-independent or address-dependent -// Returns true if the mapping is address-dependent and false otherwise -func isRestrictedMapping(addrStr string) (bool, error) { - var xorAddr1 stun.XORMappedAddress - var xorAddr2 stun.XORMappedAddress - - mapTestConn, err := connect(addrStr) - if err != nil { - return false, fmt.Errorf("Error creating STUN connection: %w", err) - } - - defer mapTestConn.Close() - - // Test I: Regular binding request - message := stun.MustBuild(stun.TransactionID, stun.BindingRequest) - - resp, err := mapTestConn.RoundTrip(message, mapTestConn.PrimaryAddr) - if err != nil { - return false, fmt.Errorf("Error completing roundtrip map test: %w", err) - } - - // Decoding XOR-MAPPED-ADDRESS attribute from message. - if err = xorAddr1.GetFrom(resp); err != nil { - return false, fmt.Errorf("Error retrieving XOR-MAPPED-ADDRESS resonse: %w", err) - } - - // Decoding OTHER-ADDRESS attribute from message. - var otherAddr stun.OtherAddress - if err = otherAddr.GetFrom(resp); err != nil { - return false, fmt.Errorf("NAT discovery feature not supported: %w", err) - } - - if err = mapTestConn.AddOtherAddr(otherAddr.String()); err != nil { - return false, fmt.Errorf("Error resolving address %s: %w", otherAddr.String(), err) - } - - // Test II: Send binding request to other address - resp, err = mapTestConn.RoundTrip(message, mapTestConn.OtherAddr) - if err != nil { - return false, fmt.Errorf("Error retrieveing server response: %w", err) - } - - // Decoding XOR-MAPPED-ADDRESS attribute from message. - if err = xorAddr2.GetFrom(resp); err != nil { - return false, fmt.Errorf("Error retrieving XOR-MAPPED-ADDRESS resonse: %w", err) - } - - return xorAddr1.String() != xorAddr2.String(), nil - -} - -// Performs two tests from RFC 5780 to determine whether the filtering type -// of the client's NAT is port-dependent. -// Returns true if the filtering is port-dependent and false otherwise -// Note: This function is no longer used because a client's NAT type is -// determined only by their mapping type, but the functionality might -// be useful in the future and remains here. -func isRestrictedFiltering(addrStr string) (bool, error) { - var xorAddr stun.XORMappedAddress - - mapTestConn, err := connect(addrStr) - if err != nil { - log.Printf("Error creating STUN connection: %s", err.Error()) - return false, err - } - - defer mapTestConn.Close() - - // Test I: Regular binding request - message := stun.MustBuild(stun.TransactionID, stun.BindingRequest) - - resp, err := mapTestConn.RoundTrip(message, mapTestConn.PrimaryAddr) - if err == ErrTimedOut { - log.Printf("Error: no response from server") - return false, err - } - if err != nil { - log.Printf("Error: %s", err.Error()) - return false, err - } - - // Decoding XOR-MAPPED-ADDRESS attribute from message. - if err = xorAddr.GetFrom(resp); err != nil { - log.Printf("Error retrieving XOR-MAPPED-ADDRESS from resonse: %s", err.Error()) - return false, err - } - - // Test III: Request port change - message.Add(stun.AttrChangeRequest, []byte{0x00, 0x00, 0x00, 0x02}) - - _, err = mapTestConn.RoundTrip(message, mapTestConn.PrimaryAddr) - if err != ErrTimedOut && err != nil { - // something else went wrong - log.Printf("Error reading response from server: %s", err.Error()) - return false, err - } - - return err == ErrTimedOut, nil -} - -// Given an address string, returns a StunServerConn -func connect(addrStr string) (*StunServerConn, error) { - // Creating a "connection" to STUN server. - addr, err := net.ResolveUDPAddr("udp4", addrStr) - if err != nil { - log.Printf("Error resolving address: %s\n", err.Error()) - return nil, err - } - - c, err := net.ListenUDP("udp4", nil) - if err != nil { - return nil, err - } - - mChan := listen(c) - - return &StunServerConn{ - conn: c, - PrimaryAddr: addr, - messageChan: mChan, - }, nil -} - -type StunServerConn struct { - conn net.PacketConn - PrimaryAddr *net.UDPAddr - OtherAddr *net.UDPAddr - messageChan chan *stun.Message -} - -func (c *StunServerConn) Close() { - c.conn.Close() -} - -func (c *StunServerConn) RoundTrip(msg *stun.Message, addr net.Addr) (*stun.Message, error) { - _, err := c.conn.WriteTo(msg.Raw, addr) - if err != nil { - return nil, err - } - - // Wait for response or timeout - select { - case m, ok := <-c.messageChan: - if !ok { - return nil, fmt.Errorf("error reading from messageChan") - } - return m, nil - case <-time.After(10 * time.Second): - return nil, ErrTimedOut - } -} - -func (c *StunServerConn) AddOtherAddr(addrStr string) error { - addr2, err := net.ResolveUDPAddr("udp4", addrStr) - if err != nil { - return err - } - c.OtherAddr = addr2 - return nil -} - -// taken from https://github.com/pion/stun/blob/master/cmd/stun-traversal/main.go -func listen(conn *net.UDPConn) chan *stun.Message { - messages := make(chan *stun.Message) - go func() { - for { - buf := make([]byte, 1024) - - n, _, err := conn.ReadFromUDP(buf) - if err != nil { - close(messages) - return - } - buf = buf[:n] - - m := new(stun.Message) - m.Raw = buf - err = m.Decode() - if err != nil { - close(messages) - return - } - - messages <- m - } - }() - return messages -} diff --git a/common/safelog/log.go b/common/safelog/log.go index 6ca23ee..9148e53 100644 --- a/common/safelog/log.go +++ b/common/safelog/log.go @@ -1,6 +1,6 @@ //Package for a safer logging wrapper around the standard logging package -//import "git.torproject.org/pluggable-transports/snowflake.git/v2/common/safelog" +//import "git.torproject.org/pluggable-transports/snowflake.git/common/safelog" package safelog import ( @@ -38,7 +38,7 @@ type LogScrubber struct { func (ls *LogScrubber) Lock() { (*ls).lock.Lock() } func (ls *LogScrubber) Unlock() { (*ls).lock.Unlock() } -func Scrub(b []byte) []byte { +func scrub(b []byte) []byte { scrubbedBytes := b for _, pattern := range scrubberPatterns { // this is a workaround since go does not yet support look ahead or look @@ -62,7 +62,7 @@ func (ls *LogScrubber) Write(b []byte) (n int, err error) { return } fullLines := ls.buffer[:i+1] - _, err = ls.Output.Write(Scrub(fullLines)) + _, err = ls.Output.Write(scrub(fullLines)) if err != nil { return } diff --git a/common/task/periodic.go b/common/task/periodic.go deleted file mode 100644 index 7c1a235..0000000 --- a/common/task/periodic.go +++ /dev/null @@ -1,116 +0,0 @@ -// Package task -// Reused from https://github.com/v2fly/v2ray-core/blob/784775f68922f07d40c9eead63015b2026af2ade/common/task/periodic.go -/* -The MIT License (MIT) - -Copyright (c) 2015-2021 V2Ray & V2Fly Community - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. -*/ -package task - -import ( - "sync" - "time" -) - -// Periodic is a task that runs periodically. -type Periodic struct { - // Interval of the task being run - Interval time.Duration - // Execute is the task function - Execute func() error - - access sync.Mutex - timer *time.Timer - running bool -} - -func (t *Periodic) hasClosed() bool { - t.access.Lock() - defer t.access.Unlock() - - return !t.running -} - -func (t *Periodic) checkedExecute() error { - if t.hasClosed() { - return nil - } - - if err := t.Execute(); err != nil { - t.access.Lock() - t.running = false - t.access.Unlock() - return err - } - - t.access.Lock() - defer t.access.Unlock() - - if !t.running { - return nil - } - - t.timer = time.AfterFunc(t.Interval, func() { - t.checkedExecute() - }) - - return nil -} - -// Start implements common.Runnable. -func (t *Periodic) Start() error { - t.access.Lock() - if t.running { - t.access.Unlock() - return nil - } - t.running = true - t.access.Unlock() - - if err := t.checkedExecute(); err != nil { - t.access.Lock() - t.running = false - t.access.Unlock() - return err - } - - return nil -} - -func (t *Periodic) WaitThenStart() { - time.AfterFunc(t.Interval, func() { - t.Start() - }) -} - -// Close implements common.Closable. -func (t *Periodic) Close() error { - t.access.Lock() - defer t.access.Unlock() - - t.running = false - if t.timer != nil { - t.timer.Stop() - t.timer = nil - } - - return nil -} diff --git a/common/turbotunnel/clientid.go b/common/turbotunnel/clientid.go deleted file mode 100644 index 17257e1..0000000 --- a/common/turbotunnel/clientid.go +++ /dev/null @@ -1,28 +0,0 @@ -package turbotunnel - -import ( - "crypto/rand" - "encoding/hex" -) - -// ClientID is an abstract identifier that binds together all the communications -// belonging to a single client session, even though those communications may -// arrive from multiple IP addresses or over multiple lower-level connections. -// It plays the same role that an (IP address, port number) tuple plays in a -// net.UDPConn: it's the return address pertaining to a long-lived abstract -// client session. The client attaches its ClientID to each of its -// communications, enabling the server to disambiguate requests among its many -// clients. ClientID implements the net.Addr interface. -type ClientID [8]byte - -func NewClientID() ClientID { - var id ClientID - _, err := rand.Read(id[:]) - if err != nil { - panic(err) - } - return id -} - -func (id ClientID) Network() string { return "clientid" } -func (id ClientID) String() string { return hex.EncodeToString(id[:]) } diff --git a/common/turbotunnel/clientmap.go b/common/turbotunnel/clientmap.go deleted file mode 100644 index 53d0302..0000000 --- a/common/turbotunnel/clientmap.go +++ /dev/null @@ -1,145 +0,0 @@ -package turbotunnel - -import ( - "container/heap" - "net" - "sync" - "time" -) - -// clientRecord is a record of a recently seen client, with the time it was last -// seen and a send queue. -type clientRecord struct { - Addr net.Addr - LastSeen time.Time - SendQueue chan []byte -} - -// ClientMap manages a mapping of live clients (keyed by address, which will be -// a ClientID) to their respective send queues. ClientMap's functions are safe -// to call from multiple goroutines. -type ClientMap struct { - // We use an inner structure to avoid exposing public heap.Interface - // functions to users of clientMap. - inner clientMapInner - // Synchronizes access to inner. - lock sync.Mutex -} - -// NewClientMap creates a ClientMap that expires clients after a timeout. -// -// The timeout does not have to be kept in sync with QUIC's internal idle -// timeout. If a client is removed from the client map while the QUIC session is -// still live, the worst that can happen is a loss of whatever packets were in -// the send queue at the time. If QUIC later decides to send more packets to the -// same client, we'll instantiate a new send queue, and if the client ever -// connects again with the proper client ID, we'll deliver them. -func NewClientMap(timeout time.Duration) *ClientMap { - m := &ClientMap{ - inner: clientMapInner{ - byAge: make([]*clientRecord, 0), - byAddr: make(map[net.Addr]int), - }, - } - go func() { - for { - time.Sleep(timeout / 2) - now := time.Now() - m.lock.Lock() - m.inner.removeExpired(now, timeout) - m.lock.Unlock() - } - }() - return m -} - -// SendQueue returns the send queue corresponding to addr, creating it if -// necessary. -func (m *ClientMap) SendQueue(addr net.Addr) chan []byte { - m.lock.Lock() - defer m.lock.Unlock() - return m.inner.SendQueue(addr, time.Now()) -} - -// clientMapInner is the inner type of ClientMap, implementing heap.Interface. -// byAge is the backing store, a heap ordered by LastSeen time, to facilitate -// expiring old client records. byAddr is a map from addresses (i.e., ClientIDs) -// to heap indices, to allow looking up by address. Unlike ClientMap, -// clientMapInner requires external synchonization. -type clientMapInner struct { - byAge []*clientRecord - byAddr map[net.Addr]int -} - -// removeExpired removes all client records whose LastSeen timestamp is more -// than timeout in the past. -func (inner *clientMapInner) removeExpired(now time.Time, timeout time.Duration) { - for len(inner.byAge) > 0 && now.Sub(inner.byAge[0].LastSeen) >= timeout { - heap.Pop(inner) - } -} - -// SendQueue finds the existing client record corresponding to addr, or creates -// a new one if none exists yet. It updates the client record's LastSeen time -// and returns its SendQueue. -func (inner *clientMapInner) SendQueue(addr net.Addr, now time.Time) chan []byte { - var record *clientRecord - i, ok := inner.byAddr[addr] - if ok { - // Found one, update its LastSeen. - record = inner.byAge[i] - record.LastSeen = now - heap.Fix(inner, i) - } else { - // Not found, create a new one. - record = &clientRecord{ - Addr: addr, - LastSeen: now, - SendQueue: make(chan []byte, queueSize), - } - heap.Push(inner, record) - } - return record.SendQueue -} - -// heap.Interface for clientMapInner. - -func (inner *clientMapInner) Len() int { - if len(inner.byAge) != len(inner.byAddr) { - panic("inconsistent clientMap") - } - return len(inner.byAge) -} - -func (inner *clientMapInner) Less(i, j int) bool { - return inner.byAge[i].LastSeen.Before(inner.byAge[j].LastSeen) -} - -func (inner *clientMapInner) Swap(i, j int) { - inner.byAge[i], inner.byAge[j] = inner.byAge[j], inner.byAge[i] - inner.byAddr[inner.byAge[i].Addr] = i - inner.byAddr[inner.byAge[j].Addr] = j -} - -func (inner *clientMapInner) Push(x interface{}) { - record := x.(*clientRecord) - if _, ok := inner.byAddr[record.Addr]; ok { - panic("duplicate address in clientMap") - } - // Insert into byAddr map. - inner.byAddr[record.Addr] = len(inner.byAge) - // Insert into byAge slice. - inner.byAge = append(inner.byAge, record) -} - -func (inner *clientMapInner) Pop() interface{} { - n := len(inner.byAddr) - // Remove from byAge slice. - record := inner.byAge[n-1] - inner.byAge[n-1] = nil - inner.byAge = inner.byAge[:n-1] - // Remove from byAddr map. - delete(inner.byAddr, record.Addr) - close(record.SendQueue) - return record -} diff --git a/common/turbotunnel/consts.go b/common/turbotunnel/consts.go deleted file mode 100644 index 34c474f..0000000 --- a/common/turbotunnel/consts.go +++ /dev/null @@ -1,17 +0,0 @@ -// Package turbotunnel provides support for overlaying a virtual net.PacketConn -// on some other network carrier. -// -// https://github.com/net4people/bbs/issues/9 -package turbotunnel - -import "errors" - -// This magic prefix is how a client opts into turbo tunnel mode. It is just a -// randomly generated byte string. -var Token = [8]byte{0x12, 0x93, 0x60, 0x5d, 0x27, 0x81, 0x75, 0xf5} - -// The size of receive and send queues. -const queueSize = 2048 - -var errClosedPacketConn = errors.New("operation on closed connection") -var errNotImplemented = errors.New("not implemented") diff --git a/common/turbotunnel/queuepacketconn.go b/common/turbotunnel/queuepacketconn.go deleted file mode 100644 index 14a9833..0000000 --- a/common/turbotunnel/queuepacketconn.go +++ /dev/null @@ -1,137 +0,0 @@ -package turbotunnel - -import ( - "net" - "sync" - "sync/atomic" - "time" -) - -// taggedPacket is a combination of a []byte and a net.Addr, encapsulating the -// return type of PacketConn.ReadFrom. -type taggedPacket struct { - P []byte - Addr net.Addr -} - -// QueuePacketConn implements net.PacketConn by storing queues of packets. There -// is one incoming queue (where packets are additionally tagged by the source -// address of the client that sent them). There are many outgoing queues, one -// for each client address that has been recently seen. The QueueIncoming method -// inserts a packet into the incoming queue, to eventually be returned by -// ReadFrom. WriteTo inserts a packet into an address-specific outgoing queue, -// which can later by accessed through the OutgoingQueue method. -type QueuePacketConn struct { - clients *ClientMap - localAddr net.Addr - recvQueue chan taggedPacket - closeOnce sync.Once - closed chan struct{} - // What error to return when the QueuePacketConn is closed. - err atomic.Value -} - -// NewQueuePacketConn makes a new QueuePacketConn, set to track recent clients -// for at least a duration of timeout. -func NewQueuePacketConn(localAddr net.Addr, timeout time.Duration) *QueuePacketConn { - return &QueuePacketConn{ - clients: NewClientMap(timeout), - localAddr: localAddr, - recvQueue: make(chan taggedPacket, queueSize), - closed: make(chan struct{}), - } -} - -// QueueIncoming queues and incoming packet and its source address, to be -// returned in a future call to ReadFrom. -func (c *QueuePacketConn) QueueIncoming(p []byte, addr net.Addr) { - select { - case <-c.closed: - // If we're closed, silently drop it. - return - default: - } - // Copy the slice so that the caller may reuse it. - buf := make([]byte, len(p)) - copy(buf, p) - select { - case c.recvQueue <- taggedPacket{buf, addr}: - default: - // Drop the incoming packet if the receive queue is full. - } -} - -// OutgoingQueue returns the queue of outgoing packets corresponding to addr, -// creating it if necessary. The contents of the queue will be packets that are -// written to the address in question using WriteTo. -func (c *QueuePacketConn) OutgoingQueue(addr net.Addr) <-chan []byte { - return c.clients.SendQueue(addr) -} - -// ReadFrom returns a packet and address previously stored by QueueIncoming. -func (c *QueuePacketConn) ReadFrom(p []byte) (int, net.Addr, error) { - select { - case <-c.closed: - return 0, nil, &net.OpError{Op: "read", Net: c.LocalAddr().Network(), Addr: c.LocalAddr(), Err: c.err.Load().(error)} - default: - } - select { - case <-c.closed: - return 0, nil, &net.OpError{Op: "read", Net: c.LocalAddr().Network(), Addr: c.LocalAddr(), Err: c.err.Load().(error)} - case packet := <-c.recvQueue: - return copy(p, packet.P), packet.Addr, nil - } -} - -// WriteTo queues an outgoing packet for the given address. The queue can later -// be retrieved using the OutgoingQueue method. -func (c *QueuePacketConn) WriteTo(p []byte, addr net.Addr) (int, error) { - select { - case <-c.closed: - return 0, &net.OpError{Op: "write", Net: c.LocalAddr().Network(), Addr: c.LocalAddr(), Err: c.err.Load().(error)} - default: - } - // Copy the slice so that the caller may reuse it. - buf := make([]byte, len(p)) - copy(buf, p) - select { - case c.clients.SendQueue(addr) <- buf: - return len(buf), nil - default: - // Drop the outgoing packet if the send queue is full. - return len(buf), nil - } -} - -// closeWithError unblocks pending operations and makes future operations fail -// with the given error. If err is nil, it becomes errClosedPacketConn. -func (c *QueuePacketConn) closeWithError(err error) error { - var newlyClosed bool - c.closeOnce.Do(func() { - newlyClosed = true - // Store the error to be returned by future PacketConn - // operations. - if err == nil { - err = errClosedPacketConn - } - c.err.Store(err) - close(c.closed) - }) - if !newlyClosed { - return &net.OpError{Op: "close", Net: c.LocalAddr().Network(), Addr: c.LocalAddr(), Err: c.err.Load().(error)} - } - return nil -} - -// Close unblocks pending operations and makes future operations fail with a -// "closed connection" error. -func (c *QueuePacketConn) Close() error { - return c.closeWithError(nil) -} - -// LocalAddr returns the localAddr value that was passed to NewQueuePacketConn. -func (c *QueuePacketConn) LocalAddr() net.Addr { return c.localAddr } - -func (c *QueuePacketConn) SetDeadline(t time.Time) error { return errNotImplemented } -func (c *QueuePacketConn) SetReadDeadline(t time.Time) error { return errNotImplemented } -func (c *QueuePacketConn) SetWriteDeadline(t time.Time) error { return errNotImplemented } diff --git a/common/turbotunnel/redialpacketconn.go b/common/turbotunnel/redialpacketconn.go deleted file mode 100644 index cf6a8c9..0000000 --- a/common/turbotunnel/redialpacketconn.go +++ /dev/null @@ -1,204 +0,0 @@ -package turbotunnel - -import ( - "context" - "errors" - "net" - "sync" - "sync/atomic" - "time" -) - -// RedialPacketConn implements a long-lived net.PacketConn atop a sequence of -// other, transient net.PacketConns. RedialPacketConn creates a new -// net.PacketConn by calling a provided dialContext function. Whenever the -// net.PacketConn experiences a ReadFrom or WriteTo error, RedialPacketConn -// calls the dialContext function again and starts sending and receiving packets -// on the new net.PacketConn. RedialPacketConn's own ReadFrom and WriteTo -// methods return an error only when the dialContext function returns an error. -// -// RedialPacketConn uses static local and remote addresses that are independent -// of those of any dialed net.PacketConn. -type RedialPacketConn struct { - localAddr net.Addr - remoteAddr net.Addr - dialContext func(context.Context) (net.PacketConn, error) - recvQueue chan []byte - sendQueue chan []byte - closed chan struct{} - closeOnce sync.Once - // The first dial error, which causes the clientPacketConn to be - // closed and is returned from future read/write operations. Compare to - // the rerr and werr in io.Pipe. - err atomic.Value -} - -// NewQueuePacketConn makes a new RedialPacketConn, with the given static local -// and remote addresses, and dialContext function. -func NewRedialPacketConn( - localAddr, remoteAddr net.Addr, - dialContext func(context.Context) (net.PacketConn, error), -) *RedialPacketConn { - c := &RedialPacketConn{ - localAddr: localAddr, - remoteAddr: remoteAddr, - dialContext: dialContext, - recvQueue: make(chan []byte, queueSize), - sendQueue: make(chan []byte, queueSize), - closed: make(chan struct{}), - err: atomic.Value{}, - } - go c.dialLoop() - return c -} - -// dialLoop repeatedly calls c.dialContext and passes the resulting -// net.PacketConn to c.exchange. It returns only when c is closed or dialContext -// returns an error. -func (c *RedialPacketConn) dialLoop() { - ctx, cancel := context.WithCancel(context.Background()) - for { - select { - case <-c.closed: - cancel() - return - default: - } - conn, err := c.dialContext(ctx) - if err != nil { - c.closeWithError(err) - cancel() - return - } - c.exchange(conn) - conn.Close() - } -} - -// exchange calls ReadFrom on the given net.PacketConn and places the resulting -// packets in the receive queue, and takes packets from the send queue and calls -// WriteTo on them, making the current net.PacketConn active. -func (c *RedialPacketConn) exchange(conn net.PacketConn) { - readErrCh := make(chan error) - writeErrCh := make(chan error) - - go func() { - defer close(readErrCh) - for { - select { - case <-c.closed: - return - case <-writeErrCh: - return - default: - } - - var buf [1500]byte - n, _, err := conn.ReadFrom(buf[:]) - if err != nil { - readErrCh <- err - return - } - p := make([]byte, n) - copy(p, buf[:]) - select { - case c.recvQueue <- p: - default: // OK to drop packets. - } - } - }() - - go func() { - defer close(writeErrCh) - for { - select { - case <-c.closed: - return - case <-readErrCh: - return - case p := <-c.sendQueue: - _, err := conn.WriteTo(p, c.remoteAddr) - if err != nil { - writeErrCh <- err - return - } - } - } - }() - - select { - case <-readErrCh: - case <-writeErrCh: - } -} - -// ReadFrom reads a packet from the currently active net.PacketConn. The -// packet's original remote address is replaced with the RedialPacketConn's own -// remote address. -func (c *RedialPacketConn) ReadFrom(p []byte) (int, net.Addr, error) { - select { - case <-c.closed: - return 0, nil, &net.OpError{Op: "read", Net: c.LocalAddr().Network(), Source: c.LocalAddr(), Addr: c.remoteAddr, Err: c.err.Load().(error)} - default: - } - select { - case <-c.closed: - return 0, nil, &net.OpError{Op: "read", Net: c.LocalAddr().Network(), Source: c.LocalAddr(), Addr: c.remoteAddr, Err: c.err.Load().(error)} - case buf := <-c.recvQueue: - return copy(p, buf), c.remoteAddr, nil - } -} - -// WriteTo writes a packet to the currently active net.PacketConn. The addr -// argument is ignored and instead replaced with the RedialPacketConn's own -// remote address. -func (c *RedialPacketConn) WriteTo(p []byte, addr net.Addr) (int, error) { - // addr is ignored. - select { - case <-c.closed: - return 0, &net.OpError{Op: "write", Net: c.LocalAddr().Network(), Source: c.LocalAddr(), Addr: c.remoteAddr, Err: c.err.Load().(error)} - default: - } - buf := make([]byte, len(p)) - copy(buf, p) - select { - case c.sendQueue <- buf: - return len(buf), nil - default: - // Drop the outgoing packet if the send queue is full. - return len(buf), nil - } -} - -// closeWithError unblocks pending operations and makes future operations fail -// with the given error. If err is nil, it becomes errClosedPacketConn. -func (c *RedialPacketConn) closeWithError(err error) error { - var once bool - c.closeOnce.Do(func() { - // Store the error to be returned by future read/write - // operations. - if err == nil { - err = errors.New("operation on closed connection") - } - c.err.Store(err) - close(c.closed) - once = true - }) - if !once { - return &net.OpError{Op: "close", Net: c.LocalAddr().Network(), Addr: c.LocalAddr(), Err: c.err.Load().(error)} - } - return nil -} - -// Close unblocks pending operations and makes future operations fail with a -// "closed connection" error. -func (c *RedialPacketConn) Close() error { - return c.closeWithError(nil) -} - -// LocalAddr returns the localAddr value that was passed to NewRedialPacketConn. -func (c *RedialPacketConn) LocalAddr() net.Addr { return c.localAddr } - -func (c *RedialPacketConn) SetDeadline(t time.Time) error { return errNotImplemented } -func (c *RedialPacketConn) SetReadDeadline(t time.Time) error { return errNotImplemented } -func (c *RedialPacketConn) SetWriteDeadline(t time.Time) error { return errNotImplemented } diff --git a/common/util/util.go b/common/util/util.go deleted file mode 100644 index 00f7302..0000000 --- a/common/util/util.go +++ /dev/null @@ -1,99 +0,0 @@ -package util - -import ( - "encoding/json" - "errors" - "net" - - "github.com/pion/ice/v2" - "github.com/pion/sdp/v3" - "github.com/pion/webrtc/v3" -) - -func SerializeSessionDescription(desc *webrtc.SessionDescription) (string, error) { - bytes, err := json.Marshal(*desc) - if err != nil { - return "", err - } - return string(bytes), nil -} - -func DeserializeSessionDescription(msg string) (*webrtc.SessionDescription, error) { - var parsed map[string]interface{} - err := json.Unmarshal([]byte(msg), &parsed) - if err != nil { - return nil, err - } - if _, ok := parsed["type"]; !ok { - return nil, errors.New("cannot deserialize SessionDescription without type field") - } - if _, ok := parsed["sdp"]; !ok { - return nil, errors.New("cannot deserialize SessionDescription without sdp field") - } - - var stype webrtc.SDPType - switch parsed["type"].(string) { - default: - return nil, errors.New("Unknown SDP type") - case "offer": - stype = webrtc.SDPTypeOffer - case "pranswer": - stype = webrtc.SDPTypePranswer - case "answer": - stype = webrtc.SDPTypeAnswer - case "rollback": - stype = webrtc.SDPTypeRollback - } - - return &webrtc.SessionDescription{ - Type: stype, - SDP: parsed["sdp"].(string), - }, nil -} - -// Stolen from https://github.com/golang/go/pull/30278 -func IsLocal(ip net.IP) bool { - if ip4 := ip.To4(); ip4 != nil { - // Local IPv4 addresses are defined in https://tools.ietf.org/html/rfc1918 - return ip4[0] == 10 || - (ip4[0] == 172 && ip4[1]&0xf0 == 16) || - (ip4[0] == 192 && ip4[1] == 168) || - // Carrier-Grade NAT as per https://tools.ietf.org/htm/rfc6598 - (ip4[0] == 100 && ip4[1]&0xc0 == 64) || - // Dynamic Configuration as per https://tools.ietf.org/htm/rfc3927 - (ip4[0] == 169 && ip4[1] == 254) - } - // Local IPv6 addresses are defined in https://tools.ietf.org/html/rfc4193 - return len(ip) == net.IPv6len && ip[0]&0xfe == 0xfc -} - -// Removes local LAN address ICE candidates -func StripLocalAddresses(str string) string { - var desc sdp.SessionDescription - err := desc.Unmarshal([]byte(str)) - if err != nil { - return str - } - for _, m := range desc.MediaDescriptions { - attrs := make([]sdp.Attribute, 0) - for _, a := range m.Attributes { - if a.IsICECandidate() { - c, err := ice.UnmarshalCandidate(a.Value) - if err == nil && c.Type() == ice.CandidateTypeHost { - ip := net.ParseIP(c.Address()) - if ip != nil && (IsLocal(ip) || ip.IsUnspecified() || ip.IsLoopback()) { - /* no append in this case */ - continue - } - } - } - attrs = append(attrs, a) - } - m.Attributes = attrs - } - bts, err := desc.Marshal() - if err != nil { - return str - } - return string(bts) -} diff --git a/common/util/util_test.go b/common/util/util_test.go deleted file mode 100644 index 9d52f62..0000000 --- a/common/util/util_test.go +++ /dev/null @@ -1,28 +0,0 @@ -package util - -import ( - "testing" - - . "github.com/smartystreets/goconvey/convey" -) - -func TestUtil(t *testing.T) { - Convey("Strip", t, func() { - const offerStart = "v=0\r\no=- 4358805017720277108 2 IN IP4 8.8.8.8\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 8.8.8.8\r\n" - const goodCandidate = "a=candidate:3769337065 1 udp 2122260223 8.8.8.8 56688 typ host generation 0 network-id 1 network-cost 50\r\n" - const offerEnd = "a=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" - - offer := offerStart + goodCandidate + - "a=candidate:3769337065 1 udp 2122260223 192.168.0.100 56688 typ host generation 0 network-id 1 network-cost 50\r\n" + // IsLocal IPv4 - "a=candidate:3769337065 1 udp 2122260223 100.127.50.5 56688 typ host generation 0 network-id 1 network-cost 50\r\n" + // IsLocal IPv4 - "a=candidate:3769337065 1 udp 2122260223 169.254.250.88 56688 typ host generation 0 network-id 1 network-cost 50\r\n" + // IsLocal IPv4 - "a=candidate:3769337065 1 udp 2122260223 fdf8:f53b:82e4::53 56688 typ host generation 0 network-id 1 network-cost 50\r\n" + // IsLocal IPv6 - "a=candidate:3769337065 1 udp 2122260223 0.0.0.0 56688 typ host generation 0 network-id 1 network-cost 50\r\n" + // IsUnspecified IPv4 - "a=candidate:3769337065 1 udp 2122260223 :: 56688 typ host generation 0 network-id 1 network-cost 50\r\n" + // IsUnspecified IPv6 - "a=candidate:3769337065 1 udp 2122260223 127.0.0.1 56688 typ host generation 0 network-id 1 network-cost 50\r\n" + // IsLoopback IPv4 - "a=candidate:3769337065 1 udp 2122260223 ::1 56688 typ host generation 0 network-id 1 network-cost 50\r\n" + // IsLoopback IPv6 - offerEnd - - So(StripLocalAddresses(offer), ShouldEqual, offerStart+goodCandidate+offerEnd) - }) -} diff --git a/common/utls/client_hello_id.go b/common/utls/client_hello_id.go deleted file mode 100644 index 8a13280..0000000 --- a/common/utls/client_hello_id.go +++ /dev/null @@ -1,38 +0,0 @@ -package utls - -import ( - "errors" - utls "github.com/refraction-networking/utls" - "strings" -) - -// ported from https://github.com/max-b/snowflake/commit/9dded063cb74c6941a16ad90b9dd0e06e618e55e -var clientHelloIDMap = map[string]utls.ClientHelloID{ - // No HelloCustom: not useful for external configuration. - // No HelloRandomized: doesn't negotiate consistent ALPN. - "hellorandomizedalpn": utls.HelloRandomizedALPN, - "hellorandomizednoalpn": utls.HelloRandomizedNoALPN, - "hellofirefox_auto": utls.HelloFirefox_Auto, - "hellofirefox_55": utls.HelloFirefox_55, - "hellofirefox_56": utls.HelloFirefox_56, - "hellofirefox_63": utls.HelloFirefox_63, - "hellofirefox_65": utls.HelloFirefox_65, - "hellochrome_auto": utls.HelloChrome_Auto, - "hellochrome_58": utls.HelloChrome_58, - "hellochrome_62": utls.HelloChrome_62, - "hellochrome_70": utls.HelloChrome_70, - "hellochrome_72": utls.HelloChrome_72, - "helloios_auto": utls.HelloIOS_Auto, - "helloios_11_1": utls.HelloIOS_11_1, - "helloios_12_1": utls.HelloIOS_12_1, -} - -var errNameNotFound = errors.New("client hello name is unrecognized") - -func NameToUTLSID(name string) (utls.ClientHelloID, error) { - normalizedName := strings.ToLower(name) - if id, ok := clientHelloIDMap[normalizedName]; ok { - return id, nil - } - return utls.ClientHelloID{}, errNameNotFound -} diff --git a/common/utls/roundtripper.go b/common/utls/roundtripper.go deleted file mode 100644 index 53b997f..0000000 --- a/common/utls/roundtripper.go +++ /dev/null @@ -1,238 +0,0 @@ -package utls - -import ( - "context" - "crypto/tls" - "errors" - "net" - "net/http" - "sync" - "time" - - utls "github.com/refraction-networking/utls" - "golang.org/x/net/http2" -) - -// NewUTLSHTTPRoundTripper creates an instance of RoundTripper that dial to remote HTTPS endpoint with -// an alternative version of TLS implementation that attempts to imitate browsers' fingerprint. -// clientHelloID is the clientHello that uTLS attempts to imitate -// uTlsConfig is the TLS Configuration template -// backdropTransport is the transport that will be used for non-https traffic -// removeSNI indicates not to send Server Name Indication Extension -// returns a RoundTripper: its behaviour is documented at -// https://gitlab.torproject.org/tpo/anti-censorship/pluggable-transports/snowflake/-/merge_requests/76#note_2777161 -func NewUTLSHTTPRoundTripper(clientHelloID utls.ClientHelloID, uTlsConfig *utls.Config, - backdropTransport http.RoundTripper, removeSNI bool) http.RoundTripper { - rtImpl := &uTLSHTTPRoundTripperImpl{ - clientHelloID: clientHelloID, - config: uTlsConfig, - connectWithH1: map[string]bool{}, - backdropTransport: backdropTransport, - pendingConn: map[pendingConnKey]*unclaimedConnection{}, - removeSNI: removeSNI, - } - rtImpl.init() - return rtImpl -} - -type uTLSHTTPRoundTripperImpl struct { - clientHelloID utls.ClientHelloID - config *utls.Config - - accessConnectWithH1 sync.Mutex - connectWithH1 map[string]bool - - httpsH1Transport http.RoundTripper - httpsH2Transport http.RoundTripper - backdropTransport http.RoundTripper - - accessDialingConnection sync.Mutex - pendingConn map[pendingConnKey]*unclaimedConnection - - removeSNI bool -} - -type pendingConnKey struct { - isH2 bool - dest string -} - -var errEAGAIN = errors.New("incorrect ALPN negotiated, try again with another ALPN") -var errEAGAINTooMany = errors.New("incorrect ALPN negotiated") -var errExpired = errors.New("connection have expired") - -func (r *uTLSHTTPRoundTripperImpl) RoundTrip(req *http.Request) (*http.Response, error) { - if req.URL.Scheme != "https" { - return r.backdropTransport.RoundTrip(req) - } - for retryCount := 0; retryCount < 5; retryCount++ { - if r.getShouldConnectWithH1(req.URL.Host) { - resp, err := r.httpsH1Transport.RoundTrip(req) - if errors.Is(err, errEAGAIN) { - continue - } - return resp, err - } - resp, err := r.httpsH2Transport.RoundTrip(req) - if errors.Is(err, errEAGAIN) { - continue - } - return resp, err - } - return nil, errEAGAINTooMany -} - -func (r *uTLSHTTPRoundTripperImpl) getShouldConnectWithH1(domainName string) bool { - r.accessConnectWithH1.Lock() - defer r.accessConnectWithH1.Unlock() - if value, set := r.connectWithH1[domainName]; set { - return value - } - return false -} - -func (r *uTLSHTTPRoundTripperImpl) setShouldConnectWithH1(domainName string) { - r.accessConnectWithH1.Lock() - defer r.accessConnectWithH1.Unlock() - r.connectWithH1[domainName] = true -} - -func (r *uTLSHTTPRoundTripperImpl) clearShouldConnectWithH1(domainName string) { - r.accessConnectWithH1.Lock() - defer r.accessConnectWithH1.Unlock() - r.connectWithH1[domainName] = false -} - -func getPendingConnectionID(dest string, alpnIsH2 bool) pendingConnKey { - return pendingConnKey{isH2: alpnIsH2, dest: dest} -} - -func (r *uTLSHTTPRoundTripperImpl) putConn(addr string, alpnIsH2 bool, conn net.Conn) { - connId := getPendingConnectionID(addr, alpnIsH2) - r.pendingConn[connId] = NewUnclaimedConnection(conn, time.Minute) -} -func (r *uTLSHTTPRoundTripperImpl) getConn(addr string, alpnIsH2 bool) net.Conn { - connId := getPendingConnectionID(addr, alpnIsH2) - if conn, ok := r.pendingConn[connId]; ok { - delete(r.pendingConn, connId) - if claimedConnection, err := conn.claimConnection(); err == nil { - return claimedConnection - } - } - return nil -} -func (r *uTLSHTTPRoundTripperImpl) dialOrGetTLSWithExpectedALPN(ctx context.Context, addr string, expectedH2 bool) (net.Conn, error) { - r.accessDialingConnection.Lock() - defer r.accessDialingConnection.Unlock() - - if r.getShouldConnectWithH1(addr) == expectedH2 { - return nil, errEAGAIN - } - - //Get a cached connection if possible to reduce preflight connection closed without sending data - if gconn := r.getConn(addr, expectedH2); gconn != nil { - return gconn, nil - } - - conn, err := r.dialTLS(ctx, addr) - if err != nil { - return nil, err - } - - protocol := conn.ConnectionState().NegotiatedProtocol - - protocolIsH2 := protocol == http2.NextProtoTLS - - if protocolIsH2 == expectedH2 { - return conn, err - } - - r.putConn(addr, protocolIsH2, conn) - - if protocolIsH2 { - r.clearShouldConnectWithH1(addr) - } else { - r.setShouldConnectWithH1(addr) - } - - return nil, errEAGAIN -} - -// based on https://repo.or.cz/dnstt.git/commitdiff/d92a791b6864901f9263f7d73d97cfd30ac53b09..98bdffa1706dfc041d1e99b86c47f29d72ad3a0c -// by dcf1 -func (r *uTLSHTTPRoundTripperImpl) dialTLS(ctx context.Context, addr string) (*utls.UConn, error) { - config := r.config.Clone() - - host, _, err := net.SplitHostPort(addr) - if err != nil { - return nil, err - } - config.ServerName = host - - dialer := &net.Dialer{} - conn, err := dialer.DialContext(ctx, "tcp", addr) - if err != nil { - return nil, err - } - uconn := utls.UClient(conn, config, r.clientHelloID) - if (net.ParseIP(config.ServerName) != nil) || r.removeSNI { - err := uconn.RemoveSNIExtension() - if err != nil { - uconn.Close() - return nil, err - } - } - - err = uconn.Handshake() - if err != nil { - return nil, err - } - return uconn, nil -} - -func (r *uTLSHTTPRoundTripperImpl) init() { - r.httpsH2Transport = &http2.Transport{ - DialTLS: func(network, addr string, cfg *tls.Config) (net.Conn, error) { - return r.dialOrGetTLSWithExpectedALPN(context.Background(), addr, true) - }, - } - r.httpsH1Transport = &http.Transport{ - DialTLSContext: func(ctx context.Context, network string, addr string) (net.Conn, error) { - return r.dialOrGetTLSWithExpectedALPN(ctx, addr, false) - }, - } -} - -func NewUnclaimedConnection(conn net.Conn, expireTime time.Duration) *unclaimedConnection { - c := &unclaimedConnection{ - Conn: conn, - } - time.AfterFunc(expireTime, c.tick) - return c -} - -type unclaimedConnection struct { - net.Conn - claimed bool - access sync.Mutex -} - -func (c *unclaimedConnection) claimConnection() (net.Conn, error) { - c.access.Lock() - defer c.access.Unlock() - if !c.claimed { - c.claimed = true - return c.Conn, nil - } - return nil, errExpired -} - -func (c *unclaimedConnection) tick() { - c.access.Lock() - defer c.access.Unlock() - if !c.claimed { - c.claimed = true - c.Conn.Close() - c.Conn = nil - } -} diff --git a/common/utls/roundtripper_test.go b/common/utls/roundtripper_test.go deleted file mode 100644 index bccb799..0000000 --- a/common/utls/roundtripper_test.go +++ /dev/null @@ -1,164 +0,0 @@ -package utls - -import ( - "crypto/rsa" - "crypto/tls" - "crypto/x509" - "crypto/x509/pkix" - "math/big" - "math/rand" - "net/http" - "testing" - "time" - - stdcontext "context" - - utls "github.com/refraction-networking/utls" - "golang.org/x/net/http2" - - . "github.com/smartystreets/goconvey/convey" -) - -func TestRoundTripper(t *testing.T) { - var selfSignedCert []byte - var selfSignedPrivateKey *rsa.PrivateKey - httpServerContext, cancel := stdcontext.WithCancel(stdcontext.Background()) - Convey("[Test]Set up http servers", t, func(c C) { - c.Convey("[Test]Generate Self-Signed Cert", func(c C) { - // Ported from https://gist.github.com/samuel/8b500ddd3f6118d052b5e6bc16bc4c09 - - // note that we use the insecure math/rand here because some platforms - // fail the test suite at build time in Debian, due to entropy starvation. - // since that's not a problem at test time, we do *not* use a secure - // mechanism for key generation. - // - // DO NOT REUSE THIS CODE IN PRODUCTION, IT IS DANGEROUS - insecureRandReader := rand.New(rand.NewSource(1337)) - priv, err := rsa.GenerateKey(insecureRandReader, 4096) - c.So(err, ShouldBeNil) - template := x509.Certificate{ - SerialNumber: big.NewInt(1), - Subject: pkix.Name{ - CommonName: "Testing Certificate", - }, - NotBefore: time.Now(), - NotAfter: time.Now().Add(time.Hour * 24 * 180), - - KeyUsage: x509.KeyUsageKeyEncipherment | x509.KeyUsageDigitalSignature, - ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, - BasicConstraintsValid: true, - } - derBytes, err := x509.CreateCertificate(insecureRandReader, &template, &template, priv.Public(), priv) - c.So(err, ShouldBeNil) - selfSignedPrivateKey = priv - selfSignedCert = derBytes - }) - c.Convey("[Test]Setup http2 server", func(c C) { - listener, err := tls.Listen("tcp", "127.0.0.1:23802", &tls.Config{ - NextProtos: []string{http2.NextProtoTLS}, - Certificates: []tls.Certificate{ - tls.Certificate{Certificate: [][]byte{selfSignedCert}, PrivateKey: selfSignedPrivateKey}, - }, - }) - c.So(err, ShouldBeNil) - s := http.Server{} - go s.Serve(listener) - go func() { - <-httpServerContext.Done() - s.Close() - }() - }) - c.Convey("[Test]Setup http1 server", func(c C) { - listener, err := tls.Listen("tcp", "127.0.0.1:23801", &tls.Config{ - NextProtos: []string{"http/1.1"}, - Certificates: []tls.Certificate{ - tls.Certificate{Certificate: [][]byte{selfSignedCert}, PrivateKey: selfSignedPrivateKey}, - }, - }) - c.So(err, ShouldBeNil) - s := http.Server{} - go s.Serve(listener) - go func() { - <-httpServerContext.Done() - s.Close() - }() - }) - }) - for _, v := range []struct { - id utls.ClientHelloID - name string - }{ - { - id: utls.HelloChrome_58, - name: "HelloChrome_58", - }, - { - id: utls.HelloChrome_62, - name: "HelloChrome_62", - }, - { - id: utls.HelloChrome_70, - name: "HelloChrome_70", - }, - { - id: utls.HelloChrome_72, - name: "HelloChrome_72", - }, - { - id: utls.HelloChrome_83, - name: "HelloChrome_83", - }, - { - id: utls.HelloFirefox_55, - name: "HelloFirefox_55", - }, - { - id: utls.HelloFirefox_55, - name: "HelloFirefox_55", - }, - { - id: utls.HelloFirefox_63, - name: "HelloFirefox_63", - }, - { - id: utls.HelloFirefox_65, - name: "HelloFirefox_65", - }, - { - id: utls.HelloIOS_11_1, - name: "HelloIOS_11_1", - }, - { - id: utls.HelloIOS_12_1, - name: "HelloIOS_12_1", - }, - } { - t.Run("Testing fingerprint for "+v.name, func(t *testing.T) { - rtter := NewUTLSHTTPRoundTripper(v.id, &utls.Config{ - InsecureSkipVerify: true, - }, http.DefaultTransport, false) - - for count := 0; count <= 10; count++ { - Convey("HTTP 1.1 Test", t, func(c C) { - { - req, err := http.NewRequest("GET", "https://127.0.0.1:23801/", nil) - So(err, ShouldBeNil) - _, err = rtter.RoundTrip(req) - So(err, ShouldBeNil) - } - }) - - Convey("HTTP 2 Test", t, func(c C) { - { - req, err := http.NewRequest("GET", "https://127.0.0.1:23802/", nil) - So(err, ShouldBeNil) - _, err = rtter.RoundTrip(req) - So(err, ShouldBeNil) - } - }) - } - }) - } - - cancel() -} diff --git a/common/websocketconn/websocketconn.go b/common/websocketconn/websocketconn.go deleted file mode 100644 index c745522..0000000 --- a/common/websocketconn/websocketconn.go +++ /dev/null @@ -1,120 +0,0 @@ -package websocketconn - -import ( - "io" - "time" - - "github.com/gorilla/websocket" -) - -// An abstraction that makes an underlying WebSocket connection look like a -// net.Conn. -type Conn struct { - *websocket.Conn - Reader io.Reader - Writer io.Writer -} - -func (conn *Conn) Read(b []byte) (n int, err error) { - return conn.Reader.Read(b) -} - -func (conn *Conn) Write(b []byte) (n int, err error) { - return conn.Writer.Write(b) -} - -func (conn *Conn) Close() error { - conn.Reader.(*io.PipeReader).Close() - conn.Writer.(*io.PipeWriter).Close() - // Ignore any error in trying to write a Close frame. - _ = conn.Conn.WriteControl(websocket.CloseMessage, []byte{}, time.Now().Add(time.Second)) - return conn.Conn.Close() -} - -func (conn *Conn) SetDeadline(t time.Time) error { - errRead := conn.Conn.SetReadDeadline(t) - errWrite := conn.Conn.SetWriteDeadline(t) - err := errRead - if err == nil { - err = errWrite - } - return err -} - -func readLoop(w io.Writer, ws *websocket.Conn) error { - for { - messageType, r, err := ws.NextReader() - if err != nil { - return err - } - if messageType != websocket.BinaryMessage && messageType != websocket.TextMessage { - continue - } - _, err = io.Copy(w, r) - if err != nil { - return err - } - } -} - -func writeLoop(ws *websocket.Conn, r io.Reader) error { - for { - var buf [2048]byte - n, err := r.Read(buf[:]) - if err != nil { - return err - } - data := buf[:n] - w, err := ws.NextWriter(websocket.BinaryMessage) - if err != nil { - return err - } - n, err = w.Write(data) - if err != nil { - return err - } - err = w.Close() - if err != nil { - return err - } - } -} - -// websocket.Conn methods start returning websocket.CloseError after the -// connection has been closed. We want to instead interpret that as io.EOF, just -// as you would find with a normal net.Conn. This only converts -// websocket.CloseErrors with known codes; other codes like CloseProtocolError -// and CloseAbnormalClosure will still be reported as anomalous. -func closeErrorToEOF(err error) error { - if websocket.IsCloseError(err, websocket.CloseNormalClosure, websocket.CloseNoStatusReceived) { - err = io.EOF - } - return err -} - -// Create a new Conn. -func New(ws *websocket.Conn) *Conn { - // Set up synchronous pipes to serialize reads and writes to the - // underlying websocket.Conn. - // - // https://godoc.org/github.com/gorilla/websocket#hdr-Concurrency - // "Connections support one concurrent reader and one concurrent writer. - // Applications are responsible for ensuring that no more than one - // goroutine calls the write methods (NextWriter, etc.) concurrently and - // that no more than one goroutine calls the read methods (NextReader, - // etc.) concurrently. The Close and WriteControl methods can be called - // concurrently with all other methods." - pr1, pw1 := io.Pipe() - go func() { - pw1.CloseWithError(closeErrorToEOF(readLoop(pw1, ws))) - }() - pr2, pw2 := io.Pipe() - go func() { - pr2.CloseWithError(closeErrorToEOF(writeLoop(ws, pr2))) - }() - return &Conn{ - Conn: ws, - Reader: pr1, - Writer: pw2, - } -} diff --git a/common/websocketconn/websocketconn_test.go b/common/websocketconn/websocketconn_test.go deleted file mode 100644 index 92774d4..0000000 --- a/common/websocketconn/websocketconn_test.go +++ /dev/null @@ -1,263 +0,0 @@ -package websocketconn - -import ( - "bytes" - "fmt" - "io" - "io/ioutil" - "net" - "net/http" - "net/url" - "sync" - "testing" - "time" - - "github.com/gorilla/websocket" -) - -// Returns a (server, client) pair of websocketconn.Conns. -func connPair() (*Conn, *Conn, error) { - // Will be assigned inside server.Handler. - var serverConn *Conn - - // Start up a web server to receive the request. - ln, err := net.Listen("tcp", "127.0.0.1:0") - if err != nil { - return nil, nil, err - } - defer ln.Close() - errCh := make(chan error) - server := http.Server{ - Handler: http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) { - upgrader := websocket.Upgrader{ - CheckOrigin: func(*http.Request) bool { return true }, - } - ws, err := upgrader.Upgrade(rw, req, nil) - if err != nil { - errCh <- err - return - } - serverConn = New(ws) - close(errCh) - }), - } - defer server.Close() - go func() { - err := server.Serve(ln) - if err != nil && err != http.ErrServerClosed { - errCh <- err - } - }() - - // Make a request to the web server. - urlStr := (&url.URL{Scheme: "ws", Host: ln.Addr().String()}).String() - ws, _, err := (&websocket.Dialer{}).Dial(urlStr, nil) - if err != nil { - return nil, nil, err - } - clientConn := New(ws) - - // The server is finished when errCh is written to or closed. - err = <-errCh - if err != nil { - return nil, nil, err - } - return serverConn, clientConn, nil -} - -// Test that you can write in chunks and read the result concatenated. -func TestWrite(t *testing.T) { - tests := [][][]byte{ - {}, - {[]byte("foo")}, - {[]byte("foo"), []byte("bar")}, - {{}, []byte("foo"), {}, {}, []byte("bar")}, - } - - for _, test := range tests { - s, c, err := connPair() - if err != nil { - t.Fatal(err) - } - - // This is a little awkward because we need to read to and write - // from both ends of the Conn, and we need to do it in separate - // goroutines because otherwise a Write may block waiting for - // someone to Read it. Here we set up a loop in a separate - // goroutine, reading from the Conn s and writing to the dataCh - // and errCh channels, whose ultimate effect in the select loop - // below is like - // data, err := ioutil.ReadAll(s) - dataCh := make(chan []byte) - errCh := make(chan error) - go func() { - for { - var buf [1024]byte - n, err := s.Read(buf[:]) - if err != nil { - errCh <- err - return - } - p := make([]byte, n) - copy(p, buf[:]) - dataCh <- p - } - }() - - // Write the data to the client side of the Conn, one chunk at a - // time. - for i, chunk := range test { - n, err := c.Write(chunk) - if err != nil || n != len(chunk) { - t.Fatalf("%+q Write chunk %d: got (%d, %v), expected (%d, %v)", - test, i, n, err, len(chunk), nil) - } - } - // We cannot immediately c.Close here, because that closes the - // connection right away, without waiting for buffered data to - // be sent. - - // Pull data and err from the server goroutine above. - var data []byte - err = nil - loop: - for { - select { - case p := <-dataCh: - data = append(data, p...) - case err = <-errCh: - break loop - case <-time.After(100 * time.Millisecond): - break loop - } - } - s.Close() - c.Close() - - // Now data and err contain the result of reading everything - // from s. - expected := bytes.Join(test, []byte{}) - if err != nil || !bytes.Equal(data, expected) { - t.Fatalf("%+q ReadAll: got (%+q, %v), expected (%+q, %v)", - test, data, err, expected, nil) - } - } -} - -// Test that multiple goroutines may call Read on a Conn simultaneously. Run -// this with -// go test -race -func TestConcurrentRead(t *testing.T) { - s, c, err := connPair() - if err != nil { - t.Fatal(err) - } - defer s.Close() - - // Set up multiple threads reading from the same conn. - errCh := make(chan error, 2) - var wg sync.WaitGroup - wg.Add(2) - for i := 0; i < 2; i++ { - go func() { - defer wg.Done() - _, err := io.Copy(ioutil.Discard, s) - if err != nil { - errCh <- err - } - }() - } - - // Write a bunch of data to the other end. - for i := 0; i < 2000; i++ { - _, err := fmt.Fprintf(c, "%d", i) - if err != nil { - c.Close() - t.Fatalf("Write: %v", err) - } - } - c.Close() - - wg.Wait() - close(errCh) - - err = <-errCh - if err != nil { - t.Fatalf("Read: %v", err) - } -} - -// Test that multiple goroutines may call Write on a Conn simultaneously. Run -// this with -// go test -race -func TestConcurrentWrite(t *testing.T) { - s, c, err := connPair() - if err != nil { - t.Fatal(err) - } - - // Set up multiple threads writing to the same conn. - errCh := make(chan error, 3) - var wg sync.WaitGroup - wg.Add(2) - for i := 0; i < 2; i++ { - go func() { - defer wg.Done() - for j := 0; j < 1000; j++ { - _, err := fmt.Fprintf(s, "%d", j) - if err != nil { - errCh <- err - break - } - } - }() - } - go func() { - wg.Wait() - err := s.Close() - if err != nil { - errCh <- err - } - close(errCh) - }() - - // Read from the other end. - _, err = io.Copy(ioutil.Discard, c) - c.Close() - if err != nil { - t.Fatalf("Read: %v", err) - } - - err = <-errCh - if err != nil { - t.Fatalf("Write: %v", err) - } -} - -// Test that Read and Write methods return errors after Close. -func TestClose(t *testing.T) { - s, c, err := connPair() - if err != nil { - t.Fatal(err) - } - defer c.Close() - - err = s.Close() - if err != nil { - t.Fatal(err) - } - - var buf [10]byte - n, err := s.Read(buf[:]) - if n != 0 || err == nil { - t.Fatalf("Read after Close returned (%v, %v), expected (%v, non-nil)", n, err, 0) - } - - _, err = s.Write([]byte{1, 2, 3}) - // Here we break the abstraction a little and look for a specific error, - // io.ErrClosedPipe. This is because we know the Conn uses an io.Pipe - // internally. - if err != io.ErrClosedPipe { - t.Fatalf("Write after Close returned %v, expected %v", err, io.ErrClosedPipe) - } -} diff --git a/distinctcounter/counter.go b/distinctcounter/counter.go deleted file mode 100644 index 67110b4..0000000 --- a/distinctcounter/counter.go +++ /dev/null @@ -1,37 +0,0 @@ -package main - -import ( - "flag" - "fmt" - "log" - "os" - "time" - - "git.torproject.org/pluggable-transports/snowflake.git/v2/common/ipsetsink/sinkcluster" -) - -func main() { - inputFile := flag.String("in", "", "") - start := flag.String("from", "", "") - end := flag.String("to", "", "") - flag.Parse() - startTime, err := time.Parse(time.RFC3339, *start) - if err != nil { - log.Fatal("unable to parse start time:", err) - } - endTime, err := time.Parse(time.RFC3339, *end) - if err != nil { - log.Fatal("unable to parse end time:", err) - } - fd, err := os.Open(*inputFile) - if err != nil { - log.Fatal("unable to open input file:", err) - } - counter := sinkcluster.NewClusterCounter(startTime, endTime) - result, err := counter.Count(fd) - if err != nil { - log.Fatal("unable to count:", err) - } - fmt.Printf("sum = %v\n", result.Sum) - fmt.Printf("chunkIncluded = %v\n", result.ChunkIncluded) -} diff --git a/doc/broker-spec.txt b/doc/broker-spec.txt index b138605..2877784 100644 --- a/doc/broker-spec.txt +++ b/doc/broker-spec.txt @@ -8,7 +8,7 @@ The Snowflake broker is used to hand out Snowflake proxies to clients using the This document specifies how the Snowflake broker interacts with other parts of the Tor ecosystem, starting with the metrics CollecTor module and to be expanded upon later. -1. Metrics Reporting (version 1.1) +1. Metrics Reporting (version 1.0) Metrics data from the Snowflake broker can be retrieved by sending an HTTP GET request to https://[Snowflake broker URL]/metrics and consists of the following items: @@ -31,24 +31,6 @@ Metrics data from the Snowflake broker can be retrieved by sending an HTTP GET r A count of the total number of unique IP addresses of Snowflake proxies that have polled. - "snowflake-ips-standalone" NUM NL - [At most once.] - - A count of the total number of unique IP addresses of snowflake - proxies of type "standalone" that have polled. - - "snowflake-ips-badge" NUM NL - [At most once.] - - A count of the total number of unique IP addresses of snowflake - proxies of type "badge" that have polled. - - "snowflake-ips-webext" NUM NL - [At most once.] - - A count of the total number of unique IP addresses of snowflake - proxies of type "webext" that have polled. - "snowflake-idle-count" NUM NL [At most once.] @@ -62,212 +44,8 @@ Metrics data from the Snowflake broker can be retrieved by sending an HTTP GET r from the broker but no proxies were available, rounded up to the nearest multiple of 8. - "client-restricted-denied-count" NUM NL - [At most once.] - - A count of the number of times a client with a restricted or - unknown NAT type has requested a proxy from the broker but no - proxies were available, rounded up to the nearest multiple of 8. - - "client-unrestricted-denied-count" NUM NL - [At most once.] - - A count of the number of times a client with an unrestricted NAT - type has requested a proxy from the broker but no proxies were - available, rounded up to the nearest multiple of 8. - "client-snowflake-match-count" NUM NL [At most once.] A count of the number of times a client successfully received a proxy from the broker, rounded up to the nearest multiple of 8. - - "snowflake-ips-nat-restricted" NUM NL - [At most once.] - - A count of the total number of unique IP addresses of snowflake - proxies that have a restricted NAT type. - - "snowflake-ips-nat-unrestricted" NUM NL - [At most once.] - - A count of the total number of unique IP addresses of snowflake - proxies that have an unrestricted NAT type. - - "snowflake-ips-nat-unknown" NUM NL - [At most once.] - - A count of the total number of unique IP addresses of snowflake - proxies that have an unknown NAT type. - - "snowflake-proxy-poll-with-relay-url-count" NUM NL - [At most once.] - - A count of snowflake proxy polls with relay url extension present. - This means this proxy understands relay url, and is sending its - allowed prefix. - "snowflake-proxy-poll-without-relay-url-count" NUM NL - [At most once.] - - A count of snowflake proxy polls with relay url extension absent. - This means this proxy is not yet updated. - "snowflake-proxy-rejected-for-relay-url-count" NUM NL - [At most once.] - - A count of snowflake proxy polls with relay url extension rejected - based on broker's relay url extension policy. - This means an incompatible allowed relay pattern is included in the - proxy poll message. -2. Broker messaging specification and endpoints - -The broker facilitates the connection of snowflake clients and snowflake proxies -through the exchange of WebRTC SDP information with its endpoints. - -2.1. Client interactions with the broker - -The broker offers multiple ways for clients to exchange registration -messages. - -2.1.1. HTTPS POST - -Clients interact with the broker by making a POST request to `/client` with the -offer SDP in the request body: -``` -POST /client HTTP - -[offer SDP] -``` -If the broker is behind a domain-fronted connection, this request is accompanied -with the necessary HOST information. - -If the client is matched up with a proxy, they receive a 200 OK response with -the proxy's answer SDP in the request body: -``` -HTTP 200 OK - -[answer SDP] -``` - -If no proxies were available, they receive a 503 status code: -``` -HTTP 503 Service Unavailable -``` - -2.1.2. AMP - -The broker's /amp/client endpoint receives client poll messages encoded -into the URL path, and sends client poll responses encoded as HTML that -conforms to the requirements of AMP (Accelerated Mobile Pages). This -endpoint is intended to be accessed through an AMP cache, using the --ampcache option of snowflake-client. - -The client encodes its poll message into a GET request as follows: -``` -GET /amp/client/0[0 or more bytes]/[base64 of client poll message] -``` -The components of the path are as follows: -* "/amp/client/", the root of the endpoint. -* "0", a format version number, which controls the interpretation of the - rest of the path. Only the first byte matters as a version indicator - (not the whole first path component). -* Any number of slash or non-slash bytes. These may be used as padding - or to prevent cache collisions in the AMP cache. -* A final slash. -* base64 encoding of the client poll message, using the URL-safe - alphabet (which does not include slash). - -The broker returns a client poll response message in the HTTP response. -The message is encoded using AMP armor, an AMP-compatible HTML encoding. -The data stream is notionally a "0" byte (a format version indicator) -followed by the base64 encoding of the message (using the standard -alphabet, with "=" padding). This stream is broken into -whitespace-separated chunks, which are then bundled into HTML
-elements. The 
 elements are then surrounded by AMP boilerplate. To
-decode, search the HTML for 
 elements, concatenate their contents
-and join on whitespace, discard the "0" prefix, and base64 decode.
-
-2.2 Proxy interactions with the broker
-
-Proxies poll the broker with a proxy poll request to `/proxy`:
-
-```
-POST /proxy HTTP
-
-{
-  Sid: [generated session id of proxy],
-  Version: 1.3,
-  Type: ["badge"|"webext"|"standalone"|"mobile"],
-  NAT: ["unknown"|"restricted"|"unrestricted"],
-  Clients: [number of current clients, rounded down to multiples of 8],
-  AcceptedRelayPattern: [a pattern representing accepted set of relay domains]
-}
-```
-
-If the request is well-formed, they receive a 200 OK response.
-
-If a client is matched:
-```
-HTTP 200 OK
-
-{
-  Status: "client match",
-  {
-    type: offer,
-    sdp: [WebRTC SDP]
-  },
-  RelayURL: [the WebSocket URL proxy should connect to relay Snowflake traffic]
-}
-```
-
-If a client is not matched:
-```
-HTTP 200 OK
-
-{
-    Status: "no match"
-}
-```
-
-If the request is malformed:
-```
-HTTP 400 BadRequest
-```
-
-If they are matched with a client, they provide their SDP answer with a POST
-request to `/answer`:
-```
-POST /answer HTTP
-
-{
-  Sid: [generated session id of proxy],
-  Version: 1.3,
-  Answer:
-  {
-    type: answer,
-    sdp: [WebRTC SDP]
-  }
-}
-```
-
-If the request is well-formed, they receive a 200 OK response.
-
-If the client retrieved the answer:
-```
-HTTP 200 OK
-
-{
-  Status: "success"
-}
-```
-
-If the client left:
-```
-HTTP 200 OK
-
-{
-  Status: "client gone"
-}
-
-3) If the request is malformed:
-HTTP 400 BadRequest
-```
diff --git a/doc/snowflake-client.1 b/doc/snowflake-client.1
deleted file mode 100644
index 122ada0..0000000
--- a/doc/snowflake-client.1
+++ /dev/null
@@ -1,50 +0,0 @@
-.TH SNOWFLAKE-CLIENT "1" "July 2021" "snowflake-client" "User Commands"
-.SH NAME
-snowflake-client \- WebRTC pluggable transport client for Tor
-.SH DESCRIPTION
-Snowflake helps users circumvent censorship by making a WebRTC
-connection to volunteer proxies. These proxies relay Tor traffic to a
-Snowflake bridge and then through the Tor network.
-.SS "Usage of snowflake-client:"
-.HP
-\fB\-ampcache\fR string
-.IP
-URL of AMP cache to use as a proxy for signaling
-.HP
-\fB\-front\fR string
-.IP
-front domain
-.HP
-\fB\-ice\fR string
-.IP
-comma\-separated list of ICE servers
-.HP
-\fB\-keep\-local\-addresses\fR
-.IP
-keep local LAN address ICE candidates
-.HP
-\fB\-log\fR string
-.IP
-name of log file
-.HP
-\fB\-log\-to\-state\-dir\fR
-.IP
-resolve the log file relative to tor's pt state dir
-.HP
-\fB\-logToStateDir\fR
-.IP
-use \fB\-log\-to\-state\-dir\fR instead
-.HP
-\fB\-max\fR int
-.IP
-capacity for number of multiplexed WebRTC peers (default 1)
-.HP
-\fB\-unsafe\-logging\fR
-.IP
-prevent logs from being scrubbed
-.HP
-\fB\-url\fR string
-.IP
-URL of signaling broker
-.SH "SEE ALSO"
-https://snowflake.torproject.org
diff --git a/doc/snowflake-proxy.1 b/doc/snowflake-proxy.1
deleted file mode 100644
index 67f71b6..0000000
--- a/doc/snowflake-proxy.1
+++ /dev/null
@@ -1,38 +0,0 @@
-.TH SNOWFLAKE-PROXY "1" "June 2021" "swnoflake-proxy" "User Commands"
-.SH NAME
-snowflake-proxy \- WebRTC pluggable transport proxy for Tor
-.SH DESCRIPTION
-Snowflake helps users circumvent censorship by making a WebRTC
-connection to volunteer proxies. These proxies relay Tor traffic to a
-Snowflake bridge and then through the Tor network.
-.SS "Usage of snowflake-proxy:"
-.HP
-\fB\-broker\fR string
-.IP
-broker URL (default "https://snowflake\-broker.torproject.net/")
-.HP
-\fB\-capacity\fR uint
-.IP
-maximum concurrent clients (default 10)
-.HP
-\fB\-keep\-local\-addresses\fR
-.IP
-keep local LAN address ICE candidates
-.HP
-\fB\-log\fR string
-.IP
-log filename
-.HP
-\fB\-relay\fR string
-.IP
-websocket relay URL (default "wss://snowflake.torproject.net/")
-.HP
-\fB\-stun\fR string
-.IP
-stun URL (default "stun:stun.stunprotocol.org:3478")
-.HP
-\fB\-unsafe\-logging\fR
-.IP
-prevent logs from being scrubbed
-.SH "SEE ALSO"
-https://snowflake.torproject.org
diff --git a/doc/using-the-snowflake-library.md b/doc/using-the-snowflake-library.md
deleted file mode 100644
index c3e83e0..0000000
--- a/doc/using-the-snowflake-library.md
+++ /dev/null
@@ -1,164 +0,0 @@
-Snowflake is available as a general-purpose pluggable transports library and adheres to the [pluggable transports v2.1 Go API](https://github.com/Pluggable-Transports/Pluggable-Transports-spec/blob/master/releases/PTSpecV2.1/Pluggable%20Transport%20Specification%20v2.1%20-%20Go%20Transport%20API.pdf).
-
-### Client library
-
-The Snowflake client library contains functions for running a Snowflake client.
-
-Example usage:
-
-```Golang
-package main
-
-import (
-    "log"
-
-    sf "git.torproject.org/pluggable-transports/snowflake.git/v2/client/lib"
-)
-
-func main() {
-
-    config := sf.ClientConfig{
-        BrokerURL:   "https://snowflake-broker.example.com",
-        FrontDomain: "https://friendlyfrontdomain.net",
-        ICEAddresses: []string{
-            "stun:stun.voip.blackberry.com:3478",
-            "stun:stun.stunprotocol.org:3478"},
-        Max: 1,
-    }
-    transport, err := sf.NewSnowflakeClient(config)
-    if err != nil {
-        log.Fatal("Failed to start snowflake transport: ", err)
-    }
-
-    // transport implements the ClientFactory interface and returns a net.Conn
-    conn, err := transport.Dial()
-    if err != nil {
-        log.Printf("dial error: %s", err)
-        return
-    }
-    defer conn.Close()
-
-    // ...
-
-}
-```
-
-#### Using your own rendezvous method
-
-You can define and use your own rendezvous method to communicate with a Snowflake broker by implementing the `RendezvousMethod` interface.
-
-```Golang
-
-package main
-
-import (
-    "log"
-
-    sf "git.torproject.org/pluggable-transports/snowflake.git/v2/client/lib"
-)
-
-type StubMethod struct {
-}
-
-func (m *StubMethod) Exchange(pollReq []byte) ([]byte, error) {
-    var brokerResponse []byte
-    var err error
-
-    //Implement the logic you need to communicate with the Snowflake broker here
-
-    return brokerResponse, err
-}
-
-func main() {
-    config := sf.ClientConfig{
-        ICEAddresses:       []string{
-            "stun:stun.voip.blackberry.com:3478",
-            "stun:stun.stunprotocol.org:3478"},
-    }
-    transport, err := sf.NewSnowflakeClient(config)
-    if err != nil {
-        log.Fatal("Failed to start snowflake transport: ", err)
-    }
-
-    // custom rendezvous methods can be set with `SetRendezvousMethod`
-    rendezvous := &StubMethod{}
-    transport.SetRendezvousMethod(rendezvous)
-
-    // transport implements the ClientFactory interface and returns a net.Conn
-    conn, err := transport.Dial()
-    if err != nil {
-        log.Printf("dial error: %s", err)
-        return
-    }
-    defer conn.Close()
-
-    // ...
-
-}
-```
-
-### Server library
-
-The Snowflake server library contains functions for running a Snowflake server.
-
-Example usage:
-```Golang
-
-package main
-
-import (
-    "log"
-    "net"
-
-    sf "git.torproject.org/pluggable-transports/snowflake.git/v2/server/lib"
-    "golang.org/x/crypto/acme/autocert"
-)
-
-func main() {
-
-    // The snowflake server runs a websocket server. To run this securely, you will
-    // need a valid certificate.
-    certManager := &autocert.Manager{
-        Prompt:     autocert.AcceptTOS,
-        HostPolicy: autocert.HostWhitelist("snowflake.yourdomain.com"),
-        Email:      "you@yourdomain.com",
-    }
-
-    transport := sf.NewSnowflakeServer(certManager.GetCertificate)
-
-    addr, err := net.ResolveTCPAddr("tcp", "127.0.0.1:443")
-    if err != nil {
-        log.Printf("error resolving bind address: %s", err.Error())
-    }
-    ln, err := transport.Listen(addr)
-    if err != nil {
-        log.Printf("error opening listener: %s", err.Error())
-    }
-    for {
-        conn, err := ln.Accept()
-        if err != nil {
-            if err, ok := err.(net.Error); ok && err.Temporary() {
-                continue
-            }
-            log.Printf("Snowflake accept error: %s", err)
-            break
-        }
-        go func() {
-            // ...
-
-            defer conn.Close()
-        }()
-    }
-
-    // ...
-
-}
-
-```
-### Running your own Snowflake infrastructure
-
-At the moment we do not have the ability to share Snowfake infrastructure between different types of applications. If you are planning on using Snowflake as a transport for your application, you will need to:
-
-- Run a Snowflake broker. See our [broker documentation](../broker/) and [installation guide](https://gitlab.torproject.org/tpo/anti-censorship/team/-/wikis/Survival-Guides/Snowflake-Broker-Installation-Guide) for more information
-
-- Run Snowflake proxies. These can be run as [standalone Go proxies](../proxy/) or [browser-based proxies](https://gitlab.torproject.org/tpo/anti-censorship/pluggable-transports/snowflake-webext).
diff --git a/go.mod b/go.mod
deleted file mode 100644
index c782967..0000000
--- a/go.mod
+++ /dev/null
@@ -1,24 +0,0 @@
-module git.torproject.org/pluggable-transports/snowflake.git/v2
-
-go 1.13
-
-require (
-	git.torproject.org/pluggable-transports/goptlib.git v1.1.0
-	github.com/clarkduvall/hyperloglog v0.0.0-20171127014514-a0107a5d8004 // indirect
-	github.com/gorilla/websocket v1.4.1
-	github.com/pion/ice/v2 v2.2.6
-	github.com/pion/sdp/v3 v3.0.5
-	github.com/pion/stun v0.3.5
-	github.com/pion/webrtc/v3 v3.1.41
-	github.com/prometheus/client_golang v1.10.0
-	github.com/prometheus/client_model v0.2.0
-	github.com/refraction-networking/utls v1.0.0
-	github.com/smartystreets/goconvey v1.6.4
-	github.com/stretchr/testify v1.7.1
-	github.com/xtaci/kcp-go/v5 v5.6.1
-	github.com/xtaci/smux v1.5.15
-	gitlab.torproject.org/tpo/anti-censorship/geoip v0.0.0-20210928150955-7ce4b3d98d01
-	golang.org/x/crypto v0.0.0-20220516162934-403b01795ae8
-	golang.org/x/net v0.0.0-20220425223048-2871e0cb64e4
-	google.golang.org/protobuf v1.26.0
-)
diff --git a/go.sum b/go.sum
deleted file mode 100644
index e610825..0000000
--- a/go.sum
+++ /dev/null
@@ -1,558 +0,0 @@
-cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
-cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
-git.torproject.org/pluggable-transports/goptlib.git v1.1.0 h1:LMQAA8pAho+QtYrrVNimJQiINNEwcwuuD99vezD/PAo=
-git.torproject.org/pluggable-transports/goptlib.git v1.1.0/go.mod h1:YT4XMSkuEXbtqlydr9+OxqFAyspUv0Gr9qhM3B++o/Q=
-github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
-github.com/Knetic/govaluate v3.0.1-0.20171022003610-9aa49832a739+incompatible/go.mod h1:r7JcOSlj0wfOMncg0iLm8Leh48TZaKVeNIfJntJ2wa0=
-github.com/Shopify/sarama v1.19.0/go.mod h1:FVkBWblsNy7DGZRfXLU0O9RCGt5g3g3yEuWXgklEdEo=
-github.com/Shopify/toxiproxy v2.1.4+incompatible/go.mod h1:OXgGpZ6Cli1/URJOF1DMxUHB2q5Ap20/P/eIdh4G0pI=
-github.com/VividCortex/gohistogram v1.0.0/go.mod h1:Pf5mBqqDxYaXu3hDrrU+w6nw50o/4+TcAqDqk/vUH7g=
-github.com/afex/hystrix-go v0.0.0-20180502004556-fa1af6a1f4f5/go.mod h1:SkGFH1ia65gfNATL8TAiHDNxPzPdmEL5uirI2Uyuz6c=
-github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc=
-github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc=
-github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0=
-github.com/alecthomas/units v0.0.0-20190717042225-c3de453c63f4/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0=
-github.com/alecthomas/units v0.0.0-20190924025748-f65c72e2690d/go.mod h1:rBZYJk541a8SKzHPHnH3zbiI+7dagKZ0cgpgrD7Fyho=
-github.com/apache/thrift v0.12.0/go.mod h1:cp2SuWMxlEZw2r+iP2GNCdIi4C1qmUzdZFSVb+bacwQ=
-github.com/apache/thrift v0.13.0/go.mod h1:cp2SuWMxlEZw2r+iP2GNCdIi4C1qmUzdZFSVb+bacwQ=
-github.com/armon/circbuf v0.0.0-20150827004946-bbbad097214e/go.mod h1:3U/XgcO3hCbHZ8TKRvWD2dDTCfh9M9ya+I9JpbB7O8o=
-github.com/armon/go-metrics v0.0.0-20180917152333-f0300d1749da/go.mod h1:Q73ZrmVTwzkszR9V5SSuryQ31EELlFMUz1kKyl939pY=
-github.com/armon/go-radix v0.0.0-20180808171621-7fddfc383310/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgIH9cCH8=
-github.com/aryann/difflib v0.0.0-20170710044230-e206f873d14a/go.mod h1:DAHtR1m6lCRdSC2Tm3DSWRPvIPr6xNKyeHdqDQSQT+A=
-github.com/aws/aws-lambda-go v1.13.3/go.mod h1:4UKl9IzQMoD+QF79YdCuzCwp8VbmG4VAQwij/eHl5CU=
-github.com/aws/aws-sdk-go v1.27.0/go.mod h1:KmX6BPdI08NWTb3/sm4ZGu5ShLoqVDhKgpiN924inxo=
-github.com/aws/aws-sdk-go-v2 v0.18.0/go.mod h1:JWVYvqSMppoMJC0x5wdwiImzgXTI9FuZwxzkQq9wy+g=
-github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q=
-github.com/beorn7/perks v1.0.0/go.mod h1:KWe93zE9D1o94FZ5RNwFwVgaQK1VOXiVxmqh+CedLV8=
-github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
-github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
-github.com/bgentry/speakeasy v0.1.0/go.mod h1:+zsyZBPWlz7T6j88CTgSN5bM796AkVf0kBD4zp0CCIs=
-github.com/casbin/casbin/v2 v2.1.2/go.mod h1:YcPU1XXisHhLzuxH9coDNf2FbKpjGlbCg3n9yuLkIJQ=
-github.com/cenkalti/backoff v2.2.1+incompatible/go.mod h1:90ReRw6GdpyfrHakVjL/QHaoyV4aDUVVkXQJJJ3NXXM=
-github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
-github.com/cespare/xxhash/v2 v2.1.1 h1:6MnRN8NT7+YBpUIWxHtefFZOKTAPgGjpQSxqLNn0+qY=
-github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
-github.com/clarkduvall/hyperloglog v0.0.0-20171127014514-a0107a5d8004 h1:mK6JroY6bLiPS3s6QCYOSjRyErFc2iHNkhhmRfF0nHo=
-github.com/clarkduvall/hyperloglog v0.0.0-20171127014514-a0107a5d8004/go.mod h1:drodPoQNro6QBO6TJ/MpMZbz8Bn2eSDtRN6jpG4VGw8=
-github.com/clbanning/x2j v0.0.0-20191024224557-825249438eec/go.mod h1:jMjuTZXRI4dUb/I5gc9Hdhagfvm9+RyrPryS/auMzxE=
-github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
-github.com/cockroachdb/datadriven v0.0.0-20190809214429-80d97fb3cbaa/go.mod h1:zn76sxSg3SzpJ0PPJaLDCu+Bu0Lg3sKTORVIj19EIF8=
-github.com/codahale/hdrhistogram v0.0.0-20161010025455-3a0bb77429bd/go.mod h1:sE/e/2PUdi/liOCUjSTXgM1o87ZssimdTWN964YiIeI=
-github.com/coreos/go-semver v0.2.0/go.mod h1:nnelYz7RCh+5ahJtPPxZlU+153eP4D4r3EedlOD2RNk=
-github.com/coreos/go-systemd v0.0.0-20180511133405-39ca1b05acc7/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4=
-github.com/coreos/pkg v0.0.0-20160727233714-3ac0863d7acf/go.mod h1:E3G3o1h8I7cfcXa63jLwjI0eiQQMgzzUDFVpN/nH/eA=
-github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU=
-github.com/creack/pty v1.1.7/go.mod h1:lj5s0c3V2DBrqTV7llrYr5NG6My20zk30Fl46Y7DoTY=
-github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
-github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
-github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
-github.com/dgrijalva/jwt-go v3.2.0+incompatible/go.mod h1:E3ru+11k8xSBh+hMPgOLZmtrrCbhqsmaPHjLKYnJCaQ=
-github.com/dustin/go-humanize v0.0.0-20171111073723-bb3d318650d4/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk=
-github.com/eapache/go-resiliency v1.1.0/go.mod h1:kFI+JgMyC7bLPUVY133qvEBtVayf5mFgVsvEsIPBvNs=
-github.com/eapache/go-xerial-snappy v0.0.0-20180814174437-776d5712da21/go.mod h1:+020luEh2TKB4/GOp8oxxtq0Daoen/Cii55CzbTV6DU=
-github.com/eapache/queue v1.1.0/go.mod h1:6eCeP0CKFpHLu8blIFXhExK/dRa7WDZfr6jVFPTqq+I=
-github.com/edsrzf/mmap-go v1.0.0/go.mod h1:YO35OhQPt3KJa3ryjFM5Bs14WD66h8eGKpfaBNrHW5M=
-github.com/envoyproxy/go-control-plane v0.6.9/go.mod h1:SBwIajubJHhxtWwsL9s8ss4safvEdbitLhGGK48rN6g=
-github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
-github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c=
-github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4=
-github.com/franela/goblin v0.0.0-20200105215937-c9ffbefa60db/go.mod h1:7dvUGVsVBjqR7JHJk0brhHOZYGmfBYOrK0ZhYMEtBr4=
-github.com/franela/goreq v0.0.0-20171204163338-bcd34c9993f8/go.mod h1:ZhphrRTfi2rbfLwlschooIH4+wKKDR4Pdxhh+TRoA20=
-github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
-github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ=
-github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04=
-github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as=
-github.com/go-kit/kit v0.9.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as=
-github.com/go-kit/kit v0.10.0/go.mod h1:xUsJbQ/Fp4kEt7AFgCuvyX4a71u8h9jB8tj/ORgOZ7o=
-github.com/go-logfmt/logfmt v0.3.0/go.mod h1:Qt1PoO58o5twSAckw1HlFXLmHsOX5/0LbT9GBnD5lWE=
-github.com/go-logfmt/logfmt v0.4.0/go.mod h1:3RMwSq7FuexP4Kalkev3ejPJsZTpXXBr9+V4qmtdjCk=
-github.com/go-logfmt/logfmt v0.5.0/go.mod h1:wCYkCAKZfumFQihp8CzCvQ3paCTfi41vtzG1KdI/P7A=
-github.com/go-sql-driver/mysql v1.4.0/go.mod h1:zAC/RDZ24gD3HViQzih4MyKcchzm+sOG5ZlKdlhCg5w=
-github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY=
-github.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0/go.mod h1:fyg7847qk6SyHyPtNmDHnmrv/HOrqktSC+C9fM+CJOE=
-github.com/gogo/googleapis v1.1.0/go.mod h1:gf4bu3Q80BeJ6H1S1vYPm8/ELATdvryBaNFGgqEef3s=
-github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ=
-github.com/gogo/protobuf v1.2.0/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ=
-github.com/gogo/protobuf v1.2.1/go.mod h1:hp+jE20tsWTFYpLwKvXlhS1hjn+gTNwPg2I6zVXpSg4=
-github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
-github.com/golang/groupcache v0.0.0-20160516000752-02826c3e7903/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
-github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
-github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=
-github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
-github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
-github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
-github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8=
-github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA=
-github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs=
-github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w=
-github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0=
-github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
-github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
-github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
-github.com/golang/protobuf v1.5.2 h1:ROPKBNFfQgOUMifHyP+KYbvpjbdoFNs+aK7DXlji0Tw=
-github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
-github.com/golang/snappy v0.0.0-20180518054509-2e65f85255db/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
-github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ=
-github.com/google/btree v1.0.0/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ=
-github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M=
-github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
-github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
-github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
-github.com/google/go-cmp v0.5.4/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
-github.com/google/go-cmp v0.5.5 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU=
-github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
-github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
-github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI=
-github.com/google/uuid v1.0.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
-github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I=
-github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
-github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1 h1:EGx4pi6eqNxGaHF6qqu48+N2wcFQ5qg5FXgOdqsJ5d8=
-github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY=
-github.com/gorilla/context v1.1.1/go.mod h1:kBGZzfjB9CEq2AlWe17Uuf7NDRt0dE0s8S51q0aT7Yg=
-github.com/gorilla/mux v1.6.2/go.mod h1:1lud6UwP+6orDFRuTfBEV8e9/aOM/c4fVVCaMa2zaAs=
-github.com/gorilla/mux v1.7.3/go.mod h1:1lud6UwP+6orDFRuTfBEV8e9/aOM/c4fVVCaMa2zaAs=
-github.com/gorilla/websocket v0.0.0-20170926233335-4201258b820c/go.mod h1:E7qHFY5m1UJ88s3WnNqhKjPHQ0heANvMoAMk2YaljkQ=
-github.com/gorilla/websocket v1.4.1 h1:q7AeDBpnBk8AogcD4DSag/Ukw/KV+YhzLj2bP5HvKCM=
-github.com/gorilla/websocket v1.4.1/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
-github.com/grpc-ecosystem/go-grpc-middleware v1.0.1-0.20190118093823-f849b5445de4/go.mod h1:FiyG127CGDf3tlThmgyCl78X/SZQqEOJBCDaAfeWzPs=
-github.com/grpc-ecosystem/go-grpc-prometheus v1.2.0/go.mod h1:8NvIoxWQoOIhqOTXgfV/d3M/q6VIi02HzZEHgUlZvzk=
-github.com/grpc-ecosystem/grpc-gateway v1.9.5/go.mod h1:vNeuVxBJEsws4ogUvrchl83t/GYV9WGTSLVdBhOQFDY=
-github.com/hashicorp/consul/api v1.3.0/go.mod h1:MmDNSzIMUjNpY/mQ398R4bk2FnqQLoPndWW5VkKPlCE=
-github.com/hashicorp/consul/sdk v0.3.0/go.mod h1:VKf9jXwCTEY1QZP2MOLRhb5i/I/ssyNV1vwHyQBF0x8=
-github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
-github.com/hashicorp/go-cleanhttp v0.5.1/go.mod h1:JpRdi6/HCYpAwUzNwuwqhbovhLtngrth3wmdIIUrZ80=
-github.com/hashicorp/go-immutable-radix v1.0.0/go.mod h1:0y9vanUI8NX6FsYoO3zeMjhV/C5i9g4Q3DwcSNZ4P60=
-github.com/hashicorp/go-msgpack v0.5.3/go.mod h1:ahLV/dePpqEmjfWmKiqvPkv/twdG7iPBM1vqhUKIvfM=
-github.com/hashicorp/go-multierror v1.0.0/go.mod h1:dHtQlpGsu+cZNNAkkCN/P3hoUDHhCYQXV3UM06sGGrk=
-github.com/hashicorp/go-rootcerts v1.0.0/go.mod h1:K6zTfqpRlCUIjkwsN4Z+hiSfzSTQa6eBIzfwKfwNnHU=
-github.com/hashicorp/go-sockaddr v1.0.0/go.mod h1:7Xibr9yA9JjQq1JpNB2Vw7kxv8xerXegt+ozgdvDeDU=
-github.com/hashicorp/go-syslog v1.0.0/go.mod h1:qPfqrKkXGihmCqbJM2mZgkZGvKG1dFdvsLplgctolz4=
-github.com/hashicorp/go-uuid v1.0.0/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
-github.com/hashicorp/go-uuid v1.0.1/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
-github.com/hashicorp/go-version v1.2.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA=
-github.com/hashicorp/go.net v0.0.1/go.mod h1:hjKkEWcCURg++eb33jQU7oqQcI9XDCnUzHA0oac0k90=
-github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
-github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
-github.com/hashicorp/logutils v1.0.0/go.mod h1:QIAnNjmIWmVIIkWDTG1z5v++HQmx9WQRO+LraFDTW64=
-github.com/hashicorp/mdns v1.0.0/go.mod h1:tL+uN++7HEJ6SQLQ2/p+z2pH24WQKWjBPkE0mNTz8vQ=
-github.com/hashicorp/memberlist v0.1.3/go.mod h1:ajVTdAv/9Im8oMAAj5G31PhhMCZJV2pPBoIllUwCN7I=
-github.com/hashicorp/serf v0.8.2/go.mod h1:6hOLApaqBFA1NXqRQAsxw9QxuDEvNxSQRwA/JwenrHc=
-github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
-github.com/hudl/fargo v1.3.0/go.mod h1:y3CKSmjA+wD2gak7sUSXTAoopbhU08POFhmITJgmKTg=
-github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8=
-github.com/influxdata/influxdb1-client v0.0.0-20191209144304-8bf82d3c094d/go.mod h1:qj24IKcXYK6Iy9ceXlo3Tc+vtHo9lIhSX5JddghvEPo=
-github.com/jmespath/go-jmespath v0.0.0-20180206201540-c2b33e8439af/go.mod h1:Nht3zPeWKUH0NzdCt2Blrr5ys8VGpn0CEB0cQHVjt7k=
-github.com/jonboulle/clockwork v0.1.0/go.mod h1:Ii8DK3G1RaLaWxj9trq07+26W01tbo22gdxWY5EU2bo=
-github.com/jpillora/backoff v1.0.0/go.mod h1:J/6gKK9jxlEcS3zixgDgUAsiuZ7yrSoa/FX5e0EB2j4=
-github.com/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU=
-github.com/json-iterator/go v1.1.7/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4=
-github.com/json-iterator/go v1.1.8/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4=
-github.com/json-iterator/go v1.1.10/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4=
-github.com/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7C0MuV77Wo=
-github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU=
-github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w=
-github.com/julienschmidt/httprouter v1.3.0/go.mod h1:JR6WtHb+2LUe8TCKY3cZOxFyyO8IZAc4RVcycCCAKdM=
-github.com/kisielk/errcheck v1.1.0/go.mod h1:EZBBE59ingxPouuu3KfxchcWSUPOHkagtvWXihfKN4Q=
-github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
-github.com/klauspost/cpuid v1.2.4/go.mod h1:Pj4uuM528wm8OyEC2QMXAi2YiTZ96dNQPGgoMS4s3ek=
-github.com/klauspost/cpuid v1.3.1 h1:5JNjFYYQrZeKRJ0734q51WCEEn2huer72Dc7K+R/b6s=
-github.com/klauspost/cpuid v1.3.1/go.mod h1:bYW4mA6ZgKPob1/Dlai2LviZJO7KGI3uoWLd42rAQw4=
-github.com/klauspost/reedsolomon v1.9.9 h1:qCL7LZlv17xMixl55nq2/Oa1Y86nfO8EqDfv2GHND54=
-github.com/klauspost/reedsolomon v1.9.9/go.mod h1:O7yFFHiQwDR6b2t63KPUpccPtNdp5ADgh1gg4fd12wo=
-github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
-github.com/konsorten/go-windows-terminal-sequences v1.0.3/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
-github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc=
-github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI=
-github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
-github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
-github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE=
-github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
-github.com/lightstep/lightstep-tracer-common/golang/gogo v0.0.0-20190605223551-bc2310a04743/go.mod h1:qklhhLq1aX+mtWk9cPHPzaBjWImj5ULL6C7HFJtXQMM=
-github.com/lightstep/lightstep-tracer-go v0.18.1/go.mod h1:jlF1pusYV4pidLvZ+XD0UBX0ZE6WURAspgAczcDHrL4=
-github.com/lyft/protoc-gen-validate v0.0.13/go.mod h1:XbGvPuh87YZc5TdIa2/I4pLk0QoUACkjt2znoq26NVQ=
-github.com/mattn/go-colorable v0.0.9/go.mod h1:9vuHe8Xs5qXnSaW/c/ABM9alt+Vo+STaOChaDxuIBZU=
-github.com/mattn/go-isatty v0.0.3/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4=
-github.com/mattn/go-isatty v0.0.4/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4=
-github.com/mattn/go-runewidth v0.0.2/go.mod h1:LwmH8dsx7+W8Uxz3IHJYH5QSwggIsqBzpuz5H//U1FU=
-github.com/matttproud/golang_protobuf_extensions v1.0.1 h1:4hp9jkHxhMHkqkrB3Ix0jegS5sx/RkqARlsWZ6pIwiU=
-github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0=
-github.com/miekg/dns v1.0.14/go.mod h1:W1PPwlIAgtquWBMBEV9nkV9Cazfe8ScdGz/Lj7v3Nrg=
-github.com/mitchellh/cli v1.0.0/go.mod h1:hNIlj7HEI86fIcpObd7a0FcrxTWetlwJDGcceTlRvqc=
-github.com/mitchellh/go-homedir v1.0.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0=
-github.com/mitchellh/go-testing-interface v1.0.0/go.mod h1:kRemZodwjscx+RGhAo8eIhFbs2+BFgRtFPeD/KE+zxI=
-github.com/mitchellh/gox v0.4.0/go.mod h1:Sd9lOJ0+aimLBi73mGofS1ycjY8lL3uZM3JPS42BGNg=
-github.com/mitchellh/iochan v1.0.0/go.mod h1:JwYml1nuB7xOzsp52dPpHFffvOCDupsG0QubkSMEySY=
-github.com/mitchellh/mapstructure v0.0.0-20160808181253-ca63d7c062ee/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y=
-github.com/mitchellh/mapstructure v1.1.2/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y=
-github.com/mmcloughlin/avo v0.0.0-20200803215136-443f81d77104 h1:ULR/QWMgcgRiZLUjSSJMU+fW+RDMstRdmnDWj9Q+AsA=
-github.com/mmcloughlin/avo v0.0.0-20200803215136-443f81d77104/go.mod h1:wqKykBG2QzQDJEzvRkcS8x6MiSJkF52hXZsXcjaB3ls=
-github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
-github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
-github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=
-github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=
-github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U=
-github.com/mwitkow/go-conntrack v0.0.0-20190716064945-2f068394615f/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U=
-github.com/nats-io/jwt v0.3.0/go.mod h1:fRYCDE99xlTsqUzISS1Bi75UBJ6ljOJQOAAu5VglpSg=
-github.com/nats-io/jwt v0.3.2/go.mod h1:/euKqTS1ZD+zzjYrY7pseZrTtWQSjujC7xjPc8wL6eU=
-github.com/nats-io/nats-server/v2 v2.1.2/go.mod h1:Afk+wRZqkMQs/p45uXdrVLuab3gwv3Z8C4HTBu8GD/k=
-github.com/nats-io/nats.go v1.9.1/go.mod h1:ZjDU1L/7fJ09jvUSRVBR2e7+RnLiiIQyqyzEE/Zbp4w=
-github.com/nats-io/nkeys v0.1.0/go.mod h1:xpnFELMwJABBLVhffcfd1MZx6VsNRFpEugbxziKVo7w=
-github.com/nats-io/nkeys v0.1.3/go.mod h1:xpnFELMwJABBLVhffcfd1MZx6VsNRFpEugbxziKVo7w=
-github.com/nats-io/nuid v1.0.1/go.mod h1:19wcPz3Ph3q0Jbyiqsd0kePYG7A95tJPxeL+1OSON2c=
-github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A=
-github.com/nxadm/tail v1.4.8/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU=
-github.com/oklog/oklog v0.3.2/go.mod h1:FCV+B7mhrz4o+ueLpx+KqkyXRGMWOYEvfiXtdGtbWGs=
-github.com/oklog/run v1.0.0/go.mod h1:dlhp/R75TPv97u0XWUtDeV/lRKWPKSdTuV0TZvrmrQA=
-github.com/olekukonko/tablewriter v0.0.0-20170122224234-a0225b3f23b5/go.mod h1:vsDQFd/mU46D+Z4whnwzcISnGGzXWMclvtLoiIKAKIo=
-github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
-github.com/onsi/ginkgo v1.7.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
-github.com/onsi/ginkgo v1.12.1/go.mod h1:zj2OWP4+oCPe1qIXoGWkgMRwljMUYCdkwsT2108oapk=
-github.com/onsi/ginkgo v1.16.4/go.mod h1:dX+/inL/fNMqNlz0e9LfyB9TswhZpCVdJM/Z6Vvnwo0=
-github.com/onsi/ginkgo v1.16.5/go.mod h1:+E8gABHa3K6zRBolWtd+ROzc/U5bkGt0FwiG042wbpU=
-github.com/onsi/gomega v1.4.3/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY=
-github.com/onsi/gomega v1.7.1/go.mod h1:XdKZgCCFLUoM/7CFJVPcG8C1xQ1AJ0vpAezJrB7JYyY=
-github.com/onsi/gomega v1.10.1/go.mod h1:iN09h71vgCQne3DLsj+A5owkum+a2tYe+TOCB1ybHNo=
-github.com/onsi/gomega v1.17.0/go.mod h1:HnhC7FXeEQY45zxNK3PPoIUhzk/80Xly9PcubAlGdZY=
-github.com/op/go-logging v0.0.0-20160315200505-970db520ece7/go.mod h1:HzydrMdWErDVzsI23lYNej1Htcns9BCg93Dk0bBINWk=
-github.com/opentracing-contrib/go-observer v0.0.0-20170622124052-a52f23424492/go.mod h1:Ngi6UdF0k5OKD5t5wlmGhe/EDKPoUM3BXZSSfIuJbis=
-github.com/opentracing/basictracer-go v1.0.0/go.mod h1:QfBfYuafItcjQuMwinw9GhYKwFXS9KnPs5lxoYwgW74=
-github.com/opentracing/opentracing-go v1.0.2/go.mod h1:UkNAQd3GIcIGf0SeVgPpRdFStlNbqXla1AfSYxPUl2o=
-github.com/opentracing/opentracing-go v1.1.0/go.mod h1:UkNAQd3GIcIGf0SeVgPpRdFStlNbqXla1AfSYxPUl2o=
-github.com/openzipkin-contrib/zipkin-go-opentracing v0.4.5/go.mod h1:/wsWhb9smxSfWAKL3wpBW7V8scJMt8N8gnaMCS9E/cA=
-github.com/openzipkin/zipkin-go v0.1.6/go.mod h1:QgAqvLzwWbR/WpD4A3cGpPtJrZXNIiJc5AZX7/PBEpw=
-github.com/openzipkin/zipkin-go v0.2.1/go.mod h1:NaW6tEwdmWMaCDZzg8sh+IBNOxHMPnhQw8ySjnjRyN4=
-github.com/openzipkin/zipkin-go v0.2.2/go.mod h1:NaW6tEwdmWMaCDZzg8sh+IBNOxHMPnhQw8ySjnjRyN4=
-github.com/pact-foundation/pact-go v1.0.4/go.mod h1:uExwJY4kCzNPcHRj+hCR/HBbOOIwwtUjcrb0b5/5kLM=
-github.com/pascaldekloe/goe v0.0.0-20180627143212-57f6aae5913c/go.mod h1:lzWF7FIEvWOWxwDKqyGYQf6ZUaNfKdP144TG7ZOy1lc=
-github.com/pborman/uuid v1.2.0/go.mod h1:X/NO0urCmaxf9VXbdlT7C2Yzkj2IKimNn4k+gtPdI/k=
-github.com/performancecopilot/speed v3.0.0+incompatible/go.mod h1:/CLtqpZ5gBg1M9iaPbIdPPGyKcA8hKdoy6hAWba7Yac=
-github.com/pierrec/lz4 v1.0.2-0.20190131084431-473cd7ce01a1/go.mod h1:3/3N9NVKO0jef7pBehbT1qWhCMrIgbYNnFAZCqQ5LRc=
-github.com/pierrec/lz4 v2.0.5+incompatible/go.mod h1:pdkljMzZIN41W+lC3N2tnIh5sFi+IEE17M5jbnwPHcY=
-github.com/pion/datachannel v1.5.2 h1:piB93s8LGmbECrpO84DnkIVWasRMk3IimbcXkTQLE6E=
-github.com/pion/datachannel v1.5.2/go.mod h1:FTGQWaHrdCwIJ1rw6xBIfZVkslikjShim5yr05XFuCQ=
-github.com/pion/dtls/v2 v2.1.3/go.mod h1:o6+WvyLDAlXF7YiPB/RlskRoeK+/JtuaZa5emwQcWus=
-github.com/pion/dtls/v2 v2.1.5 h1:jlh2vtIyUBShchoTDqpCCqiYCyRFJ/lvf/gQ8TALs+c=
-github.com/pion/dtls/v2 v2.1.5/go.mod h1:BqCE7xPZbPSubGasRoDFJeTsyJtdD1FanJYL0JGheqY=
-github.com/pion/ice/v2 v2.2.6 h1:R/vaLlI1J2gCx141L5PEwtuGAGcyS6e7E0hDeJFq5Ig=
-github.com/pion/ice/v2 v2.2.6/go.mod h1:SWuHiOGP17lGromHTFadUe1EuPgFh/oCU6FCMZHooVE=
-github.com/pion/interceptor v0.1.11 h1:00U6OlqxA3FFB50HSg25J/8cWi7P6FbSzw4eFn24Bvs=
-github.com/pion/interceptor v0.1.11/go.mod h1:tbtKjZY14awXd7Bq0mmWvgtHB5MDaRN7HV3OZ/uy7s8=
-github.com/pion/logging v0.2.2 h1:M9+AIj/+pxNsDfAT64+MAVgJO0rsyLnoJKCqf//DoeY=
-github.com/pion/logging v0.2.2/go.mod h1:k0/tDVsRCX2Mb2ZEmTqNa7CWsQPc+YYCB7Q+5pahoms=
-github.com/pion/mdns v0.0.5 h1:Q2oj/JB3NqfzY9xGZ1fPzZzK7sDSD8rZPOvcIQ10BCw=
-github.com/pion/mdns v0.0.5/go.mod h1:UgssrvdD3mxpi8tMxAXbsppL3vJ4Jipw1mTCW+al01g=
-github.com/pion/randutil v0.1.0 h1:CFG1UdESneORglEsnimhUjf33Rwjubwj6xfiOXBa3mA=
-github.com/pion/randutil v0.1.0/go.mod h1:XcJrSMMbbMRhASFVOlj/5hQial/Y8oH/HVo7TBZq+j8=
-github.com/pion/rtcp v1.2.9 h1:1ujStwg++IOLIEoOiIQ2s+qBuJ1VN81KW+9pMPsif+U=
-github.com/pion/rtcp v1.2.9/go.mod h1:qVPhiCzAm4D/rxb6XzKeyZiQK69yJpbUDJSF7TgrqNo=
-github.com/pion/rtp v1.7.13 h1:qcHwlmtiI50t1XivvoawdCGTP4Uiypzfrsap+bijcoA=
-github.com/pion/rtp v1.7.13/go.mod h1:bDb5n+BFZxXx0Ea7E5qe+klMuqiBrP+w8XSjiWtCUko=
-github.com/pion/sctp v1.8.0/go.mod h1:xFe9cLMZ5Vj6eOzpyiKjT9SwGM4KpK/8Jbw5//jc+0s=
-github.com/pion/sctp v1.8.2 h1:yBBCIrUMJ4yFICL3RIvR4eh/H2BTTvlligmSTy+3kiA=
-github.com/pion/sctp v1.8.2/go.mod h1:xFe9cLMZ5Vj6eOzpyiKjT9SwGM4KpK/8Jbw5//jc+0s=
-github.com/pion/sdp/v3 v3.0.5 h1:ouvI7IgGl+V4CrqskVtr3AaTrPvPisEOxwgpdktctkU=
-github.com/pion/sdp/v3 v3.0.5/go.mod h1:iiFWFpQO8Fy3S5ldclBkpXqmWy02ns78NOKoLLL0YQw=
-github.com/pion/srtp/v2 v2.0.9 h1:JJq3jClmDFBPX/F5roEb0U19jSU7eUhyDqR/NZ34EKQ=
-github.com/pion/srtp/v2 v2.0.9/go.mod h1:5TtM9yw6lsH0ppNCehB/EjEUli7VkUgKSPJqWVqbhQ4=
-github.com/pion/stun v0.3.5 h1:uLUCBCkQby4S1cf6CGuR9QrVOKcvUwFeemaC865QHDg=
-github.com/pion/stun v0.3.5/go.mod h1:gDMim+47EeEtfWogA37n6qXZS88L5V6LqFcf+DZA2UA=
-github.com/pion/transport v0.12.2/go.mod h1:N3+vZQD9HlDP5GWkZ85LohxNsDcNgofQmyL6ojX5d8Q=
-github.com/pion/transport v0.12.3/go.mod h1:OViWW9SP2peE/HbwBvARicmAVnesphkNkCVZIWJ6q9A=
-github.com/pion/transport v0.13.0 h1:KWTA5ZrQogizzYwPEciGtHPLwpAjE91FgXnyu+Hv2uY=
-github.com/pion/transport v0.13.0/go.mod h1:yxm9uXpK9bpBBWkITk13cLo1y5/ur5VQpG22ny6EP7g=
-github.com/pion/turn/v2 v2.0.8 h1:KEstL92OUN3k5k8qxsXHpr7WWfrdp7iJZHx99ud8muw=
-github.com/pion/turn/v2 v2.0.8/go.mod h1:+y7xl719J8bAEVpSXBXvTxStjJv3hbz9YFflvkpcGPw=
-github.com/pion/udp v0.1.1 h1:8UAPvyqmsxK8oOjloDk4wUt63TzFe9WEJkg5lChlj7o=
-github.com/pion/udp v0.1.1/go.mod h1:6AFo+CMdKQm7UiA0eUPA8/eVCTx8jBIITLZHc9DWX5M=
-github.com/pion/webrtc/v3 v3.1.41 h1:QogLjtriu+OwerRp4r6emTg4+zDWUy5R6EqthDBy7c0=
-github.com/pion/webrtc/v3 v3.1.41/go.mod h1:sUcW9SFPEWerDqGOBmdYEMfRvbdd7rgwo4bNzfsXww4=
-github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
-github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
-github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
-github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
-github.com/pkg/profile v1.2.1/go.mod h1:hJw3o1OdXxsrSjjVksARp5W95eeEaEfptyVZyv6JUPA=
-github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
-github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
-github.com/posener/complete v1.1.1/go.mod h1:em0nMJCgc9GFtwrmVmEMR/ZL6WyhyjMBndrE9hABlRI=
-github.com/prometheus/client_golang v0.9.1/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw=
-github.com/prometheus/client_golang v0.9.3-0.20190127221311-3c4408c8b829/go.mod h1:p2iRAGwDERtqlqzRXnrOVns+ignqQo//hLXqYxZYVNs=
-github.com/prometheus/client_golang v1.0.0/go.mod h1:db9x61etRT2tGnBNRi70OPL5FsnadC4Ky3P0J6CfImo=
-github.com/prometheus/client_golang v1.3.0/go.mod h1:hJaj2vgQTGQmVCsAACORcieXFeDPbaTKGT+JTgUa3og=
-github.com/prometheus/client_golang v1.7.1/go.mod h1:PY5Wy2awLA44sXw4AOSfFBetzPP4j5+D6mVACh+pe2M=
-github.com/prometheus/client_golang v1.10.0 h1:/o0BDeWzLWXNZ+4q5gXltUvaMpJqckTa+jTNoB+z4cg=
-github.com/prometheus/client_golang v1.10.0/go.mod h1:WJM3cc3yu7XKBKa/I8WeZm+V3eltZnBwfENSU7mdogU=
-github.com/prometheus/client_model v0.0.0-20180712105110-5c3871d89910/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo=
-github.com/prometheus/client_model v0.0.0-20190115171406-56726106282f/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo=
-github.com/prometheus/client_model v0.0.0-20190129233127-fd36f4220a90/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
-github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
-github.com/prometheus/client_model v0.1.0/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
-github.com/prometheus/client_model v0.2.0 h1:uq5h0d+GuxiXLJLNABMgp2qUWDPiLvgCzz2dUR+/W/M=
-github.com/prometheus/client_model v0.2.0/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
-github.com/prometheus/common v0.2.0/go.mod h1:TNfzLD0ON7rHzMJeJkieUDPYmFC7Snx/y86RQel1bk4=
-github.com/prometheus/common v0.4.1/go.mod h1:TNfzLD0ON7rHzMJeJkieUDPYmFC7Snx/y86RQel1bk4=
-github.com/prometheus/common v0.7.0/go.mod h1:DjGbpBbp5NYNiECxcL/VnbXCCaQpKd3tt26CguLLsqA=
-github.com/prometheus/common v0.10.0/go.mod h1:Tlit/dnDKsSWFlCLTWaA1cyBgKHSMdTB80sz/V91rCo=
-github.com/prometheus/common v0.18.0 h1:WCVKW7aL6LEe1uryfI9dnEc2ZqNB1Fn0ok930v0iL1Y=
-github.com/prometheus/common v0.18.0/go.mod h1:U+gB1OBLb1lF3O42bTCL+FK18tX9Oar16Clt/msog/s=
-github.com/prometheus/procfs v0.0.0-20181005140218-185b4288413d/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk=
-github.com/prometheus/procfs v0.0.0-20190117184657-bf6a532e95b1/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk=
-github.com/prometheus/procfs v0.0.2/go.mod h1:TjEm7ze935MbeOT/UhFTIMYKhuLP4wbCsTZCD3I8kEA=
-github.com/prometheus/procfs v0.0.8/go.mod h1:7Qr8sr6344vo1JqZ6HhLceV9o3AJ1Ff+GxbHq6oeK9A=
-github.com/prometheus/procfs v0.1.3/go.mod h1:lV6e/gmhEcM9IjHGsFOCxxuZ+z1YqCvr4OA4YeYWdaU=
-github.com/prometheus/procfs v0.6.0 h1:mxy4L2jP6qMonqmq+aTtOx1ifVWUgG/TAmntgbh3xv4=
-github.com/prometheus/procfs v0.6.0/go.mod h1:cz+aTbrPOrUb4q7XlbU9ygM+/jj0fzG6c1xBZuNvfVA=
-github.com/rcrowley/go-metrics v0.0.0-20181016184325-3113b8401b8a/go.mod h1:bCqnVzQkZxMG4s8nGwiZ5l3QUCyqpo9Y+/ZMZ9VjZe4=
-github.com/refraction-networking/utls v1.0.0 h1:6XQHSjDmeBCF9sPq8p2zMVGq7Ud3rTD2q88Fw8Tz1tA=
-github.com/refraction-networking/utls v1.0.0/go.mod h1:tz9gX959MEFfFN5whTIocCLUG57WiILqtdVxI8c6Wj0=
-github.com/rogpeppe/fastuuid v0.0.0-20150106093220-6724a57986af/go.mod h1:XWv6SoW27p1b0cqNHllgS5HIMJraePCO15w5zCzIWYg=
-github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4=
-github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
-github.com/ryanuber/columnize v0.0.0-20160712163229-9b3edd62028f/go.mod h1:sm1tb6uqfes/u+d4ooFouqFdy9/2g9QGwK3SQygK0Ts=
-github.com/samuel/go-zookeeper v0.0.0-20190923202752-2cc03de413da/go.mod h1:gi+0XIa01GRL2eRQVjQkKGqKF3SF9vZR/HnPullcV2E=
-github.com/sclevine/agouti v3.0.0+incompatible/go.mod h1:b4WX9W9L1sfQKXeJf1mUTLZKJ48R1S7H23Ji7oFO5Bw=
-github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529/go.mod h1:DxrIzT+xaE7yg65j358z/aeFdxmN0P9QXhEzd20vsDc=
-github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc=
-github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo=
-github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE=
-github.com/sirupsen/logrus v1.6.0/go.mod h1:7uNnSEd1DgxDLC74fIahvMZmmYsHGZGEOFrfsX/uA88=
-github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d h1:zE9ykElWQ6/NYmHa3jpm/yHnI4xSofP+UP6SpjHcSeM=
-github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc=
-github.com/smartystreets/goconvey v1.6.4 h1:fv0U8FUIMPNf1L9lnHLvLhgicrIVChEkdzIKYqbNC9s=
-github.com/smartystreets/goconvey v1.6.4/go.mod h1:syvi0/a8iFYH4r/RixwvyeAJjdLS9QV7WQ/tjFTllLA=
-github.com/soheilhy/cmux v0.1.4/go.mod h1:IM3LyeVVIOuxMH7sFAkER9+bJ4dT7Ms6E4xg4kGIyLM=
-github.com/sony/gobreaker v0.4.1/go.mod h1:ZKptC7FHNvhBz7dN2LGjPVBz2sZJmc0/PkyDJOjmxWY=
-github.com/spf13/cobra v0.0.3/go.mod h1:1l0Ry5zgKvJasoi3XT1TypsSe7PqH0Sj9dhYf7v3XqQ=
-github.com/spf13/pflag v1.0.1/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4=
-github.com/streadway/amqp v0.0.0-20190404075320-75d898a42a94/go.mod h1:AZpEONHx3DKn8O/DFsRAY58/XVQiIPMTMB1SddzLXVw=
-github.com/streadway/amqp v0.0.0-20190827072141-edfb9018d271/go.mod h1:AZpEONHx3DKn8O/DFsRAY58/XVQiIPMTMB1SddzLXVw=
-github.com/streadway/handy v0.0.0-20190108123426-d5acb3125c2a/go.mod h1:qNTQ5P5JnDBl6z3cMAg/SywNDC5ABu5ApDIw6lUbRmI=
-github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
-github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
-github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
-github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
-github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
-github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA=
-github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
-github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
-github.com/stretchr/testify v1.7.1 h1:5TQK59W5E3v0r2duFAb7P95B6hEeOyEnHRa8MjYSMTY=
-github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
-github.com/templexxx/cpu v0.0.1/go.mod h1:w7Tb+7qgcAlIyX4NhLuDKt78AHA5SzPmq0Wj6HiEnnk=
-github.com/templexxx/cpu v0.0.7 h1:pUEZn8JBy/w5yzdYWgx+0m0xL9uk6j4K91C5kOViAzo=
-github.com/templexxx/cpu v0.0.7/go.mod h1:w7Tb+7qgcAlIyX4NhLuDKt78AHA5SzPmq0Wj6HiEnnk=
-github.com/templexxx/xorsimd v0.4.1 h1:iUZcywbOYDRAZUasAs2eSCUW8eobuZDy0I9FJiORkVg=
-github.com/templexxx/xorsimd v0.4.1/go.mod h1:W+ffZz8jJMH2SXwuKu9WhygqBMbFnp14G2fqEr8qaNo=
-github.com/tjfoc/gmsm v1.3.2 h1:7JVkAn5bvUJ7HtU08iW6UiD+UTmJTIToHCfeFzkcCxM=
-github.com/tjfoc/gmsm v1.3.2/go.mod h1:HaUcFuY0auTiaHB9MHFGCPx5IaLhTUd2atbCFBQXn9w=
-github.com/tmc/grpc-websocket-proxy v0.0.0-20170815181823-89b8d40f7ca8/go.mod h1:ncp9v5uamzpCO7NfCPTXjqaC+bZgJeR0sMTm6dMHP7U=
-github.com/urfave/cli v1.20.0/go.mod h1:70zkFmudgCuE/ngEzBv17Jvp/497gISqfk5gWijbERA=
-github.com/urfave/cli v1.22.1/go.mod h1:Gos4lmkARVdJ6EkW0WaNv/tZAAMe9V7XWyB60NtXRu0=
-github.com/xiang90/probing v0.0.0-20190116061207-43a291ad63a2/go.mod h1:UETIi67q53MR2AWcXfiuqkDkRtnGDLqkBTpCHuJHxtU=
-github.com/xtaci/kcp-go/v5 v5.6.1 h1:Pwn0aoeNSPF9dTS7IgiPXn0HEtaIlVb6y5UKWPsx8bI=
-github.com/xtaci/kcp-go/v5 v5.6.1/go.mod h1:W3kVPyNYwZ06p79dNwFWQOVFrdcBpDBsdyvK8moQrYo=
-github.com/xtaci/lossyconn v0.0.0-20190602105132-8df528c0c9ae h1:J0GxkO96kL4WF+AIT3M4mfUVinOCPgf2uUWYFUzN0sM=
-github.com/xtaci/lossyconn v0.0.0-20190602105132-8df528c0c9ae/go.mod h1:gXtu8J62kEgmN++bm9BVICuT/e8yiLI2KFobd/TRFsE=
-github.com/xtaci/smux v1.5.15 h1:6hMiXswcleXj5oNfcJc+DXS8Vj36XX2LaX98udog6Kc=
-github.com/xtaci/smux v1.5.15/go.mod h1:OMlQbT5vcgl2gb49mFkYo6SMf+zP3rcjcwQz7ZU7IGY=
-github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
-github.com/yuin/goldmark v1.1.32/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
-github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
-gitlab.torproject.org/tpo/anti-censorship/geoip v0.0.0-20210928150955-7ce4b3d98d01 h1:4949mHh9Vj2/okk48yG8nhP6TosFWOUfSfSr502sKGE=
-gitlab.torproject.org/tpo/anti-censorship/geoip v0.0.0-20210928150955-7ce4b3d98d01/go.mod h1:K3LOI4H8fa6j+7E10ViHeGEQV10304FG4j94ypmKLjY=
-go.etcd.io/bbolt v1.3.3/go.mod h1:IbVyRI1SCnLcuJnV2u8VeU0CEYM7e686BmAb1XKL+uU=
-go.etcd.io/etcd v0.0.0-20191023171146-3cf2f69b5738/go.mod h1:dnLIgRNXwCJa5e+c6mIZCrds/GIG4ncV9HhK5PX7jPg=
-go.opencensus.io v0.20.1/go.mod h1:6WKK9ahsWS3RSO+PY9ZHZUfv2irvY6gN279GOPZjmmk=
-go.opencensus.io v0.20.2/go.mod h1:6WKK9ahsWS3RSO+PY9ZHZUfv2irvY6gN279GOPZjmmk=
-go.opencensus.io v0.22.2/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=
-go.uber.org/atomic v1.3.2/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE=
-go.uber.org/atomic v1.5.0/go.mod h1:sABNBOSYdrvTF6hTgEIbc7YasKWGhgEQZyfxyTvoXHQ=
-go.uber.org/multierr v1.1.0/go.mod h1:wR5kodmAFQ0UK8QlbwjlSNy0Z68gJhDJUG5sjR94q/0=
-go.uber.org/multierr v1.3.0/go.mod h1:VgVr7evmIr6uPjLBxg28wmKNXyqE9akIJ5XnfpiKl+4=
-go.uber.org/tools v0.0.0-20190618225709-2cfd321de3ee/go.mod h1:vJERXedbb3MVM5f9Ejo0C68/HhF8uaILCdgjnY+goOA=
-go.uber.org/zap v1.10.0/go.mod h1:vwi/ZaCAaUcBkycHslxD9B2zi4UTXhF60s6SWpuDF0Q=
-go.uber.org/zap v1.13.0/go.mod h1:zwrFLgMcdUuIBviXEYEH1YKNaOBnKXsx2IPda5bBwHM=
-golang.org/x/arch v0.0.0-20190909030613-46d78d1859ac/go.mod h1:flIaEI6LNU6xOCD5PaJvn9wGP0agmIOqjrtsKGRguv4=
-golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
-golang.org/x/crypto v0.0.0-20181029021203-45a5f77698d3/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
-golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
-golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
-golang.org/x/crypto v0.0.0-20190701094942-4def268fd1a4/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
-golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
-golang.org/x/crypto v0.0.0-20191219195013-becbf705a915/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
-golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
-golang.org/x/crypto v0.0.0-20200728195943-123391ffb6de/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
-golang.org/x/crypto v0.0.0-20220131195533-30dcbda58838/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
-golang.org/x/crypto v0.0.0-20220427172511-eb4f295cb31f/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
-golang.org/x/crypto v0.0.0-20220516162934-403b01795ae8 h1:y+mHpWoQJNAHt26Nhh6JP7hvM71IRZureyvZhoVALIs=
-golang.org/x/crypto v0.0.0-20220516162934-403b01795ae8/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
-golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
-golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
-golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
-golang.org/x/lint v0.0.0-20190301231843-5614ed5bae6f/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
-golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
-golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
-golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc=
-golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg=
-golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
-golang.org/x/mod v0.3.0 h1:RM4zey1++hCTbCVQfnWeKs9/IEsaBLA8vTkd0WVtmH4=
-golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
-golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
-golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
-golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
-golang.org/x/net v0.0.0-20181023162649-9b4f9f5ad519/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
-golang.org/x/net v0.0.0-20181114220301-adae6a3d119a/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
-golang.org/x/net v0.0.0-20181201002055-351d144fa1fc/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
-golang.org/x/net v0.0.0-20181220203305-927f97764cc3/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
-golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
-golang.org/x/net v0.0.0-20190125091013-d26f9f9a57f3/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
-golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
-golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
-golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
-golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks=
-golang.org/x/net v0.0.0-20190613194153-d28f0bde5980/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
-golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
-golang.org/x/net v0.0.0-20190813141303-74dc4d7220e7/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
-golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
-golang.org/x/net v0.0.0-20200520004742-59133d7f0dd7/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
-golang.org/x/net v0.0.0-20200625001655-4c5254603344/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA=
-golang.org/x/net v0.0.0-20200707034311-ab3426394381/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA=
-golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
-golang.org/x/net v0.0.0-20201201195509-5d6afe98e0b7/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
-golang.org/x/net v0.0.0-20210119194325-5f4716e94777/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
-golang.org/x/net v0.0.0-20210428140749-89ef3d95e781/go.mod h1:OJAsFXCWl8Ukc7SiCT/9KSuxbyM7479/AVlXFRxuMCk=
-golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
-golang.org/x/net v0.0.0-20211201190559-0a0e4e1bb54c/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
-golang.org/x/net v0.0.0-20220127200216-cd36cc0744dd/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk=
-golang.org/x/net v0.0.0-20220401154927-543a649e0bdd/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk=
-golang.org/x/net v0.0.0-20220425223048-2871e0cb64e4 h1:HVyaeDAYux4pnY+D/SiwmLOR36ewZ4iGQIIrtnuCjFA=
-golang.org/x/net v0.0.0-20220425223048-2871e0cb64e4/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk=
-golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
-golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
-golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
-golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
-golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
-golang.org/x/sync v0.0.0-20190227155943-e225da77a7e6/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
-golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
-golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
-golang.org/x/sync v0.0.0-20200625203802-6e8e738ad208/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
-golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
-golang.org/x/sync v0.0.0-20201207232520-09787c993a3a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
-golang.org/x/sys v0.0.0-20180823144017-11551d06cbcc/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
-golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
-golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
-golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
-golang.org/x/sys v0.0.0-20181026203630-95b1ffbd15a5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
-golang.org/x/sys v0.0.0-20181107165924-66b7b1311ac8/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
-golang.org/x/sys v0.0.0-20181116152217-5ac8a444bdc5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
-golang.org/x/sys v0.0.0-20181122145206-62eef0e2fa9b/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
-golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
-golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20190502145724-3ef323f4f1fd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20190726091711-fc99dfbffb4e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20190826190057-c7b8b68b1456/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20190904154756-749cb33beabd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20191005200804-aed5e4c7ecf9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20191120155948-bd437916bb0e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20191220142924-d4481acd189f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20200106162015-b016eb3dc98e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20200615200032-f1bc736245b1/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20200625212154-ddb9806d33ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20200808120158-1030fc2bf1d9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20210112080510-489259a85091/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20210309074719-68d13333faf2/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e h1:fLOSk5Q00efkSvAm+4xcoXD+RRmLmmulPn5I3Y9F2EM=
-golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
-golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
-golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
-golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
-golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
-golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
-golang.org/x/text v0.3.7 h1:olpwvP2KacW1ZWvsR7uQhoyTYvKAupfQrRGBFM352Gk=
-golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
-golang.org/x/time v0.0.0-20180412165947-fbb02b2291d2/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
-golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
-golang.org/x/tools v0.0.0-20180221164845-07fd8470d635/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
-golang.org/x/tools v0.0.0-20180828015842-6cd1fcedba52/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
-golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
-golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
-golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY=
-golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
-golang.org/x/tools v0.0.0-20190312170243-e65039ee4138/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
-golang.org/x/tools v0.0.0-20190328211700-ab21143f2384/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
-golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
-golang.org/x/tools v0.0.0-20190621195816-6e04913cbbac/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc=
-golang.org/x/tools v0.0.0-20191029041327-9cc4af7d6b2c/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
-golang.org/x/tools v0.0.0-20191029190741-b9c20aec41a5/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
-golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
-golang.org/x/tools v0.0.0-20200103221440-774c71fcf114/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
-golang.org/x/tools v0.0.0-20200425043458-8463f397d07c/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
-golang.org/x/tools v0.0.0-20200808161706-5bf02b21f123/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA=
-golang.org/x/tools v0.0.0-20201224043029-2b0845dc783e h1:4nW4NLDYnU28ojHaHO8OVxFHk/aQ33U01a9cjED+pzE=
-golang.org/x/tools v0.0.0-20201224043029-2b0845dc783e/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
-golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
-golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
-golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
-golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 h1:go1bK/D/BFZV2I8cIQd1NKEZ+0owSTG1fDTci4IqFcE=
-golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
-google.golang.org/api v0.3.1/go.mod h1:6wY9I6uQWHQ8EM57III9mq/AjF+i8G65rmVagqKMtkk=
-google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
-google.golang.org/appengine v1.2.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
-google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
-google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc=
-google.golang.org/genproto v0.0.0-20190307195333-5fe7a883aa19/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
-google.golang.org/genproto v0.0.0-20190425155659-357c62f0e4bb/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
-google.golang.org/genproto v0.0.0-20190530194941-fb225487d101/go.mod h1:z3L6/3dTEVtUr6QSP8miRzeRqwQOioJ9I66odjN4I7s=
-google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc=
-google.golang.org/grpc v1.17.0/go.mod h1:6QZJwpn2B+Zp71q/5VxRsJ6NXXVCE5NRUHRo+f3cWCs=
-google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
-google.golang.org/grpc v1.20.0/go.mod h1:chYK+tFQF0nDUGJgXMSgLCQk3phJEuONr2DCgLDdAQM=
-google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38=
-google.golang.org/grpc v1.21.0/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM=
-google.golang.org/grpc v1.22.1/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg=
-google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg=
-google.golang.org/grpc v1.23.1/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg=
-google.golang.org/grpc v1.26.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk=
-google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
-google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
-google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM=
-google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE=
-google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo=
-google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
-google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
-google.golang.org/protobuf v1.26.0 h1:bxAC2xTBsZGibn2RTntX0oH50xLsqy1OxA9tTL3p/lk=
-google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
-gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw=
-gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
-gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
-gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo=
-gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
-gopkg.in/cheggaaa/pb.v1 v1.0.25/go.mod h1:V/YB90LKu/1FcN3WVnfiiE5oMCibMjukxqG/qStrOgw=
-gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
-gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys=
-gopkg.in/gcfg.v1 v1.2.3/go.mod h1:yesOnuUOFQAhST5vPY4nbZsb/huCgGGXlipJsBn0b3o=
-gopkg.in/resty.v1 v1.12.0/go.mod h1:mDo4pnntr5jdWRML875a/NmxYqAlA73dVijT2AXvQQo=
-gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw=
-gopkg.in/warnings.v0 v0.1.2/go.mod h1:jksf8JmL6Qr/oQM2OXTHunEvvTAsrWBLb6OOjuVWRNI=
-gopkg.in/yaml.v2 v2.0.0-20170812160011-eb3733d160e7/go.mod h1:JAlM8MvJe8wmxCU4Bli9HhUf9+ttbYbLASfIpnQbh74=
-gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
-gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
-gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
-gopkg.in/yaml.v2 v2.2.5/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
-gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
-gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
-gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo=
-gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
-honnef.co/go/tools v0.0.0-20180728063816-88497007e858/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
-honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
-honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
-honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg=
-rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4=
-sigs.k8s.io/yaml v1.1.0/go.mod h1:UJmg0vDUVViEyp3mgSv9WPwZCDxu4rQW1olrI1uml+o=
-sourcegraph.com/sourcegraph/appdash v0.0.0-20190731080439-ebfcffb1b5c0/go.mod h1:hI742Nqp5OhwiqlzhgfbWU4mW4yO10fP+LoT9WOswdU=
diff --git a/probetest/Dockerfile b/probetest/Dockerfile
deleted file mode 100644
index 966ab28..0000000
--- a/probetest/Dockerfile
+++ /dev/null
@@ -1,3 +0,0 @@
-FROM golang:1.13
-
-COPY probetest /go/bin
diff --git a/probetest/README.md b/probetest/README.md
deleted file mode 100644
index 44c7837..0000000
--- a/probetest/README.md
+++ /dev/null
@@ -1,53 +0,0 @@
-
-
-**Table of Contents**
-
-- [Overview](#overview)
-- [Running your own](#running-your-own)
-
-
-
-This is code for a remote probe test component of Snowflake.
-
-### Overview
-
-This is a probe test server to allow proxies to test their compatability
-with Snowflake. Right now the only type of test implemented is a
-compatability check for clients with symmetric NATs.
-
-### Running your own
-
-The server uses TLS by default.
-There is a `--disable-tls` option for testing purposes,
-but you should use TLS in production.
-
-To build the probe server, run
-```go build```
-
-To deploy the probe server, first set the necessary env variables with
-```
-export HOSTNAMES=${YOUR HOSTNAMES}
-export EMAIL=${YOUR EMAIL}
-```
-then run ```docker-compose up```
-
-Setting up a symmetric NAT configuration requires a few extra steps. After
-upping the docker container, run
-```docker inspect snowflake-probetest```
-to find the subnet used by the probetest container. Then run
-```sudo iptables -L -t nat``` to find the POSTROUTING rules for the subnet.
-It should look something like this:
-```
-Chain POSTROUTING (policy ACCEPT)
-target     prot opt source               destination
-MASQUERADE  all  --  172.19.0.0/16        anywhere
-```
-to modify this rule, execute the command
-```sudo iptables -t nat -R POSTROUTING $RULE_NUM -s 172.19.0.0/16 -j MASQUERADE --random```
-where RULE_NUM is the numbered rule corresponding to your docker container's subnet masquerade rule.
-Afterwards, you should see the rule changed to be:
-```
-Chain POSTROUTING (policy ACCEPT)
-target     prot opt source               destination
-MASQUERADE  all  --  172.19.0.0/16        anywhere      random
-```
diff --git a/probetest/docker-compose.yml b/probetest/docker-compose.yml
deleted file mode 100644
index 9283383..0000000
--- a/probetest/docker-compose.yml
+++ /dev/null
@@ -1,11 +0,0 @@
- version: "3.8"
-
- services:
-    snowflake-probetest:
-        build: .
-        container_name: snowflake-probetest
-        ports:
-         - "8443:8443"
-        volumes:
-        - /home/snowflake-broker/acme-cert-cache:/go/bin/acme-cert-cache
-        entrypoint: [ "probetest" , "-addr", ":8443" , "-acme-hostnames", $HOSTNAMES, "-acme-email", $EMAIL, "-acme-cert-cache", "/go/bin/acme-cert-cache"]
diff --git a/probetest/probetest.go b/probetest/probetest.go
deleted file mode 100644
index 4b8baa4..0000000
--- a/probetest/probetest.go
+++ /dev/null
@@ -1,238 +0,0 @@
-/*
-Probe test server to check the reachability of Snowflake proxies from
-clients with symmetric NATs.
-
-The probe server receives an offer from a proxy, returns an answer, and then
-attempts to establish a datachannel connection to that proxy. The proxy will
-self-determine whether the connection opened successfully.
-*/
-package main
-
-import (
-	"crypto/tls"
-	"flag"
-	"fmt"
-	"io"
-	"io/ioutil"
-	"log"
-	"net/http"
-	"os"
-	"strings"
-	"time"
-
-	"git.torproject.org/pluggable-transports/snowflake.git/v2/common/messages"
-	"git.torproject.org/pluggable-transports/snowflake.git/v2/common/safelog"
-	"git.torproject.org/pluggable-transports/snowflake.git/v2/common/util"
-
-	"github.com/pion/webrtc/v3"
-	"golang.org/x/crypto/acme/autocert"
-)
-
-const (
-	readLimit          = 100000                         //Maximum number of bytes to be read from an HTTP request
-	dataChannelTimeout = 20 * time.Second               //time after which we assume proxy data channel will not open
-	stunUrl            = "stun:stun.l.google.com:19302" //default STUN URL
-)
-
-// Create a PeerConnection from an SDP offer. Blocks until the gathering of ICE
-// candidates is complete and the answer is available in LocalDescription.
-func makePeerConnectionFromOffer(sdp *webrtc.SessionDescription,
-	dataChan chan struct{}) (*webrtc.PeerConnection, error) {
-
-	config := webrtc.Configuration{
-		ICEServers: []webrtc.ICEServer{
-			{
-				URLs: []string{stunUrl},
-			},
-		},
-	}
-	pc, err := webrtc.NewPeerConnection(config)
-	if err != nil {
-		return nil, fmt.Errorf("accept: NewPeerConnection: %s", err)
-	}
-	pc.OnDataChannel(func(dc *webrtc.DataChannel) {
-		dc.OnOpen(func() {
-			close(dataChan)
-		})
-		dc.OnClose(func() {
-			dc.Close()
-		})
-	})
-	// As of v3.0.0, pion-webrtc uses trickle ICE by default.
-	// We have to wait for candidate gathering to complete
-	// before we send the offer
-	done := webrtc.GatheringCompletePromise(pc)
-	err = pc.SetRemoteDescription(*sdp)
-	if err != nil {
-		if inerr := pc.Close(); inerr != nil {
-			log.Printf("unable to call pc.Close after pc.SetRemoteDescription with error: %v", inerr)
-		}
-		return nil, fmt.Errorf("accept: SetRemoteDescription: %s", err)
-	}
-
-	answer, err := pc.CreateAnswer(nil)
-	if err != nil {
-		if inerr := pc.Close(); inerr != nil {
-			log.Printf("ICE gathering has generated an error when calling pc.Close: %v", inerr)
-		}
-		return nil, err
-	}
-
-	err = pc.SetLocalDescription(answer)
-	if err != nil {
-		if err = pc.Close(); err != nil {
-			log.Printf("pc.Close after setting local description returned : %v", err)
-		}
-		return nil, err
-	}
-	// Wait for ICE candidate gathering to complete
-	<-done
-	return pc, nil
-}
-
-func probeHandler(w http.ResponseWriter, r *http.Request) {
-	w.Header().Set("Access-Control-Allow-Origin", "*")
-	resp, err := ioutil.ReadAll(http.MaxBytesReader(w, r.Body, readLimit))
-	if nil != err {
-		log.Println("Invalid data.")
-		w.WriteHeader(http.StatusBadRequest)
-		return
-	}
-
-	offer, _, err := messages.DecodePollResponse(resp)
-	if err != nil {
-		log.Printf("Error reading offer: %s", err.Error())
-		w.WriteHeader(http.StatusBadRequest)
-		return
-	}
-	if offer == "" {
-		log.Printf("Error processing session description: %s", err.Error())
-		w.WriteHeader(http.StatusBadRequest)
-		return
-	}
-	sdp, err := util.DeserializeSessionDescription(offer)
-	if err != nil {
-		log.Printf("Error processing session description: %s", err.Error())
-		w.WriteHeader(http.StatusBadRequest)
-		return
-	}
-
-	dataChan := make(chan struct{})
-	pc, err := makePeerConnectionFromOffer(sdp, dataChan)
-	if err != nil {
-		log.Printf("Error making WebRTC connection: %s", err)
-		w.WriteHeader(http.StatusInternalServerError)
-		return
-	}
-
-	sdp = &webrtc.SessionDescription{
-		Type: pc.LocalDescription().Type,
-		SDP:  util.StripLocalAddresses(pc.LocalDescription().SDP),
-	}
-	answer, err := util.SerializeSessionDescription(sdp)
-	if err != nil {
-		log.Printf("Error making WebRTC connection: %s", err)
-		w.WriteHeader(http.StatusInternalServerError)
-		return
-	}
-	body, err := messages.EncodeAnswerRequest(answer, "stub-sid")
-	if err != nil {
-		log.Printf("Error making WebRTC connection: %s", err)
-		w.WriteHeader(http.StatusInternalServerError)
-		return
-	}
-
-	w.Write(body)
-	// Set a timeout on peerconnection. If the connection state has not
-	// advanced to PeerConnectionStateConnected in this time,
-	// destroy the peer connection and return the token.
-	go func() {
-		timer := time.NewTimer(dataChannelTimeout)
-		defer timer.Stop()
-
-		select {
-		case <-dataChan:
-		case <-timer.C:
-		}
-
-		if err := pc.Close(); err != nil {
-			log.Printf("Error calling pc.Close: %v", err)
-		}
-	}()
-	return
-
-}
-
-func main() {
-	var acmeEmail string
-	var acmeHostnamesCommas string
-	var acmeCertCacheDir string
-	var addr string
-	var disableTLS bool
-	var certFilename, keyFilename string
-	var unsafeLogging bool
-
-	flag.StringVar(&acmeEmail, "acme-email", "", "optional contact email for Let's Encrypt notifications")
-	flag.StringVar(&acmeHostnamesCommas, "acme-hostnames", "", "comma-separated hostnames for TLS certificate")
-	flag.StringVar(&acmeCertCacheDir, "acme-cert-cache", "acme-cert-cache", "directory in which certificates should be cached")
-	flag.StringVar(&certFilename, "cert", "", "TLS certificate file")
-	flag.StringVar(&keyFilename, "key", "", "TLS private key file")
-	flag.StringVar(&addr, "addr", ":8443", "address to listen on")
-	flag.BoolVar(&disableTLS, "disable-tls", false, "don't use HTTPS")
-	flag.BoolVar(&unsafeLogging, "unsafe-logging", false, "prevent logs from being scrubbed")
-	flag.Parse()
-
-	var logOutput io.Writer = os.Stderr
-	if unsafeLogging {
-		log.SetOutput(logOutput)
-	} else {
-		// Scrub log output just in case an address ends up there
-		log.SetOutput(&safelog.LogScrubber{Output: logOutput})
-	}
-
-	log.SetFlags(log.LstdFlags | log.LUTC)
-
-	http.HandleFunc("/probe", probeHandler)
-
-	server := http.Server{
-		Addr: addr,
-	}
-
-	var err error
-	if acmeHostnamesCommas != "" {
-		acmeHostnames := strings.Split(acmeHostnamesCommas, ",")
-		log.Printf("ACME hostnames: %q", acmeHostnames)
-
-		var cache autocert.Cache
-		if err = os.MkdirAll(acmeCertCacheDir, 0700); err != nil {
-			log.Printf("Warning: Couldn't create cache directory %q (reason: %s) so we're *not* using our certificate cache.", acmeCertCacheDir, err)
-		} else {
-			cache = autocert.DirCache(acmeCertCacheDir)
-		}
-
-		certManager := autocert.Manager{
-			Cache:      cache,
-			Prompt:     autocert.AcceptTOS,
-			HostPolicy: autocert.HostWhitelist(acmeHostnames...),
-			Email:      acmeEmail,
-		}
-		// start certificate manager handler
-		go func() {
-			log.Printf("Starting HTTP-01 listener")
-			log.Fatal(http.ListenAndServe(":80", certManager.HTTPHandler(nil)))
-		}()
-
-		server.TLSConfig = &tls.Config{GetCertificate: certManager.GetCertificate}
-		err = server.ListenAndServeTLS("", "")
-	} else if certFilename != "" && keyFilename != "" {
-		err = server.ListenAndServeTLS(certFilename, keyFilename)
-	} else if disableTLS {
-		err = server.ListenAndServe()
-	} else {
-		log.Fatal("the --cert and --key, --acme-hostnames, or --disable-tls option is required")
-	}
-
-	if err != nil {
-		log.Println(err)
-	}
-}
diff --git a/proxy-go/README.md b/proxy-go/README.md
new file mode 100644
index 0000000..264fc4f
--- /dev/null
+++ b/proxy-go/README.md
@@ -0,0 +1,3 @@
+This is a standalone (not browser-based) version of the Snowflake proxy.
+
+Usage: ./proxy-go
diff --git a/proxy-go/snowflake.go b/proxy-go/snowflake.go
new file mode 100644
index 0000000..9b7dad2
--- /dev/null
+++ b/proxy-go/snowflake.go
@@ -0,0 +1,482 @@
+package main
+
+import (
+	"bytes"
+	"crypto/rand"
+	"encoding/base64"
+	"encoding/json"
+	"flag"
+	"fmt"
+	"io"
+	"io/ioutil"
+	"log"
+	"net"
+	"net/http"
+	"net/url"
+	"os"
+	"regexp"
+	"strings"
+	"sync"
+	"time"
+
+	"git.torproject.org/pluggable-transports/snowflake.git/common/safelog"
+	"github.com/pion/webrtc"
+	"golang.org/x/net/websocket"
+)
+
+const defaultBrokerURL = "https://snowflake-broker.bamsoftware.com/"
+const defaultRelayURL = "wss://snowflake.bamsoftware.com/"
+const defaultSTUNURL = "stun:stun.l.google.com:19302"
+const pollInterval = 5 * time.Second
+
+//amount of time after sending an SDP answer before the proxy assumes the
+//client is not going to connect
+const dataChannelTimeout = 20 * time.Second
+
+const readLimit = 100000 //Maximum number of bytes to be read from an HTTP request
+
+var brokerURL *url.URL
+var relayURL string
+
+const (
+	sessionIDLength = 16
+)
+
+var (
+	tokens chan bool
+	config webrtc.Configuration
+	client http.Client
+)
+
+var remoteIPPatterns = []*regexp.Regexp{
+	/* IPv4 */
+	regexp.MustCompile(`(?m)^c=IN IP4 ([\d.]+)(?:(?:\/\d+)?\/\d+)?(:? |\r?\n)`),
+	/* IPv6 */
+	regexp.MustCompile(`(?m)^c=IN IP6 ([0-9A-Fa-f:.]+)(?:\/\d+)?(:? |\r?\n)`),
+}
+
+// https://tools.ietf.org/html/rfc4566#section-5.7
+func remoteIPFromSDP(sdp string) net.IP {
+	for _, pattern := range remoteIPPatterns {
+		m := pattern.FindStringSubmatch(sdp)
+		if m != nil {
+			// Ignore parsing errors, ParseIP returns nil.
+			return net.ParseIP(m[1])
+		}
+	}
+	return nil
+}
+
+type webRTCConn struct {
+	dc *webrtc.DataChannel
+	pc *webrtc.PeerConnection
+	pr *io.PipeReader
+
+	lock sync.Mutex // Synchronization for DataChannel destruction
+	once sync.Once  // Synchronization for PeerConnection destruction
+}
+
+func (c *webRTCConn) Read(b []byte) (int, error) {
+	return c.pr.Read(b)
+}
+
+func (c *webRTCConn) Write(b []byte) (int, error) {
+	c.lock.Lock()
+	defer c.lock.Unlock()
+	// log.Printf("webrtc Write %d %+q", len(b), string(b))
+	log.Printf("Write %d bytes --> WebRTC", len(b))
+	if c.dc != nil {
+		c.dc.Send(b)
+	}
+	return len(b), nil
+}
+
+func (c *webRTCConn) Close() (err error) {
+	c.once.Do(func() {
+		err = c.pc.Close()
+	})
+	return
+}
+
+func (c *webRTCConn) LocalAddr() net.Addr {
+	return nil
+}
+
+func (c *webRTCConn) RemoteAddr() net.Addr {
+	//Parse Remote SDP offer and extract client IP
+	clientIP := remoteIPFromSDP(c.pc.RemoteDescription().SDP)
+	if clientIP == nil {
+		return nil
+	}
+	return &net.IPAddr{IP: clientIP, Zone: ""}
+}
+
+func (c *webRTCConn) SetDeadline(t time.Time) error {
+	// nolint: golint
+	return fmt.Errorf("SetDeadline not implemented")
+}
+
+func (c *webRTCConn) SetReadDeadline(t time.Time) error {
+	// nolint: golint
+	return fmt.Errorf("SetReadDeadline not implemented")
+}
+
+func (c *webRTCConn) SetWriteDeadline(t time.Time) error {
+	// nolint: golint
+	return fmt.Errorf("SetWriteDeadline not implemented")
+}
+
+func getToken() {
+	<-tokens
+}
+
+func retToken() {
+	tokens <- true
+}
+
+func genSessionID() string {
+	buf := make([]byte, sessionIDLength)
+	_, err := rand.Read(buf)
+	if err != nil {
+		panic(err.Error())
+	}
+	return strings.TrimRight(base64.StdEncoding.EncodeToString(buf), "=")
+}
+
+func limitedRead(r io.Reader, limit int64) ([]byte, error) {
+	p, err := ioutil.ReadAll(&io.LimitedReader{R: r, N: limit + 1})
+	if err != nil {
+		return p, err
+	} else if int64(len(p)) == limit+1 {
+		return p[0:limit], io.ErrUnexpectedEOF
+	}
+	return p, err
+}
+
+func pollOffer(sid string) *webrtc.SessionDescription {
+	broker := brokerURL.ResolveReference(&url.URL{Path: "proxy"})
+	timeOfNextPoll := time.Now()
+	for {
+		// Sleep until we're scheduled to poll again.
+		now := time.Now()
+		time.Sleep(timeOfNextPoll.Sub(now))
+		// Compute the next time to poll -- if it's in the past, that
+		// means that the POST took longer than pollInterval, so we're
+		// allowed to do another one immediately.
+		timeOfNextPoll = timeOfNextPoll.Add(pollInterval)
+		if timeOfNextPoll.Before(now) {
+			timeOfNextPoll = now
+		}
+
+		req, _ := http.NewRequest("POST", broker.String(), bytes.NewBuffer([]byte(sid)))
+		req.Header.Set("X-Session-ID", sid)
+		resp, err := client.Do(req)
+		if err != nil {
+			log.Printf("error polling broker: %s", err)
+		} else {
+			defer resp.Body.Close()
+			if resp.StatusCode != http.StatusOK {
+				log.Printf("broker returns: %d", resp.StatusCode)
+			} else {
+				body, err := limitedRead(resp.Body, readLimit)
+				if err != nil {
+					log.Printf("error reading broker response: %s", err)
+				} else {
+					return deserializeSessionDescription(string(body))
+				}
+			}
+		}
+	}
+}
+
+func sendAnswer(sid string, pc *webrtc.PeerConnection) error {
+	broker := brokerURL.ResolveReference(&url.URL{Path: "answer"})
+	body := bytes.NewBuffer([]byte(serializeSessionDescription(pc.LocalDescription())))
+	req, _ := http.NewRequest("POST", broker.String(), body)
+	req.Header.Set("X-Session-ID", sid)
+	resp, err := client.Do(req)
+	if err != nil {
+		return err
+	}
+	if resp.StatusCode != http.StatusOK {
+		return fmt.Errorf("broker returned %d", resp.StatusCode)
+	}
+	return nil
+}
+
+func CopyLoop(c1 net.Conn, c2 net.Conn) {
+	var wg sync.WaitGroup
+	copyer := func(dst io.ReadWriteCloser, src io.ReadWriteCloser) {
+		defer wg.Done()
+		if _, err := io.Copy(dst, src); err != nil {
+			log.Printf("io.Copy inside CopyLoop generated an error: %v", err)
+		}
+		dst.Close()
+		src.Close()
+	}
+	wg.Add(2)
+	go copyer(c1, c2)
+	go copyer(c2, c1)
+	wg.Wait()
+}
+
+// We pass conn.RemoteAddr() as an additional parameter, rather than calling
+// conn.RemoteAddr() inside this function, as a workaround for a hang that
+// otherwise occurs inside of conn.pc.RemoteDescription() (called by
+// RemoteAddr). https://bugs.torproject.org/18628#comment:8
+func datachannelHandler(conn *webRTCConn, remoteAddr net.Addr) {
+	defer conn.Close()
+	defer retToken()
+
+	u, err := url.Parse(relayURL)
+	if err != nil {
+		log.Fatalf("invalid relay url: %s", err)
+	}
+
+	// Retrieve client IP address
+	if remoteAddr != nil {
+		// Encode client IP address in relay URL
+		q := u.Query()
+		clientIP := remoteAddr.String()
+		q.Set("client_ip", clientIP)
+		u.RawQuery = q.Encode()
+	} else {
+		log.Printf("no remote address given in websocket")
+	}
+
+	wsConn, err := websocket.Dial(u.String(), "", relayURL)
+	if err != nil {
+		log.Printf("error dialing relay: %s", err)
+		return
+	}
+	log.Printf("connected to relay")
+	defer wsConn.Close()
+	wsConn.PayloadType = websocket.BinaryFrame
+	CopyLoop(conn, wsConn)
+	log.Printf("datachannelHandler ends")
+}
+
+// Create a PeerConnection from an SDP offer. Blocks until the gathering of ICE
+// candidates is complete and the answer is available in LocalDescription.
+// Installs an OnDataChannel callback that creates a webRTCConn and passes it to
+// datachannelHandler.
+func makePeerConnectionFromOffer(sdp *webrtc.SessionDescription, config webrtc.Configuration, dataChan chan struct{}) (*webrtc.PeerConnection, error) {
+	pc, err := webrtc.NewPeerConnection(config)
+	if err != nil {
+		return nil, fmt.Errorf("accept: NewPeerConnection: %s", err)
+	}
+	pc.OnDataChannel(func(dc *webrtc.DataChannel) {
+		log.Println("OnDataChannel")
+		close(dataChan)
+
+		pr, pw := io.Pipe()
+		conn := &webRTCConn{pc: pc, dc: dc, pr: pr}
+
+		dc.OnOpen(func() {
+			log.Println("OnOpen channel")
+		})
+		dc.OnClose(func() {
+			conn.lock.Lock()
+			defer conn.lock.Unlock()
+			log.Println("OnClose channel")
+			conn.dc = nil
+			dc.Close()
+			pw.Close()
+		})
+		dc.OnMessage(func(msg webrtc.DataChannelMessage) {
+			log.Printf("OnMessage <--- %d bytes", len(msg.Data))
+			var n int
+			n, err = pw.Write(msg.Data)
+			if err != nil {
+				if inerr := pw.CloseWithError(err); inerr != nil {
+					log.Printf("close with error generated an error: %v", inerr)
+				}
+			}
+			if n != len(msg.Data) {
+				panic("short write")
+			}
+		})
+
+		go datachannelHandler(conn, conn.RemoteAddr())
+	})
+
+	err = pc.SetRemoteDescription(*sdp)
+	if err != nil {
+		if inerr := pc.Close(); inerr != nil {
+			log.Printf("unable to call pc.Close after pc.SetRemoteDescription with error: %v", inerr)
+		}
+		return nil, fmt.Errorf("accept: SetRemoteDescription: %s", err)
+	}
+	log.Println("sdp offer successfully received.")
+
+	log.Println("Generating answer...")
+	answer, err := pc.CreateAnswer(nil)
+	// blocks on ICE gathering. we need to add a timeout if needed
+	// not putting this in a separate go routine, because we need
+	// SetLocalDescription(answer) to be called before sendAnswer
+	if err != nil {
+		if inerr := pc.Close(); inerr != nil {
+			log.Printf("ICE gathering has generated an error when calling pc.Close: %v", inerr)
+		}
+		return nil, err
+	}
+
+	err = pc.SetLocalDescription(answer)
+	if err != nil {
+		if err = pc.Close(); err != nil {
+			log.Printf("pc.Close after setting local description returned : %v", err)
+		}
+		return nil, err
+	}
+
+	return pc, nil
+}
+
+func runSession(sid string) {
+	offer := pollOffer(sid)
+	if offer == nil {
+		log.Printf("bad offer from broker")
+		retToken()
+		return
+	}
+	dataChan := make(chan struct{})
+	pc, err := makePeerConnectionFromOffer(offer, config, dataChan)
+	if err != nil {
+		log.Printf("error making WebRTC connection: %s", err)
+		retToken()
+		return
+	}
+	err = sendAnswer(sid, pc)
+	if err != nil {
+		log.Printf("error sending answer to client through broker: %s", err)
+		if inerr := pc.Close(); inerr != nil {
+			log.Printf("error calling pc.Close: %v", inerr)
+		}
+		retToken()
+		return
+	}
+	// Set a timeout on peerconnection. If the connection state has not
+	// advanced to PeerConnectionStateConnected in this time,
+	// destroy the peer connection and return the token.
+	select {
+	case <-dataChan:
+		log.Println("Connection successful.")
+	case <-time.After(dataChannelTimeout):
+		log.Println("Timed out waiting for client to open data channel.")
+		if err := pc.Close(); err != nil {
+			log.Printf("error calling pc.Close: %v", err)
+		}
+		retToken()
+	}
+}
+
+func main() {
+	var capacity uint
+	var stunURL string
+	var logFilename string
+	var rawBrokerURL string
+
+	flag.UintVar(&capacity, "capacity", 10, "maximum concurrent clients")
+	flag.StringVar(&rawBrokerURL, "broker", defaultBrokerURL, "broker URL")
+	flag.StringVar(&relayURL, "relay", defaultRelayURL, "websocket relay URL")
+	flag.StringVar(&stunURL, "stun", defaultSTUNURL, "stun URL")
+	flag.StringVar(&logFilename, "log", "", "log filename")
+	flag.Parse()
+
+	var logOutput io.Writer = os.Stderr
+	log.SetFlags(log.LstdFlags | log.LUTC)
+	if logFilename != "" {
+		f, err := os.OpenFile(logFilename, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0600)
+		if err != nil {
+			log.Fatal(err)
+		}
+		defer f.Close()
+		logOutput = io.MultiWriter(os.Stderr, f)
+	}
+	//We want to send the log output through our scrubber first
+	log.SetOutput(&safelog.LogScrubber{Output: logOutput})
+
+	log.Println("starting")
+
+	var err error
+	brokerURL, err = url.Parse(rawBrokerURL)
+	if err != nil {
+		log.Fatalf("invalid broker url: %s", err)
+	}
+	_, err = url.Parse(stunURL)
+	if err != nil {
+		log.Fatalf("invalid stun url: %s", err)
+	}
+	_, err = url.Parse(relayURL)
+	if err != nil {
+		log.Fatalf("invalid relay url: %s", err)
+	}
+
+	config = webrtc.Configuration{
+		ICEServers: []webrtc.ICEServer{
+			{
+				URLs: []string{stunURL},
+			},
+		},
+	}
+	tokens = make(chan bool, capacity)
+	for i := uint(0); i < capacity; i++ {
+		tokens <- true
+	}
+
+	for {
+		getToken()
+		sessionID := genSessionID()
+		runSession(sessionID)
+	}
+}
+
+func deserializeSessionDescription(msg string) *webrtc.SessionDescription {
+	var parsed map[string]interface{}
+	err := json.Unmarshal([]byte(msg), &parsed)
+	if nil != err {
+		log.Println(err)
+		return nil
+	}
+	if _, ok := parsed["type"]; !ok {
+		log.Println("Cannot deserialize SessionDescription without type field.")
+		return nil
+	}
+	if _, ok := parsed["sdp"]; !ok {
+		log.Println("Cannot deserialize SessionDescription without sdp field.")
+		return nil
+	}
+
+	var stype webrtc.SDPType
+	switch parsed["type"].(string) {
+	default:
+		log.Println("Unknown SDP type")
+		return nil
+	case "offer":
+		stype = webrtc.SDPTypeOffer
+	case "pranswer":
+		stype = webrtc.SDPTypePranswer
+	case "answer":
+		stype = webrtc.SDPTypeAnswer
+	case "rollback":
+		stype = webrtc.SDPTypeRollback
+	}
+
+	if err != nil {
+		log.Println(err)
+		return nil
+	}
+	return &webrtc.SessionDescription{
+		Type: stype,
+		SDP:  parsed["sdp"].(string),
+	}
+}
+
+func serializeSessionDescription(desc *webrtc.SessionDescription) string {
+	bytes, err := json.Marshal(*desc)
+	if nil != err {
+		log.Println(err)
+		return ""
+	}
+	return string(bytes)
+}
diff --git a/proxy-go/webrtc_test.go b/proxy-go/webrtc_test.go
new file mode 100644
index 0000000..2413207
--- /dev/null
+++ b/proxy-go/webrtc_test.go
@@ -0,0 +1,109 @@
+package main
+
+import (
+	"net"
+	"strings"
+	"testing"
+)
+
+func TestRemoteIPFromSDP(t *testing.T) {
+	tests := []struct {
+		sdp      string
+		expected net.IP
+	}{
+		// https://tools.ietf.org/html/rfc4566#section-5
+		{`v=0
+o=jdoe 2890844526 2890842807 IN IP4 10.47.16.5
+s=SDP Seminar
+i=A Seminar on the session description protocol
+u=http://www.example.com/seminars/sdp.pdf
+e=j.doe@example.com (Jane Doe)
+c=IN IP4 224.2.17.12/127
+t=2873397496 2873404696
+a=recvonly
+m=audio 49170 RTP/AVP 0
+m=video 51372 RTP/AVP 99
+a=rtpmap:99 h263-1998/90000
+`, net.ParseIP("224.2.17.12")},
+		// Missing c= line
+		{`v=0
+o=jdoe 2890844526 2890842807 IN IP4 10.47.16.5
+s=SDP Seminar
+i=A Seminar on the session description protocol
+u=http://www.example.com/seminars/sdp.pdf
+e=j.doe@example.com (Jane Doe)
+t=2873397496 2873404696
+a=recvonly
+m=audio 49170 RTP/AVP 0
+m=video 51372 RTP/AVP 99
+a=rtpmap:99 h263-1998/90000
+`, nil},
+		// Single line, IP address only
+		{`c=IN IP4 224.2.1.1
+`, net.ParseIP("224.2.1.1")},
+		// Same, with TTL
+		{`c=IN IP4 224.2.1.1/127
+`, net.ParseIP("224.2.1.1")},
+		// Same, with TTL and multicast addresses
+		{`c=IN IP4 224.2.1.1/127/3
+`, net.ParseIP("224.2.1.1")},
+		// IPv6, address only
+		{`c=IN IP6 FF15::101
+`, net.ParseIP("ff15::101")},
+		// Same, with multicast addresses
+		{`c=IN IP6 FF15::101/3
+`, net.ParseIP("ff15::101")},
+		// Multiple c= lines
+		{`c=IN IP4 1.2.3.4
+c=IN IP4 5.6.7.8
+`, net.ParseIP("1.2.3.4")},
+		// Modified from SDP sent by snowflake-client.
+		{`v=0
+o=- 7860378660295630295 2 IN IP4 127.0.0.1
+s=-
+t=0 0
+a=group:BUNDLE data
+a=msid-semantic: WMS
+m=application 54653 DTLS/SCTP 5000
+c=IN IP4 1.2.3.4
+a=candidate:3581707038 1 udp 2122260223 192.168.0.1 54653 typ host generation 0 network-id 1 network-cost 50
+a=candidate:2617212910 1 tcp 1518280447 192.168.0.1 59673 typ host tcptype passive generation 0 network-id 1 network-cost 50
+a=candidate:2082671819 1 udp 1686052607 1.2.3.4 54653 typ srflx raddr 192.168.0.1 rport 54653 generation 0 network-id 1 network-cost 50
+a=ice-ufrag:IBdf
+a=ice-pwd:G3lTrrC9gmhQx481AowtkhYz
+a=fingerprint:sha-256 53:F8:84:D9:3C:1F:A0:44:AA:D6:3C:65:80:D3:CB:6F:23:90:17:41:06:F9:9C:10:D8:48:4A:A8:B6:FA:14:A1
+a=setup:actpass
+a=mid:data
+a=sctpmap:5000 webrtc-datachannel 1024
+`, net.ParseIP("1.2.3.4")},
+		// Improper character within IPv4
+		{`c=IN IP4 224.2z.1.1
+`, nil},
+		// Improper character within IPv6
+		{`c=IN IP6 ff15:g::101
+`, nil},
+		// Bogus "IP7" addrtype
+		{`c=IN IP7 1.2.3.4
+`, nil},
+	}
+
+	for _, test := range tests {
+		// https://tools.ietf.org/html/rfc4566#section-5: "The sequence
+		// CRLF (0x0d0a) is used to end a record, although parsers
+		// SHOULD be tolerant and also accept records terminated with a
+		// single newline character." We represent the test cases with
+		// LF line endings for convenience, and test them both that way
+		// and with CRLF line endings.
+		lfSDP := test.sdp
+		crlfSDP := strings.Replace(lfSDP, "\n", "\r\n", -1)
+
+		ip := remoteIPFromSDP(lfSDP)
+		if !ip.Equal(test.expected) {
+			t.Errorf("expected %q, got %q from %q", test.expected, ip, lfSDP)
+		}
+		ip = remoteIPFromSDP(crlfSDP)
+		if !ip.Equal(test.expected) {
+			t.Errorf("expected %q, got %q from %q", test.expected, ip, crlfSDP)
+		}
+	}
+}
diff --git a/proxy/.eslintignore b/proxy/.eslintignore
new file mode 100644
index 0000000..f580632
--- /dev/null
+++ b/proxy/.eslintignore
@@ -0,0 +1,7 @@
+build/
+test/
+webext/snowflake.js
+
+# FIXME: Whittle these away
+spec/
+shims.js
diff --git a/proxy/.eslintrc.json b/proxy/.eslintrc.json
new file mode 100644
index 0000000..406f9e5
--- /dev/null
+++ b/proxy/.eslintrc.json
@@ -0,0 +1,13 @@
+{
+    "env": {
+        "browser": true,
+        "es6": true
+    },
+    "extends": "eslint:recommended",
+    "rules": {
+        "indent": ["error", 2, {
+          "SwitchCase": 1,
+          "MemberExpression": 0
+        }]
+    }
+}
diff --git a/proxy/README.md b/proxy/README.md
index e736667..61468c0 100644
--- a/proxy/README.md
+++ b/proxy/README.md
@@ -1,48 +1,75 @@
-
-
-**Table of Contents**
+This is the browser proxy component of Snowflake.
 
-- [Dependencies](#dependencies)
-- [Building the standalone Snowflake proxy](#building-the-standalone-snowflake-proxy)
-- [Running a standalone Snowflake proxy](#running-a-standalone-snowflake-proxy)
-
-
-
-This is a standalone (not browser-based) version of the Snowflake proxy. For browser-based versions of the Snowflake proxy, see https://gitlab.torproject.org/tpo/anti-censorship/pluggable-transports/snowflake-webext.
-
-### Dependencies
-
-- Go 1.13+
-- We use the [pion/webrtc](https://github.com/pion/webrtc) library for WebRTC communication with Snowflake proxies. Note: running `go get` will fetch this dependency automatically during the build process.
-
-### Building the standalone Snowflake proxy
-
-To build the Snowflake proxy, make sure you are in the `proxy/` directory, and then run:
+### Embedding
 
+See https://snowflake.torproject.org/ for more info:
 ```
-go get
-go build
+
 ```
 
-### Running a standalone Snowflake proxy
+### Building
 
-The Snowflake proxy can be run with the following options:
 ```
-Usage of ./proxy:
-  -broker string
-        broker URL (default "https://snowflake-broker.torproject.net/")
-  -capacity uint
-        maximum concurrent clients
-  -keep-local-addresses
-        keep local LAN address ICE candidates
-  -log string
-        log filename
-  -relay string
-        websocket relay URL (default "wss://snowflake.torproject.net/")
-  -stun string
-        stun URL (default "stun:stun.stunprotocol.org:3478")
-  -unsafe-logging
-        prevent logs from being scrubbed
+npm run build
 ```
 
-For more information on how to run a Snowflake proxy in deployment, see our [community documentation](https://community.torproject.org/relay/setup/snowflake/standalone/).
+### Testing
+
+Unit testing with Jasmine are available with:
+```
+npm install
+npm test
+```
+
+To run locally, start an http server in `build/` and navigate to `/embed.html`.
+
+### Preparing to deploy
+
+Background information:
+ * https://bugs.torproject.org/23947#comment:8
+ * https://help.torproject.org/tsa/doc/static-sites/
+ * https://help.torproject.org/tsa/doc/ssh-jump-host/
+
+You need to be in LDAP group "snowflake" and have set up an SSH key with your LDAP account.
+In your ~/.ssh/config file, you should have something like:
+
+```
+Host staticiforme
+HostName staticiforme.torproject.org
+User 
+ProxyJump people.torproject.org
+IdentityFile ~/.ssh/tor
+```
+
+### Deploying
+
+```
+npm run build
+```
+
+Do a "dry run" rsync with `-n` to check that only expected files are being changed. If you don't understand why a file would be updated, you can add the `-i` option to see the reason.
+
+```
+rsync -n --chown=:snowflake --chmod ug=rw,D+x --perms --delete -crv build/ staticiforme:/srv/snowflake.torproject.org/htdocs/
+```
+
+If it looks good, then repeat the rsync without `-n`.
+
+```
+rsync --chown=:snowflake --chmod ug=rw,D+x --perms --delete -crv build/ staticiforme:/srv/snowflake.torproject.org/htdocs/
+```
+
+You can ignore errors of the form `rsync: failed to set permissions on "/": Operation not permitted (1)`.
+
+Then run the command to copy the new files to the live web servers:
+
+```
+ssh staticiforme 'static-update-component snowflake.torproject.org'
+```
+
+### Parameters
+
+With no parameters,
+snowflake uses the default relay `snowflake.freehaven.net:443` and
+uses automatic signaling with the default broker at
+`https://snowflake-broker.freehaven.net/`.
diff --git a/proxy/broker.js b/proxy/broker.js
new file mode 100644
index 0000000..9806e76
--- /dev/null
+++ b/proxy/broker.js
@@ -0,0 +1,122 @@
+/* global log, dbg, snowflake */
+
+/*
+Communication with the snowflake broker.
+
+Browser snowflakes must register with the broker in order
+to get assigned to clients.
+*/
+
+// Represents a broker running remotely.
+class Broker {
+
+  // When interacting with the Broker, snowflake must generate a unique session
+  // ID so the Broker can keep track of each proxy's signalling channels.
+  // On construction, this Broker object does not do anything until
+  // |getClientOffer| is called.
+  constructor(url) {
+    // Promises some client SDP Offer.
+    // Registers this Snowflake with the broker using an HTTP POST request, and
+    // waits for a response containing some client offer that the Broker chooses
+    // for this proxy..
+    // TODO: Actually support multiple clients.
+    this.getClientOffer = this.getClientOffer.bind(this);
+    // urlSuffix for the broker is different depending on what action
+    // is desired.
+    this._postRequest = this._postRequest.bind(this);
+    this.url = url;
+    this.clients = 0;
+    if (0 === this.url.indexOf('localhost', 0)) {
+      // Ensure url has the right protocol + trailing slash.
+      this.url = 'http://' + this.url;
+    }
+    if (0 !== this.url.indexOf('http', 0)) {
+      this.url = 'https://' + this.url;
+    }
+    if ('/' !== this.url.substr(-1)) {
+      this.url += '/';
+    }
+  }
+
+  getClientOffer(id) {
+    return new Promise((fulfill, reject) => {
+      var xhr;
+      xhr = new XMLHttpRequest();
+      xhr.onreadystatechange = function() {
+        if (xhr.DONE !== xhr.readyState) {
+          return;
+        }
+        switch (xhr.status) {
+          case Broker.STATUS.OK:
+            return fulfill(xhr.responseText); // Should contain offer.
+          case Broker.STATUS.GATEWAY_TIMEOUT:
+            return reject(Broker.MESSAGE.TIMEOUT);
+          default:
+            log('Broker ERROR: Unexpected ' + xhr.status + ' - ' + xhr.statusText);
+            snowflake.ui.setStatus(' failure. Please refresh.');
+            return reject(Broker.MESSAGE.UNEXPECTED);
+        }
+      };
+      this._xhr = xhr; // Used by spec to fake async Broker interaction
+      return this._postRequest(id, xhr, 'proxy', id);
+    });
+  }
+
+  // Assumes getClientOffer happened, and a WebRTC SDP answer has been generated.
+  // Sends it back to the broker, which passes it to back to the original client.
+  sendAnswer(id, answer) {
+    var xhr;
+    dbg(id + ' - Sending answer back to broker...\n');
+    dbg(answer.sdp);
+    xhr = new XMLHttpRequest();
+    xhr.onreadystatechange = function() {
+      if (xhr.DONE !== xhr.readyState) {
+        return;
+      }
+      switch (xhr.status) {
+        case Broker.STATUS.OK:
+          dbg('Broker: Successfully replied with answer.');
+          return dbg(xhr.responseText);
+        case Broker.STATUS.GONE:
+          return dbg('Broker: No longer valid to reply with answer.');
+        default:
+          dbg('Broker ERROR: Unexpected ' + xhr.status + ' - ' + xhr.statusText);
+          return snowflake.ui.setStatus(' failure. Please refresh.');
+      }
+    };
+    return this._postRequest(id, xhr, 'answer', JSON.stringify(answer));
+  }
+
+  _postRequest(id, xhr, urlSuffix, payload) {
+    var err;
+    try {
+      xhr.open('POST', this.url + urlSuffix);
+      xhr.setRequestHeader('X-Session-ID', id);
+    } catch (error) {
+      err = error;
+      /*
+      An exception happens here when, for example, NoScript allows the domain
+      on which the proxy badge runs, but not the domain to which it's trying
+      to make the HTTP xhr. The exception message is like "Component
+      returned failure code: 0x805e0006 [nsIXMLHttpRequest.open]" on Firefox.
+      */
+      log('Broker: exception while connecting: ' + err.message);
+      return;
+    }
+    return xhr.send(payload);
+  }
+
+}
+
+Broker.STATUS = {
+  OK: 200,
+  GONE: 410,
+  GATEWAY_TIMEOUT: 504
+};
+
+Broker.MESSAGE = {
+  TIMEOUT: 'Timed out waiting for a client offer.',
+  UNEXPECTED: 'Unexpected status.'
+};
+
+Broker.prototype.clients = 0;
diff --git a/proxy/config.js b/proxy/config.js
new file mode 100644
index 0000000..95ae92e
--- /dev/null
+++ b/proxy/config.js
@@ -0,0 +1,36 @@
+
+class Config {}
+
+Config.prototype.brokerUrl = 'snowflake-broker.freehaven.net';
+
+Config.prototype.relayAddr = {
+  host: 'snowflake.freehaven.net',
+  port: '443'
+};
+
+// Original non-wss relay:
+// host: '192.81.135.242'
+// port: 9902
+Config.prototype.cookieName = "snowflake-allow";
+
+// Bytes per second. Set to undefined to disable limit.
+Config.prototype.rateLimitBytes = void 0;
+
+Config.prototype.minRateLimit = 10 * 1024;
+
+Config.prototype.rateLimitHistory = 5.0;
+
+Config.prototype.defaultBrokerPollInterval = 300.0 * 1000;
+
+Config.prototype.maxNumClients = 1;
+
+Config.prototype.connectionsPerClient = 1;
+
+// TODO: Different ICE servers.
+Config.prototype.pcConfig = {
+  iceServers: [
+    {
+      urls: ['stun:stun.l.google.com:19302']
+    }
+  ]
+};
diff --git a/proxy/init-badge.js b/proxy/init-badge.js
new file mode 100644
index 0000000..fe8cc91
--- /dev/null
+++ b/proxy/init-badge.js
@@ -0,0 +1,223 @@
+/* global Util, Params, Config, UI, Broker, Snowflake, Popup, Parse, availableLangs, WS */
+
+/*
+UI
+*/
+
+class Messages {
+  constructor(json) {
+    this.json = json;
+  }
+  getMessage(m, ...rest) {
+    let message = this.json[m].message;
+    return message.replace(/\$(\d+)/g, (...args) => {
+      return rest[Number(args[1]) - 1];
+    });
+  }
+}
+
+let messages = null;
+
+class BadgeUI extends UI {
+
+  constructor() {
+    super();
+    this.popup = new Popup();
+  }
+
+  setStatus() {}
+
+  missingFeature(missing) {
+    this.popup.setEnabled(false);
+    this.popup.setActive(false);
+    this.popup.setStatusText(messages.getMessage('popupStatusOff'));
+    this.setIcon('off');
+    this.popup.setStatusDesc(missing, true);
+    this.popup.hideButton();
+  }
+
+  turnOn() {
+    const clients = this.active ? 1 : 0;
+    this.popup.setChecked(true);
+    if (clients > 0) {
+      this.popup.setStatusText(messages.getMessage('popupStatusOn', String(clients)));
+      this.setIcon('running');
+    } else {
+      this.popup.setStatusText(messages.getMessage('popupStatusReady'));
+      this.setIcon('on');
+    }
+    // FIXME: Share stats from webext
+    this.popup.setStatusDesc('');
+    this.popup.setEnabled(true);
+    this.popup.setActive(this.active);
+  }
+
+  turnOff() {
+    this.popup.setChecked(false);
+    this.popup.setStatusText(messages.getMessage('popupStatusOff'));
+    this.setIcon('off');
+    this.popup.setStatusDesc('');
+    this.popup.setEnabled(false);
+    this.popup.setActive(false);
+  }
+
+  setActive(connected) {
+    super.setActive(connected);
+    this.turnOn();
+  }
+
+  setIcon(status) {
+    document.getElementById('icon').href = `assets/toolbar-${status}.ico`;
+  }
+
+}
+
+BadgeUI.prototype.popup = null;
+
+
+/*
+Entry point.
+*/
+
+// Defaults to opt-in.
+var COOKIE_NAME = "snowflake-allow";
+var COOKIE_LIFETIME = "Thu, 01 Jan 2038 00:00:00 GMT";
+var COOKIE_EXPIRE = "Thu, 01 Jan 1970 00:00:01 GMT";
+
+function setSnowflakeCookie(val, expires) {
+  document.cookie = `${COOKIE_NAME}=${val}; path=/; expires=${expires};`;
+}
+
+const defaultLang = 'en_US';
+
+// Resolve as in,
+// https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/Internationalization#Localized_string_selection
+function getLang() {
+  let lang = navigator.language || defaultLang;
+  lang = lang.replace(/-/g, '_');
+  if (availableLangs.has(lang)) {
+    return lang;
+  }
+  lang = lang.split('_')[0];
+  if (availableLangs.has(lang)) {
+    return lang;
+  }
+  return defaultLang;
+}
+
+var debug, snowflake, config, broker, ui, log, dbg, init, update, silenceNotifications, query;
+
+(function() {
+
+  snowflake = null;
+
+  query = new URLSearchParams(location.search);
+
+  debug = Params.getBool(query, 'debug', false);
+
+  silenceNotifications = Params.getBool(query, 'silent', false);
+
+  // Log to both console and UI if applicable.
+  // Requires that the snowflake and UI objects are hooked up in order to
+  // log to console.
+  log = function(msg) {
+    console.log('Snowflake: ' + msg);
+    return snowflake != null ? snowflake.ui.log(msg) : void 0;
+  };
+
+  dbg = function(msg) {
+    if (debug) { log(msg); }
+  };
+
+  update = function() {
+    const cookies = Parse.cookie(document.cookie);
+    if (cookies[COOKIE_NAME] !== '1') {
+      ui.turnOff();
+      snowflake.disable();
+      log('Currently not active.');
+      return;
+    }
+
+    if (!Util.hasWebRTC()) {
+      ui.missingFeature(messages.getMessage('popupWebRTCOff'));
+      snowflake.disable();
+      return;
+    }
+
+    WS.probeWebsocket(config.relayAddr)
+    .then(
+      () => {
+        ui.turnOn();
+        dbg('Contacting Broker at ' + broker.url);
+        log('Starting snowflake');
+        snowflake.setRelayAddr(config.relayAddr);
+        snowflake.beginWebRTC();
+      },
+      () => {
+        ui.missingFeature(messages.getMessage('popupBridgeUnreachable'));
+        snowflake.disable();
+        log('Could not connect to bridge.');
+      }
+    );
+  };
+
+  init = function() {
+    ui = new BadgeUI();
+
+    if (!Util.hasCookies()) {
+      ui.missingFeature(messages.getMessage('badgeCookiesOff'));
+      return;
+    }
+
+    config = new Config;
+    if ('off' !== query.get('ratelimit')) {
+      config.rateLimitBytes = Params.getByteCount(query, 'ratelimit', config.rateLimitBytes);
+    }
+    broker = new Broker(config.brokerUrl);
+    snowflake = new Snowflake(config, ui, broker);
+    log('== snowflake proxy ==');
+    update();
+
+    document.getElementById('enabled').addEventListener('change', (event) => {
+      if (event.target.checked) {
+        setSnowflakeCookie('1', COOKIE_LIFETIME);
+      } else {
+        setSnowflakeCookie('', COOKIE_EXPIRE);
+      }
+      update();
+    })
+  };
+
+  // Notification of closing tab with active proxy.
+  window.onbeforeunload = function() {
+    if (
+      !silenceNotifications &&
+      snowflake !== null &&
+      Snowflake.MODE.WEBRTC_READY === snowflake.state
+    ) {
+      return Snowflake.MESSAGE.CONFIRMATION;
+    }
+    return null;
+  };
+
+  window.onunload = function() {
+    if (snowflake !== null) { snowflake.disable(); }
+    return null;
+  };
+
+  window.onload = function() {
+    fetch(`./_locales/${getLang()}/messages.json`)
+    .then((res) => {
+      if (!res.ok) { return; }
+      return res.json();
+    })
+    .then((json) => {
+      messages = new Messages(json);
+      Popup.fill(document.body, (m) => {
+        return messages.getMessage(m);
+      });
+      init();
+    });
+  }
+
+}());
diff --git a/proxy/init-node.js b/proxy/init-node.js
new file mode 100644
index 0000000..789e6e3
--- /dev/null
+++ b/proxy/init-node.js
@@ -0,0 +1,27 @@
+/* global Config, UI, Broker, Snowflake */
+
+/*
+Entry point.
+*/
+
+var config = new Config;
+
+var ui = new UI();
+
+var broker = new Broker(config.brokerUrl);
+
+var snowflake = new Snowflake(config, ui, broker);
+
+var log = function(msg) {
+  return console.log('Snowflake: ' + msg);
+};
+
+var dbg = log;
+
+log('== snowflake proxy ==');
+
+dbg('Contacting Broker at ' + broker.url);
+
+snowflake.setRelayAddr(config.relayAddr);
+
+snowflake.beginWebRTC();
diff --git a/proxy/init-testing.js b/proxy/init-testing.js
new file mode 100644
index 0000000..5b63099
--- /dev/null
+++ b/proxy/init-testing.js
@@ -0,0 +1,125 @@
+/* global TESTING, Util, Params, Config, UI, Broker, Snowflake */
+
+/*
+UI
+*/
+
+class DebugUI extends UI {
+
+  constructor() {
+    super();
+    // Setup other DOM handlers if it's debug mode.
+    this.$status = document.getElementById('status');
+    this.$msglog = document.getElementById('msglog');
+    this.$msglog.value = '';
+  }
+
+  // Status bar
+  setStatus(msg) {
+    var txt;
+    txt = document.createTextNode('Status: ' + msg);
+    while (this.$status.firstChild) {
+      this.$status.removeChild(this.$status.firstChild);
+    }
+    return this.$status.appendChild(txt);
+  }
+
+  setActive(connected) {
+    super.setActive(connected);
+    return this.$msglog.className = connected ? 'active' : '';
+  }
+
+  log(msg) {
+    // Scroll to latest
+    this.$msglog.value += msg + '\n';
+    return this.$msglog.scrollTop = this.$msglog.scrollHeight;
+  }
+
+}
+
+// DOM elements references.
+DebugUI.prototype.$msglog = null;
+
+DebugUI.prototype.$status = null;
+
+/*
+Entry point.
+*/
+
+var snowflake, query, debug, silenceNotifications, log, dbg, init;
+
+(function() {
+
+  if (((typeof TESTING === "undefined" || TESTING === null) || !TESTING) && !Util.featureDetect()) {
+    console.log('webrtc feature not detected. shutting down');
+    return;
+  }
+
+  snowflake = null;
+
+  query = new URLSearchParams(location.search);
+
+  debug = Params.getBool(query, 'debug', false);
+
+  silenceNotifications = Params.getBool(query, 'silent', false);
+
+  // Log to both console and UI if applicable.
+  // Requires that the snowflake and UI objects are hooked up in order to
+  // log to console.
+  log = function(msg) {
+    console.log('Snowflake: ' + msg);
+    return snowflake != null ? snowflake.ui.log(msg) : void 0;
+  };
+
+  dbg = function(msg) {
+    if (debug || ((snowflake != null ? snowflake.ui : void 0) instanceof DebugUI)) {
+      return log(msg);
+    }
+  };
+
+  init = function() {
+    var broker, config, ui;
+    config = new Config;
+    if ('off' !== query['ratelimit']) {
+      config.rateLimitBytes = Params.getByteCount(query, 'ratelimit', config.rateLimitBytes);
+    }
+    ui = null;
+    if (document.getElementById('status') !== null) {
+      ui = new DebugUI();
+    } else {
+      ui = new UI();
+    }
+    broker = new Broker(config.brokerUrl);
+    snowflake = new Snowflake(config, ui, broker);
+    log('== snowflake proxy ==');
+    if (Util.snowflakeIsDisabled(config.cookieName)) {
+      // Do not activate the proxy if any number of conditions are true.
+      log('Currently not active.');
+      return;
+    }
+    // Otherwise, begin setting up WebRTC and acting as a proxy.
+    dbg('Contacting Broker at ' + broker.url);
+    snowflake.setRelayAddr(config.relayAddr);
+    return snowflake.beginWebRTC();
+  };
+
+  // Notification of closing tab with active proxy.
+  window.onbeforeunload = function() {
+    if (
+      !silenceNotifications &&
+      snowflake !== null &&
+      Snowflake.MODE.WEBRTC_READY === snowflake.state
+    ) {
+      return Snowflake.MESSAGE.CONFIRMATION;
+    }
+    return null;
+  };
+
+  window.onunload = function() {
+    if (snowflake !== null) { snowflake.disable(); }
+    return null;
+  };
+
+  window.onload = init;
+
+}());
diff --git a/proxy/init-webext.js b/proxy/init-webext.js
new file mode 100644
index 0000000..6789ffd
--- /dev/null
+++ b/proxy/init-webext.js
@@ -0,0 +1,215 @@
+/* global Util, chrome, Config, UI, Broker, Snowflake, WS */
+/* eslint no-unused-vars: 0 */
+
+/*
+UI
+*/
+
+class WebExtUI extends UI {
+
+  constructor() {
+    super();
+    this.onConnect = this.onConnect.bind(this);
+    this.onMessage = this.onMessage.bind(this);
+    this.onDisconnect = this.onDisconnect.bind(this);
+    this.initStats();
+    chrome.runtime.onConnect.addListener(this.onConnect);
+  }
+
+  initStats() {
+    this.stats = [0];
+    setInterval((() => {
+      this.stats.unshift(0);
+      this.stats.splice(24);
+      this.postActive();
+    }), 60 * 60 * 1000);
+  }
+
+  initToggle() {
+    // First, check if we have our status stored
+    (new Promise((resolve) => {
+      chrome.storage.local.get(["snowflake-enabled"], resolve);
+    }))
+    .then((result) => {
+      let enabled = this.enabled;
+      if (result['snowflake-enabled'] !== void 0) {
+        enabled = result['snowflake-enabled'];
+      } else {
+        log("Toggle state not yet saved");
+      }
+      // If it isn't enabled, stop
+      if (!enabled) {
+        this.setEnabled(enabled);
+        return;
+      }
+      // Otherwise, do feature checks
+      if (!Util.hasWebRTC()) {
+        this.missingFeature = 'popupWebRTCOff';
+        this.setEnabled(false);
+        return;
+      }
+      WS.probeWebsocket(config.relayAddr)
+      .then(
+        () => {
+          this.setEnabled(true);
+        },
+        () => {
+          log('Could not connect to bridge.');
+          this.missingFeature = 'popupBridgeUnreachable';
+          this.setEnabled(false);
+        }
+      );
+    });
+  }
+
+  postActive() {
+    this.setIcon();
+    if (!this.port) { return; }
+    this.port.postMessage({
+      active: this.active,
+      total: this.stats.reduce((function(t, c) {
+        return t + c;
+      }), 0),
+      enabled: this.enabled,
+      missingFeature: this.missingFeature,
+    });
+  }
+
+  onConnect(port) {
+    this.port = port;
+    port.onDisconnect.addListener(this.onDisconnect);
+    port.onMessage.addListener(this.onMessage);
+    this.postActive();
+  }
+
+  onMessage(m) {
+    (new Promise((resolve) => {
+      chrome.storage.local.set({ "snowflake-enabled": m.enabled }, resolve);
+    }))
+    .then(() => {
+      log("Stored toggle state");
+      this.initToggle();
+    });
+  }
+
+  onDisconnect() {
+    this.port = null;
+  }
+
+  setActive(connected) {
+    super.setActive(connected);
+    if (connected) {
+      this.stats[0] += 1;
+    }
+    this.postActive();
+  }
+
+  setEnabled(enabled) {
+    this.enabled = enabled;
+    this.postActive();
+    update();
+  }
+
+  setIcon() {
+    let path = null;
+    if (!this.enabled) {
+      path = {
+        48: "assets/toolbar-off-48.png",
+        96: "assets/toolbar-off-96.png"
+      };
+    } else if (this.active) {
+      path = {
+        48: "assets/toolbar-running-48.png",
+        96: "assets/toolbar-running-96.png"
+      };
+    } else {
+      path = {
+        48: "assets/toolbar-on-48.png",
+        96: "assets/toolbar-on-96.png"
+      };
+    }
+    chrome.browserAction.setIcon({
+      path: path,
+    });
+  }
+
+}
+
+WebExtUI.prototype.port = null;
+
+WebExtUI.prototype.stats = null;
+
+WebExtUI.prototype.enabled = true;
+
+/*
+Entry point.
+*/
+
+var debug, snowflake, config, broker, ui, log, dbg, init, update, silenceNotifications;
+
+(function () {
+
+  silenceNotifications = false;
+  debug = false;
+  snowflake = null;
+  config = null;
+  broker = null;
+  ui = null;
+
+  // Log to both console and UI if applicable.
+  // Requires that the snowflake and UI objects are hooked up in order to
+  // log to console.
+  log = function(msg) {
+    console.log('Snowflake: ' + msg);
+    return snowflake != null ? snowflake.ui.log(msg) : void 0;
+  };
+
+  dbg = function(msg) {
+    if (debug) {
+      return log(msg);
+    }
+  };
+
+  init = function() {
+    config = new Config;
+    ui = new WebExtUI();
+    broker = new Broker(config.brokerUrl);
+    snowflake = new Snowflake(config, ui, broker);
+    log('== snowflake proxy ==');
+    ui.initToggle();
+  };
+
+  update = function() {
+    if (!ui.enabled) {
+      // Do not activate the proxy if any number of conditions are true.
+      snowflake.disable();
+      log('Currently not active.');
+      return;
+    }
+    // Otherwise, begin setting up WebRTC and acting as a proxy.
+    dbg('Contacting Broker at ' + broker.url);
+    log('Starting snowflake');
+    snowflake.setRelayAddr(config.relayAddr);
+    return snowflake.beginWebRTC();
+  };
+
+  // Notification of closing tab with active proxy.
+  window.onbeforeunload = function() {
+    if (
+      !silenceNotifications &&
+      snowflake !== null &&
+      Snowflake.MODE.WEBRTC_READY === snowflake.state
+    ) {
+      return Snowflake.MESSAGE.CONFIRMATION;
+    }
+    return null;
+  };
+
+  window.onunload = function() {
+    if (snowflake !== null) { snowflake.disable(); }
+    return null;
+  };
+
+  window.onload = init;
+
+}());
diff --git a/proxy/lib/proxy-go_test.go b/proxy/lib/proxy-go_test.go
deleted file mode 100644
index b5ff86c..0000000
--- a/proxy/lib/proxy-go_test.go
+++ /dev/null
@@ -1,497 +0,0 @@
-package snowflake_proxy
-
-import (
-	"bytes"
-	"fmt"
-	"io"
-	"io/ioutil"
-	"net"
-	"net/http"
-	"strconv"
-	"strings"
-	"testing"
-
-	"git.torproject.org/pluggable-transports/snowflake.git/v2/common/messages"
-	"git.torproject.org/pluggable-transports/snowflake.git/v2/common/util"
-	"github.com/pion/webrtc/v3"
-	. "github.com/smartystreets/goconvey/convey"
-)
-
-// Set up a mock broker to communicate with
-type MockTransport struct {
-	statusOverride int
-	body           []byte
-}
-
-// Just returns a response with fake SDP answer.
-func (m *MockTransport) RoundTrip(req *http.Request) (*http.Response, error) {
-	s := ioutil.NopCloser(bytes.NewReader(m.body))
-	r := &http.Response{
-		StatusCode: m.statusOverride,
-		Body:       s,
-	}
-	return r, nil
-}
-
-// Set up a mock faulty transport
-type FaultyTransport struct {
-	statusOverride int
-	body           []byte
-}
-
-// Just returns a response with fake SDP answer.
-func (f *FaultyTransport) RoundTrip(req *http.Request) (*http.Response, error) {
-	return nil, fmt.Errorf("TransportFailed")
-}
-
-func TestRemoteIPFromSDP(t *testing.T) {
-	tests := []struct {
-		sdp      string
-		expected net.IP
-	}{
-		// https://tools.ietf.org/html/rfc4566#section-5
-		{`v=0
-o=jdoe 2890844526 2890842807 IN IP4 10.47.16.5
-s=SDP Seminar
-i=A Seminar on the session description protocol
-u=http://www.example.com/seminars/sdp.pdf
-e=j.doe@example.com (Jane Doe)
-c=IN IP4 224.2.17.12/127
-t=2873397496 2873404696
-a=recvonly
-m=audio 49170 RTP/AVP 0
-m=video 51372 RTP/AVP 99
-a=rtpmap:99 h263-1998/90000
-`, net.ParseIP("224.2.17.12")},
-		// local addresses only
-		{`v=0
-o=jdoe 2890844526 2890842807 IN IP4 10.47.16.5
-s=SDP Seminar
-i=A Seminar on the session description protocol
-u=http://www.example.com/seminars/sdp.pdf
-e=j.doe@example.com (Jane Doe)
-c=IN IP4 10.47.16.5/127
-t=2873397496 2873404696
-a=recvonly
-m=audio 49170 RTP/AVP 0
-m=video 51372 RTP/AVP 99
-a=rtpmap:99 h263-1998/90000
-`, nil},
-		// Remote IP in candidate attribute only
-		{`v=0
-o=- 4358805017720277108 2 IN IP4 0.0.0.0
-s=-
-t=0 0
-a=group:BUNDLE data
-a=msid-semantic: WMS
-m=application 56688 DTLS/SCTP 5000
-c=IN IP4 0.0.0.0
-a=candidate:3769337065 1 udp 2122260223 1.2.3.4 56688 typ host generation 0 network-id 1 network-cost 50
-a=ice-ufrag:aMAZ
-a=ice-pwd:jcHb08Jjgrazp2dzjdrvPPvV
-a=ice-options:trickle
-a=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
-a=setup:actpass
-a=mid:data
-a=sctpmap:5000 webrtc-datachannel 1024
-`, net.ParseIP("1.2.3.4")},
-		// Unspecified address
-		{`v=0
-o=jdoe 2890844526 2890842807 IN IP4 0.0.0.0
-s=SDP Seminar
-i=A Seminar on the session description protocol
-u=http://www.example.com/seminars/sdp.pdf
-e=j.doe@example.com (Jane Doe)
-t=2873397496 2873404696
-a=recvonly
-m=audio 49170 RTP/AVP 0
-m=video 51372 RTP/AVP 99
-a=rtpmap:99 h263-1998/90000
-`, nil},
-		// Missing c= line
-		{`v=0
-o=jdoe 2890844526 2890842807 IN IP4 10.47.16.5
-s=SDP Seminar
-i=A Seminar on the session description protocol
-u=http://www.example.com/seminars/sdp.pdf
-e=j.doe@example.com (Jane Doe)
-t=2873397496 2873404696
-a=recvonly
-m=audio 49170 RTP/AVP 0
-m=video 51372 RTP/AVP 99
-a=rtpmap:99 h263-1998/90000
-`, nil},
-		// Single line, IP address only
-		{`v=0
-o=- 4358805017720277108 2 IN IP4 0.0.0.0
-s=-
-t=0 0
-a=group:BUNDLE data
-a=msid-semantic: WMS
-m=application 56688 DTLS/SCTP 5000
-c=IN IP4 224.2.1.1
-`, net.ParseIP("224.2.1.1")},
-		// Same, with TTL
-		{`v=0
-o=- 4358805017720277108 2 IN IP4 0.0.0.0
-s=-
-t=0 0
-a=group:BUNDLE data
-a=msid-semantic: WMS
-m=application 56688 DTLS/SCTP 5000
-c=IN IP4 224.2.1.1/127
-`, net.ParseIP("224.2.1.1")},
-		// Same, with TTL and multicast addresses
-		{`v=0
-o=- 4358805017720277108 2 IN IP4 0.0.0.0
-s=-
-t=0 0
-a=group:BUNDLE data
-a=msid-semantic: WMS
-m=application 56688 DTLS/SCTP 5000
-c=IN IP4 224.2.1.1/127/3
-`, net.ParseIP("224.2.1.1")},
-		// IPv6, address only
-		{`v=0
-o=- 4358805017720277108 2 IN IP4 0.0.0.0
-s=-
-t=0 0
-a=group:BUNDLE data
-a=msid-semantic: WMS
-m=application 56688 DTLS/SCTP 5000
-c=IN IP6 FF15::101
-`, net.ParseIP("ff15::101")},
-		// Same, with multicast addresses
-		{`v=0
-o=- 4358805017720277108 2 IN IP4 0.0.0.0
-s=-
-t=0 0
-a=group:BUNDLE data
-a=msid-semantic: WMS
-m=application 56688 DTLS/SCTP 5000
-c=IN IP6 FF15::101/3
-`, net.ParseIP("ff15::101")},
-		// Multiple c= lines
-		{`v=0
-o=- 4358805017720277108 2 IN IP4 0.0.0.0
-s=-
-t=0 0
-a=group:BUNDLE data
-a=msid-semantic: WMS
-m=application 56688 DTLS/SCTP 5000
-c=IN IP4 1.2.3.4
-c=IN IP4 5.6.7.8
-`, net.ParseIP("1.2.3.4")},
-		// Modified from SDP sent by snowflake-client.
-		{`v=0
-o=- 7860378660295630295 2 IN IP4 127.0.0.1
-s=-
-t=0 0
-a=group:BUNDLE data
-a=msid-semantic: WMS
-m=application 54653 DTLS/SCTP 5000
-c=IN IP4 1.2.3.4
-a=candidate:3581707038 1 udp 2122260223 192.168.0.1 54653 typ host generation 0 network-id 1 network-cost 50
-a=candidate:2617212910 1 tcp 1518280447 192.168.0.1 59673 typ host tcptype passive generation 0 network-id 1 network-cost 50
-a=candidate:2082671819 1 udp 1686052607 1.2.3.4 54653 typ srflx raddr 192.168.0.1 rport 54653 generation 0 network-id 1 network-cost 50
-a=ice-ufrag:IBdf
-a=ice-pwd:G3lTrrC9gmhQx481AowtkhYz
-a=fingerprint:sha-256 53:F8:84:D9:3C:1F:A0:44:AA:D6:3C:65:80:D3:CB:6F:23:90:17:41:06:F9:9C:10:D8:48:4A:A8:B6:FA:14:A1
-a=setup:actpass
-a=mid:data
-a=sctpmap:5000 webrtc-datachannel 1024
-`, net.ParseIP("1.2.3.4")},
-		// Improper character within IPv4
-		{`v=0
-o=- 4358805017720277108 2 IN IP4 0.0.0.0
-s=-
-t=0 0
-a=group:BUNDLE data
-a=msid-semantic: WMS
-m=application 56688 DTLS/SCTP 5000
-c=IN IP4 224.2z.1.1
-`, nil},
-		// Improper character within IPv6
-		{`v=0
-o=- 4358805017720277108 2 IN IP4 0.0.0.0
-s=-
-t=0 0
-a=group:BUNDLE data
-a=msid-semantic: WMS
-m=application 56688 DTLS/SCTP 5000
-c=IN IP6 ff15:g::101
-`, nil},
-		// Bogus "IP7" addrtype
-		{`v=0
-o=- 4358805017720277108 2 IN IP4 0.0.0.0
-s=-
-t=0 0
-a=group:BUNDLE data
-a=msid-semantic: WMS
-m=application 56688 DTLS/SCTP 5000
-c=IN IP7 1.2.3.4
-`, nil},
-	}
-
-	for _, test := range tests {
-		// https://tools.ietf.org/html/rfc4566#section-5: "The sequence
-		// CRLF (0x0d0a) is used to end a record, although parsers
-		// SHOULD be tolerant and also accept records terminated with a
-		// single newline character." We represent the test cases with
-		// LF line endings for convenience, and test them both that way
-		// and with CRLF line endings.
-		lfSDP := test.sdp
-		crlfSDP := strings.Replace(lfSDP, "\n", "\r\n", -1)
-
-		ip := remoteIPFromSDP(lfSDP)
-		if !ip.Equal(test.expected) {
-			t.Errorf("expected %q, got %q from %q", test.expected, ip, lfSDP)
-		}
-		ip = remoteIPFromSDP(crlfSDP)
-		if !ip.Equal(test.expected) {
-			t.Errorf("expected %q, got %q from %q", test.expected, ip, crlfSDP)
-		}
-	}
-}
-
-func TestSessionDescriptions(t *testing.T) {
-	Convey("Session description deserialization", t, func() {
-		for _, test := range []struct {
-			msg string
-			ret *webrtc.SessionDescription
-		}{
-			{
-				"test",
-				nil,
-			},
-			{
-				`{"type":"answer"}`,
-				nil,
-			},
-			{
-				`{"sdp":"test"}`,
-				nil,
-			},
-			{
-				`{"type":"test", "sdp":"test"}`,
-				nil,
-			},
-			{
-				`{"type":"answer", "sdp":"test"}`,
-				&webrtc.SessionDescription{
-					Type: webrtc.SDPTypeAnswer,
-					SDP:  "test",
-				},
-			},
-			{
-				`{"type":"pranswer", "sdp":"test"}`,
-				&webrtc.SessionDescription{
-					Type: webrtc.SDPTypePranswer,
-					SDP:  "test",
-				},
-			},
-			{
-				`{"type":"rollback", "sdp":"test"}`,
-				&webrtc.SessionDescription{
-					Type: webrtc.SDPTypeRollback,
-					SDP:  "test",
-				},
-			},
-			{
-				`{"type":"offer", "sdp":"test"}`,
-				&webrtc.SessionDescription{
-					Type: webrtc.SDPTypeOffer,
-					SDP:  "test",
-				},
-			},
-		} {
-			desc, _ := util.DeserializeSessionDescription(test.msg)
-			So(desc, ShouldResemble, test.ret)
-		}
-	})
-	Convey("Session description serialization", t, func() {
-		for _, test := range []struct {
-			desc *webrtc.SessionDescription
-			ret  string
-		}{
-			{
-				&webrtc.SessionDescription{
-					Type: webrtc.SDPTypeOffer,
-					SDP:  "test",
-				},
-				`{"type":"offer","sdp":"test"}`,
-			},
-		} {
-			msg, err := util.SerializeSessionDescription(test.desc)
-			So(msg, ShouldResemble, test.ret)
-			So(err, ShouldBeNil)
-		}
-	})
-}
-
-func TestBrokerInteractions(t *testing.T) {
-	const sampleSDP = `"v=0\r\no=- 4358805017720277108 2 IN IP4 8.8.8.8\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 8.8.8.8\r\na=candidate:3769337065 1 udp 2122260223 8.8.8.8 56688 typ host generation 0 network-id 1 network-cost 50\r\na=candidate:2921887769 1 tcp 1518280447 8.8.8.8 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"`
-
-	const sampleOffer = `{"type":"offer","sdp":` + sampleSDP + `}`
-	const sampleAnswer = `{"type":"answer","sdp":` + sampleSDP + `}`
-
-	Convey("Proxy connections to broker", t, func() {
-		var err error
-		broker, err = newSignalingServer("localhost", false)
-		So(err, ShouldEqual, nil)
-		tokens = newTokens(0)
-
-		//Mock peerConnection
-		config = webrtc.Configuration{
-			ICEServers: []webrtc.ICEServer{
-				{
-					URLs: []string{"stun:stun.l.google.com:19302"},
-				},
-			},
-		}
-		pc, _ := webrtc.NewPeerConnection(config)
-		offer, _ := util.DeserializeSessionDescription(sampleOffer)
-		pc.SetRemoteDescription(*offer)
-		answer, _ := pc.CreateAnswer(nil)
-		pc.SetLocalDescription(answer)
-
-		Convey("polls broker correctly", func() {
-			var err error
-
-			b, err := messages.EncodePollResponse(sampleOffer, true, "unknown")
-			So(err, ShouldEqual, nil)
-			broker.transport = &MockTransport{
-				http.StatusOK,
-				b,
-			}
-
-			sdp, _ := broker.pollOffer(sampleOffer, DefaultProxyType, "", nil)
-			expectedSDP, _ := strconv.Unquote(sampleSDP)
-			So(sdp.SDP, ShouldResemble, expectedSDP)
-		})
-		Convey("handles poll error", func() {
-			var err error
-
-			b := []byte("test")
-			So(err, ShouldEqual, nil)
-			broker.transport = &MockTransport{
-				http.StatusOK,
-				b,
-			}
-
-			sdp, _ := broker.pollOffer(sampleOffer, DefaultProxyType, "", nil)
-			So(sdp, ShouldBeNil)
-		})
-		Convey("sends answer to broker", func() {
-			var err error
-
-			b, err := messages.EncodeAnswerResponse(true)
-			So(err, ShouldEqual, nil)
-			broker.transport = &MockTransport{
-				http.StatusOK,
-				b,
-			}
-
-			err = broker.sendAnswer(sampleAnswer, pc)
-			So(err, ShouldEqual, nil)
-
-			b, err = messages.EncodeAnswerResponse(false)
-			So(err, ShouldEqual, nil)
-			broker.transport = &MockTransport{
-				http.StatusOK,
-				b,
-			}
-
-			err = broker.sendAnswer(sampleAnswer, pc)
-			So(err, ShouldNotBeNil)
-		})
-		Convey("handles answer error", func() {
-			//Error if faulty transport
-			broker.transport = &FaultyTransport{}
-			err := broker.sendAnswer(sampleAnswer, pc)
-			So(err, ShouldNotBeNil)
-
-			//Error if status code is not ok
-			broker.transport = &MockTransport{
-				http.StatusGone,
-				[]byte(""),
-			}
-			err = broker.sendAnswer("test", pc)
-			So(err, ShouldNotEqual, nil)
-			So(err.Error(), ShouldResemble,
-				"error sending answer to broker: remote returned status code 410")
-
-			//Error if we can't parse broker message
-			broker.transport = &MockTransport{
-				http.StatusOK,
-				[]byte("test"),
-			}
-			err = broker.sendAnswer("test", pc)
-			So(err, ShouldNotBeNil)
-
-			//Error if broker message surpasses read limit
-			broker.transport = &MockTransport{
-				http.StatusOK,
-				make([]byte, 100001),
-			}
-			err = broker.sendAnswer("test", pc)
-			So(err, ShouldNotBeNil)
-		})
-	})
-}
-
-func TestUtilityFuncs(t *testing.T) {
-	Convey("LimitedRead", t, func() {
-		c, s := net.Pipe()
-		Convey("Successful read", func() {
-			go func() {
-				bytes := make([]byte, 50)
-				c.Write(bytes)
-				c.Close()
-			}()
-			bytes, err := limitedRead(s, 60)
-			So(len(bytes), ShouldEqual, 50)
-			So(err, ShouldBeNil)
-		})
-		Convey("Large read", func() {
-			go func() {
-				bytes := make([]byte, 50)
-				c.Write(bytes)
-				c.Close()
-			}()
-			bytes, err := limitedRead(s, 49)
-			So(len(bytes), ShouldEqual, 49)
-			So(err, ShouldEqual, io.ErrUnexpectedEOF)
-		})
-		Convey("Failed read", func() {
-			s.Close()
-			bytes, err := limitedRead(s, 49)
-			So(len(bytes), ShouldEqual, 0)
-			So(err, ShouldEqual, io.ErrClosedPipe)
-		})
-	})
-	Convey("SessionID Generation", t, func() {
-		sid1 := genSessionID()
-		sid2 := genSessionID()
-		So(sid1, ShouldNotEqual, sid2)
-	})
-	Convey("CopyLoop", t, func() {
-		c1, s1 := net.Pipe()
-		c2, s2 := net.Pipe()
-		go copyLoop(s1, s2, nil)
-		go func() {
-			bytes := []byte("Hello!")
-			c1.Write(bytes)
-		}()
-		bytes := make([]byte, 6)
-		n, err := c2.Read(bytes)
-		So(n, ShouldEqual, 6)
-		So(err, ShouldEqual, nil)
-		So(bytes, ShouldResemble, []byte("Hello!"))
-		s1.Close()
-
-		//Check that copy loop has closed other connection
-		_, err = s2.Write(bytes)
-		So(err, ShouldNotBeNil)
-	})
-}
diff --git a/proxy/lib/pt_event_logger.go b/proxy/lib/pt_event_logger.go
deleted file mode 100644
index cb262e4..0000000
--- a/proxy/lib/pt_event_logger.go
+++ /dev/null
@@ -1,52 +0,0 @@
-package snowflake_proxy
-
-import (
-	"git.torproject.org/pluggable-transports/snowflake.git/v2/common/task"
-	"io"
-	"log"
-	"time"
-
-	"git.torproject.org/pluggable-transports/snowflake.git/v2/common/event"
-)
-
-func NewProxyEventLogger(logPeriod time.Duration, output io.Writer) event.SnowflakeEventReceiver {
-	logger := log.New(output, "", log.LstdFlags|log.LUTC)
-	el := &logEventLogger{logPeriod: logPeriod, logger: logger}
-	el.task = &task.Periodic{Interval: logPeriod, Execute: el.logTick}
-	el.task.WaitThenStart()
-	return el
-}
-
-type logEventLogger struct {
-	inboundSum      int
-	outboundSum     int
-	connectionCount int
-	logPeriod       time.Duration
-	task            *task.Periodic
-	logger          *log.Logger
-}
-
-func (p *logEventLogger) OnNewSnowflakeEvent(e event.SnowflakeEvent) {
-	switch e.(type) {
-	case event.EventOnProxyConnectionOver:
-		e := e.(event.EventOnProxyConnectionOver)
-		p.inboundSum += e.InboundTraffic
-		p.outboundSum += e.OutboundTraffic
-		p.connectionCount += 1
-	}
-}
-
-func (p *logEventLogger) logTick() error {
-	inbound, inboundUnit := formatTraffic(p.inboundSum)
-	outbound, outboundUnit := formatTraffic(p.outboundSum)
-	p.logger.Printf("In the last %v, there were %v connections. Traffic Relayed ↑ %v %v, ↓ %v %v.\n",
-		p.logPeriod.String(), p.connectionCount, inbound, inboundUnit, outbound, outboundUnit)
-	p.outboundSum = 0
-	p.inboundSum = 0
-	p.connectionCount = 0
-	return nil
-}
-
-func (p *logEventLogger) Close() error {
-	return p.task.Close()
-}
diff --git a/proxy/lib/snowflake.go b/proxy/lib/snowflake.go
deleted file mode 100644
index f9bcddb..0000000
--- a/proxy/lib/snowflake.go
+++ /dev/null
@@ -1,729 +0,0 @@
-/*
-Package snowflake_proxy provides functionality for creating, starting, and stopping a snowflake
-proxy.
-
-To run a proxy, you must first create a proxy configuration. Unconfigured fields
-will be set to the defined defaults.
-
-	proxy := snowflake_proxy.SnowflakeProxy{
-		BrokerURL: "https://snowflake-broker.example.com",
-		STUNURL: "stun:stun.stunprotocol.org:3478",
-		// ...
-	}
-
-You may then start and stop the proxy. Stopping the proxy will close existing connections and
-the proxy will not poll for more clients.
-
-	go func() {
-		err := proxy.Start()
-		// handle error
-	}
-
-	// ...
-
-	proxy.Stop()
-*/
-package snowflake_proxy
-
-import (
-	"bytes"
-	"crypto/rand"
-	"encoding/base64"
-	"fmt"
-	"io"
-	"io/ioutil"
-	"log"
-	"net"
-	"net/http"
-	"net/url"
-	"strings"
-	"sync"
-	"time"
-
-	"git.torproject.org/pluggable-transports/snowflake.git/v2/common/event"
-	"git.torproject.org/pluggable-transports/snowflake.git/v2/common/messages"
-	"git.torproject.org/pluggable-transports/snowflake.git/v2/common/namematcher"
-	"git.torproject.org/pluggable-transports/snowflake.git/v2/common/task"
-	"git.torproject.org/pluggable-transports/snowflake.git/v2/common/util"
-	"git.torproject.org/pluggable-transports/snowflake.git/v2/common/websocketconn"
-	"github.com/gorilla/websocket"
-	"github.com/pion/ice/v2"
-	"github.com/pion/webrtc/v3"
-)
-
-const DefaultBrokerURL = "https://snowflake-broker.torproject.net/"
-
-const DefaultNATProbeURL = "https://snowflake-broker.torproject.net:8443/probe"
-
-const DefaultRelayURL = "wss://snowflake.bamsoftware.com/"
-
-const DefaultSTUNURL = "stun:stun.stunprotocol.org:3478"
-const DefaultProxyType = "standalone"
-const pollInterval = 5 * time.Second
-
-const (
-	// NATUnknown represents a NAT type which is unknown.
-	NATUnknown = "unknown"
-	// NATRestricted represents a restricted NAT.
-	NATRestricted = "restricted"
-	// NATUnrestricted represents an unrestricted NAT.
-	NATUnrestricted = "unrestricted"
-)
-
-//amount of time after sending an SDP answer before the proxy assumes the
-//client is not going to connect
-const dataChannelTimeout = 20 * time.Second
-
-const readLimit = 100000 //Maximum number of bytes to be read from an HTTP request
-
-var broker *SignalingServer
-
-var currentNATTypeAccess = &sync.RWMutex{}
-
-// currentNATType describes local network environment.
-// Obtain currentNATTypeAccess before access.
-var currentNATType = NATUnknown
-
-func getCurrentNATType() string {
-	currentNATTypeAccess.RLock()
-	defer currentNATTypeAccess.RUnlock()
-	return currentNATType
-}
-
-const (
-	sessionIDLength = 16
-)
-
-var (
-	tokens *tokens_t
-	config webrtc.Configuration
-	client http.Client
-)
-
-// SnowflakeProxy is used to configure an embedded
-// Snowflake in another Go application.
-type SnowflakeProxy struct {
-	// Capacity is the maximum number of clients a Snowflake will serve.
-	// Proxies with a capacity of 0 will accept an unlimited number of clients.
-	Capacity uint
-	// STUNURL is the URL of the STUN server the proxy will use
-	STUNURL string
-	// BrokerURL is the URL of the Snowflake broker
-	BrokerURL string
-	// KeepLocalAddresses indicates whether local SDP candidates will be sent to the broker
-	KeepLocalAddresses bool
-	// RelayURL is the URL of the Snowflake server that all traffic will be relayed to
-	RelayURL string
-	// RelayDomainNamePattern is the pattern specify allowed domain name for relay
-	// If the pattern starts with ^ then an exact match is required.
-	// The rest of pattern is the suffix of domain name.
-	// There is no look ahead assertion when matching domain name suffix,
-	// thus the string prepend the suffix does not need to be empty or ends with a dot.
-	RelayDomainNamePattern string
-	AllowNonTLSRelay       bool
-	// NATProbeURL is the URL of the probe service we use for NAT checks
-	NATProbeURL string
-	// NATTypeMeasurementInterval is time before NAT type is retested
-	NATTypeMeasurementInterval time.Duration
-	// ProxyType is the type reported to the broker, if not provided it "standalone" will be used
-	ProxyType       string
-	EventDispatcher event.SnowflakeEventDispatcher
-	shutdown        chan struct{}
-}
-
-// Checks whether an IP address is a remote address for the client
-func isRemoteAddress(ip net.IP) bool {
-	return !(util.IsLocal(ip) || ip.IsUnspecified() || ip.IsLoopback())
-}
-
-func genSessionID() string {
-	buf := make([]byte, sessionIDLength)
-	_, err := rand.Read(buf)
-	if err != nil {
-		panic(err.Error())
-	}
-	return strings.TrimRight(base64.StdEncoding.EncodeToString(buf), "=")
-}
-
-func limitedRead(r io.Reader, limit int64) ([]byte, error) {
-	p, err := ioutil.ReadAll(&io.LimitedReader{R: r, N: limit + 1})
-	if err != nil {
-		return p, err
-	} else if int64(len(p)) == limit+1 {
-		return p[0:limit], io.ErrUnexpectedEOF
-	}
-	return p, err
-}
-
-// SignalingServer keeps track of the SignalingServer in use by the Snowflake
-type SignalingServer struct {
-	url                *url.URL
-	transport          http.RoundTripper
-	keepLocalAddresses bool
-}
-
-func newSignalingServer(rawURL string, keepLocalAddresses bool) (*SignalingServer, error) {
-	var err error
-	s := new(SignalingServer)
-	s.keepLocalAddresses = keepLocalAddresses
-	s.url, err = url.Parse(rawURL)
-	if err != nil {
-		return nil, fmt.Errorf("invalid broker url: %s", err)
-	}
-
-	s.transport = http.DefaultTransport.(*http.Transport)
-	s.transport.(*http.Transport).ResponseHeaderTimeout = 30 * time.Second
-
-	return s, nil
-}
-
-// Post sends a POST request to the SignalingServer
-func (s *SignalingServer) Post(path string, payload io.Reader) ([]byte, error) {
-
-	req, err := http.NewRequest("POST", path, payload)
-	if err != nil {
-		return nil, err
-	}
-	resp, err := s.transport.RoundTrip(req)
-	if err != nil {
-		return nil, err
-	}
-
-	if resp.StatusCode != http.StatusOK {
-		return nil, fmt.Errorf("remote returned status code %d", resp.StatusCode)
-	}
-
-	defer resp.Body.Close()
-	return limitedRead(resp.Body, readLimit)
-}
-
-func (s *SignalingServer) pollOffer(sid string, proxyType string, acceptedRelayPattern string, shutdown chan struct{}) (*webrtc.SessionDescription, string) {
-	brokerPath := s.url.ResolveReference(&url.URL{Path: "proxy"})
-
-	ticker := time.NewTicker(pollInterval)
-	defer ticker.Stop()
-
-	// Run the loop once before hitting the ticker
-	for ; true; <-ticker.C {
-		select {
-		case <-shutdown:
-			return nil, ""
-		default:
-			numClients := int((tokens.count() / 8) * 8) // Round down to 8
-			currentNATTypeLoaded := getCurrentNATType()
-			body, err := messages.EncodeProxyPollRequestWithRelayPrefix(sid, proxyType, currentNATTypeLoaded, numClients, acceptedRelayPattern)
-			if err != nil {
-				log.Printf("Error encoding poll message: %s", err.Error())
-				return nil, ""
-			}
-			resp, err := s.Post(brokerPath.String(), bytes.NewBuffer(body))
-			if err != nil {
-				log.Printf("error polling broker: %s", err.Error())
-			}
-
-			offer, _, relayURL, err := messages.DecodePollResponseWithRelayURL(resp)
-			if err != nil {
-				log.Printf("Error reading broker response: %s", err.Error())
-				log.Printf("body: %s", resp)
-				return nil, ""
-			}
-			if offer != "" {
-				offer, err := util.DeserializeSessionDescription(offer)
-				if err != nil {
-					log.Printf("Error processing session description: %s", err.Error())
-					return nil, ""
-				}
-				return offer, relayURL
-
-			}
-		}
-	}
-	return nil, ""
-}
-
-func (s *SignalingServer) sendAnswer(sid string, pc *webrtc.PeerConnection) error {
-	brokerPath := s.url.ResolveReference(&url.URL{Path: "answer"})
-	ld := pc.LocalDescription()
-	if !s.keepLocalAddresses {
-		ld = &webrtc.SessionDescription{
-			Type: ld.Type,
-			SDP:  util.StripLocalAddresses(ld.SDP),
-		}
-	}
-	answer, err := util.SerializeSessionDescription(ld)
-	if err != nil {
-		return err
-	}
-	body, err := messages.EncodeAnswerRequest(answer, sid)
-	if err != nil {
-		return err
-	}
-	resp, err := s.Post(brokerPath.String(), bytes.NewBuffer(body))
-	if err != nil {
-		return fmt.Errorf("error sending answer to broker: %s", err.Error())
-	}
-
-	success, err := messages.DecodeAnswerResponse(resp)
-	if err != nil {
-		return err
-	}
-	if !success {
-		return fmt.Errorf("broker returned client timeout")
-	}
-
-	return nil
-}
-
-func copyLoop(c1 io.ReadWriteCloser, c2 io.ReadWriteCloser, shutdown chan struct{}) {
-	var once sync.Once
-	defer c2.Close()
-	defer c1.Close()
-	done := make(chan struct{})
-	copyer := func(dst io.ReadWriteCloser, src io.ReadWriteCloser) {
-		// Ignore io.ErrClosedPipe because it is likely caused by the
-		// termination of copyer in the other direction.
-		if _, err := io.Copy(dst, src); err != nil && err != io.ErrClosedPipe {
-			log.Printf("io.Copy inside CopyLoop generated an error: %v", err)
-		}
-		once.Do(func() {
-			close(done)
-		})
-	}
-
-	go copyer(c1, c2)
-	go copyer(c2, c1)
-
-	select {
-	case <-done:
-	case <-shutdown:
-	}
-	log.Println("copy loop ended")
-}
-
-// We pass conn.RemoteAddr() as an additional parameter, rather than calling
-// conn.RemoteAddr() inside this function, as a workaround for a hang that
-// otherwise occurs inside of conn.pc.RemoteDescription() (called by
-// RemoteAddr). https://bugs.torproject.org/18628#comment:8
-func (sf *SnowflakeProxy) datachannelHandler(conn *webRTCConn, remoteAddr net.Addr, relayURL string) {
-	defer conn.Close()
-	defer tokens.ret()
-
-	if relayURL == "" {
-		relayURL = sf.RelayURL
-	}
-	u, err := url.Parse(relayURL)
-	if err != nil {
-		log.Fatalf("invalid relay url: %s", err)
-	}
-
-	if remoteAddr != nil {
-		// Encode client IP address in relay URL
-		q := u.Query()
-		clientIP := remoteAddr.String()
-		q.Set("client_ip", clientIP)
-		u.RawQuery = q.Encode()
-	} else {
-		log.Printf("no remote address given in websocket")
-	}
-
-	ws, _, err := websocket.DefaultDialer.Dial(u.String(), nil)
-	if err != nil {
-		log.Printf("error dialing relay: %s = %s", u.String(), err)
-		return
-	}
-	wsConn := websocketconn.New(ws)
-	log.Printf("connected to relay: %v", relayURL)
-	defer wsConn.Close()
-	copyLoop(conn, wsConn, sf.shutdown)
-	log.Printf("datachannelHandler ends")
-}
-
-type dataChannelHandlerWithRelayURL struct {
-	RelayURL string
-	sf       *SnowflakeProxy
-}
-
-func (d dataChannelHandlerWithRelayURL) datachannelHandler(conn *webRTCConn, remoteAddr net.Addr) {
-	d.sf.datachannelHandler(conn, remoteAddr, d.RelayURL)
-}
-
-// Create a PeerConnection from an SDP offer. Blocks until the gathering of ICE
-// candidates is complete and the answer is available in LocalDescription.
-// Installs an OnDataChannel callback that creates a webRTCConn and passes it to
-// datachannelHandler.
-func (sf *SnowflakeProxy) makePeerConnectionFromOffer(sdp *webrtc.SessionDescription,
-	config webrtc.Configuration,
-	dataChan chan struct{},
-	handler func(conn *webRTCConn, remoteAddr net.Addr)) (*webrtc.PeerConnection, error) {
-
-	s := webrtc.SettingEngine{}
-	s.SetICEMulticastDNSMode(ice.MulticastDNSModeDisabled)
-	api := webrtc.NewAPI(webrtc.WithSettingEngine(s))
-	pc, err := api.NewPeerConnection(config)
-	if err != nil {
-		return nil, fmt.Errorf("accept: NewPeerConnection: %s", err)
-	}
-	pc.OnDataChannel(func(dc *webrtc.DataChannel) {
-		log.Println("OnDataChannel")
-		close(dataChan)
-
-		pr, pw := io.Pipe()
-		conn := &webRTCConn{pc: pc, dc: dc, pr: pr, eventLogger: sf.EventDispatcher}
-		conn.bytesLogger = newBytesSyncLogger()
-
-		dc.OnOpen(func() {
-			log.Println("OnOpen channel")
-		})
-		dc.OnClose(func() {
-			conn.lock.Lock()
-			defer conn.lock.Unlock()
-			log.Println("OnClose channel")
-			log.Println(conn.bytesLogger.ThroughputSummary())
-			in, out := conn.bytesLogger.GetStat()
-			conn.eventLogger.OnNewSnowflakeEvent(event.EventOnProxyConnectionOver{
-				InboundTraffic:  in,
-				OutboundTraffic: out,
-			})
-			conn.dc = nil
-			dc.Close()
-			pw.Close()
-		})
-		dc.OnMessage(func(msg webrtc.DataChannelMessage) {
-			var n int
-			n, err = pw.Write(msg.Data)
-			if err != nil {
-				if inerr := pw.CloseWithError(err); inerr != nil {
-					log.Printf("close with error generated an error: %v", inerr)
-				}
-			}
-			conn.bytesLogger.AddOutbound(n)
-			if n != len(msg.Data) {
-				panic("short write")
-			}
-		})
-
-		go handler(conn, conn.RemoteAddr())
-	})
-	// As of v3.0.0, pion-webrtc uses trickle ICE by default.
-	// We have to wait for candidate gathering to complete
-	// before we send the offer
-	done := webrtc.GatheringCompletePromise(pc)
-	err = pc.SetRemoteDescription(*sdp)
-	if err != nil {
-		if inerr := pc.Close(); inerr != nil {
-			log.Printf("unable to call pc.Close after pc.SetRemoteDescription with error: %v", inerr)
-		}
-		return nil, fmt.Errorf("accept: SetRemoteDescription: %s", err)
-	}
-	log.Println("sdp offer successfully received.")
-
-	log.Println("Generating answer...")
-	answer, err := pc.CreateAnswer(nil)
-	// blocks on ICE gathering. we need to add a timeout if needed
-	// not putting this in a separate go routine, because we need
-	// SetLocalDescription(answer) to be called before sendAnswer
-	if err != nil {
-		if inerr := pc.Close(); inerr != nil {
-			log.Printf("ICE gathering has generated an error when calling pc.Close: %v", inerr)
-		}
-		return nil, err
-	}
-
-	err = pc.SetLocalDescription(answer)
-	if err != nil {
-		if err = pc.Close(); err != nil {
-			log.Printf("pc.Close after setting local description returned : %v", err)
-		}
-		return nil, err
-	}
-	// Wait for ICE candidate gathering to complete
-	<-done
-	return pc, nil
-}
-
-// Create a new PeerConnection. Blocks until the gathering of ICE
-// candidates is complete and the answer is available in LocalDescription.
-func (sf *SnowflakeProxy) makeNewPeerConnection(config webrtc.Configuration,
-	dataChan chan struct{}) (*webrtc.PeerConnection, error) {
-
-	s := webrtc.SettingEngine{}
-	s.SetICEMulticastDNSMode(ice.MulticastDNSModeDisabled)
-	api := webrtc.NewAPI(webrtc.WithSettingEngine(s))
-	pc, err := api.NewPeerConnection(config)
-	if err != nil {
-		return nil, fmt.Errorf("accept: NewPeerConnection: %s", err)
-	}
-
-	// Must create a data channel before creating an offer
-	// https://github.com/pion/webrtc/wiki/Release-WebRTC@v3.0.0
-	dc, err := pc.CreateDataChannel("test", &webrtc.DataChannelInit{})
-	if err != nil {
-		log.Printf("CreateDataChannel ERROR: %s", err)
-		return nil, err
-	}
-	dc.OnOpen(func() {
-		log.Println("WebRTC: DataChannel.OnOpen")
-		close(dataChan)
-	})
-	dc.OnClose(func() {
-		log.Println("WebRTC: DataChannel.OnClose")
-		dc.Close()
-	})
-
-	offer, err := pc.CreateOffer(nil)
-	// TODO: Potentially timeout and retry if ICE isn't working.
-	if err != nil {
-		log.Println("Failed to prepare offer", err)
-		pc.Close()
-		return nil, err
-	}
-	log.Println("WebRTC: Created offer")
-
-	// As of v3.0.0, pion-webrtc uses trickle ICE by default.
-	// We have to wait for candidate gathering to complete
-	// before we send the offer
-	done := webrtc.GatheringCompletePromise(pc)
-	err = pc.SetLocalDescription(offer)
-	if err != nil {
-		log.Println("Failed to prepare offer", err)
-		pc.Close()
-		return nil, err
-	}
-	log.Println("WebRTC: Set local description")
-
-	// Wait for ICE candidate gathering to complete
-	<-done
-	return pc, nil
-}
-
-func (sf *SnowflakeProxy) runSession(sid string) {
-	offer, relayURL := broker.pollOffer(sid, sf.ProxyType, sf.RelayDomainNamePattern, sf.shutdown)
-	if offer == nil {
-		log.Printf("bad offer from broker")
-		tokens.ret()
-		return
-	}
-	matcher := namematcher.NewNameMatcher(sf.RelayDomainNamePattern)
-	parsedRelayURL, err := url.Parse(relayURL)
-	if err != nil {
-		log.Printf("bad offer from broker: bad Relay URL %v", err.Error())
-		tokens.ret()
-		return
-	}
-	if relayURL != "" && (!matcher.IsMember(parsedRelayURL.Hostname()) || (!sf.AllowNonTLSRelay && parsedRelayURL.Scheme != "wss")) {
-		log.Printf("bad offer from broker: rejected Relay URL")
-		tokens.ret()
-		return
-	}
-	dataChan := make(chan struct{})
-	dataChannelAdaptor := dataChannelHandlerWithRelayURL{RelayURL: relayURL, sf: sf}
-	pc, err := sf.makePeerConnectionFromOffer(offer, config, dataChan, dataChannelAdaptor.datachannelHandler)
-	if err != nil {
-		log.Printf("error making WebRTC connection: %s", err)
-		tokens.ret()
-		return
-	}
-	err = broker.sendAnswer(sid, pc)
-	if err != nil {
-		log.Printf("error sending answer to client through broker: %s", err)
-		if inerr := pc.Close(); inerr != nil {
-			log.Printf("error calling pc.Close: %v", inerr)
-		}
-		tokens.ret()
-		return
-	}
-	// Set a timeout on peerconnection. If the connection state has not
-	// advanced to PeerConnectionStateConnected in this time,
-	// destroy the peer connection and return the token.
-	select {
-	case <-dataChan:
-		log.Println("Connection successful.")
-	case <-time.After(dataChannelTimeout):
-		log.Println("Timed out waiting for client to open data channel.")
-		if err := pc.Close(); err != nil {
-			log.Printf("error calling pc.Close: %v", err)
-		}
-		tokens.ret()
-	}
-}
-
-// Start configures and starts a Snowflake, fully formed and special. Configuration
-// values that are unset will default to their corresponding default values.
-func (sf *SnowflakeProxy) Start() error {
-	var err error
-
-	log.Println("starting")
-	sf.shutdown = make(chan struct{})
-
-	// blank configurations revert to default
-	if sf.BrokerURL == "" {
-		sf.BrokerURL = DefaultBrokerURL
-	}
-	if sf.RelayURL == "" {
-		sf.RelayURL = DefaultRelayURL
-	}
-	if sf.STUNURL == "" {
-		sf.STUNURL = DefaultSTUNURL
-	}
-	if sf.NATProbeURL == "" {
-		sf.NATProbeURL = DefaultNATProbeURL
-	}
-	if sf.ProxyType == "" {
-		sf.ProxyType = DefaultProxyType
-	}
-	if sf.EventDispatcher == nil {
-		sf.EventDispatcher = event.NewSnowflakeEventDispatcher()
-	}
-
-	broker, err = newSignalingServer(sf.BrokerURL, sf.KeepLocalAddresses)
-	if err != nil {
-		return fmt.Errorf("error configuring broker: %s", err)
-	}
-
-	_, err = url.Parse(sf.STUNURL)
-	if err != nil {
-		return fmt.Errorf("invalid stun url: %s", err)
-	}
-	_, err = url.Parse(sf.RelayURL)
-	if err != nil {
-		return fmt.Errorf("invalid relay url: %s", err)
-	}
-
-	if !namematcher.IsValidRule(sf.RelayDomainNamePattern) {
-		return fmt.Errorf("invalid relay domain name pattern")
-	}
-
-	config = webrtc.Configuration{
-		ICEServers: []webrtc.ICEServer{
-			{
-				URLs: []string{sf.STUNURL},
-			},
-		},
-	}
-	tokens = newTokens(sf.Capacity)
-
-	// use probetest to determine NAT compatability
-	sf.checkNATType(config, sf.NATProbeURL)
-
-	currentNATTypeLoaded := getCurrentNATType()
-
-	log.Printf("NAT type: %s", currentNATTypeLoaded)
-
-	NatRetestTask := task.Periodic{
-		Interval: sf.NATTypeMeasurementInterval,
-		Execute: func() error {
-			sf.checkNATType(config, sf.NATProbeURL)
-			return nil
-		},
-	}
-
-	if sf.NATTypeMeasurementInterval != 0 {
-		NatRetestTask.WaitThenStart()
-		defer NatRetestTask.Close()
-	}
-
-	ticker := time.NewTicker(pollInterval)
-	defer ticker.Stop()
-
-	for ; true; <-ticker.C {
-		select {
-		case <-sf.shutdown:
-			return nil
-		default:
-			tokens.get()
-			sessionID := genSessionID()
-			sf.runSession(sessionID)
-		}
-	}
-	return nil
-}
-
-// Stop closes all existing connections and shuts down the Snowflake.
-func (sf *SnowflakeProxy) Stop() {
-	close(sf.shutdown)
-}
-
-func (sf *SnowflakeProxy) checkNATType(config webrtc.Configuration, probeURL string) {
-
-	probe, err := newSignalingServer(probeURL, false)
-	if err != nil {
-		log.Printf("Error parsing url: %s", err.Error())
-	}
-
-	// create offer
-	dataChan := make(chan struct{})
-	pc, err := sf.makeNewPeerConnection(config, dataChan)
-	if err != nil {
-		log.Printf("error making WebRTC connection: %s", err)
-		return
-	}
-
-	offer := pc.LocalDescription()
-	sdp, err := util.SerializeSessionDescription(offer)
-	log.Printf("Offer: %s", sdp)
-	if err != nil {
-		log.Printf("Error encoding probe message: %s", err.Error())
-		return
-	}
-
-	// send offer
-	body, err := messages.EncodePollResponse(sdp, true, "")
-	if err != nil {
-		log.Printf("Error encoding probe message: %s", err.Error())
-		return
-	}
-	resp, err := probe.Post(probe.url.String(), bytes.NewBuffer(body))
-	if err != nil {
-		log.Printf("error polling probe: %s", err.Error())
-		return
-	}
-
-	sdp, _, err = messages.DecodeAnswerRequest(resp)
-	if err != nil {
-		log.Printf("Error reading probe response: %s", err.Error())
-		return
-	}
-	answer, err := util.DeserializeSessionDescription(sdp)
-	if err != nil {
-		log.Printf("Error setting answer: %s", err.Error())
-		return
-	}
-	err = pc.SetRemoteDescription(*answer)
-	if err != nil {
-		log.Printf("Error setting answer: %s", err.Error())
-		return
-	}
-
-	currentNATTypeLoaded := getCurrentNATType()
-
-	currentNATTypeTestResult := NATUnknown
-	select {
-	case <-dataChan:
-		currentNATTypeTestResult = NATUnrestricted
-	case <-time.After(dataChannelTimeout):
-		currentNATTypeTestResult = NATRestricted
-	}
-
-	currentNATTypeToStore := NATUnknown
-	switch currentNATTypeLoaded + "->" + currentNATTypeTestResult {
-	case NATUnrestricted + "->" + NATUnknown:
-		currentNATTypeToStore = NATUnrestricted
-
-	case NATRestricted + "->" + NATUnknown:
-		currentNATTypeToStore = NATRestricted
-
-	default:
-		currentNATTypeToStore = currentNATTypeTestResult
-	}
-
-	log.Printf("NAT Type measurement: %v -> %v = %v\n", currentNATTypeLoaded, currentNATTypeTestResult, currentNATTypeToStore)
-
-	currentNATTypeAccess.Lock()
-	currentNATType = currentNATTypeToStore
-	currentNATTypeAccess.Unlock()
-
-	if err := pc.Close(); err != nil {
-		log.Printf("error calling pc.Close: %v", err)
-	}
-
-}
diff --git a/proxy/lib/tokens.go b/proxy/lib/tokens.go
deleted file mode 100644
index d312ecf..0000000
--- a/proxy/lib/tokens.go
+++ /dev/null
@@ -1,44 +0,0 @@
-package snowflake_proxy
-
-import (
-	"sync/atomic"
-)
-
-type tokens_t struct {
-	ch       chan struct{}
-	capacity uint
-	clients  int64
-}
-
-func newTokens(capacity uint) *tokens_t {
-	var ch chan struct{}
-	if capacity != 0 {
-		ch = make(chan struct{}, capacity)
-	}
-
-	return &tokens_t{
-		ch:       ch,
-		capacity: capacity,
-		clients:  0,
-	}
-}
-
-func (t *tokens_t) get() {
-	atomic.AddInt64(&t.clients, 1)
-
-	if t.capacity != 0 {
-		t.ch <- struct{}{}
-	}
-}
-
-func (t *tokens_t) ret() {
-	atomic.AddInt64(&t.clients, -1)
-
-	if t.capacity != 0 {
-		<-t.ch
-	}
-}
-
-func (t tokens_t) count() int64 {
-	return atomic.LoadInt64(&t.clients)
-}
diff --git a/proxy/lib/tokens_test.go b/proxy/lib/tokens_test.go
deleted file mode 100644
index 4393a21..0000000
--- a/proxy/lib/tokens_test.go
+++ /dev/null
@@ -1,28 +0,0 @@
-package snowflake_proxy
-
-import (
-	"testing"
-
-	. "github.com/smartystreets/goconvey/convey"
-)
-
-func TestTokens(t *testing.T) {
-	Convey("Tokens", t, func() {
-		tokens := newTokens(2)
-		So(tokens.count(), ShouldEqual, 0)
-		tokens.get()
-		So(tokens.count(), ShouldEqual, 1)
-		tokens.ret()
-		So(tokens.count(), ShouldEqual, 0)
-	})
-	Convey("Tokens capacity 0", t, func() {
-		tokens := newTokens(0)
-		So(tokens.count(), ShouldEqual, 0)
-		for i := 0; i < 20; i++ {
-			tokens.get()
-		}
-		So(tokens.count(), ShouldEqual, 20)
-		tokens.ret()
-		So(tokens.count(), ShouldEqual, 19)
-	})
-}
diff --git a/proxy/lib/util.go b/proxy/lib/util.go
deleted file mode 100644
index c7b3684..0000000
--- a/proxy/lib/util.go
+++ /dev/null
@@ -1,99 +0,0 @@
-package snowflake_proxy
-
-import (
-	"fmt"
-	"time"
-)
-
-// bytesLogger is an interface which is used to allow logging the throughput
-// of the Snowflake. A default bytesLogger(bytesNullLogger) does nothing.
-type bytesLogger interface {
-	AddOutbound(int)
-	AddInbound(int)
-	ThroughputSummary() string
-	GetStat() (in int, out int)
-}
-
-// bytesNullLogger Default bytesLogger does nothing.
-type bytesNullLogger struct{}
-
-// AddOutbound in bytesNullLogger does nothing
-func (b bytesNullLogger) AddOutbound(amount int) {}
-
-// AddInbound in bytesNullLogger does nothing
-func (b bytesNullLogger) AddInbound(amount int) {}
-
-// ThroughputSummary in bytesNullLogger does nothing
-func (b bytesNullLogger) ThroughputSummary() string { return "" }
-
-func (b bytesNullLogger) GetStat() (in int, out int) { return -1, -1 }
-
-// bytesSyncLogger uses channels to safely log from multiple sources with output
-// occuring at reasonable intervals.
-type bytesSyncLogger struct {
-	outboundChan, inboundChan              chan int
-	outbound, inbound, outEvents, inEvents int
-	start                                  time.Time
-}
-
-// newBytesSyncLogger returns a new bytesSyncLogger and starts it loggin.
-func newBytesSyncLogger() *bytesSyncLogger {
-	b := &bytesSyncLogger{
-		outboundChan: make(chan int, 5),
-		inboundChan:  make(chan int, 5),
-	}
-	go b.log()
-	b.start = time.Now()
-	return b
-}
-
-func (b *bytesSyncLogger) log() {
-	for {
-		select {
-		case amount := <-b.outboundChan:
-			b.outbound += amount
-			b.outEvents++
-		case amount := <-b.inboundChan:
-			b.inbound += amount
-			b.inEvents++
-		}
-	}
-}
-
-// AddOutbound add a number of bytes to the outbound total reported by the logger
-func (b *bytesSyncLogger) AddOutbound(amount int) {
-	b.outboundChan <- amount
-}
-
-// AddInbound add a number of bytes to the inbound total reported by the logger
-func (b *bytesSyncLogger) AddInbound(amount int) {
-	b.inboundChan <- amount
-}
-
-// ThroughputSummary view a formatted summary of the throughput totals
-func (b *bytesSyncLogger) ThroughputSummary() string {
-	inbound := b.inbound
-	outbound := b.outbound
-
-	inbound, inUnit := formatTraffic(inbound)
-	outbound, outUnit := formatTraffic(outbound)
-
-	t := time.Now()
-	return fmt.Sprintf("Traffic throughput (up|down): %d %s|%d %s -- (%d OnMessages, %d Sends, over %d seconds)", inbound, inUnit, outbound, outUnit, b.outEvents, b.inEvents, int(t.Sub(b.start).Seconds()))
-}
-
-func (b *bytesSyncLogger) GetStat() (in int, out int) { return b.inbound, b.outbound }
-
-func formatTraffic(amount int) (value int, unit string) {
-	value = amount
-	units := []string{"B", "KB", "MB", "GB"}
-	for i, u := range units {
-		unit = u
-		if (value < 1000) || (i == len(units)-1) {
-			break
-		}
-		value = value / 1000
-	}
-	return
-
-}
diff --git a/proxy/lib/webrtcconn.go b/proxy/lib/webrtcconn.go
deleted file mode 100644
index 919a679..0000000
--- a/proxy/lib/webrtcconn.go
+++ /dev/null
@@ -1,123 +0,0 @@
-package snowflake_proxy
-
-import (
-	"fmt"
-	"io"
-	"log"
-	"net"
-	"regexp"
-	"sync"
-	"time"
-
-	"git.torproject.org/pluggable-transports/snowflake.git/v2/common/event"
-	"github.com/pion/ice/v2"
-	"github.com/pion/sdp/v3"
-	"github.com/pion/webrtc/v3"
-)
-
-var remoteIPPatterns = []*regexp.Regexp{
-	/* IPv4 */
-	regexp.MustCompile(`(?m)^c=IN IP4 ([\d.]+)(?:(?:\/\d+)?\/\d+)?(:? |\r?\n)`),
-	/* IPv6 */
-	regexp.MustCompile(`(?m)^c=IN IP6 ([0-9A-Fa-f:.]+)(?:\/\d+)?(:? |\r?\n)`),
-}
-
-type webRTCConn struct {
-	dc *webrtc.DataChannel
-	pc *webrtc.PeerConnection
-	pr *io.PipeReader
-
-	lock sync.Mutex // Synchronization for DataChannel destruction
-	once sync.Once  // Synchronization for PeerConnection destruction
-
-	bytesLogger bytesLogger
-	eventLogger event.SnowflakeEventReceiver
-}
-
-func (c *webRTCConn) Read(b []byte) (int, error) {
-	return c.pr.Read(b)
-}
-
-func (c *webRTCConn) Write(b []byte) (int, error) {
-	c.bytesLogger.AddInbound(len(b))
-	c.lock.Lock()
-	defer c.lock.Unlock()
-	if c.dc != nil {
-		c.dc.Send(b)
-	}
-	return len(b), nil
-}
-
-func (c *webRTCConn) Close() (err error) {
-	c.once.Do(func() {
-		err = c.pc.Close()
-	})
-	return
-}
-
-func (c *webRTCConn) LocalAddr() net.Addr {
-	return nil
-}
-
-func (c *webRTCConn) RemoteAddr() net.Addr {
-	//Parse Remote SDP offer and extract client IP
-	clientIP := remoteIPFromSDP(c.pc.RemoteDescription().SDP)
-	if clientIP == nil {
-		return nil
-	}
-	return &net.IPAddr{IP: clientIP, Zone: ""}
-}
-
-func (c *webRTCConn) SetDeadline(t time.Time) error {
-	// nolint: golint
-	return fmt.Errorf("SetDeadline not implemented")
-}
-
-func (c *webRTCConn) SetReadDeadline(t time.Time) error {
-	// nolint: golint
-	return fmt.Errorf("SetReadDeadline not implemented")
-}
-
-func (c *webRTCConn) SetWriteDeadline(t time.Time) error {
-	// nolint: golint
-	return fmt.Errorf("SetWriteDeadline not implemented")
-}
-
-func remoteIPFromSDP(str string) net.IP {
-	// Look for remote IP in "a=candidate" attribute fields
-	// https://tools.ietf.org/html/rfc5245#section-15.1
-	var desc sdp.SessionDescription
-	err := desc.Unmarshal([]byte(str))
-	if err != nil {
-		log.Println("Error parsing SDP: ", err.Error())
-		return nil
-	}
-	for _, m := range desc.MediaDescriptions {
-		for _, a := range m.Attributes {
-			if a.IsICECandidate() {
-				c, err := ice.UnmarshalCandidate(a.Value)
-				if err == nil {
-					ip := net.ParseIP(c.Address())
-					if ip != nil && isRemoteAddress(ip) {
-						return ip
-					}
-				}
-			}
-		}
-	}
-	// Finally look for remote IP in "c=" Connection Data field
-	// https://tools.ietf.org/html/rfc4566#section-5.7
-	for _, pattern := range remoteIPPatterns {
-		m := pattern.FindStringSubmatch(str)
-		if m != nil {
-			// Ignore parsing errors, ParseIP returns nil.
-			ip := net.ParseIP(m[1])
-			if ip != nil && isRemoteAddress(ip) {
-				return ip
-			}
-
-		}
-	}
-
-	return nil
-}
diff --git a/proxy/main.go b/proxy/main.go
deleted file mode 100644
index 563b3de..0000000
--- a/proxy/main.go
+++ /dev/null
@@ -1,80 +0,0 @@
-package main
-
-import (
-	"flag"
-	"git.torproject.org/pluggable-transports/snowflake.git/v2/common/event"
-	"io"
-	"io/ioutil"
-	"log"
-	"os"
-	"time"
-
-	"git.torproject.org/pluggable-transports/snowflake.git/v2/common/safelog"
-	sf "git.torproject.org/pluggable-transports/snowflake.git/v2/proxy/lib"
-)
-
-func main() {
-	capacity := flag.Uint("capacity", 0, "maximum concurrent clients")
-	stunURL := flag.String("stun", sf.DefaultSTUNURL, "STUN URL")
-	logFilename := flag.String("log", "", "log filename")
-	rawBrokerURL := flag.String("broker", sf.DefaultBrokerURL, "broker URL")
-	unsafeLogging := flag.Bool("unsafe-logging", false, "prevent logs from being scrubbed")
-	keepLocalAddresses := flag.Bool("keep-local-addresses", false, "keep local LAN address ICE candidates")
-	relayURL := flag.String("relay", sf.DefaultRelayURL, "websocket relay URL")
-	allowedRelayHostNamePattern := flag.String("allowed-relay-hostname-pattern", "snowflake.torproject.net$", "a pattern to specify allowed hostname pattern for relay URL.")
-	allowNonTLSRelay := flag.Bool("allow-non-tls-relay", false, "allow relay without tls encryption")
-	NATTypeMeasurementInterval := flag.Duration("nat-retest-interval", time.Hour*24,
-		"the time interval in second before NAT type is retested, 0s disables retest. Valid time units are \"s\", \"m\", \"h\". ")
-	SummaryInterval := flag.Duration("summary-interval", time.Hour,
-		"the time interval to output summary, 0s disables summaries. Valid time units are \"s\", \"m\", \"h\". ")
-	verboseLogging := flag.Bool("verbose", false, "increase log verbosity")
-
-	flag.Parse()
-
-	eventLogger := event.NewSnowflakeEventDispatcher()
-
-	proxy := sf.SnowflakeProxy{
-		Capacity:           uint(*capacity),
-		STUNURL:            *stunURL,
-		BrokerURL:          *rawBrokerURL,
-		KeepLocalAddresses: *keepLocalAddresses,
-		RelayURL:           *relayURL,
-
-		NATTypeMeasurementInterval: *NATTypeMeasurementInterval,
-		EventDispatcher:            eventLogger,
-
-		RelayDomainNamePattern: *allowedRelayHostNamePattern,
-		AllowNonTLSRelay:       *allowNonTLSRelay,
-	}
-
-	var logOutput io.Writer = os.Stderr
-	var eventlogOutput io.Writer = os.Stderr
-	log.SetFlags(log.LstdFlags | log.LUTC)
-
-	if !*verboseLogging {
-		logOutput = ioutil.Discard
-	}
-
-	if *logFilename != "" {
-		f, err := os.OpenFile(*logFilename, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0600)
-		if err != nil {
-			log.Fatal(err)
-		}
-		defer f.Close()
-		logOutput = io.MultiWriter(logOutput, f)
-		eventlogOutput = io.MultiWriter(eventlogOutput, f)
-	}
-	if *unsafeLogging {
-		log.SetOutput(logOutput)
-	} else {
-		log.SetOutput(&safelog.LogScrubber{Output: logOutput})
-	}
-
-	periodicEventLogger := sf.NewProxyEventLogger(*SummaryInterval, eventlogOutput)
-	eventLogger.AddSnowflakeEventListener(periodicEventLogger)
-
-	err := proxy.Start()
-	if err != nil {
-		log.Fatal(err)
-	}
-}
diff --git a/proxy/make.js b/proxy/make.js
new file mode 100755
index 0000000..c7be058
--- /dev/null
+++ b/proxy/make.js
@@ -0,0 +1,187 @@
+#!/usr/bin/env node
+
+/* global require, process */
+
+var { writeFileSync, readdirSync, statSync } = require('fs');
+var { execSync, spawn } = require('child_process');
+var cldr = require('cldr');
+
+// All files required.
+var FILES = [
+  'broker.js',
+  'config.js',
+  'proxypair.js',
+  'snowflake.js',
+  'ui.js',
+  'util.js',
+  'websocket.js',
+  'shims.js'
+];
+
+var FILES_SPEC = [
+  'spec/broker.spec.js',
+  'spec/init.spec.js',
+  'spec/proxypair.spec.js',
+  'spec/snowflake.spec.js',
+  'spec/ui.spec.js',
+  'spec/util.spec.js',
+  'spec/websocket.spec.js'
+];
+
+var STATIC = 'static';
+
+var SHARED_FILES = [
+  'embed.html',
+  'embed.css',
+  'popup.js',
+  'assets',
+  '_locales',
+];
+
+var concatJS = function(outDir, init, outFile, pre) {
+  var files = FILES.concat(`init-${init}.js`);
+  var outPath = `${outDir}/${outFile}`;
+  writeFileSync(outPath, pre, 'utf8');
+  execSync(`cat ${files.join(' ')} >> ${outPath}`);
+};
+
+var copyTranslations = function(outDir) {
+  execSync('git submodule update --init -- translation')
+  execSync(`cp -rf translation/* ${outDir}/_locales/`);
+};
+
+var getDisplayName = function(locale) {
+  var code = locale.split("_")[0];
+  try {
+    var name = cldr.extractLanguageDisplayNames(code)[code];
+  }
+  catch(e) {
+    return '';
+  }
+  if (name === undefined) {
+    return '';
+  }
+  return name;
+}
+
+var availableLangs = function() {
+  let out = "const availableLangs = new Set([\n";
+  let dirs = readdirSync('translation').filter((f) => {
+    const s = statSync(`translation/${f}`);
+    return s.isDirectory();
+  });
+  dirs.push('en_US');
+  dirs.sort();
+  dirs = dirs.map(d => `  '${d}',`);
+  out += dirs.join("\n");
+  out += "\n]);\n\n";
+  return out;
+};
+
+var translatedLangs = function() {
+  let out = "const availableLangs = {\n";
+  let dirs = readdirSync('translation').filter((f) => {
+    const s = statSync(`translation/${f}`);
+    return s.isDirectory();
+  });
+  dirs.push('en_US');
+  dirs.sort();
+  dirs = dirs.map(d => `'${d}': {"name": '${getDisplayName(d)}'},`);
+  out += dirs.join("\n");
+  out += "\n};\n\n";
+  return out;
+};
+var tasks = new Map();
+
+var task = function(key, msg, func) {
+  tasks.set(key, {
+    msg, func
+  });
+};
+
+task('test', 'snowflake unit tests', function() {
+  var jasmineFiles, outFile, proc;
+  execSync('mkdir -p test');
+  execSync('jasmine init >&-');
+  // Simply concat all the files because we're not using node exports.
+  jasmineFiles = FILES.concat('init-testing.js', FILES_SPEC);
+  outFile = 'test/bundle.spec.js';
+  execSync('echo "TESTING = true" > ' + outFile);
+  execSync('cat ' + jasmineFiles.join(' ') + ' | cat >> ' + outFile);
+  proc = spawn('jasmine', ['test/bundle.spec.js'], {
+    stdio: 'inherit'
+  });
+  proc.on("exit", function(code) {
+    process.exit(code);
+  });
+});
+
+task('build', 'build the snowflake proxy', function() {
+  const outDir = 'build';
+  execSync(`rm -rf ${outDir}`);
+  execSync(`cp -r ${STATIC}/ ${outDir}/`);
+  copyTranslations(outDir);
+  concatJS(outDir, 'badge', 'embed.js', availableLangs());
+  writeFileSync(`${outDir}/index.js`, translatedLangs(), 'utf8');
+  execSync(`cat ${STATIC}/index.js >> ${outDir}/index.js`);
+  console.log('Snowflake prepared.');
+});
+
+task('webext', 'build the webextension', function() {
+  const outDir = 'webext';
+  execSync(`git clean -f -x -d ${outDir}/`);
+  execSync(`cp -r ${STATIC}/{${SHARED_FILES.join(',')}} ${outDir}/`, { shell: '/bin/bash' });
+  copyTranslations(outDir);
+  concatJS(outDir, 'webext', 'snowflake.js', '');
+  console.log('Webextension prepared.');
+});
+
+task('node', 'build the node binary', function() {
+  execSync('mkdir -p build');
+  concatJS('build', 'node', 'snowflake.js', '');
+  console.log('Node prepared.');
+});
+
+task('pack-webext', 'pack the webextension for deployment', function() {
+  try {
+    execSync(`rm -f source.zip`);
+    execSync(`rm -f webext/webext.zip`);
+  } catch (error) {
+    //Usually this happens because the zip files were removed previously
+    console.log('Error removing zip files');
+  }
+  execSync(`git submodule update --remote`);
+  var version = process.argv[3];
+  console.log(version);
+  var manifest = require('./webext/manifest.json')
+  manifest.version = version;
+  writeFileSync('./webext/manifest.json', JSON.stringify(manifest, null, 2), 'utf8');
+  execSync(`git commit -am "bump version to ${version}"`);
+  try {
+    execSync(`git tag webext-${version}`);
+  } catch (error) {
+    console.log('Error creating git tag');
+    // Revert changes
+    execSync(`git reset HEAD~`);
+    execSync(`git checkout ./webext/manifest.json`);
+    execSync(`git submodule update`);
+    return;
+  }
+  execSync(`git archive -o source.zip HEAD .`);
+  execSync(`npm run webext`);
+  execSync(`cd webext && zip -Xr webext.zip ./*`);
+});
+
+task('clean', 'remove all built files', function() {
+  execSync('rm -rf build test spec/support');
+});
+
+var cmd = process.argv[2];
+
+if (tasks.has(cmd)) {
+  var t = tasks.get(cmd);
+  console.log(t.msg);
+  t.func();
+} else {
+  console.error('Command not supported.');
+}
diff --git a/proxy/package.json b/proxy/package.json
new file mode 100644
index 0000000..6946691
--- /dev/null
+++ b/proxy/package.json
@@ -0,0 +1,34 @@
+{
+  "name": "snowflake-pt",
+  "version": "0.0.0-git",
+  "description": "Snowflake is a WebRTC pluggable transport for Tor.",
+  "main": "build/snowflake.js",
+  "directories": {
+    "test": "test"
+  },
+  "scripts": {
+    "test": "node make.js test",
+    "build": "node make.js build",
+    "webext": "node make.js webext",
+    "pack-webext": "node make.js pack-webext",
+    "clean": "node make.js clean",
+    "prepublish": "node make.js node",
+    "start": "node build/snowflake.js",
+    "lint": "eslint . --ext .js"
+  },
+  "bin": {
+    "snowflake": "build/snowflake.js"
+  },
+  "author": "Serene Han",
+  "license": "BSD-3-Clause",
+  "devDependencies": {
+    "eslint": "^6.0.1",
+    "jasmine": "2.5.2"
+  },
+  "dependencies": {
+    "cldr": "^5.4.1",
+    "wrtc": "^0.0.61",
+    "ws": "^3.3.1",
+    "xmlhttprequest": "^1.8.0"
+  }
+}
diff --git a/proxy/proxypair.js b/proxy/proxypair.js
new file mode 100644
index 0000000..58efa1a
--- /dev/null
+++ b/proxy/proxypair.js
@@ -0,0 +1,269 @@
+/* global snowflake, log, dbg, Util, PeerConnection, Snowflake, Parse, WS */
+
+/*
+Represents a single:
+
+   client <-- webrtc --> snowflake <-- websocket --> relay
+
+Every ProxyPair has a Snowflake ID, which is necessary when responding to the
+Broker with an WebRTC answer.
+*/
+
+class ProxyPair {
+
+  /*
+  Constructs a ProxyPair where:
+  - @relayAddr is the destination relay
+  - @rateLimit specifies a rate limit on traffic
+  */
+  constructor(relayAddr, rateLimit, pcConfig) {
+    // Given a WebRTC DataChannel, prepare callbacks.
+    this.prepareDataChannel = this.prepareDataChannel.bind(this);
+    // Assumes WebRTC datachannel is connected.
+    this.connectRelay = this.connectRelay.bind(this);
+    // WebRTC --> websocket
+    this.onClientToRelayMessage = this.onClientToRelayMessage.bind(this);
+    // websocket --> WebRTC
+    this.onRelayToClientMessage = this.onRelayToClientMessage.bind(this);
+    this.onError = this.onError.bind(this);
+    // Send as much data in both directions as the rate limit currently allows.
+    this.flush = this.flush.bind(this);
+    this.relayAddr = relayAddr;
+    this.rateLimit = rateLimit;
+    this.pcConfig = pcConfig;
+    this.id = Util.genSnowflakeID();
+    this.c2rSchedule = [];
+    this.r2cSchedule = [];
+  }
+
+  // Prepare a WebRTC PeerConnection and await for an SDP offer.
+  begin() {
+    this.pc = new PeerConnection(this.pcConfig, {
+      optional: [
+        {
+          DtlsSrtpKeyAgreement: true
+        },
+        {
+          RtpDataChannels: false
+        }
+      ]
+    });
+    this.pc.onicecandidate = (evt) => {
+      // Browser sends a null candidate once the ICE gathering completes.
+      if (null === evt.candidate) {
+        // TODO: Use a promise.all to tell Snowflake about all offers at once,
+        // once multiple proxypairs are supported.
+        dbg('Finished gathering ICE candidates.');
+        return snowflake.broker.sendAnswer(this.id, this.pc.localDescription);
+      }
+    };
+    // OnDataChannel triggered remotely from the client when connection succeeds.
+    return this.pc.ondatachannel = (dc) => {
+      var channel;
+      channel = dc.channel;
+      dbg('Data Channel established...');
+      this.prepareDataChannel(channel);
+      return this.client = channel;
+    };
+  }
+
+  receiveWebRTCOffer(offer) {
+    if ('offer' !== offer.type) {
+      log('Invalid SDP received -- was not an offer.');
+      return false;
+    }
+    try {
+      this.pc.setRemoteDescription(offer);
+    } catch (error) {
+      log('Invalid SDP message.');
+      return false;
+    }
+    dbg('SDP ' + offer.type + ' successfully received.');
+    return true;
+  }
+
+  prepareDataChannel(channel) {
+    channel.onopen = () => {
+      log('WebRTC DataChannel opened!');
+      if (!this.active) {
+        return;
+      }
+      this.running = true;
+      snowflake.state = Snowflake.MODE.WEBRTC_READY;
+      snowflake.ui.setActive(true);
+      // This is the point when the WebRTC datachannel is done, so the next step
+      // is to establish websocket to the server.
+      return this.connectRelay();
+    };
+    channel.onclose = () => {
+      log('WebRTC DataChannel closed.');
+      snowflake.ui.setStatus('disconnected by webrtc.');
+      snowflake.ui.setActive(false);
+      snowflake.state = Snowflake.MODE.INIT;
+      this.flush();
+      return this.close();
+    };
+    channel.onerror = function() {
+      return log('Data channel error!');
+    };
+    channel.binaryType = "arraybuffer";
+    return channel.onmessage = this.onClientToRelayMessage;
+  }
+
+  connectRelay() {
+    var params, peer_ip, ref;
+    dbg('Connecting to relay...');
+    // Get a remote IP address from the PeerConnection, if possible. Add it to
+    // the WebSocket URL's query string if available.
+    // MDN marks remoteDescription as "experimental". However the other two
+    // options, currentRemoteDescription and pendingRemoteDescription, which
+    // are not marked experimental, were undefined when I tried them in Firefox
+    // 52.2.0.
+    // https://developer.mozilla.org/en-US/docs/Web/API/RTCPeerConnection/remoteDescription
+    peer_ip = Parse.ipFromSDP((ref = this.pc.remoteDescription) != null ? ref.sdp : void 0);
+    params = [];
+    if (peer_ip != null) {
+      params.push(["client_ip", peer_ip]);
+    }
+    var relay = this.relay = WS.makeWebsocket(this.relayAddr, params);
+    this.relay.label = 'websocket-relay';
+    this.relay.onopen = () => {
+      if (this.timer) {
+        clearTimeout(this.timer);
+        this.timer = 0;
+      }
+      log(relay.label + ' connected!');
+      return snowflake.ui.setStatus('connected');
+    };
+    this.relay.onclose = () => {
+      log(relay.label + ' closed.');
+      snowflake.ui.setStatus('disconnected.');
+      snowflake.ui.setActive(false);
+      snowflake.state = Snowflake.MODE.INIT;
+      this.flush();
+      return this.close();
+    };
+    this.relay.onerror = this.onError;
+    this.relay.onmessage = this.onRelayToClientMessage;
+    // TODO: Better websocket timeout handling.
+    return this.timer = setTimeout((() => {
+      if (0 === this.timer) {
+        return;
+      }
+      log(relay.label + ' timed out connecting.');
+      return relay.onclose();
+    }), 5000);
+  }
+
+  onClientToRelayMessage(msg) {
+    dbg('WebRTC --> websocket data: ' + msg.data.byteLength + ' bytes');
+    this.c2rSchedule.push(msg.data);
+    return this.flush();
+  }
+
+  onRelayToClientMessage(event) {
+    dbg('websocket --> WebRTC data: ' + event.data.byteLength + ' bytes');
+    this.r2cSchedule.push(event.data);
+    return this.flush();
+  }
+
+  onError(event) {
+    var ws;
+    ws = event.target;
+    log(ws.label + ' error.');
+    return this.close();
+  }
+
+  // Close both WebRTC and websocket.
+  close() {
+    if (this.timer) {
+      clearTimeout(this.timer);
+      this.timer = 0;
+    }
+    if (this.webrtcIsReady()) {
+      this.client.close();
+    }
+    this.client = null;
+    if (this.peerConnOpen()) {
+      this.pc.close();
+    }
+    this.pc = null;
+    if (this.relayIsReady()) {
+      this.relay.close();
+    }
+    this.relay = null;
+    this.onCleanup();
+    this.active = false;
+    this.running = false;
+  }
+
+  flush() {
+    var busy, checkChunks;
+    if (this.flush_timeout_id) {
+      clearTimeout(this.flush_timeout_id);
+    }
+    this.flush_timeout_id = null;
+    busy = true;
+    checkChunks = () => {
+      var chunk;
+      busy = false;
+      // WebRTC --> websocket
+      if (this.relayIsReady() && this.relay.bufferedAmount < this.MAX_BUFFER && this.c2rSchedule.length > 0) {
+        chunk = this.c2rSchedule.shift();
+        this.rateLimit.update(chunk.byteLength);
+        this.relay.send(chunk);
+        busy = true;
+      }
+      // websocket --> WebRTC
+      if (this.webrtcIsReady() && this.client.bufferedAmount < this.MAX_BUFFER && this.r2cSchedule.length > 0) {
+        chunk = this.r2cSchedule.shift();
+        this.rateLimit.update(chunk.byteLength);
+        this.client.send(chunk);
+        return busy = true;
+      }
+    };
+    while (busy && !this.rateLimit.isLimited()) {
+      checkChunks();
+    }
+    if (this.r2cSchedule.length > 0 || this.c2rSchedule.length > 0 || (this.relayIsReady() && this.relay.bufferedAmount > 0) || (this.webrtcIsReady() && this.client.bufferedAmount > 0)) {
+      return this.flush_timeout_id = setTimeout(this.flush, this.rateLimit.when() * 1000);
+    }
+  }
+
+  webrtcIsReady() {
+    return null !== this.client && 'open' === this.client.readyState;
+  }
+
+  relayIsReady() {
+    return (null !== this.relay) && (WebSocket.OPEN === this.relay.readyState);
+  }
+
+  isClosed(ws) {
+    return void 0 === ws || WebSocket.CLOSED === ws.readyState;
+  }
+
+  peerConnOpen() {
+    return (null !== this.pc) && ('closed' !== this.pc.connectionState);
+  }
+
+}
+
+ProxyPair.prototype.MAX_BUFFER = 10 * 1024 * 1024;
+
+ProxyPair.prototype.pc = null;
+
+ProxyPair.prototype.client = null; // WebRTC Data channel
+
+ProxyPair.prototype.relay = null; // websocket
+
+ProxyPair.prototype.timer = 0;
+
+ProxyPair.prototype.running = false; // Whether a datachannel is opened
+
+ProxyPair.prototype.active = false; // Whether serving a client.
+
+ProxyPair.prototype.flush_timeout_id = null;
+
+ProxyPair.prototype.onCleanup = null;
+
+ProxyPair.prototype.id = null;
diff --git a/proxy/shims.js b/proxy/shims.js
new file mode 100644
index 0000000..5d93183
--- /dev/null
+++ b/proxy/shims.js
@@ -0,0 +1,31 @@
+/* global module, require */
+
+/*
+WebRTC shims for multiple browsers.
+*/
+
+if (typeof module !== "undefined" && module !== null ? module.exports : void 0) {
+  window = {};
+  document = {
+    getElementById: function() {
+      return null;
+    }
+  };
+  chrome = {};
+  location = { search: '' };
+  ({ URLSearchParams } = require('url'));
+  if ((typeof TESTING === "undefined" || TESTING === null) || !TESTING) {
+    webrtc = require('wrtc');
+    PeerConnection = webrtc.RTCPeerConnection;
+    IceCandidate = webrtc.RTCIceCandidate;
+    SessionDescription = webrtc.RTCSessionDescription;
+    WebSocket = require('ws');
+    ({ XMLHttpRequest } = require('xmlhttprequest'));
+  }
+} else {
+  PeerConnection = window.RTCPeerConnection || window.mozRTCPeerConnection || window.webkitRTCPeerConnection;
+  IceCandidate = window.RTCIceCandidate || window.mozRTCIceCandidate;
+  SessionDescription = window.RTCSessionDescription || window.mozRTCSessionDescription;
+  WebSocket = window.WebSocket;
+  XMLHttpRequest = window.XMLHttpRequest;
+}
diff --git a/proxy/snowflake.js b/proxy/snowflake.js
new file mode 100644
index 0000000..cdc59fb
--- /dev/null
+++ b/proxy/snowflake.js
@@ -0,0 +1,185 @@
+/* global log, dbg, DummyRateLimit, BucketRateLimit, SessionDescription, ProxyPair */
+
+/*
+A JavaScript WebRTC snowflake proxy
+
+Uses WebRTC from the client, and Websocket to the server.
+
+Assume that the webrtc client plugin is always the offerer, in which case
+this proxy must always act as the answerer.
+
+TODO: More documentation
+*/
+
+// Minimum viable snowflake for now - just 1 client.
+class Snowflake {
+
+  // Prepare the Snowflake with a Broker (to find clients) and optional UI.
+  constructor(config, ui, broker) {
+    // Receive an SDP offer from some client assigned by the Broker,
+    // |pair| - an available ProxyPair.
+    this.receiveOffer = this.receiveOffer.bind(this);
+    this.config = config;
+    this.ui = ui;
+    this.broker = broker;
+    this.state = Snowflake.MODE.INIT;
+    this.proxyPairs = [];
+    if (void 0 === this.config.rateLimitBytes) {
+      this.rateLimit = new DummyRateLimit();
+    } else {
+      this.rateLimit = new BucketRateLimit(this.config.rateLimitBytes * this.config.rateLimitHistory, this.config.rateLimitHistory);
+    }
+    this.retries = 0;
+  }
+
+  // Set the target relay address spec, which is expected to be websocket.
+  // TODO: Should potentially fetch the target from broker later, or modify
+  // entirely for the Tor-independent version.
+  setRelayAddr(relayAddr) {
+    this.relayAddr = relayAddr;
+    log('Using ' + relayAddr.host + ':' + relayAddr.port + ' as Relay.');
+    return true;
+  }
+
+  // Initialize WebRTC PeerConnection, which requires beginning the signalling
+  // process. |pollBroker| automatically arranges signalling.
+  beginWebRTC() {
+    this.state = Snowflake.MODE.WEBRTC_CONNECTING;
+    log('ProxyPair Slots: ' + this.proxyPairs.length);
+    log('Snowflake IDs: ' + (this.proxyPairs.map(function(p) {
+      return p.id;
+    })).join(' | '));
+    this.pollBroker();
+    return this.pollInterval = setInterval((() => {
+      return this.pollBroker();
+    }), this.config.defaultBrokerPollInterval);
+  }
+
+  // Regularly poll Broker for clients to serve until this snowflake is
+  // serving at capacity, at which point stop polling.
+  pollBroker() {
+    var msg, pair, recv;
+    // Poll broker for clients.
+    pair = this.nextAvailableProxyPair();
+    if (!pair) {
+      log('At client capacity.');
+      return;
+    }
+    // Do nothing until a new proxyPair is available.
+    pair.active = true;
+    msg = 'Polling for client ... ';
+    if (this.retries > 0) {
+      msg += '[retries: ' + this.retries + ']';
+    }
+    this.ui.setStatus(msg);
+    recv = this.broker.getClientOffer(pair.id);
+    recv.then((desc) => {
+      if (pair.active) {
+        if (!this.receiveOffer(pair, desc)) {
+          return pair.active = false;
+        }
+        //set a timeout for channel creation
+        return setTimeout((() => {
+          if (!pair.running) {
+            log('proxypair datachannel timed out waiting for open');
+            pair.close();
+            return pair.active = false;
+          }
+        }), 20000); // 20 second timeout
+      }
+    }, function() {
+      return pair.active = false;
+    });
+    return this.retries++;
+  }
+
+  // Returns the first ProxyPair that's available to connect.
+  nextAvailableProxyPair() {
+    if (this.proxyPairs.length < this.config.connectionsPerClient) {
+      return this.makeProxyPair(this.relayAddr);
+    }
+    return this.proxyPairs.find(function(pp) {
+      return !pp.active;
+    });
+  }
+
+  receiveOffer(pair, desc) {
+    var e, offer, sdp;
+    try {
+      offer = JSON.parse(desc);
+      dbg('Received:\n\n' + offer.sdp + '\n');
+      sdp = new SessionDescription(offer);
+      if (pair.receiveWebRTCOffer(sdp)) {
+        this.sendAnswer(pair);
+        return true;
+      } else {
+        return false;
+      }
+    } catch (error) {
+      e = error;
+      log('ERROR: Unable to receive Offer: ' + e);
+      return false;
+    }
+  }
+
+  sendAnswer(pair) {
+    var fail, next;
+    next = function(sdp) {
+      dbg('webrtc: Answer ready.');
+      return pair.pc.setLocalDescription(sdp).catch(fail);
+    };
+    fail = function() {
+      pair.active = false
+      return dbg('webrtc: Failed to create or set Answer');
+    };
+    return pair.pc.createAnswer().then(next).catch(fail);
+  }
+
+  makeProxyPair(relay) {
+    var pair;
+    pair = new ProxyPair(relay, this.rateLimit, this.config.pcConfig);
+    this.proxyPairs.push(pair);
+    pair.onCleanup = () => {
+      var ind;
+      // Delete from the list of active proxy pairs.
+      ind = this.proxyPairs.indexOf(pair);
+      if (ind > -1) {
+        return this.proxyPairs.splice(ind, 1);
+      }
+    };
+    pair.begin();
+    return pair;
+  }
+
+  // Stop all proxypairs.
+  disable() {
+    var results;
+    log('Disabling Snowflake.');
+    clearInterval(this.pollInterval);
+    results = [];
+    while (this.proxyPairs.length > 0) {
+      results.push(this.proxyPairs.pop().close());
+    }
+    return results;
+  }
+
+}
+
+Snowflake.prototype.relayAddr = null;
+
+Snowflake.prototype.rateLimit = null;
+
+Snowflake.prototype.pollInterval = null;
+
+Snowflake.prototype.retries = 0;
+
+// Janky state machine
+Snowflake.MODE = {
+  INIT: 0,
+  WEBRTC_CONNECTING: 1,
+  WEBRTC_READY: 2
+};
+
+Snowflake.MESSAGE = {
+  CONFIRMATION: 'You\'re currently serving a Tor user via Snowflake.'
+};
diff --git a/proxy/spec/broker.spec.js b/proxy/spec/broker.spec.js
new file mode 100644
index 0000000..4eb3029
--- /dev/null
+++ b/proxy/spec/broker.spec.js
@@ -0,0 +1,119 @@
+/* global expect, it, describe, spyOn, Broker */
+
+/*
+jasmine tests for Snowflake broker
+*/
+
+// fake xhr
+// class XMLHttpRequest
+class XMLHttpRequest {
+  constructor() {
+    this.onreadystatechange = null;
+  }
+  open() {}
+  setRequestHeader() {}
+  send() {}
+};
+
+XMLHttpRequest.prototype.DONE = 1;
+
+
+describe('Broker', function() {
+
+  it('can be created', function() {
+    var b;
+    b = new Broker('fake');
+    expect(b.url).toEqual('https://fake/');
+    expect(b.id).not.toBeNull();
+  });
+
+  describe('getClientOffer', function() {
+
+    it('polls and promises a client offer', function(done) {
+      var b, poll;
+      b = new Broker('fake');
+      // fake successful request and response from broker.
+      spyOn(b, '_postRequest').and.callFake(function() {
+        b._xhr.readyState = b._xhr.DONE;
+        b._xhr.status = Broker.STATUS.OK;
+        b._xhr.responseText = 'fake offer';
+        return b._xhr.onreadystatechange();
+      });
+      poll = b.getClientOffer();
+      expect(poll).not.toBeNull();
+      expect(b._postRequest).toHaveBeenCalled();
+      return poll.then(function(desc) {
+        expect(desc).toEqual('fake offer');
+        return done();
+      }).catch(function() {
+        fail('should not reject on Broker.STATUS.OK');
+        return done();
+      });
+    });
+
+    it('rejects if the broker timed-out', function(done) {
+      var b, poll;
+      b = new Broker('fake');
+      // fake timed-out request from broker
+      spyOn(b, '_postRequest').and.callFake(function() {
+        b._xhr.readyState = b._xhr.DONE;
+        b._xhr.status = Broker.STATUS.GATEWAY_TIMEOUT;
+        return b._xhr.onreadystatechange();
+      });
+      poll = b.getClientOffer();
+      expect(poll).not.toBeNull();
+      expect(b._postRequest).toHaveBeenCalled();
+      return poll.then(function(desc) {
+        fail('should not fulfill on Broker.STATUS.GATEWAY_TIMEOUT');
+        return done();
+      }, function(err) {
+        expect(err).toBe(Broker.MESSAGE.TIMEOUT);
+        return done();
+      });
+    });
+
+    it('rejects on any other status', function(done) {
+      var b, poll;
+      b = new Broker('fake');
+      // fake timed-out request from broker
+      spyOn(b, '_postRequest').and.callFake(function() {
+        b._xhr.readyState = b._xhr.DONE;
+        b._xhr.status = 1337;
+        return b._xhr.onreadystatechange();
+      });
+      poll = b.getClientOffer();
+      expect(poll).not.toBeNull();
+      expect(b._postRequest).toHaveBeenCalled();
+      return poll.then(function(desc) {
+        fail('should not fulfill on non-OK status');
+        return done();
+      }, function(err) {
+        expect(err).toBe(Broker.MESSAGE.UNEXPECTED);
+        expect(b._xhr.status).toBe(1337);
+        return done();
+      });
+
+    });
+
+  });
+
+  it('responds to the broker with answer', function() {
+    var b = new Broker('fake');
+    spyOn(b, '_postRequest');
+    b.sendAnswer('fake id', 123);
+    expect(b._postRequest).toHaveBeenCalledWith('fake id', jasmine.any(Object), 'answer', '123');
+  });
+
+  it('POST XMLHttpRequests to the broker', function() {
+    var b = new Broker('fake');
+    b._xhr = new XMLHttpRequest();
+    spyOn(b._xhr, 'open');
+    spyOn(b._xhr, 'setRequestHeader');
+    spyOn(b._xhr, 'send');
+    b._postRequest(0, b._xhr, 'test', 'data');
+    expect(b._xhr.open).toHaveBeenCalled();
+    expect(b._xhr.setRequestHeader).toHaveBeenCalled();
+    expect(b._xhr.send).toHaveBeenCalled();
+  });
+
+});
diff --git a/proxy/spec/init.spec.js b/proxy/spec/init.spec.js
new file mode 100644
index 0000000..748bc86
--- /dev/null
+++ b/proxy/spec/init.spec.js
@@ -0,0 +1,35 @@
+/* global expect, it, describe, Snowflake, UI */
+
+// Fake snowflake to interact with
+
+var snowflake = {
+  ui: new UI,
+  broker: {
+    sendAnswer: function() {}
+  },
+  state: Snowflake.MODE.INIT
+};
+
+describe('Init', function() {
+
+  it('gives a dialog when closing, only while active', function() {
+    silenceNotifications = false;
+    snowflake.state = Snowflake.MODE.WEBRTC_READY;
+    var msg = window.onbeforeunload();
+    expect(snowflake.state).toBe(Snowflake.MODE.WEBRTC_READY);
+    expect(msg).toBe(Snowflake.MESSAGE.CONFIRMATION);
+    snowflake.state = Snowflake.MODE.INIT;
+    msg = window.onbeforeunload();
+    expect(snowflake.state).toBe(Snowflake.MODE.INIT);
+    expect(msg).toBe(null);
+  });
+
+  it('does not give a dialog when silent flag is on', function() {
+    silenceNotifications = true;
+    snowflake.state = Snowflake.MODE.WEBRTC_READY;
+    var msg = window.onbeforeunload();
+    expect(snowflake.state).toBe(Snowflake.MODE.WEBRTC_READY);
+    expect(msg).toBe(null);
+  });
+
+});
diff --git a/proxy/spec/proxypair.spec.js b/proxy/spec/proxypair.spec.js
new file mode 100644
index 0000000..f15d6d2
--- /dev/null
+++ b/proxy/spec/proxypair.spec.js
@@ -0,0 +1,163 @@
+/* global expect, it, describe, spyOn */
+
+/*
+jasmine tests for Snowflake proxypair
+*/
+
+// Replacement for MessageEvent constructor.
+// https://developer.mozilla.org/en-US/docs/Web/API/MessageEvent/MessageEvent
+var MessageEvent = function(type, init) {
+  return init;
+};
+
+// Asymmetic matcher that checks that two arrays have the same contents.
+var arrayMatching = function(sample) {
+  return {
+    asymmetricMatch: function(other) {
+      var _, a, b, i, j, len;
+      a = new Uint8Array(sample);
+      b = new Uint8Array(other);
+      if (a.length !== b.length) {
+        return false;
+      }
+      for (i = j = 0, len = a.length; j < len; i = ++j) {
+        _ = a[i];
+        if (a[i] !== b[i]) {
+          return false;
+        }
+      }
+      return true;
+    },
+    jasmineToString: function() {
+      return '';
+    }
+  };
+};
+
+describe('ProxyPair', function() {
+
+  var config, destination, fakeRelay, pp, rateLimit;
+  fakeRelay = Parse.address('0.0.0.0:12345');
+  rateLimit = new DummyRateLimit;
+  config = new Config;
+  destination = [];
+
+  // Using the mock PeerConnection definition from spec/snowflake.spec.js
+  var pp = new ProxyPair(fakeRelay, rateLimit, config.pcConfig);
+
+  beforeEach(function() {
+    return pp.begin();
+  });
+
+  it('begins webrtc connection', function() {
+    return expect(pp.pc).not.toBeNull();
+  });
+
+  describe('accepts WebRTC offer from some client', function() {
+
+    beforeEach(function() {
+      return pp.begin();
+    });
+
+    it('rejects invalid offers', function() {
+      expect(typeof pp.pc.setRemoteDescription).toBe("function");
+      expect(pp.pc).not.toBeNull();
+      expect(pp.receiveWebRTCOffer({})).toBe(false);
+      expect(pp.receiveWebRTCOffer({
+        type: 'answer'
+      })).toBe(false);
+    });
+
+    it('accepts valid offers', function() {
+      expect(pp.pc).not.toBeNull();
+      expect(pp.receiveWebRTCOffer({
+        type: 'offer',
+        sdp: 'foo'
+      })).toBe(true);
+    });
+
+  });
+
+  it('responds with a WebRTC answer correctly', function() {
+    spyOn(snowflake.broker, 'sendAnswer');
+    pp.pc.onicecandidate({
+      candidate: null
+    });
+    expect(snowflake.broker.sendAnswer).toHaveBeenCalled();
+  });
+
+  it('handles a new data channel correctly', function() {
+    expect(pp.client).toBeNull();
+    pp.pc.ondatachannel({
+      channel: {}
+    });
+    expect(pp.client).not.toBeNull();
+    expect(pp.client.onopen).not.toBeNull();
+    expect(pp.client.onclose).not.toBeNull();
+    expect(pp.client.onerror).not.toBeNull();
+    expect(pp.client.onmessage).not.toBeNull();
+  });
+
+  it('connects to the relay once datachannel opens', function() {
+    spyOn(pp, 'connectRelay');
+    pp.active = true;
+    pp.client.onopen();
+    expect(pp.connectRelay).toHaveBeenCalled();
+  });
+
+  it('connects to a relay', function() {
+    pp.connectRelay();
+    expect(pp.relay.onopen).not.toBeNull();
+    expect(pp.relay.onclose).not.toBeNull();
+    expect(pp.relay.onerror).not.toBeNull();
+    expect(pp.relay.onmessage).not.toBeNull();
+  });
+
+  describe('flushes data between client and relay', function() {
+
+    it('proxies data from client to relay', function() {
+      var msg;
+      pp.pc.ondatachannel({
+        channel: {
+          bufferedAmount: 0,
+          readyState: "open",
+          send: function(data) {}
+        }
+      });
+      spyOn(pp.client, 'send');
+      spyOn(pp.relay, 'send');
+      msg = new MessageEvent("message", {
+        data: Uint8Array.from([1, 2, 3]).buffer
+      });
+      pp.onClientToRelayMessage(msg);
+      pp.flush();
+      expect(pp.client.send).not.toHaveBeenCalled();
+      expect(pp.relay.send).toHaveBeenCalledWith(arrayMatching([1, 2, 3]));
+    });
+
+    it('proxies data from relay to client', function() {
+      var msg;
+      spyOn(pp.client, 'send');
+      spyOn(pp.relay, 'send');
+      msg = new MessageEvent("message", {
+        data: Uint8Array.from([4, 5, 6]).buffer
+      });
+      pp.onRelayToClientMessage(msg);
+      pp.flush();
+      expect(pp.client.send).toHaveBeenCalledWith(arrayMatching([4, 5, 6]));
+      expect(pp.relay.send).not.toHaveBeenCalled();
+    });
+
+    it('sends nothing with nothing to flush', function() {
+      spyOn(pp.client, 'send');
+      spyOn(pp.relay, 'send');
+      pp.flush();
+      expect(pp.client.send).not.toHaveBeenCalled();
+      expect(pp.relay.send).not.toHaveBeenCalled();
+    });
+
+  });
+
+});
+
+// TODO: rate limit tests
diff --git a/proxy/spec/snowflake.spec.js b/proxy/spec/snowflake.spec.js
new file mode 100644
index 0000000..970947b
--- /dev/null
+++ b/proxy/spec/snowflake.spec.js
@@ -0,0 +1,103 @@
+/* global expect, it, describe, spyOn, Snowflake, Config, UI */
+
+/*
+jasmine tests for Snowflake
+*/
+
+// Fake browser functionality:
+class PeerConnection {
+  setRemoteDescription() {
+    return true;
+  }
+  send() {}
+}
+
+class SessionDescription {}
+SessionDescription.prototype.type = 'offer';
+
+class WebSocket {
+  constructor() {
+    this.bufferedAmount = 0;
+  }
+  send() {}
+}
+WebSocket.prototype.OPEN = 1;
+WebSocket.prototype.CLOSED = 0;
+
+var log = function() {};
+
+var config = new Config();
+
+var ui = new UI();
+
+class FakeBroker {
+  getClientOffer() {
+    return new Promise(function() {
+      return {};
+    });
+  }
+}
+
+describe('Snowflake', function() {
+
+  it('constructs correctly', function() {
+    var s;
+    s = new Snowflake(config, ui, {
+      fake: 'broker'
+    });
+    expect(s.rateLimit).not.toBeNull();
+    expect(s.broker).toEqual({
+      fake: 'broker'
+    });
+    expect(s.ui).not.toBeNull();
+    expect(s.retries).toBe(0);
+  });
+
+  it('sets relay address correctly', function() {
+    var s;
+    s = new Snowflake(config, ui, null);
+    s.setRelayAddr('foo');
+    expect(s.relayAddr).toEqual('foo');
+  });
+
+  it('initalizes WebRTC connection', function() {
+    var s;
+    s = new Snowflake(config, ui, new FakeBroker());
+    spyOn(s.broker, 'getClientOffer').and.callThrough();
+    s.beginWebRTC();
+    expect(s.retries).toBe(1);
+    expect(s.broker.getClientOffer).toHaveBeenCalled();
+  });
+
+  it('receives SDP offer and sends answer', function() {
+    var pair, s;
+    s = new Snowflake(config, ui, new FakeBroker());
+    pair = {
+      receiveWebRTCOffer: function() {}
+    };
+    spyOn(pair, 'receiveWebRTCOffer').and.returnValue(true);
+    spyOn(s, 'sendAnswer');
+    s.receiveOffer(pair, '{"type":"offer","sdp":"foo"}');
+    expect(s.sendAnswer).toHaveBeenCalled();
+  });
+
+  it('does not send answer when receiving invalid offer', function() {
+    var pair, s;
+    s = new Snowflake(config, ui, new FakeBroker());
+    pair = {
+      receiveWebRTCOffer: function() {}
+    };
+    spyOn(pair, 'receiveWebRTCOffer').and.returnValue(false);
+    spyOn(s, 'sendAnswer');
+    s.receiveOffer(pair, '{"type":"not a good offer","sdp":"foo"}');
+    expect(s.sendAnswer).not.toHaveBeenCalled();
+  });
+
+  it('can make a proxypair', function() {
+    var s;
+    s = new Snowflake(config, ui, new FakeBroker());
+    s.makeProxyPair();
+    expect(s.proxyPairs.length).toBe(1);
+  });
+
+});
diff --git a/proxy/spec/ui.spec.js b/proxy/spec/ui.spec.js
new file mode 100644
index 0000000..dc9aa35
--- /dev/null
+++ b/proxy/spec/ui.spec.js
@@ -0,0 +1,68 @@
+/* global expect, it, describe, spyOn, DebugUI */
+/* eslint no-redeclare: 0 */
+
+/*
+jasmine tests for Snowflake UI
+*/
+
+var document = {
+  getElementById: function() {
+    return {};
+  },
+  createTextNode: function(txt) {
+    return txt;
+  }
+};
+
+describe('UI', function() {
+
+  it('activates debug mode when badge does not exist', function() {
+    var u;
+    spyOn(document, 'getElementById').and.callFake(function(id) {
+      if ('badge' === id) {
+        return null;
+      }
+      return {};
+    });
+    u = new DebugUI();
+    expect(document.getElementById.calls.count()).toEqual(2);
+    expect(u.$status).not.toBeNull();
+    expect(u.$msglog).not.toBeNull();
+  });
+
+  it('sets status message when in debug mode', function() {
+    var u;
+    u = new DebugUI();
+    u.$status = {
+      innerHTML: '',
+      appendChild: function(txt) {
+        return this.innerHTML = txt;
+      }
+    };
+    u.setStatus('test');
+    expect(u.$status.innerHTML).toEqual('Status: test');
+  });
+
+  it('sets message log css correctly for debug mode', function() {
+    var u;
+    u = new DebugUI();
+    u.setActive(true);
+    expect(u.$msglog.className).toEqual('active');
+    u.setActive(false);
+    expect(u.$msglog.className).toEqual('');
+  });
+
+  it('logs to the textarea correctly when debug mode', function() {
+    var u;
+    u = new DebugUI();
+    u.$msglog = {
+      value: '',
+      scrollTop: 0,
+      scrollHeight: 1337
+    };
+    u.log('test');
+    expect(u.$msglog.value).toEqual('test\n');
+    expect(u.$msglog.scrollTop).toEqual(1337);
+  });
+
+});
diff --git a/proxy/spec/util.spec.js b/proxy/spec/util.spec.js
new file mode 100644
index 0000000..6eb5be4
--- /dev/null
+++ b/proxy/spec/util.spec.js
@@ -0,0 +1,252 @@
+/* global expect, it, describe, Parse, Params */
+
+/*
+jasmine tests for Snowflake utils
+*/
+
+describe('Parse', function() {
+
+  describe('cookie', function() {
+
+    it('parses correctly', function() {
+      expect(Parse.cookie('')).toEqual({});
+      expect(Parse.cookie('a=b')).toEqual({
+        a: 'b'
+      });
+      expect(Parse.cookie('a=b=c')).toEqual({
+        a: 'b=c'
+      });
+      expect(Parse.cookie('a=b; c=d')).toEqual({
+        a: 'b',
+        c: 'd'
+      });
+      expect(Parse.cookie('a=b ; c=d')).toEqual({
+        a: 'b',
+        c: 'd'
+      });
+      expect(Parse.cookie('a= b')).toEqual({
+        a: 'b'
+      });
+      expect(Parse.cookie('a=')).toEqual({
+        a: ''
+      });
+      expect(Parse.cookie('key')).toBeNull();
+      expect(Parse.cookie('key=%26%20')).toEqual({
+        key: '& '
+      });
+      expect(Parse.cookie('a=\'\'')).toEqual({
+        a: '\'\''
+      });
+    });
+
+  });
+
+  describe('address', function() {
+
+    it('parses IPv4', function() {
+      expect(Parse.address('')).toBeNull();
+      expect(Parse.address('3.3.3.3:4444')).toEqual({
+        host: '3.3.3.3',
+        port: 4444
+      });
+      expect(Parse.address('3.3.3.3')).toBeNull();
+      expect(Parse.address('3.3.3.3:0x1111')).toBeNull();
+      expect(Parse.address('3.3.3.3:-4444')).toBeNull();
+      expect(Parse.address('3.3.3.3:65536')).toBeNull();
+    });
+
+    it('parses IPv6', function() {
+      expect(Parse.address('[1:2::a:f]:4444')).toEqual({
+        host: '1:2::a:f',
+        port: 4444
+      });
+      expect(Parse.address('[1:2::a:f]')).toBeNull();
+      expect(Parse.address('[1:2::a:f]:0x1111')).toBeNull();
+      expect(Parse.address('[1:2::a:f]:-4444')).toBeNull();
+      expect(Parse.address('[1:2::a:f]:65536')).toBeNull();
+      expect(Parse.address('[1:2::ffff:1.2.3.4]:4444')).toEqual({
+        host: '1:2::ffff:1.2.3.4',
+        port: 4444
+      });
+    });
+
+  });
+
+  describe('byte count', function() {
+
+    it('returns null for bad inputs', function() {
+      expect(Parse.byteCount("")).toBeNull();
+      expect(Parse.byteCount("x")).toBeNull();
+      expect(Parse.byteCount("1x")).toBeNull();
+      expect(Parse.byteCount("1.x")).toBeNull();
+      expect(Parse.byteCount("1.2x")).toBeNull();
+      expect(Parse.byteCount("toString")).toBeNull();
+      expect(Parse.byteCount("1toString")).toBeNull();
+      expect(Parse.byteCount("1.toString")).toBeNull();
+      expect(Parse.byteCount("1.2toString")).toBeNull();
+      expect(Parse.byteCount("k")).toBeNull();
+      expect(Parse.byteCount("m")).toBeNull();
+      expect(Parse.byteCount("g")).toBeNull();
+      expect(Parse.byteCount("K")).toBeNull();
+      expect(Parse.byteCount("M")).toBeNull();
+      expect(Parse.byteCount("G")).toBeNull();
+      expect(Parse.byteCount("-1")).toBeNull();
+      expect(Parse.byteCount("-1k")).toBeNull();
+      expect(Parse.byteCount("1.2.3")).toBeNull();
+      expect(Parse.byteCount("1.2.3k")).toBeNull();
+    });
+
+    it('handles numbers without a suffix', function() {
+      expect(Parse.byteCount("10")).toEqual(10);
+      expect(Parse.byteCount("10.")).toEqual(10);
+      expect(Parse.byteCount("1.5")).toEqual(1.5);
+    });
+
+    it('handles lowercase suffixes', function() {
+      expect(Parse.byteCount("10k")).toEqual(10*1024);
+      expect(Parse.byteCount("10m")).toEqual(10*1024*1024);
+      expect(Parse.byteCount("10g")).toEqual(10*1024*1024*1024);
+      expect(Parse.byteCount("10.k")).toEqual(10*1024);
+      expect(Parse.byteCount("10.m")).toEqual(10*1024*1024);
+      expect(Parse.byteCount("10.g")).toEqual(10*1024*1024*1024);
+      expect(Parse.byteCount("1.5k")).toEqual(1.5*1024);
+      expect(Parse.byteCount("1.5m")).toEqual(1.5*1024*1024);
+      expect(Parse.byteCount("1.5G")).toEqual(1.5*1024*1024*1024);
+    });
+
+    it('handles uppercase suffixes', function() {
+      expect(Parse.byteCount("10K")).toEqual(10*1024);
+      expect(Parse.byteCount("10M")).toEqual(10*1024*1024);
+      expect(Parse.byteCount("10G")).toEqual(10*1024*1024*1024);
+      expect(Parse.byteCount("10.K")).toEqual(10*1024);
+      expect(Parse.byteCount("10.M")).toEqual(10*1024*1024);
+      expect(Parse.byteCount("10.G")).toEqual(10*1024*1024*1024);
+      expect(Parse.byteCount("1.5K")).toEqual(1.5*1024);
+      expect(Parse.byteCount("1.5M")).toEqual(1.5*1024*1024);
+      expect(Parse.byteCount("1.5G")).toEqual(1.5*1024*1024*1024);
+    });
+
+  });
+
+  describe('ipFromSDP', function() {
+
+    var testCases = [
+      {
+        // https://tools.ietf.org/html/rfc4566#section-5
+        sdp: "v=0\no=jdoe 2890844526 2890842807 IN IP4 10.47.16.5\ns=SDP Seminar\ni=A Seminar on the session description protocol\nu=http://www.example.com/seminars/sdp.pdf\ne=j.doe@example.com (Jane Doe)\nc=IN IP4 224.2.17.12/127\nt=2873397496 2873404696\na=recvonly\nm=audio 49170 RTP/AVP 0\nm=video 51372 RTP/AVP 99\na=rtpmap:99 h263-1998/90000",
+        expected: '224.2.17.12'
+      },
+      {
+        // Missing c= line
+        sdp: "v=0\no=jdoe 2890844526 2890842807 IN IP4 10.47.16.5\ns=SDP Seminar\ni=A Seminar on the session description protocol\nu=http://www.example.com/seminars/sdp.pdf\ne=j.doe@example.com (Jane Doe)\nt=2873397496 2873404696\na=recvonly\nm=audio 49170 RTP/AVP 0\nm=video 51372 RTP/AVP 99\na=rtpmap:99 h263-1998/90000",
+        expected: void 0
+      },
+      {
+        // Single line, IP address only
+        sdp: "c=IN IP4 224.2.1.1\n",
+        expected: '224.2.1.1'
+      },
+      {
+        // Same, with TTL
+        sdp: "c=IN IP4 224.2.1.1/127\n",
+        expected: '224.2.1.1'
+      },
+      {
+        // Same, with TTL and multicast addresses
+        sdp: "c=IN IP4 224.2.1.1/127/3\n",
+        expected: '224.2.1.1'
+      },
+      {
+        // IPv6, address only
+        sdp: "c=IN IP6 FF15::101\n",
+        expected: 'ff15::101'
+      },
+      {
+        // Same, with multicast addresses
+        sdp: "c=IN IP6 FF15::101/3\n",
+        expected: 'ff15::101'
+      },
+      {
+        // Multiple c= lines
+        sdp: "c=IN IP4 1.2.3.4\nc=IN IP4 5.6.7.8",
+        expected: '1.2.3.4'
+      },
+      {
+        // Modified from SDP sent by snowflake-client.
+        sdp: "v=0\no=- 7860378660295630295 2 IN IP4 127.0.0.1\ns=-\nt=0 0\na=group:BUNDLE data\na=msid-semantic: WMS\nm=application 54653 DTLS/SCTP 5000\nc=IN IP4 1.2.3.4\na=candidate:3581707038 1 udp 2122260223 192.168.0.1 54653 typ host generation 0 network-id 1 network-cost 50\na=candidate:2617212910 1 tcp 1518280447 192.168.0.1 59673 typ host tcptype passive generation 0 network-id 1 network-cost 50\na=candidate:2082671819 1 udp 1686052607 1.2.3.4 54653 typ srflx raddr 192.168.0.1 rport 54653 generation 0 network-id 1 network-cost 50\na=ice-ufrag:IBdf\na=ice-pwd:G3lTrrC9gmhQx481AowtkhYz\na=fingerprint:sha-256 53:F8:84:D9:3C:1F:A0:44:AA:D6:3C:65:80:D3:CB:6F:23:90:17:41:06:F9:9C:10:D8:48:4A:A8:B6:FA:14:A1\na=setup:actpass\na=mid:data\na=sctpmap:5000 webrtc-datachannel 1024",
+        expected: '1.2.3.4'
+      },
+      {
+        // Improper character within IPv4
+        sdp: "c=IN IP4 224.2z.1.1",
+        expected: void 0
+      },
+      {
+        // Improper character within IPv6
+        sdp: "c=IN IP6 ff15:g::101",
+        expected: void 0
+      },
+      {
+        // Bogus "IP7" addrtype
+        sdp: "c=IN IP7 1.2.3.4\n",
+        expected: void 0
+      }
+    ];
+
+    it('parses SDP', function() {
+      var i, len, ref, ref1, results, test;
+      results = [];
+      for (i = 0, len = testCases.length; i < len; i++) {
+        test = testCases[i];
+        // https://tools.ietf.org/html/rfc4566#section-5: "The sequence # CRLF
+        // (0x0d0a) is used to end a record, although parsers SHOULD be tolerant
+        // and also accept records terminated with a single newline character."
+        // We represent the test cases with LF line endings for convenience, and
+        // test them both that way and with CRLF line endings.
+        expect((ref = Parse.ipFromSDP(test.sdp)) != null ? ref.toLowerCase() : void 0).toEqual(test.expected);
+        results.push(expect((ref1 = Parse.ipFromSDP(test.sdp.replace(/\n/, "\r\n"))) != null ? ref1.toLowerCase() : void 0).toEqual(test.expected));
+      }
+      return results;
+    });
+
+  });
+
+});
+
+describe('Params', function() {
+
+  describe('bool', function() {
+
+    var getBool = function(query) {
+      return Params.getBool(new URLSearchParams(query), 'param', false);
+    };
+
+    it('parses correctly', function() {
+      expect(getBool('param=true')).toBe(true);
+      expect(getBool('param')).toBe(true);
+      expect(getBool('param=')).toBe(true);
+      expect(getBool('param=1')).toBe(true);
+      expect(getBool('param=0')).toBe(false);
+      expect(getBool('param=false')).toBe(false);
+      expect(getBool('param=unexpected')).toBeNull();
+      expect(getBool('pram=true')).toBe(false);
+    });
+
+  });
+
+  describe('byteCount', function() {
+
+    var DEFAULT = 77;
+    var getByteCount = function(query) {
+      return Params.getByteCount(new URLSearchParams(query), 'param', DEFAULT);
+    };
+
+    it('supports default values', function() {
+      expect(getByteCount('param=x')).toBeNull();
+      expect(getByteCount('param=10')).toEqual(10);
+      expect(getByteCount('foo=10k')).toEqual(DEFAULT);
+    });
+
+  });
+
+});
diff --git a/proxy/spec/websocket.spec.js b/proxy/spec/websocket.spec.js
new file mode 100644
index 0000000..6c2ef2e
--- /dev/null
+++ b/proxy/spec/websocket.spec.js
@@ -0,0 +1,41 @@
+/* global expect, it, describe, WS */
+
+/*
+jasmine tests for Snowflake websocket
+*/
+
+describe('BuildUrl', function() {
+
+  it('should parse just protocol and host', function() {
+    expect(WS.buildUrl('http', 'example.com')).toBe('http://example.com');
+  });
+
+  it('should handle different ports', function() {
+    expect(WS.buildUrl('http', 'example.com', 80)).toBe('http://example.com');
+    expect(WS.buildUrl('http', 'example.com', 81)).toBe('http://example.com:81');
+    expect(WS.buildUrl('http', 'example.com', 443)).toBe('http://example.com:443');
+    expect(WS.buildUrl('http', 'example.com', 444)).toBe('http://example.com:444');
+  });
+
+  it('should handle paths', function() {
+    expect(WS.buildUrl('http', 'example.com', 80, '/')).toBe('http://example.com/');
+    expect(WS.buildUrl('http', 'example.com', 80, '/test?k=%#v')).toBe('http://example.com/test%3Fk%3D%25%23v');
+    expect(WS.buildUrl('http', 'example.com', 80, '/test')).toBe('http://example.com/test');
+  });
+
+  it('should handle params', function() {
+    expect(WS.buildUrl('http', 'example.com', 80, '/test', [['k', '%#v']])).toBe('http://example.com/test?k=%25%23v');
+    expect(WS.buildUrl('http', 'example.com', 80, '/test', [['a', 'b'], ['c', 'd']])).toBe('http://example.com/test?a=b&c=d');
+  });
+
+  it('should handle ips', function() {
+    expect(WS.buildUrl('http', '1.2.3.4')).toBe('http://1.2.3.4');
+    expect(WS.buildUrl('http', '1:2::3:4')).toBe('http://[1:2::3:4]');
+  });
+
+  it('should handle bogus', function() {
+    expect(WS.buildUrl('http', 'bog][us')).toBe('http://bog%5D%5Bus');
+    expect(WS.buildUrl('http', 'bog:u]s')).toBe('http://bog%3Au%5Ds');
+  });
+
+});
diff --git a/proxy/static/.htaccess b/proxy/static/.htaccess
new file mode 100644
index 0000000..1a8277f
--- /dev/null
+++ b/proxy/static/.htaccess
@@ -0,0 +1,5 @@
+
+    Header always unset X-Frame-Options
+
+
+Redirect permanent /snowflake.html /
diff --git a/proxy/static/SourceSansPro-Regular.ttf b/proxy/static/SourceSansPro-Regular.ttf
new file mode 100644
index 0000000..278ad8a
Binary files /dev/null and b/proxy/static/SourceSansPro-Regular.ttf differ
diff --git a/proxy/static/_locales/en_US/messages.json b/proxy/static/_locales/en_US/messages.json
new file mode 100644
index 0000000..0d638c7
--- /dev/null
+++ b/proxy/static/_locales/en_US/messages.json
@@ -0,0 +1,80 @@
+{
+  "appDesc": {
+    "message": "Snowflake is a WebRTC pluggable transport for Tor."
+  },
+  "popupEnabled": {
+    "message": "Enabled"
+  },
+  "popupLearnMore": {
+    "message": "Learn more"
+  },
+  "popupStatusOff": {
+    "message": "Snowflake is off"
+  },
+  "popupStatusOn": {
+    "message": "Number of users currently connected: $1"
+  },
+  "popupStatusReady": {
+    "message": "Your Snowflake is ready to help users circumvent censorship"
+  },
+  "popupWebRTCOff": {
+    "message": "WebRTC feature is not detected."
+  },
+  "popupBridgeUnreachable": {
+    "message": "Could not connect to the bridge."
+  },
+  "popupDescOn": {
+    "message": "Number of users your Snowflake has helped circumvent censorship in the last 24 hours: $1"
+  },
+  "badgeCookiesOff": {
+    "message": "Cookies are not enabled."
+  },
+  "websiteIntro": {
+    "message": "Snowflake is a system to defeat internet censorship. People who are censored can use Snowflake to access the internet. Their connection goes through Snowflake proxies, which are run by volunteers. For more detailed information about how Snowflake works see our documentation wiki."
+  },
+  "docWiki": {
+    "message": "documentation wiki"
+  },
+  "browser": {
+    "message": "Browser"
+  },
+  "censoredUsers": {
+    "message": "If your internet access is censored, you should download Tor Browser."
+  },
+  "extension": {
+    "message": "Extension"
+  },
+  "installExtension": {
+    "message": "If your internet access is not censored, you should consider installing the Snowflake extension to help users in censored networks. There is no need to worry about which websites people are accessing through your proxy. Their visible browsing IP address will match their Tor exit node, not yours."
+  },
+  "installFirefox": {
+    "message": "Install in Firefox"
+  },
+  "installChrome": {
+    "message": "Install in Chrome"
+  },
+  "reportingBugs": {
+    "message": "Reporting Bugs"
+  },
+  "fileBug": {
+    "message": "If you encounter problems with Snowflake as a client or a proxy, please consider filing a bug.  To do so, you will have to,"
+  },
+  "sharedAccount": {
+    "message": "Either create an account or log in using the shared cypherpunks account with password writecode."
+  },
+  "bugTracker": {
+    "message": "File a ticket using our bug tracker."
+  },
+  "descriptive": {
+    "message": "Please try to be as descriptive as possible with your ticket and if possible include log messages that will help us reproduce the bug. Consider adding keywords snowflake-webextension or snowflake-client to let us know how which part of the Snowflake system is experiencing problems."
+  },
+  "embed": {
+    "message": "Embed"
+  },
+  "possible": {
+    "message": "It is now possible to embed the Snowflake badge on any website:"
+  },
+  "looksLike": {
+    "message": "Which looks like this:"
+  }
+}
diff --git a/proxy/static/assets/arrowhead-right-12.svg b/proxy/static/assets/arrowhead-right-12.svg
new file mode 100644
index 0000000..3f7e664
--- /dev/null
+++ b/proxy/static/assets/arrowhead-right-12.svg
@@ -0,0 +1,4 @@
+
+
\ No newline at end of file
diff --git a/proxy/static/assets/arrowhead-right-dark-12.svg b/proxy/static/assets/arrowhead-right-dark-12.svg
new file mode 100644
index 0000000..6534fd0
--- /dev/null
+++ b/proxy/static/assets/arrowhead-right-dark-12.svg
@@ -0,0 +1,4 @@
+
+
\ No newline at end of file
diff --git a/proxy/static/assets/favicon.ico b/proxy/static/assets/favicon.ico
new file mode 100644
index 0000000..48060b1
Binary files /dev/null and b/proxy/static/assets/favicon.ico differ
diff --git a/proxy/static/assets/status-off-dark.svg b/proxy/static/assets/status-off-dark.svg
new file mode 100644
index 0000000..3df7cc3
--- /dev/null
+++ b/proxy/static/assets/status-off-dark.svg
@@ -0,0 +1,11 @@
+
+
+    
+    Fill-4
+    Created with Sketch.
+    
+        
+            
+        
+    
+
\ No newline at end of file
diff --git a/proxy/static/assets/status-off.svg b/proxy/static/assets/status-off.svg
new file mode 100644
index 0000000..843b278
--- /dev/null
+++ b/proxy/static/assets/status-off.svg
@@ -0,0 +1,7 @@
+
+
+    status-off
+    
+        
+    
+
\ No newline at end of file
diff --git a/proxy/static/assets/status-on-dark.svg b/proxy/static/assets/status-on-dark.svg
new file mode 100644
index 0000000..bfc9894
--- /dev/null
+++ b/proxy/static/assets/status-on-dark.svg
@@ -0,0 +1,11 @@
+
+
+    
+    Fill-4
+    Created with Sketch.
+    
+        
+            
+        
+    
+
\ No newline at end of file
diff --git a/proxy/static/assets/status-on.svg b/proxy/static/assets/status-on.svg
new file mode 100644
index 0000000..4cd2be8
--- /dev/null
+++ b/proxy/static/assets/status-on.svg
@@ -0,0 +1,7 @@
+
+
+    status-on
+    
+        
+    
+
\ No newline at end of file
diff --git a/proxy/static/assets/status-running.svg b/proxy/static/assets/status-running.svg
new file mode 100644
index 0000000..dffb7ea
--- /dev/null
+++ b/proxy/static/assets/status-running.svg
@@ -0,0 +1,7 @@
+
+
+    status-on
+    
+        
+    
+
diff --git a/proxy/static/assets/toolbar-off-48.png b/proxy/static/assets/toolbar-off-48.png
new file mode 100644
index 0000000..9a28a6f
Binary files /dev/null and b/proxy/static/assets/toolbar-off-48.png differ
diff --git a/proxy/static/assets/toolbar-off-96.png b/proxy/static/assets/toolbar-off-96.png
new file mode 100644
index 0000000..d022b51
Binary files /dev/null and b/proxy/static/assets/toolbar-off-96.png differ
diff --git a/proxy/static/assets/toolbar-off.ico b/proxy/static/assets/toolbar-off.ico
new file mode 100644
index 0000000..5b6f875
Binary files /dev/null and b/proxy/static/assets/toolbar-off.ico differ
diff --git a/proxy/static/assets/toolbar-off.svg b/proxy/static/assets/toolbar-off.svg
new file mode 100644
index 0000000..2b35669
--- /dev/null
+++ b/proxy/static/assets/toolbar-off.svg
@@ -0,0 +1,14 @@
+
+
+    
+    toolbar_icon_grey
+    Created with Sketch.
+    
+        
+            
+                
+                
+            
+        
+    
+
\ No newline at end of file
diff --git a/proxy/static/assets/toolbar-on-48.png b/proxy/static/assets/toolbar-on-48.png
new file mode 100644
index 0000000..990ab30
Binary files /dev/null and b/proxy/static/assets/toolbar-on-48.png differ
diff --git a/proxy/static/assets/toolbar-on-96.png b/proxy/static/assets/toolbar-on-96.png
new file mode 100644
index 0000000..d0226b6
Binary files /dev/null and b/proxy/static/assets/toolbar-on-96.png differ
diff --git a/proxy/static/assets/toolbar-on.ico b/proxy/static/assets/toolbar-on.ico
new file mode 100644
index 0000000..d015872
Binary files /dev/null and b/proxy/static/assets/toolbar-on.ico differ
diff --git a/proxy/static/assets/toolbar-on.svg b/proxy/static/assets/toolbar-on.svg
new file mode 100644
index 0000000..70c079a
--- /dev/null
+++ b/proxy/static/assets/toolbar-on.svg
@@ -0,0 +1,14 @@
+
+
+    
+    toolbar_icon_purple
+    Created with Sketch.
+    
+        
+            
+                
+                
+            
+        
+    
+
\ No newline at end of file
diff --git a/proxy/static/assets/toolbar-running-48.png b/proxy/static/assets/toolbar-running-48.png
new file mode 100644
index 0000000..9df5476
Binary files /dev/null and b/proxy/static/assets/toolbar-running-48.png differ
diff --git a/proxy/static/assets/toolbar-running-96.png b/proxy/static/assets/toolbar-running-96.png
new file mode 100644
index 0000000..956c7d1
Binary files /dev/null and b/proxy/static/assets/toolbar-running-96.png differ
diff --git a/proxy/static/assets/toolbar-running.ico b/proxy/static/assets/toolbar-running.ico
new file mode 100644
index 0000000..c414520
Binary files /dev/null and b/proxy/static/assets/toolbar-running.ico differ
diff --git a/proxy/static/assets/toolbar-running.svg b/proxy/static/assets/toolbar-running.svg
new file mode 100644
index 0000000..5599c87
--- /dev/null
+++ b/proxy/static/assets/toolbar-running.svg
@@ -0,0 +1,14 @@
+
+
+    
+    toolbar_icon_grey
+    Created with Sketch.
+    
+        
+            
+                
+                
+            
+        
+    
+
diff --git a/proxy/static/bootstrap.css b/proxy/static/bootstrap.css
new file mode 100644
index 0000000..fa704df
--- /dev/null
+++ b/proxy/static/bootstrap.css
@@ -0,0 +1,334 @@
+/* This is a subset of bootstrap.css */
+.navbar-brand img {
+  max-width: 4em; }
+
+.navbar {
+display: none; }
+
+.navbar {
+  position: relative;
+  display: flex;
+  flex-wrap: wrap;
+  align-items: center;
+  justify-content: space-between;
+  padding: 0.5rem 1rem; }
+  .navbar > .container,
+  .navbar > .container-fluid {
+    display: flex;
+    flex-wrap: wrap;
+    align-items: center;
+    justify-content: space-between; }
+
+.navbar-brand {
+  display: inline-block;
+  padding-top: 0.3125rem;
+  padding-bottom: 0.3125rem;
+  margin-right: 1rem;
+  font-size: 1.25rem;
+  line-height: inherit;
+  white-space: nowrap; }
+  .navbar-brand:focus, .navbar-brand:hover {
+    text-decoration: none; }
+
+.navbar-dark .navbar-brand {
+  color: #FFFFFF; }
+  .navbar-dark .navbar-brand:focus, .navbar-dark .navbar-brand:hover {
+    color: #FFFFFF; }
+.navbar-dark .navbar-nav .nav-link {
+  color: #FFFFFF; }
+  .navbar-dark .navbar-nav .nav-link:focus, .navbar-dark .navbar-nav .nav-link:hover {
+    color: rgba(255, 255, 255, 0.75); }
+  .navbar-dark .navbar-nav .nav-link.disabled {
+    color: rgba(255, 255, 255, 0.25); }
+.navbar-dark .navbar-nav .show > .nav-link,
+.navbar-dark .navbar-nav .active > .nav-link,
+.navbar-dark .navbar-nav .nav-link.show,
+.navbar-dark .navbar-nav .nav-link.active {
+  color: #FFFFFF; }
+.navbar-dark .navbar-toggler {
+  color: #FFFFFF;
+  border-color: rgba(255, 255, 255, 0.1); }
+.navbar-dark .navbar-toggler-icon {
+  background-image: url("data:image/svg+xml;charset=utf8,%3Csvg viewBox='0 0 30 30' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath stroke='%23FFFFFF' stroke-width='2' stroke-linecap='round' stroke-miterlimit='10' d='M4 7h22M4 15h22M4 23h22'/%3E%3C/svg%3E"); }
+.navbar-dark .navbar-text {
+  color: #FFFFFF; }
+  .navbar-dark .navbar-text a {
+    color: #FFFFFF; }
+    .navbar-dark .navbar-text a:focus, .navbar-dark .navbar-text a:hover {
+      color: #FFFFFF; }
+
+.navbar {
+  background-image: url("./images/onion-bg.svg");
+  background-repeat: no-repeat;
+  background-position: 10px 12px; }
+
+.navbar-brand span {
+  font-size: 0.6em;
+  display: flex; }
+
+.no-gutters {
+  margin-right: 0;
+  margin-left: 0; }
+  .no-gutters > .col,
+  .no-gutters > [class*="col-"] {
+    padding-right: 0;
+    padding-left: 0; }
+
+.no-gutters {
+  margin-bottom: 0 !important; }
+
+.no-background {
+  background-image: none !important; }
+
+.bg-dark {
+  background-color: #59316B !important; }
+
+a.bg-dark:focus, a.bg-dark:hover {
+  background-color: #3c2148 !important; }
+
+.p-4 {
+  padding: 1.5rem !important; }
+
+.btn-group,
+.btn-group-vertical {
+  position: relative;
+  display: inline-flex;
+  vertical-align: middle; }
+  .btn-group > .btn,
+  .btn-group-vertical > .btn {
+    position: relative;
+    flex: 0 1 auto; }
+    .btn-group > .btn:hover,
+    .btn-group-vertical > .btn:hover {
+      z-index: 2; }
+    .btn-group > .btn:focus, .btn-group > .btn:active, .btn-group > .btn.active,
+    .btn-group-vertical > .btn:focus,
+    .btn-group-vertical > .btn:active,
+    .btn-group-vertical > .btn.active {
+      z-index: 2; }
+  .btn-group .btn + .btn,
+  .btn-group .btn + .btn-group,
+  .btn-group .btn-group + .btn,
+  .btn-group .btn-group + .btn-group,
+  .btn-group-vertical .btn + .btn,
+  .btn-group-vertical .btn + .btn-group,
+  .btn-group-vertical .btn-group + .btn,
+  .btn-group-vertical .btn-group + .btn-group {
+    margin-left: -1px; }
+
+.btn-group > .btn:not(:first-child):not(:last-child):not(.dropdown-toggle) {
+  border-radius: 0; }
+
+.btn-group > .btn:first-child {
+  margin-left: 0; }
+  .btn-group > .btn:first-child:not(:last-child):not(.dropdown-toggle) {
+    border-top-right-radius: 0;
+    border-bottom-right-radius: 0; }
+
+.btn-group > .btn:last-child:not(:first-child),
+.btn-group > .dropdown-toggle:not(:first-child) {
+  border-top-left-radius: 0;
+  border-bottom-left-radius: 0; }
+
+.btn-group > .btn-group {
+  float: left; }
+
+.btn-group > .btn-group:not(:first-child):not(:last-child) > .btn {
+  border-radius: 0; }
+
+.btn-group > .btn-group:first-child:not(:last-child) > .btn:last-child,
+.btn-group > .btn-group:first-child:not(:last-child) > .dropdown-toggle {
+  border-top-right-radius: 0;
+  border-bottom-right-radius: 0; }
+
+.btn-group > .btn-group:last-child:not(:first-child) > .btn:first-child {
+  border-top-left-radius: 0;
+  border-bottom-left-radius: 0; }
+
+.dropup,
+.dropdown {
+  position: relative; }
+
+.dropdown-toggle::after {
+  display: inline-block;
+  width: 0;
+  height: 0;
+  margin-left: 0.255em;
+  vertical-align: 0.255em;
+  content: "";
+  border-top: 0.3em solid;
+  border-right: 0.3em solid transparent;
+  border-bottom: 0;
+  border-left: 0.3em solid transparent; }
+.dropdown-toggle:empty::after {
+  margin-left: 0; }
+
+.dropup .dropdown-toggle::after {
+  display: inline-block;
+  width: 0;
+  height: 0;
+  margin-left: 0.255em;
+  vertical-align: 0.255em;
+  content: "";
+  border-top: 0;
+  border-right: 0.3em solid transparent;
+  border-bottom: 0.3em solid;
+  border-left: 0.3em solid transparent; }
+.dropup .dropdown-toggle:empty::after {
+  margin-left: 0; }
+
+.dropdown-menu {
+  position: absolute;
+  top: 100%;
+  left: 0;
+  z-index: 1000;
+  display: none;
+  float: left;
+  min-width: 10rem;
+  padding: 0.5rem 0;
+  margin: 0.125rem 0 0;
+  font-size: 1rem;
+  color: #212529;
+  text-align: left;
+  list-style: none;
+  background-color: #FFFFFF;
+  background-clip: padding-box;
+  border: 1px solid rgba(0, 0, 0, 0.15);
+  border-radius: 0.25rem; }
+
+.dropup .dropdown-menu {
+  margin-top: 0;
+  margin-bottom: 0.125rem; }
+.dropup .dropdown-toggle::after {
+  display: inline-block;
+  width: 0;
+  height: 0;
+  margin-left: 0.255em;
+  vertical-align: 0.255em;
+  content: "";
+  border-top: 0;
+  border-right: 0.3em solid transparent;
+  border-bottom: 0.3em solid;
+  border-left: 0.3em solid transparent; }
+.dropup .dropdown-toggle:empty::after {
+  margin-left: 0; }
+
+.dropdown-menu.show {
+  display: block; }
+
+.dropdown-item {
+  display: block;
+  width: 100%;
+  padding: 0.25rem 1.5rem;
+  clear: both;
+  font-weight: 400;
+  color: #212529;
+  text-align: inherit;
+  white-space: nowrap;
+  background: none;
+  border: 0; }
+  .dropdown-item:focus, .dropdown-item:hover {
+    color: #16181b;
+    text-decoration: none;
+    background-color: #F8F9FA; }
+  .dropdown-item.active, .dropdown-item:active {
+    color: #FFFFFF;
+    text-decoration: none;
+    background-color: #7D4698; }
+  .dropdown-item.disabled, .dropdown-item:disabled {
+    color: #848E97;
+    background-color: transparent; }
+
+.btn {
+  display: inline-block;
+  font-weight: 400;
+  text-align: center;
+  white-space: nowrap;
+  vertical-align: middle;
+  user-select: none;
+  border: 1px solid transparent;
+  padding: 0.375rem 0.75rem;
+  font-size: 1rem;
+  line-height: 1.5;
+  border-radius: 0.25rem;
+  transition: background-color 0.15s ease-in-out, border-color 0.15s ease-in-out, box-shadow 0.15s ease-in-out; }
+  .btn:focus, .btn:hover {
+    text-decoration: none; }
+  .btn:focus, .btn.focus {
+    outline: 0;
+    box-shadow: 0 0 0 0.2rem rgba(125, 70, 152, 0.25); }
+  .btn.disabled, .btn:disabled {
+    opacity: .65; }
+  .btn:not([disabled]):not(.disabled):active, .btn:not([disabled]):not(.disabled).active {
+    background-image: none; }
+
+a.btn.disabled,
+fieldset[disabled] a.btn {
+  pointer-events: none; }
+
+.btn-dark {
+  color: #fff;
+  background-color: #59316B;
+  border-color: #59316B; }
+  .btn-dark:hover {
+    color: #fff;
+    background-color: #432551;
+    border-color: #3c2148; }
+  .btn-dark:focus, .btn-dark.focus {
+    box-shadow: 0 0 0 0.2rem rgba(89, 49, 107, 0.5); }
+  .btn-dark.disabled, .btn-dark:disabled {
+    background-color: #59316B;
+    border-color: #59316B; }
+  .btn-dark:not([disabled]):not(.disabled):active, .btn-dark:not([disabled]):not(.disabled).active, .show > .btn-dark.dropdown-toggle {
+    color: #fff;
+    background-color: #3c2148;
+    border-color: #351d3f;
+    box-shadow: 0 0 0 0.2rem rgba(89, 49, 107, 0.5); }
+
+
+.btn-block {
+  display: block;
+  width: 100%; }
+
+.btn-block + .btn-block {
+  margin-top: 0.5rem; }
+
+input[type="submit"].btn-block,
+input[type="reset"].btn-block,
+input[type="button"].btn-block {
+  width: 100%; }
+
+*,
+*::before,
+*::after {
+  box-sizing: border-box; }
+
+a,
+area,
+button,
+[role="button"],
+input:not([type="range"]),
+label,
+select,
+summary,
+textarea {
+  touch-action: manipulation; }
+
+a {
+  color: #7D4698;
+  text-decoration: none;
+  background-color: transparent;
+  -webkit-text-decoration-skip: objects; }
+  a:hover {
+    color: #522e64;
+    text-decoration: underline; }
+
+a:not([href]):not([tabindex]) {
+  color: inherit;
+  text-decoration: none; }
+  a:not([href]):not([tabindex]):focus, a:not([href]):not([tabindex]):hover {
+    color: inherit;
+    text-decoration: none; }
+  a:not([href]):not([tabindex]):focus {
+    outline: 0; }
+
diff --git a/proxy/static/chrome150.jpg b/proxy/static/chrome150.jpg
new file mode 100644
index 0000000..fc8a83f
Binary files /dev/null and b/proxy/static/chrome150.jpg differ
diff --git a/proxy/static/embed.css b/proxy/static/embed.css
new file mode 100644
index 0000000..162521a
--- /dev/null
+++ b/proxy/static/embed.css
@@ -0,0 +1,150 @@
+body {
+  color: black;
+  margin: 10px;
+  font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen-Sans, Ubuntu, Cantarell, "Helvetica Neue", sans-serif;
+  width: 300px;
+  font-size: 12px;
+}
+
+#active {
+  margin: 20px 0;
+  text-align: center;
+}
+
+#statusimg {
+  background-image: url("assets/status-off.svg");
+  background-repeat: no-repeat;
+  background-position: center center;
+  min-height: 60px;
+}
+#statusimg.on {
+  background-image: url("assets/status-on.svg");
+}
+#statusimg.on.running {
+  background-image: url("assets/status-running.svg");
+}
+
+.b {
+  border-top: 1px solid gainsboro;
+  padding: 10px;
+  position: relative;
+}
+
+.b a {
+  color: inherit;
+  display: inline-block;
+  text-decoration: none;
+}
+
+.error {
+  color: firebrick;
+}
+
+.learn:before {
+  content : " ";
+  display: block;
+  position: absolute;
+  top: 12px;
+  background-image: url('assets/arrowhead-right-12.svg');
+  width: 12px;
+  height: 12px;
+  opacity : 0.6;
+  z-index: 9999;
+  right: 0px;
+  margin-right: 10px;
+}
+
+/* Snowflake Status */
+
+.transfering {
+  -webkit-animation:spin 8s linear infinite;
+  -moz-animation:spin 8s linear infinite;
+  animation:spin 8s linear infinite;
+  fill: BlueViolet;
+}
+@-moz-keyframes spin { 100% { -moz-transform: rotate(360deg); } }
+@-webkit-keyframes spin { 100% { -webkit-transform: rotate(360deg); } }
+@keyframes spin { 100% { -webkit-transform: rotate(360deg); transform:rotate(360deg); } }
+
+/* Toggle */
+
+.switch {
+  position: relative;
+  display: inline-block;
+  width: 30px;
+  height: 17px;
+  float: right;
+}
+
+.switch input {
+  opacity: 0;
+  width: 0;
+  height: 0;
+}
+
+.slider {
+  position: absolute;
+  cursor: pointer;
+  top: 0;
+  left: 0;
+  right: 0;
+  bottom: 0;
+  background-color: #ccc;
+  -webkit-transition: .4s;
+  transition: .4s;
+  border-radius: 17px;
+}
+
+.slider:before {
+  position: absolute;
+  content: "";
+  height: 13px;
+  width: 13px;
+  left: 2px;
+  bottom: 2px;
+  background-color: white;
+  -webkit-transition: .4s;
+  transition: .4s;
+  border-radius: 50%;
+}
+
+input:checked + .slider {
+  background-color: BlueViolet;
+}
+
+input:focus + .slider {
+  box-shadow: 0 0 1px BlueViolet;
+}
+
+input:checked + .slider:before {
+  -webkit-transform: translateX(13px);
+  -ms-transform: translateX(13px);
+  transform: translateX(13px);
+}
+
+/* Dark Mode */
+@media (prefers-color-scheme: dark) {
+  body {
+    /* https://design.firefox.com/photon/visuals/color.html#dark-theme */
+    color: white;
+    background-color: #38383d;
+  }
+  #statusimg {
+    background-image: url("assets/status-off-dark.svg");
+  }
+  #statusimg.on {
+    background-image: url("assets/status-on-dark.svg");
+  }
+  #statusimg.on.running {
+    background-image: url("assets/status-running.svg");
+  }
+  input:checked + .slider {
+    background-color: #cc80ff;
+  }
+  input:focus + .slider {
+    box-shadow: 0 0 1px #cc80ff;
+  }
+  .learn:before {
+    background-image: url('assets/arrowhead-right-dark-12.svg');
+  }
+}
diff --git a/proxy/static/embed.html b/proxy/static/embed.html
new file mode 100644
index 0000000..b3ca800
--- /dev/null
+++ b/proxy/static/embed.html
@@ -0,0 +1,30 @@
+
+
+  
+    
+    
+    
+    Snowflake
+    
+    
+    
+    
+  
+  
+    
+
+

__MSG_popupStatusOff__

+

+
+
+ + +
+ + + diff --git a/proxy/static/firefox150.jpg b/proxy/static/firefox150.jpg new file mode 100644 index 0000000..1eda543 Binary files /dev/null and b/proxy/static/firefox150.jpg differ diff --git a/proxy/static/index.css b/proxy/static/index.css new file mode 100644 index 0000000..b23e1ad --- /dev/null +++ b/proxy/static/index.css @@ -0,0 +1,94 @@ +@font-face { + font-family: Source Sans Pro; + src: url("SourceSansPro-Regular.ttf"); +} + +body { + margin: 0; + font-family: "Source Sans Pro", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol"; + font-size: 1.3rem; + font-weight: 400; + line-height: 1.5; + color: #212529; +} + +header { + margin: 0; + background-color: #59316B; + padding: 0 5.2rem; +} + +#content { + max-width: 90rem; + margin: 0 auto 2.6rem auto; + padding: 2.6rem 5.2rem; + background-color: #FFFFFF; +} + +@media only screen and (max-width: 600px) { + #content { + padding: 2.6rem 1.3rem; + } +} + +section { + margin: 1.3rem 0; +} + +h1 { + margin: 0; + font-size: 2.6rem; + color: #7D4698; + text-align: center; +} + +h2 { + margin: 0; + font-size: 2rem; + color: #7D4698; +} + +.sidebyside { + display: flex; + flex-flow: row wrap; + align-items: flex-start; +} + +.sidebyside section { + flex: 1 1 15rem; + padding: 0 1.3rem; +} + +.addon { + margin-top: 2.6rem 0; + text-align: center; +} + +.addon a { + display: inline-block; + padding: 0 1.3rem; +} + +.diagram, .screenshot { + padding: 2.6rem 5.2rem; + text-align: center; +} + +.diagram img, .screenshot img { + max-width: 100%; +} + +textarea { + max-width: 100%; + width: 600px; +} + +.dropdown:hover .dropdown-menu { + display: block; + height: 350px; + overflow: auto; +} + +.pull-right { + float: right !important; +} diff --git a/proxy/static/index.html b/proxy/static/index.html new file mode 100644 index 0000000..32cdcde --- /dev/null +++ b/proxy/static/index.html @@ -0,0 +1,116 @@ + + + + + + Snowflake + + + + + + +
+
+

SNOWFLAKE

+ +

Diagram

+ +

Snowflake is a system to defeat internet censorship. People who are + censored can use Snowflake to access the internet. Their connection goes + through Snowflake proxies, which are run by volunteers. For more detailed + information about how Snowflake works see our + documentation wiki.

+ +
+ +
+

Browser

+ +

If your internet access is censored, you should download + Tor Browser.

+ +

Tor Browser screenshot

+
+ +
+

Extension

+ +

If your internet access is not censored, you should + consider installing the Snowflake extension to help users in censored + networks. There is no need to worry about which websites people are + accessing through your proxy. Their visible browsing IP address will + match their Tor exit node, not yours.

+ +

+ + Install in Firefox
+ Install in Firefox +
+ + Install in Chrome
+ Install in Chrome +
+

+
+ +
+ +
+

Reporting Bugs

+ +

If you encounter problems with Snowflake as a client or a proxy, + please consider filing a bug. To do so, you will have to,

+ +
    +
  1. + Either create an + account or log in + using the shared cypherpunks account with password writecode.
  2. +
  3. + File a ticket + using our bug tracker.
  4. +
+ +

+ Please try to be as descriptive as possible with your ticket and if + possible include log messages that will help us reproduce the bug. + Consider adding keywords snowflake-webextension or snowflake-client + to let us know how which part of the Snowflake system is experiencing + problems. +

+
+ +
+

Embed

+ +

It is now possible to embed the Snowflake badge on any website:

+ + + +

Which looks like this:

+ + +
+ +
+
+ + + diff --git a/proxy/static/index.js b/proxy/static/index.js new file mode 100644 index 0000000..80a3aeb --- /dev/null +++ b/proxy/static/index.js @@ -0,0 +1,83 @@ +/* global availableLangs */ + +class Messages { + constructor(json) { + this.json = json; + } + getMessage(m, ...rest) { + if (Object.prototype.hasOwnProperty.call(this.json, m)) { + let message = this.json[m].message; + return message.replace(/\$(\d+)/g, (...args) => { + return rest[Number(args[1]) - 1]; + }); + } + } +} + + +var defaultLang = "en_US"; + +var getLang = function() { + let lang = navigator.language || defaultLang; + lang = lang.replace(/-/g, '_'); + + //prioritize override language + var url_string = window.location.href; //window.location.href + var url = new URL(url_string); + var override_lang = url.searchParams.get("lang"); + if (override_lang != null) { + lang = override_lang; + } + + if (Object.prototype.hasOwnProperty.call(availableLangs, lang)) { + return lang; + } + lang = lang.split('_')[0]; + if (Object.prototype.hasOwnProperty.call(availableLangs, lang)) { + return lang; + } + return defaultLang; +} + +var fill = function(n, func) { + switch(n.nodeType) { + case 1: // Node.ELEMENT_NODE + { + const m = /^__MSG_([^_]*)__$/.exec(n.dataset.msgid); + if (m) { + var val = func(m[1]); + if (val != undefined) { + n.innerHTML = val + } + } + n.childNodes.forEach(c => fill(c, func)); + break; + } + } +} + + +fetch(`./_locales/${getLang()}/messages.json`) +.then((res) => { + if (!res.ok) { return; } + return res.json(); +}) +.then((json) => { + var language = document.getElementById('language-switcher'); + var lang = `${getLang()}` + language.innerText = availableLangs[lang].name + ' (' + lang + ')'; + var messages = new Messages(json); + fill(document.body, (m) => { + return messages.getMessage(m); + }); +}); + +// Populate language switcher list +for (var lang in availableLangs) { + var languageList = document.getElementById('supported-languages'); + var link = document.createElement('a'); + link.setAttribute('href', '?lang='+lang); + link.setAttribute('class', "dropdown-item"); + link.innerText = availableLangs[lang].name + ' (' + lang + ')'; + languageList.lastChild.after(link); +} diff --git a/proxy/static/popup.js b/proxy/static/popup.js new file mode 100644 index 0000000..80cbcc6 --- /dev/null +++ b/proxy/static/popup.js @@ -0,0 +1,50 @@ +/* exported Popup */ + +// Add or remove a class from elem.classList, depending on cond. +function setClass(elem, className, cond) { + if (cond) { + elem.classList.add(className); + } else { + elem.classList.remove(className); + } +} + +class Popup { + constructor() { + this.div = document.getElementById('active'); + this.statustext = document.getElementById('statustext'); + this.statusdesc = document.getElementById('statusdesc'); + this.img = document.getElementById('statusimg'); + } + setEnabled(enabled) { + setClass(this.img, 'on', enabled); + } + setActive(active) { + setClass(this.img, 'running', active); + } + setStatusText(txt) { + this.statustext.innerText = txt; + } + setStatusDesc(desc, error) { + this.statusdesc.innerText = desc; + setClass(this.statusdesc, 'error', error); + } + hideButton() { + document.querySelector('.button').style.display = 'none'; + } + setChecked(checked) { + document.getElementById('enabled').checked = checked; + } + static fill(n, func) { + switch(n.nodeType) { + case 3: { // Node.TEXT_NODE + const m = /^__MSG_([^_]*)__$/.exec(n.nodeValue); + if (m) { n.nodeValue = func(m[1]); } + break; + } + case 1: // Node.ELEMENT_NODE + n.childNodes.forEach(c => Popup.fill(c, func)); + break; + } + } +} diff --git a/proxy/static/screenshot.png b/proxy/static/screenshot.png new file mode 100644 index 0000000..58c0540 Binary files /dev/null and b/proxy/static/screenshot.png differ diff --git a/proxy/static/tor-logo@2x.png b/proxy/static/tor-logo@2x.png new file mode 100644 index 0000000..5a459de Binary files /dev/null and b/proxy/static/tor-logo@2x.png differ diff --git a/proxy/translation b/proxy/translation new file mode 160000 index 0000000..120578e --- /dev/null +++ b/proxy/translation @@ -0,0 +1 @@ +Subproject commit 120578ec9dbf0975fc9ac573130282f628b9747a diff --git a/proxy/ui.js b/proxy/ui.js new file mode 100644 index 0000000..f667bef --- /dev/null +++ b/proxy/ui.js @@ -0,0 +1,17 @@ +/* +All of Snowflake's DOM manipulation and inputs. +*/ + +class UI { + + setStatus() {} + + setActive(connected) { + return this.active = connected; + } + + log() {} + +} + +UI.prototype.active = false; diff --git a/proxy/util.js b/proxy/util.js new file mode 100644 index 0000000..42843d7 --- /dev/null +++ b/proxy/util.js @@ -0,0 +1,216 @@ +/* exported Util, Params, DummyRateLimit */ + +/* +A JavaScript WebRTC snowflake proxy + +Contains helpers for parsing query strings and other utilities. +*/ + +class Util { + + static genSnowflakeID() { + return Math.random().toString(36).substring(2); + } + + static hasWebRTC() { + return typeof PeerConnection === 'function'; + } + + static hasCookies() { + return navigator.cookieEnabled; + } + +} + + +class Parse { + + // Parse a cookie data string (usually document.cookie). The return type is an + // object mapping cookies names to values. Returns null on error. + // http://www.w3.org/TR/DOM-Level-2-HTML/html.html#ID-8747038 + static cookie(cookies) { + var i, j, len, name, result, string, strings, value; + result = {}; + strings = []; + if (cookies) { + strings = cookies.split(';'); + } + for (i = 0, len = strings.length; i < len; i++) { + string = strings[i]; + j = string.indexOf('='); + if (-1 === j) { + return null; + } + name = decodeURIComponent(string.substr(0, j).trim()); + value = decodeURIComponent(string.substr(j + 1).trim()); + if (!(name in result)) { + result[name] = value; + } + } + return result; + } + + // Parse an address in the form 'host:port'. Returns an Object with keys 'host' + // (String) and 'port' (int). Returns null on error. + static address(spec) { + var host, m, port; + m = null; + if (!m) { + // IPv6 syntax. + m = spec.match(/^\[([\0-9a-fA-F:.]+)\]:([0-9]+)$/); + } + if (!m) { + // IPv4 syntax. + m = spec.match(/^([0-9.]+):([0-9]+)$/); + } + if (!m) { + // TODO: Domain match + return null; + } + host = m[1]; + port = parseInt(m[2], 10); + if (isNaN(port) || port < 0 || port > 65535) { + return null; + } + return { + host: host, + port: port + }; + } + + // Parse a count of bytes. A suffix of 'k', 'm', or 'g' (or uppercase) + // does what you would think. Returns null on error. + static byteCount(spec) { + let matches = spec.match(/^(\d+(?:\.\d*)?)(\w*)$/); + if (matches === null) { + return null; + } + let count = Number(matches[1]); + if (isNaN(count)) { + return null; + } + const UNITS = new Map([ + ['', 1], + ['k', 1024], + ['m', 1024*1024], + ['g', 1024*1024*1024], + ]); + let unit = matches[2].toLowerCase(); + if (!UNITS.has(unit)) { + return null; + } + let multiplier = UNITS.get(unit); + return count * multiplier; + } + + // Parse a connection-address out of the "c=" Connection Data field of a + // session description. Return undefined if none is found. + // https://tools.ietf.org/html/rfc4566#section-5.7 + static ipFromSDP(sdp) { + var i, len, m, pattern, ref; + ref = [/^c=IN IP4 ([\d.]+)(?:(?:\/\d+)?\/\d+)?(:? |$)/m, /^c=IN IP6 ([0-9A-Fa-f:.]+)(?:\/\d+)?(:? |$)/m]; + for (i = 0, len = ref.length; i < len; i++) { + pattern = ref[i]; + m = pattern.exec(sdp); + if (m != null) { + return m[1]; + } + } + } + +} + + +class Params { + + static getBool(query, param, defaultValue) { + if (!query.has(param)) { + return defaultValue; + } + var val; + val = query.get(param); + if ('true' === val || '1' === val || '' === val) { + return true; + } + if ('false' === val || '0' === val) { + return false; + } + return null; + } + + // Get an object value and parse it as a byte count. Example byte counts are + // '100' and '1.3m'. Returns |defaultValue| if param is not a key. Return null + // on a parsing error. + static getByteCount(query, param, defaultValue) { + if (!query.has(param)) { + return defaultValue; + } + return Parse.byteCount(query.get(param)); + } + +} + + +class BucketRateLimit { + + constructor(capacity, time) { + this.capacity = capacity; + this.time = time; + } + + age() { + var delta, now; + now = new Date(); + delta = (now - this.lastUpdate) / 1000.0; + this.lastUpdate = now; + this.amount -= delta * this.capacity / this.time; + if (this.amount < 0.0) { + return this.amount = 0.0; + } + } + + update(n) { + this.age(); + this.amount += n; + return this.amount <= this.capacity; + } + + // How many seconds in the future will the limit expire? + when() { + this.age(); + return (this.amount - this.capacity) / (this.capacity / this.time); + } + + isLimited() { + this.age(); + return this.amount > this.capacity; + } + +} + +BucketRateLimit.prototype.amount = 0.0; + +BucketRateLimit.prototype.lastUpdate = new Date(); + + +// A rate limiter that never limits. +class DummyRateLimit { + + constructor(capacity, time) { + this.capacity = capacity; + this.time = time; + } + + update() { + return true; + } + + when() { + return 0.0; + } + + isLimited() { + return false; + } + +} diff --git a/proxy/webext/README.md b/proxy/webext/README.md new file mode 100644 index 0000000..cd53ff1 --- /dev/null +++ b/proxy/webext/README.md @@ -0,0 +1,11 @@ +Build it, + +``` +cd .. +npm install +npm run webext +``` + +and then load this directory as an unpacked extension. + * https://developer.mozilla.org/en-US/docs/Tools/about:debugging#Loading_a_temporary_extension + * https://developer.chrome.com/extensions/getstarted#manifest diff --git a/proxy/webext/embed.js b/proxy/webext/embed.js new file mode 100644 index 0000000..eae482f --- /dev/null +++ b/proxy/webext/embed.js @@ -0,0 +1,48 @@ +/* global chrome, Popup */ + +// Fill i18n in HTML +window.onload = () => { + Popup.fill(document.body, (m) => { + return chrome.i18n.getMessage(m); + }); +}; + +const port = chrome.runtime.connect({ + name: "popup" +}); + +port.onMessage.addListener((m) => { + const { active, enabled, total, missingFeature } = m; + const popup = new Popup(); + + if (missingFeature) { + popup.setEnabled(false); + popup.setActive(false); + popup.setStatusText(chrome.i18n.getMessage('popupStatusOff')); + popup.setStatusDesc(chrome.i18n.getMessage(missingFeature), true); + popup.hideButton(); + return; + } + + const clients = active ? 1 : 0; + + if (enabled) { + popup.setChecked(true); + if (clients > 0) { + popup.setStatusText(chrome.i18n.getMessage('popupStatusOn', String(clients))); + } else { + popup.setStatusText(chrome.i18n.getMessage('popupStatusReady')); + } + popup.setStatusDesc((total > 0) ? chrome.i18n.getMessage('popupDescOn', String(total)) : ''); + } else { + popup.setChecked(false); + popup.setStatusText(chrome.i18n.getMessage('popupStatusOff')); + popup.setStatusDesc(""); + } + popup.setEnabled(enabled); + popup.setActive(active); +}); + +document.addEventListener('change', (event) => { + port.postMessage({ enabled: event.target.checked }); +}) diff --git a/proxy/webext/manifest.json b/proxy/webext/manifest.json new file mode 100644 index 0000000..b3c757a --- /dev/null +++ b/proxy/webext/manifest.json @@ -0,0 +1,24 @@ +{ + "manifest_version": 2, + "name": "Snowflake", + "version": "0.0.13", + "description": "__MSG_appDesc__", + "default_locale": "en_US", + "background": { + "scripts": [ + "snowflake.js" + ], + "persistent": true + }, + "browser_action": { + "default_icon": { + "48": "assets/toolbar-on-48.png", + "96": "assets/toolbar-on-96.png" + }, + "default_title": "Snowflake", + "default_popup": "embed.html" + }, + "permissions": [ + "storage" + ] +} \ No newline at end of file diff --git a/proxy/websocket.js b/proxy/websocket.js new file mode 100644 index 0000000..da7ba94 --- /dev/null +++ b/proxy/websocket.js @@ -0,0 +1,78 @@ +/* +Only websocket-specific stuff. +*/ + +class WS { + + // Build an escaped URL string from unescaped components. Only scheme and host + // are required. See RFC 3986, section 3. + static buildUrl(scheme, host, port, path, params) { + var parts; + parts = []; + parts.push(encodeURIComponent(scheme)); + parts.push('://'); + // If it contains a colon but no square brackets, treat it as IPv6. + if (host.match(/:/) && !host.match(/[[\]]/)) { + parts.push('['); + parts.push(host); + parts.push(']'); + } else { + parts.push(encodeURIComponent(host)); + } + if (void 0 !== port && this.DEFAULT_PORTS[scheme] !== port) { + parts.push(':'); + parts.push(encodeURIComponent(port.toString())); + } + if (void 0 !== path && '' !== path) { + if (!path.match(/^\//)) { + path = '/' + path; + } + path = path.replace(/[^/]+/, function(m) { + return encodeURIComponent(m); + }); + parts.push(path); + } + if (void 0 !== params) { + parts.push('?'); + parts.push(new URLSearchParams(params).toString()); + } + return parts.join(''); + } + + static makeWebsocket(addr, params) { + var url, ws, wsProtocol; + wsProtocol = this.WSS_ENABLED ? 'wss' : 'ws'; + url = this.buildUrl(wsProtocol, addr.host, addr.port, '/', params); + ws = new WebSocket(url); + /* + 'User agents can use this as a hint for how to handle incoming binary data: + if the attribute is set to 'blob', it is safe to spool it to disk, and if it + is set to 'arraybuffer', it is likely more efficient to keep the data in + memory.' + */ + ws.binaryType = 'arraybuffer'; + return ws; + } + + static probeWebsocket(addr) { + return new Promise((resolve, reject) => { + const ws = WS.makeWebsocket(addr); + ws.onopen = () => { + resolve(); + ws.close(); + }; + ws.onerror = () => { + reject(); + ws.close(); + }; + }); + } + +} + +WS.WSS_ENABLED = true; + +WS.DEFAULT_PORTS = { + http: 80, + https: 443 +}; diff --git a/server-webrtc/README.md b/server-webrtc/README.md new file mode 100644 index 0000000..53cad14 --- /dev/null +++ b/server-webrtc/README.md @@ -0,0 +1,26 @@ +Ordinarily, the WebRTC client plugin speaks with a Broker which helps +match and signal with a browser proxy, which ultimately speaks with a default +websocket server. + + +However, this directory contains a WebRTC server plugin which uses an +HTTP server that simulates the interaction that a client would have with +the broker, for direct testing. + +Edit server-webrtc/torrc and add "-http 127.0.0.1:8080" to the end of the +ServerTransportPlugin line: +``` +ServerTransportPlugin snowflake exec ./server-webrtc -http 127.0.0.1:8080 +``` + +``` +cd server-webrtc/ +go build +tor -f torrc +``` + +Edit client/torrc and add "-url http://127.0.0.1:8080" to the end of the +ClientTransportPlugin line: +``` +ClientTransportPlugin snowflake exec ./client -url http://127.0.0.1:8080/ +``` diff --git a/server-webrtc/http.go b/server-webrtc/http.go new file mode 100644 index 0000000..1232239 --- /dev/null +++ b/server-webrtc/http.go @@ -0,0 +1,74 @@ +// An HTTP-based signaling channel for the WebRTC server. It imitates the +// broker as seen by clients, but it doesn't connect them to an +// intermediate WebRTC proxy, rather connects them directly to this WebRTC +// server. This code should be deleted when we have proxies in place. + +package main + +import ( + "fmt" + "io/ioutil" + "log" + "net/http" + + "github.com/keroserene/go-webrtc" +) + +type httpHandler struct { + config *webrtc.Configuration +} + +func (h *httpHandler) ServeHTTP(w http.ResponseWriter, req *http.Request) { + switch req.Method { + case "GET": + w.Header().Set("Content-Type", "text/plain; charset=utf-8") + w.WriteHeader(http.StatusOK) + _, err := w.Write([]byte(`HTTP signaling channel + +Send a POST request containing an SDP offer. The response will +contain an SDP answer. +`)) + if err != nil { + log.Printf("GET request write failed with error: %v", err) + } + return + case "POST": + break + default: + http.Error(w, "Bad request.", http.StatusBadRequest) + return + } + + // POST handling begins here. + body, err := ioutil.ReadAll(http.MaxBytesReader(w, req.Body, 100000)) + if err != nil { + http.Error(w, "Bad request.", http.StatusBadRequest) + return + } + offer := webrtc.DeserializeSessionDescription(string(body)) + if offer == nil { + http.Error(w, "Bad request.", http.StatusBadRequest) + return + } + + pc, err := makePeerConnectionFromOffer(offer, h.config) + if err != nil { + http.Error(w, fmt.Sprintf("Cannot create offer: %s", err), http.StatusInternalServerError) + return + } + + log.Println("answering HTTP POST") + + w.WriteHeader(http.StatusOK) + _, err = w.Write([]byte(pc.LocalDescription().Serialize())) + if err != nil { + log.Printf("answering HTTP POST write failed with error %v", err) + } + +} + +func receiveSignalsHTTP(addr string, config *webrtc.Configuration) error { + http.Handle("/", &httpHandler{config}) + log.Printf("listening HTTP on %s", addr) + return http.ListenAndServe(addr, nil) +} diff --git a/server-webrtc/snowflake.go b/server-webrtc/snowflake.go new file mode 100644 index 0000000..d5b1604 --- /dev/null +++ b/server-webrtc/snowflake.go @@ -0,0 +1,257 @@ +package main + +import ( + "flag" + "fmt" + "io" + "io/ioutil" + "log" + "net" + "os" + "os/signal" + "sync" + "syscall" + "time" + + pt "git.torproject.org/pluggable-transports/goptlib.git" + "github.com/keroserene/go-webrtc" +) + +var ptMethodName = "snowflake" +var ptInfo pt.ServerInfo +var logFile *os.File + +func copyLoop(WebRTC, ORPort net.Conn) { + var wg sync.WaitGroup + wg.Add(2) + go func() { + if _, err := io.Copy(ORPort, WebRTC); err != nil { + log.Printf("copy WebRTC to ORPort error in copyLoop: %v", err) + } + wg.Done() + }() + go func() { + if _, err := io.Copy(WebRTC, ORPort); err != nil { + log.Printf("copy ORPort to WebRTC error in copyLoop: %v", err) + } + wg.Done() + }() + wg.Wait() +} + +type webRTCConn struct { + dc *webrtc.DataChannel + pc *webrtc.PeerConnection + pr *io.PipeReader + + lock sync.Mutex // Synchronization for DataChannel destruction + once sync.Once // Synchronization for PeerConnection destruction +} + +func (c *webRTCConn) Read(b []byte) (int, error) { + return c.pr.Read(b) +} + +func (c *webRTCConn) Write(b []byte) (int, error) { + c.lock.Lock() + defer c.lock.Unlock() + // log.Printf("webrtc Write %d %+q", len(b), string(b)) + log.Printf("Write %d bytes --> WebRTC", len(b)) + if c.dc != nil { + c.dc.Send(b) + } + return len(b), nil +} + +func (c *webRTCConn) Close() (err error) { + c.once.Do(func() { + err = c.pc.Destroy() + }) + return +} + +func (c *webRTCConn) LocalAddr() net.Addr { + return nil +} + +func (c *webRTCConn) RemoteAddr() net.Addr { + return nil +} + +func (c *webRTCConn) SetDeadline(t time.Time) error { + // nolint:golint + return fmt.Errorf("SetDeadline not implemented") +} + +func (c *webRTCConn) SetReadDeadline(t time.Time) error { + // nolint:golint + return fmt.Errorf("SetReadDeadline not implemented") +} + +func (c *webRTCConn) SetWriteDeadline(t time.Time) error { + // nolint:golint + return fmt.Errorf("SetWriteDeadline not implemented") +} + +func datachannelHandler(conn *webRTCConn) { + defer conn.Close() + + or, err := pt.DialOr(&ptInfo, "", ptMethodName) // TODO: Extended OR + if err != nil { + log.Printf("Failed to connect to ORPort: " + err.Error()) + return + } + defer or.Close() + + copyLoop(conn, or) +} + +// Create a PeerConnection from an SDP offer. Blocks until the gathering of ICE +// candidates is complete and the answer is available in LocalDescription. +// Installs an OnDataChannel callback that creates a webRTCConn and passes it to +// datachannelHandler. +func makePeerConnectionFromOffer(sdp *webrtc.SessionDescription, config *webrtc.Configuration) (*webrtc.PeerConnection, error) { + pc, err := webrtc.NewPeerConnection(config) + if err != nil { + return nil, fmt.Errorf("accept: NewPeerConnection: %s", err) + } + pc.OnNegotiationNeeded = func() { + panic("OnNegotiationNeeded") + } + pc.OnDataChannel = func(dc *webrtc.DataChannel) { + log.Println("OnDataChannel") + + pr, pw := io.Pipe() + conn := &webRTCConn{pc: pc, dc: dc, pr: pr} + + dc.OnOpen = func() { + log.Println("OnOpen channel") + } + dc.OnClose = func() { + conn.lock.Lock() + defer conn.lock.Unlock() + log.Println("OnClose channel") + conn.dc = nil + pc.DeleteDataChannel(dc) + pw.Close() + } + dc.OnMessage = func(msg []byte) { + log.Printf("OnMessage <--- %d bytes", len(msg)) + var n int + n, err = pw.Write(msg) + if err != nil { + if inerr := pw.CloseWithError(err); inerr != nil { + log.Printf("close with error returned error: %v", inerr) + } + } + if n != len(msg) { + panic("short write") + } + } + + go datachannelHandler(conn) + } + + err = pc.SetRemoteDescription(sdp) + if err != nil { + if err = pc.Destroy(); err != nil { + log.Printf("pc.Destroy returned an error: %v", err) + } + return nil, fmt.Errorf("accept: SetRemoteDescription: %s", err) + } + log.Println("sdp offer successfully received.") + + log.Println("Generating answer...") + answer, err := pc.CreateAnswer() + if err != nil { + if err = pc.Destroy(); err != nil { + log.Printf("pc.Destroy returned an error: %v", err) + } + return nil, err + } + + if answer == nil { + if err = pc.Destroy(); err != nil { + log.Printf("pc.Destroy returned an error: %v", err) + } + return nil, fmt.Errorf("failed gathering ICE candidates") + } + + err = pc.SetLocalDescription(answer) + if err != nil { + if err = pc.Destroy(); err != nil { + log.Printf("pc.Destroy returned an error: %v", err) + } + return nil, err + } + + return pc, nil +} + +func main() { + var httpAddr string + var logFilename string + + flag.StringVar(&httpAddr, "http", "", "listen for HTTP signaling") + flag.StringVar(&logFilename, "log", "", "log file to write to") + flag.Parse() + + log.SetFlags(log.LstdFlags | log.LUTC) + if logFilename != "" { + f, err := os.OpenFile(logFilename, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0600) + if err != nil { + log.Fatalf("can't open log file: %s", err) + } + defer logFile.Close() + log.SetOutput(f) + } + + log.Println("starting") + webrtc.SetLoggingVerbosity(1) + var err error + ptInfo, err = pt.ServerSetup(nil) + if err != nil { + log.Fatal(err) + } + + webRTCConfig := webrtc.NewConfiguration(webrtc.OptionIceServer("stun:stun.l.google.com:19302")) + + // Start HTTP-based signaling receiver. + go func() { + err := receiveSignalsHTTP(httpAddr, webRTCConfig) + if err != nil { + log.Printf("receiveSignalsHTTP: %s", err) + } + }() + + for _, bindaddr := range ptInfo.Bindaddrs { + switch bindaddr.MethodName { + case ptMethodName: + bindaddr.Addr.Port = 12345 // lies!!! + pt.Smethod(bindaddr.MethodName, bindaddr.Addr) + default: + if err := pt.SmethodError(bindaddr.MethodName, "no such method"); err != nil { + log.Printf("SmethodError returned error: %v", err) + } + } + } + pt.SmethodsDone() + + sigChan := make(chan os.Signal, 1) + signal.Notify(sigChan, syscall.SIGTERM) + + if os.Getenv("TOR_PT_EXIT_ON_STDIN_CLOSE") == "1" { + // This environment variable means we should treat EOF on stdin + // just like SIGTERM: https://bugs.torproject.org/15435. + go func() { + if _, err := io.Copy(ioutil.Discard, os.Stdin); err != nil { + log.Printf("error copying os.Stdin to ioutil.Discard: %v", err) + } + log.Printf("synthesizing SIGTERM because of stdin close") + sigChan <- syscall.SIGTERM + }() + } + + // wait for a signal + <-sigChan +} diff --git a/server-webrtc/torrc b/server-webrtc/torrc new file mode 100644 index 0000000..e037c97 --- /dev/null +++ b/server-webrtc/torrc @@ -0,0 +1,8 @@ +BridgeRelay 1 +ORPort 9001 +ExtORPort auto +SocksPort 0 +ExitPolicy reject *:* +DataDirectory datadir + +ServerTransportPlugin snowflake exec ./server-webrtc diff --git a/server/README.md b/server/README.md index 18b24a7..312a506 100644 --- a/server/README.md +++ b/server/README.md @@ -1,12 +1,3 @@ - - -**Table of Contents** - -- [Setup](#setup) -- [TLS](#tls) - - - This is the server transport plugin for Snowflake. The actual transport protocol it uses is [WebSocket](https://tools.ietf.org/html/rfc6455). diff --git a/server/lib/http.go b/server/lib/http.go deleted file mode 100644 index 0aba81e..0000000 --- a/server/lib/http.go +++ /dev/null @@ -1,204 +0,0 @@ -package snowflake_server - -import ( - "bufio" - "bytes" - "fmt" - "io" - "log" - "net" - "net/http" - "sync" - "time" - - "git.torproject.org/pluggable-transports/snowflake.git/v2/common/encapsulation" - "git.torproject.org/pluggable-transports/snowflake.git/v2/common/turbotunnel" - "git.torproject.org/pluggable-transports/snowflake.git/v2/common/websocketconn" - "github.com/gorilla/websocket" -) - -const requestTimeout = 10 * time.Second - -// How long to remember outgoing packets for a client, when we don't currently -// have an active WebSocket connection corresponding to that client. Because a -// client session may span multiple WebSocket connections, we keep packets we -// aren't able to send immediately in memory, for a little while but not -// indefinitely. -const clientMapTimeout = 1 * time.Minute - -// How big to make the map of ClientIDs to IP addresses. The map is used in -// turbotunnelMode to store a reasonable IP address for a client session that -// may outlive any single WebSocket connection. -const clientIDAddrMapCapacity = 10240 - -// How long to wait for ListenAndServe or ListenAndServeTLS to return an error -// before deciding that it's not going to return. -const listenAndServeErrorTimeout = 100 * time.Millisecond - -var upgrader = websocket.Upgrader{ - CheckOrigin: func(r *http.Request) bool { return true }, -} - -// clientIDAddrMap stores short-term mappings from ClientIDs to IP addresses. -// When we call pt.DialOr, tor wants us to provide a USERADDR string that -// represents the remote IP address of the client (for metrics purposes, etc.). -// This data structure bridges the gap between ServeHTTP, which knows about IP -// addresses, and handleStream, which is what calls pt.DialOr. The common piece -// of information linking both ends of the chain is the ClientID, which is -// attached to the WebSocket connection and every session. -var clientIDAddrMap = newClientIDMap(clientIDAddrMapCapacity) - -type httpHandler struct { - // pconn is the adapter layer between stream-oriented WebSocket - // connections and the packet-oriented KCP layer. - pconn *turbotunnel.QueuePacketConn -} - -func (handler *httpHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { - ws, err := upgrader.Upgrade(w, r, nil) - if err != nil { - log.Println(err) - return - } - - conn := websocketconn.New(ws) - defer conn.Close() - - // Pass the address of client as the remote address of incoming connection - clientIPParam := r.URL.Query().Get("client_ip") - addr := clientAddr(clientIPParam) - - var token [len(turbotunnel.Token)]byte - _, err = io.ReadFull(conn, token[:]) - if err != nil { - // Don't bother logging EOF: that happens with an unused - // connection, which clients make frequently as they maintain a - // pool of proxies. - if err != io.EOF { - log.Printf("reading token: %v", err) - } - return - } - - switch { - case bytes.Equal(token[:], turbotunnel.Token[:]): - err = turbotunnelMode(conn, addr, handler.pconn) - default: - // We didn't find a matching token, which means that we are - // dealing with a client that doesn't know about such things. - // Close the conn as we no longer support the old - // one-session-per-WebSocket mode. - log.Println("Received unsupported oneshot connection") - return - } - if err != nil { - log.Println(err) - return - } -} - -// turbotunnelMode handles clients that sent turbotunnel.Token at the start of -// their stream. These clients expect to send and receive encapsulated packets, -// with a long-lived session identified by ClientID. -func turbotunnelMode(conn net.Conn, addr net.Addr, pconn *turbotunnel.QueuePacketConn) error { - // Read the ClientID prefix. Every packet encapsulated in this WebSocket - // connection pertains to the same ClientID. - var clientID turbotunnel.ClientID - _, err := io.ReadFull(conn, clientID[:]) - if err != nil { - return fmt.Errorf("reading ClientID: %v", err) - } - - // Store a a short-term mapping from the ClientID to the client IP - // address attached to this WebSocket connection. tor will want us to - // provide a client IP address when we call pt.DialOr. But a KCP session - // does not necessarily correspond to any single IP address--it's - // composed of packets that are carried in possibly multiple WebSocket - // streams. We apply the heuristic that the IP address of the most - // recent WebSocket connection that has had to do with a session, at the - // time the session is established, is the IP address that should be - // credited for the entire KCP session. - clientIDAddrMap.Set(clientID, addr) - - var wg sync.WaitGroup - wg.Add(2) - done := make(chan struct{}) - - // The remainder of the WebSocket stream consists of encapsulated - // packets. We read them one by one and feed them into the - // QueuePacketConn on which kcp.ServeConn was set up, which eventually - // leads to KCP-level sessions in the acceptSessions function. - go func() { - defer wg.Done() - defer close(done) // Signal the write loop to finish - for { - p, err := encapsulation.ReadData(conn) - if err != nil { - return - } - pconn.QueueIncoming(p, clientID) - } - }() - - // At the same time, grab packets addressed to this ClientID and - // encapsulate them into the downstream. - go func() { - defer wg.Done() - defer conn.Close() // Signal the read loop to finish - - // Buffer encapsulation.WriteData operations to keep length - // prefixes in the same send as the data that follows. - bw := bufio.NewWriter(conn) - for { - select { - case <-done: - return - case p, ok := <-pconn.OutgoingQueue(clientID): - if !ok { - return - } - _, err := encapsulation.WriteData(bw, p) - if err == nil { - err = bw.Flush() - } - if err != nil { - return - } - } - } - }() - - wg.Wait() - - return nil -} - -// ClientMapAddr is a string that represents a connecting client. -type ClientMapAddr string - -func (addr ClientMapAddr) Network() string { - return "snowflake" -} - -func (addr ClientMapAddr) String() string { - return string(addr) -} - -// Return a client address -func clientAddr(clientIPParam string) net.Addr { - if clientIPParam == "" { - return ClientMapAddr("") - } - // Check if client addr is a valid IP - clientIP := net.ParseIP(clientIPParam) - if clientIP == nil { - return ClientMapAddr("") - } - // Check if client addr is 0.0.0.0 or [::]. Some proxies erroneously - // report an address of 0.0.0.0: https://bugs.torproject.org/33157. - if clientIP.IsUnspecified() { - return ClientMapAddr("") - } - // Add a stub port number. USERADDR requires a port number. - return ClientMapAddr((&net.TCPAddr{IP: clientIP, Port: 1, Zone: ""}).String()) -} diff --git a/server/lib/server_test.go b/server/lib/server_test.go deleted file mode 100644 index 8e0deb4..0000000 --- a/server/lib/server_test.go +++ /dev/null @@ -1,55 +0,0 @@ -package snowflake_server - -import ( - "net" - "strconv" - "testing" - - . "github.com/smartystreets/goconvey/convey" -) - -func TestClientAddr(t *testing.T) { - Convey("Testing clientAddr", t, func() { - // good tests - for _, test := range []struct { - input string - expected net.IP - }{ - {"1.2.3.4", net.ParseIP("1.2.3.4")}, - {"1:2::3:4", net.ParseIP("1:2::3:4")}, - } { - useraddr := clientAddr(test.input).String() - host, port, err := net.SplitHostPort(useraddr) - if err != nil { - t.Errorf("clientAddr(%q) → SplitHostPort error %v", test.input, err) - continue - } - if !test.expected.Equal(net.ParseIP(host)) { - t.Errorf("clientAddr(%q) → host %q, not %v", test.input, host, test.expected) - } - portNo, err := strconv.Atoi(port) - if err != nil { - t.Errorf("clientAddr(%q) → port %q", test.input, port) - continue - } - if portNo == 0 { - t.Errorf("clientAddr(%q) → port %d", test.input, portNo) - } - } - - // bad tests - for _, input := range []string{ - "", - "abc", - "1.2.3.4.5", - "[12::34]", - "0.0.0.0", - "[::]", - } { - useraddr := clientAddr(input).String() - if useraddr != "" { - t.Errorf("clientAddr(%q) → %q, not %q", input, useraddr, "") - } - } - }) -} diff --git a/server/lib/snowflake.go b/server/lib/snowflake.go deleted file mode 100644 index 44f287f..0000000 --- a/server/lib/snowflake.go +++ /dev/null @@ -1,297 +0,0 @@ -/* -Package snowflake_server implements the functionality necessary to accept Snowflake -connections from Snowflake clients. - -Included in the package is a Transport type that implements the Pluggable Transports v2.1 Go API -specification. To start a TLS Snowflake server using the golang.org/x/crypto/acme/autocert -library, configure a certificate manager for the server's domain name and then create a new -Transport as follows: - - // The snowflake server runs a websocket server. To run this securely, you will - // need a valid certificate. - certManager := &autocert.Manager{ - Prompt: autocert.AcceptTOS, - HostPolicy: autocert.HostWhitelist("snowflake.yourdomain.com"), - Email: "you@yourdomain.com", - } - - transport := snowflake_server.NewSnowflakeServer(certManager.GetCertificate) - - -The Listen function starts a new listener, and Accept will return incoming Snowflake connections: - - ln, err := transport.Listen(addr) - if err != nil { - // handle error - } - for { - conn, err := ln.Accept() - if err != nil { - // handle error - } - // handle conn - } - - -*/ -package snowflake_server - -import ( - "crypto/tls" - "errors" - "fmt" - "io" - "log" - "net" - "net/http" - "sync" - "time" - - "git.torproject.org/pluggable-transports/snowflake.git/v2/common/turbotunnel" - "github.com/xtaci/kcp-go/v5" - "github.com/xtaci/smux" - "golang.org/x/net/http2" -) - -const ( - // WindowSize is the number of packets in the send and receive window of a KCP connection. - WindowSize = 65535 - // StreamSize controls the maximum amount of in flight data between a client and server. - StreamSize = 1048576 //1MB -) - -// Transport is a structure with methods that conform to the Go PT v2.1 API -// https://github.com/Pluggable-Transports/Pluggable-Transports-spec/blob/master/releases/PTSpecV2.1/Pluggable%20Transport%20Specification%20v2.1%20-%20Go%20Transport%20API.pdf -type Transport struct { - getCertificate func(*tls.ClientHelloInfo) (*tls.Certificate, error) -} - -// NewSnowflakeServer returns a new server-side Transport for Snowflake. -func NewSnowflakeServer(getCertificate func(*tls.ClientHelloInfo) (*tls.Certificate, error)) *Transport { - - return &Transport{getCertificate: getCertificate} -} - -// Listen starts a listener on addr that will accept both turbotunnel -// and legacy Snowflake connections. -func (t *Transport) Listen(addr net.Addr) (*SnowflakeListener, error) { - listener := &SnowflakeListener{ - addr: addr, - queue: make(chan net.Conn, 65534), - closed: make(chan struct{}), - } - - handler := httpHandler{ - // pconn is shared among all connections to this server. It - // overlays packet-based client sessions on top of ephemeral - // WebSocket connections. - pconn: turbotunnel.NewQueuePacketConn(addr, clientMapTimeout), - } - server := &http.Server{ - Addr: addr.String(), - Handler: &handler, - ReadTimeout: requestTimeout, - } - // We need to override server.TLSConfig.GetCertificate--but first - // server.TLSConfig needs to be non-nil. If we just create our own new - // &tls.Config, it will lack the default settings that the net/http - // package sets up for things like HTTP/2. Therefore we first call - // http2.ConfigureServer for its side effect of initializing - // server.TLSConfig properly. An alternative would be to make a dummy - // net.Listener, call Serve on it, and let it return. - // https://github.com/golang/go/issues/16588#issuecomment-237386446 - err := http2.ConfigureServer(server, nil) - if err != nil { - return nil, err - } - server.TLSConfig.GetCertificate = t.getCertificate - - // Another unfortunate effect of the inseparable net/http ListenAndServe - // is that we can't check for Listen errors like "permission denied" and - // "address already in use" without potentially entering the infinite - // loop of Serve. The hack we apply here is to wait a short time, - // listenAndServeErrorTimeout, to see if an error is returned (because - // it's better if the error message goes to the tor log through - // SMETHOD-ERROR than if it only goes to the snowflake log). - errChan := make(chan error) - go func() { - if t.getCertificate == nil { - // TLS is disabled - log.Printf("listening with plain HTTP on %s", addr) - err := server.ListenAndServe() - if err != nil { - log.Printf("error in ListenAndServe: %s", err) - } - errChan <- err - } else { - log.Printf("listening with HTTPS on %s", addr) - err := server.ListenAndServeTLS("", "") - if err != nil { - log.Printf("error in ListenAndServeTLS: %s", err) - } - errChan <- err - } - }() - - select { - case err = <-errChan: - break - case <-time.After(listenAndServeErrorTimeout): - break - } - - listener.server = server - - // Start a KCP engine, set up to read and write its packets over the - // WebSocket connections that arrive at the web server. - // handler.ServeHTTP is responsible for encapsulation/decapsulation of - // packets on behalf of KCP. KCP takes those packets and turns them into - // sessions which appear in the acceptSessions function. - ln, err := kcp.ServeConn(nil, 0, 0, handler.pconn) - if err != nil { - server.Close() - return nil, err - } - go func() { - defer ln.Close() - err := listener.acceptSessions(ln) - if err != nil { - log.Printf("acceptSessions: %v", err) - } - }() - - listener.ln = ln - - return listener, nil - -} - -type SnowflakeListener struct { - addr net.Addr - queue chan net.Conn - server *http.Server - ln *kcp.Listener - closed chan struct{} - closeOnce sync.Once -} - -// Accept allows the caller to accept incoming Snowflake connections. -// We accept connections from a queue to accommodate both incoming -// smux Streams and legacy non-turbotunnel connections. -func (l *SnowflakeListener) Accept() (net.Conn, error) { - select { - case <-l.closed: - //channel has been closed, no longer accepting connections - return nil, io.ErrClosedPipe - case conn := <-l.queue: - return conn, nil - } -} - -// Addr returns the address of the SnowflakeListener -func (l *SnowflakeListener) Addr() net.Addr { - return l.addr -} - -// Close closes the Snowflake connection. -func (l *SnowflakeListener) Close() error { - // Close our HTTP server and our KCP listener - l.closeOnce.Do(func() { - close(l.closed) - l.server.Close() - l.ln.Close() - }) - return nil -} - -// acceptStreams layers an smux.Session on the KCP connection and awaits streams -// on it. Passes each stream to our SnowflakeListener accept queue. -func (l *SnowflakeListener) acceptStreams(conn *kcp.UDPSession) error { - // Look up the IP address associated with this KCP session, via the - // ClientID that is returned by the session's RemoteAddr method. - addr, ok := clientIDAddrMap.Get(conn.RemoteAddr().(turbotunnel.ClientID)) - if !ok { - // This means that the map is tending to run over capacity, not - // just that there was not client_ip on the incoming connection. - // We store "" in the map in the absence of client_ip. This log - // message means you should increase clientIDAddrMapCapacity. - log.Printf("no address in clientID-to-IP map (capacity %d)", clientIDAddrMapCapacity) - } - - smuxConfig := smux.DefaultConfig() - smuxConfig.Version = 2 - smuxConfig.KeepAliveTimeout = 10 * time.Minute - smuxConfig.MaxStreamBuffer = StreamSize - sess, err := smux.Server(conn, smuxConfig) - if err != nil { - return err - } - - for { - stream, err := sess.AcceptStream() - if err != nil { - if err, ok := err.(net.Error); ok && err.Temporary() { - continue - } - return err - } - l.queueConn(&SnowflakeClientConn{Conn: stream, address: addr}) - } -} - -// acceptSessions listens for incoming KCP connections and passes them to -// acceptStreams. It is handler.ServeHTTP that provides the network interface -// that drives this function. -func (l *SnowflakeListener) acceptSessions(ln *kcp.Listener) error { - for { - conn, err := ln.AcceptKCP() - if err != nil { - if err, ok := err.(net.Error); ok && err.Temporary() { - continue - } - return err - } - // Permit coalescing the payloads of consecutive sends. - conn.SetStreamMode(true) - // Set the maximum send and receive window sizes to a high number - // Removes KCP bottlenecks: https://gitlab.torproject.org/tpo/anti-censorship/pluggable-transports/snowflake/-/issues/40026 - conn.SetWindowSize(WindowSize, WindowSize) - // Disable the dynamic congestion window (limit only by the - // maximum of local and remote static windows). - conn.SetNoDelay( - 0, // default nodelay - 0, // default interval - 0, // default resend - 1, // nc=1 => congestion window off - ) - go func() { - defer conn.Close() - err := l.acceptStreams(conn) - if err != nil && !errors.Is(err, io.ErrClosedPipe) { - log.Printf("acceptStreams: %v", err) - } - }() - } -} - -func (l *SnowflakeListener) queueConn(conn net.Conn) error { - select { - case <-l.closed: - return fmt.Errorf("accepted connection on closed listener") - case l.queue <- conn: - return nil - } -} - -// SnowflakeClientConn is a wrapper for the underlying turbotunnel -// conn. We need to reference our client address map to determine the -// remote address -type SnowflakeClientConn struct { - net.Conn - address net.Addr -} - -// RemoteAddr returns the mapped client address of the Snowflake connection -func (conn *SnowflakeClientConn) RemoteAddr() net.Addr { - return conn.address -} diff --git a/server/lib/turbotunnel.go b/server/lib/turbotunnel.go deleted file mode 100644 index d8f9d23..0000000 --- a/server/lib/turbotunnel.go +++ /dev/null @@ -1,86 +0,0 @@ -package snowflake_server - -import ( - "net" - "sync" - - "git.torproject.org/pluggable-transports/snowflake.git/v2/common/turbotunnel" -) - -// clientIDMap is a fixed-capacity mapping from ClientIDs to a net.Addr. -// Adding a new entry using the Set method causes the oldest existing entry to -// be forgotten. -// -// This data type is meant to be used to remember the IP address associated with -// a ClientID, during the short period of time between when a WebSocket -// connection with that ClientID began, and when a KCP session is established. -// -// The design requirements of this type are that it needs to remember a mapping -// for only a short time, and old entries should expire so as not to consume -// unbounded memory. It is not a critical error if an entry is forgotten before -// it is needed; better to forget entries than to use too much memory. -type clientIDMap struct { - lock sync.Mutex - // entries is a circular buffer of (ClientID, addr) pairs. - entries []struct { - clientID turbotunnel.ClientID - addr net.Addr - } - // oldest is the index of the oldest member of the entries buffer, the - // one that will be overwritten at the next call to Set. - oldest int - // current points to the index of the most recent entry corresponding to - // each ClientID. - current map[turbotunnel.ClientID]int -} - -// newClientIDMap makes a new clientIDMap with the given capacity. -func newClientIDMap(capacity int) *clientIDMap { - return &clientIDMap{ - entries: make([]struct { - clientID turbotunnel.ClientID - addr net.Addr - }, capacity), - oldest: 0, - current: make(map[turbotunnel.ClientID]int), - } -} - -// Set adds a mapping from clientID to addr, replacing any previous mapping for -// clientID. It may also cause the clientIDMap to forget at most one other -// mapping, the oldest one. -func (m *clientIDMap) Set(clientID turbotunnel.ClientID, addr net.Addr) { - m.lock.Lock() - defer m.lock.Unlock() - if len(m.entries) == 0 { - // The invariant m.oldest < len(m.entries) does not hold in this - // special case. - return - } - // m.oldest is the index of the entry we're about to overwrite. If it's - // the current entry for any ClientID, we need to delete that clientID - // from the current map (that ClientID is now forgotten). - if i, ok := m.current[m.entries[m.oldest].clientID]; ok && i == m.oldest { - delete(m.current, m.entries[m.oldest].clientID) - } - // Overwrite the oldest entry. - m.entries[m.oldest].clientID = clientID - m.entries[m.oldest].addr = addr - // Add the overwritten entry to the quick-lookup map. - m.current[clientID] = m.oldest - // What was the oldest entry is now the newest. - m.oldest = (m.oldest + 1) % len(m.entries) -} - -// Get returns a previously stored mapping. The second return value indicates -// whether clientID was actually present in the map. If it is false, then the -// returned address will be nil. -func (m *clientIDMap) Get(clientID turbotunnel.ClientID) (net.Addr, bool) { - m.lock.Lock() - defer m.lock.Unlock() - if i, ok := m.current[clientID]; ok { - return m.entries[i].addr, true - } else { - return nil, false - } -} diff --git a/server/lib/turbotunnel_test.go b/server/lib/turbotunnel_test.go deleted file mode 100644 index 2918844..0000000 --- a/server/lib/turbotunnel_test.go +++ /dev/null @@ -1,129 +0,0 @@ -package snowflake_server - -import ( - "encoding/binary" - "net" - "testing" - - "git.torproject.org/pluggable-transports/snowflake.git/v2/common/turbotunnel" -) - -func TestClientIDMap(t *testing.T) { - // Convert a uint64 into a ClientID. - id := func(n uint64) turbotunnel.ClientID { - var clientID turbotunnel.ClientID - binary.PutUvarint(clientID[:], n) - return clientID - } - - // Does m.Get(key) and checks that the output matches what is expected. - expectGet := func(m *clientIDMap, clientID turbotunnel.ClientID, expectedAddr string, expectedOK bool) { - t.Helper() - addr, ok := m.Get(clientID) - if (ok && addr.String() != expectedAddr) || ok != expectedOK { - t.Errorf("expected (%+q, %v), got (%+q, %v)", expectedAddr, expectedOK, addr, ok) - } - } - - // Checks that the len of m.current is as expected. - expectSize := func(m *clientIDMap, expectedLen int) { - t.Helper() - if len(m.current) != expectedLen { - t.Errorf("expected map len %d, got %d %+v", expectedLen, len(m.current), m.current) - } - } - - // Convert a string to a net.Addr - ip := func(addr string) net.Addr { - ret, err := net.ResolveIPAddr("ip", addr) - if err != nil { - t.Errorf("received error: %s", err.Error()) - } - return ret - } - - // Zero-capacity map can't remember anything. - { - m := newClientIDMap(0) - expectSize(m, 0) - expectGet(m, id(0), "", false) - expectGet(m, id(1234), "", false) - - m.Set(id(0), ip("1.1.1.1")) - expectSize(m, 0) - expectGet(m, id(0), "", false) - expectGet(m, id(1234), "", false) - - m.Set(id(1234), ip("1.1.1.1")) - expectSize(m, 0) - expectGet(m, id(0), "", false) - expectGet(m, id(1234), "", false) - } - - { - m := newClientIDMap(1) - expectSize(m, 0) - expectGet(m, id(0), "", false) - expectGet(m, id(1), "", false) - - m.Set(id(0), ip("1.1.1.1")) - expectSize(m, 1) - expectGet(m, id(0), "1.1.1.1", true) - expectGet(m, id(1), "", false) - - m.Set(id(1), ip("1.1.1.2")) // forgets the (0, "1.1.1.1") entry - expectSize(m, 1) - expectGet(m, id(0), "", false) - expectGet(m, id(1), "1.1.1.2", true) - - m.Set(id(1), ip("1.1.1.3")) // forgets the (1, "1.1.1.2") entry - expectSize(m, 1) - expectGet(m, id(0), "", false) - expectGet(m, id(1), "1.1.1.3", true) - } - - { - m := newClientIDMap(5) - m.Set(id(0), ip("1.1.1.1")) - m.Set(id(1), ip("1.1.1.2")) - m.Set(id(2), ip("1.1.1.3")) - m.Set(id(0), ip("1.1.1.4")) // shadows the (0, "1.1.1.1") entry - m.Set(id(3), ip("1.1.1.5")) - expectSize(m, 4) - expectGet(m, id(0), "1.1.1.4", true) - expectGet(m, id(1), "1.1.1.2", true) - expectGet(m, id(2), "1.1.1.3", true) - expectGet(m, id(3), "1.1.1.5", true) - expectGet(m, id(4), "", false) - - m.Set(id(4), ip("1.1.1.6")) // forgets the (0, "1.1.1.1") entry but should preserve (0, "1.1.1.4") - expectSize(m, 5) - expectGet(m, id(0), "1.1.1.4", true) - expectGet(m, id(1), "1.1.1.2", true) - expectGet(m, id(2), "1.1.1.3", true) - expectGet(m, id(3), "1.1.1.5", true) - expectGet(m, id(4), "1.1.1.6", true) - - m.Set(id(5), ip("1.1.1.7")) // forgets the (1, "1.1.1.2") entry - m.Set(id(0), ip("1.1.1.8")) // forgets the (2, "1.1.1.3") entry and shadows (0, "1.1.1.4") - expectSize(m, 4) - expectGet(m, id(0), "1.1.1.8", true) - expectGet(m, id(1), "", false) - expectGet(m, id(2), "", false) - expectGet(m, id(3), "1.1.1.5", true) - expectGet(m, id(4), "1.1.1.6", true) - expectGet(m, id(5), "1.1.1.7", true) - - m.Set(id(0), ip("1.1.1.9")) // forgets the (0, "1.1.1.4") entry and shadows (0, "1.1.1.8") - m.Set(id(0), ip("1.1.1.10")) // forgets the (3, "1.1.1.5") entry and shadows (0, "1.1.1.9") - m.Set(id(0), ip("1.1.1.11")) // forgets the (4, "1.1.1.6") entry and shadows (0, "1.1.1.10") - m.Set(id(0), ip("1.1.1.12")) // forgets the (5, "1.1.1.7") entry and shadows (0, "1.1.1.11") - expectSize(m, 1) - expectGet(m, id(0), "1.1.1.12", true) - expectGet(m, id(1), "", false) - expectGet(m, id(2), "", false) - expectGet(m, id(3), "", false) - expectGet(m, id(4), "", false) - expectGet(m, id(5), "", false) - } -} diff --git a/server/server.go b/server/server.go index 4b53c86..b1b566a 100644 --- a/server/server.go +++ b/server/server.go @@ -3,7 +3,7 @@ package main import ( - "errors" + "crypto/tls" "flag" "fmt" "io" @@ -17,15 +17,23 @@ import ( "strings" "sync" "syscall" - - "git.torproject.org/pluggable-transports/snowflake.git/v2/common/safelog" - "golang.org/x/crypto/acme/autocert" + "time" pt "git.torproject.org/pluggable-transports/goptlib.git" - sf "git.torproject.org/pluggable-transports/snowflake.git/v2/server/lib" + "git.torproject.org/pluggable-transports/snowflake.git/common/safelog" + "git.torproject.org/pluggable-transports/websocket.git/websocket" + "golang.org/x/crypto/acme/autocert" + "golang.org/x/net/http2" ) const ptMethodName = "snowflake" +const requestTimeout = 10 * time.Second + +const maxMessageSize = 64 * 1024 + +// How long to wait for ListenAndServe or ListenAndServeTLS to return an error +// before deciding that it's not going to return. +const listenAndServeErrorTimeout = 100 * time.Millisecond var ptInfo pt.ServerInfo @@ -42,24 +50,80 @@ additional HTTP listener on port 80 to work with ACME. flag.PrintDefaults() } -//proxy copies data bidirectionally from one connection to another. -func proxy(local *net.TCPConn, conn net.Conn) { +// An abstraction that makes an underlying WebSocket connection look like an +// io.ReadWriteCloser. +type webSocketConn struct { + Ws *websocket.WebSocket + messageBuf []byte +} + +// Implements io.Reader. +func (conn *webSocketConn) Read(b []byte) (n int, err error) { + for len(conn.messageBuf) == 0 { + var m websocket.Message + m, err = conn.Ws.ReadMessage() + if err != nil { + return + } + if m.Opcode == 8 { + err = io.EOF + return + } + if m.Opcode != 2 { + err = fmt.Errorf("got non-binary opcode %d", m.Opcode) + return + } + conn.messageBuf = m.Payload + } + + n = copy(b, conn.messageBuf) + conn.messageBuf = conn.messageBuf[n:] + + return +} + +// Implements io.Writer. +func (conn *webSocketConn) Write(b []byte) (int, error) { + err := conn.Ws.WriteMessage(2, b) + return len(b), err +} + +// Implements io.Closer. +func (conn *webSocketConn) Close() error { + // Ignore any error in trying to write a Close frame. + _ = conn.Ws.WriteFrame(8, nil) + return conn.Ws.Conn.Close() +} + +// Create a new webSocketConn. +func newWebSocketConn(ws *websocket.WebSocket) webSocketConn { + var conn webSocketConn + conn.Ws = ws + return conn +} + +// Copy from WebSocket to socket and vice versa. +func proxy(local *net.TCPConn, conn *webSocketConn) { var wg sync.WaitGroup wg.Add(2) go func() { - if _, err := io.Copy(conn, local); err != nil && !errors.Is(err, io.ErrClosedPipe) { + if _, err := io.Copy(conn, local); err != nil { log.Printf("error copying ORPort to WebSocket %v", err) } - local.CloseRead() + if err := local.CloseRead(); err != nil { + log.Printf("error closing read after copying ORPort to WebSocket %v", err) + } conn.Close() wg.Done() }() go func() { - if _, err := io.Copy(local, conn); err != nil && !errors.Is(err, io.ErrClosedPipe) { - log.Printf("error copying WebSocket to ORPort %v", err) + if _, err := io.Copy(local, conn); err != nil { + log.Printf("error copying WebSocket to ORPort") + } + if err := local.CloseWrite(); err != nil { + log.Printf("error closing write after copying WebSocket to ORPort %v", err) } - local.CloseWrite() conn.Close() wg.Done() }() @@ -67,38 +131,120 @@ func proxy(local *net.TCPConn, conn net.Conn) { wg.Wait() } -//handleConn bidirectionally connects a client snowflake connection with an ORPort. -func handleConn(conn net.Conn) error { - addr := conn.RemoteAddr().String() - statsChannel <- addr != "" - or, err := pt.DialOr(&ptInfo, addr, ptMethodName) - if err != nil { - return fmt.Errorf("failed to connect to ORPort: %s", err) +// Return an address string suitable to pass into pt.DialOr. +func clientAddr(clientIPParam string) string { + if clientIPParam == "" { + return "" } - defer or.Close() - proxy(or, conn) - return nil + // Check if client addr is a valid IP + clientIP := net.ParseIP(clientIPParam) + if clientIP == nil { + return "" + } + // Add a dummy port number. USERADDR requires a port number. + return (&net.TCPAddr{IP: clientIP, Port: 1, Zone: ""}).String() } -//acceptLoop accepts incoming client snowflake connection and passes them to a handler function. -func acceptLoop(ln net.Listener) { - for { - conn, err := ln.Accept() - if err != nil { - if err, ok := err.(net.Error); ok && err.Temporary() { - continue - } - log.Printf("Snowflake accept error: %s", err) - break - } - go func() { - defer conn.Close() - err := handleConn(conn) - if err != nil { - log.Printf("handleConn: %v", err) - } - }() +func webSocketHandler(ws *websocket.WebSocket) { + // Undo timeouts on HTTP request handling. + if err := ws.Conn.SetDeadline(time.Time{}); err != nil { + log.Printf("unable to set deadlines with error: %v", err) } + conn := newWebSocketConn(ws) + defer conn.Close() + + // Pass the address of client as the remote address of incoming connection + clientIPParam := ws.Request().URL.Query().Get("client_ip") + addr := clientAddr(clientIPParam) + if addr == "" { + statsChannel <- false + } else { + statsChannel <- true + } + or, err := pt.DialOr(&ptInfo, addr, ptMethodName) + + if err != nil { + log.Printf("failed to connect to ORPort: %s", err) + return + } + defer or.Close() + + proxy(or, &conn) +} + +func initServer(addr *net.TCPAddr, + getCertificate func(*tls.ClientHelloInfo) (*tls.Certificate, error), + listenAndServe func(*http.Server, chan<- error)) (*http.Server, error) { + // We're not capable of listening on port 0 (i.e., an ephemeral port + // unknown in advance). The reason is that while the net/http package + // exposes ListenAndServe and ListenAndServeTLS, those functions never + // return, so there's no opportunity to find out what the port number + // is, in between the Listen and Serve steps. + // https://groups.google.com/d/msg/Golang-nuts/3F1VRCCENp8/3hcayZiwYM8J + if addr.Port == 0 { + return nil, fmt.Errorf("cannot listen on port %d; configure a port using ServerTransportListenAddr", addr.Port) + } + + var config websocket.Config + config.MaxMessageSize = maxMessageSize + server := &http.Server{ + Addr: addr.String(), + Handler: config.Handler(webSocketHandler), + ReadTimeout: requestTimeout, + } + // We need to override server.TLSConfig.GetCertificate--but first + // server.TLSConfig needs to be non-nil. If we just create our own new + // &tls.Config, it will lack the default settings that the net/http + // package sets up for things like HTTP/2. Therefore we first call + // http2.ConfigureServer for its side effect of initializing + // server.TLSConfig properly. An alternative would be to make a dummy + // net.Listener, call Serve on it, and let it return. + // https://github.com/golang/go/issues/16588#issuecomment-237386446 + err := http2.ConfigureServer(server, nil) + if err != nil { + return server, err + } + server.TLSConfig.GetCertificate = getCertificate + + // Another unfortunate effect of the inseparable net/http ListenAndServe + // is that we can't check for Listen errors like "permission denied" and + // "address already in use" without potentially entering the infinite + // loop of Serve. The hack we apply here is to wait a short time, + // listenAndServeErrorTimeout, to see if an error is returned (because + // it's better if the error message goes to the tor log through + // SMETHOD-ERROR than if it only goes to the snowflake log). + errChan := make(chan error) + go listenAndServe(server, errChan) + select { + case err = <-errChan: + break + case <-time.After(listenAndServeErrorTimeout): + break + } + + return server, err +} + +func startServer(addr *net.TCPAddr) (*http.Server, error) { + return initServer(addr, nil, func(server *http.Server, errChan chan<- error) { + log.Printf("listening with plain HTTP on %s", addr) + err := server.ListenAndServe() + if err != nil { + log.Printf("error in ListenAndServe: %s", err) + } + errChan <- err + }) +} + +func startServerTLS(addr *net.TCPAddr, getCertificate func(*tls.ClientHelloInfo) (*tls.Certificate, error)) (*http.Server, error) { + return initServer(addr, getCertificate, func(server *http.Server, errChan chan<- error) { + log.Printf("listening with HTTPS on %s", addr) + err := server.ListenAndServeTLS("", "") + if err != nil { + log.Printf("error in ListenAndServeTLS: %s", err) + } + errChan <- err + }) } func getCertificateCacheDir() (string, error) { @@ -114,14 +260,12 @@ func main() { var acmeHostnamesCommas string var disableTLS bool var logFilename string - var unsafeLogging bool flag.Usage = usage flag.StringVar(&acmeEmail, "acme-email", "", "optional contact email for Let's Encrypt notifications") flag.StringVar(&acmeHostnamesCommas, "acme-hostnames", "", "comma-separated hostnames for TLS certificate") flag.BoolVar(&disableTLS, "disable-tls", false, "don't use HTTPS") flag.StringVar(&logFilename, "log", "", "log file to write to") - flag.BoolVar(&unsafeLogging, "unsafe-logging", false, "prevent logs from being scrubbed") flag.Parse() log.SetFlags(log.LstdFlags | log.LUTC) @@ -135,12 +279,8 @@ func main() { defer f.Close() logOutput = f } - if unsafeLogging { - log.SetOutput(logOutput) - } else { - // We want to send the log output through our scrubber first - log.SetOutput(&safelog.LogScrubber{Output: logOutput}) - } + //We want to send the log output through our scrubber first + log.SetOutput(&safelog.LogScrubber{Output: logOutput}) if !disableTLS && acmeHostnamesCommas == "" { log.Fatal("the --acme-hostnames option is required") @@ -184,10 +324,12 @@ func main() { // https://github.com/ietf-wg-acme/acme/blob/master/draft-ietf-acme-acme.md#http-challenge needHTTP01Listener := !disableTLS - listeners := make([]net.Listener, 0) + servers := make([]*http.Server, 0) for _, bindaddr := range ptInfo.Bindaddrs { if bindaddr.MethodName != ptMethodName { - pt.SmethodError(bindaddr.MethodName, "no such method") + if err = pt.SmethodError(bindaddr.MethodName, "no such method"); err != nil { + log.Printf("pt.SmethodError returned error: %v", err) + } continue } @@ -199,7 +341,9 @@ func main() { lnHTTP01, err = net.ListenTCP("tcp", &addr) if err != nil { log.Printf("error opening HTTP-01 ACME listener: %s", err) - pt.SmethodError(bindaddr.MethodName, "HTTP-01 ACME listener: "+err.Error()) + if inerr := pt.SmethodError(bindaddr.MethodName, "HTTP-01 ACME listener: "+err.Error()); inerr != nil { + log.Printf("pt.SmethodError returned error: %v", inerr) + } continue } server := &http.Server{ @@ -209,47 +353,31 @@ func main() { go func() { log.Fatal(server.Serve(lnHTTP01)) }() - listeners = append(listeners, lnHTTP01) + servers = append(servers, server) needHTTP01Listener = false } - // We're not capable of listening on port 0 (i.e., an ephemeral port - // unknown in advance). The reason is that while the net/http package - // exposes ListenAndServe and ListenAndServeTLS, those functions never - // return, so there's no opportunity to find out what the port number - // is, in between the Listen and Serve steps. - // https://groups.google.com/d/msg/Golang-nuts/3F1VRCCENp8/3hcayZiwYM8J - if bindaddr.Addr.Port == 0 { - err := fmt.Errorf( - "cannot listen on port %d; configure a port using ServerTransportListenAddr", - bindaddr.Addr.Port) - log.Printf("error opening listener: %s", err) - pt.SmethodError(bindaddr.MethodName, err.Error()) - continue - } - - var transport *sf.Transport + var server *http.Server args := pt.Args{} if disableTLS { args.Add("tls", "no") - transport = sf.NewSnowflakeServer(nil) + server, err = startServer(bindaddr.Addr) } else { args.Add("tls", "yes") for _, hostname := range acmeHostnames { args.Add("hostname", hostname) } - transport = sf.NewSnowflakeServer(certManager.GetCertificate) + server, err = startServerTLS(bindaddr.Addr, certManager.GetCertificate) } - ln, err := transport.Listen(bindaddr.Addr) if err != nil { log.Printf("error opening listener: %s", err) - pt.SmethodError(bindaddr.MethodName, err.Error()) + if inerr := pt.SmethodError(bindaddr.MethodName, err.Error()); inerr != nil { + log.Printf("pt.SmethodError returned error: %v", inerr) + } continue } - defer ln.Close() - go acceptLoop(ln) pt.SmethodArgs(bindaddr.MethodName, bindaddr.Addr, args) - listeners = append(listeners, ln) + servers = append(servers, server) } pt.SmethodsDone() @@ -268,12 +396,12 @@ func main() { }() } - // Wait for a signal. + // wait for a signal sig := <-sigChan - // Signal received, shut down. + // signal received, shut down log.Printf("caught signal %q, exiting", sig) - for _, ln := range listeners { - ln.Close() + for _, server := range servers { + server.Close() } } diff --git a/server/server_test.go b/server/server_test.go new file mode 100644 index 0000000..84ac7ba --- /dev/null +++ b/server/server_test.go @@ -0,0 +1,49 @@ +package main + +import ( + "net" + "strconv" + "testing" +) + +func TestClientAddr(t *testing.T) { + // good tests + for _, test := range []struct { + input string + expected net.IP + }{ + {"1.2.3.4", net.ParseIP("1.2.3.4")}, + {"1:2::3:4", net.ParseIP("1:2::3:4")}, + } { + useraddr := clientAddr(test.input) + host, port, err := net.SplitHostPort(useraddr) + if err != nil { + t.Errorf("clientAddr(%q) → SplitHostPort error %v", test.input, err) + continue + } + if !test.expected.Equal(net.ParseIP(host)) { + t.Errorf("clientAddr(%q) → host %q, not %v", test.input, host, test.expected) + } + portNo, err := strconv.Atoi(port) + if err != nil { + t.Errorf("clientAddr(%q) → port %q", test.input, port) + continue + } + if portNo == 0 { + t.Errorf("clientAddr(%q) → port %d", test.input, portNo) + } + } + + // bad tests + for _, input := range []string{ + "", + "abc", + "1.2.3.4.5", + "[12::34]", + } { + useraddr := clientAddr(input) + if useraddr != "" { + t.Errorf("clientAddr(%q) → %q, not %q", input, useraddr, "") + } + } +}