Implementing SOCKS5 Server in Node.js

Implementing SOCKS5 Server in Node.js

·

5 min read

There are 2 protocols which defines SOCKS5 server. The first one is SOCKS Protocol Version 5, which defines the basic process of SOCK5. The second one is Username/Password Authentication for SOCKS V5, which defines the username/password authentication process for SOCK5.

Now let's implement both protocols in Node.js.

TCP server

The first part is to create a tcp server, which is easy in Node.js.

const net = require("net");

const port = 1984;

const server = net.createServer(handle);
server.listen(port, () => {
    console.log(`socks5 server listen at ${port}`);
});

Note that the createServer function takes in a callback function, in which we will implement all the code.

async function handle(socket) {
    try {
        const isAuthNeeded = await handshake(socket);
        if (isAuthNeeded) await auth(socket);
        await proxy(socket);
    } catch (e) {
        console.error(e);
        socket.end();
    }
}

When we have the socket object, we can define a function to read messages from this socket.

function consume(socket) {
    return new Promise(resolve => {
        socket.once("data", resolve);
    });
}

Handshake

After tcp connection, the client should sent a version identifier/method selection message:

+----+----------+----------+
|VER | NMETHODS | METHODS  |
+----+----------+----------+
| 1  |    1     | 1 to 255 |
+----+----------+----------+

This message tells the server which methods the client supports, and then the server should choose one and send back a METHOD selection message:

+----+--------+
|VER | METHOD |
+----+--------+
| 1  |   1    |
+----+--------+

Now let's see this process in code.

async function handshake(socket) {
    const data = await consume(socket);
    if (data[0] !== 0x05) {
        throw new Error("invalid protocol", data);
    }

    const nmethods = data[1];
    const method = data.slice(2, 2 + nmethods);

    if (method.includes(0x02)) {
        socket.write(Buffer.from([0x05, 0x02]));
        return true;
    } else if (method.includes(0x00)) {
        socket.write(Buffer.from([0x05, 0x00]));
        return false;
    } else {
        socket.write(Buffer.from([0x00, 0xff]));
        throw new Error("method not supported: ", method);
    }
}

In above code, what the server does is if the client supports 0x02(which means USERNAME/PASSWORD), choose it, if not, then choose 0x00(which means NO AUTHENTICATION REQUIRED). If none of both methods supports, then send back 0xff, which means NO ACCEPTABLE METHODS.

Auth

If the server choose the USERNAME/PASSWORD method, then comes the authentication process.

The client should send a message with username and password information with structure as below.

+----+------+----------+------+----------+
|VER | ULEN |  UNAME   | PLEN |  PASSWD  |
+----+------+----------+------+----------+
| 1  |  1   | 1 to 255 |  1   | 1 to 255 |
+----+------+----------+------+----------+

And the server should verify the UNAME and PASSWD, and then send a response:

+----+--------+
|VER | STATUS |
+----+--------+
| 1  |   1    |
+----+--------+

Note that a STATUS field of 0x00 indicates success. Any other value stands for failure.

async function auth(socket) {
    const data = await consume(socket);
    if (data[0] !== 0x01) {
        throw new Error("invalid protocol", data);
    }

    const ulen = data[1];
    const uname = data.slice(2, 2 + ulen).toString();
    const plen = data[2 + ulen];
    const passwd = data.slice(2 + ulen + 1, 2 + ulen + 1 + plen).toString();

    if (uname !== username || passwd !== password) {
        socket.write(Buffer.from([0x01, 0x09]));
        throw new Error("username or password invalid");
    } else {
        socket.write(Buffer.from([0x01, 0x00]));
    }
}

Proxy

After connection and authentication, then comes the real proxying part.

First the client should send a proxy request, which tells the server the destication address and port.

+----+-----+-------+------+----------+----------+
|VER | CMD |  RSV  | ATYP | DST.ADDR | DST.PORT |
+----+-----+-------+------+----------+----------+
| 1  |  1  | X'00' |  1   | Variable |    2     |
+----+-----+-------+------+----------+----------+
async function proxyRequest(socket) {
    const data = await consume(socket);

    if (data[0] !== 0x05) {
        throw new Error("invalid protocol", data);
    }

    const cmd = data[1];
    if (cmd !== 0x01) {
        throw new Error("unsupported cmd", cmd);
    }

    const atyp = data[3];
    if (atyp !== 0x01) {
        throw new Error("unsupported atyp", atyp);
    }

    const dstHost = `${data[4]}.${data[5]}.${data[6]}.${data[7]}`;
    const dstPort = data.readUInt16BE(8, 10);

    return { dstHost, dstPort };
}

Then the server should open a tcp connection to destination address, and send the client a response.

+----+-----+-------+------+----------+----------+
|VER | REP |  RSV  | ATYP | BND.ADDR | BND.PORT |
+----+-----+-------+------+----------+----------+
| 1  |  1  | X'00' |  1   | Variable |    2     |
+----+-----+-------+------+----------+----------+

Note that the RSV field tells the client the connection status, and 0x00 means a success.

async function proxyReply(socket, address, family, port) {
    const ver = 0x01;
    const rep = 0x00;
    const rsv = 0x00;

    let atyp;
    let addr = [];
    switch (family) {
        case "IPv4":
            atyp = 0x01;
            addr.push(...address.split(".").map(v => Number(v)));
            break;
        case "IPv6":
            // todo
            atyp = 0x04;
            break;
        default:
            throw new Error("invalid family", family);
    }

    const buf = Buffer.from([ver, rep, rsv, atyp, ...addr, port >> 8, port & 0xff]);
    socket.write(buf);
}

After this kind of negotication, then the client could send real requests to the server, the server should redirect them to the destination server. After the destination server sends back real responses, the server should redirect them to the client.

async function proxy(socket) {
    const { dstHost, dstPort } = await proxyRequest(socket);

    const destSocket = net.connect({ host: dstHost, port: dstPort });
    destSocket.once("connect", () => {
        const address = destSocket.address();
        proxyReply(socket, address.address, address.family, address.port);

        // use pipe to proxy data
        socket.pipe(destSocket);
        destSocket.pipe(socket);
    });
    destSocket.on("error", e => {
        console.error("proxy error", e);
        socket.end();
        destSocket.end();
    });
}

Test

This is the whole SOCK5 server code. You can use any existing SOCK5 client to test it. I use the built-in proxy function of Telegram to test it.

136134138-e99d726a-0f61-4a08-937f-9d434e8b8a6e.png

Note that this server code is just for learning purpose, not for production.

During the process, I learn a lot from this repo. If you're interested, check it out.