I recently finished reading The Go Programming Language , and wanted to write something to practice. Go is more suitable for writing server software, and before that I learned Socks5 protocol, so I decided to write a Socks5 proxy server. The basic function is finished, part of the idea is referred to ginuerzh/gost. I named it Subsocks, sub- meaning under … (similar to “subway”). The project Repository is here: lyuhuang/subsocks. Here’s an introduction and a brief summary of its implementation.

Use

A Subsocks process can be client-side or server-side, depending on the configuration. The client receives a Socks5 request from an application (e.g. a browser), encapsulates it in a specified security protocol (e.g. HTTPS), and sends it to the server. The server side then unwraps the request from the client and accesses the network.

image

The configuration file format is TOML. Create a client configuration file cli.toml , with the following contents:

1
2
3
4
5
6
7
8
[client]
listen = "127.0.0.1:1030"

server.protocol = "https"
server.address = "SERVER_IP:1080" # replace SERVER_IP with the server IP

http.path = "/proxy" # same as http.path of the server
tls.skip_verify = true # skip verify the server's certificate since the certificate is self-signed

Then start the client:

1
subsocks -c cli.toml

Also create the server-side configuration file ser.toml :

1
2
3
4
5
[server]
listen = "0.0.0.0:1080"
protocol = "https"

http.path = "/proxy"

Then start the server:

1
subsocks -c ser.toml

Then we set the browser Socks5 proxy address to 127.0.0.1:1030 and we are ready to use. The above example will use an automatically generated self-signed certificate, so the client should set tls.skip_verify = true to skip the certificate authentication. This approach may be vulnerable to man-in-the-middle attacks. A more secure approach is to configure tls.cert and tls.key on the server side to set a custom certificate, and to configure tls.ca on the client side to lock the certificate if it is not signed by an authoritative CA. Detailed documentation can be found on the project home page.

Protocol Stack

Since Subsocks is Socks5 wrapped around other protocols, Subsocks has a few more layers in the protocol stack than a normal Socks5 proxy. We may need to implement Socks5 on top of HTTP or Websocket.

image

Go is perfect for this. For example, adding a TLS layer to a normal TCP connection to make it a TLS connection can be done in a single line of code:

1
tlsConn := tls.Client(conn, tlsConfig)

tls.Client passes in a net.Conn object and a TLS configuration, and returns a new net.Conn object. net.Conn is an interface, we only need to care about the Read, Write and other methods, everything else is transparent. The returned tlsConn can then be used as a normal connection, without having to care about the details of TLS at all.

Similarly, we can implement an HTTP wrapper, pass in a net.Conn. Any data written on the new connection object will be wrapped in an HTTP request; any data read will be stripped of the HTTP headers. We simply write a type that implements the net.Conn interface.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
type httpWrapper struct {
	net.Conn
	client *Client
	buf    *bytes.Buffer
	ioBuf  *bufio.Reader
}

func newHTTPWrapper(conn net.Conn, client *Client) *httpWrapper

func (h *httpWrapper) Read(b []byte) (n int, err error) {
	if len(b) == 0 {
		return 0, nil
	}

	if h.buf.Len() > 0 {
		return h.buf.Read(b)
	}

	res, err := http.ReadResponse(h.ioBuf, nil)
	if err != nil {
		return 0, err
	}
	defer res.Body.Close()

	if n, err = res.Body.Read(b); err != nil && err != io.EOF {
		return
	}
	if _, err = h.buf.ReadFrom(res.Body); err != nil && err != io.EOF {
		return
	}
	err = nil
	return
}

func (h *httpWrapper) Write(b []byte) (n int, err error) {
	req := http.Request{
		Method: "POST",
		Proto:  "HTTP/1.1",
		URL: &url.URL{
			Scheme: h.client.Config.ServerProtocol,
			Host:   h.client.Config.ServerAddr,
			Path:   h.client.Config.HTTPPath,
		},
		Host:          h.client.Config.ServerAddr,
		ContentLength: int64(len(b)),
		Body:          ioutil.NopCloser(bytes.NewBuffer(b)),
	}
	if err := req.Write(h.Conn); err != nil {
		return 0, err
	}
	return len(b), nil
}

httpWrapper is embedded in net.Conn, and httpWrapper.Conn is assigned to the original connection, so other methods of net.Conn, such as LocalAddr, are equivalent to those of the original connection. We only implement Read and Write .

The idea of Read is to read the body of the HTTP message into a buffer, read it in the buffer each time it is read, and wait if the buffer is empty. If the buffer is not empty, the buffer is read; otherwise, the HTTP message is read for its Body, and if it is not exhausted, the remaining data is written to the buffer. This has the same effect.

Write is simpler, just wrap the data in the HTTP request and send it out. Of course, some optimization can be done here, if the data is small, it will be written to a buffer and then sent out as an HTTP request.

HTTPS is actually HTTP over TLS, so you only need to add a TLS layer on top of the HTTP wrapper to get the HTTPS wrapper:

1
httpsConn := newHTTPWrapper(tls.Client(conn, tlsConfig), client)

This is the idea of implementing wrappers for other protocols, calling different wrappers according to the configuration, wrapping a normal TCP connection into a protocol-specific connection, and then implementing Socks5 on top of it. At the moment I have implemented HTTP and Websocket, this pattern is also convenient for extending new protocols in the future.

Socks5

As a proxy protocol, the idea of Socks5 is basically to send a proxy request, then establish the proxy state, and then forward the data blindly. Note that all data passed between the Subsocks client and the server is encapsulated in the specified protocol. The whole process is similar to the Connect method of HTTP.

image

The flow of the Bind method is more or less the same as the Connect method, except that the server does not actively connect to the target server but opens the port and waits for a connection from the target server. Once the proxy state is established, it also forwards the data blindly.

The UDP associate is a little more complicated. Because of the TCP protocol between the Subsocks client and server, the UDP associate method cannot be used directly. Here we refer to gost’s approach and modify Socks5 protocol slightly to add a UDP over TCP method. The process has the following steps:

  • Client connection to establish a TCP connection to the server;
  • Negotiation (same as other methods);
  • Client sends UDP over TCP request, DST.ADDR and DST.PORT fields can be empty;
  • The server creates a UDP socket and returns its address to the client;
  • The next data sent by the client to the server is in the same format as the Socks5 UDP request (see RFC 1928 Section 7), the server will also encapsulate all data received by the UDP socket in this format and send it to the client. This is done by::
    • The RSV field indicates the length of the packet, because TCP is a stream-oriented protocol and does not preserve message boundaries.
    • ADDR and DST.PORT fields of the client packet, the server sends the data to the target server using the UDP protocol;
    • ADDR and DST.PORT fields in the packet sent by the server to the client indicate the UDP address of the target server.

Again, the data passed by the UDP over TCP method is also encapsulated in the specified protocol. At this point, we have fully implemented the Socks5 protocol. Thanks to the many convenient features of Go, the overall implementation is not too complicated. Please refer to the source code for details.

Build and Release

I used Github Actions quite well for this project. I wanted to trigger an action for every tag, compile the binaries for each platform, and upload them to the release page.

Thanks to Go’s cross-compilation, we can easily compile binaries for each platform. I simply wrote a script to do this:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
#!/usr/bin/env bash

platforms=(
"linux amd64"
"linux 386"
"windows amd64 .exe"
"windows 386 .exe"
"darwin amd64"
)

rm -rf targets
mkdir -p targets

for p in "${platforms[@]}"; do
	eval $(echo $p | awk '{printf"os=%s;arch=%s;ext=%s",$1,$2,$3}')
	GOOS=$os GOARCH=$arch go build -o targets/subsocks-$os-$arch$ext
done

Then you just need to execute the script in Github Actions. How do I upload to the release page? You can use the hub command directly in Github Actions, which can be used for many Github operations. The following command will create a release with attachments:

1
hub release create -a FILE -m MESSAGE TAG

My release.yml is written like this:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
name: Release

on:
  push:
    tags:
    - 'v*'

jobs:
  binary:
    name: Release binary files
    runs-on: ubuntu-latest
    steps:
    - name: Set up Go 1.x
      uses: actions/setup-go@v2
      with:
        go-version: ^1.13

    - name: Checkout
      uses: actions/checkout@v2

    - name: Build
      run: |
                bash build.sh

    - name: Release
      env:
        GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
      run: |
        set -x
        assets=()
        for asset in targets/*; do
          assets+=("-a" "$asset")
        done
        tag_name="${GITHUB_REF##*/}"
        hub release create "${assets[@]}" -m "$tag_name" "$tag_name"        

Follow up

No user authentication mechanism has been implemented yet, and access control can only be achieved by configuring a secret path. Later, HTTP summary authentication can be implemented for access control purposes. I don’t plan to implement Socks5 negotiation authentication mechanism, since Subsocks’ Socks5 is based on other protocols, it is obviously better to do authentication in the lower protocol.

Another thing I want to do is an intelligent proxy. It can decide whether a connection or request goes through a proxy based on some rules.

It could be extended to support more protocols, such as SSH and so on. But it doesn’t make much sense at the moment. Rather than that, it might be more useful to have the client support HTTP proxies, since some software only supports HTTP proxies.