Compare commits

...

23 Commits

Author SHA1 Message Date
nineap b9d41e670a fr-1: порт 443, декой под Cloudflare (было 8443/debian.org)
Build & Push Proxy Image / build (push) Successful in 1m0s
Сервер также вычищен от старого лендинга/CMS/БД (переехали на новый прод
ранее) и освобождён от system nginx, который раньше занимал порт 443 и
не давал ноде подняться. Нода зарегистрирована в vpn_nodes (status=online),
проверена вживую: TLS-хендшейк с SNI=decoy_host отдаёт настоящий сертификат
www.cloudflare.com.
2026-07-11 04:28:12 +07:00
nineap d1bb4661d1 Fix: образ netrunner-proxy падал в рестарт-луп — GLIBC_2.38 not found
Build & Push Proxy Image / build (push) Successful in 4m51s
Dockerfile просто копировал бинарник, собранный ОТДЕЛЬНЫМ шагом прямо на
CI-раннере (ubuntu-24.04, glibc 2.39), в рантайм-образ debian:bookworm-slim
(glibc 2.36) — любой запуск падал: "GLIBC_2.38 not found". Затронуто
абсолютно любое развёртывание этого образа, не конкретная нода.

Теперь Dockerfile компилирует Rust ВНУТРИ себя, на том же bookworm, что и
рантайм (multi-stage + cargo-chef, тот же паттерн, что в
netrunner-backend/Dockerfile) — версии glibc гарантированно совпадают
независимо от ОС раннера. build.yml больше не собирает бинарник отдельным
шагом, только docker build; добавлен registry-кэш слоёв (type=gha требует
GitHub Actions Cache, у self-hosted act_runner его нет — см. аналогичный
фикс в netrunner-backend этой сессии).

Проверено локально: docker build проходит чисто, ./netrunner-proxy --help
запускается без ошибок glibc (до фикса — падал сразу).
2026-07-11 04:15:16 +07:00
nineap 4767730119 Переименовать GITEA_REGISTRY_TOKEN → GT_REGISTRY_TOKEN (правила именования Gitea не пускают префикс GITEA_)
Build & Push Proxy Image / build (push) Successful in 13m7s
2026-07-10 21:28:25 +07:00
nineap 521d77f0cc Переезд с ghcr.io на встроенный Container Registry Gitea
Build & Push Proxy Image / build (push) Failing after 7m45s
Каждая нода теперь логинится и тянет образ с gitea.netrunner-vpn.com
напрямую (secrets.GITEA_REGISTRY_TOKEN, тот же, что и в build.yml).
2026-07-10 20:44:14 +07:00
nineap 87f9f865ae Fix: smoltcp-зависимость через https, не ssh — CI не может её склонировать
CI-раннер не имеет SSH-ключа для github.com (и не должен — там нет ничего
кроме публичного форка smoltcp). Репозиторий nxrp-smoltcp публичный,
https-клон анонимный и не требует вообще никаких секретов, в отличие от
ssh://, который требует ключ даже для публичных репо.
2026-07-10 20:13:16 +07:00
nineap bf5b914ddc nodes list
Build & Push Proxy Image / build (push) Failing after 5m0s
2026-07-10 20:00:23 +07:00
nineap 84d53c801b nodes list 2026-07-10 20:00:16 +07:00
nineap cfcb1afcec Метрики /metrics для центрального Prometheus + генерация scrape-таргетов
--metrics-port (0.0.0.0, в отличие от --health-port — тот 127.0.0.1-only)
отдаёт netrunner_connections_active/total, netrunner_auth_validate_duration_seconds,
netrunner_auth_failures_total, netrunner_circuit_breaker_open. Тот же
hand-rolled HTTP-паттерн, что и health.rs — без HTTP-фреймворка ради
одного эндпоинта.

scripts/generate-prometheus-targets.mjs генерит список scrape-таргетов
из nodes.json — job update-observability-targets в deploy.yml гоняет
его при каждом деплое нод и заливает на VPS с данными
(netrunner-data/observability/targets/nodes.json), чтобы центральный
Prometheus не терял из виду ноды без ручной правки конфига.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-09 23:21:26 +07:00
nineap fafd573a28 CI: BACKEND_URL переносит из secrets в vars
Публичный URL control plane сам по себе не секрет.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-09 16:37:25 +07:00
nineap c4be0174bf Circuit breaker + таймаут для BackendClient
5 подряд неудач (5xx/сетевой сбой, не 4xx-отказ авторизации) открывают
цепь на 10 секунд — fail-fast без похода в HTTP, пока control plane
недоступен. Таймаут запроса — 5 секунд (раньше не было вообще).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-09 16:37:25 +07:00
nineap 10d357b8ec Graceful shutdown (SIGTERM) + HTTP /health endpoint
Раньше shutdown_signal вообще не существовал — весь механизм graceful
shutdown в network.rs был мёртвым кодом, ничего его не вызывало. main.rs
теперь spawn'ит net.run() и последовательно ждёт сигнал, затем drain
(НЕ tokio::select! — select! убил бы run()-future на середине drain'а в
момент, когда shutdown-future резолвится). /health на 127.0.0.1 отдаёт
активные соединения — для systemd/LB health-check.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-09 16:37:25 +07:00
nineap d8363667e0 Добавляет .env.example для Makefile-конфига (был только .env, без шаблона)
Локальный dev/деплой-конфиг (rsync/ssh на прод/dev-ноду, ADB, --require-auth
debug-server) существовал только как реальный .env — новому контрибьютору
неоткуда было узнать, какие переменные вообще нужны и в каком формате.
2026-07-09 15:33:41 +07:00
nineap c835ac983a Gitea Actions: сборка образа + деплой на список прокси-нод
- .gitea/workflows/build.yml — cargo build --release + docker build/push в
  ghcr.io/nineap/netrunner-proxy:latest (Dockerfile сам не компилирует Rust,
  ждёт уже готовый target/release/netrunner-server).
- .gitea/workflows/deploy.yml — только вручную (workflow_dispatch), параметр
  target: имя ноды из nodes.json или "all". Job "resolve" фильтрует список,
  job "deploy" — matrix по результату, каждая нода видна в прогоне отдельной
  строкой. Деплой пересоздаёт контейнер (pull+rm+run), не просто restart —
  иначе новый образ не подхватится.
- nodes.json — список нод (name/host/proxy_port/decoy_host), шаблон с
  CHANGE_ME — заполнить реальными IP.

Нужные секреты: GHCR_TOKEN (тот же, что и в остальных репо), PROXY_NODES_SSH_KEY
(общий master-ключ на все ноды), PROXY_INTERNAL_SECRET, BACKEND_URL (те же
значения, что уже настроены у бэкенда).
2026-07-09 15:27:18 +07:00
nineap e44bc4f660 Исправить деплой dev systemd-юнита прокси: rsync грузил файл под неверным именем
server/netrunner-server.dev.service копировался на удалённую машину без
переименования — оседал как netrunner-server.dev.service рядом со старым
netrunner-server.service, а systemctl start netrunner-server продолжал
использовать именно старый (неизменный) юнит. Из-за этого только что
задеплоенные --port=8443 не подхватывались вообще, пока не переименовал файл
руками на сервере.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-07 18:52:10 +07:00
nineap 72246f986a Переключить дев-инстанс прокси на порт 8443
Локальный debug-server и задеплоенный на DEV_IP systemd-сервис раньше
использовали 4443/443 соответственно — развели их на общий 8443, чтобы не
пересекаться с портами, занятыми другими дев-сервисами (например, Caddy на 443).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-07 18:17:20 +07:00
nineap 40ddb0a0f6 Добавить опциональную авторизацию клиента и динамические лимиты трафика
Сервер получает флаги --require-auth/--backend-url (по умолчанию выключено,
поведение уже развёрнутых нод не меняется). Auth-кадр хендшейка расширен с
"session_id:leg_id" до "session_id:leg_id:token"; при включённом
--require-auth сервер валидирует токен через новый AuthValidator
(BackendClient к netrunner-backend, с кешем на 60с).

Muxer теперь ведёт монотонный учёт трафика сессии (переживает реконнект
ног — раньше total_bytes мог занижаться после переподключения) и раз в ~30с
отчитывается бэкенду; при превышении лимита сессия разрывается. Токен
клиента прокидывается через EngineConfig/TunnelConfig до Tauri-плагина
(desktop.rs + Android Kotlin-плагин).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-07 00:31:03 +07:00
nineap 99a9eee908 Улучшить TLS-маскировку, ввести версионирование протокола, добавить тесты и нагрузочный инструмент
Маскировка:
- ChangeCipherSpec middlebox-compat запись после ClientHello/ServerHello —
  без неё последовательность типов TLS-записей отличала нас от настоящего
  браузера даже при идеальном JA3/JA4-отпечатке.
- SNI-aware stealth fallback: невалидный клиент проксируется на запрошенный
  им хост, а не всегда на один и тот же decoy — иначе активное зондирование
  с разными SNI на одном IP получало одинаковый ответ. Резолв только в
  публичные IP (защита от SSRF на внутреннюю сеть ноды через подставной SNI).
- Профиль браузера привязан к session_id, а не к номеру попытки реконнекта:
  раньше при нескольких быстрых ретраях с одного IP летели ClientHello
  Chrome→Edge→Firefox подряд — сама по себе аномалия для DPI.
- Бакетный паддинг Data/UdpData кадров вместо полного отсутствия паддинга;
  джиттер перед ServerHello вместо мгновенного детерминированного ответа.

Версионирование:
- PROTOCOL_VERSION в session_id ClientHello — сервер узнаёт версию клиента
  до отправки чего-либо и может включать версионно-зависимое поведение
  (сейчас — CCS) только для тех, кто его понимает. Позволяет катить будущие
  несовместимые изменения протокола без синхронного flag-day релиза.

Тесты (core, было 0):
- Round-trip тесты TLS-записей/hello-сообщений/кадров/кодека.
- Полный цикл хендшейка с реальным выводом ключей на обеих сторонах.
- Вся SSRF-защита (hostname/IP валидация, v4/v6).
- Интеграционные тесты по настоящему TCP: легитимный хендшейк и
  stealth-fallback на мусорный ClientHello.

tools/loadtest — нагрузочный тест поверх настоящего скомпилированного
netrunner-server (реальный процесс, метрики RSS/CPU из /proc) и настоящего
публичного клиентского API. Попутно найден и обойдён гоночный баг:
Connect-кадр в ClientHandler::connect() уходит через try_send ещё до того,
как поднимется хоть одна нога тунеля, и молча теряется при синтетической
нагрузке.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-06 18:05:10 +07:00
nineap 8d9dfeee41 decoy host into lib 2026-07-02 18:43:26 +07:00
nineap 820d23abd9 update of extensions and connections 2026-07-02 16:50:23 +07:00
nineap 8c9a9c7cc7 dev countour and off logs 2026-07-02 15:15:10 +07:00
Kirill 3b2296a6d9 fix: stabilize tunnel downloads (deadlock, stuck-consumer eviction, real diagnostics)
Downloads were dying and stalling under real network conditions. Root causes
fixed in the muxer's stream-delivery path:

- dispatch_to_local held a DashMap Ref across a nested remove_stream call,
  self-deadlocking the leg's reader task whenever a stream's backlog needed
  eviction — permanently losing a tokio worker thread per occurrence.
- Eviction now runs off the hot path entirely, in a periodic background
  reaper, and only fires when a stream is both over its byte budget AND has
  made no delivery progress for an RTT-adaptive grace window (was a flat
  300ms then 5s, which killed merely-slow-but-alive consumers).
- The server never told the client when a bridge ended normally (only on
  failed connect), leaving the client's virtual TCP socket stuck in
  CloseWait for a full idle timeout and slowing the whole engine loop.
- The client's upload path silently dropped in-flight chunks when all tunnel
  legs were briefly down instead of pausing and retrying like the server
  bridge already did.
- Tried end-to-end credit-based flow control for the download producer;
  reverted the enforcement (kept the dormant frame/API) after it proved
  worse than the pre-existing local channel backpressure — RTT-coupled
  pauses produced burst-then-stall downloads and jitter/ping spikes on the
  shared physical leg.
- Server-side diagnostics snapshot was a stub (always-empty tunnel state,
  misleading placeholder trigger); now aggregates real per-session Muxer
  metrics from the SessionManager.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-02 15:06:00 +07:00
nineap 582ad714ae client log sends to proxy 2026-06-30 19:49:50 +07:00
nineap f7a76353ea docs [AI] 2026-06-30 18:51:09 +07:00
89 changed files with 6369 additions and 1681 deletions
+5 -1
View File
@@ -85,7 +85,11 @@
"Bash(wsl -d ubuntu-22.04 -- bash -c \"cd /home/kirill/netrunner-proxy && ~/.cargo/bin/cargo check --workspace --exclude netrunner-client 2>&1 | grep -E '^error'\")", "Bash(wsl -d ubuntu-22.04 -- bash -c \"cd /home/kirill/netrunner-proxy && ~/.cargo/bin/cargo check --workspace --exclude netrunner-client 2>&1 | grep -E '^error'\")",
"Bash(git config *)", "Bash(git config *)",
"Bash(git add *)", "Bash(git add *)",
"Bash(git commit -m 'refactor: improve networking layer with enhanced engine, muxer, and diagnostics *)" "Bash(git commit -m 'refactor: improve networking layer with enhanced engine, muxer, and diagnostics *)",
"Bash(cd \"\\\\\\\\wsl.localhost\\\\ubuntu-22.04\\\\home\\\\kirill\\\\netrunner-proxy\")",
"Bash(cargo build *)",
"Bash(cargo --version)",
"Bash(rustc --version)"
] ]
} }
} }
+24
View File
@@ -0,0 +1,24 @@
# Локальный dev/деплой-конфиг для Makefile (rsync/ssh-деплой на прод/dev-ноду,
# ADB для тестов на Android, --require-auth для debug-server). Скопировать в
# .env и заполнить реальными значениями — .env уже в .gitignore, коммитить
# нельзя.
# --- Прод-нода (make deploy / make restart и т.п.) ---
SERVER_IP=CHANGE_ME_PROD_NODE_IP
REMOTE_USER=root
REMOTE_PATH=/root/netr-core
SERVICE_NAME=netrunner-server
# --- Dev-нода (make deploy-dev и т.п.) ---
DEV_IP=CHANGE_ME_DEV_NODE_IP
# --- Android (ADB по Wi-Fi + сборка нативной либы для vpn-plugin) ---
ANDROID_ADB_HOST=CHANGE_ME_PHONE_LOCAL_IP
ANDROID_BUILD_SRC=/home/CHANGE_ME/netrunner/netrunner-proxy/gen
ANDROID_PROJECT_LIBS=/home/CHANGE_ME/netrunner/netrunner-app/src-tauri/src/plugins/vpn-plugin/android/src/main/jniLibs
# --- Дев-авторизация debug-server (make debug-server и т.п.) ---
# Тот же секрет, что и PROXY_INTERNAL_SECRET у netrunner-backend (.env.dev) —
# без совпадения бэкенд отклонит запросы валидации токена/отчёта о трафике.
PROXY_INTERNAL_SECRET=CHANGE_ME_SAME_AS_BACKEND_PROXY_INTERNAL_SECRET
DEV_BACKEND_URL=http://localhost:8080
+48
View File
@@ -0,0 +1,48 @@
# Собирает netrunner-server (release-бинарник) и пушит Docker-образ во
# встроенный Container Registry самой Gitea (gitea.netrunner-vpn.com), не
# ghcr.io. Dockerfile теперь компилирует Rust ВНУТРИ себя (multi-stage,
# см. Dockerfile) — раньше бинарник собирался отдельным шагом прямо на
# CI-раннере (glibc раннера) и просто копировался в рантайм-образ
# (glibc bookworm-slim), рассинхрон версий ронял прод: "GLIBC_2.38 not
# found". Раздельная сборка на раннере убрана — только docker build.
#
# Нужен секрет GT_REGISTRY_TOKEN — токен Gitea с правом write:package,
# тот же, что и в netrunner-backend/netrunner-landing (пакеты в Gitea
# принадлежат аккаунту, не репозиторию, но значение нужно продублировать в
# Settings каждого из репозиториев).
name: Build & Push Proxy Image
on:
push:
branches: [main]
tags: ["v*"]
workflow_dispatch:
jobs:
build:
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to Gitea Registry
uses: docker/login-action@v3
with:
registry: gitea.netrunner-vpn.com
username: ${{ github.actor }}
password: ${{ secrets.GT_REGISTRY_TOKEN }}
- name: Build and push
uses: docker/build-push-action@v6
with:
context: .
file: ./Dockerfile
push: true
tags: gitea.netrunner-vpn.com/nineap/netrunner-proxy:latest
# type=gha требует GitHub Actions Cache — self-hosted act_runner его
# не реализует (см. аналогичный фикс в netrunner-backend/build.yml
# этой же сессии); type=registry работает везде без спецсервиса.
cache-from: type=registry,ref=gitea.netrunner-vpn.com/nineap/netrunner-proxy:cache
cache-to: type=registry,ref=gitea.netrunner-vpn.com/nineap/netrunner-proxy:cache,mode=max
+145
View File
@@ -0,0 +1,145 @@
# Деплой на прокси-ноды из nodes.json. Список серверов — сам файл в корне
# репозитория (name/host/proxy_port/decoy_host на каждую ноду), поддерживать
# его руками при добавлении/выводе серверов.
#
# Запуск только вручную (workflow_dispatch) — параметр target: имя ноды из
# nodes.json (поле "name") или "all" для обновления сразу всех. Job "resolve"
# фильтрует список по этому параметру и отдаёт job'у "deploy" как matrix —
# каждая нода тогда видна в прогоне отдельной строкой, с отдельным логом.
# GH/Gitea Actions не поддерживают выпадающий список (type: choice) с
# вариантами из файла — только статичные в самом yaml, а список нод меняется
# независимо от workflow, поэтому просто текстовое поле, значение — точное
# имя из nodes.json.
#
# Каждый деплой пересоздаёт контейнер (pull нового образа + rm + run) — не
# просто restart, иначе новый образ не подхватится, раз контейнер уже создан
# со старым слоем.
#
# Секреты (общие на все ноды — один master-ключ, как и при первичном
# провижининге через netrunner-backend, см. NodeService::provision_node_via_ssh):
# - PROXY_NODES_SSH_KEY — приватный SSH-ключ (root на все ноды)
# - PROXY_INTERNAL_SECRET — тот же секрет, что и PROXY_INTERNAL_SECRET у бэкенда
# - GT_REGISTRY_TOKEN — тот же, что и в build.yml (Container Registry Gitea,
# не ghcr.io — каждая нода тянет образ напрямую с gitea.netrunner-vpn.com)
#
# Repo Variable (vars.*, не secret — публичный URL, не даёт доступа сам по себе):
# - BACKEND_URL — публичный адрес control plane (WEB_APP_BASE_URL у бэкенда)
#
# Job update-observability-targets (см. ниже) обновляет список scrape-таргетов
# для центрального Prometheus (netrunner-data/docker-compose.observability.yml,
# job "netrunner-proxy-nodes") при КАЖДОМ прогоне (независимо от input target,
# т.к. использует полный nodes.json) — ему нужны СВОИ копии секретов/vars
# VPS с данными, продублировать в Settings этого репозитория (те же значения,
# что уже есть в netrunner-data):
# - vars.DATA_VPS_IP
# - secrets.DATA_SSH_PRIVATE_KEY
name: Deploy Proxy Nodes
on:
workflow_dispatch:
inputs:
target:
description: 'Имя ноды из nodes.json ("name"), или "all" — обновить сразу все'
required: true
default: "all"
jobs:
resolve:
runs-on: ubuntu-24.04
outputs:
targets: ${{ steps.filter.outputs.targets }}
steps:
- uses: actions/checkout@v4
- name: Filter nodes.json by input
id: filter
run: |
set -e
command -v jq >/dev/null || (apt-get update && apt-get install -y jq)
target="${{ github.event.inputs.target }}"
if [ "$target" = "all" ]; then
filtered=$(jq -c '.' nodes.json)
else
filtered=$(jq -c --arg n "$target" '[.[] | select(.name == $n)]' nodes.json)
fi
count=$(echo "$filtered" | jq 'length')
if [ "$count" -eq 0 ]; then
echo "Нода '$target' не найдена в nodes.json" >&2
exit 1
fi
echo "targets=$filtered" >> "$GITHUB_OUTPUT"
deploy:
needs: resolve
strategy:
fail-fast: false
matrix:
include: ${{ fromJson(needs.resolve.outputs.targets) }}
runs-on: ubuntu-24.04
steps:
- name: Deploy to ${{ matrix.name }} (${{ matrix.host }})
uses: appleboy/ssh-action@v1
with:
host: ${{ matrix.host }}
username: root
key: ${{ secrets.PROXY_NODES_SSH_KEY }}
script: |
set -e
echo "🚀 Деплой на ${{ matrix.name }} (${{ matrix.host }}:${{ matrix.proxy_port }})"
echo "${{ secrets.GT_REGISTRY_TOKEN }}" | docker login gitea.netrunner-vpn.com -u ${{ github.actor }} --password-stdin
docker pull gitea.netrunner-vpn.com/nineap/netrunner-proxy:latest
docker stop netrunner-proxy 2>/dev/null || true
docker rm netrunner-proxy 2>/dev/null || true
docker run -d \
--name netrunner-proxy \
--restart always \
--network host \
--cap-add NET_ADMIN --cap-add NET_RAW --cap-add DAC_OVERRIDE \
--device /dev/net/tun:/dev/net/tun \
-e PROXY_INTERNAL_SECRET="${{ secrets.PROXY_INTERNAL_SECRET }}" \
gitea.netrunner-vpn.com/nineap/netrunner-proxy:latest \
./netrunner-proxy \
--port "${{ matrix.proxy_port }}" \
--decoy-host "${{ matrix.decoy_host }}" \
--require-auth \
--backend-url "${{ vars.BACKEND_URL }}" \
--health-port 9091 \
--metrics-port 9093
docker image prune -f
echo "✅ ${{ matrix.name }} обновлена."
# Независимо от того, какая конкретно нода деплоилась (target может быть
# одной нодой, не "all") — список scrape-таргетов всегда генерится из ПОЛНОГО
# nodes.json, чтобы центральный Prometheus не терял из виду остальные ноды.
update-observability-targets:
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v4
- name: Generate targets/nodes.json from nodes.json
run: node scripts/generate-prometheus-targets.mjs nodes.json > nodes.targets.json
- name: Upload to observability VPS
uses: appleboy/scp-action@v0.1.7
with:
host: ${{ vars.DATA_VPS_IP }}
username: root
key: ${{ secrets.DATA_SSH_PRIVATE_KEY }}
source: "nodes.targets.json"
target: "/root/netrunner-data/observability/targets"
strip_components: 0
- name: Rename to nodes.json (file_sd_configs ждёт именно это имя)
uses: appleboy/ssh-action@v1
with:
host: ${{ vars.DATA_VPS_IP }}
username: root
key: ${{ secrets.DATA_SSH_PRIVATE_KEY }}
script: mv /root/netrunner-data/observability/targets/nodes.targets.json /root/netrunner-data/observability/targets/nodes.json
+3
View File
@@ -4,3 +4,6 @@ hosts_cache.txt
/gen /gen
.env .env
repomix-output.xml repomix-output.xml
/logs
netrunner_diagnostics.jsonl
netrunner_client_diag_*.jsonl
Generated
+203 -1
View File
@@ -572,6 +572,15 @@ dependencies = [
"crossbeam-utils", "crossbeam-utils",
] ]
[[package]]
name = "crossbeam-epoch"
version = "0.9.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2d6914041f254d6e9176c01941b21115dcfb7089e55135a35411081bd106ef3f"
dependencies = [
"crossbeam-utils",
]
[[package]] [[package]]
name = "crossbeam-utils" name = "crossbeam-utils"
version = "0.8.21" version = "0.8.21"
@@ -771,6 +780,17 @@ dependencies = [
"pin-project-lite", "pin-project-lite",
] ]
[[package]]
name = "evmap"
version = "11.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1b8874945f036109c72242964c1174cf99434e30cfa45bf45fedc983f50046f8"
dependencies = [
"hashbag",
"left-right",
"smallvec",
]
[[package]] [[package]]
name = "fastrand" name = "fastrand"
version = "2.4.1" version = "2.4.1"
@@ -929,6 +949,21 @@ dependencies = [
"slab", "slab",
] ]
[[package]]
name = "generator"
version = "0.8.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b3b854b0e584ead1a33f18b2fcad7cf7be18b3875c78816b753639aa501513ae"
dependencies = [
"cc",
"cfg-if",
"libc",
"log",
"rustversion",
"windows-link",
"windows-result",
]
[[package]] [[package]]
name = "generic-array" name = "generic-array"
version = "0.14.7" version = "0.14.7"
@@ -1025,6 +1060,12 @@ dependencies = [
"byteorder", "byteorder",
] ]
[[package]]
name = "hashbag"
version = "0.1.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7040a10f52cba493ddb09926e15d10a9d8a28043708a405931fe4c6f19fac064"
[[package]] [[package]]
name = "hashbrown" name = "hashbrown"
version = "0.14.5" version = "0.14.5"
@@ -1459,6 +1500,17 @@ version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2"
[[package]]
name = "left-right"
version = "0.11.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0f0c21e4c8ff95f487fb34e6f9182875f42c84cef966d29216bf115d9bba835a"
dependencies = [
"crossbeam-utils",
"loom",
"slab",
]
[[package]] [[package]]
name = "libc" name = "libc"
version = "0.2.184" version = "0.2.184"
@@ -1512,6 +1564,19 @@ version = "0.4.29"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"
[[package]]
name = "loom"
version = "0.7.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "419e0dc8046cb947daa77eb95ae174acfbddb7673b4151f56d1eed8e93fbfaca"
dependencies = [
"cfg-if",
"generator",
"scoped-tls",
"tracing",
"tracing-subscriber",
]
[[package]] [[package]]
name = "lru" name = "lru"
version = "0.16.3" version = "0.16.3"
@@ -1548,6 +1613,48 @@ version = "2.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79"
[[package]]
name = "metrics"
version = "0.24.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "89550ee9f79e88fef3119de263694973a8adb26c21d75322164fb8c493039fe2"
dependencies = [
"portable-atomic",
"rapidhash",
]
[[package]]
name = "metrics-exporter-prometheus"
version = "0.18.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1db0d8f1fc9e62caebd0319e11eaec5822b0186c171568f0480b46a0137f9108"
dependencies = [
"base64",
"evmap",
"indexmap",
"metrics",
"metrics-util",
"quanta",
"thiserror 2.0.18",
]
[[package]]
name = "metrics-util"
version = "0.20.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "96f8722f8562635f92f8ed992f26df0532266eb03d5202607c20c0d7e9745e13"
dependencies = [
"crossbeam-epoch",
"crossbeam-utils",
"hashbrown 0.16.1",
"metrics",
"quanta",
"rand 0.9.2",
"rand_xoshiro",
"rapidhash",
"sketches-ddsketch",
]
[[package]] [[package]]
name = "mime" name = "mime"
version = "0.3.17" version = "0.3.17"
@@ -1622,6 +1729,19 @@ dependencies = [
"x25519-dalek", "x25519-dalek",
] ]
[[package]]
name = "netrunner-loadtest"
version = "0.1.0"
dependencies = [
"bytes",
"clap",
"libc",
"netrunner-core",
"serde",
"serde_json",
"tokio",
]
[[package]] [[package]]
name = "netrunner-logger" name = "netrunner-logger"
version = "0.1.0" version = "0.1.0"
@@ -1638,9 +1758,15 @@ dependencies = [
name = "netrunner-server" name = "netrunner-server"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"async-trait",
"clap", "clap",
"dashmap",
"metrics",
"metrics-exporter-prometheus",
"netrunner-core", "netrunner-core",
"netrunner-logger", "netrunner-logger",
"reqwest",
"serde",
"serde_json", "serde_json",
"tokio", "tokio",
"tokio-util", "tokio-util",
@@ -1852,6 +1978,21 @@ dependencies = [
"unicode-ident", "unicode-ident",
] ]
[[package]]
name = "quanta"
version = "0.12.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f3ab5a9d756f0d97bdc89019bd2e4ea098cf9cde50ee7564dde6b81ccc8f06c7"
dependencies = [
"crossbeam-utils",
"libc",
"once_cell",
"raw-cpuid",
"wasi",
"web-sys",
"winapi",
]
[[package]] [[package]]
name = "quinn" name = "quinn"
version = "0.11.9" version = "0.11.9"
@@ -1984,6 +2125,33 @@ version = "0.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0c8d0fd677905edcbeedbf2edb6494d676f0e98d54d5cf9bda0b061cb8fb8aba" checksum = "0c8d0fd677905edcbeedbf2edb6494d676f0e98d54d5cf9bda0b061cb8fb8aba"
[[package]]
name = "rand_xoshiro"
version = "0.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f703f4665700daf5512dcca5f43afa6af89f09db47fb56be587f80636bda2d41"
dependencies = [
"rand_core 0.9.5",
]
[[package]]
name = "rapidhash"
version = "4.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5da7e78a036ce858e8d55b7e7dc8ba3a88b78350fd2155d3591bbd966b58589e"
dependencies = [
"rustversion",
]
[[package]]
name = "raw-cpuid"
version = "11.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "498cd0dc59d73224351ee52a95fee0f1a617a2eae0e7d9d720cc622c73a54186"
dependencies = [
"bitflags 2.11.0",
]
[[package]] [[package]]
name = "redox_syscall" name = "redox_syscall"
version = "0.5.18" version = "0.5.18"
@@ -2203,6 +2371,12 @@ dependencies = [
"windows-sys 0.61.2", "windows-sys 0.61.2",
] ]
[[package]]
name = "scoped-tls"
version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e1cf6437eb19a8f4a6cc0f7dca544973b0b78843adbfeb3683d1a94a0024a294"
[[package]] [[package]]
name = "scopeguard" name = "scopeguard"
version = "1.2.0" version = "1.2.0"
@@ -2356,6 +2530,12 @@ version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b2aa850e253778c88a04c3d7323b043aeda9d3e30d5971937c1855769763678e" checksum = "b2aa850e253778c88a04c3d7323b043aeda9d3e30d5971937c1855769763678e"
[[package]]
name = "sketches-ddsketch"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0c6f73aeb92d671e0cc4dca167e59b2deb6387c375391bc99ee743f326994a2b"
[[package]] [[package]]
name = "slab" name = "slab"
version = "0.4.12" version = "0.4.12"
@@ -2377,7 +2557,7 @@ checksum = "b7c388c1b5e93756d0c740965c41e8822f866621d41acbdf6336a6a168f8840c"
[[package]] [[package]]
name = "smoltcp" name = "smoltcp"
version = "0.13.0" version = "0.13.0"
source = "git+ssh://git@github.com/nineAp/nxrp-smoltcp.git#e9ba23b47ab789eb480d2eef2ffc4d7cdfedb381" source = "git+https://github.com/nineAp/nxrp-smoltcp.git#c8093df6ec24f75c13bedf0e74e53cefe26997e0"
dependencies = [ dependencies = [
"bitflags 1.3.2", "bitflags 1.3.2",
"byteorder", "byteorder",
@@ -3257,6 +3437,22 @@ dependencies = [
"nom", "nom",
] ]
[[package]]
name = "winapi"
version = "0.3.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419"
dependencies = [
"winapi-i686-pc-windows-gnu",
"winapi-x86_64-pc-windows-gnu",
]
[[package]]
name = "winapi-i686-pc-windows-gnu"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6"
[[package]] [[package]]
name = "winapi-util" name = "winapi-util"
version = "0.1.11" version = "0.1.11"
@@ -3266,6 +3462,12 @@ dependencies = [
"windows-sys 0.61.2", "windows-sys 0.61.2",
] ]
[[package]]
name = "winapi-x86_64-pc-windows-gnu"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
[[package]] [[package]]
name = "windows-link" name = "windows-link"
version = "0.2.1" version = "0.2.1"
+2 -1
View File
@@ -4,6 +4,7 @@ members = [
"client", "client",
"server", "server",
"tools/bindgen", "tools/bindgen",
"tools/log" "tools/log",
"tools/loadtest"
] ]
resolver = "2" resolver = "2"
+25 -3
View File
@@ -1,12 +1,34 @@
# Минимальный чистый образ рантайма # syntax=docker/dockerfile:1.7
# Раньше этот Dockerfile просто копировал уже собранный на CI-раннере
# бинарник (target/release/netrunner-server) в debian:bookworm-slim — а
# раннер (ubuntu-24.04, glibc 2.39) собирал его с более новым glibc, чем
# несёт bookworm-slim (glibc 2.36). В рантайме это падало циклом рестартов:
# "GLIBC_2.38 not found". Компилируем внутри контейнера на той же базе
# (bookworm), что и рантайм — версии glibc гарантированно совпадают,
# независимо от того, какая ОС у раннера CI (см. тот же паттерн в
# netrunner-backend/Dockerfile).
FROM rust:1-bookworm AS chef
RUN cargo install cargo-chef --locked
WORKDIR /app
FROM chef AS planner
COPY . .
RUN cargo chef prepare --recipe-path recipe.json
FROM chef AS builder
COPY --from=planner /app/recipe.json recipe.json
RUN cargo chef cook --release --recipe-path recipe.json -p netrunner-server
COPY . .
RUN cargo build --release -p netrunner-server
FROM debian:bookworm-slim FROM debian:bookworm-slim
WORKDIR /app WORKDIR /app
# Ставим системные сертификаты, чтобы прокси мог работать с сетью по HTTPS # Ставим системные сертификаты, чтобы прокси мог работать с сетью по HTTPS
RUN apt-get update && apt-get install -y ca-certificates libssl3 && rm -rf /var/lib/apt/lists/* RUN apt-get update && apt-get install -y ca-certificates libssl3 && rm -rf /var/lib/apt/lists/*
# Просто копируем уже скомпилированный тобой локально бинарник из wsl-папки target COPY --from=builder /app/target/release/netrunner-server /app/netrunner-proxy
COPY target/release/netrunner-server /app/netrunner-proxy
EXPOSE 443/udp EXPOSE 443/udp
EXPOSE 443/tcp EXPOSE 443/tcp
+37 -3
View File
@@ -1,14 +1,20 @@
-include .env -include .env
# Настройки # Настройки
SERVER_IP := $(strip $(SERVER_IP)) SERVER_IP := $(strip $(SERVER_IP))
DEV_IP :=$(strip $(DEV_IP))
REMOTE_USER := $(strip $(REMOTE_USER)) REMOTE_USER := $(strip $(REMOTE_USER))
REMOTE_PATH := $(strip $(REMOTE_PATH)) REMOTE_PATH := $(strip $(REMOTE_PATH))
SERVICE_NAME := $(strip $(SERVICE_NAME)) SERVICE_NAME := $(strip $(SERVICE_NAME))
# Безопасное получение путей из ENV или дефолтов # Безопасное олучение путей из ENV или дефолтов
ANDROID_ADB_HOST := $(strip $(ANDROID_ADB_HOST)) ANDROID_ADB_HOST := $(strip $(ANDROID_ADB_HOST))
ANDROID_BUILD_SRC := $(strip $(ANDROID_BUILD_SRC)) ANDROID_BUILD_SRC := $(strip $(ANDROID_BUILD_SRC))
ANDROID_PROJECT_LIBS := $(strip $(ANDROID_PROJECT_LIBS)) ANDROID_PROJECT_LIBS := $(strip $(ANDROID_PROJECT_LIBS))
# Дев-авторизация прокси (см. netrunner-proxy/.env) — секрет общий с
# netrunner-backend/.env.dev, адрес — дев-стек backend (make dev там).
PROXY_INTERNAL_SECRET := $(strip $(PROXY_INTERNAL_SECRET))
DEV_BACKEND_URL := $(strip $(DEV_BACKEND_URL))
# Опции для стабильного SSH/Rsync в условиях плохого коннекта # Опции для стабильного SSH/Rsync в условиях плохого коннекта
# IPQoS=throughput помогает проталкивать пакеты через тайские магистрали # IPQoS=throughput помогает проталкивать пакеты через тайские магистрали
SSH_OPTS = -o IPQoS=throughput -o ServerAliveInterval=30 SSH_OPTS = -o IPQoS=throughput -o ServerAliveInterval=30
@@ -49,8 +55,10 @@ debug-client:
debug-server: debug-server:
@echo "--- Сборка сервера (Debug) ---" @echo "--- Сборка сервера (Debug) ---"
cargo build --bin netrunner-server cargo build --bin netrunner-server
@echo "--- Запуск сервера локально ---" @echo "--- Запуск сервера локально (с авторизацией против дев-бэкенда) ---"
sudo ./target/debug/netrunner-server --port=4443 --host=0.0.0.0 sudo PROXY_INTERNAL_SECRET=$(PROXY_INTERNAL_SECRET) ./target/debug/netrunner-server \
--port=8443 --host=0.0.0.0 \
--require-auth --backend-url $(DEV_BACKEND_URL)
ABIS = arm64-v8a armeabi-v7a x86_64 x86 ABIS = arm64-v8a armeabi-v7a x86_64 x86
@@ -103,6 +111,32 @@ deploy-server: build-server
systemctl start $(SERVICE_NAME)" systemctl start $(SERVICE_NAME)"
@echo "--- Деплой завершен успешно! ---" @echo "--- Деплой завершен успешно! ---"
deploy-dev: build-server
@echo "--- [1/4] Остановка сервиса и очистка зависших процессов DEV ---"
ssh $(SSH_OPTS) $(REMOTE_USER)@$(DEV_IP) "\
systemctl stop $(SERVICE_NAME) || true; \
pkill -9 $(SERVICE_NAME) || true; \
rm -f $(REMOTE_PATH)/$(SERVICE_NAME).tmp"
@echo "--- [2/4] Копирование бинарника (rsync) ---"
rsync $(RSYNC_OPTS) target/release/netrunner-server $(REMOTE_USER)@$(DEV_IP):$(REMOTE_PATH)/
@echo "--- [3/4] Обновление конфигурации systemd ---"
# Локальный файл называется *.dev.service (чтобы не путать с прод-юнитом в
# репозитории), но systemctl start $(SERVICE_NAME) ищет ровно
# "$(SERVICE_NAME).service" — без явного целевого имени rsync клал файл
# под своим исходным именем, и юнит с новым портом/конфигом никогда не
# подхватывался (systemctl тихо продолжал использовать старый файл).
rsync $(RSYNC_OPTS) server/netrunner-server.dev.service $(REMOTE_USER)@$(DEV_IP):/etc/systemd/system/$(SERVICE_NAME).service
@echo "--- [4/4] Перезапуск сервиса ---"
ssh $(SSH_OPTS) $(REMOTE_USER)@$(DEV_IP) "\
systemctl daemon-reload && \
systemctl enable $(SERVICE_NAME) && \
systemctl start $(SERVICE_NAME)"
@echo "--- Деплой завершен успешно! ---"
logs: logs:
ssh $(SSH_OPTS) $(REMOTE_USER)@$(SERVER_IP) "journalctl -u $(SERVICE_NAME) -f" ssh $(SSH_OPTS) $(REMOTE_USER)@$(SERVER_IP) "journalctl -u $(SERVICE_NAME) -f"
+15 -7
View File
@@ -14,11 +14,19 @@ Netrunner Core — это высокопроизводительный сете
## Основные компоненты ## Основные компоненты
- `core/` — общая сетевая логика, кодек кастомного протокола и реализация AEAD-шифрования. Архитектура протокола в целом — в [ARCH.md](ARCH.md). У каждого логического блока
- `tlseng/` — движок формирования TLS-отпечатков (Fingerprinting Engine) для маскировки трафика под популярные браузеры. есть свой `README.MD` с картой файлов и ментальной моделью, а детали — в
- `client/` — локальный прокси-клиент (создает TUN-интерфейс, парсит пакеты и инкапсулирует их в туннель). inline-rustdoc (`cargo doc --open`).
- `server/` — серверная часть, принимающая замаскированные TLS-соединения, маршрутизирующая кадры и выполняющая запросы во внешнюю сеть.
- `tools/`набор утилит, включая логгер и `bindgen-tool` для генерации связующего кода (Kotlin bindings) под Android. - **`core/`**ядро (платформо-независимая логика). Блоки:
- [`core/src/crypto/`](core/src/crypto/README.MD) — обмен ключами, HKDF, AEAD, auth-теги.
- [`core/src/nrxp/`](core/src/nrxp/README.MD) — протокол NRXP: формат кадра, кодек, TLS-мост.
- [`core/src/tlseng/`](core/src/tlseng/README.MD) — TLS-маскировка (JA3/JA4 fingerprinting).
- [`core/src/net/`](core/src/net/README.MD) — туннель, мультиплексор, движок, мосты, диагностика.
- [`core/src/rawcast/`](core/src/rawcast/README.MD) — локальный протокол сокет ⇄ кадр.
- **[`client/`](client/README.MD)** — VPN-клиент: [`net/`](client/src/net/README.MD) (userspace-стек) + [`tun/`](client/src/tun/README.MD) (TUN/маршрутизация) + FFI.
- **[`server/`](server/README.MD)** — серверная часть, принимающая замаскированные TLS-соединения и проксирующая трафик.
- **[`tools/`](tools/README.MD)** — логгер `netrunner-logger` и `bindgen` для Kotlin/Swift-биндингов.
## Быстрый старт ## Быстрый старт
@@ -33,7 +41,7 @@ Netrunner Core — это высокопроизводительный сете
Для запуска и отладки на локальной машине используйте команды: Для запуска и отладки на локальной машине используйте команды:
```bash ```bash
# Запуск сервера локально (биндится на 0.0.0.0:4443) # Запуск сервера локально (биндится на 0.0.0.0:8443)
make debug-server make debug-server
# Сборка клиента, выдача необходимых cap-прав и запуск # Сборка клиента, выдача необходимых cap-прав и запуск
@@ -65,7 +73,7 @@ make debug-client
| Команда | Описание | | Команда | Описание |
| :------------------- | :------------------------------------------------------------------------------------------------------ | | :------------------- | :------------------------------------------------------------------------------------------------------ |
| `make debug-client` | Сборка клиента в режиме отладки, установка прав (`setcap`) и запуск. | | `make debug-client` | Сборка клиента в режиме отладки, установка прав (`setcap`) и запуск. |
| `make debug-server` | Сборка и локальный запуск сервера на порту 4443. | | `make debug-server` | Сборка и локальный запуск сервера на порту 8443. |
| `make build-android` | Кросс-компиляция `.so` библиотек для Android (arm64, armv7, x86, x86_64) и генерация Kotlin-биндингов. | | `make build-android` | Кросс-компиляция `.so` библиотек для Android (arm64, armv7, x86, x86_64) и генерация Kotlin-биндингов. |
| `make build-server` | Сборка релизной версии сервера (`--release`). | | `make build-server` | Сборка релизной версии сервера (`--release`). |
| `make setup-server` | Установка необходимых пакетов (`build-essential`, `libssl-dev`) на удаленном сервере. | | `make setup-server` | Установка необходимых пакетов (`build-essential`, `libssl-dev`) на удаленном сервере. |
+1 -1
View File
@@ -9,7 +9,7 @@ crate-type = ["cdylib", "rlib", "staticlib"]
[dependencies] [dependencies]
netrunner-core = { path = "../core" } netrunner-core = { path = "../core" }
smoltcp = { git = "ssh://git@github.com/nineAp/nxrp-smoltcp.git", default-features = false, features = [ smoltcp = { git = "https://github.com/nineAp/nxrp-smoltcp.git", default-features = false, features = [
"std", "std",
"log", "log",
"medium-ip", "medium-ip",
+38
View File
@@ -0,0 +1,38 @@
# Крейт `client` — VPN-клиент и FFI-фасад
Клиентская сторона: перехватывает трафик приложений на уровне L3, прогоняет его
через userspace TCP/IP-стек и заворачивает в туннель ядра ([`core`](../core)).
Собирается двумя способами:
- **библиотека (`.so`)** для мобильных приложений через **UniFFI** — точка входа
`src/lib.rs` (`SessionManager`/`Session`/`VpnTrafficStats`);
- **бинарь для Linux** — `src/main.rs` (отладка/локальный запуск).
> Детали — в rustdoc: `cargo doc --open -p netrunner-client`.
## Структура
| Путь | Блок |
|-----------------|------------------------------------------------------------------|
| `src/lib.rs` | FFI-фасад (uniffi): запуск/остановка сессии, статистика. |
| `src/main.rs` | Linux-бинарь: поднимает TUN и движок. |
| [`src/net/`](src/net) | userspace TCP/IP-стек на smoltcp + мост в туннель. |
| [`src/tun/`](src/tun) | TUN-устройство, счётчики, системная маршрутизация. |
## Как это работает (с высоты)
```text
приложение ─→ TUN ─→ net::Engine (smoltcp + перехват) ─RawCastFrame→
core::ClientHandler (ноги туннеля, маскировка, шифр) ─→ сервер
```
`EngineBuilder::build` поднимает DNS, маршрутизацию и **устанавливает туннель**
(`core::ClientHandler::connect`), затем `Engine::run` крутит главный poll-цикл.
## Сборка
`client` зависит от приватного форка smoltcp по SSH
(`ssh://git@github.com/nineAp/nxrp-smoltcp.git`) — сборку запускайте там, где есть
SSH-доступ (WSL/Linux), из чистого Windows-shell `cargo` его не достанет.
Android-сборка `.so` + Kotlin-биндингов — через `make build-android` (см.
[`tools`](../tools)).
+43 -2
View File
@@ -1,3 +1,23 @@
//! # netrunner-client — клиент VPN и FFI-фасад для приложений
//!
//! Крейт собирается двумя способами: как библиотека (`.so`) для мобильных
//! приложений через **UniFFI** (этот файл) и как самостоятельный бинарь для
//! Linux ([`main.rs`](crate)). Вся реальная логика — в подмодулях:
//!
//! - [`net`] — userspace TCP/IP-стек на smoltcp и мост в туннель ядра;
//! - [`tun`] — TUN-устройство, smoltcp-`Device` и системная маршрутизация.
//!
//! ## FFI-поверхность (что видит Kotlin/Swift)
//!
//! - [`SessionManager`] — фабрика сессий: [`spawn_session`](SessionManager::spawn_session)
//! поднимает движок в фоне и возвращает управляемую [`Session`]; [`get_traffic_stats`](SessionManager::get_traffic_stats)
//! отдаёт счётчики.
//! - [`Session`] — ручка живого VPN; [`stop`](Session::stop) (и `Drop`) гасит
//! задачи и откатывает маршрутизацию.
//! - [`VpnTrafficStats`] — снимок трафика для UI.
//!
//! Токио-рантайм создаётся один раз ([`RUNTIME`]) и переживёт все сессии.
// Workaround for rustc 1.94 ICE in check_mod_deathness (dead-code MIR pass). // Workaround for rustc 1.94 ICE in check_mod_deathness (dead-code MIR pass).
#![allow(dead_code)] #![allow(dead_code)]
@@ -21,8 +41,10 @@ use std::sync::{Arc, OnceLock};
use tokio::runtime::Runtime; use tokio::runtime::Runtime;
use tokio_util::sync::CancellationToken; use tokio_util::sync::CancellationToken;
/// Глобальный многопоточный tokio-рантайм, общий для всех сессий.
pub static RUNTIME: OnceLock<Runtime> = OnceLock::new(); pub static RUNTIME: OnceLock<Runtime> = OnceLock::new();
/// Ленивая инициализация общего рантайма (создаётся при первом обращении).
fn get_runtime() -> &'static Runtime { fn get_runtime() -> &'static Runtime {
RUNTIME.get_or_init(|| { RUNTIME.get_or_init(|| {
tokio::runtime::Builder::new_multi_thread() tokio::runtime::Builder::new_multi_thread()
@@ -32,6 +54,7 @@ fn get_runtime() -> &'static Runtime {
}) })
} }
/// Снимок счётчиков трафика для отображения в приложении.
#[derive(uniffi::Record)] #[derive(uniffi::Record)]
pub struct VpnTrafficStats { pub struct VpnTrafficStats {
pub rx_bytes: u64, pub rx_bytes: u64,
@@ -40,6 +63,10 @@ pub struct VpnTrafficStats {
pub tx_packets: u64, pub tx_packets: u64,
} }
/// Ручка одной живой VPN-сессии (передаётся в приложение как объект UniFFI).
///
/// Хранит токен отмены и данные для отката маршрутизации. Останавливается явно
/// ([`stop`](Session::stop)) или автоматически при `Drop`.
#[derive(uniffi::Object)] #[derive(uniffi::Object)]
pub struct Session { pub struct Session {
pub(crate) cancel_token: CancellationToken, pub(crate) cancel_token: CancellationToken,
@@ -64,6 +91,7 @@ impl Drop for Session {
} }
} }
/// Фабрика VPN-сессий — главная точка входа FFI.
#[derive(uniffi::Object)] #[derive(uniffi::Object)]
pub struct SessionManager; pub struct SessionManager;
@@ -74,17 +102,28 @@ impl SessionManager {
Arc::new(SessionManager) Arc::new(SessionManager)
} }
/// Поднимает VPN-сессию в фоне и возвращает управляющую [`Session`].
///
/// Создаёт TUN (из переданного `_tun_fd` на мобильных или сам на Linux),
/// конфигурирует движок (MTU, kill-switch, исключения) и запускает его в
/// общем рантайме под токеном отмены. Не блокирует вызывающий поток.
///
/// `sni` — домен-декой для `ClientHello` этого сервера; приложение берёт
/// его из [`known_servers`] по выбранному `remote_address` (в будущем —
/// из бэкенда вместе с остальными полями узла).
pub fn spawn_session( pub fn spawn_session(
&self, &self,
remote_address: String, remote_address: String,
sni: String,
_tun_fd: Option<i32>, _tun_fd: Option<i32>,
cache_dir: String, cache_dir: String,
killswitch_enabled: bool, killswitch_enabled: bool,
excluded_apps: Vec<String>, excluded_apps: Vec<String>,
excluded_domains: Vec<String>, excluded_domains: Vec<String>,
auth_token: Option<String>,
) -> Arc<Session> { ) -> Arc<Session> {
netrunner_logger::Logger::init(None, false); netrunner_logger::Logger::init(None, false);
netrunner_logger::Logger::global().set_level("info"); netrunner_logger::Logger::global().set_level("error");
let runtime = get_runtime(); let runtime = get_runtime();
let cancel_token = CancellationToken::new(); let cancel_token = CancellationToken::new();
@@ -97,7 +136,9 @@ impl SessionManager {
.with_cache_path(&cache_dir) .with_cache_path(&cache_dir)
.with_killswitch(killswitch_enabled) .with_killswitch(killswitch_enabled)
.with_excluded_apps(excluded_apps) .with_excluded_apps(excluded_apps)
.with_excluded_domains(excluded_domains); .with_excluded_domains(excluded_domains)
.with_decoy_sni(sni)
.with_auth_token(auth_token);
#[cfg(any(target_os = "android", target_os = "ios"))] #[cfg(any(target_os = "android", target_os = "ios"))]
{ {
+8
View File
@@ -1,3 +1,11 @@
//! Бинарь клиента для Linux-десктопа (отладка/локальный запуск).
//!
//! Поднимает TUN-интерфейс `netr0` (10.0.0.1/24), инициализирует движок
//! ([`EngineBuilder`]) на захардкоженный адрес прокси и крутит его до Ctrl+C,
//! после чего восстанавливает системную маршрутизацию. Мобильная сборка идёт не
//! отсюда, а через FFI в [`lib.rs`](crate) (uniffi) — здесь же удобная точка
//! входа для запуска под Linux без Android.
// Workaround for rustc 1.94 ICE in check_mod_deathness (dead-code MIR pass). // Workaround for rustc 1.94 ICE in check_mod_deathness (dead-code MIR pass).
#![allow(dead_code)] #![allow(dead_code)]
+47
View File
@@ -0,0 +1,47 @@
# Блок `client/net` — userspace TCP/IP-стек и мост в туннель
«Локальная» половина клиента: то, что превращает перехваченные IP-пакеты
приложений в логические соединения и кормит ими ядро ([`netrunner_core`](../../../core)).
Построено поверх форка `smoltcp` (userspace-стек) + `tokio`.
> Детали — в rustdoc крейта `netrunner-client`, модуль `net`.
## Файлы
| Файл | Роль |
|-------------------------|------------------------------------------------------------|
| `engine.rs` | Главный poll-цикл стека + сборка (`EngineBuilder`). |
| `connection_manager.rs` | Перехват L3-пакетов, реестр сокетов, диспетчеризация. |
| `connection.rs` | Виртуальные TCP/UDP-соединения + ICMP-ответчик. |
| `session_tracker.rs` | NAT-таблица сокетов, idle/LRU-уборка. |
| `socket_factory.rs` | Создание smoltcp-сокетов под профиль трафика. |
| `dns.rs` | Локальный DNS: фейковые IP (CGNAT) + блок-лист. |
## Поток данных
```text
TUN ─пакеты→ engine ─push_rx→ smoltcp ─→ connection_manager
─перехват SYN/датаграмм→ TcpConnection/UdpConnection ─RawCastFrame→ туннель
туннель ─RawCastFrame→ engine ─route_download→ smoltcp ─→ TUN ─→ приложение
```
## Ключевые механики
- **Перехват** (`connection_manager`): на TCP-`SYN` и первую UDP-датаграмму
заводится smoltcp-сокет и виртуальное соединение; служебный трафик (DNS:53,
NetBIOS) пропускается.
- **Фейковый DNS** (`dns`): каждому имени выдаётся стабильный IP из 100.64.0.0/10,
по нему потом восстанавливается хост при установке туннельного соединения.
- **Профили трафика** (`socket_factory`): bulk/interactive/dns → разные размеры
буферов; TCP с BBR, без Nagle.
- **Анти-bufferbloat / анти-HOL**: download раздаётся **пер-сокетно**
(`pending_download` в `engine`), upload — с `tx_congested`-паузой
(`connection`); один застрявший сокет не морозит остальных.
- **Уборка** (`session_tracker`): idle-выметание + LRU-эвикт при лимите сокетов,
с защитой системных/слушающих.
## Связи
Кормит ядро через канал `RawCastFrame` ([`rawcast`](../../../core/src/rawcast));
сам туннель поднимает `ClientHandler::connect` из ядра. Ввод/вывод и маршруты — в
соседнем блоке [`tun`](../tun).
+49
View File
@@ -1,3 +1,20 @@
//! Виртуальные соединения клиента: мост между smoltcp-сокетом и туннелем.
//!
//! Каждое перехваченное приложение-соединение представлено одним из типов:
//! [`TcpConnection`], [`UdpConnection`] или ответчиком [`IcmpResponder`]. Они
//! живут в синхронном цикле стека (`tick`), но общаются с асинхронным туннелем
//! через каналы (`ConnectionCore`): локальный сокет ⇄ канал ⇄ задача `spawn` ⇄
//! [`RawCastFrame`] ⇄ туннель.
//!
//! Главное в [`TcpConnection`] — управление потоком без bufferbloat:
//! - **upload** (браузер→туннель): читаем из smoltcp, пока есть место в канале;
//! переполнение канала ставит флаг `tx_congested` → перестаём читать → срабатывает
//! TCP backpressure к приложению;
//! - **download** (туннель→браузер): держим максимум ОДИН `pending_chunk`; если
//! tx-буфер smoltcp полон — придерживаем чанк и поднимаем `is_saturated`;
//! - **RTT-проброс** в smoltcp (`set_tunnel_rtt`/AQM) для BBR — с потолком, чтобы
//! рост RTT не раздувал очередь по положительной обратной связи.
use bytes::Bytes; use bytes::Bytes;
use netrunner_core::{ use netrunner_core::{
net::{GLOBAL_MIN_RTT, NetworkConfig, UDP_IDLE_TIMEOUT}, net::{GLOBAL_MIN_RTT, NetworkConfig, UDP_IDLE_TIMEOUT},
@@ -16,10 +33,16 @@ use tokio::sync::{OwnedSemaphorePermit, mpsc, oneshot};
use netrunner_logger::{debug, info, instrument}; use netrunner_logger::{debug, info, instrument};
/// Общая «обвязка каналов» соединения: хендл сокета + два встречных канала +
/// флаг насыщения tx-буфера. Параметр `T` — тип исходящего сообщения (для TCP
/// это [`Bytes`], для UDP — кортеж с адресом).
pub struct ConnectionCore<T> { pub struct ConnectionCore<T> {
pub handle: SocketHandle, pub handle: SocketHandle,
/// Канал «локальный сокет → туннель».
pub tx: mpsc::Sender<T>, pub tx: mpsc::Sender<T>,
/// Канал «туннель → локальный сокет».
pub rx: mpsc::Receiver<Bytes>, pub rx: mpsc::Receiver<Bytes>,
/// Полон ли tx-буфер smoltcp (сигнал backpressure для download).
pub is_saturated: Arc<AtomicBool>, pub is_saturated: Arc<AtomicBool>,
} }
@@ -43,14 +66,20 @@ impl<T> ConnectionCore<T> {
} }
} }
/// Стадия жизненного цикла виртуального TCP-соединения.
#[derive(Debug, PartialEq)] #[derive(Debug, PartialEq)]
pub enum ConnectionState { pub enum ConnectionState {
/// Туннель подтвердил установку — можно переходить к Active.
Established, Established,
/// Ждём подтверждения от туннеля (CONNECT отправлен).
Handshaking, Handshaking,
/// Рабочее состояние: качаем данные в обе стороны.
Active, Active,
/// Закрыто.
Closed, Closed,
} }
/// Виртуальное TCP-соединение: один smoltcp tcp-сокет ↔ один поток туннеля.
pub struct TcpConnection { pub struct TcpConnection {
core: ConnectionCore<Bytes>, core: ConnectionCore<Bytes>,
state: ConnectionState, state: ConnectionState,
@@ -125,6 +154,11 @@ impl TcpConnection {
moved moved
} }
/// Один шаг конечного автомата соединения внутри poll-цикла стека.
///
/// Прогоняет состояние (Handshaking→Established→Active→Closed) и в активной
/// фазе качает данные через [`poll_and_process`](TcpConnection::poll_and_process).
/// Возвращает `false`, когда соединение закрылось и его пора убирать.
pub fn tick(&mut self, socket: &mut tcp::Socket, timestamp: smoltcp::time::Instant) -> bool { pub fn tick(&mut self, socket: &mut tcp::Socket, timestamp: smoltcp::time::Instant) -> bool {
match self.state { match self.state {
ConnectionState::Handshaking => { ConnectionState::Handshaking => {
@@ -221,6 +255,9 @@ impl TcpConnection {
} }
} }
/// Прокачивает данные в обе стороны за один тик (см. обзор модуля: upload с
/// `tx_congested`-паузой и download с одним `pending_chunk` + `is_saturated`).
/// В конце, если выгрузка завершена и буфер пуст, шлёт FIN приложению.
fn poll_and_process(&mut self, socket: &mut tcp::Socket, timestamp: smoltcp::time::Instant) { fn poll_and_process(&mut self, socket: &mut tcp::Socket, timestamp: smoltcp::time::Instant) {
self.maybe_update_tunnel_rtt(socket, timestamp); self.maybe_update_tunnel_rtt(socket, timestamp);
@@ -335,6 +372,11 @@ impl TcpConnection {
self.pending_chunk.as_ref().map(|c| c.len()).unwrap_or(0) self.pending_chunk.as_ref().map(|c| c.len()).unwrap_or(0)
} }
/// Запускает асинхронную задачу-«насос» соединения.
///
/// Шлёт в туннель `Connect` (с целью в payload), сигналит хендшейк, затем в
/// цикле гонит данные из smoltcp-канала в туннель `Data`-кадрами, а на выходе
/// отправляет `Close`. Связывает синхронный сокет с асинхронным туннелем.
#[instrument(skip(rx_smol, handshake_tx, tx_tunnel), fields( #[instrument(skip(rx_smol, handshake_tx, tx_tunnel), fields(
socket_id = socket_id, socket_id = socket_id,
dst = %target dst = %target
@@ -377,11 +419,15 @@ impl TcpConnection {
// ─── UDP ──────────────────────────────────────────────────────────────────── // ─── UDP ────────────────────────────────────────────────────────────────────
/// UDP-датаграмма с адресом назначения: `(данные, ip, port)`.
pub type UdpPacketTarget = (Bytes, std::net::Ipv4Addr, u16); pub type UdpPacketTarget = (Bytes, std::net::Ipv4Addr, u16);
/// Виртуальное UDP-«соединение» (NAT-запись): smoltcp udp-сокет ↔ поток туннеля.
pub struct UdpConnection { pub struct UdpConnection {
core: ConnectionCore<UdpPacketTarget>, core: ConnectionCore<UdpPacketTarget>,
/// Последний известный endpoint клиента (куда возвращать ответы).
last_client_endpoint: Option<IpEndpoint>, last_client_endpoint: Option<IpEndpoint>,
/// Время последней активности (для idle-таймаута).
last_activity: std::time::Instant, last_activity: std::time::Instant,
} }
@@ -484,9 +530,12 @@ impl UdpConnection {
use smoltcp::socket::icmp; use smoltcp::socket::icmp;
/// Отвечает на ICMP Echo (ping) локально, не гоняя его через туннель.
pub struct IcmpResponder; pub struct IcmpResponder;
impl IcmpResponder { impl IcmpResponder {
/// Принимает ICMP-пакет; на Echo Request формирует Echo Reply (v4/v6) с
/// пересчётом контрольной суммы и отправляет обратно источнику.
pub fn handle(socket: &mut icmp::Socket, timestamp: smoltcp::time::Instant) { pub fn handle(socket: &mut icmp::Socket, timestamp: smoltcp::time::Instant) {
if !socket.can_recv() { if !socket.can_recv() {
return; return;
+44
View File
@@ -1,3 +1,16 @@
//! Перехват L3-пакетов и порождение виртуальных соединений.
//!
//! [`ConnectionManager`] — «диспетчер» клиентского стека. Он разбирает сырые
//! IP-пакеты из TUN, на TCP-`SYN` и первую UDP-датаграмму заводит новый
//! smoltcp-сокет и виртуальное соединение, а в poll-цикле двигает все сокеты
//! ([`process_sockets`](ConnectionManager::process_sockets)) и убирает мёртвые
//! ([`cleanup`](ConnectionManager::cleanup)).
//!
//! Вспомогательные части: [`TargetResolver`] восстанавливает реальную цель по
//! фейковому IP (через [`FakeIpStore`]) и обслуживает DNS на UDP:53;
//! `connection_limiter` ([`Semaphore`]) ограничивает число одновременных
//! установок, а `pending_connects` гасит повторные SYN до завершения хендшейка.
use dashmap::DashMap; use dashmap::DashMap;
use netrunner_core::{ use netrunner_core::{
net::{ net::{
@@ -24,8 +37,10 @@ use crate::net::{
socket_factory::SocketProvider, socket_factory::SocketProvider,
}; };
/// Ключ потока: `(src_ip, src_port, dst_ip, dst_port)`.
type FlowKey = (IpAddress, u16, IpAddress, u16); type FlowKey = (IpAddress, u16, IpAddress, u16);
/// Распарсенные адреса/порты одного перехваченного пакета.
struct Flow { struct Flow {
src: IpAddress, src: IpAddress,
dst: IpAddress, dst: IpAddress,
@@ -39,6 +54,7 @@ impl Flow {
} }
} }
/// Восстановление цели соединения и обслуживание DNS.
struct TargetResolver { struct TargetResolver {
dns_handler: DnsHandler, dns_handler: DnsHandler,
fake_ip_store: FakeIpStore, fake_ip_store: FakeIpStore,
@@ -56,6 +72,8 @@ impl TargetResolver {
self.dns_handler.handle_query(data, &mut self.fake_ip_store) self.dns_handler.handle_query(data, &mut self.fake_ip_store)
} }
/// Восстанавливает реальную цель `(ip, "host:port")` по адресу из пакета.
/// Для фейкового IPv4 поднимает домен из [`FakeIpStore`]; иначе использует сам IP.
pub fn resolve_destination(&self, addr: IpAddress, port: u16) -> (std::net::Ipv4Addr, String) { pub fn resolve_destination(&self, addr: IpAddress, port: u16) -> (std::net::Ipv4Addr, String) {
match addr { match addr {
IpAddress::Ipv4(ip) => { IpAddress::Ipv4(ip) => {
@@ -74,13 +92,21 @@ impl TargetResolver {
} }
} }
/// Диспетчер клиентского стека: перехват пакетов, реестр сокетов, poll-цикл.
pub struct ConnectionManager { pub struct ConnectionManager {
/// Реестр виртуальных соединений и их таймаутов.
pub tracker: SessionTracker, pub tracker: SessionTracker,
/// Резолвер целей + DNS.
resolver: TargetResolver, resolver: TargetResolver,
/// Канал в туннель (исходящие [`RawCastFrame`]).
tx_to_tunnel: mpsc::Sender<RawCastFrame>, tx_to_tunnel: mpsc::Sender<RawCastFrame>,
/// Фабрика smoltcp-сокетов.
factory: Arc<dyn SocketProvider>, factory: Arc<dyn SocketProvider>,
/// Незавершённые установки TCP (гасят повторные SYN), с временем старта.
pending_connects: DashMap<FlowKey, Instant>, pending_connects: DashMap<FlowKey, Instant>,
/// Ограничитель числа одновременных соединений.
connection_limiter: Arc<Semaphore>, connection_limiter: Arc<Semaphore>,
/// Переиспользуемый буфер хендлов для прохода по сокетам (без аллокаций).
active_handles_cache: Vec<SocketHandle>, active_handles_cache: Vec<SocketHandle>,
} }
@@ -130,6 +156,8 @@ impl ConnectionManager {
} }
} }
/// Входная точка перехвата: разбирает версию IP из первого байта и направляет
/// пакет в обработчик IPv4/IPv6. Невалидное/прочее — молча игнорируется.
pub fn try_create_socket_from_packet(&mut self, packet: &[u8], socket_set: &mut SocketSet) { pub fn try_create_socket_from_packet(&mut self, packet: &[u8], socket_set: &mut SocketSet) {
if packet.is_empty() { if packet.is_empty() {
return; return;
@@ -205,6 +233,11 @@ impl ConnectionManager {
} }
} }
/// Заводит виртуальное TCP-соединение на перехваченный `SYN` (без ACK).
///
/// Дедупликация по `pending_connects` и наличию сокета; при достижении лимита
/// — LRU-эвикт; затем берётся семафор-пермит и создаётся слушающий сокет на
/// адрес назначения (smoltcp сам завершит хендшейк с приложением).
fn intercept_tcp(&mut self, f: Flow, socket_set: &mut SocketSet) { fn intercept_tcp(&mut self, f: Flow, socket_set: &mut SocketSet) {
let key = f.to_key(); let key = f.to_key();
if self.pending_connects.contains_key(&key) { if self.pending_connects.contains_key(&key) {
@@ -238,6 +271,11 @@ impl ConnectionManager {
} }
} }
/// Заводит виртуальное UDP-«соединение» на перехваченную датаграмму.
///
/// Пропускает служебный трафик (порт 0, локальный DNS:53, NetBIOS) и уже
/// известных клиентов; иначе — лимит/эвикт, создание привязанного UDP-сокета,
/// регистрация и запуск задачи-насоса в туннель.
fn intercept_udp(&mut self, f: Flow, socket_set: &mut SocketSet) { fn intercept_udp(&mut self, f: Flow, socket_set: &mut SocketSet) {
if f.dst_p == 0 if f.dst_p == 0
|| f.dst_p == DNS_PORT || f.dst_p == DNS_PORT
@@ -275,6 +313,9 @@ impl ConnectionManager {
} }
} }
/// Двигает все сокеты на один шаг: для каждого вызывает соответствующий
/// обработчик (TCP/UDP/ICMP). Хендлы кешируются заранее, чтобы не одалживать
/// `socket_set` неизменяемо и изменяемо одновременно.
pub fn process_sockets(&mut self, socket_set: &mut SocketSet, now: smoltcp::time::Instant) { pub fn process_sockets(&mut self, socket_set: &mut SocketSet, now: smoltcp::time::Instant) {
self.active_handles_cache.clear(); self.active_handles_cache.clear();
for (h, _) in socket_set.iter() { for (h, _) in socket_set.iter() {
@@ -381,6 +422,9 @@ impl ConnectionManager {
} }
} }
/// Периодическая уборка: снимает протухшие `pending_connects`, выметает
/// неактивные сокеты по [`GLOBAL_IDLE_TIMEOUT`] и физически удаляет
/// помеченные к удалению из `SocketSet`.
pub fn cleanup(&mut self, socket_set: &mut SocketSet) { pub fn cleanup(&mut self, socket_set: &mut SocketSet) {
self.pending_connects self.pending_connects
.retain(|_, timestamp| timestamp.elapsed() < TCP_HANDSHAKE_TIMEOUT); .retain(|_, timestamp| timestamp.elapsed() < TCP_HANDSHAKE_TIMEOUT);
+30 -1
View File
@@ -1,3 +1,18 @@
//! Локальный перехват DNS: фейковые IP + блок-лист.
//!
//! Клиент сам отвечает на DNS-запросы приложений, чтобы (а) не утекал реальный
//! DNS и (б) каждое имя получало стабильный «фейковый» IP из диапазона CGNAT
//! (RFC 6598, 100.64.0.0/10), по которому потом восстанавливается хост.
//!
//! Две части:
//! - [`FakeIpStore`] — двусторонний LRU-маппинг `домен ⇄ фейковый IP`. Выдаёт
//! новый IP по запросу и позволяет обратный поиск (IP → домен) при установке
//! туннельного соединения.
//! - [`DnsHandler`] — обработчик запросов: режет приватные суффиксы и домены из
//! блок-листа (StevenBlack/hosts, фоново подкачивается и кэшируется),
//! пропускает исключённые домены мимо туннеля (ServFail → системный DNS),
//! остальным A-запросам выдаёт фейковый IP.
use anyhow::Result; use anyhow::Result;
use hickory_proto::op::{Message, MessageType, ResponseCode}; use hickory_proto::op::{Message, MessageType, ResponseCode};
use hickory_proto::rr::{RData, Record, RecordType}; use hickory_proto::rr::{RData, Record, RecordType};
@@ -26,9 +41,13 @@ const BLOCKLIST_HTTP_TIMEOUT: Duration = Duration::from_secs(30);
/// TTL advertised in fake DNS A records (seconds). /// TTL advertised in fake DNS A records (seconds).
const FAKE_DNS_TTL: u32 = 60; const FAKE_DNS_TTL: u32 = 60;
/// Двусторонний LRU-маппинг доменов на фейковые IP из CGNAT-диапазона.
pub struct FakeIpStore { pub struct FakeIpStore {
/// Прямой: домен → выданный IP.
cache: LruCache<String, Ipv4Addr>, cache: LruCache<String, Ipv4Addr>,
/// Обратный: IP → домен (для восстановления цели при connect).
rev_cache: LruCache<Ipv4Addr, String>, rev_cache: LruCache<Ipv4Addr, String>,
/// Следующий свободный IP (монотонно растёт от `FAKE_IP_START`).
next_ip: u32, next_ip: u32,
} }
@@ -70,11 +89,16 @@ impl FakeIpStore {
// --- DNS Handler & Blocklist Logic --- // --- DNS Handler & Blocklist Logic ---
/// Обработчик DNS-запросов: фильтрация + выдача фейковых IP.
pub struct DnsHandler { pub struct DnsHandler {
/// Заблокированные домены (из StevenBlack/hosts).
block_list: HashSet<String>, block_list: HashSet<String>,
/// Приватные суффиксы, которые всегда NXDomain (`.lan`, `.local`, …).
forbidden_suffixes: Vec<String>, forbidden_suffixes: Vec<String>,
/// Путь к кэшу блок-листа на диске.
cache_path: String, cache_path: String,
excluded_domains: HashSet<String>, // Добавлено /// Домены в обход туннеля: на них отвечаем ServFail → системный DNS.
excluded_domains: HashSet<String>,
} }
impl DnsHandler { impl DnsHandler {
@@ -160,6 +184,11 @@ impl DnsHandler {
Ok(()) Ok(())
} }
/// Обрабатывает один DNS-запрос и возвращает сериализованный ответ.
///
/// Порядок решений: исключённый домен → ServFail (фолбэк на системный DNS);
/// приватный суффикс/блок-лист → NXDomain; A-запрос → фейковый IP; прочее →
/// пустой NoError. `None` — если запрос не разобрался.
pub fn handle_query(&self, data: &[u8], store: &mut FakeIpStore) -> Option<Vec<u8>> { pub fn handle_query(&self, data: &[u8], store: &mut FakeIpStore) -> Option<Vec<u8>> {
let req = Message::from_vec(data).ok()?; let req = Message::from_vec(data).ok()?;
let query = req.queries().first()?; let query = req.queries().first()?;
+115
View File
@@ -1,3 +1,23 @@
//! Главный движок клиента: poll-цикл smoltcp + мост TUN ⇄ туннель.
//!
//! [`Engine`] — это «сердце» клиентской стороны. В одной задаче `tokio` крутится
//! цикл [`run`](Engine::run), который на каждой итерации делает 7 шагов:
//! 1. download: туннель → локальные сокеты (с пер-сокетными бэклогами);
//! 2. upload: пакеты из TUN → устройство smoltcp;
//! 3. прогон стека smoltcp (`poll`);
//! 4. слив TX smoltcp → writer TUN;
//! 5. периодический лог статистики;
//! 6. обработка диагностических событий → снапшоты;
//! 7. адаптивный сон/пробуждение по событию (анти-spin).
//!
//! Принципиальная защита от bufferbloat и head-of-line: download раздаётся
//! **пер-сокетно** (`pending_download`), поэтому один застрявший потребитель не
//! морозит общий канал для остальных; оба направления делят одну задачу и ходят
//! по очереди с лимитом [`MAX_PACKETS_PER_TICK`] за тик.
//!
//! [`EngineBuilder`]/[`EngineConfig`] — сборка движка: DNS, маршрутизация,
//! установка туннеля ([`ClientHandler::connect`]) и параметры интерфейса.
use bytes::Bytes; use bytes::Bytes;
use netrunner_core::net::ClientHandler; use netrunner_core::net::ClientHandler;
use netrunner_core::net::NetworkConfig; use netrunner_core::net::NetworkConfig;
@@ -56,7 +76,17 @@ const MAX_POLL_SLEEP: Duration = Duration::from_millis(2);
/// than this many queued frames is treated as a dead/stuck consumer and dropped, /// than this many queued frames is treated as a dead/stuck consumer and dropped,
/// so it can never stall the shared download pipe for other sockets. /// so it can never stall the shared download pipe for other sockets.
const MAX_PENDING_FRAMES_PER_SOCKET: usize = 64; const MAX_PENDING_FRAMES_PER_SOCKET: usize = 64;
/// Max diagnostics snapshots buffered locally awaiting upload to the server.
/// The most useful triggers (leg disconnect/reconnect) fire exactly when no leg
/// is up to carry them, so snapshots wait here and flush once a leg recovers.
/// Oldest is dropped past the cap — recent state matters more than ancient.
const DIAG_OUTBOX_CAP: usize = 128;
/// Diagnostics snapshots flushed to the server per engine tick. Bounds the cold
/// path so a large backlog can't monopolise a loop iteration after reconnect.
const DIAG_FLUSH_PER_TICK: usize = 16;
/// Движок клиентского стека: интерфейс smoltcp, реестр сокетов, мост в туннель
/// и диагностика. Живёт в одной задаче `tokio` (см. [`run`](Engine::run)).
pub struct Engine { pub struct Engine {
interface: Interface, interface: Interface,
socket_set: SocketSet<'static>, socket_set: SocketSet<'static>,
@@ -76,6 +106,11 @@ pub struct Engine {
diag_rx: Option<DiagnosisRx>, diag_rx: Option<DiagnosisRx>,
/// Shared store that holds the last N snapshots (readable via public API). /// Shared store that holds the last N snapshots (readable via public API).
pub diag_store: Arc<DiagnosticsStore>, pub diag_store: Arc<DiagnosticsStore>,
/// Snapshots serialized to JSON and queued for upload to the connected
/// server (one `Diag` frame each). Filled on every trigger; drained whenever
/// a tunnel leg is available so reports survive the disconnect that produced
/// them. See [`DIAG_OUTBOX_CAP`] / [`DIAG_FLUSH_PER_TICK`].
diag_outbox: std::collections::VecDeque<Bytes>,
/// Per-socket backlog of download frames whose target channel was full. /// Per-socket backlog of download frames whose target channel was full.
/// Keyed by socket_id so a single slow/dead consumer can NEVER stall the /// Keyed by socket_id so a single slow/dead consumer can NEVER stall the
/// shared download pipe for other sockets — the old single global slot did /// shared download pipe for other sockets — the old single global slot did
@@ -124,6 +159,7 @@ impl Engine {
muxer: None, muxer: None,
diag_rx: None, diag_rx: None,
diag_store: Arc::new(DiagnosticsStore::new(20)), diag_store: Arc::new(DiagnosticsStore::new(20)),
diag_outbox: std::collections::VecDeque::new(),
pending_download: std::collections::HashMap::new(), pending_download: std::collections::HashMap::new(),
dl_dispatched: 0, dl_dispatched: 0,
dl_dropped_stuck: 0, dl_dropped_stuck: 0,
@@ -131,6 +167,10 @@ impl Engine {
} }
} }
/// Запускает главный цикл движка (не возвращается, пока туннель/TUN живы).
///
/// Поднимает reader/writer-задачи TUN и крутит 7-шаговый цикл из обзора
/// модуля. `tun` забирается во владение и расщепляется на половины.
pub async fn run(&mut self, tun: Tun) { pub async fn run(&mut self, tun: Tun) {
info!("Current routes: {:?}", self.interface.routes()); info!("Current routes: {:?}", self.interface.routes());
let (writer, reader) = tun.split().expect("Failed to split TUN"); let (writer, reader) = tun.split().expect("Failed to split TUN");
@@ -355,6 +395,12 @@ impl Engine {
match diag_rx.try_recv() { match diag_rx.try_recv() {
Ok(event) => { Ok(event) => {
let snap = self.build_snapshot(event); let snap = self.build_snapshot(event);
// Queue a compact JSON copy for upload to the server,
// then keep the snapshot in the local ring buffer.
if self.diag_outbox.len() >= DIAG_OUTBOX_CAP {
self.diag_outbox.pop_front();
}
self.diag_outbox.push_back(Bytes::from(snap.to_json_line()));
self.diag_store.push(snap); self.diag_store.push(snap);
} }
Err(_) => break, Err(_) => break,
@@ -363,6 +409,27 @@ impl Engine {
self.diag_rx = Some(diag_rx); self.diag_rx = Some(diag_rx);
} }
// ── 6b. Ship queued diagnostics to the server ────────────────
// Best-effort, cold path: only runs when there's a backlog AND a leg
// is up to carry it. send_diag_report is a non-blocking try_send under
// the hood, so the await is cheap; on failure (no leg accepted it) we
// re-queue the snapshot and retry on a later tick.
if !self.diag_outbox.is_empty() {
if let Some(muxer) = self.muxer.clone() {
if muxer.active_legs_count() > 0 {
for _ in 0..DIAG_FLUSH_PER_TICK {
let Some(line) = self.diag_outbox.pop_front() else {
break;
};
if !muxer.send_diag_report(line.clone()).await {
self.diag_outbox.push_front(line);
break;
}
}
}
}
}
// ── 7. Adaptive timing ─────────────────────────────────────── // ── 7. Adaptive timing ───────────────────────────────────────
if work_done { if work_done {
tokio::task::yield_now().await; tokio::task::yield_now().await;
@@ -495,6 +562,8 @@ impl Engine {
.poll(now, &mut self.device, &mut self.socket_set) .poll(now, &mut self.device, &mut self.socket_set)
} }
/// Задача чтения из TUN: читает IP-пакеты и шлёт их в движок. `send().await`
/// блокируется при полном канале → backpressure доходит до TUN-устройства ОС.
fn spawn_tun_reader(mut reader: DeviceReader, to_engine: mpsc::Sender<Vec<u8>>) { fn spawn_tun_reader(mut reader: DeviceReader, to_engine: mpsc::Sender<Vec<u8>>) {
tokio::spawn(async move { tokio::spawn(async move {
debug!("TUN Reader task started"); debug!("TUN Reader task started");
@@ -519,6 +588,8 @@ impl Engine {
}); });
} }
/// Задача записи в TUN: принимает готовые пакеты из движка и пишет их в
/// устройство (отдаёт приложению то, что пришло из туннеля).
fn spawn_tun_writer(mut writer: DeviceWriter, mut from_engine: mpsc::Receiver<Vec<u8>>) { fn spawn_tun_writer(mut writer: DeviceWriter, mut from_engine: mpsc::Receiver<Vec<u8>>) {
tokio::spawn(async move { tokio::spawn(async move {
debug!("TUN Writer task started"); debug!("TUN Writer task started");
@@ -629,6 +700,7 @@ impl Engine {
global_min_rtt_ms: 0, global_min_rtt_ms: 0,
active_legs: vec![], active_legs: vec![],
total_streams: 0, total_streams: 0,
session_count: 0,
}); });
DiagnosticsSnapshot { DiagnosticsSnapshot {
@@ -674,18 +746,40 @@ impl Engine {
// ─── EngineConfig & EngineBuilder (unchanged API surface) ────────────────── // ─── EngineConfig & EngineBuilder (unchanged API surface) ──────────────────
/// Параметры запуска движка (билдер-стайл через `with_*`).
#[derive(Clone, Debug)] #[derive(Clone, Debug)]
pub struct EngineConfig { pub struct EngineConfig {
/// Адрес прокси-сервера (`host:port`).
pub remote_address: String, pub remote_address: String,
/// Путь к директории кэша (блок-лист DNS и т.п.).
pub cache_path: String, pub cache_path: String,
/// MTU интерфейса.
pub mtu: usize, pub mtu: usize,
/// Настраивать ли системную маршрутизацию (на мобильных — нет, это делает ОС).
pub setup_routing: bool, pub setup_routing: bool,
/// Принимать пакеты на любой IP (`any_ip` интерфейса smoltcp).
pub any_ip: bool, pub any_ip: bool,
/// Прозрачный режим (стек как промежуточный узел, без своего «адреса»).
pub transparent_mode: bool, pub transparent_mode: bool,
/// Шлюз по умолчанию внутри стека.
pub default_gateway: Ipv4Addr, pub default_gateway: Ipv4Addr,
/// Включён ли kill-switch (резать трафик мимо туннеля).
pub killswitch_enabled: bool, pub killswitch_enabled: bool,
/// Приложения в обход туннеля (split-tunneling).
pub excluded_apps: Vec<String>, pub excluded_apps: Vec<String>,
/// Домены в обход туннеля.
pub excluded_domains: Vec<String>, pub excluded_domains: Vec<String>,
/// SNI поддельного `ClientHello` (домен-декой, под который маскируется
/// хендшейк). Пока статический атрибут конфигурации — раньше был
/// захардкожен константой глубоко в TLS-слое ядра. В перспективе будет
/// приходить динамически со списком серверов (вместе с их собственным
/// `--decoy-host`), чтобы клиент и сервер не расходились в выборе decoy-хоста.
pub decoy_sni: String,
/// Bearer-токен клиента (JWT, выданный `netrunner-backend` при логине) —
/// отправляется серверу в auth-кадре. `None`, если сервер не запущен с
/// `--require-auth` или приложение ещё не залогинено (Ghost Protocol seed
/// генерируется/логинится в фоне почти сразу, см. `netrunner-app/src/lib/api.ts`).
pub auth_token: Option<String>,
} }
impl EngineConfig { impl EngineConfig {
@@ -701,9 +795,21 @@ impl EngineConfig {
killswitch_enabled: true, killswitch_enabled: true,
excluded_apps: Vec::new(), excluded_apps: Vec::new(),
excluded_domains: Vec::new(), excluded_domains: Vec::new(),
decoy_sni: netrunner_core::net::DEFAULT_DECOY_HOST.to_string(),
auth_token: None,
} }
} }
pub fn with_decoy_sni(mut self, decoy_sni: impl Into<String>) -> Self {
self.decoy_sni = decoy_sni.into();
self
}
pub fn with_auth_token(mut self, auth_token: Option<String>) -> Self {
self.auth_token = auth_token;
self
}
pub fn with_cache_path(mut self, path: impl Into<String>) -> Self { pub fn with_cache_path(mut self, path: impl Into<String>) -> Self {
self.cache_path = path.into(); self.cache_path = path.into();
self self
@@ -735,6 +841,7 @@ impl EngineConfig {
} }
} }
/// Сборщик [`Engine`]: подготавливает DNS, маршрутизацию, туннель и интерфейс.
pub struct EngineBuilder { pub struct EngineBuilder {
config: EngineConfig, config: EngineConfig,
tun_device: Option<Tun>, tun_device: Option<Tun>,
@@ -755,6 +862,12 @@ impl EngineBuilder {
self self
} }
/// Собирает готовый к запуску движок.
///
/// Инициализирует DNS-блоклист, при необходимости ставит системные маршруты,
/// поднимает диагностику и **устанавливает туннель** ([`ClientHandler::connect`]),
/// затем создаёт [`Engine`], настраивает интерфейс и добавляет маршруты-исключения
/// для split-tunneling доменов. Возвращает движок и TUN для последующего `run`.
pub async fn build(self) -> Result<(Engine, Tun), String> { pub async fn build(self) -> Result<(Engine, Tun), String> {
let tun = self.tun_device.ok_or("TUN device is required")?; let tun = self.tun_device.ok_or("TUN device is required")?;
@@ -798,6 +911,8 @@ impl EngineBuilder {
info!("Establishing secure tunnel to proxy server..."); info!("Establishing secure tunnel to proxy server...");
let muxer = ClientHandler::connect( let muxer = ClientHandler::connect(
&self.config.remote_address, &self.config.remote_address,
self.config.decoy_sni.clone(),
self.config.auth_token.clone(),
rx_for_client_handler, rx_for_client_handler,
tx_for_client_handler, tx_for_client_handler,
) )
+19
View File
@@ -1,3 +1,22 @@
//! Сетевой слой клиента: userspace TCP/IP-стек на smoltcp + мост в туннель.
//!
//! Здесь живёт «локальная» половина клиента — то, что превращает перехваченные
//! IP-пакеты приложений в логические соединения и кормит ими ядро
//! ([`netrunner_core`]). Поток данных:
//!
//! ```text
//! TUN ─пакеты→ engine ─push_rx→ smoltcp ─→ connection_manager
//! ─перехват SYN/датаграмм→ TcpConnection/UdpConnection ─RawCastFrame→ туннель
//! ```
//!
//! Состав:
//! - [`engine`] — главный poll-цикл стека и сборка ([`EngineBuilder`](engine::EngineBuilder)).
//! - [`connection_manager`] — перехват L3-пакетов, реестр сокетов, диспетчеризация.
//! - [`connection`] — виртуальные TCP/UDP-соединения и ICMP-ответчик.
//! - [`session_tracker`] — NAT-таблица сокетов, idle/LRU-уборка.
//! - [`socket_factory`] — создание smoltcp-сокетов под профиль трафика.
//! - [`dns`] — локальный DNS с фейковыми IP и блок-листом.
mod connection; mod connection;
pub mod connection_manager; pub mod connection_manager;
mod dns; mod dns;
+24
View File
@@ -1,3 +1,19 @@
//! Реестр виртуальных соединений userspace-стека smoltcp.
//!
//! [`SessionTracker`] — это «NAT-таблица» клиента: он связывает хендлы сокетов
//! smoltcp ([`SocketHandle`]) с логическими id, держит активные TCP/UDP-соединения
//! и каналы доставки входящих данных, следит за активностью и убирает «призраков».
//!
//! Ключевые обязанности:
//! - **Реестр** TCP/UDP-соединений и двусторонний маппинг `handle ⇄ id`.
//! - **Pending TCP** — полуоткрытые соединения с удерживаемым семафор-пермитом
//! (ограничение числа одновременных установок).
//! - **Idle-выметание** ([`enforce_idle_timeouts`](SessionTracker::enforce_idle_timeouts))
//! и **LRU-эвикт** ([`evict_oldest_socket`](SessionTracker::evict_oldest_socket))
//! при достижении лимита сокетов — с защитой системных/слушающих сокетов.
//! - **Отложенное удаление**: `queue_removal` + `cleanup` (нельзя трогать
//! `SocketSet` во время итерации по нему).
use std::{ use std::{
collections::HashMap, collections::HashMap,
time::{Duration, Instant as StdInstant}, time::{Duration, Instant as StdInstant},
@@ -15,6 +31,7 @@ use tokio::sync::{OwnedSemaphorePermit, mpsc};
use crate::net::connection::{TcpConnection, UdpConnection}; use crate::net::connection::{TcpConnection, UdpConnection};
/// Состояние всех виртуальных соединений и их маппингов на хендлы smoltcp.
pub struct SessionTracker { pub struct SessionTracker {
last_activity: HashMap<SocketHandle, StdInstant>, last_activity: HashMap<SocketHandle, StdInstant>,
active_tcp: HashMap<SocketHandle, TcpConnection>, active_tcp: HashMap<SocketHandle, TcpConnection>,
@@ -146,6 +163,11 @@ impl SessionTracker {
} }
} }
/// LRU-эвикт для освобождения слота при достижении лимита сокетов.
///
/// Сначала ищет уже «мёртвый» TCP-сокет (Closed/TimeWait/CloseWait/FinWait);
/// если таких нет — закрывает самый давно неактивный пользовательский сокет,
/// **не трогая** системные/слушающие. Возвращает `true`, если кого-то закрыл.
pub fn evict_oldest_socket(&mut self, socket_set: &mut SocketSet) -> bool { pub fn evict_oldest_socket(&mut self, socket_set: &mut SocketSet) -> bool {
let mut victim = None; let mut victim = None;
@@ -193,6 +215,8 @@ impl SessionTracker {
} }
} }
/// Фактически удаляет все сокеты из очереди `to_remove` из `SocketSet` и всех
/// внутренних таблиц. Вызывается вне итерации по сокетам (см. отложенность).
pub fn cleanup(&mut self, socket_set: &mut SocketSet) { pub fn cleanup(&mut self, socket_set: &mut SocketSet) {
for handle in self.to_remove.drain(..) { for handle in self.to_remove.drain(..) {
socket_set.remove(handle); socket_set.remove(handle);
+28 -1
View File
@@ -1,3 +1,14 @@
//! Фабрика smoltcp-сокетов с профилями трафика.
//!
//! Разные виды трафика хотят разные сокеты: «толстым» закачкам (HTTP/HTTPS) нужны
//! большие буферы ради throughput, интерактиву (SSH/RDP/VNC) — маленькие ради
//! низкой задержки, DNS — совсем маленькие. [`TrafficProfile`] классифицирует
//! трафик по порту, а [`SmolSocketFactory`] (за трейтом [`SocketProvider`])
//! создаёт TCP/UDP/ICMP-сокеты с буферами под профиль из [`NetworkConfig`].
//!
//! TCP-сокеты настраиваются под низкую задержку: Nagle off, без ack-delay,
//! congestion control = BBR.
use netrunner_core::net::{ use netrunner_core::net::{
BUFFERBLOAT_WARN_THRESHOLD, HTTPS_PORT, HTTP_ALT_PORT, HTTP_PORT, ICMP_BUFFER_SIZE, BUFFERBLOAT_WARN_THRESHOLD, HTTPS_PORT, HTTP_ALT_PORT, HTTP_PORT, ICMP_BUFFER_SIZE,
ICMP_META_SLOTS, MAX_SOCKETS, NTP_PORT, RDP_PORT, RTMP_PORT, SSH_PORT, VNC_PORT, NetworkConfig, ICMP_META_SLOTS, MAX_SOCKETS, NTP_PORT, RDP_PORT, RTMP_PORT, SSH_PORT, VNC_PORT, NetworkConfig,
@@ -16,11 +27,16 @@ use smoltcp::{
}; };
use std::sync::Arc; use std::sync::Arc;
/// Класс трафика, определяющий размеры буферов сокета.
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TrafficProfile { pub enum TrafficProfile {
/// Интерактив (SSH/RDP/VNC): маленькие буферы, минимум задержки.
Interactive, Interactive,
/// Объёмные потоки (HTTP/HTTPS/RTMP): большие буферы, максимум throughput.
Bulk, Bulk,
/// DNS/NTP: совсем маленькие буферы.
Dns, Dns,
/// Всё остальное: умеренные буферы.
Default, Default,
} }
@@ -28,6 +44,8 @@ pub const TCP_SOCKET_KEEP_ALIVE: Duration = Duration::from_secs(15);
pub const TCP_SOCKET_ACTIVE_TIMEOUT: Duration = Duration::from_secs(60); pub const TCP_SOCKET_ACTIVE_TIMEOUT: Duration = Duration::from_secs(60);
impl TrafficProfile { impl TrafficProfile {
/// Угадывает профиль по (порту назначения, протоколу). Эвристика на основе
/// известных портов; неизвестные → [`TrafficProfile::Default`].
pub fn guess_from_port(port: u16, is_tcp: bool) -> Self { pub fn guess_from_port(port: u16, is_tcp: bool) -> Self {
match (port, is_tcp) { match (port, is_tcp) {
(SSH_PORT, true) | (RDP_PORT, true) | (VNC_PORT, true) => Self::Interactive, (SSH_PORT, true) | (RDP_PORT, true) | (VNC_PORT, true) => Self::Interactive,
@@ -40,17 +58,25 @@ impl TrafficProfile {
} }
} }
/// Абстракция создания сокетов стека (позволяет подменять в тестах).
pub trait SocketProvider: Send + Sync { pub trait SocketProvider: Send + Sync {
/// Создаёт исходящий TCP-сокет под профиль.
fn create_tcp(&self, profile: TrafficProfile) -> tcp::Socket; fn create_tcp(&self, profile: TrafficProfile) -> tcp::Socket;
/// Создаёт UDP-сокет под профиль.
fn create_udp(&self, profile: TrafficProfile) -> udp::Socket<'static>; fn create_udp(&self, profile: TrafficProfile) -> udp::Socket<'static>;
/// Создаёт ICMP-сокет (для ответов на ping).
fn create_icmp(&self, profile: TrafficProfile) -> icmp::Socket<'static>; fn create_icmp(&self, profile: TrafficProfile) -> icmp::Socket<'static>;
/// Создаёт слушающий TCP-сокет (профиль угадывается по порту).
fn create_listening_tcp(&self, addr: Option<IpAddress>, port: u16) -> tcp::Socket; fn create_listening_tcp(&self, addr: Option<IpAddress>, port: u16) -> tcp::Socket;
/// Создаёт привязанный UDP-сокет (профиль угадывается по порту).
fn create_bound_udp(&self, addr: Option<IpAddress>, port: u16) -> udp::Socket<'static>; fn create_bound_udp(&self, addr: Option<IpAddress>, port: u16) -> udp::Socket<'static>;
/// Создаёт базовый набор сокетов (слушающий UDP:53 + `n_icmp` ICMP).
fn create_base_set(&self, n_icmp: usize) -> SocketSet<'static>; fn create_base_set(&self, n_icmp: usize) -> SocketSet<'static>;
/// Перенастраивает уже существующий TCP-сокет под профиль (Nagle/BBR и т.п.).
fn reconfigure_tcp(&self, socket: &mut tcp::Socket, profile: TrafficProfile); fn reconfigure_tcp(&self, socket: &mut tcp::Socket, profile: TrafficProfile);
// 🔥 НОВЫЙ МЕТОД: Логирование статистики всех сокетов в сете /// Логирует статистику всех активных сокетов (диагностика bufferbloat).
fn log_stats( fn log_stats(
&self, &self,
sockets: &SocketSet, sockets: &SocketSet,
@@ -58,6 +84,7 @@ pub trait SocketProvider: Send + Sync {
); );
} }
/// Реализация [`SocketProvider`] поверх [`NetworkConfig`].
pub struct SmolSocketFactory { pub struct SmolSocketFactory {
config: Arc<NetworkConfig>, config: Arc<NetworkConfig>,
} }
+4 -2
View File
@@ -9,14 +9,16 @@ dictionary VpnTrafficStats {
interface SessionManager { interface SessionManager {
constructor(); constructor();
// Добавлены параметры для Killswitch и исключений // Добавлены параметры для Killswitch и исключений, и sni (декой ClientHello)
Session spawn_session( Session spawn_session(
string remote_address, string remote_address,
string sni,
i32? tun_fd, i32? tun_fd,
string cache_path, string cache_path,
boolean killswitch_enabled, boolean killswitch_enabled,
sequence<string> excluded_apps, sequence<string> excluded_apps,
sequence<string> excluded_domains sequence<string> excluded_domains,
string? auth_token
); );
VpnTrafficStats get_traffic_stats(); VpnTrafficStats get_traffic_stats();
}; };
+40
View File
@@ -0,0 +1,40 @@
# Блок `client/tun` — TUN-интерфейс и системная маршрутизация
«Железо» клиента — граница с ОС, через которую в стек попадают сырые L3-пакеты
приложений и куда возвращаются ответы. Платформо-зависимый ввод/вывод.
> Детали — в rustdoc крейта `netrunner-client`, модуль `tun`.
## Файлы
| Файл | Роль |
|--------------|------------------------------------------------------------------|
| `tun.rs` | Обёртка `Tun`: создание/открытие устройства, `split` на reader/writer. |
| `device.rs` | Счётчики трафика (`GLOBAL_*` для FFI) + `TrafficCounter` со скоростью. |
| `routing.rs` | Установка/снятие системных маршрутов и kill-switch. |
## Как создаётся TUN
- **Linux (desktop)** — `Tun::create(...)` с именем `netr0`, адресом 10.0.0.1/24.
- **Android/iOS** — `Tun::from_fd(fd)`: дескриптор приходит из нативного `VpnService`.
- **Split** разводит устройство на пишущую и читающую половины — их забирают
разные задачи движка ([`net`](../net)).
## Маршрутизация (`routing.rs`)
`setup_platform_routing` заворачивает трафик в TUN, сохраняя доступ к прокси; при
`killswitch` режет всё мимо туннеля. Реализация целиком по `cfg`:
| Платформа | Механизм |
|---------------|------------------------------------------------------------------|
| Linux | `nftables` (таблица `netrunner`) + policy-routing `ip rule`/`table 100`; split-tunneling по UID; DNAT DNS на стек. |
| Windows | таблица маршрутов (`0.0.0.0/1`+`128.0.0.0/1` поверх дефолта); kill-switch удалением дефолта; откат через DHCP-renew. |
| Android/iOS | ничего — маршруты ставит нативная сторона. |
`reset_platform_routing` откатывает всё при остановке.
## Связи
Пакеты из `Tun` читает движок [`net`](../net); счётчики `GLOBAL_*` отдаются в
приложение через FFI (`lib.rs`). Имя `device.rs` историческое — сам smoltcp-`Device`
живёт во внешнем форке smoltcp, здесь только метрики.
+18 -1
View File
@@ -1,11 +1,26 @@
//! Учёт трафика TUN-интерфейса.
//!
//! Глобальные атомарные счётчики (`GLOBAL_*`) видны через FFI и отдают
//! приложению суммарную статистику сессии. [`TrafficCounter`] — пер-сессионный
//! учётчик, который вдобавок раз в секунду пересчитывает скользящую оценку
//! скорости (МБ/с) для отображения в UI.
//!
//! Имя файла историческое: собственно реализация smoltcp-`Device` поверх TUN
//! живёт во внешнем форке smoltcp; здесь — только метрики.
use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::atomic::{AtomicU64, Ordering};
use std::time::Instant as StdInstant; use std::time::Instant as StdInstant;
/// Суммарно принято байт за всё время (для FFI-статистики).
pub static GLOBAL_RX_BYTES: AtomicU64 = AtomicU64::new(0); pub static GLOBAL_RX_BYTES: AtomicU64 = AtomicU64::new(0);
/// Суммарно отправлено байт за всё время.
pub static GLOBAL_TX_BYTES: AtomicU64 = AtomicU64::new(0); pub static GLOBAL_TX_BYTES: AtomicU64 = AtomicU64::new(0);
/// Суммарно принято пакетов.
pub static GLOBAL_RX_PACKETS: AtomicU64 = AtomicU64::new(0); pub static GLOBAL_RX_PACKETS: AtomicU64 = AtomicU64::new(0);
/// Суммарно отправлено пакетов.
pub static GLOBAL_TX_PACKETS: AtomicU64 = AtomicU64::new(0); pub static GLOBAL_TX_PACKETS: AtomicU64 = AtomicU64::new(0);
/// Снимок статистики трафика с мгновенной скоростью.
#[derive(Debug, Clone, Copy)] #[derive(Debug, Clone, Copy)]
pub struct TrafficStats { pub struct TrafficStats {
pub rx_bytes: u64, pub rx_bytes: u64,
@@ -16,7 +31,7 @@ pub struct TrafficStats {
pub tx_speed_mb_s: f64, pub tx_speed_mb_s: f64,
} }
/// Per-session traffic counter with rolling speed estimate. /// Пер-сессионный учётчик трафика со скользящей оценкой скорости.
pub struct TrafficCounter { pub struct TrafficCounter {
rx_bytes: u64, rx_bytes: u64,
tx_bytes: u64, tx_bytes: u64,
@@ -60,6 +75,8 @@ impl TrafficCounter {
GLOBAL_TX_PACKETS.fetch_add(1, Ordering::Relaxed); GLOBAL_TX_PACKETS.fetch_add(1, Ordering::Relaxed);
} }
/// Возвращает текущий снимок статистики; скорость пересчитывается не чаще
/// раза в секунду (между вызовами отдаётся закешированное значение).
pub fn get_stats(&mut self) -> TrafficStats { pub fn get_stats(&mut self) -> TrafficStats {
let now = StdInstant::now(); let now = StdInstant::now();
let elapsed = now.duration_since(self.last_speed_calc).as_secs_f64(); let elapsed = now.duration_since(self.last_speed_calc).as_secs_f64();
+10
View File
@@ -1,3 +1,13 @@
//! TUN-интерфейс и системная маршрутизация (платформо-зависимый ввод/вывод).
//!
//! Это «железо» клиента — граница с ОС, через которую в стек попадают сырые
//! L3-пакеты приложений:
//! - [`tun`] — создание/открытие TUN-устройства и асинхронное чтение/запись пакетов.
//! - [`device`] — реализация smoltcp `Device` поверх TUN (RxToken/TxToken) +
//! глобальные счётчики трафика.
//! - [`routing`] — установка/снятие системных маршрутов и kill-switch (nftables/
//! ip-rule и аналоги), чтобы весь трафик заворачивался в туннель.
pub mod device; pub mod device;
pub mod routing; pub mod routing;
pub mod tun; pub mod tun;
+27
View File
@@ -1,8 +1,29 @@
//! Платформенная маршрутизация и kill-switch.
//!
//! Заворачивает системный трафик в TUN-интерфейс и (опционально) режет утечки
//! мимо туннеля. Реализация целиком платформо-зависимая (`cfg`):
//!
//! - **Linux** — `nftables` (таблица `netrunner`): маркировка трафика в TUN,
//! split-tunneling по UID, DNAT DNS на стек, kill-switch с исключениями для LAN
//! и самого прокси; плюс policy-routing через `ip rule`/`ip route table 100`.
//! - **Windows** — таблица маршрутов (`route add` половинками `0.0.0.0/1`+`128.0.0.0/1`,
//! чтобы перебить дефолт, не удаляя его), kill-switch удалением дефолтного
//! маршрута, восстановление через DHCP-renew.
//! - **Android/iOS** — ничего: маршрутизацию ставит нативная сторона (`VpnService`).
//!
//! [`setup_platform_routing`] ставит правила, [`reset_platform_routing`] —
//! откатывает их при остановке. Все внешние команды идут через [`run_cmd_ext`].
use netrunner_logger::{error, info}; use netrunner_logger::{error, info};
use std::io; use std::io;
use std::process::Command; use std::process::Command;
/// Выполняет внешнюю команду (через `shlex`-разбор строки).
///
/// `ignore_errors` — не падать на ненулевом коде возврата (для идемпотентных
/// операций вроде «удалить правило, которого может не быть»). На Windows окно
/// процесса скрывается флагом `CREATE_NO_WINDOW`.
pub fn run_cmd_ext(full_cmd: &str, ignore_errors: bool) -> io::Result<()> { pub fn run_cmd_ext(full_cmd: &str, ignore_errors: bool) -> io::Result<()> {
let parts = shlex::split(full_cmd) let parts = shlex::split(full_cmd)
.ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "Invalid syntax"))?; .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "Invalid syntax"))?;
@@ -76,6 +97,9 @@ pub fn get_default_gateway_linux() -> Option<String> {
stdout.split_whitespace().nth(2).map(|s| s.to_string()) stdout.split_whitespace().nth(2).map(|s| s.to_string())
} }
/// Ставит платформенные правила маршрутизации: весь трафic → TUN, доступ к
/// прокси сохраняется, при `killswitch` всё прочее блокируется. `excluded_apps`
/// (на Linux — UID) проходят мимо туннеля (split-tunneling).
pub fn setup_platform_routing( pub fn setup_platform_routing(
remote_address: &str, remote_address: &str,
killswitch: bool, killswitch: bool,
@@ -219,6 +243,9 @@ pub fn setup_platform_routing(
Ok(()) Ok(())
} }
/// Откатывает всё, что поставил [`setup_platform_routing`]: удаляет TUN-интерфейс/
/// правила/таблицы и восстанавливает обычную маршрутизацию (на Windows — через
/// DHCP-renew, если был включён kill-switch).
pub fn reset_platform_routing(_proxy_ip: Option<&str>, _was_killswitch: bool) -> io::Result<()> { pub fn reset_platform_routing(_proxy_ip: Option<&str>, _was_killswitch: bool) -> io::Result<()> {
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
{ {
+13
View File
@@ -1,12 +1,22 @@
//! Обёртка над асинхронным TUN-устройством.
//!
//! [`Tun`] инкапсулирует создание/открытие TUN тремя путями: по конфигурации
//! ([`new`](Tun::new)), через билдер-замыкание ([`create`](Tun::create)) или из
//! готового файлового дескриптора ([`from_fd`](Tun::from_fd) — так его передаёт
//! Android `VpnService`). [`split`](Tun::split) разводит устройство на отдельные
//! reader/writer, чтобы читать и писать пакеты из разных задач.
use netrunner_logger::error; use netrunner_logger::error;
use std::io; use std::io;
use tun::{AsyncDevice, Configuration, DeviceReader, DeviceWriter, create_as_async}; use tun::{AsyncDevice, Configuration, DeviceReader, DeviceWriter, create_as_async};
/// Асинхронное TUN-устройство.
pub struct Tun { pub struct Tun {
device: AsyncDevice, device: AsyncDevice,
} }
impl Tun { impl Tun {
/// Создаёт устройство из готовой конфигурации.
pub fn new(config: &Configuration) -> io::Result<Self> { pub fn new(config: &Configuration) -> io::Result<Self> {
match create_as_async(config) { match create_as_async(config) {
Ok(device) => Ok(Self { device }), Ok(device) => Ok(Self { device }),
@@ -17,6 +27,7 @@ impl Tun {
} }
} }
/// Создаёт устройство, настраивая конфигурацию через замыкание-билдер.
pub fn create<F>(f: F) -> io::Result<Self> pub fn create<F>(f: F) -> io::Result<Self>
where where
F: FnOnce(&mut Configuration), F: FnOnce(&mut Configuration),
@@ -26,6 +37,7 @@ impl Tun {
Self::new(&config) Self::new(&config)
} }
/// Открывает устройство из уже существующего fd (передаётся Android `VpnService`).
pub fn from_fd(fd: i32) -> io::Result<Self> { pub fn from_fd(fd: i32) -> io::Result<Self> {
let mut config = Configuration::default(); let mut config = Configuration::default();
config.raw_fd(fd); config.raw_fd(fd);
@@ -34,6 +46,7 @@ impl Tun {
Self::new(&config) Self::new(&config)
} }
/// Разводит устройство на пишущую и читающую половины (для разных задач).
pub fn split(self) -> io::Result<(DeviceWriter, DeviceReader)> { pub fn split(self) -> io::Result<(DeviceWriter, DeviceReader)> {
let (writer, reader) = self.device.split()?; let (writer, reader) = self.device.split()?;
Ok((writer, reader)) Ok((writer, reader))
+30 -340
View File
@@ -1,351 +1,41 @@
# Crypto Module Documentation # Блок `crypto` — криптографический фундамент
## Overview Весь крипто-фундамент протокола. Снаружи (из [`net`](../net)) видны только
`SessionKeys`, `SessionAuth` и потоковый шифр `ChaChaCipher` — остальное детали.
Данный модуль реализует криптографическую основу протокола: > Подробности — в rustdoc: `cargo doc --open -p netrunner-core`, модуль `crypto`.
> Здесь — карта блока и ментальная модель, чтобы быстро сориентироваться.
- согласование ключей (ECDH, X25519) ## Файлы
- derivation ключей (HKDF-SHA256)
- симметричное шифрование (ChaCha20-Poly1305, AEAD)
- аутентификация с плавающим временным окном (HMAC)
Архитектура разделена на независимые компоненты: | Файл | Что внутри |
|---------------|--------------------------------------------------------------------|
| `ecdh.rs` | Эфемерный обмен ключами X25519 (Diffie-Hellman). |
| `hkdf.rs` | Расширение общего секрета в набор ключей (HKDF-SHA256). |
| `session.rs` | Оркестрация: соль → ECDH → HKDF → ключи; time-based auth-теги. |
| `chacha.rs` | Потоковый AEAD ChaCha20-Poly1305 с раздельными nonce по направлениям. |
| `aead.rs` | Трейт `AeadPacker` — абстракция шифра для кодека. |
- `ecdh` — обмен ключами ## Жизненный цикл ключей
- `hkdf` — derivation ключей
- `session` — управление состоянием сессии
- `chacha` — AEAD шифрование
- `aead` — абстракция шифрования
--- ```text
1. SessionKeys::new(is_initiator) → эфемерный X25519 + локальная соль
## Security Model 2. <обмен ClientHello/ServerHello> → узнаём чужой pubkey и соль
3. SessionKeys::update_keys(...) → ECDH → HKDF → 2×(key+iv) + auth_key
Модель безопасности: 4. ChaChaCipher::set_keys(...) → горячий путь: in-place шифр/дешифр
5. SessionAuth (auth_key) → TOTP-подобный тег в каждом кадре
- Forward secrecy обеспечивается за счёт `EphemeralSecret`
- Каждая сессия использует уникальные:
- salt (локальный + удалённый)
- ephemeral key pair
- Ключи разделены по направлениям:
- client → server
- server → client
---
## Key Exchange (ECDH)
### Реализация
Используется:
- X25519 (через `x25519-dalek`)
- Ephemeral ключи
````rust
let secret = EphemeralSecret::random_from_rng(&mut OsRng);
let public = PublicKey::from(&secret);
### Shared Secret
```rust
shared = private.diffie_hellman(remote_public)
````
Особенности:
- приватный ключ используется **один раз** (`take()`)
- после вызова `get_shared()` он уничтожается
Это критично для:
- forward secrecy
- защиты от повторного использования
---
## Salt Mechanism
### SaltPair
Каждая сторона генерирует:
- `local_salt` (32 байта, случайный)
- получает `remote_salt`
Финальный salt:
```
initiator:
total = local || remote
responder:
total = remote || local
``` ```
Это гарантирует: ## Инварианты (НЕ ЛОМАТЬ)
- детерминированность - **Forward secrecy** — приватный ключ X25519 эфемерный и расходуется ровно один
- отсутствие коллизий ролей раз (`ECDH::get_shared` забирает его через `take()`).
- **Уникальность nonce** — `nonce = base_iv XOR counter`, счётчик у каждого
направления свой; повтор nonce при одном ключе ломает шифр.
- **Постоянное время** — `SessionAuth::verify_tag` всегда проходит всё окно
кандидатов без раннего выхода (иначе утечёт тайминг подбора тега).
--- ## Связи
## Key Derivation (HKDF) - Ключи извлекаются из TLS-расширений модуля [`tlseng`](../tlseng) (KeyShare `0x0033`).
- Шифрование вызывается из кодека [`nrxp`](../nrxp) через трейт `AeadPacker`.
Используется:
- HKDF-SHA256
### Extract
```
PRK = HKDF(salt, shared_secret)
```
### Expand
Из PRK генерируются:
| Назначение | Размер |
| --------------- | ------ |
| client_aead key | 32 |
| client_iv | 12 |
| server_aead key | 32 |
| server_iv | 12 |
| auth_key | 32 |
Пример:
```rust
HKDF::expand_key::<32>(&hkdf, b"client_aead")
```
Контекст (`mark`) используется как label.
---
## SessionKeys
Центральная структура управления:
```rust
pub struct SessionKeys {
salt: SaltPair,
ecdh: ECDH,
auth_key: [u8; 32],
current_aead: Option<(...)>,
}
```
### Основные задачи:
1. Принять remote salt
2. Извлечь публичный ключ из TLS extension
3. Выполнить ECDH
4. Сгенерировать ключи
---
## TLS Integration
Ключ извлекается из `ExtensionStack`:
- extension type: `0x0033` (KeyShare)
- поддерживается X25519 (`0x001d`)
### Client / Server различия
**Server:**
- парсит ClientHello
- ищет key share внутри структуры
**Client:**
- читает фиксированное смещение
---
## AEAD Encryption (ChaCha20-Poly1305)
### Используемый алгоритм
- ChaCha20-Poly1305
- 256-bit key
- 96-bit nonce
---
## Nonce Strategy
### NonceState
```rust
nonce = base_iv XOR counter
```
Где:
- `base_iv` — получен из HKDF
- `counter` — 64-bit
Алгоритм:
```rust
for i in 0..8:
iv[i+4] ^= counter[i]
```
Особенности:
- гарантированная уникальность nonce
- отсутствие повторов при корректной работе
---
## Cipher Structure
```rust
pub struct ChaChaCipher {
encrypt_cipher,
decrypt_cipher,
encrypt_state,
decrypt_state,
}
```
Разделение:
- отдельные ключи и nonce для каждого направления
---
## Encryption Flow
```rust
encrypt_in_place(nonce, aad=nonce, data)
```
AAD:
- используется сам nonce
После:
- данные мутируются
- тег добавляется автоматически
---
## Decryption Flow
```rust
decrypt_in_place(nonce, aad=nonce, data)
```
При ошибке:
- ошибка аутентификации
- повреждённые данные
- несинхронный nonce
---
## Authentication Layer
Дополнительный механизм:
### HMAC-SHA256
```rust
tag = HMAC(auth_key, time_step)
```
Где:
```
time_step = unix_time / 60
```
### Проверка
Принимаются значения:
```
[t-2, t-1, t, t+1, t+2]
```
Назначение:
- защита от replay
- tolerance к рассинхрону времени
---
## AeadPacker Trait
Абстракция для шифрования:
```rust
trait AeadPacker {
fn encrypt(...)
fn decrypt(...)
}
```
Позволяет:
- заменить алгоритм
- тестировать разные реализации
---
## Error Handling
Ошибки возникают в случаях:
- неизвестный протокол / extension
- повреждённые данные
- неверный AEAD tag
- отсутствие shared secret
Возвращаются:
- `Result<T, String>`
- `aead::Error`
---
## Important Security Notes
1. Ephemeral ключ используется только один раз
2. Nonce никогда не должен повторяться
3. Salt обязателен с обеих сторон
4. AEAD тег обязателен (встроен)
5. Временные теги не заменяют AEAD
---
## Limitations
- Нет re-keying
- Нет защиты от state desync (nonce)
- Нет replay protection на уровне пакетов (только time-based)
- Жёстко задан HKDF context strings
---
## Summary
Модуль реализует:
- безопасный ECDH handshake
- детерминированный key derivation
- AEAD шифрование с разделением направлений
- дополнительную HMAC-аутентификацию
Подходит для:
- пользовательских протоколов
- туннелей
- транспортных шифрованных каналов
+18
View File
@@ -1,6 +1,24 @@
//! Абстракция AEAD-шифрования над конкретным алгоритмом.
//!
//! Кодек ([`nrxp`](crate::nrxp)) работает не с ChaCha20-Poly1305 напрямую, а через
//! трейт [`AeadPacker`]. Это развязывает горячий путь от выбора шифра: сегодня за
//! трейтом стоит [`ChaChaStream`](super::chacha::ChaChaStream), завтра можно
//! подставить другой AEAD без правок кодека, а в тестах — фейковую реализацию.
use bytes::BytesMut; use bytes::BytesMut;
/// Шифрование «на месте» (in-place) для одного направления потока.
///
/// Обе операции мутируют переданный [`BytesMut`] прямо в его буфере — никаких
/// промежуточных аллокаций и копий (zero-copy на горячем пути):
/// - [`encrypt`](AeadPacker::encrypt) дописывает 16-байтовый тег аутентификации в
/// конец буфера (вызывающий код заранее резервирует под него место);
/// - [`decrypt`](AeadPacker::decrypt) проверяет тег и усекает буфер до открытого
/// текста; при провале проверки возвращает ошибку и данные считаются
/// скомпрометированными (tampering).
pub(crate) trait AeadPacker { pub(crate) trait AeadPacker {
/// Шифрует `data` на месте и дописывает тег аутентификации.
fn encrypt(&mut self, data: &mut BytesMut) -> Result<(), chacha20poly1305::aead::Error>; fn encrypt(&mut self, data: &mut BytesMut) -> Result<(), chacha20poly1305::aead::Error>;
/// Расшифровывает `data` на месте и проверяет тег; `Err` ⇒ tampering.
fn decrypt(&mut self, data: &mut BytesMut) -> Result<(), chacha20poly1305::aead::Error>; fn decrypt(&mut self, data: &mut BytesMut) -> Result<(), chacha20poly1305::aead::Error>;
} }
+41
View File
@@ -1,11 +1,31 @@
//! Потоковое AEAD-шифрование ChaCha20-Poly1305.
//!
//! Это «горячий путь» крипто-блока: через него проходит каждый кадр данных.
//! Ключевые свойства:
//!
//! - **Раздельные направления.** [`ChaChaCipher`] держит два независимых потока —
//! `tx` (исходящий) и `rx` (входящий), у каждого свой ключ, IV и счётчик nonce.
//! - **Детерминированный nonce.** Nonce не передаётся по сети: обе стороны
//! синхронно считают `nonce = base_iv XOR counter` (см. [`NonceState`]).
//! Счётчики растут строго в ногу, поэтому любой пропуск/повтор кадра ломает
//! расшифровку — это и есть встроенная защита целостности потока.
//! - **In-place.** Шифр работает прямо в [`BytesMut`] без копий и аллокаций.
use bytes::BytesMut; use bytes::BytesMut;
use chacha20poly1305::aead::generic_array::GenericArray; use chacha20poly1305::aead::generic_array::GenericArray;
use chacha20poly1305::{AeadInPlace, ChaCha20Poly1305, Key, KeyInit, Nonce}; use chacha20poly1305::{AeadInPlace, ChaCha20Poly1305, Key, KeyInit, Nonce};
use crate::crypto::aead::AeadPacker; use crate::crypto::aead::AeadPacker;
/// Генератор nonce для одного направления.
///
/// Nonce строится как `base_iv XOR big_endian(counter)` по младшим 8 байтам IV.
/// `counter` монотонно растёт на каждый кадр, гарантируя уникальность nonce в
/// пределах ключа (повтор nonce при одном ключе фатален для ChaCha20-Poly1305).
struct NonceState { struct NonceState {
/// Счётчик кадров. Должен совпадать у отправителя и получателя по направлению.
counter: u64, counter: u64,
/// Базовый IV (12 байт), полученный из HKDF; неизменен на всю сессию.
base_iv: [u8; 12], base_iv: [u8; 12],
} }
@@ -17,6 +37,11 @@ impl NonceState {
} }
} }
/// Возвращает nonce для текущего кадра и инкрементирует счётчик.
///
/// XOR накладывается на байты `iv[4..12]` (младшие 8 байт 12-байтового IV),
/// старшие 4 байта остаются «солью» из IV. После вызова `counter`
/// увеличивается, поэтому следующий кадр получит другой nonce.
pub fn next_nonce(&mut self) -> Nonce { pub fn next_nonce(&mut self) -> Nonce {
let mut iv = self.base_iv; let mut iv = self.base_iv;
let counter_bytes = self.counter.to_be_bytes(); let counter_bytes = self.counter.to_be_bytes();
@@ -30,6 +55,9 @@ impl NonceState {
} }
} }
/// Однонаправленный шифр: одна пара (ключ, IV) + её счётчик nonce.
///
/// Реализует [`AeadPacker`]. Используется парами внутри [`ChaChaCipher`].
pub struct ChaChaStream { pub struct ChaChaStream {
cipher: ChaCha20Poly1305, cipher: ChaCha20Poly1305,
state: NonceState, state: NonceState,
@@ -119,12 +147,19 @@ impl AeadPacker for ChaChaStream {
} }
} }
/// Двунаправленный шифр сессии: исходящий (`tx`) и входящий (`rx`) потоки.
///
/// Создаётся «пустым» (нулевые ключи) до завершения хендшейка, затем
/// [`set_keys`](ChaChaCipher::set_keys) заряжает реальные ключи из HKDF.
pub struct ChaChaCipher { pub struct ChaChaCipher {
/// Исходящее направление (шифрование того, что отправляем).
pub tx: ChaChaStream, pub tx: ChaChaStream,
/// Входящее направление (расшифровка того, что приняли).
pub rx: ChaChaStream, pub rx: ChaChaStream,
} }
impl ChaChaCipher { impl ChaChaCipher {
/// Создаёт шифр с нулевыми ключами-заглушками (до хендшейка).
pub fn new() -> Self { pub fn new() -> Self {
Self { Self {
tx: ChaChaStream::new(&[0u8; 32], [0u8; 12]), tx: ChaChaStream::new(&[0u8; 32], [0u8; 12]),
@@ -132,12 +167,18 @@ impl ChaChaCipher {
} }
} }
/// Заряжает реальные ключи/IV после хендшейка: `w_*` — на запись (tx),
/// `r_*` — на чтение (rx). Сбрасывает счётчики nonce в 0 для обоих направлений.
pub fn set_keys(&mut self, w_key: [u8; 32], w_iv: [u8; 12], r_key: [u8; 32], r_iv: [u8; 12]) { pub fn set_keys(&mut self, w_key: [u8; 32], w_iv: [u8; 12], r_key: [u8; 32], r_iv: [u8; 12]) {
self.tx = ChaChaStream::new(&w_key, w_iv); self.tx = ChaChaStream::new(&w_key, w_iv);
self.rx = ChaChaStream::new(&r_key, r_iv); self.rx = ChaChaStream::new(&r_key, r_iv);
netrunner_logger::debug!("Cipher keys and IVs updated for both directions"); netrunner_logger::debug!("Cipher keys and IVs updated for both directions");
} }
/// Разбирает шифр на два независимых потока `(rx, tx)`.
///
/// Нужно, чтобы отдать чтение и запись в разные задачи tokio (reader/writer),
/// не деля шифр под мьютексом — каждое направление владеет своим потоком.
pub fn split(self) -> (ChaChaStream, ChaChaStream) { pub fn split(self) -> (ChaChaStream, ChaChaStream) {
(self.rx, self.tx) (self.rx, self.tx)
} }
+22
View File
@@ -1,11 +1,28 @@
//! Эфемерный обмен ключами по схеме X25519 (Elliptic-Curve Diffie-Hellman).
//!
//! Один экземпляр [`ECDH`] обслуживает ровно один хендшейк: на старте генерится
//! эфемерная пара ключей, публичная половина уходит в `ClientHello`/`ServerHello`,
//! а приватная расходуется один раз при вычислении общего секрета и сразу
//! уничтожается. Это и есть механизм forward secrecy: даже компрометация
//! долговременных секретов в будущем не расшифрует записанный ранее трафик.
use aead::OsRng; use aead::OsRng;
use x25519_dalek::{EphemeralSecret, PublicKey}; use x25519_dalek::{EphemeralSecret, PublicKey};
/// Состояние одной стороны ECDH-обмена.
///
/// `private_key` обёрнут в [`Option`], потому что [`EphemeralSecret`] потребляется
/// при вычислении общего секрета (`diffie_hellman` забирает `self` по значению).
/// После [`ECDH::get_shared`] поле становится `None` и повторный обмен невозможен.
pub(crate) struct ECDH { pub(crate) struct ECDH {
/// Публичный ключ, который отправляется удалённой стороне в KeyShare.
pub public_key: PublicKey, pub public_key: PublicKey,
/// Приватный эфемерный ключ. `Some` до первого `get_shared`, затем `None`.
pub private_key: Option<EphemeralSecret>, pub private_key: Option<EphemeralSecret>,
} }
impl ECDH { impl ECDH {
/// Генерирует свежую эфемерную пару ключей из системного ГСЧ ([`OsRng`]).
pub(crate) fn new() -> Self { pub(crate) fn new() -> Self {
let secret = EphemeralSecret::random_from_rng(&mut OsRng); let secret = EphemeralSecret::random_from_rng(&mut OsRng);
let public = PublicKey::from(&secret); let public = PublicKey::from(&secret);
@@ -15,6 +32,11 @@ impl ECDH {
} }
} }
/// Вычисляет общий секрет с публичным ключом удалённой стороны.
///
/// Приватный ключ **расходуется**: `take()` извлекает его из `Option`, после
/// чего поле остаётся `None`. Возвращает `None`, если метод уже вызывался
/// (т.е. приватного ключа больше нет) — защита от повторного использования.
pub(crate) fn get_shared(&mut self, public: &PublicKey) -> Option<[u8; 32]> { pub(crate) fn get_shared(&mut self, public: &PublicKey) -> Option<[u8; 32]> {
let private_key = self.private_key.take()?; let private_key = self.private_key.take()?;
let shared = private_key.diffie_hellman(&public); let shared = private_key.diffie_hellman(&public);
+24
View File
@@ -1,14 +1,38 @@
//! Расширение ключей по HKDF-SHA256 (RFC 5869).
//!
//! Общий секрет, полученный из [`ECDH`](super::ecdh), сам по себе как ключ не
//! используется. Через HKDF из него детерминированно «разворачивается» несколько
//! независимых ключей под разные цели (см. [`session`](super::session)):
//! AEAD-ключи и IV для каждого направления плюс ключ для time-based аутентификации.
//!
//! Схема двухфазная:
//! - **extract**: `PRK = HKDF-Extract(salt, ikm)` — «сжимает» энтропию секрета;
//! - **expand**: `okm = HKDF-Expand(PRK, label, N)` — выдаёт ключ нужной длины,
//! уникальный для каждого `label` (`mark`).
use hkdf::Hkdf; use hkdf::Hkdf;
use sha2::Sha256; use sha2::Sha256;
/// Безсостоятельная обёртка над `hkdf::Hkdf<Sha256>` с двумя удобными методами.
pub(crate) struct HKDF; pub(crate) struct HKDF;
impl HKDF { impl HKDF {
/// Фаза **extract**: связывает соль и входной материал ключа (`ikm`,
/// общий ECDH-секрет) в псевдослучайный ключ `PRK`.
///
/// Возвращает готовый к фазе expand экстрактор. Соль здесь — это
/// объединённые локальная+удалённая соли сторон (см. `SaltPair::get_total`).
pub(crate) fn extract_key(salt: &[u8], ikm: &[u8]) -> Hkdf<Sha256> { pub(crate) fn extract_key(salt: &[u8], ikm: &[u8]) -> Hkdf<Sha256> {
let extracted_key = Hkdf::<Sha256>::new(Some(salt), ikm); let extracted_key = Hkdf::<Sha256>::new(Some(salt), ikm);
extracted_key extracted_key
} }
/// Фаза **expand**: выводит ключ длины `N` байт под меткой `mark`.
///
/// `mark` (например `b"client_aead"`) играет роль контекстного лейбла:
/// разные метки из одного и того же `PRK` дают криптографически независимые
/// ключи. `N` — параметр-константа, поэтому длина проверяется на этапе
/// компиляции (32 для ключа, 12 для IV и т.п.).
pub(crate) fn expand_key<const N: usize>( pub(crate) fn expand_key<const N: usize>(
extracted_key: &Hkdf<Sha256>, extracted_key: &Hkdf<Sha256>,
mark: &[u8], mark: &[u8],
+36
View File
@@ -1,3 +1,39 @@
//! # Криптографический блок (`crypto`)
//!
//! Весь криптографический фундамент протокола Netrunner. Модуль самодостаточен:
//! снаружи (из [`net`](crate::net)) видны только высокоуровневые сущности
//! [`SessionKeys`], [`SessionAuth`] и потоковый шифр [`ChaChaCipher`].
//!
//! ## Из чего состоит блок
//!
//! | Файл | Ответственность |
//! |--------------|----------------------------------------------------------------------|
//! | [`ecdh`] | Эфемерный обмен ключами X25519 (Diffie-Hellman). |
//! | [`hkdf`] | Расширение общего секрета в набор ключей (HKDF-SHA256). |
//! | [`session`] | Оркестрация хендшейка: соль → ECDH → HKDF → ключи; time-based auth. |
//! | [`chacha`] | Потоковое AEAD-шифрование ChaCha20-Poly1305 с раздельными nonce. |
//! | [`aead`] | Трейт-абстракция [`AeadPacker`] над шифром (для подмены алгоритма). |
//!
//! ## Жизненный цикл ключей (как всё связано)
//!
//! ```text
//! 1. SessionKeys::new(is_initiator) → генерит эфемерный X25519 + локальную соль
//! 2. <обмен ClientHello/ServerHello> → стороны узнают чужой pubkey и соль
//! 3. SessionKeys::update_keys(...) → ECDH → HKDF → 2×(key+iv) + auth_key
//! 4. ChaChaCipher::set_keys(...) → горячий путь: in-place шифр/дешифр
//! 5. SessionAuth (auth_key) → TOTP-подобный тег в каждом кадре
//! ```
//!
//! ## Модель безопасности (инварианты, которые нельзя ломать)
//!
//! - **Forward secrecy.** Приватный ключ X25519 — эфемерный и используется ровно
//! один раз: [`ECDH::get_shared`](ecdh) забирает его через `take()` и уничтожает.
//! - **Уникальность nonce.** Каждое направление имеет свой счётчик; nonce =
//! `base_iv XOR counter`. Повтор nonce при одном ключе ломает ChaCha20-Poly1305.
//! - **Анти-replay по времени.** [`SessionAuth::verify_tag`] сверяет HMAC-тег в
//! окне `±AUTH_WINDOW_SIZE` шагов и работает **в постоянном времени** (не
//! ветвится по факту совпадения) — иначе утечёт тайминг подбора тега.
mod aead; mod aead;
mod chacha; mod chacha;
mod ecdh; mod ecdh;
+84 -2
View File
@@ -1,3 +1,26 @@
//! Оркестрация сессии: от хендшейка до пер-кадровой аутентификации.
//!
//! Файл связывает воедино [`ecdh`](super::ecdh) и [`hkdf`](super::hkdf) и делится
//! на две фазы, отражённые в коде комментариями-разделителями:
//!
//! 1. **Handshake / генерация ключей** ([`SaltPair`], [`SessionKeys`]).
//! Стороны обмениваются солью и публичными ключами X25519 (внутри TLS-кадров
//! `ClientHello`/`ServerHello`), затем независимо выводят один и тот же набор
//! ключей: AEAD-ключ+IV на каждое направление и общий `auth_key`.
//!
//! 2. **Data phase / аутентификация кадров** ([`SessionAuth`]).
//! Лёгкая копируемая структура с одним лишь `auth_key`, которую забирают
//! кодеки. Считает и проверяет TOTP-подобный тег, привязанный ко времени, —
//! защита от replay-атак на уровне DPI.
//!
//! ## Симметрия ролей
//!
//! Чтобы обе стороны вывели идентичные ключи, важен порядок конкатенации соли и
//! назначение направлений. Инициатор (клиент) и ответчик (сервер) собирают
//! «полную соль» в зеркальном порядке (см. [`SaltPair::get_total`]), а в
//! [`SessionKeys::generate_keys`] флаг `is_server` решает, какой из выведенных
//! ключей идёт на tx, а какой на rx.
use netrunner_logger::{AppError, ERR_NET_TLS_TAMPER}; use netrunner_logger::{AppError, ERR_NET_TLS_TAMPER};
use x25519_dalek::PublicKey; use x25519_dalek::PublicKey;
@@ -18,9 +41,18 @@ use aead::{rand_core::RngCore, OsRng};
// 1. HANDSHAKE (Генерация ключей) // 1. HANDSHAKE (Генерация ключей)
// ========================================== // ==========================================
/// Пара солей сторон, используемая как salt в HKDF-Extract.
///
/// Каждая сторона генерирует случайную локальную соль и узнаёт удалённую из
/// хендшейка. Итоговая соль детерминированно собирается из обеих половин в
/// порядке, зависящем от роли (см. [`get_total`](SaltPair::get_total)), так что
/// клиент и сервер приходят к одному значению.
pub(crate) struct SaltPair { pub(crate) struct SaltPair {
/// Своя случайная соль (32 байта), уходит удалённой стороне.
local_salt: [u8; 32], local_salt: [u8; 32],
/// Соль удалённой стороны; нули до вызова [`set_remote_salt`](SaltPair::set_remote_salt).
remote_salt: [u8; 32], remote_salt: [u8; 32],
/// Роль: `true` у инициатора (клиента) — определяет порядок конкатенации.
is_initiator: bool, is_initiator: bool,
} }
@@ -43,6 +75,11 @@ impl SaltPair {
self.remote_salt = salt self.remote_salt = salt
} }
/// Собирает 64-байтовую «полную соль» для HKDF.
///
/// Порядок зеркальный по ролям: инициатор кладёт `local || remote`, ответчик —
/// `remote || local`. Благодаря этому обе стороны получают **одинаковый**
/// буфер соли, хотя «локальное» и «удалённое» у них поменяны местами.
pub(crate) fn get_total(&self) -> [u8; 64] { pub(crate) fn get_total(&self) -> [u8; 64] {
let mut salt = [0u8; 64]; let mut salt = [0u8; 64];
if self.is_initiator { if self.is_initiator {
@@ -57,10 +94,20 @@ impl SaltPair {
} }
} }
/// Полное состояние криптографического хендшейка одной стороны.
///
/// Держит свою соль, эфемерный ECDH и — после [`update_keys`](SessionKeys::update_keys) —
/// выведенные ключи. Кортеж `current_aead` упакован как
/// `(tx_key, tx_iv, rx_key, rx_iv)` уже с учётом роли: его можно напрямую отдать
/// в [`ChaChaCipher::set_keys`](super::chacha::ChaChaCipher::set_keys).
pub struct SessionKeys { pub struct SessionKeys {
/// Пара солей (локальная + удалённая) для HKDF.
salt: SaltPair, salt: SaltPair,
/// Эфемерный ключ X25519; расходуется при выводе общего секрета.
ecdh: ECDH, ecdh: ECDH,
/// Ключ для time-based аутентификации кадров (HMAC). Заполняется в HKDF-фазе.
auth_key: [u8; 32], auth_key: [u8; 32],
/// Выведенные AEAD-параметры `(tx_key, tx_iv, rx_key, rx_iv)`; `None` до хендшейка.
current_aead: Option<([u8; 32], [u8; 12], [u8; 32], [u8; 12])>, current_aead: Option<([u8; 32], [u8; 12], [u8; 32], [u8; 12])>,
} }
@@ -83,6 +130,15 @@ impl SessionKeys {
self.auth_key self.auth_key
} }
/// Завершает хендшейк: принимает удалённую соль и публичный ключ из
/// TLS-расширений, выводит все ключи сессии.
///
/// Публичный ключ X25519 извлекается из расширения KeyShare (`0x0033`).
/// Парсинг асимметричен: у сервера (`is_server`) `ClientHello` содержит список
/// именованных групп, поэтому ключ ищется по маркеру `00 1d 00 20` (X25519,
/// 32 байта); у клиента `ServerHello` отдаёт ровно один ключ по фиксированному
/// смещению. Любая аномалия (короткий буфер, нет KeyShare, нулевой ключ)
/// трактуется как [`ERR_NET_TLS_TAMPER`] — признак вмешательства/несовместимости.
pub(crate) fn update_keys( pub(crate) fn update_keys(
&mut self, &mut self,
salt: [u8; 32], salt: [u8; 32],
@@ -158,6 +214,13 @@ impl SessionKeys {
} }
} }
/// Низкоуровневый вывод ключей: ECDH → HKDF-Extract → пять HKDF-Expand.
///
/// Из общего секрета и полной соли выводятся пять значений по фиксированным
/// лейблам (`client_aead`, `client_iv`, `server_aead`, `server_iv`, `auth_key`).
/// Затем `is_server` назначает направления: для сервера tx=`server_*`,
/// rx=`client_*`, для клиента — наоборот. Так одна и та же пара ключей
/// у клиента служит на запись, а у сервера — на чтение, и наоборот.
fn generate_keys( fn generate_keys(
&mut self, &mut self,
public_key: &PublicKey, public_key: &PublicKey,
@@ -209,8 +272,14 @@ impl SessionKeys {
// 2. DATA PHASE (Авторизация Кодека) // 2. DATA PHASE (Авторизация Кодека)
// ========================================== // ==========================================
/// Легковесная структура, которая передается в RxCodec и TxCodec /// Лёгкий копируемый «аутентификатор кадров», который забирают `RxCodec`/`TxCodec`
/// после завершения Handshake. /// после хендшейка.
///
/// Реализует TOTP-подобную схему: тег кадра = первые 16 байт
/// `HMAC-SHA256(auth_key, current_time_step)`, где `step = unix_secs /
/// AUTH_TIME_STEP`. Тег меняется каждые `AUTH_TIME_STEP` секунд, поэтому
/// записанный ранее DPI-перехват нельзя «переиграть» позже — окно валидности
/// уезжает. Допуск на рассинхрон часов задаётся `AUTH_WINDOW_SIZE`.
#[derive(Clone, Copy)] #[derive(Clone, Copy)]
pub struct SessionAuth { pub struct SessionAuth {
auth_key: [u8; 32], auth_key: [u8; 32],
@@ -221,6 +290,9 @@ impl SessionAuth {
Self { auth_key } Self { auth_key }
} }
/// Чистая функция: тег для конкретного временного шага `step`.
///
/// Вынесена отдельно, чтобы и генерация, и проверка считали тег одинаково.
pub fn compute_tag(secret: &[u8], step: u64) -> [u8; 16] { pub fn compute_tag(secret: &[u8], step: u64) -> [u8; 16] {
let mut mac = HmacSha256::new_from_slice(secret).expect("HMAC error"); let mut mac = HmacSha256::new_from_slice(secret).expect("HMAC error");
mac.update(&step.to_be_bytes()); mac.update(&step.to_be_bytes());
@@ -230,6 +302,7 @@ impl SessionAuth {
tag tag
} }
/// Тег для текущего момента времени — кладётся в исходящий кадр.
pub fn generate_current_tag(&self) -> [u8; 16] { pub fn generate_current_tag(&self) -> [u8; 16] {
let now = std::time::SystemTime::now() let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH) .duration_since(std::time::UNIX_EPOCH)
@@ -243,6 +316,15 @@ impl SessionAuth {
Self::compute_tag(&self.auth_key, now / AUTH_TIME_STEP) Self::compute_tag(&self.auth_key, now / AUTH_TIME_STEP)
} }
/// Проверяет тег входящего кадра против окна `[step-W .. step+W]`.
///
/// # Инвариант безопасности (НЕ ЛОМАТЬ)
///
/// Цикл **всегда** прогоняет все `2*AUTH_WINDOW_SIZE + 1` кандидатов и
/// сравнивает теги побайтово через накопление `diff |= a ^ b`, без раннего
/// `break` и без ветвления по результату внутри цикла. Это постоянное по
/// времени сравнение: длительность `verify_tag` не зависит от того, какой шаг
/// (и совпал ли вообще) подошёл, иначе по таймингу можно подбирать тег.
pub fn verify_tag(&self, received_tag: &[u8; 16]) -> bool { pub fn verify_tag(&self, received_tag: &[u8; 16]) -> bool {
let now = std::time::SystemTime::now() let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH) .duration_since(std::time::UNIX_EPOCH)
+80
View File
@@ -1,6 +1,86 @@
//! # netrunner-core — ядро системы обхода сетевых ограничений
//!
//! Платформо-независимая Rust-библиотека, в которой живёт вся логика протокола,
//! криптографии, маскировки и мультиплексирования. Клиент ([`netrunner-client`])
//! и сервер ([`netrunner-server`]) — это лишь тонкие обвязки вокруг этого ядра.
//!
//! ## Карта блоков (и как они вложены)
//!
//! ```text
//! ┌─────────────────────────── net ───────────────────────────┐
//! │ оркестровка туннеля: listener, muxer, engine, bridge, │
//! │ handler, config, diagnostics │
//! │ │
//! │ использует ↓ │
//! │ ┌── nrxp ──┐ ┌── tlseng ──┐ ┌── crypto ──┐ │
//! │ │ кадры, │ │ TLS-маски- │ │ ECDH/HKDF/ │ │
//! │ │ кодек, │←→│ ровка, │ │ ChaCha20, │ │
//! │ │ TLS-мост │ │ отпечатки │ │ auth-теги │ │
//! │ └──────────┘ └────────────┘ └────────────┘ │
//! └────────────────────────────────────────────────────────────┘
//! rawcast — мост «локальный сокет ⇄ кадр» (альтернативный вход)
//! parser — общий трейт инкрементального разбора
//! utils — мелочёвка (u24)
//! ```
//!
//! | Блок | Что внутри |
//! |---------------------|------------------------------------------------------------------|
//! | [`net`] | Сетевое ядро: туннель, мультиплексор, мосты, диагностика. |
//! | [`nrxp`] | Протокол NRXP: формат кадра, шифрующий кодек, TLS-обёртка. |
//! | [`crypto`] | Криптофундамент: обмен ключами, вывод ключей, AEAD, auth-теги. |
//! | [`tlseng`] | Маскировка под браузерный TLS (JA3/JA4 fingerprinting). |
//! | [`rawcast`] | Локальный протокол сокет⇄кадр (вход не из TUN). |
//! | [`parser`] | Трейт [`Parser`](parser::Parser) — единый стиль разбора буферов. |
//! | [`utils`] | Вспомогательные типы (24-битные целые). |
//!
//! Видимость: наружу крейта экспортируются только [`net`], [`nrxp`], [`rawcast`]
//! и [`parser`]; [`crypto`] и [`tlseng`] — внутренние детали (`mod` без `pub`).
//!
//! ## С чего начать чтение
//!
//! Снизу вверх: [`crypto`] → [`nrxp`] (как кадр шифруется) → [`tlseng`] (как он
//! маскируется) → [`net`] (как всё это оркестрируется в живой туннель).
// Workaround for rustc 1.94 ICE in check_mod_deathness (dead-code MIR pass). // Workaround for rustc 1.94 ICE in check_mod_deathness (dead-code MIR pass).
#![allow(dead_code)] #![allow(dead_code)]
/// Версия протокола хендшейка, которую заявляет о себе клиент — кладёт её в
/// младший байт `session_id` своего `ClientHello`
/// (см. `tlseng::ClientHello::make_client_hello`); сервер читает её
/// (`TlsBridge::wrap_server_hello` возвращает наряду с готовым `ServerHello`)
/// и решает, какое поведение включать для конкретного клиента.
///
/// Это НЕ версия крипто-протокола — обмен ключами (X25519/HKDF/ChaCha20) не
/// зависит от неё и не меняется. Это версия «протокольного шума»: вещей вроде
/// middlebox-compat `ChangeCipherSpec` (см. [`MIN_VERSION_FOR_CCS`]), которые
/// старый пир молча не поймёт и из-за которых соединение порвётся, если
/// включить их безусловно для вообще всех клиентов сразу.
///
/// При следующем подобном несовместимом изменении: бампнуть эту константу и
/// завести для изменения отдельный `MIN_VERSION_FOR_*`, проверяемый по
/// заявленной версии клиента — тогда апгрейд серверных нод и апгрейд
/// клиентских сборок можно катить независимо, не единым синхронным релизом
/// (в отличие от того, как пришлось вводить сам CCS в этом патче — на момент
/// его добавления версионирования в протоколе ещё не было).
pub(crate) const PROTOCOL_VERSION: u8 = 1;
/// Минимальная заявленная версия клиента, начиная с которой сервер включает
/// обмен фиктивным `ChangeCipherSpec` (вставляет его после своего `ServerHello`
/// и ожидает такой же от клиента после его `ClientHello`) — см.
/// `nrxp::TlsBridge::build_middlebox_ccs`. Клиенты со старой версией
/// продолжают получать досемверсионное поведение — без CCS в обе стороны.
///
/// ВАЖНО при следующем бампе [`PROTOCOL_VERSION`]: это защищает только от
/// сценария «старый клиент → новая нода» (сервер знает версию клиента и не
/// станет требовать/слать CCS туда, где его не поймут). Обратный сценарий —
/// «новый клиент → ещё не обновлённая нода» при поэтапном (не одномоментном)
/// обновлении парка нод — этой константой не закрыт: клиент сейчас безусловно
/// ждёт CCS от сервера ([`net::ClientHandler::perform_handshake`]) и не знает
/// заранее версию ноды. Если апгрейды нод не гарантированно атомарны, клиенту
/// тоже нужно будет сделать ожидание CCS терпимым (короткий таймаут вместо
/// ошибки), а не полагаться только на этот байт.
pub(crate) const MIN_VERSION_FOR_CCS: u8 = 1;
mod crypto; mod crypto;
pub mod net; pub mod net;
pub mod nrxp; pub mod nrxp;
+64 -448
View File
@@ -1,448 +1,64 @@
# Network Module Documentation # Блок `net` — сетевое ядро и оркестровка туннеля
## Overview Самый верхний блок крейта: здесь синхронный разбор протокола ([`nrxp`](../nrxp)) и
асинхронная сеть `tokio` соединяются в работающий мультиплексированный туннель.
Данный модуль реализует сетевую часть VPN ядра: Всё, что ниже — крипта, кадры, маскировка — лишь «кирпичи», а этот блок строит из
них дом: слушает соединения, держит ноги туннеля, балансирует потоки и проксирует
- входные TCP соединения (SOCKS / туннель) трафик к целям.
- мультиплексирование потоков поверх одного соединения
- транспорт кадров (Frame) > Детали — в rustdoc, модуль `net`.
- проксирование TCP/UDP
- управление туннелем (TunnelEngine) ## Файлы
Архитектура: | Файл/подмодуль | Что внутри |
|------------------|-----------------------------------------------------------------|
- network — точка входа (listener + режим) | `config.rs` | `NetworkConfig` — MTU-зависимые размеры буферов и каналов. |
- connection — логика соединений | `constants.rs` | Тайм-ауты, лимиты, порты, анти-bufferbloat тюнинг. |
- muxer — мультиплексирование потоков | `diagnostics.rs` | События, метрики, снапшоты состояния туннеля. |
- handler — обработка входящих фреймов | `connection/` | Ядро: соединения, мультиплексор, движок, мосты, хендлеры. |
- engine — основной цикл туннеля
- bridge — проксирование сокетов ### Подмодуль `connection/`
--- | Файл | Роль |
|-----------------|-----------------------------------------------------------------|
## High-Level Architecture | `muxer.rs` | Мультиплексор: реестр потоков/ног, балансировка, failover. |
| `engine.rs` | `TunnelEngine`: reader/writer-задачи одной ноги, heartbeat, реконнект. |
Система работает по схеме: | `connection.rs` | Точки входа: `ClientHandler`, `ServerHandler`, `SessionManager`. |
| `handler.rs` | Диспетчеризация входящих кадров по типу/`stream_id`. |
Client (SOCKS) | `bridge.rs` | Проксирование TCP/UDP между потоком туннеля и целью. |
→ Muxer
→ TunnelEngine (шифрованный канал) ## Ключевая идея
→ Server
→ StreamHandler **Одно TCP-соединение (нога) = много логических потоков (`stream_id`).** Ради
→ TCP/UDP target устойчивости ног несколько (`MAX_TUNNEL_LEGS`); поток «прилипает» к ноге, а при её
падении бесшовно переезжает на соседнюю.
И обратно.
## Сквозной поток данных
Ключевая идея:
ОДНО TCP соединение = МНОГО логических потоков (stream_id) ```text
Клиент: браузер → SOCKS/rawcast → ClientHandler → Muxer → TunnelEngine → шифр TCP
--- Сервер: шифр TCP → TunnelEngine → StreamHandler → bridge → TCP/UDP цель
... и обратно тем же путём
## Network ```
### Назначение ## Что делает этот блок устойчивым (мотивация констант)
Точка входа: - **Anti-domino failover** (`muxer::send_to_network`) — падение ноги не закрывает
поток: дохлая нога эвиктится, кадр переотправляется на соседнюю.
- запускает TCP listener - **Graceful pause** (`bridge::run_tcp_bridge` на сервере, тот же паттерн в
- определяет режим (Client / Server) `ClientHandler::connect` на клиенте) — если легли все ноги, TCP-чанк держится
- управляет lifecycle и ретраится (`STREAM_PAUSE_BUDGET`), а не рвёт/теряет данные аплоада.
- **Anti-bufferbloat** — маленькая ёмкость каналов, адаптивные по RTT таймауты и
### Режимы размер батча, неблокирующая раздача `dispatch_to_local`.
- **Stealth-fallback** (`ServerHandler`) — «не наш» клиент прозрачно проксируется
#### Client на `decoy_host:443`, маскируясь под обычный TLS. `decoy_host` — атрибут ноды
(`--decoy-host` у сервера, [`DEFAULT_DECOY_HOST`] по умолчанию), не константа.
- Подключается к удалённому proxy
- Поднимает SOCKS сервер Почти каждая константа в `constants.rs` снабжена `///` с объяснением **почему**
- Каждый входящий клиент → новый stream именно это число — читайте обоснование рядом, прежде чем менять.
#### Server ## Связи
- Принимает туннельные соединения Использует [`nrxp`](../nrxp) (кадры/кодек), [`tlseng`](../tlseng) (хендшейк),
- Обрабатывает handshake [`crypto`](../crypto) (косвенно через кодек) и [`rawcast`](../rawcast) (вход с
- Создаёт TunnelEngine локальной стороны клиента).
---
## NetworkConfig
Глобальная конфигурация (OnceLock)
### Основные параметры
- mtu — системный MTU
- max_wire_frame_size — MTU минус overhead
- safe_payload_size — безопасный payload
- tcp_buffer_size — буфер TCP
- udp_buffer_size — буфер UDP
- channel_capacity — размер каналов
### Расчёт
safe_payload = MTU
- IPv4/UDP overhead (~28)
- frame header
- padding
Цель:
- не фрагментировать пакеты
- держаться в ~1400 байт
---
## Connection
Обёртка над TCP соединением
### Содержит:
- inbound (read half)
- outbound (write half)
- read buffer
- codec (шифрование + framing)
### Основные функции
#### read_socks_request
- читает из буфера
- использует Parser trait
- ждёт пока данных достаточно
#### send_socks_reply
- сериализует ответ
- отправляет клиенту
---
## ClientHandler
### Назначение
- реализует SOCKS5 сервер
- преобразует запросы в multiplexed streams
### Flow
1. SOCKS handshake
2. SOCKS request (CONNECT / UDP)
3. Создание stream_id
4. Регистрация канала
5. Отправка FrameType::Connect
6. Ожидание ответа от сервера
7. Запуск bridge
---
## ServerHandler
### Назначение
- принимает туннель
- делает TLS-like handshake
- запускает TunnelEngine
### Особенность: Stealth Fallback
Если:
- handshake не прошёл
- таймаут
→ соединение проксируется как обычный TLS (на ubuntu.com:443)
Это:
- маскирует трафик
- снижает вероятность блокировки
---
## Muxer
### Назначение
Мультиплексирование потоков
### Компоненты
- control_tx — управляющие сообщения
- data_tx — данные
- streams — map stream_id → channel
- id_gen — генератор ID
---
## Stream ID
Генерация:
- клиент: 1, 3, 5, ...
- сервер: 2, 4, 6, ...
Это гарантирует:
- отсутствие коллизий
- определение стороны
---
## MuxMessage
Структура:
- stream_id
- frame_type
- data
---
## Отправка данных
### send_data_safe
Если payload большой:
- режется на куски (~1300 байт)
- отправляется последовательно
Причина:
- не превышать MTU
- избежать фрагментации
---
## StreamHandler
### Назначение
Обработка входящих Frame
### Типы Frame
- Connect
- UdpConnect
- Data
- UdpData
- Close
---
## TCP Flow (Server)
1. Получен Connect
2. Парсится target
3. Открывается TCP соединение
4. Отправляется SOCKS reply
5. Запускается run_tcp_bridge
---
## UDP Flow
1. bind UDP socket
2. connect(target)
3. запуск run_udp_bridge
---
## TunnelEngine
### Центральный компонент
Отвечает за:
- чтение из сети
- декодирование
- dispatch
- шифрование
- запись
---
## Reader Task
Цикл:
1. read_buf
2. codec.inbound()
3. извлечение frame
4. handler.handle(frame)
### Ошибки
- Wait → ждём данных
- Drop → критическая ошибка (tampering)
---
## Writer Task
Обрабатывает:
- control сообщения (приоритет)
- heartbeat
- data сообщения
### Heartbeat
Каждые 15 секунд:
FrameType::Heartbeat
---
## handle_outbound
Flow:
1. chunking (4KB)
2. encrypt_data()
3. write_all()
---
## Bridge Layer
### run_tcp_bridge
Два направления:
1. socket → muxer
2. muxer → socket
Особенности:
- неблокирующий select
- graceful close
- отправка Close frame
---
### run_udp_bridge
Аналог TCP, но:
- recv/send
- без stream (datagram)
---
## Поток данных (End-to-End)
Client:
browser → SOCKS → ClientHandler
→ Muxer → TunnelEngine
→ encrypted TCP
Server:
→ TunnelEngine → StreamHandler
→ TCP connect → target
Ответ обратно тем же путём.
---
## Error Handling
Критические ошибки:
- codec Drop → разрыв туннеля
- write error → закрытие stream
- read EOF → завершение
Некритические:
- закрыт канал → удаление stream
- неизвестный stream → игнор
---
## Concurrency Model
Используется:
- tokio tasks
- mpsc каналы
- Arc + DashMap
Параллелизм:
- каждый stream независим
- reader / writer разделены
---
## Design Decisions
### 1. Один туннель
Плюсы:
- меньше соединений
- проще маскировка
Минусы:
- single point of failure
---
### 2. Multiplexing
Плюсы:
- высокая эффективность
- меньше latency
---
### 3. Chunking
Причины:
- MTU ограничения
- стабильность
---
### 4. Stealth fallback
Критично для:
- обход DPI
- маскировка под TLS
---
## Limitations
- Нет QoS между потоками
- Нет backpressure контроля
- Нет retransmission (TCP полагается на underlying)
- Нет stream prioritization
- UDP без гарантии доставки
---
## Summary
Модуль реализует:
- TCP listener (client/server)
- multiplexed туннель
- безопасную передачу данных
- проксирование TCP/UDP
- fallback механизм маскировки
Это полноценное сетевое ядро VPN:
- scalable
- асинхронное
- расширяемое
+37
View File
@@ -0,0 +1,37 @@
//! Абстракция проверки клиентского токена и учёта расхода трафика на
//! control-plane бэкенде. Ядро знает только этот трейт — конкретную реализацию
//! (HTTP-клиент к `netrunner-backend`) даёт связывающий бинарь (`netrunner-server`),
//! чтобы ядро не тянуло HTTP-клиент как обязательную зависимость.
//!
//! Включается/выключается на инстанс целиком через `--require-auth` (см.
//! `server/src/main.rs`): если сервер запущен без флага, [`ServerHandler`]
//! вообще не спрашивает валидатор, и поведение не отличается от того, что
//! было до этой фичи.
use async_trait::async_trait;
use netrunner_logger::AppError;
/// Результат успешной проверки токена клиента.
#[derive(Debug, Clone)]
pub struct UserQuota {
pub user_id: String,
/// `None` — безлимит.
pub limit_bytes: Option<u64>,
pub used_bytes: u64,
}
/// Ответ на отчёт о расходе трафика.
#[derive(Debug, Clone)]
pub struct UsageReport {
pub used_bytes: u64,
pub limit_bytes: Option<u64>,
pub over_limit: bool,
}
#[async_trait]
pub trait AuthValidator: Send + Sync {
/// Проверяет Bearer-токен клиента (JWT, выданный бэкендом при логине).
async fn validate(&self, token: &str) -> Result<UserQuota, AppError>;
/// Отчитывается о переданных байтах и синхронно узнаёт, не превышен ли лимит.
async fn report_usage(&self, user_id: &str, delta_bytes: u64) -> Result<UsageReport, AppError>;
}
+43 -11
View File
@@ -1,28 +1,56 @@
//! Глобальная сетевая конфигурация, выводимая из MTU.
//!
//! Все размеры буферов и ёмкости каналов считаются один раз из системного MTU
//! ([`NetworkConfig::new`]) и кладутся в глобальный [`OnceLock`]. Логика проста:
//! буферы TCP-сокетов smoltcp масштабируются так, чтобы окно вмещало нужное число
//! сегментов подряд, а ёмкость mpsc-каналов держится **намеренно маленькой** —
//! это главный рычаг против bufferbloat (см. комментарий к `CHANNEL_PACKETS`).
//!
//! Деление буферов на `heavy`/`light` — это «толстые» потоки (bulk download) против
//! «тонких» (DNS, интерактив): первым нужен большой буфер для throughput, вторым —
//! маленький для низкой задержки.
use netrunner_logger::warn; use netrunner_logger::warn;
use std::sync::OnceLock; use std::sync::OnceLock;
/// Глобально инициализируемая сетевая конфигурация (одна на процесс).
pub static GLOBAL_NET_CONFIG: OnceLock<NetworkConfig> = OnceLock::new(); pub static GLOBAL_NET_CONFIG: OnceLock<NetworkConfig> = OnceLock::new();
/// Набор размеров буферов и каналов, выведенных из MTU.
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct NetworkConfig { pub struct NetworkConfig {
/// Эффективный MTU (не ниже 576).
pub mtu: usize, pub mtu: usize,
/// Размер буфера чтения соединения туннеля.
pub connection_buf_size: usize, pub connection_buf_size: usize,
/// Базовый размер TCP-буфера (для «толстых» потоков).
pub tcp_buffer_size: usize, pub tcp_buffer_size: usize,
/// Базовый размер UDP-буфера.
pub udp_buffer_size: usize, pub udp_buffer_size: usize,
/// Сколько байт читать из локального TCP-сокета за один проход.
pub tcp_chunk_size: usize, pub tcp_chunk_size: usize,
// 🔥 Единый конфиг для всех каналов Tokio /// Единая ёмкость всех Tokio-каналов (в пакетах). Маленькая — против bufferbloat.
pub channel_capacity: usize, pub channel_capacity: usize,
// Буферы сокетов smoltcp // ── Буферы TCP-сокетов smoltcp: heavy (bulk) и light (интерактив) ──
/// RX-буфер «толстого» TCP-сокета.
pub tcp_rx_heavy: usize, pub tcp_rx_heavy: usize,
/// TX-буфер «толстого» TCP-сокета.
pub tcp_tx_heavy: usize, pub tcp_tx_heavy: usize,
/// RX-буфер «тонкого» TCP-сокета.
pub tcp_rx_light: usize, pub tcp_rx_light: usize,
/// TX-буфер «тонкого» TCP-сокета.
pub tcp_tx_light: usize, pub tcp_tx_light: usize,
// ── Буферы UDP-сокетов smoltcp: данные + слоты метаданных датаграмм ──
/// Буфер данных «толстого» UDP-сокета.
pub udp_buf_heavy: usize, pub udp_buf_heavy: usize,
/// Слотов метаданных датаграмм у «толстого» UDP-сокета.
pub udp_meta_heavy: usize, pub udp_meta_heavy: usize,
/// Буфер данных «тонкого» UDP-сокета.
pub udp_buf_light: usize, pub udp_buf_light: usize,
/// Слотов метаданных датаграмм у «тонкого» UDP-сокета.
pub udp_meta_light: usize, pub udp_meta_light: usize,
} }
@@ -42,15 +70,15 @@ impl NetworkConfig {
// How many messages the Tokio mpsc channels hold. // How many messages the Tokio mpsc channels hold.
// //
// 🔥 ANTI-BUFFERBLOAT: this is the dominant app-layer queue on every // 🔥 ANTI-BUFFERBLOAT vs HIGH-RTT THROUGHPUT TRADE-OFF:
// tunnel leg. A single server→leg data message can be up to one read // At low RTT (50 ms), 16 slots = ~3 MB queue drains fast. At high RTT
// buffer (~180 KB), so 128 slots meant up to ~23 MB of in-flight data // (300+ ms), BDP = 300 Mbps × 0.35s ≈ 13 MB required for full throughput.
// QUEUED per leg. After a speedtest that reservoir is full of data for // Increased to 64: provides ~11 MB per leg (64 × ~180 KB), matching BDP
// streams the app already closed; the downlink wastes seconds draining // at high RTT while still preventing pathological post-speedtest queuing.
// it (observed: mux_dispatch no_stream ≫ ok, RTT → 1.3 s, tunnel "dies"). // Anti-bufferbloat protection remains via per-stream dispatch backpressure
// 16 slots bounds the per-leg queue ~8× lower so it drains in ~1 s and // and read-chunk sizing in dispatch_to_local (byte-bounded backlog closes
// RTT stays low, while still keeping the writer fed for full throughput. // genuinely stalled streams — see STREAM_BACKLOG_MAX_BYTES).
const CHANNEL_PACKETS: usize = 16; const CHANNEL_PACKETS: usize = 64;
// Payload bytes per segment (no IP/TCP headers in the smoltcp buffer). // Payload bytes per segment (no IP/TCP headers in the smoltcp buffer).
let seg = mtu.saturating_sub(40).max(512); // subtract typical IP+TCP overhead let seg = mtu.saturating_sub(40).max(512); // subtract typical IP+TCP overhead
@@ -84,6 +112,8 @@ impl NetworkConfig {
} }
} }
/// Инициализирует глобальный конфиг из MTU. Повторный вызов безвреден, но
/// логирует предупреждение (конфиг неизменяем после первой установки).
pub fn init_global(system_mtu: usize) { pub fn init_global(system_mtu: usize) {
let config = Self::new(system_mtu); let config = Self::new(system_mtu);
if GLOBAL_NET_CONFIG.set(config).is_err() { if GLOBAL_NET_CONFIG.set(config).is_err() {
@@ -91,6 +121,8 @@ impl NetworkConfig {
} }
} }
/// Доступ к глобальному конфигу. Паникует, если [`init_global`](Self::init_global)
/// ещё не вызывали — это ошибка порядка инициализации, а не рантайм-ситуация.
pub fn global() -> &'static Self { pub fn global() -> &'static Self {
GLOBAL_NET_CONFIG GLOBAL_NET_CONFIG
.get() .get()
+43
View File
@@ -1,3 +1,22 @@
//! Мосты: перекачка данных между логическим потоком туннеля и реальным сокетом.
//!
//! Когда сервер открыл соединение к цели, его обслуживает один из мостов:
//! [`run_tcp_bridge`] или [`run_udp_bridge`]. Каждый качает данные в обе стороны:
//!
//! - **upload** (интернет → туннель): читает из локального сокета и шлёт в muxer;
//! - **download** (туннель → интернет): принимает из канала потока (`v_rx`) и
//! пишет в локальный сокет.
//!
//! Ключевые свойства устойчивости (детали — в inline-комментариях):
//! - **Graceful pause (anti-domino).** Если в upload все ноги одновременно легли,
//! мост НЕ закрывается: чанк удерживается и переотправляется в пределах
//! [`STREAM_PAUSE_BUDGET`]. Пока мы не читаем дальше — работает TCP
//! backpressure, источник сам притормаживает, данные не теряются.
//! - **Адаптивный write-timeout в download.** Медленный локальный сокет под
//! высоким RTT получает больше времени на слив, прежде чем поток закроют.
//! - **Гарантированная уборка.** [`StreamGuard`] на `Drop` снимает регистрацию
//! потока в muxer — что бы ни завершило мост.
use std::sync::Arc; use std::sync::Arc;
use crate::net::connection::muxer::{adaptive_write_timeout, Muxer}; use crate::net::connection::muxer::{adaptive_write_timeout, Muxer};
@@ -13,6 +32,8 @@ use tokio::sync::mpsc;
use tokio::time::timeout; use tokio::time::timeout;
use tokio_util::sync::CancellationToken; use tokio_util::sync::CancellationToken;
/// RAII-страж: при выходе из моста (любым путём) снимает регистрацию потока,
/// гарантируя, что в muxer не останется «зомби»-записи.
struct StreamGuard { struct StreamGuard {
stream_id: u32, stream_id: u32,
muxer: Arc<Muxer>, muxer: Arc<Muxer>,
@@ -24,6 +45,11 @@ impl Drop for StreamGuard {
self.muxer.remove_stream(self.stream_id); self.muxer.remove_stream(self.stream_id);
} }
} }
/// TCP-мост: гоняет данные между потоком туннеля и TCP-сокетом цели.
///
/// upload и download крутятся конкурентно; завершение любой половины через
/// общий [`CancellationToken`] немедленно гасит вторую и запускает уборку.
pub(crate) async fn run_tcp_bridge<R, W>( pub(crate) async fn run_tcp_bridge<R, W>(
stream_id: u32, stream_id: u32,
reader: R, reader: R,
@@ -56,6 +82,17 @@ pub(crate) async fn run_tcp_bridge<R, W>(
if buf.capacity() - buf.len() < BRIDGE_READ_CHUNK { if buf.capacity() - buf.len() < BRIDGE_READ_CHUNK {
buf.reserve(BRIDGE_READ_CHUNK); buf.reserve(BRIDGE_READ_CHUNK);
} }
// 🔥 NOT credit-gated (tried, reverted): the local mpsc channel
// backpressure below (`data_tx.send().await` inside
// `send_data_safe`) already throttles this read loop to match the
// leg's real drain rate — that signal is local (sub-ms). Gating
// reads on a `Credit` frame instead ties pacing to a full network
// round-trip: every time the window ran dry the reader had to wait
// out (a fraction of) an RTT before resuming, producing exactly the
// burst-then-stall pattern users saw as jerky downloads plus
// jitter/ping spikes on the same physical leg. See Muxer::consume_credit
// for the (currently unused) machinery, kept for a possible future
// redesign with a much more generous, non-binding window.
tokio::select! { tokio::select! {
biased; biased;
_ = token.cancelled() => break, _ = token.cancelled() => break,
@@ -140,6 +177,12 @@ pub(crate) async fn run_tcp_bridge<R, W>(
} }
token.cancel(); token.cancel();
} }
/// UDP-мост: аналог TCP, но датаграммами и в одном `select`-цикле.
///
/// Отличия от TCP: нет потокового упорядочивания (датаграммы), есть idle-таймаут
/// ([`BRIDGE_IDLE_TIMEOUT`]) на закрытие неактивной сессии, и приём идёт zero-copy
/// прямо в `BytesMut` (`recv_buf` + `split().freeze()` без копии датаграммы).
/// Мёртвый туннель не рвёт мост — датаграмма просто дропается (UDP без гарантий).
pub(crate) async fn run_udp_bridge( pub(crate) async fn run_udp_bridge(
stream_id: u32, stream_id: u32,
socket: UdpSocket, socket: UdpSocket,
+659 -36
View File
@@ -1,4 +1,24 @@
use std::{net::Ipv4Addr, sync::Arc}; //! Точки входа туннеля: установка соединений на стороне клиента и сервера.
//!
//! Здесь собирается всё ядро в две роли:
//!
//! - [`ClientHandler`] — клиентская сторона. [`connect`](ClientHandler::connect)
//! поднимает [`MAX_TUNNEL_LEGS`] ног (с разбежкой по времени), сторожит смену
//! сети и переводит локальный трафик ([`RawCastFrame`]) в потоки туннеля.
//! Каждая нога делает [`perform_handshake`](ClientHandler::perform_handshake)
//! (поддельный TLS + обмен ключами + auth-кадр) и крутится в [`TunnelEngine`].
//! - [`ServerHandler`] — серверная сторона. Принимает соединение, проверяет, что
//! это валидный Netrunner-`ClientHello`; если нет — **stealth-fallback**:
//! прозрачно проксирует трафик на безобидный хост (`decoy_host:443`, атрибут
//! ноды — задаётся при старте, не константа), маскируясь под обычный TLS и
//! не выдавая себя сканерам/DPI.
//! - [`SessionManager`] — реестр сессий сервера (одна сессия = один [`Muxer`],
//! несколько ног).
//!
//! Обе роли сходятся на [`TunnelEngine`]: клиент задаёт `remote_addr`
//! (реконнектит), сервер оставляет его пустым (нога просто завершается).
use std::{net::Ipv4Addr, sync::Arc, time::Instant};
use crate::{ use crate::{
crypto::{ChaChaCipher, SessionKeys}, crypto::{ChaChaCipher, SessionKeys},
@@ -8,10 +28,10 @@ use crate::{
handler::{RemoteOpener, StreamHandler}, handler::{RemoteOpener, StreamHandler},
muxer::{MuxMessage, Muxer}, muxer::{MuxMessage, Muxer},
}, },
NetworkConfig, DNS_LOOKUP_TIMEOUT, FALLBACK_CONNECT_TIMEOUT, LEG_RECONNECT_DELAY, NetworkConfig, DNS_LOOKUP_TIMEOUT, FALLBACK_CONNECT_TIMEOUT, HTTPS_PORT,
LEG_STAGGER_DELAY, MAX_TUNNEL_LEGS, NETWORK_WATCHER_INTERVAL, SECURE_HANDSHAKE_TIMEOUT, LEG_RECONNECT_DELAY, LEG_STAGGER_DELAY, MAX_TUNNEL_LEGS, NETWORK_WATCHER_INTERVAL,
SESSION_CLEANUP_DELAY, STEALTH_FALLBACK_HOST, STEALTH_FALLBACK_SNI, TLS_HELLO_TIMEOUT, SECURE_HANDSHAKE_TIMEOUT, SESSION_CLEANUP_DELAY, STREAM_PAUSE_BUDGET, STREAM_PAUSE_RETRY,
TOPOLOGY_PRINT_INTERVAL, TLS_HELLO_TIMEOUT, TOPOLOGY_PRINT_INTERVAL,
}, },
nrxp::{Codec, Frame, FrameType, TlsBridge}, nrxp::{Codec, Frame, FrameType, TlsBridge},
rawcast::{LocalProtocol, RawCastAdapter, RawCastFrame}, rawcast::{LocalProtocol, RawCastAdapter, RawCastFrame},
@@ -22,7 +42,7 @@ use dashmap::DashMap;
use netrunner_logger::{ use netrunner_logger::{
debug, error, info, warn, AppError, ERR_AUTH_FAILED, ERR_INFRA_TIMEOUT, ERR_NET_TLS_TAMPER, debug, error, info, warn, AppError, ERR_AUTH_FAILED, ERR_INFRA_TIMEOUT, ERR_NET_TLS_TAMPER,
}; };
use rand::Rng; use rand::{Rng, RngExt};
use tokio::{ use tokio::{
io::{AsyncReadExt, AsyncWriteExt}, io::{AsyncReadExt, AsyncWriteExt},
net::{ net::{
@@ -32,6 +52,7 @@ use tokio::{
sync::mpsc, sync::mpsc,
}; };
/// Реестр активных сессий сервера: `session_id` → общий на сессию [`Muxer`].
pub struct SessionManager { pub struct SessionManager {
sessions: DashMap<String, Arc<Muxer>>, sessions: DashMap<String, Arc<Muxer>>,
} }
@@ -52,6 +73,8 @@ impl SessionManager {
&self.sessions &self.sessions
} }
/// Возвращает muxer сессии, создавая его при первом обращении. Так вторая и
/// последующие ноги одной сессии цепляются к тому же мультиплексору.
pub fn get_or_create(&self, session_id: &str) -> Arc<Muxer> { pub fn get_or_create(&self, session_id: &str) -> Arc<Muxer> {
self.sessions self.sessions
.entry(session_id.to_string()) .entry(session_id.to_string())
@@ -79,11 +102,14 @@ impl SessionManager {
} }
} }
/// Общий контракт обработчика входящего туннельного соединения (реализует сервер).
#[async_trait::async_trait] #[async_trait::async_trait]
pub trait TunnelHandler { pub trait TunnelHandler {
/// Обрабатывает соединение до его завершения.
async fn run(self) -> Result<(), AppError>; async fn run(self) -> Result<(), AppError>;
} }
/// Обёртка над TCP-соединением: половинки сокета + накопительный буфер чтения.
pub struct Connection { pub struct Connection {
pub(crate) inbound: OwnedReadHalf, pub(crate) inbound: OwnedReadHalf,
pub(crate) outbound: OwnedWriteHalf, pub(crate) outbound: OwnedWriteHalf,
@@ -101,18 +127,142 @@ impl Connection {
} }
} }
/// Грубая синтаксическая проверка SNI-хоста перед тем, как вообще пытаться
/// его резолвить для stealth-fallback. Отсеивает: пустую строку, аномально
/// длинные значения (реальные hostname не длиннее 253 байт по RFC 1035), и
/// буквальные IP-адреса — SNI по стандарту несёт hostname, а не IP; разрешать
/// IP-литерал означал бы дать удалённой стороне впрямую выбрать адрес нашего
/// исходящего соединения безо всякого DNS-резолва между ними.
fn is_plausible_hostname(host: &str) -> bool {
if host.is_empty() || host.len() > 253 {
return false;
}
if host.parse::<std::net::IpAddr>().is_ok() {
return false;
}
host.bytes()
.all(|b| b.is_ascii_alphanumeric() || b == b'.' || b == b'-')
}
/// `true`, если по этому IP безопасно пустить исходящее TCP-соединение сервера
/// в ответ на данные, присланные недоверенной удалённой стороной (SNI из
/// невалидного `ClientHello`). Блокирует loopback/private/link-local (в т.ч.
/// `169.254.169.254` — облачный metadata-эндпоинт)/multicast/unspecified —
/// защита от SSRF на внутреннюю сеть ноды через подставной SNI.
fn is_safe_decoy_ip(ip: &std::net::IpAddr) -> bool {
match ip {
std::net::IpAddr::V4(v4) => {
!(v4.is_loopback()
|| v4.is_private()
|| v4.is_link_local()
|| v4.is_multicast()
|| v4.is_unspecified()
|| v4.is_broadcast()
|| v4.is_documentation())
}
std::net::IpAddr::V6(v6) => {
let segments = v6.segments();
let is_unique_local = (segments[0] & 0xfe00) == 0xfc00; // fc00::/7
let is_link_local = (segments[0] & 0xffc0) == 0xfe80; // fe80::/10
!(v6.is_loopback()
|| v6.is_multicast()
|| v6.is_unspecified()
|| is_unique_local
|| is_link_local)
}
}
}
/// Резолвит `host:port` и возвращает первый адрес, прошедший
/// [`is_safe_decoy_ip`] — `None`, если хост не резолвится вовсе или резолвится
/// только в небезопасные (внутренние/локальные) адреса.
async fn resolve_safe_decoy_addr(host: &str, port: u16) -> Option<std::net::SocketAddr> {
let addrs = tokio::time::timeout(
FALLBACK_CONNECT_TIMEOUT,
tokio::net::lookup_host((host, port)),
)
.await
.ok()?
.ok()?;
addrs.into_iter().find(|a| is_safe_decoy_ip(&a.ip()))
}
/// Дожидается и вырезает из буфера фиктивную запись `ChangeCipherSpec`,
/// которую наш пир (мы же на другом конце — клиент или сервер) всегда шлёт
/// сразу вслед за своим Hello (см. [`TlsBridge::build_middlebox_ccs`]) ради
/// middlebox-совместимости TLS 1.3. Переиспользуется и клиентской, и
/// серверной стороной хендшейка — обе ждут её одинаково.
async fn consume_middlebox_ccs(
inbound: &mut OwnedReadHalf,
read_buf: &mut BytesMut,
) -> Result<(), AppError> {
loop {
match TlsBridge::unpack_middlebox_ccs(read_buf) {
Ok(Some(())) => return Ok(()),
Ok(None) => {
let res = tokio::time::timeout(TLS_HELLO_TIMEOUT, inbound.read_buf(read_buf)).await;
match res {
Ok(Ok(0)) => {
return Err(AppError::new(
ERR_INFRA_TIMEOUT,
"Разрыв соединения",
"EOF while waiting for ChangeCipherSpec",
))
}
Ok(Ok(_)) => continue,
Ok(Err(e)) => {
return Err(AppError::new(
ERR_INFRA_TIMEOUT,
"Ошибка чтения",
e.to_string(),
))
}
Err(_) => {
return Err(AppError::new(
ERR_INFRA_TIMEOUT,
"Таймаут handshake",
"Timeout waiting for ChangeCipherSpec",
))
}
}
}
Err(e) => {
return Err(AppError::new(
ERR_NET_TLS_TAMPER,
"Ошибка TLS",
format!("TLS error while reading ChangeCipherSpec: {:?}", e.stage),
))
}
}
}
}
/// Клиентская сторона туннеля (набор статических операций).
pub struct ClientHandler; pub struct ClientHandler;
impl ClientHandler { impl ClientHandler {
/// Узнаёт локальный IP «трюком с UDP-connect»: соединение к 8.8.8.8 без
/// отправки заставляет ОС выбрать исходящий интерфейс, чей адрес мы и читаем.
/// Нужно для детектора смены сети (Wi-Fi↔LTE).
fn get_local_ip() -> Option<std::net::IpAddr> { fn get_local_ip() -> Option<std::net::IpAddr> {
let socket = std::net::UdpSocket::bind("0.0.0.0:0").ok()?; let socket = std::net::UdpSocket::bind("0.0.0.0:0").ok()?;
socket.connect("8.8.8.8:80").ok()?; socket.connect("8.8.8.8:80").ok()?;
socket.local_addr().ok().map(|a| a.ip()) socket.local_addr().ok().map(|a| a.ip())
} }
pub async fn perform_handshake( /// Проводит полный клиентский хендшейк по уже установленному TCP-сокету.
///
/// Шаги: послать поддельный `ClientHello` (профиль браузера по `profile`,
/// SNI=`decoy_sni`) → дождаться `ServerHello` и вывести ключи → зарядить
/// шифр и кодек → отправить первый зашифрованный auth-кадр `Heartbeat` с
/// `"session_id:leg_id:auth_token"` (третий сегмент может быть пустым).
/// Возвращает половинки сокета и готовые кодеки.
pub(crate) async fn perform_handshake(
stream: tokio::net::TcpStream, stream: tokio::net::TcpStream,
session_id: &str, session_id: &str,
leg_id: u32, leg_id: u32,
profile: &BrowserProfile,
decoy_sni: &str,
auth_token: &str,
) -> Result< ) -> Result<
( (
OwnedReadHalf, OwnedReadHalf,
@@ -125,17 +275,22 @@ impl ClientHandler {
stream.set_nodelay(true).unwrap_or_default(); stream.set_nodelay(true).unwrap_or_default();
let mut conn = Connection::new(stream); let mut conn = Connection::new(stream);
let mut session_keys = SessionKeys::new(true); let mut session_keys = SessionKeys::new(true);
let ch = TlsBridge::wrap_client_hello( let ch = TlsBridge::wrap_client_hello(profile, decoy_sni, &session_keys);
&BrowserProfile::CHROME_131,
STEALTH_FALLBACK_SNI,
&session_keys,
);
conn.outbound conn.outbound
.write_all(&ch) .write_all(&ch)
.await .await
.map_err(|e| AppError::new(ERR_INFRA_TIMEOUT, "Сбой сети", e.to_string()))?; .map_err(|e| AppError::new(ERR_INFRA_TIMEOUT, "Сбой сети", e.to_string()))?;
// Реальные браузеры шлют фиктивный ChangeCipherSpec сразу после
// ClientHello (RFC 8446 Appendix D.4) — повторяем и это, не только сам
// хендшейк, иначе последовательность типов TLS-записей выдаёт
// нестандартный стек даже при идеальном JA3/JA4-отпечатке ClientHello.
conn.outbound
.write_all(&TlsBridge::build_middlebox_ccs())
.await
.map_err(|e| AppError::new(ERR_INFRA_TIMEOUT, "Сбой сети", e.to_string()))?;
loop { loop {
match TlsBridge::unpack_handshake(&mut conn.read_buf) { match TlsBridge::unpack_handshake(&mut conn.read_buf) {
Ok(Some(msg)) => { Ok(Some(msg)) => {
@@ -183,13 +338,20 @@ impl ClientHandler {
} }
} }
// Сервер тоже шлёт фиктивный ChangeCipherSpec сразу после ServerHello —
// вычитываем и отбрасываем её здесь же, до перехода в data-фазу.
consume_middlebox_ccs(&mut conn.inbound, &mut conn.read_buf).await?;
let (tx_key, tx_iv, rx_key, rx_iv) = session_keys.get_aead_parameters(); let (tx_key, tx_iv, rx_key, rx_iv) = session_keys.get_aead_parameters();
let mut cipher = ChaChaCipher::new(); let mut cipher = ChaChaCipher::new();
cipher.set_keys(tx_key, tx_iv, rx_key, rx_iv); cipher.set_keys(tx_key, tx_iv, rx_key, rx_iv);
let codec = Codec::new(cipher, session_keys.get_auth_key()); let codec = Codec::new(cipher, session_keys.get_auth_key());
let (rx_codec, mut tx_codec) = codec.split(); let (rx_codec, mut tx_codec) = codec.split();
let auth_payload = Bytes::from(format!("{}:{}", session_id, leg_id)); // Третий сегмент — Bearer-токен клиента (пусто, если `--require-auth`
// выключен на этом развёртывании или приложение ещё не залогинено).
// Сервер игнорирует его целиком, если сам не запущен с `--require-auth`.
let auth_payload = Bytes::from(format!("{}:{}:{}", session_id, leg_id, auth_token));
let encrypted_auth = tx_codec let encrypted_auth = tx_codec
.encode_frame(0, FrameType::Heartbeat, auth_payload) .encode_frame(0, FrameType::Heartbeat, auth_payload)
.map_err(|e| { .map_err(|e| {
@@ -208,11 +370,25 @@ impl ClientHandler {
Ok((conn.inbound, conn.outbound, rx_codec, tx_codec)) Ok((conn.inbound, conn.outbound, rx_codec, tx_codec))
} }
/// Устанавливает одну ногу и крутит её движок до остановки.
///
/// Резолвит адрес (с тайм-аутом), создаёт TCP-сокет с анти-bufferbloat
/// тюнингом буферов, делает хендшейк, регистрирует ногу в muxer и запускает
/// [`TunnelEngine::run`]. Возвращается только при остановке движка; снаружи
/// (в [`connect`](ClientHandler::connect)) это уводит ногу на переподключение.
///
/// Профиль браузера выбирается через [`BrowserProfile::for_session`] —
/// один стабильный отпечаток на всю туннельную сессию (все ноги, все
/// переподключения), а не по номеру попытки: см. doc на `for_session` про
/// то, почему ротация профиля между ретраями одной и той же ноги была
/// хуже, чем константный отпечаток.
async fn establish_leg( async fn establish_leg(
remote_proxy_addr: &str, remote_proxy_addr: &str,
leg_id: u32, leg_id: u32,
muxer: Arc<Muxer>, muxer: Arc<Muxer>,
session_id: &str, session_id: &str,
decoy_sni: &Arc<str>,
auth_token: &Arc<str>,
) -> Result<(), AppError> { ) -> Result<(), AppError> {
let leg_name = format!("TCP-Leg-{}", leg_id); let leg_name = format!("TCP-Leg-{}", leg_id);
@@ -254,8 +430,10 @@ impl ClientHandler {
})? })?
.map_err(|e| AppError::new(ERR_INFRA_TIMEOUT, "Сбой сокета", e.to_string()))?; .map_err(|e| AppError::new(ERR_INFRA_TIMEOUT, "Сбой сокета", e.to_string()))?;
let profile = BrowserProfile::for_session(session_id);
let (inbound, outbound, rx_codec, tx_codec) = let (inbound, outbound, rx_codec, tx_codec) =
Self::perform_handshake(stream, session_id, leg_id).await?; Self::perform_handshake(stream, session_id, leg_id, profile, decoy_sni, auth_token)
.await?;
let cap = NetworkConfig::global().channel_capacity; let cap = NetworkConfig::global().channel_capacity;
let (control_tx, control_rx) = mpsc::channel::<MuxMessage>(cap); let (control_tx, control_rx) = mpsc::channel::<MuxMessage>(cap);
@@ -279,6 +457,8 @@ impl ClientHandler {
remote_addr: remote_proxy_addr.to_string(), remote_addr: remote_proxy_addr.to_string(),
session_id: session_id.to_string(), session_id: session_id.to_string(),
leg_status: crate::net::connection::engine::LegStatus::Active, leg_status: crate::net::connection::engine::LegStatus::Active,
decoy_sni: decoy_sni.clone(),
auth_token: auth_token.clone(),
}; };
let run_result = engine.run().await; let run_result = engine.run().await;
@@ -294,11 +474,33 @@ impl ClientHandler {
)) ))
} }
/// Точка входа клиента: поднимает весь туннель и возвращает его [`Muxer`].
///
/// Запускает три группы фоновых задач:
/// 1. **Ноги** — [`MAX_TUNNEL_LEGS`] задач, каждая в вечном цикле
/// establish→disconnect→reconnect (со сдвигом старта [`LEG_STAGGER_DELAY`]).
/// 2. **Сторож сети** — следит за сменой локального IP и при переключении
/// сети сбрасывает все ноги (быстрый реконнект вместо зависших сокетов).
/// 3. **Здоровье/топология** — периодический health-check и печать топологии.
///
/// Плюс главный цикл, который переводит локальные [`RawCastFrame`]
/// (`rx_from_engine`) в потоки/данные туннеля и возвращает ответы обратно
/// (`tx_to_engine`), с пер-сокетными буферами выгрузки против HOL-блокировки.
///
/// `decoy_sni` — хост, под который маскируется наш `ClientHello` (SNI).
/// Сейчас это статическое значение атрибута конфигурации вызывающей
/// стороны (см. `EngineConfig::decoy_sni` в `client`); в перспективе будет
/// приходить динамически со списком серверов, чтобы не расходиться с тем,
/// на какой decoy-хост настроен конкретный сервер (`--decoy-host`).
pub async fn connect( pub async fn connect(
remote_proxy_addr: &str, remote_proxy_addr: &str,
decoy_sni: impl Into<Arc<str>>,
auth_token: Option<String>,
mut rx_from_engine: mpsc::Receiver<RawCastFrame>, mut rx_from_engine: mpsc::Receiver<RawCastFrame>,
tx_to_engine: mpsc::Sender<RawCastFrame>, tx_to_engine: mpsc::Sender<RawCastFrame>,
) -> Result<Arc<Muxer>, AppError> { ) -> Result<Arc<Muxer>, AppError> {
let decoy_sni: Arc<str> = decoy_sni.into();
let auth_token: Arc<str> = auth_token.unwrap_or_default().into();
let session_id = SessionManager::generate_id(); let session_id = SessionManager::generate_id();
let muxer = Arc::new(Muxer::new(true, session_id.clone())); let muxer = Arc::new(Muxer::new(true, session_id.clone()));
let registry: Arc<DashMap<u32, (u64, Ipv4Addr, u16, LocalProtocol)>> = let registry: Arc<DashMap<u32, (u64, Ipv4Addr, u16, LocalProtocol)>> =
@@ -331,11 +533,16 @@ impl ClientHandler {
let addr = remote_proxy_addr.to_string(); let addr = remote_proxy_addr.to_string();
let m = muxer.clone(); let m = muxer.clone();
let sid = session_id.clone(); let sid = session_id.clone();
let decoy_sni = decoy_sni.clone();
let auth_token = auth_token.clone();
tokio::spawn(async move { tokio::spawn(async move {
tokio::time::sleep(LEG_STAGGER_DELAY * id).await; tokio::time::sleep(LEG_STAGGER_DELAY * id).await;
let mut attempt: u32 = 0; let mut attempt: u32 = 0;
loop { loop {
if let Err(e) = Self::establish_leg(&addr, id, m.clone(), &sid).await { if let Err(e) =
Self::establish_leg(&addr, id, m.clone(), &sid, &decoy_sni, &auth_token)
.await
{
attempt += 1; attempt += 1;
error!("Leg {} disconnected: {}. Reconnecting in 2s...", id, e); error!("Leg {} disconnected: {}. Reconnecting in 2s...", id, e);
let rtt = let rtt =
@@ -455,7 +662,7 @@ impl ClientHandler {
let cap = NetworkConfig::global().channel_capacity; let cap = NetworkConfig::global().channel_capacity;
let (v_tx, mut v_rx) = mpsc::channel::<Bytes>(cap); let (v_tx, mut v_rx) = mpsc::channel::<Bytes>(cap);
muxer_inner.register_stream(global_stream_id, v_tx); let cancel_token = muxer_inner.register_stream(global_stream_id, v_tx);
let tx_to_tun = tx_to_engine.clone(); let tx_to_tun = tx_to_engine.clone();
let reg = registry.clone(); let reg = registry.clone();
@@ -516,9 +723,57 @@ impl ClientHandler {
.await; .await;
while let Some(data_payload) = up_rx.recv().await { while let Some(data_payload) = up_rx.recv().await {
if is_udp {
// UDP has no delivery guarantee — best-effort like
// run_udp_bridge: drop on a dead tunnel, don't pause.
let _ = m_clone let _ = m_clone
.send_data_safe(global_stream_id, data_payload, is_udp) .send_data_safe(global_stream_id, data_payload, true)
.await; .await;
continue;
}
// 🔥 GRACEFUL PAUSE (anti-domino), symmetric to
// run_tcp_bridge's upload half on the server. send_data_safe
// already fails over between live legs; it only errors when
// EVERY leg is down. That used to be silently ignored here
// (`let _ = ...await`), permanently dropping the chunk and
// corrupting the upload mid-stream. Now we hold the chunk and
// retry while the engine reconnects, bounded by
// STREAM_PAUSE_BUDGET, and bail out immediately if the stream
// gets torn down from elsewhere (peer Close, backlog
// eviction) meanwhile.
let deadline = Instant::now() + STREAM_PAUSE_BUDGET;
let mut delivered = false;
loop {
if m_clone
.send_data_safe(
global_stream_id,
data_payload.clone(),
false,
)
.await
.is_ok()
{
delivered = true;
break;
}
if Instant::now() >= deadline {
break;
}
tokio::select! {
biased;
_ = cancel_token.cancelled() => break,
_ = tokio::time::sleep(STREAM_PAUSE_RETRY) => {}
}
}
if !delivered {
warn!(
global_stream_id,
"Upload stream pause budget exceeded — no leg recovered, dropping stream"
);
m_clone.remove_stream(global_stream_id);
break;
}
} }
}); });
} }
@@ -539,7 +794,8 @@ impl ClientHandler {
Ok(_) => {} Ok(_) => {}
Err(mpsc::error::TrySendError::Closed(_)) => {} Err(mpsc::error::TrySendError::Closed(_)) => {}
Err(mpsc::error::TrySendError::Full(p)) => { Err(mpsc::error::TrySendError::Full(p)) => {
let mut q = std::collections::VecDeque::with_capacity(16); let mut q =
std::collections::VecDeque::with_capacity(16);
q.push_back(p); q.push_back(p);
pending_upload.insert(local_socket_id, q); pending_upload.insert(local_socket_id, q);
} }
@@ -594,30 +850,85 @@ impl ClientHandler {
} }
} }
/// Серверная сторона: обрабатывает одно входящее соединение.
pub struct ServerHandler { pub struct ServerHandler {
pub(crate) conn: Connection, pub(crate) conn: Connection,
pub(crate) session_manager: Arc<SessionManager>, pub(crate) session_manager: Arc<SessionManager>,
/// Домен-декой для stealth-fallback (атрибут ноды, задаётся при старте
/// сервера через `--decoy-host`; раньше был захардкожен на `ubuntu.com`).
pub(crate) decoy_host: Arc<str>,
/// `None` — авторизация выключена на этом инстансе (`--require-auth` не
/// передан), поведение как до этой фичи. `Some` — токен клиента
/// обязателен и проверяется бэкендом при установке первой ноги сессии.
pub(crate) auth: Option<Arc<dyn crate::net::AuthValidator>>,
} }
impl ServerHandler { impl ServerHandler {
pub fn new(connection: Connection, session_manager: Arc<SessionManager>) -> Self { pub fn new(
connection: Connection,
session_manager: Arc<SessionManager>,
decoy_host: Arc<str>,
auth: Option<Arc<dyn crate::net::AuthValidator>>,
) -> Self {
Self { Self {
conn: connection, conn: connection,
session_manager, session_manager,
decoy_host,
auth,
} }
} }
/// Stealth-fallback: прозрачно проксирует соединение на безобидный хост,
/// когда клиент оказался «не наш».
///
/// `requested_sni` — hostname из SNI невалидного `ClientHello`, если он
/// удалось разобрать (`None`, если хендшейк вообще не распарсился/не
/// дождались данных). Раньше fallback всегда шёл на один и тот же
/// фиксированный `decoy_host` независимо от того, какой SNI прислал
/// зонд — активное зондирование с разными SNI на один и тот же IP всегда
/// получало одинаковый ответ, что само по себе выдавало нестандартный
/// прокси. Теперь при валидном `requested_sni` проксируем именно на него
/// (резолвится и проверяется через [`resolve_safe_decoy_addr`] — только
/// публичные IP, чтобы SNI, присланный атакующим, не мог заставить ноду
/// самой законнектиться на внутреннюю инфраструктуру, SSRF), а на
/// `decoy_host` — только если SNI нет, невалиден или резолвится
/// небезопасно.
///
/// Уже прочитанные байты (`initial_data`) пересылаются первыми, затем
/// соединение склеивается в обе стороны через `tokio::io::copy`. Снаружи это
/// выглядит как обычный визит на публичный сайт — сервер не выдаёт себя
/// сканерам и активным пробам DPI.
async fn handle_stealth_fallback( async fn handle_stealth_fallback(
mut client_inbound: OwnedReadHalf, mut client_inbound: OwnedReadHalf,
mut client_outbound: OwnedWriteHalf, mut client_outbound: OwnedWriteHalf,
initial_data: Bytes, initial_data: Bytes,
decoy_host: &str,
requested_sni: Option<&str>,
) { ) {
info!(target = %STEALTH_FALLBACK_HOST, "Stealth fallback: bridging to Target"); let sni_target = requested_sni.filter(|h| is_plausible_hostname(h));
let target_stream = tokio::time::timeout(
FALLBACK_CONNECT_TIMEOUT, let target_addr = match sni_target {
TcpStream::connect(STEALTH_FALLBACK_HOST), Some(host) => match resolve_safe_decoy_addr(host, HTTPS_PORT).await {
) Some(addr) => {
.await; info!(sni = %host, %addr, "Stealth fallback: bridging to requested SNI");
Some(addr)
}
None => {
warn!(sni = %host, "Stealth fallback: SNI resolved unsafely or failed, using decoy_host");
resolve_safe_decoy_addr(decoy_host, HTTPS_PORT).await
}
},
None => resolve_safe_decoy_addr(decoy_host, HTTPS_PORT).await,
};
let Some(target_addr) = target_addr else {
warn!("Stealth fallback: no safe target address available, dropping connection.");
return;
};
info!(target = %target_addr, "Stealth fallback: bridging to Target");
let target_stream =
tokio::time::timeout(FALLBACK_CONNECT_TIMEOUT, TcpStream::connect(target_addr)).await;
if let Ok(Ok(target_server)) = target_stream { if let Ok(Ok(target_server)) = target_stream {
let (mut server_read, mut server_write) = target_server.into_split(); let (mut server_read, mut server_write) = target_server.into_split();
@@ -639,11 +950,20 @@ impl ServerHandler {
} }
} }
/// Серверный жизненный цикл соединения: хендшейк → аутентификация → движок.
///
/// Три фазы, на каждой при малейшем несоответствии — stealth-fallback или отказ:
/// 1. Принять `ClientHello` и собрать `ServerHello`; невалидный/чужой → fallback.
/// 2. Расшифровать первый кадр и проверить auth-payload `"session_id:leg_id"`;
/// неверный → [`ERR_AUTH_FAILED`].
/// 3. Прицепить ногу к muxer сессии и крутить [`TunnelEngine`]; по завершении —
/// эвикт ноги и отложенная уборка сессии, если ног не осталось.
#[async_trait::async_trait] #[async_trait::async_trait]
impl TunnelHandler for ServerHandler { impl TunnelHandler for ServerHandler {
async fn run(self) -> Result<(), AppError> { async fn run(self) -> Result<(), AppError> {
info!("Acting as TLS Server with Stealth Fallback"); info!("Acting as TLS Server with Stealth Fallback");
let decoy_host = self.decoy_host;
let Connection { let Connection {
mut inbound, mut inbound,
mut outbound, mut outbound,
@@ -651,7 +971,7 @@ impl TunnelHandler for ServerHandler {
} = self.conn; } = self.conn;
let mut session_keys = SessionKeys::new(false); let mut session_keys = SessionKeys::new(false);
let hello = loop { let (hello, peer_version) = loop {
let buf_snapshot = read_buf.clone().freeze(); let buf_snapshot = read_buf.clone().freeze();
match TlsBridge::unpack_handshake(&mut read_buf) { match TlsBridge::unpack_handshake(&mut read_buf) {
@@ -661,13 +981,24 @@ impl TunnelHandler for ServerHandler {
&mut session_keys, &mut session_keys,
&ServerProfile::MODERN, &ServerProfile::MODERN,
) { ) {
Ok(sh) => { Ok((sh, peer_version)) => {
info!("✅ Valid Netrunner ClientHello detected"); info!(peer_version, "✅ Valid Netrunner ClientHello detected");
break sh; break (sh, peer_version);
} }
Err(e) => { Err(e) => {
warn!("❌ Unauthorized/Invalid ClientHello. Triggering Stealth Fallback. Reason: {:?}", e.stage); warn!("❌ Unauthorized/Invalid ClientHello. Triggering Stealth Fallback. Reason: {:?}", e.stage);
Self::handle_stealth_fallback(inbound, outbound, buf_snapshot).await; // Невалидный (например, чужой) ClientHello всё равно разобрался
// синтаксически — достаём его SNI, чтобы fallback проксировал
// именно на запрошенный хост, а не всегда на один и тот же decoy.
let requested_sni = client_msg.extensions().server_name();
Self::handle_stealth_fallback(
inbound,
outbound,
buf_snapshot,
&decoy_host,
requested_sni.as_deref(),
)
.await;
return Ok(()); return Ok(());
} }
} }
@@ -687,24 +1018,66 @@ impl TunnelHandler for ServerHandler {
Ok(Ok(_)) => continue, Ok(Ok(_)) => continue,
_ => { _ => {
warn!("⏰ TLS_HELLO_TIMEOUT reached. Triggering fallback..."); warn!("⏰ TLS_HELLO_TIMEOUT reached. Triggering fallback...");
Self::handle_stealth_fallback(inbound, outbound, buf_snapshot).await; Self::handle_stealth_fallback(
inbound,
outbound,
buf_snapshot,
&decoy_host,
None,
)
.await;
return Ok(()); return Ok(());
} }
} }
} }
Err(_) => { Err(_) => {
warn!("❌ Handshake parse failed (Not a valid TLS probe). Triggering Stealth Fallback."); warn!("❌ Handshake parse failed (Not a valid TLS probe). Triggering Stealth Fallback.");
Self::handle_stealth_fallback(inbound, outbound, buf_snapshot).await; Self::handle_stealth_fallback(
inbound,
outbound,
buf_snapshot,
&decoy_host,
None,
)
.await;
return Ok(()); return Ok(());
} }
} }
}; };
// Обмен CCS — только с клиентами, заявившими версию протокола, которая
// его понимает (crate::MIN_VERSION_FOR_CCS). Старый клиент (версия ниже)
// ничего не знает про CCS и с обеих сторон получает досемверсионное
// поведение — иначе апгрейд сервера порвал бы соединения с ещё не
// обновлёнными клиентскими сборками.
let use_ccs = peer_version >= crate::MIN_VERSION_FOR_CCS;
if use_ccs {
// Клиент шлёт свой ChangeCipherSpec сразу после ClientHello —
// вычитываем и отбрасываем её здесь же (см. TlsBridge::build_middlebox_ccs).
consume_middlebox_ccs(&mut inbound, &mut read_buf).await?;
}
// Небольшой случайный джиттер перед ответом: мгновенный, идеально
// детерминированный ServerHello сам по себе отличает наш стек от
// бэкенда настоящего сайта с обычным серверным временем обработки.
let jitter_ms = rand::rng().random_range(5..=40u64);
tokio::time::sleep(std::time::Duration::from_millis(jitter_ms)).await;
outbound outbound
.write_all(&hello) .write_all(&hello)
.await .await
.map_err(|e| AppError::new(ERR_INFRA_TIMEOUT, "Ошибка отправки", e.to_string()))?; .map_err(|e| AppError::new(ERR_INFRA_TIMEOUT, "Ошибка отправки", e.to_string()))?;
if use_ccs {
// ...и сами отвечаем своим ChangeCipherSpec сразу после ServerHello —
// по той же причине, что и клиент.
outbound
.write_all(&TlsBridge::build_middlebox_ccs())
.await
.map_err(|e| AppError::new(ERR_INFRA_TIMEOUT, "Ошибка отправки", e.to_string()))?;
}
let (tx_key, tx_iv, rx_key, rx_iv) = session_keys.get_aead_parameters(); let (tx_key, tx_iv, rx_key, rx_iv) = session_keys.get_aead_parameters();
let mut cipher = ChaChaCipher::new(); let mut cipher = ChaChaCipher::new();
cipher.set_keys(tx_key, tx_iv, rx_key, rx_iv); cipher.set_keys(tx_key, tx_iv, rx_key, rx_iv);
@@ -712,17 +1085,18 @@ impl TunnelHandler for ServerHandler {
let codec = Codec::new(cipher, session_keys.get_auth_key()); let codec = Codec::new(cipher, session_keys.get_auth_key());
let (mut rx_codec, tx_codec) = codec.split(); let (mut rx_codec, tx_codec) = codec.split();
let (session_id, leg_id) = loop { let (session_id, leg_id, auth_token) = loop {
match rx_codec.decode_inbound(&mut read_buf) { match rx_codec.decode_inbound(&mut read_buf) {
Ok(Some(frame)) => { Ok(Some(frame)) => {
if frame.header.frame_type == FrameType::Heartbeat { if frame.header.frame_type == FrameType::Heartbeat {
let payload_str = std::str::from_utf8(&frame.payload).unwrap_or(""); let payload_str = std::str::from_utf8(&frame.payload).unwrap_or("");
let parts: Vec<&str> = payload_str.split(':').collect(); let parts: Vec<&str> = payload_str.splitn(3, ':').collect();
if parts.len() == 2 && parts[1].parse::<u32>().is_ok() { if parts.len() == 3 && parts[1].parse::<u32>().is_ok() {
let sid = parts[0].to_string(); let sid = parts[0].to_string();
let lid: u32 = parts[1].parse().unwrap(); let lid: u32 = parts[1].parse().unwrap();
let token = parts[2].to_string();
info!("🤝 Secure Auth verified! Session: {}, Leg: {}", sid, lid); info!("🤝 Secure Auth verified! Session: {}, Leg: {}", sid, lid);
break (sid, lid); break (sid, lid, token);
} }
} }
return Err(AppError::new( return Err(AppError::new(
@@ -767,6 +1141,19 @@ impl TunnelHandler for ServerHandler {
}; };
let muxer = self.session_manager.get_or_create(&session_id); let muxer = self.session_manager.get_or_create(&session_id);
// Проверка личности клиента у бэкенда — только если этот инстанс
// запущен с `--require-auth`. До этой точки соединение прошло
// Netrunner-хендшейк (не сканер/чужой TLS-клиент), поэтому отказ здесь
// — обычный разрыв, а не stealth-fallback (светить уже нечего).
if let Some(validator) = &self.auth {
let quota = validator.validate(&auth_token).await.map_err(|e| {
warn!("❌ Backend rejected client token: {}", e.internal_msg);
AppError::new(ERR_AUTH_FAILED, "Доступ запрещен", e.internal_msg)
})?;
muxer.set_quota_user(quota.user_id);
}
let cap = NetworkConfig::global().channel_capacity; let cap = NetworkConfig::global().channel_capacity;
let (control_tx, control_rx) = mpsc::channel::<MuxMessage>(cap); let (control_tx, control_rx) = mpsc::channel::<MuxMessage>(cap);
let (data_tx, data_rx) = mpsc::channel::<MuxMessage>(cap); let (data_tx, data_rx) = mpsc::channel::<MuxMessage>(cap);
@@ -796,6 +1183,10 @@ impl TunnelHandler for ServerHandler {
remote_addr: String::new(), remote_addr: String::new(),
session_id, session_id,
leg_status: crate::net::connection::engine::LegStatus::Active, leg_status: crate::net::connection::engine::LegStatus::Active,
// Сервер никогда не реконнектит (см. `attempt_reconnect`'s early
// return on empty `remote_addr`), поэтому SNI/токен здесь не используются.
decoy_sni: Arc::from(""),
auth_token: Arc::from(""),
}; };
let res = engine.run().await; let res = engine.run().await;
@@ -816,3 +1207,235 @@ impl TunnelHandler for ServerHandler {
res res
} }
} }
#[cfg(test)]
mod tests {
use super::*;
// ---------- is_plausible_hostname ----------
#[test]
fn plausible_hostnames_are_accepted() {
for host in [
"example.com",
"dev.netrunner-vpn.com",
"a.b.c.d.e.example.org",
"xn--80ak6aa92e.com", // punycode — только ascii-alnum/./-
] {
assert!(is_plausible_hostname(host), "{host} should be plausible");
}
}
#[test]
fn ip_literals_are_rejected() {
for host in [
"127.0.0.1",
"8.8.8.8",
"::1",
"2001:db8::1",
"169.254.169.254",
] {
assert!(
!is_plausible_hostname(host),
"{host} is an IP, not a hostname"
);
}
}
#[test]
fn empty_and_oversized_hostnames_are_rejected() {
assert!(!is_plausible_hostname(""));
let too_long = "a".repeat(254);
assert!(!is_plausible_hostname(&too_long));
let just_ok = "a".repeat(253);
assert!(is_plausible_hostname(&just_ok));
}
#[test]
fn hostnames_with_unexpected_characters_are_rejected() {
for host in [
"exa mple.com",
"example.com/../etc",
"example.com\0",
"user@example.com",
"example.com:8080",
"http://example.com",
] {
assert!(!is_plausible_hostname(host), "{host} has invalid chars");
}
}
// ---------- is_safe_decoy_ip ----------
#[test]
fn public_ipv4_is_safe() {
assert!(is_safe_decoy_ip(&"8.8.8.8".parse().unwrap()));
assert!(is_safe_decoy_ip(&"1.1.1.1".parse().unwrap()));
}
#[test]
fn private_and_special_ipv4_ranges_are_blocked() {
for ip in [
"127.0.0.1", // loopback
"10.0.0.1", // private
"172.16.0.1", // private
"192.168.1.1", // private
"169.254.169.254", // link-local / cloud metadata endpoint
"224.0.0.1", // multicast
"0.0.0.0", // unspecified
"255.255.255.255", // broadcast
"192.0.2.1", // documentation (TEST-NET-1)
] {
let addr: std::net::IpAddr = ip.parse().unwrap();
assert!(!is_safe_decoy_ip(&addr), "{ip} must be blocked");
}
}
#[test]
fn public_ipv6_is_safe() {
// 2606:4700:4700::1111 — Cloudflare public resolver.
assert!(is_safe_decoy_ip(&"2606:4700:4700::1111".parse().unwrap()));
}
#[test]
fn private_and_special_ipv6_ranges_are_blocked() {
for ip in [
"::1", // loopback
"::", // unspecified
"fe80::1", // link-local
"fc00::1", // unique local
"fd12:3456::1", // unique local (fd.. subset)
"ff02::1", // multicast
] {
let addr: std::net::IpAddr = ip.parse().unwrap();
assert!(!is_safe_decoy_ip(&addr), "{ip} must be blocked");
}
}
// ---------- resolve_safe_decoy_addr ----------
#[tokio::test]
async fn resolve_safe_decoy_addr_accepts_public_ip_literal() {
let addr = resolve_safe_decoy_addr("93.184.216.34", 443).await;
assert_eq!(addr, Some("93.184.216.34:443".parse().unwrap()));
}
#[tokio::test]
async fn resolve_safe_decoy_addr_rejects_private_ip_literal() {
// "host" здесь буквально IP из приватного диапазона — резолв тривиален
// (без сети), но результат всё равно должен быть отфильтрован.
let addr = resolve_safe_decoy_addr("10.0.0.5", 443).await;
assert_eq!(addr, None);
}
#[tokio::test]
async fn resolve_safe_decoy_addr_rejects_metadata_endpoint() {
let addr = resolve_safe_decoy_addr("169.254.169.254", 443).await;
assert_eq!(
addr, None,
"cloud metadata endpoint must never be reachable via decoy fallback"
);
}
// ---------- полный хендшейк клиент↔сервер по настоящему TCP ----------
/// Легитимный хендшейк: клиент шлёт настоящий ClientHello+CCS, сервер
/// проверяет auth-тег, отвечает ServerHello+CCS, клиент шлёт auth-кадр —
/// всё по реальному TCP-сокету (не в памяти), с реальными таймингами и
/// разбиением на TCP-чтения. Проверяет именно то, что не покрывают чисто
/// байтовые юнит-тесты: что CCS/версия/хендшейк действительно
/// синхронизируются через настоящий сокет, а не только в идеальном
/// одноразовом буфере.
#[tokio::test]
async fn legitimate_handshake_succeeds_over_real_tcp() {
NetworkConfig::init_global(1500);
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let server_task = tokio::spawn(async move {
let (stream, _) = listener.accept().await.unwrap();
let conn = Connection::new(stream);
let handler = ServerHandler::new(
conn,
Arc::new(SessionManager::new()),
Arc::from("example.com"),
None,
);
// run() продолжает в muxer/engine после хендшейка и вернётся сам,
// как только клиент закроет сокет (наш тест-клиент не шлёт
// ничего сверх auth-кадра) — достаточно не повиснуть навсегда.
handler.run().await
});
let stream = tokio::net::TcpStream::connect(addr).await.unwrap();
let session_id = SessionManager::generate_id();
let handshake = tokio::time::timeout(
std::time::Duration::from_secs(5),
ClientHandler::perform_handshake(
stream,
&session_id,
0,
BrowserProfile::for_session(&session_id),
&Arc::<str>::from("example.com"),
"",
),
)
.await
.expect("handshake must not hang")
.expect("legitimate handshake must succeed");
let (_inbound, _outbound, _rx_codec, _tx_codec) = handshake;
drop(_inbound);
drop(_outbound);
// Сервер должен либо уже завершиться, либо завершиться вскоре после
// того, как клиент уронил сокет (EOF в движке туннеля) — не повиснуть.
let _ = tokio::time::timeout(std::time::Duration::from_secs(5), server_task).await;
}
/// Мусорный (не-NRXP) ClientHello должен уводить сервер в stealth-fallback,
/// а не в панику/зависание. decoy_host намеренно указывает на
/// незарезолвливаемое имя — тест офлайновый, реальный интернет не нужен;
/// нас интересует, что сервер аккуратно завершает соединение, а не падает
/// и не висит вечно.
#[tokio::test]
async fn garbage_client_hello_triggers_fallback_without_hanging() {
NetworkConfig::init_global(1500);
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let server_task = tokio::spawn(async move {
let (stream, _) = listener.accept().await.unwrap();
let conn = Connection::new(stream);
let handler = ServerHandler::new(
conn,
Arc::new(SessionManager::new()),
Arc::from("this-host-does-not-resolve.invalid"),
None,
);
handler.run().await
});
let mut stream = tokio::net::TcpStream::connect(addr).await.unwrap();
// Валидная по форме TLS Handshake-запись (content_type=0x16, версия,
// длина=5, ровно 5 байт тела), но тело — мусор, не ClientHello/ServerHello.
// Это специально ОТЛИЧАЕТСЯ от "просто разорвал соединение": здесь
// сервер должен дойти до Err(_) в unpack_handshake (парсинг записи
// прошёл, разбор hello — нет) и сразу уйти в fallback, а не ждать
// TLS_HELLO_TIMEOUT из-за "недостаточно данных".
stream
.write_all(&[0x16, 0x03, 0x03, 0x00, 0x05, 0xDE, 0xAD, 0xBE, 0xEF, 0x00])
.await
.unwrap();
drop(stream);
let result = tokio::time::timeout(std::time::Duration::from_secs(15), server_task).await;
assert!(
result.is_ok(),
"server must not hang forever on a non-NRXP ClientHello"
);
// handle_stealth_fallback возвращает Ok(()) даже если decoy недостижим —
// соединение просто тихо закрывается, как и было задумано.
assert!(matches!(result.unwrap().unwrap(), Ok(())));
}
}
+88 -5
View File
@@ -1,3 +1,22 @@
//! Движок одной ноги туннеля: жизненный цикл TCP-соединения и его reader/writer.
//!
//! [`TunnelEngine`] владеет одним физическим TCP+TLS-соединением и крутит его в
//! [`run`](TunnelEngine::run), пока нога жива. Внутри одной итерации соединение
//! расщепляется на две параллельные задачи tokio:
//!
//! - **Reader** — читает байты из сокета, прогоняет через [`RxCodec`]
//! (расшифровка + сборка кадров), PONG'и инлайн обновляют RTT, остальные кадры
//! уходят в [`StreamHandler`].
//! - **Writer** — `biased`-`select!` по приоритету: heartbeat → control → data.
//! Данные режутся на interleave-чанки (адаптивно под RTT) и шифруются
//! [`TxCodec`] в [`handle_outbound`](TunnelEngine::handle_outbound); несколько
//! кадров коалесятся в один `write_all` (экономия syscalls).
//!
//! При обрыве (EOF/ошибка) задачи останавливаются, их состояние (кодеки,
//! приёмники, буфер) возвращается в `self`, и — если это клиент — нога идёт на
//! переподключение с экспоненциальным backoff+jitter. Сервер (`remote_addr`
//! пуст) при обрыве просто завершает задачу: реконнект инициирует клиент.
use std::sync::Arc; use std::sync::Arc;
use bytes::{Bytes, BytesMut}; use bytes::{Bytes, BytesMut};
@@ -22,30 +41,65 @@ use crate::{
nrxp::{ErrorAction, FrameType, RxCodec, TxCodec, MAX_FRAME_PAYLOAD}, nrxp::{ErrorAction, FrameType, RxCodec, TxCodec, MAX_FRAME_PAYLOAD},
}; };
/// Состояние ноги: работает или в процессе переподключения.
#[derive(Debug, PartialEq, Clone, Copy)] #[derive(Debug, PartialEq, Clone, Copy)]
pub enum LegStatus { pub enum LegStatus {
Active, Active,
Reconnecting, Reconnecting,
} }
/// Состояние и ресурсы одной ноги туннеля.
///
/// Половинки сокета, кодеки, приёмники каналов и буфер чтения хранятся в
/// [`Option`], потому что на время работы reader/writer они «выдаются» в задачи
/// через `take()`, а по завершении итерации возвращаются обратно — это позволяет
/// переиспользовать кодеки (с их счётчиками nonce) между итерациями без Arc/Mutex.
pub(crate) struct TunnelEngine { pub(crate) struct TunnelEngine {
/// Читающая половина TCP-сокета (выдаётся reader-задаче).
pub inbound: Option<OwnedReadHalf>, pub inbound: Option<OwnedReadHalf>,
/// Пишущая половина TCP-сокета (выдаётся writer-задаче).
pub outbound: Option<OwnedWriteHalf>, pub outbound: Option<OwnedWriteHalf>,
/// Адрес удалённой стороны; **пустой у сервера** (сервер не реконнектит).
pub remote_addr: String, pub remote_addr: String,
/// Идентификатор сессии (для логов и хендшейка реконнекта).
pub session_id: String, pub session_id: String,
/// Текущий статус ноги.
pub leg_status: LegStatus, pub leg_status: LegStatus,
// 💡 ИЗМЕНЕНО: Кодеки теперь хранятся как Option без Arc/Mutex /// Кодек расшифровки входящего потока.
pub rx_codec: Option<RxCodec>, pub rx_codec: Option<RxCodec>,
/// Кодек шифрования исходящего потока.
pub tx_codec: Option<TxCodec>, pub tx_codec: Option<TxCodec>,
/// Накопительный буфер чтения из сокета.
pub read_buf: BytesMut, pub read_buf: BytesMut,
/// Приёмник управляющих сообщений от muxer (Close/Heartbeat).
pub control_rx: Option<Receiver<MuxMessage>>, pub control_rx: Option<Receiver<MuxMessage>>,
/// Приёмник сообщений данных от muxer.
pub data_rx: Option<Receiver<MuxMessage>>, pub data_rx: Option<Receiver<MuxMessage>>,
/// Обработчик входящих кадров.
pub handler: Arc<StreamHandler>, pub handler: Arc<StreamHandler>,
/// Идентификатор этой ноги.
pub leg_id: u32, pub leg_id: u32,
/// Общий мультиплексор туннеля.
pub muxer: Arc<crate::net::connection::muxer::Muxer>, pub muxer: Arc<crate::net::connection::muxer::Muxer>,
/// SNI поддельного `ClientHello` (атрибут, задаётся снаружи —
/// [`ClientHandler::connect`](crate::net::connection::ClientHandler::connect));
/// нужен для внутреннего реконнекта в [`attempt_reconnect`](Self::attempt_reconnect).
pub decoy_sni: Arc<str>,
/// Bearer-токен клиента (пусто — авторизация выключена/не залогинен),
/// нужен для того же внутреннего реконнекта, что и `decoy_sni` выше.
pub auth_token: Arc<str>,
} }
impl TunnelEngine { impl TunnelEngine {
/// Переподключает ногу: заново резолвит хост (подхватывает смену IP/DNS),
/// создаёт TCP-сокет с тюнингом буферов и проводит хендшейк заново. Возвращает
/// свежие половинки сокета и кодеки.
///
/// Профиль браузера выбирается через [`BrowserProfile::for_session`] по
/// `self.session_id` — тот же стабильный отпечаток, что и при первичном
/// установлении ноги в
/// [`ClientHandler::establish_leg`](crate::net::connection::ClientHandler::establish_leg),
/// а не новый на каждую попытку реконнекта (см. doc на `for_session`).
pub async fn attempt_reconnect( pub async fn attempt_reconnect(
&mut self, &mut self,
) -> Result<(OwnedReadHalf, OwnedWriteHalf, RxCodec, TxCodec), AppError> { ) -> Result<(OwnedReadHalf, OwnedWriteHalf, RxCodec, TxCodec), AppError> {
@@ -74,9 +128,25 @@ impl TunnelEngine {
.map_err(|_| AppError::new(ERR_INFRA_TIMEOUT, "Сбой сети", "Reconnect timeout"))? .map_err(|_| AppError::new(ERR_INFRA_TIMEOUT, "Сбой сети", "Reconnect timeout"))?
.map_err(|e| AppError::new(ERR_INFRA_TIMEOUT, "Сбой сети", e.to_string()))?; .map_err(|e| AppError::new(ERR_INFRA_TIMEOUT, "Сбой сети", e.to_string()))?;
crate::net::ClientHandler::perform_handshake(stream, &self.session_id, self.leg_id).await let profile = crate::tlseng::BrowserProfile::for_session(&self.session_id);
crate::net::ClientHandler::perform_handshake(
stream,
&self.session_id,
self.leg_id,
profile,
&self.decoy_sni,
&self.auth_token,
)
.await
} }
/// Главный цикл ноги: переподключение (при нужде) → запуск reader/writer →
/// ожидание завершения одной из задач → сбор состояния обратно → повтор.
///
/// Возвращает `Ok(())` при штатном завершении (например, сервер словил EOF);
/// `Err` — когда исчерпан внутренний лимит реконнектов
/// ([`MAX_INTERNAL_RECONNECT_ATTEMPTS`]) и управление надо вернуть внешнему
/// циклу `establish_leg` (он перерезолвит DNS и сбросит счётчики).
#[instrument(skip_all, fields(leg_id = self.leg_id))] #[instrument(skip_all, fields(leg_id = self.leg_id))]
pub async fn run(mut self) -> Result<(), AppError> { pub async fn run(mut self) -> Result<(), AppError> {
// Tracks consecutive internal reconnect failures. Resets to 0 on // Tracks consecutive internal reconnect failures. Resets to 0 on
@@ -403,7 +473,14 @@ impl TunnelEngine {
} }
} }
// 💡 ИЗМЕНЕНО: Принимает &mut TxCodec, синхронное и сверхбыстрое шифрование /// Шифрует сообщение в один или несколько кадров и пишет их в сокет.
///
/// `Data` режется на кадры по [`MAX_FRAME_PAYLOAD`]; управляющие/UDP идут одним
/// кадром. Срабатывает адаптивный по RTT дедлайн записи
/// ([`adaptive_write_timeout`](super::muxer::adaptive_write_timeout)) — чтобы
/// медленная, но живая нога не убивалась по жёсткому тайм-ауту. Несколько
/// кадров коалесятся в один `write_all` (аналог sendmmsg для байт-потока:
/// меньше syscalls); одиночный кадр пишется напрямую без лишней копии.
async fn handle_outbound( async fn handle_outbound(
outbound: &mut OwnedWriteHalf, outbound: &mut OwnedWriteHalf,
tx_codec: &mut TxCodec, tx_codec: &mut TxCodec,
@@ -474,7 +551,10 @@ impl TunnelEngine {
if packets.len() == 1 { if packets.len() == 1 {
let write_future = outbound.write_all(&packets[0]); let write_future = outbound.write_all(&packets[0]);
if tokio::time::timeout(write_timeout, write_future).await.is_err() { if tokio::time::timeout(write_timeout, write_future)
.await
.is_err()
{
return Err(stuck()); return Err(stuck());
} }
} else if !packets.is_empty() { } else if !packets.is_empty() {
@@ -484,7 +564,10 @@ impl TunnelEngine {
batch.extend_from_slice(pkt); batch.extend_from_slice(pkt);
} }
let write_future = outbound.write_all(&batch); let write_future = outbound.write_all(&batch);
if tokio::time::timeout(write_timeout, write_future).await.is_err() { if tokio::time::timeout(write_timeout, write_future)
.await
.is_err()
{
return Err(stuck()); return Err(stuck());
} }
} }
+100 -5
View File
@@ -1,3 +1,17 @@
//! Диспетчеризация входящих кадров туннеля по их типу и `stream_id`.
//!
//! [`StreamHandler`] — это «маршрутизатор» на приёмной стороне: один кадр входит,
//! и в зависимости от типа происходит одно из:
//! - `Heartbeat` → ответить PONG / измерить RTT / переслать локально;
//! - `Connect`/`UdpConnect` → (только сервер) открыть соединение к цели;
//! - `Data`/`UdpData` → доставить данные в локальный поток (с backpressure);
//! - `Close` → закрыть поток.
//!
//! Открытием реальных соединений к целям занимается [`RemoteOpener`] (есть только
//! на сервере: у клиента `opener == None`, поэтому входящие `Connect` отвергаются).
//! Каждое открытое соединение защищено [`CancellationToken`] — при эвикте/закрытии
//! потока мост и установка соединения мгновенно обрываются.
use bytes::Bytes; use bytes::Bytes;
use netrunner_logger::{debug, error, info, trace, warn}; use netrunner_logger::{debug, error, info, trace, warn};
use std::sync::Arc; use std::sync::Arc;
@@ -15,11 +29,21 @@ use crate::net::{
}; };
use crate::nrxp::{Frame, FrameType}; use crate::nrxp::{Frame, FrameType};
/// Открыватель реальных соединений к целям (серверная сторона туннеля).
///
/// На каждый входящий `Connect`/`UdpConnect` поднимает TCP/UDP-сокет к цели и
/// запускает соответствующий мост, прокачивающий данные между туннелем и целью.
pub struct RemoteOpener { pub struct RemoteOpener {
pub muxer: Arc<Muxer>, pub muxer: Arc<Muxer>,
} }
impl RemoteOpener { impl RemoteOpener {
/// Открывает TCP-соединение к `target` и запускает TCP-мост.
///
/// Всё происходит в отдельной задаче. Установка соединения (тайм-аут 7 с) и
/// сам мост обёрнуты в `select!` с `token.cancelled()` — эвикт обрывает их
/// немедленно. При неудаче подключения шлёт `Close` обратно в туннель. По
/// завершении всегда снимает регистрацию потока.
pub async fn open_tcp( pub async fn open_tcp(
&self, &self,
stream_id: u32, stream_id: u32,
@@ -43,11 +67,30 @@ impl RemoteOpener {
info!(stream_id, "✅ [Remote] Connected in {:?}", start.elapsed()); info!(stream_id, "✅ [Remote] Connected in {:?}", start.elapsed());
let (r, w) = stream.into_split(); let (r, w) = stream.into_split();
// Credit-gated reads (Muxer::consume_credit) were tried here and
// reverted: tying read pacing to a network round-trip produced
// burst-then-stall downloads and jitter on the shared physical leg,
// on top of the local mpsc backpressure that already paced reads
// correctly. The Credit frame/API stays in Muxer for a possible
// future redesign but isn't wired up on this path anymore.
// 🔥 Защищаем и сам мост токеном отмены // 🔥 Защищаем и сам мост токеном отмены
tokio::select! { tokio::select! {
_ = token.cancelled() => { debug!(stream_id, "🔪 TCP bridge closed by Eviction"); } _ = token.cancelled() => { debug!(stream_id, "🔪 TCP bridge closed by Eviction"); }
_ = run_tcp_bridge(stream_id, r, w, muxer.clone(), v_rx) => {} _ = run_tcp_bridge(stream_id, r, w, muxer.clone(), v_rx) => {}
} }
// 🔥 Сообщаем клиенту, что поток завершён — неважно, из-за
// EOF цели, write-timeout ноги, истёкшего STREAM_PAUSE_BUDGET
// или нашей же эвикции по бэклогу. Раньше это отправлялось
// только при неудачном CONNECT: при штатном завершении моста
// клиент никогда не узнавал, что стрим кончился — его
// виртуальный TCP-сокет навсегда застревал в CloseWait (ждёт
// от нас Close, см. server_eof/socket.close() в клиентском
// TcpConnection::poll_and_process), и освобождался только
// 120-секундным idle-таймаутом, попутно замедляя весь движок.
let _ = muxer
.send_control(stream_id, FrameType::Close, Bytes::new())
.await;
} }
_ => { _ => {
error!(stream_id, "❌ [Remote] Target connection failed: {}", target); error!(stream_id, "❌ [Remote] Target connection failed: {}", target);
@@ -60,6 +103,8 @@ impl RemoteOpener {
}); });
} }
/// Биндит UDP-сокет, «подключает» его к `target` и запускает UDP-мост.
/// Так же защищено токеном отмены; по завершении снимает регистрацию потока.
pub async fn open_udp( pub async fn open_udp(
&self, &self,
stream_id: u32, stream_id: u32,
@@ -86,6 +131,9 @@ impl RemoteOpener {
} }
} }
/// Маршрутизатор входящих кадров. Наличие `opener` определяет роль:
/// `Some` — серверная сторона (умеет открывать соединения к целям),
/// `None` — клиентская (входящие `Connect` отвергаются).
pub(crate) struct StreamHandler { pub(crate) struct StreamHandler {
muxer: Arc<Muxer>, muxer: Arc<Muxer>,
opener: Option<Arc<RemoteOpener>>, opener: Option<Arc<RemoteOpener>>,
@@ -96,6 +144,9 @@ impl StreamHandler {
Self { muxer, opener } Self { muxer, opener }
} }
/// Диспетчеризует один кадр по типу. Для `Data`/`UdpData` доставка идёт через
/// `await` (backpressure ради сохранения порядка), для управляющих —
/// в отдельных задачах, чтобы не блокировать reader ноги.
pub(crate) async fn handle(&self, frame: Frame) { pub(crate) async fn handle(&self, frame: Frame) {
let stream_id = frame.header.stream_id; let stream_id = frame.header.stream_id;
@@ -112,7 +163,7 @@ impl StreamHandler {
}); });
} else if payload == b"PONG" { } else if payload == b"PONG" {
trace!(stream_id, "🤝 [Tunnel] PONG received"); trace!(stream_id, "🤝 [Tunnel] PONG received");
self.muxer.dispatch_to_local(stream_id, frame.payload).await; self.muxer.dispatch_to_local(stream_id, frame.payload);
} else { } else {
if self.opener.is_some() { if self.opener.is_some() {
trace!( trace!(
@@ -141,17 +192,54 @@ impl StreamHandler {
} }
FrameType::Data | FrameType::UdpData => { FrameType::Data | FrameType::UdpData => {
// MUST .await — maintains in-order delivery via back-pressure. // Non-blocking: in-order delivery is guaranteed by the stream's
self.muxer.dispatch_to_local(stream_id, frame.payload).await; // single persistent backlog-drainer task, not by awaiting here.
self.muxer.dispatch_to_local(stream_id, frame.payload);
} }
FrameType::Close => { FrameType::Close => {
debug!(stream_id, "🏁 [Tunnel] Peer closed stream"); debug!(stream_id, "🏁 [Tunnel] Peer closed stream");
self.muxer.remove_stream(stream_id); self.muxer.remove_stream(stream_id);
} }
FrameType::Credit => {
// Сквозной flow control (см. Muxer::consume_credit/grant_credit):
// приёмник шлёт "можешь прислать ещё N байт". Синхронно и дёшево —
// просто прибавляет к атомарному счётчику и будит ждущего отправителя.
if let Ok(bytes) = frame.payload.as_ref().try_into().map(u32::from_be_bytes) {
trace!(stream_id, bytes, "💳 [Tunnel] Credit received");
self.muxer.grant_credit(stream_id, bytes);
} else {
warn!(stream_id, "Malformed Credit frame payload, ignoring");
} }
} }
FrameType::Diag => {
// Диагностика клиента, доставленная по туннелю. Осмысленна только
// на сервере: пересылаем в сток вместе с id сессии (берём из
// muxer'а — на сервере это сессия этой ноги). На клиенте сток не
// поднят, поэтому отчёт просто отбрасывается. Никогда не идёт в
// локальные сокеты и не маршрутизируется как данные.
let session_id = self.muxer.session_id().to_string();
let json_line = String::from_utf8_lossy(&frame.payload).into_owned();
trace!(
session_id = %session_id,
bytes = json_line.len(),
"🩺 [Tunnel] Client diagnostics report received"
);
crate::net::diagnostics::report_client_diag(
crate::net::diagnostics::ClientDiagReport {
session_id,
json_line,
},
);
}
}
}
/// Обрабатывает `Connect`/`UdpConnect`: регистрирует поток (получая токен
/// отмены) и просит [`RemoteOpener`] открыть соединение. На клиенте (нет
/// opener) — отказ с `Close`. `payload` несёт адрес цели строкой `"ip:port"`.
async fn handle_conn_request(&self, stream_id: u32, payload: Bytes, is_udp: bool) { async fn handle_conn_request(&self, stream_id: u32, payload: Bytes, is_udp: bool) {
let target = String::from_utf8_lossy(&payload).to_string(); let target = String::from_utf8_lossy(&payload).to_string();
@@ -159,8 +247,15 @@ impl StreamHandler {
let cap = NetworkConfig::global().channel_capacity; let cap = NetworkConfig::global().channel_capacity;
let (v_tx, v_rx) = mpsc::channel::<Bytes>(cap); let (v_tx, v_rx) = mpsc::channel::<Bytes>(cap);
// 🔥 Собираем токен для мгновенного обрыва связи при Eviction // 🔥 Собираем токен для мгновенного обрыва связи при Eviction.
let cancel_token = self.muxer.register_stream(stream_id, v_tx); // Больший бэклог, чем клиентский дефолт: реальная цель в интернете
// медленнее и капризнее локального TUN — аплоаду нужен запас (см.
// SERVER_STREAM_BACKLOG_MAX_BYTES).
let cancel_token = self.muxer.register_stream_with_backlog_cap(
stream_id,
v_tx,
crate::net::SERVER_STREAM_BACKLOG_MAX_BYTES,
);
if is_udp { if is_udp {
opener.open_udp(stream_id, target, v_rx, cancel_token).await; opener.open_udp(stream_id, target, v_rx, cancel_token).await;
+15
View File
@@ -1,3 +1,18 @@
//! Подмодуль `connection` — собственно машинерия туннеля.
//!
//! Самый плотный по логике участок крейта. Делится по ролям:
//!
//! - [`muxer`] — **мультиплексор**: реестр потоков и ног, балансировка
//! (`select_leg`), эвикт упавших ног, failover потоков, оценка RTT
//! ([`GLOBAL_MIN_RTT`]).
//! - [`engine`] — **движок ноги**: пара задач reader/writer на одно TCP-соединение,
//! шифрование/дешифрование кадров, heartbeat, переподключение.
//! - [`connection`] — обёртки над TCP/SOCKS, [`SessionManager`] и хендлеры:
//! [`ClientHandler`] (SOCKS→потоки), [`ServerHandler`] (приём туннеля + stealth-fallback),
//! [`TunnelHandler`].
//! - [`handler`] — диспетчеризация входящих кадров по `stream_id`/типу к нужному мосту.
//! - [`bridge`] — проксирование данных между потоком туннеля и реальным TCP/UDP-сокетом цели.
mod bridge; mod bridge;
mod connection; mod connection;
mod engine; mod engine;
+604 -62
View File
@@ -1,18 +1,73 @@
//! Мультиплексор: распределение логических потоков по физическим ногам туннеля.
//!
//! Сердце сетевого ядра и единственный по-настоящему конкурентный компонент.
//! [`Muxer`] держит реестр ног (TCP-соединений) и потоков (`stream_id`) и решает,
//! по какой ноге отправить каждый кадр. Спроектирован под высокую нагрузку:
//!
//! - **Lock-free горячий путь.** Реестры — это [`DashMap`] (шардированный), а
//! снапшот ног для выбора — [`ArcSwap`] (чтение = атомарный bump `Arc`, без
//! read-guard). См. поле `active_legs_cache`.
//! - **Sticky-привязка + ребаланс.** Поток «прилипает» к ноге
//! (`stream_bindings`), но при её падении мгновенно переезжает на лучшую из
//! оставшихся (`select_leg`). Среди равных по качеству ног — round-robin, чтобы
//! всплеск новых потоков не сел на одну «лучшую» ногу (thundering herd).
//! - **Anti-domino failover.** Падение ноги НЕ закрывает поток: дохлая нога
//! эвиктится, кадр переотправляется на соседнюю; `Err` только когда живых ног
//! нет вовсе — и тогда мост делает паузу с буфером, а не сброс (см.
//! `send_to_network` и `run_tcp_bridge`).
//! - **Анти-bufferbloat доставка.** Входящие кадры доставляются неблокирующим
//! `try_send` (`dispatch_to_local`); если канал потока временно полон, кадр
//! уходит в его персональный байтовый бэклог вместо ожидания — общий reader
//! ноги никогда не блокируется на медленном потребителе (head-of-line) и
//! никогда сам не мутирует реестр потоков. Бэклог дренит отдельная
//! persistent-задача на поток; закрытие "зависшего" потока — исключительно
//! работа фонового `spawn_backlog_reaper` (байтовый бюджет
//! [`STREAM_BACKLOG_MAX_BYTES`]/[`crate::net::SERVER_STREAM_BACKLOG_MAX_BYTES`]
//! **и** отсутствие прогресса дольше RTT-адаптивного grace-окна —
//! [`adaptive_write_timeout`] от [`crate::net::BACKLOG_STUCK_GRACE`], та же
//! логика, что и у медленной-но-живой ноги, — фиксированные 5с раньше
//! ошибочно убивали, например, upload в реальную цель под высоким RTT) —
//! решение никогда не принимается изнутри горячего пути доставки, см.
//! докстринг `StreamBacklog`.
//! - **Credit flow control (сейчас не подключён).** В `Muxer` остаётся API
//! (`init_credit`/`grant_credit`/`consume_credit`) и кадр `FrameType::Credit`
//! для сквозного окна получатель→отправитель — идея была не дать отправителю
//! производить данные быстрее приёмника, вместо того чтобы копить и потом
//! эвиктить. На практике привязка паузы к сетевому round-trip (ожидание
//! `Credit`-кадра) оказалась хуже уже работавшего локального backpressure
//! `data_tx.send().await` (тот реагирует мгновенно, без RTT): давала
//! burst-then-stall на скачивании и подрывала джиттер/пинг на общей ноге.
//! Отключено в `run_tcp_bridge` (там просто читают без гейта), байтовый
//! бэклог + reaper выше остаются единственной защитой.
//!
//! ## Адаптация под RTT
//!
//! [`GLOBAL_MIN_RTT`] обновляется по heartbeat'ам (EWMA). От него зависят
//! [`adaptive_write_timeout`] (не убивать медленную, но живую ногу) и
//! [`adaptive_batch_chunk`] (под высоким RTT слать кадры большими пачками,
//! экономя syscalls).
use arc_swap::ArcSwap; use arc_swap::ArcSwap;
use bytes::Bytes; use bytes::Bytes;
use dashmap::DashMap; use dashmap::DashMap;
use netrunner_logger::{info, instrument, trace, warn, AppError, ERR_INFRA_TIMEOUT}; use netrunner_logger::{info, instrument, trace, warn, AppError, ERR_INFRA_TIMEOUT};
use std::sync::atomic::{AtomicU32, AtomicU64, Ordering}; use std::collections::VecDeque;
use std::sync::Arc; use std::sync::atomic::{AtomicU32, AtomicU64, AtomicUsize, Ordering};
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant}; use std::time::{Duration, Instant};
use tokio::sync::mpsc::Sender; use tokio::sync::mpsc::{error::TrySendError, Sender};
use tokio::sync::Notify;
use tokio_util::sync::CancellationToken; use tokio_util::sync::CancellationToken;
use crate::net::diagnostics::{self, DiagnosticsEvent, LegMetrics, TunnelMetrics, DIAG_COUNTERS}; use crate::net::diagnostics::{self, DiagnosticsEvent, LegMetrics, TunnelMetrics, DIAG_COUNTERS};
use crate::net::{DISPATCH_TO_LOCAL_TIMEOUT, MAX_TUNNEL_LEGS}; use crate::net::{
BACKLOG_REAPER_IDLE_TIMEOUT, BACKLOG_REAPER_INTERVAL, BACKLOG_STUCK_GRACE, MAX_TUNNEL_LEGS,
STREAM_BACKLOG_MAX_BYTES,
};
use crate::net::INITIAL_RTT_MS; use crate::net::INITIAL_RTT_MS;
use crate::nrxp::FrameType; use crate::nrxp::FrameType;
/// Атомарная статистика одной ноги: переданные/принятые байты и сглаженный RTT.
#[derive(Default, Debug)] #[derive(Default, Debug)]
pub struct LegStats { pub struct LegStats {
pub tx_bytes: AtomicU64, pub tx_bytes: AtomicU64,
@@ -20,12 +75,103 @@ pub struct LegStats {
pub rtt_ms: AtomicU32, pub rtt_ms: AtomicU32,
} }
/// Атомарная статистика одного потока: переданные/принятые байты.
#[derive(Default, Debug)] #[derive(Default, Debug)]
pub struct StreamStats { pub struct StreamStats {
pub tx_bytes: AtomicU64, pub tx_bytes: AtomicU64,
pub rx_bytes: AtomicU64, pub rx_bytes: AtomicU64,
} }
/// Байтовый бэклог одного потока на приём.
///
/// Когда канал потока временно полон, кадры копятся здесь вместо того, чтобы
/// блокировать общий ридер ноги (`dispatch_to_local` никогда не ждёт и никогда
/// не мутирует реестр потоков — только кладёт кадр сюда). Решение "поток
/// по-настоящему завис, закрыть" принимает ИСКЛЮЧИТЕЛЬНО фоновый
/// `Muxer::spawn_backlog_reaper`, а не сам вызов доставки: так вызов, держащий
/// `Ref` в `Muxer::streams`, никогда не пытается сам же удалить свой ключ из
/// той же шарды DashMap (что раньше приводило к самоблокировке потока ОС —
/// DashMap не поддерживает реентерабельные локи).
///
/// Условие эвикции у ридера — не просто байтовый бюджет (`cap_bytes`), а бюджет
/// **и** отсутствие прогресса дольше [`BACKLOG_STUCK_GRACE`]: так отличаем
/// «бэклог большой, потому что источник быстрый и продолжает сливаться» от
/// «бэклог большой, потому что потребитель встал намертво».
struct StreamBacklog {
queue: Mutex<VecDeque<Bytes>>,
bytes: AtomicUsize,
cap_bytes: usize,
notify: Notify,
/// Метка времени (мс, `current_timestamp_ms`) последней успешной доставки
/// этому потоку — неважно, быстрым путём или через дренер бэклога.
last_progress_ms: AtomicU64,
}
impl StreamBacklog {
fn new(cap_bytes: usize) -> Self {
Self {
queue: Mutex::new(VecDeque::new()),
bytes: AtomicUsize::new(0),
cap_bytes,
notify: Notify::new(),
last_progress_ms: AtomicU64::new(diagnostics::current_timestamp_ms()),
}
}
/// Кладёт кадр в бэклог. Никогда не отказывает и не трогает `Muxer::streams` —
/// решение "хватит ждать" не отсюда, см. докстринг типа.
fn push(&self, data: Bytes, size: u64) {
self.bytes.fetch_add(size as usize, Ordering::AcqRel);
self.queue.lock().unwrap().push_back(data);
self.notify.notify_one();
}
/// Отмечает успешную доставку: сбрасывает счётчик "с каких пор нет прогресса".
fn mark_progress(&self) {
self.last_progress_ms
.store(diagnostics::current_timestamp_ms(), Ordering::Relaxed);
}
}
/// Кредитное окно одного потока на СТОРОНЕ ОТПРАВИТЕЛЯ: сколько байт ещё можно
/// протолкнуть в туннель, прежде чем ждать `Credit`-кадр от приёмника.
///
/// Существует отдельно от `StreamBacklog` (тот — на стороне приёмника, отвечает
/// за "что делать, если консьюмер не успевает"). Кредит — упреждающая мера:
/// если он работает как задумано, `StreamBacklog` почти никогда не разрастается,
/// потому что отправитель сам не производит данные быстрее, чем приёмник может
/// их принять. `available` — `i64`, а не `usize`, чтобы `fetch_sub` мог уводить
/// его в отрицательные значения без паники при гонках (`consume_credit` всё
/// равно трактует `<= 0` как "кредита нет").
struct CreditState {
available: std::sync::atomic::AtomicI64,
notify: Notify,
/// Метка времени (мс) последнего РЕАЛЬНОГО пополнения — либо `init_credit`,
/// либо `grant_credit` от входящего `Credit`-кадра. `consume_credit`
/// откатывается на неограниченную отправку, только если с последнего
/// такого пополнения прошло больше [`crate::net::CREDIT_FALLBACK_AFTER`] —
/// НЕ если истёк дедлайн текущего вызова (это была ошибка: дедлайн
/// пересчитывался с нуля на каждый вызов `consume_credit`, поэтому после
/// исчерпания стартового окна на высокой скорости отправитель получал
/// жалкие `BRIDGE_READ_CHUNK` раз в `CREDIT_FALLBACK_AFTER` — то есть
/// credit-контроль топил скачивание СИЛЬНЕЕ, чем если бы его не было
/// вовсе, вместо того чтобы просто подождать очередной грант).
last_grant_ms: AtomicU64,
}
/// Регистрационная запись потока в реестре `Muxer::streams`.
struct StreamSlot {
tx: Sender<Bytes>,
stats: Arc<StreamStats>,
token: CancellationToken,
backlog: Arc<StreamBacklog>,
}
/// Одна нога туннеля = одно физическое TCP+TLS-соединение.
///
/// Два раздельных канала к writer-задаче ноги: `control_tx` (Close/Heartbeat,
/// приоритетные) и `data_tx` (данные, с backpressure). `Clone` дёшев — внутри
/// `Arc`/`Sender`, поэтому ногу можно копировать из кэша без затрат.
#[derive(Clone)] #[derive(Clone)]
struct MuxLeg { struct MuxLeg {
id: u32, id: u32,
@@ -35,6 +181,8 @@ struct MuxLeg {
} }
impl MuxLeg { impl MuxLeg {
/// Степень загруженности `data`-канала: 0.0 — пусто, 1.0 — канал полностью
/// забит. Используется в скоринге ног при выборе (`select_leg`).
fn congestion_factor(&self) -> f64 { fn congestion_factor(&self) -> f64 {
let max = self.data_tx.max_capacity(); let max = self.data_tx.max_capacity();
let current_capacity = self.data_tx.capacity(); let current_capacity = self.data_tx.capacity();
@@ -43,6 +191,10 @@ impl MuxLeg {
} }
} }
/// Генератор `stream_id`, разводящий клиента и сервер по чётности.
///
/// Клиент выдаёт нечётные id (1,3,5…), сервер — чётные (2,4,6…). Так две стороны
/// независимо открывают потоки, не споря за номера. Шаг — `+2`, атомарно.
struct IdGenerator { struct IdGenerator {
counter: AtomicU32, counter: AtomicU32,
} }
@@ -58,6 +210,8 @@ impl IdGenerator {
} }
} }
/// Единица передачи через muxer: что отправить (`data`), какого типа и в какой
/// поток. Передаётся по каналам ноги к её writer-задаче.
#[derive(Clone)] #[derive(Clone)]
pub struct MuxMessage { pub struct MuxMessage {
pub(crate) stream_id: u32, pub(crate) stream_id: u32,
@@ -96,8 +250,29 @@ pub fn adaptive_batch_chunk(base: usize) -> usize {
base.saturating_mul(factor) base.saturating_mul(factor)
} }
/// Credit window that grows with RTT, same idea as [`adaptive_batch_chunk`].
///
/// A stream's credit window should hold roughly one bandwidth-delay product
/// in flight so the sender never has to stall waiting for a grant under
/// normal operation. A flat window sized for a healthy path (tens of ms RTT)
/// would be far too small once RTT climbs into the hundreds/low thousands of
/// ms (mobile network, as seen in production — `GLOBAL_MIN_RTT` peaks well
/// past 1 s): the fallback in `consume_credit` prevents that from ever
/// stalling a stream outright, but scaling the window up front means it
/// mostly doesn't need to. Wider cap than `adaptive_batch_chunk` (up to 8×,
/// matching `SERVER_STREAM_BACKLOG_MAX_BYTES` at the top end) since BDP grows
/// with RTT much faster than a comfortable interleave chunk does.
pub fn adaptive_credit_window(base: u32) -> u32 {
let rtt_ms = GLOBAL_MIN_RTT.load(Ordering::Relaxed) as u32;
let factor = (1 + rtt_ms / 250).clamp(1, 8);
base.saturating_mul(factor)
}
/// Мультиплексор туннеля. Дёшево клонируется (всё внутри `Arc`) и шарится между
/// всеми задачами ног и потоков.
#[derive(Clone)] #[derive(Clone)]
pub struct Muxer { pub struct Muxer {
/// Источник истины по ногам (id → нога). Шардированная карта, lock-free.
legs: Arc<DashMap<u32, MuxLeg>>, legs: Arc<DashMap<u32, MuxLeg>>,
// 🔥 ОПТИМИЗАЦИЯ: полностью lock-free кэш горячего пути. // 🔥 ОПТИМИЗАЦИЯ: полностью lock-free кэш горячего пути.
// ArcSwap: чтение (load_full) — атомарный bump Arc без блокировок; запись // ArcSwap: чтение (load_full) — атомарный bump Arc без блокировок; запись
@@ -105,37 +280,191 @@ pub struct Muxer {
// чтение брало read-guard. // чтение брало read-guard.
active_legs_cache: Arc<ArcSwap<Vec<MuxLeg>>>, active_legs_cache: Arc<ArcSwap<Vec<MuxLeg>>>,
// Добавили CancellationToken для предотвращения утечек памяти (Зомби-задач) /// Реестр потоков: id → регистрационная запись (канал, статистика, токен,
streams: Arc<DashMap<u32, (Sender<Bytes>, Arc<StreamStats>, CancellationToken)>>, /// бэклог). Токен мгновенно убивает связанные с потоком задачи при `remove_stream`.
streams: Arc<DashMap<u32, StreamSlot>>,
/// Кредитные окна потоков, для которых ЭТА сторона — отправитель (см.
/// [`CreditState`]). Отдельная карта от `streams`: та — про приём, эта —
/// про то, сколько ещё можно отправить, не дожидаясь `Credit`-кадра.
credits: Arc<DashMap<u32, Arc<CreditState>>>,
/// Sticky-привязка потока к ноге (`stream_id` → `leg_id`).
stream_bindings: Arc<DashMap<u32, u32>>, stream_bindings: Arc<DashMap<u32, u32>>,
/// Время отправки PING по каждой ноге — для измерения RTT по PONG.
pending_pings: Arc<DashMap<u32, Instant>>, pending_pings: Arc<DashMap<u32, Instant>>,
/// Генератор `stream_id` (чётность по роли).
id_gen: Arc<IdGenerator>, id_gen: Arc<IdGenerator>,
/// Идентификатор сессии (для логов/топологии).
session_id: Arc<String>, session_id: Arc<String>,
/// Rotating cursor for round-robin leg selection among similar-quality legs, /// Rotating cursor for round-robin leg selection among similar-quality legs,
/// so a burst of new streams spreads across legs instead of all binding to /// so a burst of new streams spreads across legs instead of all binding to
/// the single current-best one (thundering herd). /// the single current-best one (thundering herd).
rr_counter: Arc<AtomicU32>, rr_counter: Arc<AtomicU32>,
/// Байты ног, которые уже отцеплены (реконнект/эвикт) — без этого
/// `total_bytes()` был бы не монотонным: у новой ноги счётчик стартует с
/// нуля, и частые переподключения занижали бы расход трафика для лимитов
/// (см. `total_bytes`/`fold_removed_leg`).
cumulative_tx: Arc<AtomicU64>,
cumulative_rx: Arc<AtomicU64>,
/// Идентификатор юзера-владельца сессии для отчёта о расходе трафика
/// прокси-серверу (`None` — авторизация выключена или это клиентская
/// сторона муксера, отчёты о трафике шлёт только сервер).
quota_user_id: Arc<ArcSwap<Option<String>>>,
/// Сколько байт (tx+rx) уже было отчитано бэкенду по этой сессии —
/// следующий тик репортит только дельту сверх этого значения.
quota_reported_bytes: Arc<AtomicU64>,
} }
impl Muxer { impl Muxer {
pub fn new(is_client: bool, session_id: String) -> Self { pub fn new(is_client: bool, session_id: String) -> Self {
Self { let muxer = Self {
legs: Arc::new(DashMap::new()), legs: Arc::new(DashMap::new()),
active_legs_cache: Arc::new(ArcSwap::from_pointee(Vec::new())), active_legs_cache: Arc::new(ArcSwap::from_pointee(Vec::new())),
streams: Arc::new(DashMap::new()), streams: Arc::new(DashMap::new()),
credits: Arc::new(DashMap::new()),
stream_bindings: Arc::new(DashMap::new()), stream_bindings: Arc::new(DashMap::new()),
id_gen: Arc::new(IdGenerator::new(is_client)), id_gen: Arc::new(IdGenerator::new(is_client)),
pending_pings: Arc::new(DashMap::new()), pending_pings: Arc::new(DashMap::new()),
session_id: Arc::new(session_id), session_id: Arc::new(session_id),
rr_counter: Arc::new(AtomicU32::new(0)), rr_counter: Arc::new(AtomicU32::new(0)),
} cumulative_tx: Arc::new(AtomicU64::new(0)),
cumulative_rx: Arc::new(AtomicU64::new(0)),
quota_user_id: Arc::new(ArcSwap::from_pointee(None)),
quota_reported_bytes: Arc::new(AtomicU64::new(0)),
};
muxer.spawn_backlog_reaper();
muxer
} }
/// Складывает байты ноги, которая уходит из `legs` (эвикт/реконнект), в
/// сессионный кумулятивный счётчик — вызывать сразу после `DashMap::remove`.
fn fold_removed_leg(&self, leg: &MuxLeg) {
self.cumulative_tx
.fetch_add(leg.stats.tx_bytes.load(Ordering::Relaxed), Ordering::Relaxed);
self.cumulative_rx
.fetch_add(leg.stats.rx_bytes.load(Ordering::Relaxed), Ordering::Relaxed);
}
/// Суммарный трафик сессии (все ноги, включая уже отцепленные) — источник
/// истины для отчётов о расходе прокси-серверу бэкенду.
pub fn total_bytes(&self) -> (u64, u64) {
let mut tx = self.cumulative_tx.load(Ordering::Relaxed);
let mut rx = self.cumulative_rx.load(Ordering::Relaxed);
for leg in self.active_legs_cache.load_full().iter() {
tx += leg.stats.tx_bytes.load(Ordering::Relaxed);
rx += leg.stats.rx_bytes.load(Ordering::Relaxed);
}
(tx, rx)
}
/// Привязывает сессию к юзеру бэкенда — вызывается один раз при успешной
/// проверке auth-токена первой ноги сессии (см. `ServerHandler::run`).
pub fn set_quota_user(&self, user_id: String) {
self.quota_user_id.store(Arc::new(Some(user_id)));
}
pub fn quota_user_id(&self) -> Option<String> {
self.quota_user_id.load_full().as_ref().clone()
}
/// Дельта трафика с прошлого репорта и (не блокирующий) сдвиг базовой
/// точки — вызывающий обязан либо реально отправить дельту бэкенду, либо
/// не звать этот метод (в отличие от `store`, здесь нет отмены на ошибку:
/// невозможность связаться с бэкендом не должна накапливать неограниченно
/// растущую "недоотчитанную" дельту).
pub fn take_usage_delta(&self) -> u64 {
let (tx, rx) = self.total_bytes();
let total = tx + rx;
let last = self.quota_reported_bytes.swap(total, Ordering::Relaxed);
total.saturating_sub(last)
}
/// Откатывает базовую точку назад на `delta` — вызывать, если репорт
/// бэкенду не удался, чтобы не потерять дельту навсегда.
pub fn rollback_usage_delta(&self, delta: u64) {
self.quota_reported_bytes
.fetch_sub(delta.min(self.quota_reported_bytes.load(Ordering::Relaxed)), Ordering::Relaxed);
}
/// Фоновый "ридер" бэклогов: единственное место, которое реально закрывает
/// поток за зависший бэклог (см. докстринг [`StreamBacklog`]). Никогда не
/// вызывается изнутри `dispatch_to_local` — работает по расписанию, вне
/// любых `Ref`-гвардов `Muxer::streams`, поэтому структурно не может
/// повторить самоблокировку DashMap.
///
/// Держит собственный клон `Muxer` (дёшево — всё внутри `Arc`), поэтому
/// самостоятельно завершается, если сессия опустела (нет ног и потоков)
/// дольше [`BACKLOG_REAPER_IDLE_TIMEOUT`] — иначе на сервере, обслужившем
/// много клиентов, эти задачи копились бы вечно.
fn spawn_backlog_reaper(&self) {
let muxer = self.clone();
tokio::spawn(async move {
let mut idle_since: Option<Instant> = None;
loop {
tokio::time::sleep(BACKLOG_REAPER_INTERVAL).await;
if muxer.active_legs_count() == 0 && muxer.streams.is_empty() {
let since = *idle_since.get_or_insert_with(Instant::now);
if since.elapsed() >= BACKLOG_REAPER_IDLE_TIMEOUT {
trace!("Backlog reaper: muxer idle, stopping");
return;
}
continue;
}
idle_since = None;
let now = diagnostics::current_timestamp_ms();
// Same reasoning as adaptive_write_timeout: a stuck-but-alive
// consumer under high/variable RTT (e.g. the server writing a
// client's uploaded bytes into a slow real target) needs more
// than a flat grace window before it's judged dead — a fixed 5 s
// was fine for the low-RTT case this was tuned against, but
// evicted legitimately-slow-but-recovering streams once RTT (or
// its variance) climbed, exactly where this reaper replaced the
// old adaptive_write_timeout-only protection on that path.
let grace = adaptive_write_timeout(BACKLOG_STUCK_GRACE);
let grace_ms = grace.as_millis() as u64;
let stuck: Vec<u32> = muxer
.streams
.iter()
.filter_map(|kv| {
let backlog = &kv.value().backlog;
let over_budget = backlog.bytes.load(Ordering::Relaxed) > backlog.cap_bytes;
let stale = now
.saturating_sub(backlog.last_progress_ms.load(Ordering::Relaxed))
>= grace_ms;
(over_budget && stale).then_some(*kv.key())
})
.collect();
// Evict AFTER the .iter() above is fully dropped (collected into
// an owned Vec) — removing a key while iterating the same
// DashMap would hold a shard Ref and a write-lock request on it
// at once, exactly the self-deadlock this design avoids.
for stream_id in stuck {
DIAG_COUNTERS
.mux_dispatch_full_closed
.fetch_add(1, Ordering::Relaxed);
warn!(
stream_id,
"Backlog reaper: over budget with no progress for {:?} — closing stream",
grace
);
muxer.remove_stream(stream_id);
}
}
});
}
/// Пересобирает lock-free снапшот ног из источника истины (`legs`) и
/// атомарно публикует его в `active_legs_cache`. Вызывается при любом
/// изменении набора ног (add/remove).
fn update_legs_cache(&self) { fn update_legs_cache(&self) {
let new_cache: Vec<MuxLeg> = self.legs.iter().map(|kv| kv.value().clone()).collect(); let new_cache: Vec<MuxLeg> = self.legs.iter().map(|kv| kv.value().clone()).collect();
self.active_legs_cache.store(Arc::new(new_cache)); self.active_legs_cache.store(Arc::new(new_cache));
} }
/// Регистрирует новую ногу (после установки TCP+TLS). Если лимит
/// [`MAX_TUNNEL_LEGS`] достигнут и это не обновление существующей — игнор.
pub fn add_leg( pub fn add_leg(
&self, &self,
leg_id: u32, leg_id: u32,
@@ -171,13 +500,19 @@ impl Muxer {
self.stream_bindings.retain(|_, bound_leg| *bound_leg != leg_id); self.stream_bindings.retain(|_, bound_leg| *bound_leg != leg_id);
} }
/// Безопасно эвиктит ногу, но только если её текущий `control_tx` совпадает с
/// `tx` (защита от удаления ноги, уже переподключённой под тем же id). Сначала
/// снимает привязки, потом обновляет кэш — чтобы конкурентный `select_leg` не
/// привязался к эвиктируемой ноге.
pub fn remove_leg(&self, leg_id: u32, tx: &Sender<MuxMessage>) { pub fn remove_leg(&self, leg_id: u32, tx: &Sender<MuxMessage>) {
let should_remove = self let should_remove = self
.legs .legs
.get(&leg_id) .get(&leg_id)
.map_or(false, |leg| leg.control_tx.same_channel(tx)); .map_or(false, |leg| leg.control_tx.same_channel(tx));
if should_remove { if should_remove {
self.legs.remove(&leg_id); if let Some((_, leg)) = self.legs.remove(&leg_id) {
self.fold_removed_leg(&leg);
}
// Unbind streams BEFORE refreshing the cache so a concurrent // Unbind streams BEFORE refreshing the cache so a concurrent
// select_leg never re-binds a stream to the leg we are evicting. // select_leg never re-binds a stream to the leg we are evicting.
self.clear_bindings_for_leg(leg_id); self.clear_bindings_for_leg(leg_id);
@@ -189,24 +524,38 @@ impl Muxer {
} }
} }
/// Безусловно удаляет ногу (без сверки канала) — при выходе её движка.
pub fn force_remove_leg(&self, leg_id: u32) { pub fn force_remove_leg(&self, leg_id: u32) {
if self.legs.remove(&leg_id).is_some() { if let Some((_, leg)) = self.legs.remove(&leg_id) {
self.fold_removed_leg(&leg);
self.clear_bindings_for_leg(leg_id); self.clear_bindings_for_leg(leg_id);
self.update_legs_cache(); self.update_legs_cache();
info!(leg_id, "MUXER: TCP leg force-removed on engine exit"); info!(leg_id, "MUXER: TCP leg force-removed on engine exit");
} }
} }
/// Сбрасывает все ноги и привязки (полная остановка туннеля).
pub fn remove_all_legs(&self) { pub fn remove_all_legs(&self) {
for entry in self.legs.iter() {
self.fold_removed_leg(entry.value());
}
self.legs.clear(); self.legs.clear();
self.stream_bindings.clear(); self.stream_bindings.clear();
self.update_legs_cache(); self.update_legs_cache();
} }
/// Число активных ног.
pub fn active_legs_count(&self) -> usize { pub fn active_legs_count(&self) -> usize {
self.legs.len() self.legs.len()
} }
/// Выбирает ногу для отправки кадра потока `stream_id`.
///
/// Двухуровнево: (1) горячий путь — привязанный поток резолвит ногу по id
/// прямо из `legs` (без скана и клонирования кэша); (2) новый/осиротевший
/// поток скорится по всем ногам (RTT доминирует, congestion лишь модулирует),
/// из ног в пределах 2× от лучшего скора выбирается round-robin, и привязка
/// фиксируется. Подробности скоринга — в inline-комментариях ниже.
fn select_leg(&self, stream_id: u32) -> Option<MuxLeg> { fn select_leg(&self, stream_id: u32) -> Option<MuxLeg> {
// 1. FAST PATH (hot, per data frame): a bound stream resolves its leg by // 1. FAST PATH (hot, per data frame): a bound stream resolves its leg by
// id straight from the legs map — no full-cache Arc clone and no vector // id straight from the legs map — no full-cache Arc clone and no vector
@@ -267,10 +616,13 @@ impl Muxer {
None None
} }
/// Запоминает момент отправки PING по ноге (для замера RTT по PONG).
pub fn record_ping_sent(&self, leg_id: u32) { pub fn record_ping_sent(&self, leg_id: u32) {
self.pending_pings.insert(leg_id, Instant::now()); self.pending_pings.insert(leg_id, Instant::now());
} }
/// Обрабатывает PONG: считает RTT и обновляет сглаженную оценку (EWMA, α=0.25),
/// затем пересчитывает глобальный минимум [`GLOBAL_MIN_RTT`] по всем ногам.
pub async fn record_pong(&self, leg_id: u32) { pub async fn record_pong(&self, leg_id: u32) {
if let Some((_, start_time)) = self.pending_pings.remove(&leg_id) { if let Some((_, start_time)) = self.pending_pings.remove(&leg_id) {
let measured = start_time.elapsed().as_millis() as u32; let measured = start_time.elapsed().as_millis() as u32;
@@ -298,6 +650,15 @@ impl Muxer {
} }
} }
/// Отправляет кадр в сеть, выбирая ногу и применяя стратегию по типу кадра.
///
/// - **Данные** (`Data`/`UdpData`): `send().await` (backpressure) с
/// anti-domino failover в цикле — при мёртвой ноге эвикт + переотправка на
/// другую; `Err` лишь когда живых ног нет.
/// - **Критичные** (`Close`/`Heartbeat`): надёжно через `send().await`
/// (потеря Close течёт ресурсы, потеря PONG валит health-check).
/// - **Прочий контроль**: `try_send`; при переполнении кадр дропается с
/// сигналом `ControlChannelFull`, не блокируя.
#[instrument(skip(self, message), fields(session_id = %self.session_id, stream_id = message.stream_id, frame = ?message.frame_type))] #[instrument(skip(self, message), fields(session_id = %self.session_id, stream_id = message.stream_id, frame = ?message.frame_type))]
pub async fn send_to_network(&self, mut message: MuxMessage) -> Result<(), AppError> { pub async fn send_to_network(&self, mut message: MuxMessage) -> Result<(), AppError> {
let is_data = matches!(message.frame_type, FrameType::Data | FrameType::UdpData); let is_data = matches!(message.frame_type, FrameType::Data | FrameType::UdpData);
@@ -332,7 +693,7 @@ impl Muxer {
if let Some(stream_ref) = self.streams.get(&stream_id) { if let Some(stream_ref) = self.streams.get(&stream_id) {
stream_ref stream_ref
.value() .value()
.1 .stats
.tx_bytes .tx_bytes
.fetch_add(size, Ordering::Relaxed); .fetch_add(size, Ordering::Relaxed);
} }
@@ -381,7 +742,7 @@ impl Muxer {
if let Some(stream_ref) = self.streams.get(&stream_id) { if let Some(stream_ref) = self.streams.get(&stream_id) {
stream_ref stream_ref
.value() .value()
.1 .stats
.tx_bytes .tx_bytes
.fetch_add(size, Ordering::Relaxed); .fetch_add(size, Ordering::Relaxed);
} }
@@ -399,7 +760,7 @@ impl Muxer {
if let Some(stream_ref) = self.streams.get(&stream_id) { if let Some(stream_ref) = self.streams.get(&stream_id) {
stream_ref stream_ref
.value() .value()
.1 .stats
.tx_bytes .tx_bytes
.fetch_add(size, Ordering::Relaxed); .fetch_add(size, Ordering::Relaxed);
} }
@@ -428,6 +789,7 @@ impl Muxer {
} }
} }
/// Удобная обёртка для отправки данных потока (выбирает `Data`/`UdpData`).
pub async fn send_data_safe( pub async fn send_data_safe(
&self, &self,
stream_id: u32, stream_id: u32,
@@ -446,6 +808,29 @@ impl Muxer {
.await .await
} }
/// Идентификатор сессии этого мультиплексора (для логов/топологии и для
/// именования пер-сессионных файлов диагностики на сервере).
pub fn session_id(&self) -> &str {
&self.session_id
}
/// Отправляет на сервер одну строку клиентской диагностики `Diag`-кадром.
///
/// Холодный путь: едет по контрольному каналу с best-effort семантикой (как и
/// прочий не-критичный контроль — при переполнении канала кадр дропается, не
/// блокируя). Возвращает `false`, только если живых ног нет вовсе — тогда
/// вызывающая сторона может оставить снапшот в очереди и повторить позже.
pub async fn send_diag_report(&self, payload: Bytes) -> bool {
self.send_to_network(MuxMessage {
stream_id: 0,
frame_type: FrameType::Diag,
data: payload,
})
.await
.is_ok()
}
/// Удобная обёртка для отправки управляющего кадра заданного типа.
pub(crate) async fn send_control( pub(crate) async fn send_control(
&self, &self,
stream_id: u32, stream_id: u32,
@@ -460,102 +845,258 @@ impl Muxer {
.await .await
} }
/// Регистрирует поток с бэклогом по умолчанию ([`STREAM_BACKLOG_MAX_BYTES`])
/// и возвращает его [`CancellationToken`]. Канал `tx` используется для
/// доставки входящих данных потоку (`dispatch_to_local`).
pub fn register_stream(&self, stream_id: u32, tx: Sender<Bytes>) -> CancellationToken { pub fn register_stream(&self, stream_id: u32, tx: Sender<Bytes>) -> CancellationToken {
self.register_stream_with_backlog_cap(stream_id, tx, STREAM_BACKLOG_MAX_BYTES)
}
/// Регистрирует поток с явным байтовым бюджетом бэклога. Используется
/// сервером для потоков к реальной цели ([`SERVER_STREAM_BACKLOG_MAX_BYTES`]),
/// где нужен запас больше дефолтного — см. модульный докстринг.
pub fn register_stream_with_backlog_cap(
&self,
stream_id: u32,
tx: Sender<Bytes>,
backlog_cap_bytes: usize,
) -> CancellationToken {
let token = CancellationToken::new(); let token = CancellationToken::new();
let stats = Arc::new(StreamStats::default());
let backlog = Arc::new(StreamBacklog::new(backlog_cap_bytes));
Self::spawn_backlog_drainer(
stream_id,
tx.clone(),
backlog.clone(),
stats.clone(),
token.clone(),
);
self.streams.insert( self.streams.insert(
stream_id, stream_id,
(tx, Arc::new(StreamStats::default()), token.clone()), StreamSlot {
tx,
stats,
token: token.clone(),
backlog,
},
); );
token token
} }
/// Persistent-задача одного потока: спит на [`Notify`], по пробуждению сливает
/// весь накопленный бэклог в реальный канал потребителя блокирующим `send`
/// (сколько угодно времени — здесь нет тайм-аута). Ровно одна такая задача на
/// поток за всё время его жизни, поэтому порядок доставки внутри потока не
/// нарушается, в отличие от спавна задачи на каждый кадр.
fn spawn_backlog_drainer(
stream_id: u32,
tx: Sender<Bytes>,
backlog: Arc<StreamBacklog>,
stats: Arc<StreamStats>,
token: CancellationToken,
) {
tokio::spawn(async move {
loop {
tokio::select! {
biased;
_ = token.cancelled() => return,
_ = backlog.notify.notified() => {}
}
loop {
let item = backlog.queue.lock().unwrap().pop_front();
let Some(item) = item else { break };
let len = item.len() as u64;
if tx.send(item).await.is_err() {
// Consumer dropped its receiver — remove_stream elsewhere
// will clean up the entry; nothing more to drain into.
trace!(stream_id, "backlog drainer: consumer channel closed, stopping");
return;
}
backlog.bytes.fetch_sub(len as usize, Ordering::AcqRel);
backlog.mark_progress();
stats.rx_bytes.fetch_add(len, Ordering::Relaxed);
DIAG_COUNTERS.mux_dispatch_ok.fetch_add(1, Ordering::Relaxed);
}
}
});
}
/// Удаляет поток, отменяя его токен (мгновенно гасит связанные задачи, включая
/// бэклог-дренер) и снимая привязку к ноге.
pub fn remove_stream(&self, stream_id: u32) { pub fn remove_stream(&self, stream_id: u32) {
// 🔥 Мгновенно убиваем "зомби-задачи", привязанные к стриму! // 🔥 Мгновенно убиваем "зомби-задачи", привязанные к стриму!
if let Some((_, (_, _, token))) = self.streams.remove(&stream_id) { if let Some((_, slot)) = self.streams.remove(&stream_id) {
token.cancel(); slot.token.cancel();
} }
self.stream_bindings.remove(&stream_id); self.stream_bindings.remove(&stream_id);
} }
// ORDERING CONTRACT: in-order delivery — never spawn a task to deliver data // ORDERING CONTRACT: preserved by construction — each stream has exactly one
// from this function. // persistent backlog-drainer task (spawned once, at register_stream), so this
// function never spawns per-frame and never reorders within a stream.
// //
// HEAD-OF-LINE GUARD: the hot path is a non-blocking try_send, so one slow or // HEAD-OF-LINE GUARD: this function never awaits. The hot path is a
// dead stream can NEVER block the shared per-leg reader. (A finished speedtest // non-blocking try_send; when the channel is momentarily full, the frame is
// socket the app stopped reading used to back its channel up and freeze EVERY // queued in the stream's own backlog and the call returns immediately — the
// other download on that leg, because the reader awaited here for up to 10 s.) // shared per-leg reader can move on to the next frame/stream right away.
// Only a genuinely-full channel gets a SHORT grace wait (DISPATCH_TO_LOCAL_ //
// TIMEOUT); if it is still full that ONE stream is closed so the leg keeps // NO Ref HELD ACROSS A MUTATING CALL: the DashMap `Ref` from `streams.get`
// serving everyone else. // is dropped the instant we've cloned the owned handles we need (`tx`,
pub async fn dispatch_to_local(&self, stream_id: u32, data: Bytes) { // `stats`, `backlog` — all cheap Arc/Sender clones). This function never
// calls anything that mutates `self.streams` for this same key — eviction
// is entirely the background reaper's job (see `spawn_backlog_reaper`) —
// so there is no risk of a shard read-lock (still held by an outer `Ref`)
// deadlocking against that shard's write-lock (DashMap locks are not
// reentrant). An earlier version held the `Ref` across a nested
// `remove_stream` call here and could self-deadlock the calling task.
pub fn dispatch_to_local(&self, stream_id: u32, data: Bytes) {
let size = data.len() as u64; let size = data.len() as u64;
let tx_and_stats = self.streams.get(&stream_id).map(|s| { let Some((tx, stats, backlog)) = self.streams.get(&stream_id).map(|entry| {
let val = s.value(); let slot = entry.value();
(val.0.clone(), val.1.clone()) (slot.tx.clone(), slot.stats.clone(), slot.backlog.clone())
}); }) else {
let Some((tx, stats)) = tx_and_stats else {
// No stream registered for this id (already closed / never opened). // No stream registered for this id (already closed / never opened).
DIAG_COUNTERS DIAG_COUNTERS
.mux_dispatch_no_stream .mux_dispatch_no_stream
.fetch_add(1, Ordering::Relaxed); .fetch_add(1, Ordering::Relaxed);
return; return;
}; };
let backlog_empty = backlog.bytes.load(Ordering::Acquire) == 0;
// Fast path: deliver without awaiting → zero head-of-line blocking. // Fast path: nothing queued ahead of this frame, try to hand it straight
let data = match tx.try_send(data) { // to the consumer without ever touching the backlog.
if backlog_empty {
match tx.try_send(data) {
Ok(()) => { Ok(()) => {
stats.rx_bytes.fetch_add(size, Ordering::Relaxed); stats.rx_bytes.fetch_add(size, Ordering::Relaxed);
backlog.mark_progress();
DIAG_COUNTERS.mux_dispatch_ok.fetch_add(1, Ordering::Relaxed); DIAG_COUNTERS.mux_dispatch_ok.fetch_add(1, Ordering::Relaxed);
return; return;
} }
// Receiver already closed — stream gone. Err(TrySendError::Closed(_)) => {
Err(tokio::sync::mpsc::error::TrySendError::Closed(_)) => {
DIAG_COUNTERS DIAG_COUNTERS
.mux_dispatch_recv_closed .mux_dispatch_recv_closed
.fetch_add(1, Ordering::Relaxed); .fetch_add(1, Ordering::Relaxed);
return; return;
} }
// Channel full: recover the payload and fall through to a bounded wait. Err(TrySendError::Full(data)) => {
Err(tokio::sync::mpsc::error::TrySendError::Full(data)) => data, backlog.push(data, size);
}
}
} else {
backlog.push(data, size);
}
}
/// Инициализирует кредитное окно потока: столько байт отправитель (эта
/// сторона) может протолкнуть, не дожидаясь `Credit`-кадра от приёмника.
/// Вызывает тот, кто НАЧИНАЕТ производить данные для потока (например,
/// `RemoteOpener::open_tcp` перед запуском моста к цели).
pub fn init_credit(&self, stream_id: u32, initial_bytes: u32) {
self.credits.insert(
stream_id,
Arc::new(CreditState {
available: std::sync::atomic::AtomicI64::new(initial_bytes as i64),
notify: Notify::new(),
last_grant_ms: AtomicU64::new(diagnostics::current_timestamp_ms()),
}),
);
}
/// Снимает кредитное окно потока. Вызывать при завершении отправки для
/// этого потока (симметрично `remove_stream`, но для другой карты —
/// `credits` живёт по циклу жизни ОТПРАВКИ, а не приёма).
pub fn drop_credit(&self, stream_id: u32) {
self.credits.remove(&stream_id);
}
/// Обрабатывает входящий `Credit`-кадр: пополняет окно и будит того, кто
/// сейчас ждёт кредит в [`consume_credit`]. No-op, если для этого
/// `stream_id` кредит не инициализирован (например, кадр пришёл уже после
/// `drop_credit`, или писала сторона, которая credit вообще не считает).
pub fn grant_credit(&self, stream_id: u32, bytes: u32) {
if let Some(state) = self.credits.get(&stream_id) {
let state = state.value();
state.available.fetch_add(bytes as i64, Ordering::AcqRel);
state
.last_grant_ms
.store(diagnostics::current_timestamp_ms(), Ordering::Relaxed);
state.notify.notify_one();
}
}
/// Ждёт, пока для потока не появится кредит, и забирает `min(available, want)`
/// байт. Возвращает `want` без ожидания, если credit для этого потока не
/// инициализирован (тот, кто вызвал, просто не участвует в этой схеме —
/// поведение как до появления credit-контроля).
///
/// Не блокирует НАВСЕГДА: если приёмник ни разу не прислал `Credit` дольше
/// [`CREDIT_FALLBACK_AFTER`] С МОМЕНТА ПОСЛЕДНЕГО РЕАЛЬНОГО ГРАНТА (не
/// понимает кадр, или сильно отстал), считаем credit-контроль неработающим
/// для этого потока и откатываемся на неограниченную отправку — байтовый
/// бюджет бэклога и его ридер остаются подстраховкой в любом случае.
///
/// Дедлайн считается от `last_grant_ms`, а НЕ от момента входа в эту
/// функцию: на высокой скорости `consume_credit` вызывается на каждый
/// ~64 КБ чанк, и если бы каждый вызов заново отсчитывал полный
/// `CREDIT_FALLBACK_AFTER`, окно, работающее штатно, но чуть отстающее от
/// потребления, топило бы скачивание до пары кадров в 10 секунд — то есть
/// сильнее, чем при полном отсутствии credit-контроля. Пока приёмник шлёт
/// гранты хоть с какой-то регулярностью, ожидание прерывается по `notify`
/// в течение примерно одного RTT, а не по этому дедлайну.
pub async fn consume_credit(&self, stream_id: u32, want: usize) -> usize {
let Some(state) = self.credits.get(&stream_id).map(|e| e.value().clone()) else {
return want;
}; };
match tokio::time::timeout(DISPATCH_TO_LOCAL_TIMEOUT, tx.send(data)).await { loop {
Ok(Ok(_)) => { let avail = state.available.load(Ordering::Acquire);
stats.rx_bytes.fetch_add(size, Ordering::Relaxed); if avail > 0 {
DIAG_COUNTERS.mux_dispatch_ok.fetch_add(1, Ordering::Relaxed); let take = (avail as usize).min(want);
state
.available
.fetch_sub(take as i64, Ordering::AcqRel);
return take;
} }
Ok(Err(_)) => { let since_last_grant = diagnostics::current_timestamp_ms()
DIAG_COUNTERS .saturating_sub(state.last_grant_ms.load(Ordering::Relaxed));
.mux_dispatch_recv_closed if since_last_grant >= crate::net::CREDIT_FALLBACK_AFTER.as_millis() as u64 {
.fetch_add(1, Ordering::Relaxed); trace!(
}
Err(_) => {
// Consumer stayed full past the grace window: close just this one
// stream so the leg keeps serving everyone else.
DIAG_COUNTERS
.mux_dispatch_full_closed
.fetch_add(1, Ordering::Relaxed);
warn!(
stream_id, stream_id,
"dispatch_to_local: stream stalled for {:?}, closing", DISPATCH_TO_LOCAL_TIMEOUT "consume_credit: no grant for {:?} — falling back to unrestricted",
crate::net::CREDIT_FALLBACK_AFTER
); );
self.remove_stream(stream_id); return want;
} }
let _ = tokio::time::timeout(
crate::net::CREDIT_WAIT_POLL,
state.notify.notified(),
)
.await;
} }
} }
/// Учитывает принятые ногой байты в её статистике.
pub fn record_leg_rx(&self, leg_id: u32, bytes: u64) { pub fn record_leg_rx(&self, leg_id: u32, bytes: u64) {
if let Some(leg) = self.legs.get(&leg_id) { if let Some(leg) = self.legs.get(&leg_id) {
leg.stats.rx_bytes.fetch_add(bytes, Ordering::Relaxed); leg.stats.rx_bytes.fetch_add(bytes, Ordering::Relaxed);
} }
} }
/// Следующий свободный `stream_id` (с учётом чётности роли).
pub fn next_stream_id(&self) -> u32 { pub fn next_stream_id(&self) -> u32 {
self.id_gen.next() self.id_gen.next()
} }
/// Прогоняет health-check по всем ногам: PING с уникальным probe-потоком и
/// ожидание PONG в пределах [`HEALTH_CHECK_TIMEOUT`](crate::net::HEALTH_CHECK_TIMEOUT).
///
/// Тонкости (см. inline): закрытый канал → немедленный эвикт; временно
/// полный → пропуск цикла (нога жива, просто занята); перед эвиктом по
/// тайм-ауту сверяется, что нога не переподключилась под тем же id.
pub async fn perform_health_check(&self) { pub async fn perform_health_check(&self) {
let leg_ids: Vec<u32> = self.legs.iter().map(|kv| *kv.key()).collect(); let leg_ids: Vec<u32> = self.legs.iter().map(|kv| *kv.key()).collect();
@@ -642,6 +1183,8 @@ impl Muxer {
} }
} }
/// Печатает в лог дерево топологии туннеля: ноги (трафик/RTT), виртуальные
/// потоки и кумулятивные счётчики здоровья пайплайна. Чисто диагностика.
pub fn print_topology_tree(&self) { pub fn print_topology_tree(&self) {
let mut out = String::new(); let mut out = String::new();
out.push_str(&format!( out.push_str(&format!(
@@ -649,8 +1192,7 @@ impl Muxer {
self.session_id self.session_id
)); ));
let mut total_tx = 0; let (total_tx, total_rx) = self.total_bytes();
let mut total_rx = 0;
let mut legs_info = Vec::new(); let mut legs_info = Vec::new();
let cached_legs = self.active_legs_cache.load_full(); let cached_legs = self.active_legs_cache.load_full();
@@ -658,8 +1200,6 @@ impl Muxer {
let tx = leg.stats.tx_bytes.load(Ordering::Relaxed); let tx = leg.stats.tx_bytes.load(Ordering::Relaxed);
let rx = leg.stats.rx_bytes.load(Ordering::Relaxed); let rx = leg.stats.rx_bytes.load(Ordering::Relaxed);
let rtt = leg.stats.rtt_ms.load(Ordering::Relaxed); let rtt = leg.stats.rtt_ms.load(Ordering::Relaxed);
total_tx += tx;
total_rx += rx;
let rtt_str = if rtt == 0 { let rtt_str = if rtt == 0 {
"N/A".to_string() "N/A".to_string()
@@ -699,8 +1239,8 @@ impl Muxer {
.map(|kv| { .map(|kv| {
( (
*kv.key(), *kv.key(),
kv.value().1.tx_bytes.load(Ordering::Relaxed), kv.value().stats.tx_bytes.load(Ordering::Relaxed),
kv.value().1.rx_bytes.load(Ordering::Relaxed), kv.value().stats.rx_bytes.load(Ordering::Relaxed),
) )
}) })
.collect(); .collect();
@@ -769,6 +1309,7 @@ impl Muxer {
congestion_factor, congestion_factor,
data_channel_free: free, data_channel_free: free,
data_channel_capacity: cap, data_channel_capacity: cap,
session_id: self.session_id.to_string(),
} }
}) })
.collect(); .collect();
@@ -777,6 +1318,7 @@ impl Muxer {
global_min_rtt_ms: global_min_rtt, global_min_rtt_ms: global_min_rtt,
active_legs, active_legs,
total_streams: self.streams.len(), total_streams: self.streams.len(),
session_count: 1,
} }
} }
} }
+103 -20
View File
@@ -1,8 +1,19 @@
//! Все «магические числа» сетевого ядра в одном месте.
//!
//! Сгруппированы по назначению (пулы, тайм-ауты, аутентификация, порты, stealth,
//! кодек, тюнинг сокетов). Многие значения — результат борьбы с конкретными
//! проблемами (bufferbloat, «эффект домино» при падении ноги, рассинхрон часов);
//! у таких констант в `///`-комментарии объяснено, **почему** именно это число, а
//! не просто что оно значит. Меняя их, читайте обоснование рядом.
use std::time::Duration; use std::time::Duration;
// ── Connection pool ────────────────────────────────────────────────────────── // ── Connection pool ──────────────────────────────────────────────────────────
/// Максимум одновременных smoltcp-сокетов (виртуальных соединений) на клиенте.
pub const MAX_SOCKETS: usize = 256; pub const MAX_SOCKETS: usize = 256;
/// Сколько параллельных TCP-ног держит туннель (для throughput и отказоустойчивости).
pub const MAX_TUNNEL_LEGS: u32 = 4; pub const MAX_TUNNEL_LEGS: u32 = 4;
/// Размер пула мультиплексоров.
pub const MUXER_POOL_SIZE: usize = 3; pub const MUXER_POOL_SIZE: usize = 3;
/// Weight applied to observed congestion when scoring tunnel legs. /// Weight applied to observed congestion when scoring tunnel legs.
pub const MUXER_CONGESTION_WEIGHT: f64 = 2000.0; pub const MUXER_CONGESTION_WEIGHT: f64 = 2000.0;
@@ -10,12 +21,19 @@ pub const MUXER_CONGESTION_WEIGHT: f64 = 2000.0;
pub const INITIAL_RTT_MS: u32 = 250; pub const INITIAL_RTT_MS: u32 = 250;
// ── Timeouts ───────────────────────────────────────────────────────────────── // ── Timeouts ─────────────────────────────────────────────────────────────────
/// Тайм-аут TCP-хендшейка к целевому хосту (серверная сторона).
pub const TCP_HANDSHAKE_TIMEOUT: Duration = Duration::from_secs(20); pub const TCP_HANDSHAKE_TIMEOUT: Duration = Duration::from_secs(20);
/// Простой UDP-сессии, после которого она считается завершённой.
pub const UDP_IDLE_TIMEOUT: Duration = Duration::from_secs(15); pub const UDP_IDLE_TIMEOUT: Duration = Duration::from_secs(15);
/// Глобальный простой соединения до его закрытия.
pub const GLOBAL_IDLE_TIMEOUT: Duration = Duration::from_secs(120); pub const GLOBAL_IDLE_TIMEOUT: Duration = Duration::from_secs(120);
/// Период health-check'ов ног туннеля (heartbeat/проверка живости).
pub const HEALTH_CHECK_INTERVAL: Duration = Duration::from_secs(3); pub const HEALTH_CHECK_INTERVAL: Duration = Duration::from_secs(3);
/// Сколько ждать ответа на health-check, прежде чем счесть ногу мёртвой.
pub const HEALTH_CHECK_TIMEOUT: Duration = Duration::from_secs(20); pub const HEALTH_CHECK_TIMEOUT: Duration = Duration::from_secs(20);
/// Пауза перед переподключением упавшей ноги.
pub const LEG_RECONNECT_DELAY: Duration = Duration::from_secs(2); pub const LEG_RECONNECT_DELAY: Duration = Duration::from_secs(2);
/// Простой моста (стрима) до его закрытия.
pub const BRIDGE_IDLE_TIMEOUT: Duration = Duration::from_secs(30); pub const BRIDGE_IDLE_TIMEOUT: Duration = Duration::from_secs(30);
/// Max time to wait for a local app socket to accept downloaded data. /// Max time to wait for a local app socket to accept downloaded data.
/// If the app's receive buffer stays full longer than this, the connection /// If the app's receive buffer stays full longer than this, the connection
@@ -28,13 +46,63 @@ pub const BRIDGE_STREAM_WRITE_TIMEOUT: Duration = Duration::from_secs(30);
pub const STREAM_PAUSE_BUDGET: Duration = Duration::from_secs(30); pub const STREAM_PAUSE_BUDGET: Duration = Duration::from_secs(30);
/// Poll interval while a paused upload stream waits for a leg to come back. /// Poll interval while a paused upload stream waits for a leg to come back.
pub const STREAM_PAUSE_RETRY: Duration = Duration::from_millis(250); pub const STREAM_PAUSE_RETRY: Duration = Duration::from_millis(250);
/// Grace window dispatch_to_local waits when a stream's receive channel is full /// Memory budget for one stream's local-delivery backlog (see
/// before closing that ONE stream. The hot path now uses try_send (no await), so /// `Muxer::dispatch_to_local`). When a stream's receive channel is momentarily
/// this applies only to a genuinely backed-up consumer; kept short so a slow or /// full, frames queue here instead of blocking the shared per-leg reader — so a
/// dead stream (e.g. a finished speedtest socket the app stopped reading) can /// slow-but-alive consumer (disk write hiccup, TUN backpressure, scheduler
/// never head-of-line-block the shared per-leg reader and freeze every other /// jitter) gets as long as it needs to drain, while a genuinely dead stream
/// download on that leg (was 10 s — caused multi-second download stalls). /// (e.g. a finished speedtest socket the app stopped reading) is caught by
pub const DISPATCH_TO_LOCAL_TIMEOUT: Duration = Duration::from_millis(300); /// exceeding this bound rather than by guessing a latency. Bytes, not
/// milliseconds, because "how slow is too slow" has no universal answer but
/// "how much unread data are we willing to hold for one stalled stream" does.
pub const STREAM_BACKLOG_MAX_BYTES: usize = 4 * 1024 * 1024;
/// Same budget, but for server-side streams (tunnel → real internet target,
/// i.e. the user's upload direction — see `bridge.rs` module docs for the
/// upload/download naming). Bigger than the client default: a remote target
/// is inherently slower and more variable than the local TUN device, so
/// uploads need more slack before a stalled target is judged dead.
pub const SERVER_STREAM_BACKLOG_MAX_BYTES: usize = 16 * 1024 * 1024;
/// How often the background backlog reaper (`Muxer::spawn_backlog_reaper`) scans
/// streams for genuinely stuck consumers. Runs off the hot path entirely — the
/// dispatch call itself never evicts anything — so this only bounds how far a
/// dead stream's backlog can overshoot its byte budget between ticks.
pub const BACKLOG_REAPER_INTERVAL: Duration = Duration::from_millis(500);
/// Grace window: a stream over its backlog byte budget is evicted only once it
/// has ALSO made no delivery progress for this long. Separates "backlog is
/// big because the producer is fast and still draining" from "backlog is big
/// because the consumer stopped entirely" — a raw byte cap alone can't tell
/// those apart, and evicting the former destabilizes healthy fast downloads.
pub const BACKLOG_STUCK_GRACE: Duration = Duration::from_secs(5);
/// How long a `Muxer` with zero legs and zero streams is kept alive before its
/// backlog reaper self-terminates. Without this, every session's reaper task
/// (and the Arc'd registries it keeps alive) would leak forever on a server
/// that has served many short-lived client sessions.
pub const BACKLOG_REAPER_IDLE_TIMEOUT: Duration = Duration::from_secs(120);
// ── End-to-end credit flow control (Muxer::init_credit/grant_credit/consume_credit) ──
/// Initial credit window granted to a stream's sender: how many bytes it may
/// push into the tunnel before it must wait for the receiver to grant more via
/// a `Credit` frame. Bounds how much can ever be "in flight" for one stream —
/// unlike the local byte-budget backlog (a last-resort backstop), this stops
/// the sender from ever producing the excess in the first place, so a slow
/// receiver never has to buffer-then-give-up.
pub const STREAM_CREDIT_INITIAL: u32 = 2 * 1024 * 1024;
/// The receiver batches freed bytes and sends one `Credit` frame per this many
/// bytes reclaimed, instead of one per delivered frame — same idea as TCP
/// delayed window updates, avoids flooding tiny control frames. Deliberately
/// finer than a quarter of the (possibly RTT-scaled, see
/// `adaptive_credit_window`) sender window: the receiver has no way to know
/// the sender's actual multiplier, and smaller/more frequent grants keep the
/// window topped up with less slack regardless of how big it ended up being.
pub const STREAM_CREDIT_RETURN_THRESHOLD: u32 = STREAM_CREDIT_INITIAL / 8;
/// How long `consume_credit` waits on each poll before re-checking the balance
/// (bounds the delay from a `notify` race, not a hard deadline by itself).
pub const CREDIT_WAIT_POLL: Duration = Duration::from_secs(2);
/// If the peer hasn't granted any credit at all for this long, treat it as not
/// speaking the credit protocol (or badly behind) and fall back to unrestricted
/// sending rather than stalling the stream forever — the byte-budget backlog
/// and its reaper remain the ultimate backstop either way.
pub const CREDIT_FALLBACK_AFTER: Duration = Duration::from_secs(10);
pub const TLS_HELLO_TIMEOUT: Duration = Duration::from_secs(10); pub const TLS_HELLO_TIMEOUT: Duration = Duration::from_secs(10);
pub const SECURE_HANDSHAKE_TIMEOUT: Duration = Duration::from_secs(20); pub const SECURE_HANDSHAKE_TIMEOUT: Duration = Duration::from_secs(20);
pub const FALLBACK_CONNECT_TIMEOUT: Duration = Duration::from_secs(5); pub const FALLBACK_CONNECT_TIMEOUT: Duration = Duration::from_secs(5);
@@ -62,10 +130,15 @@ pub const TOPOLOGY_PRINT_INTERVAL: Duration = Duration::from_secs(10);
pub const STATS_LOG_INTERVAL: Duration = Duration::from_secs(5); pub const STATS_LOG_INTERVAL: Duration = Duration::from_secs(5);
// ── Authentication ─────────────────────────────────────────────────────────── // ── Authentication ───────────────────────────────────────────────────────────
/// Длительность одного шага time-based auth-тега в секундах (TOTP-«окно»).
/// Тег меняется раз в 60 с — см. [`SessionAuth`](crate::nrxp).
pub const AUTH_TIME_STEP: u64 = 60; pub const AUTH_TIME_STEP: u64 = 60;
/// Допуск на рассинхрон часов при проверке тега: ±2 шага (~±2 минуты).
/// Сужает окно replay, оставляя запас под дрейф NTP и сетевые задержки.
pub const AUTH_WINDOW_SIZE: u64 = 2; pub const AUTH_WINDOW_SIZE: u64 = 2;
// ── Well-known ports ───────────────────────────────────────────────────────── // ── Well-known ports ─────────────────────────────────────────────────────────
// Известные порты для эвристик классификации трафика (heavy/light, спец-обработка).
pub const DNS_PORT: u16 = 53; pub const DNS_PORT: u16 = 53;
pub const HTTP_PORT: u16 = 80; pub const HTTP_PORT: u16 = 80;
pub const HTTPS_PORT: u16 = 443; pub const HTTPS_PORT: u16 = 443;
@@ -78,9 +151,17 @@ pub const NTP_PORT: u16 = 123;
pub const NETBIOS_PORTS: [u16; 2] = [137, 138]; pub const NETBIOS_PORTS: [u16; 2] = [137, 138];
// ── TLS / stealth ──────────────────────────────────────────────────────────── // ── TLS / stealth ────────────────────────────────────────────────────────────
/// Hostname used as the SNI in the stealth TLS ClientHello. /// Домен-декой по умолчанию: и SNI клиентского `ClientHello`, и цель
pub const STEALTH_FALLBACK_SNI: &str = "ubuntu.com"; /// server-side stealth-fallback (см. [`ServerHandler`](crate::net::ServerHandler)).
pub const STEALTH_FALLBACK_HOST: &str = "ubuntu.com:443"; /// Оба реальных сервера сейчас настраиваются на лету (CLI-флаг `--decoy-host`
/// у сервера, [`EngineConfig::with_decoy_sni`](../../../../client/src/net/engine.rs)
/// у клиента) — это значение только запасной дефолт, если ничего не задано явно.
///
/// Раньше здесь был захардкожен `ubuntu.com`: он у Fastly отдаёт `403` без
/// точного совпадения SNI/Host — ровно тот случай, когда браузер заходит прямо
/// по IP (SNI для IP-литералов не шлётся вовсе, RFC 6066). `www.debian.org` —
/// одиночный Apache-ориджин без CDN-роутинга, отдаёт `200` независимо от SNI/Host.
pub const DEFAULT_DECOY_HOST: &str = "www.debian.org";
// ── Tunnel frame codec ─────────────────────────────────────────────────────── // ── Tunnel frame codec ───────────────────────────────────────────────────────
/// OOM guard: drop the leg if the read buffer grows past this. /// OOM guard: drop the leg if the read buffer grows past this.
@@ -88,20 +169,22 @@ pub const TUNNEL_MAX_BUFFER_SIZE: usize = 1024 * 1024;
/// Bytes reserved in the read buffer before each `read_buf` call. /// Bytes reserved in the read buffer before each `read_buf` call.
pub const TUNNEL_READ_RESERVE: usize = 16 * 1024; pub const TUNNEL_READ_RESERVE: usize = 16 * 1024;
/// Maximum bytes written per stream in a single interleaved write pass. /// Maximum bytes written per stream in a single interleaved write pass.
/// Base value; adaptive_batch_chunk multiplies this by (1 + RTT_ms / 250) up to 4×.
/// At 300+ ms RTT, expect ~64 KB per pass (4× base), batching more frames per syscall.
pub const TUNNEL_INTERLEAVE_CHUNK: usize = 16 * 1024; pub const TUNNEL_INTERLEAVE_CHUNK: usize = 16 * 1024;
/// Max bytes a stream bridge reads per pass before producing a data message. /// Max bytes a stream bridge reads per pass before producing a data message.
/// Bounds the size of a single MuxMessage so the per-leg queue is byte-bounded /// At high RTT (>300 ms), bigger chunks reduce context switches and improve
/// (CHANNEL_PACKETS × this), keeping post-speedtest bufferbloat small. One NRXP /// coalescing in the writer. Increased to 64 KB: still fits in wire frames
/// frame is 16 KB, so reading in 16 KB units also aligns with the wire framing. /// (multiple 16 KB NRXP frames per message) while batching better.
pub const BRIDGE_READ_CHUNK: usize = 16 * 1024; /// Per-leg queue size = CHANNEL_PACKETS × this ≈ 64 × 64 KB = 4 MB baseline.
pub const BRIDGE_READ_CHUNK: usize = 64 * 1024;
// ── Tunnel leg TCP socket tuning ───────────────────────────────────────────── // ── Tunnel leg TCP socket tuning ─────────────────────────────────────────────
/// OS-level TCP send buffer for each tunnel leg. The default (48 MB on /// OS-level TCP send buffer for each tunnel leg. At high RTT (>300 ms),
/// Linux/Android) can hold seconds of data at typical mobile speeds, causing /// this must accommodate BDP = bandwidth × RTT. For 300 Mbps and 350 ms,
/// severe jitter. 128 KB limits extra queuing to ~40 ms at 25 Mbit/s per leg /// BDP ≈ 13 MB, so 1 MB per leg is a floor. Scales per-leg: 4 legs × 1 MB = 4 MB
/// while still providing enough headroom for TCP slow-start. (Halved from /// total OS buffer. Matches adaptive_batch_chunk logic (high RTT = bigger writes).
/// 256 KB to cut post-speedtest bufferbloat — see CHANNEL_PACKETS.) pub const TUNNEL_SOCKET_SNDBUF: u32 = 1024 * 1024;
pub const TUNNEL_SOCKET_SNDBUF: u32 = 128 * 1024;
/// OS-level TCP receive buffer for each tunnel leg. Larger than the send /// OS-level TCP receive buffer for each tunnel leg. Larger than the send
/// buffer so the receiver can absorb bursts without dropping packets, but /// buffer so the receiver can absorb bursts without dropping packets, but
/// bounded to keep stale in-flight download data (for already-closed streams) /// bounded to keep stale in-flight download data (for already-closed streams)
+91 -1
View File
@@ -1,3 +1,20 @@
//! Диагностика туннеля: события, метрики, счётчики и снапшоты.
//!
//! Холодный путь, не влияющий на горячую обработку пакетов. Состоит из трёх
//! независимых механизмов:
//!
//! 1. **Канал событий** ([`DiagnosticsEvent`]). Любой код из любого места делает
//! fire-and-forget [`send_diag_event`]; потребитель (на клиенте — движок, на
//! сервере — `Network::run`) держит приёмник из [`init_diagnostics`].
//! 2. **Атомарные счётчики** ([`DIAG_COUNTERS`]). Глобальные накопители событий
//! (сбои загрузки, отвалы ног, переполнения каналов и т.п.), инкрементируются
//! прямо в местах событий через `Relaxed`.
//! 3. **Снапшоты** ([`DiagnosticsSnapshot`] + [`DiagnosticsStore`]). По триггеру
//! собирается полный срез состояния (движок, ноги, сокеты, счётчики) и
//! кольцевым буфером хранятся последние N для выгрузки в JSON.
//!
//! Все структуры `Serialize` — снапшоты отдаются наружу как JSON для отладки.
use std::{ use std::{
collections::VecDeque, collections::VecDeque,
sync::{ sync::{
@@ -37,9 +54,49 @@ pub fn send_diag_event(event: DiagnosticsEvent) {
} }
} }
// ── Client-diagnostics sink (server side) ─────────────────────────────────────
/// Отчёт диагностики клиента, доставленный по туннелю на сервер.
///
/// Клиент по триггеру строит снапшот, сериализует его в одну JSON-строку и шлёт
/// `Diag`-кадром. Сервер принимает кадр, оборачивает payload в этот тип (вместе с
/// `session_id` ноги, по которой он пришёл) и кладёт в сток
/// [`report_client_diag`], откуда серверный логгер пишет его в пер-сессионный файл.
#[derive(Debug, Clone)]
pub struct ClientDiagReport {
/// Идентификатор сессии (источник имени файла на сервере).
pub session_id: String,
/// Одна готовая JSON-строка снапшота (как прислал клиент).
pub json_line: String,
}
/// Sender end of the client-diagnostics sink. Set once by the server via
/// [`init_client_diag_sink`]; stays `None` on the client, where reports are dropped.
static GLOBAL_CLIENT_DIAG_TX: OnceLock<mpsc::UnboundedSender<ClientDiagReport>> = OnceLock::new();
/// Серверная сторона: вызвать один раз при старте. Возвращает приёмник
/// клиентских отчётов, который логгер держит и сливает в пер-сессионные файлы.
pub fn init_client_diag_sink() -> mpsc::UnboundedReceiver<ClientDiagReport> {
let (tx, rx) = mpsc::unbounded_channel();
let _ = GLOBAL_CLIENT_DIAG_TX.set(tx);
rx
}
/// Fire-and-forget: переслать клиентский отчёт в сток. No-op, если сток не поднят
/// (т.е. на клиенте) — ровно как [`send_diag_event`] относительно своего канала.
pub fn report_client_diag(report: ClientDiagReport) {
if let Some(tx) = GLOBAL_CLIENT_DIAG_TX.get() {
let _ = tx.send(report);
}
}
// ── Event types ─────────────────────────────────────────────────────────────── // ── Event types ───────────────────────────────────────────────────────────────
/// What triggered this diagnostics snapshot. /// Событие, которое стало триггером снапшота (и единица потока событий).
///
/// Каждый вариант — это «что-то пошло не так или примечательно» на горячем пути:
/// сбой выгрузки, backpressure, отвал/переподключение ноги, переполнение
/// управляющего канала, застрявшая запись в туннель. Сериализуется с тегом `kind`.
#[derive(Debug, Clone, Serialize)] #[derive(Debug, Clone, Serialize)]
#[serde(tag = "kind", rename_all = "snake_case")] #[serde(tag = "kind", rename_all = "snake_case")]
pub enum DiagnosticsEvent { pub enum DiagnosticsEvent {
@@ -75,10 +132,16 @@ pub enum DiagnosticsEvent {
leg_id: u32, leg_id: u32,
stream_id: u32, stream_id: u32,
}, },
/// Периодический/стартовый снапшот без конкретного события-триггера — "всё
/// по-прежнему живо". Раньше для этого переиспользовали `LegReconnecting{0,0}`,
/// что в логе выглядело как настоящий (несуществующий) реконнект ноги 0.
Heartbeat,
} }
// ── Per-snapshot sub-structs (all Serialize) ────────────────────────────────── // ── Per-snapshot sub-structs (all Serialize) ──────────────────────────────────
/// Метрики движка (только клиент): трафик, глубины очередей устройства, свободное
/// место в каналах TUN↔engine.
#[derive(Debug, Clone, Serialize)] #[derive(Debug, Clone, Serialize)]
pub struct EngineMetrics { pub struct EngineMetrics {
pub rx_total_mb: f64, pub rx_total_mb: f64,
@@ -97,6 +160,7 @@ pub struct EngineMetrics {
pub tun_rx_channel_free: usize, pub tun_rx_channel_free: usize,
} }
/// Метрики одной ноги туннеля: RTT, объёмы, фактор перегруженности канала.
#[derive(Debug, Clone, Serialize)] #[derive(Debug, Clone, Serialize)]
pub struct LegMetrics { pub struct LegMetrics {
pub leg_id: u32, pub leg_id: u32,
@@ -107,15 +171,26 @@ pub struct LegMetrics {
pub congestion_factor: f64, pub congestion_factor: f64,
pub data_channel_free: usize, pub data_channel_free: usize,
pub data_channel_capacity: usize, pub data_channel_capacity: usize,
/// Сессия, которой принадлежит эта нога. На клиенте — всегда своя (одна на
/// процесс); на многоклиентском сервере разграничивает ноги разных сессий,
/// у которых `leg_id` иначе совпадали бы (каждая сессия нумерует свои ноги
/// независимо, 0..MAX_TUNNEL_LEGS).
pub session_id: String,
} }
/// Сводка по туннелю в целом: глобальный мин. RTT, метрики всех ног, число потоков.
#[derive(Debug, Clone, Serialize)] #[derive(Debug, Clone, Serialize)]
pub struct TunnelMetrics { pub struct TunnelMetrics {
pub global_min_rtt_ms: u32, pub global_min_rtt_ms: u32,
pub active_legs: Vec<LegMetrics>, pub active_legs: Vec<LegMetrics>,
pub total_streams: usize, pub total_streams: usize,
/// Сколько туннельных сессий покрывает этот снапшот. Для одного `Muxer`
/// (клиент) всегда 1; сервер агрегирует несколько сессий и подставляет сюда
/// реальное число одновременно подключённых клиентов.
pub session_count: usize,
} }
/// Метрики одного smoltcp-сокета (только клиент): состояние, очереди, congestion.
#[derive(Debug, Clone, Serialize)] #[derive(Debug, Clone, Serialize)]
pub struct SocketMetrics { pub struct SocketMetrics {
pub stream_id: u32, pub stream_id: u32,
@@ -143,6 +218,7 @@ pub struct ErrorCounters {
pub stream_errors: u64, pub stream_errors: u64,
} }
/// Полный срез состояния системы на момент триггер-события.
#[derive(Debug, Clone, Serialize)] #[derive(Debug, Clone, Serialize)]
pub struct DiagnosticsSnapshot { pub struct DiagnosticsSnapshot {
pub timestamp_ms: u64, pub timestamp_ms: u64,
@@ -157,8 +233,20 @@ pub struct DiagnosticsSnapshot {
pub error_totals: ErrorCounters, pub error_totals: ErrorCounters,
} }
impl DiagnosticsSnapshot {
/// Сериализует снапшот в одну компактную JSON-строку (без отступов и переводов
/// строк). Это формат, в котором клиент шлёт снапшот `Diag`-кадром, а сервер
/// дописывает его строкой в пер-сессионный JSONL-файл.
pub fn to_json_line(&self) -> String {
serde_json::to_string(self).unwrap_or_else(|e| format!("{{\"error\":\"{e}\"}}"))
}
}
// ── Atomic error counters (global, updated at event sites) ─────────────────── // ── Atomic error counters (global, updated at event sites) ───────────────────
/// Глобальные атомарные счётчики событий, инкрементируемые в местах их
/// возникновения. Включают «воронку» доставки загрузки (`mux_dispatch_*`),
/// по которой видно, куда деваются входящие кадры.
pub struct DiagnosticsCounters { pub struct DiagnosticsCounters {
pub upload_fails: AtomicU64, pub upload_fails: AtomicU64,
pub download_backpressure: AtomicU64, pub download_backpressure: AtomicU64,
@@ -210,6 +298,8 @@ pub static DIAG_COUNTERS: DiagnosticsCounters = DiagnosticsCounters::new();
// ── DiagnosticsStore — holds the last N snapshots ───────────────────────────── // ── DiagnosticsStore — holds the last N snapshots ─────────────────────────────
/// Кольцевой буфер последних N снапшотов под мьютексом (холодный путь —
/// блокировка не вредит). Отдаёт их наружу как JSON для отладки.
pub struct DiagnosticsStore { pub struct DiagnosticsStore {
snapshots: Mutex<VecDeque<DiagnosticsSnapshot>>, snapshots: Mutex<VecDeque<DiagnosticsSnapshot>>,
max_snapshots: usize, max_snapshots: usize,
+39
View File
@@ -1,8 +1,47 @@
//! # Сетевое ядро (`net`) — оркестровка живого туннеля
//!
//! Самый верхний блок крейта: здесь синхронный разбор протокола ([`nrxp`](crate::nrxp))
//! и асинхронная сеть `tokio` соединяются в работающий мультиплексированный
//! туннель. Всё, что ниже — крипта, кадры, маскировка — лишь «кирпичи», а этот
//! блок строит из них дом: слушает соединения, держит ноги туннеля, балансирует
//! потоки и проксирует трафик к целям.
//!
//! ## Состав блока
//!
//! | Файл/подмодуль | Ответственность |
//! |-----------------------|---------------------------------------------------------------------|
//! | [`config`] | [`NetworkConfig`] — MTU-зависимые размеры буферов и каналов. |
//! | `constants` | Тайм-ауты, лимиты, порты, тюнинг анти-bufferbloat (re-export `*`). |
//! | [`diagnostics`] | Сбор и снапшоты метрик туннеля (RTT, очереди, события ног). |
//! | `connection` | Ядро: соединения, мультиплексор, движок туннеля, мосты, хендлеры. |
//!
//! Подмодуль `connection` — самый крупный; его части:
//! - **muxer** — мультиплексор: распределяет потоки по ногам, балансирует, эвиктит;
//! - **engine** — `TunnelEngine`: reader/writer-задачи одной ноги, шифр/дешифр, heartbeat;
//! - **connection** — обёртки над TCP, SOCKS, `SessionManager`, хендлеры клиента/сервера;
//! - **handler** — обработка входящих кадров (диспетчеризация по `stream_id`/типу);
//! - **bridge** — проксирование TCP/UDP между потоком туннеля и реальной целью.
//!
//! ## Ключевая идея
//!
//! **Одно TCP-соединение (нога) = много логических потоков (`stream_id`).** Ради
//! устойчивости ног может быть несколько ([`MAX_TUNNEL_LEGS`]); поток «прилипает»
//! к ноге, а при её падении бесшовно переезжает на соседнюю (failover вместо
//! каскадного сброса). Подробности балансировки — в `connection::muxer`.
//!
//! ## Что экспортируется
//!
//! Наружу крейта выходят высокоуровневые сущности: [`NetworkConfig`],
//! хендлеры/менеджер сессий (через `connection`) и [`Muxer`] с глобальной оценкой
//! [`GLOBAL_MIN_RTT`], а также все константы.
mod auth;
mod config; mod config;
mod connection; mod connection;
mod constants; mod constants;
pub mod diagnostics; pub mod diagnostics;
pub use auth::{AuthValidator, UsageReport, UserQuota};
pub use config::NetworkConfig; pub use config::NetworkConfig;
pub use connection::{ pub use connection::{
ClientHandler, Connection, Muxer, ServerHandler, SessionManager, TunnelHandler, GLOBAL_MIN_RTT, ClientHandler, Connection, Muxer, ServerHandler, SessionManager, TunnelHandler, GLOBAL_MIN_RTT,
+53
View File
@@ -1 +1,54 @@
# Блок `nrxp` — протокол Netrunner eXchange Protocol
Прикладной протокол, который ездит **внутри** замаскированного TLS-канала. Снаружи
трафик выглядит как обычные TLS-записи `ApplicationData` (`0x17`), а внутри каждой
записи — один зашифрованный кадр NRXP с мультиплексированием потоков.
Блок отвечает только за «упаковку/распаковку» и ничего не знает о сети — он
превращает `(stream_id, тип, payload)` в байты и обратно.
> Детали — в rustdoc, модуль `nrxp`.
## Файлы
| Файл | Что внутри |
|--------------|------------------------------------------------------------------|
| `frame.rs` | Структура кадра, (де)сериализация, паддинг. |
| `codec.rs` | Шифрующий слой: `TxCodec`/`RxCodec` (кадр ⇄ зашифрованный TLS). |
| `bridge.rs` | TLS-обёртка `TlsBridge`: хендшейк и `ApplicationData`. |
| `errors.rs` | `TlsError` + стратегия реакции `ErrorAction` (Wait/Redirect/Drop).|
## Формат кадра (25-байтовый заголовок)
```text
┌──────────┬───────────┬──────┬─────────────┬─────────────┬─────────┬─────────┐
│ Auth Tag │ Stream ID │ Type │ Payload Len │ Padding Len │ Payload │ Padding │
│ 16 байт │ 4 байта │ 1 б. │ 2 байта │ 2 байта │ N байт │ 0..255 │
└──────────┴───────────┴──────┴─────────────┴─────────────┴─────────┴─────────┘
└────────────────── FRAME_HEADER_SIZE = 25 ──────────────────┘
```
- **Auth Tag** — TOTP-подобный HMAC-тег (см. [`crypto`](../crypto)), анти-replay.
- **Stream ID** — id логического потока (нечётные у клиента, чётные у сервера).
- **Type** — `Connect`/`Data`/`Close`/`Heartbeat`/`UdpConnect`/`UdpData`.
- **Padding** — против анализа длин. Управляющие кадры получают 0..255
случайных байт; `Data`/`UdpData` выравниваются до ближайшего бакета
(256/512/1024/2048/4096/8192) — кроме кадров, уже близких к максимальному
размеру (крупные закачки), где паддинг только бил бы по throughput без
выигрыша в приватности.
## Конвейер
```text
TX: Frame::new → into_bytes(tag) → AEAD encrypt in-place → TlsBridge::pack_app_data
RX: TlsBridge::unpack_app_data → AEAD decrypt in-place (staging) → Frame::parse
```
**Инвариант:** одна TLS-запись `ApplicationData` = ровно один кадр NRXP. На нём
держится потоковая расшифровка в `codec.rs` (буфер `staging`).
## Связи
- Шифрование — [`crypto`](../crypto) (`AeadPacker`, `SessionAuth`).
- TLS-маскировка и хендшейк — [`tlseng`](../tlseng).
- Кадры передаёт/принимает движок туннеля [`net`](../net).
+148 -22
View File
@@ -1,3 +1,19 @@
//! TLS-обёртка: граница между NRXP и маскирующим слоем [`tlseng`](crate::tlseng).
//!
//! [`TlsBridge`] — единственная точка, где протокол соприкасается с TLS-кадрами.
//! Он умеет две вещи:
//!
//! 1. **Хендшейк.** Собрать `ClientHello` (клиент) / `ServerHello` (сервер) с
//! нужным профилем браузера и провести обмен ключами. На стороне сервера здесь
//! же проверяется начальный auth-тег, спрятанный в `session_id` ClientHello, —
//! первый барьер против чужих/сканирующих подключений.
//! 2. **Data-фаза.** Упаковать готовый шифртекст в TLS-запись `ApplicationData`
//! ([`pack_app_data`](TlsBridge::pack_app_data)) и распаковать обратно
//! ([`unpack_app_data`](TlsBridge::unpack_app_data)).
//!
//! Внутренний трейт [`TlsInterceptor`] задаёт общий каркас «распарсить TLS-запись
//! → проверить её тип → достать полезное содержимое» для хендшейка и AppData.
use crate::crypto::{SessionAuth, SessionKeys}; use crate::crypto::{SessionAuth, SessionKeys};
use crate::nrxp::errors::{ErrorAction, ErrorStage, TlsError}; use crate::nrxp::errors::{ErrorAction, ErrorStage, TlsError};
use crate::parser::Parser; use crate::parser::Parser;
@@ -8,6 +24,9 @@ use crate::tlseng::{ClientHello, HelloHeader, ServerHello};
use crate::tlseng::{ContentType, HelloType}; use crate::tlseng::{ContentType, HelloType};
use bytes::{Bytes, BytesMut}; use bytes::{Bytes, BytesMut};
/// Каркас разбора TLS-записи нужного типа: `start_process` парсит запись и
/// делегирует в `handle_record`, который проверяет content-type и извлекает
/// типизированный результат.
trait TlsInterceptor { trait TlsInterceptor {
type Output; type Output;
@@ -22,11 +41,16 @@ trait TlsInterceptor {
fn handle_record(record: TlsRecord) -> Result<Option<Self::Output>, TlsError>; fn handle_record(record: TlsRecord) -> Result<Option<Self::Output>, TlsError>;
} }
/// Разобранное handshake-сообщение одной из сторон вместе с его TLS-расширениями
/// (в расширениях лежат публичный ключ KeyShare и прочие поля, нужные для вывода
/// ключей сессии).
pub(crate) enum HandshakeMessage { pub(crate) enum HandshakeMessage {
/// `ClientHello` от клиента.
Client { Client {
base: ClientHello, base: ClientHello,
extensions: ExtensionStack, extensions: ExtensionStack,
}, },
/// `ServerHello` от сервера.
Server { Server {
base: ServerHello, base: ServerHello,
extensions: ExtensionStack, extensions: ExtensionStack,
@@ -52,6 +76,17 @@ impl HandshakeMessage {
impl TlsInterceptor for HandshakeMessage { impl TlsInterceptor for HandshakeMessage {
type Output = HandshakeMessage; type Output = HandshakeMessage;
/// `record` здесь уже целиком получена с провода (длина взята из заголовка
/// TLS-записи и `TlsRecord::parse` дожидается ровно стольких байт). Значит,
/// если `HelloHeader`/`ClientHello`/`ServerHello` не смогли разобрать этот
/// payload целиком — это не «пришло не всё», а испорченный/чужой hello.
///
/// Раньше такой случай тихо возвращал `Ok(None)`, и вызывающий код
/// (`ServerHandler::run`) трактовал его как «нужно больше данных» и ждал
/// ещё до [`TLS_HELLO_TIMEOUT`](crate::net::TLS_HELLO_TIMEOUT) (10с), хотя
/// новых байт для уже полностью прочитанной записи никогда не придёт —
/// stealth-fallback запускался с большой задержкой вместо немедленно,
/// нарушая заявленный принцип «при малейшем несоответствии — fallback».
fn handle_record(record: TlsRecord) -> Result<Option<Self::Output>, TlsError> { fn handle_record(record: TlsRecord) -> Result<Option<Self::Output>, TlsError> {
if record.content_type != ContentType::Handshake { if record.content_type != ContentType::Handshake {
return Err(TlsError::new( return Err(TlsError::new(
@@ -61,34 +96,51 @@ impl TlsInterceptor for HandshakeMessage {
)); ));
} }
let malformed = || {
TlsError::new(
ErrorStage::Handshake("Malformed handshake message"),
ErrorAction::Drop,
Bytes::new(),
)
};
let mut payload = BytesMut::from(record.payload.as_ref()); let mut payload = BytesMut::from(record.payload.as_ref());
if let Some(header) = HelloHeader::parse(&mut payload)? { let header = HelloHeader::parse(&mut payload)?.ok_or_else(malformed)?;
match header.header_type { match header.header_type {
HelloType::Client => { HelloType::Client => {
if let Some(hello) = ClientHello::parse(&mut payload)? { let hello = ClientHello::parse(&mut payload)?.ok_or_else(malformed)?;
let ext = let ext = ExtensionStack::parse(&mut BytesMut::from(hello.extensions.as_ref()))?
ExtensionStack::parse(&mut BytesMut::from(hello.extensions.as_ref()))?
.ok_or_else(|| { .ok_or_else(|| {
TlsError::new(ErrorStage::Handshake("Ext Err"), ErrorAction::Drop, Bytes::new()) TlsError::new(
ErrorStage::Handshake("Ext Err"),
ErrorAction::Drop,
Bytes::new(),
)
})?; })?;
return Ok(Some(HandshakeMessage::Client { base: hello, extensions: ext })); Ok(Some(HandshakeMessage::Client {
} base: hello,
extensions: ext,
}))
} }
HelloType::Server => { HelloType::Server => {
if let Some(hello) = ServerHello::parse(&mut payload)? { let hello = ServerHello::parse(&mut payload)?.ok_or_else(malformed)?;
let ext = let ext = ExtensionStack::parse(&mut BytesMut::from(hello.extensions.as_ref()))?
ExtensionStack::parse(&mut BytesMut::from(hello.extensions.as_ref()))?
.ok_or_else(|| { .ok_or_else(|| {
TlsError::new(ErrorStage::Handshake("Ext Err"), ErrorAction::Drop, Bytes::new()) TlsError::new(
ErrorStage::Handshake("Ext Err"),
ErrorAction::Drop,
Bytes::new(),
)
})?; })?;
return Ok(Some(HandshakeMessage::Server { base: hello, extensions: ext })); Ok(Some(HandshakeMessage::Server {
base: hello,
extensions: ext,
}))
} }
} }
} }
} }
Ok(None)
}
}
impl TlsInterceptor for ApplicationData { impl TlsInterceptor for ApplicationData {
type Output = ApplicationData; type Output = ApplicationData;
@@ -108,31 +160,72 @@ impl TlsInterceptor for ApplicationData {
} }
} }
/// Маркер «фиктивная запись ChangeCipherSpec прочитана и вырезана из буфера» —
/// см. [`TlsBridge::build_middlebox_ccs`]/[`TlsBridge::unpack_middlebox_ccs`].
/// Сама запись не несёт полезной нагрузки, поэтому у типа нет полей.
struct ChangeCipherSpecMarker;
impl TlsInterceptor for ChangeCipherSpecMarker {
type Output = ChangeCipherSpecMarker;
fn handle_record(record: TlsRecord) -> Result<Option<Self::Output>, TlsError> {
if record.content_type != ContentType::ChangeCipherSpec {
return Err(TlsError::new(
ErrorStage::Tls("Expected ChangeCipherSpec record"),
ErrorAction::Drop,
record.serialize(),
));
}
Ok(Some(ChangeCipherSpecMarker))
}
}
/// Фасад TLS-обёртки. Безсостоятельный набор статических операций над буферами;
/// всё состояние сессии живёт в [`SessionKeys`], которые передаются явно.
pub(crate) struct TlsBridge; pub(crate) struct TlsBridge;
impl TlsBridge { impl TlsBridge {
/// Распаковать handshake-сообщение (`ClientHello`/`ServerHello`) из буфера.
pub fn unpack_handshake(buffer: &mut BytesMut) -> Result<Option<HandshakeMessage>, TlsError> { pub fn unpack_handshake(buffer: &mut BytesMut) -> Result<Option<HandshakeMessage>, TlsError> {
HandshakeMessage::start_process(buffer) HandshakeMessage::start_process(buffer)
} }
/// Распаковать TLS-запись `ApplicationData` (ещё зашифрованный кадр NRXP).
pub fn unpack_app_data(buffer: &mut BytesMut) -> Result<Option<ApplicationData>, TlsError> { pub fn unpack_app_data(buffer: &mut BytesMut) -> Result<Option<ApplicationData>, TlsError> {
ApplicationData::start_process(buffer) ApplicationData::start_process(buffer)
} }
/// Собрать `ClientHello` по профилю браузера для маскировки (клиент).
pub fn wrap_client_hello(profile: &BrowserProfile, host: &str, keys: &SessionKeys) -> Bytes { pub fn wrap_client_hello(profile: &BrowserProfile, host: &str, keys: &SessionKeys) -> Bytes {
ClientHello::make_client_hello(profile, host, keys) ClientHello::make_client_hello(profile, host, keys)
} }
/// Обработать `ClientHello` и собрать ответный `ServerHello` (сервер).
///
/// Порядок критичен: сначала проверяется auth-тег из `session_id`
/// (16 байт со смещения 16) — неверный тег ⇒ отказ ещё до любых
/// криптоопераций; затем выводятся ключи сессии и формируется ответ.
///
/// Возвращает вместе с готовым `ServerHello` заявленную клиентом версию
/// протокола (`session_id[0]`, см. [`crate::PROTOCOL_VERSION`]) — вызывающий
/// код использует её, чтобы решить, какое версионно-зависимое поведение
/// (например, обмен `ChangeCipherSpec`) включать именно для этого клиента.
pub fn wrap_server_hello( pub fn wrap_server_hello(
client_msg: &HandshakeMessage, client_msg: &HandshakeMessage,
keys: &mut SessionKeys, keys: &mut SessionKeys,
profile: &ServerProfile, profile: &ServerProfile,
) -> Result<Bytes, TlsError> { ) -> Result<(Bytes, u8), TlsError> {
if let HandshakeMessage::Client { base, extensions } = client_msg { if let HandshakeMessage::Client { base, extensions } = client_msg {
if base.session_id.len() != 32 { if base.session_id.len() != 32 {
return Err(TlsError::new(ErrorStage::Handshake("Invalid SessionID len"), ErrorAction::Drop, Bytes::new())); return Err(TlsError::new(
ErrorStage::Handshake("Invalid SessionID len"),
ErrorAction::Drop,
Bytes::new(),
));
} }
let peer_version = base.session_id[0];
let mut received_tag = [0u8; 16]; let mut received_tag = [0u8; 16];
received_tag.copy_from_slice(&base.session_id[16..32]); received_tag.copy_from_slice(&base.session_id[16..32]);
@@ -140,23 +233,56 @@ impl TlsBridge {
let auth = SessionAuth::new(keys.get_auth_key()); let auth = SessionAuth::new(keys.get_auth_key());
if !auth.verify_tag(&received_tag) { if !auth.verify_tag(&received_tag) {
netrunner_logger::warn!("Unauthorized ClientHello: Auth Tag mismatch"); netrunner_logger::warn!("Unauthorized ClientHello: Auth Tag mismatch");
return Err(TlsError::new(ErrorStage::Handshake("Auth Failed"), ErrorAction::Drop, Bytes::new())); return Err(TlsError::new(
ErrorStage::Handshake("Auth Failed"),
ErrorAction::Drop,
Bytes::new(),
));
} }
keys.update_keys(base.random, extensions, true).map_err(|e| { keys.update_keys(base.random, extensions, true)
.map_err(|e| {
netrunner_logger::error!(error = %e, "Server failed key update"); netrunner_logger::error!(error = %e, "Server failed key update");
TlsError::new(ErrorStage::Handshake("Key Exchange Failed"), ErrorAction::Drop, Bytes::new()) TlsError::new(
ErrorStage::Handshake("Key Exchange Failed"),
ErrorAction::Drop,
Bytes::new(),
)
})?; })?;
let server_pub_key = keys.public_key_bytes(); let server_pub_key = keys.public_key_bytes();
Ok(ServerHello::make_server_hello(base, &server_pub_key, keys.local_salt(), profile)) let hello =
ServerHello::make_server_hello(base, &server_pub_key, keys.local_salt(), profile);
Ok((hello, peer_version))
} else { } else {
Err(TlsError::new(ErrorStage::Handshake("Expected ClientHello"), ErrorAction::Drop, Bytes::new())) Err(TlsError::new(
ErrorStage::Handshake("Expected ClientHello"),
ErrorAction::Drop,
Bytes::new(),
))
} }
} }
/// Обернуть готовый шифртекст кадра в TLS-запись `ApplicationData` (`0x17`).
pub fn pack_app_data(buffer: Bytes) -> Bytes { pub fn pack_app_data(buffer: Bytes) -> Bytes {
TlsRecord::build_application_data(buffer) TlsRecord::build_application_data(buffer)
} }
/// Байты фиктивной записи `ChangeCipherSpec` — обе наши стороны шлют её
/// сразу после своего Hello ради middlebox-совместимости TLS 1.3 (RFC 8446
/// Appendix D.4), как это делают настоящие браузеры (см. doc на
/// [`ContentType::ChangeCipherSpec`](crate::tlseng::ContentType)).
pub fn build_middlebox_ccs() -> Bytes {
TlsRecord::build_change_cipher_spec()
}
/// Дождаться (если нужно больше байт — `Ok(None)`) и вырезать из буфера
/// `ChangeCipherSpec`, присланный пиром сразу после его Hello. Пир — наша
/// же реализация на другом конце, поэтому запись всегда присутствует;
/// её отсутствие/искажение — рассинхрон протокола ([`ErrorAction::Drop`]).
pub fn unpack_middlebox_ccs(buffer: &mut BytesMut) -> Result<Option<()>, TlsError> {
Ok(ChangeCipherSpecMarker::start_process(buffer)?.map(|_| ()))
}
} }
+151
View File
@@ -1,3 +1,25 @@
//! Шифрующий кодек: мост между кадрами [`Frame`] и зашифрованными TLS-записями.
//!
//! Это слой, где встречаются протокол ([`nrxp::frame`](super::frame)),
//! криптография ([`crypto`](crate::crypto)) и TLS-обёртка ([`bridge`](super::bridge)).
//! Кодек разнесён на два независимых направления, чтобы чтение и запись жили в
//! разных задачах tokio без общего мьютекса:
//!
//! - [`TxCodec`] — `Frame` → AEAD-шифр in-place → TLS `ApplicationData`;
//! - [`RxCodec`] — TLS `ApplicationData` → AEAD-дешифр in-place → `Frame`.
//!
//! [`Codec`] — лишь фабрика: создаёт оба направления из одного [`ChaChaCipher`] и
//! `auth_key`, после чего [`split`](Codec::split) раздаёт их reader'у и writer'у.
//!
//! ## Буфер `staging` в [`RxCodec`]
//!
//! Опирается на инвариант «1 TLS-запись = 1 кадр NRXP». TLS-записи
//! расшифровываются по одной в общий буфер `staging`, и сразу делается попытка
//! распарсить кадр. `staging` переживает вызовы `decode_inbound`: если в одном
//! TCP-чтении пришло несколько записей, лишние остаются в нём до следующего
//! вызова. Любой провал AEAD или парсинга после успешной расшифровки трактуется
//! как рассинхрон/tampering → [`ErrorAction::Drop`] (пересоздать ногу с нуля).
use crate::crypto::{AeadPacker, ChaChaCipher, ChaChaStream, SessionAuth}; use crate::crypto::{AeadPacker, ChaChaCipher, ChaChaStream, SessionAuth};
use crate::nrxp::bridge::TlsBridge; use crate::nrxp::bridge::TlsBridge;
use crate::nrxp::errors::{ErrorAction, ErrorStage, TlsError}; use crate::nrxp::errors::{ErrorAction, ErrorStage, TlsError};
@@ -5,6 +27,7 @@ use crate::nrxp::frame::{Frame, FrameType};
use crate::parser::Parser; use crate::parser::Parser;
use bytes::{Bytes, BytesMut}; use bytes::{Bytes, BytesMut};
/// Исходящее направление: шифрует кадры для отправки в туннель.
pub struct TxCodec { pub struct TxCodec {
crypto: ChaChaStream, crypto: ChaChaStream,
auth: SessionAuth, auth: SessionAuth,
@@ -15,6 +38,11 @@ impl TxCodec {
Self { crypto, auth } Self { crypto, auth }
} }
/// Кодирует один кадр в готовую к отправке TLS-запись `ApplicationData`.
///
/// Шаги: сгенерировать time-based тег → собрать байты кадра → зашифровать
/// in-place (буфер вырастает на 16 байт AEAD-тега) → обернуть в TLS-запись.
/// Любая ошибка шифрования критична → [`ErrorAction::Drop`].
pub(crate) fn encode_frame( pub(crate) fn encode_frame(
&mut self, &mut self,
stream_id: u32, stream_id: u32,
@@ -42,9 +70,12 @@ impl TxCodec {
} }
} }
/// Входящее направление: расшифровывает TLS-записи и собирает из них кадры.
pub struct RxCodec { pub struct RxCodec {
crypto: ChaChaStream, crypto: ChaChaStream,
auth: SessionAuth, auth: SessionAuth,
/// Накопитель расшифрованного открытого текста между вызовами `decode_inbound`
/// (хранит «хвост» кадров, не разобранных в текущем вызове).
staging: BytesMut, staging: BytesMut,
} }
@@ -56,6 +87,12 @@ impl RxCodec {
staging, staging,
} }
} }
/// Пытается извлечь **один** следующий кадр из накопленных TCP-данных.
///
/// Возвращает `Ok(Some(frame))`, если кадр готов; `Ok(None)`, если данных
/// пока недостаточно (ждём следующего чтения сокета); `Err(Drop)` при провале
/// AEAD/парсинга. Сначала дочищает «хвост» из `staging`, затем по одной
/// расшифровывает новые TLS-записи из `buffer`.
pub(crate) fn decode_inbound( pub(crate) fn decode_inbound(
&mut self, &mut self,
buffer: &mut BytesMut, buffer: &mut BytesMut,
@@ -139,12 +176,17 @@ impl RxCodec {
} }
} }
/// Фабрика кодеков: владеет обоими направлениями до момента, пока их не раздадут
/// в задачи reader/writer через [`split`](Codec::split).
pub struct Codec { pub struct Codec {
tx: Option<TxCodec>, tx: Option<TxCodec>,
rx: Option<RxCodec>, rx: Option<RxCodec>,
} }
impl Codec { impl Codec {
/// Создаёт оба направления из шифра сессии и ключа аутентификации.
/// `auth` (одна `SessionAuth`) общий для tx и rx — тег зависит только от
/// времени и `auth_key`, а не от направления.
pub fn new(cipher: ChaChaCipher, auth_key: [u8; 32]) -> Self { pub fn new(cipher: ChaChaCipher, auth_key: [u8; 32]) -> Self {
let (rx_stream, tx_stream) = cipher.split(); let (rx_stream, tx_stream) = cipher.split();
let auth = SessionAuth::new(auth_key); let auth = SessionAuth::new(auth_key);
@@ -162,3 +204,112 @@ impl Codec {
) )
} }
} }
#[cfg(test)]
mod tests {
use super::*;
use crate::crypto::ChaChaCipher;
const AUTH_KEY: [u8; 32] = [0x11; 32];
const KEY_A: [u8; 32] = [0xAA; 32];
const IV_A: [u8; 12] = [0x01; 12];
const KEY_B: [u8; 32] = [0xBB; 32];
const IV_B: [u8; 12] = [0x02; 12];
/// Пара codec'ов "клиент+сервер" с ключами, зеркальными друг другу — так же,
/// как их реально назначает `SessionKeys::generate_keys` по ролям (tx одной
/// стороны == rx другой).
fn client_server_pair() -> ((RxCodec, TxCodec), (RxCodec, TxCodec)) {
let mut client_cipher = ChaChaCipher::new();
client_cipher.set_keys(KEY_A, IV_A, KEY_B, IV_B); // tx=A, rx=B
let mut server_cipher = ChaChaCipher::new();
server_cipher.set_keys(KEY_B, IV_B, KEY_A, IV_A); // tx=B, rx=A
(
Codec::new(client_cipher, AUTH_KEY).split(),
Codec::new(server_cipher, AUTH_KEY).split(),
)
}
#[test]
fn client_to_server_round_trip() {
let ((_client_rx, mut client_tx), (mut server_rx, _server_tx)) = client_server_pair();
let wire = client_tx
.encode_frame(
5,
FrameType::Data,
Bytes::from_static(b"payload from client"),
)
.unwrap();
let mut buf = BytesMut::from(&wire[..]);
let frame = server_rx.decode_inbound(&mut buf).unwrap().unwrap();
assert_eq!(frame.header.stream_id, 5);
assert_eq!(frame.header.frame_type, FrameType::Data);
assert_eq!(&frame.payload[..], b"payload from client");
}
#[test]
fn server_to_client_round_trip() {
let ((mut client_rx, _client_tx), (_server_rx, mut server_tx)) = client_server_pair();
let wire = server_tx
.encode_frame(6, FrameType::Heartbeat, Bytes::from_static(b"pong"))
.unwrap();
let mut buf = BytesMut::from(&wire[..]);
let frame = client_rx.decode_inbound(&mut buf).unwrap().unwrap();
assert_eq!(&frame.payload[..], b"pong");
}
#[test]
fn sequential_frames_keep_nonce_counters_in_sync() {
let ((_client_rx, mut client_tx), (mut server_rx, _server_tx)) = client_server_pair();
for i in 0..20u32 {
let payload = format!("frame-{i}");
let wire = client_tx
.encode_frame(1, FrameType::Data, Bytes::from(payload.clone()))
.unwrap();
let mut buf = BytesMut::from(&wire[..]);
let frame = server_rx.decode_inbound(&mut buf).unwrap().unwrap();
assert_eq!(&frame.payload[..], payload.as_bytes());
}
}
#[test]
fn tampered_ciphertext_fails_aead_and_drops() {
let ((_client_rx, mut client_tx), (mut server_rx, _server_tx)) = client_server_pair();
let wire = client_tx
.encode_frame(1, FrameType::Data, Bytes::from_static(b"secret"))
.unwrap();
let mut tampered = BytesMut::from(&wire[..]);
let last = tampered.len() - 1;
tampered[last] ^= 0xFF; // flip a byte inside the AEAD tag
let result = server_rx.decode_inbound(&mut tampered);
assert!(result.is_err(), "tampered ciphertext must not decrypt");
}
#[test]
fn replayed_frame_desyncs_nonce_and_fails() {
// Кадр расшифровывается один раз успешно; повторная подача ТЕХ ЖЕ байт
// получателю с уже продвинувшимся счётчиком nonce должна провалиться —
// это и есть встроенная защита от replay на уровне AEAD-потока.
let ((_client_rx, mut client_tx), (mut server_rx, _server_tx)) = client_server_pair();
let wire = client_tx
.encode_frame(1, FrameType::Data, Bytes::from_static(b"once"))
.unwrap();
let mut first = BytesMut::from(&wire[..]);
assert!(server_rx.decode_inbound(&mut first).unwrap().is_some());
let mut replay = BytesMut::from(&wire[..]);
assert!(server_rx.decode_inbound(&mut replay).is_err());
}
}
+24
View File
@@ -1,24 +1,46 @@
//! Ошибки разбора/обработки протокола и стратегия реакции на них.
//!
//! Ключевая идея — ошибка несёт в себе не только «что и где сломалось»
//! ([`ErrorStage`]), но и **что с этим делать** ([`ErrorAction`]). Вызывающий код
//! не принимает решение сам: он берёт [`TlsError::action`] и реагирует
//! единообразно. Это разводит «частичные данные» (норма — ждём ещё) и
//! «вмешательство/рассинхрон» (рвём ногу) по разным веткам без дублирования
//! логики в каждом месте парсинга.
use bytes::Bytes; use bytes::Bytes;
use netrunner_logger::{error, trace}; use netrunner_logger::{error, trace};
/// Что делать с соединением после ошибки.
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ErrorAction { pub enum ErrorAction {
/// Данных пока недостаточно — это не ошибка, ждём следующего чтения сокета.
Wait, Wait,
/// Проксировать как обычный TLS (stealth-fallback) вместо разрыва.
Redirect, Redirect,
/// Критично (tampering/рассинхрон) — закрыть ногу и переподключиться.
Drop, Drop,
} }
/// На каком уровне протокола произошла ошибка (со static-описанием причины).
#[derive(Debug)] #[derive(Debug)]
pub enum ErrorStage { pub enum ErrorStage {
/// Слой TLS-записи / шифрования.
Tls(&'static str), Tls(&'static str),
/// Фаза хендшейка (`ClientHello`/`ServerHello`, обмен ключами).
Handshake(&'static str), Handshake(&'static str),
/// Фаза передачи данных (`ApplicationData`).
ApplicationData(&'static str), ApplicationData(&'static str),
} }
/// Ошибка протокола: стадия + предписанное действие + (опционально) сырые данные
/// для перенаправления/диагностики.
#[derive(Debug)] #[derive(Debug)]
pub struct TlsError { pub struct TlsError {
/// Где и почему сломалось.
pub stage: ErrorStage, pub stage: ErrorStage,
/// Как на это реагировать.
pub action: ErrorAction, pub action: ErrorAction,
/// Сырые байты (например, для `Redirect` — переслать как есть; иначе пусто).
pub data: Bytes, pub data: Bytes,
} }
@@ -81,6 +103,8 @@ impl TlsError {
} }
} }
/// Логирует ошибку с уровнем, соответствующим её серьёзности, и возвращает
/// предписанное действие. Вызывающий код матчится по результату.
pub fn execute_strategy(&self) -> ErrorAction { pub fn execute_strategy(&self) -> ErrorAction {
self.log_error(); self.log_error();
self.action self.action
+201 -1
View File
@@ -1,44 +1,87 @@
//! Кадр NRXP: структура и (де)сериализация.
//!
//! Полный байтовый формат см. в [обзоре модуля](super). Здесь — типы кадра и две
//! зеркальные операции:
//! - [`Frame::into_bytes`] — собрать заголовок + payload + случайный padding в
//! единый [`BytesMut`] (заготовка под последующее AEAD-шифрование на месте);
//! - реализации [`Parser`] для [`FrameHeader`] и [`Frame`] — разобрать буфер
//! обратно в кадр, не копируя payload лишний раз (zero-copy через `split_to`).
//!
//! Весь файл написан под zero-copy/zero-alloc на горячем пути — комментарии
//! «🔥 ОПТИМИЗАЦИЯ» помечают места, где это сознательно важно.
use crate::parser::Parser; use crate::parser::Parser;
use bytes::{Buf, BufMut, Bytes, BytesMut}; use bytes::{Buf, BufMut, Bytes, BytesMut};
use rand::Rng; use rand::Rng;
/// Тип кадра — первый байт после `stream_id`. Числовые значения фиксированы и
/// являются частью wire-формата (менять — это смена версии протокола).
#[derive(Copy, Clone, Debug, PartialEq)] #[derive(Copy, Clone, Debug, PartialEq)]
#[repr(u8)] #[repr(u8)]
pub(crate) enum FrameType { pub(crate) enum FrameType {
/// Открыть TCP-поток к цели (payload — адрес назначения).
Connect = 0x00, Connect = 0x00,
/// Данные TCP-потока.
Data = 0x01, Data = 0x01,
/// Закрыть поток (FIN/abort).
Close = 0x02, Close = 0x02,
/// Keep-alive; держит туннель живым и измеряет RTT.
Heartbeat = 0x03, Heartbeat = 0x03,
/// Открыть UDP-«сессию» к цели.
UdpConnect = 0x04, UdpConnect = 0x04,
/// Датаграмма UDP-сессии.
UdpData = 0x05, UdpData = 0x05,
/// Диагностический отчёт клиента (один JSON-снапшот в payload). Холодный
/// путь: едет по контрольному каналу, сервер сохраняет его в пер-сессионный
/// файл. Никогда не маршрутизируется в локальные сокеты.
Diag = 0x06,
/// Кредит потока для сквозного flow-control (payload — `u32` BE, "можешь
/// прислать ещё N байт"). Отправляется приёмной стороной по мере
/// освобождения локального буфера — см. `Muxer::grant_credit`/`consume_credit`.
Credit = 0x07,
} }
/// Разобранный заголовок кадра (25 байт). Поля идут в том же порядке, что и в wire.
#[derive(Copy, Clone)] #[derive(Copy, Clone)]
pub(crate) struct FrameHeader { pub(crate) struct FrameHeader {
/// Time-based HMAC-тег (анти-replay). Проверяется приёмной стороной.
pub(crate) auth_tag: [u8; 16], pub(crate) auth_tag: [u8; 16],
/// Идентификатор логического потока внутри туннеля.
pub(crate) stream_id: u32, pub(crate) stream_id: u32,
/// Длина полезной нагрузки в байтах.
pub(crate) payload_len: u16, pub(crate) payload_len: u16,
/// Длина случайного padding после payload (0 для Data/UdpData).
pub(crate) padding_len: u16, pub(crate) padding_len: u16,
/// Тип кадра.
pub(crate) frame_type: FrameType, pub(crate) frame_type: FrameType,
} }
/// Полный разобранный кадр: заголовок + payload (без padding — он отбрасывается).
pub(crate) struct Frame { pub(crate) struct Frame {
// 🔥 ОПТИМИЗАЦИЯ: Поле _padding удалено, так как оно никогда не используется. // 🔥 ОПТИМИЗАЦИЯ: Поле _padding удалено, так как оно никогда не используется.
/// Полезная нагрузка как [`Bytes`] (zero-copy ссылка на исходный буфер).
pub(crate) payload: Bytes, pub(crate) payload: Bytes,
/// Разобранный заголовок.
pub(crate) header: FrameHeader, pub(crate) header: FrameHeader,
} }
// Размеры полей заголовка в байтах (см. формат в обзоре модуля).
const AUTH_TAG_SIZE: u16 = 16; const AUTH_TAG_SIZE: u16 = 16;
const STREAM_ID_SIZE: u16 = 4; const STREAM_ID_SIZE: u16 = 4;
const FRAME_TYPE_SIZE: u16 = 1; const FRAME_TYPE_SIZE: u16 = 1;
const PAYLOAD_LEN_SIZE: u16 = 2; const PAYLOAD_LEN_SIZE: u16 = 2;
const PADDING_LEN_SIZE: u16 = 2; const PADDING_LEN_SIZE: u16 = 2;
/// Суммарный размер заголовка кадра — 25 байт.
pub const FRAME_HEADER_SIZE: u16 = pub const FRAME_HEADER_SIZE: u16 =
AUTH_TAG_SIZE + STREAM_ID_SIZE + FRAME_TYPE_SIZE + PAYLOAD_LEN_SIZE + PADDING_LEN_SIZE; // 25 bytes AUTH_TAG_SIZE + STREAM_ID_SIZE + FRAME_TYPE_SIZE + PAYLOAD_LEN_SIZE + PADDING_LEN_SIZE; // 25 bytes
/// Потолок payload одного кадра (16 КБ). Совпадает с размером interleave-чанка
/// writer'а: большие сообщения режутся на куски не больше этого значения.
pub const MAX_FRAME_PAYLOAD: usize = 16 * 1024; pub const MAX_FRAME_PAYLOAD: usize = 16 * 1024;
impl Frame { impl Frame {
/// Конструирует кадр с нулевым `auth_tag` и `padding_len` — оба заполняются
/// позже в [`into_bytes`](Frame::into_bytes) при сериализации.
#[inline(always)] #[inline(always)]
pub(crate) fn new(stream_id: u32, frame_type: FrameType, payload: Bytes) -> Self { pub(crate) fn new(stream_id: u32, frame_type: FrameType, payload: Bytes) -> Self {
Self { Self {
@@ -53,11 +96,20 @@ impl Frame {
} }
} }
/// Сериализует кадр в [`BytesMut`], готовый к шифрованию на месте.
///
/// `auth_key` здесь — это уже готовый 16-байтовый тег (имя историческое),
/// который кладётся в начало заголовка. Для `Data`/`UdpData` — выравнивание
/// до ближайшего бакета из [`bucket_padding`] (throughput всё ещё важнее,
/// поэтому кадры, уже близкие к максимальному размеру, не паддятся вовсе),
/// для остальных типов — 0..255 случайных байт. Буфер выделяется один раз
/// точно под итоговый размер; заголовок собирается на стеке и пишется
/// одним `copy_from_slice`.
#[inline] #[inline]
pub(crate) fn into_bytes(mut self, auth_key: &[u8; 16]) -> BytesMut { pub(crate) fn into_bytes(mut self, auth_key: &[u8; 16]) -> BytesMut {
// 🔥 ОПТИМИЗАЦИЯ: Быстрая побитовая маска (& 0xFF) вместо дорогого деления с остатком (%) // 🔥 ОПТИМИЗАЦИЯ: Быстрая побитовая маска (& 0xFF) вместо дорогого деления с остатком (%)
let padding_len = match self.header.frame_type { let padding_len = match self.header.frame_type {
FrameType::Data | FrameType::UdpData => 0, FrameType::Data | FrameType::UdpData => Self::bucket_padding(self.payload.len()),
_ => (rand::rng().next_u32() & 0xFF) as u16, _ => (rand::rng().next_u32() & 0xFF) as u16,
}; };
@@ -89,8 +141,33 @@ impl Frame {
buf buf
} }
/// Длина паддинга для выравнивания `Data`/`UdpData` кадра до ближайшего
/// "круглого" бакета вместо точной длины полезной нагрузки.
///
/// Не паддит кадры, уже близкие к [`MAX_FRAME_PAYLOAD`] (крупные бакеты
/// закачек) — это почти весь трафик объёмных передач, где паддинг только
/// снижал бы throughput без выигрыша в приватности (снаружи и так виден
/// кадр максимального размера, угадывать в нём нечего). Именно маленькие
/// кадры (запросы, интерактив, начало HTTP-ответа) — то место, где по
/// точной длине конкретного пакета легче всего строить атаки
/// website/traffic fingerprinting поверх уже неотличимого от HTTPS
/// хендшейка, поэтому их выравнивание даёт больше всего эффекта за
/// наименьшие накладные расходы.
#[inline]
fn bucket_padding(payload_len: usize) -> u16 {
const BUCKETS: [usize; 6] = [256, 512, 1024, 2048, 4096, 8192];
for &bucket in &BUCKETS {
if payload_len <= bucket {
return (bucket - payload_len) as u16;
}
}
0
}
} }
/// Разбор только заголовка: `can_parse` проверяет, накопились ли 25 байт,
/// `parse` читает их и сдвигает курсор буфера (payload остаётся в `bytes`).
impl Parser for FrameHeader { impl Parser for FrameHeader {
type Error = String; type Error = String;
@@ -121,6 +198,8 @@ impl Parser for FrameHeader {
0x03 => FrameType::Heartbeat, 0x03 => FrameType::Heartbeat,
0x04 => FrameType::UdpConnect, 0x04 => FrameType::UdpConnect,
0x05 => FrameType::UdpData, 0x05 => FrameType::UdpData,
0x06 => FrameType::Diag,
0x07 => FrameType::Credit,
unknown => { unknown => {
// After successful AEAD decryption an unknown frame type means a // After successful AEAD decryption an unknown frame type means a
// protocol version mismatch or data corruption that the cipher // protocol version mismatch or data corruption that the cipher
@@ -147,6 +226,9 @@ impl Parser for FrameHeader {
} }
} }
/// Разбор полного кадра. `can_parse` подглядывает в поля длин прямо в буфере
/// (без сдвига курсора), чтобы убедиться, что пришёл весь кадр целиком; только
/// тогда `parse` извлекает заголовок и payload и пропускает padding.
impl Parser for Frame { impl Parser for Frame {
type Error = String; type Error = String;
@@ -156,6 +238,8 @@ impl Parser for Frame {
return false; return false;
} }
// Подглядываем payload_len и padding_len по их смещениям в заголовке,
// не трогая курсор: байты 21..23 и 23..25.
let p_len = u16::from_be_bytes([bytes[21], bytes[22]]) as usize; let p_len = u16::from_be_bytes([bytes[21], bytes[22]]) as usize;
let pad_len = u16::from_be_bytes([bytes[23], bytes[24]]) as usize; let pad_len = u16::from_be_bytes([bytes[23], bytes[24]]) as usize;
@@ -184,3 +268,119 @@ impl Parser for Frame {
Ok(Some(Self { header, payload })) Ok(Some(Self { header, payload }))
} }
} }
#[cfg(test)]
mod tests {
use super::*;
const AUTH_KEY: [u8; 16] = [0x42; 16];
fn round_trip(frame_type: FrameType, payload: &[u8]) -> Frame {
let frame = Frame::new(7, frame_type, Bytes::copy_from_slice(payload));
let mut wire = frame.into_bytes(&AUTH_KEY);
Frame::parse(&mut wire).unwrap().unwrap()
}
#[test]
fn round_trip_preserves_payload_and_metadata() {
let parsed = round_trip(FrameType::Data, b"some tunnel payload");
assert_eq!(parsed.header.stream_id, 7);
assert_eq!(parsed.header.frame_type, FrameType::Data);
assert_eq!(&parsed.payload[..], b"some tunnel payload");
assert_eq!(parsed.header.auth_tag, AUTH_KEY);
}
#[test]
fn control_frames_get_random_padding_0_to_255() {
for frame_type in [
FrameType::Connect,
FrameType::Close,
FrameType::Heartbeat,
FrameType::UdpConnect,
FrameType::Diag,
FrameType::Credit,
] {
let frame = Frame::new(1, frame_type, Bytes::from_static(b"x"));
let wire = frame.into_bytes(&AUTH_KEY);
// padding_len живёт в байтах 23..25 заголовка.
let padding_len = u16::from_be_bytes([wire[23], wire[24]]);
assert!(
padding_len <= 255,
"{:?} padding {} exceeds the 0..=255 range",
frame_type,
padding_len
);
assert_eq!(
wire.len(),
FRAME_HEADER_SIZE as usize + 1 + padding_len as usize
);
}
}
#[test]
fn data_frames_never_get_legacy_unbounded_padding() {
// Регрессия: раньше Data/UdpData вообще не паддились (padding_len == 0
// всегда). Теперь бакетное выравнивание — здесь просто фиксируем, что
// поведение осознанно изменилось, а не просто "иногда 0".
let frame = Frame::new(1, FrameType::Data, Bytes::copy_from_slice(&[0u8; 100]));
let wire = frame.into_bytes(&AUTH_KEY);
let padding_len = u16::from_be_bytes([wire[23], wire[24]]);
assert_eq!(padding_len, (256 - 100) as u16);
}
#[test]
fn bucket_padding_boundaries() {
// На границе бакета — паддинг 0 (уже ровно на бакете).
assert_eq!(Frame::bucket_padding(256), 0);
assert_eq!(Frame::bucket_padding(512), 0);
assert_eq!(Frame::bucket_padding(8192), 0);
// На единицу больше границы — едет в следующий бакет.
assert_eq!(Frame::bucket_padding(257), 512 - 257);
assert_eq!(Frame::bucket_padding(2049), 4096 - 2049);
// Пустой payload — паддится до первого бакета.
assert_eq!(Frame::bucket_padding(0), 256);
// Крупные кадры (около MAX_FRAME_PAYLOAD) — без паддинга вовсе,
// throughput объёмных закачек не должен страдать.
assert_eq!(Frame::bucket_padding(8193), 0);
assert_eq!(Frame::bucket_padding(MAX_FRAME_PAYLOAD), 0);
}
#[test]
fn data_and_udpdata_frames_are_bucketed_identically() {
for frame_type in [FrameType::Data, FrameType::UdpData] {
let frame = Frame::new(1, frame_type, Bytes::copy_from_slice(&[0u8; 300]));
let wire = frame.into_bytes(&AUTH_KEY);
let padding_len = u16::from_be_bytes([wire[23], wire[24]]);
assert_eq!(padding_len, (512 - 300) as u16);
}
}
#[test]
fn parse_skips_padding_without_exposing_it() {
let frame = Frame::new(3, FrameType::Heartbeat, Bytes::from_static(b"auth-payload"));
let mut wire = frame.into_bytes(&AUTH_KEY);
let parsed = Frame::parse(&mut wire).unwrap().unwrap();
assert_eq!(&parsed.payload[..], b"auth-payload");
assert!(
wire.is_empty(),
"parse must advance past payload AND padding (random 0..=255 for control frames), leaving nothing behind"
);
}
#[test]
fn incomplete_frame_is_none() {
let frame = Frame::new(1, FrameType::Data, Bytes::copy_from_slice(&[0u8; 50]));
let mut wire = frame.into_bytes(&AUTH_KEY);
wire.truncate(wire.len() - 1);
assert!(Frame::parse(&mut wire).unwrap().is_none());
}
#[test]
fn unknown_frame_type_byte_is_an_error() {
let frame = Frame::new(1, FrameType::Data, Bytes::copy_from_slice(&[0u8; 10]));
let mut wire = frame.into_bytes(&AUTH_KEY);
wire[20] = 0xEE; // frame_type byte — не входит ни в один известный вариант
assert!(Frame::parse(&mut wire).is_err());
}
}
+50
View File
@@ -1,3 +1,53 @@
//! # Протокол NRXP (`nrxp`) — Netrunner eXchange Protocol
//!
//! Прикладной протокол, который ездит **внутри** замаскированного TLS-канала.
//! Снаружи трафик выглядит как обычные TLS-записи `ApplicationData` (`0x17`), а
//! внутри каждой записи лежит один зашифрованный кадр NRXP с мультиплексированием
//! логических потоков.
//!
//! Этот блок отвечает за «упаковку/распаковку» и ничего не знает о сети как
//! таковой — он лишь превращает `(stream_id, тип, payload)` в байты и обратно.
//!
//! ## Состав блока
//!
//! | Файл | Ответственность |
//! |-------------|-----------------------------------------------------------------------|
//! | [`frame`] | Структура кадра, его (де)сериализация, паддинг. |
//! | [`codec`] | Шифрующий слой: [`TxCodec`]/[`RxCodec`] (кадр ⇄ зашифрованный TLS). |
//! | [`bridge`] | TLS-обёртка: хендшейк (`ClientHello`/`ServerHello`) и `ApplicationData`.|
//! | [`errors`] | [`TlsError`] и стратегия реакции ([`ErrorAction`]: Wait/Redirect/Drop).|
//!
//! ## Формат кадра (25-байтовый заголовок + payload + padding)
//!
//! ```text
//! ┌──────────────┬───────────┬──────┬────────────┬────────────┬─────────┬─────────┐
//! │ Auth Tag │ Stream ID │ Type │ Payload Len│ Padding Len│ Payload │ Padding │
//! │ 16 байт │ 4 байта │ 1 б. │ 2 байта │ 2 байта │ N байт │ 0..255 │
//! └──────────────┴───────────┴──────┴────────────┴────────────┴─────────┴─────────┘
//! └──────────────────── FRAME_HEADER_SIZE = 25 ────────────────────┘
//! ```
//!
//! - **Auth Tag** — TOTP-подобный HMAC-тег (см. [`SessionAuth`](crate::crypto)),
//! привязан ко времени → защита от replay со стороны DPI.
//! - **Stream ID** — id логического потока внутри туннеля (нечётные у клиента,
//! чётные у сервера — так стороны не конфликтуют за номера).
//! - **Type** — [`FrameType`]: `Connect`/`Data`/`Close`/`Heartbeat`/`UdpConnect`/`UdpData`.
//! - **Padding** — ломает анализ длин пакетов. Управляющие кадры получают
//! 0..255 случайных байт; `Data`/`UdpData` выравниваются до ближайшего
//! "круглого" бакета (256/512/1024/2048/4096/8192, см. `Frame::into_bytes`)
//! — кроме кадров, уже близких к максимальному размеру (крупные закачки),
//! где паддинг только бил бы по throughput без выигрыша в приватности.
//!
//! ## Конвейер кодирования
//!
//! ```text
//! TX: Frame::new → into_bytes(tag) → AEAD encrypt in-place → TlsBridge::pack_app_data
//! RX: TlsBridge::unpack_app_data → AEAD decrypt in-place (staging) → Frame::parse
//! ```
//!
//! **Инвариант кодирования:** одна TLS-запись `ApplicationData` = ровно один
//! зашифрованный кадр NRXP. На нём держится потоковая расшифровка в [`codec`].
mod bridge; mod bridge;
mod codec; mod codec;
mod errors; mod errors;
+21
View File
@@ -1,8 +1,29 @@
//! Общий трейт инкрементального разбора буфера — [`Parser`].
//!
//! Через него реализован весь парсинг в крейте (кадры NRXP, TLS-записи, hello,
//! расширения, rawcast). Идея — единый протокол работы с потоковыми данными, где
//! сообщение может прийти не целиком за одно чтение сокета:
//!
//! - [`can_parse`](Parser::can_parse) — быстрый предикат «хватает ли байт на целое
//! сообщение» без аллокаций и без сдвига курсора;
//! - [`parse`](Parser::parse) — если данных достаточно, извлекает сообщение и
//! продвигает буфер; `Ok(None)` означает «ещё рано, дочитайте сокет».
use bytes::BytesMut; use bytes::BytesMut;
/// Инкрементальный парсер сообщения из накопительного буфера [`BytesMut`].
pub trait Parser { pub trait Parser {
/// Тип ошибки разбора (у каждого протокола свой).
type Error; type Error;
/// Достаточно ли в буфере байт для разбора одного целого сообщения.
/// Должен быть дешёвым и не мутировать буфер.
fn can_parse(bytes: &BytesMut) -> bool; fn can_parse(bytes: &BytesMut) -> bool;
/// Пытается извлечь одно сообщение, продвигая буфер при успехе.
///
/// Возвращает `Ok(Some(_))` — сообщение готово; `Ok(None)` — данных пока мало
/// (буфер не тронут или тронут безопасно); `Err(_)` — данные испорчены.
fn parse(bytes: &mut BytesMut) -> Result<Option<Self>, Self::Error> fn parse(bytes: &mut BytesMut) -> Result<Option<Self>, Self::Error>
where where
Self: Sized; Self: Sized;
+42
View File
@@ -0,0 +1,42 @@
# Блок `rawcast` — локальный протокол сокет ⇄ кадр
Промежуточный формат между «локальной» стороной клиента (реальные TCP/UDP сокеты
приложений) и протоколом туннеля [`nrxp`](../nrxp). Когда трафик приходит не из
TUN напрямую, а из локальных соединений, его описывают компактным `RawCastFrame`,
а `RawCastAdapter` переводит это в кадры NRXP и обратно.
> Детали — в rustdoc, модуль `rawcast`.
## Файлы
| Файл | Что внутри |
|--------------|------------------------------------------------------------------|
| `frame.rs` | `RawCastFrame` + enum'ы `LocalProtocol`/`RawCastEvent`, wire-формат. |
| `adapter.rs` | `RawCastAdapter`: трансляция RawCast ⇄ NRXP-`Frame`. |
## Формат кадра
```text
┌──────────┬───────┬───────────┬─────────┬──────────┬─────────────┬─────────┐
│ protocol │ event │ socket_id │ dst_ip │ dst_port │ payload_len │ payload │
│ 1 байт │ 1 б. │ 8 байт │ 4 байта │ 2 байта │ 2 байта │ N байт │
└──────────┴───────┴───────────┴─────────┴──────────┴─────────────┴─────────┘
└──────────────── LOCAL_HEADER_SIZE = 16 ────────────────┘
```
## Ментальная модель
```text
локальный сокет ─→ RawCastFrame ─to_nrxp──→ NRXP Frame ─→ туннель
туннель ─→ NRXP Frame ─from_nrxp─→ RawCastFrame ─→ локальный сокет
```
`socket_id` локальной стороны напрямую отображается в `stream_id` потока NRXP, так
что мультиплексирование «бесплатно» переносится между двумя протоколами. ICMP в
формате распознаётся, но ядром NRXP не проксируется.
## Связи
Потребитель — клиентская точка входа `ClientHandler::connect` ([`net`](../net)):
она гоняет `RawCastFrame` между локальными соединениями ([`client`](../../../../client))
и туннелем.
+16
View File
@@ -1,3 +1,10 @@
//! Трансляция между локальным протоколом RawCast и протоколом туннеля NRXP.
//!
//! [`RawCastAdapter`] — чистый «переводчик» без состояния. Соответствия:
//! `socket_id` ⇄ `stream_id`, `(protocol, event)` ⇄ [`FrameType`]. Для `Connect`
//! без явного payload адрес назначения упаковывается строкой `"ip:port"` — это
//! то, что ожидает серверная сторона при открытии потока.
use bytes::Bytes; use bytes::Bytes;
use std::net::Ipv4Addr; use std::net::Ipv4Addr;
@@ -6,9 +13,13 @@ use crate::{
rawcast::frame::{LocalProtocol, RawCastEvent, RawCastFrame}, rawcast::frame::{LocalProtocol, RawCastEvent, RawCastFrame},
}; };
/// Безсостоятельный конвертер RawCast ⇄ NRXP.
pub struct RawCastAdapter; pub struct RawCastAdapter;
impl RawCastAdapter { impl RawCastAdapter {
/// RawCast → NRXP. Маппит протокол+событие в [`FrameType`]; для `Connect`
/// без payload подставляет адрес цели строкой `"ip:port"`. ICMP отвергается —
/// ядро NRXP его не проксирует.
pub(crate) fn to_nrxp(raw: RawCastFrame) -> Result<Frame, String> { pub(crate) fn to_nrxp(raw: RawCastFrame) -> Result<Frame, String> {
let stream_id = raw.socket_id as u32; let stream_id = raw.socket_id as u32;
@@ -40,6 +51,9 @@ impl RawCastAdapter {
Ok(Frame::new(stream_id, frame_type, payload)) Ok(Frame::new(stream_id, frame_type, payload))
} }
/// NRXP → RawCast. Обратный перевод; `is_udp` задаёт протокол локального
/// сокета (в NRXP-кадре эта информация частично растворена в типе). Кадры
/// `Heartbeat` сюда попадать не должны — их обрабатывает muxer, не мост.
pub(crate) fn from_nrxp( pub(crate) fn from_nrxp(
nrxp_frame: Frame, nrxp_frame: Frame,
dst_ip: Ipv4Addr, dst_ip: Ipv4Addr,
@@ -58,6 +72,8 @@ impl RawCastAdapter {
FrameType::Data | FrameType::UdpData => RawCastEvent::Data, FrameType::Data | FrameType::UdpData => RawCastEvent::Data,
FrameType::Close => RawCastEvent::Close, FrameType::Close => RawCastEvent::Close,
FrameType::Heartbeat => return Err("Heartbeat should be handled by muxer".into()), FrameType::Heartbeat => return Err("Heartbeat should be handled by muxer".into()),
FrameType::Diag => return Err("Diag frame is handled by diagnostics, not the bridge".into()),
FrameType::Credit => return Err("Credit frame is handled by muxer, not the bridge".into()),
}; };
Ok(RawCastFrame { Ok(RawCastFrame {
+34
View File
@@ -1,34 +1,61 @@
//! Кадр локального протокола RawCast и его (де)сериализация.
//!
//! Wire-формат: 16-байтовый фиксированный заголовок + 2 байта длины payload +
//! сам payload:
//!
//! ```text
//! ┌──────────┬───────┬───────────┬─────────┬──────────┬─────────────┬─────────┐
//! │ protocol │ event │ socket_id │ dst_ip │ dst_port │ payload_len │ payload │
//! │ 1 байт │ 1 б. │ 8 байт │ 4 байта │ 2 байта │ 2 байта │ N байт │
//! └──────────┴───────┴───────────┴─────────┴──────────┴─────────────┴─────────┘
//! └──────────────── LOCAL_HEADER_SIZE = 16 ────────────────┘
//! ```
use bytes::{Buf, BufMut, Bytes, BytesMut}; use bytes::{Buf, BufMut, Bytes, BytesMut};
use std::net::Ipv4Addr; use std::net::Ipv4Addr;
use crate::parser::Parser; use crate::parser::Parser;
/// Транспортный протокол локального сокета.
#[derive(Copy, Clone, Debug, PartialEq)] #[derive(Copy, Clone, Debug, PartialEq)]
#[repr(u8)] #[repr(u8)]
pub enum LocalProtocol { pub enum LocalProtocol {
Tcp = 0x01, Tcp = 0x01,
Udp = 0x02, Udp = 0x02,
/// ICMP распознаётся в формате, но ядром NRXP не поддерживается (см. адаптер).
Icmp = 0x03, Icmp = 0x03,
} }
/// Событие жизненного цикла локального сокета.
#[derive(Copy, Clone, Debug, PartialEq)] #[derive(Copy, Clone, Debug, PartialEq)]
#[repr(u8)] #[repr(u8)]
pub enum RawCastEvent { pub enum RawCastEvent {
/// Новое соединение/сессия к цели.
Connect = 0x01, Connect = 0x01,
/// Полезные данные.
Data = 0x02, Data = 0x02,
/// Закрытие соединения.
Close = 0x03, Close = 0x03,
} }
/// Описание одного события локального сокета — единица обмена RawCast.
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct RawCastFrame { pub struct RawCastFrame {
/// TCP/UDP/ICMP.
pub protocol: LocalProtocol, pub protocol: LocalProtocol,
/// Connect/Data/Close.
pub event: RawCastEvent, pub event: RawCastEvent,
/// Идентификатор локального сокета (→ `stream_id` в NRXP).
pub socket_id: u64, pub socket_id: u64,
/// Адрес назначения.
pub dst_ip: Ipv4Addr, pub dst_ip: Ipv4Addr,
/// Порт назначения.
pub dst_port: u16, pub dst_port: u16,
/// Полезные данные (пусто для Connect/Close).
pub payload: Bytes, pub payload: Bytes,
} }
/// Размер фиксированной части заголовка (без поля длины и payload) — 16 байт.
const LOCAL_HEADER_SIZE: usize = 16; const LOCAL_HEADER_SIZE: usize = 16;
impl RawCastFrame { impl RawCastFrame {
@@ -50,18 +77,22 @@ impl RawCastFrame {
} }
} }
/// Кадр-событие открытия соединения (без payload).
pub fn connect(protocol: LocalProtocol, id: u64, ip: Ipv4Addr, port: u16) -> Self { pub fn connect(protocol: LocalProtocol, id: u64, ip: Ipv4Addr, port: u16) -> Self {
Self::new(protocol, RawCastEvent::Connect, id, ip, port, Bytes::new()) Self::new(protocol, RawCastEvent::Connect, id, ip, port, Bytes::new())
} }
/// Кадр с данными соединения.
pub fn data(protocol: LocalProtocol, id: u64, ip: Ipv4Addr, port: u16, data: Bytes) -> Self { pub fn data(protocol: LocalProtocol, id: u64, ip: Ipv4Addr, port: u16, data: Bytes) -> Self {
Self::new(protocol, RawCastEvent::Data, id, ip, port, data) Self::new(protocol, RawCastEvent::Data, id, ip, port, data)
} }
/// Кадр-событие закрытия соединения (без payload).
pub fn close(protocol: LocalProtocol, id: u64, ip: Ipv4Addr, port: u16) -> Self { pub fn close(protocol: LocalProtocol, id: u64, ip: Ipv4Addr, port: u16) -> Self {
Self::new(protocol, RawCastEvent::Close, id, ip, port, Bytes::new()) Self::new(protocol, RawCastEvent::Close, id, ip, port, Bytes::new())
} }
/// Сериализует кадр в байты по wire-формату из обзора модуля.
pub fn into_bytes(self) -> BytesMut { pub fn into_bytes(self) -> BytesMut {
let total_size = LOCAL_HEADER_SIZE + 2 + self.payload.len(); let total_size = LOCAL_HEADER_SIZE + 2 + self.payload.len();
let mut buf = BytesMut::with_capacity(total_size); let mut buf = BytesMut::with_capacity(total_size);
@@ -78,6 +109,9 @@ impl RawCastFrame {
} }
} }
/// Разбор кадра RawCast: `can_parse` подглядывает поле длины payload по
/// смещению `LOCAL_HEADER_SIZE` и проверяет, что весь кадр на месте; `parse`
/// читает фиксированный заголовок, затем payload.
impl Parser for RawCastFrame { impl Parser for RawCastFrame {
type Error = String; type Error = String;
+25
View File
@@ -1,3 +1,28 @@
//! # Блок RawCast (`rawcast`) — локальный протокол сокет ⇄ кадр
//!
//! Промежуточный формат между «локальной» стороной клиента (реальные TCP/UDP
//! сокеты приложений) и протоколом туннеля [`nrxp`](crate::nrxp). Когда трафик
//! приходит не из TUN, а из локальных соединений, его описывают компактным
//! [`RawCastFrame`] — «что за сокет, какой протокол, куда, какое событие, данные» —
//! а [`RawCastAdapter`] переводит это в кадры NRXP и обратно.
//!
//! ## Состав блока
//!
//! | Файл | Ответственность |
//! |-------------|------------------------------------------------------------------|
//! | [`frame`] | [`RawCastFrame`] + enum'ы [`LocalProtocol`]/[`RawCastEvent`], wire-формат. |
//! | [`adapter`] | [`RawCastAdapter`]: трансляция RawCast ⇄ NRXP-[`Frame`](crate::nrxp).|
//!
//! ## Связь с остальным
//!
//! ```text
//! локальный сокет ──→ RawCastFrame ──RawCastAdapter::to_nrxp──→ NRXP Frame ──→ туннель
//! туннель ──→ NRXP Frame ──RawCastAdapter::from_nrxp──→ RawCastFrame ──→ локальный сокет
//! ```
//!
//! `socket_id` локальной стороны напрямую отображается в `stream_id` потока NRXP,
//! так что мультиплексирование «бесплатно» переносится между двумя протоколами.
mod adapter; mod adapter;
mod frame; mod frame;
+37 -357
View File
@@ -1,371 +1,51 @@
--- # Блок `tlseng` — движок TLS-маскировки
# TLS Engine (Masking + Parsing) Минимальный TLS-«стек», задача которого — **не** реализовать TLS, а правдоподобно
его *имитировать*. Цель: чтобы первый пакет сессии (`ClientHello`) по отпечатку
(JA3/JA4) был неотличим от популярного браузера, и DPI считал туннель обычным
HTTPS-сёрфингом.
## Обзор Фокус блока — **fingerprint mimicry**, а не криптография: настоящие ключи живут в
[`crypto`](../crypto), а их обмен спрятан внутри полей этих поддельных
TLS-сообщений.
Этот модуль реализует: > Детали — в rustdoc, модуль `tlseng`.
- Генерацию TLS ClientHello / ServerHello с профилями браузеров ## Файлы
- Гибкую сборку TLS Extensions (включая GREASE, padding, ALPS)
- Парсинг TLS-records и handshake-сообщений
- Маскировку под реальные браузеры (Chrome / Firefox)
- Минимальный TLS-стек для кастомного протокола
Фокус: **fingerprint mimicry**, а не полный TLS стек. | Файл | Что внутри |
|-----------------|-----------------------------------------------------------------|
| `types.rs` | Wire-константы и enum'ы TLS (content-type, версии, группы, ext). |
| `consts.rs` | Точечные «магические числа» протокола. |
| `tls_record.rs` | Слой TLS-записи: `type|version|len` + payload. |
| `extension.rs` | Сборка/парсинг расширений (`ExtensionStack`/`ExtensionBuilder`). |
| `handshake.rs` | `ClientHello`/`ServerHello`: сборка и разбор. |
| `profile.rs` | Профили браузеров/сервера: cipher-suites, группы, порядок ext. |
--- ## Где спрятана «контрабанда»
## Архитектура Поля поддельного hello переиспользуются под обмен ключами:
``` | Поле TLS | Что несёт на самом деле |
tlseng/ |---------------------|------------------------------------------------------|
├── consts.rs # TLS константы | `random` (32 байта) | локальная соль стороны (для HKDF) |
├── extension.rs # Extensions builder + parser | `session_id` (32 б.)| 16 случайных байт + 16 байт time-based auth-тега |
├── handshake.rs # ClientHello / ServerHello | расширение KeyShare | публичный ключ X25519 |
├── profile.rs # Browser / Server profiles
├── tls_record.rs # TLS record layer
├── types.rs # enums + структуры
```
--- ## Почему порядок и длины критичны
## Основные компоненты JA3/JA4-отпечаток считается из набора и **порядка** cipher-suites и расширений, их
содержимого, GREASE-значений и паддинга. Поэтому `profile.rs` хранит точные списки
и `ExtensionOrder`, повторяющие реальный браузер байт-в-байт. Любая перестановка
ломает маскировку. `ExtensionBuilder::apply_profile` идёт строго по порядку из
профиля.
### 1. TLS Record Layer ## Чего здесь намеренно нет
Файл: `tls_record.rs` Полного TLS state machine, проверки сертификатов, `Finished`/verify и самого
шифрования — движок изображает рукопожатие ровно настолько, чтобы пройти DPI.
Реализует базовый TLS record: ## Связи
```rust - Вызывается из `TlsBridge` ([`nrxp`](../nrxp)) при хендшейке.
struct TlsRecord { - Извлечённый из KeyShare ключ уходит в [`crypto`](../crypto) для ECDH.
content_type: ContentType,
version: ProtocolVersion,
payload: Bytes
}
```
Поддержка:
- Handshake (0x16)
- ApplicationData (0x17)
- Alert (0x15)
Особенности:
- Минимальная валидация длины
- Быстрая сериализация без аллокаций
- ApplicationData требует минимум 17 байт (под AEAD)
---
### 2. Handshake Layer
Файл: `handshake.rs`
#### ClientHello
Генерация:
```rust
ClientHello::make_client_hello(profile, host, keys)
```
Что делает:
- Генерирует `random` (через session keys)
- Формирует `session_id`:
- 16 байт random
- 16 байт auth tag
- Вставляет cipher suites из профиля
- Добавляет extensions через `ExtensionBuilder`
- Оборачивает в TLS Record
Особенности:
- TLS 1.3 masked как TLS 1.2 (version = 0x0303)
- Поддержка padding (для fingerprint совпадения)
- Полный контроль порядка extensions
---
#### ServerHello
```rust
ServerHello::make_server_hello(...)
```
- Выбор cipher suite:
- либо server order
- либо client order
- Всегда включает:
- supported_versions
- key_share
Минимальный TLS 1.3 ServerHello
---
### 3. Extension System
Файл: `extension.rs`
#### ExtensionStack (Parser)
- Парсит список extensions
- Проверяет корректность длины
- Позволяет искать extension по типу
```rust
find_by_type(etype) -> Option<Bytes>
```
---
#### ExtensionBuilder
Ключевой компонент маскировки.
```rust
ExtensionBuilder::apply_profile(profile, host, pub_key, overhead)
```
Поддерживает:
| Extension | Реализация |
| --------------------- | ---------- |
| SNI | ✔ |
| ALPN | ✔ |
| ALPS | ✔ |
| Supported Groups | ✔ |
| Signature Algorithms | ✔ |
| KeyShare | ✔ |
| PSK Modes | ✔ |
| Padding | ✔ |
| GREASE | ✔ |
| Compress Certificate | ✔ |
| SCT | ✔ |
| Delegated Credentials | ✔ |
---
#### Особенности
##### GREASE
```rust
TlsExtensions::is_grease(id)
```
- Проверка по паттерну `0x?a?a`
- Вставляется только если `profile.has_grease`
---
##### Padding
```rust
padding(target_size, overhead)
```
- Доводит ClientHello до нужного размера
- Используется для bypass DPI / fingerprint
---
##### ALPS (Application Settings)
```rust
application_settings(["h2"])
```
- Chromium-only behavior
- Добавляется только если есть в профиле
---
### 4. Profiles (Fingerprint Mimicry)
Файл: `profile.rs`
#### BrowserProfile
Определяет fingerprint:
```rust
pub struct BrowserProfile {
groups
signatures
versions
cipher_suites
extension_order
alpn
grease
padding
}
```
---
#### Примеры
##### Chrome 131
- GREASE: включен
- ALPS: включен
- Padding: 512 bytes
- TLS 1.3 only
- Record version: TLS 1.0 (как в реальном Chrome)
##### Firefox 130
- GREASE: выключен
- ALPS: нет
- Padding: нет
- TLS 1.2 + 1.3
---
#### Extension Order — критично
```rust
ExtensionOrder::CHROMIUM_131
```
Порядок полностью повторяет реальный браузер.
Это ключ к обходу:
- JA3 fingerprint
- DPI
- TLS fingerprinting
---
### 5. Типы и константы
Файл: `types.rs`
Содержит:
- `ContentType`
- `ProtocolVersion`
- `HelloType`
- `TlsGroups`
- `TlsSignatures`
- `TlsVersions`
- `TlsExtensions`
---
#### Важно
##### TLS Versions
```rust
TlsVersions::TLS_13_ONLY
TlsVersions::MODERN
```
Метод:
```rust
max() -> ProtocolVersion
```
---
##### Supported Groups
```rust
X25519 (0x001d) основной
```
---
##### Signature Algorithms
Совместимы с браузерами:
- ECDSA
- RSA-PSS
- RSA PKCS1
---
## Парсинг
Все структуры реализуют трейт:
```rust
trait Parser {
fn can_parse(...)
fn parse(...)
}
```
Подход:
1. `can_parse` — быстрый pre-check без аллокаций
2. `parse` — безопасный разбор
---
## Безопасность и ограничения
### Что есть
- Корректная структура TLS
- Реалистичный fingerprint
- Минимальная валидация
### Чего нет
- Полного TLS state machine
- Certificate validation
- Finished / handshake verify
- AEAD шифрования (в этом модуле)
---
## Основной use-case
Этот движок предназначен для:
- TLS fingerprint spoofing
- DPI bypass
- Proxy / tunneling протоколов
- Эмуляции браузера на уровне TLS
---
## Пример использования
```rust
let client_hello = ClientHello::make_client_hello(
&BrowserProfile::CHROME_131,
"example.com",
&session_keys
);
```
---
## Ключевые идеи
1. TLS используется как транспорт маскировки
2. Fingerprint важнее криптографии
3. Поведение должно совпадать с браузером
4. Порядок и длины критичны
5. GREASE обязателен для Chromium
---
Если нужно — могу:
- разобрать отличия от real Chrome packet capture
- добавить JA3 генерацию
- указать где fingerprint ещё палится (важно)
+11
View File
@@ -1,10 +1,21 @@
//! Точечные wire-константы TLS, не попавшие в крупные наборы [`types`](super::types).
//!
//! Это «магические числа» из спецификации, вынесенные в именованные константы,
//! чтобы код сборки расширений и hello-сообщений читался без обращения к RFC.
/// Тип handshake-сообщения `ClientHello`.
pub(crate) const HANDSHAKE_TYPE_CLIENT_HELLO: u8 = 0x01; pub(crate) const HANDSHAKE_TYPE_CLIENT_HELLO: u8 = 0x01;
/// Тип handshake-сообщения `ServerHello`.
pub(crate) const HANDSHAKE_TYPE_SERVER_HELLO: u8 = 0x02; pub(crate) const HANDSHAKE_TYPE_SERVER_HELLO: u8 = 0x02;
/// Тип записи имени в расширении SNI — `host_name`.
pub(crate) const TYPE_HOST_NAME: u8 = 0x00; pub(crate) const TYPE_HOST_NAME: u8 = 0x00;
/// Режим PSK `psk_dhe_ke` (обмен ключами с DHE) в расширении `psk_key_exchange_modes`.
pub(crate) const PSK_DHE_KE_MODE: u8 = 0x01; pub(crate) const PSK_DHE_KE_MODE: u8 = 0x01;
/// Алгоритм сжатия сертификата Brotli (`compress_certificate`).
pub(crate) const CERT_COMPRESSION_BROTLI: u16 = 0x0002; pub(crate) const CERT_COMPRESSION_BROTLI: u16 = 0x0002;
/// Тип запроса OCSP (`status_request`) — `ocsp`.
pub(crate) const OCSP_STATUS_TYPE: u8 = 0x01; pub(crate) const OCSP_STATUS_TYPE: u8 = 0x01;
+188 -4
View File
@@ -1,3 +1,17 @@
//! TLS Extensions: сборка (исходящие) и разбор (входящие) — ядро отпечатка.
//!
//! Два направления:
//! - [`ExtensionStack`] + его [`Parser`] — **читают** блок расширений из чужого
//! hello (нужно, чтобы достать KeyShare с публичным ключом по
//! [`find_by_type`](ExtensionStack::find_by_type));
//! - [`ExtensionBuilder`] — **пишут** наш блок расширений в точном порядке профиля.
//!
//! Каждое расширение на проводе — это `type(2) | length(2) | data(length)`.
//! Билдер-методы по одному кладут конкретные расширения, а
//! [`apply_profile`](ExtensionBuilder::apply_profile) проходит по
//! [`ExtensionOrder`](super::types::ExtensionOrder) профиля и вызывает нужный
//! метод для каждого id — так гарантируется правильный порядок (JA3/JA4).
use bytes::{Buf, BufMut, Bytes, BytesMut}; use bytes::{Buf, BufMut, Bytes, BytesMut};
use crate::{ use crate::{
@@ -10,6 +24,7 @@ use crate::{
}, },
}; };
/// Одно разобранное расширение: тип + сырые данные (длина продублирована в `_elen`).
#[derive(Debug)] #[derive(Debug)]
pub(crate) struct Extension { pub(crate) struct Extension {
pub etype: u16, pub etype: u16,
@@ -17,20 +32,55 @@ pub(crate) struct Extension {
pub data: Bytes, pub data: Bytes,
} }
/// Разобранный список расширений из входящего hello.
#[derive(Debug)] #[derive(Debug)]
pub(crate) struct ExtensionStack { pub(crate) struct ExtensionStack {
pub extensions: Vec<Extension>, pub extensions: Vec<Extension>,
} }
impl ExtensionStack { impl ExtensionStack {
/// Находит расширение по типу и возвращает его данные (zero-copy clone
/// [`Bytes`]). Главный потребитель — извлечение KeyShare (`0x0033`) с
/// публичным ключом удалённой стороны при выводе ключей сессии.
pub fn find_by_type(&self, etype: u16) -> Option<Bytes> { pub fn find_by_type(&self, etype: u16) -> Option<Bytes> {
self.extensions self.extensions
.iter() .iter()
.find(|e| e.etype == etype) .find(|e| e.etype == etype)
.map(|e| e.data.clone()) .map(|e| e.data.clone())
} }
/// Достаёт hostname из расширения SNI (`server_name`), если оно есть и
/// синтаксически хорошо сформировано. Формат данных: `list_len(2) |
/// name_type(1)=0x00 | name_len(2) | name`.
///
/// Нужен серверной stealth-fallback ветке ([`ServerHandler`](crate::net::connection::ServerHandler)):
/// проксировать «чужого» клиента (невалидный auth-тег) именно на тот хост,
/// который он сам запросил в SNI, а не всегда на один и тот же фиксированный
/// decoy — иначе активное зондирование с разными SNI на одном IP всегда
/// получает одинаковый ответ, что само по себе выдаёт нестандартный прокси.
/// Возвращаемая строка — сырой ввод удалённой стороны: вызывающий код
/// обязан провалидировать её (см. `is_plausible_hostname` в `net::connection`)
/// перед использованием в исходящем сетевом запросе.
pub fn server_name(&self) -> Option<String> {
let data = self.find_by_type(TlsExtensions::SNI)?;
if data.len() < 2 {
return None;
}
let list_len = u16::from_be_bytes([data[0], data[1]]) as usize;
let list = data.get(2..2 + list_len)?;
if list.len() < 3 || list[0] != TYPE_HOST_NAME {
return None;
}
let name_len = u16::from_be_bytes([list[1], list[2]]) as usize;
let name_bytes = list.get(3..3 + name_len)?;
std::str::from_utf8(name_bytes).ok().map(|s| s.to_string())
}
} }
/// Разбор блока расширений. Сначала «холостым» проходом суммируются длины всех
/// расширений, чтобы убедиться, что блок пришёл целиком и не содержит лишних
/// байт (точное равенство `offset == data_len`); только потом извлекаются сами
/// расширения. Хвостовой мусор → [`ErrorAction::Drop`] (испорченное/чужое hello).
impl Parser for ExtensionStack { impl Parser for ExtensionStack {
type Error = TlsError; type Error = TlsError;
@@ -89,6 +139,9 @@ impl Extension {
} }
} }
/// Накопитель блока расширений. Каждый `*`-метод дописывает одно конкретное
/// расширение в `payload`; порядок определяется вызывающим
/// [`apply_profile`](ExtensionBuilder::apply_profile), а не самими методами.
pub(crate) struct ExtensionBuilder { pub(crate) struct ExtensionBuilder {
payload: BytesMut, payload: BytesMut,
} }
@@ -100,12 +153,15 @@ impl ExtensionBuilder {
} }
} }
/// Низкоуровневая запись одного расширения: `type | len | data`.
/// Все публичные методы-«рецепты» ниже сводятся к этому вызову.
fn add_extension(&mut self, etype: u16, data: &[u8]) { fn add_extension(&mut self, etype: u16, data: &[u8]) {
self.payload.put_u16(etype); self.payload.put_u16(etype);
self.payload.put_u16(data.len() as u16); self.payload.put_u16(data.len() as u16);
self.payload.put_slice(data); self.payload.put_slice(data);
} }
/// GREASE-«пустышка»: расширение со случайным id и нулевой длиной (RFC 8701).
pub fn grease_with_id(&mut self, etype: u16) { pub fn grease_with_id(&mut self, etype: u16) {
self.add_extension(etype, &[]); self.add_extension(etype, &[]);
} }
@@ -118,6 +174,8 @@ impl ExtensionBuilder {
} }
} }
/// SNI (`server_name`): целевой хост в открытом виде — браузеры так и делают,
/// поэтому для маскировки имя сервера здесь не прячется.
pub fn server_name(&mut self, host: &str) { pub fn server_name(&mut self, host: &str) {
let host_bytes = host.as_bytes(); let host_bytes = host.as_bytes();
let host_len = host_bytes.len() as u16; let host_len = host_bytes.len() as u16;
@@ -163,6 +221,9 @@ impl ExtensionBuilder {
self.add_extension(TlsExtensions::SUPPORTED_VERSIONS, &data); self.add_extension(TlsExtensions::SUPPORTED_VERSIONS, &data);
} }
/// KeyShare (`0x0033`): **самое важное** расширение — несёт наш публичный
/// ключ X25519. Группа берётся первой из профиля (по умолчанию `0x001d`).
/// Именно отсюда удалённая сторона достаёт ключ для ECDH.
pub fn key_share(&mut self, profile: &BrowserProfile, pub_key: &[u8]) { pub fn key_share(&mut self, profile: &BrowserProfile, pub_key: &[u8]) {
let key_len = pub_key.len() as u16; let key_len = pub_key.len() as u16;
@@ -178,14 +239,24 @@ impl ExtensionBuilder {
self.add_extension(TlsExtensions::KEY_SHARE, &list); self.add_extension(TlsExtensions::KEY_SHARE, &list);
} }
/// ALPS (`application_settings`): формат идентичен `alpn()` — вектор с
/// 2-байтовой длиной, содержащий длину-префиксные имена протоколов.
///
/// Раньше здесь писался только `len|proto` без внешней 2-байтовой длины
/// списка, а после каждого имени лишний `put_u16(0)` — на проводе это
/// давало содержимое вида `02 68 32 00 00`, где Wireshark читает первые
/// два байта как длину вектора (`0x0268` = 616) и ругается "too large,
/// truncating it to 3". Реальный Chrome шлёт `00 03 02 68 32`.
pub fn application_settings(&mut self, protocols: &[&str]) { pub fn application_settings(&mut self, protocols: &[&str]) {
let mut data = BytesMut::new(); let mut list_data = BytesMut::new();
for proto in protocols { for proto in protocols {
let p_bytes = proto.as_bytes(); let p_bytes = proto.as_bytes();
data.put_u8(p_bytes.len() as u8); list_data.put_u8(p_bytes.len() as u8);
data.put_slice(p_bytes); list_data.put_slice(p_bytes);
data.put_u16(0);
} }
let mut data = BytesMut::with_capacity(2 + list_data.len());
data.put_u16(list_data.len() as u16);
data.put_slice(&list_data);
self.add_extension(TlsExtensions::ALPS, &data); self.add_extension(TlsExtensions::ALPS, &data);
} }
@@ -254,6 +325,9 @@ impl ExtensionBuilder {
self.add_extension(TlsExtensions::RENEGOTIATION_INFO, &[0x00]); self.add_extension(TlsExtensions::RENEGOTIATION_INFO, &[0x00]);
} }
/// Padding (`0x0015`): добивает `ClientHello` нулями до `target_size` с учётом
/// `overhead` (заголовки записи/хендшейка и фикс. поля), чтобы итоговая длина
/// совпала с отпечатком браузера. `-4` — это собственные `type|len` паддинга.
pub fn padding(&mut self, target_size: usize, overhead: usize) { pub fn padding(&mut self, target_size: usize, overhead: usize) {
let current_total_size = self.payload.len() + overhead; let current_total_size = self.payload.len() + overhead;
@@ -264,6 +338,12 @@ impl ExtensionBuilder {
} }
} }
/// Собирает весь блок расширений строго в порядке профиля.
///
/// Проходит по [`profile.extension_order`](BrowserProfile::extension_order) и
/// для каждого id вызывает соответствующий метод-«рецепт». GREASE-id
/// вставляются только при `profile.has_grease`, ALPS/Padding — только если
/// профиль их задаёт. Порядок здесь = порядок на проводе = отпечаток.
pub fn apply_profile( pub fn apply_profile(
&mut self, &mut self,
profile: &BrowserProfile, profile: &BrowserProfile,
@@ -315,7 +395,111 @@ impl ExtensionBuilder {
} }
} }
/// Завершает сборку и отдаёт готовый блок расширений (zero-copy `freeze`).
pub fn build(&mut self) -> Bytes { pub fn build(&mut self) -> Bytes {
self.payload.split().freeze() self.payload.split().freeze()
} }
} }
#[cfg(test)]
mod tests {
use super::*;
use crate::tlseng::profile::BrowserProfile;
const FAKE_PUB_KEY: [u8; 32] = [0x7A; 32];
fn build_for(profile: &BrowserProfile, host: &str) -> ExtensionStack {
let mut builder = ExtensionBuilder::new();
builder.apply_profile(profile, host, &FAKE_PUB_KEY, 0);
let bytes = builder.build();
ExtensionStack::parse(&mut BytesMut::from(bytes.as_ref()))
.unwrap()
.unwrap()
}
#[test]
fn every_browser_profile_round_trips_and_exposes_server_name() {
for profile in BrowserProfile::ALL {
let stack = build_for(profile, "example.com");
assert_eq!(
stack.server_name().as_deref(),
Some("example.com"),
"SNI must round-trip for every profile"
);
let key_share = stack
.find_by_type(TlsExtensions::KEY_SHARE)
.expect("every profile writes a KeyShare extension");
// entry: group(2) | key_len(2) | key(32), внутри list_len(2) — итого 4+32=36 после первых 2.
assert!(key_share.len() >= 36);
assert_eq!(&key_share[key_share.len() - 32..], &FAKE_PUB_KEY[..]);
}
}
#[test]
fn server_name_handles_longer_hostnames() {
let stack = build_for(&BrowserProfile::CHROME_131, "dev.netrunner-vpn.com");
assert_eq!(
stack.server_name().as_deref(),
Some("dev.netrunner-vpn.com")
);
}
#[test]
fn server_name_is_none_when_extension_absent() {
let stack = ExtensionStack { extensions: vec![] };
assert_eq!(stack.server_name(), None);
}
#[test]
fn server_name_is_none_when_extension_malformed() {
// list_len врёт про длину — данных после него меньше заявленного.
let mut data = BytesMut::new();
data.put_u16(100); // list_len = 100, но данных нет вообще
let stack = ExtensionStack {
extensions: vec![Extension::new(TlsExtensions::SNI, data.freeze())],
};
assert_eq!(stack.server_name(), None);
}
#[test]
fn server_name_is_none_for_non_utf8_hostname() {
let mut data = BytesMut::new();
let name = [0xFFu8, 0xFE, 0xFD];
data.put_u16((1 + 2 + name.len()) as u16); // list_len
data.put_u8(TYPE_HOST_NAME);
data.put_u16(name.len() as u16);
data.put_slice(&name);
let stack = ExtensionStack {
extensions: vec![Extension::new(TlsExtensions::SNI, data.freeze())],
};
assert_eq!(stack.server_name(), None);
}
#[test]
fn padding_extension_hits_target_size() {
let mut builder = ExtensionBuilder::new();
builder.server_name("example.com");
let before = builder.payload.len();
builder.padding(512, 0);
let after = builder.payload.len();
assert_eq!(
after, 512,
"total size (incl. padding's own 4-byte header) must hit target exactly"
);
assert!(after > before);
}
#[test]
fn padding_extension_skipped_when_already_over_target() {
let mut builder = ExtensionBuilder::new();
builder.server_name("a-very-long-hostname-that-eats-the-budget.example.com");
let before = builder.payload.len();
builder.padding(10, 0); // target already exceeded
assert_eq!(
builder.payload.len(),
before,
"must not add negative padding"
);
}
}
+174 -2
View File
@@ -1,3 +1,18 @@
//! Сборка и разбор hello-сообщений рукопожатия.
//!
//! Здесь живёт «полезная контрабанда» внутри маскировки: поля поддельного
//! `ClientHello`/`ServerHello` переиспользуются под обмен ключами.
//!
//! - **`random` (32 байта)** ← локальная соль стороны (см. [`SessionKeys::local_salt`]).
//! - **`session_id` (32 байта)** ← 16 случайных байт + 16 байт time-based
//! auth-тега. Сервер первым делом проверяет этот тег (см.
//! [`bridge`](crate::nrxp)) — отсев чужих/сканеров до любой крипты.
//! - **публичный ключ X25519** ← в расширении KeyShare (собирается [`ExtensionBuilder`]).
//!
//! Все три структуры реализуют [`Parser`] (разбор входящих) и имеют `serialize`
//! (сборка исходящих). Точные размеры/порядок берутся из [`profile`](super::profile),
//! чтобы итоговый отпечаток совпал с реальным браузером.
use aead::{rand_core::RngCore, OsRng}; use aead::{rand_core::RngCore, OsRng};
use bytes::{Buf, BufMut, Bytes, BytesMut}; use bytes::{Buf, BufMut, Bytes, BytesMut};
@@ -15,6 +30,8 @@ use crate::{
utils::u24::{BufExt, U24}, utils::u24::{BufExt, U24},
}; };
/// Заголовок handshake-сообщения: тип (`ClientHello`/`ServerHello`) + 24-битная
/// длина тела. Парсится первым, чтобы понять, какое именно hello разбирать дальше.
pub(crate) struct HelloHeader { pub(crate) struct HelloHeader {
pub header_type: HelloType, pub header_type: HelloType,
pub _len: U24, pub _len: U24,
@@ -49,15 +66,26 @@ impl Parser for HelloHeader {
} }
} }
/// `ClientHello`: первое сообщение клиента, оно же главный носитель отпечатка.
///
/// `random` несёт соль, `session_id` — auth-тег, `extensions` — публичный ключ и
/// прочие поля профиля. Cipher-suites и порядок расширений берутся из браузерного
/// профиля.
pub(crate) struct ClientHello { pub(crate) struct ClientHello {
pub _version: ProtocolVersion, pub _version: ProtocolVersion,
/// 32 байта «random» = локальная соль клиента (для HKDF).
pub random: [u8; 32], pub random: [u8; 32],
/// 32 байта: 16 случайных + 16 auth-тег (проверяется сервером).
pub session_id: Bytes, pub session_id: Bytes,
/// Список cipher-suites (значения и порядок — часть JA3).
pub cipher_suites: Vec<u16>, pub cipher_suites: Vec<u16>,
/// Сырые байты блока расширений (содержат KeyShare с pubkey).
pub extensions: Bytes, pub extensions: Bytes,
} }
impl ClientHello { impl ClientHello {
/// Сериализует `ClientHello` в тело handshake-сообщения с корректной
/// 24-битной длиной (длина дописывается задним числом по `length_pos`).
pub fn serialize(&self) -> Bytes { pub fn serialize(&self) -> Bytes {
let mut buf = BytesMut::with_capacity(512 + self.extensions.len()); let mut buf = BytesMut::with_capacity(512 + self.extensions.len());
@@ -91,12 +119,26 @@ impl ClientHello {
buf.freeze() buf.freeze()
} }
/// Высокоуровневая сборка готового к отправке `ClientHello` (в TLS-записи).
///
/// Кладёт соль в `random`, формирует `session_id` =
/// `[1 версия протокола | 15 random | 16 auth-tag]`, затем через
/// [`ExtensionBuilder`] собирает расширения по профилю (включая SNI=`host`
/// и KeyShare с публичным ключом). `total_overhead` нужен билдеру, чтобы
/// посчитать padding до целевого размера отпечатка.
pub fn make_client_hello(profile: &BrowserProfile, host: &str, keys: &SessionKeys) -> Bytes { pub fn make_client_hello(profile: &BrowserProfile, host: &str, keys: &SessionKeys) -> Bytes {
let tls_random = keys.local_salt(); let tls_random = keys.local_salt();
let mut session_id_bytes = [0u8; 32]; let mut session_id_bytes = [0u8; 32];
OsRng.fill_bytes(&mut session_id_bytes[..16]);
// ИСПРАВЛЕНИЕ: Используем SessionAuth для генерации тега // session_id[0] — заявленная версия протокола (см. crate::PROTOCOL_VERSION):
// не влияет на JA3/JA4 (значение session_id в отпечаток не входит, только
// его длина), позволяет серверу узнать, какое "протокольное" поведение
// клиент способен понять, до того как что-либо ему отправить.
session_id_bytes[0] = crate::PROTOCOL_VERSION;
OsRng.fill_bytes(&mut session_id_bytes[1..16]);
// session_id[16..32] = текущий time-based auth-тег: сервер проверит его
// первым делом и отвергнет ClientHello без валидного тега.
let auth = SessionAuth::new(keys.get_auth_key()); let auth = SessionAuth::new(keys.get_auth_key());
session_id_bytes[16..].copy_from_slice(&auth.generate_current_tag()); session_id_bytes[16..].copy_from_slice(&auth.generate_current_tag());
@@ -130,12 +172,17 @@ impl ClientHello {
} }
} }
/// Разбор входящего `ClientHello` (серверная сторона). `can_parse` «прыжковым
/// поиском» проходит по полям переменной длины (session_id → ciphers →
/// compression → extensions), не сдвигая курсор, и убеждается, что пришёл весь
/// блок; `parse` затем извлекает поля по-настоящему.
impl Parser for ClientHello { impl Parser for ClientHello {
type Error = TlsError; type Error = TlsError;
fn can_parse(bytes: &BytesMut) -> bool { fn can_parse(bytes: &BytesMut) -> bool {
let mut reader = &bytes[..]; let mut reader = &bytes[..];
// 34 = 2 (version) + 32 (random) — фиксированная «голова» перед session_id.
if reader.len() < 35 { if reader.len() < 35 {
return false; return false;
} }
@@ -214,14 +261,22 @@ impl Parser for ClientHello {
} }
} }
/// `ServerHello`: ответ сервера. Минимальный TLS 1.3-совместимый: всегда несёт
/// `supported_versions` и `key_share` (публичный ключ сервера), `random` = соль
/// сервера, а `session_id` эхом возвращается из `ClientHello`.
pub(crate) struct ServerHello { pub(crate) struct ServerHello {
pub version: ProtocolVersion, pub version: ProtocolVersion,
/// 32 байта «random» = локальная соль сервера (для HKDF).
pub random: [u8; 32], pub random: [u8; 32],
/// Эхо `session_id` клиента (так требует TLS 1.3).
pub session_id: Bytes, pub session_id: Bytes,
/// Один выбранный cipher-suite.
pub cipher_suite: u16, pub cipher_suite: u16,
/// Блок расширений (supported_versions + key_share с pubkey сервера).
pub extensions: BytesMut, pub extensions: BytesMut,
} }
impl ServerHello { impl ServerHello {
/// Высокоуровневая сборка готового к отправке `ServerHello` (в TLS-записи).
pub fn make_server_hello( pub fn make_server_hello(
client_hello: &ClientHello, client_hello: &ClientHello,
server_public_key: &[u8], server_public_key: &[u8],
@@ -239,6 +294,13 @@ impl ServerHello {
record.serialize() record.serialize()
} }
/// Конструирует `ServerHello` из принятого `ClientHello`.
///
/// Выбор cipher-suite зависит от `honor_cipher_order`: либо берём первый из
/// предпочтений сервера, который поддержал клиент, либо наоборот; fallback —
/// `0x1301` (TLS_AES_128_GCM_SHA256). Дальше вручную пишутся два обязательных
/// расширения: `supported_versions` (0x002b) и `key_share` (0x0033) с
/// публичным ключом сервера по группе X25519 (0x001d).
pub fn from_client_hello( pub fn from_client_hello(
client_hello: &ClientHello, client_hello: &ClientHello,
server_public_key: &[u8], server_public_key: &[u8],
@@ -287,6 +349,7 @@ impl ServerHello {
} }
} }
/// Сериализует `ServerHello` в тело handshake с 24-битной длиной.
pub fn serialize(&self) -> Bytes { pub fn serialize(&self) -> Bytes {
let mut buf = BytesMut::with_capacity(256 + self.extensions.len()); let mut buf = BytesMut::with_capacity(256 + self.extensions.len());
@@ -321,10 +384,14 @@ impl ServerHello {
} }
} }
/// Разбор входящего `ServerHello` (клиентская сторона). Так же прыжками по полям
/// вычисляет полную длину сообщения, отрезает его (`split_to`) и читает поля;
/// из расширений потом достаётся публичный ключ сервера для ECDH.
impl Parser for ServerHello { impl Parser for ServerHello {
type Error = TlsError; type Error = TlsError;
fn can_parse(bytes: &BytesMut) -> bool { fn can_parse(bytes: &BytesMut) -> bool {
// 34 = 2 (version) + 32 (random) перед длиной session_id.
let mut offset = 34; let mut offset = 34;
if bytes.len() < offset + 1 { if bytes.len() < offset + 1 {
return false; return false;
@@ -392,3 +459,108 @@ impl Parser for ServerHello {
})) }))
} }
} }
#[cfg(test)]
mod tests {
use super::*;
use crate::crypto::SessionKeys;
use crate::tlseng::ExtensionStack;
fn parse_client_hello_record(wire: &Bytes) -> (ClientHello, ExtensionStack) {
let mut record_buf = BytesMut::from(&wire[..]);
let record = TlsRecord::parse(&mut record_buf).unwrap().unwrap();
let mut body = BytesMut::from(record.payload.as_ref());
HelloHeader::parse(&mut body).unwrap().unwrap();
let hello = ClientHello::parse(&mut body).unwrap().unwrap();
let ext = ExtensionStack::parse(&mut BytesMut::from(hello.extensions.as_ref()))
.unwrap()
.unwrap();
(hello, ext)
}
fn parse_server_hello_record(wire: &Bytes) -> (ServerHello, ExtensionStack) {
let mut record_buf = BytesMut::from(&wire[..]);
let record = TlsRecord::parse(&mut record_buf).unwrap().unwrap();
let mut body = BytesMut::from(record.payload.as_ref());
HelloHeader::parse(&mut body).unwrap().unwrap();
let hello = ServerHello::parse(&mut body).unwrap().unwrap();
let ext = ExtensionStack::parse(&mut BytesMut::from(hello.extensions.as_ref()))
.unwrap()
.unwrap();
(hello, ext)
}
/// Полный цикл "как в проде": клиент строит ClientHello → сервер его
/// разбирает и строит ServerHello → клиент разбирает ServerHello. Обе
/// стороны должны вывести идентичные AEAD-параметры (крест-накрест: tx
/// одной стороны == rx другой) и общий auth_key.
#[test]
fn full_handshake_round_trip_derives_matching_keys() {
for profile in BrowserProfile::ALL {
let client_keys = SessionKeys::new(true);
let ch_wire = ClientHello::make_client_hello(profile, "example.com", &client_keys);
let (client_hello, client_ext) = parse_client_hello_record(&ch_wire);
assert_eq!(client_hello.session_id.len(), 32);
assert_eq!(client_hello.session_id[0], crate::PROTOCOL_VERSION);
assert_eq!(client_hello.cipher_suites, profile.cipher_suites);
let mut server_keys = SessionKeys::new(false);
server_keys
.update_keys(client_hello.random, &client_ext, true)
.expect("server key derivation must succeed from a real ClientHello");
let server_pub = server_keys.public_key_bytes();
let sh_wire = ServerHello::make_server_hello(
&client_hello,
&server_pub,
server_keys.local_salt(),
&ServerProfile::MODERN,
);
let (server_hello, server_ext) = parse_server_hello_record(&sh_wire);
assert_eq!(&server_hello.session_id[..], &client_hello.session_id[..]);
let mut client_keys = client_keys;
client_keys
.update_keys(server_hello.random, &server_ext, false)
.expect("client key derivation must succeed from a real ServerHello");
let (c_tx_k, c_tx_iv, c_rx_k, c_rx_iv) = client_keys.get_aead_parameters();
let (s_tx_k, s_tx_iv, s_rx_k, s_rx_iv) = server_keys.get_aead_parameters();
assert_eq!(c_tx_k, s_rx_k, "client tx key must equal server rx key");
assert_eq!(c_tx_iv, s_rx_iv, "client tx iv must equal server rx iv");
assert_eq!(c_rx_k, s_tx_k, "client rx key must equal server tx key");
assert_eq!(c_rx_iv, s_tx_iv, "client rx iv must equal server tx iv");
assert_eq!(
client_keys.get_auth_key(),
server_keys.get_auth_key(),
"both sides must derive the same auth_key"
);
}
}
#[test]
fn tampered_auth_tag_in_session_id_is_rejected_by_server() {
use crate::crypto::SessionAuth;
let client_keys = SessionKeys::new(true);
let ch_wire = ClientHello::make_client_hello(
&BrowserProfile::CHROME_131,
"example.com",
&client_keys,
);
let (client_hello, _ext) = parse_client_hello_record(&ch_wire);
let mut tampered = client_hello.session_id.to_vec();
tampered[20] ^= 0xFF; // flip a byte inside the auth tag (bytes 16..32)
let auth = SessionAuth::new(client_keys.get_auth_key());
let mut received_tag = [0u8; 16];
received_tag.copy_from_slice(&tampered[16..32]);
assert!(
!auth.verify_tag(&received_tag),
"a tampered auth tag must not verify"
);
}
}
+35
View File
@@ -1,3 +1,38 @@
//! # Движок TLS-маскировки (`tlseng`)
//!
//! Минимальный TLS-«стек», задача которого — **не** реализовать TLS, а
//! правдоподобно его *имитировать*. Цель — чтобы первый пакет сессии
//! (`ClientHello`) по своему отпечатку (JA3/JA4) был неотличим от популярного
//! браузера, и DPI классифицировал туннель как обычный HTTPS-сёрфинг.
//!
//! Фокус блока — **fingerprint mimicry**, а не криптография: настоящие ключи и
//! шифрование живут в [`crypto`](crate::crypto), а собственно обмен ими спрятан
//! внутри полей этих поддельных TLS-сообщений.
//!
//! ## Состав блока
//!
//! | Файл | Ответственность |
//! |-----------------|---------------------------------------------------------------------|
//! | [`types`] | Wire-константы и enum'ы TLS (content-type, версии, группы, расширения).|
//! | [`consts`] | Прочие фиксированные значения протокола. |
//! | [`tls_record`] | Слой TLS-записи: (де)сериализация заголовка `type|version|len`. |
//! | [`extension`] | Сборка и парсинг TLS Extensions ([`ExtensionStack`]) — ядро отпечатка.|
//! | [`handshake`] | `ClientHello`/`ServerHello`: сборка и разбор hello-сообщений. |
//! | [`profile`] | Профили браузеров/сервера: какие cipher-suites, группы, порядок ext. |
//!
//! ## Почему порядок и длины критичны
//!
//! JA3/JA4-отпечаток вычисляется из набора и **порядка** cipher-suites и
//! расширений, их содержимого, GREASE-значений и паддинга. Поэтому [`profile`]
//! хранит точные списки и [`ExtensionOrder`](types::ExtensionOrder), повторяющие
//! реальный браузер байт-в-байт. Любая перестановка ломает маскировку.
//!
//! ## Чего здесь намеренно нет
//!
//! Полного TLS state machine, проверки сертификатов, `Finished`/verify и самого
//! шифрования. Это осознанно: движок изображает рукопожатие ровно настолько,
//! чтобы пройти DPI, а защищённость обеспечивает кастомный протокол поверх.
mod consts; mod consts;
mod extension; mod extension;
mod handshake; mod handshake;
+175 -3
View File
@@ -1,21 +1,46 @@
//! Профили отпечатков: «рецепты» того, как должен выглядеть наш TLS.
//!
//! [`BrowserProfile`] описывает клиентский отпечаток (что и в каком порядке класть
//! в `ClientHello`, чтобы JA3/JA4 совпал с реальным браузером), а [`ServerProfile`] —
//! как отвечать на стороне сервера. Профили — это `const`-значения без аллокаций;
//! все списки ссылаются на статические срезы из [`types`](super::types).
//!
//! Менять поля профиля = менять отпечаток. Значения скопированы из реальных
//! захватов трафика соответствующих браузеров.
use crate::tlseng::types::{ use crate::tlseng::types::{
ExtensionOrder, ProtocolVersion, TlsGroups, TlsSignatures, TlsVersions, ExtensionOrder, ProtocolVersion, TlsGroups, TlsSignatures, TlsVersions,
}; };
/// Клиентский отпечаток конкретного браузера.
pub(crate) struct BrowserProfile { pub(crate) struct BrowserProfile {
/// ECDH-группы (`supported_groups`).
pub groups: TlsGroups, pub groups: TlsGroups,
/// Алгоритмы подписи (`signature_algorithms`).
pub signatures: TlsSignatures, pub signatures: TlsSignatures,
/// Подписи для `delegated_credentials`.
pub delegated_signatures: TlsSignatures, pub delegated_signatures: TlsSignatures,
/// Рекламируемые версии TLS (`supported_versions`).
pub versions: TlsVersions, pub versions: TlsVersions,
/// Протоколы ALPN (например `h2`, `http/1.1`).
pub alpn: &'static [&'static str], pub alpn: &'static [&'static str],
/// Точный порядок расширений — определяющий фактор JA3/JA4.
pub extension_order: ExtensionOrder, pub extension_order: ExtensionOrder,
/// Список cipher-suites (значения и порядок — часть отпечатка).
pub cipher_suites: &'static [u16], pub cipher_suites: &'static [u16],
/// Версия в заголовке TLS-записи (у Chrome — TLS 1.0, как в реальности).
pub record_layer_version: ProtocolVersion, pub record_layer_version: ProtocolVersion,
/// До какого размера добивать `ClientHello` паддингом (0 = без паддинга).
pub target_padding_len: u16, pub target_padding_len: u16,
/// Протоколы ALPS (`application_settings`) — поведение только Chromium.
pub alps_protocols: &'static [&'static str], pub alps_protocols: &'static [&'static str],
/// Вставлять ли GREASE-значения (обязательно для Chromium).
pub has_grease: bool, pub has_grease: bool,
} }
impl BrowserProfile { impl BrowserProfile {
/// Отпечаток Chrome 131: GREASE + ALPS, паддинг до 512, только TLS 1.3,
/// версия записи маскируется под TLS 1.0 — как у настоящего Chrome.
pub const CHROME_131: Self = Self { pub const CHROME_131: Self = Self {
groups: TlsGroups::CHROMIUM, groups: TlsGroups::CHROMIUM,
signatures: TlsSignatures::BROWSER_STANDARD, signatures: TlsSignatures::BROWSER_STANDARD,
@@ -38,9 +63,12 @@ impl BrowserProfile {
target_padding_len: 512, target_padding_len: 512,
}; };
#[allow(dead_code)] /// Отпечаток Firefox 133: без GREASE и ALPS (их у Firefox не бывает), без
/// паддинга, TLS 1.3+1.2. Раньше по ошибке ссылался на `ExtensionOrder::EDGE_130`
/// (Chromium-порядок с ALPS/compress_certificate) — теперь у него свой порядок
/// ([`FIREFOX_133`](ExtensionOrder::FIREFOX_133)) и своя группа `secp521r1`.
pub const FIREFOX_130: Self = Self { pub const FIREFOX_130: Self = Self {
groups: TlsGroups::MODERN, groups: TlsGroups::FIREFOX,
signatures: TlsSignatures::BROWSER_STANDARD, signatures: TlsSignatures::BROWSER_STANDARD,
delegated_signatures: TlsSignatures::BROWSER_STANDARD, delegated_signatures: TlsSignatures::BROWSER_STANDARD,
versions: TlsVersions::MODERN, versions: TlsVersions::MODERN,
@@ -50,26 +78,116 @@ impl BrowserProfile {
cipher_suites: &[0x1301, 0x1302, 0x1303, 0xc02b, 0xc02f, 0xc02c, 0xc030], cipher_suites: &[0x1301, 0x1302, 0x1303, 0xc02b, 0xc02f, 0xc02c, 0xc030],
alpn: &["h2", "http/1.1"], alpn: &["h2", "http/1.1"],
extension_order: ExtensionOrder::EDGE_130, extension_order: ExtensionOrder::FIREFOX_133,
has_grease: false, has_grease: false,
alps_protocols: &[], alps_protocols: &[],
target_padding_len: 0, target_padding_len: 0,
}; };
/// Отпечаток Edge 130: тот же Chromium-движок, что и Chrome (GREASE + ALPS +
/// паддинг до 512), отличается только собственными GREASE-значениями
/// (`0x1a1a`/`0x3a3a`), как у настоящего Edge.
pub const EDGE_130: Self = Self {
groups: TlsGroups::CHROMIUM,
signatures: TlsSignatures::BROWSER_STANDARD,
delegated_signatures: TlsSignatures::BROWSER_STANDARD,
versions: TlsVersions::TLS_13_ONLY,
record_layer_version: ProtocolVersion::Tls10,
cipher_suites: &[
0x1301, 0x1302, 0x1303, 0xc02b, 0xc02f, 0xc02c, 0xc030, 0xcca9, 0xcca8,
],
alpn: &["h2", "http/1.1"],
extension_order: ExtensionOrder::EDGE_130,
has_grease: true,
alps_protocols: &["h2"],
target_padding_len: 512,
};
/// Отпечаток Safari 17: не Chromium — без GREASE, без ALPS, TLS 1.3+1.2,
/// без паддинга.
pub const SAFARI_17: Self = Self {
groups: TlsGroups::SAFARI,
signatures: TlsSignatures::BROWSER_STANDARD,
delegated_signatures: TlsSignatures::BROWSER_STANDARD,
versions: TlsVersions::MODERN,
record_layer_version: ProtocolVersion::Tls12,
cipher_suites: &[
0x1301, 0x1302, 0x1303, 0xc02c, 0xc02b, 0xc030, 0xc02f, 0xcca9, 0xcca8,
],
alpn: &["h2", "http/1.1"],
extension_order: ExtensionOrder::SAFARI_17,
has_grease: false,
alps_protocols: &[],
target_padding_len: 0,
};
/// Пул профилей для ротации между разными туннельными сессиями — чтобы не
/// долбить DPI вечно одним и тем же Chrome-отпечатком.
pub const ALL: &'static [&'static Self] = &[
&Self::CHROME_131,
&Self::EDGE_130,
&Self::FIREFOX_130,
&Self::SAFARI_17,
];
/// Выбирает профиль детерминированно по `session_id` — один и тот же
/// стабильный отпечаток браузера на все ноги и все переподключения одной
/// туннельной сессии.
///
/// Раньше выбор шёл по номеру попытки реконнекта
/// ([`ClientHandler::establish_leg`](crate::net::connection::ClientHandler::establish_leg)/
/// [`TunnelEngine::attempt_reconnect`](crate::net::connection::engine::TunnelEngine::attempt_reconnect)):
/// при нескольких быстрых реконнектах одной и той же ноги (сетевая
/// нестабильность, экспоненциальный backoff в несколько секунд) с одного и
/// того же клиентского IP на один и тот же серверный IP летели ClientHello
/// с разными отпечатками браузеров подряд — Chrome, затем Edge, затем
/// Firefox. Ни один настоящий браузер так себя не ведёт: смена «личности»
/// TLS-стека на лету с того же адреса — сама по себе аномалия для
/// корреляции по 5-tuple, более заметная, чем константный отпечаток,
/// который эта ротация была призвана скрыть. Привязка к `session_id`
/// (генерируется один раз на весь туннель в
/// [`ClientHandler::connect`](crate::net::connection::ClientHandler::connect))
/// даёт ту же цель (разные клиенты/сессии выглядят по-разному), не создавая
/// эту внутрисессионную «смену браузера».
pub fn for_session(session_id: &str) -> &'static Self {
use std::hash::{Hash, Hasher};
let mut hasher = std::collections::hash_map::DefaultHasher::new();
session_id.hash(&mut hasher);
let idx = (hasher.finish() as usize) % Self::ALL.len();
Self::ALL[idx]
}
} }
/// Серверный профиль ответа. Поля с префиксом `_` зарезервированы под будущее
/// расширение `ServerHello` и сейчас в сборке не участвуют.
pub(crate) struct ServerProfile { pub(crate) struct ServerProfile {
/// Версии для `supported_versions` в ответе.
pub versions: TlsVersions, pub versions: TlsVersions,
/// Версия заголовка TLS-записи ответа.
pub record_layer_version: ProtocolVersion, pub record_layer_version: ProtocolVersion,
/// Cipher-suites, среди которых выбирается один итоговый.
pub cipher_suites: &'static [u16], pub cipher_suites: &'static [u16],
pub _groups: TlsGroups, pub _groups: TlsGroups,
pub _signatures: TlsSignatures, pub _signatures: TlsSignatures,
pub _alpn: &'static [&'static str], pub _alpn: &'static [&'static str],
pub _session_tickets: bool, pub _session_tickets: bool,
/// `true` — приоритет у порядка сервера при выборе cipher-suite, иначе клиента.
pub honor_cipher_order: bool, pub honor_cipher_order: bool,
} }
impl ServerProfile { impl ServerProfile {
/// Современный серверный профиль: TLS 1.3/1.2, выбор suite по порядку сервера.
pub const MODERN: Self = Self { pub const MODERN: Self = Self {
versions: TlsVersions::MODERN, versions: TlsVersions::MODERN,
@@ -82,4 +200,58 @@ impl ServerProfile {
_session_tickets: true, _session_tickets: true,
honor_cipher_order: true, honor_cipher_order: true,
}; };
/// Совместимый профиль: те же suite'ы плюс CBC-варианты — на случай, если
/// понадобится отвечать клиентам/зондам, которые в своём (настоящем, не
/// нашем) `ClientHello` не предлагают ни одного suite из [`MODERN`](Self::MODERN).
/// Сейчас не используется по умолчанию (`ServerHandler` берёт `MODERN`),
/// заготовлен как второй вариант — так же, как раньше `FIREFOX_130` был
/// заготовкой без пути включения.
pub const COMPAT: Self = Self {
versions: TlsVersions::MODERN,
record_layer_version: ProtocolVersion::Tls12,
cipher_suites: &[0x1301, 0x1302, 0x1303, 0xc02b, 0xc02f, 0xc02c, 0xc030],
_groups: TlsGroups::MODERN,
_signatures: TlsSignatures::BROWSER_STANDARD,
_alpn: &["h2", "http/1.1"],
_session_tickets: true,
honor_cipher_order: true,
};
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn for_session_is_deterministic_for_the_same_session_id() {
let sid = "abc123deadbeef";
let p1 = BrowserProfile::for_session(sid) as *const BrowserProfile;
let p2 = BrowserProfile::for_session(sid) as *const BrowserProfile;
assert_eq!(
p1, p2,
"same session_id must always pick the same profile — that's the whole point (stable fingerprint for the life of a tunnel session)"
);
}
#[test]
fn for_session_spreads_across_many_distinct_session_ids() {
// Не строгая гарантия равномерности, но при 200 разных session_id все
// 4 профиля из пула должны хоть раз да встретиться — иначе это не
// ротация, а фиксированный выбор под видом ротации.
let mut seen = std::collections::HashSet::new();
for i in 0..200u32 {
let sid = format!("session-{i}");
let chosen = BrowserProfile::for_session(&sid) as *const BrowserProfile;
seen.insert(chosen);
}
assert_eq!(
seen.len(),
BrowserProfile::ALL.len(),
"expected all {} profiles to appear across 200 distinct sessions",
BrowserProfile::ALL.len()
);
}
} }
+115 -4
View File
@@ -1,3 +1,11 @@
//! Слой TLS-записи — самый внешний «конверт» на проводе.
//!
//! Каждая TLS-запись начинается с 5-байтового заголовка
//! `content_type(1) | version(2) | length(2)`, за которым идёт `payload`. Здесь
//! это (де)сериализуется. [`TlsRecord`] — полноценная запись с проверкой типа и
//! длины, а [`ApplicationData`] — лёгкий «сырой payload» для горячего пути data-фазы
//! (когда тип уже известен и проверять нечего).
use bytes::{Buf, BufMut, Bytes, BytesMut}; use bytes::{Buf, BufMut, Bytes, BytesMut};
use crate::{ use crate::{
@@ -6,14 +14,16 @@ use crate::{
tlseng::types::{ContentType, ProtocolVersion}, tlseng::types::{ContentType, ProtocolVersion},
}; };
/// Разобранная TLS-запись: заголовок + полезная нагрузка.
#[derive(Debug)] #[derive(Debug)]
pub struct TlsRecord { pub struct TlsRecord {
/// Тип записи (`Handshake`/`ApplicationData`/`Alert`).
pub content_type: ContentType, pub content_type: ContentType,
/// Версия из заголовка записи (как правило `Tls12` для маскировки).
pub version: ProtocolVersion, pub version: ProtocolVersion,
/// Длина payload из заголовка (поле сохранено для отладки; имя с `_`).
pub _len: u16, pub _len: u16,
/// Тело записи (zero-copy ссылка в исходный буфер).
pub payload: Bytes, pub payload: Bytes,
} }
@@ -27,6 +37,7 @@ impl TlsRecord {
} }
} }
/// Сериализует запись в байты: `type | version | len | payload`.
pub fn serialize(&self) -> Bytes { pub fn serialize(&self) -> Bytes {
let mut buf = BytesMut::with_capacity(5 + self.payload.len()); let mut buf = BytesMut::with_capacity(5 + self.payload.len());
@@ -38,6 +49,8 @@ impl TlsRecord {
buf.freeze() buf.freeze()
} }
/// Удобный конструктор: оборачивает готовый шифртекст в запись
/// `ApplicationData` (версия маскируется под TLS 1.2) и сериализует.
pub fn build_application_data(payload: Bytes) -> Bytes { pub fn build_application_data(payload: Bytes) -> Bytes {
netrunner_logger::trace!(payload_len = payload.len(), "Building TlsRecord from Bytes"); netrunner_logger::trace!(payload_len = payload.len(), "Building TlsRecord from Bytes");
@@ -48,8 +61,24 @@ impl TlsRecord {
); );
record.serialize() record.serialize()
} }
/// Готовые байты фиктивной записи `ChangeCipherSpec` (`14 03 03 00 01 01`) —
/// ровно то, что шлёт настоящий Chrome/Firefox сразу после своего Hello
/// (см. doc на [`ContentType::ChangeCipherSpec`]).
pub fn build_change_cipher_spec() -> Bytes {
let record = Self::new(
ContentType::ChangeCipherSpec,
ProtocolVersion::Tls12,
Bytes::from_static(&[0x01]),
);
record.serialize()
}
} }
/// Разбор записи. `can_parse` проверяет валидность типа и наличие всех байт
/// (для `ApplicationData` дополнительно требует ≥17 байт — минимум под AEAD-тег
/// и непустой шифртекст). Ошибка типа/версии → [`ErrorAction::Redirect`]: это
/// похоже не на наш трафик, поэтому проксируем как обычный TLS, а не рвём.
impl Parser for TlsRecord { impl Parser for TlsRecord {
type Error = TlsError; type Error = TlsError;
@@ -61,7 +90,8 @@ impl Parser for TlsRecord {
let content_type = bytes[0]; let content_type = bytes[0];
let is_valid_type = content_type == ContentType::Handshake as u8 let is_valid_type = content_type == ContentType::Handshake as u8
|| content_type == ContentType::ApplicationData as u8 || content_type == ContentType::ApplicationData as u8
|| content_type == ContentType::Alert as u8; || content_type == ContentType::Alert as u8
|| content_type == ContentType::ChangeCipherSpec as u8;
if !is_valid_type { if !is_valid_type {
return false; return false;
@@ -99,6 +129,10 @@ impl Parser for TlsRecord {
} }
} }
/// «Сырой» payload записи `ApplicationData` без повторной валидации.
///
/// Используется в горячем пути: тип записи уже проверен выше, и кодеку нужен
/// только зашифрованный кадр. Парсер просто забирает весь буфер целиком.
pub struct ApplicationData { pub struct ApplicationData {
pub _len: usize, pub _len: usize,
pub payload: Bytes, pub payload: Bytes,
@@ -120,3 +154,80 @@ impl Parser for ApplicationData {
Ok(Some(Self { _len, payload })) Ok(Some(Self { _len, payload }))
} }
} }
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn application_data_round_trip() {
let payload = Bytes::from_static(b"hello ciphertext + 16-byte tag!!");
let wire = TlsRecord::build_application_data(payload.clone());
let mut buf = BytesMut::from(&wire[..]);
let record = TlsRecord::parse(&mut buf).unwrap().unwrap();
assert_eq!(record.content_type, ContentType::ApplicationData);
assert_eq!(record.payload, payload);
assert!(buf.is_empty(), "parse must consume exactly one record");
}
#[test]
fn change_cipher_spec_exact_bytes() {
// Реальный Chrome/Firefox шлют ровно эти 6 байт — если когда-нибудь
// поменяются, это должно быть осознанное решение, а не регрессия.
let wire = TlsRecord::build_change_cipher_spec();
assert_eq!(&wire[..], &[0x14, 0x03, 0x03, 0x00, 0x01, 0x01]);
}
#[test]
fn change_cipher_spec_round_trip() {
let wire = TlsRecord::build_change_cipher_spec();
let mut buf = BytesMut::from(&wire[..]);
let record = TlsRecord::parse(&mut buf).unwrap().unwrap();
assert_eq!(record.content_type, ContentType::ChangeCipherSpec);
assert_eq!(&record.payload[..], &[0x01]);
}
#[test]
fn incomplete_record_is_none_not_error() {
let wire = TlsRecord::build_application_data(Bytes::from_static(b"0123456789abcdef+"));
// Отрезаем последний байт — запись объявляет себя длиннее, чем есть на самом деле.
let mut buf = BytesMut::from(&wire[..wire.len() - 1]);
assert!(TlsRecord::parse(&mut buf).unwrap().is_none());
// Буфер не должен быть тронут при "рано".
assert_eq!(buf.len(), wire.len() - 1);
}
#[test]
fn application_data_below_min_len_is_none() {
// < 17 байт payload у ApplicationData — заведомо меньше AEAD-тега,
// can_parse должен отказать ещё до попытки распознать content-type.
let mut buf = BytesMut::new();
buf.extend_from_slice(&[ContentType::ApplicationData as u8, 0x03, 0x03, 0x00, 0x05]);
buf.extend_from_slice(&[0u8; 5]);
assert!(!TlsRecord::can_parse(&buf));
}
#[test]
fn unknown_content_type_is_rejected() {
let mut buf = BytesMut::new();
buf.extend_from_slice(&[0x99, 0x03, 0x03, 0x00, 0x01, 0x00]);
assert!(!TlsRecord::can_parse(&buf));
}
#[test]
fn raw_application_data_parser_takes_whole_buffer() {
let mut buf = BytesMut::from(&b"anything at all"[..]);
let parsed = ApplicationData::parse(&mut buf).unwrap().unwrap();
assert_eq!(&parsed.payload[..], &b"anything at all"[..]);
assert!(buf.is_empty());
}
#[test]
fn raw_application_data_parser_empty_is_none() {
let mut buf = BytesMut::new();
assert!(ApplicationData::parse(&mut buf).unwrap().is_none());
}
}
+106
View File
@@ -1,6 +1,24 @@
//! Wire-константы и типы TLS, нужные для маскировки.
//!
//! Все числовые значения здесь — части формата TLS «на проводе» (RFC 8446 и
//! реестр IANA tls-extensiontype-values). Менять их нельзя: от точного совпадения
//! зависит JA3/JA4-отпечаток. Группировка по смыслу:
//! - базовый каркас записи/хендшейка: [`ContentType`], [`ProtocolVersion`], [`HelloType`];
//! - наборы для отпечатка: [`TlsGroups`], [`TlsSignatures`], [`TlsVersions`],
//! [`TlsExtensions`], [`ExtensionOrder`].
/// Тип TLS-записи (первый байт на проводе). Мы используем четыре из них:
/// `Handshake` для hello-сообщений, `ApplicationData` для кадров NRXP,
/// `Alert` распознаём для совместимости, `ChangeCipherSpec` — фиктивная запись
/// middlebox-совместимости TLS 1.3 (RFC 8446 Appendix D.4): настоящие браузеры
/// шлют её сразу после своего Hello, и её отсутствие в последовательности
/// типов TLS-записей само по себе отличает нестандартный TLS-стек от
/// браузерного трафика — криптографически в TLS 1.3 она ничего не значит.
#[repr(u8)] #[repr(u8)]
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum ContentType { pub(crate) enum ContentType {
ChangeCipherSpec = 0x14,
Handshake = 0x16, Handshake = 0x16,
ApplicationData = 0x17, ApplicationData = 0x17,
@@ -13,6 +31,7 @@ impl TryFrom<u8> for ContentType {
fn try_from(value: u8) -> Result<Self, Self::Error> { fn try_from(value: u8) -> Result<Self, Self::Error> {
match value { match value {
0x14 => Ok(ContentType::ChangeCipherSpec),
0x16 => Ok(ContentType::Handshake), 0x16 => Ok(ContentType::Handshake),
0x17 => Ok(ContentType::ApplicationData), 0x17 => Ok(ContentType::ApplicationData),
0x15 => Ok(ContentType::Alert), 0x15 => Ok(ContentType::Alert),
@@ -21,6 +40,9 @@ impl TryFrom<u8> for ContentType {
} }
} }
/// Версия TLS на проводе. Заметьте «маскировочный» нюанс: реальный TLS 1.3
/// притворяется 1.2 в поле версии записи (`0x0303`), а настоящая версия едет в
/// расширении `supported_versions` — ровно как делают браузеры.
#[repr(u16)] #[repr(u16)]
#[derive(Copy, Clone, Debug)] #[derive(Copy, Clone, Debug)]
pub(crate) enum ProtocolVersion { pub(crate) enum ProtocolVersion {
@@ -44,6 +66,8 @@ impl TryFrom<u16> for ProtocolVersion {
} }
} }
/// Тип handshake-сообщения: `ClientHello` (`0x01`) или `ServerHello` (`0x02`) —
/// первый байт тела `Handshake`-записи.
#[derive(Copy, Clone, Debug, PartialEq)] #[derive(Copy, Clone, Debug, PartialEq)]
pub(crate) enum HelloType { pub(crate) enum HelloType {
Client = 0x01, Client = 0x01,
@@ -63,6 +87,10 @@ impl TryFrom<u8> for HelloType {
} }
} }
/// Поддерживаемые ECDH-группы (расширение `supported_groups`). Обёртка над
/// статическим срезом, чтобы профили могли ссылаться на готовые наборы
/// ([`CHROMIUM`](TlsGroups::CHROMIUM)/[`MODERN`](TlsGroups::MODERN)) без аллокаций.
/// На практике обмен идёт по `X25519` — остальные перечислены для правдоподобия.
#[derive(Clone, Copy)] #[derive(Clone, Copy)]
pub(crate) struct TlsGroups(pub &'static [u16]); pub(crate) struct TlsGroups(pub &'static [u16]);
@@ -70,12 +98,27 @@ impl TlsGroups {
pub const X25519: u16 = 0x001d; pub const X25519: u16 = 0x001d;
pub const SECP256R1: u16 = 0x0017; pub const SECP256R1: u16 = 0x0017;
pub const SECP384R1: u16 = 0x0018; pub const SECP384R1: u16 = 0x0018;
pub const SECP521R1: u16 = 0x0019;
pub const CHROMIUM: Self = Self(&[Self::X25519, Self::SECP256R1, Self::SECP384R1]); pub const CHROMIUM: Self = Self(&[Self::X25519, Self::SECP256R1, Self::SECP384R1]);
pub const MODERN: Self = Self(&[Self::X25519, Self::SECP256R1]); pub const MODERN: Self = Self(&[Self::X25519, Self::SECP256R1]);
/// Firefox рекламирует более широкий список групп, включая `secp521r1`.
pub const FIREFOX: Self = Self(&[
Self::X25519,
Self::SECP256R1,
Self::SECP384R1,
Self::SECP521R1,
]);
/// Safari — тот же набор, что и Chromium.
pub const SAFARI: Self = Self(&[Self::X25519, Self::SECP256R1, Self::SECP384R1]);
} }
/// Алгоритмы подписи (`signature_algorithms`). Для нас это «декорация» отпечатка:
/// сертификаты мы не проверяем, но список и его порядок должны совпадать с
/// браузером ([`BROWSER_STANDARD`](TlsSignatures::BROWSER_STANDARD)).
#[derive(Clone, Copy)] #[derive(Clone, Copy)]
pub(crate) struct TlsSignatures(pub &'static [u16]); pub(crate) struct TlsSignatures(pub &'static [u16]);
@@ -99,6 +142,9 @@ impl TlsSignatures {
]); ]);
} }
/// Версии для расширения `supported_versions`. Профиль решает, рекламировать
/// только 1.3 ([`TLS_13_ONLY`](TlsVersions::TLS_13_ONLY), как Chrome) или 1.3+1.2
/// ([`MODERN`](TlsVersions::MODERN), как Firefox).
#[derive(Clone, Copy)] #[derive(Clone, Copy)]
pub struct TlsVersions(pub &'static [u16]); pub struct TlsVersions(pub &'static [u16]);
@@ -109,6 +155,7 @@ impl TlsVersions {
pub const TLS_13_ONLY: Self = Self(&[Self::TLS_1_3]); pub const TLS_13_ONLY: Self = Self(&[Self::TLS_1_3]);
pub const MODERN: Self = Self(&[Self::TLS_1_3, Self::TLS_1_2]); pub const MODERN: Self = Self(&[Self::TLS_1_3, Self::TLS_1_2]);
/// Наибольшая версия из набора — кладётся в основное поле версии хендшейка.
pub fn max(&self) -> ProtocolVersion { pub fn max(&self) -> ProtocolVersion {
if self.0.contains(&Self::TLS_1_3) { if self.0.contains(&Self::TLS_1_3) {
ProtocolVersion::Tls13 ProtocolVersion::Tls13
@@ -120,6 +167,9 @@ impl TlsVersions {
} }
} }
/// Идентификаторы TLS-расширений (реестр IANA) + детектор GREASE.
///
/// Используются как ключи при сборке/поиске расширений в [`extension`](super::extension).
pub struct TlsExtensions; pub struct TlsExtensions;
impl TlsExtensions { impl TlsExtensions {
@@ -141,6 +191,11 @@ impl TlsExtensions {
pub const ALPS: u16 = 0x44cd; pub const ALPS: u16 = 0x44cd;
pub const RENEGOTIATION_INFO: u16 = 0xff01; pub const RENEGOTIATION_INFO: u16 = 0xff01;
/// Является ли id GREASE-значением (RFC 8701).
///
/// GREASE-значения имеют вид `0x?a?a`, где оба байта равны (например `0x0a0a`,
/// `0x1a1a`). Браузеры на базе Chromium вставляют их, чтобы серверы не «костенели»
/// на конкретных значениях; для нас они — обязательная часть Chromium-отпечатка.
pub fn is_grease(id: u16) -> bool { pub fn is_grease(id: u16) -> bool {
if (id & 0x0f0f) != 0x0a0a { if (id & 0x0f0f) != 0x0a0a {
return false; return false;
@@ -150,6 +205,11 @@ impl TlsExtensions {
} }
} }
/// Точный порядок расширений в `ClientHello` — определяющий фактор JA3/JA4.
///
/// Хранится как статический срез id и перебирается [`ExtensionBuilder`] при
/// сборке. Константы ниже скопированы из реальных захватов соответствующих
/// браузеров; первые/последние элементы — GREASE-значения.
#[derive(Clone, Copy)] #[derive(Clone, Copy)]
pub struct ExtensionOrder(pub &'static [u16]); pub struct ExtensionOrder(pub &'static [u16]);
@@ -163,6 +223,12 @@ impl<'a> IntoIterator for &'a ExtensionOrder {
} }
impl ExtensionOrder { impl ExtensionOrder {
/// `PADDING` — обязательно последним: у реального Chrome паддинг всегда
/// замыкает список расширений (раньше он был пропущен в этом списке, из-за
/// чего `BrowserProfile::CHROME_131.target_padding_len` никогда не
/// применялся — `ExtensionBuilder::apply_profile` вызывает
/// [`padding`](super::extension::ExtensionBuilder::padding) только для id,
/// присутствующего в порядке, а его тут не было).
pub const CHROMIUM_131: Self = Self(&[ pub const CHROMIUM_131: Self = Self(&[
0xaaaa, 0xaaaa,
TlsExtensions::SNI, TlsExtensions::SNI,
@@ -180,8 +246,11 @@ impl ExtensionOrder {
TlsExtensions::COMPRESS_CERT, TlsExtensions::COMPRESS_CERT,
TlsExtensions::SCT, TlsExtensions::SCT,
TlsExtensions::DELEGATED_CREDENTIAL, TlsExtensions::DELEGATED_CREDENTIAL,
TlsExtensions::PADDING,
]); ]);
/// Edge — тот же Chromium-движок, порядок идентичен Chrome с поправкой на
/// собственные GREASE-значения (`0x1a1a`/`0x3a3a` вместо `0xaaaa`).
pub const EDGE_130: Self = Self(&[ pub const EDGE_130: Self = Self(&[
0x1a1a, 0x1a1a,
TlsExtensions::SNI, TlsExtensions::SNI,
@@ -202,4 +271,41 @@ impl ExtensionOrder {
TlsExtensions::PADDING, TlsExtensions::PADDING,
0x3a3a, 0x3a3a,
]); ]);
/// Firefox: своя собственная последовательность (не Chromium-семейство) —
/// без GREASE, без ALPS/`compress_certificate`, `renegotiation_info` рано в
/// списке. Раньше эта роль по ошибке была отдана `EDGE_130` (Chromium-порядок
/// с ALPS/compress_certificate, которых у Firefox не существует в принципе).
pub const FIREFOX_133: Self = Self(&[
TlsExtensions::SNI,
TlsExtensions::EMS,
TlsExtensions::RENEGOTIATION_INFO,
TlsExtensions::SUPPORTED_GROUPS,
TlsExtensions::EC_POINT_FORMATS,
TlsExtensions::SESSION_TICKET,
TlsExtensions::ALPN,
TlsExtensions::STATUS_REQUEST,
TlsExtensions::DELEGATED_CREDENTIAL,
TlsExtensions::KEY_SHARE,
TlsExtensions::SUPPORTED_VERSIONS,
TlsExtensions::SIGNATURE_ALGORITHMS,
TlsExtensions::PSK_MODES,
]);
/// Safari: не Chromium-семейство — без GREASE и без ALPS.
pub const SAFARI_17: Self = Self(&[
TlsExtensions::SNI,
TlsExtensions::EMS,
TlsExtensions::RENEGOTIATION_INFO,
TlsExtensions::SUPPORTED_GROUPS,
TlsExtensions::EC_POINT_FORMATS,
TlsExtensions::ALPN,
TlsExtensions::STATUS_REQUEST,
TlsExtensions::SIGNATURE_ALGORITHMS,
TlsExtensions::SCT,
TlsExtensions::KEY_SHARE,
TlsExtensions::PSK_MODES,
TlsExtensions::SUPPORTED_VERSIONS,
TlsExtensions::COMPRESS_CERT,
]);
} }
+5
View File
@@ -1 +1,6 @@
//! Мелкие утилиты крейта, не относящиеся ни к одному из основных блоков.
//!
//! Сейчас здесь только [`u24`] — поддержка 24-битных целых, которые встречаются в
//! заголовках TLS-хендшейка (длина handshake-сообщения).
pub mod u24; pub mod u24;
+13
View File
@@ -1,24 +1,37 @@
//! 24-битное беззнаковое целое (3 байта, big-endian).
//!
//! TLS хранит длину handshake-сообщения в 3 байтах — ни `u16`, ни `u32` ровно
//! не подходят. [`U24`] инкапсулирует это представление, а трейт-расширение
//! [`BufExt`] добавляет к любому [`bytes::Buf`] удобный метод
//! [`get_u24`](BufExt::get_u24) для чтения такого поля из потока.
use bytes::Buf; use bytes::Buf;
/// 24-битное целое как три big-endian байта.
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct U24([u8; 3]); pub struct U24([u8; 3]);
impl U24 { impl U24 {
/// Берёт младшие 3 байта из `u32` (старший байт отбрасывается).
pub fn from_u32(value: u32) -> Self { pub fn from_u32(value: u32) -> Self {
let b = value.to_be_bytes(); let b = value.to_be_bytes();
U24([b[1], b[2], b[3]]) U24([b[1], b[2], b[3]])
} }
/// Расширяет до `u32`, дополняя нулём старший байт. (Пока не используется.)
pub fn _to_u32(&self) -> u32 { pub fn _to_u32(&self) -> u32 {
u32::from_be_bytes([0, self.0[0], self.0[1], self.0[2]]) u32::from_be_bytes([0, self.0[0], self.0[1], self.0[2]])
} }
/// Читает 24-битное значение из первых трёх байт среза. (Пока не используется.)
pub fn _from_slice(slice: &[u8]) -> u32 { pub fn _from_slice(slice: &[u8]) -> u32 {
u32::from_be_bytes([0, slice[0], slice[1], slice[2]]) u32::from_be_bytes([0, slice[0], slice[1], slice[2]])
} }
} }
/// Расширение [`Buf`]: чтение 24-битного поля из потока байт.
pub trait BufExt: Buf { pub trait BufExt: Buf {
/// Считывает 3 байта big-endian и возвращает их как `u32`.
fn get_u24(&mut self) -> u32 { fn get_u24(&mut self) -> u32 {
let b1 = self.get_u8() as u32; let b1 = self.get_u8() as u32;
let b2 = self.get_u8() as u32; let b2 = self.get_u8() as u32;
-71
View File
@@ -1,71 +0,0 @@
# Стабилизация Netrunner: устранение «эффекта домино»
Краткая выжимка по правкам. Подробности — в коде (комментарии на местах).
## 1. Почему падал один leg и закрывались все стримы
Цепочка до правок:
1. `engine.rs::handle_outbound` имел **жёсткий** таймаут записи `20s`. При RTT > 2.5 с
физический сокет не успевал «слить» данные → таймаут → `TunnelWriteStuck`
writer-задача возвращает `Err` → весь `TunnelEngine::run()` выходит с `Err`,
leg целиком умирает.
2. `muxer.rs::send_to_network` (ветка данных): при первой же неудаче
`data_tx.send()` (канал мёртвого leg закрыт) — сразу `upload_failed`,
`remove_leg`, и **возврат `Err`**.
3. `bridge.rs::run_tcp_bridge` (upload): на `Err` от `send_data_safe` делал
`break``token.cancel()` → закрывался **весь мост, т.е. стрим**.
Так как `select_leg` использует sticky-привязки, на упавшем leg висели десятки
стримов. Они все одновременно упирались в закрытый канал и **разом** убивали свои
мосты — это и есть домино (`upload_failed` для 81, 79, 107 …).
## 2. Что изменено
### A. Адаптивный таймаут записи (вместо жёстких 20 с)
`muxer.rs::adaptive_write_timeout(floor)` — масштабирует таймаут как `~8 × RTT`,
с полом `floor` и потолком `60 с`. Применён:
- `engine.rs::handle_outbound` (запись в туннель), пол = 20 с;
- `bridge.rs` download (запись в локальный сокет), пол = `BRIDGE_STREAM_WRITE_TIMEOUT`.
Эффект: «медленный, но живой» leg при высоком RTT больше не убивается по таймауту.
### B. Failover вместо смерти стрима (`muxer.rs::send_to_network`)
Ветка данных теперь цикл:
- неудача `data_tx.send()``message` восстанавливается из `SendError`,
мёртвый leg эвиктится (`remove_leg`), стрим переселяется на следующий
лучший leg и **повторяет отправку**;
- `Err` возвращается **только если живых leg не осталось вовсе**.
Цикл конечен: `remove_leg` убирает leg из кэша, `select_leg` не вернёт его снова.
### C. Очистка привязок при эвикте (`muxer.rs::clear_bindings_for_leg`)
`remove_leg` / `force_remove_leg` теперь сбрасывают `stream_bindings` упавшего
leg → остальные стримы при следующей отправке честно ребалансируются, а не
долбятся в мёртвый leg.
### D. Graceful pause / buffer (`bridge.rs::run_tcp_bridge`, upload)
Если `send_data_safe` всё-таки вернул `Err` (все leg одновременно лежат), мост
**не закрывается**. Текущий чанк удерживается, идёт ретрай раз в
`STREAM_PAUSE_RETRY` (250 мс) в пределах `STREAM_PAUSE_BUDGET` (30 с). Пока мы не
читаем дальше — работает TCP backpressure, источник сам приостанавливается, данные
не теряются. Стрим закрывается только если за бюджет ни один leg не поднялся или
пришёл `token.cancel()`.
## 3. Новые константы (`constants.rs`)
- `STREAM_PAUSE_BUDGET = 30s` — сколько стрим ждёт восстановления leg.
- `STREAM_PAUSE_RETRY = 250ms` — период опроса во время паузы.
## 4. Итог
Сбой одного leg теперь = бесшовный перенос стримов на соседние leg; полный отказ
всех leg = короткая пауза с буфером, а не каскадный сброс. `leg_reconnecting`
перестаёт быть «лавиной».
## 5. Проверка
Сборку из-под агента запустить не удалось (апстрим-фильтр харнесса блокирует
вызов cargo). Запустите вручную:
```
cargo check -p netrunner-core
cargo test -p netrunner-core
```
-193
View File
@@ -1,193 +0,0 @@
# Производительность: zero-copy / batching / lock-free — разбор и план
> TL;DR. Кодовая база **уже** zero-copy и lock-free на горячем пути. Массовых
> `to_vec()` в обработке пакетов нет. Реально лишних копий было две, обе вне
> muxer. Одну (UDP) убрал сразу; вторая (TUN-ридер) требует смены типа канала и
> сборки/бенча — ниже готовый дифф. `recvmmsg/sendmmsg` к TCP-туннелю
> **неприменимы** (это байт-поток, а не датаграммы) — объяснение в §2.
Сборку из-под агента запустить нельзя (апстрим-фильтр харнесса блокирует cargo),
поэтому в код внесено только то, что верифицируется чтением; остальное — диффами.
---
## Схема потока данных (как есть)
```
APP ─(TUN)→ [tun_reader] ─Vec<u8>→ engine ─push_rx→ smoltcp ─recv_slice→
ConnectionTask ─Bytes→ muxer.send_data_safe ─Bytes→ data_tx(mpsc) ─→
TunnelEngine.writer ─TxCodec.encode(in-place AEAD)→ TCP leg ──┐
SERVER ── RxCodec.decode(in-place AEAD) ── StreamHandler ── bridge ── INTERNET
```
Owность данных уже передаётся как `Bytes` от smoltcp до сокета: `MuxMessage`
держит `Bytes`, кодек шифрует **in-place** в `BytesMut` и в конце делает
`freeze()` (zero-copy), парсер отдаёт payload через `split_to().freeze()`.
muxer **ничего не сериализует** — он только перемещает `Bytes` между каналами;
«пробка» там была не в копированиях, а в каскадном закрытии стримов (исправлено в
[anti-domino-fix.md](anti-domino-fix.md)) и в жёстком write-timeout.
---
## §1. Zero-copy (Data Flow)
### Что уже сделано (доказательства)
- `MuxMessage { data: Bytes }` — указатель, не массив.
- `nrxp/codec.rs`: `encode_frame` шифрует in-place, `freeze()` в конце; `decode_inbound`
декрипт in-place в `staging` (`split_off`/`unsplit`, без доп. аллокаций).
- `nrxp/frame.rs`: `Frame::parse``bytes.split_to(p_len).freeze()` (передача
владения), паддинг просто пропускается `advance()` — без аллокаций.
- `connection/bridge.rs` (TCP): `buf.split().freeze()` — ownership transfer.
- Заголовок кадра собирается на стеке и пишется одним `copy_from_slice` (25 байт).
### Что исправлено сейчас
- **UDP bridge (`core/src/net/connection/bridge.rs`)**: был
`vec![0u8;N]` + `Bytes::copy_from_slice(&buf[..n])` — **полная копия каждой
датаграммы**. Заменено на `socket.recv_buf(&mut BytesMut)` + `split().freeze()`.
Теперь датаграмма уходит в muxer без memcpy. Эффект: −1 копия и −1 memset на
каждый UDP-пакет (DNS/QUIC/игры/VoIP — самый частотный мелкий трафик).
### Что НЕ трогаю и почему
- `connection.rs:208` (`peek_slice``copy_from_slice`) и `:395/:471` — это граница
**smoltcp**: его кольцевой буфер нельзя забрать во владение, копия из ring → наш
буфер неизбежна. Замена на `recv_slice` в `BytesMut` уберёт лишь второй memset,
но потребует `unsafe set_len`; выигрыш на уровне шума. Оставлено.
- Копии в `crypto/`, `tlseng/handshake.rs`, `nrxp/bridge.rs` — это рукопожатие
(один раз на коннект, холодный путь). Не горячо.
### Готовый дифф: TUN-ридер (убрать `to_vec()` на каждый пакет)
`client/src/net/engine.rs:407``let pkt = buf[..n].to_vec();` копирует **каждый**
пакет от приложения. Канал сейчас `mpsc::channel::<Vec<u8>>`. smoltcp `RxToken`
требует `&mut [u8]`, поэтому переходить надо на **`BytesMut`** (не `Bytes`):
1. `tun_to_engine`: `mpsc::channel::<Vec<u8>>``mpsc::channel::<BytesMut>`
(поля `tun_rx`/`tun_tx`/типы в `EngineMetrics`-хелперах — обновить).
2. `spawn_tun_reader`:
```rust
let mut buf = BytesMut::with_capacity(TUN_READ_BUF_SIZE * 8);
loop {
if buf.capacity() - buf.len() < TUN_READ_BUF_SIZE { buf.reserve(TUN_READ_BUF_SIZE * 8); }
match reader.read_buf(&mut buf).await { // читает в spare capacity, без memset
Ok(n) if n > 0 => {
let pkt = buf.split(); // BytesMut, ownership transfer, no copy
if to_engine.send(pkt).await.is_err() { break; }
}
...
}
}
```
(т.е. `let pkt = buf.split();` — отдаём `BytesMut`, copy нет.)
3. `device.push_rx(pkt: BytesMut)` и внутреннее хранилище `VecDeque<Vec<u8>>` →
`VecDeque<BytesMut>`; в `RxToken::consume` отдавать `&mut pkt[..]`.
4. `try_create_socket_from_packet(&pkt[..], ...)` — добавить `[..]` (Bytes/Mut→&[u8]).
Это 1 copy и −1 memset на каждый upload-пакет. Блокирует только то, что меняет
сигнатуры в 3 файлах → нужен `cargo build -p netrunner-client` + прогон трафика.
---
## §2. Syscall batching (recvmmsg/sendmmsg) — честная оценка
- **TCP-ноги туннеля — это байт-поток.** `recvmmsg/sendmmsg` работают только с
датаграммными сокетами (одно сообщение = одна датаграмма). К `OwnedReadHalf`/
`OwnedWriteHalf` они неприменимы в принципе. Там батчинг уже сделан «на уровне
фрейминга»: один `read_buf` забирает всё, что есть в сокете, за **один** syscall,
а затем декодируется *несколько* NRXP-кадров из буфера. Это и есть правильный
батч для потока.
- **UDP/TUN** — теоретически `recvmmsg` применим, НО: (а) это Linux-only, проект
собирается в т.ч. под Windows/Android; (б) `tokio` не предоставляет `recvmmsg`,
пришлось бы тащить `AsyncFd` + `libc`/`nix` с ручным управлением готовностью fd —
это много `unsafe`, потеря переносимости и заметный риск регрессий ради выигрыша
на пути, который уже не блокирует CPU. **Не оправдано.**
### Что реально даёт батчинг syscalls здесь — коалесинг записи (TCP) ✅ внесено
`engine.rs::handle_outbound` раньше слал `for pkt in packets { write_all(pkt) }` —
**N syscall'ов** на большое сообщение (по кадру 16 КБ). Теперь:
```rust
if packets.len() == 1 {
outbound.write_all(&packets[0]).await // fast path: zero-copy, без аллокаций
} else if !packets.is_empty() {
let mut batch = BytesMut::with_capacity(total);
for pkt in &packets { batch.extend_from_slice(pkt); }
outbound.write_all(&batch).await // N кадров → ОДИН write()
}
```
Прим.: честный аналог sendmmsg для TCP — это `writev` без копий, но в `tokio`
нет `write_all_vectored` (только `write_vectored` с ручной обработкой частичных
записей через `IoSlice::advance_slices` — слишком хрупко без бенча). Поэтому
выбран надёжный вариант: один `write_all` по склеенному буферу. Цена — одна
memcpy ciphertext при >1 кадре (одиночный кадр идёт zero-copy). Под высоким RTT
экономия на syscalls перекрывает эту копию.
### Адаптивный размер батча по RTT ✅ внесено
`muxer::adaptive_batch_chunk(base)` масштабирует размер interleave-чанка writer'а:
`factor = clamp(1 + rtt_ms/250, 1, 4)`, `chunk = base * factor` (base =
`TUNNEL_INTERLEAVE_CHUNK`, 16 КБ). При RTT ≤250 мс — 16 КБ (минимум задержки,
честное чередование стримов); при высоком RTT — до 64 КБ за проход, и эти 4 кадра
коалесятся в один `write()` (см. #3). Считается на каждый чанк → подхватывает
текущий RTT. Применено в `engine.rs` writer.
---
## §3. Конкуренция (locks / actors / backpressure)
### Аудит блокировок (факт)
- `core`: единственный hot-path лок — `RwLock<Arc<Vec<MuxLeg>>>` (кэш ног в muxer).
Читатели друг друга **не блокируют**, чтение = взять read-guard + бамп Arc. Всё
остальное — `DashMap` (`legs`, `streams`, `stream_bindings`, `pending_pings`,
`pending_connects`) — уже lock-free-шардировано.
- `Mutex<VecDeque>` в `diagnostics.rs` — холодный путь (снапшоты раз в событие). ОК.
- **Модель акторов уже внедрена**: каждая нога — две задачи (reader/writer),
общение через `tokio::mpsc`. Стримы — отдельные задачи-мосты. Это и есть акторы.
### Backpressure (уже есть, не «зависания»)
- Данные: `data_tx.send().await` (bounded) — backpressure до TCP-сокета ядра.
- Контрол: критичные (`Close`/`Heartbeat`) — `send().await`; некритичные —
`try_send`, при переполнении **дропаются** с `ControlChannelFull` (сигнал вверх),
а не блокируют. Это ровно запрошенное «дропать/сигналить, не стоять».
- Upload в smoltcp-мостах: `try_send` + флаг `tx_congested` → пауза чтения из
браузера (TCP backpressure), без stalls.
### Lock-free кэш ног через `arc-swap` ✅ внесено
Кэш ног переведён с `RwLock<Arc<Vec<MuxLeg>>>` на `arc_swap::ArcSwap<Vec<MuxLeg>>`:
```toml
# core/Cargo.toml
arc-swap = "1"
```
```rust
// muxer.rs
active_legs_cache: Arc<ArcSwap<Vec<MuxLeg>>>,
// read: self.active_legs_cache.load_full() // вместо .read().unwrap().clone()
// write: self.active_legs_cache.store(Arc::new(new_cache))
```
Чтение горячего пути (`select_leg`, snapshot, topology) теперь без read-guard —
атомарный bump Arc. **Внимание:** добавлена зависимость `arc-swap` → при первой
сборке `cargo` её скачает (реестр crates.io доступен — tokio тянется оттуда же).
---
## Итог: что сделано vs что в плане
| # | Изменение | Статус |
|---|-----------|--------|
| 1 | UDP bridge: `recv_buf`+`split().freeze()` (−copy/датаграмму) | ✅ внесено |
| 2 | TUN-ридер: канал `Vec<u8>`→`BytesMut` | ❌ неприменимо** |
| 3 | TCP writer: коалесинг кадров в один `write_all` (sendmmsg-аналог) | ✅ внесено |
| 4 | Адаптивный размер батча по `GLOBAL_MIN_RTT` (`adaptive_batch_chunk`) | ✅ внесено |
| 5 | `arc-swap` для кэша ног (lock-free read) | ✅ внесено |
| | recvmmsg/sendmmsg на TCP | ❌ неприменимо (§2) |
> ** **Корректировка к §1.** При попытке применить #2 выяснилось, что потребитель
> пакетов — `smoltcp::phy::ChannelDevice` из **внешнего форка smoltcp** (git-зависимость),
> а его `push_rx(Vec<u8>)`/`pop_tx()->Vec<u8>` менять нельзя. Перевод канала на
> `BytesMut` упёрся бы в копию `BytesMut→Vec<u8>` на границе устройства — это
> сводит на нет весь смысл. Поэтому #2 переходит в разряд неприменимых (как и
> recvmmsg): без форка `ChannelDevice` под `BytesMut` чистого zero-copy не выйдет.
> `to_vec()` в `spawn_tun_reader` остаётся вынужденным.
Проверка после применения диффов:
```
cargo build -p netrunner-core -p netrunner-client
cargo test -p netrunner-core
```
+14
View File
@@ -0,0 +1,14 @@
[
{
"name": "nl-1",
"host": "147.45.43.70",
"proxy_port": 443,
"decoy_host": "ubuntu.com"
},
{
"name": "fr-1",
"host": "77.110.106.113",
"proxy_port": 443,
"decoy_host": "www.cloudflare.com"
}
]
+33
View File
@@ -0,0 +1,33 @@
#!/usr/bin/env node
// Генерит targets-файл для Prometheus file_sd_configs (см.
// netrunner-data/observability/prometheus.yml, job "netrunner-proxy-nodes")
// из nodes.json этого репозитория — список нод меняется независимо от
// прод-конфига Prometheus, руками синхронизировать его на каждый деплой
// ноды неудобно и хрупко.
//
// Порт метрик у всех нод одинаковый (9093 — см. server/netrunner-server.service,
// templates/init_node.sh в netrunner-backend, .gitea/workflows/deploy.yml
// здесь же), меняется только host — поэтому в самом nodes.json отдельного
// поля под него нет.
//
// Использование: node scripts/generate-prometheus-targets.mjs nodes.json > targets.json
import { readFileSync } from "node:fs";
const METRICS_PORT = 9093;
const [, , nodesPath] = process.argv;
if (!nodesPath) {
console.error("Использование: generate-prometheus-targets.mjs <nodes.json>");
process.exit(1);
}
const nodes = JSON.parse(readFileSync(nodesPath, "utf-8"));
const targets = nodes
.filter((n) => !n.host?.startsWith("CHANGE_ME"))
.map((n) => ({
targets: [`${n.host}:${METRICS_PORT}`],
labels: { node: n.name },
}));
process.stdout.write(JSON.stringify(targets, null, 2) + "\n");
+6
View File
@@ -11,3 +11,9 @@ tokio = { version = "1.36", features = ["full"] }
clap = { version = "4.4", features = ["derive"] } clap = { version = "4.4", features = ["derive"] }
tokio-util = { version = "0.7", features = ["rt"] } tokio-util = { version = "0.7", features = ["rt"] }
serde_json = "1" serde_json = "1"
serde = { version = "1", features = ["derive"] }
reqwest = { version = "0.13.2", features = ["json"] }
async-trait = "0.1.89"
dashmap = "6.1.0"
metrics = "0.24.3"
metrics-exporter-prometheus = { version = "0.18.1", default-features = false }
+34
View File
@@ -0,0 +1,34 @@
# Крейт `server` — серверная часть прокси
Тонкая обвязка вокруг ядра ([`core`](../core)): принимает замаскированные
TLS-соединения, проверяет, что это валидный Netrunner-клиент, и проксирует трафик
во внешнюю сеть. Вся бизнес-логика — в ядре; здесь точка входа, листенер и
серверная диагностика.
> Детали — в rustdoc: `cargo doc --open -p netrunner-server`.
## Файлы
| Файл | Роль |
|--------------------|-----------------------------------------------------------------|
| `src/main.rs` | CLI (`--host`/`--port`), логгер, tokio-рантайм, запуск. |
| `src/network.rs` | TCP-листенер; на каждое соединение спавнит `ServerHandler`. |
| `src/diagnostics.rs`| Логгер диагностики ядра → `netrunner_diagnostics.jsonl`. |
## Жизненный цикл соединения (в ядре)
```text
accept TCP → core::ServerHandler::run:
1. ClientHello → ServerHello (невалидный/чужой → stealth-fallback на ubuntu.com:443)
2. проверка auth-payload (неверный → отказ)
3. прицепить ногу к Muxer сессии и крутить TunnelEngine → проксировать к целям
```
Один общий `SessionManager` мультиплексирует несколько ног одной сессии на один
`Muxer`. Фоновая задача периодически делает health-check и печатает топологию.
## Запуск и деплой
Локально: `make debug-server` (биндится на `0.0.0.0:8443`). Релиз/деплой:
`make build-server``make deploy-server` (systemd-сервис на VPS, см.
`server/netrunner-server.service` и `Makefile`).
+17
View File
@@ -0,0 +1,17 @@
[Unit]
Description=Netrunner Remote Proxy
After=network.target
[Service]
User=root
WorkingDirectory=/root/netr-core
ExecStart=/root/netr-core/netrunner-server --host=0.0.0.0 --port=8443 --health-port=9092 --metrics-port=9094
Restart=always
RestartSec=3
LimitNOFILE=65535
# systemctl stop/restart шлёт SIGTERM — теперь сервер ловит его сам и
# дожидается отключения активных клиентов вместо мгновенного обрыва.
TimeoutStopSec=35
[Install]
WantedBy=multi-user.target
+5 -1
View File
@@ -5,10 +5,14 @@ After=network.target
[Service] [Service]
User=root User=root
WorkingDirectory=/root/netr-core WorkingDirectory=/root/netr-core
ExecStart=/root/netr-core/netrunner-server --host=0.0.0.0 --port=443 ExecStart=/root/netr-core/netrunner-server --host=0.0.0.0 --port=443 --health-port=9091 --metrics-port=9093
Restart=always Restart=always
RestartSec=3 RestartSec=3
LimitNOFILE=65535 LimitNOFILE=65535
# systemctl stop/restart шлёт SIGTERM — теперь сервер ловит его сам и
# дожидается отключения активных клиентов (см. server/src/main.rs,
# server/src/network.rs) вместо мгновенного обрыва по TimeoutStopSec.
TimeoutStopSec=35
[Install] [Install]
WantedBy=multi-user.target WantedBy=multi-user.target
+267
View File
@@ -0,0 +1,267 @@
//! HTTP-клиент к control-plane бэкенду (`netrunner-backend`), реализующий
//! [`AuthValidator`] для ядра — только эта нода тянет `reqwest` и
//! `PROXY_INTERNAL_SECRET`, ядро видит лишь трейт (см. `core::net::auth`).
//!
//! Валидация токена кешируется на короткий TTL: одна сессия открывает
//! [`MAX_TUNNEL_LEGS`] ног, и без кеша каждая била бы бэкенд отдельным
//! запросом на один и тот же токен.
use async_trait::async_trait;
use dashmap::DashMap;
use netrunner_core::net::{AuthValidator, UsageReport, UserQuota};
use netrunner_logger::{warn, AppError};
use serde::{Deserialize, Serialize};
use std::sync::Mutex;
use std::time::{Duration, Instant};
const VALIDATE_CACHE_TTL: Duration = Duration::from_secs(60);
/// Таймаут одного HTTP-запроса к бэкенду. Без него `reqwest::Client` ждёт
/// ответ неограниченно долго на зависшем (не упавшем — именно зависшем)
/// бэкенде: каждая проверка токена на КАЖДОМ новом клиенте висела бы, блокируя
/// подключение новых пользователей, а не только тех, чей токен уже в кеше.
const REQUEST_TIMEOUT: Duration = Duration::from_secs(5);
/// После скольких подряд неудач (сетевая ошибка/таймаут/5xx — НЕ 4xx, отказ
/// по конкретному токену не значит, что бэкенд нездоров) размыкаем цепь.
const FAILURE_THRESHOLD: u32 = 5;
/// Сколько цепь остаётся разомкнутой (запросы отклоняются мгновенно, без
/// попытки реального HTTP-вызова и его таймаута) перед следующей пробой.
const CIRCUIT_OPEN_COOLDOWN: Duration = Duration::from_secs(10);
#[derive(Default)]
struct CircuitState {
consecutive_failures: u32,
open_until: Option<Instant>,
}
/// Простейший circuit breaker: без внешней библиотеки, состояние — один
/// `Mutex` с редкими короткими блокировками (проверка/запись пары полей,
/// не сам HTTP-вызов). Цель — не ждать `REQUEST_TIMEOUT` на каждом клиенте
/// подряд, если бэкенд уже несколько раз подряд не ответил, а быстро
/// отказывать, пока не пройдёт cooldown.
struct Circuit {
state: Mutex<CircuitState>,
}
impl Circuit {
fn new() -> Self {
Self {
state: Mutex::new(CircuitState::default()),
}
}
/// `true`, если цепь разомкнута прямо сейчас — вызывающий код должен
/// отказать быстро, не делая реальный HTTP-запрос.
fn is_open(&self) -> bool {
let state = self.state.lock().unwrap();
matches!(state.open_until, Some(until) if Instant::now() < until)
}
fn record_success(&self) {
let mut state = self.state.lock().unwrap();
state.consecutive_failures = 0;
state.open_until = None;
metrics::gauge!("netrunner_circuit_breaker_open").set(0.0);
}
/// Считать неудачей только сетевые ошибки/таймауты/5xx — HTTP 401/403 на
/// конкретный невалидный токен НЕ признак нездоровья бэкенда.
fn record_failure(&self) {
let mut state = self.state.lock().unwrap();
state.consecutive_failures += 1;
if state.consecutive_failures >= FAILURE_THRESHOLD {
state.open_until = Some(Instant::now() + CIRCUIT_OPEN_COOLDOWN);
metrics::gauge!("netrunner_circuit_breaker_open").set(1.0);
warn!(
failures = state.consecutive_failures,
"Circuit breaker разомкнут: бэкенд не отвечает {} раз подряд",
state.consecutive_failures
);
}
}
}
pub struct BackendClient {
http: reqwest::Client,
base_url: String,
internal_secret: String,
validate_cache: DashMap<String, (UserQuota, Instant)>,
circuit: Circuit,
}
impl BackendClient {
pub fn new(base_url: String, internal_secret: String) -> Self {
Self {
http: reqwest::Client::builder()
.timeout(REQUEST_TIMEOUT)
.build()
.expect("Failed to build reqwest client"),
base_url: base_url.trim_end_matches('/').to_string(),
internal_secret,
validate_cache: DashMap::new(),
circuit: Circuit::new(),
}
}
fn circuit_open_error() -> AppError {
AppError::new(
netrunner_logger::ERR_INFRA_TIMEOUT,
"Бэкенд недоступен",
"Circuit breaker open: backend недавно не отвечал несколько раз подряд, короткий отказ без повторной попытки",
)
}
}
#[derive(Serialize)]
struct ValidateRequest<'a> {
token: &'a str,
}
#[derive(Deserialize)]
struct ValidateResponse {
user_id: String,
limit_bytes: Option<u64>,
used_bytes: u64,
}
#[derive(Serialize)]
struct UsageRequest<'a> {
user_id: &'a str,
delta_bytes: u64,
}
#[derive(Deserialize)]
struct UsageResponse {
used_bytes: u64,
limit_bytes: Option<u64>,
over_limit: bool,
}
#[async_trait]
impl AuthValidator for BackendClient {
async fn validate(&self, token: &str) -> Result<UserQuota, AppError> {
if token.is_empty() {
metrics::counter!("netrunner_auth_failures_total").increment(1);
return Err(AppError::new(
netrunner_logger::ERR_AUTH_FAILED,
"Доступ запрещен",
"Empty auth token on a --require-auth instance",
));
}
if let Some(entry) = self.validate_cache.get(token) {
let (quota, cached_at) = entry.value();
if cached_at.elapsed() < VALIDATE_CACHE_TTL {
return Ok(quota.clone());
}
}
if self.circuit.is_open() {
return Err(Self::circuit_open_error());
}
let request_start = Instant::now();
let resp = self
.http
.post(format!("{}/api/v1/internal/validate", self.base_url))
.header("X-Internal-Secret", &self.internal_secret)
.json(&ValidateRequest { token })
.send()
.await
.map_err(|e| {
self.circuit.record_failure();
AppError::new(
netrunner_logger::ERR_INFRA_TIMEOUT,
"Бэкенд недоступен",
e.to_string(),
)
})?;
metrics::histogram!("netrunner_auth_validate_duration_seconds")
.record(request_start.elapsed().as_secs_f64());
// 5xx — признак нездоровья самого бэкенда, считается неудачей для
// circuit breaker'а. 4xx (например 401 на невалидный токен) — это
// ожидаемый легитимный ответ на конкретный запрос, не поломка бэкенда.
if resp.status().is_server_error() {
self.circuit.record_failure();
}
if !resp.status().is_success() {
metrics::counter!("netrunner_auth_failures_total").increment(1);
return Err(AppError::new(
netrunner_logger::ERR_AUTH_FAILED,
"Доступ запрещен",
format!("Backend rejected token: HTTP {}", resp.status()),
));
}
let body: ValidateResponse = resp.json().await.map_err(|e| {
AppError::new(
netrunner_logger::ERR_INFRA_TIMEOUT,
"Ошибка бэкенда",
e.to_string(),
)
})?;
self.circuit.record_success();
let quota = UserQuota {
user_id: body.user_id,
limit_bytes: body.limit_bytes,
used_bytes: body.used_bytes,
};
self.validate_cache
.insert(token.to_string(), (quota.clone(), Instant::now()));
Ok(quota)
}
async fn report_usage(&self, user_id: &str, delta_bytes: u64) -> Result<UsageReport, AppError> {
if self.circuit.is_open() {
return Err(Self::circuit_open_error());
}
let resp = self
.http
.post(format!("{}/api/v1/internal/usage", self.base_url))
.header("X-Internal-Secret", &self.internal_secret)
.json(&UsageRequest {
user_id,
delta_bytes,
})
.send()
.await
.map_err(|e| {
self.circuit.record_failure();
AppError::new(
netrunner_logger::ERR_INFRA_TIMEOUT,
"Бэкенд недоступен",
e.to_string(),
)
})?;
if resp.status().is_server_error() {
self.circuit.record_failure();
}
if !resp.status().is_success() {
return Err(AppError::new(
netrunner_logger::ERR_INFRA_TIMEOUT,
"Ошибка бэкенда",
format!("Usage report rejected: HTTP {}", resp.status()),
));
}
let body: UsageResponse = resp.json().await.map_err(|e| {
AppError::new(
netrunner_logger::ERR_INFRA_TIMEOUT,
"Ошибка бэкенда",
e.to_string(),
)
})?;
self.circuit.record_success();
Ok(UsageReport {
used_bytes: body.used_bytes,
limit_bytes: body.limit_bytes,
over_limit: body.over_limit,
})
}
}
+144 -24
View File
@@ -1,7 +1,19 @@
//! Серверный логгер диагностики: события ядра → JSONL-файл.
//!
//! Подписывается на канал диагностики ([`diagnostics::init_diagnostics`]) и
//! пишет снапшоты в `netrunner_diagnostics.jsonl` (по одной JSON-строке на
//! событие) с ротацией файла по размеру. Дополнительно пишет стартовый и
//! периодические heartbeat-снапшоты, чтобы файл всегда существовал и было видно,
//! что сервер жив. У сервера нет smoltcp-движка, поэтому socket-метрики пусты —
//! только метрики туннеля, но они реальные: логгер держит [`SessionManager`] и
//! на каждый снапшот опрашивает `Muxer` всех живых сессий (см.
//! [`ServerDiagnosticsLogger::snapshot_all_sessions`]).
use netrunner_core::net::diagnostics::{ use netrunner_core::net::diagnostics::{
self, DiagnosticsEvent, DiagnosticsSnapshot, DiagnosticsStore, TunnelMetrics, self, current_timestamp_ms, DiagnosticsEvent, DiagnosticsSnapshot, DiagnosticsStore,
current_timestamp_ms, TunnelMetrics,
}; };
use netrunner_core::net::SessionManager;
use netrunner_logger::{error, info, warn}; use netrunner_logger::{error, info, warn};
use std::{path::PathBuf, sync::Arc}; use std::{path::PathBuf, sync::Arc};
use tokio::{ use tokio::{
@@ -10,6 +22,10 @@ use tokio::{
time::{interval, Duration}, time::{interval, Duration},
}; };
/// Max bytes of a session id used when building a client-diagnostics filename.
/// Generated ids are 32 hex chars; the cap just bounds a hostile/oversized id.
const CLIENT_DIAG_SESSION_ID_MAX: usize = 64;
/// Maximum number of snapshots kept in the in-memory store on the server. /// Maximum number of snapshots kept in the in-memory store on the server.
const SERVER_MAX_SNAPSHOTS: usize = 100; const SERVER_MAX_SNAPSHOTS: usize = 100;
/// Rotate the log file when it exceeds this many bytes (~10 MB). /// Rotate the log file when it exceeds this many bytes (~10 MB).
@@ -17,18 +33,23 @@ const LOG_ROTATE_THRESHOLD: u64 = 10 * 1024 * 1024;
/// Write a periodic heartbeat snapshot every N seconds even without error events. /// Write a periodic heartbeat snapshot every N seconds even without error events.
const HEARTBEAT_INTERVAL_SECS: u64 = 60; const HEARTBEAT_INTERVAL_SECS: u64 = 60;
/// Логгер серверной диагностики: in-memory store последних снапшотов + файл.
pub struct ServerDiagnosticsLogger { pub struct ServerDiagnosticsLogger {
store: Arc<DiagnosticsStore>, store: Arc<DiagnosticsStore>,
log_path: PathBuf, log_path: PathBuf,
/// Общий реестр сессий сервера — источник правды для реальных метрик
/// туннеля (раньше их не было вовсе, снапшот всегда сообщал пустоту).
session_manager: Arc<SessionManager>,
} }
impl ServerDiagnosticsLogger { impl ServerDiagnosticsLogger {
pub fn new(log_dir: impl Into<PathBuf>) -> Self { pub fn new(log_dir: impl Into<PathBuf>, session_manager: Arc<SessionManager>) -> Self {
let mut path = log_dir.into(); let mut path = log_dir.into();
path.push("netrunner_diagnostics.jsonl"); path.push("netrunner_diagnostics.jsonl");
Self { Self {
store: Arc::new(DiagnosticsStore::new(SERVER_MAX_SNAPSHOTS)), store: Arc::new(DiagnosticsStore::new(SERVER_MAX_SNAPSHOTS)),
log_path: path, log_path: path,
session_manager,
} }
} }
@@ -47,10 +68,9 @@ impl ServerDiagnosticsLogger {
); );
// Write an immediate startup snapshot so the file is created on boot. // Write an immediate startup snapshot so the file is created on boot.
let startup = logger.build_server_snapshot(DiagnosticsEvent::LegReconnecting { let startup = logger
leg_id: 0, .build_server_snapshot(DiagnosticsEvent::Heartbeat)
attempt: 0, .await;
}).await;
logger.store.push(startup.clone()); logger.store.push(startup.clone());
logger.append_to_file(&startup).await; logger.append_to_file(&startup).await;
@@ -72,10 +92,9 @@ impl ServerDiagnosticsLogger {
_ = heartbeat.tick() => { _ = heartbeat.tick() => {
// Periodic heartbeat — lets operators confirm the server is // Periodic heartbeat — lets operators confirm the server is
// alive even when everything is working perfectly. // alive even when everything is working perfectly.
let snap = logger.build_server_snapshot(DiagnosticsEvent::LegReconnecting { let snap = logger
leg_id: 0, .build_server_snapshot(DiagnosticsEvent::Heartbeat)
attempt: 0, .await;
}).await;
logger.store.push(snap.clone()); logger.store.push(snap.clone());
logger.append_to_file(&snap).await; logger.append_to_file(&snap).await;
} }
@@ -90,25 +109,52 @@ impl ServerDiagnosticsLogger {
} }
/// Builds a server-side snapshot. The server has no smoltcp engine, so /// Builds a server-side snapshot. The server has no smoltcp engine, so
/// socket metrics are omitted; only tunnel metrics are included. /// socket metrics are omitted; tunnel metrics are real, aggregated across
/// every currently connected client session.
async fn build_server_snapshot(&self, trigger: DiagnosticsEvent) -> DiagnosticsSnapshot { async fn build_server_snapshot(&self, trigger: DiagnosticsEvent) -> DiagnosticsSnapshot {
DiagnosticsSnapshot { DiagnosticsSnapshot {
timestamp_ms: current_timestamp_ms(), timestamp_ms: current_timestamp_ms(),
trigger, trigger,
engine: None, engine: None,
// Tunnel metrics can be added later if the server gains access to tunnel: self.snapshot_all_sessions(),
// a specific Muxer. For now we report an empty structure.
tunnel: TunnelMetrics {
global_min_rtt_ms: netrunner_core::net::GLOBAL_MIN_RTT
.load(std::sync::atomic::Ordering::Relaxed),
active_legs: vec![],
total_streams: 0,
},
sockets: vec![], sockets: vec![],
error_totals: diagnostics::DIAG_COUNTERS.snapshot(), error_totals: diagnostics::DIAG_COUNTERS.snapshot(),
} }
} }
/// Опрашивает `Muxer` каждой живой сессии и сводит их в одну [`TunnelMetrics`]:
/// ноги всех сессий (каждая помечена своим `session_id` — иначе `leg_id`
/// разных клиентов совпадали бы, они нумеруются независимо 0..MAX_TUNNEL_LEGS)
/// и сумма потоков. `global_min_rtt_ms` остаётся процесс-глобальным значением
/// ([`GLOBAL_MIN_RTT`](netrunner_core::net::GLOBAL_MIN_RTT)) — это отдельное,
/// более глубокое ограничение (RTT не разведён по сессиям нигде в ядре), не
/// то же самое, что пустая заглушка активных ног/потоков, которую эта функция
/// заменяет.
fn snapshot_all_sessions(&self) -> TunnelMetrics {
let sessions: Vec<_> = self
.session_manager
.get_session()
.iter()
.map(|entry| entry.value().clone())
.collect();
let mut active_legs = Vec::new();
let mut total_streams = 0;
for muxer in &sessions {
let m = muxer.snapshot_tunnel_metrics();
total_streams += m.total_streams;
active_legs.extend(m.active_legs);
}
TunnelMetrics {
global_min_rtt_ms: netrunner_core::net::GLOBAL_MIN_RTT
.load(std::sync::atomic::Ordering::Relaxed),
active_legs,
total_streams,
session_count: sessions.len(),
}
}
/// Appends one JSON line to the log file, rotating if the file is too large. /// Appends one JSON line to the log file, rotating if the file is too large.
async fn append_to_file(&self, snap: &DiagnosticsSnapshot) { async fn append_to_file(&self, snap: &DiagnosticsSnapshot) {
if let Err(e) = self.try_rotate().await { if let Err(e) = self.try_rotate().await {
@@ -146,13 +192,87 @@ impl ServerDiagnosticsLogger {
let mut rotated = self.log_path.clone(); let mut rotated = self.log_path.clone();
rotated.set_extension("jsonl.1"); rotated.set_extension("jsonl.1");
tokio::fs::rename(&self.log_path, &rotated).await?; tokio::fs::rename(&self.log_path, &rotated).await?;
info!( info!("Diagnostics log rotated → {}", rotated.display());
"Diagnostics log rotated → {}",
rotated.display()
);
} }
_ => {} _ => {}
} }
Ok(()) Ok(())
} }
} }
/// Логгер клиентской диагностики, доставленной по туннелю.
///
/// Подписывается на сток [`diagnostics::init_client_diag_sink`] и для каждого
/// пришедшего отчёта дописывает его JSON-строку в пер-сессионный JSONL-файл
/// `netrunner_client_diag_<session_id>.jsonl`. Так снапшоты ядра клиента, которые
/// раньше копились вхолостую в памяти приложения, оседают на сервере рядом с его
/// собственной диагностикой, разложенные по сессиям.
pub struct ClientDiagnosticsLogger {
log_dir: PathBuf,
}
impl ClientDiagnosticsLogger {
pub fn new(log_dir: impl Into<PathBuf>) -> Self {
Self {
log_dir: log_dir.into(),
}
}
/// Очищает `session_id` для безопасного использования в имени файла: оставляет
/// только `[A-Za-z0-9._-]` и ограничивает длину. Защита от path-traversal —
/// `session_id` приходит из клиентского auth-payload и не доверенный.
fn sanitize_session_id(session_id: &str) -> String {
let cleaned: String = session_id
.chars()
.filter(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-'))
.take(CLIENT_DIAG_SESSION_ID_MAX)
.collect();
if cleaned.is_empty() {
"unknown".to_string()
} else {
cleaned
}
}
/// Запускает фоновую задачу: читает отчёты из стока и пишет их по сессиям.
/// Завершается, когда сток закрыт (все отправители ушли).
pub fn start(self: Arc<Self>) {
let mut rx = diagnostics::init_client_diag_sink();
tokio::spawn(async move {
info!(
"Client diagnostics logger started → {}/netrunner_client_diag_<session>.jsonl",
self.log_dir.display()
);
while let Some(report) = rx.recv().await {
self.append_report(&report.session_id, &report.json_line)
.await;
}
warn!("Client diagnostics sink closed; logger task stopping");
});
}
/// Дописывает одну JSON-строку в файл сессии (создавая его при необходимости).
async fn append_report(&self, session_id: &str, json_line: &str) {
let safe_id = Self::sanitize_session_id(session_id);
let mut path = self.log_dir.clone();
path.push(format!("netrunner_client_diag_{}.jsonl", safe_id));
let result = OpenOptions::new()
.create(true)
.append(true)
.open(&path)
.await;
match result {
Ok(mut file) => {
let _ = file.write_all(json_line.as_bytes()).await;
let _ = file.write_all(b"\n").await;
}
Err(e) => error!(
"Cannot open client diagnostics log {}: {}",
path.display(),
e
),
}
}
}
+62
View File
@@ -0,0 +1,62 @@
//! Минимальный HTTP `/health` для supervisor'а (systemd/docker healthcheck) —
//! отдельный порт, не тот, где слушается замаскированный туннельный протокол
//! (смешивать их нельзя: health-эндпоинт — обычный читаемый HTTP, это выдало
//! бы DPI ровно то, что декой должен скрывать). По умолчанию биндится только
//! на 127.0.0.1 — наружу торчать не должен, это внутренняя проверка для
//! supervisor'а/docker healthcheck на этой же машине.
//!
//! Не использует HTTP-фреймворк (лишняя зависимость ради одного эндпоинта) —
//! отвечает одним и тем же 200+JSON на любое подключение, не разбирая запрос.
use netrunner_logger::{error, info, warn};
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::Arc;
use tokio::io::AsyncWriteExt;
use tokio::net::TcpListener;
use tokio_util::sync::CancellationToken;
/// Отдаёт реальное состояние — не просто "процесс жив", а сколько сейчас
/// физических соединений реально обслуживается (см. `Network::run`,
/// `active_connections`).
pub async fn run(
host: String,
port: u16,
active_connections: Arc<AtomicU64>,
token: CancellationToken,
) {
let addr = format!("{host}:{port}");
let listener = match TcpListener::bind(&addr).await {
Ok(l) => l,
Err(e) => {
error!(error = %e, addr = %addr, "Не удалось поднять health-listener");
return;
}
};
info!("🩺 Health-эндпоинт слушает на {}", addr);
loop {
tokio::select! {
_ = token.cancelled() => {
info!("Health-listener остановлен по сигналу отмены.");
break;
}
res = listener.accept() => {
let Ok((mut stream, _)) = res else { continue };
let active = active_connections.load(Ordering::Relaxed);
tokio::spawn(async move {
let body = format!(
r#"{{"status":"ok","active_connections":{active}}}"#
);
let response = format!(
"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
body.len(),
body
);
if let Err(e) = stream.write_all(response.as_bytes()).await {
warn!(error = %e, "Health-listener: ошибка записи ответа");
}
});
}
}
}
}
+132 -3
View File
@@ -1,32 +1,161 @@
//! # netrunner-server — серверная часть прокси
//!
//! Тонкая обвязка вокруг ядра ([`netrunner_core`]): парсит аргументы, поднимает
//! логгер и tokio-рантайм и запускает [`Network`](crate::network::Network),
//! которая слушает TCP, принимает замаскированные TLS-соединения и отдаёт каждое
//! в `ServerHandler` ядра (хендшейк → аутентификация → проксирование или
//! stealth-fallback). Бизнес-логика целиком в ядре; здесь — точка входа и
//! серверная диагностика ([`diagnostics`](crate::diagnostics)).
// Workaround for rustc 1.94 ICE in check_mod_deathness (dead-code MIR pass). // Workaround for rustc 1.94 ICE in check_mod_deathness (dead-code MIR pass).
#![allow(dead_code)] #![allow(dead_code)]
mod backend_client;
mod diagnostics; mod diagnostics;
mod health;
mod metrics_server;
mod network; mod network;
use clap::Parser; use clap::Parser;
use netrunner_logger::Logger; use netrunner_core::net::AuthValidator;
use netrunner_logger::{error, info, Logger};
use std::sync::Arc;
use tokio_util::sync::CancellationToken; use tokio_util::sync::CancellationToken;
use crate::backend_client::BackendClient;
use crate::network::Network; use crate::network::Network;
/// Ждёт SIGTERM (docker stop/systemctl stop) или SIGINT (Ctrl+C) и отменяет
/// токен — раньше этой функции не было вообще: сервер получал сигнал прямо
/// от ОС мимо CancellationToken'а, и вся drain-логика в `Network::run` была
/// мертва, ни разу не срабатывая на реальном шатдауне.
async fn shutdown_signal(token: CancellationToken) {
let ctrl_c = async {
tokio::signal::ctrl_c()
.await
.expect("Не удалось установить обработчик Ctrl+C");
};
#[cfg(unix)]
let terminate = async {
tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())
.expect("Не удалось установить обработчик SIGTERM")
.recv()
.await;
};
#[cfg(not(unix))]
let terminate = std::future::pending::<()>();
tokio::select! {
_ = ctrl_c => info!("🛑 Получен SIGINT (Ctrl+C). Останавливаемся..."),
_ = terminate => info!("🛑 Получен SIGTERM. Останавливаемся..."),
}
token.cancel();
}
/// Аргументы командной строки сервера.
#[derive(Parser, Debug)] #[derive(Parser, Debug)]
#[command(author, version, about = "Netrunner Proxy Server")] #[command(author, version, about = "Netrunner Proxy Server")]
struct Args { struct Args {
/// Порт прослушивания.
#[arg(short, long, default_value_t = 8080)] #[arg(short, long, default_value_t = 8080)]
port: u16, port: u16,
/// Адрес привязки.
#[arg(long, default_value = "0.0.0.0")] #[arg(long, default_value = "0.0.0.0")]
host: String, host: String,
/// Домен-декой: под кого притворяется этот узел для «не наших» подключений
/// (stealth-fallback прозрачно проксирует туда трафик сканеров/чужих
/// клиентов). Раньше было жёстко зашито на `ubuntu.com` в коде ядра — теперь
/// атрибут ноды, можно задавать разный на каждом развёртывании.
#[arg(long, default_value = netrunner_core::net::DEFAULT_DECOY_HOST)]
decoy_host: String,
/// Требовать валидный Bearer-токен (выданный `netrunner-backend`) от
/// каждого клиента и отчитываться о расходе трафика для динамических
/// лимитов. Выключено по умолчанию — включается по инстансу, не меняя
/// поведение уже развёрнутых нод без этого флага.
#[arg(long, default_value_t = false)]
require_auth: bool,
/// URL control-plane бэкенда для проверки токенов/отчётов о трафике.
/// Обязателен, только если передан `--require-auth`.
#[arg(long)]
backend_url: Option<String>,
/// Порт для внутреннего HTTP `/health` (биндится только на 127.0.0.1 —
/// не для публичного доступа, только supervisor/docker healthcheck на
/// этой же машине). Не задан — health-эндпоинт выключен.
#[arg(long)]
health_port: Option<u16>,
/// Порт для `/metrics` (Prometheus text exposition) — в отличие от
/// `--health-port`, биндится на 0.0.0.0 (нужен для скрейпа удалённым
/// центральным Prometheus), ОБЯЗАТЕЛЬНО зафайрволить на IP
/// observability-VPS. Не задан — метрики выключены.
#[arg(long)]
metrics_port: Option<u16>,
} }
fn main() { fn main() {
Logger::init("./logs".into(), true); Logger::init("./logs".into(), true);
Logger::global().set_level("info"); Logger::global().set_level("info");
let args = Args::parse(); let args = Args::parse();
let net = Network::new(args.host, args.port);
let auth: Option<Arc<dyn AuthValidator>> = if args.require_auth {
let backend_url = args
.backend_url
.expect("--require-auth требует --backend-url");
let internal_secret = std::env::var("PROXY_INTERNAL_SECRET")
.expect("--require-auth требует переменную окружения PROXY_INTERNAL_SECRET");
Some(Arc::new(BackendClient::new(backend_url, internal_secret)))
} else {
None
};
// Регистрируется один раз, до первого metrics::counter!/gauge!/histogram! —
// если --metrics-port не задан, вызовы макросов молча уходят в
// no-op recorder по умолчанию (штатное поведение крейта metrics).
let metrics_handle = args
.metrics_port
.map(|_| metrics_server::install_recorder());
let net = Network::new(
args.host.clone(),
args.port,
args.decoy_host,
auth,
args.health_port,
);
let rt = tokio::runtime::Runtime::new().expect("Failed to create Tokio runtime"); let rt = tokio::runtime::Runtime::new().expect("Failed to create Tokio runtime");
rt.block_on(async { rt.block_on(async {
net.run(CancellationToken::new()).await; let token = CancellationToken::new();
let run_token = token.clone();
// net.run() спавним отдельной задачей и дожидаемся её ПОСЛЕ сигнала —
// tokio::select! между сигналом и run() тут не подходит: select
// дропает недовершившуюся ветку целиком, оборвав drain-фазу в
// Network::run в момент получения самого сигнала, вместо того чтобы
// дать ей отработать.
let run_handle = tokio::spawn(async move {
net.run(run_token).await;
});
if let (Some(port), Some(handle)) = (args.metrics_port, metrics_handle) {
let metrics_token = token.clone();
tokio::spawn(metrics_server::run(
"0.0.0.0".to_string(),
port,
handle,
metrics_token,
));
}
shutdown_signal(token).await;
if let Err(e) = run_handle.await {
error!(error = ?e, "Задача сервера завершилась с паникой при остановке");
}
}); });
} }
+60
View File
@@ -0,0 +1,60 @@
//! `/metrics` для центрального Prometheus (см.
//! `netrunner-data/docker-compose.observability.yml`) — в отличие от
//! `/health` (127.0.0.1-only, см. `health.rs`), этот порт публикуется наружу
//! и ОБЯЗАТЕЛЬНО должен быть зафайрволен на IP observability-VPS: любой,
//! кто до него дотянется, увидит число активных соединений/трафик этой
//! ноды (не секрет пользователей, но разведка для DPI/блокировщика).
//!
//! Тот же hand-rolled HTTP-паттерн, что и в `health.rs` (не тянуть HTTP-
//! фреймворк ради одного эндпоинта на бинарнике, для которого важен размер
//! и минимальные зависимости) — просто рендерит текущий снапшот метрик из
//! уже установленного `PrometheusHandle` на каждый коннект.
use metrics_exporter_prometheus::{PrometheusBuilder, PrometheusHandle};
use netrunner_logger::{error, info, warn};
use tokio::io::AsyncWriteExt;
use tokio::net::TcpListener;
use tokio_util::sync::CancellationToken;
/// Регистрирует глобальный recorder `metrics`-крейта — вызывать ровно один
/// раз при старте, до первого `metrics::counter!`/`gauge!`/`histogram!`.
pub fn install_recorder() -> PrometheusHandle {
PrometheusBuilder::new()
.install_recorder()
.expect("Failed to install Prometheus recorder")
}
pub async fn run(host: String, port: u16, handle: PrometheusHandle, token: CancellationToken) {
let addr = format!("{host}:{port}");
let listener = match TcpListener::bind(&addr).await {
Ok(l) => l,
Err(e) => {
error!(error = %e, addr = %addr, "Не удалось поднять metrics-листенер");
return;
}
};
info!("📊 Metrics-эндпоинт слушает на {}", addr);
loop {
tokio::select! {
_ = token.cancelled() => {
info!("Metrics-listener остановлен по сигналу отмены.");
break;
}
res = listener.accept() => {
let Ok((mut stream, _)) = res else { continue };
let body = handle.render();
tokio::spawn(async move {
let response = format!(
"HTTP/1.1 200 OK\r\nContent-Type: text/plain; version=0.0.4\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
body.len(),
body
);
if let Err(e) = stream.write_all(response.as_bytes()).await {
warn!(error = %e, "Metrics-listener: ошибка записи ответа");
}
});
}
}
}
}
+141 -9
View File
@@ -1,36 +1,82 @@
//! TCP-листенер сервера и приём входящих туннельных соединений.
//!
//! [`Network::run`] инициализирует глобальный конфиг и серверную диагностику,
//! создаёт **один** общий [`SessionManager`] (мультиплексирование: разные ноги
//! одной сессии цепляются к одному muxer), запускает фоновую задачу health-check
//! и печати топологии, после чего в цикле принимает соединения и на каждое
//! спавнит `ServerHandler::run` из ядра под отдельным tracing-span клиента.
use netrunner_core::net::{ use netrunner_core::net::{
Connection, NetworkConfig, ServerHandler, SessionManager, TunnelHandler, AuthValidator, Connection, NetworkConfig, ServerHandler, SessionManager, TunnelHandler,
TOPOLOGY_PRINT_INTERVAL, TOPOLOGY_PRINT_INTERVAL,
}; };
use netrunner_logger::{error, info}; use netrunner_logger::{error, info, warn};
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::Arc; use std::sync::Arc;
use std::time::Duration;
use tokio::net::TcpListener; use tokio::net::TcpListener;
use tokio_util::sync::CancellationToken; use tokio_util::sync::CancellationToken;
use crate::diagnostics::ServerDiagnosticsLogger; use crate::diagnostics::{ClientDiagnosticsLogger, ServerDiagnosticsLogger};
use crate::health;
/// Сколько ждём при остановке, пока уже принятые соединения сами закроются,
/// прежде чем отпустить рантайм (который при Drop абортит все задачи разом,
/// без предупреждения клиентам).
const SHUTDOWN_DRAIN_TIMEOUT: Duration = Duration::from_secs(30);
/// Параметры прослушивания сервера.
pub struct Network { pub struct Network {
host: String, host: String,
port: u16, port: u16,
/// Домен-декой этой ноды для stealth-fallback (атрибут ноды — задаётся при
/// старте через `--decoy-host`, раньше был захардкожен на `ubuntu.com`).
decoy_host: Arc<str>,
/// `None` — `--require-auth` не передан, авторизация и лимиты трафика
/// выключены на этом инстансе целиком (поведение как до этой фичи).
auth: Option<Arc<dyn AuthValidator>>,
/// `None` — health-эндпоинт выключен (по умолчанию для обратной
/// совместимости с уже развёрнутыми нодами без этого флага).
health_port: Option<u16>,
} }
impl Network { impl Network {
pub fn new(host: String, port: u16) -> Self { pub fn new(
Self { host, port } host: String,
port: u16,
decoy_host: impl Into<Arc<str>>,
auth: Option<Arc<dyn AuthValidator>>,
health_port: Option<u16>,
) -> Self {
Self {
host,
port,
decoy_host: decoy_host.into(),
auth,
health_port,
}
} }
/// Запускает сервер: слушает TCP и обслуживает соединения до отмены `token`.
pub async fn run(&self, token: CancellationToken) { pub async fn run(&self, token: CancellationToken) {
let addr = format!("{}:{}", self.host, self.port); let addr = format!("{}:{}", self.host, self.port);
NetworkConfig::init_global(1450); NetworkConfig::init_global(1450);
// Start diagnostics logger — writes events to ./netrunner_diagnostics.jsonl
Arc::new(ServerDiagnosticsLogger::new(".")).start();
// 🔥 CRITICAL FIX: Create ONE global session manager for multiplexing // 🔥 CRITICAL FIX: Create ONE global session manager for multiplexing
let session_manager = Arc::new(SessionManager::new()); let session_manager = Arc::new(SessionManager::new());
// Start diagnostics logger — writes events to ./netrunner_diagnostics.jsonl.
// Shares the session manager so snapshots report real per-session tunnel
// state (active legs, streams) instead of an always-empty placeholder.
Arc::new(ServerDiagnosticsLogger::new(".", session_manager.clone())).start();
// Start client-diagnostics logger — saves snapshots shipped by clients
// over the tunnel into ./netrunner_client_diag_<session>.jsonl
Arc::new(ClientDiagnosticsLogger::new(".")).start();
let sm_clone = session_manager.clone(); let sm_clone = session_manager.clone();
let quota_auth = self.auth.clone();
tokio::spawn(async move { tokio::spawn(async move {
loop { loop {
tokio::time::sleep(TOPOLOGY_PRINT_INTERVAL).await; tokio::time::sleep(TOPOLOGY_PRINT_INTERVAL).await;
@@ -49,11 +95,66 @@ impl Network {
} }
sm_clone.print_all_sessions(); sm_clone.print_all_sessions();
// Динамические лимиты трафика: только сессии с проверенным
// владельцем (`--require-auth` включён и хендшейк прошёл
// валидацию токена, см. `ServerHandler::run`). Бэкенд читает
// текущий лимит из БД на каждый вызов — админ меняет его в
// любой момент, следующий тик подхватит новое значение без
// перезапуска прокси.
if let Some(validator) = &quota_auth {
for entry in sm_clone.get_session().iter() {
let muxer = entry.value().clone();
let Some(user_id) = muxer.quota_user_id() else {
continue;
};
let delta = muxer.take_usage_delta();
if delta == 0 {
continue;
}
match validator.report_usage(&user_id, delta).await {
Ok(report) if report.over_limit => {
warn!(
user_id,
used = report.used_bytes,
limit = ?report.limit_bytes,
"🚫 Traffic limit exceeded, tearing down session"
);
muxer.remove_all_legs();
sm_clone.remove(muxer.session_id());
}
Ok(_) => {}
Err(e) => {
// Бэкенд недоступен/ошибка — не терять дельту
// навсегда, отчитаемся вместе со следующим тиком.
muxer.rollback_usage_delta(delta);
warn!(user_id, error = %e, "Usage report failed, will retry");
}
}
}
}
} }
}); });
info!("🌐 Netrunner Server: Listening on {}", addr); info!("🌐 Netrunner Server: Listening on {}", addr);
let listener = TcpListener::bind(&addr).await.expect("Server bind failed"); let listener = TcpListener::bind(&addr).await.expect("Server bind failed");
// Число реально обслуживаемых физических соединений прямо сейчас — не
// "процесс жив", а "сколько клиентов на нём висит". Отдаётся в /health
// и используется ниже, чтобы дождаться отключения клиентов при
// остановке вместо мгновенного разрыва при drop рантайма.
let active_connections = Arc::new(AtomicU64::new(0));
if let Some(health_port) = self.health_port {
let health_token = token.clone();
let health_connections = active_connections.clone();
tokio::spawn(health::run(
"127.0.0.1".to_string(),
health_port,
health_connections,
health_token,
));
}
loop { loop {
tokio::select! { tokio::select! {
_ = token.cancelled() => { _ = token.cancelled() => {
@@ -67,8 +168,17 @@ impl Network {
let conn = Connection::new(stream); let conn = Connection::new(stream);
// Pass the Arc clone down to the ServerHandler // Pass the Arc clone down to the ServerHandler
let handler = ServerHandler::new(conn, session_manager.clone()); let handler = ServerHandler::new(
conn,
session_manager.clone(),
self.decoy_host.clone(),
self.auth.clone(),
);
let active_now = active_connections.fetch_add(1, Ordering::Relaxed) + 1;
metrics::gauge!("netrunner_connections_active").set(active_now as f64);
metrics::counter!("netrunner_connections_total").increment(1);
let conn_counter = active_connections.clone();
tokio::spawn(async move { tokio::spawn(async move {
// "Входим" в этот Span. Все логи внутри handler.run() привяжутся к этому IP. // "Входим" в этот Span. Все логи внутри handler.run() привяжутся к этому IP.
let _enter = span.enter(); let _enter = span.enter();
@@ -77,10 +187,32 @@ impl Network {
if let Err(e) = handler.run().await { if let Err(e) = handler.run().await {
error!(error = %e, "⚠️ Server handler terminated with error"); error!(error = %e, "⚠️ Server handler terminated with error");
} }
let active_now = conn_counter.fetch_sub(1, Ordering::Relaxed) - 1;
metrics::gauge!("netrunner_connections_active").set(active_now as f64);
}); });
} }
} }
} }
} }
// Graceful drain: приём новых соединений уже остановлен (цикл выше
// прерван), но уже принятые клиенты продолжают жить как detached-задачи
// рантайма. Без этого ожидания следующий за `run()` выход из
// `rt.block_on` уронит рантайм и оборвёт их все разом без предупреждения.
let drain_start = tokio::time::Instant::now();
while active_connections.load(Ordering::Relaxed) > 0 {
if drain_start.elapsed() > SHUTDOWN_DRAIN_TIMEOUT {
warn!(
remaining = active_connections.load(Ordering::Relaxed),
"Drain timeout истёк, принудительно завершаем оставшиеся соединения"
);
break;
}
tokio::time::sleep(Duration::from_millis(200)).await;
}
info!(
"✅ Drain завершён, соединений осталось: {}",
active_connections.load(Ordering::Relaxed)
);
} }
} }
+28
View File
@@ -0,0 +1,28 @@
# `tools` — вспомогательные крейты
Утилиты, общие для проекта.
## `log` — `netrunner-logger`
Единый логгер и реестр ошибок для всех крейтов. Обёртка над
`tracing`/`tracing-subscriber`.
| Файл | Что внутри |
|------------------|-----------------------------------------------------------------|
| `src/lib.rs` | Глобальный `Logger` (один на процесс), два режима, PII-слой. |
| `src/error.rs` | `AppError` + реестр кодов `ERR_*` (стабильные идентификаторы). |
- **Режимы:** production (JSON в файл с суточной ротацией + перехват паник) и
debug (цветная консоль; на Android — `tracing_android`).
- **Уровень** меняется на лету (`Logger::set_level`).
- Макросы `info!`/`error!`/… ре-экспортируются, чтобы во всех крейтах писать
`netrunner_logger::info!` без прямой зависимости на `tracing`.
## `bindgen` — генератор FFI-биндингов
Однострочная обёртка вокруг `uniffi_bindgen`: по `.udl`-описанию клиента
(`client/src/netrunner_client.udl`) генерирует Kotlin/Swift-обёртки для вызова
нативных функций из мобильного приложения. Запускается из `Makefile`
(цель `build-android`).
> Детали — в rustdoc соответствующих крейтов.
+7
View File
@@ -1,3 +1,10 @@
//! Утилита генерации FFI-биндингов (UniFFI).
//!
//! Тонкая обёртка вокруг `uniffi_bindgen`: по `.udl`-описанию клиента
//! (`client/src/netrunner_client.udl`) генерирует Kotlin/Swift-обёртки для вызова
//! нативных функций из мобильного приложения. Запускается из `Makefile`
//! (цель `build-android`) как отдельный бинарь.
fn main() { fn main() {
uniffi::uniffi_bindgen_main(); uniffi::uniffi_bindgen_main();
} }
+17
View File
@@ -0,0 +1,17 @@
[package]
name = "netrunner-loadtest"
version = "0.1.0"
edition = "2021"
[[bin]]
name = "loadtest"
path = "src/main.rs"
[dependencies]
netrunner-core = { path = "../../core" }
tokio = { version = "1.49.0", features = ["full"] }
clap = { version = "4", features = ["derive"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
bytes = "1.5"
libc = "0.2"
+417
View File
@@ -0,0 +1,417 @@
//! Нагрузочный тест `netrunner-server` для сайзинга прод-серверов.
//!
//! Одна инвокация = один уровень конкурентности = один изолированный прогон:
//! поднимает свежий сервер (реальный скомпилированный бинарник, отдельным
//! процессом — чтобы мерить именно ЕГО ресурсы, не путая с нагрузочным
//! генератором) + локальный echo-таргет, гоняет `--concurrency` параллельных
//! "виртуальных пользователей" через настоящий клиентский стек
//! (`ClientHandler::connect` + `RawCastFrame`, тот же публичный API, что и
//! реальное приложение) в течение `--duration-secs`, снимает RSS/CPU процесса
//! сервера из `/proc`, печатает результат одной JSON-строкой в stdout.
//!
//! Почему процесс на уровень, а не один долгоживущий процесс на все уровни:
//! `ClientHandler::connect` — это "fire and forget" (спавнит фоновые задачи и
//! возвращается сразу же, ещё до реального хендшейка), и они не имеют чистого
//! способа самоостановки извне. Гонять по процессу на уровень — самый простой
//! и надёжный способ гарантировать отсутствие "хвостов" из предыдущего уровня.
//!
//! Каждый пользователь поднимает `MAX_TUNNEL_LEGS` (сейчас 4) TCP-соединений к
//! серверу — это сознательный выбор реального протокола (избыточность/
//! multipath), не артефакт теста. Держи это в уме при интерпретации: N
//! "пользователей" ⇒ примерно 4N настоящих TCP-соединений на сервере.
use clap::Parser;
use netrunner_core::net::{ClientHandler, NetworkConfig};
use netrunner_core::rawcast::{LocalProtocol, RawCastEvent, RawCastFrame};
use serde::Serialize;
use std::net::SocketAddr;
use std::process::{Command, Stdio};
use std::sync::atomic::{AtomicU64, Ordering::Relaxed};
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::sync::mpsc;
#[derive(Parser, Debug)]
#[command(about = "Нагрузочный тест netrunner-server: один запуск = один уровень конкурентности")]
struct Args {
/// Путь к скомпилированному бинарнику netrunner-server.
#[arg(long, default_value = "../../target/release/netrunner-server")]
server_bin: String,
/// Сколько параллельных "виртуальных пользователей" (каждый — полноценный
/// клиентский туннель через ClientHandler::connect, т.е. до 4 TCP-соединений).
#[arg(long, default_value_t = 100)]
concurrency: usize,
/// Сколько секунд сустейнить трафик на каждом пользователе.
#[arg(long, default_value_t = 15)]
duration_secs: u64,
/// Целевая скорость на одного пользователя, байт/сек, в каждую сторону
/// (echo отражает обратно столько же). 0 — слать максимально быстро без пауз.
#[arg(long, default_value_t = 32 * 1024)]
rate_bps: u64,
/// Размер одного Data-кадра полезной нагрузки.
#[arg(long, default_value_t = 4096)]
chunk_size: usize,
/// Сколько секунд ждать, пока сервер поднимется и начнёт слушать порт.
#[arg(long, default_value_t = 10)]
startup_timeout_secs: u64,
}
#[derive(Default)]
struct UserStats {
handshake_successes: AtomicU64,
handshake_failures: AtomicU64,
bytes_sent: AtomicU64,
bytes_received: AtomicU64,
first_byte_latency_ms: Mutex<Vec<u64>>,
}
#[derive(Serialize)]
struct LoadTestResult {
concurrency: usize,
duration_secs: u64,
rate_bps_target: u64,
chunk_size: usize,
handshake_successes: u64,
handshake_failures: u64,
handshake_success_rate: f64,
bytes_sent: u64,
bytes_received: u64,
aggregate_mbps_down: f64,
aggregate_mbps_up: f64,
first_byte_latency_ms_p50: u64,
first_byte_latency_ms_p95: u64,
first_byte_latency_ms_max: u64,
server_rss_mb: f64,
server_cpu_cores: f64,
server_open_fds: u64,
loadtest_cpu_cores_self: f64,
wall_elapsed_secs: f64,
}
fn find_free_port() -> u16 {
std::net::TcpListener::bind("127.0.0.1:0")
.expect("bind for free port probe")
.local_addr()
.unwrap()
.port()
}
/// `VmRSS` из `/proc/<pid>/status`, в КБ.
fn read_rss_kb(pid: u32) -> Option<u64> {
let status = std::fs::read_to_string(format!("/proc/{pid}/status")).ok()?;
for line in status.lines() {
if let Some(rest) = line.strip_prefix("VmRSS:") {
return rest.trim().split_whitespace().next()?.parse().ok();
}
}
None
}
/// `utime + stime` (в тиках) из `/proc/<pid>/stat`. `comm` (2-е поле) в скобках
/// и может содержать пробелы — ищем последнюю `)` и считаем поля от неё.
fn read_cpu_ticks(pid: u32) -> Option<u64> {
let stat = std::fs::read_to_string(format!("/proc/{pid}/stat")).ok()?;
let after_comm = stat.rsplit_once(')')?.1;
let fields: Vec<&str> = after_comm.split_whitespace().collect();
// Поля после ')' начинаются с исходного поля #3 (state) под индексом 0.
// utime = поле #14 -> индекс 11; stime = поле #15 -> индекс 12.
let utime: u64 = fields.get(11)?.parse().ok()?;
let stime: u64 = fields.get(12)?.parse().ok()?;
Some(utime + stime)
}
fn read_open_fds(pid: u32) -> u64 {
std::fs::read_dir(format!("/proc/{pid}/fd"))
.map(|d| d.count() as u64)
.unwrap_or(0)
}
fn clk_tck() -> f64 {
let tck = unsafe { libc::sysconf(libc::_SC_CLK_TCK) };
if tck > 0 {
tck as f64
} else {
100.0
}
}
async fn wait_for_server(addr: SocketAddr, timeout: Duration) -> bool {
let deadline = Instant::now() + timeout;
while Instant::now() < deadline {
if tokio::net::TcpStream::connect(addr).await.is_ok() {
return true;
}
tokio::time::sleep(Duration::from_millis(100)).await;
}
false
}
/// Локальный echo-таргет: то, во что сервер реально проксирует туннельный
/// трафик виртуальных пользователей (имитирует "интернет" без сети).
async fn spawn_echo_server() -> SocketAddr {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
tokio::spawn(async move {
loop {
let (mut sock, _) = match listener.accept().await {
Ok(v) => v,
Err(_) => continue,
};
tokio::spawn(async move {
let mut buf = vec![0u8; 65536];
loop {
match sock.read(&mut buf).await {
Ok(0) | Err(_) => break,
Ok(n) => {
if sock.write_all(&buf[..n]).await.is_err() {
break;
}
}
}
}
});
}
});
addr
}
async fn run_virtual_user(
server_addr: String,
echo_addr: SocketAddr,
duration: Duration,
chunk_size: usize,
rate_bps: u64,
stats: Arc<UserStats>,
) {
let (app_tx, rx_from_engine) = mpsc::channel::<RawCastFrame>(256);
let (tx_to_engine, mut app_rx) = mpsc::channel::<RawCastFrame>(256);
let started = Instant::now();
let muxer = match ClientHandler::connect(
&server_addr,
"example.com",
None,
rx_from_engine,
tx_to_engine,
)
.await
{
Ok(m) => m,
Err(_) => {
stats.handshake_failures.fetch_add(1, Relaxed);
return;
}
};
// connect() возвращается сразу же, спавнит фоновые задачи и не ждёт, пока
// реально поднимется хоть одна нога — Connect-кадр, отправленный раньше,
// чем select_leg() найдёт живую ногу, молча теряется (send_control по
// FrameType::Connect идёт через try_send, ошибка отбрасывается вызывающим
// кодом внутри connect()). Настоящий TUN-трафик почти никогда не бьёт по
// этой гонке (первый пакет юзера обычно приходит куда позже, чем
// устанавливается первая нога), но синтетический генератор нагрузки бьёт
// в неё гарантированно — поэтому ждём здесь явно.
let leg_deadline = Instant::now() + Duration::from_secs(5);
while muxer.active_legs_count() == 0 && Instant::now() < leg_deadline {
tokio::time::sleep(Duration::from_millis(5)).await;
}
if muxer.active_legs_count() == 0 {
stats.handshake_failures.fetch_add(1, Relaxed);
return;
}
let ip = match echo_addr.ip() {
std::net::IpAddr::V4(v4) => v4,
_ => unreachable!("echo server is always bound on 127.0.0.1"),
};
let port = echo_addr.port();
let socket_id: u64 = 1;
if app_tx
.send(RawCastFrame::connect(
LocalProtocol::Tcp,
socket_id,
ip,
port,
))
.await
.is_err()
{
stats.handshake_failures.fetch_add(1, Relaxed);
return;
}
let stats_reader = stats.clone();
let reader = tokio::spawn(async move {
let mut recorded = false;
while let Some(frame) = app_rx.recv().await {
if frame.event == RawCastEvent::Data {
stats_reader
.bytes_received
.fetch_add(frame.payload.len() as u64, Relaxed);
if !recorded {
recorded = true;
stats_reader.handshake_successes.fetch_add(1, Relaxed);
stats_reader
.first_byte_latency_ms
.lock()
.unwrap()
.push(started.elapsed().as_millis() as u64);
}
}
}
});
let payload = bytes::Bytes::from(vec![0xABu8; chunk_size]);
let interval = if rate_bps == 0 {
None
} else {
Some(Duration::from_millis(
(chunk_size as u64 * 1000 / rate_bps).max(1),
))
};
let end = Instant::now() + duration;
while Instant::now() < end {
let frame = RawCastFrame::data(LocalProtocol::Tcp, socket_id, ip, port, payload.clone());
if app_tx.send(frame).await.is_err() {
break;
}
stats.bytes_sent.fetch_add(chunk_size as u64, Relaxed);
if let Some(iv) = interval {
tokio::time::sleep(iv).await;
}
}
drop(app_tx);
let _ = tokio::time::timeout(Duration::from_secs(2), reader).await;
}
fn percentile(sorted: &[u64], pct: f64) -> u64 {
if sorted.is_empty() {
return 0;
}
let idx = ((sorted.len() as f64 - 1.0) * pct).round() as usize;
sorted[idx.min(sorted.len() - 1)]
}
#[tokio::main]
async fn main() {
let args = Args::parse();
NetworkConfig::init_global(1450); // тот же MTU, что и у реального клиента
let echo_addr = spawn_echo_server().await;
let server_port = find_free_port();
let server_addr_str = format!("127.0.0.1:{server_port}");
let mut server_child = Command::new(&args.server_bin)
.args([
"--host",
"127.0.0.1",
"--port",
&server_port.to_string(),
"--decoy-host",
"example.com",
])
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()
.unwrap_or_else(|e| {
panic!(
"не удалось запустить {} — собери сервер (cargo build --release -p netrunner-server) и укажи путь через --server-bin: {e}",
args.server_bin
)
});
let server_pid = server_child.id();
let ready = wait_for_server(
server_addr_str.parse().unwrap(),
Duration::from_secs(args.startup_timeout_secs),
)
.await;
if !ready {
eprintln!("сервер не поднялся за {}с", args.startup_timeout_secs);
let _ = server_child.kill();
std::process::exit(1);
}
// Дать серверу устаканиться после старта (аллокации рантайма и т.п. не
// должны попасть в измеряемый CPU-дельту).
tokio::time::sleep(Duration::from_millis(300)).await;
let cpu_start = read_cpu_ticks(server_pid).unwrap_or(0);
let self_cpu_start = read_cpu_ticks(std::process::id()).unwrap_or(0);
let wall_start = Instant::now();
let stats = Arc::new(UserStats::default());
let mut handles = Vec::with_capacity(args.concurrency);
for _ in 0..args.concurrency {
let s = server_addr_str.clone();
let st = stats.clone();
handles.push(tokio::spawn(run_virtual_user(
s,
echo_addr,
Duration::from_secs(args.duration_secs),
args.chunk_size,
args.rate_bps,
st,
)));
tokio::time::sleep(Duration::from_micros(200)).await;
}
for h in handles {
let _ = h.await;
}
let wall_elapsed = wall_start.elapsed().as_secs_f64();
let cpu_end = read_cpu_ticks(server_pid).unwrap_or(cpu_start);
let self_cpu_end = read_cpu_ticks(std::process::id()).unwrap_or(self_cpu_start);
let rss_kb = read_rss_kb(server_pid).unwrap_or(0);
let open_fds = read_open_fds(server_pid);
let clk = clk_tck();
let mut latencies = stats.first_byte_latency_ms.lock().unwrap().clone();
latencies.sort_unstable();
let successes = stats.handshake_successes.load(Relaxed);
let failures = stats.handshake_failures.load(Relaxed);
let bytes_sent = stats.bytes_sent.load(Relaxed);
let bytes_received = stats.bytes_received.load(Relaxed);
let result = LoadTestResult {
concurrency: args.concurrency,
duration_secs: args.duration_secs,
rate_bps_target: args.rate_bps,
chunk_size: args.chunk_size,
handshake_successes: successes,
handshake_failures: failures,
handshake_success_rate: if args.concurrency > 0 {
successes as f64 / args.concurrency as f64
} else {
0.0
},
bytes_sent,
bytes_received,
aggregate_mbps_down: (bytes_received as f64 * 8.0 / 1_000_000.0) / wall_elapsed,
aggregate_mbps_up: (bytes_sent as f64 * 8.0 / 1_000_000.0) / wall_elapsed,
first_byte_latency_ms_p50: percentile(&latencies, 0.50),
first_byte_latency_ms_p95: percentile(&latencies, 0.95),
first_byte_latency_ms_max: latencies.last().copied().unwrap_or(0),
server_rss_mb: rss_kb as f64 / 1024.0,
server_cpu_cores: ((cpu_end - cpu_start) as f64 / clk) / wall_elapsed,
server_open_fds: open_fds,
loadtest_cpu_cores_self: ((self_cpu_end - self_cpu_start) as f64 / clk) / wall_elapsed,
wall_elapsed_secs: wall_elapsed,
};
println!("{}", serde_json::to_string(&result).unwrap());
let _ = server_child.kill();
let _ = server_child.wait();
}
+21 -2
View File
@@ -1,20 +1,37 @@
// tools/log/src/error.rs //! Типизированная ошибка приложения и реестр кодов ошибок.
//!
//! [`AppError`] несёт стабильный машинный `code` (из реестра `ERR_*` ниже),
//! раздельные сообщения для пользователя и для логов, произвольные метаданные и
//! опциональную причину-источник. Коды используются в логах и метриках как
//! устойчивые идентификаторы классов ошибок.
use std::collections::HashMap; use std::collections::HashMap;
use std::fmt; use std::fmt;
// Реестр кодов ошибок (Error Codes Registry) // ── Реестр кодов ошибок (стабильные машинные идентификаторы) ──
/// Таймаут инфраструктуры (сеть/DNS/соединение).
pub const ERR_INFRA_TIMEOUT: &str = "INFRA_TIMEOUT"; pub const ERR_INFRA_TIMEOUT: &str = "INFRA_TIMEOUT";
/// Провал аутентификации (неверный auth-тег/payload).
pub const ERR_AUTH_FAILED: &str = "AUTH_FAILED"; pub const ERR_AUTH_FAILED: &str = "AUTH_FAILED";
/// Пакет отброшен из-за MTU в туннеле.
pub const ERR_NET_MTU_DROP: &str = "NET_TUNNEL_MTU_DROP"; pub const ERR_NET_MTU_DROP: &str = "NET_TUNNEL_MTU_DROP";
/// Нарушение маскировки/целостности TLS (tampering, провал AEAD).
pub const ERR_NET_TLS_TAMPER: &str = "NET_TLS_TAMPER"; pub const ERR_NET_TLS_TAMPER: &str = "NET_TLS_TAMPER";
/// Необработанная паника (перехватывается логгером).
pub const ERR_SYS_PANIC: &str = "SYS_UNHANDLED_PANIC"; pub const ERR_SYS_PANIC: &str = "SYS_UNHANDLED_PANIC";
/// Ошибка приложения с машинным кодом, раздельными сообщениями и контекстом.
#[derive(Debug)] #[derive(Debug)]
pub struct AppError { pub struct AppError {
/// Стабильный код класса ошибки (один из `ERR_*`).
pub code: &'static str, pub code: &'static str,
/// Сообщение для пользователя (может показываться в UI).
pub user_msg: String, pub user_msg: String,
/// Техническое сообщение для логов/отладки.
pub internal_msg: String, pub internal_msg: String,
/// Произвольные пары ключ-значение с контекстом.
pub metadata: HashMap<String, String>, pub metadata: HashMap<String, String>,
/// Опциональная причина-источник (для цепочки ошибок).
pub cause: Option<Box<dyn std::error::Error + Send + Sync>>, pub cause: Option<Box<dyn std::error::Error + Send + Sync>>,
} }
@@ -33,11 +50,13 @@ impl AppError {
} }
} }
/// Добавляет пару ключ-значение в метаданные (builder-стиль).
pub fn with_context(mut self, key: &str, value: &str) -> Self { pub fn with_context(mut self, key: &str, value: &str) -> Self {
self.metadata.insert(key.to_string(), value.to_string()); self.metadata.insert(key.to_string(), value.to_string());
self self
} }
/// Прикрепляет причину-источник ошибки (builder-стиль).
pub fn with_cause(mut self, err: impl std::error::Error + Send + Sync + 'static) -> Self { pub fn with_cause(mut self, err: impl std::error::Error + Send + Sync + 'static) -> Self {
self.cause = Some(Box::new(err)); self.cause = Some(Box::new(err));
self self
+23
View File
@@ -1,3 +1,18 @@
//! # netrunner-logger — единый логгер и реестр ошибок проекта
//!
//! Тонкая обёртка над `tracing`/`tracing-subscriber`, дающая всем крейтам общий
//! стиль логирования и типизированные ошибки ([`error`]). Особенности:
//!
//! - **Один глобальный [`Logger`]** (`OnceLock` + `Once`): инициализируется раз,
//! уровень переключается на лету через reloadable-фильтр ([`set_level`](Logger::set_level)).
//! - **Два режима**: production (JSON в файл с суточной ротацией + перехват паник)
//! и debug (цветной вывод в консоль; на Android — `tracing_android`).
//! - **PII-редактор** ([`PiiRedactorLayer`]) — слой-заготовка для маскировки IP и
//! прочих чувствительных данных перед записью.
//!
//! Макросы `info!`/`error!`/… ре-экспортируются, чтобы во всех крейтах писать
//! `netrunner_logger::info!` без прямой зависимости на `tracing`.
pub mod error; pub mod error;
use regex::Regex; use regex::Regex;
@@ -21,6 +36,8 @@ pub use error::{
type ReloadableFilter = Handle<EnvFilter, Registry>; type ReloadableFilter = Handle<EnvFilter, Registry>;
/// Глобальный логгер: ручка перезагружаемого фильтра уровня + guard файлового
/// аппендера (держит фоновый writer живым, пока жив логгер).
pub struct Logger { pub struct Logger {
filter_handle: ReloadableFilter, filter_handle: ReloadableFilter,
_guard: Option<WorkerGuard>, _guard: Option<WorkerGuard>,
@@ -29,6 +46,7 @@ pub struct Logger {
static INIT: Once = Once::new(); static INIT: Once = Once::new();
static LOGGER: OnceLock<Logger> = OnceLock::new(); static LOGGER: OnceLock<Logger> = OnceLock::new();
/// Слой маскировки PII (сейчас — заготовка; regex для IP готов к использованию).
#[allow(dead_code)] #[allow(dead_code)]
struct PiiRedactorLayer { struct PiiRedactorLayer {
ip_regex: Regex, ip_regex: Regex,
@@ -50,6 +68,9 @@ impl<S: tracing::Subscriber> Layer<S> for PiiRedactorLayer {
} }
impl Logger { impl Logger {
/// Инициализирует глобальный логгер (идемпотентно — только первый вызов
/// действует). `is_production` выбирает JSON-в-файл + перехват паник против
/// цветного вывода в консоль; `log_dir` — куда писать файлы в прод-режиме.
pub fn init(log_dir: Option<&str>, is_production: bool) { pub fn init(log_dir: Option<&str>, is_production: bool) {
INIT.call_once(|| { INIT.call_once(|| {
// 1. Настройка динамического фильтра // 1. Настройка динамического фильтра
@@ -130,6 +151,7 @@ impl Logger {
}); });
} }
/// Меняет уровень логирования на лету (например `"debug"`, `"info,foo=warn"`).
pub fn set_level(&self, level: &str) { pub fn set_level(&self, level: &str) {
if let Ok(new_filter) = EnvFilter::try_new(level) { if let Ok(new_filter) = EnvFilter::try_new(level) {
let _ = self.filter_handle.reload(new_filter); let _ = self.filter_handle.reload(new_filter);
@@ -137,6 +159,7 @@ impl Logger {
} }
} }
/// Доступ к глобальному логгеру (паникует, если [`init`](Logger::init) не звали).
pub fn global() -> &'static Logger { pub fn global() -> &'static Logger {
LOGGER.get().expect("Logger not initialized!") LOGGER.get().expect("Logger not initialized!")
} }