diff --git a/.env.example b/.env.example index 305765c..955ec04 100644 --- a/.env.example +++ b/.env.example @@ -4,12 +4,13 @@ CLIENT_UI_PORT=3456 CLIENT_PROXY_PORT=8082 HARBOR_GATEWAY_CONTROL_PORT=3456 BASE_IMAGE=debian:bookworm-slim -SINGBOX_VERSION=1.13.18 +SINGBOX_VERSION=1.14.0-rc.5 INSTALL_RUNTIME_DEPS=true INSTALL_SINGBOX=true PROXY_PORT=8080 PROXY_BIND_IP=0.0.0.0 SING_BOX_API_PORT=19090 +SING_BOX_TRAFFIC_SOURCE=snapshot TPROXY_PORT=7895 TPROXY_MARK=1 TPROXY_TABLE=100 diff --git a/.gitea/workflows/gateway-build.yml b/.gitea/workflows/gateway-build.yml index edd1ad2..d478bb9 100644 --- a/.gitea/workflows/gateway-build.yml +++ b/.gitea/workflows/gateway-build.yml @@ -18,7 +18,7 @@ env: RUNTIME_BASE_SOURCE_IMAGE: mirror.gcr.io/library/debian:bookworm-slim APT_MIRROR: http://mirror.yandex.ru/debian APT_SECURITY_MIRROR: http://mirror.yandex.ru/debian-security - SINGBOX_VERSION: 1.13.18 + SINGBOX_VERSION: 1.14.0-rc.5 jobs: build-and-push: diff --git a/Dockerfile b/Dockerfile index a50a594..fd162f0 100644 --- a/Dockerfile +++ b/Dockerfile @@ -13,7 +13,7 @@ COPY monitoring/grafana/harbor-gateway.json ./monitoring/grafana/harbor-gateway. RUN npm run build:production FROM ${BASE_IMAGE} -ARG SINGBOX_VERSION=1.13.18 +ARG SINGBOX_VERSION=1.14.0-rc.5 ARG INSTALL_RUNTIME_DEPS=true ARG INSTALL_SINGBOX=true @@ -46,6 +46,9 @@ RUN if [ "${INSTALL_SINGBOX}" = "true" ]; then \ WORKDIR /app COPY --from=build /src/dist /app/dist +COPY --from=build /src/node_modules/@bufbuild/protobuf /app/node_modules/@bufbuild/protobuf +COPY --from=build /src/node_modules/@connectrpc/connect /app/node_modules/@connectrpc/connect +COPY --from=build /src/node_modules/@connectrpc/connect-node /app/node_modules/@connectrpc/connect-node COPY package.json /app/package.json COPY entrypoint.sh /entrypoint.sh @@ -59,6 +62,7 @@ ENV PORT=3456 \ TPROXY_PORT=7895 \ DATA_DIR=/var/lib/vpn-proxy \ SING_BOX_CONFIG=/etc/sing-box/config.json \ - SING_BOX_CACHE=/var/lib/sing-box/cache.db + SING_BOX_CACHE=/var/lib/sing-box/cache.db \ + SING_BOX_TRAFFIC_SOURCE=snapshot ENTRYPOINT ["dumb-init", "/entrypoint.sh"] diff --git a/Dockerfile.client b/Dockerfile.client index a960838..bd44a10 100644 --- a/Dockerfile.client +++ b/Dockerfile.client @@ -13,7 +13,7 @@ COPY monitoring/grafana/harbor-gateway.json ./monitoring/grafana/harbor-gateway. RUN npm run build:production FROM ${RUNTIME_IMAGE} -ARG SINGBOX_VERSION=1.13.18 +ARG SINGBOX_VERSION=1.14.0-rc.5 RUN apt-get update \ && apt-get install -y --no-install-recommends ca-certificates curl dumb-init nodejs tar \ @@ -34,6 +34,9 @@ RUN set -eux; \ WORKDIR /app COPY --from=build /src/dist /app/dist +COPY --from=build /src/node_modules/@bufbuild/protobuf /app/node_modules/@bufbuild/protobuf +COPY --from=build /src/node_modules/@connectrpc/connect /app/node_modules/@connectrpc/connect +COPY --from=build /src/node_modules/@connectrpc/connect-node /app/node_modules/@connectrpc/connect-node COPY package.json /app/package.json COPY entrypoint.client.sh /entrypoint.client.sh @@ -49,6 +52,7 @@ ENV APP_MODE=client \ SING_BOX_CACHE=/var/lib/sing-box/cache.db \ RULE_SET_DOWNLOAD_DETOUR=vpn \ ROUTING_RU_DIRECT=true \ + SING_BOX_TRAFFIC_SOURCE=native \ LOG_LEVEL=info EXPOSE 3456 8082 diff --git a/Dockerfile.runtime-base b/Dockerfile.runtime-base index 3e5c6be..90935b9 100644 --- a/Dockerfile.runtime-base +++ b/Dockerfile.runtime-base @@ -1,6 +1,6 @@ ARG BASE_IMAGE=mirror.gcr.io/library/debian:bookworm-slim FROM ${BASE_IMAGE} -ARG SINGBOX_VERSION=1.13.18 +ARG SINGBOX_VERSION=1.14.0-rc.5 ARG APT_MIRROR=http://mirror.yandex.ru/debian ARG APT_SECURITY_MIRROR=http://mirror.yandex.ru/debian-security ARG HTTP_PROXY diff --git a/README.md b/README.md index 33953ae..ecf05ac 100644 --- a/README.md +++ b/README.md @@ -132,6 +132,8 @@ curl -fsSL https://git.dokops.ru/dokril/vpn-proxy/raw/branch/master/install.sh | Сам по себе локальный прокси не перенаправляет приложения автоматически. Адрес `127.0.0.1:8082` нужно указать в настройках нужного приложения или в системных настройках macOS. +Кнопка «Трафик» в правой панели показывает активные соединения, которые прошли через Harbor Connect. Данные о приложениях macOS недоступны, потому что sing-box работает внутри Docker. + ### Другие порты Передайте нужные значения при повторном запуске установщика: @@ -269,12 +271,23 @@ curl -fsSL https://git.dokops.ru/dokril/vpn-proxy/raw/branch/master/install.sh | | `PROXY_ALLOWED_CIDRS` | приватные IPv4-сети | Сети, которым разрешён доступ к Gateway Proxy | | `GATEWAY_CLIENT_CIDRS` | приватные IPv4-сети | Сети, трафик которых Gateway может маршрутизировать | | `DIRECT_TRAFFIC_MARK` | `0x40000000` | Зарезервированный одиночный connmark-бит учёта Direct; измените при конфликте с host QoS/firewall, не пересекаясь с `TPROXY_MARK` | +| `SING_BOX_TRAFFIC_SOURCE` | `snapshot` | Источник Gateway traffic counters: `snapshot`, `shadow` или `native` | | `LOG_LEVEL` | `info` | Уровень подробности журнала | Остальные значения в `.env.example` относятся к сборке контейнера и внутренней маршрутизации. Меняйте их только при нестандартном развёртывании. После изменения `.env` пересоздайте контейнер командой `up -d` — обычного `restart` недостаточно. +`snapshot` сохраняет прежний опрос Clash API раз в 2 секунды. `shadow` дополнительно читает native lifecycle, но оставляет snapshot единственным источником публичных totals. `native` делает lifecycle единственным writer и не опрашивает `/connections`; Clash API остаётся только для selector/failover. Режим меняется только при пересоздании обоих Gateway-контейнеров и не переключается автоматически при ошибке. + +Rollback сохраняет volumes и возвращает прежний writer: + +```bash +SINGBOX_VERSION=1.13.18 \ +SING_BOX_TRAFFIC_SOURCE=snapshot \ +docker compose -f docker-compose.gateway.yml up -d --build +``` + ## Prometheus и Grafana Gateway публикует уже накопленные Harbor traffic counters по адресу `http://:3456/metrics`. Scrape не запускает дополнительный netfilter read и не меняет сохранённое состояние. Harbor обновляет snapshot раз в 15 секунд, поэтому рекомендуемый начальный scrape interval и refresh dashboard — 30 секунд: @@ -295,9 +308,11 @@ scrape_configs: Dashboard начинает со скорости скачивания и отправки в конце выбранного периода, общего трафика и VPN / Direct внутри sing-box за этот период. Для стандартного диапазона, который заканчивается сейчас, карточки скорости показывают текущее значение. Единый фильтр `Устройства` по умолчанию охватывает все устройства, но позволяет выбрать одно; он управляет скоростью, общим трафиком, маршрутами, сервисами, доменами и технической детализацией. Таблица «Все устройства за период» намеренно остаётся общей: она показывает все устройства с ненулевым трафиком, сортируется в обе стороны и выбирает устройство в том же фильтре. Блок «Куда уходит трафик» показывает основные назначения и Top-15 доменов без пагинации. Свёрнутая техническая детализация отдельно показывает точки входа Gateway / Proxy и Direct IPv4 мимо sing-box. Автообновление настроено на 30 секунд; индикатор показывает возраст самого старого из контуров общего, domain / sing-box и Direct IPv4 трафика, предупреждает после 60 секунд и считает данные устаревшими после 120 секунд. -Domain и sing-box outbound counters снимаются с активных соединений раз в 2 секунды и хранятся в памяти dataplane до его перезапуска; историю и retention хранит Prometheus. Перед routing sing-box до 1 секунды распознаёт HTTP Host, TLS SNI и QUIC Server Name. YouTube и OpenAI / ChatGPT объединяются по известным связанным доменам в label `service`, остальные значения сохраняют домен как имя сервиса. Если устройство и Harbor source известны, но hostname недоступен (например, ECH или IP-only), трафик попадает в `domain="_unknown",service="Не распознано"` и не теряется. Новые domain series сверх process limit складываются в `_other`. +В `snapshot` и `shadow` domain и sing-box outbound counters снимаются с активных соединений раз в 2 секунды. В `native` dataplane получает полный lifecycle, включая короткие соединения и финальный хвост; данные всё равно хранятся в памяти только до перезапуска, а историю и retention хранит Prometheus. Перед routing sing-box до 1 секунды распознаёт HTTP Host, TLS SNI и QUIC Server Name. YouTube и OpenAI / ChatGPT объединяются по известным связанным доменам в label `service`, остальные значения сохраняют домен как имя сервиса. Если устройство и Harbor source известны, но hostname недоступен (например, ECH или IP-only), трафик попадает в `domain="_unknown",service="Не распознано"` и не теряется. Новые domain series сверх process limit складываются в `_other`. -Direct IPv4 считает L3 packet bytes с IP-заголовками и retransmit, а sing-box tracker — логические TCP/UDP bytes без tunnel overhead. Эти семейства нельзя складывать в один «точный общий трафик». Snapshot polling может пропустить короткие соединения и финальный хвост; IPv6, трафик вне Gateway, назначения из `BYPASS_CIDRS` и quota провайдера не входят в новый route split. +Состояние collector и сравнение `shadow` экспортируются отдельными bounded gauges `harbor_traffic_collector_*` и `harbor_traffic_shadow_*`. Они не содержат UUID, IP, домены или пользовательские имена и не заменяют canonical traffic counters. + +Direct IPv4 считает L3 packet bytes с IP-заголовками и retransmit, а sing-box tracker — логические TCP/UDP bytes без tunnel overhead. Эти семейства нельзя складывать в один «точный общий трафик». Snapshot polling может пропустить короткие соединения и финальный хвост; native lifecycle закрывает этот разрыв только для трафика, вошедшего в sing-box. IPv6, трафик вне Gateway, назначения из `BYPASS_CIDRS` и quota провайдера не входят в новый route split. Готовый dashboard: [`monitoring/grafana/harbor-gateway.json`](monitoring/grafana/harbor-gateway.json). При импорте Grafana попросит выбрать Prometheus data source. Та же конфигурация и dashboard доступны для копирования в Gateway drawer «Как использовать» → «Prometheus и Grafana». @@ -344,14 +359,24 @@ docker compose -f docker-compose.client.local.yml config docker compose -f docker-compose.client.local.yml up -d --build ``` -Интерфейс доступен на `http://127.0.0.1:3457`, HTTP/SOCKS5-прокси — на `127.0.0.1:8083`. Остановить и удалить только тестовый стек можно командой: +Интерфейс доступен на `http://127.0.0.1:3457`, HTTP/SOCKS5-прокси — на `127.0.0.1:8083`. Остановить тестовый стек с сохранением его volumes можно командой: ```bash -docker compose -f docker-compose.client.local.yml down -v +docker compose -f docker-compose.client.local.yml down ``` Порты можно заменить через `LOCAL_CLIENT_UI_PORT` и `LOCAL_CLIENT_PROXY_PORT`. +Для rollback canary на стабильный sing-box без инспектора используйте: + +```bash +SINGBOX_VERSION=1.13.18 \ +SING_BOX_TRAFFIC_SOURCE=disabled \ +docker compose -f docker-compose.client.local.yml up -d --build +``` + +Не добавляйте `-v` к `down`, если хотите сохранить тестовые подписки и настройки. + ## Служебные команды Этот раздел нужен тем, кто собирает, проверяет или развёртывает сам проект. Для обычного использования он не требуется. diff --git a/buf.gen.yaml b/buf.gen.yaml new file mode 100644 index 0000000..87e1f28 --- /dev/null +++ b/buf.gen.yaml @@ -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 diff --git a/docker-compose.client.yml b/docker-compose.client.yml index 14e33b4..e496512 100644 --- a/docker-compose.client.yml +++ b/docker-compose.client.yml @@ -4,7 +4,7 @@ services: context: . dockerfile: Dockerfile.client args: - SINGBOX_VERSION: ${SINGBOX_VERSION:-1.13.18} + SINGBOX_VERSION: ${SINGBOX_VERSION:-1.14.0-rc.5} container_name: harbor-connect environment: APP_MODE: client @@ -14,6 +14,7 @@ services: DATA_DIR: /var/lib/vpn-proxy SING_BOX_CONFIG: /etc/sing-box/config.json SING_BOX_CACHE: /var/lib/sing-box/cache.db + SING_BOX_TRAFFIC_SOURCE: ${SING_BOX_TRAFFIC_SOURCE:-native} HARBOR_HOST_NETWORK_STATE: /run/harbor-host/network.json HARBOR_GATEWAY_CONTROL_PORT: ${HARBOR_GATEWAY_CONTROL_PORT:-3456} LOG_LEVEL: ${LOG_LEVEL:-info} diff --git a/docker-compose.gateway.yml b/docker-compose.gateway.yml index fe7ccc0..98b0a28 100644 --- a/docker-compose.gateway.yml +++ b/docker-compose.gateway.yml @@ -5,7 +5,7 @@ x-gateway-image: &gateway-image dockerfile: Dockerfile args: BASE_IMAGE: ${BASE_IMAGE:-debian:bookworm-slim} - SINGBOX_VERSION: ${SINGBOX_VERSION:-1.13.18} + SINGBOX_VERSION: ${SINGBOX_VERSION:-1.14.0-rc.5} INSTALL_RUNTIME_DEPS: ${INSTALL_RUNTIME_DEPS:-true} INSTALL_SINGBOX: ${INSTALL_SINGBOX:-true} @@ -25,6 +25,9 @@ services: DATA_DIR: /var/lib/vpn-proxy SING_BOX_CONFIG: /var/lib/vpn-proxy/sing-box-config.json SING_BOX_CACHE: /var/lib/sing-box/cache.db + SING_BOX_TRAFFIC_SOURCE: ${SING_BOX_TRAFFIC_SOURCE:-snapshot} + SING_BOX_API_SECRET: /var/lib/sing-box/api.secret + SING_BOX_RUNTIME_CONFIG: /var/lib/sing-box/runtime-config.json DATAPLANE_SOCKET: /run/vpn-proxy/dataplane.sock volumes: - vpn-proxy-data:/var/lib/vpn-proxy @@ -49,6 +52,7 @@ services: DATA_DIR: /var/lib/vpn-proxy SING_BOX_CONFIG: /var/lib/vpn-proxy/sing-box-config.json SING_BOX_CACHE: /var/lib/sing-box/cache.db + SING_BOX_TRAFFIC_SOURCE: ${SING_BOX_TRAFFIC_SOURCE:-snapshot} DATAPLANE_SOCKET: /run/vpn-proxy/dataplane.sock ports: - "${PORT:-3456}:${PORT:-3456}" diff --git a/package-lock.json b/package-lock.json index 4124567..9885962 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,6 +8,9 @@ "name": "vpn-proxy-gateway", "version": "0.1.0", "dependencies": { + "@bufbuild/protobuf": "2.6.0", + "@connectrpc/connect": "2.0.3", + "@connectrpc/connect-node": "2.0.3", "@vitejs/plugin-react": "^5.0.0", "react": "^19.0.0", "react-dom": "^19.0.0", @@ -15,6 +18,8 @@ }, "devDependencies": { "@babel/parser": "7.29.3", + "@bufbuild/buf": "1.47.2", + "@bufbuild/protoc-gen-es": "2.6.0", "@csstools/selector-specificity": "6.0.0", "@types/node": "22.19.17", "@types/node18": "npm:@types/node@18.19.130", @@ -288,6 +293,229 @@ "node": ">=6.9.0" } }, + "node_modules/@bufbuild/buf": { + "version": "1.47.2", + "resolved": "https://registry.npmjs.org/@bufbuild/buf/-/buf-1.47.2.tgz", + "integrity": "sha512-glY5kCAoO4+a7HvDb+BLOdoHSdCk4mdXdkp53H8JFz7maOnkxCiHHXgRX+taFyEu25N8ybn7NjZFrZSdRwq2sA==", + "dev": true, + "hasInstallScript": true, + "license": "Apache-2.0", + "bin": { + "buf": "bin/buf", + "protoc-gen-buf-breaking": "bin/protoc-gen-buf-breaking", + "protoc-gen-buf-lint": "bin/protoc-gen-buf-lint" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@bufbuild/buf-darwin-arm64": "1.47.2", + "@bufbuild/buf-darwin-x64": "1.47.2", + "@bufbuild/buf-linux-aarch64": "1.47.2", + "@bufbuild/buf-linux-armv7": "1.47.2", + "@bufbuild/buf-linux-x64": "1.47.2", + "@bufbuild/buf-win32-arm64": "1.47.2", + "@bufbuild/buf-win32-x64": "1.47.2" + } + }, + "node_modules/@bufbuild/buf-darwin-arm64": { + "version": "1.47.2", + "resolved": "https://registry.npmjs.org/@bufbuild/buf-darwin-arm64/-/buf-darwin-arm64-1.47.2.tgz", + "integrity": "sha512-74WerFn06y+azgVfsnzhfbI5wla/OLPDnIvaNJBWHaqya/3bfascJkDylW2GVNHmwG1K/cscpmcc/RJPaO7ntQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@bufbuild/buf-darwin-x64": { + "version": "1.47.2", + "resolved": "https://registry.npmjs.org/@bufbuild/buf-darwin-x64/-/buf-darwin-x64-1.47.2.tgz", + "integrity": "sha512-adAiOacOQe8Ym/YXPCEiq9mrPeKRmDtF2TgqPWTcDy6mF7TqR7hMJINkEEuMd1EeACmXnzMOnXlm9ICtvdYgPg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@bufbuild/buf-linux-aarch64": { + "version": "1.47.2", + "resolved": "https://registry.npmjs.org/@bufbuild/buf-linux-aarch64/-/buf-linux-aarch64-1.47.2.tgz", + "integrity": "sha512-52vY+Owffr5diw2PyfQJqH+Fld6zW6NhNZak4zojvc2MjZKubWM0TfNyM9jXz2YrwyB+cyxkabE60nBI80m37w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@bufbuild/buf-linux-armv7": { + "version": "1.47.2", + "resolved": "https://registry.npmjs.org/@bufbuild/buf-linux-armv7/-/buf-linux-armv7-1.47.2.tgz", + "integrity": "sha512-g9KtpObDeHZ/VG/0b5ZCieOao7L/WYZ0fPqFSs4N07D3APgEDhJG6vLyUcDgJMDgyLcgkNjNz0+XdYQb/tXyQw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@bufbuild/buf-linux-x64": { + "version": "1.47.2", + "resolved": "https://registry.npmjs.org/@bufbuild/buf-linux-x64/-/buf-linux-x64-1.47.2.tgz", + "integrity": "sha512-MODCK2BzD1Mgoyr+5Sp8xA8qMNdytj8hYheyhA5NnCGTkQf8sfqAjpBSAAmKk6Zar8HOlVXML6tzE/ioDFFGwQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@bufbuild/buf-win32-arm64": { + "version": "1.47.2", + "resolved": "https://registry.npmjs.org/@bufbuild/buf-win32-arm64/-/buf-win32-arm64-1.47.2.tgz", + "integrity": "sha512-563YKYWJl3LrCY3G3+zuhb8HwOs6DzWslwGPFkKV2hwHyWyvd1DR1JjiLvw9zX64IKNctQ0HempSqc3kcboaqQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@bufbuild/buf-win32-x64": { + "version": "1.47.2", + "resolved": "https://registry.npmjs.org/@bufbuild/buf-win32-x64/-/buf-win32-x64-1.47.2.tgz", + "integrity": "sha512-Sqcdv7La2xBDh3bTdEYb2f4UTMMqCcYe/D0RELhvQ5wDn6I35V3/2YT1OF5fRuf0BZLCo0OdO37S9L47uHSz2g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@bufbuild/protobuf": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@bufbuild/protobuf/-/protobuf-2.6.0.tgz", + "integrity": "sha512-6cuonJVNOIL7lTj5zgo/Rc2bKAo4/GvN+rKCrUj7GdEHRzCk8zKOfFwUsL9nAVk5rSIsRmlgcpLzTRysopEeeg==", + "license": "(Apache-2.0 AND BSD-3-Clause)" + }, + "node_modules/@bufbuild/protoc-gen-es": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@bufbuild/protoc-gen-es/-/protoc-gen-es-2.6.0.tgz", + "integrity": "sha512-sKvgGndyw1stawiDKMLZyilj1BzMuUTlvyrBiDnzxGIjCMK4hoE0DsVBiqCuTFqENnLmEGdy+huOZ5KgQAGlFA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@bufbuild/protobuf": "^2.6.0", + "@bufbuild/protoplugin": "2.6.0" + }, + "bin": { + "protoc-gen-es": "bin/protoc-gen-es" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@bufbuild/protobuf": "2.6.0" + }, + "peerDependenciesMeta": { + "@bufbuild/protobuf": { + "optional": true + } + } + }, + "node_modules/@bufbuild/protoplugin": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@bufbuild/protoplugin/-/protoplugin-2.6.0.tgz", + "integrity": "sha512-mfAwI+4GqUtbw/ddfyolEHaAL86ozRIVlOg2A+SVRbjx1CjsMc1YJO+hBSkt/pqfpR+PmWBbZLstHbXP8KGtMQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@bufbuild/protobuf": "2.6.0", + "@typescript/vfs": "^1.5.2", + "typescript": "5.4.5" + } + }, + "node_modules/@bufbuild/protoplugin/node_modules/typescript": { + "version": "5.4.5", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.4.5.tgz", + "integrity": "sha512-vcI4UpRgg81oIRUFwR0WSIHKt11nJ7SAVlYNIu+QpqeyXP+gpQJy/Z4+F0aGxSE4MqwjyXvW/TzgkLAx2AGHwQ==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/@connectrpc/connect": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@connectrpc/connect/-/connect-2.0.3.tgz", + "integrity": "sha512-jAbVMHVtDCydGt2P20VpmLjbLtERqSV0RMSyQF3k2zhK8pzQ2QaCAcyVhufClqrOAFZUKL5BqVYtttaxvhmRgg==", + "license": "Apache-2.0", + "peerDependencies": { + "@bufbuild/protobuf": "^2.2.0" + } + }, + "node_modules/@connectrpc/connect-node": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@connectrpc/connect-node/-/connect-node-2.0.3.tgz", + "integrity": "sha512-GZ8WXBCeoZY31wzmnrrV4IA0nvYzEwqt9yHg304b7y/ovKh0IEbBuSWbee/hJu2Tt7PD0C8D4WUwheECCeLpQA==", + "license": "Apache-2.0", + "engines": { + "node": ">=18.14.1" + }, + "peerDependencies": { + "@bufbuild/protobuf": "^2.2.0", + "@connectrpc/connect": "2.0.3" + } + }, "node_modules/@csstools/selector-specificity": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/@csstools/selector-specificity/-/selector-specificity-6.0.0.tgz", @@ -1538,6 +1766,19 @@ "node": ">=16.20.0" } }, + "node_modules/@typescript/vfs": { + "version": "1.6.4", + "resolved": "https://registry.npmjs.org/@typescript/vfs/-/vfs-1.6.4.tgz", + "integrity": "sha512-PJFXFS4ZJKiJ9Qiuix6Dz/OwEIqHD7Dme1UwZhTK11vR+5dqW2ACbdndWQexBzCx+CPuMe5WBYQWCsFyGlQLlQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.4.3" + }, + "peerDependencies": { + "typescript": "*" + } + }, "node_modules/@vitejs/plugin-react": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-5.2.0.tgz", diff --git a/package.json b/package.json index dfc3ac0..52c0383 100644 --- a/package.json +++ b/package.json @@ -6,6 +6,7 @@ "description": "Gateway-first VPN proxy control panel for sing-box TProxy deployments.", "scripts": { "dev": "vite --host 0.0.0.0", + "generate:singbox-api": "XDG_CACHE_HOME=${TMPDIR:-/tmp}/harbor-buf-cache buf generate --template buf.gen.yaml", "build": "vite build", "build:production": "npm run build && npm run build:server", "build:server": "tsc -p tsconfig.server.json", @@ -18,6 +19,9 @@ "start": "node dist/server/main.js" }, "dependencies": { + "@bufbuild/protobuf": "2.6.0", + "@connectrpc/connect": "2.0.3", + "@connectrpc/connect-node": "2.0.3", "@vitejs/plugin-react": "^5.0.0", "react": "^19.0.0", "react-dom": "^19.0.0", @@ -25,6 +29,8 @@ }, "devDependencies": { "@babel/parser": "7.29.3", + "@bufbuild/buf": "1.47.2", + "@bufbuild/protoc-gen-es": "2.6.0", "@csstools/selector-specificity": "6.0.0", "@types/node": "22.19.17", "@types/node18": "npm:@types/node@18.19.130", diff --git a/proto/sing-box/v1.14.0-rc.5/daemon/started_service.proto b/proto/sing-box/v1.14.0-rc.5/daemon/started_service.proto new file mode 100644 index 0000000..2c27937 --- /dev/null +++ b/proto/sing-box/v1.14.0-rc.5/daemon/started_service.proto @@ -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 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; +} diff --git a/scripts/build-on-107-deploy-111.sh b/scripts/build-on-107-deploy-111.sh index f6fe66a..52cf1b9 100755 --- a/scripts/build-on-107-deploy-111.sh +++ b/scripts/build-on-107-deploy-111.sh @@ -12,7 +12,7 @@ GATEWAY_IMAGE="${GATEWAY_IMAGE:-${IMAGE_NAME}:${IMAGE_TAG}}" BASE_IMAGE="${BASE_IMAGE:-vpn-proxy-runtime-base:bookworm-slim}" NODE_BUILD_IMAGE="${NODE_BUILD_IMAGE:-node:20.19-alpine}" RUNTIME_BASE_SOURCE_IMAGE="${RUNTIME_BASE_SOURCE_IMAGE:-mirror.gcr.io/library/debian:bookworm-slim}" -SINGBOX_VERSION="${SINGBOX_VERSION:-1.13.18}" +SINGBOX_VERSION="${SINGBOX_VERSION:-1.14.0-rc.5}" DOCKER_BUILD_PULL="${DOCKER_BUILD_PULL:-false}" INSTALL_RUNTIME_DEPS="${INSTALL_RUNTIME_DEPS:-false}" INSTALL_SINGBOX="${INSTALL_SINGBOX:-false}" @@ -63,7 +63,7 @@ else fi echo "Building image on ${BUILD_HOST}" -BUILD_COMMAND="set -e; echo 'Docker context:' \$(docker context show 2>/dev/null || true); docker info 2>/dev/null | sed -n '/HTTP Proxy:/p;/HTTPS Proxy:/p;/Name:/p'; cd '${BUILD_PATH}'; if ! docker image inspect '${BASE_IMAGE}' >/dev/null 2>&1; then if [ '${AUTO_BUILD_RUNTIME_BASE}' = 'true' ]; then echo 'Runtime base image ${BASE_IMAGE} is missing on ${BUILD_HOST}; building it now.'; BASE_IMAGE='${RUNTIME_BASE_SOURCE_IMAGE}' RUNTIME_BASE_IMAGE='${BASE_IMAGE}' SINGBOX_VERSION='${SINGBOX_VERSION}' ./scripts/build-runtime-base.sh; else echo 'Runtime base image ${BASE_IMAGE} is missing on ${BUILD_HOST}.'; echo 'Seed it once with: ./scripts/build-runtime-base.sh'; exit 1; fi; fi; npm ci && npm run build:production && docker build --pull='${DOCKER_BUILD_PULL}' --build-arg NODE_BUILD_IMAGE='${NODE_BUILD_IMAGE}' --build-arg BASE_IMAGE='${BASE_IMAGE}' --build-arg SINGBOX_VERSION='${SINGBOX_VERSION}' --build-arg INSTALL_RUNTIME_DEPS='${INSTALL_RUNTIME_DEPS}' --build-arg INSTALL_SINGBOX='${INSTALL_SINGBOX}' -t '${GATEWAY_IMAGE}' ." +BUILD_COMMAND="set -e; echo 'Docker context:' \$(docker context show 2>/dev/null || true); docker info 2>/dev/null | sed -n '/HTTP Proxy:/p;/HTTPS Proxy:/p;/Name:/p'; cd '${BUILD_PATH}'; if ! docker image inspect '${BASE_IMAGE}' >/dev/null 2>&1 || ! docker run --rm '${BASE_IMAGE}' sh -lc \"command -v npm >/dev/null && sing-box version 2>&1 | grep -Fx 'sing-box version ${SINGBOX_VERSION}'\"; then if [ '${AUTO_BUILD_RUNTIME_BASE}' = 'true' ]; then echo 'Runtime base image ${BASE_IMAGE} is missing or does not contain sing-box ${SINGBOX_VERSION}; building it now.'; BASE_IMAGE='${RUNTIME_BASE_SOURCE_IMAGE}' RUNTIME_BASE_IMAGE='${BASE_IMAGE}' SINGBOX_VERSION='${SINGBOX_VERSION}' ./scripts/build-runtime-base.sh; else echo 'Runtime base image ${BASE_IMAGE} is missing or does not contain sing-box ${SINGBOX_VERSION} on ${BUILD_HOST}.'; echo 'Seed it once with: ./scripts/build-runtime-base.sh'; exit 1; fi; fi; docker run --rm '${BASE_IMAGE}' sh -lc \"command -v npm >/dev/null && sing-box version 2>&1 | grep -Fx 'sing-box version ${SINGBOX_VERSION}'\"; npm ci && npm run build:production && docker build --pull='${DOCKER_BUILD_PULL}' --build-arg NODE_BUILD_IMAGE='${NODE_BUILD_IMAGE}' --build-arg BASE_IMAGE='${BASE_IMAGE}' --build-arg SINGBOX_VERSION='${SINGBOX_VERSION}' --build-arg INSTALL_RUNTIME_DEPS='${INSTALL_RUNTIME_DEPS}' --build-arg INSTALL_SINGBOX='${INSTALL_SINGBOX}' -t '${GATEWAY_IMAGE}' . && docker run --rm --entrypoint sing-box '${GATEWAY_IMAGE}' version 2>&1 | grep -Fx 'sing-box version ${SINGBOX_VERSION}'" if [ "${BUILD_HOST}" = "local" ]; then bash -lc "${BUILD_COMMAND}" else diff --git a/scripts/build-runtime-base.sh b/scripts/build-runtime-base.sh index 1782dec..7a7f35c 100755 --- a/scripts/build-runtime-base.sh +++ b/scripts/build-runtime-base.sh @@ -3,7 +3,7 @@ set -euo pipefail BASE_IMAGE="${BASE_IMAGE:-mirror.gcr.io/library/debian:bookworm-slim}" RUNTIME_BASE_IMAGE="${RUNTIME_BASE_IMAGE:-vpn-proxy-runtime-base:bookworm-slim}" -SINGBOX_VERSION="${SINGBOX_VERSION:-1.13.18}" +SINGBOX_VERSION="${SINGBOX_VERSION:-1.14.0-rc.5}" APT_MIRROR="${APT_MIRROR:-http://mirror.yandex.ru/debian}" APT_SECURITY_MIRROR="${APT_SECURITY_MIRROR:-http://mirror.yandex.ru/debian-security}" HTTP_PROXY="${HTTP_PROXY:-$(docker info 2>/dev/null | awk -F': ' '/HTTP Proxy:/ {print $2; exit}')}" diff --git a/scripts/install-macos-client.sh b/scripts/install-macos-client.sh index 048b5cd..8fd54fd 100755 --- a/scripts/install-macos-client.sh +++ b/scripts/install-macos-client.sh @@ -9,7 +9,8 @@ COMPOSE_FILE="docker-compose.client.yml" DEFAULT_PROXY_PORT="8082" REQUESTED_PROXY_PORT="${VPN_PROXY_CLIENT_PORT:-}" REQUESTED_UI_PORT="${VPN_PROXY_CLIENT_UI_PORT:-${CLIENT_UI_PORT:-}}" -TARGET_SINGBOX_VERSION="${SINGBOX_VERSION:-1.13.18}" +TARGET_SINGBOX_VERSION="${SINGBOX_VERSION:-1.14.0-rc.5}" +TARGET_TRAFFIC_SOURCE="${SING_BOX_TRAFFIC_SOURCE:-native}" CLIENT_CONTAINER_NAME="harbor-connect" LEGACY_CLIENT_CONTAINER_NAME="vpn-proxy-client" NETWORK_MONITOR_LABEL="com.dokril.harbor-connect.network" @@ -290,6 +291,11 @@ need curl need rsync need tar +case "$TARGET_TRAFFIC_SOURCE" in + native|disabled) ;; + *) die "SING_BOX_TRAFFIC_SOURCE must be native or disabled" ;; +esac + docker compose version >/dev/null 2>&1 || die "Docker Compose plugin is required" docker info >/dev/null 2>&1 || die "Docker Desktop is not running" @@ -321,6 +327,7 @@ assert_ui_outside_proxy_range set_env_value APP_MODE client set_env_value SINGBOX_VERSION "$TARGET_SINGBOX_VERSION" +set_env_value SING_BOX_TRAFFIC_SOURCE "$TARGET_TRAFFIC_SOURCE" set_env_value CLIENT_UI_PORT "$UI_PORT" set_env_value CLIENT_PROXY_PORT "$PROXY_PORT" set_env_value PROXY_PORT "$PROXY_PORT" diff --git a/scripts/runtime-impact.mjs b/scripts/runtime-impact.mjs index fabb770..53deae6 100644 --- a/scripts/runtime-impact.mjs +++ b/scripts/runtime-impact.mjs @@ -19,6 +19,7 @@ const noRuntimeImpact = [ /^entrypoint\.client\.sh$/, /^install\.sh$/, /^scripts\/(?:check-import-boundaries\.mjs|clean-test-dist\.mjs|harbor-network-monitor\.sh|harbor-version\.mjs|install-macos-client\.sh)$/, + /^tools\/test-singbox-(?:client-rc|gateway-native-traffic|native-traffic)\.sh$/, ]; const foundation = [ /^\.dockerignore$/, @@ -32,11 +33,14 @@ const foundation = [ /^tsconfig(?:\.[^.]+)?\.json$/, ]; const controlAndDataplane = [ + /^buf\.gen\.yaml$/, + /^proto\//, new RegExp(`^src/server/main${CODE_EXTENSION}`), - new RegExp(`^src/server/(?:config|gatewayRouting|singbox|singboxRuntime|version)${CODE_EXTENSION}`), + new RegExp(`^src/server/(?:config|gatewayNativeRuntime|gatewayRouting|singbox|singboxRuntime|version)${CODE_EXTENSION}`), + /^src\/server\/generated\//, new RegExp(`^src/server/adapters/neighbors${CODE_EXTENSION}`), - new RegExp(`^src/server/services/(?:connectivityDiagnosticsService|deviceInventoryService|devicePolicyService|singboxSelectorService)${CODE_EXTENSION}`), - new RegExp(`^src/shared/(?:connectivityDiagnostics|errors)${CODE_EXTENSION}`), + new RegExp(`^src/server/services/(?:connectivityDiagnosticsService|deviceInventoryService|devicePolicyService|liveTrafficService|singboxSelectorService)${CODE_EXTENSION}`), + new RegExp(`^src/shared/(?:connectivityDiagnostics|errors|liveTraffic)${CODE_EXTENSION}`), /^src\/server\/infrastructure\/dataplane\//, ]; const dataplane = [ diff --git a/src/server/config.ts b/src/server/config.ts index 2cc78a0..317bc88 100644 --- a/src/server/config.ts +++ b/src/server/config.ts @@ -1,5 +1,7 @@ import path from "node:path"; +const appMode = process.env.APP_MODE === "client" ? "client" : "gateway"; +const appComponent = process.env.APP_COMPONENT || ""; const dataDir = process.env.DATA_DIR || path.resolve(".vpn-proxy"); const parsePort = (value: string | undefined, fallback: number) => { const parsed = Number.parseInt(value || '', 10); @@ -7,17 +9,33 @@ const parsePort = (value: string | undefined, fallback: number) => { }; const proxyPort = parsePort( process.env.PROXY_PORT, - process.env.APP_MODE === "client" ? 8082 : 8080, + appMode === "client" ? 8082 : 8080, ); +const trafficSource = process.env.SING_BOX_TRAFFIC_SOURCE + || (appMode === "client" ? "native" : "snapshot"); +if (appMode === "client" && trafficSource !== "native" && trafficSource !== "disabled") { + throw new Error("SING_BOX_TRAFFIC_SOURCE must be native or disabled in client mode"); +} +if (appMode === "gateway" && !["snapshot", "shadow", "native"].includes(trafficSource)) { + throw new Error("SING_BOX_TRAFFIC_SOURCE must be snapshot, shadow or native in gateway mode"); +} +if (appMode === "gateway" && trafficSource !== "snapshot" + && ((appComponent !== "control" && appComponent !== "dataplane") + || !process.env.DATAPLANE_SOCKET?.trim())) { + throw new Error("Gateway shadow and native traffic modes require split control/dataplane topology"); +} export const settings = { - appMode: process.env.APP_MODE === "client" ? "client" : "gateway", + appMode, + appComponent, port: parsePort(process.env.PORT, 3456), proxyPort, diagnosticsProxyPort: parsePort(process.env.DIAGNOSTICS_PROXY_PORT, 18080), failoverPrimaryProxyPort: parsePort(process.env.FAILOVER_PRIMARY_PROXY_PORT, 18081), failoverReserveProxyPort: parsePort(process.env.FAILOVER_RESERVE_PROXY_PORT, 18082), singboxApiPort: parsePort(process.env.SING_BOX_API_PORT, 19090), + singboxNativeApiPort: 19091, + singboxTrafficSource: trafficSource as "native" | "disabled" | "snapshot" | "shadow", tproxyPort: parsePort(process.env.TPROXY_PORT, 7895), tproxyMark: process.env.TPROXY_MARK || "1", tproxyChain: process.env.TPROXY_CHAIN || "VPN_PROXY_TPROXY", @@ -40,6 +58,10 @@ export const settings = { configPath: process.env.SING_BOX_CONFIG || path.join(dataDir, "sing-box-config.json"), cachePath: process.env.SING_BOX_CACHE || "/var/lib/sing-box/cache.db", + gatewayNativeApiSecretPath: + process.env.SING_BOX_API_SECRET || "/var/lib/sing-box/api.secret", + gatewayRuntimeConfigPath: + process.env.SING_BOX_RUNTIME_CONFIG || "/var/lib/sing-box/runtime-config.json", statePath: path.join(dataDir, "state.json"), deviceStatePath: path.join(dataDir, "devices.json"), activityJournalPath: path.join(dataDir, "activity-journal.json"), diff --git a/src/server/dataplane.ts b/src/server/dataplane.ts index f7f3d43..774e34d 100644 --- a/src/server/dataplane.ts +++ b/src/server/dataplane.ts @@ -1,7 +1,9 @@ import fs from 'node:fs'; import http from 'node:http'; +import net from 'node:net'; import path from 'node:path'; import type { IncomingMessage, ServerResponse } from 'node:http'; +import type { LiveTrafficConnection, LiveTrafficSnapshot } from '../shared/liveTraffic.js'; import { settings } from './config.js'; import { createSingboxRuntime } from './singboxRuntime.js'; import { buildVersionInfo } from './version.js'; @@ -13,13 +15,27 @@ import { createDomainTrafficService, readSingboxConnections, } from './services/domainTrafficService.js'; +import { deviceId } from './services/deviceInventoryService.js'; +import { + createLiveTrafficService, +} from './services/liveTrafficService.js'; import { createSingboxSelectorService } from './services/singboxSelectorService.js'; const socketPath = settings.dataplaneSocket; +const trafficMode = settings.singboxTrafficSource as 'snapshot' | 'shadow' | 'native'; +const nativeTrafficEnabled = trafficMode === 'shadow' || trafficMode === 'native'; const runtime = createSingboxRuntime({ configPath: settings.configPath, gateway: true, tproxyChain: settings.tproxyChain, + gatewayRuntimeConfigPath: settings.gatewayRuntimeConfigPath, + ...(nativeTrafficEnabled ? { + nativeApi: { + apiPort: settings.singboxNativeApiPort, + secretPath: settings.gatewayNativeApiSecretPath, + runtimeConfigPath: settings.gatewayRuntimeConfigPath, + }, + } : {}), }); const versionInfo = buildVersionInfo('gateway'); const traffic = createDeviceTrafficService({ @@ -46,10 +62,23 @@ const failoverDiagnostics = { reserve: createConnectivityDiagnosticsService({ proxyPort: settings.failoverReserveProxyPort }), }; const selector = createSingboxSelectorService({ port: settings.singboxApiPort }); -const domainTraffic = createDomainTrafficService({ +const snapshotDomainTraffic = createDomainTrafficService({ observe: () => readSingboxConnections(settings.singboxApiPort), devices: () => traffic.snapshot().devices, }); +const nativeDomainTraffic = createDomainTrafficService({ + observe: () => ({ connections: [] }), + devices: () => traffic.snapshot().devices, +}); +const domainTraffic = trafficMode === 'native' ? nativeDomainTraffic : snapshotDomainTraffic; +let originsByIp = new Map(); +let liveTraffic = createLiveTrafficService({ + port: settings.singboxNativeApiPort, + enabled: false, + gateway: true, + isRuntimeRunning: () => false, + resolveOrigin, +}); let ready = false; let trafficTimer: NodeJS.Timeout | null = null; let domainTrafficTimer: NodeJS.Timeout | null = null; @@ -65,6 +94,133 @@ function errorMessage(error: unknown) { return error instanceof Error ? error.message : String(error); } +function updateOrigins(devices: unknown) { + const next = new Map(); + 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 { return new Promise((resolve, reject) => { const chunks: Buffer[] = []; @@ -105,6 +261,7 @@ const server = http.createServer(async (req: IncomingMessage, res: ServerRespons gatewayBackendVersion: versionInfo.components.gatewayBackend, singBoxVersion: versionInfo.runtime.singBox, devicePolicy: devicePolicy.snapshot(), + trafficCollector: trafficCollectorSource(), ready, }); } @@ -115,7 +272,10 @@ const server = http.createServer(async (req: IncomingMessage, res: ServerRespons return sendJson(res, 200, traffic.snapshot()); } if (req.method === 'GET' && req.url === '/domain-traffic') { - return sendJson(res, 200, domainTraffic.snapshot()); + return sendJson(res, 200, domainTrafficSnapshot()); + } + if (req.method === 'GET' && req.url === '/traffic/live') { + return sendJson(res, 200, liveTrafficSnapshot()); } if (req.method === 'GET' && req.url === '/device-policy') { return sendJson(res, 200, devicePolicy.snapshot()); @@ -153,7 +313,10 @@ const server = http.createServer(async (req: IncomingMessage, res: ServerRespons } if (req.method === 'POST' && req.url === '/failover/activity/read') { const { thresholdBytesPerSecond = 0 } = record(await readJson(req)); - return sendJson(res, 200, { activity: domainTraffic.activitySnapshot(thresholdBytesPerSecond) }); + const sourceLive = trafficMode !== 'native' || liveTrafficSnapshot().source.state === 'live'; + return sendJson(res, 200, { + activity: sourceLive ? domainTraffic.activitySnapshot(thresholdBytesPerSecond) : null, + }); } if (req.method === 'POST' && req.url === '/config/check') { const { config } = record(await readJson(req)); @@ -183,27 +346,44 @@ server.listen(socketPath, async () => { } catch (error) { console.warn(`[dataplane] sing-box не запущен: ${errorMessage(error)}`); } finally { + refreshOrigins(); + liveTraffic = createLiveTrafficService({ + port: settings.singboxNativeApiPort, + enabled: nativeTrafficEnabled, + gateway: true, + isRuntimeRunning: () => runtime.running && !runtime.nativeApiWarning, + resolveOrigin, + authorization: () => runtime.nativeApiSecret, + onProjection: (batch) => { + nativeDomainTraffic.ingestNative(batch); + }, + }); + liveTraffic.start(); ready = true; if (settings.deviceTrafficAccountingEnabled) { setImmediate(() => { - traffic.refresh() + refreshDeviceTraffic() .catch((error: unknown) => console.warn(`[dataplane] traffic counters не запущены: ${errorMessage(error)}`)); }); trafficTimer = setInterval(() => { - traffic.refresh().catch((error: unknown) => console.warn(`[dataplane] traffic counters не обновлены: ${errorMessage(error)}`)); + refreshDeviceTraffic().catch((error: unknown) => console.warn(`[dataplane] traffic counters не обновлены: ${errorMessage(error)}`)); }, 15_000); trafficTimer.unref(); + } else { + trafficTimer = setInterval(refreshOrigins, 15_000); + trafficTimer.unref(); + } + if (trafficMode !== 'native') { + setImmediate(() => { + snapshotDomainTraffic.refresh() + .catch((error: unknown) => console.warn(`[dataplane] domain traffic не запущен: ${errorMessage(error)}`)); + }); + domainTrafficTimer = setInterval(() => { + snapshotDomainTraffic.refresh() + .catch((error: unknown) => console.warn(`[dataplane] domain traffic не обновлён: ${errorMessage(error)}`)); + }, 2_000); + domainTrafficTimer.unref(); } - setImmediate(() => { - domainTraffic.refresh() - .catch((error: unknown) => console.warn(`[dataplane] domain traffic не запущен: ${errorMessage(error)}`)); - }); - // ponytail: snapshots can miss connections shorter than 2s; switch to an upstream close-event API if sing-box adds one. - domainTrafficTimer = setInterval(() => { - domainTraffic.refresh() - .catch((error: unknown) => console.warn(`[dataplane] domain traffic не обновлён: ${errorMessage(error)}`)); - }, 2_000); - domainTrafficTimer.unref(); console.log(`[dataplane] control socket: ${socketPath}`); } }); @@ -215,6 +395,7 @@ async function shutdown() { ready = false; if (trafficTimer) clearInterval(trafficTimer); if (domainTrafficTimer) clearInterval(domainTrafficTimer); + await liveTraffic.stop(); await runtime.shutdown(); server.close(() => { fs.rmSync(socketPath, { force: true }); diff --git a/src/server/dataplaneClient.ts b/src/server/dataplaneClient.ts index a06e8ad..3b1f876 100644 --- a/src/server/dataplaneClient.ts +++ b/src/server/dataplaneClient.ts @@ -73,6 +73,7 @@ export function createDataplaneClient(socketPath: string, send: SendDataplaneReq observeDevices: () => send(socketPath, '/devices', 'GET'), observeTraffic: () => send(socketPath, '/device-traffic', 'GET'), observeDomainTraffic: () => send(socketPath, '/domain-traffic', 'GET'), + observeLiveTraffic: () => send(socketPath, '/traffic/live', 'GET'), observeDevicePolicy: () => send(socketPath, '/device-policy', 'GET'), applyDevicePolicies: (devices: unknown) => send(socketPath, '/device-policy', 'PUT', { devices }), runConnectivityDiagnostics: async (services: unknown = [], target: unknown = null) => { diff --git a/src/server/gatewayNativeRuntime.ts b/src/server/gatewayNativeRuntime.ts new file mode 100644 index 0000000..af69a08 --- /dev/null +++ b/src/server/gatewayNativeRuntime.ts @@ -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 { + return value && typeof value === 'object' && !Array.isArray(value) + ? value as Record + : {}; +} + +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 }; + } +} diff --git a/src/server/generated/daemon/started_service_pb.ts b/src/server/generated/daemon/started_service_pb.ts new file mode 100644 index 0000000..aa6cb2e --- /dev/null +++ b/src/server/generated/daemon/started_service_pb.ts @@ -0,0 +1,3787 @@ +// @generated by protoc-gen-es v2.6.0 with parameter "target=ts" +// @generated from file daemon/started_service.proto (package daemon, syntax proto3) +/* eslint-disable */ + +import type { GenEnum, GenFile, GenMessage, GenService } from "@bufbuild/protobuf/codegenv2"; +import { enumDesc, fileDesc, messageDesc, serviceDesc } from "@bufbuild/protobuf/codegenv2"; +import type { EmptySchema } from "@bufbuild/protobuf/wkt"; +import { file_google_protobuf_empty } from "@bufbuild/protobuf/wkt"; +import type { Message } from "@bufbuild/protobuf"; + +/** + * Describes the file daemon/started_service.proto. + */ +export const file_daemon_started_service: GenFile = /*@__PURE__*/ + fileDesc("ChxkYWVtb24vc3RhcnRlZF9zZXJ2aWNlLnByb3RvEgZkYWVtb24iLgoHVmVyc2lvbhIPCgd2ZXJzaW9uGAEgASgJEhIKCmFwaVZlcnNpb24YAiABKAUilwEKDVNlcnZpY2VTdGF0dXMSKgoGc3RhdHVzGAEgASgOMhouZGFlbW9uLlNlcnZpY2VTdGF0dXMuVHlwZRIUCgxlcnJvck1lc3NhZ2UYAiABKAkiRAoEVHlwZRIICgRJRExFEAASDAoIU1RBUlRJTkcQARILCgdTVEFSVEVEEAISDAoIU1RPUFBJTkcQAxIJCgVGQVRBTBAEIioKFlN1YnNjcmliZVN0YXR1c1JlcXVlc3QSEAoIaW50ZXJ2YWwYASABKAMieAoDTG9nEiUKCG1lc3NhZ2VzGAEgAygLMhMuZGFlbW9uLkxvZy5NZXNzYWdlEg0KBXJlc2V0GAIgASgIGjsKB01lc3NhZ2USHwoFbGV2ZWwYASABKA4yEC5kYWVtb24uTG9nTGV2ZWwSDwoHbWVzc2FnZRgCIAEoCSIyCg9EZWZhdWx0TG9nTGV2ZWwSHwoFbGV2ZWwYASABKA4yEC5kYWVtb24uTG9nTGV2ZWwiwwEKBlN0YXR1cxIOCgZtZW1vcnkYASABKAQSEgoKZ29yb3V0aW5lcxgCIAEoBRIVCg1jb25uZWN0aW9uc0luGAMgASgFEhYKDmNvbm5lY3Rpb25zT3V0GAQgASgFEhgKEHRyYWZmaWNBdmFpbGFibGUYBSABKAgSDgoGdXBsaW5rGAYgASgDEhAKCGRvd25saW5rGAcgASgDEhMKC3VwbGlua1RvdGFsGAggASgDEhUKDWRvd25saW5rVG90YWwYCSABKAMiJgoGR3JvdXBzEhwKBWdyb3VwGAEgAygLMg0uZGFlbW9uLkdyb3VwInwKBUdyb3VwEgsKA3RhZxgBIAEoCRIMCgR0eXBlGAIgASgJEhIKCnNlbGVjdGFibGUYAyABKAgSEAoIc2VsZWN0ZWQYBCABKAkSEAoIaXNFeHBhbmQYBSABKAgSIAoFaXRlbXMYBiADKAsyES5kYWVtb24uR3JvdXBJdGVtIlEKCUdyb3VwSXRlbRILCgN0YWcYASABKAkSDAoEdHlwZRgCIAEoCRITCgt1cmxUZXN0VGltZRgDIAEoAxIUCgx1cmxUZXN0RGVsYXkYBCABKAUiJQoOVVJMVGVzdFJlcXVlc3QSEwoLb3V0Ym91bmRUYWcYASABKAkiPgoVU2VsZWN0T3V0Ym91bmRSZXF1ZXN0EhAKCGdyb3VwVGFnGAEgASgJEhMKC291dGJvdW5kVGFnGAIgASgJIjsKFVNldEdyb3VwRXhwYW5kUmVxdWVzdBIQCghncm91cFRhZxgBIAEoCRIQCghpc0V4cGFuZBgCIAEoCCIZCglDbGFzaE1vZGUSDAoEbW9kZRgDIAEoCSI4Cg9DbGFzaE1vZGVTdGF0dXMSEAoIbW9kZUxpc3QYASADKAkSEwoLY3VycmVudE1vZGUYAiABKAkiLwobU3Vic2NyaWJlQ29ubmVjdGlvbnNSZXF1ZXN0EhAKCGludGVydmFsGAEgASgDIq4BCg9Db25uZWN0aW9uRXZlbnQSKQoEdHlwZRgBIAEoDjIbLmRhZW1vbi5Db25uZWN0aW9uRXZlbnRUeXBlEgoKAmlkGAIgASgJEiYKCmNvbm5lY3Rpb24YAyABKAsyEi5kYWVtb24uQ29ubmVjdGlvbhITCgt1cGxpbmtEZWx0YRgEIAEoAxIVCg1kb3dubGlua0RlbHRhGAUgASgDEhAKCGNsb3NlZEF0GAYgASgDIkoKEENvbm5lY3Rpb25FdmVudHMSJwoGZXZlbnRzGAEgAygLMhcuZGFlbW9uLkNvbm5lY3Rpb25FdmVudBINCgVyZXNldBgCIAEoCCKzAwoKQ29ubmVjdGlvbhIKCgJpZBgBIAEoCRIPCgdpbmJvdW5kGAIgASgJEhMKC2luYm91bmRUeXBlGAMgASgJEhEKCWlwVmVyc2lvbhgEIAEoBRIPCgduZXR3b3JrGAUgASgJEg4KBnNvdXJjZRgGIAEoCRITCgtkZXN0aW5hdGlvbhgHIAEoCRIOCgZkb21haW4YCCABKAkSEAoIcHJvdG9jb2wYCSABKAkSDAoEdXNlchgKIAEoCRIUCgxmcm9tT3V0Ym91bmQYCyABKAkSEQoJY3JlYXRlZEF0GAwgASgDEhAKCGNsb3NlZEF0GA0gASgDEg4KBnVwbGluaxgOIAEoAxIQCghkb3dubGluaxgPIAEoAxITCgt1cGxpbmtUb3RhbBgQIAEoAxIVCg1kb3dubGlua1RvdGFsGBEgASgDEgwKBHJ1bGUYEiABKAkSEAoIb3V0Ym91bmQYEyABKAkSFAoMb3V0Ym91bmRUeXBlGBQgASgJEhEKCWNoYWluTGlzdBgVIAMoCRIoCgtwcm9jZXNzSW5mbxgWIAEoCzITLmRhZW1vbi5Qcm9jZXNzSW5mbyJtCgtQcm9jZXNzSW5mbxIRCglwcm9jZXNzSWQYASABKA0SDgoGdXNlcklkGAIgASgFEhAKCHVzZXJOYW1lGAMgASgJEhMKC3Byb2Nlc3NQYXRoGAQgASgJEhQKDHBhY2thZ2VOYW1lcxgFIAMoCSIkChZDbG9zZUNvbm5lY3Rpb25SZXF1ZXN0EgoKAmlkGAEgASgJIkEKEkRlcHJlY2F0ZWRXYXJuaW5ncxIrCgh3YXJuaW5ncxgBIAMoCzIZLmRhZW1vbi5EZXByZWNhdGVkV2FybmluZyKYAQoRRGVwcmVjYXRlZFdhcm5pbmcSDwoHbWVzc2FnZRgBIAEoCRIRCglpbXBlbmRpbmcYAiABKAgSFQoNbWlncmF0aW9uTGluaxgDIAEoCRITCgtkZXNjcmlwdGlvbhgEIAEoCRIZChFkZXByZWNhdGVkVmVyc2lvbhgFIAEoCRIYChBzY2hlZHVsZWRWZXJzaW9uGAYgASgJIh4KCVN0YXJ0ZWRBdBIRCglzdGFydGVkQXQYASABKAMiNAoMT3V0Ym91bmRMaXN0EiQKCW91dGJvdW5kcxgBIAMoCzIRLmRhZW1vbi5Hcm91cEl0ZW0ifQoZTmV0d29ya1F1YWxpdHlUZXN0UmVxdWVzdBIRCgljb25maWdVUkwYASABKAkSEwoLb3V0Ym91bmRUYWcYAiABKAkSDgoGc2VyaWFsGAMgASgIEhkKEW1heFJ1bnRpbWVTZWNvbmRzGAQgASgFEg0KBWh0dHAzGAUgASgIIskCChpOZXR3b3JrUXVhbGl0eVRlc3RQcm9ncmVzcxINCgVwaGFzZRgBIAEoBRIYChBkb3dubG9hZENhcGFjaXR5GAIgASgDEhYKDnVwbG9hZENhcGFjaXR5GAMgASgDEhMKC2Rvd25sb2FkUlBNGAQgASgFEhEKCXVwbG9hZFJQTRgFIAEoBRIVCg1pZGxlTGF0ZW5jeU1zGAYgASgFEhEKCWVsYXBzZWRNcxgHIAEoAxIPCgdpc0ZpbmFsGAggASgIEg0KBWVycm9yGAkgASgJEiAKGGRvd25sb2FkQ2FwYWNpdHlBY2N1cmFjeRgKIAEoBRIeChZ1cGxvYWRDYXBhY2l0eUFjY3VyYWN5GAsgASgFEhsKE2Rvd25sb2FkUlBNQWNjdXJhY3kYDCABKAUSGQoRdXBsb2FkUlBNQWNjdXJhY3kYDSABKAUiNgoPU1RVTlRlc3RSZXF1ZXN0Eg4KBnNlcnZlchgBIAEoCRITCgtvdXRib3VuZFRhZxgCIAEoCSKuAQoQU1RVTlRlc3RQcm9ncmVzcxINCgVwaGFzZRgBIAEoBRIUCgxleHRlcm5hbEFkZHIYAiABKAkSEQoJbGF0ZW5jeU1zGAMgASgFEhIKCm5hdE1hcHBpbmcYBCABKAUSFAoMbmF0RmlsdGVyaW5nGAUgASgFEg8KB2lzRmluYWwYBiABKAgSDQoFZXJyb3IYByABKAkSGAoQbmF0VHlwZVN1cHBvcnRlZBgIIAEoCCJLChVUYWlsc2NhbGVTdGF0dXNVcGRhdGUSMgoJZW5kcG9pbnRzGAEgAygLMh8uZGFlbW9uLlRhaWxzY2FsZUVuZHBvaW50U3RhdHVzIp8DChdUYWlsc2NhbGVFbmRwb2ludFN0YXR1cxITCgtlbmRwb2ludFRhZxgBIAEoCRIUCgxiYWNrZW5kU3RhdGUYAiABKAkSEQoJc3RhdGVUZXh0GAMgASgJEg8KB2F1dGhVUkwYBCABKAkSEwoLbmV0d29ya05hbWUYBSABKAkSFgoObWFnaWNETlNTdWZmaXgYBiABKAkSIwoEc2VsZhgHIAEoCzIVLmRhZW1vbi5UYWlsc2NhbGVQZWVyEi4KCnVzZXJHcm91cHMYCCADKAsyGi5kYWVtb24uVGFpbHNjYWxlVXNlckdyb3VwEicKCGV4aXROb2RlGAkgASgLMhUuZGFlbW9uLlRhaWxzY2FsZVBlZXISDwoHa2V5QXV0aBgKIAEoCBIVCg1jYW5TaGFyZUZpbGVzGAsgASgIEhgKEHdhaXRpbmdGaWxlQ291bnQYDCABKAUSGgoScmVjZWl2aW5nRmlsZUNvdW50GA0gASgFEhcKD3VucmVhZEZpbGVDb3VudBgOIAEoBRITCgtjZXJ0RG9tYWlucxgPIAMoCSKJAQoSVGFpbHNjYWxlVXNlckdyb3VwEg4KBnVzZXJJRBgBIAEoAxIRCglsb2dpbk5hbWUYAiABKAkSEwoLZGlzcGxheU5hbWUYAyABKAkSFQoNcHJvZmlsZVBpY1VSTBgEIAEoCRIkCgVwZWVycxgFIAMoCzIVLmRhZW1vbi5UYWlsc2NhbGVQZWVyIsoCCg1UYWlsc2NhbGVQZWVyEhAKCGhvc3ROYW1lGAEgASgJEg8KB2Ruc05hbWUYAiABKAkSCgoCb3MYAyABKAkSFAoMdGFpbHNjYWxlSVBzGAQgAygJEg4KBm9ubGluZRgFIAEoCBIQCghleGl0Tm9kZRgGIAEoCBIWCg5leGl0Tm9kZU9wdGlvbhgHIAEoCBIOCgZhY3RpdmUYCCABKAgSDwoHcnhCeXRlcxgJIAEoAxIPCgd0eEJ5dGVzGAogASgDEhEKCWtleUV4cGlyeRgLIAEoAxIQCghzdGFibGVJRBgMIAEoCRIPCgdleHBpcmVkGA0gASgIEhMKC3NzaEhvc3RLZXlzGA4gAygJEhIKCnNoYXJlZU5vZGUYDyABKAgSEAoIbGFzdFNlZW4YECABKAMSFwoPY2FuUmVjZWl2ZUZpbGVzGBEgASgIIjsKFFRhaWxzY2FsZVBpbmdSZXF1ZXN0EhMKC2VuZHBvaW50VGFnGAEgASgJEg4KBnBlZXJJUBgCIAEoCSKeAQoVVGFpbHNjYWxlUGluZ1Jlc3BvbnNlEhEKCWxhdGVuY3lNcxgBIAEoARIQCghpc0RpcmVjdBgCIAEoCBIQCghlbmRwb2ludBgDIAEoCRIUCgxkZXJwUmVnaW9uSUQYBCABKAUSFgoOZGVycFJlZ2lvbkNvZGUYBSABKAkSDQoFZXJyb3IYBiABKAkSEQoJcGVlclJlbGF5GAcgASgJIkQKG1NldFRhaWxzY2FsZUV4aXROb2RlUmVxdWVzdBITCgtlbmRwb2ludFRhZxgBIAEoCRIQCghzdGFibGVJRBgCIAEoCSItChZUYWlsc2NhbGVMb2dvdXRSZXF1ZXN0EhMKC2VuZHBvaW50VGFnGAEgASgJIl4KG1RhaWxzY2FsZUNlcnRpZmljYXRlUmVxdWVzdBITCgtlbmRwb2ludFRhZxgBIAEoCRIOCgZkb21haW4YAiABKAkSGgoSbWluVmFsaWRpdHlTZWNvbmRzGAMgASgDIkUKFFRhaWxzY2FsZUNlcnRpZmljYXRlEhYKDmNlcnRpZmljYXRlUEVNGAEgASgMEhUKDXByaXZhdGVLZXlQRU0YAiABKAwirAEKGVRhaWxzY2FsZVNTSENsaWVudE1lc3NhZ2USKgoFc3RhcnQYASABKAsyGS5kYWVtb24uVGFpbHNjYWxlU1NIU3RhcnRIABIqCgVpbnB1dBgCIAEoCzIZLmRhZW1vbi5UYWlsc2NhbGVTU0hJbnB1dEgAEiwKBnJlc2l6ZRgDIAEoCzIaLmRhZW1vbi5UYWlsc2NhbGVTU0hSZXNpemVIAEIJCgdtZXNzYWdlItgBChFUYWlsc2NhbGVTU0hTdGFydBITCgtlbmRwb2ludFRhZxgBIAEoCRITCgtwZWVyQWRkcmVzcxgCIAEoCRIQCgh1c2VybmFtZRgDIAEoCRIUCgx0ZXJtaW5hbFR5cGUYBCABKAkSDwoHY29sdW1ucxgFIAEoBRIMCgRyb3dzGAYgASgFEhMKC3dpZHRoUGl4ZWxzGAcgASgFEhQKDGhlaWdodFBpeGVscxgIIAEoBRIQCghob3N0S2V5cxgJIAMoCRIVCg1mb3J3YXJkX2FnZW50GAogASgIIiEKEVRhaWxzY2FsZVNTSElucHV0EgwKBGRhdGEYASABKAwiXgoSVGFpbHNjYWxlU1NIUmVzaXplEg8KB2NvbHVtbnMYASABKAUSDAoEcm93cxgCIAEoBRITCgt3aWR0aFBpeGVscxgDIAEoBRIUCgxoZWlnaHRQaXhlbHMYBCABKAUijAIKGVRhaWxzY2FsZVNTSFNlcnZlck1lc3NhZ2USNAoKYXV0aEJhbm5lchgBIAEoCzIeLmRhZW1vbi5UYWlsc2NhbGVTU0hBdXRoQmFubmVySAASKgoFcmVhZHkYAiABKAsyGS5kYWVtb24uVGFpbHNjYWxlU1NIUmVhZHlIABIsCgZvdXRwdXQYAyABKAsyGi5kYWVtb24uVGFpbHNjYWxlU1NIT3V0cHV0SAASKAoEZXhpdBgEIAEoCzIYLmRhZW1vbi5UYWlsc2NhbGVTU0hFeGl0SAASKgoFZXJyb3IYBSABKAsyGS5kYWVtb24uVGFpbHNjYWxlU1NIRXJyb3JIAEIJCgdtZXNzYWdlIikKFlRhaWxzY2FsZVNTSEF1dGhCYW5uZXISDwoHbWVzc2FnZRgBIAEoCSITChFUYWlsc2NhbGVTU0hSZWFkeSIiChJUYWlsc2NhbGVTU0hPdXRwdXQSDAoEZGF0YRgBIAEoDCJKChBUYWlsc2NhbGVTU0hFeGl0EhAKCGV4aXRDb2RlGAEgASgFEg4KBnNpZ25hbBgCIAEoCRIUCgxlcnJvck1lc3NhZ2UYAyABKAkiJAoRVGFpbHNjYWxlU1NIRXJyb3ISDwoHbWVzc2FnZRgBIAEoCSI0Ch1TdWJzY3JpYmVUYWlsZHJvcEluYm94UmVxdWVzdBITCgtlbmRwb2ludFRhZxgBIAEoCSIzChxNYXJrVGFpbGRyb3BJbmJveFJlYWRSZXF1ZXN0EhMKC2VuZHBvaW50VGFnGAEgASgJInsKDVRhaWxkcm9wSW5ib3gSEwoLZW5kcG9pbnRUYWcYASABKAkSIwoFZmlsZXMYAiADKAsyFC5kYWVtb24uVGFpbGRyb3BGaWxlEjAKCXJlY2VpdmluZxgDIAMoCzIdLmRhZW1vbi5UYWlsZHJvcFJlY2VpdmluZ0ZpbGUiUgoMVGFpbGRyb3BGaWxlEgwKBG5hbWUYASABKAkSDAoEc2l6ZRgCIAEoAxISCgpzZW5kZXJOYW1lGAMgASgJEhIKCm1vZGlmaWVkQXQYBCABKAMicAoVVGFpbGRyb3BSZWNlaXZpbmdGaWxlEgwKBG5hbWUYASABKAkSDAoEc2l6ZRgCIAEoAxIVCg1yZWNlaXZlZEJ5dGVzGAMgASgDEhAKCHNlbmRlcklEGAQgASgJEhIKCnNlbmRlck5hbWUYBSABKAkirAEKGVRhaWxkcm9wU2VuZENsaWVudE1lc3NhZ2USKgoFc3RhcnQYASABKAsyGS5kYWVtb24uVGFpbGRyb3BTZW5kU3RhcnRIABIqCgVjaHVuaxgCIAEoCzIZLmRhZW1vbi5UYWlsZHJvcEZpbGVDaHVua0gAEiwKCGZpbGVEb25lGAMgASgLMhguZGFlbW9uLlRhaWxkcm9wRmlsZURvbmVIAEIJCgdtZXNzYWdlImsKEVRhaWxkcm9wU2VuZFN0YXJ0EhMKC2VuZHBvaW50VGFnGAEgASgJEhQKDHBlZXJTdGFibGVJRBgCIAEoCRIrCgVmaWxlcxgDIAMoCzIcLmRhZW1vbi5UYWlsZHJvcE91dGdvaW5nRmlsZSIyChRUYWlsZHJvcE91dGdvaW5nRmlsZRIMCgRuYW1lGAEgASgJEgwKBHNpemUYAiABKAMiIQoRVGFpbGRyb3BGaWxlQ2h1bmsSDAoEZGF0YRgBIAEoDCISChBUYWlsZHJvcEZpbGVEb25lInEKGVRhaWxkcm9wU2VuZFNlcnZlck1lc3NhZ2USMAoIcHJvZ3Jlc3MYASABKAsyHC5kYWVtb24uVGFpbGRyb3BTZW5kUHJvZ3Jlc3NIABIXCg1yZWNlaXZlZEJ5dGVzGAIgASgDSABCCQoHbWVzc2FnZSJTChRUYWlsZHJvcFNlbmRQcm9ncmVzcxIRCglmaWxlSW5kZXgYASABKAUSEQoJc2VudEJ5dGVzGAIgASgDEhUKDWZpbGVDb21wbGV0ZWQYAyABKAgiQAobRG93bmxvYWRUYWlsZHJvcEZpbGVSZXF1ZXN0EhMKC2VuZHBvaW50VGFnGAEgASgJEgwKBG5hbWUYAiABKAkiNwoZRG93bmxvYWRUYWlsZHJvcEZpbGVDaHVuaxIMCgRzaXplGAEgASgDEgwKBGRhdGEYAiABKAwiPgoZRGVsZXRlVGFpbGRyb3BGaWxlUmVxdWVzdBITCgtlbmRwb2ludFRhZxgBIAEoCRIMCgRuYW1lGAIgASgJIlUKHkNhbmNlbFRhaWxkcm9wUmVjZWl2aW5nUmVxdWVzdBITCgtlbmRwb2ludFRhZxgBIAEoCRIQCghzZW5kZXJJRBgCIAEoCRIMCgRuYW1lGAMgASgJIqQBChJVU0JQcm92aWRlck1lc3NhZ2USKQoGYXR0YWNoGAEgASgLMhcuZGFlbW9uLlVTQkRldmljZUF0dGFjaEgAEikKBmRldGFjaBgCIAEoCzIXLmRhZW1vbi5VU0JEZXZpY2VEZXRhY2hIABItCgt1cmJSZXNwb25zZRgDIAEoCzIWLmRhZW1vbi5VU0JVUkJSZXNwb25zZUgAQgkKB21lc3NhZ2UiwQEKEFVTQlNlcnZlck1lc3NhZ2USJwoFcmVhZHkYASABKAsyFi5kYWVtb24uVVNCRGV2aWNlUmVhZHlIABIrCgp1cmJSZXF1ZXN0GAIgASgLMhUuZGFlbW9uLlVTQlVSQlJlcXVlc3RIABIpCgVhYm9ydBgDIAEoCzIYLmRhZW1vbi5VU0JFbmRwb2ludEFib3J0SAASIQoFZXJyb3IYBCABKAsyEC5kYWVtb24uVVNCRXJyb3JIAEIJCgdtZXNzYWdlItUCChNVU0JEZXZpY2VEZXNjcmlwdG9yEhAKCGRldmljZUlkGAEgASgJEg4KBmJ1c051bRgCIAEoDRIOCgZkZXZOdW0YAyABKA0SDQoFc3BlZWQYBCABKA0SEAoIdmVuZG9ySWQYBSABKA0SEQoJcHJvZHVjdElkGAYgASgNEhEKCWJjZERldmljZRgHIAEoDRITCgtkZXZpY2VDbGFzcxgIIAEoDRIWCg5kZXZpY2VTdWJDbGFzcxgJIAEoDRIWCg5kZXZpY2VQcm90b2NvbBgKIAEoDRIaChJjb25maWd1cmF0aW9uVmFsdWUYCyABKA0SGQoRbnVtQ29uZmlndXJhdGlvbnMYDCABKA0SKAoKaW50ZXJmYWNlcxgNIAMoCzIULmRhZW1vbi5VU0JJbnRlcmZhY2USDgoGc2VyaWFsGA4gASgJEg8KB3Byb2R1Y3QYDyABKAkiVQoPVVNCRGV2aWNlQXR0YWNoEhEKCXNlcnZlclRhZxgBIAEoCRIvCgpkZXNjcmlwdG9yGAIgASgLMhsuZGFlbW9uLlVTQkRldmljZURlc2NyaXB0b3IiXAoMVVNCSW50ZXJmYWNlEhYKDmludGVyZmFjZUNsYXNzGAEgASgNEhkKEWludGVyZmFjZVN1YkNsYXNzGAIgASgNEhkKEWludGVyZmFjZVByb3RvY29sGAMgASgNIiMKD1VTQkRldmljZURldGFjaBIQCghkZXZpY2VJZBgBIAEoCSIxCg5VU0JEZXZpY2VSZWFkeRIQCghkZXZpY2VJZBgBIAEoCRINCgVidXNJZBgCIAEoCSKTAgoNVVNCVVJCUmVxdWVzdBIQCghkZXZpY2VJZBgBIAEoCRILCgNzZXEYAiABKAQSEAoIZW5kcG9pbnQYAyABKA0SEwoLZGlyZWN0aW9uSW4YBCABKAgSFQoNdHJhbnNmZXJGbGFncxgFIAEoDRINCgVzZXR1cBgGIAEoDBIcChR0cmFuc2ZlckJ1ZmZlckxlbmd0aBgHIAEoDRIPCgdvdXREYXRhGAggASgMEhcKD251bWJlck9mUGFja2V0cxgJIAEoBRISCgpzdGFydEZyYW1lGAogASgFEhAKCGludGVydmFsGAsgASgFEigKCmlzb1BhY2tldHMYDCADKAsyFC5kYWVtb24uVVNCSXNvUGFja2V0Io8BCg5VU0JVUkJSZXNwb25zZRIQCghkZXZpY2VJZBgBIAEoCRILCgNzZXEYAiABKAQSDgoGc3RhdHVzGAMgASgFEhQKDGFjdHVhbExlbmd0aBgEIAEoBRIOCgZpbkRhdGEYBSABKAwSKAoKaXNvUGFja2V0cxgGIAMoCzIULmRhZW1vbi5VU0JJc29QYWNrZXQiVAoMVVNCSXNvUGFja2V0Eg4KBm9mZnNldBgBIAEoBRIOCgZsZW5ndGgYAiABKAUSFAoMYWN0dWFsTGVuZ3RoGAMgASgFEg4KBnN0YXR1cxgEIAEoBSI2ChBVU0JFbmRwb2ludEFib3J0EhAKCGRldmljZUlkGAEgASgJEhAKCGVuZHBvaW50GAIgASgNIi0KCFVTQkVycm9yEhAKCGRldmljZUlkGAEgASgJEg8KB21lc3NhZ2UYAiABKAkiRQoXVVNCSVBTZXJ2ZXJTdGF0dXNVcGRhdGUSKgoHc2VydmVycxgBIAMoCzIZLmRhZW1vbi5VU0JJUFNlcnZlclN0YXR1cyJQChFVU0JJUFNlcnZlclN0YXR1cxIRCglzZXJ2ZXJUYWcYASABKAkSKAoHZGV2aWNlcxgCIAMoCzIXLmRhZW1vbi5VU0JTaGFyZWREZXZpY2UirwEKD1VTQlNoYXJlZERldmljZRIvCgpkZXNjcmlwdG9yGAEgASgLMhsuZGFlbW9uLlVTQkRldmljZURlc2NyaXB0b3ISDQoFYnVzSWQYAiABKAkSEAoIc3RhYmxlSWQYAyABKAkSIwoHYmFja2VuZBgEIAEoDjISLmRhZW1vbi5VU0JCYWNrZW5kEiUKBXN0YXRlGAUgASgOMhYuZGFlbW9uLlVTQkRldmljZVN0YXRlIk8KF09wZW5Db25uZWN0U3RhdHVzVXBkYXRlEjQKCWVuZHBvaW50cxgBIAMoCzIhLmRhZW1vbi5PcGVuQ29ubmVjdEVuZHBvaW50U3RhdHVzIs0BChlPcGVuQ29ubmVjdEVuZHBvaW50U3RhdHVzEhMKC2VuZHBvaW50VGFnGAEgASgJEg0KBXN0YXRlGAIgASgJEhEKCXN0YXRlVGV4dBgDIAEoCRI3Cg1hdXRoQ2hhbGxlbmdlGAQgASgLMiAuZGFlbW9uLk9wZW5Db25uZWN0QXV0aENoYWxsZW5nZRINCgVlcnJvchgFIAEoCRIxCgp0dW5uZWxJbmZvGAYgASgLMh0uZGFlbW9uLk9wZW5Db25uZWN0VHVubmVsSW5mbyKYAQoVT3BlbkNvbm5lY3RUdW5uZWxJbmZvEg4KBnNlcnZlchgBIAEoCRIOCgZmbGF2b3IYAiABKAkSEQoJdHJhbnNwb3J0GAMgASgJEgwKBGlwdjQYBCADKAkSDAoEaXB2NhgFIAMoCRILCgNkbnMYBiADKAkSCwoDbXR1GAcgASgNEhYKDmNvbm5lY3RlZFNpbmNlGAggASgDIsYBChhPcGVuQ29ubmVjdEF1dGhDaGFsbGVuZ2USCgoCaWQYASABKAkSDgoGYmFubmVyGAIgASgJEg8KB21lc3NhZ2UYAyABKAkSDQoFZXJyb3IYBCABKAkSKwoEZm9ybRgFIAEoCzIbLmRhZW1vbi5PcGVuQ29ubmVjdEF1dGhGb3JtSAASNAoHYnJvd3NlchgGIAEoCzIhLmRhZW1vbi5PcGVuQ29ubmVjdEJyb3dzZXJSZXF1ZXN0SABCCwoJY2hhbGxlbmdlIkcKE09wZW5Db25uZWN0QXV0aEZvcm0SMAoGZmllbGRzGAEgAygLMiAuZGFlbW9uLk9wZW5Db25uZWN0QXV0aEZvcm1GaWVsZCKfAQoYT3BlbkNvbm5lY3RBdXRoRm9ybUZpZWxkEhUKDXN1Ym1pc3Npb25LZXkYASABKAkSDAoEbmFtZRgCIAEoCRINCgVsYWJlbBgDIAEoCRIMCgRraW5kGAQgASgJEg0KBXZhbHVlGAUgASgJEjIKB29wdGlvbnMYBiADKAsyIS5kYWVtb24uT3BlbkNvbm5lY3RBdXRoRm9ybUNob2ljZSI5ChlPcGVuQ29ubmVjdEF1dGhGb3JtQ2hvaWNlEg0KBXZhbHVlGAEgASgJEg0KBWxhYmVsGAIgASgJIqwBChlPcGVuQ29ubmVjdEJyb3dzZXJSZXF1ZXN0EgsKA3VybBgBIAEoCRIQCghmaW5hbFVSTBgCIAEoCRITCgtjb29raWVOYW1lcxgDIAMoCRITCgtoZWFkZXJOYW1lcxgEIAMoCRIbChNjYWxsYmFja1VSTFByZWZpeGVzGAUgAygJEhgKEGVhcmx5Q29va2llTmFtZXMYBiADKAkSDwoHY2FjaGVJRBgHIAEoCSI3ChhPcGVuQ29ubmVjdEJyb3dzZXJDb29raWUSDAoEbmFtZRgBIAEoCRINCgV2YWx1ZRgCIAEoCSI4ChhPcGVuQ29ubmVjdEJyb3dzZXJIZWFkZXISDAoEbmFtZRgBIAEoCRIOCgZ2YWx1ZXMYAiADKAkijQEKG09wZW5Db25uZWN0QXV0aEZvcm1SZXNwb25zZRI/CgZ2YWx1ZXMYASADKAsyLy5kYWVtb24uT3BlbkNvbm5lY3RBdXRoRm9ybVJlc3BvbnNlLlZhbHVlc0VudHJ5Gi0KC1ZhbHVlc0VudHJ5EgsKA2tleRgBIAEoCRINCgV2YWx1ZRgCIAEoCToCOAEikgEKGE9wZW5Db25uZWN0QnJvd3NlclJlc3VsdBIQCghmaW5hbFVSTBgBIAEoCRIxCgdjb29raWVzGAIgAygLMiAuZGFlbW9uLk9wZW5Db25uZWN0QnJvd3NlckNvb2tpZRIxCgdoZWFkZXJzGAMgAygLMiAuZGFlbW9uLk9wZW5Db25uZWN0QnJvd3NlckhlYWRlciLDAQohT3BlbkNvbm5lY3RBdXRoUmVzcG9uc2VTdWJtaXNzaW9uEhMKC2VuZHBvaW50VGFnGAEgASgJEhMKC2NoYWxsZW5nZUlEGAIgASgJEjMKBGZvcm0YAyABKAsyIy5kYWVtb24uT3BlbkNvbm5lY3RBdXRoRm9ybVJlc3BvbnNlSAASMwoHYnJvd3NlchgEIAEoCzIgLmRhZW1vbi5PcGVuQ29ubmVjdEJyb3dzZXJSZXN1bHRIAEIKCghyZXNwb25zZSJKCh5PcGVuQ29ubmVjdEF1dGhDaGFsbGVuZ2VDYW5jZWwSEwoLZW5kcG9pbnRUYWcYASABKAkSEwoLY2hhbGxlbmdlSUQYAiABKAkiRwoTT3BlblZQTlN0YXR1c1VwZGF0ZRIwCgllbmRwb2ludHMYASADKAsyHS5kYWVtb24uT3BlblZQTkVuZHBvaW50U3RhdHVzIrkBChVPcGVuVlBORW5kcG9pbnRTdGF0dXMSEwoLZW5kcG9pbnRUYWcYASABKAkSDQoFc3RhdGUYAiABKAkSEQoJc3RhdGVUZXh0GAMgASgJEisKCWNoYWxsZW5nZRgEIAEoCzIYLmRhZW1vbi5PcGVuVlBOQ2hhbGxlbmdlEg0KBWVycm9yGAUgASgJEi0KCnR1bm5lbEluZm8YBiABKAsyGS5kYWVtb24uT3BlblZQTlR1bm5lbEluZm8imAEKEU9wZW5WUE5UdW5uZWxJbmZvEg4KBnNlcnZlchgBIAEoCRIPCgduZXR3b3JrGAMgASgJEgwKBGlwdjQYBCADKAkSDAoEaXB2NhgFIAMoCRILCgNkbnMYBiADKAkSCwoDbXR1GAcgASgNEhYKDmNvbm5lY3RlZFNpbmNlGAggASgDEg4KBmNpcGhlchgJIAEoCUoECAIQAyKqAQoQT3BlblZQTkNoYWxsZW5nZRIKCgJpZBgBIAEoCRIMCgRraW5kGAIgASgJEhAKCHVzZXJuYW1lGAMgASgJEg8KB21lc3NhZ2UYBCABKAkSCwoDdXJsGAUgASgJEhUKDXNlY3JldE1lc3NhZ2UYBiABKAkSDAoEZWNobxgHIAEoCBIVCg1wcmV2aW91c0Vycm9yGAggASgJEhAKCGRlYWRsaW5lGAkgASgDInoKGk9wZW5WUE5DaGFsbGVuZ2VTdWJtaXNzaW9uEhMKC2VuZHBvaW50VGFnGAEgASgJEhMKC2NoYWxsZW5nZUlEGAIgASgJEhAKCHVzZXJuYW1lGAMgASgJEhAKCHBhc3N3b3JkGAQgASgJEg4KBnNlY3JldBgFIAEoCSJCChZPcGVuVlBOQ2hhbGxlbmdlQ2FuY2VsEhMKC2VuZHBvaW50VGFnGAEgASgJEhMKC2NoYWxsZW5nZUlEGAIgASgJInAKEU5vdGlmaWNhdGlvbkV2ZW50EiQKBHNlbmQYASABKAsyFC5kYWVtb24uTm90aWZpY2F0aW9uSAASLAoGY2FuY2VsGAIgASgLMhouZGFlbW9uLk5vdGlmaWNhdGlvbkNhbmNlbEgAQgcKBWV2ZW50IoQBCgxOb3RpZmljYXRpb24SEgoKaWRlbnRpZmllchgBIAEoCRIQCgh0eXBlTmFtZRgCIAEoCRIOCgZ0eXBlSUQYAyABKAUSDQoFdGl0bGUYBCABKAkSEAoIc3VidGl0bGUYBSABKAkSDAoEYm9keRgGIAEoCRIPCgdvcGVuVVJMGAcgASgJIjgKEk5vdGlmaWNhdGlvbkNhbmNlbBISCgppZGVudGlmaWVyGAEgASgJEg4KBnR5cGVJRBgCIAEoBSpVCghMb2dMZXZlbBIJCgVQQU5JQxAAEgkKBUZBVEFMEAESCQoFRVJST1IQAhIICgRXQVJOEAMSCAoESU5GTxAEEgkKBURFQlVHEAUSCQoFVFJBQ0UQBippChNDb25uZWN0aW9uRXZlbnRUeXBlEhgKFENPTk5FQ1RJT05fRVZFTlRfTkVXEAASGwoXQ09OTkVDVElPTl9FVkVOVF9VUERBVEUQARIbChdDT05ORUNUSU9OX0VWRU5UX0NMT1NFRBACKmwKDlVTQkRldmljZVN0YXRlEhkKFVVTQl9ERVZJQ0VfU1RBVEVfSURMRRAAEh0KGVVTQl9ERVZJQ0VfU1RBVEVfQVRUQUNIRUQQARIgChxVU0JfREVWSUNFX1NUQVRFX1VOQVZBSUxBQkxFEAIqngEKClVTQkJhY2tlbmQSGwoXVVNCX0JBQ0tFTkRfVU5TUEVDSUZJRUQQABIbChdVU0JfQkFDS0VORF9MSU5VWF9TWVNGUxABEhcKE1VTQl9CQUNLRU5EX0RZTkFNSUMQAhIcChhVU0JfQkFDS0VORF9EQVJXSU5fSU9LSVQQAxIfChtVU0JfQkFDS0VORF9XSU5ET1dTX1ZCT1hVU0IQBDLbGgoOU3RhcnRlZFNlcnZpY2USNwoKR2V0VmVyc2lvbhIWLmdvb2dsZS5wcm90b2J1Zi5FbXB0eRoPLmRhZW1vbi5WZXJzaW9uIgASSwoWU3Vic2NyaWJlU2VydmljZVN0YXR1cxIWLmdvb2dsZS5wcm90b2J1Zi5FbXB0eRoVLmRhZW1vbi5TZXJ2aWNlU3RhdHVzIgAwARI3CgxTdWJzY3JpYmVMb2cSFi5nb29nbGUucHJvdG9idWYuRW1wdHkaCy5kYWVtb24uTG9nIgAwARJHChJHZXREZWZhdWx0TG9nTGV2ZWwSFi5nb29nbGUucHJvdG9idWYuRW1wdHkaFy5kYWVtb24uRGVmYXVsdExvZ0xldmVsIgASPQoJQ2xlYXJMb2dzEhYuZ29vZ2xlLnByb3RvYnVmLkVtcHR5GhYuZ29vZ2xlLnByb3RvYnVmLkVtcHR5IgASRQoPU3Vic2NyaWJlU3RhdHVzEh4uZGFlbW9uLlN1YnNjcmliZVN0YXR1c1JlcXVlc3QaDi5kYWVtb24uU3RhdHVzIgAwARI9Cg9TdWJzY3JpYmVHcm91cHMSFi5nb29nbGUucHJvdG9idWYuRW1wdHkaDi5kYWVtb24uR3JvdXBzIgAwARJHChJHZXRDbGFzaE1vZGVTdGF0dXMSFi5nb29nbGUucHJvdG9idWYuRW1wdHkaFy5kYWVtb24uQ2xhc2hNb2RlU3RhdHVzIgASQwoSU3Vic2NyaWJlQ2xhc2hNb2RlEhYuZ29vZ2xlLnByb3RvYnVmLkVtcHR5GhEuZGFlbW9uLkNsYXNoTW9kZSIAMAESOwoMU2V0Q2xhc2hNb2RlEhEuZGFlbW9uLkNsYXNoTW9kZRoWLmdvb2dsZS5wcm90b2J1Zi5FbXB0eSIAEjsKB1VSTFRlc3QSFi5kYWVtb24uVVJMVGVzdFJlcXVlc3QaFi5nb29nbGUucHJvdG9idWYuRW1wdHkiABJJCg5TZWxlY3RPdXRib3VuZBIdLmRhZW1vbi5TZWxlY3RPdXRib3VuZFJlcXVlc3QaFi5nb29nbGUucHJvdG9idWYuRW1wdHkiABJJCg5TZXRHcm91cEV4cGFuZBIdLmRhZW1vbi5TZXRHcm91cEV4cGFuZFJlcXVlc3QaFi5nb29nbGUucHJvdG9idWYuRW1wdHkiABJZChRTdWJzY3JpYmVDb25uZWN0aW9ucxIjLmRhZW1vbi5TdWJzY3JpYmVDb25uZWN0aW9uc1JlcXVlc3QaGC5kYWVtb24uQ29ubmVjdGlvbkV2ZW50cyIAMAESSwoPQ2xvc2VDb25uZWN0aW9uEh4uZGFlbW9uLkNsb3NlQ29ubmVjdGlvblJlcXVlc3QaFi5nb29nbGUucHJvdG9idWYuRW1wdHkiABJHChNDbG9zZUFsbENvbm5lY3Rpb25zEhYuZ29vZ2xlLnByb3RvYnVmLkVtcHR5GhYuZ29vZ2xlLnByb3RvYnVmLkVtcHR5IgASTQoVR2V0RGVwcmVjYXRlZFdhcm5pbmdzEhYuZ29vZ2xlLnByb3RvYnVmLkVtcHR5GhouZGFlbW9uLkRlcHJlY2F0ZWRXYXJuaW5ncyIAEjsKDEdldFN0YXJ0ZWRBdBIWLmdvb2dsZS5wcm90b2J1Zi5FbXB0eRoRLmRhZW1vbi5TdGFydGVkQXQiABJGChJTdWJzY3JpYmVPdXRib3VuZHMSFi5nb29nbGUucHJvdG9idWYuRW1wdHkaFC5kYWVtb24uT3V0Ym91bmRMaXN0IgAwARJkChdTdGFydE5ldHdvcmtRdWFsaXR5VGVzdBIhLmRhZW1vbi5OZXR3b3JrUXVhbGl0eVRlc3RSZXF1ZXN0GiIuZGFlbW9uLk5ldHdvcmtRdWFsaXR5VGVzdFByb2dyZXNzIgAwARJGCg1TdGFydFNUVU5UZXN0EhcuZGFlbW9uLlNUVU5UZXN0UmVxdWVzdBoYLmRhZW1vbi5TVFVOVGVzdFByb2dyZXNzIgAwARJVChhTdWJzY3JpYmVUYWlsc2NhbGVTdGF0dXMSFi5nb29nbGUucHJvdG9idWYuRW1wdHkaHS5kYWVtb24uVGFpbHNjYWxlU3RhdHVzVXBkYXRlIgAwARJVChJTdGFydFRhaWxzY2FsZVBpbmcSHC5kYWVtb24uVGFpbHNjYWxlUGluZ1JlcXVlc3QaHS5kYWVtb24uVGFpbHNjYWxlUGluZ1Jlc3BvbnNlIgAwARJVChRTZXRUYWlsc2NhbGVFeGl0Tm9kZRIjLmRhZW1vbi5TZXRUYWlsc2NhbGVFeGl0Tm9kZVJlcXVlc3QaFi5nb29nbGUucHJvdG9idWYuRW1wdHkiABJLCg9UYWlsc2NhbGVMb2dvdXQSHi5kYWVtb24uVGFpbHNjYWxlTG9nb3V0UmVxdWVzdBoWLmdvb2dsZS5wcm90b2J1Zi5FbXB0eSIAEl4KF0dldFRhaWxzY2FsZUNlcnRpZmljYXRlEiMuZGFlbW9uLlRhaWxzY2FsZUNlcnRpZmljYXRlUmVxdWVzdBocLmRhZW1vbi5UYWlsc2NhbGVDZXJ0aWZpY2F0ZSIAEmYKGFN0YXJ0VGFpbHNjYWxlU1NIU2Vzc2lvbhIhLmRhZW1vbi5UYWlsc2NhbGVTU0hDbGllbnRNZXNzYWdlGiEuZGFlbW9uLlRhaWxzY2FsZVNTSFNlcnZlck1lc3NhZ2UiACgBMAESWgoWU3Vic2NyaWJlVGFpbGRyb3BJbmJveBIlLmRhZW1vbi5TdWJzY3JpYmVUYWlsZHJvcEluYm94UmVxdWVzdBoVLmRhZW1vbi5UYWlsZHJvcEluYm94IgAwARJXChVNYXJrVGFpbGRyb3BJbmJveFJlYWQSJC5kYWVtb24uTWFya1RhaWxkcm9wSW5ib3hSZWFkUmVxdWVzdBoWLmdvb2dsZS5wcm90b2J1Zi5FbXB0eSIAEl8KEVNlbmRUYWlsZHJvcEZpbGVzEiEuZGFlbW9uLlRhaWxkcm9wU2VuZENsaWVudE1lc3NhZ2UaIS5kYWVtb24uVGFpbGRyb3BTZW5kU2VydmVyTWVzc2FnZSIAKAEwARJiChREb3dubG9hZFRhaWxkcm9wRmlsZRIjLmRhZW1vbi5Eb3dubG9hZFRhaWxkcm9wRmlsZVJlcXVlc3QaIS5kYWVtb24uRG93bmxvYWRUYWlsZHJvcEZpbGVDaHVuayIAMAESUQoSRGVsZXRlVGFpbGRyb3BGaWxlEiEuZGFlbW9uLkRlbGV0ZVRhaWxkcm9wRmlsZVJlcXVlc3QaFi5nb29nbGUucHJvdG9idWYuRW1wdHkiABJbChdDYW5jZWxUYWlsZHJvcFJlY2VpdmluZxImLmRhZW1vbi5DYW5jZWxUYWlsZHJvcFJlY2VpdmluZ1JlcXVlc3QaFi5nb29nbGUucHJvdG9idWYuRW1wdHkiABJPChFQcm92aWRlVVNCRGV2aWNlcxIaLmRhZW1vbi5VU0JQcm92aWRlck1lc3NhZ2UaGC5kYWVtb24uVVNCU2VydmVyTWVzc2FnZSIAKAEwARJZChpTdWJzY3JpYmVVU0JJUFNlcnZlclN0YXR1cxIWLmdvb2dsZS5wcm90b2J1Zi5FbXB0eRofLmRhZW1vbi5VU0JJUFNlcnZlclN0YXR1c1VwZGF0ZSIAMAESWQoaU3Vic2NyaWJlT3BlbkNvbm5lY3RTdGF0dXMSFi5nb29nbGUucHJvdG9idWYuRW1wdHkaHy5kYWVtb24uT3BlbkNvbm5lY3RTdGF0dXNVcGRhdGUiADABEmQKHVN1Ym1pdE9wZW5Db25uZWN0QXV0aFJlc3BvbnNlEikuZGFlbW9uLk9wZW5Db25uZWN0QXV0aFJlc3BvbnNlU3VibWlzc2lvbhoWLmdvb2dsZS5wcm90b2J1Zi5FbXB0eSIAEmIKHkNhbmNlbE9wZW5Db25uZWN0QXV0aENoYWxsZW5nZRImLmRhZW1vbi5PcGVuQ29ubmVjdEF1dGhDaGFsbGVuZ2VDYW5jZWwaFi5nb29nbGUucHJvdG9idWYuRW1wdHkiABJRChZTdWJzY3JpYmVPcGVuVlBOU3RhdHVzEhYuZ29vZ2xlLnByb3RvYnVmLkVtcHR5GhsuZGFlbW9uLk9wZW5WUE5TdGF0dXNVcGRhdGUiADABEl4KHlN1Ym1pdE9wZW5WUE5DaGFsbGVuZ2VSZXNwb25zZRIiLmRhZW1vbi5PcGVuVlBOQ2hhbGxlbmdlU3VibWlzc2lvbhoWLmdvb2dsZS5wcm90b2J1Zi5FbXB0eSIAElIKFkNhbmNlbE9wZW5WUE5DaGFsbGVuZ2USHi5kYWVtb24uT3BlblZQTkNoYWxsZW5nZUNhbmNlbBoWLmdvb2dsZS5wcm90b2J1Zi5FbXB0eSIAEk8KFlN1YnNjcmliZU5vdGlmaWNhdGlvbnMSFi5nb29nbGUucHJvdG9idWYuRW1wdHkaGS5kYWVtb24uTm90aWZpY2F0aW9uRXZlbnQiADABQiVaI2dpdGh1Yi5jb20vc2FnZXJuZXQvc2luZy1ib3gvZGFlbW9uYgZwcm90bzM", [file_google_protobuf_empty]); + +/** + * @generated from message daemon.Version + */ +export type Version = Message<"daemon.Version"> & { + /** + * @generated from field: string version = 1; + */ + version: string; + + /** + * @generated from field: int32 apiVersion = 2; + */ + apiVersion: number; +}; + +/** + * Describes the message daemon.Version. + * Use `create(VersionSchema)` to create a new message. + */ +export const VersionSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_daemon_started_service, 0); + +/** + * @generated from message daemon.ServiceStatus + */ +export type ServiceStatus = Message<"daemon.ServiceStatus"> & { + /** + * @generated from field: daemon.ServiceStatus.Type status = 1; + */ + status: ServiceStatus_Type; + + /** + * @generated from field: string errorMessage = 2; + */ + errorMessage: string; +}; + +/** + * Describes the message daemon.ServiceStatus. + * Use `create(ServiceStatusSchema)` to create a new message. + */ +export const ServiceStatusSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_daemon_started_service, 1); + +/** + * @generated from enum daemon.ServiceStatus.Type + */ +export enum ServiceStatus_Type { + /** + * @generated from enum value: IDLE = 0; + */ + IDLE = 0, + + /** + * @generated from enum value: STARTING = 1; + */ + STARTING = 1, + + /** + * @generated from enum value: STARTED = 2; + */ + STARTED = 2, + + /** + * @generated from enum value: STOPPING = 3; + */ + STOPPING = 3, + + /** + * @generated from enum value: FATAL = 4; + */ + FATAL = 4, +} + +/** + * Describes the enum daemon.ServiceStatus.Type. + */ +export const ServiceStatus_TypeSchema: GenEnum = /*@__PURE__*/ + enumDesc(file_daemon_started_service, 1, 0); + +/** + * @generated from message daemon.SubscribeStatusRequest + */ +export type SubscribeStatusRequest = Message<"daemon.SubscribeStatusRequest"> & { + /** + * @generated from field: int64 interval = 1; + */ + interval: bigint; +}; + +/** + * Describes the message daemon.SubscribeStatusRequest. + * Use `create(SubscribeStatusRequestSchema)` to create a new message. + */ +export const SubscribeStatusRequestSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_daemon_started_service, 2); + +/** + * @generated from message daemon.Log + */ +export type Log = Message<"daemon.Log"> & { + /** + * @generated from field: repeated daemon.Log.Message messages = 1; + */ + messages: Log_Message[]; + + /** + * @generated from field: bool reset = 2; + */ + reset: boolean; +}; + +/** + * Describes the message daemon.Log. + * Use `create(LogSchema)` to create a new message. + */ +export const LogSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_daemon_started_service, 3); + +/** + * @generated from message daemon.Log.Message + */ +export type Log_Message = Message<"daemon.Log.Message"> & { + /** + * @generated from field: daemon.LogLevel level = 1; + */ + level: LogLevel; + + /** + * @generated from field: string message = 2; + */ + message: string; +}; + +/** + * Describes the message daemon.Log.Message. + * Use `create(Log_MessageSchema)` to create a new message. + */ +export const Log_MessageSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_daemon_started_service, 3, 0); + +/** + * @generated from message daemon.DefaultLogLevel + */ +export type DefaultLogLevel = Message<"daemon.DefaultLogLevel"> & { + /** + * @generated from field: daemon.LogLevel level = 1; + */ + level: LogLevel; +}; + +/** + * Describes the message daemon.DefaultLogLevel. + * Use `create(DefaultLogLevelSchema)` to create a new message. + */ +export const DefaultLogLevelSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_daemon_started_service, 4); + +/** + * @generated from message daemon.Status + */ +export type Status = Message<"daemon.Status"> & { + /** + * @generated from field: uint64 memory = 1; + */ + memory: bigint; + + /** + * @generated from field: int32 goroutines = 2; + */ + goroutines: number; + + /** + * @generated from field: int32 connectionsIn = 3; + */ + connectionsIn: number; + + /** + * @generated from field: int32 connectionsOut = 4; + */ + connectionsOut: number; + + /** + * @generated from field: bool trafficAvailable = 5; + */ + trafficAvailable: boolean; + + /** + * @generated from field: int64 uplink = 6; + */ + uplink: bigint; + + /** + * @generated from field: int64 downlink = 7; + */ + downlink: bigint; + + /** + * @generated from field: int64 uplinkTotal = 8; + */ + uplinkTotal: bigint; + + /** + * @generated from field: int64 downlinkTotal = 9; + */ + downlinkTotal: bigint; +}; + +/** + * Describes the message daemon.Status. + * Use `create(StatusSchema)` to create a new message. + */ +export const StatusSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_daemon_started_service, 5); + +/** + * @generated from message daemon.Groups + */ +export type Groups = Message<"daemon.Groups"> & { + /** + * @generated from field: repeated daemon.Group group = 1; + */ + group: Group[]; +}; + +/** + * Describes the message daemon.Groups. + * Use `create(GroupsSchema)` to create a new message. + */ +export const GroupsSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_daemon_started_service, 6); + +/** + * @generated from message daemon.Group + */ +export type Group = Message<"daemon.Group"> & { + /** + * @generated from field: string tag = 1; + */ + tag: string; + + /** + * @generated from field: string type = 2; + */ + type: string; + + /** + * @generated from field: bool selectable = 3; + */ + selectable: boolean; + + /** + * @generated from field: string selected = 4; + */ + selected: string; + + /** + * @generated from field: bool isExpand = 5; + */ + isExpand: boolean; + + /** + * @generated from field: repeated daemon.GroupItem items = 6; + */ + items: GroupItem[]; +}; + +/** + * Describes the message daemon.Group. + * Use `create(GroupSchema)` to create a new message. + */ +export const GroupSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_daemon_started_service, 7); + +/** + * @generated from message daemon.GroupItem + */ +export type GroupItem = Message<"daemon.GroupItem"> & { + /** + * @generated from field: string tag = 1; + */ + tag: string; + + /** + * @generated from field: string type = 2; + */ + type: string; + + /** + * @generated from field: int64 urlTestTime = 3; + */ + urlTestTime: bigint; + + /** + * @generated from field: int32 urlTestDelay = 4; + */ + urlTestDelay: number; +}; + +/** + * Describes the message daemon.GroupItem. + * Use `create(GroupItemSchema)` to create a new message. + */ +export const GroupItemSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_daemon_started_service, 8); + +/** + * @generated from message daemon.URLTestRequest + */ +export type URLTestRequest = Message<"daemon.URLTestRequest"> & { + /** + * @generated from field: string outboundTag = 1; + */ + outboundTag: string; +}; + +/** + * Describes the message daemon.URLTestRequest. + * Use `create(URLTestRequestSchema)` to create a new message. + */ +export const URLTestRequestSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_daemon_started_service, 9); + +/** + * @generated from message daemon.SelectOutboundRequest + */ +export type SelectOutboundRequest = Message<"daemon.SelectOutboundRequest"> & { + /** + * @generated from field: string groupTag = 1; + */ + groupTag: string; + + /** + * @generated from field: string outboundTag = 2; + */ + outboundTag: string; +}; + +/** + * Describes the message daemon.SelectOutboundRequest. + * Use `create(SelectOutboundRequestSchema)` to create a new message. + */ +export const SelectOutboundRequestSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_daemon_started_service, 10); + +/** + * @generated from message daemon.SetGroupExpandRequest + */ +export type SetGroupExpandRequest = Message<"daemon.SetGroupExpandRequest"> & { + /** + * @generated from field: string groupTag = 1; + */ + groupTag: string; + + /** + * @generated from field: bool isExpand = 2; + */ + isExpand: boolean; +}; + +/** + * Describes the message daemon.SetGroupExpandRequest. + * Use `create(SetGroupExpandRequestSchema)` to create a new message. + */ +export const SetGroupExpandRequestSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_daemon_started_service, 11); + +/** + * @generated from message daemon.ClashMode + */ +export type ClashMode = Message<"daemon.ClashMode"> & { + /** + * @generated from field: string mode = 3; + */ + mode: string; +}; + +/** + * Describes the message daemon.ClashMode. + * Use `create(ClashModeSchema)` to create a new message. + */ +export const ClashModeSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_daemon_started_service, 12); + +/** + * @generated from message daemon.ClashModeStatus + */ +export type ClashModeStatus = Message<"daemon.ClashModeStatus"> & { + /** + * @generated from field: repeated string modeList = 1; + */ + modeList: string[]; + + /** + * @generated from field: string currentMode = 2; + */ + currentMode: string; +}; + +/** + * Describes the message daemon.ClashModeStatus. + * Use `create(ClashModeStatusSchema)` to create a new message. + */ +export const ClashModeStatusSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_daemon_started_service, 13); + +/** + * @generated from message daemon.SubscribeConnectionsRequest + */ +export type SubscribeConnectionsRequest = Message<"daemon.SubscribeConnectionsRequest"> & { + /** + * @generated from field: int64 interval = 1; + */ + interval: bigint; +}; + +/** + * Describes the message daemon.SubscribeConnectionsRequest. + * Use `create(SubscribeConnectionsRequestSchema)` to create a new message. + */ +export const SubscribeConnectionsRequestSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_daemon_started_service, 14); + +/** + * @generated from message daemon.ConnectionEvent + */ +export type ConnectionEvent = Message<"daemon.ConnectionEvent"> & { + /** + * @generated from field: daemon.ConnectionEventType type = 1; + */ + type: ConnectionEventType; + + /** + * @generated from field: string id = 2; + */ + id: string; + + /** + * @generated from field: daemon.Connection connection = 3; + */ + connection?: Connection; + + /** + * @generated from field: int64 uplinkDelta = 4; + */ + uplinkDelta: bigint; + + /** + * @generated from field: int64 downlinkDelta = 5; + */ + downlinkDelta: bigint; + + /** + * @generated from field: int64 closedAt = 6; + */ + closedAt: bigint; +}; + +/** + * Describes the message daemon.ConnectionEvent. + * Use `create(ConnectionEventSchema)` to create a new message. + */ +export const ConnectionEventSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_daemon_started_service, 15); + +/** + * @generated from message daemon.ConnectionEvents + */ +export type ConnectionEvents = Message<"daemon.ConnectionEvents"> & { + /** + * @generated from field: repeated daemon.ConnectionEvent events = 1; + */ + events: ConnectionEvent[]; + + /** + * @generated from field: bool reset = 2; + */ + reset: boolean; +}; + +/** + * Describes the message daemon.ConnectionEvents. + * Use `create(ConnectionEventsSchema)` to create a new message. + */ +export const ConnectionEventsSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_daemon_started_service, 16); + +/** + * @generated from message daemon.Connection + */ +export type Connection = Message<"daemon.Connection"> & { + /** + * @generated from field: string id = 1; + */ + id: string; + + /** + * @generated from field: string inbound = 2; + */ + inbound: string; + + /** + * @generated from field: string inboundType = 3; + */ + inboundType: string; + + /** + * @generated from field: int32 ipVersion = 4; + */ + ipVersion: number; + + /** + * @generated from field: string network = 5; + */ + network: string; + + /** + * @generated from field: string source = 6; + */ + source: string; + + /** + * @generated from field: string destination = 7; + */ + destination: string; + + /** + * @generated from field: string domain = 8; + */ + domain: string; + + /** + * @generated from field: string protocol = 9; + */ + protocol: string; + + /** + * @generated from field: string user = 10; + */ + user: string; + + /** + * @generated from field: string fromOutbound = 11; + */ + fromOutbound: string; + + /** + * @generated from field: int64 createdAt = 12; + */ + createdAt: bigint; + + /** + * @generated from field: int64 closedAt = 13; + */ + closedAt: bigint; + + /** + * @generated from field: int64 uplink = 14; + */ + uplink: bigint; + + /** + * @generated from field: int64 downlink = 15; + */ + downlink: bigint; + + /** + * @generated from field: int64 uplinkTotal = 16; + */ + uplinkTotal: bigint; + + /** + * @generated from field: int64 downlinkTotal = 17; + */ + downlinkTotal: bigint; + + /** + * @generated from field: string rule = 18; + */ + rule: string; + + /** + * @generated from field: string outbound = 19; + */ + outbound: string; + + /** + * @generated from field: string outboundType = 20; + */ + outboundType: string; + + /** + * @generated from field: repeated string chainList = 21; + */ + chainList: string[]; + + /** + * @generated from field: daemon.ProcessInfo processInfo = 22; + */ + processInfo?: ProcessInfo; +}; + +/** + * Describes the message daemon.Connection. + * Use `create(ConnectionSchema)` to create a new message. + */ +export const ConnectionSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_daemon_started_service, 17); + +/** + * @generated from message daemon.ProcessInfo + */ +export type ProcessInfo = Message<"daemon.ProcessInfo"> & { + /** + * @generated from field: uint32 processId = 1; + */ + processId: number; + + /** + * @generated from field: int32 userId = 2; + */ + userId: number; + + /** + * @generated from field: string userName = 3; + */ + userName: string; + + /** + * @generated from field: string processPath = 4; + */ + processPath: string; + + /** + * @generated from field: repeated string packageNames = 5; + */ + packageNames: string[]; +}; + +/** + * Describes the message daemon.ProcessInfo. + * Use `create(ProcessInfoSchema)` to create a new message. + */ +export const ProcessInfoSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_daemon_started_service, 18); + +/** + * @generated from message daemon.CloseConnectionRequest + */ +export type CloseConnectionRequest = Message<"daemon.CloseConnectionRequest"> & { + /** + * @generated from field: string id = 1; + */ + id: string; +}; + +/** + * Describes the message daemon.CloseConnectionRequest. + * Use `create(CloseConnectionRequestSchema)` to create a new message. + */ +export const CloseConnectionRequestSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_daemon_started_service, 19); + +/** + * @generated from message daemon.DeprecatedWarnings + */ +export type DeprecatedWarnings = Message<"daemon.DeprecatedWarnings"> & { + /** + * @generated from field: repeated daemon.DeprecatedWarning warnings = 1; + */ + warnings: DeprecatedWarning[]; +}; + +/** + * Describes the message daemon.DeprecatedWarnings. + * Use `create(DeprecatedWarningsSchema)` to create a new message. + */ +export const DeprecatedWarningsSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_daemon_started_service, 20); + +/** + * @generated from message daemon.DeprecatedWarning + */ +export type DeprecatedWarning = Message<"daemon.DeprecatedWarning"> & { + /** + * @generated from field: string message = 1; + */ + message: string; + + /** + * @generated from field: bool impending = 2; + */ + impending: boolean; + + /** + * @generated from field: string migrationLink = 3; + */ + migrationLink: string; + + /** + * @generated from field: string description = 4; + */ + description: string; + + /** + * @generated from field: string deprecatedVersion = 5; + */ + deprecatedVersion: string; + + /** + * @generated from field: string scheduledVersion = 6; + */ + scheduledVersion: string; +}; + +/** + * Describes the message daemon.DeprecatedWarning. + * Use `create(DeprecatedWarningSchema)` to create a new message. + */ +export const DeprecatedWarningSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_daemon_started_service, 21); + +/** + * @generated from message daemon.StartedAt + */ +export type StartedAt = Message<"daemon.StartedAt"> & { + /** + * @generated from field: int64 startedAt = 1; + */ + startedAt: bigint; +}; + +/** + * Describes the message daemon.StartedAt. + * Use `create(StartedAtSchema)` to create a new message. + */ +export const StartedAtSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_daemon_started_service, 22); + +/** + * @generated from message daemon.OutboundList + */ +export type OutboundList = Message<"daemon.OutboundList"> & { + /** + * @generated from field: repeated daemon.GroupItem outbounds = 1; + */ + outbounds: GroupItem[]; +}; + +/** + * Describes the message daemon.OutboundList. + * Use `create(OutboundListSchema)` to create a new message. + */ +export const OutboundListSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_daemon_started_service, 23); + +/** + * @generated from message daemon.NetworkQualityTestRequest + */ +export type NetworkQualityTestRequest = Message<"daemon.NetworkQualityTestRequest"> & { + /** + * @generated from field: string configURL = 1; + */ + configURL: string; + + /** + * @generated from field: string outboundTag = 2; + */ + outboundTag: string; + + /** + * @generated from field: bool serial = 3; + */ + serial: boolean; + + /** + * @generated from field: int32 maxRuntimeSeconds = 4; + */ + maxRuntimeSeconds: number; + + /** + * @generated from field: bool http3 = 5; + */ + http3: boolean; +}; + +/** + * Describes the message daemon.NetworkQualityTestRequest. + * Use `create(NetworkQualityTestRequestSchema)` to create a new message. + */ +export const NetworkQualityTestRequestSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_daemon_started_service, 24); + +/** + * @generated from message daemon.NetworkQualityTestProgress + */ +export type NetworkQualityTestProgress = Message<"daemon.NetworkQualityTestProgress"> & { + /** + * @generated from field: int32 phase = 1; + */ + phase: number; + + /** + * @generated from field: int64 downloadCapacity = 2; + */ + downloadCapacity: bigint; + + /** + * @generated from field: int64 uploadCapacity = 3; + */ + uploadCapacity: bigint; + + /** + * @generated from field: int32 downloadRPM = 4; + */ + downloadRPM: number; + + /** + * @generated from field: int32 uploadRPM = 5; + */ + uploadRPM: number; + + /** + * @generated from field: int32 idleLatencyMs = 6; + */ + idleLatencyMs: number; + + /** + * @generated from field: int64 elapsedMs = 7; + */ + elapsedMs: bigint; + + /** + * @generated from field: bool isFinal = 8; + */ + isFinal: boolean; + + /** + * @generated from field: string error = 9; + */ + error: string; + + /** + * @generated from field: int32 downloadCapacityAccuracy = 10; + */ + downloadCapacityAccuracy: number; + + /** + * @generated from field: int32 uploadCapacityAccuracy = 11; + */ + uploadCapacityAccuracy: number; + + /** + * @generated from field: int32 downloadRPMAccuracy = 12; + */ + downloadRPMAccuracy: number; + + /** + * @generated from field: int32 uploadRPMAccuracy = 13; + */ + uploadRPMAccuracy: number; +}; + +/** + * Describes the message daemon.NetworkQualityTestProgress. + * Use `create(NetworkQualityTestProgressSchema)` to create a new message. + */ +export const NetworkQualityTestProgressSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_daemon_started_service, 25); + +/** + * @generated from message daemon.STUNTestRequest + */ +export type STUNTestRequest = Message<"daemon.STUNTestRequest"> & { + /** + * @generated from field: string server = 1; + */ + server: string; + + /** + * @generated from field: string outboundTag = 2; + */ + outboundTag: string; +}; + +/** + * Describes the message daemon.STUNTestRequest. + * Use `create(STUNTestRequestSchema)` to create a new message. + */ +export const STUNTestRequestSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_daemon_started_service, 26); + +/** + * @generated from message daemon.STUNTestProgress + */ +export type STUNTestProgress = Message<"daemon.STUNTestProgress"> & { + /** + * @generated from field: int32 phase = 1; + */ + phase: number; + + /** + * @generated from field: string externalAddr = 2; + */ + externalAddr: string; + + /** + * @generated from field: int32 latencyMs = 3; + */ + latencyMs: number; + + /** + * @generated from field: int32 natMapping = 4; + */ + natMapping: number; + + /** + * @generated from field: int32 natFiltering = 5; + */ + natFiltering: number; + + /** + * @generated from field: bool isFinal = 6; + */ + isFinal: boolean; + + /** + * @generated from field: string error = 7; + */ + error: string; + + /** + * @generated from field: bool natTypeSupported = 8; + */ + natTypeSupported: boolean; +}; + +/** + * Describes the message daemon.STUNTestProgress. + * Use `create(STUNTestProgressSchema)` to create a new message. + */ +export const STUNTestProgressSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_daemon_started_service, 27); + +/** + * @generated from message daemon.TailscaleStatusUpdate + */ +export type TailscaleStatusUpdate = Message<"daemon.TailscaleStatusUpdate"> & { + /** + * @generated from field: repeated daemon.TailscaleEndpointStatus endpoints = 1; + */ + endpoints: TailscaleEndpointStatus[]; +}; + +/** + * Describes the message daemon.TailscaleStatusUpdate. + * Use `create(TailscaleStatusUpdateSchema)` to create a new message. + */ +export const TailscaleStatusUpdateSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_daemon_started_service, 28); + +/** + * @generated from message daemon.TailscaleEndpointStatus + */ +export type TailscaleEndpointStatus = Message<"daemon.TailscaleEndpointStatus"> & { + /** + * @generated from field: string endpointTag = 1; + */ + endpointTag: string; + + /** + * @generated from field: string backendState = 2; + */ + backendState: string; + + /** + * @generated from field: string stateText = 3; + */ + stateText: string; + + /** + * @generated from field: string authURL = 4; + */ + authURL: string; + + /** + * @generated from field: string networkName = 5; + */ + networkName: string; + + /** + * @generated from field: string magicDNSSuffix = 6; + */ + magicDNSSuffix: string; + + /** + * @generated from field: daemon.TailscalePeer self = 7; + */ + self?: TailscalePeer; + + /** + * @generated from field: repeated daemon.TailscaleUserGroup userGroups = 8; + */ + userGroups: TailscaleUserGroup[]; + + /** + * @generated from field: daemon.TailscalePeer exitNode = 9; + */ + exitNode?: TailscalePeer; + + /** + * @generated from field: bool keyAuth = 10; + */ + keyAuth: boolean; + + /** + * @generated from field: bool canShareFiles = 11; + */ + canShareFiles: boolean; + + /** + * @generated from field: int32 waitingFileCount = 12; + */ + waitingFileCount: number; + + /** + * @generated from field: int32 receivingFileCount = 13; + */ + receivingFileCount: number; + + /** + * @generated from field: int32 unreadFileCount = 14; + */ + unreadFileCount: number; + + /** + * @generated from field: repeated string certDomains = 15; + */ + certDomains: string[]; +}; + +/** + * Describes the message daemon.TailscaleEndpointStatus. + * Use `create(TailscaleEndpointStatusSchema)` to create a new message. + */ +export const TailscaleEndpointStatusSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_daemon_started_service, 29); + +/** + * @generated from message daemon.TailscaleUserGroup + */ +export type TailscaleUserGroup = Message<"daemon.TailscaleUserGroup"> & { + /** + * @generated from field: int64 userID = 1; + */ + userID: bigint; + + /** + * @generated from field: string loginName = 2; + */ + loginName: string; + + /** + * @generated from field: string displayName = 3; + */ + displayName: string; + + /** + * @generated from field: string profilePicURL = 4; + */ + profilePicURL: string; + + /** + * @generated from field: repeated daemon.TailscalePeer peers = 5; + */ + peers: TailscalePeer[]; +}; + +/** + * Describes the message daemon.TailscaleUserGroup. + * Use `create(TailscaleUserGroupSchema)` to create a new message. + */ +export const TailscaleUserGroupSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_daemon_started_service, 30); + +/** + * @generated from message daemon.TailscalePeer + */ +export type TailscalePeer = Message<"daemon.TailscalePeer"> & { + /** + * @generated from field: string hostName = 1; + */ + hostName: string; + + /** + * @generated from field: string dnsName = 2; + */ + dnsName: string; + + /** + * @generated from field: string os = 3; + */ + os: string; + + /** + * @generated from field: repeated string tailscaleIPs = 4; + */ + tailscaleIPs: string[]; + + /** + * @generated from field: bool online = 5; + */ + online: boolean; + + /** + * @generated from field: bool exitNode = 6; + */ + exitNode: boolean; + + /** + * @generated from field: bool exitNodeOption = 7; + */ + exitNodeOption: boolean; + + /** + * @generated from field: bool active = 8; + */ + active: boolean; + + /** + * @generated from field: int64 rxBytes = 9; + */ + rxBytes: bigint; + + /** + * @generated from field: int64 txBytes = 10; + */ + txBytes: bigint; + + /** + * @generated from field: int64 keyExpiry = 11; + */ + keyExpiry: bigint; + + /** + * @generated from field: string stableID = 12; + */ + stableID: string; + + /** + * @generated from field: bool expired = 13; + */ + expired: boolean; + + /** + * @generated from field: repeated string sshHostKeys = 14; + */ + sshHostKeys: string[]; + + /** + * @generated from field: bool shareeNode = 15; + */ + shareeNode: boolean; + + /** + * @generated from field: int64 lastSeen = 16; + */ + lastSeen: bigint; + + /** + * @generated from field: bool canReceiveFiles = 17; + */ + canReceiveFiles: boolean; +}; + +/** + * Describes the message daemon.TailscalePeer. + * Use `create(TailscalePeerSchema)` to create a new message. + */ +export const TailscalePeerSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_daemon_started_service, 31); + +/** + * @generated from message daemon.TailscalePingRequest + */ +export type TailscalePingRequest = Message<"daemon.TailscalePingRequest"> & { + /** + * @generated from field: string endpointTag = 1; + */ + endpointTag: string; + + /** + * @generated from field: string peerIP = 2; + */ + peerIP: string; +}; + +/** + * Describes the message daemon.TailscalePingRequest. + * Use `create(TailscalePingRequestSchema)` to create a new message. + */ +export const TailscalePingRequestSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_daemon_started_service, 32); + +/** + * @generated from message daemon.TailscalePingResponse + */ +export type TailscalePingResponse = Message<"daemon.TailscalePingResponse"> & { + /** + * @generated from field: double latencyMs = 1; + */ + latencyMs: number; + + /** + * @generated from field: bool isDirect = 2; + */ + isDirect: boolean; + + /** + * @generated from field: string endpoint = 3; + */ + endpoint: string; + + /** + * @generated from field: int32 derpRegionID = 4; + */ + derpRegionID: number; + + /** + * @generated from field: string derpRegionCode = 5; + */ + derpRegionCode: string; + + /** + * @generated from field: string error = 6; + */ + error: string; + + /** + * @generated from field: string peerRelay = 7; + */ + peerRelay: string; +}; + +/** + * Describes the message daemon.TailscalePingResponse. + * Use `create(TailscalePingResponseSchema)` to create a new message. + */ +export const TailscalePingResponseSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_daemon_started_service, 33); + +/** + * @generated from message daemon.SetTailscaleExitNodeRequest + */ +export type SetTailscaleExitNodeRequest = Message<"daemon.SetTailscaleExitNodeRequest"> & { + /** + * @generated from field: string endpointTag = 1; + */ + endpointTag: string; + + /** + * @generated from field: string stableID = 2; + */ + stableID: string; +}; + +/** + * Describes the message daemon.SetTailscaleExitNodeRequest. + * Use `create(SetTailscaleExitNodeRequestSchema)` to create a new message. + */ +export const SetTailscaleExitNodeRequestSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_daemon_started_service, 34); + +/** + * @generated from message daemon.TailscaleLogoutRequest + */ +export type TailscaleLogoutRequest = Message<"daemon.TailscaleLogoutRequest"> & { + /** + * @generated from field: string endpointTag = 1; + */ + endpointTag: string; +}; + +/** + * Describes the message daemon.TailscaleLogoutRequest. + * Use `create(TailscaleLogoutRequestSchema)` to create a new message. + */ +export const TailscaleLogoutRequestSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_daemon_started_service, 35); + +/** + * @generated from message daemon.TailscaleCertificateRequest + */ +export type TailscaleCertificateRequest = Message<"daemon.TailscaleCertificateRequest"> & { + /** + * @generated from field: string endpointTag = 1; + */ + endpointTag: string; + + /** + * @generated from field: string domain = 2; + */ + domain: string; + + /** + * @generated from field: int64 minValiditySeconds = 3; + */ + minValiditySeconds: bigint; +}; + +/** + * Describes the message daemon.TailscaleCertificateRequest. + * Use `create(TailscaleCertificateRequestSchema)` to create a new message. + */ +export const TailscaleCertificateRequestSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_daemon_started_service, 36); + +/** + * @generated from message daemon.TailscaleCertificate + */ +export type TailscaleCertificate = Message<"daemon.TailscaleCertificate"> & { + /** + * @generated from field: bytes certificatePEM = 1; + */ + certificatePEM: Uint8Array; + + /** + * @generated from field: bytes privateKeyPEM = 2; + */ + privateKeyPEM: Uint8Array; +}; + +/** + * Describes the message daemon.TailscaleCertificate. + * Use `create(TailscaleCertificateSchema)` to create a new message. + */ +export const TailscaleCertificateSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_daemon_started_service, 37); + +/** + * @generated from message daemon.TailscaleSSHClientMessage + */ +export type TailscaleSSHClientMessage = Message<"daemon.TailscaleSSHClientMessage"> & { + /** + * @generated from oneof daemon.TailscaleSSHClientMessage.message + */ + message: { + /** + * @generated from field: daemon.TailscaleSSHStart start = 1; + */ + value: TailscaleSSHStart; + case: "start"; + } | { + /** + * @generated from field: daemon.TailscaleSSHInput input = 2; + */ + value: TailscaleSSHInput; + case: "input"; + } | { + /** + * @generated from field: daemon.TailscaleSSHResize resize = 3; + */ + value: TailscaleSSHResize; + case: "resize"; + } | { case: undefined; value?: undefined }; +}; + +/** + * Describes the message daemon.TailscaleSSHClientMessage. + * Use `create(TailscaleSSHClientMessageSchema)` to create a new message. + */ +export const TailscaleSSHClientMessageSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_daemon_started_service, 38); + +/** + * @generated from message daemon.TailscaleSSHStart + */ +export type TailscaleSSHStart = Message<"daemon.TailscaleSSHStart"> & { + /** + * @generated from field: string endpointTag = 1; + */ + endpointTag: string; + + /** + * @generated from field: string peerAddress = 2; + */ + peerAddress: string; + + /** + * @generated from field: string username = 3; + */ + username: string; + + /** + * @generated from field: string terminalType = 4; + */ + terminalType: string; + + /** + * @generated from field: int32 columns = 5; + */ + columns: number; + + /** + * @generated from field: int32 rows = 6; + */ + rows: number; + + /** + * @generated from field: int32 widthPixels = 7; + */ + widthPixels: number; + + /** + * @generated from field: int32 heightPixels = 8; + */ + heightPixels: number; + + /** + * @generated from field: repeated string hostKeys = 9; + */ + hostKeys: string[]; + + /** + * @generated from field: bool forward_agent = 10; + */ + forwardAgent: boolean; +}; + +/** + * Describes the message daemon.TailscaleSSHStart. + * Use `create(TailscaleSSHStartSchema)` to create a new message. + */ +export const TailscaleSSHStartSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_daemon_started_service, 39); + +/** + * @generated from message daemon.TailscaleSSHInput + */ +export type TailscaleSSHInput = Message<"daemon.TailscaleSSHInput"> & { + /** + * @generated from field: bytes data = 1; + */ + data: Uint8Array; +}; + +/** + * Describes the message daemon.TailscaleSSHInput. + * Use `create(TailscaleSSHInputSchema)` to create a new message. + */ +export const TailscaleSSHInputSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_daemon_started_service, 40); + +/** + * @generated from message daemon.TailscaleSSHResize + */ +export type TailscaleSSHResize = Message<"daemon.TailscaleSSHResize"> & { + /** + * @generated from field: int32 columns = 1; + */ + columns: number; + + /** + * @generated from field: int32 rows = 2; + */ + rows: number; + + /** + * @generated from field: int32 widthPixels = 3; + */ + widthPixels: number; + + /** + * @generated from field: int32 heightPixels = 4; + */ + heightPixels: number; +}; + +/** + * Describes the message daemon.TailscaleSSHResize. + * Use `create(TailscaleSSHResizeSchema)` to create a new message. + */ +export const TailscaleSSHResizeSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_daemon_started_service, 41); + +/** + * @generated from message daemon.TailscaleSSHServerMessage + */ +export type TailscaleSSHServerMessage = Message<"daemon.TailscaleSSHServerMessage"> & { + /** + * @generated from oneof daemon.TailscaleSSHServerMessage.message + */ + message: { + /** + * @generated from field: daemon.TailscaleSSHAuthBanner authBanner = 1; + */ + value: TailscaleSSHAuthBanner; + case: "authBanner"; + } | { + /** + * @generated from field: daemon.TailscaleSSHReady ready = 2; + */ + value: TailscaleSSHReady; + case: "ready"; + } | { + /** + * @generated from field: daemon.TailscaleSSHOutput output = 3; + */ + value: TailscaleSSHOutput; + case: "output"; + } | { + /** + * @generated from field: daemon.TailscaleSSHExit exit = 4; + */ + value: TailscaleSSHExit; + case: "exit"; + } | { + /** + * @generated from field: daemon.TailscaleSSHError error = 5; + */ + value: TailscaleSSHError; + case: "error"; + } | { case: undefined; value?: undefined }; +}; + +/** + * Describes the message daemon.TailscaleSSHServerMessage. + * Use `create(TailscaleSSHServerMessageSchema)` to create a new message. + */ +export const TailscaleSSHServerMessageSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_daemon_started_service, 42); + +/** + * @generated from message daemon.TailscaleSSHAuthBanner + */ +export type TailscaleSSHAuthBanner = Message<"daemon.TailscaleSSHAuthBanner"> & { + /** + * @generated from field: string message = 1; + */ + message: string; +}; + +/** + * Describes the message daemon.TailscaleSSHAuthBanner. + * Use `create(TailscaleSSHAuthBannerSchema)` to create a new message. + */ +export const TailscaleSSHAuthBannerSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_daemon_started_service, 43); + +/** + * @generated from message daemon.TailscaleSSHReady + */ +export type TailscaleSSHReady = Message<"daemon.TailscaleSSHReady"> & { +}; + +/** + * Describes the message daemon.TailscaleSSHReady. + * Use `create(TailscaleSSHReadySchema)` to create a new message. + */ +export const TailscaleSSHReadySchema: GenMessage = /*@__PURE__*/ + messageDesc(file_daemon_started_service, 44); + +/** + * @generated from message daemon.TailscaleSSHOutput + */ +export type TailscaleSSHOutput = Message<"daemon.TailscaleSSHOutput"> & { + /** + * @generated from field: bytes data = 1; + */ + data: Uint8Array; +}; + +/** + * Describes the message daemon.TailscaleSSHOutput. + * Use `create(TailscaleSSHOutputSchema)` to create a new message. + */ +export const TailscaleSSHOutputSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_daemon_started_service, 45); + +/** + * @generated from message daemon.TailscaleSSHExit + */ +export type TailscaleSSHExit = Message<"daemon.TailscaleSSHExit"> & { + /** + * @generated from field: int32 exitCode = 1; + */ + exitCode: number; + + /** + * @generated from field: string signal = 2; + */ + signal: string; + + /** + * @generated from field: string errorMessage = 3; + */ + errorMessage: string; +}; + +/** + * Describes the message daemon.TailscaleSSHExit. + * Use `create(TailscaleSSHExitSchema)` to create a new message. + */ +export const TailscaleSSHExitSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_daemon_started_service, 46); + +/** + * @generated from message daemon.TailscaleSSHError + */ +export type TailscaleSSHError = Message<"daemon.TailscaleSSHError"> & { + /** + * @generated from field: string message = 1; + */ + message: string; +}; + +/** + * Describes the message daemon.TailscaleSSHError. + * Use `create(TailscaleSSHErrorSchema)` to create a new message. + */ +export const TailscaleSSHErrorSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_daemon_started_service, 47); + +/** + * @generated from message daemon.SubscribeTaildropInboxRequest + */ +export type SubscribeTaildropInboxRequest = Message<"daemon.SubscribeTaildropInboxRequest"> & { + /** + * @generated from field: string endpointTag = 1; + */ + endpointTag: string; +}; + +/** + * Describes the message daemon.SubscribeTaildropInboxRequest. + * Use `create(SubscribeTaildropInboxRequestSchema)` to create a new message. + */ +export const SubscribeTaildropInboxRequestSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_daemon_started_service, 48); + +/** + * @generated from message daemon.MarkTaildropInboxReadRequest + */ +export type MarkTaildropInboxReadRequest = Message<"daemon.MarkTaildropInboxReadRequest"> & { + /** + * @generated from field: string endpointTag = 1; + */ + endpointTag: string; +}; + +/** + * Describes the message daemon.MarkTaildropInboxReadRequest. + * Use `create(MarkTaildropInboxReadRequestSchema)` to create a new message. + */ +export const MarkTaildropInboxReadRequestSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_daemon_started_service, 49); + +/** + * @generated from message daemon.TaildropInbox + */ +export type TaildropInbox = Message<"daemon.TaildropInbox"> & { + /** + * @generated from field: string endpointTag = 1; + */ + endpointTag: string; + + /** + * @generated from field: repeated daemon.TaildropFile files = 2; + */ + files: TaildropFile[]; + + /** + * @generated from field: repeated daemon.TaildropReceivingFile receiving = 3; + */ + receiving: TaildropReceivingFile[]; +}; + +/** + * Describes the message daemon.TaildropInbox. + * Use `create(TaildropInboxSchema)` to create a new message. + */ +export const TaildropInboxSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_daemon_started_service, 50); + +/** + * @generated from message daemon.TaildropFile + */ +export type TaildropFile = Message<"daemon.TaildropFile"> & { + /** + * @generated from field: string name = 1; + */ + name: string; + + /** + * @generated from field: int64 size = 2; + */ + size: bigint; + + /** + * @generated from field: string senderName = 3; + */ + senderName: string; + + /** + * @generated from field: int64 modifiedAt = 4; + */ + modifiedAt: bigint; +}; + +/** + * Describes the message daemon.TaildropFile. + * Use `create(TaildropFileSchema)` to create a new message. + */ +export const TaildropFileSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_daemon_started_service, 51); + +/** + * @generated from message daemon.TaildropReceivingFile + */ +export type TaildropReceivingFile = Message<"daemon.TaildropReceivingFile"> & { + /** + * @generated from field: string name = 1; + */ + name: string; + + /** + * @generated from field: int64 size = 2; + */ + size: bigint; + + /** + * @generated from field: int64 receivedBytes = 3; + */ + receivedBytes: bigint; + + /** + * @generated from field: string senderID = 4; + */ + senderID: string; + + /** + * @generated from field: string senderName = 5; + */ + senderName: string; +}; + +/** + * Describes the message daemon.TaildropReceivingFile. + * Use `create(TaildropReceivingFileSchema)` to create a new message. + */ +export const TaildropReceivingFileSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_daemon_started_service, 52); + +/** + * @generated from message daemon.TaildropSendClientMessage + */ +export type TaildropSendClientMessage = Message<"daemon.TaildropSendClientMessage"> & { + /** + * @generated from oneof daemon.TaildropSendClientMessage.message + */ + message: { + /** + * @generated from field: daemon.TaildropSendStart start = 1; + */ + value: TaildropSendStart; + case: "start"; + } | { + /** + * @generated from field: daemon.TaildropFileChunk chunk = 2; + */ + value: TaildropFileChunk; + case: "chunk"; + } | { + /** + * @generated from field: daemon.TaildropFileDone fileDone = 3; + */ + value: TaildropFileDone; + case: "fileDone"; + } | { case: undefined; value?: undefined }; +}; + +/** + * Describes the message daemon.TaildropSendClientMessage. + * Use `create(TaildropSendClientMessageSchema)` to create a new message. + */ +export const TaildropSendClientMessageSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_daemon_started_service, 53); + +/** + * @generated from message daemon.TaildropSendStart + */ +export type TaildropSendStart = Message<"daemon.TaildropSendStart"> & { + /** + * @generated from field: string endpointTag = 1; + */ + endpointTag: string; + + /** + * @generated from field: string peerStableID = 2; + */ + peerStableID: string; + + /** + * @generated from field: repeated daemon.TaildropOutgoingFile files = 3; + */ + files: TaildropOutgoingFile[]; +}; + +/** + * Describes the message daemon.TaildropSendStart. + * Use `create(TaildropSendStartSchema)` to create a new message. + */ +export const TaildropSendStartSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_daemon_started_service, 54); + +/** + * @generated from message daemon.TaildropOutgoingFile + */ +export type TaildropOutgoingFile = Message<"daemon.TaildropOutgoingFile"> & { + /** + * @generated from field: string name = 1; + */ + name: string; + + /** + * @generated from field: int64 size = 2; + */ + size: bigint; +}; + +/** + * Describes the message daemon.TaildropOutgoingFile. + * Use `create(TaildropOutgoingFileSchema)` to create a new message. + */ +export const TaildropOutgoingFileSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_daemon_started_service, 55); + +/** + * @generated from message daemon.TaildropFileChunk + */ +export type TaildropFileChunk = Message<"daemon.TaildropFileChunk"> & { + /** + * @generated from field: bytes data = 1; + */ + data: Uint8Array; +}; + +/** + * Describes the message daemon.TaildropFileChunk. + * Use `create(TaildropFileChunkSchema)` to create a new message. + */ +export const TaildropFileChunkSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_daemon_started_service, 56); + +/** + * @generated from message daemon.TaildropFileDone + */ +export type TaildropFileDone = Message<"daemon.TaildropFileDone"> & { +}; + +/** + * Describes the message daemon.TaildropFileDone. + * Use `create(TaildropFileDoneSchema)` to create a new message. + */ +export const TaildropFileDoneSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_daemon_started_service, 57); + +/** + * @generated from message daemon.TaildropSendServerMessage + */ +export type TaildropSendServerMessage = Message<"daemon.TaildropSendServerMessage"> & { + /** + * @generated from oneof daemon.TaildropSendServerMessage.message + */ + message: { + /** + * @generated from field: daemon.TaildropSendProgress progress = 1; + */ + value: TaildropSendProgress; + case: "progress"; + } | { + /** + * @generated from field: int64 receivedBytes = 2; + */ + value: bigint; + case: "receivedBytes"; + } | { case: undefined; value?: undefined }; +}; + +/** + * Describes the message daemon.TaildropSendServerMessage. + * Use `create(TaildropSendServerMessageSchema)` to create a new message. + */ +export const TaildropSendServerMessageSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_daemon_started_service, 58); + +/** + * @generated from message daemon.TaildropSendProgress + */ +export type TaildropSendProgress = Message<"daemon.TaildropSendProgress"> & { + /** + * @generated from field: int32 fileIndex = 1; + */ + fileIndex: number; + + /** + * @generated from field: int64 sentBytes = 2; + */ + sentBytes: bigint; + + /** + * @generated from field: bool fileCompleted = 3; + */ + fileCompleted: boolean; +}; + +/** + * Describes the message daemon.TaildropSendProgress. + * Use `create(TaildropSendProgressSchema)` to create a new message. + */ +export const TaildropSendProgressSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_daemon_started_service, 59); + +/** + * @generated from message daemon.DownloadTaildropFileRequest + */ +export type DownloadTaildropFileRequest = Message<"daemon.DownloadTaildropFileRequest"> & { + /** + * @generated from field: string endpointTag = 1; + */ + endpointTag: string; + + /** + * @generated from field: string name = 2; + */ + name: string; +}; + +/** + * Describes the message daemon.DownloadTaildropFileRequest. + * Use `create(DownloadTaildropFileRequestSchema)` to create a new message. + */ +export const DownloadTaildropFileRequestSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_daemon_started_service, 60); + +/** + * @generated from message daemon.DownloadTaildropFileChunk + */ +export type DownloadTaildropFileChunk = Message<"daemon.DownloadTaildropFileChunk"> & { + /** + * @generated from field: int64 size = 1; + */ + size: bigint; + + /** + * @generated from field: bytes data = 2; + */ + data: Uint8Array; +}; + +/** + * Describes the message daemon.DownloadTaildropFileChunk. + * Use `create(DownloadTaildropFileChunkSchema)` to create a new message. + */ +export const DownloadTaildropFileChunkSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_daemon_started_service, 61); + +/** + * @generated from message daemon.DeleteTaildropFileRequest + */ +export type DeleteTaildropFileRequest = Message<"daemon.DeleteTaildropFileRequest"> & { + /** + * @generated from field: string endpointTag = 1; + */ + endpointTag: string; + + /** + * @generated from field: string name = 2; + */ + name: string; +}; + +/** + * Describes the message daemon.DeleteTaildropFileRequest. + * Use `create(DeleteTaildropFileRequestSchema)` to create a new message. + */ +export const DeleteTaildropFileRequestSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_daemon_started_service, 62); + +/** + * @generated from message daemon.CancelTaildropReceivingRequest + */ +export type CancelTaildropReceivingRequest = Message<"daemon.CancelTaildropReceivingRequest"> & { + /** + * @generated from field: string endpointTag = 1; + */ + endpointTag: string; + + /** + * @generated from field: string senderID = 2; + */ + senderID: string; + + /** + * @generated from field: string name = 3; + */ + name: string; +}; + +/** + * Describes the message daemon.CancelTaildropReceivingRequest. + * Use `create(CancelTaildropReceivingRequestSchema)` to create a new message. + */ +export const CancelTaildropReceivingRequestSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_daemon_started_service, 63); + +/** + * @generated from message daemon.USBProviderMessage + */ +export type USBProviderMessage = Message<"daemon.USBProviderMessage"> & { + /** + * @generated from oneof daemon.USBProviderMessage.message + */ + message: { + /** + * @generated from field: daemon.USBDeviceAttach attach = 1; + */ + value: USBDeviceAttach; + case: "attach"; + } | { + /** + * @generated from field: daemon.USBDeviceDetach detach = 2; + */ + value: USBDeviceDetach; + case: "detach"; + } | { + /** + * @generated from field: daemon.USBURBResponse urbResponse = 3; + */ + value: USBURBResponse; + case: "urbResponse"; + } | { case: undefined; value?: undefined }; +}; + +/** + * Describes the message daemon.USBProviderMessage. + * Use `create(USBProviderMessageSchema)` to create a new message. + */ +export const USBProviderMessageSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_daemon_started_service, 64); + +/** + * @generated from message daemon.USBServerMessage + */ +export type USBServerMessage = Message<"daemon.USBServerMessage"> & { + /** + * @generated from oneof daemon.USBServerMessage.message + */ + message: { + /** + * @generated from field: daemon.USBDeviceReady ready = 1; + */ + value: USBDeviceReady; + case: "ready"; + } | { + /** + * @generated from field: daemon.USBURBRequest urbRequest = 2; + */ + value: USBURBRequest; + case: "urbRequest"; + } | { + /** + * @generated from field: daemon.USBEndpointAbort abort = 3; + */ + value: USBEndpointAbort; + case: "abort"; + } | { + /** + * @generated from field: daemon.USBError error = 4; + */ + value: USBError; + case: "error"; + } | { case: undefined; value?: undefined }; +}; + +/** + * Describes the message daemon.USBServerMessage. + * Use `create(USBServerMessageSchema)` to create a new message. + */ +export const USBServerMessageSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_daemon_started_service, 65); + +/** + * @generated from message daemon.USBDeviceDescriptor + */ +export type USBDeviceDescriptor = Message<"daemon.USBDeviceDescriptor"> & { + /** + * @generated from field: string deviceId = 1; + */ + deviceId: string; + + /** + * @generated from field: uint32 busNum = 2; + */ + busNum: number; + + /** + * @generated from field: uint32 devNum = 3; + */ + devNum: number; + + /** + * @generated from field: uint32 speed = 4; + */ + speed: number; + + /** + * @generated from field: uint32 vendorId = 5; + */ + vendorId: number; + + /** + * @generated from field: uint32 productId = 6; + */ + productId: number; + + /** + * @generated from field: uint32 bcdDevice = 7; + */ + bcdDevice: number; + + /** + * @generated from field: uint32 deviceClass = 8; + */ + deviceClass: number; + + /** + * @generated from field: uint32 deviceSubClass = 9; + */ + deviceSubClass: number; + + /** + * @generated from field: uint32 deviceProtocol = 10; + */ + deviceProtocol: number; + + /** + * @generated from field: uint32 configurationValue = 11; + */ + configurationValue: number; + + /** + * @generated from field: uint32 numConfigurations = 12; + */ + numConfigurations: number; + + /** + * @generated from field: repeated daemon.USBInterface interfaces = 13; + */ + interfaces: USBInterface[]; + + /** + * @generated from field: string serial = 14; + */ + serial: string; + + /** + * @generated from field: string product = 15; + */ + product: string; +}; + +/** + * Describes the message daemon.USBDeviceDescriptor. + * Use `create(USBDeviceDescriptorSchema)` to create a new message. + */ +export const USBDeviceDescriptorSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_daemon_started_service, 66); + +/** + * @generated from message daemon.USBDeviceAttach + */ +export type USBDeviceAttach = Message<"daemon.USBDeviceAttach"> & { + /** + * @generated from field: string serverTag = 1; + */ + serverTag: string; + + /** + * @generated from field: daemon.USBDeviceDescriptor descriptor = 2; + */ + descriptor?: USBDeviceDescriptor; +}; + +/** + * Describes the message daemon.USBDeviceAttach. + * Use `create(USBDeviceAttachSchema)` to create a new message. + */ +export const USBDeviceAttachSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_daemon_started_service, 67); + +/** + * @generated from message daemon.USBInterface + */ +export type USBInterface = Message<"daemon.USBInterface"> & { + /** + * @generated from field: uint32 interfaceClass = 1; + */ + interfaceClass: number; + + /** + * @generated from field: uint32 interfaceSubClass = 2; + */ + interfaceSubClass: number; + + /** + * @generated from field: uint32 interfaceProtocol = 3; + */ + interfaceProtocol: number; +}; + +/** + * Describes the message daemon.USBInterface. + * Use `create(USBInterfaceSchema)` to create a new message. + */ +export const USBInterfaceSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_daemon_started_service, 68); + +/** + * @generated from message daemon.USBDeviceDetach + */ +export type USBDeviceDetach = Message<"daemon.USBDeviceDetach"> & { + /** + * @generated from field: string deviceId = 1; + */ + deviceId: string; +}; + +/** + * Describes the message daemon.USBDeviceDetach. + * Use `create(USBDeviceDetachSchema)` to create a new message. + */ +export const USBDeviceDetachSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_daemon_started_service, 69); + +/** + * @generated from message daemon.USBDeviceReady + */ +export type USBDeviceReady = Message<"daemon.USBDeviceReady"> & { + /** + * @generated from field: string deviceId = 1; + */ + deviceId: string; + + /** + * @generated from field: string busId = 2; + */ + busId: string; +}; + +/** + * Describes the message daemon.USBDeviceReady. + * Use `create(USBDeviceReadySchema)` to create a new message. + */ +export const USBDeviceReadySchema: GenMessage = /*@__PURE__*/ + messageDesc(file_daemon_started_service, 70); + +/** + * @generated from message daemon.USBURBRequest + */ +export type USBURBRequest = Message<"daemon.USBURBRequest"> & { + /** + * @generated from field: string deviceId = 1; + */ + deviceId: string; + + /** + * @generated from field: uint64 seq = 2; + */ + seq: bigint; + + /** + * @generated from field: uint32 endpoint = 3; + */ + endpoint: number; + + /** + * @generated from field: bool directionIn = 4; + */ + directionIn: boolean; + + /** + * @generated from field: uint32 transferFlags = 5; + */ + transferFlags: number; + + /** + * @generated from field: bytes setup = 6; + */ + setup: Uint8Array; + + /** + * @generated from field: uint32 transferBufferLength = 7; + */ + transferBufferLength: number; + + /** + * @generated from field: bytes outData = 8; + */ + outData: Uint8Array; + + /** + * @generated from field: int32 numberOfPackets = 9; + */ + numberOfPackets: number; + + /** + * @generated from field: int32 startFrame = 10; + */ + startFrame: number; + + /** + * @generated from field: int32 interval = 11; + */ + interval: number; + + /** + * @generated from field: repeated daemon.USBIsoPacket isoPackets = 12; + */ + isoPackets: USBIsoPacket[]; +}; + +/** + * Describes the message daemon.USBURBRequest. + * Use `create(USBURBRequestSchema)` to create a new message. + */ +export const USBURBRequestSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_daemon_started_service, 71); + +/** + * @generated from message daemon.USBURBResponse + */ +export type USBURBResponse = Message<"daemon.USBURBResponse"> & { + /** + * @generated from field: string deviceId = 1; + */ + deviceId: string; + + /** + * @generated from field: uint64 seq = 2; + */ + seq: bigint; + + /** + * @generated from field: int32 status = 3; + */ + status: number; + + /** + * @generated from field: int32 actualLength = 4; + */ + actualLength: number; + + /** + * @generated from field: bytes inData = 5; + */ + inData: Uint8Array; + + /** + * @generated from field: repeated daemon.USBIsoPacket isoPackets = 6; + */ + isoPackets: USBIsoPacket[]; +}; + +/** + * Describes the message daemon.USBURBResponse. + * Use `create(USBURBResponseSchema)` to create a new message. + */ +export const USBURBResponseSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_daemon_started_service, 72); + +/** + * @generated from message daemon.USBIsoPacket + */ +export type USBIsoPacket = Message<"daemon.USBIsoPacket"> & { + /** + * @generated from field: int32 offset = 1; + */ + offset: number; + + /** + * @generated from field: int32 length = 2; + */ + length: number; + + /** + * @generated from field: int32 actualLength = 3; + */ + actualLength: number; + + /** + * @generated from field: int32 status = 4; + */ + status: number; +}; + +/** + * Describes the message daemon.USBIsoPacket. + * Use `create(USBIsoPacketSchema)` to create a new message. + */ +export const USBIsoPacketSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_daemon_started_service, 73); + +/** + * @generated from message daemon.USBEndpointAbort + */ +export type USBEndpointAbort = Message<"daemon.USBEndpointAbort"> & { + /** + * @generated from field: string deviceId = 1; + */ + deviceId: string; + + /** + * @generated from field: uint32 endpoint = 2; + */ + endpoint: number; +}; + +/** + * Describes the message daemon.USBEndpointAbort. + * Use `create(USBEndpointAbortSchema)` to create a new message. + */ +export const USBEndpointAbortSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_daemon_started_service, 74); + +/** + * @generated from message daemon.USBError + */ +export type USBError = Message<"daemon.USBError"> & { + /** + * @generated from field: string deviceId = 1; + */ + deviceId: string; + + /** + * @generated from field: string message = 2; + */ + message: string; +}; + +/** + * Describes the message daemon.USBError. + * Use `create(USBErrorSchema)` to create a new message. + */ +export const USBErrorSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_daemon_started_service, 75); + +/** + * @generated from message daemon.USBIPServerStatusUpdate + */ +export type USBIPServerStatusUpdate = Message<"daemon.USBIPServerStatusUpdate"> & { + /** + * @generated from field: repeated daemon.USBIPServerStatus servers = 1; + */ + servers: USBIPServerStatus[]; +}; + +/** + * Describes the message daemon.USBIPServerStatusUpdate. + * Use `create(USBIPServerStatusUpdateSchema)` to create a new message. + */ +export const USBIPServerStatusUpdateSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_daemon_started_service, 76); + +/** + * @generated from message daemon.USBIPServerStatus + */ +export type USBIPServerStatus = Message<"daemon.USBIPServerStatus"> & { + /** + * @generated from field: string serverTag = 1; + */ + serverTag: string; + + /** + * @generated from field: repeated daemon.USBSharedDevice devices = 2; + */ + devices: USBSharedDevice[]; +}; + +/** + * Describes the message daemon.USBIPServerStatus. + * Use `create(USBIPServerStatusSchema)` to create a new message. + */ +export const USBIPServerStatusSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_daemon_started_service, 77); + +/** + * @generated from message daemon.USBSharedDevice + */ +export type USBSharedDevice = Message<"daemon.USBSharedDevice"> & { + /** + * @generated from field: daemon.USBDeviceDescriptor descriptor = 1; + */ + descriptor?: USBDeviceDescriptor; + + /** + * @generated from field: string busId = 2; + */ + busId: string; + + /** + * @generated from field: string stableId = 3; + */ + stableId: string; + + /** + * @generated from field: daemon.USBBackend backend = 4; + */ + backend: USBBackend; + + /** + * @generated from field: daemon.USBDeviceState state = 5; + */ + state: USBDeviceState; +}; + +/** + * Describes the message daemon.USBSharedDevice. + * Use `create(USBSharedDeviceSchema)` to create a new message. + */ +export const USBSharedDeviceSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_daemon_started_service, 78); + +/** + * @generated from message daemon.OpenConnectStatusUpdate + */ +export type OpenConnectStatusUpdate = Message<"daemon.OpenConnectStatusUpdate"> & { + /** + * @generated from field: repeated daemon.OpenConnectEndpointStatus endpoints = 1; + */ + endpoints: OpenConnectEndpointStatus[]; +}; + +/** + * Describes the message daemon.OpenConnectStatusUpdate. + * Use `create(OpenConnectStatusUpdateSchema)` to create a new message. + */ +export const OpenConnectStatusUpdateSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_daemon_started_service, 79); + +/** + * @generated from message daemon.OpenConnectEndpointStatus + */ +export type OpenConnectEndpointStatus = Message<"daemon.OpenConnectEndpointStatus"> & { + /** + * @generated from field: string endpointTag = 1; + */ + endpointTag: string; + + /** + * @generated from field: string state = 2; + */ + state: string; + + /** + * @generated from field: string stateText = 3; + */ + stateText: string; + + /** + * @generated from field: daemon.OpenConnectAuthChallenge authChallenge = 4; + */ + authChallenge?: OpenConnectAuthChallenge; + + /** + * @generated from field: string error = 5; + */ + error: string; + + /** + * @generated from field: daemon.OpenConnectTunnelInfo tunnelInfo = 6; + */ + tunnelInfo?: OpenConnectTunnelInfo; +}; + +/** + * Describes the message daemon.OpenConnectEndpointStatus. + * Use `create(OpenConnectEndpointStatusSchema)` to create a new message. + */ +export const OpenConnectEndpointStatusSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_daemon_started_service, 80); + +/** + * @generated from message daemon.OpenConnectTunnelInfo + */ +export type OpenConnectTunnelInfo = Message<"daemon.OpenConnectTunnelInfo"> & { + /** + * @generated from field: string server = 1; + */ + server: string; + + /** + * @generated from field: string flavor = 2; + */ + flavor: string; + + /** + * @generated from field: string transport = 3; + */ + transport: string; + + /** + * @generated from field: repeated string ipv4 = 4; + */ + ipv4: string[]; + + /** + * @generated from field: repeated string ipv6 = 5; + */ + ipv6: string[]; + + /** + * @generated from field: repeated string dns = 6; + */ + dns: string[]; + + /** + * @generated from field: uint32 mtu = 7; + */ + mtu: number; + + /** + * @generated from field: int64 connectedSince = 8; + */ + connectedSince: bigint; +}; + +/** + * Describes the message daemon.OpenConnectTunnelInfo. + * Use `create(OpenConnectTunnelInfoSchema)` to create a new message. + */ +export const OpenConnectTunnelInfoSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_daemon_started_service, 81); + +/** + * @generated from message daemon.OpenConnectAuthChallenge + */ +export type OpenConnectAuthChallenge = Message<"daemon.OpenConnectAuthChallenge"> & { + /** + * @generated from field: string id = 1; + */ + id: string; + + /** + * @generated from field: string banner = 2; + */ + banner: string; + + /** + * @generated from field: string message = 3; + */ + message: string; + + /** + * @generated from field: string error = 4; + */ + error: string; + + /** + * @generated from oneof daemon.OpenConnectAuthChallenge.challenge + */ + challenge: { + /** + * @generated from field: daemon.OpenConnectAuthForm form = 5; + */ + value: OpenConnectAuthForm; + case: "form"; + } | { + /** + * @generated from field: daemon.OpenConnectBrowserRequest browser = 6; + */ + value: OpenConnectBrowserRequest; + case: "browser"; + } | { case: undefined; value?: undefined }; +}; + +/** + * Describes the message daemon.OpenConnectAuthChallenge. + * Use `create(OpenConnectAuthChallengeSchema)` to create a new message. + */ +export const OpenConnectAuthChallengeSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_daemon_started_service, 82); + +/** + * @generated from message daemon.OpenConnectAuthForm + */ +export type OpenConnectAuthForm = Message<"daemon.OpenConnectAuthForm"> & { + /** + * @generated from field: repeated daemon.OpenConnectAuthFormField fields = 1; + */ + fields: OpenConnectAuthFormField[]; +}; + +/** + * Describes the message daemon.OpenConnectAuthForm. + * Use `create(OpenConnectAuthFormSchema)` to create a new message. + */ +export const OpenConnectAuthFormSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_daemon_started_service, 83); + +/** + * @generated from message daemon.OpenConnectAuthFormField + */ +export type OpenConnectAuthFormField = Message<"daemon.OpenConnectAuthFormField"> & { + /** + * @generated from field: string submissionKey = 1; + */ + submissionKey: string; + + /** + * @generated from field: string name = 2; + */ + name: string; + + /** + * @generated from field: string label = 3; + */ + label: string; + + /** + * @generated from field: string kind = 4; + */ + kind: string; + + /** + * @generated from field: string value = 5; + */ + value: string; + + /** + * @generated from field: repeated daemon.OpenConnectAuthFormChoice options = 6; + */ + options: OpenConnectAuthFormChoice[]; +}; + +/** + * Describes the message daemon.OpenConnectAuthFormField. + * Use `create(OpenConnectAuthFormFieldSchema)` to create a new message. + */ +export const OpenConnectAuthFormFieldSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_daemon_started_service, 84); + +/** + * @generated from message daemon.OpenConnectAuthFormChoice + */ +export type OpenConnectAuthFormChoice = Message<"daemon.OpenConnectAuthFormChoice"> & { + /** + * @generated from field: string value = 1; + */ + value: string; + + /** + * @generated from field: string label = 2; + */ + label: string; +}; + +/** + * Describes the message daemon.OpenConnectAuthFormChoice. + * Use `create(OpenConnectAuthFormChoiceSchema)` to create a new message. + */ +export const OpenConnectAuthFormChoiceSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_daemon_started_service, 85); + +/** + * @generated from message daemon.OpenConnectBrowserRequest + */ +export type OpenConnectBrowserRequest = Message<"daemon.OpenConnectBrowserRequest"> & { + /** + * @generated from field: string url = 1; + */ + url: string; + + /** + * @generated from field: string finalURL = 2; + */ + finalURL: string; + + /** + * @generated from field: repeated string cookieNames = 3; + */ + cookieNames: string[]; + + /** + * @generated from field: repeated string headerNames = 4; + */ + headerNames: string[]; + + /** + * @generated from field: repeated string callbackURLPrefixes = 5; + */ + callbackURLPrefixes: string[]; + + /** + * @generated from field: repeated string earlyCookieNames = 6; + */ + earlyCookieNames: string[]; + + /** + * @generated from field: string cacheID = 7; + */ + cacheID: string; +}; + +/** + * Describes the message daemon.OpenConnectBrowserRequest. + * Use `create(OpenConnectBrowserRequestSchema)` to create a new message. + */ +export const OpenConnectBrowserRequestSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_daemon_started_service, 86); + +/** + * @generated from message daemon.OpenConnectBrowserCookie + */ +export type OpenConnectBrowserCookie = Message<"daemon.OpenConnectBrowserCookie"> & { + /** + * @generated from field: string name = 1; + */ + name: string; + + /** + * @generated from field: string value = 2; + */ + value: string; +}; + +/** + * Describes the message daemon.OpenConnectBrowserCookie. + * Use `create(OpenConnectBrowserCookieSchema)` to create a new message. + */ +export const OpenConnectBrowserCookieSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_daemon_started_service, 87); + +/** + * @generated from message daemon.OpenConnectBrowserHeader + */ +export type OpenConnectBrowserHeader = Message<"daemon.OpenConnectBrowserHeader"> & { + /** + * @generated from field: string name = 1; + */ + name: string; + + /** + * @generated from field: repeated string values = 2; + */ + values: string[]; +}; + +/** + * Describes the message daemon.OpenConnectBrowserHeader. + * Use `create(OpenConnectBrowserHeaderSchema)` to create a new message. + */ +export const OpenConnectBrowserHeaderSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_daemon_started_service, 88); + +/** + * @generated from message daemon.OpenConnectAuthFormResponse + */ +export type OpenConnectAuthFormResponse = Message<"daemon.OpenConnectAuthFormResponse"> & { + /** + * @generated from field: map values = 1; + */ + values: { [key: string]: string }; +}; + +/** + * Describes the message daemon.OpenConnectAuthFormResponse. + * Use `create(OpenConnectAuthFormResponseSchema)` to create a new message. + */ +export const OpenConnectAuthFormResponseSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_daemon_started_service, 89); + +/** + * @generated from message daemon.OpenConnectBrowserResult + */ +export type OpenConnectBrowserResult = Message<"daemon.OpenConnectBrowserResult"> & { + /** + * @generated from field: string finalURL = 1; + */ + finalURL: string; + + /** + * @generated from field: repeated daemon.OpenConnectBrowserCookie cookies = 2; + */ + cookies: OpenConnectBrowserCookie[]; + + /** + * @generated from field: repeated daemon.OpenConnectBrowserHeader headers = 3; + */ + headers: OpenConnectBrowserHeader[]; +}; + +/** + * Describes the message daemon.OpenConnectBrowserResult. + * Use `create(OpenConnectBrowserResultSchema)` to create a new message. + */ +export const OpenConnectBrowserResultSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_daemon_started_service, 90); + +/** + * @generated from message daemon.OpenConnectAuthResponseSubmission + */ +export type OpenConnectAuthResponseSubmission = Message<"daemon.OpenConnectAuthResponseSubmission"> & { + /** + * @generated from field: string endpointTag = 1; + */ + endpointTag: string; + + /** + * @generated from field: string challengeID = 2; + */ + challengeID: string; + + /** + * @generated from oneof daemon.OpenConnectAuthResponseSubmission.response + */ + response: { + /** + * @generated from field: daemon.OpenConnectAuthFormResponse form = 3; + */ + value: OpenConnectAuthFormResponse; + case: "form"; + } | { + /** + * @generated from field: daemon.OpenConnectBrowserResult browser = 4; + */ + value: OpenConnectBrowserResult; + case: "browser"; + } | { case: undefined; value?: undefined }; +}; + +/** + * Describes the message daemon.OpenConnectAuthResponseSubmission. + * Use `create(OpenConnectAuthResponseSubmissionSchema)` to create a new message. + */ +export const OpenConnectAuthResponseSubmissionSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_daemon_started_service, 91); + +/** + * @generated from message daemon.OpenConnectAuthChallengeCancel + */ +export type OpenConnectAuthChallengeCancel = Message<"daemon.OpenConnectAuthChallengeCancel"> & { + /** + * @generated from field: string endpointTag = 1; + */ + endpointTag: string; + + /** + * @generated from field: string challengeID = 2; + */ + challengeID: string; +}; + +/** + * Describes the message daemon.OpenConnectAuthChallengeCancel. + * Use `create(OpenConnectAuthChallengeCancelSchema)` to create a new message. + */ +export const OpenConnectAuthChallengeCancelSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_daemon_started_service, 92); + +/** + * @generated from message daemon.OpenVPNStatusUpdate + */ +export type OpenVPNStatusUpdate = Message<"daemon.OpenVPNStatusUpdate"> & { + /** + * @generated from field: repeated daemon.OpenVPNEndpointStatus endpoints = 1; + */ + endpoints: OpenVPNEndpointStatus[]; +}; + +/** + * Describes the message daemon.OpenVPNStatusUpdate. + * Use `create(OpenVPNStatusUpdateSchema)` to create a new message. + */ +export const OpenVPNStatusUpdateSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_daemon_started_service, 93); + +/** + * @generated from message daemon.OpenVPNEndpointStatus + */ +export type OpenVPNEndpointStatus = Message<"daemon.OpenVPNEndpointStatus"> & { + /** + * @generated from field: string endpointTag = 1; + */ + endpointTag: string; + + /** + * @generated from field: string state = 2; + */ + state: string; + + /** + * @generated from field: string stateText = 3; + */ + stateText: string; + + /** + * @generated from field: daemon.OpenVPNChallenge challenge = 4; + */ + challenge?: OpenVPNChallenge; + + /** + * @generated from field: string error = 5; + */ + error: string; + + /** + * @generated from field: daemon.OpenVPNTunnelInfo tunnelInfo = 6; + */ + tunnelInfo?: OpenVPNTunnelInfo; +}; + +/** + * Describes the message daemon.OpenVPNEndpointStatus. + * Use `create(OpenVPNEndpointStatusSchema)` to create a new message. + */ +export const OpenVPNEndpointStatusSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_daemon_started_service, 94); + +/** + * @generated from message daemon.OpenVPNTunnelInfo + */ +export type OpenVPNTunnelInfo = Message<"daemon.OpenVPNTunnelInfo"> & { + /** + * @generated from field: string server = 1; + */ + server: string; + + /** + * @generated from field: string network = 3; + */ + network: string; + + /** + * @generated from field: repeated string ipv4 = 4; + */ + ipv4: string[]; + + /** + * @generated from field: repeated string ipv6 = 5; + */ + ipv6: string[]; + + /** + * @generated from field: repeated string dns = 6; + */ + dns: string[]; + + /** + * @generated from field: uint32 mtu = 7; + */ + mtu: number; + + /** + * @generated from field: int64 connectedSince = 8; + */ + connectedSince: bigint; + + /** + * @generated from field: string cipher = 9; + */ + cipher: string; +}; + +/** + * Describes the message daemon.OpenVPNTunnelInfo. + * Use `create(OpenVPNTunnelInfoSchema)` to create a new message. + */ +export const OpenVPNTunnelInfoSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_daemon_started_service, 95); + +/** + * @generated from message daemon.OpenVPNChallenge + */ +export type OpenVPNChallenge = Message<"daemon.OpenVPNChallenge"> & { + /** + * @generated from field: string id = 1; + */ + id: string; + + /** + * @generated from field: string kind = 2; + */ + kind: string; + + /** + * @generated from field: string username = 3; + */ + username: string; + + /** + * @generated from field: string message = 4; + */ + message: string; + + /** + * @generated from field: string url = 5; + */ + url: string; + + /** + * @generated from field: string secretMessage = 6; + */ + secretMessage: string; + + /** + * @generated from field: bool echo = 7; + */ + echo: boolean; + + /** + * @generated from field: string previousError = 8; + */ + previousError: string; + + /** + * @generated from field: int64 deadline = 9; + */ + deadline: bigint; +}; + +/** + * Describes the message daemon.OpenVPNChallenge. + * Use `create(OpenVPNChallengeSchema)` to create a new message. + */ +export const OpenVPNChallengeSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_daemon_started_service, 96); + +/** + * @generated from message daemon.OpenVPNChallengeSubmission + */ +export type OpenVPNChallengeSubmission = Message<"daemon.OpenVPNChallengeSubmission"> & { + /** + * @generated from field: string endpointTag = 1; + */ + endpointTag: string; + + /** + * @generated from field: string challengeID = 2; + */ + challengeID: string; + + /** + * @generated from field: string username = 3; + */ + username: string; + + /** + * @generated from field: string password = 4; + */ + password: string; + + /** + * @generated from field: string secret = 5; + */ + secret: string; +}; + +/** + * Describes the message daemon.OpenVPNChallengeSubmission. + * Use `create(OpenVPNChallengeSubmissionSchema)` to create a new message. + */ +export const OpenVPNChallengeSubmissionSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_daemon_started_service, 97); + +/** + * @generated from message daemon.OpenVPNChallengeCancel + */ +export type OpenVPNChallengeCancel = Message<"daemon.OpenVPNChallengeCancel"> & { + /** + * @generated from field: string endpointTag = 1; + */ + endpointTag: string; + + /** + * @generated from field: string challengeID = 2; + */ + challengeID: string; +}; + +/** + * Describes the message daemon.OpenVPNChallengeCancel. + * Use `create(OpenVPNChallengeCancelSchema)` to create a new message. + */ +export const OpenVPNChallengeCancelSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_daemon_started_service, 98); + +/** + * @generated from message daemon.NotificationEvent + */ +export type NotificationEvent = Message<"daemon.NotificationEvent"> & { + /** + * @generated from oneof daemon.NotificationEvent.event + */ + event: { + /** + * @generated from field: daemon.Notification send = 1; + */ + value: Notification; + case: "send"; + } | { + /** + * @generated from field: daemon.NotificationCancel cancel = 2; + */ + value: NotificationCancel; + case: "cancel"; + } | { case: undefined; value?: undefined }; +}; + +/** + * Describes the message daemon.NotificationEvent. + * Use `create(NotificationEventSchema)` to create a new message. + */ +export const NotificationEventSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_daemon_started_service, 99); + +/** + * @generated from message daemon.Notification + */ +export type Notification = Message<"daemon.Notification"> & { + /** + * @generated from field: string identifier = 1; + */ + identifier: string; + + /** + * @generated from field: string typeName = 2; + */ + typeName: string; + + /** + * @generated from field: int32 typeID = 3; + */ + typeID: number; + + /** + * @generated from field: string title = 4; + */ + title: string; + + /** + * @generated from field: string subtitle = 5; + */ + subtitle: string; + + /** + * @generated from field: string body = 6; + */ + body: string; + + /** + * @generated from field: string openURL = 7; + */ + openURL: string; +}; + +/** + * Describes the message daemon.Notification. + * Use `create(NotificationSchema)` to create a new message. + */ +export const NotificationSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_daemon_started_service, 100); + +/** + * @generated from message daemon.NotificationCancel + */ +export type NotificationCancel = Message<"daemon.NotificationCancel"> & { + /** + * @generated from field: string identifier = 1; + */ + identifier: string; + + /** + * @generated from field: int32 typeID = 2; + */ + typeID: number; +}; + +/** + * Describes the message daemon.NotificationCancel. + * Use `create(NotificationCancelSchema)` to create a new message. + */ +export const NotificationCancelSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_daemon_started_service, 101); + +/** + * @generated from enum daemon.LogLevel + */ +export enum LogLevel { + /** + * @generated from enum value: PANIC = 0; + */ + PANIC = 0, + + /** + * @generated from enum value: FATAL = 1; + */ + FATAL = 1, + + /** + * @generated from enum value: ERROR = 2; + */ + ERROR = 2, + + /** + * @generated from enum value: WARN = 3; + */ + WARN = 3, + + /** + * @generated from enum value: INFO = 4; + */ + INFO = 4, + + /** + * @generated from enum value: DEBUG = 5; + */ + DEBUG = 5, + + /** + * @generated from enum value: TRACE = 6; + */ + TRACE = 6, +} + +/** + * Describes the enum daemon.LogLevel. + */ +export const LogLevelSchema: GenEnum = /*@__PURE__*/ + enumDesc(file_daemon_started_service, 0); + +/** + * @generated from enum daemon.ConnectionEventType + */ +export enum ConnectionEventType { + /** + * @generated from enum value: CONNECTION_EVENT_NEW = 0; + */ + CONNECTION_EVENT_NEW = 0, + + /** + * @generated from enum value: CONNECTION_EVENT_UPDATE = 1; + */ + CONNECTION_EVENT_UPDATE = 1, + + /** + * @generated from enum value: CONNECTION_EVENT_CLOSED = 2; + */ + CONNECTION_EVENT_CLOSED = 2, +} + +/** + * Describes the enum daemon.ConnectionEventType. + */ +export const ConnectionEventTypeSchema: GenEnum = /*@__PURE__*/ + enumDesc(file_daemon_started_service, 1); + +/** + * @generated from enum daemon.USBDeviceState + */ +export enum USBDeviceState { + /** + * @generated from enum value: USB_DEVICE_STATE_IDLE = 0; + */ + USB_DEVICE_STATE_IDLE = 0, + + /** + * @generated from enum value: USB_DEVICE_STATE_ATTACHED = 1; + */ + USB_DEVICE_STATE_ATTACHED = 1, + + /** + * @generated from enum value: USB_DEVICE_STATE_UNAVAILABLE = 2; + */ + USB_DEVICE_STATE_UNAVAILABLE = 2, +} + +/** + * Describes the enum daemon.USBDeviceState. + */ +export const USBDeviceStateSchema: GenEnum = /*@__PURE__*/ + enumDesc(file_daemon_started_service, 2); + +/** + * @generated from enum daemon.USBBackend + */ +export enum USBBackend { + /** + * @generated from enum value: USB_BACKEND_UNSPECIFIED = 0; + */ + USB_BACKEND_UNSPECIFIED = 0, + + /** + * @generated from enum value: USB_BACKEND_LINUX_SYSFS = 1; + */ + USB_BACKEND_LINUX_SYSFS = 1, + + /** + * @generated from enum value: USB_BACKEND_DYNAMIC = 2; + */ + USB_BACKEND_DYNAMIC = 2, + + /** + * @generated from enum value: USB_BACKEND_DARWIN_IOKIT = 3; + */ + USB_BACKEND_DARWIN_IOKIT = 3, + + /** + * @generated from enum value: USB_BACKEND_WINDOWS_VBOXUSB = 4; + */ + USB_BACKEND_WINDOWS_VBOXUSB = 4, +} + +/** + * Describes the enum daemon.USBBackend. + */ +export const USBBackendSchema: GenEnum = /*@__PURE__*/ + enumDesc(file_daemon_started_service, 3); + +/** + * @generated from service daemon.StartedService + */ +export const StartedService: GenService<{ + /** + * @generated from rpc daemon.StartedService.GetVersion + */ + getVersion: { + methodKind: "unary"; + input: typeof EmptySchema; + output: typeof VersionSchema; + }, + /** + * @generated from rpc daemon.StartedService.SubscribeServiceStatus + */ + subscribeServiceStatus: { + methodKind: "server_streaming"; + input: typeof EmptySchema; + output: typeof ServiceStatusSchema; + }, + /** + * @generated from rpc daemon.StartedService.SubscribeLog + */ + subscribeLog: { + methodKind: "server_streaming"; + input: typeof EmptySchema; + output: typeof LogSchema; + }, + /** + * @generated from rpc daemon.StartedService.GetDefaultLogLevel + */ + getDefaultLogLevel: { + methodKind: "unary"; + input: typeof EmptySchema; + output: typeof DefaultLogLevelSchema; + }, + /** + * @generated from rpc daemon.StartedService.ClearLogs + */ + clearLogs: { + methodKind: "unary"; + input: typeof EmptySchema; + output: typeof EmptySchema; + }, + /** + * @generated from rpc daemon.StartedService.SubscribeStatus + */ + subscribeStatus: { + methodKind: "server_streaming"; + input: typeof SubscribeStatusRequestSchema; + output: typeof StatusSchema; + }, + /** + * @generated from rpc daemon.StartedService.SubscribeGroups + */ + subscribeGroups: { + methodKind: "server_streaming"; + input: typeof EmptySchema; + output: typeof GroupsSchema; + }, + /** + * @generated from rpc daemon.StartedService.GetClashModeStatus + */ + getClashModeStatus: { + methodKind: "unary"; + input: typeof EmptySchema; + output: typeof ClashModeStatusSchema; + }, + /** + * @generated from rpc daemon.StartedService.SubscribeClashMode + */ + subscribeClashMode: { + methodKind: "server_streaming"; + input: typeof EmptySchema; + output: typeof ClashModeSchema; + }, + /** + * @generated from rpc daemon.StartedService.SetClashMode + */ + setClashMode: { + methodKind: "unary"; + input: typeof ClashModeSchema; + output: typeof EmptySchema; + }, + /** + * @generated from rpc daemon.StartedService.URLTest + */ + uRLTest: { + methodKind: "unary"; + input: typeof URLTestRequestSchema; + output: typeof EmptySchema; + }, + /** + * @generated from rpc daemon.StartedService.SelectOutbound + */ + selectOutbound: { + methodKind: "unary"; + input: typeof SelectOutboundRequestSchema; + output: typeof EmptySchema; + }, + /** + * @generated from rpc daemon.StartedService.SetGroupExpand + */ + setGroupExpand: { + methodKind: "unary"; + input: typeof SetGroupExpandRequestSchema; + output: typeof EmptySchema; + }, + /** + * @generated from rpc daemon.StartedService.SubscribeConnections + */ + subscribeConnections: { + methodKind: "server_streaming"; + input: typeof SubscribeConnectionsRequestSchema; + output: typeof ConnectionEventsSchema; + }, + /** + * @generated from rpc daemon.StartedService.CloseConnection + */ + closeConnection: { + methodKind: "unary"; + input: typeof CloseConnectionRequestSchema; + output: typeof EmptySchema; + }, + /** + * @generated from rpc daemon.StartedService.CloseAllConnections + */ + closeAllConnections: { + methodKind: "unary"; + input: typeof EmptySchema; + output: typeof EmptySchema; + }, + /** + * @generated from rpc daemon.StartedService.GetDeprecatedWarnings + */ + getDeprecatedWarnings: { + methodKind: "unary"; + input: typeof EmptySchema; + output: typeof DeprecatedWarningsSchema; + }, + /** + * @generated from rpc daemon.StartedService.GetStartedAt + */ + getStartedAt: { + methodKind: "unary"; + input: typeof EmptySchema; + output: typeof StartedAtSchema; + }, + /** + * @generated from rpc daemon.StartedService.SubscribeOutbounds + */ + subscribeOutbounds: { + methodKind: "server_streaming"; + input: typeof EmptySchema; + output: typeof OutboundListSchema; + }, + /** + * @generated from rpc daemon.StartedService.StartNetworkQualityTest + */ + startNetworkQualityTest: { + methodKind: "server_streaming"; + input: typeof NetworkQualityTestRequestSchema; + output: typeof NetworkQualityTestProgressSchema; + }, + /** + * @generated from rpc daemon.StartedService.StartSTUNTest + */ + startSTUNTest: { + methodKind: "server_streaming"; + input: typeof STUNTestRequestSchema; + output: typeof STUNTestProgressSchema; + }, + /** + * @generated from rpc daemon.StartedService.SubscribeTailscaleStatus + */ + subscribeTailscaleStatus: { + methodKind: "server_streaming"; + input: typeof EmptySchema; + output: typeof TailscaleStatusUpdateSchema; + }, + /** + * @generated from rpc daemon.StartedService.StartTailscalePing + */ + startTailscalePing: { + methodKind: "server_streaming"; + input: typeof TailscalePingRequestSchema; + output: typeof TailscalePingResponseSchema; + }, + /** + * @generated from rpc daemon.StartedService.SetTailscaleExitNode + */ + setTailscaleExitNode: { + methodKind: "unary"; + input: typeof SetTailscaleExitNodeRequestSchema; + output: typeof EmptySchema; + }, + /** + * @generated from rpc daemon.StartedService.TailscaleLogout + */ + tailscaleLogout: { + methodKind: "unary"; + input: typeof TailscaleLogoutRequestSchema; + output: typeof EmptySchema; + }, + /** + * @generated from rpc daemon.StartedService.GetTailscaleCertificate + */ + getTailscaleCertificate: { + methodKind: "unary"; + input: typeof TailscaleCertificateRequestSchema; + output: typeof TailscaleCertificateSchema; + }, + /** + * @generated from rpc daemon.StartedService.StartTailscaleSSHSession + */ + startTailscaleSSHSession: { + methodKind: "bidi_streaming"; + input: typeof TailscaleSSHClientMessageSchema; + output: typeof TailscaleSSHServerMessageSchema; + }, + /** + * @generated from rpc daemon.StartedService.SubscribeTaildropInbox + */ + subscribeTaildropInbox: { + methodKind: "server_streaming"; + input: typeof SubscribeTaildropInboxRequestSchema; + output: typeof TaildropInboxSchema; + }, + /** + * @generated from rpc daemon.StartedService.MarkTaildropInboxRead + */ + markTaildropInboxRead: { + methodKind: "unary"; + input: typeof MarkTaildropInboxReadRequestSchema; + output: typeof EmptySchema; + }, + /** + * @generated from rpc daemon.StartedService.SendTaildropFiles + */ + sendTaildropFiles: { + methodKind: "bidi_streaming"; + input: typeof TaildropSendClientMessageSchema; + output: typeof TaildropSendServerMessageSchema; + }, + /** + * @generated from rpc daemon.StartedService.DownloadTaildropFile + */ + downloadTaildropFile: { + methodKind: "server_streaming"; + input: typeof DownloadTaildropFileRequestSchema; + output: typeof DownloadTaildropFileChunkSchema; + }, + /** + * @generated from rpc daemon.StartedService.DeleteTaildropFile + */ + deleteTaildropFile: { + methodKind: "unary"; + input: typeof DeleteTaildropFileRequestSchema; + output: typeof EmptySchema; + }, + /** + * @generated from rpc daemon.StartedService.CancelTaildropReceiving + */ + cancelTaildropReceiving: { + methodKind: "unary"; + input: typeof CancelTaildropReceivingRequestSchema; + output: typeof EmptySchema; + }, + /** + * @generated from rpc daemon.StartedService.ProvideUSBDevices + */ + provideUSBDevices: { + methodKind: "bidi_streaming"; + input: typeof USBProviderMessageSchema; + output: typeof USBServerMessageSchema; + }, + /** + * @generated from rpc daemon.StartedService.SubscribeUSBIPServerStatus + */ + subscribeUSBIPServerStatus: { + methodKind: "server_streaming"; + input: typeof EmptySchema; + output: typeof USBIPServerStatusUpdateSchema; + }, + /** + * @generated from rpc daemon.StartedService.SubscribeOpenConnectStatus + */ + subscribeOpenConnectStatus: { + methodKind: "server_streaming"; + input: typeof EmptySchema; + output: typeof OpenConnectStatusUpdateSchema; + }, + /** + * @generated from rpc daemon.StartedService.SubmitOpenConnectAuthResponse + */ + submitOpenConnectAuthResponse: { + methodKind: "unary"; + input: typeof OpenConnectAuthResponseSubmissionSchema; + output: typeof EmptySchema; + }, + /** + * @generated from rpc daemon.StartedService.CancelOpenConnectAuthChallenge + */ + cancelOpenConnectAuthChallenge: { + methodKind: "unary"; + input: typeof OpenConnectAuthChallengeCancelSchema; + output: typeof EmptySchema; + }, + /** + * @generated from rpc daemon.StartedService.SubscribeOpenVPNStatus + */ + subscribeOpenVPNStatus: { + methodKind: "server_streaming"; + input: typeof EmptySchema; + output: typeof OpenVPNStatusUpdateSchema; + }, + /** + * @generated from rpc daemon.StartedService.SubmitOpenVPNChallengeResponse + */ + submitOpenVPNChallengeResponse: { + methodKind: "unary"; + input: typeof OpenVPNChallengeSubmissionSchema; + output: typeof EmptySchema; + }, + /** + * @generated from rpc daemon.StartedService.CancelOpenVPNChallenge + */ + cancelOpenVPNChallenge: { + methodKind: "unary"; + input: typeof OpenVPNChallengeCancelSchema; + output: typeof EmptySchema; + }, + /** + * @generated from rpc daemon.StartedService.SubscribeNotifications + */ + subscribeNotifications: { + methodKind: "server_streaming"; + input: typeof EmptySchema; + output: typeof NotificationEventSchema; + }, +}> = /*@__PURE__*/ + serviceDesc(file_daemon_started_service, 0); diff --git a/src/server/http/routes/liveTrafficRoute.ts b/src/server/http/routes/liveTrafficRoute.ts new file mode 100644 index 0000000..3407e79 --- /dev/null +++ b/src/server/http/routes/liveTrafficRoute.ts @@ -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; +} + +interface DeviceInventoryReader { + snapshot(): unknown; +} + +function record(value: unknown): Record { + return value && typeof value === 'object' && !Array.isArray(value) + ? value as Record + : {}; +} + +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; + }, + }; +} diff --git a/src/server/index.ts b/src/server/index.ts index 90adc6d..51025a5 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -84,6 +84,7 @@ import { createConnectivityDiagnosticsRoute } from './http/routes/connectivityDi import { createGatewayPresenceRoute } from './http/routes/gatewayPresenceRoute.js'; import { createSharedProxyRoute } from './http/routes/sharedProxyRoute.js'; import { createVersionRoute } from './http/routes/versionRoute.js'; +import { createLiveTrafficRoute } from './http/routes/liveTrafficRoute.js'; import { createSingboxSelectorService } from './services/singboxSelectorService.js'; import { createFailoverService } from './features/failover/failoverService.js'; import { createFailoverRoute } from './http/routes/failoverRoute.js'; @@ -260,6 +261,16 @@ function selectRuntime() { throw new Error('Harbor runtime is not configured'); } const singboxRuntime = selectRuntime(); +const clientLiveTraffic = settings.appMode === 'client' + ? (await import('./services/liveTrafficService.js')).createLiveTrafficService({ + port: settings.singboxNativeApiPort, + enabled: settings.singboxTrafficSource === 'native', + isRuntimeRunning: () => Boolean(localRuntime?.running), + }) + : null; +const liveTraffic = clientLiveTraffic || (remoteDataplane ? { + snapshot: () => requireRemoteRuntime().observeLiveTraffic(), +} : null); function requireRemoteRuntime() { if (!remoteRuntime) throw new Error('Harbor dataplane runtime is not configured'); @@ -515,6 +526,10 @@ const versionRoute = createVersionRoute({ ? () => requireRemoteRuntime().refresh() : null, }); +const liveTrafficRoute = createLiveTrafficRoute({ + traffic: liveTraffic, + deviceInventory: remoteDataplane ? deviceInventory : null, +}); const subscriptionValidationRoute = createSubscriptionValidationRoute({ validateSubscription: createValidateSubscription(fetchSubscription), readBody, @@ -887,6 +902,19 @@ function currentConfigMatchesAppliedTarget(state: StoredState) { } catch { return false; } + if (settings.appMode === 'client' || settings.appMode === 'gateway') { + const apiServices = (Array.isArray(config.services) ? config.services : []) + .map(record) + .filter(({ type }) => type === 'api'); + const nativeApiMatches = apiServices.length === 1 + && apiServices[0].listen === '127.0.0.1' + && apiServices[0].listen_port === settings.singboxNativeApiPort + && apiServices[0].dashboard === false + && !Object.hasOwn(apiServices[0], 'secret'); + const nativeApiExpected = settings.singboxTrafficSource === 'native' + || settings.singboxTrafficSource === 'shadow'; + if (nativeApiExpected ? !nativeApiMatches : apiServices.length > 0) return false; + } if (state.appliedFailoverPolicy) { const expectedRole = state.appliedProfileId === state.appliedFailoverPolicy.reserve.profileId && state.appliedServerId === state.appliedFailoverPolicy.reserve.serverId @@ -955,6 +983,8 @@ async function handleApi(req: IncomingMessage, res: ServerResponse) { if (await versionRoute.handle(req, res)) return; + if (await liveTrafficRoute.handle(req, res)) return; + if (await sharedProxyRoute.handle(req, res)) return; if (await deviceInventoryRoute.handle(req, res)) return; @@ -1004,6 +1034,7 @@ async function shutdown() { gatewayAutoService.stopDiscovery(); if (deviceDiscoveryTimer) clearInterval(deviceDiscoveryTimer); await gatewayFailover?.shutdown().catch((error) => console.warn(`[control] failover shutdown: ${errorMessage(error)}`)); + await clientLiveTraffic?.stop().catch((error) => console.warn(`[control] traffic shutdown: ${errorMessage(error)}`)); await serializeControl(() => singboxRuntime.shutdown()); process.exit(0); } @@ -1073,6 +1104,8 @@ if (deviceInventory) { .catch((error: unknown) => console.warn(`[control] device policy не применена: ${errorMessage(error)}`)); } +clientLiveTraffic?.start(); + server.listen(settings.port, '0.0.0.0', () => { console.log(`[control] ${settings.appMode} UI слушает :${settings.port}`); }); diff --git a/src/server/prometheusMetrics.ts b/src/server/prometheusMetrics.ts index 8992e69..7c2686f 100644 --- a/src/server/prometheusMetrics.ts +++ b/src/server/prometheusMetrics.ts @@ -1,6 +1,12 @@ import type { ServerResponse } from 'node:http'; const COUNTER_PATTERN = /^\d+$/; +const SIGNED_DECIMAL_PATTERN = /^-?\d+$/; +const COLLECTOR_MODES = new Set(['snapshot', 'shadow', 'native']); +const COLLECTOR_WRITERS = new Set(['snapshot', 'native']); +const COLLECTOR_STATES = new Set([ + 'connecting', 'live', 'degraded', 'stale', 'stopped', 'incompatible', 'disabled', +]); const labelValue = (value: unknown) => String(value ?? '') .replaceAll('\\', '\\\\') @@ -23,6 +29,19 @@ function counter(value: unknown) { return decimal; } +function signedGauge(value: unknown) { + const decimal = String(value ?? ''); + if (!SIGNED_DECIMAL_PATTERN.test(decimal)) throw new Error(`Invalid Prometheus gauge: ${decimal}`); + return decimal; +} + +function safeInteger(value: unknown, { signed = false } = {}) { + if (!Number.isSafeInteger(value) || (!signed && Number(value) < 0)) { + throw new Error(`Invalid Prometheus gauge: ${String(value)}`); + } + return String(value); +} + function timestamp(value: unknown) { const milliseconds = Date.parse(String(value ?? '')); return Number.isFinite(milliseconds) ? String(milliseconds / 1000) : null; @@ -151,10 +170,64 @@ export function renderPrometheusMetrics(value: unknown) { } const domainTraffic = record(snapshot.domainTraffic); + const collectorSource = record(domainTraffic.source); + const hasCollectorDiagnostics = ['mode', 'writer', 'native', 'shadow'] + .some((field) => Object.hasOwn(collectorSource, field)); + if (hasCollectorDiagnostics) { + const source = collectorSource; + const mode = String(source.mode || ''); + const writer = String(source.writer || ''); + if (!COLLECTOR_MODES.has(mode) || !COLLECTOR_WRITERS.has(writer)) { + throw new Error('Invalid traffic collector labels'); + } + lines.push( + '# HELP harbor_traffic_collector_info Current Gateway traffic collector mode and canonical writer.', + '# TYPE harbor_traffic_collector_info gauge', + ); + metric(lines, 'harbor_traffic_collector_info', { mode, writer }, '1'); + + if (source.native !== null) { + const native = record(source.native); + const state = String(native.state || ''); + if (!COLLECTOR_STATES.has(state)) throw new Error('Invalid traffic collector state'); + lines.push( + '# HELP harbor_traffic_collector_state Current native traffic collector state.', + '# TYPE harbor_traffic_collector_state gauge', + ); + metric(lines, 'harbor_traffic_collector_state', { state }, '1'); + lines.push( + '# HELP harbor_traffic_collector_unattributed_bytes Native traffic bytes not attributed to a lifecycle connection.', + '# TYPE harbor_traffic_collector_unattributed_bytes gauge', + ); + metric(lines, 'harbor_traffic_collector_unattributed_bytes', { direction: 'download' }, counter(native.unattributedDownloadBytes)); + metric(lines, 'harbor_traffic_collector_unattributed_bytes', { direction: 'upload' }, counter(native.unattributedUploadBytes)); + } + + if (source.shadow !== null) { + const shadow = record(source.shadow); + lines.push( + '# HELP harbor_traffic_shadow_active_difference Native active connections minus snapshot active connections.', + '# TYPE harbor_traffic_shadow_active_difference gauge', + `harbor_traffic_shadow_active_difference ${safeInteger(shadow.activeDifference, { signed: true })}`, + '# HELP harbor_traffic_shadow_difference_bytes Native traffic bytes minus snapshot traffic bytes.', + '# TYPE harbor_traffic_shadow_difference_bytes gauge', + ); + metric(lines, 'harbor_traffic_shadow_difference_bytes', { direction: 'download' }, signedGauge(shadow.downloadDifferenceBytes)); + metric(lines, 'harbor_traffic_shadow_difference_bytes', { direction: 'upload' }, signedGauge(shadow.uploadDifferenceBytes)); + lines.push( + '# HELP harbor_traffic_shadow_route_mismatches Route aggregate keys that differ between native and snapshot projections.', + '# TYPE harbor_traffic_shadow_route_mismatches gauge', + `harbor_traffic_shadow_route_mismatches ${safeInteger(shadow.routeMismatches)}`, + '# HELP harbor_traffic_shadow_device_mismatches Device aggregate keys that differ between native and snapshot projections.', + '# TYPE harbor_traffic_shadow_device_mismatches gauge', + `harbor_traffic_shadow_device_mismatches ${safeInteger(shadow.deviceMismatches)}`, + ); + } + } const trackedSeries = Array.isArray(domainTraffic.tracked) ? domainTraffic.tracked.map(record) : []; if (trackedSeries.length) { lines.push( - '# HELP harbor_singbox_tracked_bytes_total Bytes observed by the sing-box TCP/UDP tracker; excludes IP and tunnel overhead and may miss short connections.', + '# HELP harbor_singbox_tracked_bytes_total Bytes observed by the configured sing-box traffic collector; excludes IP and tunnel overhead.', '# TYPE harbor_singbox_tracked_bytes_total counter', ); for (const series of trackedSeries) { diff --git a/src/server/services/domainTrafficService.ts b/src/server/services/domainTrafficService.ts index 8e4e9cd..bffcb3b 100644 --- a/src/server/services/domainTrafficService.ts +++ b/src/server/services/domainTrafficService.ts @@ -3,6 +3,7 @@ import http from 'node:http'; import net from 'node:net'; import { domainToASCII } from 'node:url'; import { deviceId } from './deviceInventoryService.js'; +import type { NativeTrafficProjectionBatch } from './liveTrafficService.js'; const MAX_RESPONSE_BYTES = 4 * 1024 * 1024; const DEFAULT_MAX_SERIES = 4096; @@ -17,6 +18,7 @@ const SERVICE_DOMAINS = [ interface ParsedBaseConnection { id: string; + startedAt?: string; upload: bigint; download: bigint; } @@ -38,6 +40,7 @@ type ParsedConnection = }); interface PreviousConnection { + startedAt?: string; outcome: AttributionOutcome | 'classified'; key?: string; requestedKey?: string; @@ -67,7 +70,7 @@ interface RouteSeriesTotal { interface DomainTrafficSnapshot { epoch: string; observedAt: string | null; - source: { error: string | null }; + source: { error: string | null; activeConnections: number }; overflowConnections: string; attributionEvents: Record; tracked: Array & { @@ -170,6 +173,46 @@ function parseConnection(value: unknown, devicesByIp: Map }; } +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 { return new Promise((resolve, reject) => { const request = http.get({ host: '127.0.0.1', port, path: '/connections' }, (response) => { @@ -223,6 +266,9 @@ export function createDomainTrafficService({ const normalSeriesLimit = maxSeries - 2; let normalSeries = 0; let previousConnections = new Map(); + const settledNativeConnections = new Map(); + let nativeEpoch: string | null = null; + let activeConnections = 0; let overflowConnections = 0n; const attributionEvents: Record = { unresolved_host: 0n, @@ -237,7 +283,7 @@ export function createDomainTrafficService({ let current: DomainTrafficSnapshot = { epoch, observedAt: null, - source: { error: null }, + source: { error: null, activeConnections: 0 }, overflowConnections: '0', attributionEvents: { unresolved_host: '0', unknown_device: '0', unsupported_source: '0' }, tracked: [], @@ -249,7 +295,7 @@ export function createDomainTrafficService({ return { epoch, observedAt: current.observedAt, - source: { error }, + source: { error, activeConnections }, overflowConnections: overflowConnections.toString(), attributionEvents: Object.fromEntries( ATTRIBUTION_OUTCOMES.map((outcome) => [outcome, attributionEvents[outcome].toString()]), @@ -290,6 +336,153 @@ export function createDomainTrafficService({ }; } + function applyParsedConnections({ + connections, + reset, + closedIds = [], + observed, + deviceLabels, + sourceActiveConnections, + }: { + connections: ParsedConnection[]; + reset: boolean; + closedIds?: string[]; + observed: Date; + deviceLabels: Map; + sourceActiveConnections?: number; + }) { + const nextConnections = reset + ? new Map() + : new Map(previousConnections); + const activityEntries: ActivityEntry[] = []; + for (const connection of connections) { + const settled = settledNativeConnections.get(connection.id); + const previous = previousConnections.get(connection.id) + ?? (connection.startedAt && settled?.startedAt === connection.startedAt ? settled : undefined); + if (previous === settled) settledNativeConnections.delete(connection.id); + if (connection.outcome !== 'classified' && previous?.outcome !== connection.outcome) { + attributionEvents[connection.outcome] += 1n; + } + if (connection.outcome !== 'unsupported_source') { + const uploadDelta = previous?.trackedUpload != null && connection.upload >= previous.trackedUpload + ? connection.upload - previous.trackedUpload + : connection.upload; + const downloadDelta = previous?.trackedDownload != null && connection.download >= previous.trackedDownload + ? connection.download - previous.trackedDownload + : connection.download; + const trackedKey = `${connection.source}\0${connection.outbound}`; + const tracked = trackedTotals.get(trackedKey) || { + source: connection.source, + outbound: connection.outbound, + uploadBytes: 0n, + downloadBytes: 0n, + }; + tracked.uploadBytes += uploadDelta; + tracked.downloadBytes += downloadDelta; + trackedTotals.set(trackedKey, tracked); + if (activityEnabled && connection.outbound === 'vpn' && uploadDelta + downloadDelta > 0n) { + activityEntries.push({ + device: 'deviceId' in connection + ? deviceLabels.get(connection.deviceId) || 'Устройство' + : 'Неизвестное устройство', + service: 'service' in connection ? connection.service : 'Не распознано', + upload: uploadDelta, + download: downloadDelta, + }); + } + } + if (connection.outcome === 'unknown_device' || connection.outcome === 'unsupported_source') { + nextConnections.set(connection.id, { + startedAt: connection.startedAt, + outcome: connection.outcome, + countedUpload: previous?.countedUpload ?? null, + countedDownload: previous?.countedDownload ?? null, + trackedUpload: connection.outcome === 'unknown_device' + ? connection.upload + : previous?.trackedUpload ?? null, + trackedDownload: connection.outcome === 'unknown_device' + ? connection.download + : previous?.trackedDownload ?? null, + }); + continue; + } + if (!('deviceId' in connection)) throw new Error('Sing-box вернул невалидную attribution запись'); + const requestedKey = `${connection.deviceId}\0${connection.domain}\0${connection.source}`; + let key = previous?.requestedKey === requestedKey && previous.key ? previous.key : requestedKey; + let domain = connection.domain; + let service = connection.service; + if (key !== requestedKey) { + domain = '_other'; + service = 'Другие домены'; + } else if (!totals.has(key) && normalSeries >= normalSeriesLimit) { + overflowConnections += 1n; + domain = '_other'; + service = 'Другие домены'; + key = `_other\0${domain}\0${connection.source}`; + } else if (!totals.has(key)) { + normalSeries += 1; + } + const uploadDelta = previous?.countedUpload != null && connection.upload >= previous.countedUpload + ? connection.upload - previous.countedUpload + : connection.upload; + const downloadDelta = previous?.countedDownload != null && connection.download >= previous.countedDownload + ? connection.download - previous.countedDownload + : connection.download; + const routeKey = `${connection.deviceId}\0${connection.source}\0${connection.outbound}`; + const routeTotal = routeTotals.get(routeKey) || { + deviceId: connection.deviceId, + source: connection.source, + outbound: connection.outbound, + uploadBytes: 0n, + downloadBytes: 0n, + }; + routeTotal.uploadBytes += uploadDelta; + routeTotal.downloadBytes += downloadDelta; + routeTotals.set(routeKey, routeTotal); + const total = totals.get(key) || { + deviceId: key === requestedKey ? connection.deviceId : '_other', + domain, + service, + source: connection.source, + uploadBytes: 0n, + downloadBytes: 0n, + }; + total.uploadBytes += uploadDelta; + total.downloadBytes += downloadDelta; + totals.set(key, total); + nextConnections.set(connection.id, { + startedAt: connection.startedAt, + outcome: connection.outcome, + key, + requestedKey, + countedUpload: connection.upload, + countedDownload: connection.download, + trackedUpload: connection.upload, + trackedDownload: connection.download, + }); + } + for (const id of closedIds) { + const baseline = nextConnections.get(id); + if (baseline?.startedAt) { + settledNativeConnections.delete(id); + settledNativeConnections.set(id, baseline); + while (settledNativeConnections.size > 2_048) { + settledNativeConnections.delete(settledNativeConnections.keys().next().value as string); + } + } + nextConnections.delete(id); + } + previousConnections = nextConnections; + activeConnections = sourceActiveConnections ?? nextConnections.size; + if (activityEnabled) { + activitySamples.push({ at: observed.getTime(), entries: activityEntries }); + activitySamples = activitySamples.filter(({ at }) => at >= observed.getTime() - 10_000); + } + current = { ...current, observedAt: observed.toISOString() }; + current = buildSnapshot(); + return current; + } + async function performRefresh() { try { const response = record(await observe()); @@ -306,118 +499,46 @@ export function createDomainTrafficService({ deviceLabels.set(id, publicDeviceLabel(device)); } const connections = response.connections.map((connection) => parseConnection(connection, devicesByIp)); - const activeConnections = new Map(); - const activityEntries: ActivityEntry[] = []; - for (const connection of connections) { - const previous = previousConnections.get(connection.id); - if (connection.outcome !== 'classified' && previous?.outcome !== connection.outcome) { - attributionEvents[connection.outcome] += 1n; + nativeEpoch = null; + return applyParsedConnections({ + connections, + reset: true, + observed: now(), + deviceLabels, + sourceActiveConnections: response.connections.length, + }); + } catch (error) { + current = buildSnapshot(error instanceof Error ? error.message : String(error)); + throw error; + } + } + + function ingestNative(batch: NativeTrafficProjectionBatch) { + try { + const observed = new Date(batch.observedAt); + if (!batch.epoch || Number.isNaN(observed.getTime())) throw new Error('Sing-box вернул невалидный native traffic batch'); + const deviceLabels = new Map(); + for (const value of batch.connections) { + const connection = record(value); + const origin = record(connection.origin); + if (origin.kind === 'device' && typeof origin.id === 'string' && origin.id) { + deviceLabels.set(origin.id, publicDeviceLabel({ alias: origin.label })); } - if (connection.outcome !== 'unsupported_source') { - const uploadDelta = previous?.trackedUpload != null && connection.upload >= previous.trackedUpload - ? connection.upload - previous.trackedUpload - : connection.upload; - const downloadDelta = previous?.trackedDownload != null && connection.download >= previous.trackedDownload - ? connection.download - previous.trackedDownload - : connection.download; - const trackedKey = `${connection.source}\0${connection.outbound}`; - const tracked = trackedTotals.get(trackedKey) || { - source: connection.source, - outbound: connection.outbound, - uploadBytes: 0n, - downloadBytes: 0n, - }; - tracked.uploadBytes += uploadDelta; - tracked.downloadBytes += downloadDelta; - trackedTotals.set(trackedKey, tracked); - if (activityEnabled && connection.outbound === 'vpn' && uploadDelta + downloadDelta > 0n) { - activityEntries.push({ - device: 'deviceId' in connection - ? deviceLabels.get(connection.deviceId) || 'Устройство' - : 'Неизвестное устройство', - service: 'service' in connection ? connection.service : 'Не распознано', - upload: uploadDelta, - download: downloadDelta, - }); - } - } - if (connection.outcome === 'unknown_device' || connection.outcome === 'unsupported_source') { - activeConnections.set(connection.id, { - outcome: connection.outcome, - countedUpload: previous?.countedUpload ?? null, - countedDownload: previous?.countedDownload ?? null, - trackedUpload: connection.outcome === 'unknown_device' - ? connection.upload - : previous?.trackedUpload ?? null, - trackedDownload: connection.outcome === 'unknown_device' - ? connection.download - : previous?.trackedDownload ?? null, - }); - continue; - } - if (!('deviceId' in connection)) throw new Error('Sing-box вернул невалидную attribution запись'); - const requestedKey = `${connection.deviceId}\0${connection.domain}\0${connection.source}`; - let key = previous?.requestedKey === requestedKey && previous.key ? previous.key : requestedKey; - let domain = connection.domain; - let service = connection.service; - if (key !== requestedKey) { - domain = '_other'; - service = 'Другие домены'; - } else if (!totals.has(key) && normalSeries >= normalSeriesLimit) { - overflowConnections += 1n; - domain = '_other'; - service = 'Другие домены'; - key = `_other\0${domain}\0${connection.source}`; - } else if (!totals.has(key)) { - normalSeries += 1; - } - const uploadDelta = previous?.countedUpload != null && connection.upload >= previous.countedUpload - ? connection.upload - previous.countedUpload - : connection.upload; - const downloadDelta = previous?.countedDownload != null && connection.download >= previous.countedDownload - ? connection.download - previous.countedDownload - : connection.download; - const routeKey = `${connection.deviceId}\0${connection.source}\0${connection.outbound}`; - const routeTotal = routeTotals.get(routeKey) || { - deviceId: connection.deviceId, - source: connection.source, - outbound: connection.outbound, - uploadBytes: 0n, - downloadBytes: 0n, - }; - routeTotal.uploadBytes += uploadDelta; - routeTotal.downloadBytes += downloadDelta; - routeTotals.set(routeKey, routeTotal); - const total = totals.get(key) || { - deviceId: key === requestedKey ? connection.deviceId : '_other', - domain, - service, - source: connection.source, - uploadBytes: 0n, - downloadBytes: 0n, - }; - total.uploadBytes += uploadDelta; - total.downloadBytes += downloadDelta; - totals.set(key, total); - activeConnections.set(connection.id, { - outcome: connection.outcome, - key, - requestedKey, - countedUpload: connection.upload, - countedDownload: connection.download, - trackedUpload: connection.upload, - trackedDownload: connection.download, - }); } - previousConnections = activeConnections; - const observed = now(); - if (activityEnabled) { - activitySamples.push({ at: observed.getTime(), entries: activityEntries }); - activitySamples = activitySamples.filter(({ at }) => at >= observed.getTime() - 10_000); + if (nativeEpoch !== batch.epoch) { + nativeEpoch = batch.epoch; + previousConnections = new Map(); + settledNativeConnections.clear(); } - current = { ...current, observedAt: observed.toISOString() }; - current = buildSnapshot(); - return current; + const connections = batch.connections.map(parseNativeConnection); + const result = applyParsedConnections({ + connections, + reset: batch.reset, + closedIds: batch.closedIds, + observed, + deviceLabels, + }); + return result; } catch (error) { current = buildSnapshot(error instanceof Error ? error.message : String(error)); throw error; @@ -492,5 +613,5 @@ export function createDomainTrafficService({ }; } - return { snapshot: () => current, refresh, enableActivity, disableActivity, activitySnapshot }; + return { snapshot: () => current, refresh, ingestNative, enableActivity, disableActivity, activitySnapshot }; } diff --git a/src/server/services/liveTrafficService.ts b/src/server/services/liveTrafficService.ts new file mode 100644 index 0000000..9a8c24f --- /dev/null +++ b/src/server/services/liveTrafficService.ts @@ -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; + 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, + options: { signal: AbortSignal; headers?: Record }, + ): Promise<{ version: string; apiVersion: number }>; + getStartedAt( + input: Record, + options: { signal: AbortSignal; headers?: Record }, + ): Promise<{ startedAt: bigint }>; + subscribeConnections( + input: { interval: bigint }, + options: { signal: AbortSignal; headers?: Record }, + ): AsyncIterable; + subscribeStatus( + input: { interval: bigint }, + options: { signal: AbortSignal; headers?: Record }, + ): AsyncIterable; +} + +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; + 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 { + 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 | undefined, + final: Omit | 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(); + const recent = new Map(); + const settled = new Map(); + + 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(); + const closedIds = new Set(); + const closedConnections = new Map(); + 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(); + 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((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 | 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; diff --git a/src/server/singbox.ts b/src/server/singbox.ts index 9ee2bc0..a8a6776 100644 --- a/src/server/singbox.ts +++ b/src/server/singbox.ts @@ -99,6 +99,8 @@ export function buildGatewayConfig(subscriptionConfig: unknown, selectedTag: unk routeRules = [], }: { clientDirect?: boolean; routeRules?: unknown } = {}) { const clientMode = settings.appMode === 'client'; + const nativeTraffic = settings.singboxTrafficSource === 'native' + || settings.singboxTrafficSource === 'shadow'; const directClient = clientMode && clientDirect; const vpnOutbound = selectedOutbound(subscriptionConfig, selectedTag); const outboundTag = directClient ? 'direct' : vpnOutbound.tag; @@ -158,13 +160,21 @@ export function buildGatewayConfig(subscriptionConfig: unknown, selectedTag: unk return { log: { level: settings.logLevel, timestamp: true }, + ...(nativeTraffic ? { + services: [{ + type: 'api', + listen: '127.0.0.1', + listen_port: settings.singboxNativeApiPort, + dashboard: false, + }], + } : {}), experimental: { cache_file: { enabled: true, path: settings.cachePath }, ...(!clientMode ? { clash_api: { external_controller: `127.0.0.1:${settings.singboxApiPort}` }, } : {}), }, - dns: { independent_cache: true }, + dns: nativeTraffic ? {} : { independent_cache: true }, inbounds, outbounds: [ vpnOutbound, @@ -185,6 +195,8 @@ export function buildDualChannelGatewayConfig( { routeRules = [], defaultRole = 'primary' }: { routeRules?: unknown; defaultRole?: 'primary' | 'reserve' } = {}, ) { if (settings.appMode === 'client') throw new Error('Dual-channel config доступен только Gateway'); + const nativeTraffic = settings.singboxTrafficSource === 'native' + || settings.singboxTrafficSource === 'shadow'; const primary = selectedOutbound( channels.primary.subscriptionConfig, channels.primary.selectedServerId, @@ -204,11 +216,19 @@ export function buildDualChannelGatewayConfig( const userInbounds = [TPROXY_INBOUND, MIXED_INBOUND]; return { log: { level: settings.logLevel, timestamp: true }, + ...(nativeTraffic ? { + services: [{ + type: 'api', + listen: '127.0.0.1', + listen_port: settings.singboxNativeApiPort, + dashboard: false, + }], + } : {}), experimental: { cache_file: { enabled: true, path: settings.cachePath }, clash_api: { external_controller: `127.0.0.1:${settings.singboxApiPort}` }, }, - dns: { independent_cache: true }, + dns: nativeTraffic ? {} : { independent_cache: true }, inbounds: [ { type: 'tproxy', tag: TPROXY_INBOUND, listen: '::', listen_port: settings.tproxyPort }, { type: 'mixed', tag: MIXED_INBOUND, listen: settings.bindIp, listen_port: settings.proxyPort, set_system_proxy: false }, diff --git a/src/server/singboxRuntime.ts b/src/server/singboxRuntime.ts index f1723c4..1caa83e 100644 --- a/src/server/singboxRuntime.ts +++ b/src/server/singboxRuntime.ts @@ -3,33 +3,64 @@ import fs from 'node:fs'; import { spawn, spawnSync, type ChildProcess } from 'node:child_process'; import { setGatewayInterception } from './gatewayRouting.js'; import { HarborError } from '../shared/errors.js'; +import { + materializeGatewayNativeConfig, + materializeGatewaySnapshotConfig, +} from './gatewayNativeRuntime.js'; export function createSingboxRuntime({ configPath, gateway = false, tproxyChain = '', + gatewayRuntimeConfigPath, + nativeApi, }: { configPath: string; gateway?: boolean; tproxyChain?: string; + gatewayRuntimeConfigPath?: string; + nativeApi?: { + apiPort: number; + secretPath: string; + runtimeConfigPath: string; + }; }) { let child: ChildProcess | null = null; let configHash = ''; let startedAt: string | null = null; + let nativeApiSecret: string | null = null; + let nativeApiWarning: string | null = null; - const state = () => ({ running: Boolean(child), startedAt }); + const state = () => ({ running: Boolean(child), startedAt, nativeApiWarning }); + + function checked(configFile: string) { + const check = spawnSync('sing-box', ['check', '-c', configFile], { encoding: 'utf8' }); + if (check.status !== 0) { + throw new HarborError('CONFIG_INVALID', { + cause: new Error((check.stderr || check.stdout || check.error?.message || 'sing-box check failed').trim()), + }); + } + } function checkConfig(config: unknown) { + if (nativeApi) { + const materialized = materializeGatewayNativeConfig(config, nativeApi); + checked(materialized.configPath); + return { + valid: true, + ...(materialized.warning ? { warning: materialized.warning } : {}), + }; + } + if (gatewayRuntimeConfigPath) { + const materialized = materializeGatewaySnapshotConfig(config, gatewayRuntimeConfigPath); + checked(materialized.configPath); + return { valid: true }; + } const directory = fs.mkdtempSync(`${configPath}.check-`); const candidatePath = `${directory}/config.json`; try { fs.writeFileSync(candidatePath, JSON.stringify(config)); - const check = spawnSync('sing-box', ['check', '-c', candidatePath], { encoding: 'utf8' }); - if (check.status !== 0) { - throw new HarborError('CONFIG_INVALID', { - cause: new Error((check.stderr || check.stdout || check.error?.message || 'sing-box check failed').trim()), - }); - } + checked(candidatePath); return { valid: true }; } finally { fs.rmSync(directory, { recursive: true, force: true }); @@ -41,6 +72,7 @@ export function createSingboxRuntime({ if (!child) { configHash = ''; startedAt = null; + nativeApiSecret = null; return state(); } @@ -48,6 +80,7 @@ export function createSingboxRuntime({ child = null; configHash = ''; startedAt = null; + nativeApiSecret = null; await new Promise((resolve) => { const timeout = setTimeout(() => { current.kill('SIGKILL'); @@ -65,23 +98,37 @@ export function createSingboxRuntime({ async function apply({ force = false } = {}) { if (!fs.existsSync(configPath)) { await stop(); + nativeApiWarning = null; return state(); } - const check = spawnSync('sing-box', ['check', '-c', configPath], { encoding: 'utf8' }); - if (check.status !== 0) { - throw new HarborError('CONFIG_INVALID', { - cause: new Error((check.stderr || check.stdout || check.error?.message || 'sing-box check failed').trim()), - }); + let materialized = { configPath, secret: null as string | null, warning: null as string | null }; + if (nativeApi || gatewayRuntimeConfigPath) { + let config: unknown; + try { + config = JSON.parse(fs.readFileSync(configPath, 'utf8')); + } catch (cause) { + throw new HarborError('CONFIG_INVALID', { cause }); + } + materialized = nativeApi + ? materializeGatewayNativeConfig(config, nativeApi) + : materializeGatewaySnapshotConfig(config, gatewayRuntimeConfigPath!); } + checked(materialized.configPath); - const nextHash = crypto.createHash('sha256').update(fs.readFileSync(configPath)).digest('hex'); - if (!force && child && nextHash === configHash) return state(); + const nextHash = crypto.createHash('sha256') + .update(fs.readFileSync(materialized.configPath)) + .digest('hex'); + if (!force && child && nextHash === configHash) { + nativeApiSecret = materialized.secret; + nativeApiWarning = materialized.warning; + return state(); + } await stop(); let current: ChildProcess; try { - current = spawn('sing-box', ['run', '-c', configPath], { + current = spawn('sing-box', ['run', '-c', materialized.configPath], { stdio: ['ignore', 'inherit', 'inherit'], }); await new Promise((resolve, reject) => { @@ -94,6 +141,8 @@ export function createSingboxRuntime({ child = current; configHash = nextHash; startedAt = new Date().toISOString(); + nativeApiSecret = materialized.secret; + nativeApiWarning = materialized.warning; try { if (gateway) setGatewayInterception(true, tproxyChain); } catch (error) { @@ -101,6 +150,7 @@ export function createSingboxRuntime({ child = null; configHash = ''; startedAt = null; + nativeApiSecret = null; throw new HarborError('PROCESS_START_FAILED', { cause: error }); } current.once('exit', () => { @@ -108,6 +158,7 @@ export function createSingboxRuntime({ child = null; configHash = ''; startedAt = null; + nativeApiSecret = null; if (gateway) setGatewayInterception(false, tproxyChain); }); return state(); @@ -116,6 +167,8 @@ export function createSingboxRuntime({ return { get running() { return Boolean(child); }, get startedAt() { return startedAt; }, + get nativeApiSecret() { return nativeApiSecret; }, + get nativeApiWarning() { return nativeApiWarning; }, refresh: async () => state(), checkConfig, apply, diff --git a/src/shared/liveTraffic.ts b/src/shared/liveTraffic.ts new file mode 100644 index 0000000..1d0766e --- /dev/null +++ b/src/shared/liveTraffic.ts @@ -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([ + '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 { + if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error('Expected object'); + return value as Record; +} + +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(); + 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; +} diff --git a/src/shared/versions.ts b/src/shared/versions.ts index 6e560be..4f05cda 100644 --- a/src/shared/versions.ts +++ b/src/shared/versions.ts @@ -1,7 +1,7 @@ export const HARBOR_VERSIONS = Object.freeze({ - macClient: '0.33.1', - gatewayClient: '0.34.1', - gatewayBackend: '0.34.0', + macClient: '0.34.0', + gatewayClient: '0.36.0', + gatewayBackend: '0.36.0', }); export interface ParsedVersion { diff --git a/src/web/App.tsx b/src/web/App.tsx index 509c579..0353c06 100644 --- a/src/web/App.tsx +++ b/src/web/App.tsx @@ -29,6 +29,7 @@ const componentActions = { pingServers: api.servers.ping, runConnectivityDiagnostics: api.diagnostics.connectivity, loadActivityJournal: api.activityJournal.page, + loadLiveTraffic: api.traffic.live, }; interface UiError { diff --git a/src/web/api/harborClient.ts b/src/web/api/harborClient.ts index ae6dc2f..dab8b2e 100644 --- a/src/web/api/harborClient.ts +++ b/src/web/api/harborClient.ts @@ -230,6 +230,9 @@ export const api = { activityJournal: { page: (cursor: string | null = null) => request(`/api/activity-journal?limit=50${cursor ? `&cursor=${encodeURIComponent(cursor)}` : ''}`), }, + traffic: { + live: () => request('/api/traffic/live'), + }, singbox: { stop: () => request('/api/singbox/stop', { method: 'POST' }), restart: () => request('/api/singbox/restart', { method: 'POST' }), diff --git a/src/web/components/ClientOverviewPage.tsx b/src/web/components/ClientOverviewPage.tsx index d8304c2..5ba68b4 100644 --- a/src/web/components/ClientOverviewPage.tsx +++ b/src/web/components/ClientOverviewPage.tsx @@ -47,6 +47,11 @@ import { InstructionsToggle, useInstructionsFeature, } from '../features/instructions/index.js'; +import { + TrafficPanel, + TrafficToggle, + useTrafficFeature, +} from '../features/traffic/index.js'; import { FailoverPanel, FailoverToggle, useFailoverFeature } from '../features/failover/index.js'; import { ActivityJournalPanel, @@ -72,7 +77,7 @@ const VERSION_PARTS = [ ] as const; const DRAWER_SWITCH_MS = 620; -const DRAWER_ORDER = ['subscription', 'failover', 'instructions', 'devices', 'diagnostics', 'routing', 'journal'] as const; +const DRAWER_ORDER = ['subscription', 'failover', 'instructions', 'devices', 'traffic', 'diagnostics', 'routing', 'journal'] as const; type DrawerKey = typeof DRAWER_ORDER[number]; const failoverReasonLabel = (reason: string | null) => ({ @@ -128,6 +133,7 @@ interface ComponentActions { pingServers: (profileId: string, ids: string[]) => Promise; runConnectivityDiagnostics: (target?: unknown) => Promise; loadActivityJournal: (cursor?: string | null) => Promise; + loadLiveTraffic: () => Promise; } interface ClientViewState extends StateSnapshot { @@ -586,6 +592,11 @@ export function ClientOverviewPage({ }); const failoverFeature = useFailoverFeature(); const activityJournalFeature = useActivityJournalFeature(); + const trafficFeature = useTrafficFeature({ + enabled: true, + isGateway, + loadLiveTraffic: actions.loadLiveTraffic, + }); const diagnosticsAvailable = hasSubscription; const drawerControls = { subscription: { @@ -612,6 +623,12 @@ export function ClientOverviewPage({ show: devicesFeature.toggle, close: devicesFeature.close, }, + traffic: { + isOpen: trafficFeature.isOpen, + panelRef: trafficFeature.panelRef, + show: trafficFeature.toggle, + close: trafficFeature.close, + }, diagnostics: { isOpen: diagnosticsFeature.isOpen, panelRef: diagnosticsFeature.panelRef, @@ -654,8 +671,9 @@ export function ClientOverviewPage({ diagnosticsFeature.close(); failoverFeature.close(); activityJournalFeature.close(); + trafficFeature.close(); } - }, [hasSubscription, isGateway]); + }, [hasSubscription]); useEffect(() => { if (!diagnosticsAvailable) diagnosticsFeature.close(); @@ -851,6 +869,11 @@ export function ClientOverviewPage({ open={activeRailDrawer === 'devices'} onToggle={() => switchDrawer('devices')} />} + switchDrawer('traffic')} + /> } + {hasSubscription && } + {diagnosticsAvailable && Promise; +} + +const routeLabels: Record = { + 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(null); + const [requestState, setRequestState] = useState('idle'); + const panelRef = useRef(null); + const toggleRef = useRef(null); + const closeRef = useRef(null); + + useEffect(() => { + if (enabled) return; + setIsOpen(false); + setPaused(false); + }, [enabled]); + + useEffect(() => { + if (!enabled || !isOpen || paused) return undefined; + let cancelled = false; + let timer: ReturnType | 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; + +export function TrafficToggle({ + feature, + open, + onToggle, +}: { + feature: TrafficFeature; + open: boolean; + onToggle: () => void; +}) { + return + + ; +} + +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
{ + if (event.target === event.currentTarget && event.animationName === 'client-traffic-connection-out') { + onExited(); + } + }} + > + + {expanded &&
+
Источник
{source}
+
Назначение
{groupDestination(group)}
+
Правило
{group.route.rule || '—'}
+
Цепочка
{chain}
+
} +
; +} + +function TrafficState({ feature }: { feature: TrafficFeature }) { + const { snapshot, requestState } = feature; + const sourceState = snapshot?.source.state; + if (!snapshot && (requestState === 'idle' || requestState === 'loading')) { + return
+ {[0, 1, 2, 3].map((item) => )} +
; + } + if (!snapshot) return

Инспектор трафика временно недоступен.

; + if (sourceState === 'disabled') { + return

{feature.isGateway + ? 'Инспектор трафика выключен в настройках Harbor Gateway.' + : 'Инспектор трафика выключен в настройках Harbor Connect.'}

; + } + if (sourceState === 'incompatible') { + return

Эта версия sing-box не поддерживает инспектор трафика.

; + } + if (sourceState === 'stopped') { + return

VPN остановлен. Данные появятся после запуска.

; + } + if (sourceState === 'connecting' && snapshot.connections.length === 0) { + return

Подключаем инспектор трафика…

; + } + return null; +} + +export function TrafficPanel({ feature }: { feature: TrafficFeature }) { + const [query, setQuery] = useState(''); + const [routeFilter, setRouteFilter] = useState('all'); + const [qualityFilter, setQualityFilter] = useState('all'); + const [retentionSeconds, setRetentionSeconds] = useState(storedRetentionSeconds); + const [displayedGroups, setDisplayedGroups] = useState([]); + 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 +
+
+ {feature.isGateway ? 'GATEWAY' : 'MAC'} · {snapshot?.summary.active || 0} АКТИВНЫХ + + +
+

Трафик

+

Соединения сгруппированы по назначению, протоколу и маршруту.

+
+ + {snapshot &&
+ Активных распознано {snapshot.summary.recognized} + Активных требует внимания {snapshot.summary.unresolved} +
} + +
+ +
+ {([ + ['all', 'Все'], + ['vpn', 'VPN'], + ['direct', 'Direct'], + ['other', 'Другое'], + ] as const).map(([value, label]) => )} +
+
+ {([ + ['all', 'Все'], + ['recognized', 'Распознано'], + ['attention', 'Требует внимания'], + ] as const).map(([value, label]) => )} +
+
+ Показывать завершённые +
+ {RETENTION_OPTIONS.map((seconds) => )} +
+
+
+ + {feature.paused && snapshot &&

+ Пауза · показаны данные на {updatedAt(snapshot.observedAt).replace('обновлено ', '')}. +

} + {!feature.paused && stale && snapshot &&

+ Данные временно не обновляются. Показан последний полученный снимок. +

} + {!feature.paused && !stale && snapshot && sourceState === 'connecting' && snapshot.connections.length > 0 &&

+ Инспектор переподключается. Показан последний полученный снимок. +

} + {degraded && snapshot &&

+ Часть трафика не удалось сопоставить с соединениями: ↓ {formatByteString(snapshot.source.unattributedDownloadBytes)} · ↑ {formatByteString(snapshot.source.unattributedUploadBytes)}. +

} + + + + {canShowList && retainedConnections.length === 0 && displayedGroups.length === 0 &&

+ Активных соединений пока нет. +

} + {canShowList && retainedConnections.length > 0 && groups.length === 0 && displayedGroups.length === 0 &&

+ По выбранным фильтрам ничего не найдено. +

} + {canShowList && displayedGroups.length > 0 &&
+ {displayedGroups.map((row) => finishExit(row.group.id)} + onToggle={() => setExpandedId((current) => current === row.group.id ? '' : row.group.id)} + />)} +
} + + {snapshot?.summary.truncated &&

+ Снимок ограничен 256 соединениями; активные показаны первыми. +

} +

+ {feature.isGateway + ? 'Только соединения, прошедшие через sing-box Gateway. Трафик, обходящий sing-box напрямую, здесь не виден.' + : 'Только трафик через Harbor Connect. Приложения macOS недоступны внутри Docker.'} +

+
; +} diff --git a/src/web/features/traffic/index.ts b/src/web/features/traffic/index.ts new file mode 100644 index 0000000..1dd82d1 --- /dev/null +++ b/src/web/features/traffic/index.ts @@ -0,0 +1,6 @@ +export { + TrafficPanel, + TrafficToggle, + useTrafficFeature, + type TrafficFeature, +} from './TrafficFeature.js'; diff --git a/src/web/features/traffic/trafficRows.ts b/src/web/features/traffic/trafficRows.ts new file mode 100644 index 0000000..4656ac3 --- /dev/null +++ b/src/web/features/traffic/trafficRows.ts @@ -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(); + 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; +} diff --git a/src/web/styles/features/traffic.css b/src/web/styles/features/traffic.css new file mode 100644 index 0000000..95edd25 --- /dev/null +++ b/src/web/styles/features/traffic.css @@ -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; + } +} diff --git a/src/web/styles/index.css b/src/web/styles/index.css index 5da1609..a143e8f 100644 --- a/src/web/styles/index.css +++ b/src/web/styles/index.css @@ -10,5 +10,6 @@ @import './features/diagnostics.css'; @import './features/failover.css'; @import './features/activity-journal.css'; +@import './features/traffic.css'; @import './layout.css'; @import './themes.css'; diff --git a/test/deploy/runtime-impact.test.js b/test/deploy/runtime-impact.test.js index 38a0d99..a1ec45f 100644 --- a/test/deploy/runtime-impact.test.js +++ b/test/deploy/runtime-impact.test.js @@ -95,6 +95,32 @@ test('classifier covers current dataplane reachability without promoting its cli }); }); +test('native traffic contracts and collector restart both Gateway processes', () => { + for (const file of [ + 'buf.gen.yaml', + 'proto/sing-box/v1.14.0-rc.5/daemon/started_service.proto', + 'src/server/gatewayNativeRuntime.ts', + 'src/server/generated/daemon/started_service_pb.ts', + 'src/server/services/liveTrafficService.ts', + 'src/shared/liveTraffic.ts', + ]) { + assert.deepEqual(classifyRuntimeImpact([file]), { + affectedComponents: ['control', 'dataplane'], + restartScope: 'both', + }, file); + } + for (const file of [ + 'tools/test-singbox-client-rc.sh', + 'tools/test-singbox-gateway-native-traffic.sh', + 'tools/test-singbox-native-traffic.sh', + ]) { + assert.deepEqual(classifyRuntimeImpact([file]), { + affectedComponents: [], + restartScope: 'none', + }, file); + } +}); + test('version bumps do not promote a control-only change to a dataplane restart', () => { assert.deepEqual(classifyRuntimeImpact(['src/web/App.tsx', 'src/shared/versions.ts']), { affectedComponents: ['control'], @@ -118,8 +144,12 @@ test('release-critical inputs restart both and unknown paths fail closed', () => assert.throws(() => classifyRuntimeImpact(['scripts/new-runtime.sh']), /Unclassified path/); }); -test('every tracked repository path has an explicit ownership class', () => { - const tracked = execFileSync('git', ['ls-files'], { cwd: root, encoding: 'utf8' }) +test('every repository path has an explicit ownership class', () => { + const tracked = execFileSync( + 'git', + ['ls-files', '--cached', '--others', '--exclude-standard'], + { cwd: root, encoding: 'utf8' }, + ) .trim() .split('\n'); assert.doesNotThrow(() => classifyRuntimeImpact(tracked)); diff --git a/test/install-script.test.js b/test/install-script.test.js index 8b83664..bc6a3da 100644 --- a/test/install-script.test.js +++ b/test/install-script.test.js @@ -50,9 +50,11 @@ test('macOS installer removes stale archive files without deleting local state', assert.match(installer, /--exclude='\.git'/); }); -test('macOS installer refreshes a stale pinned sing-box version', () => { +test('macOS installer pins sing-box 1.14.0-rc.5 and native traffic together', () => { const installer = fs.readFileSync(path.join(root, 'scripts', 'install-macos-client.sh'), 'utf8'); - assert.match(installer, /TARGET_SINGBOX_VERSION="\$\{SINGBOX_VERSION:-1\.13\.18\}"/); + assert.match(installer, /TARGET_SINGBOX_VERSION="\$\{SINGBOX_VERSION:-1\.14\.0-rc\.5\}"/); + assert.match(installer, /TARGET_TRAFFIC_SOURCE="\$\{SING_BOX_TRAFFIC_SOURCE:-native\}"/); assert.match(installer, /set_env_value SINGBOX_VERSION "\$TARGET_SINGBOX_VERSION"/); + assert.match(installer, /set_env_value SING_BOX_TRAFFIC_SOURCE "\$TARGET_TRAFFIC_SOURCE"/); }); diff --git a/test/server/compiled-entrypoint.test.js b/test/server/compiled-entrypoint.test.js index cd8561d..95f2ce9 100644 --- a/test/server/compiled-entrypoint.test.js +++ b/test/server/compiled-entrypoint.test.js @@ -152,22 +152,37 @@ test('production paths use only the compiled dispatcher', () => { assert.match(workflow, /command -v npm[^']+command -v git[^']+test -x \/bin\/bash/); assert.doesNotMatch(workflow, /docker image inspect "\$\{\{ env\.NODE_BUILD_IMAGE \}\}"/); assert.match(legacyBuild, /npm run build:production && docker build/); + assert.match(legacyBuild, /docker run --rm[^;]+sing-box version[^;]+grep -Fx/); + assert.match(legacyBuild, /docker run --rm --entrypoint sing-box[^;]+version[^;]+grep -Fx/); assert.match(dockerignore, /^dist$/m); }); -test('every shipped build defaults to sing-box 1.13.18', () => { - for (const file of [ - '.env.example', - '.gitea/workflows/gateway-build.yml', - 'Dockerfile', - 'Dockerfile.client', - 'Dockerfile.runtime-base', - 'docker-compose.client.yml', - 'docker-compose.gateway.yml', - 'scripts/build-on-107-deploy-111.sh', - 'scripts/build-runtime-base.sh', +test('Mac client builds default to exact sing-box 1.14.0-rc.5', () => { + assert.match( + fs.readFileSync(path.join(root, 'Dockerfile.client'), 'utf8'), + /^ARG SINGBOX_VERSION=1\.14\.0-rc\.5$/m, + ); + assert.match( + fs.readFileSync(path.join(root, 'docker-compose.client.yml'), 'utf8'), + /SINGBOX_VERSION: \$\{SINGBOX_VERSION:-1\.14\.0-rc\.5\}/, + ); +}); + +test('Gateway builds use the Mac-qualified exact sing-box 1.14.0-rc.5', () => { + for (const [file, pin] of [ + ['.env.example', /^SINGBOX_VERSION=1\.14\.0-rc\.5$/m], + ['.gitea/workflows/gateway-build.yml', /^\s*SINGBOX_VERSION: 1\.14\.0-rc\.5$/m], + ['Dockerfile', /^ARG SINGBOX_VERSION=1\.14\.0-rc\.5$/m], + ['Dockerfile.runtime-base', /^ARG SINGBOX_VERSION=1\.14\.0-rc\.5$/m], + ['docker-compose.gateway.yml', /SINGBOX_VERSION: \$\{SINGBOX_VERSION:-1\.14\.0-rc\.5\}/], + ['scripts/build-on-107-deploy-111.sh', /^SINGBOX_VERSION="\$\{SINGBOX_VERSION:-1\.14\.0-rc\.5\}"$/m], + ['scripts/build-runtime-base.sh', /^SINGBOX_VERSION="\$\{SINGBOX_VERSION:-1\.14\.0-rc\.5\}"$/m], ]) { - assert.match(fs.readFileSync(path.join(root, file), 'utf8'), /SINGBOX_VERSION[^\n]*1\.13\.18/); + assert.match(fs.readFileSync(path.join(root, file), 'utf8'), pin); + } + const gatewayDockerfile = fs.readFileSync(path.join(root, 'Dockerfile'), 'utf8'); + for (const dependency of ['@bufbuild/protobuf', '@connectrpc/connect', '@connectrpc/connect-node']) { + assert.match(gatewayDockerfile, new RegExp(`/src/node_modules/${dependency.replace('/', '\\/')}`)); } }); diff --git a/test/server/dataplane-client.test.js b/test/server/dataplane-client.test.js index d65bd52..94c2c33 100644 --- a/test/server/dataplane-client.test.js +++ b/test/server/dataplane-client.test.js @@ -26,6 +26,8 @@ test('control uses the dataplane socket protocol', async () => { assert.equal(traffic.running, true); const domainTraffic = await client.observeDomainTraffic(); assert.equal(domainTraffic.running, true); + const liveTraffic = await client.observeLiveTraffic(); + assert.equal(liveTraffic.running, true); await client.observeDevicePolicy(); await client.applyDevicePolicies([{ id: 'dev_0011223344556677' }]); await client.runConnectivityDiagnostics( @@ -47,6 +49,7 @@ test('control uses the dataplane socket protocol', async () => { 'GET /devices /run/dataplane.sock', 'GET /device-traffic /run/dataplane.sock', 'GET /domain-traffic /run/dataplane.sock', + 'GET /traffic/live /run/dataplane.sock', 'GET /device-policy /run/dataplane.sock', 'PUT /device-policy /run/dataplane.sock', 'POST /diagnostics/connectivity /run/dataplane.sock', @@ -59,16 +62,16 @@ test('control uses the dataplane socket protocol', async () => { 'POST /restart /run/dataplane.sock', 'POST /stop /run/dataplane.sock', ]); - assert.deepEqual(requests[6].body, { devices: [{ id: 'dev_0011223344556677' }] }); - assert.deepEqual(requests[7].body, { + assert.deepEqual(requests[7].body, { devices: [{ id: 'dev_0011223344556677' }] }); + assert.deepEqual(requests[8].body, { services: [{ id: 'custom-test', url: 'https://example.com' }], target: 'site:custom-test', }); - assert.equal(requests[7].timeoutMs, 25_000); - assert.deepEqual(requests[8].body, { config: { outbounds: [] } }); - assert.deepEqual(requests[9].body, { role: 'primary', services: [], target: 'site:youtube', timeoutMs: 9_000 }); - assert.equal(requests[9].timeoutMs, 19_000); - assert.deepEqual(requests[11].body, { role: 'reserve' }); + assert.equal(requests[8].timeoutMs, 25_000); + assert.deepEqual(requests[9].body, { config: { outbounds: [] } }); + assert.deepEqual(requests[10].body, { role: 'primary', services: [], target: 'site:youtube', timeoutMs: 9_000 }); + assert.equal(requests[10].timeoutMs, 19_000); + assert.deepEqual(requests[12].body, { role: 'reserve' }); }); test('connectivity diagnostics expose a retryable domain error', async () => { diff --git a/test/server/deploy-split.test.js b/test/server/deploy-split.test.js index d0f26db..8faa4aa 100644 --- a/test/server/deploy-split.test.js +++ b/test/server/deploy-split.test.js @@ -33,6 +33,16 @@ test('gateway deploy updates control without recreating dataplane', () => { assert.doesNotMatch(workflow, /grep -Eq/); }); +test('Gateway native API credentials remain private to the dataplane volume', () => { + assert.match(compose, /SING_BOX_TRAFFIC_SOURCE: \$\{SING_BOX_TRAFFIC_SOURCE:-snapshot\}/); + assert.match(compose, /vpn-proxy-dataplane:[\s\S]*SING_BOX_API_SECRET: \/var\/lib\/sing-box\/api\.secret[\s\S]*sing-box-cache:\/var\/lib\/sing-box/); + assert.doesNotMatch( + compose.match(/vpn-proxy-control:[\s\S]*?(?=\nvolumes:)/)?.[0] || '', + /SING_BOX_API_SECRET|sing-box-cache|19091/, + ); + assert.doesNotMatch(compose.match(/ports:[\s\S]*?volumes:/)?.[0] || '', /19091/); +}); + test('manual hard deploy safely forces the existing full Gateway path', () => { assert.match(workflow, /workflow_dispatch:\s*\n\s+inputs:\s*\n\s+hard_deploy:[\s\S]*default: false[\s\S]*type: boolean/); assert.match(workflow, /env:\s*\n\s+HARD_DEPLOY_INPUT: \$\{\{ inputs\.hard_deploy \}\}/); diff --git a/test/server/device-traffic.test.js b/test/server/device-traffic.test.js index d659d18..442097e 100644 --- a/test/server/device-traffic.test.js +++ b/test/server/device-traffic.test.js @@ -29,8 +29,9 @@ const observation = (ip, mac = '00:11:22:33:44:55', deviceInterface = 'eth0') => test('dataplane exposes cached traffic snapshots without making accounting a readiness dependency', () => { assert.match(dataplaneSource, /req\.method === 'GET' && req\.url === '\/device-traffic'[\s\S]*traffic\.snapshot\(\)/); - assert.match(dataplaneSource, /ready = true;[\s\S]*deviceTrafficAccountingEnabled[\s\S]*setImmediate[\s\S]*traffic\.refresh\(\)/); - assert.match(dataplaneSource, /traffic\.refresh\(\)\.catch/); + assert.match(dataplaneSource, /async function refreshDeviceTraffic\(\)[\s\S]*traffic\.refresh\(\)/); + assert.match(dataplaneSource, /ready = true;[\s\S]*deviceTrafficAccountingEnabled[\s\S]*setImmediate[\s\S]*refreshDeviceTraffic\(\)/); + assert.match(dataplaneSource, /refreshDeviceTraffic\(\)\.catch/); }); test('traffic selection keeps only unambiguous IPv4 neighbors', () => { diff --git a/test/server/domain-traffic.test.js b/test/server/domain-traffic.test.js index daf7a6e..0814e61 100644 --- a/test/server/domain-traffic.test.js +++ b/test/server/domain-traffic.test.js @@ -16,6 +16,40 @@ const connection = (connectionId, type, host, upload, download, sourceIP = devic download, chains, }); +const nativeConnection = (connectionId, uploadBytes, downloadBytes, overrides = {}) => ({ + id: connectionId, + startedAt: '2026-08-31T10:00:00.000Z', + closedAt: null, + inbound: { tag: 'tproxy-in', type: 'tproxy' }, + network: 'tcp', + protocol: 'tls', + source: { ip: device.ip, port: 54_000 }, + destination: { domain: 'example.com', ip: null, port: 443, provenance: 'sing-box' }, + origin: { kind: 'device', id, label: 'MacBook', provenance: 'source-ip' }, + route: { + kind: 'vpn', + scope: 'local-sing-box', + outbound: 'channel-selector', + outboundType: 'selector', + chain: ['channel-primary', 'channel-selector'], + rule: 'final', + }, + traffic: { + uploadBytes, + downloadBytes, + uploadBytesPerSecond: '0', + downloadBytesPerSecond: '0', + }, + ...overrides, +}); +const nativeBatch = (connections, overrides = {}) => ({ + epoch: 'sing-box-100', + observedAt: '2026-08-31T10:00:00.000Z', + reset: false, + connections, + closedIds: [], + ...overrides, +}); test('sing-box route traffic keeps vpn, direct and unknown deltas separate', async () => { let response = { connections: [ @@ -238,3 +272,73 @@ test('failover activity is zero-work while disabled and uses the existing connec service.disableActivity(); assert.equal(service.activitySnapshot(500), null); }); + +test('native lifecycle batches keep decimal precision and dedupe a same-epoch reconnect reset and final tail', () => { + let now = new Date('2026-08-31T10:00:00.000Z'); + const service = createDomainTrafficService({ + observe: () => ({ connections: [] }), + devices: () => [], + now: () => now, + }); + const initial = nativeConnection('native', '9007199254740993', '10'); + service.ingestNative(nativeBatch([initial], { reset: true })); + now = new Date('2026-08-31T10:00:01.000Z'); + service.ingestNative(nativeBatch([initial], { + reset: true, + observedAt: now.toISOString(), + })); + + assert.equal(service.snapshot().series[0].uploadBytes, '9007199254740993'); + assert.equal(service.snapshot().source.activeConnections, 1); + + service.enableActivity(); + now = new Date('2026-08-31T10:00:02.000Z'); + service.ingestNative(nativeBatch([ + nativeConnection('native', '9007199254740998', '15', { closedAt: now.toISOString() }), + ], { + observedAt: now.toISOString(), + closedIds: ['native'], + })); + + assert.equal(service.snapshot().series[0].uploadBytes, '9007199254740998'); + assert.equal(service.snapshot().series[0].downloadBytes, '15'); + assert.equal(service.snapshot().source.activeConnections, 0); + assert.equal(service.activitySnapshot(0).state, 'active'); + + now = new Date('2026-08-31T10:00:03.000Z'); + service.ingestNative(nativeBatch([ + nativeConnection('native', '9007199254740998', '15'), + ], { + reset: true, + observedAt: now.toISOString(), + })); + + assert.equal(service.snapshot().series[0].uploadBytes, '9007199254740998'); + assert.equal(service.snapshot().series[0].downloadBytes, '15'); + assert.equal(service.snapshot().source.activeConnections, 1); + + now = new Date('2026-08-31T10:00:14.000Z'); + service.ingestNative(nativeBatch([], { observedAt: now.toISOString() })); + assert.equal(service.snapshot().observedAt, now.toISOString()); + assert.equal(service.snapshot().source.activeConnections, 1); + assert.equal(service.activitySnapshot(0).state, 'quiet'); +}); + +test('one native batch accounts every final tail before lifecycle and UI caps', () => { + const service = createDomainTrafficService({ + observe: () => ({ connections: [] }), + devices: () => [], + }); + const connections = Array.from({ length: 2_049 }, (_, index) => ( + nativeConnection(`closed-${index}`, '1', '1', { closedAt: '2026-08-31T10:00:00.000Z' }) + )); + + service.ingestNative(nativeBatch(connections, { + reset: true, + closedIds: connections.map(({ id: connectionId }) => connectionId), + })); + + assert.equal(service.snapshot().series[0].uploadBytes, '2049'); + assert.equal(service.snapshot().series[0].downloadBytes, '2049'); + assert.equal(service.snapshot().source.activeConnections, 0); +}); diff --git a/test/server/gateway-native-config.test.js b/test/server/gateway-native-config.test.js new file mode 100644 index 0000000..bdcda1f --- /dev/null +++ b/test/server/gateway-native-config.test.js @@ -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/); +}); diff --git a/test/server/gateway-native-runtime.test.js b/test/server/gateway-native-runtime.test.js new file mode 100644 index 0000000..afd3373 --- /dev/null +++ b/test/server/gateway-native-runtime.test.js @@ -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); +}); diff --git a/test/server/live-traffic-ledger.test.js b/test/server/live-traffic-ledger.test.js new file mode 100644 index 0000000..d27dff4 --- /dev/null +++ b/test/server/live-traffic-ledger.test.js @@ -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); +}); diff --git a/test/server/live-traffic-route.test.js b/test/server/live-traffic-route.test.js new file mode 100644 index 0000000..b41e360 --- /dev/null +++ b/test/server/live-traffic-route.test.js @@ -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', + ); +}); diff --git a/test/server/prometheus-metrics.test.js b/test/server/prometheus-metrics.test.js index 3a28c64..8c09155 100644 --- a/test/server/prometheus-metrics.test.js +++ b/test/server/prometheus-metrics.test.js @@ -40,6 +40,28 @@ const snapshot = { }, domainTraffic: { observedAt, + source: { + error: null, + mode: 'shadow', + writer: 'snapshot', + activeConnections: 4, + native: { + state: 'degraded', + epoch: 'epoch-1', + sequence: 7, + observedAt, + active: 5, + unattributedUploadBytes: '11', + unattributedDownloadBytes: '22', + }, + shadow: { + activeDifference: 1, + uploadDifferenceBytes: '-30', + downloadDifferenceBytes: '40', + routeMismatches: 2, + deviceMismatches: 3, + }, + }, overflowConnections: '2', attributionEvents: { unresolved_host: '3', @@ -97,6 +119,15 @@ test('Prometheus exposition keeps exact counters, stable identity and escaped na assert.match(output, /harbor_domain_traffic_attribution_events_total\{outcome="unresolved_host"\} 3/); assert.match(output, /harbor_domain_traffic_attribution_events_total\{outcome="unknown_device"\} 4/); assert.match(output, /harbor_domain_traffic_attribution_events_total\{outcome="unsupported_source"\} 5/); + assert.match(output, /harbor_traffic_collector_info\{mode="shadow",writer="snapshot"\} 1/); + assert.match(output, /harbor_traffic_collector_state\{state="degraded"\} 1/); + assert.match(output, /harbor_traffic_collector_unattributed_bytes\{direction="download"\} 22/); + assert.match(output, /harbor_traffic_collector_unattributed_bytes\{direction="upload"\} 11/); + assert.match(output, /harbor_traffic_shadow_active_difference 1/); + assert.match(output, /harbor_traffic_shadow_difference_bytes\{direction="download"\} 40/); + assert.match(output, /harbor_traffic_shadow_difference_bytes\{direction="upload"\} -30/); + assert.match(output, /harbor_traffic_shadow_route_mismatches 2/); + assert.match(output, /harbor_traffic_shadow_device_mismatches 3/); assert.doesNotMatch(output, /harbor_device_domain_traffic_bytes_total\{[^\n]*name=/); assert.doesNotMatch(output, /harbor_device_(?:direct_ipv4_packet|singbox_tracked)_bytes_total\{[^\n]*(?:name|ip|mac|server)=/); assert.equal(output.endsWith('\n'), true); @@ -122,6 +153,15 @@ test('Prometheus response uses the negotiated legacy text contract without mutat assert.deepEqual(snapshot, before); }); +test('legacy combined Gateway snapshots keep existing metrics without collector diagnostics', () => { + const legacy = structuredClone(snapshot); + legacy.domainTraffic.source = { error: null, activeConnections: 0 }; + const output = renderPrometheusMetrics(legacy); + + assert.match(output, /harbor_singbox_tracked_bytes_total/); + assert.doesNotMatch(output, /harbor_traffic_collector_info/); +}); + test('invalid canonical counters fail the scrape instead of publishing corrupt values', () => { const invalid = { traffic: { gatewayBytes: 'broken', proxyBytes: '0' }, devices: [] }; assert.throws( @@ -135,6 +175,12 @@ test('invalid canonical counters fail the scrape instead of publishing corrupt v const invalidRoute = structuredClone(snapshot); invalidRoute.domainTraffic.routes[0].outbound = 'vpn-server-tag'; assert.throws(() => renderPrometheusMetrics(invalidRoute), /Invalid sing-box outbound labels/); + const invalidCollector = structuredClone(snapshot); + invalidCollector.domainTraffic.source.mode = 'future'; + assert.throws(() => renderPrometheusMetrics(invalidCollector), /Invalid traffic collector labels/); + const invalidShadow = structuredClone(snapshot); + invalidShadow.domainTraffic.source.shadow.uploadDifferenceBytes = '1.5'; + assert.throws(() => renderPrometheusMetrics(invalidShadow), /Invalid Prometheus gauge/); }); function routeResponse() { diff --git a/test/server/singbox-gateway-mode.test.js b/test/server/singbox-gateway-mode.test.js index 705a583..1db6d9b 100644 --- a/test/server/singbox-gateway-mode.test.js +++ b/test/server/singbox-gateway-mode.test.js @@ -1,12 +1,28 @@ import assert from 'node:assert/strict'; +import { create } from '@bufbuild/protobuf'; +import { connectNodeAdapter } from '@connectrpc/connect-node'; +import { spawn } from 'node:child_process'; import fs from 'node:fs'; +import http from 'node:http'; +import http2 from 'node:http2'; import os from 'node:os'; import path from 'node:path'; import test from 'node:test'; +import { + ConnectionEventsSchema, + StartedAtSchema, + StartedService, + StatusSchema, + VersionSchema, +} from '../../dist/server/generated/daemon/started_service_pb.js'; + +const root = path.resolve(import.meta.dirname, '../..'); + process.env.APP_MODE = 'gateway'; process.env.DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), 'vpn-proxy-gateway-test-')); process.env.SING_BOX_CACHE = path.join(process.env.DATA_DIR, 'cache.db'); +process.env.SING_BOX_TRAFFIC_SOURCE = 'snapshot'; const { buildDualChannelGatewayConfig, @@ -124,3 +140,257 @@ test('cached dual-channel outbounds must match the applied provider fingerprints config.outbounds[2].outbounds = ['channel-primary']; assert.equal(dualChannelConfigMatchesApplied(config, applied, 'primary'), false); }); + +function request(socketPath, pathname, method = 'GET', body) { + return new Promise((resolve, reject) => { + const payload = body === undefined ? null : JSON.stringify(body); + const value = http.request({ + socketPath, + path: pathname, + method, + ...(payload ? { headers: { 'content-type': 'application/json', 'content-length': Buffer.byteLength(payload) } } : {}), + }, (response) => { + const chunks = []; + response.on('data', (chunk) => chunks.push(chunk)); + response.on('end', () => resolve(JSON.parse(Buffer.concat(chunks).toString('utf8')))); + }); + value.on('error', reject); + value.end(payload); + }); +} + +async function waitForSocket(socketPath, child, stderr) { + for (let attempt = 0; attempt < 100; attempt += 1) { + if (child.exitCode !== null) throw new Error(`dataplane exited: ${stderr()}`); + try { + const status = await request(socketPath, '/status'); + if (status.ready) return; + } catch {} + await new Promise((resolve) => setTimeout(resolve, 20)); + } + throw new Error(`dataplane did not start: ${stderr()}`); +} + +async function startDataplane(mode, apiPort) { + const directory = fs.mkdtempSync(path.join(os.tmpdir(), `harbor-${mode}-`)); + const socketPath = path.join(directory, 'dataplane.sock'); + const configPath = path.join(directory, 'sing-box.json'); + const binDirectory = path.join(directory, 'bin'); + fs.mkdirSync(binDirectory); + fs.writeFileSync(configPath, JSON.stringify({ + services: [{ + type: 'api', + listen: '127.0.0.1', + listen_port: 19091, + dashboard: false, + }], + inbounds: [], + outbounds: [], + })); + for (const [name, source] of [ + ['sing-box', `#!/bin/sh +if [ "$1" = check ]; then exit 0; fi +trap 'exit 0' TERM INT +while :; do sleep 1; done +`], + ['ip', `#!/bin/sh +printf '%s\n' '[{"dst":"192.168.50.7","lladdr":"00:11:22:33:44:55","dev":"en0","state":["REACHABLE"]}]' +`], + ['iptables', '#!/bin/sh\nexit 0\n'], + ['iptables-restore', '#!/bin/sh\nexit 0\n'], + ]) { + const executable = path.join(binDirectory, name); + fs.writeFileSync(executable, source); + fs.chmodSync(executable, 0o755); + } + const child = spawn(process.execPath, ['dist/server/main.js'], { + cwd: root, + env: { + ...process.env, + APP_MODE: 'gateway', + APP_COMPONENT: 'dataplane', + DATA_DIR: directory, + DATAPLANE_SOCKET: socketPath, + DEVICE_TRAFFIC_ACCOUNTING_ENABLED: 'false', + SING_BOX_API_PORT: String(apiPort), + SING_BOX_CACHE: path.join(directory, 'cache.db'), + SING_BOX_CONFIG: configPath, + SING_BOX_API_SECRET: path.join(directory, 'api.secret'), + SING_BOX_RUNTIME_CONFIG: path.join(directory, 'runtime-config.json'), + SING_BOX_TRAFFIC_SOURCE: mode, + PATH: `${binDirectory}:${process.env.PATH || ''}`, + }, + stdio: ['ignore', 'ignore', 'pipe'], + }); + let error = ''; + child.stderr.on('data', (chunk) => { error += chunk; }); + const stop = async () => { + if (child.exitCode === null) { + const exited = new Promise((resolve) => child.once('exit', resolve)); + child.kill('SIGTERM'); + await exited; + } + fs.rmSync(directory, { recursive: true, force: true }); + }; + try { + await waitForSocket(socketPath, child, () => error); + return { socketPath, child, stop }; + } catch (reason) { + await stop(); + throw reason; + } +} + +function waitForAbort(signal) { + return new Promise((resolve) => { + if (signal.aborted) resolve(); + else signal.addEventListener('abort', resolve, { once: true }); + }); +} + +async function waitForSnapshot(socketPath, pathname, predicate, method = 'GET', body) { + const deadline = Date.now() + 3_000; + while (Date.now() < deadline) { + const snapshot = await request(socketPath, pathname, method, body); + if (predicate(snapshot)) return snapshot; + await new Promise((resolve) => setTimeout(resolve, 20)); + } + throw new Error(`timed out waiting for ${pathname}`); +} + +function nativeRoutes(router, source) { + router.service(StartedService, { + getVersion: () => create(VersionSchema, { version: '1.14.0-rc.5', apiVersion: 4 }), + getStartedAt: () => create(StartedAtSchema, { startedAt: 1_700_000_000_000n }), + async *subscribeConnections(_request, context) { + yield create(ConnectionEventsSchema, { + reset: true, + events: [{ + type: 0, + id: 'native-1', + connection: { + id: 'native-1', + inbound: 'tproxy-in', + inboundType: 'tproxy', + network: 'tcp', + source: '192.168.50.7:54000', + destination: '203.0.113.10:443', + domain: 'native.example', + protocol: 'tls', + createdAt: 1_700_000_000_000n, + uplinkTotal: 101n, + downlinkTotal: 202n, + outbound: 'channel-selector', + outboundType: 'selector', + chainList: ['channel-primary', 'channel-selector'], + }, + }], + }); + await waitForAbort(context.signal); + }, + async *subscribeStatus(_request, context) { + while (!context.signal.aborted) { + yield create(StatusSchema, { + connectionsIn: source.mismatch ? 2 : 1, + uplinkTotal: source.mismatch ? 999n : 101n, + downlinkTotal: source.mismatch ? 999n : 202n, + }); + await Promise.race([ + waitForAbort(context.signal), + new Promise((resolve) => setTimeout(resolve, 100)), + ]); + } + }, + }); +} + +test('Gateway modes keep snapshot compare-only and make native the sole canonical writer', async (t) => { + let connectionReads = 0; + const nativeSource = { mismatch: false }; + const clash = http.createServer((req, res) => { + if (req.url === '/connections') connectionReads += 1; + res.writeHead(200, { 'content-type': 'application/json' }); + res.end(JSON.stringify({ connections: [{ + id: 'legacy-1', + upload: 11, + download: 22, + metadata: { + type: 'tproxy/tproxy-in', + host: 'legacy.example', + sourceIP: '192.168.50.7', + }, + chains: ['channel-primary'], + }] })); + }); + await new Promise((resolve) => clash.listen(0, '127.0.0.1', resolve)); + t.after(() => new Promise((resolve) => clash.close(resolve))); + const apiPort = clash.address().port; + const native = http2.createServer(connectNodeAdapter({ + routes: (router) => nativeRoutes(router, nativeSource), + })); + await new Promise((resolve) => native.listen(19091, '127.0.0.1', resolve)); + t.after(() => new Promise((resolve) => native.close(resolve))); + + for (const mode of ['snapshot', 'shadow', 'native']) { + connectionReads = 0; + const dataplane = await startDataplane(mode, apiPort); + try { + const live = mode === 'snapshot' + ? await request(dataplane.socketPath, '/traffic/live') + : await waitForSnapshot( + dataplane.socketPath, + '/traffic/live', + (snapshot) => snapshot.source.state === 'live', + ); + const domain = await waitForSnapshot( + dataplane.socketPath, + '/domain-traffic', + (snapshot) => snapshot.tracked[0]?.uploadBytes === (mode === 'native' ? '101' : '11'), + ); + assert.equal(domain.source.mode, mode); + assert.equal(domain.source.writer, mode === 'native' ? 'native' : 'snapshot'); + assert.equal(domain.source.native === null, mode === 'snapshot'); + assert.equal(domain.source.shadow === null, mode !== 'shadow'); + assert.equal(live.source.state, mode === 'snapshot' ? 'disabled' : 'live'); + assert.equal(connectionReads > 0, mode !== 'native'); + if (mode === 'shadow') { + assert.equal(domain.source.native.active, 1); + assert.equal(domain.source.shadow.uploadDifferenceBytes, '90'); + assert.equal(domain.source.shadow.downloadDifferenceBytes, '180'); + } + if (mode === 'native') { + assert.equal(connectionReads, 0); + assert.equal(live.connections[0].id, 'native-1'); + assert.deepEqual(domain.tracked, [{ + source: 'gateway', + outbound: 'vpn', + uploadBytes: '101', + downloadBytes: '202', + }]); + await request(dataplane.socketPath, '/failover/activity', 'PUT', { enabled: true }); + await waitForSnapshot( + dataplane.socketPath, + '/failover/activity/read', + (response) => response.activity?.state === 'quiet', + 'POST', + { thresholdBytesPerSecond: 0 }, + ); + nativeSource.mismatch = true; + await waitForSnapshot( + dataplane.socketPath, + '/traffic/live', + (snapshot) => snapshot.source.state === 'degraded', + ); + const degradedActivity = await request( + dataplane.socketPath, + '/failover/activity/read', + 'POST', + { thresholdBytesPerSecond: 0 }, + ); + assert.equal(degradedActivity.activity, null); + } + } finally { + await dataplane.stop(); + } + } +}); diff --git a/test/server/singbox-native-api.test.js b/test/server/singbox-native-api.test.js new file mode 100644 index 0000000..5fccbc8 --- /dev/null +++ b/test/server/singbox-native-api.test.js @@ -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(); +}); diff --git a/test/server/startup-recovery.test.js b/test/server/startup-recovery.test.js index b3fe1d1..b5e7e6f 100644 --- a/test/server/startup-recovery.test.js +++ b/test/server/startup-recovery.test.js @@ -11,6 +11,35 @@ import { normalizeSubscriptionConfig } from '../../dist/server/subscription.js'; const root = path.resolve(import.meta.dirname, '../..'); +test('startup recovery rejects a materialized native API secret as shared config truth', async (t) => { + const server = { + id: 'server-a', + label: 'Server A', + host: 'a.example', + port: 443, + protocol: 'vless', + }; + const fixture = await startClientFixture(t, { + state: profileState(server), + config: { + ...generatedConfig(server), + services: [{ + type: 'api', + listen: '127.0.0.1', + listen_port: 19091, + dashboard: false, + secret: 'a'.repeat(64), + }], + }, + trafficSource: 'native', + }); + + assert.equal(fixture.state.connection.desired, 'stopped'); + assert.equal(fixture.state.connection.process, 'stopped'); + assert.equal(fs.existsSync(fixture.markerPath), false); + assert.equal(fs.existsSync(path.join(fixture.directory, 'sing-box-config.json')), false); +}); + function listen(server, ...args) { return new Promise((resolve, reject) => { server.once('error', reject); @@ -121,6 +150,7 @@ async function startClientFixture(t, { config, hostNetwork, gatewayPresencePort, + trafficSource, }) { const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'harbor-startup-recovery-')); const { binDirectory, markerPath } = fakeSingbox(directory); @@ -148,6 +178,7 @@ async function startClientFixture(t, { : hostNetworkPath, ...(gatewayPresencePort ? { HARBOR_GATEWAY_CONTROL_PORT: String(gatewayPresencePort) } : {}), HARBOR_TEST_RUN_MARKER: markerPath, + ...(trafficSource ? { SING_BOX_TRAFFIC_SOURCE: trafficSource } : {}), }, stdio: ['ignore', 'ignore', 'pipe'], }); @@ -363,6 +394,34 @@ test('boot rejects an existing config owned by a different applied target', asyn assert.equal(fs.existsSync(path.join(fixture.directory, 'sing-box-config.json')), false); }); +test('disabled traffic source rejects an existing config with any API service', async (t) => { + const server = { + id: 'server-a', + label: 'Server A', + host: 'a.example', + port: 443, + protocol: 'vless', + }; + const fixture = await startClientFixture(t, { + state: profileState(server), + config: { + ...generatedConfig(server), + services: [{ + type: 'api', + listen: '0.0.0.0', + listen_port: 19091, + dashboard: false, + }], + }, + trafficSource: 'disabled', + }); + + assert.equal(fixture.state.connection.desired, 'stopped'); + assert.equal(fixture.state.connection.process, 'stopped'); + assert.equal(fs.existsSync(fixture.markerPath), false); + assert.equal(fs.existsSync(path.join(fixture.directory, 'sing-box-config.json')), false); +}); + test('stopped Gateway boot explicitly stops an already running remote dataplane', async (t) => { const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'harbor-stopped-remote-')); const socketPath = path.join(directory, 'dataplane.sock'); diff --git a/test/web/api-errors.test.js b/test/web/api-errors.test.js index c08bc1c..22793f8 100644 --- a/test/web/api-errors.test.js +++ b/test/web/api-errors.test.js @@ -131,6 +131,7 @@ test('typed endpoint facade preserves exact request contracts and raw payload id expectedRevision: 10, }), }], + [() => api.traffic.live(), '/api/traffic/live', {}], [() => api.singbox.stop(), '/api/singbox/stop', { method: 'POST' }], [() => api.singbox.restart(), '/api/singbox/restart', { method: 'POST' }], [() => api.servers.ping('profile-1', ['one', 'two']), '/api/profiles/profile-1/servers/ping', { diff --git a/test/web/live-traffic-contract.test.js b/test/web/live-traffic-contract.test.js new file mode 100644 index 0000000..f5e7242 --- /dev/null +++ b/test/web/live-traffic-contract.test.js @@ -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('')); + const devices = rail.indexOf('= 0 && traffic > devices && diagnostics > traffic); + assert.equal((rail.match(/}/); + assert.doesNotMatch(page, /!isGateway && hasSubscription && { + 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 && /); + assert.match(feature, /displayedGroups\.map\(\(row\) => Активных распознано<\/b>/); + assert.match(feature, /Активных требует внимания<\/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/); +}); diff --git a/test/web/responsive-layout-contract.test.js b/test/web/responsive-layout-contract.test.js index ed768a5..5695656 100644 --- a/test/web/responsive-layout-contract.test.js +++ b/test/web/responsive-layout-contract.test.js @@ -128,7 +128,7 @@ test('secondary menus share one right rail and switch equal drawers as a vertica assert.match(component, /\(null\)/); assert.match(component, /const activeRailDrawer = drawerSwitchTarget && drawerControls\[drawerSwitchTarget\]\.isOpen[\s\S]*drawerControls\[drawer\]\.isOpen/); diff --git a/test/web/style-boundaries.test.js b/test/web/style-boundaries.test.js index adfdd4a..efda8eb 100644 --- a/test/web/style-boundaries.test.js +++ b/test/web/style-boundaries.test.js @@ -33,36 +33,37 @@ const expectedImports = [ './features/diagnostics.css', './features/failover.css', './features/activity-journal.css', + './features/traffic.css', './layout.css', './themes.css', ]; const sha256 = (value) => crypto.createHash('sha256').update(value).digest('hex'); const acceptedLedger = { counts: { - cascadeEdges: 1120, + cascadeEdges: 1126, customProperties: 115, - declarations: 4455, + declarations: 4676, important: 0, - keyframes: 52, - media: 19, - rules: 1193, - variableReferences: 1138, + keyframes: 55, + media: 22, + rules: 1262, + variableReferences: 1214, }, hashes: { - cascadeEdges: 'd8eeaf20637638f97dd826ae7486469367fb7f5c7e2d32a6fb1d64647e8b52e0', + cascadeEdges: '1f3c75839bd37bb312b9aed2987ed61571e5148f8b23c8288424cb193c7a8dda', customProperties: '81d1e70737ae5a8e4b20d4c1be1b24685975a404863444da4a8821be1f0d5097', - declarations: '89b645d94c44f0dcfea1a7c5f653a8124f0c87057a9898437419ddce813271d2', + declarations: '616fc7cb72a9db509f6b41e1808118c2e2be7da6ce4c078dfa2eb4a963f0dbb1', duplicateKeyframes: '4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945', duplicateSelectors: '8982145dba05b33bf1c93b2d54cfbe76305b276ff3c657aa020144016ada5848', - keyframes: '405688c9a452aa9d54e9c50dd30abb13143fdb5910f63f4474ecefcc800311e6', - ruleDeclarationSequences: '12a5a2deec8a8521f49551d9a29c1950a01bce225f1b387def47304fcfe1c960', - selectors: 'fa7cc612da0fc1e3804a884032e9ab99e0bb0776b29a9e40d44a2891de198067', - variableReferences: '1cbaf891cd2df7c91003d8879a205075211678ad40a817b746c4dde54830ad6d', - witnesses: 'bae0329346060aeeef91dee449ccee7187e68501c0b5b082026312e7c21a4798', + keyframes: 'b9b0bf3fad92e69b93ec0c24f01da15e78bbe89a095597d64ce4f7ef5e272fdd', + ruleDeclarationSequences: '3a65866ea25506c6fd79b68bfbbdeddd495f6957dbd99dec6a814b816ba4cca2', + selectors: '56e1c67b35230649a3c69b2aea67c1929580700f1b4357ee05dfb11012d72655', + variableReferences: '71722a81fa16b3586ae9ee5890f79176729dfdf7fe58c4d4994755f8059aa034', + witnesses: '2272ed9b07e02edd232c33a971c4cc5171f6a612ccaa36418933f0fd6bb465c1', }, }; -test('public stylesheet exposes exactly fourteen flat semantic owners', () => { +test('public stylesheet exposes exactly fifteen flat semantic owners', () => { const imports = Array.from(index.matchAll(/^@import ['"](\.\/[^'"]+\.css)['"];$/gm), ([, file]) => file); assert.deepEqual(imports, expectedImports); assert.equal(index, `${expectedImports.map((file) => `@import '${file}';`).join('\n')}\n`); @@ -211,7 +212,7 @@ test('client typography uses the shared semantic scale outside the token owner', test('accepted stylesheet has pinned declaration, selector, keyframe, variable, and cascade ledgers', () => { const witnesses = readStyleWitnesses(root); - assert.equal(witnesses.length, 1198); + assert.equal(witnesses.length, 1292); assert.equal(witnesses.filter((witness) => witness.unknown || witness.ancestorUnknown).length, 0); const ledger = createStyleLedger(readStyleSource(root), { witnesses }); assert.deepEqual(ledger.counts, acceptedLedger.counts); @@ -408,8 +409,8 @@ test('main owns one public stylesheet and the regrouped production CSS is determ assert.equal((main.match(/import ['"][^'"]+\.css['"]/g) || []).length, 1); const assets = fs.readdirSync(path.join(root, 'dist/assets')).filter((file) => file.endsWith('.css')); - assert.deepEqual(assets, ['index-C2yOT86L.css']); + assert.deepEqual(assets, ['index-tJmxnB8a.css']); const built = fs.readFileSync(path.join(root, 'dist/assets', assets[0])); - assert.equal(built.byteLength, 169625); - assert.equal(sha256(built), 'a9bb2c83ab62798e762f4338be1869a6d29366903f83f90d4abcaa2855e44d8e'); + assert.equal(built.byteLength, 176543); + assert.equal(sha256(built), '38a283204c656164c38c17aa03f41c931544e05b5732248807d911b2645bf970'); }); diff --git a/test/web/ui-foundations-contract.test.js b/test/web/ui-foundations-contract.test.js index e0f3833..386be8e 100644 --- a/test/web/ui-foundations-contract.test.js +++ b/test/web/ui-foundations-contract.test.js @@ -34,7 +34,7 @@ test('all repeated client primitive consumers use the shared owners', () => { assert.equal((production.match(//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' diff --git a/tools/test-singbox-gateway-native-traffic.sh b/tools/test-singbox-gateway-native-traffic.sh new file mode 100755 index 0000000..13979c2 --- /dev/null +++ b/tools/test-singbox-gateway-native-traffic.sh @@ -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 diff --git a/tools/test-singbox-native-traffic.sh b/tools/test-singbox-native-traffic.sh new file mode 100755 index 0000000..92733d8 --- /dev/null +++ b/tools/test-singbox-native-traffic.sh @@ -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 diff --git a/tsconfig.test.json b/tsconfig.test.json index 3101d7a..4bba745 100644 --- a/tsconfig.test.json +++ b/tsconfig.test.json @@ -24,6 +24,7 @@ "src/web/features/activity-journal/activityJournalModel.ts", "src/web/features/instructions/prometheus.ts", "src/web/features/routing/ruleReorderModel.ts", + "src/web/features/traffic/trafficRows.ts", "monitoring/grafana/harbor-gateway.json" ], "exclude": [".test-dist", "dist", "node_modules"]