2 Commits
Author SHA1 Message Date
dokril bdf3f22b12 Merge branch 'codex/task-057-059'
Build and Deploy Gateway / deploy (push) Has been skipped
Build and Deploy Gateway / build-and-push (push) Failing after 17s
# Conflicts:
#	src/shared/versions.ts
#	test/web/style-boundaries.test.js
2026-08-31 05:20:57 +03:00
dokril 4d066cb879 Add native traffic inspection to Harbor Connect and Gateway 2026-08-31 05:19:15 +03:00
62 changed files with 10975 additions and 220 deletions
+2 -1
View File
@@ -4,12 +4,13 @@ CLIENT_UI_PORT=3456
CLIENT_PROXY_PORT=8082
HARBOR_GATEWAY_CONTROL_PORT=3456
BASE_IMAGE=debian:bookworm-slim
SINGBOX_VERSION=1.13.18
SINGBOX_VERSION=1.14.0-rc.5
INSTALL_RUNTIME_DEPS=true
INSTALL_SINGBOX=true
PROXY_PORT=8080
PROXY_BIND_IP=0.0.0.0
SING_BOX_API_PORT=19090
SING_BOX_TRAFFIC_SOURCE=snapshot
TPROXY_PORT=7895
TPROXY_MARK=1
TPROXY_TABLE=100
+1 -1
View File
@@ -18,7 +18,7 @@ env:
RUNTIME_BASE_SOURCE_IMAGE: mirror.gcr.io/library/debian:bookworm-slim
APT_MIRROR: http://mirror.yandex.ru/debian
APT_SECURITY_MIRROR: http://mirror.yandex.ru/debian-security
SINGBOX_VERSION: 1.13.18
SINGBOX_VERSION: 1.14.0-rc.5
jobs:
build-and-push:
+6 -2
View File
@@ -13,7 +13,7 @@ COPY monitoring/grafana/harbor-gateway.json ./monitoring/grafana/harbor-gateway.
RUN npm run build:production
FROM ${BASE_IMAGE}
ARG SINGBOX_VERSION=1.13.18
ARG SINGBOX_VERSION=1.14.0-rc.5
ARG INSTALL_RUNTIME_DEPS=true
ARG INSTALL_SINGBOX=true
@@ -46,6 +46,9 @@ RUN if [ "${INSTALL_SINGBOX}" = "true" ]; then \
WORKDIR /app
COPY --from=build /src/dist /app/dist
COPY --from=build /src/node_modules/@bufbuild/protobuf /app/node_modules/@bufbuild/protobuf
COPY --from=build /src/node_modules/@connectrpc/connect /app/node_modules/@connectrpc/connect
COPY --from=build /src/node_modules/@connectrpc/connect-node /app/node_modules/@connectrpc/connect-node
COPY package.json /app/package.json
COPY entrypoint.sh /entrypoint.sh
@@ -59,6 +62,7 @@ ENV PORT=3456 \
TPROXY_PORT=7895 \
DATA_DIR=/var/lib/vpn-proxy \
SING_BOX_CONFIG=/etc/sing-box/config.json \
SING_BOX_CACHE=/var/lib/sing-box/cache.db
SING_BOX_CACHE=/var/lib/sing-box/cache.db \
SING_BOX_TRAFFIC_SOURCE=snapshot
ENTRYPOINT ["dumb-init", "/entrypoint.sh"]
+5 -1
View File
@@ -13,7 +13,7 @@ COPY monitoring/grafana/harbor-gateway.json ./monitoring/grafana/harbor-gateway.
RUN npm run build:production
FROM ${RUNTIME_IMAGE}
ARG SINGBOX_VERSION=1.13.18
ARG SINGBOX_VERSION=1.14.0-rc.5
RUN apt-get update \
&& apt-get install -y --no-install-recommends ca-certificates curl dumb-init nodejs tar \
@@ -34,6 +34,9 @@ RUN set -eux; \
WORKDIR /app
COPY --from=build /src/dist /app/dist
COPY --from=build /src/node_modules/@bufbuild/protobuf /app/node_modules/@bufbuild/protobuf
COPY --from=build /src/node_modules/@connectrpc/connect /app/node_modules/@connectrpc/connect
COPY --from=build /src/node_modules/@connectrpc/connect-node /app/node_modules/@connectrpc/connect-node
COPY package.json /app/package.json
COPY entrypoint.client.sh /entrypoint.client.sh
@@ -49,6 +52,7 @@ ENV APP_MODE=client \
SING_BOX_CACHE=/var/lib/sing-box/cache.db \
RULE_SET_DOWNLOAD_DETOUR=vpn \
ROUTING_RU_DIRECT=true \
SING_BOX_TRAFFIC_SOURCE=native \
LOG_LEVEL=info
EXPOSE 3456 8082
+1 -1
View File
@@ -1,6 +1,6 @@
ARG BASE_IMAGE=mirror.gcr.io/library/debian:bookworm-slim
FROM ${BASE_IMAGE}
ARG SINGBOX_VERSION=1.13.18
ARG SINGBOX_VERSION=1.14.0-rc.5
ARG APT_MIRROR=http://mirror.yandex.ru/debian
ARG APT_SECURITY_MIRROR=http://mirror.yandex.ru/debian-security
ARG HTTP_PROXY
+29 -4
View File
@@ -132,6 +132,8 @@ curl -fsSL https://git.dokops.ru/dokril/vpn-proxy/raw/branch/master/install.sh |
Сам по себе локальный прокси не перенаправляет приложения автоматически. Адрес `127.0.0.1:8082` нужно указать в настройках нужного приложения или в системных настройках macOS.
Кнопка «Трафик» в правой панели показывает активные соединения, которые прошли через Harbor Connect. Данные о приложениях macOS недоступны, потому что sing-box работает внутри Docker.
### Другие порты
Передайте нужные значения при повторном запуске установщика:
@@ -269,12 +271,23 @@ curl -fsSL https://git.dokops.ru/dokril/vpn-proxy/raw/branch/master/install.sh |
| `PROXY_ALLOWED_CIDRS` | приватные IPv4-сети | Сети, которым разрешён доступ к Gateway Proxy |
| `GATEWAY_CLIENT_CIDRS` | приватные IPv4-сети | Сети, трафик которых Gateway может маршрутизировать |
| `DIRECT_TRAFFIC_MARK` | `0x40000000` | Зарезервированный одиночный connmark-бит учёта Direct; измените при конфликте с host QoS/firewall, не пересекаясь с `TPROXY_MARK` |
| `SING_BOX_TRAFFIC_SOURCE` | `snapshot` | Источник Gateway traffic counters: `snapshot`, `shadow` или `native` |
| `LOG_LEVEL` | `info` | Уровень подробности журнала |
Остальные значения в `.env.example` относятся к сборке контейнера и внутренней маршрутизации. Меняйте их только при нестандартном развёртывании.
После изменения `.env` пересоздайте контейнер командой `up -d` — обычного `restart` недостаточно.
`snapshot` сохраняет прежний опрос Clash API раз в 2 секунды. `shadow` дополнительно читает native lifecycle, но оставляет snapshot единственным источником публичных totals. `native` делает lifecycle единственным writer и не опрашивает `/connections`; Clash API остаётся только для selector/failover. Режим меняется только при пересоздании обоих Gateway-контейнеров и не переключается автоматически при ошибке.
Rollback сохраняет volumes и возвращает прежний writer:
```bash
SINGBOX_VERSION=1.13.18 \
SING_BOX_TRAFFIC_SOURCE=snapshot \
docker compose -f docker-compose.gateway.yml up -d --build
```
## Prometheus и Grafana
Gateway публикует уже накопленные Harbor traffic counters по адресу `http://<gateway>:3456/metrics`. Scrape не запускает дополнительный netfilter read и не меняет сохранённое состояние. Harbor обновляет snapshot раз в 15 секунд, поэтому рекомендуемый начальный scrape interval и refresh dashboard — 30 секунд:
@@ -295,9 +308,11 @@ scrape_configs:
Dashboard начинает со скорости скачивания и отправки в конце выбранного периода, общего трафика и VPN / Direct внутри sing-box за этот период. Для стандартного диапазона, который заканчивается сейчас, карточки скорости показывают текущее значение. Единый фильтр `Устройства` по умолчанию охватывает все устройства, но позволяет выбрать одно; он управляет скоростью, общим трафиком, маршрутами, сервисами, доменами и технической детализацией. Таблица «Все устройства за период» намеренно остаётся общей: она показывает все устройства с ненулевым трафиком, сортируется в обе стороны и выбирает устройство в том же фильтре. Блок «Куда уходит трафик» показывает основные назначения и Top-15 доменов без пагинации. Свёрнутая техническая детализация отдельно показывает точки входа Gateway / Proxy и Direct IPv4 мимо sing-box. Автообновление настроено на 30 секунд; индикатор показывает возраст самого старого из контуров общего, domain / sing-box и Direct IPv4 трафика, предупреждает после 60 секунд и считает данные устаревшими после 120 секунд.
Domain и sing-box outbound counters снимаются с активных соединений раз в 2 секунды и хранятся в памяти dataplane до его перезапуска; историю и retention хранит Prometheus. Перед routing sing-box до 1 секунды распознаёт HTTP Host, TLS SNI и QUIC Server Name. YouTube и OpenAI / ChatGPT объединяются по известным связанным доменам в label `service`, остальные значения сохраняют домен как имя сервиса. Если устройство и Harbor source известны, но hostname недоступен (например, ECH или IP-only), трафик попадает в `domain="_unknown",service="Не распознано"` и не теряется. Новые domain series сверх process limit складываются в `_other`.
В `snapshot` и `shadow` domain и sing-box outbound counters снимаются с активных соединений раз в 2 секунды. В `native` dataplane получает полный lifecycle, включая короткие соединения и финальный хвост; данные всё равно хранятся в памяти только до перезапуска, а историю и retention хранит Prometheus. Перед routing sing-box до 1 секунды распознаёт HTTP Host, TLS SNI и QUIC Server Name. YouTube и OpenAI / ChatGPT объединяются по известным связанным доменам в label `service`, остальные значения сохраняют домен как имя сервиса. Если устройство и Harbor source известны, но hostname недоступен (например, ECH или IP-only), трафик попадает в `domain="_unknown",service="Не распознано"` и не теряется. Новые domain series сверх process limit складываются в `_other`.
Direct IPv4 считает L3 packet bytes с IP-заголовками и retransmit, а sing-box tracker — логические TCP/UDP bytes без tunnel overhead. Эти семейства нельзя складывать в один «точный общий трафик». Snapshot polling может пропустить короткие соединения и финальный хвост; IPv6, трафик вне Gateway, назначения из `BYPASS_CIDRS` и quota провайдера не входят в новый route split.
Состояние collector и сравнение `shadow` экспортируются отдельными bounded gauges `harbor_traffic_collector_*` и `harbor_traffic_shadow_*`. Они не содержат UUID, IP, домены или пользовательские имена и не заменяют canonical traffic counters.
Direct IPv4 считает L3 packet bytes с IP-заголовками и retransmit, а sing-box tracker — логические TCP/UDP bytes без tunnel overhead. Эти семейства нельзя складывать в один «точный общий трафик». Snapshot polling может пропустить короткие соединения и финальный хвост; native lifecycle закрывает этот разрыв только для трафика, вошедшего в sing-box. IPv6, трафик вне Gateway, назначения из `BYPASS_CIDRS` и quota провайдера не входят в новый route split.
Готовый dashboard: [`monitoring/grafana/harbor-gateway.json`](monitoring/grafana/harbor-gateway.json). При импорте Grafana попросит выбрать Prometheus data source. Та же конфигурация и dashboard доступны для копирования в Gateway drawer «Как использовать» → «Prometheus и Grafana».
@@ -344,14 +359,24 @@ docker compose -f docker-compose.client.local.yml config
docker compose -f docker-compose.client.local.yml up -d --build
```
Интерфейс доступен на `http://127.0.0.1:3457`, HTTP/SOCKS5-прокси — на `127.0.0.1:8083`. Остановить и удалить только тестовый стек можно командой:
Интерфейс доступен на `http://127.0.0.1:3457`, HTTP/SOCKS5-прокси — на `127.0.0.1:8083`. Остановить тестовый стек с сохранением его volumes можно командой:
```bash
docker compose -f docker-compose.client.local.yml down -v
docker compose -f docker-compose.client.local.yml down
```
Порты можно заменить через `LOCAL_CLIENT_UI_PORT` и `LOCAL_CLIENT_PROXY_PORT`.
Для rollback canary на стабильный sing-box без инспектора используйте:
```bash
SINGBOX_VERSION=1.13.18 \
SING_BOX_TRAFFIC_SOURCE=disabled \
docker compose -f docker-compose.client.local.yml up -d --build
```
Не добавляйте `-v` к `down`, если хотите сохранить тестовые подписки и настройки.
## Служебные команды
Этот раздел нужен тем, кто собирает, проверяет или развёртывает сам проект. Для обычного использования он не требуется.
+9
View File
@@ -0,0 +1,9 @@
version: v2
clean: true
inputs:
- directory: proto/sing-box/v1.14.0-rc.5
plugins:
- local: protoc-gen-es
out: src/server/generated
opt:
- target=ts
+2 -1
View File
@@ -4,7 +4,7 @@ services:
context: .
dockerfile: Dockerfile.client
args:
SINGBOX_VERSION: ${SINGBOX_VERSION:-1.13.18}
SINGBOX_VERSION: ${SINGBOX_VERSION:-1.14.0-rc.5}
container_name: harbor-connect
environment:
APP_MODE: client
@@ -14,6 +14,7 @@ services:
DATA_DIR: /var/lib/vpn-proxy
SING_BOX_CONFIG: /etc/sing-box/config.json
SING_BOX_CACHE: /var/lib/sing-box/cache.db
SING_BOX_TRAFFIC_SOURCE: ${SING_BOX_TRAFFIC_SOURCE:-native}
HARBOR_HOST_NETWORK_STATE: /run/harbor-host/network.json
HARBOR_GATEWAY_CONTROL_PORT: ${HARBOR_GATEWAY_CONTROL_PORT:-3456}
LOG_LEVEL: ${LOG_LEVEL:-info}
+5 -1
View File
@@ -5,7 +5,7 @@ x-gateway-image: &gateway-image
dockerfile: Dockerfile
args:
BASE_IMAGE: ${BASE_IMAGE:-debian:bookworm-slim}
SINGBOX_VERSION: ${SINGBOX_VERSION:-1.13.18}
SINGBOX_VERSION: ${SINGBOX_VERSION:-1.14.0-rc.5}
INSTALL_RUNTIME_DEPS: ${INSTALL_RUNTIME_DEPS:-true}
INSTALL_SINGBOX: ${INSTALL_SINGBOX:-true}
@@ -25,6 +25,9 @@ services:
DATA_DIR: /var/lib/vpn-proxy
SING_BOX_CONFIG: /var/lib/vpn-proxy/sing-box-config.json
SING_BOX_CACHE: /var/lib/sing-box/cache.db
SING_BOX_TRAFFIC_SOURCE: ${SING_BOX_TRAFFIC_SOURCE:-snapshot}
SING_BOX_API_SECRET: /var/lib/sing-box/api.secret
SING_BOX_RUNTIME_CONFIG: /var/lib/sing-box/runtime-config.json
DATAPLANE_SOCKET: /run/vpn-proxy/dataplane.sock
volumes:
- vpn-proxy-data:/var/lib/vpn-proxy
@@ -49,6 +52,7 @@ services:
DATA_DIR: /var/lib/vpn-proxy
SING_BOX_CONFIG: /var/lib/vpn-proxy/sing-box-config.json
SING_BOX_CACHE: /var/lib/sing-box/cache.db
SING_BOX_TRAFFIC_SOURCE: ${SING_BOX_TRAFFIC_SOURCE:-snapshot}
DATAPLANE_SOCKET: /run/vpn-proxy/dataplane.sock
ports:
- "${PORT:-3456}:${PORT:-3456}"
+241
View File
@@ -8,6 +8,9 @@
"name": "vpn-proxy-gateway",
"version": "0.1.0",
"dependencies": {
"@bufbuild/protobuf": "2.6.0",
"@connectrpc/connect": "2.0.3",
"@connectrpc/connect-node": "2.0.3",
"@vitejs/plugin-react": "^5.0.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
@@ -15,6 +18,8 @@
},
"devDependencies": {
"@babel/parser": "7.29.3",
"@bufbuild/buf": "1.47.2",
"@bufbuild/protoc-gen-es": "2.6.0",
"@csstools/selector-specificity": "6.0.0",
"@types/node": "22.19.17",
"@types/node18": "npm:@types/node@18.19.130",
@@ -288,6 +293,229 @@
"node": ">=6.9.0"
}
},
"node_modules/@bufbuild/buf": {
"version": "1.47.2",
"resolved": "https://registry.npmjs.org/@bufbuild/buf/-/buf-1.47.2.tgz",
"integrity": "sha512-glY5kCAoO4+a7HvDb+BLOdoHSdCk4mdXdkp53H8JFz7maOnkxCiHHXgRX+taFyEu25N8ybn7NjZFrZSdRwq2sA==",
"dev": true,
"hasInstallScript": true,
"license": "Apache-2.0",
"bin": {
"buf": "bin/buf",
"protoc-gen-buf-breaking": "bin/protoc-gen-buf-breaking",
"protoc-gen-buf-lint": "bin/protoc-gen-buf-lint"
},
"engines": {
"node": ">=12"
},
"optionalDependencies": {
"@bufbuild/buf-darwin-arm64": "1.47.2",
"@bufbuild/buf-darwin-x64": "1.47.2",
"@bufbuild/buf-linux-aarch64": "1.47.2",
"@bufbuild/buf-linux-armv7": "1.47.2",
"@bufbuild/buf-linux-x64": "1.47.2",
"@bufbuild/buf-win32-arm64": "1.47.2",
"@bufbuild/buf-win32-x64": "1.47.2"
}
},
"node_modules/@bufbuild/buf-darwin-arm64": {
"version": "1.47.2",
"resolved": "https://registry.npmjs.org/@bufbuild/buf-darwin-arm64/-/buf-darwin-arm64-1.47.2.tgz",
"integrity": "sha512-74WerFn06y+azgVfsnzhfbI5wla/OLPDnIvaNJBWHaqya/3bfascJkDylW2GVNHmwG1K/cscpmcc/RJPaO7ntQ==",
"cpu": [
"arm64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@bufbuild/buf-darwin-x64": {
"version": "1.47.2",
"resolved": "https://registry.npmjs.org/@bufbuild/buf-darwin-x64/-/buf-darwin-x64-1.47.2.tgz",
"integrity": "sha512-adAiOacOQe8Ym/YXPCEiq9mrPeKRmDtF2TgqPWTcDy6mF7TqR7hMJINkEEuMd1EeACmXnzMOnXlm9ICtvdYgPg==",
"cpu": [
"x64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@bufbuild/buf-linux-aarch64": {
"version": "1.47.2",
"resolved": "https://registry.npmjs.org/@bufbuild/buf-linux-aarch64/-/buf-linux-aarch64-1.47.2.tgz",
"integrity": "sha512-52vY+Owffr5diw2PyfQJqH+Fld6zW6NhNZak4zojvc2MjZKubWM0TfNyM9jXz2YrwyB+cyxkabE60nBI80m37w==",
"cpu": [
"arm64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@bufbuild/buf-linux-armv7": {
"version": "1.47.2",
"resolved": "https://registry.npmjs.org/@bufbuild/buf-linux-armv7/-/buf-linux-armv7-1.47.2.tgz",
"integrity": "sha512-g9KtpObDeHZ/VG/0b5ZCieOao7L/WYZ0fPqFSs4N07D3APgEDhJG6vLyUcDgJMDgyLcgkNjNz0+XdYQb/tXyQw==",
"cpu": [
"arm"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@bufbuild/buf-linux-x64": {
"version": "1.47.2",
"resolved": "https://registry.npmjs.org/@bufbuild/buf-linux-x64/-/buf-linux-x64-1.47.2.tgz",
"integrity": "sha512-MODCK2BzD1Mgoyr+5Sp8xA8qMNdytj8hYheyhA5NnCGTkQf8sfqAjpBSAAmKk6Zar8HOlVXML6tzE/ioDFFGwQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@bufbuild/buf-win32-arm64": {
"version": "1.47.2",
"resolved": "https://registry.npmjs.org/@bufbuild/buf-win32-arm64/-/buf-win32-arm64-1.47.2.tgz",
"integrity": "sha512-563YKYWJl3LrCY3G3+zuhb8HwOs6DzWslwGPFkKV2hwHyWyvd1DR1JjiLvw9zX64IKNctQ0HempSqc3kcboaqQ==",
"cpu": [
"arm64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@bufbuild/buf-win32-x64": {
"version": "1.47.2",
"resolved": "https://registry.npmjs.org/@bufbuild/buf-win32-x64/-/buf-win32-x64-1.47.2.tgz",
"integrity": "sha512-Sqcdv7La2xBDh3bTdEYb2f4UTMMqCcYe/D0RELhvQ5wDn6I35V3/2YT1OF5fRuf0BZLCo0OdO37S9L47uHSz2g==",
"cpu": [
"x64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@bufbuild/protobuf": {
"version": "2.6.0",
"resolved": "https://registry.npmjs.org/@bufbuild/protobuf/-/protobuf-2.6.0.tgz",
"integrity": "sha512-6cuonJVNOIL7lTj5zgo/Rc2bKAo4/GvN+rKCrUj7GdEHRzCk8zKOfFwUsL9nAVk5rSIsRmlgcpLzTRysopEeeg==",
"license": "(Apache-2.0 AND BSD-3-Clause)"
},
"node_modules/@bufbuild/protoc-gen-es": {
"version": "2.6.0",
"resolved": "https://registry.npmjs.org/@bufbuild/protoc-gen-es/-/protoc-gen-es-2.6.0.tgz",
"integrity": "sha512-sKvgGndyw1stawiDKMLZyilj1BzMuUTlvyrBiDnzxGIjCMK4hoE0DsVBiqCuTFqENnLmEGdy+huOZ5KgQAGlFA==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"@bufbuild/protobuf": "^2.6.0",
"@bufbuild/protoplugin": "2.6.0"
},
"bin": {
"protoc-gen-es": "bin/protoc-gen-es"
},
"engines": {
"node": ">=14"
},
"peerDependencies": {
"@bufbuild/protobuf": "2.6.0"
},
"peerDependenciesMeta": {
"@bufbuild/protobuf": {
"optional": true
}
}
},
"node_modules/@bufbuild/protoplugin": {
"version": "2.6.0",
"resolved": "https://registry.npmjs.org/@bufbuild/protoplugin/-/protoplugin-2.6.0.tgz",
"integrity": "sha512-mfAwI+4GqUtbw/ddfyolEHaAL86ozRIVlOg2A+SVRbjx1CjsMc1YJO+hBSkt/pqfpR+PmWBbZLstHbXP8KGtMQ==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"@bufbuild/protobuf": "2.6.0",
"@typescript/vfs": "^1.5.2",
"typescript": "5.4.5"
}
},
"node_modules/@bufbuild/protoplugin/node_modules/typescript": {
"version": "5.4.5",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.4.5.tgz",
"integrity": "sha512-vcI4UpRgg81oIRUFwR0WSIHKt11nJ7SAVlYNIu+QpqeyXP+gpQJy/Z4+F0aGxSE4MqwjyXvW/TzgkLAx2AGHwQ==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
},
"engines": {
"node": ">=14.17"
}
},
"node_modules/@connectrpc/connect": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/@connectrpc/connect/-/connect-2.0.3.tgz",
"integrity": "sha512-jAbVMHVtDCydGt2P20VpmLjbLtERqSV0RMSyQF3k2zhK8pzQ2QaCAcyVhufClqrOAFZUKL5BqVYtttaxvhmRgg==",
"license": "Apache-2.0",
"peerDependencies": {
"@bufbuild/protobuf": "^2.2.0"
}
},
"node_modules/@connectrpc/connect-node": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/@connectrpc/connect-node/-/connect-node-2.0.3.tgz",
"integrity": "sha512-GZ8WXBCeoZY31wzmnrrV4IA0nvYzEwqt9yHg304b7y/ovKh0IEbBuSWbee/hJu2Tt7PD0C8D4WUwheECCeLpQA==",
"license": "Apache-2.0",
"engines": {
"node": ">=18.14.1"
},
"peerDependencies": {
"@bufbuild/protobuf": "^2.2.0",
"@connectrpc/connect": "2.0.3"
}
},
"node_modules/@csstools/selector-specificity": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/@csstools/selector-specificity/-/selector-specificity-6.0.0.tgz",
@@ -1538,6 +1766,19 @@
"node": ">=16.20.0"
}
},
"node_modules/@typescript/vfs": {
"version": "1.6.4",
"resolved": "https://registry.npmjs.org/@typescript/vfs/-/vfs-1.6.4.tgz",
"integrity": "sha512-PJFXFS4ZJKiJ9Qiuix6Dz/OwEIqHD7Dme1UwZhTK11vR+5dqW2ACbdndWQexBzCx+CPuMe5WBYQWCsFyGlQLlQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"debug": "^4.4.3"
},
"peerDependencies": {
"typescript": "*"
}
},
"node_modules/@vitejs/plugin-react": {
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-5.2.0.tgz",
+6
View File
@@ -6,6 +6,7 @@
"description": "Gateway-first VPN proxy control panel for sing-box TProxy deployments.",
"scripts": {
"dev": "vite --host 0.0.0.0",
"generate:singbox-api": "XDG_CACHE_HOME=${TMPDIR:-/tmp}/harbor-buf-cache buf generate --template buf.gen.yaml",
"build": "vite build",
"build:production": "npm run build && npm run build:server",
"build:server": "tsc -p tsconfig.server.json",
@@ -18,6 +19,9 @@
"start": "node dist/server/main.js"
},
"dependencies": {
"@bufbuild/protobuf": "2.6.0",
"@connectrpc/connect": "2.0.3",
"@connectrpc/connect-node": "2.0.3",
"@vitejs/plugin-react": "^5.0.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
@@ -25,6 +29,8 @@
},
"devDependencies": {
"@babel/parser": "7.29.3",
"@bufbuild/buf": "1.47.2",
"@bufbuild/protoc-gen-es": "2.6.0",
"@csstools/selector-specificity": "6.0.0",
"@types/node": "22.19.17",
"@types/node18": "npm:@types/node@18.19.130",
@@ -0,0 +1,808 @@
syntax = "proto3";
package daemon;
option go_package = "github.com/sagernet/sing-box/daemon";
import "google/protobuf/empty.proto";
service StartedService {
rpc GetVersion(google.protobuf.Empty) returns(Version) {}
rpc SubscribeServiceStatus(google.protobuf.Empty) returns(stream ServiceStatus) {}
rpc SubscribeLog(google.protobuf.Empty) returns(stream Log) {}
rpc GetDefaultLogLevel(google.protobuf.Empty) returns(DefaultLogLevel) {}
rpc ClearLogs(google.protobuf.Empty) returns(google.protobuf.Empty) {}
rpc SubscribeStatus(SubscribeStatusRequest) returns(stream Status) {}
rpc SubscribeGroups(google.protobuf.Empty) returns(stream Groups) {}
rpc GetClashModeStatus(google.protobuf.Empty) returns(ClashModeStatus) {}
rpc SubscribeClashMode(google.protobuf.Empty) returns(stream ClashMode) {}
rpc SetClashMode(ClashMode) returns(google.protobuf.Empty) {}
rpc URLTest(URLTestRequest) returns(google.protobuf.Empty) {}
rpc SelectOutbound(SelectOutboundRequest) returns (google.protobuf.Empty) {}
rpc SetGroupExpand(SetGroupExpandRequest) returns (google.protobuf.Empty) {}
rpc SubscribeConnections(SubscribeConnectionsRequest) returns(stream ConnectionEvents) {}
rpc CloseConnection(CloseConnectionRequest) returns(google.protobuf.Empty) {}
rpc CloseAllConnections(google.protobuf.Empty) returns(google.protobuf.Empty) {}
rpc GetDeprecatedWarnings(google.protobuf.Empty) returns(DeprecatedWarnings) {}
rpc GetStartedAt(google.protobuf.Empty) returns(StartedAt) {}
rpc SubscribeOutbounds(google.protobuf.Empty) returns (stream OutboundList) {}
rpc StartNetworkQualityTest(NetworkQualityTestRequest) returns (stream NetworkQualityTestProgress) {}
rpc StartSTUNTest(STUNTestRequest) returns (stream STUNTestProgress) {}
rpc SubscribeTailscaleStatus(google.protobuf.Empty) returns (stream TailscaleStatusUpdate) {}
rpc StartTailscalePing(TailscalePingRequest) returns (stream TailscalePingResponse) {}
rpc SetTailscaleExitNode(SetTailscaleExitNodeRequest) returns (google.protobuf.Empty) {}
rpc TailscaleLogout(TailscaleLogoutRequest) returns (google.protobuf.Empty) {}
rpc GetTailscaleCertificate(TailscaleCertificateRequest) returns (TailscaleCertificate) {}
rpc StartTailscaleSSHSession(stream TailscaleSSHClientMessage) returns (stream TailscaleSSHServerMessage) {}
rpc SubscribeTaildropInbox(SubscribeTaildropInboxRequest) returns (stream TaildropInbox) {}
rpc MarkTaildropInboxRead(MarkTaildropInboxReadRequest) returns (google.protobuf.Empty) {}
rpc SendTaildropFiles(stream TaildropSendClientMessage) returns (stream TaildropSendServerMessage) {}
rpc DownloadTaildropFile(DownloadTaildropFileRequest) returns (stream DownloadTaildropFileChunk) {}
rpc DeleteTaildropFile(DeleteTaildropFileRequest) returns (google.protobuf.Empty) {}
rpc CancelTaildropReceiving(CancelTaildropReceivingRequest) returns (google.protobuf.Empty) {}
rpc ProvideUSBDevices(stream USBProviderMessage) returns (stream USBServerMessage) {}
rpc SubscribeUSBIPServerStatus(google.protobuf.Empty) returns (stream USBIPServerStatusUpdate) {}
rpc SubscribeOpenConnectStatus(google.protobuf.Empty) returns (stream OpenConnectStatusUpdate) {}
rpc SubmitOpenConnectAuthResponse(OpenConnectAuthResponseSubmission) returns (google.protobuf.Empty) {}
rpc CancelOpenConnectAuthChallenge(OpenConnectAuthChallengeCancel) returns (google.protobuf.Empty) {}
rpc SubscribeOpenVPNStatus(google.protobuf.Empty) returns (stream OpenVPNStatusUpdate) {}
rpc SubmitOpenVPNChallengeResponse(OpenVPNChallengeSubmission) returns (google.protobuf.Empty) {}
rpc CancelOpenVPNChallenge(OpenVPNChallengeCancel) returns (google.protobuf.Empty) {}
rpc SubscribeNotifications(google.protobuf.Empty) returns (stream NotificationEvent) {}
}
message Version {
string version = 1;
int32 apiVersion = 2;
}
message ServiceStatus {
enum Type {
IDLE = 0;
STARTING = 1;
STARTED = 2;
STOPPING = 3;
FATAL = 4;
}
Type status = 1;
string errorMessage = 2;
}
message SubscribeStatusRequest {
int64 interval = 1;
}
enum LogLevel {
PANIC = 0;
FATAL = 1;
ERROR = 2;
WARN = 3;
INFO = 4;
DEBUG = 5;
TRACE = 6;
}
message Log {
repeated Message messages = 1;
bool reset = 2;
message Message {
LogLevel level = 1;
string message = 2;
}
}
message DefaultLogLevel {
LogLevel level = 1;
}
message Status {
uint64 memory = 1;
int32 goroutines = 2;
int32 connectionsIn = 3;
int32 connectionsOut = 4;
bool trafficAvailable = 5;
int64 uplink = 6;
int64 downlink = 7;
int64 uplinkTotal = 8;
int64 downlinkTotal = 9;
}
message Groups {
repeated Group group = 1;
}
message Group {
string tag = 1;
string type = 2;
bool selectable = 3;
string selected = 4;
bool isExpand = 5;
repeated GroupItem items = 6;
}
message GroupItem {
string tag = 1;
string type = 2;
int64 urlTestTime = 3;
int32 urlTestDelay = 4;
}
message URLTestRequest {
string outboundTag = 1;
}
message SelectOutboundRequest {
string groupTag = 1;
string outboundTag = 2;
}
message SetGroupExpandRequest {
string groupTag = 1;
bool isExpand = 2;
}
message ClashMode {
string mode = 3;
}
message ClashModeStatus {
repeated string modeList = 1;
string currentMode = 2;
}
message SubscribeConnectionsRequest {
int64 interval = 1;
}
enum ConnectionEventType {
CONNECTION_EVENT_NEW = 0;
CONNECTION_EVENT_UPDATE = 1;
CONNECTION_EVENT_CLOSED = 2;
}
message ConnectionEvent {
ConnectionEventType type = 1;
string id = 2;
Connection connection = 3;
int64 uplinkDelta = 4;
int64 downlinkDelta = 5;
int64 closedAt = 6;
}
message ConnectionEvents {
repeated ConnectionEvent events = 1;
bool reset = 2;
}
message Connection {
string id = 1;
string inbound = 2;
string inboundType = 3;
int32 ipVersion = 4;
string network = 5;
string source = 6;
string destination = 7;
string domain = 8;
string protocol = 9;
string user = 10;
string fromOutbound = 11;
int64 createdAt = 12;
int64 closedAt = 13;
int64 uplink = 14;
int64 downlink = 15;
int64 uplinkTotal = 16;
int64 downlinkTotal = 17;
string rule = 18;
string outbound = 19;
string outboundType = 20;
repeated string chainList = 21;
ProcessInfo processInfo = 22;
}
message ProcessInfo {
uint32 processId = 1;
int32 userId = 2;
string userName = 3;
string processPath = 4;
repeated string packageNames = 5;
}
message CloseConnectionRequest {
string id = 1;
}
message DeprecatedWarnings {
repeated DeprecatedWarning warnings = 1;
}
message DeprecatedWarning {
string message = 1;
bool impending = 2;
string migrationLink = 3;
string description = 4;
string deprecatedVersion = 5;
string scheduledVersion = 6;
}
message StartedAt {
int64 startedAt = 1;
}
message OutboundList {
repeated GroupItem outbounds = 1;
}
message NetworkQualityTestRequest {
string configURL = 1;
string outboundTag = 2;
bool serial = 3;
int32 maxRuntimeSeconds = 4;
bool http3 = 5;
}
message NetworkQualityTestProgress {
int32 phase = 1;
int64 downloadCapacity = 2;
int64 uploadCapacity = 3;
int32 downloadRPM = 4;
int32 uploadRPM = 5;
int32 idleLatencyMs = 6;
int64 elapsedMs = 7;
bool isFinal = 8;
string error = 9;
int32 downloadCapacityAccuracy = 10;
int32 uploadCapacityAccuracy = 11;
int32 downloadRPMAccuracy = 12;
int32 uploadRPMAccuracy = 13;
}
message STUNTestRequest {
string server = 1;
string outboundTag = 2;
}
message STUNTestProgress {
int32 phase = 1;
string externalAddr = 2;
int32 latencyMs = 3;
int32 natMapping = 4;
int32 natFiltering = 5;
bool isFinal = 6;
string error = 7;
bool natTypeSupported = 8;
}
message TailscaleStatusUpdate {
repeated TailscaleEndpointStatus endpoints = 1;
}
message TailscaleEndpointStatus {
string endpointTag = 1;
string backendState = 2;
string stateText = 3;
string authURL = 4;
string networkName = 5;
string magicDNSSuffix = 6;
TailscalePeer self = 7;
repeated TailscaleUserGroup userGroups = 8;
TailscalePeer exitNode = 9;
bool keyAuth = 10;
bool canShareFiles = 11;
int32 waitingFileCount = 12;
int32 receivingFileCount = 13;
int32 unreadFileCount = 14;
repeated string certDomains = 15;
}
message TailscaleUserGroup {
int64 userID = 1;
string loginName = 2;
string displayName = 3;
string profilePicURL = 4;
repeated TailscalePeer peers = 5;
}
message TailscalePeer {
string hostName = 1;
string dnsName = 2;
string os = 3;
repeated string tailscaleIPs = 4;
bool online = 5;
bool exitNode = 6;
bool exitNodeOption = 7;
bool active = 8;
int64 rxBytes = 9;
int64 txBytes = 10;
int64 keyExpiry = 11;
string stableID = 12;
bool expired = 13;
repeated string sshHostKeys = 14;
bool shareeNode = 15;
int64 lastSeen = 16;
bool canReceiveFiles = 17;
}
message TailscalePingRequest {
string endpointTag = 1;
string peerIP = 2;
}
message TailscalePingResponse {
double latencyMs = 1;
bool isDirect = 2;
string endpoint = 3;
int32 derpRegionID = 4;
string derpRegionCode = 5;
string error = 6;
string peerRelay = 7;
}
message SetTailscaleExitNodeRequest {
string endpointTag = 1;
string stableID = 2;
}
message TailscaleLogoutRequest {
string endpointTag = 1;
}
message TailscaleCertificateRequest {
string endpointTag = 1;
string domain = 2;
int64 minValiditySeconds = 3;
}
message TailscaleCertificate {
bytes certificatePEM = 1;
bytes privateKeyPEM = 2;
}
message TailscaleSSHClientMessage {
oneof message {
TailscaleSSHStart start = 1;
TailscaleSSHInput input = 2;
TailscaleSSHResize resize = 3;
}
}
message TailscaleSSHStart {
string endpointTag = 1;
string peerAddress = 2;
string username = 3;
string terminalType = 4;
int32 columns = 5;
int32 rows = 6;
int32 widthPixels = 7;
int32 heightPixels = 8;
repeated string hostKeys = 9;
bool forward_agent = 10;
}
message TailscaleSSHInput {
bytes data = 1;
}
message TailscaleSSHResize {
int32 columns = 1;
int32 rows = 2;
int32 widthPixels = 3;
int32 heightPixels = 4;
}
message TailscaleSSHServerMessage {
oneof message {
TailscaleSSHAuthBanner authBanner = 1;
TailscaleSSHReady ready = 2;
TailscaleSSHOutput output = 3;
TailscaleSSHExit exit = 4;
TailscaleSSHError error = 5;
}
}
message TailscaleSSHAuthBanner {
string message = 1;
}
message TailscaleSSHReady {
}
message TailscaleSSHOutput {
bytes data = 1;
}
message TailscaleSSHExit {
int32 exitCode = 1;
string signal = 2;
string errorMessage = 3;
}
message TailscaleSSHError {
string message = 1;
}
message SubscribeTaildropInboxRequest {
string endpointTag = 1;
}
message MarkTaildropInboxReadRequest {
string endpointTag = 1;
}
message TaildropInbox {
string endpointTag = 1;
repeated TaildropFile files = 2;
repeated TaildropReceivingFile receiving = 3;
}
message TaildropFile {
string name = 1;
int64 size = 2;
string senderName = 3;
int64 modifiedAt = 4;
}
message TaildropReceivingFile {
string name = 1;
int64 size = 2;
int64 receivedBytes = 3;
string senderID = 4;
string senderName = 5;
}
message TaildropSendClientMessage {
oneof message {
TaildropSendStart start = 1;
TaildropFileChunk chunk = 2;
TaildropFileDone fileDone = 3;
}
}
message TaildropSendStart {
string endpointTag = 1;
string peerStableID = 2;
repeated TaildropOutgoingFile files = 3;
}
message TaildropOutgoingFile {
string name = 1;
int64 size = 2;
}
message TaildropFileChunk {
bytes data = 1;
}
message TaildropFileDone {}
message TaildropSendServerMessage {
oneof message {
TaildropSendProgress progress = 1;
int64 receivedBytes = 2;
}
}
message TaildropSendProgress {
int32 fileIndex = 1;
int64 sentBytes = 2;
bool fileCompleted = 3;
}
message DownloadTaildropFileRequest {
string endpointTag = 1;
string name = 2;
}
message DownloadTaildropFileChunk {
int64 size = 1;
bytes data = 2;
}
message DeleteTaildropFileRequest {
string endpointTag = 1;
string name = 2;
}
message CancelTaildropReceivingRequest {
string endpointTag = 1;
string senderID = 2;
string name = 3;
}
message USBProviderMessage {
oneof message {
USBDeviceAttach attach = 1;
USBDeviceDetach detach = 2;
USBURBResponse urbResponse = 3;
}
}
message USBServerMessage {
oneof message {
USBDeviceReady ready = 1;
USBURBRequest urbRequest = 2;
USBEndpointAbort abort = 3;
USBError error = 4;
}
}
message USBDeviceDescriptor {
string deviceId = 1;
uint32 busNum = 2;
uint32 devNum = 3;
uint32 speed = 4;
uint32 vendorId = 5;
uint32 productId = 6;
uint32 bcdDevice = 7;
uint32 deviceClass = 8;
uint32 deviceSubClass = 9;
uint32 deviceProtocol = 10;
uint32 configurationValue = 11;
uint32 numConfigurations = 12;
repeated USBInterface interfaces = 13;
string serial = 14;
string product = 15;
}
message USBDeviceAttach {
string serverTag = 1;
USBDeviceDescriptor descriptor = 2;
}
message USBInterface {
uint32 interfaceClass = 1;
uint32 interfaceSubClass = 2;
uint32 interfaceProtocol = 3;
}
message USBDeviceDetach {
string deviceId = 1;
}
message USBDeviceReady {
string deviceId = 1;
string busId = 2;
}
message USBURBRequest {
string deviceId = 1;
uint64 seq = 2;
uint32 endpoint = 3;
bool directionIn = 4;
uint32 transferFlags = 5;
bytes setup = 6;
uint32 transferBufferLength = 7;
bytes outData = 8;
int32 numberOfPackets = 9;
int32 startFrame = 10;
int32 interval = 11;
repeated USBIsoPacket isoPackets = 12;
}
message USBURBResponse {
string deviceId = 1;
uint64 seq = 2;
int32 status = 3;
int32 actualLength = 4;
bytes inData = 5;
repeated USBIsoPacket isoPackets = 6;
}
message USBIsoPacket {
int32 offset = 1;
int32 length = 2;
int32 actualLength = 3;
int32 status = 4;
}
message USBEndpointAbort {
string deviceId = 1;
uint32 endpoint = 2;
}
message USBError {
string deviceId = 1;
string message = 2;
}
message USBIPServerStatusUpdate {
repeated USBIPServerStatus servers = 1;
}
message USBIPServerStatus {
string serverTag = 1;
repeated USBSharedDevice devices = 2;
}
message USBSharedDevice {
USBDeviceDescriptor descriptor = 1;
string busId = 2;
string stableId = 3;
USBBackend backend = 4;
USBDeviceState state = 5;
}
enum USBDeviceState {
USB_DEVICE_STATE_IDLE = 0;
USB_DEVICE_STATE_ATTACHED = 1;
USB_DEVICE_STATE_UNAVAILABLE = 2;
}
enum USBBackend {
USB_BACKEND_UNSPECIFIED = 0;
USB_BACKEND_LINUX_SYSFS = 1;
USB_BACKEND_DYNAMIC = 2;
USB_BACKEND_DARWIN_IOKIT = 3;
USB_BACKEND_WINDOWS_VBOXUSB = 4;
}
message OpenConnectStatusUpdate {
repeated OpenConnectEndpointStatus endpoints = 1;
}
message OpenConnectEndpointStatus {
string endpointTag = 1;
string state = 2;
string stateText = 3;
OpenConnectAuthChallenge authChallenge = 4;
string error = 5;
OpenConnectTunnelInfo tunnelInfo = 6;
}
message OpenConnectTunnelInfo {
string server = 1;
string flavor = 2;
string transport = 3;
repeated string ipv4 = 4;
repeated string ipv6 = 5;
repeated string dns = 6;
uint32 mtu = 7;
int64 connectedSince = 8;
}
message OpenConnectAuthChallenge {
string id = 1;
string banner = 2;
string message = 3;
string error = 4;
oneof challenge {
OpenConnectAuthForm form = 5;
OpenConnectBrowserRequest browser = 6;
}
}
message OpenConnectAuthForm {
repeated OpenConnectAuthFormField fields = 1;
}
message OpenConnectAuthFormField {
string submissionKey = 1;
string name = 2;
string label = 3;
string kind = 4;
string value = 5;
repeated OpenConnectAuthFormChoice options = 6;
}
message OpenConnectAuthFormChoice {
string value = 1;
string label = 2;
}
message OpenConnectBrowserRequest {
string url = 1;
string finalURL = 2;
repeated string cookieNames = 3;
repeated string headerNames = 4;
repeated string callbackURLPrefixes = 5;
repeated string earlyCookieNames = 6;
string cacheID = 7;
}
message OpenConnectBrowserCookie {
string name = 1;
string value = 2;
}
message OpenConnectBrowserHeader {
string name = 1;
repeated string values = 2;
}
message OpenConnectAuthFormResponse {
map<string, string> values = 1;
}
message OpenConnectBrowserResult {
string finalURL = 1;
repeated OpenConnectBrowserCookie cookies = 2;
repeated OpenConnectBrowserHeader headers = 3;
}
message OpenConnectAuthResponseSubmission {
string endpointTag = 1;
string challengeID = 2;
oneof response {
OpenConnectAuthFormResponse form = 3;
OpenConnectBrowserResult browser = 4;
}
}
message OpenConnectAuthChallengeCancel {
string endpointTag = 1;
string challengeID = 2;
}
message OpenVPNStatusUpdate {
repeated OpenVPNEndpointStatus endpoints = 1;
}
message OpenVPNEndpointStatus {
string endpointTag = 1;
string state = 2;
string stateText = 3;
OpenVPNChallenge challenge = 4;
string error = 5;
OpenVPNTunnelInfo tunnelInfo = 6;
}
message OpenVPNTunnelInfo {
string server = 1;
reserved 2;
string network = 3;
repeated string ipv4 = 4;
repeated string ipv6 = 5;
repeated string dns = 6;
uint32 mtu = 7;
int64 connectedSince = 8;
string cipher = 9;
}
message OpenVPNChallenge {
string id = 1;
string kind = 2;
string username = 3;
string message = 4;
string url = 5;
string secretMessage = 6;
bool echo = 7;
string previousError = 8;
int64 deadline = 9;
}
message OpenVPNChallengeSubmission {
string endpointTag = 1;
string challengeID = 2;
string username = 3;
string password = 4;
string secret = 5;
}
message OpenVPNChallengeCancel {
string endpointTag = 1;
string challengeID = 2;
}
message NotificationEvent {
oneof event {
Notification send = 1;
NotificationCancel cancel = 2;
}
}
message Notification {
string identifier = 1;
string typeName = 2;
int32 typeID = 3;
string title = 4;
string subtitle = 5;
string body = 6;
string openURL = 7;
}
message NotificationCancel {
string identifier = 1;
int32 typeID = 2;
}
+2 -2
View File
@@ -12,7 +12,7 @@ GATEWAY_IMAGE="${GATEWAY_IMAGE:-${IMAGE_NAME}:${IMAGE_TAG}}"
BASE_IMAGE="${BASE_IMAGE:-vpn-proxy-runtime-base:bookworm-slim}"
NODE_BUILD_IMAGE="${NODE_BUILD_IMAGE:-node:20.19-alpine}"
RUNTIME_BASE_SOURCE_IMAGE="${RUNTIME_BASE_SOURCE_IMAGE:-mirror.gcr.io/library/debian:bookworm-slim}"
SINGBOX_VERSION="${SINGBOX_VERSION:-1.13.18}"
SINGBOX_VERSION="${SINGBOX_VERSION:-1.14.0-rc.5}"
DOCKER_BUILD_PULL="${DOCKER_BUILD_PULL:-false}"
INSTALL_RUNTIME_DEPS="${INSTALL_RUNTIME_DEPS:-false}"
INSTALL_SINGBOX="${INSTALL_SINGBOX:-false}"
@@ -63,7 +63,7 @@ else
fi
echo "Building image on ${BUILD_HOST}"
BUILD_COMMAND="set -e; echo 'Docker context:' \$(docker context show 2>/dev/null || true); docker info 2>/dev/null | sed -n '/HTTP Proxy:/p;/HTTPS Proxy:/p;/Name:/p'; cd '${BUILD_PATH}'; if ! docker image inspect '${BASE_IMAGE}' >/dev/null 2>&1; then if [ '${AUTO_BUILD_RUNTIME_BASE}' = 'true' ]; then echo 'Runtime base image ${BASE_IMAGE} is missing on ${BUILD_HOST}; building it now.'; BASE_IMAGE='${RUNTIME_BASE_SOURCE_IMAGE}' RUNTIME_BASE_IMAGE='${BASE_IMAGE}' SINGBOX_VERSION='${SINGBOX_VERSION}' ./scripts/build-runtime-base.sh; else echo 'Runtime base image ${BASE_IMAGE} is missing on ${BUILD_HOST}.'; echo 'Seed it once with: ./scripts/build-runtime-base.sh'; exit 1; fi; fi; npm ci && npm run build:production && docker build --pull='${DOCKER_BUILD_PULL}' --build-arg NODE_BUILD_IMAGE='${NODE_BUILD_IMAGE}' --build-arg BASE_IMAGE='${BASE_IMAGE}' --build-arg SINGBOX_VERSION='${SINGBOX_VERSION}' --build-arg INSTALL_RUNTIME_DEPS='${INSTALL_RUNTIME_DEPS}' --build-arg INSTALL_SINGBOX='${INSTALL_SINGBOX}' -t '${GATEWAY_IMAGE}' ."
BUILD_COMMAND="set -e; echo 'Docker context:' \$(docker context show 2>/dev/null || true); docker info 2>/dev/null | sed -n '/HTTP Proxy:/p;/HTTPS Proxy:/p;/Name:/p'; cd '${BUILD_PATH}'; if ! docker image inspect '${BASE_IMAGE}' >/dev/null 2>&1 || ! docker run --rm '${BASE_IMAGE}' sh -lc \"command -v npm >/dev/null && sing-box version 2>&1 | grep -Fx 'sing-box version ${SINGBOX_VERSION}'\"; then if [ '${AUTO_BUILD_RUNTIME_BASE}' = 'true' ]; then echo 'Runtime base image ${BASE_IMAGE} is missing or does not contain sing-box ${SINGBOX_VERSION}; building it now.'; BASE_IMAGE='${RUNTIME_BASE_SOURCE_IMAGE}' RUNTIME_BASE_IMAGE='${BASE_IMAGE}' SINGBOX_VERSION='${SINGBOX_VERSION}' ./scripts/build-runtime-base.sh; else echo 'Runtime base image ${BASE_IMAGE} is missing or does not contain sing-box ${SINGBOX_VERSION} on ${BUILD_HOST}.'; echo 'Seed it once with: ./scripts/build-runtime-base.sh'; exit 1; fi; fi; docker run --rm '${BASE_IMAGE}' sh -lc \"command -v npm >/dev/null && sing-box version 2>&1 | grep -Fx 'sing-box version ${SINGBOX_VERSION}'\"; npm ci && npm run build:production && docker build --pull='${DOCKER_BUILD_PULL}' --build-arg NODE_BUILD_IMAGE='${NODE_BUILD_IMAGE}' --build-arg BASE_IMAGE='${BASE_IMAGE}' --build-arg SINGBOX_VERSION='${SINGBOX_VERSION}' --build-arg INSTALL_RUNTIME_DEPS='${INSTALL_RUNTIME_DEPS}' --build-arg INSTALL_SINGBOX='${INSTALL_SINGBOX}' -t '${GATEWAY_IMAGE}' . && docker run --rm --entrypoint sing-box '${GATEWAY_IMAGE}' version 2>&1 | grep -Fx 'sing-box version ${SINGBOX_VERSION}'"
if [ "${BUILD_HOST}" = "local" ]; then
bash -lc "${BUILD_COMMAND}"
else
+1 -1
View File
@@ -3,7 +3,7 @@ set -euo pipefail
BASE_IMAGE="${BASE_IMAGE:-mirror.gcr.io/library/debian:bookworm-slim}"
RUNTIME_BASE_IMAGE="${RUNTIME_BASE_IMAGE:-vpn-proxy-runtime-base:bookworm-slim}"
SINGBOX_VERSION="${SINGBOX_VERSION:-1.13.18}"
SINGBOX_VERSION="${SINGBOX_VERSION:-1.14.0-rc.5}"
APT_MIRROR="${APT_MIRROR:-http://mirror.yandex.ru/debian}"
APT_SECURITY_MIRROR="${APT_SECURITY_MIRROR:-http://mirror.yandex.ru/debian-security}"
HTTP_PROXY="${HTTP_PROXY:-$(docker info 2>/dev/null | awk -F': ' '/HTTP Proxy:/ {print $2; exit}')}"
+8 -1
View File
@@ -9,7 +9,8 @@ COMPOSE_FILE="docker-compose.client.yml"
DEFAULT_PROXY_PORT="8082"
REQUESTED_PROXY_PORT="${VPN_PROXY_CLIENT_PORT:-}"
REQUESTED_UI_PORT="${VPN_PROXY_CLIENT_UI_PORT:-${CLIENT_UI_PORT:-}}"
TARGET_SINGBOX_VERSION="${SINGBOX_VERSION:-1.13.18}"
TARGET_SINGBOX_VERSION="${SINGBOX_VERSION:-1.14.0-rc.5}"
TARGET_TRAFFIC_SOURCE="${SING_BOX_TRAFFIC_SOURCE:-native}"
CLIENT_CONTAINER_NAME="harbor-connect"
LEGACY_CLIENT_CONTAINER_NAME="vpn-proxy-client"
NETWORK_MONITOR_LABEL="com.dokril.harbor-connect.network"
@@ -290,6 +291,11 @@ need curl
need rsync
need tar
case "$TARGET_TRAFFIC_SOURCE" in
native|disabled) ;;
*) die "SING_BOX_TRAFFIC_SOURCE must be native or disabled" ;;
esac
docker compose version >/dev/null 2>&1 || die "Docker Compose plugin is required"
docker info >/dev/null 2>&1 || die "Docker Desktop is not running"
@@ -321,6 +327,7 @@ assert_ui_outside_proxy_range
set_env_value APP_MODE client
set_env_value SINGBOX_VERSION "$TARGET_SINGBOX_VERSION"
set_env_value SING_BOX_TRAFFIC_SOURCE "$TARGET_TRAFFIC_SOURCE"
set_env_value CLIENT_UI_PORT "$UI_PORT"
set_env_value CLIENT_PROXY_PORT "$PROXY_PORT"
set_env_value PROXY_PORT "$PROXY_PORT"
+7 -3
View File
@@ -19,6 +19,7 @@ const noRuntimeImpact = [
/^entrypoint\.client\.sh$/,
/^install\.sh$/,
/^scripts\/(?:check-import-boundaries\.mjs|clean-test-dist\.mjs|harbor-network-monitor\.sh|harbor-version\.mjs|install-macos-client\.sh)$/,
/^tools\/test-singbox-(?:client-rc|gateway-native-traffic|native-traffic)\.sh$/,
];
const foundation = [
/^\.dockerignore$/,
@@ -32,11 +33,14 @@ const foundation = [
/^tsconfig(?:\.[^.]+)?\.json$/,
];
const controlAndDataplane = [
/^buf\.gen\.yaml$/,
/^proto\//,
new RegExp(`^src/server/main${CODE_EXTENSION}`),
new RegExp(`^src/server/(?:config|gatewayRouting|singbox|singboxRuntime|version)${CODE_EXTENSION}`),
new RegExp(`^src/server/(?:config|gatewayNativeRuntime|gatewayRouting|singbox|singboxRuntime|version)${CODE_EXTENSION}`),
/^src\/server\/generated\//,
new RegExp(`^src/server/adapters/neighbors${CODE_EXTENSION}`),
new RegExp(`^src/server/services/(?:connectivityDiagnosticsService|deviceInventoryService|devicePolicyService|singboxSelectorService)${CODE_EXTENSION}`),
new RegExp(`^src/shared/(?:connectivityDiagnostics|errors)${CODE_EXTENSION}`),
new RegExp(`^src/server/services/(?:connectivityDiagnosticsService|deviceInventoryService|devicePolicyService|liveTrafficService|singboxSelectorService)${CODE_EXTENSION}`),
new RegExp(`^src/shared/(?:connectivityDiagnostics|errors|liveTraffic)${CODE_EXTENSION}`),
/^src\/server\/infrastructure\/dataplane\//,
];
const dataplane = [
+24 -2
View File
@@ -1,5 +1,7 @@
import path from "node:path";
const appMode = process.env.APP_MODE === "client" ? "client" : "gateway";
const appComponent = process.env.APP_COMPONENT || "";
const dataDir = process.env.DATA_DIR || path.resolve(".vpn-proxy");
const parsePort = (value: string | undefined, fallback: number) => {
const parsed = Number.parseInt(value || '', 10);
@@ -7,17 +9,33 @@ const parsePort = (value: string | undefined, fallback: number) => {
};
const proxyPort = parsePort(
process.env.PROXY_PORT,
process.env.APP_MODE === "client" ? 8082 : 8080,
appMode === "client" ? 8082 : 8080,
);
const trafficSource = process.env.SING_BOX_TRAFFIC_SOURCE
|| (appMode === "client" ? "native" : "snapshot");
if (appMode === "client" && trafficSource !== "native" && trafficSource !== "disabled") {
throw new Error("SING_BOX_TRAFFIC_SOURCE must be native or disabled in client mode");
}
if (appMode === "gateway" && !["snapshot", "shadow", "native"].includes(trafficSource)) {
throw new Error("SING_BOX_TRAFFIC_SOURCE must be snapshot, shadow or native in gateway mode");
}
if (appMode === "gateway" && trafficSource !== "snapshot"
&& ((appComponent !== "control" && appComponent !== "dataplane")
|| !process.env.DATAPLANE_SOCKET?.trim())) {
throw new Error("Gateway shadow and native traffic modes require split control/dataplane topology");
}
export const settings = {
appMode: process.env.APP_MODE === "client" ? "client" : "gateway",
appMode,
appComponent,
port: parsePort(process.env.PORT, 3456),
proxyPort,
diagnosticsProxyPort: parsePort(process.env.DIAGNOSTICS_PROXY_PORT, 18080),
failoverPrimaryProxyPort: parsePort(process.env.FAILOVER_PRIMARY_PROXY_PORT, 18081),
failoverReserveProxyPort: parsePort(process.env.FAILOVER_RESERVE_PROXY_PORT, 18082),
singboxApiPort: parsePort(process.env.SING_BOX_API_PORT, 19090),
singboxNativeApiPort: 19091,
singboxTrafficSource: trafficSource as "native" | "disabled" | "snapshot" | "shadow",
tproxyPort: parsePort(process.env.TPROXY_PORT, 7895),
tproxyMark: process.env.TPROXY_MARK || "1",
tproxyChain: process.env.TPROXY_CHAIN || "VPN_PROXY_TPROXY",
@@ -40,6 +58,10 @@ export const settings = {
configPath:
process.env.SING_BOX_CONFIG || path.join(dataDir, "sing-box-config.json"),
cachePath: process.env.SING_BOX_CACHE || "/var/lib/sing-box/cache.db",
gatewayNativeApiSecretPath:
process.env.SING_BOX_API_SECRET || "/var/lib/sing-box/api.secret",
gatewayRuntimeConfigPath:
process.env.SING_BOX_RUNTIME_CONFIG || "/var/lib/sing-box/runtime-config.json",
statePath: path.join(dataDir, "state.json"),
deviceStatePath: path.join(dataDir, "devices.json"),
activityJournalPath: path.join(dataDir, "activity-journal.json"),
+196 -15
View File
@@ -1,7 +1,9 @@
import fs from 'node:fs';
import http from 'node:http';
import net from 'node:net';
import path from 'node:path';
import type { IncomingMessage, ServerResponse } from 'node:http';
import type { LiveTrafficConnection, LiveTrafficSnapshot } from '../shared/liveTraffic.js';
import { settings } from './config.js';
import { createSingboxRuntime } from './singboxRuntime.js';
import { buildVersionInfo } from './version.js';
@@ -13,13 +15,27 @@ import {
createDomainTrafficService,
readSingboxConnections,
} from './services/domainTrafficService.js';
import { deviceId } from './services/deviceInventoryService.js';
import {
createLiveTrafficService,
} from './services/liveTrafficService.js';
import { createSingboxSelectorService } from './services/singboxSelectorService.js';
const socketPath = settings.dataplaneSocket;
const trafficMode = settings.singboxTrafficSource as 'snapshot' | 'shadow' | 'native';
const nativeTrafficEnabled = trafficMode === 'shadow' || trafficMode === 'native';
const runtime = createSingboxRuntime({
configPath: settings.configPath,
gateway: true,
tproxyChain: settings.tproxyChain,
gatewayRuntimeConfigPath: settings.gatewayRuntimeConfigPath,
...(nativeTrafficEnabled ? {
nativeApi: {
apiPort: settings.singboxNativeApiPort,
secretPath: settings.gatewayNativeApiSecretPath,
runtimeConfigPath: settings.gatewayRuntimeConfigPath,
},
} : {}),
});
const versionInfo = buildVersionInfo('gateway');
const traffic = createDeviceTrafficService({
@@ -46,10 +62,23 @@ const failoverDiagnostics = {
reserve: createConnectivityDiagnosticsService({ proxyPort: settings.failoverReserveProxyPort }),
};
const selector = createSingboxSelectorService({ port: settings.singboxApiPort });
const domainTraffic = createDomainTrafficService({
const snapshotDomainTraffic = createDomainTrafficService({
observe: () => readSingboxConnections(settings.singboxApiPort),
devices: () => traffic.snapshot().devices,
});
const nativeDomainTraffic = createDomainTrafficService({
observe: () => ({ connections: [] }),
devices: () => traffic.snapshot().devices,
});
const domainTraffic = trafficMode === 'native' ? nativeDomainTraffic : snapshotDomainTraffic;
let originsByIp = new Map<string, LiveTrafficConnection['origin'] | null>();
let liveTraffic = createLiveTrafficService({
port: settings.singboxNativeApiPort,
enabled: false,
gateway: true,
isRuntimeRunning: () => false,
resolveOrigin,
});
let ready = false;
let trafficTimer: NodeJS.Timeout | null = null;
let domainTrafficTimer: NodeJS.Timeout | null = null;
@@ -65,6 +94,133 @@ function errorMessage(error: unknown) {
return error instanceof Error ? error.message : String(error);
}
function updateOrigins(devices: unknown) {
const next = new Map<string, LiveTrafficConnection['origin'] | null>();
for (const value of Array.isArray(devices) ? devices : []) {
const device = record(value);
const ip = String(device.ip || '');
const mac = String(device.mac || '').toLowerCase();
if (!net.isIPv4(ip) || !/^[0-9a-f]{2}(?::[0-9a-f]{2}){5}$/.test(mac)) continue;
const origin: LiveTrafficConnection['origin'] = {
kind: 'device',
id: deviceId(mac),
label: ip,
provenance: 'source-ip',
};
next.set(ip, next.has(ip) ? null : origin);
}
originsByIp = next;
}
function resolveOrigin(sourceIp: string): LiveTrafficConnection['origin'] {
return originsByIp.get(sourceIp) || {
kind: 'unknown',
id: null,
label: 'Неизвестное устройство',
provenance: 'unknown',
};
}
async function refreshDeviceTraffic() {
try {
return await traffic.refresh();
} finally {
refreshOrigins();
}
}
function refreshOrigins() {
updateOrigins(readNeighborSnapshot().observations);
}
function decimal(value: unknown) {
return typeof value === 'string' && /^\d+$/.test(value) ? BigInt(value) : 0n;
}
function trackedTotals(snapshot: unknown) {
let upload = 0n;
let download = 0n;
for (const value of Array.isArray(record(snapshot).tracked) ? record(snapshot).tracked as unknown[] : []) {
const entry = record(value);
upload += decimal(entry.uploadBytes);
download += decimal(entry.downloadBytes);
}
return { upload, download };
}
function mismatchCount(left: unknown, right: unknown, fields: string[]) {
const entries = (value: unknown) => {
const values = Array.isArray(value) ? value : [];
return new Map(values.map((item) => {
const entry = record(item);
const key = fields.map((field) => String(entry[field] || '')).join('\0');
return [key, `${entry.uploadBytes || '0'}\0${entry.downloadBytes || '0'}`];
}));
};
const leftEntries = entries(left);
const rightEntries = entries(right);
const keys = new Set([...leftEntries.keys(), ...rightEntries.keys()]);
let mismatches = 0;
for (const key of keys) if (leftEntries.get(key) !== rightEntries.get(key)) mismatches += 1;
return mismatches;
}
function liveTrafficSnapshot(): LiveTrafficSnapshot {
const snapshot = liveTraffic.snapshot();
return nativeTrafficEnabled && runtime.nativeApiWarning ? {
...snapshot,
source: {
...snapshot.source,
state: 'incompatible',
error: runtime.nativeApiWarning,
},
} : snapshot;
}
function trafficCollectorSource() {
const canonical = domainTraffic.snapshot();
const canonicalSource = record(canonical.source);
const nativeLive = nativeTrafficEnabled ? liveTrafficSnapshot() : null;
const nativeProjection = nativeDomainTraffic.snapshot();
const legacyProjection = snapshotDomainTraffic.snapshot();
let shadow = null;
if (trafficMode === 'shadow') {
const nativeTotals = trackedTotals(nativeProjection);
const legacyTotals = trackedTotals(legacyProjection);
shadow = {
activeDifference: Number(record(nativeProjection.source).activeConnections || 0)
- Number(record(legacyProjection.source).activeConnections || 0),
uploadDifferenceBytes: (nativeTotals.upload - legacyTotals.upload).toString(),
downloadDifferenceBytes: (nativeTotals.download - legacyTotals.download).toString(),
routeMismatches: mismatchCount(nativeProjection.tracked, legacyProjection.tracked, ['source', 'outbound']),
deviceMismatches: mismatchCount(nativeProjection.routes, legacyProjection.routes, ['deviceId', 'source', 'outbound']),
};
}
return {
error: runtime.nativeApiWarning
|| (trafficMode === 'native' ? nativeLive?.source.error : canonicalSource.error)
|| null,
mode: trafficMode,
writer: trafficMode === 'native' ? 'native' as const : 'snapshot' as const,
activeConnections: Number(canonicalSource.activeConnections || 0),
native: nativeLive ? {
state: nativeLive.source.state,
epoch: nativeLive.epoch,
sequence: nativeLive.sequence,
observedAt: nativeLive.observedAt,
active: nativeLive.summary.active,
unattributedUploadBytes: nativeLive.source.unattributedUploadBytes,
unattributedDownloadBytes: nativeLive.source.unattributedDownloadBytes,
} : null,
shadow,
};
}
function domainTrafficSnapshot() {
const snapshot = domainTraffic.snapshot();
return { ...snapshot, source: trafficCollectorSource() };
}
function readJson(req: IncomingMessage): Promise<unknown> {
return new Promise((resolve, reject) => {
const chunks: Buffer[] = [];
@@ -105,6 +261,7 @@ const server = http.createServer(async (req: IncomingMessage, res: ServerRespons
gatewayBackendVersion: versionInfo.components.gatewayBackend,
singBoxVersion: versionInfo.runtime.singBox,
devicePolicy: devicePolicy.snapshot(),
trafficCollector: trafficCollectorSource(),
ready,
});
}
@@ -115,7 +272,10 @@ const server = http.createServer(async (req: IncomingMessage, res: ServerRespons
return sendJson(res, 200, traffic.snapshot());
}
if (req.method === 'GET' && req.url === '/domain-traffic') {
return sendJson(res, 200, domainTraffic.snapshot());
return sendJson(res, 200, domainTrafficSnapshot());
}
if (req.method === 'GET' && req.url === '/traffic/live') {
return sendJson(res, 200, liveTrafficSnapshot());
}
if (req.method === 'GET' && req.url === '/device-policy') {
return sendJson(res, 200, devicePolicy.snapshot());
@@ -153,7 +313,10 @@ const server = http.createServer(async (req: IncomingMessage, res: ServerRespons
}
if (req.method === 'POST' && req.url === '/failover/activity/read') {
const { thresholdBytesPerSecond = 0 } = record(await readJson(req));
return sendJson(res, 200, { activity: domainTraffic.activitySnapshot(thresholdBytesPerSecond) });
const sourceLive = trafficMode !== 'native' || liveTrafficSnapshot().source.state === 'live';
return sendJson(res, 200, {
activity: sourceLive ? domainTraffic.activitySnapshot(thresholdBytesPerSecond) : null,
});
}
if (req.method === 'POST' && req.url === '/config/check') {
const { config } = record(await readJson(req));
@@ -183,27 +346,44 @@ server.listen(socketPath, async () => {
} catch (error) {
console.warn(`[dataplane] sing-box не запущен: ${errorMessage(error)}`);
} finally {
refreshOrigins();
liveTraffic = createLiveTrafficService({
port: settings.singboxNativeApiPort,
enabled: nativeTrafficEnabled,
gateway: true,
isRuntimeRunning: () => runtime.running && !runtime.nativeApiWarning,
resolveOrigin,
authorization: () => runtime.nativeApiSecret,
onProjection: (batch) => {
nativeDomainTraffic.ingestNative(batch);
},
});
liveTraffic.start();
ready = true;
if (settings.deviceTrafficAccountingEnabled) {
setImmediate(() => {
traffic.refresh()
refreshDeviceTraffic()
.catch((error: unknown) => console.warn(`[dataplane] traffic counters не запущены: ${errorMessage(error)}`));
});
trafficTimer = setInterval(() => {
traffic.refresh().catch((error: unknown) => console.warn(`[dataplane] traffic counters не обновлены: ${errorMessage(error)}`));
refreshDeviceTraffic().catch((error: unknown) => console.warn(`[dataplane] traffic counters не обновлены: ${errorMessage(error)}`));
}, 15_000);
trafficTimer.unref();
} else {
trafficTimer = setInterval(refreshOrigins, 15_000);
trafficTimer.unref();
}
if (trafficMode !== 'native') {
setImmediate(() => {
snapshotDomainTraffic.refresh()
.catch((error: unknown) => console.warn(`[dataplane] domain traffic не запущен: ${errorMessage(error)}`));
});
domainTrafficTimer = setInterval(() => {
snapshotDomainTraffic.refresh()
.catch((error: unknown) => console.warn(`[dataplane] domain traffic не обновлён: ${errorMessage(error)}`));
}, 2_000);
domainTrafficTimer.unref();
}
setImmediate(() => {
domainTraffic.refresh()
.catch((error: unknown) => console.warn(`[dataplane] domain traffic не запущен: ${errorMessage(error)}`));
});
// ponytail: snapshots can miss connections shorter than 2s; switch to an upstream close-event API if sing-box adds one.
domainTrafficTimer = setInterval(() => {
domainTraffic.refresh()
.catch((error: unknown) => console.warn(`[dataplane] domain traffic не обновлён: ${errorMessage(error)}`));
}, 2_000);
domainTrafficTimer.unref();
console.log(`[dataplane] control socket: ${socketPath}`);
}
});
@@ -215,6 +395,7 @@ async function shutdown() {
ready = false;
if (trafficTimer) clearInterval(trafficTimer);
if (domainTrafficTimer) clearInterval(domainTrafficTimer);
await liveTraffic.stop();
await runtime.shutdown();
server.close(() => {
fs.rmSync(socketPath, { force: true });
+1
View File
@@ -73,6 +73,7 @@ export function createDataplaneClient(socketPath: string, send: SendDataplaneReq
observeDevices: () => send(socketPath, '/devices', 'GET'),
observeTraffic: () => send(socketPath, '/device-traffic', 'GET'),
observeDomainTraffic: () => send(socketPath, '/domain-traffic', 'GET'),
observeLiveTraffic: () => send(socketPath, '/traffic/live', 'GET'),
observeDevicePolicy: () => send(socketPath, '/device-policy', 'GET'),
applyDevicePolicies: (devices: unknown) => send(socketPath, '/device-policy', 'PUT', { devices }),
runConnectivityDiagnostics: async (services: unknown = [], target: unknown = null) => {
+163
View File
@@ -0,0 +1,163 @@
import crypto from 'node:crypto';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
interface MaterializeOptions {
apiPort: number;
secretPath: string;
runtimeConfigPath: string;
}
function record(value: unknown): Record<string, unknown> {
return value && typeof value === 'object' && !Array.isArray(value)
? value as Record<string, unknown>
: {};
}
function message(error: unknown) {
return error instanceof Error ? error.message : String(error);
}
function privateWrite(filePath: string, value: unknown) {
fs.mkdirSync(path.dirname(filePath), { recursive: true, mode: 0o700 });
const temporaryPath = `${filePath}.${process.pid}.${crypto.randomBytes(8).toString('hex')}.tmp`;
let descriptor: number | null = null;
try {
descriptor = fs.openSync(
temporaryPath,
fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_EXCL | fs.constants.O_NOFOLLOW,
0o600,
);
fs.writeFileSync(descriptor, JSON.stringify(value));
fs.fchmodSync(descriptor, 0o600);
fs.fsyncSync(descriptor);
fs.closeSync(descriptor);
descriptor = null;
fs.renameSync(temporaryPath, filePath);
fs.chmodSync(filePath, 0o600);
const status = fs.lstatSync(filePath);
if (!status.isFile() || status.isSymbolicLink() || (status.mode & 0o777) !== 0o600) {
throw new Error('private runtime config is not a regular 0600 file');
}
} finally {
if (descriptor !== null) fs.closeSync(descriptor);
fs.rmSync(temporaryPath, { force: true });
}
}
function openSecret(secretPath: string) {
const readFlags = fs.constants.O_RDWR | fs.constants.O_NOFOLLOW;
try {
return { descriptor: fs.openSync(secretPath, readFlags), created: false };
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error;
}
try {
return {
descriptor: fs.openSync(
secretPath,
readFlags | fs.constants.O_CREAT | fs.constants.O_EXCL,
0o600,
),
created: true,
};
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== 'EEXIST') throw error;
return { descriptor: fs.openSync(secretPath, readFlags), created: false };
}
}
export function ensureGatewayNativeApiSecret(secretPath: string) {
fs.mkdirSync(path.dirname(secretPath), { recursive: true, mode: 0o700 });
const { descriptor, created } = openSecret(secretPath);
try {
const secret = created
? crypto.randomBytes(32).toString('hex')
: fs.readFileSync(descriptor, 'utf8');
if (created) {
fs.writeFileSync(descriptor, secret);
fs.fsyncSync(descriptor);
}
if (!/^[0-9a-f]{64}$/.test(secret)) {
throw new Error('native API secret must contain exactly 64 lowercase hex characters');
}
fs.fchmodSync(descriptor, 0o600);
const opened = fs.fstatSync(descriptor);
const linked = fs.lstatSync(secretPath);
if (!opened.isFile() || linked.isSymbolicLink() || !linked.isFile()
|| opened.dev !== linked.dev || opened.ino !== linked.ino
|| (opened.mode & 0o777) !== 0o600 || (linked.mode & 0o777) !== 0o600) {
throw new Error('native API secret is not a regular 0600 file');
}
return secret;
} finally {
fs.closeSync(descriptor);
}
}
function withoutApiServices(config: unknown) {
const safe = structuredClone(record(config));
const services = Array.isArray(safe.services)
? safe.services.filter((service) => record(service).type !== 'api')
: [];
if (services.length) safe.services = services;
else delete safe.services;
return safe;
}
export function materializeGatewaySnapshotConfig(config: unknown, runtimeConfigPath: string) {
privateWrite(runtimeConfigPath, withoutApiServices(config));
return { configPath: runtimeConfigPath, secret: null, warning: null };
}
function withAuthenticatedApi(config: unknown, secret: string) {
const materialized = structuredClone(record(config));
const services = Array.isArray(materialized.services) ? materialized.services : [];
materialized.services = services.map((service) => (
record(service).type === 'api'
? { ...record(service), secret }
: service
));
return materialized;
}
function validateApiService(config: unknown, apiPort: number) {
const configuredServices = record(config).services;
const services = Array.isArray(configuredServices) ? configuredServices : [];
const apiServices = services.map(record).filter(({ type }) => type === 'api');
if (apiServices.length !== 1) throw new Error('expected exactly one native API service');
const [service] = apiServices;
if (service.listen !== '127.0.0.1' || service.listen_port !== apiPort
|| service.dashboard !== false || Object.hasOwn(service, 'secret')) {
throw new Error(`native API service must be unauthenticated base config on 127.0.0.1:${apiPort}`);
}
}
export function materializeGatewayNativeConfig(
config: unknown,
{ apiPort, secretPath, runtimeConfigPath }: MaterializeOptions,
) {
let warning: string | null = null;
try {
validateApiService(config, apiPort);
const secret = ensureGatewayNativeApiSecret(secretPath);
privateWrite(runtimeConfigPath, withAuthenticatedApi(config, secret));
return { configPath: runtimeConfigPath, secret, warning };
} catch (error) {
warning = `Native traffic API disabled: ${message(error)}`;
}
const safeConfig = withoutApiServices(config);
try {
privateWrite(runtimeConfigPath, safeConfig);
return { configPath: runtimeConfigPath, secret: null, warning };
} catch (error) {
warning = `${warning}; private runtime config unavailable: ${message(error)}`;
const suffix = crypto.createHash('sha256').update(runtimeConfigPath).digest('hex').slice(0, 12);
const fallbackPath = path.join(os.tmpdir(), `harbor-singbox-runtime-${process.pid}-${suffix}.json`);
privateWrite(fallbackPath, safeConfig);
return { configPath: fallbackPath, secret: null, warning };
}
}
File diff suppressed because one or more lines are too long
@@ -0,0 +1,73 @@
import type { IncomingMessage, ServerResponse } from 'node:http';
import {
assertLiveTrafficSnapshot,
type LiveTrafficSnapshot,
} from '../../../shared/liveTraffic.js';
import { HarborError } from '../../../shared/errors.js';
import { sendJson } from '../response.js';
interface LiveTrafficReader {
snapshot(): unknown | Promise<unknown>;
}
interface DeviceInventoryReader {
snapshot(): unknown;
}
function record(value: unknown): Record<string, unknown> {
return value && typeof value === 'object' && !Array.isArray(value)
? value as Record<string, unknown>
: {};
}
export function enrichLiveTrafficDeviceLabels(
snapshot: LiveTrafficSnapshot,
inventory: unknown,
): LiveTrafficSnapshot {
const devices = Array.isArray(record(inventory).devices)
? (record(inventory).devices as unknown[]).map(record)
: [];
const labels = new Map(devices.flatMap((device) => {
const id = String(device.id || '');
if (!/^dev_[a-f0-9]{16}$/.test(id)) return [];
const label = [device.alias, device.hostname, device.ip]
.find((value) => typeof value === 'string' && value.trim());
return label ? [[id, String(label).trim()] as const] : [];
}));
if (!labels.size) return snapshot;
return {
...snapshot,
connections: snapshot.connections.map((connection) => {
const label = connection.origin.kind === 'device' && connection.origin.id
? labels.get(connection.origin.id)
: null;
return label ? {
...connection,
origin: { ...connection.origin, label },
} : connection;
}),
};
}
export function createLiveTrafficRoute({
traffic,
deviceInventory = null,
}: {
traffic: LiveTrafficReader | null;
deviceInventory?: DeviceInventoryReader | null;
}) {
return {
async handle(req: IncomingMessage, res: ServerResponse) {
const pathname = new URL(req.url || '/', 'http://localhost').pathname;
if (pathname !== '/api/traffic/live') return false;
if (req.method !== 'GET' || !traffic) throw new HarborError('ENDPOINT_NOT_FOUND');
const snapshot = assertLiveTrafficSnapshot(await traffic.snapshot());
const enriched = deviceInventory
? enrichLiveTrafficDeviceLabels(snapshot, deviceInventory.snapshot())
: snapshot;
sendJson(res, 200, enriched);
return true;
},
};
}
+33
View File
@@ -84,6 +84,7 @@ import { createConnectivityDiagnosticsRoute } from './http/routes/connectivityDi
import { createGatewayPresenceRoute } from './http/routes/gatewayPresenceRoute.js';
import { createSharedProxyRoute } from './http/routes/sharedProxyRoute.js';
import { createVersionRoute } from './http/routes/versionRoute.js';
import { createLiveTrafficRoute } from './http/routes/liveTrafficRoute.js';
import { createSingboxSelectorService } from './services/singboxSelectorService.js';
import { createFailoverService } from './features/failover/failoverService.js';
import { createFailoverRoute } from './http/routes/failoverRoute.js';
@@ -260,6 +261,16 @@ function selectRuntime() {
throw new Error('Harbor runtime is not configured');
}
const singboxRuntime = selectRuntime();
const clientLiveTraffic = settings.appMode === 'client'
? (await import('./services/liveTrafficService.js')).createLiveTrafficService({
port: settings.singboxNativeApiPort,
enabled: settings.singboxTrafficSource === 'native',
isRuntimeRunning: () => Boolean(localRuntime?.running),
})
: null;
const liveTraffic = clientLiveTraffic || (remoteDataplane ? {
snapshot: () => requireRemoteRuntime().observeLiveTraffic(),
} : null);
function requireRemoteRuntime() {
if (!remoteRuntime) throw new Error('Harbor dataplane runtime is not configured');
@@ -515,6 +526,10 @@ const versionRoute = createVersionRoute({
? () => requireRemoteRuntime().refresh()
: null,
});
const liveTrafficRoute = createLiveTrafficRoute({
traffic: liveTraffic,
deviceInventory: remoteDataplane ? deviceInventory : null,
});
const subscriptionValidationRoute = createSubscriptionValidationRoute({
validateSubscription: createValidateSubscription(fetchSubscription),
readBody,
@@ -887,6 +902,19 @@ function currentConfigMatchesAppliedTarget(state: StoredState) {
} catch {
return false;
}
if (settings.appMode === 'client' || settings.appMode === 'gateway') {
const apiServices = (Array.isArray(config.services) ? config.services : [])
.map(record)
.filter(({ type }) => type === 'api');
const nativeApiMatches = apiServices.length === 1
&& apiServices[0].listen === '127.0.0.1'
&& apiServices[0].listen_port === settings.singboxNativeApiPort
&& apiServices[0].dashboard === false
&& !Object.hasOwn(apiServices[0], 'secret');
const nativeApiExpected = settings.singboxTrafficSource === 'native'
|| settings.singboxTrafficSource === 'shadow';
if (nativeApiExpected ? !nativeApiMatches : apiServices.length > 0) return false;
}
if (state.appliedFailoverPolicy) {
const expectedRole = state.appliedProfileId === state.appliedFailoverPolicy.reserve.profileId
&& state.appliedServerId === state.appliedFailoverPolicy.reserve.serverId
@@ -955,6 +983,8 @@ async function handleApi(req: IncomingMessage, res: ServerResponse) {
if (await versionRoute.handle(req, res)) return;
if (await liveTrafficRoute.handle(req, res)) return;
if (await sharedProxyRoute.handle(req, res)) return;
if (await deviceInventoryRoute.handle(req, res)) return;
@@ -1004,6 +1034,7 @@ async function shutdown() {
gatewayAutoService.stopDiscovery();
if (deviceDiscoveryTimer) clearInterval(deviceDiscoveryTimer);
await gatewayFailover?.shutdown().catch((error) => console.warn(`[control] failover shutdown: ${errorMessage(error)}`));
await clientLiveTraffic?.stop().catch((error) => console.warn(`[control] traffic shutdown: ${errorMessage(error)}`));
await serializeControl(() => singboxRuntime.shutdown());
process.exit(0);
}
@@ -1073,6 +1104,8 @@ if (deviceInventory) {
.catch((error: unknown) => console.warn(`[control] device policy не применена: ${errorMessage(error)}`));
}
clientLiveTraffic?.start();
server.listen(settings.port, '0.0.0.0', () => {
console.log(`[control] ${settings.appMode} UI слушает :${settings.port}`);
});
+74 -1
View File
@@ -1,6 +1,12 @@
import type { ServerResponse } from 'node:http';
const COUNTER_PATTERN = /^\d+$/;
const SIGNED_DECIMAL_PATTERN = /^-?\d+$/;
const COLLECTOR_MODES = new Set(['snapshot', 'shadow', 'native']);
const COLLECTOR_WRITERS = new Set(['snapshot', 'native']);
const COLLECTOR_STATES = new Set([
'connecting', 'live', 'degraded', 'stale', 'stopped', 'incompatible', 'disabled',
]);
const labelValue = (value: unknown) => String(value ?? '')
.replaceAll('\\', '\\\\')
@@ -23,6 +29,19 @@ function counter(value: unknown) {
return decimal;
}
function signedGauge(value: unknown) {
const decimal = String(value ?? '');
if (!SIGNED_DECIMAL_PATTERN.test(decimal)) throw new Error(`Invalid Prometheus gauge: ${decimal}`);
return decimal;
}
function safeInteger(value: unknown, { signed = false } = {}) {
if (!Number.isSafeInteger(value) || (!signed && Number(value) < 0)) {
throw new Error(`Invalid Prometheus gauge: ${String(value)}`);
}
return String(value);
}
function timestamp(value: unknown) {
const milliseconds = Date.parse(String(value ?? ''));
return Number.isFinite(milliseconds) ? String(milliseconds / 1000) : null;
@@ -151,10 +170,64 @@ export function renderPrometheusMetrics(value: unknown) {
}
const domainTraffic = record(snapshot.domainTraffic);
const collectorSource = record(domainTraffic.source);
const hasCollectorDiagnostics = ['mode', 'writer', 'native', 'shadow']
.some((field) => Object.hasOwn(collectorSource, field));
if (hasCollectorDiagnostics) {
const source = collectorSource;
const mode = String(source.mode || '');
const writer = String(source.writer || '');
if (!COLLECTOR_MODES.has(mode) || !COLLECTOR_WRITERS.has(writer)) {
throw new Error('Invalid traffic collector labels');
}
lines.push(
'# HELP harbor_traffic_collector_info Current Gateway traffic collector mode and canonical writer.',
'# TYPE harbor_traffic_collector_info gauge',
);
metric(lines, 'harbor_traffic_collector_info', { mode, writer }, '1');
if (source.native !== null) {
const native = record(source.native);
const state = String(native.state || '');
if (!COLLECTOR_STATES.has(state)) throw new Error('Invalid traffic collector state');
lines.push(
'# HELP harbor_traffic_collector_state Current native traffic collector state.',
'# TYPE harbor_traffic_collector_state gauge',
);
metric(lines, 'harbor_traffic_collector_state', { state }, '1');
lines.push(
'# HELP harbor_traffic_collector_unattributed_bytes Native traffic bytes not attributed to a lifecycle connection.',
'# TYPE harbor_traffic_collector_unattributed_bytes gauge',
);
metric(lines, 'harbor_traffic_collector_unattributed_bytes', { direction: 'download' }, counter(native.unattributedDownloadBytes));
metric(lines, 'harbor_traffic_collector_unattributed_bytes', { direction: 'upload' }, counter(native.unattributedUploadBytes));
}
if (source.shadow !== null) {
const shadow = record(source.shadow);
lines.push(
'# HELP harbor_traffic_shadow_active_difference Native active connections minus snapshot active connections.',
'# TYPE harbor_traffic_shadow_active_difference gauge',
`harbor_traffic_shadow_active_difference ${safeInteger(shadow.activeDifference, { signed: true })}`,
'# HELP harbor_traffic_shadow_difference_bytes Native traffic bytes minus snapshot traffic bytes.',
'# TYPE harbor_traffic_shadow_difference_bytes gauge',
);
metric(lines, 'harbor_traffic_shadow_difference_bytes', { direction: 'download' }, signedGauge(shadow.downloadDifferenceBytes));
metric(lines, 'harbor_traffic_shadow_difference_bytes', { direction: 'upload' }, signedGauge(shadow.uploadDifferenceBytes));
lines.push(
'# HELP harbor_traffic_shadow_route_mismatches Route aggregate keys that differ between native and snapshot projections.',
'# TYPE harbor_traffic_shadow_route_mismatches gauge',
`harbor_traffic_shadow_route_mismatches ${safeInteger(shadow.routeMismatches)}`,
'# HELP harbor_traffic_shadow_device_mismatches Device aggregate keys that differ between native and snapshot projections.',
'# TYPE harbor_traffic_shadow_device_mismatches gauge',
`harbor_traffic_shadow_device_mismatches ${safeInteger(shadow.deviceMismatches)}`,
);
}
}
const trackedSeries = Array.isArray(domainTraffic.tracked) ? domainTraffic.tracked.map(record) : [];
if (trackedSeries.length) {
lines.push(
'# HELP harbor_singbox_tracked_bytes_total Bytes observed by the sing-box TCP/UDP tracker; excludes IP and tunnel overhead and may miss short connections.',
'# HELP harbor_singbox_tracked_bytes_total Bytes observed by the configured sing-box traffic collector; excludes IP and tunnel overhead.',
'# TYPE harbor_singbox_tracked_bytes_total counter',
);
for (const series of trackedSeries) {
+234 -113
View File
@@ -3,6 +3,7 @@ import http from 'node:http';
import net from 'node:net';
import { domainToASCII } from 'node:url';
import { deviceId } from './deviceInventoryService.js';
import type { NativeTrafficProjectionBatch } from './liveTrafficService.js';
const MAX_RESPONSE_BYTES = 4 * 1024 * 1024;
const DEFAULT_MAX_SERIES = 4096;
@@ -17,6 +18,7 @@ const SERVICE_DOMAINS = [
interface ParsedBaseConnection {
id: string;
startedAt?: string;
upload: bigint;
download: bigint;
}
@@ -38,6 +40,7 @@ type ParsedConnection =
});
interface PreviousConnection {
startedAt?: string;
outcome: AttributionOutcome | 'classified';
key?: string;
requestedKey?: string;
@@ -67,7 +70,7 @@ interface RouteSeriesTotal {
interface DomainTrafficSnapshot {
epoch: string;
observedAt: string | null;
source: { error: string | null };
source: { error: string | null; activeConnections: number };
overflowConnections: string;
attributionEvents: Record<AttributionOutcome, string>;
tracked: Array<Omit<RouteSeriesTotal, 'deviceId' | 'uploadBytes' | 'downloadBytes'> & {
@@ -170,6 +173,46 @@ function parseConnection(value: unknown, devicesByIp: Map<string, string | null>
};
}
function decimalCounter(value: unknown) {
if (typeof value !== 'string' || !/^\d+$/.test(value)) {
throw new Error('Sing-box вернул невалидный native traffic counter');
}
return BigInt(value);
}
function parseNativeConnection(value: unknown): ParsedConnection {
const connection = record(value);
const inbound = record(connection.inbound);
const origin = record(connection.origin);
const destination = record(connection.destination);
const route = record(connection.route);
const traffic = record(connection.traffic);
const parsed = {
id: String(connection.id || ''),
startedAt: typeof connection.startedAt === 'string' ? connection.startedAt : undefined,
upload: decimalCounter(traffic.uploadBytes),
download: decimalCounter(traffic.downloadBytes),
};
if (!parsed.id) throw new Error('Sing-box вернул native traffic без id');
const source = sourceFor(`${String(inbound.type || '')}/${String(inbound.tag || '')}`);
if (!source) return { ...parsed, outcome: 'unsupported_source' };
const outbound: TrafficRoute = route.kind === 'vpn' || route.kind === 'direct' ? route.kind : 'unknown';
const currentDeviceId = origin.kind === 'device' && typeof origin.id === 'string' && origin.id
? origin.id
: null;
if (!currentDeviceId) return { ...parsed, outcome: 'unknown_device', source, outbound };
const classifiedDomain = classifyDomain(destination.domain);
const domain = classifiedDomain || UNKNOWN_DOMAIN;
return {
...parsed,
outcome: classifiedDomain ? 'classified' : 'unresolved_host',
deviceId: currentDeviceId,
...domain,
source,
outbound,
};
}
export function readSingboxConnections(port: number, timeoutMs = 1500): Promise<unknown> {
return new Promise((resolve, reject) => {
const request = http.get({ host: '127.0.0.1', port, path: '/connections' }, (response) => {
@@ -223,6 +266,9 @@ export function createDomainTrafficService({
const normalSeriesLimit = maxSeries - 2;
let normalSeries = 0;
let previousConnections = new Map<string, PreviousConnection>();
const settledNativeConnections = new Map<string, PreviousConnection>();
let nativeEpoch: string | null = null;
let activeConnections = 0;
let overflowConnections = 0n;
const attributionEvents: Record<AttributionOutcome, bigint> = {
unresolved_host: 0n,
@@ -237,7 +283,7 @@ export function createDomainTrafficService({
let current: DomainTrafficSnapshot = {
epoch,
observedAt: null,
source: { error: null },
source: { error: null, activeConnections: 0 },
overflowConnections: '0',
attributionEvents: { unresolved_host: '0', unknown_device: '0', unsupported_source: '0' },
tracked: [],
@@ -249,7 +295,7 @@ export function createDomainTrafficService({
return {
epoch,
observedAt: current.observedAt,
source: { error },
source: { error, activeConnections },
overflowConnections: overflowConnections.toString(),
attributionEvents: Object.fromEntries(
ATTRIBUTION_OUTCOMES.map((outcome) => [outcome, attributionEvents[outcome].toString()]),
@@ -290,6 +336,153 @@ export function createDomainTrafficService({
};
}
function applyParsedConnections({
connections,
reset,
closedIds = [],
observed,
deviceLabels,
sourceActiveConnections,
}: {
connections: ParsedConnection[];
reset: boolean;
closedIds?: string[];
observed: Date;
deviceLabels: Map<string, string>;
sourceActiveConnections?: number;
}) {
const nextConnections = reset
? new Map<string, PreviousConnection>()
: new Map(previousConnections);
const activityEntries: ActivityEntry[] = [];
for (const connection of connections) {
const settled = settledNativeConnections.get(connection.id);
const previous = previousConnections.get(connection.id)
?? (connection.startedAt && settled?.startedAt === connection.startedAt ? settled : undefined);
if (previous === settled) settledNativeConnections.delete(connection.id);
if (connection.outcome !== 'classified' && previous?.outcome !== connection.outcome) {
attributionEvents[connection.outcome] += 1n;
}
if (connection.outcome !== 'unsupported_source') {
const uploadDelta = previous?.trackedUpload != null && connection.upload >= previous.trackedUpload
? connection.upload - previous.trackedUpload
: connection.upload;
const downloadDelta = previous?.trackedDownload != null && connection.download >= previous.trackedDownload
? connection.download - previous.trackedDownload
: connection.download;
const trackedKey = `${connection.source}\0${connection.outbound}`;
const tracked = trackedTotals.get(trackedKey) || {
source: connection.source,
outbound: connection.outbound,
uploadBytes: 0n,
downloadBytes: 0n,
};
tracked.uploadBytes += uploadDelta;
tracked.downloadBytes += downloadDelta;
trackedTotals.set(trackedKey, tracked);
if (activityEnabled && connection.outbound === 'vpn' && uploadDelta + downloadDelta > 0n) {
activityEntries.push({
device: 'deviceId' in connection
? deviceLabels.get(connection.deviceId) || 'Устройство'
: 'Неизвестное устройство',
service: 'service' in connection ? connection.service : 'Не распознано',
upload: uploadDelta,
download: downloadDelta,
});
}
}
if (connection.outcome === 'unknown_device' || connection.outcome === 'unsupported_source') {
nextConnections.set(connection.id, {
startedAt: connection.startedAt,
outcome: connection.outcome,
countedUpload: previous?.countedUpload ?? null,
countedDownload: previous?.countedDownload ?? null,
trackedUpload: connection.outcome === 'unknown_device'
? connection.upload
: previous?.trackedUpload ?? null,
trackedDownload: connection.outcome === 'unknown_device'
? connection.download
: previous?.trackedDownload ?? null,
});
continue;
}
if (!('deviceId' in connection)) throw new Error('Sing-box вернул невалидную attribution запись');
const requestedKey = `${connection.deviceId}\0${connection.domain}\0${connection.source}`;
let key = previous?.requestedKey === requestedKey && previous.key ? previous.key : requestedKey;
let domain = connection.domain;
let service = connection.service;
if (key !== requestedKey) {
domain = '_other';
service = 'Другие домены';
} else if (!totals.has(key) && normalSeries >= normalSeriesLimit) {
overflowConnections += 1n;
domain = '_other';
service = 'Другие домены';
key = `_other\0${domain}\0${connection.source}`;
} else if (!totals.has(key)) {
normalSeries += 1;
}
const uploadDelta = previous?.countedUpload != null && connection.upload >= previous.countedUpload
? connection.upload - previous.countedUpload
: connection.upload;
const downloadDelta = previous?.countedDownload != null && connection.download >= previous.countedDownload
? connection.download - previous.countedDownload
: connection.download;
const routeKey = `${connection.deviceId}\0${connection.source}\0${connection.outbound}`;
const routeTotal = routeTotals.get(routeKey) || {
deviceId: connection.deviceId,
source: connection.source,
outbound: connection.outbound,
uploadBytes: 0n,
downloadBytes: 0n,
};
routeTotal.uploadBytes += uploadDelta;
routeTotal.downloadBytes += downloadDelta;
routeTotals.set(routeKey, routeTotal);
const total = totals.get(key) || {
deviceId: key === requestedKey ? connection.deviceId : '_other',
domain,
service,
source: connection.source,
uploadBytes: 0n,
downloadBytes: 0n,
};
total.uploadBytes += uploadDelta;
total.downloadBytes += downloadDelta;
totals.set(key, total);
nextConnections.set(connection.id, {
startedAt: connection.startedAt,
outcome: connection.outcome,
key,
requestedKey,
countedUpload: connection.upload,
countedDownload: connection.download,
trackedUpload: connection.upload,
trackedDownload: connection.download,
});
}
for (const id of closedIds) {
const baseline = nextConnections.get(id);
if (baseline?.startedAt) {
settledNativeConnections.delete(id);
settledNativeConnections.set(id, baseline);
while (settledNativeConnections.size > 2_048) {
settledNativeConnections.delete(settledNativeConnections.keys().next().value as string);
}
}
nextConnections.delete(id);
}
previousConnections = nextConnections;
activeConnections = sourceActiveConnections ?? nextConnections.size;
if (activityEnabled) {
activitySamples.push({ at: observed.getTime(), entries: activityEntries });
activitySamples = activitySamples.filter(({ at }) => at >= observed.getTime() - 10_000);
}
current = { ...current, observedAt: observed.toISOString() };
current = buildSnapshot();
return current;
}
async function performRefresh() {
try {
const response = record(await observe());
@@ -306,118 +499,46 @@ export function createDomainTrafficService({
deviceLabels.set(id, publicDeviceLabel(device));
}
const connections = response.connections.map((connection) => parseConnection(connection, devicesByIp));
const activeConnections = new Map<string, PreviousConnection>();
const activityEntries: ActivityEntry[] = [];
for (const connection of connections) {
const previous = previousConnections.get(connection.id);
if (connection.outcome !== 'classified' && previous?.outcome !== connection.outcome) {
attributionEvents[connection.outcome] += 1n;
nativeEpoch = null;
return applyParsedConnections({
connections,
reset: true,
observed: now(),
deviceLabels,
sourceActiveConnections: response.connections.length,
});
} catch (error) {
current = buildSnapshot(error instanceof Error ? error.message : String(error));
throw error;
}
}
function ingestNative(batch: NativeTrafficProjectionBatch) {
try {
const observed = new Date(batch.observedAt);
if (!batch.epoch || Number.isNaN(observed.getTime())) throw new Error('Sing-box вернул невалидный native traffic batch');
const deviceLabels = new Map<string, string>();
for (const value of batch.connections) {
const connection = record(value);
const origin = record(connection.origin);
if (origin.kind === 'device' && typeof origin.id === 'string' && origin.id) {
deviceLabels.set(origin.id, publicDeviceLabel({ alias: origin.label }));
}
if (connection.outcome !== 'unsupported_source') {
const uploadDelta = previous?.trackedUpload != null && connection.upload >= previous.trackedUpload
? connection.upload - previous.trackedUpload
: connection.upload;
const downloadDelta = previous?.trackedDownload != null && connection.download >= previous.trackedDownload
? connection.download - previous.trackedDownload
: connection.download;
const trackedKey = `${connection.source}\0${connection.outbound}`;
const tracked = trackedTotals.get(trackedKey) || {
source: connection.source,
outbound: connection.outbound,
uploadBytes: 0n,
downloadBytes: 0n,
};
tracked.uploadBytes += uploadDelta;
tracked.downloadBytes += downloadDelta;
trackedTotals.set(trackedKey, tracked);
if (activityEnabled && connection.outbound === 'vpn' && uploadDelta + downloadDelta > 0n) {
activityEntries.push({
device: 'deviceId' in connection
? deviceLabels.get(connection.deviceId) || 'Устройство'
: 'Неизвестное устройство',
service: 'service' in connection ? connection.service : 'Не распознано',
upload: uploadDelta,
download: downloadDelta,
});
}
}
if (connection.outcome === 'unknown_device' || connection.outcome === 'unsupported_source') {
activeConnections.set(connection.id, {
outcome: connection.outcome,
countedUpload: previous?.countedUpload ?? null,
countedDownload: previous?.countedDownload ?? null,
trackedUpload: connection.outcome === 'unknown_device'
? connection.upload
: previous?.trackedUpload ?? null,
trackedDownload: connection.outcome === 'unknown_device'
? connection.download
: previous?.trackedDownload ?? null,
});
continue;
}
if (!('deviceId' in connection)) throw new Error('Sing-box вернул невалидную attribution запись');
const requestedKey = `${connection.deviceId}\0${connection.domain}\0${connection.source}`;
let key = previous?.requestedKey === requestedKey && previous.key ? previous.key : requestedKey;
let domain = connection.domain;
let service = connection.service;
if (key !== requestedKey) {
domain = '_other';
service = 'Другие домены';
} else if (!totals.has(key) && normalSeries >= normalSeriesLimit) {
overflowConnections += 1n;
domain = '_other';
service = 'Другие домены';
key = `_other\0${domain}\0${connection.source}`;
} else if (!totals.has(key)) {
normalSeries += 1;
}
const uploadDelta = previous?.countedUpload != null && connection.upload >= previous.countedUpload
? connection.upload - previous.countedUpload
: connection.upload;
const downloadDelta = previous?.countedDownload != null && connection.download >= previous.countedDownload
? connection.download - previous.countedDownload
: connection.download;
const routeKey = `${connection.deviceId}\0${connection.source}\0${connection.outbound}`;
const routeTotal = routeTotals.get(routeKey) || {
deviceId: connection.deviceId,
source: connection.source,
outbound: connection.outbound,
uploadBytes: 0n,
downloadBytes: 0n,
};
routeTotal.uploadBytes += uploadDelta;
routeTotal.downloadBytes += downloadDelta;
routeTotals.set(routeKey, routeTotal);
const total = totals.get(key) || {
deviceId: key === requestedKey ? connection.deviceId : '_other',
domain,
service,
source: connection.source,
uploadBytes: 0n,
downloadBytes: 0n,
};
total.uploadBytes += uploadDelta;
total.downloadBytes += downloadDelta;
totals.set(key, total);
activeConnections.set(connection.id, {
outcome: connection.outcome,
key,
requestedKey,
countedUpload: connection.upload,
countedDownload: connection.download,
trackedUpload: connection.upload,
trackedDownload: connection.download,
});
}
previousConnections = activeConnections;
const observed = now();
if (activityEnabled) {
activitySamples.push({ at: observed.getTime(), entries: activityEntries });
activitySamples = activitySamples.filter(({ at }) => at >= observed.getTime() - 10_000);
if (nativeEpoch !== batch.epoch) {
nativeEpoch = batch.epoch;
previousConnections = new Map();
settledNativeConnections.clear();
}
current = { ...current, observedAt: observed.toISOString() };
current = buildSnapshot();
return current;
const connections = batch.connections.map(parseNativeConnection);
const result = applyParsedConnections({
connections,
reset: batch.reset,
closedIds: batch.closedIds,
observed,
deviceLabels,
});
return result;
} catch (error) {
current = buildSnapshot(error instanceof Error ? error.message : String(error));
throw error;
@@ -492,5 +613,5 @@ export function createDomainTrafficService({
};
}
return { snapshot: () => current, refresh, enableActivity, disableActivity, activitySnapshot };
return { snapshot: () => current, refresh, ingestNative, enableActivity, disableActivity, activitySnapshot };
}
+777
View File
@@ -0,0 +1,777 @@
import { isIP } from 'node:net';
import { createClient } from '@connectrpc/connect';
import { createGrpcTransport } from '@connectrpc/connect-node';
import type { LiveTrafficConnection, LiveTrafficSnapshot, LiveTrafficSourceState } from '../../shared/liveTraffic.js';
import {
ConnectionEventType,
StartedService,
type Connection,
type ConnectionEvents,
type Status,
} from '../generated/daemon/started_service_pb.js';
const CONNECTION_INTERVAL = 1_000_000_000n;
const SUPPORTED_SINGBOX_VERSION = '1.14.0-rc.5';
const SUPPORTED_SINGBOX_API_VERSION = 4;
const MAX_VISIBLE = 256;
const MAX_SETTLED_IDS = 2048;
const MAX_RECENT_CONNECTIONS = 2048;
const RECENT_CONNECTION_MS = 30_000;
const RETRY_MS = 500;
const STALE_MS = 3_000;
const VPN_OUTBOUND_TYPES = new Set(['vless', 'vmess', 'trojan', 'shadowsocks', 'hysteria2']);
interface ActiveConnection {
value: Omit<LiveTrafficConnection, 'traffic'>;
upload: bigint;
download: bigint;
uploadRate: bigint;
downloadRate: bigint;
}
interface LiveTrafficLedgerOptions {
enabled?: boolean;
now?: () => Date;
gateway?: boolean;
resolveOrigin?: (sourceIp: string) => LiveTrafficConnection['origin'];
}
export interface NativeTrafficProjectionBatch {
epoch: string;
observedAt: string;
reset: boolean;
connections: LiveTrafficConnection[];
closedIds: string[];
}
interface NativeTrafficClient {
getVersion(
input: Record<string, never>,
options: { signal: AbortSignal; headers?: Record<string, string> },
): Promise<{ version: string; apiVersion: number }>;
getStartedAt(
input: Record<string, never>,
options: { signal: AbortSignal; headers?: Record<string, string> },
): Promise<{ startedAt: bigint }>;
subscribeConnections(
input: { interval: bigint },
options: { signal: AbortSignal; headers?: Record<string, string> },
): AsyncIterable<ConnectionEvents>;
subscribeStatus(
input: { interval: bigint },
options: { signal: AbortSignal; headers?: Record<string, string> },
): AsyncIterable<Status>;
}
interface LiveTrafficServiceOptions {
port: number;
enabled: boolean;
isRuntimeRunning: () => boolean;
gateway?: boolean;
resolveOrigin?: (sourceIp: string) => LiveTrafficConnection['origin'];
authorization?: () => string | null;
unavailableError?: string | null;
onProjection?: (batch: NativeTrafficProjectionBatch) => Promise<void> | void;
clientFactory?: (port: number) => NativeTrafficClient;
}
function positive(value: bigint) {
return value > 0n ? value : 0n;
}
function safeError(error: unknown) {
return (error instanceof Error ? error.message : String(error || 'Native traffic stream unavailable'))
.replace(/https?:\/\/\S+/gi, '[endpoint]')
.slice(0, 300);
}
function parseEndpoint(value: string) {
const text = value.trim();
const bracketed = /^\[(.+)]:(\d+)$/.exec(text);
if (bracketed) return { ip: bracketed[1], port: Number(bracketed[2]) };
const separator = text.lastIndexOf(':');
if (separator > 0 && !text.slice(0, separator).includes(':') && /^\d+$/.test(text.slice(separator + 1))) {
return { ip: text.slice(0, separator), port: Number(text.slice(separator + 1)) };
}
return { ip: text, port: null };
}
function isoFromMilliseconds(value: bigint, fallback: Date) {
const milliseconds = Number(value);
return Number.isSafeInteger(milliseconds) && milliseconds > 0
? new Date(milliseconds).toISOString()
: fallback.toISOString();
}
function routeKindFromValues(
outbound: string | null,
outboundType: string | null,
chain: string[] = [],
gateway = false,
): 'vpn' | 'direct' | 'other' {
if (outbound === 'direct' || outboundType === 'direct') return 'direct';
if (gateway) {
if (chain[0] === 'direct') return 'direct';
return chain.length > 0 ? 'vpn' : 'other';
}
return outboundType && VPN_OUTBOUND_TYPES.has(outboundType) ? 'vpn' : 'other';
}
function routeKind(connection: Connection, gateway: boolean): 'vpn' | 'direct' | 'other' {
return routeKindFromValues(
connection.outbound || null,
connection.outboundType || null,
connection.chainList,
gateway,
);
}
function macOrigin(): LiveTrafficConnection['origin'] {
return {
kind: 'this-mac',
id: null,
label: 'Этот Mac',
provenance: 'client-runtime',
};
}
function unknownOrigin(sourceIp: string): LiveTrafficConnection['origin'] {
return {
kind: 'unknown',
id: null,
label: sourceIp || 'Неизвестное устройство',
provenance: 'unknown',
};
}
function mapConnection(
connection: Connection,
now: Date,
gateway: boolean,
resolveOrigin?: (sourceIp: string) => LiveTrafficConnection['origin'],
): Omit<LiveTrafficConnection, 'traffic'> {
const source = parseEndpoint(connection.source);
const destination = parseEndpoint(connection.destination);
const destinationHost = destination.ip.trim();
const domain = connection.domain.trim().toLowerCase()
|| (destinationHost && !isIP(destinationHost) ? destinationHost.toLowerCase() : null);
const destinationIp = isIP(destinationHost) ? destinationHost : null;
return {
id: connection.id,
startedAt: isoFromMilliseconds(connection.createdAt, now),
closedAt: null,
inbound: { tag: connection.inbound, type: connection.inboundType },
network: connection.network === 'tcp' || connection.network === 'udp' ? connection.network : 'unknown',
protocol: connection.protocol || null,
source,
destination: {
domain,
ip: destinationIp,
port: destination.port,
provenance: domain || destinationIp ? 'sing-box' : 'unknown',
},
origin: resolveOrigin?.(source.ip) ?? (gateway ? unknownOrigin(source.ip) : macOrigin()),
route: {
kind: routeKind(connection, gateway),
scope: 'local-sing-box',
outbound: connection.outbound || null,
outboundType: connection.outboundType || null,
chain: [...connection.chainList],
rule: connection.rule || null,
},
};
}
function mergeFinalMetadata(
current: Omit<LiveTrafficConnection, 'traffic'> | undefined,
final: Omit<LiveTrafficConnection, 'traffic'> | null,
gateway: boolean,
) {
if (!current) return final;
if (!final) return current;
const domain = final.destination.domain ?? current.destination.domain;
const ip = final.destination.ip ?? current.destination.ip;
const outbound = final.route.outbound ?? current.route.outbound;
const outboundType = final.route.outboundType ?? current.route.outboundType;
return {
...current,
inbound: {
tag: final.inbound.tag || current.inbound.tag,
type: final.inbound.type || current.inbound.type,
},
network: final.network === 'unknown' ? current.network : final.network,
protocol: final.protocol ?? current.protocol,
source: {
ip: final.source.ip || current.source.ip,
port: final.source.port ?? current.source.port,
},
destination: {
domain,
ip,
port: final.destination.port ?? current.destination.port,
provenance: domain || ip ? 'sing-box' as const : 'unknown' as const,
},
route: {
...current.route,
kind: routeKindFromValues(
outbound,
outboundType,
final.route.chain.length ? final.route.chain : current.route.chain,
gateway,
),
outbound,
outboundType,
chain: final.route.chain.length ? final.route.chain : current.route.chain,
rule: final.route.rule ?? current.route.rule,
},
};
}
export function createLiveTrafficLedger({
enabled = true,
now = () => new Date(),
gateway = false,
resolveOrigin,
}: LiveTrafficLedgerOptions = {}) {
let epoch: string | null = null;
let sequence = 0;
let observedAt: string | null = null;
let state: LiveTrafficSourceState = enabled ? 'connecting' : 'disabled';
let singBoxVersion: string | null = null;
let singBoxApiVersion: number | null = null;
let error: string | null = null;
let accountedUpload = 0n;
let accountedDownload = 0n;
let explicitGapUpload = 0n;
let explicitGapDownload = 0n;
let statusGapUpload = 0n;
let statusGapDownload = 0n;
let mismatchCount = 0;
let resetSeen = false;
let statusSeen = false;
let projectionError = false;
let lastStatus: Status | null = null;
const active = new Map<string, ActiveConnection>();
const recent = new Map<string, ActiveConnection>();
const settled = new Map<string, true>();
const changed = (updateObservedAt = true) => {
sequence += 1;
if (updateObservedAt) observedAt = now().toISOString();
};
const settle = (id: string) => {
if (!id) return;
settled.delete(id);
settled.set(id, true);
while (settled.size > MAX_SETTLED_IDS) settled.delete(settled.keys().next().value as string);
};
const rememberRecent = (connection: ActiveConnection) => {
const id = connection.value.id;
recent.delete(id);
recent.set(id, connection);
while (recent.size > MAX_RECENT_CONNECTIONS) recent.delete(recent.keys().next().value as string);
};
const pruneRecent = (timestamp: Date) => {
const cutoff = timestamp.getTime() - RECENT_CONNECTION_MS;
for (const [id, connection] of recent) {
const closedAt = connection.value.closedAt;
if (closedAt !== null && Date.parse(closedAt) <= cutoff) recent.delete(id);
}
};
const clearEpoch = () => {
active.clear();
recent.clear();
settled.clear();
accountedUpload = 0n;
accountedDownload = 0n;
explicitGapUpload = 0n;
explicitGapDownload = 0n;
statusGapUpload = 0n;
statusGapDownload = 0n;
mismatchCount = 0;
resetSeen = false;
statusSeen = false;
projectionError = false;
lastStatus = null;
};
const reconcileStatus = (countMismatch = false) => {
if (!lastStatus) return;
const statusUpload = positive(lastStatus.uplinkTotal);
const statusDownload = positive(lastStatus.downlinkTotal);
statusGapUpload = statusUpload > accountedUpload ? statusUpload - accountedUpload : 0n;
statusGapDownload = statusDownload > accountedDownload ? statusDownload - accountedDownload : 0n;
const mismatch = active.size !== lastStatus.connectionsIn
|| statusGapUpload > 0n
|| statusGapDownload > 0n
|| accountedUpload > statusUpload
|| accountedDownload > statusDownload;
if (countMismatch) mismatchCount = mismatch ? mismatchCount + 1 : 0;
if (resetSeen && statusSeen) state = mismatchCount >= 3 || projectionError ? 'degraded' : 'live';
};
const addUnattributed = (upload: bigint, download: bigint) => {
const safeUpload = positive(upload);
const safeDownload = positive(download);
explicitGapUpload += safeUpload;
explicitGapDownload += safeDownload;
accountedUpload += safeUpload;
accountedDownload += safeDownload;
};
const project = (connection: ActiveConnection): LiveTrafficConnection => ({
...connection.value,
origin: resolveOrigin?.(connection.value.source.ip) ?? connection.value.origin,
traffic: {
uploadBytes: connection.upload.toString(),
downloadBytes: connection.download.toString(),
uploadBytesPerSecond: connection.uploadRate.toString(),
downloadBytesPerSecond: connection.downloadRate.toString(),
},
});
return {
beginEpoch(startedAt: bigint, version: string, apiVersion: number) {
const nextEpoch = `sing-box-${startedAt}`;
const epochChanged = nextEpoch !== epoch;
if (epochChanged) clearEpoch();
epoch = nextEpoch;
singBoxVersion = version;
singBoxApiVersion = apiVersion;
error = null;
state = 'connecting';
changed(epochChanged || observedAt === null);
},
applyConnections(batch: ConnectionEvents) {
const timestamp = now();
const touched = new Set<string>();
const closedIds = new Set<string>();
const closedConnections = new Map<string, LiveTrafficConnection>();
pruneRecent(timestamp);
if (batch.reset || batch.events.some(({ type }) => (
type === ConnectionEventType.CONNECTION_EVENT_UPDATE
))) {
for (const connection of active.values()) {
connection.uploadRate = 0n;
connection.downloadRate = 0n;
}
}
if (batch.reset) {
const next = new Map<string, ActiveConnection>();
for (const event of batch.events) {
const connection = event.connection;
if (!connection?.id) continue;
touched.add(connection.id);
const upload = positive(connection.uplinkTotal);
const download = positive(connection.downlinkTotal);
const mapped = mapConnection(connection, timestamp, gateway, resolveOrigin);
const recentPrevious = recent.get(connection.id);
const previous = active.get(connection.id)
?? (recentPrevious?.value.startedAt === mapped.startedAt ? recentPrevious : undefined);
if (connection.closedAt > 0n || event.closedAt > 0n) {
const alreadySettled = settled.has(connection.id);
if (!alreadySettled) {
accountedUpload += previous ? positive(upload - previous.upload) : upload;
accountedDownload += previous ? positive(download - previous.download) : download;
const closedAt = event.closedAt > 0n
? isoFromMilliseconds(event.closedAt, timestamp)
: isoFromMilliseconds(connection.closedAt, timestamp);
const settledConnection = {
value: { ...mapped, closedAt },
upload,
download,
uploadRate: 0n,
downloadRate: 0n,
};
closedConnections.set(connection.id, project(settledConnection));
rememberRecent(settledConnection);
}
closedIds.add(connection.id);
settle(connection.id);
continue;
}
recent.delete(connection.id);
settled.delete(connection.id);
accountedUpload += previous ? positive(upload - previous.upload) : upload;
accountedDownload += previous ? positive(download - previous.download) : download;
next.set(connection.id, {
value: mapped,
upload,
download,
uploadRate: 0n,
downloadRate: 0n,
});
}
for (const [id] of active) {
if (next.has(id)) continue;
closedIds.add(id);
settle(id);
}
active.clear();
for (const [id, connection] of next) active.set(id, connection);
resetSeen = true;
} else {
for (const event of batch.events) {
const id = event.id || event.connection?.id || '';
if (!id) continue;
touched.add(id);
if (event.type === ConnectionEventType.CONNECTION_EVENT_NEW) {
const connection = event.connection;
if (!connection || active.has(id)) continue;
if (connection.closedAt > 0n || event.closedAt > 0n) {
settle(id);
continue;
}
const mapped = mapConnection(connection, timestamp, gateway, resolveOrigin);
const previous = recent.get(id);
if (!previous && settled.has(id)) continue;
if (previous && mapped.startedAt <= previous.value.startedAt) continue;
recent.delete(id);
settled.delete(id);
const upload = positive(connection.uplinkTotal);
const download = positive(connection.downlinkTotal);
active.set(id, {
value: mapped,
upload,
download,
uploadRate: 0n,
downloadRate: 0n,
});
accountedUpload += upload;
accountedDownload += download;
continue;
}
if (event.type === ConnectionEventType.CONNECTION_EVENT_UPDATE) {
const upload = positive(event.uplinkDelta);
const download = positive(event.downlinkDelta);
const connection = active.get(id);
if (!connection) continue;
connection.upload += upload;
connection.download += download;
connection.uploadRate += upload;
connection.downloadRate += download;
accountedUpload += upload;
accountedDownload += download;
continue;
}
if (event.type === ConnectionEventType.CONNECTION_EVENT_CLOSED
&& !settled.has(id) && !recent.has(id)) {
const current = active.get(id);
const finalUpload = event.connection ? positive(event.connection.uplinkTotal) : 0n;
const finalDownload = event.connection ? positive(event.connection.downlinkTotal) : 0n;
const tailUpload = event.connection
? (current && finalUpload > current.upload ? finalUpload - current.upload : current ? 0n : finalUpload)
: positive(event.uplinkDelta);
const tailDownload = event.connection
? (current && finalDownload > current.download ? finalDownload - current.download : current ? 0n : finalDownload)
: positive(event.downlinkDelta);
if (current) {
accountedUpload += tailUpload;
accountedDownload += tailDownload;
} else if (event.connection) {
addUnattributed(tailUpload, tailDownload);
}
const metadata = mergeFinalMetadata(
current?.value,
event.connection ? mapConnection(event.connection, timestamp, gateway, resolveOrigin) : null,
gateway,
);
if (metadata) {
const closedAt = event.closedAt > 0n
? isoFromMilliseconds(event.closedAt, timestamp)
: event.connection && event.connection.closedAt > 0n
? isoFromMilliseconds(event.connection.closedAt, timestamp)
: timestamp.toISOString();
const settledConnection = {
value: { ...metadata, id, closedAt },
upload: current ? current.upload + tailUpload : finalUpload,
download: current ? current.download + tailDownload : finalDownload,
uploadRate: 0n,
downloadRate: 0n,
};
closedConnections.set(id, project(settledConnection));
rememberRecent(settledConnection);
}
active.delete(id);
closedIds.add(id);
settle(id);
}
}
}
reconcileStatus();
changed();
if (!epoch) return null;
const connections: LiveTrafficConnection[] = [];
for (const id of touched) {
const settledConnection = closedConnections.get(id);
if (settledConnection) connections.push(settledConnection);
else {
const connection = active.get(id);
if (connection) connections.push(project(connection));
}
}
return {
epoch,
observedAt: timestamp.toISOString(),
reset: batch.reset,
connections,
closedIds: [...closedIds],
} satisfies NativeTrafficProjectionBatch;
},
applyStatus(status: Status) {
const timestamp = now();
pruneRecent(timestamp);
lastStatus = status;
statusSeen = true;
reconcileStatus(true);
changed();
return epoch && state === 'live' ? {
epoch,
observedAt: timestamp.toISOString(),
reset: false,
connections: [],
closedIds: [],
} satisfies NativeTrafficProjectionBatch : null;
},
markStopped() {
if (state === 'stopped' && active.size === 0) return;
clearEpoch();
epoch = null;
state = 'stopped';
error = null;
changed();
},
markTransportError(reason: unknown) {
error = safeError(reason);
state = epoch ? 'stale' : 'connecting';
changed(false);
},
markProjectionError(reason: unknown) {
projectionError = true;
error = safeError(reason);
state = 'degraded';
changed(false);
},
markProjectionHealthy() {
if (!projectionError) return;
projectionError = false;
error = null;
reconcileStatus();
changed(false);
},
markUnavailable(reason: unknown) {
clearEpoch();
epoch = null;
state = 'incompatible';
error = safeError(reason);
changed();
},
markIncompatible(version: string, apiVersion: number) {
clearEpoch();
epoch = null;
singBoxVersion = version;
singBoxApiVersion = apiVersion;
state = 'incompatible';
error = `sing-box ${version} API ${apiVersion} is incompatible`;
changed();
},
snapshot(): LiveTrafficSnapshot {
const recentCutoff = now().getTime() - RECENT_CONNECTION_MS;
const all = [...active.values()];
all.sort((left, right) => right.value.startedAt.localeCompare(left.value.startedAt)
|| left.value.id.localeCompare(right.value.id));
const allRecent = [...recent.values()].filter(({ value }) => (
value.closedAt !== null && Date.parse(value.closedAt) > recentCutoff
));
allRecent.sort((left, right) => (right.value.closedAt ?? '').localeCompare(left.value.closedAt ?? '')
|| left.value.id.localeCompare(right.value.id));
const visible = all.slice(0, MAX_VISIBLE);
if (visible.length < MAX_VISIBLE) visible.push(...allRecent.slice(0, MAX_VISIBLE - visible.length));
const connections = visible.map(project);
const recognized = all.filter(({ value }) => value.destination.domain !== null).length;
return {
apiVersion: 1,
epoch,
sequence,
observedAt,
capabilities: {
lifecycle: true,
deviceAttribution: Boolean(resolveOrigin),
applicationAttribution: false,
},
source: {
transport: 'native',
state,
completeness: 'lifecycle',
singBoxVersion,
singBoxApiVersion,
error,
unattributedUploadBytes: (explicitGapUpload + statusGapUpload).toString(),
unattributedDownloadBytes: (explicitGapDownload + statusGapDownload).toString(),
},
summary: {
active: all.length,
recent: allRecent.length,
visible: connections.length,
recognized,
unresolved: all.length - recognized,
unresolvedOrigin: all.filter((connection) => project(connection).origin.kind === 'unknown').length,
truncated: all.length + allRecent.length > MAX_VISIBLE,
},
connections,
};
},
};
}
function defaultClientFactory(port: number): NativeTrafficClient {
return createClient(StartedService, createGrpcTransport({
baseUrl: `http://127.0.0.1:${port}`,
}));
}
function delay(milliseconds: number) {
return new Promise<void>((resolve) => {
const timer = setTimeout(resolve, milliseconds);
timer.unref();
});
}
export function createLiveTrafficService({
port,
enabled,
isRuntimeRunning,
gateway = false,
resolveOrigin,
authorization,
unavailableError = null,
onProjection,
clientFactory = defaultClientFactory,
}: LiveTrafficServiceOptions) {
const ledger = createLiveTrafficLedger({ enabled, gateway, resolveOrigin });
if (!enabled && unavailableError) ledger.markUnavailable(unavailableError);
let stopped = false;
let controller: AbortController | null = null;
let running: Promise<void> | null = null;
let failedProjection: NativeTrafficProjectionBatch | null = null;
let projectionQueue = Promise.resolve();
const project = (batch: NativeTrafficProjectionBatch) => {
if (!onProjection) return Promise.resolve();
const run = async () => {
if (failedProjection) {
const retry = failedProjection;
try {
await onProjection(retry);
failedProjection = null;
} catch (reason) {
ledger.markProjectionError(reason);
throw reason;
}
}
try {
await onProjection(batch);
ledger.markProjectionHealthy();
} catch (reason) {
failedProjection = batch;
ledger.markProjectionError(reason);
throw reason;
}
};
const result = projectionQueue.then(run, run);
projectionQueue = result.catch(() => undefined);
return result;
};
const attach = async () => {
const client = clientFactory(port);
const signal = controller?.signal;
if (!signal) return;
const secret = authorization?.();
const options = secret ? { signal, headers: { authorization: `Bearer ${secret}` } } : { signal };
const version = await client.getVersion({}, options);
if (version.version !== SUPPORTED_SINGBOX_VERSION
|| version.apiVersion !== SUPPORTED_SINGBOX_API_VERSION) {
ledger.markIncompatible(version.version, version.apiVersion);
return;
}
const started = await client.getStartedAt({}, options);
ledger.beginEpoch(started.startedAt, version.version, version.apiVersion);
let lastStatusAt = Date.now();
const watchdog = setInterval(() => {
if (!isRuntimeRunning() || Date.now() - lastStatusAt > STALE_MS) {
controller?.abort(new Error(isRuntimeRunning() ? 'Native traffic status is stale' : 'sing-box stopped'));
}
}, RETRY_MS);
watchdog.unref();
const streams = [
(async () => {
for await (const batch of client.subscribeConnections({ interval: CONNECTION_INTERVAL }, options)) {
const projection = ledger.applyConnections(batch);
if (projection) await project(projection);
}
})(),
(async () => {
for await (const status of client.subscribeStatus({ interval: CONNECTION_INTERVAL }, options)) {
lastStatusAt = Date.now();
const projection = ledger.applyStatus(status);
if (projection) await project(projection);
}
})(),
];
try {
await Promise.race(streams);
throw new Error('Native traffic stream ended');
} finally {
controller?.abort(new Error('Native traffic stream ended'));
await Promise.allSettled(streams);
clearInterval(watchdog);
}
};
const loop = async () => {
while (!stopped) {
if (!isRuntimeRunning()) {
ledger.markStopped();
await delay(RETRY_MS);
continue;
}
controller = new AbortController();
try {
await attach();
} catch (reason) {
if (!stopped) {
if (isRuntimeRunning()) ledger.markTransportError(reason);
else ledger.markStopped();
}
} finally {
controller = null;
}
if (!stopped) await delay(RETRY_MS);
}
};
return {
start() {
if (!enabled || running) return;
running = loop();
},
async stop() {
stopped = true;
controller?.abort();
await running;
},
snapshot: ledger.snapshot,
};
}
export type LiveTrafficService = ReturnType<typeof createLiveTrafficService>;
+22 -2
View File
@@ -99,6 +99,8 @@ export function buildGatewayConfig(subscriptionConfig: unknown, selectedTag: unk
routeRules = [],
}: { clientDirect?: boolean; routeRules?: unknown } = {}) {
const clientMode = settings.appMode === 'client';
const nativeTraffic = settings.singboxTrafficSource === 'native'
|| settings.singboxTrafficSource === 'shadow';
const directClient = clientMode && clientDirect;
const vpnOutbound = selectedOutbound(subscriptionConfig, selectedTag);
const outboundTag = directClient ? 'direct' : vpnOutbound.tag;
@@ -158,13 +160,21 @@ export function buildGatewayConfig(subscriptionConfig: unknown, selectedTag: unk
return {
log: { level: settings.logLevel, timestamp: true },
...(nativeTraffic ? {
services: [{
type: 'api',
listen: '127.0.0.1',
listen_port: settings.singboxNativeApiPort,
dashboard: false,
}],
} : {}),
experimental: {
cache_file: { enabled: true, path: settings.cachePath },
...(!clientMode ? {
clash_api: { external_controller: `127.0.0.1:${settings.singboxApiPort}` },
} : {}),
},
dns: { independent_cache: true },
dns: nativeTraffic ? {} : { independent_cache: true },
inbounds,
outbounds: [
vpnOutbound,
@@ -185,6 +195,8 @@ export function buildDualChannelGatewayConfig(
{ routeRules = [], defaultRole = 'primary' }: { routeRules?: unknown; defaultRole?: 'primary' | 'reserve' } = {},
) {
if (settings.appMode === 'client') throw new Error('Dual-channel config доступен только Gateway');
const nativeTraffic = settings.singboxTrafficSource === 'native'
|| settings.singboxTrafficSource === 'shadow';
const primary = selectedOutbound(
channels.primary.subscriptionConfig,
channels.primary.selectedServerId,
@@ -204,11 +216,19 @@ export function buildDualChannelGatewayConfig(
const userInbounds = [TPROXY_INBOUND, MIXED_INBOUND];
return {
log: { level: settings.logLevel, timestamp: true },
...(nativeTraffic ? {
services: [{
type: 'api',
listen: '127.0.0.1',
listen_port: settings.singboxNativeApiPort,
dashboard: false,
}],
} : {}),
experimental: {
cache_file: { enabled: true, path: settings.cachePath },
clash_api: { external_controller: `127.0.0.1:${settings.singboxApiPort}` },
},
dns: { independent_cache: true },
dns: nativeTraffic ? {} : { independent_cache: true },
inbounds: [
{ type: 'tproxy', tag: TPROXY_INBOUND, listen: '::', listen_port: settings.tproxyPort },
{ type: 'mixed', tag: MIXED_INBOUND, listen: settings.bindIp, listen_port: settings.proxyPort, set_system_proxy: false },
+68 -15
View File
@@ -3,33 +3,64 @@ import fs from 'node:fs';
import { spawn, spawnSync, type ChildProcess } from 'node:child_process';
import { setGatewayInterception } from './gatewayRouting.js';
import { HarborError } from '../shared/errors.js';
import {
materializeGatewayNativeConfig,
materializeGatewaySnapshotConfig,
} from './gatewayNativeRuntime.js';
export function createSingboxRuntime({
configPath,
gateway = false,
tproxyChain = '',
gatewayRuntimeConfigPath,
nativeApi,
}: {
configPath: string;
gateway?: boolean;
tproxyChain?: string;
gatewayRuntimeConfigPath?: string;
nativeApi?: {
apiPort: number;
secretPath: string;
runtimeConfigPath: string;
};
}) {
let child: ChildProcess | null = null;
let configHash = '';
let startedAt: string | null = null;
let nativeApiSecret: string | null = null;
let nativeApiWarning: string | null = null;
const state = () => ({ running: Boolean(child), startedAt });
const state = () => ({ running: Boolean(child), startedAt, nativeApiWarning });
function checked(configFile: string) {
const check = spawnSync('sing-box', ['check', '-c', configFile], { encoding: 'utf8' });
if (check.status !== 0) {
throw new HarborError('CONFIG_INVALID', {
cause: new Error((check.stderr || check.stdout || check.error?.message || 'sing-box check failed').trim()),
});
}
}
function checkConfig(config: unknown) {
if (nativeApi) {
const materialized = materializeGatewayNativeConfig(config, nativeApi);
checked(materialized.configPath);
return {
valid: true,
...(materialized.warning ? { warning: materialized.warning } : {}),
};
}
if (gatewayRuntimeConfigPath) {
const materialized = materializeGatewaySnapshotConfig(config, gatewayRuntimeConfigPath);
checked(materialized.configPath);
return { valid: true };
}
const directory = fs.mkdtempSync(`${configPath}.check-`);
const candidatePath = `${directory}/config.json`;
try {
fs.writeFileSync(candidatePath, JSON.stringify(config));
const check = spawnSync('sing-box', ['check', '-c', candidatePath], { encoding: 'utf8' });
if (check.status !== 0) {
throw new HarborError('CONFIG_INVALID', {
cause: new Error((check.stderr || check.stdout || check.error?.message || 'sing-box check failed').trim()),
});
}
checked(candidatePath);
return { valid: true };
} finally {
fs.rmSync(directory, { recursive: true, force: true });
@@ -41,6 +72,7 @@ export function createSingboxRuntime({
if (!child) {
configHash = '';
startedAt = null;
nativeApiSecret = null;
return state();
}
@@ -48,6 +80,7 @@ export function createSingboxRuntime({
child = null;
configHash = '';
startedAt = null;
nativeApiSecret = null;
await new Promise<void>((resolve) => {
const timeout = setTimeout(() => {
current.kill('SIGKILL');
@@ -65,23 +98,37 @@ export function createSingboxRuntime({
async function apply({ force = false } = {}) {
if (!fs.existsSync(configPath)) {
await stop();
nativeApiWarning = null;
return state();
}
const check = spawnSync('sing-box', ['check', '-c', configPath], { encoding: 'utf8' });
if (check.status !== 0) {
throw new HarborError('CONFIG_INVALID', {
cause: new Error((check.stderr || check.stdout || check.error?.message || 'sing-box check failed').trim()),
});
let materialized = { configPath, secret: null as string | null, warning: null as string | null };
if (nativeApi || gatewayRuntimeConfigPath) {
let config: unknown;
try {
config = JSON.parse(fs.readFileSync(configPath, 'utf8'));
} catch (cause) {
throw new HarborError('CONFIG_INVALID', { cause });
}
materialized = nativeApi
? materializeGatewayNativeConfig(config, nativeApi)
: materializeGatewaySnapshotConfig(config, gatewayRuntimeConfigPath!);
}
checked(materialized.configPath);
const nextHash = crypto.createHash('sha256').update(fs.readFileSync(configPath)).digest('hex');
if (!force && child && nextHash === configHash) return state();
const nextHash = crypto.createHash('sha256')
.update(fs.readFileSync(materialized.configPath))
.digest('hex');
if (!force && child && nextHash === configHash) {
nativeApiSecret = materialized.secret;
nativeApiWarning = materialized.warning;
return state();
}
await stop();
let current: ChildProcess;
try {
current = spawn('sing-box', ['run', '-c', configPath], {
current = spawn('sing-box', ['run', '-c', materialized.configPath], {
stdio: ['ignore', 'inherit', 'inherit'],
});
await new Promise<void>((resolve, reject) => {
@@ -94,6 +141,8 @@ export function createSingboxRuntime({
child = current;
configHash = nextHash;
startedAt = new Date().toISOString();
nativeApiSecret = materialized.secret;
nativeApiWarning = materialized.warning;
try {
if (gateway) setGatewayInterception(true, tproxyChain);
} catch (error) {
@@ -101,6 +150,7 @@ export function createSingboxRuntime({
child = null;
configHash = '';
startedAt = null;
nativeApiSecret = null;
throw new HarborError('PROCESS_START_FAILED', { cause: error });
}
current.once('exit', () => {
@@ -108,6 +158,7 @@ export function createSingboxRuntime({
child = null;
configHash = '';
startedAt = null;
nativeApiSecret = null;
if (gateway) setGatewayInterception(false, tproxyChain);
});
return state();
@@ -116,6 +167,8 @@ export function createSingboxRuntime({
return {
get running() { return Boolean(child); },
get startedAt() { return startedAt; },
get nativeApiSecret() { return nativeApiSecret; },
get nativeApiWarning() { return nativeApiWarning; },
refresh: async () => state(),
checkConfig,
apply,
+213
View File
@@ -0,0 +1,213 @@
export type LiveTrafficSourceState =
| 'connecting'
| 'live'
| 'degraded'
| 'stale'
| 'stopped'
| 'incompatible'
| 'disabled';
export interface LiveTrafficConnection {
id: string;
startedAt: string;
closedAt: string | null;
inbound: { tag: string; type: string };
network: 'tcp' | 'udp' | 'unknown';
protocol: string | null;
source: { ip: string; port: number | null };
destination: {
domain: string | null;
ip: string | null;
port: number | null;
provenance: 'sing-box' | 'unknown';
};
origin: {
kind: 'this-mac' | 'device' | 'unknown';
id: string | null;
label: string;
provenance: 'client-runtime' | 'source-ip' | 'unknown';
};
route: {
kind: 'vpn' | 'direct' | 'other';
scope: 'local-sing-box';
outbound: string | null;
outboundType: string | null;
chain: string[];
rule: string | null;
};
traffic: {
uploadBytes: string;
downloadBytes: string;
uploadBytesPerSecond: string;
downloadBytesPerSecond: string;
};
}
export interface LiveTrafficSnapshot {
apiVersion: 1;
epoch: string | null;
sequence: number;
observedAt: string | null;
capabilities: {
lifecycle: true;
deviceAttribution: boolean;
applicationAttribution: false;
};
source: {
transport: 'native';
state: LiveTrafficSourceState;
completeness: 'lifecycle';
singBoxVersion: string | null;
singBoxApiVersion: number | null;
error: string | null;
unattributedUploadBytes: string;
unattributedDownloadBytes: string;
};
summary: {
active: number;
recent: number;
visible: number;
recognized: number;
unresolved: number;
unresolvedOrigin: number;
truncated: boolean;
};
connections: LiveTrafficConnection[];
}
const sourceStates = new Set<LiveTrafficSourceState>([
'connecting', 'live', 'degraded', 'stale', 'stopped', 'incompatible', 'disabled',
]);
const decimal = /^\d+$/;
function isoTimestamp(value: unknown) {
return typeof value === 'string'
&& !Number.isNaN(Date.parse(value))
&& new Date(value).toISOString() === value;
}
function decimalString(value: unknown) {
return typeof value === 'string' && decimal.test(value);
}
function record(value: unknown): Record<string, unknown> {
if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error('Expected object');
return value as Record<string, unknown>;
}
function nullableString(value: unknown) {
if (value !== null && typeof value !== 'string') throw new Error('Expected nullable string');
}
function nonNegativeInteger(value: unknown) {
if (!Number.isSafeInteger(value) || Number(value) < 0) throw new Error('Expected non-negative integer');
}
function nullablePort(value: unknown) {
if (value !== null && (!Number.isInteger(value) || Number(value) < 0 || Number(value) > 65_535)) {
throw new Error('Expected nullable port');
}
}
export function assertLiveTrafficSnapshot(value: unknown): LiveTrafficSnapshot {
const snapshot = record(value);
if (snapshot.apiVersion !== 1) throw new Error('Expected live traffic apiVersion 1');
nullableString(snapshot.epoch);
nullableString(snapshot.observedAt);
nonNegativeInteger(snapshot.sequence);
const capabilities = record(snapshot.capabilities);
if (capabilities.lifecycle !== true || typeof capabilities.deviceAttribution !== 'boolean'
|| capabilities.applicationAttribution !== false) throw new Error('Invalid traffic capabilities');
const source = record(snapshot.source);
if (source.transport !== 'native' || source.completeness !== 'lifecycle'
|| !sourceStates.has(source.state as LiveTrafficSourceState)) throw new Error('Invalid traffic source');
nullableString(source.singBoxVersion);
nullableString(source.error);
if (source.singBoxApiVersion !== null) nonNegativeInteger(source.singBoxApiVersion);
if (!decimalString(source.unattributedUploadBytes)
|| !decimalString(source.unattributedDownloadBytes)) throw new Error('Invalid traffic gap');
const summary = record(snapshot.summary);
for (const field of ['active', 'recent', 'visible', 'recognized', 'unresolved', 'unresolvedOrigin']) {
nonNegativeInteger(summary[field]);
}
if (typeof summary.truncated !== 'boolean') throw new Error('Invalid traffic summary');
if (!Array.isArray(snapshot.connections) || snapshot.connections.length > 256) {
throw new Error('Invalid traffic connection list');
}
const activeTotal = Number(summary.active);
const recentTotal = Number(summary.recent);
const visibleTotal = Number(summary.visible);
const expectedVisible = Math.min(256, activeTotal + recentTotal);
if (visibleTotal !== snapshot.connections.length
|| visibleTotal !== expectedVisible
|| Number(summary.recognized) + Number(summary.unresolved) !== Number(summary.active)
|| Number(summary.unresolvedOrigin) > Number(summary.active)
|| summary.truncated !== (activeTotal + recentTotal > visibleTotal)) {
throw new Error('Inconsistent traffic summary');
}
const ids = new Set<string>();
let visibleActive = 0;
let visibleRecent = 0;
let recentSeen = false;
for (const rawConnection of snapshot.connections) {
const connection = record(rawConnection);
if (typeof connection.id !== 'string' || !connection.id
|| ids.has(connection.id)
|| !isoTimestamp(connection.startedAt)
|| (connection.closedAt !== null && !isoTimestamp(connection.closedAt))) {
throw new Error('Invalid traffic connection identity');
}
ids.add(connection.id);
if (connection.closedAt === null) {
if (recentSeen) throw new Error('Inconsistent traffic summary');
visibleActive += 1;
} else {
recentSeen = true;
visibleRecent += 1;
}
const inbound = record(connection.inbound);
const sourceAddress = record(connection.source);
const destination = record(connection.destination);
const origin = record(connection.origin);
const route = record(connection.route);
const traffic = record(connection.traffic);
if (typeof inbound.tag !== 'string' || typeof inbound.type !== 'string'
|| !['tcp', 'udp', 'unknown'].includes(String(connection.network))
|| (connection.protocol !== null && typeof connection.protocol !== 'string')
|| typeof sourceAddress.ip !== 'string'
|| (destination.domain !== null && typeof destination.domain !== 'string')
|| (destination.ip !== null && typeof destination.ip !== 'string')
|| !['sing-box', 'unknown'].includes(String(destination.provenance))
|| !['this-mac', 'device', 'unknown'].includes(String(origin.kind))
|| (origin.id !== null && typeof origin.id !== 'string')
|| typeof origin.label !== 'string'
|| !['client-runtime', 'source-ip', 'unknown'].includes(String(origin.provenance))
|| !['vpn', 'direct', 'other'].includes(String(route.kind))
|| route.scope !== 'local-sing-box'
|| (route.outbound !== null && typeof route.outbound !== 'string')
|| (route.outboundType !== null && typeof route.outboundType !== 'string')
|| (route.rule !== null && typeof route.rule !== 'string')
|| !Array.isArray(route.chain) || !route.chain.every((item) => typeof item === 'string')) {
throw new Error('Invalid traffic connection');
}
nullablePort(sourceAddress.port);
nullablePort(destination.port);
for (const field of ['uploadBytes', 'downloadBytes', 'uploadBytesPerSecond', 'downloadBytesPerSecond']) {
if (!decimalString(traffic[field])) throw new Error('Invalid traffic byte value');
}
if (connection.closedAt !== null
&& (traffic.uploadBytesPerSecond !== '0' || traffic.downloadBytesPerSecond !== '0')) {
throw new Error('Invalid closed traffic rate');
}
}
if (visibleActive !== Math.min(activeTotal, visibleTotal)
|| visibleRecent !== visibleTotal - visibleActive
|| visibleRecent > recentTotal) {
throw new Error('Inconsistent traffic summary');
}
return value as LiveTrafficSnapshot;
}
+3 -3
View File
@@ -1,7 +1,7 @@
export const HARBOR_VERSIONS = Object.freeze({
macClient: '0.33.1',
gatewayClient: '0.34.1',
gatewayBackend: '0.34.0',
macClient: '0.34.0',
gatewayClient: '0.36.0',
gatewayBackend: '0.36.0',
});
export interface ParsedVersion {
+1
View File
@@ -29,6 +29,7 @@ const componentActions = {
pingServers: api.servers.ping,
runConnectivityDiagnostics: api.diagnostics.connectivity,
loadActivityJournal: api.activityJournal.page,
loadLiveTraffic: api.traffic.live,
};
interface UiError {
+3
View File
@@ -230,6 +230,9 @@ export const api = {
activityJournal: {
page: (cursor: string | null = null) => request(`/api/activity-journal?limit=50${cursor ? `&cursor=${encodeURIComponent(cursor)}` : ''}`),
},
traffic: {
live: () => request('/api/traffic/live'),
},
singbox: {
stop: () => request('/api/singbox/stop', { method: 'POST' }),
restart: () => request('/api/singbox/restart', { method: 'POST' }),
+27 -2
View File
@@ -47,6 +47,11 @@ import {
InstructionsToggle,
useInstructionsFeature,
} from '../features/instructions/index.js';
import {
TrafficPanel,
TrafficToggle,
useTrafficFeature,
} from '../features/traffic/index.js';
import { FailoverPanel, FailoverToggle, useFailoverFeature } from '../features/failover/index.js';
import {
ActivityJournalPanel,
@@ -72,7 +77,7 @@ const VERSION_PARTS = [
] as const;
const DRAWER_SWITCH_MS = 620;
const DRAWER_ORDER = ['subscription', 'failover', 'instructions', 'devices', 'diagnostics', 'routing', 'journal'] as const;
const DRAWER_ORDER = ['subscription', 'failover', 'instructions', 'devices', 'traffic', 'diagnostics', 'routing', 'journal'] as const;
type DrawerKey = typeof DRAWER_ORDER[number];
const failoverReasonLabel = (reason: string | null) => ({
@@ -128,6 +133,7 @@ interface ComponentActions {
pingServers: (profileId: string, ids: string[]) => Promise<unknown>;
runConnectivityDiagnostics: (target?: unknown) => Promise<unknown>;
loadActivityJournal: (cursor?: string | null) => Promise<unknown>;
loadLiveTraffic: () => Promise<unknown>;
}
interface ClientViewState extends StateSnapshot {
@@ -586,6 +592,11 @@ export function ClientOverviewPage({
});
const failoverFeature = useFailoverFeature();
const activityJournalFeature = useActivityJournalFeature();
const trafficFeature = useTrafficFeature({
enabled: true,
isGateway,
loadLiveTraffic: actions.loadLiveTraffic,
});
const diagnosticsAvailable = hasSubscription;
const drawerControls = {
subscription: {
@@ -612,6 +623,12 @@ export function ClientOverviewPage({
show: devicesFeature.toggle,
close: devicesFeature.close,
},
traffic: {
isOpen: trafficFeature.isOpen,
panelRef: trafficFeature.panelRef,
show: trafficFeature.toggle,
close: trafficFeature.close,
},
diagnostics: {
isOpen: diagnosticsFeature.isOpen,
panelRef: diagnosticsFeature.panelRef,
@@ -654,8 +671,9 @@ export function ClientOverviewPage({
diagnosticsFeature.close();
failoverFeature.close();
activityJournalFeature.close();
trafficFeature.close();
}
}, [hasSubscription, isGateway]);
}, [hasSubscription]);
useEffect(() => {
if (!diagnosticsAvailable) diagnosticsFeature.close();
@@ -851,6 +869,11 @@ export function ClientOverviewPage({
open={activeRailDrawer === 'devices'}
onToggle={() => switchDrawer('devices')}
/>}
<TrafficToggle
feature={trafficFeature}
open={activeRailDrawer === 'traffic'}
onToggle={() => switchDrawer('traffic')}
/>
<DiagnosticsToggle
feature={diagnosticsFeature}
open={activeRailDrawer === 'diagnostics'}
@@ -936,6 +959,8 @@ export function ClientOverviewPage({
{isGateway && hasSubscription && <DevicesPanel feature={devicesFeature} />}
{hasSubscription && <TrafficPanel feature={trafficFeature} />}
{diagnosticsAvailable && <ConnectivityDiagnosticsPanel
feature={diagnosticsFeature}
runConnectivityDiagnostics={actions.runConnectivityDiagnostics}
+503
View File
@@ -0,0 +1,503 @@
import { useEffect, useId, useMemo, useRef, useState } from 'react';
import {
assertLiveTrafficSnapshot,
type LiveTrafficConnection,
type LiveTrafficSnapshot,
} from '../../../shared/liveTraffic.js';
import { Drawer } from '../../ui/Drawer.js';
import { RailAction } from '../../ui/RailAction.js';
import { formatByteString } from '../../utils/format.js';
import {
groupTrafficConnections,
reconcileTrafficGroups,
trafficGroupMatches,
type DisplayedTrafficGroup,
type TrafficConnectionGroup,
type TrafficQualityFilter,
type TrafficRouteFilter,
} from './trafficRows.js';
const POLL_MS = 1_000;
const RETENTION_STORAGE_KEY = 'harbor:traffic-retention-seconds';
const RETENTION_OPTIONS = [5, 10, 30] as const;
type RequestState = 'idle' | 'loading' | 'ready' | 'error';
type RetentionSeconds = typeof RETENTION_OPTIONS[number];
interface TrafficFeatureOptions {
enabled: boolean;
isGateway: boolean;
loadLiveTraffic: () => Promise<unknown>;
}
const routeLabels: Record<LiveTrafficConnection['route']['kind'], string> = {
vpn: 'VPN',
direct: 'Direct',
other: 'Другое',
};
function storedRetentionSeconds(): RetentionSeconds {
try {
const value = Number(localStorage.getItem(RETENTION_STORAGE_KEY));
return RETENTION_OPTIONS.includes(value as RetentionSeconds) ? value as RetentionSeconds : 10;
} catch {
return 10;
}
}
function address(ip: string | null, port: number | null) {
if (!ip) return '—';
return port === null ? ip : `${ip}:${port}`;
}
function updatedAt(value: string | null | undefined) {
if (!value) return 'обновлений ещё нет';
const date = new Date(value);
return Number.isNaN(date.getTime())
? 'время неизвестно'
: `обновлено ${new Intl.DateTimeFormat('ru-RU', {
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
}).format(date)}`;
}
export function useTrafficFeature({ enabled, isGateway, loadLiveTraffic }: TrafficFeatureOptions) {
const [isOpen, setIsOpen] = useState(false);
const [paused, setPaused] = useState(false);
const [snapshot, setSnapshot] = useState<LiveTrafficSnapshot | null>(null);
const [requestState, setRequestState] = useState<RequestState>('idle');
const panelRef = useRef<HTMLElement>(null);
const toggleRef = useRef<HTMLButtonElement>(null);
const closeRef = useRef<HTMLButtonElement>(null);
useEffect(() => {
if (enabled) return;
setIsOpen(false);
setPaused(false);
}, [enabled]);
useEffect(() => {
if (!enabled || !isOpen || paused) return undefined;
let cancelled = false;
let timer: ReturnType<typeof setTimeout> | undefined;
setRequestState((current) => current === 'idle' ? 'loading' : current);
const poll = async () => {
try {
const next = assertLiveTrafficSnapshot(await loadLiveTraffic());
if (!cancelled) {
setSnapshot(next);
setRequestState('ready');
}
} catch {
if (!cancelled) setRequestState('error');
} finally {
if (!cancelled) timer = setTimeout(poll, POLL_MS);
}
};
void poll();
return () => {
cancelled = true;
if (timer) clearTimeout(timer);
};
}, [enabled, isOpen, paused, loadLiveTraffic]);
useEffect(() => {
if (!isOpen) return undefined;
const frame = requestAnimationFrame(() => closeRef.current?.focus());
const closeTraffic = (event: PointerEvent | KeyboardEvent) => {
if (event.type === 'keydown' && (event as KeyboardEvent).key !== 'Escape') return;
if (event.type !== 'keydown' && (
panelRef.current?.contains(event.target as Node) || toggleRef.current?.contains(event.target as Node)
)) return;
setIsOpen(false);
setPaused(false);
};
document.addEventListener('pointerdown', closeTraffic);
document.addEventListener('keydown', closeTraffic);
return () => {
cancelAnimationFrame(frame);
document.removeEventListener('pointerdown', closeTraffic);
document.removeEventListener('keydown', closeTraffic);
requestAnimationFrame(() => {
if (panelRef.current?.contains(document.activeElement)) toggleRef.current?.focus();
});
};
}, [isOpen]);
function close() {
setIsOpen(false);
setPaused(false);
}
function toggle() {
if (isOpen) close();
else if (enabled) setIsOpen(true);
}
return {
isGateway,
isOpen,
paused,
snapshot,
requestState,
panelRef,
toggleRef,
closeRef,
close,
toggle,
togglePause: () => setPaused((current) => !current),
};
}
export type TrafficFeature = ReturnType<typeof useTrafficFeature>;
export function TrafficToggle({
feature,
open,
onToggle,
}: {
feature: TrafficFeature;
open: boolean;
onToggle: () => void;
}) {
return <RailAction
buttonRef={feature.toggleRef}
className="client-traffic-toggle"
open={open}
controls="client-traffic"
ariaLabel={open ? 'Закрыть трафик' : 'Открыть трафик'}
label="Трафик"
onClick={onToggle}
>
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M3.5 19.5h17" />
<path d="m5 16 4-4 3 2 6-7" />
<circle cx="5" cy="16" r=".7" />
<circle cx="9" cy="12" r=".7" />
<circle cx="12" cy="14" r=".7" />
<circle cx="18" cy="7" r=".7" />
</svg>
</RailAction>;
}
function groupStatus(group: TrafficConnectionGroup) {
if (group.connections.length === 1) {
return group.activeCount > 0 ? group.protocol : `Завершено · ${group.protocol}`;
}
const states = [];
if (group.activeCount > 0) states.push(`Активно: ${group.activeCount}`);
if (group.recentCount > 0) {
states.push(`${group.activeCount > 0 ? 'завершено' : 'Завершено'}: ${group.recentCount}`);
}
states.push(group.protocol);
return states.join(' · ');
}
function groupDestination(group: TrafficConnectionGroup) {
const { domain, ip, port } = group.destination;
if (!domain) return address(ip, port);
if (group.destinationIps.length === 1) return `${domain} · ${address(group.destinationIps[0], port)}`;
if (group.destinationIps.length > 1) {
return `${domain} · IP: ${group.destinationIps.length}${port === null ? '' : ` · порт ${port}`}`;
}
return port === null ? domain : `${domain} · порт ${port}`;
}
function TrafficGroupRow({
group,
expanded,
exiting,
onExited,
onToggle,
}: {
group: TrafficConnectionGroup;
expanded: boolean;
exiting: boolean;
onExited: () => void;
onToggle: () => void;
}) {
const detailsId = useId();
const onlyConnection = group.connections.length === 1 ? group.connections[0] : null;
const source = onlyConnection
? `${group.origin.label} · ${address(onlyConnection.source.ip, onlyConnection.source.port)}`
: `${group.origin.label} · соединений: ${group.connections.length}`;
const chain = group.route.chain.length
? group.route.chain.join(' → ')
: group.route.outbound || '—';
return <div
className={`client-traffic-connection${exiting ? ' is-exiting' : ''}`}
role="listitem"
inert={exiting || undefined}
aria-hidden={exiting || undefined}
onAnimationEnd={(event) => {
if (event.target === event.currentTarget && event.animationName === 'client-traffic-connection-out') {
onExited();
}
}}
>
<button
className="client-traffic-connection-summary"
type="button"
aria-expanded={expanded}
aria-controls={detailsId}
onClick={onToggle}
>
<span className="client-traffic-identity">
<strong aria-label={group.connections.length > 1
? `${group.label}, соединений: ${group.connections.length}`
: undefined}
>{group.label}{group.connections.length > 1 ? ` ×${group.connections.length}` : ''}</strong>
<small>{groupStatus(group)}</small>
</span>
<span className="client-traffic-route" data-route={group.route.kind}>
{routeLabels[group.route.kind]}
</span>
<span className="client-traffic-values">
{group.activeCount > 0 && <strong>
<span> {formatByteString(group.traffic.downloadBytesPerSecond)}/с</span>
<span> {formatByteString(group.traffic.uploadBytesPerSecond)}/с</span>
</strong>}
<small>
<span> {formatByteString(group.traffic.downloadBytes)}</span>
<span> {formatByteString(group.traffic.uploadBytes)}</span>
</small>
</span>
<svg className="client-traffic-chevron" viewBox="0 0 16 16" aria-hidden="true">
<path d="m5 6 3 3 3-3" />
</svg>
</button>
{expanded && <dl id={detailsId} className="client-traffic-details">
<div><dt>Источник</dt><dd>{source}</dd></div>
<div><dt>Назначение</dt><dd>{groupDestination(group)}</dd></div>
<div><dt>Правило</dt><dd>{group.route.rule || '—'}</dd></div>
<div><dt>Цепочка</dt><dd>{chain}</dd></div>
</dl>}
</div>;
}
function TrafficState({ feature }: { feature: TrafficFeature }) {
const { snapshot, requestState } = feature;
const sourceState = snapshot?.source.state;
if (!snapshot && (requestState === 'idle' || requestState === 'loading')) {
return <div className="client-traffic-skeleton" role="status" aria-label="Загружаем трафик">
{[0, 1, 2, 3].map((item) => <span key={item} />)}
</div>;
}
if (!snapshot) return <p className="client-traffic-state" role="status">Инспектор трафика временно недоступен.</p>;
if (sourceState === 'disabled') {
return <p className="client-traffic-state" role="status">{feature.isGateway
? 'Инспектор трафика выключен в настройках Harbor Gateway.'
: 'Инспектор трафика выключен в настройках Harbor Connect.'}</p>;
}
if (sourceState === 'incompatible') {
return <p className="client-traffic-state" role="status">Эта версия sing-box не поддерживает инспектор трафика.</p>;
}
if (sourceState === 'stopped') {
return <p className="client-traffic-state" role="status">VPN остановлен. Данные появятся после запуска.</p>;
}
if (sourceState === 'connecting' && snapshot.connections.length === 0) {
return <p className="client-traffic-state" role="status">Подключаем инспектор трафика</p>;
}
return null;
}
export function TrafficPanel({ feature }: { feature: TrafficFeature }) {
const [query, setQuery] = useState('');
const [routeFilter, setRouteFilter] = useState<TrafficRouteFilter>('all');
const [qualityFilter, setQualityFilter] = useState<TrafficQualityFilter>('all');
const [retentionSeconds, setRetentionSeconds] = useState<RetentionSeconds>(storedRetentionSeconds);
const [displayedGroups, setDisplayedGroups] = useState<DisplayedTrafficGroup[]>([]);
const [reducedMotion, setReducedMotion] = useState(() => (
matchMedia('(prefers-reduced-motion: reduce)').matches
));
const [expandedId, setExpandedId] = useState('');
const snapshot = feature.snapshot;
const sourceState = snapshot?.source.state;
const snapshotTime = snapshot?.observedAt ? Date.parse(snapshot.observedAt) : Number.NaN;
const retainedConnections = useMemo(() => (snapshot?.connections || []).filter((connection) => {
if (connection.closedAt === null || !Number.isFinite(snapshotTime)) return true;
return snapshotTime - Date.parse(connection.closedAt) < retentionSeconds * 1_000;
}), [snapshot, snapshotTime, retentionSeconds]);
const trafficGroups = useMemo(() => groupTrafficConnections(retainedConnections), [retainedConnections]);
const groups = useMemo(() => trafficGroups.filter((group) => (
trafficGroupMatches(group, query, routeFilter, qualityFilter)
)), [trafficGroups, query, routeFilter, qualityFilter]);
useEffect(() => {
const media = matchMedia('(prefers-reduced-motion: reduce)');
const update = () => setReducedMotion(media.matches);
media.addEventListener('change', update);
return () => media.removeEventListener('change', update);
}, []);
useEffect(() => {
const immediate = reducedMotion
|| !snapshot
|| ['disabled', 'incompatible', 'stopped'].includes(sourceState || '');
if (immediate) {
const desiredIds = new Set(groups.map((group) => group.id));
setExpandedId((current) => desiredIds.has(current) ? current : '');
}
setDisplayedGroups((current) => reconcileTrafficGroups(current, groups, immediate));
}, [groups, reducedMotion, snapshot, sourceState]);
function selectRetention(seconds: RetentionSeconds) {
setRetentionSeconds(seconds);
try {
localStorage.setItem(RETENTION_STORAGE_KEY, String(seconds));
} catch {
// The setting remains available for this session when storage is unavailable.
}
}
function finishExit(id: string) {
setDisplayedGroups((current) => current.filter((row) => (
row.group.id !== id || !row.exiting
)));
setExpandedId((current) => current === id ? '' : current);
}
const canShowList = snapshot
&& !['disabled', 'incompatible', 'stopped'].includes(sourceState || '')
&& (sourceState !== 'connecting' || retainedConnections.length > 0);
const stale = feature.requestState === 'error' || sourceState === 'stale';
const degraded = sourceState === 'degraded';
return <Drawer
panelRef={feature.panelRef}
closeRef={feature.closeRef}
id="client-traffic"
className="client-traffic"
sheetClassName="client-traffic-sheet"
open={feature.isOpen}
labelledBy="client-traffic-title"
closeLabel="Закрыть трафик"
onClose={feature.close}
>
<header className="client-traffic-header">
<div className="client-traffic-meta">
<span>{feature.isGateway ? 'GATEWAY' : 'MAC'} · {snapshot?.summary.active || 0} АКТИВНЫХ</span>
<time dateTime={snapshot?.observedAt || undefined}>{updatedAt(snapshot?.observedAt)}</time>
<button
type="button"
aria-pressed={feature.paused}
onClick={feature.togglePause}
>{feature.paused ? 'Продолжить' : 'Пауза'}</button>
</div>
<h2 id="client-traffic-title">Трафик</h2>
<p>Соединения сгруппированы по назначению, протоколу и маршруту.</p>
</header>
{snapshot && <div className="client-traffic-summary" aria-label="Качество распознавания трафика">
<span><b>Активных распознано</b> {snapshot.summary.recognized}</span>
<span><b>Активных требует внимания</b> {snapshot.summary.unresolved}</span>
</div>}
<div className="client-traffic-tools">
<label className="client-traffic-search">
<svg viewBox="0 0 24 24" aria-hidden="true">
<circle cx="10.5" cy="10.5" r="6" />
<path d="m15 15 5 5" />
</svg>
<span className="client-live-region">Поиск трафика</span>
<input
type="search"
value={query}
aria-label="Найти домен, сервис или IP"
placeholder="Домен, сервис или IP"
onChange={(event) => setQuery(event.target.value)}
/>
</label>
<div className="client-traffic-filters" role="group" aria-label="Фильтр по маршруту">
{([
['all', 'Все'],
['vpn', 'VPN'],
['direct', 'Direct'],
['other', 'Другое'],
] as const).map(([value, label]) => <button
type="button"
key={value}
aria-pressed={routeFilter === value}
onClick={() => setRouteFilter(value)}
>{label}</button>)}
</div>
<div className="client-traffic-filters" role="group" aria-label="Фильтр по качеству распознавания">
{([
['all', 'Все'],
['recognized', 'Распознано'],
['attention', 'Требует внимания'],
] as const).map(([value, label]) => <button
type="button"
key={value}
aria-pressed={qualityFilter === value}
onClick={() => setQualityFilter(value)}
>{label}</button>)}
</div>
<div className="client-traffic-retention">
<span>Показывать завершённые</span>
<div className="client-traffic-filters" role="group" aria-label="Время показа завершённых соединений">
{RETENTION_OPTIONS.map((seconds) => <button
type="button"
key={seconds}
aria-pressed={retentionSeconds === seconds}
onClick={() => selectRetention(seconds)}
>{seconds} с</button>)}
</div>
</div>
</div>
{feature.paused && snapshot && <p className="client-traffic-notice" role="status">
Пауза · показаны данные на {updatedAt(snapshot.observedAt).replace('обновлено ', '')}.
</p>}
{!feature.paused && stale && snapshot && <p className="client-traffic-notice is-warning" role="status">
Данные временно не обновляются. Показан последний полученный снимок.
</p>}
{!feature.paused && !stale && snapshot && sourceState === 'connecting' && snapshot.connections.length > 0 && <p
className="client-traffic-notice"
role="status"
>
Инспектор переподключается. Показан последний полученный снимок.
</p>}
{degraded && snapshot && <p className="client-traffic-notice is-warning" role="status">
Часть трафика не удалось сопоставить с соединениями: {formatByteString(snapshot.source.unattributedDownloadBytes)} · {formatByteString(snapshot.source.unattributedUploadBytes)}.
</p>}
<TrafficState feature={feature} />
{canShowList && retainedConnections.length === 0 && displayedGroups.length === 0 && <p className="client-traffic-state">
Активных соединений пока нет.
</p>}
{canShowList && retainedConnections.length > 0 && groups.length === 0 && displayedGroups.length === 0 && <p className="client-traffic-state">
По выбранным фильтрам ничего не найдено.
</p>}
{canShowList && displayedGroups.length > 0 && <div
className="client-traffic-list"
role="list"
aria-label="Группы активных и недавно завершённых соединений"
aria-busy={feature.requestState === 'loading'}
>
{displayedGroups.map((row) => <TrafficGroupRow
key={row.group.id}
group={row.group}
expanded={expandedId === row.group.id}
exiting={row.exiting}
onExited={() => finishExit(row.group.id)}
onToggle={() => setExpandedId((current) => current === row.group.id ? '' : row.group.id)}
/>)}
</div>}
{snapshot?.summary.truncated && <p className="client-traffic-truncated" role="status">
Снимок ограничен 256 соединениями; активные показаны первыми.
</p>}
<p className="client-traffic-honesty">
{feature.isGateway
? 'Только соединения, прошедшие через sing-box Gateway. Трафик, обходящий sing-box напрямую, здесь не виден.'
: 'Только трафик через Harbor Connect. Приложения macOS недоступны внутри Docker.'}
</p>
</Drawer>;
}
+6
View File
@@ -0,0 +1,6 @@
export {
TrafficPanel,
TrafficToggle,
useTrafficFeature,
type TrafficFeature,
} from './TrafficFeature.js';
+133
View File
@@ -0,0 +1,133 @@
import type { LiveTrafficConnection } from '../../../shared/liveTraffic.js';
import { byteString } from '../../utils/format.js';
export type TrafficRouteFilter = 'all' | 'vpn' | 'direct' | 'other';
export type TrafficQualityFilter = 'all' | 'recognized' | 'attention';
export interface TrafficConnectionGroup {
id: string;
label: string;
connections: LiveTrafficConnection[];
activeCount: number;
recentCount: number;
protocol: string;
route: LiveTrafficConnection['route'];
origin: LiveTrafficConnection['origin'];
destination: LiveTrafficConnection['destination'];
destinationIps: string[];
traffic: LiveTrafficConnection['traffic'];
}
export interface DisplayedTrafficGroup {
group: TrafficConnectionGroup;
exiting: boolean;
}
function normalizedDestination(connection: LiveTrafficConnection) {
const domain = connection.destination.domain?.trim().toLowerCase() || null;
const ip = connection.destination.ip?.trim().toLowerCase() || null;
return { domain, ip };
}
function trafficGroupId(connection: LiveTrafficConnection) {
const { domain, ip } = normalizedDestination(connection);
const destination = domain ? ['domain', domain] : ip ? ['ip', ip] : ['unknown', connection.id];
return JSON.stringify([
destination,
connection.destination.port,
connection.network,
connection.protocol?.trim().toLowerCase() || null,
[connection.origin.kind, connection.origin.id, connection.origin.label, connection.origin.provenance],
[
connection.route.kind,
connection.route.scope,
connection.route.outbound,
connection.route.outboundType,
connection.route.chain,
connection.route.rule,
],
]);
}
function buildTrafficGroup(id: string, connections: LiveTrafficConnection[]): TrafficConnectionGroup {
const first = connections[0];
const { domain, ip } = normalizedDestination(first);
const active = connections.filter(({ closedAt }) => closedAt === null);
const sum = (field: keyof LiveTrafficConnection['traffic'], values = connections) => values
.reduce((total, connection) => total + byteString(connection.traffic[field]), 0n)
.toString();
return {
id,
label: domain || ip || 'Назначение не определено',
connections,
activeCount: active.length,
recentCount: connections.length - active.length,
protocol: first.protocol || first.network.toUpperCase(),
route: first.route,
origin: first.origin,
destination: { ...first.destination, domain, ip },
destinationIps: [...new Set(connections.flatMap(({ destination }) => {
const address = destination.ip?.trim().toLowerCase();
return address ? [address] : [];
}))].sort((left, right) => left.localeCompare(right)),
traffic: {
uploadBytes: sum('uploadBytes'),
downloadBytes: sum('downloadBytes'),
uploadBytesPerSecond: sum('uploadBytesPerSecond', active),
downloadBytesPerSecond: sum('downloadBytesPerSecond', active),
},
};
}
export function groupTrafficConnections(connections: LiveTrafficConnection[]) {
const grouped = new Map<string, LiveTrafficConnection[]>();
for (const connection of connections) {
const id = trafficGroupId(connection);
const members = grouped.get(id);
if (members) members.push(connection);
else grouped.set(id, [connection]);
}
return [...grouped].map(([id, members]) => buildTrafficGroup(id, members));
}
export function trafficGroupMatches(
group: TrafficConnectionGroup,
query: string,
route: TrafficRouteFilter,
quality: TrafficQualityFilter,
) {
if (route !== 'all' && group.route.kind !== route) return false;
const recognized = group.destination.domain !== null;
if (quality === 'recognized' && !recognized) return false;
if (quality === 'attention' && recognized) return false;
const needle = query.trim().toLocaleLowerCase('ru-RU');
if (!needle) return true;
return group.connections.some((connection) => [
connection.destination.domain,
connection.destination.ip,
connection.source.ip,
connection.protocol,
connection.inbound.tag,
connection.inbound.type,
connection.route.outbound,
connection.route.outboundType,
...connection.route.chain,
].some((value) => String(value || '').toLocaleLowerCase('ru-RU').includes(needle)));
}
export function reconcileTrafficGroups(
current: DisplayedTrafficGroup[],
desired: TrafficConnectionGroup[],
immediate: boolean,
) {
const next = desired.map((group) => ({ group, exiting: false }));
if (immediate) return next;
const desiredIds = new Set(desired.map((group) => group.id));
current.forEach((row, index) => {
if (!desiredIds.has(row.group.id)) {
next.splice(Math.min(index, next.length), 0, { ...row, exiting: true });
}
});
return next;
}
+469
View File
@@ -0,0 +1,469 @@
.client-traffic-toggle svg {
width: 24px;
height: 24px;
}
.client-traffic-sheet {
padding: 54px 72px 72px 34px;
}
@media (max-width: 768px) {
.client-traffic {
width: 100vw;
}
}
.client-traffic-header {
display: grid;
gap: 9px;
margin: 0 8px 28px;
}
.client-traffic-meta {
min-height: 28px;
display: grid;
grid-template-columns: minmax(0, 1fr) auto auto;
align-items: center;
gap: 12px;
padding-right: 28px;
}
.client-traffic-meta > span,
.client-traffic-meta time {
color: var(--client-muted);
font: var(--type-label);
letter-spacing: var(--type-label-tracking);
text-transform: var(--type-label-transform);
font-variant-numeric: var(--numeric-tabular);
}
.client-traffic-meta time {
text-transform: var(--type-label-transform);
white-space: nowrap;
}
.client-traffic-meta button {
width: 96px;
min-height: 32px;
padding: 0;
border: 0;
background: transparent;
color: var(--client-text);
font: var(--type-control);
letter-spacing: var(--type-control-tracking);
text-transform: var(--type-control-transform);
text-align: right;
cursor: pointer;
}
.client-traffic-meta button:hover,
.client-traffic-meta button:focus-visible,
.client-traffic-meta button[aria-pressed='true'] {
color: var(--client-accent);
}
.client-traffic-meta button:focus-visible {
outline: 2px solid var(--client-accent);
outline-offset: 3px;
}
.client-traffic-header h2 {
margin: 4px 0 0;
font: var(--type-drawer-title);
letter-spacing: var(--type-drawer-title-tracking);
text-transform: var(--type-drawer-title-transform);
}
.client-traffic-header p {
max-width: 44ch;
color: var(--client-muted);
font: var(--type-body);
letter-spacing: var(--type-body-tracking);
text-transform: var(--type-body-transform);
}
.client-traffic-summary {
display: grid;
gap: 6px;
margin: 0 8px 24px;
color: var(--client-text);
font: var(--type-data);
letter-spacing: var(--type-data-tracking);
text-transform: var(--type-data-transform);
font-variant-numeric: var(--numeric-tabular);
}
.client-traffic-summary span {
display: flex;
gap: 8px;
}
.client-traffic-summary b {
min-width: 112px;
color: var(--client-muted);
font: inherit;
}
.client-traffic-tools {
display: grid;
gap: 4px;
margin: 0 8px 22px;
}
.client-traffic-search {
display: grid;
grid-template-columns: 28px minmax(0, 1fr);
align-items: center;
border-bottom: 1px solid var(--client-border);
color: var(--client-muted);
}
.client-traffic-search:focus-within {
border-bottom-color: var(--client-accent);
color: var(--client-accent);
}
.client-traffic-search svg {
width: 19px;
height: 19px;
fill: none;
stroke: currentColor;
stroke-linecap: round;
stroke-linejoin: round;
stroke-width: 1.7;
}
.client-traffic-search input {
width: 100%;
height: 42px;
padding: 0;
border: 0;
outline: 0;
background: transparent;
color: var(--client-text);
font: var(--type-body);
letter-spacing: var(--type-body-tracking);
text-transform: var(--type-body-transform);
}
.client-traffic-search input::placeholder {
color: var(--client-muted);
opacity: 1;
}
.client-traffic-filters {
display: flex;
flex-wrap: wrap;
gap: 4px 18px;
}
.client-traffic-filters button {
min-height: 36px;
padding: 4px 0 2px;
border: 0;
border-bottom: 2px solid transparent;
background: transparent;
color: var(--client-muted);
font: var(--type-control);
letter-spacing: var(--type-control-tracking);
text-transform: var(--type-control-transform);
cursor: pointer;
}
.client-traffic-filters button:hover,
.client-traffic-filters button:focus-visible,
.client-traffic-filters button[aria-pressed='true'] {
color: var(--client-accent);
}
.client-traffic-filters button[aria-pressed='true'] {
border-bottom-color: var(--client-accent);
}
.client-traffic-filters button:focus-visible {
outline: 2px solid var(--client-accent);
outline-offset: 2px;
}
.client-traffic-retention {
min-height: 36px;
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 14px;
}
.client-traffic-retention > span {
color: var(--client-muted);
font: var(--type-control);
letter-spacing: var(--type-control-tracking);
text-transform: var(--type-control-transform);
}
.client-traffic-notice,
.client-traffic-state,
.client-traffic-truncated,
.client-traffic-honesty {
margin: 0 8px;
color: var(--client-muted);
font: var(--type-control);
letter-spacing: var(--type-control-tracking);
text-transform: var(--type-control-transform);
}
.client-traffic-notice {
margin-bottom: 14px;
color: var(--client-accent);
}
.client-traffic-notice.is-warning {
color: oklch(0.68 0.14 72);
}
.client-traffic-state {
min-height: 118px;
display: grid;
place-items: center;
text-align: center;
}
.client-traffic-skeleton {
display: grid;
gap: 10px;
margin: 0 8px;
}
.client-traffic-skeleton span {
height: 58px;
background: color-mix(in oklch, var(--client-border) 24%, transparent);
opacity: 0.55;
}
.client-traffic-list {
display: grid;
margin: 0 8px;
padding: 0;
}
.client-traffic-connection {
min-width: 0;
box-shadow: inset 0 -1px 0 color-mix(in oklch, var(--client-border) 58%, transparent);
animation: client-traffic-connection-in 420ms cubic-bezier(0.16, 1, 0.3, 1) both;
}
.client-traffic-connection.is-exiting {
pointer-events: none;
animation: client-traffic-connection-out 240ms cubic-bezier(0.4, 0, 1, 1) both;
}
.client-traffic-connection-summary {
width: 100%;
min-height: 68px;
display: grid;
grid-template-columns: minmax(0, 1fr) 54px 154px 18px;
align-items: center;
gap: 10px;
padding: 10px 0;
border: 0;
background: transparent;
color: var(--client-text);
text-align: left;
cursor: pointer;
}
.client-traffic-connection-summary:hover,
.client-traffic-connection-summary:focus-visible {
outline: 0;
color: var(--client-accent);
}
.client-traffic-connection-summary:focus-visible {
box-shadow: inset 0 0 0 2px var(--client-accent);
}
.client-traffic-identity,
.client-traffic-values {
min-width: 0;
display: grid;
gap: 4px;
}
.client-traffic-identity strong {
overflow: hidden;
font: var(--type-item-title);
letter-spacing: var(--type-item-title-tracking);
text-overflow: ellipsis;
text-transform: var(--type-item-title-transform);
white-space: nowrap;
}
.client-traffic-identity small {
overflow: hidden;
color: var(--client-muted);
font: var(--type-control);
letter-spacing: var(--type-control-tracking);
text-overflow: ellipsis;
text-transform: var(--type-control-transform);
white-space: nowrap;
}
.client-traffic-route {
justify-self: start;
color: var(--client-muted);
font: var(--type-control);
letter-spacing: var(--type-control-tracking);
text-transform: var(--type-control-transform);
}
.client-traffic-route[data-route='vpn'] {
color: var(--harbor-connect);
}
.client-traffic-route[data-route='direct'] {
color: var(--harbor-gateway);
}
.client-traffic-values {
justify-items: end;
font-variant-numeric: var(--numeric-tabular);
}
.client-traffic-values strong,
.client-traffic-values small {
display: flex;
justify-content: flex-end;
gap: 10px;
white-space: nowrap;
}
.client-traffic-values strong {
font: var(--type-control);
letter-spacing: var(--type-control-tracking);
text-transform: var(--type-control-transform);
}
.client-traffic-values small {
color: var(--client-muted);
font: var(--type-micro);
letter-spacing: var(--type-micro-tracking);
text-transform: var(--type-micro-transform);
}
.client-traffic-values span:first-child {
color: var(--harbor-connect);
}
.client-traffic-values span:last-child {
color: var(--harbor-gateway);
}
.client-traffic-chevron {
width: 16px;
height: 16px;
fill: none;
stroke: currentColor;
stroke-linecap: round;
stroke-linejoin: round;
stroke-width: 1.6;
}
.client-traffic-details {
display: grid;
gap: 7px;
margin: 0;
padding: 4px 0 16px;
animation: client-traffic-details-in 180ms cubic-bezier(0.16, 1, 0.3, 1) both;
}
.client-traffic-details > div {
min-width: 0;
display: grid;
grid-template-columns: 82px minmax(0, 1fr);
gap: 10px;
}
.client-traffic-details dt,
.client-traffic-details dd {
margin: 0;
font: var(--type-control);
letter-spacing: var(--type-control-tracking);
text-transform: var(--type-control-transform);
}
.client-traffic-details dt {
color: var(--client-muted);
}
.client-traffic-details dd {
min-width: 0;
overflow-wrap: anywhere;
color: var(--client-text);
}
.client-traffic-truncated {
padding-top: 16px;
text-align: center;
}
.client-traffic-honesty {
margin-top: 26px;
padding-top: 16px;
box-shadow: inset 0 1px 0 color-mix(in oklch, var(--client-border) 46%, transparent);
}
@keyframes client-traffic-details-in {
from { opacity: 0; transform: translateY(-5px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes client-traffic-connection-in {
from { opacity: 0; transform: translateY(-6px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes client-traffic-connection-out {
from { opacity: 1; transform: translateY(0); }
to { opacity: 0; transform: translateY(4px); }
}
@media (max-width: 560px) {
.client-traffic-sheet {
padding: 40px 58px 60px 18px;
}
.client-traffic-meta {
grid-template-columns: minmax(0, 1fr) auto;
}
.client-traffic-meta > span {
grid-column: 1 / -1;
}
.client-traffic-connection-summary {
grid-template-columns: minmax(0, 1fr) auto 18px;
}
.client-traffic-values {
grid-column: 1 / -1;
grid-row: 2;
justify-items: start;
}
.client-traffic-values strong,
.client-traffic-values small {
justify-content: flex-start;
}
.client-traffic-details > div {
grid-template-columns: 1fr;
gap: 2px;
}
}
@media (prefers-reduced-motion: reduce) {
.client-traffic-connection,
.client-traffic-details {
animation: none;
}
}
+1
View File
@@ -10,5 +10,6 @@
@import './features/diagnostics.css';
@import './features/failover.css';
@import './features/activity-journal.css';
@import './features/traffic.css';
@import './layout.css';
@import './themes.css';
+32 -2
View File
@@ -95,6 +95,32 @@ test('classifier covers current dataplane reachability without promoting its cli
});
});
test('native traffic contracts and collector restart both Gateway processes', () => {
for (const file of [
'buf.gen.yaml',
'proto/sing-box/v1.14.0-rc.5/daemon/started_service.proto',
'src/server/gatewayNativeRuntime.ts',
'src/server/generated/daemon/started_service_pb.ts',
'src/server/services/liveTrafficService.ts',
'src/shared/liveTraffic.ts',
]) {
assert.deepEqual(classifyRuntimeImpact([file]), {
affectedComponents: ['control', 'dataplane'],
restartScope: 'both',
}, file);
}
for (const file of [
'tools/test-singbox-client-rc.sh',
'tools/test-singbox-gateway-native-traffic.sh',
'tools/test-singbox-native-traffic.sh',
]) {
assert.deepEqual(classifyRuntimeImpact([file]), {
affectedComponents: [],
restartScope: 'none',
}, file);
}
});
test('version bumps do not promote a control-only change to a dataplane restart', () => {
assert.deepEqual(classifyRuntimeImpact(['src/web/App.tsx', 'src/shared/versions.ts']), {
affectedComponents: ['control'],
@@ -118,8 +144,12 @@ test('release-critical inputs restart both and unknown paths fail closed', () =>
assert.throws(() => classifyRuntimeImpact(['scripts/new-runtime.sh']), /Unclassified path/);
});
test('every tracked repository path has an explicit ownership class', () => {
const tracked = execFileSync('git', ['ls-files'], { cwd: root, encoding: 'utf8' })
test('every repository path has an explicit ownership class', () => {
const tracked = execFileSync(
'git',
['ls-files', '--cached', '--others', '--exclude-standard'],
{ cwd: root, encoding: 'utf8' },
)
.trim()
.split('\n');
assert.doesNotThrow(() => classifyRuntimeImpact(tracked));
+4 -2
View File
@@ -50,9 +50,11 @@ test('macOS installer removes stale archive files without deleting local state',
assert.match(installer, /--exclude='\.git'/);
});
test('macOS installer refreshes a stale pinned sing-box version', () => {
test('macOS installer pins sing-box 1.14.0-rc.5 and native traffic together', () => {
const installer = fs.readFileSync(path.join(root, 'scripts', 'install-macos-client.sh'), 'utf8');
assert.match(installer, /TARGET_SINGBOX_VERSION="\$\{SINGBOX_VERSION:-1\.13\.18\}"/);
assert.match(installer, /TARGET_SINGBOX_VERSION="\$\{SINGBOX_VERSION:-1\.14\.0-rc\.5\}"/);
assert.match(installer, /TARGET_TRAFFIC_SOURCE="\$\{SING_BOX_TRAFFIC_SOURCE:-native\}"/);
assert.match(installer, /set_env_value SINGBOX_VERSION "\$TARGET_SINGBOX_VERSION"/);
assert.match(installer, /set_env_value SING_BOX_TRAFFIC_SOURCE "\$TARGET_TRAFFIC_SOURCE"/);
});
+27 -12
View File
@@ -152,22 +152,37 @@ test('production paths use only the compiled dispatcher', () => {
assert.match(workflow, /command -v npm[^']+command -v git[^']+test -x \/bin\/bash/);
assert.doesNotMatch(workflow, /docker image inspect "\$\{\{ env\.NODE_BUILD_IMAGE \}\}"/);
assert.match(legacyBuild, /npm run build:production && docker build/);
assert.match(legacyBuild, /docker run --rm[^;]+sing-box version[^;]+grep -Fx/);
assert.match(legacyBuild, /docker run --rm --entrypoint sing-box[^;]+version[^;]+grep -Fx/);
assert.match(dockerignore, /^dist$/m);
});
test('every shipped build defaults to sing-box 1.13.18', () => {
for (const file of [
'.env.example',
'.gitea/workflows/gateway-build.yml',
'Dockerfile',
'Dockerfile.client',
'Dockerfile.runtime-base',
'docker-compose.client.yml',
'docker-compose.gateway.yml',
'scripts/build-on-107-deploy-111.sh',
'scripts/build-runtime-base.sh',
test('Mac client builds default to exact sing-box 1.14.0-rc.5', () => {
assert.match(
fs.readFileSync(path.join(root, 'Dockerfile.client'), 'utf8'),
/^ARG SINGBOX_VERSION=1\.14\.0-rc\.5$/m,
);
assert.match(
fs.readFileSync(path.join(root, 'docker-compose.client.yml'), 'utf8'),
/SINGBOX_VERSION: \$\{SINGBOX_VERSION:-1\.14\.0-rc\.5\}/,
);
});
test('Gateway builds use the Mac-qualified exact sing-box 1.14.0-rc.5', () => {
for (const [file, pin] of [
['.env.example', /^SINGBOX_VERSION=1\.14\.0-rc\.5$/m],
['.gitea/workflows/gateway-build.yml', /^\s*SINGBOX_VERSION: 1\.14\.0-rc\.5$/m],
['Dockerfile', /^ARG SINGBOX_VERSION=1\.14\.0-rc\.5$/m],
['Dockerfile.runtime-base', /^ARG SINGBOX_VERSION=1\.14\.0-rc\.5$/m],
['docker-compose.gateway.yml', /SINGBOX_VERSION: \$\{SINGBOX_VERSION:-1\.14\.0-rc\.5\}/],
['scripts/build-on-107-deploy-111.sh', /^SINGBOX_VERSION="\$\{SINGBOX_VERSION:-1\.14\.0-rc\.5\}"$/m],
['scripts/build-runtime-base.sh', /^SINGBOX_VERSION="\$\{SINGBOX_VERSION:-1\.14\.0-rc\.5\}"$/m],
]) {
assert.match(fs.readFileSync(path.join(root, file), 'utf8'), /SINGBOX_VERSION[^\n]*1\.13\.18/);
assert.match(fs.readFileSync(path.join(root, file), 'utf8'), pin);
}
const gatewayDockerfile = fs.readFileSync(path.join(root, 'Dockerfile'), 'utf8');
for (const dependency of ['@bufbuild/protobuf', '@connectrpc/connect', '@connectrpc/connect-node']) {
assert.match(gatewayDockerfile, new RegExp(`/src/node_modules/${dependency.replace('/', '\\/')}`));
}
});
+10 -7
View File
@@ -26,6 +26,8 @@ test('control uses the dataplane socket protocol', async () => {
assert.equal(traffic.running, true);
const domainTraffic = await client.observeDomainTraffic();
assert.equal(domainTraffic.running, true);
const liveTraffic = await client.observeLiveTraffic();
assert.equal(liveTraffic.running, true);
await client.observeDevicePolicy();
await client.applyDevicePolicies([{ id: 'dev_0011223344556677' }]);
await client.runConnectivityDiagnostics(
@@ -47,6 +49,7 @@ test('control uses the dataplane socket protocol', async () => {
'GET /devices /run/dataplane.sock',
'GET /device-traffic /run/dataplane.sock',
'GET /domain-traffic /run/dataplane.sock',
'GET /traffic/live /run/dataplane.sock',
'GET /device-policy /run/dataplane.sock',
'PUT /device-policy /run/dataplane.sock',
'POST /diagnostics/connectivity /run/dataplane.sock',
@@ -59,16 +62,16 @@ test('control uses the dataplane socket protocol', async () => {
'POST /restart /run/dataplane.sock',
'POST /stop /run/dataplane.sock',
]);
assert.deepEqual(requests[6].body, { devices: [{ id: 'dev_0011223344556677' }] });
assert.deepEqual(requests[7].body, {
assert.deepEqual(requests[7].body, { devices: [{ id: 'dev_0011223344556677' }] });
assert.deepEqual(requests[8].body, {
services: [{ id: 'custom-test', url: 'https://example.com' }],
target: 'site:custom-test',
});
assert.equal(requests[7].timeoutMs, 25_000);
assert.deepEqual(requests[8].body, { config: { outbounds: [] } });
assert.deepEqual(requests[9].body, { role: 'primary', services: [], target: 'site:youtube', timeoutMs: 9_000 });
assert.equal(requests[9].timeoutMs, 19_000);
assert.deepEqual(requests[11].body, { role: 'reserve' });
assert.equal(requests[8].timeoutMs, 25_000);
assert.deepEqual(requests[9].body, { config: { outbounds: [] } });
assert.deepEqual(requests[10].body, { role: 'primary', services: [], target: 'site:youtube', timeoutMs: 9_000 });
assert.equal(requests[10].timeoutMs, 19_000);
assert.deepEqual(requests[12].body, { role: 'reserve' });
});
test('connectivity diagnostics expose a retryable domain error', async () => {
+10
View File
@@ -33,6 +33,16 @@ test('gateway deploy updates control without recreating dataplane', () => {
assert.doesNotMatch(workflow, /grep -Eq/);
});
test('Gateway native API credentials remain private to the dataplane volume', () => {
assert.match(compose, /SING_BOX_TRAFFIC_SOURCE: \$\{SING_BOX_TRAFFIC_SOURCE:-snapshot\}/);
assert.match(compose, /vpn-proxy-dataplane:[\s\S]*SING_BOX_API_SECRET: \/var\/lib\/sing-box\/api\.secret[\s\S]*sing-box-cache:\/var\/lib\/sing-box/);
assert.doesNotMatch(
compose.match(/vpn-proxy-control:[\s\S]*?(?=\nvolumes:)/)?.[0] || '',
/SING_BOX_API_SECRET|sing-box-cache|19091/,
);
assert.doesNotMatch(compose.match(/ports:[\s\S]*?volumes:/)?.[0] || '', /19091/);
});
test('manual hard deploy safely forces the existing full Gateway path', () => {
assert.match(workflow, /workflow_dispatch:\s*\n\s+inputs:\s*\n\s+hard_deploy:[\s\S]*default: false[\s\S]*type: boolean/);
assert.match(workflow, /env:\s*\n\s+HARD_DEPLOY_INPUT: \$\{\{ inputs\.hard_deploy \}\}/);
+3 -2
View File
@@ -29,8 +29,9 @@ const observation = (ip, mac = '00:11:22:33:44:55', deviceInterface = 'eth0') =>
test('dataplane exposes cached traffic snapshots without making accounting a readiness dependency', () => {
assert.match(dataplaneSource, /req\.method === 'GET' && req\.url === '\/device-traffic'[\s\S]*traffic\.snapshot\(\)/);
assert.match(dataplaneSource, /ready = true;[\s\S]*deviceTrafficAccountingEnabled[\s\S]*setImmediate[\s\S]*traffic\.refresh\(\)/);
assert.match(dataplaneSource, /traffic\.refresh\(\)\.catch/);
assert.match(dataplaneSource, /async function refreshDeviceTraffic\(\)[\s\S]*traffic\.refresh\(\)/);
assert.match(dataplaneSource, /ready = true;[\s\S]*deviceTrafficAccountingEnabled[\s\S]*setImmediate[\s\S]*refreshDeviceTraffic\(\)/);
assert.match(dataplaneSource, /refreshDeviceTraffic\(\)\.catch/);
});
test('traffic selection keeps only unambiguous IPv4 neighbors', () => {
+104
View File
@@ -16,6 +16,40 @@ const connection = (connectionId, type, host, upload, download, sourceIP = devic
download,
chains,
});
const nativeConnection = (connectionId, uploadBytes, downloadBytes, overrides = {}) => ({
id: connectionId,
startedAt: '2026-08-31T10:00:00.000Z',
closedAt: null,
inbound: { tag: 'tproxy-in', type: 'tproxy' },
network: 'tcp',
protocol: 'tls',
source: { ip: device.ip, port: 54_000 },
destination: { domain: 'example.com', ip: null, port: 443, provenance: 'sing-box' },
origin: { kind: 'device', id, label: 'MacBook', provenance: 'source-ip' },
route: {
kind: 'vpn',
scope: 'local-sing-box',
outbound: 'channel-selector',
outboundType: 'selector',
chain: ['channel-primary', 'channel-selector'],
rule: 'final',
},
traffic: {
uploadBytes,
downloadBytes,
uploadBytesPerSecond: '0',
downloadBytesPerSecond: '0',
},
...overrides,
});
const nativeBatch = (connections, overrides = {}) => ({
epoch: 'sing-box-100',
observedAt: '2026-08-31T10:00:00.000Z',
reset: false,
connections,
closedIds: [],
...overrides,
});
test('sing-box route traffic keeps vpn, direct and unknown deltas separate', async () => {
let response = { connections: [
@@ -238,3 +272,73 @@ test('failover activity is zero-work while disabled and uses the existing connec
service.disableActivity();
assert.equal(service.activitySnapshot(500), null);
});
test('native lifecycle batches keep decimal precision and dedupe a same-epoch reconnect reset and final tail', () => {
let now = new Date('2026-08-31T10:00:00.000Z');
const service = createDomainTrafficService({
observe: () => ({ connections: [] }),
devices: () => [],
now: () => now,
});
const initial = nativeConnection('native', '9007199254740993', '10');
service.ingestNative(nativeBatch([initial], { reset: true }));
now = new Date('2026-08-31T10:00:01.000Z');
service.ingestNative(nativeBatch([initial], {
reset: true,
observedAt: now.toISOString(),
}));
assert.equal(service.snapshot().series[0].uploadBytes, '9007199254740993');
assert.equal(service.snapshot().source.activeConnections, 1);
service.enableActivity();
now = new Date('2026-08-31T10:00:02.000Z');
service.ingestNative(nativeBatch([
nativeConnection('native', '9007199254740998', '15', { closedAt: now.toISOString() }),
], {
observedAt: now.toISOString(),
closedIds: ['native'],
}));
assert.equal(service.snapshot().series[0].uploadBytes, '9007199254740998');
assert.equal(service.snapshot().series[0].downloadBytes, '15');
assert.equal(service.snapshot().source.activeConnections, 0);
assert.equal(service.activitySnapshot(0).state, 'active');
now = new Date('2026-08-31T10:00:03.000Z');
service.ingestNative(nativeBatch([
nativeConnection('native', '9007199254740998', '15'),
], {
reset: true,
observedAt: now.toISOString(),
}));
assert.equal(service.snapshot().series[0].uploadBytes, '9007199254740998');
assert.equal(service.snapshot().series[0].downloadBytes, '15');
assert.equal(service.snapshot().source.activeConnections, 1);
now = new Date('2026-08-31T10:00:14.000Z');
service.ingestNative(nativeBatch([], { observedAt: now.toISOString() }));
assert.equal(service.snapshot().observedAt, now.toISOString());
assert.equal(service.snapshot().source.activeConnections, 1);
assert.equal(service.activitySnapshot(0).state, 'quiet');
});
test('one native batch accounts every final tail before lifecycle and UI caps', () => {
const service = createDomainTrafficService({
observe: () => ({ connections: [] }),
devices: () => [],
});
const connections = Array.from({ length: 2_049 }, (_, index) => (
nativeConnection(`closed-${index}`, '1', '1', { closedAt: '2026-08-31T10:00:00.000Z' })
));
service.ingestNative(nativeBatch(connections, {
reset: true,
closedIds: connections.map(({ id: connectionId }) => connectionId),
}));
assert.equal(service.snapshot().series[0].uploadBytes, '2049');
assert.equal(service.snapshot().series[0].downloadBytes, '2049');
assert.equal(service.snapshot().source.activeConnections, 0);
});
+99
View File
@@ -0,0 +1,99 @@
import assert from 'node:assert/strict';
import { spawnSync } from 'node:child_process';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { pathToFileURL } from 'node:url';
import test from 'node:test';
const singboxUrl = pathToFileURL(path.resolve('dist/server/singbox.js')).href;
const subscriptionConfig = {
outbounds: [{
type: 'vless',
tag: 'vpn',
server: 'vpn.example.test',
server_port: 443,
uuid: '00000000-0000-4000-8000-000000000000',
tls: { enabled: true },
}],
};
function run(source, { component = 'dataplane', socket = '/tmp/harbor-test.sock' } = {}) {
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'harbor-gateway-native-config-'));
const script = `
const { buildGatewayConfig, buildDualChannelGatewayConfig } = await import(${JSON.stringify(singboxUrl)});
const subscription = ${JSON.stringify(subscriptionConfig)};
process.stdout.write(JSON.stringify({
single: buildGatewayConfig(subscription, 'vpn'),
dual: buildDualChannelGatewayConfig({
primary: { subscriptionConfig: subscription, selectedServerId: 'vpn' },
reserve: { subscriptionConfig: subscription, selectedServerId: 'vpn' },
}),
}));
`;
try {
return spawnSync(process.execPath, ['--input-type=module', '--eval', script], {
cwd: path.resolve('.'),
encoding: 'utf8',
env: {
...process.env,
APP_MODE: 'gateway',
APP_COMPONENT: component,
DATA_DIR: directory,
SING_BOX_CACHE: path.join(directory, 'cache.db'),
SING_BOX_TRAFFIC_SOURCE: source,
DATAPLANE_SOCKET: socket,
},
});
} finally {
fs.rmSync(directory, { recursive: true, force: true });
}
}
test('Gateway snapshot keeps the 1.13-compatible config and Clash traffic API', () => {
const result = run('snapshot', { component: '', socket: '' });
assert.equal(result.status, 0, result.stderr);
const configs = JSON.parse(result.stdout);
for (const config of Object.values(configs)) {
assert.equal(config.services, undefined);
assert.deepEqual(config.dns, { independent_cache: true });
assert.deepEqual(config.experimental.clash_api, { external_controller: '127.0.0.1:19090' });
}
});
test('Gateway shadow and native add one secret-free loopback API to single and dual configs', () => {
for (const source of ['shadow', 'native']) {
const result = run(source);
assert.equal(result.status, 0, result.stderr);
const configs = JSON.parse(result.stdout);
for (const config of Object.values(configs)) {
assert.deepEqual(config.services, [{
type: 'api',
listen: '127.0.0.1',
listen_port: 19091,
dashboard: false,
}]);
assert.equal(JSON.stringify(config).includes('secret'), false);
assert.deepEqual(config.dns, {});
assert.deepEqual(config.experimental.clash_api, { external_controller: '127.0.0.1:19090' });
}
}
});
test('Gateway shadow and native reject combined or socket-less topology', () => {
for (const options of [
{ component: '', socket: '' },
{ component: 'control', socket: '' },
{ component: 'dataplane', socket: '' },
]) {
const result = run('native', options);
assert.notEqual(result.status, 0);
assert.match(result.stderr, /require split control\/dataplane topology/);
}
});
test('Gateway rejects unknown traffic sources', () => {
const result = run('disabled');
assert.notEqual(result.status, 0);
assert.match(result.stderr, /must be snapshot, shadow or native/);
});
+185
View File
@@ -0,0 +1,185 @@
import assert from 'node:assert/strict';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import test from 'node:test';
import {
ensureGatewayNativeApiSecret,
materializeGatewayNativeConfig,
} from '../../dist/server/gatewayNativeRuntime.js';
import { createSingboxRuntime } from '../../dist/server/singboxRuntime.js';
const apiService = {
type: 'api',
listen: '127.0.0.1',
listen_port: 19091,
dashboard: false,
};
function mode(filePath) {
return fs.statSync(filePath).mode & 0o777;
}
async function waitForJson(filePath) {
for (let attempt = 0; attempt < 100; attempt += 1) {
try {
return JSON.parse(fs.readFileSync(filePath, 'utf8'));
} catch {}
await new Promise((resolve) => setTimeout(resolve, 5));
}
throw new Error(`Timed out waiting for ${filePath}`);
}
test('Gateway native materialization keeps a stable 0600 secret out of shared config', (t) => {
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'harbor-native-secret-'));
t.after(() => fs.rmSync(directory, { recursive: true, force: true }));
const secretPath = path.join(directory, 'api.secret');
const runtimeConfigPath = path.join(directory, 'runtime-config.json');
const config = { services: [apiService], inbounds: [], outbounds: [] };
const first = materializeGatewayNativeConfig(config, {
apiPort: 19091,
secretPath,
runtimeConfigPath,
});
const second = materializeGatewayNativeConfig(config, {
apiPort: 19091,
secretPath,
runtimeConfigPath,
});
assert.match(first.secret, /^[0-9a-f]{64}$/);
assert.equal(second.secret, first.secret);
assert.equal(first.warning, null);
assert.equal(mode(secretPath), 0o600);
assert.equal(mode(runtimeConfigPath), 0o600);
assert.equal(JSON.stringify(config).includes(first.secret), false);
assert.equal(JSON.parse(fs.readFileSync(runtimeConfigPath, 'utf8')).services[0].secret, first.secret);
});
test('Gateway native secret rejects symlinks and repairs regular-file permissions', (t) => {
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'harbor-native-secret-mode-'));
t.after(() => fs.rmSync(directory, { recursive: true, force: true }));
const regularPath = path.join(directory, 'regular.secret');
fs.writeFileSync(regularPath, 'a'.repeat(64), { mode: 0o644 });
assert.equal(ensureGatewayNativeApiSecret(regularPath), 'a'.repeat(64));
assert.equal(mode(regularPath), 0o600);
const linkPath = path.join(directory, 'linked.secret');
fs.symlinkSync(regularPath, linkPath);
assert.throws(() => ensureGatewayNativeApiSecret(linkPath));
});
test('materialization strips every API service and returns a warning on unsafe input', (t) => {
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'harbor-native-safe-config-'));
t.after(() => fs.rmSync(directory, { recursive: true, force: true }));
const config = {
services: [
apiService,
{ type: 'api', listen: '0.0.0.0', listen_port: 19092, dashboard: false },
{ type: 'resolved' },
],
};
const result = materializeGatewayNativeConfig(config, {
apiPort: 19091,
secretPath: path.join(directory, 'api.secret'),
runtimeConfigPath: path.join(directory, 'runtime-config.json'),
});
const runtimeConfig = JSON.parse(fs.readFileSync(result.configPath, 'utf8'));
assert.equal(result.secret, null);
assert.match(result.warning, /expected exactly one native API service/);
assert.deepEqual(runtimeConfig.services, [{ type: 'resolved' }]);
});
test('runtime starts the VPN-safe config and reports native materialization warnings', async (t) => {
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'harbor-native-runtime-'));
const binDirectory = path.join(directory, 'bin');
const configPath = path.join(directory, 'shared.json');
const capturedPath = path.join(directory, 'captured.json');
fs.mkdirSync(binDirectory);
fs.writeFileSync(configPath, JSON.stringify({
services: [{ ...apiService, listen: '0.0.0.0' }],
inbounds: [],
outbounds: [],
}));
fs.writeFileSync(path.join(binDirectory, 'sing-box'), `#!/usr/bin/env node
const fs = require('node:fs');
const configPath = process.argv[process.argv.indexOf('-c') + 1];
const config = JSON.parse(fs.readFileSync(configPath, 'utf8'));
if (config.services?.some((service) => service.type === 'api')) process.exit(7);
if (process.argv[2] === 'check') process.exit(0);
fs.writeFileSync(process.env.HARBOR_CAPTURED_CONFIG, JSON.stringify(config));
process.on('SIGTERM', () => process.exit(0));
setInterval(() => {}, 60_000);
`);
fs.chmodSync(path.join(binDirectory, 'sing-box'), 0o755);
const previousPath = process.env.PATH;
process.env.PATH = `${binDirectory}:${previousPath}`;
process.env.HARBOR_CAPTURED_CONFIG = capturedPath;
const runtime = createSingboxRuntime({
configPath,
nativeApi: {
apiPort: 19091,
secretPath: path.join(directory, 'api.secret'),
runtimeConfigPath: path.join(directory, 'runtime-config.json'),
},
});
t.after(async () => {
await runtime.stop();
process.env.PATH = previousPath;
delete process.env.HARBOR_CAPTURED_CONFIG;
fs.rmSync(directory, { recursive: true, force: true });
});
const checked = runtime.checkConfig(JSON.parse(fs.readFileSync(configPath, 'utf8')));
assert.match(checked.warning, /must be unauthenticated base config/);
const state = await runtime.apply();
assert.equal(state.running, true);
assert.match(state.nativeApiWarning, /must be unauthenticated base config/);
assert.equal(runtime.nativeApiSecret, null);
assert.equal((await waitForJson(capturedPath)).services, undefined);
});
test('snapshot runtime strips a native API left by the previous mode before starting sing-box', async (t) => {
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'harbor-snapshot-runtime-'));
const binDirectory = path.join(directory, 'bin');
const configPath = path.join(directory, 'shared.json');
const runtimeConfigPath = path.join(directory, 'runtime-config.json');
const capturedPath = path.join(directory, 'captured.json');
fs.mkdirSync(binDirectory);
fs.writeFileSync(configPath, JSON.stringify({
services: [apiService],
inbounds: [],
outbounds: [],
}));
fs.writeFileSync(path.join(binDirectory, 'sing-box'), `#!/usr/bin/env node
const fs = require('node:fs');
const configPath = process.argv[process.argv.indexOf('-c') + 1];
const config = JSON.parse(fs.readFileSync(configPath, 'utf8'));
if (config.services?.some((service) => service.type === 'api')) process.exit(7);
if (process.argv[2] === 'check') process.exit(0);
fs.writeFileSync(process.env.HARBOR_CAPTURED_CONFIG, JSON.stringify(config));
process.on('SIGTERM', () => process.exit(0));
setInterval(() => {}, 60_000);
`);
fs.chmodSync(path.join(binDirectory, 'sing-box'), 0o755);
const previousPath = process.env.PATH;
process.env.PATH = `${binDirectory}:${previousPath}`;
process.env.HARBOR_CAPTURED_CONFIG = capturedPath;
const runtime = createSingboxRuntime({ configPath, gatewayRuntimeConfigPath: runtimeConfigPath });
t.after(async () => {
await runtime.stop();
process.env.PATH = previousPath;
delete process.env.HARBOR_CAPTURED_CONFIG;
fs.rmSync(directory, { recursive: true, force: true });
});
const state = await runtime.apply();
assert.equal(state.running, true);
assert.equal(mode(runtimeConfigPath), 0o600);
assert.equal((await waitForJson(capturedPath)).services, undefined);
});
+692
View File
@@ -0,0 +1,692 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { ConnectionEventType } from '../../dist/server/generated/daemon/started_service_pb.js';
import { createLiveTrafficLedger } from '../../dist/server/services/liveTrafficService.js';
function connection(id, overrides = {}) {
return {
id,
inbound: 'mixed-in',
inboundType: 'mixed',
network: 'tcp',
source: '127.0.0.1:54000',
destination: '203.0.113.10:443',
domain: 'example.test',
protocol: 'tls',
createdAt: 1_700_000_000_000n,
closedAt: 0n,
uplinkTotal: 0n,
downlinkTotal: 0n,
outbound: 'test-vpn',
outboundType: 'vless',
rule: 'final',
chainList: ['test-vpn'],
...overrides,
};
}
function event(type, id, overrides = {}) {
return {
type,
id,
uplinkDelta: 0n,
downlinkDelta: 0n,
closedAt: 0n,
...overrides,
};
}
function status(connectionsIn, uplinkTotal, downlinkTotal) {
return { connectionsIn, uplinkTotal, downlinkTotal };
}
test('ledger applies NEW, UPDATE and only the final CLOSED tail once', () => {
let now = new Date('2026-08-31T10:00:00.000Z');
const ledger = createLiveTrafficLedger({ now: () => now });
ledger.beginEpoch(1_700_000_000_000n, '1.14.0-rc.5', 4);
ledger.applyConnections({ reset: true, events: [] });
ledger.applyStatus(status(0, 0n, 0n));
const opened = connection('a', { uplinkTotal: 10n, downlinkTotal: 20n });
ledger.applyConnections({
reset: false,
events: [event(ConnectionEventType.CONNECTION_EVENT_NEW, 'a', { connection: opened })],
});
let snapshot = ledger.snapshot();
assert.deepEqual(snapshot.connections[0].traffic, {
uploadBytes: '10',
downloadBytes: '20',
uploadBytesPerSecond: '0',
downloadBytesPerSecond: '0',
});
assert.equal(snapshot.connections[0].origin.label, 'Этот Mac');
assert.equal(snapshot.connections[0].closedAt, null);
assert.equal(snapshot.connections[0].route.kind, 'vpn');
now = new Date('2026-08-31T10:00:01.000Z');
ledger.applyConnections({
reset: false,
events: [event(ConnectionEventType.CONNECTION_EVENT_UPDATE, 'a', {
uplinkDelta: 5n,
downlinkDelta: 7n,
})],
});
snapshot = ledger.snapshot();
assert.deepEqual(snapshot.connections[0].traffic, {
uploadBytes: '15',
downloadBytes: '27',
uploadBytesPerSecond: '5',
downloadBytesPerSecond: '7',
});
ledger.applyConnections({
reset: false,
events: [event(ConnectionEventType.CONNECTION_EVENT_CLOSED, 'a', {
connection: connection('a', {
closedAt: BigInt(now.getTime()),
uplinkTotal: 18n,
downlinkTotal: 30n,
}),
})],
});
const firstClosed = ledger.snapshot().connections[0];
now = new Date('2026-08-31T10:00:10.000Z');
ledger.applyConnections({
reset: false,
events: [event(ConnectionEventType.CONNECTION_EVENT_CLOSED, 'a', {
connection: connection('a', { uplinkTotal: 999n, downlinkTotal: 999n }),
})],
});
ledger.applyStatus(status(0, 18n, 30n));
snapshot = ledger.snapshot();
assert.equal(snapshot.source.state, 'live');
assert.equal(snapshot.summary.active, 0);
assert.equal(snapshot.summary.recent, 1);
assert.equal(snapshot.connections[0].closedAt, firstClosed.closedAt);
assert.deepEqual(snapshot.connections[0].traffic, {
uploadBytes: '18',
downloadBytes: '30',
uploadBytesPerSecond: '0',
downloadBytesPerSecond: '0',
});
assert.equal(snapshot.source.unattributedUploadBytes, '0');
assert.equal(snapshot.source.unattributedDownloadBytes, '0');
ledger.markStopped();
snapshot = ledger.snapshot();
assert.equal(snapshot.source.state, 'stopped');
assert.equal(snapshot.summary.recent, 0);
});
test('connection churn does not clear rates before the next update tick', () => {
const ledger = createLiveTrafficLedger();
ledger.beginEpoch(100n, '1.14.0-rc.5', 4);
ledger.applyConnections({
reset: true,
events: [event(ConnectionEventType.CONNECTION_EVENT_NEW, 'a', {
connection: connection('a'),
})],
});
ledger.applyConnections({
reset: false,
events: [event(ConnectionEventType.CONNECTION_EVENT_UPDATE, 'a', {
uplinkDelta: 5n,
downlinkDelta: 7n,
})],
});
ledger.applyConnections({
reset: false,
events: [event(ConnectionEventType.CONNECTION_EVENT_NEW, 'b', {
connection: connection('b'),
})],
});
const active = new Map(ledger.snapshot().connections.map((item) => [item.id, item]));
assert.equal(active.get('a').traffic.uploadBytesPerSecond, '5');
assert.equal(active.get('a').traffic.downloadBytesPerSecond, '7');
assert.equal(active.get('b').traffic.uploadBytesPerSecond, '0');
});
test('a connection completed between polls remains visible until the exact 30 second boundary', () => {
let now = new Date('2026-08-31T10:00:00.000Z');
const ledger = createLiveTrafficLedger({ now: () => now });
ledger.beginEpoch(100n, '1.14.0-rc.5', 4);
ledger.applyConnections({
reset: false,
events: [
event(ConnectionEventType.CONNECTION_EVENT_NEW, 'quick', {
connection: connection('quick', {
createdAt: BigInt(now.getTime()),
uplinkTotal: 2n,
downlinkTotal: 3n,
}),
}),
event(ConnectionEventType.CONNECTION_EVENT_CLOSED, 'quick', {
uplinkDelta: 5n,
downlinkDelta: 7n,
}),
],
});
let snapshot = ledger.snapshot();
assert.deepEqual(snapshot.summary, {
active: 0,
recent: 1,
visible: 1,
recognized: 0,
unresolved: 0,
unresolvedOrigin: 0,
truncated: false,
});
assert.equal(snapshot.connections[0].closedAt, '2026-08-31T10:00:00.000Z');
assert.deepEqual(snapshot.connections[0].traffic, {
uploadBytes: '7',
downloadBytes: '10',
uploadBytesPerSecond: '0',
downloadBytesPerSecond: '0',
});
now = new Date('2026-08-31T10:00:29.999Z');
assert.equal(ledger.snapshot().summary.recent, 1);
now = new Date('2026-08-31T10:00:30.000Z');
snapshot = ledger.snapshot();
assert.equal(snapshot.summary.recent, 0);
assert.equal(snapshot.connections.length, 0);
});
test('a connection already closed in an RC5 reset remains visible as recent', () => {
const ledger = createLiveTrafficLedger({ now: () => new Date('2026-08-31T10:00:02.000Z') });
ledger.beginEpoch(100n, '1.14.0-rc.5', 4);
ledger.applyConnections({
reset: true,
events: [event(ConnectionEventType.CONNECTION_EVENT_CLOSED, 'reset-closed', {
connection: connection('reset-closed', {
createdAt: BigInt(Date.parse('2026-08-31T10:00:00.000Z')),
closedAt: BigInt(Date.parse('2026-08-31T10:00:01.000Z')),
uplinkTotal: 3n,
downlinkTotal: 7n,
}),
})],
});
const snapshot = ledger.snapshot();
assert.equal(snapshot.summary.active, 0);
assert.equal(snapshot.summary.recent, 1);
assert.equal(snapshot.connections[0].id, 'reset-closed');
assert.equal(snapshot.connections[0].closedAt, '2026-08-31T10:00:01.000Z');
});
test('a genuinely newer lifecycle with the same UUID supersedes its recent tombstone', () => {
let now = new Date('2026-08-31T10:00:00.000Z');
const ledger = createLiveTrafficLedger({ now: () => now });
ledger.beginEpoch(100n, '1.14.0-rc.5', 4);
ledger.applyConnections({ reset: true, events: [] });
ledger.applyConnections({
reset: false,
events: [event(ConnectionEventType.CONNECTION_EVENT_NEW, 'revived', {
connection: connection('revived', { createdAt: BigInt(now.getTime()), uplinkTotal: 3n }),
})],
});
ledger.applyConnections({
reset: false,
events: [event(ConnectionEventType.CONNECTION_EVENT_CLOSED, 'revived')],
});
const firstClosedAt = ledger.snapshot().connections[0].closedAt;
ledger.applyConnections({
reset: false,
events: [event(ConnectionEventType.CONNECTION_EVENT_NEW, 'revived', {
connection: connection('revived', { createdAt: BigInt(now.getTime()), uplinkTotal: 999n }),
})],
});
assert.equal(ledger.snapshot().summary.active, 0);
assert.equal(ledger.snapshot().connections[0].closedAt, firstClosedAt);
now = new Date('2026-08-31T10:00:01.000Z');
ledger.applyConnections({
reset: false,
events: [event(ConnectionEventType.CONNECTION_EVENT_NEW, 'revived', {
connection: connection('revived', { createdAt: BigInt(now.getTime()), uplinkTotal: 4n }),
})],
});
const snapshot = ledger.snapshot();
assert.equal(snapshot.summary.active, 1);
assert.equal(snapshot.summary.recent, 0);
assert.equal(snapshot.connections[0].closedAt, null);
assert.equal(snapshot.connections[0].traffic.uploadBytes, '4');
});
test('reset reconciles active deltas without treating earlier closed traffic as a gap', () => {
const ledger = createLiveTrafficLedger();
ledger.beginEpoch(100n, '1.14.0-rc.5', 4);
ledger.applyConnections({
reset: true,
events: [
event(ConnectionEventType.CONNECTION_EVENT_NEW, 'closed-a', {
connection: connection('closed-a', { uplinkTotal: 100n }),
}),
event(ConnectionEventType.CONNECTION_EVENT_NEW, 'active-b', {
connection: connection('active-b', { uplinkTotal: 10n }),
}),
],
});
ledger.applyStatus(status(2, 110n, 0n));
ledger.applyConnections({
reset: false,
events: [event(ConnectionEventType.CONNECTION_EVENT_CLOSED, 'closed-a')],
});
ledger.applyStatus(status(1, 110n, 0n));
ledger.applyConnections({
reset: true,
events: [event(ConnectionEventType.CONNECTION_EVENT_NEW, 'active-b', {
connection: connection('active-b', { uplinkTotal: 20n }),
})],
});
ledger.applyStatus(status(1, 120n, 0n));
const snapshot = ledger.snapshot();
assert.equal(snapshot.source.state, 'live');
assert.equal(snapshot.source.unattributedUploadBytes, '0');
assert.equal(snapshot.summary.active, 1);
assert.equal(snapshot.summary.recent, 1);
assert.equal(snapshot.connections.find(({ id }) => id === 'active-b').traffic.uploadBytes, '20');
});
test('reset can revive the same lifecycle tombstone without counting its totals twice', () => {
const ledger = createLiveTrafficLedger();
ledger.beginEpoch(100n, '1.14.0-rc.5', 4);
ledger.applyConnections({
reset: false,
events: [event(ConnectionEventType.CONNECTION_EVENT_NEW, 'same', {
connection: connection('same', { uplinkTotal: 10n }),
})],
});
ledger.applyConnections({
reset: false,
events: [event(ConnectionEventType.CONNECTION_EVENT_CLOSED, 'same')],
});
ledger.applyConnections({
reset: true,
events: [event(ConnectionEventType.CONNECTION_EVENT_NEW, 'same', {
connection: connection('same', { uplinkTotal: 10n }),
})],
});
ledger.applyStatus(status(1, 10n, 0n));
ledger.applyStatus(status(1, 10n, 0n));
ledger.applyStatus(status(1, 10n, 0n));
const snapshot = ledger.snapshot();
assert.equal(snapshot.source.state, 'live');
assert.equal(snapshot.source.unattributedUploadBytes, '0');
assert.equal(snapshot.summary.active, 1);
assert.equal(snapshot.summary.recent, 0);
});
test('destination hostnames remain recognized without protocol sniffing', () => {
const ledger = createLiveTrafficLedger();
ledger.beginEpoch(100n, '1.14.0-rc.5', 4);
ledger.applyConnections({
reset: true,
events: [event(ConnectionEventType.CONNECTION_EVENT_NEW, 'hostname', {
connection: connection('hostname', {
destination: 'Example.COM:443',
domain: '',
protocol: '',
}),
})],
});
const snapshot = ledger.snapshot();
assert.equal(snapshot.summary.recognized, 1);
assert.deepEqual(snapshot.connections[0].destination, {
domain: 'example.com',
ip: null,
port: 443,
provenance: 'sing-box',
});
});
test('CLOSED keeps newly available native domain, protocol and route metadata', () => {
const ledger = createLiveTrafficLedger();
ledger.beginEpoch(100n, '1.14.0-rc.5', 4);
ledger.applyConnections({
reset: false,
events: [event(ConnectionEventType.CONNECTION_EVENT_NEW, 'late-metadata', {
connection: connection('late-metadata', {
domain: '',
protocol: '',
uplinkTotal: 1n,
downlinkTotal: 2n,
}),
})],
});
ledger.applyConnections({
reset: false,
events: [event(ConnectionEventType.CONNECTION_EVENT_CLOSED, 'late-metadata', {
connection: connection('late-metadata', {
domain: 'recognized.example',
protocol: 'http2',
outbound: 'direct',
outboundType: 'direct',
chainList: ['direct'],
rule: 'domain-final',
uplinkTotal: 3n,
downlinkTotal: 5n,
}),
})],
});
const closed = ledger.snapshot().connections[0];
assert.equal(closed.destination.domain, 'recognized.example');
assert.equal(closed.protocol, 'http2');
assert.deepEqual(closed.route, {
kind: 'direct',
scope: 'local-sing-box',
outbound: 'direct',
outboundType: 'direct',
chain: ['direct'],
rule: 'domain-final',
});
assert.equal(closed.traffic.uploadBytes, '3');
assert.equal(closed.traffic.downloadBytes, '5');
});
test('CLOSED partial route metadata keeps route kind consistent with its outbound', () => {
const ledger = createLiveTrafficLedger();
ledger.beginEpoch(100n, '1.14.0-rc.5', 4);
ledger.applyConnections({
reset: false,
events: [event(ConnectionEventType.CONNECTION_EVENT_NEW, 'partial-route', {
connection: connection('partial-route'),
})],
});
ledger.applyConnections({
reset: false,
events: [event(ConnectionEventType.CONNECTION_EVENT_CLOSED, 'partial-route', {
connection: connection('partial-route', {
outbound: '',
outboundType: '',
chainList: ['late-hop'],
rule: 'late-rule',
}),
})],
});
const route = ledger.snapshot().connections[0].route;
assert.equal(route.kind, 'vpn');
assert.equal(route.outbound, 'test-vpn');
assert.equal(route.outboundType, 'vless');
assert.deepEqual(route.chain, ['late-hop']);
assert.equal(route.rule, 'late-rule');
});
test('transport errors preserve the last observed data timestamp', () => {
let now = new Date('2026-08-31T10:00:00.000Z');
const ledger = createLiveTrafficLedger({ now: () => now });
ledger.beginEpoch(100n, '1.14.0-rc.5', 4);
ledger.applyConnections({ reset: true, events: [] });
ledger.applyStatus(status(0, 0n, 0n));
const lastGood = ledger.snapshot();
now = new Date('2026-08-31T10:01:00.000Z');
ledger.markTransportError(new Error('stream ended'));
const firstStale = ledger.snapshot();
ledger.beginEpoch(100n, '1.14.0-rc.5', 4);
ledger.markTransportError(new Error('stream ended again'));
const stale = ledger.snapshot();
assert.equal(stale.source.state, 'stale');
assert.equal(stale.observedAt, lastGood.observedAt);
assert.equal(firstStale.observedAt, lastGood.observedAt);
assert.equal(stale.sequence, lastGood.sequence + 3);
});
test('reset is idempotent, a restart creates a clean epoch, and settled UUIDs are bounded', () => {
const ledger = createLiveTrafficLedger();
const active = connection('active', { uplinkTotal: 10n, downlinkTotal: 20n });
const closed = connection('closed', {
closedAt: 1_700_000_001_000n,
uplinkTotal: 5n,
downlinkTotal: 7n,
});
ledger.beginEpoch(100n, '1.14.0-rc.5', 4);
const reset = {
reset: true,
events: [
event(ConnectionEventType.CONNECTION_EVENT_NEW, 'active', { connection: active }),
event(ConnectionEventType.CONNECTION_EVENT_NEW, 'closed', { connection: closed }),
],
};
const firstReset = ledger.applyConnections(reset);
ledger.applyStatus(status(1, 15n, 27n));
const repeatedReset = ledger.applyConnections(reset);
ledger.applyStatus(status(1, 15n, 27n));
assert.equal(ledger.snapshot().source.state, 'live');
assert.equal(ledger.snapshot().source.unattributedUploadBytes, '0');
assert.deepEqual(ledger.snapshot().connections.map(({ id }) => id), ['active']);
assert.equal(ledger.snapshot().summary.recent, 0);
assert.deepEqual(firstReset.connections.map(({ id }) => id), ['active', 'closed']);
assert.deepEqual(repeatedReset.connections.map(({ id }) => id), ['active']);
const settledEvents = Array.from({ length: 2_049 }, (_, index) => {
const id = `settled-${String(index).padStart(4, '0')}`;
return event(ConnectionEventType.CONNECTION_EVENT_CLOSED, id);
});
ledger.applyConnections({ reset: false, events: settledEvents });
ledger.applyConnections({
reset: false,
events: [
event(ConnectionEventType.CONNECTION_EVENT_NEW, 'settled-0000', {
connection: connection('settled-0000'),
}),
event(ConnectionEventType.CONNECTION_EVENT_NEW, 'settled-2048', {
connection: connection('settled-2048'),
}),
],
});
assert.deepEqual(
ledger.snapshot().connections.map(({ id }) => id).sort(),
['active', 'settled-0000'],
);
ledger.applyConnections({
reset: false,
events: [
event(ConnectionEventType.CONNECTION_EVENT_NEW, 'epoch-recent', {
connection: connection('epoch-recent'),
}),
event(ConnectionEventType.CONNECTION_EVENT_CLOSED, 'epoch-recent'),
],
});
assert.equal(ledger.snapshot().summary.recent, 1);
ledger.beginEpoch(101n, '1.14.0-rc.5', 4);
const restarted = ledger.snapshot();
assert.equal(restarted.epoch, 'sing-box-101');
assert.equal(restarted.source.state, 'connecting');
assert.equal(restarted.summary.active, 0);
assert.equal(restarted.summary.recent, 0);
assert.equal(restarted.source.unattributedUploadBytes, '0');
});
test('three consecutive status mismatches degrade without assigning the byte gap', () => {
const ledger = createLiveTrafficLedger();
ledger.beginEpoch(100n, '1.14.0-rc.5', 4);
ledger.applyConnections({
reset: true,
events: [event(ConnectionEventType.CONNECTION_EVENT_NEW, 'a', {
connection: connection('a', { uplinkTotal: 10n, downlinkTotal: 20n }),
})],
});
assert.ok(ledger.applyStatus(status(1, 15n, 27n)));
assert.ok(ledger.applyStatus(status(1, 15n, 27n)));
assert.equal(ledger.snapshot().source.state, 'live');
assert.equal(ledger.applyStatus(status(1, 15n, 27n)), null);
let snapshot = ledger.snapshot();
assert.equal(snapshot.source.state, 'degraded');
assert.equal(snapshot.source.unattributedUploadBytes, '5');
assert.equal(snapshot.source.unattributedDownloadBytes, '7');
assert.deepEqual(snapshot.connections[0].traffic, {
uploadBytes: '10',
downloadBytes: '20',
uploadBytesPerSecond: '0',
downloadBytesPerSecond: '0',
});
assert.ok(ledger.applyStatus(status(1, 10n, 20n)));
snapshot = ledger.snapshot();
assert.equal(snapshot.source.state, 'live');
assert.equal(snapshot.source.unattributedUploadBytes, '0');
assert.equal(snapshot.source.unattributedDownloadBytes, '0');
});
test('a missing NEW does not double-count UPDATE before an absolute CLOSED total', () => {
const ledger = createLiveTrafficLedger({ now: () => new Date('2023-11-14T22:13:21.000Z') });
ledger.beginEpoch(100n, '1.14.0-rc.5', 4);
ledger.applyConnections({ reset: true, events: [] });
ledger.applyConnections({
reset: false,
events: [event(ConnectionEventType.CONNECTION_EVENT_UPDATE, 'missed', {
uplinkDelta: 10n,
downlinkDelta: 20n,
})],
});
ledger.applyStatus(status(0, 10n, 20n));
assert.equal(ledger.snapshot().source.unattributedUploadBytes, '10');
ledger.applyConnections({
reset: false,
events: [event(ConnectionEventType.CONNECTION_EVENT_CLOSED, 'missed', {
connection: connection('missed', {
closedAt: 1_700_000_001_000n,
uplinkTotal: 15n,
downlinkTotal: 27n,
}),
})],
});
ledger.applyStatus(status(0, 15n, 27n));
let snapshot = ledger.snapshot();
assert.equal(snapshot.source.unattributedUploadBytes, '15');
assert.equal(snapshot.source.unattributedDownloadBytes, '27');
assert.equal(snapshot.summary.recent, 1);
assert.equal(snapshot.connections[0].closedAt, '2023-11-14T22:13:21.000Z');
assert.deepEqual(snapshot.connections[0].traffic, {
uploadBytes: '15',
downloadBytes: '27',
uploadBytesPerSecond: '0',
downloadBytesPerSecond: '0',
});
ledger.applyConnections({
reset: false,
events: [event(ConnectionEventType.CONNECTION_EVENT_CLOSED, 'metadata-less', {
uplinkDelta: 3n,
downlinkDelta: 4n,
})],
});
ledger.applyStatus(status(0, 18n, 31n));
snapshot = ledger.snapshot();
assert.equal(snapshot.source.unattributedUploadBytes, '18');
assert.equal(snapshot.source.unattributedDownloadBytes, '31');
});
test('snapshot caps visibility at 256 while summary covers every active connection', () => {
const ledger = createLiveTrafficLedger();
ledger.beginEpoch(100n, '1.14.0-rc.5', 4);
ledger.applyConnections({
reset: false,
events: [
event(ConnectionEventType.CONNECTION_EVENT_NEW, 'recent', { connection: connection('recent') }),
event(ConnectionEventType.CONNECTION_EVENT_CLOSED, 'recent'),
],
});
const events = Array.from({ length: 257 }, (_, index) => {
const id = index === 255 ? 'z-tie' : index === 256 ? 'a-tie' : `id-${String(index).padStart(3, '0')}`;
const createdAt = index >= 255 ? 1_700_000_000_255n : 1_700_000_000_000n + BigInt(index);
return event(ConnectionEventType.CONNECTION_EVENT_NEW, id, {
connection: connection(id, {
createdAt,
domain: index % 2 === 0 ? `service-${index}.example` : '',
destination: `203.0.113.${index % 255}:443`,
outbound: index % 3 === 0 ? 'direct' : 'test-vpn',
outboundType: index % 3 === 0 ? 'direct' : 'vless',
}),
});
});
ledger.applyConnections({ reset: true, events });
const snapshot = ledger.snapshot();
assert.deepEqual(snapshot.summary, {
active: 257,
recent: 1,
visible: 256,
recognized: 129,
unresolved: 128,
unresolvedOrigin: 0,
truncated: true,
});
assert.equal(snapshot.connections.length, 256);
assert.deepEqual(snapshot.connections.slice(0, 2).map(({ id }) => id), ['a-tie', 'z-tie']);
assert.equal(snapshot.connections.some(({ id }) => id === 'id-000'), false);
assert.equal(snapshot.connections.some(({ id }) => id === 'recent'), false);
});
test('gateway projection uses selector chains and resolves every active origin from the current device map', () => {
let deviceVisible = false;
const ledger = createLiveTrafficLedger({
gateway: true,
resolveOrigin: (sourceIp) => deviceVisible
? { kind: 'device', id: 'device-a', label: 'MacBook', provenance: 'source-ip' }
: { kind: 'unknown', id: null, label: sourceIp, provenance: 'unknown' },
});
ledger.beginEpoch(100n, '1.14.0-rc.5', 4);
const first = ledger.applyConnections({
reset: true,
events: [event(ConnectionEventType.CONNECTION_EVENT_NEW, 'gateway', {
connection: connection('gateway', {
inbound: 'tproxy-in',
inboundType: 'tproxy',
source: '192.168.50.7:54000',
outbound: 'channel-selector',
outboundType: 'selector',
chainList: ['channel-primary', 'channel-selector'],
}),
})],
});
assert.equal(first.connections[0].route.kind, 'vpn');
assert.equal(ledger.snapshot().summary.unresolvedOrigin, 1);
deviceVisible = true;
assert.equal(ledger.snapshot().summary.unresolvedOrigin, 0);
assert.equal(ledger.snapshot().connections[0].origin.id, 'device-a');
const second = ledger.applyConnections({
reset: false,
events: [event(ConnectionEventType.CONNECTION_EVENT_UPDATE, 'gateway', { uplinkDelta: 1n })],
});
assert.equal(second.connections[0].origin.id, 'device-a');
assert.equal(second.connections[0].traffic.uploadBytes, '1');
assert.equal(ledger.snapshot().capabilities.deviceAttribution, true);
});
test('recent closed storage is bounded independently from the 256-row response cap', () => {
const ledger = createLiveTrafficLedger();
ledger.beginEpoch(100n, '1.14.0-rc.5', 4);
const events = Array.from({ length: 2_049 }, (_, index) => {
const id = `recent-${String(index).padStart(4, '0')}`;
return event(ConnectionEventType.CONNECTION_EVENT_CLOSED, id, { connection: connection(id) });
});
const projection = ledger.applyConnections({ reset: false, events });
const snapshot = ledger.snapshot();
assert.equal(projection.connections.length, 2_049);
assert.equal(projection.closedIds.length, 2_049);
assert.equal(snapshot.summary.active, 0);
assert.equal(snapshot.summary.recent, 2_048);
assert.equal(snapshot.summary.visible, 256);
assert.equal(snapshot.summary.truncated, true);
assert.equal(snapshot.connections[0].id, 'recent-0001');
assert.equal(snapshot.connections.some(({ id }) => id === 'recent-0000'), false);
});
+208
View File
@@ -0,0 +1,208 @@
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import test from 'node:test';
import {
createLiveTrafficRoute,
enrichLiveTrafficDeviceLabels,
} from '../../dist/server/http/routes/liveTrafficRoute.js';
function response() {
return {
writeHead(status, headers) {
this.status = status;
this.headers = headers;
},
end(payload) {
this.rawPayload = payload;
this.payload = JSON.parse(payload);
},
};
}
const snapshot = {
apiVersion: 1,
epoch: 'sing-box-1700000000000',
sequence: 7,
observedAt: '2026-08-31T10:00:00.000Z',
capabilities: {
lifecycle: true,
deviceAttribution: false,
applicationAttribution: false,
},
source: {
transport: 'native',
state: 'live',
completeness: 'lifecycle',
singBoxVersion: '1.14.0-rc.5',
singBoxApiVersion: 4,
error: null,
unattributedUploadBytes: '0',
unattributedDownloadBytes: '0',
},
summary: {
active: 0,
recent: 0,
visible: 0,
recognized: 0,
unresolved: 0,
unresolvedOrigin: 0,
truncated: false,
},
connections: [],
};
test('split Gateway control reads the cached socket while combined Gateway keeps no collector', () => {
const index = readFileSync(new URL('../../src/server/index.ts', import.meta.url), 'utf8');
assert.match(index, /const liveTraffic = clientLiveTraffic \|\| \(remoteDataplane \? \{[\s\S]*observeLiveTraffic\(\)[\s\S]*\} : null\)/);
assert.match(index, /createLiveTrafficRoute\(\{[\s\S]*traffic: liveTraffic,[\s\S]*deviceInventory: remoteDataplane \? deviceInventory : null/);
assert.match(index, /clientLiveTraffic\?\.start\(\)/);
assert.doesNotMatch(index, /liveTraffic\?\.start\(\)/);
});
test('GET returns the injected cached snapshot without another data-source operation', async () => {
let snapshots = 0;
const route = createLiveTrafficRoute({
traffic: {
snapshot() {
snapshots += 1;
return snapshot;
},
},
});
const res = response();
assert.equal(await route.handle({ method: 'GET', url: '/api/traffic/live?ignored=1' }, res), true);
assert.equal(snapshots, 1);
assert.equal(res.status, 200);
assert.deepEqual(res.headers, { 'content-type': 'application/json; charset=utf-8' });
assert.deepEqual(res.payload, snapshot);
});
test('async Gateway snapshots are validated and known dev labels are enriched without changing summary', async () => {
const gatewaySnapshot = {
...snapshot,
capabilities: { ...snapshot.capabilities, deviceAttribution: true },
summary: {
active: 300,
recent: 0,
visible: 256,
recognized: 300,
unresolved: 0,
unresolvedOrigin: 299,
truncated: true,
},
connections: Array.from({ length: 256 }, (_, index) => ({
id: `connection-${String(index).padStart(3, '0')}`,
startedAt: new Date(Date.parse(snapshot.observedAt) - index * 1000).toISOString(),
closedAt: null,
inbound: { tag: 'tproxy-in', type: 'tproxy' },
network: 'tcp',
protocol: 'tls',
source: { ip: index === 0 ? '192.168.50.7' : '192.168.50.8', port: 50_000 + index },
destination: { domain: 'example.com', ip: '203.0.113.1', port: 443, provenance: 'sing-box' },
origin: index === 0
? { kind: 'device', id: 'dev_0011223344556677', label: '192.168.50.7', provenance: 'source-ip' }
: { kind: 'unknown', id: null, label: 'Неизвестное устройство', provenance: 'unknown' },
route: {
kind: 'vpn',
scope: 'local-sing-box',
outbound: 'proxy',
outboundType: 'selector',
chain: ['proxy'],
rule: 'default',
},
traffic: {
uploadBytes: '10',
downloadBytes: '20',
uploadBytesPerSecond: '1',
downloadBytesPerSecond: '2',
},
})),
};
const original = structuredClone(gatewaySnapshot);
const route = createLiveTrafficRoute({
traffic: { snapshot: async () => gatewaySnapshot },
deviceInventory: {
snapshot: () => ({
devices: [{
id: 'dev_0011223344556677',
alias: 'Гостиная',
hostname: 'tv.local',
ip: '192.168.50.7',
}],
}),
},
});
const res = response();
assert.equal(await route.handle({ method: 'GET', url: '/api/traffic/live' }, res), true);
assert.equal(res.payload.connections[0].origin.label, 'Гостиная');
assert.equal(res.payload.connections[1].origin.kind, 'unknown');
assert.deepEqual(res.payload.summary, gatewaySnapshot.summary);
assert.deepEqual(gatewaySnapshot, original);
});
test('device label enrichment follows alias, hostname and IP without identifying unknown origins', () => {
const connection = {
id: 'connection-1',
startedAt: snapshot.observedAt,
closedAt: null,
inbound: { tag: 'tproxy-in', type: 'tproxy' },
network: 'tcp',
protocol: 'tls',
source: { ip: '192.168.50.7', port: 50_000 },
destination: { domain: 'example.com', ip: '203.0.113.1', port: 443, provenance: 'sing-box' },
origin: { kind: 'device', id: 'dev_0011223344556677', label: '192.168.50.7', provenance: 'source-ip' },
route: { kind: 'vpn', scope: 'local-sing-box', outbound: 'proxy', outboundType: 'selector', chain: ['proxy'], rule: 'default' },
traffic: { uploadBytes: '1', downloadBytes: '2', uploadBytesPerSecond: '0', downloadBytesPerSecond: '0' },
};
const source = {
...snapshot,
capabilities: { ...snapshot.capabilities, deviceAttribution: true },
summary: { ...snapshot.summary, active: 1, visible: 1, recognized: 1 },
connections: [connection],
};
const labels = (device) => enrichLiveTrafficDeviceLabels(source, { devices: [device] })
.connections[0].origin.label;
assert.equal(labels({ id: connection.origin.id, alias: ' ТВ ', hostname: 'tv.local', ip: '192.168.50.7' }), 'ТВ');
assert.equal(labels({ id: connection.origin.id, alias: '', hostname: 'tv.local', ip: '192.168.50.7' }), 'tv.local');
assert.equal(labels({ id: connection.origin.id, alias: '', hostname: null, ip: '192.168.50.7' }), '192.168.50.7');
assert.equal(labels({ id: 'dev_ffffffffffffffff', alias: 'Чужой', ip: connection.source.ip }), connection.origin.label);
const unknown = { ...source, connections: [{ ...connection, origin: { kind: 'unknown', id: null, label: 'Неизвестно', provenance: 'unknown' } }] };
assert.equal(enrichLiveTrafficDeviceLabels(unknown, { devices: [{ ...connection.origin, id: 'dev_0011223344556677', alias: 'Не угадывать' }] }).connections[0].origin.label, 'Неизвестно');
});
test('route rejects malformed cached snapshots before responding', async () => {
const route = createLiveTrafficRoute({
traffic: { snapshot: async () => ({ ...snapshot, apiVersion: 2 }) },
});
await assert.rejects(
route.handle({ method: 'GET', url: '/api/traffic/live' }, response()),
/apiVersion 1/,
);
});
test('route ignores other paths and rejects mutation methods without reading the cache', async () => {
let snapshots = 0;
const route = createLiveTrafficRoute({
traffic: { snapshot: () => { snapshots += 1; return snapshot; } },
});
assert.equal(await route.handle({ method: 'GET', url: '/api/traffic/history' }, response()), false);
await assert.rejects(
route.handle({ method: 'POST', url: '/api/traffic/live' }, response()),
(error) => error.code === 'ENDPOINT_NOT_FOUND',
);
assert.equal(snapshots, 0);
});
test('route is unavailable when no traffic collector exists', async () => {
const route = createLiveTrafficRoute({ traffic: null });
await assert.rejects(
route.handle({ method: 'GET', url: '/api/traffic/live' }, response()),
(error) => error.code === 'ENDPOINT_NOT_FOUND',
);
});
+46
View File
@@ -40,6 +40,28 @@ const snapshot = {
},
domainTraffic: {
observedAt,
source: {
error: null,
mode: 'shadow',
writer: 'snapshot',
activeConnections: 4,
native: {
state: 'degraded',
epoch: 'epoch-1',
sequence: 7,
observedAt,
active: 5,
unattributedUploadBytes: '11',
unattributedDownloadBytes: '22',
},
shadow: {
activeDifference: 1,
uploadDifferenceBytes: '-30',
downloadDifferenceBytes: '40',
routeMismatches: 2,
deviceMismatches: 3,
},
},
overflowConnections: '2',
attributionEvents: {
unresolved_host: '3',
@@ -97,6 +119,15 @@ test('Prometheus exposition keeps exact counters, stable identity and escaped na
assert.match(output, /harbor_domain_traffic_attribution_events_total\{outcome="unresolved_host"\} 3/);
assert.match(output, /harbor_domain_traffic_attribution_events_total\{outcome="unknown_device"\} 4/);
assert.match(output, /harbor_domain_traffic_attribution_events_total\{outcome="unsupported_source"\} 5/);
assert.match(output, /harbor_traffic_collector_info\{mode="shadow",writer="snapshot"\} 1/);
assert.match(output, /harbor_traffic_collector_state\{state="degraded"\} 1/);
assert.match(output, /harbor_traffic_collector_unattributed_bytes\{direction="download"\} 22/);
assert.match(output, /harbor_traffic_collector_unattributed_bytes\{direction="upload"\} 11/);
assert.match(output, /harbor_traffic_shadow_active_difference 1/);
assert.match(output, /harbor_traffic_shadow_difference_bytes\{direction="download"\} 40/);
assert.match(output, /harbor_traffic_shadow_difference_bytes\{direction="upload"\} -30/);
assert.match(output, /harbor_traffic_shadow_route_mismatches 2/);
assert.match(output, /harbor_traffic_shadow_device_mismatches 3/);
assert.doesNotMatch(output, /harbor_device_domain_traffic_bytes_total\{[^\n]*name=/);
assert.doesNotMatch(output, /harbor_device_(?:direct_ipv4_packet|singbox_tracked)_bytes_total\{[^\n]*(?:name|ip|mac|server)=/);
assert.equal(output.endsWith('\n'), true);
@@ -122,6 +153,15 @@ test('Prometheus response uses the negotiated legacy text contract without mutat
assert.deepEqual(snapshot, before);
});
test('legacy combined Gateway snapshots keep existing metrics without collector diagnostics', () => {
const legacy = structuredClone(snapshot);
legacy.domainTraffic.source = { error: null, activeConnections: 0 };
const output = renderPrometheusMetrics(legacy);
assert.match(output, /harbor_singbox_tracked_bytes_total/);
assert.doesNotMatch(output, /harbor_traffic_collector_info/);
});
test('invalid canonical counters fail the scrape instead of publishing corrupt values', () => {
const invalid = { traffic: { gatewayBytes: 'broken', proxyBytes: '0' }, devices: [] };
assert.throws(
@@ -135,6 +175,12 @@ test('invalid canonical counters fail the scrape instead of publishing corrupt v
const invalidRoute = structuredClone(snapshot);
invalidRoute.domainTraffic.routes[0].outbound = 'vpn-server-tag';
assert.throws(() => renderPrometheusMetrics(invalidRoute), /Invalid sing-box outbound labels/);
const invalidCollector = structuredClone(snapshot);
invalidCollector.domainTraffic.source.mode = 'future';
assert.throws(() => renderPrometheusMetrics(invalidCollector), /Invalid traffic collector labels/);
const invalidShadow = structuredClone(snapshot);
invalidShadow.domainTraffic.source.shadow.uploadDifferenceBytes = '1.5';
assert.throws(() => renderPrometheusMetrics(invalidShadow), /Invalid Prometheus gauge/);
});
function routeResponse() {
+270
View File
@@ -1,12 +1,28 @@
import assert from 'node:assert/strict';
import { create } from '@bufbuild/protobuf';
import { connectNodeAdapter } from '@connectrpc/connect-node';
import { spawn } from 'node:child_process';
import fs from 'node:fs';
import http from 'node:http';
import http2 from 'node:http2';
import os from 'node:os';
import path from 'node:path';
import test from 'node:test';
import {
ConnectionEventsSchema,
StartedAtSchema,
StartedService,
StatusSchema,
VersionSchema,
} from '../../dist/server/generated/daemon/started_service_pb.js';
const root = path.resolve(import.meta.dirname, '../..');
process.env.APP_MODE = 'gateway';
process.env.DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), 'vpn-proxy-gateway-test-'));
process.env.SING_BOX_CACHE = path.join(process.env.DATA_DIR, 'cache.db');
process.env.SING_BOX_TRAFFIC_SOURCE = 'snapshot';
const {
buildDualChannelGatewayConfig,
@@ -124,3 +140,257 @@ test('cached dual-channel outbounds must match the applied provider fingerprints
config.outbounds[2].outbounds = ['channel-primary'];
assert.equal(dualChannelConfigMatchesApplied(config, applied, 'primary'), false);
});
function request(socketPath, pathname, method = 'GET', body) {
return new Promise((resolve, reject) => {
const payload = body === undefined ? null : JSON.stringify(body);
const value = http.request({
socketPath,
path: pathname,
method,
...(payload ? { headers: { 'content-type': 'application/json', 'content-length': Buffer.byteLength(payload) } } : {}),
}, (response) => {
const chunks = [];
response.on('data', (chunk) => chunks.push(chunk));
response.on('end', () => resolve(JSON.parse(Buffer.concat(chunks).toString('utf8'))));
});
value.on('error', reject);
value.end(payload);
});
}
async function waitForSocket(socketPath, child, stderr) {
for (let attempt = 0; attempt < 100; attempt += 1) {
if (child.exitCode !== null) throw new Error(`dataplane exited: ${stderr()}`);
try {
const status = await request(socketPath, '/status');
if (status.ready) return;
} catch {}
await new Promise((resolve) => setTimeout(resolve, 20));
}
throw new Error(`dataplane did not start: ${stderr()}`);
}
async function startDataplane(mode, apiPort) {
const directory = fs.mkdtempSync(path.join(os.tmpdir(), `harbor-${mode}-`));
const socketPath = path.join(directory, 'dataplane.sock');
const configPath = path.join(directory, 'sing-box.json');
const binDirectory = path.join(directory, 'bin');
fs.mkdirSync(binDirectory);
fs.writeFileSync(configPath, JSON.stringify({
services: [{
type: 'api',
listen: '127.0.0.1',
listen_port: 19091,
dashboard: false,
}],
inbounds: [],
outbounds: [],
}));
for (const [name, source] of [
['sing-box', `#!/bin/sh
if [ "$1" = check ]; then exit 0; fi
trap 'exit 0' TERM INT
while :; do sleep 1; done
`],
['ip', `#!/bin/sh
printf '%s\n' '[{"dst":"192.168.50.7","lladdr":"00:11:22:33:44:55","dev":"en0","state":["REACHABLE"]}]'
`],
['iptables', '#!/bin/sh\nexit 0\n'],
['iptables-restore', '#!/bin/sh\nexit 0\n'],
]) {
const executable = path.join(binDirectory, name);
fs.writeFileSync(executable, source);
fs.chmodSync(executable, 0o755);
}
const child = spawn(process.execPath, ['dist/server/main.js'], {
cwd: root,
env: {
...process.env,
APP_MODE: 'gateway',
APP_COMPONENT: 'dataplane',
DATA_DIR: directory,
DATAPLANE_SOCKET: socketPath,
DEVICE_TRAFFIC_ACCOUNTING_ENABLED: 'false',
SING_BOX_API_PORT: String(apiPort),
SING_BOX_CACHE: path.join(directory, 'cache.db'),
SING_BOX_CONFIG: configPath,
SING_BOX_API_SECRET: path.join(directory, 'api.secret'),
SING_BOX_RUNTIME_CONFIG: path.join(directory, 'runtime-config.json'),
SING_BOX_TRAFFIC_SOURCE: mode,
PATH: `${binDirectory}:${process.env.PATH || ''}`,
},
stdio: ['ignore', 'ignore', 'pipe'],
});
let error = '';
child.stderr.on('data', (chunk) => { error += chunk; });
const stop = async () => {
if (child.exitCode === null) {
const exited = new Promise((resolve) => child.once('exit', resolve));
child.kill('SIGTERM');
await exited;
}
fs.rmSync(directory, { recursive: true, force: true });
};
try {
await waitForSocket(socketPath, child, () => error);
return { socketPath, child, stop };
} catch (reason) {
await stop();
throw reason;
}
}
function waitForAbort(signal) {
return new Promise((resolve) => {
if (signal.aborted) resolve();
else signal.addEventListener('abort', resolve, { once: true });
});
}
async function waitForSnapshot(socketPath, pathname, predicate, method = 'GET', body) {
const deadline = Date.now() + 3_000;
while (Date.now() < deadline) {
const snapshot = await request(socketPath, pathname, method, body);
if (predicate(snapshot)) return snapshot;
await new Promise((resolve) => setTimeout(resolve, 20));
}
throw new Error(`timed out waiting for ${pathname}`);
}
function nativeRoutes(router, source) {
router.service(StartedService, {
getVersion: () => create(VersionSchema, { version: '1.14.0-rc.5', apiVersion: 4 }),
getStartedAt: () => create(StartedAtSchema, { startedAt: 1_700_000_000_000n }),
async *subscribeConnections(_request, context) {
yield create(ConnectionEventsSchema, {
reset: true,
events: [{
type: 0,
id: 'native-1',
connection: {
id: 'native-1',
inbound: 'tproxy-in',
inboundType: 'tproxy',
network: 'tcp',
source: '192.168.50.7:54000',
destination: '203.0.113.10:443',
domain: 'native.example',
protocol: 'tls',
createdAt: 1_700_000_000_000n,
uplinkTotal: 101n,
downlinkTotal: 202n,
outbound: 'channel-selector',
outboundType: 'selector',
chainList: ['channel-primary', 'channel-selector'],
},
}],
});
await waitForAbort(context.signal);
},
async *subscribeStatus(_request, context) {
while (!context.signal.aborted) {
yield create(StatusSchema, {
connectionsIn: source.mismatch ? 2 : 1,
uplinkTotal: source.mismatch ? 999n : 101n,
downlinkTotal: source.mismatch ? 999n : 202n,
});
await Promise.race([
waitForAbort(context.signal),
new Promise((resolve) => setTimeout(resolve, 100)),
]);
}
},
});
}
test('Gateway modes keep snapshot compare-only and make native the sole canonical writer', async (t) => {
let connectionReads = 0;
const nativeSource = { mismatch: false };
const clash = http.createServer((req, res) => {
if (req.url === '/connections') connectionReads += 1;
res.writeHead(200, { 'content-type': 'application/json' });
res.end(JSON.stringify({ connections: [{
id: 'legacy-1',
upload: 11,
download: 22,
metadata: {
type: 'tproxy/tproxy-in',
host: 'legacy.example',
sourceIP: '192.168.50.7',
},
chains: ['channel-primary'],
}] }));
});
await new Promise((resolve) => clash.listen(0, '127.0.0.1', resolve));
t.after(() => new Promise((resolve) => clash.close(resolve)));
const apiPort = clash.address().port;
const native = http2.createServer(connectNodeAdapter({
routes: (router) => nativeRoutes(router, nativeSource),
}));
await new Promise((resolve) => native.listen(19091, '127.0.0.1', resolve));
t.after(() => new Promise((resolve) => native.close(resolve)));
for (const mode of ['snapshot', 'shadow', 'native']) {
connectionReads = 0;
const dataplane = await startDataplane(mode, apiPort);
try {
const live = mode === 'snapshot'
? await request(dataplane.socketPath, '/traffic/live')
: await waitForSnapshot(
dataplane.socketPath,
'/traffic/live',
(snapshot) => snapshot.source.state === 'live',
);
const domain = await waitForSnapshot(
dataplane.socketPath,
'/domain-traffic',
(snapshot) => snapshot.tracked[0]?.uploadBytes === (mode === 'native' ? '101' : '11'),
);
assert.equal(domain.source.mode, mode);
assert.equal(domain.source.writer, mode === 'native' ? 'native' : 'snapshot');
assert.equal(domain.source.native === null, mode === 'snapshot');
assert.equal(domain.source.shadow === null, mode !== 'shadow');
assert.equal(live.source.state, mode === 'snapshot' ? 'disabled' : 'live');
assert.equal(connectionReads > 0, mode !== 'native');
if (mode === 'shadow') {
assert.equal(domain.source.native.active, 1);
assert.equal(domain.source.shadow.uploadDifferenceBytes, '90');
assert.equal(domain.source.shadow.downloadDifferenceBytes, '180');
}
if (mode === 'native') {
assert.equal(connectionReads, 0);
assert.equal(live.connections[0].id, 'native-1');
assert.deepEqual(domain.tracked, [{
source: 'gateway',
outbound: 'vpn',
uploadBytes: '101',
downloadBytes: '202',
}]);
await request(dataplane.socketPath, '/failover/activity', 'PUT', { enabled: true });
await waitForSnapshot(
dataplane.socketPath,
'/failover/activity/read',
(response) => response.activity?.state === 'quiet',
'POST',
{ thresholdBytesPerSecond: 0 },
);
nativeSource.mismatch = true;
await waitForSnapshot(
dataplane.socketPath,
'/traffic/live',
(snapshot) => snapshot.source.state === 'degraded',
);
const degradedActivity = await request(
dataplane.socketPath,
'/failover/activity/read',
'POST',
{ thresholdBytesPerSecond: 0 },
);
assert.equal(degradedActivity.activity, null);
}
} finally {
await dataplane.stop();
}
}
});
+355
View File
@@ -0,0 +1,355 @@
import assert from 'node:assert/strict';
import { execFileSync } from 'node:child_process';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { pathToFileURL } from 'node:url';
import test from 'node:test';
import { createLiveTrafficService } from '../../dist/server/services/liveTrafficService.js';
const subscriptionConfig = {
outbounds: [{
type: 'vless',
tag: 'test-vpn',
server: 'vpn.example.test',
server_port: 443,
uuid: '00000000-0000-4000-8000-000000000000',
tls: { enabled: true },
}],
};
function buildClientConfig(trafficSource) {
const dataDir = fs.mkdtempSync(path.join(os.tmpdir(), `harbor-native-${trafficSource}-`));
const singboxUrl = pathToFileURL(path.resolve('dist/server/singbox.js')).href;
const script = `
const { buildGatewayConfig } = await import(${JSON.stringify(singboxUrl)});
const config = buildGatewayConfig(${JSON.stringify(subscriptionConfig)}, 'test-vpn');
process.stdout.write(JSON.stringify(config));
`;
try {
return JSON.parse(execFileSync(process.execPath, ['--input-type=module', '--eval', script], {
cwd: path.resolve('.'),
encoding: 'utf8',
env: {
...process.env,
APP_MODE: 'client',
DATA_DIR: dataDir,
SING_BOX_CACHE: path.join(dataDir, 'cache.db'),
SING_BOX_TRAFFIC_SOURCE: trafficSource,
},
}));
} finally {
fs.rmSync(dataDir, { recursive: true, force: true });
}
}
function abortableStream(value, signal) {
return (async function* stream() {
yield value;
await new Promise((_, reject) => {
const abort = () => reject(signal.reason || new Error('aborted'));
if (signal.aborted) abort();
else signal.addEventListener('abort', abort, { once: true });
});
}());
}
async function waitFor(check, timeout = 1_000) {
const deadline = Date.now() + timeout;
while (!check()) {
if (Date.now() >= deadline) throw new Error('Timed out waiting for native traffic collector');
await new Promise((resolve) => setTimeout(resolve, 5));
}
}
test('client native mode adds the private API service and removes the retired DNS option', () => {
const config = buildClientConfig('native');
assert.deepEqual(config.services, [{
type: 'api',
listen: '127.0.0.1',
listen_port: 19091,
dashboard: false,
}]);
assert.deepEqual(config.dns, {});
assert.equal(config.experimental.clash_api, undefined);
});
test('client disabled mode omits the API service and keeps the 1.13-compatible DNS option', () => {
const config = buildClientConfig('disabled');
assert.equal(config.services, undefined);
assert.deepEqual(config.dns, { independent_cache: true });
assert.equal(config.experimental.clash_api, undefined);
});
test('native collector authenticates all RC5 lifecycle calls and uses one-second Go duration intervals', async () => {
const requests = [];
const connection = {
id: 'connection-1',
inbound: 'mixed-in',
inboundType: 'mixed',
network: 'tcp',
source: '127.0.0.1:54000',
destination: '203.0.113.10:443',
domain: 'example.test',
protocol: 'tls',
createdAt: 1_700_000_000_000n,
closedAt: 0n,
uplinkTotal: 12n,
downlinkTotal: 34n,
outbound: 'test-vpn',
outboundType: 'vless',
rule: 'final',
chainList: ['test-vpn'],
};
const clientFactory = (port) => {
requests.push(['factory', port]);
return {
async getVersion(_input, { signal, headers }) {
requests.push(['version', signal instanceof AbortSignal, headers?.authorization]);
return { version: '1.14.0-rc.5', apiVersion: 4 };
},
async getStartedAt(_input, { signal, headers }) {
requests.push(['started-at', signal instanceof AbortSignal, headers?.authorization]);
return { startedAt: 1_700_000_000_000n };
},
subscribeConnections({ interval }, { signal, headers }) {
requests.push(['connections', interval, headers?.authorization]);
return abortableStream({
reset: true,
events: [{ type: 0, id: connection.id, connection }],
}, signal);
},
subscribeStatus({ interval }, { signal, headers }) {
requests.push(['status', interval, headers?.authorization]);
return abortableStream({
connectionsIn: 1,
uplinkTotal: 12n,
downlinkTotal: 34n,
}, signal);
},
};
};
const service = createLiveTrafficService({
port: 19091,
enabled: true,
isRuntimeRunning: () => true,
authorization: () => 'test-secret',
clientFactory,
});
service.start();
await waitFor(() => service.snapshot().source.state === 'live');
const snapshot = service.snapshot();
assert.equal(snapshot.epoch, 'sing-box-1700000000000');
assert.equal(snapshot.source.singBoxVersion, '1.14.0-rc.5');
assert.equal(snapshot.source.singBoxApiVersion, 4);
assert.equal(snapshot.connections[0].destination.domain, 'example.test');
assert.deepEqual(requests, [
['factory', 19091],
['version', true, 'Bearer test-secret'],
['started-at', true, 'Bearer test-secret'],
['connections', 1_000_000_000n, 'Bearer test-secret'],
['status', 1_000_000_000n, 'Bearer test-secret'],
]);
await service.stop();
});
test('disabled collector stays cached and never constructs a native client', async () => {
let factoryCalls = 0;
const service = createLiveTrafficService({
port: 19091,
enabled: false,
isRuntimeRunning: () => true,
clientFactory: () => {
factoryCalls += 1;
throw new Error('must not connect');
},
});
service.start();
assert.equal(service.snapshot().source.state, 'disabled');
assert.equal(factoryCalls, 0);
await service.stop();
});
test('a native runtime materialization error is cached as incompatible without constructing a client', async () => {
let factoryCalls = 0;
const service = createLiveTrafficService({
port: 19091,
enabled: false,
unavailableError: 'secret file unavailable at https://private.example/path',
isRuntimeRunning: () => true,
clientFactory: () => {
factoryCalls += 1;
throw new Error('must not connect');
},
});
service.start();
assert.equal(service.snapshot().source.state, 'incompatible');
assert.equal(service.snapshot().source.error, 'secret file unavailable at [endpoint]');
assert.equal(factoryCalls, 0);
await service.stop();
});
for (const incompatible of [
{ version: '1.13.18', apiVersion: 4 },
{ version: '1.14.0-rc.5', apiVersion: 5 },
]) {
test(`collector rejects unqualified sing-box ${incompatible.version} API ${incompatible.apiVersion}`, async () => {
let startedAtCalls = 0;
const service = createLiveTrafficService({
port: 19091,
enabled: true,
isRuntimeRunning: () => true,
clientFactory: () => ({
async getVersion() { return incompatible; },
async getStartedAt() {
startedAtCalls += 1;
return { startedAt: 1n };
},
subscribeConnections() { throw new Error('must not subscribe'); },
subscribeStatus() { throw new Error('must not subscribe'); },
}),
});
service.start();
await waitFor(() => service.snapshot().source.state === 'incompatible');
assert.equal(service.snapshot().source.singBoxVersion, incompatible.version);
assert.equal(service.snapshot().source.singBoxApiVersion, incompatible.apiVersion);
assert.equal(startedAtCalls, 0);
await service.stop();
});
}
test('collector reconnects when either RC5 stream ends and cancels its sibling', async () => {
let factoryCalls = 0;
let authorizationCalls = 0;
const attachedSecrets = [];
let firstStatusSignal;
const pending = (signal) => (async function* stream() {
await new Promise((_, reject) => {
const abort = () => reject(signal.reason || new Error('aborted'));
if (signal.aborted) abort();
else signal.addEventListener('abort', abort, { once: true });
});
}());
const clientFactory = () => {
factoryCalls += 1;
const attempt = factoryCalls;
return {
async getVersion(_input, { headers }) {
attachedSecrets.push(headers.authorization);
return { version: '1.14.0-rc.5', apiVersion: 4 };
},
async getStartedAt() {
return { startedAt: 1_700_000_000_000n };
},
subscribeConnections(_input, { signal }) {
return attempt === 1 ? (async function* ended() {})() : pending(signal);
},
subscribeStatus(_input, { signal }) {
if (attempt === 1) firstStatusSignal = signal;
return pending(signal);
},
};
};
const service = createLiveTrafficService({
port: 19091,
enabled: true,
isRuntimeRunning: () => true,
authorization: () => `secret-${++authorizationCalls}`,
clientFactory,
});
service.start();
await waitFor(() => factoryCalls >= 2, 2_000);
assert.equal(firstStatusSignal.aborted, true);
assert.equal(factoryCalls, 2);
assert.deepEqual(attachedSecrets, ['Bearer secret-1', 'Bearer secret-2']);
await service.stop();
});
test('a failed lifecycle projection is retained across one collector reconnect', async () => {
let factoryCalls = 0;
const projected = [];
const idleStream = (signal) => (async function* stream() {
await new Promise((_, reject) => {
const abort = () => reject(signal.reason || new Error('aborted'));
if (signal.aborted) abort();
else signal.addEventListener('abort', abort, { once: true });
});
}());
const connection = {
id: 'projection',
inbound: 'tproxy-in',
inboundType: 'tproxy',
network: 'tcp',
source: '192.168.50.7:54000',
destination: '203.0.113.10:443',
domain: 'example.test',
protocol: 'tls',
createdAt: 1_700_000_000_000n,
closedAt: 0n,
uplinkTotal: 1n,
downlinkTotal: 0n,
outbound: 'channel-selector',
outboundType: 'selector',
rule: 'final',
chainList: ['channel-primary', 'channel-selector'],
};
const clientFactory = () => {
factoryCalls += 1;
const attempt = factoryCalls;
return {
async getVersion() {
return { version: '1.14.0-rc.5', apiVersion: 4 };
},
async getStartedAt() {
return { startedAt: 1_700_000_000_000n };
},
subscribeConnections(_input, { signal }) {
return (async function* stream() {
yield { reset: true, events: [{ type: 0, id: connection.id, connection }] };
await new Promise((_, reject) => {
signal.addEventListener('abort', () => reject(signal.reason || new Error('aborted')), { once: true });
});
}());
},
subscribeStatus(_input, { signal }) {
return attempt === 1
? idleStream(signal)
: (async function* stream() {
while (!signal.aborted) {
yield { connectionsIn: 1, uplinkTotal: 1n, downlinkTotal: 0n };
await new Promise((resolve) => setTimeout(resolve, 20));
}
}());
},
};
};
const service = createLiveTrafficService({
port: 19091,
enabled: true,
gateway: true,
isRuntimeRunning: () => true,
clientFactory,
onProjection: async (batch) => {
projected.push(batch.connections[0]?.traffic.uploadBytes || 'heartbeat');
if (projected.length === 1) throw new Error('writer unavailable');
},
});
service.start();
await waitFor(() => factoryCalls >= 2
&& projected.includes('heartbeat')
&& service.snapshot().source.state === 'live', 2_000);
assert.equal(projected[0], '1');
assert.ok(projected.filter((value) => value === '1').length >= 2);
assert.ok(projected.includes('heartbeat'));
assert.equal(factoryCalls, 2);
assert.equal(service.snapshot().source.error, null);
assert.equal(service.snapshot().connections[0].route.kind, 'vpn');
await service.stop();
});
+59
View File
@@ -11,6 +11,35 @@ import { normalizeSubscriptionConfig } from '../../dist/server/subscription.js';
const root = path.resolve(import.meta.dirname, '../..');
test('startup recovery rejects a materialized native API secret as shared config truth', async (t) => {
const server = {
id: 'server-a',
label: 'Server A',
host: 'a.example',
port: 443,
protocol: 'vless',
};
const fixture = await startClientFixture(t, {
state: profileState(server),
config: {
...generatedConfig(server),
services: [{
type: 'api',
listen: '127.0.0.1',
listen_port: 19091,
dashboard: false,
secret: 'a'.repeat(64),
}],
},
trafficSource: 'native',
});
assert.equal(fixture.state.connection.desired, 'stopped');
assert.equal(fixture.state.connection.process, 'stopped');
assert.equal(fs.existsSync(fixture.markerPath), false);
assert.equal(fs.existsSync(path.join(fixture.directory, 'sing-box-config.json')), false);
});
function listen(server, ...args) {
return new Promise((resolve, reject) => {
server.once('error', reject);
@@ -121,6 +150,7 @@ async function startClientFixture(t, {
config,
hostNetwork,
gatewayPresencePort,
trafficSource,
}) {
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'harbor-startup-recovery-'));
const { binDirectory, markerPath } = fakeSingbox(directory);
@@ -148,6 +178,7 @@ async function startClientFixture(t, {
: hostNetworkPath,
...(gatewayPresencePort ? { HARBOR_GATEWAY_CONTROL_PORT: String(gatewayPresencePort) } : {}),
HARBOR_TEST_RUN_MARKER: markerPath,
...(trafficSource ? { SING_BOX_TRAFFIC_SOURCE: trafficSource } : {}),
},
stdio: ['ignore', 'ignore', 'pipe'],
});
@@ -363,6 +394,34 @@ test('boot rejects an existing config owned by a different applied target', asyn
assert.equal(fs.existsSync(path.join(fixture.directory, 'sing-box-config.json')), false);
});
test('disabled traffic source rejects an existing config with any API service', async (t) => {
const server = {
id: 'server-a',
label: 'Server A',
host: 'a.example',
port: 443,
protocol: 'vless',
};
const fixture = await startClientFixture(t, {
state: profileState(server),
config: {
...generatedConfig(server),
services: [{
type: 'api',
listen: '0.0.0.0',
listen_port: 19091,
dashboard: false,
}],
},
trafficSource: 'disabled',
});
assert.equal(fixture.state.connection.desired, 'stopped');
assert.equal(fixture.state.connection.process, 'stopped');
assert.equal(fs.existsSync(fixture.markerPath), false);
assert.equal(fs.existsSync(path.join(fixture.directory, 'sing-box-config.json')), false);
});
test('stopped Gateway boot explicitly stops an already running remote dataplane', async (t) => {
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'harbor-stopped-remote-'));
const socketPath = path.join(directory, 'dataplane.sock');
+1
View File
@@ -131,6 +131,7 @@ test('typed endpoint facade preserves exact request contracts and raw payload id
expectedRevision: 10,
}),
}],
[() => api.traffic.live(), '/api/traffic/live', {}],
[() => api.singbox.stop(), '/api/singbox/stop', { method: 'POST' }],
[() => api.singbox.restart(), '/api/singbox/restart', { method: 'POST' }],
[() => api.servers.ping('profile-1', ['one', 'two']), '/api/profiles/profile-1/servers/ping', {
+348
View File
@@ -0,0 +1,348 @@
import assert from 'node:assert/strict';
import fs from 'node:fs';
import path from 'node:path';
import test from 'node:test';
import { assertLiveTrafficSnapshot } from '../../.test-dist/src/shared/liveTraffic.js';
import {
groupTrafficConnections,
reconcileTrafficGroups,
trafficGroupMatches,
} from '../../.test-dist/src/web/features/traffic/trafficRows.js';
const root = path.resolve(import.meta.dirname, '../..');
const source = (file) => fs.readFileSync(path.join(root, file), 'utf8');
const app = source('src/web/App.tsx');
const api = source('src/web/api/harborClient.ts');
const page = source('src/web/components/ClientOverviewPage.tsx');
const feature = source('src/web/features/traffic/TrafficFeature.tsx');
const rowModel = source('src/web/features/traffic/trafficRows.ts');
const boundary = source('src/web/features/traffic/index.ts');
const styles = source('src/web/styles/features/traffic.css');
const primitives = source('src/web/styles/primitives.css');
const validSnapshot = {
apiVersion: 1,
epoch: 'epoch-1',
sequence: 1,
observedAt: '2026-08-31T10:00:00.000Z',
capabilities: { lifecycle: true, deviceAttribution: false, applicationAttribution: false },
source: {
transport: 'native',
state: 'live',
completeness: 'lifecycle',
singBoxVersion: '1.14.0-rc.5',
singBoxApiVersion: 1,
error: null,
unattributedUploadBytes: '0',
unattributedDownloadBytes: '0',
},
summary: {
active: 1,
recent: 0,
visible: 1,
recognized: 1,
unresolved: 0,
unresolvedOrigin: 0,
truncated: false,
},
connections: [{
id: 'connection-1',
startedAt: '2026-08-31T09:59:59.000Z',
closedAt: null,
inbound: { tag: 'mixed-in', type: 'mixed' },
network: 'tcp',
protocol: 'tls',
source: { ip: '127.0.0.1', port: 53000 },
destination: { domain: 'example.com', ip: '203.0.113.1', port: 443, provenance: 'sing-box' },
origin: { kind: 'this-mac', id: null, label: 'Этот Mac', provenance: 'client-runtime' },
route: {
kind: 'vpn',
scope: 'local-sing-box',
outbound: 'proxy',
outboundType: 'selector',
chain: ['proxy'],
rule: 'default',
},
traffic: {
uploadBytes: '10',
downloadBytes: '20',
uploadBytesPerSecond: '1',
downloadBytesPerSecond: '2',
},
}],
};
function trafficConnection(id, overrides = {}) {
const base = validSnapshot.connections[0];
return {
...base,
...overrides,
id,
inbound: { ...base.inbound, ...overrides.inbound },
source: { ...base.source, ...overrides.source },
destination: { ...base.destination, ...overrides.destination },
origin: { ...base.origin, ...overrides.origin },
route: { ...base.route, ...overrides.route },
traffic: { ...base.traffic, ...overrides.traffic },
};
}
test('live traffic runtime boundary accepts the DTO and rejects inconsistent or malformed snapshots', () => {
assert.equal(assertLiveTrafficSnapshot(validSnapshot), validSnapshot);
assert.throws(() => assertLiveTrafficSnapshot({
...validSnapshot,
summary: { ...validSnapshot.summary, visible: 0 },
}), /Inconsistent traffic summary/);
assert.throws(() => assertLiveTrafficSnapshot({
...validSnapshot,
connections: [{
...validSnapshot.connections[0],
route: { ...validSnapshot.connections[0].route, outbound: 42 },
}],
}), /Invalid traffic connection/);
assert.throws(() => assertLiveTrafficSnapshot({
...validSnapshot,
summary: { ...validSnapshot.summary, active: 0, recent: 1, recognized: 0 },
connections: [{
...validSnapshot.connections[0],
closedAt: '2026-08-31T10:00:00.000Z',
}],
}), /Invalid closed traffic rate/);
assert.throws(() => assertLiveTrafficSnapshot({
...validSnapshot,
summary: { ...validSnapshot.summary, active: 2, visible: 2, recognized: 2 },
connections: [validSnapshot.connections[0], validSnapshot.connections[0]],
}), /Invalid traffic connection identity/);
assert.throws(() => assertLiveTrafficSnapshot({
...validSnapshot,
connections: [{ ...validSnapshot.connections[0], closedAt: '2026-08-31 10:00:00Z' }],
}), /Invalid traffic connection identity/);
assert.throws(() => assertLiveTrafficSnapshot({
...validSnapshot,
connections: [{
...validSnapshot.connections[0],
traffic: { ...validSnapshot.connections[0].traffic, uploadBytes: 10 },
}],
}), /Invalid traffic byte value/);
});
test('Mac and Gateway traffic drawers use one feature boundary and the cached read-only endpoint', () => {
assert.match(boundary, /TrafficPanel,[\s\S]*TrafficToggle,[\s\S]*useTrafficFeature/);
assert.match(api, /traffic: \{[\s\S]*live: \(\) => request\('\/api\/traffic\/live'\)/);
assert.match(app, /loadLiveTraffic: api\.traffic\.live/);
assert.match(page, /useTrafficFeature\(\{[\s\S]*enabled: true,[\s\S]*isGateway,[\s\S]*loadLiveTraffic: actions\.loadLiveTraffic/);
assert.match(feature, /interface TrafficFeatureOptions \{[\s\S]*isGateway: boolean/);
assert.match(feature, /return \{[\s\S]*isGateway,[\s\S]*isOpen/);
const rail = page.slice(page.indexOf('<nav'), page.indexOf('</nav>'));
const devices = rail.indexOf('<DevicesToggle');
const traffic = rail.indexOf('<TrafficToggle');
const diagnostics = rail.indexOf('<DiagnosticsToggle');
assert.ok(devices >= 0 && traffic > devices && diagnostics > traffic);
assert.equal((rail.match(/<TrafficToggle/g) || []).length, 1);
assert.doesNotMatch(rail, /!isGateway\s*&&\s*<TrafficToggle/);
assert.match(page, /\{hasSubscription && <TrafficPanel feature=\{trafficFeature\} \/>}/);
assert.doesNotMatch(page, /!isGateway && hasSubscription && <TrafficPanel/);
assert.doesNotMatch(page, /if \(isGateway\) trafficFeature\.close\(\)/);
assert.match(page, /DRAWER_ORDER = \['subscription', 'failover', 'instructions', 'devices', 'traffic', 'diagnostics', 'routing', 'journal'\]/);
assert.doesNotMatch(feature, /from ['"][^'"]*\/api(?:\/|\.js)/);
});
test('traffic polling runs every second only while the drawer is open and unpaused', () => {
assert.match(feature, /const POLL_MS = 1_000/);
assert.match(feature, /if \(!enabled \|\| !isOpen \|\| paused\) return undefined/);
assert.match(feature, /assertLiveTrafficSnapshot\(await loadLiveTraffic\(\)\)/);
assert.match(feature, /setSnapshot\(next\)[\s\S]*setRequestState\('ready'\)/);
assert.match(feature, /catch \{[\s\S]*setRequestState\('error'\)/);
assert.match(feature, /timer = setTimeout\(poll, POLL_MS\)/);
assert.match(feature, /cancelled = true[\s\S]*clearTimeout\(timer\)/);
assert.match(feature, /aria-pressed=\{feature\.paused\}[\s\S]*Продолжить[\s\S]*Пауза/);
assert.doesNotMatch(feature, /setInterval|WebSocket|EventSource/);
});
test('traffic drawer exposes the requested truthful states and accessible controls', () => {
for (const copy of [
'VPN остановлен',
'Активных соединений пока нет',
'Инспектор трафика временно недоступен',
'Эта версия sing-box не поддерживает инспектор трафика',
'Инспектор трафика выключен в настройках Harbor Connect.',
'Инспектор трафика выключен в настройках Harbor Gateway.',
'Показан последний полученный снимок',
'Только трафик через Harbor Connect. Приложения macOS недоступны внутри Docker.',
'Только соединения, прошедшие через sing-box Gateway. Трафик, обходящий sing-box напрямую, здесь не виден.',
]) assert.match(feature, new RegExp(copy.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')));
assert.match(feature, /\{feature\.isGateway \? 'GATEWAY' : 'MAC'\} · \{snapshot\?\.summary\.active \|\| 0\} АКТИВНЫХ/);
assert.match(feature, /feature\.isGateway[\s\S]*Инспектор трафика выключен в настройках Harbor Gateway\.[\s\S]*Инспектор трафика выключен в настройках Harbor Connect\./);
assert.match(feature, /feature\.isGateway[\s\S]*Только соединения, прошедшие через sing-box Gateway\. Трафик, обходящий sing-box напрямую, здесь не виден\.[\s\S]*Только трафик через Harbor Connect\. Приложения macOS недоступны внутри Docker\./);
assert.match(feature, /type="search"[\s\S]*aria-label="Найти домен, сервис или IP"/);
assert.match(feature, /role="group" aria-label="Фильтр по маршруту"/);
assert.match(feature, /role="group" aria-label="Фильтр по качеству распознавания"/);
assert.match(feature, /aria-pressed=\{routeFilter === value\}/);
assert.match(feature, /aria-pressed=\{qualityFilter === value\}/);
assert.match(feature, /const \[expandedId, setExpandedId\] = useState\(''\)/);
assert.match(feature, /aria-expanded=\{expanded\}[\s\S]*aria-controls=\{detailsId\}/);
assert.match(feature, /Источник[\s\S]*Назначение[\s\S]*Правило[\s\S]*Цепочка/);
assert.doesNotMatch(feature, /closeConnection|reroute|history|sessionStorage/);
});
test('traffic retention is local, bounded to approved choices and uses the frozen server observation clock', () => {
assert.match(feature, /const RETENTION_STORAGE_KEY = 'harbor:traffic-retention-seconds'/);
assert.match(feature, /const RETENTION_OPTIONS = \[5, 10, 30\] as const/);
assert.match(feature, /Number\(localStorage\.getItem\(RETENTION_STORAGE_KEY\)\)/);
assert.match(feature, /RETENTION_OPTIONS\.includes\(value as RetentionSeconds\)[\s\S]*: 10/);
assert.match(feature, /localStorage\.setItem\(RETENTION_STORAGE_KEY, String\(seconds\)\)/);
assert.match(feature, /Показывать завершённые/);
assert.match(feature, /aria-label="Время показа завершённых соединений"/);
assert.match(feature, /snapshot\?\.observedAt \? Date\.parse\(snapshot\.observedAt\) : Number\.NaN/);
assert.match(feature, /connection\.closedAt === null \|\| !Number\.isFinite\(snapshotTime\)/);
assert.match(feature, /snapshotTime - Date\.parse\(connection\.closedAt\) < retentionSeconds \* 1_000/);
assert.match(feature, /groupTrafficConnections\(retainedConnections\)/);
assert.match(feature, /trafficGroups\.filter\([\s\S]*trafficGroupMatches/);
assert.match(feature, /group\.connections\.length === 1[\s\S]*`Завершено · \$\{group\.protocol\}`/);
assert.match(feature, /Соединения сгруппированы по назначению, протоколу и маршруту\./);
});
test('traffic groups combine compatible UUIDs with exact byte sums and whole-group search', () => {
const groups = groupTrafficConnections([
trafficConnection('active', {
destination: { domain: 'yandex.ru', ip: '203.0.113.1' },
traffic: {
uploadBytes: '10',
downloadBytes: '20',
uploadBytesPerSecond: '1',
downloadBytesPerSecond: '2',
},
}),
trafficConnection('closed-a', {
closedAt: '2026-08-31T09:59:59.500Z',
destination: { domain: 'yandex.ru', ip: '203.0.113.2' },
traffic: {
uploadBytes: '30',
downloadBytes: '40',
uploadBytesPerSecond: '0',
downloadBytesPerSecond: '0',
},
}),
trafficConnection('closed-b', {
closedAt: '2026-08-31T09:59:59.700Z',
destination: { domain: 'yandex.ru', ip: '203.0.113.2' },
traffic: {
uploadBytes: '9007199254740993',
downloadBytes: '9007199254740995',
uploadBytesPerSecond: '0',
downloadBytesPerSecond: '0',
},
}),
]);
assert.equal(groups.length, 1);
assert.equal(groups[0].label, 'yandex.ru');
assert.equal(groups[0].activeCount, 1);
assert.equal(groups[0].recentCount, 2);
assert.deepEqual(groups[0].connections.map(({ id }) => id), ['active', 'closed-a', 'closed-b']);
assert.deepEqual(groups[0].destinationIps, ['203.0.113.1', '203.0.113.2']);
assert.deepEqual(groups[0].traffic, {
uploadBytes: '9007199254741033',
downloadBytes: '9007199254741055',
uploadBytesPerSecond: '1',
downloadBytesPerSecond: '2',
});
assert.equal(trafficGroupMatches(groups[0], '203.0.113.2', 'all', 'all'), true);
assert.equal(trafficGroupMatches(groups[0], 'missing.test', 'all', 'all'), false);
assert.equal(trafficGroupMatches(groups[0], '', 'vpn', 'recognized'), true);
assert.equal(trafficGroupMatches(groups[0], '', 'direct', 'all'), false);
});
test('traffic grouping keeps incompatible and unknown destinations separate', () => {
const base = trafficConnection('base', { destination: { domain: 'example.com', ip: '203.0.113.1' } });
const same = trafficConnection('same', { destination: { domain: 'Example.COM', ip: '203.0.113.2' } });
assert.equal(groupTrafficConnections([base, same]).length, 1);
const variants = [
['domain', { destination: { domain: 'www.example.com', ip: '203.0.113.1' } }],
['port', { destination: { domain: 'example.com', ip: '203.0.113.1', port: 80 } }],
['network', { network: 'udp' }],
['protocol', { protocol: 'http' }],
['origin', { origin: { kind: 'device', id: 'device-1', label: 'iPhone', provenance: 'source-ip' } }],
['route kind', { route: { kind: 'other' } }],
['route outbound', { route: { outbound: 'other-proxy' } }],
['route outbound type', { route: { outboundType: 'vless' } }],
['route chain', { route: { chain: ['proxy', 'hop'] } }],
['route rule', { route: { rule: 'other-rule' } }],
];
for (const [name, overrides] of variants) {
assert.equal(groupTrafficConnections([base, trafficConnection(String(name), overrides)]).length, 2, name);
}
assert.equal(groupTrafficConnections([
trafficConnection('ip-a', { destination: { domain: null, ip: '203.0.113.1' } }),
trafficConnection('ip-b', { destination: { domain: null, ip: '203.0.113.2' } }),
]).length, 2);
assert.equal(groupTrafficConnections([
trafficConnection('unknown-a', { destination: { domain: null, ip: null, provenance: 'unknown' } }),
trafficConnection('unknown-b', { destination: { domain: null, ip: null, provenance: 'unknown' } }),
]).length, 2);
const deviceOrigin = { kind: 'device', id: 'device-1', label: 'iPhone', provenance: 'source-ip' };
const [deviceGroup] = groupTrafficConnections([
trafficConnection('device', { origin: deviceOrigin }),
]);
assert.deepEqual(deviceGroup.origin, deviceOrigin);
assert.match(feature, /const source = onlyConnection[\s\S]*group\.origin\.label[\s\S]*onlyConnection\.source\.ip[\s\S]*group\.connections\.length/);
});
test('traffic groups stay mounted and inert through exit while the same group cancels removal', () => {
const group = (id, count = 1) => ({ id, connections: Array.from({ length: count }) });
let rows = reconcileTrafficGroups([], [group('a'), group('b'), group('c')], false);
rows = reconcileTrafficGroups(rows, [group('b', 2)], false);
assert.deepEqual(rows.map((row) => [row.group.id, row.group.connections.length, row.exiting]), [
['a', 1, true],
['b', 2, false],
['c', 1, true],
]);
rows = reconcileTrafficGroups(rows, [group('a'), group('b', 3), group('c')], false);
assert.deepEqual(rows.map((row) => [row.group.id, row.group.connections.length, row.exiting]), [
['a', 1, false],
['b', 3, false],
['c', 1, false],
]);
assert.deepEqual(reconcileTrafficGroups(rows, [], true), []);
assert.match(rowModel, /const desiredIds = new Set\(desired\.map\(\(group\) => group\.id\)\)/);
assert.match(feature, /groupTrafficConnections\(retainedConnections\)/);
assert.match(feature, /trafficGroupMatches\(group, query, routeFilter, qualityFilter\)/);
assert.match(feature, /reconcileTrafficGroups\(current, groups, immediate\)/);
assert.match(feature, /setExpandedId\(\(current\) => desiredIds\.has\(current\) \? current : ''\)/);
assert.match(feature, /inert=\{exiting \|\| undefined\}/);
assert.match(feature, /event\.target === event\.currentTarget[\s\S]*event\.animationName === 'client-traffic-connection-out'/);
assert.match(feature, /row\.group\.id !== id \|\| !row\.exiting/);
assert.match(feature, /matchMedia\('\(prefers-reduced-motion: reduce\)'\)[\s\S]*media\.addEventListener\('change', update\)[\s\S]*media\.removeEventListener\('change', update\)/);
assert.match(feature, /const immediate = reducedMotion[\s\S]*\['disabled', 'incompatible', 'stopped'\]\.includes/);
assert.match(feature, /aria-label="Группы активных и недавно завершённых соединений"/);
assert.match(feature, /group\.connections\.length > 1[\s\S]*×\$\{group\.connections\.length\}/);
assert.match(feature, /group\.activeCount > 0 && <strong>/);
assert.match(feature, /displayedGroups\.map\(\(row\) => <TrafficGroupRow[\s\S]*key=\{row\.group\.id\}/);
assert.match(feature, /<b>Активных распознано<\/b>/);
assert.match(feature, /<b>Активных требует внимания<\/b>/);
});
test('traffic styling preserves the shared drawer geometry and minimal motion', () => {
assert.match(styles, /\.client-traffic-toggle svg \{[\s\S]*width: 24px;[\s\S]*height: 24px/);
assert.match(primitives, /\.client-drawer \{[\s\S]*width: min\(580px, 100vw\)/);
assert.match(styles, /@media \(max-width: 768px\) \{[\s\S]*\.client-traffic \{[\s\S]*width: 100vw/);
assert.doesNotMatch(styles, /overflow-y:\s*(?:auto|scroll)/);
assert.match(styles, /\.client-traffic-meta button \{[\s\S]*width: 96px/);
assert.match(styles, /\.client-traffic-details \{[\s\S]*animation: client-traffic-details-in 180ms/);
assert.match(styles, /\.client-traffic-connection \{[\s\S]*animation: client-traffic-connection-in 420ms/);
assert.match(styles, /\.client-traffic-connection\.is-exiting \{[\s\S]*animation: client-traffic-connection-out 240ms/);
const detailMotion = /@keyframes client-traffic-details-in \{([\s\S]*?)\n\}/.exec(styles)?.[1] || '';
assert.match(detailMotion, /opacity:/);
assert.match(detailMotion, /translateY/);
assert.doesNotMatch(detailMotion, /height|width|margin|padding|scale|filter/);
for (const name of ['client-traffic-connection-in', 'client-traffic-connection-out']) {
const rowMotion = new RegExp(`@keyframes ${name} \\{([\\s\\S]*?)\\n\\}`).exec(styles)?.[1] || '';
assert.match(rowMotion, /opacity:/);
assert.match(rowMotion, /translateY/);
assert.doesNotMatch(rowMotion, /height|width|margin|padding|scale|filter/);
}
assert.match(styles, /@media \(prefers-reduced-motion: reduce\) \{[\s\S]*\.client-traffic-connection,[\s\S]*\.client-traffic-details \{[\s\S]*animation: none/);
});
+3 -3
View File
@@ -128,7 +128,7 @@ test('secondary menus share one right rail and switch equal drawers as a vertica
assert.match(component, /<SubscriptionToggle[\s\S]*<InstructionsToggle[\s\S]*<DevicesToggle/);
assert.match(subscription, /controls="client-subscription-drawer"/);
assert.match(component, /<InstructionsToggle[\s\S]*<RoutingToggle/);
assert.match(component, /<InstructionsToggle[\s\S]*<DevicesToggle[\s\S]*<DiagnosticsToggle[\s\S]*<RoutingToggle/);
assert.match(component, /<InstructionsToggle[\s\S]*<DevicesToggle[\s\S]*<TrafficToggle[\s\S]*<DiagnosticsToggle[\s\S]*<RoutingToggle/);
assert.match(diagnosticsFeature, /client-diagnostics-toggle/);
assert.match(component, /<ConnectivityDiagnosticsPanel/);
assert.doesNotMatch(component, /diagnosticsToggleRef|diagnosticsPanelRef|diagnosticsCloseRef/);
@@ -142,7 +142,7 @@ test('secondary menus share one right rail and switch equal drawers as a vertica
assert.match(styles, /\.client-local-rules-toggle:disabled:hover span\s*\{[\s\S]*opacity:\s*1/);
assert.match(subscription, /client-rail-subscription-back[\s\S]*client-rail-subscription-front[\s\S]*client-rail-subscription-lines/);
assert.match(instructions, /client-rail-book-page-position is-left[\s\S]*client-rail-book-page is-left[\s\S]*client-rail-book-page-position is-right[\s\S]*client-rail-book-page is-right[\s\S]*client-rail-book-spine/);
assert.match(component, /<InstructionsToggle[\s\S]*<DevicesToggle[\s\S]*<DiagnosticsToggle[\s\S]*<RoutingToggle/);
assert.match(component, /<InstructionsToggle[\s\S]*<DevicesToggle[\s\S]*<TrafficToggle[\s\S]*<DiagnosticsToggle[\s\S]*<RoutingToggle/);
assert.match(diagnosticsFeature, /client-rail-diagnostics-trace[\s\S]*client-rail-diagnostics-position[\s\S]*client-rail-diagnostics-glass/);
assert.match(devices, /client-rail-device-monitor[\s\S]*M5 19h5\.5M7 15v4[\s\S]*client-rail-device-phone[\s\S]*client-rail-device-link[\s\S]*M19 16v3h-5\.5/);
assert.match(routing, /client-rail-rule-track[\s\S]*client-rail-rule-knob-position is-top[\s\S]*client-rail-rule-knob is-top[\s\S]*client-rail-rule-knob-position is-bottom[\s\S]*client-rail-rule-knob is-bottom/);
@@ -167,7 +167,7 @@ test('secondary menus share one right rail and switch equal drawers as a vertica
assert.match(instructions, /<Drawer[\s\S]*className="client-instructions"/);
assert.match(routing, /<Drawer[\s\S]*className="client-local-rules"/);
assert.match(subscription, /<Drawer[\s\S]*className="client-subscription-drawer"/);
assert.match(component, /const DRAWER_ORDER = \['subscription', 'failover', 'instructions', 'devices', 'diagnostics', 'routing', 'journal'\]/);
assert.match(component, /const DRAWER_ORDER = \['subscription', 'failover', 'instructions', 'devices', 'traffic', 'diagnostics', 'routing', 'journal'\]/);
assert.match(component, /function switchDrawer\(target: DrawerKey\)[\s\S]*from\.inert = true[\s\S]*translateY\(\$\{direction \* 100\}%\)[\s\S]*translateY\(\$\{-direction \* 100\}%\)/);
assert.match(component, /const \[drawerSwitchTarget, setDrawerSwitchTarget\] = useState<DrawerKey \| null>\(null\)/);
assert.match(component, /const activeRailDrawer = drawerSwitchTarget && drawerControls\[drawerSwitchTarget\]\.isOpen[\s\S]*drawerControls\[drawer\]\.isOpen/);
+19 -18
View File
@@ -33,36 +33,37 @@ const expectedImports = [
'./features/diagnostics.css',
'./features/failover.css',
'./features/activity-journal.css',
'./features/traffic.css',
'./layout.css',
'./themes.css',
];
const sha256 = (value) => crypto.createHash('sha256').update(value).digest('hex');
const acceptedLedger = {
counts: {
cascadeEdges: 1120,
cascadeEdges: 1126,
customProperties: 115,
declarations: 4455,
declarations: 4676,
important: 0,
keyframes: 52,
media: 19,
rules: 1193,
variableReferences: 1138,
keyframes: 55,
media: 22,
rules: 1262,
variableReferences: 1214,
},
hashes: {
cascadeEdges: 'd8eeaf20637638f97dd826ae7486469367fb7f5c7e2d32a6fb1d64647e8b52e0',
cascadeEdges: '1f3c75839bd37bb312b9aed2987ed61571e5148f8b23c8288424cb193c7a8dda',
customProperties: '81d1e70737ae5a8e4b20d4c1be1b24685975a404863444da4a8821be1f0d5097',
declarations: '89b645d94c44f0dcfea1a7c5f653a8124f0c87057a9898437419ddce813271d2',
declarations: '616fc7cb72a9db509f6b41e1808118c2e2be7da6ce4c078dfa2eb4a963f0dbb1',
duplicateKeyframes: '4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945',
duplicateSelectors: '8982145dba05b33bf1c93b2d54cfbe76305b276ff3c657aa020144016ada5848',
keyframes: '405688c9a452aa9d54e9c50dd30abb13143fdb5910f63f4474ecefcc800311e6',
ruleDeclarationSequences: '12a5a2deec8a8521f49551d9a29c1950a01bce225f1b387def47304fcfe1c960',
selectors: 'fa7cc612da0fc1e3804a884032e9ab99e0bb0776b29a9e40d44a2891de198067',
variableReferences: '1cbaf891cd2df7c91003d8879a205075211678ad40a817b746c4dde54830ad6d',
witnesses: 'bae0329346060aeeef91dee449ccee7187e68501c0b5b082026312e7c21a4798',
keyframes: 'b9b0bf3fad92e69b93ec0c24f01da15e78bbe89a095597d64ce4f7ef5e272fdd',
ruleDeclarationSequences: '3a65866ea25506c6fd79b68bfbbdeddd495f6957dbd99dec6a814b816ba4cca2',
selectors: '56e1c67b35230649a3c69b2aea67c1929580700f1b4357ee05dfb11012d72655',
variableReferences: '71722a81fa16b3586ae9ee5890f79176729dfdf7fe58c4d4994755f8059aa034',
witnesses: '2272ed9b07e02edd232c33a971c4cc5171f6a612ccaa36418933f0fd6bb465c1',
},
};
test('public stylesheet exposes exactly fourteen flat semantic owners', () => {
test('public stylesheet exposes exactly fifteen flat semantic owners', () => {
const imports = Array.from(index.matchAll(/^@import ['"](\.\/[^'"]+\.css)['"];$/gm), ([, file]) => file);
assert.deepEqual(imports, expectedImports);
assert.equal(index, `${expectedImports.map((file) => `@import '${file}';`).join('\n')}\n`);
@@ -211,7 +212,7 @@ test('client typography uses the shared semantic scale outside the token owner',
test('accepted stylesheet has pinned declaration, selector, keyframe, variable, and cascade ledgers', () => {
const witnesses = readStyleWitnesses(root);
assert.equal(witnesses.length, 1198);
assert.equal(witnesses.length, 1292);
assert.equal(witnesses.filter((witness) => witness.unknown || witness.ancestorUnknown).length, 0);
const ledger = createStyleLedger(readStyleSource(root), { witnesses });
assert.deepEqual(ledger.counts, acceptedLedger.counts);
@@ -408,8 +409,8 @@ test('main owns one public stylesheet and the regrouped production CSS is determ
assert.equal((main.match(/import ['"][^'"]+\.css['"]/g) || []).length, 1);
const assets = fs.readdirSync(path.join(root, 'dist/assets')).filter((file) => file.endsWith('.css'));
assert.deepEqual(assets, ['index-C2yOT86L.css']);
assert.deepEqual(assets, ['index-tJmxnB8a.css']);
const built = fs.readFileSync(path.join(root, 'dist/assets', assets[0]));
assert.equal(built.byteLength, 169625);
assert.equal(sha256(built), 'a9bb2c83ab62798e762f4338be1869a6d29366903f83f90d4abcaa2855e44d8e');
assert.equal(built.byteLength, 176543);
assert.equal(sha256(built), '38a283204c656164c38c17aa03f41c931544e05b5732248807d911b2645bf970');
});
+2 -2
View File
@@ -34,7 +34,7 @@ test('all repeated client primitive consumers use the shared owners', () => {
assert.equal((production.match(/<Tooltip\b/g) || []).length, 16);
assert.equal((production.match(/<CopyButton\b/g) || []).length, 2);
assert.equal((production.match(/<RailAction\b/g) || []).length, 7);
assert.equal((production.match(/<Drawer\b/g) || []).length, 7);
assert.equal((production.match(/<RailAction\b/g) || []).length, 8);
assert.equal((production.match(/<Drawer\b/g) || []).length, 8);
assert.doesNotMatch(production, /className="client-tooltip"|className="client-copy-label"|className="client-drawer-close"/);
});
+123
View File
@@ -0,0 +1,123 @@
#!/usr/bin/env bash
set -euo pipefail
readonly RC_VERSION='1.14.0-rc.5'
readonly IMAGE="harbor-singbox-client-rc-test:${RC_VERSION}"
FIXTURES="$(mktemp -d)"
SUFFIX="${FIXTURES##*/}"
readonly SUFFIX="${SUFFIX//[^[:alnum:]]/}"
readonly NETWORK="harbor-singbox-rc-${SUFFIX}"
readonly TARGET="harbor-singbox-rc-target-${SUFFIX}"
readonly PROXY="harbor-singbox-rc-proxy-${SUFFIX}"
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
NETWORK_CREATED=false
TARGET_CREATED=false
PROXY_CREATED=false
cleanup() {
[[ "$PROXY_CREATED" == true ]] && docker rm -f "$PROXY" >/dev/null 2>&1 || true
[[ "$TARGET_CREATED" == true ]] && docker rm -f "$TARGET" >/dev/null 2>&1 || true
[[ "$NETWORK_CREATED" == true ]] && docker network rm "$NETWORK" >/dev/null 2>&1 || true
docker image rm "$IMAGE" >/dev/null 2>&1 || true
rm -rf "$FIXTURES"
}
trap cleanup EXIT
check_config() {
local name="$1"
local output
local unexpected
if ! output="$(docker run --rm \
-v "$FIXTURES:/fixtures:ro" \
--entrypoint sing-box \
"$IMAGE" check -c "/fixtures/${name}.json" 2>&1)"; then
printf '%s\n' "$output" >&2
return 1
fi
unexpected="$(printf '%s\n' "$output" \
| grep -Ei 'warn|deprecated' \
| grep -Evi 'independent_cache.*DNS option is deprecated' || true)"
if [[ -n "$unexpected" ]]; then
printf 'unexpected warning for %s:\n%s\n' "$name" "$unexpected" >&2
return 1
fi
printf 'PASS config %s\n' "$name"
}
docker build \
--build-arg "SINGBOX_VERSION=${RC_VERSION}" \
-t "$IMAGE" \
-f "$ROOT/Dockerfile.client" \
"$ROOT"
version_output="$(docker run --rm --entrypoint sing-box "$IMAGE" version)"
printf '%s\n' "$version_output"
grep -Fxq "sing-box version ${RC_VERSION}" <<< "$version_output"
cat > "$FIXTURES/generate-configs.mjs" <<'EOF'
import fs from 'node:fs';
import { parseSubscriptionBody } from '/app/dist/server/subscription.js';
import { buildGatewayConfig } from '/app/dist/server/singbox.js';
const reality = 'vless://00000000-0000-4000-8000-000000000001@reality.example.test:443?security=reality&type=tcp&pbk=AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA&sid=0123456789abcdef&sni=cover.example.test&fp=chrome#Reality';
const websocket = 'vless://00000000-0000-4000-8000-000000000002@ws.example.test:8443?encryption=none&security=tls&sni=edge.example.test&fp=firefox&alpn=h2%2Chttp%2F1.1&type=ws&host=edge.example.test&path=%2Fsocket%3Fed%3D2048#TLS%20WS';
function generated(link, clientDirect = false) {
const parsed = parseSubscriptionBody(Buffer.from(link).toString('base64'));
return buildGatewayConfig(parsed.config, parsed.servers[0].id, { clientDirect });
}
for (const [name, config] of [
['local-vpn', generated(websocket)],
['gateway-direct', generated(websocket, true)],
['vless-reality', generated(reality)],
['vless-tls-websocket', generated(websocket)],
]) {
fs.writeFileSync(`/fixtures/${name}.json`, JSON.stringify(config));
}
EOF
docker run --rm \
-e APP_MODE=client \
-e PROXY_PORT=18081 \
-e DIAGNOSTICS_PROXY_PORT=18082 \
-e DATA_DIR=/tmp/harbor-rc \
-e SING_BOX_CACHE=/tmp/harbor-rc-cache.db \
-v "$FIXTURES:/fixtures" \
--entrypoint node \
"$IMAGE" /fixtures/generate-configs.mjs
check_config local-vpn
check_config gateway-direct
check_config vless-reality
check_config vless-tls-websocket
docker network create "$NETWORK" >/dev/null
NETWORK_CREATED=true
docker create --name "$TARGET" --network "$NETWORK" --entrypoint node "$IMAGE" \
-e 'require("node:http").createServer((request,response)=>response.end(request.url)).listen(18080,"0.0.0.0")' >/dev/null
TARGET_CREATED=true
docker start "$TARGET" >/dev/null
docker create --name "$PROXY" --network "$NETWORK" \
-v "$FIXTURES:/fixtures:ro" --entrypoint sing-box "$IMAGE" run -c /fixtures/gateway-direct.json >/dev/null
PROXY_CREATED=true
docker start "$PROXY" >/dev/null
for _ in {1..30}; do
if docker run --rm --network "$NETWORK" --entrypoint curl "$IMAGE" \
--noproxy '' -fsS -x "http://${PROXY}:18081" "http://${TARGET}:18080/http" | grep -Fxq '/http'; then
break
fi
sleep 0.2
done
http_body="$(docker run --rm --network "$NETWORK" --entrypoint curl "$IMAGE" \
--noproxy '' -fsS -x "http://${PROXY}:18081" "http://${TARGET}:18080/http")"
socks_body="$(docker run --rm --network "$NETWORK" --entrypoint curl "$IMAGE" \
--noproxy '' -fsS --socks5-hostname "${PROXY}:18081" "http://${TARGET}:18080/socks")"
[[ "$http_body" == '/http' ]]
[[ "$socks_body" == '/socks' ]]
printf 'PASS mixed inbound HTTP\nPASS mixed inbound SOCKS5\n'
+132
View File
@@ -0,0 +1,132 @@
#!/usr/bin/env bash
set -euo pipefail
readonly RC_VERSION='1.14.0-rc.5'
readonly ROLLBACK_VERSION='1.13.18'
TEMP_DIR="$(mktemp -d)"
SUFFIX="${TEMP_DIR##*/}"
readonly SUFFIX="${SUFFIX//[^[:alnum:]]/}"
readonly IMAGE="harbor-gateway-native-test:${RC_VERSION}-${SUFFIX}"
readonly ROLLBACK_IMAGE="harbor-gateway-rollback-test:${ROLLBACK_VERSION}-${SUFFIX}"
readonly CONTAINER="harbor-gateway-native-${SUFFIX}"
cleanup() {
docker rm -f "$CONTAINER" >/dev/null 2>&1 || true
docker image rm "$IMAGE" "$ROLLBACK_IMAGE" >/dev/null 2>&1 || true
rm -rf "$TEMP_DIR"
}
trap cleanup EXIT
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
docker build --build-arg "SINGBOX_VERSION=${RC_VERSION}" -t "$IMAGE" -f "$ROOT/Dockerfile" "$ROOT"
docker build --build-arg "SINGBOX_VERSION=${ROLLBACK_VERSION}" -t "$ROLLBACK_IMAGE" -f "$ROOT/Dockerfile" "$ROOT"
docker run --rm \
-e APP_MODE=gateway \
-e APP_COMPONENT=control \
-e DATAPLANE_SOCKET=/tmp/dataplane.sock \
-e SING_BOX_TRAFFIC_SOURCE=snapshot \
--entrypoint node \
"$ROLLBACK_IMAGE" --input-type=module --eval '
import assert from "node:assert/strict";
import { spawnSync } from "node:child_process";
import fs from "node:fs";
import { buildGatewayConfig } from "/app/dist/server/singbox.js";
const subscription = { outbounds: [{ type: "vless", tag: "vpn", server: "vpn.example.test", server_port: 443, uuid: "00000000-0000-4000-8000-000000000000", tls: { enabled: true } }] };
const config = buildGatewayConfig(subscription, "vpn");
assert.equal(config.services, undefined);
assert.deepEqual(config.dns, { independent_cache: true });
fs.writeFileSync("/tmp/rollback.json", JSON.stringify(config));
const checked = spawnSync("sing-box", ["check", "-c", "/tmp/rollback.json"], { encoding: "utf8" });
assert.equal(checked.status, 0, checked.stderr || checked.stdout);
assert.match(spawnSync("sing-box", ["version"], { encoding: "utf8" }).stdout, /^sing-box version 1\.13\.18$/m);
console.log("PASS Gateway snapshot rollback 1.13.18");
'
docker create -i --name "$CONTAINER" \
-e APP_MODE=gateway \
-e APP_COMPONENT=dataplane \
-e DATAPLANE_SOCKET=/tmp/dataplane.sock \
-e SING_BOX_TRAFFIC_SOURCE=native \
-e DATA_DIR=/tmp/harbor-gateway-native \
-e SING_BOX_CACHE=/tmp/harbor-gateway-native/cache.db \
--entrypoint /bin/bash "$IMAGE" -s >/dev/null
docker start -a -i "$CONTAINER" <<'CONTAINER_SCRIPT'
set -euo pipefail
cd /app
node --input-type=module <<'NODE'
import assert from 'node:assert/strict';
import { spawn, spawnSync } from 'node:child_process';
import fs from 'node:fs';
import { once } from 'node:events';
import { createClient } from '@connectrpc/connect';
import { createGrpcTransport } from '@connectrpc/connect-node';
import { StartedService } from '/app/dist/server/generated/daemon/started_service_pb.js';
import { materializeGatewayNativeConfig } from '/app/dist/server/gatewayNativeRuntime.js';
import { buildDualChannelGatewayConfig, buildGatewayConfig } from '/app/dist/server/singbox.js';
const directory = '/tmp/harbor-gateway-native';
const secretPath = `${directory}/api.secret`;
const runtimePath = `${directory}/runtime-config.json`;
const subscription = { outbounds: [{
type: 'vless', tag: 'vpn', server: 'vpn.example.test', server_port: 443,
uuid: '00000000-0000-4000-8000-000000000000', tls: { enabled: true },
}] };
const single = buildGatewayConfig(subscription, 'vpn');
const dual = buildDualChannelGatewayConfig({
primary: { subscriptionConfig: subscription, selectedServerId: 'vpn' },
reserve: { subscriptionConfig: subscription, selectedServerId: 'vpn' },
});
let secret = '';
for (const config of [single, dual]) {
assert.equal(JSON.stringify(config).includes('secret'), false);
const materialized = materializeGatewayNativeConfig(config, {
apiPort: 19091, secretPath, runtimeConfigPath: runtimePath,
});
assert.equal(materialized.warning, null);
secret ||= materialized.secret;
assert.equal(materialized.secret, secret);
assert.equal(fs.statSync(secretPath).mode & 0o777, 0o600);
assert.equal(fs.statSync(runtimePath).mode & 0o777, 0o600);
const checked = spawnSync('sing-box', ['check', '-c', runtimePath], { encoding: 'utf8' });
assert.equal(checked.status, 0, checked.stderr || checked.stdout);
}
materializeGatewayNativeConfig(single, { apiPort: 19091, secretPath, runtimeConfigPath: runtimePath });
const process = spawn('sing-box', ['run', '-c', runtimePath], { stdio: ['ignore', 'pipe', 'pipe'] });
let log = '';
process.stdout.on('data', (chunk) => { log += chunk; });
process.stderr.on('data', (chunk) => { log += chunk; });
const client = createClient(StartedService, createGrpcTransport({ baseUrl: 'http://127.0.0.1:19091' }));
const controller = new AbortController();
const options = { signal: controller.signal, headers: { authorization: `Bearer ${secret}` } };
try {
let version;
for (let attempt = 0; attempt < 50; attempt += 1) {
try {
version = await client.getVersion({}, { ...options, timeoutMs: 200 });
break;
} catch {
await new Promise((resolve) => setTimeout(resolve, 100));
}
}
assert.ok(version, `native API did not start\n${log}`);
assert.equal(version.version, '1.14.0-rc.5');
assert.ok((await client.getStartedAt({}, options)).startedAt > 0n);
const connectionStream = client.subscribeConnections({ interval: 100_000_000n }, options)[Symbol.asyncIterator]();
const statusStream = client.subscribeStatus({ interval: 100_000_000n }, options)[Symbol.asyncIterator]();
const timeout = new Promise((_, reject) => setTimeout(() => reject(new Error('native streams timed out')), 5_000));
assert.equal((await Promise.race([connectionStream.next(), timeout])).done, false);
assert.equal((await Promise.race([statusStream.next(), timeout])).done, false);
console.log('PASS Gateway RC5 single/dual config, 0600 secret and authenticated lifecycle API');
} finally {
controller.abort();
process.kill('SIGTERM');
await Promise.race([once(process, 'exit'), new Promise((resolve) => setTimeout(resolve, 1_000))]);
}
NODE
CONTAINER_SCRIPT
+298
View File
@@ -0,0 +1,298 @@
#!/usr/bin/env bash
set -euo pipefail
readonly RC_VERSION='1.14.0-rc.5'
readonly ROLLBACK_VERSION='1.13.18'
TEMP_DIR="$(mktemp -d)"
SUFFIX="${TEMP_DIR##*/}"
readonly SUFFIX="${SUFFIX//[^[:alnum:]]/}"
readonly IMAGE="harbor-singbox-native-test:${RC_VERSION}-${SUFFIX}"
readonly CONTAINER="harbor-singbox-native-${SUFFIX}"
readonly ROLLBACK_IMAGE="harbor-singbox-rollback-test:${ROLLBACK_VERSION}-${SUFFIX}"
readonly ROLLBACK_CONTAINER="harbor-singbox-rollback-${SUFFIX}"
IMAGE_CREATED=false
CONTAINER_CREATED=false
ROLLBACK_IMAGE_CREATED=false
ROLLBACK_CONTAINER_CREATED=false
cleanup() {
[[ "$ROLLBACK_CONTAINER_CREATED" == true ]] && docker rm -f "$ROLLBACK_CONTAINER" >/dev/null 2>&1 || true
[[ "$CONTAINER_CREATED" == true ]] && docker rm -f "$CONTAINER" >/dev/null 2>&1 || true
[[ "$ROLLBACK_IMAGE_CREATED" == true ]] && docker image rm "$ROLLBACK_IMAGE" >/dev/null 2>&1 || true
[[ "$IMAGE_CREATED" == true ]] && docker image rm "$IMAGE" >/dev/null 2>&1 || true
rm -rf "$TEMP_DIR"
}
trap cleanup EXIT
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
docker build \
--build-arg "SINGBOX_VERSION=${RC_VERSION}" \
-t "$IMAGE" \
-f "$ROOT/Dockerfile.client" \
"$ROOT"
IMAGE_CREATED=true
docker build \
--build-arg "SINGBOX_VERSION=${ROLLBACK_VERSION}" \
-t "$ROLLBACK_IMAGE" \
-f "$ROOT/Dockerfile.client" \
"$ROOT"
ROLLBACK_IMAGE_CREATED=true
docker create -i \
--name "$ROLLBACK_CONTAINER" \
-e EXPECTED_SINGBOX_VERSION="$ROLLBACK_VERSION" \
-e APP_MODE=client \
-e PROXY_PORT=18081 \
-e DIAGNOSTICS_PROXY_PORT=18082 \
-e SING_BOX_TRAFFIC_SOURCE=disabled \
-e DATA_DIR=/tmp/harbor-rollback-test \
-e SING_BOX_CONFIG=/tmp/harbor-rollback-test/config.json \
-e SING_BOX_CACHE=/tmp/harbor-rollback-test/cache.db \
--entrypoint /bin/bash \
"$ROLLBACK_IMAGE" -s >/dev/null
ROLLBACK_CONTAINER_CREATED=true
docker start -a -i "$ROLLBACK_CONTAINER" <<'ROLLBACK_SCRIPT'
set -euo pipefail
cd /app
node --input-type=module <<'NODE'
import assert from 'node:assert/strict';
import { spawnSync } from 'node:child_process';
import fs from 'node:fs';
import { buildGatewayConfig } from '/app/dist/server/singbox.js';
import { parseSubscriptionBody } from '/app/dist/server/subscription.js';
const websocket = 'vless://00000000-0000-4000-8000-000000000002@ws.example.test:8443?encryption=none&security=tls&sni=edge.example.test&fp=firefox&alpn=h2%2Chttp%2F1.1&type=ws&host=edge.example.test&path=%2Fsocket%3Fed%3D2048#TLS%20WS';
const parsed = parseSubscriptionBody(Buffer.from(websocket).toString('base64'));
const config = buildGatewayConfig(parsed.config, parsed.servers[0].id, { clientDirect: true });
assert.equal(config.services, undefined);
assert.deepEqual(config.dns, { independent_cache: true });
fs.mkdirSync('/tmp/harbor-rollback-test', { recursive: true });
fs.writeFileSync(process.env.SING_BOX_CONFIG, JSON.stringify(config));
const version = spawnSync('sing-box', ['version'], { encoding: 'utf8' });
assert.equal(version.status, 0, version.stderr);
assert.equal(version.stdout.split('\n')[0], `sing-box version ${process.env.EXPECTED_SINGBOX_VERSION}`);
const checked = spawnSync('sing-box', ['check', '-c', process.env.SING_BOX_CONFIG], { encoding: 'utf8' });
assert.equal(checked.status, 0, checked.stderr || checked.stdout);
console.log(`PASS rollback config ${process.env.EXPECTED_SINGBOX_VERSION} services=absent dns.independent_cache=true`);
NODE
ROLLBACK_SCRIPT
docker create -i \
--name "$CONTAINER" \
-e EXPECTED_SINGBOX_VERSION="$RC_VERSION" \
-e APP_MODE=client \
-e PROXY_PORT=18081 \
-e DIAGNOSTICS_PROXY_PORT=18082 \
-e SING_BOX_TRAFFIC_SOURCE=native \
-e DATA_DIR=/tmp/harbor-native-test \
-e SING_BOX_CONFIG=/tmp/harbor-native-test/config.json \
-e SING_BOX_CACHE=/tmp/harbor-native-test/cache.db \
--entrypoint /bin/bash \
"$IMAGE" -s >/dev/null
CONTAINER_CREATED=true
docker start -a -i "$CONTAINER" <<'CONTAINER_SCRIPT'
set -euo pipefail
cd /app
node --input-type=module <<'NODE'
import assert from 'node:assert/strict';
import { spawn, spawnSync } from 'node:child_process';
import { once } from 'node:events';
import fs from 'node:fs';
import http from 'node:http';
import { createClient } from '@connectrpc/connect';
import { createGrpcTransport } from '@connectrpc/connect-node';
import {
ConnectionEventType,
StartedService,
} from '/app/dist/server/generated/daemon/started_service_pb.js';
import { buildGatewayConfig } from '/app/dist/server/singbox.js';
import { parseSubscriptionBody } from '/app/dist/server/subscription.js';
const API_PORT = 19091;
const CONFIG_PATH = process.env.SING_BOX_CONFIG;
const EXPECTED_VERSION = process.env.EXPECTED_SINGBOX_VERSION;
const websocket = 'vless://00000000-0000-4000-8000-000000000002@ws.example.test:8443?encryption=none&security=tls&sni=edge.example.test&fp=firefox&alpn=h2%2Chttp%2F1.1&type=ws&host=edge.example.test&path=%2Fsocket%3Fed%3D2048#TLS%20WS';
function timeout(promise, label, milliseconds = 10_000) {
let timer;
return Promise.race([
promise,
new Promise((_, reject) => {
timer = setTimeout(() => reject(new Error(`Timed out waiting for ${label}`)), milliseconds);
}),
]).finally(() => clearTimeout(timer));
}
function deferred() {
let resolve;
const promise = new Promise((done) => { resolve = done; });
return { promise, resolve };
}
function stop(child) {
if (!child || child.exitCode !== null) return Promise.resolve();
child.kill('SIGTERM');
return Promise.race([once(child, 'exit'), new Promise((resolve) => setTimeout(resolve, 1_000))]);
}
const parsed = parseSubscriptionBody(Buffer.from(websocket).toString('base64'));
const config = buildGatewayConfig(parsed.config, parsed.servers[0].id, { clientDirect: true });
assert.deepEqual(config.services, [{
type: 'api',
listen: '127.0.0.1',
listen_port: API_PORT,
dashboard: false,
}]);
fs.mkdirSync(new URL('.', `file://${CONFIG_PATH}`).pathname, { recursive: true });
fs.writeFileSync(CONFIG_PATH, JSON.stringify(config));
const version = spawnSync('sing-box', ['version'], { encoding: 'utf8' });
assert.equal(version.status, 0, version.stderr);
assert.equal(version.stdout.split('\n')[0], `sing-box version ${EXPECTED_VERSION}`);
const checked = spawnSync('sing-box', ['check', '-c', CONFIG_PATH], { encoding: 'utf8' });
assert.equal(checked.status, 0, checked.stderr || checked.stdout);
let singBox;
let curl;
let singBoxLog = '';
const controller = new AbortController();
const requestArrived = deferred();
const releaseResponse = deferred();
const firstBatch = deferred();
const firstStatus = deferred();
const newEvent = deferred();
const updateEvent = deferred();
const closedEvent = deferred();
let connectionId = '';
const target = http.createServer(async (_request, response) => {
requestArrived.resolve();
await releaseResponse.promise;
response.writeHead(200, { 'content-type': 'application/octet-stream' });
for (let index = 0; index < 8; index += 1) {
response.write(Buffer.alloc(32 * 1024, 97 + index));
await new Promise((resolve) => setTimeout(resolve, 150));
}
response.end('done');
});
try {
target.listen(18080, '127.0.0.1');
await once(target, 'listening');
singBox = spawn('sing-box', ['run', '-c', CONFIG_PATH], { stdio: ['ignore', 'pipe', 'pipe'] });
for (const stream of [singBox.stdout, singBox.stderr]) {
stream.on('data', (chunk) => { singBoxLog = `${singBoxLog}${chunk}`.slice(-8_000); });
}
const client = createClient(StartedService, createGrpcTransport({
baseUrl: `http://127.0.0.1:${API_PORT}`,
}));
let apiVersion;
for (let attempt = 0; attempt < 50; attempt += 1) {
try {
apiVersion = await client.getVersion({}, { signal: controller.signal, timeoutMs: 200 });
break;
} catch {
await new Promise((resolve) => setTimeout(resolve, 100));
}
}
assert.ok(apiVersion, `native h2c API did not start\n${singBoxLog}`);
assert.equal(apiVersion.version, EXPECTED_VERSION);
assert.ok(apiVersion.apiVersion >= 1);
const started = await client.getStartedAt({}, { signal: controller.signal, timeoutMs: 1_000 });
assert.ok(started.startedAt > 0n);
const reader = (async () => {
try {
for await (const batch of client.subscribeConnections(
{ interval: 100_000_000n },
{ signal: controller.signal },
)) {
firstBatch.resolve();
for (const event of batch.events) {
if (event.type === ConnectionEventType.CONNECTION_EVENT_NEW
&& event.connection?.inbound === 'mixed-in') {
connectionId = event.id || event.connection.id;
if (connectionId) newEvent.resolve(event);
}
if (connectionId && event.id === connectionId
&& event.type === ConnectionEventType.CONNECTION_EVENT_UPDATE
&& (event.uplinkDelta > 0n || event.downlinkDelta > 0n)) {
updateEvent.resolve(event);
}
if (connectionId && event.id === connectionId
&& event.type === ConnectionEventType.CONNECTION_EVENT_CLOSED) {
closedEvent.resolve(event);
}
}
}
} catch (error) {
if (!controller.signal.aborted) throw error;
}
})();
const statusReader = (async () => {
try {
for await (const status of client.subscribeStatus(
{ interval: 100_000_000n },
{ signal: controller.signal },
)) {
firstStatus.resolve(status);
}
} catch (error) {
if (!controller.signal.aborted) throw error;
}
})();
await timeout(firstBatch.promise, 'initial connection snapshot');
const status = await timeout(firstStatus.promise, 'first status snapshot');
assert.equal(status.trafficAvailable, true);
assert.ok(Number.isInteger(status.connectionsIn) && status.connectionsIn >= 0);
assert.ok(Number.isInteger(status.connectionsOut) && status.connectionsOut >= 0);
assert.ok(status.uplinkTotal >= 0n);
assert.ok(status.downlinkTotal >= 0n);
curl = spawn('curl', [
'--noproxy', '', '-fsS', '-x', 'http://127.0.0.1:18081',
'http://127.0.0.1:18080/lifecycle',
], { stdio: ['ignore', 'pipe', 'pipe'] });
let curlBody = Buffer.alloc(0);
let curlError = '';
curl.stdout.on('data', (chunk) => { curlBody = Buffer.concat([curlBody, chunk]); });
curl.stderr.on('data', (chunk) => { curlError += chunk; });
await timeout(requestArrived.promise, 'proxied request');
const opened = await timeout(newEvent.promise, 'NEW lifecycle event');
assert.equal(opened.connection?.inboundType, 'mixed');
assert.equal(opened.connection?.outbound, 'direct');
releaseResponse.resolve();
const [curlCode] = await timeout(once(curl, 'exit'), 'proxied response');
assert.equal(curlCode, 0, curlError);
assert.ok(curlBody.length > 256 * 1024);
await timeout(updateEvent.promise, 'UPDATE lifecycle event');
const closed = await timeout(closedEvent.promise, 'CLOSED lifecycle event');
assert.ok(closed.closedAt > 0n || (closed.connection?.closedAt || 0n) > 0n);
controller.abort();
await Promise.all([reader, statusReader]);
console.log(`PASS native h2c API ${apiVersion.version} (api ${apiVersion.apiVersion})`);
console.log(`PASS started/status ${started.startedAt} traffic=${status.trafficAvailable} in=${status.connectionsIn} out=${status.connectionsOut}`);
console.log(`PASS lifecycle NEW UPDATE CLOSED ${connectionId}`);
console.log('PASS active HTTP connection through generated mixed-in -> direct');
} finally {
controller.abort();
if (curl?.exitCode === null) curl.kill('SIGKILL');
await stop(singBox);
await new Promise((resolve) => target.close(resolve));
}
NODE
CONTAINER_SCRIPT
+1
View File
@@ -24,6 +24,7 @@
"src/web/features/activity-journal/activityJournalModel.ts",
"src/web/features/instructions/prometheus.ts",
"src/web/features/routing/ruleReorderModel.ts",
"src/web/features/traffic/trafficRows.ts",
"monitoring/grafana/harbor-gateway.json"
],
"exclude": [".test-dist", "dist", "node_modules"]