Make Snowflake compatible with the pluggable transports v2 Go API
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.
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 https://www.pluggabletransports.info, 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.
On the server side, the specification also requires a constructor, this time returns a
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/snowflake.go. The latter two are new and are roughly divided as follows:
lib/snowflake.gohas the v2 Go API functions and structures
lib/http.gocontains all the functions necessary to run the HTTP server that accepts incoming WebSocket connections
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)