Compare commits
2
Commits
f4882c53c2
...
bdf3f22b12
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bdf3f22b12 | ||
|
|
4d066cb879 |
+2
-1
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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,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
|
||||||
|
|||||||
@@ -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`, если хотите сохранить тестовые подписки и настройки.
|
||||||
|
|
||||||
## Служебные команды
|
## Служебные команды
|
||||||
|
|
||||||
Этот раздел нужен тем, кто собирает, проверяет или развёртывает сам проект. Для обычного использования он не требуется.
|
Этот раздел нужен тем, кто собирает, проверяет или развёртывает сам проект. Для обычного использования он не требуется.
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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,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}"
|
||||||
|
|||||||
Generated
+241
@@ -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,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;
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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}')}"
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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 });
|
||||||
|
|||||||
@@ -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) => {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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}`);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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 };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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 },
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
export const HARBOR_VERSIONS = Object.freeze({
|
export const HARBOR_VERSIONS = Object.freeze({
|
||||||
macClient: '0.33.1',
|
macClient: '0.34.0',
|
||||||
gatewayClient: '0.34.1',
|
gatewayClient: '0.36.0',
|
||||||
gatewayBackend: '0.34.0',
|
gatewayBackend: '0.36.0',
|
||||||
});
|
});
|
||||||
|
|
||||||
export interface ParsedVersion {
|
export interface ParsedVersion {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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' }),
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
export {
|
||||||
|
TrafficPanel,
|
||||||
|
TrafficToggle,
|
||||||
|
useTrafficFeature,
|
||||||
|
type TrafficFeature,
|
||||||
|
} from './TrafficFeature.js';
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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';
|
||||||
|
|||||||
@@ -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));
|
||||||
|
|||||||
@@ -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"/);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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('/', '\\/')}`));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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 () => {
|
||||||
|
|||||||
@@ -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 \}\}/);
|
||||||
|
|||||||
@@ -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', () => {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
|||||||
@@ -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/);
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
@@ -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',
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -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() {
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
@@ -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');
|
||||||
|
|||||||
@@ -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', {
|
||||||
|
|||||||
@@ -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/);
|
||||||
|
});
|
||||||
@@ -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/);
|
||||||
|
|||||||
@@ -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: 1120,
|
cascadeEdges: 1126,
|
||||||
customProperties: 115,
|
customProperties: 115,
|
||||||
declarations: 4455,
|
declarations: 4676,
|
||||||
important: 0,
|
important: 0,
|
||||||
keyframes: 52,
|
keyframes: 55,
|
||||||
media: 19,
|
media: 22,
|
||||||
rules: 1193,
|
rules: 1262,
|
||||||
variableReferences: 1138,
|
variableReferences: 1214,
|
||||||
},
|
},
|
||||||
hashes: {
|
hashes: {
|
||||||
cascadeEdges: 'd8eeaf20637638f97dd826ae7486469367fb7f5c7e2d32a6fb1d64647e8b52e0',
|
cascadeEdges: '1f3c75839bd37bb312b9aed2987ed61571e5148f8b23c8288424cb193c7a8dda',
|
||||||
customProperties: '81d1e70737ae5a8e4b20d4c1be1b24685975a404863444da4a8821be1f0d5097',
|
customProperties: '81d1e70737ae5a8e4b20d4c1be1b24685975a404863444da4a8821be1f0d5097',
|
||||||
declarations: '89b645d94c44f0dcfea1a7c5f653a8124f0c87057a9898437419ddce813271d2',
|
declarations: '616fc7cb72a9db509f6b41e1808118c2e2be7da6ce4c078dfa2eb4a963f0dbb1',
|
||||||
duplicateKeyframes: '4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945',
|
duplicateKeyframes: '4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945',
|
||||||
duplicateSelectors: '8982145dba05b33bf1c93b2d54cfbe76305b276ff3c657aa020144016ada5848',
|
duplicateSelectors: '8982145dba05b33bf1c93b2d54cfbe76305b276ff3c657aa020144016ada5848',
|
||||||
keyframes: '405688c9a452aa9d54e9c50dd30abb13143fdb5910f63f4474ecefcc800311e6',
|
keyframes: 'b9b0bf3fad92e69b93ec0c24f01da15e78bbe89a095597d64ce4f7ef5e272fdd',
|
||||||
ruleDeclarationSequences: '12a5a2deec8a8521f49551d9a29c1950a01bce225f1b387def47304fcfe1c960',
|
ruleDeclarationSequences: '3a65866ea25506c6fd79b68bfbbdeddd495f6957dbd99dec6a814b816ba4cca2',
|
||||||
selectors: 'fa7cc612da0fc1e3804a884032e9ab99e0bb0776b29a9e40d44a2891de198067',
|
selectors: '56e1c67b35230649a3c69b2aea67c1929580700f1b4357ee05dfb11012d72655',
|
||||||
variableReferences: '1cbaf891cd2df7c91003d8879a205075211678ad40a817b746c4dde54830ad6d',
|
variableReferences: '71722a81fa16b3586ae9ee5890f79176729dfdf7fe58c4d4994755f8059aa034',
|
||||||
witnesses: 'bae0329346060aeeef91dee449ccee7187e68501c0b5b082026312e7c21a4798',
|
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, 1198);
|
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-C2yOT86L.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, 169625);
|
assert.equal(built.byteLength, 176543);
|
||||||
assert.equal(sha256(built), 'a9bb2c83ab62798e762f4338be1869a6d29366903f83f90d4abcaa2855e44d8e');
|
assert.equal(sha256(built), '38a283204c656164c38c17aa03f41c931544e05b5732248807d911b2645bf970');
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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"/);
|
||||||
});
|
});
|
||||||
|
|||||||
Executable
+123
@@ -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'
|
||||||
Executable
+132
@@ -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
|
||||||
Executable
+298
@@ -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
|
||||||
@@ -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"]
|
||||||
|
|||||||
Reference in New Issue
Block a user