Skip to content

Make Snowflake compatible with the pluggable transports v2 Go API

Cecylia Bocovich requested to merge cohosh/snowflake:libraritize into main

This is a change to split the Snowflake client and server into two parts: one part is a library that implements the Pluggable Transports v2.1 Go API, and the other part is a main executable that parses and handles inputs and sets up the Pluggable Transports v1 communication with tor.


We've had requests from other projects to make Snowflake friendly to the v2.1 PT spec. Guardian Project has been working on integrating Snowflake into Orbot and Onion Browser for iOS. In order to do this, they have to patch Snowflake to turn it into a callable library. In the process of getting this working, we worked with them to make several improvements to Snowflake in #40018 (closed).

I2P has also reached out to say that they'd be interested in Snowflake as a pluggable transport, and would have an easier time using it if it implemented the v2 specification

What is the v2 specification

As far as I can tell, the pluggable transports v2.1 (the latest version) Go API is pretty simple, but also a bit inconsistent.

Client API

A pluggable transport client needs a construction function that may take a destination address string and returns a net.Conn for the transport:

func Dial(address string) net.Conn

According to the specification, it can also take in additional parameters. They give an obfs4 implementation as an example where Dial has the function signature

func Dial(nodeID *ntor.NodeID, publicKey *ntor.PublickKey, sessionKey *ntor.Keypair, iatMode int) *net.Conn

First of all, this returns a *net.Conn rather than a net.Conn. Secondly, when I looked up the library of v2 pluggable transports linked from, I found they approached this a different way. Creating an obfs4 connection to a server requires the use of two functions:

func NewObfs4Client(certString string, iatMode int, dialer proxy.Dialer) (*Transport, error)
func (transport *Transport) Dial(address string) (net.Conn, error) 

So the constructor here is a separate function that returns a struct that implements the Dial function mentioned in the specification.

Server API

On the server side, the specification also requires a constructor, this time returns a net.Listener:

Listen(address string) net.Listener

And similarly, their implementation of obfs4 implements the constructor in two functions:

func NewObfs4Server(stateDir string) (*Transport, error) 
func (transport *Transport) Listen(address string) net.Listener

I don't know which strategy is community accepted, but all operator foundation transports I've looked at follow the two constructor approach so that's what I went with here. It doesn't seem like it would be too difficult to for the tool calling these libraries to adapt to either case.

Structure of this code

It makes a lot of sense to me, given that in the v1 world all pluggable transports are their own programs, to implement Snowflake in two pieces: a library that implements the v2 Go API, and a main program that calls this library.

Notes on the client-side changes

This was surprisingly simple, partially because the Snowflake client is already implemented as a library and a main program that calls it. Most of the changes are a result of moving functions between lib/snowflake.go and the main program, and implementing a SnowflakeConn wrapper for the smux.Stream that starts snowflake collection when returned from a call to Dial and stops collection when it is closed.

I really like how the client turned out, and I think it's a bit cleaner of a division between the main program and library from what we had before. The functionality is all the same, it's more the case that code was moved around and wrapped in an API-friendly way.

Notes on the server-side changes

Adapting the Server to return a net.Listener was more difficult than the client, mostly because of our support of the legacy oneshotmode. The library consists of three files: lib/turbotunnel.go, lib/http.go, and lib/snowflake.go. The latter two are new and are roughly divided as follows:

  • lib/snowflake.go has the v2 Go API functions and structures
  • lib/http.go contains all the functions necessary to run the HTTP server that accepts incoming WebSocket connections

The Accept() function of the returned net.Listener reads incoming connections from a queue. This queue is populated by both incoming oneshotmode connections and new smux streams. If we decide to no longer support oneshotmode connections, we could simply make this function a wrapper for accepting smux streams.

Closes #40036 (closed)

Edited by Cecylia Bocovich

Merge request reports