Compare commits

..

11 Commits

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

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

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

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

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

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

Нужные секреты: GHCR_TOKEN (тот же, что и в остальных репо), PROXY_NODES_SSH_KEY
(общий master-ключ на все ноды), PROXY_INTERNAL_SECRET, BACKEND_URL (те же
значения, что уже настроены у бэкенда).
2026-07-09 15:27:18 +07:00
16 changed files with 837 additions and 15 deletions
+24
View File
@@ -0,0 +1,24 @@
# Локальный dev/деплой-конфиг для Makefile (rsync/ssh-деплой на прод/dev-ноду,
# ADB для тестов на Android, --require-auth для debug-server). Скопировать в
# .env и заполнить реальными значениями — .env уже в .gitignore, коммитить
# нельзя.
# --- Прод-нода (make deploy / make restart и т.п.) ---
SERVER_IP=CHANGE_ME_PROD_NODE_IP
REMOTE_USER=root
REMOTE_PATH=/root/netr-core
SERVICE_NAME=netrunner-server
# --- Dev-нода (make deploy-dev и т.п.) ---
DEV_IP=CHANGE_ME_DEV_NODE_IP
# --- Android (ADB по Wi-Fi + сборка нативной либы для vpn-plugin) ---
ANDROID_ADB_HOST=CHANGE_ME_PHONE_LOCAL_IP
ANDROID_BUILD_SRC=/home/CHANGE_ME/netrunner/netrunner-proxy/gen
ANDROID_PROJECT_LIBS=/home/CHANGE_ME/netrunner/netrunner-app/src-tauri/src/plugins/vpn-plugin/android/src/main/jniLibs
# --- Дев-авторизация debug-server (make debug-server и т.п.) ---
# Тот же секрет, что и PROXY_INTERNAL_SECRET у netrunner-backend (.env.dev) —
# без совпадения бэкенд отклонит запросы валидации токена/отчёта о трафике.
PROXY_INTERNAL_SECRET=CHANGE_ME_SAME_AS_BACKEND_PROXY_INTERNAL_SECRET
DEV_BACKEND_URL=http://localhost:8080
+50
View File
@@ -0,0 +1,50 @@
# Собирает netrunner-server (release-бинарник) и пушит Docker-образ во
# встроенный Container Registry самой Gitea (gitea.netrunner-vpn.com), не
# ghcr.io. Dockerfile сам не компилирует Rust — он просто копирует уже
# готовый target/release/netrunner-server (см. Dockerfile), поэтому cargo
# build идёт отдельным шагом перед 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: Install Rust toolchain
uses: dtolnay/rust-toolchain@stable
- name: Cache cargo
uses: Swatinem/rust-cache@v2
- name: Build release binary
run: cargo build --release -p netrunner-server
- 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
+145
View File
@@ -0,0 +1,145 @@
# Деплой на прокси-ноды из nodes.json. Список серверов — сам файл в корне
# репозитория (name/host/proxy_port/decoy_host на каждую ноду), поддерживать
# его руками при добавлении/выводе серверов.
#
# Запуск только вручную (workflow_dispatch) — параметр target: имя ноды из
# nodes.json (поле "name") или "all" для обновления сразу всех. Job "resolve"
# фильтрует список по этому параметру и отдаёт job'у "deploy" как matrix —
# каждая нода тогда видна в прогоне отдельной строкой, с отдельным логом.
# GH/Gitea Actions не поддерживают выпадающий список (type: choice) с
# вариантами из файла — только статичные в самом yaml, а список нод меняется
# независимо от workflow, поэтому просто текстовое поле, значение — точное
# имя из nodes.json.
#
# Каждый деплой пересоздаёт контейнер (pull нового образа + rm + run) — не
# просто restart, иначе новый образ не подхватится, раз контейнер уже создан
# со старым слоем.
#
# Секреты (общие на все ноды — один master-ключ, как и при первичном
# провижининге через netrunner-backend, см. NodeService::provision_node_via_ssh):
# - PROXY_NODES_SSH_KEY — приватный SSH-ключ (root на все ноды)
# - PROXY_INTERNAL_SECRET — тот же секрет, что и PROXY_INTERNAL_SECRET у бэкенда
# - GT_REGISTRY_TOKEN — тот же, что и в build.yml (Container Registry Gitea,
# не ghcr.io — каждая нода тянет образ напрямую с gitea.netrunner-vpn.com)
#
# Repo Variable (vars.*, не secret — публичный URL, не даёт доступа сам по себе):
# - BACKEND_URL — публичный адрес control plane (WEB_APP_BASE_URL у бэкенда)
#
# Job update-observability-targets (см. ниже) обновляет список scrape-таргетов
# для центрального Prometheus (netrunner-data/docker-compose.observability.yml,
# job "netrunner-proxy-nodes") при КАЖДОМ прогоне (независимо от input target,
# т.к. использует полный nodes.json) — ему нужны СВОИ копии секретов/vars
# VPS с данными, продублировать в Settings этого репозитория (те же значения,
# что уже есть в netrunner-data):
# - vars.DATA_VPS_IP
# - secrets.DATA_SSH_PRIVATE_KEY
name: Deploy Proxy Nodes
on:
workflow_dispatch:
inputs:
target:
description: 'Имя ноды из nodes.json ("name"), или "all" — обновить сразу все'
required: true
default: "all"
jobs:
resolve:
runs-on: ubuntu-24.04
outputs:
targets: ${{ steps.filter.outputs.targets }}
steps:
- uses: actions/checkout@v4
- name: Filter nodes.json by input
id: filter
run: |
set -e
command -v jq >/dev/null || (apt-get update && apt-get install -y jq)
target="${{ github.event.inputs.target }}"
if [ "$target" = "all" ]; then
filtered=$(jq -c '.' nodes.json)
else
filtered=$(jq -c --arg n "$target" '[.[] | select(.name == $n)]' nodes.json)
fi
count=$(echo "$filtered" | jq 'length')
if [ "$count" -eq 0 ]; then
echo "Нода '$target' не найдена в nodes.json" >&2
exit 1
fi
echo "targets=$filtered" >> "$GITHUB_OUTPUT"
deploy:
needs: resolve
strategy:
fail-fast: false
matrix:
include: ${{ fromJson(needs.resolve.outputs.targets) }}
runs-on: ubuntu-24.04
steps:
- name: Deploy to ${{ matrix.name }} (${{ matrix.host }})
uses: appleboy/ssh-action@v1
with:
host: ${{ matrix.host }}
username: root
key: ${{ secrets.PROXY_NODES_SSH_KEY }}
script: |
set -e
echo "🚀 Деплой на ${{ matrix.name }} (${{ matrix.host }}:${{ matrix.proxy_port }})"
echo "${{ secrets.GT_REGISTRY_TOKEN }}" | docker login gitea.netrunner-vpn.com -u ${{ github.actor }} --password-stdin
docker pull gitea.netrunner-vpn.com/nineap/netrunner-proxy:latest
docker stop netrunner-proxy 2>/dev/null || true
docker rm netrunner-proxy 2>/dev/null || true
docker run -d \
--name netrunner-proxy \
--restart always \
--network host \
--cap-add NET_ADMIN --cap-add NET_RAW --cap-add DAC_OVERRIDE \
--device /dev/net/tun:/dev/net/tun \
-e PROXY_INTERNAL_SECRET="${{ secrets.PROXY_INTERNAL_SECRET }}" \
gitea.netrunner-vpn.com/nineap/netrunner-proxy:latest \
./netrunner-proxy \
--port "${{ matrix.proxy_port }}" \
--decoy-host "${{ matrix.decoy_host }}" \
--require-auth \
--backend-url "${{ vars.BACKEND_URL }}" \
--health-port 9091 \
--metrics-port 9093
docker image prune -f
echo "✅ ${{ matrix.name }} обновлена."
# Независимо от того, какая конкретно нода деплоилась (target может быть
# одной нодой, не "all") — список scrape-таргетов всегда генерится из ПОЛНОГО
# nodes.json, чтобы центральный Prometheus не терял из виду остальные ноды.
update-observability-targets:
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v4
- name: Generate targets/nodes.json from nodes.json
run: node scripts/generate-prometheus-targets.mjs nodes.json > nodes.targets.json
- name: Upload to observability VPS
uses: appleboy/scp-action@v0.1.7
with:
host: ${{ vars.DATA_VPS_IP }}
username: root
key: ${{ secrets.DATA_SSH_PRIVATE_KEY }}
source: "nodes.targets.json"
target: "/root/netrunner-data/observability/targets"
strip_components: 0
- name: Rename to nodes.json (file_sd_configs ждёт именно это имя)
uses: appleboy/ssh-action@v1
with:
host: ${{ vars.DATA_VPS_IP }}
username: root
key: ${{ secrets.DATA_SSH_PRIVATE_KEY }}
script: mv /root/netrunner-data/observability/targets/nodes.targets.json /root/netrunner-data/observability/targets/nodes.json
Generated
+186 -1
View File
@@ -572,6 +572,15 @@ dependencies = [
"crossbeam-utils", "crossbeam-utils",
] ]
[[package]]
name = "crossbeam-epoch"
version = "0.9.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2d6914041f254d6e9176c01941b21115dcfb7089e55135a35411081bd106ef3f"
dependencies = [
"crossbeam-utils",
]
[[package]] [[package]]
name = "crossbeam-utils" name = "crossbeam-utils"
version = "0.8.21" version = "0.8.21"
@@ -771,6 +780,17 @@ dependencies = [
"pin-project-lite", "pin-project-lite",
] ]
[[package]]
name = "evmap"
version = "11.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1b8874945f036109c72242964c1174cf99434e30cfa45bf45fedc983f50046f8"
dependencies = [
"hashbag",
"left-right",
"smallvec",
]
[[package]] [[package]]
name = "fastrand" name = "fastrand"
version = "2.4.1" version = "2.4.1"
@@ -929,6 +949,21 @@ dependencies = [
"slab", "slab",
] ]
[[package]]
name = "generator"
version = "0.8.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b3b854b0e584ead1a33f18b2fcad7cf7be18b3875c78816b753639aa501513ae"
dependencies = [
"cc",
"cfg-if",
"libc",
"log",
"rustversion",
"windows-link",
"windows-result",
]
[[package]] [[package]]
name = "generic-array" name = "generic-array"
version = "0.14.7" version = "0.14.7"
@@ -1025,6 +1060,12 @@ dependencies = [
"byteorder", "byteorder",
] ]
[[package]]
name = "hashbag"
version = "0.1.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7040a10f52cba493ddb09926e15d10a9d8a28043708a405931fe4c6f19fac064"
[[package]] [[package]]
name = "hashbrown" name = "hashbrown"
version = "0.14.5" version = "0.14.5"
@@ -1459,6 +1500,17 @@ version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2"
[[package]]
name = "left-right"
version = "0.11.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0f0c21e4c8ff95f487fb34e6f9182875f42c84cef966d29216bf115d9bba835a"
dependencies = [
"crossbeam-utils",
"loom",
"slab",
]
[[package]] [[package]]
name = "libc" name = "libc"
version = "0.2.184" version = "0.2.184"
@@ -1512,6 +1564,19 @@ version = "0.4.29"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"
[[package]]
name = "loom"
version = "0.7.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "419e0dc8046cb947daa77eb95ae174acfbddb7673b4151f56d1eed8e93fbfaca"
dependencies = [
"cfg-if",
"generator",
"scoped-tls",
"tracing",
"tracing-subscriber",
]
[[package]] [[package]]
name = "lru" name = "lru"
version = "0.16.3" version = "0.16.3"
@@ -1548,6 +1613,48 @@ version = "2.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79"
[[package]]
name = "metrics"
version = "0.24.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "89550ee9f79e88fef3119de263694973a8adb26c21d75322164fb8c493039fe2"
dependencies = [
"portable-atomic",
"rapidhash",
]
[[package]]
name = "metrics-exporter-prometheus"
version = "0.18.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1db0d8f1fc9e62caebd0319e11eaec5822b0186c171568f0480b46a0137f9108"
dependencies = [
"base64",
"evmap",
"indexmap",
"metrics",
"metrics-util",
"quanta",
"thiserror 2.0.18",
]
[[package]]
name = "metrics-util"
version = "0.20.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "96f8722f8562635f92f8ed992f26df0532266eb03d5202607c20c0d7e9745e13"
dependencies = [
"crossbeam-epoch",
"crossbeam-utils",
"hashbrown 0.16.1",
"metrics",
"quanta",
"rand 0.9.2",
"rand_xoshiro",
"rapidhash",
"sketches-ddsketch",
]
[[package]] [[package]]
name = "mime" name = "mime"
version = "0.3.17" version = "0.3.17"
@@ -1654,6 +1761,8 @@ dependencies = [
"async-trait", "async-trait",
"clap", "clap",
"dashmap", "dashmap",
"metrics",
"metrics-exporter-prometheus",
"netrunner-core", "netrunner-core",
"netrunner-logger", "netrunner-logger",
"reqwest", "reqwest",
@@ -1869,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"
@@ -2001,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"
@@ -2220,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"
@@ -2373,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"
@@ -2394,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",
@@ -3274,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"
@@ -3283,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"
+1 -1
View File
@@ -9,7 +9,7 @@ crate-type = ["cdylib", "rlib", "staticlib"]
[dependencies] [dependencies]
netrunner-core = { path = "../core" } netrunner-core = { path = "../core" }
smoltcp = { git = "ssh://git@github.com/nineAp/nxrp-smoltcp.git", default-features = false, features = [ smoltcp = { git = "https://github.com/nineAp/nxrp-smoltcp.git", default-features = false, features = [
"std", "std",
"log", "log",
"medium-ip", "medium-ip",
+14
View File
@@ -0,0 +1,14 @@
[
{
"name": "nl-1",
"host": "147.45.43.70",
"proxy_port": 443,
"decoy_host": "ubuntu.com"
},
{
"name": "fr-1",
"host": "77.110.106.113",
"proxy_port": 8443,
"decoy_host": "www.debian.org"
}
]
+33
View File
@@ -0,0 +1,33 @@
#!/usr/bin/env node
// Генерит targets-файл для Prometheus file_sd_configs (см.
// netrunner-data/observability/prometheus.yml, job "netrunner-proxy-nodes")
// из nodes.json этого репозитория — список нод меняется независимо от
// прод-конфига Prometheus, руками синхронизировать его на каждый деплой
// ноды неудобно и хрупко.
//
// Порт метрик у всех нод одинаковый (9093 — см. server/netrunner-server.service,
// templates/init_node.sh в netrunner-backend, .gitea/workflows/deploy.yml
// здесь же), меняется только host — поэтому в самом nodes.json отдельного
// поля под него нет.
//
// Использование: node scripts/generate-prometheus-targets.mjs nodes.json > targets.json
import { readFileSync } from "node:fs";
const METRICS_PORT = 9093;
const [, , nodesPath] = process.argv;
if (!nodesPath) {
console.error("Использование: generate-prometheus-targets.mjs <nodes.json>");
process.exit(1);
}
const nodes = JSON.parse(readFileSync(nodesPath, "utf-8"));
const targets = nodes
.filter((n) => !n.host?.startsWith("CHANGE_ME"))
.map((n) => ({
targets: [`${n.host}:${METRICS_PORT}`],
labels: { node: n.name },
}));
process.stdout.write(JSON.stringify(targets, null, 2) + "\n");
+2
View File
@@ -15,3 +15,5 @@ serde = { version = "1", features = ["derive"] }
reqwest = { version = "0.13.2", features = ["json"] } reqwest = { version = "0.13.2", features = ["json"] }
async-trait = "0.1.89" async-trait = "0.1.89"
dashmap = "6.1.0" dashmap = "6.1.0"
metrics = "0.24.3"
metrics-exporter-prometheus = { version = "0.18.1", default-features = false }
+4 -1
View File
@@ -5,10 +5,13 @@ 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=8443 ExecStart=/root/netr-core/netrunner-server --host=0.0.0.0 --port=8443 --health-port=9092 --metrics-port=9094
Restart=always Restart=always
RestartSec=3 RestartSec=3
LimitNOFILE=65535 LimitNOFILE=65535
# systemctl stop/restart шлёт SIGTERM — теперь сервер ловит его сам и
# дожидается отключения активных клиентов вместо мгновенного обрыва.
TimeoutStopSec=35
[Install] [Install]
WantedBy=multi-user.target WantedBy=multi-user.target
+5 -1
View File
@@ -5,10 +5,14 @@ After=network.target
[Service] [Service]
User=root User=root
WorkingDirectory=/root/netr-core WorkingDirectory=/root/netr-core
ExecStart=/root/netr-core/netrunner-server --host=0.0.0.0 --port=443 ExecStart=/root/netr-core/netrunner-server --host=0.0.0.0 --port=443 --health-port=9091 --metrics-port=9093
Restart=always Restart=always
RestartSec=3 RestartSec=3
LimitNOFILE=65535 LimitNOFILE=65535
# systemctl stop/restart шлёт SIGTERM — теперь сервер ловит его сам и
# дожидается отключения активных клиентов (см. server/src/main.rs,
# server/src/network.rs) вместо мгновенного обрыва по TimeoutStopSec.
TimeoutStopSec=35
[Install] [Install]
WantedBy=multi-user.target WantedBy=multi-user.target
+108 -2
View File
@@ -9,28 +9,108 @@
use async_trait::async_trait; use async_trait::async_trait;
use dashmap::DashMap; use dashmap::DashMap;
use netrunner_core::net::{AuthValidator, UsageReport, UserQuota}; use netrunner_core::net::{AuthValidator, UsageReport, UserQuota};
use netrunner_logger::AppError; use netrunner_logger::{warn, AppError};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::sync::Mutex;
use std::time::{Duration, Instant}; use std::time::{Duration, Instant};
const VALIDATE_CACHE_TTL: Duration = Duration::from_secs(60); 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 { pub struct BackendClient {
http: reqwest::Client, http: reqwest::Client,
base_url: String, base_url: String,
internal_secret: String, internal_secret: String,
validate_cache: DashMap<String, (UserQuota, Instant)>, validate_cache: DashMap<String, (UserQuota, Instant)>,
circuit: Circuit,
} }
impl BackendClient { impl BackendClient {
pub fn new(base_url: String, internal_secret: String) -> Self { pub fn new(base_url: String, internal_secret: String) -> Self {
Self { Self {
http: reqwest::Client::new(), http: reqwest::Client::builder()
.timeout(REQUEST_TIMEOUT)
.build()
.expect("Failed to build reqwest client"),
base_url: base_url.trim_end_matches('/').to_string(), base_url: base_url.trim_end_matches('/').to_string(),
internal_secret, internal_secret,
validate_cache: DashMap::new(), 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)] #[derive(Serialize)]
@@ -62,6 +142,7 @@ struct UsageResponse {
impl AuthValidator for BackendClient { impl AuthValidator for BackendClient {
async fn validate(&self, token: &str) -> Result<UserQuota, AppError> { async fn validate(&self, token: &str) -> Result<UserQuota, AppError> {
if token.is_empty() { if token.is_empty() {
metrics::counter!("netrunner_auth_failures_total").increment(1);
return Err(AppError::new( return Err(AppError::new(
netrunner_logger::ERR_AUTH_FAILED, netrunner_logger::ERR_AUTH_FAILED,
"Доступ запрещен", "Доступ запрещен",
@@ -76,6 +157,11 @@ impl AuthValidator for BackendClient {
} }
} }
if self.circuit.is_open() {
return Err(Self::circuit_open_error());
}
let request_start = Instant::now();
let resp = self let resp = self
.http .http
.post(format!("{}/api/v1/internal/validate", self.base_url)) .post(format!("{}/api/v1/internal/validate", self.base_url))
@@ -84,14 +170,24 @@ impl AuthValidator for BackendClient {
.send() .send()
.await .await
.map_err(|e| { .map_err(|e| {
self.circuit.record_failure();
AppError::new( AppError::new(
netrunner_logger::ERR_INFRA_TIMEOUT, netrunner_logger::ERR_INFRA_TIMEOUT,
"Бэкенд недоступен", "Бэкенд недоступен",
e.to_string(), 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() { if !resp.status().is_success() {
metrics::counter!("netrunner_auth_failures_total").increment(1);
return Err(AppError::new( return Err(AppError::new(
netrunner_logger::ERR_AUTH_FAILED, netrunner_logger::ERR_AUTH_FAILED,
"Доступ запрещен", "Доступ запрещен",
@@ -107,6 +203,7 @@ impl AuthValidator for BackendClient {
) )
})?; })?;
self.circuit.record_success();
let quota = UserQuota { let quota = UserQuota {
user_id: body.user_id, user_id: body.user_id,
limit_bytes: body.limit_bytes, limit_bytes: body.limit_bytes,
@@ -118,6 +215,10 @@ impl AuthValidator for BackendClient {
} }
async fn report_usage(&self, user_id: &str, delta_bytes: u64) -> Result<UsageReport, AppError> { 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 let resp = self
.http .http
.post(format!("{}/api/v1/internal/usage", self.base_url)) .post(format!("{}/api/v1/internal/usage", self.base_url))
@@ -129,6 +230,7 @@ impl AuthValidator for BackendClient {
.send() .send()
.await .await
.map_err(|e| { .map_err(|e| {
self.circuit.record_failure();
AppError::new( AppError::new(
netrunner_logger::ERR_INFRA_TIMEOUT, netrunner_logger::ERR_INFRA_TIMEOUT,
"Бэкенд недоступен", "Бэкенд недоступен",
@@ -136,6 +238,9 @@ impl AuthValidator for BackendClient {
) )
})?; })?;
if resp.status().is_server_error() {
self.circuit.record_failure();
}
if !resp.status().is_success() { if !resp.status().is_success() {
return Err(AppError::new( return Err(AppError::new(
netrunner_logger::ERR_INFRA_TIMEOUT, netrunner_logger::ERR_INFRA_TIMEOUT,
@@ -152,6 +257,7 @@ impl AuthValidator for BackendClient {
) )
})?; })?;
self.circuit.record_success();
Ok(UsageReport { Ok(UsageReport {
used_bytes: body.used_bytes, used_bytes: body.used_bytes,
limit_bytes: body.limit_bytes, limit_bytes: body.limit_bytes,
+3 -6
View File
@@ -10,8 +10,8 @@
//! [`ServerDiagnosticsLogger::snapshot_all_sessions`]). //! [`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_core::net::SessionManager;
use netrunner_logger::{error, info, warn}; use netrunner_logger::{error, info, warn};
@@ -192,10 +192,7 @@ 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()
);
} }
_ => {} _ => {}
} }
+62
View File
@@ -0,0 +1,62 @@
//! Минимальный HTTP `/health` для supervisor'а (systemd/docker healthcheck) —
//! отдельный порт, не тот, где слушается замаскированный туннельный протокол
//! (смешивать их нельзя: health-эндпоинт — обычный читаемый HTTP, это выдало
//! бы DPI ровно то, что декой должен скрывать). По умолчанию биндится только
//! на 127.0.0.1 — наружу торчать не должен, это внутренняя проверка для
//! supervisor'а/docker healthcheck на этой же машине.
//!
//! Не использует HTTP-фреймворк (лишняя зависимость ради одного эндпоинта) —
//! отвечает одним и тем же 200+JSON на любое подключение, не разбирая запрос.
use netrunner_logger::{error, info, warn};
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::Arc;
use tokio::io::AsyncWriteExt;
use tokio::net::TcpListener;
use tokio_util::sync::CancellationToken;
/// Отдаёт реальное состояние — не просто "процесс жив", а сколько сейчас
/// физических соединений реально обслуживается (см. `Network::run`,
/// `active_connections`).
pub async fn run(
host: String,
port: u16,
active_connections: Arc<AtomicU64>,
token: CancellationToken,
) {
let addr = format!("{host}:{port}");
let listener = match TcpListener::bind(&addr).await {
Ok(l) => l,
Err(e) => {
error!(error = %e, addr = %addr, "Не удалось поднять health-listener");
return;
}
};
info!("🩺 Health-эндпоинт слушает на {}", addr);
loop {
tokio::select! {
_ = token.cancelled() => {
info!("Health-listener остановлен по сигналу отмены.");
break;
}
res = listener.accept() => {
let Ok((mut stream, _)) = res else { continue };
let active = active_connections.load(Ordering::Relaxed);
tokio::spawn(async move {
let body = format!(
r#"{{"status":"ok","active_connections":{active}}}"#
);
let response = format!(
"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
body.len(),
body
);
if let Err(e) = stream.write_all(response.as_bytes()).await {
warn!(error = %e, "Health-listener: ошибка записи ответа");
}
});
}
}
}
}
+84 -3
View File
@@ -12,16 +12,47 @@
mod backend_client; mod backend_client;
mod diagnostics; mod diagnostics;
mod health;
mod metrics_server;
mod network; mod network;
use clap::Parser; use clap::Parser;
use netrunner_core::net::AuthValidator; use netrunner_core::net::AuthValidator;
use netrunner_logger::Logger; use netrunner_logger::{error, info, Logger};
use std::sync::Arc; use std::sync::Arc;
use tokio_util::sync::CancellationToken; use tokio_util::sync::CancellationToken;
use crate::backend_client::BackendClient; 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")]
@@ -52,6 +83,19 @@ struct Args {
/// Обязателен, только если передан `--require-auth`. /// Обязателен, только если передан `--require-auth`.
#[arg(long)] #[arg(long)]
backend_url: Option<String>, 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() {
@@ -70,11 +114,48 @@ fn main() {
None None
}; };
let net = Network::new(args.host, args.port, args.decoy_host, auth); // Регистрируется один раз, до первого metrics::counter!/gauge!/histogram! —
// если --metrics-port не задан, вызовы макросов молча уходят в
// no-op recorder по умолчанию (штатное поведение крейта metrics).
let metrics_handle = args
.metrics_port
.map(|_| metrics_server::install_recorder());
let net = Network::new(
args.host.clone(),
args.port,
args.decoy_host,
auth,
args.health_port,
);
let rt = tokio::runtime::Runtime::new().expect("Failed to create Tokio runtime"); let rt = tokio::runtime::Runtime::new().expect("Failed to create Tokio runtime");
rt.block_on(async { rt.block_on(async {
net.run(CancellationToken::new()).await; let token = CancellationToken::new();
let run_token = token.clone();
// net.run() спавним отдельной задачей и дожидаемся её ПОСЛЕ сигнала —
// tokio::select! между сигналом и run() тут не подходит: select
// дропает недовершившуюся ветку целиком, оборвав drain-фазу в
// Network::run в момент получения самого сигнала, вместо того чтобы
// дать ей отработать.
let run_handle = tokio::spawn(async move {
net.run(run_token).await;
});
if let (Some(port), Some(handle)) = (args.metrics_port, metrics_handle) {
let metrics_token = token.clone();
tokio::spawn(metrics_server::run(
"0.0.0.0".to_string(),
port,
handle,
metrics_token,
));
}
shutdown_signal(token).await;
if let Err(e) = run_handle.await {
error!(error = ?e, "Задача сервера завершилась с паникой при остановке");
}
}); });
} }
+60
View File
@@ -0,0 +1,60 @@
//! `/metrics` для центрального Prometheus (см.
//! `netrunner-data/docker-compose.observability.yml`) — в отличие от
//! `/health` (127.0.0.1-only, см. `health.rs`), этот порт публикуется наружу
//! и ОБЯЗАТЕЛЬНО должен быть зафайрволен на IP observability-VPS: любой,
//! кто до него дотянется, увидит число активных соединений/трафик этой
//! ноды (не секрет пользователей, но разведка для DPI/блокировщика).
//!
//! Тот же hand-rolled HTTP-паттерн, что и в `health.rs` (не тянуть HTTP-
//! фреймворк ради одного эндпоинта на бинарнике, для которого важен размер
//! и минимальные зависимости) — просто рендерит текущий снапшот метрик из
//! уже установленного `PrometheusHandle` на каждый коннект.
use metrics_exporter_prometheus::{PrometheusBuilder, PrometheusHandle};
use netrunner_logger::{error, info, warn};
use tokio::io::AsyncWriteExt;
use tokio::net::TcpListener;
use tokio_util::sync::CancellationToken;
/// Регистрирует глобальный recorder `metrics`-крейта — вызывать ровно один
/// раз при старте, до первого `metrics::counter!`/`gauge!`/`histogram!`.
pub fn install_recorder() -> PrometheusHandle {
PrometheusBuilder::new()
.install_recorder()
.expect("Failed to install Prometheus recorder")
}
pub async fn run(host: String, port: u16, handle: PrometheusHandle, token: CancellationToken) {
let addr = format!("{host}:{port}");
let listener = match TcpListener::bind(&addr).await {
Ok(l) => l,
Err(e) => {
error!(error = %e, addr = %addr, "Не удалось поднять metrics-листенер");
return;
}
};
info!("📊 Metrics-эндпоинт слушает на {}", addr);
loop {
tokio::select! {
_ = token.cancelled() => {
info!("Metrics-listener остановлен по сигналу отмены.");
break;
}
res = listener.accept() => {
let Ok((mut stream, _)) = res else { continue };
let body = handle.render();
tokio::spawn(async move {
let response = format!(
"HTTP/1.1 200 OK\r\nContent-Type: text/plain; version=0.0.4\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
body.len(),
body
);
if let Err(e) = stream.write_all(response.as_bytes()).await {
warn!(error = %e, "Metrics-listener: ошибка записи ответа");
}
});
}
}
}
}
+56
View File
@@ -11,11 +11,19 @@ use netrunner_core::net::{
TOPOLOGY_PRINT_INTERVAL, TOPOLOGY_PRINT_INTERVAL,
}; };
use netrunner_logger::{error, info, warn}; 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::{ClientDiagnosticsLogger, 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 {
@@ -27,6 +35,9 @@ pub struct Network {
/// `None` — `--require-auth` не передан, авторизация и лимиты трафика /// `None` — `--require-auth` не передан, авторизация и лимиты трафика
/// выключены на этом инстансе целиком (поведение как до этой фичи). /// выключены на этом инстансе целиком (поведение как до этой фичи).
auth: Option<Arc<dyn AuthValidator>>, auth: Option<Arc<dyn AuthValidator>>,
/// `None` — health-эндпоинт выключен (по умолчанию для обратной
/// совместимости с уже развёрнутыми нодами без этого флага).
health_port: Option<u16>,
} }
impl Network { impl Network {
@@ -35,12 +46,14 @@ impl Network {
port: u16, port: u16,
decoy_host: impl Into<Arc<str>>, decoy_host: impl Into<Arc<str>>,
auth: Option<Arc<dyn AuthValidator>>, auth: Option<Arc<dyn AuthValidator>>,
health_port: Option<u16>,
) -> Self { ) -> Self {
Self { Self {
host, host,
port, port,
decoy_host: decoy_host.into(), decoy_host: decoy_host.into(),
auth, auth,
health_port,
} }
} }
@@ -125,6 +138,23 @@ impl Network {
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() => {
@@ -145,6 +175,10 @@ impl Network {
self.auth.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();
@@ -153,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)
);
} }
} }