Когда мы начинали делать проект, задача казалась обманчиво знакомой. Нужно получить изображение удалённого экрана, передать события мыши и клавиатуры в обратную сторону и как‑нибудь соединить два компьютера. Если смотреть на задачу с высоты архитектурной диаграммы, всё помещается в несколько прямоугольников.

Проблемы начинаются ниже этой диаграммы. Один пользователь находится за офисным межсетевым экраном, второй подключён через домашний роутер. На Windows экран входа живёт не в той сессии, в которой запущен интерфейс приложения. macOS может разрешить захват экрана и запретить ввод. Wayland вообще не даёт приложению просто взять содержимое экрана. При плохом канале очередь видеокадров растёт быстрее, чем сеть успевает их отправлять. А если пустить трафик через свой сервер, возникает неприятный вопрос: может ли этот сервер увидеть экран или перехватить управление?

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

Сразу уточню терминологию. Под P2P в текущей нативной реализации мы понимаем прямое TCP‑соединение внутри доступной локальной сети. Это не обещание пробить любой NAT в интернете. Если прямой маршрут недоступен, сессия переходит на ретранслятор. Такое ограничение менее эффектно звучит, зато точно описывает работающую систему.

Из чего состоит система

Мы довольно рано разделили систему на управляющий контур и контур данных.

Управляющий контур знает пользователей, устройства, разрешения, тарифные ограничения, идентификаторы сессий и их состояние. Он создаёт короткоживущие разрешения на подключение, выбирает ретранслятор и сообщает участникам параметры сессии. Серверная часть этого контура написана на PHP, а сервис сигналинга на Go и WebSocket.

Контур данных переносит изображение, ввод, буфер обмена и файлы. Здесь работают нативный агент на Rust, прямые TCP‑соединения и, если прямой путь не получился, небольшой ретранслятор на Go. Интерфейс настольного клиента собран на Tauri 2, но всё чувствительное к задержкам и правам операционной системы находится в Rust.

Упрощённый запуск сессии выглядит так:

Схема установки защищённого соединения
Схема установки защищённого соединения

Сигналинг не переносит видео. Его задача сводится к согласованию сессии, обмену ключами, публикации P2P‑кандидатов, переключению на ретранслятор и завершению соединения. Это принципиальное разделение. Потеря WebSocket не должна автоматически означать потерю уже установленного канала данных.

Почему мы не оставили всё на WebRTC

Первая естественная мысль для P2P звучит так: взять WebRTC, получить ICE, TURN, защищённые каналы и готовую работу из браузера. Для браузерного сценария этот путь у нас остался. Но для нативного удалённого рабочего стола вскрылись неудобные свойства.

Нам были нужны разные правила доставки для разных типов данных. События клавиатуры должны идти строго по порядку. Очередь старых видеокадров, наоборот, вредна: пользователь скорее увидит свежий кадр с пропуском промежуточных, чем через пять секунд посмотрит всю историю движения окна. Файловые блоки можно распараллелить, но нельзя терять. Полноэкранное обновление хочется разнести по нескольким потокам, а небольшие изменения экрана нельзя переупорядочивать.

Можно строить всё это поверх каналов WebRTC, но тогда значительная часть поведения всё равно оказывается нашей. Добавились и платформенные различия между нативной и браузерной реализациями, сложная диагностика ICE и зависимость от того, как конкретная библиотека управляет очередями.

В нативном клиенте мы в итоге сделали собственный протокол REMOTIK/2 поверх TCP. Это решение нельзя назвать бесплатным. Мы получили предсказуемую модель потока и одинаковое кадрирование для прямого соединения и ретранслятора, но вместе с ней получили ответственность за ограничения пакетов, backpressure, heartbeat, переподключение вспомогательных потоков, защиту от replay и совместимость версий.

Одна логическая сессия и тринадцать TCP‑потоков

У нативной сессии есть одна управляющая полоса, четыре полосы видео и восемь полос передачи файлов. Все они подключаются к одному TCP‑порту, а назначение указывается в первой строке соединения.

REMOTIK/2 session=<id> device=<id> role=initiator lane=control
REMOTIK/2 session=<id> device=<id> role=initiator lane=video-0
REMOTIK/2 session=<id> device=<id> role=initiator lane=file-0

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

Видеополосы обслуживаются независимо. Если пропал только video-2, мы не закрываем окно удалённого рабочего стола и не обрываем ввод. Клиент восстанавливает именно эту полосу. Аналогично файловые полосы могут подключиться позже, а до этого блоки файла временно идут по управляющему каналу.

Почему не сделать один TCP‑поток? Из‑за head of line blocking. Если в один поток положить большой видеокадр, за ним могут застрять отпускание клавиши, движение мыши и подтверждение файлового блока. TCP гарантирует порядок байтов, но не знает, что для пользователя отпускание Ctrl важнее уже устаревшей полосы изображения.

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

Как устанавливается прямое соединение

После принятия сессии и завершения обмена E2E‑ключами сторона, показывающая экран, открывает TcpListener на 0.0.0.0:0. Нулевой порт означает, что свободный порт выбирает операционная система. Затем агент определяет локальный IPv4-адрес основного маршрута и отправляет через сигналинг сообщение p2p_candidates:

{
  "port": 52341,
  "addresses": ["192.168.1.20"]
}

Просматривающая сторона перебирает кандидаты и открывает управляющую полосу. После неё поднимаются вспомогательные соединения. Все они повторяют заголовок REMOTIK/2 с тем же идентификатором сессии и своим именем полосы.

На принимающей стороне имя полосы проверяется строго. Допустимы только control, video-0 до video-3 и file-0 до file-7. Заголовок ограничен 4 КиБ, на его чтение даётся пять секунд. Это мелкие ограничения, но именно из таких ограничений состоит защита сетевого демона от случайного или намеренного исчерпания ресурсов.

Текущий способ определения адреса намеренно простой: UDP‑сокет логически подключается к внешнему адресу, после чего мы смотрим выбранный системой локальный адрес. Пакет при этом отправлять не обязательно. Метод хорошо находит основной интерфейс, но плохо описывает машины с несколькими сетевыми картами, VPN и сложной маршрутизацией. Мы считаем это известным ограничением, а не скрываем под общим словом «P2P».

Если прямое подключение не удалось за отведённое время, участники получают уже зарезервированные параметры ретранслятора и открывают те же полосы через него. Формат прикладных пакетов и E2E‑шифрование не меняются. Благодаря этому код захвата экрана, ввода и файлов не знает, находится между участниками сервер или обычный коммутатор локальной сети.

Что на самом деле делает ретранслятор

Ретранслятор не является удалённым рабочим столом на сервере. Он не декодирует видео, не разбирает события клавиатуры и не хранит буфер обмена. Его основная структура данных сопоставляет пару соединений по идентификатору сессии, ролям и имени полосы, после чего байты копируются в обе стороны.

Перед прикладными данными клиент отправляет строку авторизации:

REMOTIK/2 session=<id> device=<id> role=target lane=control token=<short-lived-token>

Токен подписан Ed25519 управляющим контуром. Внутри зафиксированы область действия, сессия, устройство, роль, эпоха транспорта и срок действия. Сервис сигналинга и ретранслятор получают только публичные ключи проверки. Ключ, которым выпускаются разрешения, в контур данных не передаётся.

В рабочей конфигурации внешнее TCP‑соединение дополнительно защищено TLS 1.3 с проверкой сертификата и имени узла. На первый взгляд это кажется лишним, ведь внутри всё равно есть E2E. Но TLS защищает строку подключения и токен, затрудняет пассивный анализ до начала прикладного обмена и не даёт локальному посреднику незаметно подменить адрес ретранслятора. E2E решает другую задачу: даже сам корректный ретранслятор не должен видеть содержимое сессии.

При этом E2E не делает сервер невидимым. Ретранслятор знает IP‑адреса участников, время соединения, объём и направление трафика. По таймингам можно отличить активную работу от неподвижного экрана. Сквозное шифрование защищает содержание, но не всю метаинформацию. Это важная граница модели угроз.

Выбор ретранслятора по реальному пути

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

Нативные клиенты поэтому периодически измеряют TCP RTT до каждого доступного узла. Браузер делает три ограниченных эхо‑запроса по WebSocket и берёт медиану. Результаты передаются в управляющий контур вместе с heartbeat устройства.

При создании сессии мы складываем свежий RTT от первого участника до узла и RTT от второго участника до того же узла. Узел с минимальной суммой получает приоритет. Если измерений нет или они устарели, используем расстояние по координатам, а затем статический приоритет. При восстановлении уже начатой сессии стараемся не менять узел без необходимости, иначе восстановление транспорта само создаёт новый источник сбоев.

Откуда берётся E2E‑ключ

Самая опасная ошибка здесь состоит в том, чтобы просто обменяться публичными X25519-ключами через сигналинг и объявить задачу решённой. Такой обмен защищает от чтения трафика ретранслятором, но не защищает от подмены ключа самим сигналингом. Посредник может заменить оба публичных ключа, построить две зашифрованные сессии и читать данные между ними.

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

Для каждой сессии обе стороны создают эфемерную пару X25519. Публичный эфемерный ключ подписывается долговременным ключом устройства. Подпись покрывает не строку Base64, а версионированный бинарный transcript:

"remotik-session-key-v1\0"
len(session_id) || session_id
transport_epoch_u64
len(sender_device_id) || sender_device_id
len(peer_device_id) || peer_device_id
len(role) || role
identity_key_version_u32
ephemeral_public_key_32

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

После проверки подписи стороны вычисляют общий секрет:

shared = X25519(local_secret, peer_public)
keys = HKDF-SHA256(
    ikm = shared,
    salt = session_id,
    info = "remotik-relay-v1",
    length = 64
)

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

Для каждой транспортной полосы из базового ключа отдельно выводится дочерний ключ через HKDF‑SHA256. В salt записана версия механизма полос, в info записано имя control, video-0 или file-7. Поэтому каждая полоса получает собственное пространство ключей и nonce. Потеря и повторное подключение одной полосы не должно затрагивать счётчики остальных.

Формат зашифрованного кадра

Внутри TCP‑потока сначала идёт длина шифротекста, затем nonce и результат ChaCha20-Poly1305:

u32 cipher_len, big endian
12 bytes nonce
ciphertext
16 bytes authentication tag внутри ciphertext

Nonce состоит из четырёх нулевых байтов и 64-битного счётчика в big endian. Счётчик начинается с нуля и увеличивается на каждой отправке. Получатель хранит максимальное принятое значение. Повторное или меньшее значение отклоняется до передачи данных прикладному коду.

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

После расшифрования находится прикладной кадр:

u8 message_type
u32 payload_len, big endian
payload

Размер payload ограничен 4 МиБ. Типы сообщений включают видео, ввод, буфер обмена, запрос ключевого кадра, курсор, список дисплеев, файловые метаданные, блоки файла, подтверждения и служебный heartbeat. Неизвестный тип можно пропустить. Это позволяет добавлять служебные сообщения без синхронного обновления всех клиентов.

SAS как дополнительная проверка

Для сеансов с одноразовым шестизначным кодом мы дополнительно вычисляем четырёхзначный SAS. В его вывод входят общий X25519-секрет, оба эфемерных публичных ключа, идентификатор сессии и код подключения. Публичные ключи предварительно сортируются лексикографически, поэтому обе стороны получают одинаковый порядок.

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

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

Эпоха транспорта и отзыв старых разрешений

Долгоживущая сессия почти неизбежно переподключается. Но простое создание новых токенов оставляет старые пригодными до окончания срока действия. Для этого у сессии есть монотонно растущая transport_epoch.

Эпоха входит в токены сигналинга и ретранслятора, в подписанный transcript E2E‑ключа и в каждое сообщение сигналинга. Перед выдачей новых параметров управляющий контур регистрирует новую эпоху в сервисах данных. Более высокая эпоха закрывает старые подключения, а сообщения и разрешения предыдущего поколения перестают приниматься.

Здесь обнаружилась классическая проблема распределённой транзакции. База данных может сохранить новую эпоху, а вызов ретранслятора завершится по таймауту. Или ретранслятор создаст слот, но HTTP‑ответ потеряется. Мы не стали изображать из нескольких HTTP‑сервисов атомарную транзакцию. Вместо этого в базе есть состояние pending_transport_epoch, а фоновый reconciler доводит операцию до нужного состояния или очищает лишние ресурсы.

Почему видео оказалось сложнее криптографии

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

На Windows основной путь захвата использует DXGI через библиотеку scrap. Для привилегированного режима и защищённого рабочего стола есть резервный GDI‑захват. На macOS приходится учитывать разрешение на запись экрана, смену дисплеев, сон и пробуждение. На Linux X11 можно захватывать напрямую, а под Wayland используется XDG Desktop Portal и PipeWire. Для удалённого ввода в Wayland применяется portal‑механизм с EIS, потому что глобальная инъекция событий там намеренно закрыта.

Во всех случаях энкодер получает BGRA, переводит изображение в I420 и кодирует VP9 через libvpx в режиме реального времени. VP8 остаётся в формате протокола для совместимости, но новый нативный путь использует VP9.

Сначала мы кодировали полный кадр. Это просто и хорошо работает на видео, но удалённый рабочий стол чаще показывает редактор, терминал или таблицу. Между двумя кадрами там меняется курсор, несколько строк текста или небольшой прямоугольник окна. Повторно кодировать 2560 на 1440 ради мигающего курсора бессмысленно.

Dirty regions и независимо декодируемые плитки

Мы делим кадр на блоки 64 на 64 пикселя и сравниваем каждый блок с предыдущим BGRA‑кадром. Изменившиеся соседние блоки объединяются в прямоугольники. Каждый такой прямоугольник кодируется как самостоятельный ключевой кадр VP9, после чего несколько плиток объединяются в один пакет RVFB.

У пакета есть номер последовательности, размер полного изображения и список плиток. У каждой плитки записаны координаты, размер и длина VP9-битстрима. Получатель декодирует плитки во временный RGBA‑буфер и накладывает их на полный кадр.

Если изменилось более 60 процентов блоков, если это первый кадр, пришёл запрос на восстановление или прошло 30 секунд с прошлого полного обновления, отправляется полный refresh. Но и он не является одним большим VP9-кадром. Изображение режется на четыре вертикальные полосы, каждая кодируется независимым ключевым кадром и может уйти по своей видеополосе.

Зачем периодический полный refresh, если TCP ничего не теряет? Потери появляются выше TCP. Кадр может быть намеренно вытеснен из очереди из‑за backpressure, декодер может отклонить повреждённый пакет, процесс показа может перезапуститься. Инкрементальные обновления опираются на общий предыдущий кадр, поэтому системе нужен механизм самовосстановления.

Параллельное видео и проблема порядка

Четыре TCP‑полосы дают пропускную способность, но порядок между ними отсутствует. Представим, что полноэкранный refresh с номером 100 разбит на четыре части. Сразу после него пользователь открыл меню, и небольшой dirty‑пакет получил номер 101. Если пакет 101 по управляющей полосе придёт раньше медленной четвёртой части кадра 100, наивный декодер сочтёт остаток refresh устаревшим. Часть экрана останется старой до следующего полного обновления.

Мы решили это барьером. После постановки всех четырёх фрагментов в видеополосы отправитель пишет в управляющую полосу VIDEO_BARRIER(100). Получатель, увидев барьер, временно складывает более новые dirty‑пакеты в ограниченную очередь. Когда все фрагменты 100 собраны и атомарно нарисованы, очередь проигрывается, причём только для номеров новее 100.

Обычные dirty‑пакеты мы принципиально не раскладываем по нескольким полосам. Они остаются целыми и упорядоченными в control. Если потерять половину инкрементального набора после того, как хост уже принял новый кадр за базовый, некоторые области больше не будут считаться изменёнными. Полный refresh можно безопасно распараллелить именно потому, что его части независимо восстанавливают весь экран.

Сборка фрагментов и очередь за барьером ограничены по памяти и времени. Незавершённый refresh не ждёт бесконечно. По таймауту клиент запрашивает новый ключевой кадр.

Backpressure важнее номинального FPS

У удалённого рабочего стола нет смысла честно доставлять каждый кадр, если сеть уже отстаёт на несколько секунд. Поэтому видеополосы работают по принципу «последний кадр важнее». Отправка не должна бесконечно блокировать поток захвата. Если очередь занята, старое ещё не отправленное обновление заменяется новым, а следующий кадр принудительно становится восстановительным, когда это необходимо для целостности базы.

Размер системных TCP‑буферов для интерактивных полос ограничен 256 КиБ, включён TCP_NODELAY. Большой буфер выглядит полезным в тесте пропускной способности, но в интерактивной сессии он способен спрятать несколько секунд устаревших кадров от нашей логики backpressure.

Частота захвата адаптируется ступенчато: 30, 20, 15, 10 и 5 кадров в секунду. Каждый вытесненный кадр немедленно опускает частоту на одну ступень. После 20 успешных отправок подряд частота поднимается на ступень. Мы предпочли снижать плавность, а не резко ухудшать качество текста.

Есть и отдельный idle‑режим. Если видимые пиксели не менялись три секунды, захват снижается до 2 кадров в секунду. Событие ввода или буфера обмена будит цикл немедленно через Condvar, поэтому пользователь не ждёт следующего полусекундного тика.

Для маленьких плиток пришлось отдельно настраивать битрейт. Если рассчитать CBR только по площади 64 на 64, libvpx получает настолько маленький бюджет, что текст превращается в крупные цветные блоки. Поэтому бюджет плитки имеет нижнюю границу 512 Кбит/с и не может быть меньше четверти бюджета полного кадра. Это один из тех параметров, которые трудно вывести исключительно из формул. Его приходится смотреть на реальных шрифтах и интерфейсах.

Ввод, курсор и координаты

События ввода передаются как небольшие JSON‑объекты внутри зашифрованного бинарного кадра. Для мыши координаты нормализованы в диапазон от 0 до 1 относительно текущего закодированного изображения. Хост переводит их в координаты выбранного монитора. Это избавляет протокол от зависимости от DPI и физического разрешения окна просмотра.

Для клавиатуры передаются key и аппаратоподобный code. На принимающей стороне есть платформенное сопоставление клавиш и отдельный учёт модификаторов. Без него потерянный keyup легко оставляет удалённый компьютер с логически зажатым Ctrl или Shift.

Курсор передаётся отдельно от видео. Его положение и форма опрашиваются примерно раз в 16 мс. Вшивать курсор в каждый кадр проще, но тогда движение мыши требует постоянного кодирования областей экрана, а локальный курсор ощущается вязким.

На macOS есть два пути инъекции: обычная библиотека ввода и прямой CoreGraphics fallback. На Wayland ввод проходит через разрешённый portal‑сеанс. Ошибка прав не должна выглядеть как успешно отправленное событие, поэтому хост возвращает служебный статус, который новый клиент умеет показать пользователю, а старый безопасно игнорирует.

Передача файлов без блокировки экрана

Файл режется на блоки по 64 КиБ. Метаданные, согласие на приём, cumulative ACK, завершение и отмена идут через управляющую полосу. Сами блоки распределяются round robin по восьми файловым полосам.

Получатель может увидеть блоки не по порядку, поэтому временно хранит их в BTreeMap по смещению и записывает на диск только непрерывный префикс. Число ожидающих блоков ограничено. Подтверждение отправляется после продвижения примерно на 1 МиБ или через 50 мс, чтобы не создавать ACK на каждый блок.

Если файловая полоса переподключилась и непонятно, какие данные успели пройти, отправитель откатывается к последнему подтверждённому смещению и повторяет неподтверждённый хвост. Получатель принимает дубликаты по смещению. Это проще, чем пытаться точно восстановить состояние нескольких TCP‑соединений после обрыва.

Файл сначала пишется во временный путь. Финальное имя появляется только после полного получения. Иначе пользователь может принять недокачанный файл за готовый.

Heartbeat и частично мёртвые соединения

Запись в TCP‑сокет может долго выглядеть успешной после того, как обратный маршрут уже исчез. Поэтому одной проверки ошибки write недостаточно.

На управляющей и видеополосах раз в пять секунд отправляется зашифрованный heartbeat. Если обновлённый клиент уже продемонстрировал поддержку heartbeat и от него ничего не приходит 16 секунд, полоса считается мёртвой. Для видео восстанавливается только конкретное соединение. Для control запускается восстановление всей транспортной сессии.

Отдельный heartbeat есть у сигналинга: ping каждые 10 секунд и таймаут получения 31 секунда. Эти числа не магические. Они дают пережить краткий сетевой провал, но обнаруживают типичное закрытие NAT или прокси намного раньше системного TCP timeout.

Совместимость здесь тоже потребовала осторожности. Старый клиент умеет игнорировать неизвестный тип heartbeat, но сам его не отправляет. Если безусловно включить receive watchdog, новая версия начнёт закрывать вполне живые соединения со старой через 16 секунд. Поэтому таймаут чтения активируется только после того, как peer хотя бы один раз прислал heartbeat.

Экран входа и смена процесса

Удалённый доступ до входа пользователя ломает удобную модель «один процесс владеет сессией». На Windows обычное приложение и служба находятся в разных пользовательских сессиях, а защищённый рабочий стол требует привилегированного захвата и ввода. На macOS экран входа и пользовательская графическая сессия тоже требуют передачи владения между процессами.

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

Новый хост ждёт специальный подписанный ACK, в котором связаны оба эфемерных ключа. Только после подтверждения он имеет право занять транспорт. Это барьер между «ключ принят» и «процесс действительно может публиковать кадры». Если переключение сорвалось после rekey, предусмотрено ещё одно поколение ключа, а не откат к уже выведенному из обращения секрету.

Этот код заметно сложнее обычного reconnect. Зато без явного протокола handoff появляются редкие ошибки, при которых экран входа работает, пользователь вводит пароль, а после появления рабочего стола соединение навсегда остаётся на последнем кадре.

Что мы считаем российским решением

У криптографического алгоритма нет гражданства. X25519 и ChaCha20-Poly1305 не становятся российскими от места сборки бинарника. Для нас «российский» в этой задаче означает другое: мы контролируем исходный код клиента и серверных компонентов, можем разворачивать управляющий контур и ретрансляторы в нужной юрисдикции, не зависим от чужого облачного data plane и понимаем, какие данные проходят через инфраструктуру.

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

Есть ещё операционная сторона. Нельзя писать в логи токены подключения, пароли, публичные эфемерные ключи вместе с подписями и тем более производные ключи сессии. Для диагностики нужны этапы соединения, коды закрытия, выбранный режим, RTT, счётчики отброшенных кадров, ошибок декодирования и отказов replay. Чем лучше структурирована такая телеметрия, тем меньше соблазн однажды включить дамп всего сетевого сообщения в production.

Что получилось неидеально

Самое заметное ограничение уже упоминалось: нативное прямое соединение сейчас ориентировано на LAN. Полноценный NAT traversal через интернет потребует аккуратной работы с кандидатами, одновременным открытием соединений, IPv6 и политиками корпоративных сетей. Подменить эту работу словом «P2P» было бы нечестно.

Dirty detection пока сравнивает текущий кадр с полным предыдущим BGRA‑буфером. На больших разрешениях это расходует память и пропускную способность памяти, даже когда по сети уходит маленькая плитка. Платформенные API повреждённых областей могли бы сократить работу, но дают разные гарантии и снова разводят реализации по операционным системам.

VP9 в программном libvpx даёт хорошую совместимость и качество текста, но нагружает CPU. Аппаратные энкодеры выглядят следующим логичным шагом, однако добиться одинакового поведения, ключевых кадров и качества мелких плиток на разных GPU сложнее, чем просто заменить одну функцию кодирования.

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

Наконец, короткий SAS полезен только тогда, когда человек действительно сравнил цифры. Хороший интерфейс может подтолкнуть к проверке, но не может гарантировать внимательность пользователя.

Какие выводы мы из этого вынесли

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

Второй вывод: для интерактивного видео свежесть важнее полноты. Большие очереди и попытка доставить каждый кадр создают красивый график «без потерь», но плохой удалённый рабочий стол.

Третий вывод: параллелизм без протокола порядка переносит проблему, а не решает её. Четыре TCP‑потока ускорили полный refresh только после появления фрагментов, sequence и барьера.

Четвёртый вывод: reconnect нужно проектировать вместе с первой версией протокола. Эпохи, отзыв токенов, повторная выработка ключей, независимое восстановление полос и передача сессии между процессами невозможно надёжно добавить одним обработчиком onclose.

Пятый вывод: самые полезные тесты удалённого доступа проверяют не happy path. Нужны тесты устаревшей эпохи, повторного nonce, неверной роли в подписи, потери одной видеополосы, прихода dirty‑пакета раньше полного refresh, обрыва файла после неоднозначной записи, сна и пробуждения, смены монитора и перехода с экрана входа в пользовательскую сессию.

Проект всё ещё развивается, и часть архитектуры наверняка изменится. Но базовая граница останется прежней: серверы помогают двум устройствам встретиться и при необходимости переносят непрозрачные байты, а содержимое удалённой сессии должно оставаться между конечными устройствами. Всё остальное, от VP9-плиток до тринадцати TCP‑потоков, выросло из попытки честно выдержать эту границу в реальной сети и на трёх очень разных настольных системах.