В компьютерных сетях (как и, в принципе, при любой передаче информации) есть и всегда будут существовать две задачи:

  • конфиденциальность (confidentiality) - я отправляю письмо Маше, это всем известно, но что в этом письме - можем прочитать только мы с Машей

  • анонимность (anonymity) - все могут прочитать содержимое письма, но от кого оно и куда - непонятно (понимаем только мы с Машей)

Соответственно, имея те или иные цели есть множество решений этих задач.

Итак, хорошо. Вот я подключаюсь к своему любимому серверу, и делаю я это по SSH. В целом - мне не так важно, что кто-то узнает, что именно Я подключаюсь именно к ЭТОМУ серверу, больше меня беспокоит, чтобы никто не прочитал мои пароли, мой трафик, и влез таким образом в мои дела. Т.е. в данном случае речь идёт о конфиденциальности. Если бы я делал это по старому доброму Telnet, то увы, здесь я полностью под вашей властью - и по IP меня можно вычислить, и все передаваемые данные в открытом виде.

Иногда бывает так, что в силу тех или иных обстоятельств нет маршрута до узла, тогда есть прокси-серверы - посредники, к которым можно достучаться, а у них есть доступ к нужному узлу.
Отчасти, использование прокси-сервера это, так же, шаг в сторону анонимности, т.к. "кто же отправляет запрос" стало чуть менее понятно.
Есть простые "бытовые" прокси-протоколы HTTP и SOCKS, функцию свою они выполняют - но - все данные в открытом виде.
Чем это может грозить? Да, пароли могут стырить, это уже все знают. Ещё, если есть задача блокировать вам соединение/доступ до нужного узла, можно прочитать эту попытку в данных к проси-серверу и блокировать уже это соединение к прокси-серверу. Совсем неприятно.

Окей, неплохо бы тогда зашифровать трафик до прокси-сервера. Ну, в принципе, тот же SSH-туннель подойдет.
И так же, наподобие простой SOCKS-прокси в 2012 году появляется защищенный ShadowSocks.
Мало того, что вся информация (куда хочется достучаться) зашифрована, так ещё и понять, по подключению, что это ShadowSocks - сложно.

Оговорочки про Shadowsocks

Да, в Википедии написано, что

Clowwindy сказал, что Shadowsocks изначально был разработан для «самостоятельного использования» и «преодоления брандмауэра», а не для обеспечения криптографической безопасности.

но это уже не открытый SOCKS, всё же.

Плюсом, все же есть многочисленные исследования и попытки идентификации этого протокола

Если копать в сторону сокрытия подключения, то появляются всякие протоколы типа Trojan/VMESS/VLESS. Ещё круче.

По сути, помимо конфиденциальности информации они уже начинают выполнять задачу сокрытия самого факта подключения, для обхода защит от таких подключений.

Можно сказать, в какой-то мере, что прокси-сервер решает задачу "транспорта" - передачу данных от узла А к узлу Б, если "прямого доступа" нет.

Все эти прокси-протоколы - HHTP/SOCKS/ShadowSocks/Trojan/VLESS/VMESS - работают поверх TCP соединения. Уж как минимум, оно должно быть установлено.

А что делать, если я не могу достучаться до этого сервера по TCP?
Да, чёрт возьми, если у меня и TCP-то нет, интернета нет?!
Это ведь вопрос передачи информации: используй любой способ, хоть почтовых голубей, без шуток.

Что есть TCP соединение? Это двунаправленный поток данных, гарантирующий правильный порядок отправки и принятия этих самых данных

По сути, если не работает TCP (который, сам является транспортным протоколом) - давайте используем какой-нибудь другой транспортный протокол - вот, почтовых голубей. По сути, это тоже транспортный протокол - есть точка А, точка Б, и мы можем передавать данные между ними письмами, причем, какие данные (что написано в письмах) - нас не волнует.

Итак, задачей данной статьи будет изучение, каким образом передаются данные в протоколе ShadowSocks, передача его поверх другого протокола, и, наконец, исследование использования WebRTC-звонков для передачи нужных данных.

Оглавление

  1. Почтовые голуби

  2. Как ShadowSocks передает данные по сети

  3. Плагины для ShadowSocks

  4. WebRTC и стеганография

  5. Что можно передавать по WebRTC

  6. Простой туннель данных по WebRTC

  7. Пишем простой TCP-транспорт с мультиплексором для ShadowSocks/XRAY

  8. Проблема начала звонка: сигнальный сервер

  9. Заключение

Как ShadowSocks передает данные по сети

Рассмотрим самую простую конфигурацию ShadowSocks клиента и сервера, я попросил её сделать для меня DeepSeek:

Напиши мне самую простую и самую минимальную конфигурации Shadowsocks для клиента и сервера

server.config.json

{
    "server": "0.0.0.0",
    "server_port": 8388,
    "password": "your-strong-password",
    "method": "aes-256-gcm",
    "timeout": 300
}

client.config.json

{
    "server": "127.0.0.1",
    "server_port": 8388,
    "password": "your-strong-password",
    "method": "aes-256-gcm",
    "timeout": 300,
    "local_address": "127.0.0.1",
    "local_port": 1080
}

Сервер слушает порт 8388, а чтобы подключиться и использовать проксю, доступен SOCKS-интерфейс на порту 1080

Посмотрим в Wireshark, как устроен трафик ShadowSocks, посмотрим на TCP-подключения к серверному порту 8388, фильтр tcp.port==8388.

При выполнении запроса curl -x socks5://localhost:1080 ifconfig.me мы видим обмен пакетами между случайно выбранным портом (41032) для подключения к серверу.

При повторе запроса, или создании других подключений к локальной SOCKS-проксе на порту 1080, ShadowSocks будет подключаться к сетевому порту 8388 каждый раз с нового случайно выбранного порта.
В принципе, все тоже самое, как и у обычной HTTP/SOCKS прокси. Окей.

1е соединение с порта 41032, 2е соединение с порта 49900
1е соединение с порта 41032, 2е соединение с порта 49900

Займемся немного программированием. Делать я (или мы) это будем на NodeJS. Увы, и, ах, я знаю все эти фразы "Python version handles 5000 connections with 50MB RAM while node.javascript version handles 100 connections with 300MB RAM. Why should we continue to support node.javascript?" но Python я не люблю, а для учебных целей без высоких нагрузок сойдет любой ЯП, который не отвлекает на себя много внимания. На JS я кодю давно и привык к нему, хотя, самому уже хочется перелезать на Go.

По сути, то, что мы коннектимся к порту 8388, означает, что для транспорта ShadowSocks данных используется протокол TCP.

Подсмотрим, уже как программисты, как устроен этот трафик. Напишем простой сервер на NodeJS, который будет слушать некий порт 8390 и перенаправлять все соединения на порт 8388. А с клиента ShadowSocks мы будем коннектиться не к настоящему серверу на порту 8388, а к нашей "прослушке" - к порту 8390.
По сути, мы пишем примитивный TCP-прокси сервер, которые ещё называются TCP-relay.

Создаем сервер net.createServer, который будет слушать порт 8390.
Для каждого входящего подключения - сокета socket мы будем создавать подключение до реального ShadowSocks сервера на порту 8388 net.createConnection(8388, "127.0.0.1"), и это подключение пайпать к уже имеющемуся socket.

import net from "node:net";

net.createServer(socket => {
	console.log("connection", socket.remoteAddress, socket.remotePort);

	const socketToShadowSocksServer = net.createConnection(8388, "127.0.0.1");

	socket
		.pipe(socketToShadowSocksServer)
		.pipe(socket);
})
	.listen(8390, "127.0.0.1");

Выполним 3 раза подняд curl -x socks5://localhost:1080 ifconfig.me и получим соответствующее

connection 127.0.0.1 46422
connection 127.0.0.1 46430
connection 127.0.0.1 46442

Только что мы перенаправили ShadowSocks трафик от клиента, через наш сервер-"прослушку".

Что это значит для нас? По сути, это значит, что мы можем в целом, с этим трафиком делать всё, что нам вздумается, разве что лишь, гарантируя, что он дойдет на "настоящий" порт 8388.

Как уже говорилось, если мы напрямую не можем законнектится по TCP к серверу, или такое соединение кто-то обрубает, то мы можем вообще использовать свой "разрешенный транспорт" - ту же голубиную почту.

Для этого посмотрим детальнее, как происходит прием/отправка буферов данных (сообщений с сырыми данными) в этих сокетах. По сути каждое письмо почтового голубя - это массив байт.

По сути, каждый сокет - это поток (stream) данных NodeJS. И чтобы подсмотреть, какие данные приходят в сокет, нужно подписаться на событие event:data. NodeJS позволяет писать простенькие потоки stream.Transform, с помощью которых это можно сделать чуть более лаконичней:

import stream from "node:stream";

function createLogStream(label) {
	return new stream.Transform({
		transform(chunk, encoding, callback) {
			console.log(label, chunk.length);
			console.log(getHexTable(chunk));

			this.push(chunk);
			callback();
		}
	});
}

...

socket
	.pipe(createLogStream(`${socket.remoteAddress} ${socket.remotePort} --> ss-server`))
	.pipe(socketToShadowSocksServer)
	.pipe(createLogStream(`${socket.remoteAddress} ${socket.remotePort} <-- ss-server`))
	.pipe(socket);

Выполняем curl-запрос:

connection 127.0.0.1 55854
127.0.0.1 55854 --> ss-server 182
00000000  09 bf 77 91 c7 65 3a 3b a4 72 c9 a3 48 26 92 60 f4 6d 08 f1 3e ee 71 18 1f 72 fa 73 12 03 37 22  ..w..e:;.r..H&.`.m..>.q..r.s..7"
00000020  3d b5 4d 60 8c 91 58 fe 54 b4 b9 00 d1 4f f9 0d 5e 56 a8 7a 56 5d c3 2a fa 8c e2 4a cd d1 7f d8  =.M`..X.T....O..^V.zV].*...J....
00000040  0d 3a 4e d0 56 86 03 20 c9 08 73 7a 16 72 47 1c 8b 42 22 4a de 0e 2a 4e ae be 52 0d bd 63 8f 75  .:N.V.. ..sz.rG..B"J..*N..R..c.u
00000060  15 7c 4e f3 b7 b9 16 22 bd 86 60 6d 5f 66 84 99 bb 5a 1e 01 60 79 dc 91 4c 94 e1 d4 6f dd 29 aa  .|N...."..`m_f...Z..`y..L...o.).
00000080  5d e4 58 32 0f 4c da 8e 26 46 44 91 46 4a 2d 5e 70 fa e0 2c 2a b2 50 1a 52 06 a8 a5 59 55 49 06  ].X2.L..&FD.FJ-^p..,*.P.R...YUI.
000000a0  f8 19 5c cf aa ce 95 39 64 26 be 9f 28 18 bf 5d 13 1b f8 8f 68 f4                                ..\....9d&..(..]....h.
127.0.0.1 55854 <-- ss-server 232
00000000  c0 33 ec 39 ae 39 f4 b9 08 49 5c 0b 1c 81 5c f4 26 ff 41 e0 28 33 02 99 27 9d 69 dc d9 88 ff de  .3.9.9...I\...\.&.A.(3..'.i.....
00000020  2e bf 14 d7 4f 24 40 11 5b c4 8f 71 34 9d bd a3 db 1d 6f 92 a5 26 3b ff 0d ec 29 85 3b b6 0f 69  ....O$@.[..q4.....o..&;...).;..i
00000040  41 14 cb cc 23 c4 c0 07 e4 d5 c5 31 35 d7 d2 03 bb f3 fa c5 b6 2c e1 0c d3 24 16 95 d9 73 a7 2f  A...#......15........,...$...s./
00000060  84 45 25 27 8f 95 b0 95 63 08 3a dc e3 79 6b 6d 84 ae b8 41 0a 6a a9 e7 2f 1d d4 3a ea 30 f9 07  .E%'....c.:..ykm...A.j../..:.0..
00000080  f4 58 bc 6e ee 30 09 95 3f 57 b9 c3 7f 6a c3 50 33 3d db 94 c4 21 6d 23 17 3e f1 1e 8d 60 3a 40  .X.n.0..?W...j.P3=...!m#.>...`:@
000000a0  6f c8 bb 68 ad 8d 55 df 3d 74 aa 3e 6e fb c3 61 ae db d0 16 a5 ea c9 da 57 b5 8c 92 16 b9 ff 2c  o..h..U.=t.>n..a........W......,
000000c0  08 68 f7 21 38 5a 09 40 61 ed ad 25 5b 59 25 b9 90 00 4a 55 62 40 7f 72 74 c8 87 20 f9 1e f4 f1  .h.!8Z.@a..%[Y%...JUb@.rt.. ....
000000e0  a2 e7 93 80 e1 bd 0a 03                                                                          ........

Код функции getHexTable
function getHexTable(buffer, offset = 0, length = null, bytesPerLine = 32) {
	if (!Buffer.isBuffer(buffer)) buffer = Buffer.from(buffer);

	const totalLength = length || buffer.length;
	let output = "";

	for (let i = 0; i < totalLength; i += bytesPerLine) {
		const lineOffset = offset + i;
		const hexOffset = lineOffset.toString(16).padStart(8, "0");

		let hexPart = "";
		let asciiPart = "";

		for (let j = 0; j < bytesPerLine; j++) {
			const pos = i + j;
			if (pos < totalLength) {
				const byte = buffer[pos];
				hexPart += byte.toString(16).padStart(2, "0") + " ";
				asciiPart += (byte >= 32 && byte <= 126) ? String.fromCharCode(byte) : ".";
			} else {
				hexPart += "   ";
				asciiPart += " ";
			}
		}

		output += `${hexOffset}  ${hexPart} ${asciiPart}\n`;
	}

	return output.trim();
}

Видим, что, в отличие от сырого TCP уровня на уровне ShadowSocks есть один пакет исходящих данных, и после него - один пакет входящих данных. В принципе, здесь даже одного голубя хватит.

По сути, в этой точке мы уже можем написать свой транспорт для ShadowSocks.

Если мы хотим брать ShadowSocks-данные, паковать их как то в свой протокол, передавать каким-то своим способом, и снова передавать ShadowSocks-серверу, то нам нужно самим написать клиент-программу и сервер-программу.

Запущенный ShadowSocks-клиент ss-local с SOCKS-проксей на порту 8010 вместо оригинального ShadowSocks-сервера будет коннектиться и передавать трафик на наш proxy-client на порту 8390, далее наш proxy-client будет передавать трафик на наш proxy-server на порту 8391, и уже трафик с него будет идти в оригинальный ShadowSocks-сервер на порту 8388.

Заметим, что proxy-client модифицировать трафик как угодно, важно лишь, чтобы в оригинальный ShadowSocks-сервер пришел исходный трафик.

Т.е. между proxy-client и proxy-server мы можем писать любые трансформации трафика, любой транспорт, применять любые подходящие протоколы.

Оказывается, такая схема есть в самой спецификации ShadowSocks - и называется она плагины.

Плагины для ShadowSocks

Оказывается, в ShadowSocks есть спецификая SIP003, описывающая плагины для ShadowSocks.

Эти плагины так и запускаются - есть программа proxy-client, принимающая ShadowSocks-трафик, и программа proxy-server, отдающая ShadowSocks-трафик на реальный ShadowSocks-сервер.

Не обязательно самому возиться с множеством терминалов, можно отдать запуск плагинов самому ShadowSocks клиенту и серверу.

Вот пример конфига с использованием плагина simple-obfs - плагина для обфускации трафика (уже немного устарел):

server.config.json

{
    "server": "0.0.0.0",
    "server_port": 8388,
    "password": "your-strong-password",
    "method": "aes-256-gcm",
	"plugin": "obfs-local",
    "plugin_opts": "obfs=http;obfs-host=www.cloudflare.com"
}

client.config.json

{
    "server": "127.0.0.1",
    "server_port": 8388,
    "password": "your-strong-password",
    "method": "aes-256-gcm",
    "local_address": "127.0.0.1",
    "local_port": 1080,
	"plugin": "obfs-server",
    "plugin_opts": "obfs=http;failover=127.0.0.1:80"
}

В данном случае трафик немного маскируется под HTTP запросы к cloudflare.com, если на сервер придёт какой-либо другой запрос, то сервер перенаправит его на 127.0.0.1:80.

Очень приличным, на мой взгляд, является плагин Cloak, который каждый запрос в ShadowSocks маскирует под какое-то HTTPS соединение.

Собственно, если посмотреть на выходящие соединения фреймверка XRAY, так же можно "вмешиваться" в передачу данных, создавая свои транспорты. Есть некоторые детали, о которых будет сказано далее.

WebRTC и стеганография

Задача, которую ShadowSocks решает - конфиденциальность, это значит, что используя его поверх любого другого транспорта, не нужно об этом париться.

Задача, которую он не решает - собственно, сам транспорт. Shadowsocks работает поверх протокола TCP. А как я уже говорил выше, если TCP соединения до нужного сервера нет...
Вот у нас по мобильному интернету не всегда есть TCP соединения, это не блокировка по SNI, реально нет коннекта. А вот ВКонтакте - работает.

В принципе, голубь может и отдохнуть - я могу посылать сообщения ВКонтакте, выбрать два профиля двух людей, один будет - клиент - другой - сервер, и уже можно бросаться ShadowSocks-пакетами. Да, в теории, это будет работать. Скорость соединения, конечно... маловато будет, и блок сразу прилетит.

Зато можно будет говорить, что я пустил ShadowSocks-трафик поверх сообщений ВКонтакте.

Так, ну ладно, думаю дальше. Можно звонить по телефону, так же работают ещё и Вк-Звонки.
По идее, можно вместо человеческого голоса пересылать всяческие сигналы - а это уже придумано и называется стеганография, и вообще на этом шипел весь Dial-Up. Т.е. настроить какой-нибудь виртуальный микрофон, считывать и передавать с него аудиоданные, кодировать их и раскодировать... В принципе, интересно. И проекты такие есть.

Хорошо. Я не очень люблю серьёзную математику с Фурье-преобразованиями, мне больше че-нить попрограммировать. Вк-Звонки, очевидно, используют технологию WebRTC, которую я и стал изучать.

Что можно передавать по WebRTC

Довольно быстро вскрылось, что, помимо аудио и видео каналов передачи данных между пирами (участниками звонка), можно так же создать DataChannel - канал передачи данных. Как минимум - для организации чата и обмена техническими сообщениями.

С точки зрения транспорта для ShadowSocks/XRAY - то, что нужно. Просто канал, который передаёт данные, "из А в Б".

Корневой класс для работы с WebRTC это RTCPeerConnection, который в конструктор просит iceServers - STUN/TURN серверы для передачи трафика.

Т.к. я пользуюсь провайдерским интернетом и всегда сижу за NAT, то меня интересуют только TURN серверы - по сути relay серверы, через которых передаются данные звонка.

Обозначились вопросы:

  • Откуда взять эти самые работающие TURN серверы?

  • Можно ли создать WebRTC соединение между пирами, и создать только DataChannel между ними?

  • Будет ли оно работать?

Чтобы ответить на первый вопрос - можно подсмотреть эти сервера при совершении звонка Вконтакте. Делается это простым monkeypatching'ом в браузере.
Если эта технология используется, значит, где-то в недрах есть вызов конструктора RTCPeerConnection.
Всё, что нам нужно - подсмотреть его аргументы.
Выполним перед подключением/звонком этот код в браузере и смотрим, что пишет в начале звонка:

const OriginalRTCPeerConnection = window.RTCPeerConnection;

window.RTCPeerConnection = function (...args) {
	console.log("Calling RTCPeerConnection with", args);
	return OriginalRTCPeerConnection(...args);
};

window.RTCPeerConnection.prototype = OriginalRTCPeerConnection.prototype;

Получаем TURN-сервер, до которого есть TCP-коннект для наших экспериментов:

На 2й и 3й вопрос можно ответить только опытным путём, чем мы сейчас и займемся.

Простой туннель данных по WebRTC

Для написания кода и тестирования, в интернете можно найти множество сервисов, предоставляющих STUN/TURN. Есть, например, https://www.metered.ca/ - он предлагает бесплатный TURN-сервер в месяц с ограничением трафика в 500 мб. Для разработки - самое то!

Задача: написать WebRTC соединение между двумя пирами, и создать только DataChannel между ними - проверить возможность отправлять и получать данные для каждого из двух пиров.

Для работы с WebRTC используется NPM-библиотека wrtc.

Напишем класс DataPeer, главным в котором будет

  • метод sendData(data) для отправки буфера данных

  • событие data - получения буфера данных

Для эмитирования событий отнаследуемся от EventEmitter

import EventEmitter from "events";

class DataPeer extends EventEmitter {
	constructor() {
		super();
	}

	sendData(data) {
	}
}

Создаем RTCPeerConnection, в конструктор которого передаём полученные iceServers и iceTransportPolicy: "relay" - т.к. нас интересуют только TURN-сервера, для ретрансляции трафика, iceServers пробрасываем через конструктор DataPeer

import EventEmitter from "events";
import wrtc from "wrtc";

class DataPeer extends EventEmitter {
	constructor(iceServers) {
		super();

		this.peerConnection = new wrtc.RTCPeerConnection({
			iceServers,
			iceTransportPolicy: "relay"
		});

		this.peerConnection.ondatachannel = event => {
			this.setDataChannel(event.channel);
		};
	}
}

Соединение в WebRTC устроено по схеме обмена offer (предложение) и answer (ответа).
Всегда есть пир, инициализирующий соединение и пир, принимающий это соединение.

Первый пир создаёт и отправляет offer (функция createOffer), второй пир принимает offer, на основе него создаёт и отправляет answer (функция createAnswer).
Наконец, первый пир устанавливает answer ответ второго, и соединение установлено.

В этой последовательности, а особенно её программировании, куча подводных камней. Например, функция waitForIceGathering - она ожидает кандидатов-серверов, через которые будет установлено соединение. И ждать их нужно сразу после вызова peerConnection.setLocalDescription.
Она запускает процесс поиска (gathering) кандидатов-серверов (iceCandidates) для соединения, каждый раз выстреливая событие onicecandidate.
Нам нужно ждать до первого кандидата-сервера с type === "relay" т.е. TURN.

async waitForIceGathering() {
	return new Promise((resolve, reject) => {
		this.peerConnection.onicecandidate = event => {
			// дожидаемся первого relay candidate
			if (event.candidate &&
				event.candidate.type === "relay") {
				resolve();
			}
		};
	});
}

async createOffer() {
	this.setDataChannel(this.peerConnection.createDataChannel("data"));
	const offer = await this.peerConnection.createOffer();

	await this.peerConnection.setLocalDescription(offer);

	await this.waitForIceGathering();

	return this.peerConnection.localDescription;
}

async createAnswer(offer) {
	await this.peerConnection.setRemoteDescription(offer);

	const answer = await this.peerConnection.createAnswer();

	await this.peerConnection.setLocalDescription(answer);

	await this.waitForIceGathering();

	return this.peerConnection.localDescription;
}

async setAnswer(answer) {
	await this.peerConnection.setRemoteDescription(answer);
}

Теперь по поводу самого DataChannel. Кто инициализирует соединение (создаёт offer) - тот и создает до соединения нужный DataChannel, чтобы информация о нём записалась в offer.
Принимающий соединение получит DataChannel в событии ondatachannel.

Напишем метод setDataChannel, чтобы использовать его в обоих случаях, подпишемся на события DataChannel onopen, onclose и onmessage и реализуем метод sendData, использовав установленный dataChannel.

constructor(iceServers) {
...

	this.peerConnection.ondatachannel = event => {
		this.setDataChannel(event.channel);
	};
}

sendData(data) {
	this.dataChannel.send(data);
}

setDataChannel(dataChannel) {
	this.dataChannel = dataChannel;

	this.dataChannel.onopen = () => {
		this.emit("connected");
	};
	
	this.dataChannel.onclose = () => {
		this.emit("disconnected");
	};
	
	this.dataChannel.onmessage = event => {
		this.emit("data", event.data);
	};
}

Для тестирования создадим два пира, проведем процедуру обмена offer и answer, подпишемся на события connected и data, и отправим тестовое сообщение.

const peer1 = new DataPeer(iceServers);
const peer2 = new DataPeer(iceServers);

const offer = await peer1.createOffer();
const answer = await peer2.createAnswer(offer);
await peer1.setAnswer(answer);

peer1.once("connected", () => {
	console.log("peer1 connected");

	peer1.on("data", data => {
		console.log("peer1 recieve data", data);
	});

	peer1.sendData("TEST 1");
});

peer2.once("connected", () => {
	console.log("peer2 connected");

	peer2.on("data", data => {
		console.log("peer2 recieve data", data);
	});

	peer2.sendData("TEST 2");
});

Запуская, таки получим ожидаемое:

peer1 connected
peer2 connected
peer2 recieve data TEST 1
peer1 recieve data TEST 2

Конечно, для более внушаемого теста, разных пиров нужно запустить на разных машинах, и, желательно, за NAT.
Но! Есть один маленький, но очень значимый нюанс. Если вы захотите провести тесты на разных машинах - вам не удастся этого сделать, т.к. нет механизма обмена offer и answer между пирами. Об этом чуть позже.

По факту, благодаря использованию TURN-сервера, мы можем сделать туннель данных, уже поверх которого пойдут шифрованные ShadowSocks-данные.
А интерфейс класса DataPeer, с методом sendData, и событием data, как раз нам это и предоставляет. Отлично!

Полный код класса DataPeer с тестированием подключения
import EventEmitter from "events";

import wrtc from "wrtc";

class DataPeer extends EventEmitter {
	constructor(iceServers) {
		super();

		this.peerConnection = new wrtc.RTCPeerConnection({
			iceServers,
			iceTransportPolicy: "relay"
		});

		this.peerConnection.ondatachannel = event => {
			this.setDataChannel(event.channel);
		};
	}

	sendData(data) {
		this.dataChannel.send(data);
	}

	setDataChannel(dataChannel) {
		this.dataChannel = dataChannel;

		this.dataChannel.onopen = () => {
			this.emit("connected");
		};

		this.dataChannel.onclose = () => {
			this.emit("disconnected");
		};

		this.dataChannel.onmessage = event => {
			this.emit("data", event.data);
		};
	}

	async waitForIceGathering() {
		return new Promise((resolve, reject) => {
			this.peerConnection.onicecandidate = event => {
				// дожидаемся первого relay candidate
				if (event.candidate &&
					event.candidate.type === "relay") {
					resolve();
				}
			};
		});
	}

	async createOffer() {
		this.setDataChannel(this.peerConnection.createDataChannel("data"));

		const offer = await this.peerConnection.createOffer();

		await this.peerConnection.setLocalDescription(offer);

		await this.waitForIceGathering();

		return this.peerConnection.localDescription;
	}

	async createAnswer(offer) {
		await this.peerConnection.setRemoteDescription(offer);

		const answer = await this.peerConnection.createAnswer();

		await this.peerConnection.setLocalDescription(answer);

		await this.waitForIceGathering();

		return this.peerConnection.localDescription;
	}

	async setAnswer(answer) {
		await this.peerConnection.setRemoteDescription(answer);
	}
}

const iceServers = ...

const peer1 = new DataPeer(iceServers);
const peer2 = new DataPeer(iceServers);

const offer = await peer1.createOffer();
const answer = await peer2.createAnswer(offer);
await peer1.setAnswer(answer);

peer1.once("connected", () => {
	console.log("peer1 connected");

	peer1.on("data", data => {
		console.log("peer1 recieve data", data);
	});

	peer1.sendData("TEST 1");
});

peer2.once("connected", () => {
	console.log("peer2 connected");

	peer2.on("data", data => {
		console.log("peer2 recieve data", data);
	});

	peer2.sendData("TEST 2");
});

Пишем простой TCP-транспорт с мультиплексором для ShadowSocks/XRAY

При программировании TCP-relay мы видели, что, для каждого нового "пользовательского" SOCKS-подключения к ShadowSocks-клиенту, ShadowSocks делает также новое TCP-подключение до своего сервера.

Этот факт немного мешает нам так просто поиспользовать DataPeer и его метод sendData. Разные подключения могут жить разное время, одновременно может быть обмен данными с разных подключений.

Каждое подключение можно представить как телефонный звонок. Разные люди могут совершать (и заканчивать) одновременно звонки, они могут длиться разное время.
DataPeer же, представляет собой один туннель данных - все равно что, один "долгий" телефонный звонок. И нам нужно все эти данные по множеству одновременных звонков передавать через единственный звонок DataPeer.

Эта задача называется мультиплексированием. Да, пускай у нас было много каналов связей, передающих информацию, но мы можем (с потерей скорости) начать передавать всю эту информацию только через один канал.

Поскольку мы знаем все порты подключения, которые приходили с ShadowSocks-клиента, мы можем использовать их номера как номера каналов связи (идентификаторы телефонных звонков). А чтобы передавать данные через DataPeer, будем добавлять к каждому буферу данных этот самый номер.

Объясним рисунок: у нас есть 2 локальных подключения, с портов 49032 и 44021. На каждом из этих подключений s - это буфер отправляемых данных, a r - буфер принятых данных.
Т.е. r2 (49032) - это второй буфер принимаемых данных для подключения на порту 49032.
Если DataPeer будет корректно маркировать все буфера данных, на входе и на выходе, всё будет окей.

Для мультиплексирования потоков (stream) данных NodeJS, ровно как и сокетов (sockets).
Для NodeJS есть порт распространенной либы на Go - yamux.
Увы, она у меня не взлетела, после серий тестов стабильно крашилась с ошибкой; сценарий, когда я начинал рандомно блуждать в интернете, используя локальную SOCKS-проксю - не понятен, поэтому я решил не делать issue и править код (плюс, не люблю TypeScript), и быстренько набросал c таким же интерфейсом либу - muxjs. (Полагаю, в скором времени напишу статью, как написать простенький мультиплексор).

Использование следующее: основной поток данных создается конструктором mux()

import mux from "@lis355/muxjs";

const multiplexorStream = new mux();

Для каждого дочернего потока мы просто вызываем openStream(), а чтобы узнать, что на другой стороне создали дочерний поток - подписываемся на событие stream:

const multiplexorStream = new mux();
multiplexorStream.on("stream", stream => {
		console.log(`multiplexorStream stream ${stream.id} opened by other side`);
	});

const stream = multiplexorStream.openStream();
console.log(`multiplexorStream stream ${stream.id} opened`);

Перейдем программированию клиента и сервера.

Клиент должен обрабатывать подключения с ShadowSocks ss-local на порту 8390.
Каждое соединие - т.е. localSocket он будет мультиплексировать через muxStream.
А вот уже данные, которые приходят в событие data у muxStream он будет отправлять в DataPeer - туннель по WebRTC.

Сервер должен так же соединить свой DataPeer и свой muxStream. Затем, для каждого нового stream в muxStream - создать подключение с ShadowSocks ss-server на порту 8388 и перенаправить данные нужным путём.

Связь данных между DataPeer и muxStream будет осуществляться через события data, методы DataPeer.sendData() и muxStream.write() следующим путем:

muxStream.on("data", data => {
	dataPeer.sendData(data);
});

dataPeer.on("data", data => {
	muxStream.write(data);
});

Код проверки клиента и сервера будет выглядеть примерно так (пока что отдельное подключение DataPeer'ов чуть позже):

const clientDataPeer = new DataPeer(iceServers);
const serverDataPeer = new DataPeer(iceServers);

const offer = await clientDataPeer.createOffer();
const answer = await serverDataPeer.createAnswer(offer);
await clientDataPeer.setAnswer(answer);

await Promise.all([
	new Promise(resolve => clientDataPeer.once("connected", resolve)),
	new Promise(resolve => serverDataPeer.once("connected", resolve))
]);

console.log("DataPeers connected");

async function client() {
	const muxStream = new mux();

	muxStream.on("data", data => {
		clientDataPeer.sendData(data);
	});

	clientDataPeer.on("data", data => {
		muxStream.write(data);
	});

	net.createServer(localSocket => {
		console.log("client connection from ss-local", localSocket.remoteAddress, localSocket.remotePort);

		const stream = muxStream.openStream();

		localSocket
			.pipe(stream)
			.pipe(localSocket);
	})
		.listen(8390, "127.0.0.1");
}

async function server() {
	const muxStream = new mux();

	muxStream.on("data", data => {
		serverDataPeer.sendData(data);
	});

	serverDataPeer.on("data", data => {
		muxStream.write(data);
	});

	muxStream.on("stream", stream => {
		console.log("server create connection to ss-server");

		const socketToShadowSocksServer = net.createConnection(8388, "127.0.0.1");

		stream
			.pipe(socketToShadowSocksServer)
			.pipe(stream);
	});
}

server();
client();

При запуске у меня код упал, ругнувшись, что muxStream.write принимает Buffer а не какой-то там ArrayBuffer - окей, преобразуем:

muxStream.write(Buffer.from(data));

При выполнении curl -x socks5://localhost:1080 ifconfig.me всё работает! Айпишник выводится мой, т.к. и клиент и сервер у меня на моей машине, и можно ставить точки остановки, глядя, как снуют туда-сюда данные.

Проблема начала звонка: сигнальный сервер

Всё это работало, да только есть серьёзный нюанс: оба DataPeer находились на одной машине. Для подключения они должны обменяться offer и answer - у меня они это делали прямо в коде, в одном процессе, поэтому всё и работало.

В мире WebRTC установлению, начальной настройке "звонка" уделяется много внимания. Как минимум, этим занимаются так называемые, специальные сигнальные сервера.
Обычно, если есть некий сервис, предлагающий звонки и текстовый чат, то у него под капотом есть специальный сигнальный сервер, который и производит обмен offer и answer. На самом деле это просто строковые данные, строки в определенной спецификации.

Ну допустим, взять какой-нибудь сервер, и написать (и/или) захостить свой.

Но это-то всё и ломает, скажете вы! Конечно, и будете правы.
Изначально идея была такая, что доступны звонки, но остальной интернет - недоступен.

Нужно лишь обменяться offer и answer - это буквально 2 текстовых сообщения, "туда-и-обратно", если есть физическая доступность то можно хоть QR коды подключения сделать и ими обменяться. Ну да, или голубя послать)

Окей, если Вк-Звонки работают - значит где-то там есть нужный сигнальный сервер. В теории, конечно. Но его так просто уже не использовать - это абсолютно чужой код с неизвестными интерфейсами и совершенно кастомной логикой, нагруженный ещё кучей задач.

У меня более, гм, тупое предложение: Вк-сообщения же работают. Можно их попробовать для этих целей поиспользовать. Ну а что? Чем не канал передачи данных?) Тем более, что нужно отправить всего-лишь один offer в одну сторону и оттуда прочитать один answer.

Выбор за вами! Может быть, вы что-то придумаете ещё?)

Заключение

Фраза, засевшая у меня в голове, которая меня сподвигла к написанию этой статьи: если где-то передается информация, то значит это место можно использовать, чтобы передавать любую информацию.

ShadowSocks обеспечивает какую-никакую, а конфиденциальность данных, и оказалось, его можно пускать поверх других протоколов - от обычного TCP-соединения (как он, обычно, и используется), до "голубиной почты" и WebRTC построенного туннеля данных, если есть такая возможность.

Мало кто пишет об использованной литературе, да и в принципе, большую часть знаний я узнал опытным путём и сёрфя нет, но все же, я за академический подход - Таненбаум - "Компьютерные сети"

Спасибо за прочитанную статью и удачи в программировании!

P.S. Ищу работу в области компьютерных сетей в Питере, с интересным проектом и командой, визиточка - https://lis355.github.io/.