Compare commits
13
Commits
f4882c53c2
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4c58384056 | ||
|
|
74c5b66482 | ||
|
|
1ae23d848b | ||
|
|
ab14fc979e | ||
|
|
76a99f098a | ||
|
|
e0bdafd25e | ||
|
|
3c2eefe108 | ||
|
|
6777422a27 | ||
|
|
d060e3bada | ||
|
|
797c73d35c | ||
|
|
79e00a2390 | ||
|
|
bdf3f22b12 | ||
|
|
4d066cb879 |
@@ -1,6 +1,6 @@
|
|||||||
---
|
---
|
||||||
name: design-harbor-device-ecosystem
|
name: design-harbor-device-ecosystem
|
||||||
description: Design, plan, implement, review, or document Harbor Gateway integrations with current and future companion devices. Use for LAN advertisement and discovery, Connect-Gateway pairing, trusted device identity, connected-client presence, ecosystem membership in the Gateway device list, multi-Gateway selection, capability or protocol negotiation, and related API, persistence, migration, or UI contracts. Do not use for traffic-only LAN inventory or visual polish that does not change ecosystem behavior.
|
description: "Use when changing or reviewing Harbor companion-device discovery, pairing, trust, presence, routing handoff, or their API and persistence contracts. Not for traffic-only inventory or visual polish."
|
||||||
---
|
---
|
||||||
|
|
||||||
# Design Harbor Device Ecosystem
|
# Design Harbor Device Ecosystem
|
||||||
@@ -18,7 +18,7 @@ Keep discovery, trust, application presence, routing, and LAN observation separa
|
|||||||
|
|
||||||
## Workflow
|
## Workflow
|
||||||
|
|
||||||
1. Read `PRODUCT.md`, `workpack/PRODUCT_PRIORITIES.md`, `workpack/DATA_CONSISTENCY_MODEL.md`, `workpack/DESIGN_OWNER_POLICY.md`, `workpack/STATUS.md`, and the selected workpack task.
|
1. Start with the selected workpack task when one exists. Read `PRODUCT.md` and `workpack/PRODUCT_PRIORITIES.md` for product scope, `workpack/DATA_CONSISTENCY_MODEL.md` for persistence or projection changes, and `workpack/STATUS.md` for active roadmap/cutover work. Any visible UI decision requires `workpack/DESIGN_OWNER_POLICY.md`; reuse an exact owner decision already recorded for this scope.
|
||||||
2. Read [device-ecosystem-contract.md](references/device-ecosystem-contract.md) before changing discovery, pairing, identity, presence, routing handoff, or the Gateway device projection.
|
2. Read [device-ecosystem-contract.md](references/device-ecosystem-contract.md) before changing discovery, pairing, identity, presence, routing handoff, or the Gateway device projection.
|
||||||
3. Trace the current producer -> transport -> persistence -> canonical snapshot -> UI path. Confirm the owning service and every caller before editing.
|
3. Trace the current producer -> transport -> persistence -> canonical snapshot -> UI path. Confirm the owning service and every caller before editing.
|
||||||
4. Classify each proposed field and state as one of: discovery candidate, persisted trust relationship, authenticated presence lease, route state, network observation, or derived binding. Reject fields that mix categories.
|
4. Classify each proposed field and state as one of: discovery candidate, persisted trust relationship, authenticated presence lease, route state, network observation, or derived binding. Reject fields that mix categories.
|
||||||
@@ -44,7 +44,7 @@ Keep discovery, trust, application presence, routing, and LAN observation separa
|
|||||||
|
|
||||||
## Acceptance pass
|
## Acceptance pass
|
||||||
|
|
||||||
Before handing off an ecosystem change, verify:
|
Verify the affected contracts below. Protocol, authentication, shared projection, or migration changes require coverage across the impacted lifecycle; a documentation-only correction uses consistency/link checks. Do not operate live devices without the exact authorization described above:
|
||||||
|
|
||||||
- A supported client can discover more than one Gateway and survives address changes without changing Gateway identity.
|
- A supported client can discover more than one Gateway and survives address changes without changing Gateway identity.
|
||||||
- Spoofed discovery cannot create trust, change the active route, or appear as a paired ecosystem member.
|
- Spoofed discovery cannot create trust, change the active route, or appear as a paired ecosystem member.
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
---
|
---
|
||||||
name: design-vpn-client-ui
|
name: design-vpn-client-ui
|
||||||
description: Design, implement, review, or refine the client-facing VPN interfaces in this repository using the established calm monospace visual language and smooth state-driven motion. Use for the current macOS client and future end-user gateway client screens, especially power controls, subscriptions, traffic usage, proxy copy controls, server selection, responsive layout, hover feedback, transitions, and animation polish. Do not use for the administrative gateway UI unless the user explicitly asks to apply the client visual language there.
|
description: "Use when changing or reviewing Harbor Connect and Gateway client UI. Apply the established visual and motion language; not for administrative Gateway UI unless explicitly requested."
|
||||||
---
|
---
|
||||||
|
|
||||||
# Design VPN Client UI
|
# Design VPN Client UI
|
||||||
@@ -9,23 +9,20 @@ Preserve the repo's focused one-screen VPN client language: a centered primary a
|
|||||||
|
|
||||||
## Workflow
|
## Workflow
|
||||||
|
|
||||||
1. Read `PRODUCT.md` and the complete client component and styles before editing.
|
1. Inspect the affected client component, state owner, relevant styles, and nearest analogue. Read `PRODUCT.md` when product behavior or mode semantics change; expand context when shared invariants require it.
|
||||||
2. Inspect supplied evidence and trace the real DOM and state change that causes the visual issue. Follow repository testing policy; do not launch manual or interactive visual testing unless the user explicitly requests it in the current prompt.
|
2. Inspect supplied evidence and trace the real DOM and state change that causes the visual issue. Follow repository testing policy; do not launch manual or interactive visual testing unless the user explicitly requests it in the current prompt.
|
||||||
3. Read [visual-language.md](references/visual-language.md) for layout, hierarchy, color, and typography work.
|
3. Read [visual-language.md](references/visual-language.md) for layout, hierarchy, color, and typography work.
|
||||||
4. Read [motion-and-interaction.md](references/motion-and-interaction.md) for animation, hover, refresh, input, copy, or state-transition work.
|
4. Read [motion-and-interaction.md](references/motion-and-interaction.md) for animation, hover, refresh, input, copy, or state-transition work.
|
||||||
5. Reuse existing React state, CSS variables, formatters, and API paths. Prefer a narrow CSS/markup change over a new abstraction or dependency.
|
5. Reuse existing React state, CSS variables, formatters, and API paths. Prefer a narrow CSS/markup change over a new abstraction or dependency.
|
||||||
6. Keep geometry stable across every state. Reserve space before animating content.
|
6. Keep geometry stable across every state. Reserve space before animating content.
|
||||||
7. Implement `prefers-reduced-motion` alongside every new animation.
|
7. Implement `prefers-reduced-motion` alongside every new animation.
|
||||||
8. Run `npm test`, `npm run build`, and `git diff --check`. Perform manual visual inspection only when explicitly requested.
|
8. Run focused automated checks for affected behavior and `npm run build` for client code changes; use the full `npm test` suite when shared behavior changes or narrower evidence is insufficient. Documentation-only work uses structural/link checks and `git diff --check`. Manual visual inspection requires an explicit request in the current prompt.
|
||||||
|
|
||||||
## Communicating a proposed change
|
## Communicating a proposed change
|
||||||
|
|
||||||
For implementation proposals and progress summaries, combine structure instead of maximizing it:
|
For a local correction, report the result and focused evidence briefly. For a change spanning
|
||||||
|
several owners or state lifecycles, use one compact table of affected modules plus the
|
||||||
- start with one compact table grouped by affected modules such as frontend, UI, backend/API, styles, tests, and versions;
|
relevant states, accessibility, and motion decisions. Omit empty sections and unrelated modules.
|
||||||
- use short lists only for cross-cutting details such as states, accessibility, motion, or changed files;
|
|
||||||
- keep unaffected modules visible in the table when that clarifies scope;
|
|
||||||
- avoid both an unstructured paragraph and a separate table for every subsection.
|
|
||||||
|
|
||||||
## Non-negotiable decisions
|
## Non-negotiable decisions
|
||||||
|
|
||||||
@@ -49,7 +46,7 @@ For implementation proposals and progress summaries, combine structure instead o
|
|||||||
|
|
||||||
## Acceptance pass
|
## Acceptance pass
|
||||||
|
|
||||||
Before handing off, verify:
|
Check the affected behaviors below using automated evidence where possible. Shared layout or motion changes require broader coverage; a local correction does not require exercising unrelated controls. Manual inspection remains limited to an explicit request in the current prompt:
|
||||||
|
|
||||||
- Power on/off is unmistakable without reading the label.
|
- Power on/off is unmistakable without reading the label.
|
||||||
- Switching on/off preserves the exact positions of title, timer, and hint.
|
- Switching on/off preserves the exact positions of title, timer, and hint.
|
||||||
|
|||||||
+2
-1
@@ -4,12 +4,13 @@ CLIENT_UI_PORT=3456
|
|||||||
CLIENT_PROXY_PORT=8082
|
CLIENT_PROXY_PORT=8082
|
||||||
HARBOR_GATEWAY_CONTROL_PORT=3456
|
HARBOR_GATEWAY_CONTROL_PORT=3456
|
||||||
BASE_IMAGE=debian:bookworm-slim
|
BASE_IMAGE=debian:bookworm-slim
|
||||||
SINGBOX_VERSION=1.13.18
|
SINGBOX_VERSION=1.14.0-rc.5
|
||||||
INSTALL_RUNTIME_DEPS=true
|
INSTALL_RUNTIME_DEPS=true
|
||||||
INSTALL_SINGBOX=true
|
INSTALL_SINGBOX=true
|
||||||
PROXY_PORT=8080
|
PROXY_PORT=8080
|
||||||
PROXY_BIND_IP=0.0.0.0
|
PROXY_BIND_IP=0.0.0.0
|
||||||
SING_BOX_API_PORT=19090
|
SING_BOX_API_PORT=19090
|
||||||
|
SING_BOX_TRAFFIC_SOURCE=native
|
||||||
TPROXY_PORT=7895
|
TPROXY_PORT=7895
|
||||||
TPROXY_MARK=1
|
TPROXY_MARK=1
|
||||||
TPROXY_TABLE=100
|
TPROXY_TABLE=100
|
||||||
|
|||||||
@@ -14,11 +14,11 @@ on:
|
|||||||
env:
|
env:
|
||||||
DEPLOY_PATH: /opt/vpn-proxy
|
DEPLOY_PATH: /opt/vpn-proxy
|
||||||
BASE_IMAGE: vpn-proxy-runtime-base:bookworm-slim
|
BASE_IMAGE: vpn-proxy-runtime-base:bookworm-slim
|
||||||
NODE_BUILD_IMAGE: mirror.gcr.io/library/node:20.19-bookworm
|
NODE_BUILD_IMAGE: mirror.gcr.io/library/node:24.21.0-bookworm
|
||||||
RUNTIME_BASE_SOURCE_IMAGE: mirror.gcr.io/library/debian:bookworm-slim
|
RUNTIME_BASE_SOURCE_IMAGE: mirror.gcr.io/library/debian:bookworm-slim
|
||||||
APT_MIRROR: http://mirror.yandex.ru/debian
|
APT_MIRROR: http://mirror.yandex.ru/debian
|
||||||
APT_SECURITY_MIRROR: http://mirror.yandex.ru/debian-security
|
APT_SECURITY_MIRROR: http://mirror.yandex.ru/debian-security
|
||||||
SINGBOX_VERSION: 1.13.18
|
SINGBOX_VERSION: 1.14.0-rc.5
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build-and-push:
|
build-and-push:
|
||||||
@@ -100,7 +100,7 @@ jobs:
|
|||||||
echo "Restart scope: ${RESTART_SCOPE}"
|
echo "Restart scope: ${RESTART_SCOPE}"
|
||||||
echo "affected_components=${AFFECTED_COMPONENTS}" >> "$GITHUB_OUTPUT"
|
echo "affected_components=${AFFECTED_COMPONENTS}" >> "$GITHUB_OUTPUT"
|
||||||
echo "restart_scope=${RESTART_SCOPE}" >> "$GITHUB_OUTPUT"
|
echo "restart_scope=${RESTART_SCOPE}" >> "$GITHUB_OUTPUT"
|
||||||
if command -v npm >/dev/null 2>&1; then
|
if command -v npm >/dev/null 2>&1 && node scripts/check-sqlite-runtime.mjs; then
|
||||||
npm ci --no-audit --no-fund
|
npm ci --no-audit --no-fund
|
||||||
npm run typecheck
|
npm run typecheck
|
||||||
npm run check:boundaries
|
npm run check:boundaries
|
||||||
@@ -108,7 +108,7 @@ jobs:
|
|||||||
npm run build:production
|
npm run build:production
|
||||||
else
|
else
|
||||||
if ! docker run --rm "${{ env.NODE_BUILD_IMAGE }}" sh -lc 'command -v npm >/dev/null && command -v git >/dev/null && test -x /bin/bash'; then
|
if ! docker run --rm "${{ env.NODE_BUILD_IMAGE }}" sh -lc 'command -v npm >/dev/null && command -v git >/dev/null && test -x /bin/bash'; then
|
||||||
echo "Cannot validate change: host npm and the Node 20.19 build toolchain are unavailable." >&2
|
echo "Cannot validate change: the pinned Node 24.21.0 build toolchain is unavailable." >&2
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
echo "Host npm not found; validating inside ${{ env.NODE_BUILD_IMAGE }}"
|
echo "Host npm not found; validating inside ${{ env.NODE_BUILD_IMAGE }}"
|
||||||
@@ -147,6 +147,7 @@ jobs:
|
|||||||
APT_MIRROR="${{ env.APT_MIRROR }}" \
|
APT_MIRROR="${{ env.APT_MIRROR }}" \
|
||||||
APT_SECURITY_MIRROR="${{ env.APT_SECURITY_MIRROR }}" \
|
APT_SECURITY_MIRROR="${{ env.APT_SECURITY_MIRROR }}" \
|
||||||
SINGBOX_VERSION="${{ env.SINGBOX_VERSION }}" \
|
SINGBOX_VERSION="${{ env.SINGBOX_VERSION }}" \
|
||||||
|
NODE_BUILD_IMAGE="${{ env.NODE_BUILD_IMAGE }}" \
|
||||||
./scripts/build-runtime-base.sh
|
./scripts/build-runtime-base.sh
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ data/
|
|||||||
|
|
||||||
# Local roadmap and task workspace
|
# Local roadmap and task workspace
|
||||||
/workpack/
|
/workpack/
|
||||||
|
/design-qa.md
|
||||||
|
|
||||||
# Node/Vite
|
# Node/Vite
|
||||||
node_modules/
|
node_modules/
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
24.21.0
|
||||||
+15
-4
@@ -1,8 +1,10 @@
|
|||||||
ARG NODE_BUILD_IMAGE=node:20.19-alpine
|
ARG NODE_BUILD_IMAGE=node:24.21.0-bookworm
|
||||||
ARG BASE_IMAGE=debian:bookworm-slim
|
ARG BASE_IMAGE=debian:bookworm-slim
|
||||||
|
|
||||||
FROM ${NODE_BUILD_IMAGE} AS build
|
FROM ${NODE_BUILD_IMAGE} AS build
|
||||||
WORKDIR /src
|
WORKDIR /src
|
||||||
|
COPY scripts/check-sqlite-runtime.mjs ./scripts/check-sqlite-runtime.mjs
|
||||||
|
RUN node scripts/check-sqlite-runtime.mjs
|
||||||
COPY package.json package-lock.json ./
|
COPY package.json package-lock.json ./
|
||||||
RUN npm ci
|
RUN npm ci
|
||||||
COPY index.html vite.config.ts tsconfig*.json ./
|
COPY index.html vite.config.ts tsconfig*.json ./
|
||||||
@@ -13,13 +15,14 @@ COPY monitoring/grafana/harbor-gateway.json ./monitoring/grafana/harbor-gateway.
|
|||||||
RUN npm run build:production
|
RUN npm run build:production
|
||||||
|
|
||||||
FROM ${BASE_IMAGE}
|
FROM ${BASE_IMAGE}
|
||||||
ARG SINGBOX_VERSION=1.13.18
|
COPY --from=build /usr/local /usr/local
|
||||||
|
ARG SINGBOX_VERSION=1.14.0-rc.5
|
||||||
ARG INSTALL_RUNTIME_DEPS=true
|
ARG INSTALL_RUNTIME_DEPS=true
|
||||||
ARG INSTALL_SINGBOX=true
|
ARG INSTALL_SINGBOX=true
|
||||||
|
|
||||||
RUN if [ "${INSTALL_RUNTIME_DEPS}" = "true" ]; then \
|
RUN if [ "${INSTALL_RUNTIME_DEPS}" = "true" ]; then \
|
||||||
apt-get update \
|
apt-get update \
|
||||||
&& apt-get install -y --no-install-recommends ca-certificates curl iptables iproute2 ieee-data nodejs dumb-init \
|
&& apt-get install -y --no-install-recommends ca-certificates curl iptables iproute2 ieee-data dumb-init \
|
||||||
&& rm -rf /var/lib/apt/lists/*; \
|
&& rm -rf /var/lib/apt/lists/*; \
|
||||||
else \
|
else \
|
||||||
command -v dumb-init >/dev/null \
|
command -v dumb-init >/dev/null \
|
||||||
@@ -46,6 +49,13 @@ RUN if [ "${INSTALL_SINGBOX}" = "true" ]; then \
|
|||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY --from=build /src/dist /app/dist
|
COPY --from=build /src/dist /app/dist
|
||||||
|
COPY --from=build /src/node_modules/@bufbuild/protobuf /app/node_modules/@bufbuild/protobuf
|
||||||
|
COPY --from=build /src/node_modules/@connectrpc/connect /app/node_modules/@connectrpc/connect
|
||||||
|
COPY --from=build /src/node_modules/@connectrpc/connect-node /app/node_modules/@connectrpc/connect-node
|
||||||
|
COPY --from=build /src/node_modules/tldts /app/node_modules/tldts
|
||||||
|
COPY --from=build /src/node_modules/tldts-core /app/node_modules/tldts-core
|
||||||
|
COPY scripts/check-sqlite-runtime.mjs /app/scripts/check-sqlite-runtime.mjs
|
||||||
|
RUN node /app/scripts/check-sqlite-runtime.mjs
|
||||||
COPY package.json /app/package.json
|
COPY package.json /app/package.json
|
||||||
COPY entrypoint.sh /entrypoint.sh
|
COPY entrypoint.sh /entrypoint.sh
|
||||||
|
|
||||||
@@ -59,6 +69,7 @@ ENV PORT=3456 \
|
|||||||
TPROXY_PORT=7895 \
|
TPROXY_PORT=7895 \
|
||||||
DATA_DIR=/var/lib/vpn-proxy \
|
DATA_DIR=/var/lib/vpn-proxy \
|
||||||
SING_BOX_CONFIG=/etc/sing-box/config.json \
|
SING_BOX_CONFIG=/etc/sing-box/config.json \
|
||||||
SING_BOX_CACHE=/var/lib/sing-box/cache.db
|
SING_BOX_CACHE=/var/lib/sing-box/cache.db \
|
||||||
|
SING_BOX_TRAFFIC_SOURCE=snapshot
|
||||||
|
|
||||||
ENTRYPOINT ["dumb-init", "/entrypoint.sh"]
|
ENTRYPOINT ["dumb-init", "/entrypoint.sh"]
|
||||||
|
|||||||
+14
-3
@@ -1,8 +1,10 @@
|
|||||||
ARG NODE_BUILD_IMAGE=node:20.19-alpine
|
ARG NODE_BUILD_IMAGE=node:24.21.0-bookworm
|
||||||
ARG RUNTIME_IMAGE=debian:bookworm-slim
|
ARG RUNTIME_IMAGE=debian:bookworm-slim
|
||||||
|
|
||||||
FROM ${NODE_BUILD_IMAGE} AS build
|
FROM ${NODE_BUILD_IMAGE} AS build
|
||||||
WORKDIR /src
|
WORKDIR /src
|
||||||
|
COPY scripts/check-sqlite-runtime.mjs ./scripts/check-sqlite-runtime.mjs
|
||||||
|
RUN node scripts/check-sqlite-runtime.mjs
|
||||||
COPY package.json package-lock.json ./
|
COPY package.json package-lock.json ./
|
||||||
RUN npm ci
|
RUN npm ci
|
||||||
COPY index.html vite.config.ts tsconfig*.json ./
|
COPY index.html vite.config.ts tsconfig*.json ./
|
||||||
@@ -13,10 +15,11 @@ COPY monitoring/grafana/harbor-gateway.json ./monitoring/grafana/harbor-gateway.
|
|||||||
RUN npm run build:production
|
RUN npm run build:production
|
||||||
|
|
||||||
FROM ${RUNTIME_IMAGE}
|
FROM ${RUNTIME_IMAGE}
|
||||||
ARG SINGBOX_VERSION=1.13.18
|
COPY --from=build /usr/local /usr/local
|
||||||
|
ARG SINGBOX_VERSION=1.14.0-rc.5
|
||||||
|
|
||||||
RUN apt-get update \
|
RUN apt-get update \
|
||||||
&& apt-get install -y --no-install-recommends ca-certificates curl dumb-init nodejs tar \
|
&& apt-get install -y --no-install-recommends ca-certificates curl dumb-init tar \
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
RUN set -eux; \
|
RUN set -eux; \
|
||||||
@@ -34,6 +37,13 @@ RUN set -eux; \
|
|||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY --from=build /src/dist /app/dist
|
COPY --from=build /src/dist /app/dist
|
||||||
|
COPY --from=build /src/node_modules/@bufbuild/protobuf /app/node_modules/@bufbuild/protobuf
|
||||||
|
COPY --from=build /src/node_modules/@connectrpc/connect /app/node_modules/@connectrpc/connect
|
||||||
|
COPY --from=build /src/node_modules/@connectrpc/connect-node /app/node_modules/@connectrpc/connect-node
|
||||||
|
COPY --from=build /src/node_modules/tldts /app/node_modules/tldts
|
||||||
|
COPY --from=build /src/node_modules/tldts-core /app/node_modules/tldts-core
|
||||||
|
COPY scripts/check-sqlite-runtime.mjs /app/scripts/check-sqlite-runtime.mjs
|
||||||
|
RUN node /app/scripts/check-sqlite-runtime.mjs
|
||||||
COPY package.json /app/package.json
|
COPY package.json /app/package.json
|
||||||
COPY entrypoint.client.sh /entrypoint.client.sh
|
COPY entrypoint.client.sh /entrypoint.client.sh
|
||||||
|
|
||||||
@@ -49,6 +59,7 @@ ENV APP_MODE=client \
|
|||||||
SING_BOX_CACHE=/var/lib/sing-box/cache.db \
|
SING_BOX_CACHE=/var/lib/sing-box/cache.db \
|
||||||
RULE_SET_DOWNLOAD_DETOUR=vpn \
|
RULE_SET_DOWNLOAD_DETOUR=vpn \
|
||||||
ROUTING_RU_DIRECT=true \
|
ROUTING_RU_DIRECT=true \
|
||||||
|
SING_BOX_TRAFFIC_SOURCE=native \
|
||||||
LOG_LEVEL=info
|
LOG_LEVEL=info
|
||||||
|
|
||||||
EXPOSE 3456 8082
|
EXPOSE 3456 8082
|
||||||
|
|||||||
@@ -1,6 +1,11 @@
|
|||||||
ARG BASE_IMAGE=mirror.gcr.io/library/debian:bookworm-slim
|
ARG BASE_IMAGE=mirror.gcr.io/library/debian:bookworm-slim
|
||||||
|
ARG NODE_BUILD_IMAGE=node:24.21.0-bookworm
|
||||||
|
FROM ${NODE_BUILD_IMAGE} AS node-runtime
|
||||||
FROM ${BASE_IMAGE}
|
FROM ${BASE_IMAGE}
|
||||||
ARG SINGBOX_VERSION=1.13.18
|
COPY --from=node-runtime /usr/local /usr/local
|
||||||
|
COPY scripts/check-sqlite-runtime.mjs /opt/harbor/check-sqlite-runtime.mjs
|
||||||
|
RUN node /opt/harbor/check-sqlite-runtime.mjs
|
||||||
|
ARG SINGBOX_VERSION=1.14.0-rc.5
|
||||||
ARG APT_MIRROR=http://mirror.yandex.ru/debian
|
ARG APT_MIRROR=http://mirror.yandex.ru/debian
|
||||||
ARG APT_SECURITY_MIRROR=http://mirror.yandex.ru/debian-security
|
ARG APT_SECURITY_MIRROR=http://mirror.yandex.ru/debian-security
|
||||||
ARG HTTP_PROXY
|
ARG HTTP_PROXY
|
||||||
@@ -32,7 +37,7 @@ RUN export http_proxy="${http_proxy:-${HTTP_PROXY:-}}" \
|
|||||||
-o Acquire::http::Timeout=20 \
|
-o Acquire::http::Timeout=20 \
|
||||||
-o Acquire::https::Timeout=20 \
|
-o Acquire::https::Timeout=20 \
|
||||||
-o Acquire::ForceIPv4=true \
|
-o Acquire::ForceIPv4=true \
|
||||||
install -y --no-install-recommends ca-certificates curl iptables ipset iproute2 ieee-data nodejs npm dumb-init \
|
install -y --no-install-recommends ca-certificates curl iptables ipset iproute2 ieee-data dumb-init \
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
RUN set -eux; \
|
RUN set -eux; \
|
||||||
|
|||||||
@@ -87,7 +87,7 @@ http://АДРЕС-GATEWAY:3456
|
|||||||
|
|
||||||
Откройте «Устройства» в правой панели Gateway — подписка для просмотра списка не требуется. Harbor раз в 15 секунд читает локальную таблицу соседей и показывает каждое устройство одной компактной строкой: заданное название, hostname или IP, последний контакт, выбранный график трафика и иконку применённого маршрута. По умолчанию график показывает приблизительный выход `VPN`/`Direct`; переключатель `Вход` возвращает накопленную разбивку `Gateway`/`Прокси`. Наведите курсор на имя или переведите на него фокус, чтобы открыть IP, MAC и доступный hostname; нажатие на значение копирует его. Hostname определяется через локальное обратное разрешение имён и может отсутствовать, если сеть его не публикует. Технические interface и manufacturer продолжают храниться для идентификации, но не занимают место в строке. Список разделён на «Закреплённые», «Остальные» и «Фоновые»: последняя группа сохраняется между перезапусками, показывает только identity/presence и кнопку возврата без графика, traffic и route controls. Название, закрепление, фоновое положение и накопленные totals сохраняются в volume Gateway, пока устройство остаётся в inventory.
|
Откройте «Устройства» в правой панели Gateway — подписка для просмотра списка не требуется. Harbor раз в 15 секунд читает локальную таблицу соседей и показывает каждое устройство одной компактной строкой: заданное название, hostname или IP, последний контакт, выбранный график трафика и иконку применённого маршрута. По умолчанию график показывает приблизительный выход `VPN`/`Direct`; переключатель `Вход` возвращает накопленную разбивку `Gateway`/`Прокси`. Наведите курсор на имя или переведите на него фокус, чтобы открыть IP, MAC и доступный hostname; нажатие на значение копирует его. Hostname определяется через локальное обратное разрешение имён и может отсутствовать, если сеть его не публикует. Технические interface и manufacturer продолжают храниться для идентификации, но не занимают место в строке. Список разделён на «Закреплённые», «Остальные» и «Фоновые»: последняя группа сохраняется между перезапусками, показывает только identity/presence и кнопку возврата без графика, traffic и route controls. Название, закрепление, фоновое положение и накопленные totals сохраняются в volume Gateway, пока устройство остаётся в inventory.
|
||||||
|
|
||||||
Левая панель списка ищет по имени, hostname, IP, MAC и тегам, фильтрует новые, закреплённые, фоновые или устройства без тегов и позволяет выбрать несколько тегов по правилу «хотя бы один». Каталог тегов общий для Gateway: в нём можно создать до 32 тегов и назначить устройству до 8. Назначения сохраняются вместе с `devices.json`, но маршруты не меняют. После удаления устройства по 30-дневному retention его назначения удаляются, сам каталог остаётся; вернувшееся позже устройство появляется без тегов. Если Mac-клиент подключён к старой версии Gateway, список продолжает работать, а управление тегами скрывается до обновления Gateway.
|
Левая панель списка ищет по имени, hostname, IP, MAC и тегам, фильтрует новые, закреплённые, фоновые или устройства без тегов и позволяет выбрать несколько тегов по правилу «хотя бы один». Каталог тегов общий для Gateway: в нём можно создать до 32 тегов и назначить устройству до 8. Назначения сохраняются в документе устройств внутри `harbor.sqlite`, но маршруты не меняют. После удаления устройства по 30-дневному retention его назначения удаляются, сам каталог остаётся; вернувшееся позже устройство появляется без тегов. Если Mac-клиент подключён к старой версии Gateway, список продолжает работать, а управление тегами скрывается до обновления Gateway.
|
||||||
|
|
||||||
Красная кнопка `Сбросить данные` после отдельного подтверждения обнуляет вход и выход всех устройств и начинает считать их заново. Общий график скорости на Home и уже сохранённая история Prometheus/Grafana не очищаются: входной counter выглядит для Prometheus как стандартный reset, а для выхода Harbor сохраняет только baseline отображения и не изменяет raw dataplane counters.
|
Красная кнопка `Сбросить данные` после отдельного подтверждения обнуляет вход и выход всех устройств и начинает считать их заново. Общий график скорости на Home и уже сохранённая история Prometheus/Grafana не очищаются: входной counter выглядит для Prometheus как стандартный reset, а для выхода Harbor сохраняет только baseline отображения и не изменяет raw dataplane counters.
|
||||||
|
|
||||||
@@ -132,6 +132,8 @@ curl -fsSL https://git.dokops.ru/dokril/vpn-proxy/raw/branch/master/install.sh |
|
|||||||
|
|
||||||
Сам по себе локальный прокси не перенаправляет приложения автоматически. Адрес `127.0.0.1:8082` нужно указать в настройках нужного приложения или в системных настройках macOS.
|
Сам по себе локальный прокси не перенаправляет приложения автоматически. Адрес `127.0.0.1:8082` нужно указать в настройках нужного приложения или в системных настройках macOS.
|
||||||
|
|
||||||
|
Кнопка «Трафик» в правой панели показывает активные соединения, которые прошли через Harbor Connect. Данные о приложениях macOS недоступны, потому что sing-box работает внутри Docker.
|
||||||
|
|
||||||
### Другие порты
|
### Другие порты
|
||||||
|
|
||||||
Передайте нужные значения при повторном запуске установщика:
|
Передайте нужные значения при повторном запуске установщика:
|
||||||
@@ -269,14 +271,35 @@ curl -fsSL https://git.dokops.ru/dokril/vpn-proxy/raw/branch/master/install.sh |
|
|||||||
| `PROXY_ALLOWED_CIDRS` | приватные IPv4-сети | Сети, которым разрешён доступ к Gateway Proxy |
|
| `PROXY_ALLOWED_CIDRS` | приватные IPv4-сети | Сети, которым разрешён доступ к Gateway Proxy |
|
||||||
| `GATEWAY_CLIENT_CIDRS` | приватные IPv4-сети | Сети, трафик которых Gateway может маршрутизировать |
|
| `GATEWAY_CLIENT_CIDRS` | приватные IPv4-сети | Сети, трафик которых Gateway может маршрутизировать |
|
||||||
| `DIRECT_TRAFFIC_MARK` | `0x40000000` | Зарезервированный одиночный connmark-бит учёта Direct; измените при конфликте с host QoS/firewall, не пересекаясь с `TPROXY_MARK` |
|
| `DIRECT_TRAFFIC_MARK` | `0x40000000` | Зарезервированный одиночный connmark-бит учёта Direct; измените при конфликте с host QoS/firewall, не пересекаясь с `TPROXY_MARK` |
|
||||||
|
| `SING_BOX_TRAFFIC_SOURCE` | `native` | Источник Gateway traffic counters: `snapshot`, `shadow` или `native` |
|
||||||
| `LOG_LEVEL` | `info` | Уровень подробности журнала |
|
| `LOG_LEVEL` | `info` | Уровень подробности журнала |
|
||||||
|
|
||||||
Остальные значения в `.env.example` относятся к сборке контейнера и внутренней маршрутизации. Меняйте их только при нестандартном развёртывании.
|
Остальные значения в `.env.example` относятся к сборке контейнера и внутренней маршрутизации. Меняйте их только при нестандартном развёртывании.
|
||||||
|
|
||||||
После изменения `.env` пересоздайте контейнер командой `up -d` — обычного `restart` недостаточно.
|
После изменения `.env` пересоздайте контейнер командой `up -d` — обычного `restart` недостаточно.
|
||||||
|
|
||||||
|
Production Gateway по умолчанию использует `native`: новый счётчик видит полный жизненный цикл соединений, включая короткие соединения и последние байты перед закрытием. `shadow` оставляет основным старый счётчик и запускает новый только для сравнения. `snapshot` полностью выключает инспектор и опрашивает активные соединения раз в 2 секунды. Режим меняется только при пересоздании обоих 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
|
||||||
|
```
|
||||||
|
|
||||||
|
## Локальная история трафика
|
||||||
|
|
||||||
|
Harbor полностью работает без Prometheus. В существующем drawer «Трафик» режимы `Сейчас / История` разделяют текущие соединения и локальные суммы. История поддерживает `24 часа / 7 дней / 30 дней / 90 дней`, поиск, маршрут и устройство на Gateway; строки раскрываются как сервис → домен → полное имя → IP. Например, `www.yandex.ru` и `mail.yandex.com` остаются разными именами внутри группы «Яндекс». IP без наблюдённого домена не выдаётся за распознанный сайт.
|
||||||
|
|
||||||
|
`traffic.sqlite` хранит рабочие данные за 90 дней: завершённые минуты за последние 7 дней, далее часы. Текущая история отстаёт не более чем на минуту при исправном сборе; API сообщает фактически доступный период, детализацию и пропуски. История начинается с включения нового native-сбора. Данные по доменам относятся только к соединениям, наблюдаемым sing-box, и не восстанавливают ранее накопленные общие счётчики.
|
||||||
|
|
||||||
|
Запись и запросы выполняются в отдельном рабочем потоке. Ошибка базы или переполнение ограниченной очереди отмечает историю как неполную, но не останавливает VPN или экспорт метрик. Для защиты от повторного учёта сохраняются позиции счётчиков: активные — пока нужны их исходные значения, закрытые — до 90 дней либо смены процесса sing-box. Размер зависит не только от доменов, но и от числа соединений; это не база фиксированного размера. Освобождённые страницы переиспользуются без обязательного немедленного уменьшения файла.
|
||||||
|
|
||||||
## Prometheus и Grafana
|
## Prometheus и Grafana
|
||||||
|
|
||||||
|
Prometheus необязателен. Он хранит только экспортируемые метрики по политике своего владельца, а не копию всей SQLite. Полную доменную/IP-детализацию внешнего архива этот релиз не обещает. Нет синхронизации баз, автоматического восполнения пропущенных scrape, восстановления SQLite из Prometheus или переключения интерфейса на него. Очистка локальной истории не удаляет внешнюю; отсутствие Prometheus не продлевает локальные 90 дней.
|
||||||
|
|
||||||
Gateway публикует уже накопленные Harbor traffic counters по адресу `http://<gateway>:3456/metrics`. Scrape не запускает дополнительный netfilter read и не меняет сохранённое состояние. Harbor обновляет snapshot раз в 15 секунд, поэтому рекомендуемый начальный scrape interval и refresh dashboard — 30 секунд:
|
Gateway публикует уже накопленные Harbor traffic counters по адресу `http://<gateway>:3456/metrics`. Scrape не запускает дополнительный netfilter read и не меняет сохранённое состояние. Harbor обновляет snapshot раз в 15 секунд, поэтому рекомендуемый начальный scrape interval и refresh dashboard — 30 секунд:
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
@@ -289,15 +312,17 @@ scrape_configs:
|
|||||||
- targets: ["<gateway>:3456"]
|
- targets: ["<gateway>:3456"]
|
||||||
```
|
```
|
||||||
|
|
||||||
`harbor_traffic_bytes_total` содержит общий накопленный объём по источникам Gateway/Proxy. `harbor_device_traffic_bytes_total` содержит upload/download по стабильному `device_id`; пользовательское название и текущий IP находятся в `harbor_device_info`. `harbor_device_domain_traffic_bytes_total` добавляет наблюдённые домен, сервис, источник и направление для каждого устройства. `harbor_domain_traffic_attribution_events_total{outcome}` помогает отличить нераспознанный hostname, неизвестное устройство и неподдерживаемый inbound без динамических high-cardinality labels.
|
`harbor_traffic_bytes_total` содержит общий накопленный объём по источникам Gateway/Proxy. `harbor_device_traffic_bytes_total` содержит upload/download по стабильному `device_id`; пользовательское название и текущий IP находятся в `harbor_device_info`. Gauge `harbor_device_applied_policy{device_id}` показывает последнюю применённую policy: `0` для Direct и `1` для VPN. История начинается с первого Prometheus scrape после обновления и не восстанавливается задним числом. `harbor_device_domain_traffic_bytes_total` добавляет наблюдённые домен, сервис, источник и направление для каждого устройства. `harbor_domain_traffic_attribution_events_total{outcome}` помогает отличить нераспознанный hostname, неизвестное устройство и неподдерживаемый inbound без динамических high-cardinality labels.
|
||||||
|
|
||||||
Фактический выход экспортируется отдельно. `harbor_singbox_tracked_bytes_total{source,outbound,direction}` показывает наблюдённые sing-box байты с `outbound="vpn|direct|unknown"`; вариант с префиксом `harbor_device_...` добавляет `device_id`. `harbor_direct_ipv4_packet_bytes_total{direction}` считает IPv4-пакеты, которые Gateway направил напрямую вместо sing-box, включая policy Direct и работу при остановленном VPN runtime; вариант `harbor_device_...` содержит атрибутированную детализацию. `source="gateway|proxy"` по-прежнему означает место входа, а `outbound` — выбранный sing-box выход.
|
Фактический выход экспортируется отдельно. `harbor_singbox_tracked_bytes_total{source,outbound,direction}` показывает наблюдённые sing-box байты с `outbound="vpn|direct|unknown"`; вариант с префиксом `harbor_device_...` добавляет `device_id`. `harbor_direct_ipv4_packet_bytes_total{direction}` считает IPv4-пакеты, которые Gateway направил напрямую вместо sing-box, включая policy Direct и работу при остановленном VPN runtime; вариант `harbor_device_...` содержит атрибутированную детализацию. `source="gateway|proxy"` по-прежнему означает место входа, а `outbound` — выбранный sing-box выход.
|
||||||
|
|
||||||
Dashboard начинает со скорости скачивания и отправки в конце выбранного периода, общего трафика и VPN / Direct внутри sing-box за этот период. Для стандартного диапазона, который заканчивается сейчас, карточки скорости показывают текущее значение. Единый фильтр `Устройства` по умолчанию охватывает все устройства, но позволяет выбрать одно; он управляет скоростью, общим трафиком, маршрутами, сервисами, доменами и технической детализацией. Таблица «Все устройства за период» намеренно остаётся общей: она показывает все устройства с ненулевым трафиком, сортируется в обе стороны и выбирает устройство в том же фильтре. Блок «Куда уходит трафик» показывает основные назначения и Top-15 доменов без пагинации. Свёрнутая техническая детализация отдельно показывает точки входа Gateway / Proxy и Direct IPv4 мимо sing-box. Автообновление настроено на 30 секунд; индикатор показывает возраст самого старого из контуров общего, domain / sing-box и Direct IPv4 трафика, предупреждает после 60 секунд и считает данные устаревшими после 120 секунд.
|
Dashboard начинает со скорости скачивания и отправки в конце выбранного периода, общего трафика и фактического VPN / Direct за этот период. Под обзором полоса `Применённый режим` показывает applied policy устройства, а график `Фактический VPN / Direct` независимо показывает маршрут наблюдённых байтов. Поэтому компьютер с policy Direct, браузер которого использует Harbor Proxy, остаётся Direct на полосе режима, но его proxy-соединения учитываются в VPN. Единый фильтр `Устройства` управляет режимом, скоростью, общим трафиком, сервисами, доменами и технической детализацией. Таблица «Все устройства за период» намеренно остаётся общей и выбирает устройство в том же фильтре. Блок «Куда уходит трафик» показывает основные назначения и Top-15 доменов без пагинации. Свёрнутая техническая детализация показывает `source × outbound`, включая `proxy · vpn`, и раздельные Direct-пути через sing-box и Linux мимо sing-box. Автообновление настроено на 30 секунд; индикатор предупреждает после 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, включая короткие соединения и финальный хвост; существующая проекция экспортируемых domain/outbound counters хранится в памяти до перезапуска, а необязательный Prometheus независимо сохраняет полученные метрики. Полный поток также поступает в отдельную локальную `traffic.sqlite`; её очистка не сбрасывает эту проекцию. Перед 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. Основные Grafana panels складывают их только как приблизительную пользовательскую оценку непересекающихся Direct-путей; техническая секция сохраняет значения раздельными. Эту сумму нельзя считать точным provider или wire total. Snapshot polling может пропустить короткие соединения и финальный хвост; native lifecycle закрывает этот разрыв только для трафика, вошедшего в sing-box. IPv6, трафик вне Gateway, назначения из `BYPASS_CIDRS` и quota провайдера не входят в route split.
|
||||||
|
|
||||||
Готовый dashboard: [`monitoring/grafana/harbor-gateway.json`](monitoring/grafana/harbor-gateway.json). При импорте Grafana попросит выбрать Prometheus data source. Та же конфигурация и dashboard доступны для копирования в Gateway drawer «Как использовать» → «Prometheus и Grafana».
|
Готовый dashboard: [`monitoring/grafana/harbor-gateway.json`](monitoring/grafana/harbor-gateway.json). При импорте Grafana попросит выбрать Prometheus data source. Та же конфигурация и dashboard доступны для копирования в Gateway drawer «Как использовать» → «Prometheus и Grafana».
|
||||||
|
|
||||||
@@ -344,20 +369,32 @@ docker compose -f docker-compose.client.local.yml config
|
|||||||
docker compose -f docker-compose.client.local.yml up -d --build
|
docker compose -f docker-compose.client.local.yml up -d --build
|
||||||
```
|
```
|
||||||
|
|
||||||
Интерфейс доступен на `http://127.0.0.1:3457`, HTTP/SOCKS5-прокси — на `127.0.0.1:8083`. Остановить и удалить только тестовый стек можно командой:
|
Интерфейс доступен на `http://127.0.0.1:3457`, HTTP/SOCKS5-прокси — на `127.0.0.1:8083`. Остановить тестовый стек с сохранением его volumes можно командой:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker compose -f docker-compose.client.local.yml down -v
|
docker compose -f docker-compose.client.local.yml down
|
||||||
```
|
```
|
||||||
|
|
||||||
Порты можно заменить через `LOCAL_CLIENT_UI_PORT` и `LOCAL_CLIENT_PROXY_PORT`.
|
Порты можно заменить через `LOCAL_CLIENT_UI_PORT` и `LOCAL_CLIENT_PROXY_PORT`.
|
||||||
|
|
||||||
|
Для rollback canary на стабильный sing-box без инспектора используйте:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
SINGBOX_VERSION=1.13.18 \
|
||||||
|
SING_BOX_TRAFFIC_SOURCE=disabled \
|
||||||
|
docker compose -f docker-compose.client.local.yml up -d --build
|
||||||
|
```
|
||||||
|
|
||||||
|
Не добавляйте `-v` к `down`, если хотите сохранить тестовые подписки и настройки.
|
||||||
|
|
||||||
## Служебные команды
|
## Служебные команды
|
||||||
|
|
||||||
Этот раздел нужен тем, кто собирает, проверяет или развёртывает сам проект. Для обычного использования он не требуется.
|
Этот раздел нужен тем, кто собирает, проверяет или развёртывает сам проект. Для обычного использования он не требуется.
|
||||||
|
|
||||||
### Команды npm
|
### Команды npm
|
||||||
|
|
||||||
|
Для сборки и backend закреплён Node **24.21.0** (`.node-version`); используется встроенная SQLite без ORM. `npm run check:runtime` проверяет точную Node-версию, движок SQLite не старше 3.51.3 и точность 64-битных счётчиков. Та же проверка выполняется в сборочных и конечных Docker-образах. При использовании fnm: `fnm use 24.21.0`.
|
||||||
|
|
||||||
| Команда | Назначение |
|
| Команда | Назначение |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| `npm ci` | Установить точные версии зависимостей из `package-lock.json` |
|
| `npm ci` | Установить точные версии зависимостей из `package-lock.json` |
|
||||||
@@ -381,4 +418,8 @@ docker compose -f docker-compose.client.local.yml down -v
|
|||||||
|
|
||||||
Подписка, выбранный сервер и состояние подключения хранятся в именованных Docker volumes. Поэтому обычные команды `restart`, `down`, обновление проекта и повторная сборка не удаляют настройки.
|
Подписка, выбранный сервер и состояние подключения хранятся в именованных Docker volumes. Поэтому обычные команды `restart`, `down`, обновление проекта и повторная сборка не удаляют настройки.
|
||||||
|
|
||||||
|
На каждом Mac/Gateway свои `harbor.sqlite` (настройки, подписки, устройства, правила, накопленные счётчики и журнал) и `traffic.sqlite` (ограниченная история). Журнал сохраняет прежний предел 30 дней/10 000 событий; настройки не подчиняются retention истории. Секреты, hardware ID, генерируемый конфиг и кеш sing-box остаются файлами.
|
||||||
|
|
||||||
|
Первый запуск транзакционно импортирует прежние JSON, сохраняя IDs, revisions и исходные значения счётчиков. После успеха SQLite становится единственным рабочим хранилищем; исходные JSON остаются неизменными резервными копиями, без параллельной записи. Повреждение или неизвестная версия останавливает миграцию без обнуления. Старый бинарник не читает новые данные: простой downgrade вернул бы устаревшие JSON. Правила backup и восстановления описаны в [state recovery](docs/recovery/state-recovery.md).
|
||||||
|
|
||||||
Не публикуйте файл `.env`, ссылку подписки и содержимое Docker volumes. `.env` уже исключён из Git.
|
Не публикуйте файл `.env`, ссылку подписки и содержимое Docker volumes. `.env` уже исключён из Git.
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
version: v2
|
||||||
|
clean: true
|
||||||
|
inputs:
|
||||||
|
- directory: proto/sing-box/v1.14.0-rc.5
|
||||||
|
plugins:
|
||||||
|
- local: protoc-gen-es
|
||||||
|
out: src/server/generated
|
||||||
|
opt:
|
||||||
|
- target=ts
|
||||||
@@ -4,7 +4,7 @@ services:
|
|||||||
context: .
|
context: .
|
||||||
dockerfile: Dockerfile.client
|
dockerfile: Dockerfile.client
|
||||||
args:
|
args:
|
||||||
SINGBOX_VERSION: ${SINGBOX_VERSION:-1.13.18}
|
SINGBOX_VERSION: ${SINGBOX_VERSION:-1.14.0-rc.5}
|
||||||
container_name: harbor-connect
|
container_name: harbor-connect
|
||||||
environment:
|
environment:
|
||||||
APP_MODE: client
|
APP_MODE: client
|
||||||
@@ -14,6 +14,7 @@ services:
|
|||||||
DATA_DIR: /var/lib/vpn-proxy
|
DATA_DIR: /var/lib/vpn-proxy
|
||||||
SING_BOX_CONFIG: /etc/sing-box/config.json
|
SING_BOX_CONFIG: /etc/sing-box/config.json
|
||||||
SING_BOX_CACHE: /var/lib/sing-box/cache.db
|
SING_BOX_CACHE: /var/lib/sing-box/cache.db
|
||||||
|
SING_BOX_TRAFFIC_SOURCE: ${SING_BOX_TRAFFIC_SOURCE:-native}
|
||||||
HARBOR_HOST_NETWORK_STATE: /run/harbor-host/network.json
|
HARBOR_HOST_NETWORK_STATE: /run/harbor-host/network.json
|
||||||
HARBOR_GATEWAY_CONTROL_PORT: ${HARBOR_GATEWAY_CONTROL_PORT:-3456}
|
HARBOR_GATEWAY_CONTROL_PORT: ${HARBOR_GATEWAY_CONTROL_PORT:-3456}
|
||||||
LOG_LEVEL: ${LOG_LEVEL:-info}
|
LOG_LEVEL: ${LOG_LEVEL:-info}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ x-gateway-image: &gateway-image
|
|||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
args:
|
args:
|
||||||
BASE_IMAGE: ${BASE_IMAGE:-debian:bookworm-slim}
|
BASE_IMAGE: ${BASE_IMAGE:-debian:bookworm-slim}
|
||||||
SINGBOX_VERSION: ${SINGBOX_VERSION:-1.13.18}
|
SINGBOX_VERSION: ${SINGBOX_VERSION:-1.14.0-rc.5}
|
||||||
INSTALL_RUNTIME_DEPS: ${INSTALL_RUNTIME_DEPS:-true}
|
INSTALL_RUNTIME_DEPS: ${INSTALL_RUNTIME_DEPS:-true}
|
||||||
INSTALL_SINGBOX: ${INSTALL_SINGBOX:-true}
|
INSTALL_SINGBOX: ${INSTALL_SINGBOX:-true}
|
||||||
|
|
||||||
@@ -25,6 +25,9 @@ services:
|
|||||||
DATA_DIR: /var/lib/vpn-proxy
|
DATA_DIR: /var/lib/vpn-proxy
|
||||||
SING_BOX_CONFIG: /var/lib/vpn-proxy/sing-box-config.json
|
SING_BOX_CONFIG: /var/lib/vpn-proxy/sing-box-config.json
|
||||||
SING_BOX_CACHE: /var/lib/sing-box/cache.db
|
SING_BOX_CACHE: /var/lib/sing-box/cache.db
|
||||||
|
SING_BOX_TRAFFIC_SOURCE: ${SING_BOX_TRAFFIC_SOURCE:-native}
|
||||||
|
SING_BOX_API_SECRET: /var/lib/sing-box/api.secret
|
||||||
|
SING_BOX_RUNTIME_CONFIG: /var/lib/sing-box/runtime-config.json
|
||||||
DATAPLANE_SOCKET: /run/vpn-proxy/dataplane.sock
|
DATAPLANE_SOCKET: /run/vpn-proxy/dataplane.sock
|
||||||
volumes:
|
volumes:
|
||||||
- vpn-proxy-data:/var/lib/vpn-proxy
|
- vpn-proxy-data:/var/lib/vpn-proxy
|
||||||
@@ -49,6 +52,7 @@ services:
|
|||||||
DATA_DIR: /var/lib/vpn-proxy
|
DATA_DIR: /var/lib/vpn-proxy
|
||||||
SING_BOX_CONFIG: /var/lib/vpn-proxy/sing-box-config.json
|
SING_BOX_CONFIG: /var/lib/vpn-proxy/sing-box-config.json
|
||||||
SING_BOX_CACHE: /var/lib/sing-box/cache.db
|
SING_BOX_CACHE: /var/lib/sing-box/cache.db
|
||||||
|
SING_BOX_TRAFFIC_SOURCE: ${SING_BOX_TRAFFIC_SOURCE:-native}
|
||||||
DATAPLANE_SOCKET: /run/vpn-proxy/dataplane.sock
|
DATAPLANE_SOCKET: /run/vpn-proxy/dataplane.sock
|
||||||
ports:
|
ports:
|
||||||
- "${PORT:-3456}:${PORT:-3456}"
|
- "${PORT:-3456}:${PORT:-3456}"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# Harbor application state v1
|
# Harbor application state v1
|
||||||
|
|
||||||
`GET /api/state` is the canonical Harbor domain snapshot. Successful mutations return the same snapshot as `state`. The persisted owner is `state.json` schema v8; React keeps only drafts, disclosure, focus, animation and transport freshness.
|
`GET /api/state` is the canonical Harbor domain snapshot. Successful mutations return the same snapshot as `state`. The persisted owner is the `state` JSON document in `harbor.sqlite` (currently document schema v10). React keeps only drafts, disclosure, focus, animation and transport freshness.
|
||||||
|
|
||||||
An abbreviated snapshot:
|
An abbreviated snapshot:
|
||||||
|
|
||||||
@@ -107,7 +107,7 @@ Enabling failover while VPN is stopped validates a temporary dual-channel candid
|
|||||||
|
|
||||||
The dual config keeps one stable inbound and a sing-box selector with `interrupt_exist_connections: false`. A switch changes the outbound for new connections only. Before an automatic switch, the existing `/connections` observer measures VPN byte deltas over a bounded 10-second window. Active or unknown traffic blocks the switch; the public snapshot contains only aggregate speed, connection count and at most three safe device/service labels.
|
The dual config keeps one stable inbound and a sing-box selector with `interrupt_exist_connections: false`. A switch changes the outbound for new connections only. Before an automatic switch, the existing `/connections` observer measures VPN byte deltas over a bounded 10-second window. Active or unknown traffic blocks the switch; the public snapshot contains only aggregate speed, connection count and at most three safe device/service labels.
|
||||||
|
|
||||||
Failover mutations use `PUT /api/failover`, `POST /api/failover/pause` and `POST /api/failover/switch`. Important user events are stored separately in `activity-journal.json` and read through `GET /api/activity-journal`. The journal is not a second state owner, contains no provider URLs or raw diagnostics, uses stable ID cursors and prunes entries after 30 days.
|
Failover mutations use `PUT /api/failover`, `POST /api/failover/pause` and `POST /api/failover/switch`. Important user events are separate rows in the `harbor.sqlite` journal table and read through `GET /api/activity-journal`. The journal is not a second state owner, contains no provider URLs or raw diagnostics, uses stable ID cursors and retains at most 30 days and 10,000 entries.
|
||||||
|
|
||||||
## Compatibility and migration
|
## Compatibility and migration
|
||||||
|
|
||||||
@@ -115,6 +115,16 @@ Schema v5 migrates the legacy singleton and `subscription-cache.json` into one p
|
|||||||
|
|
||||||
Schema v6 adds the routing-rule outbound. Rules read from schemas v0-v5 migrate to `outbound: "direct"` in their existing order and both desired/applied arrays are normalized together. A schema-v6 rule without a valid outbound is rejected rather than silently rewritten. Schema v7 adds canonical connectivity-diagnostics settings. Schema v8 adds a disabled failover policy, empty runtime history and no applied dual config, so upgrading does not start monitoring or change traffic.
|
Schema v6 adds the routing-rule outbound. Rules read from schemas v0-v5 migrate to `outbound: "direct"` in their existing order and both desired/applied arrays are normalized together. A schema-v6 rule without a valid outbound is rejected rather than silently rewritten. Schema v7 adds canonical connectivity-diagnostics settings. Schema v8 adds a disabled failover policy, empty runtime history and no applied dual config, so upgrading does not start monitoring or change traffic.
|
||||||
|
|
||||||
Migration atomically backs up the previous `state.json`. After the embedded profile is committed, Harbor also backs up and removes the legacy subscription cache so there is one persisted owner. Invalid legacy cache/config returns to a truthful stopped first-run state instead of starting stale generated config.
|
The initial SQLite migration imports settings, devices and journal in one transaction, including a legacy subscription cache when needed. Original JSON files remain unchanged as transition-time backups, with no parallel writes. Invalid input or a conflicting cache owner aborts migration without replacing state or starting stale configuration. Subsequent starts use only SQLite. Current document normalizers also preserve traffic-display settings and the device inventory's tag catalogue.
|
||||||
|
|
||||||
The old HTTP projection remains bounded for one release. Schema v8 persistence is not downgrade-compatible: stop Harbor and restore the `state.json.backup-v<fromVersion>-*` matching the rollback binary instead of deploying old code over v8 data. Rolling back before profiles still also requires the matching legacy subscription-cache backup.
|
The existing HTTP compatibility projection is unchanged. A pre-SQLite binary cannot read current persistence: restore a complete compatible backup or explicitly export current data before downgrading. Old JSON files do not contain post-migration changes. See [state recovery](../recovery/state-recovery.md).
|
||||||
|
|
||||||
|
## Local traffic history
|
||||||
|
|
||||||
|
`GET /api/traffic/history` reads only the local collector's `traffic.sqlite`, through a worker and, on a split Gateway, the existing control/dataplane socket. It accepts `range=24h|7d|30d|90d`, `level=service|domain|hostname|ip`, parent filters `service/domain/hostname`, `originId`, `route=all|vpn|direct|other`, `search`, `offset` and an optional `until` timestamp in milliseconds. Pages contain at most 100 groups. Bytes are decimal strings, preserving integers above JavaScript's safe-number range.
|
||||||
|
|
||||||
|
The response reports the requested/effective period, first available observation, minute/hour boundary, current collector state, gap count and partial coverage. `query.until` is the effective end of a complete bucket; use it for matching drilldown and pagination. Current history can lag by one minute. Data older than 7 days is hourly; retention and rollup can change available granularity between requests.
|
||||||
|
|
||||||
|
History starts with the new collector, not with previously accumulated device counters. Full observed hostnames and IPs remain distinct; service grouping is a local presentation classification, not proof of ownership of an IP. Unknown domains remain unknown. A history storage error reports unavailable/partial data without stopping VPN or exported metrics.
|
||||||
|
|
||||||
|
Prometheus is optional, independent and contains only exported metrics—not a copy of this database. There is no synchronization, automatic UI fallback, scrape backfill or restoration from Prometheus. Local cleanup does not delete external history or change its retention.
|
||||||
|
|||||||
@@ -1,64 +1,39 @@
|
|||||||
# Harbor state recovery
|
# Harbor state recovery
|
||||||
|
|
||||||
Harbor keeps the existing data directory and `state.json` path. The current persisted format is `schemaVersion: 8`: schema v2 introduced local route rules, v3 added rule enabled state, v4 added stable server IDs, v5 embeds the canonical `profiles[]` collection with desired/applied profile identity, v6 adds an explicit `vpn` or `direct` outbound to every route rule, v7 stores connectivity-diagnostics settings, and v8 adds Gateway failover state.
|
## Storage owners
|
||||||
|
|
||||||
## Atomic writes
|
Harbor uses the existing data directory. `harbor.sqlite` is the only working owner of settings, profiles, subscriptions, device inventory and accumulated device counters. Settings and devices are versioned JSON documents inside SQLite (currently state schema 10 and inventory schema 3); the journal is a separate indexed table with the existing 30-day/10,000-event limit.
|
||||||
|
|
||||||
Persistent files are written to a unique temporary file in the same directory, flushed with `fsync`, closed and atomically renamed over the target. A failure before rename leaves the previous target untouched and removes the temporary file.
|
`traffic.sqlite` is separate, replaceable working history: 90 days, completed minute buckets for the latest 7 days and hourly buckets before that. Removing history does not reset settings or exported counters. Prometheus is optional and independently retains only the metrics it scrapes; it cannot restore this database.
|
||||||
|
|
||||||
Profile/server switching prepares candidate config and runtime before the final state publication. If any later step fails, Harbor restores the previous config, runtime and canonical state.
|
Secrets, hardware identity, generated configuration and sing-box's own cache remain files.
|
||||||
|
|
||||||
## Migration to profiles
|
## Atomic writes and migration
|
||||||
|
|
||||||
On startup, a legacy state is normalized before the process starts. Harbor creates one profile named `Основной`, moves the provider URL/config and metadata into it, and preserves unambiguous desired/applied server identity. A legacy state explicitly marked stopped clears stale applied residue.
|
SQLite uses WAL, FULL synchronous commits and a five-second busy timeout. A document mutation runs its read and write in one transaction. Journal append/deduplication/pruning is transactional. Profile/server switching still prepares candidate configuration and runtime before publishing the canonical state.
|
||||||
|
|
||||||
Before replacing state Harbor saves the original beside it:
|
Before the first successful SQLite startup, Harbor imports `state.json`, `devices.json`, `activity-journal.json` and, when required by a pre-profile schema, `subscription-cache.json` in one transaction. Existing normalizers preserve revision, stable IDs, ordered rules and decimal-string counters. A null optional subscription cache is valid.
|
||||||
|
|
||||||
```text
|
The import marker commits with all imported records. An unsupported version, invalid input or conflicting cache owner aborts the entire import. Harbor does not erase settings, rename damaged originals, start stale configuration or silently return to first-run. Correct the reported original and retry only after making a backup.
|
||||||
state.json.backup-v4-2026-08-11T12-00-00-000Z
|
|
||||||
```
|
|
||||||
|
|
||||||
After a valid profile has been committed, the raw legacy cache is saved and removed as a second owner:
|
After a successful import, the original JSON files remain unchanged under their original names as transition-time backups. They are never read or written as current state again. Changing them does not change Harbor. A corrupt or unsupported SQLite database does not fall back to those stale JSON files.
|
||||||
|
|
||||||
```text
|
## Backup and recovery
|
||||||
subscription-cache.json.backup-v1-2026-08-11T12-00-00-000Z
|
|
||||||
```
|
|
||||||
|
|
||||||
An invalid legacy provider config is backed up but not started. Harbor removes stale generated config and returns to a stopped first-run state.
|
Stop both control and collector processes before offline recovery. On a Gateway this means the control and dataplane components; stop the Mac backend for Mac recovery.
|
||||||
|
|
||||||
## Migration to ordered VPN/Direct rules
|
1. Preserve the whole data directory, including any `-wal` and `-shm` files, before changing anything.
|
||||||
|
2. Restore a matching backup of `harbor.sqlite` and any necessary secret/config files. Do not mix a database with another backup's WAL.
|
||||||
|
3. Start the same compatible release and inspect `GET /api/state` before applying a profile.
|
||||||
|
|
||||||
When schemas v0-v5 are read, Harbor preserves the order of `routeRules` and `appliedRouteRules` and adds `outbound: "direct"` to legacy entries before atomically committing schema v6. The original file is preserved using its actual source version, for example:
|
For online backups use SQLite's backup API; copying only a live `.sqlite` file can omit committed WAL data. Offline copies after a clean stop are simpler.
|
||||||
|
|
||||||
```text
|
To discard only working traffic history, stop the collector and move its `traffic.sqlite` plus any associated `traffic.sqlite-wal` and `traffic.sqlite-shm` aside together. Leave `harbor.sqlite` untouched. A new collector database starts a new coverage period; there is no automatic Prometheus backfill. Do not unlink an open database.
|
||||||
state.json.backup-v5-2026-08-17T12-00-00-000Z
|
|
||||||
```
|
|
||||||
|
|
||||||
After migration, malformed schema-v6 rules are rejected; Harbor does not reinterpret a missing or unknown outbound as direct.
|
Deletion/retention makes pages reusable; it does not necessarily shrink the physical file immediately. Traffic retention and compaction run in the worker, outside connection processing.
|
||||||
|
|
||||||
## Migration to failover
|
## Downgrade
|
||||||
|
|
||||||
Schemas v0-v7 migrate to v8 with failover disabled, empty switch history and no applied dual config. Migration does not start probes, enable traffic accounting or change the single-channel runtime. The original state is preserved as `state.json.backup-v<fromVersion>-*` before the atomic replacement.
|
A pre-SQLite binary ignores `harbor.sqlite`. Merely starting it would revive old JSON settings and lose all changes since the cutover. Automatic downgrade is unsupported.
|
||||||
|
|
||||||
The separate `activity-journal.json` is created on the first important event. It uses the same atomic write and corrupt-file isolation mechanism as state, retains at most 30 days, and can be removed while Harbor is stopped without affecting subscriptions, routing or VPN startup.
|
Either restore a complete pre-upgrade backup deliberately, accepting the loss of subsequent changes, or first export current state into the exact schema required by the older binary. No automatic export/downgrade tool is provided. Preserve the SQLite backup in either case; do not overwrite current state with stale JSON as a recovery shortcut.
|
||||||
|
|
||||||
## Corrupt JSON
|
|
||||||
|
|
||||||
If `state.json` cannot be parsed, Harbor renames the exact damaged bytes to:
|
|
||||||
|
|
||||||
```text
|
|
||||||
state.json.corrupt-2026-08-11T12-00-00-000Z
|
|
||||||
```
|
|
||||||
|
|
||||||
It then creates a valid empty current-schema state and reports storage recovery. A corrupt legacy subscription cache is preserved with the same suffix and is never used to start stale config.
|
|
||||||
|
|
||||||
## Manual recovery and downgrade
|
|
||||||
|
|
||||||
Perform recovery while Harbor is stopped:
|
|
||||||
|
|
||||||
1. Copy the whole data directory.
|
|
||||||
2. Inspect the intended backup with `jq . <backup-file>`.
|
|
||||||
3. Restore only matching state/cache backups to their original filenames.
|
|
||||||
4. Start Harbor and verify `GET /api/state` before applying a profile.
|
|
||||||
|
|
||||||
A pre-v8 binary cannot interpret failover state. Restore `state.json.backup-v<fromVersion>-*` matching the rollback binary; deploying old code over schema v8 is not safe. A rollback to pre-v5 additionally requires the matching state and subscription-cache backups because that binary cannot interpret canonical profiles.
|
|
||||||
|
|||||||
@@ -196,14 +196,14 @@
|
|||||||
"type": "prometheus",
|
"type": "prometheus",
|
||||||
"uid": "${DS_PROMETHEUS}"
|
"uid": "${DS_PROMETHEUS}"
|
||||||
},
|
},
|
||||||
"description": "Наблюдённые sing-box байты для выбранных устройств по фактически выбранному выходу. Direct IPv4 мимо sing-box учитывается отдельно в технической детализации.",
|
"description": "Фактический выход выбранных устройств за период. VPN включает трафик, вошедший через Gateway и явно настроенный Proxy. Direct приблизительно объединяет логические байты sing-box и IPv4 L3 packet bytes Linux; точная разбивка остаётся в технической детализации.",
|
||||||
"fieldConfig": {
|
"fieldConfig": {
|
||||||
"defaults": {
|
"defaults": {
|
||||||
"color": {
|
"color": {
|
||||||
"mode": "palette-classic"
|
"mode": "palette-classic"
|
||||||
},
|
},
|
||||||
"decimals": 1,
|
"decimals": 1,
|
||||||
"noValue": "Нет трафика",
|
"noValue": "Нет данных",
|
||||||
"unit": "bytes"
|
"unit": "bytes"
|
||||||
},
|
},
|
||||||
"overrides": [
|
"overrides": [
|
||||||
@@ -290,7 +290,7 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"editorMode": "code",
|
"editorMode": "code",
|
||||||
"expr": "sum(increase(harbor_device_singbox_tracked_bytes_total{device_id=~\"$device_id\", outbound=\"direct\"}[$__range])) > 0",
|
"expr": "((sum(increase(harbor_device_singbox_tracked_bytes_total{device_id=~\"$device_id\", outbound=\"direct\"}[$__range])) or vector(0)) + (sum(increase(harbor_device_direct_ipv4_packet_bytes_total{device_id=~\"$device_id\"}[$__range])) or vector(0))) > 0",
|
||||||
"instant": true,
|
"instant": true,
|
||||||
"legendFormat": "Direct",
|
"legendFormat": "Direct",
|
||||||
"range": false,
|
"range": false,
|
||||||
@@ -382,7 +382,73 @@
|
|||||||
"type": "prometheus",
|
"type": "prometheus",
|
||||||
"uid": "${DS_PROMETHEUS}"
|
"uid": "${DS_PROMETHEUS}"
|
||||||
},
|
},
|
||||||
"description": "Скорость трафика выбранных устройств. Окно усреднения адаптируется к масштабу времени; скачивание показано вверх, отправка — вниз. Это наблюдаемый Harbor трафик, а не скорость интернет-канала.",
|
"description": "Последняя применённая policy устройства по данным Prometheus: Direct или VPN. Это не маршрут каждого байта: устройство в Direct может одновременно использовать VPN через явно настроенный Harbor Proxy.",
|
||||||
|
"fieldConfig": {
|
||||||
|
"defaults": {
|
||||||
|
"color": {
|
||||||
|
"mode": "thresholds"
|
||||||
|
},
|
||||||
|
"mappings": [
|
||||||
|
{
|
||||||
|
"options": {
|
||||||
|
"0": {
|
||||||
|
"color": "orange",
|
||||||
|
"index": 0,
|
||||||
|
"text": "Direct"
|
||||||
|
},
|
||||||
|
"1": {
|
||||||
|
"color": "green",
|
||||||
|
"index": 1,
|
||||||
|
"text": "VPN"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"type": "value"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"noValue": "Нет данных"
|
||||||
|
},
|
||||||
|
"overrides": []
|
||||||
|
},
|
||||||
|
"gridPos": {
|
||||||
|
"h": 4,
|
||||||
|
"w": 16,
|
||||||
|
"x": 0,
|
||||||
|
"y": 5
|
||||||
|
},
|
||||||
|
"id": 17,
|
||||||
|
"options": {
|
||||||
|
"alignValue": "left",
|
||||||
|
"legend": {
|
||||||
|
"displayMode": "list",
|
||||||
|
"placement": "bottom",
|
||||||
|
"showLegend": false
|
||||||
|
},
|
||||||
|
"mergeValues": true,
|
||||||
|
"rowHeight": 0.9,
|
||||||
|
"showValue": "always",
|
||||||
|
"tooltip": {
|
||||||
|
"mode": "single",
|
||||||
|
"sort": "none"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"targets": [
|
||||||
|
{
|
||||||
|
"editorMode": "code",
|
||||||
|
"expr": "max by (device_id) (harbor_device_applied_policy{device_id=~\"$device_id\"}) * on (device_id) group_left (name, ip) max by (device_id, name, ip) (harbor_device_info)",
|
||||||
|
"legendFormat": "{{name}} · {{ip}}",
|
||||||
|
"range": true,
|
||||||
|
"refId": "A"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"title": "Применённый режим",
|
||||||
|
"type": "state-timeline"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"datasource": {
|
||||||
|
"type": "prometheus",
|
||||||
|
"uid": "${DS_PROMETHEUS}"
|
||||||
|
},
|
||||||
|
"description": "Фактическая скорость по выходу выбранных устройств. VPN суммирует Gateway и Proxy. Direct приблизительно объединяет непересекающиеся sing-box и Linux IPv4 пути с разной byte semantics. Скачивание показано вверх, отправка вниз.",
|
||||||
"fieldConfig": {
|
"fieldConfig": {
|
||||||
"defaults": {
|
"defaults": {
|
||||||
"color": {
|
"color": {
|
||||||
@@ -414,14 +480,14 @@
|
|||||||
"overrides": [
|
"overrides": [
|
||||||
{
|
{
|
||||||
"matcher": {
|
"matcher": {
|
||||||
"id": "byName",
|
"id": "byRegexp",
|
||||||
"options": "Скачивание"
|
"options": "^VPN"
|
||||||
},
|
},
|
||||||
"properties": [
|
"properties": [
|
||||||
{
|
{
|
||||||
"id": "color",
|
"id": "color",
|
||||||
"value": {
|
"value": {
|
||||||
"fixedColor": "blue",
|
"fixedColor": "green",
|
||||||
"mode": "fixed"
|
"mode": "fixed"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -429,8 +495,8 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"matcher": {
|
"matcher": {
|
||||||
"id": "byName",
|
"id": "byRegexp",
|
||||||
"options": "Отправка"
|
"options": "^Direct"
|
||||||
},
|
},
|
||||||
"properties": [
|
"properties": [
|
||||||
{
|
{
|
||||||
@@ -442,6 +508,21 @@
|
|||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"matcher": {
|
||||||
|
"id": "byRegexp",
|
||||||
|
"options": "^Не определено"
|
||||||
|
},
|
||||||
|
"properties": [
|
||||||
|
{
|
||||||
|
"id": "color",
|
||||||
|
"value": {
|
||||||
|
"fixedColor": "gray",
|
||||||
|
"mode": "fixed"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"matcher": {
|
"matcher": {
|
||||||
"id": "byFrameRefID",
|
"id": "byFrameRefID",
|
||||||
@@ -453,6 +534,30 @@
|
|||||||
"value": "negative-Y"
|
"value": "negative-Y"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"matcher": {
|
||||||
|
"id": "byFrameRefID",
|
||||||
|
"options": "D"
|
||||||
|
},
|
||||||
|
"properties": [
|
||||||
|
{
|
||||||
|
"id": "custom.transform",
|
||||||
|
"value": "negative-Y"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"matcher": {
|
||||||
|
"id": "byFrameRefID",
|
||||||
|
"options": "F"
|
||||||
|
},
|
||||||
|
"properties": [
|
||||||
|
{
|
||||||
|
"id": "custom.transform",
|
||||||
|
"value": "negative-Y"
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
@@ -460,7 +565,7 @@
|
|||||||
"h": 12,
|
"h": 12,
|
||||||
"w": 16,
|
"w": 16,
|
||||||
"x": 0,
|
"x": 0,
|
||||||
"y": 5
|
"y": 9
|
||||||
},
|
},
|
||||||
"id": 3,
|
"id": 3,
|
||||||
"options": {
|
"options": {
|
||||||
@@ -482,20 +587,48 @@
|
|||||||
"targets": [
|
"targets": [
|
||||||
{
|
{
|
||||||
"editorMode": "code",
|
"editorMode": "code",
|
||||||
"expr": "sum(rate(harbor_device_traffic_bytes_total{device_id=~\"$device_id\", direction=\"download\"}[$__rate_interval])) > 0",
|
"expr": "sum(rate(harbor_device_singbox_tracked_bytes_total{device_id=~\"$device_id\", outbound=\"vpn\", direction=\"download\"}[$__rate_interval])) > 0",
|
||||||
"legendFormat": "Скачивание",
|
"legendFormat": "VPN · скачивание",
|
||||||
"range": true,
|
"range": true,
|
||||||
"refId": "A"
|
"refId": "A"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"editorMode": "code",
|
"editorMode": "code",
|
||||||
"expr": "sum(rate(harbor_device_traffic_bytes_total{device_id=~\"$device_id\", direction=\"upload\"}[$__rate_interval])) > 0",
|
"expr": "sum(rate(harbor_device_singbox_tracked_bytes_total{device_id=~\"$device_id\", outbound=\"vpn\", direction=\"upload\"}[$__rate_interval])) > 0",
|
||||||
"legendFormat": "Отправка",
|
"legendFormat": "VPN · отправка",
|
||||||
"range": true,
|
"range": true,
|
||||||
"refId": "B"
|
"refId": "B"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"editorMode": "code",
|
||||||
|
"expr": "((sum(rate(harbor_device_singbox_tracked_bytes_total{device_id=~\"$device_id\", outbound=\"direct\", direction=\"download\"}[$__rate_interval])) or vector(0)) + (sum(rate(harbor_device_direct_ipv4_packet_bytes_total{device_id=~\"$device_id\", direction=\"download\"}[$__rate_interval])) or vector(0))) > 0",
|
||||||
|
"legendFormat": "Direct · скачивание",
|
||||||
|
"range": true,
|
||||||
|
"refId": "C"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"editorMode": "code",
|
||||||
|
"expr": "((sum(rate(harbor_device_singbox_tracked_bytes_total{device_id=~\"$device_id\", outbound=\"direct\", direction=\"upload\"}[$__rate_interval])) or vector(0)) + (sum(rate(harbor_device_direct_ipv4_packet_bytes_total{device_id=~\"$device_id\", direction=\"upload\"}[$__rate_interval])) or vector(0))) > 0",
|
||||||
|
"legendFormat": "Direct · отправка",
|
||||||
|
"range": true,
|
||||||
|
"refId": "D"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"editorMode": "code",
|
||||||
|
"expr": "sum(rate(harbor_device_singbox_tracked_bytes_total{device_id=~\"$device_id\", outbound=\"unknown\", direction=\"download\"}[$__rate_interval])) > 0",
|
||||||
|
"legendFormat": "Не определено · скачивание",
|
||||||
|
"range": true,
|
||||||
|
"refId": "E"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"editorMode": "code",
|
||||||
|
"expr": "sum(rate(harbor_device_singbox_tracked_bytes_total{device_id=~\"$device_id\", outbound=\"unknown\", direction=\"upload\"}[$__rate_interval])) > 0",
|
||||||
|
"legendFormat": "Не определено · отправка",
|
||||||
|
"range": true,
|
||||||
|
"refId": "F"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"title": "Скорость трафика",
|
"title": "Фактический VPN / Direct",
|
||||||
"type": "timeseries"
|
"type": "timeseries"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -582,7 +715,7 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"gridPos": {
|
"gridPos": {
|
||||||
"h": 12,
|
"h": 16,
|
||||||
"w": 8,
|
"w": 8,
|
||||||
"x": 16,
|
"x": 16,
|
||||||
"y": 5
|
"y": 5
|
||||||
@@ -648,7 +781,7 @@
|
|||||||
"h": 1,
|
"h": 1,
|
||||||
"w": 24,
|
"w": 24,
|
||||||
"x": 0,
|
"x": 0,
|
||||||
"y": 17
|
"y": 21
|
||||||
},
|
},
|
||||||
"id": 12,
|
"id": 12,
|
||||||
"panels": [],
|
"panels": [],
|
||||||
@@ -676,7 +809,7 @@
|
|||||||
"h": 15,
|
"h": 15,
|
||||||
"w": 9,
|
"w": 9,
|
||||||
"x": 0,
|
"x": 0,
|
||||||
"y": 18
|
"y": 22
|
||||||
},
|
},
|
||||||
"id": 8,
|
"id": 8,
|
||||||
"options": {
|
"options": {
|
||||||
@@ -775,7 +908,7 @@
|
|||||||
"h": 15,
|
"h": 15,
|
||||||
"w": 15,
|
"w": 15,
|
||||||
"x": 9,
|
"x": 9,
|
||||||
"y": 18
|
"y": 22
|
||||||
},
|
},
|
||||||
"id": 9,
|
"id": 9,
|
||||||
"options": {
|
"options": {
|
||||||
@@ -837,7 +970,7 @@
|
|||||||
"h": 1,
|
"h": 1,
|
||||||
"w": 24,
|
"w": 24,
|
||||||
"x": 0,
|
"x": 0,
|
||||||
"y": 33
|
"y": 37
|
||||||
},
|
},
|
||||||
"id": 13,
|
"id": 13,
|
||||||
"panels": [
|
"panels": [
|
||||||
@@ -846,7 +979,7 @@
|
|||||||
"type": "prometheus",
|
"type": "prometheus",
|
||||||
"uid": "${DS_PROMETHEUS}"
|
"uid": "${DS_PROMETHEUS}"
|
||||||
},
|
},
|
||||||
"description": "Где выбранный трафик вошёл в Harbor: через Gateway или Proxy. Это техническая точка входа, а не выбор VPN / Direct.",
|
"description": "Как sing-box увидел трафик выбранных устройств: точка входа gateway или proxy и фактически выбранный outbound. Например, компьютер с policy Direct и браузерным Proxy появляется как proxy · vpn.",
|
||||||
"fieldConfig": {
|
"fieldConfig": {
|
||||||
"defaults": {
|
"defaults": {
|
||||||
"color": {
|
"color": {
|
||||||
@@ -856,44 +989,13 @@
|
|||||||
"noValue": "Нет данных",
|
"noValue": "Нет данных",
|
||||||
"unit": "bytes"
|
"unit": "bytes"
|
||||||
},
|
},
|
||||||
"overrides": [
|
"overrides": []
|
||||||
{
|
|
||||||
"matcher": {
|
|
||||||
"id": "byName",
|
|
||||||
"options": "gateway"
|
|
||||||
},
|
|
||||||
"properties": [
|
|
||||||
{
|
|
||||||
"id": "color",
|
|
||||||
"value": {
|
|
||||||
"fixedColor": "blue",
|
|
||||||
"mode": "fixed"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"matcher": {
|
|
||||||
"id": "byName",
|
|
||||||
"options": "proxy"
|
|
||||||
},
|
|
||||||
"properties": [
|
|
||||||
{
|
|
||||||
"id": "color",
|
|
||||||
"value": {
|
|
||||||
"fixedColor": "purple",
|
|
||||||
"mode": "fixed"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
},
|
||||||
"gridPos": {
|
"gridPos": {
|
||||||
"h": 7,
|
"h": 7,
|
||||||
"w": 12,
|
"w": 12,
|
||||||
"x": 0,
|
"x": 0,
|
||||||
"y": 34
|
"y": 38
|
||||||
},
|
},
|
||||||
"id": 4,
|
"id": 4,
|
||||||
"options": {
|
"options": {
|
||||||
@@ -917,14 +1019,14 @@
|
|||||||
"targets": [
|
"targets": [
|
||||||
{
|
{
|
||||||
"editorMode": "code",
|
"editorMode": "code",
|
||||||
"expr": "sum by (source) (increase(harbor_device_traffic_bytes_total{device_id=~\"$device_id\"}[$__range])) > 0",
|
"expr": "sum by (source, outbound) (increase(harbor_device_singbox_tracked_bytes_total{device_id=~\"$device_id\"}[$__range])) > 0",
|
||||||
"instant": true,
|
"instant": true,
|
||||||
"legendFormat": "{{source}}",
|
"legendFormat": "{{source}} · {{outbound}}",
|
||||||
"range": false,
|
"range": false,
|
||||||
"refId": "A"
|
"refId": "A"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"title": "Точки входа Gateway / Proxy",
|
"title": "Вход × выход sing-box",
|
||||||
"type": "bargauge"
|
"type": "bargauge"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -932,7 +1034,7 @@
|
|||||||
"type": "prometheus",
|
"type": "prometheus",
|
||||||
"uid": "${DS_PROMETHEUS}"
|
"uid": "${DS_PROMETHEUS}"
|
||||||
},
|
},
|
||||||
"description": "IPv4-пакеты выбранных устройств, которые Gateway направил напрямую вместо sing-box: policy Direct или работа при остановленном VPN runtime. Это L3 packet bytes с заголовками и retransmit; их нельзя складывать с sing-box tracker bytes.",
|
"description": "Два непересекающихся Direct-пути выбранных устройств. Через sing-box показаны логические TCP/UDP bytes; Linux мимо sing-box показывает IPv4 L3 packet bytes с заголовками и retransmit. Их сумма в основных панелях является только приблизительной оценкой.",
|
||||||
"fieldConfig": {
|
"fieldConfig": {
|
||||||
"defaults": {
|
"defaults": {
|
||||||
"color": {
|
"color": {
|
||||||
@@ -942,13 +1044,44 @@
|
|||||||
"noValue": "Нет данных",
|
"noValue": "Нет данных",
|
||||||
"unit": "bytes"
|
"unit": "bytes"
|
||||||
},
|
},
|
||||||
"overrides": []
|
"overrides": [
|
||||||
|
{
|
||||||
|
"matcher": {
|
||||||
|
"id": "byFrameRefID",
|
||||||
|
"options": "A"
|
||||||
|
},
|
||||||
|
"properties": [
|
||||||
|
{
|
||||||
|
"id": "color",
|
||||||
|
"value": {
|
||||||
|
"fixedColor": "orange",
|
||||||
|
"mode": "fixed"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"matcher": {
|
||||||
|
"id": "byFrameRefID",
|
||||||
|
"options": "B"
|
||||||
|
},
|
||||||
|
"properties": [
|
||||||
|
{
|
||||||
|
"id": "color",
|
||||||
|
"value": {
|
||||||
|
"fixedColor": "purple",
|
||||||
|
"mode": "fixed"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
},
|
},
|
||||||
"gridPos": {
|
"gridPos": {
|
||||||
"h": 7,
|
"h": 7,
|
||||||
"w": 12,
|
"w": 12,
|
||||||
"x": 12,
|
"x": 12,
|
||||||
"y": 34
|
"y": 38
|
||||||
},
|
},
|
||||||
"id": 16,
|
"id": 16,
|
||||||
"options": {
|
"options": {
|
||||||
@@ -972,14 +1105,22 @@
|
|||||||
"targets": [
|
"targets": [
|
||||||
{
|
{
|
||||||
"editorMode": "code",
|
"editorMode": "code",
|
||||||
"expr": "sum by (direction) (increase(harbor_device_direct_ipv4_packet_bytes_total{device_id=~\"$device_id\"}[$__range])) > 0",
|
"expr": "sum(increase(harbor_device_singbox_tracked_bytes_total{device_id=~\"$device_id\", outbound=\"direct\"}[$__range])) > 0",
|
||||||
"instant": true,
|
"instant": true,
|
||||||
"legendFormat": "{{direction}}",
|
"legendFormat": "Через sing-box",
|
||||||
"range": false,
|
"range": false,
|
||||||
"refId": "A"
|
"refId": "A"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"editorMode": "code",
|
||||||
|
"expr": "sum(increase(harbor_device_direct_ipv4_packet_bytes_total{device_id=~\"$device_id\"}[$__range])) > 0",
|
||||||
|
"instant": true,
|
||||||
|
"legendFormat": "Linux мимо sing-box",
|
||||||
|
"range": false,
|
||||||
|
"refId": "B"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"title": "Direct IPv4 мимо sing-box",
|
"title": "Direct раздельно",
|
||||||
"type": "bargauge"
|
"type": "bargauge"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
@@ -1008,7 +1149,7 @@
|
|||||||
"uid": "${DS_PROMETHEUS}"
|
"uid": "${DS_PROMETHEUS}"
|
||||||
},
|
},
|
||||||
"definition": "query_result(label_join(max by (device_id, name, ip) (harbor_device_info), \"display\", \" · \", \"name\", \"ip\"))",
|
"definition": "query_result(label_join(max by (device_id, name, ip) (harbor_device_info), \"display\", \" · \", \"name\", \"ip\"))",
|
||||||
"description": "Выберите все устройства или одно устройство. Фильтр управляет скоростью, общим трафиком, маршрутами, сервисами, доменами и технической детализацией.",
|
"description": "Выберите все устройства или одно устройство. Фильтр управляет применённым режимом, фактическим VPN/Direct, общим трафиком, сервисами, доменами и технической детализацией.",
|
||||||
"hide": 0,
|
"hide": 0,
|
||||||
"includeAll": true,
|
"includeAll": true,
|
||||||
"label": "Устройства",
|
"label": "Устройства",
|
||||||
@@ -1044,6 +1185,6 @@
|
|||||||
"timezone": "browser",
|
"timezone": "browser",
|
||||||
"title": "Harbor Gateway: трафик",
|
"title": "Harbor Gateway: трафик",
|
||||||
"uid": "harbor-gateway-traffic",
|
"uid": "harbor-gateway-traffic",
|
||||||
"version": 9,
|
"version": 10,
|
||||||
"weekStart": "monday"
|
"weekStart": "monday"
|
||||||
}
|
}
|
||||||
|
|||||||
Generated
+271
-27
@@ -8,21 +8,29 @@
|
|||||||
"name": "vpn-proxy-gateway",
|
"name": "vpn-proxy-gateway",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@bufbuild/protobuf": "2.6.0",
|
||||||
|
"@connectrpc/connect": "2.0.3",
|
||||||
|
"@connectrpc/connect-node": "2.0.3",
|
||||||
"@vitejs/plugin-react": "^5.0.0",
|
"@vitejs/plugin-react": "^5.0.0",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"react-dom": "^19.0.0",
|
"react-dom": "^19.0.0",
|
||||||
|
"tldts": "7.4.12",
|
||||||
"vite": "^7.0.0"
|
"vite": "^7.0.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@babel/parser": "7.29.3",
|
"@babel/parser": "7.29.3",
|
||||||
|
"@bufbuild/buf": "1.47.2",
|
||||||
|
"@bufbuild/protoc-gen-es": "2.6.0",
|
||||||
"@csstools/selector-specificity": "6.0.0",
|
"@csstools/selector-specificity": "6.0.0",
|
||||||
"@types/node": "22.19.17",
|
"@types/node": "24.13.4",
|
||||||
"@types/node18": "npm:@types/node@18.19.130",
|
|
||||||
"@types/react": "^19.2.18",
|
"@types/react": "^19.2.18",
|
||||||
"@types/react-dom": "^19.2.4",
|
"@types/react-dom": "^19.2.4",
|
||||||
"postcss": "8.5.14",
|
"postcss": "8.5.14",
|
||||||
"postcss-selector-parser": "7.1.4",
|
"postcss-selector-parser": "7.1.4",
|
||||||
"typescript": "7.0.2"
|
"typescript": "7.0.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "24.21.x"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@babel/code-frame": {
|
"node_modules/@babel/code-frame": {
|
||||||
@@ -288,6 +296,229 @@
|
|||||||
"node": ">=6.9.0"
|
"node": ">=6.9.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@bufbuild/buf": {
|
||||||
|
"version": "1.47.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@bufbuild/buf/-/buf-1.47.2.tgz",
|
||||||
|
"integrity": "sha512-glY5kCAoO4+a7HvDb+BLOdoHSdCk4mdXdkp53H8JFz7maOnkxCiHHXgRX+taFyEu25N8ybn7NjZFrZSdRwq2sA==",
|
||||||
|
"dev": true,
|
||||||
|
"hasInstallScript": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"bin": {
|
||||||
|
"buf": "bin/buf",
|
||||||
|
"protoc-gen-buf-breaking": "bin/protoc-gen-buf-breaking",
|
||||||
|
"protoc-gen-buf-lint": "bin/protoc-gen-buf-lint"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@bufbuild/buf-darwin-arm64": "1.47.2",
|
||||||
|
"@bufbuild/buf-darwin-x64": "1.47.2",
|
||||||
|
"@bufbuild/buf-linux-aarch64": "1.47.2",
|
||||||
|
"@bufbuild/buf-linux-armv7": "1.47.2",
|
||||||
|
"@bufbuild/buf-linux-x64": "1.47.2",
|
||||||
|
"@bufbuild/buf-win32-arm64": "1.47.2",
|
||||||
|
"@bufbuild/buf-win32-x64": "1.47.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@bufbuild/buf-darwin-arm64": {
|
||||||
|
"version": "1.47.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@bufbuild/buf-darwin-arm64/-/buf-darwin-arm64-1.47.2.tgz",
|
||||||
|
"integrity": "sha512-74WerFn06y+azgVfsnzhfbI5wla/OLPDnIvaNJBWHaqya/3bfascJkDylW2GVNHmwG1K/cscpmcc/RJPaO7ntQ==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@bufbuild/buf-darwin-x64": {
|
||||||
|
"version": "1.47.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@bufbuild/buf-darwin-x64/-/buf-darwin-x64-1.47.2.tgz",
|
||||||
|
"integrity": "sha512-adAiOacOQe8Ym/YXPCEiq9mrPeKRmDtF2TgqPWTcDy6mF7TqR7hMJINkEEuMd1EeACmXnzMOnXlm9ICtvdYgPg==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@bufbuild/buf-linux-aarch64": {
|
||||||
|
"version": "1.47.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@bufbuild/buf-linux-aarch64/-/buf-linux-aarch64-1.47.2.tgz",
|
||||||
|
"integrity": "sha512-52vY+Owffr5diw2PyfQJqH+Fld6zW6NhNZak4zojvc2MjZKubWM0TfNyM9jXz2YrwyB+cyxkabE60nBI80m37w==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@bufbuild/buf-linux-armv7": {
|
||||||
|
"version": "1.47.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@bufbuild/buf-linux-armv7/-/buf-linux-armv7-1.47.2.tgz",
|
||||||
|
"integrity": "sha512-g9KtpObDeHZ/VG/0b5ZCieOao7L/WYZ0fPqFSs4N07D3APgEDhJG6vLyUcDgJMDgyLcgkNjNz0+XdYQb/tXyQw==",
|
||||||
|
"cpu": [
|
||||||
|
"arm"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@bufbuild/buf-linux-x64": {
|
||||||
|
"version": "1.47.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@bufbuild/buf-linux-x64/-/buf-linux-x64-1.47.2.tgz",
|
||||||
|
"integrity": "sha512-MODCK2BzD1Mgoyr+5Sp8xA8qMNdytj8hYheyhA5NnCGTkQf8sfqAjpBSAAmKk6Zar8HOlVXML6tzE/ioDFFGwQ==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@bufbuild/buf-win32-arm64": {
|
||||||
|
"version": "1.47.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@bufbuild/buf-win32-arm64/-/buf-win32-arm64-1.47.2.tgz",
|
||||||
|
"integrity": "sha512-563YKYWJl3LrCY3G3+zuhb8HwOs6DzWslwGPFkKV2hwHyWyvd1DR1JjiLvw9zX64IKNctQ0HempSqc3kcboaqQ==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@bufbuild/buf-win32-x64": {
|
||||||
|
"version": "1.47.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@bufbuild/buf-win32-x64/-/buf-win32-x64-1.47.2.tgz",
|
||||||
|
"integrity": "sha512-Sqcdv7La2xBDh3bTdEYb2f4UTMMqCcYe/D0RELhvQ5wDn6I35V3/2YT1OF5fRuf0BZLCo0OdO37S9L47uHSz2g==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@bufbuild/protobuf": {
|
||||||
|
"version": "2.6.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@bufbuild/protobuf/-/protobuf-2.6.0.tgz",
|
||||||
|
"integrity": "sha512-6cuonJVNOIL7lTj5zgo/Rc2bKAo4/GvN+rKCrUj7GdEHRzCk8zKOfFwUsL9nAVk5rSIsRmlgcpLzTRysopEeeg==",
|
||||||
|
"license": "(Apache-2.0 AND BSD-3-Clause)"
|
||||||
|
},
|
||||||
|
"node_modules/@bufbuild/protoc-gen-es": {
|
||||||
|
"version": "2.6.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@bufbuild/protoc-gen-es/-/protoc-gen-es-2.6.0.tgz",
|
||||||
|
"integrity": "sha512-sKvgGndyw1stawiDKMLZyilj1BzMuUTlvyrBiDnzxGIjCMK4hoE0DsVBiqCuTFqENnLmEGdy+huOZ5KgQAGlFA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"@bufbuild/protobuf": "^2.6.0",
|
||||||
|
"@bufbuild/protoplugin": "2.6.0"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"protoc-gen-es": "bin/protoc-gen-es"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=14"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@bufbuild/protobuf": "2.6.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@bufbuild/protobuf": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@bufbuild/protoplugin": {
|
||||||
|
"version": "2.6.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@bufbuild/protoplugin/-/protoplugin-2.6.0.tgz",
|
||||||
|
"integrity": "sha512-mfAwI+4GqUtbw/ddfyolEHaAL86ozRIVlOg2A+SVRbjx1CjsMc1YJO+hBSkt/pqfpR+PmWBbZLstHbXP8KGtMQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"@bufbuild/protobuf": "2.6.0",
|
||||||
|
"@typescript/vfs": "^1.5.2",
|
||||||
|
"typescript": "5.4.5"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@bufbuild/protoplugin/node_modules/typescript": {
|
||||||
|
"version": "5.4.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.4.5.tgz",
|
||||||
|
"integrity": "sha512-vcI4UpRgg81oIRUFwR0WSIHKt11nJ7SAVlYNIu+QpqeyXP+gpQJy/Z4+F0aGxSE4MqwjyXvW/TzgkLAx2AGHwQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"bin": {
|
||||||
|
"tsc": "bin/tsc",
|
||||||
|
"tsserver": "bin/tsserver"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=14.17"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@connectrpc/connect": {
|
||||||
|
"version": "2.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@connectrpc/connect/-/connect-2.0.3.tgz",
|
||||||
|
"integrity": "sha512-jAbVMHVtDCydGt2P20VpmLjbLtERqSV0RMSyQF3k2zhK8pzQ2QaCAcyVhufClqrOAFZUKL5BqVYtttaxvhmRgg==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"peerDependencies": {
|
||||||
|
"@bufbuild/protobuf": "^2.2.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@connectrpc/connect-node": {
|
||||||
|
"version": "2.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@connectrpc/connect-node/-/connect-node-2.0.3.tgz",
|
||||||
|
"integrity": "sha512-GZ8WXBCeoZY31wzmnrrV4IA0nvYzEwqt9yHg304b7y/ovKh0IEbBuSWbee/hJu2Tt7PD0C8D4WUwheECCeLpQA==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18.14.1"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@bufbuild/protobuf": "^2.2.0",
|
||||||
|
"@connectrpc/connect": "2.0.3"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@csstools/selector-specificity": {
|
"node_modules/@csstools/selector-specificity": {
|
||||||
"version": "6.0.0",
|
"version": "6.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/@csstools/selector-specificity/-/selector-specificity-6.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/@csstools/selector-specificity/-/selector-specificity-6.0.0.tgz",
|
||||||
@@ -1151,33 +1382,15 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@types/node": {
|
"node_modules/@types/node": {
|
||||||
"version": "22.19.17",
|
"version": "24.13.4",
|
||||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.17.tgz",
|
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.4.tgz",
|
||||||
"integrity": "sha512-wGdMcf+vPYM6jikpS/qhg6WiqSV/OhG+jeeHT/KlVqxYfD40iYJf9/AE1uQxVWFvU7MipKRkRv8NSHiCGgPr8Q==",
|
"integrity": "sha512-YJ7EqCstVTzIr0fMr7qul/977en+pQHrfmuKIo6Zr9i75Be21dr3MovcfvGtyvi2HAUrRerWps5sMO9I7WaxDw==",
|
||||||
"devOptional": true,
|
"devOptional": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"undici-types": "~6.21.0"
|
"undici-types": "~7.18.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@types/node18": {
|
|
||||||
"name": "@types/node",
|
|
||||||
"version": "18.19.130",
|
|
||||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.130.tgz",
|
|
||||||
"integrity": "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"undici-types": "~5.26.4"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@types/node18/node_modules/undici-types": {
|
|
||||||
"version": "5.26.5",
|
|
||||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz",
|
|
||||||
"integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/@types/react": {
|
"node_modules/@types/react": {
|
||||||
"version": "19.2.18",
|
"version": "19.2.18",
|
||||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.18.tgz",
|
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.18.tgz",
|
||||||
@@ -1538,6 +1751,19 @@
|
|||||||
"node": ">=16.20.0"
|
"node": ">=16.20.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@typescript/vfs": {
|
||||||
|
"version": "1.6.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@typescript/vfs/-/vfs-1.6.4.tgz",
|
||||||
|
"integrity": "sha512-PJFXFS4ZJKiJ9Qiuix6Dz/OwEIqHD7Dme1UwZhTK11vR+5dqW2ACbdndWQexBzCx+CPuMe5WBYQWCsFyGlQLlQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"debug": "^4.4.3"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"typescript": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@vitejs/plugin-react": {
|
"node_modules/@vitejs/plugin-react": {
|
||||||
"version": "5.2.0",
|
"version": "5.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-5.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-5.2.0.tgz",
|
||||||
@@ -2005,6 +2231,24 @@
|
|||||||
"url": "https://github.com/sponsors/SuperchupuDev"
|
"url": "https://github.com/sponsors/SuperchupuDev"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/tldts": {
|
||||||
|
"version": "7.4.12",
|
||||||
|
"resolved": "https://registry.npmjs.org/tldts/-/tldts-7.4.12.tgz",
|
||||||
|
"integrity": "sha512-WylhSDKVeYnWXL3a+vKTaOxjnOeEGw938hImY8zoRWJjRRK/Jp1K+IihBzIONpUmW4e3WmXT6q5FW6vlESVZCA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"tldts-core": "^7.4.12"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"tldts": "bin/cli.js"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/tldts-core": {
|
||||||
|
"version": "7.4.12",
|
||||||
|
"resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.12.tgz",
|
||||||
|
"integrity": "sha512-nYNzS2WRf4QJmjzFFgAxLOBjyBxAGRbCy9PVBPaglcYyYajh40VBn+v5Ngr96ZMc7oM0+aCJdtQnNejvdBnXMQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/typescript": {
|
"node_modules/typescript": {
|
||||||
"version": "7.0.2",
|
"version": "7.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-7.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/typescript/-/typescript-7.0.2.tgz",
|
||||||
@@ -2041,9 +2285,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/undici-types": {
|
"node_modules/undici-types": {
|
||||||
"version": "6.21.0",
|
"version": "7.18.2",
|
||||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
|
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz",
|
||||||
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
|
"integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==",
|
||||||
"devOptional": true,
|
"devOptional": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
|||||||
+13
-2
@@ -2,10 +2,16 @@
|
|||||||
"name": "vpn-proxy-gateway",
|
"name": "vpn-proxy-gateway",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
|
"engines": {
|
||||||
|
"node": "24.21.x"
|
||||||
|
},
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"description": "Gateway-first VPN proxy control panel for sing-box TProxy deployments.",
|
"description": "Gateway-first VPN proxy control panel for sing-box TProxy deployments.",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
"check:runtime": "node scripts/check-sqlite-runtime.mjs",
|
||||||
|
"pretest": "npm run check:runtime",
|
||||||
"dev": "vite --host 0.0.0.0",
|
"dev": "vite --host 0.0.0.0",
|
||||||
|
"generate:singbox-api": "XDG_CACHE_HOME=${TMPDIR:-/tmp}/harbor-buf-cache buf generate --template buf.gen.yaml",
|
||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
"build:production": "npm run build && npm run build:server",
|
"build:production": "npm run build && npm run build:server",
|
||||||
"build:server": "tsc -p tsconfig.server.json",
|
"build:server": "tsc -p tsconfig.server.json",
|
||||||
@@ -18,16 +24,21 @@
|
|||||||
"start": "node dist/server/main.js"
|
"start": "node dist/server/main.js"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@bufbuild/protobuf": "2.6.0",
|
||||||
|
"@connectrpc/connect": "2.0.3",
|
||||||
|
"@connectrpc/connect-node": "2.0.3",
|
||||||
"@vitejs/plugin-react": "^5.0.0",
|
"@vitejs/plugin-react": "^5.0.0",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"react-dom": "^19.0.0",
|
"react-dom": "^19.0.0",
|
||||||
|
"tldts": "7.4.12",
|
||||||
"vite": "^7.0.0"
|
"vite": "^7.0.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@babel/parser": "7.29.3",
|
"@babel/parser": "7.29.3",
|
||||||
|
"@bufbuild/buf": "1.47.2",
|
||||||
|
"@bufbuild/protoc-gen-es": "2.6.0",
|
||||||
"@csstools/selector-specificity": "6.0.0",
|
"@csstools/selector-specificity": "6.0.0",
|
||||||
"@types/node": "22.19.17",
|
"@types/node": "24.13.4",
|
||||||
"@types/node18": "npm:@types/node@18.19.130",
|
|
||||||
"@types/react": "^19.2.18",
|
"@types/react": "^19.2.18",
|
||||||
"@types/react-dom": "^19.2.4",
|
"@types/react-dom": "^19.2.4",
|
||||||
"postcss": "8.5.14",
|
"postcss": "8.5.14",
|
||||||
|
|||||||
@@ -0,0 +1,808 @@
|
|||||||
|
syntax = "proto3";
|
||||||
|
|
||||||
|
package daemon;
|
||||||
|
option go_package = "github.com/sagernet/sing-box/daemon";
|
||||||
|
|
||||||
|
import "google/protobuf/empty.proto";
|
||||||
|
|
||||||
|
service StartedService {
|
||||||
|
rpc GetVersion(google.protobuf.Empty) returns(Version) {}
|
||||||
|
rpc SubscribeServiceStatus(google.protobuf.Empty) returns(stream ServiceStatus) {}
|
||||||
|
rpc SubscribeLog(google.protobuf.Empty) returns(stream Log) {}
|
||||||
|
rpc GetDefaultLogLevel(google.protobuf.Empty) returns(DefaultLogLevel) {}
|
||||||
|
rpc ClearLogs(google.protobuf.Empty) returns(google.protobuf.Empty) {}
|
||||||
|
rpc SubscribeStatus(SubscribeStatusRequest) returns(stream Status) {}
|
||||||
|
rpc SubscribeGroups(google.protobuf.Empty) returns(stream Groups) {}
|
||||||
|
|
||||||
|
rpc GetClashModeStatus(google.protobuf.Empty) returns(ClashModeStatus) {}
|
||||||
|
rpc SubscribeClashMode(google.protobuf.Empty) returns(stream ClashMode) {}
|
||||||
|
rpc SetClashMode(ClashMode) returns(google.protobuf.Empty) {}
|
||||||
|
|
||||||
|
rpc URLTest(URLTestRequest) returns(google.protobuf.Empty) {}
|
||||||
|
rpc SelectOutbound(SelectOutboundRequest) returns (google.protobuf.Empty) {}
|
||||||
|
rpc SetGroupExpand(SetGroupExpandRequest) returns (google.protobuf.Empty) {}
|
||||||
|
|
||||||
|
rpc SubscribeConnections(SubscribeConnectionsRequest) returns(stream ConnectionEvents) {}
|
||||||
|
rpc CloseConnection(CloseConnectionRequest) returns(google.protobuf.Empty) {}
|
||||||
|
rpc CloseAllConnections(google.protobuf.Empty) returns(google.protobuf.Empty) {}
|
||||||
|
rpc GetDeprecatedWarnings(google.protobuf.Empty) returns(DeprecatedWarnings) {}
|
||||||
|
rpc GetStartedAt(google.protobuf.Empty) returns(StartedAt) {}
|
||||||
|
|
||||||
|
rpc SubscribeOutbounds(google.protobuf.Empty) returns (stream OutboundList) {}
|
||||||
|
rpc StartNetworkQualityTest(NetworkQualityTestRequest) returns (stream NetworkQualityTestProgress) {}
|
||||||
|
rpc StartSTUNTest(STUNTestRequest) returns (stream STUNTestProgress) {}
|
||||||
|
rpc SubscribeTailscaleStatus(google.protobuf.Empty) returns (stream TailscaleStatusUpdate) {}
|
||||||
|
rpc StartTailscalePing(TailscalePingRequest) returns (stream TailscalePingResponse) {}
|
||||||
|
rpc SetTailscaleExitNode(SetTailscaleExitNodeRequest) returns (google.protobuf.Empty) {}
|
||||||
|
rpc TailscaleLogout(TailscaleLogoutRequest) returns (google.protobuf.Empty) {}
|
||||||
|
rpc GetTailscaleCertificate(TailscaleCertificateRequest) returns (TailscaleCertificate) {}
|
||||||
|
rpc StartTailscaleSSHSession(stream TailscaleSSHClientMessage) returns (stream TailscaleSSHServerMessage) {}
|
||||||
|
rpc SubscribeTaildropInbox(SubscribeTaildropInboxRequest) returns (stream TaildropInbox) {}
|
||||||
|
rpc MarkTaildropInboxRead(MarkTaildropInboxReadRequest) returns (google.protobuf.Empty) {}
|
||||||
|
rpc SendTaildropFiles(stream TaildropSendClientMessage) returns (stream TaildropSendServerMessage) {}
|
||||||
|
rpc DownloadTaildropFile(DownloadTaildropFileRequest) returns (stream DownloadTaildropFileChunk) {}
|
||||||
|
rpc DeleteTaildropFile(DeleteTaildropFileRequest) returns (google.protobuf.Empty) {}
|
||||||
|
rpc CancelTaildropReceiving(CancelTaildropReceivingRequest) returns (google.protobuf.Empty) {}
|
||||||
|
rpc ProvideUSBDevices(stream USBProviderMessage) returns (stream USBServerMessage) {}
|
||||||
|
rpc SubscribeUSBIPServerStatus(google.protobuf.Empty) returns (stream USBIPServerStatusUpdate) {}
|
||||||
|
rpc SubscribeOpenConnectStatus(google.protobuf.Empty) returns (stream OpenConnectStatusUpdate) {}
|
||||||
|
rpc SubmitOpenConnectAuthResponse(OpenConnectAuthResponseSubmission) returns (google.protobuf.Empty) {}
|
||||||
|
rpc CancelOpenConnectAuthChallenge(OpenConnectAuthChallengeCancel) returns (google.protobuf.Empty) {}
|
||||||
|
rpc SubscribeOpenVPNStatus(google.protobuf.Empty) returns (stream OpenVPNStatusUpdate) {}
|
||||||
|
rpc SubmitOpenVPNChallengeResponse(OpenVPNChallengeSubmission) returns (google.protobuf.Empty) {}
|
||||||
|
rpc CancelOpenVPNChallenge(OpenVPNChallengeCancel) returns (google.protobuf.Empty) {}
|
||||||
|
rpc SubscribeNotifications(google.protobuf.Empty) returns (stream NotificationEvent) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
message Version {
|
||||||
|
string version = 1;
|
||||||
|
int32 apiVersion = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
message ServiceStatus {
|
||||||
|
enum Type {
|
||||||
|
IDLE = 0;
|
||||||
|
STARTING = 1;
|
||||||
|
STARTED = 2;
|
||||||
|
STOPPING = 3;
|
||||||
|
FATAL = 4;
|
||||||
|
}
|
||||||
|
Type status = 1;
|
||||||
|
string errorMessage = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
message SubscribeStatusRequest {
|
||||||
|
int64 interval = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
enum LogLevel {
|
||||||
|
PANIC = 0;
|
||||||
|
FATAL = 1;
|
||||||
|
ERROR = 2;
|
||||||
|
WARN = 3;
|
||||||
|
INFO = 4;
|
||||||
|
DEBUG = 5;
|
||||||
|
TRACE = 6;
|
||||||
|
}
|
||||||
|
|
||||||
|
message Log {
|
||||||
|
repeated Message messages = 1;
|
||||||
|
bool reset = 2;
|
||||||
|
message Message {
|
||||||
|
LogLevel level = 1;
|
||||||
|
string message = 2;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
message DefaultLogLevel {
|
||||||
|
LogLevel level = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
message Status {
|
||||||
|
uint64 memory = 1;
|
||||||
|
int32 goroutines = 2;
|
||||||
|
int32 connectionsIn = 3;
|
||||||
|
int32 connectionsOut = 4;
|
||||||
|
bool trafficAvailable = 5;
|
||||||
|
int64 uplink = 6;
|
||||||
|
int64 downlink = 7;
|
||||||
|
int64 uplinkTotal = 8;
|
||||||
|
int64 downlinkTotal = 9;
|
||||||
|
}
|
||||||
|
|
||||||
|
message Groups {
|
||||||
|
repeated Group group = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
message Group {
|
||||||
|
string tag = 1;
|
||||||
|
string type = 2;
|
||||||
|
bool selectable = 3;
|
||||||
|
string selected = 4;
|
||||||
|
bool isExpand = 5;
|
||||||
|
repeated GroupItem items = 6;
|
||||||
|
}
|
||||||
|
|
||||||
|
message GroupItem {
|
||||||
|
string tag = 1;
|
||||||
|
string type = 2;
|
||||||
|
int64 urlTestTime = 3;
|
||||||
|
int32 urlTestDelay = 4;
|
||||||
|
}
|
||||||
|
|
||||||
|
message URLTestRequest {
|
||||||
|
string outboundTag = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
message SelectOutboundRequest {
|
||||||
|
string groupTag = 1;
|
||||||
|
string outboundTag = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
message SetGroupExpandRequest {
|
||||||
|
string groupTag = 1;
|
||||||
|
bool isExpand = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
message ClashMode {
|
||||||
|
string mode = 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
message ClashModeStatus {
|
||||||
|
repeated string modeList = 1;
|
||||||
|
string currentMode = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
message SubscribeConnectionsRequest {
|
||||||
|
int64 interval = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
enum ConnectionEventType {
|
||||||
|
CONNECTION_EVENT_NEW = 0;
|
||||||
|
CONNECTION_EVENT_UPDATE = 1;
|
||||||
|
CONNECTION_EVENT_CLOSED = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
message ConnectionEvent {
|
||||||
|
ConnectionEventType type = 1;
|
||||||
|
string id = 2;
|
||||||
|
Connection connection = 3;
|
||||||
|
int64 uplinkDelta = 4;
|
||||||
|
int64 downlinkDelta = 5;
|
||||||
|
int64 closedAt = 6;
|
||||||
|
}
|
||||||
|
|
||||||
|
message ConnectionEvents {
|
||||||
|
repeated ConnectionEvent events = 1;
|
||||||
|
bool reset = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
message Connection {
|
||||||
|
string id = 1;
|
||||||
|
string inbound = 2;
|
||||||
|
string inboundType = 3;
|
||||||
|
int32 ipVersion = 4;
|
||||||
|
string network = 5;
|
||||||
|
string source = 6;
|
||||||
|
string destination = 7;
|
||||||
|
string domain = 8;
|
||||||
|
string protocol = 9;
|
||||||
|
string user = 10;
|
||||||
|
string fromOutbound = 11;
|
||||||
|
int64 createdAt = 12;
|
||||||
|
int64 closedAt = 13;
|
||||||
|
int64 uplink = 14;
|
||||||
|
int64 downlink = 15;
|
||||||
|
int64 uplinkTotal = 16;
|
||||||
|
int64 downlinkTotal = 17;
|
||||||
|
string rule = 18;
|
||||||
|
string outbound = 19;
|
||||||
|
string outboundType = 20;
|
||||||
|
repeated string chainList = 21;
|
||||||
|
ProcessInfo processInfo = 22;
|
||||||
|
}
|
||||||
|
|
||||||
|
message ProcessInfo {
|
||||||
|
uint32 processId = 1;
|
||||||
|
int32 userId = 2;
|
||||||
|
string userName = 3;
|
||||||
|
string processPath = 4;
|
||||||
|
repeated string packageNames = 5;
|
||||||
|
}
|
||||||
|
|
||||||
|
message CloseConnectionRequest {
|
||||||
|
string id = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
message DeprecatedWarnings {
|
||||||
|
repeated DeprecatedWarning warnings = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
message DeprecatedWarning {
|
||||||
|
string message = 1;
|
||||||
|
bool impending = 2;
|
||||||
|
string migrationLink = 3;
|
||||||
|
string description = 4;
|
||||||
|
string deprecatedVersion = 5;
|
||||||
|
string scheduledVersion = 6;
|
||||||
|
}
|
||||||
|
|
||||||
|
message StartedAt {
|
||||||
|
int64 startedAt = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
message OutboundList {
|
||||||
|
repeated GroupItem outbounds = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
message NetworkQualityTestRequest {
|
||||||
|
string configURL = 1;
|
||||||
|
string outboundTag = 2;
|
||||||
|
bool serial = 3;
|
||||||
|
int32 maxRuntimeSeconds = 4;
|
||||||
|
bool http3 = 5;
|
||||||
|
}
|
||||||
|
|
||||||
|
message NetworkQualityTestProgress {
|
||||||
|
int32 phase = 1;
|
||||||
|
int64 downloadCapacity = 2;
|
||||||
|
int64 uploadCapacity = 3;
|
||||||
|
int32 downloadRPM = 4;
|
||||||
|
int32 uploadRPM = 5;
|
||||||
|
int32 idleLatencyMs = 6;
|
||||||
|
int64 elapsedMs = 7;
|
||||||
|
bool isFinal = 8;
|
||||||
|
string error = 9;
|
||||||
|
int32 downloadCapacityAccuracy = 10;
|
||||||
|
int32 uploadCapacityAccuracy = 11;
|
||||||
|
int32 downloadRPMAccuracy = 12;
|
||||||
|
int32 uploadRPMAccuracy = 13;
|
||||||
|
}
|
||||||
|
|
||||||
|
message STUNTestRequest {
|
||||||
|
string server = 1;
|
||||||
|
string outboundTag = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
message STUNTestProgress {
|
||||||
|
int32 phase = 1;
|
||||||
|
string externalAddr = 2;
|
||||||
|
int32 latencyMs = 3;
|
||||||
|
int32 natMapping = 4;
|
||||||
|
int32 natFiltering = 5;
|
||||||
|
bool isFinal = 6;
|
||||||
|
string error = 7;
|
||||||
|
bool natTypeSupported = 8;
|
||||||
|
}
|
||||||
|
|
||||||
|
message TailscaleStatusUpdate {
|
||||||
|
repeated TailscaleEndpointStatus endpoints = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
message TailscaleEndpointStatus {
|
||||||
|
string endpointTag = 1;
|
||||||
|
string backendState = 2;
|
||||||
|
string stateText = 3;
|
||||||
|
string authURL = 4;
|
||||||
|
string networkName = 5;
|
||||||
|
string magicDNSSuffix = 6;
|
||||||
|
TailscalePeer self = 7;
|
||||||
|
repeated TailscaleUserGroup userGroups = 8;
|
||||||
|
TailscalePeer exitNode = 9;
|
||||||
|
bool keyAuth = 10;
|
||||||
|
bool canShareFiles = 11;
|
||||||
|
int32 waitingFileCount = 12;
|
||||||
|
int32 receivingFileCount = 13;
|
||||||
|
int32 unreadFileCount = 14;
|
||||||
|
repeated string certDomains = 15;
|
||||||
|
}
|
||||||
|
|
||||||
|
message TailscaleUserGroup {
|
||||||
|
int64 userID = 1;
|
||||||
|
string loginName = 2;
|
||||||
|
string displayName = 3;
|
||||||
|
string profilePicURL = 4;
|
||||||
|
repeated TailscalePeer peers = 5;
|
||||||
|
}
|
||||||
|
|
||||||
|
message TailscalePeer {
|
||||||
|
string hostName = 1;
|
||||||
|
string dnsName = 2;
|
||||||
|
string os = 3;
|
||||||
|
repeated string tailscaleIPs = 4;
|
||||||
|
bool online = 5;
|
||||||
|
bool exitNode = 6;
|
||||||
|
bool exitNodeOption = 7;
|
||||||
|
bool active = 8;
|
||||||
|
int64 rxBytes = 9;
|
||||||
|
int64 txBytes = 10;
|
||||||
|
int64 keyExpiry = 11;
|
||||||
|
string stableID = 12;
|
||||||
|
bool expired = 13;
|
||||||
|
repeated string sshHostKeys = 14;
|
||||||
|
bool shareeNode = 15;
|
||||||
|
int64 lastSeen = 16;
|
||||||
|
bool canReceiveFiles = 17;
|
||||||
|
}
|
||||||
|
|
||||||
|
message TailscalePingRequest {
|
||||||
|
string endpointTag = 1;
|
||||||
|
string peerIP = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
message TailscalePingResponse {
|
||||||
|
double latencyMs = 1;
|
||||||
|
bool isDirect = 2;
|
||||||
|
string endpoint = 3;
|
||||||
|
int32 derpRegionID = 4;
|
||||||
|
string derpRegionCode = 5;
|
||||||
|
string error = 6;
|
||||||
|
string peerRelay = 7;
|
||||||
|
}
|
||||||
|
|
||||||
|
message SetTailscaleExitNodeRequest {
|
||||||
|
string endpointTag = 1;
|
||||||
|
string stableID = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
message TailscaleLogoutRequest {
|
||||||
|
string endpointTag = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
message TailscaleCertificateRequest {
|
||||||
|
string endpointTag = 1;
|
||||||
|
string domain = 2;
|
||||||
|
int64 minValiditySeconds = 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
message TailscaleCertificate {
|
||||||
|
bytes certificatePEM = 1;
|
||||||
|
bytes privateKeyPEM = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
message TailscaleSSHClientMessage {
|
||||||
|
oneof message {
|
||||||
|
TailscaleSSHStart start = 1;
|
||||||
|
TailscaleSSHInput input = 2;
|
||||||
|
TailscaleSSHResize resize = 3;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
message TailscaleSSHStart {
|
||||||
|
string endpointTag = 1;
|
||||||
|
string peerAddress = 2;
|
||||||
|
string username = 3;
|
||||||
|
string terminalType = 4;
|
||||||
|
int32 columns = 5;
|
||||||
|
int32 rows = 6;
|
||||||
|
int32 widthPixels = 7;
|
||||||
|
int32 heightPixels = 8;
|
||||||
|
repeated string hostKeys = 9;
|
||||||
|
bool forward_agent = 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
message TailscaleSSHInput {
|
||||||
|
bytes data = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
message TailscaleSSHResize {
|
||||||
|
int32 columns = 1;
|
||||||
|
int32 rows = 2;
|
||||||
|
int32 widthPixels = 3;
|
||||||
|
int32 heightPixels = 4;
|
||||||
|
}
|
||||||
|
|
||||||
|
message TailscaleSSHServerMessage {
|
||||||
|
oneof message {
|
||||||
|
TailscaleSSHAuthBanner authBanner = 1;
|
||||||
|
TailscaleSSHReady ready = 2;
|
||||||
|
TailscaleSSHOutput output = 3;
|
||||||
|
TailscaleSSHExit exit = 4;
|
||||||
|
TailscaleSSHError error = 5;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
message TailscaleSSHAuthBanner {
|
||||||
|
string message = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
message TailscaleSSHReady {
|
||||||
|
}
|
||||||
|
|
||||||
|
message TailscaleSSHOutput {
|
||||||
|
bytes data = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
message TailscaleSSHExit {
|
||||||
|
int32 exitCode = 1;
|
||||||
|
string signal = 2;
|
||||||
|
string errorMessage = 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
message TailscaleSSHError {
|
||||||
|
string message = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
message SubscribeTaildropInboxRequest {
|
||||||
|
string endpointTag = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
message MarkTaildropInboxReadRequest {
|
||||||
|
string endpointTag = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
message TaildropInbox {
|
||||||
|
string endpointTag = 1;
|
||||||
|
repeated TaildropFile files = 2;
|
||||||
|
repeated TaildropReceivingFile receiving = 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
message TaildropFile {
|
||||||
|
string name = 1;
|
||||||
|
int64 size = 2;
|
||||||
|
string senderName = 3;
|
||||||
|
int64 modifiedAt = 4;
|
||||||
|
}
|
||||||
|
|
||||||
|
message TaildropReceivingFile {
|
||||||
|
string name = 1;
|
||||||
|
int64 size = 2;
|
||||||
|
int64 receivedBytes = 3;
|
||||||
|
string senderID = 4;
|
||||||
|
string senderName = 5;
|
||||||
|
}
|
||||||
|
|
||||||
|
message TaildropSendClientMessage {
|
||||||
|
oneof message {
|
||||||
|
TaildropSendStart start = 1;
|
||||||
|
TaildropFileChunk chunk = 2;
|
||||||
|
TaildropFileDone fileDone = 3;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
message TaildropSendStart {
|
||||||
|
string endpointTag = 1;
|
||||||
|
string peerStableID = 2;
|
||||||
|
repeated TaildropOutgoingFile files = 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
message TaildropOutgoingFile {
|
||||||
|
string name = 1;
|
||||||
|
int64 size = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
message TaildropFileChunk {
|
||||||
|
bytes data = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
message TaildropFileDone {}
|
||||||
|
|
||||||
|
message TaildropSendServerMessage {
|
||||||
|
oneof message {
|
||||||
|
TaildropSendProgress progress = 1;
|
||||||
|
int64 receivedBytes = 2;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
message TaildropSendProgress {
|
||||||
|
int32 fileIndex = 1;
|
||||||
|
int64 sentBytes = 2;
|
||||||
|
bool fileCompleted = 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
message DownloadTaildropFileRequest {
|
||||||
|
string endpointTag = 1;
|
||||||
|
string name = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
message DownloadTaildropFileChunk {
|
||||||
|
int64 size = 1;
|
||||||
|
bytes data = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
message DeleteTaildropFileRequest {
|
||||||
|
string endpointTag = 1;
|
||||||
|
string name = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
message CancelTaildropReceivingRequest {
|
||||||
|
string endpointTag = 1;
|
||||||
|
string senderID = 2;
|
||||||
|
string name = 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
message USBProviderMessage {
|
||||||
|
oneof message {
|
||||||
|
USBDeviceAttach attach = 1;
|
||||||
|
USBDeviceDetach detach = 2;
|
||||||
|
USBURBResponse urbResponse = 3;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
message USBServerMessage {
|
||||||
|
oneof message {
|
||||||
|
USBDeviceReady ready = 1;
|
||||||
|
USBURBRequest urbRequest = 2;
|
||||||
|
USBEndpointAbort abort = 3;
|
||||||
|
USBError error = 4;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
message USBDeviceDescriptor {
|
||||||
|
string deviceId = 1;
|
||||||
|
uint32 busNum = 2;
|
||||||
|
uint32 devNum = 3;
|
||||||
|
uint32 speed = 4;
|
||||||
|
uint32 vendorId = 5;
|
||||||
|
uint32 productId = 6;
|
||||||
|
uint32 bcdDevice = 7;
|
||||||
|
uint32 deviceClass = 8;
|
||||||
|
uint32 deviceSubClass = 9;
|
||||||
|
uint32 deviceProtocol = 10;
|
||||||
|
uint32 configurationValue = 11;
|
||||||
|
uint32 numConfigurations = 12;
|
||||||
|
repeated USBInterface interfaces = 13;
|
||||||
|
string serial = 14;
|
||||||
|
string product = 15;
|
||||||
|
}
|
||||||
|
|
||||||
|
message USBDeviceAttach {
|
||||||
|
string serverTag = 1;
|
||||||
|
USBDeviceDescriptor descriptor = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
message USBInterface {
|
||||||
|
uint32 interfaceClass = 1;
|
||||||
|
uint32 interfaceSubClass = 2;
|
||||||
|
uint32 interfaceProtocol = 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
message USBDeviceDetach {
|
||||||
|
string deviceId = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
message USBDeviceReady {
|
||||||
|
string deviceId = 1;
|
||||||
|
string busId = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
message USBURBRequest {
|
||||||
|
string deviceId = 1;
|
||||||
|
uint64 seq = 2;
|
||||||
|
uint32 endpoint = 3;
|
||||||
|
bool directionIn = 4;
|
||||||
|
uint32 transferFlags = 5;
|
||||||
|
bytes setup = 6;
|
||||||
|
uint32 transferBufferLength = 7;
|
||||||
|
bytes outData = 8;
|
||||||
|
int32 numberOfPackets = 9;
|
||||||
|
int32 startFrame = 10;
|
||||||
|
int32 interval = 11;
|
||||||
|
repeated USBIsoPacket isoPackets = 12;
|
||||||
|
}
|
||||||
|
|
||||||
|
message USBURBResponse {
|
||||||
|
string deviceId = 1;
|
||||||
|
uint64 seq = 2;
|
||||||
|
int32 status = 3;
|
||||||
|
int32 actualLength = 4;
|
||||||
|
bytes inData = 5;
|
||||||
|
repeated USBIsoPacket isoPackets = 6;
|
||||||
|
}
|
||||||
|
|
||||||
|
message USBIsoPacket {
|
||||||
|
int32 offset = 1;
|
||||||
|
int32 length = 2;
|
||||||
|
int32 actualLength = 3;
|
||||||
|
int32 status = 4;
|
||||||
|
}
|
||||||
|
|
||||||
|
message USBEndpointAbort {
|
||||||
|
string deviceId = 1;
|
||||||
|
uint32 endpoint = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
message USBError {
|
||||||
|
string deviceId = 1;
|
||||||
|
string message = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
message USBIPServerStatusUpdate {
|
||||||
|
repeated USBIPServerStatus servers = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
message USBIPServerStatus {
|
||||||
|
string serverTag = 1;
|
||||||
|
repeated USBSharedDevice devices = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
message USBSharedDevice {
|
||||||
|
USBDeviceDescriptor descriptor = 1;
|
||||||
|
string busId = 2;
|
||||||
|
string stableId = 3;
|
||||||
|
USBBackend backend = 4;
|
||||||
|
USBDeviceState state = 5;
|
||||||
|
}
|
||||||
|
|
||||||
|
enum USBDeviceState {
|
||||||
|
USB_DEVICE_STATE_IDLE = 0;
|
||||||
|
USB_DEVICE_STATE_ATTACHED = 1;
|
||||||
|
USB_DEVICE_STATE_UNAVAILABLE = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
enum USBBackend {
|
||||||
|
USB_BACKEND_UNSPECIFIED = 0;
|
||||||
|
USB_BACKEND_LINUX_SYSFS = 1;
|
||||||
|
USB_BACKEND_DYNAMIC = 2;
|
||||||
|
USB_BACKEND_DARWIN_IOKIT = 3;
|
||||||
|
USB_BACKEND_WINDOWS_VBOXUSB = 4;
|
||||||
|
}
|
||||||
|
|
||||||
|
message OpenConnectStatusUpdate {
|
||||||
|
repeated OpenConnectEndpointStatus endpoints = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
message OpenConnectEndpointStatus {
|
||||||
|
string endpointTag = 1;
|
||||||
|
string state = 2;
|
||||||
|
string stateText = 3;
|
||||||
|
OpenConnectAuthChallenge authChallenge = 4;
|
||||||
|
string error = 5;
|
||||||
|
OpenConnectTunnelInfo tunnelInfo = 6;
|
||||||
|
}
|
||||||
|
|
||||||
|
message OpenConnectTunnelInfo {
|
||||||
|
string server = 1;
|
||||||
|
string flavor = 2;
|
||||||
|
string transport = 3;
|
||||||
|
repeated string ipv4 = 4;
|
||||||
|
repeated string ipv6 = 5;
|
||||||
|
repeated string dns = 6;
|
||||||
|
uint32 mtu = 7;
|
||||||
|
int64 connectedSince = 8;
|
||||||
|
}
|
||||||
|
|
||||||
|
message OpenConnectAuthChallenge {
|
||||||
|
string id = 1;
|
||||||
|
string banner = 2;
|
||||||
|
string message = 3;
|
||||||
|
string error = 4;
|
||||||
|
oneof challenge {
|
||||||
|
OpenConnectAuthForm form = 5;
|
||||||
|
OpenConnectBrowserRequest browser = 6;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
message OpenConnectAuthForm {
|
||||||
|
repeated OpenConnectAuthFormField fields = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
message OpenConnectAuthFormField {
|
||||||
|
string submissionKey = 1;
|
||||||
|
string name = 2;
|
||||||
|
string label = 3;
|
||||||
|
string kind = 4;
|
||||||
|
string value = 5;
|
||||||
|
repeated OpenConnectAuthFormChoice options = 6;
|
||||||
|
}
|
||||||
|
|
||||||
|
message OpenConnectAuthFormChoice {
|
||||||
|
string value = 1;
|
||||||
|
string label = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
message OpenConnectBrowserRequest {
|
||||||
|
string url = 1;
|
||||||
|
string finalURL = 2;
|
||||||
|
repeated string cookieNames = 3;
|
||||||
|
repeated string headerNames = 4;
|
||||||
|
repeated string callbackURLPrefixes = 5;
|
||||||
|
repeated string earlyCookieNames = 6;
|
||||||
|
string cacheID = 7;
|
||||||
|
}
|
||||||
|
|
||||||
|
message OpenConnectBrowserCookie {
|
||||||
|
string name = 1;
|
||||||
|
string value = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
message OpenConnectBrowserHeader {
|
||||||
|
string name = 1;
|
||||||
|
repeated string values = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
message OpenConnectAuthFormResponse {
|
||||||
|
map<string, string> values = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
message OpenConnectBrowserResult {
|
||||||
|
string finalURL = 1;
|
||||||
|
repeated OpenConnectBrowserCookie cookies = 2;
|
||||||
|
repeated OpenConnectBrowserHeader headers = 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
message OpenConnectAuthResponseSubmission {
|
||||||
|
string endpointTag = 1;
|
||||||
|
string challengeID = 2;
|
||||||
|
oneof response {
|
||||||
|
OpenConnectAuthFormResponse form = 3;
|
||||||
|
OpenConnectBrowserResult browser = 4;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
message OpenConnectAuthChallengeCancel {
|
||||||
|
string endpointTag = 1;
|
||||||
|
string challengeID = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
message OpenVPNStatusUpdate {
|
||||||
|
repeated OpenVPNEndpointStatus endpoints = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
message OpenVPNEndpointStatus {
|
||||||
|
string endpointTag = 1;
|
||||||
|
string state = 2;
|
||||||
|
string stateText = 3;
|
||||||
|
OpenVPNChallenge challenge = 4;
|
||||||
|
string error = 5;
|
||||||
|
OpenVPNTunnelInfo tunnelInfo = 6;
|
||||||
|
}
|
||||||
|
|
||||||
|
message OpenVPNTunnelInfo {
|
||||||
|
string server = 1;
|
||||||
|
reserved 2;
|
||||||
|
string network = 3;
|
||||||
|
repeated string ipv4 = 4;
|
||||||
|
repeated string ipv6 = 5;
|
||||||
|
repeated string dns = 6;
|
||||||
|
uint32 mtu = 7;
|
||||||
|
int64 connectedSince = 8;
|
||||||
|
string cipher = 9;
|
||||||
|
}
|
||||||
|
|
||||||
|
message OpenVPNChallenge {
|
||||||
|
string id = 1;
|
||||||
|
string kind = 2;
|
||||||
|
string username = 3;
|
||||||
|
string message = 4;
|
||||||
|
string url = 5;
|
||||||
|
string secretMessage = 6;
|
||||||
|
bool echo = 7;
|
||||||
|
string previousError = 8;
|
||||||
|
int64 deadline = 9;
|
||||||
|
}
|
||||||
|
|
||||||
|
message OpenVPNChallengeSubmission {
|
||||||
|
string endpointTag = 1;
|
||||||
|
string challengeID = 2;
|
||||||
|
string username = 3;
|
||||||
|
string password = 4;
|
||||||
|
string secret = 5;
|
||||||
|
}
|
||||||
|
|
||||||
|
message OpenVPNChallengeCancel {
|
||||||
|
string endpointTag = 1;
|
||||||
|
string challengeID = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
message NotificationEvent {
|
||||||
|
oneof event {
|
||||||
|
Notification send = 1;
|
||||||
|
NotificationCancel cancel = 2;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
message Notification {
|
||||||
|
string identifier = 1;
|
||||||
|
string typeName = 2;
|
||||||
|
int32 typeID = 3;
|
||||||
|
string title = 4;
|
||||||
|
string subtitle = 5;
|
||||||
|
string body = 6;
|
||||||
|
string openURL = 7;
|
||||||
|
}
|
||||||
|
|
||||||
|
message NotificationCancel {
|
||||||
|
string identifier = 1;
|
||||||
|
int32 typeID = 2;
|
||||||
|
}
|
||||||
@@ -10,9 +10,9 @@ GIT_REF="$(git rev-parse --short HEAD 2>/dev/null || echo manual)"
|
|||||||
IMAGE_TAG="${IMAGE_TAG:-${GIT_REF}-$(date +%Y%m%d%H%M%S)}"
|
IMAGE_TAG="${IMAGE_TAG:-${GIT_REF}-$(date +%Y%m%d%H%M%S)}"
|
||||||
GATEWAY_IMAGE="${GATEWAY_IMAGE:-${IMAGE_NAME}:${IMAGE_TAG}}"
|
GATEWAY_IMAGE="${GATEWAY_IMAGE:-${IMAGE_NAME}:${IMAGE_TAG}}"
|
||||||
BASE_IMAGE="${BASE_IMAGE:-vpn-proxy-runtime-base:bookworm-slim}"
|
BASE_IMAGE="${BASE_IMAGE:-vpn-proxy-runtime-base:bookworm-slim}"
|
||||||
NODE_BUILD_IMAGE="${NODE_BUILD_IMAGE:-node:20.19-alpine}"
|
NODE_BUILD_IMAGE="${NODE_BUILD_IMAGE:-node:24.21.0-bookworm}"
|
||||||
RUNTIME_BASE_SOURCE_IMAGE="${RUNTIME_BASE_SOURCE_IMAGE:-mirror.gcr.io/library/debian:bookworm-slim}"
|
RUNTIME_BASE_SOURCE_IMAGE="${RUNTIME_BASE_SOURCE_IMAGE:-mirror.gcr.io/library/debian:bookworm-slim}"
|
||||||
SINGBOX_VERSION="${SINGBOX_VERSION:-1.13.18}"
|
SINGBOX_VERSION="${SINGBOX_VERSION:-1.14.0-rc.5}"
|
||||||
DOCKER_BUILD_PULL="${DOCKER_BUILD_PULL:-false}"
|
DOCKER_BUILD_PULL="${DOCKER_BUILD_PULL:-false}"
|
||||||
INSTALL_RUNTIME_DEPS="${INSTALL_RUNTIME_DEPS:-false}"
|
INSTALL_RUNTIME_DEPS="${INSTALL_RUNTIME_DEPS:-false}"
|
||||||
INSTALL_SINGBOX="${INSTALL_SINGBOX:-false}"
|
INSTALL_SINGBOX="${INSTALL_SINGBOX:-false}"
|
||||||
@@ -63,7 +63,7 @@ else
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
echo "Building image on ${BUILD_HOST}"
|
echo "Building image on ${BUILD_HOST}"
|
||||||
BUILD_COMMAND="set -e; echo 'Docker context:' \$(docker context show 2>/dev/null || true); docker info 2>/dev/null | sed -n '/HTTP Proxy:/p;/HTTPS Proxy:/p;/Name:/p'; cd '${BUILD_PATH}'; if ! docker image inspect '${BASE_IMAGE}' >/dev/null 2>&1; then if [ '${AUTO_BUILD_RUNTIME_BASE}' = 'true' ]; then echo 'Runtime base image ${BASE_IMAGE} is missing on ${BUILD_HOST}; building it now.'; BASE_IMAGE='${RUNTIME_BASE_SOURCE_IMAGE}' RUNTIME_BASE_IMAGE='${BASE_IMAGE}' SINGBOX_VERSION='${SINGBOX_VERSION}' ./scripts/build-runtime-base.sh; else echo 'Runtime base image ${BASE_IMAGE} is missing on ${BUILD_HOST}.'; echo 'Seed it once with: ./scripts/build-runtime-base.sh'; exit 1; fi; fi; npm ci && npm run build:production && docker build --pull='${DOCKER_BUILD_PULL}' --build-arg NODE_BUILD_IMAGE='${NODE_BUILD_IMAGE}' --build-arg BASE_IMAGE='${BASE_IMAGE}' --build-arg SINGBOX_VERSION='${SINGBOX_VERSION}' --build-arg INSTALL_RUNTIME_DEPS='${INSTALL_RUNTIME_DEPS}' --build-arg INSTALL_SINGBOX='${INSTALL_SINGBOX}' -t '${GATEWAY_IMAGE}' ."
|
BUILD_COMMAND="set -e; echo 'Docker context:' \$(docker context show 2>/dev/null || true); docker info 2>/dev/null | sed -n '/HTTP Proxy:/p;/HTTPS Proxy:/p;/Name:/p'; cd '${BUILD_PATH}'; if ! docker image inspect '${BASE_IMAGE}' >/dev/null 2>&1 || ! docker run --rm '${BASE_IMAGE}' sh -lc \"command -v npm >/dev/null && sing-box version 2>&1 | grep -Fx 'sing-box version ${SINGBOX_VERSION}'\"; then if [ '${AUTO_BUILD_RUNTIME_BASE}' = 'true' ]; then echo 'Runtime base image ${BASE_IMAGE} is missing or does not contain sing-box ${SINGBOX_VERSION}; building it now.'; BASE_IMAGE='${RUNTIME_BASE_SOURCE_IMAGE}' RUNTIME_BASE_IMAGE='${BASE_IMAGE}' SINGBOX_VERSION='${SINGBOX_VERSION}' ./scripts/build-runtime-base.sh; else echo 'Runtime base image ${BASE_IMAGE} is missing or does not contain sing-box ${SINGBOX_VERSION} on ${BUILD_HOST}.'; echo 'Seed it once with: ./scripts/build-runtime-base.sh'; exit 1; fi; fi; docker run --rm '${BASE_IMAGE}' sh -lc \"command -v npm >/dev/null && sing-box version 2>&1 | grep -Fx 'sing-box version ${SINGBOX_VERSION}'\"; node scripts/check-sqlite-runtime.mjs && npm ci && npm run build:production && docker build --pull='${DOCKER_BUILD_PULL}' --build-arg NODE_BUILD_IMAGE='${NODE_BUILD_IMAGE}' --build-arg BASE_IMAGE='${BASE_IMAGE}' --build-arg SINGBOX_VERSION='${SINGBOX_VERSION}' --build-arg INSTALL_RUNTIME_DEPS='${INSTALL_RUNTIME_DEPS}' --build-arg INSTALL_SINGBOX='${INSTALL_SINGBOX}' -t '${GATEWAY_IMAGE}' . && docker run --rm --entrypoint sing-box '${GATEWAY_IMAGE}' version 2>&1 | grep -Fx 'sing-box version ${SINGBOX_VERSION}'"
|
||||||
if [ "${BUILD_HOST}" = "local" ]; then
|
if [ "${BUILD_HOST}" = "local" ]; then
|
||||||
bash -lc "${BUILD_COMMAND}"
|
bash -lc "${BUILD_COMMAND}"
|
||||||
else
|
else
|
||||||
|
|||||||
@@ -2,8 +2,9 @@
|
|||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
BASE_IMAGE="${BASE_IMAGE:-mirror.gcr.io/library/debian:bookworm-slim}"
|
BASE_IMAGE="${BASE_IMAGE:-mirror.gcr.io/library/debian:bookworm-slim}"
|
||||||
|
NODE_BUILD_IMAGE="${NODE_BUILD_IMAGE:-node:24.21.0-bookworm}"
|
||||||
RUNTIME_BASE_IMAGE="${RUNTIME_BASE_IMAGE:-vpn-proxy-runtime-base:bookworm-slim}"
|
RUNTIME_BASE_IMAGE="${RUNTIME_BASE_IMAGE:-vpn-proxy-runtime-base:bookworm-slim}"
|
||||||
SINGBOX_VERSION="${SINGBOX_VERSION:-1.13.18}"
|
SINGBOX_VERSION="${SINGBOX_VERSION:-1.14.0-rc.5}"
|
||||||
APT_MIRROR="${APT_MIRROR:-http://mirror.yandex.ru/debian}"
|
APT_MIRROR="${APT_MIRROR:-http://mirror.yandex.ru/debian}"
|
||||||
APT_SECURITY_MIRROR="${APT_SECURITY_MIRROR:-http://mirror.yandex.ru/debian-security}"
|
APT_SECURITY_MIRROR="${APT_SECURITY_MIRROR:-http://mirror.yandex.ru/debian-security}"
|
||||||
HTTP_PROXY="${HTTP_PROXY:-$(docker info 2>/dev/null | awk -F': ' '/HTTP Proxy:/ {print $2; exit}')}"
|
HTTP_PROXY="${HTTP_PROXY:-$(docker info 2>/dev/null | awk -F': ' '/HTTP Proxy:/ {print $2; exit}')}"
|
||||||
@@ -18,6 +19,7 @@ if [ -n "${HTTP_PROXY}" ]; then echo "HTTP proxy: ${HTTP_PROXY}"; fi
|
|||||||
if [ -n "${HTTPS_PROXY}" ]; then echo "HTTPS proxy: ${HTTPS_PROXY}"; fi
|
if [ -n "${HTTPS_PROXY}" ]; then echo "HTTPS proxy: ${HTTPS_PROXY}"; fi
|
||||||
|
|
||||||
docker build \
|
docker build \
|
||||||
|
--build-arg NODE_BUILD_IMAGE="${NODE_BUILD_IMAGE}" \
|
||||||
--build-arg BASE_IMAGE="${BASE_IMAGE}" \
|
--build-arg BASE_IMAGE="${BASE_IMAGE}" \
|
||||||
--build-arg SINGBOX_VERSION="${SINGBOX_VERSION}" \
|
--build-arg SINGBOX_VERSION="${SINGBOX_VERSION}" \
|
||||||
--build-arg APT_MIRROR="${APT_MIRROR}" \
|
--build-arg APT_MIRROR="${APT_MIRROR}" \
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { DatabaseSync } from 'node:sqlite';
|
||||||
|
|
||||||
|
assert.equal(process.versions.node, '24.21.0', 'Harbor requires the pinned Node 24.21.0 runtime');
|
||||||
|
const db = new DatabaseSync(':memory:');
|
||||||
|
try {
|
||||||
|
const version = db.prepare('SELECT sqlite_version() AS version').get().version;
|
||||||
|
const [major, minor, patch] = version.split('.').map(Number);
|
||||||
|
assert.ok(major > 3 || (major === 3 && (minor > 51 || (minor === 51 && patch >= 3))),
|
||||||
|
'Harbor requires SQLite >= 3.51.3 with the WAL-reset fix');
|
||||||
|
db.exec('CREATE TABLE probe (bytes INTEGER NOT NULL) STRICT');
|
||||||
|
db.prepare('INSERT INTO probe VALUES (?)').run(9007199254740993n);
|
||||||
|
const statement = db.prepare('SELECT bytes FROM probe');
|
||||||
|
statement.setReadBigInts(true);
|
||||||
|
assert.equal(statement.get().bytes, 9007199254740993n);
|
||||||
|
console.log(`Harbor runtime: Node ${process.versions.node}, SQLite ${version}, ${process.platform}/${process.arch}`);
|
||||||
|
} finally {
|
||||||
|
db.close();
|
||||||
|
}
|
||||||
@@ -36,6 +36,7 @@ services:
|
|||||||
DATA_DIR: /var/lib/vpn-proxy
|
DATA_DIR: /var/lib/vpn-proxy
|
||||||
SING_BOX_CONFIG: /var/lib/vpn-proxy/sing-box-config.json
|
SING_BOX_CONFIG: /var/lib/vpn-proxy/sing-box-config.json
|
||||||
SING_BOX_CACHE: /var/lib/sing-box/cache.db
|
SING_BOX_CACHE: /var/lib/sing-box/cache.db
|
||||||
|
SING_BOX_TRAFFIC_SOURCE: \${SING_BOX_TRAFFIC_SOURCE:-native}
|
||||||
DATAPLANE_SOCKET: /run/vpn-proxy/dataplane.sock
|
DATAPLANE_SOCKET: /run/vpn-proxy/dataplane.sock
|
||||||
volumes:
|
volumes:
|
||||||
- vpn-proxy-data:/var/lib/vpn-proxy
|
- vpn-proxy-data:/var/lib/vpn-proxy
|
||||||
@@ -59,6 +60,7 @@ services:
|
|||||||
DATA_DIR: /var/lib/vpn-proxy
|
DATA_DIR: /var/lib/vpn-proxy
|
||||||
SING_BOX_CONFIG: /var/lib/vpn-proxy/sing-box-config.json
|
SING_BOX_CONFIG: /var/lib/vpn-proxy/sing-box-config.json
|
||||||
SING_BOX_CACHE: /var/lib/sing-box/cache.db
|
SING_BOX_CACHE: /var/lib/sing-box/cache.db
|
||||||
|
SING_BOX_TRAFFIC_SOURCE: \${SING_BOX_TRAFFIC_SOURCE:-native}
|
||||||
DATAPLANE_SOCKET: /run/vpn-proxy/dataplane.sock
|
DATAPLANE_SOCKET: /run/vpn-proxy/dataplane.sock
|
||||||
ports:
|
ports:
|
||||||
- "\${PORT:-3456}:\${PORT:-3456}"
|
- "\${PORT:-3456}:\${PORT:-3456}"
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ export function affectedComponents(files) {
|
|||||||
const add = (...components) => components.forEach((component) => affected.add(component));
|
const add = (...components) => components.forEach((component) => affected.add(component));
|
||||||
for (const file of files) {
|
for (const file of files) {
|
||||||
if (file === VERSION_FILE) continue;
|
if (file === VERSION_FILE) continue;
|
||||||
if (/^(?:\.dockerignore$|package(?:-lock)?\.json$|tsconfig\.base\.json$|src\/shared\/)/.test(file)) add(...COMPONENTS);
|
if (/^(?:\.dockerignore$|\.node-version$|scripts\/check-sqlite-runtime\.mjs$|package(?:-lock)?\.json$|tsconfig\.base\.json$|src\/shared\/)/.test(file)) add(...COMPONENTS);
|
||||||
else if (/^(src\/web\/|public\/|monitoring\/grafana\/|index\.html$|tsconfig\.web\.json$|vite\.config\.[cm]?[jt]s$)/.test(file)) {
|
else if (/^(src\/web\/|public\/|monitoring\/grafana\/|index\.html$|tsconfig\.web\.json$|vite\.config\.[cm]?[jt]s$)/.test(file)) {
|
||||||
add('macClient', 'gatewayClient');
|
add('macClient', 'gatewayClient');
|
||||||
} else if (/^(src\/server\/|tsconfig\.server\.json$)/.test(file)) add('macClient', 'gatewayBackend');
|
} else if (/^(src\/server\/|tsconfig\.server\.json$)/.test(file)) add('macClient', 'gatewayBackend');
|
||||||
|
|||||||
@@ -9,7 +9,8 @@ COMPOSE_FILE="docker-compose.client.yml"
|
|||||||
DEFAULT_PROXY_PORT="8082"
|
DEFAULT_PROXY_PORT="8082"
|
||||||
REQUESTED_PROXY_PORT="${VPN_PROXY_CLIENT_PORT:-}"
|
REQUESTED_PROXY_PORT="${VPN_PROXY_CLIENT_PORT:-}"
|
||||||
REQUESTED_UI_PORT="${VPN_PROXY_CLIENT_UI_PORT:-${CLIENT_UI_PORT:-}}"
|
REQUESTED_UI_PORT="${VPN_PROXY_CLIENT_UI_PORT:-${CLIENT_UI_PORT:-}}"
|
||||||
TARGET_SINGBOX_VERSION="${SINGBOX_VERSION:-1.13.18}"
|
TARGET_SINGBOX_VERSION="${SINGBOX_VERSION:-1.14.0-rc.5}"
|
||||||
|
TARGET_TRAFFIC_SOURCE="${SING_BOX_TRAFFIC_SOURCE:-native}"
|
||||||
CLIENT_CONTAINER_NAME="harbor-connect"
|
CLIENT_CONTAINER_NAME="harbor-connect"
|
||||||
LEGACY_CLIENT_CONTAINER_NAME="vpn-proxy-client"
|
LEGACY_CLIENT_CONTAINER_NAME="vpn-proxy-client"
|
||||||
NETWORK_MONITOR_LABEL="com.dokril.harbor-connect.network"
|
NETWORK_MONITOR_LABEL="com.dokril.harbor-connect.network"
|
||||||
@@ -290,6 +291,11 @@ need curl
|
|||||||
need rsync
|
need rsync
|
||||||
need tar
|
need tar
|
||||||
|
|
||||||
|
case "$TARGET_TRAFFIC_SOURCE" in
|
||||||
|
native|disabled) ;;
|
||||||
|
*) die "SING_BOX_TRAFFIC_SOURCE must be native or disabled" ;;
|
||||||
|
esac
|
||||||
|
|
||||||
docker compose version >/dev/null 2>&1 || die "Docker Compose plugin is required"
|
docker compose version >/dev/null 2>&1 || die "Docker Compose plugin is required"
|
||||||
docker info >/dev/null 2>&1 || die "Docker Desktop is not running"
|
docker info >/dev/null 2>&1 || die "Docker Desktop is not running"
|
||||||
|
|
||||||
@@ -321,6 +327,7 @@ assert_ui_outside_proxy_range
|
|||||||
|
|
||||||
set_env_value APP_MODE client
|
set_env_value APP_MODE client
|
||||||
set_env_value SINGBOX_VERSION "$TARGET_SINGBOX_VERSION"
|
set_env_value SINGBOX_VERSION "$TARGET_SINGBOX_VERSION"
|
||||||
|
set_env_value SING_BOX_TRAFFIC_SOURCE "$TARGET_TRAFFIC_SOURCE"
|
||||||
set_env_value CLIENT_UI_PORT "$UI_PORT"
|
set_env_value CLIENT_UI_PORT "$UI_PORT"
|
||||||
set_env_value CLIENT_PROXY_PORT "$PROXY_PORT"
|
set_env_value CLIENT_PROXY_PORT "$PROXY_PORT"
|
||||||
set_env_value PROXY_PORT "$PROXY_PORT"
|
set_env_value PROXY_PORT "$PROXY_PORT"
|
||||||
|
|||||||
@@ -19,8 +19,11 @@ const noRuntimeImpact = [
|
|||||||
/^entrypoint\.client\.sh$/,
|
/^entrypoint\.client\.sh$/,
|
||||||
/^install\.sh$/,
|
/^install\.sh$/,
|
||||||
/^scripts\/(?:check-import-boundaries\.mjs|clean-test-dist\.mjs|harbor-network-monitor\.sh|harbor-version\.mjs|install-macos-client\.sh)$/,
|
/^scripts\/(?:check-import-boundaries\.mjs|clean-test-dist\.mjs|harbor-network-monitor\.sh|harbor-version\.mjs|install-macos-client\.sh)$/,
|
||||||
|
/^tools\/test-singbox-(?:client-rc|gateway-native-traffic|native-traffic)\.sh$/,
|
||||||
];
|
];
|
||||||
const foundation = [
|
const foundation = [
|
||||||
|
/^\.node-version$/,
|
||||||
|
/^scripts\/check-sqlite-runtime\.mjs$/,
|
||||||
/^\.dockerignore$/,
|
/^\.dockerignore$/,
|
||||||
/^\.gitea\/workflows\//,
|
/^\.gitea\/workflows\//,
|
||||||
/^Dockerfile(?:\.runtime-base)?$/,
|
/^Dockerfile(?:\.runtime-base)?$/,
|
||||||
@@ -32,11 +35,16 @@ const foundation = [
|
|||||||
/^tsconfig(?:\.[^.]+)?\.json$/,
|
/^tsconfig(?:\.[^.]+)?\.json$/,
|
||||||
];
|
];
|
||||||
const controlAndDataplane = [
|
const controlAndDataplane = [
|
||||||
|
/^src\/server\/services\/(?:sqlite|trafficHistoryStore|trafficHistoryService|trafficHistoryWorker)\.ts$/,
|
||||||
|
/^src\/shared\/trafficHistory\.ts$/,
|
||||||
|
/^buf\.gen\.yaml$/,
|
||||||
|
/^proto\//,
|
||||||
new RegExp(`^src/server/main${CODE_EXTENSION}`),
|
new RegExp(`^src/server/main${CODE_EXTENSION}`),
|
||||||
new RegExp(`^src/server/(?:config|gatewayRouting|singbox|singboxRuntime|version)${CODE_EXTENSION}`),
|
new RegExp(`^src/server/(?:config|gatewayNativeRuntime|gatewayRouting|singbox|singboxRuntime|version)${CODE_EXTENSION}`),
|
||||||
|
/^src\/server\/generated\//,
|
||||||
new RegExp(`^src/server/adapters/neighbors${CODE_EXTENSION}`),
|
new RegExp(`^src/server/adapters/neighbors${CODE_EXTENSION}`),
|
||||||
new RegExp(`^src/server/services/(?:connectivityDiagnosticsService|deviceInventoryService|devicePolicyService|singboxSelectorService)${CODE_EXTENSION}`),
|
new RegExp(`^src/server/services/(?:connectivityDiagnosticsService|deviceInventoryService|devicePolicyService|liveTrafficService|singboxSelectorService)${CODE_EXTENSION}`),
|
||||||
new RegExp(`^src/shared/(?:connectivityDiagnostics|errors)${CODE_EXTENSION}`),
|
new RegExp(`^src/shared/(?:connectivityDiagnostics|errors|liveTraffic)${CODE_EXTENSION}`),
|
||||||
/^src\/server\/infrastructure\/dataplane\//,
|
/^src\/server\/infrastructure\/dataplane\//,
|
||||||
];
|
];
|
||||||
const dataplane = [
|
const dataplane = [
|
||||||
|
|||||||
+24
-2
@@ -1,5 +1,7 @@
|
|||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
|
|
||||||
|
const appMode = process.env.APP_MODE === "client" ? "client" : "gateway";
|
||||||
|
const appComponent = process.env.APP_COMPONENT || "";
|
||||||
const dataDir = process.env.DATA_DIR || path.resolve(".vpn-proxy");
|
const dataDir = process.env.DATA_DIR || path.resolve(".vpn-proxy");
|
||||||
const parsePort = (value: string | undefined, fallback: number) => {
|
const parsePort = (value: string | undefined, fallback: number) => {
|
||||||
const parsed = Number.parseInt(value || '', 10);
|
const parsed = Number.parseInt(value || '', 10);
|
||||||
@@ -7,17 +9,33 @@ const parsePort = (value: string | undefined, fallback: number) => {
|
|||||||
};
|
};
|
||||||
const proxyPort = parsePort(
|
const proxyPort = parsePort(
|
||||||
process.env.PROXY_PORT,
|
process.env.PROXY_PORT,
|
||||||
process.env.APP_MODE === "client" ? 8082 : 8080,
|
appMode === "client" ? 8082 : 8080,
|
||||||
);
|
);
|
||||||
|
const trafficSource = process.env.SING_BOX_TRAFFIC_SOURCE
|
||||||
|
|| (appMode === "client" ? "native" : "snapshot");
|
||||||
|
if (appMode === "client" && trafficSource !== "native" && trafficSource !== "disabled") {
|
||||||
|
throw new Error("SING_BOX_TRAFFIC_SOURCE must be native or disabled in client mode");
|
||||||
|
}
|
||||||
|
if (appMode === "gateway" && !["snapshot", "shadow", "native"].includes(trafficSource)) {
|
||||||
|
throw new Error("SING_BOX_TRAFFIC_SOURCE must be snapshot, shadow or native in gateway mode");
|
||||||
|
}
|
||||||
|
if (appMode === "gateway" && trafficSource !== "snapshot"
|
||||||
|
&& ((appComponent !== "control" && appComponent !== "dataplane")
|
||||||
|
|| !process.env.DATAPLANE_SOCKET?.trim())) {
|
||||||
|
throw new Error("Gateway shadow and native traffic modes require split control/dataplane topology");
|
||||||
|
}
|
||||||
|
|
||||||
export const settings = {
|
export const settings = {
|
||||||
appMode: process.env.APP_MODE === "client" ? "client" : "gateway",
|
appMode,
|
||||||
|
appComponent,
|
||||||
port: parsePort(process.env.PORT, 3456),
|
port: parsePort(process.env.PORT, 3456),
|
||||||
proxyPort,
|
proxyPort,
|
||||||
diagnosticsProxyPort: parsePort(process.env.DIAGNOSTICS_PROXY_PORT, 18080),
|
diagnosticsProxyPort: parsePort(process.env.DIAGNOSTICS_PROXY_PORT, 18080),
|
||||||
failoverPrimaryProxyPort: parsePort(process.env.FAILOVER_PRIMARY_PROXY_PORT, 18081),
|
failoverPrimaryProxyPort: parsePort(process.env.FAILOVER_PRIMARY_PROXY_PORT, 18081),
|
||||||
failoverReserveProxyPort: parsePort(process.env.FAILOVER_RESERVE_PROXY_PORT, 18082),
|
failoverReserveProxyPort: parsePort(process.env.FAILOVER_RESERVE_PROXY_PORT, 18082),
|
||||||
singboxApiPort: parsePort(process.env.SING_BOX_API_PORT, 19090),
|
singboxApiPort: parsePort(process.env.SING_BOX_API_PORT, 19090),
|
||||||
|
singboxNativeApiPort: 19091,
|
||||||
|
singboxTrafficSource: trafficSource as "native" | "disabled" | "snapshot" | "shadow",
|
||||||
tproxyPort: parsePort(process.env.TPROXY_PORT, 7895),
|
tproxyPort: parsePort(process.env.TPROXY_PORT, 7895),
|
||||||
tproxyMark: process.env.TPROXY_MARK || "1",
|
tproxyMark: process.env.TPROXY_MARK || "1",
|
||||||
tproxyChain: process.env.TPROXY_CHAIN || "VPN_PROXY_TPROXY",
|
tproxyChain: process.env.TPROXY_CHAIN || "VPN_PROXY_TPROXY",
|
||||||
@@ -40,6 +58,10 @@ export const settings = {
|
|||||||
configPath:
|
configPath:
|
||||||
process.env.SING_BOX_CONFIG || path.join(dataDir, "sing-box-config.json"),
|
process.env.SING_BOX_CONFIG || path.join(dataDir, "sing-box-config.json"),
|
||||||
cachePath: process.env.SING_BOX_CACHE || "/var/lib/sing-box/cache.db",
|
cachePath: process.env.SING_BOX_CACHE || "/var/lib/sing-box/cache.db",
|
||||||
|
gatewayNativeApiSecretPath:
|
||||||
|
process.env.SING_BOX_API_SECRET || "/var/lib/sing-box/api.secret",
|
||||||
|
gatewayRuntimeConfigPath:
|
||||||
|
process.env.SING_BOX_RUNTIME_CONFIG || "/var/lib/sing-box/runtime-config.json",
|
||||||
statePath: path.join(dataDir, "state.json"),
|
statePath: path.join(dataDir, "state.json"),
|
||||||
deviceStatePath: path.join(dataDir, "devices.json"),
|
deviceStatePath: path.join(dataDir, "devices.json"),
|
||||||
activityJournalPath: path.join(dataDir, "activity-journal.json"),
|
activityJournalPath: path.join(dataDir, "activity-journal.json"),
|
||||||
|
|||||||
+224
-8
@@ -1,7 +1,9 @@
|
|||||||
import fs from 'node:fs';
|
import fs from 'node:fs';
|
||||||
import http from 'node:http';
|
import http from 'node:http';
|
||||||
|
import net from 'node:net';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import type { IncomingMessage, ServerResponse } from 'node:http';
|
import type { IncomingMessage, ServerResponse } from 'node:http';
|
||||||
|
import type { LiveTrafficConnection, LiveTrafficSnapshot } from '../shared/liveTraffic.js';
|
||||||
import { settings } from './config.js';
|
import { settings } from './config.js';
|
||||||
import { createSingboxRuntime } from './singboxRuntime.js';
|
import { createSingboxRuntime } from './singboxRuntime.js';
|
||||||
import { buildVersionInfo } from './version.js';
|
import { buildVersionInfo } from './version.js';
|
||||||
@@ -9,17 +11,34 @@ import { readNeighborSnapshot } from './adapters/neighbors.js';
|
|||||||
import { createDeviceTrafficService } from './services/deviceTrafficService.js';
|
import { createDeviceTrafficService } from './services/deviceTrafficService.js';
|
||||||
import { createDevicePolicyService } from './services/devicePolicyService.js';
|
import { createDevicePolicyService } from './services/devicePolicyService.js';
|
||||||
import { createConnectivityDiagnosticsService } from './services/connectivityDiagnosticsService.js';
|
import { createConnectivityDiagnosticsService } from './services/connectivityDiagnosticsService.js';
|
||||||
|
import { createDnsDiagnosticsService } from './services/dnsDiagnosticsService.js';
|
||||||
import {
|
import {
|
||||||
createDomainTrafficService,
|
createDomainTrafficService,
|
||||||
readSingboxConnections,
|
readSingboxConnections,
|
||||||
} from './services/domainTrafficService.js';
|
} from './services/domainTrafficService.js';
|
||||||
|
import { deviceId } from './services/deviceInventoryService.js';
|
||||||
|
import {
|
||||||
|
createLiveTrafficService,
|
||||||
|
} from './services/liveTrafficService.js';
|
||||||
import { createSingboxSelectorService } from './services/singboxSelectorService.js';
|
import { createSingboxSelectorService } from './services/singboxSelectorService.js';
|
||||||
|
import { createTrafficHistoryService } from './services/trafficHistoryService.js';
|
||||||
|
import { parseTrafficHistoryQuery } from '../shared/trafficHistory.js';
|
||||||
|
|
||||||
const socketPath = settings.dataplaneSocket;
|
const socketPath = settings.dataplaneSocket;
|
||||||
|
const trafficMode = settings.singboxTrafficSource as 'snapshot' | 'shadow' | 'native';
|
||||||
|
const nativeTrafficEnabled = trafficMode === 'shadow' || trafficMode === 'native';
|
||||||
const runtime = createSingboxRuntime({
|
const runtime = createSingboxRuntime({
|
||||||
configPath: settings.configPath,
|
configPath: settings.configPath,
|
||||||
gateway: true,
|
gateway: true,
|
||||||
tproxyChain: settings.tproxyChain,
|
tproxyChain: settings.tproxyChain,
|
||||||
|
gatewayRuntimeConfigPath: settings.gatewayRuntimeConfigPath,
|
||||||
|
...(nativeTrafficEnabled ? {
|
||||||
|
nativeApi: {
|
||||||
|
apiPort: settings.singboxNativeApiPort,
|
||||||
|
secretPath: settings.gatewayNativeApiSecretPath,
|
||||||
|
runtimeConfigPath: settings.gatewayRuntimeConfigPath,
|
||||||
|
},
|
||||||
|
} : {}),
|
||||||
});
|
});
|
||||||
const versionInfo = buildVersionInfo('gateway');
|
const versionInfo = buildVersionInfo('gateway');
|
||||||
const traffic = createDeviceTrafficService({
|
const traffic = createDeviceTrafficService({
|
||||||
@@ -41,15 +60,35 @@ const devicePolicy = createDevicePolicyService({
|
|||||||
const connectivityDiagnostics = createConnectivityDiagnosticsService({
|
const connectivityDiagnostics = createConnectivityDiagnosticsService({
|
||||||
proxyPort: settings.diagnosticsProxyPort,
|
proxyPort: settings.diagnosticsProxyPort,
|
||||||
});
|
});
|
||||||
|
const dnsDiagnostics = createDnsDiagnosticsService({
|
||||||
|
proxyPort: settings.diagnosticsProxyPort,
|
||||||
|
});
|
||||||
const failoverDiagnostics = {
|
const failoverDiagnostics = {
|
||||||
primary: createConnectivityDiagnosticsService({ proxyPort: settings.failoverPrimaryProxyPort }),
|
primary: createConnectivityDiagnosticsService({ proxyPort: settings.failoverPrimaryProxyPort }),
|
||||||
reserve: createConnectivityDiagnosticsService({ proxyPort: settings.failoverReserveProxyPort }),
|
reserve: createConnectivityDiagnosticsService({ proxyPort: settings.failoverReserveProxyPort }),
|
||||||
};
|
};
|
||||||
const selector = createSingboxSelectorService({ port: settings.singboxApiPort });
|
const selector = createSingboxSelectorService({ port: settings.singboxApiPort });
|
||||||
const domainTraffic = createDomainTrafficService({
|
const snapshotDomainTraffic = createDomainTrafficService({
|
||||||
observe: () => readSingboxConnections(settings.singboxApiPort),
|
observe: () => readSingboxConnections(settings.singboxApiPort),
|
||||||
devices: () => traffic.snapshot().devices,
|
devices: () => traffic.snapshot().devices,
|
||||||
});
|
});
|
||||||
|
const nativeDomainTraffic = createDomainTrafficService({
|
||||||
|
observe: () => ({ connections: [] }),
|
||||||
|
devices: () => traffic.snapshot().devices,
|
||||||
|
});
|
||||||
|
const domainTraffic = trafficMode === 'native' ? nativeDomainTraffic : snapshotDomainTraffic;
|
||||||
|
let originsByIp = new Map<string, LiveTrafficConnection['origin'] | null>();
|
||||||
|
let liveTraffic = createLiveTrafficService({
|
||||||
|
port: settings.singboxNativeApiPort,
|
||||||
|
enabled: false,
|
||||||
|
gateway: true,
|
||||||
|
isRuntimeRunning: () => false,
|
||||||
|
resolveOrigin,
|
||||||
|
});
|
||||||
|
const trafficHistory = createTrafficHistoryService({
|
||||||
|
filePath: path.join(settings.dataDir, 'traffic.sqlite'),
|
||||||
|
source: () => liveTrafficSnapshot().source.state,
|
||||||
|
});
|
||||||
let ready = false;
|
let ready = false;
|
||||||
let trafficTimer: NodeJS.Timeout | null = null;
|
let trafficTimer: NodeJS.Timeout | null = null;
|
||||||
let domainTrafficTimer: NodeJS.Timeout | null = null;
|
let domainTrafficTimer: NodeJS.Timeout | null = null;
|
||||||
@@ -65,6 +104,133 @@ function errorMessage(error: unknown) {
|
|||||||
return error instanceof Error ? error.message : String(error);
|
return error instanceof Error ? error.message : String(error);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function updateOrigins(devices: unknown) {
|
||||||
|
const next = new Map<string, LiveTrafficConnection['origin'] | null>();
|
||||||
|
for (const value of Array.isArray(devices) ? devices : []) {
|
||||||
|
const device = record(value);
|
||||||
|
const ip = String(device.ip || '');
|
||||||
|
const mac = String(device.mac || '').toLowerCase();
|
||||||
|
if (!net.isIPv4(ip) || !/^[0-9a-f]{2}(?::[0-9a-f]{2}){5}$/.test(mac)) continue;
|
||||||
|
const origin: LiveTrafficConnection['origin'] = {
|
||||||
|
kind: 'device',
|
||||||
|
id: deviceId(mac),
|
||||||
|
label: ip,
|
||||||
|
provenance: 'source-ip',
|
||||||
|
};
|
||||||
|
next.set(ip, next.has(ip) ? null : origin);
|
||||||
|
}
|
||||||
|
originsByIp = next;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveOrigin(sourceIp: string): LiveTrafficConnection['origin'] {
|
||||||
|
return originsByIp.get(sourceIp) || {
|
||||||
|
kind: 'unknown',
|
||||||
|
id: null,
|
||||||
|
label: 'Неизвестное устройство',
|
||||||
|
provenance: 'unknown',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshDeviceTraffic() {
|
||||||
|
try {
|
||||||
|
return await traffic.refresh();
|
||||||
|
} finally {
|
||||||
|
refreshOrigins();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function refreshOrigins() {
|
||||||
|
updateOrigins(readNeighborSnapshot().observations);
|
||||||
|
}
|
||||||
|
|
||||||
|
function decimal(value: unknown) {
|
||||||
|
return typeof value === 'string' && /^\d+$/.test(value) ? BigInt(value) : 0n;
|
||||||
|
}
|
||||||
|
|
||||||
|
function trackedTotals(snapshot: unknown) {
|
||||||
|
let upload = 0n;
|
||||||
|
let download = 0n;
|
||||||
|
for (const value of Array.isArray(record(snapshot).tracked) ? record(snapshot).tracked as unknown[] : []) {
|
||||||
|
const entry = record(value);
|
||||||
|
upload += decimal(entry.uploadBytes);
|
||||||
|
download += decimal(entry.downloadBytes);
|
||||||
|
}
|
||||||
|
return { upload, download };
|
||||||
|
}
|
||||||
|
|
||||||
|
function mismatchCount(left: unknown, right: unknown, fields: string[]) {
|
||||||
|
const entries = (value: unknown) => {
|
||||||
|
const values = Array.isArray(value) ? value : [];
|
||||||
|
return new Map(values.map((item) => {
|
||||||
|
const entry = record(item);
|
||||||
|
const key = fields.map((field) => String(entry[field] || '')).join('\0');
|
||||||
|
return [key, `${entry.uploadBytes || '0'}\0${entry.downloadBytes || '0'}`];
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
const leftEntries = entries(left);
|
||||||
|
const rightEntries = entries(right);
|
||||||
|
const keys = new Set([...leftEntries.keys(), ...rightEntries.keys()]);
|
||||||
|
let mismatches = 0;
|
||||||
|
for (const key of keys) if (leftEntries.get(key) !== rightEntries.get(key)) mismatches += 1;
|
||||||
|
return mismatches;
|
||||||
|
}
|
||||||
|
|
||||||
|
function liveTrafficSnapshot(): LiveTrafficSnapshot {
|
||||||
|
const snapshot = liveTraffic.snapshot();
|
||||||
|
return nativeTrafficEnabled && runtime.nativeApiWarning ? {
|
||||||
|
...snapshot,
|
||||||
|
source: {
|
||||||
|
...snapshot.source,
|
||||||
|
state: 'incompatible',
|
||||||
|
error: runtime.nativeApiWarning,
|
||||||
|
},
|
||||||
|
} : snapshot;
|
||||||
|
}
|
||||||
|
|
||||||
|
function trafficCollectorSource() {
|
||||||
|
const canonical = domainTraffic.snapshot();
|
||||||
|
const canonicalSource = record(canonical.source);
|
||||||
|
const nativeLive = nativeTrafficEnabled ? liveTrafficSnapshot() : null;
|
||||||
|
const nativeProjection = nativeDomainTraffic.snapshot();
|
||||||
|
const legacyProjection = snapshotDomainTraffic.snapshot();
|
||||||
|
let shadow = null;
|
||||||
|
if (trafficMode === 'shadow') {
|
||||||
|
const nativeTotals = trackedTotals(nativeProjection);
|
||||||
|
const legacyTotals = trackedTotals(legacyProjection);
|
||||||
|
shadow = {
|
||||||
|
activeDifference: Number(record(nativeProjection.source).activeConnections || 0)
|
||||||
|
- Number(record(legacyProjection.source).activeConnections || 0),
|
||||||
|
uploadDifferenceBytes: (nativeTotals.upload - legacyTotals.upload).toString(),
|
||||||
|
downloadDifferenceBytes: (nativeTotals.download - legacyTotals.download).toString(),
|
||||||
|
routeMismatches: mismatchCount(nativeProjection.tracked, legacyProjection.tracked, ['source', 'outbound']),
|
||||||
|
deviceMismatches: mismatchCount(nativeProjection.routes, legacyProjection.routes, ['deviceId', 'source', 'outbound']),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
error: runtime.nativeApiWarning
|
||||||
|
|| (trafficMode === 'native' ? nativeLive?.source.error : canonicalSource.error)
|
||||||
|
|| null,
|
||||||
|
mode: trafficMode,
|
||||||
|
writer: trafficMode === 'native' ? 'native' as const : 'snapshot' as const,
|
||||||
|
activeConnections: Number(canonicalSource.activeConnections || 0),
|
||||||
|
native: nativeLive ? {
|
||||||
|
state: nativeLive.source.state,
|
||||||
|
epoch: nativeLive.epoch,
|
||||||
|
sequence: nativeLive.sequence,
|
||||||
|
observedAt: nativeLive.observedAt,
|
||||||
|
active: nativeLive.summary.active,
|
||||||
|
unattributedUploadBytes: nativeLive.source.unattributedUploadBytes,
|
||||||
|
unattributedDownloadBytes: nativeLive.source.unattributedDownloadBytes,
|
||||||
|
} : null,
|
||||||
|
shadow,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function domainTrafficSnapshot() {
|
||||||
|
const snapshot = domainTraffic.snapshot();
|
||||||
|
return { ...snapshot, source: trafficCollectorSource() };
|
||||||
|
}
|
||||||
|
|
||||||
function readJson(req: IncomingMessage): Promise<unknown> {
|
function readJson(req: IncomingMessage): Promise<unknown> {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const chunks: Buffer[] = [];
|
const chunks: Buffer[] = [];
|
||||||
@@ -105,6 +271,7 @@ const server = http.createServer(async (req: IncomingMessage, res: ServerRespons
|
|||||||
gatewayBackendVersion: versionInfo.components.gatewayBackend,
|
gatewayBackendVersion: versionInfo.components.gatewayBackend,
|
||||||
singBoxVersion: versionInfo.runtime.singBox,
|
singBoxVersion: versionInfo.runtime.singBox,
|
||||||
devicePolicy: devicePolicy.snapshot(),
|
devicePolicy: devicePolicy.snapshot(),
|
||||||
|
trafficCollector: trafficCollectorSource(),
|
||||||
ready,
|
ready,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -115,7 +282,14 @@ const server = http.createServer(async (req: IncomingMessage, res: ServerRespons
|
|||||||
return sendJson(res, 200, traffic.snapshot());
|
return sendJson(res, 200, traffic.snapshot());
|
||||||
}
|
}
|
||||||
if (req.method === 'GET' && req.url === '/domain-traffic') {
|
if (req.method === 'GET' && req.url === '/domain-traffic') {
|
||||||
return sendJson(res, 200, domainTraffic.snapshot());
|
return sendJson(res, 200, domainTrafficSnapshot());
|
||||||
|
}
|
||||||
|
if (req.method === 'GET' && req.url === '/traffic/live') {
|
||||||
|
return sendJson(res, 200, liveTrafficSnapshot());
|
||||||
|
}
|
||||||
|
const url = new URL(req.url || '/', 'http://localhost');
|
||||||
|
if (req.method === 'GET' && url.pathname === '/traffic/history') {
|
||||||
|
return sendJson(res, 200, await trafficHistory.query(parseTrafficHistoryQuery(url.searchParams)));
|
||||||
}
|
}
|
||||||
if (req.method === 'GET' && req.url === '/device-policy') {
|
if (req.method === 'GET' && req.url === '/device-policy') {
|
||||||
return sendJson(res, 200, devicePolicy.snapshot());
|
return sendJson(res, 200, devicePolicy.snapshot());
|
||||||
@@ -132,6 +306,25 @@ const server = http.createServer(async (req: IncomingMessage, res: ServerRespons
|
|||||||
target,
|
target,
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
if (req.method === 'POST' && req.url === '/diagnostics/dns/catalog') {
|
||||||
|
const { customResolvers = [], customDomains = [] } = record(await readJson(req));
|
||||||
|
return sendJson(res, 200, await dnsDiagnostics.catalog(
|
||||||
|
Array.isArray(customResolvers) ? customResolvers : [],
|
||||||
|
Array.isArray(customDomains) ? customDomains : [],
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if (req.method === 'POST' && req.url === '/diagnostics/dns') {
|
||||||
|
const {
|
||||||
|
customResolvers = [], customDomains = [], domainId, resolverId = null,
|
||||||
|
} = record(await readJson(req));
|
||||||
|
return sendJson(res, 200, await dnsDiagnostics.run({
|
||||||
|
vpnAvailable: runtime.running,
|
||||||
|
customResolvers: Array.isArray(customResolvers) ? customResolvers : [],
|
||||||
|
customDomains: Array.isArray(customDomains) ? customDomains : [],
|
||||||
|
domainId,
|
||||||
|
resolverId,
|
||||||
|
}));
|
||||||
|
}
|
||||||
if (req.method === 'POST' && req.url === '/failover/probe') {
|
if (req.method === 'POST' && req.url === '/failover/probe') {
|
||||||
const { role, services = [], target = null, timeoutMs = 6_000 } = record(await readJson(req));
|
const { role, services = [], target = null, timeoutMs = 6_000 } = record(await readJson(req));
|
||||||
if (role !== 'primary' && role !== 'reserve') throw new Error('Неизвестная failover role');
|
if (role !== 'primary' && role !== 'reserve') throw new Error('Неизвестная failover role');
|
||||||
@@ -153,7 +346,10 @@ const server = http.createServer(async (req: IncomingMessage, res: ServerRespons
|
|||||||
}
|
}
|
||||||
if (req.method === 'POST' && req.url === '/failover/activity/read') {
|
if (req.method === 'POST' && req.url === '/failover/activity/read') {
|
||||||
const { thresholdBytesPerSecond = 0 } = record(await readJson(req));
|
const { thresholdBytesPerSecond = 0 } = record(await readJson(req));
|
||||||
return sendJson(res, 200, { activity: domainTraffic.activitySnapshot(thresholdBytesPerSecond) });
|
const sourceLive = trafficMode !== 'native' || liveTrafficSnapshot().source.state === 'live';
|
||||||
|
return sendJson(res, 200, {
|
||||||
|
activity: sourceLive ? domainTraffic.activitySnapshot(thresholdBytesPerSecond) : null,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
if (req.method === 'POST' && req.url === '/config/check') {
|
if (req.method === 'POST' && req.url === '/config/check') {
|
||||||
const { config } = record(await readJson(req));
|
const { config } = record(await readJson(req));
|
||||||
@@ -183,27 +379,45 @@ server.listen(socketPath, async () => {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn(`[dataplane] sing-box не запущен: ${errorMessage(error)}`);
|
console.warn(`[dataplane] sing-box не запущен: ${errorMessage(error)}`);
|
||||||
} finally {
|
} finally {
|
||||||
|
refreshOrigins();
|
||||||
|
liveTraffic = createLiveTrafficService({
|
||||||
|
port: settings.singboxNativeApiPort,
|
||||||
|
enabled: nativeTrafficEnabled,
|
||||||
|
gateway: true,
|
||||||
|
isRuntimeRunning: () => runtime.running && !runtime.nativeApiWarning,
|
||||||
|
resolveOrigin,
|
||||||
|
authorization: () => runtime.nativeApiSecret,
|
||||||
|
onProjection: (batch) => {
|
||||||
|
trafficHistory.enqueue(batch);
|
||||||
|
nativeDomainTraffic.ingestNative(batch);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
liveTraffic.start();
|
||||||
ready = true;
|
ready = true;
|
||||||
if (settings.deviceTrafficAccountingEnabled) {
|
if (settings.deviceTrafficAccountingEnabled) {
|
||||||
setImmediate(() => {
|
setImmediate(() => {
|
||||||
traffic.refresh()
|
refreshDeviceTraffic()
|
||||||
.catch((error: unknown) => console.warn(`[dataplane] traffic counters не запущены: ${errorMessage(error)}`));
|
.catch((error: unknown) => console.warn(`[dataplane] traffic counters не запущены: ${errorMessage(error)}`));
|
||||||
});
|
});
|
||||||
trafficTimer = setInterval(() => {
|
trafficTimer = setInterval(() => {
|
||||||
traffic.refresh().catch((error: unknown) => console.warn(`[dataplane] traffic counters не обновлены: ${errorMessage(error)}`));
|
refreshDeviceTraffic().catch((error: unknown) => console.warn(`[dataplane] traffic counters не обновлены: ${errorMessage(error)}`));
|
||||||
}, 15_000);
|
}, 15_000);
|
||||||
trafficTimer.unref();
|
trafficTimer.unref();
|
||||||
|
} else {
|
||||||
|
trafficTimer = setInterval(refreshOrigins, 15_000);
|
||||||
|
trafficTimer.unref();
|
||||||
}
|
}
|
||||||
|
if (trafficMode !== 'native') {
|
||||||
setImmediate(() => {
|
setImmediate(() => {
|
||||||
domainTraffic.refresh()
|
snapshotDomainTraffic.refresh()
|
||||||
.catch((error: unknown) => console.warn(`[dataplane] domain traffic не запущен: ${errorMessage(error)}`));
|
.catch((error: unknown) => console.warn(`[dataplane] domain traffic не запущен: ${errorMessage(error)}`));
|
||||||
});
|
});
|
||||||
// ponytail: snapshots can miss connections shorter than 2s; switch to an upstream close-event API if sing-box adds one.
|
|
||||||
domainTrafficTimer = setInterval(() => {
|
domainTrafficTimer = setInterval(() => {
|
||||||
domainTraffic.refresh()
|
snapshotDomainTraffic.refresh()
|
||||||
.catch((error: unknown) => console.warn(`[dataplane] domain traffic не обновлён: ${errorMessage(error)}`));
|
.catch((error: unknown) => console.warn(`[dataplane] domain traffic не обновлён: ${errorMessage(error)}`));
|
||||||
}, 2_000);
|
}, 2_000);
|
||||||
domainTrafficTimer.unref();
|
domainTrafficTimer.unref();
|
||||||
|
}
|
||||||
console.log(`[dataplane] control socket: ${socketPath}`);
|
console.log(`[dataplane] control socket: ${socketPath}`);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -215,6 +429,8 @@ async function shutdown() {
|
|||||||
ready = false;
|
ready = false;
|
||||||
if (trafficTimer) clearInterval(trafficTimer);
|
if (trafficTimer) clearInterval(trafficTimer);
|
||||||
if (domainTrafficTimer) clearInterval(domainTrafficTimer);
|
if (domainTrafficTimer) clearInterval(domainTrafficTimer);
|
||||||
|
await liveTraffic.stop();
|
||||||
|
await trafficHistory.close();
|
||||||
await runtime.shutdown();
|
await runtime.shutdown();
|
||||||
server.close(() => {
|
server.close(() => {
|
||||||
fs.rmSync(socketPath, { force: true });
|
fs.rmSync(socketPath, { force: true });
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import http from 'node:http';
|
import http from 'node:http';
|
||||||
import { HarborError } from '../shared/errors.js';
|
import { HarborError } from '../shared/errors.js';
|
||||||
|
import { historyQueryParams, type TrafficHistoryQuery } from '../shared/trafficHistory.js';
|
||||||
|
|
||||||
type SendDataplaneRequest = (
|
type SendDataplaneRequest = (
|
||||||
socketPath: string,
|
socketPath: string,
|
||||||
@@ -73,6 +74,8 @@ export function createDataplaneClient(socketPath: string, send: SendDataplaneReq
|
|||||||
observeDevices: () => send(socketPath, '/devices', 'GET'),
|
observeDevices: () => send(socketPath, '/devices', 'GET'),
|
||||||
observeTraffic: () => send(socketPath, '/device-traffic', 'GET'),
|
observeTraffic: () => send(socketPath, '/device-traffic', 'GET'),
|
||||||
observeDomainTraffic: () => send(socketPath, '/domain-traffic', 'GET'),
|
observeDomainTraffic: () => send(socketPath, '/domain-traffic', 'GET'),
|
||||||
|
observeLiveTraffic: () => send(socketPath, '/traffic/live', 'GET'),
|
||||||
|
observeTrafficHistory: (query: TrafficHistoryQuery) => send(socketPath, `/traffic/history?${historyQueryParams(query)}`, 'GET', null, 12_000),
|
||||||
observeDevicePolicy: () => send(socketPath, '/device-policy', 'GET'),
|
observeDevicePolicy: () => send(socketPath, '/device-policy', 'GET'),
|
||||||
applyDevicePolicies: (devices: unknown) => send(socketPath, '/device-policy', 'PUT', { devices }),
|
applyDevicePolicies: (devices: unknown) => send(socketPath, '/device-policy', 'PUT', { devices }),
|
||||||
runConnectivityDiagnostics: async (services: unknown = [], target: unknown = null) => {
|
runConnectivityDiagnostics: async (services: unknown = [], target: unknown = null) => {
|
||||||
@@ -82,6 +85,23 @@ export function createDataplaneClient(socketPath: string, send: SendDataplaneReq
|
|||||||
throw new HarborError('DIAGNOSTICS_FAILED', { cause });
|
throw new HarborError('DIAGNOSTICS_FAILED', { cause });
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
getDnsDiagnosticsCatalog: (customResolvers: unknown = [], customDomains: unknown = []) => (
|
||||||
|
send(socketPath, '/diagnostics/dns/catalog', 'POST', { customResolvers, customDomains })
|
||||||
|
),
|
||||||
|
runDnsDiagnostics: async (
|
||||||
|
customResolvers: unknown = [],
|
||||||
|
customDomains: unknown = [],
|
||||||
|
domainId: unknown,
|
||||||
|
resolverId: unknown = null,
|
||||||
|
) => {
|
||||||
|
try {
|
||||||
|
return await send(socketPath, '/diagnostics/dns', 'POST', {
|
||||||
|
customResolvers, customDomains, domainId, resolverId,
|
||||||
|
}, 40_000);
|
||||||
|
} catch (cause) {
|
||||||
|
throw new HarborError('DIAGNOSTICS_FAILED', { cause });
|
||||||
|
}
|
||||||
|
},
|
||||||
checkConfig: (config: unknown) => send(socketPath, '/config/check', 'POST', { config }, 15_000),
|
checkConfig: (config: unknown) => send(socketPath, '/config/check', 'POST', { config }, 15_000),
|
||||||
runFailoverProbe: (role: 'primary' | 'reserve', services: unknown, target: unknown, timeoutMs: number) => (
|
runFailoverProbe: (role: 'primary' | 'reserve', services: unknown, target: unknown, timeoutMs: number) => (
|
||||||
send(socketPath, '/failover/probe', 'POST', { role, services, target, timeoutMs }, timeoutMs + 10_000)
|
send(socketPath, '/failover/probe', 'POST', { role, services, target, timeoutMs }, timeoutMs + 10_000)
|
||||||
|
|||||||
@@ -27,6 +27,13 @@ interface ConnectivityDiagnosticsDependencies {
|
|||||||
update(mutator: (state: StoredState) => Record<string, unknown>): StoredState;
|
update(mutator: (state: StoredState) => Record<string, unknown>): StoredState;
|
||||||
};
|
};
|
||||||
runDiagnostics(services: unknown, target: unknown): Promise<unknown>;
|
runDiagnostics(services: unknown, target: unknown): Promise<unknown>;
|
||||||
|
dnsCatalog(customResolvers: unknown, customDomains: unknown): Promise<unknown>;
|
||||||
|
runDnsDiagnostics(
|
||||||
|
customResolvers: unknown,
|
||||||
|
customDomains: unknown,
|
||||||
|
domainId: unknown,
|
||||||
|
resolverId: unknown,
|
||||||
|
): Promise<unknown>;
|
||||||
}
|
}
|
||||||
|
|
||||||
function diagnosticsResult(value: unknown): DiagnosticsResult {
|
function diagnosticsResult(value: unknown): DiagnosticsResult {
|
||||||
@@ -66,21 +73,38 @@ export function createConnectivityDiagnosticsUseCase(
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
async dnsCatalog() {
|
||||||
|
const settings = normalizeDiagnosticSettings(dependencies.state.read().diagnostics);
|
||||||
|
return dependencies.dnsCatalog(settings.customDnsResolvers, settings.customDnsDomains);
|
||||||
|
},
|
||||||
|
async runDns(domainId: unknown, resolverId: unknown) {
|
||||||
|
const settings = normalizeDiagnosticSettings(dependencies.state.read().diagnostics);
|
||||||
|
return dependencies.runDnsDiagnostics(
|
||||||
|
settings.customDnsResolvers,
|
||||||
|
settings.customDnsDomains,
|
||||||
|
domainId,
|
||||||
|
resolverId,
|
||||||
|
);
|
||||||
|
},
|
||||||
updateSettings(settings: unknown, expectedRevision: unknown) {
|
updateSettings(settings: unknown, expectedRevision: unknown) {
|
||||||
if (!Number.isSafeInteger(expectedRevision) || Number(expectedRevision) < 0) {
|
if (!Number.isSafeInteger(expectedRevision) || Number(expectedRevision) < 0) {
|
||||||
throw new HarborError('REQUEST_INVALID');
|
throw new HarborError('REQUEST_INVALID');
|
||||||
}
|
}
|
||||||
|
const current = dependencies.state.read();
|
||||||
|
if (current.revision !== expectedRevision) throw new HarborError('STATE_CONFLICT');
|
||||||
let diagnostics: DiagnosticSettings;
|
let diagnostics: DiagnosticSettings;
|
||||||
try {
|
try {
|
||||||
const requested = settings && typeof settings === 'object' && !Array.isArray(settings)
|
const requested = settings && typeof settings === 'object' && !Array.isArray(settings)
|
||||||
? settings as Record<string, unknown>
|
? settings as Record<string, unknown>
|
||||||
: {};
|
: {};
|
||||||
diagnostics = normalizeDiagnosticSettings({ ...requested, configured: true }, { strict: true });
|
diagnostics = normalizeDiagnosticSettings({
|
||||||
|
...normalizeDiagnosticSettings(current.diagnostics),
|
||||||
|
...requested,
|
||||||
|
configured: true,
|
||||||
|
}, { strict: true });
|
||||||
} catch (cause) {
|
} catch (cause) {
|
||||||
throw new HarborError('REQUEST_INVALID', { cause });
|
throw new HarborError('REQUEST_INVALID', { cause });
|
||||||
}
|
}
|
||||||
const current = dependencies.state.read();
|
|
||||||
if (current.revision !== expectedRevision) throw new HarborError('STATE_CONFLICT');
|
|
||||||
if (isDeepStrictEqual(current.diagnostics, diagnostics)) return;
|
if (isDeepStrictEqual(current.diagnostics, diagnostics)) return;
|
||||||
dependencies.state.update((state) => ({ ...state, diagnostics }));
|
dependencies.state.update((state) => ({ ...state, diagnostics }));
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1,163 @@
|
|||||||
|
import crypto from 'node:crypto';
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import os from 'node:os';
|
||||||
|
import path from 'node:path';
|
||||||
|
|
||||||
|
interface MaterializeOptions {
|
||||||
|
apiPort: number;
|
||||||
|
secretPath: string;
|
||||||
|
runtimeConfigPath: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function record(value: unknown): Record<string, unknown> {
|
||||||
|
return value && typeof value === 'object' && !Array.isArray(value)
|
||||||
|
? value as Record<string, unknown>
|
||||||
|
: {};
|
||||||
|
}
|
||||||
|
|
||||||
|
function message(error: unknown) {
|
||||||
|
return error instanceof Error ? error.message : String(error);
|
||||||
|
}
|
||||||
|
|
||||||
|
function privateWrite(filePath: string, value: unknown) {
|
||||||
|
fs.mkdirSync(path.dirname(filePath), { recursive: true, mode: 0o700 });
|
||||||
|
const temporaryPath = `${filePath}.${process.pid}.${crypto.randomBytes(8).toString('hex')}.tmp`;
|
||||||
|
let descriptor: number | null = null;
|
||||||
|
try {
|
||||||
|
descriptor = fs.openSync(
|
||||||
|
temporaryPath,
|
||||||
|
fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_EXCL | fs.constants.O_NOFOLLOW,
|
||||||
|
0o600,
|
||||||
|
);
|
||||||
|
fs.writeFileSync(descriptor, JSON.stringify(value));
|
||||||
|
fs.fchmodSync(descriptor, 0o600);
|
||||||
|
fs.fsyncSync(descriptor);
|
||||||
|
fs.closeSync(descriptor);
|
||||||
|
descriptor = null;
|
||||||
|
fs.renameSync(temporaryPath, filePath);
|
||||||
|
fs.chmodSync(filePath, 0o600);
|
||||||
|
const status = fs.lstatSync(filePath);
|
||||||
|
if (!status.isFile() || status.isSymbolicLink() || (status.mode & 0o777) !== 0o600) {
|
||||||
|
throw new Error('private runtime config is not a regular 0600 file');
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (descriptor !== null) fs.closeSync(descriptor);
|
||||||
|
fs.rmSync(temporaryPath, { force: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function openSecret(secretPath: string) {
|
||||||
|
const readFlags = fs.constants.O_RDWR | fs.constants.O_NOFOLLOW;
|
||||||
|
try {
|
||||||
|
return { descriptor: fs.openSync(secretPath, readFlags), created: false };
|
||||||
|
} catch (error) {
|
||||||
|
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return {
|
||||||
|
descriptor: fs.openSync(
|
||||||
|
secretPath,
|
||||||
|
readFlags | fs.constants.O_CREAT | fs.constants.O_EXCL,
|
||||||
|
0o600,
|
||||||
|
),
|
||||||
|
created: true,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
if ((error as NodeJS.ErrnoException).code !== 'EEXIST') throw error;
|
||||||
|
return { descriptor: fs.openSync(secretPath, readFlags), created: false };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ensureGatewayNativeApiSecret(secretPath: string) {
|
||||||
|
fs.mkdirSync(path.dirname(secretPath), { recursive: true, mode: 0o700 });
|
||||||
|
const { descriptor, created } = openSecret(secretPath);
|
||||||
|
try {
|
||||||
|
const secret = created
|
||||||
|
? crypto.randomBytes(32).toString('hex')
|
||||||
|
: fs.readFileSync(descriptor, 'utf8');
|
||||||
|
if (created) {
|
||||||
|
fs.writeFileSync(descriptor, secret);
|
||||||
|
fs.fsyncSync(descriptor);
|
||||||
|
}
|
||||||
|
if (!/^[0-9a-f]{64}$/.test(secret)) {
|
||||||
|
throw new Error('native API secret must contain exactly 64 lowercase hex characters');
|
||||||
|
}
|
||||||
|
fs.fchmodSync(descriptor, 0o600);
|
||||||
|
const opened = fs.fstatSync(descriptor);
|
||||||
|
const linked = fs.lstatSync(secretPath);
|
||||||
|
if (!opened.isFile() || linked.isSymbolicLink() || !linked.isFile()
|
||||||
|
|| opened.dev !== linked.dev || opened.ino !== linked.ino
|
||||||
|
|| (opened.mode & 0o777) !== 0o600 || (linked.mode & 0o777) !== 0o600) {
|
||||||
|
throw new Error('native API secret is not a regular 0600 file');
|
||||||
|
}
|
||||||
|
return secret;
|
||||||
|
} finally {
|
||||||
|
fs.closeSync(descriptor);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function withoutApiServices(config: unknown) {
|
||||||
|
const safe = structuredClone(record(config));
|
||||||
|
const services = Array.isArray(safe.services)
|
||||||
|
? safe.services.filter((service) => record(service).type !== 'api')
|
||||||
|
: [];
|
||||||
|
if (services.length) safe.services = services;
|
||||||
|
else delete safe.services;
|
||||||
|
return safe;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function materializeGatewaySnapshotConfig(config: unknown, runtimeConfigPath: string) {
|
||||||
|
privateWrite(runtimeConfigPath, withoutApiServices(config));
|
||||||
|
return { configPath: runtimeConfigPath, secret: null, warning: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
function withAuthenticatedApi(config: unknown, secret: string) {
|
||||||
|
const materialized = structuredClone(record(config));
|
||||||
|
const services = Array.isArray(materialized.services) ? materialized.services : [];
|
||||||
|
materialized.services = services.map((service) => (
|
||||||
|
record(service).type === 'api'
|
||||||
|
? { ...record(service), secret }
|
||||||
|
: service
|
||||||
|
));
|
||||||
|
return materialized;
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateApiService(config: unknown, apiPort: number) {
|
||||||
|
const configuredServices = record(config).services;
|
||||||
|
const services = Array.isArray(configuredServices) ? configuredServices : [];
|
||||||
|
const apiServices = services.map(record).filter(({ type }) => type === 'api');
|
||||||
|
if (apiServices.length !== 1) throw new Error('expected exactly one native API service');
|
||||||
|
const [service] = apiServices;
|
||||||
|
if (service.listen !== '127.0.0.1' || service.listen_port !== apiPort
|
||||||
|
|| service.dashboard !== false || Object.hasOwn(service, 'secret')) {
|
||||||
|
throw new Error(`native API service must be unauthenticated base config on 127.0.0.1:${apiPort}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function materializeGatewayNativeConfig(
|
||||||
|
config: unknown,
|
||||||
|
{ apiPort, secretPath, runtimeConfigPath }: MaterializeOptions,
|
||||||
|
) {
|
||||||
|
let warning: string | null = null;
|
||||||
|
try {
|
||||||
|
validateApiService(config, apiPort);
|
||||||
|
const secret = ensureGatewayNativeApiSecret(secretPath);
|
||||||
|
privateWrite(runtimeConfigPath, withAuthenticatedApi(config, secret));
|
||||||
|
return { configPath: runtimeConfigPath, secret, warning };
|
||||||
|
} catch (error) {
|
||||||
|
warning = `Native traffic API disabled: ${message(error)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const safeConfig = withoutApiServices(config);
|
||||||
|
try {
|
||||||
|
privateWrite(runtimeConfigPath, safeConfig);
|
||||||
|
return { configPath: runtimeConfigPath, secret: null, warning };
|
||||||
|
} catch (error) {
|
||||||
|
warning = `${warning}; private runtime config unavailable: ${message(error)}`;
|
||||||
|
const suffix = crypto.createHash('sha256').update(runtimeConfigPath).digest('hex').slice(0, 12);
|
||||||
|
const fallbackPath = path.join(os.tmpdir(), `harbor-singbox-runtime-${process.pid}-${suffix}.json`);
|
||||||
|
privateWrite(fallbackPath, safeConfig);
|
||||||
|
return { configPath: fallbackPath, secret: null, warning };
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because one or more lines are too long
@@ -4,7 +4,7 @@ import type { ConnectivityDiagnosticsUseCase } from '../../features/diagnostics/
|
|||||||
import { sendJson } from '../response.js';
|
import { sendJson } from '../response.js';
|
||||||
|
|
||||||
interface ConnectivityDiagnosticsRouteDependencies {
|
interface ConnectivityDiagnosticsRouteDependencies {
|
||||||
diagnostics: Pick<ConnectivityDiagnosticsUseCase, 'run' | 'updateSettings'>;
|
diagnostics: Pick<ConnectivityDiagnosticsUseCase, 'run' | 'dnsCatalog' | 'runDns' | 'updateSettings'>;
|
||||||
readBody(req: IncomingMessage): Promise<Record<string, unknown>>;
|
readBody(req: IncomingMessage): Promise<Record<string, unknown>>;
|
||||||
sendState(res: ServerResponse): Promise<void>;
|
sendState(res: ServerResponse): Promise<void>;
|
||||||
}
|
}
|
||||||
@@ -14,6 +14,15 @@ export function createConnectivityDiagnosticsRoute(
|
|||||||
) {
|
) {
|
||||||
return {
|
return {
|
||||||
async handle(req: IncomingMessage, res: ServerResponse) {
|
async handle(req: IncomingMessage, res: ServerResponse) {
|
||||||
|
if (req.url === '/api/diagnostics/dns' && req.method === 'GET') {
|
||||||
|
sendJson(res, 200, await dependencies.diagnostics.dnsCatalog());
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (req.url === '/api/diagnostics/dns' && req.method === 'POST') {
|
||||||
|
const { domainId, resolverId = null } = await dependencies.readBody(req);
|
||||||
|
sendJson(res, 200, await dependencies.diagnostics.runDns(domainId, resolverId));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
if (req.url === '/api/diagnostics/connectivity' && req.method === 'POST') {
|
if (req.url === '/api/diagnostics/connectivity' && req.method === 'POST') {
|
||||||
const { target = null } = await dependencies.readBody(req);
|
const { target = null } = await dependencies.readBody(req);
|
||||||
const result = await dependencies.diagnostics.run(target);
|
const result = await dependencies.diagnostics.run(target);
|
||||||
|
|||||||
@@ -0,0 +1,115 @@
|
|||||||
|
import type { IncomingMessage, ServerResponse } from 'node:http';
|
||||||
|
import { isDeepStrictEqual } from 'node:util';
|
||||||
|
|
||||||
|
import {
|
||||||
|
assertLiveTrafficSnapshot,
|
||||||
|
type LiveTrafficSnapshot,
|
||||||
|
} from '../../../shared/liveTraffic.js';
|
||||||
|
import type { StoredState } from '../../../shared/contracts/state.js';
|
||||||
|
import { HarborError } from '../../../shared/errors.js';
|
||||||
|
import {
|
||||||
|
normalizeTrafficSettings,
|
||||||
|
type TrafficSettings,
|
||||||
|
} from '../../../shared/trafficSettings.js';
|
||||||
|
import { sendJson } from '../response.js';
|
||||||
|
|
||||||
|
interface LiveTrafficReader {
|
||||||
|
snapshot(): unknown | Promise<unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DeviceInventoryReader {
|
||||||
|
snapshot(): unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TrafficSettingsState {
|
||||||
|
read(): { revision?: unknown; traffic?: TrafficSettings };
|
||||||
|
update(mutator: (state: StoredState) => Record<string, unknown>): unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
function record(value: unknown): Record<string, unknown> {
|
||||||
|
return value && typeof value === 'object' && !Array.isArray(value)
|
||||||
|
? value as Record<string, unknown>
|
||||||
|
: {};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function enrichLiveTrafficDeviceLabels(
|
||||||
|
snapshot: LiveTrafficSnapshot,
|
||||||
|
inventory: unknown,
|
||||||
|
): LiveTrafficSnapshot {
|
||||||
|
const devices = Array.isArray(record(inventory).devices)
|
||||||
|
? (record(inventory).devices as unknown[]).map(record)
|
||||||
|
: [];
|
||||||
|
const labels = new Map(devices.flatMap((device) => {
|
||||||
|
const id = String(device.id || '');
|
||||||
|
if (!/^dev_[a-f0-9]{16}$/.test(id)) return [];
|
||||||
|
const label = [device.alias, device.hostname, device.ip]
|
||||||
|
.find((value) => typeof value === 'string' && value.trim());
|
||||||
|
return label ? [[id, String(label).trim()] as const] : [];
|
||||||
|
}));
|
||||||
|
if (!labels.size) return snapshot;
|
||||||
|
return {
|
||||||
|
...snapshot,
|
||||||
|
connections: snapshot.connections.map((connection) => {
|
||||||
|
const label = connection.origin.kind === 'device' && connection.origin.id
|
||||||
|
? labels.get(connection.origin.id)
|
||||||
|
: null;
|
||||||
|
return label ? {
|
||||||
|
...connection,
|
||||||
|
origin: { ...connection.origin, label },
|
||||||
|
} : connection;
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createLiveTrafficRoute({
|
||||||
|
traffic,
|
||||||
|
deviceInventory = null,
|
||||||
|
settingsState = null,
|
||||||
|
readBody = null,
|
||||||
|
sendState = null,
|
||||||
|
}: {
|
||||||
|
traffic: LiveTrafficReader | null;
|
||||||
|
deviceInventory?: DeviceInventoryReader | null;
|
||||||
|
settingsState?: TrafficSettingsState | null;
|
||||||
|
readBody?: ((req: IncomingMessage) => Promise<Record<string, unknown>>) | null;
|
||||||
|
sendState?: ((res: ServerResponse) => Promise<void>) | null;
|
||||||
|
}) {
|
||||||
|
return {
|
||||||
|
async handle(req: IncomingMessage, res: ServerResponse) {
|
||||||
|
const pathname = new URL(req.url || '/', 'http://localhost').pathname;
|
||||||
|
if (pathname === '/api/traffic/live') {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
if (pathname === '/api/traffic/settings') {
|
||||||
|
if (req.method !== 'PUT' || !settingsState || !readBody || !sendState) {
|
||||||
|
throw new HarborError('ENDPOINT_NOT_FOUND');
|
||||||
|
}
|
||||||
|
const body = await readBody(req);
|
||||||
|
const expectedRevision = body.expectedRevision;
|
||||||
|
if (!Number.isSafeInteger(expectedRevision) || Number(expectedRevision) < 0) {
|
||||||
|
throw new HarborError('REQUEST_INVALID');
|
||||||
|
}
|
||||||
|
let settings: TrafficSettings;
|
||||||
|
try {
|
||||||
|
settings = normalizeTrafficSettings(body.settings, { strict: true });
|
||||||
|
} catch (cause) {
|
||||||
|
throw new HarborError('REQUEST_INVALID', { cause });
|
||||||
|
}
|
||||||
|
const current = settingsState.read();
|
||||||
|
if (current.revision !== expectedRevision) throw new HarborError('STATE_CONFLICT');
|
||||||
|
if (!isDeepStrictEqual(current.traffic, settings)) {
|
||||||
|
settingsState.update((state) => ({ ...state, traffic: settings }));
|
||||||
|
}
|
||||||
|
await sendState(res);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import type { IncomingMessage, ServerResponse } from 'node:http';
|
||||||
|
import { HarborError } from '../../../shared/errors.js';
|
||||||
|
import { assertTrafficHistorySnapshot, emptyTrafficHistory, parseTrafficHistoryQuery, type TrafficHistoryQuery } from '../../../shared/trafficHistory.js';
|
||||||
|
import { sendJson } from '../response.js';
|
||||||
|
|
||||||
|
export function createTrafficHistoryRoute({ readHistory, deviceInventory }: {
|
||||||
|
readHistory: ((query: TrafficHistoryQuery) => Promise<unknown>) | null;
|
||||||
|
deviceInventory?: { snapshot(): unknown } | null;
|
||||||
|
}) {
|
||||||
|
return {
|
||||||
|
async handle(req: IncomingMessage, res: ServerResponse) {
|
||||||
|
const url = new URL(req.url || '/', 'http://localhost');
|
||||||
|
if (url.pathname !== '/api/traffic/history') return false;
|
||||||
|
if (req.method !== 'GET') throw new HarborError('ENDPOINT_NOT_FOUND');
|
||||||
|
let query: TrafficHistoryQuery;
|
||||||
|
try { query = parseTrafficHistoryQuery(url.searchParams); }
|
||||||
|
catch (cause) { throw new HarborError('REQUEST_INVALID', { cause }); }
|
||||||
|
let snapshot;
|
||||||
|
try {
|
||||||
|
snapshot = readHistory ? assertTrafficHistorySnapshot(await readHistory(query)) : emptyTrafficHistory(query);
|
||||||
|
} catch {
|
||||||
|
snapshot = emptyTrafficHistory(query, 'stale');
|
||||||
|
snapshot.storage = { status: 'error', errorCode: 'TRAFFIC_HISTORY_UNAVAILABLE' };
|
||||||
|
snapshot.coverage.partial = true;
|
||||||
|
}
|
||||||
|
const inventory = deviceInventory?.snapshot() as { devices?: Array<{ id: string; alias?: string; hostname?: string; ip?: string }> } | undefined;
|
||||||
|
const labels = new Map((inventory?.devices || []).map((device) => [device.id, device.alias || device.hostname || device.ip]));
|
||||||
|
snapshot.origins = snapshot.origins.map((origin) => ({ ...origin, label: labels.get(origin.id) || origin.label }));
|
||||||
|
sendJson(res, 200, snapshot);
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
+85
-135
@@ -39,21 +39,15 @@ import {
|
|||||||
} from '../shared/contracts/state.js';
|
} from '../shared/contracts/state.js';
|
||||||
import { serverIdentityKey } from '../shared/serverIdentity.js';
|
import { serverIdentityKey } from '../shared/serverIdentity.js';
|
||||||
import { HarborError, normalizeHarborError } from '../shared/errors.js';
|
import { HarborError, normalizeHarborError } from '../shared/errors.js';
|
||||||
import {
|
import { openHarborStorage } from './services/harborStorage.js';
|
||||||
atomicWriteFile,
|
|
||||||
createJsonStore,
|
|
||||||
createStateStore,
|
|
||||||
} from './services/stateStore.js';
|
|
||||||
import { createDevicePolicyService } from './services/devicePolicyService.js';
|
import { createDevicePolicyService } from './services/devicePolicyService.js';
|
||||||
import {
|
import {
|
||||||
createDeviceInventoryService,
|
createDeviceInventoryService,
|
||||||
createVendorLookup,
|
createVendorLookup,
|
||||||
DEVICE_INVENTORY_SCHEMA_VERSION,
|
|
||||||
migrateDeviceInventoryState,
|
|
||||||
type InventoryState,
|
|
||||||
} from './services/deviceInventoryService.js';
|
} from './services/deviceInventoryService.js';
|
||||||
import { buildVersionInfo } from './version.js';
|
import { buildVersionInfo } from './version.js';
|
||||||
import { createConnectivityDiagnosticsService } from './services/connectivityDiagnosticsService.js';
|
import { createConnectivityDiagnosticsService } from './services/connectivityDiagnosticsService.js';
|
||||||
|
import { createDnsDiagnosticsService } from './services/dnsDiagnosticsService.js';
|
||||||
import { createStateService } from './features/state/stateService.js';
|
import { createStateService } from './features/state/stateService.js';
|
||||||
import { createStateRoute } from './http/routes/stateRoute.js';
|
import { createStateRoute } from './http/routes/stateRoute.js';
|
||||||
import { sendError } from './http/response.js';
|
import { sendError } from './http/response.js';
|
||||||
@@ -84,6 +78,10 @@ import { createConnectivityDiagnosticsRoute } from './http/routes/connectivityDi
|
|||||||
import { createGatewayPresenceRoute } from './http/routes/gatewayPresenceRoute.js';
|
import { createGatewayPresenceRoute } from './http/routes/gatewayPresenceRoute.js';
|
||||||
import { createSharedProxyRoute } from './http/routes/sharedProxyRoute.js';
|
import { createSharedProxyRoute } from './http/routes/sharedProxyRoute.js';
|
||||||
import { createVersionRoute } from './http/routes/versionRoute.js';
|
import { createVersionRoute } from './http/routes/versionRoute.js';
|
||||||
|
import { createLiveTrafficRoute } from './http/routes/liveTrafficRoute.js';
|
||||||
|
import { createTrafficHistoryRoute } from './http/routes/trafficHistoryRoute.js';
|
||||||
|
import { createTrafficHistoryService } from './services/trafficHistoryService.js';
|
||||||
|
import type { LiveTrafficSourceState } from '../shared/liveTraffic.js';
|
||||||
import { createSingboxSelectorService } from './services/singboxSelectorService.js';
|
import { createSingboxSelectorService } from './services/singboxSelectorService.js';
|
||||||
import { createFailoverService } from './features/failover/failoverService.js';
|
import { createFailoverService } from './features/failover/failoverService.js';
|
||||||
import { createFailoverRoute } from './http/routes/failoverRoute.js';
|
import { createFailoverRoute } from './http/routes/failoverRoute.js';
|
||||||
@@ -109,50 +107,12 @@ function errorMessage(error: unknown) {
|
|||||||
|
|
||||||
fs.mkdirSync(settings.dataDir, { recursive: true });
|
fs.mkdirSync(settings.dataDir, { recursive: true });
|
||||||
|
|
||||||
const stateFileExisted = fs.existsSync(settings.statePath);
|
const storage = openHarborStorage(settings.dataDir);
|
||||||
const legacyStateBytes = stateFileExisted
|
const stateStore = storage.state;
|
||||||
? fs.readFileSync(settings.statePath, 'utf8')
|
const deviceStore = storage.devices;
|
||||||
: null;
|
const initialStoredState = stateStore.read();
|
||||||
let legacyStateRecord: Record<string, unknown> = {};
|
if (storage.imported) console.log('[storage] SQLite migration committed; original JSON files retained as backups');
|
||||||
try {
|
const activityJournal = createActivityJournalService({ db: storage.db });
|
||||||
legacyStateRecord = record(legacyStateBytes === null ? null : JSON.parse(legacyStateBytes));
|
|
||||||
} catch {}
|
|
||||||
const legacyStateVersion = Number.isSafeInteger(legacyStateRecord.schemaVersion)
|
|
||||||
? Number(legacyStateRecord.schemaVersion)
|
|
||||||
: 0;
|
|
||||||
const legacySubscriptionCacheBytes = fs.existsSync(settings.subscriptionCachePath)
|
|
||||||
? fs.readFileSync(settings.subscriptionCachePath, 'utf8')
|
|
||||||
: null;
|
|
||||||
const subscriptionCacheStore = createJsonStore({
|
|
||||||
filePath: settings.subscriptionCachePath,
|
|
||||||
defaultValue: null,
|
|
||||||
});
|
|
||||||
const rawLegacySubscriptionCache = subscriptionCacheStore.read();
|
|
||||||
const legacyCacheRecord = record(rawLegacySubscriptionCache);
|
|
||||||
const legacyStateSubscriptionUrl = String(legacyStateRecord.subscriptionUrl || '').trim();
|
|
||||||
const legacyCacheSubscriptionUrl = String(legacyCacheRecord.url || '').trim();
|
|
||||||
const legacyCacheOwnerMismatch = legacyStateVersion < 5
|
|
||||||
&& Boolean(legacyCacheRecord.config)
|
|
||||||
&& (legacyStateSubscriptionUrl
|
|
||||||
? legacyCacheSubscriptionUrl !== legacyStateSubscriptionUrl
|
|
||||||
: !legacyCacheSubscriptionUrl);
|
|
||||||
let legacySubscriptionCache = rawLegacySubscriptionCache;
|
|
||||||
let legacySubscriptionCacheRejected = Boolean(subscriptionCacheStore.recovery);
|
|
||||||
if (legacyCacheOwnerMismatch) {
|
|
||||||
legacySubscriptionCache = null;
|
|
||||||
} else if (legacyCacheRecord.config) {
|
|
||||||
try {
|
|
||||||
legacySubscriptionCache = {
|
|
||||||
...legacyCacheRecord,
|
|
||||||
...normalizeSubscriptionConfig(legacyCacheRecord.config),
|
|
||||||
};
|
|
||||||
} catch {
|
|
||||||
legacySubscriptionCache = null;
|
|
||||||
legacySubscriptionCacheRejected = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const stateStore = createStateStore(settings.statePath, { legacySubscriptionCache });
|
|
||||||
const activityJournal = createActivityJournalService({ filePath: settings.activityJournalPath });
|
|
||||||
const appendJournal = (event: ActivityJournalEventInput) => {
|
const appendJournal = (event: ActivityJournalEventInput) => {
|
||||||
try {
|
try {
|
||||||
activityJournal.append(event);
|
activityJournal.append(event);
|
||||||
@@ -160,83 +120,6 @@ const appendJournal = (event: ActivityJournalEventInput) => {
|
|||||||
console.warn(`[journal] событие не сохранено: ${errorMessage(error)}`);
|
console.warn(`[journal] событие не сохранено: ${errorMessage(error)}`);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
const deviceStore = createJsonStore<InventoryState>({
|
|
||||||
filePath: settings.deviceStatePath,
|
|
||||||
defaultValue: migrateDeviceInventoryState({}),
|
|
||||||
migrate: migrateDeviceInventoryState,
|
|
||||||
initializeMissing: true,
|
|
||||||
backupWhen: () => true,
|
|
||||||
});
|
|
||||||
deviceStore.read();
|
|
||||||
if (deviceStore.migration) {
|
|
||||||
console.log(`[storage] devices migrated to v${DEVICE_INVENTORY_SCHEMA_VERSION}; backup: ${deviceStore.migration.backupPath}`);
|
|
||||||
}
|
|
||||||
if (deviceStore.recovery) {
|
|
||||||
console.warn(`[storage] corrupt devices recovered; backup: ${deviceStore.recovery.backupPath}`);
|
|
||||||
}
|
|
||||||
let initialStoredState = stateStore.read();
|
|
||||||
if (stateStore.migration) {
|
|
||||||
console.log(`[storage] state migrated to v${stateStore.migration.toVersion}; backup: ${stateStore.migration.backupPath}`);
|
|
||||||
}
|
|
||||||
if (stateStore.recovery) {
|
|
||||||
console.warn(`[storage] corrupt state recovered; backup: ${stateStore.recovery.backupPath}`);
|
|
||||||
}
|
|
||||||
if (subscriptionCacheStore.recovery) {
|
|
||||||
console.warn(`[storage] corrupt subscription cache recovered; backup: ${subscriptionCacheStore.recovery.backupPath}`);
|
|
||||||
}
|
|
||||||
const rejectedLegacyMigration = legacySubscriptionCacheRejected
|
|
||||||
&& (
|
|
||||||
!stateFileExisted
|
|
||||||
|| Boolean(stateStore.recovery)
|
|
||||||
|| Boolean(stateStore.migration && stateStore.migration.fromVersion < 5)
|
|
||||||
);
|
|
||||||
const mismatchedLegacyMigration = legacyCacheOwnerMismatch
|
|
||||||
&& (
|
|
||||||
!stateFileExisted
|
|
||||||
|| Boolean(stateStore.recovery)
|
|
||||||
|| Boolean(stateStore.migration && stateStore.migration.fromVersion < 5)
|
|
||||||
);
|
|
||||||
if (rejectedLegacyMigration) {
|
|
||||||
initialStoredState = stateStore.update((state) => ({
|
|
||||||
...state,
|
|
||||||
profiles: [],
|
|
||||||
desiredProfileId: '',
|
|
||||||
appliedProfileId: '',
|
|
||||||
appliedServerId: '',
|
|
||||||
appliedServerSnapshot: null,
|
|
||||||
connectionDesired: 'stopped',
|
|
||||||
}));
|
|
||||||
removeSingboxConfig();
|
|
||||||
} else if (mismatchedLegacyMigration) {
|
|
||||||
initialStoredState = stateStore.update((state) => ({
|
|
||||||
...state,
|
|
||||||
appliedProfileId: '',
|
|
||||||
appliedServerId: '',
|
|
||||||
appliedServerSnapshot: null,
|
|
||||||
connectionDesired: 'stopped',
|
|
||||||
}));
|
|
||||||
removeSingboxConfig();
|
|
||||||
}
|
|
||||||
if (
|
|
||||||
legacySubscriptionCacheBytes !== null
|
|
||||||
&& (
|
|
||||||
Boolean(subscriptionCacheStore.recovery)
|
|
||||||
|| (
|
|
||||||
Boolean(legacyCacheRecord.config)
|
|
||||||
&& (
|
|
||||||
legacySubscriptionCacheRejected
|
|
||||||
|| legacyCacheOwnerMismatch
|
|
||||||
|| normalizeStoredState(initialStoredState).profiles.some((profile) => profile.subscriptionConfig)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
const backupPath = subscriptionCacheStore.recovery?.backupPath
|
|
||||||
|| `${settings.subscriptionCachePath}.backup-v1-${new Date().toISOString().replace(/[:.]/g, '-')}`;
|
|
||||||
if (!subscriptionCacheStore.recovery) atomicWriteFile(backupPath, legacySubscriptionCacheBytes);
|
|
||||||
subscriptionCacheStore.remove();
|
|
||||||
console.log(`[storage] legacy subscription cache migrated; backup: ${backupPath}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
function readProfileConfig(profileId = '') {
|
function readProfileConfig(profileId = '') {
|
||||||
const state = normalizeStoredState(stateStore.read());
|
const state = normalizeStoredState(stateStore.read());
|
||||||
@@ -260,6 +143,22 @@ function selectRuntime() {
|
|||||||
throw new Error('Harbor runtime is not configured');
|
throw new Error('Harbor runtime is not configured');
|
||||||
}
|
}
|
||||||
const singboxRuntime = selectRuntime();
|
const singboxRuntime = selectRuntime();
|
||||||
|
let clientLiveTraffic: ReturnType<typeof import('./services/liveTrafficService.js').createLiveTrafficService> | null = null;
|
||||||
|
const clientHistory = settings.appMode === 'client' ? createTrafficHistoryService({
|
||||||
|
filePath: path.join(settings.dataDir, 'traffic.sqlite'),
|
||||||
|
source: (): LiveTrafficSourceState => clientLiveTraffic?.snapshot().source.state || 'disabled',
|
||||||
|
}) : null;
|
||||||
|
clientLiveTraffic = settings.appMode === 'client'
|
||||||
|
? (await import('./services/liveTrafficService.js')).createLiveTrafficService({
|
||||||
|
port: settings.singboxNativeApiPort,
|
||||||
|
enabled: settings.singboxTrafficSource === 'native',
|
||||||
|
isRuntimeRunning: () => Boolean(localRuntime?.running),
|
||||||
|
onProjection: (batch) => clientHistory?.enqueue(batch),
|
||||||
|
})
|
||||||
|
: null;
|
||||||
|
const liveTraffic = clientLiveTraffic || (remoteDataplane ? {
|
||||||
|
snapshot: () => requireRemoteRuntime().observeLiveTraffic(),
|
||||||
|
} : null);
|
||||||
|
|
||||||
function requireRemoteRuntime() {
|
function requireRemoteRuntime() {
|
||||||
if (!remoteRuntime) throw new Error('Harbor dataplane runtime is not configured');
|
if (!remoteRuntime) throw new Error('Harbor dataplane runtime is not configured');
|
||||||
@@ -301,6 +200,9 @@ const deviceInventory = settings.appMode === 'gateway'
|
|||||||
const localConnectivityDiagnostics = !remoteDataplane
|
const localConnectivityDiagnostics = !remoteDataplane
|
||||||
? createConnectivityDiagnosticsService({ proxyPort: settings.diagnosticsProxyPort })
|
? createConnectivityDiagnosticsService({ proxyPort: settings.diagnosticsProxyPort })
|
||||||
: null;
|
: null;
|
||||||
|
const localDnsDiagnostics = !remoteDataplane
|
||||||
|
? createDnsDiagnosticsService({ proxyPort: settings.diagnosticsProxyPort })
|
||||||
|
: null;
|
||||||
const localFailoverDiagnostics = !remoteDataplane ? {
|
const localFailoverDiagnostics = !remoteDataplane ? {
|
||||||
primary: createConnectivityDiagnosticsService({ proxyPort: settings.failoverPrimaryProxyPort }),
|
primary: createConnectivityDiagnosticsService({ proxyPort: settings.failoverPrimaryProxyPort }),
|
||||||
reserve: createConnectivityDiagnosticsService({ proxyPort: settings.failoverReserveProxyPort }),
|
reserve: createConnectivityDiagnosticsService({ proxyPort: settings.failoverReserveProxyPort }),
|
||||||
@@ -339,12 +241,7 @@ function requireLocalConnectivityDiagnostics() {
|
|||||||
}
|
}
|
||||||
let deviceDiscoveryTimer: NodeJS.Timeout | null = null;
|
let deviceDiscoveryTimer: NodeJS.Timeout | null = null;
|
||||||
let controlOperation: Promise<unknown> = Promise.resolve();
|
let controlOperation: Promise<unknown> = Promise.resolve();
|
||||||
let operationState: OperationState = stateStore.recovery ? {
|
let operationState: OperationState = { kind: null, status: 'idle', startedAt: null, error: null };
|
||||||
kind: 'storage-recovery',
|
|
||||||
status: 'failed',
|
|
||||||
startedAt: stateStore.recovery.recoveredAt,
|
|
||||||
error: `Повреждённый state сохранён: ${path.basename(stateStore.recovery.backupPath)}`,
|
|
||||||
} : { kind: null, status: 'idle', startedAt: null, error: null };
|
|
||||||
let revision = normalizeStoredState(initialStoredState).revision;
|
let revision = normalizeStoredState(initialStoredState).revision;
|
||||||
const gatewayAutoService = createGatewayAutoService({
|
const gatewayAutoService = createGatewayAutoService({
|
||||||
appMode: settings.appMode,
|
appMode: settings.appMode,
|
||||||
@@ -492,6 +389,23 @@ const connectivityDiagnostics = createConnectivityDiagnosticsUseCase({
|
|||||||
services,
|
services,
|
||||||
target,
|
target,
|
||||||
}),
|
}),
|
||||||
|
dnsCatalog: (customResolvers, customDomains) => remoteDataplane
|
||||||
|
? requireRemoteRuntime().getDnsDiagnosticsCatalog(customResolvers, customDomains)
|
||||||
|
: localDnsDiagnostics!.catalog(
|
||||||
|
Array.isArray(customResolvers) ? customResolvers : [],
|
||||||
|
Array.isArray(customDomains) ? customDomains : [],
|
||||||
|
),
|
||||||
|
runDnsDiagnostics: async (customResolvers, customDomains, domainId, resolverId) => remoteDataplane
|
||||||
|
? requireRemoteRuntime().runDnsDiagnostics(
|
||||||
|
customResolvers, customDomains, domainId, resolverId,
|
||||||
|
)
|
||||||
|
: localDnsDiagnostics!.run({
|
||||||
|
vpnAvailable: Boolean((await singboxRuntime.refresh()).running),
|
||||||
|
customResolvers: Array.isArray(customResolvers) ? customResolvers : [],
|
||||||
|
customDomains: Array.isArray(customDomains) ? customDomains : [],
|
||||||
|
domainId,
|
||||||
|
resolverId,
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
const connectivityDiagnosticsRoute = createConnectivityDiagnosticsRoute({
|
const connectivityDiagnosticsRoute = createConnectivityDiagnosticsRoute({
|
||||||
diagnostics: connectivityDiagnostics,
|
diagnostics: connectivityDiagnostics,
|
||||||
@@ -515,6 +429,21 @@ const versionRoute = createVersionRoute({
|
|||||||
? () => requireRemoteRuntime().refresh()
|
? () => requireRemoteRuntime().refresh()
|
||||||
: null,
|
: null,
|
||||||
});
|
});
|
||||||
|
const liveTrafficRoute = createLiveTrafficRoute({
|
||||||
|
traffic: liveTraffic,
|
||||||
|
deviceInventory: remoteDataplane ? deviceInventory : null,
|
||||||
|
settingsState: {
|
||||||
|
read: () => normalizeStoredState(stateStore.read()),
|
||||||
|
update: updateStoredState,
|
||||||
|
},
|
||||||
|
readBody,
|
||||||
|
sendState: (res) => stateRoute.send(res),
|
||||||
|
});
|
||||||
|
const trafficHistoryRoute = createTrafficHistoryRoute({
|
||||||
|
readHistory: clientHistory ? (query) => clientHistory.query(query)
|
||||||
|
: remoteRuntime ? (query) => remoteRuntime.observeTrafficHistory(query) : null,
|
||||||
|
deviceInventory,
|
||||||
|
});
|
||||||
const subscriptionValidationRoute = createSubscriptionValidationRoute({
|
const subscriptionValidationRoute = createSubscriptionValidationRoute({
|
||||||
validateSubscription: createValidateSubscription(fetchSubscription),
|
validateSubscription: createValidateSubscription(fetchSubscription),
|
||||||
readBody,
|
readBody,
|
||||||
@@ -887,6 +816,19 @@ function currentConfigMatchesAppliedTarget(state: StoredState) {
|
|||||||
} catch {
|
} catch {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
if (settings.appMode === 'client' || settings.appMode === 'gateway') {
|
||||||
|
const apiServices = (Array.isArray(config.services) ? config.services : [])
|
||||||
|
.map(record)
|
||||||
|
.filter(({ type }) => type === 'api');
|
||||||
|
const nativeApiMatches = apiServices.length === 1
|
||||||
|
&& apiServices[0].listen === '127.0.0.1'
|
||||||
|
&& apiServices[0].listen_port === settings.singboxNativeApiPort
|
||||||
|
&& apiServices[0].dashboard === false
|
||||||
|
&& !Object.hasOwn(apiServices[0], 'secret');
|
||||||
|
const nativeApiExpected = settings.singboxTrafficSource === 'native'
|
||||||
|
|| settings.singboxTrafficSource === 'shadow';
|
||||||
|
if (nativeApiExpected ? !nativeApiMatches : apiServices.length > 0) return false;
|
||||||
|
}
|
||||||
if (state.appliedFailoverPolicy) {
|
if (state.appliedFailoverPolicy) {
|
||||||
const expectedRole = state.appliedProfileId === state.appliedFailoverPolicy.reserve.profileId
|
const expectedRole = state.appliedProfileId === state.appliedFailoverPolicy.reserve.profileId
|
||||||
&& state.appliedServerId === state.appliedFailoverPolicy.reserve.serverId
|
&& state.appliedServerId === state.appliedFailoverPolicy.reserve.serverId
|
||||||
@@ -955,6 +897,9 @@ async function handleApi(req: IncomingMessage, res: ServerResponse) {
|
|||||||
|
|
||||||
if (await versionRoute.handle(req, res)) return;
|
if (await versionRoute.handle(req, res)) return;
|
||||||
|
|
||||||
|
if (await liveTrafficRoute.handle(req, res)) return;
|
||||||
|
if (await trafficHistoryRoute.handle(req, res)) return;
|
||||||
|
|
||||||
if (await sharedProxyRoute.handle(req, res)) return;
|
if (await sharedProxyRoute.handle(req, res)) return;
|
||||||
|
|
||||||
if (await deviceInventoryRoute.handle(req, res)) return;
|
if (await deviceInventoryRoute.handle(req, res)) return;
|
||||||
@@ -1004,7 +949,10 @@ async function shutdown() {
|
|||||||
gatewayAutoService.stopDiscovery();
|
gatewayAutoService.stopDiscovery();
|
||||||
if (deviceDiscoveryTimer) clearInterval(deviceDiscoveryTimer);
|
if (deviceDiscoveryTimer) clearInterval(deviceDiscoveryTimer);
|
||||||
await gatewayFailover?.shutdown().catch((error) => console.warn(`[control] failover shutdown: ${errorMessage(error)}`));
|
await gatewayFailover?.shutdown().catch((error) => console.warn(`[control] failover shutdown: ${errorMessage(error)}`));
|
||||||
|
await clientLiveTraffic?.stop().catch((error) => console.warn(`[control] traffic shutdown: ${errorMessage(error)}`));
|
||||||
|
await clientHistory?.close();
|
||||||
await serializeControl(() => singboxRuntime.shutdown());
|
await serializeControl(() => singboxRuntime.shutdown());
|
||||||
|
storage.close();
|
||||||
process.exit(0);
|
process.exit(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1073,6 +1021,8 @@ if (deviceInventory) {
|
|||||||
.catch((error: unknown) => console.warn(`[control] device policy не применена: ${errorMessage(error)}`));
|
.catch((error: unknown) => console.warn(`[control] device policy не применена: ${errorMessage(error)}`));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
clientLiveTraffic?.start();
|
||||||
|
|
||||||
server.listen(settings.port, '0.0.0.0', () => {
|
server.listen(settings.port, '0.0.0.0', () => {
|
||||||
console.log(`[control] ${settings.appMode} UI слушает :${settings.port}`);
|
console.log(`[control] ${settings.appMode} UI слушает :${settings.port}`);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,6 +1,13 @@
|
|||||||
import type { ServerResponse } from 'node:http';
|
import type { ServerResponse } from 'node:http';
|
||||||
|
|
||||||
const COUNTER_PATTERN = /^\d+$/;
|
const COUNTER_PATTERN = /^\d+$/;
|
||||||
|
const SIGNED_DECIMAL_PATTERN = /^-?\d+$/;
|
||||||
|
const COLLECTOR_MODES = new Set(['snapshot', 'shadow', 'native']);
|
||||||
|
const COLLECTOR_WRITERS = new Set(['snapshot', 'native']);
|
||||||
|
const APPLIED_POLICY_VALUES: Readonly<Record<string, string>> = Object.freeze({ direct: '0', vpn: '1' });
|
||||||
|
const COLLECTOR_STATES = new Set([
|
||||||
|
'connecting', 'live', 'degraded', 'stale', 'stopped', 'incompatible', 'disabled',
|
||||||
|
]);
|
||||||
|
|
||||||
const labelValue = (value: unknown) => String(value ?? '')
|
const labelValue = (value: unknown) => String(value ?? '')
|
||||||
.replaceAll('\\', '\\\\')
|
.replaceAll('\\', '\\\\')
|
||||||
@@ -23,6 +30,19 @@ function counter(value: unknown) {
|
|||||||
return decimal;
|
return decimal;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function signedGauge(value: unknown) {
|
||||||
|
const decimal = String(value ?? '');
|
||||||
|
if (!SIGNED_DECIMAL_PATTERN.test(decimal)) throw new Error(`Invalid Prometheus gauge: ${decimal}`);
|
||||||
|
return decimal;
|
||||||
|
}
|
||||||
|
|
||||||
|
function safeInteger(value: unknown, { signed = false } = {}) {
|
||||||
|
if (!Number.isSafeInteger(value) || (!signed && Number(value) < 0)) {
|
||||||
|
throw new Error(`Invalid Prometheus gauge: ${String(value)}`);
|
||||||
|
}
|
||||||
|
return String(value);
|
||||||
|
}
|
||||||
|
|
||||||
function timestamp(value: unknown) {
|
function timestamp(value: unknown) {
|
||||||
const milliseconds = Date.parse(String(value ?? ''));
|
const milliseconds = Date.parse(String(value ?? ''));
|
||||||
return Number.isFinite(milliseconds) ? String(milliseconds / 1000) : null;
|
return Number.isFinite(milliseconds) ? String(milliseconds / 1000) : null;
|
||||||
@@ -74,6 +94,20 @@ export function renderPrometheusMetrics(value: unknown) {
|
|||||||
ip: device.ip || '',
|
ip: device.ip || '',
|
||||||
}, '1');
|
}, '1');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
lines.push(
|
||||||
|
'# HELP harbor_device_applied_policy Current applied device policy: 0 Direct, 1 VPN.',
|
||||||
|
'# TYPE harbor_device_applied_policy gauge',
|
||||||
|
);
|
||||||
|
for (const device of devices) {
|
||||||
|
const appliedPolicy = String(device.appliedPolicy || '');
|
||||||
|
if (!Object.hasOwn(APPLIED_POLICY_VALUES, appliedPolicy)) {
|
||||||
|
throw new Error('Invalid applied device policy');
|
||||||
|
}
|
||||||
|
metric(lines, 'harbor_device_applied_policy', {
|
||||||
|
device_id: device.id,
|
||||||
|
}, APPLIED_POLICY_VALUES[appliedPolicy]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const deviceTraffic = devices.flatMap((device) => [
|
const deviceTraffic = devices.flatMap((device) => [
|
||||||
@@ -151,10 +185,64 @@ export function renderPrometheusMetrics(value: unknown) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const domainTraffic = record(snapshot.domainTraffic);
|
const domainTraffic = record(snapshot.domainTraffic);
|
||||||
|
const collectorSource = record(domainTraffic.source);
|
||||||
|
const hasCollectorDiagnostics = ['mode', 'writer', 'native', 'shadow']
|
||||||
|
.some((field) => Object.hasOwn(collectorSource, field));
|
||||||
|
if (hasCollectorDiagnostics) {
|
||||||
|
const source = collectorSource;
|
||||||
|
const mode = String(source.mode || '');
|
||||||
|
const writer = String(source.writer || '');
|
||||||
|
if (!COLLECTOR_MODES.has(mode) || !COLLECTOR_WRITERS.has(writer)) {
|
||||||
|
throw new Error('Invalid traffic collector labels');
|
||||||
|
}
|
||||||
|
lines.push(
|
||||||
|
'# HELP harbor_traffic_collector_info Current Gateway traffic collector mode and canonical writer.',
|
||||||
|
'# TYPE harbor_traffic_collector_info gauge',
|
||||||
|
);
|
||||||
|
metric(lines, 'harbor_traffic_collector_info', { mode, writer }, '1');
|
||||||
|
|
||||||
|
if (source.native !== null) {
|
||||||
|
const native = record(source.native);
|
||||||
|
const state = String(native.state || '');
|
||||||
|
if (!COLLECTOR_STATES.has(state)) throw new Error('Invalid traffic collector state');
|
||||||
|
lines.push(
|
||||||
|
'# HELP harbor_traffic_collector_state Current native traffic collector state.',
|
||||||
|
'# TYPE harbor_traffic_collector_state gauge',
|
||||||
|
);
|
||||||
|
metric(lines, 'harbor_traffic_collector_state', { state }, '1');
|
||||||
|
lines.push(
|
||||||
|
'# HELP harbor_traffic_collector_unattributed_bytes Native traffic bytes not attributed to a lifecycle connection.',
|
||||||
|
'# TYPE harbor_traffic_collector_unattributed_bytes gauge',
|
||||||
|
);
|
||||||
|
metric(lines, 'harbor_traffic_collector_unattributed_bytes', { direction: 'download' }, counter(native.unattributedDownloadBytes));
|
||||||
|
metric(lines, 'harbor_traffic_collector_unattributed_bytes', { direction: 'upload' }, counter(native.unattributedUploadBytes));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (source.shadow !== null) {
|
||||||
|
const shadow = record(source.shadow);
|
||||||
|
lines.push(
|
||||||
|
'# HELP harbor_traffic_shadow_active_difference Native active connections minus snapshot active connections.',
|
||||||
|
'# TYPE harbor_traffic_shadow_active_difference gauge',
|
||||||
|
`harbor_traffic_shadow_active_difference ${safeInteger(shadow.activeDifference, { signed: true })}`,
|
||||||
|
'# HELP harbor_traffic_shadow_difference_bytes Native traffic bytes minus snapshot traffic bytes.',
|
||||||
|
'# TYPE harbor_traffic_shadow_difference_bytes gauge',
|
||||||
|
);
|
||||||
|
metric(lines, 'harbor_traffic_shadow_difference_bytes', { direction: 'download' }, signedGauge(shadow.downloadDifferenceBytes));
|
||||||
|
metric(lines, 'harbor_traffic_shadow_difference_bytes', { direction: 'upload' }, signedGauge(shadow.uploadDifferenceBytes));
|
||||||
|
lines.push(
|
||||||
|
'# HELP harbor_traffic_shadow_route_mismatches Route aggregate keys that differ between native and snapshot projections.',
|
||||||
|
'# TYPE harbor_traffic_shadow_route_mismatches gauge',
|
||||||
|
`harbor_traffic_shadow_route_mismatches ${safeInteger(shadow.routeMismatches)}`,
|
||||||
|
'# HELP harbor_traffic_shadow_device_mismatches Device aggregate keys that differ between native and snapshot projections.',
|
||||||
|
'# TYPE harbor_traffic_shadow_device_mismatches gauge',
|
||||||
|
`harbor_traffic_shadow_device_mismatches ${safeInteger(shadow.deviceMismatches)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
const trackedSeries = Array.isArray(domainTraffic.tracked) ? domainTraffic.tracked.map(record) : [];
|
const trackedSeries = Array.isArray(domainTraffic.tracked) ? domainTraffic.tracked.map(record) : [];
|
||||||
if (trackedSeries.length) {
|
if (trackedSeries.length) {
|
||||||
lines.push(
|
lines.push(
|
||||||
'# HELP harbor_singbox_tracked_bytes_total Bytes observed by the sing-box TCP/UDP tracker; excludes IP and tunnel overhead and may miss short connections.',
|
'# HELP harbor_singbox_tracked_bytes_total Bytes observed by the configured sing-box traffic collector; excludes IP and tunnel overhead.',
|
||||||
'# TYPE harbor_singbox_tracked_bytes_total counter',
|
'# TYPE harbor_singbox_tracked_bytes_total counter',
|
||||||
);
|
);
|
||||||
for (const series of trackedSeries) {
|
for (const series of trackedSeries) {
|
||||||
|
|||||||
@@ -1,151 +1,75 @@
|
|||||||
import crypto from 'node:crypto';
|
import crypto from 'node:crypto';
|
||||||
import fs from 'node:fs';
|
import type { DatabaseSync } from 'node:sqlite';
|
||||||
import {
|
import {
|
||||||
ACTIVITY_JOURNAL_MAX_EVENTS,
|
ACTIVITY_JOURNAL_MAX_EVENTS,
|
||||||
ACTIVITY_JOURNAL_RETENTION_DAYS,
|
ACTIVITY_JOURNAL_RETENTION_DAYS,
|
||||||
normalizeActivityEventInput,
|
normalizeActivityEventInput,
|
||||||
normalizeStoredActivityEvent,
|
|
||||||
type ActivityJournalEvent,
|
type ActivityJournalEvent,
|
||||||
type ActivityJournalEventInput,
|
type ActivityJournalEventInput,
|
||||||
type ActivityJournalPage,
|
type ActivityJournalPage,
|
||||||
} from '../../shared/activityJournal.js';
|
} from '../../shared/activityJournal.js';
|
||||||
import { createJsonStore } from './stateStore.js';
|
import { transaction } from './sqlite.js';
|
||||||
|
|
||||||
interface JournalState {
|
export function createActivityJournalService({ db, now = () => new Date() }: {
|
||||||
schemaVersion: 1;
|
db: DatabaseSync;
|
||||||
events: ActivityJournalEvent[];
|
|
||||||
}
|
|
||||||
|
|
||||||
const migrateJournal = (value: unknown): JournalState => {
|
|
||||||
const candidate = value && typeof value === 'object' && !Array.isArray(value)
|
|
||||||
? value as Record<string, unknown>
|
|
||||||
: {};
|
|
||||||
return {
|
|
||||||
schemaVersion: 1,
|
|
||||||
events: (Array.isArray(candidate.events) ? candidate.events : [])
|
|
||||||
.map(normalizeStoredActivityEvent)
|
|
||||||
.filter((event): event is ActivityJournalEvent => Boolean(event)),
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
export function createActivityJournalService({
|
|
||||||
filePath,
|
|
||||||
now = () => new Date(),
|
|
||||||
}: {
|
|
||||||
filePath: string;
|
|
||||||
now?: () => Date;
|
now?: () => Date;
|
||||||
}) {
|
}) {
|
||||||
const store = createJsonStore<JournalState>({
|
|
||||||
filePath,
|
|
||||||
defaultValue: { schemaVersion: 1, events: [] },
|
|
||||||
migrate: migrateJournal,
|
|
||||||
});
|
|
||||||
let recoveryRecorded = false;
|
|
||||||
let writeFailed = false;
|
let writeFailed = false;
|
||||||
|
const insert = db.prepare('INSERT INTO journal(id, occurred_at, dedupe_key, value) VALUES (?, ?, ?, ?) ON CONFLICT(dedupe_key) DO NOTHING');
|
||||||
function retained(events: ActivityJournalEvent[]) {
|
function prune() {
|
||||||
const cutoff = now().getTime() - ACTIVITY_JOURNAL_RETENTION_DAYS * 86_400_000;
|
const cutoff = new Date(now().getTime() - ACTIVITY_JOURNAL_RETENTION_DAYS * 86_400_000).toISOString();
|
||||||
return events
|
db.prepare('DELETE FROM journal WHERE occurred_at < ?').run(cutoff);
|
||||||
.filter(({ occurredAt }) => Date.parse(occurredAt) >= cutoff)
|
db.prepare(`DELETE FROM journal WHERE sequence IN (
|
||||||
.slice(-ACTIVITY_JOURNAL_MAX_EVENTS);
|
SELECT sequence FROM journal ORDER BY sequence DESC LIMIT -1 OFFSET ?
|
||||||
|
)`).run(ACTIVITY_JOURNAL_MAX_EVENTS);
|
||||||
}
|
}
|
||||||
|
|
||||||
function append(value: ActivityJournalEventInput) {
|
function append(value: ActivityJournalEventInput) {
|
||||||
const input = normalizeActivityEventInput(value);
|
const input = normalizeActivityEventInput(value);
|
||||||
const storedInput = input.dedupeKey ? {
|
const event: ActivityJournalEvent = {
|
||||||
...input,
|
...input,
|
||||||
dedupeKey: `${input.type}:sha256:${crypto.createHash('sha256').update(input.dedupeKey).digest('hex')}`,
|
|
||||||
} : input;
|
|
||||||
let appended: ActivityJournalEvent | null = null;
|
|
||||||
try {
|
|
||||||
store.update((state) => {
|
|
||||||
const events = retained(state.events);
|
|
||||||
if (storedInput.dedupeKey && events.some(({ dedupeKey }) => dedupeKey === storedInput.dedupeKey)) {
|
|
||||||
return { schemaVersion: 1, events };
|
|
||||||
}
|
|
||||||
appended = {
|
|
||||||
id: crypto.randomUUID(),
|
id: crypto.randomUUID(),
|
||||||
occurredAt: now().toISOString(),
|
occurredAt: now().toISOString(),
|
||||||
...storedInput,
|
dedupeKey: input.dedupeKey
|
||||||
|
? `${input.type}:sha256:${crypto.createHash('sha256').update(input.dedupeKey).digest('hex')}`
|
||||||
|
: null,
|
||||||
};
|
};
|
||||||
return { schemaVersion: 1, events: retained([...events, appended]) };
|
try {
|
||||||
|
const appended = transaction(db, () => {
|
||||||
|
prune();
|
||||||
|
const { changes } = insert.run(event.id, event.occurredAt, event.dedupeKey, JSON.stringify(event));
|
||||||
|
prune();
|
||||||
|
return Number(changes) ? event : null;
|
||||||
});
|
});
|
||||||
writeFailed = false;
|
writeFailed = false;
|
||||||
|
return appended;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
writeFailed = true;
|
writeFailed = true;
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
return appended;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function ensureRecoveryEvent() {
|
|
||||||
if (!store.recovery || recoveryRecorded) return;
|
|
||||||
append({
|
|
||||||
type: 'journal.recovered',
|
|
||||||
severity: 'warning',
|
|
||||||
source: 'storage',
|
|
||||||
dedupeKey: `journal.recovered:${store.recovery.recoveredAt}`,
|
|
||||||
data: {},
|
|
||||||
});
|
|
||||||
recoveryRecorded = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
function page(limitValue: unknown = 50, cursorValue: unknown = null): ActivityJournalPage {
|
function page(limitValue: unknown = 50, cursorValue: unknown = null): ActivityJournalPage {
|
||||||
|
const base = { retentionDays: ACTIVITY_JOURNAL_RETENTION_DAYS, generatedAt: now().toISOString() } as const;
|
||||||
try {
|
try {
|
||||||
let state = store.read();
|
transaction(db, prune);
|
||||||
ensureRecoveryEvent();
|
|
||||||
if (store.recovery) state = store.read();
|
|
||||||
const retainedEvents = retained(state.events);
|
|
||||||
if (retainedEvents.length !== state.events.length) {
|
|
||||||
state = store.update(() => ({ schemaVersion: 1, events: retainedEvents }));
|
|
||||||
}
|
|
||||||
const events = [...state.events].reverse();
|
|
||||||
const limit = Math.min(100, Math.max(1, Number.isSafeInteger(limitValue) ? Number(limitValue) : 50));
|
const limit = Math.min(100, Math.max(1, Number.isSafeInteger(limitValue) ? Number(limitValue) : 50));
|
||||||
const cursor = typeof cursorValue === 'string' ? cursorValue : '';
|
const cursor = typeof cursorValue === 'string' ? cursorValue : '';
|
||||||
const cursorIndex = cursor ? events.findIndex(({ id }) => id === cursor) : -1;
|
const before = cursor ? db.prepare('SELECT sequence FROM journal WHERE id = ?').get(cursor) : null;
|
||||||
if (cursor && cursorIndex < 0) return {
|
const rows = cursor && !before ? [] : db.prepare(`
|
||||||
events: [],
|
SELECT id, value FROM journal WHERE (? IS NULL OR sequence < ?) ORDER BY sequence DESC LIMIT ?
|
||||||
nextCursor: null,
|
`).all(before?.sequence ?? null, before?.sequence ?? null, limit + 1);
|
||||||
retentionDays: 30,
|
const selected = rows.slice(0, limit);
|
||||||
generatedAt: now().toISOString(),
|
|
||||||
storage: writeFailed
|
|
||||||
? { status: 'error', errorCode: 'JOURNAL_UNAVAILABLE' }
|
|
||||||
: { status: 'ready', errorCode: null },
|
|
||||||
};
|
|
||||||
const safeStart = cursorIndex + 1;
|
|
||||||
const selected = events.slice(safeStart, safeStart + limit);
|
|
||||||
return {
|
return {
|
||||||
events: selected.map((event) => ({ ...event, dedupeKey: null })),
|
...base,
|
||||||
nextCursor: safeStart + selected.length < events.length ? selected.at(-1)?.id || null : null,
|
events: selected.map((row) => ({ ...JSON.parse(String(row.value)) as ActivityJournalEvent, dedupeKey: null })),
|
||||||
retentionDays: 30,
|
nextCursor: rows.length > limit ? String(selected.at(-1)?.id) : null,
|
||||||
generatedAt: now().toISOString(),
|
|
||||||
storage: writeFailed
|
storage: writeFailed
|
||||||
? { status: 'error', errorCode: 'JOURNAL_UNAVAILABLE' }
|
? { status: 'error', errorCode: 'JOURNAL_UNAVAILABLE' }
|
||||||
: { status: 'ready', errorCode: null },
|
: { status: 'ready', errorCode: null },
|
||||||
};
|
};
|
||||||
} catch {
|
} catch {
|
||||||
return {
|
return { ...base, events: [], nextCursor: null, storage: { status: 'error', errorCode: 'JOURNAL_UNAVAILABLE' } };
|
||||||
events: [],
|
|
||||||
nextCursor: null,
|
|
||||||
retentionDays: 30,
|
|
||||||
generatedAt: now().toISOString(),
|
|
||||||
storage: { status: 'error', errorCode: 'JOURNAL_UNAVAILABLE' },
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (fs.existsSync(filePath)) {
|
|
||||||
try {
|
|
||||||
const state = store.read();
|
|
||||||
const retainedEvents = retained(state.events);
|
|
||||||
if (retainedEvents.length !== state.events.length) {
|
|
||||||
store.update(() => ({ schemaVersion: 1, events: retainedEvents }));
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
writeFailed = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return { append, page };
|
return { append, page };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,591 @@
|
|||||||
|
import crypto from 'node:crypto';
|
||||||
|
import dgram from 'node:dgram';
|
||||||
|
import { getServers as dnsGetServers } from 'node:dns';
|
||||||
|
import { lookup as dnsLookup } from 'node:dns/promises';
|
||||||
|
import net from 'node:net';
|
||||||
|
import { spawn } from 'node:child_process';
|
||||||
|
import { once } from 'node:events';
|
||||||
|
|
||||||
|
import {
|
||||||
|
DNS_DIAGNOSTIC_DOMAINS,
|
||||||
|
DNS_DIAGNOSTIC_RESOLVERS,
|
||||||
|
type CustomDnsDomain,
|
||||||
|
type CustomDnsResolver,
|
||||||
|
type DnsDomainDefinition,
|
||||||
|
type DnsResolverDefinition,
|
||||||
|
} from '../../shared/connectivityDiagnostics.js';
|
||||||
|
|
||||||
|
type PathKind = 'direct' | 'vpn';
|
||||||
|
type RecordKind = 'A' | 'AAAA';
|
||||||
|
|
||||||
|
interface ParsedDnsResponse {
|
||||||
|
rcode: string;
|
||||||
|
truncated: boolean;
|
||||||
|
ipv4: string[];
|
||||||
|
ipv6: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface WireExchange {
|
||||||
|
response: Buffer;
|
||||||
|
transport: 'udp' | 'tcp' | 'doh';
|
||||||
|
latencyMs: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface QueryPathResult {
|
||||||
|
status: string;
|
||||||
|
rcode: string | null;
|
||||||
|
ipv4: string[];
|
||||||
|
ipv6: string[];
|
||||||
|
latencyMs: number | null;
|
||||||
|
transport: string | null;
|
||||||
|
error: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ExchangeRequest {
|
||||||
|
path: PathKind;
|
||||||
|
resolver: DnsResolverDefinition;
|
||||||
|
packet: Buffer;
|
||||||
|
proxyPort: number;
|
||||||
|
timeoutMs: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
type ClassicExchange = (request: ExchangeRequest) => Promise<WireExchange>;
|
||||||
|
type DohExchange = (request: ExchangeRequest) => Promise<WireExchange>;
|
||||||
|
type TcpExchange = (request: ExchangeRequest) => Promise<WireExchange>;
|
||||||
|
|
||||||
|
const RCODE = ['NOERROR', 'FORMERR', 'SERVFAIL', 'NXDOMAIN', 'NOTIMP', 'REFUSED'];
|
||||||
|
const MAX_DNS_PACKET_BYTES = 65_535;
|
||||||
|
const CLASSIC_TIMEOUT_MS = 2_500;
|
||||||
|
const DOH_TIMEOUT_MS = 5_000;
|
||||||
|
const MAX_PARALLEL_RESOLVERS = 3;
|
||||||
|
|
||||||
|
const BLOCKED_IPV4 = new net.BlockList();
|
||||||
|
for (const [address, prefix] of [
|
||||||
|
['0.0.0.0', 8], ['10.0.0.0', 8], ['100.64.0.0', 10], ['127.0.0.0', 8],
|
||||||
|
['169.254.0.0', 16], ['172.16.0.0', 12], ['192.168.0.0', 16],
|
||||||
|
['192.0.0.0', 24], ['192.0.2.0', 24], ['192.88.99.0', 24],
|
||||||
|
['198.18.0.0', 15], ['198.51.100.0', 24], ['203.0.113.0', 24],
|
||||||
|
['224.0.0.0', 4], ['240.0.0.0', 4],
|
||||||
|
] as Array<[string, number]>) BLOCKED_IPV4.addSubnet(address, prefix, 'ipv4');
|
||||||
|
const BLOCKED_IPV6 = new net.BlockList();
|
||||||
|
for (const [address, prefix] of [
|
||||||
|
['::', 128], ['::1', 128], ['64:ff9b:1::', 48], ['100::', 64],
|
||||||
|
['2001:db8::', 32], ['3fff::', 20], ['5f00::', 16],
|
||||||
|
['fc00::', 7], ['fe80::', 10], ['ff00::', 8],
|
||||||
|
] as Array<[string, number]>) BLOCKED_IPV6.addSubnet(address, prefix, 'ipv6');
|
||||||
|
|
||||||
|
function isPublicAddress(address: string) {
|
||||||
|
const family = net.isIP(address);
|
||||||
|
if (family === 4) return !BLOCKED_IPV4.check(address, 'ipv4');
|
||||||
|
if (family === 6) return !BLOCKED_IPV6.check(address, 'ipv6');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function encodeName(hostname: string) {
|
||||||
|
const labels = hostname.split('.');
|
||||||
|
const chunks = labels.map((label) => {
|
||||||
|
const value = Buffer.from(label, 'ascii');
|
||||||
|
if (!value.length || value.length > 63) throw new Error('invalid DNS hostname');
|
||||||
|
return Buffer.concat([Buffer.from([value.length]), value]);
|
||||||
|
});
|
||||||
|
return Buffer.concat([...chunks, Buffer.from([0])]);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildDnsQuery(hostname: string, type: RecordKind, id: number) {
|
||||||
|
const header = Buffer.alloc(12);
|
||||||
|
header.writeUInt16BE(id, 0);
|
||||||
|
header.writeUInt16BE(0x0100, 2);
|
||||||
|
header.writeUInt16BE(1, 4);
|
||||||
|
const question = Buffer.alloc(4);
|
||||||
|
question.writeUInt16BE(type === 'A' ? 1 : 28, 0);
|
||||||
|
question.writeUInt16BE(1, 2);
|
||||||
|
return Buffer.concat([header, encodeName(hostname), question]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function readName(packet: Buffer, start: number) {
|
||||||
|
let offset = start;
|
||||||
|
let next = start;
|
||||||
|
let jumped = false;
|
||||||
|
const labels: string[] = [];
|
||||||
|
const visited = new Set<number>();
|
||||||
|
for (let depth = 0; depth < 64; depth += 1) {
|
||||||
|
if (offset >= packet.length) throw new Error('malformed DNS name');
|
||||||
|
const length = packet[offset];
|
||||||
|
if ((length & 0xc0) === 0xc0) {
|
||||||
|
if (offset + 1 >= packet.length) throw new Error('malformed DNS pointer');
|
||||||
|
const pointer = ((length & 0x3f) << 8) | packet[offset + 1];
|
||||||
|
if (visited.has(pointer)) throw new Error('recursive DNS pointer');
|
||||||
|
visited.add(pointer);
|
||||||
|
if (!jumped) next = offset + 2;
|
||||||
|
jumped = true;
|
||||||
|
offset = pointer;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (length & 0xc0) throw new Error('unsupported DNS label');
|
||||||
|
offset += 1;
|
||||||
|
if (length === 0) {
|
||||||
|
if (!jumped) next = offset;
|
||||||
|
return { name: labels.join('.'), next };
|
||||||
|
}
|
||||||
|
if (offset + length > packet.length) throw new Error('malformed DNS label');
|
||||||
|
labels.push(packet.subarray(offset, offset + length).toString('ascii'));
|
||||||
|
offset += length;
|
||||||
|
if (!jumped) next = offset;
|
||||||
|
}
|
||||||
|
throw new Error('DNS name is too deep');
|
||||||
|
}
|
||||||
|
|
||||||
|
function ipv6Address(value: Buffer) {
|
||||||
|
const groups = [];
|
||||||
|
for (let offset = 0; offset < 16; offset += 2) groups.push(value.readUInt16BE(offset).toString(16));
|
||||||
|
return groups.join(':');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseDnsResponse(packet: Buffer, expectedId: number): ParsedDnsResponse {
|
||||||
|
if (packet.length < 12 || packet.length > MAX_DNS_PACKET_BYTES) throw new Error('malformed DNS response');
|
||||||
|
if (packet.readUInt16BE(0) !== expectedId || !(packet.readUInt16BE(2) & 0x8000)) {
|
||||||
|
throw new Error('unexpected DNS response');
|
||||||
|
}
|
||||||
|
const flags = packet.readUInt16BE(2);
|
||||||
|
const questionCount = packet.readUInt16BE(4);
|
||||||
|
const answerCount = packet.readUInt16BE(6);
|
||||||
|
let offset = 12;
|
||||||
|
for (let index = 0; index < questionCount; index += 1) {
|
||||||
|
offset = readName(packet, offset).next;
|
||||||
|
if (offset + 4 > packet.length) throw new Error('malformed DNS question');
|
||||||
|
offset += 4;
|
||||||
|
}
|
||||||
|
const ipv4: string[] = [];
|
||||||
|
const ipv6: string[] = [];
|
||||||
|
for (let index = 0; index < answerCount; index += 1) {
|
||||||
|
offset = readName(packet, offset).next;
|
||||||
|
if (offset + 10 > packet.length) throw new Error('malformed DNS answer');
|
||||||
|
const type = packet.readUInt16BE(offset);
|
||||||
|
const dnsClass = packet.readUInt16BE(offset + 2);
|
||||||
|
const length = packet.readUInt16BE(offset + 8);
|
||||||
|
offset += 10;
|
||||||
|
if (offset + length > packet.length) throw new Error('malformed DNS data');
|
||||||
|
if (dnsClass === 1 && type === 1 && length === 4) ipv4.push([...packet.subarray(offset, offset + 4)].join('.'));
|
||||||
|
if (dnsClass === 1 && type === 28 && length === 16) ipv6.push(ipv6Address(packet.subarray(offset, offset + 16)));
|
||||||
|
offset += length;
|
||||||
|
}
|
||||||
|
const code = flags & 0x0f;
|
||||||
|
return {
|
||||||
|
rcode: RCODE[code] || `RCODE_${code}`,
|
||||||
|
truncated: Boolean(flags & 0x0200),
|
||||||
|
ipv4: [...new Set(ipv4)],
|
||||||
|
ipv6: [...new Set(ipv6)],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolverEndpoint(endpoint: string) {
|
||||||
|
if (net.isIP(endpoint)) return { address: endpoint, port: 53 };
|
||||||
|
const ipv6 = /^\[([^\]]+)\]:(\d+)$/.exec(endpoint);
|
||||||
|
if (ipv6 && net.isIP(ipv6[1]) === 6) return { address: ipv6[1], port: Number(ipv6[2]) };
|
||||||
|
const ipv4 = /^([^:]+):(\d+)$/.exec(endpoint);
|
||||||
|
if (ipv4 && net.isIP(ipv4[1]) === 4) return { address: ipv4[1], port: Number(ipv4[2]) };
|
||||||
|
throw new Error('invalid DNS resolver');
|
||||||
|
}
|
||||||
|
|
||||||
|
function timeoutError() {
|
||||||
|
const error = new Error('timeout');
|
||||||
|
Object.assign(error, { code: 'ETIMEDOUT' });
|
||||||
|
return error;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function udpExchange(address: string, port: number, packet: Buffer, timeoutMs: number) {
|
||||||
|
const socket = dgram.createSocket(net.isIP(address) === 6 ? 'udp6' : 'udp4');
|
||||||
|
return new Promise<Buffer>((resolve, reject) => {
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
socket.close();
|
||||||
|
reject(timeoutError());
|
||||||
|
}, timeoutMs);
|
||||||
|
socket.once('error', (error) => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
socket.close();
|
||||||
|
reject(error);
|
||||||
|
});
|
||||||
|
socket.once('message', (message) => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
socket.close();
|
||||||
|
resolve(message);
|
||||||
|
});
|
||||||
|
socket.send(packet, port, address, (error) => {
|
||||||
|
if (!error) return;
|
||||||
|
clearTimeout(timer);
|
||||||
|
socket.close();
|
||||||
|
reject(error);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readExact(socket: net.Socket, length: number) {
|
||||||
|
const chunks: Buffer[] = [];
|
||||||
|
let total = 0;
|
||||||
|
while (total < length) {
|
||||||
|
const chunk = socket.read(length - total) as Buffer | null;
|
||||||
|
if (chunk) {
|
||||||
|
chunks.push(chunk);
|
||||||
|
total += chunk.length;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
await Promise.race([
|
||||||
|
once(socket, 'readable'),
|
||||||
|
once(socket, 'error').then(([error]) => Promise.reject(error)),
|
||||||
|
once(socket, 'close').then(() => Promise.reject(new Error('connection closed'))),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
return Buffer.concat(chunks, total);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function connectSocket(host: string, port: number, timeoutMs: number) {
|
||||||
|
const socket = net.connect({ host, port });
|
||||||
|
socket.setTimeout(timeoutMs, () => socket.destroy(timeoutError()));
|
||||||
|
await Promise.race([
|
||||||
|
once(socket, 'connect'),
|
||||||
|
once(socket, 'error').then(([error]) => Promise.reject(error)),
|
||||||
|
]);
|
||||||
|
return socket;
|
||||||
|
}
|
||||||
|
|
||||||
|
function socksAddress(address: string, port: number) {
|
||||||
|
const family = net.isIP(address);
|
||||||
|
const portBytes = Buffer.alloc(2);
|
||||||
|
portBytes.writeUInt16BE(port, 0);
|
||||||
|
if (family === 4) return Buffer.concat([Buffer.from([1, ...address.split('.').map(Number)]), portBytes]);
|
||||||
|
if (family === 6) {
|
||||||
|
const bytes = Buffer.alloc(16);
|
||||||
|
const [head = '', tail = ''] = address.split('::');
|
||||||
|
const headGroups = head ? head.split(':') : [];
|
||||||
|
const tailGroups = tail ? tail.split(':') : [];
|
||||||
|
const groups = address.includes('::')
|
||||||
|
? [...headGroups, ...Array(8 - headGroups.length - tailGroups.length).fill('0'), ...tailGroups]
|
||||||
|
: headGroups;
|
||||||
|
if (groups.length !== 8) throw new Error('invalid IPv6 resolver');
|
||||||
|
groups.forEach((group, index) => bytes.writeUInt16BE(Number.parseInt(group || '0', 16), index * 2));
|
||||||
|
return Buffer.concat([Buffer.from([4]), bytes, portBytes]);
|
||||||
|
}
|
||||||
|
throw new Error('invalid SOCKS destination');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function socksHandshake(command: 1 | 3, address: string, port: number, proxyPort: number, timeoutMs: number) {
|
||||||
|
const socket = await connectSocket('127.0.0.1', proxyPort, timeoutMs);
|
||||||
|
socket.write(Buffer.from([5, 1, 0]));
|
||||||
|
const greeting = await readExact(socket, 2);
|
||||||
|
if (greeting[0] !== 5 || greeting[1] !== 0) throw new Error('SOCKS authentication failed');
|
||||||
|
socket.write(Buffer.concat([Buffer.from([5, command, 0]), socksAddress(address, port)]));
|
||||||
|
const header = await readExact(socket, 4);
|
||||||
|
if (header[0] !== 5 || header[1] !== 0) throw new Error('SOCKS connection failed');
|
||||||
|
const addressLength = header[3] === 1 ? 4 : header[3] === 4 ? 16 : header[3] === 3 ? (await readExact(socket, 1))[0] : 0;
|
||||||
|
if (!addressLength) throw new Error('invalid SOCKS response');
|
||||||
|
const bound = await readExact(socket, addressLength + 2);
|
||||||
|
const boundAddress = header[3] === 1
|
||||||
|
? [...bound.subarray(0, 4)].join('.')
|
||||||
|
: header[3] === 4
|
||||||
|
? ipv6Address(bound.subarray(0, 16))
|
||||||
|
: bound.subarray(0, addressLength).toString('ascii');
|
||||||
|
return {
|
||||||
|
socket,
|
||||||
|
boundAddress: ['0.0.0.0', '::'].includes(boundAddress) ? '127.0.0.1' : boundAddress,
|
||||||
|
boundPort: bound.readUInt16BE(addressLength),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function tcpDns(socket: net.Socket, packet: Buffer) {
|
||||||
|
const length = Buffer.alloc(2);
|
||||||
|
length.writeUInt16BE(packet.length, 0);
|
||||||
|
socket.write(Buffer.concat([length, packet]));
|
||||||
|
const size = (await readExact(socket, 2)).readUInt16BE(0);
|
||||||
|
if (!size || size > MAX_DNS_PACKET_BYTES) throw new Error('invalid DNS TCP response');
|
||||||
|
return readExact(socket, size);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function defaultClassicExchange({ path, resolver, packet, proxyPort, timeoutMs }: ExchangeRequest): Promise<WireExchange> {
|
||||||
|
const { address, port } = resolverEndpoint(resolver.endpoint);
|
||||||
|
const startedAt = Date.now();
|
||||||
|
if (path === 'direct') {
|
||||||
|
const response = await udpExchange(address, port, packet, timeoutMs);
|
||||||
|
return { response, transport: 'udp', latencyMs: Date.now() - startedAt };
|
||||||
|
}
|
||||||
|
const control = await socksHandshake(3, '0.0.0.0', 0, proxyPort, timeoutMs);
|
||||||
|
try {
|
||||||
|
const target = socksAddress(address, port);
|
||||||
|
const request = Buffer.concat([Buffer.from([0, 0, 0]), target, packet]);
|
||||||
|
const response = await udpExchange(control.boundAddress, control.boundPort, request, timeoutMs);
|
||||||
|
const headerLength = response[3] === 1 ? 10 : response[3] === 4 ? 22 : response[3] === 3 ? 7 + response[4] : 0;
|
||||||
|
if (!headerLength || response[2] !== 0 || response.length <= headerLength) throw new Error('invalid SOCKS UDP response');
|
||||||
|
return { response: response.subarray(headerLength), transport: 'udp', latencyMs: Date.now() - startedAt };
|
||||||
|
} finally {
|
||||||
|
control.socket.destroy();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function tcpFallback({ path, resolver, packet, proxyPort, timeoutMs }: ExchangeRequest) {
|
||||||
|
const { address, port } = resolverEndpoint(resolver.endpoint);
|
||||||
|
const startedAt = Date.now();
|
||||||
|
const socket = path === 'direct'
|
||||||
|
? await connectSocket(address, port, timeoutMs)
|
||||||
|
: (await socksHandshake(1, address, port, proxyPort, timeoutMs)).socket;
|
||||||
|
try {
|
||||||
|
return { response: await tcpDns(socket, packet), transport: 'tcp' as const, latencyMs: Date.now() - startedAt };
|
||||||
|
} finally {
|
||||||
|
socket.destroy();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function defaultDohExchange({ path, resolver, packet, proxyPort, timeoutMs }: ExchangeRequest): Promise<WireExchange> {
|
||||||
|
const endpoint = new URL(resolver.endpoint);
|
||||||
|
const marker = Buffer.from('\n__HARBOR_DOH_META__');
|
||||||
|
const args = [
|
||||||
|
'--silent', '--show-error', '--proto', '=https', '--connect-timeout', '3',
|
||||||
|
'--max-time', String(timeoutMs / 1_000), '--request', 'POST',
|
||||||
|
'--header', 'content-type: application/dns-message', '--header', 'accept: application/dns-message',
|
||||||
|
'--data-binary', '@-', '--output', '-', '--write-out', `${marker.toString()}%{http_code}:%{time_total}`,
|
||||||
|
...(path === 'vpn' ? ['--proxy', `http://127.0.0.1:${proxyPort}`] : ['--noproxy', '*']),
|
||||||
|
...(resolver.bootstrap ? ['--resolve', `${endpoint.hostname}:443:${resolver.bootstrap}`] : []),
|
||||||
|
resolver.endpoint,
|
||||||
|
];
|
||||||
|
const child = spawn('curl', args, { stdio: ['pipe', 'pipe', 'pipe'] });
|
||||||
|
const stdout: Buffer[] = [];
|
||||||
|
const stderr: Buffer[] = [];
|
||||||
|
child.stdout.on('data', (chunk: Buffer) => stdout.push(chunk));
|
||||||
|
child.stderr.on('data', (chunk: Buffer) => stderr.push(chunk));
|
||||||
|
child.stdin.end(packet);
|
||||||
|
const [code] = await once(child, 'close') as [number | null];
|
||||||
|
const output = Buffer.concat(stdout);
|
||||||
|
const markerIndex = output.lastIndexOf(marker);
|
||||||
|
if (code !== 0 || markerIndex < 0) throw new Error(Buffer.concat(stderr).toString('utf8').trim() || 'DoH request failed');
|
||||||
|
const [statusText, secondsText] = output.subarray(markerIndex + marker.length).toString('ascii').split(':');
|
||||||
|
const status = Number(statusText);
|
||||||
|
if (status < 200 || status >= 300) throw new Error(`DoH HTTP ${status}`);
|
||||||
|
const response = output.subarray(0, markerIndex);
|
||||||
|
if (!response.length || response.length > MAX_DNS_PACKET_BYTES) throw new Error('invalid DoH response');
|
||||||
|
return { response, transport: 'doh', latencyMs: Math.round(Number(secondsText) * 1_000) };
|
||||||
|
}
|
||||||
|
|
||||||
|
function availableResolvers(customResolvers: CustomDnsResolver[], getServers: () => string[]) {
|
||||||
|
const system = getServers().map((endpoint) => ({
|
||||||
|
id: `system-${crypto.createHash('sha256').update(endpoint).digest('hex').slice(0, 12)}`,
|
||||||
|
label: 'Системный DNS',
|
||||||
|
kind: 'dns' as const,
|
||||||
|
endpoint,
|
||||||
|
system: true,
|
||||||
|
}));
|
||||||
|
const custom: DnsResolverDefinition[] = customResolvers.map((resolver) => ({ ...resolver, custom: true }));
|
||||||
|
return [...system, ...DNS_DIAGNOSTIC_RESOLVERS, ...custom];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function prepareResolver(resolver: DnsResolverDefinition, lookup: typeof dnsLookup) {
|
||||||
|
if (resolver.kind !== 'doh' || resolver.bootstrap) return resolver;
|
||||||
|
const endpoint = new URL(resolver.endpoint);
|
||||||
|
const addresses = await lookup(endpoint.hostname, { all: true, verbatim: true });
|
||||||
|
if (!addresses.length || addresses.some(({ address }) => !isPublicAddress(address))) {
|
||||||
|
throw new Error('DoH endpoint is not public');
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...resolver,
|
||||||
|
bootstrap: addresses.find(({ family }) => family === 4)?.address || addresses[0].address,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function domains(customDomains: CustomDnsDomain[]): DnsDomainDefinition[] {
|
||||||
|
return [...DNS_DIAGNOSTIC_DOMAINS, ...customDomains.map((domain) => ({ ...domain, custom: true }))];
|
||||||
|
}
|
||||||
|
|
||||||
|
function errorCode(error: unknown) {
|
||||||
|
const code = error && typeof error === 'object' && 'code' in error ? String(error.code) : '';
|
||||||
|
if (code === 'ETIMEDOUT' || (error instanceof Error && error.message === 'timeout')) return 'timeout';
|
||||||
|
const message = error instanceof Error ? error.message : '';
|
||||||
|
if (
|
||||||
|
message === 'DoH endpoint is not public'
|
||||||
|
|| message.startsWith('malformed DNS')
|
||||||
|
|| message.startsWith('unexpected DNS')
|
||||||
|
|| /^DoH HTTP \d{3}$/.test(message)
|
||||||
|
) return message;
|
||||||
|
return 'DNS request failed';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function queryType(
|
||||||
|
hostname: string,
|
||||||
|
type: RecordKind,
|
||||||
|
path: PathKind,
|
||||||
|
resolver: DnsResolverDefinition,
|
||||||
|
proxyPort: number,
|
||||||
|
classicExchange: ClassicExchange,
|
||||||
|
dohExchange: DohExchange,
|
||||||
|
tcpExchange: TcpExchange,
|
||||||
|
) {
|
||||||
|
const id = crypto.randomInt(0, 65_536);
|
||||||
|
const packet = buildDnsQuery(hostname, type, id);
|
||||||
|
const exchange = resolver.kind === 'doh' ? dohExchange : classicExchange;
|
||||||
|
let lastError: unknown;
|
||||||
|
for (let attempt = 0; attempt < 2; attempt += 1) {
|
||||||
|
try {
|
||||||
|
let result = await exchange({
|
||||||
|
path, resolver, packet, proxyPort,
|
||||||
|
timeoutMs: resolver.kind === 'doh' ? DOH_TIMEOUT_MS : CLASSIC_TIMEOUT_MS,
|
||||||
|
});
|
||||||
|
let parsed = parseDnsResponse(result.response, id);
|
||||||
|
if (resolver.kind === 'dns' && parsed.truncated) {
|
||||||
|
result = await tcpExchange({ path, resolver, packet, proxyPort, timeoutMs: CLASSIC_TIMEOUT_MS });
|
||||||
|
parsed = parseDnsResponse(result.response, id);
|
||||||
|
}
|
||||||
|
return { ...parsed, latencyMs: result.latencyMs, transport: result.transport };
|
||||||
|
} catch (error) {
|
||||||
|
lastError = error;
|
||||||
|
if (errorCode(error) !== 'timeout') break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw lastError;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function queryPath(
|
||||||
|
hostname: string,
|
||||||
|
path: PathKind,
|
||||||
|
resolver: DnsResolverDefinition,
|
||||||
|
proxyPort: number,
|
||||||
|
classicExchange: ClassicExchange,
|
||||||
|
dohExchange: DohExchange,
|
||||||
|
tcpExchange: TcpExchange,
|
||||||
|
): Promise<QueryPathResult> {
|
||||||
|
const settled = await Promise.allSettled((['A', 'AAAA'] as RecordKind[]).map((type) => (
|
||||||
|
queryType(hostname, type, path, resolver, proxyPort, classicExchange, dohExchange, tcpExchange)
|
||||||
|
)));
|
||||||
|
const values = settled.flatMap((result) => result.status === 'fulfilled' ? [result.value] : []);
|
||||||
|
const errors = settled.flatMap((result) => result.status === 'rejected' ? [errorCode(result.reason)] : []);
|
||||||
|
const rcodes = [...new Set(values.map(({ rcode }) => rcode))];
|
||||||
|
const ipv4 = [...new Set(values.flatMap((value) => value.ipv4))];
|
||||||
|
const ipv6 = [...new Set(values.flatMap((value) => value.ipv6))];
|
||||||
|
const rcode = rcodes.find((value) => value !== 'NOERROR') || rcodes[0] || null;
|
||||||
|
const status = ipv4.length || ipv6.length
|
||||||
|
? 'answered'
|
||||||
|
: rcode === 'NXDOMAIN'
|
||||||
|
? 'nxdomain'
|
||||||
|
: rcode === 'SERVFAIL'
|
||||||
|
? 'servfail'
|
||||||
|
: values.length
|
||||||
|
? 'no-addresses'
|
||||||
|
: errors.every((error) => error === 'timeout')
|
||||||
|
? 'timeout'
|
||||||
|
: 'error';
|
||||||
|
return {
|
||||||
|
status,
|
||||||
|
rcode,
|
||||||
|
ipv4,
|
||||||
|
ipv6,
|
||||||
|
latencyMs: values.length ? Math.max(...values.map(({ latencyMs }) => latencyMs)) : null,
|
||||||
|
transport: [...new Set(values.map(({ transport }) => transport))].join('+') || null,
|
||||||
|
error: errors.length ? [...new Set(errors)].join('; ') : null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function addressSet(result: QueryPathResult) {
|
||||||
|
return [...result.ipv4, ...result.ipv6].sort();
|
||||||
|
}
|
||||||
|
|
||||||
|
function comparison(direct: QueryPathResult, vpn: QueryPathResult) {
|
||||||
|
const directOk = direct.status === 'answered' || direct.status === 'no-addresses' || direct.status === 'nxdomain';
|
||||||
|
const vpnOk = vpn.status === 'answered' || vpn.status === 'no-addresses' || vpn.status === 'nxdomain';
|
||||||
|
if (!directOk && !vpnOk) return 'failed';
|
||||||
|
if (directOk && !vpnOk) return 'direct-only';
|
||||||
|
if (!directOk && vpnOk) return 'vpn-only';
|
||||||
|
return JSON.stringify(addressSet(direct)) === JSON.stringify(addressSet(vpn)) && direct.rcode === vpn.rcode
|
||||||
|
? 'same'
|
||||||
|
: 'different';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createDnsDiagnosticsService({
|
||||||
|
proxyPort,
|
||||||
|
getServers = dnsGetServers,
|
||||||
|
lookup = dnsLookup,
|
||||||
|
classicExchange = defaultClassicExchange,
|
||||||
|
dohExchange = defaultDohExchange,
|
||||||
|
tcpExchange = tcpFallback,
|
||||||
|
now = () => new Date().toISOString(),
|
||||||
|
}: {
|
||||||
|
proxyPort: number;
|
||||||
|
getServers?: () => string[];
|
||||||
|
lookup?: typeof dnsLookup;
|
||||||
|
classicExchange?: ClassicExchange;
|
||||||
|
dohExchange?: DohExchange;
|
||||||
|
tcpExchange?: TcpExchange;
|
||||||
|
now?: () => string;
|
||||||
|
}) {
|
||||||
|
async function catalog(customResolvers: CustomDnsResolver[] = [], customDomains: CustomDnsDomain[] = []) {
|
||||||
|
return {
|
||||||
|
resolvers: availableResolvers(customResolvers, getServers),
|
||||||
|
domains: domains(customDomains),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function run({
|
||||||
|
vpnAvailable,
|
||||||
|
customResolvers = [],
|
||||||
|
customDomains = [],
|
||||||
|
domainId,
|
||||||
|
resolverId = null,
|
||||||
|
}: {
|
||||||
|
vpnAvailable: boolean;
|
||||||
|
customResolvers?: CustomDnsResolver[];
|
||||||
|
customDomains?: CustomDnsDomain[];
|
||||||
|
domainId: unknown;
|
||||||
|
resolverId?: unknown;
|
||||||
|
}) {
|
||||||
|
const available = await catalog(customResolvers, customDomains);
|
||||||
|
const domain = available.domains.find(({ id }) => id === domainId);
|
||||||
|
if (!domain) throw new Error('Unknown DNS diagnostic domain');
|
||||||
|
const selected = resolverId
|
||||||
|
? available.resolvers.filter(({ id }) => id === resolverId)
|
||||||
|
: available.resolvers;
|
||||||
|
if (!selected.length) throw new Error('Unknown DNS diagnostic resolver');
|
||||||
|
const results: unknown[] = new Array(selected.length);
|
||||||
|
let cursor = 0;
|
||||||
|
await Promise.all(Array.from({ length: Math.min(MAX_PARALLEL_RESOLVERS, selected.length) }, async () => {
|
||||||
|
while (cursor < selected.length) {
|
||||||
|
const index = cursor;
|
||||||
|
cursor += 1;
|
||||||
|
const catalogResolver = selected[index];
|
||||||
|
let resolver: DnsResolverDefinition;
|
||||||
|
try {
|
||||||
|
resolver = await prepareResolver(catalogResolver, lookup);
|
||||||
|
} catch (error) {
|
||||||
|
const unavailable: QueryPathResult = {
|
||||||
|
status: 'error', rcode: null, ipv4: [], ipv6: [], latencyMs: null,
|
||||||
|
transport: null, error: errorCode(error),
|
||||||
|
};
|
||||||
|
results[index] = {
|
||||||
|
resolver: catalogResolver,
|
||||||
|
direct: unavailable,
|
||||||
|
vpn: vpnAvailable ? unavailable : { ...unavailable, status: 'vpn-off', error: 'VPN выключен' },
|
||||||
|
comparison: 'failed',
|
||||||
|
warning: null,
|
||||||
|
};
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const directPromise = queryPath(domain.hostname, 'direct', resolver, proxyPort, classicExchange, dohExchange, tcpExchange);
|
||||||
|
const vpnPromise = vpnAvailable
|
||||||
|
? queryPath(domain.hostname, 'vpn', resolver, proxyPort, classicExchange, dohExchange, tcpExchange)
|
||||||
|
: Promise.resolve<QueryPathResult>({
|
||||||
|
status: 'vpn-off', rcode: null, ipv4: [], ipv6: [], latencyMs: null,
|
||||||
|
transport: null, error: 'VPN выключен',
|
||||||
|
});
|
||||||
|
const [direct, vpn] = await Promise.all([directPromise, vpnPromise]);
|
||||||
|
const addresses = [...direct.ipv4, ...direct.ipv6, ...vpn.ipv4, ...vpn.ipv6];
|
||||||
|
results[index] = {
|
||||||
|
resolver: catalogResolver,
|
||||||
|
direct,
|
||||||
|
vpn,
|
||||||
|
comparison: comparison(direct, vpn),
|
||||||
|
warning: domain.builtIn && addresses.some((address) => !isPublicAddress(address))
|
||||||
|
? 'private-address'
|
||||||
|
: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
return { checkedAt: now(), domain, results };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { catalog, run };
|
||||||
|
}
|
||||||
|
|
||||||
|
export type DnsDiagnosticsService = ReturnType<typeof createDnsDiagnosticsService>;
|
||||||
@@ -3,6 +3,7 @@ import http from 'node:http';
|
|||||||
import net from 'node:net';
|
import net from 'node:net';
|
||||||
import { domainToASCII } from 'node:url';
|
import { domainToASCII } from 'node:url';
|
||||||
import { deviceId } from './deviceInventoryService.js';
|
import { deviceId } from './deviceInventoryService.js';
|
||||||
|
import type { NativeTrafficProjectionBatch } from './liveTrafficService.js';
|
||||||
|
|
||||||
const MAX_RESPONSE_BYTES = 4 * 1024 * 1024;
|
const MAX_RESPONSE_BYTES = 4 * 1024 * 1024;
|
||||||
const DEFAULT_MAX_SERIES = 4096;
|
const DEFAULT_MAX_SERIES = 4096;
|
||||||
@@ -17,6 +18,7 @@ const SERVICE_DOMAINS = [
|
|||||||
|
|
||||||
interface ParsedBaseConnection {
|
interface ParsedBaseConnection {
|
||||||
id: string;
|
id: string;
|
||||||
|
startedAt?: string;
|
||||||
upload: bigint;
|
upload: bigint;
|
||||||
download: bigint;
|
download: bigint;
|
||||||
}
|
}
|
||||||
@@ -38,6 +40,7 @@ type ParsedConnection =
|
|||||||
});
|
});
|
||||||
|
|
||||||
interface PreviousConnection {
|
interface PreviousConnection {
|
||||||
|
startedAt?: string;
|
||||||
outcome: AttributionOutcome | 'classified';
|
outcome: AttributionOutcome | 'classified';
|
||||||
key?: string;
|
key?: string;
|
||||||
requestedKey?: string;
|
requestedKey?: string;
|
||||||
@@ -67,7 +70,7 @@ interface RouteSeriesTotal {
|
|||||||
interface DomainTrafficSnapshot {
|
interface DomainTrafficSnapshot {
|
||||||
epoch: string;
|
epoch: string;
|
||||||
observedAt: string | null;
|
observedAt: string | null;
|
||||||
source: { error: string | null };
|
source: { error: string | null; activeConnections: number };
|
||||||
overflowConnections: string;
|
overflowConnections: string;
|
||||||
attributionEvents: Record<AttributionOutcome, string>;
|
attributionEvents: Record<AttributionOutcome, string>;
|
||||||
tracked: Array<Omit<RouteSeriesTotal, 'deviceId' | 'uploadBytes' | 'downloadBytes'> & {
|
tracked: Array<Omit<RouteSeriesTotal, 'deviceId' | 'uploadBytes' | 'downloadBytes'> & {
|
||||||
@@ -170,6 +173,46 @@ function parseConnection(value: unknown, devicesByIp: Map<string, string | null>
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function decimalCounter(value: unknown) {
|
||||||
|
if (typeof value !== 'string' || !/^\d+$/.test(value)) {
|
||||||
|
throw new Error('Sing-box вернул невалидный native traffic counter');
|
||||||
|
}
|
||||||
|
return BigInt(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseNativeConnection(value: unknown): ParsedConnection {
|
||||||
|
const connection = record(value);
|
||||||
|
const inbound = record(connection.inbound);
|
||||||
|
const origin = record(connection.origin);
|
||||||
|
const destination = record(connection.destination);
|
||||||
|
const route = record(connection.route);
|
||||||
|
const traffic = record(connection.traffic);
|
||||||
|
const parsed = {
|
||||||
|
id: String(connection.id || ''),
|
||||||
|
startedAt: typeof connection.startedAt === 'string' ? connection.startedAt : undefined,
|
||||||
|
upload: decimalCounter(traffic.uploadBytes),
|
||||||
|
download: decimalCounter(traffic.downloadBytes),
|
||||||
|
};
|
||||||
|
if (!parsed.id) throw new Error('Sing-box вернул native traffic без id');
|
||||||
|
const source = sourceFor(`${String(inbound.type || '')}/${String(inbound.tag || '')}`);
|
||||||
|
if (!source) return { ...parsed, outcome: 'unsupported_source' };
|
||||||
|
const outbound: TrafficRoute = route.kind === 'vpn' || route.kind === 'direct' ? route.kind : 'unknown';
|
||||||
|
const currentDeviceId = origin.kind === 'device' && typeof origin.id === 'string' && origin.id
|
||||||
|
? origin.id
|
||||||
|
: null;
|
||||||
|
if (!currentDeviceId) return { ...parsed, outcome: 'unknown_device', source, outbound };
|
||||||
|
const classifiedDomain = classifyDomain(destination.domain);
|
||||||
|
const domain = classifiedDomain || UNKNOWN_DOMAIN;
|
||||||
|
return {
|
||||||
|
...parsed,
|
||||||
|
outcome: classifiedDomain ? 'classified' : 'unresolved_host',
|
||||||
|
deviceId: currentDeviceId,
|
||||||
|
...domain,
|
||||||
|
source,
|
||||||
|
outbound,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export function readSingboxConnections(port: number, timeoutMs = 1500): Promise<unknown> {
|
export function readSingboxConnections(port: number, timeoutMs = 1500): Promise<unknown> {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const request = http.get({ host: '127.0.0.1', port, path: '/connections' }, (response) => {
|
const request = http.get({ host: '127.0.0.1', port, path: '/connections' }, (response) => {
|
||||||
@@ -223,6 +266,9 @@ export function createDomainTrafficService({
|
|||||||
const normalSeriesLimit = maxSeries - 2;
|
const normalSeriesLimit = maxSeries - 2;
|
||||||
let normalSeries = 0;
|
let normalSeries = 0;
|
||||||
let previousConnections = new Map<string, PreviousConnection>();
|
let previousConnections = new Map<string, PreviousConnection>();
|
||||||
|
const settledNativeConnections = new Map<string, PreviousConnection>();
|
||||||
|
let nativeEpoch: string | null = null;
|
||||||
|
let activeConnections = 0;
|
||||||
let overflowConnections = 0n;
|
let overflowConnections = 0n;
|
||||||
const attributionEvents: Record<AttributionOutcome, bigint> = {
|
const attributionEvents: Record<AttributionOutcome, bigint> = {
|
||||||
unresolved_host: 0n,
|
unresolved_host: 0n,
|
||||||
@@ -237,7 +283,7 @@ export function createDomainTrafficService({
|
|||||||
let current: DomainTrafficSnapshot = {
|
let current: DomainTrafficSnapshot = {
|
||||||
epoch,
|
epoch,
|
||||||
observedAt: null,
|
observedAt: null,
|
||||||
source: { error: null },
|
source: { error: null, activeConnections: 0 },
|
||||||
overflowConnections: '0',
|
overflowConnections: '0',
|
||||||
attributionEvents: { unresolved_host: '0', unknown_device: '0', unsupported_source: '0' },
|
attributionEvents: { unresolved_host: '0', unknown_device: '0', unsupported_source: '0' },
|
||||||
tracked: [],
|
tracked: [],
|
||||||
@@ -249,7 +295,7 @@ export function createDomainTrafficService({
|
|||||||
return {
|
return {
|
||||||
epoch,
|
epoch,
|
||||||
observedAt: current.observedAt,
|
observedAt: current.observedAt,
|
||||||
source: { error },
|
source: { error, activeConnections },
|
||||||
overflowConnections: overflowConnections.toString(),
|
overflowConnections: overflowConnections.toString(),
|
||||||
attributionEvents: Object.fromEntries(
|
attributionEvents: Object.fromEntries(
|
||||||
ATTRIBUTION_OUTCOMES.map((outcome) => [outcome, attributionEvents[outcome].toString()]),
|
ATTRIBUTION_OUTCOMES.map((outcome) => [outcome, attributionEvents[outcome].toString()]),
|
||||||
@@ -290,26 +336,30 @@ export function createDomainTrafficService({
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async function performRefresh() {
|
function applyParsedConnections({
|
||||||
try {
|
connections,
|
||||||
const response = record(await observe());
|
reset,
|
||||||
if (!Array.isArray(response.connections)) throw new Error('Sing-box не вернул connections array');
|
closedIds = [],
|
||||||
const devicesByIp = new Map<string, string | null>();
|
observed,
|
||||||
const deviceLabels = new Map<string, string>();
|
deviceLabels,
|
||||||
const observedDevices = devices();
|
sourceActiveConnections,
|
||||||
for (const value of Array.isArray(observedDevices) ? observedDevices : []) {
|
}: {
|
||||||
const device = record(value);
|
connections: ParsedConnection[];
|
||||||
const ip = String(device.ip || '');
|
reset: boolean;
|
||||||
const id = typeof device.mac === 'string' ? deviceId(device.mac.toLowerCase()) : null;
|
closedIds?: string[];
|
||||||
if (!net.isIPv4(ip) || !id) continue;
|
observed: Date;
|
||||||
devicesByIp.set(ip, devicesByIp.has(ip) ? null : id);
|
deviceLabels: Map<string, string>;
|
||||||
deviceLabels.set(id, publicDeviceLabel(device));
|
sourceActiveConnections?: number;
|
||||||
}
|
}) {
|
||||||
const connections = response.connections.map((connection) => parseConnection(connection, devicesByIp));
|
const nextConnections = reset
|
||||||
const activeConnections = new Map<string, PreviousConnection>();
|
? new Map<string, PreviousConnection>()
|
||||||
|
: new Map(previousConnections);
|
||||||
const activityEntries: ActivityEntry[] = [];
|
const activityEntries: ActivityEntry[] = [];
|
||||||
for (const connection of connections) {
|
for (const connection of connections) {
|
||||||
const previous = previousConnections.get(connection.id);
|
const settled = settledNativeConnections.get(connection.id);
|
||||||
|
const previous = previousConnections.get(connection.id)
|
||||||
|
?? (connection.startedAt && settled?.startedAt === connection.startedAt ? settled : undefined);
|
||||||
|
if (previous === settled) settledNativeConnections.delete(connection.id);
|
||||||
if (connection.outcome !== 'classified' && previous?.outcome !== connection.outcome) {
|
if (connection.outcome !== 'classified' && previous?.outcome !== connection.outcome) {
|
||||||
attributionEvents[connection.outcome] += 1n;
|
attributionEvents[connection.outcome] += 1n;
|
||||||
}
|
}
|
||||||
@@ -342,7 +392,8 @@ export function createDomainTrafficService({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (connection.outcome === 'unknown_device' || connection.outcome === 'unsupported_source') {
|
if (connection.outcome === 'unknown_device' || connection.outcome === 'unsupported_source') {
|
||||||
activeConnections.set(connection.id, {
|
nextConnections.set(connection.id, {
|
||||||
|
startedAt: connection.startedAt,
|
||||||
outcome: connection.outcome,
|
outcome: connection.outcome,
|
||||||
countedUpload: previous?.countedUpload ?? null,
|
countedUpload: previous?.countedUpload ?? null,
|
||||||
countedDownload: previous?.countedDownload ?? null,
|
countedDownload: previous?.countedDownload ?? null,
|
||||||
@@ -399,7 +450,8 @@ export function createDomainTrafficService({
|
|||||||
total.uploadBytes += uploadDelta;
|
total.uploadBytes += uploadDelta;
|
||||||
total.downloadBytes += downloadDelta;
|
total.downloadBytes += downloadDelta;
|
||||||
totals.set(key, total);
|
totals.set(key, total);
|
||||||
activeConnections.set(connection.id, {
|
nextConnections.set(connection.id, {
|
||||||
|
startedAt: connection.startedAt,
|
||||||
outcome: connection.outcome,
|
outcome: connection.outcome,
|
||||||
key,
|
key,
|
||||||
requestedKey,
|
requestedKey,
|
||||||
@@ -409,8 +461,19 @@ export function createDomainTrafficService({
|
|||||||
trackedDownload: connection.download,
|
trackedDownload: connection.download,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
previousConnections = activeConnections;
|
for (const id of closedIds) {
|
||||||
const observed = now();
|
const baseline = nextConnections.get(id);
|
||||||
|
if (baseline?.startedAt) {
|
||||||
|
settledNativeConnections.delete(id);
|
||||||
|
settledNativeConnections.set(id, baseline);
|
||||||
|
while (settledNativeConnections.size > 2_048) {
|
||||||
|
settledNativeConnections.delete(settledNativeConnections.keys().next().value as string);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
nextConnections.delete(id);
|
||||||
|
}
|
||||||
|
previousConnections = nextConnections;
|
||||||
|
activeConnections = sourceActiveConnections ?? nextConnections.size;
|
||||||
if (activityEnabled) {
|
if (activityEnabled) {
|
||||||
activitySamples.push({ at: observed.getTime(), entries: activityEntries });
|
activitySamples.push({ at: observed.getTime(), entries: activityEntries });
|
||||||
activitySamples = activitySamples.filter(({ at }) => at >= observed.getTime() - 10_000);
|
activitySamples = activitySamples.filter(({ at }) => at >= observed.getTime() - 10_000);
|
||||||
@@ -418,6 +481,64 @@ export function createDomainTrafficService({
|
|||||||
current = { ...current, observedAt: observed.toISOString() };
|
current = { ...current, observedAt: observed.toISOString() };
|
||||||
current = buildSnapshot();
|
current = buildSnapshot();
|
||||||
return current;
|
return current;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function performRefresh() {
|
||||||
|
try {
|
||||||
|
const response = record(await observe());
|
||||||
|
if (!Array.isArray(response.connections)) throw new Error('Sing-box не вернул connections array');
|
||||||
|
const devicesByIp = new Map<string, string | null>();
|
||||||
|
const deviceLabels = new Map<string, string>();
|
||||||
|
const observedDevices = devices();
|
||||||
|
for (const value of Array.isArray(observedDevices) ? observedDevices : []) {
|
||||||
|
const device = record(value);
|
||||||
|
const ip = String(device.ip || '');
|
||||||
|
const id = typeof device.mac === 'string' ? deviceId(device.mac.toLowerCase()) : null;
|
||||||
|
if (!net.isIPv4(ip) || !id) continue;
|
||||||
|
devicesByIp.set(ip, devicesByIp.has(ip) ? null : id);
|
||||||
|
deviceLabels.set(id, publicDeviceLabel(device));
|
||||||
|
}
|
||||||
|
const connections = response.connections.map((connection) => parseConnection(connection, devicesByIp));
|
||||||
|
nativeEpoch = null;
|
||||||
|
return applyParsedConnections({
|
||||||
|
connections,
|
||||||
|
reset: true,
|
||||||
|
observed: now(),
|
||||||
|
deviceLabels,
|
||||||
|
sourceActiveConnections: response.connections.length,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
current = buildSnapshot(error instanceof Error ? error.message : String(error));
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function ingestNative(batch: NativeTrafficProjectionBatch) {
|
||||||
|
try {
|
||||||
|
const observed = new Date(batch.observedAt);
|
||||||
|
if (!batch.epoch || Number.isNaN(observed.getTime())) throw new Error('Sing-box вернул невалидный native traffic batch');
|
||||||
|
const deviceLabels = new Map<string, string>();
|
||||||
|
for (const value of batch.connections) {
|
||||||
|
const connection = record(value);
|
||||||
|
const origin = record(connection.origin);
|
||||||
|
if (origin.kind === 'device' && typeof origin.id === 'string' && origin.id) {
|
||||||
|
deviceLabels.set(origin.id, publicDeviceLabel({ alias: origin.label }));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (nativeEpoch !== batch.epoch) {
|
||||||
|
nativeEpoch = batch.epoch;
|
||||||
|
previousConnections = new Map();
|
||||||
|
settledNativeConnections.clear();
|
||||||
|
}
|
||||||
|
const connections = batch.connections.map(parseNativeConnection);
|
||||||
|
const result = applyParsedConnections({
|
||||||
|
connections,
|
||||||
|
reset: batch.reset,
|
||||||
|
closedIds: batch.closedIds,
|
||||||
|
observed,
|
||||||
|
deviceLabels,
|
||||||
|
});
|
||||||
|
return result;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
current = buildSnapshot(error instanceof Error ? error.message : String(error));
|
current = buildSnapshot(error instanceof Error ? error.message : String(error));
|
||||||
throw error;
|
throw error;
|
||||||
@@ -492,5 +613,5 @@ export function createDomainTrafficService({
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
return { snapshot: () => current, refresh, enableActivity, disableActivity, activitySnapshot };
|
return { snapshot: () => current, refresh, ingestNative, enableActivity, disableActivity, activitySnapshot };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,121 @@
|
|||||||
|
import fs from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
import type { DatabaseSync } from 'node:sqlite';
|
||||||
|
import { normalizeStoredActivityEvent, type ActivityJournalEvent } from '../../shared/activityJournal.js';
|
||||||
|
import { normalizeSubscriptionConfig } from '../subscription.js';
|
||||||
|
import { migrateDeviceInventoryState } from './deviceInventoryService.js';
|
||||||
|
import { migrateStoredState } from './stateStore.js';
|
||||||
|
import { openSqlite, transaction } from './sqlite.js';
|
||||||
|
|
||||||
|
function object(value: unknown): Record<string, unknown> {
|
||||||
|
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||||
|
throw new Error('Invalid stored Harbor document');
|
||||||
|
}
|
||||||
|
return value as Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function legacyDocument(directory: string, name: string) {
|
||||||
|
const file = path.join(directory, name);
|
||||||
|
if (!fs.existsSync(file)) return null;
|
||||||
|
// Never run the legacy JSON recovery writer during import: originals are the backup.
|
||||||
|
try {
|
||||||
|
const value: unknown = JSON.parse(fs.readFileSync(file, 'utf8'));
|
||||||
|
return value === null && name === 'subscription-cache.json' ? null : object(value);
|
||||||
|
}
|
||||||
|
catch { throw new Error(`Cannot migrate ${name}: invalid JSON document; original retained`); }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createSqliteDocumentStore<T>(db: DatabaseSync, key: string, migrate: (value: unknown) => T) {
|
||||||
|
const select = db.prepare('SELECT value FROM documents WHERE key = ?');
|
||||||
|
const save = db.prepare('INSERT INTO documents(key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value');
|
||||||
|
function read(): T {
|
||||||
|
const row = select.get(key);
|
||||||
|
if (!row || typeof row.value !== 'string') throw new Error(`Missing Harbor document: ${key}`);
|
||||||
|
return migrate(JSON.parse(row.value));
|
||||||
|
}
|
||||||
|
function write(value: T) {
|
||||||
|
const next = migrate(structuredClone(value));
|
||||||
|
save.run(key, JSON.stringify(next));
|
||||||
|
return structuredClone(next);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
read,
|
||||||
|
write,
|
||||||
|
update: (change: (value: T) => T) => transaction(db, () => {
|
||||||
|
const next = change(read());
|
||||||
|
if (next && typeof next === 'object' && 'then' in next) throw new TypeError('State store mutator must be synchronous');
|
||||||
|
return write(next);
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function openHarborStorage(directory: string) {
|
||||||
|
const db = openSqlite(path.join(directory, 'harbor.sqlite'));
|
||||||
|
let imported = false;
|
||||||
|
try {
|
||||||
|
const version = Number(db.prepare('PRAGMA user_version').get()?.user_version);
|
||||||
|
if (version !== 0 && version !== 1) throw new Error(`Unsupported Harbor database version: ${version}`);
|
||||||
|
transaction(db, () => {
|
||||||
|
db.exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS documents (key TEXT PRIMARY KEY, value TEXT NOT NULL CHECK(json_valid(value))) STRICT;
|
||||||
|
CREATE TABLE IF NOT EXISTS journal (
|
||||||
|
sequence INTEGER PRIMARY KEY, id TEXT NOT NULL UNIQUE, occurred_at TEXT NOT NULL,
|
||||||
|
dedupe_key TEXT UNIQUE, value TEXT NOT NULL CHECK(json_valid(value))
|
||||||
|
) STRICT;
|
||||||
|
CREATE INDEX IF NOT EXISTS journal_time ON journal(occurred_at);
|
||||||
|
`);
|
||||||
|
const initialized = db.prepare("SELECT value FROM documents WHERE key = 'storage-version'").get();
|
||||||
|
if (!initialized) {
|
||||||
|
// All inputs are read and normalized before any imported record is committed.
|
||||||
|
const rawState = legacyDocument(directory, 'state.json') || {};
|
||||||
|
const rawDevices = legacyDocument(directory, 'devices.json') || {};
|
||||||
|
const rawJournal = legacyDocument(directory, 'activity-journal.json');
|
||||||
|
const stateVersion = Number(rawState.schemaVersion || 0);
|
||||||
|
const legacyCache = stateVersion < 5 && !Array.isArray(rawState.profiles)
|
||||||
|
? legacyDocument(directory, 'subscription-cache.json')
|
||||||
|
: null;
|
||||||
|
let cache: unknown = null;
|
||||||
|
if (legacyCache?.config) {
|
||||||
|
const stateUrl = String(rawState.subscriptionUrl || '').trim();
|
||||||
|
const cacheUrl = String(legacyCache.url || '').trim();
|
||||||
|
if (!cacheUrl || (stateUrl && cacheUrl !== stateUrl)) {
|
||||||
|
throw new Error('Cannot migrate subscription cache: owner mismatch; originals retained');
|
||||||
|
}
|
||||||
|
cache = { ...legacyCache, ...normalizeSubscriptionConfig(legacyCache.config) };
|
||||||
|
}
|
||||||
|
const state = migrateStoredState(rawState, cache);
|
||||||
|
const devices = migrateDeviceInventoryState(rawDevices);
|
||||||
|
let events: ActivityJournalEvent[] = [];
|
||||||
|
if (rawJournal) {
|
||||||
|
if (rawJournal.schemaVersion !== 1 || !Array.isArray(rawJournal.events)) {
|
||||||
|
throw new Error('Unsupported activity journal document; original retained');
|
||||||
|
}
|
||||||
|
events = rawJournal.events.map((raw) => {
|
||||||
|
const event = normalizeStoredActivityEvent(raw);
|
||||||
|
if (!event) throw new Error('Invalid activity journal event; original retained');
|
||||||
|
return event;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const insert = db.prepare('INSERT INTO documents(key, value) VALUES (?, ?)');
|
||||||
|
insert.run('state', JSON.stringify(state));
|
||||||
|
insert.run('devices', JSON.stringify(devices));
|
||||||
|
const insertEvent = db.prepare('INSERT INTO journal(id, occurred_at, dedupe_key, value) VALUES (?, ?, ?, ?)');
|
||||||
|
for (const event of events) insertEvent.run(event.id, event.occurredAt, event.dedupeKey, JSON.stringify(event));
|
||||||
|
insert.run('storage-version', '1');
|
||||||
|
imported = true;
|
||||||
|
} else if (initialized.value !== '1') {
|
||||||
|
throw new Error('Unsupported Harbor storage version');
|
||||||
|
}
|
||||||
|
db.exec('PRAGMA user_version = 1');
|
||||||
|
});
|
||||||
|
const state = createSqliteDocumentStore(db, 'state', migrateStoredState);
|
||||||
|
const devices = createSqliteDocumentStore(db, 'devices', migrateDeviceInventoryState);
|
||||||
|
// Validate stored versions before startup. SQLite corruption never falls back to JSON.
|
||||||
|
state.read();
|
||||||
|
devices.read();
|
||||||
|
return { db, state, devices, imported, close: () => db.close() };
|
||||||
|
} catch (error) {
|
||||||
|
db.close();
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,786 @@
|
|||||||
|
import { isIP } from 'node:net';
|
||||||
|
|
||||||
|
import { createClient } from '@connectrpc/connect';
|
||||||
|
import { createGrpcTransport } from '@connectrpc/connect-node';
|
||||||
|
|
||||||
|
import type { LiveTrafficConnection, LiveTrafficSnapshot, LiveTrafficSourceState } from '../../shared/liveTraffic.js';
|
||||||
|
import {
|
||||||
|
ConnectionEventType,
|
||||||
|
StartedService,
|
||||||
|
type Connection,
|
||||||
|
type ConnectionEvents,
|
||||||
|
type Status,
|
||||||
|
} from '../generated/daemon/started_service_pb.js';
|
||||||
|
|
||||||
|
const CONNECTION_INTERVAL = 1_000_000_000n;
|
||||||
|
const SUPPORTED_SINGBOX_VERSION = '1.14.0-rc.5';
|
||||||
|
const SUPPORTED_SINGBOX_API_VERSION = 4;
|
||||||
|
const MAX_VISIBLE = 256;
|
||||||
|
const MAX_SETTLED_IDS = 2048;
|
||||||
|
const MAX_RECENT_CONNECTIONS = 2048;
|
||||||
|
const RECENT_CONNECTION_MS = 30_000;
|
||||||
|
const RETRY_MS = 500;
|
||||||
|
const STALE_MS = 3_000;
|
||||||
|
const VPN_OUTBOUND_TYPES = new Set(['vless', 'vmess', 'trojan', 'shadowsocks', 'hysteria2']);
|
||||||
|
|
||||||
|
interface ActiveConnection {
|
||||||
|
value: Omit<LiveTrafficConnection, 'traffic'>;
|
||||||
|
upload: bigint;
|
||||||
|
download: bigint;
|
||||||
|
uploadRate: bigint;
|
||||||
|
downloadRate: bigint;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface LiveTrafficLedgerOptions {
|
||||||
|
enabled?: boolean;
|
||||||
|
now?: () => Date;
|
||||||
|
gateway?: boolean;
|
||||||
|
resolveOrigin?: (sourceIp: string) => LiveTrafficConnection['origin'];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NativeTrafficProjectionBatch {
|
||||||
|
epoch: string;
|
||||||
|
observedAt: string;
|
||||||
|
reset: boolean;
|
||||||
|
connections: LiveTrafficConnection[];
|
||||||
|
closedIds: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface NativeTrafficClient {
|
||||||
|
getVersion(
|
||||||
|
input: Record<string, never>,
|
||||||
|
options: { signal: AbortSignal; headers?: Record<string, string> },
|
||||||
|
): Promise<{ version: string; apiVersion: number }>;
|
||||||
|
getStartedAt(
|
||||||
|
input: Record<string, never>,
|
||||||
|
options: { signal: AbortSignal; headers?: Record<string, string> },
|
||||||
|
): Promise<{ startedAt: bigint }>;
|
||||||
|
subscribeConnections(
|
||||||
|
input: { interval: bigint },
|
||||||
|
options: { signal: AbortSignal; headers?: Record<string, string> },
|
||||||
|
): AsyncIterable<ConnectionEvents>;
|
||||||
|
subscribeStatus(
|
||||||
|
input: { interval: bigint },
|
||||||
|
options: { signal: AbortSignal; headers?: Record<string, string> },
|
||||||
|
): AsyncIterable<Status>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface LiveTrafficServiceOptions {
|
||||||
|
port: number;
|
||||||
|
enabled: boolean;
|
||||||
|
isRuntimeRunning: () => boolean;
|
||||||
|
gateway?: boolean;
|
||||||
|
resolveOrigin?: (sourceIp: string) => LiveTrafficConnection['origin'];
|
||||||
|
authorization?: () => string | null;
|
||||||
|
unavailableError?: string | null;
|
||||||
|
onProjection?: (batch: NativeTrafficProjectionBatch) => Promise<void> | void;
|
||||||
|
clientFactory?: (port: number) => NativeTrafficClient;
|
||||||
|
}
|
||||||
|
|
||||||
|
function positive(value: bigint) {
|
||||||
|
return value > 0n ? value : 0n;
|
||||||
|
}
|
||||||
|
|
||||||
|
function safeError(error: unknown) {
|
||||||
|
return (error instanceof Error ? error.message : String(error || 'Native traffic stream unavailable'))
|
||||||
|
.replace(/https?:\/\/\S+/gi, '[endpoint]')
|
||||||
|
.slice(0, 300);
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseEndpoint(value: string) {
|
||||||
|
const text = value.trim();
|
||||||
|
const bracketed = /^\[(.+)]:(\d+)$/.exec(text);
|
||||||
|
if (bracketed) return { ip: bracketed[1], port: Number(bracketed[2]) };
|
||||||
|
const separator = text.lastIndexOf(':');
|
||||||
|
if (separator > 0 && !text.slice(0, separator).includes(':') && /^\d+$/.test(text.slice(separator + 1))) {
|
||||||
|
return { ip: text.slice(0, separator), port: Number(text.slice(separator + 1)) };
|
||||||
|
}
|
||||||
|
return { ip: text, port: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
function isoFromMilliseconds(value: bigint, fallback: Date) {
|
||||||
|
const milliseconds = Number(value);
|
||||||
|
return Number.isSafeInteger(milliseconds) && milliseconds > 0
|
||||||
|
? new Date(milliseconds).toISOString()
|
||||||
|
: fallback.toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
|
function routeKindFromValues(
|
||||||
|
outbound: string | null,
|
||||||
|
outboundType: string | null,
|
||||||
|
chain: string[] = [],
|
||||||
|
gateway = false,
|
||||||
|
): 'vpn' | 'direct' | 'other' {
|
||||||
|
if (outbound === 'direct' || outboundType === 'direct') return 'direct';
|
||||||
|
if (gateway) {
|
||||||
|
if (chain[0] === 'direct') return 'direct';
|
||||||
|
return chain.length > 0 ? 'vpn' : 'other';
|
||||||
|
}
|
||||||
|
return outboundType && VPN_OUTBOUND_TYPES.has(outboundType) ? 'vpn' : 'other';
|
||||||
|
}
|
||||||
|
|
||||||
|
function routeKind(connection: Connection, gateway: boolean): 'vpn' | 'direct' | 'other' {
|
||||||
|
return routeKindFromValues(
|
||||||
|
connection.outbound || null,
|
||||||
|
connection.outboundType || null,
|
||||||
|
connection.chainList,
|
||||||
|
gateway,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function macOrigin(): LiveTrafficConnection['origin'] {
|
||||||
|
return {
|
||||||
|
kind: 'this-mac',
|
||||||
|
id: null,
|
||||||
|
label: 'Этот Mac',
|
||||||
|
provenance: 'client-runtime',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function unknownOrigin(sourceIp: string): LiveTrafficConnection['origin'] {
|
||||||
|
return {
|
||||||
|
kind: 'unknown',
|
||||||
|
id: null,
|
||||||
|
label: sourceIp || 'Неизвестное устройство',
|
||||||
|
provenance: 'unknown',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapConnection(
|
||||||
|
connection: Connection,
|
||||||
|
now: Date,
|
||||||
|
gateway: boolean,
|
||||||
|
resolveOrigin?: (sourceIp: string) => LiveTrafficConnection['origin'],
|
||||||
|
): Omit<LiveTrafficConnection, 'traffic'> {
|
||||||
|
const source = parseEndpoint(connection.source);
|
||||||
|
const destination = parseEndpoint(connection.destination);
|
||||||
|
const destinationHost = destination.ip.trim();
|
||||||
|
const domain = connection.domain.trim().toLowerCase()
|
||||||
|
|| (destinationHost && !isIP(destinationHost) ? destinationHost.toLowerCase() : null);
|
||||||
|
const destinationIp = isIP(destinationHost) ? destinationHost : null;
|
||||||
|
return {
|
||||||
|
id: connection.id,
|
||||||
|
startedAt: isoFromMilliseconds(connection.createdAt, now),
|
||||||
|
closedAt: null,
|
||||||
|
inbound: { tag: connection.inbound, type: connection.inboundType },
|
||||||
|
network: connection.network === 'tcp' || connection.network === 'udp' ? connection.network : 'unknown',
|
||||||
|
protocol: connection.protocol || null,
|
||||||
|
source,
|
||||||
|
destination: {
|
||||||
|
domain,
|
||||||
|
ip: destinationIp,
|
||||||
|
port: destination.port,
|
||||||
|
provenance: domain || destinationIp ? 'sing-box' : 'unknown',
|
||||||
|
},
|
||||||
|
origin: resolveOrigin?.(source.ip) ?? (gateway ? unknownOrigin(source.ip) : macOrigin()),
|
||||||
|
route: {
|
||||||
|
kind: routeKind(connection, gateway),
|
||||||
|
scope: 'local-sing-box',
|
||||||
|
outbound: connection.outbound || null,
|
||||||
|
outboundType: connection.outboundType || null,
|
||||||
|
chain: [...connection.chainList],
|
||||||
|
rule: connection.rule || null,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function mergeFinalMetadata(
|
||||||
|
current: Omit<LiveTrafficConnection, 'traffic'> | undefined,
|
||||||
|
final: Omit<LiveTrafficConnection, 'traffic'> | null,
|
||||||
|
gateway: boolean,
|
||||||
|
) {
|
||||||
|
if (!current) return final;
|
||||||
|
if (!final) return current;
|
||||||
|
const domain = final.destination.domain ?? current.destination.domain;
|
||||||
|
const ip = final.destination.ip ?? current.destination.ip;
|
||||||
|
const outbound = final.route.outbound ?? current.route.outbound;
|
||||||
|
const outboundType = final.route.outboundType ?? current.route.outboundType;
|
||||||
|
return {
|
||||||
|
...current,
|
||||||
|
inbound: {
|
||||||
|
tag: final.inbound.tag || current.inbound.tag,
|
||||||
|
type: final.inbound.type || current.inbound.type,
|
||||||
|
},
|
||||||
|
network: final.network === 'unknown' ? current.network : final.network,
|
||||||
|
protocol: final.protocol ?? current.protocol,
|
||||||
|
source: {
|
||||||
|
ip: final.source.ip || current.source.ip,
|
||||||
|
port: final.source.port ?? current.source.port,
|
||||||
|
},
|
||||||
|
destination: {
|
||||||
|
domain,
|
||||||
|
ip,
|
||||||
|
port: final.destination.port ?? current.destination.port,
|
||||||
|
provenance: domain || ip ? 'sing-box' as const : 'unknown' as const,
|
||||||
|
},
|
||||||
|
route: {
|
||||||
|
...current.route,
|
||||||
|
kind: routeKindFromValues(
|
||||||
|
outbound,
|
||||||
|
outboundType,
|
||||||
|
final.route.chain.length ? final.route.chain : current.route.chain,
|
||||||
|
gateway,
|
||||||
|
),
|
||||||
|
outbound,
|
||||||
|
outboundType,
|
||||||
|
chain: final.route.chain.length ? final.route.chain : current.route.chain,
|
||||||
|
rule: final.route.rule ?? current.route.rule,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createLiveTrafficLedger({
|
||||||
|
enabled = true,
|
||||||
|
now = () => new Date(),
|
||||||
|
gateway = false,
|
||||||
|
resolveOrigin,
|
||||||
|
}: LiveTrafficLedgerOptions = {}) {
|
||||||
|
let epoch: string | null = null;
|
||||||
|
let sequence = 0;
|
||||||
|
let observedAt: string | null = null;
|
||||||
|
let state: LiveTrafficSourceState = enabled ? 'connecting' : 'disabled';
|
||||||
|
let singBoxVersion: string | null = null;
|
||||||
|
let singBoxApiVersion: number | null = null;
|
||||||
|
let error: string | null = null;
|
||||||
|
let accountedUpload = 0n;
|
||||||
|
let accountedDownload = 0n;
|
||||||
|
let explicitGapUpload = 0n;
|
||||||
|
let explicitGapDownload = 0n;
|
||||||
|
let statusGapUpload = 0n;
|
||||||
|
let statusGapDownload = 0n;
|
||||||
|
let mismatchCount = 0;
|
||||||
|
let resetSeen = false;
|
||||||
|
let statusSeen = false;
|
||||||
|
let projectionError = false;
|
||||||
|
let lastStatus: Status | null = null;
|
||||||
|
const active = new Map<string, ActiveConnection>();
|
||||||
|
const recent = new Map<string, ActiveConnection>();
|
||||||
|
const settled = new Map<string, true>();
|
||||||
|
|
||||||
|
const changed = (updateObservedAt = true) => {
|
||||||
|
sequence += 1;
|
||||||
|
if (updateObservedAt) observedAt = now().toISOString();
|
||||||
|
};
|
||||||
|
const settle = (id: string) => {
|
||||||
|
if (!id) return;
|
||||||
|
settled.delete(id);
|
||||||
|
settled.set(id, true);
|
||||||
|
while (settled.size > MAX_SETTLED_IDS) settled.delete(settled.keys().next().value as string);
|
||||||
|
};
|
||||||
|
const rememberRecent = (connection: ActiveConnection) => {
|
||||||
|
const id = connection.value.id;
|
||||||
|
recent.delete(id);
|
||||||
|
recent.set(id, connection);
|
||||||
|
while (recent.size > MAX_RECENT_CONNECTIONS) recent.delete(recent.keys().next().value as string);
|
||||||
|
};
|
||||||
|
const pruneRecent = (timestamp: Date) => {
|
||||||
|
const cutoff = timestamp.getTime() - RECENT_CONNECTION_MS;
|
||||||
|
for (const [id, connection] of recent) {
|
||||||
|
const closedAt = connection.value.closedAt;
|
||||||
|
if (closedAt !== null && Date.parse(closedAt) <= cutoff) recent.delete(id);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const clearEpoch = () => {
|
||||||
|
active.clear();
|
||||||
|
recent.clear();
|
||||||
|
settled.clear();
|
||||||
|
accountedUpload = 0n;
|
||||||
|
accountedDownload = 0n;
|
||||||
|
explicitGapUpload = 0n;
|
||||||
|
explicitGapDownload = 0n;
|
||||||
|
statusGapUpload = 0n;
|
||||||
|
statusGapDownload = 0n;
|
||||||
|
mismatchCount = 0;
|
||||||
|
resetSeen = false;
|
||||||
|
statusSeen = false;
|
||||||
|
projectionError = false;
|
||||||
|
lastStatus = null;
|
||||||
|
};
|
||||||
|
const reconcileStatus = (countMismatch = false) => {
|
||||||
|
if (!lastStatus) return;
|
||||||
|
const statusUpload = positive(lastStatus.uplinkTotal);
|
||||||
|
const statusDownload = positive(lastStatus.downlinkTotal);
|
||||||
|
statusGapUpload = statusUpload > accountedUpload ? statusUpload - accountedUpload : 0n;
|
||||||
|
statusGapDownload = statusDownload > accountedDownload ? statusDownload - accountedDownload : 0n;
|
||||||
|
const mismatch = active.size !== lastStatus.connectionsIn
|
||||||
|
|| statusGapUpload > 0n
|
||||||
|
|| statusGapDownload > 0n
|
||||||
|
|| accountedUpload > statusUpload
|
||||||
|
|| accountedDownload > statusDownload;
|
||||||
|
if (countMismatch) mismatchCount = mismatch ? mismatchCount + 1 : 0;
|
||||||
|
if (resetSeen && statusSeen) state = mismatchCount >= 3 || projectionError ? 'degraded' : 'live';
|
||||||
|
};
|
||||||
|
const addUnattributed = (upload: bigint, download: bigint) => {
|
||||||
|
const safeUpload = positive(upload);
|
||||||
|
const safeDownload = positive(download);
|
||||||
|
explicitGapUpload += safeUpload;
|
||||||
|
explicitGapDownload += safeDownload;
|
||||||
|
accountedUpload += safeUpload;
|
||||||
|
accountedDownload += safeDownload;
|
||||||
|
};
|
||||||
|
const project = (connection: ActiveConnection): LiveTrafficConnection => ({
|
||||||
|
...connection.value,
|
||||||
|
origin: resolveOrigin?.(connection.value.source.ip) ?? connection.value.origin,
|
||||||
|
traffic: {
|
||||||
|
uploadBytes: connection.upload.toString(),
|
||||||
|
downloadBytes: connection.download.toString(),
|
||||||
|
uploadBytesPerSecond: connection.uploadRate.toString(),
|
||||||
|
downloadBytesPerSecond: connection.downloadRate.toString(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
beginEpoch(startedAt: bigint, version: string, apiVersion: number) {
|
||||||
|
const nextEpoch = `sing-box-${startedAt}`;
|
||||||
|
const epochChanged = nextEpoch !== epoch;
|
||||||
|
if (epochChanged) clearEpoch();
|
||||||
|
epoch = nextEpoch;
|
||||||
|
singBoxVersion = version;
|
||||||
|
singBoxApiVersion = apiVersion;
|
||||||
|
error = null;
|
||||||
|
state = 'connecting';
|
||||||
|
changed(epochChanged || observedAt === null);
|
||||||
|
},
|
||||||
|
|
||||||
|
applyConnections(batch: ConnectionEvents) {
|
||||||
|
const timestamp = now();
|
||||||
|
const touched = new Set<string>();
|
||||||
|
const closedIds = new Set<string>();
|
||||||
|
const closedConnections = new Map<string, LiveTrafficConnection>();
|
||||||
|
pruneRecent(timestamp);
|
||||||
|
if (batch.reset || batch.events.some(({ type }) => (
|
||||||
|
type === ConnectionEventType.CONNECTION_EVENT_UPDATE
|
||||||
|
))) {
|
||||||
|
for (const connection of active.values()) {
|
||||||
|
connection.uploadRate = 0n;
|
||||||
|
connection.downloadRate = 0n;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (batch.reset) {
|
||||||
|
const next = new Map<string, ActiveConnection>();
|
||||||
|
for (const event of batch.events) {
|
||||||
|
const connection = event.connection;
|
||||||
|
if (!connection?.id) continue;
|
||||||
|
touched.add(connection.id);
|
||||||
|
const upload = positive(connection.uplinkTotal);
|
||||||
|
const download = positive(connection.downlinkTotal);
|
||||||
|
const mapped = mapConnection(connection, timestamp, gateway, resolveOrigin);
|
||||||
|
const recentPrevious = recent.get(connection.id);
|
||||||
|
const previous = active.get(connection.id)
|
||||||
|
?? (recentPrevious?.value.startedAt === mapped.startedAt ? recentPrevious : undefined);
|
||||||
|
if (connection.closedAt > 0n || event.closedAt > 0n) {
|
||||||
|
const alreadySettled = settled.has(connection.id);
|
||||||
|
if (!alreadySettled) {
|
||||||
|
accountedUpload += previous ? positive(upload - previous.upload) : upload;
|
||||||
|
accountedDownload += previous ? positive(download - previous.download) : download;
|
||||||
|
const closedAt = event.closedAt > 0n
|
||||||
|
? isoFromMilliseconds(event.closedAt, timestamp)
|
||||||
|
: isoFromMilliseconds(connection.closedAt, timestamp);
|
||||||
|
const settledConnection = {
|
||||||
|
value: { ...mapped, closedAt },
|
||||||
|
upload,
|
||||||
|
download,
|
||||||
|
uploadRate: 0n,
|
||||||
|
downloadRate: 0n,
|
||||||
|
};
|
||||||
|
closedConnections.set(connection.id, project(settledConnection));
|
||||||
|
rememberRecent(settledConnection);
|
||||||
|
}
|
||||||
|
closedIds.add(connection.id);
|
||||||
|
settle(connection.id);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
recent.delete(connection.id);
|
||||||
|
settled.delete(connection.id);
|
||||||
|
accountedUpload += previous ? positive(upload - previous.upload) : upload;
|
||||||
|
accountedDownload += previous ? positive(download - previous.download) : download;
|
||||||
|
next.set(connection.id, {
|
||||||
|
value: mapped,
|
||||||
|
upload,
|
||||||
|
download,
|
||||||
|
uploadRate: 0n,
|
||||||
|
downloadRate: 0n,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
for (const [id] of active) {
|
||||||
|
if (next.has(id)) continue;
|
||||||
|
closedIds.add(id);
|
||||||
|
settle(id);
|
||||||
|
}
|
||||||
|
active.clear();
|
||||||
|
for (const [id, connection] of next) active.set(id, connection);
|
||||||
|
resetSeen = true;
|
||||||
|
} else {
|
||||||
|
for (const event of batch.events) {
|
||||||
|
const id = event.id || event.connection?.id || '';
|
||||||
|
if (!id) continue;
|
||||||
|
touched.add(id);
|
||||||
|
if (event.type === ConnectionEventType.CONNECTION_EVENT_NEW) {
|
||||||
|
const connection = event.connection;
|
||||||
|
if (!connection || active.has(id)) continue;
|
||||||
|
if (connection.closedAt > 0n || event.closedAt > 0n) {
|
||||||
|
settle(id);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const mapped = mapConnection(connection, timestamp, gateway, resolveOrigin);
|
||||||
|
const previous = recent.get(id);
|
||||||
|
if (!previous && settled.has(id)) continue;
|
||||||
|
if (previous && mapped.startedAt <= previous.value.startedAt) continue;
|
||||||
|
recent.delete(id);
|
||||||
|
settled.delete(id);
|
||||||
|
const upload = positive(connection.uplinkTotal);
|
||||||
|
const download = positive(connection.downlinkTotal);
|
||||||
|
active.set(id, {
|
||||||
|
value: mapped,
|
||||||
|
upload,
|
||||||
|
download,
|
||||||
|
uploadRate: 0n,
|
||||||
|
downloadRate: 0n,
|
||||||
|
});
|
||||||
|
accountedUpload += upload;
|
||||||
|
accountedDownload += download;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (event.type === ConnectionEventType.CONNECTION_EVENT_UPDATE) {
|
||||||
|
const upload = positive(event.uplinkDelta);
|
||||||
|
const download = positive(event.downlinkDelta);
|
||||||
|
const connection = active.get(id);
|
||||||
|
if (!connection) continue;
|
||||||
|
connection.upload += upload;
|
||||||
|
connection.download += download;
|
||||||
|
connection.uploadRate += upload;
|
||||||
|
connection.downloadRate += download;
|
||||||
|
accountedUpload += upload;
|
||||||
|
accountedDownload += download;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (event.type === ConnectionEventType.CONNECTION_EVENT_CLOSED
|
||||||
|
&& !settled.has(id) && !recent.has(id)) {
|
||||||
|
const current = active.get(id);
|
||||||
|
const finalUpload = event.connection ? positive(event.connection.uplinkTotal) : 0n;
|
||||||
|
const finalDownload = event.connection ? positive(event.connection.downlinkTotal) : 0n;
|
||||||
|
const tailUpload = event.connection
|
||||||
|
? (current && finalUpload > current.upload ? finalUpload - current.upload : current ? 0n : finalUpload)
|
||||||
|
: positive(event.uplinkDelta);
|
||||||
|
const tailDownload = event.connection
|
||||||
|
? (current && finalDownload > current.download ? finalDownload - current.download : current ? 0n : finalDownload)
|
||||||
|
: positive(event.downlinkDelta);
|
||||||
|
if (current) {
|
||||||
|
accountedUpload += tailUpload;
|
||||||
|
accountedDownload += tailDownload;
|
||||||
|
} else if (event.connection) {
|
||||||
|
addUnattributed(tailUpload, tailDownload);
|
||||||
|
}
|
||||||
|
const metadata = mergeFinalMetadata(
|
||||||
|
current?.value,
|
||||||
|
event.connection ? mapConnection(event.connection, timestamp, gateway, resolveOrigin) : null,
|
||||||
|
gateway,
|
||||||
|
);
|
||||||
|
if (metadata) {
|
||||||
|
const closedAt = event.closedAt > 0n
|
||||||
|
? isoFromMilliseconds(event.closedAt, timestamp)
|
||||||
|
: event.connection && event.connection.closedAt > 0n
|
||||||
|
? isoFromMilliseconds(event.connection.closedAt, timestamp)
|
||||||
|
: timestamp.toISOString();
|
||||||
|
const settledConnection = {
|
||||||
|
value: { ...metadata, id, closedAt },
|
||||||
|
upload: current ? current.upload + tailUpload : finalUpload,
|
||||||
|
download: current ? current.download + tailDownload : finalDownload,
|
||||||
|
uploadRate: 0n,
|
||||||
|
downloadRate: 0n,
|
||||||
|
};
|
||||||
|
closedConnections.set(id, project(settledConnection));
|
||||||
|
rememberRecent(settledConnection);
|
||||||
|
}
|
||||||
|
active.delete(id);
|
||||||
|
closedIds.add(id);
|
||||||
|
settle(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
reconcileStatus();
|
||||||
|
changed();
|
||||||
|
if (!epoch) return null;
|
||||||
|
const connections: LiveTrafficConnection[] = [];
|
||||||
|
for (const id of touched) {
|
||||||
|
const settledConnection = closedConnections.get(id);
|
||||||
|
if (settledConnection) connections.push(settledConnection);
|
||||||
|
else {
|
||||||
|
const connection = active.get(id);
|
||||||
|
if (connection) connections.push(project(connection));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
epoch,
|
||||||
|
observedAt: timestamp.toISOString(),
|
||||||
|
reset: batch.reset,
|
||||||
|
connections,
|
||||||
|
closedIds: [...closedIds],
|
||||||
|
} satisfies NativeTrafficProjectionBatch;
|
||||||
|
},
|
||||||
|
|
||||||
|
applyStatus(status: Status) {
|
||||||
|
const timestamp = now();
|
||||||
|
pruneRecent(timestamp);
|
||||||
|
lastStatus = status;
|
||||||
|
statusSeen = true;
|
||||||
|
reconcileStatus(true);
|
||||||
|
changed();
|
||||||
|
return epoch && state === 'live' ? {
|
||||||
|
epoch,
|
||||||
|
observedAt: timestamp.toISOString(),
|
||||||
|
reset: false,
|
||||||
|
connections: [],
|
||||||
|
closedIds: [],
|
||||||
|
} satisfies NativeTrafficProjectionBatch : null;
|
||||||
|
},
|
||||||
|
|
||||||
|
markStopped() {
|
||||||
|
if (state === 'stopped' && active.size === 0) return;
|
||||||
|
clearEpoch();
|
||||||
|
epoch = null;
|
||||||
|
state = 'stopped';
|
||||||
|
error = null;
|
||||||
|
changed();
|
||||||
|
},
|
||||||
|
|
||||||
|
markTransportError(reason: unknown) {
|
||||||
|
error = safeError(reason);
|
||||||
|
state = epoch ? 'stale' : 'connecting';
|
||||||
|
changed(false);
|
||||||
|
},
|
||||||
|
|
||||||
|
markProjectionError(reason: unknown) {
|
||||||
|
projectionError = true;
|
||||||
|
error = safeError(reason);
|
||||||
|
state = 'degraded';
|
||||||
|
changed(false);
|
||||||
|
},
|
||||||
|
|
||||||
|
markProjectionHealthy() {
|
||||||
|
if (!projectionError) return;
|
||||||
|
projectionError = false;
|
||||||
|
error = null;
|
||||||
|
reconcileStatus();
|
||||||
|
changed(false);
|
||||||
|
},
|
||||||
|
|
||||||
|
markUnavailable(reason: unknown) {
|
||||||
|
clearEpoch();
|
||||||
|
epoch = null;
|
||||||
|
state = 'incompatible';
|
||||||
|
error = safeError(reason);
|
||||||
|
changed();
|
||||||
|
},
|
||||||
|
|
||||||
|
markIncompatible(version: string, apiVersion: number) {
|
||||||
|
clearEpoch();
|
||||||
|
epoch = null;
|
||||||
|
singBoxVersion = version;
|
||||||
|
singBoxApiVersion = apiVersion;
|
||||||
|
state = 'incompatible';
|
||||||
|
error = `sing-box ${version} API ${apiVersion} is incompatible`;
|
||||||
|
changed();
|
||||||
|
},
|
||||||
|
|
||||||
|
snapshot(): LiveTrafficSnapshot {
|
||||||
|
const recentCutoff = now().getTime() - RECENT_CONNECTION_MS;
|
||||||
|
const all = [...active.values()];
|
||||||
|
all.sort((left, right) => right.value.startedAt.localeCompare(left.value.startedAt)
|
||||||
|
|| left.value.id.localeCompare(right.value.id));
|
||||||
|
const allRecent = [...recent.values()].filter(({ value }) => (
|
||||||
|
value.closedAt !== null && Date.parse(value.closedAt) > recentCutoff
|
||||||
|
));
|
||||||
|
allRecent.sort((left, right) => (right.value.closedAt ?? '').localeCompare(left.value.closedAt ?? '')
|
||||||
|
|| left.value.id.localeCompare(right.value.id));
|
||||||
|
const visible = all.slice(0, MAX_VISIBLE);
|
||||||
|
if (visible.length < MAX_VISIBLE) visible.push(...allRecent.slice(0, MAX_VISIBLE - visible.length));
|
||||||
|
const connections = visible.map(project);
|
||||||
|
const recognized = all.filter(({ value }) => value.destination.domain !== null).length;
|
||||||
|
return {
|
||||||
|
apiVersion: 1,
|
||||||
|
epoch,
|
||||||
|
sequence,
|
||||||
|
observedAt,
|
||||||
|
capabilities: {
|
||||||
|
lifecycle: true,
|
||||||
|
deviceAttribution: Boolean(resolveOrigin),
|
||||||
|
applicationAttribution: false,
|
||||||
|
},
|
||||||
|
source: {
|
||||||
|
transport: 'native',
|
||||||
|
state,
|
||||||
|
completeness: 'lifecycle',
|
||||||
|
singBoxVersion,
|
||||||
|
singBoxApiVersion,
|
||||||
|
error,
|
||||||
|
unattributedUploadBytes: (explicitGapUpload + statusGapUpload).toString(),
|
||||||
|
unattributedDownloadBytes: (explicitGapDownload + statusGapDownload).toString(),
|
||||||
|
},
|
||||||
|
summary: {
|
||||||
|
active: all.length,
|
||||||
|
recent: allRecent.length,
|
||||||
|
visible: connections.length,
|
||||||
|
recognized,
|
||||||
|
unresolved: all.length - recognized,
|
||||||
|
unresolvedOrigin: all.filter((connection) => project(connection).origin.kind === 'unknown').length,
|
||||||
|
truncated: all.length + allRecent.length > MAX_VISIBLE,
|
||||||
|
},
|
||||||
|
connections,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function defaultClientFactory(port: number): NativeTrafficClient {
|
||||||
|
return createClient(StartedService, createGrpcTransport({
|
||||||
|
baseUrl: `http://127.0.0.1:${port}`,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function delay(milliseconds: number, signal: AbortSignal) {
|
||||||
|
return new Promise<void>((resolve) => {
|
||||||
|
const finish = () => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
signal.removeEventListener('abort', finish);
|
||||||
|
resolve();
|
||||||
|
};
|
||||||
|
const timer = setTimeout(finish, milliseconds);
|
||||||
|
timer.unref();
|
||||||
|
signal.addEventListener('abort', finish, { once: true });
|
||||||
|
if (signal.aborted) finish();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
const stopController = new AbortController();
|
||||||
|
let controller: AbortController | null = null;
|
||||||
|
let running: Promise<void> | null = null;
|
||||||
|
let failedProjection: NativeTrafficProjectionBatch | null = null;
|
||||||
|
let projectionQueue = Promise.resolve();
|
||||||
|
|
||||||
|
const project = (batch: NativeTrafficProjectionBatch) => {
|
||||||
|
if (!onProjection) return Promise.resolve();
|
||||||
|
const run = async () => {
|
||||||
|
if (failedProjection) {
|
||||||
|
const retry = failedProjection;
|
||||||
|
try {
|
||||||
|
await onProjection(retry);
|
||||||
|
failedProjection = null;
|
||||||
|
} catch (reason) {
|
||||||
|
ledger.markProjectionError(reason);
|
||||||
|
throw reason;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await onProjection(batch);
|
||||||
|
ledger.markProjectionHealthy();
|
||||||
|
} catch (reason) {
|
||||||
|
failedProjection = batch;
|
||||||
|
ledger.markProjectionError(reason);
|
||||||
|
throw reason;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const result = projectionQueue.then(run, run);
|
||||||
|
projectionQueue = result.catch(() => undefined);
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
|
||||||
|
const attach = async () => {
|
||||||
|
const client = clientFactory(port);
|
||||||
|
const signal = controller?.signal;
|
||||||
|
if (!signal) return;
|
||||||
|
const secret = authorization?.();
|
||||||
|
const options = secret ? { signal, headers: { authorization: `Bearer ${secret}` } } : { signal };
|
||||||
|
const version = await client.getVersion({}, options);
|
||||||
|
if (version.version !== SUPPORTED_SINGBOX_VERSION
|
||||||
|
|| version.apiVersion !== SUPPORTED_SINGBOX_API_VERSION) {
|
||||||
|
ledger.markIncompatible(version.version, version.apiVersion);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const started = await client.getStartedAt({}, options);
|
||||||
|
ledger.beginEpoch(started.startedAt, version.version, version.apiVersion);
|
||||||
|
let lastStatusAt = Date.now();
|
||||||
|
const watchdog = setInterval(() => {
|
||||||
|
if (!isRuntimeRunning() || Date.now() - lastStatusAt > STALE_MS) {
|
||||||
|
controller?.abort(new Error(isRuntimeRunning() ? 'Native traffic status is stale' : 'sing-box stopped'));
|
||||||
|
}
|
||||||
|
}, RETRY_MS);
|
||||||
|
watchdog.unref();
|
||||||
|
const streams = [
|
||||||
|
(async () => {
|
||||||
|
for await (const batch of client.subscribeConnections({ interval: CONNECTION_INTERVAL }, options)) {
|
||||||
|
const projection = ledger.applyConnections(batch);
|
||||||
|
if (projection) await project(projection);
|
||||||
|
}
|
||||||
|
})(),
|
||||||
|
(async () => {
|
||||||
|
for await (const status of client.subscribeStatus({ interval: CONNECTION_INTERVAL }, options)) {
|
||||||
|
lastStatusAt = Date.now();
|
||||||
|
const projection = ledger.applyStatus(status);
|
||||||
|
if (projection) await project(projection);
|
||||||
|
}
|
||||||
|
})(),
|
||||||
|
];
|
||||||
|
try {
|
||||||
|
await Promise.race(streams);
|
||||||
|
throw new Error('Native traffic stream ended');
|
||||||
|
} finally {
|
||||||
|
controller?.abort(new Error('Native traffic stream ended'));
|
||||||
|
await Promise.allSettled(streams);
|
||||||
|
clearInterval(watchdog);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const loop = async () => {
|
||||||
|
while (!stopped) {
|
||||||
|
if (!isRuntimeRunning()) {
|
||||||
|
ledger.markStopped();
|
||||||
|
await delay(RETRY_MS, stopController.signal);
|
||||||
|
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, stopController.signal);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
start() {
|
||||||
|
if (!enabled || running) return;
|
||||||
|
running = loop();
|
||||||
|
},
|
||||||
|
async stop() {
|
||||||
|
stopped = true;
|
||||||
|
stopController.abort();
|
||||||
|
controller?.abort();
|
||||||
|
await running;
|
||||||
|
},
|
||||||
|
snapshot: ledger.snapshot,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export type LiveTrafficService = ReturnType<typeof createLiveTrafficService>;
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import fs from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { DatabaseSync } from 'node:sqlite';
|
||||||
|
|
||||||
|
export function openSqlite(filePath: string) {
|
||||||
|
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
||||||
|
const db = new DatabaseSync(filePath);
|
||||||
|
try {
|
||||||
|
fs.chmodSync(filePath, 0o600);
|
||||||
|
db.exec('PRAGMA busy_timeout = 5000; PRAGMA journal_mode = WAL; PRAGMA synchronous = FULL; PRAGMA foreign_keys = ON;');
|
||||||
|
return db;
|
||||||
|
} catch (error) {
|
||||||
|
db.close();
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function transaction<T>(db: DatabaseSync, change: () => T): T {
|
||||||
|
db.exec('BEGIN IMMEDIATE');
|
||||||
|
try {
|
||||||
|
const result = change();
|
||||||
|
if (result && typeof result === 'object' && 'then' in result) {
|
||||||
|
throw new TypeError('SQLite transaction must be synchronous');
|
||||||
|
}
|
||||||
|
db.exec('COMMIT');
|
||||||
|
return result;
|
||||||
|
} catch (error) {
|
||||||
|
db.exec('ROLLBACK');
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,7 +13,7 @@ import {
|
|||||||
type NormalizedServer,
|
type NormalizedServer,
|
||||||
} from '../../shared/serverIdentity.js';
|
} from '../../shared/serverIdentity.js';
|
||||||
|
|
||||||
export const STATE_SCHEMA_VERSION = 8;
|
export const STATE_SCHEMA_VERSION = 10;
|
||||||
|
|
||||||
export interface AtomicWriteOptions {
|
export interface AtomicWriteOptions {
|
||||||
beforeRename?: (temporaryPath: string, filePath: string) => void;
|
beforeRename?: (temporaryPath: string, filePath: string) => void;
|
||||||
|
|||||||
@@ -0,0 +1,194 @@
|
|||||||
|
import { Worker } from 'node:worker_threads';
|
||||||
|
import type { LiveTrafficSourceState } from '../../shared/liveTraffic.js';
|
||||||
|
import {
|
||||||
|
assertTrafficHistorySnapshot,
|
||||||
|
emptyTrafficHistory,
|
||||||
|
type TrafficHistoryQuery,
|
||||||
|
} from '../../shared/trafficHistory.js';
|
||||||
|
import type { NativeTrafficProjectionBatch } from './liveTrafficService.js';
|
||||||
|
import type { HistoryWorkerRequest } from './trafficHistoryWorker.js';
|
||||||
|
|
||||||
|
type Request = HistoryWorkerRequest extends infer R ? R extends { id: number } ? Omit<R, 'id'> : never : never;
|
||||||
|
const MAX_QUEUED_CONNECTIONS = 16_384;
|
||||||
|
interface Job {
|
||||||
|
id: number;
|
||||||
|
message: Request;
|
||||||
|
resolve: (value: unknown) => void;
|
||||||
|
reject: (error: Error) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createTrafficHistoryService({ filePath, source }: {
|
||||||
|
filePath: string;
|
||||||
|
source: () => LiveTrafficSourceState;
|
||||||
|
}) {
|
||||||
|
let worker: Worker | null = null;
|
||||||
|
let ready = false;
|
||||||
|
let terminating: Promise<void> | null = null;
|
||||||
|
let active: Job | null = null;
|
||||||
|
const jobs: Job[] = [];
|
||||||
|
let watchdog: NodeJS.Timeout | undefined;
|
||||||
|
let readDeadline: NodeJS.Timeout | undefined;
|
||||||
|
let readers = 0;
|
||||||
|
let sequence = 0;
|
||||||
|
let pending: NativeTrafficProjectionBatch[] = [];
|
||||||
|
let queued = 0;
|
||||||
|
let missedSince: number | null = null;
|
||||||
|
let storageError = false;
|
||||||
|
let stopping = false;
|
||||||
|
let closed = false;
|
||||||
|
let flushing: Promise<void> | null = null;
|
||||||
|
|
||||||
|
function clearDeadlines() {
|
||||||
|
clearTimeout(watchdog);
|
||||||
|
clearTimeout(readDeadline);
|
||||||
|
}
|
||||||
|
function failed(current: Worker) {
|
||||||
|
if (worker !== current) return;
|
||||||
|
const startupFailed = !ready;
|
||||||
|
worker = null;
|
||||||
|
ready = false;
|
||||||
|
clearDeadlines();
|
||||||
|
active?.reject(new Error('TRAFFIC_HISTORY_UNAVAILABLE'));
|
||||||
|
active = null;
|
||||||
|
if (startupFailed) {
|
||||||
|
for (const job of jobs.splice(0)) job.reject(new Error('TRAFFIC_HISTORY_UNAVAILABLE'));
|
||||||
|
}
|
||||||
|
// Native SQLite may still be finishing a call. Never start a second writer until it exits.
|
||||||
|
terminating = current.terminate().then(() => {}, () => {}).then(() => {
|
||||||
|
terminating = null;
|
||||||
|
dispatch();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function ensureWorker() {
|
||||||
|
if (worker) return worker;
|
||||||
|
const current = new Worker(new URL('./trafficHistoryWorker.js', import.meta.url), { workerData: { filePath } });
|
||||||
|
worker = current;
|
||||||
|
watchdog = setTimeout(() => failed(current), 60_000);
|
||||||
|
current.on('message', (message: { ready?: boolean; id?: number; result?: unknown; error?: string }) => {
|
||||||
|
if (worker !== current) return;
|
||||||
|
if (message.ready) {
|
||||||
|
clearDeadlines();
|
||||||
|
ready = true;
|
||||||
|
dispatch();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!active || active.id !== message.id) return;
|
||||||
|
const job = active;
|
||||||
|
active = null;
|
||||||
|
clearDeadlines();
|
||||||
|
if (message.error) job.reject(new Error('TRAFFIC_HISTORY_UNAVAILABLE'));
|
||||||
|
else job.resolve(message.result);
|
||||||
|
dispatch();
|
||||||
|
});
|
||||||
|
current.on('error', () => failed(current));
|
||||||
|
current.on('exit', () => failed(current));
|
||||||
|
return current;
|
||||||
|
}
|
||||||
|
function dispatch() {
|
||||||
|
if (active || terminating || !jobs.length) return;
|
||||||
|
let current: Worker;
|
||||||
|
try { current = ensureWorker(); }
|
||||||
|
catch {
|
||||||
|
for (const job of jobs.splice(0)) job.reject(new Error('TRAFFIC_HISTORY_UNAVAILABLE'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!ready) return;
|
||||||
|
const job = jobs.shift()!;
|
||||||
|
active = job;
|
||||||
|
if (job.message.kind === 'query') {
|
||||||
|
// A slow read only expires its caller. Keep the slot until SQL actually finishes.
|
||||||
|
readDeadline = setTimeout(() => job.reject(new Error('TRAFFIC_HISTORY_UNAVAILABLE')), 5_000);
|
||||||
|
}
|
||||||
|
watchdog = setTimeout(() => failed(current), 60_000);
|
||||||
|
try { current.postMessage({ ...job.message, id: job.id }); }
|
||||||
|
catch { failed(current); }
|
||||||
|
}
|
||||||
|
function request(message: Request): Promise<unknown> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const job = { id: ++sequence, message, resolve, reject };
|
||||||
|
if (message.kind === 'ingest') jobs.unshift(job);
|
||||||
|
else jobs.push(job);
|
||||||
|
dispatch();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function enqueue(batch: NativeTrafficProjectionBatch) {
|
||||||
|
if (stopping) return;
|
||||||
|
if (queued + batch.connections.length > MAX_QUEUED_CONNECTIONS || pending.length >= 120) {
|
||||||
|
missedSince ??= Date.parse(batch.observedAt);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
pending.push(batch);
|
||||||
|
queued += batch.connections.length;
|
||||||
|
}
|
||||||
|
function flush(): Promise<void> {
|
||||||
|
if (closed) return Promise.resolve();
|
||||||
|
if (flushing) return flushing;
|
||||||
|
const batches = pending;
|
||||||
|
const missed = missedSince;
|
||||||
|
pending = [];
|
||||||
|
queued = 0;
|
||||||
|
missedSince = null;
|
||||||
|
flushing = request({ kind: 'ingest', batches, source: source(), missedSince: missed })
|
||||||
|
.then(() => { storageError = false; })
|
||||||
|
.catch(() => {
|
||||||
|
storageError = true;
|
||||||
|
missedSince = Math.min(missedSince ?? Infinity, missed ?? Infinity,
|
||||||
|
batches.length ? Date.parse(batches[0].observedAt) : Date.now());
|
||||||
|
// Recoverable cumulative counters will reconcile on the next batch. Lost closed flows
|
||||||
|
// stay an explicit gap; history must never backpressure the native/metrics collector.
|
||||||
|
})
|
||||||
|
.finally(() => { flushing = null; });
|
||||||
|
return flushing;
|
||||||
|
}
|
||||||
|
const timer = setInterval(() => { if (!stopping) void flush(); }, 1_000);
|
||||||
|
timer.unref();
|
||||||
|
|
||||||
|
return {
|
||||||
|
enqueue,
|
||||||
|
flush,
|
||||||
|
async query(query: TrafficHistoryQuery) {
|
||||||
|
let admitted = false;
|
||||||
|
try {
|
||||||
|
if (stopping || readers >= 32) throw new Error('TRAFFIC_HISTORY_BUSY');
|
||||||
|
readers++;
|
||||||
|
admitted = true;
|
||||||
|
await flush();
|
||||||
|
if (stopping) throw new Error('TRAFFIC_HISTORY_UNAVAILABLE');
|
||||||
|
const result = assertTrafficHistorySnapshot(await request({ kind: 'query', query }));
|
||||||
|
result.source = source();
|
||||||
|
if (missedSince !== null || storageError) result.coverage.partial = true;
|
||||||
|
if (storageError) result.storage = { status: 'error', errorCode: 'TRAFFIC_HISTORY_UNAVAILABLE' };
|
||||||
|
return result;
|
||||||
|
} catch {
|
||||||
|
const result = emptyTrafficHistory(query, source());
|
||||||
|
result.storage = { status: 'error', errorCode: 'TRAFFIC_HISTORY_UNAVAILABLE' };
|
||||||
|
result.coverage.partial = true;
|
||||||
|
return result;
|
||||||
|
} finally {
|
||||||
|
if (admitted) readers--;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async close() {
|
||||||
|
if (stopping) return;
|
||||||
|
stopping = true;
|
||||||
|
clearInterval(timer);
|
||||||
|
for (let index = jobs.length - 1; index >= 0; index--) {
|
||||||
|
if (jobs[index].message.kind === 'query') {
|
||||||
|
jobs.splice(index, 1)[0].reject(new Error('TRAFFIC_HISTORY_UNAVAILABLE'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await flushing;
|
||||||
|
await flush();
|
||||||
|
if (worker) {
|
||||||
|
try { await request({ kind: 'close' }); } catch { /* shutdown remains bounded */ }
|
||||||
|
const current = worker;
|
||||||
|
worker = null;
|
||||||
|
ready = false;
|
||||||
|
clearDeadlines();
|
||||||
|
await current?.terminate();
|
||||||
|
}
|
||||||
|
await terminating;
|
||||||
|
closed = true;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,262 @@
|
|||||||
|
import net from 'node:net';
|
||||||
|
import { domainToASCII } from 'node:url';
|
||||||
|
import { getDomain } from 'tldts';
|
||||||
|
import type { LiveTrafficSourceState } from '../../shared/liveTraffic.js';
|
||||||
|
import { emptyTrafficHistory, type TrafficHistoryQuery, type TrafficHistorySnapshot } from '../../shared/trafficHistory.js';
|
||||||
|
import type { NativeTrafficProjectionBatch } from './liveTrafficService.js';
|
||||||
|
import { classifyDomain } from './domainTrafficService.js';
|
||||||
|
import { openSqlite, transaction } from './sqlite.js';
|
||||||
|
|
||||||
|
const MINUTE = 60_000;
|
||||||
|
const HOUR = 60 * MINUTE;
|
||||||
|
const DAY = 24 * HOUR;
|
||||||
|
const MAX_INTEGER = (1n << 63n) - 1n;
|
||||||
|
|
||||||
|
function hostname(value: string | null) {
|
||||||
|
const name = domainToASCII((value || '').trim().replace(/\.$/, '')).toLowerCase();
|
||||||
|
return name.length <= 253 && !net.isIP(name)
|
||||||
|
&& name.split('.').every((part) => /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/.test(part)) ? name : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function openTrafficHistoryStore(filePath: string, now = Date.now) {
|
||||||
|
const db = openSqlite(filePath);
|
||||||
|
try {
|
||||||
|
const version = Number(db.prepare('PRAGMA user_version').get()?.user_version);
|
||||||
|
if (version !== 0 && version !== 1) throw new Error(`Unsupported traffic database version: ${version}`);
|
||||||
|
transaction(db, () => db.exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS meta (key TEXT PRIMARY KEY, value TEXT NOT NULL) STRICT;
|
||||||
|
CREATE TABLE IF NOT EXISTS dimensions (
|
||||||
|
id INTEGER PRIMARY KEY, identity TEXT NOT NULL UNIQUE,
|
||||||
|
origin_id TEXT NOT NULL, origin_label TEXT NOT NULL, source_ip TEXT NOT NULL,
|
||||||
|
inbound TEXT NOT NULL, service TEXT NOT NULL, domain TEXT NOT NULL,
|
||||||
|
hostname TEXT NOT NULL, ip TEXT NOT NULL, route TEXT NOT NULL, outbound TEXT NOT NULL
|
||||||
|
) STRICT;
|
||||||
|
CREATE TABLE IF NOT EXISTS buckets (
|
||||||
|
at INTEGER NOT NULL, resolution INTEGER NOT NULL, dimension_id INTEGER NOT NULL REFERENCES dimensions(id),
|
||||||
|
upload INTEGER NOT NULL CHECK(upload >= 0), download INTEGER NOT NULL CHECK(download >= 0),
|
||||||
|
PRIMARY KEY(at, resolution, dimension_id)
|
||||||
|
) STRICT, WITHOUT ROWID;
|
||||||
|
CREATE INDEX IF NOT EXISTS buckets_dimension_time ON buckets(dimension_id, at, upload, download);
|
||||||
|
CREATE TABLE IF NOT EXISTS checkpoints (
|
||||||
|
identity TEXT PRIMARY KEY, upload INTEGER NOT NULL, download INTEGER NOT NULL,
|
||||||
|
last_seen INTEGER NOT NULL, closed INTEGER NOT NULL
|
||||||
|
) STRICT;
|
||||||
|
CREATE INDEX IF NOT EXISTS checkpoint_age ON checkpoints(last_seen);
|
||||||
|
CREATE INDEX IF NOT EXISTS checkpoint_active ON checkpoints(json_extract(identity, '$[1]')) WHERE closed = 0;
|
||||||
|
CREATE TABLE IF NOT EXISTS gaps (at INTEGER PRIMARY KEY, until_at INTEGER NOT NULL) STRICT;
|
||||||
|
PRAGMA user_version = 1;
|
||||||
|
`));
|
||||||
|
} catch (error) { db.close(); throw error; }
|
||||||
|
db.exec(`CREATE TEMP TABLE period_totals (
|
||||||
|
period TEXT NOT NULL, dimension_id INTEGER NOT NULL, upload INTEGER NOT NULL, download INTEGER NOT NULL,
|
||||||
|
PRIMARY KEY(period, dimension_id)
|
||||||
|
) STRICT, WITHOUT ROWID`);
|
||||||
|
const periods = new Map<string, { from: number; to: number }>();
|
||||||
|
const dropPeriod = db.prepare('DELETE FROM period_totals WHERE period = ?');
|
||||||
|
function periodTotals(from: number, to: number) {
|
||||||
|
const key = `${from}:${to}`;
|
||||||
|
if (!periods.has(key)) {
|
||||||
|
// Four visible ranges; old frozen pages are recomputed after eviction.
|
||||||
|
if (periods.size === 4) {
|
||||||
|
const oldest = periods.keys().next().value!;
|
||||||
|
dropPeriod.run(oldest);
|
||||||
|
periods.delete(oldest);
|
||||||
|
}
|
||||||
|
db.prepare(`INSERT INTO period_totals
|
||||||
|
SELECT ?, d.id, SUM(b.upload), SUM(b.download)
|
||||||
|
FROM dimensions d CROSS JOIN buckets b INDEXED BY buckets_dimension_time
|
||||||
|
ON b.dimension_id = d.id WHERE b.at >= ? AND b.at < ? GROUP BY d.id`).run(key, from, to);
|
||||||
|
periods.set(key, { from, to });
|
||||||
|
}
|
||||||
|
return key;
|
||||||
|
}
|
||||||
|
// SQLite's built-in lower() handles ASCII only; service labels also use Cyrillic.
|
||||||
|
db.function('lower_unicode', { deterministic: true }, (value) => String(value).toLowerCase());
|
||||||
|
const meta = (key: string) => db.prepare('SELECT value FROM meta WHERE key = ?').get(key)?.value as string | undefined;
|
||||||
|
const setMeta = db.prepare('INSERT INTO meta(key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value');
|
||||||
|
const checkpoint = db.prepare('SELECT upload, download, last_seen FROM checkpoints WHERE identity = ?');
|
||||||
|
checkpoint.setReadBigInts(true);
|
||||||
|
const saveCheckpoint = db.prepare(`INSERT INTO checkpoints VALUES (?, ?, ?, ?, ?)
|
||||||
|
ON CONFLICT(identity) DO UPDATE SET upload = excluded.upload, download = excluded.download,
|
||||||
|
last_seen = excluded.last_seen, closed = excluded.closed`);
|
||||||
|
const saveDimension = db.prepare(`INSERT INTO dimensions(identity, origin_id, origin_label, source_ip, inbound,
|
||||||
|
service, domain, hostname, ip, route, outbound) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
ON CONFLICT(identity) DO UPDATE SET origin_label = excluded.origin_label RETURNING id`);
|
||||||
|
const saveBucket = db.prepare(`INSERT INTO buckets VALUES (?, ?, ?, ?, ?)
|
||||||
|
ON CONFLICT(at, resolution, dimension_id) DO UPDATE SET
|
||||||
|
upload = upload + excluded.upload, download = download + excluded.download`);
|
||||||
|
const saveGap = db.prepare('INSERT INTO gaps VALUES (?, ?) ON CONFLICT(at) DO UPDATE SET until_at = MAX(until_at, excluded.until_at)');
|
||||||
|
let lastMaintenance = 0;
|
||||||
|
|
||||||
|
function gap(from: number, to: number) {
|
||||||
|
saveGap.run(Math.floor(from / MINUTE) * MINUTE, Math.max(from, to));
|
||||||
|
}
|
||||||
|
// A reopened collector cannot prove that short-lived flows were observed while it was down.
|
||||||
|
const previousObservation = Number(meta('observed') || 0);
|
||||||
|
if (previousObservation) transaction(db, () => gap(previousObservation, now()));
|
||||||
|
function maintain(timestamp = now()) {
|
||||||
|
const hourCutoff = Math.floor((timestamp - 7 * DAY) / HOUR) * HOUR;
|
||||||
|
const cutoff = Math.floor((timestamp - 90 * DAY) / HOUR) * HOUR;
|
||||||
|
transaction(db, () => {
|
||||||
|
db.prepare('DELETE FROM buckets WHERE at < ?').run(cutoff);
|
||||||
|
db.prepare(`INSERT INTO buckets(at, resolution, dimension_id, upload, download)
|
||||||
|
SELECT (at / ?) * ?, ?, dimension_id, SUM(upload), SUM(download)
|
||||||
|
FROM buckets WHERE resolution = ? AND at < ? GROUP BY (at / ?), dimension_id
|
||||||
|
ON CONFLICT(at, resolution, dimension_id) DO UPDATE SET
|
||||||
|
upload = upload + excluded.upload, download = download + excluded.download
|
||||||
|
`).run(HOUR, HOUR, HOUR, MINUTE, hourCutoff, HOUR);
|
||||||
|
db.prepare('DELETE FROM buckets WHERE resolution = ? AND at < ?').run(MINUTE, hourCutoff);
|
||||||
|
// Idle active flows can outlive retention; their cumulative baseline is still required.
|
||||||
|
// ponytail: closed tombstones can grow for 90 days; tighter pruning needs an upstream replay watermark.
|
||||||
|
db.prepare('DELETE FROM checkpoints WHERE closed = 1 AND last_seen < ?').run(cutoff);
|
||||||
|
db.prepare('DELETE FROM gaps WHERE until_at < ?').run(cutoff);
|
||||||
|
db.exec('DELETE FROM dimensions WHERE id NOT IN (SELECT dimension_id FROM buckets)');
|
||||||
|
db.exec('DELETE FROM period_totals');
|
||||||
|
});
|
||||||
|
periods.clear();
|
||||||
|
lastMaintenance = timestamp;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ingest(batches: NativeTrafficProjectionBatch[], source: LiveTrafficSourceState, missedSince: number | null = null) {
|
||||||
|
const timestamp = now();
|
||||||
|
const changedPeriods = new Set<string>();
|
||||||
|
transaction(db, () => {
|
||||||
|
const collectionStart = Number(meta('started') || batches[0] && Date.parse(batches[0].observedAt) || timestamp);
|
||||||
|
if (!meta('started') && batches.length) setMeta.run('started', String(collectionStart));
|
||||||
|
let lastAt = Number(meta('observed') || 0);
|
||||||
|
if (missedSince !== null) gap(missedSince, timestamp);
|
||||||
|
for (const batch of batches) {
|
||||||
|
const at = Date.parse(batch.observedAt);
|
||||||
|
if (!Number.isFinite(at) || !batch.epoch) continue;
|
||||||
|
if (meta('epoch') !== batch.epoch) {
|
||||||
|
// Projection batches are FIFO. A new sing-box epoch cannot replay old lifecycles.
|
||||||
|
db.exec('DELETE FROM checkpoints');
|
||||||
|
setMeta.run('epoch', batch.epoch);
|
||||||
|
}
|
||||||
|
if (batch.reset) db.prepare('UPDATE checkpoints SET closed = 1, last_seen = MAX(last_seen, ?) WHERE closed = 0').run(at);
|
||||||
|
if (lastAt && (batch.reset || at - lastAt > 5_000)) gap(lastAt, at);
|
||||||
|
for (const connection of batch.connections) {
|
||||||
|
const started = Date.parse(connection.startedAt);
|
||||||
|
if (!Number.isFinite(started)) continue;
|
||||||
|
if (connection.closedAt && Date.parse(connection.closedAt) < timestamp - 90 * DAY) continue;
|
||||||
|
const identity = JSON.stringify([batch.epoch, connection.id, connection.startedAt]);
|
||||||
|
const old = checkpoint.get(identity);
|
||||||
|
const upload = BigInt(connection.traffic.uploadBytes);
|
||||||
|
const download = BigInt(connection.traffic.downloadBytes);
|
||||||
|
if (upload < 0n || download < 0n || upload > MAX_INTEGER || download > MAX_INTEGER) {
|
||||||
|
gap(lastAt || at, at);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (old && at < Number(old.last_seen)) continue;
|
||||||
|
// Initial snapshots baseline pre-existing flows; they are not historical observations.
|
||||||
|
const baseline = !old && started < collectionStart;
|
||||||
|
let up = baseline ? 0n : upload - BigInt(String(old?.upload ?? 0));
|
||||||
|
let down = baseline ? 0n : download - BigInt(String(old?.download ?? 0));
|
||||||
|
if (up < 0n || down < 0n) {
|
||||||
|
gap(old ? Number(old.last_seen) : at, at);
|
||||||
|
up = up < 0n ? 0n : up;
|
||||||
|
down = down < 0n ? 0n : down;
|
||||||
|
}
|
||||||
|
saveCheckpoint.run(identity, old && upload < BigInt(String(old.upload)) ? old.upload : upload,
|
||||||
|
old && download < BigInt(String(old.download)) ? old.download : download,
|
||||||
|
at, connection.closedAt === null ? 0 : 1);
|
||||||
|
if (up === 0n && down === 0n) continue;
|
||||||
|
const host = hostname(connection.destination.domain);
|
||||||
|
const domain = host ? getDomain(host, { allowPrivateDomains: true }) || host : '';
|
||||||
|
const classification = classifyDomain(host);
|
||||||
|
const service = ['yandex.ru', 'yandex.com', 'yandex.net', 'yastatic.net'].includes(domain) ? 'Яндекс'
|
||||||
|
: classification && classification.service !== classification.domain ? classification.service : domain;
|
||||||
|
const origin = connection.origin.kind === 'this-mac' ? 'this-mac'
|
||||||
|
: connection.origin.id || `unknown:${connection.source.ip}`;
|
||||||
|
const ip = net.isIP(connection.destination.ip || '') ? connection.destination.ip! : '';
|
||||||
|
const dims = [origin, connection.source.ip, connection.inbound.tag, service, domain,
|
||||||
|
host, ip, connection.route.kind, connection.route.outbound || ''];
|
||||||
|
const dimension = saveDimension.get(JSON.stringify(dims), origin, connection.origin.label,
|
||||||
|
...dims.slice(1));
|
||||||
|
const resolution = at < Math.floor((timestamp - 7 * DAY) / HOUR) * HOUR ? HOUR : MINUTE;
|
||||||
|
const bucketAt = Math.floor(at / resolution) * resolution;
|
||||||
|
saveBucket.run(bucketAt, resolution, dimension!.id, up, down);
|
||||||
|
for (const [key, period] of periods) {
|
||||||
|
if (bucketAt >= period.from && bucketAt < period.to) changedPeriods.add(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (batch.closedIds.length) {
|
||||||
|
const closed = db.prepare(`UPDATE checkpoints SET closed = 1, last_seen = MAX(last_seen, ?)
|
||||||
|
WHERE closed = 0 AND json_extract(identity, '$[1]') IN (SELECT value FROM json_each(?))`)
|
||||||
|
.run(at, JSON.stringify(batch.closedIds));
|
||||||
|
if (closed.changes) gap(lastAt || at, at); // Terminal identity without final byte totals.
|
||||||
|
}
|
||||||
|
lastAt = Math.max(lastAt, at);
|
||||||
|
}
|
||||||
|
if (lastAt) setMeta.run('observed', String(lastAt));
|
||||||
|
if (source === 'degraded' || source === 'stale' || source === 'incompatible'
|
||||||
|
|| (source === 'connecting' && lastAt)) gap(lastAt || timestamp, timestamp);
|
||||||
|
setMeta.run('source', source);
|
||||||
|
for (const key of changedPeriods) dropPeriod.run(key);
|
||||||
|
});
|
||||||
|
for (const key of changedPeriods) {
|
||||||
|
periods.delete(key);
|
||||||
|
}
|
||||||
|
if (timestamp - lastMaintenance >= HOUR) maintain(timestamp);
|
||||||
|
}
|
||||||
|
|
||||||
|
function query(input: TrafficHistoryQuery): TrafficHistorySnapshot {
|
||||||
|
const timestamp = now();
|
||||||
|
if (timestamp - lastMaintenance >= HOUR) maintain(timestamp);
|
||||||
|
const result = emptyTrafficHistory(input, (meta('source') || 'connecting') as LiveTrafficSourceState, timestamp);
|
||||||
|
const requestedTo = Date.parse(result.period.to);
|
||||||
|
const retentionStart = Math.floor((timestamp - 90 * DAY) / HOUR) * HOUR;
|
||||||
|
const from = Math.max(Date.parse(result.period.from), retentionStart);
|
||||||
|
const minuteFrom = Math.floor((timestamp - 7 * DAY) / HOUR) * HOUR;
|
||||||
|
const terminalResolution = requestedTo < minuteFrom ? HOUR : MINUTE;
|
||||||
|
const to = Math.floor(requestedTo / terminalResolution) * terminalResolution;
|
||||||
|
result.period.to = new Date(to).toISOString();
|
||||||
|
result.query.until = to;
|
||||||
|
// Bounds describe complete stored buckets; old data is explicitly hourly, not minute-precise.
|
||||||
|
const effectiveFrom = Math.min(to, Math.floor(from / (from < minuteFrom ? HOUR : MINUTE)) * (from < minuteFrom ? HOUR : MINUTE));
|
||||||
|
result.period.from = new Date(effectiveFrom).toISOString();
|
||||||
|
result.period.minuteFrom = new Date(minuteFrom).toISOString();
|
||||||
|
result.period.availableFrom = meta('started') ? new Date(Math.max(Number(meta('started')), retentionStart)).toISOString() : null;
|
||||||
|
const lastAt = Number(meta('observed') || 0);
|
||||||
|
result.coverage.lastObservedAt = lastAt ? new Date(lastAt).toISOString() : null;
|
||||||
|
result.coverage.gapCount = Number(db.prepare('SELECT COUNT(*) AS count FROM gaps WHERE until_at >= ? AND at < ?').get(effectiveFrom, to)?.count);
|
||||||
|
result.coverage.partial = result.coverage.gapCount > 0 || ['stale', 'degraded', 'incompatible'].includes(result.source)
|
||||||
|
|| (lastAt > 0 && result.source === 'live' && timestamp - lastAt > 5_000);
|
||||||
|
const period = periodTotals(effectiveFrom, to);
|
||||||
|
const where = ['b.period = ?'];
|
||||||
|
const args: Array<string | number> = [period];
|
||||||
|
for (const [column, value] of [
|
||||||
|
['origin_id', input.originId], ['service', input.service], ['domain', input.domain], ['hostname', input.hostname],
|
||||||
|
['route', input.route === 'all' ? '' : input.route],
|
||||||
|
]) if (value) { where.push(`d.${column} = ?`); args.push(value); }
|
||||||
|
// Empty domain is a real IP-only group. Parent filters need an explicit level, not truthiness alone.
|
||||||
|
if (input.level !== 'service') { where.push('d.service = ?'); args.push(input.service); }
|
||||||
|
if (input.level === 'hostname' || input.level === 'ip') { where.push('d.domain = ?'); args.push(input.domain); }
|
||||||
|
if (input.level === 'ip') { where.push('d.hostname = ?'); args.push(input.hostname); }
|
||||||
|
if (input.search) {
|
||||||
|
where.push("instr(lower_unicode(d.hostname || ' ' || d.ip || ' ' || d.service), ?) > 0"); args.push(input.search.toLowerCase());
|
||||||
|
}
|
||||||
|
const joined = `FROM period_totals b JOIN dimensions d ON d.id = b.dimension_id WHERE ${where.join(' AND ')}`;
|
||||||
|
const totals = db.prepare(`SELECT COALESCE(SUM(b.upload), 0) AS upload, COALESCE(SUM(b.download), 0) AS download ${joined}`);
|
||||||
|
totals.setReadBigInts(true);
|
||||||
|
const sum = totals.get(...args)!;
|
||||||
|
result.totals = { uploadBytes: String(sum.upload), downloadBytes: String(sum.download) };
|
||||||
|
const rows = db.prepare(`SELECT d.${input.level} AS key, SUM(b.upload) AS upload, SUM(b.download) AS download,
|
||||||
|
CASE WHEN COUNT(DISTINCT d.route) = 1 THEN MIN(d.route) ELSE 'mixed' END AS route
|
||||||
|
${joined} GROUP BY d.${input.level} ORDER BY SUM(b.upload) + SUM(b.download) DESC, key LIMIT 101 OFFSET ?`);
|
||||||
|
rows.setReadBigInts(true);
|
||||||
|
const page = rows.all(...args, input.offset);
|
||||||
|
result.rows = page.slice(0, 100).map((row) => ({
|
||||||
|
key: String(row.key), label: String(row.key) || (input.level === 'ip' ? 'IP неизвестен' : 'Без домена'),
|
||||||
|
uploadBytes: String(row.upload), downloadBytes: String(row.download), route: String(row.route) as 'mixed',
|
||||||
|
}));
|
||||||
|
result.nextOffset = page.length > 100 ? input.offset + 100 : null;
|
||||||
|
const origins = db.prepare(`SELECT d.origin_id AS id, MAX(d.origin_label) AS label FROM dimensions d
|
||||||
|
JOIN period_totals b ON b.dimension_id = d.id WHERE b.period = ?
|
||||||
|
GROUP BY d.origin_id ORDER BY id LIMIT 257`).all(period);
|
||||||
|
result.origins = origins.slice(0, 256).map((row) => ({ id: String(row.id), label: String(row.label) }));
|
||||||
|
result.originsTruncated = origins.length > 256;
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
return { ingest, query, maintain, close: () => db.close() };
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import { parentPort, workerData } from 'node:worker_threads';
|
||||||
|
import type { LiveTrafficSourceState } from '../../shared/liveTraffic.js';
|
||||||
|
import type { TrafficHistoryQuery } from '../../shared/trafficHistory.js';
|
||||||
|
import type { NativeTrafficProjectionBatch } from './liveTrafficService.js';
|
||||||
|
import { openTrafficHistoryStore } from './trafficHistoryStore.js';
|
||||||
|
|
||||||
|
export type HistoryWorkerRequest =
|
||||||
|
| { id: number; kind: 'ingest'; batches: NativeTrafficProjectionBatch[]; source: LiveTrafficSourceState; missedSince: number | null }
|
||||||
|
| { id: number; kind: 'query'; query: TrafficHistoryQuery }
|
||||||
|
| { id: number; kind: 'close' };
|
||||||
|
|
||||||
|
const store = openTrafficHistoryStore(workerData.filePath);
|
||||||
|
parentPort!.postMessage({ ready: true });
|
||||||
|
parentPort!.on('message', (message: HistoryWorkerRequest) => {
|
||||||
|
try {
|
||||||
|
let result: unknown = null;
|
||||||
|
if (message.kind === 'ingest') store.ingest(message.batches, message.source, message.missedSince);
|
||||||
|
else if (message.kind === 'query') result = store.query(message.query);
|
||||||
|
else store.close();
|
||||||
|
parentPort!.postMessage({ id: message.id, result });
|
||||||
|
if (message.kind === 'close') parentPort!.close();
|
||||||
|
} catch {
|
||||||
|
// Do not expose filesystem paths, SQL or native metadata through the public error.
|
||||||
|
parentPort!.postMessage({ id: message.id, error: 'TRAFFIC_HISTORY_UNAVAILABLE' });
|
||||||
|
}
|
||||||
|
});
|
||||||
+22
-2
@@ -99,6 +99,8 @@ export function buildGatewayConfig(subscriptionConfig: unknown, selectedTag: unk
|
|||||||
routeRules = [],
|
routeRules = [],
|
||||||
}: { clientDirect?: boolean; routeRules?: unknown } = {}) {
|
}: { clientDirect?: boolean; routeRules?: unknown } = {}) {
|
||||||
const clientMode = settings.appMode === 'client';
|
const clientMode = settings.appMode === 'client';
|
||||||
|
const nativeTraffic = settings.singboxTrafficSource === 'native'
|
||||||
|
|| settings.singboxTrafficSource === 'shadow';
|
||||||
const directClient = clientMode && clientDirect;
|
const directClient = clientMode && clientDirect;
|
||||||
const vpnOutbound = selectedOutbound(subscriptionConfig, selectedTag);
|
const vpnOutbound = selectedOutbound(subscriptionConfig, selectedTag);
|
||||||
const outboundTag = directClient ? 'direct' : vpnOutbound.tag;
|
const outboundTag = directClient ? 'direct' : vpnOutbound.tag;
|
||||||
@@ -158,13 +160,21 @@ export function buildGatewayConfig(subscriptionConfig: unknown, selectedTag: unk
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
log: { level: settings.logLevel, timestamp: true },
|
log: { level: settings.logLevel, timestamp: true },
|
||||||
|
...(nativeTraffic ? {
|
||||||
|
services: [{
|
||||||
|
type: 'api',
|
||||||
|
listen: '127.0.0.1',
|
||||||
|
listen_port: settings.singboxNativeApiPort,
|
||||||
|
dashboard: false,
|
||||||
|
}],
|
||||||
|
} : {}),
|
||||||
experimental: {
|
experimental: {
|
||||||
cache_file: { enabled: true, path: settings.cachePath },
|
cache_file: { enabled: true, path: settings.cachePath },
|
||||||
...(!clientMode ? {
|
...(!clientMode ? {
|
||||||
clash_api: { external_controller: `127.0.0.1:${settings.singboxApiPort}` },
|
clash_api: { external_controller: `127.0.0.1:${settings.singboxApiPort}` },
|
||||||
} : {}),
|
} : {}),
|
||||||
},
|
},
|
||||||
dns: { independent_cache: true },
|
dns: nativeTraffic ? {} : { independent_cache: true },
|
||||||
inbounds,
|
inbounds,
|
||||||
outbounds: [
|
outbounds: [
|
||||||
vpnOutbound,
|
vpnOutbound,
|
||||||
@@ -185,6 +195,8 @@ export function buildDualChannelGatewayConfig(
|
|||||||
{ routeRules = [], defaultRole = 'primary' }: { routeRules?: unknown; defaultRole?: 'primary' | 'reserve' } = {},
|
{ routeRules = [], defaultRole = 'primary' }: { routeRules?: unknown; defaultRole?: 'primary' | 'reserve' } = {},
|
||||||
) {
|
) {
|
||||||
if (settings.appMode === 'client') throw new Error('Dual-channel config доступен только Gateway');
|
if (settings.appMode === 'client') throw new Error('Dual-channel config доступен только Gateway');
|
||||||
|
const nativeTraffic = settings.singboxTrafficSource === 'native'
|
||||||
|
|| settings.singboxTrafficSource === 'shadow';
|
||||||
const primary = selectedOutbound(
|
const primary = selectedOutbound(
|
||||||
channels.primary.subscriptionConfig,
|
channels.primary.subscriptionConfig,
|
||||||
channels.primary.selectedServerId,
|
channels.primary.selectedServerId,
|
||||||
@@ -204,11 +216,19 @@ export function buildDualChannelGatewayConfig(
|
|||||||
const userInbounds = [TPROXY_INBOUND, MIXED_INBOUND];
|
const userInbounds = [TPROXY_INBOUND, MIXED_INBOUND];
|
||||||
return {
|
return {
|
||||||
log: { level: settings.logLevel, timestamp: true },
|
log: { level: settings.logLevel, timestamp: true },
|
||||||
|
...(nativeTraffic ? {
|
||||||
|
services: [{
|
||||||
|
type: 'api',
|
||||||
|
listen: '127.0.0.1',
|
||||||
|
listen_port: settings.singboxNativeApiPort,
|
||||||
|
dashboard: false,
|
||||||
|
}],
|
||||||
|
} : {}),
|
||||||
experimental: {
|
experimental: {
|
||||||
cache_file: { enabled: true, path: settings.cachePath },
|
cache_file: { enabled: true, path: settings.cachePath },
|
||||||
clash_api: { external_controller: `127.0.0.1:${settings.singboxApiPort}` },
|
clash_api: { external_controller: `127.0.0.1:${settings.singboxApiPort}` },
|
||||||
},
|
},
|
||||||
dns: { independent_cache: true },
|
dns: nativeTraffic ? {} : { independent_cache: true },
|
||||||
inbounds: [
|
inbounds: [
|
||||||
{ type: 'tproxy', tag: TPROXY_INBOUND, listen: '::', listen_port: settings.tproxyPort },
|
{ type: 'tproxy', tag: TPROXY_INBOUND, listen: '::', listen_port: settings.tproxyPort },
|
||||||
{ type: 'mixed', tag: MIXED_INBOUND, listen: settings.bindIp, listen_port: settings.proxyPort, set_system_proxy: false },
|
{ type: 'mixed', tag: MIXED_INBOUND, listen: settings.bindIp, listen_port: settings.proxyPort, set_system_proxy: false },
|
||||||
|
|||||||
@@ -3,33 +3,64 @@ import fs from 'node:fs';
|
|||||||
import { spawn, spawnSync, type ChildProcess } from 'node:child_process';
|
import { spawn, spawnSync, type ChildProcess } from 'node:child_process';
|
||||||
import { setGatewayInterception } from './gatewayRouting.js';
|
import { setGatewayInterception } from './gatewayRouting.js';
|
||||||
import { HarborError } from '../shared/errors.js';
|
import { HarborError } from '../shared/errors.js';
|
||||||
|
import {
|
||||||
|
materializeGatewayNativeConfig,
|
||||||
|
materializeGatewaySnapshotConfig,
|
||||||
|
} from './gatewayNativeRuntime.js';
|
||||||
|
|
||||||
export function createSingboxRuntime({
|
export function createSingboxRuntime({
|
||||||
configPath,
|
configPath,
|
||||||
gateway = false,
|
gateway = false,
|
||||||
tproxyChain = '',
|
tproxyChain = '',
|
||||||
|
gatewayRuntimeConfigPath,
|
||||||
|
nativeApi,
|
||||||
}: {
|
}: {
|
||||||
configPath: string;
|
configPath: string;
|
||||||
gateway?: boolean;
|
gateway?: boolean;
|
||||||
tproxyChain?: string;
|
tproxyChain?: string;
|
||||||
|
gatewayRuntimeConfigPath?: string;
|
||||||
|
nativeApi?: {
|
||||||
|
apiPort: number;
|
||||||
|
secretPath: string;
|
||||||
|
runtimeConfigPath: string;
|
||||||
|
};
|
||||||
}) {
|
}) {
|
||||||
let child: ChildProcess | null = null;
|
let child: ChildProcess | null = null;
|
||||||
let configHash = '';
|
let configHash = '';
|
||||||
let startedAt: string | null = null;
|
let startedAt: string | null = null;
|
||||||
|
let nativeApiSecret: string | null = null;
|
||||||
|
let nativeApiWarning: string | null = null;
|
||||||
|
|
||||||
const state = () => ({ running: Boolean(child), startedAt });
|
const state = () => ({ running: Boolean(child), startedAt, nativeApiWarning });
|
||||||
|
|
||||||
function checkConfig(config: unknown) {
|
function checked(configFile: string) {
|
||||||
const directory = fs.mkdtempSync(`${configPath}.check-`);
|
const check = spawnSync('sing-box', ['check', '-c', configFile], { encoding: 'utf8' });
|
||||||
const candidatePath = `${directory}/config.json`;
|
|
||||||
try {
|
|
||||||
fs.writeFileSync(candidatePath, JSON.stringify(config));
|
|
||||||
const check = spawnSync('sing-box', ['check', '-c', candidatePath], { encoding: 'utf8' });
|
|
||||||
if (check.status !== 0) {
|
if (check.status !== 0) {
|
||||||
throw new HarborError('CONFIG_INVALID', {
|
throw new HarborError('CONFIG_INVALID', {
|
||||||
cause: new Error((check.stderr || check.stdout || check.error?.message || 'sing-box check failed').trim()),
|
cause: new Error((check.stderr || check.stdout || check.error?.message || 'sing-box check failed').trim()),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function checkConfig(config: unknown) {
|
||||||
|
if (nativeApi) {
|
||||||
|
const materialized = materializeGatewayNativeConfig(config, nativeApi);
|
||||||
|
checked(materialized.configPath);
|
||||||
|
return {
|
||||||
|
valid: true,
|
||||||
|
...(materialized.warning ? { warning: materialized.warning } : {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (gatewayRuntimeConfigPath) {
|
||||||
|
const materialized = materializeGatewaySnapshotConfig(config, gatewayRuntimeConfigPath);
|
||||||
|
checked(materialized.configPath);
|
||||||
|
return { valid: true };
|
||||||
|
}
|
||||||
|
const directory = fs.mkdtempSync(`${configPath}.check-`);
|
||||||
|
const candidatePath = `${directory}/config.json`;
|
||||||
|
try {
|
||||||
|
fs.writeFileSync(candidatePath, JSON.stringify(config));
|
||||||
|
checked(candidatePath);
|
||||||
return { valid: true };
|
return { valid: true };
|
||||||
} finally {
|
} finally {
|
||||||
fs.rmSync(directory, { recursive: true, force: true });
|
fs.rmSync(directory, { recursive: true, force: true });
|
||||||
@@ -41,6 +72,7 @@ export function createSingboxRuntime({
|
|||||||
if (!child) {
|
if (!child) {
|
||||||
configHash = '';
|
configHash = '';
|
||||||
startedAt = null;
|
startedAt = null;
|
||||||
|
nativeApiSecret = null;
|
||||||
return state();
|
return state();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -48,6 +80,7 @@ export function createSingboxRuntime({
|
|||||||
child = null;
|
child = null;
|
||||||
configHash = '';
|
configHash = '';
|
||||||
startedAt = null;
|
startedAt = null;
|
||||||
|
nativeApiSecret = null;
|
||||||
await new Promise<void>((resolve) => {
|
await new Promise<void>((resolve) => {
|
||||||
const timeout = setTimeout(() => {
|
const timeout = setTimeout(() => {
|
||||||
current.kill('SIGKILL');
|
current.kill('SIGKILL');
|
||||||
@@ -65,23 +98,37 @@ export function createSingboxRuntime({
|
|||||||
async function apply({ force = false } = {}) {
|
async function apply({ force = false } = {}) {
|
||||||
if (!fs.existsSync(configPath)) {
|
if (!fs.existsSync(configPath)) {
|
||||||
await stop();
|
await stop();
|
||||||
|
nativeApiWarning = null;
|
||||||
return state();
|
return state();
|
||||||
}
|
}
|
||||||
|
|
||||||
const check = spawnSync('sing-box', ['check', '-c', configPath], { encoding: 'utf8' });
|
let materialized = { configPath, secret: null as string | null, warning: null as string | null };
|
||||||
if (check.status !== 0) {
|
if (nativeApi || gatewayRuntimeConfigPath) {
|
||||||
throw new HarborError('CONFIG_INVALID', {
|
let config: unknown;
|
||||||
cause: new Error((check.stderr || check.stdout || check.error?.message || 'sing-box check failed').trim()),
|
try {
|
||||||
});
|
config = JSON.parse(fs.readFileSync(configPath, 'utf8'));
|
||||||
|
} catch (cause) {
|
||||||
|
throw new HarborError('CONFIG_INVALID', { cause });
|
||||||
}
|
}
|
||||||
|
materialized = nativeApi
|
||||||
|
? materializeGatewayNativeConfig(config, nativeApi)
|
||||||
|
: materializeGatewaySnapshotConfig(config, gatewayRuntimeConfigPath!);
|
||||||
|
}
|
||||||
|
checked(materialized.configPath);
|
||||||
|
|
||||||
const nextHash = crypto.createHash('sha256').update(fs.readFileSync(configPath)).digest('hex');
|
const nextHash = crypto.createHash('sha256')
|
||||||
if (!force && child && nextHash === configHash) return state();
|
.update(fs.readFileSync(materialized.configPath))
|
||||||
|
.digest('hex');
|
||||||
|
if (!force && child && nextHash === configHash) {
|
||||||
|
nativeApiSecret = materialized.secret;
|
||||||
|
nativeApiWarning = materialized.warning;
|
||||||
|
return state();
|
||||||
|
}
|
||||||
|
|
||||||
await stop();
|
await stop();
|
||||||
let current: ChildProcess;
|
let current: ChildProcess;
|
||||||
try {
|
try {
|
||||||
current = spawn('sing-box', ['run', '-c', configPath], {
|
current = spawn('sing-box', ['run', '-c', materialized.configPath], {
|
||||||
stdio: ['ignore', 'inherit', 'inherit'],
|
stdio: ['ignore', 'inherit', 'inherit'],
|
||||||
});
|
});
|
||||||
await new Promise<void>((resolve, reject) => {
|
await new Promise<void>((resolve, reject) => {
|
||||||
@@ -94,6 +141,8 @@ export function createSingboxRuntime({
|
|||||||
child = current;
|
child = current;
|
||||||
configHash = nextHash;
|
configHash = nextHash;
|
||||||
startedAt = new Date().toISOString();
|
startedAt = new Date().toISOString();
|
||||||
|
nativeApiSecret = materialized.secret;
|
||||||
|
nativeApiWarning = materialized.warning;
|
||||||
try {
|
try {
|
||||||
if (gateway) setGatewayInterception(true, tproxyChain);
|
if (gateway) setGatewayInterception(true, tproxyChain);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -101,6 +150,7 @@ export function createSingboxRuntime({
|
|||||||
child = null;
|
child = null;
|
||||||
configHash = '';
|
configHash = '';
|
||||||
startedAt = null;
|
startedAt = null;
|
||||||
|
nativeApiSecret = null;
|
||||||
throw new HarborError('PROCESS_START_FAILED', { cause: error });
|
throw new HarborError('PROCESS_START_FAILED', { cause: error });
|
||||||
}
|
}
|
||||||
current.once('exit', () => {
|
current.once('exit', () => {
|
||||||
@@ -108,6 +158,7 @@ export function createSingboxRuntime({
|
|||||||
child = null;
|
child = null;
|
||||||
configHash = '';
|
configHash = '';
|
||||||
startedAt = null;
|
startedAt = null;
|
||||||
|
nativeApiSecret = null;
|
||||||
if (gateway) setGatewayInterception(false, tproxyChain);
|
if (gateway) setGatewayInterception(false, tproxyChain);
|
||||||
});
|
});
|
||||||
return state();
|
return state();
|
||||||
@@ -116,6 +167,8 @@ export function createSingboxRuntime({
|
|||||||
return {
|
return {
|
||||||
get running() { return Boolean(child); },
|
get running() { return Boolean(child); },
|
||||||
get startedAt() { return startedAt; },
|
get startedAt() { return startedAt; },
|
||||||
|
get nativeApiSecret() { return nativeApiSecret; },
|
||||||
|
get nativeApiWarning() { return nativeApiWarning; },
|
||||||
refresh: async () => state(),
|
refresh: async () => state(),
|
||||||
checkConfig,
|
checkConfig,
|
||||||
apply,
|
apply,
|
||||||
|
|||||||
@@ -24,6 +24,40 @@ export const CONNECTIVITY_SITES = Object.freeze([
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
export const MAX_CUSTOM_DIAGNOSTIC_SERVICES = 5;
|
export const MAX_CUSTOM_DIAGNOSTIC_SERVICES = 5;
|
||||||
|
export const MAX_CUSTOM_DNS_RESOLVERS = 5;
|
||||||
|
export const MAX_CUSTOM_DNS_DOMAINS = 5;
|
||||||
|
|
||||||
|
export const DNS_DIAGNOSTIC_DOMAINS = Object.freeze([
|
||||||
|
{ id: 'youtube', label: 'YouTube', hostname: 'www.youtube.com', builtIn: true },
|
||||||
|
{ id: 'chatgpt', label: 'ChatGPT', hostname: 'chatgpt.com', builtIn: true },
|
||||||
|
]);
|
||||||
|
|
||||||
|
export const DNS_DIAGNOSTIC_RESOLVERS = Object.freeze([
|
||||||
|
{ id: 'google-dns', label: 'Google DNS', kind: 'dns', endpoint: '8.8.8.8' },
|
||||||
|
{
|
||||||
|
id: 'google-doh',
|
||||||
|
label: 'Google DoH',
|
||||||
|
kind: 'doh',
|
||||||
|
endpoint: 'https://dns.google/dns-query',
|
||||||
|
bootstrap: '8.8.8.8',
|
||||||
|
},
|
||||||
|
{ id: 'cloudflare-dns', label: 'Cloudflare DNS', kind: 'dns', endpoint: '1.1.1.1' },
|
||||||
|
{
|
||||||
|
id: 'cloudflare-doh',
|
||||||
|
label: 'Cloudflare DoH',
|
||||||
|
kind: 'doh',
|
||||||
|
endpoint: 'https://cloudflare-dns.com/dns-query',
|
||||||
|
bootstrap: '1.1.1.1',
|
||||||
|
},
|
||||||
|
{ id: 'yandex-dns', label: 'Яндекс DNS', kind: 'dns', endpoint: '77.88.8.8' },
|
||||||
|
{
|
||||||
|
id: 'yandex-doh',
|
||||||
|
label: 'Яндекс DoH',
|
||||||
|
kind: 'doh',
|
||||||
|
endpoint: 'https://common.dot.dns.yandex.net/dns-query',
|
||||||
|
bootstrap: '77.88.8.8',
|
||||||
|
},
|
||||||
|
] satisfies DnsResolverDefinition[]);
|
||||||
|
|
||||||
export interface DiagnosticService {
|
export interface DiagnosticService {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -31,10 +65,43 @@ export interface DiagnosticService {
|
|||||||
url: string;
|
url: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface DnsResolverDefinition {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
kind: 'dns' | 'doh';
|
||||||
|
endpoint: string;
|
||||||
|
bootstrap?: string;
|
||||||
|
system?: boolean;
|
||||||
|
custom?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DnsDomainDefinition {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
hostname: string;
|
||||||
|
builtIn?: boolean;
|
||||||
|
custom?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CustomDnsResolver {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
kind: 'dns' | 'doh';
|
||||||
|
endpoint: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CustomDnsDomain {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
hostname: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface DiagnosticSettings {
|
export interface DiagnosticSettings {
|
||||||
configured: boolean;
|
configured: boolean;
|
||||||
customServices: DiagnosticService[];
|
customServices: DiagnosticService[];
|
||||||
hiddenServiceIds: string[];
|
hiddenServiceIds: string[];
|
||||||
|
customDnsResolvers: CustomDnsResolver[];
|
||||||
|
customDnsDomains: CustomDnsDomain[];
|
||||||
}
|
}
|
||||||
|
|
||||||
function record(value: unknown): Record<string, unknown> {
|
function record(value: unknown): Record<string, unknown> {
|
||||||
@@ -62,6 +129,65 @@ function diagnosticService(value: unknown): DiagnosticService | null {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function ipLiteral(value: string) {
|
||||||
|
if (!value || /[\s/?#@\[\]]/.test(value)) return false;
|
||||||
|
try {
|
||||||
|
const parsed = new URL(`http://${value.includes(':') ? `[${value}]` : value}/`);
|
||||||
|
const hostname = parsed.hostname.replace(/^\[|\]$/g, '');
|
||||||
|
return hostname === value.toLowerCase() && (value.includes(':') || /^(?:\d{1,3}\.){3}\d{1,3}$/.test(value));
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function dohEndpoint(value: string) {
|
||||||
|
try {
|
||||||
|
const parsed = new URL(value);
|
||||||
|
if (
|
||||||
|
parsed.protocol !== 'https:'
|
||||||
|
|| parsed.username
|
||||||
|
|| parsed.password
|
||||||
|
|| parsed.search
|
||||||
|
|| parsed.hash
|
||||||
|
|| (parsed.port && parsed.port !== '443')
|
||||||
|
) return null;
|
||||||
|
return parsed.href;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function customDnsResolver(value: unknown): CustomDnsResolver | null {
|
||||||
|
const candidate = record(value);
|
||||||
|
const id = typeof candidate.id === 'string' ? candidate.id.trim() : '';
|
||||||
|
const label = typeof candidate.label === 'string' ? candidate.label.trim() : '';
|
||||||
|
const kind = candidate.kind === 'dns' || candidate.kind === 'doh' ? candidate.kind : null;
|
||||||
|
const rawEndpoint = typeof candidate.endpoint === 'string' ? candidate.endpoint.trim() : '';
|
||||||
|
if (!/^custom-dns-[a-z0-9-]{1,72}$/i.test(id) || !label || label.length > 40 || !kind) return null;
|
||||||
|
const endpoint = kind === 'dns' ? (ipLiteral(rawEndpoint) ? rawEndpoint.toLowerCase() : null) : dohEndpoint(rawEndpoint);
|
||||||
|
return endpoint ? { id, label, kind, endpoint } : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizedHostname(value: unknown) {
|
||||||
|
const text = typeof value === 'string' ? value.trim().replace(/\.$/, '') : '';
|
||||||
|
if (!text || text.length > 253 || /[\s/?#@:]/.test(text)) return null;
|
||||||
|
try {
|
||||||
|
const hostname = new URL(`http://${text}/`).hostname.toLowerCase();
|
||||||
|
return hostname && hostname.length <= 253 ? hostname : null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function customDnsDomain(value: unknown): CustomDnsDomain | null {
|
||||||
|
const candidate = record(value);
|
||||||
|
const id = typeof candidate.id === 'string' ? candidate.id.trim() : '';
|
||||||
|
const label = typeof candidate.label === 'string' ? candidate.label.trim() : '';
|
||||||
|
const hostname = normalizedHostname(candidate.hostname);
|
||||||
|
if (!/^custom-domain-[a-z0-9-]{1,68}$/i.test(id) || !label || label.length > 40 || !hostname) return null;
|
||||||
|
return { id, label, hostname };
|
||||||
|
}
|
||||||
|
|
||||||
export function normalizeDiagnosticSettings(
|
export function normalizeDiagnosticSettings(
|
||||||
value: unknown,
|
value: unknown,
|
||||||
{ strict = false }: { strict?: boolean } = {},
|
{ strict = false }: { strict?: boolean } = {},
|
||||||
@@ -78,17 +204,35 @@ export function normalizeDiagnosticSettings(
|
|||||||
const hiddenServiceIds = requestedHiddenIds
|
const hiddenServiceIds = requestedHiddenIds
|
||||||
.filter((id): id is string => typeof id === 'string' && builtInIds.has(id))
|
.filter((id): id is string => typeof id === 'string' && builtInIds.has(id))
|
||||||
.filter((id, index, ids) => ids.indexOf(id) === index);
|
.filter((id, index, ids) => ids.indexOf(id) === index);
|
||||||
|
const requestedDnsResolvers = Array.isArray(candidate.customDnsResolvers) ? candidate.customDnsResolvers : [];
|
||||||
|
const customDnsResolvers = requestedDnsResolvers
|
||||||
|
.map(customDnsResolver)
|
||||||
|
.filter((resolver): resolver is CustomDnsResolver => Boolean(resolver))
|
||||||
|
.filter((resolver, index, resolvers) => resolvers.findIndex(({ id }) => id === resolver.id) === index)
|
||||||
|
.slice(0, MAX_CUSTOM_DNS_RESOLVERS);
|
||||||
|
const requestedDnsDomains = Array.isArray(candidate.customDnsDomains) ? candidate.customDnsDomains : [];
|
||||||
|
const customDnsDomains = requestedDnsDomains
|
||||||
|
.map(customDnsDomain)
|
||||||
|
.filter((domain): domain is CustomDnsDomain => Boolean(domain))
|
||||||
|
.filter((domain, index, domains) => domains.findIndex(({ id }) => id === domain.id) === index)
|
||||||
|
.slice(0, MAX_CUSTOM_DNS_DOMAINS);
|
||||||
if (strict && (
|
if (strict && (
|
||||||
typeof candidate.configured !== 'boolean'
|
typeof candidate.configured !== 'boolean'
|
||||||
|| !Array.isArray(candidate.customServices)
|
|| !Array.isArray(candidate.customServices)
|
||||||
|| !Array.isArray(candidate.hiddenServiceIds)
|
|| !Array.isArray(candidate.hiddenServiceIds)
|
||||||
|| customServices.length !== requestedServices.length
|
|| customServices.length !== requestedServices.length
|
||||||
|| hiddenServiceIds.length !== requestedHiddenIds.length
|
|| hiddenServiceIds.length !== requestedHiddenIds.length
|
||||||
|
|| (candidate.customDnsResolvers !== undefined && !Array.isArray(candidate.customDnsResolvers))
|
||||||
|
|| (candidate.customDnsDomains !== undefined && !Array.isArray(candidate.customDnsDomains))
|
||||||
|
|| customDnsResolvers.length !== requestedDnsResolvers.length
|
||||||
|
|| customDnsDomains.length !== requestedDnsDomains.length
|
||||||
)) throw new TypeError('Invalid diagnostic settings');
|
)) throw new TypeError('Invalid diagnostic settings');
|
||||||
return {
|
return {
|
||||||
configured: candidate.configured === true,
|
configured: candidate.configured === true,
|
||||||
customServices,
|
customServices,
|
||||||
hiddenServiceIds,
|
hiddenServiceIds,
|
||||||
|
customDnsResolvers,
|
||||||
|
customDnsDomains,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -18,6 +18,10 @@ import {
|
|||||||
type FailoverRuntimeState,
|
type FailoverRuntimeState,
|
||||||
type FailoverSnapshot,
|
type FailoverSnapshot,
|
||||||
} from '../failover.js';
|
} from '../failover.js';
|
||||||
|
import {
|
||||||
|
normalizeTrafficSettings,
|
||||||
|
type TrafficSettings,
|
||||||
|
} from '../trafficSettings.js';
|
||||||
|
|
||||||
export type HarborMode = 'client' | 'gateway';
|
export type HarborMode = 'client' | 'gateway';
|
||||||
export type ConnectionState = 'running' | 'stopped';
|
export type ConnectionState = 'running' | 'stopped';
|
||||||
@@ -93,6 +97,7 @@ export interface StateSnapshot {
|
|||||||
lastError: string | null;
|
lastError: string | null;
|
||||||
};
|
};
|
||||||
diagnostics: DiagnosticSettings;
|
diagnostics: DiagnosticSettings;
|
||||||
|
traffic: TrafficSettings;
|
||||||
failover: FailoverSnapshot;
|
failover: FailoverSnapshot;
|
||||||
route: {
|
route: {
|
||||||
rulesContractVersion?: typeof ROUTE_RULES_CONTRACT_VERSION;
|
rulesContractVersion?: typeof ROUTE_RULES_CONTRACT_VERSION;
|
||||||
@@ -133,6 +138,7 @@ export interface PersistedState extends Record<string, unknown> {
|
|||||||
connectionDesired?: ConnectionState;
|
connectionDesired?: ConnectionState;
|
||||||
gatewayAutoEnabled?: boolean;
|
gatewayAutoEnabled?: boolean;
|
||||||
diagnostics: DiagnosticSettings;
|
diagnostics: DiagnosticSettings;
|
||||||
|
traffic: TrafficSettings;
|
||||||
failoverPolicy: FailoverPolicy;
|
failoverPolicy: FailoverPolicy;
|
||||||
failoverRuntimeState: FailoverRuntimeState;
|
failoverRuntimeState: FailoverRuntimeState;
|
||||||
appliedFailoverPolicy: AppliedFailoverPolicy | null;
|
appliedFailoverPolicy: AppliedFailoverPolicy | null;
|
||||||
@@ -307,6 +313,7 @@ export function normalizeStoredState(value: unknown): StoredState {
|
|||||||
? state.routeRulesRevision
|
? state.routeRulesRevision
|
||||||
: 0,
|
: 0,
|
||||||
diagnostics: normalizeDiagnosticSettings(state.diagnostics),
|
diagnostics: normalizeDiagnosticSettings(state.diagnostics),
|
||||||
|
traffic: normalizeTrafficSettings(state.traffic),
|
||||||
failoverPolicy: normalizeFailoverPolicy(state.failoverPolicy),
|
failoverPolicy: normalizeFailoverPolicy(state.failoverPolicy),
|
||||||
failoverRuntimeState: normalizeFailoverRuntimeState(state.failoverRuntimeState),
|
failoverRuntimeState: normalizeFailoverRuntimeState(state.failoverRuntimeState),
|
||||||
appliedFailoverPolicy: normalizeAppliedFailoverPolicy(state.appliedFailoverPolicy),
|
appliedFailoverPolicy: normalizeAppliedFailoverPolicy(state.appliedFailoverPolicy),
|
||||||
@@ -410,6 +417,7 @@ export function createStateSnapshot({
|
|||||||
lastError: null,
|
lastError: null,
|
||||||
},
|
},
|
||||||
diagnostics: stored.diagnostics,
|
diagnostics: stored.diagnostics,
|
||||||
|
traffic: stored.traffic,
|
||||||
failover: failoverSnapshot || createIdleFailoverSnapshot(stored.failoverPolicy),
|
failover: failoverSnapshot || createIdleFailoverSnapshot(stored.failoverPolicy),
|
||||||
route: {
|
route: {
|
||||||
rulesContractVersion: ROUTE_RULES_CONTRACT_VERSION,
|
rulesContractVersion: ROUTE_RULES_CONTRACT_VERSION,
|
||||||
@@ -449,12 +457,15 @@ export function assertStateSnapshot(snapshot: unknown): StateSnapshot {
|
|||||||
const candidateWithDiagnostics = rawCandidate && rawCandidate.diagnostics === undefined
|
const candidateWithDiagnostics = rawCandidate && rawCandidate.diagnostics === undefined
|
||||||
? { ...rawCandidate, diagnostics: normalizeDiagnosticSettings(null) }
|
? { ...rawCandidate, diagnostics: normalizeDiagnosticSettings(null) }
|
||||||
: rawCandidate;
|
: rawCandidate;
|
||||||
const candidateWithFailover = candidateWithDiagnostics && candidateWithDiagnostics.failover === undefined
|
const candidateWithTraffic = candidateWithDiagnostics && candidateWithDiagnostics.traffic === undefined
|
||||||
|
? { ...candidateWithDiagnostics, traffic: normalizeTrafficSettings(null) }
|
||||||
|
: candidateWithDiagnostics;
|
||||||
|
const candidateWithFailover = candidateWithTraffic && candidateWithTraffic.failover === undefined
|
||||||
? {
|
? {
|
||||||
...candidateWithDiagnostics,
|
...candidateWithTraffic,
|
||||||
failover: createIdleFailoverSnapshot(normalizeFailoverPolicy(null)),
|
failover: createIdleFailoverSnapshot(normalizeFailoverPolicy(null)),
|
||||||
}
|
}
|
||||||
: candidateWithDiagnostics;
|
: candidateWithTraffic;
|
||||||
const candidate = candidateWithFailover?.route?.rulesContractVersion === undefined
|
const candidate = candidateWithFailover?.route?.rulesContractVersion === undefined
|
||||||
&& Array.isArray(candidateWithFailover?.route?.localRules)
|
&& Array.isArray(candidateWithFailover?.route?.localRules)
|
||||||
&& Array.isArray(candidateWithFailover?.route?.activeLocalRules)
|
&& Array.isArray(candidateWithFailover?.route?.activeLocalRules)
|
||||||
@@ -512,6 +523,14 @@ export function assertStateSnapshot(snapshot: unknown): StateSnapshot {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
const validTraffic = (traffic: TrafficSettings) => {
|
||||||
|
try {
|
||||||
|
normalizeTrafficSettings(traffic, { strict: true });
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
const validFailover = (value: FailoverSnapshot) => {
|
const validFailover = (value: FailoverSnapshot) => {
|
||||||
const channel = (item: FailoverSnapshot['primary']) => (
|
const channel = (item: FailoverSnapshot['primary']) => (
|
||||||
item && typeof item.target?.profileId === 'string' && typeof item.target?.serverId === 'string'
|
item && typeof item.target?.profileId === 'string' && typeof item.target?.serverId === 'string'
|
||||||
@@ -573,6 +592,7 @@ export function assertStateSnapshot(snapshot: unknown): StateSnapshot {
|
|||||||
!nullableDate(candidate.connection.startedAt) ||
|
!nullableDate(candidate.connection.startedAt) ||
|
||||||
!nullableString(candidate.connection.lastError) ||
|
!nullableString(candidate.connection.lastError) ||
|
||||||
!validDiagnostics(candidate.diagnostics) ||
|
!validDiagnostics(candidate.diagnostics) ||
|
||||||
|
!validTraffic(candidate.traffic) ||
|
||||||
!candidate.failover ||
|
!candidate.failover ||
|
||||||
typeof candidate.failover.observationEpoch !== 'string' ||
|
typeof candidate.failover.observationEpoch !== 'string' ||
|
||||||
!Number.isSafeInteger(candidate.failover.observationSequence) ||
|
!Number.isSafeInteger(candidate.failover.observationSequence) ||
|
||||||
|
|||||||
@@ -0,0 +1,213 @@
|
|||||||
|
export type LiveTrafficSourceState =
|
||||||
|
| 'connecting'
|
||||||
|
| 'live'
|
||||||
|
| 'degraded'
|
||||||
|
| 'stale'
|
||||||
|
| 'stopped'
|
||||||
|
| 'incompatible'
|
||||||
|
| 'disabled';
|
||||||
|
|
||||||
|
export interface LiveTrafficConnection {
|
||||||
|
id: string;
|
||||||
|
startedAt: string;
|
||||||
|
closedAt: string | null;
|
||||||
|
inbound: { tag: string; type: string };
|
||||||
|
network: 'tcp' | 'udp' | 'unknown';
|
||||||
|
protocol: string | null;
|
||||||
|
source: { ip: string; port: number | null };
|
||||||
|
destination: {
|
||||||
|
domain: string | null;
|
||||||
|
ip: string | null;
|
||||||
|
port: number | null;
|
||||||
|
provenance: 'sing-box' | 'unknown';
|
||||||
|
};
|
||||||
|
origin: {
|
||||||
|
kind: 'this-mac' | 'device' | 'unknown';
|
||||||
|
id: string | null;
|
||||||
|
label: string;
|
||||||
|
provenance: 'client-runtime' | 'source-ip' | 'unknown';
|
||||||
|
};
|
||||||
|
route: {
|
||||||
|
kind: 'vpn' | 'direct' | 'other';
|
||||||
|
scope: 'local-sing-box';
|
||||||
|
outbound: string | null;
|
||||||
|
outboundType: string | null;
|
||||||
|
chain: string[];
|
||||||
|
rule: string | null;
|
||||||
|
};
|
||||||
|
traffic: {
|
||||||
|
uploadBytes: string;
|
||||||
|
downloadBytes: string;
|
||||||
|
uploadBytesPerSecond: string;
|
||||||
|
downloadBytesPerSecond: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LiveTrafficSnapshot {
|
||||||
|
apiVersion: 1;
|
||||||
|
epoch: string | null;
|
||||||
|
sequence: number;
|
||||||
|
observedAt: string | null;
|
||||||
|
capabilities: {
|
||||||
|
lifecycle: true;
|
||||||
|
deviceAttribution: boolean;
|
||||||
|
applicationAttribution: false;
|
||||||
|
};
|
||||||
|
source: {
|
||||||
|
transport: 'native';
|
||||||
|
state: LiveTrafficSourceState;
|
||||||
|
completeness: 'lifecycle';
|
||||||
|
singBoxVersion: string | null;
|
||||||
|
singBoxApiVersion: number | null;
|
||||||
|
error: string | null;
|
||||||
|
unattributedUploadBytes: string;
|
||||||
|
unattributedDownloadBytes: string;
|
||||||
|
};
|
||||||
|
summary: {
|
||||||
|
active: number;
|
||||||
|
recent: number;
|
||||||
|
visible: number;
|
||||||
|
recognized: number;
|
||||||
|
unresolved: number;
|
||||||
|
unresolvedOrigin: number;
|
||||||
|
truncated: boolean;
|
||||||
|
};
|
||||||
|
connections: LiveTrafficConnection[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const sourceStates = new Set<LiveTrafficSourceState>([
|
||||||
|
'connecting', 'live', 'degraded', 'stale', 'stopped', 'incompatible', 'disabled',
|
||||||
|
]);
|
||||||
|
const decimal = /^\d+$/;
|
||||||
|
|
||||||
|
function isoTimestamp(value: unknown) {
|
||||||
|
return typeof value === 'string'
|
||||||
|
&& !Number.isNaN(Date.parse(value))
|
||||||
|
&& new Date(value).toISOString() === value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function decimalString(value: unknown) {
|
||||||
|
return typeof value === 'string' && decimal.test(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function record(value: unknown): Record<string, unknown> {
|
||||||
|
if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error('Expected object');
|
||||||
|
return value as Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function nullableString(value: unknown) {
|
||||||
|
if (value !== null && typeof value !== 'string') throw new Error('Expected nullable string');
|
||||||
|
}
|
||||||
|
|
||||||
|
function nonNegativeInteger(value: unknown) {
|
||||||
|
if (!Number.isSafeInteger(value) || Number(value) < 0) throw new Error('Expected non-negative integer');
|
||||||
|
}
|
||||||
|
|
||||||
|
function nullablePort(value: unknown) {
|
||||||
|
if (value !== null && (!Number.isInteger(value) || Number(value) < 0 || Number(value) > 65_535)) {
|
||||||
|
throw new Error('Expected nullable port');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function assertLiveTrafficSnapshot(value: unknown): LiveTrafficSnapshot {
|
||||||
|
const snapshot = record(value);
|
||||||
|
if (snapshot.apiVersion !== 1) throw new Error('Expected live traffic apiVersion 1');
|
||||||
|
nullableString(snapshot.epoch);
|
||||||
|
nullableString(snapshot.observedAt);
|
||||||
|
nonNegativeInteger(snapshot.sequence);
|
||||||
|
|
||||||
|
const capabilities = record(snapshot.capabilities);
|
||||||
|
if (capabilities.lifecycle !== true || typeof capabilities.deviceAttribution !== 'boolean'
|
||||||
|
|| capabilities.applicationAttribution !== false) throw new Error('Invalid traffic capabilities');
|
||||||
|
|
||||||
|
const source = record(snapshot.source);
|
||||||
|
if (source.transport !== 'native' || source.completeness !== 'lifecycle'
|
||||||
|
|| !sourceStates.has(source.state as LiveTrafficSourceState)) throw new Error('Invalid traffic source');
|
||||||
|
nullableString(source.singBoxVersion);
|
||||||
|
nullableString(source.error);
|
||||||
|
if (source.singBoxApiVersion !== null) nonNegativeInteger(source.singBoxApiVersion);
|
||||||
|
if (!decimalString(source.unattributedUploadBytes)
|
||||||
|
|| !decimalString(source.unattributedDownloadBytes)) throw new Error('Invalid traffic gap');
|
||||||
|
|
||||||
|
const summary = record(snapshot.summary);
|
||||||
|
for (const field of ['active', 'recent', 'visible', 'recognized', 'unresolved', 'unresolvedOrigin']) {
|
||||||
|
nonNegativeInteger(summary[field]);
|
||||||
|
}
|
||||||
|
if (typeof summary.truncated !== 'boolean') throw new Error('Invalid traffic summary');
|
||||||
|
if (!Array.isArray(snapshot.connections) || snapshot.connections.length > 256) {
|
||||||
|
throw new Error('Invalid traffic connection list');
|
||||||
|
}
|
||||||
|
const activeTotal = Number(summary.active);
|
||||||
|
const recentTotal = Number(summary.recent);
|
||||||
|
const visibleTotal = Number(summary.visible);
|
||||||
|
const expectedVisible = Math.min(256, activeTotal + recentTotal);
|
||||||
|
if (visibleTotal !== snapshot.connections.length
|
||||||
|
|| visibleTotal !== expectedVisible
|
||||||
|
|| Number(summary.recognized) + Number(summary.unresolved) !== Number(summary.active)
|
||||||
|
|| Number(summary.unresolvedOrigin) > Number(summary.active)
|
||||||
|
|| summary.truncated !== (activeTotal + recentTotal > visibleTotal)) {
|
||||||
|
throw new Error('Inconsistent traffic summary');
|
||||||
|
}
|
||||||
|
|
||||||
|
const ids = new Set<string>();
|
||||||
|
let visibleActive = 0;
|
||||||
|
let visibleRecent = 0;
|
||||||
|
let recentSeen = false;
|
||||||
|
for (const rawConnection of snapshot.connections) {
|
||||||
|
const connection = record(rawConnection);
|
||||||
|
if (typeof connection.id !== 'string' || !connection.id
|
||||||
|
|| ids.has(connection.id)
|
||||||
|
|| !isoTimestamp(connection.startedAt)
|
||||||
|
|| (connection.closedAt !== null && !isoTimestamp(connection.closedAt))) {
|
||||||
|
throw new Error('Invalid traffic connection identity');
|
||||||
|
}
|
||||||
|
ids.add(connection.id);
|
||||||
|
if (connection.closedAt === null) {
|
||||||
|
if (recentSeen) throw new Error('Inconsistent traffic summary');
|
||||||
|
visibleActive += 1;
|
||||||
|
} else {
|
||||||
|
recentSeen = true;
|
||||||
|
visibleRecent += 1;
|
||||||
|
}
|
||||||
|
const inbound = record(connection.inbound);
|
||||||
|
const sourceAddress = record(connection.source);
|
||||||
|
const destination = record(connection.destination);
|
||||||
|
const origin = record(connection.origin);
|
||||||
|
const route = record(connection.route);
|
||||||
|
const traffic = record(connection.traffic);
|
||||||
|
if (typeof inbound.tag !== 'string' || typeof inbound.type !== 'string'
|
||||||
|
|| !['tcp', 'udp', 'unknown'].includes(String(connection.network))
|
||||||
|
|| (connection.protocol !== null && typeof connection.protocol !== 'string')
|
||||||
|
|| typeof sourceAddress.ip !== 'string'
|
||||||
|
|| (destination.domain !== null && typeof destination.domain !== 'string')
|
||||||
|
|| (destination.ip !== null && typeof destination.ip !== 'string')
|
||||||
|
|| !['sing-box', 'unknown'].includes(String(destination.provenance))
|
||||||
|
|| !['this-mac', 'device', 'unknown'].includes(String(origin.kind))
|
||||||
|
|| (origin.id !== null && typeof origin.id !== 'string')
|
||||||
|
|| typeof origin.label !== 'string'
|
||||||
|
|| !['client-runtime', 'source-ip', 'unknown'].includes(String(origin.provenance))
|
||||||
|
|| !['vpn', 'direct', 'other'].includes(String(route.kind))
|
||||||
|
|| route.scope !== 'local-sing-box'
|
||||||
|
|| (route.outbound !== null && typeof route.outbound !== 'string')
|
||||||
|
|| (route.outboundType !== null && typeof route.outboundType !== 'string')
|
||||||
|
|| (route.rule !== null && typeof route.rule !== 'string')
|
||||||
|
|| !Array.isArray(route.chain) || !route.chain.every((item) => typeof item === 'string')) {
|
||||||
|
throw new Error('Invalid traffic connection');
|
||||||
|
}
|
||||||
|
nullablePort(sourceAddress.port);
|
||||||
|
nullablePort(destination.port);
|
||||||
|
for (const field of ['uploadBytes', 'downloadBytes', 'uploadBytesPerSecond', 'downloadBytesPerSecond']) {
|
||||||
|
if (!decimalString(traffic[field])) throw new Error('Invalid traffic byte value');
|
||||||
|
}
|
||||||
|
if (connection.closedAt !== null
|
||||||
|
&& (traffic.uploadBytesPerSecond !== '0' || traffic.downloadBytesPerSecond !== '0')) {
|
||||||
|
throw new Error('Invalid closed traffic rate');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (visibleActive !== Math.min(activeTotal, visibleTotal)
|
||||||
|
|| visibleRecent !== visibleTotal - visibleActive
|
||||||
|
|| visibleRecent > recentTotal) {
|
||||||
|
throw new Error('Inconsistent traffic summary');
|
||||||
|
}
|
||||||
|
return value as LiveTrafficSnapshot;
|
||||||
|
}
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
import type { LiveTrafficSourceState } from './liveTraffic.js';
|
||||||
|
|
||||||
|
export const TRAFFIC_HISTORY_RANGES = { '24h': 1, '7d': 7, '30d': 30, '90d': 90 } as const;
|
||||||
|
export type TrafficHistoryRange = keyof typeof TRAFFIC_HISTORY_RANGES;
|
||||||
|
export type TrafficHistoryLevel = 'service' | 'domain' | 'hostname' | 'ip';
|
||||||
|
export interface TrafficHistoryQuery {
|
||||||
|
range: TrafficHistoryRange;
|
||||||
|
level: TrafficHistoryLevel;
|
||||||
|
originId: string;
|
||||||
|
search: string;
|
||||||
|
route: 'all' | 'vpn' | 'direct' | 'other';
|
||||||
|
service: string;
|
||||||
|
domain: string;
|
||||||
|
hostname: string;
|
||||||
|
offset: number;
|
||||||
|
until: number | null;
|
||||||
|
}
|
||||||
|
export interface TrafficHistoryRow {
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
uploadBytes: string;
|
||||||
|
downloadBytes: string;
|
||||||
|
route: 'vpn' | 'direct' | 'other' | 'mixed';
|
||||||
|
}
|
||||||
|
export interface TrafficHistorySnapshot {
|
||||||
|
apiVersion: 1;
|
||||||
|
generatedAt: string;
|
||||||
|
query: TrafficHistoryQuery;
|
||||||
|
period: { from: string; to: string; availableFrom: string | null; minuteFrom: string; retentionDays: 90 };
|
||||||
|
storage: { status: 'ready' | 'error'; errorCode: string | null };
|
||||||
|
source: LiveTrafficSourceState;
|
||||||
|
coverage: { partial: boolean; gapCount: number; lastObservedAt: string | null };
|
||||||
|
totals: { uploadBytes: string; downloadBytes: string };
|
||||||
|
rows: TrafficHistoryRow[];
|
||||||
|
nextOffset: number | null;
|
||||||
|
origins: Array<{ id: string; label: string }>;
|
||||||
|
originsTruncated: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseTrafficHistoryQuery(params: URLSearchParams): TrafficHistoryQuery {
|
||||||
|
const range = params.get('range') || '24h';
|
||||||
|
const level = params.get('level') || 'service';
|
||||||
|
const route = params.get('route') || 'all';
|
||||||
|
if (!Object.hasOwn(TRAFFIC_HISTORY_RANGES, range)
|
||||||
|
|| !['service', 'domain', 'hostname', 'ip'].includes(level)
|
||||||
|
|| !['all', 'vpn', 'direct', 'other'].includes(route)) throw new TypeError('Invalid history query');
|
||||||
|
const text = (key: string, max = 253) => {
|
||||||
|
const value = params.get(key) || '';
|
||||||
|
if (value.length > max || /[\x00-\x1f]/.test(value)) throw new TypeError('Invalid history filter');
|
||||||
|
return value;
|
||||||
|
};
|
||||||
|
const offset = Number(params.get('offset') || 0);
|
||||||
|
const until = params.has('until') ? Number(params.get('until')) : null;
|
||||||
|
if (!Number.isSafeInteger(offset) || offset < 0 || offset > 1_000_000
|
||||||
|
|| (until !== null && (!Number.isSafeInteger(until) || until <= 0 || until > 8_640_000_000_000_000))) throw new TypeError('Invalid history page');
|
||||||
|
return {
|
||||||
|
range: range as TrafficHistoryRange, level: level as TrafficHistoryLevel,
|
||||||
|
route: route as TrafficHistoryQuery['route'], originId: text('originId', 128), search: text('search', 200).trim(),
|
||||||
|
service: text('service'), domain: text('domain'), hostname: text('hostname'), offset, until,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function historyQueryParams(query: TrafficHistoryQuery) {
|
||||||
|
return new URLSearchParams(Object.entries(query).filter(([, value]) => value !== null)
|
||||||
|
.map(([key, value]): [string, string] => [key, String(value)]));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function emptyTrafficHistory(query: TrafficHistoryQuery, source: LiveTrafficSourceState = 'disabled', now = Date.now()): TrafficHistorySnapshot {
|
||||||
|
const to = Math.min(query.until ?? now, now);
|
||||||
|
return {
|
||||||
|
apiVersion: 1, generatedAt: new Date(now).toISOString(), query: { ...query, until: to },
|
||||||
|
period: { from: new Date(to - TRAFFIC_HISTORY_RANGES[query.range] * 86_400_000).toISOString(),
|
||||||
|
to: new Date(to).toISOString(), availableFrom: null,
|
||||||
|
minuteFrom: new Date(now - 7 * 86_400_000).toISOString(), retentionDays: 90 },
|
||||||
|
storage: { status: 'ready', errorCode: null }, source,
|
||||||
|
coverage: { partial: false, gapCount: 0, lastObservedAt: null },
|
||||||
|
totals: { uploadBytes: '0', downloadBytes: '0' }, rows: [], nextOffset: null, origins: [], originsTruncated: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function assertTrafficHistorySnapshot(value: unknown): TrafficHistorySnapshot {
|
||||||
|
if (!value || typeof value !== 'object') throw new TypeError('Invalid history snapshot');
|
||||||
|
const v = value as TrafficHistorySnapshot;
|
||||||
|
const iso = (s: unknown) => typeof s === 'string' && Number.isFinite(Date.parse(s));
|
||||||
|
const bytes = (s: unknown) => typeof s === 'string' && /^\d+$/.test(s);
|
||||||
|
if (v.apiVersion !== 1 || !iso(v.generatedAt) || !v.query || !v.period || !v.storage || !v.coverage || !v.totals
|
||||||
|
|| v.period.retentionDays !== 90 || !iso(v.period.from) || !iso(v.period.to) || !iso(v.period.minuteFrom)
|
||||||
|
|| !(v.period.availableFrom === null || iso(v.period.availableFrom))
|
||||||
|
|| !['ready', 'error'].includes(v.storage.status)
|
||||||
|
|| !(v.storage.errorCode === null || typeof v.storage.errorCode === 'string')
|
||||||
|
|| !['live', 'connecting', 'disabled', 'stopped', 'stale', 'degraded', 'incompatible'].includes(v.source)
|
||||||
|
|| typeof v.coverage.partial !== 'boolean' || !Number.isSafeInteger(v.coverage.gapCount) || v.coverage.gapCount < 0
|
||||||
|
|| !(v.coverage.lastObservedAt === null || iso(v.coverage.lastObservedAt))
|
||||||
|
|| !bytes(v.totals.uploadBytes) || !bytes(v.totals.downloadBytes)
|
||||||
|
|| !Array.isArray(v.rows) || v.rows.length > 100
|
||||||
|
|| !Array.isArray(v.origins) || v.origins.length > 256 || typeof v.originsTruncated !== 'boolean'
|
||||||
|
|| !(v.nextOffset === null || (Number.isSafeInteger(v.nextOffset) && v.nextOffset >= 0))) {
|
||||||
|
throw new TypeError('Invalid history snapshot');
|
||||||
|
}
|
||||||
|
parseTrafficHistoryQuery(historyQueryParams(v.query));
|
||||||
|
for (const row of v.rows) {
|
||||||
|
if (!row || typeof row.key !== 'string' || typeof row.label !== 'string'
|
||||||
|
|| row.key.length > 253 || row.label.length > 253
|
||||||
|
|| !bytes(row.uploadBytes) || !bytes(row.downloadBytes)
|
||||||
|
|| !['vpn', 'direct', 'other', 'mixed'].includes(row.route)) throw new TypeError('Invalid history row');
|
||||||
|
}
|
||||||
|
for (const origin of v.origins) {
|
||||||
|
if (!origin || typeof origin.id !== 'string' || typeof origin.label !== 'string') throw new TypeError('Invalid history origin');
|
||||||
|
}
|
||||||
|
return v;
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
export const TRAFFIC_RETENTION_OPTIONS = [5, 10, 30] as const;
|
||||||
|
|
||||||
|
export type TrafficGrouping = 'site' | 'device';
|
||||||
|
export type TrafficSort = 'popular' | 'recent';
|
||||||
|
export type TrafficRetentionSeconds = typeof TRAFFIC_RETENTION_OPTIONS[number];
|
||||||
|
|
||||||
|
export interface TrafficSettings {
|
||||||
|
grouping: TrafficGrouping;
|
||||||
|
sort: TrafficSort;
|
||||||
|
retentionSeconds: TrafficRetentionSeconds;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DEFAULT_TRAFFIC_SETTINGS: TrafficSettings = {
|
||||||
|
grouping: 'site',
|
||||||
|
sort: 'popular',
|
||||||
|
retentionSeconds: 10,
|
||||||
|
};
|
||||||
|
|
||||||
|
export function normalizeTrafficSettings(
|
||||||
|
value: unknown,
|
||||||
|
{ strict = false }: { strict?: boolean } = {},
|
||||||
|
): TrafficSettings {
|
||||||
|
const candidate = value && typeof value === 'object' && !Array.isArray(value)
|
||||||
|
? value as Record<string, unknown>
|
||||||
|
: {};
|
||||||
|
const grouping = candidate.grouping;
|
||||||
|
const sort = candidate.sort;
|
||||||
|
const retentionSeconds = candidate.retentionSeconds;
|
||||||
|
if (strict && (
|
||||||
|
!['site', 'device'].includes(String(grouping))
|
||||||
|
|| !['popular', 'recent'].includes(String(sort))
|
||||||
|
|| !TRAFFIC_RETENTION_OPTIONS.includes(Number(retentionSeconds) as TrafficRetentionSeconds)
|
||||||
|
)) throw new TypeError('Invalid traffic settings');
|
||||||
|
return {
|
||||||
|
grouping: grouping === 'device' ? 'device' : 'site',
|
||||||
|
sort: sort === 'recent' ? 'recent' : 'popular',
|
||||||
|
retentionSeconds: TRAFFIC_RETENTION_OPTIONS.includes(Number(retentionSeconds) as TrafficRetentionSeconds)
|
||||||
|
? Number(retentionSeconds) as TrafficRetentionSeconds
|
||||||
|
: DEFAULT_TRAFFIC_SETTINGS.retentionSeconds,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
export const HARBOR_VERSIONS = Object.freeze({
|
export const HARBOR_VERSIONS = Object.freeze({
|
||||||
macClient: '0.33.1',
|
macClient: '0.37.2',
|
||||||
gatewayClient: '0.34.1',
|
gatewayClient: '0.39.2',
|
||||||
gatewayBackend: '0.34.0',
|
gatewayBackend: '0.39.1',
|
||||||
});
|
});
|
||||||
|
|
||||||
export interface ParsedVersion {
|
export interface ParsedVersion {
|
||||||
|
|||||||
+18
-3
@@ -28,7 +28,11 @@ const componentActions = {
|
|||||||
setDevicePolicy: api.devices.setPolicy,
|
setDevicePolicy: api.devices.setPolicy,
|
||||||
pingServers: api.servers.ping,
|
pingServers: api.servers.ping,
|
||||||
runConnectivityDiagnostics: api.diagnostics.connectivity,
|
runConnectivityDiagnostics: api.diagnostics.connectivity,
|
||||||
|
loadDnsDiagnosticsCatalog: api.diagnostics.dnsCatalog,
|
||||||
|
runDnsDiagnostics: api.diagnostics.dns,
|
||||||
loadActivityJournal: api.activityJournal.page,
|
loadActivityJournal: api.activityJournal.page,
|
||||||
|
loadLiveTraffic: api.traffic.live,
|
||||||
|
loadTrafficHistory: api.traffic.history,
|
||||||
};
|
};
|
||||||
|
|
||||||
interface UiError {
|
interface UiError {
|
||||||
@@ -55,6 +59,7 @@ const operationErrorContext: Record<string, string> = {
|
|||||||
'subscription-refresh': 'subscription',
|
'subscription-refresh': 'subscription',
|
||||||
'subscription-forget': 'subscription',
|
'subscription-forget': 'subscription',
|
||||||
'route-rules': 'routing',
|
'route-rules': 'routing',
|
||||||
|
'traffic-settings': 'traffic',
|
||||||
'failover-save': 'failover',
|
'failover-save': 'failover',
|
||||||
'failover-pause': 'failover',
|
'failover-pause': 'failover',
|
||||||
'failover-resume': 'failover',
|
'failover-resume': 'failover',
|
||||||
@@ -108,9 +113,14 @@ export function App() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadState();
|
let cancelled = false;
|
||||||
const timer = setInterval(loadState, 5000);
|
let timer: ReturnType<typeof setTimeout>;
|
||||||
return () => clearInterval(timer);
|
const poll = async () => {
|
||||||
|
await loadState();
|
||||||
|
if (!cancelled) timer = setTimeout(poll, 5000);
|
||||||
|
};
|
||||||
|
void poll();
|
||||||
|
return () => { cancelled = true; clearTimeout(timer); };
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -312,6 +322,11 @@ export function App() {
|
|||||||
() => api.diagnostics.updateSettings(settings, revisionRef.current),
|
() => api.diagnostics.updateSettings(settings, revisionRef.current),
|
||||||
'diagnostics',
|
'diagnostics',
|
||||||
)}
|
)}
|
||||||
|
onUpdateTrafficSettings={(settings: unknown) => run(
|
||||||
|
'trafficSettings',
|
||||||
|
() => api.traffic.updateSettings(settings, revisionRef.current),
|
||||||
|
'traffic',
|
||||||
|
)}
|
||||||
onSaveFailover={(policy: unknown) => run(
|
onSaveFailover={(policy: unknown) => run(
|
||||||
'failover',
|
'failover',
|
||||||
() => api.failover.save(policy, revisionRef.current),
|
() => api.failover.save(policy, revisionRef.current),
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { ERROR_DEFINITIONS, errorDefinition } from '../../shared/errors.js';
|
import { ERROR_DEFINITIONS, errorDefinition } from '../../shared/errors.js';
|
||||||
import { assertStateSnapshot, type StateSnapshot } from '../../shared/contracts/state.js';
|
import { assertStateSnapshot, type StateSnapshot } from '../../shared/contracts/state.js';
|
||||||
import { ROUTE_RULES_CONTRACT_VERSION } from '../../shared/routingRules.js';
|
import { ROUTE_RULES_CONTRACT_VERSION } from '../../shared/routingRules.js';
|
||||||
|
import { historyQueryParams, type TrafficHistoryQuery } from '../../shared/trafficHistory.js';
|
||||||
|
|
||||||
type RequestOptions = Omit<RequestInit, 'headers'> & {
|
type RequestOptions = Omit<RequestInit, 'headers'> & {
|
||||||
headers?: Record<string, string>;
|
headers?: Record<string, string>;
|
||||||
@@ -49,24 +50,25 @@ export async function request(
|
|||||||
options: RequestOptions = {},
|
options: RequestOptions = {},
|
||||||
fetchImpl: FetchImplementation = fetch,
|
fetchImpl: FetchImplementation = fetch,
|
||||||
): Promise<unknown> {
|
): Promise<unknown> {
|
||||||
let response: JsonResponse;
|
const deadline = new AbortController();
|
||||||
|
const timer = setTimeout(() => deadline.abort(new HarborApiError({ code: 'CONTROL_UNREACHABLE' })),
|
||||||
|
(options.method || 'GET') === 'GET' ? 15_000 : 60_000);
|
||||||
|
const signal = options.signal ? AbortSignal.any([options.signal, deadline.signal]) : deadline.signal;
|
||||||
try {
|
try {
|
||||||
response = await fetchImpl(url, {
|
const response = await fetchImpl(url, {
|
||||||
...options,
|
...options,
|
||||||
|
signal,
|
||||||
headers: {
|
headers: {
|
||||||
'content-type': 'application/json',
|
'content-type': 'application/json',
|
||||||
...(options.headers || {}),
|
...(options.headers || {}),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
} catch (error) {
|
|
||||||
if (record(error).name === 'AbortError') throw error;
|
|
||||||
throw new HarborApiError({ code: 'CONTROL_UNREACHABLE' });
|
|
||||||
}
|
|
||||||
|
|
||||||
let data: unknown = {};
|
let data: unknown = {};
|
||||||
try {
|
try {
|
||||||
data = await response.json();
|
data = await response.json();
|
||||||
} catch {
|
} catch {
|
||||||
|
if (signal.aborted) throw signal.reason;
|
||||||
if (response.ok) throw new HarborApiError({ code: 'UNKNOWN' }, response.status);
|
if (response.ok) throw new HarborApiError({ code: 'UNKNOWN' }, response.status);
|
||||||
}
|
}
|
||||||
const payload = record(data);
|
const payload = record(data);
|
||||||
@@ -77,6 +79,13 @@ export async function request(
|
|||||||
throw new HarborApiError(errorPayload, response.status);
|
throw new HarborApiError(errorPayload, response.status);
|
||||||
}
|
}
|
||||||
return data;
|
return data;
|
||||||
|
} catch (error) {
|
||||||
|
if (signal.aborted) throw signal.reason;
|
||||||
|
if (error instanceof HarborApiError || record(error).name === 'AbortError') throw error;
|
||||||
|
throw new HarborApiError({ code: 'CONTROL_UNREACHABLE' });
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timer);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const api = {
|
export const api = {
|
||||||
@@ -202,6 +211,14 @@ export const api = {
|
|||||||
body: JSON.stringify({ target }),
|
body: JSON.stringify({ target }),
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
dnsCatalog: () => request('/api/diagnostics/dns'),
|
||||||
|
dns: (domainId: string, resolverId: string | null = null) => request(
|
||||||
|
'/api/diagnostics/dns',
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ domainId, resolverId }),
|
||||||
|
},
|
||||||
|
),
|
||||||
updateSettings: (settings: unknown, expectedRevision: number) => request(
|
updateSettings: (settings: unknown, expectedRevision: number) => request(
|
||||||
'/api/diagnostics/settings',
|
'/api/diagnostics/settings',
|
||||||
{
|
{
|
||||||
@@ -230,6 +247,17 @@ export const api = {
|
|||||||
activityJournal: {
|
activityJournal: {
|
||||||
page: (cursor: string | null = null) => request(`/api/activity-journal?limit=50${cursor ? `&cursor=${encodeURIComponent(cursor)}` : ''}`),
|
page: (cursor: string | null = null) => request(`/api/activity-journal?limit=50${cursor ? `&cursor=${encodeURIComponent(cursor)}` : ''}`),
|
||||||
},
|
},
|
||||||
|
traffic: {
|
||||||
|
history: (query: TrafficHistoryQuery, signal?: AbortSignal) => request(`/api/traffic/history?${historyQueryParams(query)}`, { signal }),
|
||||||
|
live: () => request('/api/traffic/live'),
|
||||||
|
updateSettings: (settings: unknown, expectedRevision: number) => request(
|
||||||
|
'/api/traffic/settings',
|
||||||
|
{
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify({ settings, expectedRevision }),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
},
|
||||||
singbox: {
|
singbox: {
|
||||||
stop: () => request('/api/singbox/stop', { method: 'POST' }),
|
stop: () => request('/api/singbox/stop', { method: 'POST' }),
|
||||||
restart: () => request('/api/singbox/restart', { method: 'POST' }),
|
restart: () => request('/api/singbox/restart', { method: 'POST' }),
|
||||||
@@ -275,6 +303,7 @@ export function parseHarborState(value: unknown): HarborClientState {
|
|||||||
selection: snapshot.selection,
|
selection: snapshot.selection,
|
||||||
connection: snapshot.connection,
|
connection: snapshot.connection,
|
||||||
diagnostics: snapshot.diagnostics,
|
diagnostics: snapshot.diagnostics,
|
||||||
|
traffic: snapshot.traffic,
|
||||||
failover: snapshot.failover,
|
failover: snapshot.failover,
|
||||||
route: snapshot.route,
|
route: snapshot.route,
|
||||||
operation: snapshot.operation,
|
operation: snapshot.operation,
|
||||||
|
|||||||
@@ -47,6 +47,11 @@ import {
|
|||||||
InstructionsToggle,
|
InstructionsToggle,
|
||||||
useInstructionsFeature,
|
useInstructionsFeature,
|
||||||
} from '../features/instructions/index.js';
|
} from '../features/instructions/index.js';
|
||||||
|
import {
|
||||||
|
TrafficPanel,
|
||||||
|
TrafficToggle,
|
||||||
|
useTrafficFeature,
|
||||||
|
} from '../features/traffic/index.js';
|
||||||
import { FailoverPanel, FailoverToggle, useFailoverFeature } from '../features/failover/index.js';
|
import { FailoverPanel, FailoverToggle, useFailoverFeature } from '../features/failover/index.js';
|
||||||
import {
|
import {
|
||||||
ActivityJournalPanel,
|
ActivityJournalPanel,
|
||||||
@@ -72,7 +77,7 @@ const VERSION_PARTS = [
|
|||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
const DRAWER_SWITCH_MS = 620;
|
const DRAWER_SWITCH_MS = 620;
|
||||||
const DRAWER_ORDER = ['subscription', 'failover', 'instructions', 'devices', 'diagnostics', 'routing', 'journal'] as const;
|
const DRAWER_ORDER = ['subscription', 'failover', 'instructions', 'devices', 'traffic', 'diagnostics', 'routing', 'journal'] as const;
|
||||||
type DrawerKey = typeof DRAWER_ORDER[number];
|
type DrawerKey = typeof DRAWER_ORDER[number];
|
||||||
|
|
||||||
const failoverReasonLabel = (reason: string | null) => ({
|
const failoverReasonLabel = (reason: string | null) => ({
|
||||||
@@ -117,6 +122,7 @@ interface VersionBadgeProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface ComponentActions {
|
interface ComponentActions {
|
||||||
|
loadTrafficHistory: import('../features/traffic/index.js').LoadTrafficHistory;
|
||||||
listDevices: () => Promise<unknown>;
|
listDevices: () => Promise<unknown>;
|
||||||
refreshDevices: () => Promise<unknown>;
|
refreshDevices: () => Promise<unknown>;
|
||||||
resetDeviceTraffic: (expectedRevision: number) => Promise<unknown>;
|
resetDeviceTraffic: (expectedRevision: number) => Promise<unknown>;
|
||||||
@@ -127,7 +133,10 @@ interface ComponentActions {
|
|||||||
setDevicePolicy: (id: string, mode: 'vpn' | 'direct', expectedRevision: number) => Promise<unknown>;
|
setDevicePolicy: (id: string, mode: 'vpn' | 'direct', expectedRevision: number) => Promise<unknown>;
|
||||||
pingServers: (profileId: string, ids: string[]) => Promise<unknown>;
|
pingServers: (profileId: string, ids: string[]) => Promise<unknown>;
|
||||||
runConnectivityDiagnostics: (target?: unknown) => Promise<unknown>;
|
runConnectivityDiagnostics: (target?: unknown) => Promise<unknown>;
|
||||||
|
loadDnsDiagnosticsCatalog: () => Promise<unknown>;
|
||||||
|
runDnsDiagnostics: (domainId: string, resolverId?: string | null) => Promise<unknown>;
|
||||||
loadActivityJournal: (cursor?: string | null) => Promise<unknown>;
|
loadActivityJournal: (cursor?: string | null) => Promise<unknown>;
|
||||||
|
loadLiveTraffic: () => Promise<unknown>;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ClientViewState extends StateSnapshot {
|
interface ClientViewState extends StateSnapshot {
|
||||||
@@ -154,6 +163,7 @@ interface ClientOverviewPageProps {
|
|||||||
onSetGatewayAuto: (enabled: boolean) => Promise<unknown>;
|
onSetGatewayAuto: (enabled: boolean) => Promise<unknown>;
|
||||||
onSaveRouteRules: (rules: RouteRule[], expectedRevision: number) => Promise<unknown>;
|
onSaveRouteRules: (rules: RouteRule[], expectedRevision: number) => Promise<unknown>;
|
||||||
onUpdateDiagnosticsSettings: (settings: unknown) => Promise<unknown>;
|
onUpdateDiagnosticsSettings: (settings: unknown) => Promise<unknown>;
|
||||||
|
onUpdateTrafficSettings: (settings: unknown) => Promise<unknown>;
|
||||||
onSaveFailover: (policy: FailoverPolicy) => Promise<unknown>;
|
onSaveFailover: (policy: FailoverPolicy) => Promise<unknown>;
|
||||||
onPauseFailover: (paused: boolean) => Promise<unknown>;
|
onPauseFailover: (paused: boolean) => Promise<unknown>;
|
||||||
onSwitchFailover: (role: 'primary' | 'reserve') => Promise<unknown>;
|
onSwitchFailover: (role: 'primary' | 'reserve') => Promise<unknown>;
|
||||||
@@ -469,6 +479,7 @@ export function ClientOverviewPage({
|
|||||||
onSetGatewayAuto,
|
onSetGatewayAuto,
|
||||||
onSaveRouteRules,
|
onSaveRouteRules,
|
||||||
onUpdateDiagnosticsSettings,
|
onUpdateDiagnosticsSettings,
|
||||||
|
onUpdateTrafficSettings,
|
||||||
onSaveFailover,
|
onSaveFailover,
|
||||||
onPauseFailover,
|
onPauseFailover,
|
||||||
onSwitchFailover,
|
onSwitchFailover,
|
||||||
@@ -586,6 +597,14 @@ export function ClientOverviewPage({
|
|||||||
});
|
});
|
||||||
const failoverFeature = useFailoverFeature();
|
const failoverFeature = useFailoverFeature();
|
||||||
const activityJournalFeature = useActivityJournalFeature();
|
const activityJournalFeature = useActivityJournalFeature();
|
||||||
|
const trafficFeature = useTrafficFeature({
|
||||||
|
enabled: true,
|
||||||
|
isGateway,
|
||||||
|
loadLiveTraffic: actions.loadLiveTraffic,
|
||||||
|
loadHistory: actions.loadTrafficHistory,
|
||||||
|
settings: state.traffic,
|
||||||
|
updateSettings: onUpdateTrafficSettings,
|
||||||
|
});
|
||||||
const diagnosticsAvailable = hasSubscription;
|
const diagnosticsAvailable = hasSubscription;
|
||||||
const drawerControls = {
|
const drawerControls = {
|
||||||
subscription: {
|
subscription: {
|
||||||
@@ -612,6 +631,12 @@ export function ClientOverviewPage({
|
|||||||
show: devicesFeature.toggle,
|
show: devicesFeature.toggle,
|
||||||
close: devicesFeature.close,
|
close: devicesFeature.close,
|
||||||
},
|
},
|
||||||
|
traffic: {
|
||||||
|
isOpen: trafficFeature.isOpen,
|
||||||
|
panelRef: trafficFeature.panelRef,
|
||||||
|
show: trafficFeature.toggle,
|
||||||
|
close: trafficFeature.close,
|
||||||
|
},
|
||||||
diagnostics: {
|
diagnostics: {
|
||||||
isOpen: diagnosticsFeature.isOpen,
|
isOpen: diagnosticsFeature.isOpen,
|
||||||
panelRef: diagnosticsFeature.panelRef,
|
panelRef: diagnosticsFeature.panelRef,
|
||||||
@@ -654,8 +679,9 @@ export function ClientOverviewPage({
|
|||||||
diagnosticsFeature.close();
|
diagnosticsFeature.close();
|
||||||
failoverFeature.close();
|
failoverFeature.close();
|
||||||
activityJournalFeature.close();
|
activityJournalFeature.close();
|
||||||
|
trafficFeature.close();
|
||||||
}
|
}
|
||||||
}, [hasSubscription, isGateway]);
|
}, [hasSubscription]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!diagnosticsAvailable) diagnosticsFeature.close();
|
if (!diagnosticsAvailable) diagnosticsFeature.close();
|
||||||
@@ -851,6 +877,11 @@ export function ClientOverviewPage({
|
|||||||
open={activeRailDrawer === 'devices'}
|
open={activeRailDrawer === 'devices'}
|
||||||
onToggle={() => switchDrawer('devices')}
|
onToggle={() => switchDrawer('devices')}
|
||||||
/>}
|
/>}
|
||||||
|
<TrafficToggle
|
||||||
|
feature={trafficFeature}
|
||||||
|
open={activeRailDrawer === 'traffic'}
|
||||||
|
onToggle={() => switchDrawer('traffic')}
|
||||||
|
/>
|
||||||
<DiagnosticsToggle
|
<DiagnosticsToggle
|
||||||
feature={diagnosticsFeature}
|
feature={diagnosticsFeature}
|
||||||
open={activeRailDrawer === 'diagnostics'}
|
open={activeRailDrawer === 'diagnostics'}
|
||||||
@@ -936,9 +967,13 @@ export function ClientOverviewPage({
|
|||||||
|
|
||||||
{isGateway && hasSubscription && <DevicesPanel feature={devicesFeature} />}
|
{isGateway && hasSubscription && <DevicesPanel feature={devicesFeature} />}
|
||||||
|
|
||||||
|
{hasSubscription && <TrafficPanel feature={trafficFeature} />}
|
||||||
|
|
||||||
{diagnosticsAvailable && <ConnectivityDiagnosticsPanel
|
{diagnosticsAvailable && <ConnectivityDiagnosticsPanel
|
||||||
feature={diagnosticsFeature}
|
feature={diagnosticsFeature}
|
||||||
runConnectivityDiagnostics={actions.runConnectivityDiagnostics}
|
runConnectivityDiagnostics={actions.runConnectivityDiagnostics}
|
||||||
|
loadDnsDiagnosticsCatalog={actions.loadDnsDiagnosticsCatalog}
|
||||||
|
runDnsDiagnostics={actions.runDnsDiagnostics}
|
||||||
settings={state.diagnostics}
|
settings={state.diagnostics}
|
||||||
updateSettings={onUpdateDiagnosticsSettings}
|
updateSettings={onUpdateDiagnosticsSettings}
|
||||||
isGateway={isGateway}
|
isGateway={isGateway}
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import {
|
|||||||
} from './connectivityResult.js';
|
} from './connectivityResult.js';
|
||||||
import type { DiagnosticsFeature } from './DiagnosticsFeature.js';
|
import type { DiagnosticsFeature } from './DiagnosticsFeature.js';
|
||||||
import { saveCustomDiagnosticService } from './customServiceAction.js';
|
import { saveCustomDiagnosticService } from './customServiceAction.js';
|
||||||
|
import { DnsDiagnosticsSection } from './DnsDiagnosticsSection.js';
|
||||||
|
|
||||||
const CUSTOM_SERVICES_KEY = 'harbor-diagnostic-services';
|
const CUSTOM_SERVICES_KEY = 'harbor-diagnostic-services';
|
||||||
const HIDDEN_SERVICES_KEY = 'harbor-hidden-diagnostic-services';
|
const HIDDEN_SERVICES_KEY = 'harbor-hidden-diagnostic-services';
|
||||||
@@ -36,7 +37,7 @@ interface IpSourceDefinition {
|
|||||||
|
|
||||||
type StatusValue = [className: string, label: string];
|
type StatusValue = [className: string, label: string];
|
||||||
type RunConnectivityDiagnostics = (target: string) => Promise<unknown>;
|
type RunConnectivityDiagnostics = (target: string) => Promise<unknown>;
|
||||||
type UpdateSettings = (settings: Pick<DiagnosticSettings, 'customServices' | 'hiddenServiceIds'>) => Promise<unknown>;
|
type UpdateSettings = (settings: Partial<DiagnosticSettings>) => Promise<unknown>;
|
||||||
|
|
||||||
function record(value: unknown): value is Record<string, unknown> {
|
function record(value: unknown): value is Record<string, unknown> {
|
||||||
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
||||||
@@ -236,12 +237,16 @@ function mergeResult(previous: ConnectivityResult | null, incoming: Connectivity
|
|||||||
export function ConnectivityDiagnosticsPanel({
|
export function ConnectivityDiagnosticsPanel({
|
||||||
feature,
|
feature,
|
||||||
runConnectivityDiagnostics,
|
runConnectivityDiagnostics,
|
||||||
|
loadDnsDiagnosticsCatalog,
|
||||||
|
runDnsDiagnostics,
|
||||||
settings,
|
settings,
|
||||||
updateSettings,
|
updateSettings,
|
||||||
isGateway,
|
isGateway,
|
||||||
}: {
|
}: {
|
||||||
feature: DiagnosticsFeature;
|
feature: DiagnosticsFeature;
|
||||||
runConnectivityDiagnostics: RunConnectivityDiagnostics;
|
runConnectivityDiagnostics: RunConnectivityDiagnostics;
|
||||||
|
loadDnsDiagnosticsCatalog: () => Promise<unknown>;
|
||||||
|
runDnsDiagnostics: (domainId: string, resolverId?: string | null) => Promise<unknown>;
|
||||||
settings: DiagnosticSettings;
|
settings: DiagnosticSettings;
|
||||||
updateSettings: UpdateSettings;
|
updateSettings: UpdateSettings;
|
||||||
isGateway: boolean;
|
isGateway: boolean;
|
||||||
@@ -618,6 +623,14 @@ export function ConnectivityDiagnosticsPanel({
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<DnsDiagnosticsSection
|
||||||
|
open={open}
|
||||||
|
settings={settings}
|
||||||
|
updateSettings={updateSettings}
|
||||||
|
loadCatalog={loadDnsDiagnosticsCatalog}
|
||||||
|
runDiagnostics={runDnsDiagnostics}
|
||||||
|
/>
|
||||||
|
|
||||||
</Drawer>
|
</Drawer>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,444 @@
|
|||||||
|
import { useEffect, useMemo, useState, type FormEvent } from 'react';
|
||||||
|
|
||||||
|
import {
|
||||||
|
MAX_CUSTOM_DNS_DOMAINS,
|
||||||
|
MAX_CUSTOM_DNS_RESOLVERS,
|
||||||
|
type DiagnosticSettings,
|
||||||
|
type DnsDomainDefinition,
|
||||||
|
type DnsResolverDefinition,
|
||||||
|
} from '../../../shared/connectivityDiagnostics.js';
|
||||||
|
import { Tooltip } from '../../ui/Tooltip.js';
|
||||||
|
|
||||||
|
interface DnsPathResult {
|
||||||
|
status: string;
|
||||||
|
rcode: string | null;
|
||||||
|
ipv4: string[];
|
||||||
|
ipv6: string[];
|
||||||
|
latencyMs: number | null;
|
||||||
|
transport: string | null;
|
||||||
|
error: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DnsRowResult {
|
||||||
|
resolver: DnsResolverDefinition;
|
||||||
|
direct: DnsPathResult;
|
||||||
|
vpn: DnsPathResult;
|
||||||
|
comparison: 'same' | 'different' | 'direct-only' | 'vpn-only' | 'failed';
|
||||||
|
warning: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DnsRunResult {
|
||||||
|
checkedAt: string;
|
||||||
|
domain: DnsDomainDefinition;
|
||||||
|
results: DnsRowResult[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DnsCatalog {
|
||||||
|
domains: DnsDomainDefinition[];
|
||||||
|
resolvers: DnsResolverDefinition[];
|
||||||
|
}
|
||||||
|
|
||||||
|
type UpdateSettings = (settings: Partial<DiagnosticSettings>) => Promise<unknown>;
|
||||||
|
|
||||||
|
function record(value: unknown): Record<string, unknown> {
|
||||||
|
return value && typeof value === 'object' && !Array.isArray(value)
|
||||||
|
? value as Record<string, unknown>
|
||||||
|
: {};
|
||||||
|
}
|
||||||
|
|
||||||
|
function stringArray(value: unknown) {
|
||||||
|
if (!Array.isArray(value) || value.some((item) => typeof item !== 'string')) throw new TypeError('Invalid DNS addresses');
|
||||||
|
return value as string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolver(value: unknown): DnsResolverDefinition {
|
||||||
|
const item = record(value);
|
||||||
|
if (
|
||||||
|
typeof item.id !== 'string' || !item.id
|
||||||
|
|| typeof item.label !== 'string' || !item.label
|
||||||
|
|| !['dns', 'doh'].includes(String(item.kind))
|
||||||
|
|| typeof item.endpoint !== 'string' || !item.endpoint
|
||||||
|
) throw new TypeError('Invalid DNS resolver');
|
||||||
|
return {
|
||||||
|
id: item.id,
|
||||||
|
label: item.label,
|
||||||
|
kind: item.kind as 'dns' | 'doh',
|
||||||
|
endpoint: item.endpoint,
|
||||||
|
...(item.bootstrap ? { bootstrap: String(item.bootstrap) } : {}),
|
||||||
|
system: item.system === true,
|
||||||
|
custom: item.custom === true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function domain(value: unknown): DnsDomainDefinition {
|
||||||
|
const item = record(value);
|
||||||
|
if (
|
||||||
|
typeof item.id !== 'string' || !item.id
|
||||||
|
|| typeof item.label !== 'string' || !item.label
|
||||||
|
|| typeof item.hostname !== 'string' || !item.hostname
|
||||||
|
) throw new TypeError('Invalid DNS domain');
|
||||||
|
return {
|
||||||
|
id: item.id,
|
||||||
|
label: item.label,
|
||||||
|
hostname: item.hostname,
|
||||||
|
builtIn: item.builtIn === true,
|
||||||
|
custom: item.custom === true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function pathResult(value: unknown): DnsPathResult {
|
||||||
|
const item = record(value);
|
||||||
|
if (
|
||||||
|
typeof item.status !== 'string'
|
||||||
|
|| !(item.rcode === null || typeof item.rcode === 'string')
|
||||||
|
|| !(item.latencyMs === null || Number.isFinite(item.latencyMs))
|
||||||
|
|| !(item.transport === null || typeof item.transport === 'string')
|
||||||
|
|| !(item.error === null || typeof item.error === 'string')
|
||||||
|
) throw new TypeError('Invalid DNS path result');
|
||||||
|
return {
|
||||||
|
status: item.status,
|
||||||
|
rcode: item.rcode as string | null,
|
||||||
|
ipv4: stringArray(item.ipv4),
|
||||||
|
ipv6: stringArray(item.ipv6),
|
||||||
|
latencyMs: item.latencyMs as number | null,
|
||||||
|
transport: item.transport as string | null,
|
||||||
|
error: item.error as string | null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseCatalog(value: unknown): DnsCatalog {
|
||||||
|
const item = record(value);
|
||||||
|
if (!Array.isArray(item.domains) || !Array.isArray(item.resolvers)) throw new TypeError('Invalid DNS catalog');
|
||||||
|
return { domains: item.domains.map(domain), resolvers: item.resolvers.map(resolver) };
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseRun(value: unknown): DnsRunResult {
|
||||||
|
const item = record(value);
|
||||||
|
if (typeof item.checkedAt !== 'string' || !Array.isArray(item.results)) throw new TypeError('Invalid DNS result');
|
||||||
|
return {
|
||||||
|
checkedAt: item.checkedAt,
|
||||||
|
domain: domain(item.domain),
|
||||||
|
results: item.results.map((value) => {
|
||||||
|
const row = record(value);
|
||||||
|
if (!['same', 'different', 'direct-only', 'vpn-only', 'failed'].includes(String(row.comparison))) {
|
||||||
|
throw new TypeError('Invalid DNS comparison');
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
resolver: resolver(row.resolver),
|
||||||
|
direct: pathResult(row.direct),
|
||||||
|
vpn: pathResult(row.vpn),
|
||||||
|
comparison: row.comparison as DnsRowResult['comparison'],
|
||||||
|
warning: row.warning === null ? null : String(row.warning),
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function requestMessage(value: unknown) {
|
||||||
|
const item = record(value);
|
||||||
|
return typeof item.message === 'string' ? item.message : 'DNS checker временно недоступен.';
|
||||||
|
}
|
||||||
|
|
||||||
|
function Refresh({
|
||||||
|
label,
|
||||||
|
running,
|
||||||
|
disabled,
|
||||||
|
compact = false,
|
||||||
|
onClick,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
running: boolean;
|
||||||
|
disabled: boolean;
|
||||||
|
compact?: boolean;
|
||||||
|
onClick: () => void;
|
||||||
|
}) {
|
||||||
|
return <span className={`client-diagnostics-refresh-wrap client-tooltip-anchor${compact ? ' client-diagnostics-row-refresh-wrap' : ''}`}>
|
||||||
|
<button
|
||||||
|
className={`client-diagnostics-refresh${compact ? ' client-diagnostics-row-refresh' : ''}${running ? ' is-running' : ''}`}
|
||||||
|
type="button"
|
||||||
|
aria-label={label}
|
||||||
|
aria-busy={running}
|
||||||
|
disabled={disabled}
|
||||||
|
onClick={onClick}
|
||||||
|
>
|
||||||
|
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||||
|
<path d="M20 11a8 8 0 1 0-2.3 6.7M20 5v6h-6" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<Tooltip>{label}</Tooltip>
|
||||||
|
</span>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function pathStatus(result: DnsPathResult | undefined) {
|
||||||
|
if (!result) return '—';
|
||||||
|
const labels: Record<string, string> = {
|
||||||
|
'vpn-off': 'VPN выключен',
|
||||||
|
timeout: 'Timeout',
|
||||||
|
nxdomain: 'NXDOMAIN',
|
||||||
|
servfail: 'SERVFAIL',
|
||||||
|
error: 'Ошибка ответа',
|
||||||
|
'no-addresses': result.rcode || 'Нет адресов',
|
||||||
|
};
|
||||||
|
return labels[result.status] || result.error || result.rcode || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function ResultCell({ result, running, route }: {
|
||||||
|
result: DnsPathResult | undefined;
|
||||||
|
running: boolean;
|
||||||
|
route: string;
|
||||||
|
}) {
|
||||||
|
const failed = result && !['answered', 'no-addresses', 'nxdomain'].includes(result.status);
|
||||||
|
return <span
|
||||||
|
className={`client-dns-result${failed ? ' is-error' : ''}${running ? ' is-running' : ''}`}
|
||||||
|
aria-label={`${route}: ${running ? 'Тестируем' : pathStatus(result)}`}
|
||||||
|
>
|
||||||
|
<span><b>A</b>{running && !result ? 'Тестируем' : result?.ipv4.join(', ') || '—'}</span>
|
||||||
|
<span><b>AAAA</b>{running && !result ? 'Тестируем' : result?.ipv6.join(', ') || '—'}</span>
|
||||||
|
<small>{running
|
||||||
|
? 'Тестируем'
|
||||||
|
: result?.latencyMs !== null && result?.latencyMs !== undefined
|
||||||
|
? `${result.latencyMs} мс · ${result.transport || 'DNS'}`
|
||||||
|
: pathStatus(result)}</small>
|
||||||
|
</span>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const comparisonLabel: Record<DnsRowResult['comparison'], string> = {
|
||||||
|
same: 'совпадают',
|
||||||
|
different: 'различаются',
|
||||||
|
'direct-only': 'только Direct',
|
||||||
|
'vpn-only': 'только VPN',
|
||||||
|
failed: 'нет ответа',
|
||||||
|
};
|
||||||
|
|
||||||
|
function preserveAddresses(previous: DnsPathResult | undefined, next: DnsPathResult) {
|
||||||
|
return previous && !['answered', 'no-addresses', 'nxdomain'].includes(next.status)
|
||||||
|
? { ...next, ipv4: previous.ipv4, ipv6: previous.ipv6 }
|
||||||
|
: next;
|
||||||
|
}
|
||||||
|
|
||||||
|
function mergeRow(previous: DnsRowResult | undefined, next: DnsRowResult): DnsRowResult {
|
||||||
|
return previous ? {
|
||||||
|
...next,
|
||||||
|
direct: preserveAddresses(previous.direct, next.direct),
|
||||||
|
vpn: preserveAddresses(previous.vpn, next.vpn),
|
||||||
|
} : next;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DnsDiagnosticsSection({
|
||||||
|
open,
|
||||||
|
settings,
|
||||||
|
updateSettings,
|
||||||
|
loadCatalog,
|
||||||
|
runDiagnostics,
|
||||||
|
}: {
|
||||||
|
open: boolean;
|
||||||
|
settings: DiagnosticSettings;
|
||||||
|
updateSettings: UpdateSettings;
|
||||||
|
loadCatalog: () => Promise<unknown>;
|
||||||
|
runDiagnostics: (domainId: string, resolverId?: string | null) => Promise<unknown>;
|
||||||
|
}) {
|
||||||
|
const [catalog, setCatalog] = useState<DnsCatalog | null>(null);
|
||||||
|
const [activeDomainId, setActiveDomainId] = useState('youtube');
|
||||||
|
const [cache, setCache] = useState<Record<string, Record<string, DnsRowResult>>>({});
|
||||||
|
const [running, setRunning] = useState(false);
|
||||||
|
const [activeResolverId, setActiveResolverId] = useState<string | null>(null);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
const [addingDomain, setAddingDomain] = useState(false);
|
||||||
|
const [domainName, setDomainName] = useState('');
|
||||||
|
const [domainHostname, setDomainHostname] = useState('');
|
||||||
|
const [addingResolver, setAddingResolver] = useState(false);
|
||||||
|
const [resolverName, setResolverName] = useState('');
|
||||||
|
const [resolverEndpoint, setResolverEndpoint] = useState('');
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const rows = useMemo(() => cache[activeDomainId] || {}, [cache, activeDomainId]);
|
||||||
|
|
||||||
|
async function refreshCatalog() {
|
||||||
|
const next = parseCatalog(await loadCatalog());
|
||||||
|
setCatalog(next);
|
||||||
|
if (!next.domains.some(({ id }) => id === activeDomainId)) setActiveDomainId(next.domains[0]?.id || '');
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open || catalog) return;
|
||||||
|
void refreshCatalog().catch((requestError) => setError(requestMessage(requestError)));
|
||||||
|
}, [open, catalog]);
|
||||||
|
|
||||||
|
async function run(resolverId: string | null = null) {
|
||||||
|
if (!catalog || !activeDomainId) return;
|
||||||
|
setRunning(true);
|
||||||
|
setActiveResolverId(resolverId);
|
||||||
|
setError('');
|
||||||
|
try {
|
||||||
|
const result = parseRun(await runDiagnostics(activeDomainId, resolverId));
|
||||||
|
setCache((current) => {
|
||||||
|
const previous = current[activeDomainId] || {};
|
||||||
|
const incoming = Object.fromEntries(result.results.map((row) => [
|
||||||
|
row.resolver.id,
|
||||||
|
mergeRow(previous[row.resolver.id], row),
|
||||||
|
]));
|
||||||
|
return { ...current, [activeDomainId]: resolverId ? { ...previous, ...incoming } : incoming };
|
||||||
|
});
|
||||||
|
} catch (requestError) {
|
||||||
|
setError(requestMessage(requestError));
|
||||||
|
} finally {
|
||||||
|
setRunning(false);
|
||||||
|
setActiveResolverId(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveDomain(event: FormEvent<HTMLFormElement>) {
|
||||||
|
event.preventDefault();
|
||||||
|
if (settings.customDnsDomains.length >= MAX_CUSTOM_DNS_DOMAINS) return;
|
||||||
|
setSaving(true);
|
||||||
|
setError('');
|
||||||
|
try {
|
||||||
|
const hostname = new URL(`http://${domainHostname.trim().replace(/\.$/, '')}/`).hostname;
|
||||||
|
const id = `custom-domain-${globalThis.crypto?.randomUUID?.() || Date.now()}`;
|
||||||
|
const saved = await updateSettings({
|
||||||
|
customDnsDomains: [...settings.customDnsDomains, {
|
||||||
|
id,
|
||||||
|
label: domainName.trim() || hostname,
|
||||||
|
hostname,
|
||||||
|
}],
|
||||||
|
});
|
||||||
|
if (saved === false) throw new Error('Не удалось сохранить домен.');
|
||||||
|
setDomainName('');
|
||||||
|
setDomainHostname('');
|
||||||
|
setAddingDomain(false);
|
||||||
|
setCatalog(null);
|
||||||
|
setActiveDomainId(id);
|
||||||
|
} catch (validationError) {
|
||||||
|
setError(validationError instanceof Error ? validationError.message : 'Проверьте домен.');
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveResolver(event: FormEvent<HTMLFormElement>) {
|
||||||
|
event.preventDefault();
|
||||||
|
if (settings.customDnsResolvers.length >= MAX_CUSTOM_DNS_RESOLVERS) return;
|
||||||
|
setSaving(true);
|
||||||
|
setError('');
|
||||||
|
try {
|
||||||
|
const endpoint = resolverEndpoint.trim();
|
||||||
|
const kind = endpoint.startsWith('https://') ? 'doh' : 'dns';
|
||||||
|
const saved = await updateSettings({
|
||||||
|
customDnsResolvers: [...settings.customDnsResolvers, {
|
||||||
|
id: `custom-dns-${globalThis.crypto?.randomUUID?.() || Date.now()}`,
|
||||||
|
label: resolverName.trim() || endpoint,
|
||||||
|
kind,
|
||||||
|
endpoint,
|
||||||
|
}],
|
||||||
|
});
|
||||||
|
if (saved === false) throw new Error('Не удалось сохранить DNS.');
|
||||||
|
setResolverName('');
|
||||||
|
setResolverEndpoint('');
|
||||||
|
setAddingResolver(false);
|
||||||
|
setCatalog(null);
|
||||||
|
} catch (validationError) {
|
||||||
|
setError(validationError instanceof Error ? validationError.message : 'Проверьте DNS или DoH endpoint.');
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function remove(kind: 'domain' | 'resolver', id: string) {
|
||||||
|
setSaving(true);
|
||||||
|
setError('');
|
||||||
|
try {
|
||||||
|
const saved = await updateSettings(kind === 'domain'
|
||||||
|
? { customDnsDomains: settings.customDnsDomains.filter((item) => item.id !== id) }
|
||||||
|
: { customDnsResolvers: settings.customDnsResolvers.filter((item) => item.id !== id) });
|
||||||
|
if (saved === false) throw new Error('Не удалось сохранить DNS settings.');
|
||||||
|
setCatalog(null);
|
||||||
|
setCache({});
|
||||||
|
} catch (requestError) {
|
||||||
|
setError(requestMessage(requestError));
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const blocked = running || saving;
|
||||||
|
return <section className="client-diagnostics-section client-dns-section" aria-labelledby="diagnostic-dns-title">
|
||||||
|
<div className="client-diagnostics-section-title client-dns-title-row">
|
||||||
|
<span id="diagnostic-dns-title">DNS</span>
|
||||||
|
<Refresh label="Проверить DNS" running={running && !activeResolverId} disabled={blocked || !catalog} onClick={() => void run()} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="client-dns-domain-row">
|
||||||
|
<label htmlFor="client-dns-domain">Домен</label>
|
||||||
|
<select
|
||||||
|
id="client-dns-domain"
|
||||||
|
value={activeDomainId}
|
||||||
|
disabled={blocked || !catalog?.domains.length}
|
||||||
|
onChange={(event) => setActiveDomainId(event.target.value)}
|
||||||
|
>
|
||||||
|
{(catalog?.domains || []).map((item) => <option key={item.id} value={item.id}>{item.label} · {item.hostname}</option>)}
|
||||||
|
</select>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={blocked || addingDomain || settings.customDnsDomains.length >= MAX_CUSTOM_DNS_DOMAINS}
|
||||||
|
onClick={() => setAddingDomain(true)}
|
||||||
|
>+ Домен</button>
|
||||||
|
{catalog?.domains.find(({ id }) => id === activeDomainId)?.custom && <button
|
||||||
|
className="client-row-delete"
|
||||||
|
type="button"
|
||||||
|
aria-label="Удалить выбранный домен"
|
||||||
|
disabled={blocked}
|
||||||
|
onClick={() => void remove('domain', activeDomainId)}
|
||||||
|
>×</button>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{addingDomain && <form className="client-dns-inline-form" onSubmit={saveDomain}>
|
||||||
|
<input value={domainName} maxLength={40} placeholder="Название" aria-label="Название DNS-домена" onChange={(event) => setDomainName(event.target.value)} />
|
||||||
|
<input value={domainHostname} placeholder="example.com" aria-label="DNS-домен" required onChange={(event) => setDomainHostname(event.target.value)} />
|
||||||
|
<button type="submit" disabled={blocked}>Добавить</button>
|
||||||
|
<button type="button" aria-label="Отменить добавление домена" onClick={() => setAddingDomain(false)}>×</button>
|
||||||
|
</form>}
|
||||||
|
|
||||||
|
{error && <div className="client-diagnostics-error" role="alert"><span>{error}</span></div>}
|
||||||
|
|
||||||
|
<div className="client-dns-table" role="table" aria-busy={running}>
|
||||||
|
<div className="client-dns-header" role="row">
|
||||||
|
<span role="columnheader">DNS</span><span role="columnheader">Напрямую</span><span role="columnheader">VPN</span><span aria-hidden="true" />
|
||||||
|
</div>
|
||||||
|
{(catalog?.resolvers || []).map((item) => {
|
||||||
|
const result = rows[item.id];
|
||||||
|
const rowRunning = running && (!activeResolverId || activeResolverId === item.id);
|
||||||
|
return <div key={item.id} className={`client-dns-row${rowRunning ? ' is-running' : ''}`} role="row" data-diagnostic-target={`dns:${item.id}`}>
|
||||||
|
<span className="client-dns-resolver client-diagnostics-row-name" role="rowheader">
|
||||||
|
<span><b>{item.label}</b><small>{item.kind === 'doh' ? 'DoH' : 'DNS'} · {item.endpoint}</small><small>{result?.warning === 'private-address' ? 'получен частный адрес' : result ? comparisonLabel[result.comparison] : 'не проверено'}</small></span>
|
||||||
|
<Refresh label={`Проверить: ${item.label}`} running={rowRunning && Boolean(activeResolverId)} disabled={blocked} compact onClick={() => void run(item.id)} />
|
||||||
|
</span>
|
||||||
|
<span role="cell"><ResultCell result={result?.direct} running={rowRunning} route={`Напрямую, ${item.label}`} /></span>
|
||||||
|
<span role="cell"><ResultCell result={result?.vpn} running={rowRunning} route={`VPN, ${item.label}`} /></span>
|
||||||
|
{item.custom ? <button
|
||||||
|
className="client-row-delete"
|
||||||
|
type="button"
|
||||||
|
aria-label={`Удалить DNS ${item.label}`}
|
||||||
|
disabled={blocked}
|
||||||
|
onClick={() => void remove('resolver', item.id)}
|
||||||
|
>×</button> : <span aria-hidden="true" />}
|
||||||
|
</div>;
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!catalog && !error && <p className="client-diagnostics-services-empty">Загружаем DNS…</p>}
|
||||||
|
<div className="client-row-add-slot client-diagnostics-add-slot">
|
||||||
|
<button
|
||||||
|
className="client-row-add"
|
||||||
|
type="button"
|
||||||
|
disabled={blocked || addingResolver || settings.customDnsResolvers.length >= MAX_CUSTOM_DNS_RESOLVERS}
|
||||||
|
onClick={() => setAddingResolver(true)}
|
||||||
|
>{settings.customDnsResolvers.length >= MAX_CUSTOM_DNS_RESOLVERS ? 'Лимит 5 DNS' : '+ Добавить DNS'}</button>
|
||||||
|
</div>
|
||||||
|
{addingResolver && <form className="client-dns-inline-form" onSubmit={saveResolver}>
|
||||||
|
<input value={resolverName} maxLength={40} placeholder="Название" aria-label="Название DNS-резолвера" onChange={(event) => setResolverName(event.target.value)} />
|
||||||
|
<input value={resolverEndpoint} placeholder="8.8.8.8 или https://…/dns-query" aria-label="DNS или DoH endpoint" required onChange={(event) => setResolverEndpoint(event.target.value)} />
|
||||||
|
<button type="submit" disabled={blocked}>Добавить</button>
|
||||||
|
<button type="button" aria-label="Отменить добавление DNS" onClick={() => setAddingResolver(false)}>×</button>
|
||||||
|
</form>}
|
||||||
|
<p className="client-dns-note">Разные ответы могут быть нормой для CDN.</p>
|
||||||
|
</section>;
|
||||||
|
}
|
||||||
@@ -482,6 +482,9 @@ export function FailoverPanel({
|
|||||||
const selectedId = channelOptions.find(({ target }) => (
|
const selectedId = channelOptions.find(({ target }) => (
|
||||||
target.profileId === draft[channel].profileId && target.serverId === draft[channel].serverId
|
target.profileId === draft[channel].profileId && target.serverId === draft[channel].serverId
|
||||||
))?.id || '';
|
))?.id || '';
|
||||||
|
const failureProgress = !checking && channel === 'primary' && snapshot.reason === 'failure-window'
|
||||||
|
? `${seconds(Math.max(0, Date.now() - Date.parse(snapshot.primary.stateSince || new Date().toISOString())))} / ${seconds(draft.failureWindowMs)} с`
|
||||||
|
: '';
|
||||||
const healthLabel = missing
|
const healthLabel = missing
|
||||||
? 'Сервер не найден'
|
? 'Сервер не найден'
|
||||||
: !draft.enabled
|
: !draft.enabled
|
||||||
@@ -490,15 +493,20 @@ export function FailoverPanel({
|
|||||||
? 'VPN выключен'
|
? 'VPN выключен'
|
||||||
: snapshot.activation === 'pending'
|
: snapshot.activation === 'pending'
|
||||||
? 'Проверим после запуска VPN'
|
? 'Проверим после запуска VPN'
|
||||||
: channel === 'primary' && snapshot.reason === 'failure-window'
|
: checking
|
||||||
? `Проверяем основной канал · ${seconds(Math.max(0, Date.now() - Date.parse(snapshot.primary.stateSince || new Date().toISOString())))} из ${seconds(draft.failureWindowMs)} с`
|
? `Проверяем ${channel === 'primary' ? 'основной' : 'резервный'} канал`
|
||||||
|
: failureProgress
|
||||||
|
? 'Проверяем основной канал'
|
||||||
: channel === 'primary' && snapshot.currentRole === 'reserve' && health === 'healthy' && snapshot.reason === 'recovery-hold'
|
: channel === 'primary' && snapshot.currentRole === 'reserve' && health === 'healthy' && snapshot.reason === 'recovery-hold'
|
||||||
? 'Проверяем восстановление'
|
? 'Проверяем восстановление'
|
||||||
: health === 'healthy' ? 'Работает' : health === 'unhealthy' ? 'Недоступен' : health === 'not-monitoring' ? 'Проверка выключена' : 'Ждёт проверки';
|
: health === 'healthy' ? 'Работает' : health === 'unhealthy' ? 'Недоступен' : health === 'not-monitoring' ? 'Проверка выключена' : 'Ждёт проверки';
|
||||||
return <div className="client-failover-channel-slot" key={channel}><article className="client-failover-channel" data-channel={channel}>
|
return <div className="client-failover-channel-slot" key={channel}><article className="client-failover-channel" data-channel={channel}>
|
||||||
<div className="client-failover-channel-title">
|
<div className="client-failover-channel-title">
|
||||||
<h3>{channel === 'primary' ? 'Основной' : 'Резервный'}</h3>
|
<h3>{channel === 'primary' ? 'Основной' : 'Резервный'}</h3>
|
||||||
<strong className={health === 'healthy' ? 'is-healthy' : health === 'unhealthy' || missing ? 'is-unhealthy' : ''}>{healthLabel}</strong>
|
</div>
|
||||||
|
<div className="client-failover-channel-status">
|
||||||
|
<strong className={healthLabel === 'Работает' ? 'is-healthy' : healthLabel === 'Недоступен' || missing ? 'is-unhealthy' : ''}>{healthLabel}</strong>
|
||||||
|
<span className="client-failover-channel-progress">{failureProgress}</span>
|
||||||
</div>
|
</div>
|
||||||
<FailoverPicker
|
<FailoverPicker
|
||||||
id={`client-failover-${channel}-options`}
|
id={`client-failover-${channel}-options`}
|
||||||
@@ -510,9 +518,6 @@ export function FailoverPanel({
|
|||||||
onOpenChange={(open) => setOpenPicker(open ? channel : '')}
|
onOpenChange={(open) => setOpenPicker(open ? channel : '')}
|
||||||
onChange={(optionId) => updateTarget(channel, optionId)}
|
onChange={(optionId) => updateTarget(channel, optionId)}
|
||||||
/>
|
/>
|
||||||
<span className={`client-failover-channel-checking${checking ? ' is-visible' : ''}`} aria-hidden="true">
|
|
||||||
Проверяем {channel === 'primary' ? 'основной' : 'резервный'} канал
|
|
||||||
</span>
|
|
||||||
</article>{channel === 'primary' && <div className="client-failover-channel-actions" aria-label="Действия с каналами">
|
</article>{channel === 'primary' && <div className="client-failover-channel-actions" aria-label="Действия с каналами">
|
||||||
<button
|
<button
|
||||||
className={`client-failover-check-action client-tooltip-anchor${checking ? ' is-running' : ''}`}
|
className={`client-failover-check-action client-tooltip-anchor${checking ? ' is-running' : ''}`}
|
||||||
|
|||||||
@@ -97,7 +97,7 @@ export function instructionBlocks({ isGateway, host, port, controlHost }: {
|
|||||||
paragraphs: [
|
paragraphs: [
|
||||||
`Prometheus забирает накопленные Harbor counters с http://${controlHost}/metrics. Ручка читает готовый snapshot и не запускает новый сбор трафика.`,
|
`Prometheus забирает накопленные Harbor counters с http://${controlHost}/metrics. Ручка читает готовый snapshot и не запускает новый сбор трафика.`,
|
||||||
'Harbor обновляет traffic snapshot раз в 15 секунд, поэтому начальный scrape interval и refresh dashboard в 30 секунд не создают лишних одинаковых выборок.',
|
'Harbor обновляет traffic snapshot раз в 15 секунд, поэтому начальный scrape interval и refresh dashboard в 30 секунд не создают лишних одинаковых выборок.',
|
||||||
'Единый фильтр «Устройства» управляет скоростью, накопленным трафиком, сервисами и доменами для всех или одного устройства. Отдельный график показывает текущую скорость каждого активного устройства; нулевые series скрыты.',
|
'Единый фильтр «Устройства» управляет applied policy, фактическим VPN/Direct, общим трафиком, сервисами и доменами. Policy Direct не запрещает VPN через явно настроенный Harbor Proxy: режим останется Direct, а proxy-байты появятся в VPN.',
|
||||||
],
|
],
|
||||||
steps: [
|
steps: [
|
||||||
'Добавьте блок ниже в prometheus.yml и перезагрузите Prometheus.',
|
'Добавьте блок ниже в prometheus.yml и перезагрузите Prometheus.',
|
||||||
@@ -110,7 +110,7 @@ export function instructionBlocks({ isGateway, host, port, controlHost }: {
|
|||||||
{ id: 'prometheus-config', label: 'prometheus.yml', text: prometheusScrapeConfig(controlHost) },
|
{ id: 'prometheus-config', label: 'prometheus.yml', text: prometheusScrapeConfig(controlHost) },
|
||||||
{ id: 'grafana-dashboard', label: 'Grafana dashboard', text: grafanaDashboardJson },
|
{ id: 'grafana-dashboard', label: 'Grafana dashboard', text: grafanaDashboardJson },
|
||||||
],
|
],
|
||||||
note: 'Domain counters снимаются с активных соединений sing-box раз в 2 секунды. Историю хранит Prometheus; соединения между снимками могут быть пропущены, неизвестный домен записывается как _unknown, а policy Direct виден только в отдельной Direct IPv4 metric.',
|
note: 'Историю хранит Prometheus. Основной Direct приблизительно объединяет sing-box и Linux IPv4 bytes с разной семантикой; точные значения остаются раздельными в технической секции. До первого scrape история applied policy отсутствует.',
|
||||||
}] : []),
|
}] : []),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,79 @@
|
|||||||
|
import { useEffect, useId, useLayoutEffect, useRef, useState, type ReactNode } from 'react';
|
||||||
|
|
||||||
|
export function TrafficReveal({ open, id, className = '', children }: {
|
||||||
|
open: boolean; id?: string; className?: string; children: ReactNode;
|
||||||
|
}) {
|
||||||
|
const [present, setPresent] = useState(open);
|
||||||
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
if (open) setPresent(true);
|
||||||
|
const node = ref.current;
|
||||||
|
if (!node) return;
|
||||||
|
const media = matchMedia('(prefers-reduced-motion: reduce)');
|
||||||
|
if (media.matches) {
|
||||||
|
node.style.opacity = open ? '1' : '0';
|
||||||
|
if (!open) setPresent(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const animation = node.animate({ opacity: open ? 1 : 0 }, {
|
||||||
|
duration: 180, easing: 'cubic-bezier(0.2, 0, 0, 1)', fill: 'forwards',
|
||||||
|
});
|
||||||
|
animation.onfinish = () => { if (!open) setPresent(false); };
|
||||||
|
const reduce = () => {
|
||||||
|
if (!media.matches) return;
|
||||||
|
animation.cancel();
|
||||||
|
node.style.opacity = open ? '1' : '0';
|
||||||
|
if (!open) setPresent(false);
|
||||||
|
};
|
||||||
|
media.addEventListener('change', reduce);
|
||||||
|
return () => {
|
||||||
|
node.style.opacity = getComputedStyle(node).opacity;
|
||||||
|
animation.cancel();
|
||||||
|
media.removeEventListener('change', reduce);
|
||||||
|
};
|
||||||
|
}, [open]);
|
||||||
|
return open || present ? <div ref={ref} id={id} className={className}
|
||||||
|
style={{ opacity: 0 }} aria-hidden={!open} inert={!open || undefined}
|
||||||
|
>{children}</div> : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TrafficMenu({ label, active = false, closeOnSelect = false, children }: {
|
||||||
|
label: string; active?: boolean; closeOnSelect?: boolean; children: ReactNode;
|
||||||
|
}) {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
|
const trigger = useRef<HTMLButtonElement>(null);
|
||||||
|
const id = useId();
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
const outside = (event: PointerEvent) => {
|
||||||
|
if (!ref.current?.contains(event.target as Node)) setOpen(false);
|
||||||
|
};
|
||||||
|
document.addEventListener('pointerdown', outside);
|
||||||
|
return () => document.removeEventListener('pointerdown', outside);
|
||||||
|
}, [open]);
|
||||||
|
return <div className="client-traffic-menu" ref={ref} onKeyDown={(event) => {
|
||||||
|
if (event.key !== 'Escape' || !open) return;
|
||||||
|
event.stopPropagation();
|
||||||
|
setOpen(false);
|
||||||
|
trigger.current?.focus();
|
||||||
|
}}>
|
||||||
|
<button ref={trigger} type="button" className="client-traffic-menu-trigger"
|
||||||
|
aria-expanded={open} aria-controls={id} data-active={active} title={label}
|
||||||
|
onClick={() => setOpen((value) => !value)}
|
||||||
|
>{label}</button>
|
||||||
|
<TrafficReveal open={open} id={id} className="client-traffic-menu-content">
|
||||||
|
<div onClick={(event) => {
|
||||||
|
if (!closeOnSelect || !(event.target as HTMLElement).closest('button')) return;
|
||||||
|
setOpen(false);
|
||||||
|
trigger.current?.focus();
|
||||||
|
}}>{children}</div>
|
||||||
|
</TrafficReveal>
|
||||||
|
</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TrafficColumns() {
|
||||||
|
return <div className="client-traffic-columns" aria-hidden="true">
|
||||||
|
<span>Сайт</span><span>Скачивание</span><span>Отправка</span>
|
||||||
|
</div>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,610 @@
|
|||||||
|
import { useEffect, useId, useMemo, useRef, useState } from 'react';
|
||||||
|
|
||||||
|
import {
|
||||||
|
assertLiveTrafficSnapshot,
|
||||||
|
type LiveTrafficConnection,
|
||||||
|
type LiveTrafficSnapshot,
|
||||||
|
} from '../../../shared/liveTraffic.js';
|
||||||
|
import {
|
||||||
|
TRAFFIC_RETENTION_OPTIONS,
|
||||||
|
type TrafficSettings,
|
||||||
|
} from '../../../shared/trafficSettings.js';
|
||||||
|
import { Drawer } from '../../ui/Drawer.js';
|
||||||
|
import { RailAction } from '../../ui/RailAction.js';
|
||||||
|
import { formatByteString } from '../../utils/format.js';
|
||||||
|
import { TrafficColumns, TrafficMenu, TrafficReveal } from './TrafficControls.js';
|
||||||
|
import { TrafficHistoryPanel, type LoadTrafficHistory } from './TrafficHistoryPanel.js';
|
||||||
|
import {
|
||||||
|
groupTrafficConnections,
|
||||||
|
reconcileTrafficGroups,
|
||||||
|
sortTrafficGroups,
|
||||||
|
summarizeTrafficOrigins,
|
||||||
|
trafficConnectionMatchesFilters,
|
||||||
|
trafficGroupMatches,
|
||||||
|
trafficOriginId,
|
||||||
|
type DisplayedTrafficGroup,
|
||||||
|
type TrafficConnectionGroup,
|
||||||
|
type TrafficQualityFilter,
|
||||||
|
type TrafficRouteFilter,
|
||||||
|
} from './trafficRows.js';
|
||||||
|
|
||||||
|
const POLL_MS = 1_000;
|
||||||
|
|
||||||
|
type RequestState = 'idle' | 'loading' | 'ready' | 'error';
|
||||||
|
|
||||||
|
interface TrafficFeatureOptions {
|
||||||
|
enabled: boolean;
|
||||||
|
isGateway: boolean;
|
||||||
|
loadLiveTraffic: () => Promise<unknown>;
|
||||||
|
loadHistory: LoadTrafficHistory;
|
||||||
|
settings: TrafficSettings;
|
||||||
|
updateSettings: (settings: TrafficSettings) => Promise<unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const routeLabels: Record<LiveTrafficConnection['route']['kind'], string> = {
|
||||||
|
vpn: 'VPN',
|
||||||
|
direct: 'Direct',
|
||||||
|
other: 'Другое',
|
||||||
|
};
|
||||||
|
|
||||||
|
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,
|
||||||
|
loadHistory,
|
||||||
|
settings,
|
||||||
|
updateSettings,
|
||||||
|
}: TrafficFeatureOptions) {
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
const [paused, setPaused] = useState(false);
|
||||||
|
const [view, setView] = useState<'live' | 'history'>('live');
|
||||||
|
const [snapshot, setSnapshot] = useState<LiveTrafficSnapshot | null>(null);
|
||||||
|
const [requestState, setRequestState] = useState<RequestState>('idle');
|
||||||
|
const panelRef = useRef<HTMLElement>(null);
|
||||||
|
const toggleRef = useRef<HTMLButtonElement>(null);
|
||||||
|
const closeRef = useRef<HTMLButtonElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (enabled) return;
|
||||||
|
setIsOpen(false);
|
||||||
|
setPaused(false);
|
||||||
|
}, [enabled]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!enabled || !isOpen || paused || view !== 'live') return undefined;
|
||||||
|
let cancelled = false;
|
||||||
|
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||||
|
setRequestState((current) => current === 'idle' ? 'loading' : current);
|
||||||
|
|
||||||
|
const poll = async () => {
|
||||||
|
try {
|
||||||
|
const next = assertLiveTrafficSnapshot(await loadLiveTraffic());
|
||||||
|
if (!cancelled) {
|
||||||
|
setSnapshot(next);
|
||||||
|
setRequestState('ready');
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
if (!cancelled) setRequestState('error');
|
||||||
|
} finally {
|
||||||
|
if (!cancelled) timer = setTimeout(poll, POLL_MS);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
void poll();
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
if (timer) clearTimeout(timer);
|
||||||
|
};
|
||||||
|
}, [enabled, isOpen, paused, view, 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,
|
||||||
|
view,
|
||||||
|
setView,
|
||||||
|
loadHistory,
|
||||||
|
snapshot,
|
||||||
|
requestState,
|
||||||
|
settings,
|
||||||
|
updateSettings,
|
||||||
|
panelRef,
|
||||||
|
toggleRef,
|
||||||
|
closeRef,
|
||||||
|
close,
|
||||||
|
toggle,
|
||||||
|
togglePause: () => setPaused((current) => !current),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export type TrafficFeature = ReturnType<typeof useTrafficFeature>;
|
||||||
|
|
||||||
|
export function TrafficToggle({
|
||||||
|
feature,
|
||||||
|
open,
|
||||||
|
onToggle,
|
||||||
|
}: {
|
||||||
|
feature: TrafficFeature;
|
||||||
|
open: boolean;
|
||||||
|
onToggle: () => void;
|
||||||
|
}) {
|
||||||
|
return <RailAction
|
||||||
|
buttonRef={feature.toggleRef}
|
||||||
|
className="client-traffic-toggle"
|
||||||
|
open={open}
|
||||||
|
controls="client-traffic"
|
||||||
|
ariaLabel={open ? 'Закрыть трафик' : 'Открыть трафик'}
|
||||||
|
label="Трафик"
|
||||||
|
onClick={onToggle}
|
||||||
|
>
|
||||||
|
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||||
|
<path d="M3.5 19.5h17" />
|
||||||
|
<path d="m5 16 4-4 3 2 6-7" />
|
||||||
|
<circle cx="5" cy="16" r=".7" />
|
||||||
|
<circle cx="9" cy="12" r=".7" />
|
||||||
|
<circle cx="12" cy="14" r=".7" />
|
||||||
|
<circle cx="18" cy="7" r=".7" />
|
||||||
|
</svg>
|
||||||
|
</RailAction>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function groupStatus(group: TrafficConnectionGroup) {
|
||||||
|
if (group.origins.length > 1) {
|
||||||
|
return `${group.connections.length} соединений · ${group.origins.length} устройств · ${group.protocol}`;
|
||||||
|
}
|
||||||
|
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}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export 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.origins.length > 1
|
||||||
|
? `${group.origins.length} устройств · соединений: ${group.connections.length}`
|
||||||
|
: `${group.origin.label} · соединений: ${group.connections.length}`;
|
||||||
|
const chain = group.route.chain.length
|
||||||
|
? group.route.chain.join(' / ')
|
||||||
|
: group.route.outbound || '—';
|
||||||
|
|
||||||
|
return <div
|
||||||
|
className={`client-traffic-connection${exiting ? ' is-exiting' : ''}`}
|
||||||
|
role="listitem"
|
||||||
|
inert={exiting || undefined}
|
||||||
|
aria-hidden={exiting || undefined}
|
||||||
|
onAnimationEnd={(event) => {
|
||||||
|
if (event.target === event.currentTarget && event.animationName === 'client-traffic-connection-out') {
|
||||||
|
onExited();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
className="client-traffic-connection-summary"
|
||||||
|
type="button"
|
||||||
|
aria-expanded={expanded}
|
||||||
|
aria-controls={detailsId}
|
||||||
|
onClick={onToggle}
|
||||||
|
>
|
||||||
|
<span className="client-traffic-identity">
|
||||||
|
<strong title={group.label}>{group.label}</strong>
|
||||||
|
</span>
|
||||||
|
<span className="client-traffic-download" aria-label={`Скачивание: ${group.activeCount > 0 ? `${formatByteString(group.traffic.downloadBytesPerSecond)}/с` : 'соединение завершено'}`}>
|
||||||
|
{group.activeCount > 0 ? `${formatByteString(group.traffic.downloadBytesPerSecond)}/с` : '—'}
|
||||||
|
</span>
|
||||||
|
<span className="client-traffic-upload" aria-label={`Отправка: ${group.activeCount > 0 ? `${formatByteString(group.traffic.uploadBytesPerSecond)}/с` : 'соединение завершено'}`}>
|
||||||
|
{group.activeCount > 0 ? `${formatByteString(group.traffic.uploadBytesPerSecond)}/с` : '—'}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
<TrafficReveal open={expanded} id={detailsId}>
|
||||||
|
<dl className="client-traffic-details">
|
||||||
|
<div><dt>Состояние</dt><dd>{groupStatus(group)}</dd></div>
|
||||||
|
<div><dt>Маршрут</dt><dd>{routeLabels[group.route.kind]}</dd></div>
|
||||||
|
<div><dt>Скачано</dt><dd>{formatByteString(group.traffic.downloadBytes)}</dd></div>
|
||||||
|
<div><dt>Отправлено</dt><dd>{formatByteString(group.traffic.uploadBytes)}</dd></div>
|
||||||
|
{group.origins.length > 1 && group.origins.map((origin) => <div
|
||||||
|
className="client-traffic-origin-breakdown"
|
||||||
|
key={origin.id}
|
||||||
|
>
|
||||||
|
<dt>{origin.label}</dt>
|
||||||
|
<dd>
|
||||||
|
Скачано {formatByteString(origin.traffic.downloadBytes)} · отправлено {formatByteString(origin.traffic.uploadBytes)}
|
||||||
|
</dd>
|
||||||
|
</div>)}
|
||||||
|
<div><dt>Источник</dt><dd>{source}</dd></div>
|
||||||
|
<div><dt>Назначение</dt><dd>{groupDestination(group)}</dd></div>
|
||||||
|
<div><dt>Правило</dt><dd>{group.route.rule || '—'}</dd></div>
|
||||||
|
<div><dt>Цепочка</dt><dd>{chain}</dd></div>
|
||||||
|
</dl>
|
||||||
|
</TrafficReveal>
|
||||||
|
</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function TrafficState({ feature }: { feature: TrafficFeature }) {
|
||||||
|
const { snapshot, requestState } = feature;
|
||||||
|
const sourceState = snapshot?.source.state;
|
||||||
|
if (!snapshot && (requestState === 'idle' || requestState === 'loading')) {
|
||||||
|
return <div className="client-traffic-skeleton" role="status" aria-label="Загружаем трафик">
|
||||||
|
{[0, 1, 2, 3].map((item) => <span key={item} />)}
|
||||||
|
</div>;
|
||||||
|
}
|
||||||
|
if (!snapshot) return <p className="client-traffic-state" role="status">Инспектор трафика временно недоступен.</p>;
|
||||||
|
if (sourceState === 'disabled') {
|
||||||
|
return <p className="client-traffic-state" role="status">{feature.isGateway
|
||||||
|
? 'Инспектор трафика выключен в настройках Harbor Gateway.'
|
||||||
|
: 'Инспектор трафика выключен в настройках Harbor Connect.'}</p>;
|
||||||
|
}
|
||||||
|
if (sourceState === 'incompatible') {
|
||||||
|
return <p className="client-traffic-state" role="status">Эта версия sing-box не поддерживает инспектор трафика.</p>;
|
||||||
|
}
|
||||||
|
if (sourceState === 'stopped') {
|
||||||
|
return <p className="client-traffic-state" role="status">VPN остановлен. Данные появятся после запуска.</p>;
|
||||||
|
}
|
||||||
|
if (sourceState === 'connecting' && snapshot.connections.length === 0) {
|
||||||
|
return <p className="client-traffic-state" role="status">Подключаем инспектор трафика…</p>;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TrafficPanel({ feature }: { feature: TrafficFeature }) {
|
||||||
|
const [query, setQuery] = useState('');
|
||||||
|
const [deviceQuery, setDeviceQuery] = useState('');
|
||||||
|
const [selectedOriginId, setSelectedOriginId] = useState('');
|
||||||
|
const [settingsPending, setSettingsPending] = useState(false);
|
||||||
|
const [routeFilter, setRouteFilter] = useState<TrafficRouteFilter>('all');
|
||||||
|
const [qualityFilter, setQualityFilter] = useState<TrafficQualityFilter>('all');
|
||||||
|
const [displayedGroups, setDisplayedGroups] = useState<DisplayedTrafficGroup[]>([]);
|
||||||
|
const [reducedMotion, setReducedMotion] = useState(() => (
|
||||||
|
matchMedia('(prefers-reduced-motion: reduce)').matches
|
||||||
|
));
|
||||||
|
const [expandedId, setExpandedId] = useState('');
|
||||||
|
const snapshot = feature.snapshot;
|
||||||
|
const { grouping, sort: sortMode, retentionSeconds } = feature.settings;
|
||||||
|
const ordering = JSON.stringify([query, selectedOriginId, routeFilter, qualityFilter, grouping, sortMode]);
|
||||||
|
const previousOrdering = useRef(ordering);
|
||||||
|
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 filteredConnections = useMemo(() => retainedConnections.filter((connection) => (
|
||||||
|
trafficConnectionMatchesFilters(connection, routeFilter, qualityFilter)
|
||||||
|
)), [retainedConnections, routeFilter, qualityFilter]);
|
||||||
|
const origins = useMemo(() => summarizeTrafficOrigins(filteredConnections), [filteredConnections]);
|
||||||
|
const matchingOrigins = useMemo(() => {
|
||||||
|
const needle = deviceQuery.trim().toLocaleLowerCase('ru-RU');
|
||||||
|
return needle ? origins.filter((origin) => (
|
||||||
|
[origin.label, origin.ip].some((value) => String(value || '').toLocaleLowerCase('ru-RU').includes(needle))
|
||||||
|
)) : origins;
|
||||||
|
}, [origins, deviceQuery]);
|
||||||
|
const selectedConnections = useMemo(() => selectedOriginId
|
||||||
|
? filteredConnections.filter((connection) => trafficOriginId(connection) === selectedOriginId)
|
||||||
|
: filteredConnections, [filteredConnections, selectedOriginId]);
|
||||||
|
const trafficGroups = useMemo(() => (
|
||||||
|
groupTrafficConnections(selectedConnections, grouping)
|
||||||
|
), [selectedConnections, grouping]);
|
||||||
|
const groups = useMemo(() => sortTrafficGroups(trafficGroups.filter((group) => (
|
||||||
|
trafficGroupMatches(group, query, 'all', 'all')
|
||||||
|
)), sortMode), [trafficGroups, query, sortMode]);
|
||||||
|
|
||||||
|
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 : '');
|
||||||
|
}
|
||||||
|
const reorder = previousOrdering.current !== ordering;
|
||||||
|
previousOrdering.current = ordering;
|
||||||
|
setDisplayedGroups((current) => reconcileTrafficGroups(current, groups, immediate, !reorder));
|
||||||
|
}, [groups, reducedMotion, snapshot, sourceState, ordering]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (selectedOriginId && !origins.some(({ id }) => id === selectedOriginId)) {
|
||||||
|
setSelectedOriginId('');
|
||||||
|
}
|
||||||
|
}, [origins, selectedOriginId]);
|
||||||
|
|
||||||
|
async function saveSettings(settings: TrafficSettings) {
|
||||||
|
if (settingsPending) return;
|
||||||
|
setSettingsPending(true);
|
||||||
|
try {
|
||||||
|
await feature.updateSettings(settings);
|
||||||
|
} finally {
|
||||||
|
setSettingsPending(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function finishExit(id: string) {
|
||||||
|
if (groups.some((group) => group.id === id)) return;
|
||||||
|
setDisplayedGroups((current) => current.filter((row) => (
|
||||||
|
row.group.id !== id || !row.exiting
|
||||||
|
)));
|
||||||
|
setExpandedId((current) => current === id ? '' : current);
|
||||||
|
}
|
||||||
|
|
||||||
|
const canShowList = snapshot
|
||||||
|
&& !['disabled', 'incompatible', 'stopped'].includes(sourceState || '')
|
||||||
|
&& (sourceState !== 'connecting' || retainedConnections.length > 0);
|
||||||
|
const stale = feature.requestState === 'error' || sourceState === 'stale';
|
||||||
|
const degraded = sourceState === 'degraded';
|
||||||
|
|
||||||
|
return <Drawer
|
||||||
|
panelRef={feature.panelRef}
|
||||||
|
closeRef={feature.closeRef}
|
||||||
|
id="client-traffic"
|
||||||
|
className="client-traffic"
|
||||||
|
sheetClassName="client-traffic-sheet"
|
||||||
|
open={feature.isOpen}
|
||||||
|
labelledBy="client-traffic-title"
|
||||||
|
closeLabel="Закрыть трафик"
|
||||||
|
closeText="Закрыть"
|
||||||
|
onClose={feature.close}
|
||||||
|
>
|
||||||
|
<header className="client-traffic-header">
|
||||||
|
<h2 id="client-traffic-title">Трафик</h2>
|
||||||
|
<button type="button" className="client-traffic-pause" aria-pressed={feature.paused}
|
||||||
|
onClick={feature.togglePause}
|
||||||
|
>{feature.paused ? 'Продолжить' : 'Пауза'}</button>
|
||||||
|
</header>
|
||||||
|
<div className="client-traffic-tabs" role="group" aria-label="Режим трафика" data-view={feature.view}>
|
||||||
|
<button type="button" aria-pressed={feature.view === 'live'} onClick={() => feature.setView('live')}>Сейчас</button>
|
||||||
|
<button type="button" aria-pressed={feature.view === 'history'} onClick={() => feature.setView('history')}>История</button>
|
||||||
|
</div>
|
||||||
|
<div className="client-traffic-views">
|
||||||
|
<section className={`client-traffic-view${feature.view === 'live' ? ' is-active' : ''}`}
|
||||||
|
aria-label="Сейчас" aria-hidden={feature.view !== 'live'} inert={feature.view !== 'live' || undefined}
|
||||||
|
>
|
||||||
|
<div className="client-traffic-toolbar">
|
||||||
|
<label className="client-traffic-search">
|
||||||
|
<span className="client-live-region">Поиск соединений</span>
|
||||||
|
<input type="search" value={query} aria-label="Найти сайт, IP или сервис" placeholder="Найти сайт"
|
||||||
|
onChange={(event) => setQuery(event.target.value)} />
|
||||||
|
</label>
|
||||||
|
{feature.isGateway && <TrafficMenu
|
||||||
|
label={origins.find((origin) => origin.id === selectedOriginId)?.label || 'Все устройства'}
|
||||||
|
active={Boolean(selectedOriginId)} closeOnSelect
|
||||||
|
>
|
||||||
|
<label className="client-traffic-search">
|
||||||
|
<input type="search" value={deviceQuery} aria-label="Найти устройство" placeholder="Найти устройство"
|
||||||
|
onChange={(event) => setDeviceQuery(event.target.value)} />
|
||||||
|
</label>
|
||||||
|
<div className="client-traffic-device-list">
|
||||||
|
<button type="button" aria-pressed={!selectedOriginId} onClick={() => setSelectedOriginId('')}>Все устройства</button>
|
||||||
|
{matchingOrigins.map((origin) => <button type="button" key={origin.id}
|
||||||
|
aria-pressed={selectedOriginId === origin.id} onClick={() => setSelectedOriginId(origin.id)}
|
||||||
|
>{origin.label}</button>)}
|
||||||
|
</div>
|
||||||
|
{!matchingOrigins.length && <p className="client-traffic-notice">Устройства не найдены.</p>}
|
||||||
|
</TrafficMenu>}
|
||||||
|
<TrafficMenu label="Фильтры" active={routeFilter !== 'all' || qualityFilter !== 'all'}>
|
||||||
|
<div className="client-traffic-settings">
|
||||||
|
<div className="client-traffic-option-row">
|
||||||
|
<span>Маршрут</span>
|
||||||
|
<div className="client-traffic-filters" role="group" aria-label="Фильтр по маршруту">
|
||||||
|
{([
|
||||||
|
['all', 'Все'],
|
||||||
|
['vpn', 'VPN'],
|
||||||
|
['direct', 'Direct'],
|
||||||
|
['other', 'Другое'],
|
||||||
|
] as const).map(([value, label]) => <button
|
||||||
|
type="button"
|
||||||
|
key={value}
|
||||||
|
aria-pressed={routeFilter === value}
|
||||||
|
onClick={() => setRouteFilter(value)}
|
||||||
|
>{label}</button>)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="client-traffic-option-row">
|
||||||
|
<span>Распознано</span>
|
||||||
|
<div className="client-traffic-filters" role="group" aria-label="Фильтр по качеству распознавания">
|
||||||
|
{([
|
||||||
|
['all', 'Все'],
|
||||||
|
['recognized', 'Распознано'],
|
||||||
|
['attention', 'Требует внимания'],
|
||||||
|
] as const).map(([value, label]) => <button
|
||||||
|
type="button"
|
||||||
|
key={value}
|
||||||
|
aria-pressed={qualityFilter === value}
|
||||||
|
onClick={() => setQualityFilter(value)}
|
||||||
|
>{label}</button>)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="client-traffic-option-row">
|
||||||
|
<span>Группировка</span>
|
||||||
|
<div className="client-traffic-filters" role="group" aria-label="Группировка соединений">
|
||||||
|
{([
|
||||||
|
['site', 'По сайтам'],
|
||||||
|
['device', 'По устройствам'],
|
||||||
|
] as const).map(([value, label]) => <button
|
||||||
|
type="button"
|
||||||
|
key={value}
|
||||||
|
disabled={settingsPending}
|
||||||
|
aria-pressed={grouping === value}
|
||||||
|
onClick={() => void saveSettings({ ...feature.settings, grouping: value })}
|
||||||
|
>{label}</button>)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="client-traffic-option-row">
|
||||||
|
<span>Сортировка</span>
|
||||||
|
<div className="client-traffic-filters" role="group" aria-label="Сортировка соединений">
|
||||||
|
{([
|
||||||
|
['popular', 'Популярные'],
|
||||||
|
['recent', 'Последние'],
|
||||||
|
] as const).map(([value, label]) => <button
|
||||||
|
type="button"
|
||||||
|
key={value}
|
||||||
|
disabled={settingsPending}
|
||||||
|
aria-pressed={sortMode === value}
|
||||||
|
onClick={() => void saveSettings({ ...feature.settings, sort: value })}
|
||||||
|
>{label}</button>)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="client-traffic-option-row">
|
||||||
|
<span>Завершённые</span>
|
||||||
|
<div className="client-traffic-filters" role="group" aria-label="Время показа завершённых соединений">
|
||||||
|
{TRAFFIC_RETENTION_OPTIONS.map((seconds) => <button
|
||||||
|
type="button"
|
||||||
|
key={seconds}
|
||||||
|
disabled={settingsPending}
|
||||||
|
aria-pressed={retentionSeconds === seconds}
|
||||||
|
onClick={() => void saveSettings({ ...feature.settings, retentionSeconds: seconds })}
|
||||||
|
>{seconds} с</button>)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{snapshot && <div className="client-traffic-summary">
|
||||||
|
<span>Распознано: {snapshot.summary.recognized}</span>
|
||||||
|
<span>Без домена: {snapshot.summary.unresolved}</span>
|
||||||
|
<time dateTime={snapshot.observedAt || undefined}>{updatedAt(snapshot.observedAt)}</time>
|
||||||
|
</div>}
|
||||||
|
<p className="client-traffic-honesty">{feature.isGateway
|
||||||
|
? 'Только соединения, прошедшие через sing-box Gateway. Трафик, обходящий sing-box напрямую, здесь не виден.'
|
||||||
|
: 'Только трафик через Harbor Connect. Приложения macOS недоступны внутри Docker.'}</p>
|
||||||
|
</TrafficMenu>
|
||||||
|
</div>
|
||||||
|
<TrafficColumns />
|
||||||
|
<div className="client-traffic-scroll">
|
||||||
|
{feature.paused && snapshot && <p className="client-traffic-notice" role="status">
|
||||||
|
Пауза · показаны данные на {updatedAt(snapshot.observedAt).replace('обновлено ', '')}.
|
||||||
|
</p>}
|
||||||
|
{!feature.paused && stale && snapshot && <p className="client-traffic-notice is-warning" role="status">
|
||||||
|
Не удалось обновить. Показан последний полученный снимок.
|
||||||
|
</p>}
|
||||||
|
{!feature.paused && !stale && snapshot && sourceState === 'connecting' && snapshot.connections.length > 0 && <p
|
||||||
|
className="client-traffic-notice"
|
||||||
|
role="status"
|
||||||
|
>
|
||||||
|
Инспектор переподключается. Показан последний полученный снимок.
|
||||||
|
</p>}
|
||||||
|
{degraded && snapshot && <p className="client-traffic-notice is-warning" role="status">
|
||||||
|
Часть трафика не распознана.
|
||||||
|
</p>}
|
||||||
|
|
||||||
|
<TrafficState feature={feature} />
|
||||||
|
|
||||||
|
{canShowList && retainedConnections.length === 0 && displayedGroups.length === 0 && <p className="client-traffic-state">
|
||||||
|
Активных соединений пока нет.
|
||||||
|
</p>}
|
||||||
|
{canShowList && retainedConnections.length > 0 && groups.length === 0 && displayedGroups.length === 0 && <p className="client-traffic-state">
|
||||||
|
По выбранным фильтрам ничего не найдено.
|
||||||
|
</p>}
|
||||||
|
{canShowList && displayedGroups.length > 0 && <div
|
||||||
|
className="client-traffic-list"
|
||||||
|
role="list"
|
||||||
|
aria-label="Группы активных и недавно завершённых соединений"
|
||||||
|
aria-busy={feature.requestState === 'loading'}
|
||||||
|
>
|
||||||
|
{displayedGroups.map((row) => <TrafficGroupRow
|
||||||
|
key={row.group.id}
|
||||||
|
group={row.group}
|
||||||
|
expanded={expandedId === row.group.id}
|
||||||
|
exiting={row.exiting}
|
||||||
|
onExited={() => finishExit(row.group.id)}
|
||||||
|
onToggle={() => setExpandedId((current) => current === row.group.id ? '' : row.group.id)}
|
||||||
|
/>)}
|
||||||
|
</div>}
|
||||||
|
|
||||||
|
{snapshot?.summary.truncated && <p className="client-traffic-truncated" role="status">
|
||||||
|
Показано не более 256 соединений.
|
||||||
|
</p>}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<section className={`client-traffic-view${feature.view === 'history' ? ' is-active' : ''}`}
|
||||||
|
aria-label="История" aria-hidden={feature.view !== 'history'} inert={feature.view !== 'history' || undefined}
|
||||||
|
>
|
||||||
|
<TrafficHistoryPanel active={feature.isOpen && !feature.paused && feature.view === 'history'}
|
||||||
|
paused={feature.paused} isGateway={feature.isGateway} load={feature.loadHistory} />
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</Drawer>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,177 @@
|
|||||||
|
import { useEffect, useId, useMemo, useState } from 'react';
|
||||||
|
import {
|
||||||
|
assertTrafficHistorySnapshot,
|
||||||
|
parseTrafficHistoryQuery,
|
||||||
|
type TrafficHistoryLevel,
|
||||||
|
type TrafficHistoryQuery,
|
||||||
|
type TrafficHistorySnapshot,
|
||||||
|
} from '../../../shared/trafficHistory.js';
|
||||||
|
import { formatByteString } from '../../utils/format.js';
|
||||||
|
import { TrafficColumns, TrafficMenu, TrafficReveal } from './TrafficControls.js';
|
||||||
|
|
||||||
|
export type LoadTrafficHistory = (query: TrafficHistoryQuery, signal?: AbortSignal) => Promise<unknown>;
|
||||||
|
const nextLevel: Record<TrafficHistoryLevel, TrafficHistoryLevel | null> = {
|
||||||
|
service: 'domain', domain: 'hostname', hostname: 'ip', ip: null,
|
||||||
|
};
|
||||||
|
const levelLabels = { service: 'Сервис', domain: 'Домен', hostname: 'Полное имя', ip: 'IP' };
|
||||||
|
|
||||||
|
function useHistory(active: boolean, query: TrafficHistoryQuery, load: LoadTrafficHistory) {
|
||||||
|
const key = JSON.stringify(query);
|
||||||
|
const [result, setResult] = useState<{ key: string; snapshot: TrafficHistorySnapshot | null; status: 'loading' | 'ready' | 'error' }>({
|
||||||
|
key, snapshot: null, status: 'loading',
|
||||||
|
});
|
||||||
|
useEffect(() => {
|
||||||
|
if (!active) return;
|
||||||
|
const controller = new AbortController();
|
||||||
|
let timer: ReturnType<typeof setTimeout>;
|
||||||
|
const poll = async () => {
|
||||||
|
try {
|
||||||
|
const snapshot = assertTrafficHistorySnapshot(await load(JSON.parse(key) as TrafficHistoryQuery, controller.signal));
|
||||||
|
if (snapshot.storage.status === 'error') throw new Error('History unavailable');
|
||||||
|
if (!controller.signal.aborted) setResult({ key, snapshot, status: 'ready' });
|
||||||
|
} catch {
|
||||||
|
if (!controller.signal.aborted) setResult((previous) => ({
|
||||||
|
key, snapshot: previous.key === key ? previous.snapshot : null, status: 'error',
|
||||||
|
}));
|
||||||
|
} finally {
|
||||||
|
if (!controller.signal.aborted) timer = setTimeout(poll, 15_000);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
timer = setTimeout(poll, 200);
|
||||||
|
return () => { controller.abort(); clearTimeout(timer); };
|
||||||
|
}, [active, key, load]);
|
||||||
|
return result.key === key ? result : { key, snapshot: null, status: 'loading' as const };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function HistoryRows({ snapshot, active, load }: { snapshot: TrafficHistorySnapshot; active: boolean; load: LoadTrafficHistory }) {
|
||||||
|
const [expanded, setExpanded] = useState<string | null>(null);
|
||||||
|
const detailsId = useId();
|
||||||
|
const { query } = snapshot;
|
||||||
|
const childLevel = nextLevel[query.level];
|
||||||
|
return <div className="client-traffic-list" role="list" aria-label={`История: ${levelLabels[query.level]}`}>
|
||||||
|
{snapshot.rows.map((row, index) => <div className="client-traffic-history-row" role="listitem" key={row.key}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="client-traffic-connection-summary"
|
||||||
|
aria-expanded={childLevel ? expanded === row.key : undefined}
|
||||||
|
aria-controls={childLevel ? `${detailsId}-${index}` : undefined}
|
||||||
|
disabled={!childLevel}
|
||||||
|
onClick={() => setExpanded((current) => current === row.key ? null : row.key)}
|
||||||
|
>
|
||||||
|
<span className="client-traffic-identity"><strong title={row.label}>{row.label}</strong></span>
|
||||||
|
<span className="client-traffic-download" aria-label={`Скачивание: ${formatByteString(row.downloadBytes)}`}>{formatByteString(row.downloadBytes)}</span>
|
||||||
|
<span className="client-traffic-upload" aria-label={`Отправка: ${formatByteString(row.uploadBytes)}`}>{formatByteString(row.uploadBytes)}</span>
|
||||||
|
</button>
|
||||||
|
{childLevel && <TrafficReveal open={expanded === row.key} id={`${detailsId}-${index}`} className="client-traffic-history-children">
|
||||||
|
<HistoryBranch key={row.key} active={active && expanded === row.key} load={load} query={{
|
||||||
|
...query, level: childLevel, [query.level]: row.key, offset: 0,
|
||||||
|
}} />
|
||||||
|
</TrafficReveal>}
|
||||||
|
</div>)}
|
||||||
|
</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function HistoryBranch({ active, query, load }: { active: boolean; query: TrafficHistoryQuery; load: LoadTrafficHistory }) {
|
||||||
|
const [offset, setOffset] = useState(0);
|
||||||
|
const requested = useMemo(() => ({ ...query, offset }), [query, offset]);
|
||||||
|
const result = useHistory(active, requested, load);
|
||||||
|
return <>
|
||||||
|
{result.status !== 'ready' && <p className="client-traffic-history-status" role="status">
|
||||||
|
{result.status === 'loading' ? 'Загружаем…' : 'Данные временно недоступны.'}
|
||||||
|
</p>}
|
||||||
|
{result.snapshot && <>
|
||||||
|
<HistoryRows snapshot={result.snapshot} active={active} load={load} />
|
||||||
|
<HistoryPages snapshot={result.snapshot} offset={offset} change={setOffset} />
|
||||||
|
</>}
|
||||||
|
</>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function HistoryPages({ snapshot, offset, change }: { snapshot: TrafficHistorySnapshot; offset: number; change: (offset: number) => void }) {
|
||||||
|
if (!offset && snapshot.nextOffset === null) return null;
|
||||||
|
return <div className="client-traffic-filters" role="group" aria-label="Страницы истории">
|
||||||
|
<button type="button" disabled={!offset} onClick={() => change(Math.max(0, offset - 100))}>Назад</button>
|
||||||
|
<button type="button" disabled={snapshot.nextOffset === null} onClick={() => change(snapshot.nextOffset!)}>Далее</button>
|
||||||
|
</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TrafficHistoryPanel({ active, paused = false, isGateway, load }: {
|
||||||
|
active: boolean; paused?: boolean; isGateway: boolean; load: LoadTrafficHistory;
|
||||||
|
}) {
|
||||||
|
const [query, setQuery] = useState(() => parseTrafficHistoryQuery(new URLSearchParams()));
|
||||||
|
const [deviceSearch, setDeviceSearch] = useState('');
|
||||||
|
const result = useHistory(active, query, load);
|
||||||
|
const snapshot = result.snapshot;
|
||||||
|
const origins = (snapshot?.origins || []).filter((origin) => origin.label.toLocaleLowerCase('ru').includes(deviceSearch.toLocaleLowerCase('ru')));
|
||||||
|
function change(patch: Partial<TrafficHistoryQuery>) { setQuery((current) => ({ ...current, ...patch, offset: 0, until: null })); }
|
||||||
|
const status = paused ? 'История на паузе.'
|
||||||
|
: result.status === 'loading' ? 'Загружаем историю…'
|
||||||
|
: result.status === 'error' ? `История временно недоступна.${snapshot ? ' Показаны последние данные.' : ''}`
|
||||||
|
: snapshot?.source === 'disabled' ? 'Сбор выключен. Показана сохранённая история.'
|
||||||
|
: snapshot?.source === 'stopped' ? 'VPN остановлен. Показана сохранённая история.'
|
||||||
|
: snapshot?.source === 'connecting' ? 'Сборщик подключается. Показана сохранённая история.'
|
||||||
|
: snapshot?.coverage.partial ? 'В периоде есть пропуски.' : '';
|
||||||
|
return <>
|
||||||
|
<div className="client-traffic-toolbar">
|
||||||
|
<div className="client-traffic-filters client-traffic-periods" role="group" aria-label="Период истории">
|
||||||
|
{([['24h', '24 часа'], ['7d', '7 дней'], ['30d', '30 дней'], ['90d', '90 дней']] as const).map(([value, label]) => <button
|
||||||
|
type="button" key={value} aria-pressed={query.range === value} onClick={() => change({ range: value })}
|
||||||
|
>{label}</button>)}
|
||||||
|
</div>
|
||||||
|
{isGateway && <TrafficMenu
|
||||||
|
label={snapshot?.origins.find((origin) => origin.id === query.originId)?.label || (query.originId ? 'Выбрано устройство' : 'Все устройства')}
|
||||||
|
active={Boolean(query.originId)} closeOnSelect
|
||||||
|
>
|
||||||
|
<label className="client-traffic-search">
|
||||||
|
<input type="search" value={deviceSearch} aria-label="Найти устройство в истории" placeholder="Найти устройство"
|
||||||
|
onChange={(event) => setDeviceSearch(event.target.value)} />
|
||||||
|
</label>
|
||||||
|
<div className="client-traffic-device-list">
|
||||||
|
<button type="button" aria-pressed={!query.originId} onClick={() => change({ originId: '' })}>Все устройства</button>
|
||||||
|
{origins.map((origin) => <button type="button" key={origin.id} aria-pressed={query.originId === origin.id}
|
||||||
|
onClick={() => change({ originId: origin.id })}
|
||||||
|
>{origin.label}</button>)}
|
||||||
|
</div>
|
||||||
|
{!origins.length && <p className="client-traffic-notice">Устройства не найдены.</p>}
|
||||||
|
{snapshot?.originsTruncated && <p className="client-traffic-notice">Показаны первые 256 устройств.</p>}
|
||||||
|
</TrafficMenu>}
|
||||||
|
<TrafficMenu label="Фильтры" active={Boolean(query.search) || query.route !== 'all'}>
|
||||||
|
<label className="client-traffic-search">
|
||||||
|
<input type="search" value={query.search} maxLength={200} aria-label="Найти сайт, IP или сервис в истории" placeholder="Найти сайт"
|
||||||
|
onChange={(event) => change({ search: event.target.value })} />
|
||||||
|
</label>
|
||||||
|
<div className="client-traffic-option-row"><span>Маршрут</span>
|
||||||
|
<div className="client-traffic-filters" role="group" aria-label="Маршрут в истории">
|
||||||
|
{([['all', 'Все'], ['vpn', 'VPN'], ['direct', 'Direct'], ['other', 'Другое']] as const).map(([value, label]) => <button
|
||||||
|
type="button" key={value} aria-pressed={query.route === value} onClick={() => change({ route: value })}
|
||||||
|
>{label}</button>)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{snapshot && <>
|
||||||
|
<div className="client-traffic-summary" aria-label="Расход за выбранный период">
|
||||||
|
<span>Скачано {formatByteString(snapshot.totals.downloadBytes)}</span>
|
||||||
|
<span>Отправлено {formatByteString(snapshot.totals.uploadBytes)}</span>
|
||||||
|
</div>
|
||||||
|
<p className="client-traffic-honesty">
|
||||||
|
{`Данные до ${new Date(snapshot.period.to).toLocaleString('ru-RU')}.`}
|
||||||
|
{snapshot.period.availableFrom && ` Сбор с ${new Date(snapshot.period.availableFrom).toLocaleString('ru-RU')}.`}
|
||||||
|
{snapshot.coverage.lastObservedAt && ` Обновлено ${new Date(snapshot.coverage.lastObservedAt).toLocaleString('ru-RU')}.`}
|
||||||
|
{' '}IP без домена не означает распознанный сайт.
|
||||||
|
</p>
|
||||||
|
</>}
|
||||||
|
</TrafficMenu>
|
||||||
|
</div>
|
||||||
|
<TrafficColumns />
|
||||||
|
<div className="client-traffic-scroll">
|
||||||
|
{status && <p className="client-traffic-history-status" role="status">{status}
|
||||||
|
{snapshot && (paused || result.status === 'error') && ` Обновлено ${new Date(snapshot.generatedAt).toLocaleTimeString('ru-RU')}.`}
|
||||||
|
</p>}
|
||||||
|
{snapshot && <>
|
||||||
|
{snapshot.rows.length ? <HistoryRows key={JSON.stringify(query)} snapshot={snapshot} active={active} load={load} />
|
||||||
|
: <p className="client-traffic-state">{query.search || query.originId || query.route !== 'all'
|
||||||
|
? 'По выбранным фильтрам ничего не найдено.' : 'История пока пуста.'}</p>}
|
||||||
|
<HistoryPages snapshot={snapshot} offset={query.offset}
|
||||||
|
change={(offset) => setQuery((current) => ({ ...current, offset, until: snapshot.query.until }))} />
|
||||||
|
</>}
|
||||||
|
</div>
|
||||||
|
</>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
export {
|
||||||
|
TrafficPanel,
|
||||||
|
TrafficToggle,
|
||||||
|
useTrafficFeature,
|
||||||
|
type TrafficFeature,
|
||||||
|
} from './TrafficFeature.js';
|
||||||
|
export type { LoadTrafficHistory } from './TrafficHistoryPanel.js';
|
||||||
@@ -0,0 +1,235 @@
|
|||||||
|
import type { LiveTrafficConnection } from '../../../shared/liveTraffic.js';
|
||||||
|
import type { TrafficGrouping, TrafficSort } from '../../../shared/trafficSettings.js';
|
||||||
|
import { byteString } from '../../utils/format.js';
|
||||||
|
|
||||||
|
export type TrafficRouteFilter = 'all' | 'vpn' | 'direct' | 'other';
|
||||||
|
export type TrafficQualityFilter = 'all' | 'recognized' | 'attention';
|
||||||
|
export type { TrafficSort } from '../../../shared/trafficSettings.js';
|
||||||
|
|
||||||
|
export interface TrafficOriginSummary {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
ip: string | null;
|
||||||
|
kind: LiveTrafficConnection['origin']['kind'];
|
||||||
|
connections: number;
|
||||||
|
traffic: Pick<LiveTrafficConnection['traffic'], 'uploadBytes' | 'downloadBytes'>;
|
||||||
|
}
|
||||||
|
|
||||||
|
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[];
|
||||||
|
origins: TrafficOriginSummary[];
|
||||||
|
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 };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function trafficOriginId(connection: LiveTrafficConnection) {
|
||||||
|
return connection.origin.id || `${connection.origin.kind}:${connection.source.ip || 'unknown'}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function trafficGroupId(connection: LiveTrafficConnection, grouping: TrafficGrouping) {
|
||||||
|
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,
|
||||||
|
grouping === 'device'
|
||||||
|
? [connection.origin.kind, connection.origin.id, connection.origin.label, connection.origin.provenance]
|
||||||
|
: null,
|
||||||
|
[
|
||||||
|
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();
|
||||||
|
const origins = new Map<string, LiveTrafficConnection[]>();
|
||||||
|
for (const connection of connections) {
|
||||||
|
const originId = trafficOriginId(connection);
|
||||||
|
const members = origins.get(originId);
|
||||||
|
if (members) members.push(connection);
|
||||||
|
else origins.set(originId, [connection]);
|
||||||
|
}
|
||||||
|
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)),
|
||||||
|
origins: [...origins.entries()].map(([originId, members]) => ({
|
||||||
|
id: originId,
|
||||||
|
label: members[0].origin.label,
|
||||||
|
ip: members[0].source.ip || null,
|
||||||
|
kind: members[0].origin.kind,
|
||||||
|
connections: members.length,
|
||||||
|
traffic: {
|
||||||
|
uploadBytes: sum('uploadBytes', members),
|
||||||
|
downloadBytes: sum('downloadBytes', members),
|
||||||
|
},
|
||||||
|
})).sort((left, right) => (
|
||||||
|
right.connections - left.connections || left.label.localeCompare(right.label)
|
||||||
|
)),
|
||||||
|
traffic: {
|
||||||
|
uploadBytes: sum('uploadBytes'),
|
||||||
|
downloadBytes: sum('downloadBytes'),
|
||||||
|
uploadBytesPerSecond: sum('uploadBytesPerSecond', active),
|
||||||
|
downloadBytesPerSecond: sum('downloadBytesPerSecond', active),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function groupTrafficConnections(
|
||||||
|
connections: LiveTrafficConnection[],
|
||||||
|
grouping: TrafficGrouping = 'device',
|
||||||
|
) {
|
||||||
|
const grouped = new Map<string, LiveTrafficConnection[]>();
|
||||||
|
for (const connection of connections) {
|
||||||
|
const id = trafficGroupId(connection, grouping);
|
||||||
|
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 summarizeTrafficOrigins(connections: LiveTrafficConnection[]): TrafficOriginSummary[] {
|
||||||
|
const groups = new Map<string, LiveTrafficConnection[]>();
|
||||||
|
for (const connection of connections) {
|
||||||
|
const id = trafficOriginId(connection);
|
||||||
|
const members = groups.get(id);
|
||||||
|
if (members) members.push(connection);
|
||||||
|
else groups.set(id, [connection]);
|
||||||
|
}
|
||||||
|
return [...groups.entries()].map(([id, members]) => ({
|
||||||
|
id,
|
||||||
|
label: members[0].origin.label,
|
||||||
|
ip: members[0].source.ip || null,
|
||||||
|
kind: members[0].origin.kind,
|
||||||
|
connections: members.length,
|
||||||
|
traffic: {
|
||||||
|
uploadBytes: members.reduce((total, item) => total + byteString(item.traffic.uploadBytes), 0n).toString(),
|
||||||
|
downloadBytes: members.reduce((total, item) => total + byteString(item.traffic.downloadBytes), 0n).toString(),
|
||||||
|
},
|
||||||
|
})).sort((left, right) => (
|
||||||
|
right.connections - left.connections || left.label.localeCompare(right.label) || left.id.localeCompare(right.id)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function trafficConnectionMatchesFilters(
|
||||||
|
connection: LiveTrafficConnection,
|
||||||
|
route: TrafficRouteFilter,
|
||||||
|
quality: TrafficQualityFilter,
|
||||||
|
) {
|
||||||
|
if (route !== 'all' && connection.route.kind !== route) return false;
|
||||||
|
const recognized = connection.destination.domain !== null;
|
||||||
|
return quality === 'all'
|
||||||
|
|| (quality === 'recognized' && recognized)
|
||||||
|
|| (quality === 'attention' && !recognized);
|
||||||
|
}
|
||||||
|
|
||||||
|
function newestStartedAt(group: TrafficConnectionGroup) {
|
||||||
|
return group.connections.reduce((latest, connection) => (
|
||||||
|
connection.startedAt > latest ? connection.startedAt : latest
|
||||||
|
), '');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sortTrafficGroups(groups: TrafficConnectionGroup[], mode: TrafficSort) {
|
||||||
|
return [...groups].sort((left, right) => {
|
||||||
|
const frequency = right.connections.length - left.connections.length;
|
||||||
|
const recent = newestStartedAt(right).localeCompare(newestStartedAt(left));
|
||||||
|
const primary = mode === 'popular' ? frequency || recent : recent || frequency;
|
||||||
|
if (primary) return primary;
|
||||||
|
return left.id < right.id ? -1 : left.id > right.id ? 1 : 0;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
preserveOrder = false,
|
||||||
|
) {
|
||||||
|
const next = desired.map((group) => ({ group, exiting: false }));
|
||||||
|
if (preserveOrder) {
|
||||||
|
const remaining = new Map(next.map((row) => [row.group.id, row]));
|
||||||
|
const retained = current.flatMap((row) => {
|
||||||
|
const updated = remaining.get(row.group.id);
|
||||||
|
remaining.delete(row.group.id);
|
||||||
|
return updated ? [updated] : immediate ? [] : [{ ...row, exiting: true }];
|
||||||
|
});
|
||||||
|
return [...retained, ...remaining.values()];
|
||||||
|
}
|
||||||
|
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;
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
export type OperationKey = 'connection' | 'serverApply' | 'profileAdd' | 'profileRename'
|
export type OperationKey = 'connection' | 'serverApply' | 'profileAdd' | 'profileRename'
|
||||||
| 'profileSelect' | 'profileActivate' | 'profileRefresh' | 'profileDelete'
|
| 'profileSelect' | 'profileActivate' | 'profileRefresh' | 'profileDelete'
|
||||||
| 'gatewayAuto' | 'routeRules' | 'diagnosticsSettings' | 'failover';
|
| 'gatewayAuto' | 'routeRules' | 'diagnosticsSettings' | 'trafficSettings' | 'failover';
|
||||||
|
|
||||||
export interface OperationState {
|
export interface OperationState {
|
||||||
status: 'running';
|
status: 'running';
|
||||||
@@ -22,6 +22,7 @@ const OPERATION_KEYS: readonly OperationKey[] = [
|
|||||||
'gatewayAuto',
|
'gatewayAuto',
|
||||||
'routeRules',
|
'routeRules',
|
||||||
'diagnosticsSettings',
|
'diagnosticsSettings',
|
||||||
|
'trafficSettings',
|
||||||
'failover',
|
'failover',
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -426,6 +426,205 @@
|
|||||||
padding-inline: 8px;
|
padding-inline: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.client-dns-section {
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-dns-title-row > .client-diagnostics-refresh-wrap {
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-dns-domain-row {
|
||||||
|
min-width: 0;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: auto minmax(0, 1fr) auto 28px;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
min-height: 38px;
|
||||||
|
padding-inline: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-dns-domain-row label,
|
||||||
|
.client-dns-domain-row button,
|
||||||
|
.client-dns-domain-row select,
|
||||||
|
.client-dns-inline-form input,
|
||||||
|
.client-dns-inline-form button {
|
||||||
|
font: var(--type-label);
|
||||||
|
letter-spacing: var(--type-label-tracking);
|
||||||
|
text-transform: var(--type-label-transform);
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-dns-domain-row label {
|
||||||
|
color: var(--client-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-dns-domain-row select {
|
||||||
|
min-width: 0;
|
||||||
|
height: 34px;
|
||||||
|
border: 0;
|
||||||
|
outline: 0;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--client-text);
|
||||||
|
box-shadow: 0 1px color-mix(in oklch, var(--client-border) 48%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-dns-domain-row select:focus-visible {
|
||||||
|
box-shadow: 0 1px var(--client-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-dns-domain-row button:not(.client-row-delete),
|
||||||
|
.client-dns-inline-form button {
|
||||||
|
padding: 4px 0;
|
||||||
|
border: 0;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--client-accent);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-dns-domain-row button:disabled,
|
||||||
|
.client-dns-inline-form button:disabled {
|
||||||
|
color: var(--client-muted);
|
||||||
|
cursor: default;
|
||||||
|
opacity: 0.55;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-dns-inline-form {
|
||||||
|
min-width: 0;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(90px, 0.65fr) minmax(0, 1.35fr) auto 28px;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
min-height: 40px;
|
||||||
|
padding-inline: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-dns-inline-form input {
|
||||||
|
min-width: 0;
|
||||||
|
height: 34px;
|
||||||
|
padding: 0 2px;
|
||||||
|
border: 0;
|
||||||
|
outline: 0;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--client-text);
|
||||||
|
box-shadow: 0 1px transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-dns-inline-form input:focus {
|
||||||
|
box-shadow: 0 1px var(--client-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-dns-inline-form input::placeholder {
|
||||||
|
color: color-mix(in oklch, var(--client-muted) 70%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-dns-table {
|
||||||
|
display: grid;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-dns-header,
|
||||||
|
.client-dns-row {
|
||||||
|
min-width: 0;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 35% minmax(0, 1fr) minmax(0, 1fr) 28px;
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-dns-header {
|
||||||
|
padding: 7px 0;
|
||||||
|
color: var(--client-muted);
|
||||||
|
font: var(--type-label);
|
||||||
|
letter-spacing: var(--type-label-tracking);
|
||||||
|
text-transform: var(--type-label-transform);
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-dns-header > span,
|
||||||
|
.client-dns-row > span {
|
||||||
|
min-width: 0;
|
||||||
|
padding-inline: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-dns-row {
|
||||||
|
position: relative;
|
||||||
|
min-height: 92px;
|
||||||
|
border-top: 1px solid color-mix(in oklch, var(--client-border) 48%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-dns-row.is-running .client-dns-resolver {
|
||||||
|
color: var(--client-accent);
|
||||||
|
text-shadow: 0 0 9px color-mix(in oklch, var(--client-accent) 38%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-dns-resolver {
|
||||||
|
align-items: flex-start;
|
||||||
|
padding-block: 10px;
|
||||||
|
color: var(--client-text);
|
||||||
|
transition: color 420ms ease, text-shadow 520ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-dns-resolver > span:first-child {
|
||||||
|
display: grid;
|
||||||
|
gap: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-dns-resolver b,
|
||||||
|
.client-dns-resolver small,
|
||||||
|
.client-dns-result {
|
||||||
|
font: var(--type-label);
|
||||||
|
letter-spacing: var(--type-label-tracking);
|
||||||
|
text-transform: var(--type-label-transform);
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-dns-resolver small {
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
color: var(--client-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-dns-result {
|
||||||
|
min-height: 72px;
|
||||||
|
display: grid;
|
||||||
|
align-content: center;
|
||||||
|
gap: 5px;
|
||||||
|
padding-block: 10px;
|
||||||
|
color: var(--client-text);
|
||||||
|
font-variant-numeric: var(--numeric-tabular);
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-dns-result > span {
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-dns-result b {
|
||||||
|
display: inline-block;
|
||||||
|
min-width: 42px;
|
||||||
|
margin-right: 5px;
|
||||||
|
color: var(--client-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-dns-result small {
|
||||||
|
min-height: 1em;
|
||||||
|
color: var(--client-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-dns-result.is-running small {
|
||||||
|
color: var(--client-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-dns-result.is-error small {
|
||||||
|
color: oklch(0.68 0.15 28);
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-dns-row > .client-row-delete {
|
||||||
|
align-self: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-dns-note {
|
||||||
|
margin: 5px 8px 0;
|
||||||
|
color: var(--client-muted);
|
||||||
|
font: var(--type-label);
|
||||||
|
letter-spacing: var(--type-label-tracking);
|
||||||
|
text-transform: var(--type-label-transform);
|
||||||
|
}
|
||||||
|
|
||||||
@media (max-width: 560px) {
|
@media (max-width: 560px) {
|
||||||
.client-diagnostics-table th,
|
.client-diagnostics-table th,
|
||||||
.client-diagnostics-table td {
|
.client-diagnostics-table td {
|
||||||
@@ -461,4 +660,57 @@
|
|||||||
width: 44px;
|
width: 44px;
|
||||||
height: 44px;
|
height: 44px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.client-dns-domain-row {
|
||||||
|
grid-template-columns: auto minmax(0, 1fr) 44px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-dns-domain-row label {
|
||||||
|
grid-column: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-dns-domain-row select {
|
||||||
|
grid-column: 1 / 3;
|
||||||
|
grid-row: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-dns-domain-row button:not(.client-row-delete) {
|
||||||
|
grid-column: 2;
|
||||||
|
grid-row: 1;
|
||||||
|
justify-self: end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-dns-domain-row > .client-row-delete {
|
||||||
|
grid-column: 3;
|
||||||
|
grid-row: 1 / 3;
|
||||||
|
width: 44px;
|
||||||
|
height: 44px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-dns-inline-form {
|
||||||
|
grid-template-columns: minmax(0, 1fr) 44px;
|
||||||
|
padding-block: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-dns-inline-form input,
|
||||||
|
.client-dns-inline-form button[type='submit'] {
|
||||||
|
grid-column: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-dns-inline-form button:last-child {
|
||||||
|
grid-column: 2;
|
||||||
|
grid-row: 1 / 4;
|
||||||
|
width: 44px;
|
||||||
|
height: 44px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-dns-header,
|
||||||
|
.client-dns-row {
|
||||||
|
grid-template-columns: 32% minmax(0, 1fr) minmax(0, 1fr) 44px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-dns-header > span,
|
||||||
|
.client-dns-row > span {
|
||||||
|
padding-inline: 5px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,16 +20,16 @@
|
|||||||
.client-failover-channels { display: grid; grid-template-columns: minmax(0, 1fr) 52px minmax(0, 1fr); gap: 12px; }
|
.client-failover-channels { display: grid; grid-template-columns: minmax(0, 1fr) 52px minmax(0, 1fr); gap: 12px; }
|
||||||
.client-failover-channel-slot { display: contents; }
|
.client-failover-channel-slot { display: contents; }
|
||||||
.client-failover-channel { position: relative; display: grid; align-content: start; gap: 7px; min-width: 0; }
|
.client-failover-channel { position: relative; display: grid; align-content: start; gap: 7px; min-width: 0; }
|
||||||
.client-failover-channel-title { min-height: 24px; display: flex; align-items: baseline; justify-content: space-between; gap: 12px; }
|
.client-failover-channel-title { min-height: 24px; }
|
||||||
.client-failover-channel-title h3 { margin: 0; font: var(--type-data); font-variant-numeric: var(--numeric-tabular); letter-spacing: var(--type-data-tracking); text-transform: var(--type-data-transform); }
|
.client-failover-channel-title h3 { margin: 0; font: var(--type-data); font-variant-numeric: var(--numeric-tabular); letter-spacing: var(--type-data-tracking); text-transform: var(--type-data-transform); }
|
||||||
.client-failover-channel[data-channel='primary'] .client-failover-channel-title h3 { color: var(--harbor-connect); text-shadow: 0 0 9px color-mix(in oklch, var(--harbor-connect) 30%, transparent); }
|
.client-failover-channel[data-channel='primary'] .client-failover-channel-title h3 { color: var(--harbor-connect); text-shadow: 0 0 9px color-mix(in oklch, var(--harbor-connect) 30%, transparent); }
|
||||||
.client-failover-channel[data-channel='reserve'] .client-failover-channel-title h3 { color: var(--harbor-gateway); text-shadow: 0 0 9px color-mix(in oklch, var(--harbor-gateway) 30%, transparent); }
|
.client-failover-channel[data-channel='reserve'] .client-failover-channel-title h3 { color: var(--harbor-gateway); text-shadow: 0 0 9px color-mix(in oklch, var(--harbor-gateway) 30%, transparent); }
|
||||||
.client-failover-channel-title strong { min-width: 0; color: var(--client-muted); font: var(--type-control); letter-spacing: var(--type-control-tracking); text-transform: var(--type-control-transform); text-align: right; white-space: nowrap; transition: color 600ms ease, filter 600ms ease; }
|
.client-failover-channel-status { min-height: 32px; display: grid; grid-template-columns: minmax(0, 1fr); grid-template-rows: repeat(2, minmax(0, 1fr)); align-items: baseline; color: var(--client-muted); font: var(--type-control); letter-spacing: var(--type-control-tracking); text-transform: var(--type-control-transform); }
|
||||||
.client-failover-channel-title strong.is-healthy { color: var(--client-accent); }
|
.client-failover-channel-status strong { min-width: 0; white-space: nowrap; transition: color 600ms ease, filter 600ms ease; }
|
||||||
.client-failover-channel-title strong.is-unhealthy { color: oklch(0.68 0.15 28); }
|
.client-failover-channel-status strong.is-healthy { color: var(--client-accent); }
|
||||||
|
.client-failover-channel-status strong.is-unhealthy { color: oklch(0.68 0.15 28); }
|
||||||
|
.client-failover-channel-progress { min-width: 0; font-variant-numeric: var(--numeric-tabular); text-align: left; white-space: nowrap; }
|
||||||
.client-failover-picker { position: relative; min-width: 0; z-index: 1; }
|
.client-failover-picker { position: relative; min-width: 0; z-index: 1; }
|
||||||
.client-failover-channel-checking { min-height: 16px; color: var(--client-muted); font: var(--type-control); letter-spacing: var(--type-control-tracking); text-transform: var(--type-control-transform); opacity: 0; visibility: hidden; transition: opacity 180ms ease; }
|
|
||||||
.client-failover-channel-checking.is-visible { opacity: 1; visibility: visible; }
|
|
||||||
.client-failover-channel-actions { grid-column: 2; grid-row: 1; min-height: 66px; display: grid; place-content: center; gap: 2px; }
|
.client-failover-channel-actions { grid-column: 2; grid-row: 1; min-height: 66px; display: grid; place-content: center; gap: 2px; }
|
||||||
.client-failover-check-action, .client-failover-direction-action { width: 44px; height: 44px; display: grid; place-items: center; padding: 0; border: 0; background: transparent; color: var(--client-accent); cursor: pointer; }
|
.client-failover-check-action, .client-failover-direction-action { width: 44px; height: 44px; display: grid; place-items: center; padding: 0; border: 0; background: transparent; color: var(--client-accent); cursor: pointer; }
|
||||||
.client-failover-direction-action.is-primary { color: var(--harbor-connect); }
|
.client-failover-direction-action.is-primary { color: var(--harbor-connect); }
|
||||||
@@ -101,5 +101,6 @@
|
|||||||
.client-failover-number-input:focus-visible { outline: 0; color: var(--client-accent); text-shadow: 0 0 9px color-mix(in oklch, var(--client-accent) 38%, transparent); }
|
.client-failover-number-input:focus-visible { outline: 0; color: var(--client-accent); text-shadow: 0 0 9px color-mix(in oklch, var(--client-accent) 38%, transparent); }
|
||||||
@keyframes failover-service-in { from { opacity: 0; filter: blur(5px); transform: translateY(-4px); } to { opacity: 1; filter: blur(0); transform: translateY(0); } }
|
@keyframes failover-service-in { from { opacity: 0; filter: blur(5px); transform: translateY(-4px); } to { opacity: 1; filter: blur(0); transform: translateY(0); } }
|
||||||
@keyframes failover-service-out { from { opacity: 1; filter: blur(0); transform: translateX(0) scale(1); } to { opacity: 0; filter: blur(6px); transform: translateX(16px) scale(.96); } }
|
@keyframes failover-service-out { from { opacity: 1; filter: blur(0); transform: translateX(0) scale(1); } to { opacity: 0; filter: blur(6px); transform: translateX(16px) scale(.96); } }
|
||||||
@media (max-width: 560px) { .client-failover-heading { align-items: flex-start; flex-wrap: wrap; } .client-failover-heading > h2 { min-width: 0; flex: 1 1 auto; } .client-failover-header-actions { flex: 1 1 100%; justify-content: flex-start; gap: 4px 10px; } .client-failover-channels { grid-template-columns: minmax(0, 1fr); gap: 12px; } .client-failover-channel-actions { grid-column: 1; grid-row: auto; min-height: 44px; display: flex; justify-content: center; gap: 8px; } .client-failover-service { grid-template-columns: minmax(0, 1fr) 32px; gap: 6px 10px; } .client-failover-service > .client-failover-number-setting { grid-column: 1; grid-row: 2; } .client-failover-remove-service { grid-column: 2; grid-row: 1 / span 2; } .client-failover-service-editor { grid-template-columns: 1fr; gap: 2px; } }
|
@media (max-width: 680px) { .client-failover-channels { grid-template-columns: minmax(0, 1fr); gap: 12px; } .client-failover-channel-actions { grid-column: 1; grid-row: auto; min-height: 44px; display: flex; justify-content: center; gap: 8px; } }
|
||||||
@media (prefers-reduced-motion: reduce) { .client-failover-channel-title strong, .client-failover-channel-checking, .client-failover-switch::before, .client-failover-switch > span, .client-failover-picker-trigger, .client-failover-picker-trigger::before, .client-failover-picker-trigger svg, .client-failover-picker-list, .client-failover-picker-list button, .client-failover-number-steps, .client-failover-number-control:hover .client-failover-number-steps, .client-failover-number-control:focus-within .client-failover-number-steps, .client-failover-remove-service, .client-failover-remove-lid, .client-failover-direction-action svg { transition: none; } .client-failover-check-action.is-running svg { animation: none; } .client-failover-service, .client-failover-service-editor { animation: none; } }
|
@media (max-width: 560px) { .client-failover-heading { align-items: flex-start; flex-wrap: wrap; } .client-failover-heading > h2 { min-width: 0; flex: 1 1 auto; } .client-failover-header-actions { flex: 1 1 100%; justify-content: flex-start; gap: 4px 10px; } .client-failover-service { grid-template-columns: minmax(0, 1fr) 32px; gap: 6px 10px; } .client-failover-service > .client-failover-number-setting { grid-column: 1; grid-row: 2; } .client-failover-remove-service { grid-column: 2; grid-row: 1 / span 2; } .client-failover-service-editor { grid-template-columns: 1fr; gap: 2px; } }
|
||||||
|
@media (prefers-reduced-motion: reduce) { .client-failover-channel-status strong, .client-failover-switch::before, .client-failover-switch > span, .client-failover-picker-trigger, .client-failover-picker-trigger::before, .client-failover-picker-trigger svg, .client-failover-picker-list, .client-failover-picker-list button, .client-failover-number-steps, .client-failover-number-control:hover .client-failover-number-steps, .client-failover-number-control:focus-within .client-failover-number-steps, .client-failover-remove-service, .client-failover-remove-lid, .client-failover-direction-action svg { transition: none; } .client-failover-check-action.is-running svg { animation: none; } .client-failover-service, .client-failover-service-editor { animation: none; } }
|
||||||
|
|||||||
@@ -0,0 +1,562 @@
|
|||||||
|
.client-traffic-toggle svg {
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-traffic {
|
||||||
|
--font-size-control: 0.875rem;
|
||||||
|
--font-size-body: 0.875rem;
|
||||||
|
--font-size-data: 1rem;
|
||||||
|
--font-size-item-title: 1rem;
|
||||||
|
--font-size-drawer-title: 1.5rem;
|
||||||
|
--font-weight-bold: 500;
|
||||||
|
--font-weight-strong: 500;
|
||||||
|
--type-control: var(--font-weight-bold) var(--font-size-control)/var(--line-height-control) var(--font-family-client);
|
||||||
|
--type-body: var(--font-weight-body) var(--font-size-body)/var(--line-height-body) var(--font-family-client);
|
||||||
|
--type-data: var(--font-weight-strong) var(--font-size-data)/var(--line-height-data) var(--font-family-client);
|
||||||
|
--type-item-title: var(--font-weight-bold) var(--font-size-item-title)/var(--line-height-item-title) var(--font-family-client);
|
||||||
|
--type-drawer-title: var(--font-weight-bold) var(--font-size-drawer-title)/var(--line-height-drawer-title) var(--font-family-client);
|
||||||
|
--traffic-value-width: 112px;
|
||||||
|
width: min(800px, 100vw);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-traffic-sheet {
|
||||||
|
height: 100%;
|
||||||
|
min-height: 0;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) 112px 80px;
|
||||||
|
grid-template-rows: 44px 56px minmax(0, 1fr);
|
||||||
|
gap: 12px 16px;
|
||||||
|
padding: 28px 78px 24px 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-traffic-header {
|
||||||
|
grid-area: 1 / 1 / 2 / 3;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) 112px;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-traffic-header h2 {
|
||||||
|
margin: 0;
|
||||||
|
font: var(--type-drawer-title);
|
||||||
|
letter-spacing: var(--type-drawer-title-tracking);
|
||||||
|
text-transform: var(--type-drawer-title-transform);
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-traffic .client-drawer-close {
|
||||||
|
grid-area: 1 / 3;
|
||||||
|
position: static;
|
||||||
|
width: 80px;
|
||||||
|
height: 44px;
|
||||||
|
font: var(--type-control);
|
||||||
|
letter-spacing: var(--type-control-tracking);
|
||||||
|
text-transform: var(--type-control-transform);
|
||||||
|
text-align: right;
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-traffic-pause,
|
||||||
|
.client-traffic-menu-trigger,
|
||||||
|
.client-traffic-tabs button,
|
||||||
|
.client-traffic-filters button,
|
||||||
|
.client-traffic-device-list button {
|
||||||
|
min-height: 36px;
|
||||||
|
padding: 4px 0;
|
||||||
|
border: 0;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--client-muted);
|
||||||
|
font: var(--type-control);
|
||||||
|
letter-spacing: var(--type-control-tracking);
|
||||||
|
text-transform: var(--type-control-transform);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: color 180ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-traffic-pause {
|
||||||
|
width: 112px;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-traffic button:enabled:hover,
|
||||||
|
.client-traffic button[aria-pressed='true'],
|
||||||
|
.client-traffic-menu-trigger[data-active='true'],
|
||||||
|
.client-traffic-menu-trigger[aria-expanded='true'] {
|
||||||
|
color: var(--client-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-traffic button:focus-visible {
|
||||||
|
outline: 2px solid var(--client-accent);
|
||||||
|
outline-offset: -2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-traffic button:disabled {
|
||||||
|
opacity: 0.55;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-traffic-tabs {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
align-items: start;
|
||||||
|
gap: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-traffic-tabs button {
|
||||||
|
width: 88px;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-traffic-tabs::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
top: 36px;
|
||||||
|
width: 68px;
|
||||||
|
height: 2px;
|
||||||
|
background: var(--client-accent);
|
||||||
|
transition: transform 200ms cubic-bezier(0.2, 0, 0, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-traffic-tabs[data-view='history']::after {
|
||||||
|
transform: translateX(112px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-traffic-views {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
position: relative;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-traffic-view {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-width: 0;
|
||||||
|
opacity: 0;
|
||||||
|
visibility: hidden;
|
||||||
|
pointer-events: none;
|
||||||
|
transition: opacity 180ms cubic-bezier(0.2, 0, 0, 1), visibility 0s 180ms;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-traffic-view.is-active {
|
||||||
|
opacity: 1;
|
||||||
|
visibility: visible;
|
||||||
|
pointer-events: auto;
|
||||||
|
transition-delay: 0s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-traffic-toolbar {
|
||||||
|
position: relative;
|
||||||
|
z-index: 2;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) auto auto;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px 24px;
|
||||||
|
min-height: 56px;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-traffic-toolbar > .client-traffic-menu:last-child {
|
||||||
|
grid-column: -2;
|
||||||
|
justify-self: end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-traffic-search {
|
||||||
|
display: block;
|
||||||
|
min-width: 0;
|
||||||
|
border-bottom: 1px solid var(--client-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-traffic-search:focus-within {
|
||||||
|
border-bottom-color: var(--client-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-traffic-search input {
|
||||||
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
height: 40px;
|
||||||
|
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-search input::-webkit-search-cancel-button {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-traffic-menu {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-traffic-menu-trigger {
|
||||||
|
max-width: 180px;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-traffic-menu-trigger[data-active='true'],
|
||||||
|
.client-traffic-menu-trigger[aria-expanded='true'] {
|
||||||
|
text-decoration: underline;
|
||||||
|
text-underline-offset: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-traffic-menu-content {
|
||||||
|
position: absolute;
|
||||||
|
top: 100%;
|
||||||
|
right: 0;
|
||||||
|
width: min(420px, 100%);
|
||||||
|
max-height: min(480px, calc(100dvh - 280px));
|
||||||
|
overflow-y: auto;
|
||||||
|
overscroll-behavior: contain;
|
||||||
|
padding: 18px;
|
||||||
|
border: 1px solid var(--client-border);
|
||||||
|
background: var(--client-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-traffic-device-list {
|
||||||
|
display: grid;
|
||||||
|
margin-top: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-traffic-device-list button {
|
||||||
|
text-align: left;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-traffic-settings {
|
||||||
|
display: grid;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-traffic-filters {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 4px 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-traffic-filters button {
|
||||||
|
border-bottom: 2px solid transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-traffic-filters button[aria-pressed='true'] {
|
||||||
|
border-bottom-color: var(--client-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-traffic-periods {
|
||||||
|
gap: 4px 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-traffic-periods button {
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-traffic-option-row {
|
||||||
|
display: grid;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-traffic-option-row > span {
|
||||||
|
color: var(--client-muted);
|
||||||
|
font: var(--type-body);
|
||||||
|
letter-spacing: var(--type-body-tracking);
|
||||||
|
text-transform: var(--type-body-transform);
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-traffic-columns,
|
||||||
|
.client-traffic-connection-summary {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) var(--traffic-value-width) var(--traffic-value-width);
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-traffic-columns {
|
||||||
|
flex: none;
|
||||||
|
min-height: 44px;
|
||||||
|
overflow-y: hidden;
|
||||||
|
scrollbar-width: thin;
|
||||||
|
scrollbar-gutter: stable;
|
||||||
|
border-bottom: 1px solid var(--client-border);
|
||||||
|
color: var(--client-muted);
|
||||||
|
font: var(--type-body);
|
||||||
|
letter-spacing: var(--type-body-tracking);
|
||||||
|
text-transform: var(--type-body-transform);
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-traffic-columns span:not(:first-child) {
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-traffic-scroll {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
overflow-y: auto;
|
||||||
|
overscroll-behavior: contain;
|
||||||
|
scrollbar-width: thin;
|
||||||
|
scrollbar-gutter: stable;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-traffic-list {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-traffic-connection,
|
||||||
|
.client-traffic-history-row {
|
||||||
|
min-width: 0;
|
||||||
|
box-shadow: inset 0 -1px 0 color-mix(in oklch, var(--client-border) 46%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-traffic-connection {
|
||||||
|
animation: client-traffic-connection-in 180ms cubic-bezier(0.2, 0, 0, 1) both;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-traffic-connection.is-exiting {
|
||||||
|
pointer-events: none;
|
||||||
|
animation: client-traffic-connection-out 180ms cubic-bezier(0.2, 0, 0, 1) both;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-traffic-connection-summary {
|
||||||
|
width: 100%;
|
||||||
|
min-height: 64px;
|
||||||
|
padding: 12px 0;
|
||||||
|
border: 0;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--client-text);
|
||||||
|
text-align: left;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-traffic-identity {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-traffic-identity strong {
|
||||||
|
display: block;
|
||||||
|
overflow: hidden;
|
||||||
|
font: var(--type-item-title);
|
||||||
|
letter-spacing: var(--type-item-title-tracking);
|
||||||
|
text-transform: var(--type-item-title-transform);
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-traffic-connection-summary:enabled:hover .client-traffic-identity strong,
|
||||||
|
.client-traffic-connection-summary[aria-expanded='true'] .client-traffic-identity strong {
|
||||||
|
text-decoration: underline;
|
||||||
|
text-underline-offset: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-traffic-connection-summary:disabled {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-traffic-download,
|
||||||
|
.client-traffic-upload {
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
text-align: right;
|
||||||
|
white-space: nowrap;
|
||||||
|
font: var(--type-data);
|
||||||
|
letter-spacing: var(--type-data-tracking);
|
||||||
|
text-transform: var(--type-data-transform);
|
||||||
|
font-variant-numeric: var(--numeric-tabular);
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-traffic-download {
|
||||||
|
color: var(--harbor-connect);
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-traffic-upload {
|
||||||
|
color: var(--client-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-traffic-history-children {
|
||||||
|
padding-left: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-traffic-details {
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
margin: 0;
|
||||||
|
padding: 4px 0 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-traffic-details > div {
|
||||||
|
min-width: 0;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 112px minmax(0, 1fr);
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-traffic-details dt,
|
||||||
|
.client-traffic-details dd {
|
||||||
|
margin: 0;
|
||||||
|
font: var(--type-body);
|
||||||
|
letter-spacing: var(--type-body-tracking);
|
||||||
|
text-transform: var(--type-body-transform);
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-traffic-details dt {
|
||||||
|
color: var(--client-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-traffic-details dd {
|
||||||
|
color: var(--client-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-traffic-notice,
|
||||||
|
.client-traffic-history-status,
|
||||||
|
.client-traffic-state,
|
||||||
|
.client-traffic-summary,
|
||||||
|
.client-traffic-honesty,
|
||||||
|
.client-traffic-truncated {
|
||||||
|
margin: 12px 0;
|
||||||
|
color: var(--client-muted);
|
||||||
|
font: var(--type-body);
|
||||||
|
letter-spacing: var(--type-body-tracking);
|
||||||
|
text-transform: var(--type-body-transform);
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-traffic-summary {
|
||||||
|
display: grid;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-traffic-notice.is-warning {
|
||||||
|
color: var(--client-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-traffic-state {
|
||||||
|
min-height: 118px;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-traffic-skeleton {
|
||||||
|
display: grid;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 12px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-traffic-skeleton span {
|
||||||
|
height: 48px;
|
||||||
|
background: color-mix(in oklch, var(--client-border) 24%, transparent);
|
||||||
|
opacity: 0.55;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes client-traffic-connection-in {
|
||||||
|
from { opacity: 0; }
|
||||||
|
to { opacity: 1; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes client-traffic-connection-out {
|
||||||
|
from { opacity: 1; }
|
||||||
|
to { opacity: 0; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.client-traffic {
|
||||||
|
width: 100vw;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-traffic-sheet {
|
||||||
|
padding: 20px 64px 20px 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-traffic-toolbar {
|
||||||
|
grid-template-columns: minmax(0, 1fr) auto;
|
||||||
|
min-height: 92px;
|
||||||
|
gap: 8px 16px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-traffic-toolbar > .client-traffic-search,
|
||||||
|
.client-traffic-periods {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
.client-traffic-sheet {
|
||||||
|
grid-template-columns: minmax(0, 1fr) 80px 68px;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 16px 56px 16px 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-traffic-header {
|
||||||
|
grid-template-columns: minmax(0, 1fr) 80px;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-traffic-pause {
|
||||||
|
width: 80px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-traffic .client-drawer-close {
|
||||||
|
width: 68px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-traffic-columns,
|
||||||
|
.client-traffic-connection-summary {
|
||||||
|
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
|
||||||
|
gap: 8px 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-traffic-columns > span:first-child,
|
||||||
|
.client-traffic-identity {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-traffic-columns {
|
||||||
|
padding-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-traffic-columns > span:nth-child(2),
|
||||||
|
.client-traffic-download {
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-traffic-details > div {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-traffic-history-children {
|
||||||
|
padding-left: 12px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (pointer: coarse) {
|
||||||
|
.client-traffic button {
|
||||||
|
min-height: 44px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.client-traffic *,
|
||||||
|
.client-traffic-tabs::after {
|
||||||
|
animation: none;
|
||||||
|
transition: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,5 +10,6 @@
|
|||||||
@import './features/diagnostics.css';
|
@import './features/diagnostics.css';
|
||||||
@import './features/failover.css';
|
@import './features/failover.css';
|
||||||
@import './features/activity-journal.css';
|
@import './features/activity-journal.css';
|
||||||
|
@import './features/traffic.css';
|
||||||
@import './layout.css';
|
@import './layout.css';
|
||||||
@import './themes.css';
|
@import './themes.css';
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ interface DrawerBaseProps {
|
|||||||
className?: string;
|
className?: string;
|
||||||
sheetClassName?: string;
|
sheetClassName?: string;
|
||||||
closeLabel: string;
|
closeLabel: string;
|
||||||
|
closeText?: string;
|
||||||
onClose: () => unknown;
|
onClose: () => unknown;
|
||||||
leading?: ReactNode;
|
leading?: ReactNode;
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
@@ -30,6 +31,7 @@ export function Drawer({
|
|||||||
labelledBy,
|
labelledBy,
|
||||||
label,
|
label,
|
||||||
closeLabel,
|
closeLabel,
|
||||||
|
closeText = '×',
|
||||||
onClose,
|
onClose,
|
||||||
leading,
|
leading,
|
||||||
children,
|
children,
|
||||||
@@ -51,7 +53,7 @@ export function Drawer({
|
|||||||
type="button"
|
type="button"
|
||||||
aria-label={closeLabel}
|
aria-label={closeLabel}
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
>×</button>
|
>{closeText}</button>
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
</aside>;
|
</aside>;
|
||||||
|
|||||||
@@ -95,6 +95,39 @@ 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',
|
||||||
|
'.node-version',
|
||||||
|
'scripts/check-sqlite-runtime.mjs',
|
||||||
|
'src/server/services/sqlite.ts',
|
||||||
|
'src/server/services/trafficHistoryStore.ts',
|
||||||
|
'src/server/services/trafficHistoryService.ts',
|
||||||
|
'src/server/services/trafficHistoryWorker.ts',
|
||||||
|
'src/shared/trafficHistory.ts',
|
||||||
|
]) {
|
||||||
|
assert.deepEqual(classifyRuntimeImpact([file]), {
|
||||||
|
affectedComponents: ['control', 'dataplane'],
|
||||||
|
restartScope: 'both',
|
||||||
|
}, file);
|
||||||
|
}
|
||||||
|
for (const file of [
|
||||||
|
'tools/test-singbox-client-rc.sh',
|
||||||
|
'tools/test-singbox-gateway-native-traffic.sh',
|
||||||
|
'tools/test-singbox-native-traffic.sh',
|
||||||
|
]) {
|
||||||
|
assert.deepEqual(classifyRuntimeImpact([file]), {
|
||||||
|
affectedComponents: [],
|
||||||
|
restartScope: 'none',
|
||||||
|
}, file);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
test('version bumps do not promote a control-only change to a dataplane restart', () => {
|
test('version bumps do not promote a control-only change to a dataplane restart', () => {
|
||||||
assert.deepEqual(classifyRuntimeImpact(['src/web/App.tsx', 'src/shared/versions.ts']), {
|
assert.deepEqual(classifyRuntimeImpact(['src/web/App.tsx', 'src/shared/versions.ts']), {
|
||||||
affectedComponents: ['control'],
|
affectedComponents: ['control'],
|
||||||
@@ -118,8 +151,12 @@ test('release-critical inputs restart both and unknown paths fail closed', () =>
|
|||||||
assert.throws(() => classifyRuntimeImpact(['scripts/new-runtime.sh']), /Unclassified path/);
|
assert.throws(() => classifyRuntimeImpact(['scripts/new-runtime.sh']), /Unclassified path/);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('every tracked repository path has an explicit ownership class', () => {
|
test('every repository path has an explicit ownership class', () => {
|
||||||
const tracked = execFileSync('git', ['ls-files'], { cwd: root, encoding: 'utf8' })
|
const tracked = execFileSync(
|
||||||
|
'git',
|
||||||
|
['ls-files', '--cached', '--others', '--exclude-standard'],
|
||||||
|
{ cwd: root, encoding: 'utf8' },
|
||||||
|
)
|
||||||
.trim()
|
.trim()
|
||||||
.split('\n');
|
.split('\n');
|
||||||
assert.doesNotThrow(() => classifyRuntimeImpact(tracked));
|
assert.doesNotThrow(() => classifyRuntimeImpact(tracked));
|
||||||
|
|||||||
@@ -50,9 +50,11 @@ test('macOS installer removes stale archive files without deleting local state',
|
|||||||
assert.match(installer, /--exclude='\.git'/);
|
assert.match(installer, /--exclude='\.git'/);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('macOS installer refreshes a stale pinned sing-box version', () => {
|
test('macOS installer pins sing-box 1.14.0-rc.5 and native traffic together', () => {
|
||||||
const installer = fs.readFileSync(path.join(root, 'scripts', 'install-macos-client.sh'), 'utf8');
|
const installer = fs.readFileSync(path.join(root, 'scripts', 'install-macos-client.sh'), 'utf8');
|
||||||
|
|
||||||
assert.match(installer, /TARGET_SINGBOX_VERSION="\$\{SINGBOX_VERSION:-1\.13\.18\}"/);
|
assert.match(installer, /TARGET_SINGBOX_VERSION="\$\{SINGBOX_VERSION:-1\.14\.0-rc\.5\}"/);
|
||||||
|
assert.match(installer, /TARGET_TRAFFIC_SOURCE="\$\{SING_BOX_TRAFFIC_SOURCE:-native\}"/);
|
||||||
assert.match(installer, /set_env_value SINGBOX_VERSION "\$TARGET_SINGBOX_VERSION"/);
|
assert.match(installer, /set_env_value SINGBOX_VERSION "\$TARGET_SINGBOX_VERSION"/);
|
||||||
|
assert.match(installer, /set_env_value SING_BOX_TRAFFIC_SOURCE "\$TARGET_TRAFFIC_SOURCE"/);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -5,8 +5,20 @@ import path from 'node:path';
|
|||||||
import test from 'node:test';
|
import test from 'node:test';
|
||||||
|
|
||||||
import { createActivityJournalService } from '../../dist/server/services/activityJournalService.js';
|
import { createActivityJournalService } from '../../dist/server/services/activityJournalService.js';
|
||||||
|
import { openHarborStorage } from '../../dist/server/services/harborStorage.js';
|
||||||
|
import { DatabaseSync } from 'node:sqlite';
|
||||||
import { assertActivityJournalPage } from '../../dist/shared/activityJournal.js';
|
import { assertActivityJournalPage } from '../../dist/shared/activityJournal.js';
|
||||||
|
|
||||||
|
function serviceFor(t, filePath, now) {
|
||||||
|
const storage = openHarborStorage(path.dirname(filePath));
|
||||||
|
t.after(() => storage.close());
|
||||||
|
return createActivityJournalService({ db: storage.db, now });
|
||||||
|
}
|
||||||
|
function persisted(filePath) {
|
||||||
|
const db = new DatabaseSync(path.join(path.dirname(filePath), 'harbor.sqlite'), { readOnly: true });
|
||||||
|
try { return { schemaVersion: 1, events: db.prepare('SELECT value FROM journal ORDER BY sequence').all().map((row) => JSON.parse(row.value)) }; }
|
||||||
|
finally { db.close(); }
|
||||||
|
}
|
||||||
function fixture(t) {
|
function fixture(t) {
|
||||||
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'harbor-journal-'));
|
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'harbor-journal-'));
|
||||||
t.after(() => fs.rmSync(directory, { recursive: true, force: true }));
|
t.after(() => fs.rmSync(directory, { recursive: true, force: true }));
|
||||||
@@ -24,7 +36,7 @@ const event = (dedupeKey, profileLabel = 'Home') => ({
|
|||||||
test('journal appends typed events, deduplicates and keeps stable newest-first cursors', (t) => {
|
test('journal appends typed events, deduplicates and keeps stable newest-first cursors', (t) => {
|
||||||
let clock = new Date('2026-08-19T10:00:00.000Z');
|
let clock = new Date('2026-08-19T10:00:00.000Z');
|
||||||
const filePath = fixture(t);
|
const filePath = fixture(t);
|
||||||
const service = createActivityJournalService({ filePath, now: () => clock });
|
const service = serviceFor(t, filePath, () => clock);
|
||||||
service.append(event('refresh:1', 'One'));
|
service.append(event('refresh:1', 'One'));
|
||||||
clock = new Date('2026-08-19T10:01:00.000Z');
|
clock = new Date('2026-08-19T10:01:00.000Z');
|
||||||
service.append(event('refresh:2', 'Two'));
|
service.append(event('refresh:2', 'Two'));
|
||||||
@@ -38,20 +50,20 @@ test('journal appends typed events, deduplicates and keeps stable newest-first c
|
|||||||
const older = service.page(10, first.nextCursor);
|
const older = service.page(10, first.nextCursor);
|
||||||
assert.deepEqual(older.events.map(({ data }) => data.profileLabel), ['One']);
|
assert.deepEqual(older.events.map(({ data }) => data.profileLabel), ['One']);
|
||||||
assert.equal(first.events[0].dedupeKey, null);
|
assert.equal(first.events[0].dedupeKey, null);
|
||||||
const inode = fs.statSync(filePath).ino;
|
const inode = fs.statSync(path.join(path.dirname(filePath), 'harbor.sqlite')).ino;
|
||||||
assert.equal(service.page(10).events.length, 3);
|
assert.equal(service.page(10).events.length, 3);
|
||||||
assert.equal(fs.statSync(filePath).ino, inode);
|
assert.equal(fs.statSync(path.join(path.dirname(filePath), 'harbor.sqlite')).ino, inode);
|
||||||
assert.deepEqual(service.page(10, 'expired-cursor').events, []);
|
assert.deepEqual(service.page(10, 'expired-cursor').events, []);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('journal prunes events older than 30 days and rejects unsafe payloads', (t) => {
|
test('journal prunes events older than 30 days and rejects unsafe payloads', (t) => {
|
||||||
let clock = new Date('2026-07-01T00:00:00.000Z');
|
let clock = new Date('2026-07-01T00:00:00.000Z');
|
||||||
const filePath = fixture(t);
|
const filePath = fixture(t);
|
||||||
const service = createActivityJournalService({ filePath, now: () => clock });
|
const service = serviceFor(t, filePath, () => clock);
|
||||||
service.append(event('old'));
|
service.append(event('old'));
|
||||||
clock = new Date('2026-08-19T00:00:00.000Z');
|
clock = new Date('2026-08-19T00:00:00.000Z');
|
||||||
assert.deepEqual(service.page().events, []);
|
assert.deepEqual(service.page().events, []);
|
||||||
assert.deepEqual(JSON.parse(fs.readFileSync(filePath, 'utf8')).events, []);
|
assert.deepEqual(persisted(filePath).events, []);
|
||||||
service.append(event('new'));
|
service.append(event('new'));
|
||||||
assert.throws(() => service.append({ ...event('unsafe'), data: { rawUrl: 'https://secret' } }), /Unsafe/);
|
assert.throws(() => service.append({ ...event('unsafe'), data: { rawUrl: 'https://secret' } }), /Unsafe/);
|
||||||
service.append({ ...event('ip'), data: { ...event('ip').data, host: '192.168.1.1' } });
|
service.append({ ...event('ip'), data: { ...event('ip').data, host: '192.168.1.1' } });
|
||||||
@@ -68,9 +80,9 @@ test('journal prunes events older than 30 days and rejects unsafe payloads', (t)
|
|||||||
'subscription.refreshed:user:pass',
|
'subscription.refreshed:user:pass',
|
||||||
]) service.append({ ...event('safe'), dedupeKey });
|
]) service.append({ ...event('safe'), dedupeKey });
|
||||||
service.append({ ...event('safe'), dedupeKey: 'subscription.refreshed:user:pass' });
|
service.append({ ...event('safe'), dedupeKey: 'subscription.refreshed:user:pass' });
|
||||||
const persisted = fs.readFileSync(filePath, 'utf8');
|
const persistedText = JSON.stringify(persisted(filePath));
|
||||||
assert.doesNotMatch(persisted, /user:pass|192\.168\.1\.1|192\.168\.1\.7|2001:db8|token=secret/);
|
assert.doesNotMatch(persistedText, /user:pass|192\.168\.1\.1|192\.168\.1\.7|2001:db8|token=secret/);
|
||||||
assert.match(persisted, /subscription\.refreshed:sha256:[a-f0-9]{64}/);
|
assert.match(persistedText, /subscription\.refreshed:sha256:[a-f0-9]{64}/);
|
||||||
assert.throws(() => service.append({ ...event('safe'), dedupeKey: 'https://user:pass@example.test/private?token=x' }), /dedupe/i);
|
assert.throws(() => service.append({ ...event('safe'), dedupeKey: 'https://user:pass@example.test/private?token=x' }), /dedupe/i);
|
||||||
assert.throws(() => service.append({ ...event('safe'), dedupeKey: 'subscription.refreshed:private/path' }), /dedupe/i);
|
assert.throws(() => service.append({ ...event('safe'), dedupeKey: 'subscription.refreshed:private/path' }), /dedupe/i);
|
||||||
});
|
});
|
||||||
@@ -84,36 +96,24 @@ test('journal persists the 10,000 event cap when opening an oversized store', (t
|
|||||||
...event(`event:${index}`),
|
...event(`event:${index}`),
|
||||||
}));
|
}));
|
||||||
fs.writeFileSync(filePath, JSON.stringify({ schemaVersion: 1, events }));
|
fs.writeFileSync(filePath, JSON.stringify({ schemaVersion: 1, events }));
|
||||||
const service = createActivityJournalService({
|
const service = serviceFor(t, filePath, () => new Date('2026-08-19T01:00:00.000Z'));
|
||||||
filePath,
|
|
||||||
now: () => new Date('2026-08-19T01:00:00.000Z'),
|
|
||||||
});
|
|
||||||
assert.equal(JSON.parse(fs.readFileSync(filePath, 'utf8')).events.length, 10_000);
|
|
||||||
assert.equal(service.page(1).events.length, 1);
|
assert.equal(service.page(1).events.length, 1);
|
||||||
|
assert.equal(persisted(filePath).events.length, 10_000);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('corrupt journal is isolated and recovery becomes a safe event', (t) => {
|
test('corrupt journal blocks import and leaves the original intact', (t) => {
|
||||||
const filePath = fixture(t);
|
const filePath = fixture(t);
|
||||||
fs.writeFileSync(filePath, '{broken');
|
fs.writeFileSync(filePath, '{broken');
|
||||||
const service = createActivityJournalService({
|
assert.throws(() => serviceFor(t, filePath), /Cannot migrate activity-journal.json/);
|
||||||
filePath,
|
assert.equal(fs.readFileSync(filePath, 'utf8'), '{broken');
|
||||||
now: () => new Date('2026-08-19T12:00:00.000Z'),
|
|
||||||
});
|
|
||||||
const page = service.page();
|
|
||||||
assert.equal(page.storage.status, 'ready');
|
|
||||||
assert.equal(page.events[0].type, 'journal.recovered');
|
|
||||||
assert.ok(fs.readdirSync(path.dirname(filePath)).some((name) => name.includes('.corrupt-')));
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('journal exposes a latched write failure until a later append succeeds', (t) => {
|
test('journal exposes a latched write failure until a later append succeeds', (t) => {
|
||||||
const filePath = fixture(t);
|
const filePath = fixture(t);
|
||||||
const service = createActivityJournalService({ filePath });
|
const service = serviceFor(t, filePath);
|
||||||
service.append(event('before-error'));
|
service.append(event('before-error'));
|
||||||
const renameSync = fs.renameSync;
|
const db = new DatabaseSync(path.join(path.dirname(filePath), 'harbor.sqlite'));
|
||||||
fs.renameSync = (source, target) => {
|
db.exec("CREATE TRIGGER reject_journal BEFORE INSERT ON journal BEGIN SELECT RAISE(ABORT, 'simulated journal failure'); END");
|
||||||
if (target === filePath) throw new Error('simulated journal write failure');
|
|
||||||
return renameSync(source, target);
|
|
||||||
};
|
|
||||||
try {
|
try {
|
||||||
assert.throws(() => service.append(event('lost')));
|
assert.throws(() => service.append(event('lost')));
|
||||||
const failed = service.page();
|
const failed = service.page();
|
||||||
@@ -122,7 +122,8 @@ test('journal exposes a latched write failure until a later append succeeds', (t
|
|||||||
assert.equal(failed.events.length, 1);
|
assert.equal(failed.events.length, 1);
|
||||||
assert.equal(failed.events[0].dedupeKey, null);
|
assert.equal(failed.events[0].dedupeKey, null);
|
||||||
} finally {
|
} finally {
|
||||||
fs.renameSync = renameSync;
|
db.exec('DROP TRIGGER reject_journal');
|
||||||
|
db.close();
|
||||||
}
|
}
|
||||||
service.append(event('recovered'));
|
service.append(event('recovered'));
|
||||||
assert.equal(service.page().storage.status, 'ready');
|
assert.equal(service.page().storage.status, 'ready');
|
||||||
@@ -145,7 +146,7 @@ test('schema version 1 keeps legacy recovery and accepts per-channel health even
|
|||||||
}],
|
}],
|
||||||
}));
|
}));
|
||||||
let clock = new Date('2026-08-19T10:00:00.000Z');
|
let clock = new Date('2026-08-19T10:00:00.000Z');
|
||||||
const service = createActivityJournalService({ filePath, now: () => clock });
|
const service = serviceFor(t, filePath, () => clock);
|
||||||
const inputs = [
|
const inputs = [
|
||||||
['failover.primary_unavailable', 'primary', 'warning', 'probe-failed'],
|
['failover.primary_unavailable', 'primary', 'warning', 'probe-failed'],
|
||||||
['failover.primary_recovered', 'primary', 'info', 'probe-recovered'],
|
['failover.primary_recovered', 'primary', 'info', 'probe-recovered'],
|
||||||
@@ -173,10 +174,10 @@ test('schema version 1 keeps legacy recovery and accepts per-channel health even
|
|||||||
]);
|
]);
|
||||||
assert.deepEqual(page.events.at(-1).data, { role: 'primary', reason: 'primary-recovered' });
|
assert.deepEqual(page.events.at(-1).data, { role: 'primary', reason: 'primary-recovered' });
|
||||||
assert.equal(page.retentionDays, 30);
|
assert.equal(page.retentionDays, 30);
|
||||||
assert.equal(JSON.parse(fs.readFileSync(filePath, 'utf8')).schemaVersion, 1);
|
assert.equal(persisted(filePath).schemaVersion, 1);
|
||||||
});
|
});
|
||||||
test('journal page parser rejects malformed wire data and strips unknown event fields', (t) => {
|
test('journal page parser rejects malformed wire data and strips unknown event fields', (t) => {
|
||||||
const service = createActivityJournalService({ filePath: fixture(t) });
|
const service = serviceFor(t, fixture(t));
|
||||||
service.append(event('wire'));
|
service.append(event('wire'));
|
||||||
const page = service.page();
|
const page = service.page();
|
||||||
const parsed = assertActivityJournalPage({
|
const parsed = assertActivityJournalPage({
|
||||||
|
|||||||
@@ -108,6 +108,13 @@ test('compiled dispatcher starts and stops control and dataplane contracts', asy
|
|||||||
assert.equal(page.status, 200);
|
assert.equal(page.status, 200);
|
||||||
assert.match(page.type, /^text\/html/);
|
assert.match(page.type, /^text\/html/);
|
||||||
assert.match(page.body, /<div id="root"><\/div>/);
|
assert.match(page.body, /<div id="root"><\/div>/);
|
||||||
|
const historyResponse = await fetch(`http://127.0.0.1:${port}/api/traffic/history?range=90d`);
|
||||||
|
const history = await historyResponse.json();
|
||||||
|
assert.equal(historyResponse.status, 200);
|
||||||
|
assert.equal(history.storage.status, 'ready');
|
||||||
|
assert.equal(history.period.retentionDays, 90);
|
||||||
|
assert.deepEqual(history.rows, []);
|
||||||
|
assert.equal(fs.existsSync(path.join(controlData, 'harbor.sqlite')), true);
|
||||||
await stop(control.child);
|
await stop(control.child);
|
||||||
|
|
||||||
const dataplane = start({
|
const dataplane = start({
|
||||||
@@ -123,6 +130,11 @@ test('compiled dispatcher starts and stops control and dataplane contracts', asy
|
|||||||
return response.status === 200 ? response.body : null;
|
return response.status === 200 ? response.body : null;
|
||||||
}, dataplane.child, dataplane.stderr);
|
}, dataplane.child, dataplane.stderr);
|
||||||
assert.equal(status.ready, true);
|
assert.equal(status.ready, true);
|
||||||
|
const gatewayHistory = await socketRequest(socketPath, '/traffic/history?range=7d');
|
||||||
|
assert.equal(gatewayHistory.status, 200);
|
||||||
|
assert.equal(gatewayHistory.body.storage.status, 'ready');
|
||||||
|
assert.equal(gatewayHistory.body.query.range, '7d');
|
||||||
|
assert.equal(fs.existsSync(path.join(dataplaneData, 'traffic.sqlite')), true);
|
||||||
await stop(dataplane.child);
|
await stop(dataplane.child);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -148,26 +160,41 @@ test('production paths use only the compiled dispatcher', () => {
|
|||||||
assert.doesNotMatch(entrypoint, /\/app\/src/);
|
assert.doesNotMatch(entrypoint, /\/app\/src/);
|
||||||
}
|
}
|
||||||
assert.match(workflow, /npm run build:production/);
|
assert.match(workflow, /npm run build:production/);
|
||||||
assert.match(workflow, /NODE_BUILD_IMAGE: mirror\.gcr\.io\/library\/node:20\.19-bookworm/);
|
assert.match(workflow, /NODE_BUILD_IMAGE: mirror\.gcr\.io\/library\/node:24\.21\.0-bookworm/);
|
||||||
assert.match(workflow, /command -v npm[^']+command -v git[^']+test -x \/bin\/bash/);
|
assert.match(workflow, /command -v npm[^']+command -v git[^']+test -x \/bin\/bash/);
|
||||||
assert.doesNotMatch(workflow, /docker image inspect "\$\{\{ env\.NODE_BUILD_IMAGE \}\}"/);
|
assert.doesNotMatch(workflow, /docker image inspect "\$\{\{ env\.NODE_BUILD_IMAGE \}\}"/);
|
||||||
assert.match(legacyBuild, /npm run build:production && docker build/);
|
assert.match(legacyBuild, /npm run build:production && docker build/);
|
||||||
|
assert.match(legacyBuild, /docker run --rm[^;]+sing-box version[^;]+grep -Fx/);
|
||||||
|
assert.match(legacyBuild, /docker run --rm --entrypoint sing-box[^;]+version[^;]+grep -Fx/);
|
||||||
assert.match(dockerignore, /^dist$/m);
|
assert.match(dockerignore, /^dist$/m);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('every shipped build defaults to sing-box 1.13.18', () => {
|
test('Mac client builds default to exact sing-box 1.14.0-rc.5', () => {
|
||||||
for (const file of [
|
assert.match(
|
||||||
'.env.example',
|
fs.readFileSync(path.join(root, 'Dockerfile.client'), 'utf8'),
|
||||||
'.gitea/workflows/gateway-build.yml',
|
/^ARG SINGBOX_VERSION=1\.14\.0-rc\.5$/m,
|
||||||
'Dockerfile',
|
);
|
||||||
'Dockerfile.client',
|
assert.match(
|
||||||
'Dockerfile.runtime-base',
|
fs.readFileSync(path.join(root, 'docker-compose.client.yml'), 'utf8'),
|
||||||
'docker-compose.client.yml',
|
/SINGBOX_VERSION: \$\{SINGBOX_VERSION:-1\.14\.0-rc\.5\}/,
|
||||||
'docker-compose.gateway.yml',
|
);
|
||||||
'scripts/build-on-107-deploy-111.sh',
|
});
|
||||||
'scripts/build-runtime-base.sh',
|
|
||||||
|
test('Gateway builds use the Mac-qualified exact sing-box 1.14.0-rc.5', () => {
|
||||||
|
for (const [file, pin] of [
|
||||||
|
['.env.example', /^SINGBOX_VERSION=1\.14\.0-rc\.5$/m],
|
||||||
|
['.gitea/workflows/gateway-build.yml', /^\s*SINGBOX_VERSION: 1\.14\.0-rc\.5$/m],
|
||||||
|
['Dockerfile', /^ARG SINGBOX_VERSION=1\.14\.0-rc\.5$/m],
|
||||||
|
['Dockerfile.runtime-base', /^ARG SINGBOX_VERSION=1\.14\.0-rc\.5$/m],
|
||||||
|
['docker-compose.gateway.yml', /SINGBOX_VERSION: \$\{SINGBOX_VERSION:-1\.14\.0-rc\.5\}/],
|
||||||
|
['scripts/build-on-107-deploy-111.sh', /^SINGBOX_VERSION="\$\{SINGBOX_VERSION:-1\.14\.0-rc\.5\}"$/m],
|
||||||
|
['scripts/build-runtime-base.sh', /^SINGBOX_VERSION="\$\{SINGBOX_VERSION:-1\.14\.0-rc\.5\}"$/m],
|
||||||
]) {
|
]) {
|
||||||
assert.match(fs.readFileSync(path.join(root, file), 'utf8'), /SINGBOX_VERSION[^\n]*1\.13\.18/);
|
assert.match(fs.readFileSync(path.join(root, file), 'utf8'), pin);
|
||||||
|
}
|
||||||
|
const gatewayDockerfile = fs.readFileSync(path.join(root, 'Dockerfile'), 'utf8');
|
||||||
|
for (const dependency of ['@bufbuild/protobuf', '@connectrpc/connect', '@connectrpc/connect-node']) {
|
||||||
|
assert.match(gatewayDockerfile, new RegExp(`/src/node_modules/${dependency.replace('/', '\\/')}`));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -192,7 +192,15 @@ test('connectivity use case keeps applied priority, selected fallback and error
|
|||||||
test('diagnostics settings validate and replace one canonical revision', () => {
|
test('diagnostics settings validate and replace one canonical revision', () => {
|
||||||
let state = {
|
let state = {
|
||||||
revision: 7,
|
revision: 7,
|
||||||
diagnostics: { configured: false, customServices: [], hiddenServiceIds: [] },
|
diagnostics: {
|
||||||
|
configured: false,
|
||||||
|
customServices: [],
|
||||||
|
hiddenServiceIds: [],
|
||||||
|
customDnsResolvers: [{
|
||||||
|
id: 'custom-dns-office', label: 'Office', kind: 'dns', endpoint: '192.168.1.1',
|
||||||
|
}],
|
||||||
|
customDnsDomains: [{ id: 'custom-domain-office', label: 'Office', hostname: 'office.example.com' }],
|
||||||
|
},
|
||||||
};
|
};
|
||||||
const useCase = createConnectivityDiagnosticsUseCase({
|
const useCase = createConnectivityDiagnosticsUseCase({
|
||||||
state: {
|
state: {
|
||||||
@@ -212,6 +220,10 @@ test('diagnostics settings validate and replace one canonical revision', () => {
|
|||||||
configured: true,
|
configured: true,
|
||||||
customServices: [{ id: 'custom-status', label: 'Status', url: 'https://example.com/status' }],
|
customServices: [{ id: 'custom-status', label: 'Status', url: 'https://example.com/status' }],
|
||||||
hiddenServiceIds: ['speedtest'],
|
hiddenServiceIds: ['speedtest'],
|
||||||
|
customDnsResolvers: [{
|
||||||
|
id: 'custom-dns-office', label: 'Office', kind: 'dns', endpoint: '192.168.1.1',
|
||||||
|
}],
|
||||||
|
customDnsDomains: [{ id: 'custom-domain-office', label: 'Office', hostname: 'office.example.com' }],
|
||||||
});
|
});
|
||||||
assert.equal(state.revision, 8);
|
assert.equal(state.revision, 8);
|
||||||
assert.throws(() => useCase.updateSettings({ customServices: [], hiddenServiceIds: [] }, 7), {
|
assert.throws(() => useCase.updateSettings({ customServices: [], hiddenServiceIds: [] }, 7), {
|
||||||
@@ -245,6 +257,11 @@ test('connectivity route preserves exact URL, defaults and raw response', async
|
|||||||
calls.push(args);
|
calls.push(args);
|
||||||
return { checkedAt: 'now', vpn: { server: null } };
|
return { checkedAt: 'now', vpn: { server: null } };
|
||||||
},
|
},
|
||||||
|
dnsCatalog: async () => ({ resolvers: [{ id: 'google-dns' }], domains: [] }),
|
||||||
|
runDns: async (...args) => {
|
||||||
|
calls.push(['dns', ...args]);
|
||||||
|
return { checkedAt: 'dns-now', results: [] };
|
||||||
|
},
|
||||||
updateSettings: (...args) => calls.push(['settings', ...args]),
|
updateSettings: (...args) => calls.push(['settings', ...args]),
|
||||||
},
|
},
|
||||||
readBody: async () => {
|
readBody: async () => {
|
||||||
@@ -276,6 +293,16 @@ test('connectivity route preserves exact URL, defaults and raw response', async
|
|||||||
assert.deepEqual(calls.at(-1), ['settings', body.settings, 4]);
|
assert.deepEqual(calls.at(-1), ['settings', body.settings, 4]);
|
||||||
assert.equal(settingsResponse.payload.success, true);
|
assert.equal(settingsResponse.payload.success, true);
|
||||||
|
|
||||||
|
const catalogResponse = routeResponse();
|
||||||
|
assert.equal(await route.handle({ method: 'GET', url: '/api/diagnostics/dns' }, catalogResponse), true);
|
||||||
|
assert.deepEqual(catalogResponse.payload, { resolvers: [{ id: 'google-dns' }], domains: [] });
|
||||||
|
|
||||||
|
body = { domainId: 'youtube', resolverId: 'google-dns' };
|
||||||
|
const dnsResponse = routeResponse();
|
||||||
|
assert.equal(await route.handle({ method: 'POST', url: '/api/diagnostics/dns' }, dnsResponse), true);
|
||||||
|
assert.deepEqual(calls.at(-1), ['dns', 'youtube', 'google-dns']);
|
||||||
|
assert.deepEqual(dnsResponse.payload, { checkedAt: 'dns-now', results: [] });
|
||||||
|
|
||||||
for (const [method, url] of [
|
for (const [method, url] of [
|
||||||
['GET', '/api/diagnostics/connectivity'],
|
['GET', '/api/diagnostics/connectivity'],
|
||||||
['POST', '/api/diagnostics/connectivity?target=all'],
|
['POST', '/api/diagnostics/connectivity?target=all'],
|
||||||
@@ -284,7 +311,7 @@ test('connectivity route preserves exact URL, defaults and raw response', async
|
|||||||
]) {
|
]) {
|
||||||
assert.equal(await route.handle({ method, url }, routeResponse()), false);
|
assert.equal(await route.handle({ method, url }, routeResponse()), false);
|
||||||
}
|
}
|
||||||
assert.equal(bodyReads, 3);
|
assert.equal(bodyReads, 4);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('a targeted IP row uses three samples and keeps the majority address', async () => {
|
test('a targeted IP row uses three samples and keeps the majority address', async () => {
|
||||||
|
|||||||
@@ -26,12 +26,16 @@ test('control uses the dataplane socket protocol', async () => {
|
|||||||
assert.equal(traffic.running, true);
|
assert.equal(traffic.running, true);
|
||||||
const domainTraffic = await client.observeDomainTraffic();
|
const domainTraffic = await client.observeDomainTraffic();
|
||||||
assert.equal(domainTraffic.running, true);
|
assert.equal(domainTraffic.running, true);
|
||||||
|
const liveTraffic = await client.observeLiveTraffic();
|
||||||
|
assert.equal(liveTraffic.running, true);
|
||||||
await client.observeDevicePolicy();
|
await client.observeDevicePolicy();
|
||||||
await client.applyDevicePolicies([{ id: 'dev_0011223344556677' }]);
|
await client.applyDevicePolicies([{ id: 'dev_0011223344556677' }]);
|
||||||
await client.runConnectivityDiagnostics(
|
await client.runConnectivityDiagnostics(
|
||||||
[{ id: 'custom-test', url: 'https://example.com' }],
|
[{ id: 'custom-test', url: 'https://example.com' }],
|
||||||
'site:custom-test',
|
'site:custom-test',
|
||||||
);
|
);
|
||||||
|
await client.getDnsDiagnosticsCatalog([{ id: 'dns' }], [{ id: 'domain' }]);
|
||||||
|
await client.runDnsDiagnostics([{ id: 'dns' }], [{ id: 'domain' }], 'youtube', 'google-dns');
|
||||||
await client.checkConfig({ outbounds: [] });
|
await client.checkConfig({ outbounds: [] });
|
||||||
await client.runFailoverProbe('primary', [], 'site:youtube', 9_000);
|
await client.runFailoverProbe('primary', [], 'site:youtube', 9_000);
|
||||||
await client.readFailoverSelector();
|
await client.readFailoverSelector();
|
||||||
@@ -47,9 +51,12 @@ test('control uses the dataplane socket protocol', async () => {
|
|||||||
'GET /devices /run/dataplane.sock',
|
'GET /devices /run/dataplane.sock',
|
||||||
'GET /device-traffic /run/dataplane.sock',
|
'GET /device-traffic /run/dataplane.sock',
|
||||||
'GET /domain-traffic /run/dataplane.sock',
|
'GET /domain-traffic /run/dataplane.sock',
|
||||||
|
'GET /traffic/live /run/dataplane.sock',
|
||||||
'GET /device-policy /run/dataplane.sock',
|
'GET /device-policy /run/dataplane.sock',
|
||||||
'PUT /device-policy /run/dataplane.sock',
|
'PUT /device-policy /run/dataplane.sock',
|
||||||
'POST /diagnostics/connectivity /run/dataplane.sock',
|
'POST /diagnostics/connectivity /run/dataplane.sock',
|
||||||
|
'POST /diagnostics/dns/catalog /run/dataplane.sock',
|
||||||
|
'POST /diagnostics/dns /run/dataplane.sock',
|
||||||
'POST /config/check /run/dataplane.sock',
|
'POST /config/check /run/dataplane.sock',
|
||||||
'POST /failover/probe /run/dataplane.sock',
|
'POST /failover/probe /run/dataplane.sock',
|
||||||
'GET /failover/selector /run/dataplane.sock',
|
'GET /failover/selector /run/dataplane.sock',
|
||||||
@@ -59,16 +66,24 @@ test('control uses the dataplane socket protocol', async () => {
|
|||||||
'POST /restart /run/dataplane.sock',
|
'POST /restart /run/dataplane.sock',
|
||||||
'POST /stop /run/dataplane.sock',
|
'POST /stop /run/dataplane.sock',
|
||||||
]);
|
]);
|
||||||
assert.deepEqual(requests[6].body, { devices: [{ id: 'dev_0011223344556677' }] });
|
assert.deepEqual(requests[7].body, { devices: [{ id: 'dev_0011223344556677' }] });
|
||||||
assert.deepEqual(requests[7].body, {
|
assert.deepEqual(requests[8].body, {
|
||||||
services: [{ id: 'custom-test', url: 'https://example.com' }],
|
services: [{ id: 'custom-test', url: 'https://example.com' }],
|
||||||
target: 'site:custom-test',
|
target: 'site:custom-test',
|
||||||
});
|
});
|
||||||
assert.equal(requests[7].timeoutMs, 25_000);
|
assert.equal(requests[8].timeoutMs, 25_000);
|
||||||
assert.deepEqual(requests[8].body, { config: { outbounds: [] } });
|
assert.deepEqual(requests[9].body, {
|
||||||
assert.deepEqual(requests[9].body, { role: 'primary', services: [], target: 'site:youtube', timeoutMs: 9_000 });
|
customResolvers: [{ id: 'dns' }], customDomains: [{ id: 'domain' }],
|
||||||
assert.equal(requests[9].timeoutMs, 19_000);
|
});
|
||||||
assert.deepEqual(requests[11].body, { role: 'reserve' });
|
assert.deepEqual(requests[10].body, {
|
||||||
|
customResolvers: [{ id: 'dns' }], customDomains: [{ id: 'domain' }],
|
||||||
|
domainId: 'youtube', resolverId: 'google-dns',
|
||||||
|
});
|
||||||
|
assert.equal(requests[10].timeoutMs, 40_000);
|
||||||
|
assert.deepEqual(requests[11].body, { config: { outbounds: [] } });
|
||||||
|
assert.deepEqual(requests[12].body, { role: 'primary', services: [], target: 'site:youtube', timeoutMs: 9_000 });
|
||||||
|
assert.equal(requests[12].timeoutMs, 19_000);
|
||||||
|
assert.deepEqual(requests[14].body, { role: 'reserve' });
|
||||||
});
|
});
|
||||||
|
|
||||||
test('connectivity diagnostics expose a retryable domain error', async () => {
|
test('connectivity diagnostics expose a retryable domain error', async () => {
|
||||||
|
|||||||
@@ -33,6 +33,17 @@ test('gateway deploy updates control without recreating dataplane', () => {
|
|||||||
assert.doesNotMatch(workflow, /grep -Eq/);
|
assert.doesNotMatch(workflow, /grep -Eq/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('Gateway native API credentials remain private to the dataplane volume', () => {
|
||||||
|
assert.equal(compose.split('SING_BOX_TRAFFIC_SOURCE: ${SING_BOX_TRAFFIC_SOURCE:-native}').length - 1, 2);
|
||||||
|
assert.equal(deploy.split('SING_BOX_TRAFFIC_SOURCE: \\${SING_BOX_TRAFFIC_SOURCE:-native}').length - 1, 2);
|
||||||
|
assert.match(compose, /vpn-proxy-dataplane:[\s\S]*SING_BOX_API_SECRET: \/var\/lib\/sing-box\/api\.secret[\s\S]*sing-box-cache:\/var\/lib\/sing-box/);
|
||||||
|
assert.doesNotMatch(
|
||||||
|
compose.match(/vpn-proxy-control:[\s\S]*?(?=\nvolumes:)/)?.[0] || '',
|
||||||
|
/SING_BOX_API_SECRET|sing-box-cache|19091/,
|
||||||
|
);
|
||||||
|
assert.doesNotMatch(compose.match(/ports:[\s\S]*?volumes:/)?.[0] || '', /19091/);
|
||||||
|
});
|
||||||
|
|
||||||
test('manual hard deploy safely forces the existing full Gateway path', () => {
|
test('manual hard deploy safely forces the existing full Gateway path', () => {
|
||||||
assert.match(workflow, /workflow_dispatch:\s*\n\s+inputs:\s*\n\s+hard_deploy:[\s\S]*default: false[\s\S]*type: boolean/);
|
assert.match(workflow, /workflow_dispatch:\s*\n\s+inputs:\s*\n\s+hard_deploy:[\s\S]*default: false[\s\S]*type: boolean/);
|
||||||
assert.match(workflow, /env:\s*\n\s+HARD_DEPLOY_INPUT: \$\{\{ inputs\.hard_deploy \}\}/);
|
assert.match(workflow, /env:\s*\n\s+HARD_DEPLOY_INPUT: \$\{\{ inputs\.hard_deploy \}\}/);
|
||||||
|
|||||||
@@ -29,8 +29,9 @@ const observation = (ip, mac = '00:11:22:33:44:55', deviceInterface = 'eth0') =>
|
|||||||
|
|
||||||
test('dataplane exposes cached traffic snapshots without making accounting a readiness dependency', () => {
|
test('dataplane exposes cached traffic snapshots without making accounting a readiness dependency', () => {
|
||||||
assert.match(dataplaneSource, /req\.method === 'GET' && req\.url === '\/device-traffic'[\s\S]*traffic\.snapshot\(\)/);
|
assert.match(dataplaneSource, /req\.method === 'GET' && req\.url === '\/device-traffic'[\s\S]*traffic\.snapshot\(\)/);
|
||||||
assert.match(dataplaneSource, /ready = true;[\s\S]*deviceTrafficAccountingEnabled[\s\S]*setImmediate[\s\S]*traffic\.refresh\(\)/);
|
assert.match(dataplaneSource, /async function refreshDeviceTraffic\(\)[\s\S]*traffic\.refresh\(\)/);
|
||||||
assert.match(dataplaneSource, /traffic\.refresh\(\)\.catch/);
|
assert.match(dataplaneSource, /ready = true;[\s\S]*deviceTrafficAccountingEnabled[\s\S]*setImmediate[\s\S]*refreshDeviceTraffic\(\)/);
|
||||||
|
assert.match(dataplaneSource, /refreshDeviceTraffic\(\)\.catch/);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('traffic selection keeps only unambiguous IPv4 neighbors', () => {
|
test('traffic selection keeps only unambiguous IPv4 neighbors', () => {
|
||||||
|
|||||||
@@ -0,0 +1,208 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
import test from 'node:test';
|
||||||
|
|
||||||
|
import {
|
||||||
|
buildDnsQuery,
|
||||||
|
createDnsDiagnosticsService,
|
||||||
|
parseDnsResponse,
|
||||||
|
} from '../../dist/server/services/dnsDiagnosticsService.js';
|
||||||
|
|
||||||
|
function wireResponse(query, { rcode = 0, truncated = false, address = null } = {}) {
|
||||||
|
const header = Buffer.alloc(12);
|
||||||
|
query.copy(header, 0, 0, 2);
|
||||||
|
header.writeUInt16BE(0x8180 | rcode | (truncated ? 0x0200 : 0), 2);
|
||||||
|
header.writeUInt16BE(1, 4);
|
||||||
|
header.writeUInt16BE(address ? 1 : 0, 6);
|
||||||
|
const question = query.subarray(12);
|
||||||
|
if (!address) return Buffer.concat([header, question]);
|
||||||
|
const type = query.readUInt16BE(query.length - 4);
|
||||||
|
const data = type === 1
|
||||||
|
? Buffer.from(address.split('.').map(Number))
|
||||||
|
: Buffer.from(address.split(':').flatMap((part) => {
|
||||||
|
const value = Number.parseInt(part || '0', 16);
|
||||||
|
return [value >> 8, value & 0xff];
|
||||||
|
}));
|
||||||
|
const answer = Buffer.alloc(12);
|
||||||
|
answer.writeUInt16BE(0xc00c, 0);
|
||||||
|
answer.writeUInt16BE(type, 2);
|
||||||
|
answer.writeUInt16BE(1, 4);
|
||||||
|
answer.writeUInt32BE(60, 6);
|
||||||
|
answer.writeUInt16BE(data.length, 10);
|
||||||
|
return Buffer.concat([header, question, answer, data]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function addressFor(packet, path = 'direct') {
|
||||||
|
return packet.readUInt16BE(packet.length - 4) === 1
|
||||||
|
? path === 'direct' ? '8.8.8.8' : '9.9.9.9'
|
||||||
|
: path === 'direct' ? '2001:db8:0:0:0:0:0:1' : '2001:db8:0:0:0:0:0:2';
|
||||||
|
}
|
||||||
|
|
||||||
|
test('DNS wire codec parses compressed A/AAAA and rejects malformed packets', () => {
|
||||||
|
const aQuery = buildDnsQuery('www.youtube.com', 'A', 17);
|
||||||
|
const aaaaQuery = buildDnsQuery('www.youtube.com', 'AAAA', 18);
|
||||||
|
assert.deepEqual(parseDnsResponse(wireResponse(aQuery, { address: '8.8.8.8' }), 17), {
|
||||||
|
rcode: 'NOERROR', truncated: false, ipv4: ['8.8.8.8'], ipv6: [],
|
||||||
|
});
|
||||||
|
assert.deepEqual(parseDnsResponse(wireResponse(aaaaQuery, {
|
||||||
|
address: '2001:db8:0:0:0:0:0:1',
|
||||||
|
}), 18).ipv6, ['2001:db8:0:0:0:0:0:1']);
|
||||||
|
assert.equal(parseDnsResponse(wireResponse(aQuery, { rcode: 3 }), 17).rcode, 'NXDOMAIN');
|
||||||
|
assert.equal(parseDnsResponse(wireResponse(aQuery, { rcode: 2 }), 17).rcode, 'SERVFAIL');
|
||||||
|
assert.throws(() => parseDnsResponse(Buffer.from([0, 1]), 1), /malformed/);
|
||||||
|
assert.throws(() => parseDnsResponse(wireResponse(aQuery), 99), /unexpected/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('DNS checker keeps Direct and VPN independent and never probes VPN when it is off', async () => {
|
||||||
|
const calls = [];
|
||||||
|
const classicExchange = async ({ path: route, resolver, packet }) => {
|
||||||
|
calls.push([route, resolver.endpoint, packet.readUInt16BE(packet.length - 4)]);
|
||||||
|
return {
|
||||||
|
response: wireResponse(packet, { address: addressFor(packet, route) }),
|
||||||
|
transport: 'udp',
|
||||||
|
latencyMs: route === 'direct' ? 10 : 20,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
const service = createDnsDiagnosticsService({
|
||||||
|
proxyPort: 18080, getServers: () => [], classicExchange,
|
||||||
|
now: () => '2026-09-01T00:00:00.000Z',
|
||||||
|
});
|
||||||
|
const running = await service.run({ vpnAvailable: true, domainId: 'youtube', resolverId: 'google-dns' });
|
||||||
|
assert.deepEqual(calls.map(([route]) => route).sort(), ['direct', 'direct', 'vpn', 'vpn']);
|
||||||
|
assert.equal(running.results[0].comparison, 'different');
|
||||||
|
assert.deepEqual(running.results[0].direct.ipv4, ['8.8.8.8']);
|
||||||
|
assert.deepEqual(running.results[0].vpn.ipv6, ['2001:db8:0:0:0:0:0:2']);
|
||||||
|
|
||||||
|
calls.length = 0;
|
||||||
|
const stopped = await service.run({ vpnAvailable: false, domainId: 'youtube', resolverId: 'google-dns' });
|
||||||
|
assert.deepEqual(calls.map(([route]) => route), ['direct', 'direct']);
|
||||||
|
assert.equal(stopped.results[0].vpn.status, 'vpn-off');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('DNS checker retries timeout once and uses TCP fallback on a truncated response', async () => {
|
||||||
|
let attempts = 0;
|
||||||
|
const timedOut = createDnsDiagnosticsService({
|
||||||
|
proxyPort: 18080,
|
||||||
|
getServers: () => [],
|
||||||
|
classicExchange: async () => {
|
||||||
|
attempts += 1;
|
||||||
|
const error = new Error('timeout');
|
||||||
|
error.code = 'ETIMEDOUT';
|
||||||
|
throw error;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const timeoutResult = await timedOut.run({
|
||||||
|
vpnAvailable: false, domainId: 'youtube', resolverId: 'google-dns',
|
||||||
|
});
|
||||||
|
assert.equal(attempts, 4);
|
||||||
|
assert.equal(timeoutResult.results[0].direct.status, 'timeout');
|
||||||
|
|
||||||
|
const fallbackCalls = [];
|
||||||
|
const fallback = createDnsDiagnosticsService({
|
||||||
|
proxyPort: 18080,
|
||||||
|
getServers: () => [],
|
||||||
|
classicExchange: async ({ packet }) => ({
|
||||||
|
response: wireResponse(packet, { truncated: true }), transport: 'udp', latencyMs: 2,
|
||||||
|
}),
|
||||||
|
tcpExchange: async ({ path: route, packet }) => {
|
||||||
|
fallbackCalls.push(route);
|
||||||
|
return {
|
||||||
|
response: wireResponse(packet, { address: addressFor(packet, route) }),
|
||||||
|
transport: 'tcp', latencyMs: 4,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const fallbackResult = await fallback.run({
|
||||||
|
vpnAvailable: true, domainId: 'youtube', resolverId: 'google-dns',
|
||||||
|
});
|
||||||
|
assert.deepEqual(fallbackCalls.sort(), ['direct', 'direct', 'vpn', 'vpn']);
|
||||||
|
assert.equal(fallbackResult.results[0].direct.transport, 'tcp');
|
||||||
|
assert.equal(fallbackResult.results[0].vpn.transport, 'tcp');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('custom DoH is pinned only to public bootstrap addresses', async () => {
|
||||||
|
const resolver = {
|
||||||
|
id: 'custom-dns-office', label: 'Office DoH', kind: 'doh', endpoint: 'https://dns.example/dns-query',
|
||||||
|
};
|
||||||
|
const calls = [];
|
||||||
|
const service = createDnsDiagnosticsService({
|
||||||
|
proxyPort: 18080,
|
||||||
|
getServers: () => [],
|
||||||
|
lookup: async () => [{ address: '93.184.216.34', family: 4 }],
|
||||||
|
dohExchange: async ({ path: route, resolver: pinned, packet }) => {
|
||||||
|
calls.push([route, pinned.bootstrap]);
|
||||||
|
return {
|
||||||
|
response: wireResponse(packet, { address: addressFor(packet, route) }),
|
||||||
|
transport: 'doh', latencyMs: 7,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const result = await service.run({
|
||||||
|
vpnAvailable: true, customResolvers: [resolver], domainId: 'chatgpt', resolverId: resolver.id,
|
||||||
|
});
|
||||||
|
assert.deepEqual(calls, [
|
||||||
|
['direct', '93.184.216.34'], ['direct', '93.184.216.34'],
|
||||||
|
['vpn', '93.184.216.34'], ['vpn', '93.184.216.34'],
|
||||||
|
]);
|
||||||
|
assert.equal(result.results[0].direct.transport, 'doh');
|
||||||
|
|
||||||
|
let requests = 0;
|
||||||
|
const blocked = createDnsDiagnosticsService({
|
||||||
|
proxyPort: 18080,
|
||||||
|
getServers: () => [],
|
||||||
|
lookup: async () => [{ address: '127.0.0.1', family: 4 }],
|
||||||
|
dohExchange: async () => { requests += 1; throw new Error('unexpected'); },
|
||||||
|
});
|
||||||
|
const blockedResult = await blocked.run({
|
||||||
|
vpnAvailable: true, customResolvers: [resolver], domainId: 'chatgpt', resolverId: resolver.id,
|
||||||
|
});
|
||||||
|
assert.equal(requests, 0);
|
||||||
|
assert.equal(blockedResult.results[0].direct.error, 'DoH endpoint is not public');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('full DNS run includes system and built-in rows with at most three resolver workers', async () => {
|
||||||
|
const active = new Map();
|
||||||
|
let maximumResolvers = 0;
|
||||||
|
const exchange = async ({ path: route, resolver, packet }) => {
|
||||||
|
active.set(resolver.id, (active.get(resolver.id) || 0) + 1);
|
||||||
|
maximumResolvers = Math.max(maximumResolvers, active.size);
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 2));
|
||||||
|
const count = active.get(resolver.id) - 1;
|
||||||
|
if (count) active.set(resolver.id, count);
|
||||||
|
else active.delete(resolver.id);
|
||||||
|
return {
|
||||||
|
response: wireResponse(packet, { address: addressFor(packet, route) }),
|
||||||
|
transport: resolver.kind === 'doh' ? 'doh' : 'udp',
|
||||||
|
latencyMs: 2,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
const service = createDnsDiagnosticsService({
|
||||||
|
proxyPort: 18080,
|
||||||
|
getServers: () => ['192.168.1.1'],
|
||||||
|
classicExchange: exchange,
|
||||||
|
dohExchange: exchange,
|
||||||
|
});
|
||||||
|
const catalog = await service.catalog();
|
||||||
|
assert.equal(catalog.resolvers[0].system, true);
|
||||||
|
assert.equal(catalog.resolvers.length, 7);
|
||||||
|
assert.deepEqual(catalog.domains.map(({ hostname }) => hostname), ['www.youtube.com', 'chatgpt.com']);
|
||||||
|
|
||||||
|
const result = await service.run({ vpnAvailable: false, domainId: 'youtube' });
|
||||||
|
assert.equal(result.results.length, 7);
|
||||||
|
assert.ok(maximumResolvers <= 3);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('default transports enforce direct bypass, VPN proxy, binary DoH and no redirects', () => {
|
||||||
|
const source = fs.readFileSync(
|
||||||
|
path.resolve(import.meta.dirname, '../../src/server/services/dnsDiagnosticsService.ts'),
|
||||||
|
'utf8',
|
||||||
|
);
|
||||||
|
assert.match(source, /path === 'direct'[\s\S]*udpExchange\(address, port/);
|
||||||
|
assert.match(source, /socksHandshake\(3,[\s\S]*socksHandshake\(1,/);
|
||||||
|
assert.match(source, /'--noproxy', '\*'/);
|
||||||
|
assert.match(source, /'--proxy', `http:\/\/127\.0\.0\.1:\$\{proxyPort\}`/);
|
||||||
|
assert.match(source, /'--data-binary', '@-'/);
|
||||||
|
assert.match(source, /'--resolve'/);
|
||||||
|
assert.doesNotMatch(source, /--location/);
|
||||||
|
});
|
||||||
@@ -16,6 +16,40 @@ const connection = (connectionId, type, host, upload, download, sourceIP = devic
|
|||||||
download,
|
download,
|
||||||
chains,
|
chains,
|
||||||
});
|
});
|
||||||
|
const nativeConnection = (connectionId, uploadBytes, downloadBytes, overrides = {}) => ({
|
||||||
|
id: connectionId,
|
||||||
|
startedAt: '2026-08-31T10:00:00.000Z',
|
||||||
|
closedAt: null,
|
||||||
|
inbound: { tag: 'tproxy-in', type: 'tproxy' },
|
||||||
|
network: 'tcp',
|
||||||
|
protocol: 'tls',
|
||||||
|
source: { ip: device.ip, port: 54_000 },
|
||||||
|
destination: { domain: 'example.com', ip: null, port: 443, provenance: 'sing-box' },
|
||||||
|
origin: { kind: 'device', id, label: 'MacBook', provenance: 'source-ip' },
|
||||||
|
route: {
|
||||||
|
kind: 'vpn',
|
||||||
|
scope: 'local-sing-box',
|
||||||
|
outbound: 'channel-selector',
|
||||||
|
outboundType: 'selector',
|
||||||
|
chain: ['channel-primary', 'channel-selector'],
|
||||||
|
rule: 'final',
|
||||||
|
},
|
||||||
|
traffic: {
|
||||||
|
uploadBytes,
|
||||||
|
downloadBytes,
|
||||||
|
uploadBytesPerSecond: '0',
|
||||||
|
downloadBytesPerSecond: '0',
|
||||||
|
},
|
||||||
|
...overrides,
|
||||||
|
});
|
||||||
|
const nativeBatch = (connections, overrides = {}) => ({
|
||||||
|
epoch: 'sing-box-100',
|
||||||
|
observedAt: '2026-08-31T10:00:00.000Z',
|
||||||
|
reset: false,
|
||||||
|
connections,
|
||||||
|
closedIds: [],
|
||||||
|
...overrides,
|
||||||
|
});
|
||||||
|
|
||||||
test('sing-box route traffic keeps vpn, direct and unknown deltas separate', async () => {
|
test('sing-box route traffic keeps vpn, direct and unknown deltas separate', async () => {
|
||||||
let response = { connections: [
|
let response = { connections: [
|
||||||
@@ -102,6 +136,22 @@ test('domain traffic accumulates connection deltas by device, service and source
|
|||||||
downloadBytes: '70',
|
downloadBytes: '70',
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
assert.deepEqual(service.snapshot().routes, [
|
||||||
|
{
|
||||||
|
deviceId: id,
|
||||||
|
source: 'gateway',
|
||||||
|
outbound: 'vpn',
|
||||||
|
uploadBytes: '22',
|
||||||
|
downloadBytes: '200',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
deviceId: id,
|
||||||
|
source: 'proxy',
|
||||||
|
outbound: 'vpn',
|
||||||
|
uploadBytes: '22',
|
||||||
|
downloadBytes: '260',
|
||||||
|
},
|
||||||
|
]);
|
||||||
assert.deepEqual(service.snapshot().attributionEvents, {
|
assert.deepEqual(service.snapshot().attributionEvents, {
|
||||||
unresolved_host: '1',
|
unresolved_host: '1',
|
||||||
unknown_device: '1',
|
unknown_device: '1',
|
||||||
@@ -238,3 +288,73 @@ test('failover activity is zero-work while disabled and uses the existing connec
|
|||||||
service.disableActivity();
|
service.disableActivity();
|
||||||
assert.equal(service.activitySnapshot(500), null);
|
assert.equal(service.activitySnapshot(500), null);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('native lifecycle batches keep decimal precision and dedupe a same-epoch reconnect reset and final tail', () => {
|
||||||
|
let now = new Date('2026-08-31T10:00:00.000Z');
|
||||||
|
const service = createDomainTrafficService({
|
||||||
|
observe: () => ({ connections: [] }),
|
||||||
|
devices: () => [],
|
||||||
|
now: () => now,
|
||||||
|
});
|
||||||
|
const initial = nativeConnection('native', '9007199254740993', '10');
|
||||||
|
service.ingestNative(nativeBatch([initial], { reset: true }));
|
||||||
|
now = new Date('2026-08-31T10:00:01.000Z');
|
||||||
|
service.ingestNative(nativeBatch([initial], {
|
||||||
|
reset: true,
|
||||||
|
observedAt: now.toISOString(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
assert.equal(service.snapshot().series[0].uploadBytes, '9007199254740993');
|
||||||
|
assert.equal(service.snapshot().source.activeConnections, 1);
|
||||||
|
|
||||||
|
service.enableActivity();
|
||||||
|
now = new Date('2026-08-31T10:00:02.000Z');
|
||||||
|
service.ingestNative(nativeBatch([
|
||||||
|
nativeConnection('native', '9007199254740998', '15', { closedAt: now.toISOString() }),
|
||||||
|
], {
|
||||||
|
observedAt: now.toISOString(),
|
||||||
|
closedIds: ['native'],
|
||||||
|
}));
|
||||||
|
|
||||||
|
assert.equal(service.snapshot().series[0].uploadBytes, '9007199254740998');
|
||||||
|
assert.equal(service.snapshot().series[0].downloadBytes, '15');
|
||||||
|
assert.equal(service.snapshot().source.activeConnections, 0);
|
||||||
|
assert.equal(service.activitySnapshot(0).state, 'active');
|
||||||
|
|
||||||
|
now = new Date('2026-08-31T10:00:03.000Z');
|
||||||
|
service.ingestNative(nativeBatch([
|
||||||
|
nativeConnection('native', '9007199254740998', '15'),
|
||||||
|
], {
|
||||||
|
reset: true,
|
||||||
|
observedAt: now.toISOString(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
assert.equal(service.snapshot().series[0].uploadBytes, '9007199254740998');
|
||||||
|
assert.equal(service.snapshot().series[0].downloadBytes, '15');
|
||||||
|
assert.equal(service.snapshot().source.activeConnections, 1);
|
||||||
|
|
||||||
|
now = new Date('2026-08-31T10:00:14.000Z');
|
||||||
|
service.ingestNative(nativeBatch([], { observedAt: now.toISOString() }));
|
||||||
|
assert.equal(service.snapshot().observedAt, now.toISOString());
|
||||||
|
assert.equal(service.snapshot().source.activeConnections, 1);
|
||||||
|
assert.equal(service.activitySnapshot(0).state, 'quiet');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('one native batch accounts every final tail before lifecycle and UI caps', () => {
|
||||||
|
const service = createDomainTrafficService({
|
||||||
|
observe: () => ({ connections: [] }),
|
||||||
|
devices: () => [],
|
||||||
|
});
|
||||||
|
const connections = Array.from({ length: 2_049 }, (_, index) => (
|
||||||
|
nativeConnection(`closed-${index}`, '1', '1', { closedAt: '2026-08-31T10:00:00.000Z' })
|
||||||
|
));
|
||||||
|
|
||||||
|
service.ingestNative(nativeBatch(connections, {
|
||||||
|
reset: true,
|
||||||
|
closedIds: connections.map(({ id: connectionId }) => connectionId),
|
||||||
|
}));
|
||||||
|
|
||||||
|
assert.equal(service.snapshot().series[0].uploadBytes, '2049');
|
||||||
|
assert.equal(service.snapshot().series[0].downloadBytes, '2049');
|
||||||
|
assert.equal(service.snapshot().source.activeConnections, 0);
|
||||||
|
});
|
||||||
|
|||||||
@@ -0,0 +1,99 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { spawnSync } from 'node:child_process';
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import os from 'node:os';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { pathToFileURL } from 'node:url';
|
||||||
|
import test from 'node:test';
|
||||||
|
|
||||||
|
const singboxUrl = pathToFileURL(path.resolve('dist/server/singbox.js')).href;
|
||||||
|
const subscriptionConfig = {
|
||||||
|
outbounds: [{
|
||||||
|
type: 'vless',
|
||||||
|
tag: 'vpn',
|
||||||
|
server: 'vpn.example.test',
|
||||||
|
server_port: 443,
|
||||||
|
uuid: '00000000-0000-4000-8000-000000000000',
|
||||||
|
tls: { enabled: true },
|
||||||
|
}],
|
||||||
|
};
|
||||||
|
|
||||||
|
function run(source, { component = 'dataplane', socket = '/tmp/harbor-test.sock' } = {}) {
|
||||||
|
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'harbor-gateway-native-config-'));
|
||||||
|
const script = `
|
||||||
|
const { buildGatewayConfig, buildDualChannelGatewayConfig } = await import(${JSON.stringify(singboxUrl)});
|
||||||
|
const subscription = ${JSON.stringify(subscriptionConfig)};
|
||||||
|
process.stdout.write(JSON.stringify({
|
||||||
|
single: buildGatewayConfig(subscription, 'vpn'),
|
||||||
|
dual: buildDualChannelGatewayConfig({
|
||||||
|
primary: { subscriptionConfig: subscription, selectedServerId: 'vpn' },
|
||||||
|
reserve: { subscriptionConfig: subscription, selectedServerId: 'vpn' },
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
`;
|
||||||
|
try {
|
||||||
|
return spawnSync(process.execPath, ['--input-type=module', '--eval', script], {
|
||||||
|
cwd: path.resolve('.'),
|
||||||
|
encoding: 'utf8',
|
||||||
|
env: {
|
||||||
|
...process.env,
|
||||||
|
APP_MODE: 'gateway',
|
||||||
|
APP_COMPONENT: component,
|
||||||
|
DATA_DIR: directory,
|
||||||
|
SING_BOX_CACHE: path.join(directory, 'cache.db'),
|
||||||
|
SING_BOX_TRAFFIC_SOURCE: source,
|
||||||
|
DATAPLANE_SOCKET: socket,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
fs.rmSync(directory, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
test('Gateway snapshot keeps the 1.13-compatible config and Clash traffic API', () => {
|
||||||
|
const result = run('snapshot', { component: '', socket: '' });
|
||||||
|
assert.equal(result.status, 0, result.stderr);
|
||||||
|
const configs = JSON.parse(result.stdout);
|
||||||
|
for (const config of Object.values(configs)) {
|
||||||
|
assert.equal(config.services, undefined);
|
||||||
|
assert.deepEqual(config.dns, { independent_cache: true });
|
||||||
|
assert.deepEqual(config.experimental.clash_api, { external_controller: '127.0.0.1:19090' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Gateway shadow and native add one secret-free loopback API to single and dual configs', () => {
|
||||||
|
for (const source of ['shadow', 'native']) {
|
||||||
|
const result = run(source);
|
||||||
|
assert.equal(result.status, 0, result.stderr);
|
||||||
|
const configs = JSON.parse(result.stdout);
|
||||||
|
for (const config of Object.values(configs)) {
|
||||||
|
assert.deepEqual(config.services, [{
|
||||||
|
type: 'api',
|
||||||
|
listen: '127.0.0.1',
|
||||||
|
listen_port: 19091,
|
||||||
|
dashboard: false,
|
||||||
|
}]);
|
||||||
|
assert.equal(JSON.stringify(config).includes('secret'), false);
|
||||||
|
assert.deepEqual(config.dns, {});
|
||||||
|
assert.deepEqual(config.experimental.clash_api, { external_controller: '127.0.0.1:19090' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Gateway shadow and native reject combined or socket-less topology', () => {
|
||||||
|
for (const options of [
|
||||||
|
{ component: '', socket: '' },
|
||||||
|
{ component: 'control', socket: '' },
|
||||||
|
{ component: 'dataplane', socket: '' },
|
||||||
|
]) {
|
||||||
|
const result = run('native', options);
|
||||||
|
assert.notEqual(result.status, 0);
|
||||||
|
assert.match(result.stderr, /require split control\/dataplane topology/);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Gateway rejects unknown traffic sources', () => {
|
||||||
|
const result = run('disabled');
|
||||||
|
assert.notEqual(result.status, 0);
|
||||||
|
assert.match(result.stderr, /must be snapshot, shadow or native/);
|
||||||
|
});
|
||||||
@@ -0,0 +1,185 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import os from 'node:os';
|
||||||
|
import path from 'node:path';
|
||||||
|
import test from 'node:test';
|
||||||
|
|
||||||
|
import {
|
||||||
|
ensureGatewayNativeApiSecret,
|
||||||
|
materializeGatewayNativeConfig,
|
||||||
|
} from '../../dist/server/gatewayNativeRuntime.js';
|
||||||
|
import { createSingboxRuntime } from '../../dist/server/singboxRuntime.js';
|
||||||
|
|
||||||
|
const apiService = {
|
||||||
|
type: 'api',
|
||||||
|
listen: '127.0.0.1',
|
||||||
|
listen_port: 19091,
|
||||||
|
dashboard: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
function mode(filePath) {
|
||||||
|
return fs.statSync(filePath).mode & 0o777;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function waitForJson(filePath) {
|
||||||
|
for (let attempt = 0; attempt < 100; attempt += 1) {
|
||||||
|
try {
|
||||||
|
return JSON.parse(fs.readFileSync(filePath, 'utf8'));
|
||||||
|
} catch {}
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 5));
|
||||||
|
}
|
||||||
|
throw new Error(`Timed out waiting for ${filePath}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
test('Gateway native materialization keeps a stable 0600 secret out of shared config', (t) => {
|
||||||
|
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'harbor-native-secret-'));
|
||||||
|
t.after(() => fs.rmSync(directory, { recursive: true, force: true }));
|
||||||
|
const secretPath = path.join(directory, 'api.secret');
|
||||||
|
const runtimeConfigPath = path.join(directory, 'runtime-config.json');
|
||||||
|
const config = { services: [apiService], inbounds: [], outbounds: [] };
|
||||||
|
|
||||||
|
const first = materializeGatewayNativeConfig(config, {
|
||||||
|
apiPort: 19091,
|
||||||
|
secretPath,
|
||||||
|
runtimeConfigPath,
|
||||||
|
});
|
||||||
|
const second = materializeGatewayNativeConfig(config, {
|
||||||
|
apiPort: 19091,
|
||||||
|
secretPath,
|
||||||
|
runtimeConfigPath,
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.match(first.secret, /^[0-9a-f]{64}$/);
|
||||||
|
assert.equal(second.secret, first.secret);
|
||||||
|
assert.equal(first.warning, null);
|
||||||
|
assert.equal(mode(secretPath), 0o600);
|
||||||
|
assert.equal(mode(runtimeConfigPath), 0o600);
|
||||||
|
assert.equal(JSON.stringify(config).includes(first.secret), false);
|
||||||
|
assert.equal(JSON.parse(fs.readFileSync(runtimeConfigPath, 'utf8')).services[0].secret, first.secret);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Gateway native secret rejects symlinks and repairs regular-file permissions', (t) => {
|
||||||
|
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'harbor-native-secret-mode-'));
|
||||||
|
t.after(() => fs.rmSync(directory, { recursive: true, force: true }));
|
||||||
|
const regularPath = path.join(directory, 'regular.secret');
|
||||||
|
fs.writeFileSync(regularPath, 'a'.repeat(64), { mode: 0o644 });
|
||||||
|
assert.equal(ensureGatewayNativeApiSecret(regularPath), 'a'.repeat(64));
|
||||||
|
assert.equal(mode(regularPath), 0o600);
|
||||||
|
|
||||||
|
const linkPath = path.join(directory, 'linked.secret');
|
||||||
|
fs.symlinkSync(regularPath, linkPath);
|
||||||
|
assert.throws(() => ensureGatewayNativeApiSecret(linkPath));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('materialization strips every API service and returns a warning on unsafe input', (t) => {
|
||||||
|
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'harbor-native-safe-config-'));
|
||||||
|
t.after(() => fs.rmSync(directory, { recursive: true, force: true }));
|
||||||
|
const config = {
|
||||||
|
services: [
|
||||||
|
apiService,
|
||||||
|
{ type: 'api', listen: '0.0.0.0', listen_port: 19092, dashboard: false },
|
||||||
|
{ type: 'resolved' },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
const result = materializeGatewayNativeConfig(config, {
|
||||||
|
apiPort: 19091,
|
||||||
|
secretPath: path.join(directory, 'api.secret'),
|
||||||
|
runtimeConfigPath: path.join(directory, 'runtime-config.json'),
|
||||||
|
});
|
||||||
|
const runtimeConfig = JSON.parse(fs.readFileSync(result.configPath, 'utf8'));
|
||||||
|
|
||||||
|
assert.equal(result.secret, null);
|
||||||
|
assert.match(result.warning, /expected exactly one native API service/);
|
||||||
|
assert.deepEqual(runtimeConfig.services, [{ type: 'resolved' }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('runtime starts the VPN-safe config and reports native materialization warnings', async (t) => {
|
||||||
|
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'harbor-native-runtime-'));
|
||||||
|
const binDirectory = path.join(directory, 'bin');
|
||||||
|
const configPath = path.join(directory, 'shared.json');
|
||||||
|
const capturedPath = path.join(directory, 'captured.json');
|
||||||
|
fs.mkdirSync(binDirectory);
|
||||||
|
fs.writeFileSync(configPath, JSON.stringify({
|
||||||
|
services: [{ ...apiService, listen: '0.0.0.0' }],
|
||||||
|
inbounds: [],
|
||||||
|
outbounds: [],
|
||||||
|
}));
|
||||||
|
fs.writeFileSync(path.join(binDirectory, 'sing-box'), `#!/usr/bin/env node
|
||||||
|
const fs = require('node:fs');
|
||||||
|
const configPath = process.argv[process.argv.indexOf('-c') + 1];
|
||||||
|
const config = JSON.parse(fs.readFileSync(configPath, 'utf8'));
|
||||||
|
if (config.services?.some((service) => service.type === 'api')) process.exit(7);
|
||||||
|
if (process.argv[2] === 'check') process.exit(0);
|
||||||
|
fs.writeFileSync(process.env.HARBOR_CAPTURED_CONFIG, JSON.stringify(config));
|
||||||
|
process.on('SIGTERM', () => process.exit(0));
|
||||||
|
setInterval(() => {}, 60_000);
|
||||||
|
`);
|
||||||
|
fs.chmodSync(path.join(binDirectory, 'sing-box'), 0o755);
|
||||||
|
|
||||||
|
const previousPath = process.env.PATH;
|
||||||
|
process.env.PATH = `${binDirectory}:${previousPath}`;
|
||||||
|
process.env.HARBOR_CAPTURED_CONFIG = capturedPath;
|
||||||
|
const runtime = createSingboxRuntime({
|
||||||
|
configPath,
|
||||||
|
nativeApi: {
|
||||||
|
apiPort: 19091,
|
||||||
|
secretPath: path.join(directory, 'api.secret'),
|
||||||
|
runtimeConfigPath: path.join(directory, 'runtime-config.json'),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
t.after(async () => {
|
||||||
|
await runtime.stop();
|
||||||
|
process.env.PATH = previousPath;
|
||||||
|
delete process.env.HARBOR_CAPTURED_CONFIG;
|
||||||
|
fs.rmSync(directory, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
const checked = runtime.checkConfig(JSON.parse(fs.readFileSync(configPath, 'utf8')));
|
||||||
|
assert.match(checked.warning, /must be unauthenticated base config/);
|
||||||
|
const state = await runtime.apply();
|
||||||
|
assert.equal(state.running, true);
|
||||||
|
assert.match(state.nativeApiWarning, /must be unauthenticated base config/);
|
||||||
|
assert.equal(runtime.nativeApiSecret, null);
|
||||||
|
assert.equal((await waitForJson(capturedPath)).services, undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('snapshot runtime strips a native API left by the previous mode before starting sing-box', async (t) => {
|
||||||
|
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'harbor-snapshot-runtime-'));
|
||||||
|
const binDirectory = path.join(directory, 'bin');
|
||||||
|
const configPath = path.join(directory, 'shared.json');
|
||||||
|
const runtimeConfigPath = path.join(directory, 'runtime-config.json');
|
||||||
|
const capturedPath = path.join(directory, 'captured.json');
|
||||||
|
fs.mkdirSync(binDirectory);
|
||||||
|
fs.writeFileSync(configPath, JSON.stringify({
|
||||||
|
services: [apiService],
|
||||||
|
inbounds: [],
|
||||||
|
outbounds: [],
|
||||||
|
}));
|
||||||
|
fs.writeFileSync(path.join(binDirectory, 'sing-box'), `#!/usr/bin/env node
|
||||||
|
const fs = require('node:fs');
|
||||||
|
const configPath = process.argv[process.argv.indexOf('-c') + 1];
|
||||||
|
const config = JSON.parse(fs.readFileSync(configPath, 'utf8'));
|
||||||
|
if (config.services?.some((service) => service.type === 'api')) process.exit(7);
|
||||||
|
if (process.argv[2] === 'check') process.exit(0);
|
||||||
|
fs.writeFileSync(process.env.HARBOR_CAPTURED_CONFIG, JSON.stringify(config));
|
||||||
|
process.on('SIGTERM', () => process.exit(0));
|
||||||
|
setInterval(() => {}, 60_000);
|
||||||
|
`);
|
||||||
|
fs.chmodSync(path.join(binDirectory, 'sing-box'), 0o755);
|
||||||
|
|
||||||
|
const previousPath = process.env.PATH;
|
||||||
|
process.env.PATH = `${binDirectory}:${previousPath}`;
|
||||||
|
process.env.HARBOR_CAPTURED_CONFIG = capturedPath;
|
||||||
|
const runtime = createSingboxRuntime({ configPath, gatewayRuntimeConfigPath: runtimeConfigPath });
|
||||||
|
t.after(async () => {
|
||||||
|
await runtime.stop();
|
||||||
|
process.env.PATH = previousPath;
|
||||||
|
delete process.env.HARBOR_CAPTURED_CONFIG;
|
||||||
|
fs.rmSync(directory, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
const state = await runtime.apply();
|
||||||
|
assert.equal(state.running, true);
|
||||||
|
assert.equal(mode(runtimeConfigPath), 0o600);
|
||||||
|
assert.equal((await waitForJson(capturedPath)).services, undefined);
|
||||||
|
});
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import os from 'node:os';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { DatabaseSync } from 'node:sqlite';
|
||||||
|
import test from 'node:test';
|
||||||
|
import { openHarborStorage } from '../../dist/server/services/harborStorage.js';
|
||||||
|
import { migrateStoredState } from '../../dist/server/services/stateStore.js';
|
||||||
|
import { migrateDeviceInventoryState } from '../../dist/server/services/deviceInventoryService.js';
|
||||||
|
import { createActivityJournalService } from '../../dist/server/services/activityJournalService.js';
|
||||||
|
|
||||||
|
function fixture(t) {
|
||||||
|
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'harbor-sqlite-'));
|
||||||
|
const stores = [];
|
||||||
|
t.after(() => {
|
||||||
|
for (const store of stores) if (store.db.isOpen) store.close();
|
||||||
|
fs.rmSync(directory, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
return { directory, open: () => { const store = openHarborStorage(directory); stores.push(store); return store; } };
|
||||||
|
}
|
||||||
|
|
||||||
|
test('atomic import preserves revisions, rules, device checkpoints and journal; JSON becomes backup only', (t) => {
|
||||||
|
const f = fixture(t);
|
||||||
|
const state = migrateStoredState({ revision: 41, routeRules: [{ type: 'domain_suffix', value: 'example.org', enabled: true }] });
|
||||||
|
const mac = 'aa:bb:cc:dd:ee:ff';
|
||||||
|
const devices = migrateDeviceInventoryState({ revision: 17, traffic: {
|
||||||
|
baselinesByMac: { [mac]: { epoch: 'kernel-1', uploadBytes: '9007199254740993', downloadBytes: '123' } },
|
||||||
|
totalsByMac: { [mac]: { uploadBytes: '9007199254740993', downloadBytes: '987', observedAt: '2026-09-10T10:00:00.000Z' } },
|
||||||
|
} });
|
||||||
|
const event = { id: '00000000-0000-4000-8000-000000000001', occurredAt: new Date().toISOString(),
|
||||||
|
type: 'connection.stopped', severity: 'info', source: 'connection', dedupeKey: null, data: {} };
|
||||||
|
for (const [name, value] of [['state.json', state], ['devices.json', devices], ['activity-journal.json', { schemaVersion: 1, events: [event] }]]) {
|
||||||
|
fs.writeFileSync(path.join(f.directory, name), JSON.stringify(value));
|
||||||
|
}
|
||||||
|
const originals = ['state.json', 'devices.json', 'activity-journal.json'].map((name) => fs.readFileSync(path.join(f.directory, name), 'utf8'));
|
||||||
|
let store = f.open();
|
||||||
|
assert.equal(store.imported, true);
|
||||||
|
assert.deepEqual(store.state.read(), state);
|
||||||
|
assert.deepEqual(store.devices.read(), devices);
|
||||||
|
assert.deepEqual(createActivityJournalService({ db: store.db }).page().events, [event]);
|
||||||
|
store.state.update((value) => ({ ...value, revision: 42 }));
|
||||||
|
store.devices.update((value) => ({ ...value, revision: 18 }));
|
||||||
|
store.close();
|
||||||
|
// Even broken obsolete files cannot override or break the canonical SQL state.
|
||||||
|
fs.writeFileSync(path.join(f.directory, 'state.json'), '{obsolete');
|
||||||
|
store = f.open();
|
||||||
|
assert.equal(store.imported, false);
|
||||||
|
assert.equal(store.state.read().revision, 42);
|
||||||
|
assert.equal(store.devices.read().revision, 18);
|
||||||
|
assert.equal(store.devices.read().traffic.baselinesByMac[mac].uploadBytes, '9007199254740993');
|
||||||
|
assert.equal(fs.readFileSync(path.join(f.directory, 'devices.json'), 'utf8'), originals[1]);
|
||||||
|
assert.equal(fs.readFileSync(path.join(f.directory, 'activity-journal.json'), 'utf8'), originals[2]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('failed import leaves no partial documents and can be retried after fixing the original', (t) => {
|
||||||
|
const f = fixture(t);
|
||||||
|
fs.writeFileSync(path.join(f.directory, 'state.json'), JSON.stringify({ revision: 9 }));
|
||||||
|
fs.writeFileSync(path.join(f.directory, 'devices.json'), '{broken');
|
||||||
|
assert.throws(f.open, /Cannot migrate devices.json/);
|
||||||
|
assert.equal(fs.readFileSync(path.join(f.directory, 'devices.json'), 'utf8'), '{broken');
|
||||||
|
const inspect = new DatabaseSync(path.join(f.directory, 'harbor.sqlite'));
|
||||||
|
assert.equal(inspect.prepare("SELECT COUNT(*) AS n FROM sqlite_master WHERE name = 'documents'").get().n, 0);
|
||||||
|
inspect.close();
|
||||||
|
fs.writeFileSync(path.join(f.directory, 'devices.json'), '{}');
|
||||||
|
assert.equal(f.open().state.read().revision, 9);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('an optional legacy null subscription cache migrates without changing the backup', (t) => {
|
||||||
|
const f = fixture(t);
|
||||||
|
fs.writeFileSync(path.join(f.directory, 'state.json'), JSON.stringify({ schemaVersion: 4, revision: 7 }));
|
||||||
|
fs.writeFileSync(path.join(f.directory, 'subscription-cache.json'), 'null');
|
||||||
|
assert.equal(f.open().state.read().revision, 7);
|
||||||
|
assert.equal(fs.readFileSync(path.join(f.directory, 'subscription-cache.json'), 'utf8'), 'null');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a database constraint failure rolls back the entire import including journal and marker', (t) => {
|
||||||
|
const f = fixture(t);
|
||||||
|
const event = { id: '00000000-0000-4000-8000-000000000001', occurredAt: new Date().toISOString(),
|
||||||
|
type: 'connection.stopped', severity: 'info', source: 'connection', dedupeKey: null, data: {} };
|
||||||
|
fs.writeFileSync(path.join(f.directory, 'activity-journal.json'), JSON.stringify({ schemaVersion: 1, events: [event, event] }));
|
||||||
|
assert.throws(f.open, /UNIQUE/);
|
||||||
|
const db = new DatabaseSync(path.join(f.directory, 'harbor.sqlite'));
|
||||||
|
assert.equal(db.prepare('PRAGMA user_version').get().user_version, 0);
|
||||||
|
assert.equal(db.prepare("SELECT COUNT(*) AS n FROM sqlite_master WHERE type = 'table'").get().n, 0);
|
||||||
|
db.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('durable DB corruption, future versions and invalid synchronous mutations never fall back to old JSON', (t) => {
|
||||||
|
const f = fixture(t);
|
||||||
|
const store = f.open();
|
||||||
|
assert.throws(() => store.state.update(async (state) => state), /synchronous/);
|
||||||
|
assert.equal(store.state.read().revision, 0);
|
||||||
|
store.db.exec('PRAGMA user_version = 99');
|
||||||
|
store.close();
|
||||||
|
fs.writeFileSync(path.join(f.directory, 'state.json'), '{}');
|
||||||
|
assert.throws(f.open, /Unsupported Harbor database/);
|
||||||
|
});
|
||||||
@@ -0,0 +1,692 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
|
||||||
|
import { ConnectionEventType } from '../../dist/server/generated/daemon/started_service_pb.js';
|
||||||
|
import { createLiveTrafficLedger } from '../../dist/server/services/liveTrafficService.js';
|
||||||
|
|
||||||
|
function connection(id, overrides = {}) {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
inbound: 'mixed-in',
|
||||||
|
inboundType: 'mixed',
|
||||||
|
network: 'tcp',
|
||||||
|
source: '127.0.0.1:54000',
|
||||||
|
destination: '203.0.113.10:443',
|
||||||
|
domain: 'example.test',
|
||||||
|
protocol: 'tls',
|
||||||
|
createdAt: 1_700_000_000_000n,
|
||||||
|
closedAt: 0n,
|
||||||
|
uplinkTotal: 0n,
|
||||||
|
downlinkTotal: 0n,
|
||||||
|
outbound: 'test-vpn',
|
||||||
|
outboundType: 'vless',
|
||||||
|
rule: 'final',
|
||||||
|
chainList: ['test-vpn'],
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function event(type, id, overrides = {}) {
|
||||||
|
return {
|
||||||
|
type,
|
||||||
|
id,
|
||||||
|
uplinkDelta: 0n,
|
||||||
|
downlinkDelta: 0n,
|
||||||
|
closedAt: 0n,
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function status(connectionsIn, uplinkTotal, downlinkTotal) {
|
||||||
|
return { connectionsIn, uplinkTotal, downlinkTotal };
|
||||||
|
}
|
||||||
|
|
||||||
|
test('ledger applies NEW, UPDATE and only the final CLOSED tail once', () => {
|
||||||
|
let now = new Date('2026-08-31T10:00:00.000Z');
|
||||||
|
const ledger = createLiveTrafficLedger({ now: () => now });
|
||||||
|
ledger.beginEpoch(1_700_000_000_000n, '1.14.0-rc.5', 4);
|
||||||
|
ledger.applyConnections({ reset: true, events: [] });
|
||||||
|
ledger.applyStatus(status(0, 0n, 0n));
|
||||||
|
|
||||||
|
const opened = connection('a', { uplinkTotal: 10n, downlinkTotal: 20n });
|
||||||
|
ledger.applyConnections({
|
||||||
|
reset: false,
|
||||||
|
events: [event(ConnectionEventType.CONNECTION_EVENT_NEW, 'a', { connection: opened })],
|
||||||
|
});
|
||||||
|
let snapshot = ledger.snapshot();
|
||||||
|
assert.deepEqual(snapshot.connections[0].traffic, {
|
||||||
|
uploadBytes: '10',
|
||||||
|
downloadBytes: '20',
|
||||||
|
uploadBytesPerSecond: '0',
|
||||||
|
downloadBytesPerSecond: '0',
|
||||||
|
});
|
||||||
|
assert.equal(snapshot.connections[0].origin.label, 'Этот Mac');
|
||||||
|
assert.equal(snapshot.connections[0].closedAt, null);
|
||||||
|
assert.equal(snapshot.connections[0].route.kind, 'vpn');
|
||||||
|
|
||||||
|
now = new Date('2026-08-31T10:00:01.000Z');
|
||||||
|
ledger.applyConnections({
|
||||||
|
reset: false,
|
||||||
|
events: [event(ConnectionEventType.CONNECTION_EVENT_UPDATE, 'a', {
|
||||||
|
uplinkDelta: 5n,
|
||||||
|
downlinkDelta: 7n,
|
||||||
|
})],
|
||||||
|
});
|
||||||
|
snapshot = ledger.snapshot();
|
||||||
|
assert.deepEqual(snapshot.connections[0].traffic, {
|
||||||
|
uploadBytes: '15',
|
||||||
|
downloadBytes: '27',
|
||||||
|
uploadBytesPerSecond: '5',
|
||||||
|
downloadBytesPerSecond: '7',
|
||||||
|
});
|
||||||
|
|
||||||
|
ledger.applyConnections({
|
||||||
|
reset: false,
|
||||||
|
events: [event(ConnectionEventType.CONNECTION_EVENT_CLOSED, 'a', {
|
||||||
|
connection: connection('a', {
|
||||||
|
closedAt: BigInt(now.getTime()),
|
||||||
|
uplinkTotal: 18n,
|
||||||
|
downlinkTotal: 30n,
|
||||||
|
}),
|
||||||
|
})],
|
||||||
|
});
|
||||||
|
const firstClosed = ledger.snapshot().connections[0];
|
||||||
|
now = new Date('2026-08-31T10:00:10.000Z');
|
||||||
|
ledger.applyConnections({
|
||||||
|
reset: false,
|
||||||
|
events: [event(ConnectionEventType.CONNECTION_EVENT_CLOSED, 'a', {
|
||||||
|
connection: connection('a', { uplinkTotal: 999n, downlinkTotal: 999n }),
|
||||||
|
})],
|
||||||
|
});
|
||||||
|
ledger.applyStatus(status(0, 18n, 30n));
|
||||||
|
snapshot = ledger.snapshot();
|
||||||
|
assert.equal(snapshot.source.state, 'live');
|
||||||
|
assert.equal(snapshot.summary.active, 0);
|
||||||
|
assert.equal(snapshot.summary.recent, 1);
|
||||||
|
assert.equal(snapshot.connections[0].closedAt, firstClosed.closedAt);
|
||||||
|
assert.deepEqual(snapshot.connections[0].traffic, {
|
||||||
|
uploadBytes: '18',
|
||||||
|
downloadBytes: '30',
|
||||||
|
uploadBytesPerSecond: '0',
|
||||||
|
downloadBytesPerSecond: '0',
|
||||||
|
});
|
||||||
|
assert.equal(snapshot.source.unattributedUploadBytes, '0');
|
||||||
|
assert.equal(snapshot.source.unattributedDownloadBytes, '0');
|
||||||
|
ledger.markStopped();
|
||||||
|
snapshot = ledger.snapshot();
|
||||||
|
assert.equal(snapshot.source.state, 'stopped');
|
||||||
|
assert.equal(snapshot.summary.recent, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('connection churn does not clear rates before the next update tick', () => {
|
||||||
|
const ledger = createLiveTrafficLedger();
|
||||||
|
ledger.beginEpoch(100n, '1.14.0-rc.5', 4);
|
||||||
|
ledger.applyConnections({
|
||||||
|
reset: true,
|
||||||
|
events: [event(ConnectionEventType.CONNECTION_EVENT_NEW, 'a', {
|
||||||
|
connection: connection('a'),
|
||||||
|
})],
|
||||||
|
});
|
||||||
|
ledger.applyConnections({
|
||||||
|
reset: false,
|
||||||
|
events: [event(ConnectionEventType.CONNECTION_EVENT_UPDATE, 'a', {
|
||||||
|
uplinkDelta: 5n,
|
||||||
|
downlinkDelta: 7n,
|
||||||
|
})],
|
||||||
|
});
|
||||||
|
ledger.applyConnections({
|
||||||
|
reset: false,
|
||||||
|
events: [event(ConnectionEventType.CONNECTION_EVENT_NEW, 'b', {
|
||||||
|
connection: connection('b'),
|
||||||
|
})],
|
||||||
|
});
|
||||||
|
|
||||||
|
const active = new Map(ledger.snapshot().connections.map((item) => [item.id, item]));
|
||||||
|
assert.equal(active.get('a').traffic.uploadBytesPerSecond, '5');
|
||||||
|
assert.equal(active.get('a').traffic.downloadBytesPerSecond, '7');
|
||||||
|
assert.equal(active.get('b').traffic.uploadBytesPerSecond, '0');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a connection completed between polls remains visible until the exact 30 second boundary', () => {
|
||||||
|
let now = new Date('2026-08-31T10:00:00.000Z');
|
||||||
|
const ledger = createLiveTrafficLedger({ now: () => now });
|
||||||
|
ledger.beginEpoch(100n, '1.14.0-rc.5', 4);
|
||||||
|
ledger.applyConnections({
|
||||||
|
reset: false,
|
||||||
|
events: [
|
||||||
|
event(ConnectionEventType.CONNECTION_EVENT_NEW, 'quick', {
|
||||||
|
connection: connection('quick', {
|
||||||
|
createdAt: BigInt(now.getTime()),
|
||||||
|
uplinkTotal: 2n,
|
||||||
|
downlinkTotal: 3n,
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
event(ConnectionEventType.CONNECTION_EVENT_CLOSED, 'quick', {
|
||||||
|
uplinkDelta: 5n,
|
||||||
|
downlinkDelta: 7n,
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
let snapshot = ledger.snapshot();
|
||||||
|
assert.deepEqual(snapshot.summary, {
|
||||||
|
active: 0,
|
||||||
|
recent: 1,
|
||||||
|
visible: 1,
|
||||||
|
recognized: 0,
|
||||||
|
unresolved: 0,
|
||||||
|
unresolvedOrigin: 0,
|
||||||
|
truncated: false,
|
||||||
|
});
|
||||||
|
assert.equal(snapshot.connections[0].closedAt, '2026-08-31T10:00:00.000Z');
|
||||||
|
assert.deepEqual(snapshot.connections[0].traffic, {
|
||||||
|
uploadBytes: '7',
|
||||||
|
downloadBytes: '10',
|
||||||
|
uploadBytesPerSecond: '0',
|
||||||
|
downloadBytesPerSecond: '0',
|
||||||
|
});
|
||||||
|
|
||||||
|
now = new Date('2026-08-31T10:00:29.999Z');
|
||||||
|
assert.equal(ledger.snapshot().summary.recent, 1);
|
||||||
|
now = new Date('2026-08-31T10:00:30.000Z');
|
||||||
|
snapshot = ledger.snapshot();
|
||||||
|
assert.equal(snapshot.summary.recent, 0);
|
||||||
|
assert.equal(snapshot.connections.length, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a connection already closed in an RC5 reset remains visible as recent', () => {
|
||||||
|
const ledger = createLiveTrafficLedger({ now: () => new Date('2026-08-31T10:00:02.000Z') });
|
||||||
|
ledger.beginEpoch(100n, '1.14.0-rc.5', 4);
|
||||||
|
ledger.applyConnections({
|
||||||
|
reset: true,
|
||||||
|
events: [event(ConnectionEventType.CONNECTION_EVENT_CLOSED, 'reset-closed', {
|
||||||
|
connection: connection('reset-closed', {
|
||||||
|
createdAt: BigInt(Date.parse('2026-08-31T10:00:00.000Z')),
|
||||||
|
closedAt: BigInt(Date.parse('2026-08-31T10:00:01.000Z')),
|
||||||
|
uplinkTotal: 3n,
|
||||||
|
downlinkTotal: 7n,
|
||||||
|
}),
|
||||||
|
})],
|
||||||
|
});
|
||||||
|
|
||||||
|
const snapshot = ledger.snapshot();
|
||||||
|
assert.equal(snapshot.summary.active, 0);
|
||||||
|
assert.equal(snapshot.summary.recent, 1);
|
||||||
|
assert.equal(snapshot.connections[0].id, 'reset-closed');
|
||||||
|
assert.equal(snapshot.connections[0].closedAt, '2026-08-31T10:00:01.000Z');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a genuinely newer lifecycle with the same UUID supersedes its recent tombstone', () => {
|
||||||
|
let now = new Date('2026-08-31T10:00:00.000Z');
|
||||||
|
const ledger = createLiveTrafficLedger({ now: () => now });
|
||||||
|
ledger.beginEpoch(100n, '1.14.0-rc.5', 4);
|
||||||
|
ledger.applyConnections({ reset: true, events: [] });
|
||||||
|
ledger.applyConnections({
|
||||||
|
reset: false,
|
||||||
|
events: [event(ConnectionEventType.CONNECTION_EVENT_NEW, 'revived', {
|
||||||
|
connection: connection('revived', { createdAt: BigInt(now.getTime()), uplinkTotal: 3n }),
|
||||||
|
})],
|
||||||
|
});
|
||||||
|
ledger.applyConnections({
|
||||||
|
reset: false,
|
||||||
|
events: [event(ConnectionEventType.CONNECTION_EVENT_CLOSED, 'revived')],
|
||||||
|
});
|
||||||
|
|
||||||
|
const firstClosedAt = ledger.snapshot().connections[0].closedAt;
|
||||||
|
ledger.applyConnections({
|
||||||
|
reset: false,
|
||||||
|
events: [event(ConnectionEventType.CONNECTION_EVENT_NEW, 'revived', {
|
||||||
|
connection: connection('revived', { createdAt: BigInt(now.getTime()), uplinkTotal: 999n }),
|
||||||
|
})],
|
||||||
|
});
|
||||||
|
assert.equal(ledger.snapshot().summary.active, 0);
|
||||||
|
assert.equal(ledger.snapshot().connections[0].closedAt, firstClosedAt);
|
||||||
|
|
||||||
|
now = new Date('2026-08-31T10:00:01.000Z');
|
||||||
|
ledger.applyConnections({
|
||||||
|
reset: false,
|
||||||
|
events: [event(ConnectionEventType.CONNECTION_EVENT_NEW, 'revived', {
|
||||||
|
connection: connection('revived', { createdAt: BigInt(now.getTime()), uplinkTotal: 4n }),
|
||||||
|
})],
|
||||||
|
});
|
||||||
|
const snapshot = ledger.snapshot();
|
||||||
|
assert.equal(snapshot.summary.active, 1);
|
||||||
|
assert.equal(snapshot.summary.recent, 0);
|
||||||
|
assert.equal(snapshot.connections[0].closedAt, null);
|
||||||
|
assert.equal(snapshot.connections[0].traffic.uploadBytes, '4');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('reset reconciles active deltas without treating earlier closed traffic as a gap', () => {
|
||||||
|
const ledger = createLiveTrafficLedger();
|
||||||
|
ledger.beginEpoch(100n, '1.14.0-rc.5', 4);
|
||||||
|
ledger.applyConnections({
|
||||||
|
reset: true,
|
||||||
|
events: [
|
||||||
|
event(ConnectionEventType.CONNECTION_EVENT_NEW, 'closed-a', {
|
||||||
|
connection: connection('closed-a', { uplinkTotal: 100n }),
|
||||||
|
}),
|
||||||
|
event(ConnectionEventType.CONNECTION_EVENT_NEW, 'active-b', {
|
||||||
|
connection: connection('active-b', { uplinkTotal: 10n }),
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
ledger.applyStatus(status(2, 110n, 0n));
|
||||||
|
ledger.applyConnections({
|
||||||
|
reset: false,
|
||||||
|
events: [event(ConnectionEventType.CONNECTION_EVENT_CLOSED, 'closed-a')],
|
||||||
|
});
|
||||||
|
ledger.applyStatus(status(1, 110n, 0n));
|
||||||
|
ledger.applyConnections({
|
||||||
|
reset: true,
|
||||||
|
events: [event(ConnectionEventType.CONNECTION_EVENT_NEW, 'active-b', {
|
||||||
|
connection: connection('active-b', { uplinkTotal: 20n }),
|
||||||
|
})],
|
||||||
|
});
|
||||||
|
ledger.applyStatus(status(1, 120n, 0n));
|
||||||
|
|
||||||
|
const snapshot = ledger.snapshot();
|
||||||
|
assert.equal(snapshot.source.state, 'live');
|
||||||
|
assert.equal(snapshot.source.unattributedUploadBytes, '0');
|
||||||
|
assert.equal(snapshot.summary.active, 1);
|
||||||
|
assert.equal(snapshot.summary.recent, 1);
|
||||||
|
assert.equal(snapshot.connections.find(({ id }) => id === 'active-b').traffic.uploadBytes, '20');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('reset can revive the same lifecycle tombstone without counting its totals twice', () => {
|
||||||
|
const ledger = createLiveTrafficLedger();
|
||||||
|
ledger.beginEpoch(100n, '1.14.0-rc.5', 4);
|
||||||
|
ledger.applyConnections({
|
||||||
|
reset: false,
|
||||||
|
events: [event(ConnectionEventType.CONNECTION_EVENT_NEW, 'same', {
|
||||||
|
connection: connection('same', { uplinkTotal: 10n }),
|
||||||
|
})],
|
||||||
|
});
|
||||||
|
ledger.applyConnections({
|
||||||
|
reset: false,
|
||||||
|
events: [event(ConnectionEventType.CONNECTION_EVENT_CLOSED, 'same')],
|
||||||
|
});
|
||||||
|
ledger.applyConnections({
|
||||||
|
reset: true,
|
||||||
|
events: [event(ConnectionEventType.CONNECTION_EVENT_NEW, 'same', {
|
||||||
|
connection: connection('same', { uplinkTotal: 10n }),
|
||||||
|
})],
|
||||||
|
});
|
||||||
|
ledger.applyStatus(status(1, 10n, 0n));
|
||||||
|
ledger.applyStatus(status(1, 10n, 0n));
|
||||||
|
ledger.applyStatus(status(1, 10n, 0n));
|
||||||
|
|
||||||
|
const snapshot = ledger.snapshot();
|
||||||
|
assert.equal(snapshot.source.state, 'live');
|
||||||
|
assert.equal(snapshot.source.unattributedUploadBytes, '0');
|
||||||
|
assert.equal(snapshot.summary.active, 1);
|
||||||
|
assert.equal(snapshot.summary.recent, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('destination hostnames remain recognized without protocol sniffing', () => {
|
||||||
|
const ledger = createLiveTrafficLedger();
|
||||||
|
ledger.beginEpoch(100n, '1.14.0-rc.5', 4);
|
||||||
|
ledger.applyConnections({
|
||||||
|
reset: true,
|
||||||
|
events: [event(ConnectionEventType.CONNECTION_EVENT_NEW, 'hostname', {
|
||||||
|
connection: connection('hostname', {
|
||||||
|
destination: 'Example.COM:443',
|
||||||
|
domain: '',
|
||||||
|
protocol: '',
|
||||||
|
}),
|
||||||
|
})],
|
||||||
|
});
|
||||||
|
|
||||||
|
const snapshot = ledger.snapshot();
|
||||||
|
assert.equal(snapshot.summary.recognized, 1);
|
||||||
|
assert.deepEqual(snapshot.connections[0].destination, {
|
||||||
|
domain: 'example.com',
|
||||||
|
ip: null,
|
||||||
|
port: 443,
|
||||||
|
provenance: 'sing-box',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('CLOSED keeps newly available native domain, protocol and route metadata', () => {
|
||||||
|
const ledger = createLiveTrafficLedger();
|
||||||
|
ledger.beginEpoch(100n, '1.14.0-rc.5', 4);
|
||||||
|
ledger.applyConnections({
|
||||||
|
reset: false,
|
||||||
|
events: [event(ConnectionEventType.CONNECTION_EVENT_NEW, 'late-metadata', {
|
||||||
|
connection: connection('late-metadata', {
|
||||||
|
domain: '',
|
||||||
|
protocol: '',
|
||||||
|
uplinkTotal: 1n,
|
||||||
|
downlinkTotal: 2n,
|
||||||
|
}),
|
||||||
|
})],
|
||||||
|
});
|
||||||
|
ledger.applyConnections({
|
||||||
|
reset: false,
|
||||||
|
events: [event(ConnectionEventType.CONNECTION_EVENT_CLOSED, 'late-metadata', {
|
||||||
|
connection: connection('late-metadata', {
|
||||||
|
domain: 'recognized.example',
|
||||||
|
protocol: 'http2',
|
||||||
|
outbound: 'direct',
|
||||||
|
outboundType: 'direct',
|
||||||
|
chainList: ['direct'],
|
||||||
|
rule: 'domain-final',
|
||||||
|
uplinkTotal: 3n,
|
||||||
|
downlinkTotal: 5n,
|
||||||
|
}),
|
||||||
|
})],
|
||||||
|
});
|
||||||
|
|
||||||
|
const closed = ledger.snapshot().connections[0];
|
||||||
|
assert.equal(closed.destination.domain, 'recognized.example');
|
||||||
|
assert.equal(closed.protocol, 'http2');
|
||||||
|
assert.deepEqual(closed.route, {
|
||||||
|
kind: 'direct',
|
||||||
|
scope: 'local-sing-box',
|
||||||
|
outbound: 'direct',
|
||||||
|
outboundType: 'direct',
|
||||||
|
chain: ['direct'],
|
||||||
|
rule: 'domain-final',
|
||||||
|
});
|
||||||
|
assert.equal(closed.traffic.uploadBytes, '3');
|
||||||
|
assert.equal(closed.traffic.downloadBytes, '5');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('CLOSED partial route metadata keeps route kind consistent with its outbound', () => {
|
||||||
|
const ledger = createLiveTrafficLedger();
|
||||||
|
ledger.beginEpoch(100n, '1.14.0-rc.5', 4);
|
||||||
|
ledger.applyConnections({
|
||||||
|
reset: false,
|
||||||
|
events: [event(ConnectionEventType.CONNECTION_EVENT_NEW, 'partial-route', {
|
||||||
|
connection: connection('partial-route'),
|
||||||
|
})],
|
||||||
|
});
|
||||||
|
ledger.applyConnections({
|
||||||
|
reset: false,
|
||||||
|
events: [event(ConnectionEventType.CONNECTION_EVENT_CLOSED, 'partial-route', {
|
||||||
|
connection: connection('partial-route', {
|
||||||
|
outbound: '',
|
||||||
|
outboundType: '',
|
||||||
|
chainList: ['late-hop'],
|
||||||
|
rule: 'late-rule',
|
||||||
|
}),
|
||||||
|
})],
|
||||||
|
});
|
||||||
|
|
||||||
|
const route = ledger.snapshot().connections[0].route;
|
||||||
|
assert.equal(route.kind, 'vpn');
|
||||||
|
assert.equal(route.outbound, 'test-vpn');
|
||||||
|
assert.equal(route.outboundType, 'vless');
|
||||||
|
assert.deepEqual(route.chain, ['late-hop']);
|
||||||
|
assert.equal(route.rule, 'late-rule');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('transport errors preserve the last observed data timestamp', () => {
|
||||||
|
let now = new Date('2026-08-31T10:00:00.000Z');
|
||||||
|
const ledger = createLiveTrafficLedger({ now: () => now });
|
||||||
|
ledger.beginEpoch(100n, '1.14.0-rc.5', 4);
|
||||||
|
ledger.applyConnections({ reset: true, events: [] });
|
||||||
|
ledger.applyStatus(status(0, 0n, 0n));
|
||||||
|
const lastGood = ledger.snapshot();
|
||||||
|
|
||||||
|
now = new Date('2026-08-31T10:01:00.000Z');
|
||||||
|
ledger.markTransportError(new Error('stream ended'));
|
||||||
|
const firstStale = ledger.snapshot();
|
||||||
|
|
||||||
|
ledger.beginEpoch(100n, '1.14.0-rc.5', 4);
|
||||||
|
ledger.markTransportError(new Error('stream ended again'));
|
||||||
|
const stale = ledger.snapshot();
|
||||||
|
|
||||||
|
assert.equal(stale.source.state, 'stale');
|
||||||
|
assert.equal(stale.observedAt, lastGood.observedAt);
|
||||||
|
assert.equal(firstStale.observedAt, lastGood.observedAt);
|
||||||
|
assert.equal(stale.sequence, lastGood.sequence + 3);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('reset is idempotent, a restart creates a clean epoch, and settled UUIDs are bounded', () => {
|
||||||
|
const ledger = createLiveTrafficLedger();
|
||||||
|
const active = connection('active', { uplinkTotal: 10n, downlinkTotal: 20n });
|
||||||
|
const closed = connection('closed', {
|
||||||
|
closedAt: 1_700_000_001_000n,
|
||||||
|
uplinkTotal: 5n,
|
||||||
|
downlinkTotal: 7n,
|
||||||
|
});
|
||||||
|
ledger.beginEpoch(100n, '1.14.0-rc.5', 4);
|
||||||
|
const reset = {
|
||||||
|
reset: true,
|
||||||
|
events: [
|
||||||
|
event(ConnectionEventType.CONNECTION_EVENT_NEW, 'active', { connection: active }),
|
||||||
|
event(ConnectionEventType.CONNECTION_EVENT_NEW, 'closed', { connection: closed }),
|
||||||
|
],
|
||||||
|
};
|
||||||
|
const firstReset = ledger.applyConnections(reset);
|
||||||
|
ledger.applyStatus(status(1, 15n, 27n));
|
||||||
|
const repeatedReset = ledger.applyConnections(reset);
|
||||||
|
ledger.applyStatus(status(1, 15n, 27n));
|
||||||
|
assert.equal(ledger.snapshot().source.state, 'live');
|
||||||
|
assert.equal(ledger.snapshot().source.unattributedUploadBytes, '0');
|
||||||
|
assert.deepEqual(ledger.snapshot().connections.map(({ id }) => id), ['active']);
|
||||||
|
assert.equal(ledger.snapshot().summary.recent, 0);
|
||||||
|
assert.deepEqual(firstReset.connections.map(({ id }) => id), ['active', 'closed']);
|
||||||
|
assert.deepEqual(repeatedReset.connections.map(({ id }) => id), ['active']);
|
||||||
|
|
||||||
|
const settledEvents = Array.from({ length: 2_049 }, (_, index) => {
|
||||||
|
const id = `settled-${String(index).padStart(4, '0')}`;
|
||||||
|
return event(ConnectionEventType.CONNECTION_EVENT_CLOSED, id);
|
||||||
|
});
|
||||||
|
ledger.applyConnections({ reset: false, events: settledEvents });
|
||||||
|
ledger.applyConnections({
|
||||||
|
reset: false,
|
||||||
|
events: [
|
||||||
|
event(ConnectionEventType.CONNECTION_EVENT_NEW, 'settled-0000', {
|
||||||
|
connection: connection('settled-0000'),
|
||||||
|
}),
|
||||||
|
event(ConnectionEventType.CONNECTION_EVENT_NEW, 'settled-2048', {
|
||||||
|
connection: connection('settled-2048'),
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
assert.deepEqual(
|
||||||
|
ledger.snapshot().connections.map(({ id }) => id).sort(),
|
||||||
|
['active', 'settled-0000'],
|
||||||
|
);
|
||||||
|
|
||||||
|
ledger.applyConnections({
|
||||||
|
reset: false,
|
||||||
|
events: [
|
||||||
|
event(ConnectionEventType.CONNECTION_EVENT_NEW, 'epoch-recent', {
|
||||||
|
connection: connection('epoch-recent'),
|
||||||
|
}),
|
||||||
|
event(ConnectionEventType.CONNECTION_EVENT_CLOSED, 'epoch-recent'),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
assert.equal(ledger.snapshot().summary.recent, 1);
|
||||||
|
|
||||||
|
ledger.beginEpoch(101n, '1.14.0-rc.5', 4);
|
||||||
|
const restarted = ledger.snapshot();
|
||||||
|
assert.equal(restarted.epoch, 'sing-box-101');
|
||||||
|
assert.equal(restarted.source.state, 'connecting');
|
||||||
|
assert.equal(restarted.summary.active, 0);
|
||||||
|
assert.equal(restarted.summary.recent, 0);
|
||||||
|
assert.equal(restarted.source.unattributedUploadBytes, '0');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('three consecutive status mismatches degrade without assigning the byte gap', () => {
|
||||||
|
const ledger = createLiveTrafficLedger();
|
||||||
|
ledger.beginEpoch(100n, '1.14.0-rc.5', 4);
|
||||||
|
ledger.applyConnections({
|
||||||
|
reset: true,
|
||||||
|
events: [event(ConnectionEventType.CONNECTION_EVENT_NEW, 'a', {
|
||||||
|
connection: connection('a', { uplinkTotal: 10n, downlinkTotal: 20n }),
|
||||||
|
})],
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.ok(ledger.applyStatus(status(1, 15n, 27n)));
|
||||||
|
assert.ok(ledger.applyStatus(status(1, 15n, 27n)));
|
||||||
|
assert.equal(ledger.snapshot().source.state, 'live');
|
||||||
|
assert.equal(ledger.applyStatus(status(1, 15n, 27n)), null);
|
||||||
|
let snapshot = ledger.snapshot();
|
||||||
|
assert.equal(snapshot.source.state, 'degraded');
|
||||||
|
assert.equal(snapshot.source.unattributedUploadBytes, '5');
|
||||||
|
assert.equal(snapshot.source.unattributedDownloadBytes, '7');
|
||||||
|
assert.deepEqual(snapshot.connections[0].traffic, {
|
||||||
|
uploadBytes: '10',
|
||||||
|
downloadBytes: '20',
|
||||||
|
uploadBytesPerSecond: '0',
|
||||||
|
downloadBytesPerSecond: '0',
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.ok(ledger.applyStatus(status(1, 10n, 20n)));
|
||||||
|
snapshot = ledger.snapshot();
|
||||||
|
assert.equal(snapshot.source.state, 'live');
|
||||||
|
assert.equal(snapshot.source.unattributedUploadBytes, '0');
|
||||||
|
assert.equal(snapshot.source.unattributedDownloadBytes, '0');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a missing NEW does not double-count UPDATE before an absolute CLOSED total', () => {
|
||||||
|
const ledger = createLiveTrafficLedger({ now: () => new Date('2023-11-14T22:13:21.000Z') });
|
||||||
|
ledger.beginEpoch(100n, '1.14.0-rc.5', 4);
|
||||||
|
ledger.applyConnections({ reset: true, events: [] });
|
||||||
|
ledger.applyConnections({
|
||||||
|
reset: false,
|
||||||
|
events: [event(ConnectionEventType.CONNECTION_EVENT_UPDATE, 'missed', {
|
||||||
|
uplinkDelta: 10n,
|
||||||
|
downlinkDelta: 20n,
|
||||||
|
})],
|
||||||
|
});
|
||||||
|
ledger.applyStatus(status(0, 10n, 20n));
|
||||||
|
assert.equal(ledger.snapshot().source.unattributedUploadBytes, '10');
|
||||||
|
|
||||||
|
ledger.applyConnections({
|
||||||
|
reset: false,
|
||||||
|
events: [event(ConnectionEventType.CONNECTION_EVENT_CLOSED, 'missed', {
|
||||||
|
connection: connection('missed', {
|
||||||
|
closedAt: 1_700_000_001_000n,
|
||||||
|
uplinkTotal: 15n,
|
||||||
|
downlinkTotal: 27n,
|
||||||
|
}),
|
||||||
|
})],
|
||||||
|
});
|
||||||
|
ledger.applyStatus(status(0, 15n, 27n));
|
||||||
|
let snapshot = ledger.snapshot();
|
||||||
|
assert.equal(snapshot.source.unattributedUploadBytes, '15');
|
||||||
|
assert.equal(snapshot.source.unattributedDownloadBytes, '27');
|
||||||
|
assert.equal(snapshot.summary.recent, 1);
|
||||||
|
assert.equal(snapshot.connections[0].closedAt, '2023-11-14T22:13:21.000Z');
|
||||||
|
assert.deepEqual(snapshot.connections[0].traffic, {
|
||||||
|
uploadBytes: '15',
|
||||||
|
downloadBytes: '27',
|
||||||
|
uploadBytesPerSecond: '0',
|
||||||
|
downloadBytesPerSecond: '0',
|
||||||
|
});
|
||||||
|
|
||||||
|
ledger.applyConnections({
|
||||||
|
reset: false,
|
||||||
|
events: [event(ConnectionEventType.CONNECTION_EVENT_CLOSED, 'metadata-less', {
|
||||||
|
uplinkDelta: 3n,
|
||||||
|
downlinkDelta: 4n,
|
||||||
|
})],
|
||||||
|
});
|
||||||
|
ledger.applyStatus(status(0, 18n, 31n));
|
||||||
|
snapshot = ledger.snapshot();
|
||||||
|
assert.equal(snapshot.source.unattributedUploadBytes, '18');
|
||||||
|
assert.equal(snapshot.source.unattributedDownloadBytes, '31');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('snapshot caps visibility at 256 while summary covers every active connection', () => {
|
||||||
|
const ledger = createLiveTrafficLedger();
|
||||||
|
ledger.beginEpoch(100n, '1.14.0-rc.5', 4);
|
||||||
|
ledger.applyConnections({
|
||||||
|
reset: false,
|
||||||
|
events: [
|
||||||
|
event(ConnectionEventType.CONNECTION_EVENT_NEW, 'recent', { connection: connection('recent') }),
|
||||||
|
event(ConnectionEventType.CONNECTION_EVENT_CLOSED, 'recent'),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
const events = Array.from({ length: 257 }, (_, index) => {
|
||||||
|
const id = index === 255 ? 'z-tie' : index === 256 ? 'a-tie' : `id-${String(index).padStart(3, '0')}`;
|
||||||
|
const createdAt = index >= 255 ? 1_700_000_000_255n : 1_700_000_000_000n + BigInt(index);
|
||||||
|
return event(ConnectionEventType.CONNECTION_EVENT_NEW, id, {
|
||||||
|
connection: connection(id, {
|
||||||
|
createdAt,
|
||||||
|
domain: index % 2 === 0 ? `service-${index}.example` : '',
|
||||||
|
destination: `203.0.113.${index % 255}:443`,
|
||||||
|
outbound: index % 3 === 0 ? 'direct' : 'test-vpn',
|
||||||
|
outboundType: index % 3 === 0 ? 'direct' : 'vless',
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
ledger.applyConnections({ reset: true, events });
|
||||||
|
const snapshot = ledger.snapshot();
|
||||||
|
|
||||||
|
assert.deepEqual(snapshot.summary, {
|
||||||
|
active: 257,
|
||||||
|
recent: 1,
|
||||||
|
visible: 256,
|
||||||
|
recognized: 129,
|
||||||
|
unresolved: 128,
|
||||||
|
unresolvedOrigin: 0,
|
||||||
|
truncated: true,
|
||||||
|
});
|
||||||
|
assert.equal(snapshot.connections.length, 256);
|
||||||
|
assert.deepEqual(snapshot.connections.slice(0, 2).map(({ id }) => id), ['a-tie', 'z-tie']);
|
||||||
|
assert.equal(snapshot.connections.some(({ id }) => id === 'id-000'), false);
|
||||||
|
assert.equal(snapshot.connections.some(({ id }) => id === 'recent'), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('gateway projection uses selector chains and resolves every active origin from the current device map', () => {
|
||||||
|
let deviceVisible = false;
|
||||||
|
const ledger = createLiveTrafficLedger({
|
||||||
|
gateway: true,
|
||||||
|
resolveOrigin: (sourceIp) => deviceVisible
|
||||||
|
? { kind: 'device', id: 'device-a', label: 'MacBook', provenance: 'source-ip' }
|
||||||
|
: { kind: 'unknown', id: null, label: sourceIp, provenance: 'unknown' },
|
||||||
|
});
|
||||||
|
ledger.beginEpoch(100n, '1.14.0-rc.5', 4);
|
||||||
|
const first = ledger.applyConnections({
|
||||||
|
reset: true,
|
||||||
|
events: [event(ConnectionEventType.CONNECTION_EVENT_NEW, 'gateway', {
|
||||||
|
connection: connection('gateway', {
|
||||||
|
inbound: 'tproxy-in',
|
||||||
|
inboundType: 'tproxy',
|
||||||
|
source: '192.168.50.7:54000',
|
||||||
|
outbound: 'channel-selector',
|
||||||
|
outboundType: 'selector',
|
||||||
|
chainList: ['channel-primary', 'channel-selector'],
|
||||||
|
}),
|
||||||
|
})],
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(first.connections[0].route.kind, 'vpn');
|
||||||
|
assert.equal(ledger.snapshot().summary.unresolvedOrigin, 1);
|
||||||
|
deviceVisible = true;
|
||||||
|
assert.equal(ledger.snapshot().summary.unresolvedOrigin, 0);
|
||||||
|
assert.equal(ledger.snapshot().connections[0].origin.id, 'device-a');
|
||||||
|
|
||||||
|
const second = ledger.applyConnections({
|
||||||
|
reset: false,
|
||||||
|
events: [event(ConnectionEventType.CONNECTION_EVENT_UPDATE, 'gateway', { uplinkDelta: 1n })],
|
||||||
|
});
|
||||||
|
assert.equal(second.connections[0].origin.id, 'device-a');
|
||||||
|
assert.equal(second.connections[0].traffic.uploadBytes, '1');
|
||||||
|
assert.equal(ledger.snapshot().capabilities.deviceAttribution, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('recent closed storage is bounded independently from the 256-row response cap', () => {
|
||||||
|
const ledger = createLiveTrafficLedger();
|
||||||
|
ledger.beginEpoch(100n, '1.14.0-rc.5', 4);
|
||||||
|
const events = Array.from({ length: 2_049 }, (_, index) => {
|
||||||
|
const id = `recent-${String(index).padStart(4, '0')}`;
|
||||||
|
return event(ConnectionEventType.CONNECTION_EVENT_CLOSED, id, { connection: connection(id) });
|
||||||
|
});
|
||||||
|
const projection = ledger.applyConnections({ reset: false, events });
|
||||||
|
|
||||||
|
const snapshot = ledger.snapshot();
|
||||||
|
assert.equal(projection.connections.length, 2_049);
|
||||||
|
assert.equal(projection.closedIds.length, 2_049);
|
||||||
|
assert.equal(snapshot.summary.active, 0);
|
||||||
|
assert.equal(snapshot.summary.recent, 2_048);
|
||||||
|
assert.equal(snapshot.summary.visible, 256);
|
||||||
|
assert.equal(snapshot.summary.truncated, true);
|
||||||
|
assert.equal(snapshot.connections[0].id, 'recent-0001');
|
||||||
|
assert.equal(snapshot.connections.some(({ id }) => id === 'recent-0000'), false);
|
||||||
|
});
|
||||||
@@ -0,0 +1,253 @@
|
|||||||
|
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',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('PUT persists validated traffic settings with revision protection and returns canonical state', async () => {
|
||||||
|
let state = { revision: 4, traffic: { grouping: 'site', sort: 'popular', retentionSeconds: 10 } };
|
||||||
|
let sent = false;
|
||||||
|
const route = createLiveTrafficRoute({
|
||||||
|
traffic: null,
|
||||||
|
settingsState: {
|
||||||
|
read: () => state,
|
||||||
|
update: (mutator) => { state = { ...mutator(state), revision: state.revision + 1 }; },
|
||||||
|
},
|
||||||
|
readBody: async () => ({
|
||||||
|
settings: { grouping: 'device', sort: 'recent', retentionSeconds: 30 },
|
||||||
|
expectedRevision: 4,
|
||||||
|
}),
|
||||||
|
sendState: async (res) => { sent = true; res.end(JSON.stringify(state)); },
|
||||||
|
});
|
||||||
|
const res = response();
|
||||||
|
|
||||||
|
assert.equal(await route.handle({ method: 'PUT', url: '/api/traffic/settings' }, res), true);
|
||||||
|
assert.equal(sent, true);
|
||||||
|
assert.deepEqual(state.traffic, { grouping: 'device', sort: 'recent', retentionSeconds: 30 });
|
||||||
|
assert.equal(state.revision, 5);
|
||||||
|
|
||||||
|
const conflict = createLiveTrafficRoute({
|
||||||
|
traffic: null,
|
||||||
|
settingsState: { read: () => state, update: () => { throw new Error('must not update'); } },
|
||||||
|
readBody: async () => ({ settings: state.traffic, expectedRevision: 4 }),
|
||||||
|
sendState: async () => {},
|
||||||
|
});
|
||||||
|
await assert.rejects(
|
||||||
|
conflict.handle({ method: 'PUT', url: '/api/traffic/settings' }, response()),
|
||||||
|
(error) => error.code === 'STATE_CONFLICT',
|
||||||
|
);
|
||||||
|
|
||||||
|
const invalid = createLiveTrafficRoute({
|
||||||
|
traffic: null,
|
||||||
|
settingsState: { read: () => state, update: () => { throw new Error('must not update'); } },
|
||||||
|
readBody: async () => ({ settings: { grouping: 'guess' }, expectedRevision: 5 }),
|
||||||
|
sendState: async () => {},
|
||||||
|
});
|
||||||
|
await assert.rejects(
|
||||||
|
invalid.handle({ method: 'PUT', url: '/api/traffic/settings' }, response()),
|
||||||
|
(error) => error.code === 'REQUEST_INVALID',
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -27,6 +27,7 @@ const snapshot = {
|
|||||||
proxyUploadBytes: '0',
|
proxyUploadBytes: '0',
|
||||||
proxyDownloadBytes: '0',
|
proxyDownloadBytes: '0',
|
||||||
proxyTrafficObservedAt: null,
|
proxyTrafficObservedAt: null,
|
||||||
|
appliedPolicy: 'direct',
|
||||||
}],
|
}],
|
||||||
directTraffic: {
|
directTraffic: {
|
||||||
observedAt,
|
observedAt,
|
||||||
@@ -40,6 +41,28 @@ const snapshot = {
|
|||||||
},
|
},
|
||||||
domainTraffic: {
|
domainTraffic: {
|
||||||
observedAt,
|
observedAt,
|
||||||
|
source: {
|
||||||
|
error: null,
|
||||||
|
mode: 'shadow',
|
||||||
|
writer: 'snapshot',
|
||||||
|
activeConnections: 4,
|
||||||
|
native: {
|
||||||
|
state: 'degraded',
|
||||||
|
epoch: 'epoch-1',
|
||||||
|
sequence: 7,
|
||||||
|
observedAt,
|
||||||
|
active: 5,
|
||||||
|
unattributedUploadBytes: '11',
|
||||||
|
unattributedDownloadBytes: '22',
|
||||||
|
},
|
||||||
|
shadow: {
|
||||||
|
activeDifference: 1,
|
||||||
|
uploadDifferenceBytes: '-30',
|
||||||
|
downloadDifferenceBytes: '40',
|
||||||
|
routeMismatches: 2,
|
||||||
|
deviceMismatches: 3,
|
||||||
|
},
|
||||||
|
},
|
||||||
overflowConnections: '2',
|
overflowConnections: '2',
|
||||||
attributionEvents: {
|
attributionEvents: {
|
||||||
unresolved_host: '3',
|
unresolved_host: '3',
|
||||||
@@ -58,6 +81,12 @@ const snapshot = {
|
|||||||
outbound: 'vpn',
|
outbound: 'vpn',
|
||||||
uploadBytes: '55',
|
uploadBytes: '55',
|
||||||
downloadBytes: '666',
|
downloadBytes: '666',
|
||||||
|
}, {
|
||||||
|
deviceId: 'dev_0011223344556677',
|
||||||
|
source: 'proxy',
|
||||||
|
outbound: 'vpn',
|
||||||
|
uploadBytes: '12',
|
||||||
|
downloadBytes: '345',
|
||||||
}],
|
}],
|
||||||
series: [{
|
series: [{
|
||||||
deviceId: 'dev_0011223344556677',
|
deviceId: 'dev_0011223344556677',
|
||||||
@@ -76,6 +105,8 @@ test('Prometheus exposition keeps exact counters, stable identity and escaped na
|
|||||||
assert.match(output, /# TYPE harbor_traffic_bytes_total counter/);
|
assert.match(output, /# TYPE harbor_traffic_bytes_total counter/);
|
||||||
assert.match(output, /harbor_traffic_bytes_total\{source="gateway"\} 9007199254740993/);
|
assert.match(output, /harbor_traffic_bytes_total\{source="gateway"\} 9007199254740993/);
|
||||||
assert.match(output, /harbor_device_info\{device_id="dev_0011223344556677",name="ТВ \\"Зал\\"\\\\основной\\nэкран",ip="192\.168\.50\.7"\} 1/);
|
assert.match(output, /harbor_device_info\{device_id="dev_0011223344556677",name="ТВ \\"Зал\\"\\\\основной\\nэкран",ip="192\.168\.50\.7"\} 1/);
|
||||||
|
assert.match(output, /harbor_device_applied_policy\{device_id="dev_0011223344556677"\} 0/);
|
||||||
|
assert.doesNotMatch(output, /harbor_device_applied_policy\{[^\n]*(?:name|ip|mac|desired|status)=/);
|
||||||
assert.match(output, /harbor_device_traffic_bytes_total\{device_id="dev_0011223344556677",source="gateway",direction="upload"\} 9007199254740993/);
|
assert.match(output, /harbor_device_traffic_bytes_total\{device_id="dev_0011223344556677",source="gateway",direction="upload"\} 9007199254740993/);
|
||||||
assert.doesNotMatch(output, /harbor_device_traffic_bytes_total\{[^\n]*name=/);
|
assert.doesNotMatch(output, /harbor_device_traffic_bytes_total\{[^\n]*name=/);
|
||||||
assert.doesNotMatch(output, /harbor_device_traffic_bytes_total\{[^\n]*source="proxy"/);
|
assert.doesNotMatch(output, /harbor_device_traffic_bytes_total\{[^\n]*source="proxy"/);
|
||||||
@@ -90,6 +121,7 @@ test('Prometheus exposition keeps exact counters, stable identity and escaped na
|
|||||||
assert.match(output, /harbor_singbox_tracked_bytes_total\{source="gateway",outbound="vpn",direction="download"\} 777/);
|
assert.match(output, /harbor_singbox_tracked_bytes_total\{source="gateway",outbound="vpn",direction="download"\} 777/);
|
||||||
assert.match(output, /# TYPE harbor_device_singbox_tracked_bytes_total counter/);
|
assert.match(output, /# TYPE harbor_device_singbox_tracked_bytes_total counter/);
|
||||||
assert.match(output, /harbor_device_singbox_tracked_bytes_total\{device_id="dev_0011223344556677",source="gateway",outbound="vpn",direction="upload"\} 55/);
|
assert.match(output, /harbor_device_singbox_tracked_bytes_total\{device_id="dev_0011223344556677",source="gateway",outbound="vpn",direction="upload"\} 55/);
|
||||||
|
assert.match(output, /harbor_device_singbox_tracked_bytes_total\{device_id="dev_0011223344556677",source="proxy",outbound="vpn",direction="download"\} 345/);
|
||||||
assert.match(output, /harbor_device_domain_traffic_bytes_total\{device_id="dev_0011223344556677",domain="chatgpt\.com",service="OpenAI \/ ChatGPT",source="proxy",direction="download"\} 345/);
|
assert.match(output, /harbor_device_domain_traffic_bytes_total\{device_id="dev_0011223344556677",domain="chatgpt\.com",service="OpenAI \/ ChatGPT",source="proxy",direction="download"\} 345/);
|
||||||
assert.match(output, /harbor_domain_traffic_last_observed_timestamp_seconds 1786183200/);
|
assert.match(output, /harbor_domain_traffic_last_observed_timestamp_seconds 1786183200/);
|
||||||
assert.match(output, /harbor_domain_traffic_overflow_connections_total 2/);
|
assert.match(output, /harbor_domain_traffic_overflow_connections_total 2/);
|
||||||
@@ -97,6 +129,15 @@ test('Prometheus exposition keeps exact counters, stable identity and escaped na
|
|||||||
assert.match(output, /harbor_domain_traffic_attribution_events_total\{outcome="unresolved_host"\} 3/);
|
assert.match(output, /harbor_domain_traffic_attribution_events_total\{outcome="unresolved_host"\} 3/);
|
||||||
assert.match(output, /harbor_domain_traffic_attribution_events_total\{outcome="unknown_device"\} 4/);
|
assert.match(output, /harbor_domain_traffic_attribution_events_total\{outcome="unknown_device"\} 4/);
|
||||||
assert.match(output, /harbor_domain_traffic_attribution_events_total\{outcome="unsupported_source"\} 5/);
|
assert.match(output, /harbor_domain_traffic_attribution_events_total\{outcome="unsupported_source"\} 5/);
|
||||||
|
assert.match(output, /harbor_traffic_collector_info\{mode="shadow",writer="snapshot"\} 1/);
|
||||||
|
assert.match(output, /harbor_traffic_collector_state\{state="degraded"\} 1/);
|
||||||
|
assert.match(output, /harbor_traffic_collector_unattributed_bytes\{direction="download"\} 22/);
|
||||||
|
assert.match(output, /harbor_traffic_collector_unattributed_bytes\{direction="upload"\} 11/);
|
||||||
|
assert.match(output, /harbor_traffic_shadow_active_difference 1/);
|
||||||
|
assert.match(output, /harbor_traffic_shadow_difference_bytes\{direction="download"\} 40/);
|
||||||
|
assert.match(output, /harbor_traffic_shadow_difference_bytes\{direction="upload"\} -30/);
|
||||||
|
assert.match(output, /harbor_traffic_shadow_route_mismatches 2/);
|
||||||
|
assert.match(output, /harbor_traffic_shadow_device_mismatches 3/);
|
||||||
assert.doesNotMatch(output, /harbor_device_domain_traffic_bytes_total\{[^\n]*name=/);
|
assert.doesNotMatch(output, /harbor_device_domain_traffic_bytes_total\{[^\n]*name=/);
|
||||||
assert.doesNotMatch(output, /harbor_device_(?:direct_ipv4_packet|singbox_tracked)_bytes_total\{[^\n]*(?:name|ip|mac|server)=/);
|
assert.doesNotMatch(output, /harbor_device_(?:direct_ipv4_packet|singbox_tracked)_bytes_total\{[^\n]*(?:name|ip|mac|server)=/);
|
||||||
assert.equal(output.endsWith('\n'), true);
|
assert.equal(output.endsWith('\n'), true);
|
||||||
@@ -122,6 +163,15 @@ test('Prometheus response uses the negotiated legacy text contract without mutat
|
|||||||
assert.deepEqual(snapshot, before);
|
assert.deepEqual(snapshot, before);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('legacy combined Gateway snapshots keep existing metrics without collector diagnostics', () => {
|
||||||
|
const legacy = structuredClone(snapshot);
|
||||||
|
legacy.domainTraffic.source = { error: null, activeConnections: 0 };
|
||||||
|
const output = renderPrometheusMetrics(legacy);
|
||||||
|
|
||||||
|
assert.match(output, /harbor_singbox_tracked_bytes_total/);
|
||||||
|
assert.doesNotMatch(output, /harbor_traffic_collector_info/);
|
||||||
|
});
|
||||||
|
|
||||||
test('invalid canonical counters fail the scrape instead of publishing corrupt values', () => {
|
test('invalid canonical counters fail the scrape instead of publishing corrupt values', () => {
|
||||||
const invalid = { traffic: { gatewayBytes: 'broken', proxyBytes: '0' }, devices: [] };
|
const invalid = { traffic: { gatewayBytes: 'broken', proxyBytes: '0' }, devices: [] };
|
||||||
assert.throws(
|
assert.throws(
|
||||||
@@ -135,6 +185,25 @@ test('invalid canonical counters fail the scrape instead of publishing corrupt v
|
|||||||
const invalidRoute = structuredClone(snapshot);
|
const invalidRoute = structuredClone(snapshot);
|
||||||
invalidRoute.domainTraffic.routes[0].outbound = 'vpn-server-tag';
|
invalidRoute.domainTraffic.routes[0].outbound = 'vpn-server-tag';
|
||||||
assert.throws(() => renderPrometheusMetrics(invalidRoute), /Invalid sing-box outbound labels/);
|
assert.throws(() => renderPrometheusMetrics(invalidRoute), /Invalid sing-box outbound labels/);
|
||||||
|
const invalidCollector = structuredClone(snapshot);
|
||||||
|
invalidCollector.domainTraffic.source.mode = 'future';
|
||||||
|
assert.throws(() => renderPrometheusMetrics(invalidCollector), /Invalid traffic collector labels/);
|
||||||
|
const invalidShadow = structuredClone(snapshot);
|
||||||
|
invalidShadow.domainTraffic.source.shadow.uploadDifferenceBytes = '1.5';
|
||||||
|
assert.throws(() => renderPrometheusMetrics(invalidShadow), /Invalid Prometheus gauge/);
|
||||||
|
const invalidPolicy = structuredClone(snapshot);
|
||||||
|
invalidPolicy.devices[0].appliedPolicy = 'future';
|
||||||
|
assert.throws(() => renderPrometheusMetrics(invalidPolicy), /Invalid applied device policy/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Prometheus applied policy gauge reports VPN without changing series identity', () => {
|
||||||
|
const vpn = structuredClone(snapshot);
|
||||||
|
vpn.devices[0].appliedPolicy = 'vpn';
|
||||||
|
|
||||||
|
assert.match(
|
||||||
|
renderPrometheusMetrics(vpn),
|
||||||
|
/harbor_device_applied_policy\{device_id="dev_0011223344556677"\} 1/,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
function routeResponse() {
|
function routeResponse() {
|
||||||
|
|||||||
@@ -1,12 +1,28 @@
|
|||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
|
import { create } from '@bufbuild/protobuf';
|
||||||
|
import { connectNodeAdapter } from '@connectrpc/connect-node';
|
||||||
|
import { spawn } from 'node:child_process';
|
||||||
import fs from 'node:fs';
|
import fs from 'node:fs';
|
||||||
|
import http from 'node:http';
|
||||||
|
import http2 from 'node:http2';
|
||||||
import os from 'node:os';
|
import os from 'node:os';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import test from 'node:test';
|
import test from 'node:test';
|
||||||
|
|
||||||
|
import {
|
||||||
|
ConnectionEventsSchema,
|
||||||
|
StartedAtSchema,
|
||||||
|
StartedService,
|
||||||
|
StatusSchema,
|
||||||
|
VersionSchema,
|
||||||
|
} from '../../dist/server/generated/daemon/started_service_pb.js';
|
||||||
|
|
||||||
|
const root = path.resolve(import.meta.dirname, '../..');
|
||||||
|
|
||||||
process.env.APP_MODE = 'gateway';
|
process.env.APP_MODE = 'gateway';
|
||||||
process.env.DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), 'vpn-proxy-gateway-test-'));
|
process.env.DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), 'vpn-proxy-gateway-test-'));
|
||||||
process.env.SING_BOX_CACHE = path.join(process.env.DATA_DIR, 'cache.db');
|
process.env.SING_BOX_CACHE = path.join(process.env.DATA_DIR, 'cache.db');
|
||||||
|
process.env.SING_BOX_TRAFFIC_SOURCE = 'snapshot';
|
||||||
|
|
||||||
const {
|
const {
|
||||||
buildDualChannelGatewayConfig,
|
buildDualChannelGatewayConfig,
|
||||||
@@ -124,3 +140,257 @@ test('cached dual-channel outbounds must match the applied provider fingerprints
|
|||||||
config.outbounds[2].outbounds = ['channel-primary'];
|
config.outbounds[2].outbounds = ['channel-primary'];
|
||||||
assert.equal(dualChannelConfigMatchesApplied(config, applied, 'primary'), false);
|
assert.equal(dualChannelConfigMatchesApplied(config, applied, 'primary'), false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function request(socketPath, pathname, method = 'GET', body) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const payload = body === undefined ? null : JSON.stringify(body);
|
||||||
|
const value = http.request({
|
||||||
|
socketPath,
|
||||||
|
path: pathname,
|
||||||
|
method,
|
||||||
|
...(payload ? { headers: { 'content-type': 'application/json', 'content-length': Buffer.byteLength(payload) } } : {}),
|
||||||
|
}, (response) => {
|
||||||
|
const chunks = [];
|
||||||
|
response.on('data', (chunk) => chunks.push(chunk));
|
||||||
|
response.on('end', () => resolve(JSON.parse(Buffer.concat(chunks).toString('utf8'))));
|
||||||
|
});
|
||||||
|
value.on('error', reject);
|
||||||
|
value.end(payload);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function waitForSocket(socketPath, child, stderr) {
|
||||||
|
for (let attempt = 0; attempt < 100; attempt += 1) {
|
||||||
|
if (child.exitCode !== null) throw new Error(`dataplane exited: ${stderr()}`);
|
||||||
|
try {
|
||||||
|
const status = await request(socketPath, '/status');
|
||||||
|
if (status.ready) return;
|
||||||
|
} catch {}
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||||
|
}
|
||||||
|
throw new Error(`dataplane did not start: ${stderr()}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function startDataplane(mode, apiPort) {
|
||||||
|
const directory = fs.mkdtempSync(path.join(os.tmpdir(), `harbor-${mode}-`));
|
||||||
|
const socketPath = path.join(directory, 'dataplane.sock');
|
||||||
|
const configPath = path.join(directory, 'sing-box.json');
|
||||||
|
const binDirectory = path.join(directory, 'bin');
|
||||||
|
fs.mkdirSync(binDirectory);
|
||||||
|
fs.writeFileSync(configPath, JSON.stringify({
|
||||||
|
services: [{
|
||||||
|
type: 'api',
|
||||||
|
listen: '127.0.0.1',
|
||||||
|
listen_port: 19091,
|
||||||
|
dashboard: false,
|
||||||
|
}],
|
||||||
|
inbounds: [],
|
||||||
|
outbounds: [],
|
||||||
|
}));
|
||||||
|
for (const [name, source] of [
|
||||||
|
['sing-box', `#!/bin/sh
|
||||||
|
if [ "$1" = check ]; then exit 0; fi
|
||||||
|
trap 'exit 0' TERM INT
|
||||||
|
while :; do sleep 1; done
|
||||||
|
`],
|
||||||
|
['ip', `#!/bin/sh
|
||||||
|
printf '%s\n' '[{"dst":"192.168.50.7","lladdr":"00:11:22:33:44:55","dev":"en0","state":["REACHABLE"]}]'
|
||||||
|
`],
|
||||||
|
['iptables', '#!/bin/sh\nexit 0\n'],
|
||||||
|
['iptables-restore', '#!/bin/sh\nexit 0\n'],
|
||||||
|
]) {
|
||||||
|
const executable = path.join(binDirectory, name);
|
||||||
|
fs.writeFileSync(executable, source);
|
||||||
|
fs.chmodSync(executable, 0o755);
|
||||||
|
}
|
||||||
|
const child = spawn(process.execPath, ['dist/server/main.js'], {
|
||||||
|
cwd: root,
|
||||||
|
env: {
|
||||||
|
...process.env,
|
||||||
|
APP_MODE: 'gateway',
|
||||||
|
APP_COMPONENT: 'dataplane',
|
||||||
|
DATA_DIR: directory,
|
||||||
|
DATAPLANE_SOCKET: socketPath,
|
||||||
|
DEVICE_TRAFFIC_ACCOUNTING_ENABLED: 'false',
|
||||||
|
SING_BOX_API_PORT: String(apiPort),
|
||||||
|
SING_BOX_CACHE: path.join(directory, 'cache.db'),
|
||||||
|
SING_BOX_CONFIG: configPath,
|
||||||
|
SING_BOX_API_SECRET: path.join(directory, 'api.secret'),
|
||||||
|
SING_BOX_RUNTIME_CONFIG: path.join(directory, 'runtime-config.json'),
|
||||||
|
SING_BOX_TRAFFIC_SOURCE: mode,
|
||||||
|
PATH: `${binDirectory}:${process.env.PATH || ''}`,
|
||||||
|
},
|
||||||
|
stdio: ['ignore', 'ignore', 'pipe'],
|
||||||
|
});
|
||||||
|
let error = '';
|
||||||
|
child.stderr.on('data', (chunk) => { error += chunk; });
|
||||||
|
const stop = async () => {
|
||||||
|
if (child.exitCode === null) {
|
||||||
|
const exited = new Promise((resolve) => child.once('exit', resolve));
|
||||||
|
child.kill('SIGTERM');
|
||||||
|
await exited;
|
||||||
|
}
|
||||||
|
fs.rmSync(directory, { recursive: true, force: true });
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
await waitForSocket(socketPath, child, () => error);
|
||||||
|
return { socketPath, child, stop };
|
||||||
|
} catch (reason) {
|
||||||
|
await stop();
|
||||||
|
throw reason;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function waitForAbort(signal) {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
if (signal.aborted) resolve();
|
||||||
|
else signal.addEventListener('abort', resolve, { once: true });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function waitForSnapshot(socketPath, pathname, predicate, method = 'GET', body) {
|
||||||
|
const deadline = Date.now() + 3_000;
|
||||||
|
while (Date.now() < deadline) {
|
||||||
|
const snapshot = await request(socketPath, pathname, method, body);
|
||||||
|
if (predicate(snapshot)) return snapshot;
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||||
|
}
|
||||||
|
throw new Error(`timed out waiting for ${pathname}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function nativeRoutes(router, source) {
|
||||||
|
router.service(StartedService, {
|
||||||
|
getVersion: () => create(VersionSchema, { version: '1.14.0-rc.5', apiVersion: 4 }),
|
||||||
|
getStartedAt: () => create(StartedAtSchema, { startedAt: 1_700_000_000_000n }),
|
||||||
|
async *subscribeConnections(_request, context) {
|
||||||
|
yield create(ConnectionEventsSchema, {
|
||||||
|
reset: true,
|
||||||
|
events: [{
|
||||||
|
type: 0,
|
||||||
|
id: 'native-1',
|
||||||
|
connection: {
|
||||||
|
id: 'native-1',
|
||||||
|
inbound: 'tproxy-in',
|
||||||
|
inboundType: 'tproxy',
|
||||||
|
network: 'tcp',
|
||||||
|
source: '192.168.50.7:54000',
|
||||||
|
destination: '203.0.113.10:443',
|
||||||
|
domain: 'native.example',
|
||||||
|
protocol: 'tls',
|
||||||
|
createdAt: 1_700_000_000_000n,
|
||||||
|
uplinkTotal: 101n,
|
||||||
|
downlinkTotal: 202n,
|
||||||
|
outbound: 'channel-selector',
|
||||||
|
outboundType: 'selector',
|
||||||
|
chainList: ['channel-primary', 'channel-selector'],
|
||||||
|
},
|
||||||
|
}],
|
||||||
|
});
|
||||||
|
await waitForAbort(context.signal);
|
||||||
|
},
|
||||||
|
async *subscribeStatus(_request, context) {
|
||||||
|
while (!context.signal.aborted) {
|
||||||
|
yield create(StatusSchema, {
|
||||||
|
connectionsIn: source.mismatch ? 2 : 1,
|
||||||
|
uplinkTotal: source.mismatch ? 999n : 101n,
|
||||||
|
downlinkTotal: source.mismatch ? 999n : 202n,
|
||||||
|
});
|
||||||
|
await Promise.race([
|
||||||
|
waitForAbort(context.signal),
|
||||||
|
new Promise((resolve) => setTimeout(resolve, 100)),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test('Gateway modes keep snapshot compare-only and make native the sole canonical writer', async (t) => {
|
||||||
|
let connectionReads = 0;
|
||||||
|
const nativeSource = { mismatch: false };
|
||||||
|
const clash = http.createServer((req, res) => {
|
||||||
|
if (req.url === '/connections') connectionReads += 1;
|
||||||
|
res.writeHead(200, { 'content-type': 'application/json' });
|
||||||
|
res.end(JSON.stringify({ connections: [{
|
||||||
|
id: 'legacy-1',
|
||||||
|
upload: 11,
|
||||||
|
download: 22,
|
||||||
|
metadata: {
|
||||||
|
type: 'tproxy/tproxy-in',
|
||||||
|
host: 'legacy.example',
|
||||||
|
sourceIP: '192.168.50.7',
|
||||||
|
},
|
||||||
|
chains: ['channel-primary'],
|
||||||
|
}] }));
|
||||||
|
});
|
||||||
|
await new Promise((resolve) => clash.listen(0, '127.0.0.1', resolve));
|
||||||
|
t.after(() => new Promise((resolve) => clash.close(resolve)));
|
||||||
|
const apiPort = clash.address().port;
|
||||||
|
const native = http2.createServer(connectNodeAdapter({
|
||||||
|
routes: (router) => nativeRoutes(router, nativeSource),
|
||||||
|
}));
|
||||||
|
await new Promise((resolve) => native.listen(19091, '127.0.0.1', resolve));
|
||||||
|
t.after(() => new Promise((resolve) => native.close(resolve)));
|
||||||
|
|
||||||
|
for (const mode of ['snapshot', 'shadow', 'native']) {
|
||||||
|
connectionReads = 0;
|
||||||
|
const dataplane = await startDataplane(mode, apiPort);
|
||||||
|
try {
|
||||||
|
const live = mode === 'snapshot'
|
||||||
|
? await request(dataplane.socketPath, '/traffic/live')
|
||||||
|
: await waitForSnapshot(
|
||||||
|
dataplane.socketPath,
|
||||||
|
'/traffic/live',
|
||||||
|
(snapshot) => snapshot.source.state === 'live',
|
||||||
|
);
|
||||||
|
const domain = await waitForSnapshot(
|
||||||
|
dataplane.socketPath,
|
||||||
|
'/domain-traffic',
|
||||||
|
(snapshot) => snapshot.tracked[0]?.uploadBytes === (mode === 'native' ? '101' : '11'),
|
||||||
|
);
|
||||||
|
assert.equal(domain.source.mode, mode);
|
||||||
|
assert.equal(domain.source.writer, mode === 'native' ? 'native' : 'snapshot');
|
||||||
|
assert.equal(domain.source.native === null, mode === 'snapshot');
|
||||||
|
assert.equal(domain.source.shadow === null, mode !== 'shadow');
|
||||||
|
assert.equal(live.source.state, mode === 'snapshot' ? 'disabled' : 'live');
|
||||||
|
assert.equal(connectionReads > 0, mode !== 'native');
|
||||||
|
if (mode === 'shadow') {
|
||||||
|
assert.equal(domain.source.native.active, 1);
|
||||||
|
assert.equal(domain.source.shadow.uploadDifferenceBytes, '90');
|
||||||
|
assert.equal(domain.source.shadow.downloadDifferenceBytes, '180');
|
||||||
|
}
|
||||||
|
if (mode === 'native') {
|
||||||
|
assert.equal(connectionReads, 0);
|
||||||
|
assert.equal(live.connections[0].id, 'native-1');
|
||||||
|
assert.deepEqual(domain.tracked, [{
|
||||||
|
source: 'gateway',
|
||||||
|
outbound: 'vpn',
|
||||||
|
uploadBytes: '101',
|
||||||
|
downloadBytes: '202',
|
||||||
|
}]);
|
||||||
|
await request(dataplane.socketPath, '/failover/activity', 'PUT', { enabled: true });
|
||||||
|
await waitForSnapshot(
|
||||||
|
dataplane.socketPath,
|
||||||
|
'/failover/activity/read',
|
||||||
|
(response) => response.activity?.state === 'quiet',
|
||||||
|
'POST',
|
||||||
|
{ thresholdBytesPerSecond: 0 },
|
||||||
|
);
|
||||||
|
nativeSource.mismatch = true;
|
||||||
|
await waitForSnapshot(
|
||||||
|
dataplane.socketPath,
|
||||||
|
'/traffic/live',
|
||||||
|
(snapshot) => snapshot.source.state === 'degraded',
|
||||||
|
);
|
||||||
|
const degradedActivity = await request(
|
||||||
|
dataplane.socketPath,
|
||||||
|
'/failover/activity/read',
|
||||||
|
'POST',
|
||||||
|
{ thresholdBytesPerSecond: 0 },
|
||||||
|
);
|
||||||
|
assert.equal(degradedActivity.activity, null);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
await dataplane.stop();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|||||||
@@ -0,0 +1,378 @@
|
|||||||
|
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 stop interrupts an active retry delay', async () => {
|
||||||
|
const service = createLiveTrafficService({
|
||||||
|
port: 19091,
|
||||||
|
enabled: true,
|
||||||
|
isRuntimeRunning: () => true,
|
||||||
|
clientFactory: () => ({
|
||||||
|
async getVersion() { return { version: '1.13.18', apiVersion: 4 }; },
|
||||||
|
async getStartedAt() { throw new Error('must not start'); },
|
||||||
|
subscribeConnections() { throw new Error('must not subscribe'); },
|
||||||
|
subscribeStatus() { throw new Error('must not subscribe'); },
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
service.start();
|
||||||
|
await waitFor(() => service.snapshot().source.state === 'incompatible');
|
||||||
|
await new Promise((resolve) => setImmediate(resolve));
|
||||||
|
const stoppedBeforeNextTurn = await Promise.race([
|
||||||
|
service.stop().then(() => true),
|
||||||
|
new Promise((resolve) => setImmediate(() => resolve(false))),
|
||||||
|
]);
|
||||||
|
assert.equal(stoppedBeforeNextTurn, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
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();
|
||||||
|
});
|
||||||
@@ -5,12 +5,42 @@ import http from 'node:http';
|
|||||||
import os from 'node:os';
|
import os from 'node:os';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import test from 'node:test';
|
import test from 'node:test';
|
||||||
|
import { DatabaseSync } from 'node:sqlite';
|
||||||
|
|
||||||
import { buildGatewayPresence } from '../../dist/server/gatewayPresence.js';
|
import { buildGatewayPresence } from '../../dist/server/gatewayPresence.js';
|
||||||
import { normalizeSubscriptionConfig } from '../../dist/server/subscription.js';
|
import { normalizeSubscriptionConfig } from '../../dist/server/subscription.js';
|
||||||
|
|
||||||
const root = path.resolve(import.meta.dirname, '../..');
|
const root = path.resolve(import.meta.dirname, '../..');
|
||||||
|
|
||||||
|
test('startup recovery rejects a materialized native API secret as shared config truth', async (t) => {
|
||||||
|
const server = {
|
||||||
|
id: 'server-a',
|
||||||
|
label: 'Server A',
|
||||||
|
host: 'a.example',
|
||||||
|
port: 443,
|
||||||
|
protocol: 'vless',
|
||||||
|
};
|
||||||
|
const fixture = await startClientFixture(t, {
|
||||||
|
state: profileState(server),
|
||||||
|
config: {
|
||||||
|
...generatedConfig(server),
|
||||||
|
services: [{
|
||||||
|
type: 'api',
|
||||||
|
listen: '127.0.0.1',
|
||||||
|
listen_port: 19091,
|
||||||
|
dashboard: false,
|
||||||
|
secret: 'a'.repeat(64),
|
||||||
|
}],
|
||||||
|
},
|
||||||
|
trafficSource: 'native',
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(fixture.state.connection.desired, 'stopped');
|
||||||
|
assert.equal(fixture.state.connection.process, 'stopped');
|
||||||
|
assert.equal(fs.existsSync(fixture.markerPath), false);
|
||||||
|
assert.equal(fs.existsSync(path.join(fixture.directory, 'sing-box-config.json')), false);
|
||||||
|
});
|
||||||
|
|
||||||
function listen(server, ...args) {
|
function listen(server, ...args) {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
server.once('error', reject);
|
server.once('error', reject);
|
||||||
@@ -121,6 +151,8 @@ async function startClientFixture(t, {
|
|||||||
config,
|
config,
|
||||||
hostNetwork,
|
hostNetwork,
|
||||||
gatewayPresencePort,
|
gatewayPresencePort,
|
||||||
|
trafficSource,
|
||||||
|
expectMigrationError,
|
||||||
}) {
|
}) {
|
||||||
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'harbor-startup-recovery-'));
|
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'harbor-startup-recovery-'));
|
||||||
const { binDirectory, markerPath } = fakeSingbox(directory);
|
const { binDirectory, markerPath } = fakeSingbox(directory);
|
||||||
@@ -148,6 +180,7 @@ async function startClientFixture(t, {
|
|||||||
: hostNetworkPath,
|
: hostNetworkPath,
|
||||||
...(gatewayPresencePort ? { HARBOR_GATEWAY_CONTROL_PORT: String(gatewayPresencePort) } : {}),
|
...(gatewayPresencePort ? { HARBOR_GATEWAY_CONTROL_PORT: String(gatewayPresencePort) } : {}),
|
||||||
HARBOR_TEST_RUN_MARKER: markerPath,
|
HARBOR_TEST_RUN_MARKER: markerPath,
|
||||||
|
...(trafficSource ? { SING_BOX_TRAFFIC_SOURCE: trafficSource } : {}),
|
||||||
},
|
},
|
||||||
stdio: ['ignore', 'ignore', 'pipe'],
|
stdio: ['ignore', 'ignore', 'pipe'],
|
||||||
});
|
});
|
||||||
@@ -157,6 +190,15 @@ async function startClientFixture(t, {
|
|||||||
await stopChild(child);
|
await stopChild(child);
|
||||||
fs.rmSync(directory, { recursive: true, force: true });
|
fs.rmSync(directory, { recursive: true, force: true });
|
||||||
});
|
});
|
||||||
|
if (expectMigrationError) {
|
||||||
|
await assert.rejects(waitForState(port, child, () => stderr), expectMigrationError);
|
||||||
|
assert.notEqual(child.exitCode, 0);
|
||||||
|
assert.equal(fs.existsSync(markerPath), false);
|
||||||
|
assert.deepEqual(JSON.parse(fs.readFileSync(path.join(directory, 'state.json'), 'utf8')), state);
|
||||||
|
assert.equal(fs.readFileSync(path.join(directory, 'subscription-cache.json'), 'utf8'), cacheContents);
|
||||||
|
assert.deepEqual(JSON.parse(fs.readFileSync(path.join(directory, 'sing-box-config.json'), 'utf8')), config);
|
||||||
|
return { directory, markerPath };
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
directory,
|
directory,
|
||||||
markerPath,
|
markerPath,
|
||||||
@@ -226,7 +268,9 @@ test('gateway-direct boot keeps the local proxy and diagnostics but omits every
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
const config = JSON.parse(fs.readFileSync(path.join(fixture.directory, 'sing-box-config.json'), 'utf8'));
|
const config = JSON.parse(fs.readFileSync(path.join(fixture.directory, 'sing-box-config.json'), 'utf8'));
|
||||||
const stored = JSON.parse(fs.readFileSync(path.join(fixture.directory, 'state.json'), 'utf8'));
|
const db = new DatabaseSync(path.join(fixture.directory, 'harbor.sqlite'), { readOnly: true });
|
||||||
|
const stored = JSON.parse(db.prepare("SELECT value FROM documents WHERE key = 'state'").get().value);
|
||||||
|
db.close();
|
||||||
|
|
||||||
assert.equal(fixture.state.route.mode, 'gateway-direct');
|
assert.equal(fixture.state.route.mode, 'gateway-direct');
|
||||||
assert.deepEqual(fixture.state.route.activeLocalRules, []);
|
assert.deepEqual(fixture.state.route.activeLocalRules, []);
|
||||||
@@ -248,23 +292,15 @@ test('corrupt legacy cache with no canonical subscription fails closed instead o
|
|||||||
port: 443,
|
port: 443,
|
||||||
protocol: 'vless',
|
protocol: 'vless',
|
||||||
};
|
};
|
||||||
const fixture = await startClientFixture(t, {
|
await startClientFixture(t, {
|
||||||
state: { schemaVersion: 4, revision: 2, connectionDesired: 'running' },
|
state: { schemaVersion: 4, revision: 2, connectionDesired: 'running' },
|
||||||
cacheContents: '{broken',
|
cacheContents: '{broken',
|
||||||
config: generatedConfig(staleServer),
|
config: generatedConfig(staleServer),
|
||||||
|
expectMigrationError: /Cannot migrate subscription-cache.json/,
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
assert.equal(fixture.state.subscription.status, 'missing');
|
test('legacy cache owned by another URL aborts migration without mixing providers or changing originals', async (t) => {
|
||||||
assert.deepEqual(fixture.state.profiles, []);
|
|
||||||
assert.equal(fixture.state.connection.desired, 'stopped');
|
|
||||||
assert.equal(fixture.state.connection.process, 'stopped');
|
|
||||||
assert.equal(fs.existsSync(fixture.markerPath), false);
|
|
||||||
assert.ok(fs.readdirSync(fixture.directory).some((name) => (
|
|
||||||
name.startsWith('subscription-cache.json.corrupt-')
|
|
||||||
)));
|
|
||||||
});
|
|
||||||
|
|
||||||
test('legacy cache owned by another URL is backed up without mixing providers and boots stopped', async (t) => {
|
|
||||||
const stateServer = {
|
const stateServer = {
|
||||||
id: 'server-a',
|
id: 'server-a',
|
||||||
label: 'State server',
|
label: 'State server',
|
||||||
@@ -283,7 +319,8 @@ test('legacy cache owned by another URL is backed up without mixing providers an
|
|||||||
server: 'cache.example',
|
server: 'cache.example',
|
||||||
server_port: 8443,
|
server_port: 8443,
|
||||||
};
|
};
|
||||||
const fixture = await startClientFixture(t, {
|
await startClientFixture(t, {
|
||||||
|
expectMigrationError: /owner mismatch/,
|
||||||
state: {
|
state: {
|
||||||
schemaVersion: 4,
|
schemaVersion: 4,
|
||||||
revision: 2,
|
revision: 2,
|
||||||
@@ -300,20 +337,6 @@ test('legacy cache owned by another URL is backed up without mixing providers an
|
|||||||
}),
|
}),
|
||||||
config: generatedConfig(stateServer),
|
config: generatedConfig(stateServer),
|
||||||
});
|
});
|
||||||
|
|
||||||
assert.equal(fixture.state.profiles.length, 1);
|
|
||||||
assert.equal(fixture.state.profiles[0].subscription.host, 'state.example/…');
|
|
||||||
assert.deepEqual(fixture.state.profiles[0].servers.map(({ id }) => id), [stateServer.id]);
|
|
||||||
assert.equal(JSON.stringify(fixture.state).includes('cache.example'), false);
|
|
||||||
assert.equal(fixture.state.connection.desired, 'stopped');
|
|
||||||
assert.equal(fixture.state.connection.process, 'stopped');
|
|
||||||
assert.equal(fixture.state.selection.appliedServerId, '');
|
|
||||||
assert.equal(fs.existsSync(path.join(fixture.directory, 'subscription-cache.json')), false);
|
|
||||||
assert.ok(fs.readdirSync(fixture.directory).some((name) => (
|
|
||||||
name.startsWith('subscription-cache.json.backup-v1-')
|
|
||||||
)));
|
|
||||||
assert.equal(fs.existsSync(path.join(fixture.directory, 'sing-box-config.json')), false);
|
|
||||||
assert.equal(fs.existsSync(fixture.markerPath), false);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('boot rejects an existing config whose route mode disagrees with the current route', async (t) => {
|
test('boot rejects an existing config whose route mode disagrees with the current route', async (t) => {
|
||||||
@@ -363,6 +386,34 @@ test('boot rejects an existing config owned by a different applied target', asyn
|
|||||||
assert.equal(fs.existsSync(path.join(fixture.directory, 'sing-box-config.json')), false);
|
assert.equal(fs.existsSync(path.join(fixture.directory, 'sing-box-config.json')), false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('disabled traffic source rejects an existing config with any API service', async (t) => {
|
||||||
|
const server = {
|
||||||
|
id: 'server-a',
|
||||||
|
label: 'Server A',
|
||||||
|
host: 'a.example',
|
||||||
|
port: 443,
|
||||||
|
protocol: 'vless',
|
||||||
|
};
|
||||||
|
const fixture = await startClientFixture(t, {
|
||||||
|
state: profileState(server),
|
||||||
|
config: {
|
||||||
|
...generatedConfig(server),
|
||||||
|
services: [{
|
||||||
|
type: 'api',
|
||||||
|
listen: '0.0.0.0',
|
||||||
|
listen_port: 19091,
|
||||||
|
dashboard: false,
|
||||||
|
}],
|
||||||
|
},
|
||||||
|
trafficSource: 'disabled',
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(fixture.state.connection.desired, 'stopped');
|
||||||
|
assert.equal(fixture.state.connection.process, 'stopped');
|
||||||
|
assert.equal(fs.existsSync(fixture.markerPath), false);
|
||||||
|
assert.equal(fs.existsSync(path.join(fixture.directory, 'sing-box-config.json')), false);
|
||||||
|
});
|
||||||
|
|
||||||
test('stopped Gateway boot explicitly stops an already running remote dataplane', async (t) => {
|
test('stopped Gateway boot explicitly stops an already running remote dataplane', async (t) => {
|
||||||
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'harbor-stopped-remote-'));
|
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'harbor-stopped-remote-'));
|
||||||
const socketPath = path.join(directory, 'dataplane.sock');
|
const socketPath = path.join(directory, 'dataplane.sock');
|
||||||
|
|||||||
@@ -5,6 +5,13 @@ import http from 'node:http';
|
|||||||
import os from 'node:os';
|
import os from 'node:os';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import test from 'node:test';
|
import test from 'node:test';
|
||||||
|
import { DatabaseSync } from 'node:sqlite';
|
||||||
|
|
||||||
|
function readState(directory) {
|
||||||
|
const db = new DatabaseSync(path.join(directory, 'harbor.sqlite'), { readOnly: true });
|
||||||
|
try { return JSON.parse(db.prepare("SELECT value FROM documents WHERE key = 'state'").get().value); }
|
||||||
|
finally { db.close(); }
|
||||||
|
}
|
||||||
|
|
||||||
import {
|
import {
|
||||||
assertStateSnapshot,
|
assertStateSnapshot,
|
||||||
@@ -99,7 +106,7 @@ test('state v1 projects legacy storage through the canonical profile snapshot',
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('startup discards a rejected cached subscription and returns to first-run', async (t) => {
|
test('startup rejects an invalid legacy subscription without changing migration originals', async (t) => {
|
||||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'harbor-rejected-cache-'));
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'harbor-rejected-cache-'));
|
||||||
const port = await freePort();
|
const port = await freePort();
|
||||||
const subscriptionUrl = 'https://provider.example/disabled';
|
const subscriptionUrl = 'https://provider.example/disabled';
|
||||||
@@ -141,16 +148,11 @@ test('startup discards a rejected cached subscription and returns to first-run',
|
|||||||
fs.rmSync(dir, { recursive: true, force: true });
|
fs.rmSync(dir, { recursive: true, force: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
const state = await waitForState(port, child, () => stderr);
|
await assert.rejects(waitForState(port, child, () => stderr), /Harbor exited early/);
|
||||||
assert.equal(state.subscription.status, 'missing');
|
assert.notEqual(child.exitCode, 0);
|
||||||
assert.equal(state.hasSubscription, false);
|
assert.equal(fs.existsSync(path.join(dir, 'subscription-cache.json')), true);
|
||||||
assert.deepEqual(state.servers, []);
|
assert.equal(fs.readFileSync(path.join(dir, 'sing-box-config.json'), 'utf8'), '{}');
|
||||||
assert.ok(state.route.localRules.some((rule) => (
|
assert.equal(JSON.parse(fs.readFileSync(path.join(dir, 'state.json'), 'utf8')).subscriptionUrl, subscriptionUrl);
|
||||||
rule.type === 'domain_suffix' && rule.value === 'example.org' && rule.enabled
|
|
||||||
)));
|
|
||||||
assert.equal(fs.existsSync(path.join(dir, 'subscription-cache.json')), false);
|
|
||||||
assert.equal(fs.existsSync(path.join(dir, 'sing-box-config.json')), false);
|
|
||||||
assert.equal(child.exitCode, null);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('data invariant: canonical profile API mutates one snapshot and preserves migrated profile data', async (t) => {
|
test('data invariant: canonical profile API mutates one snapshot and preserves migrated profile data', async (t) => {
|
||||||
@@ -305,15 +307,15 @@ setInterval(() => {}, 60_000);
|
|||||||
assert.equal(initial.route.localRulesRevision, 0);
|
assert.equal(initial.route.localRulesRevision, 0);
|
||||||
assert.equal(initial.route.localRulesPendingRestart, false);
|
assert.equal(initial.route.localRulesPendingRestart, false);
|
||||||
assert.equal(JSON.stringify(initial).includes(subscriptionUrl), false);
|
assert.equal(JSON.stringify(initial).includes(subscriptionUrl), false);
|
||||||
const migratedState = JSON.parse(fs.readFileSync(path.join(dir, 'state.json'), 'utf8'));
|
const migratedState = readState(dir);
|
||||||
assert.equal(migratedState.schemaVersion, STATE_SCHEMA_VERSION);
|
assert.equal(migratedState.schemaVersion, STATE_SCHEMA_VERSION);
|
||||||
assert.equal(migratedState.profiles.length, 1);
|
assert.equal(migratedState.profiles.length, 1);
|
||||||
assert.equal(migratedState.profiles[0].subscriptionUrl, subscriptionUrl);
|
assert.equal(migratedState.profiles[0].subscriptionUrl, subscriptionUrl);
|
||||||
assert.equal(migratedState.profiles[0].desiredServerId, testServerId);
|
assert.equal(migratedState.profiles[0].desiredServerId, testServerId);
|
||||||
assert.equal(migratedState.profiles[0].subscriptionConfig.outbounds[0].tag, testServerId);
|
assert.equal(migratedState.profiles[0].subscriptionConfig.outbounds[0].tag, testServerId);
|
||||||
assert.equal(Object.hasOwn(migratedState, 'subscriptionUrl'), false);
|
assert.equal(Object.hasOwn(migratedState, 'subscriptionUrl'), false);
|
||||||
assert.equal(fs.existsSync(path.join(dir, 'subscription-cache.json')), false);
|
assert.equal(fs.existsSync(path.join(dir, 'subscription-cache.json')), true);
|
||||||
assert.ok(fs.readdirSync(dir).some((name) => name.startsWith('subscription-cache.json.backup-v1-')));
|
assert.deepEqual(JSON.parse(fs.readFileSync(path.join(dir, 'subscription-cache.json'), 'utf8')), { url: subscriptionUrl, config });
|
||||||
const stateKeys = Object.keys(initial).sort();
|
const stateKeys = Object.keys(initial).sort();
|
||||||
assert.deepEqual(stateKeys, [
|
assert.deepEqual(stateKeys, [
|
||||||
'apiVersion',
|
'apiVersion',
|
||||||
@@ -339,6 +341,7 @@ setInterval(() => {}, 60_000);
|
|||||||
'singboxStartedAt',
|
'singboxStartedAt',
|
||||||
'subscription',
|
'subscription',
|
||||||
'subscriptionHost',
|
'subscriptionHost',
|
||||||
|
'traffic',
|
||||||
'userInfo',
|
'userInfo',
|
||||||
]);
|
]);
|
||||||
let revision = initial.revision;
|
let revision = initial.revision;
|
||||||
@@ -372,7 +375,7 @@ setInterval(() => {}, 60_000);
|
|||||||
|
|
||||||
const primaryProfileId = initial.profiles[0].id;
|
const primaryProfileId = initial.profiles[0].id;
|
||||||
const preservedPrimary = structuredClone(
|
const preservedPrimary = structuredClone(
|
||||||
JSON.parse(fs.readFileSync(path.join(dir, 'state.json'), 'utf8')).profiles[0],
|
readState(dir).profiles[0],
|
||||||
);
|
);
|
||||||
const preservedConfig = fs.readFileSync(path.join(dir, 'sing-box-config.json'), 'utf8');
|
const preservedConfig = fs.readFileSync(path.join(dir, 'sing-box-config.json'), 'utf8');
|
||||||
for (const [pathname, expectedCode] of [
|
for (const [pathname, expectedCode] of [
|
||||||
@@ -393,9 +396,9 @@ setInterval(() => {}, 60_000);
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
assert.equal(failedAdd.payload.error.code, expectedCode);
|
assert.equal(failedAdd.payload.error.code, expectedCode);
|
||||||
const storedAfterFailure = JSON.parse(fs.readFileSync(path.join(dir, 'state.json'), 'utf8'));
|
const storedAfterFailure = readState(dir);
|
||||||
assert.deepEqual(storedAfterFailure.profiles, [preservedPrimary]);
|
assert.deepEqual(storedAfterFailure.profiles, [preservedPrimary]);
|
||||||
assert.equal(fs.existsSync(path.join(dir, 'subscription-cache.json')), false);
|
assert.equal(fs.existsSync(path.join(dir, 'subscription-cache.json')), true);
|
||||||
assert.equal(
|
assert.equal(
|
||||||
fs.readFileSync(path.join(dir, 'sing-box-config.json'), 'utf8'),
|
fs.readFileSync(path.join(dir, 'sing-box-config.json'), 'utf8'),
|
||||||
preservedConfig,
|
preservedConfig,
|
||||||
@@ -474,7 +477,7 @@ setInterval(() => {}, 60_000);
|
|||||||
assert.equal(added.state.selection.desiredProfileId, primaryProfileId);
|
assert.equal(added.state.selection.desiredProfileId, primaryProfileId);
|
||||||
assert.equal(added.state.profiles.find(({ id }) => id === workProfileId).desiredServerId, '');
|
assert.equal(added.state.profiles.find(({ id }) => id === workProfileId).desiredServerId, '');
|
||||||
assert.equal(
|
assert.equal(
|
||||||
JSON.parse(fs.readFileSync(path.join(dir, 'state.json'), 'utf8'))
|
readState(dir)
|
||||||
.profiles.find(({ id }) => id === workProfileId).subscriptionConfig.outbounds[0].tag,
|
.profiles.find(({ id }) => id === workProfileId).subscriptionConfig.outbounds[0].tag,
|
||||||
testServerId,
|
testServerId,
|
||||||
);
|
);
|
||||||
@@ -516,7 +519,7 @@ setInterval(() => {}, 60_000);
|
|||||||
);
|
);
|
||||||
assert.equal(failedRefresh.response.status, 400);
|
assert.equal(failedRefresh.response.status, 400);
|
||||||
assert.equal(failedRefresh.payload.error.code, 'SUBSCRIPTION_INVALID');
|
assert.equal(failedRefresh.payload.error.code, 'SUBSCRIPTION_INVALID');
|
||||||
const storedAfterFailedRefresh = JSON.parse(fs.readFileSync(path.join(dir, 'state.json'), 'utf8'));
|
const storedAfterFailedRefresh = readState(dir);
|
||||||
const staleWorkProfile = storedAfterFailedRefresh.profiles.find(({ id }) => id === workProfileId);
|
const staleWorkProfile = storedAfterFailedRefresh.profiles.find(({ id }) => id === workProfileId);
|
||||||
assert.equal(staleWorkProfile.desiredServerId, testServerId);
|
assert.equal(staleWorkProfile.desiredServerId, testServerId);
|
||||||
assert.equal(staleWorkProfile.lastRefreshErrorCode, 'SUBSCRIPTION_INVALID');
|
assert.equal(staleWorkProfile.lastRefreshErrorCode, 'SUBSCRIPTION_INVALID');
|
||||||
|
|||||||
@@ -102,6 +102,8 @@ test('schema v5 migrates rules and diagnostics settings with an exact backup', (
|
|||||||
configured: false,
|
configured: false,
|
||||||
customServices: [],
|
customServices: [],
|
||||||
hiddenServiceIds: [],
|
hiddenServiceIds: [],
|
||||||
|
customDnsResolvers: [],
|
||||||
|
customDnsDomains: [],
|
||||||
});
|
});
|
||||||
assert.equal(migrated.routeRulesRevision, 7);
|
assert.equal(migrated.routeRulesRevision, 7);
|
||||||
assert.deepEqual(migrated.routeRules.map(({ outbound }) => outbound), ['direct', 'direct']);
|
assert.deepEqual(migrated.routeRules.map(({ outbound }) => outbound), ['direct', 'direct']);
|
||||||
@@ -137,17 +139,47 @@ test('schema v7 migrates failover disabled without losing canonical state', (t)
|
|||||||
});
|
});
|
||||||
const migrated = store.read();
|
const migrated = store.read();
|
||||||
|
|
||||||
assert.equal(migrated.schemaVersion, 8);
|
assert.equal(migrated.schemaVersion, STATE_SCHEMA_VERSION);
|
||||||
assert.equal(migrated.revision, 19);
|
assert.equal(migrated.revision, 19);
|
||||||
assert.equal(migrated.routeRulesRevision, 4);
|
assert.equal(migrated.routeRulesRevision, 4);
|
||||||
assert.equal(migrated.failoverPolicy.enabled, false);
|
assert.equal(migrated.failoverPolicy.enabled, false);
|
||||||
assert.equal(migrated.failoverRuntimeState.lastSwitchAt, null);
|
assert.equal(migrated.failoverRuntimeState.lastSwitchAt, null);
|
||||||
assert.equal(migrated.appliedFailoverPolicy, null);
|
assert.equal(migrated.appliedFailoverPolicy, null);
|
||||||
assert.deepEqual(migrated.diagnostics.hiddenServiceIds, ['google']);
|
assert.deepEqual(migrated.diagnostics.hiddenServiceIds, ['google']);
|
||||||
|
assert.deepEqual(migrated.traffic, { grouping: 'site', sort: 'popular', retentionSeconds: 10 });
|
||||||
assert.equal(store.migration.fromVersion, 7);
|
assert.equal(store.migration.fromVersion, 7);
|
||||||
assert.deepEqual(JSON.parse(fs.readFileSync(store.migration.backupPath, 'utf8')), legacy);
|
assert.deepEqual(JSON.parse(fs.readFileSync(store.migration.backupPath, 'utf8')), legacy);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('schema v9 adds empty DNS settings atomically and preserves the exact backup', (t) => {
|
||||||
|
const filePath = fixture(t);
|
||||||
|
const legacy = {
|
||||||
|
schemaVersion: 9,
|
||||||
|
revision: 23,
|
||||||
|
routeRules: [],
|
||||||
|
appliedRouteRules: [],
|
||||||
|
diagnostics: {
|
||||||
|
configured: true,
|
||||||
|
customServices: [{ id: 'custom-status', label: 'Status', url: 'https://example.com/' }],
|
||||||
|
hiddenServiceIds: ['youtube'],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const bytes = JSON.stringify(legacy);
|
||||||
|
fs.writeFileSync(filePath, bytes);
|
||||||
|
|
||||||
|
const store = createStateStore(filePath, {
|
||||||
|
now: () => new Date('2026-09-01T12:00:00.000Z'),
|
||||||
|
});
|
||||||
|
const migrated = store.read();
|
||||||
|
|
||||||
|
assert.equal(migrated.schemaVersion, 10);
|
||||||
|
assert.equal(migrated.revision, 23);
|
||||||
|
assert.deepEqual(migrated.diagnostics.customDnsResolvers, []);
|
||||||
|
assert.deepEqual(migrated.diagnostics.customDnsDomains, []);
|
||||||
|
assert.equal(store.migration.fromVersion, 9);
|
||||||
|
assert.equal(fs.readFileSync(store.migration.backupPath, 'utf8'), bytes);
|
||||||
|
});
|
||||||
|
|
||||||
test('schema v6 rejects missing or unknown outbound without rewriting source bytes', (t) => {
|
test('schema v6 rejects missing or unknown outbound without rewriting source bytes', (t) => {
|
||||||
for (const [name, rule] of [
|
for (const [name, rule] of [
|
||||||
['missing', { type: 'domain', value: 'example.com', enabled: true }],
|
['missing', { type: 'domain', value: 'example.com', enabled: true }],
|
||||||
|
|||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import os from 'node:os';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { DatabaseSync } from 'node:sqlite';
|
||||||
|
import { performance } from 'node:perf_hooks';
|
||||||
|
import test from 'node:test';
|
||||||
|
import { openTrafficHistoryStore } from '../../dist/server/services/trafficHistoryStore.js';
|
||||||
|
import { createTrafficHistoryService } from '../../dist/server/services/trafficHistoryService.js';
|
||||||
|
import { parseTrafficHistoryQuery } from '../../dist/shared/trafficHistory.js';
|
||||||
|
|
||||||
|
test('large history preserves totals and serves concurrent readers through cleanup and filters', async (t) => {
|
||||||
|
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'harbor-history-load-'));
|
||||||
|
const file = path.join(directory, 'traffic.sqlite');
|
||||||
|
const at = Math.floor(Date.now() / 60_000) * 60_000;
|
||||||
|
const minutes = process.env.HARBOR_HISTORY_LOAD === '1' ? 3000 : 300;
|
||||||
|
let service;
|
||||||
|
t.after(async () => {
|
||||||
|
await service?.close();
|
||||||
|
fs.rmSync(directory, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
openTrafficHistoryStore(file).close();
|
||||||
|
const db = new DatabaseSync(file);
|
||||||
|
try {
|
||||||
|
db.exec(`BEGIN;
|
||||||
|
WITH RECURSIVE n(i) AS (VALUES(1) UNION ALL SELECT i+1 FROM n WHERE i<2000)
|
||||||
|
INSERT INTO dimensions SELECT i,'dimension-'||i,'device-'||(i%20),'Device '||(i%20),
|
||||||
|
'192.0.2.1','tproxy-in','service-'||(i%250),'domain-'||(i%250)||'.test',
|
||||||
|
'host-'||i||'.test','203.0.113.'||(i%250),'vpn','vpn-one' FROM n;`);
|
||||||
|
db.prepare(`WITH RECURSIVE minutes(i) AS (VALUES(1) UNION ALL SELECT i+1 FROM minutes WHERE i<?)
|
||||||
|
INSERT INTO buckets SELECT ?-i*60000,60000,d.id,100,200 FROM minutes
|
||||||
|
CROSS JOIN dimensions d WHERE d.id<=1000`).run(minutes, at);
|
||||||
|
db.prepare('INSERT INTO buckets SELECT ?,60000,id,100,200 FROM dimensions WHERE id>1000').run(at - 91 * 86_400_000);
|
||||||
|
db.exec('COMMIT');
|
||||||
|
// Exercise the additive index upgrade on a populated pre-fix database.
|
||||||
|
db.exec('DROP INDEX buckets_dimension_time');
|
||||||
|
} finally { db.close(); }
|
||||||
|
|
||||||
|
service = createTrafficHistoryService({ filePath: file, source: () => 'live' });
|
||||||
|
const query = parseTrafficHistoryQuery(new URLSearchParams(`range=7d&until=${at}`));
|
||||||
|
const start = performance.now();
|
||||||
|
const concurrent = await Promise.all(Array.from({ length: 4 }, () => service.query(query)));
|
||||||
|
const concurrentMs = performance.now() - start;
|
||||||
|
for (const result of concurrent) {
|
||||||
|
assert.equal(result.storage.status, 'ready');
|
||||||
|
assert.equal(result.totals.downloadBytes, String(minutes * 1000 * 200));
|
||||||
|
assert.equal(result.origins.length, 20);
|
||||||
|
assert.equal(result.nextOffset, 100);
|
||||||
|
}
|
||||||
|
const timings = [];
|
||||||
|
for (const [filter, dimensions] of [
|
||||||
|
[{ originId: 'device-1' }, 50],
|
||||||
|
[{ search: 'host-99' }, 11],
|
||||||
|
[{ level: 'ip', service: 'service-1', domain: 'domain-1.test', hostname: 'host-1.test' }, 1],
|
||||||
|
[{ offset: 300 }, 1000],
|
||||||
|
]) {
|
||||||
|
const begin = performance.now();
|
||||||
|
const result = await service.query({ ...query, ...filter });
|
||||||
|
timings.push({ filter, ms: performance.now() - begin });
|
||||||
|
assert.equal(result.storage.status, 'ready');
|
||||||
|
assert.equal(result.totals.downloadBytes, String(minutes * dimensions * 200));
|
||||||
|
if (filter.offset) assert.equal(result.rows.length, 0);
|
||||||
|
}
|
||||||
|
t.diagnostic(JSON.stringify({ buckets: minutes * 1000, concurrentMs, timings }));
|
||||||
|
});
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
import { createTrafficHistoryRoute } from '../../dist/server/http/routes/trafficHistoryRoute.js';
|
||||||
|
import { emptyTrafficHistory, parseTrafficHistoryQuery } from '../../dist/shared/trafficHistory.js';
|
||||||
|
|
||||||
|
function response() {
|
||||||
|
return { writeHead(status) { this.status = status; }, end(body) { this.payload = JSON.parse(body); } };
|
||||||
|
}
|
||||||
|
|
||||||
|
test('history API reads only its local source, enriches labels and returns explicit unavailable coverage', async () => {
|
||||||
|
const route = createTrafficHistoryRoute({
|
||||||
|
readHistory: async (query) => ({ ...emptyTrafficHistory(query, 'live'), origins: [{ id: 'dev-a', label: 'IP' }] }),
|
||||||
|
deviceInventory: { snapshot: () => ({ devices: [{ id: 'dev-a', alias: 'Ноутбук' }] }) },
|
||||||
|
});
|
||||||
|
const res = response();
|
||||||
|
assert.equal(await route.handle({ method: 'GET', url: '/api/traffic/history?range=90d&search=yandex' }, res), true);
|
||||||
|
assert.equal(res.status, 200);
|
||||||
|
assert.equal(res.payload.query.search, 'yandex');
|
||||||
|
assert.equal(res.payload.period.retentionDays, 90);
|
||||||
|
assert.equal(res.payload.origins[0].label, 'Ноутбук');
|
||||||
|
const failed = createTrafficHistoryRoute({ readHistory: async () => { throw Error('disk'); } });
|
||||||
|
await failed.handle({ method: 'GET', url: '/api/traffic/history' }, res);
|
||||||
|
assert.equal(res.payload.storage.status, 'error');
|
||||||
|
assert.equal(res.payload.coverage.partial, true);
|
||||||
|
await createTrafficHistoryRoute({ readHistory: null }).handle({ method: 'GET', url: '/api/traffic/history' }, res);
|
||||||
|
assert.equal(res.payload.source, 'disabled');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('history API rejects invalid queries and mutation methods before reading storage', async () => {
|
||||||
|
let reads = 0;
|
||||||
|
const route = createTrafficHistoryRoute({ readHistory: async (query) => { reads++; return emptyTrafficHistory(query); } });
|
||||||
|
for (const params of ['range=forever', 'level=ip%3BDROP', 'route=no', 'offset=-1', 'until=Infinity', 'search=%00', `search=${'x'.repeat(201)}`]) {
|
||||||
|
await assert.rejects(route.handle({ method: 'GET', url: `/api/traffic/history?${params}` }, response()),
|
||||||
|
(error) => error.code === 'REQUEST_INVALID');
|
||||||
|
}
|
||||||
|
await assert.rejects(route.handle({ method: 'DELETE', url: '/api/traffic/history' }, response()),
|
||||||
|
(error) => error.code === 'ENDPOINT_NOT_FOUND');
|
||||||
|
assert.equal(await route.handle({ method: 'GET', url: '/api/state' }, response()), false);
|
||||||
|
assert.equal(reads, 0);
|
||||||
|
assert.equal(parseTrafficHistoryQuery(new URLSearchParams('range=90d')).range, '90d');
|
||||||
|
});
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import os from 'node:os';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { Worker } from 'node:worker_threads';
|
||||||
|
import test from 'node:test';
|
||||||
|
import { createTrafficHistoryService } from '../../dist/server/services/trafficHistoryService.js';
|
||||||
|
import { openTrafficHistoryStore } from '../../dist/server/services/trafficHistoryStore.js';
|
||||||
|
import { emptyTrafficHistory, parseTrafficHistoryQuery } from '../../dist/shared/trafficHistory.js';
|
||||||
|
|
||||||
|
const query = parseTrafficHistoryQuery(new URLSearchParams());
|
||||||
|
async function fixture(t) {
|
||||||
|
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'harbor-history-queue-'));
|
||||||
|
const service = createTrafficHistoryService({ filePath: path.join(directory, 'traffic.sqlite'), source: () => 'live' });
|
||||||
|
await service.flush();
|
||||||
|
const post = Worker.prototype.postMessage;
|
||||||
|
const sent = [];
|
||||||
|
const waiting = [];
|
||||||
|
const held = [];
|
||||||
|
t.mock.method(Worker.prototype, 'postMessage', function (message) {
|
||||||
|
sent.push(message.kind);
|
||||||
|
if (message.kind !== 'query') return post.call(this, message);
|
||||||
|
const job = { worker: this, message };
|
||||||
|
if (waiting.length) waiting.shift()(job);
|
||||||
|
else held.push(job);
|
||||||
|
});
|
||||||
|
t.mock.timers.enable({ apis: ['setTimeout'] });
|
||||||
|
t.after(async () => {
|
||||||
|
t.mock.restoreAll();
|
||||||
|
t.mock.timers.reset();
|
||||||
|
await service.close();
|
||||||
|
fs.rmSync(directory, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
return { service, sent, file: path.join(directory, 'traffic.sqlite'),
|
||||||
|
next: () => held.length ? Promise.resolve(held.shift()) : new Promise((resolve) => waiting.push(resolve)),
|
||||||
|
answer: ({ worker, message }) => worker.emit('message', { id: message.id, result: emptyTrafficHistory(message.query, 'live') }),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
test('queued reads get their own execution budget and do not reset the collector', async (t) => {
|
||||||
|
const f = await fixture(t);
|
||||||
|
const results = Array.from({ length: 3 }, () => f.service.query(query));
|
||||||
|
const first = await f.next();
|
||||||
|
const worker = first.worker;
|
||||||
|
assert.equal(f.sent.filter((kind) => kind === 'query').length, 1);
|
||||||
|
t.mock.timers.tick(4_000);
|
||||||
|
f.answer(first);
|
||||||
|
for (let index = 1; index < 3; index++) {
|
||||||
|
const next = await f.next();
|
||||||
|
assert.equal(next.worker, worker);
|
||||||
|
t.mock.timers.tick(4_000);
|
||||||
|
f.answer(next);
|
||||||
|
}
|
||||||
|
assert.deepEqual((await Promise.all(results)).map((result) => result.storage.status), ['ready', 'ready', 'ready']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a slow read expires alone, retains its active slot, and lets pending writes go next', async (t) => {
|
||||||
|
const f = await fixture(t);
|
||||||
|
const firstResult = f.service.query(query);
|
||||||
|
const otherResult = f.service.query(query);
|
||||||
|
const first = await f.next();
|
||||||
|
t.mock.timers.tick(5_000);
|
||||||
|
assert.equal((await firstResult).storage.status, 'error');
|
||||||
|
const write = f.service.flush();
|
||||||
|
assert.equal(f.sent.filter((kind) => kind === 'query').length, 1);
|
||||||
|
const before = f.sent.length;
|
||||||
|
f.answer(first);
|
||||||
|
await write;
|
||||||
|
const second = await f.next();
|
||||||
|
assert.deepEqual(f.sent.slice(before), ['ingest', 'query']);
|
||||||
|
assert.equal(first.worker, second.worker);
|
||||||
|
f.answer(second);
|
||||||
|
const result = await otherResult;
|
||||||
|
assert.equal(result.storage.status, 'ready');
|
||||||
|
assert.equal(result.coverage.partial, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a full read queue reserves write capacity and shutdown rejects queued and future reads', async (t) => {
|
||||||
|
const f = await fixture(t);
|
||||||
|
const results = Array.from({ length: 32 }, () => f.service.query(query));
|
||||||
|
const first = await f.next();
|
||||||
|
assert.equal((await f.service.query(query)).storage.status, 'error');
|
||||||
|
const write = f.service.flush();
|
||||||
|
const closing = f.service.close();
|
||||||
|
f.answer(first);
|
||||||
|
await write;
|
||||||
|
await closing;
|
||||||
|
const snapshots = await Promise.all(results);
|
||||||
|
assert.equal(snapshots[0].storage.status, 'ready');
|
||||||
|
assert.ok(snapshots.slice(1).every((result) => result.storage.status === 'error'));
|
||||||
|
const sent = f.sent.length;
|
||||||
|
assert.equal((await f.service.query(query)).storage.status, 'error');
|
||||||
|
await f.service.flush();
|
||||||
|
assert.equal(f.sent.length, sent);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('hard recovery waits for the previous worker to exit before starting a replacement', async (t) => {
|
||||||
|
const f = await fixture(t);
|
||||||
|
const firstResult = f.service.query(query);
|
||||||
|
const otherResult = f.service.query(query);
|
||||||
|
const first = await f.next();
|
||||||
|
const terminate = Worker.prototype.terminate;
|
||||||
|
let release;
|
||||||
|
let stopping = 0;
|
||||||
|
const gate = new Promise((resolve) => { release = resolve; });
|
||||||
|
t.mock.method(Worker.prototype, 'terminate', function () {
|
||||||
|
stopping++;
|
||||||
|
return gate.then(() => terminate.call(this));
|
||||||
|
});
|
||||||
|
t.mock.timers.tick(60_000);
|
||||||
|
assert.equal((await firstResult).storage.status, 'error');
|
||||||
|
assert.equal(stopping, 1);
|
||||||
|
assert.equal(f.sent.filter((kind) => kind === 'query').length, 1);
|
||||||
|
release();
|
||||||
|
const second = await f.next();
|
||||||
|
assert.notEqual(second.worker, first.worker);
|
||||||
|
f.answer(second);
|
||||||
|
assert.equal((await otherResult).storage.status, 'ready');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('shutdown persists real counters queued behind an occupied read and survives reopen', async (t) => {
|
||||||
|
const f = await fixture(t);
|
||||||
|
const reading = f.service.query(query);
|
||||||
|
const first = await f.next();
|
||||||
|
const at = new Date(Date.now() - 120_000).toISOString();
|
||||||
|
f.service.enqueue({ epoch: 'shutdown', observedAt: at, reset: false, closedIds: [], connections: [{
|
||||||
|
id: 'persist-on-close', startedAt: at, closedAt: at,
|
||||||
|
traffic: { uploadBytes: '17', downloadBytes: '29' },
|
||||||
|
destination: { domain: 'example.org', ip: '203.0.113.1' },
|
||||||
|
source: { ip: '192.0.2.1' }, inbound: { tag: 'tproxy-in' },
|
||||||
|
origin: { kind: 'device', id: 'device-1', label: 'Laptop' },
|
||||||
|
route: { kind: 'vpn', outbound: 'vpn-one' },
|
||||||
|
}] });
|
||||||
|
const closing = f.service.close();
|
||||||
|
f.answer(first);
|
||||||
|
await reading;
|
||||||
|
await closing;
|
||||||
|
const store = openTrafficHistoryStore(f.file);
|
||||||
|
try {
|
||||||
|
assert.deepEqual(store.query(query).totals, { uploadBytes: '17', downloadBytes: '29' });
|
||||||
|
} finally { store.close(); }
|
||||||
|
});
|
||||||
@@ -0,0 +1,331 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import os from 'node:os';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { DatabaseSync } from 'node:sqlite';
|
||||||
|
import { performance } from 'node:perf_hooks';
|
||||||
|
import test from 'node:test';
|
||||||
|
import { openTrafficHistoryStore } from '../../dist/server/services/trafficHistoryStore.js';
|
||||||
|
import { createTrafficHistoryService } from '../../dist/server/services/trafficHistoryService.js';
|
||||||
|
import { openHarborStorage } from '../../dist/server/services/harborStorage.js';
|
||||||
|
import { createDomainTrafficService } from '../../dist/server/services/domainTrafficService.js';
|
||||||
|
import { assertTrafficHistorySnapshot, parseTrafficHistoryQuery } from '../../dist/shared/trafficHistory.js';
|
||||||
|
|
||||||
|
const DAY = 86_400_000;
|
||||||
|
const base = Date.parse('2026-06-01T10:00:00.000Z');
|
||||||
|
const query = (fields = {}) => ({ ...parseTrafficHistoryQuery(new URLSearchParams('range=90d')), ...fields });
|
||||||
|
function connection(id, up, down, fields = {}) {
|
||||||
|
return {
|
||||||
|
id, startedAt: new Date(base + 1_000).toISOString(), closedAt: null,
|
||||||
|
inbound: { tag: 'tproxy-in', type: 'tproxy' }, network: 'tcp', protocol: 'tls',
|
||||||
|
source: { ip: '192.0.2.10', port: 50_000 },
|
||||||
|
destination: { domain: 'www.yandex.ru', ip: '203.0.113.10', port: 443, provenance: 'sing-box' },
|
||||||
|
origin: { kind: 'device', id: 'dev_0123456789abcdef', label: 'Laptop', provenance: 'source-ip' },
|
||||||
|
route: { kind: 'vpn', scope: 'local-sing-box', outbound: 'vpn-one', outboundType: 'vless', chain: [], rule: null },
|
||||||
|
traffic: { uploadBytes: String(up), downloadBytes: String(down), uploadBytesPerSecond: '0', downloadBytesPerSecond: '0' },
|
||||||
|
...fields,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const batch = (at, connections = [], reset = false, epoch = 'sing-box-1') => ({ epoch,
|
||||||
|
observedAt: new Date(at).toISOString(), connections, reset, closedIds: connections.filter((c) => c.closedAt).map((c) => c.id) });
|
||||||
|
function fixture(t) {
|
||||||
|
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'harbor-traffic-sql-'));
|
||||||
|
const closers = new Set();
|
||||||
|
t.after(async () => {
|
||||||
|
for (const close of closers) await close();
|
||||||
|
fs.rmSync(directory, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
return { directory, file: path.join(directory, 'traffic.sqlite'), register(store) {
|
||||||
|
const original = store.close;
|
||||||
|
store.close = () => { closers.delete(store.close); return original(); };
|
||||||
|
closers.add(store.close);
|
||||||
|
return store;
|
||||||
|
} };
|
||||||
|
}
|
||||||
|
|
||||||
|
test('SQL checkpoints survive reopen, reset/replayed CLOSED and same-UUID new lifecycles without double count', (t) => {
|
||||||
|
const f = fixture(t);
|
||||||
|
let clock = base;
|
||||||
|
let store = f.register(openTrafficHistoryStore(f.file, () => clock));
|
||||||
|
store.ingest([batch(clock, [], true)], 'live');
|
||||||
|
clock += 1_000;
|
||||||
|
store.ingest([batch(clock, [connection('a', 10, 20)])], 'live');
|
||||||
|
clock += 1_000;
|
||||||
|
store.ingest([batch(clock, [connection('a', 15, 27)])], 'live');
|
||||||
|
store.close();
|
||||||
|
store = f.register(openTrafficHistoryStore(f.file, () => clock));
|
||||||
|
store.ingest([batch(clock, [connection('a', 15, 27)], true)], 'live');
|
||||||
|
clock += 1_000;
|
||||||
|
const closed = connection('a', 18, 30, { closedAt: new Date(clock).toISOString() });
|
||||||
|
store.ingest([batch(clock, [closed])], 'live');
|
||||||
|
store.close();
|
||||||
|
store = f.register(openTrafficHistoryStore(f.file, () => clock));
|
||||||
|
clock += 1_000;
|
||||||
|
store.ingest([batch(clock, [closed], true)], 'live');
|
||||||
|
clock += 60_000;
|
||||||
|
assert.deepEqual(store.query(query()).totals, { uploadBytes: '18', downloadBytes: '30' });
|
||||||
|
store.ingest([batch(clock, [connection('a', 7, 9, { startedAt: new Date(clock).toISOString() })])], 'live');
|
||||||
|
clock += 60_000;
|
||||||
|
assert.deepEqual(store.query(query()).totals, { uploadBytes: '25', downloadBytes: '39' });
|
||||||
|
const result = assertTrafficHistorySnapshot(store.query(query()));
|
||||||
|
assert.equal(result.rows[0].label, 'Яндекс');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('full hostname, PSL registered domain, separate IP/device/routes and IP-only destinations remain queryable', (t) => {
|
||||||
|
const f = fixture(t); let clock = base;
|
||||||
|
const store = f.register(openTrafficHistoryStore(f.file, () => clock));
|
||||||
|
store.ingest([batch(clock, [], true)], 'live'); clock += 1_000;
|
||||||
|
store.ingest([batch(clock, [
|
||||||
|
connection('a', 10, 20),
|
||||||
|
connection('b', 3, 4, { destination: { domain: 'mail.yandex.com', ip: '203.0.113.20' } }),
|
||||||
|
connection('c', 5, 6, { destination: { domain: 'api.example.co.uk', ip: '203.0.113.30' } }),
|
||||||
|
connection('d', 7, 8, { destination: { domain: null, ip: '2001:db8::1' }, origin: { kind: 'unknown', id: null, label: 'Unknown' } }),
|
||||||
|
])], 'live');
|
||||||
|
clock += 60_000;
|
||||||
|
const roots = store.query(query());
|
||||||
|
assert.deepEqual(new Set(roots.rows.map((row) => row.key)), new Set(['Яндекс', 'example.co.uk', '']));
|
||||||
|
const domains = store.query(query({ level: 'domain', service: 'Яндекс' }));
|
||||||
|
assert.deepEqual(new Set(domains.rows.map((row) => row.key)), new Set(['yandex.ru', 'yandex.com']));
|
||||||
|
assert.equal(store.query(query({ level: 'hostname', service: 'Яндекс', domain: 'yandex.ru' })).rows[0].key, 'www.yandex.ru');
|
||||||
|
const ip = store.query(query({ level: 'ip', service: 'Яндекс', domain: 'yandex.ru', hostname: 'www.yandex.ru' }));
|
||||||
|
assert.equal(ip.rows[0].key, '203.0.113.10');
|
||||||
|
assert.equal(store.query(query({ level: 'ip', service: '', domain: '', hostname: '' })).rows[0].key, '2001:db8::1');
|
||||||
|
assert.equal(store.query(query({ originId: 'unknown:192.0.2.10' })).totals.downloadBytes, '8');
|
||||||
|
assert.equal(store.query(query({ search: "x' OR 1=1 --" })).rows.length, 0);
|
||||||
|
assert.equal(store.query(query({ search: 'яндекс' })).totals.downloadBytes, '24');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('initial snapshot is a baseline and downtime is explicitly partial', (t) => {
|
||||||
|
const f = fixture(t); let clock = base;
|
||||||
|
const store = f.register(openTrafficHistoryStore(f.file, () => clock));
|
||||||
|
store.ingest([batch(clock, [connection('old', 1_000, 2_000, { startedAt: new Date(base - DAY).toISOString() })], true)], 'live');
|
||||||
|
clock += 1_000;
|
||||||
|
assert.equal(store.query(query()).totals.uploadBytes, '0');
|
||||||
|
store.ingest([batch(clock, [connection('old', 1_010, 2_020, { startedAt: new Date(base - DAY).toISOString() })])], 'live');
|
||||||
|
clock += 60_000;
|
||||||
|
store.ingest([batch(clock, [connection('old', 1_040, 2_050, { startedAt: new Date(base - DAY).toISOString() })], true)], 'live');
|
||||||
|
clock += 60_000;
|
||||||
|
assert.deepEqual(store.query(query()).totals, { uploadBytes: '40', downloadBytes: '50' });
|
||||||
|
assert.equal(store.query(query()).coverage.partial, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('counter regression retains high watermarks and reconnect counts new post-collection lifecycles', (t) => {
|
||||||
|
const f = fixture(t); let clock = base;
|
||||||
|
const store = f.register(openTrafficHistoryStore(f.file, () => clock));
|
||||||
|
store.ingest([batch(clock, [], true)], 'live');
|
||||||
|
for (const total of [100, 90, 100]) {
|
||||||
|
clock += 1_000;
|
||||||
|
store.ingest([batch(clock, [connection('a', total, total)], true)], 'live');
|
||||||
|
}
|
||||||
|
clock += 60_000;
|
||||||
|
const result = store.query(query());
|
||||||
|
assert.deepEqual(result.totals, { uploadBytes: '100', downloadBytes: '100' });
|
||||||
|
assert.equal(result.coverage.partial, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('fast collector reopen marks missing coverage even if the next source state is live', (t) => {
|
||||||
|
const f = fixture(t); let clock = base;
|
||||||
|
let store = f.register(openTrafficHistoryStore(f.file, () => clock));
|
||||||
|
store.ingest([batch(clock, [], true)], 'live');
|
||||||
|
store.close(); clock += 1_000;
|
||||||
|
store = f.register(openTrafficHistoryStore(f.file, () => clock));
|
||||||
|
store.ingest([batch(clock, [connection('a', 10, 20)], true)], 'live');
|
||||||
|
clock += 60_000;
|
||||||
|
assert.equal(store.query(query()).coverage.gapCount, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('frozen history cutoff excludes its incomplete terminal bucket before and after hourly rollup', (t) => {
|
||||||
|
const f = fixture(t); let clock = base;
|
||||||
|
const store = f.register(openTrafficHistoryStore(f.file, () => clock));
|
||||||
|
store.ingest([batch(clock, [], true)], 'live'); clock += 1_000;
|
||||||
|
store.ingest([batch(clock, [connection('a', 100, 200)])], 'live');
|
||||||
|
clock += 60_000;
|
||||||
|
store.ingest([batch(clock, [connection('a', 110, 220)])], 'live');
|
||||||
|
const frozen = store.query(query());
|
||||||
|
assert.equal(frozen.query.until, base + 60_000);
|
||||||
|
assert.equal(frozen.totals.uploadBytes, '100');
|
||||||
|
clock += 1_000;
|
||||||
|
store.ingest([batch(clock, [connection('a', 120, 240)])], 'live');
|
||||||
|
assert.deepEqual(store.query(frozen.query).totals, frozen.totals);
|
||||||
|
clock = base + 8 * DAY;
|
||||||
|
const hourly = store.query(query({ until: base + 3_600_000 + 30_000 }));
|
||||||
|
assert.equal(hourly.query.until, base + 3_600_000);
|
||||||
|
assert.equal(hourly.totals.uploadBytes, '120');
|
||||||
|
const expired = store.query(query({ until: base - 100 * DAY }));
|
||||||
|
assert.equal(expired.rows.length, 0);
|
||||||
|
assert.equal(expired.period.from, expired.period.to);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('minute to hour rollup preserves exact bytes and identities; 90-day cleanup leaves settings and metrics untouched', (t) => {
|
||||||
|
const f = fixture(t); let clock = base;
|
||||||
|
const store = f.register(openTrafficHistoryStore(f.file, () => clock));
|
||||||
|
const settings = f.register(openHarborStorage(f.directory));
|
||||||
|
settings.state.update((state) => ({ ...state, revision: 42 }));
|
||||||
|
const metrics = createDomainTrafficService({ observe: () => ({ connections: [] }), devices: () => [] });
|
||||||
|
store.ingest([batch(clock, [], true)], 'live'); clock += 1_000;
|
||||||
|
const data = batch(clock, [connection('a', '9007199254740993', '500')]);
|
||||||
|
store.ingest([data], 'live'); metrics.ingestNative(data);
|
||||||
|
const metricsBefore = JSON.stringify(metrics.snapshot());
|
||||||
|
clock += 60_000;
|
||||||
|
store.ingest([batch(clock, [connection('a', '9007199254741000', '550')])], 'live');
|
||||||
|
clock = base + 8 * DAY;
|
||||||
|
store.maintain(); store.maintain();
|
||||||
|
assert.deepEqual(store.query(query()).totals, { uploadBytes: '9007199254741000', downloadBytes: '550' });
|
||||||
|
const inspect = new DatabaseSync(f.file);
|
||||||
|
assert.deepEqual(inspect.prepare('SELECT DISTINCT resolution FROM buckets').all().map((r) => r.resolution), [3_600_000]);
|
||||||
|
inspect.close();
|
||||||
|
clock = base + 91 * DAY;
|
||||||
|
store.maintain();
|
||||||
|
assert.equal(store.query(query()).rows.length, 0);
|
||||||
|
assert.equal(settings.state.read().revision, 42);
|
||||||
|
assert.equal(JSON.stringify(metrics.snapshot()), metricsBefore);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a failed bucket transaction does not advance checkpoints, and retry counts exactly once', (t) => {
|
||||||
|
const f = fixture(t); let clock = base;
|
||||||
|
const store = f.register(openTrafficHistoryStore(f.file, () => clock));
|
||||||
|
store.ingest([batch(clock, [], true)], 'live'); clock += 1_000;
|
||||||
|
const blocker = new DatabaseSync(f.file);
|
||||||
|
blocker.exec("CREATE TRIGGER reject_bucket BEFORE INSERT ON buckets BEGIN SELECT RAISE(ABORT, 'simulated disk failure'); END");
|
||||||
|
const data = batch(clock, [connection('a', 10, 20)]);
|
||||||
|
assert.throws(() => store.ingest([data], 'live'), /simulated/);
|
||||||
|
assert.equal(blocker.prepare('SELECT COUNT(*) AS n FROM checkpoints').get().n, 0);
|
||||||
|
blocker.exec('DROP TRIGGER reject_bucket'); blocker.close();
|
||||||
|
store.ingest([data], 'live'); store.ingest([data], 'live'); clock += 60_000;
|
||||||
|
assert.deepEqual(store.query(query()).totals, { uploadBytes: '10', downloadBytes: '20' });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('idle active checkpoints survive retention; a new runtime epoch removes old checkpoints but not buckets', (t) => {
|
||||||
|
const f = fixture(t); let clock = base;
|
||||||
|
const store = f.register(openTrafficHistoryStore(f.file, () => clock));
|
||||||
|
store.ingest([batch(clock, [], true)], 'live'); clock += 1_000;
|
||||||
|
store.ingest([batch(clock, [connection('idle', 100, 200)])], 'live');
|
||||||
|
clock += 91 * DAY;
|
||||||
|
store.maintain();
|
||||||
|
store.ingest([batch(clock, [connection('idle', 110, 220)])], 'live');
|
||||||
|
clock += 60_000;
|
||||||
|
assert.equal(store.query(query()).totals.uploadBytes, '10');
|
||||||
|
store.ingest([batch(clock, [], true, 'sing-box-2')], 'live');
|
||||||
|
const db = new DatabaseSync(f.file);
|
||||||
|
assert.equal(db.prepare('SELECT COUNT(*) AS n FROM checkpoints').get().n, 0);
|
||||||
|
db.close();
|
||||||
|
assert.equal(store.query(query()).totals.uploadBytes, '10');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('reset disappearance and terminal identities retire checkpoints without forgetting still-active baselines', (t) => {
|
||||||
|
const f = fixture(t); let clock = base;
|
||||||
|
const store = f.register(openTrafficHistoryStore(f.file, () => clock));
|
||||||
|
store.ingest([batch(clock, [], true)], 'live'); clock += 1_000;
|
||||||
|
store.ingest([batch(clock, [connection('gone', 10, 20), connection('idle', 100, 200), connection('terminal', 7, 8)])], 'live');
|
||||||
|
clock += 1_000;
|
||||||
|
store.ingest([{ ...batch(clock), closedIds: ['terminal'] }], 'live');
|
||||||
|
clock += 1_000;
|
||||||
|
store.ingest([batch(clock, [connection('idle', 100, 200)], true)], 'live');
|
||||||
|
clock += 91 * DAY;
|
||||||
|
store.maintain();
|
||||||
|
const db = new DatabaseSync(f.file);
|
||||||
|
assert.deepEqual(db.prepare("SELECT json_extract(identity, '$[1]') AS id, closed FROM checkpoints").all()
|
||||||
|
.map((row) => ({ ...row })), [{ id: 'idle', closed: 0 }]);
|
||||||
|
db.close();
|
||||||
|
store.ingest([batch(clock, [connection('idle', 110, 220)])], 'live');
|
||||||
|
clock += 60_000;
|
||||||
|
assert.equal(store.query(query()).totals.uploadBytes, '10');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('worker ingestion is independent of Prometheus and errors remain isolated; many closed events are not UI-capped', async (t) => {
|
||||||
|
const f = fixture(t);
|
||||||
|
const service = f.register(createTrafficHistoryService({ filePath: f.file, source: () => 'live' }));
|
||||||
|
const at = Date.now() - 120_000;
|
||||||
|
service.enqueue(batch(at, [], true));
|
||||||
|
service.enqueue(batch(at + 1, Array.from({ length: 2_049 }, (_, index) => connection(`closed-${index}`, 1, 2, {
|
||||||
|
startedAt: new Date(at + 1).toISOString(), closedAt: new Date(at + 1).toISOString(),
|
||||||
|
}))));
|
||||||
|
let ticked = false;
|
||||||
|
setImmediate(() => { ticked = true; });
|
||||||
|
const result = await service.query(query());
|
||||||
|
assert.equal(ticked, true);
|
||||||
|
assert.equal(result.storage.status, 'ready');
|
||||||
|
assert.deepEqual(result.totals, { uploadBytes: '2049', downloadBytes: '4098' });
|
||||||
|
const invalid = f.register(createTrafficHistoryService({ filePath: f.directory, source: () => 'live' }));
|
||||||
|
assert.doesNotThrow(() => invalid.enqueue(batch(at, [])));
|
||||||
|
assert.equal((await invalid.query(query())).storage.status, 'error');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('bounded 90-day history performance sample reports write/query size without claiming a hardware guarantee', (t) => {
|
||||||
|
const f = fixture(t); let clock = base;
|
||||||
|
const store = f.register(openTrafficHistoryStore(f.file, () => clock));
|
||||||
|
store.ingest([batch(clock, [], true)], 'live');
|
||||||
|
const start = performance.now();
|
||||||
|
for (let day = 0; day < 90; day++) {
|
||||||
|
clock = base + day * DAY + 1_000;
|
||||||
|
store.ingest([batch(clock, Array.from({ length: 100 }, (_, index) => connection(`${day}-${index}`, 100, 1_000, {
|
||||||
|
startedAt: new Date(clock).toISOString(), destination: { domain: `host${index}.example.org`, ip: `203.0.113.${index + 1}` },
|
||||||
|
})))], 'live');
|
||||||
|
}
|
||||||
|
const writeMs = performance.now() - start;
|
||||||
|
clock += 60_000;
|
||||||
|
const queryStart = performance.now();
|
||||||
|
const result = store.query(query());
|
||||||
|
const queryMs = performance.now() - queryStart;
|
||||||
|
assert.equal(result.totals.downloadBytes, '9000000');
|
||||||
|
const sizes = Object.fromEntries(['', '-wal'].map((suffix) => [suffix || 'db', fs.statSync(f.file + suffix).size]));
|
||||||
|
t.diagnostic(JSON.stringify({ samples: 9_000, simulatedDays: 90, writeMs, queryMs, sizes }));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('high-churn sample measures closed-lifecycle storage and epoch reclamation', (t) => {
|
||||||
|
const f = fixture(t); let clock = base;
|
||||||
|
const store = f.register(openTrafficHistoryStore(f.file, () => clock));
|
||||||
|
store.ingest([batch(clock, [], true)], 'live');
|
||||||
|
const start = performance.now();
|
||||||
|
for (let page = 0; page < 100; page++) {
|
||||||
|
clock += 1_000;
|
||||||
|
store.ingest([batch(clock, Array.from({ length: 1_000 }, (_, index) => connection(`${page}-${index}`, 100, 1_000, {
|
||||||
|
startedAt: new Date(clock).toISOString(), closedAt: new Date(clock).toISOString(),
|
||||||
|
})))], 'live');
|
||||||
|
}
|
||||||
|
const writeMs = performance.now() - start;
|
||||||
|
clock += 60_000;
|
||||||
|
const startQuery = performance.now();
|
||||||
|
assert.equal(store.query(query()).totals.downloadBytes, '100000000');
|
||||||
|
const queryMs = performance.now() - startQuery;
|
||||||
|
const db = new DatabaseSync(f.file);
|
||||||
|
assert.equal(db.prepare('SELECT COUNT(*) AS n FROM checkpoints').get().n, 100_000);
|
||||||
|
const bytes = db.prepare('PRAGMA page_count').get().page_count * db.prepare('PRAGMA page_size').get().page_size;
|
||||||
|
store.ingest([batch(clock, [], true, 'sing-box-next')], 'live');
|
||||||
|
assert.equal(db.prepare('SELECT COUNT(*) AS n FROM checkpoints').get().n, 0);
|
||||||
|
const reusableBytes = db.prepare('PRAGMA freelist_count').get().freelist_count * db.prepare('PRAGMA page_size').get().page_size;
|
||||||
|
db.close();
|
||||||
|
t.diagnostic(JSON.stringify({ closedLifecycles: 100_000, writeMs, queryMs, bytes, reusableBytes }));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('history searches each destination once, and cached periods stay exact after late data and rollback', (t) => {
|
||||||
|
const f = fixture(t); let clock = base;
|
||||||
|
let searched = 0;
|
||||||
|
const registerFunction = DatabaseSync.prototype.function;
|
||||||
|
t.mock.method(DatabaseSync.prototype, 'function', function (name, options, callback) {
|
||||||
|
return registerFunction.call(this, name, options, name === 'lower_unicode'
|
||||||
|
? (value) => { searched++; return callback(value); } : callback);
|
||||||
|
});
|
||||||
|
const store = f.register(openTrafficHistoryStore(f.file, () => clock));
|
||||||
|
store.ingest([batch(clock, [], true)], 'live');
|
||||||
|
for (let minute = 0; minute < 60; minute++) {
|
||||||
|
clock = base + minute * 60_000 + 1_000;
|
||||||
|
store.ingest([batch(clock, [connection('a', (minute + 1) * 10, (minute + 1) * 20)])], 'live');
|
||||||
|
}
|
||||||
|
clock = base + 60 * 60_000;
|
||||||
|
const searchedQuery = query({ search: 'яндекс' });
|
||||||
|
assert.deepEqual(store.query(searchedQuery).totals, { uploadBytes: '600', downloadBytes: '1200' });
|
||||||
|
assert.ok(searched <= 2, `one destination must not be searched per time bucket (${searched} calls)`);
|
||||||
|
store.ingest([batch(clock + 1, [connection('a', 601, 1202)])], 'live');
|
||||||
|
assert.equal(store.query(searchedQuery).totals.uploadBytes, '600');
|
||||||
|
store.ingest([batch(base + 30_000, [connection('late', 7, 9)])], 'live');
|
||||||
|
assert.deepEqual(store.query(searchedQuery).totals, { uploadBytes: '607', downloadBytes: '1209' });
|
||||||
|
assert.throws(() => store.ingest([batch(base + 40_000, [
|
||||||
|
connection('rolled-back', 50, 80), connection('invalid', 'invalid', 1),
|
||||||
|
])], 'live'));
|
||||||
|
assert.equal(store.query(searchedQuery).totals.uploadBytes, '607');
|
||||||
|
clock += 60_000;
|
||||||
|
assert.deepEqual(store.query(searchedQuery).totals, { uploadBytes: '608', downloadBytes: '1211' });
|
||||||
|
store.maintain();
|
||||||
|
assert.equal(store.query(searchedQuery).totals.uploadBytes, '608');
|
||||||
|
});
|
||||||
@@ -47,6 +47,9 @@ test('version paths map to the components actually shipped by this repository',
|
|||||||
'gatewayBackend',
|
'gatewayBackend',
|
||||||
]);
|
]);
|
||||||
assert.deepEqual(affectedComponents(['scripts/runtime-impact.mjs']), ['gatewayBackend']);
|
assert.deepEqual(affectedComponents(['scripts/runtime-impact.mjs']), ['gatewayBackend']);
|
||||||
|
for (const file of ['.node-version', 'scripts/check-sqlite-runtime.mjs']) {
|
||||||
|
assert.deepEqual(affectedComponents([file]), ['macClient', 'gatewayClient', 'gatewayBackend']);
|
||||||
|
}
|
||||||
assert.deepEqual(affectedComponents(['README.md', 'test/server/version.test.js']), []);
|
assert.deepEqual(affectedComponents(['README.md', 'test/server/version.test.js']), []);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -131,6 +131,7 @@ test('typed endpoint facade preserves exact request contracts and raw payload id
|
|||||||
expectedRevision: 10,
|
expectedRevision: 10,
|
||||||
}),
|
}),
|
||||||
}],
|
}],
|
||||||
|
[() => api.traffic.live(), '/api/traffic/live', {}],
|
||||||
[() => api.singbox.stop(), '/api/singbox/stop', { method: 'POST' }],
|
[() => api.singbox.stop(), '/api/singbox/stop', { method: 'POST' }],
|
||||||
[() => api.singbox.restart(), '/api/singbox/restart', { method: 'POST' }],
|
[() => api.singbox.restart(), '/api/singbox/restart', { method: 'POST' }],
|
||||||
[() => api.servers.ping('profile-1', ['one', 'two']), '/api/profiles/profile-1/servers/ping', {
|
[() => api.servers.ping('profile-1', ['one', 'two']), '/api/profiles/profile-1/servers/ping', {
|
||||||
@@ -142,8 +143,12 @@ test('typed endpoint facade preserves exact request contracts and raw payload id
|
|||||||
assert.equal(await invoke(), payload);
|
assert.equal(await invoke(), payload);
|
||||||
const [actualUrl, actualOptions] = calls.at(-1);
|
const [actualUrl, actualOptions] = calls.at(-1);
|
||||||
assert.equal(actualUrl, url);
|
assert.equal(actualUrl, url);
|
||||||
assert.deepEqual(actualOptions, {
|
assert.ok(actualOptions.signal instanceof AbortSignal);
|
||||||
...options,
|
assert.equal(actualOptions.signal.aborted, false);
|
||||||
|
const { signal: requestedSignal, ...requestOptions } = options;
|
||||||
|
const { signal: actualSignal, ...fetchOptions } = actualOptions;
|
||||||
|
assert.deepEqual(fetchOptions, {
|
||||||
|
...requestOptions,
|
||||||
headers: { 'content-type': 'application/json' },
|
headers: { 'content-type': 'application/json' },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -161,7 +166,9 @@ test('request preserves caller headers, AbortError identity and JSON fallbacks',
|
|||||||
received = [url, options];
|
received = [url, options];
|
||||||
return { ok: true, status: 200, json: async () => value };
|
return { ok: true, status: 200, json: async () => value };
|
||||||
}), value);
|
}), value);
|
||||||
assert.deepEqual(received, ['/api/test', {
|
assert.ok(received[1].signal instanceof AbortSignal);
|
||||||
|
const { signal, ...options } = received[1];
|
||||||
|
assert.deepEqual([received[0], options], ['/api/test', {
|
||||||
headers: { 'content-type': 'application/custom', 'x-harbor': 'yes' },
|
headers: { 'content-type': 'application/custom', 'x-harbor': 'yes' },
|
||||||
}]);
|
}]);
|
||||||
|
|
||||||
@@ -187,3 +194,40 @@ test('request preserves caller headers, AbortError identity and JSON fallbacks',
|
|||||||
(error) => error.code === 'CONTROL_UNREACHABLE' && error.status === 503,
|
(error) => error.code === 'CONTROL_UNREACHABLE' && error.status === 503,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('API deadlines cover headers and response bodies, while caller cancellation keeps its reason', async (t) => {
|
||||||
|
t.mock.timers.enable({ apis: ['setTimeout'] });
|
||||||
|
const stalled = (signal) => new Promise((resolve, reject) => {
|
||||||
|
signal.addEventListener('abort', () => reject(signal.reason), { once: true });
|
||||||
|
});
|
||||||
|
for (const phase of ['headers', 'body']) {
|
||||||
|
let signal;
|
||||||
|
const pending = request('/api/state', {}, async (url, options) => {
|
||||||
|
signal = options.signal;
|
||||||
|
return phase === 'headers' ? stalled(signal)
|
||||||
|
: { ok: true, status: 200, json: () => stalled(signal) };
|
||||||
|
});
|
||||||
|
const checked = assert.rejects(pending, (error) => error.code === 'CONTROL_UNREACHABLE' && error.retryable);
|
||||||
|
await Promise.resolve();
|
||||||
|
t.mock.timers.tick(14_999);
|
||||||
|
assert.equal(signal.aborted, false);
|
||||||
|
t.mock.timers.tick(1);
|
||||||
|
await checked;
|
||||||
|
}
|
||||||
|
const caller = new AbortController();
|
||||||
|
const reason = new DOMException('left the page', 'AbortError');
|
||||||
|
const pending = request('/api/test', { signal: caller.signal }, (url, options) => stalled(options.signal));
|
||||||
|
const checked = assert.rejects(pending, (error) => error === reason);
|
||||||
|
caller.abort(reason);
|
||||||
|
await checked;
|
||||||
|
let commandSignal;
|
||||||
|
const command = request('/api/diagnostics/dns', { method: 'POST' }, (url, options) => {
|
||||||
|
commandSignal = options.signal;
|
||||||
|
return stalled(commandSignal);
|
||||||
|
});
|
||||||
|
const commandChecked = assert.rejects(command, (error) => error.code === 'CONTROL_UNREACHABLE');
|
||||||
|
t.mock.timers.tick(59_999);
|
||||||
|
assert.equal(commandSignal.aborted, false);
|
||||||
|
t.mock.timers.tick(1);
|
||||||
|
await commandChecked;
|
||||||
|
});
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user