Add native traffic inspection to Harbor Connect and Gateway

This commit is contained in:
2026-08-31 05:19:15 +03:00
parent 116686a138
commit 4d066cb879
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 CLIENT_PROXY_PORT=8082
HARBOR_GATEWAY_CONTROL_PORT=3456 HARBOR_GATEWAY_CONTROL_PORT=3456
BASE_IMAGE=debian:bookworm-slim BASE_IMAGE=debian:bookworm-slim
SINGBOX_VERSION=1.13.18 SINGBOX_VERSION=1.14.0-rc.5
INSTALL_RUNTIME_DEPS=true INSTALL_RUNTIME_DEPS=true
INSTALL_SINGBOX=true INSTALL_SINGBOX=true
PROXY_PORT=8080 PROXY_PORT=8080
PROXY_BIND_IP=0.0.0.0 PROXY_BIND_IP=0.0.0.0
SING_BOX_API_PORT=19090 SING_BOX_API_PORT=19090
SING_BOX_TRAFFIC_SOURCE=snapshot
TPROXY_PORT=7895 TPROXY_PORT=7895
TPROXY_MARK=1 TPROXY_MARK=1
TPROXY_TABLE=100 TPROXY_TABLE=100
+1 -1
View File
@@ -18,7 +18,7 @@ env:
RUNTIME_BASE_SOURCE_IMAGE: mirror.gcr.io/library/debian:bookworm-slim RUNTIME_BASE_SOURCE_IMAGE: mirror.gcr.io/library/debian:bookworm-slim
APT_MIRROR: http://mirror.yandex.ru/debian APT_MIRROR: http://mirror.yandex.ru/debian
APT_SECURITY_MIRROR: http://mirror.yandex.ru/debian-security APT_SECURITY_MIRROR: http://mirror.yandex.ru/debian-security
SINGBOX_VERSION: 1.13.18 SINGBOX_VERSION: 1.14.0-rc.5
jobs: jobs:
build-and-push: 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 RUN npm run build:production
FROM ${BASE_IMAGE} FROM ${BASE_IMAGE}
ARG SINGBOX_VERSION=1.13.18 ARG SINGBOX_VERSION=1.14.0-rc.5
ARG INSTALL_RUNTIME_DEPS=true ARG INSTALL_RUNTIME_DEPS=true
ARG INSTALL_SINGBOX=true ARG INSTALL_SINGBOX=true
@@ -46,6 +46,9 @@ RUN if [ "${INSTALL_SINGBOX}" = "true" ]; then \
WORKDIR /app WORKDIR /app
COPY --from=build /src/dist /app/dist 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 package.json /app/package.json
COPY entrypoint.sh /entrypoint.sh COPY entrypoint.sh /entrypoint.sh
@@ -59,6 +62,7 @@ ENV PORT=3456 \
TPROXY_PORT=7895 \ TPROXY_PORT=7895 \
DATA_DIR=/var/lib/vpn-proxy \ DATA_DIR=/var/lib/vpn-proxy \
SING_BOX_CONFIG=/etc/sing-box/config.json \ 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"] 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 RUN npm run build:production
FROM ${RUNTIME_IMAGE} FROM ${RUNTIME_IMAGE}
ARG SINGBOX_VERSION=1.13.18 ARG SINGBOX_VERSION=1.14.0-rc.5
RUN apt-get update \ RUN apt-get update \
&& apt-get install -y --no-install-recommends ca-certificates curl dumb-init nodejs tar \ && apt-get install -y --no-install-recommends ca-certificates curl dumb-init nodejs tar \
@@ -34,6 +34,9 @@ RUN set -eux; \
WORKDIR /app WORKDIR /app
COPY --from=build /src/dist /app/dist 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 package.json /app/package.json
COPY entrypoint.client.sh /entrypoint.client.sh COPY entrypoint.client.sh /entrypoint.client.sh
@@ -49,6 +52,7 @@ ENV APP_MODE=client \
SING_BOX_CACHE=/var/lib/sing-box/cache.db \ SING_BOX_CACHE=/var/lib/sing-box/cache.db \
RULE_SET_DOWNLOAD_DETOUR=vpn \ RULE_SET_DOWNLOAD_DETOUR=vpn \
ROUTING_RU_DIRECT=true \ ROUTING_RU_DIRECT=true \
SING_BOX_TRAFFIC_SOURCE=native \
LOG_LEVEL=info LOG_LEVEL=info
EXPOSE 3456 8082 EXPOSE 3456 8082
+1 -1
View File
@@ -1,6 +1,6 @@
ARG BASE_IMAGE=mirror.gcr.io/library/debian:bookworm-slim ARG BASE_IMAGE=mirror.gcr.io/library/debian:bookworm-slim
FROM ${BASE_IMAGE} 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_MIRROR=http://mirror.yandex.ru/debian
ARG APT_SECURITY_MIRROR=http://mirror.yandex.ru/debian-security ARG APT_SECURITY_MIRROR=http://mirror.yandex.ru/debian-security
ARG HTTP_PROXY 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. Сам по себе локальный прокси не перенаправляет приложения автоматически. Адрес `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 | | `PROXY_ALLOWED_CIDRS` | приватные IPv4-сети | Сети, которым разрешён доступ к Gateway Proxy |
| `GATEWAY_CLIENT_CIDRS` | приватные IPv4-сети | Сети, трафик которых Gateway может маршрутизировать | | `GATEWAY_CLIENT_CIDRS` | приватные IPv4-сети | Сети, трафик которых Gateway может маршрутизировать |
| `DIRECT_TRAFFIC_MARK` | `0x40000000` | Зарезервированный одиночный connmark-бит учёта Direct; измените при конфликте с host QoS/firewall, не пересекаясь с `TPROXY_MARK` | | `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` | Уровень подробности журнала | | `LOG_LEVEL` | `info` | Уровень подробности журнала |
Остальные значения в `.env.example` относятся к сборке контейнера и внутренней маршрутизации. Меняйте их только при нестандартном развёртывании. Остальные значения в `.env.example` относятся к сборке контейнера и внутренней маршрутизации. Меняйте их только при нестандартном развёртывании.
После изменения `.env` пересоздайте контейнер командой `up -d` — обычного `restart` недостаточно. После изменения `.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 ## Prometheus и Grafana
Gateway публикует уже накопленные Harbor traffic counters по адресу `http://<gateway>:3456/metrics`. Scrape не запускает дополнительный netfilter read и не меняет сохранённое состояние. Harbor обновляет snapshot раз в 15 секунд, поэтому рекомендуемый начальный scrape interval и refresh dashboard — 30 секунд: 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 секунд. 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». Готовый 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 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 ```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`. Порты можно заменить через `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: . context: .
dockerfile: Dockerfile.client dockerfile: Dockerfile.client
args: args:
SINGBOX_VERSION: ${SINGBOX_VERSION:-1.13.18} SINGBOX_VERSION: ${SINGBOX_VERSION:-1.14.0-rc.5}
container_name: harbor-connect container_name: harbor-connect
environment: environment:
APP_MODE: client APP_MODE: client
@@ -14,6 +14,7 @@ services:
DATA_DIR: /var/lib/vpn-proxy DATA_DIR: /var/lib/vpn-proxy
SING_BOX_CONFIG: /etc/sing-box/config.json 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: ${SING_BOX_TRAFFIC_SOURCE:-native}
HARBOR_HOST_NETWORK_STATE: /run/harbor-host/network.json HARBOR_HOST_NETWORK_STATE: /run/harbor-host/network.json
HARBOR_GATEWAY_CONTROL_PORT: ${HARBOR_GATEWAY_CONTROL_PORT:-3456} HARBOR_GATEWAY_CONTROL_PORT: ${HARBOR_GATEWAY_CONTROL_PORT:-3456}
LOG_LEVEL: ${LOG_LEVEL:-info} LOG_LEVEL: ${LOG_LEVEL:-info}
+5 -1
View File
@@ -5,7 +5,7 @@ x-gateway-image: &gateway-image
dockerfile: Dockerfile dockerfile: Dockerfile
args: args:
BASE_IMAGE: ${BASE_IMAGE:-debian:bookworm-slim} 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_RUNTIME_DEPS: ${INSTALL_RUNTIME_DEPS:-true}
INSTALL_SINGBOX: ${INSTALL_SINGBOX:-true} INSTALL_SINGBOX: ${INSTALL_SINGBOX:-true}
@@ -25,6 +25,9 @@ services:
DATA_DIR: /var/lib/vpn-proxy DATA_DIR: /var/lib/vpn-proxy
SING_BOX_CONFIG: /var/lib/vpn-proxy/sing-box-config.json SING_BOX_CONFIG: /var/lib/vpn-proxy/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: ${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 DATAPLANE_SOCKET: /run/vpn-proxy/dataplane.sock
volumes: volumes:
- vpn-proxy-data:/var/lib/vpn-proxy - vpn-proxy-data:/var/lib/vpn-proxy
@@ -49,6 +52,7 @@ services:
DATA_DIR: /var/lib/vpn-proxy DATA_DIR: /var/lib/vpn-proxy
SING_BOX_CONFIG: /var/lib/vpn-proxy/sing-box-config.json SING_BOX_CONFIG: /var/lib/vpn-proxy/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: ${SING_BOX_TRAFFIC_SOURCE:-snapshot}
DATAPLANE_SOCKET: /run/vpn-proxy/dataplane.sock DATAPLANE_SOCKET: /run/vpn-proxy/dataplane.sock
ports: ports:
- "${PORT:-3456}:${PORT:-3456}" - "${PORT:-3456}:${PORT:-3456}"
+241
View File
@@ -8,6 +8,9 @@
"name": "vpn-proxy-gateway", "name": "vpn-proxy-gateway",
"version": "0.1.0", "version": "0.1.0",
"dependencies": { "dependencies": {
"@bufbuild/protobuf": "2.6.0",
"@connectrpc/connect": "2.0.3",
"@connectrpc/connect-node": "2.0.3",
"@vitejs/plugin-react": "^5.0.0", "@vitejs/plugin-react": "^5.0.0",
"react": "^19.0.0", "react": "^19.0.0",
"react-dom": "^19.0.0", "react-dom": "^19.0.0",
@@ -15,6 +18,8 @@
}, },
"devDependencies": { "devDependencies": {
"@babel/parser": "7.29.3", "@babel/parser": "7.29.3",
"@bufbuild/buf": "1.47.2",
"@bufbuild/protoc-gen-es": "2.6.0",
"@csstools/selector-specificity": "6.0.0", "@csstools/selector-specificity": "6.0.0",
"@types/node": "22.19.17", "@types/node": "22.19.17",
"@types/node18": "npm:@types/node@18.19.130", "@types/node18": "npm:@types/node@18.19.130",
@@ -288,6 +293,229 @@
"node": ">=6.9.0" "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": { "node_modules/@csstools/selector-specificity": {
"version": "6.0.0", "version": "6.0.0",
"resolved": "https://registry.npmjs.org/@csstools/selector-specificity/-/selector-specificity-6.0.0.tgz", "resolved": "https://registry.npmjs.org/@csstools/selector-specificity/-/selector-specificity-6.0.0.tgz",
@@ -1538,6 +1766,19 @@
"node": ">=16.20.0" "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": { "node_modules/@vitejs/plugin-react": {
"version": "5.2.0", "version": "5.2.0",
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-5.2.0.tgz", "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.", "description": "Gateway-first VPN proxy control panel for sing-box TProxy deployments.",
"scripts": { "scripts": {
"dev": "vite --host 0.0.0.0", "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": "vite build",
"build:production": "npm run build && npm run build:server", "build:production": "npm run build && npm run build:server",
"build:server": "tsc -p tsconfig.server.json", "build:server": "tsc -p tsconfig.server.json",
@@ -18,6 +19,9 @@
"start": "node dist/server/main.js" "start": "node dist/server/main.js"
}, },
"dependencies": { "dependencies": {
"@bufbuild/protobuf": "2.6.0",
"@connectrpc/connect": "2.0.3",
"@connectrpc/connect-node": "2.0.3",
"@vitejs/plugin-react": "^5.0.0", "@vitejs/plugin-react": "^5.0.0",
"react": "^19.0.0", "react": "^19.0.0",
"react-dom": "^19.0.0", "react-dom": "^19.0.0",
@@ -25,6 +29,8 @@
}, },
"devDependencies": { "devDependencies": {
"@babel/parser": "7.29.3", "@babel/parser": "7.29.3",
"@bufbuild/buf": "1.47.2",
"@bufbuild/protoc-gen-es": "2.6.0",
"@csstools/selector-specificity": "6.0.0", "@csstools/selector-specificity": "6.0.0",
"@types/node": "22.19.17", "@types/node": "22.19.17",
"@types/node18": "npm:@types/node@18.19.130", "@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}" BASE_IMAGE="${BASE_IMAGE:-vpn-proxy-runtime-base:bookworm-slim}"
NODE_BUILD_IMAGE="${NODE_BUILD_IMAGE:-node:20.19-alpine}" 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}" 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}" DOCKER_BUILD_PULL="${DOCKER_BUILD_PULL:-false}"
INSTALL_RUNTIME_DEPS="${INSTALL_RUNTIME_DEPS:-false}" INSTALL_RUNTIME_DEPS="${INSTALL_RUNTIME_DEPS:-false}"
INSTALL_SINGBOX="${INSTALL_SINGBOX:-false}" INSTALL_SINGBOX="${INSTALL_SINGBOX:-false}"
@@ -63,7 +63,7 @@ else
fi fi
echo "Building image on ${BUILD_HOST}" 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 if [ "${BUILD_HOST}" = "local" ]; then
bash -lc "${BUILD_COMMAND}" bash -lc "${BUILD_COMMAND}"
else else
+1 -1
View File
@@ -3,7 +3,7 @@ set -euo pipefail
BASE_IMAGE="${BASE_IMAGE:-mirror.gcr.io/library/debian:bookworm-slim}" BASE_IMAGE="${BASE_IMAGE:-mirror.gcr.io/library/debian:bookworm-slim}"
RUNTIME_BASE_IMAGE="${RUNTIME_BASE_IMAGE:-vpn-proxy-runtime-base: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_MIRROR="${APT_MIRROR:-http://mirror.yandex.ru/debian}"
APT_SECURITY_MIRROR="${APT_SECURITY_MIRROR:-http://mirror.yandex.ru/debian-security}" 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}')}" 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" DEFAULT_PROXY_PORT="8082"
REQUESTED_PROXY_PORT="${VPN_PROXY_CLIENT_PORT:-}" REQUESTED_PROXY_PORT="${VPN_PROXY_CLIENT_PORT:-}"
REQUESTED_UI_PORT="${VPN_PROXY_CLIENT_UI_PORT:-${CLIENT_UI_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" CLIENT_CONTAINER_NAME="harbor-connect"
LEGACY_CLIENT_CONTAINER_NAME="vpn-proxy-client" LEGACY_CLIENT_CONTAINER_NAME="vpn-proxy-client"
NETWORK_MONITOR_LABEL="com.dokril.harbor-connect.network" NETWORK_MONITOR_LABEL="com.dokril.harbor-connect.network"
@@ -290,6 +291,11 @@ need curl
need rsync need rsync
need tar 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 compose version >/dev/null 2>&1 || die "Docker Compose plugin is required"
docker info >/dev/null 2>&1 || die "Docker Desktop is not running" 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 APP_MODE client
set_env_value SINGBOX_VERSION "$TARGET_SINGBOX_VERSION" 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_UI_PORT "$UI_PORT"
set_env_value CLIENT_PROXY_PORT "$PROXY_PORT" set_env_value CLIENT_PROXY_PORT "$PROXY_PORT"
set_env_value PROXY_PORT "$PROXY_PORT" set_env_value PROXY_PORT "$PROXY_PORT"
+7 -3
View File
@@ -19,6 +19,7 @@ const noRuntimeImpact = [
/^entrypoint\.client\.sh$/, /^entrypoint\.client\.sh$/,
/^install\.sh$/, /^install\.sh$/,
/^scripts\/(?:check-import-boundaries\.mjs|clean-test-dist\.mjs|harbor-network-monitor\.sh|harbor-version\.mjs|install-macos-client\.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 = [ const foundation = [
/^\.dockerignore$/, /^\.dockerignore$/,
@@ -32,11 +33,14 @@ const foundation = [
/^tsconfig(?:\.[^.]+)?\.json$/, /^tsconfig(?:\.[^.]+)?\.json$/,
]; ];
const controlAndDataplane = [ const controlAndDataplane = [
/^buf\.gen\.yaml$/,
/^proto\//,
new RegExp(`^src/server/main${CODE_EXTENSION}`), 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/adapters/neighbors${CODE_EXTENSION}`),
new RegExp(`^src/server/services/(?:connectivityDiagnosticsService|deviceInventoryService|devicePolicyService|singboxSelectorService)${CODE_EXTENSION}`), new RegExp(`^src/server/services/(?:connectivityDiagnosticsService|deviceInventoryService|devicePolicyService|liveTrafficService|singboxSelectorService)${CODE_EXTENSION}`),
new RegExp(`^src/shared/(?:connectivityDiagnostics|errors)${CODE_EXTENSION}`), new RegExp(`^src/shared/(?:connectivityDiagnostics|errors|liveTraffic)${CODE_EXTENSION}`),
/^src\/server\/infrastructure\/dataplane\//, /^src\/server\/infrastructure\/dataplane\//,
]; ];
const dataplane = [ const dataplane = [
+24 -2
View File
@@ -1,5 +1,7 @@
import path from "node:path"; 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 dataDir = process.env.DATA_DIR || path.resolve(".vpn-proxy");
const parsePort = (value: string | undefined, fallback: number) => { const parsePort = (value: string | undefined, fallback: number) => {
const parsed = Number.parseInt(value || '', 10); const parsed = Number.parseInt(value || '', 10);
@@ -7,17 +9,33 @@ const parsePort = (value: string | undefined, fallback: number) => {
}; };
const proxyPort = parsePort( const proxyPort = parsePort(
process.env.PROXY_PORT, 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 = { export const settings = {
appMode: process.env.APP_MODE === "client" ? "client" : "gateway", appMode,
appComponent,
port: parsePort(process.env.PORT, 3456), port: parsePort(process.env.PORT, 3456),
proxyPort, proxyPort,
diagnosticsProxyPort: parsePort(process.env.DIAGNOSTICS_PROXY_PORT, 18080), diagnosticsProxyPort: parsePort(process.env.DIAGNOSTICS_PROXY_PORT, 18080),
failoverPrimaryProxyPort: parsePort(process.env.FAILOVER_PRIMARY_PROXY_PORT, 18081), failoverPrimaryProxyPort: parsePort(process.env.FAILOVER_PRIMARY_PROXY_PORT, 18081),
failoverReserveProxyPort: parsePort(process.env.FAILOVER_RESERVE_PROXY_PORT, 18082), failoverReserveProxyPort: parsePort(process.env.FAILOVER_RESERVE_PROXY_PORT, 18082),
singboxApiPort: parsePort(process.env.SING_BOX_API_PORT, 19090), 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), tproxyPort: parsePort(process.env.TPROXY_PORT, 7895),
tproxyMark: process.env.TPROXY_MARK || "1", tproxyMark: process.env.TPROXY_MARK || "1",
tproxyChain: process.env.TPROXY_CHAIN || "VPN_PROXY_TPROXY", tproxyChain: process.env.TPROXY_CHAIN || "VPN_PROXY_TPROXY",
@@ -40,6 +58,10 @@ export const settings = {
configPath: configPath:
process.env.SING_BOX_CONFIG || path.join(dataDir, "sing-box-config.json"), process.env.SING_BOX_CONFIG || path.join(dataDir, "sing-box-config.json"),
cachePath: process.env.SING_BOX_CACHE || "/var/lib/sing-box/cache.db", 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"), statePath: path.join(dataDir, "state.json"),
deviceStatePath: path.join(dataDir, "devices.json"), deviceStatePath: path.join(dataDir, "devices.json"),
activityJournalPath: path.join(dataDir, "activity-journal.json"), activityJournalPath: path.join(dataDir, "activity-journal.json"),
+189 -8
View File
@@ -1,7 +1,9 @@
import fs from 'node:fs'; import fs from 'node:fs';
import http from 'node:http'; import http from 'node:http';
import net from 'node:net';
import path from 'node:path'; import path from 'node:path';
import type { IncomingMessage, ServerResponse } from 'node:http'; import type { IncomingMessage, ServerResponse } from 'node:http';
import type { LiveTrafficConnection, LiveTrafficSnapshot } from '../shared/liveTraffic.js';
import { settings } from './config.js'; import { settings } from './config.js';
import { createSingboxRuntime } from './singboxRuntime.js'; import { createSingboxRuntime } from './singboxRuntime.js';
import { buildVersionInfo } from './version.js'; import { buildVersionInfo } from './version.js';
@@ -13,13 +15,27 @@ import {
createDomainTrafficService, createDomainTrafficService,
readSingboxConnections, readSingboxConnections,
} from './services/domainTrafficService.js'; } from './services/domainTrafficService.js';
import { deviceId } from './services/deviceInventoryService.js';
import {
createLiveTrafficService,
} from './services/liveTrafficService.js';
import { createSingboxSelectorService } from './services/singboxSelectorService.js'; import { createSingboxSelectorService } from './services/singboxSelectorService.js';
const socketPath = settings.dataplaneSocket; const socketPath = settings.dataplaneSocket;
const trafficMode = settings.singboxTrafficSource as 'snapshot' | 'shadow' | 'native';
const nativeTrafficEnabled = trafficMode === 'shadow' || trafficMode === 'native';
const runtime = createSingboxRuntime({ const runtime = createSingboxRuntime({
configPath: settings.configPath, configPath: settings.configPath,
gateway: true, gateway: true,
tproxyChain: settings.tproxyChain, tproxyChain: settings.tproxyChain,
gatewayRuntimeConfigPath: settings.gatewayRuntimeConfigPath,
...(nativeTrafficEnabled ? {
nativeApi: {
apiPort: settings.singboxNativeApiPort,
secretPath: settings.gatewayNativeApiSecretPath,
runtimeConfigPath: settings.gatewayRuntimeConfigPath,
},
} : {}),
}); });
const versionInfo = buildVersionInfo('gateway'); const versionInfo = buildVersionInfo('gateway');
const traffic = createDeviceTrafficService({ const traffic = createDeviceTrafficService({
@@ -46,10 +62,23 @@ const failoverDiagnostics = {
reserve: createConnectivityDiagnosticsService({ proxyPort: settings.failoverReserveProxyPort }), reserve: createConnectivityDiagnosticsService({ proxyPort: settings.failoverReserveProxyPort }),
}; };
const selector = createSingboxSelectorService({ port: settings.singboxApiPort }); const selector = createSingboxSelectorService({ port: settings.singboxApiPort });
const domainTraffic = createDomainTrafficService({ const snapshotDomainTraffic = createDomainTrafficService({
observe: () => readSingboxConnections(settings.singboxApiPort), observe: () => readSingboxConnections(settings.singboxApiPort),
devices: () => traffic.snapshot().devices, 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 ready = false;
let trafficTimer: NodeJS.Timeout | null = null; let trafficTimer: NodeJS.Timeout | null = null;
let domainTrafficTimer: 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); 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> { function readJson(req: IncomingMessage): Promise<unknown> {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const chunks: Buffer[] = []; const chunks: Buffer[] = [];
@@ -105,6 +261,7 @@ const server = http.createServer(async (req: IncomingMessage, res: ServerRespons
gatewayBackendVersion: versionInfo.components.gatewayBackend, gatewayBackendVersion: versionInfo.components.gatewayBackend,
singBoxVersion: versionInfo.runtime.singBox, singBoxVersion: versionInfo.runtime.singBox,
devicePolicy: devicePolicy.snapshot(), devicePolicy: devicePolicy.snapshot(),
trafficCollector: trafficCollectorSource(),
ready, ready,
}); });
} }
@@ -115,7 +272,10 @@ const server = http.createServer(async (req: IncomingMessage, res: ServerRespons
return sendJson(res, 200, traffic.snapshot()); return sendJson(res, 200, traffic.snapshot());
} }
if (req.method === 'GET' && req.url === '/domain-traffic') { 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') { if (req.method === 'GET' && req.url === '/device-policy') {
return sendJson(res, 200, devicePolicy.snapshot()); 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') { if (req.method === 'POST' && req.url === '/failover/activity/read') {
const { thresholdBytesPerSecond = 0 } = record(await readJson(req)); 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') { if (req.method === 'POST' && req.url === '/config/check') {
const { config } = record(await readJson(req)); const { config } = record(await readJson(req));
@@ -183,27 +346,44 @@ server.listen(socketPath, async () => {
} catch (error) { } catch (error) {
console.warn(`[dataplane] sing-box не запущен: ${errorMessage(error)}`); console.warn(`[dataplane] sing-box не запущен: ${errorMessage(error)}`);
} finally { } 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; ready = true;
if (settings.deviceTrafficAccountingEnabled) { if (settings.deviceTrafficAccountingEnabled) {
setImmediate(() => { setImmediate(() => {
traffic.refresh() refreshDeviceTraffic()
.catch((error: unknown) => console.warn(`[dataplane] traffic counters не запущены: ${errorMessage(error)}`)); .catch((error: unknown) => console.warn(`[dataplane] traffic counters не запущены: ${errorMessage(error)}`));
}); });
trafficTimer = setInterval(() => { 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); }, 15_000);
trafficTimer.unref(); trafficTimer.unref();
} else {
trafficTimer = setInterval(refreshOrigins, 15_000);
trafficTimer.unref();
} }
if (trafficMode !== 'native') {
setImmediate(() => { setImmediate(() => {
domainTraffic.refresh() snapshotDomainTraffic.refresh()
.catch((error: unknown) => console.warn(`[dataplane] domain traffic не запущен: ${errorMessage(error)}`)); .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(() => { domainTrafficTimer = setInterval(() => {
domainTraffic.refresh() snapshotDomainTraffic.refresh()
.catch((error: unknown) => console.warn(`[dataplane] domain traffic не обновлён: ${errorMessage(error)}`)); .catch((error: unknown) => console.warn(`[dataplane] domain traffic не обновлён: ${errorMessage(error)}`));
}, 2_000); }, 2_000);
domainTrafficTimer.unref(); domainTrafficTimer.unref();
}
console.log(`[dataplane] control socket: ${socketPath}`); console.log(`[dataplane] control socket: ${socketPath}`);
} }
}); });
@@ -215,6 +395,7 @@ async function shutdown() {
ready = false; ready = false;
if (trafficTimer) clearInterval(trafficTimer); if (trafficTimer) clearInterval(trafficTimer);
if (domainTrafficTimer) clearInterval(domainTrafficTimer); if (domainTrafficTimer) clearInterval(domainTrafficTimer);
await liveTraffic.stop();
await runtime.shutdown(); await runtime.shutdown();
server.close(() => { server.close(() => {
fs.rmSync(socketPath, { force: true }); fs.rmSync(socketPath, { force: true });
+1
View File
@@ -73,6 +73,7 @@ export function createDataplaneClient(socketPath: string, send: SendDataplaneReq
observeDevices: () => send(socketPath, '/devices', 'GET'), observeDevices: () => send(socketPath, '/devices', 'GET'),
observeTraffic: () => send(socketPath, '/device-traffic', 'GET'), observeTraffic: () => send(socketPath, '/device-traffic', 'GET'),
observeDomainTraffic: () => send(socketPath, '/domain-traffic', 'GET'), observeDomainTraffic: () => send(socketPath, '/domain-traffic', 'GET'),
observeLiveTraffic: () => send(socketPath, '/traffic/live', 'GET'),
observeDevicePolicy: () => send(socketPath, '/device-policy', 'GET'), observeDevicePolicy: () => send(socketPath, '/device-policy', 'GET'),
applyDevicePolicies: (devices: unknown) => send(socketPath, '/device-policy', 'PUT', { devices }), applyDevicePolicies: (devices: unknown) => send(socketPath, '/device-policy', 'PUT', { devices }),
runConnectivityDiagnostics: async (services: unknown = [], target: unknown = null) => { 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 { createGatewayPresenceRoute } from './http/routes/gatewayPresenceRoute.js';
import { createSharedProxyRoute } from './http/routes/sharedProxyRoute.js'; import { createSharedProxyRoute } from './http/routes/sharedProxyRoute.js';
import { createVersionRoute } from './http/routes/versionRoute.js'; import { createVersionRoute } from './http/routes/versionRoute.js';
import { createLiveTrafficRoute } from './http/routes/liveTrafficRoute.js';
import { createSingboxSelectorService } from './services/singboxSelectorService.js'; import { createSingboxSelectorService } from './services/singboxSelectorService.js';
import { createFailoverService } from './features/failover/failoverService.js'; import { createFailoverService } from './features/failover/failoverService.js';
import { createFailoverRoute } from './http/routes/failoverRoute.js'; import { createFailoverRoute } from './http/routes/failoverRoute.js';
@@ -260,6 +261,16 @@ function selectRuntime() {
throw new Error('Harbor runtime is not configured'); throw new Error('Harbor runtime is not configured');
} }
const singboxRuntime = selectRuntime(); 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() { function requireRemoteRuntime() {
if (!remoteRuntime) throw new Error('Harbor dataplane runtime is not configured'); if (!remoteRuntime) throw new Error('Harbor dataplane runtime is not configured');
@@ -515,6 +526,10 @@ const versionRoute = createVersionRoute({
? () => requireRemoteRuntime().refresh() ? () => requireRemoteRuntime().refresh()
: null, : null,
}); });
const liveTrafficRoute = createLiveTrafficRoute({
traffic: liveTraffic,
deviceInventory: remoteDataplane ? deviceInventory : null,
});
const subscriptionValidationRoute = createSubscriptionValidationRoute({ const subscriptionValidationRoute = createSubscriptionValidationRoute({
validateSubscription: createValidateSubscription(fetchSubscription), validateSubscription: createValidateSubscription(fetchSubscription),
readBody, readBody,
@@ -887,6 +902,19 @@ function currentConfigMatchesAppliedTarget(state: StoredState) {
} catch { } catch {
return false; 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) { if (state.appliedFailoverPolicy) {
const expectedRole = state.appliedProfileId === state.appliedFailoverPolicy.reserve.profileId const expectedRole = state.appliedProfileId === state.appliedFailoverPolicy.reserve.profileId
&& state.appliedServerId === state.appliedFailoverPolicy.reserve.serverId && 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 versionRoute.handle(req, res)) return;
if (await liveTrafficRoute.handle(req, res)) return;
if (await sharedProxyRoute.handle(req, res)) return; if (await sharedProxyRoute.handle(req, res)) return;
if (await deviceInventoryRoute.handle(req, res)) return; if (await deviceInventoryRoute.handle(req, res)) return;
@@ -1004,6 +1034,7 @@ async function shutdown() {
gatewayAutoService.stopDiscovery(); gatewayAutoService.stopDiscovery();
if (deviceDiscoveryTimer) clearInterval(deviceDiscoveryTimer); if (deviceDiscoveryTimer) clearInterval(deviceDiscoveryTimer);
await gatewayFailover?.shutdown().catch((error) => console.warn(`[control] failover shutdown: ${errorMessage(error)}`)); 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()); await serializeControl(() => singboxRuntime.shutdown());
process.exit(0); process.exit(0);
} }
@@ -1073,6 +1104,8 @@ if (deviceInventory) {
.catch((error: unknown) => console.warn(`[control] device policy не применена: ${errorMessage(error)}`)); .catch((error: unknown) => console.warn(`[control] device policy не применена: ${errorMessage(error)}`));
} }
clientLiveTraffic?.start();
server.listen(settings.port, '0.0.0.0', () => { server.listen(settings.port, '0.0.0.0', () => {
console.log(`[control] ${settings.appMode} UI слушает :${settings.port}`); console.log(`[control] ${settings.appMode} UI слушает :${settings.port}`);
}); });
+74 -1
View File
@@ -1,6 +1,12 @@
import type { ServerResponse } from 'node:http'; import type { ServerResponse } from 'node:http';
const COUNTER_PATTERN = /^\d+$/; 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 ?? '') const labelValue = (value: unknown) => String(value ?? '')
.replaceAll('\\', '\\\\') .replaceAll('\\', '\\\\')
@@ -23,6 +29,19 @@ function counter(value: unknown) {
return decimal; 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) { function timestamp(value: unknown) {
const milliseconds = Date.parse(String(value ?? '')); const milliseconds = Date.parse(String(value ?? ''));
return Number.isFinite(milliseconds) ? String(milliseconds / 1000) : null; return Number.isFinite(milliseconds) ? String(milliseconds / 1000) : null;
@@ -151,10 +170,64 @@ export function renderPrometheusMetrics(value: unknown) {
} }
const domainTraffic = record(snapshot.domainTraffic); 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) : []; const trackedSeries = Array.isArray(domainTraffic.tracked) ? domainTraffic.tracked.map(record) : [];
if (trackedSeries.length) { if (trackedSeries.length) {
lines.push( 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', '# TYPE harbor_singbox_tracked_bytes_total counter',
); );
for (const series of trackedSeries) { for (const series of trackedSeries) {
+147 -26
View File
@@ -3,6 +3,7 @@ import http from 'node:http';
import net from 'node:net'; import net from 'node:net';
import { domainToASCII } from 'node:url'; import { domainToASCII } from 'node:url';
import { deviceId } from './deviceInventoryService.js'; import { deviceId } from './deviceInventoryService.js';
import type { NativeTrafficProjectionBatch } from './liveTrafficService.js';
const MAX_RESPONSE_BYTES = 4 * 1024 * 1024; const MAX_RESPONSE_BYTES = 4 * 1024 * 1024;
const DEFAULT_MAX_SERIES = 4096; const DEFAULT_MAX_SERIES = 4096;
@@ -17,6 +18,7 @@ const SERVICE_DOMAINS = [
interface ParsedBaseConnection { interface ParsedBaseConnection {
id: string; id: string;
startedAt?: string;
upload: bigint; upload: bigint;
download: bigint; download: bigint;
} }
@@ -38,6 +40,7 @@ type ParsedConnection =
}); });
interface PreviousConnection { interface PreviousConnection {
startedAt?: string;
outcome: AttributionOutcome | 'classified'; outcome: AttributionOutcome | 'classified';
key?: string; key?: string;
requestedKey?: string; requestedKey?: string;
@@ -67,7 +70,7 @@ interface RouteSeriesTotal {
interface DomainTrafficSnapshot { interface DomainTrafficSnapshot {
epoch: string; epoch: string;
observedAt: string | null; observedAt: string | null;
source: { error: string | null }; source: { error: string | null; activeConnections: number };
overflowConnections: string; overflowConnections: string;
attributionEvents: Record<AttributionOutcome, string>; attributionEvents: Record<AttributionOutcome, string>;
tracked: Array<Omit<RouteSeriesTotal, 'deviceId' | 'uploadBytes' | 'downloadBytes'> & { 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> { export function readSingboxConnections(port: number, timeoutMs = 1500): Promise<unknown> {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const request = http.get({ host: '127.0.0.1', port, path: '/connections' }, (response) => { const request = http.get({ host: '127.0.0.1', port, path: '/connections' }, (response) => {
@@ -223,6 +266,9 @@ export function createDomainTrafficService({
const normalSeriesLimit = maxSeries - 2; const normalSeriesLimit = maxSeries - 2;
let normalSeries = 0; let normalSeries = 0;
let previousConnections = new Map<string, PreviousConnection>(); let previousConnections = new Map<string, PreviousConnection>();
const settledNativeConnections = new Map<string, PreviousConnection>();
let nativeEpoch: string | null = null;
let activeConnections = 0;
let overflowConnections = 0n; let overflowConnections = 0n;
const attributionEvents: Record<AttributionOutcome, bigint> = { const attributionEvents: Record<AttributionOutcome, bigint> = {
unresolved_host: 0n, unresolved_host: 0n,
@@ -237,7 +283,7 @@ export function createDomainTrafficService({
let current: DomainTrafficSnapshot = { let current: DomainTrafficSnapshot = {
epoch, epoch,
observedAt: null, observedAt: null,
source: { error: null }, source: { error: null, activeConnections: 0 },
overflowConnections: '0', overflowConnections: '0',
attributionEvents: { unresolved_host: '0', unknown_device: '0', unsupported_source: '0' }, attributionEvents: { unresolved_host: '0', unknown_device: '0', unsupported_source: '0' },
tracked: [], tracked: [],
@@ -249,7 +295,7 @@ export function createDomainTrafficService({
return { return {
epoch, epoch,
observedAt: current.observedAt, observedAt: current.observedAt,
source: { error }, source: { error, activeConnections },
overflowConnections: overflowConnections.toString(), overflowConnections: overflowConnections.toString(),
attributionEvents: Object.fromEntries( attributionEvents: Object.fromEntries(
ATTRIBUTION_OUTCOMES.map((outcome) => [outcome, attributionEvents[outcome].toString()]), ATTRIBUTION_OUTCOMES.map((outcome) => [outcome, attributionEvents[outcome].toString()]),
@@ -290,26 +336,30 @@ export function createDomainTrafficService({
}; };
} }
async function performRefresh() { function applyParsedConnections({
try { connections,
const response = record(await observe()); reset,
if (!Array.isArray(response.connections)) throw new Error('Sing-box не вернул connections array'); closedIds = [],
const devicesByIp = new Map<string, string | null>(); observed,
const deviceLabels = new Map<string, string>(); deviceLabels,
const observedDevices = devices(); sourceActiveConnections,
for (const value of Array.isArray(observedDevices) ? observedDevices : []) { }: {
const device = record(value); connections: ParsedConnection[];
const ip = String(device.ip || ''); reset: boolean;
const id = typeof device.mac === 'string' ? deviceId(device.mac.toLowerCase()) : null; closedIds?: string[];
if (!net.isIPv4(ip) || !id) continue; observed: Date;
devicesByIp.set(ip, devicesByIp.has(ip) ? null : id); deviceLabels: Map<string, string>;
deviceLabels.set(id, publicDeviceLabel(device)); sourceActiveConnections?: number;
} }) {
const connections = response.connections.map((connection) => parseConnection(connection, devicesByIp)); const nextConnections = reset
const activeConnections = new Map<string, PreviousConnection>(); ? new Map<string, PreviousConnection>()
: new Map(previousConnections);
const activityEntries: ActivityEntry[] = []; const activityEntries: ActivityEntry[] = [];
for (const connection of connections) { for (const connection of connections) {
const previous = previousConnections.get(connection.id); 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) { if (connection.outcome !== 'classified' && previous?.outcome !== connection.outcome) {
attributionEvents[connection.outcome] += 1n; attributionEvents[connection.outcome] += 1n;
} }
@@ -342,7 +392,8 @@ export function createDomainTrafficService({
} }
} }
if (connection.outcome === 'unknown_device' || connection.outcome === 'unsupported_source') { if (connection.outcome === 'unknown_device' || connection.outcome === 'unsupported_source') {
activeConnections.set(connection.id, { nextConnections.set(connection.id, {
startedAt: connection.startedAt,
outcome: connection.outcome, outcome: connection.outcome,
countedUpload: previous?.countedUpload ?? null, countedUpload: previous?.countedUpload ?? null,
countedDownload: previous?.countedDownload ?? null, countedDownload: previous?.countedDownload ?? null,
@@ -399,7 +450,8 @@ export function createDomainTrafficService({
total.uploadBytes += uploadDelta; total.uploadBytes += uploadDelta;
total.downloadBytes += downloadDelta; total.downloadBytes += downloadDelta;
totals.set(key, total); totals.set(key, total);
activeConnections.set(connection.id, { nextConnections.set(connection.id, {
startedAt: connection.startedAt,
outcome: connection.outcome, outcome: connection.outcome,
key, key,
requestedKey, requestedKey,
@@ -409,8 +461,19 @@ export function createDomainTrafficService({
trackedDownload: connection.download, trackedDownload: connection.download,
}); });
} }
previousConnections = activeConnections; for (const id of closedIds) {
const observed = now(); 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) { if (activityEnabled) {
activitySamples.push({ at: observed.getTime(), entries: activityEntries }); activitySamples.push({ at: observed.getTime(), entries: activityEntries });
activitySamples = activitySamples.filter(({ at }) => at >= observed.getTime() - 10_000); activitySamples = activitySamples.filter(({ at }) => at >= observed.getTime() - 10_000);
@@ -418,6 +481,64 @@ export function createDomainTrafficService({
current = { ...current, observedAt: observed.toISOString() }; current = { ...current, observedAt: observed.toISOString() };
current = buildSnapshot(); current = buildSnapshot();
return current; return current;
}
async function performRefresh() {
try {
const response = record(await observe());
if (!Array.isArray(response.connections)) throw new Error('Sing-box не вернул connections array');
const devicesByIp = new Map<string, string | null>();
const deviceLabels = new Map<string, string>();
const observedDevices = devices();
for (const value of Array.isArray(observedDevices) ? observedDevices : []) {
const device = record(value);
const ip = String(device.ip || '');
const id = typeof device.mac === 'string' ? deviceId(device.mac.toLowerCase()) : null;
if (!net.isIPv4(ip) || !id) continue;
devicesByIp.set(ip, devicesByIp.has(ip) ? null : id);
deviceLabels.set(id, publicDeviceLabel(device));
}
const connections = response.connections.map((connection) => parseConnection(connection, devicesByIp));
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 (nativeEpoch !== batch.epoch) {
nativeEpoch = batch.epoch;
previousConnections = new Map();
settledNativeConnections.clear();
}
const connections = batch.connections.map(parseNativeConnection);
const result = applyParsedConnections({
connections,
reset: batch.reset,
closedIds: batch.closedIds,
observed,
deviceLabels,
});
return result;
} catch (error) { } catch (error) {
current = buildSnapshot(error instanceof Error ? error.message : String(error)); current = buildSnapshot(error instanceof Error ? error.message : String(error));
throw 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 = [], routeRules = [],
}: { clientDirect?: boolean; routeRules?: unknown } = {}) { }: { clientDirect?: boolean; routeRules?: unknown } = {}) {
const clientMode = settings.appMode === 'client'; const clientMode = settings.appMode === 'client';
const nativeTraffic = settings.singboxTrafficSource === 'native'
|| settings.singboxTrafficSource === 'shadow';
const directClient = clientMode && clientDirect; const directClient = clientMode && clientDirect;
const vpnOutbound = selectedOutbound(subscriptionConfig, selectedTag); const vpnOutbound = selectedOutbound(subscriptionConfig, selectedTag);
const outboundTag = directClient ? 'direct' : vpnOutbound.tag; const outboundTag = directClient ? 'direct' : vpnOutbound.tag;
@@ -158,13 +160,21 @@ export function buildGatewayConfig(subscriptionConfig: unknown, selectedTag: unk
return { return {
log: { level: settings.logLevel, timestamp: true }, log: { level: settings.logLevel, timestamp: true },
...(nativeTraffic ? {
services: [{
type: 'api',
listen: '127.0.0.1',
listen_port: settings.singboxNativeApiPort,
dashboard: false,
}],
} : {}),
experimental: { experimental: {
cache_file: { enabled: true, path: settings.cachePath }, cache_file: { enabled: true, path: settings.cachePath },
...(!clientMode ? { ...(!clientMode ? {
clash_api: { external_controller: `127.0.0.1:${settings.singboxApiPort}` }, clash_api: { external_controller: `127.0.0.1:${settings.singboxApiPort}` },
} : {}), } : {}),
}, },
dns: { independent_cache: true }, dns: nativeTraffic ? {} : { independent_cache: true },
inbounds, inbounds,
outbounds: [ outbounds: [
vpnOutbound, vpnOutbound,
@@ -185,6 +195,8 @@ export function buildDualChannelGatewayConfig(
{ routeRules = [], defaultRole = 'primary' }: { routeRules?: unknown; defaultRole?: 'primary' | 'reserve' } = {}, { routeRules = [], defaultRole = 'primary' }: { routeRules?: unknown; defaultRole?: 'primary' | 'reserve' } = {},
) { ) {
if (settings.appMode === 'client') throw new Error('Dual-channel config доступен только Gateway'); if (settings.appMode === 'client') throw new Error('Dual-channel config доступен только Gateway');
const nativeTraffic = settings.singboxTrafficSource === 'native'
|| settings.singboxTrafficSource === 'shadow';
const primary = selectedOutbound( const primary = selectedOutbound(
channels.primary.subscriptionConfig, channels.primary.subscriptionConfig,
channels.primary.selectedServerId, channels.primary.selectedServerId,
@@ -204,11 +216,19 @@ export function buildDualChannelGatewayConfig(
const userInbounds = [TPROXY_INBOUND, MIXED_INBOUND]; const userInbounds = [TPROXY_INBOUND, MIXED_INBOUND];
return { return {
log: { level: settings.logLevel, timestamp: true }, log: { level: settings.logLevel, timestamp: true },
...(nativeTraffic ? {
services: [{
type: 'api',
listen: '127.0.0.1',
listen_port: settings.singboxNativeApiPort,
dashboard: false,
}],
} : {}),
experimental: { experimental: {
cache_file: { enabled: true, path: settings.cachePath }, cache_file: { enabled: true, path: settings.cachePath },
clash_api: { external_controller: `127.0.0.1:${settings.singboxApiPort}` }, clash_api: { external_controller: `127.0.0.1:${settings.singboxApiPort}` },
}, },
dns: { independent_cache: true }, dns: nativeTraffic ? {} : { independent_cache: true },
inbounds: [ inbounds: [
{ type: 'tproxy', tag: TPROXY_INBOUND, listen: '::', listen_port: settings.tproxyPort }, { 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 }, { 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 { spawn, spawnSync, type ChildProcess } from 'node:child_process';
import { setGatewayInterception } from './gatewayRouting.js'; import { setGatewayInterception } from './gatewayRouting.js';
import { HarborError } from '../shared/errors.js'; import { HarborError } from '../shared/errors.js';
import {
materializeGatewayNativeConfig,
materializeGatewaySnapshotConfig,
} from './gatewayNativeRuntime.js';
export function createSingboxRuntime({ export function createSingboxRuntime({
configPath, configPath,
gateway = false, gateway = false,
tproxyChain = '', tproxyChain = '',
gatewayRuntimeConfigPath,
nativeApi,
}: { }: {
configPath: string; configPath: string;
gateway?: boolean; gateway?: boolean;
tproxyChain?: string; tproxyChain?: string;
gatewayRuntimeConfigPath?: string;
nativeApi?: {
apiPort: number;
secretPath: string;
runtimeConfigPath: string;
};
}) { }) {
let child: ChildProcess | null = null; let child: ChildProcess | null = null;
let configHash = ''; let configHash = '';
let startedAt: string | null = null; 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 checkConfig(config: unknown) { function checked(configFile: string) {
const directory = fs.mkdtempSync(`${configPath}.check-`); const check = spawnSync('sing-box', ['check', '-c', configFile], { encoding: 'utf8' });
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) { if (check.status !== 0) {
throw new HarborError('CONFIG_INVALID', { throw new HarborError('CONFIG_INVALID', {
cause: new Error((check.stderr || check.stdout || check.error?.message || 'sing-box check failed').trim()), 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));
checked(candidatePath);
return { valid: true }; return { valid: true };
} finally { } finally {
fs.rmSync(directory, { recursive: true, force: true }); fs.rmSync(directory, { recursive: true, force: true });
@@ -41,6 +72,7 @@ export function createSingboxRuntime({
if (!child) { if (!child) {
configHash = ''; configHash = '';
startedAt = null; startedAt = null;
nativeApiSecret = null;
return state(); return state();
} }
@@ -48,6 +80,7 @@ export function createSingboxRuntime({
child = null; child = null;
configHash = ''; configHash = '';
startedAt = null; startedAt = null;
nativeApiSecret = null;
await new Promise<void>((resolve) => { await new Promise<void>((resolve) => {
const timeout = setTimeout(() => { const timeout = setTimeout(() => {
current.kill('SIGKILL'); current.kill('SIGKILL');
@@ -65,23 +98,37 @@ export function createSingboxRuntime({
async function apply({ force = false } = {}) { async function apply({ force = false } = {}) {
if (!fs.existsSync(configPath)) { if (!fs.existsSync(configPath)) {
await stop(); await stop();
nativeApiWarning = null;
return state(); return state();
} }
const check = spawnSync('sing-box', ['check', '-c', configPath], { encoding: 'utf8' }); let materialized = { configPath, secret: null as string | null, warning: null as string | null };
if (check.status !== 0) { if (nativeApi || gatewayRuntimeConfigPath) {
throw new HarborError('CONFIG_INVALID', { let config: unknown;
cause: new Error((check.stderr || check.stdout || check.error?.message || 'sing-box check failed').trim()), 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'); const nextHash = crypto.createHash('sha256')
if (!force && child && nextHash === configHash) return state(); .update(fs.readFileSync(materialized.configPath))
.digest('hex');
if (!force && child && nextHash === configHash) {
nativeApiSecret = materialized.secret;
nativeApiWarning = materialized.warning;
return state();
}
await stop(); await stop();
let current: ChildProcess; let current: ChildProcess;
try { try {
current = spawn('sing-box', ['run', '-c', configPath], { current = spawn('sing-box', ['run', '-c', materialized.configPath], {
stdio: ['ignore', 'inherit', 'inherit'], stdio: ['ignore', 'inherit', 'inherit'],
}); });
await new Promise<void>((resolve, reject) => { await new Promise<void>((resolve, reject) => {
@@ -94,6 +141,8 @@ export function createSingboxRuntime({
child = current; child = current;
configHash = nextHash; configHash = nextHash;
startedAt = new Date().toISOString(); startedAt = new Date().toISOString();
nativeApiSecret = materialized.secret;
nativeApiWarning = materialized.warning;
try { try {
if (gateway) setGatewayInterception(true, tproxyChain); if (gateway) setGatewayInterception(true, tproxyChain);
} catch (error) { } catch (error) {
@@ -101,6 +150,7 @@ export function createSingboxRuntime({
child = null; child = null;
configHash = ''; configHash = '';
startedAt = null; startedAt = null;
nativeApiSecret = null;
throw new HarborError('PROCESS_START_FAILED', { cause: error }); throw new HarborError('PROCESS_START_FAILED', { cause: error });
} }
current.once('exit', () => { current.once('exit', () => {
@@ -108,6 +158,7 @@ export function createSingboxRuntime({
child = null; child = null;
configHash = ''; configHash = '';
startedAt = null; startedAt = null;
nativeApiSecret = null;
if (gateway) setGatewayInterception(false, tproxyChain); if (gateway) setGatewayInterception(false, tproxyChain);
}); });
return state(); return state();
@@ -116,6 +167,8 @@ export function createSingboxRuntime({
return { return {
get running() { return Boolean(child); }, get running() { return Boolean(child); },
get startedAt() { return startedAt; }, get startedAt() { return startedAt; },
get nativeApiSecret() { return nativeApiSecret; },
get nativeApiWarning() { return nativeApiWarning; },
refresh: async () => state(), refresh: async () => state(),
checkConfig, checkConfig,
apply, 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({ export const HARBOR_VERSIONS = Object.freeze({
macClient: '0.33.0', macClient: '0.34.0',
gatewayClient: '0.34.0', gatewayClient: '0.36.0',
gatewayBackend: '0.34.0', gatewayBackend: '0.36.0',
}); });
export interface ParsedVersion { export interface ParsedVersion {
+1
View File
@@ -29,6 +29,7 @@ const componentActions = {
pingServers: api.servers.ping, pingServers: api.servers.ping,
runConnectivityDiagnostics: api.diagnostics.connectivity, runConnectivityDiagnostics: api.diagnostics.connectivity,
loadActivityJournal: api.activityJournal.page, loadActivityJournal: api.activityJournal.page,
loadLiveTraffic: api.traffic.live,
}; };
interface UiError { interface UiError {
+3
View File
@@ -230,6 +230,9 @@ export const api = {
activityJournal: { activityJournal: {
page: (cursor: string | null = null) => request(`/api/activity-journal?limit=50${cursor ? `&cursor=${encodeURIComponent(cursor)}` : ''}`), page: (cursor: string | null = null) => request(`/api/activity-journal?limit=50${cursor ? `&cursor=${encodeURIComponent(cursor)}` : ''}`),
}, },
traffic: {
live: () => request('/api/traffic/live'),
},
singbox: { singbox: {
stop: () => request('/api/singbox/stop', { method: 'POST' }), stop: () => request('/api/singbox/stop', { method: 'POST' }),
restart: () => request('/api/singbox/restart', { method: 'POST' }), restart: () => request('/api/singbox/restart', { method: 'POST' }),
+27 -2
View File
@@ -47,6 +47,11 @@ import {
InstructionsToggle, InstructionsToggle,
useInstructionsFeature, useInstructionsFeature,
} from '../features/instructions/index.js'; } from '../features/instructions/index.js';
import {
TrafficPanel,
TrafficToggle,
useTrafficFeature,
} from '../features/traffic/index.js';
import { FailoverPanel, FailoverToggle, useFailoverFeature } from '../features/failover/index.js'; import { FailoverPanel, FailoverToggle, useFailoverFeature } from '../features/failover/index.js';
import { import {
ActivityJournalPanel, ActivityJournalPanel,
@@ -72,7 +77,7 @@ const VERSION_PARTS = [
] as const; ] as const;
const DRAWER_SWITCH_MS = 620; 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]; type DrawerKey = typeof DRAWER_ORDER[number];
const failoverReasonLabel = (reason: string | null) => ({ const failoverReasonLabel = (reason: string | null) => ({
@@ -128,6 +133,7 @@ interface ComponentActions {
pingServers: (profileId: string, ids: string[]) => Promise<unknown>; pingServers: (profileId: string, ids: string[]) => Promise<unknown>;
runConnectivityDiagnostics: (target?: unknown) => Promise<unknown>; runConnectivityDiagnostics: (target?: unknown) => Promise<unknown>;
loadActivityJournal: (cursor?: string | null) => Promise<unknown>; loadActivityJournal: (cursor?: string | null) => Promise<unknown>;
loadLiveTraffic: () => Promise<unknown>;
} }
interface ClientViewState extends StateSnapshot { interface ClientViewState extends StateSnapshot {
@@ -586,6 +592,11 @@ export function ClientOverviewPage({
}); });
const failoverFeature = useFailoverFeature(); const failoverFeature = useFailoverFeature();
const activityJournalFeature = useActivityJournalFeature(); const activityJournalFeature = useActivityJournalFeature();
const trafficFeature = useTrafficFeature({
enabled: true,
isGateway,
loadLiveTraffic: actions.loadLiveTraffic,
});
const diagnosticsAvailable = hasSubscription; const diagnosticsAvailable = hasSubscription;
const drawerControls = { const drawerControls = {
subscription: { subscription: {
@@ -612,6 +623,12 @@ export function ClientOverviewPage({
show: devicesFeature.toggle, show: devicesFeature.toggle,
close: devicesFeature.close, close: devicesFeature.close,
}, },
traffic: {
isOpen: trafficFeature.isOpen,
panelRef: trafficFeature.panelRef,
show: trafficFeature.toggle,
close: trafficFeature.close,
},
diagnostics: { diagnostics: {
isOpen: diagnosticsFeature.isOpen, isOpen: diagnosticsFeature.isOpen,
panelRef: diagnosticsFeature.panelRef, panelRef: diagnosticsFeature.panelRef,
@@ -654,8 +671,9 @@ export function ClientOverviewPage({
diagnosticsFeature.close(); diagnosticsFeature.close();
failoverFeature.close(); failoverFeature.close();
activityJournalFeature.close(); activityJournalFeature.close();
trafficFeature.close();
} }
}, [hasSubscription, isGateway]); }, [hasSubscription]);
useEffect(() => { useEffect(() => {
if (!diagnosticsAvailable) diagnosticsFeature.close(); if (!diagnosticsAvailable) diagnosticsFeature.close();
@@ -851,6 +869,11 @@ export function ClientOverviewPage({
open={activeRailDrawer === 'devices'} open={activeRailDrawer === 'devices'}
onToggle={() => switchDrawer('devices')} onToggle={() => switchDrawer('devices')}
/>} />}
<TrafficToggle
feature={trafficFeature}
open={activeRailDrawer === 'traffic'}
onToggle={() => switchDrawer('traffic')}
/>
<DiagnosticsToggle <DiagnosticsToggle
feature={diagnosticsFeature} feature={diagnosticsFeature}
open={activeRailDrawer === 'diagnostics'} open={activeRailDrawer === 'diagnostics'}
@@ -936,6 +959,8 @@ export function ClientOverviewPage({
{isGateway && hasSubscription && <DevicesPanel feature={devicesFeature} />} {isGateway && hasSubscription && <DevicesPanel feature={devicesFeature} />}
{hasSubscription && <TrafficPanel feature={trafficFeature} />}
{diagnosticsAvailable && <ConnectivityDiagnosticsPanel {diagnosticsAvailable && <ConnectivityDiagnosticsPanel
feature={diagnosticsFeature} feature={diagnosticsFeature}
runConnectivityDiagnostics={actions.runConnectivityDiagnostics} 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/diagnostics.css';
@import './features/failover.css'; @import './features/failover.css';
@import './features/activity-journal.css'; @import './features/activity-journal.css';
@import './features/traffic.css';
@import './layout.css'; @import './layout.css';
@import './themes.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', () => { 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']), { assert.deepEqual(classifyRuntimeImpact(['src/web/App.tsx', 'src/shared/versions.ts']), {
affectedComponents: ['control'], 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/); assert.throws(() => classifyRuntimeImpact(['scripts/new-runtime.sh']), /Unclassified path/);
}); });
test('every tracked repository path has an explicit ownership class', () => { test('every repository path has an explicit ownership class', () => {
const tracked = execFileSync('git', ['ls-files'], { cwd: root, encoding: 'utf8' }) const tracked = execFileSync(
'git',
['ls-files', '--cached', '--others', '--exclude-standard'],
{ cwd: root, encoding: 'utf8' },
)
.trim() .trim()
.split('\n'); .split('\n');
assert.doesNotThrow(() => classifyRuntimeImpact(tracked)); 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'/); 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'); 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 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.match(workflow, /command -v npm[^']+command -v git[^']+test -x \/bin\/bash/);
assert.doesNotMatch(workflow, /docker image inspect "\$\{\{ env\.NODE_BUILD_IMAGE \}\}"/); assert.doesNotMatch(workflow, /docker image inspect "\$\{\{ env\.NODE_BUILD_IMAGE \}\}"/);
assert.match(legacyBuild, /npm run build:production && docker build/); 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); assert.match(dockerignore, /^dist$/m);
}); });
test('every shipped build defaults to sing-box 1.13.18', () => { test('Mac client builds default to exact sing-box 1.14.0-rc.5', () => {
for (const file of [ assert.match(
'.env.example', fs.readFileSync(path.join(root, 'Dockerfile.client'), 'utf8'),
'.gitea/workflows/gateway-build.yml', /^ARG SINGBOX_VERSION=1\.14\.0-rc\.5$/m,
'Dockerfile', );
'Dockerfile.client', assert.match(
'Dockerfile.runtime-base', fs.readFileSync(path.join(root, 'docker-compose.client.yml'), 'utf8'),
'docker-compose.client.yml', /SINGBOX_VERSION: \$\{SINGBOX_VERSION:-1\.14\.0-rc\.5\}/,
'docker-compose.gateway.yml', );
'scripts/build-on-107-deploy-111.sh', });
'scripts/build-runtime-base.sh',
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); assert.equal(traffic.running, true);
const domainTraffic = await client.observeDomainTraffic(); const domainTraffic = await client.observeDomainTraffic();
assert.equal(domainTraffic.running, true); assert.equal(domainTraffic.running, true);
const liveTraffic = await client.observeLiveTraffic();
assert.equal(liveTraffic.running, true);
await client.observeDevicePolicy(); await client.observeDevicePolicy();
await client.applyDevicePolicies([{ id: 'dev_0011223344556677' }]); await client.applyDevicePolicies([{ id: 'dev_0011223344556677' }]);
await client.runConnectivityDiagnostics( await client.runConnectivityDiagnostics(
@@ -47,6 +49,7 @@ test('control uses the dataplane socket protocol', async () => {
'GET /devices /run/dataplane.sock', 'GET /devices /run/dataplane.sock',
'GET /device-traffic /run/dataplane.sock', 'GET /device-traffic /run/dataplane.sock',
'GET /domain-traffic /run/dataplane.sock', 'GET /domain-traffic /run/dataplane.sock',
'GET /traffic/live /run/dataplane.sock',
'GET /device-policy /run/dataplane.sock', 'GET /device-policy /run/dataplane.sock',
'PUT /device-policy /run/dataplane.sock', 'PUT /device-policy /run/dataplane.sock',
'POST /diagnostics/connectivity /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 /restart /run/dataplane.sock',
'POST /stop /run/dataplane.sock', 'POST /stop /run/dataplane.sock',
]); ]);
assert.deepEqual(requests[6].body, { devices: [{ id: 'dev_0011223344556677' }] }); assert.deepEqual(requests[7].body, { devices: [{ id: 'dev_0011223344556677' }] });
assert.deepEqual(requests[7].body, { assert.deepEqual(requests[8].body, {
services: [{ id: 'custom-test', url: 'https://example.com' }], services: [{ id: 'custom-test', url: 'https://example.com' }],
target: 'site:custom-test', target: 'site:custom-test',
}); });
assert.equal(requests[7].timeoutMs, 25_000); assert.equal(requests[8].timeoutMs, 25_000);
assert.deepEqual(requests[8].body, { config: { outbounds: [] } }); assert.deepEqual(requests[9].body, { config: { outbounds: [] } });
assert.deepEqual(requests[9].body, { role: 'primary', services: [], target: 'site:youtube', timeoutMs: 9_000 }); assert.deepEqual(requests[10].body, { role: 'primary', services: [], target: 'site:youtube', timeoutMs: 9_000 });
assert.equal(requests[9].timeoutMs, 19_000); assert.equal(requests[10].timeoutMs, 19_000);
assert.deepEqual(requests[11].body, { role: 'reserve' }); assert.deepEqual(requests[12].body, { role: 'reserve' });
}); });
test('connectivity diagnostics expose a retryable domain error', async () => { 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/); 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', () => { 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, /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 \}\}/); 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', () => { 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, /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, /async function refreshDeviceTraffic\(\)[\s\S]*traffic\.refresh\(\)/);
assert.match(dataplaneSource, /traffic\.refresh\(\)\.catch/); 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', () => { 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, download,
chains, 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 () => { test('sing-box route traffic keeps vpn, direct and unknown deltas separate', async () => {
let response = { connections: [ let response = { connections: [
@@ -238,3 +272,73 @@ test('failover activity is zero-work while disabled and uses the existing connec
service.disableActivity(); service.disableActivity();
assert.equal(service.activitySnapshot(500), null); 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: { domainTraffic: {
observedAt, 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', overflowConnections: '2',
attributionEvents: { attributionEvents: {
unresolved_host: '3', 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="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="unknown_device"\} 4/);
assert.match(output, /harbor_domain_traffic_attribution_events_total\{outcome="unsupported_source"\} 5/); 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_domain_traffic_bytes_total\{[^\n]*name=/);
assert.doesNotMatch(output, /harbor_device_(?:direct_ipv4_packet|singbox_tracked)_bytes_total\{[^\n]*(?:name|ip|mac|server)=/); assert.doesNotMatch(output, /harbor_device_(?:direct_ipv4_packet|singbox_tracked)_bytes_total\{[^\n]*(?:name|ip|mac|server)=/);
assert.equal(output.endsWith('\n'), true); 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); 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', () => { test('invalid canonical counters fail the scrape instead of publishing corrupt values', () => {
const invalid = { traffic: { gatewayBytes: 'broken', proxyBytes: '0' }, devices: [] }; const invalid = { traffic: { gatewayBytes: 'broken', proxyBytes: '0' }, devices: [] };
assert.throws( assert.throws(
@@ -135,6 +175,12 @@ test('invalid canonical counters fail the scrape instead of publishing corrupt v
const invalidRoute = structuredClone(snapshot); const invalidRoute = structuredClone(snapshot);
invalidRoute.domainTraffic.routes[0].outbound = 'vpn-server-tag'; invalidRoute.domainTraffic.routes[0].outbound = 'vpn-server-tag';
assert.throws(() => renderPrometheusMetrics(invalidRoute), /Invalid sing-box outbound labels/); 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() { function routeResponse() {
+270
View File
@@ -1,12 +1,28 @@
import assert from 'node:assert/strict'; 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 fs from 'node:fs';
import http from 'node:http';
import http2 from 'node:http2';
import os from 'node:os'; import os from 'node:os';
import path from 'node:path'; import path from 'node:path';
import test from 'node:test'; 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.APP_MODE = 'gateway';
process.env.DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), 'vpn-proxy-gateway-test-')); 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_CACHE = path.join(process.env.DATA_DIR, 'cache.db');
process.env.SING_BOX_TRAFFIC_SOURCE = 'snapshot';
const { const {
buildDualChannelGatewayConfig, buildDualChannelGatewayConfig,
@@ -124,3 +140,257 @@ test('cached dual-channel outbounds must match the applied provider fingerprints
config.outbounds[2].outbounds = ['channel-primary']; config.outbounds[2].outbounds = ['channel-primary'];
assert.equal(dualChannelConfigMatchesApplied(config, applied, 'primary'), false); 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, '../..'); 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) { function listen(server, ...args) {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
server.once('error', reject); server.once('error', reject);
@@ -121,6 +150,7 @@ async function startClientFixture(t, {
config, config,
hostNetwork, hostNetwork,
gatewayPresencePort, gatewayPresencePort,
trafficSource,
}) { }) {
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'harbor-startup-recovery-')); const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'harbor-startup-recovery-'));
const { binDirectory, markerPath } = fakeSingbox(directory); const { binDirectory, markerPath } = fakeSingbox(directory);
@@ -148,6 +178,7 @@ async function startClientFixture(t, {
: hostNetworkPath, : hostNetworkPath,
...(gatewayPresencePort ? { HARBOR_GATEWAY_CONTROL_PORT: String(gatewayPresencePort) } : {}), ...(gatewayPresencePort ? { HARBOR_GATEWAY_CONTROL_PORT: String(gatewayPresencePort) } : {}),
HARBOR_TEST_RUN_MARKER: markerPath, HARBOR_TEST_RUN_MARKER: markerPath,
...(trafficSource ? { SING_BOX_TRAFFIC_SOURCE: trafficSource } : {}),
}, },
stdio: ['ignore', 'ignore', 'pipe'], 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); 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) => { 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 directory = fs.mkdtempSync(path.join(os.tmpdir(), 'harbor-stopped-remote-'));
const socketPath = path.join(directory, 'dataplane.sock'); 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, expectedRevision: 10,
}), }),
}], }],
[() => api.traffic.live(), '/api/traffic/live', {}],
[() => api.singbox.stop(), '/api/singbox/stop', { method: 'POST' }], [() => api.singbox.stop(), '/api/singbox/stop', { method: 'POST' }],
[() => api.singbox.restart(), '/api/singbox/restart', { method: 'POST' }], [() => api.singbox.restart(), '/api/singbox/restart', { method: 'POST' }],
[() => api.servers.ping('profile-1', ['one', 'two']), '/api/profiles/profile-1/servers/ping', { [() => 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(component, /<SubscriptionToggle[\s\S]*<InstructionsToggle[\s\S]*<DevicesToggle/);
assert.match(subscription, /controls="client-subscription-drawer"/); assert.match(subscription, /controls="client-subscription-drawer"/);
assert.match(component, /<InstructionsToggle[\s\S]*<RoutingToggle/); 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(diagnosticsFeature, /client-diagnostics-toggle/);
assert.match(component, /<ConnectivityDiagnosticsPanel/); assert.match(component, /<ConnectivityDiagnosticsPanel/);
assert.doesNotMatch(component, /diagnosticsToggleRef|diagnosticsPanelRef|diagnosticsCloseRef/); 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(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(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(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(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(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/); 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(instructions, /<Drawer[\s\S]*className="client-instructions"/);
assert.match(routing, /<Drawer[\s\S]*className="client-local-rules"/); assert.match(routing, /<Drawer[\s\S]*className="client-local-rules"/);
assert.match(subscription, /<Drawer[\s\S]*className="client-subscription-drawer"/); 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, /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 \[drawerSwitchTarget, setDrawerSwitchTarget\] = useState<DrawerKey \| null>\(null\)/);
assert.match(component, /const activeRailDrawer = drawerSwitchTarget && drawerControls\[drawerSwitchTarget\]\.isOpen[\s\S]*drawerControls\[drawer\]\.isOpen/); 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/diagnostics.css',
'./features/failover.css', './features/failover.css',
'./features/activity-journal.css', './features/activity-journal.css',
'./features/traffic.css',
'./layout.css', './layout.css',
'./themes.css', './themes.css',
]; ];
const sha256 = (value) => crypto.createHash('sha256').update(value).digest('hex'); const sha256 = (value) => crypto.createHash('sha256').update(value).digest('hex');
const acceptedLedger = { const acceptedLedger = {
counts: { counts: {
cascadeEdges: 1093, cascadeEdges: 1126,
customProperties: 115, customProperties: 115,
declarations: 4433, declarations: 4676,
important: 0, important: 0,
keyframes: 52, keyframes: 55,
media: 19, media: 22,
rules: 1188, rules: 1262,
variableReferences: 1130, variableReferences: 1214,
}, },
hashes: { hashes: {
cascadeEdges: '6676dd45ca19923561ef30b2057a9952a795fd142d83b612aaba70132378e91d', cascadeEdges: '1f3c75839bd37bb312b9aed2987ed61571e5148f8b23c8288424cb193c7a8dda',
customProperties: '81d1e70737ae5a8e4b20d4c1be1b24685975a404863444da4a8821be1f0d5097', customProperties: '81d1e70737ae5a8e4b20d4c1be1b24685975a404863444da4a8821be1f0d5097',
declarations: 'fcecaa0fb211a14826577bf9629e261b0b4b63b4601febebc43e8d757f6a07f1', declarations: '616fc7cb72a9db509f6b41e1808118c2e2be7da6ce4c078dfa2eb4a963f0dbb1',
duplicateKeyframes: '4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945', duplicateKeyframes: '4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945',
duplicateSelectors: '8982145dba05b33bf1c93b2d54cfbe76305b276ff3c657aa020144016ada5848', duplicateSelectors: '8982145dba05b33bf1c93b2d54cfbe76305b276ff3c657aa020144016ada5848',
keyframes: '405688c9a452aa9d54e9c50dd30abb13143fdb5910f63f4474ecefcc800311e6', keyframes: 'b9b0bf3fad92e69b93ec0c24f01da15e78bbe89a095597d64ce4f7ef5e272fdd',
ruleDeclarationSequences: '231bd9880d19c8d29e6d9538ccb934e135bd58b33155175e317b743916d93ef7', ruleDeclarationSequences: '3a65866ea25506c6fd79b68bfbbdeddd495f6957dbd99dec6a814b816ba4cca2',
selectors: '159b53f5cdd6c4aa6d892b9ed314d9a100345290d4c95e31a4c6be98b609c8b6', selectors: '56e1c67b35230649a3c69b2aea67c1929580700f1b4357ee05dfb11012d72655',
variableReferences: 'd8f4abdb1ea6e9077283f342efef0ea920068165d0fc2ac4e7a9a22e16b31461', variableReferences: '71722a81fa16b3586ae9ee5890f79176729dfdf7fe58c4d4994755f8059aa034',
witnesses: 'a84c2ee1c55cb68873f62f230aeec39f88093bcbb50493aaf47d852b13e6985c', 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); const imports = Array.from(index.matchAll(/^@import ['"](\.\/[^'"]+\.css)['"];$/gm), ([, file]) => file);
assert.deepEqual(imports, expectedImports); assert.deepEqual(imports, expectedImports);
assert.equal(index, `${expectedImports.map((file) => `@import '${file}';`).join('\n')}\n`); 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', () => { test('accepted stylesheet has pinned declaration, selector, keyframe, variable, and cascade ledgers', () => {
const witnesses = readStyleWitnesses(root); const witnesses = readStyleWitnesses(root);
assert.equal(witnesses.length, 1209); assert.equal(witnesses.length, 1292);
assert.equal(witnesses.filter((witness) => witness.unknown || witness.ancestorUnknown).length, 0); assert.equal(witnesses.filter((witness) => witness.unknown || witness.ancestorUnknown).length, 0);
const ledger = createStyleLedger(readStyleSource(root), { witnesses }); const ledger = createStyleLedger(readStyleSource(root), { witnesses });
assert.deepEqual(ledger.counts, acceptedLedger.counts); 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); assert.equal((main.match(/import ['"][^'"]+\.css['"]/g) || []).length, 1);
const assets = fs.readdirSync(path.join(root, 'dist/assets')).filter((file) => file.endsWith('.css')); const assets = fs.readdirSync(path.join(root, 'dist/assets')).filter((file) => file.endsWith('.css'));
assert.deepEqual(assets, ['index-fn-ai4xB.css']); assert.deepEqual(assets, ['index-tJmxnB8a.css']);
const built = fs.readFileSync(path.join(root, 'dist/assets', assets[0])); const built = fs.readFileSync(path.join(root, 'dist/assets', assets[0]));
assert.equal(built.byteLength, 168021); assert.equal(built.byteLength, 176543);
assert.equal(sha256(built), 'a160cdc915a4a8e0b95e360ed721e6037cbeda28df4e0815ab6be43f1004176d'); 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(/<Tooltip\b/g) || []).length, 16);
assert.equal((production.match(/<CopyButton\b/g) || []).length, 2); assert.equal((production.match(/<CopyButton\b/g) || []).length, 2);
assert.equal((production.match(/<RailAction\b/g) || []).length, 7); assert.equal((production.match(/<RailAction\b/g) || []).length, 8);
assert.equal((production.match(/<Drawer\b/g) || []).length, 7); assert.equal((production.match(/<Drawer\b/g) || []).length, 8);
assert.doesNotMatch(production, /className="client-tooltip"|className="client-copy-label"|className="client-drawer-close"/); 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/activity-journal/activityJournalModel.ts",
"src/web/features/instructions/prometheus.ts", "src/web/features/instructions/prometheus.ts",
"src/web/features/routing/ruleReorderModel.ts", "src/web/features/routing/ruleReorderModel.ts",
"src/web/features/traffic/trafficRows.ts",
"monitoring/grafana/harbor-gateway.json" "monitoring/grafana/harbor-gateway.json"
], ],
"exclude": [".test-dist", "dist", "node_modules"] "exclude": [".test-dist", "dist", "node_modules"]