This is the third post in a series about proxy servers and Go. Here is a list of posts in the series:

So far the series has covered HTTP and HTTPS proxies; this part is a brief discussion of SOCKS - an old and venerable proxy protocol designed to relay any kind of TCP or UDP traffic through forward proxies.

A bit of history

The SOCKS protocol has been around for a while; the latest version and the one you care about in 2022 is SOCKS5, which was specified by RFC 1928 back in 1996. The motivation for its design back then wasn't too different from what we're using forward proxies for today - dialing through firewalls. It predates HTTP version 1.1 and the CONNECT method, which is important context to keep in mind while reading this post. A brief comparison of SOCKS5 and CONNECT proxies is included later in the post.

Dialing through a SOCKS5 server in Go

This post won't explain the SOCKS5 protocol in detail; please read the RFC for that - it's short and quite readable. We'll move straight ahead to basic usage in Go - having clients dial through a SOCKS5 proxy. We'll focus on HTTP here, though SOCKS5 supports any TCP or UDP traffic.

SOCKS5 is used for forward proxies (to recall what this means, check out part 1 in this series), and the setup is similar to other proxies. There's a proxy server running on some host and port, and when accessing web pages we have to tell our client to use the proxy. The traditional port for SOCKS5 is 1080.

To set up a demonstration, I'm using microsocks - a small SOCKS5 implementation (in C) I found on GitHub; it's pretty easy to clone and build locally. Once that's done, invoke it as follows:

$ ./microsocks

This runs the proxy service listening on port 1080 without authentication; I'll have more to say about authentication later.

We can now curl through this proxy:

$ http_proxy=socks5://localhost:1080 curl -v http://example.org
* Uses proxy env variable http_proxy == 'socks5://localhost:1080'
*   Trying 127.0.0.1:1080...
* SOCKS5 connect to IPv4 93.184.216.34:80 (locally resolved)
* SOCKS5 request granted.
* Connected to (nil) (127.0.0.1) port 1080 (#0)
> GET / HTTP/1.1
> Host: example.org
> User-Agent: curl/7.81.0
> Accept: */*
>
* Mark bundle as not supporting multiuse
< HTTP/1.1 200 OK
// ... rest of response

And if you look at the terminal where microsocks is running you should see a logging message confirming that a client was connected.

The "magic" here is done with the http_proxy environment variable, using the socks5:// protocol prefix for the proxy address. The same works by default for Go's net/http - we can similarly run our our simple HTTP client:

$ http_proxy=socks5://localhost:1080 go run http-get-basic.go http://example.org
Response status: 200 OK
<!doctype html>
// ... rest of response

Naturally, the same approach works for HTTPS, using the https_proxy env var:

$ https_proxy=socks5://localhost:1080 curl -v https://example.org
* Uses proxy env variable https_proxy == 'socks5://localhost:1080'
*   Trying 127.0.0.1:1080...
* SOCKS5 connect to IPv4 93.184.216.34:443 (locally resolved)
* SOCKS5 request granted.
* Connected to (nil) (127.0.0.1) port 1080 (#0)
* ALPN, offering h2
* ALPN, offering http/1.1
*  CAfile: /etc/ssl/certs/ca-certificates.crt
*  CApath: /etc/ssl/certs
* TLSv1.0 (OUT), TLS header, Certificate Status (22):
// ... rest of TLS handshake and response

Just like a CONNECT tunnel, SOCKS5 is oblivious to the contents of the traffic flowing through it once the forwarding is set up, treating it as a stream of opaque bytes; it works well with any protocol streamed through it - including TLS.

SOCKS5 authentication

The SOCKS5 proxy protocol supports authentication using several methods, to ensure that only authorized clients can access the proxy. The authentication part of the protocol was designed to be extensible, but we'll just focus on the basic method described in the original RFC: username/password.

We can run our microsocks proxy again, this time protected by a username and password:

$ ./microsocks -u myuser -P mypass

First, let's try to curl through this proxy without setting authentication:

$ http_proxy=socks5://localhost:1080 curl -v http://example.org
* Uses proxy env variable http_proxy == 'socks5://localhost:1080'
*   Trying 127.0.0.1:1080...
* No authentication method was acceptable.
* Closing connection 0
curl: (97) No authentication method was acceptable.

Now let's actually set matching credentials in the proxy URL (note the username and password preceding the address):

$ http_proxy=socks5://myuser:mypass@localhost:1080 curl -v http://example.org
* Uses proxy env variable http_proxy == 'socks5://myuser:mypass@localhost:1080'
*   Trying 127.0.0.1:1080...
* SOCKS5 connect to IPv4 93.184.216.34:80 (locally resolved)
* SOCKS5 request granted.
* Connected to (nil) (127.0.0.1) port 1080 (#0)
> GET / HTTP/1.1
> Host: example.org
> User-Agent: curl/7.81.0
> Accept: */*
>
* Mark bundle as not supporting multiuse
< HTTP/1.1 200 OK
// ... rest of response

And the same setting will work with Go's HTTP client by default. If we don't want to require setting environment variables, we can also do this programmatically in Go as follows:

package main

import (
  "flag"
  "fmt"
  "io/ioutil"
  "log"
  "net/http"

  "golang.org/x/net/proxy"
)

func main() {
  target := flag.String("target", "http://example.org", "URL to get")
  proxyAddr := flag.String("proxy", "localhost:1080", "SOCKS5 proxy address to use")
  username := flag.String("user", "", "username for SOCKS5 proxy")
  password := flag.String("pass", "", "password for SOCKS5 proxy")
  flag.Parse()

  auth := proxy.Auth{
    User:     *username,
    Password: *password,
  }
  dialer, err := proxy.SOCKS5("tcp", *proxyAddr, &auth, nil)
  if err != nil {
    log.Fatal(err)
  }

  client := &http.Client{
    Transport: &http.Transport{
      Dial: dialer.Dial,
    },
  }

  r, err := client.Get(*target)
  if err != nil {
    log.Fatal(err)
  }
  defer r.Body.Close()
  body, err := ioutil.ReadAll(r.Body)
  if err != nil {
    log.Fatal(err)
  }
  fmt.Println(string(body))
}

The golang.org/x/net/proxy package provides explicit tools for proxies, and here specifically we're using its SOCKS5 dialer as a custom Dial in a Transport. We can run this client as follows:

$ go run http-get-socks-transport.go -proxy localhost:1080 \
    -user myuser -pass mypass \
    http://example.org
<!doctype html>
<html>
// ... rest of response

SOCKS5 vs. CONNECT tunnels

In part 2 of this series we've covered proxying arbitrary traffic via a standard HTTP server by means of the CONNECT method. What is the difference between CONNECT-based proxies and SOCKS5 proxies?

First, to repeat a bit of historic context: the SOCKS protocol predates the CONNECT method. It requires a dedicated service listening on a dedicated port, whereas handling CONNECT tunnels can be included in an existing HTTP server and share the port.

On the other hand, while SOCKS5 can handle any TCP or UDP traffic, CONNECT handles only TCP [1].

Finally, note that the security aspects of SOCKS5 are rather primitive, whereas HTTP proxies can use TLS under the hood to handle both client-server authentication and traffic encryption. It is fair to mention, however, that it's not too hard to combine protocols and "wrap" SOCKS5 in TLS - in fact, this is what ssh -D does already. An alternative would be to run SOCKS5 via SSH port forwarding.

SOCKS5 server in Go

There are a number of open-source SOCKS5 written in Go; one I liked for its clarity and simplicity is go-socks5, a project from one of Hashicorp's co-founders. A basic sample of setting up a SOCKS5 server using this package is available in its README. I will show a slightly more advanced sample that uses basic authentication just like the microsocks example discussed earlier:

package main

import (
  "flag"

  "github.com/armon/go-socks5"
)

type myCredentialStore struct {
  user     string
  password string
}

func (cs *myCredentialStore) Valid(user, password string) bool {
  return user == cs.user && password == cs.password
}

func main() {
  username := flag.String("u", "", "username for SOCKS5 proxy")
  password := flag.String("P", "", "password for SOCKS5 proxy")
  flag.Parse()

  auth := socks5.UserPassAuthenticator{
    Credentials: &myCredentialStore{user: *username, password: *password},
  }

  conf := &socks5.Config{
    AuthMethods: []socks5.Authenticator{auth},
  }

  server, err := socks5.New(conf)
  if err != nil {
    panic(err)
  }

  if err := server.ListenAndServe("tcp", "127.0.0.1:1080"); err != nil {
    panic(err)
  }
}

We can run this server as follows:

$ go run . -u myuser -P mypass

And then curl as before:

$ http_proxy=socks5://myuser:mypass@localhost:1080 curl -v http://example.org
* Uses proxy env variable http_proxy == 'socks5://myuser:mypass@localhost:1080'
*   Trying 127.0.0.1:1080...
* SOCKS5 connect to IPv4 93.184.216.34:80 (locally resolved)
* SOCKS5 request granted.
* Connected to (nil) (127.0.0.1) port 1080 (#0)
> GET / HTTP/1.1
> Host: example.org
> User-Agent: curl/7.81.0
> Accept: */*
>
* Mark bundle as not supporting multiuse
< HTTP/1.1 200 OK
// ... rest of response

[1]It should be noted that many open-source SOCKS5 servers do not, in fact, implement UDP since it's not used often. For example, the two servers discussed in this post - microsocks and go-socks5 - have no UDP support.