Compare commits
23 Commits
18cad76e38
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| b9d41e670a | |||
| d1bb4661d1 | |||
| 4767730119 | |||
| 521d77f0cc | |||
| 87f9f865ae | |||
| bf5b914ddc | |||
| 84d53c801b | |||
| cfcb1afcec | |||
| fafd573a28 | |||
| c4be0174bf | |||
| 10d357b8ec | |||
| d8363667e0 | |||
| c835ac983a | |||
| e44bc4f660 | |||
| 72246f986a | |||
| 40ddb0a0f6 | |||
| 99a9eee908 | |||
| 8d9dfeee41 | |||
| 820d23abd9 | |||
| 8c9a9c7cc7 | |||
| 3b2296a6d9 | |||
| 582ad714ae | |||
| f7a76353ea |
@@ -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)"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
@@ -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
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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",
|
||||||
|
|||||||
@@ -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
@@ -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"))]
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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)]
|
||||||
|
|
||||||
|
|||||||
@@ -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).
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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
@@ -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()?;
|
||||||
|
|||||||
@@ -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,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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>,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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, здесь только метрики.
|
||||||
@@ -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();
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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")]
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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
@@ -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-аутентификацию
|
|
||||||
|
|
||||||
Подходит для:
|
|
||||||
|
|
||||||
- пользовательских протоколов
|
|
||||||
- туннелей
|
|
||||||
- транспортных шифрованных каналов
|
|
||||||
|
|||||||
@@ -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>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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],
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
@@ -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
|
|
||||||
- асинхронное
|
|
||||||
- расширяемое
|
|
||||||
|
|||||||
@@ -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
@@ -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()
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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(())));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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
@@ -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 (4–8 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)
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
@@ -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(|_| ()))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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))
|
||||||
|
и туннелем.
|
||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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 ещё палится (важно)
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
@@ -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()
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1 +1,6 @@
|
|||||||
|
//! Мелкие утилиты крейта, не относящиеся ни к одному из основных блоков.
|
||||||
|
//!
|
||||||
|
//! Сейчас здесь только [`u24`] — поддержка 24-битных целых, которые встречаются в
|
||||||
|
//! заголовках TLS-хендшейка (длина handshake-сообщения).
|
||||||
|
|
||||||
pub mod u24;
|
pub mod u24;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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
|
|
||||||
```
|
|
||||||
@@ -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
@@ -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"
|
||||||
|
}
|
||||||
|
]
|
||||||
@@ -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");
|
||||||
@@ -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 }
|
||||||
|
|||||||
@@ -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`).
|
||||||
@@ -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,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
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
@@ -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, "Задача сервера завершилась с паникой при остановке");
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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) = "a_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)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 соответствующих крейтов.
|
||||||
@@ -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();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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!")
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user